Skip to content

Commit 54cb19d

Browse files
glasstigerclaude
andcommitted
Share the slot-lock dir and stop false quarantines
Two defects in the parent-anchored logical slot lock, which this branch introduced: before it, build() took no SlotLock at all and SlotLock created only the per-process slot directory. The lock now lives under a directory every sender beneath one sf_dir must create a file in, and ensureDirectory created it 0755. Whichever process starts first owns it, so a sender running as a different uid cannot create its lock file and acquireAt throws "could not open slot lock file". That is an IllegalStateException, not a LineSenderException, so a caller following the documented contract does not catch it and build() fails outright. Create that one directory 0777 instead and let the deployment's umask decide the sharing policy, exactly as it already does for the sf_dir the lock lives under: the usual 022 leaves it at the same 0755 as before, while a shared-group deployment gets the group-writable directory it needs. Slot directories keep the restrictive mode -- one process creates each and only that process writes inside it. The drainer then turned that failure into a permanent one. isLockContention matches only "already in use", so every other acquireLogical failure -- the permission case above, or a momentary fd exhaustion -- rethrows into the outer catch, which drops a .failed sentinel. That runs BEFORE CursorSendEngine takes the slot's own .lock, so the drainer quarantines a healthy slot another live process still owns. Nothing ever removes the sentinel and isCandidateOrphan treats it as disqualifying, so when the real owner later died its unacked data would stay stranded until an operator intervened. Write the sentinel only when engine is non-null, which is set once the engine holds the directory-local lock and so marks genuine adoption; a pre-adoption failure now leaves the slot for the next scan. The auth/upgrade and wire sentinels are unchanged -- both fire after adoption and are sanctioned orphan terminals. Both tests fail without their fix: the mode assertion reports 493 where 511 is required, and the drainer test finds the sentinel in a slot it never locked. The drainer test blocks the lock by planting .slot-locks as a regular file rather than by chmod, so it reproduces the failure on any platform and does not silently pass when CI runs as root. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 4c10246 commit 54cb19d

5 files changed

Lines changed: 144 additions & 8 deletions

File tree

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

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -728,10 +728,27 @@ public void run() {
728728
LOG.debug("drainer setup failed for slot {}: {}", slotPath, msg, t);
729729
}
730730
lastErrorMessage = msg;
731-
try {
732-
OrphanScanner.markFailed(slotPath, "setup: " + msg);
733-
} catch (Throwable ignored) {
734-
// best-effort
731+
// Quarantine ONLY a slot this drainer actually adopted. engine is
732+
// assigned after CursorSendEngine has taken the slot's own .lock, so
733+
// a null engine here means the failure happened before adoption --
734+
// while merely probing a slot another live process still owns.
735+
//
736+
// The failures that reach this point pre-adoption are LOCAL and
737+
// usually transient: acquireLogical rethrows anything that is not
738+
// lock contention, so a permission problem on the shared
739+
// .slot-locks directory or a momentary fd exhaustion lands here.
740+
// Writing the sentinel then would exclude a healthy, in-use slot
741+
// from orphan drain permanently (OrphanScanner.isCandidateOrphan
742+
// treats .failed as disqualifying, and nothing ever removes it), so
743+
// when its real owner later dies its unacked data would be stranded
744+
// until an operator intervened. Leave the slot alone; the next scan
745+
// retries it.
746+
if (engine != null) {
747+
try {
748+
OrphanScanner.markFailed(slotPath, "setup: " + msg);
749+
} catch (Throwable ignored) {
750+
// best-effort
751+
}
735752
}
736753
outcome = DrainOutcome.FAILED;
737754
} finally {

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

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,9 @@ private SlotLock(FilesFacade ff, String slotDir, int fd) {
8787
*/
8888
public static SlotLock acquire(String slotDir) {
8989
validateSlotDir(slotDir);
90-
ensureDirectory(FilesFacade.INSTANCE, slotDir, "slot dir");
90+
// DIR_MODE_DEFAULT is right here: one process creates its own slot
91+
// directory and only that process writes inside it.
92+
ensureDirectory(FilesFacade.INSTANCE, slotDir, "slot dir", Files.DIR_MODE_DEFAULT);
9193
String lockPath = slotDir + "/" + LOCK_FILE_NAME;
9294
String pidPath = slotDir + "/" + LOCK_PID_FILE_NAME;
9395
return acquireAt(FilesFacade.INSTANCE, slotDir, lockPath, pidPath);
@@ -118,7 +120,17 @@ public static SlotLock acquireLogical(FilesFacade ff, String slotDir) {
118120
throw new IllegalArgumentException(
119121
"slotDir must contain a parent and slot name: " + slotDir);
120122
}
121-
ensureDirectory(ff, paths[0], "logical slot lock dir");
123+
// SHARED_DIR_MODE, not DIR_MODE_DEFAULT: unlike a slot directory -- which
124+
// one process creates and only that process writes -- this directory is
125+
// shared by every sender under the same sf_dir, and each of them must
126+
// CREATE its own lock file inside it. At 0755 the first process to start
127+
// owns it and a sender running as a different uid cannot create its lock
128+
// file at all, so build() throws before it can open the slot. Passing
129+
// 0777 hands the sharing policy to the deployment's umask, which is what
130+
// already governs sf_dir itself: an unset umask leaves this identical to
131+
// the old 0755, while a shared-group deployment (umask 002) gets the
132+
// group-writable directory it needs.
133+
ensureDirectory(ff, paths[0], "logical slot lock dir", Files.DIR_MODE_SHARED);
122134
return acquireAt(ff, slotDir, paths[1], paths[2]);
123135
}
124136

@@ -185,9 +197,9 @@ private static SlotLock acquireAt(FilesFacade ff, String slotDir, String lockPat
185197
}
186198
}
187199

188-
private static void ensureDirectory(FilesFacade ff, String path, String description) {
200+
private static void ensureDirectory(FilesFacade ff, String path, String description, int mode) {
189201
if (!ff.exists(path)) {
190-
int rc = ff.mkdir(path, Files.DIR_MODE_DEFAULT);
202+
int rc = ff.mkdir(path, mode);
191203
// Multiple senders may create the shared parent lock directory
192204
// concurrently. Treat EEXIST as success, just as the builder does
193205
// for the SF root itself.

core/src/main/java/io/questdb/client/std/Files.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,17 @@ public final class Files {
7575
* others may traverse and read but not modify. 0755 octal
7676
*/
7777
public static final int DIR_MODE_DEFAULT = 493;
78+
/**
79+
* Creation mode for a directory that processes running as DIFFERENT users
80+
* must all create files in -- the store-and-forward logical slot-lock
81+
* directory being the one such case. 0777 octal, so the deployment's umask
82+
* decides the actual sharing policy exactly as it does for the sf_dir these
83+
* live under: the usual 022 yields the same 0755 as
84+
* {@link #DIR_MODE_DEFAULT}, while a shared-group deployment (umask 002)
85+
* gets the group-writable directory a second uid needs. Do NOT use this for
86+
* a directory only its creator writes to.
87+
*/
88+
public static final int DIR_MODE_SHARED = 511;
7889

7990
/** {@code dirent.d_type} sentinel: type unknown (filesystem doesn't fill it). */
8091
public static final int DT_UNKNOWN = 0;

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

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939

4040
import static org.junit.Assert.assertEquals;
4141
import static org.junit.Assert.assertFalse;
42+
import static org.junit.Assert.assertTrue;
4243

4344
public class BackgroundDrainerOrphanTailTest {
4445

@@ -85,6 +86,59 @@ public void testDeferredOnlyRecoveredTailRetiresWithoutConnecting() throws Excep
8586
});
8687
}
8788

89+
@Test
90+
public void testPreAdoptionSetupFailureDoesNotQuarantineTheSlot() throws Exception {
91+
// A drainer that cannot even take the logical lock has NOT adopted the
92+
// slot -- another live process may still own it. The failures that reach
93+
// that point are local and usually transient (acquireLogical rethrows
94+
// anything that is not lock contention, so a permission problem on the
95+
// shared .slot-locks directory or a momentary fd exhaustion lands
96+
// there). Writing the .failed sentinel then would exclude a healthy,
97+
// in-use slot from orphan drain permanently -- nothing ever removes it --
98+
// so when its real owner later died its unacked data would be stranded
99+
// until an operator intervened.
100+
//
101+
// Blocks the lock by planting .slot-locks as a regular FILE: acquireLogical
102+
// sees it exists, skips the mkdir, and then cannot open a lock file
103+
// beneath it. That reproduces the pre-adoption failure on every platform
104+
// and without depending on the test user's privileges -- a chmod-based
105+
// version would silently pass when CI runs as root.
106+
String slotPath = temporaryFolder.newFolder("live-slot").getAbsolutePath();
107+
TestUtils.assertMemoryLeak(() -> {
108+
try (CursorSendEngine engine = new CursorSendEngine(slotPath, SEGMENT_SIZE_BYTES)) {
109+
appendDeferredFrame(engine);
110+
}
111+
String parent = slotPath.substring(0, slotPath.lastIndexOf('/'));
112+
String lockDirPath = parent + "/.slot-locks";
113+
int fd = Files.openRW(lockDirPath);
114+
assertTrue("could not plant the blocking file", fd > -1);
115+
Files.close(fd);
116+
117+
AtomicInteger connectAttempts = new AtomicInteger();
118+
BackgroundDrainer drainer = new BackgroundDrainer(
119+
slotPath,
120+
SEGMENT_SIZE_BYTES,
121+
1L << 20,
122+
() -> {
123+
connectAttempts.incrementAndGet();
124+
throw new AssertionError("setup must fail before any connect");
125+
},
126+
5_000L,
127+
1L,
128+
5L,
129+
true,
130+
200L);
131+
132+
drainer.run();
133+
134+
assertEquals("setup must fail before the reconnect factory is used",
135+
0, connectAttempts.get());
136+
assertEquals(BackgroundDrainer.DrainOutcome.FAILED, drainer.outcome());
137+
assertFalse("a slot this drainer never adopted must not be quarantined",
138+
Files.exists(slotPath + "/" + OrphanScanner.FAILED_SENTINEL_NAME));
139+
});
140+
}
141+
88142
private static void appendDeferredFrame(CursorSendEngine engine) {
89143
long buffer = Unsafe.malloc(16, MemoryTag.NATIVE_DEFAULT);
90144
try {

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

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,32 @@ public void testLogicalLockRejectsInvalidPaths() throws Exception {
141141
});
142142
}
143143

144+
@Test
145+
public void testSharedLockDirectoryIsCreatedUmaskGoverned() throws Exception {
146+
// .slot-locks is the one directory every sender under an sf_dir must
147+
// CREATE a file in, so unlike a slot directory it cannot be created
148+
// 0755: the first process to start would own it and a sender running as
149+
// a different uid could not create its lock file, failing build(). Pass
150+
// 0777 and let the deployment's umask decide, exactly as it already does
151+
// for the sf_dir these live under.
152+
TestUtils.assertMemoryLeak(() -> {
153+
String slot = parentDir + "/mode-check";
154+
String lockDir = parentDir + "/.slot-locks";
155+
RecordingMkdirFacade ff = new RecordingMkdirFacade();
156+
try (SlotLock ignored = SlotLock.acquireLogical(ff, slot)) {
157+
assertEquals("the shared lock dir must be created umask-governed",
158+
Files.DIR_MODE_SHARED, ff.modeFor(lockDir));
159+
}
160+
// The per-slot directory keeps the restrictive mode: one process
161+
// creates it and only that process writes inside it.
162+
RecordingMkdirFacade slotFf = new RecordingMkdirFacade();
163+
String ownSlot = parentDir + "/own-slot";
164+
slotFf.mkdir(ownSlot, Files.DIR_MODE_DEFAULT);
165+
assertEquals("a slot dir must not be loosened",
166+
Files.DIR_MODE_DEFAULT, slotFf.modeFor(ownSlot));
167+
});
168+
}
169+
144170
@Test
145171
public void testLogicalLockReportsLockDirectoryCreationFailure() throws Exception {
146172
TestUtils.assertMemoryLeak(() -> {
@@ -259,6 +285,22 @@ private static boolean isDir(String path) {
259285
return true;
260286
}
261287

288+
/** Records the mode each directory was created with, then delegates. */
289+
private static final class RecordingMkdirFacade extends DelegatingFilesFacade {
290+
private final java.util.Map<String, Integer> modes = new java.util.HashMap<>();
291+
292+
int modeFor(String path) {
293+
Integer mode = modes.get(path);
294+
return mode == null ? -1 : mode;
295+
}
296+
297+
@Override
298+
public int mkdir(String path, int mode) {
299+
modes.put(path, mode);
300+
return super.mkdir(path, mode);
301+
}
302+
}
303+
262304
private static final class LockDirectoryFailureFacade extends DelegatingFilesFacade {
263305
private final String lockDir;
264306
private int openRwCalls;

0 commit comments

Comments
 (0)