Skip to content

Commit 41ae975

Browse files
bluestreak01claude
andcommitted
fix(ilp): cursor SF — apply PR-17 review critical and moderate findings
Critical: - C1: SegmentRing.openExisting quarantines corrupt-first-frame .sfa files to <path>.corrupt instead of unlinking, preventing silent loss of valid frames behind a bit-flipped frame[0] CRC. - C2: clamp wireSeq in CursorWebSocketSendLoop.handleServerRejection against nextWireSeq-1 (matches OK-ACK branch); SegmentRing.acknowledge also clamps at publishedFsn for defense-in-depth. - C4: recordFatal now runs before dispatchError at all four HALT sites in CursorWebSocketSendLoop so the typed terminal error is latched before the user handler is invoked. - C5: MmapSegment.create removes the on-disk file on mmap-fail catch; SegmentManager.serviceRing cleanup removes the path even when the spare is null. - C7: remove stray QWP_CLIENT_REVIEW.md (review notes for vi_egress). - C8: Sender.build wraps startOrphanDrainers in its own try/catch that closes the connected sender on failure; the outer catch (which closes cursorEngine directly) only fires for the pre-connect window before ownership transfer. Moderate: - M1: MmapSegment.openExisting validates baseSeq >= 0 and throws MmapSegmentException so SegmentRing's skip-with-log handles it. - M2: SegmentRing.openExisting catches Throwable per-file and wraps the whole recovery body in an outer catch that closes every recovered segment on rethrow, plugging fd+mmap leaks. - M3: POSIX read/write/append reject negative len with EINVAL, matching the existing Win32 guard. - M4: crc32c.c _Static_assert on __BYTE_ORDER__ so big-endian builds fail loudly rather than silently miscompiling slice-by-8. Tests: - C9: ServerErrorAckTerminalTest and IoThreadErrorSurfacedOnRowApiTest flipped from STATUS_SCHEMA_MISMATCH (DROP) to STATUS_PARSE_ERROR (HALT) — the terminal-throw contract being asserted is the HALT contract per spec. New testDropPolicyNackDoesNotHaltAndAdvancesAck pins the DROP_AND_CONTINUE contract. - C10: MmapSegmentTest.testFirstFrameCrcCorruptionFlagsTornTailAnd PreservesFile covers the unit-level contract for corrupt frame[0]. - C11: PrReviewRedTestsE2e adds end-to-end coverage for the central user-visible error API contract — flush() after a HALT NACK throws LineSenderServerException carrying the typed SenderError. - SegmentRingTest.testAcknowledgeIsMonotonic publishes frames before acking to reflect the new clamp-at-publishedFsn contract. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 36b0839 commit 41ae975

14 files changed

Lines changed: 967 additions & 201 deletions

File tree

QWP_CLIENT_REVIEW.md

Lines changed: 0 additions & 95 deletions
This file was deleted.

core/src/main/c/share/crc32c.c

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,27 @@
2525
#include <jni.h>
2626
#include <stdint.h>
2727
#include <stddef.h>
28+
#include <assert.h>
29+
30+
/*
31+
* Slice-by-8 fold below assumes a little-endian byte order: the
32+
* __builtin_memcpy of the first 4 bytes into a uint32_t is XORed against
33+
* `crc` and then sliced as crc & 0xff / (crc >> 8) & 0xff / (crc >> 16) &
34+
* 0xff / (crc >> 24) & 0xff. On big-endian (s390x, ppc64be) this would
35+
* shift the bytes through the wrong tables and silently produce wrong
36+
* CRCs — which on the SF path would manifest as data-loss-after-recovery
37+
* because a bit-correct frame would still fail the integrity check.
38+
*
39+
* QuestDB's shipped binaries are all little-endian (linux/macOS x86_64
40+
* and aarch64, Windows x86_64), so this is a forward-looking guard rather
41+
* than a runtime fix. Using the static-assertion form failing the build
42+
* is the right answer; we do not want a compile-time-best-effort fallback
43+
* to a portable byte-by-byte path that miscompiles silently.
44+
*/
45+
#if defined(__BYTE_ORDER__) && defined(__ORDER_LITTLE_ENDIAN__)
46+
_Static_assert(__BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__,
47+
"CRC32C slice-by-8 requires little-endian byte order");
48+
#endif
2849

2950
/*
3051
* CRC-32C (Castagnoli) software implementation, reflected — slice-by-8.

core/src/main/c/share/files.c

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,20 +94,37 @@ JNIEXPORT jint JNICALL Java_io_questdb_client_std_Files_openCleanRW0
9494

9595
JNIEXPORT jlong JNICALL Java_io_questdb_client_std_Files_read
9696
(JNIEnv *e, jclass cl, jint fd, jlong addr, jlong len, jlong offset) {
97+
// Reject negative len explicitly: jlong is signed but pread takes a
98+
// size_t. Without this guard the cast wraps a small negative value
99+
// into an enormous unsigned read length and the kernel may either
100+
// SEGV on the address space or scribble far past the caller's buffer.
101+
// The Win32 path already does this; matching here.
102+
if (len < 0) {
103+
errno = EINVAL;
104+
return -1;
105+
}
97106
ssize_t res;
98107
RESTARTABLE(pread((int) fd, (void *) (uintptr_t) addr, (size_t) len, (off_t) offset), res);
99108
return (jlong) res;
100109
}
101110

102111
JNIEXPORT jlong JNICALL Java_io_questdb_client_std_Files_write
103112
(JNIEnv *e, jclass cl, jint fd, jlong addr, jlong len, jlong offset) {
113+
if (len < 0) {
114+
errno = EINVAL;
115+
return -1;
116+
}
104117
ssize_t res;
105118
RESTARTABLE(pwrite((int) fd, (const void *) (uintptr_t) addr, (size_t) len, (off_t) offset), res);
106119
return (jlong) res;
107120
}
108121

109122
JNIEXPORT jlong JNICALL Java_io_questdb_client_std_Files_append
110123
(JNIEnv *e, jclass cl, jint fd, jlong addr, jlong len) {
124+
if (len < 0) {
125+
errno = EINVAL;
126+
return -1;
127+
}
111128
ssize_t res;
112129
RESTARTABLE(write((int) fd, (const void *) (uintptr_t) addr, (size_t) len), res);
113130
return (jlong) res;

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

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1091,11 +1091,12 @@ public Sender build() {
10911091
CursorSendEngine cursorEngine = new CursorSendEngine(
10921092
slotPath, actualSfMaxBytes,
10931093
actualSfMaxTotalBytes, actualSfAppendDeadlineNanos);
1094+
int actualErrorInboxCapacity = errorInboxCapacity != PARAMETER_NOT_SET_EXPLICITLY
1095+
? errorInboxCapacity
1096+
: io.questdb.client.cutlass.qwp.client.sf.cursor.SenderErrorDispatcher.DEFAULT_CAPACITY;
1097+
QwpWebSocketSender connected;
10941098
try {
1095-
int actualErrorInboxCapacity = errorInboxCapacity != PARAMETER_NOT_SET_EXPLICITLY
1096-
? errorInboxCapacity
1097-
: io.questdb.client.cutlass.qwp.client.sf.cursor.SenderErrorDispatcher.DEFAULT_CAPACITY;
1098-
QwpWebSocketSender connected = QwpWebSocketSender.connect(
1099+
connected = QwpWebSocketSender.connect(
10991100
hosts.getQuick(0),
11001101
ports.getQuick(0),
11011102
wsTlsConfig,
@@ -1115,6 +1116,24 @@ public Sender build() {
11151116
errorHandler,
11161117
actualErrorInboxCapacity
11171118
);
1119+
} catch (Throwable t) {
1120+
// connect() failed before ownership of cursorEngine
1121+
// transferred — close it ourselves.
1122+
try {
1123+
cursorEngine.close();
1124+
} catch (Throwable ignored) {
1125+
// best-effort
1126+
}
1127+
throw t;
1128+
}
1129+
// connect() succeeded — `connected` now owns cursorEngine
1130+
// via setCursorEngine(engine, true). From here on, ANY
1131+
// failure must close `connected` (which closes the engine
1132+
// through ownsCursorEngine), not cursorEngine directly:
1133+
// closing the engine alone would leak the I/O thread,
1134+
// dispatcher daemon, drainer pool, microbatch buffers and
1135+
// WebSocketClient inside the abandoned `connected`.
1136+
try {
11181137
// Once the foreground sender is up, dispatch drainers
11191138
// for any sibling orphan slots. Scan AFTER we acquire
11201139
// our own slot lock so we never accidentally try to
@@ -1139,7 +1158,7 @@ public Sender build() {
11391158
return connected;
11401159
} catch (Throwable t) {
11411160
try {
1142-
cursorEngine.close();
1161+
connected.close();
11431162
} catch (Throwable ignored) {
11441163
// best-effort
11451164
}

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

Lines changed: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -434,8 +434,13 @@ private void fail(Throwable initial) {
434434
System.nanoTime()
435435
);
436436
totalServerErrors.incrementAndGet();
437-
dispatchError(err);
437+
// recordFatal MUST run before dispatchError: the spec
438+
// requires signal.terminalError to be latched BEFORE the
439+
// handler is invoked, so a handler that synchronously
440+
// probes getLastTerminalError() (or calls flush()) sees
441+
// the typed error rather than null.
438442
recordFatal(new LineSenderServerException(err), err);
443+
dispatchError(err);
439444
return;
440445
}
441446
lastReconnectError = e;
@@ -481,8 +486,10 @@ private void fail(Throwable initial) {
481486
System.nanoTime()
482487
);
483488
totalServerErrors.incrementAndGet();
484-
dispatchError(err);
489+
// recordFatal MUST run before dispatchError so the producer-observable
490+
// terminal error is latched before the handler is invoked.
485491
recordFatal(new LineSenderServerException(err), err);
492+
dispatchError(err);
486493
}
487494

488495
/**
@@ -788,8 +795,11 @@ public void onClose(int code, String reason) {
788795
System.nanoTime()
789796
);
790797
totalServerErrors.incrementAndGet();
791-
dispatchError(err);
798+
// recordFatal MUST run before dispatchError so the producer-
799+
// observable terminal error is latched before the handler is
800+
// invoked.
792801
recordFatal(new LineSenderServerException(err), err);
802+
dispatchError(err);
793803
return;
794804
}
795805
fail(new LineSenderException(
@@ -837,7 +847,18 @@ private void handleServerRejection(long wireSeq) {
837847
byte status = response.getStatus();
838848
SenderError.Category category = classify(status);
839849
SenderError.Policy policy = defaultPolicyFor(category);
840-
long fsn = fsnAtZero + Math.max(0L, wireSeq);
850+
// Same sanity clamp as the success branch above: do not trust a
851+
// rejection wireSeq beyond what we've actually sent. Without this
852+
// clamp the DROP path advances ackedFsn past publishedFsn, which
853+
// makes the segment manager trim sealed segments the I/O thread
854+
// is still reading — and the next Unsafe.getInt SEGVs the JVM.
855+
long highestSent = nextWireSeq - 1L;
856+
long cappedSeq = Math.max(0L, Math.min(wireSeq, highestSent));
857+
if (cappedSeq < wireSeq) {
858+
LOG.warn("server NACK wire seq {} exceeds highest sent {} — clamping",
859+
wireSeq, highestSent);
860+
}
861+
long fsn = fsnAtZero + cappedSeq;
841862
// Best-effort table attribution: the parser populates
842863
// response.tableNames on error frames the same way it does on
843864
// STATUS_OK. If exactly one table was named, surface it; if
@@ -857,27 +878,26 @@ private void handleServerRejection(long wireSeq) {
857878
System.nanoTime()
858879
);
859880
totalServerErrors.incrementAndGet();
860-
// Async-deliver to the user handler regardless of policy. HALT
861-
// also surfaces synchronously via the producer-thread typed throw
862-
// below; DROP is observable ONLY via the async path, so the
863-
// dispatcher is the user's only chance to dead-letter the data.
864-
dispatchError(err);
865881

866882
if (policy == SenderError.Policy.HALT) {
867-
// Terminal: stash the typed payload, raise a typed exception
868-
// through the existing recordFatal -> checkError -> producer
869-
// throw path. Bytes on disk are the bytes the server
870-
// rejected; reconnect/replay cannot fix them.
883+
// Terminal: stash the typed payload BEFORE dispatching to the
884+
// handler. The spec requires signal.terminalError to be latched
885+
// before the handler is invoked so a handler that synchronously
886+
// probes getLastTerminalError() (or calls flush()) sees the
887+
// typed error rather than null. Bytes on disk are the bytes
888+
// the server rejected; reconnect/replay cannot fix them.
871889
recordFatal(new LineSenderServerException(err), err);
890+
dispatchError(err);
872891
} else {
873892
// DROP_AND_CONTINUE: advance ackedFsn past the rejected span
874893
// so the loop drains subsequent batches. The data is dropped
875894
// from the SF disk store via the existing trim path; the
876-
// dispatch above is the user's only handle to dead-letter.
895+
// dispatch is the user's only handle to dead-letter.
877896
LOG.warn("server rejected wire seq {} (category={}, status=0x{}) — dropping batch and continuing",
878897
wireSeq, category, Integer.toHexString(status & 0xFF));
879898
engine.acknowledge(fsn);
880899
totalAcks.incrementAndGet();
900+
dispatchError(err);
881901
}
882902
}
883903
}

0 commit comments

Comments
 (0)