Skip to content

Commit 21becac

Browse files
committed
Reject blocking lease calls from inside a result handler
Result handlers (onBatch/onEnd/onError) run inline on the worker's dispatch thread: QueryWorker.runLoop -> QueryImpl.runOn -> QwpQueryClient.execute consumes the I/O thread's events and invokes the handler on the worker thread, then signalDone (which sets done) runs only when that same thread loops back. So a handler that called the lease's blocking close() or await() parked the worker thread waiting for a done that only it could later produce -- a permanent, uninterruptible self-deadlock that also leaked the worker and hung any other thread awaiting the same handle. close()'s awaitUninterruptibly made it unrecoverable, and close() is now mandatory via try-with-resources, so the foot-gun was one stray call away. Add a worker-thread reentrancy guard: QueryWorker.isCurrentThreadWorker() compares Thread.currentThread() to the worker's dispatch thread, and QueryImpl.close()/await()/await(timeout) throw IllegalStateException up front when called on it. The exception unwinds at the user's call site with a message pointing to cancel() (the non-blocking in-handler stop); the worker is released normally by the app-thread close() afterwards, so no deadlock and no leak. Also fix the docs the bug exposed. Query.handler, Completion, and the QueryWorker class javadoc all claimed the handler runs on "the I/O thread"; it runs on the worker (dispatch) thread, which consumes the I/O thread's event queue inline. The handler/close()/await() javadocs now say so and forbid blocking calls from a handler. Tests: QueryWorkerTest.testCloseAndAwaitFromWorkerThreadThrowInsteadOf- Deadlocking drives close()/await()/await(timeout) on the worker thread and asserts IllegalStateException (a method-level timeout fails the test if the guard regresses into the old deadlock; verified it times out without the guard). The QuestDB facade E2E suite adds an end-to-end check that a real handler calling close()/await() throws and leaves the worker reusable.
1 parent 09c75b3 commit 21becac

5 files changed

Lines changed: 145 additions & 7 deletions

File tree

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

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,15 +36,22 @@
3636
* {@link #await(long, TimeUnit)} returning {@code true}, or an explicit
3737
* {@link #cancel()} that races to terminal).
3838
* <p>
39-
* Signaling: the Completion is signaled from the I/O thread of the pooled
40-
* query client when the handler's terminal callback ({@code onEnd},
41-
* {@code onError}, or {@code onExecDone}) returns.
39+
* Signaling: the Completion is signaled on the worker (dispatch) thread of the
40+
* pooled query client when the handler's terminal callback ({@code onEnd},
41+
* {@code onError}, or {@code onExecDone}) returns -- that callback runs inline
42+
* on the worker thread, not on the I/O thread. Because of this, {@code await()}
43+
* must never be called from inside a handler (it would self-deadlock on the
44+
* worker thread); use {@link #cancel()} to stop a query from inside a handler.
4245
*/
4346
public interface Completion {
4447

4548
/**
4649
* Blocks until the query completes. Rethrows any server-reported failure
4750
* as a {@link QueryException}. Returns normally on success.
51+
* <p>
52+
* Must NOT be called from a result handler (it runs on the worker thread
53+
* and would self-deadlock); calling it there throws
54+
* {@link IllegalStateException}. Use {@link #cancel()} instead.
4855
*
4956
* @throws QueryException if the server reported an error or
5057
* {@link #cancel()} won the race

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

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,14 +68,24 @@ public interface Query extends Closeable {
6868
* in flight, {@code close()} cancels it and waits for the terminal event
6969
* before returning the client. A real disconnect only happens at
7070
* {@link QuestDB#close()}. Idempotent.
71+
* <p>
72+
* 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
75+
* {@link IllegalStateException}. Use {@link #cancel()} (non-blocking) to
76+
* stop a query from inside a handler.
7177
*/
7278
@Override
7379
void close();
7480

7581
/**
76-
* Sets the result-batch handler. The handler is invoked on the pooled
77-
* query client's I/O thread; if it touches caller state, it is
78-
* responsible for its own synchronization.
82+
* Sets the result-batch handler. The handler is invoked on the worker
83+
* (dispatch) thread that drives {@code execute()} -- it consumes the pooled
84+
* query client's I/O-thread event queue inline, it does NOT run on the I/O
85+
* thread. If it touches caller state, it is responsible for its own
86+
* synchronization. A handler must not call the blocking {@link #close()} or
87+
* {@link Completion#await()} (they would self-deadlock on the worker
88+
* thread); use {@link #cancel()} to stop from inside a handler.
7989
*/
8090
Query handler(QwpColumnBatchHandler handler);
8191

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ void abandon(long gen) {
9595
}
9696

9797
void await(long gen) throws InterruptedException {
98+
rejectHandlerReentry("await");
9899
checkLive(gen);
99100
doneLock.lock();
100101
try {
@@ -108,6 +109,7 @@ void await(long gen) throws InterruptedException {
108109
}
109110

110111
boolean await(long gen, long timeout, TimeUnit unit) throws InterruptedException {
112+
rejectHandlerReentry("await");
111113
checkLive(gen);
112114
long remaining = unit.toNanos(timeout);
113115
doneLock.lock();
@@ -143,6 +145,7 @@ void cancel(long gen) {
143145
}
144146

145147
void close(long gen) {
148+
rejectHandlerReentry("close");
146149
// A stale generation means this lease was already released and the
147150
// worker may now be owned by another borrower. Dropping the call is
148151
// what keeps close() idempotent without releasing someone else's
@@ -229,6 +232,22 @@ private void checkLive(long gen) {
229232
}
230233
}
231234

235+
private void rejectHandlerReentry(String op) {
236+
// Result handlers (onBatch/onEnd/onError) run inline on the worker's
237+
// dispatch thread. A blocking lease op called from there would wait for
238+
// a terminal event that only this same thread can deliver -- a
239+
// permanent, uninterruptible self-deadlock plus a leaked worker. Fail
240+
// loudly at the call site instead. cancel() is the non-blocking stop.
241+
if (worker.isCurrentThreadWorker()) {
242+
throw new IllegalStateException(
243+
op + "() must not be called from a result handler. Handlers "
244+
+ "(onBatch/onEnd/onError) run on the worker thread, so " + op
245+
+ "() would block forever waiting for a terminal event that only "
246+
+ "this same thread can deliver. To stop a query from inside a "
247+
+ "handler, call cancel() (non-blocking).");
248+
}
249+
}
250+
232251
private void signalDone(byte status, String message, Throwable unexpected) {
233252
doneLock.lock();
234253
try {

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

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,11 @@
4040
* The pooled query client's own I/O thread continues to drive the wire; the
4141
* worker thread exists only to keep {@code execute()} off the application's
4242
* submitting thread. Handler callbacks ({@code onBatch}, {@code onEnd},
43-
* {@code onError}) still run on the client's I/O thread.
43+
* {@code onError}) run on this worker's own dispatch thread, which consumes the
44+
* I/O thread's event queue inline -- not on the I/O thread itself. A handler
45+
* must therefore never call the lease's blocking {@code close()}/{@code await()}
46+
* (it would self-deadlock waiting for a terminal event only this thread can
47+
* deliver); use the non-blocking {@code cancel()} to stop from inside a handler.
4448
*/
4549
public final class QueryWorker {
4650

@@ -101,6 +105,17 @@ long idleSinceMillis() {
101105
return idleSinceMillis;
102106
}
103107

108+
/**
109+
* True when the calling thread is this worker's own dispatch thread -- i.e.
110+
* a reentrant call from inside a result handler, which runs inline on this
111+
* thread. Blocking lease operations ({@link QueryImpl#close}/
112+
* {@link QueryImpl#await}) use this to fail loudly instead of
113+
* self-deadlocking.
114+
*/
115+
boolean isCurrentThreadWorker() {
116+
return Thread.currentThread() == thread;
117+
}
118+
104119
void markIdleAt(long nowMillis) {
105120
idleSinceMillis = nowMillis;
106121
}

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

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,15 @@
2626

2727
import io.questdb.client.Completion;
2828
import io.questdb.client.cutlass.qwp.client.QwpQueryClient;
29+
import io.questdb.client.impl.QueryClientPool;
2930
import io.questdb.client.impl.QueryWorker;
3031
import org.junit.Assert;
3132
import org.junit.Test;
3233

3334
import java.lang.reflect.Constructor;
3435
import java.lang.reflect.Field;
36+
import java.lang.reflect.InvocationTargetException;
37+
import java.lang.reflect.Method;
3538
import java.util.concurrent.TimeUnit;
3639
import java.util.concurrent.locks.Condition;
3740
import java.util.concurrent.locks.ReentrantLock;
@@ -155,4 +158,88 @@ public void testShutdownRacingDispatchMustNotStrandCaller() throws Exception {
155158
Assert.assertNotNull("signalUnexpected must record the closed-handle error",
156159
unexpectedF.get(queryImpl));
157160
}
161+
162+
/**
163+
* Result handlers (onBatch/onEnd/onError) run inline on the worker's
164+
* dispatch thread. The blocking lease ops -- {@code close()} and the two
165+
* {@code await()} variants -- would there wait on a terminal event that
166+
* only this same thread can deliver, a permanent self-deadlock. The
167+
* reentrancy guard must turn that into an immediate IllegalStateException.
168+
* <p>
169+
* The guard compares {@code Thread.currentThread()} to the worker's
170+
* dispatch thread, so this test points that field at the test thread (the
171+
* worker is never started) to stand in for a reentrant in-handler call.
172+
* Without the guard, {@code close()}/{@code await()} would park forever and
173+
* the method-level timeout would fail the test.
174+
*/
175+
@Test(timeout = 30_000)
176+
public void testCloseAndAwaitFromWorkerThreadThrowInsteadOfDeadlocking() throws Exception {
177+
Class<?> queryImplClass = Class.forName("io.questdb.client.impl.QueryImpl");
178+
Field queryF = QueryWorker.class.getDeclaredField("query");
179+
queryF.setAccessible(true);
180+
Field threadF = QueryWorker.class.getDeclaredField("thread");
181+
threadF.setAccessible(true);
182+
Field doneF = queryImplClass.getDeclaredField("done");
183+
doneF.setAccessible(true);
184+
Method bump = QueryWorker.class.getDeclaredMethod("bumpGeneration");
185+
bump.setAccessible(true);
186+
Method isWorker = QueryWorker.class.getDeclaredMethod("isCurrentThreadWorker");
187+
isWorker.setAccessible(true);
188+
Method close = queryImplClass.getDeclaredMethod("close", long.class);
189+
close.setAccessible(true);
190+
Method awaitNoTimeout = queryImplClass.getDeclaredMethod("await", long.class);
191+
awaitNoTimeout.setAccessible(true);
192+
Method awaitTimed = queryImplClass.getDeclaredMethod("await", long.class, long.class, TimeUnit.class);
193+
awaitTimed.setAccessible(true);
194+
195+
QueryClientPool pool = new QueryClientPool(
196+
"ws::addr=localhost:9000;",
197+
/*minSize*/ 0, /*maxSize*/ 2,
198+
/*acquireTimeoutMillis*/ 1_000L,
199+
/*idleTimeoutMillis*/ Long.MAX_VALUE,
200+
/*maxLifetimeMillis*/ Long.MAX_VALUE);
201+
QwpQueryClient client = QwpQueryClient.newPlainText("localhost", 9000);
202+
try {
203+
QueryWorker w = new QueryWorker(client, pool, 0);
204+
bump.invoke(w); // generation -> 1: a live lease
205+
Object impl = queryF.get(w);
206+
doneF.setBoolean(impl, false); // a submit is in flight, as during a handler
207+
208+
// Off the worker thread the guard must NOT fire.
209+
Assert.assertFalse("guard must not fire on a normal caller thread",
210+
(Boolean) isWorker.invoke(w));
211+
212+
// Stand in for a reentrant call from inside a result handler: the
213+
// guard compares Thread.currentThread() to the worker's dispatch
214+
// thread, so point that field at this thread.
215+
threadF.set(w, Thread.currentThread());
216+
Assert.assertTrue((Boolean) isWorker.invoke(w));
217+
218+
assertThrowsHandlerReentry("close", () -> close.invoke(impl, 1L));
219+
assertThrowsHandlerReentry("await", () -> awaitNoTimeout.invoke(impl, 1L));
220+
assertThrowsHandlerReentry("await(timeout)",
221+
() -> awaitTimed.invoke(impl, 1L, 5L, TimeUnit.SECONDS));
222+
} finally {
223+
client.close();
224+
pool.close();
225+
}
226+
}
227+
228+
private static void assertThrowsHandlerReentry(String op, ReflectiveCall call) throws Exception {
229+
try {
230+
call.run();
231+
Assert.fail(op + "() from the worker thread must throw, not block/deadlock");
232+
} catch (InvocationTargetException e) {
233+
Throwable cause = e.getCause();
234+
Assert.assertTrue(op + "(): expected IllegalStateException, was " + cause,
235+
cause instanceof IllegalStateException);
236+
Assert.assertTrue(op + "(): message must point at cancel(), was: " + cause.getMessage(),
237+
cause.getMessage().contains("cancel()"));
238+
}
239+
}
240+
241+
@FunctionalInterface
242+
private interface ReflectiveCall {
243+
void run() throws Exception;
244+
}
158245
}

0 commit comments

Comments
 (0)