Skip to content

Commit bcb1e7a

Browse files
committed
Make watchdog cancel re-check lease generation under the pool lock
QueryImpl.cancel(gen) validated the lease generation with an unlocked volatile read and then issued the wire cancel with no lock, so the two steps were not atomic. cancel() is a cross-thread watchdog/timeout API, so this is a TOCTOU: a watchdog can pass the generation check, get preempted while the lease is released (close -> release bumps the generation) and the worker is re-borrowed and re-submitted by another caller, then resume and call cancelInFlight() -- aborting that caller's in-flight query with a spurious STATUS_CANCELLED. The client never resets its cancel state on release/re-borrow, so the stale cancel lands either via the wire requestCancel(currentRequestId) or via the surviving pendingCancel latch the next executeOnce consumes. Route the cancel through QueryClientPool.cancelIfCurrent(worker, gen), which re-checks worker.generation() == gen and issues the wire cancel together under the pool lock -- the same lock acquire()/release() bump the generation under. Once cancelIfCurrent holds the lock the generation cannot change, so a cancel whose lease has gone stale is dropped instead of hitting the new borrower. The cancel itself is non-blocking (a volatile flag plus an AtomicLong set), so the lock is held only briefly. cancel() keeps a cheap unlocked fast-path that drops an obviously-stale or already-done cancel without taking the lock; the authoritative check stays under the lock. close() routes its abort-on-unawaited-close through the same path, closing the identical window on its cancelInFlight() call. QueryLeaseGenerationTest.testStaleCancelDoesNotReachClient only covered the already-stale case (it pre-advanced the generation, leaving no check->act window), so it could not catch this. Add testConcurrentCancelDoesNotReachClientAfterReborrow: it holds the pool lock so a concurrent cancel parks inside the re-check, advances the generation (release + re-borrow) under the lock, then releases it and asserts the parked cancel observed the new generation and never reached the client. The new test fails against the unlocked check-then-cancel and passes with the locked path.
1 parent f64567e commit bcb1e7a

4 files changed

Lines changed: 185 additions & 33 deletions

File tree

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,31 @@ public void close() {
305305
}
306306
}
307307

308+
/**
309+
* Cancels the in-flight query on {@code w} only while its lease generation
310+
* still equals {@code gen}, holding the pool lock across both the check and
311+
* the wire cancel. acquire() and release() bump the generation under this
312+
* same lock, so once this method holds it the generation cannot change: a
313+
* cancel whose lease has already gone stale (the worker was released and
314+
* re-borrowed) is dropped instead of aborting the new borrower's query. The
315+
* cancel itself is non-blocking -- a volatile flag plus an AtomicLong set --
316+
* so the lock is held only briefly.
317+
*/
318+
void cancelIfCurrent(QueryWorker w, long gen) {
319+
lock.lock();
320+
try {
321+
if (closed) {
322+
return;
323+
}
324+
if (w.generation() != gen) {
325+
return;
326+
}
327+
w.cancelInFlight();
328+
} finally {
329+
lock.unlock();
330+
}
331+
}
332+
308333
void reapIdle() {
309334
if (closed) {
310335
return;

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

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -126,14 +126,20 @@ boolean await(long gen, long timeout, TimeUnit unit) throws InterruptedException
126126
}
127127

128128
void cancel(long gen) {
129-
// No-op on a stale lease: the worker may now be running a different
130-
// borrower's query, and cancelling here would abort it.
131-
if (gen != worker.generation()) {
129+
// Fast-path drop of an obviously-stale or already-finished cancel,
130+
// without taking the pool lock. This is only a hint -- the
131+
// authoritative re-check runs under the pool lock inside
132+
// worker.cancelInFlight(gen).
133+
if (gen != worker.generation() || done) {
132134
return;
133135
}
134-
if (!done) {
135-
worker.cancelInFlight();
136-
}
136+
// Re-check the lease generation and issue the wire cancel atomically
137+
// under the pool lock (the same lock acquire()/release() bump the
138+
// generation under). An unlocked check followed by an unlocked cancel
139+
// is a TOCTOU: a cross-thread watchdog can pass the check, get
140+
// preempted while this lease is released and the worker re-borrowed by
141+
// another caller, then resume and abort that caller's in-flight query.
142+
worker.cancelInFlight(gen);
137143
}
138144

139145
void close(long gen) {
@@ -151,7 +157,7 @@ void close(long gen) {
151157
// it returns to the pool -- otherwise the next borrower would inherit a
152158
// running execute().
153159
if (!done) {
154-
worker.cancelInFlight();
160+
worker.cancelInFlight(gen);
155161
doneLock.lock();
156162
try {
157163
while (!done) {

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

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,8 +106,11 @@ void markIdleAt(long nowMillis) {
106106
}
107107

108108
/**
109-
* Cancels the in-flight query on this worker's client. Safe to call from
110-
* any thread; harmless if the worker is idle.
109+
* Issues an unconditional wire cancel against whatever query this worker's
110+
* client is currently running. Callers must already own the worker for the
111+
* current lease -- in practice this runs under the pool lock via
112+
* {@link QueryClientPool#cancelIfCurrent}, which validates the lease
113+
* generation first. Lease code must use {@link #cancelInFlight(long)}.
111114
*/
112115
void cancelInFlight() {
113116
try {
@@ -117,6 +120,18 @@ void cancelInFlight() {
117120
}
118121
}
119122

123+
/**
124+
* Cancels the in-flight query only if this worker's lease generation still
125+
* equals {@code gen}. Delegates to the pool so the generation re-check and
126+
* the wire cancel happen together under the pool lock that
127+
* {@link QueryClientPool#acquire} and {@link QueryClientPool#release} bump
128+
* the generation under. That atomicity stops a stale cross-thread cancel
129+
* from aborting a later borrower's query on the same worker.
130+
*/
131+
void cancelInFlight(long gen) {
132+
pool.cancelIfCurrent(this, gen);
133+
}
134+
120135
/**
121136
* Returns the {@link QwpQueryClient} this worker drives. Exposed for
122137
* introspection and tests; callers must not invoke {@code execute()} on

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

Lines changed: 130 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@
3333
import java.lang.reflect.Field;
3434
import java.lang.reflect.Method;
3535
import java.util.ArrayDeque;
36+
import java.util.concurrent.CountDownLatch;
37+
import java.util.concurrent.TimeUnit;
38+
import java.util.concurrent.atomic.AtomicReference;
39+
import java.util.concurrent.locks.ReentrantLock;
3640

3741
/**
3842
* Regression tests for M1: a stale {@code Query} lease (held after close, or a
@@ -68,32 +72,134 @@ public void testStaleCancelDoesNotReachClient() throws Exception {
6872
Method cancel = queryImplClass.getDeclaredMethod("cancel", long.class);
6973
cancel.setAccessible(true);
7074

71-
// Live lease: generation 1 (one acquire), query in flight -> cancel(1)
72-
// must reach the client.
73-
try (QwpQueryClient live = QwpQueryClient.newPlainText("localhost", 9000)) {
74-
QueryWorker w = new QueryWorker(live, null, 0);
75-
bump.invoke(w); // generation -> 1 (acquire stamp)
76-
Object impl = queryF.get(w);
77-
doneF.setBoolean(impl, false); // pretend a submit is in flight
78-
cancel.invoke(impl, 1L);
79-
Assert.assertTrue("cancel() on the live lease must reach the client",
80-
live.isPendingCancelForTest());
75+
// cancel(gen) validates the generation under the pool lock, so the
76+
// worker needs a real pool to lock on (the worker thread is never
77+
// started, so no network or I/O thread is involved).
78+
QueryClientPool pool = new QueryClientPool(
79+
"ws::addr=localhost:9000;",
80+
/*minSize*/ 0, /*maxSize*/ 2,
81+
/*acquireTimeoutMillis*/ 1_000L,
82+
/*idleTimeoutMillis*/ Long.MAX_VALUE,
83+
/*maxLifetimeMillis*/ Long.MAX_VALUE);
84+
try {
85+
// Live lease: generation 1 (one acquire), query in flight -> cancel(1)
86+
// must reach the client.
87+
try (QwpQueryClient live = QwpQueryClient.newPlainText("localhost", 9000)) {
88+
QueryWorker w = new QueryWorker(live, pool, 0);
89+
bump.invoke(w); // generation -> 1 (acquire stamp)
90+
Object impl = queryF.get(w);
91+
doneF.setBoolean(impl, false); // pretend a submit is in flight
92+
cancel.invoke(impl, 1L);
93+
Assert.assertTrue("cancel() on the live lease must reach the client",
94+
live.isPendingCancelForTest());
95+
}
96+
97+
// Stale lease: the worker was borrowed (gen 1), released and re-borrowed
98+
// (gen now 3). A cancel from the old lease (gen 1) must be dropped, even
99+
// though the current query is in flight.
100+
try (QwpQueryClient reused = QwpQueryClient.newPlainText("localhost", 9000)) {
101+
QueryWorker w = new QueryWorker(reused, pool, 0);
102+
bump.invoke(w); // -> 1 (first acquire)
103+
bump.invoke(w); // -> 2 (release)
104+
bump.invoke(w); // -> 3 (second acquire by a new borrower)
105+
Object impl = queryF.get(w);
106+
doneF.setBoolean(impl, false); // the new borrower's query is in flight
107+
cancel.invoke(impl, 1L); // stale lease cancels
108+
Assert.assertFalse("a stale lease's cancel() must NOT reach the client and "
109+
+ "cancel a different borrower's in-flight query",
110+
reused.isPendingCancelForTest());
111+
}
112+
} finally {
113+
pool.close();
81114
}
115+
}
82116

83-
// Stale lease: the worker was borrowed (gen 1), released and re-borrowed
84-
// (gen now 3). A cancel from the old lease (gen 1) must be dropped, even
85-
// though the current query is in flight.
86-
try (QwpQueryClient reused = QwpQueryClient.newPlainText("localhost", 9000)) {
87-
QueryWorker w = new QueryWorker(reused, null, 0);
88-
bump.invoke(w); // -> 1 (first acquire)
89-
bump.invoke(w); // -> 2 (release)
90-
bump.invoke(w); // -> 3 (second acquire by a new borrower)
91-
Object impl = queryF.get(w);
92-
doneF.setBoolean(impl, false); // the new borrower's query is in flight
93-
cancel.invoke(impl, 1L); // stale lease cancels
94-
Assert.assertFalse("a stale lease's cancel() must NOT reach the client and "
95-
+ "cancel a different borrower's in-flight query",
96-
reused.isPendingCancelForTest());
117+
/**
118+
* The TOCTOU the locked cancel closes: a cross-thread watchdog calls
119+
* {@code cancel(gen)} while its lease is live, but the lease goes stale (the
120+
* worker is released and re-borrowed) before the wire cancel fires. The
121+
* cancel must re-validate the generation atomically with the cancel, under
122+
* the pool lock, or it would abort the new borrower's query.
123+
* <p>
124+
* Driven deterministically: the test thread holds the pool lock, so the
125+
* watchdog's cancel parks inside the pool's generation re-check. We then
126+
* advance the generation (release + re-borrow) under the lock and release
127+
* it. The parked cancel must observe the new generation and drop. An
128+
* unlocked check-then-cancel would not park, would pass its check at the
129+
* still-live generation, and would fire the wire cancel.
130+
*/
131+
@Test
132+
public void testConcurrentCancelDoesNotReachClientAfterReborrow() throws Exception {
133+
Method bump = QueryWorker.class.getDeclaredMethod("bumpGeneration");
134+
bump.setAccessible(true);
135+
Field queryF = QueryWorker.class.getDeclaredField("query");
136+
queryF.setAccessible(true);
137+
Class<?> queryImplClass = Class.forName("io.questdb.client.impl.QueryImpl");
138+
Field doneF = queryImplClass.getDeclaredField("done");
139+
doneF.setAccessible(true);
140+
Method cancel = queryImplClass.getDeclaredMethod("cancel", long.class);
141+
cancel.setAccessible(true);
142+
Field poolLockF = QueryClientPool.class.getDeclaredField("lock");
143+
poolLockF.setAccessible(true);
144+
145+
QueryClientPool pool = new QueryClientPool(
146+
"ws::addr=localhost:9000;",
147+
/*minSize*/ 0, /*maxSize*/ 2,
148+
/*acquireTimeoutMillis*/ 1_000L,
149+
/*idleTimeoutMillis*/ Long.MAX_VALUE,
150+
/*maxLifetimeMillis*/ Long.MAX_VALUE);
151+
QwpQueryClient client = QwpQueryClient.newPlainText("localhost", 9000);
152+
try {
153+
final QueryWorker w = new QueryWorker(client, pool, 0);
154+
bump.invoke(w); // generation -> 1; the watchdog's lease captured 1
155+
final Object impl = queryF.get(w);
156+
doneF.setBoolean(impl, false); // a query is in flight
157+
158+
ReentrantLock poolLock = (ReentrantLock) poolLockF.get(pool);
159+
final CountDownLatch atCancel = new CountDownLatch(1);
160+
final CountDownLatch cancelReturned = new CountDownLatch(1);
161+
final AtomicReference<Throwable> err = new AtomicReference<>();
162+
163+
// Hold the pool lock so the watchdog's cancel cannot finish its
164+
// generation re-check + wire cancel until we let go.
165+
poolLock.lock();
166+
Thread watchdog = new Thread(() -> {
167+
atCancel.countDown();
168+
try {
169+
cancel.invoke(impl, 1L); // lease generation captured at borrow = 1
170+
} catch (Throwable t) {
171+
err.set(t);
172+
} finally {
173+
cancelReturned.countDown();
174+
}
175+
}, "watchdog-cancel");
176+
watchdog.start();
177+
Assert.assertTrue("watchdog must start", atCancel.await(5, TimeUnit.SECONDS));
178+
179+
// With the locked cancel, cancel() parks on the pool lock and cannot
180+
// return while we hold it. An unlocked check-then-cancel would have
181+
// already fired the wire cancel and returned.
182+
Assert.assertFalse("cancel() must re-check the generation under the pool "
183+
+ "lock, so it cannot complete while the lock is held",
184+
cancelReturned.await(200, TimeUnit.MILLISECONDS));
185+
186+
// The lease goes stale underneath the parked cancel: released (-> 2)
187+
// and re-borrowed by a new owner (-> 3).
188+
bump.invoke(w);
189+
bump.invoke(w);
190+
poolLock.unlock();
191+
192+
Assert.assertTrue("cancel() must return once the pool lock is free",
193+
cancelReturned.await(5, TimeUnit.SECONDS));
194+
if (err.get() != null) {
195+
throw new AssertionError("cancel() threw", err.get());
196+
}
197+
Assert.assertFalse("a cancel whose lease went stale while parked on the pool "
198+
+ "lock must NOT reach the client and abort the new borrower's query",
199+
client.isPendingCancelForTest());
200+
} finally {
201+
client.close();
202+
pool.close();
97203
}
98204
}
99205

0 commit comments

Comments
 (0)