Skip to content

Commit 923dcb4

Browse files
bluestreak01claude
andcommitted
fix(ilp): cursor SF review fixes — perf, cap, torn-tail, races
Multi-issue review pass over the cursor SF stack from PR-17. Each fix lands with a regression test (or extends an existing one); full QWP suite green at 1053 tests. - recovery sort: SegmentRing.openExisting used selection sort, which regressed the legacy SegmentLog perf fix (commit 86b6e6f) on the cursor path. Replaced with median-of-three in-place quicksort — median-of-three needed because lexicographic readdir returns our zero-padded hex names already-sorted, the worst case for a naive pivot. testLargeSegmentCountReopensInOrder guards against regression. - cap accounting: SegmentManager.register/deregister ignored the bytes a recovered or adopted ring already owns, so the documented sf_max_total_bytes cap could be exceeded by a full segment-set on restart. Now seeds totalBytes from ring.totalSegmentBytes() and serviceRing updates the counter under the same lock so concurrent register/deregister stays consistent. - torn-tail observability: openExisting silently truncated on the first bad CRC with no log or signal. Now distinguishes attempted- but-failed frame writes (non-zero bytes past lastGood) from clean unwritten space; emits WARN with byte count + offset and exposes tornTailBytes() for diagnostics. No false alarms on fresh hot spares or partial fills. - drainOrphans Javadoc: setter claimed scan/log only with drainer runtime "as a follow-up", but build() spawns the BackgroundDrainerPool. Rewritten to describe actual adoption. - BackgroundDrainerPool submit/close race: volatile-boolean gate let close() return while submit() was about to register a drainer. AtomicInteger CAS gate with sign-bit closed flag closes the window. - CursorWebSocketSendLoop close hangs and reconnect leak: start() try/catches Thread.start() and assigns ioThread only on success; close() guards on isAlive() and closes the current client field; reconnect closes the previous WebSocketClient before assigning the new one. - row-API methods accepting rows after the I/O loop went terminal: table()/longColumn()/atNow() etc. now surface the loop's terminal error on the next call instead of swallowing it until the next flush(). - non-success ACK not marking the loop fatal: the I/O response handler now routes through recordFatal so the loop exits cleanly instead of looping on the same rejected frame. - CursorSendEngine close() left .sfa files when fully drained: captures fullyDrained before ring.close() and unlinks all segment files via unlinkAllSegmentFiles(dir) when nothing is pending replay. - SegmentRing recovery leaked empty .sfa files: openExisting now Files.remove(path) after closing zero-frame segments instead of letting them accumulate across restart cycles. - closeFlushTimeoutMillis sentinel collision: -1 was both the documented "no timeout" value and the parameter-not-set sentinel. Introduced CLOSE_FLUSH_TIMEOUT_NOT_SET = Long.MIN_VALUE; -1 now correctly disables the timeout. - Windows files.c read/write/append accepted negative lengths and silently overflowed DWORD. New clamp_len helper rejects < 0 and clamps to MAXDWORD per call. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 267b380 commit 923dcb4

24 files changed

Lines changed: 2101 additions & 111 deletions

core/src/main/c/windows/files.c

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -145,14 +145,34 @@ JNIEXPORT jint JNICALL Java_io_questdb_client_std_Files_openCleanRW0
145145
return fd;
146146
}
147147

148+
/* ReadFile/WriteFile take a DWORD (uint32) byte count, but the JNI signature
149+
* exposes a jlong. A direct (DWORD) cast silently truncates the high 32 bits,
150+
* which means a 4 GiB request becomes a 0-byte transfer — the worst kind of
151+
* silent failure. Clamp to MAXDWORD so any oversized request is served as a
152+
* short transfer (matching POSIX semantics on the share/files.c side); the
153+
* Java caller already loops on the return value. Reject negative len up front
154+
* so it doesn't get reinterpreted as a huge unsigned DWORD. */
155+
static inline DWORD clamp_len(jlong len) {
156+
if (len > (jlong) MAXDWORD) {
157+
return MAXDWORD;
158+
}
159+
return (DWORD) len;
160+
}
161+
148162
JNIEXPORT jlong JNICALL Java_io_questdb_client_std_Files_read
149163
(JNIEnv *e, jclass cl, jint fd, jlong addr, jlong len, jlong offset) {
164+
if (len < 0) {
165+
SetLastError(ERROR_INVALID_PARAMETER);
166+
SaveLastError();
167+
return -1;
168+
}
169+
if (len == 0) return 0;
150170
OVERLAPPED ov;
151171
memset(&ov, 0, sizeof(ov));
152172
ov.Offset = (DWORD) (offset & 0xFFFFFFFF);
153173
ov.OffsetHigh = (DWORD) (offset >> 32);
154174
DWORD got = 0;
155-
if (!ReadFile(FD_TO_HANDLE(fd), (LPVOID) (uintptr_t) addr, (DWORD) len, &got, &ov)) {
175+
if (!ReadFile(FD_TO_HANDLE(fd), (LPVOID) (uintptr_t) addr, clamp_len(len), &got, &ov)) {
156176
DWORD err = GetLastError();
157177
if (err == ERROR_HANDLE_EOF) {
158178
return 0;
@@ -165,12 +185,18 @@ JNIEXPORT jlong JNICALL Java_io_questdb_client_std_Files_read
165185

166186
JNIEXPORT jlong JNICALL Java_io_questdb_client_std_Files_write
167187
(JNIEnv *e, jclass cl, jint fd, jlong addr, jlong len, jlong offset) {
188+
if (len < 0) {
189+
SetLastError(ERROR_INVALID_PARAMETER);
190+
SaveLastError();
191+
return -1;
192+
}
193+
if (len == 0) return 0;
168194
OVERLAPPED ov;
169195
memset(&ov, 0, sizeof(ov));
170196
ov.Offset = (DWORD) (offset & 0xFFFFFFFF);
171197
ov.OffsetHigh = (DWORD) (offset >> 32);
172198
DWORD wrote = 0;
173-
if (!WriteFile(FD_TO_HANDLE(fd), (LPCVOID) (uintptr_t) addr, (DWORD) len, &wrote, &ov)) {
199+
if (!WriteFile(FD_TO_HANDLE(fd), (LPCVOID) (uintptr_t) addr, clamp_len(len), &wrote, &ov)) {
174200
SaveLastError();
175201
return -1;
176202
}
@@ -179,8 +205,14 @@ JNIEXPORT jlong JNICALL Java_io_questdb_client_std_Files_write
179205

180206
JNIEXPORT jlong JNICALL Java_io_questdb_client_std_Files_append
181207
(JNIEnv *e, jclass cl, jint fd, jlong addr, jlong len) {
208+
if (len < 0) {
209+
SetLastError(ERROR_INVALID_PARAMETER);
210+
SaveLastError();
211+
return -1;
212+
}
213+
if (len == 0) return 0;
182214
DWORD wrote = 0;
183-
if (!WriteFile(FD_TO_HANDLE(fd), (LPCVOID) (uintptr_t) addr, (DWORD) len, &wrote, NULL)) {
215+
if (!WriteFile(FD_TO_HANDLE(fd), (LPCVOID) (uintptr_t) addr, clamp_len(len), &wrote, NULL)) {
184216
SaveLastError();
185217
return -1;
186218
}

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

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -681,8 +681,11 @@ public int getTimeout() {
681681
private SfDurability sfDurability = SfDurability.MEMORY;
682682
// close() drain timeout. Default applied at build() time. 0 or -1
683683
// means "fast close" (skip the drain entirely); any positive value
684-
// bounds the wait for ackedFsn to catch up to publishedFsn.
685-
private long closeFlushTimeoutMillis = PARAMETER_NOT_SET_EXPLICITLY;
684+
// bounds the wait for ackedFsn to catch up to publishedFsn. Uses
685+
// its own sentinel because -1 is a documented user-facing value
686+
// and would otherwise collide with PARAMETER_NOT_SET_EXPLICITLY.
687+
private static final long CLOSE_FLUSH_TIMEOUT_NOT_SET = Long.MIN_VALUE;
688+
private long closeFlushTimeoutMillis = CLOSE_FLUSH_TIMEOUT_NOT_SET;
686689
// Reconnect policy. Defaults applied at build() time. Per-outage
687690
// time cap (default 300_000), initial backoff (default 100), and
688691
// max backoff (default 5_000) for the cursor I/O loop's exponential
@@ -1036,7 +1039,7 @@ public Sender build() {
10361039
long actualSfMaxTotalBytes = sfMaxTotalBytes == PARAMETER_NOT_SET_EXPLICITLY
10371040
? Math.max(defaultMaxTotal, actualSfMaxBytes * 2)
10381041
: sfMaxTotalBytes;
1039-
long actualCloseFlushTimeoutMillis = closeFlushTimeoutMillis == PARAMETER_NOT_SET_EXPLICITLY
1042+
long actualCloseFlushTimeoutMillis = closeFlushTimeoutMillis == CLOSE_FLUSH_TIMEOUT_NOT_SET
10401043
? DEFAULT_CLOSE_FLUSH_TIMEOUT_MILLIS
10411044
: closeFlushTimeoutMillis;
10421045
long actualReconnectMaxDurationMillis =
@@ -1919,20 +1922,22 @@ public LineSenderBuilder sfAppendDeadlineMillis(long millis) {
19191922
}
19201923

19211924
/**
1922-
* Opt in to scanning {@code <sf_dir>/*} at startup for sibling slots
1923-
* that hold unacked data left behind by a crashed sender or a
1924-
* different sender_id. Default {@code false}. WebSocket only;
1925+
* Opt in to adopting sibling slots under {@code <sf_dir>/*} at
1926+
* startup that hold unacked data left behind by a crashed sender or
1927+
* a different sender_id. Default {@code false}. WebSocket only;
19251928
* requires {@code sf_dir} to be set.
19261929
* <p>
1927-
* The scan is read-only — slots flagged with the {@code .failed}
1928-
* sentinel are skipped (manual reset required), and the foreground
1929-
* sender's own slot is never reported.
1930+
* On startup, after the foreground sender has acquired its own slot
1931+
* lock, the scan walks every sibling slot directory and dispatches a
1932+
* background drainer for each candidate orphan. Each drainer takes
1933+
* the slot's exclusive lock, replays the slot's unacked frames over
1934+
* its own WebSocket connection to the same target, and unlinks the
1935+
* slot once fully drained. Concurrency is capped by
1936+
* {@link #maxBackgroundDrainers(int)} (default {@code 4}).
19301937
* <p>
1931-
* <b>Status:</b> the scan + visibility (via logs) lands in this
1932-
* release; the background drainer runtime that actually empties
1933-
* orphan slots is a follow-up. Setting {@code drain_orphans=true}
1934-
* today logs the count and paths of orphans found at startup so
1935-
* users can monitor + manually drain pending slots.
1938+
* Slots flagged with the {@code .failed} sentinel are skipped
1939+
* (manual reset required), and the foreground sender's own slot is
1940+
* never adopted.
19361941
*/
19371942
public LineSenderBuilder drainOrphans(boolean enabled) {
19381943
if (protocol != PARAMETER_NOT_SET_EXPLICITLY && protocol != PROTOCOL_WEBSOCKET) {

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

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -879,17 +879,30 @@ public QwpWebSocketSender floatColumn(CharSequence columnName, float value) {
879879
}
880880

881881
/**
882-
* Flushes buffered rows and waits until the server acknowledges all submitted
883-
* WebSocket batches.
882+
* Encodes pending rows into the cursor engine and returns once the data
883+
* is published into the engine — in-RAM for memory mode, on-disk for
884+
* store-and-forward mode. {@code flush()} does <b>not</b> wait for the
885+
* server to acknowledge the batches; ACKs arrive asynchronously and the
886+
* background I/O loop trims acked frames out of the engine independently.
884887
* <p>
885-
* If a WebSocket send, receive, ACK timeout, server error ACK, invalid ACK,
886-
* or server close is observed after the connection has been established, the
887-
* sender enters a terminal failed state. The first failure is retained and
888-
* subsequent public operations rethrow the same {@link LineSenderException}.
889-
* Create a new sender to resume sending.
888+
* If the engine's cursor ring is at the {@code sf_max_total_bytes} cap,
889+
* {@code flush()} blocks while the I/O loop drains acked frames and
890+
* frees space, up to {@code sf_append_deadline_millis} (default 30 s);
891+
* on deadline expiry, this method throws.
892+
* <p>
893+
* For close-time drain semantics — waiting for the server to ACK
894+
* everything published before shutting the I/O loop down — use
895+
* {@link io.questdb.client.Sender.LineSenderBuilder#closeFlushTimeoutMillis(long)}.
896+
* <p>
897+
* If a WebSocket send, receive, ACK timeout, server error ACK, invalid
898+
* ACK, or server close is observed after the connection has been
899+
* established, the sender enters a terminal failed state. The first
900+
* failure is retained and subsequent public operations rethrow the same
901+
* {@link LineSenderException}. Create a new sender to resume sending.
890902
*
891-
* @throws LineSenderException if the sender is closed, a row is still in
892-
* progress, connection setup fails, or a terminal
903+
* @throws LineSenderException if the sender is closed, a row is still
904+
* in progress, connection setup fails, the
905+
* engine cap deadline expires, or a terminal
893906
* WebSocket failure is observed
894907
*/
895908
@Override
@@ -1445,6 +1458,16 @@ private void checkConnectionError() {
14451458
error.fillInStackTrace();
14461459
throw error;
14471460
}
1461+
// Poll the cursor I/O loop's lastError too. Without this, a fatal
1462+
// wire / server-rejection error recorded by the I/O thread would
1463+
// only surface on the next flush() / close() — every row-level
1464+
// method (table, longColumn, atNow, etc.) routes through
1465+
// checkNotClosed → checkConnectionError, so failing to poll here
1466+
// means callers can keep accumulating rows long after the sender
1467+
// is already broken.
1468+
if (cursorSendLoop != null) {
1469+
cursorSendLoop.checkError();
1470+
}
14481471
}
14491472

14501473
private void checkTableSelected() {

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

Lines changed: 61 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
import java.util.concurrent.ExecutorService;
3333
import java.util.concurrent.Executors;
3434
import java.util.concurrent.TimeUnit;
35+
import java.util.concurrent.atomic.AtomicInteger;
3536

3637
/**
3738
* Bounded thread pool that runs {@link BackgroundDrainer} tasks. One pool
@@ -51,11 +52,24 @@ public final class BackgroundDrainerPool implements QuietCloseable {
5152

5253
private static final Logger LOG = LoggerFactory.getLogger(BackgroundDrainerPool.class);
5354
private static final long CLOSE_GRACE_MILLIS = 3_000L;
55+
// CAS gate. Single AtomicInteger packs the closed flag (sign bit) and
56+
// the in-flight submit count (low 31 bits):
57+
// state >= 0 → open, value is the in-flight submit count
58+
// state < 0 → closed bit set, low bits still track in-flight
59+
// count waiting to drain
60+
// submit() CASes state+1 only if state >= 0; close() CASes the CLOSED
61+
// bit on, then waits for state to reach exactly CLOSED_BIT (no
62+
// in-flight). This eliminates the "submit reads closed=false then
63+
// close shuts the executor down" race window: the closed-bit CAS
64+
// contends with the increment CAS on the same atomic, so submit
65+
// either lands before close (and close waits for it to finish) or
66+
// sees the closed bit and throws.
67+
private static final int CLOSED_BIT = Integer.MIN_VALUE;
68+
private final AtomicInteger state = new AtomicInteger();
5469

5570
private final ExecutorService executor;
5671
private final CopyOnWriteArrayList<BackgroundDrainer> active = new CopyOnWriteArrayList<>();
5772
private final int maxConcurrent;
58-
private volatile boolean closed;
5973

6074
public BackgroundDrainerPool(int maxConcurrent) {
6175
if (maxConcurrent <= 0) {
@@ -77,19 +91,42 @@ public int maxConcurrent() {
7791
* Submits a drainer for background execution. The pool tracks it so
7892
* {@link #close} can request a stop. Safe to call any number of
7993
* times; excess submissions queue inside the pool's executor.
94+
* <p>
95+
* Reserves a "submit slot" on the {@link #state} CAS gate first; if
96+
* the closed bit is already set, throws immediately. Otherwise the
97+
* gate guarantees {@code close()} cannot shut the executor down until
98+
* after we release the slot, so {@code executor.submit} always lands.
8099
*/
81100
public void submit(BackgroundDrainer drainer) {
82-
if (closed) {
83-
throw new IllegalStateException("pool closed");
101+
// Reserve a slot on the gate. Spin on CAS until either we win
102+
// (state was non-negative) or we observe the closed bit.
103+
for (;;) {
104+
int s = state.get();
105+
if (s < 0) {
106+
throw new IllegalStateException("pool closed");
107+
}
108+
if (state.compareAndSet(s, s + 1)) break;
84109
}
85-
active.add(drainer);
86-
executor.submit(() -> {
87-
try {
88-
drainer.run();
89-
} finally {
110+
boolean accepted = false;
111+
try {
112+
active.add(drainer);
113+
executor.submit(() -> {
114+
try {
115+
drainer.run();
116+
} finally {
117+
active.remove(drainer);
118+
}
119+
});
120+
accepted = true;
121+
} finally {
122+
if (!accepted) {
90123
active.remove(drainer);
91124
}
92-
});
125+
// Release our slot. Decrement is safe regardless of the
126+
// closed bit's state — the bit lives in position 31 and
127+
// only the low 31 bits move.
128+
state.decrementAndGet();
129+
}
93130
}
94131

95132
/**
@@ -103,8 +140,21 @@ public java.util.List<BackgroundDrainer> snapshot() {
103140

104141
@Override
105142
public void close() {
106-
if (closed) return;
107-
closed = true;
143+
// Set the closed bit. CAS-loop because the in-flight count can be
144+
// changing under us. Subsequent submit() calls will fail their
145+
// CAS check (state < 0) and throw.
146+
for (;;) {
147+
int s = state.get();
148+
if (s < 0) return; // already closed (idempotent)
149+
if (state.compareAndSet(s, s | CLOSED_BIT)) break;
150+
}
151+
// Wait for in-flight submits to release their slots — i.e. for
152+
// state to drain to exactly CLOSED_BIT (no low bits set). This
153+
// ensures every submit's executor.submit has already returned
154+
// before we shut the executor down.
155+
while (state.get() != CLOSED_BIT) {
156+
Thread.onSpinWait();
157+
}
108158
for (BackgroundDrainer d : active) {
109159
d.requestStop();
110160
}

0 commit comments

Comments
 (0)