Skip to content

Commit d976fd5

Browse files
committed
fix(qwp): preserve slot ownership after incomplete close
1 parent 8f0dd45 commit d976fd5

6 files changed

Lines changed: 271 additions & 52 deletions

File tree

core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java

Lines changed: 38 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -278,10 +278,10 @@ public class QwpWebSocketSender implements Sender {
278278
private boolean ownsCursorEngine;
279279
private long pendingBytes;
280280
// Set true by close() once the SF slot flock has been released (the normal
281-
// teardown path). Stays false if close() bailed early with the I/O thread
282-
// still running -- then cursorEngine.close() never ran and the flock is
283-
// still held, so the owning pool MUST keep the slot reserved rather than
284-
// hand the still-locked dir to the next borrow ("sf slot already in use").
281+
// teardown path). Stays false if an I/O or manager worker did not stop and
282+
// cursorEngine retained the flock, so the owning pool MUST keep the slot
283+
// reserved rather than hand the still-locked dir to the next borrow
284+
// ("sf slot already in use").
285285
private boolean slotLockReleased;
286286
private int pendingRowCount;
287287
private SenderProgressDispatcher progressDispatcher;
@@ -1038,7 +1038,10 @@ public QwpWebSocketSender charColumn(CharSequence columnName, char value) {
10381038
* replaying frames get a 2.5s grace window plus a 0.5s stop window
10391039
* — worst case ~3s when a drainer sits in a blocking native
10401040
* connect (15s background deadline) and must be abandoned to exit
1041-
* on its own.</li>
1041+
* on its own;</li>
1042+
* <li>SF manager stop: normally immediate, bounded by 5s when its worker
1043+
* is stuck in a filesystem operation. On timeout the slot remains
1044+
* locked rather than being exposed to a stale worker.</li>
10421045
* </ul>
10431046
*/
10441047
@Override
@@ -1189,15 +1192,23 @@ public void close() {
11891192
// the failed close() and now — then closing here is safe.
11901193
if (ownsCursorEngine && cursorEngine != null && cursorSendLoop != null
11911194
&& !cursorSendLoop.delegateEngineClose()) {
1195+
CursorSendEngine engine = cursorEngine;
11921196
try {
1193-
cursorEngine.close();
1197+
engine.close();
11941198
} catch (Throwable t) {
11951199
LOG.error("Error closing owned CursorSendEngine: {}", String.valueOf(t));
11961200
terminalError = captureCloseError(terminalError, t);
11971201
}
1198-
cursorEngine = null;
1199-
ownsCursorEngine = false;
1200-
slotLockReleased = true;
1202+
if (engine.isCloseCompleted()) {
1203+
cursorEngine = null;
1204+
ownsCursorEngine = false;
1205+
slotLockReleased = true;
1206+
} else {
1207+
// Preserve the engine and report the retained flock.
1208+
// Sender.close() is idempotent, so this is a deliberate
1209+
// safe leak until process exit rather than a retry path.
1210+
slotLockReleased = false;
1211+
}
12011212
}
12021213
rethrowTerminal(terminalError);
12031214
return;
@@ -1231,19 +1242,27 @@ public void close() {
12311242
}
12321243

12331244
if (ownsCursorEngine && cursorEngine != null) {
1245+
CursorSendEngine engine = cursorEngine;
12341246
try {
1235-
cursorEngine.close();
1247+
engine.close();
12361248
} catch (Throwable t) {
12371249
LOG.error("Error closing owned CursorSendEngine: {}", String.valueOf(t));
12381250
terminalError = captureCloseError(terminalError, t);
12391251
}
1240-
cursorEngine = null;
1241-
ownsCursorEngine = false;
1252+
if (engine.isCloseCompleted()) {
1253+
cursorEngine = null;
1254+
ownsCursorEngine = false;
1255+
slotLockReleased = true;
1256+
} else {
1257+
// The manager worker did not quiesce. Preserve ownership
1258+
// and report the retained flock so pools retire this slot.
1259+
// Repeated Sender.close() calls remain no-ops by contract.
1260+
slotLockReleased = false;
1261+
}
1262+
} else {
1263+
// This sender owns no cursor engine holding an SF flock.
1264+
slotLockReleased = true;
12421265
}
1243-
// Past the ioThreadStopped guard => cursorEngine.close() ran and
1244-
// released the SF flock in its finally (or this sender owned no
1245-
// engine holding one). Signal the pool it may reuse the slot.
1246-
slotLockReleased = true;
12471266

12481267
// Shutdown order: dispatcher last, after the I/O loop has stopped
12491268
// producing into it. close() drains pending entries with a short
@@ -1288,9 +1307,9 @@ public void close() {
12881307

12891308
/**
12901309
* True once {@link #close()} has released the store-and-forward slot
1291-
* flock. False means close() leaked the still-running I/O thread (and its
1292-
* resources), so the flock is still held; the owning pool must keep the
1293-
* slot index reserved instead of reusing the still-locked slot dir.
1310+
* flock. False means an I/O or manager worker did not stop and close()
1311+
* retained the lock and worker-reachable resources; the owning pool must
1312+
* keep the slot index reserved instead of reusing the still-locked dir.
12941313
*/
12951314
public boolean isSlotLockReleased() {
12961315
return slotLockReleased;

core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorSendEngine.java

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -540,25 +540,26 @@ public synchronized void close() {
540540
// while that pass is in flight lets the stale worker unlink a
541541
// path that a replacement engine — which can acquire the slot
542542
// the moment the lock is released — is actively writing through:
543-
// store-and-forward data loss after restart. Wait for confirmed
544-
// quiescence before touching anything the worker can reach.
545-
try {
546-
workerQuiescent = manager.awaitRingQuiescence(ring);
547-
} catch (Throwable ignored) {
548-
}
543+
// store-and-forward data loss after restart.
549544
if (ownsManager) {
545+
// Stopping and reaping a private manager is a stronger barrier
546+
// than waiting for this ring alone. Do it directly so a stuck
547+
// worker consumes at most one workerJoinTimeoutMillis budget,
548+
// rather than one here and a second one in manager.close().
550549
try {
551550
manager.close();
552551
} catch (Throwable ignored) {
553552
}
554-
if (!workerQuiescent) {
555-
// manager.close() joins the worker; a reaped (or
556-
// never-started) worker is an even stronger barrier
557-
// than per-ring quiescence.
558-
try {
559-
workerQuiescent = manager.isWorkerReaped();
560-
} catch (Throwable ignored) {
561-
}
553+
try {
554+
workerQuiescent = manager.isWorkerReaped();
555+
} catch (Throwable ignored) {
556+
}
557+
} else {
558+
// A shared manager must keep serving its other rings, so wait
559+
// only for the deregistered ring's current pass to finish.
560+
try {
561+
workerQuiescent = manager.awaitRingQuiescence(ring);
562+
} catch (Throwable ignored) {
562563
}
563564
}
564565
if (!workerQuiescent) {
@@ -613,6 +614,16 @@ public synchronized void close() {
613614
}
614615
}
615616

617+
/**
618+
* Whether {@link #close()} completed all cleanup, including releasing the
619+
* SF slot lock. A false value after close means manager-worker quiescence
620+
* could not be confirmed and the worker-reachable resources were retained
621+
* deliberately. Owners must not report or reuse the slot in that case.
622+
*/
623+
public synchronized boolean isCloseCompleted() {
624+
return closeCompleted;
625+
}
626+
616627
/**
617628
* Pass-through to {@link SegmentRing#findSegmentContaining(long)}.
618629
*/

core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentManager.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,10 @@ public final class SegmentManager implements QuietCloseable {
9393
// but before ownership/accounting commit. Callers may inject a deregister
9494
// or hold this stale worker snapshot while caller-side cleanup runs.
9595
private volatile Runnable beforeInstallSyncHook;
96+
// Test seam: records entry into the per-ring quiescence wait. Null in
97+
// production; owned-engine close tests use it to prove they take only the
98+
// stronger whole-manager join path, not two sequential timeout budgets.
99+
private volatile Runnable beforeRingQuiescenceAwaitHook;
96100
// Test seam: runs on the worker thread just before the trim block's
97101
// synchronized(lock) entry. Null in production; only
98102
// SegmentManagerTrimDeregisterRaceTest installs it, to deterministically
@@ -268,6 +272,10 @@ public synchronized void close() {
268272
* mirroring {@link #close()}.
269273
*/
270274
public boolean awaitRingQuiescence(SegmentRing ring) {
275+
Runnable hook = beforeRingQuiescenceAwaitHook;
276+
if (hook != null) {
277+
hook.run();
278+
}
271279
Thread t = workerThread;
272280
if (t == null || t == Thread.currentThread()) {
273281
return true;
@@ -410,6 +418,11 @@ public void setBeforeInstallSyncHook(Runnable hook) {
410418
this.beforeInstallSyncHook = hook;
411419
}
412420

421+
@TestOnly
422+
public void setBeforeRingQuiescenceAwaitHook(Runnable hook) {
423+
this.beforeRingQuiescenceAwaitHook = hook;
424+
}
425+
413426
@TestOnly
414427
public void setBeforeTrimSyncHook(Runnable hook) {
415428
this.beforeTrimSyncHook = hook;

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

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ public final class SenderPool implements AutoCloseable {
171171
// down on another thread. Guarded by lock.
172172
private int pendingLeaseTeardowns;
173173
// Slots whose delegate close() returned with the SF flock still held
174-
// (the I/O thread refused to stop). Permanently consumed: the index is
174+
// because an I/O or manager worker did not stop. Permanently consumed:
175175
// never freed and never reused, so no borrow ever hands out a still-
176176
// locked slot dir. Counted in the cap check so the lost capacity is
177177
// accounted for. Guarded by lock; only ever ticks for SF slots.
@@ -523,15 +523,15 @@ private boolean recoverOneSlotStep(long stepBudgetMillis) {
523523
// leakedSlots (retire) or the freed index carries the cap math.
524524
recoveringSlots--;
525525
if (flockHeld[0]) {
526-
// close() bailed early with the I/O thread still running and
527-
// the flock still held. Retire the slot permanently (mirror
526+
// close() retained the flock because an I/O or manager
527+
// worker did not stop. Retire the slot permanently (mirror
528528
// discardBroken/reapIdle): keep slotInUse[i] set and count it
529529
// in leakedSlots so the borrow() cap math accounts for the
530530
// lost capacity and no later borrow ever reuses the
531531
// still-locked dir.
532532
leakedSlots++;
533533
LOG.warn("startup SF recovery: slot {} retired permanently: delegate close() returned with "
534-
+ "the flock still held (I/O thread refused to stop); pool capacity reduced by 1, "
534+
+ "the flock still held (I/O or manager worker did not stop); pool capacity reduced by 1, "
535535
+ "now {} of {} usable [leakedSlots={}]",
536536
i, maxSize - leakedSlots, maxSize, leakedSlots);
537537
} else {
@@ -580,12 +580,13 @@ private boolean recoverOneSlotStep(long stepBudgetMillis) {
580580
if (flockHeld[0]) {
581581
// Out of the pool's [0, maxSize) capacity range: there is no
582582
// slotInUse entry to retire and no future borrow targets this
583-
// dir, so a still-held flock only leaks this recoverer's I/O
584-
// thread (a best-effort teardown loss, logged). Crucially we do
583+
// dir, so a still-held flock only leaks this recoverer's
584+
// worker-reachable resources (a best-effort teardown loss,
585+
// logged). Crucially we do
585586
// NOT touch leakedSlots -- that would wrongly shrink the
586587
// in-range pool capacity.
587588
LOG.warn("startup SF recovery: out-of-range slot {} closed with the flock still held "
588-
+ "(I/O thread refused to stop); its data is durable on disk for a later attempt",
589+
+ "(I/O or manager worker did not stop); its data is durable on disk for a later attempt",
589590
slotPath);
590591
}
591592
if (stopScan) {
@@ -609,7 +610,7 @@ private boolean recoverOneSlotStep(long stepBudgetMillis) {
609610
*
610611
* @param flockHeld single-element out-param set to {@code true} iff a
611612
* recoverer was built and its {@code close()} returned with
612-
* the flock still held (the I/O thread refused to stop)
613+
* the flock still held because a worker did not stop
613614
* @return {@code true} if a build/drain failure occurred that will very
614615
* likely repeat for every remaining slot, so the caller should stop scanning
615616
*/
@@ -618,8 +619,8 @@ private boolean drainCandidateSlotForRecovery(int slotIndex, String slotPath,
618619
flockHeld[0] = false;
619620
// Hoisted so the flock check after the try can consult it:
620621
// createRecoverer() takes the slot flock on <base>-slotIndex, and
621-
// delegate().close() can early-return with the I/O thread still running
622-
// (flock still held).
622+
// delegate().close() can retain it when an I/O or manager worker does
623+
// not stop.
623624
SenderSlot recoverer = null;
624625
boolean stopScan = false;
625626
try {
@@ -1067,7 +1068,7 @@ public void reapIdle() {
10671068
}
10681069
// Return reserved SF slot indices to the free set -- but only for
10691070
// slots whose delegate confirmed the flock was released. A slot
1070-
// left locked (I/O thread refused to stop) is retired permanently.
1071+
// left locked because a worker did not stop is retired permanently.
10711072
if (storeAndForward) {
10721073
lock.lock();
10731074
try {
@@ -1107,8 +1108,8 @@ public int totalSize() {
11071108

11081109
/**
11091110
* Snapshot of the number of SF slots permanently retired because a
1110-
* delegate {@code close()} returned with the slot flock still held (the
1111-
* I/O thread refused to stop). Each leaked slot permanently lowers the
1111+
* delegate {@code close()} returned with the slot flock still held after
1112+
* an I/O or manager worker did not stop. Each leaked slot permanently lowers the
11121113
* pool's effective capacity ({@code maxSize - leakedSlotCount()}). A
11131114
* non-zero, growing value explains a pool that has started timing out
11141115
* every {@code borrow()}. For metrics and tests.
@@ -1271,7 +1272,7 @@ private void freeSlotIndex(int idx) {
12711272
* non-QWP delegate never holds an SF flock, so it is always treated as
12721273
* released. A {@link QwpWebSocketSender} reports it via
12731274
* {@link QwpWebSocketSender#isSlotLockReleased()} -- false means close()
1274-
* bailed early with the I/O thread still running and the flock still held.
1275+
* retained the flock because an I/O or manager worker did not stop.
12751276
*/
12761277
private static boolean flockReleased(SenderSlot s) {
12771278
Sender d = s.delegate();
@@ -1281,8 +1282,8 @@ private static boolean flockReleased(SenderSlot s) {
12811282
/**
12821283
* Reclaims one SF slot after its delegate's {@code close()} has been
12831284
* attempted. When the flock was released the index returns to the free
1284-
* set; when {@code close()} returned with the flock still held (the I/O
1285-
* thread refused to stop) the slot is retired permanently --
1285+
* set; when {@code close()} returned with the flock still held because an
1286+
* I/O or manager worker did not stop, the slot is retired permanently --
12861287
* {@code leakedSlots++} and {@code slotInUse[idx]} stays set -- so the cap
12871288
* math accounts for the lost capacity and no later borrow ever reuses the
12881289
* still-locked dir. Either way {@code closingSlots} is decremented.
@@ -1304,7 +1305,7 @@ private boolean reclaimSlot(SenderSlot s, String context) {
13041305
}
13051306
leakedSlots++;
13061307
LOG.warn("SF slot {} retired permanently{}: delegate close() returned with the flock still held " +
1307-
"(I/O thread refused to stop); pool capacity reduced by 1, now {} of {} usable [leakedSlots={}]",
1308+
"(I/O or manager worker did not stop); pool capacity reduced by 1, now {} of {} usable [leakedSlots={}]",
13081309
s.slotIndex(), context, maxSize - leakedSlots, maxSize, leakedSlots);
13091310
return false;
13101311
}

0 commit comments

Comments
 (0)