Skip to content

Commit 2f4d7c7

Browse files
authored
fix(qwp): prevent JVM crash when closing a QWP sender (#43)
1 parent 153cdd5 commit 2f4d7c7

7 files changed

Lines changed: 604 additions & 144 deletions

File tree

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

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -284,20 +284,18 @@ private CursorSendEngine(String sfDir, long segmentSizeBytes, SegmentManager man
284284
this.ring = ringInProgress;
285285
this.watermark = watermarkInProgress;
286286
} catch (Throwable t) {
287-
// Order: ring first (releases mmap/fd), then manager (joins
288-
// worker thread, but only if we started it AND we own it),
289-
// then watermark (releases its own mmap/fd), then slot lock.
290-
// Each in its own try/catch so a single failure doesn't
291-
// strand later cleanups.
292-
if (ringInProgress != null) {
287+
// Stop an owned manager before freeing the ring and watermark it may
288+
// touch, then release the slot lock. Each cleanup is in its own
289+
// try/catch so a single failure doesn't strand later cleanups.
290+
if (ownsManager && managerStarted) {
293291
try {
294-
ringInProgress.close();
292+
manager.close();
295293
} catch (Throwable ignored) {
296294
}
297295
}
298-
if (ownsManager && managerStarted) {
296+
if (ringInProgress != null) {
299297
try {
300-
manager.close();
298+
ringInProgress.close();
301299
} catch (Throwable ignored) {
302300
}
303301
}

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

Lines changed: 119 additions & 119 deletions
Large diffs are not rendered by default.

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

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@
2828
import io.questdb.client.cutlass.qwp.client.sf.cursor.AckWatermark;
2929
import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorSendEngine;
3030
import io.questdb.client.cutlass.qwp.client.sf.cursor.MmapSegment;
31+
import io.questdb.client.cutlass.qwp.client.sf.cursor.SegmentManager;
32+
import io.questdb.client.cutlass.qwp.client.sf.cursor.SlotLock;
3133
import io.questdb.client.std.Files;
3234
import io.questdb.client.std.MemoryTag;
3335
import io.questdb.client.std.ObjList;
@@ -37,7 +39,12 @@
3739
import org.junit.Before;
3840
import org.junit.Test;
3941

42+
import java.lang.reflect.Constructor;
43+
import java.lang.reflect.Field;
44+
import java.lang.reflect.InvocationTargetException;
4045
import java.nio.file.Paths;
46+
import java.util.concurrent.TimeUnit;
47+
import java.util.concurrent.locks.LockSupport;
4148

4249
import static org.junit.Assert.assertEquals;
4350
import static org.junit.Assert.assertFalse;
@@ -190,6 +197,50 @@ public void testCloseIsIdempotent() throws Exception {
190197
});
191198
}
192199

200+
@Test
201+
public void testConstructorFailureAfterOwnedManagerStartCleansResources() throws Exception {
202+
TestUtils.assertMemoryLeak(() -> {
203+
SegmentManager manager = new SegmentManager(4096);
204+
poisonRegisterGeneration(manager);
205+
206+
Throwable thrown = invokeOwnedPrivateConstructorExpectingFailure(tmpDir, 4096, manager);
207+
assertTrue("register sabotage should surface from constructor catch: " + thrown,
208+
thrown instanceof NullPointerException);
209+
210+
assertNull("owned manager worker must be stopped by constructor catch",
211+
workerThread(manager));
212+
assertSlotCanBeReacquired(tmpDir);
213+
});
214+
}
215+
216+
@Test
217+
public void testConstructorFailureWithSharedManagerReleasesSlotButKeepsManagerRunning() throws Exception {
218+
TestUtils.assertMemoryLeak(() -> {
219+
SegmentManager manager = new SegmentManager(4096);
220+
try {
221+
manager.start();
222+
Thread originalWorker = workerThread(manager);
223+
assertNotNull("shared manager must be running before constructor", originalWorker);
224+
assertTrue("shared manager worker must be alive before constructor",
225+
originalWorker.isAlive());
226+
227+
poisonRegisterGeneration(manager);
228+
Throwable thrown = invokeSharedConstructorExpectingFailure(tmpDir, 4096, manager);
229+
assertTrue("register sabotage should surface from constructor catch: " + thrown,
230+
thrown instanceof NullPointerException);
231+
232+
Thread stillOwnedByCaller = workerThread(manager);
233+
assertNotNull("constructor catch must not close caller-owned manager",
234+
stillOwnedByCaller);
235+
assertTrue("caller-owned manager worker must remain alive",
236+
stillOwnedByCaller.isAlive());
237+
assertSlotCanBeReacquired(tmpDir);
238+
} finally {
239+
manager.close();
240+
}
241+
});
242+
}
243+
193244
@Test
194245
public void testMemoryModeSkipsDirAndStillWorks() throws Exception {
195246
TestUtils.assertMemoryLeak(() -> {
@@ -283,6 +334,53 @@ public void testRecoveryIgnoresWatermarkAbovePublishedFsn() throws Exception {
283334
});
284335
}
285336

337+
@Test(timeout = 30_000L)
338+
public void testManagerPersistedWatermarkSurvivesRestart() throws Exception {
339+
// Positive twin of testRecoveryAdvancesAckedFsnPastWatermark. That test
340+
// FORGES the .ack-watermark by hand; this one drives a real, started
341+
// SegmentManager to PERSIST it from real acks, then proves a second
342+
// session recovers the manager-written value. Without this, a regression
343+
// that silently stopped the manager's trim-path watermark.write() (e.g.
344+
// an inverted `registered` gate) would pass the whole suite: the durable-
345+
// ack tests assert on the in-memory engine.ackedFsn(), and the recovery
346+
// tests forge the watermark, so nothing observes the manager doing the
347+
// write.
348+
TestUtils.assertMemoryLeak(() -> {
349+
long segSize = MmapSegment.HEADER_SIZE
350+
+ 4 * (MmapSegment.FRAME_HEADER_SIZE + 64);
351+
long buf = Unsafe.malloc(64, MemoryTag.NATIVE_DEFAULT);
352+
try {
353+
// Session 1: four frames (publishedFsn = 3), partially acked at 2.
354+
// The ack is below publishedFsn so close() does not treat the slot
355+
// as fully drained — segments and watermark survive for recovery.
356+
// All four frames stay in the active segment, so nothing is
357+
// trimmed and the segment-derived recovery seed is
358+
// lowestBase - 1 == -1; the manager-written watermark (2) is the
359+
// only thing that can lift the recovered ackedFsn above it.
360+
try (CursorSendEngine engine = new CursorSendEngine(tmpDir, segSize)) {
361+
for (int i = 0; i < 4; i++) {
362+
engine.appendBlocking(buf, 64);
363+
}
364+
assertTrue("ack must advance", engine.acknowledge(2L));
365+
// Block until the background worker has actually written the
366+
// watermark to disk. If the trim-path write were gated off this
367+
// never reaches 2 and the helper fails with a clear message,
368+
// rather than the test flaking on a close()-before-tick race.
369+
awaitManagerPersistedWatermark(tmpDir, 2L);
370+
}
371+
// Session 2: recovery must seed ackedFsn from the manager-written
372+
// watermark (2), not the bare segment-derived seed (-1).
373+
try (CursorSendEngine engine = new CursorSendEngine(tmpDir, segSize)) {
374+
assertEquals("recovery must consume the manager-persisted watermark",
375+
2L, engine.ackedFsn());
376+
assertEquals(3L, engine.publishedFsn());
377+
}
378+
} finally {
379+
Unsafe.free(buf, 64, MemoryTag.NATIVE_DEFAULT);
380+
}
381+
});
382+
}
383+
286384
@Test
287385
public void testRestartIntoNonEmptySfDirContinuesFsnSequence() throws Exception {
288386
TestUtils.assertMemoryLeak(() -> {
@@ -500,4 +598,73 @@ public void testWasRecoveredFromDiskTrueOnReopen() throws Exception {
500598
}
501599
});
502600
}
601+
602+
private static void assertSlotCanBeReacquired(String sfDir) {
603+
try (SlotLock ignored = SlotLock.acquire(sfDir)) {
604+
// good
605+
}
606+
}
607+
608+
private static Throwable invokeOwnedPrivateConstructorExpectingFailure(
609+
String sfDir, long segmentSizeBytes, SegmentManager manager) throws Exception {
610+
Constructor<CursorSendEngine> ctor = CursorSendEngine.class.getDeclaredConstructor(
611+
String.class, long.class, SegmentManager.class, boolean.class, long.class);
612+
ctor.setAccessible(true);
613+
try {
614+
ctor.newInstance(sfDir, segmentSizeBytes, manager, true,
615+
CursorSendEngine.DEFAULT_APPEND_DEADLINE_NANOS);
616+
fail("expected constructor failure");
617+
return null;
618+
} catch (InvocationTargetException e) {
619+
return e.getCause();
620+
}
621+
}
622+
623+
private static Throwable invokeSharedConstructorExpectingFailure(
624+
String sfDir, long segmentSizeBytes, SegmentManager manager) {
625+
try {
626+
new CursorSendEngine(sfDir, segmentSizeBytes, manager);
627+
fail("expected constructor failure");
628+
return null;
629+
} catch (Throwable t) {
630+
return t;
631+
}
632+
}
633+
634+
private static void poisonRegisterGeneration(SegmentManager manager) throws Exception {
635+
// register() advances fileGeneration before publishing the ring. Nulling
636+
// it forces a deterministic constructor failure after the ring and
637+
// watermark exist, without adding a production test hook.
638+
Field f = SegmentManager.class.getDeclaredField("fileGeneration");
639+
f.setAccessible(true);
640+
f.set(manager, null);
641+
}
642+
643+
private static Thread workerThread(SegmentManager manager) throws Exception {
644+
Field f = SegmentManager.class.getDeclaredField("workerThread");
645+
f.setAccessible(true);
646+
return (Thread) f.get(manager);
647+
}
648+
649+
// Polls the on-disk watermark until it reads {@code expected}, or fails after
650+
// a bounded wait. The probe is a second mapping of the same file the manager
651+
// worker writes through; its MAP_SHARED reads observe the worker's writes, and
652+
// it is closed before the next session opens the slot.
653+
private static void awaitManagerPersistedWatermark(String slotDir, long expected) {
654+
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10);
655+
long last = AckWatermark.INVALID;
656+
try (AckWatermark probe = AckWatermark.open(slotDir)) {
657+
assertNotNull("watermark file must exist after register", probe);
658+
while (System.nanoTime() < deadline) {
659+
last = probe.read();
660+
if (last == expected) {
661+
return;
662+
}
663+
LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(2));
664+
}
665+
}
666+
fail("manager did not persist watermark=" + expected
667+
+ " within 10s (last on-disk read=" + last + ")");
668+
}
669+
503670
}

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

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,9 @@
2727
import io.questdb.client.cutlass.qwp.client.sf.cursor.MmapSegment;
2828
import io.questdb.client.cutlass.qwp.client.sf.cursor.SegmentManager;
2929
import io.questdb.client.cutlass.qwp.client.sf.cursor.SegmentRing;
30+
import io.questdb.client.std.bytes.DirectByteSink;
3031
import io.questdb.client.std.Files;
32+
import io.questdb.client.std.str.DirectUtf8Sink;
3133
import io.questdb.client.test.tools.TestUtils;
3234
import org.junit.After;
3335
import org.junit.Assert;
@@ -36,6 +38,10 @@
3638

3739
import java.lang.reflect.Field;
3840
import java.nio.file.Paths;
41+
import java.util.concurrent.CountDownLatch;
42+
import java.util.concurrent.TimeUnit;
43+
import java.util.concurrent.atomic.AtomicBoolean;
44+
import java.util.concurrent.atomic.AtomicReference;
3945

4046
/**
4147
* Concurrent regression for the {@code SegmentManager} worker race vs
@@ -130,6 +136,76 @@ public void testManagerDoesNotInstallSpareIntoClosedRing() throws Exception {
130136
});
131137
}
132138

139+
@Test(timeout = 15_000L)
140+
public void testCloseDoesNotFreePathScratchWhenWorkerStillAlive() throws Exception {
141+
TestUtils.assertMemoryLeak(() -> {
142+
long segSize = MmapSegment.HEADER_SIZE + (MmapSegment.FRAME_HEADER_SIZE + 32);
143+
String slot = tmpDir + "/timeout-slot";
144+
Assert.assertEquals(0, Files.mkdir(slot, Files.DIR_MODE_DEFAULT));
145+
MmapSegment initial = MmapSegment.create(slot + "/sf-initial.sfa", 0L, segSize);
146+
SegmentRing ring = new SegmentRing(initial, segSize);
147+
SegmentManager manager = new SegmentManager(segSize, TimeUnit.SECONDS.toNanos(60));
148+
CountDownLatch workerBlocked = new CountDownLatch(1);
149+
CountDownLatch releaseWorker = new CountDownLatch(1);
150+
AtomicBoolean fired = new AtomicBoolean();
151+
AtomicReference<Throwable> hookErr = new AtomicReference<>();
152+
boolean managerClosed = false;
153+
try {
154+
manager.register(ring, slot);
155+
manager.setBeforeInstallSyncHook(() -> {
156+
if (!fired.compareAndSet(false, true)) return;
157+
workerBlocked.countDown();
158+
try {
159+
if (!releaseWorker.await(10, TimeUnit.SECONDS)) {
160+
hookErr.compareAndSet(null,
161+
new AssertionError("timed out waiting for test to release worker"));
162+
}
163+
} catch (Throwable t) {
164+
hookErr.compareAndSet(null, t);
165+
}
166+
});
167+
manager.start();
168+
Assert.assertTrue("worker did not reach install hook",
169+
workerBlocked.await(5, TimeUnit.SECONDS));
170+
Assert.assertTrue("precondition: path scratch should be allocated",
171+
readPathScratchImpl(manager) != 0L);
172+
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.
177+
Thread.currentThread().interrupt();
178+
manager.close();
179+
Assert.assertTrue("close should preserve interrupted status",
180+
Thread.interrupted());
181+
Thread worker = readWorkerThread(manager);
182+
Assert.assertTrue("worker should still be tracked after incomplete close",
183+
worker != null && worker.isAlive());
184+
Assert.assertTrue("path scratch was freed while worker was still alive",
185+
readPathScratchImpl(manager) != 0L);
186+
187+
releaseWorker.countDown();
188+
manager.close();
189+
managerClosed = true;
190+
Assert.assertNull("successful close should clear workerThread",
191+
readWorkerThread(manager));
192+
Assert.assertEquals("successful close should free path scratch",
193+
0L, readPathScratchImpl(manager));
194+
if (hookErr.get() != null) {
195+
throw new AssertionError("install hook failed", hookErr.get());
196+
}
197+
} finally {
198+
manager.setBeforeInstallSyncHook(null);
199+
releaseWorker.countDown();
200+
if (!managerClosed) {
201+
Thread.interrupted();
202+
manager.close();
203+
}
204+
ring.close();
205+
}
206+
});
207+
}
208+
133209
private static void cleanupRecursively(String dir) {
134210
if (!Files.exists(dir)) return;
135211
long find = Files.findFirst(dir);
@@ -152,4 +228,22 @@ private static void cleanupRecursively(String dir) {
152228
Files.findClose(find);
153229
}
154230
}
231+
232+
private static long readPathScratchImpl(SegmentManager manager) throws Exception {
233+
Field pathScratchF = SegmentManager.class.getDeclaredField("pathScratch");
234+
pathScratchF.setAccessible(true);
235+
DirectUtf8Sink pathScratch = (DirectUtf8Sink) pathScratchF.get(manager);
236+
Field sinkF = DirectUtf8Sink.class.getDeclaredField("sink");
237+
sinkF.setAccessible(true);
238+
DirectByteSink sink = (DirectByteSink) sinkF.get(pathScratch);
239+
Field implF = DirectByteSink.class.getDeclaredField("impl");
240+
implF.setAccessible(true);
241+
return implF.getLong(sink);
242+
}
243+
244+
private static Thread readWorkerThread(SegmentManager manager) throws Exception {
245+
Field workerThreadF = SegmentManager.class.getDeclaredField("workerThread");
246+
workerThreadF.setAccessible(true);
247+
return (Thread) workerThreadF.get(manager);
248+
}
155249
}

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

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ public void testInstallPathDoesNotCommitAfterDeregister() throws Exception {
122122
CountDownLatch hookDone = new CountDownLatch(1);
123123
AtomicBoolean fired = new AtomicBoolean();
124124
AtomicReference<Throwable> hookErr = new AtomicReference<>();
125-
setBeforeInstallSyncHook(mgr, () -> {
125+
mgr.setBeforeInstallSyncHook(() -> {
126126
if (!fired.compareAndSet(false, true)) return;
127127
try {
128128
mgr.deregister(ring);
@@ -170,7 +170,7 @@ public void testInstallPathDoesNotCommitAfterDeregister() throws Exception {
170170
+ "under the same lock that covers deregister.",
171171
0L, observed);
172172
} finally {
173-
setBeforeInstallSyncHook(mgr, null);
173+
mgr.setBeforeInstallSyncHook(null);
174174
try {
175175
ring.close();
176176
} catch (Throwable ignored) {
@@ -226,12 +226,6 @@ private static void rmDirRecursive(String dir) {
226226
Files.remove(dir);
227227
}
228228

229-
private static void setBeforeInstallSyncHook(SegmentManager mgr, Runnable hook) throws Exception {
230-
Field f = SegmentManager.class.getDeclaredField("beforeInstallSyncHook");
231-
f.setAccessible(true);
232-
f.set(mgr, hook);
233-
}
234-
235229
private static Thread workerThread(SegmentManager mgr) throws Exception {
236230
Field f = SegmentManager.class.getDeclaredField("workerThread");
237231
f.setAccessible(true);

0 commit comments

Comments
 (0)