@@ -96,6 +96,13 @@ public final class SenderPool implements AutoCloseable {
9696 // -- the residual window documented on recoverOneSlotStep -- because the
9797 // transport has no application-level connect timeout to clamp it.
9898 private static final long RECOVERY_DRAIN_BUDGET_MILLIS = 1_000 ;
99+ // Hard cap on close()'s outstanding-lease wait. The acquire timeout is a
100+ // BORROW policy -- Long.MAX_VALUE legitimately means "block until a slot
101+ // frees" -- and must never unbound SHUTDOWN: without this cap a forgotten
102+ // lease would hang close() forever. Mirrors the egress twin, whose
103+ // shutdown join is capped at QueryWorker.SHUTDOWN_JOIN_MILLIS (same value)
104+ // regardless of user config.
105+ static final long MAX_CLOSE_LEASE_WAIT_MILLIS = 5_000 ;
99106 private final long acquireTimeoutMillis ;
100107 private final ArrayList <SenderSlot > all ;
101108 private final ArrayDeque <SenderSlot > available ;
@@ -158,6 +165,11 @@ public final class SenderPool implements AutoCloseable {
158165 // teardown. Guarded by lock.
159166 private boolean closeStarted ;
160167 private int inFlightCreations ;
168+ // Lease teardowns currently running on borrower threads (retireLease's
169+ // delegate-close section, outside the lock). close() counts these as
170+ // outstanding so it does not return while a delegate is still being torn
171+ // down on another thread. Guarded by lock.
172+ private int pendingLeaseTeardowns ;
161173 // Slots whose delegate close() returned with the SF flock still held
162174 // (the I/O thread refused to stop). Permanently consumed: the index is
163175 // never freed and never reused, so no borrow ever hands out a still-
@@ -751,17 +763,39 @@ public PooledSender borrow() {
751763 * so an in-flight startup-recovery step driven on the {@link PoolHousekeeper}
752764 * thread stops promptly between slots. {@link QuestDBImpl#close()} calls this
753765 * BEFORE stopping the housekeeper, so the housekeeper join cannot time out
754- * waiting on a fresh slot's recovery drain. Full delegate teardown still
755- * happens in {@link #close()} (guarded by {@code closeStarted}, so this early
756- * signal never short-circuits it). Idempotent; safe to call repeatedly.
766+ * waiting on a fresh slot's recovery drain. Idle-delegate teardown and the
767+ * outstanding-lease wait still happen in {@link #close()} (guarded by
768+ * {@code closeStarted}, so this early signal never short-circuits them);
769+ * borrowed delegates returned after this signal are torn down on the
770+ * returning thread via {@link #retireLease}. Idempotent; safe to call
771+ * repeatedly.
757772 */
758773 void markClosing () {
759774 closed = true ;
760775 }
761776
777+ /**
778+ * Shuts the pool down. NEVER tears down a borrowed delegate: a producer
779+ * thread may be inside one right now (mid-append, mid-flush), and closing
780+ * it from here would flush table buffers that thread is mutating and then
781+ * free their native memory under its feet -- a use-after-free / SEGV, not
782+ * an exception (C1). Instead:
783+ * <ol>
784+ * <li>waits boundedly (up to {@code acquireTimeoutMillis}, hard-capped at
785+ * {@link #MAX_CLOSE_LEASE_WAIT_MILLIS}) for outstanding leases to come home -- {@link #giveBack} and {@link #discardBroken}
786+ * observe {@code closed} and tear each delegate down on the returning
787+ * borrower's own thread, its exclusive user at that point
788+ * ({@link #retireLease}); then</li>
789+ * <li>closes the delegates of idle slots only, outside the lock.</li>
790+ * </ol>
791+ * A lease that never returns leaks its delegate (logged): a logged leak is
792+ * recoverable, a freed buffer under a live producer is a JVM crash. This
793+ * mirrors the egress twin, {@code QueryWorker.shutdown()}'s bounded
794+ * interrupt+join before {@code client.close()}. Idempotent.
795+ */
762796 @ Override
763797 public void close () {
764- SenderSlot [] snapshot ;
798+ SenderSlot [] idleSnapshot ;
765799 lock .lock ();
766800 try {
767801 if (closeStarted ) {
@@ -771,22 +805,50 @@ public void close() {
771805 // Raise the shutdown signal too (a direct, non-pooled caller may
772806 // close() without a prior markClosing()); harmless if already set.
773807 closed = true ;
774- // Snapshot under the lock so the delegate-close loop below is
775- // immune to concurrent mutation of `all`. discardBroken running
776- // on another thread can still bail thanks to the `closed` check
777- // it now performs; the snapshot is belt-and-braces for any
778- // future code path that mutates `all` outside this lock's
779- // happens-before chain.
780- snapshot = all .toArray (new SenderSlot [0 ]);
808+ // Wake parked borrowers so they observe the shutdown and throw.
781809 slotReleased .signalAll ();
810+ // Bounded graceful wait for outstanding leases. A slot is borrowed
811+ // iff it is in `all` but not in `available`; retireLease's
812+ // delegate-close section (running outside the lock on a returning
813+ // borrower's thread) is tracked by pendingLeaseTeardowns so this
814+ // method does not return while a teardown is still in flight.
815+ // The budget is the acquire timeout hard-capped at
816+ // MAX_CLOSE_LEASE_WAIT_MILLIS: a huge/infinite acquire timeout is
817+ // a borrow policy, not a licence for close() to hang forever on a
818+ // lease that never comes home.
819+ final long waitMillis = Math .min (acquireTimeoutMillis , MAX_CLOSE_LEASE_WAIT_MILLIS );
820+ long remainingNanos = TimeUnit .MILLISECONDS .toNanos (waitMillis );
821+ while ((all .size () > available .size () || pendingLeaseTeardowns > 0 ) && remainingNanos > 0 ) {
822+ try {
823+ remainingNanos = slotReleased .awaitNanos (remainingNanos );
824+ } catch (InterruptedException e ) {
825+ // Preserve the interrupt and stop waiting: idle delegates
826+ // are still torn down below, and stragglers take the
827+ // delegated-teardown path whenever they return.
828+ Thread .currentThread ().interrupt ();
829+ break ;
830+ }
831+ }
832+ int outstanding = all .size () - available .size () + pendingLeaseTeardowns ;
833+ if (outstanding > 0 ) {
834+ LOG .warn ("SenderPool.close(): {} borrowed sender lease(s) still outstanding after {}ms; "
835+ + "each connection is torn down when its lease is closed, or leaks if it never is" ,
836+ outstanding , waitMillis );
837+ }
838+ // Only idle slots are safe to close from this thread: no lease
839+ // means no user thread inside the delegate. `available` cannot
840+ // grow after this point -- borrow() throws on `closed` and
841+ // giveBack() retires instead of requeueing -- so the snapshot is
842+ // complete.
843+ idleSnapshot = available .toArray (new SenderSlot [0 ]);
782844 } finally {
783845 lock .unlock ();
784846 }
785- // Close each delegate from the snapshot, outside the lock so a slow
786- // real-close() doesn't keep the pool latched.
787- for (int i = 0 ; i < snapshot .length ; i ++) {
847+ // Close each idle delegate outside the lock so a slow real-close()
848+ // doesn't keep the pool latched.
849+ for (int i = 0 ; i < idleSnapshot .length ; i ++) {
788850 try {
789- snapshot [i ].delegate ().close ();
851+ idleSnapshot [i ].delegate ().close ();
790852 } catch (Throwable ignored ) {
791853 // Best-effort: an Error from one delegate's teardown must not
792854 // abort the loop and strand the remaining delegates unclosed.
@@ -801,23 +863,76 @@ public void close() {
801863 * The underlying delegate is closed outside the lock so a slow real-close
802864 * does not stall other borrowers.
803865 * <p>
804- * Bails when the pool is already closed: {@link #close()} owns the
805- * teardown of every delegate via its snapshot loop, so mutating
806- * {@code all} here would race that iteration on a non-thread-safe
807- * {@code ArrayList} and the {@code delegate.close()} below would be a
808- * double-close on a delegate {@code close()} has already shut down.
866+ * Safe during shutdown too: {@link #close()} never touches borrowed
867+ * delegates, so the calling (borrower's) thread is the delegate's
868+ * exclusive user and {@link #retireLease} can tear it down without racing
869+ * the close() loop.
809870 */
810871 void discardBroken (PooledSender ps ) {
872+ retireLease (ps , "" );
873+ }
874+
875+ public void giveBack (PooledSender ps ) {
811876 SenderSlot s = ps .slot ();
812877 long gen = ps .generation ();
813- boolean reserved = false ;
814878 lock .lock ();
815879 try {
816- if (closed ) {
880+ if (!closed ) {
881+ if (s .generation () != gen ) {
882+ // Stale return: this lease was already given back and the slot
883+ // possibly re-borrowed (or this is a duplicate close). Dropping
884+ // it keeps Sender.close() idempotent under a concurrent
885+ // re-borrow -- without it a double close would enqueue the slot
886+ // twice and hand it to two borrowers writing into one delegate.
887+ return ;
888+ }
889+ s .bumpGeneration ();
890+ s .markIdleAt (System .currentTimeMillis ());
891+ assert !available .contains (s ) : "slot already present in available deque on giveBack" ;
892+ available .addLast (s );
893+ slotReleased .signal ();
817894 return ;
818895 }
896+ } finally {
897+ lock .unlock ();
898+ }
899+ // Pool is shutting down: never requeue. close() deliberately does not
900+ // close borrowed delegates -- a producer thread could still be inside
901+ // one, and freeing its native buffers mid-append is a use-after-free /
902+ // SEGV (C1) -- so teardown is delegated HERE, to the returning
903+ // borrower's thread, the delegate's exclusive user at this point.
904+ // retireLease re-validates the lease generation under the lock and
905+ // signals the close() thread waiting for outstanding leases.
906+ retireLease (ps , " during pool shutdown" );
907+ }
908+
909+ /**
910+ * Retires one lease on the calling (borrower's) thread: validates the
911+ * lease generation under the lock, removes the slot from {@code all},
912+ * closes the delegate OUTSIDE the lock, and reclaims the SF slot index.
913+ * Shared by {@link #discardBroken} (broken delegate) and by
914+ * {@link #giveBack} when the pool is shutting down (delegated teardown --
915+ * see {@link #close()}).
916+ * <p>
917+ * Single-owner teardown: the caller holds the only live lease on this
918+ * slot and {@link #close()} never touches borrowed delegates, so no other
919+ * thread can be inside the delegate when it is closed here.
920+ * {@code pendingLeaseTeardowns} keeps the out-of-lock close visible to
921+ * close()'s outstanding-lease wait, so the pool does not report itself
922+ * closed while a delegate is still being torn down.
923+ *
924+ * @param ps the lease being retired
925+ * @param context phrase woven into the SF retire WARN naming the reclaim
926+ * path (e.g. {@code ""} or {@code " during pool shutdown"})
927+ */
928+ private void retireLease (PooledSender ps , String context ) {
929+ SenderSlot s = ps .slot ();
930+ long gen = ps .generation ();
931+ boolean reserved = false ;
932+ lock .lock ();
933+ try {
819934 if (s .generation () != gen ) {
820- // Stale discard : the slot was already returned/discarded and
935+ // Stale retire : the slot was already returned/discarded and
821936 // possibly re-borrowed. Dropping it avoids evicting a slot a
822937 // different borrower now owns and double-closing its delegate.
823938 return ;
@@ -832,9 +947,11 @@ void discardBroken(PooledSender ps) {
832947 closingSlots ++;
833948 reserved = true ;
834949 }
835- // Wake one waiter -- the cap check in borrow() may now admit a
836- // creation attempt (on a *different* slot).
837- slotReleased .signal ();
950+ pendingLeaseTeardowns ++;
951+ // Wake all waiters: the cap check in borrow() may now admit a
952+ // creation attempt (on a *different* slot), and a close() in
953+ // progress must re-check its outstanding-lease count.
954+ slotReleased .signalAll ();
838955 } finally {
839956 lock .unlock ();
840957 }
@@ -846,54 +963,27 @@ void discardBroken(PooledSender ps) {
846963 } catch (Throwable ignored ) {
847964 // Best-effort teardown: a delegate close() can throw an Error
848965 // (e.g. an -ea AssertionError) as well as a RuntimeException.
849- // Either way the slot accounting in the finally below MUST run,
966+ // Either way the accounting in the finally below MUST run,
850967 // otherwise an SF slot stays reserved forever (slotInUse stuck
851- // true, closingSlots over-counted) and the pool leaks capacity
852- // until borrow() can only ever time out.
968+ // true, closingSlots over-counted), the pool leaks capacity until
969+ // borrow() can only ever time out, and a concurrent close() would
970+ // wait out its full budget on a teardown that already happened.
853971 } finally {
854- if (reserved ) {
855- lock .lock ();
856- try {
972+ lock .lock ();
973+ try {
974+ pendingLeaseTeardowns --;
975+ if (reserved ) {
857976 // Free the index only when the flock was released; a slot
858- // left locked is retired permanently. Signal a waiter only
859- // on the free path, where a new creation can now be admitted.
860- if (reclaimSlot (s , "" )) {
861- slotReleased .signal ();
862- }
863- } finally {
864- lock .unlock ();
977+ // left locked is retired permanently.
978+ reclaimSlot (s , context );
865979 }
980+ slotReleased .signalAll ();
981+ } finally {
982+ lock .unlock ();
866983 }
867984 }
868985 }
869986
870- public void giveBack (PooledSender ps ) {
871- SenderSlot s = ps .slot ();
872- long gen = ps .generation ();
873- lock .lock ();
874- try {
875- if (closed ) {
876- // Pool already shut down: don't requeue; let close() finish destroying.
877- return ;
878- }
879- if (s .generation () != gen ) {
880- // Stale return: this lease was already given back and the slot
881- // possibly re-borrowed (or this is a duplicate close). Dropping
882- // it keeps Sender.close() idempotent under a concurrent
883- // re-borrow -- without it a double close would enqueue the slot
884- // twice and hand it to two borrowers writing into one delegate.
885- return ;
886- }
887- s .bumpGeneration ();
888- s .markIdleAt (System .currentTimeMillis ());
889- assert !available .contains (s ) : "slot already present in available deque on giveBack" ;
890- available .addLast (s );
891- slotReleased .signal ();
892- } finally {
893- lock .unlock ();
894- }
895- }
896-
897987 /**
898988 * Closes idle slots that have exceeded {@code idleTimeoutMillis} or that
899989 * have aged past {@code maxLifetimeMillis}. Never shrinks below
0 commit comments