Skip to content

Commit 172cfe6

Browse files
glasstigerclaude
andcommitted
Fix SF recovery, lock and quarantine safety bugs
Address blocking review findings in the store-and-forward paths. SegmentRing.openExisting no longer detaches the active segment from `opened` before the ring owns it: `new SegmentRing` and the sealed-add loop can throw (ObjList growth, native OOM), and the catch iterated only `opened`, leaking the active segment's mapping and fd. Ownership now transfers in one step and `opened` is cleared last. PersistedSymbolDict.open gains the in-flight holder MmapSegment already uses. Recovery reads the file through a mapping, and pre-JDK-21 HotSpot delivers an unsafe-access fault at the method's return, past the inner catch; without a holder the built instance's fd and buffer leak and the fault escapes build() instead of degrading to full-dictionary frames. commitMappedChunk checksums the append mapping with Crc32c.updateUnsafe rather than the native update. ff.allocate can leave the window over uncommitted blocks (ENOSPC, quota, a crash-torn tail); a SIGBUS there aborts the JVM through JNI but is a catchable InternalError at an Unsafe site, matching scanFrames. SlotLock.removeOrphanLogical acquires the lock before unlinking it. Removing a file another party holds frees the pathname without releasing the flock, so the next acquireLogical locks a second inode -- two owners of a mutual-exclusion lock. QwpWebSocketSender.close carries a reclaim-logical-lock flag that connect()'s rollback clears, so a failed connect no longer unlinks the lock Sender.build() still holds. Sender.build reports an abandoned quarantined slot through the configured errorHandler, not only via LOG.error: the client ships slf4j-api with no binding, so an embedder without a provider would learn of the loss nowhere. Files.DIR_MODE_SHARED's javadoc no longer claims the sticky bit survives: Linux mkdir(2) masks it off, so the restricted-deletion guarantee is best-effort and lock safety rests on the acquire-before-unlink above. SlotLockTest pins the held-lock case both ways. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 6d007c7 commit 172cfe6

7 files changed

Lines changed: 201 additions & 30 deletions

File tree

core/src/main/java/io/questdb/client/Sender.java

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1658,7 +1658,7 @@ public Sender build() {
16581658
quarantined = true;
16591659
cursorEngine = quarantineTornSlot(
16601660
cursorEngine, e, sfDir, senderId, slotPath, actualSfMaxBytes,
1661-
actualSfMaxTotalBytes, actualSfAppendDeadlineNanos);
1661+
actualSfMaxTotalBytes, actualSfAppendDeadlineNanos, errorHandler);
16621662
} catch (Throwable t) {
16631663
// connect() failed before ownership of cursorEngine
16641664
// transferred — close it ourselves. close(false)
@@ -3065,7 +3065,8 @@ private static long parseSizeValue(@NotNull StringSink value, @NotNull String na
30653065
private static CursorSendEngine quarantineTornSlot(
30663066
CursorSendEngine torn, UnreplayableSlotException cause, String sfDir,
30673067
String senderId, String slotPath,
3068-
long sfMaxBytes, long sfMaxTotalBytes, long sfAppendDeadlineNanos
3068+
long sfMaxBytes, long sfMaxTotalBytes, long sfAppendDeadlineNanos,
3069+
io.questdb.client.SenderErrorHandler errorHandler
30693070
) {
30703071
// The verdict, and the reason, come from the recovery seed -- the only code that
30713072
// has tried every source of truth. Recomputing them here would mean a second,
@@ -3106,6 +3107,34 @@ private static CursorSendEngine quarantineTornSlot(
31063107
LOG.error("{} -- the slot has been set aside at {} and the affected data must be resent; "
31073108
+ "this sender continues on a fresh, empty slot at {}",
31083109
detail, quarantinePath, slotPath);
3110+
// build() no longer throws for this -- it starts the producer on a fresh slot so
3111+
// the outage stays bounded. But abandoning buffered rows is precisely the event a
3112+
// caller must be able to act on, and LOG.error alone cannot carry it: this client
3113+
// ships slf4j-api with no binding, so an embedding app with no provider gets a NOP
3114+
// logger and the loss is announced nowhere. Deliver it programmatically too, so an
3115+
// errorHandler can alert / page / record it. Dispatched synchronously here because
3116+
// the async SenderErrorDispatcher belongs to the connected sender, which does not
3117+
// exist yet at build time. A throwing handler must not turn a contained outage back
3118+
// into a failed build, so swallow anything it raises.
3119+
if (errorHandler != null) {
3120+
try {
3121+
errorHandler.onError(new SenderError(
3122+
SenderError.Category.PROTOCOL_VIOLATION,
3123+
SenderError.Policy.TERMINAL,
3124+
SenderError.NO_STATUS_BYTE,
3125+
detail + " [slot set aside at " + quarantinePath
3126+
+ "; sender continues on a fresh slot at " + slotPath + ']',
3127+
SenderError.NO_MESSAGE_SEQUENCE,
3128+
SenderError.NO_MESSAGE_SEQUENCE,
3129+
SenderError.NO_MESSAGE_SEQUENCE,
3130+
null,
3131+
System.nanoTime()
3132+
));
3133+
} catch (Throwable handlerFailure) {
3134+
LOG.error("sender error handler threw while reporting a quarantined slot: {}",
3135+
String.valueOf(handlerFailure));
3136+
}
3137+
}
31093138
return new CursorSendEngine(slotPath, sfMaxBytes, sfMaxTotalBytes, sfAppendDeadlineNanos);
31103139
}
31113140

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

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,17 @@ public class QwpWebSocketSender implements Sender {
306306
// contained in that loop and never reach the producer.
307307
private Sender.InitialConnectMode initialConnectMode = Sender.InitialConnectMode.OFF;
308308
private boolean ownsCursorEngine;
309+
// Whether close() may let the engine reclaim the parent-anchored LOGICAL slot lock.
310+
// False only while an outer frame holds it: Sender.build() acquires it for the whole
311+
// construct -> connect -> quarantine transition, and connect()'s own rollback closes
312+
// this sender from INSIDE that scope. A fresh slot is "fully drained" by definition
313+
// (publishedFsn < 0), so the default close(true) took the reclaim branch and unlinked
314+
// the very lock file build() was holding -- on POSIX that frees the pathname without
315+
// releasing the flock, so the next acquireLogical creates a SECOND inode and locks it.
316+
// build()'s own careful close(false) calls could not prevent it, because connect()
317+
// closed the engine first. Reset to true once connect() hands ownership back, so a
318+
// later user-initiated close() still retires the lock normally.
319+
private boolean reclaimLogicalSlotLockOnClose = true;
309320
private long pendingBytes;
310321
// Set true by close() once the SF slot flock has been released (the normal
311322
// teardown path). Stays false if close() bailed early with the I/O thread
@@ -821,6 +832,10 @@ public static QwpWebSocketSender connect(
821832
// handler -- and close() accumulates cleanup errors and ends in
822833
// rethrowTerminal, so letting a close failure propagate here would REPLACE t
823834
// and silently demote a recoverable slot back to the permanent build() brick.
835+
// This rollback always runs inside Sender.build()'s acquireLogical scope, so
836+
// the logical slot lock is held one frame up. Closing the engine with the
837+
// default reclaim would unlink the lock file build() is still holding.
838+
sender.reclaimLogicalSlotLockOnClose = false;
824839
try {
825840
sender.close();
826841
} catch (Throwable closeFailure) {
@@ -1283,7 +1298,7 @@ public void close() {
12831298
if (ownsCursorEngine && cursorEngine != null && cursorSendLoop != null
12841299
&& !cursorSendLoop.delegateEngineClose()) {
12851300
try {
1286-
cursorEngine.close();
1301+
cursorEngine.close(reclaimLogicalSlotLockOnClose);
12871302
} catch (Throwable t) {
12881303
LOG.error("Error closing owned CursorSendEngine: {}", String.valueOf(t));
12891304
terminalError = captureCloseError(terminalError, t);
@@ -1325,7 +1340,7 @@ public void close() {
13251340

13261341
if (ownsCursorEngine && cursorEngine != null) {
13271342
try {
1328-
cursorEngine.close();
1343+
cursorEngine.close(reclaimLogicalSlotLockOnClose);
13291344
} catch (Throwable t) {
13301345
LOG.error("Error closing owned CursorSendEngine: {}", String.valueOf(t));
13311346
terminalError = captureCloseError(terminalError, t);

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

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -286,7 +286,32 @@ public static PersistedSymbolDict open(FilesFacade ff, String slotDir) {
286286
// truncates, and these bytes are the only copy of state the surviving
287287
// delta frames reference. A null degrades this slot to full
288288
// self-sufficient frames and preserves the file for a later attempt.
289-
return openExisting(ff, filePath, existing);
289+
//
290+
// The holder catches what openExisting's own catch structurally cannot.
291+
// Recovery reads the file through a mapping (Unsafe loads in
292+
// scanAndCopyRecoveredChunks), and pre-JDK-21 HotSpot delivers an
293+
// unsafe-access fault ASYNCHRONOUSLY -- at the next return or safepoint,
294+
// which can be openExisting's own return, in THIS frame. The instance is
295+
// fully built by then and owns an fd plus a buffer as large as the file,
296+
// but the assignment never happens, so nothing else can release them.
297+
// MmapSegment.openExisting takes an inFlight[] for exactly this shape; the
298+
// dictionary is at least as exposed, because ensureAppendMap grows the file
299+
// with ff.allocate and the reserve truncate is skipped after a crash, so a
300+
// sparse tail is routine. Without this, the fault also escapes open()
301+
// entirely -- past CursorSendEngine's constructor and out of Sender.build()
302+
// -- turning the documented "degrade to full-dictionary frames" into a
303+
// sender that cannot be constructed at all.
304+
PersistedSymbolDict[] inFlight = new PersistedSymbolDict[1];
305+
try {
306+
return openExisting(ff, filePath, existing, inFlight);
307+
} catch (Throwable t) {
308+
if (inFlight[0] != null) {
309+
inFlight[0].close();
310+
}
311+
LOG.warn("symbol dict {} recovery faulted late; falling back to "
312+
+ "full-dictionary frames (file left intact)", filePath, t);
313+
return null;
314+
}
290315
}
291316
// Absent, or a sub-header stub left by a crash inside openFresh: no
292317
// load-bearing content to lose, so create it.
@@ -693,7 +718,15 @@ public int size() {
693718
return size;
694719
}
695720

696-
private static PersistedSymbolDict openExisting(FilesFacade ff, String filePath, long fileLen) {
721+
/**
722+
* @param inFlight single-slot holder the caller must close when this method throws.
723+
* Set immediately before the return, so a late-delivered mmap access
724+
* fault -- which pre-JDK-21 HotSpot may raise at this method's RETURN,
725+
* in the caller's frame, past the catch below -- still leaves the fully
726+
* built instance (fd plus loaded-entry buffer) reachable by someone.
727+
*/
728+
private static PersistedSymbolDict openExisting(
729+
FilesFacade ff, String filePath, long fileLen, PersistedSymbolDict[] inFlight) {
697730
int fd = ff.openRW(filePath);
698731
if (fd < 0) {
699732
LOG.warn("symbol dict {} could not be opened (rc={}); "
@@ -763,8 +796,12 @@ private static PersistedSymbolDict openExisting(FilesFacade ff, String filePath,
763796
if (scan.validLen < len && !ff.truncate(fd, scan.validLen)) {
764797
throw new IllegalStateException("could not drop torn/stale symbol dictionary tail");
765798
}
766-
return new PersistedSymbolDict(
799+
PersistedSymbolDict dict = new PersistedSymbolDict(
767800
ff, filePath, fd, scan.validLen, scan.count, entriesAddr, entriesLen, mappedInput);
801+
// Publish to the holder BEFORE returning: from here on the caller owns the
802+
// cleanup, including when the return itself faults. See the parameter doc.
803+
inFlight[0] = dict;
804+
return dict;
768805
} catch (Throwable t) {
769806
if (inputAddr != 0L) {
770807
if (mappedInput) {
@@ -995,9 +1032,17 @@ private long checkedRequiredOffset(long recordLen) {
9951032
private void commitMappedChunk(long recStart, int hdrLen, int entriesLen, int count) {
9961033
long bodyLen = (long) hdrLen + entriesLen;
9971034
long recLen = bodyLen + CRC_SIZE;
1035+
// updateUnsafe, NOT the native update: this checksums bytes inside the append
1036+
// MAPPING. ensureAppendMap grows the file with ff.allocate, whose native fallback
1037+
// is ftruncate, so the window can cover blocks the filesystem has not committed
1038+
// (ENOSPC, a quota, a sparse tail left by a crash) -- and touching one raises
1039+
// SIGBUS. Inside JNI that ABORTS THE JVM with no recovery; at an Unsafe intrinsic
1040+
// site HotSpot converts it to a catchable InternalError. MmapSegment.scanFrames
1041+
// already uses updateUnsafe over the same class of mapping for exactly this
1042+
// reason. Costs nothing measurable and drops a JNI transition per flush.
9981043
Unsafe.getUnsafe().putInt(
9991044
recStart + bodyLen,
1000-
Crc32c.update(Crc32c.INIT, recStart, bodyLen));
1045+
Crc32c.updateUnsafe(Crc32c.INIT, recStart, bodyLen));
10011046
Unsafe.getUnsafe().storeFence();
10021047
appendOffset += recLen;
10031048
size += count;

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

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -354,21 +354,29 @@ public static SegmentRing openExisting(FilesFacade ff, String sfDir, long maxByt
354354
// the next appendOrFsn returns BACKPRESSURE_NO_SPARE, the manager
355355
// installs a hot spare, the producer rotates. Same fast path as a
356356
// mid-life ring.
357+
//
358+
// Ownership transfers to the ring in ONE step, at the very end. Detaching
359+
// the active segment from `opened` any earlier -- as an `opened.remove(last)`
360+
// before the ring is fully built did -- puts it beyond the reach of the catch
361+
// below while that catch can still fire: `new SegmentRing` and the
362+
// `sealedSegments.add` loop both allocate, so an ObjList growth IOOBE or a
363+
// native OOM lands there with the active segment referenced by nothing. Its
364+
// whole-segment mapping (up to sf_max_bytes) and its fd would leak.
357365
int last = opened.size() - 1;
358-
MmapSegment active = opened.get(last);
359-
opened.remove(last);
360-
SegmentRing ring = new SegmentRing(active, maxBytesPerSegment);
366+
SegmentRing ring = new SegmentRing(opened.get(last), maxBytesPerSegment);
361367
// Older segments become sealed in baseSeq order.
362-
for (int i = 0, n = opened.size(); i < n; i++) {
368+
for (int i = 0; i < last; i++) {
363369
ring.sealedSegments.add(opened.get(i));
364370
}
371+
// Every segment now belongs to `ring`. Release our references LAST, so a
372+
// throw anywhere above still finds all of them in `opened`.
373+
opened.clear();
365374
return ring;
366375
} catch (Throwable t) {
367-
// Close every recovered MmapSegment that's still in `opened`.
368-
// After the success path, `opened` no longer contains the active
369-
// segment (removed above), but the sealed segments transferred to
370-
// ring.sealedSegments are still owned by the ring once it's
371-
// returned -- so this catch only fires before the return statement.
376+
// Close every recovered MmapSegment that's still in `opened`. On the success
377+
// path `opened` was cleared only after the ring took ownership of every
378+
// segment, so reaching here always means the ring was never returned and
379+
// `opened` is still the sole owner -- including of the active segment.
372380
for (int i = 0, n = opened.size(); i < n; i++) {
373381
try {
374382
opened.get(i).close();

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

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -168,8 +168,33 @@ public static void removeOrphanLogical(FilesFacade ff, String slotDir) {
168168
if (paths == null) {
169169
return;
170170
}
171-
ff.remove(paths[1]);
172-
ff.remove(paths[2]);
171+
// Unlink ONLY while holding the lock. Removing a lock file another party holds
172+
// is precisely the attack Files.DIR_MODE_SHARED's javadoc describes: the unlink
173+
// does not release that party's flock, but it does free the pathname, so the
174+
// next acquirer creates a SECOND inode and locks it successfully -- two owners
175+
// of a lock whose only job is mutual exclusion.
176+
//
177+
// The retiring engine's directory-local lock does not stand in for this. It says
178+
// nothing about the LOGICAL lock, which Sender.build() holds across its whole
179+
// construct -> connect -> quarantine transition, in a frame ABOVE this one: an
180+
// ordinary failed connect closes the engine from inside that scope, reaches here,
181+
// and used to unlink the very file build() was holding. Acquiring first turns
182+
// that into a no-op instead of silent double-ownership.
183+
SlotLock guard;
184+
try {
185+
guard = acquireAt(ff, slotDir, paths[1], paths[2]);
186+
} catch (Throwable t) {
187+
// Contended (someone holds it, possibly a caller above us) or unopenable.
188+
// Leaving the pair on disk is the safe outcome -- a live holder's lock must
189+
// outlive our cleanup. The next fully-drained retirement reclaims it.
190+
return;
191+
}
192+
try {
193+
ff.remove(paths[1]);
194+
ff.remove(paths[2]);
195+
} finally {
196+
guard.close();
197+
}
173198
}
174199

175200
private static SlotLock acquireAt(FilesFacade ff, String slotDir, String lockPath, String pidPath) {

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

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -80,18 +80,24 @@ public final class Files {
8080
* create entries in: the store-and-forward root and its logical slot-lock directory.
8181
* <b>01777 octal -- 0777 plus the sticky bit.</b>
8282
* <p>
83-
* The deployment's umask decides the sharing policy: the usual 022 yields 01755, i.e.
84-
* exactly {@link #DIR_MODE_DEFAULT} plus sticky, so the common case is unchanged;
85-
* a shared-group deployment (umask 002) gets the 01775 a second uid needs. umask
86-
* masks only the rwx bits, never the sticky bit, so the restricted-deletion semantics
87-
* survive every umask.
83+
* The rwx widening is the load-bearing part and it works: the deployment's umask
84+
* decides the sharing policy, so the usual 022 yields 0755 (identical to
85+
* {@link #DIR_MODE_DEFAULT}, the common case unchanged) while a shared-group deployment
86+
* (umask 002) gets the 0775 a second uid needs to create its own entries here.
8887
* <p>
89-
* The sticky bit is not optional here. Without it, on a multi-tenant host any local
90-
* user with write access to the directory can unlink another process's lock file --
91-
* which does not release that process's flock, but does free the pathname, so the
92-
* next acquirer creates a SECOND inode and locks it successfully. Two owners of a
93-
* lock whose entire job is mutual exclusion. Sticky restricts deletion to the file's
94-
* owner and closes that.
88+
* <b>The sticky bit is best-effort, NOT a guarantee this client relies on.</b> On
89+
* Linux {@code mkdir(2)} sets the new directory to {@code (mode & ~umask & 0777)} -- the
90+
* {@code & 0777} strips {@code S_ISVTX}, so the bit is silently dropped on the primary
91+
* deployment platform (POSIX leaves sticky-on-mkdir unspecified; some platforms honor
92+
* it, Linux does not). It is therefore wrong to depend on restricted-deletion semantics
93+
* for correctness. Mutual exclusion of the slot lock does NOT rest on it: the safety
94+
* comes from never unlinking a lock file this process does not hold
95+
* ({@code SlotLock.removeOrphanLogical} acquires the lock before removing it), which
96+
* holds regardless of directory permissions. Defending a multi-tenant host against a
97+
* hostile local uid unlinking another process's lock would need the sticky bit applied
98+
* by an explicit {@code chmod} after {@code mkdir} (and on an already-existing directory
99+
* on upgrade, since {@code mkdir} does not touch it) -- there is no {@code chmod} binding
100+
* in this class today, so that protection is not provided and must not be assumed.
95101
* <p>
96102
* This must be applied to EVERY directory on the path a foreign uid has to create in.
97103
* Applying it only to {@code .slot-locks} -- while {@code sf_dir} itself stays 0755 --

0 commit comments

Comments
 (0)