Skip to content

Commit 104b1bb

Browse files
committed
fix(qwp): SenderPool.close() never tears down borrowed delegates (C1 use-after-free)
SenderPool.close() closed the delegate of every slot in its snapshot, including slots currently borrowed. A producer thread mid-append inside such a delegate had its native table buffers flushed and freed underneath it -- a use-after-free / SEGV, not an exception -- and the QuestDB facade javadoc ('instances are safe to share'; close() merely releases blocked borrowers) documented exactly that interleaving as supported usage. close() now follows one rule: never close a delegate another thread may be inside. - close() waits boundedly for outstanding leases (acquireTimeoutMillis, hard-capped at MAX_CLOSE_LEASE_WAIT_MILLIS=5s, mirroring the egress QueryWorker.SHUTDOWN_JOIN_MILLIS: an unbounded acquire timeout is a borrow policy and must never hang shutdown), then closes idle delegates only. - giveBack()/discardBroken() observing 'closed' route to the new retireLease(): single-owner teardown on the returning borrower's thread, tracked via pendingLeaseTeardowns so close() does not return while a teardown is still in flight. A lease that never returns leaks its delegate (logged): a logged leak is recoverable, a freed buffer under a live producer is a JVM crash. - PooledSender.close() flushes via slot.live(generation) rather than slot.delegate(), narrowing the concurrent duplicate-close race, and its javadoc no longer over-claims (the 'double close cannot flush into a re-borrowed slot' guarantee held only sequentially). - QuestDB.close() javadoc documents the bounded wait, the delegated teardown, and the leak-on-never-returned contract. Tests: new SenderPoolCloseLifecycleTest pins the exact interleaving (a producer parked inside longColumn() while close() runs -> the borrowed delegate is untouched, then torn down exactly once on the producer's thread), the graceful-wait / teardown-completion ordering, the 5s cap under an infinite acquire timeout, and duplicate-close idempotency. SenderPoolTest/SenderPoolSfTest tests that encoded the old close-borrowed-delegates contract are rewritten to the new one, and three facade tests that silently relied on it (leaking live senders whose reconnect dispatchers polluted other tests' native counters) now return their leases: QuestDBLazyConnectTest 5.0s->0.1s, QuestDBBuilderTest 5.2s->0.2s, QuestDBServerRecoveryTest 5.0s->1.0s.
1 parent 4278296 commit 104b1bb

9 files changed

Lines changed: 565 additions & 109 deletions

File tree

core/src/main/java/io/questdb/client/QuestDB.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,19 @@ static QuestDB connect(CharSequence configurationString) {
134134
* query client. Idempotent. Threads currently blocked in
135135
* {@link #borrowSender()} or {@link Query#submit()} are released with an
136136
* error.
137+
* <p>
138+
* Outstanding leases: a borrowed {@link Sender} is never torn down
139+
* underneath the thread using it. Instead, close() waits up to the
140+
* builder's {@link QuestDBBuilder#acquireTimeoutMillis(long) acquire
141+
* timeout} (hard-capped at 5 seconds, so an unbounded acquire timeout can
142+
* never hang shutdown) for borrowed senders to be closed; a lease closed
143+
* during (or
144+
* after) that window is flushed and its connection released safely on the
145+
* returning thread, while a lease that is never closed leaks its
146+
* connection (logged). Avoid calling close() while holding an unclosed
147+
* borrowed sender on the same thread: the lease cannot come home, so the
148+
* close stalls for the full timeout and the connection is only released
149+
* when that sender is eventually closed.
137150
*/
138151
@Override
139152
void close();

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

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -133,12 +133,20 @@ public Sender charColumn(CharSequence name, char value) {
133133
/**
134134
* Flushes pending rows and returns this lease's slot to the pool. Does not
135135
* actually close the underlying {@link Sender}; that only happens when the
136-
* owning {@code QuestDB} is closed.
136+
* owning {@code QuestDB} is closed. If the pool is already shutting down
137+
* when this lease comes home, the underlying Sender is torn down here, on
138+
* the calling thread (see {@link SenderPool#close()}).
137139
* <p>
138-
* Idempotent: a stale generation (the lease was already returned and the
139-
* slot possibly re-borrowed) is a no-op, so a double close cannot flush
140-
* into, or re-enqueue, a slot a different borrower now owns. The pool
141-
* re-checks the generation under its lock.
140+
* Idempotent for sequential reuse: a stale generation (the lease was
141+
* already returned and the slot possibly re-borrowed) is a no-op, and the
142+
* pool re-checks the generation under its lock, so a duplicate close can
143+
* never re-enqueue a slot a different borrower now owns. Concurrent
144+
* double-close of ONE lease from two threads is outside the Sender
145+
* threading contract; the flush below revalidates the lease via
146+
* {@link SenderSlot#live(long)} so a racing duplicate typically fails with
147+
* {@code IllegalStateException} rather than flushing into a re-borrowed
148+
* slot, but two threads racing inside a single lease are not fully
149+
* protected.
142150
*/
143151
@Override
144152
public void close() {
@@ -152,7 +160,12 @@ public void close() {
152160
// abnormal exit as unrecyclable, which is the fail-safe default.
153161
boolean flushed = false;
154162
try {
155-
slot.delegate().flush();
163+
// live(), not delegate(): re-validating the generation right
164+
// before the flush narrows the duplicate-close race documented
165+
// above. A stale duplicate throws here, and the finally routes it
166+
// to discardBroken, which drops it on the same stale-generation
167+
// check under the pool lock.
168+
slot.live(generation).flush();
156169
flushed = true;
157170
} finally {
158171
if (flushed) {

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

Lines changed: 156 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -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

core/src/test/java/io/questdb/client/test/QuestDBBuilderTest.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,12 @@ public void testSharedVocabularyConnectsBothPoolsLive() throws Exception {
235235
+ "auth_timeout_ms=2000;" // common
236236
+ "sender_pool_min=1;sender_pool_max=2;query_pool_min=1;query_pool_max=2;"; // pool
237237
try (QuestDB db = QuestDB.builder().fromConfig(cfg).build()) {
238-
Assert.assertNotNull(db.borrowSender());
238+
// Close the lease: a borrowed sender left outstanding makes
239+
// db.close() wait out the acquire timeout and then leak the
240+
// delegate (close() never tears down a borrowed sender -- C1).
241+
try (io.questdb.client.Sender s = db.borrowSender()) {
242+
Assert.assertNotNull(s);
243+
}
239244
try (io.questdb.client.Query q = db.borrowQuery()) {
240245
Assert.assertNotNull(q);
241246
}

0 commit comments

Comments
 (0)