Skip to content

Commit 2f4393a

Browse files
committed
small fixes
1 parent 7a00ad5 commit 2f4393a

7 files changed

Lines changed: 347 additions & 42 deletions

File tree

core/src/main/c/share/zstd_jni.c

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,41 @@ JNIEXPORT jlong JNICALL Java_io_questdb_client_std_Zstd_createDCtx(
4343
return (jlong) (uintptr_t) ZSTD_createDCtx();
4444
}
4545

46+
JNIEXPORT jlong JNICALL Java_io_questdb_client_std_Zstd_getFrameContentSize(
47+
JNIEnv *env, jclass cls,
48+
jlong src_addr, jlong src_len) {
49+
/*
50+
* Peeks the zstd frame header at src_addr to recover the declared
51+
* uncompressed size. Returns:
52+
* positive -- declared content size in bytes
53+
* -1 -- frame valid, content size not stored (ZSTD_CONTENTSIZE_UNKNOWN)
54+
* -2 -- invalid frame, truncated header, or size > INT64_MAX
55+
*
56+
* Lets the Java caller size the destination buffer in a single allocation
57+
* instead of retrying decompress on dst-too-small. Crucially, it also lets
58+
* a corrupt frame fail BEFORE any output buffer growth, eliminating a
59+
* memory-amplification vector where one bad frame would have driven
60+
* scratch growth all the way to the 64 MiB cap.
61+
*/
62+
if (src_len < 0 || (src_len > 0 && src_addr == 0)) {
63+
return -2;
64+
}
65+
unsigned long long size = ZSTD_getFrameContentSize(
66+
(const void *) (uintptr_t) src_addr, (size_t) src_len);
67+
if (size == ZSTD_CONTENTSIZE_UNKNOWN) {
68+
return -1;
69+
}
70+
if (size == ZSTD_CONTENTSIZE_ERROR) {
71+
return -2;
72+
}
73+
if (size > (unsigned long long) INT64_MAX) {
74+
/* Cast to jlong would wrap to negative and look like an error code;
75+
* reject upfront so the caller doesn't double-interpret. */
76+
return -2;
77+
}
78+
return (jlong) size;
79+
}
80+
4681
JNIEXPORT void JNICALL Java_io_questdb_client_std_Zstd_freeDCtx(
4782
JNIEnv *env, jclass cls, jlong ptr) {
4883
if (ptr != 0) {

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

Lines changed: 64 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -85,9 +85,16 @@ public class QwpEgressIoThread implements Runnable, WebSocketFrameHandler {
8585
// Per-query credit state (accessed only from the I/O thread).
8686
// creditEnabled == (initialCredit > 0); controls whether we emit CREDIT
8787
// replenish frames after each batch release.
88+
// Set true by closePool() before it drains freeBuffers / events, so any
89+
// user thread still inside releaseBuffer (or one that returns from onBatch
90+
// after close has run) sees closed == true and frees the buffer in place
91+
// instead of stranding it in a queue nobody will iterate again.
92+
// Volatile because the writer (close-caller thread) and reader (user
93+
// thread inside releaseBuffer) are different threads.
94+
private volatile boolean closed;
8895
private boolean creditEnabled;
89-
private long currentRequestId = -1L;
9096
private boolean currentQueryDone;
97+
private long currentRequestId = -1L;
9198
private volatile boolean shutdown;
9299

93100
public QwpEgressIoThread(WebSocketClient wsClient, int bufferPoolSize) {
@@ -97,8 +104,21 @@ public QwpEgressIoThread(WebSocketClient wsClient, int bufferPoolSize) {
97104
// frame the I/O thread may emit on shutdown, so a full buffer pool
98105
// never stalls the producer.
99106
this.events = new QwpSpscQueue<>(bufferPoolSize + 2);
100-
for (int i = 0; i < bufferPoolSize; i++) {
101-
freeBuffers.offer(new QwpBatchBuffer(DEFAULT_BUFFER_CAPACITY));
107+
// Track allocations as we go so a partial-pool failure (e.g. native
108+
// OOM at iteration K) frees the K-1 buffers already in freeBuffers.
109+
// Without this, the half-built QwpEgressIoThread instance escapes the
110+
// failed constructor and is unreachable from QwpQueryClient.close(),
111+
// leaking those buffers' native scratches for the JVM's lifetime.
112+
try {
113+
for (int i = 0; i < bufferPoolSize; i++) {
114+
freeBuffers.offer(new QwpBatchBuffer(DEFAULT_BUFFER_CAPACITY));
115+
}
116+
} catch (Throwable t) {
117+
for (QwpBatchBuffer b : freeBuffers) {
118+
b.close();
119+
}
120+
freeBuffers.clear();
121+
throw t;
102122
}
103123
}
104124

@@ -175,6 +195,15 @@ public void onClose(int code, String reason) {
175195
* compact past the consumed frame.
176196
*/
177197
public void releaseBuffer(QwpBatchBuffer buffer) {
198+
if (closed) {
199+
// closePool already drained and cleared freeBuffers; offering this
200+
// buffer back into the pool would strand it (no consumer left to
201+
// close it). Free its native scratch in place. The pendingRelease
202+
// queue is also abandoned -- the I/O thread that would have read
203+
// the token is gone.
204+
buffer.close();
205+
return;
206+
}
178207
freeBuffers.offer(buffer);
179208
pendingRelease.offer(RELEASE_TOKEN);
180209
}
@@ -289,13 +318,22 @@ private void decodeAndEmitExecDone(long payload, int payloadLen) {
289318

290319
/**
291320
* RESULT_END body: msg_kind(1) + requestId(8) + final_seq(varint) + total_rows(varint).
292-
* We only need total_rows.
321+
* We only need total_rows. Both varint loops cap at 10 bytes so a hostile
322+
* server cannot drive {@code shift} past 63 (where Java's {@code <<} masks
323+
* the count to 6 bits and silently wraps high bytes back into the low bits,
324+
* producing a wildly wrong total).
293325
*/
294326
private long decodeResultEnd(long payload, int payloadLen) {
295327
long p = payload + QwpConstants.HEADER_SIZE + 1 + 8;
296328
long limit = payload + payloadLen;
329+
int seqBytes = 0;
297330
while (p < limit && (Unsafe.getUnsafe().getByte(p++) & 0x80) != 0) {
298-
// skip final_seq continuation bytes
331+
if (++seqBytes > 9) {
332+
// Continuation bit set on byte 10 of the final_seq varint --
333+
// malformed. Surface a 0 total rather than read into the
334+
// total_rows section with a desynced cursor.
335+
return 0L;
336+
}
299337
}
300338
long total = 0;
301339
int shift = 0;
@@ -304,6 +342,12 @@ private long decodeResultEnd(long payload, int payloadLen) {
304342
total |= (long) (b & 0x7F) << shift;
305343
if ((b & 0x80) == 0) break;
306344
shift += 7;
345+
if (shift > 63) {
346+
// Same overflow guard decodeAndEmitExecDone uses; without it,
347+
// byte 10 of total_rows could land in the sign bit and bytes
348+
// 11+ would silently wrap.
349+
return 0L;
350+
}
307351
}
308352
return total;
309353
}
@@ -394,26 +438,26 @@ private void handleResultBatch(long payloadPtr, int payloadLen) {
394438
}
395439

396440
/**
397-
* Builds and transmits a CREDIT frame on the WebSocket. Wire format:
398-
* {@code msg_kind(0x15) + request_id(u64) + additional_bytes(varint)}.
441+
* Builds and transmits a CANCEL frame on the WebSocket. Wire format:
442+
* {@code msg_kind(0x14) + request_id(u64)}.
399443
*/
400-
private void sendCredit(long requestId, long additionalBytes) {
444+
private void sendCancel(long requestId) {
401445
sendScratch.reset();
402-
sendScratch.putByte(QwpEgressMsgKind.CREDIT);
446+
sendScratch.putByte(QwpEgressMsgKind.CANCEL);
403447
sendScratch.putLong(requestId);
404-
sendScratch.putVarint(additionalBytes);
405448
wsClient.sendBinary(sendScratch.getBufferPtr(), sendScratch.getPosition());
406449
sendScratch.reset();
407450
}
408451

409452
/**
410-
* Builds and transmits a CANCEL frame on the WebSocket. Wire format:
411-
* {@code msg_kind(0x14) + request_id(u64)}.
453+
* Builds and transmits a CREDIT frame on the WebSocket. Wire format:
454+
* {@code msg_kind(0x15) + request_id(u64) + additional_bytes(varint)}.
412455
*/
413-
private void sendCancel(long requestId) {
456+
private void sendCredit(long requestId, long additionalBytes) {
414457
sendScratch.reset();
415-
sendScratch.putByte(QwpEgressMsgKind.CANCEL);
458+
sendScratch.putByte(QwpEgressMsgKind.CREDIT);
416459
sendScratch.putLong(requestId);
460+
sendScratch.putVarint(additionalBytes);
417461
wsClient.sendBinary(sendScratch.getBufferPtr(), sendScratch.getPosition());
418462
sendScratch.reset();
419463
}
@@ -450,6 +494,12 @@ private void sendQueryRequest(QueryRequest req) {
450494
* forever on an empty queue.
451495
*/
452496
void closePool() {
497+
// Set closed BEFORE draining so a user thread that returns from
498+
// onBatch concurrently and calls releaseBuffer sees closed=true and
499+
// frees the buffer in place rather than offering it into a queue
500+
// we're about to clear. Volatile write pairs with the volatile read
501+
// in releaseBuffer.
502+
closed = true;
453503
Misc.free(sendScratch);
454504
Misc.free(decoder);
455505
QueryEvent ev;

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

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -93,20 +93,20 @@ public class QwpQueryClient implements QuietCloseable {
9393
// payload before it parks, and the client auto-replenishes by the size of
9494
// each batch as the user releases it.
9595
private long initialCreditBytes;
96-
// Client preference for server-side per-batch row cap. 0 means "unset",
97-
// server uses its default. Set via {@code max_batch_rows=N} in the
98-
// connection string or {@link #withMaxBatchRows}. Smaller values give
99-
// streaming consumers earlier access to the first rows at the cost of
100-
// more per-batch overhead; larger values amortise fixed costs over more
101-
// rows. Server may clamp down to its own hard cap.
102-
private int maxBatchRows;
10396
// Volatile so a cancel() call from a thread other than the one that ran
10497
// connect() sees the published reference (and a concurrent null-out from
10598
// close() is observed without a stale-reference race). The thread-safety
10699
// contract documented on cancel() relies on this.
107100
private volatile QwpEgressIoThread ioThread;
108101
private Thread ioThreadHandle;
109102
private boolean lastCloseTimedOut;
103+
// Client preference for server-side per-batch row cap. 0 means "unset",
104+
// server uses its default. Set via {@code max_batch_rows=N} in the
105+
// connection string or {@link #withMaxBatchRows}. Smaller values give
106+
// streaming consumers earlier access to the first rows at the cost of
107+
// more per-batch overhead; larger values amortise fixed costs over more
108+
// rows. Server may clamp down to its own hard cap.
109+
private int maxBatchRows;
110110
private int negotiatedQwpVersion;
111111
private long nextRequestId = 1;
112112
// Maximum time close() will wait for the I/O thread to exit before giving up
@@ -388,6 +388,17 @@ public void cancel() {
388388
* resources leak for the lifetime of the process. A warning is recorded by setting
389389
* {@link #lastCloseTimedOut} (queryable via {@link #wasLastCloseTimedOut}) so callers
390390
* can detect and report the condition.
391+
* <p>
392+
* <strong>Threading contract:</strong> {@code close()} must be called from a thread
393+
* other than the one currently inside a batch handler, AND the user must finish
394+
* any in-flight {@code execute()} before calling {@code close()}. Calling
395+
* {@code close()} concurrently with a {@code handler.onBatch(...)} that is still
396+
* dereferencing batch column pointers can free the WebSocket recv buffer under
397+
* those pointers and SIGSEGV the JVM. The interrupt-driven I/O thread shutdown
398+
* does NOT detect or wait for a still-running user handler -- the timeout-based
399+
* leak fallback only protects against an unresponsive I/O thread, not an
400+
* unresponsive user thread. {@link #cancel()} is the right way to ask an
401+
* in-flight {@code execute()} to return; close after it does.
391402
*/
392403
@Override
393404
public void close() {
@@ -747,11 +758,9 @@ private String buildAcceptEncodingHeader() {
747758
* the caller doesn't inherit a half-open socket.
748759
*/
749760
private void probeZstdAvailable() {
761+
long dctx;
750762
try {
751-
long dctx = Zstd.createDCtx();
752-
if (dctx != 0) {
753-
Zstd.freeDCtx(dctx);
754-
}
763+
dctx = Zstd.createDCtx();
755764
} catch (UnsatisfiedLinkError e) {
756765
LOG.error("zstd JNI symbols missing from libquestdb; aborting connect", e);
757766
if (webSocketClient != null) {
@@ -764,5 +773,20 @@ private void probeZstdAvailable() {
764773
+ "compression=raw on the connection string to skip the probe. "
765774
+ "[cause=" + e.getMessage() + "]");
766775
}
776+
if (dctx == 0) {
777+
// Native createDCtx returned 0 -- the underlying ZSTD_createDCtx failed
778+
// (typically allocator pressure). Don't proceed: a connection that
779+
// negotiated zstd but cannot decode it would surface as a confusing
780+
// mid-stream decode error on the first compressed batch instead.
781+
LOG.error("zstd createDCtx returned 0 (native allocation failure); aborting connect");
782+
if (webSocketClient != null) {
783+
webSocketClient.close();
784+
webSocketClient = null;
785+
}
786+
throw new HttpClientException("zstd decompression context allocation failed; "
787+
+ "cannot accept compressed batches. Set compression=raw on the connection "
788+
+ "string to disable compression, or retry once memory pressure subsides.");
789+
}
790+
Zstd.freeDCtx(dctx);
767791
}
768792
}

0 commit comments

Comments
 (0)