Skip to content

Commit e2d1eea

Browse files
authored
fix(qwp): fix a memory-access fault and worker leak during shutdown
1 parent be4a3d0 commit e2d1eea

2 files changed

Lines changed: 109 additions & 7 deletions

File tree

core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentManager.java

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ public final class SegmentManager implements QuietCloseable {
6767
public static final long DISK_FULL_LOG_THROTTLE_NANOS = 30_000_000_000L; // 30 s
6868
public static final long UNLIMITED_TOTAL_BYTES = Long.MAX_VALUE;
6969
private static final Logger LOG = LoggerFactory.getLogger(SegmentManager.class);
70+
private static final long WORKER_JOIN_TIMEOUT_MILLIS = 5_000L;
7071

7172
private final AtomicLong fileGeneration = new AtomicLong();
7273
private final Object lock = new Object();
@@ -107,6 +108,7 @@ public final class SegmentManager implements QuietCloseable {
107108
// {@link #lock}; the lock covers both paths so the counter stays
108109
// consistent across registration boundaries.
109110
private long totalBytes;
111+
private long workerJoinTimeoutMillis = WORKER_JOIN_TIMEOUT_MILLIS;
110112
// volatile because wakeWorker() reads workerThread without holding the
111113
// monitor; the synchronized start()/close() pair handles the
112114
// start-vs-close ordering.
@@ -168,10 +170,28 @@ public synchronized void close() {
168170
Thread t = workerThread;
169171
if (t != null) {
170172
LockSupport.unpark(t);
173+
// A pending interrupt on the caller makes Thread.join() throw at
174+
// once; clear it so the join actually reaps the worker (which
175+
// still owns segment files), then restore it for the rest of the
176+
// interrupted-teardown protocol.
177+
boolean interrupted = Thread.interrupted();
178+
long deadlineNanos = System.nanoTime() + workerJoinTimeoutMillis * 1_000_000L;
171179
try {
172-
t.join(5_000);
173-
} catch (InterruptedException ignored) {
174-
Thread.currentThread().interrupt();
180+
while (t.isAlive()) {
181+
long remainingMillis = (deadlineNanos - System.nanoTime()) / 1_000_000L;
182+
if (remainingMillis <= 0) {
183+
break;
184+
}
185+
try {
186+
t.join(remainingMillis);
187+
} catch (InterruptedException ignored) {
188+
interrupted = true;
189+
}
190+
}
191+
} finally {
192+
if (interrupted) {
193+
Thread.currentThread().interrupt();
194+
}
175195
}
176196
if (t.isAlive()) {
177197
LOG.warn("SegmentManager worker did not stop before close wait completed; "
@@ -287,6 +307,11 @@ public void setBeforeTrimSyncHook(Runnable hook) {
287307
this.beforeTrimSyncHook = hook;
288308
}
289309

310+
@TestOnly
311+
public void setWorkerJoinTimeoutMillis(long millis) {
312+
this.workerJoinTimeoutMillis = millis;
313+
}
314+
290315
public synchronized void start() {
291316
if (workerThread != null) {
292317
throw new IllegalStateException("already started");

core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerCloseRaceTest.java

Lines changed: 81 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -170,10 +170,7 @@ public void testCloseDoesNotFreePathScratchWhenWorkerStillAlive() throws Excepti
170170
Assert.assertTrue("precondition: path scratch should be allocated",
171171
readPathScratchImpl(manager) != 0L);
172172

173-
// Exercise the same branch as a timed-out join without making
174-
// the test sleep for 5 seconds: join() returns while the worker
175-
// is still alive. close() must leave worker-owned native memory
176-
// alone so the worker can resume safely.
173+
manager.setWorkerJoinTimeoutMillis(50L);
177174
Thread.currentThread().interrupt();
178175
manager.close();
179176
Assert.assertTrue("close should preserve interrupted status",
@@ -185,6 +182,7 @@ public void testCloseDoesNotFreePathScratchWhenWorkerStillAlive() throws Excepti
185182
readPathScratchImpl(manager) != 0L);
186183

187184
releaseWorker.countDown();
185+
manager.setWorkerJoinTimeoutMillis(TimeUnit.SECONDS.toMillis(60));
188186
manager.close();
189187
managerClosed = true;
190188
Assert.assertNull("successful close should clear workerThread",
@@ -206,6 +204,85 @@ public void testCloseDoesNotFreePathScratchWhenWorkerStillAlive() throws Excepti
206204
});
207205
}
208206

207+
@Test(timeout = 15_000L)
208+
public void testInterruptedCallerDoesNotAbandonReapableWorker() throws Exception {
209+
TestUtils.assertMemoryLeak(() -> {
210+
long segSize = MmapSegment.HEADER_SIZE + (MmapSegment.FRAME_HEADER_SIZE + 32);
211+
String slot = tmpDir + "/interrupt-slot";
212+
Assert.assertEquals(0, Files.mkdir(slot, Files.DIR_MODE_DEFAULT));
213+
MmapSegment initial = MmapSegment.create(slot + "/sf-initial.sfa", 0L, segSize);
214+
SegmentRing ring = new SegmentRing(initial, segSize);
215+
SegmentManager manager = new SegmentManager(segSize, TimeUnit.SECONDS.toNanos(60));
216+
CountDownLatch workerBlocked = new CountDownLatch(1);
217+
CountDownLatch releaseWorker = new CountDownLatch(1);
218+
CountDownLatch closeReturned = new CountDownLatch(1);
219+
AtomicBoolean fired = new AtomicBoolean();
220+
AtomicBoolean interruptPreserved = new AtomicBoolean();
221+
AtomicReference<Throwable> hookErr = new AtomicReference<>();
222+
AtomicReference<Throwable> closeErr = new AtomicReference<>();
223+
boolean managerClosed = false;
224+
try {
225+
manager.register(ring, slot);
226+
manager.setBeforeInstallSyncHook(() -> {
227+
if (!fired.compareAndSet(false, true)) return;
228+
workerBlocked.countDown();
229+
try {
230+
if (!releaseWorker.await(10, TimeUnit.SECONDS)) {
231+
hookErr.compareAndSet(null,
232+
new AssertionError("timed out waiting for test to release worker"));
233+
}
234+
} catch (Throwable t) {
235+
hookErr.compareAndSet(null, t);
236+
}
237+
});
238+
manager.start();
239+
Assert.assertTrue("worker did not reach install hook",
240+
workerBlocked.await(5, TimeUnit.SECONDS));
241+
242+
Thread closer = new Thread(() -> {
243+
Thread.currentThread().interrupt();
244+
try {
245+
manager.close();
246+
} catch (Throwable t) {
247+
closeErr.compareAndSet(null, t);
248+
} finally {
249+
interruptPreserved.set(Thread.currentThread().isInterrupted());
250+
closeReturned.countDown();
251+
}
252+
}, "interrupted-closer");
253+
closer.start();
254+
255+
Assert.assertFalse("interrupted close() abandoned a live worker instead of waiting",
256+
closeReturned.await(300, TimeUnit.MILLISECONDS));
257+
258+
releaseWorker.countDown();
259+
Assert.assertTrue("close() never returned after the worker was released",
260+
closeReturned.await(10, TimeUnit.SECONDS));
261+
closer.join(TimeUnit.SECONDS.toMillis(5));
262+
managerClosed = readWorkerThread(manager) == null;
263+
264+
if (closeErr.get() != null) {
265+
throw new AssertionError("close() threw", closeErr.get());
266+
}
267+
Assert.assertNull("close() must reap the worker despite the pending interrupt",
268+
readWorkerThread(manager));
269+
Assert.assertTrue("close() must restore the caller's interrupt status",
270+
interruptPreserved.get());
271+
if (hookErr.get() != null) {
272+
throw new AssertionError("install hook failed", hookErr.get());
273+
}
274+
} finally {
275+
manager.setBeforeInstallSyncHook(null);
276+
releaseWorker.countDown();
277+
if (!managerClosed) {
278+
Thread.interrupted();
279+
manager.close();
280+
}
281+
ring.close();
282+
}
283+
});
284+
}
285+
209286
private static void cleanupRecursively(String dir) {
210287
if (!Files.exists(dir)) return;
211288
long find = Files.findFirst(dir);

0 commit comments

Comments
 (0)