Skip to content

Commit c1587c9

Browse files
glasstigerclaude
andcommitted
Reclaim logical slot locks on fully-drained close
The store-and-forward logical slot lock lives in the shared <sf_dir>/.slot-locks directory, outside the slot dir so it survives a slot rename. Nothing reclaimed it: unlike the slot's .ack-watermark and .symbol-dict side-files, which CursorSendEngine.close() unlinks via removeOrphan on a fully-drained close, the logical lock and its .lock.pid sidecar were left behind. Under rotating senderIds sharing one sf_dir, .slot-locks accumulated one dead lock+pid pair per distinct slot name for the lifetime of sf_dir. SlotLock now exposes removeOrphanLogical, mirroring AckWatermark.removeOrphan and PersistedSymbolDict.removeOrphan, and CursorSendEngine's fully-drained cleanup calls it alongside the sibling side-file removals. acquireLogical and removeOrphanLogical share a resolveLogicalLock helper so the two can never target different files. The unlink is best-effort: the retiring engine still holds the directory-local lock, the real multi-writer guard, and a fully-drained retirement performs no rename, so the logical lock is not in use; a drainer momentarily mid-acquireLogical fails its immediate candidacy and directory-lock check and backs off. SlotLockTest covers the removal and its silent no-op on absent or invalid input; EngineCloseSlotLockReleaseTest adds an end-to-end test that a fully-drained engine close reclaims a build's orphaned logical lock, verified to fail without the CursorSendEngine change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 2257daa commit c1587c9

4 files changed

Lines changed: 157 additions & 14 deletions

File tree

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -728,6 +728,17 @@ public synchronized void close() {
728728
PersistedSymbolDict.removeOrphan(sfDir);
729729
} catch (Throwable ignored) {
730730
}
731+
try {
732+
// The logical slot lock lives OUTSIDE the slot dir (in the
733+
// shared .slot-locks dir) so it survives a slot rename; the
734+
// fully-drained retirement that removes this slot's other
735+
// side-files must remove it too, or .slot-locks accumulates a
736+
// dead lock+pid pair per distinct slot name for the lifetime of
737+
// sf_dir. This engine still holds the directory-local lock, so
738+
// the best-effort unlink is safe (see SlotLock.removeOrphanLogical).
739+
SlotLock.removeOrphanLogical(sfDir);
740+
} catch (Throwable ignored) {
741+
}
731742
}
732743
} finally {
733744
if (slotLock != null) {

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

Lines changed: 66 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -113,20 +113,51 @@ public static SlotLock acquireLogical(String slotDir) {
113113
@TestOnly
114114
public static SlotLock acquireLogical(FilesFacade ff, String slotDir) {
115115
validateSlotDir(slotDir);
116-
Path slotPath = Paths.get(slotDir);
117-
Path parentPath = slotPath.getParent();
118-
Path slotNamePath = slotPath.getFileName();
119-
if (parentPath == null || slotNamePath == null || slotNamePath.toString().isEmpty()) {
116+
String[] paths = resolveLogicalLock(slotDir);
117+
if (paths == null) {
120118
throw new IllegalArgumentException(
121119
"slotDir must contain a parent and slot name: " + slotDir);
122120
}
123-
String parentDir = parentPath.toString();
124-
String slotName = slotNamePath.toString();
125-
String logicalLockDir = parentDir + "/" + LOGICAL_LOCK_DIR_NAME;
126-
ensureDirectory(ff, logicalLockDir, "logical slot lock dir");
127-
String lockPath = logicalLockDir + "/" + slotName + ".lock";
128-
String pidPath = logicalLockDir + "/" + slotName + ".lock.pid";
129-
return acquireAt(ff, slotDir, lockPath, pidPath);
121+
ensureDirectory(ff, paths[0], "logical slot lock dir");
122+
return acquireAt(ff, slotDir, paths[1], paths[2]);
123+
}
124+
125+
/**
126+
* Best-effort removal of the parent-anchored logical lock files
127+
* ({@code <parent>/.slot-locks/<slotName>.lock} and its {@code .lock.pid}
128+
* sidecar) for {@code slotDir}, mirroring {@link AckWatermark#removeOrphan}
129+
* and {@link PersistedSymbolDict#removeOrphan} for the slot's in-directory
130+
* side-files. The fully-drained close that permanently retires a slot calls
131+
* this: the logical lock lives OUTSIDE the slot dir (so it survives a slot
132+
* rename), so nothing else reclaims it, and without this
133+
* {@code <sf_dir>/.slot-locks} accumulates one dead lock+pid pair per
134+
* distinct slot name for the lifetime of {@code sf_dir} -- unbounded under
135+
* rotating {@code senderId}s.
136+
* <p>
137+
* The unlink is best-effort and safe: the retiring engine still holds the
138+
* slot's directory-local {@link #acquire} lock (the real multi-writer guard)
139+
* across this cleanup, and fully-drained retirement performs no rename, so
140+
* the logical lock -- which only guards the close/rename/recreate transition
141+
* -- is not in use. An orphan drainer momentarily mid-{@link #acquireLogical}
142+
* fails its immediately-following candidacy / directory-lock check (the slot
143+
* is gone) and backs off. Unlike {@link #acquireLogical}, an unusable
144+
* {@code slotDir} is a silent no-op here rather than a throw.
145+
*/
146+
public static void removeOrphanLogical(String slotDir) {
147+
removeOrphanLogical(FilesFacade.INSTANCE, slotDir);
148+
}
149+
150+
/** Facade-aware variant of {@link #removeOrphanLogical(String)}. */
151+
public static void removeOrphanLogical(FilesFacade ff, String slotDir) {
152+
if (slotDir == null || slotDir.isEmpty()) {
153+
return;
154+
}
155+
String[] paths = resolveLogicalLock(slotDir);
156+
if (paths == null) {
157+
return;
158+
}
159+
ff.remove(paths[1]);
160+
ff.remove(paths[2]);
130161
}
131162

132163
private static SlotLock acquireAt(FilesFacade ff, String slotDir, String lockPath, String pidPath) {
@@ -167,6 +198,30 @@ private static void ensureDirectory(FilesFacade ff, String path, String descript
167198
}
168199
}
169200

201+
/**
202+
* Resolves the parent-anchored logical lock layout for {@code slotDir}:
203+
* {@code [0]} the {@code .slot-locks} directory, {@code [1]} the
204+
* {@code <slotName>.lock} path, {@code [2]} the {@code .lock.pid} path.
205+
* Returns {@code null} when {@code slotDir} has no usable parent or name.
206+
* Shared by {@link #acquireLogical} and {@link #removeOrphanLogical} so the
207+
* two can never target different files.
208+
*/
209+
private static String[] resolveLogicalLock(String slotDir) {
210+
Path slotPath = Paths.get(slotDir);
211+
Path parentPath = slotPath.getParent();
212+
Path slotNamePath = slotPath.getFileName();
213+
if (parentPath == null || slotNamePath == null || slotNamePath.toString().isEmpty()) {
214+
return null;
215+
}
216+
String logicalLockDir = parentPath + "/" + LOGICAL_LOCK_DIR_NAME;
217+
String slotName = slotNamePath.toString();
218+
return new String[]{
219+
logicalLockDir,
220+
logicalLockDir + "/" + slotName + LOCK_FILE_NAME,
221+
logicalLockDir + "/" + slotName + LOCK_PID_FILE_NAME
222+
};
223+
}
224+
170225
private static void validateSlotDir(String slotDir) {
171226
if (slotDir == null || slotDir.isEmpty()) {
172227
throw new IllegalArgumentException("slotDir must not be empty");

core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/EngineCloseSlotLockReleaseTest.java

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@
3838
import java.nio.file.Paths;
3939

4040
import static org.junit.Assert.assertEquals;
41+
import static org.junit.Assert.assertFalse;
42+
import static org.junit.Assert.assertTrue;
4143
import static org.junit.Assert.fail;
4244

4345
/**
@@ -84,22 +86,34 @@ public void setUp() {
8486
@After
8587
public void tearDown() {
8688
if (sfDir == null) return;
87-
long find = Files.findFirst(sfDir);
89+
rmDirRecursive(sfDir);
90+
}
91+
92+
private static void rmDirRecursive(String dir) {
93+
if (!Files.exists(dir)) return;
94+
long find = Files.findFirst(dir);
8895
if (find > 0) {
8996
try {
9097
int rc = 1;
9198
while (rc > 0) {
9299
String name = Files.utf8ToString(Files.findName(find));
93100
if (name != null && !".".equals(name) && !"..".equals(name)) {
94-
Files.remove(sfDir + "/" + name);
101+
String child = dir + "/" + name;
102+
long probe = Files.findFirst(child);
103+
if (probe > 0) {
104+
Files.findClose(probe);
105+
rmDirRecursive(child);
106+
} else {
107+
Files.remove(child);
108+
}
95109
}
96110
rc = Files.findNext(find);
97111
}
98112
} finally {
99113
Files.findClose(find);
100114
}
101115
}
102-
Files.remove(sfDir);
116+
Files.remove(dir);
103117
}
104118

105119
@Test(timeout = 10_000L)
@@ -176,4 +190,33 @@ public void testSlotLockReleasedEvenIfRingCloseThrows() throws Exception {
176190
}
177191
});
178192
}
193+
194+
@Test(timeout = 10_000L)
195+
public void testFullyDrainedCloseRemovesLogicalSlotLock() throws Exception {
196+
TestUtils.assertMemoryLeak(() -> {
197+
// The engine's slot lives one level under sfDir, so the parent-anchored
198+
// logical lock lands at <sfDir>/.slot-locks/slot0.lock -- inside the tree
199+
// tearDown cleans.
200+
String slotDir = sfDir + "/slot0";
201+
202+
// Simulate the build's transient logical lock: acquire and release it,
203+
// leaving the shared-dir lock file behind exactly as a real build does
204+
// (close() releases the flock but keeps the file so it survives a rename).
205+
SlotLock.acquireLogical(slotDir).close();
206+
String lockFile = sfDir + "/.slot-locks/slot0.lock";
207+
assertTrue("precondition: a build's logical lock leaves a file behind",
208+
Files.exists(lockFile));
209+
210+
// A fresh engine that never publishes is fully drained on close, so its
211+
// close() runs the retirement cleanup that must reclaim the logical lock.
212+
try (CursorSendEngine engine = new CursorSendEngine(slotDir, 4L * 1024 * 1024)) {
213+
assertEquals(slotDir, engine.sfDir());
214+
}
215+
216+
assertFalse("fully-drained engine close must reclaim the orphaned logical "
217+
+ "slot lock; otherwise .slot-locks grows unbounded under "
218+
+ "rotating senderIds",
219+
Files.exists(lockFile));
220+
});
221+
}
179222
}

core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SlotLockTest.java

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,40 @@ public void testLogicalLockReportsLockDirectoryCreationFailure() throws Exceptio
167167
});
168168
}
169169

170+
@Test
171+
public void testRemoveOrphanLogicalDeletesLockAndPidFiles() throws Exception {
172+
TestUtils.assertMemoryLeak(() -> {
173+
String slot = parentDir + "/alpha";
174+
assertEquals(0, Files.mkdir(slot, Files.DIR_MODE_DEFAULT));
175+
// acquireLogical anchors the lock under the shared parent .slot-locks dir.
176+
String lockFile = parentDir + "/.slot-locks/alpha.lock";
177+
String pidFile = parentDir + "/.slot-locks/alpha.lock.pid";
178+
try (SlotLock ignored = SlotLock.acquireLogical(slot)) {
179+
assertTrue("logical .lock created", Files.exists(lockFile));
180+
assertTrue("logical .lock.pid created", Files.exists(pidFile));
181+
}
182+
// close() releases the flock but deliberately keeps the file (it must
183+
// outlast a slot rename); only the fully-drained retirement reclaims it.
184+
assertTrue("logical .lock survives close", Files.exists(lockFile));
185+
SlotLock.removeOrphanLogical(slot);
186+
assertFalse("logical .lock removed on retirement", Files.exists(lockFile));
187+
assertFalse("logical .lock.pid removed on retirement", Files.exists(pidFile));
188+
});
189+
}
190+
191+
@Test
192+
public void testRemoveOrphanLogicalIsSilentNoOpWhenAbsentOrInvalid() throws Exception {
193+
TestUtils.assertMemoryLeak(() -> {
194+
// Never-locked slot: nothing to remove, must not throw.
195+
SlotLock.removeOrphanLogical(parentDir + "/never-locked");
196+
// Unlike acquireLogical (which throws on these), the retirement cleanup
197+
// is best-effort and tolerates unusable input silently.
198+
SlotLock.removeOrphanLogical(null);
199+
SlotLock.removeOrphanLogical("");
200+
SlotLock.removeOrphanLogical("slot"); // no parent component
201+
});
202+
}
203+
170204
@Test
171205
public void testTwoDifferentSlotsCoexist() throws Exception {
172206
TestUtils.assertMemoryLeak(() -> {

0 commit comments

Comments
 (0)