diff --git a/README.md b/README.md index a013a15a8..df9962e63 100644 --- a/README.md +++ b/README.md @@ -219,8 +219,34 @@ try (Query q = db.borrowQuery()) { ### Cancel or Time Out a Query -`submit()` returns a `Completion`. `await(timeout, unit)` returns `false` if the query is still in flight; `cancel()` -stops it. +Give a query a timeout with `timeout(...)`, or every query a default with `query_timeout_ms` in the configuration +string. The timeout bounds the whole query, measured from `submit()`. When it expires the query is stopped, `await()` +throws a `QueryException` whose `isTimeout()` is `true`, and the pooled connection stays open for the next query. + +```java +import io.questdb.client.QueryException; + +try (Query q = db.borrowQuery()) { + q.sql("SELECT * FROM big_table ORDER BY ts") + .handler(handler) + .timeout(5, TimeUnit.SECONDS); + try { + q.submit().await(); + } catch (QueryException e) { + if (!e.isTimeout()) { + throw e; + } + // the query ran longer than 5 seconds and was stopped + } +} +``` + +Servers that support per-query timeouts stop the query themselves; against older servers the client cancels it. If +the server does not end the query within `query_close_timeout_ms` of the timeout, `await()` throws anyway while the +connection finishes the aborted query in the background. + +`submit()` returns a `Completion`. `await(timeout, unit)` only bounds the wait: it returns `false` while the query keeps +running. `cancel()` stops the query. ```java import io.questdb.client.Completion; @@ -540,6 +566,7 @@ schema::key1=value1;key2=value2; | `query_pool_min` | `1` | Minimum query connections kept warm (`0` under `lazy_connect`) | | `query_pool_max` | `4` | Maximum query connections | | `acquire_timeout_ms` | `5000` | How long `borrowSender()`/`borrowQuery()` waits for a free slot | +| `query_close_timeout_ms` | `5000` | How long `Query.close()` waits for a running query; grace of `query_timeout_ms` | | `idle_timeout_ms` | `60000` | How long a pooled connection may stay idle before it is reaped | | `max_lifetime_ms` | `1800000` | Maximum lifetime of a pooled connection before it is recycled | @@ -554,6 +581,12 @@ Applied by the query pool to select and fail over between the nodes in the `addr | `zone` | | Prefer same-zone endpoints for `target=any`/`replica` (opaque, case-insensitive) | | `client_id` | | Opaque client identifier surfaced server-side for observability | +### Query keys + +| Key | Default | Description | +| ------------------ | ------- | ------------------------------------------------------------------------------------------ | +| `query_timeout_ms` | `0` | Default per-query timeout in milliseconds (`0` = none); override per query with `timeout()` | + The ingest side also accepts store-and-forward and reconnection tuning keys (`auto_flush_*`, `initial_connect_retry`, `reconnect_*`, `request_durable_ack`, `sf_*`, `max_frame_rejections`, `poison_min_escalation_window_millis`, …). `sf_max_total_bytes` caps the unacknowledged data all pooled senders buffer together — 128 MiB of memory by default, diff --git a/core/src/main/java/io/questdb/client/Completion.java b/core/src/main/java/io/questdb/client/Completion.java index 615799e07..2d4ff1cfe 100644 --- a/core/src/main/java/io/questdb/client/Completion.java +++ b/core/src/main/java/io/questdb/client/Completion.java @@ -63,6 +63,10 @@ public interface Completion { /** * Blocks up to the given timeout. Returns {@code true} if the query * completed, {@code false} on timeout. + *

+ * This bounds only how long the caller waits: on {@code false} the query + * keeps running. To stop queries that run too long, set a query timeout + * ({@link Query#timeout(long, TimeUnit)} or {@code query_timeout_ms}). * * @throws QueryException if the server reported an error or * {@link #cancel()} won the race diff --git a/core/src/main/java/io/questdb/client/Query.java b/core/src/main/java/io/questdb/client/Query.java index 539d57117..55454dd26 100644 --- a/core/src/main/java/io/questdb/client/Query.java +++ b/core/src/main/java/io/questdb/client/Query.java @@ -27,8 +27,10 @@ import io.questdb.client.cutlass.qwp.client.QwpBindSetter; import io.questdb.client.cutlass.qwp.client.QwpColumnBatchHandler; import io.questdb.client.cutlass.qwp.client.QwpServerInfo; +import io.questdb.client.cutlass.qwp.protocol.QwpConstants; import java.io.Closeable; +import java.util.concurrent.TimeUnit; /** * A query handle leased from the {@link QuestDB} pool via @@ -43,9 +45,9 @@ * creates one small lease handle per borrow (often scalar-replaced by the JIT * when used with try-with-resources). *

- * Lifecycle: configure with {@link #sql}, optional {@link #binds}, and - * {@link #handler}, then call {@link #submit()} to obtain a {@link Completion} - * and {@code await()} it before the next {@link #submit()}. + * Lifecycle: configure with {@link #sql}, optional {@link #binds} and + * {@link #timeout}, and {@link #handler}, then call {@link #submit()} to obtain + * a {@link Completion} and {@code await()} it before the next {@link #submit()}. *

* Thread safety: not thread-safe and single-flight -- one in-flight query per * handle. To run queries concurrently, borrow one handle per concurrent query. @@ -132,4 +134,40 @@ public interface Query extends Closeable { * before the first successful bind */ QwpServerInfo serverInfo(); + + /** + * Sets the query timeout for subsequent {@link #submit()} calls on this + * handle, overriding the {@code query_timeout_ms} default from the + * connection string; {@code 0} runs queries without a timeout. It stays in + * effect until changed, and every borrow starts from the configured + * default. + *

+ * The timeout is measured from {@code submit()} and bounds the whole query: + * server execution, any failover, and the time the handler spends in its + * callbacks (it is checked between result batches, so a handler blocked + * inside {@code onBatch} is not interrupted). When it expires, the query is + * stopped -- by the server when it supports per-query timeouts, otherwise by + * a client-side cancel -- no further result batch reaches the handler, the + * handler's {@code onError} receives + * {@link QwpConstants#STATUS_QUERY_TIMEOUT}, and {@link Completion#await()} + * throws a {@link QueryException} whose {@link QueryException#isTimeout()} + * is {@code true}. The pooled connection stays open and authenticated, and + * serves the next query. + *

+ * Should the server not end the query within {@code query_close_timeout_ms} + * (see {@link QuestDBBuilder#queryCloseTimeoutMillis(long)}) of the timeout, + * the caller is released with the timeout anyway while the connection + * finishes draining the aborted query; only a connection that stays silent + * for that long once more is closed and replaced. + *

+ * Unlike {@link Completion#await(long, TimeUnit)}, which only bounds how + * long the caller waits, the query timeout stops the query. + * + * @param timeout the timeout, {@code 0} for none; a positive value below + * one millisecond counts as one millisecond + * @param unit the unit of {@code timeout} + * @return this handle + * @throws IllegalArgumentException when {@code timeout} is negative + */ + Query timeout(long timeout, TimeUnit unit); } diff --git a/core/src/main/java/io/questdb/client/QueryException.java b/core/src/main/java/io/questdb/client/QueryException.java index a3fdedbd1..010cba919 100644 --- a/core/src/main/java/io/questdb/client/QueryException.java +++ b/core/src/main/java/io/questdb/client/QueryException.java @@ -24,6 +24,8 @@ package io.questdb.client; +import io.questdb.client.cutlass.qwp.protocol.QwpConstants; + /** * Thrown from {@link Completion#await()} / {@link Completion#await(long, java.util.concurrent.TimeUnit)} * when the server reported an error for the corresponding {@link Query}, @@ -32,7 +34,9 @@ *

* The original wire-level status byte is exposed via {@link #getStatus()} so * callers can distinguish cancellation from schema errors etc. without - * string-matching the message. + * string-matching the message. A query that ran past its timeout (see + * {@link Query#timeout(long, java.util.concurrent.TimeUnit)}) reports + * {@link QwpConstants#STATUS_QUERY_TIMEOUT}; {@link #isTimeout()} tests for it. */ public class QueryException extends RuntimeException { @@ -56,4 +60,13 @@ public QueryException(byte status, String message, Throwable cause) { public byte getStatus() { return status; } + + /** + * Returns {@code true} when the query ran past its timeout, whether the + * server or the client detected it. The connection that ran it stays + * usable. + */ + public boolean isTimeout() { + return status == QwpConstants.STATUS_QUERY_TIMEOUT; + } } diff --git a/core/src/main/java/io/questdb/client/QuestDBBuilder.java b/core/src/main/java/io/questdb/client/QuestDBBuilder.java index 11364f9ba..d3c70cb87 100644 --- a/core/src/main/java/io/questdb/client/QuestDBBuilder.java +++ b/core/src/main/java/io/questdb/client/QuestDBBuilder.java @@ -105,6 +105,12 @@ public QuestDBBuilder acquireTimeoutMillis(long millis) { * letting the pool grow a fresh one. Bounds the close of a handle whose * {@code submit()} is still running -- e.g. when the caller's own * {@code await(timeout)} expired and they gave up. Defaults to 5000ms. + *

+ * Also the grace period of the query timeout ({@code query_timeout_ms}, + * {@link Query#timeout(long, java.util.concurrent.TimeUnit)}): how long a + * timed-out query may take to end before the caller is released with the + * timeout anyway, and how long its connection may then take to drain the + * aborted query before it is closed instead of reused. */ public QuestDBBuilder queryCloseTimeoutMillis(long millis) { if (millis < 0) { diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QueryEvent.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QueryEvent.java index d3083e4c7..71441d6a7 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QueryEvent.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QueryEvent.java @@ -29,9 +29,21 @@ * One event per {@code RESULT_BATCH} / {@code RESULT_END} / {@code QUERY_ERROR} * received from the server, plus a synthetic error event if the connection drops * mid-query. + *

+ * The I/O thread stamps the events of a query with that query's id + * ({@link #requestId}), so {@code execute()} can tell its own events from the + * leftovers of an earlier query that its {@code execute()} stopped waiting for. + * Connection-level events -- the connection failed, the I/O thread stopped, the + * client closed -- carry {@link #ANY_REQUEST} and reach whichever query waits. */ public class QueryEvent { + /** + * {@link #requestId} of an event that belongs to no query in particular, such + * as a connection failure. Whichever query waits for events must see it. + */ + public static final long ANY_REQUEST = -1L; + public static final int KIND_BATCH = 0; public static final int KIND_END = 1; public static final int KIND_ERROR = 2; @@ -50,12 +62,14 @@ public class QueryEvent { public byte errorStatus; // valid for KIND_ERROR public int kind; public short opType; // valid for KIND_EXEC_DONE (matches CompiledQuery.SELECT/INSERT/etc.) + public long requestId = ANY_REQUEST; // query the event belongs to; see forRequest() public long rowsAffected; // valid for KIND_EXEC_DONE public long totalRows; // valid for KIND_END public QueryEvent asBatch(QwpBatchBuffer buffer) { this.kind = KIND_BATCH; this.buffer = buffer; + this.requestId = ANY_REQUEST; return this; } @@ -63,6 +77,7 @@ public QueryEvent asEnd(long totalRows) { this.kind = KIND_END; this.buffer = null; this.totalRows = totalRows; + this.requestId = ANY_REQUEST; return this; } @@ -71,6 +86,7 @@ public QueryEvent asError(byte status, String message) { this.buffer = null; this.errorStatus = status; this.errorMessage = message; + this.requestId = ANY_REQUEST; return this; } @@ -79,6 +95,7 @@ public QueryEvent asExecDone(short opType, long rowsAffected) { this.buffer = null; this.opType = opType; this.rowsAffected = rowsAffected; + this.requestId = ANY_REQUEST; return this; } @@ -87,9 +104,29 @@ public QueryEvent asTransportError(byte status, String message) { this.buffer = null; this.errorStatus = status; this.errorMessage = message; + this.requestId = ANY_REQUEST; + return this; + } + + /** + * Ties the event to the query with id {@code requestId}. Call it after the + * {@code asX()} builder, which leaves the event at {@link #ANY_REQUEST}. + */ + public QueryEvent forRequest(long requestId) { + this.requestId = requestId; return this; } + /** + * Returns {@code true} when the event belongs to a query other than + * {@code requestId}: a leftover of an earlier query that its + * {@code execute()} stopped waiting for. An {@link #ANY_REQUEST} event + * belongs to every query. + */ + public boolean isForOtherRequest(long requestId) { + return this.requestId != ANY_REQUEST && this.requestId != requestId; + } + /** * Clears object references and resets primitive fields so a pooled event is * safe to reuse across queries. The I/O thread calls the {@code asX(...)} @@ -106,5 +143,6 @@ public void reset() { this.opType = 0; this.rowsAffected = 0; this.totalRows = 0; + this.requestId = ANY_REQUEST; } } diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpColumnBatchHandler.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpColumnBatchHandler.java index 6237e1de0..86feaf128 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpColumnBatchHandler.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpColumnBatchHandler.java @@ -38,7 +38,12 @@ * Exception contract: if any callback method throws, the * exception propagates out of the {@link QwpQueryClient#execute} call on the * caller's thread and no further callbacks fire for that query. The connection - * remains usable for subsequent queries. + * remains usable for subsequent queries: when {@link #onBatch} throws while the + * query is still running, {@code execute} cancels the query, and the next query + * skips whatever the cancelled one still sends. When {@link #onError} reports a + * query timeout at the end of the grace period, the aborted query is still + * running as well; if that callback throws, {@code execute} first drains the + * query, as it does when the callback returns, and only then rethrows. */ public interface QwpColumnBatchHandler { diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpEgressIoThread.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpEgressIoThread.java index 700e219ea..3c51f44dd 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpEgressIoThread.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpEgressIoThread.java @@ -89,8 +89,9 @@ public class QwpEgressIoThread implements Runnable, WebSocketFrameHandler { // reads the fields synchronously in {@link #sendQueryRequest} and does not // retain a reference past that call. Reuse avoids a per-submit allocation // -- one in-flight query per client makes this safe: the worker that - // mutates pendingRequest is blocked on the events queue until the I/O - // thread has finished consuming the previous instance. + // mutates pendingRequest does not return from execute() before the I/O + // thread has encoded the previous instance, or it was withdrawn + // (withdrawRequest). private final QueryRequest pendingRequest = new QueryRequest(); // Single-slot request queue (Phase-1 allows one in-flight query). private final BlockingQueue requests = new ArrayBlockingQueue<>(1); @@ -113,7 +114,15 @@ public class QwpEgressIoThread implements Runnable, WebSocketFrameHandler { private volatile boolean closed; private boolean creditEnabled; private boolean currentQueryDone; + // Query being served. Stamped on the events of that query (QueryEvent.requestId); + // connection-level events stay at QueryEvent.ANY_REQUEST. private long currentRequestId = -1L; + // requestId of the last QUERY_REQUEST fully encoded into sendScratch. Once a + // request's id is published here the I/O thread no longer reads the caller's + // SQL text or bind scratch for it, so the caller may reuse them even while the + // query itself is still running. -1 until the first request. Written by the + // I/O thread only; volatile so the executing thread observes it. + private volatile long encodedRequestId = -1L; private volatile boolean shutdown; public QwpEgressIoThread(WebSocketClient wsClient, int bufferPoolSize, TerminalFailureListener terminalFailureListener) { @@ -293,9 +302,11 @@ public void releaseEvent(QueryEvent event) { /** * Queues a CANCEL frame for {@code requestId} to be sent by the I/O thread * between the next two {@code receiveFrame} iterations (typically within - * {@link #POLL_TIMEOUT_MS}). Safe to call from any thread. If a CANCEL for - * the same (or another) requestId is already pending, the newer id wins -- - * multiple concurrent cancels coalesce into one send. + * {@link #POLL_TIMEOUT_MS}) once it serves that query. Safe to call from any + * thread. If a CANCEL for the same (or another) requestId is already pending, + * the newer id wins -- multiple concurrent cancels coalesce into one send. + * See {@link #drainPendingCancel()} for a cancel of a query that has not been + * sent yet, or has already ended. */ public void requestCancel(long requestId) { pendingCancelRequestId.set(requestId); @@ -356,6 +367,15 @@ public void run() { } } + /** + * Returns {@code true} once the {@code QUERY_REQUEST} for {@code requestId} + * has been encoded, after which the I/O thread no longer reads the SQL text or + * bind payload that {@link #submitQuery} handed it. + */ + public boolean isRequestEncoded(long requestId) { + return encodedRequestId == requestId; + } + /** * Signals shutdown. Does not join the thread -- caller handles that. */ @@ -377,6 +397,10 @@ public void shutdown() { * during {@link #sendQueryRequest} and does not retain a reference after * the send completes. {@code bindCount} is the number of binds the * payload contains; zero when the user supplied no binds. + *

+ * {@code timeoutMs} is written as the {@code timeout_ms} field only when + * {@code queryFlags} carries {@link QwpEgressMsgKind#QUERY_FLAG_TIMEOUT}; + * otherwise it is ignored. */ public void submitQuery( CharSequence sql, @@ -385,7 +409,8 @@ public void submitQuery( int bindCount, long bindPayloadPtr, long bindPayloadLen, - long queryFlags + long queryFlags, + long timeoutMs ) throws InterruptedException { pendingRequest.sql = sql; pendingRequest.requestId = requestId; @@ -394,6 +419,7 @@ public void submitQuery( pendingRequest.bindPayloadPtr = bindPayloadPtr; pendingRequest.bindPayloadLen = bindPayloadLen; pendingRequest.queryFlags = queryFlags; + pendingRequest.timeoutMs = timeoutMs; requests.put(pendingRequest); } @@ -404,6 +430,28 @@ public QueryEvent takeEvent() throws InterruptedException { return events.take(); } + /** + * Pops the next event, waiting at most until the absolute + * {@link System#nanoTime()} {@code deadlineNanos}. Returns {@code null} when + * the deadline passes with no event available. Called by the user thread + * during {@code execute()} when the query has a timeout. + */ + public QueryEvent takeEvent(long deadlineNanos) throws InterruptedException { + return events.take(deadlineNanos); + } + + /** + * Takes back the request {@code requestId} that {@link #submitQuery} queued, + * provided this thread has not picked it up yet. Returns {@code true} when it + * was still queued: it is never sent, and the caller may reuse its SQL text + * and bind payload at once. Returns {@code false} when this thread has it; + * it reads them until {@link #isRequestEncoded} holds. Removal and pick-up + * are atomic with respect to each other. + */ + public boolean withdrawRequest(long requestId) { + return pendingRequest.requestId == requestId && requests.remove(pendingRequest); + } + /** * Takes a pre-allocated {@link QueryEvent} from the pool for the I/O thread * to populate before offering to {@link #events}. Falls back to a fresh @@ -421,8 +469,7 @@ private QueryEvent borrowEvent() { } private void decodeAndEmitError(long payload, int payloadLen) { - QueryEvent ev = decodeError(payload, payloadLen); - events.offer(ev); + events.offer(decodeError(payload, payloadLen).forRequest(currentRequestId)); } /** @@ -461,7 +508,7 @@ private void decodeAndEmitExecDone(long payload, int payloadLen) { emitTerminalTransportError("EXEC_DONE frame truncated mid rows_affected varint"); return; } - events.offer(new QueryEvent().asExecDone(opType, rowsAffected)); + events.offer(new QueryEvent().asExecDone(opType, rowsAffected).forRequest(currentRequestId)); } /** @@ -518,7 +565,7 @@ private void decodeAndEmitResultEnd(long payload, int payloadLen) { emitTerminalTransportError("RESULT_END frame truncated mid total_rows varint"); return; } - events.offer(new QueryEvent().asEnd(total)); + events.offer(new QueryEvent().asEnd(total).forRequest(currentRequestId)); } /** @@ -526,10 +573,22 @@ private void decodeAndEmitResultEnd(long payload, int payloadLen) { * thread at every loop boundary so a cancel set by a user thread reaches * the server regardless of whether the I/O thread was waiting on a frame * or on a free buffer. + *

+ * Only a cancel of the query being served goes out. One for a later query + * stays pending: the user thread can queue that query, and cancel it, while + * this thread still works through an earlier query whose {@code execute()} + * stopped waiting for it. Sent now, the CANCEL would reach the server before + * the query does, and the server drops a cancel of a query it does not know. + * One for an earlier query, which has already ended, is dropped. Request ids + * only grow, so comparing them tells the three apart. */ private void drainPendingCancel() { - long id = pendingCancelRequestId.getAndSet(-1L); - if (id >= 0L) { + long id = pendingCancelRequestId.get(); + if (id < 0L || id > currentRequestId) { + return; + } + // The CAS keeps a cancel the user thread set meanwhile, which is newer. + if (pendingCancelRequestId.compareAndSet(id, -1L) && id == currentRequestId) { sendCancel(id); } } @@ -628,7 +687,7 @@ private void handleResultBatch(long payloadPtr, int payloadLen) { currentQueryDone = true; return; } - events.offer(borrowEvent().asBatch(buf)); + events.offer(borrowEvent().asBatch(buf).forRequest(currentRequestId)); // Park on the release latch. Returning sooner would let receiveFrame // compact the WebSocket recv buffer, overwriting the bytes that the // user-visible column pointers still reference. User thread's @@ -721,7 +780,13 @@ private void sendQueryRequest(QueryRequest req) { // stays byte-identical and the server defaults the flags to 0. if (req.queryFlags != 0) { sendScratch.putVarint(req.queryFlags); + // Flag-gated fields follow the flags in flag-bit order. + if ((req.queryFlags & QwpEgressMsgKind.QUERY_FLAG_TIMEOUT) != 0) { + sendScratch.putVarint(req.timeoutMs); + } } + // The request no longer references the caller's SQL text or bind scratch. + encodedRequestId = req.requestId; wsClient.sendBinary(sendScratch.getBufferPtr(), sendScratch.getPosition()); sendScratch.reset(); } @@ -771,9 +836,10 @@ public interface TerminalFailureListener { /** * Mutable request holder reused across submits. Safe to reuse because at * most one query is in flight per client: the worker thread mutates fields - * and offers the instance into {@link #requests}, then blocks on the events - * queue until the I/O thread has fully consumed the previous instance and - * delivered a terminal event. + * and offers the instance into {@link #requests}, and does not return from + * {@code execute()} before this thread has encoded the instance (see + * {@link #isRequestEncoded}) or the worker withdrew it + * ({@link #withdrawRequest}). */ private static final class QueryRequest { int bindCount; @@ -783,5 +849,6 @@ private static final class QueryRequest { long queryFlags; long requestId; CharSequence sql; + long timeoutMs; } } diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpEgressMsgKind.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpEgressMsgKind.java index e9b2c8934..5f4d6c15c 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpEgressMsgKind.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpEgressMsgKind.java @@ -45,6 +45,18 @@ public final class QwpEgressMsgKind { * constant {@code io.questdb.cutlass.qwp.codec.QwpEgressMsgKind#CAP_QUERY_FLAGS}. */ public static final int CAP_QUERY_FLAGS = 0x00000002; + /** + * {@code SERVER_INFO.capabilities} bit: the server enforces a per-query + * timeout carried on {@code QUERY_REQUEST}. When set (together with + * {@link #CAP_QUERY_FLAGS}), a client that wants a timeout sets + * {@link #QUERY_FLAG_TIMEOUT} and appends {@code timeout_ms:varint} after the + * {@code query_flags} trailer; the server then ends an over-budget query with + * a {@code QUERY_ERROR} carrying + * {@link io.questdb.client.cutlass.qwp.protocol.QwpConstants#STATUS_QUERY_TIMEOUT}, + * leaving the connection open. Mirrors the server-side constant + * {@code io.questdb.cutlass.qwp.codec.QwpEgressMsgKind#CAP_QUERY_TIMEOUT}. + */ + public static final int CAP_QUERY_TIMEOUT = 0x00000008; /** * {@code SERVER_INFO.capabilities} bit advertising that the frame ends with * an additional {@code zone_id:u16_len+utf8} field after {@code node_id}. @@ -67,6 +79,13 @@ public final class QwpEgressMsgKind { * advertised {@link #CAP_QUERY_FLAGS}. */ public static final int QUERY_FLAG_RESET_DICT = 0x01; + /** + * {@code QUERY_REQUEST.query_flags} bit: a {@code timeout_ms:varint} field + * follows the {@code query_flags} varint. The server runs the query under + * that timeout instead of its default {@code query.timeout}. Sent only when + * the server advertised {@link #CAP_QUERY_TIMEOUT}. + */ + public static final int QUERY_FLAG_TIMEOUT = 0x02; public static final byte QUERY_REQUEST = 0x10; /** * Reset mask bit: clear the connection-scoped SYMBOL dict. diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpQueryClient.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpQueryClient.java index bfc924e52..94d11e1d9 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpQueryClient.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpQueryClient.java @@ -46,8 +46,10 @@ import java.util.Base64; import java.util.List; import java.util.Random; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.locks.LockSupport; /** * QWP egress (query results) client. @@ -90,11 +92,23 @@ * query {@code QUERY_ERROR} responses are NOT terminal -- the connection * remains usable for the next query. *

+ * Query timeout: {@code query_timeout_ms=} (or {@link #withQueryTimeout} / + * the {@code timeoutMs} argument of {@link #execute}) bounds each query. A + * server advertising {@link QwpEgressMsgKind#CAP_QUERY_TIMEOUT} enforces the + * timeout itself; otherwise the client cancels the query when its deadline + * expires. Either way the handler sees + * {@link QwpConstants#STATUS_QUERY_TIMEOUT} and the connection, still + * authenticated, stays open for the next query. Only a connection that does not + * answer at all within the grace period ({@link #withQueryTimeoutGrace}) is + * replaced. + *

* Status byte convention on {@link QwpColumnBatchHandler#onError}: server- * emitted {@code QUERY_ERROR} frames surface with the server's status code * ({@link WebSocketResponse#STATUS_PARSE_ERROR}, {@code STATUS_INTERNAL_ERROR}, * {@link QwpConstants#STATUS_CANCELLED}, {@link QwpConstants#STATUS_LIMIT_EXCEEDED}, - * etc.). Failures detected client-side (closed client, bind encoding error, + * {@link QwpConstants#STATUS_QUERY_TIMEOUT}, etc.). A query timeout reports + * {@code STATUS_QUERY_TIMEOUT} whether the server or the client detected it. + * Failures detected client-side (closed client, bind encoding error, * truncated / unknown frame, decoder out of sync, I/O thread interrupt) all * surface with {@link WebSocketResponse#STATUS_INTERNAL_ERROR} and the * specific cause in the message. @@ -102,6 +116,16 @@ public class QwpQueryClient implements QuietCloseable { public static final String DEFAULT_ENDPOINT_PATH = "/read/v1"; + /** + * Default grace period of the query timeout, in milliseconds: how long the + * client waits past an expired query timeout for the server to end the + * query before it releases the caller, and again before it gives up on an + * unresponsive connection. Matches the {@code QuestDB} facade's + * {@code query_close_timeout_ms} default, which the facade passes per query. + * + * @see #withQueryTimeoutGrace(long) + */ + public static final long DEFAULT_QUERY_TIMEOUT_GRACE_MS = 5_000L; public static final int DEFAULT_WS_PORT = 9000; /** * Hard ceiling on {@link #withMaxBatchRows}. Matches the client decoder's @@ -152,6 +176,22 @@ public class QwpQueryClient implements QuietCloseable { */ private static final int DEFAULT_SERVER_INFO_TIMEOUT_MS = 5_000; private static final Logger LOG = LoggerFactory.getLogger(QwpQueryClient.class); + // Upper bound on any timeout or grace period converted to nanoseconds. Keeps + // deadline arithmetic (deadline + 2 * grace) on System.nanoTime() values + // clear of overflow; ~73 years is "no limit" for every practical purpose. + private static final long MAX_TIMEOUT_NANOS = Long.MAX_VALUE / 8; + // How often an interrupted execute() checks whether the I/O thread has + // encoded its request (see retractRequest). + private static final long REQUEST_ENCODED_POLL_NANOS = TimeUnit.MICROSECONDS.toNanos(100); + // States of a query running under a timeout (see executeOnce). + // RUNNING: before the deadline, events are delivered normally. + private static final int TIMEOUT_PHASE_RUNNING = 0; + // TIMED_OUT: the deadline passed. Result batches are discarded while waiting, + // up to one grace period, for the server to end the query. + private static final int TIMEOUT_PHASE_TIMED_OUT = 1; + // DRAINING: the caller has been told about the timeout; the connection keeps + // draining the aborted query, up to one more grace period, so it can be reused. + private static final int TIMEOUT_PHASE_DRAINING = 2; // Reusable typed bind-value sink. Populated on the user thread by the // {@link QwpBindSetter} passed to execute(); the pre-encoded bytes are // handed to the I/O thread via QueryRequest. Allocated once per client to @@ -185,6 +225,12 @@ public class QwpQueryClient implements QuietCloseable { // across zones). private String clientZone; private int compressionLevel = 1; + // Absolute System.nanoTime() deadline bounding the endpoint walk of a failover + // reconnect performed on behalf of a query that has a timeout. Honoured only + // while connectDeadlineActive is set, which reconnectViaTracker() does for the + // duration of the walk. Accessed only by the executing thread. + private boolean connectDeadlineActive; + private long connectDeadlineNanos; // User-facing compression preference from the connection string. "raw" is // the library default -- no compression, no handshake header, no server- // side CPU burn on payloads where the network isn't the bottleneck @@ -262,6 +308,18 @@ public class QwpQueryClient implements QuietCloseable { // re-clamp) so a misconfigured server is observable from user code. private int negotiatedZstdLevel; private long nextRequestId = 1; + // Default per-query timeout, in milliseconds, applied by the execute() + // overloads that take no explicit timeout. 0 (the default) means no timeout. + // Set via query_timeout_ms or withQueryTimeout(); read once per execute(). + private volatile long queryTimeoutMs; + // Grace period of the query timeout; see DEFAULT_QUERY_TIMEOUT_GRACE_MS. + private volatile long queryTimeoutGraceMs = DEFAULT_QUERY_TIMEOUT_GRACE_MS; + // True after a failover reconnect failed for a reason other than + // authentication -- every endpoint was unreachable, or a query timeout cut the + // reconnect short. The next execute() then reconnects before running its + // query instead of rejecting it as "not connected". Cleared on every + // successful connect. Accessed by the executing thread only. + private boolean reconnectPending; // Cancel intent latched between {@link #cancel} and the point where // {@link #executeOnce} assigns {@link #currentRequestId}. Without this // latch, a cancel arriving in the dispatch window (after the user thread's @@ -332,6 +390,9 @@ private QwpQueryClient(String host, int port) { * {@link #execute}, reconnect to another endpoint and re-submit the query. * The user handler sees {@link QwpColumnBatchHandler#onFailoverReset} before * replayed batches begin arriving (batch_seq restarts at 0 on the new node). + *

  • {@code query_timeout_ms=N} -- default per-query timeout in milliseconds, + * applied by every {@link #execute} overload that takes no explicit timeout. + * {@code 0} (the default) means no timeout. See {@link #withQueryTimeout(long)}.
  • *
  • {@code username=;password=} -- HTTP Basic authentication. The client builds the * {@code Authorization: Basic } header from these. Server verifies the credentials * against the same user store the Postgres wire protocol uses, so a user created via @@ -407,6 +468,7 @@ public static QwpQueryClient fromConfig(CharSequence configurationString) { // over-int value must reject, not wrap. Integer connectTimeout = view.has("connect_timeout") ? view.getInt("connect_timeout", 0) : null; Long initialCredit = view.has("initial_credit") ? view.getLong("initial_credit", 0) : null; + Long queryTimeoutMs = view.has("query_timeout_ms") ? view.getLong("query_timeout_ms", 0) : null; int poolSize = view.getInt("buffer_pool_size", DEFAULT_IO_BUFFER_POOL_SIZE); String compression = view.getEnum("compression"); if (compression == null) { @@ -467,6 +529,9 @@ public static QwpQueryClient fromConfig(CharSequence configurationString) { if (initialCredit != null) { client.withInitialCredit(initialCredit); } + if (queryTimeoutMs != null) { + client.withQueryTimeout(queryTimeoutMs); + } client.withBufferPoolSize(poolSize); client.withCompression(compression, compressionLevel); if (tls) { @@ -522,6 +587,7 @@ public static void validateConfig(ConfigView view, boolean tls) { long backoffMax = view.getLong("failover_backoff_max_ms", -1); view.getLong("failover_max_duration_ms", -1); view.getLong("initial_credit", -1); + view.getLong("query_timeout_ms", -1); view.getLong("auth_timeout_ms", -1); // getInt: connect_timeout feeds an int API, so validation must also // reject values that fit a long but not an int. @@ -820,6 +886,7 @@ public synchronized void connect() { hostTracker.recordSuccess(i); currentEndpointIndex = i; connected = true; + reconnectPending = false; return; } if (lastObservedMismatch != null) { @@ -904,6 +971,55 @@ public void execute(CharSequence sql, QwpBindSetter binds, QwpColumnBatchHandler * {@link QwpEgressMsgKind#CAP_QUERY_FLAGS}; otherwise it is silently ignored. */ public void execute(CharSequence sql, QwpBindSetter binds, QwpColumnBatchHandler handler, boolean resetSymbolDict) { + execute(sql, binds, handler, resetSymbolDict, queryTimeoutMs); + } + + /** + * As {@link #execute(CharSequence, QwpBindSetter, QwpColumnBatchHandler, boolean)}, + * with an explicit query timeout that overrides the client default + * ({@link #withQueryTimeout(long)}, {@code query_timeout_ms}). + *

    + * The timeout bounds the whole call, measured from entry: binding, every + * failover reconnect and replay, server execution, and the time spent in + * the handler's own callbacks. It is checked between result batches, so a + * handler that blocks inside {@code onBatch} is not interrupted. + *

    + * When the server advertises {@link QwpEgressMsgKind#CAP_QUERY_TIMEOUT}, the + * timeout travels with the query and the server ends an over-budget query + * itself with a {@code QUERY_ERROR} carrying + * {@link QwpConstants#STATUS_QUERY_TIMEOUT}; the connection stays open and is + * reused by the next query. Against an older server the client cancels the + * query when its deadline expires and reports the cancellation as the same + * status. Either way, once the deadline has passed no further result batch + * reaches the handler. + *

    + * If the server has not ended the query within the grace period + * ({@link #withQueryTimeoutGrace(long)}) after the deadline, the handler is + * told about the timeout anyway and this call keeps draining the aborted + * query for up to one more grace period, so the connection can still be + * reused. That drain happens even if the handler's {@code onError} throws; + * the exception then propagates once the drain ends. Only a connection that + * stays silent through both grace periods is treated as failed (see + * {@link #hasTerminalFailure()}). Unless a handler callback blocks, this call + * therefore returns about two grace periods after the timeout at the latest. + * + * @param timeoutMs query timeout in milliseconds; {@code 0} runs the query + * without a timeout + * @throws IllegalArgumentException when {@code timeoutMs} is negative + */ + public void execute( + CharSequence sql, + QwpBindSetter binds, + QwpColumnBatchHandler handler, + boolean resetSymbolDict, + long timeoutMs + ) { + if (timeoutMs < 0) { + throw new IllegalArgumentException("timeoutMs must be >= 0"); + } + // The clock starts on entry, so binding, dispatch and any failover all + // count against the timeout. + final long deadlineNanos = System.nanoTime() + toBoundedNanos(timeoutMs); if (!executing.compareAndSet(false, true)) { throw new IllegalStateException( "QwpQueryClient.execute called while another execute is in flight; one query at a time per client"); @@ -915,7 +1031,7 @@ public void execute(CharSequence sql, QwpBindSetter binds, QwpColumnBatchHandler // is intentionally NOT cleared inside executeOnce(). pendingCancel = false; try { - executeImpl(sql, binds, handler, resetSymbolDict); + executeImpl(sql, binds, handler, resetSymbolDict, timeoutMs, deadlineNanos); } finally { executing.set(false); } @@ -962,6 +1078,7 @@ public java.util.Map configSnapshotForTest() { m.put("failover_max_duration_ms", failoverMaxDurationMs); m.put("max_batch_rows", maxBatchRows); m.put("initial_credit", initialCreditBytes); + m.put("query_timeout_ms", queryTimeoutMs); m.put("buffer_pool_size", bufferPoolSize); m.put("compression", compressionPreference); m.put("compression_level", compressionLevel); @@ -1025,6 +1142,25 @@ public int getNegotiatedZstdLevel() { return negotiatedZstdLevel; } + /** + * Returns the grace period of the query timeout, in milliseconds. + * + * @see #withQueryTimeoutGrace(long) + */ + public long getQueryTimeoutGraceMs() { + return queryTimeoutGraceMs; + } + + /** + * Returns the default per-query timeout in milliseconds, {@code 0} when + * queries run without a timeout. + * + * @see #withQueryTimeout(long) + */ + public long getQueryTimeoutMs() { + return queryTimeoutMs; + } + /** * Returns the {@link QwpServerInfo} decoded from the currently-bound * server's {@code SERVER_INFO} frame, or {@code null} if the client is not @@ -1034,6 +1170,19 @@ public QwpServerInfo getServerInfo() { return serverInfo; } + /** + * Returns {@code true} when the bound connection has latched a terminal + * transport failure: the server closed it, a protocol fault occurred, or it + * stopped responding after a query timeout. The next {@link #execute} then + * reconnects ({@code failover=on}) or reports the stored failure + * ({@code failover=off}). A query error reported by the server, including a + * query timeout, is not a terminal failure: the connection stays usable. + */ + public boolean hasTerminalFailure() { + GenerationListener listener = currentGenerationListener; + return listener != null && listener.get() != null; + } + public boolean isConnected() { return connected; } @@ -1338,6 +1487,42 @@ public QwpQueryClient withMaxBatchRows(int rows) { return this; } + /** + * Sets the default per-query timeout, in milliseconds, applied by every + * {@link #execute} overload that takes no explicit timeout; {@code 0} + * disables it (the default). Programmatic equivalent of the + * {@code query_timeout_ms=} connection-string key. Unlike the connection + * settings it may be changed at any time; it applies to subsequent + * {@code execute()} calls. See + * {@link #execute(CharSequence, QwpBindSetter, QwpColumnBatchHandler, boolean, long)} + * for the timeout semantics. + */ + public QwpQueryClient withQueryTimeout(long timeoutMs) { + if (timeoutMs < 0) { + throw new IllegalArgumentException("query timeout must be >= 0"); + } + this.queryTimeoutMs = timeoutMs; + return this; + } + + /** + * Sets the grace period of the query timeout, in milliseconds (default + * {@value #DEFAULT_QUERY_TIMEOUT_GRACE_MS}). After a query's timeout + * expires, the client waits up to this long for the server to end the + * query before it reports the timeout to the handler, then up to this long + * again for the connection to drain the aborted query before it gives up on + * the connection. The {@code QuestDB} facade sets it from + * {@code query_close_timeout_ms}. May be changed at any time; it applies to + * subsequent {@code execute()} calls. + */ + public QwpQueryClient withQueryTimeoutGrace(long graceMs) { + if (graceMs < 0) { + throw new IllegalArgumentException("query timeout grace must be >= 0"); + } + this.queryTimeoutGraceMs = graceMs; + return this; + } + /** * Overrides the {@link #DEFAULT_SERVER_INFO_TIMEOUT_MS} wait for the * {@code SERVER_INFO} frame. Must be called before {@link #connect}. @@ -1436,6 +1621,22 @@ private static String defaultClientId() { return "questdb-java-egress/1.0.0"; } + /** + * Hands an event the executing thread does not deliver back to the I/O + * thread, with its batch buffer: the I/O thread waits for each published + * batch to be released. + */ + private static void discardEvent(QwpEgressIoThread io, QueryEvent ev) { + if (ev.kind == QueryEvent.KIND_BATCH && ev.buffer != null) { + io.releaseBuffer(ev.buffer); + } + io.releaseEvent(ev); + } + + private static boolean isPast(long deadlineNanos) { + return System.nanoTime() - deadlineNanos >= 0; + } + @SuppressWarnings("BooleanMethodIsAlwaysInverted") private static boolean matchesTarget(byte role, String target) { if (TARGET_ANY.equals(target)) { @@ -1452,6 +1653,107 @@ private static boolean matchesTarget(byte role, String target) { return true; } + private static String queryTimeoutMessage(long timeoutMs) { + return "query timeout of " + timeoutMs + "ms exceeded"; + } + + // Milliseconds left until deadlineNanos, rounded up so a sub-millisecond + // remainder still counts as 1; 0 once the deadline has passed. + private static long remainingMillisCeil(long deadlineNanos) { + long remainingNanos = deadlineNanos - System.nanoTime(); + return remainingNanos <= 0L ? 0L : (remainingNanos + 999_999L) / 1_000_000L; + } + + /** + * Throws {@code t} as is, without wrapping it. Callbacks of + * {@link QwpColumnBatchHandler} declare no checked exceptions, so this only + * matters for a handler that throws one anyway: it reaches the caller of + * {@link #execute} unchanged, as it does when it is not held back. + */ + @SuppressWarnings("unchecked") + private static void throwUnchecked(Throwable t) throws T { + throw (T) t; + } + + private static long toBoundedNanos(long millis) { + return Math.min(TimeUnit.MILLISECONDS.toNanos(millis), MAX_TIMEOUT_NANOS); + } + + /** + * Gives up on the connection. Latches a terminal failure carrying + * {@code failureMessage}, so the next {@link #execute} replaces the + * connection ({@code failover=on}) or reports the failure + * ({@code failover=off}) and a pool discards the client, then stops the I/O + * thread. + *

    + * Runs on the executing thread, the sole consumer of the event queue, and + * keeps releasing every batch the I/O thread still publishes while it winds + * down: the I/O thread waits uninterruptibly for each published batch to be + * released, and would otherwise outlive the joins in {@link #close()} and + * {@link #cleanupFailedConnect()}. + */ + private void abandonConnection(QwpEgressIoThread io, String failureMessage) { + GenerationListener listener = currentGenerationListener; + if (listener != null) { + listener.onTerminalFailure(WebSocketResponse.STATUS_INTERNAL_ERROR, failureMessage); + } + io.shutdown(); + Thread handle = ioThreadHandle; + if (handle == null) { + return; + } + handle.interrupt(); + final long stopDeadlineNanos = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(shutdownJoinMs); + boolean interrupted = false; + while (handle.isAlive() && !isPast(stopDeadlineNanos)) { + long pollDeadlineNanos = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(10); + if (pollDeadlineNanos - stopDeadlineNanos > 0) { + pollDeadlineNanos = stopDeadlineNanos; + } + QueryEvent ev; + try { + ev = io.takeEvent(pollDeadlineNanos); + } catch (InterruptedException e) { + // Finish stopping the I/O thread first; the flag is restored below. + interrupted = true; + continue; + } + if (ev != null) { + discardEvent(io, ev); + } + } + if (interrupted) { + Thread.currentThread().interrupt(); + } + } + + /** + * Gives up on a connection that left a timed-out query unanswered through + * both grace periods (hung server, black-holed network), or whose I/O thread + * never even sent the query. See {@link #abandonConnection}. + */ + private void abandonUnresponsiveConnection(QwpEgressIoThread io) { + LOG.warn("QwpQueryClient connection did not end a timed-out query within two grace periods of {}ms; " + + "closing it", queryTimeoutGraceMs); + abandonConnection(io, "connection stopped responding after a query timeout"); + } + + /** + * Applies the deadline of a failover reconnect made on behalf of a query + * with a timeout (see {@link #connectDeadlineActive}) to one endpoint step's + * configured timeout. {@code configuredMs <= 0} means the step has no bound + * of its own ({@code connect_timeout}'s OS-default sentinel) and passes + * through unchanged when no reconnect deadline is active. + */ + private int boundToConnectDeadline(long configuredMs) { + long boundedMs = configuredMs; + if (connectDeadlineActive) { + long remainingMs = Math.max(1L, remainingMillisCeil(connectDeadlineNanos)); + boundedMs = configuredMs > 0L ? Math.min(configuredMs, remainingMs) : remainingMs; + } + return (int) Math.min(boundedMs, Integer.MAX_VALUE); + } + /** * Builds the {@code X-QWP-Accept-Encoding} header value from the user's * preference. {@code raw} (the library default) omits the header entirely @@ -1549,7 +1851,7 @@ private void connectToEndpoint(Endpoint ep, String authHeader) { webSocketClient.setQwpClientId(clientId != null ? clientId : defaultClientId()); webSocketClient.setQwpAcceptEncoding(buildAcceptEncodingHeader()); webSocketClient.setQwpMaxBatchRows(maxBatchRows); - webSocketClient.setConnectTimeout(connectTimeoutMs); + webSocketClient.setConnectTimeout(boundToConnectDeadline(connectTimeoutMs)); runUpgradeWithTimeout(ep, authHeader); negotiatedQwpVersion = webSocketClient.getServerQwpVersion(); negotiatedZstdLevel = webSocketClient.getServerNegotiatedZstdLevel(); @@ -1573,12 +1875,27 @@ private void connectToEndpoint(Endpoint ep, String authHeader) { } } - private void executeImpl(CharSequence sql, QwpBindSetter binds, QwpColumnBatchHandler handler, boolean resetSymbolDict) { + private void executeImpl( + CharSequence sql, + QwpBindSetter binds, + QwpColumnBatchHandler handler, + boolean resetSymbolDict, + long timeoutMs, + long deadlineNanos + ) { if (closedFlag.get()) { throw new IllegalStateException("QwpQueryClient is closed"); } + final boolean timed = timeoutMs > 0; if (!connected) { - throw new IllegalStateException("QwpQueryClient not connected; call connect() first"); + if (!reconnectPending) { + throw new IllegalStateException("QwpQueryClient not connected; call connect() first"); + } + // An earlier failover reconnect did not complete. Retry it for this + // query instead of leaving the client unusable. + if (!reconnectForQuery(handler, timed, timeoutMs, deadlineNanos)) { + return; + } } hostTracker.beginRound(false); long failoverDeadlineNanos; @@ -1597,7 +1914,11 @@ private void executeImpl(CharSequence sql, QwpBindSetter binds, QwpColumnBatchHa while (true) { attempt++; FailoverProbeHandler probe = new FailoverProbeHandler(handler); - executeOnce(sql, binds, probe, resetSymbolDict); + executeOnce(sql, binds, probe, resetSymbolDict, timeoutMs, deadlineNanos); + // A handler failure held back while the attempt drained a timed-out + // query propagates now: the query has ended, or the connection was + // given up on. + probe.rethrowDeferredFailure(); if (!probe.transportFailureIntercepted) { return; } @@ -1605,6 +1926,12 @@ private void executeImpl(CharSequence sql, QwpBindSetter binds, QwpColumnBatchHa handler.onError(probe.interceptedRequestId, probe.interceptedStatus, probe.interceptedMessage); return; } + if (timed && isPast(deadlineNanos)) { + handler.onError(probe.interceptedRequestId, QwpConstants.STATUS_QUERY_TIMEOUT, + queryTimeoutMessage(timeoutMs) + " before failover could replay the query; last error: " + + probe.interceptedMessage); + return; + } if (attempt >= failoverMaxAttempts || System.nanoTime() - failoverDeadlineNanos >= 0) { int failovers = Math.max(0, attempt - 1); handler.onError(probe.interceptedRequestId, probe.interceptedStatus, @@ -1621,6 +1948,10 @@ private void executeImpl(CharSequence sql, QwpBindSetter binds, QwpColumnBatchHa } cleanupFailedConnect(); connected = false; + // Cleared by a successful reconnect below. Any other way out of this + // iteration leaves the client disconnected, and the next execute() + // reconnects before running its query. + reconnectPending = true; if (failoverInitialBackoffMs > 0L) { long base = failoverInitialBackoffMs << Math.min(attempt - 1, 30); if (base < 0L) base = failoverMaxBackoffMs; @@ -1648,6 +1979,13 @@ private void executeImpl(CharSequence sql, QwpBindSetter binds, QwpColumnBatchHa if (delay > remaining) { delay = remaining; } + if (timed) { + // Never back off past the query's own deadline. + long queryRemaining = (deadlineNanos - System.nanoTime()) / 1_000_000L; + if (delay > queryRemaining) { + delay = Math.max(0L, queryRemaining); + } + } if (delay > 0L) { try { Thread.sleep(delay); @@ -1661,12 +1999,14 @@ private void executeImpl(CharSequence sql, QwpBindSetter binds, QwpColumnBatchHa } } try { - reconnectViaTracker(); + reconnectViaTracker(timed, deadlineNanos); } catch (QwpAuthFailedException authErr) { // failover.md S6: AuthError is terminal across all hosts. // Credentials are cluster-wide, so retrying floods server logs // without recovery. Surface a distinct message so monitoring // can pull auth incidents apart from generic transport failures. + // Not retried by a later execute() either. + reconnectPending = false; handler.onError(probe.interceptedRequestId, probe.interceptedStatus, "auth failure during failover reconnect [host=" + authErr.getHost() + ':' + authErr.getPort() @@ -1674,6 +2014,13 @@ private void executeImpl(CharSequence sql, QwpBindSetter binds, QwpColumnBatchHa + ", last error: " + probe.interceptedMessage + ']'); return; } catch (RuntimeException reconnectErr) { + if (timed && isPast(deadlineNanos)) { + handler.onError(probe.interceptedRequestId, QwpConstants.STATUS_QUERY_TIMEOUT, + queryTimeoutMessage(timeoutMs) + " while reconnecting for failover [last error: " + + probe.interceptedMessage + ", reconnect error: " + + reconnectErr.getMessage() + ']'); + return; + } handler.onError(probe.interceptedRequestId, probe.interceptedStatus, "failover reconnect failed after " + attempt + " attempt" + (attempt == 1 ? "" : "s") + " [last error: " @@ -1681,6 +2028,14 @@ private void executeImpl(CharSequence sql, QwpBindSetter binds, QwpColumnBatchHa + reconnectErr.getMessage() + ']'); return; } + if (timed && isPast(deadlineNanos)) { + // Reconnected, but no budget is left to replay the query. The new + // connection is idle and serves the next query. + handler.onError(probe.interceptedRequestId, QwpConstants.STATUS_QUERY_TIMEOUT, + queryTimeoutMessage(timeoutMs) + " before failover could replay the query; last error: " + + probe.interceptedMessage); + return; + } handler.onFailoverReset(probe.interceptedRequestId, serverInfo); } } @@ -1689,8 +2044,37 @@ private void executeImpl(CharSequence sql, QwpBindSetter binds, QwpColumnBatchHa * Inner loop for a single query attempt. Driven by {@link #execute}; wraps * the user's handler in a {@link FailoverProbeHandler} so that the outer * loop can intercept transport failures before they reach the user. + *

    + * With a timeout ({@code timeoutMs > 0}) the attempt runs the + * {@code TIMEOUT_PHASE_*} state machine. Before {@code deadlineNanos} + * (RUNNING) events are delivered as usual. Once it passes (TIMED_OUT), + * result batches are discarded -- releasing them keeps the server streaming + * toward its next timeout or cancel check -- and the server gets up to one + * grace period to end the query, cancelled by the client unless the server + * enforces the timeout itself. The caller learns about the timeout from that + * terminal frame, or, failing it, at the end of the grace period; the attempt + * then keeps draining (DRAINING) for up to one more grace period, so that the + * connection, which carries one query at a time, can serve the next query. + * A connection still silent after that is abandoned. The handler sees + * exactly one terminal callback in every case. + *

    + * An attempt that ends before its query's last frame -- the handler threw + * from {@code onBatch}, or the thread was interrupted -- leaves the query + * running on the connection, and cancels it. The I/O thread still works + * through it before it sends the next query, so the next attempt skips the + * leftover events: each event carries the id of its query (see + * {@link QueryEvent#requestId}). An interrupt that comes before the I/O + * thread has encoded the request takes the request back, or waits for the + * encoding, before the caller hears about it (see {@link #retractRequest}). */ - private void executeOnce(CharSequence sql, QwpBindSetter binds, FailoverProbeHandler probe, boolean resetSymbolDict) { + private void executeOnce( + CharSequence sql, + QwpBindSetter binds, + FailoverProbeHandler probe, + boolean resetSymbolDict, + long timeoutMs, + long deadlineNanos + ) { // Cache the I/O thread reference at entry: close() may null the field while // we are inside this loop, so reading the field per-iteration would NPE // exactly when the user is mid-execute() and close() races. The queue and @@ -1724,6 +2108,27 @@ private void executeOnce(CharSequence sql, QwpBindSetter binds, FailoverProbeHan return; } } + final boolean timed = timeoutMs > 0; + long wireTimeoutMs = 0L; + if (timed) { + wireTimeoutMs = remainingMillisCeil(deadlineNanos); + if (wireTimeoutMs == 0L) { + // The budget ran out before the query could be sent (a slow bind + // setter, or a failover reconnect that used it up). Nothing is on + // the wire, so the connection is untouched. + bindValues.reset(); + probe.onError(-1L, QwpConstants.STATUS_QUERY_TIMEOUT, + queryTimeoutMessage(timeoutMs) + " before the query was sent"); + return; + } + } + final long queryFlags = resolveQueryFlags(resetSymbolDict, timed); + // A server that advertised CAP_QUERY_TIMEOUT receives the remaining budget + // with the query and ends it itself with STATUS_QUERY_TIMEOUT. The + // client-side deadline is then only a backstop and must not race the + // server's own report with a CANCEL. + final boolean serverEnforcesTimeout = (queryFlags & QwpEgressMsgKind.QUERY_FLAG_TIMEOUT) != 0; + final long graceNanos = timed ? toBoundedNanos(queryTimeoutGraceMs) : 0L; long requestId = nextRequestId++; currentRequestId = requestId; // Honor a cancel that arrived during the dispatch window. The latch @@ -1733,34 +2138,144 @@ private void executeOnce(CharSequence sql, QwpBindSetter binds, FailoverProbeHan if (pendingCancel) { io.requestCancel(requestId); } + int phase = TIMEOUT_PHASE_RUNNING; + // Set once a result batch is withheld from the handler because the + // deadline had passed; a later RESULT_END is then not a success. + boolean discardedBatch = false; + // Whether the user had already cancelled when the deadline passed. A + // STATUS_CANCELLED reply then reports that cancel, not the timeout. + boolean cancelledByUser = false; + long waitDeadlineNanos = deadlineNanos; + boolean submitted = false; + // Set once the attempt has taken its query's last event, or given up on + // the connection: the query no longer runs on it. + boolean settled = false; try { io.submitQuery(sql, requestId, initialCreditBytes, bindValues.count(), bindValues.bufferPtr(), bindValues.bufferLen(), - resolveQueryFlags(resetSymbolDict)); + queryFlags, wireTimeoutMs); + submitted = true; while (true) { - QueryEvent ev = io.takeEvent(); + QueryEvent ev = timed ? io.takeEvent(waitDeadlineNanos) : io.takeEvent(); + if (ev != null && ev.isForOtherRequest(requestId)) { + // A leftover of an earlier query whose attempt ended before that + // query did. Not this query's; skip it. + discardEvent(io, ev); + if (!timed || !isPast(waitDeadlineNanos)) { + continue; + } + // Leftovers can keep coming past the wait deadline, and the + // queue still returns them then: handle the deadline now. + ev = null; + } + if (ev == null) { + // waitDeadlineNanos passed without an event. + if (phase == TIMEOUT_PHASE_RUNNING) { + cancelledByUser = pendingCancel; + phase = TIMEOUT_PHASE_TIMED_OUT; + if (!serverEnforcesTimeout) { + io.requestCancel(requestId); + } + waitDeadlineNanos = deadlineNanos + graceNanos; + continue; + } + // A request the I/O thread never encoded means the thread is + // wedged; it may still read the caller's SQL and bind buffers, + // so the caller must not be released before it is stopped. + if (phase == TIMEOUT_PHASE_TIMED_OUT && io.isRequestEncoded(requestId)) { + // The server has not ended the query within the grace + // period. Tell the caller now; keep draining the aborted + // query so the connection can serve the next one. + io.requestCancel(requestId); + phase = TIMEOUT_PHASE_DRAINING; + waitDeadlineNanos = deadlineNanos + 2 * graceNanos; + probe.onErrorWhileDraining(requestId, QwpConstants.STATUS_QUERY_TIMEOUT, queryTimeoutMessage(timeoutMs) + + "; the server did not end the query within the " + queryTimeoutGraceMs + + "ms grace period"); + continue; + } + final boolean callerWaiting = phase != TIMEOUT_PHASE_DRAINING; + abandonUnresponsiveConnection(io); + settled = true; + if (callerWaiting) { + probe.onError(requestId, QwpConstants.STATUS_QUERY_TIMEOUT, queryTimeoutMessage(timeoutMs) + + "; the connection did not respond and was closed"); + } + return; + } + if (ev.kind != QueryEvent.KIND_BATCH) { + // Every other event ends the query on the connection, and the + // attempt returns once the switch below handles it. + settled = true; + } try { switch (ev.kind) { case QueryEvent.KIND_BATCH: try { - probe.onBatch(ev.buffer.batch); + if (phase == TIMEOUT_PHASE_RUNNING && timed && isPast(deadlineNanos)) { + // The deadline passed while this batch was queued, + // or while the handler worked on the previous one. + cancelledByUser = pendingCancel; + phase = TIMEOUT_PHASE_TIMED_OUT; + if (!serverEnforcesTimeout) { + io.requestCancel(requestId); + } + waitDeadlineNanos = deadlineNanos + graceNanos; + } + if (phase == TIMEOUT_PHASE_RUNNING) { + probe.onBatch(ev.buffer.batch); + } else { + // Still decoded by the I/O thread, which keeps the + // connection-scoped SYMBOL dict in step with the + // server; just never shown to the handler. + discardedBatch = true; + } } finally { io.releaseBuffer(ev.buffer); } break; case QueryEvent.KIND_END: - probe.onEnd(requestId, ev.totalRows); + if (phase == TIMEOUT_PHASE_RUNNING + || (phase == TIMEOUT_PHASE_TIMED_OUT && !discardedBatch)) { + // Past the deadline, a complete result that withheld + // nothing from the handler still counts as success. + probe.onEnd(requestId, ev.totalRows); + } else if (phase == TIMEOUT_PHASE_TIMED_OUT) { + probe.onError(requestId, QwpConstants.STATUS_QUERY_TIMEOUT, queryTimeoutMessage(timeoutMs)); + } return; case QueryEvent.KIND_EXEC_DONE: - probe.onExecDone(requestId, ev.opType, ev.rowsAffected); + if (phase != TIMEOUT_PHASE_DRAINING) { + // A statement that completed took effect; report it as + // done even when the reply came past the deadline. + probe.onExecDone(requestId, ev.opType, ev.rowsAffected); + } return; case QueryEvent.KIND_ERROR: - probe.onError(requestId, ev.errorStatus, ev.errorMessage); + if (phase == TIMEOUT_PHASE_TIMED_OUT + && ev.errorStatus == QwpConstants.STATUS_CANCELLED + && !cancelledByUser) { + // The server honoured the cancel the deadline sent. + probe.onError(requestId, QwpConstants.STATUS_QUERY_TIMEOUT, queryTimeoutMessage(timeoutMs)); + } else if (phase != TIMEOUT_PHASE_DRAINING) { + // Includes the server's own STATUS_QUERY_TIMEOUT report. + probe.onError(requestId, ev.errorStatus, ev.errorMessage); + } return; case QueryEvent.KIND_TRANSPORT_ERROR: - probe.markTransportFailure(requestId, ev.errorStatus, ev.errorMessage); + if (phase == TIMEOUT_PHASE_RUNNING) { + probe.markTransportFailure(requestId, ev.errorStatus, ev.errorMessage); + } else if (phase == TIMEOUT_PHASE_TIMED_OUT) { + // Past the deadline there is nothing left to fail over + // for. The I/O thread latched the failure, so the next + // execute() replaces the connection. + probe.onError(requestId, QwpConstants.STATUS_QUERY_TIMEOUT, queryTimeoutMessage(timeoutMs) + + "; the connection then failed: " + ev.errorMessage); + } return; default: - probe.onError(requestId, WebSocketResponse.STATUS_INTERNAL_ERROR, "unknown event kind " + ev.kind); + if (phase != TIMEOUT_PHASE_DRAINING) { + probe.onError(requestId, WebSocketResponse.STATUS_INTERNAL_ERROR, "unknown event kind " + ev.kind); + } return; } } finally { @@ -1773,9 +2288,23 @@ private void executeOnce(CharSequence sql, QwpBindSetter binds, FailoverProbeHan } } catch (InterruptedException ie) { Thread.currentThread().interrupt(); + // Before the caller hears about the interrupt: the I/O thread must be + // done with the request (see retractRequest). + if (submitted && !io.isRequestEncoded(requestId) && retractRequest(io, requestId)) { + settled = true; + } // Interrupt on the user thread is not a transport failure; surface directly. - probe.deliverFinal(requestId, "interrupted while waiting for server response"); + // A draining attempt has already given the handler its terminal callback. + if (phase != TIMEOUT_PHASE_DRAINING) { + probe.deliverFinal(requestId, "interrupted while waiting for server response"); + } } finally { + if (submitted && !settled) { + // The attempt ends while its query still runs: the handler threw, + // or the thread was interrupted. Cancel the query, so the leftovers + // the next attempt skips stop coming. + io.requestCancel(requestId); + } currentRequestId = -1L; } } @@ -1823,7 +2352,7 @@ private void probeZstdAvailable() { private QwpServerInfo receiveServerInfoSync() { ServerInfoReceiver receiver = new ServerInfoReceiver(); - long deadlineMs = System.currentTimeMillis() + serverInfoTimeoutMs; + long deadlineMs = System.currentTimeMillis() + boundToConnectDeadline(serverInfoTimeoutMs); while (receiver.info == null && receiver.decodeError == null && receiver.closeCode < 0 @@ -1847,6 +2376,35 @@ private QwpServerInfo receiveServerInfoSync() { return receiver.info; } + /** + * Retries, on behalf of the query about to run, a failover reconnect that an + * earlier {@code execute()} could not complete. On failure the handler gets + * the query's single terminal callback and the reconnect stays pending. + * + * @return {@code true} when the client is connected again + */ + private boolean reconnectForQuery(QwpColumnBatchHandler handler, boolean timed, long timeoutMs, long deadlineNanos) { + try { + reconnectViaTracker(timed, deadlineNanos); + return true; + } catch (QwpAuthFailedException authErr) { + reconnectPending = false; + handler.onError(-1L, WebSocketResponse.STATUS_INTERNAL_ERROR, + "auth failure during reconnect [host=" + authErr.getHost() + ':' + authErr.getPort() + + ", status=" + authErr.getStatusCode() + ']'); + } catch (RuntimeException e) { + if (timed && isPast(deadlineNanos)) { + handler.onError(-1L, QwpConstants.STATUS_QUERY_TIMEOUT, + queryTimeoutMessage(timeoutMs) + " while reconnecting [reconnect error: " + + e.getMessage() + ']'); + } else { + handler.onError(-1L, WebSocketResponse.STATUS_INTERNAL_ERROR, + "reconnect failed [reconnect error: " + e.getMessage() + ']'); + } + } + return false; + } + /** * Walks the endpoint list by tracker priority (HEALTHY → UNKNOWN → * TRANSIENT_REJECT → TRANSPORT_ERROR → TOPOLOGY_REJECT). The mid-stream @@ -1874,6 +2432,12 @@ private void reconnectViaTracker() { // as a per-endpoint transport error retried across every host. String authHeader = resolveAuthorizationHeader(); while (true) { + if (connectDeadlineActive && isPast(connectDeadlineNanos)) { + // The query this reconnect serves has run out of time. The walk + // resumes on the next execute() (see reconnectPending). + throw new HttpClientException("query timeout expired during the failover reconnect [lastError=" + + (lastError == null ? "" : lastError.getMessage()) + ']'); + } int i = hostTracker.pickNext(); if (i < 0) { if (!retriedAfterReset) { @@ -1918,6 +2482,7 @@ private void reconnectViaTracker() { hostTracker.recordSuccess(i); currentEndpointIndex = i; connected = true; + reconnectPending = false; return; } if (lastMismatch != null) { @@ -1930,6 +2495,23 @@ private void reconnectViaTracker() { + ", lastError=" + (lastError == null ? "" : lastError.getMessage()) + ']'); } + /** + * {@link #reconnectViaTracker()} on behalf of a query. When the query has a + * timeout ({@code timed}), every endpoint step -- TCP connect, upgrade, + * {@code SERVER_INFO} wait -- is bounded by the query's remaining budget and + * the walk stops once {@code deadlineNanos} passes, so a black-holed endpoint + * cannot hold the caller past the timeout. + */ + private void reconnectViaTracker(boolean timed, long deadlineNanos) { + connectDeadlineActive = timed; + connectDeadlineNanos = deadlineNanos; + try { + reconnectViaTracker(); + } finally { + connectDeadlineActive = false; + } + } + private String resolveAuthorizationHeader() { // With a token provider, query it once per connect()/reconnect (the caller resolves before the // endpoint walk) so a reconnect presents a freshly refreshed token; validateToken rejects a @@ -1958,14 +2540,70 @@ private String resolveAuthorizationHeader() { return authorizationHeader; } - private long resolveQueryFlags(boolean resetSymbolDict) { - if (!resetSymbolDict) { + private long resolveQueryFlags(boolean resetSymbolDict, boolean timed) { + if (!resetSymbolDict && !timed) { return 0L; } QwpServerInfo info = serverInfo; - return info != null && (info.getCapabilities() & QwpEgressMsgKind.CAP_QUERY_FLAGS) != 0 - ? QwpEgressMsgKind.QUERY_FLAG_RESET_DICT - : 0L; + if (info == null || (info.getCapabilities() & QwpEgressMsgKind.CAP_QUERY_FLAGS) == 0) { + return 0L; + } + long flags = 0L; + if (resetSymbolDict) { + flags |= QwpEgressMsgKind.QUERY_FLAG_RESET_DICT; + } + if (timed && (info.getCapabilities() & QwpEgressMsgKind.CAP_QUERY_TIMEOUT) != 0) { + flags |= QwpEgressMsgKind.QUERY_FLAG_TIMEOUT; + } + return flags; + } + + /** + * Makes sure the I/O thread no longer reads the request {@code requestId}, + * which an interrupted {@code execute()} stops waiting for before the I/O + * thread has encoded it. Until then the I/O thread reads the caller's SQL + * text, which the caller may change once it learns about the interrupt, and + * this client's request holder and bind buffer, which the next query reuses. + *

    + * A request the I/O thread has not picked up yet is withdrawn: it is never + * sent. One it has picked up is awaited -- encoding is CPU work, done + * microseconds after the pick-up -- without regard to interrupts. An I/O + * thread that is still not done after {@link #shutdownJoinMs} is stuck, and + * the connection is given up, as on a query timeout. + * + * @return {@code true} when the query does not run on the connection: it was + * withdrawn, or the connection was given up + */ + private boolean retractRequest(QwpEgressIoThread io, long requestId) { + if (io.withdrawRequest(requestId)) { + return true; + } + final Thread handle = ioThreadHandle; + // parkNanos() returns at once while the interrupt flag is set, so clear it + // for the wait, and restore it after. + boolean interrupted = Thread.interrupted(); + try { + final long deadlineNanos = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(shutdownJoinMs); + while (!io.isRequestEncoded(requestId)) { + if (handle == null || !handle.isAlive()) { + // A stopped I/O thread reads nothing more. + return true; + } + if (isPast(deadlineNanos)) { + LOG.warn("QwpQueryClient I/O thread did not finish sending an interrupted query within {}ms; " + + "closing the connection", shutdownJoinMs); + abandonConnection(io, "I/O thread stopped responding"); + return true; + } + LockSupport.parkNanos(REQUEST_ENCODED_POLL_NANOS); + interrupted |= Thread.interrupted(); + } + return false; + } finally { + if (interrupted) { + Thread.currentThread().interrupt(); + } + } } private void runUpgradeWithTimeout(Endpoint ep, String authHeader) { @@ -1977,7 +2615,7 @@ private void runUpgradeWithTimeout(Endpoint ep, String authHeader) { // as a transport error and moves on to the next endpoint. webSocketClient.connect(ep.host, ep.port); - int timeoutMs = (int) Math.min(authTimeoutMs, Integer.MAX_VALUE); + int timeoutMs = boundToConnectDeadline(authTimeoutMs); try { webSocketClient.upgrade(DEFAULT_ENDPOINT_PATH, timeoutMs, authHeader); } catch (HttpClientException ex) { @@ -2042,6 +2680,9 @@ private static final class Endpoint { */ private static final class FailoverProbeHandler implements QwpColumnBatchHandler { final QwpColumnBatchHandler delegate; + // Thrown by the handler while its query was still running; see + // onErrorWhileDraining(). + Throwable deferredFailure; String interceptedMessage; long interceptedRequestId = -1L; byte interceptedStatus; @@ -2120,6 +2761,36 @@ void markTransportFailure(long requestId, byte status, String message) { interceptedStatus = status; interceptedMessage = message; } + + /** + * Delivers the error that ends a query for the caller while the query is + * still running on the connection: the timeout reported at the end of the + * grace period, before the aborted query has drained. The connection + * carries one query at a time, so a throw from the handler must not cut + * that drain short -- the next query would read this query's remaining + * frames as its own. The throw is held instead, and + * {@link #rethrowDeferredFailure()} raises it once the attempt is over. + */ + void onErrorWhileDraining(long requestId, byte status, String message) { + try { + delegate.onError(requestId, status, message); + } catch (Throwable t) { + deferredFailure = t; + } + } + + /** + * Raises the handler failure held back by {@link #onErrorWhileDraining}, + * if any. Called once the attempt has drained its query, or given up on + * the connection. + */ + void rethrowDeferredFailure() { + Throwable t = deferredFailure; + if (t != null) { + deferredFailure = null; + QwpQueryClient.throwUnchecked(t); + } + } } /** diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpSpscQueue.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpSpscQueue.java index c5c292d32..f8fb6c3a7 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpSpscQueue.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpSpscQueue.java @@ -146,4 +146,47 @@ public T take() throws InterruptedException { consumerThread = null; } } + + /** + * Spin-then-park take bounded by an absolute {@link System#nanoTime()} + * deadline. Returns the next value, or {@code null} once the deadline has + * passed with the ring still empty. A deadline already in the past + * degenerates to a single {@link #poll()}: a value that is already + * available is still returned. Allocation-free, like {@link #take()}. + * + * @throws InterruptedException when the consumer thread was interrupted + * while waiting + */ + public T take(long deadlineNanos) throws InterruptedException { + T value = poll(); + if (value != null) { + return value; + } + if (deadlineNanos - System.nanoTime() <= 0) { + return null; + } + for (int i = 0; i < SPIN_ITERATIONS; i++) { + Compat.onSpinWait(); + if ((value = poll()) != null) { + return value; + } + } + // Same publish-then-re-poll handshake as take(); see the comment there. + consumerThread = Thread.currentThread(); + try { + while ((value = poll()) == null) { + if (Thread.interrupted()) { + throw new InterruptedException(); + } + final long remainingNanos = deadlineNanos - System.nanoTime(); + if (remainingNanos <= 0) { + return null; + } + LockSupport.parkNanos(remainingNanos); + } + return value; + } finally { + consumerThread = null; + } + } } diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/protocol/QwpConstants.java b/core/src/main/java/io/questdb/client/cutlass/qwp/protocol/QwpConstants.java index 074cf0e3f..31f7c0aba 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/protocol/QwpConstants.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/protocol/QwpConstants.java @@ -112,6 +112,16 @@ public final class QwpConstants { * the ingress {@code STATUS_*} namespace (0x00-0x09). */ public static final byte STATUS_LIMIT_EXCEEDED = 0x0B; + /** + * Status byte on a {@code QUERY_ERROR} frame: the query ran past its + * per-query timeout. Sent by a server that enforces the timeout carried on + * {@code QUERY_REQUEST} (see {@code QwpEgressMsgKind#CAP_QUERY_TIMEOUT}), and + * synthesized by the client when its own deadline for the query expires. + * The connection stays usable for the next query. Egress extension of the + * ingress {@code STATUS_*} namespace; the next free value after + * {@code STATUS_DICTIONARY_GAP} (0x0D). + */ + public static final byte STATUS_QUERY_TIMEOUT = 0x0E; /** * Column type: BINARY (length-prefixed opaque bytes). * Wire format: identical to VARCHAR — (N+1) x uint32 offsets + concatenated bytes. diff --git a/core/src/main/java/io/questdb/client/impl/ConfigSchema.java b/core/src/main/java/io/questdb/client/impl/ConfigSchema.java index c9529a13e..75c0821bc 100644 --- a/core/src/main/java/io/questdb/client/impl/ConfigSchema.java +++ b/core/src/main/java/io/questdb/client/impl/ConfigSchema.java @@ -102,6 +102,7 @@ public final class ConfigSchema { longRange("failover_max_duration_ms", Side.EGRESS, 0, OPEN_MAX, false, false); // >= 0 intRange("max_batch_rows", Side.EGRESS, 1, 1_048_576, false, false); // [1, 1048576] longRange("initial_credit", Side.EGRESS, 0, OPEN_MAX, false, false); // >= 0 + longRange("query_timeout_ms", Side.EGRESS, 0, OPEN_MAX, false, false); // >= 0; 0 = no timeout intRange("buffer_pool_size", Side.EGRESS, 1, OPEN_MAX, false, false); // >= 1 enumKey("compression", Side.EGRESS, "zstd", "raw", "auto"); intRange("compression_level", Side.EGRESS, 1, 22, false, false); // [1, 22] diff --git a/core/src/main/java/io/questdb/client/impl/QueryImpl.java b/core/src/main/java/io/questdb/client/impl/QueryImpl.java index 82088fa8b..c15130015 100644 --- a/core/src/main/java/io/questdb/client/impl/QueryImpl.java +++ b/core/src/main/java/io/questdb/client/impl/QueryImpl.java @@ -72,9 +72,26 @@ final class QueryImpl { private final QueryWorker worker; private final QwpBindSetter wireBinds = this::applyBinds; private final WrappingHandler wrappingHandler = new WrappingHandler(); + // Set when this lease cancels its current submission; cleared by submit(). + // applyBinds() re-applies it from inside execute(), so a cancel issued while + // the submission still waits for the worker is not lost. See requestCancel(). + private volatile boolean cancelRequested; private volatile boolean done = true; private volatile String resultMessage; private volatile byte resultStatus; + // Counts submit() calls. Written under doneLock; volatile so runOn() can read + // it without the lock. Lets runOn() tell its own submission from a later one, + // see signalUnexpected(long, Throwable). + private volatile long submissionSeq; + // Stamped by submit() for the worker thread: when the query was submitted and + // the timeout it runs under (0 = none), so runOn() can hand the client only + // the part of the budget that is left. Published by worker.dispatch(). + private long submitNanos; + private long submitTimeoutMillis; + // Query timeout set on this handle, in milliseconds: -1 = the pooled client's + // default (query_timeout_ms), 0 = none. Kept across submits like the other + // builder state; resetForBorrow() restores -1. + private long timeoutMillis = -1; private volatile Throwable unexpectedError; private QwpBindSetter userBinds; private QwpColumnBatchHandler userHandler; @@ -168,13 +185,31 @@ void close(long gen) { // is now uncertain -- a late RESULT_* for the abandoned query could // corrupt the next borrower's stream -- so it is discarded rather than // returned. The pool grows a fresh worker on the next borrow. + final long budgetMillis = worker.closeQueryTimeoutMillis(); + final long startNanos = System.nanoTime(); if (!done) { worker.cancelInFlight(gen); - if (!awaitDone(worker.closeQueryTimeoutMillis())) { + if (!awaitDone(budgetMillis)) { worker.discardFromPool(gen); return; } } + // done means the caller has its outcome, not that the worker is idle: + // after a query timeout the worker may still be draining the aborted + // query so that its connection stays reusable. It must not reach another + // borrower before that finishes, so wait for it within what is left of + // the same budget; one that does not finish is discarded as above. + final long remainingMillis = budgetMillis - (System.nanoTime() - startNanos) / 1_000_000L; + if (!worker.awaitIdle(remainingMillis)) { + worker.discardFromPool(gen); + return; + } + // A connection the client gave up on -- it stopped responding after a + // query timeout, or the server closed it -- must not go back to the pool. + if (worker.client().hasTerminalFailure()) { + worker.discardFromPool(gen); + return; + } worker.releaseToPool(gen); } @@ -213,6 +248,29 @@ void setSql(long gen, CharSequence sql) { sqlBuffer.put(sql); } + /** + * Records that the current submission was cancelled. Called by + * {@link QueryWorker#cancelInFlight()} under the pool lock, after the lease + * generation was validated, so a stale handle cannot mark a later borrower's + * query. Needed because {@code execute()} starts by clearing the client's + * cancel latch: a cancel that lands while the submission still waits for the + * worker -- for instance behind a timed-out query the worker is draining -- + * would otherwise be dropped. + */ + void requestCancel() { + cancelRequested = true; + } + + void setTimeout(long gen, long timeout, TimeUnit unit) { + checkLive(gen); + if (timeout < 0) { + throw new IllegalArgumentException("timeout must be >= 0"); + } + long millis = unit.toMillis(timeout); + // A positive timeout below one millisecond must not round down to "none". + this.timeoutMillis = millis == 0 && timeout > 0 ? 1 : millis; + } + void submit(long gen) { checkLive(gen); if (sqlBuffer.length() == 0) { @@ -224,11 +282,17 @@ void submit(long gen) { if (!done) { throw new IllegalStateException("a previous submit() is still in flight; await the Completion first"); } + // The timeout runs from here, so a dispatch that has to wait for the + // worker (still draining a previously timed-out query) counts against it. + submitTimeoutMillis = timeoutMillis >= 0 ? timeoutMillis : worker.client().getQueryTimeoutMs(); + submitNanos = System.nanoTime(); + cancelRequested = false; // Reset terminal state under the lock so a stale signal from a prior // run can't be observed by the upcoming await(). doneLock.lock(); try { done = false; + submissionSeq++; resultStatus = 0; resultMessage = null; unexpectedError = null; @@ -238,7 +302,19 @@ void submit(long gen) { worker.dispatch(this); } + private static String describe(Throwable t) { + String message = t.getMessage(); + return message != null ? message : t.getClass().getSimpleName(); + } + private void applyBinds(QwpBindValues binds) { + // Runs inside execute() after it cleared the client's cancel latch for + // this query, and before the request is sent: set the latch again if the + // lease cancelled the submission meanwhile, so the query goes out with + // its CANCEL right behind it. + if (cancelRequested) { + worker.client().cancel(); + } QwpBindSetter setter = userBinds; if (setter != null) { setter.apply(binds); @@ -311,6 +387,29 @@ private void signalDone(byte status, String message, Throwable unexpected) { } } + /** + * Signals an error that escaped the run of {@code submission}, unless a later + * {@link #submit} has taken that submission's place. A run can end with an + * exception after it signalled its outcome: when a handler throws on a query + * timeout, the client rethrows the exception only once the aborted query has + * drained, and the caller, already released, may have submitted again + * meanwhile. That exception must not become the newer submission's outcome. + * The check and the signal share {@code doneLock}, which {@code submit()} + * takes to start a submission. + */ + private void signalUnexpected(long submission, Throwable t) { + // getMessage() may be user code: run it before taking the lock submit() needs. + final String message = describe(t); + doneLock.lock(); + try { + if (submission == submissionSeq) { + signalDone((byte) 0, message, t); + } + } finally { + doneLock.unlock(); + } + } + private void throwIfFailed() { Throwable unexpected = unexpectedError; if (unexpected != null) { @@ -333,6 +432,8 @@ void resetForBorrow() { userBinds = null; userHandler = null; sqlBuffer.clear(); + timeoutMillis = -1; + cancelRequested = false; resultStatus = 0; resultMessage = null; unexpectedError = null; @@ -340,12 +441,30 @@ void resetForBorrow() { } void runOn(QwpQueryClient client) { + // The submission this run executes. No later submit() can happen before + // the run signals this one's outcome, so the value is stable until then. + final long submission = submissionSeq; + long timeoutArg = 0; + if (submitTimeoutMillis > 0) { + // Only what is left of the budget since submit(); at least 1ms so the + // query still runs under a timeout rather than without one. + long elapsedMillis = (System.nanoTime() - submitNanos) / 1_000_000L; + timeoutArg = Math.max(1L, submitTimeoutMillis - elapsedMillis); + } + // The grace period after an expired timeout is query_close_timeout_ms, + // read per query: the pool learns it only after prewarming its clients. + client.withQueryTimeoutGrace(worker.closeQueryTimeoutMillis()); // Pass the StringSink directly as a CharSequence -- the wire encoder // reads chars and writes UTF-8 bytes straight into the send buffer. - // sqlBuffer is stable for the duration of execute(): the calling - // worker thread is blocked here until a terminal event arrives, and - // sql(...) cannot be invoked again until done==true. - client.execute(sqlBuffer, wireBinds, wrappingHandler); + // sqlBuffer must stay stable until the request is encoded: the caller + // can call sql(...) again only once done==true, which execute() signals + // either at the terminal event or, after a query timeout, once the I/O + // thread has encoded the request (see QwpQueryClient.executeOnce). + try { + client.execute(sqlBuffer, wireBinds, wrappingHandler, false, timeoutArg); + } catch (Throwable t) { + signalUnexpected(submission, t); + } } /** @@ -353,7 +472,7 @@ void runOn(QwpQueryClient client) { * exception escaping {@code execute()} before any handler callback). */ void signalUnexpected(Throwable t) { - signalDone((byte) 0, t.getMessage() != null ? t.getMessage() : t.getClass().getSimpleName(), t); + signalDone((byte) 0, describe(t), t); } private final class WrappingHandler implements QwpColumnBatchHandler { diff --git a/core/src/main/java/io/questdb/client/impl/QueryLease.java b/core/src/main/java/io/questdb/client/impl/QueryLease.java index 4b07e9654..267d848a9 100644 --- a/core/src/main/java/io/questdb/client/impl/QueryLease.java +++ b/core/src/main/java/io/questdb/client/impl/QueryLease.java @@ -113,4 +113,10 @@ public Completion submit() { public QwpServerInfo serverInfo() { return impl.serverInfo(generation); } + + @Override + public Query timeout(long timeout, TimeUnit unit) { + impl.setTimeout(generation, timeout, unit); + return this; + } } diff --git a/core/src/main/java/io/questdb/client/impl/QueryWorker.java b/core/src/main/java/io/questdb/client/impl/QueryWorker.java index 568ac0dfe..2e9966a34 100644 --- a/core/src/main/java/io/questdb/client/impl/QueryWorker.java +++ b/core/src/main/java/io/questdb/client/impl/QueryWorker.java @@ -28,6 +28,7 @@ import io.questdb.client.QueryException; import io.questdb.client.cutlass.qwp.client.QwpQueryClient; +import java.util.concurrent.TimeUnit; import java.util.concurrent.locks.Condition; import java.util.concurrent.locks.ReentrantLock; @@ -51,6 +52,9 @@ public final class QueryWorker { static final long SHUTDOWN_JOIN_MILLIS = 5_000; private final QwpQueryClient client; private final long createdAtMillis; + // Signalled, under signalLock, whenever the worker becomes idle: a job's + // runOn() returned, or shutdown stranded the pending job. See awaitIdle(). + private final Condition idleCondition; private final QueryClientPool pool; private final QueryImpl query; private final Condition signalCondition; @@ -78,6 +82,10 @@ public final class QueryWorker { // thread observes the latest value without taking the pool lock. private volatile long generation; private volatile long idleSinceMillis; + // True while the dispatch thread runs a job's runOn(). Guarded by signalLock. + // Outlives the job's terminal callback: after a query timeout the caller is + // released while runOn() still drains the aborted query. + private boolean running; private volatile boolean shuttingDown; public QueryWorker(QwpQueryClient client, QueryClientPool pool, int slotIndex) { @@ -85,12 +93,43 @@ public QueryWorker(QwpQueryClient client, QueryClientPool pool, int slotIndex) { this.pool = pool; this.query = new QueryImpl(this); this.signalCondition = signalLock.newCondition(); + this.idleCondition = signalLock.newCondition(); this.thread = new Thread(this::runLoop, "questdb-query-worker-" + slotIndex); this.thread.setDaemon(true); this.createdAtMillis = System.currentTimeMillis(); this.idleSinceMillis = this.createdAtMillis; } + /** + * Waits up to {@code timeoutMillis} for this worker to become idle: no job + * running and none pending. Used by {@link QueryImpl#close(long)} before + * returning the worker to the pool, because a job's terminal callback can + * precede the end of its {@code runOn()} -- after a query timeout the + * client keeps draining the aborted query so its connection stays + * reusable. Returns {@code false} on timeout or interrupt; an interrupt + * re-raises the caller's flag, like {@link QueryImpl}'s close drain. + */ + boolean awaitIdle(long timeoutMillis) { + long remainingNanos = TimeUnit.MILLISECONDS.toNanos(Math.max(0L, timeoutMillis)); + signalLock.lock(); + try { + while (running || current != null) { + if (remainingNanos <= 0L) { + return false; + } + try { + remainingNanos = idleCondition.awaitNanos(remainingNanos); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } + } + return true; + } finally { + signalLock.unlock(); + } + } + long createdAtMillis() { return createdAtMillis; } @@ -139,6 +178,9 @@ void markIdleAt(long nowMillis) { * generation first. Lease code must use {@link #cancelInFlight(long)}. */ void cancelInFlight() { + // Remembered on the lease too: a submission still waiting for this worker + // (behind a timed-out query being drained) has not reached the client yet. + query.requestCancel(); try { client.cancel(); } catch (RuntimeException ignored) { @@ -317,6 +359,7 @@ private void runLoop() { // caller so its Completion.await() does not hang. QueryImpl stranded = current; current = null; + idleCondition.signalAll(); if (stranded != null) { stranded.signalUnexpected( new QueryException((byte) 0, "QuestDB handle is closed")); @@ -335,6 +378,7 @@ private void runLoop() { // already-consumed signal, and park the worker forever while // the user thread waits on a Completion that never fires. current = null; + running = true; } finally { signalLock.unlock(); } @@ -343,6 +387,13 @@ private void runLoop() { } catch (Throwable t) { q.signalUnexpected(t); } + signalLock.lock(); + try { + running = false; + idleCondition.signalAll(); + } finally { + signalLock.unlock(); + } // Test-only barrier: deterministically reproduce the busy-worker // shutdown-drop race (df6f7ca) at its exact site. Null in production. Runnable hook = busyWorkerTestHook; diff --git a/core/src/test/java/io/questdb/client/test/QueryTimeoutFacadeTest.java b/core/src/test/java/io/questdb/client/test/QueryTimeoutFacadeTest.java new file mode 100644 index 000000000..d034922e0 --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/QueryTimeoutFacadeTest.java @@ -0,0 +1,582 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + + +package io.questdb.client.test; + +import io.questdb.client.Completion; +import io.questdb.client.Query; +import io.questdb.client.QueryException; +import io.questdb.client.QuestDB; +import io.questdb.client.cutlass.qwp.client.QwpColumnBatch; +import io.questdb.client.cutlass.qwp.client.QwpColumnBatchHandler; +import io.questdb.client.cutlass.qwp.client.QwpEgressMsgKind; +import io.questdb.client.cutlass.qwp.protocol.QwpConstants; +import io.questdb.client.test.cutlass.qwp.websocket.TestWebSocketServer; +import io.questdb.client.test.tools.TestUtils; +import org.junit.Assert; +import org.junit.Test; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * {@link Query#timeout} through the {@link QuestDB} facade: a timed-out query is + * reported as a {@link QueryException} whose {@link QueryException#isTimeout()} + * holds, while the pooled, authenticated connection stays open and serves the + * following queries. A worker still draining a timed-out query is not handed to + * the next borrower before it is idle, and one whose connection stopped + * responding is replaced rather than reused. A handler that throws -- on the + * timeout, or mid-result -- fails only its own submission. + */ +public class QueryTimeoutFacadeTest { + + @Test(timeout = 30_000) + public void testCancelOfASubmissionQueuedBehindADrainIsNotLost() throws Exception { + TestUtils.assertMemoryLeak(() -> { + final long timeoutMs = 100; + final long graceMs = 1_000; + Script script = new Script(); + script.onQuery = (s, c, id, n) -> { + if (n == 1) { + // Past the grace period: the caller is released while the worker + // is still draining this query. + s.replyOnceLater(c, id, timeoutMs + graceMs + 600, + queryError(id, QwpConstants.STATUS_QUERY_TIMEOUT, "timeout, query aborted")); + } + // n == 2: held until cancelled + }; + script.onCancel = (s, c, id) -> s.replyOnce(c, id, + queryError(id, QwpConstants.STATUS_CANCELLED, "cancelled by client")); + try (TestWebSocketServer server = startServer(script, QwpEgressMsgKind.CAP_QUERY_FLAGS | QwpEgressMsgKind.CAP_QUERY_TIMEOUT); + QuestDB db = QuestDB.connect(config(server) + "query_close_timeout_ms=" + graceMs + ";"); + Query q = db.borrowQuery()) { + q.sql("SELECT slow()").handler(new NoopHandler()).timeout(timeoutMs, TimeUnit.MILLISECONDS); + assertTimesOut(q); + + // The worker is still draining the first query, so this submission + // waits for it -- and is cancelled while it waits. + Completion c = q.timeout(5, TimeUnit.SECONDS).submit(); + c.cancel(); + try { + c.await(); + Assert.fail("the cancelled query must not complete"); + } catch (QueryException e) { + Assert.assertEquals("the cancel must reach the queued query: " + e.getMessage(), + QwpConstants.STATUS_CANCELLED, e.getStatus()); + } + } finally { + script.close(); + } + }); + } + + @Test(timeout = 30_000) + public void testConfiguredDefaultAppliesAndHandleCanOverrideIt() throws Exception { + TestUtils.assertMemoryLeak(() -> { + Script script = new Script(); + // An older server: the client cancels at the deadline. Query 2 is the + // one run without a timeout, so it is allowed to take its time. + script.onQuery = (s, c, id, n) -> { + if (n == 2) { + s.sendLater(c, 300, resultEnd(id)); + } + }; + script.onCancel = (s, c, id) -> s.replyOnce(c, id, + queryError(id, QwpConstants.STATUS_CANCELLED, "cancelled by client")); + try (TestWebSocketServer server = startServer(script, QwpEgressMsgKind.CAP_QUERY_FLAGS); + QuestDB db = QuestDB.connect(config(server) + "query_timeout_ms=100;")) { + try (Query q = db.borrowQuery()) { + q.sql("SELECT slow()").handler(new NoopHandler()); + assertTimesOut(q); + + q.timeout(0, TimeUnit.MILLISECONDS); + q.submit().await(); // no timeout: completes after 300ms + } + // A fresh borrow starts from the configured default again. + try (Query q = db.borrowQuery()) { + q.sql("SELECT slow()").handler(new NoopHandler()); + assertTimesOut(q); + } + Assert.assertEquals("all queries must share one connection", 1, server.handshakeCount()); + } finally { + script.close(); + } + }); + } + + @Test(timeout = 30_000) + public void testThrowingBatchHandlerFailsOnlyItsOwnSubmission() throws Exception { + TestUtils.assertMemoryLeak(() -> { + Script script = new Script(); + script.onQuery = (s, c, id, n) -> { + if (n == 1) { + s.send(c, firstBatch(id)); + // The rest of the result arrives after the handler has thrown. + s.sendLater(c, 300, nextBatch(id)); + s.sendLater(c, 300, resultEnd(id)); + } else { + s.send(c, execDone(id)); + } + }; + try (TestWebSocketServer server = startServer(script, QwpEgressMsgKind.CAP_QUERY_FLAGS | QwpEgressMsgKind.CAP_QUERY_TIMEOUT); + QuestDB db = QuestDB.connect(config(server)); + Query q = db.borrowQuery()) { + final IllegalStateException failure = new IllegalStateException("handler failed"); + q.sql("SELECT s FROM t").handler(new QwpColumnBatchHandler() { + @Override + public void onBatch(QwpColumnBatch batch) { + throw failure; + } + + @Override + public void onEnd(long totalRows) { + } + + @Override + public void onError(byte status, String message) { + } + }); + try { + q.submit().await(); + Assert.fail("the submission whose handler threw must fail"); + } catch (QueryException e) { + Assert.assertSame("the handler's exception must be its own submission's outcome", + failure, e.getCause()); + } + + RecordingHandler next = new RecordingHandler(); + q.sql("INSERT INTO t VALUES (1)").handler(next); + assertOwnOutcome(q); + Assert.assertTrue("the next submission must get its own reply", next.execDone); + Assert.assertEquals("the abandoned query's rows must not reach the next submission", + 0, next.batches.get()); + Assert.assertEquals("the connection must be reused", 1, server.handshakeCount()); + } finally { + script.close(); + } + }); + } + + @Test(timeout = 30_000) + public void testThrowingHandlerLeavesTheNextSubmissionIntact() throws Exception { + TestUtils.assertMemoryLeak(() -> { + final long timeoutMs = 100; + final long graceMs = 1_000; + Script script = new Script(); + script.onQuery = (s, c, id, n) -> { + if (n == 1) { + // Ends the query halfway through the second grace period: the + // caller is released first, while the worker still drains it. + s.replyOnceLater(c, id, timeoutMs + graceMs + graceMs / 2, + queryError(id, QwpConstants.STATUS_QUERY_TIMEOUT, "timeout, query aborted")); + } else { + s.send(c, execDone(id)); + } + }; + try (TestWebSocketServer server = startServer(script, QwpEgressMsgKind.CAP_QUERY_FLAGS | QwpEgressMsgKind.CAP_QUERY_TIMEOUT); + QuestDB db = QuestDB.connect(config(server) + "query_close_timeout_ms=" + graceMs + ";")) { + try (Query q = db.borrowQuery()) { + q.sql("SELECT slow()").handler(new QwpColumnBatchHandler() { + @Override + public void onBatch(QwpColumnBatch batch) { + } + + @Override + public void onEnd(long totalRows) { + } + + @Override + public void onError(byte status, String message) { + throw new IllegalStateException("handler failed: " + message); + } + }).timeout(timeoutMs, TimeUnit.MILLISECONDS); + assertTimesOut(q); + + // Submitted while the worker still drains the timed-out query: it runs + // once the drain ends and must get its own outcome -- neither the + // aborted query's late reply nor the handler's failure. + q.sql("INSERT INTO t VALUES (1)").handler(new NoopHandler()).timeout(0, TimeUnit.MILLISECONDS); + assertOwnOutcome(q); + } + // The pooled connection is handed to the next borrower in step too. + try (Query q = db.borrowQuery()) { + q.sql("INSERT INTO t VALUES (2)").handler(new NoopHandler()); + assertOwnOutcome(q); + } + Assert.assertEquals("the drained connection must be reused", 1, server.handshakeCount()); + } finally { + script.close(); + } + }); + } + + @Test(timeout = 30_000) + public void testTimedOutQueryKeepsThePooledConnection() throws Exception { + TestUtils.assertMemoryLeak(() -> { + Script script = new Script(); + script.onQuery = (s, c, id, n) -> { + if (n == 1) { + s.sendLater(c, 150, queryError(id, QwpConstants.STATUS_QUERY_TIMEOUT, + "timeout, query aborted [runtime=150ms, timeout=100ms]")); + } else { + s.send(c, execDone(id)); + } + }; + try (TestWebSocketServer server = startServer(script, QwpEgressMsgKind.CAP_QUERY_FLAGS | QwpEgressMsgKind.CAP_QUERY_TIMEOUT); + QuestDB db = QuestDB.connect(config(server))) { + try (Query q = db.borrowQuery()) { + q.sql("SELECT slow()").handler(new NoopHandler()).timeout(100, TimeUnit.MILLISECONDS); + QueryException e = assertTimesOut(q); + Assert.assertTrue("the server's report must reach the caller: " + e.getMessage(), + e.getMessage().startsWith("timeout, query aborted")); + + // The same handle, and so the same connection, runs the next query. + q.sql("INSERT INTO t VALUES (1)").submit().await(); + } + try (Query q = db.borrowQuery()) { + q.sql("INSERT INTO t VALUES (2)").handler(new NoopHandler()).submit().await(); + } + Assert.assertEquals("the authenticated connection must be kept across the timeout", + 1, server.handshakeCount()); + } finally { + script.close(); + } + }); + } + + @Test(timeout = 30_000) + public void testWorkerDrainingATimedOutQueryIsReturnedOnlyWhenIdle() throws Exception { + TestUtils.assertMemoryLeak(() -> { + final long timeoutMs = 100; + final long graceMs = 1_000; + Script script = new Script(); + script.onQuery = (s, c, id, n) -> { + if (n == 1) { + // Past the grace period but within the second one: the caller is + // released first, the connection drains the reply afterwards. + s.sendLater(c, timeoutMs + graceMs + 400, + queryError(id, QwpConstants.STATUS_QUERY_TIMEOUT, "timeout, query aborted")); + } else { + s.send(c, execDone(id)); + } + }; + try (TestWebSocketServer server = startServer(script, QwpEgressMsgKind.CAP_QUERY_FLAGS | QwpEgressMsgKind.CAP_QUERY_TIMEOUT); + QuestDB db = QuestDB.connect(config(server) + "query_close_timeout_ms=" + graceMs + ";")) { + Query q = db.borrowQuery(); + q.sql("SELECT slow()").handler(new NoopHandler()).timeout(timeoutMs, TimeUnit.MILLISECONDS); + long start = System.nanoTime(); + assertTimesOut(q); + long awaitMs = elapsedMs(start); + Assert.assertTrue("the caller is released at the end of the grace period, after " + awaitMs + "ms", + awaitMs >= timeoutMs + graceMs); + Assert.assertEquals("the caller must not wait for the server's late reply", 0, script.sends.get()); + + q.close(); + Assert.assertEquals("close() must wait for the drain before returning the worker", + 1, script.sends.get()); + + try (Query next = db.borrowQuery()) { + next.sql("INSERT INTO t VALUES (1)").handler(new NoopHandler()).submit().await(); + } + Assert.assertEquals("the drained connection must be reused", 1, server.handshakeCount()); + } finally { + script.close(); + } + }); + } + + @Test(timeout = 30_000) + public void testWorkerWhoseConnectionStoppedRespondingIsReplaced() throws Exception { + TestUtils.assertMemoryLeak(() -> { + Script script = new Script(); + script.onQuery = (s, c, id, n) -> { + if (n > 1) { + s.send(c, execDone(id)); + } + // n == 1: never answered, not even after a CANCEL. + }; + // failover=off: the client cannot replace the connection by itself, so + // the next query succeeds only if the pool discarded the worker. + try (TestWebSocketServer server = startServer(script, QwpEgressMsgKind.CAP_QUERY_FLAGS | QwpEgressMsgKind.CAP_QUERY_TIMEOUT); + QuestDB db = QuestDB.connect(config(server) + "query_close_timeout_ms=200;failover=off;")) { + try (Query q = db.borrowQuery()) { + q.sql("SELECT slow()").handler(new NoopHandler()).timeout(100, TimeUnit.MILLISECONDS); + assertTimesOut(q); + } + try (Query q = db.borrowQuery()) { + q.sql("INSERT INTO t VALUES (1)").handler(new NoopHandler()).submit().await(); + } + Assert.assertEquals("the silent connection must be replaced by a new one", 2, server.handshakeCount()); + } finally { + script.close(); + } + }); + } + + private static void assertOwnOutcome(Query q) throws InterruptedException { + try { + q.submit().await(); + } catch (QueryException e) { + Assert.fail("the submission must get its own outcome, got status=" + e.getStatus() + + ", message=" + e.getMessage() + ", cause=" + e.getCause()); + } + } + + private static QueryException assertTimesOut(Query q) throws InterruptedException { + try { + q.submit().await(); + } catch (QueryException e) { + Assert.assertTrue("expected a query timeout, got status=" + e.getStatus() + ": " + e.getMessage(), + e.isTimeout()); + return e; + } + Assert.fail("the query must time out"); + return null; + } + + private static String config(TestWebSocketServer server) { + return "ws::addr=localhost:" + server.getPort() + + ";auth_timeout_ms=2000;sender_pool_min=0;query_pool_min=1;query_pool_max=1;"; + } + + private static long elapsedMs(long startNanos) { + return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNanos); + } + + private static byte[] execDone(long requestId) { + return frame(QwpEgressMsgKind.EXEC_DONE, requestId, new byte[]{0, 0}); // op_type, rows_affected + } + + /** + * RESULT_BATCH {@code batch_seq == 0}: the schema (one VARCHAR column) and a + * single row. + */ + private static byte[] firstBatch(long requestId) { + ByteArrayOutputStream body = new ByteArrayOutputStream(); + putVarint(body, 0); // batch_seq + putVarint(body, 0); // table name length + putVarint(body, 1); // row_count + putVarint(body, 1); // column_count + putVarint(body, 1); // column name length + body.write('s'); + body.write(QwpConstants.TYPE_VARCHAR); + putVarcharCell(body, "a"); + return frame(QwpEgressMsgKind.RESULT_BATCH, requestId, 1, body.toByteArray()); + } + + private static byte[] frame(byte msgKind, long requestId, byte[] body) { + return frame(msgKind, requestId, 0, body); + } + + private static byte[] frame(byte msgKind, long requestId, int tableCount, byte[] body) { + int payloadLen = 1 + 8 + body.length; + ByteBuffer bb = ByteBuffer.allocate(QwpConstants.HEADER_SIZE + payloadLen).order(ByteOrder.LITTLE_ENDIAN); + bb.putInt(QwpConstants.MAGIC_MESSAGE); + bb.put(QwpConstants.VERSION); + bb.put((byte) 0); // flags + bb.putShort((short) tableCount); + bb.putInt(payloadLen); + bb.put(msgKind); + bb.putLong(requestId); + bb.put(body); + return bb.array(); + } + + /** + * Continuation RESULT_BATCH ({@code batch_seq == 1}): one row against the + * schema of {@link #firstBatch}. + */ + private static byte[] nextBatch(long requestId) { + ByteArrayOutputStream body = new ByteArrayOutputStream(); + putVarint(body, 1); // batch_seq + putVarint(body, 0); // table name length + putVarint(body, 1); // row_count + putVarcharCell(body, "b"); + return frame(QwpEgressMsgKind.RESULT_BATCH, requestId, 1, body.toByteArray()); + } + + private static void putVarcharCell(ByteArrayOutputStream body, String value) { + byte[] bytes = value.getBytes(StandardCharsets.UTF_8); + body.write(0); // null_flag: no nulls + ByteBuffer offsets = ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN); + offsets.putInt(0).putInt(bytes.length); + body.write(offsets.array(), 0, 8); + body.write(bytes, 0, bytes.length); + } + + private static void putVarint(ByteArrayOutputStream out, long value) { + while ((value & ~0x7FL) != 0) { + out.write((int) ((value & 0x7F) | 0x80)); + value >>>= 7; + } + out.write((int) value); + } + + private static byte[] queryError(long requestId, byte status, String message) { + byte[] msg = message.getBytes(StandardCharsets.UTF_8); + ByteBuffer body = ByteBuffer.allocate(1 + 2 + msg.length).order(ByteOrder.LITTLE_ENDIAN); + body.put(status).putShort((short) msg.length).put(msg); + return frame(QwpEgressMsgKind.QUERY_ERROR, requestId, body.array()); + } + + private static byte[] resultEnd(long requestId) { + return frame(QwpEgressMsgKind.RESULT_END, requestId, new byte[]{0, 0}); // final_seq, total_rows + } + + private static TestWebSocketServer startServer(Script script, int capabilities) throws Exception { + TestWebSocketServer server = new TestWebSocketServer(script); + server.setSendServerInfo(true); + server.setCapabilities(capabilities); + server.start(); + Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); + return server; + } + + @FunctionalInterface + private interface OnCancel { + void run(Script script, TestWebSocketServer.ClientHandler client, long requestId); + } + + @FunctionalInterface + private interface OnQuery { + void run(Script script, TestWebSocketServer.ClientHandler client, long requestId, int queryNumber); + } + + private static final class NoopHandler implements QwpColumnBatchHandler { + @Override + public void onBatch(QwpColumnBatch batch) { + } + + @Override + public void onEnd(long totalRows) { + } + + @Override + public void onError(byte status, String message) { + } + } + + private static final class RecordingHandler implements QwpColumnBatchHandler { + final AtomicInteger batches = new AtomicInteger(); + volatile boolean execDone; + + @Override + public void onBatch(QwpColumnBatch batch) { + batches.incrementAndGet(); + } + + @Override + public void onEnd(long totalRows) { + } + + @Override + public void onError(byte status, String message) { + } + + @Override + public void onExecDone(short opType, long rowsAffected) { + execDone = true; + } + } + + /** + * Scripted egress server. Like a real one it sends one terminal frame per + * request, so a cancel of a query that already ended gets no reply. + */ + private static final class Script implements TestWebSocketServer.WebSocketServerHandler, AutoCloseable { + // Delayed sends that have started, for ordering assertions. Counted before + // the frame goes out, so a client that has received it sees the count. + final AtomicInteger sends = new AtomicInteger(); + private final Set answered = ConcurrentHashMap.newKeySet(); + private final AtomicInteger queryCount = new AtomicInteger(); + private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "scripted-egress-server"); + t.setDaemon(true); + return t; + }); + volatile OnCancel onCancel; + volatile OnQuery onQuery; + + @Override + public void close() { + scheduler.shutdownNow(); + } + + @Override + public void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] data) { + if (data.length < 9) { + return; + } + long requestId = ByteBuffer.wrap(data, 1, 8).order(ByteOrder.LITTLE_ENDIAN).getLong(); + if (data[0] == QwpEgressMsgKind.QUERY_REQUEST) { + OnQuery script = onQuery; + if (script != null) { + script.run(this, client, requestId, queryCount.incrementAndGet()); + } + } else if (data[0] == QwpEgressMsgKind.CANCEL) { + OnCancel script = onCancel; + if (script != null) { + script.run(this, client, requestId); + } + } + } + + void replyOnce(TestWebSocketServer.ClientHandler client, long requestId, byte[] frame) { + if (answered.add(requestId)) { + send(client, frame); + } + } + + void replyOnceLater(TestWebSocketServer.ClientHandler client, long requestId, long delayMs, byte[] frame) { + if (answered.add(requestId)) { + sendLater(client, delayMs, frame); + } + } + + void send(TestWebSocketServer.ClientHandler client, byte[] frame) { + try { + client.sendBinary(frame); + } catch (IOException ignore) { + // the client went away; the test's assertions report the outcome + } + } + + void sendLater(TestWebSocketServer.ClientHandler client, long delayMs, byte[] frame) { + scheduler.schedule(() -> { + sends.incrementAndGet(); + send(client, frame); + }, delayMs, TimeUnit.MILLISECONDS); + } + } +} diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpQueryClientQueryTimeoutTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpQueryClientQueryTimeoutTest.java new file mode 100644 index 000000000..3fc7a3698 --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpQueryClientQueryTimeoutTest.java @@ -0,0 +1,1361 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + + +package io.questdb.client.test.cutlass.qwp.client; + +import io.questdb.client.cutlass.qwp.client.QwpColumnBatch; +import io.questdb.client.cutlass.qwp.client.QwpColumnBatchHandler; +import io.questdb.client.cutlass.qwp.client.QwpEgressMsgKind; +import io.questdb.client.cutlass.qwp.client.QwpQueryClient; +import io.questdb.client.cutlass.qwp.client.QwpServerInfo; +import io.questdb.client.cutlass.qwp.client.WebSocketResponse; +import io.questdb.client.cutlass.qwp.protocol.QwpConstants; +import io.questdb.client.test.cutlass.qwp.websocket.TestWebSocketServer; +import io.questdb.client.test.tools.TestUtils; +import org.junit.Assert; +import org.junit.Test; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.lang.reflect.Field; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.net.SocketTimeoutException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Per-query timeout of {@link QwpQueryClient}, against a scripted mock server. + * Pins the wire contract (the capability-gated {@code timeout_ms} field after + * {@code query_flags}), the client-side deadline and its grace periods, and the + * central promise of the feature: a timed-out query ends gracefully -- reported + * as {@link QwpConstants#STATUS_QUERY_TIMEOUT} -- on a connection that stays + * open and authenticated for the next query. Only a connection that does not + * answer at all is replaced. The same holds for a query its caller abandons -- + * the handler throws, or the thread is interrupted -- before the query ends: the + * next query never sees the abandoned one's leftovers. + *

    + * The server-side enforcement (breaker timeout, status mapping) is covered + * against a live server in the questdb repository. + */ +public class QwpQueryClientQueryTimeoutTest { + + private static final int CAPS_FLAGS_ONLY = QwpEgressMsgKind.CAP_QUERY_FLAGS; + private static final int CAPS_WITH_TIMEOUT = QwpEgressMsgKind.CAP_QUERY_FLAGS | QwpEgressMsgKind.CAP_QUERY_TIMEOUT; + + @Test(timeout = 30_000) + public void testBatchesArrivingAfterTheDeadlineAreDiscarded() throws Exception { + TestUtils.assertMemoryLeak(() -> { + ScriptedServer script = new ScriptedServer(); + script.onQuery = (s, c, frame, n) -> { + long id = requestIdOf(frame); + if (n == 1) { + send(c, firstBatch(id, "a")); + s.sendLater(c, 400, nextBatch(id, 1, "b"), nextBatch(id, 2, "c"), + queryError(id, QwpConstants.STATUS_QUERY_TIMEOUT, "timeout, query aborted")); + } else { + send(c, firstBatch(id, "z")); + send(c, resultEnd(id, 1)); + } + }; + try (TestWebSocketServer server = startServer(script, CAPS_WITH_TIMEOUT); + QwpQueryClient client = connect(server, "")) { + RecordingHandler h = new RecordingHandler(); + client.execute("SELECT s FROM t", null, h, false, 150); + Assert.assertEquals("only the batch that arrived before the deadline reaches the handler", + 1, h.batches.get()); + h.assertTimedOut(); + + // The discarded batches were still decoded, so the connection-scoped + // state stays in step with the server and the next query decodes. + RecordingHandler next = new RecordingHandler(); + client.execute("SELECT s FROM t", null, next, false, 0); + Assert.assertTrue("the next query must complete on the same connection", next.ended); + Assert.assertEquals(1, next.batches.get()); + Assert.assertEquals("the connection must be reused, not replaced", 1, server.handshakeCount()); + } finally { + script.close(); + } + }); + } + + @Test(timeout = 30_000) + public void testCallerIsReleasedAfterTheGraceWhileTheConnectionDrains() throws Exception { + TestUtils.assertMemoryLeak(() -> { + final long timeoutMs = 100; + final long graceMs = 1_000; + ScriptedServer script = new ScriptedServer(); + script.onQuery = (s, c, frame, n) -> { + long id = requestIdOf(frame); + if (n == 1) { + // Ends the query well after the grace period, but well within the + // second one: the caller must not wait for it, the connection must. + s.sendLater(c, timeoutMs + graceMs + 400, + queryError(id, QwpConstants.STATUS_QUERY_TIMEOUT, "timeout, query aborted")); + } else { + send(c, execDone(id)); + } + }; + try (TestWebSocketServer server = startServer(script, CAPS_WITH_TIMEOUT); + QwpQueryClient client = connect(server, "")) { + client.withQueryTimeoutGrace(graceMs); + RecordingHandler h = new RecordingHandler(); + long start = System.nanoTime(); + client.execute("SELECT slow()", null, h, false, timeoutMs); + long executeMs = elapsedMs(start); + + h.assertTimedOut(); + Assert.assertTrue("the message must say the server did not end the query in time: " + h.errorMessage, + h.errorMessage.contains("grace period")); + long releasedMs = TimeUnit.NANOSECONDS.toMillis(h.terminalNanos - start); + Assert.assertTrue("the caller must be released only once the grace period is over, after " + + releasedMs + "ms", releasedMs >= timeoutMs + graceMs); + Assert.assertTrue("the caller must be released before the server's late reply was even sent", + h.terminalNanos < script.lastSendNanos); + Assert.assertTrue("execute() must return only after draining the server's reply, after " + + executeMs + "ms", executeMs >= timeoutMs + graceMs + 400); + Assert.assertEquals("one terminal callback per query", 1, h.terminals.get()); + Assert.assertNotNull("the end of the grace period must nudge the server with a CANCEL", + script.cancels.poll(5, TimeUnit.SECONDS)); + Assert.assertFalse("a drained connection is healthy", client.hasTerminalFailure()); + + RecordingHandler next = new RecordingHandler(); + client.execute("INSERT INTO t VALUES (1)", null, next, false, 0); + Assert.assertTrue(next.execDone); + Assert.assertEquals("the drained connection must be reused", 1, server.handshakeCount()); + } finally { + script.close(); + } + }); + } + + @Test(timeout = 30_000) + public void testCancelOfAQueryQueuedBehindLeftoversIsNotLost() throws Exception { + TestUtils.assertMemoryLeak(() -> { + final Set received = ConcurrentHashMap.newKeySet(); + ScriptedServer script = new ScriptedServer(); + script.onQuery = (s, c, frame, n) -> { + long id = requestIdOf(frame); + if (n == 1) { + send(c, firstBatch(id, "a")); + // The rest arrives after the handler has thrown. + s.sendLater(c, 300, nextBatch(id, 1, "b"), resultEnd(id, 2)); + } else { + // Held: only a CANCEL ends it. + received.add(id); + } + }; + // Like a real server, ignores a cancel of a query it has not received. + script.onCancel = (s, c, frame, n) -> { + long id = requestIdOf(frame); + if (received.contains(id)) { + s.replyOnce(c, id, 0, queryError(id, QwpConstants.STATUS_CANCELLED, "cancelled by client")); + } + }; + try (TestWebSocketServer server = startServer(script, CAPS_WITH_TIMEOUT); + QwpQueryClient client = connect(server, "")) { + client.withQueryTimeoutGrace(500); + try { + client.execute("SELECT s FROM t", null, failingOnBatch(new IllegalStateException("handler failed")), false, 0); + Assert.fail("the handler's exception must propagate out of execute()"); + } catch (IllegalStateException expected) { + // the first query is abandoned, its rest still on the way + } + + // Cancelled before it is sent: the I/O thread still works through the + // abandoned query, and must send the CANCEL only after this query. + RecordingHandler next = new RecordingHandler(); + client.execute("SELECT s FROM t", binds -> client.cancel(), next, false, 2_000); + Assert.assertEquals("the cancel must reach the server after its query, got message=" + next.errorMessage, + QwpConstants.STATUS_CANCELLED, next.errorStatus); + Assert.assertEquals("the abandoned query's rows must not reach the next query", 0, next.batches.get()); + } finally { + script.close(); + } + }); + } + + @Test(timeout = 30_000) + public void testCompleteResultJustPastTheDeadlineIsASuccess() throws Exception { + TestUtils.assertMemoryLeak(() -> { + ScriptedServer script = new ScriptedServer(); + script.onQuery = (s, c, frame, n) -> { + long id = requestIdOf(frame); + // No result batch is withheld from the handler, so a reply that lands + // after the client's deadline still reports the real outcome. + s.sendLater(c, 300, n == 1 ? resultEnd(id, 0) : execDone(id)); + }; + try (TestWebSocketServer server = startServer(script, CAPS_WITH_TIMEOUT); + QwpQueryClient client = connect(server, "")) { + RecordingHandler select = new RecordingHandler(); + client.execute("SELECT 1 WHERE false", null, select, false, 100); + Assert.assertTrue("an empty result past the deadline is still complete", select.ended); + Assert.assertEquals(0, select.errorStatus); + + RecordingHandler insert = new RecordingHandler(); + client.execute("INSERT INTO t VALUES (1)", null, insert, false, 100); + Assert.assertTrue("a statement that completed past the deadline took effect", insert.execDone); + Assert.assertEquals(0, insert.errorStatus); + } finally { + script.close(); + } + }); + } + + @Test(timeout = 30_000) + public void testFailoverReplayCarriesTheRemainingBudget() throws Exception { + TestUtils.assertMemoryLeak(() -> { + ScriptedServer script = new ScriptedServer(); + script.onQuery = (s, c, frame, n) -> { + if (n == 1) { + // A transport failure mid-query: the client fails over and replays. + s.closeLater(c, 200); + } else { + send(c, execDone(requestIdOf(frame))); + } + }; + try (TestWebSocketServer server = startServer(script, CAPS_WITH_TIMEOUT); + QwpQueryClient client = connect(server, + "failover_backoff_initial_ms=0;failover_backoff_max_ms=0;")) { + RecordingHandler h = new RecordingHandler(); + client.execute("INSERT INTO t VALUES (1)", null, h, false, 10_000); + Assert.assertTrue("the replay must complete", h.execDone); + Assert.assertEquals(1, h.failoverResets.get()); + + long firstTimeout = trailerOf(script.queries.take())[1]; + long replayTimeout = trailerOf(script.queries.take())[1]; + Assert.assertTrue("the first attempt carries about the whole budget: " + firstTimeout, + firstTimeout > 9_000 && firstTimeout <= 10_000); + Assert.assertTrue("the replay must carry only the remaining budget, not a fresh one: " + + replayTimeout, replayTimeout > 0 && replayTimeout <= firstTimeout - 150); + } finally { + script.close(); + } + }); + } + + @Test(timeout = 30_000) + public void testInterruptReleasesTheCallerOnlyOnceItsRequestIsEncoded() throws Exception { + TestUtils.assertMemoryLeak(() -> { + ScriptedServer script = new ScriptedServer(); + script.onQuery = (s, c, frame, n) -> send(c, execDone(requestIdOf(frame))); + try (TestWebSocketServer server = startServer(script, CAPS_WITH_TIMEOUT); + QwpQueryClient client = connect(server, "")) { + // The I/O thread picks the request up, and stops inside its SQL text. + CountDownLatch reading = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + CharSequence sql = new BlockingSql("INSERT INTO t VALUES (1)", reading, release); + RecordingHandler interrupted = new RecordingHandler(); + Thread caller = new Thread(() -> client.execute(sql, null, interrupted, false, 0)); + caller.start(); + try { + Assert.assertTrue("the I/O thread must start encoding the request", reading.await(5, TimeUnit.SECONDS)); + caller.interrupt(); + // The caller may change the SQL text once released, and the next + // query reuses the bind buffer: not before the I/O thread is done. + caller.join(300); + Assert.assertTrue("execute() must wait for the I/O thread to finish reading the request", + caller.isAlive()); + } finally { + release.countDown(); + } + caller.join(5_000); + Assert.assertFalse("execute() must return once the request is encoded", caller.isAlive()); + Assert.assertEquals(WebSocketResponse.STATUS_INTERNAL_ERROR, interrupted.errorStatus); + + RecordingHandler next = new RecordingHandler(); + client.execute("INSERT INTO t VALUES (2)", null, next, false, 0); + Assert.assertTrue("the next statement must get its own reply", next.execDone); + Assert.assertEquals("the connection must be reused, not replaced", 1, server.handshakeCount()); + } finally { + script.close(); + } + }); + } + + @Test(timeout = 30_000) + public void testInterruptWithAStuckIoThreadGivesUpTheConnection() throws Exception { + TestUtils.assertMemoryLeak(() -> { + ScriptedServer script = new ScriptedServer(); + script.onQuery = (s, c, frame, n) -> send(c, execDone(requestIdOf(frame))); + try (TestWebSocketServer server = startServer(script, CAPS_WITH_TIMEOUT); + QwpQueryClient client = connect(server, "")) { + final long shutdownJoinMs = 300; + setShutdownJoinMs(client, shutdownJoinMs); + // The I/O thread picks the request up, and stays inside its SQL text + // until it is interrupted: stuck. + CountDownLatch reading = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + CharSequence sql = new BlockingSql("INSERT INTO t VALUES (1)", reading, release); + RecordingHandler interrupted = new RecordingHandler(); + Thread caller = new Thread(() -> client.execute(sql, null, interrupted, false, 0)); + caller.start(); + long elapsed; + try { + Assert.assertTrue("the I/O thread must start encoding the request", reading.await(5, TimeUnit.SECONDS)); + long start = System.nanoTime(); + caller.interrupt(); + caller.join(10_000); + elapsed = elapsedMs(start); + } finally { + release.countDown(); + } + Assert.assertFalse("execute() must return", caller.isAlive()); + Assert.assertEquals(WebSocketResponse.STATUS_INTERNAL_ERROR, interrupted.errorStatus); + Assert.assertTrue("execute() must first wait for the I/O thread, returned after " + elapsed + "ms", + elapsed >= shutdownJoinMs); + Assert.assertTrue("a connection whose I/O thread is stuck must be given up", client.hasTerminalFailure()); + + // failover=on (the default) replaces the connection for the next query. + RecordingHandler next = new RecordingHandler(); + client.execute("INSERT INTO t VALUES (2)", null, next, false, 0); + Assert.assertTrue("the next statement must get its own reply", next.execDone); + Assert.assertEquals("a replacement connection must have been opened", 2, server.handshakeCount()); + } finally { + script.close(); + } + }); + } + + @Test(timeout = 30_000) + public void testInterruptedQueryDoesNotAnswerTheNextQuery() throws Exception { + TestUtils.assertMemoryLeak(() -> { + ScriptedServer script = new ScriptedServer(); + script.onQuery = (s, c, frame, n) -> { + long id = requestIdOf(frame); + if (n == 1) { + // Answers only after its caller has stopped waiting. + s.sendLater(c, 500, firstBatch(id, "late"), resultEnd(id, 1)); + } else { + send(c, execDone(id)); + } + }; + try (TestWebSocketServer server = startServer(script, CAPS_WITH_TIMEOUT); + QwpQueryClient client = connect(server, "")) { + RecordingHandler first = new RecordingHandler(); + Thread caller = new Thread(() -> client.execute("SELECT s FROM t", null, first, false, 0)); + caller.start(); + long firstId = requestIdOf(script.queries.take()); + caller.interrupt(); + caller.join(5_000); + Assert.assertFalse("execute() must return when its thread is interrupted", caller.isAlive()); + Assert.assertEquals(WebSocketResponse.STATUS_INTERNAL_ERROR, first.errorStatus); + byte[] cancel = script.cancels.poll(5, TimeUnit.SECONDS); + Assert.assertNotNull("the abandoned query must be cancelled", cancel); + Assert.assertEquals(firstId, requestIdOf(cancel)); + + RecordingHandler next = new RecordingHandler(); + client.execute("INSERT INTO t VALUES (1)", null, next, false, 0); + Assert.assertTrue("the next statement must get its own reply, got status=" + next.errorStatus + + ", message=" + next.errorMessage, next.execDone); + Assert.assertEquals("the abandoned query's rows must not reach the next statement", 0, next.batches.get()); + Assert.assertEquals("the connection must be reused, not replaced", 1, server.handshakeCount()); + } finally { + script.close(); + } + }); + } + + @Test(timeout = 30_000) + public void testInterruptedQueryNotYetSentIsWithdrawn() throws Exception { + TestUtils.assertMemoryLeak(() -> { + ScriptedServer script = new ScriptedServer(); + script.onQuery = (s, c, frame, n) -> { + long id = requestIdOf(frame); + if (n == 1) { + send(c, firstBatch(id, "a")); + // The rest arrives late; until then the I/O thread does not pick up + // the next request. + s.sendLater(c, 500, nextBatch(id, 1, "b"), resultEnd(id, 2)); + } else { + send(c, execDone(id)); + } + }; + try (TestWebSocketServer server = startServer(script, CAPS_WITH_TIMEOUT); + QwpQueryClient client = connect(server, "")) { + try { + client.execute("SELECT s FROM t", null, failingOnBatch(new IllegalStateException("handler failed")), false, 0); + Assert.fail("the handler's exception must propagate out of execute()"); + } catch (IllegalStateException expected) { + // the first query is abandoned, its rest still on the way + } + + // Queued behind the abandoned query's leftovers, and interrupted there. + RecordingHandler interrupted = new RecordingHandler(); + Thread caller = new Thread(() -> client.execute("INSERT INTO t VALUES (2)", null, interrupted, false, 0)); + caller.start(); + awaitParked(caller); + caller.interrupt(); + caller.join(5_000); + Assert.assertFalse("execute() must return when its thread is interrupted", caller.isAlive()); + Assert.assertEquals(WebSocketResponse.STATUS_INTERNAL_ERROR, interrupted.errorStatus); + + RecordingHandler next = new RecordingHandler(); + client.execute("INSERT INTO t VALUES (3)", null, next, false, 0); + Assert.assertTrue("the next statement must get its own reply", next.execDone); + RecordingHandler last = new RecordingHandler(); + client.execute("INSERT INTO t VALUES (4)", null, last, false, 0); + Assert.assertTrue("the last statement must get its own reply", last.execDone); + + // The interrupted statement, withdrawn before it was sent, never reaches + // the server; every other statement reaches it exactly once. + List received = new ArrayList<>(); + for (byte[] frame : script.queries) { + received.add(sqlOf(frame)); + } + Assert.assertEquals(Arrays.asList("SELECT s FROM t", "INSERT INTO t VALUES (3)", "INSERT INTO t VALUES (4)"), + received); + } finally { + script.close(); + } + }); + } + + @Test(timeout = 30_000) + public void testLeftoversOfAnAbandonedQueryDoNotPostponeTheNextTimeout() throws Exception { + TestUtils.assertMemoryLeak(() -> { + final long timeoutMs = 100; + final long graceMs = 200; + ScriptedServer script = new ScriptedServer(); + script.onQuery = (s, c, frame, n) -> { + long id = requestIdOf(frame); + if (n == 1) { + send(c, firstBatch(id, "a")); + // Streams on, ignoring the CANCEL, for 3 seconds: far past the + // next query's deadline and grace period. + for (int i = 1; i <= 150; i++) { + s.sendLater(c, 20L * i, nextBatch(id, i, "b")); + } + } else { + send(c, execDone(id)); + } + }; + try (TestWebSocketServer server = startServer(script, CAPS_WITH_TIMEOUT); + QwpQueryClient client = connect(server, "")) { + client.withQueryTimeoutGrace(graceMs); + try { + client.execute("SELECT s FROM t", null, failingOnBatch(new IllegalStateException("handler failed")), false, 0); + Assert.fail("the handler's exception must propagate out of execute()"); + } catch (IllegalStateException expected) { + // the first query is abandoned and keeps streaming + } + + RecordingHandler next = new RecordingHandler(); + long start = System.nanoTime(); + client.execute("INSERT INTO t VALUES (1)", null, next, false, timeoutMs); + long elapsed = elapsedMs(start); + + next.assertTimedOut(); + Assert.assertEquals("the abandoned query's rows must not reach the next statement", 0, next.batches.get()); + Assert.assertTrue("the leftovers must not postpone the timeout, took " + elapsed + "ms", + elapsed < 1_500); + Assert.assertTrue("a connection still busy with the abandoned query is given up", + client.hasTerminalFailure()); + } finally { + script.close(); + } + }); + } + + @Test(timeout = 30_000) + public void testNoTimeoutFieldWithoutTheCapabilityOrTimeout() throws Exception { + TestUtils.assertMemoryLeak(() -> { + ScriptedServer script = new ScriptedServer(); + script.onQuery = (s, c, frame, n) -> send(c, execDone(requestIdOf(frame))); + try (TestWebSocketServer server = startServer(script, CAPS_FLAGS_ONLY); + QwpQueryClient client = connect(server, "")) { + client.execute("SELECT 1", null, new RecordingHandler(), false, 30_000); + Assert.assertNull("a server without CAP_QUERY_TIMEOUT must get no timeout field", + trailerOf(script.queries.take())); + } finally { + script.close(); + } + + ScriptedServer script2 = new ScriptedServer(); + script2.onQuery = (s, c, frame, n) -> send(c, execDone(requestIdOf(frame))); + try (TestWebSocketServer server = startServer(script2, CAPS_WITH_TIMEOUT); + QwpQueryClient client = connect(server, "")) { + client.execute("SELECT 1", null, new RecordingHandler(), false, 0); + Assert.assertNull("a query without a timeout must send no timeout field", + trailerOf(script2.queries.take())); + } finally { + script2.close(); + } + }); + } + + @Test(timeout = 30_000) + public void testOlderServerIsCancelledAtTheDeadlineAndTheConnectionIsKept() throws Exception { + TestUtils.assertMemoryLeak(() -> { + ScriptedServer script = new ScriptedServer(); + script.onQuery = (s, c, frame, n) -> { + if (n > 1) { + send(c, execDone(requestIdOf(frame))); + } + // n == 1: hold the query; only a CANCEL ends it. + }; + script.onCancel = (s, c, frame, n) -> s.replyOnce(c, requestIdOf(frame), 0, + queryError(requestIdOf(frame), QwpConstants.STATUS_CANCELLED, "cancelled by client")); + try (TestWebSocketServer server = startServer(script, CAPS_FLAGS_ONLY); + QwpQueryClient client = connect(server, "")) { + RecordingHandler h = new RecordingHandler(); + long start = System.nanoTime(); + client.execute("SELECT slow()", null, h, false, 150); + long elapsed = elapsedMs(start); + + h.assertTimedOut(); + Assert.assertTrue("the timeout cannot fire early, fired after " + elapsed + "ms", elapsed >= 150); + Assert.assertTrue("the cancelled query must end promptly, took " + elapsed + "ms", + elapsed < QwpQueryClient.DEFAULT_QUERY_TIMEOUT_GRACE_MS); + byte[] cancel = script.cancels.poll(5, TimeUnit.SECONDS); + Assert.assertNotNull("an older server must be sent a CANCEL at the deadline", cancel); + Assert.assertEquals("the CANCEL must target the timed-out query", + requestIdOf(script.queries.take()), requestIdOf(cancel)); + Assert.assertFalse(client.hasTerminalFailure()); + + RecordingHandler next = new RecordingHandler(); + client.execute("INSERT INTO t VALUES (1)", null, next, false, 0); + Assert.assertTrue(next.execDone); + Assert.assertEquals("the connection must be reused, not replaced", 1, server.handshakeCount()); + } finally { + script.close(); + } + }); + } + + @Test(timeout = 30_000) + public void testReconnectWalkIsBoundedByTheQueryDeadline() throws Exception { + TestUtils.assertMemoryLeak(() -> { + ScriptedServer script = new ScriptedServer(); + script.onQuery = (s, c, frame, n) -> { + if (n == 1) { + send(c, closeFrame()); + } else { + send(c, execDone(requestIdOf(frame))); + } + }; + try (TestWebSocketServer server = startServer(script, CAPS_WITH_TIMEOUT); + BlackHole blackHole = new BlackHole()) { + // The failover prefers the never-tried black hole over the endpoint + // that just failed. It accepts TCP but never answers the upgrade, so + // without the query deadline the walk would wait out auth_timeout_ms. + try (QwpQueryClient client = QwpQueryClient.fromConfig("ws::addr=localhost:" + server.getPort() + + ",localhost:" + blackHole.port() + ";auth_timeout_ms=5000;" + + "failover_backoff_initial_ms=0;failover_backoff_max_ms=0;")) { + client.connect(); + RecordingHandler h = new RecordingHandler(); + long start = System.nanoTime(); + client.execute("INSERT INTO t VALUES (1)", null, h, false, 500); + long elapsed = elapsedMs(start); + + h.assertTimedOut(); + Assert.assertTrue("the reconnect must stop at the query deadline, took " + elapsed + "ms", + elapsed < 3_000); + Assert.assertEquals(1, blackHole.accepted()); + + // The cut-short reconnect left the client disconnected; the next + // query reconnects instead of failing with "not connected". + RecordingHandler next = new RecordingHandler(); + client.execute("INSERT INTO t VALUES (2)", null, next, false, 0); + Assert.assertTrue("the next query must reconnect and complete: " + next.errorMessage, next.execDone); + Assert.assertTrue(client.isConnected()); + } + } finally { + script.close(); + } + }); + } + + @Test(timeout = 30_000) + public void testServerReportedTimeoutKeepsTheConnectionOpen() throws Exception { + TestUtils.assertMemoryLeak(() -> { + ScriptedServer script = new ScriptedServer(); + script.onQuery = (s, c, frame, n) -> { + long id = requestIdOf(frame); + if (n == 1) { + s.sendLater(c, 150, queryError(id, QwpConstants.STATUS_QUERY_TIMEOUT, + "timeout, query aborted [runtime=150ms, timeout=100ms]")); + } else { + send(c, execDone(id)); + } + }; + try (TestWebSocketServer server = startServer(script, CAPS_WITH_TIMEOUT); + QwpQueryClient client = connect(server, "")) { + RecordingHandler h = new RecordingHandler(); + client.execute("SELECT slow()", null, h, false, 100); + + h.assertTimedOut(); + Assert.assertTrue("the server's own report must reach the handler: " + h.errorMessage, + h.errorMessage.startsWith("timeout, query aborted")); + Assert.assertTrue("the client must leave a server-enforced timeout to the server", + script.cancels.isEmpty()); + Assert.assertFalse(client.hasTerminalFailure()); + + RecordingHandler next = new RecordingHandler(); + client.execute("INSERT INTO t VALUES (1)", null, next, false, 0); + Assert.assertTrue(next.execDone); + Assert.assertEquals("the authenticated connection must be reused", 1, server.handshakeCount()); + } finally { + script.close(); + } + }); + } + + @Test(timeout = 30_000) + public void testThrowingBatchHandlerDoesNotLeakRowsIntoTheNextQuery() throws Exception { + TestUtils.assertMemoryLeak(() -> { + ScriptedServer script = new ScriptedServer(); + script.onQuery = (s, c, frame, n) -> { + long id = requestIdOf(frame); + if (n == 1) { + send(c, firstBatch(id, "a")); + // The rest of the result arrives after the handler has thrown. + s.sendLater(c, 300, nextBatch(id, 1, "b"), resultEnd(id, 2)); + } else { + send(c, execDone(id)); + } + }; + try (TestWebSocketServer server = startServer(script, CAPS_WITH_TIMEOUT); + QwpQueryClient client = connect(server, "")) { + final IllegalStateException failure = new IllegalStateException("handler failed"); + try { + client.execute("SELECT s FROM t", null, failingOnBatch(failure), false, 0); + Assert.fail("the handler's exception must propagate out of execute()"); + } catch (IllegalStateException e) { + Assert.assertSame(failure, e); + } + long firstId = requestIdOf(script.queries.take()); + byte[] cancel = script.cancels.poll(5, TimeUnit.SECONDS); + Assert.assertNotNull("the abandoned query must be cancelled", cancel); + Assert.assertEquals(firstId, requestIdOf(cancel)); + + RecordingHandler next = new RecordingHandler(); + client.execute("INSERT INTO t VALUES (1)", null, next, false, 0); + Assert.assertEquals("the abandoned query's rows must not reach the next statement", 0, next.batches.get()); + Assert.assertTrue("the next statement must get its own reply, got status=" + next.errorStatus + + ", message=" + next.errorMessage, next.execDone); + Assert.assertFalse(next.ended); + Assert.assertEquals("the connection must be reused, not replaced", 1, server.handshakeCount()); + } finally { + script.close(); + } + }); + } + + @Test(timeout = 30_000) + public void testThrowingHandlerIsRethrownWhenTheConnectionIsGivenUp() throws Exception { + TestUtils.assertMemoryLeak(() -> { + final long timeoutMs = 100; + final long graceMs = 200; + ScriptedServer script = new ScriptedServer(); + script.onQuery = (s, c, frame, n) -> { + if (n > 1) { + send(c, execDone(requestIdOf(frame))); + } + // n == 1: never answered, not even after a CANCEL. + }; + try (TestWebSocketServer server = startServer(script, CAPS_WITH_TIMEOUT); + QwpQueryClient client = connect(server, "")) { + client.withQueryTimeoutGrace(graceMs); + // An Error, not an exception: the handler's throwable must come back as is. + final Error failure = new Error("handler failed"); + QwpColumnBatchHandler throwing = new QwpColumnBatchHandler() { + @Override + public void onBatch(QwpColumnBatch batch) { + } + + @Override + public void onEnd(long totalRows) { + } + + @Override + public void onError(byte status, String message) { + throw failure; + } + }; + Throwable thrown = null; + long start = System.nanoTime(); + try { + client.execute("SELECT slow()", null, throwing, false, timeoutMs); + } catch (Throwable t) { + thrown = t; + } + long elapsed = elapsedMs(start); + + Assert.assertSame("the handler's throwable must propagate unwrapped", failure, thrown); + Assert.assertTrue("execute() must give up on the connection before rethrowing, took " + elapsed + "ms", + elapsed >= timeoutMs + 2 * graceMs); + Assert.assertTrue("a silent connection must be marked failed", client.hasTerminalFailure()); + + RecordingHandler next = new RecordingHandler(); + client.execute("INSERT INTO t VALUES (1)", null, next, false, 0); + Assert.assertTrue(next.execDone); + Assert.assertEquals("a replacement connection must have been opened", 2, server.handshakeCount()); + } finally { + script.close(); + } + }); + } + + @Test(timeout = 30_000) + public void testThrowingHandlerStillDrainsTheTimedOutQuery() throws Exception { + // The handler contract lets any callback throw and keeps the connection + // usable. At the end of the grace period onError fires while the aborted + // query is still running, so a throw there must not skip the drain: + // otherwise the aborted query's late reply answers the next query. + TestUtils.assertMemoryLeak(() -> { + final long timeoutMs = 100; + final long graceMs = 1_000; + ScriptedServer script = new ScriptedServer(); + script.onQuery = (s, c, frame, n) -> { + long id = requestIdOf(frame); + if (n == 1) { + // Ends the query halfway through the second grace period: after the + // handler is told about the timeout, before the connection is given up. + s.sendLater(c, timeoutMs + graceMs + graceMs / 2, + queryError(id, QwpConstants.STATUS_QUERY_TIMEOUT, "timeout, query aborted")); + } else { + send(c, execDone(id)); + } + }; + try (TestWebSocketServer server = startServer(script, CAPS_WITH_TIMEOUT); + QwpQueryClient client = connect(server, "")) { + client.withQueryTimeoutGrace(graceMs); + QwpColumnBatchHandler throwing = new QwpColumnBatchHandler() { + @Override + public void onBatch(QwpColumnBatch batch) { + } + + @Override + public void onEnd(long totalRows) { + } + + @Override + public void onError(byte status, String message) { + throw new IllegalStateException(message); + } + }; + try { + client.execute("SELECT slow()", null, throwing, false, timeoutMs); + Assert.fail("the handler's exception must propagate out of execute()"); + } catch (IllegalStateException e) { + Assert.assertTrue("the handler must throw at the end of the grace period, while the query " + + "is still running: " + e.getMessage(), e.getMessage().contains("grace period")); + } + + RecordingHandler next = new RecordingHandler(); + client.execute("INSERT INTO t VALUES (1)", null, next, false, 0); + Assert.assertTrue("the next statement must get its own reply, not the aborted query's: status=" + + next.errorStatus + ", message=" + next.errorMessage, next.execDone); + Assert.assertEquals("the drained connection must be reused", 1, server.handshakeCount()); + } finally { + script.close(); + } + }); + } + + @Test(timeout = 30_000) + public void testTimeoutFieldCarriesTheBudget() throws Exception { + TestUtils.assertMemoryLeak(() -> { + ScriptedServer script = new ScriptedServer(); + script.onQuery = (s, c, frame, n) -> send(c, execDone(requestIdOf(frame))); + try (TestWebSocketServer server = startServer(script, CAPS_WITH_TIMEOUT); + QwpQueryClient client = connect(server, "query_timeout_ms=20000;")) { + Assert.assertEquals(20_000, client.getQueryTimeoutMs()); + + client.execute("SELECT 1", null, new RecordingHandler(), false, 30_000); + long[] trailer = trailerOf(script.queries.take()); + Assert.assertNotNull(trailer); + Assert.assertEquals(QwpEgressMsgKind.QUERY_FLAG_TIMEOUT, trailer[0]); + Assert.assertTrue("timeout_ms must carry the budget left: " + trailer[1], + trailer[1] > 29_000 && trailer[1] <= 30_000); + + // The overloads without a timeout apply the configured default, and + // the field composes with the other query flags. + client.execute("SELECT 1", new RecordingHandler(), true); + trailer = trailerOf(script.queries.take()); + Assert.assertNotNull(trailer); + Assert.assertEquals(QwpEgressMsgKind.QUERY_FLAG_RESET_DICT | QwpEgressMsgKind.QUERY_FLAG_TIMEOUT, + trailer[0]); + Assert.assertTrue("the configured default must apply: " + trailer[1], + trailer[1] > 19_000 && trailer[1] <= 20_000); + } finally { + script.close(); + } + }); + } + + @Test(timeout = 30_000) + public void testTimeoutWhileFailingOverIsReportedAsTimeout() throws Exception { + TestUtils.assertMemoryLeak(() -> { + ScriptedServer script = new ScriptedServer(); + // Every attempt fails at the transport level. + script.onQuery = (s, c, frame, n) -> send(c, closeFrame()); + try (TestWebSocketServer server = startServer(script, CAPS_WITH_TIMEOUT); + QwpQueryClient client = connect(server, + "failover_max_attempts=1000000;failover_backoff_initial_ms=10;failover_backoff_max_ms=20;")) { + RecordingHandler h = new RecordingHandler(); + long start = System.nanoTime(); + client.execute("INSERT INTO t VALUES (1)", null, h, false, 300); + long elapsed = elapsedMs(start); + + h.assertTimedOut(); + Assert.assertTrue("failover must stop at the deadline, took " + elapsed + "ms", + elapsed >= 300 && elapsed < 3_000); + Assert.assertTrue("there must have been replays", script.queryCount.get() > 1); + } finally { + script.close(); + } + }); + } + + @Test(timeout = 30_000) + public void testUnresponsiveConnectionIsReplaced() throws Exception { + TestUtils.assertMemoryLeak(() -> { + ScriptedServer script = new ScriptedServer(); + script.onQuery = (s, c, frame, n) -> { + if (n > 1) { + send(c, execDone(requestIdOf(frame))); + } + // n == 1: never answered, not even after a CANCEL. + }; + try (TestWebSocketServer server = startServer(script, CAPS_WITH_TIMEOUT); + QwpQueryClient client = connect(server, "")) { + client.withQueryTimeoutGrace(200); + RecordingHandler h = new RecordingHandler(); + long start = System.nanoTime(); + client.execute("SELECT slow()", null, h, false, 100); + long elapsed = elapsedMs(start); + + h.assertTimedOut(); + Assert.assertEquals(1, h.terminals.get()); + Assert.assertTrue("execute() must give up after two grace periods, took " + elapsed + "ms", + elapsed >= 100 + 2 * 200 && elapsed < 5_000); + Assert.assertTrue("a silent connection must be marked failed", client.hasTerminalFailure()); + + // failover=on (the default) replaces the connection for the next query. + RecordingHandler next = new RecordingHandler(); + client.execute("INSERT INTO t VALUES (1)", null, next, false, 0); + Assert.assertTrue(next.execDone); + Assert.assertEquals("a replacement connection must have been opened", 2, server.handshakeCount()); + Assert.assertFalse(client.hasTerminalFailure()); + } finally { + script.close(); + } + }); + } + + @Test(timeout = 30_000) + public void testUnresponsiveConnectionWithFailoverOffReportsTheFailure() throws Exception { + TestUtils.assertMemoryLeak(() -> { + ScriptedServer script = new ScriptedServer(); + script.onQuery = (s, c, frame, n) -> { + // never answered + }; + try (TestWebSocketServer server = startServer(script, CAPS_WITH_TIMEOUT); + QwpQueryClient client = connect(server, "failover=off;")) { + client.withQueryTimeoutGrace(100); + RecordingHandler h = new RecordingHandler(); + client.execute("SELECT slow()", null, h, false, 100); + h.assertTimedOut(); + Assert.assertTrue(client.hasTerminalFailure()); + + RecordingHandler next = new RecordingHandler(); + client.execute("SELECT 1", null, next, false, 0); + Assert.assertEquals(WebSocketResponse.STATUS_INTERNAL_ERROR, next.errorStatus); + Assert.assertTrue(next.errorMessage, next.errorMessage.contains("stopped responding")); + } finally { + script.close(); + } + }); + } + + @Test(timeout = 30_000) + public void testUserCancelBeforeTheDeadlineIsReportedAsCancel() throws Exception { + TestUtils.assertMemoryLeak(() -> { + ScriptedServer script = new ScriptedServer(); + script.onQuery = (s, c, frame, n) -> { + // held until cancelled + }; + // The cancel is acknowledged only after the deadline has passed. + script.onCancel = (s, c, frame, n) -> s.replyOnce(c, requestIdOf(frame), 400, + queryError(requestIdOf(frame), QwpConstants.STATUS_CANCELLED, "cancelled by client")); + try (TestWebSocketServer server = startServer(script, CAPS_FLAGS_ONLY); + QwpQueryClient client = connect(server, "")) { + RecordingHandler h = new RecordingHandler(); + Thread canceller = new Thread(() -> { + try { + script.queries.take(); + client.cancel(); + } catch (InterruptedException ignore) { + Thread.currentThread().interrupt(); + } + }); + canceller.start(); + client.execute("SELECT slow()", null, h, false, 200); + canceller.join(); + + Assert.assertEquals("the user's cancel came first and must be reported as such: " + h.errorMessage, + QwpConstants.STATUS_CANCELLED, h.errorStatus); + } finally { + script.close(); + } + }); + } + + /** + * Waits until {@code thread} blocks, here: parked waiting for its query's + * events, with the request queued. + */ + private static void awaitParked(Thread thread) throws InterruptedException { + final long deadlineNanos = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (thread.getState() != Thread.State.WAITING && thread.getState() != Thread.State.TIMED_WAITING) { + Assert.assertTrue("the thread must block", System.nanoTime() - deadlineNanos < 0); + Thread.sleep(1); + } + } + + private static byte[] closeFrame() { + // Marker understood by ScriptedServer's send(): close the connection. + return new byte[0]; + } + + private static QwpQueryClient connect(TestWebSocketServer server, String extraConfig) { + QwpQueryClient client = QwpQueryClient.fromConfig( + "ws::addr=localhost:" + server.getPort() + ";auth_timeout_ms=2000;" + extraConfig); + try { + client.connect(); + } catch (RuntimeException e) { + client.close(); + throw e; + } + return client; + } + + private static long elapsedMs(long startNanos) { + return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNanos); + } + + private static byte[] execDone(long requestId) { + return serverFrame(QwpEgressMsgKind.EXEC_DONE, requestId, 0, new byte[]{0, 0}); // op_type, rows_affected + } + + private static QwpColumnBatchHandler failingOnBatch(RuntimeException failure) { + return new QwpColumnBatchHandler() { + @Override + public void onBatch(QwpColumnBatch batch) { + throw failure; + } + + @Override + public void onEnd(long totalRows) { + } + + @Override + public void onError(byte status, String message) { + } + }; + } + + /** + * RESULT_BATCH {@code batch_seq == 0}: the schema (one VARCHAR column) and a + * single row. + */ + private static byte[] firstBatch(long requestId, String value) { + ByteArrayOutputStream body = new ByteArrayOutputStream(); + putVarint(body, 0); // batch_seq + putVarint(body, 0); // table name length + putVarint(body, 1); // row_count + putVarint(body, 1); // column_count + putVarint(body, 1); // column name length + body.write('s'); + body.write(QwpConstants.TYPE_VARCHAR); + putVarcharCell(body, value); + return serverFrame(QwpEgressMsgKind.RESULT_BATCH, requestId, 1, body.toByteArray()); + } + + /** + * Continuation RESULT_BATCH ({@code batch_seq > 0}): rows only, against the + * schema of {@link #firstBatch}. + */ + private static byte[] nextBatch(long requestId, long batchSeq, String value) { + ByteArrayOutputStream body = new ByteArrayOutputStream(); + putVarint(body, batchSeq); + putVarint(body, 0); // table name length + putVarint(body, 1); // row_count + putVarcharCell(body, value); + return serverFrame(QwpEgressMsgKind.RESULT_BATCH, requestId, 1, body.toByteArray()); + } + + private static void putVarcharCell(ByteArrayOutputStream body, String value) { + byte[] bytes = value.getBytes(StandardCharsets.UTF_8); + body.write(0); // null_flag: no nulls + ByteBuffer offsets = ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN); + offsets.putInt(0).putInt(bytes.length); + body.write(offsets.array(), 0, 8); + body.write(bytes, 0, bytes.length); + } + + private static void putVarint(ByteArrayOutputStream out, long value) { + while ((value & ~0x7FL) != 0) { + out.write((int) ((value & 0x7F) | 0x80)); + value >>>= 7; + } + out.write((int) value); + } + + private static byte[] queryError(long requestId, byte status, String message) { + byte[] msg = message.getBytes(StandardCharsets.UTF_8); + ByteBuffer body = ByteBuffer.allocate(1 + 2 + msg.length).order(ByteOrder.LITTLE_ENDIAN); + body.put(status).putShort((short) msg.length).put(msg); + return serverFrame(QwpEgressMsgKind.QUERY_ERROR, requestId, 0, body.array()); + } + + private static long readVarint(byte[] buf, int[] pos) { + long value = 0; + int shift = 0; + while (true) { + byte b = buf[pos[0]++]; + value |= (long) (b & 0x7F) << shift; + if ((b & 0x80) == 0) { + return value; + } + shift += 7; + } + } + + private static long requestIdOf(byte[] clientFrame) { + return ByteBuffer.wrap(clientFrame, 1, 8).order(ByteOrder.LITTLE_ENDIAN).getLong(); + } + + private static byte[] resultEnd(long requestId, long totalRows) { + ByteArrayOutputStream body = new ByteArrayOutputStream(); + putVarint(body, 0); // final_seq + putVarint(body, totalRows); + return serverFrame(QwpEgressMsgKind.RESULT_END, requestId, 0, body.toByteArray()); + } + + private static void send(TestWebSocketServer.ClientHandler client, byte[] frame) { + try { + if (frame.length == 0) { + client.sendClose(1011, "scripted failure"); + } else { + client.sendBinary(frame); + } + } catch (IOException ignore) { + // the client went away; the test's assertions report the outcome + } + } + + private static byte[] serverFrame(byte msgKind, long requestId, int tableCount, byte[] body) { + int payloadLen = 1 + 8 + body.length; + ByteBuffer bb = ByteBuffer.allocate(QwpConstants.HEADER_SIZE + payloadLen).order(ByteOrder.LITTLE_ENDIAN); + bb.putInt(QwpConstants.MAGIC_MESSAGE); + bb.put(QwpConstants.VERSION); + bb.put((byte) 0); // flags + bb.putShort((short) tableCount); + bb.putInt(payloadLen); + bb.put(msgKind); + bb.putLong(requestId); + bb.put(body); + return bb.array(); + } + + private static void setShutdownJoinMs(QwpQueryClient client, long millis) throws Exception { + Field field = QwpQueryClient.class.getDeclaredField("shutdownJoinMs"); + field.setAccessible(true); + field.setLong(client, millis); + } + + /** + * The SQL text of a captured {@code QUERY_REQUEST}. + */ + private static String sqlOf(byte[] queryRequest) { + int[] p = {1 + 8}; + int len = (int) readVarint(queryRequest, p); + return new String(queryRequest, p[0], len, StandardCharsets.UTF_8); + } + + private static TestWebSocketServer startServer(ScriptedServer script, int capabilities) throws Exception { + TestWebSocketServer server = new TestWebSocketServer(script); + server.setSendServerInfo(true); + server.setCapabilities(capabilities); + server.start(); + Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); + return server; + } + + /** + * Decodes the trailer of a captured {@code QUERY_REQUEST}: {@code {query_flags, + * timeout_ms}}, with {@code timeout_ms == -1} when the timeout flag is not + * set, or {@code null} when the frame carries no trailer at all. Assumes no + * binds. + */ + private static long[] trailerOf(byte[] f) { + Assert.assertEquals(QwpEgressMsgKind.QUERY_REQUEST, f[0]); + int[] p = {1 + 8}; + long sqlLen = readVarint(f, p); + p[0] += (int) sqlLen; + readVarint(f, p); // initial_credit + Assert.assertEquals("test frames carry no binds", 0L, readVarint(f, p)); + if (p[0] >= f.length) { + return null; + } + long flags = readVarint(f, p); + long timeoutMs = (flags & QwpEgressMsgKind.QUERY_FLAG_TIMEOUT) != 0 ? readVarint(f, p) : -1L; + Assert.assertEquals("the trailer must end the frame", f.length, p[0]); + return new long[]{flags, timeoutMs}; + } + + @FunctionalInterface + private interface Script { + void run(ScriptedServer server, TestWebSocketServer.ClientHandler client, byte[] frame, int queryNumber); + } + + /** + * A TCP listener that accepts connections and never answers them -- an + * endpoint whose WebSocket upgrade hangs. + */ + private static final class BlackHole implements AutoCloseable { + private final List accepted = Collections.synchronizedList(new ArrayList<>()); + private final ServerSocket socket; + private final Thread thread; + private volatile boolean running = true; + + BlackHole() throws IOException { + socket = new ServerSocket(0, 50, InetAddress.getLoopbackAddress()); + socket.setSoTimeout(50); + thread = new Thread(() -> { + while (running) { + try { + accepted.add(socket.accept()); + } catch (SocketTimeoutException ignore) { + // poll the running flag + } catch (IOException e) { + return; + } + } + }, "black-hole-accept"); + thread.setDaemon(true); + thread.start(); + } + + int accepted() { + return accepted.size(); + } + + @Override + public void close() throws Exception { + running = false; + thread.join(5_000); + socket.close(); + synchronized (accepted) { + for (Socket s : accepted) { + s.close(); + } + } + } + + int port() { + return socket.getLocalPort(); + } + } + + /** + * SQL text that holds up whoever reads its first character -- the I/O + * thread, which reads it while encoding the request -- until released or + * interrupted. + */ + private static final class BlockingSql implements CharSequence { + private final CountDownLatch reading; + private final CountDownLatch release; + private final String text; + + BlockingSql(String text, CountDownLatch reading, CountDownLatch release) { + this.text = text; + this.reading = reading; + this.release = release; + } + + @Override + public char charAt(int index) { + if (index == 0) { + reading.countDown(); + try { + release.await(10, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + return text.charAt(index); + } + + @Override + public int length() { + return text.length(); + } + + @Override + public CharSequence subSequence(int start, int end) { + return text.subSequence(start, end); + } + + @Override + public String toString() { + return text; + } + } + + private static final class RecordingHandler implements QwpColumnBatchHandler { + final AtomicInteger batches = new AtomicInteger(); + final AtomicInteger failoverResets = new AtomicInteger(); + final AtomicInteger terminals = new AtomicInteger(); + volatile boolean ended; + volatile String errorMessage; + volatile byte errorStatus; + volatile boolean execDone; + volatile long terminalNanos; + + @Override + public void onBatch(QwpColumnBatch batch) { + batches.incrementAndGet(); + } + + @Override + public void onEnd(long totalRows) { + ended = true; + terminal(); + } + + @Override + public void onError(byte status, String message) { + errorStatus = status; + errorMessage = message; + terminal(); + } + + @Override + public void onExecDone(short opType, long rowsAffected) { + execDone = true; + terminal(); + } + + @Override + public void onFailoverReset(QwpServerInfo newNode) { + failoverResets.incrementAndGet(); + } + + void assertTimedOut() { + Assert.assertEquals("expected a query timeout, got status=" + errorStatus + ", message=" + errorMessage, + QwpConstants.STATUS_QUERY_TIMEOUT, errorStatus); + Assert.assertFalse("a timed-out query must not also complete", ended || execDone); + } + + private void terminal() { + terminalNanos = System.nanoTime(); + terminals.incrementAndGet(); + } + } + + /** + * Mock egress server scripted per test. Records every {@code QUERY_REQUEST} + * and {@code CANCEL}, and replies like a real server: one terminal frame per + * request, so a cancel of a query that already ended gets no reply. + */ + private static final class ScriptedServer implements TestWebSocketServer.WebSocketServerHandler, AutoCloseable { + final LinkedBlockingQueue cancels = new LinkedBlockingQueue<>(); + final LinkedBlockingQueue queries = new LinkedBlockingQueue<>(); + final AtomicInteger queryCount = new AtomicInteger(); + private final Set answered = ConcurrentHashMap.newKeySet(); + private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "scripted-egress-server"); + t.setDaemon(true); + return t; + }); + volatile long lastSendNanos; + volatile Script onCancel; + volatile Script onQuery; + + @Override + public void close() { + scheduler.shutdownNow(); + } + + @Override + public void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] data) { + if (data.length == 0) { + return; + } + if (data[0] == QwpEgressMsgKind.QUERY_REQUEST) { + int n = queryCount.incrementAndGet(); + queries.add(data); + Script script = onQuery; + if (script != null) { + script.run(this, client, data, n); + } + } else if (data[0] == QwpEgressMsgKind.CANCEL) { + cancels.add(data); + Script script = onCancel; + if (script != null) { + script.run(this, client, data, 0); + } + } + } + + void closeLater(TestWebSocketServer.ClientHandler client, long delayMs) { + sendLater(client, delayMs, closeFrame()); + } + + // Sends the terminal frame for requestId unless one was already sent. + void replyOnce(TestWebSocketServer.ClientHandler client, long requestId, long delayMs, byte[] frame) { + if (answered.add(requestId)) { + sendLater(client, delayMs, frame); + } + } + + void sendLater(TestWebSocketServer.ClientHandler client, long delayMs, byte[]... frames) { + scheduler.schedule(() -> { + lastSendNanos = System.nanoTime(); + for (byte[] frame : frames) { + send(client, frame); + } + }, delayMs, TimeUnit.MILLISECONDS); + } + } +} diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpSpscQueueTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpSpscQueueTest.java index 50a930b19..9bbab56d5 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpSpscQueueTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpSpscQueueTest.java @@ -209,6 +209,75 @@ public void testNoUnparkWhenConsumerNotParked() { Assert.assertNull(q.poll()); } + @Test + public void testTimedTakeReturnsNullAtTheDeadline() throws InterruptedException { + QwpSpscQueue q = new QwpSpscQueue<>(4); + long start = System.nanoTime(); + Assert.assertNull("an empty queue must yield null once the deadline passes", + q.take(start + TimeUnit.MILLISECONDS.toNanos(100))); + long elapsedMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start); + Assert.assertTrue("timed take must wait until the deadline, waited " + elapsedMs + " ms", elapsedMs >= 100); + // The queue stays fully usable afterwards. + Assert.assertTrue(q.offer("after")); + Assert.assertEquals("after", q.take(System.nanoTime() + TimeUnit.SECONDS.toNanos(5))); + } + + @Test + public void testTimedTakeWithPastDeadlineStillReturnsAvailableValue() throws InterruptedException { + QwpSpscQueue q = new QwpSpscQueue<>(4); + long past = System.nanoTime() - TimeUnit.SECONDS.toNanos(1); + Assert.assertNull("a past deadline on an empty queue must not block", q.take(past)); + Assert.assertTrue(q.offer("ready")); + Assert.assertEquals("a value already available is returned even past the deadline", "ready", q.take(past)); + } + + @Test + public void testTimedTakeWakesOnOfferBeforeTheDeadline() throws Exception { + QwpSpscQueue q = new QwpSpscQueue<>(4); + AtomicReference taken = new AtomicReference<>(); + CountDownLatch done = new CountDownLatch(1); + Thread consumer = new Thread(() -> { + try { + taken.set(q.take(System.nanoTime() + TimeUnit.SECONDS.toNanos(30))); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + done.countDown(); + } + }, "spsc-timed-consumer"); + consumer.start(); + // Past the spin window, so the consumer is parked in parkNanos. + Thread.sleep(100); + Assert.assertTrue(q.offer("delivered")); + Assert.assertTrue("the producer's offer must unpark a timed take", done.await(5, TimeUnit.SECONDS)); + Assert.assertEquals("delivered", taken.get()); + consumer.join(1_000); + } + + @Test + public void testTimedTakeInterruptedThrows() throws Exception { + QwpSpscQueue q = new QwpSpscQueue<>(4); + AtomicReference caught = new AtomicReference<>(); + CountDownLatch done = new CountDownLatch(1); + Thread consumer = new Thread(() -> { + try { + q.take(System.nanoTime() + TimeUnit.SECONDS.toNanos(30)); + caught.set(new AssertionError("expected InterruptedException")); + } catch (Throwable t) { + caught.set(t); + } finally { + done.countDown(); + } + }, "spsc-timed-interrupt"); + consumer.start(); + Thread.sleep(100); + consumer.interrupt(); + Assert.assertTrue(done.await(5, TimeUnit.SECONDS)); + Assert.assertTrue("expected InterruptedException, got " + caught.get(), + caught.get() instanceof InterruptedException); + consumer.join(1_000); + } + @Test public void testCapacityOneIsRoundedToOne() { // Edge case: requesting capacity 1 still yields a power-of-two ring (1). diff --git a/core/src/test/java/io/questdb/client/test/impl/QueryCloseDrainTest.java b/core/src/test/java/io/questdb/client/test/impl/QueryCloseDrainTest.java index 76a4adb71..228c2a9fd 100644 --- a/core/src/test/java/io/questdb/client/test/impl/QueryCloseDrainTest.java +++ b/core/src/test/java/io/questdb/client/test/impl/QueryCloseDrainTest.java @@ -34,6 +34,8 @@ import java.lang.reflect.Field; import java.lang.reflect.Method; import java.util.ArrayList; +import java.util.concurrent.locks.Condition; +import java.util.concurrent.locks.ReentrantLock; import java.util.function.Consumer; /** @@ -127,6 +129,65 @@ public void testCloseReturnsWorkerWhenAlreadyDrained() throws Exception { }); } + @Test(timeout = 30_000) + public void testCloseDiscardsWorkerThatStaysBusy() throws Exception { + TestUtils.assertMemoryLeak(() -> { + try (QueryClientPool pool = new QueryClientPool( + CFG, 0, 2, 1_000L, Long.MAX_VALUE, Long.MAX_VALUE, NO_CONNECT)) { + setCloseQueryTimeout(pool, 150L); + QueryWorker w = pool.acquire(); + long gen = generation(w); + // done stays true (the caller has its outcome), but the worker never + // finishes the job -- a drain that outlives the close budget. + setRunning(w, true); + + long startNanos = System.nanoTime(); + closeQuery(w, gen); + long elapsedMs = (System.nanoTime() - startNanos) / 1_000_000; + + Assert.assertTrue("close() must wait about the close budget, elapsed=" + elapsedMs, elapsedMs >= 120); + Assert.assertFalse("a worker that is still busy must be discarded, not returned to the pool", + allWorkers(pool).contains(w)); + } + }); + } + + @Test(timeout = 30_000) + public void testCloseWaitsForBusyWorkerToFinishBeforeReturningIt() throws Exception { + TestUtils.assertMemoryLeak(() -> { + try (QueryClientPool pool = new QueryClientPool( + CFG, 0, 2, 1_000L, Long.MAX_VALUE, Long.MAX_VALUE, NO_CONNECT)) { + setCloseQueryTimeout(pool, 5_000L); + QueryWorker w = pool.acquire(); + long gen = generation(w); + // done stays true, but the worker is still inside the job's runOn() -- + // as after a query timeout, when it drains the aborted query after + // the caller has been released. + setRunning(w, true); + Thread finisher = new Thread(() -> { + try { + Thread.sleep(200); + setRunning(w, false); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + finisher.start(); + + long startNanos = System.nanoTime(); + closeQuery(w, gen); + long elapsedMs = (System.nanoTime() - startNanos) / 1_000_000; + finisher.join(); + + Assert.assertTrue("close() must wait for the job to finish, elapsed=" + elapsedMs, elapsedMs >= 150); + Assert.assertTrue("a worker that finished in time must be returned to the pool", + allWorkers(pool).contains(w)); + QueryWorker again = pool.acquire(); + Assert.assertSame("the returned worker must be handed out again", w, again); + } + }); + } + @SuppressWarnings("unchecked") private static ArrayList allWorkers(QueryClientPool pool) throws Exception { Field f = QueryClientPool.class.getDeclaredField("all"); @@ -165,6 +226,25 @@ private static void setCloseQueryTimeout(QueryClientPool pool, long millis) thro f.setLong(pool, millis); } + // Sets the worker's running flag under its signalLock and wakes idle waiters, + // exactly as its run loop does around a job's runOn(). + private static void setRunning(QueryWorker w, boolean running) throws Exception { + Field lockF = QueryWorker.class.getDeclaredField("signalLock"); + Field runningF = QueryWorker.class.getDeclaredField("running"); + Field idleF = QueryWorker.class.getDeclaredField("idleCondition"); + lockF.setAccessible(true); + runningF.setAccessible(true); + idleF.setAccessible(true); + ReentrantLock lock = (ReentrantLock) lockF.get(w); + lock.lock(); + try { + runningF.setBoolean(w, running); + ((Condition) idleF.get(w)).signalAll(); + } finally { + lock.unlock(); + } + } + private static void setDone(QueryWorker w, boolean done) throws Exception { Object impl = queryImpl(w); Field doneF = impl.getClass().getDeclaredField("done"); diff --git a/core/src/test/java/io/questdb/client/test/impl/QueryImplResetTest.java b/core/src/test/java/io/questdb/client/test/impl/QueryImplResetTest.java index bedbba92f..5ce9841d9 100644 --- a/core/src/test/java/io/questdb/client/test/impl/QueryImplResetTest.java +++ b/core/src/test/java/io/questdb/client/test/impl/QueryImplResetTest.java @@ -71,10 +71,12 @@ public void testResetForBorrowClearsBuilderState() throws Exception { Field bindsF = queryImplClass.getDeclaredField("userBinds"); Field sqlBufF = queryImplClass.getDeclaredField("sqlBuffer"); Field doneF = queryImplClass.getDeclaredField("done"); + Field timeoutF = queryImplClass.getDeclaredField("timeoutMillis"); handlerF.setAccessible(true); bindsF.setAccessible(true); sqlBufF.setAccessible(true); doneF.setAccessible(true); + timeoutF.setAccessible(true); // Seed builder state as a prior borrow would have left it. handlerF.set(q, new NoopHandler()); @@ -82,6 +84,7 @@ public void testResetForBorrowClearsBuilderState() throws Exception { // no-op }); ((StringSink) sqlBufF.get(q)).put("SELECT 1"); + timeoutF.setLong(q, 0L); // a prior borrow disabled the timeout doneF.setBoolean(q, false); Method reset = queryImplClass.getDeclaredMethod("resetForBorrow"); @@ -97,6 +100,8 @@ public void testResetForBorrowClearsBuilderState() throws Exception { 0, sqlBuffer.length()); Assert.assertTrue("done must be true so the handle starts idle, not in flight", doneF.getBoolean(q)); + Assert.assertEquals("the query timeout must fall back to the configured default (-1)", + -1L, timeoutF.getLong(q)); }); } diff --git a/core/src/test/java/io/questdb/client/test/impl/QwpQueryClientConfigHonoredTest.java b/core/src/test/java/io/questdb/client/test/impl/QwpQueryClientConfigHonoredTest.java index 10b1e3c03..b8650afec 100644 --- a/core/src/test/java/io/questdb/client/test/impl/QwpQueryClientConfigHonoredTest.java +++ b/core/src/test/java/io/questdb/client/test/impl/QwpQueryClientConfigHonoredTest.java @@ -62,6 +62,7 @@ public void testEveryEgressKeyIsHonored() throws Exception { assertHonored("failover_max_duration_ms=56000", "failover_max_duration_ms", 56000L); assertHonored("max_batch_rows=512", "max_batch_rows", 512); assertHonored("initial_credit=65536", "initial_credit", 65536L); + assertHonored("query_timeout_ms=1500", "query_timeout_ms", 1500L); assertHonored("buffer_pool_size=3", "buffer_pool_size", 3); assertHonored("compression=zstd", "compression", "zstd"); assertHonored("compression_level=9", "compression_level", 9); diff --git a/examples/src/main/java/com/example/query/QueryCancellationExample.java b/examples/src/main/java/com/example/query/QueryCancellationExample.java index b9def4bb9..57bda2880 100644 --- a/examples/src/main/java/com/example/query/QueryCancellationExample.java +++ b/examples/src/main/java/com/example/query/QueryCancellationExample.java @@ -19,6 +19,9 @@ * blocking {@link Completion#await()} throws {@link QueryException} carrying * that status. If the query finished before the cancel landed, {@code await()} * returns normally -- {@code cancel()} is a no-op on an already-terminal query. + *

    + * To stop queries that run too long without driving the cancel yourself, give + * them a timeout instead; see {@link QueryTimeoutExample}. */ public class QueryCancellationExample { diff --git a/examples/src/main/java/com/example/query/QueryTimeoutExample.java b/examples/src/main/java/com/example/query/QueryTimeoutExample.java new file mode 100644 index 000000000..1408b8730 --- /dev/null +++ b/examples/src/main/java/com/example/query/QueryTimeoutExample.java @@ -0,0 +1,66 @@ +package com.example.query; + +import io.questdb.client.Query; +import io.questdb.client.QueryException; +import io.questdb.client.QuestDB; +import io.questdb.client.cutlass.qwp.client.QwpColumnBatch; +import io.questdb.client.cutlass.qwp.client.QwpColumnBatchHandler; + +import java.util.concurrent.TimeUnit; + +/** + * Giving a query a timeout. + *

    + * {@link Query#timeout(long, TimeUnit)} bounds the whole query, measured from + * {@code submit()}; {@code query_timeout_ms} in the connection string sets a + * default for every query. When the timeout expires the query is stopped -- + * by the server when it supports per-query timeouts, otherwise by a cancel the + * client sends -- and {@code await()} throws a {@link QueryException} whose + * {@link QueryException#isTimeout()} is {@code true}. The pooled connection + * stays open, so the same handle goes on to run the next query on it. + *

    + * Compare {@link QueryCancellationExample}: {@code await(timeout)} only bounds + * how long the caller waits, the query keeps running until cancelled. + */ +public class QueryTimeoutExample { + + public static void main(String[] args) throws InterruptedException { + // query_timeout_ms: default timeout for every query of this handle's pool. + try (QuestDB db = QuestDB.connect("ws::addr=localhost:9000;query_timeout_ms=30000;"); + Query q = db.borrowQuery()) { + + q.sql("SELECT * FROM trades ORDER BY price") + .handler(new PrintingHandler()) + // Tighter than the default, for this handle only. + .timeout(5, TimeUnit.SECONDS); + try { + q.submit().await(); + System.out.println("finished within the timeout"); + } catch (QueryException e) { + if (!e.isTimeout()) { + throw e; + } + System.out.println("timed out: " + e.getMessage()); + } + + // The connection survived the timeout; run the next query on it. + q.sql("SELECT count() FROM trades").timeout(0, TimeUnit.SECONDS).submit().await(); + } + } + + private static final class PrintingHandler implements QwpColumnBatchHandler { + @Override + public void onBatch(QwpColumnBatch batch) { + // Process rows... kept minimal here. + } + + @Override + public void onEnd(long totalRows) { + System.out.println("done: " + totalRows + " rows"); + } + + @Override + public void onError(byte status, String message) { + } + } +}