@@ -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 }
0 commit comments