Skip to content

Commit 7948635

Browse files
committed
Bound Query.close() drain and fix the close() hang race
Query.close() used to drain an in-flight submit with an unbounded, uninterruptible wait: while (!done) doneCondition.awaitUninterruptibly(). Because the lease model makes close() mandatory (try-with-resources), ordinary code that bounded its own await(timeout) and gave up still hit this drain and could block the caller for the full remaining query duration when the server was slow to honor the cancel. Worse, when a QuestDB.close() raced an in-flight lease close() and the client I/O thread failed to join within shutdownJoinMs, QwpQueryClient.close() skipped closePool() -- the synthetic-terminal source -- so done was never set and the lease close() hung forever. Make the drain bounded and interruptible, and fail safe on timeout: - QueryImpl.close() now waits at most closeQueryTimeoutMillis via awaitNanos and aborts on interrupt (re-raising the flag). A caller is never pinned to the full query duration. - On timeout or interrupt the worker is discarded, not returned: its connection may still carry late RESULT_* frames for the abandoned query, which would corrupt the next borrower's stream. The pool grows a fresh worker on the next borrow. QueryClientPool.discard() mirrors the ingest side's discardBroken (closed/stale-generation guarded). - QueryWorker.shutdown() now interrupts the dispatch thread. takeEvent() (QwpSpscQueue.take) is interrupt-aware and executeOnce() turns the InterruptedException into a terminal -> signalDone, so a caller parked in close() is released even when the I/O thread is wedged and closePool() never runs. This closes the hang-forever race and makes QuestDB.close() more prompt. Add the query_close_timeout_ms pool knob (default 5000ms, symmetric with the ingest close_flush_timeout_millis) via QuestDBBuilder.queryCloseTimeoutMillis(long), wired through QuestDBImpl to the pool and guarded by PoolConfigHonoredTest's drift check. Update the Query.close() Javadoc to state the bounded/interruptible/discard contract. QueryCloseDrainTest covers the new behavior deterministically with a no-op connect hook: close() returns within the budget and discards a worker that does not drain, an interrupt aborts the drain promptly, and an already-drained worker is returned for reuse. The dispatch-thread interrupt's effect on a genuinely stuck execute() is verified by construction here; its end-to-end reproduction lives in the parent repo's concurrency tests.
1 parent d6bdbdf commit 7948635

9 files changed

Lines changed: 349 additions & 23 deletions

File tree

core/src/main/java/io/questdb/client/Query.java

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -64,14 +64,24 @@ public interface Query extends Closeable {
6464

6565
/**
6666
* Releases the leased pooled query client back to the pool. The caller
67-
* MUST call this (typically via try-with-resources). If a submit is still
68-
* in flight, {@code close()} cancels it and waits for the terminal event
69-
* before returning the client. A real disconnect only happens at
70-
* {@link QuestDB#close()}. Idempotent.
67+
* MUST call this (typically via try-with-resources). A real disconnect only
68+
* happens at {@link QuestDB#close()}. Idempotent.
69+
* <p>
70+
* If a submit is still in flight (the caller never awaited, or its
71+
* {@code await(timeout)} expired), {@code close()} cancels it and waits for
72+
* the terminal event so the client is idle before it returns to the pool.
73+
* That wait is bounded by {@code query_close_timeout_ms} (default 5000ms,
74+
* see {@link QuestDBBuilder#queryCloseTimeoutMillis(long)}) and is
75+
* interruptible -- interrupting the calling thread aborts it. If the query
76+
* does not drain within the budget, the client is discarded rather than
77+
* returned (its connection may carry late frames for the abandoned query),
78+
* and the pool grows a fresh one on the next borrow. {@code close()}
79+
* therefore never blocks the caller unbounded, even when the server is slow
80+
* to honor the cancel.
7181
* <p>
7282
* Must NOT be called from a result handler: handlers run on the worker
73-
* thread, so {@code close()} would block forever waiting for a terminal
74-
* event that only that thread can deliver. Calling it there throws
83+
* thread, so {@code close()} would block waiting for a terminal event that
84+
* only that thread can deliver. Calling it there throws
7585
* {@link IllegalStateException}. Use {@link #cancel()} (non-blocking) to
7686
* stop a query from inside a handler.
7787
*/

core/src/main/java/io/questdb/client/QuestDBBuilder.java

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ public final class QuestDBBuilder {
5858
static final long DEFAULT_MAX_LIFETIME_MILLIS = 30 * 60_000L;
5959
static final int DEFAULT_POOL_MAX = 4;
6060
static final int DEFAULT_POOL_MIN = 1;
61+
static final long DEFAULT_QUERY_CLOSE_TIMEOUT_MILLIS = 5_000;
6162

6263
// Every valid pool value is >= 0, so -1 unambiguously marks "not set
6364
// explicitly". The public pool setters are the only writers of these
@@ -73,6 +74,7 @@ public final class QuestDBBuilder {
7374
private String config;
7475
private long idleTimeoutMillis = UNSET;
7576
private long maxLifetimeMillis = UNSET;
77+
private long queryCloseTimeoutMillis = UNSET;
7678
private int queryPoolMax = UNSET;
7779
private int queryPoolMin = UNSET;
7880
private int senderPoolMax = UNSET;
@@ -94,6 +96,21 @@ public QuestDBBuilder acquireTimeoutMillis(long millis) {
9496
return this;
9597
}
9698

99+
/**
100+
* Maximum time {@link Query#close()} waits for an in-flight query to drain
101+
* (after issuing a cancel) before discarding the leased query client and
102+
* letting the pool grow a fresh one. Bounds the close of a handle whose
103+
* {@code submit()} is still running -- e.g. when the caller's own
104+
* {@code await(timeout)} expired and they gave up. Defaults to 5000ms.
105+
*/
106+
public QuestDBBuilder queryCloseTimeoutMillis(long millis) {
107+
if (millis < 0) {
108+
throw new IllegalArgumentException("queryCloseTimeoutMillis must be >= 0");
109+
}
110+
this.queryCloseTimeoutMillis = millis;
111+
return this;
112+
}
113+
97114
/**
98115
* Sets the async connection-event listener applied to every pooled ingest
99116
* {@link Sender}. The listener observes connect / disconnect / failover
@@ -174,6 +191,7 @@ public QuestDB build() {
174191
resolvePoolInt(queryPoolMin, "query_pool_min", view, lazyConnect ? 0 : DEFAULT_POOL_MIN, this::queryPoolMin);
175192
resolvePoolInt(queryPoolMax, "query_pool_max", view, DEFAULT_POOL_MAX, this::queryPoolMax);
176193
resolvePoolLong(acquireTimeoutMillis, "acquire_timeout_ms", view, DEFAULT_ACQUIRE_TIMEOUT_MILLIS, this::acquireTimeoutMillis);
194+
resolvePoolLong(queryCloseTimeoutMillis, "query_close_timeout_ms", view, DEFAULT_QUERY_CLOSE_TIMEOUT_MILLIS, this::queryCloseTimeoutMillis);
177195
resolvePoolLong(idleTimeoutMillis, "idle_timeout_ms", view, DEFAULT_IDLE_TIMEOUT_MILLIS, this::idleTimeoutMillis);
178196
resolvePoolLong(maxLifetimeMillis, "max_lifetime_ms", view, DEFAULT_MAX_LIFETIME_MILLIS, this::maxLifetimeMillis);
179197
resolvePoolLong(housekeeperIntervalMillis, "housekeeper_interval_ms", view, DEFAULT_HOUSEKEEPER_INTERVAL_MILLIS, this::housekeeperIntervalMillis);
@@ -189,6 +207,7 @@ public QuestDB build() {
189207
idleTimeoutMillis,
190208
maxLifetimeMillis,
191209
housekeeperIntervalMillis,
210+
queryCloseTimeoutMillis,
192211
errorHandler,
193212
connectionListener
194213
);
@@ -372,6 +391,7 @@ public java.util.Map<String, Object> poolConfigSnapshotForTest() {
372391
m.put("query_pool_min", queryPoolMin);
373392
m.put("query_pool_max", queryPoolMax);
374393
m.put("acquire_timeout_ms", acquireTimeoutMillis);
394+
m.put("query_close_timeout_ms", queryCloseTimeoutMillis);
375395
m.put("idle_timeout_ms", idleTimeoutMillis);
376396
m.put("max_lifetime_ms", maxLifetimeMillis);
377397
m.put("housekeeper_interval_ms", housekeeperIntervalMillis);

core/src/main/java/io/questdb/client/impl/ConfigSchema.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ public final class ConfigSchema {
109109
intRange("query_pool_min", Side.POOL, OPEN, OPEN_MAX, false, false);
110110
intRange("query_pool_max", Side.POOL, OPEN, OPEN_MAX, false, false);
111111
longRange("acquire_timeout_ms", Side.POOL, OPEN, OPEN_MAX, false, false);
112+
longRange("query_close_timeout_ms", Side.POOL, OPEN, OPEN_MAX, false, false);
112113
longRange("idle_timeout_ms", Side.POOL, OPEN, OPEN_MAX, false, false);
113114
longRange("max_lifetime_ms", Side.POOL, OPEN, OPEN_MAX, false, false);
114115
longRange("housekeeper_interval_ms", Side.POOL, OPEN, OPEN_MAX, false, false);

core/src/main/java/io/questdb/client/impl/QueryClientPool.java

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,12 @@
5050
*/
5151
public final class QueryClientPool implements AutoCloseable {
5252

53+
// Default upper bound, in milliseconds, on how long Query.close() waits for
54+
// an in-flight query to drain (after issuing a cancel) before discarding the
55+
// worker. Mirrors the ingest side's close_flush_timeout_millis default so a
56+
// close() can never block the caller unbounded. Tunable per pool via
57+
// closeQueryTimeoutMillis(long).
58+
static final long DEFAULT_CLOSE_QUERY_TIMEOUT_MILLIS = 5_000;
5359
private final long acquireTimeoutMillis;
5460
private final ArrayList<QueryWorker> all;
5561
private final ArrayDeque<QueryWorker> available;
@@ -76,6 +82,10 @@ public final class QueryClientPool implements AutoCloseable {
7682
private final AtomicInteger nextSlotIndex = new AtomicInteger();
7783
private final Condition workerReleased;
7884
private volatile boolean closed;
85+
// Upper bound on the Query.close() drain wait; see
86+
// DEFAULT_CLOSE_QUERY_TIMEOUT_MILLIS. Volatile because QuestDBImpl sets it
87+
// once at build time on a different thread than the borrowers that read it.
88+
private volatile long closeQueryTimeoutMillis = DEFAULT_CLOSE_QUERY_TIMEOUT_MILLIS;
7989
private int inFlightCreations;
8090

8191
public QueryClientPool(
@@ -330,6 +340,62 @@ void cancelIfCurrent(QueryWorker w, long gen) {
330340
}
331341
}
332342

343+
long closeQueryTimeoutMillis() {
344+
return closeQueryTimeoutMillis;
345+
}
346+
347+
void closeQueryTimeoutMillis(long millis) {
348+
this.closeQueryTimeoutMillis = millis;
349+
}
350+
351+
/**
352+
* Evicts a worker whose lease {@link QueryImpl#close(long)} could not drain
353+
* the in-flight query within {@link #closeQueryTimeoutMillis} (the cancel
354+
* was not honored in time, or the caller was interrupted). The worker's
355+
* connection is left in an unknown protocol state -- a late {@code RESULT_*}
356+
* frame for the abandoned query could corrupt the next borrower's stream --
357+
* so it must NOT return to the pool. Removes it from {@code all} (freeing
358+
* capacity for a fresh worker) and tears it down outside the lock via
359+
* {@link QueryWorker#shutdown()}, which interrupts the dispatch thread so a
360+
* stuck {@code execute()} returns promptly.
361+
* <p>
362+
* Bails when the pool is already closed: {@link #close()} owns the teardown
363+
* of every worker via its snapshot loop, so mutating {@code all} here would
364+
* race that iteration on a non-thread-safe {@code ArrayList}. Also bails on a
365+
* stale generation -- the worker was already released/discarded and possibly
366+
* re-borrowed, so discarding it would evict a worker a different borrower now
367+
* owns. Mirrors {@link SenderPool#discardBroken} on the ingest side.
368+
*/
369+
void discard(QueryWorker w, long gen) {
370+
lock.lock();
371+
try {
372+
if (closed) {
373+
return;
374+
}
375+
if (w.generation() != gen) {
376+
return;
377+
}
378+
// Invalidate the lease so a duplicate close()/release with the same
379+
// generation is dropped and the in-flight handle can no longer drive
380+
// this worker.
381+
w.bumpGeneration();
382+
all.remove(w);
383+
// Capacity freed -- a waiter in acquire() may now create a fresh
384+
// worker in this slot's place.
385+
workerReleased.signal();
386+
} finally {
387+
lock.unlock();
388+
}
389+
// Tear down outside the lock so a slow join doesn't keep the pool
390+
// latched. shutdown() is best-effort and idempotent.
391+
try {
392+
w.shutdown();
393+
} catch (Throwable ignored) {
394+
// Best-effort: a teardown Error (e.g. an -ea AssertionError) must
395+
// not propagate out of Query.close().
396+
}
397+
}
398+
333399
void reapIdle() {
334400
if (closed) {
335401
return;

core/src/main/java/io/questdb/client/impl/QueryImpl.java

Lines changed: 44 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -155,19 +155,24 @@ void close(long gen) {
155155
if (gen != worker.generation()) {
156156
return;
157157
}
158-
// If a submit is still in flight (the caller did not await), cancel it
159-
// and wait for the terminal event so the leased worker is idle before
160-
// it returns to the pool -- otherwise the next borrower would inherit a
161-
// running execute().
158+
// If a submit is still in flight (the caller did not await, or its
159+
// await timed out), cancel it and wait for the terminal event so the
160+
// leased worker is idle before it returns to the pool -- otherwise the
161+
// next borrower would inherit a running execute().
162+
//
163+
// The wait is bounded (closeQueryTimeoutMillis) and interruptible, so a
164+
// caller that bounded its own await() is never pinned to the full
165+
// remaining query duration here. If the query does NOT drain in time (a
166+
// server slow to honor the cancel, or the caller interrupting), the
167+
// worker is still running execute() on a connection whose protocol state
168+
// is now uncertain -- a late RESULT_* for the abandoned query could
169+
// corrupt the next borrower's stream -- so it is discarded rather than
170+
// returned. The pool grows a fresh worker on the next borrow.
162171
if (!done) {
163172
worker.cancelInFlight(gen);
164-
doneLock.lock();
165-
try {
166-
while (!done) {
167-
doneCondition.awaitUninterruptibly();
168-
}
169-
} finally {
170-
doneLock.unlock();
173+
if (!awaitDone(worker.closeQueryTimeoutMillis())) {
174+
worker.discardFromPool(gen);
175+
return;
171176
}
172177
}
173178
worker.releaseToPool(gen);
@@ -226,6 +231,34 @@ private void applyBinds(QwpBindValues binds) {
226231
}
227232
}
228233

234+
/**
235+
* Waits up to {@code timeoutMillis} for the in-flight query's terminal
236+
* event. Returns {@code true} once {@code done} is set, {@code false} on
237+
* timeout or interrupt. Unlike an uninterruptible drain, an interrupt aborts
238+
* the wait and re-raises the thread's interrupt flag, so {@code close()}
239+
* stays responsive to a caller that wants to give up.
240+
*/
241+
private boolean awaitDone(long timeoutMillis) {
242+
long remaining = TimeUnit.MILLISECONDS.toNanos(timeoutMillis);
243+
doneLock.lock();
244+
try {
245+
while (!done) {
246+
if (remaining <= 0) {
247+
return false;
248+
}
249+
try {
250+
remaining = doneCondition.awaitNanos(remaining);
251+
} catch (InterruptedException e) {
252+
Thread.currentThread().interrupt();
253+
return false;
254+
}
255+
}
256+
return true;
257+
} finally {
258+
doneLock.unlock();
259+
}
260+
}
261+
229262
private void checkLive(long gen) {
230263
if (gen != worker.generation()) {
231264
throw new IllegalStateException("query handle is not borrowed (closed or never leased)");

core/src/main/java/io/questdb/client/impl/QueryWorker.java

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,21 @@ Query lease() {
170170
return new QueryLease(query, generation);
171171
}
172172

173+
long closeQueryTimeoutMillis() {
174+
return pool.closeQueryTimeoutMillis();
175+
}
176+
177+
/**
178+
* Discards this worker from the pool instead of returning it. Called by
179+
* {@link QueryImpl#close(long)} when the in-flight query could not be
180+
* drained within the close budget, leaving the connection in an unknown
181+
* protocol state. The captured lease {@code gen} lets the pool reject a
182+
* stale discard whose worker has already been re-borrowed.
183+
*/
184+
void discardFromPool(long gen) {
185+
pool.discard(this, gen);
186+
}
187+
173188
/**
174189
* Returns this worker to the pool. Called by {@link QueryImpl#close(long)}
175190
* when the borrowed lease is released; the captured lease {@code gen} lets
@@ -188,10 +203,19 @@ void shutdown() {
188203
signalLock.unlock();
189204
}
190205
try {
191-
// If a query is in flight on this worker, ask the client to abort so
192-
// execute() returns promptly and the thread can exit before join
193-
// times out. cancel() is documented as thread-safe and is a no-op
194-
// when idle.
206+
// If a query is in flight on this worker, force execute() to return
207+
// promptly so the dispatch thread exits before the join below times
208+
// out. Two nudges, strongest first:
209+
// 1. Interrupt the dispatch thread. takeEvent() (QwpSpscQueue.take)
210+
// is interrupt-aware, and executeOnce() turns the resulting
211+
// InterruptedException into a terminal event -> signalDone. This
212+
// releases a caller parked in Query.close() even when the I/O
213+
// thread is wedged and client.close()'s synthetic terminal
214+
// (closePool()) never runs -- the race that would otherwise
215+
// strand the caller forever.
216+
// 2. Ask the client to cancel on the wire so the server stops work.
217+
// Best-effort and a no-op when idle.
218+
thread.interrupt();
195219
try {
196220
client.cancel();
197221
} catch (Throwable ignored) {

core/src/main/java/io/questdb/client/impl/QuestDBImpl.java

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,12 +62,13 @@ public QuestDBImpl(
6262
long idleTimeoutMillis,
6363
long maxLifetimeMillis,
6464
long housekeeperIntervalMillis,
65+
long queryCloseTimeoutMillis,
6566
SenderErrorHandler errorHandler,
6667
SenderConnectionListener connectionListener
6768
) {
6869
this(ingestConfig, queryConfig, senderMin, senderMax, queryMin, queryMax,
6970
acquireTimeoutMillis, idleTimeoutMillis, maxLifetimeMillis,
70-
housekeeperIntervalMillis, null, null, errorHandler, connectionListener);
71+
housekeeperIntervalMillis, queryCloseTimeoutMillis, null, null, errorHandler, connectionListener);
7172
}
7273

7374
// Test-only constructor exposing the senderFactory and connectHook seams:
@@ -93,7 +94,8 @@ public QuestDBImpl(
9394
) {
9495
this(ingestConfig, queryConfig, senderMin, senderMax, queryMin, queryMax,
9596
acquireTimeoutMillis, idleTimeoutMillis, maxLifetimeMillis,
96-
housekeeperIntervalMillis, senderFactory, connectHook, null, null);
97+
housekeeperIntervalMillis, QueryClientPool.DEFAULT_CLOSE_QUERY_TIMEOUT_MILLIS,
98+
senderFactory, connectHook, null, null);
9799
}
98100

99101
// Full constructor adding the ingest-side errorHandler/connectionListener,
@@ -111,6 +113,7 @@ public QuestDBImpl(
111113
long idleTimeoutMillis,
112114
long maxLifetimeMillis,
113115
long housekeeperIntervalMillis,
116+
long queryCloseTimeoutMillis,
114117
IntFunction<Sender> senderFactory,
115118
Consumer<QwpQueryClient> connectHook,
116119
SenderErrorHandler errorHandler,
@@ -131,6 +134,7 @@ public QuestDBImpl(
131134
builtQueryPool = new QueryClientPool(
132135
queryConfig, queryMin, queryMax, acquireTimeoutMillis,
133136
idleTimeoutMillis, maxLifetimeMillis, connectHook);
137+
builtQueryPool.closeQueryTimeoutMillis(queryCloseTimeoutMillis);
134138
builtHousekeeper = new PoolHousekeeper(builtSenderPool, builtQueryPool, housekeeperIntervalMillis);
135139
builtHousekeeper.start();
136140
} catch (Throwable e) {

core/src/test/java/io/questdb/client/test/impl/PoolConfigHonoredTest.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ public void testEveryPoolKeyIsHonored() {
5454
expected.put("query_pool_min", 0);
5555
expected.put("query_pool_max", 5);
5656
expected.put("acquire_timeout_ms", 1234L);
57+
expected.put("query_close_timeout_ms", 2468L);
5758
expected.put("idle_timeout_ms", 4321L);
5859
expected.put("max_lifetime_ms", 98765L);
5960
expected.put("housekeeper_interval_ms", 222L);

0 commit comments

Comments
 (0)