Skip to content

Commit ba4d458

Browse files
glasstigerclaude
andcommitted
Fix six moderate findings in the SF dictionary paths
Guards that don't guard. Already closed by the varint packing change: readVarintAt returns -1 for a truncated varint instead of 0 with its end at the limit, so a caller's `p > limit` bail-out can fire at an exact boundary. It had no test; one now feeds a delta declaring two entries while carrying one, ending precisely on the boundary that used to slip through. Reverted to the indistinguishable shape it reports expected:<0> but was:<2> -- the mirror claiming symbols it holds no bytes for, which the next catch-up would ship as a chunk whose deltaCount exceeds its payload. DIR_MODE_SHARED. The javadoc claimed umask governed it "exactly as it does for the sf_dir these live under", but sf_dir was created with an explicit 0755 and umask can only CLEAR bits -- so under umask 002 the lock directory became group-writable while sf_dir did not, and a second uid still could not create its slot directory. build() failed one level before the problem the mode existed to solve. sf_dir now gets the same mode, and the mode is 01777 rather than 0777: without the sticky bit any local user with write access can unlink another process's lock file, which does not release that process's flock but does free the pathname, so the next acquirer mints a second inode and locks it successfully. umask masks only the rwx bits, so the restricted-deletion semantics survive every umask and the usual 022 still yields 0755 plus sticky. The slot directory itself stays 0755 -- only its creator writes it. Foreground retry observability. Retrying an endpoint-policy rejection is what the contract demands, but dispatchError ran only in the terminal branches, so the retry was programmatically invisible: a revoked token produced a throttled slf4j WARN and nothing else, in a library that ships embedded in user applications. flush() kept returning success until SF filled, and the failure then surfaced as "cursor ring backpressured ... sf_max_total_bytes too small" -- pointing the operator at disk sizing. The retry branches now dispatch a RETRIABLE SenderError, and lastReconnectError is promoted from a connectLoop local that never escaped to a volatile field, which close()'s drain-timeout throw uses to name the real cause. scanRecoveredChunks. It rejected entryCount <= 0 but never checked that entryBytes actually holds entryCount well-formed entries. The CRC proves the bytes are what was written, not that the header triple is consistent, and the only write-side guard sits behind an assert that production never runs. Such a chunk is now rejected during the scan, so the trusted prefix ends there as it would on a CRC failure, instead of decodeLoadedSymbols discovering it later and quarantining the whole slot. ensureSentDictCapacity. The MAX_SENT_DICT_BYTES arm deliberately latches via recordFatal before throwing, because accumulateSentDict runs AFTER the frame's sendBinary and a bare throw unwinds into a reconnect that replays the same frame. The allocation one line below had no such latch, so a genuine out-of-memory took exactly the livelock the ceiling arm exists to prevent. It now latches too. PersistedSymbolDict.close(). It set closed = true and then ran munmap and truncate unguarded, so a throw from either stranded the scratch buffer and the fd for the process's lifetime -- a retry short-circuits on `closed`. Each step is now in its own try/catch, as CursorSendEngine's close already was. Two tests were updated rather than added: the one asserting the scan ACCEPTED an inconsistent chunk now asserts it ends the trusted prefix (that path's exception-type change stays pinned on its reachable sibling in CursorSendEngineTest), and SlotLockTest's mode assertion follows the constant. The OOM latch and the close hardening have no test: neither an Unsafe allocation failure nor a throwing munmap is injectable through any existing seam. Full client suite: 2702 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f9bb589 commit ba4d458

10 files changed

Lines changed: 298 additions & 63 deletions

File tree

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1550,7 +1550,11 @@ public Sender build() {
15501550
slotPath = null;
15511551
} else {
15521552
if (!Files.exists(sfDir)) {
1553-
int rc = Files.mkdir(sfDir, Files.DIR_MODE_DEFAULT);
1553+
// DIR_MODE_SHARED, matching .slot-locks: a second uid has to create
1554+
// its own slot directory in here, so restricting this to 0755 makes
1555+
// the shared lock directory pointless (see Files.DIR_MODE_SHARED).
1556+
// Under the usual umask 022 this is still 0755, plus sticky.
1557+
int rc = Files.mkdir(sfDir, Files.DIR_MODE_SHARED);
15541558
// mkdir is non-zero on failure, but "already exists"
15551559
// is one such failure. Multiple SF senders sharing one
15561560
// sf_dir can be built concurrently (the pool calls

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

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3347,14 +3347,24 @@ private void drainOnClose(boolean errorOwnedByCustomHandler) {
33473347
}
33483348
if (System.nanoTime() >= deadlineNanos) {
33493349
long acked = cursorEngine.ackedFsn();
3350-
LOG.warn("close() drain timed out after {}ms [target={} acked={}], pending data may be lost",
3351-
closeFlushTimeoutMillis, target, acked);
3350+
// Name the outage the I/O thread is riding out, when there is one. A
3351+
// foreground sender now retries endpoint-policy rejections indefinitely,
3352+
// so a revoked token reaches the operator HERE -- and blaming timeout
3353+
// tuning for what is actually an auth failure is how the review found
3354+
// this path misdirecting.
3355+
CursorWebSocketSendLoop loop = cursorSendLoop;
3356+
Throwable outage = loop == null ? null : loop.lastReconnectError();
3357+
LOG.warn("close() drain timed out after {}ms [target={} acked={}], pending data may be lost{}",
3358+
closeFlushTimeoutMillis, target, acked,
3359+
outage == null ? "" : "; wire is not draining: " + outage.getMessage());
33523360
throw new LineSenderException("close() drain timed out after ")
33533361
.put(closeFlushTimeoutMillis).put(" ms [targetFsn=")
33543362
.put(target).put(", ackedFsn=").put(acked)
33553363
.put("] - server did not acknowledge ")
33563364
.put(target - acked)
3357-
.put(" pending batches; data may be lost (use larger closeFlushTimeoutMillis or smaller batches)");
3365+
.put(outage == null
3366+
? " pending batches; data may be lost (use larger closeFlushTimeoutMillis or smaller batches)"
3367+
: " pending batches; the wire is not draining: " + outage.getMessage());
33583368
}
33593369
java.util.concurrent.locks.LockSupport.parkNanos(50_000L);
33603370
}

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

Lines changed: 77 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,12 @@ public final class CursorWebSocketSendLoop implements QuietCloseable {
376376
// successful connect attempt in any mode). Once true, stays true. This is
377377
// connection-state observability only; reconnect policy is role-based.
378378
private volatile boolean hasEverConnected;
379+
// Cause of the outage the reconnect loop is currently riding out, or null once a
380+
// connect succeeds. Written by the I/O thread, read by the producer thread so a
381+
// backpressure or drain-timeout failure can NAME the reason the wire is not draining
382+
// instead of blaming disk sizing. Was a connectLoop local that never escaped, which
383+
// is why a revoked token surfaced to operators as "sf_max_total_bytes too small".
384+
private volatile Throwable lastReconnectError;
379385
private volatile Thread ioThread;
380386
// Typed marker for a durable-ack CAPABILITY-GAP terminal: set (before the
381387
// terminalError latch, so a checkError() caller that observes the latch is
@@ -1599,7 +1605,7 @@ private void connectLoop(Throwable initial, String phase, long paceFirstAttemptM
15991605
}
16001606
int attempts = 0;
16011607
long lastLogNanos = 0L;
1602-
Throwable lastReconnectError = initial;
1608+
lastReconnectError = initial;
16031609
while (running) {
16041610
attempts++;
16051611
totalReconnectAttempts.incrementAndGet();
@@ -1665,6 +1671,8 @@ private void connectLoop(Throwable initial, String phase, long paceFirstAttemptM
16651671
}
16661672
resetCatchUpCapGapEpisode();
16671673
lastReconnectError = e;
1674+
dispatchRetriedEndpointPolicyFailure(
1675+
SenderError.Category.SECURITY_ERROR, "ws-upgrade-failed: " + e.getMessage());
16681676
long now = System.nanoTime();
16691677
if (now - lastLogNanos >= RECONNECT_LOG_THROTTLE_NANOS) {
16701678
LOG.warn("{} attempt {}: foreground auth/upgrade policy rejected the connection; "
@@ -1703,6 +1711,9 @@ private void connectLoop(Throwable initial, String phase, long paceFirstAttemptM
17031711
}
17041712
resetCatchUpCapGapEpisode();
17051713
lastReconnectError = e;
1714+
dispatchRetriedEndpointPolicyFailure(
1715+
SenderError.Category.PROTOCOL_VIOLATION,
1716+
"durable-ack-mismatch: " + e.getMessage());
17061717
long now = System.nanoTime();
17071718
if (now - lastLogNanos >= RECONNECT_LOG_THROTTLE_NANOS) {
17081719
LOG.warn("{} attempt {}: foreground durable-ack capability is temporarily "
@@ -1821,6 +1832,45 @@ private void connectLoop(Throwable initial, String phase, long paceFirstAttemptM
18211832
* failure is then a transient the drainer must ride out, never a producer-fatal
18221833
* terminal (Invariant B).
18231834
*/
1835+
/**
1836+
* Reports an endpoint-policy rejection a FOREGROUND sender is riding out.
1837+
* <p>
1838+
* Retrying is what the store-and-forward contract demands, but until now the retry was
1839+
* programmatically invisible: dispatchError ran only in the terminal branches, so a
1840+
* revoked token produced nothing but a throttled slf4j WARN -- in a library that ships
1841+
* embedded in user applications, frequently with no binding configured. flush() kept
1842+
* returning success until SF filled, and the failure then surfaced as "cursor ring
1843+
* backpressured ... sf_max_total_bytes too small", pointing the operator at disk
1844+
* sizing. That is the same complaint this PR makes to justify keeping STARTUP
1845+
* terminal; post-start it was merely delayed by SF capacity, not avoided.
1846+
* <p>
1847+
* RETRIABLE, not TERMINAL: the handler learns the wire is being rejected while the
1848+
* producer stays alive and no data is at risk.
1849+
*/
1850+
private void dispatchRetriedEndpointPolicyFailure(SenderError.Category category, String message) {
1851+
long fromFsn = engine.ackedFsn() + 1L;
1852+
dispatchError(new SenderError(
1853+
category,
1854+
SenderError.Policy.RETRIABLE,
1855+
SenderError.NO_STATUS_BYTE,
1856+
message,
1857+
SenderError.NO_MESSAGE_SEQUENCE,
1858+
fromFsn,
1859+
Math.max(fromFsn, engine.publishedFsn()),
1860+
null,
1861+
System.nanoTime()
1862+
));
1863+
}
1864+
1865+
/**
1866+
* Cause of the outage the reconnect loop is riding out, or {@code null} when the wire
1867+
* is up. Lets a producer-side failure name the real reason rather than blaming disk
1868+
* sizing -- see the field.
1869+
*/
1870+
public Throwable lastReconnectError() {
1871+
return lastReconnectError;
1872+
}
1873+
18241874
private boolean endpointPolicyFailureIsTerminal() {
18251875
return reconnectPolicy == ReconnectPolicy.ORPHAN || !hasEverConnected;
18261876
}
@@ -2341,6 +2391,7 @@ private void swapClient(WebSocketClient newClient) {
23412391
// failures on a foreground sender and rides them out instead, because
23422392
// store-and-forward now owns the buffered data (Invariant B).
23432393
this.hasEverConnected = true;
2394+
this.lastReconnectError = null;
23442395
if (old != null) {
23452396
try {
23462397
old.close();
@@ -2552,19 +2603,32 @@ private void ensureSentDictCapacity(long required) {
25522603
if (newCap > MAX_SENT_DICT_BYTES) {
25532604
newCap = MAX_SENT_DICT_BYTES;
25542605
}
2555-
if (sentDictBytesOwned) {
2556-
sentDictBytesAddr = Unsafe.realloc(
2557-
sentDictBytesAddr,
2558-
sentDictBytesCapacity,
2559-
(int) newCap,
2560-
MemoryTag.NATIVE_DEFAULT);
2561-
} else {
2562-
long newAddr = Unsafe.malloc((int) newCap, MemoryTag.NATIVE_DEFAULT);
2563-
if (sentDictBytesLen > 0) {
2564-
Unsafe.getUnsafe().copyMemory(sentDictBytesAddr, newAddr, sentDictBytesLen);
2606+
try {
2607+
if (sentDictBytesOwned) {
2608+
sentDictBytesAddr = Unsafe.realloc(
2609+
sentDictBytesAddr,
2610+
sentDictBytesCapacity,
2611+
(int) newCap,
2612+
MemoryTag.NATIVE_DEFAULT);
2613+
} else {
2614+
long newAddr = Unsafe.malloc((int) newCap, MemoryTag.NATIVE_DEFAULT);
2615+
if (sentDictBytesLen > 0) {
2616+
Unsafe.getUnsafe().copyMemory(sentDictBytesAddr, newAddr, sentDictBytesLen);
2617+
}
2618+
sentDictBytesAddr = newAddr;
2619+
sentDictBytesOwned = true;
25652620
}
2566-
sentDictBytesAddr = newAddr;
2567-
sentDictBytesOwned = true;
2621+
} catch (Throwable t) {
2622+
// Latch for exactly the reason the MAX_SENT_DICT_BYTES arm above does, and it
2623+
// was asymmetric until now: accumulateSentDict runs AFTER the frame's
2624+
// sendBinary, so a bare throw unwinds to ioLoop -> fail() -> connectLoop,
2625+
// which (running still true) reconnects and replays the same frame, which
2626+
// re-attempts the same allocation. A genuine out-of-memory therefore became an
2627+
// unbounded reconnect livelock instead of the loud failure the ceiling arm
2628+
// promises. recordFatal flips running=false so connectLoop winds down and
2629+
// checkError() surfaces it; the throw still unwinds past the pending copy.
2630+
recordFatal(t);
2631+
throw t;
25682632
}
25692633
sentDictBytesCapacity = (int) newCap;
25702634
}

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

Lines changed: 82 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -499,35 +499,58 @@ public synchronized void close() {
499499
return;
500500
}
501501
closed = true;
502+
// Each step in its own try/catch, as CursorSendEngine.close() already does.
503+
// `closed` is set first, so a throw from any step short-circuits every retry --
504+
// stranding the scratch buffer and the fd for the process's lifetime. Unreachable
505+
// with FilesFacade.INSTANCE (munmap and truncate are native calls returning
506+
// int/boolean), but the ff seam exists precisely so a test CAN inject a throw,
507+
// and a close path must not depend on its own steps never failing.
502508
if (loadedEntriesAddr != 0L) {
503-
Unsafe.free(loadedEntriesAddr, loadedEntriesLen, MemoryTag.NATIVE_DEFAULT);
509+
try {
510+
Unsafe.free(loadedEntriesAddr, loadedEntriesLen, MemoryTag.NATIVE_DEFAULT);
511+
} catch (Throwable ignored) {
512+
}
504513
// Null after freeing (like scratchAddr below) so a future accessor that
505514
// reads loadedEntriesAddr()/loadedEntriesLen() post-close cannot
506515
// dereference freed native memory; the getters are not closed-guarded.
507516
loadedEntriesAddr = 0L;
508517
loadedEntriesLen = 0;
509518
}
510519
if (appendMapAddr != 0L) {
511-
ff.munmap(appendMapAddr, appendMapCapacity, MemoryTag.MMAP_DEFAULT);
520+
long mapAddr = appendMapAddr;
521+
long mapCapacity = appendMapCapacity;
512522
appendMapAddr = 0L;
513523
appendMapCapacity = 0L;
514524
appendMapOffset = 0L;
515-
// The active window reserves space past the logical end. Return that tail on
516-
// orderly close; after a crash open() treats the zero-filled reserve as
517-
// a torn trailing chunk and truncates it to the same appendOffset.
518-
if (!ff.truncate(fd, appendOffset)) {
519-
LOG.warn("symbol dict {} could not trim mmap reserve to {}; recovery will "
520-
+ "discard the zero-filled tail on the next open",
521-
FILE_NAME, appendOffset);
525+
try {
526+
ff.munmap(mapAddr, mapCapacity, MemoryTag.MMAP_DEFAULT);
527+
} catch (Throwable ignored) {
528+
}
529+
try {
530+
// The active window reserves space past the logical end. Return that tail on
531+
// orderly close; after a crash open() treats the zero-filled reserve as
532+
// a torn trailing chunk and truncates it to the same appendOffset.
533+
if (!ff.truncate(fd, appendOffset)) {
534+
LOG.warn("symbol dict {} could not trim mmap reserve to {}; recovery will "
535+
+ "discard the zero-filled tail on the next open",
536+
FILE_NAME, appendOffset);
537+
}
538+
} catch (Throwable ignored) {
522539
}
523540
}
524541
if (scratchAddr != 0L) {
525-
Unsafe.free(scratchAddr, scratchCap, MemoryTag.NATIVE_DEFAULT);
542+
try {
543+
Unsafe.free(scratchAddr, scratchCap, MemoryTag.NATIVE_DEFAULT);
544+
} catch (Throwable ignored) {
545+
}
526546
scratchAddr = 0L;
527547
scratchCap = 0;
528548
}
529549
if (fd >= 0) {
530-
ff.close(fd);
550+
try {
551+
ff.close(fd);
552+
} catch (Throwable ignored) {
553+
}
531554
}
532555
}
533556

@@ -800,6 +823,9 @@ private static RecoveryScan scanAndCopyRecoveredChunks(long inputAddr, int len,
800823
|| entriesLen + entryBytes > Integer.MAX_VALUE) {
801824
break;
802825
}
826+
if (!isConsistentEntryRegion(inputAddr, entriesStart, entryBytes, entryCount)) {
827+
break;
828+
}
803829
Unsafe.getUnsafe().copyMemory(inputAddr + entriesStart, dstAddr + entriesLen, entryBytes);
804830
entriesLen += entryBytes;
805831
diskPos = chunkEndI + CRC_SIZE;
@@ -808,6 +834,51 @@ private static RecoveryScan scanAndCopyRecoveredChunks(long inputAddr, int len,
808834
return new RecoveryScan(count, (int) entriesLen, diskPos);
809835
}
810836

837+
/**
838+
* Whether {@code [start, start + bytes)} holds exactly {@code count} well-formed
839+
* {@code [len varint][utf8]} entries, consuming the region exactly.
840+
* <p>
841+
* The chunk CRC proves the bytes are what was WRITTEN; it says nothing about whether
842+
* the header triple is self-consistent. The only write-side guard,
843+
* {@code validateRawEntries}, sits behind an {@code assert} -- and this library ships
844+
* embedded in user applications, which run without {@code -ea}. So a producer bug or
845+
* a torn write that happens to re-checksum could record a chunk whose stored
846+
* entryCount disagrees with its entries, shifting the dense id-&gt;symbol map for
847+
* everything above it.
848+
* <p>
849+
* Checking here ends the trusted prefix at that chunk -- the same treatment a CRC
850+
* failure gets -- instead of letting decodeLoadedSymbols discover it later and throw
851+
* two layers up, which quarantines the whole slot rather than salvaging its intact
852+
* prefix. It costs one varint decode per entry on a cold path that already walks
853+
* every entry immediately afterwards.
854+
*/
855+
private static boolean isConsistentEntryRegion(long addr, int start, long bytes, long count) {
856+
long p = start;
857+
long limit = start + bytes;
858+
for (long i = 0; i < count; i++) {
859+
long len = 0;
860+
int shift = 0;
861+
boolean terminated = false;
862+
while (p < limit) {
863+
byte b = Unsafe.getUnsafe().getByte(addr + p++);
864+
len |= (long) (b & 0x7F) << shift;
865+
if ((b & 0x80) == 0) {
866+
terminated = true;
867+
break;
868+
}
869+
shift += 7;
870+
if (shift > 35) {
871+
return false; // over-long run: a canonical length varint is <= 5 bytes
872+
}
873+
}
874+
if (!terminated || len < 0 || p + len > limit) {
875+
return false;
876+
}
877+
p += len;
878+
}
879+
return p == limit;
880+
}
881+
811882
private static PersistedSymbolDict openFresh(FilesFacade ff, String filePath) {
812883
int fd = ff.openCleanRW(filePath);
813884
if (fd < 0) {

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ public static SlotLock acquireLogical(FilesFacade ff, String slotDir) {
120120
throw new IllegalArgumentException(
121121
"slotDir must contain a parent and slot name: " + slotDir);
122122
}
123-
// SHARED_DIR_MODE, not DIR_MODE_DEFAULT: unlike a slot directory -- which
123+
// DIR_MODE_SHARED, not DIR_MODE_DEFAULT: unlike a slot directory -- which
124124
// one process creates and only that process writes -- this directory is
125125
// shared by every sender under the same sf_dir, and each of them must
126126
// CREATE its own lock file inside it. At 0755 the first process to start

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

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -76,16 +76,32 @@ public final class Files {
7676
*/
7777
public static final int DIR_MODE_DEFAULT = 493;
7878
/**
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;
79+
* Creation mode for a directory that processes running as DIFFERENT users must all
80+
* create entries in: the store-and-forward root and its logical slot-lock directory.
81+
* <b>01777 octal -- 0777 plus the sticky bit.</b>
82+
* <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.
88+
* <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.
95+
* <p>
96+
* This must be applied to EVERY directory on the path a foreign uid has to create in.
97+
* Applying it only to {@code .slot-locks} -- while {@code sf_dir} itself stays 0755 --
98+
* is a half-measure: umask can only clear bits, so under umask 002 the lock directory
99+
* becomes group-writable while sf_dir does not, and the second uid still cannot create
100+
* its slot directory. build() then fails one level before the problem this mode
101+
* exists to solve. Do NOT use it for a directory only its creator writes to; the slot
102+
* directory itself stays {@link #DIR_MODE_DEFAULT}.
103+
*/
104+
public static final int DIR_MODE_SHARED = 1023;
89105

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

0 commit comments

Comments
 (0)