Skip to content

Commit 8f0dd45

Browse files
committed
fix(qwp): make engine close a worker-quiescence barrier before releasing slot resources
SegmentManager.close() gives up after a bounded join, but CursorSendEngine.close() could not observe the incomplete shutdown: it closed the ring and watermark, unlinked segment files and released the slot flock while the worker could still be mid service pass - able to unlink a spare/trim path inside a slot directory that a replacement engine had already re-acquired (SF data loss after restart). - serviceRing now claims the entry as in-service under the manager lock and skips entries deregistered before the pass starts - new SegmentManager.awaitRingQuiescence(ring): bounded, interrupt-preserving barrier that confirms the worker can never touch the ring/slot again - CursorSendEngine.close() releases ring/watermark/segment files/slot lock only after confirmed quiescence (or a reaped owned worker); otherwise it leaks them deliberately, logs, and allows close() to be retried - a timed-out SegmentManager.close() hands pathScratch ownership to the worker, which frees it on exit - no permanent native leak when the worker outlives the join - regression: CursorSendEngineSlotReacquisitionTest (slot retained while worker mid-pass + retry completes cleanup; same-slot reacquisition after normal close) and an awaitRingQuiescence contract test
1 parent a0dd202 commit 8f0dd45

3 files changed

Lines changed: 309 additions & 35 deletions

File tree

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

Lines changed: 60 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,12 @@ public final class CursorSendEngine implements QuietCloseable {
114114
// thread, JVM shutdown hooks, test cleanup). volatile + synchronized
115115
// close() makes the check-and-set atomic and gives readers a fence.
116116
private volatile boolean closed;
117+
// True once close() has run its full cleanup sequence. Stays false when
118+
// a close attempt could not confirm manager-worker quiescence and had to
119+
// leak the ring/watermark/slot lock — in that case a later close() call
120+
// retries the cleanup (the worker may have exited by then). Guarded by
121+
// the synchronized close() method; never read elsewhere.
122+
private boolean closeCompleted;
117123
// Producer-thread-only: timestamp of the last "we're backpressured" log
118124
// line, used to throttle. Plain long is fine.
119125
private long lastBackpressureLogNs;
@@ -480,7 +486,7 @@ public long appendOrFsn(long payloadAddr, int payloadLen, long spinDeadlineNanos
480486

481487
@Override
482488
public synchronized void close() {
483-
if (closed) return;
489+
if (closed && closeCompleted) return;
484490
closed = true;
485491
// Capture drain state BEFORE closing the ring — once the ring is
486492
// closed, its accessors aren't safe to read. The active segment is
@@ -492,10 +498,14 @@ public synchronized void close() {
492498
// the server has no dedup state for those messageSequences.
493499
// Memory mode has no files to unlink.
494500
// The whole close sequence runs under try/finally so the slot lock
495-
// is ALWAYS released, even if manager/ring close or unlink throws —
496-
// otherwise a kernel-held flock outlives the engine and the next
497-
// sender for the same slot collides on a lock the dead engine
498-
// never released.
501+
// is released whenever it is safe to do so, even if manager/ring
502+
// close or unlink throws — otherwise a kernel-held flock outlives
503+
// the engine and the next sender for the same slot collides on a
504+
// lock the dead engine never released. The one deliberate exception
505+
// is a manager worker that failed to quiesce: releasing the lock
506+
// then would let a replacement engine acquire the slot while the
507+
// stale worker can still create/unlink segment paths inside it.
508+
boolean workerQuiescent = false;
499509
try {
500510
// "Fully drained" includes BOTH the obvious case (every published
501511
// FSN has been acked) AND the never-published case (publishedFsn
@@ -504,9 +514,15 @@ public synchronized void close() {
504514
// recreates a fresh sf-initial.sfa — would otherwise leave that
505515
// fresh empty file behind, the next scanner finds it, adopts the
506516
// slot again, and the cycle repeats forever (M6).
507-
boolean fullyDrained = sfDir != null
508-
&& (ring.publishedFsn() < 0
509-
|| ring.ackedFsn() >= ring.publishedFsn());
517+
// Own try/catch so sabotaged/broken ring state cannot skip the
518+
// quiescence barrier below or the slot-lock release in finally.
519+
boolean fullyDrained = false;
520+
try {
521+
fullyDrained = sfDir != null
522+
&& (ring.publishedFsn() < 0
523+
|| ring.ackedFsn() >= ring.publishedFsn());
524+
} catch (Throwable ignored) {
525+
}
510526
// Each cleanup step in its own try/catch so a single failure
511527
// doesn't strand later cleanups — mirrors the constructor's
512528
// catch block. Without this, a throw from manager.deregister
@@ -517,11 +533,41 @@ public synchronized void close() {
517533
manager.deregister(ring);
518534
} catch (Throwable ignored) {
519535
}
536+
// Quiescence barrier. deregister alone only removes the entry
537+
// from the registry — the worker may still be mid service pass
538+
// for this ring (creating a spare file, trimming, unlinking).
539+
// Releasing the ring, watermark, segment files, or the slot lock
540+
// while that pass is in flight lets the stale worker unlink a
541+
// path that a replacement engine — which can acquire the slot
542+
// 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+
}
520549
if (ownsManager) {
521550
try {
522551
manager.close();
523552
} catch (Throwable ignored) {
524553
}
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+
}
562+
}
563+
}
564+
if (!workerQuiescent) {
565+
LOG.error("SF manager worker did not quiesce during engine close; leaking the "
566+
+ "ring, watermark and slot lock so a stale worker cannot corrupt a "
567+
+ "future engine on slot {}. The kernel releases the slot flock on "
568+
+ "process exit; close() may be invoked again to retry cleanup once "
569+
+ "the worker has exited.", sfDir == null ? "<memory>" : sfDir);
570+
return;
525571
}
526572
try {
527573
ring.close();
@@ -551,8 +597,13 @@ public synchronized void close() {
551597
} catch (Throwable ignored) {
552598
}
553599
}
600+
closeCompleted = true;
554601
} finally {
555-
if (slotLock != null) {
602+
// Gate on quiescence: releasing the flock while a stale worker
603+
// can still touch this slot directory hands the slot to a new
604+
// engine that the worker may then corrupt. Leaking the fd is
605+
// the safe failure mode — the kernel drops it on process exit.
606+
if (workerQuiescent && slotLock != null) {
556607
try {
557608
slotLock.close();
558609
} catch (Throwable ignored) {

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

Lines changed: 174 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -100,15 +100,34 @@ public final class SegmentManager implements QuietCloseable {
100100
// registered flag check inside the trim block closes for watermark writes
101101
// and totalBytes accounting.
102102
private volatile Runnable beforeTrimSyncHook;
103+
// Entry currently being serviced by the worker thread, or null when the
104+
// worker is between service passes (or not running). Guarded by
105+
// {@link #lock}; cleared with lock.notifyAll() so awaitRingQuiescence can
106+
// block until an in-flight pass for a just-deregistered ring finishes.
107+
private RingEntry inService;
103108
private long lastDiskFullLogNs;
104109
private volatile boolean running;
110+
// pathScratch free-exactly-once coordination between a timed-out close()
111+
// and the worker's exit path. All three are guarded by {@link #lock}.
112+
// When close() gives up on the join while the worker loop has not yet
113+
// exited, it hands scratch ownership to the worker
114+
// (scratchHandedToWorker=true) and the worker frees the buffer in its
115+
// exit block; in every other case close() frees it. Without the handoff,
116+
// a worker that outlives the bounded join leaks the native scratch
117+
// buffer forever, because nobody retries manager cleanup after close()
118+
// returns.
119+
private boolean scratchFreed;
120+
private boolean scratchHandedToWorker;
121+
private boolean workerLoopExited;
105122
// Total bytes currently allocated across every segment owned by every
106123
// registered ring (active + sealed + hot-spare). Mutated by the manager
107124
// thread on provision/trim and by register/deregister callers under
108125
// {@link #lock}; the lock covers both paths so the counter stays
109126
// consistent across registration boundaries.
110127
private long totalBytes;
111-
private long workerJoinTimeoutMillis = WORKER_JOIN_TIMEOUT_MILLIS;
128+
// volatile: read by awaitRingQuiescence() from arbitrary caller threads
129+
// while the @TestOnly setter may run on another.
130+
private volatile long workerJoinTimeoutMillis = WORKER_JOIN_TIMEOUT_MILLIS;
112131
// volatile because wakeWorker() reads workerThread without holding the
113132
// monitor; the synchronized start()/close() pair handles the
114133
// start-vs-close ordering.
@@ -194,25 +213,114 @@ public synchronized void close() {
194213
}
195214
}
196215
if (t.isAlive()) {
197-
LOG.warn("SegmentManager worker did not stop before close wait completed; "
198-
+ "leaving worker-owned resources allocated");
199-
return;
216+
synchronized (lock) {
217+
if (!workerLoopExited) {
218+
// Hand pathScratch ownership to the worker: its exit
219+
// block frees the buffer under the same lock, so the
220+
// native allocation is reclaimed even though this
221+
// close() could not confirm termination. workerThread
222+
// stays set so isWorkerReaped() reports the incomplete
223+
// shutdown and a later close() can retry the join.
224+
scratchHandedToWorker = true;
225+
LOG.warn("SegmentManager worker did not stop before close wait completed; "
226+
+ "worker frees its native scratch buffer on exit");
227+
return;
228+
}
229+
}
230+
// The worker loop has already run its exit block; the thread
231+
// is at most a few instructions from terminating and can no
232+
// longer touch manager state. Fall through and reap it.
200233
}
201234
workerThread = null;
202235
}
203236
// Free the rotation-path native scratch buffer only after worker
204-
// termination has been observed. The worker is the only thread that
205-
// touches the buffer, but close() uses a bounded join; if the worker is
206-
// still alive, leaking this one scratch allocation is safer than freeing
207-
// native memory it may still read or write.
208-
pathScratch.close();
237+
// termination (or worker-loop exit) has been observed. The worker is
238+
// the only thread that touches the buffer; the scratchFreed flag
239+
// (shared with the worker's exit block) makes the free exactly-once
240+
// no matter which side runs last.
241+
synchronized (lock) {
242+
if (!scratchFreed) {
243+
scratchFreed = true;
244+
pathScratch.close();
245+
}
246+
}
247+
}
248+
249+
/**
250+
* Quiescence barrier for {@link #deregister(SegmentRing)}. Blocks until
251+
* the worker thread is provably no longer executing a service pass for
252+
* {@code ring}, or the worker-join timeout elapses. After this returns
253+
* {@code true}, the worker will never again touch the ring, its
254+
* watermark, or path names under its slot directory: deregister has
255+
* removed the entry from the registry, a stale snapshot entry that has
256+
* not started its pass is skipped by the registration check at the top
257+
* of {@link #serviceRing(RingEntry)}, and this method has observed the
258+
* end of any in-flight pass. Only then may the caller release dependent
259+
* resources (ring, watermark, segment files, slot lock).
260+
* <p>
261+
* Returns {@code true} immediately when no worker is running or when
262+
* called from the worker thread itself (test hooks inject deregister
263+
* calls there; waiting would self-deadlock). Returns {@code false} when
264+
* the in-flight pass did not finish within the timeout — the caller must
265+
* treat the worker as still live and leak rather than release.
266+
* <p>
267+
* A pending caller interrupt is preserved but does not abort the wait,
268+
* mirroring {@link #close()}.
269+
*/
270+
public boolean awaitRingQuiescence(SegmentRing ring) {
271+
Thread t = workerThread;
272+
if (t == null || t == Thread.currentThread()) {
273+
return true;
274+
}
275+
long deadlineNanos = System.nanoTime() + workerJoinTimeoutMillis * 1_000_000L;
276+
boolean interrupted = Thread.interrupted();
277+
try {
278+
synchronized (lock) {
279+
while (inService != null && inService.ring == ring) {
280+
long remainingNanos = deadlineNanos - System.nanoTime();
281+
if (remainingNanos <= 0) {
282+
return false;
283+
}
284+
try {
285+
// Round up so a sub-millisecond remainder still waits
286+
// instead of spinning through wait(0) == wait-forever.
287+
lock.wait(Math.max(1L, remainingNanos / 1_000_000L));
288+
} catch (InterruptedException ignored) {
289+
interrupted = true;
290+
}
291+
}
292+
}
293+
return true;
294+
} finally {
295+
if (interrupted) {
296+
Thread.currentThread().interrupt();
297+
}
298+
}
299+
}
300+
301+
/**
302+
* True when no manager worker thread can be running: either
303+
* {@link #start()} was never called, or a {@link #close()} confirmed
304+
* worker termination and reaped the thread. Owners use this as a
305+
* stronger fallback barrier when {@link #awaitRingQuiescence(SegmentRing)}
306+
* times out but a subsequent {@code close()} join succeeded.
307+
*/
308+
public synchronized boolean isWorkerReaped() {
309+
return workerThread == null;
209310
}
210311

211312
/**
212313
* Stop tracking {@code ring}. Pending spares for the ring are NOT
213314
* created after this returns, but already-installed spares stay with
214315
* the ring (the ring closes them on its own {@link SegmentRing#close}).
215316
* Idempotent; safe to call from any thread.
317+
* <p>
318+
* Non-blocking: a worker service pass already in flight for this ring
319+
* may still be running when this returns. Callers about to release
320+
* resources the worker can reach (the ring itself, its watermark, its
321+
* segment files, or the slot lock guarding its directory) MUST follow
322+
* up with {@link #awaitRingQuiescence(SegmentRing)} and only release on
323+
* a {@code true} result.
216324
*/
217325
public void deregister(SegmentRing ring) {
218326
synchronized (lock) {
@@ -412,6 +520,32 @@ private String nextSparePath(String dir) {
412520
}
413521

414522
private void serviceRing(RingEntry e) {
523+
// Claim the entry as in-service so deregister-side quiescence
524+
// barriers (awaitRingQuiescence) can wait for this pass to finish.
525+
// A stale snapshot entry deregistered before the pass starts is
526+
// skipped entirely: the deregistering thread may already be
527+
// releasing the ring / watermark / slot resources, so the worker
528+
// must not touch them at all. The registered check and the
529+
// in-service claim are atomic under `lock` — deregister flips
530+
// `registered` under the same lock, so it either prevents this
531+
// pass or the barrier observes it via `inService`.
532+
synchronized (lock) {
533+
if (!e.registered) {
534+
return;
535+
}
536+
inService = e;
537+
}
538+
try {
539+
serviceRing0(e);
540+
} finally {
541+
synchronized (lock) {
542+
inService = null;
543+
lock.notifyAll();
544+
}
545+
}
546+
}
547+
548+
private void serviceRing0(RingEntry e) {
415549
// 1. Provision a hot spare if the ring needs one AND we have headroom
416550
// under the disk-total cap. Cap check is per-tick; if we're capped
417551
// here, the ring stays in BACKPRESSURE_NO_SPARE until trim (step 2)
@@ -571,26 +705,40 @@ private void serviceRing(RingEntry e) {
571705
}
572706

573707
private void workerLoop() {
574-
while (running) {
575-
// Snapshot the rings under the lock so we don't hold it through the
576-
// (potentially slow) syscalls during creation/unlink. ringSnapshot
577-
// is a thread-confined field — no per-tick allocation.
578-
ringSnapshot.clear();
579-
synchronized (lock) {
580-
for (int i = 0, n = rings.size(); i < n; i++) {
581-
ringSnapshot.add(rings.getQuick(i));
708+
try {
709+
while (running) {
710+
// Snapshot the rings under the lock so we don't hold it through the
711+
// (potentially slow) syscalls during creation/unlink. ringSnapshot
712+
// is a thread-confined field — no per-tick allocation.
713+
ringSnapshot.clear();
714+
synchronized (lock) {
715+
for (int i = 0, n = rings.size(); i < n; i++) {
716+
ringSnapshot.add(rings.getQuick(i));
717+
}
582718
}
583-
}
584-
for (int i = 0, n = ringSnapshot.size(); i < n; i++) {
719+
for (int i = 0, n = ringSnapshot.size(); i < n; i++) {
720+
if (!running) break;
721+
serviceRing(ringSnapshot.getQuick(i));
722+
}
723+
// Drop strong refs so a deregistered ring becomes collectable
724+
// before the next tick (otherwise the snapshot pins it for up
725+
// to pollNanos after deregister).
726+
ringSnapshot.clear();
585727
if (!running) break;
586-
serviceRing(ringSnapshot.getQuick(i));
728+
LockSupport.parkNanos(pollNanos);
729+
}
730+
} finally {
731+
// If a timed-out close() abandoned the reap, it handed
732+
// pathScratch ownership to this thread (see close()). Freeing it
733+
// here reclaims the native buffer even when the worker outlives
734+
// every close() attempt — nobody else retries manager cleanup.
735+
synchronized (lock) {
736+
workerLoopExited = true;
737+
if (scratchHandedToWorker && !scratchFreed) {
738+
scratchFreed = true;
739+
pathScratch.close();
740+
}
587741
}
588-
// Drop strong refs so a deregistered ring becomes collectable
589-
// before the next tick (otherwise the snapshot pins it for up
590-
// to pollNanos after deregister).
591-
ringSnapshot.clear();
592-
if (!running) break;
593-
LockSupport.parkNanos(pollNanos);
594742
}
595743
}
596744

0 commit comments

Comments
 (0)