Skip to content

Commit 79eb13c

Browse files
committed
fix(client): serialize QuestDB.close() through shutdown completion
QuestDBImpl.close() set the volatile `closed` flag before running the pool teardown chain, so a second concurrent close() caller could observe closed==true and return while the first caller was still draining and releasing pool resources -- a premature return that breaks the AutoCloseable expectation that shutdown has completed once close() returns. Make close() synchronized so the losing caller blocks on the monitor until the winner finishes teardown, then enters, sees `closed`, and no-ops. A bare CAS is insufficient: the losing caller would still return early. Adds a latch-controlled two-closer regression test (QuestDBImplCloseTest) that is red without the fix.
1 parent b8d965d commit 79eb13c

2 files changed

Lines changed: 204 additions & 1 deletion

File tree

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

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,8 +181,20 @@ public Sender borrowSender() {
181181
return senderPool.borrow();
182182
}
183183

184+
// synchronized so concurrent close() callers serialize THROUGH shutdown
185+
// completion, not merely through the `closed` flip. `closed` is set before
186+
// the teardown chain runs, so a plain volatile guard (or a bare CAS) would
187+
// let a second caller observe closed==true and return while the first is
188+
// still inside closeQuietly(senderPool) releasing the flock/mmap/I/O-thread
189+
// resources -- a premature return that breaks the AutoCloseable contract
190+
// that shutdown has completed once close() returns. The monitor makes the
191+
// losing caller block until the winner finishes, then it enters, sees
192+
// `closed` and returns a no-op. No deadlock: the teardown steps
193+
// (markClosing/housekeeper.stop()/queryPool.close()/senderPool.close())
194+
// never call back into QuestDBImpl.close() on another thread, so nothing
195+
// contends for this monitor from within the critical section.
184196
@Override
185-
public void close() {
197+
public synchronized void close() {
186198
if (closed) {
187199
return;
188200
}
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
/*+*****************************************************************************
2+
* ___ _ ____ ____
3+
* / _ \ _ _ ___ ___| |_| _ \| __ )
4+
* | | | | | | |/ _ \/ __| __| | | | _ \
5+
* | |_| | |_| | __/\__ \ |_| |_| | |_) |
6+
* \__\_\\__,_|\___||___/\__|____/|____/
7+
*
8+
* Copyright (c) 2014-2019 Appsicle
9+
* Copyright (c) 2019-2026 QuestDB
10+
*
11+
* Licensed under the Apache License, Version 2.0 (the "License");
12+
* you may not use this file except in compliance with the License.
13+
* You may obtain a copy of the License at
14+
*
15+
* http://www.apache.org/licenses/LICENSE-2.0
16+
*
17+
* Unless required by applicable law or agreed to in writing, software
18+
* distributed under the License is distributed on an "AS IS" BASIS,
19+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
20+
* See the License for the specific language governing permissions and
21+
* limitations under the License.
22+
*
23+
******************************************************************************/
24+
25+
package io.questdb.client.test.impl;
26+
27+
import io.questdb.client.Sender;
28+
import io.questdb.client.cutlass.qwp.client.QwpQueryClient;
29+
import io.questdb.client.impl.QuestDBImpl;
30+
import io.questdb.client.test.tools.TestUtils;
31+
import org.junit.Assert;
32+
import org.junit.Test;
33+
34+
import java.lang.reflect.Proxy;
35+
import java.util.concurrent.CountDownLatch;
36+
import java.util.concurrent.TimeUnit;
37+
import java.util.concurrent.atomic.AtomicInteger;
38+
import java.util.function.Consumer;
39+
import java.util.function.IntFunction;
40+
41+
/**
42+
* P2 regression: {@link QuestDBImpl#close()} must not return before shutdown
43+
* has completed, even when two threads call it concurrently. {@code closed} is
44+
* volatile and set BEFORE the pool teardown chain runs, so a naive guard lets a
45+
* second concurrent caller observe {@code closed == true} and RETURN while the
46+
* first caller is still inside {@code closeQuietly(senderPool)} tearing down the
47+
* flock/mmap/I/O-thread resources. After ANY {@code close()} returns -- the
48+
* losing concurrent caller included -- callers must be able to assume shutdown
49+
* has completed.
50+
* <p>
51+
* The window is opened deterministically by injecting (via the {@code @TestOnly}
52+
* senderFactory seam) a fake delegate whose {@code close()} parks on a latch.
53+
* The last teardown step, {@code senderPool.close()}, closes the prewarmed idle
54+
* delegate on the closing thread OUTSIDE the pool lock, so thread A parks there
55+
* with {@code closed} already raised. Thread B then calls {@code close()}:
56+
* <ul>
57+
* <li>pre-fix -- B reads {@code closed == true} and returns immediately while A
58+
* is still tearing down (premature return);</li>
59+
* <li>fixed -- B blocks until A finishes the teardown, then returns.</li>
60+
* </ul>
61+
* Latch-coordinated (no {@code Thread.sleep} for correctness) with a JUnit
62+
* timeout on the two-thread interleaving.
63+
*/
64+
public class QuestDBImplCloseTest {
65+
66+
// Non-SF http config: the injected senderFactory replaces the native build,
67+
// but the constructor's eager config probe must still parse it.
68+
private static final String QUERY_CFG = "ws::addr=127.0.0.1:9000;";
69+
private static final String SENDER_CFG = "http::addr=127.0.0.1:1;protocol_version=2;auto_flush=off;";
70+
71+
// RED (closed set before teardown, no serialization): while thread A is
72+
// parked inside the delegate teardown of the final senderPool.close() step,
73+
// thread B's close() sees closed==true and RETURNS -- closeReturnedEarly is
74+
// true and the first assertion fails. GREEN (close() serialized through
75+
// completion): B blocks on A until the teardown finishes, so its close()
76+
// does not return until delegateCloses == 1.
77+
@Test(timeout = 30_000)
78+
public void concurrentCloseSecondCallerBlocksUntilShutdownCompletes() throws Exception {
79+
TestUtils.assertMemoryLeak(() -> {
80+
AtomicInteger delegateCloses = new AtomicInteger();
81+
CountDownLatch inDelegateClose = new CountDownLatch(1);
82+
CountDownLatch releaseDelegateClose = new CountDownLatch(1);
83+
IntFunction<Sender> senderFactory = slotIndex ->
84+
parkingCloseSender(delegateCloses, inDelegateClose, releaseDelegateClose);
85+
// queryMin = 0 -> QueryClientPool prewarms nothing, so the connect
86+
// hook is never reached and its teardown is a no-op; the parking
87+
// delegate is the only blocking teardown step.
88+
Consumer<QwpQueryClient> connectHook = client -> {
89+
};
90+
91+
QuestDBImpl questDB = newQuestDB(senderFactory, connectHook);
92+
93+
// Thread A: enter close() and park inside the final teardown step
94+
// (senderPool.close() -> idle delegate close()), with closed
95+
// already raised.
96+
Thread closerA = new Thread(questDB::close, "questdb-closer-A");
97+
closerA.start();
98+
Assert.assertTrue("closer A never reached the delegate teardown",
99+
inDelegateClose.await(10, TimeUnit.SECONDS));
100+
101+
// Thread B: a concurrent close(). It must NOT return while A is
102+
// still tearing down.
103+
Thread closerB = new Thread(questDB::close, "questdb-closer-B");
104+
closerB.start();
105+
closerB.join(300);
106+
boolean closeReturnedEarly = !closerB.isAlive();
107+
int closesWhenBReturned = delegateCloses.get();
108+
109+
// Always unpark the teardown so the test fails on the assertion, not
110+
// its own timeout.
111+
releaseDelegateClose.countDown();
112+
113+
Assert.assertFalse(
114+
"concurrent close() returned while the first caller was still tearing down "
115+
+ "(delegateCloses=" + closesWhenBReturned + " when B returned): "
116+
+ "close() must serialize through shutdown completion",
117+
closeReturnedEarly);
118+
119+
// Once the teardown completes, B must return promptly and the
120+
// delegate must have been torn down exactly once.
121+
closerB.join(TimeUnit.SECONDS.toMillis(10));
122+
Assert.assertFalse("concurrent close() did not return after the teardown completed",
123+
closerB.isAlive());
124+
closerA.join(TimeUnit.SECONDS.toMillis(10));
125+
Assert.assertFalse("first close() did not return after the teardown completed",
126+
closerA.isAlive());
127+
Assert.assertEquals("the prewarmed delegate must be torn down exactly once",
128+
1, delegateCloses.get());
129+
});
130+
}
131+
132+
private static QuestDBImpl newQuestDB(
133+
IntFunction<Sender> senderFactory, Consumer<QwpQueryClient> connectHook
134+
) {
135+
return new QuestDBImpl(
136+
SENDER_CFG, QUERY_CFG,
137+
/*senderMin*/ 1, /*senderMax*/ 1,
138+
/*queryMin*/ 0, /*queryMax*/ 1,
139+
/*acquireTimeoutMillis*/ 250L,
140+
/*idleTimeoutMillis*/ Long.MAX_VALUE,
141+
/*maxLifetimeMillis*/ Long.MAX_VALUE,
142+
/*housekeeperIntervalMillis*/ Long.MAX_VALUE,
143+
senderFactory, connectHook);
144+
}
145+
146+
/**
147+
* Proxy-backed fake Sender whose {@code close()} signals {@code inClose},
148+
* parks on {@code releaseClose}, then bumps {@code closes} -- a delegate
149+
* teardown frozen mid-close so the test can probe what a concurrent
150+
* close() does while it runs.
151+
*/
152+
private static Sender parkingCloseSender(
153+
AtomicInteger closes,
154+
CountDownLatch inClose,
155+
CountDownLatch releaseClose
156+
) {
157+
return (Sender) Proxy.newProxyInstance(
158+
Sender.class.getClassLoader(),
159+
new Class[]{Sender.class},
160+
(proxy, method, args) -> {
161+
switch (method.getName()) {
162+
case "close":
163+
inClose.countDown();
164+
if (!releaseClose.await(10, TimeUnit.SECONDS)) {
165+
throw new IllegalStateException("test never released the parked close");
166+
}
167+
closes.incrementAndGet();
168+
return null;
169+
case "toString":
170+
return "ParkingCloseFakeSender";
171+
case "hashCode":
172+
return System.identityHashCode(proxy);
173+
case "equals":
174+
return proxy == args[0];
175+
default:
176+
Class<?> rt = method.getReturnType();
177+
if (rt == boolean.class) return false;
178+
if (rt == byte.class) return (byte) 0;
179+
if (rt == short.class) return (short) 0;
180+
if (rt == int.class) return 0;
181+
if (rt == long.class) return 0L;
182+
if (rt == float.class) return 0f;
183+
if (rt == double.class) return 0d;
184+
if (rt == char.class) return (char) 0;
185+
if (rt == void.class) return null;
186+
if (rt.isInstance(proxy)) return proxy;
187+
return null;
188+
}
189+
});
190+
}
191+
}

0 commit comments

Comments
 (0)