Skip to content

Commit 940e130

Browse files
committed
fix(qwp): publish close completion only after confirmed slot flock release
finishClose() wrote closeCompleted=true before slotLock.close(), so a pool thread could observe completion (isSlotLockReleased -> reprobeRetiredSlots), free the slot index, and admit a replacement sender whose SlotLock.acquire collided with the still-open flock fd -- a spurious "sf slot already in use" naming the process's own pid. SlotLock.close() also discarded the Files.close() result, reporting an unconfirmed release as completion. Reorder the terminal cleanup: release the flock first via the new SlotLock.release() (checks the close rc, retains the fd on failure so the lock state is never misreported), and publish closeCompleted only on a confirmed release. An unconfirmed release keeps closeCompleted false, degrading into the existing retired/leaked-slot accounting with the kernel's process-exit backstop. Add a @testonly hook between cleanup and release so the otherwise microsecond-wide window is deterministically testable; the new test parks the closer inside it and asserts completion is not observable while the flock is provably still held, then latches once released.
1 parent eea7f89 commit 940e130

4 files changed

Lines changed: 334 additions & 21 deletions

File tree

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

Lines changed: 104 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
import io.questdb.client.std.Files;
2929
import io.questdb.client.std.ObjList;
3030
import io.questdb.client.std.QuietCloseable;
31+
import org.jetbrains.annotations.TestOnly;
3132

3233
import java.util.concurrent.atomic.AtomicBoolean;
3334
import java.util.concurrent.locks.LockSupport;
@@ -115,15 +116,28 @@ public final class CursorSendEngine implements QuietCloseable {
115116
// thread, JVM shutdown hooks, test cleanup). volatile + synchronized
116117
// close() makes the check-and-set atomic and gives readers a fence.
117118
private volatile boolean closed;
118-
// True once close() has run its full cleanup sequence. Stays false when
119-
// a close attempt could not confirm manager-worker quiescence and had to
120-
// leak the ring/watermark/slot lock — in that case a later close() call
121-
// retries the cleanup (the worker may have exited by then). Guarded by
122-
// the synchronized close() method; never read elsewhere.
119+
// True once close() has run its full cleanup sequence INCLUDING a
120+
// CONFIRMED slot-flock release — finishClose() publishes this strictly
121+
// after SlotLock.release() reports success, never before. Pool threads
122+
// treat the flip as "the slot dir is reusable" and free the slot index
123+
// the moment they observe it (QwpWebSocketSender.isSlotLockReleased ->
124+
// SenderPool.reprobeRetiredSlots), so publishing before the release
125+
// would let a replacement engine's SlotLock.acquire collide with the
126+
// still-open fd. Stays false when a close attempt could not confirm
127+
// manager-worker quiescence (or the flock release itself failed) and had
128+
// to leak the ring/watermark/slot lock — in that case a later close()
129+
// call retries the cleanup (the worker may have exited by then).
123130
// volatile: latched by finishClose(), but read lock-free by
124131
// isCloseCompleted() from pool threads re-probing a retired slot (see the
125132
// getter for why it must not synchronize).
126133
private volatile boolean closeCompleted;
134+
// Test-only hook run by finishClose() between the terminal cleanup and
135+
// the flock release. Lets a test park the releasing thread inside the
136+
// cleanup/release window and assert that closeCompleted stays false —
137+
// i.e. that completion is never observable while the flock is still
138+
// held. volatile: finishClose may run on the manager worker's exit
139+
// thread while the hook is installed from a test thread.
140+
private volatile Runnable beforeFlockReleaseHook;
127141
// Exactly-once claim on the terminal cleanup (finishClose). Contended by
128142
// close() and a worker-exit handoff (completeDeferredClose); whoever wins
129143
// the CAS runs the cleanup, the loser never touches ring/watermark/flock.
@@ -586,9 +600,17 @@ public synchronized void close() {
586600
// cleanup runs exactly once and the worker never blocks on the
587601
// engine monitor a retried close() holds while joining it.
588602
boolean handedOff = false;
603+
boolean registrationFailed = false;
589604
try {
590605
handedOff = manager.deferUntilWorkerExit(() -> completeDeferredClose(fullyDrained));
591606
} catch (Throwable ignored) {
607+
// Allocation failure (OOM building the cleanup lambda or
608+
// growing the manager's exitCleanups list). Unlike the exact
609+
// false return below, a throw carries NO worker-liveness
610+
// information — the two must never be conflated, or the
611+
// inline cleanup below would release worker-reachable
612+
// resources under a possibly-live worker.
613+
registrationFailed = true;
592614
}
593615
if (handedOff) {
594616
LOG.error("SF manager worker did not quiesce during engine close; ring, watermark "
@@ -598,6 +620,23 @@ public synchronized void close() {
598620
+ "the stale worker on slot {}", sfDir == null ? "<memory>" : sfDir);
599621
return;
600622
}
623+
if (registrationFailed) {
624+
// The handoff never registered and the worker was never
625+
// observed — it must be presumed live and mid service pass.
626+
// Retain every worker-reachable resource (ring, watermark,
627+
// segment files, slot flock) and leave terminalCleanupClaimed
628+
// unclaimed and closeCompleted false, exactly like the
629+
// shared-manager leak branch: manager.close() above already
630+
// stopped the worker loop, so a retried close() converges via
631+
// isWorkerReaped() once the in-flight pass ends. The kernel
632+
// releases the slot flock on process exit regardless.
633+
LOG.error("SF worker-exit handoff registration failed during engine close; "
634+
+ "leaking the ring, watermark and slot lock so a possibly-live "
635+
+ "worker cannot corrupt a future engine on slot {}. close() may be "
636+
+ "invoked again to retry cleanup once the worker has exited.",
637+
sfDir == null ? "<memory>" : sfDir);
638+
return;
639+
}
601640
// Handoff rejected: the worker loop exited between the failed
602641
// bounded join and the registration attempt (both sides share the
603642
// manager's lock, so this observation is exact). A worker past
@@ -629,8 +668,13 @@ public synchronized void close() {
629668

630669
/**
631670
* Terminal cleanup: closes the ring and watermark, unlinks drained
632-
* segment files, releases the slot flock, and latches
633-
* {@link #closeCompleted}. The caller must hold the engine monitor and
671+
* segment files, releases the slot flock, and — only once the release
672+
* is <b>confirmed</b> — latches {@link #closeCompleted}. Publish order
673+
* is load-bearing: pools free the slot index the instant they observe
674+
* {@code closeCompleted} (via {@code isSlotLockReleased()}), so it must
675+
* never be visible while the flock fd is still open, or a replacement
676+
* engine races the release and fails acquisition on a live slot.
677+
* The caller must hold the engine monitor and
634678
* must have established that the manager worker can no longer touch the
635679
* slot directory (reaped, provably exited, or running this on its own
636680
* exit path) AND have won the {@link #terminalCleanupClaimed} CAS — the
@@ -669,21 +713,64 @@ private void finishClose(boolean fullyDrained) {
669713
} catch (Throwable ignored) {
670714
}
671715
}
672-
closeCompleted = true;
673716
} finally {
674717
// Reaching finishClose at all requires established quiescence, so
675718
// releasing the flock is safe even if a step above threw. Leaking
676719
// it would strand the slot until process exit for no reason.
720+
//
721+
// ORDER MATTERS: release the flock FIRST, verify it, and only
722+
// then publish closeCompleted. Pools read isCloseCompleted() as
723+
// "the slot dir is reusable" and free the slot index the moment
724+
// it flips; publishing before Files.close(fd) completes would
725+
// open a window where a replacement engine's SlotLock.acquire
726+
// collides with the still-open fd and fails with a spurious
727+
// "slot already in use".
728+
Runnable hook = beforeFlockReleaseHook;
729+
if (hook != null) {
730+
try {
731+
hook.run();
732+
} catch (Throwable ignored) {
733+
// test-only; must never block the release
734+
}
735+
}
736+
boolean released;
677737
if (slotLock != null) {
678738
try {
679-
slotLock.close();
739+
released = slotLock.release();
680740
} catch (Throwable ignored) {
681-
// best-effort; flock is also released by kernel on process exit
741+
released = false;
682742
}
743+
} else {
744+
released = true;
745+
}
746+
if (released) {
747+
closeCompleted = true;
748+
} else {
749+
// Unconfirmed release: the flock may still be held, so keep
750+
// closeCompleted false — the owning pool keeps the slot
751+
// retired (leakedSlots accounting) instead of reusing a
752+
// possibly-locked dir. The kernel releases the flock on
753+
// process exit regardless.
754+
LOG.error("SF slot flock release failed during engine close; keeping "
755+
+ "closeCompleted=false so the pool cannot reuse a possibly "
756+
+ "still-locked slot dir; the kernel releases the flock on "
757+
+ "process exit [slot={}]", sfDir == null ? "<memory>" : sfDir);
683758
}
684759
}
685760
}
686761

762+
/**
763+
* Installs a hook that {@link #finishClose} runs between the terminal
764+
* cleanup and the slot-flock release. Test-only: makes the otherwise
765+
* microsecond-wide cleanup/release window deterministic so tests can
766+
* assert {@link #isCloseCompleted()} stays false until the release is
767+
* confirmed.
768+
*/
769+
@TestOnly
770+
public void setBeforeFlockReleaseHook(Runnable hook) {
771+
this.beforeFlockReleaseHook = hook;
772+
}
773+
687774
/**
688775
* Runs on the manager worker's exit path when {@link #close()} handed
689776
* cleanup ownership to the worker (see {@code deferUntilWorkerExit}).
@@ -705,10 +792,13 @@ private void completeDeferredClose(boolean fullyDrained) {
705792
}
706793

707794
/**
708-
* Whether {@link #close()} completed all cleanup, including releasing the
709-
* SF slot lock. A false value after close means manager-worker quiescence
710-
* could not be confirmed and the worker-reachable resources were retained
711-
* deliberately — either handed to the worker's exit path (owned manager),
795+
* Whether {@link #close()} completed all cleanup, including a
796+
* <b>confirmed</b> release of the SF slot lock — the flip is published
797+
* strictly after the flock fd is closed, so observing {@code true}
798+
* guarantees the slot dir is acquirable by a replacement engine. A false
799+
* value after close means manager-worker quiescence could not be
800+
* confirmed (or the flock release itself failed) and the
801+
* worker-reachable resources were retained deliberately — either handed to the worker's exit path (owned manager),
712802
* which flips this to true the moment the worker's in-flight pass
713803
* finishes, or leaked until a retried close() (shared manager). Owners
714804
* must not reuse the slot while this is false; pools may re-probe it to

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

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -116,15 +116,43 @@ public String slotDir() {
116116
return slotDir;
117117
}
118118

119-
@Override
120-
public void close() {
121-
// Closing the fd releases the lock. We do NOT remove the .lock
122-
// file or the .lock.pid sidecar — a stale PID is harmless (next
123-
// acquirer overwrites .lock.pid on success).
124-
if (fd >= 0) {
125-
Files.close(fd);
119+
/**
120+
* Releases the flock by closing the lock fd and reports whether the
121+
* release was <b>confirmed</b>. Closing the fd releases the lock. We do
122+
* NOT remove the {@code .lock} file or the {@code .lock.pid} sidecar —
123+
* a stale PID is harmless (next acquirer overwrites {@code .lock.pid}
124+
* on success).
125+
* <p>
126+
* On close failure the fd is <b>retained</b>: the flock may still be
127+
* held by this process, so forgetting the fd would misreport the lock
128+
* state and forfeit any chance of a later retry. Idempotent — once
129+
* released, subsequent calls return {@code true}.
130+
* <p>
131+
* Owners that gate a "slot dir is reusable" signal on the release
132+
* (e.g. {@code CursorSendEngine.finishClose} publishing
133+
* {@code closeCompleted}) must call this and check the result rather
134+
* than {@link #close()}, which is best-effort by contract.
135+
*
136+
* @return {@code true} if the fd was closed (or was already released),
137+
* {@code false} if the OS reported a close failure and the
138+
* flock may still be held
139+
*/
140+
public boolean release() {
141+
if (fd < 0) {
142+
return true;
143+
}
144+
if (Files.close(fd) == 0) {
126145
fd = -1;
146+
return true;
127147
}
148+
return false;
149+
}
150+
151+
@Override
152+
public void close() {
153+
// QuietCloseable contract: best-effort, no signal. Callers that
154+
// must confirm the release use release() and check the result.
155+
release();
128156
}
129157

130158
private static String readHolder(String pidPath) {

0 commit comments

Comments
 (0)