|
28 | 28 | import io.questdb.client.cutlass.qwp.client.sf.cursor.AckWatermark; |
29 | 29 | import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorSendEngine; |
30 | 30 | 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; |
31 | 33 | import io.questdb.client.std.Files; |
32 | 34 | import io.questdb.client.std.MemoryTag; |
33 | 35 | import io.questdb.client.std.ObjList; |
|
37 | 39 | import org.junit.Before; |
38 | 40 | import org.junit.Test; |
39 | 41 |
|
| 42 | +import java.lang.reflect.Constructor; |
| 43 | +import java.lang.reflect.Field; |
| 44 | +import java.lang.reflect.InvocationTargetException; |
40 | 45 | import java.nio.file.Paths; |
| 46 | +import java.util.concurrent.TimeUnit; |
| 47 | +import java.util.concurrent.locks.LockSupport; |
41 | 48 |
|
42 | 49 | import static org.junit.Assert.assertEquals; |
43 | 50 | import static org.junit.Assert.assertFalse; |
@@ -190,6 +197,50 @@ public void testCloseIsIdempotent() throws Exception { |
190 | 197 | }); |
191 | 198 | } |
192 | 199 |
|
| 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 | + |
193 | 244 | @Test |
194 | 245 | public void testMemoryModeSkipsDirAndStillWorks() throws Exception { |
195 | 246 | TestUtils.assertMemoryLeak(() -> { |
@@ -283,6 +334,53 @@ public void testRecoveryIgnoresWatermarkAbovePublishedFsn() throws Exception { |
283 | 334 | }); |
284 | 335 | } |
285 | 336 |
|
| 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 | + |
286 | 384 | @Test |
287 | 385 | public void testRestartIntoNonEmptySfDirContinuesFsnSequence() throws Exception { |
288 | 386 | TestUtils.assertMemoryLeak(() -> { |
@@ -500,4 +598,73 @@ public void testWasRecoveredFromDiskTrueOnReopen() throws Exception { |
500 | 598 | } |
501 | 599 | }); |
502 | 600 | } |
| 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 | + |
503 | 670 | } |
0 commit comments