Skip to content

Commit 7b9924c

Browse files
committed
fix(qwp): retain startup-retired recoverers so a late flock release restores pool capacity
isSlotLockReleased() is no longer a one-shot snapshot: deferred engine cleanup on a worker/I/O-thread exit path can release the SF slot flock after close() returned. The runtime reclaim paths (discardBroken/reapIdle via reclaimSlot) already keep such slots in retiredSlots and re-probe them, but the in-range startup-recovery pass only ticked leakedSlots and dropped the recoverer, making the retirement permanent even after the release -- fatal at maxSize=1, where every later borrow timed out until process restart. Hand the retained recoverer out of drainCandidateSlotForRecovery (retainedOut replaces the flockHeld boolean) and add it to retiredSlots alongside the leakedSlots tick, so the existing reprobeRetiredSlots() drivers (capacity-starved borrow, housekeeper tick) recover the capacity once the worker finally exits. Out-of-range recoverers stay excluded: they carry no leakedSlots tick and freeSlotIndex(idx) would index past the slotInUse array.
1 parent 940e130 commit 7b9924c

2 files changed

Lines changed: 142 additions & 26 deletions

File tree

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

Lines changed: 38 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -179,12 +179,15 @@ public final class SenderPool implements AutoCloseable {
179179
// retiredSlots and returns any index whose flock has since dropped.
180180
// Guarded by lock; only ever ticks for SF slots.
181181
private int leakedSlots;
182-
// The retired slots behind the leakedSlots count (runtime reclaim paths
183-
// only; startup-recovery retirements stay permanent — their transient
184-
// recoverer sender is out of scope by retire time). Re-probed by
185-
// reprobeRetiredSlots() so a late flock release (deferred engine cleanup
186-
// on a worker exit path) restores the pool's capacity instead of
187-
// ratcheting it down until process exit. Guarded by lock.
182+
// The retired slots behind the leakedSlots count: runtime reclaim paths
183+
// (discardBroken/reapIdle via reclaimSlot) and the in-range startup-
184+
// recovery pass (recoverOneSlotStep, which retains the recoverer slot for
185+
// exactly this purpose). Re-probed by reprobeRetiredSlots() so a late
186+
// flock release (deferred engine cleanup on a worker exit path) restores
187+
// the pool's capacity instead of ratcheting it down until process exit.
188+
// Out-of-range startup recoverers are NEVER added: they carry no
189+
// leakedSlots tick and their index has no slotInUse entry to free.
190+
// Guarded by lock.
188191
private final ArrayList<SenderSlot> retiredSlots = new ArrayList<>();
189192
// SF slots currently held by the in-range startup-recovery pass
190193
// (recoverOneSlotStep): each is reserved under `lock` for the
@@ -480,7 +483,7 @@ private boolean recoverOneSlotStep(long stepBudgetMillis) {
480483
recoveryComplete = true;
481484
return false;
482485
}
483-
final boolean[] flockHeld = new boolean[1];
486+
final SenderSlot[] retained = new SenderSlot[1];
484487

485488
// Pass 1: in-range managed slots [0, maxSize). Skip live and empty slots
486489
// cheaply; spend the step on the first slot that actually holds data.
@@ -526,23 +529,29 @@ private boolean recoverOneSlotStep(long stepBudgetMillis) {
526529
// A real candidate -> spend the step on it. Advance the cursor first
527530
// so a resume never reprocesses this index.
528531
recoveryInRangeNext++;
529-
boolean stopScan = drainCandidateSlotForRecovery(i, slotPath, stepBudgetMillis, flockHeld);
532+
boolean stopScan = drainCandidateSlotForRecovery(i, slotPath, stepBudgetMillis, retained);
530533
lock.lock();
531534
try {
532535
// Release the recovery reservation accounting; from here either
533536
// leakedSlots (retire) or the freed index carries the cap math.
534537
recoveringSlots--;
535-
if (flockHeld[0]) {
538+
if (retained[0] != null) {
536539
// close() retained the flock because an I/O or manager
537-
// worker did not stop. Retire the slot permanently (mirror
540+
// worker did not stop. Retire the slot (mirror
538541
// discardBroken/reapIdle): keep slotInUse[i] set and count it
539542
// in leakedSlots so the borrow() cap math accounts for the
540543
// lost capacity and no later borrow ever reuses the
541-
// still-locked dir.
544+
// still-locked dir. Keep the recoverer in retiredSlots so
545+
// reprobeRetiredSlots() restores the capacity once the
546+
// deferred engine cleanup releases the flock — without it
547+
// the retirement would be permanent even after the release
548+
// (fatal at maxSize=1: every later borrow would time out).
542549
leakedSlots++;
543-
LOG.warn("startup SF recovery: slot {} retired permanently: delegate close() returned with "
550+
retiredSlots.add(retained[0]);
551+
LOG.warn("startup SF recovery: slot {} retired: delegate close() returned with "
544552
+ "the flock still held (I/O or manager worker did not stop); pool capacity reduced by 1, "
545-
+ "now {} of {} usable [leakedSlots={}]",
553+
+ "now {} of {} usable [leakedSlots={}]; the slot is re-probed and recovered "
554+
+ "if the worker releases the flock later",
546555
i, maxSize - leakedSlots, maxSize, leakedSlots);
547556
} else {
548557
slotInUse[i] = false;
@@ -586,15 +595,17 @@ private boolean recoverOneSlotStep(long stepBudgetMillis) {
586595
if (!OrphanScanner.isCandidateOrphan(slotPath)) {
587596
continue;
588597
}
589-
boolean stopScan = drainCandidateSlotForRecovery(idx, slotPath, stepBudgetMillis, flockHeld);
590-
if (flockHeld[0]) {
598+
boolean stopScan = drainCandidateSlotForRecovery(idx, slotPath, stepBudgetMillis, retained);
599+
if (retained[0] != null) {
591600
// Out of the pool's [0, maxSize) capacity range: there is no
592601
// slotInUse entry to retire and no future borrow targets this
593602
// dir, so a still-held flock only leaks this recoverer's
594603
// worker-reachable resources (a best-effort teardown loss,
595604
// logged). Crucially we do
596605
// NOT touch leakedSlots -- that would wrongly shrink the
597-
// in-range pool capacity.
606+
// in-range pool capacity -- and we do NOT add to retiredSlots:
607+
// there is no capacity to recover, and freeSlotIndex(idx)
608+
// would index past the slotInUse array (sized maxSize).
598609
LOG.warn("startup SF recovery: out-of-range slot {} closed with the flock still held "
599610
+ "(I/O or manager worker did not stop); its data is durable on disk for a later attempt",
600611
slotPath);
@@ -616,17 +627,20 @@ private boolean recoverOneSlotStep(long stepBudgetMillis) {
616627
* (whose {@link #defaultSender} derives the dir {@code <base>-slotIndex}),
617628
* drains its unacked data, and closes the delegate. Shared by both recovery
618629
* passes -- the in-range pass and the out-of-range pass -- which differ only
619-
* in their slot bookkeeping, handled by the caller via {@code flockHeld}.
630+
* in their slot bookkeeping, handled by the caller via {@code retainedOut}.
620631
*
621-
* @param flockHeld single-element out-param set to {@code true} iff a
622-
* recoverer was built and its {@code close()} returned with
623-
* the flock still held because a worker did not stop
632+
* @param retainedOut single-element out-param set to the recoverer iff one
633+
* was built and its {@code close()} returned with the
634+
* flock still held because a worker did not stop; the
635+
* in-range caller keeps it in {@link #retiredSlots} so a
636+
* late flock release can be re-probed. {@code null} when
637+
* the flock was released (or no recoverer was built).
624638
* @return {@code true} if a build/drain failure occurred that will very
625639
* likely repeat for every remaining slot, so the caller should stop scanning
626640
*/
627641
private boolean drainCandidateSlotForRecovery(int slotIndex, String slotPath,
628-
long remainingMillis, boolean[] flockHeld) {
629-
flockHeld[0] = false;
642+
long remainingMillis, SenderSlot[] retainedOut) {
643+
retainedOut[0] = null;
630644
// Hoisted so the flock check after the try can consult it:
631645
// createRecoverer() takes the slot flock on <base>-slotIndex, and
632646
// delegate().close() can retain it when an I/O or manager worker does
@@ -678,8 +692,8 @@ private boolean drainCandidateSlotForRecovery(int slotIndex, String slotPath,
678692
LOG.warn("startup SF recovery: scan failed for slot {} ({})",
679693
slotPath, scanErr.toString());
680694
}
681-
if (recoverer != null) {
682-
flockHeld[0] = !flockReleased(recoverer);
695+
if (recoverer != null && !flockReleased(recoverer)) {
696+
retainedOut[0] = recoverer;
683697
}
684698
return stopScan;
685699
}

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

Lines changed: 104 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -875,7 +875,7 @@ public void testStartupRecoveryRetiresSlotWhenRecovererCloseLeavesFlockHeld() th
875875
// C1 regression: the startup recovery loop MUST mirror discardBroken /
876876
// reapIdle. When a recoverer's delegate close() returns with the SF
877877
// flock still held (the I/O thread refused to stop), the recovered slot
878-
// index must be retired permanently (leakedSlots++, slotInUse stays
878+
// index must be retired (leakedSlots++, slotInUse stays
879879
// set) -- NOT freed. Freeing it would let a later borrow re-pick the
880880
// still-locked dir and resurrect "sf slot already in use", the exact
881881
// failure class this PR exists to kill. Pre-fix the recovery finally
@@ -951,7 +951,7 @@ public void testStartupRecoveryRetiresSlotWhenRecovererCloseLeavesFlockHeld() th
951951
try {
952952
Assert.assertTrue("borrow must use a fresh slot dir",
953953
Files.exists(slot("default-1")));
954-
// Capacity is permanently reduced by the leaked slot:
954+
// Capacity stays reduced while the flock is held:
955955
// max=2, one leaked + one live => the next borrow times
956956
// out rather than colliding on the locked dir.
957957
try {
@@ -979,6 +979,108 @@ public void testStartupRecoveryRetiresSlotWhenRecovererCloseLeavesFlockHeld() th
979979
});
980980
}
981981

982+
@Test
983+
public void testStartupRetiredSlotRecoveredAfterLateFlockRelease() throws Exception {
984+
// Recovery twin of
985+
// testStartupRecoveryRetiresSlotWhenRecovererCloseLeavesFlockHeld: a
986+
// slot retired by STARTUP recovery (recoverer close() returned with
987+
// the flock still held) must not stay lost until process exit.
988+
// isSlotLockReleased() is no longer a one-shot snapshot -- deferred
989+
// engine cleanup on a worker exit path can release the flock later --
990+
// so the pool must keep the recoverer in retiredSlots and re-probe it.
991+
// Pre-fix, startup recovery only ticked leakedSlots and dropped the
992+
// recoverer: at maxSize=1 every later borrow timed out forever even
993+
// after the flock dropped. This test is RED until the recoverer is
994+
// retained.
995+
TestUtils.assertMemoryLeak(() -> {
996+
// Phase 1: strand unacked data under default-0 so startup recovery
997+
// treats it as a candidate orphan and builds a recoverer.
998+
try (TestWebSocketServer silent = new TestWebSocketServer(new SilentHandler())) {
999+
int silentPort = silent.getPort();
1000+
silent.start();
1001+
Assert.assertTrue(silent.awaitStart(5, TimeUnit.SECONDS));
1002+
String cfg1 = "ws::addr=localhost:" + silentPort + ";sf_dir=" + sfDir
1003+
+ ";close_flush_timeout_millis=500;";
1004+
try (SenderPool pool = new SenderPool(cfg1, 1, 1, 1_000, Long.MAX_VALUE, Long.MAX_VALUE)) {
1005+
PooledSender s = pool.borrow();
1006+
for (int i = 0; i < 3; i++) {
1007+
s.table("recover").longColumn("v", i).atNow();
1008+
s.flush();
1009+
}
1010+
s.close();
1011+
}
1012+
}
1013+
Assert.assertTrue("unacked data must persist under default-0",
1014+
hasSegmentFile(slot("default-0")));
1015+
1016+
// Phase 2: maxSize=1 -- the worst case, where the single slot's
1017+
// retirement starves the whole pool. Forge the retention exactly
1018+
// like the retire test (closed=true makes the recovery drain and
1019+
// close a no-op, so the real flock stays held and slotLockReleased
1020+
// stays false), but only for the FIRST build of slot 0: the
1021+
// post-recovery borrow below must get a working delegate on the
1022+
// recovered index.
1023+
CountingAckHandler handler = new CountingAckHandler();
1024+
try (TestWebSocketServer ack = new TestWebSocketServer(handler)) {
1025+
int ackPort = ack.getPort();
1026+
ack.start();
1027+
Assert.assertTrue(ack.awaitStart(5, TimeUnit.SECONDS));
1028+
String cfg2 = "ws::addr=localhost:" + ackPort + ";sf_dir=" + sfDir + ";";
1029+
1030+
AtomicReference<Sender> forged = new AtomicReference<>();
1031+
IntFunction<Sender> factory = idx -> {
1032+
Sender real = Sender.builder(cfg2).senderId("default-" + idx).build();
1033+
if (idx == 0 && forged.compareAndSet(null, real)) {
1034+
try {
1035+
setBooleanField(real, "closed", true);
1036+
} catch (Exception e) {
1037+
throw new RuntimeException(e);
1038+
}
1039+
}
1040+
return real;
1041+
};
1042+
1043+
try (SenderPool pool = newPoolWithFactory(cfg2, 0, 1, 500, factory)) {
1044+
Assert.assertNotNull("recovery must have built slot 0", forged.get());
1045+
Assert.assertEquals("precondition: startup recovery must retire the slot",
1046+
1, pool.leakedSlotCount());
1047+
1048+
// While the flock is genuinely held, borrows time out: the
1049+
// cap-check re-probe finds the flock still reported held.
1050+
try {
1051+
pool.borrow();
1052+
Assert.fail("borrow must time out while the slot is retired");
1053+
} catch (LineSenderException e) {
1054+
Assert.assertTrue(e.getMessage(), e.getMessage().contains("timed out"));
1055+
}
1056+
1057+
// The late release: the "wedged worker" finishes and the
1058+
// flock genuinely drops (un-forge and close the recoverer
1059+
// for real; in production this flip comes from
1060+
// isSlotLockReleased() re-probing the retained engine after
1061+
// the worker exited).
1062+
Sender recoverer = forged.get();
1063+
setBooleanField(recoverer, "closed", false);
1064+
recoverer.close();
1065+
1066+
// The capacity-starved borrow must re-probe the startup-
1067+
// retired slot, recover its capacity, and create on the
1068+
// freed index -- no housekeeper tick required.
1069+
PooledSender b = pool.borrow();
1070+
try {
1071+
Assert.assertEquals("borrow must recover the startup-retired slot's capacity",
1072+
0, pool.leakedSlotCount());
1073+
boolean[] slotInUse = (boolean[]) getField(pool, "slotInUse");
1074+
Assert.assertTrue("recovered index 0 must carry the new borrow", slotInUse[0]);
1075+
Assert.assertEquals(1, countSlotDirs());
1076+
} finally {
1077+
b.close();
1078+
}
1079+
}
1080+
}
1081+
});
1082+
}
1083+
9821084
// ----------------------------------------------------------------------
9831085
// Concurrency stress: borrow/return churn must never collide on a slot.
9841086
// ----------------------------------------------------------------------

0 commit comments

Comments
 (0)