From cba2afe88719124d1d64b3f4dfe5f59ba2a9f27d Mon Sep 17 00:00:00 2001 From: bluestreak Date: Sat, 18 Apr 2026 18:00:46 +0100 Subject: [PATCH 01/44] wip --- .../example/query/ErrorHandlingExample.java | 64 ++++++++++ .../query/LargeResultStreamingExample.java | 68 +++++++++++ .../com/example/query/TypedResultExample.java | 113 ++++++++++++++++++ 3 files changed, 245 insertions(+) create mode 100644 examples/src/main/java/com/example/query/ErrorHandlingExample.java create mode 100644 examples/src/main/java/com/example/query/LargeResultStreamingExample.java create mode 100644 examples/src/main/java/com/example/query/TypedResultExample.java diff --git a/examples/src/main/java/com/example/query/ErrorHandlingExample.java b/examples/src/main/java/com/example/query/ErrorHandlingExample.java new file mode 100644 index 00000000..2330dc48 --- /dev/null +++ b/examples/src/main/java/com/example/query/ErrorHandlingExample.java @@ -0,0 +1,64 @@ +package com.example.query; + +import io.questdb.client.cutlass.qwp.client.QwpColumnBatch; +import io.questdb.client.cutlass.qwp.client.QwpColumnBatchHandler; +import io.questdb.client.cutlass.qwp.client.QwpQueryClient; + +/** + * Surfacing server-side errors back to application code. + *

+ * When the server rejects a query (syntax error, missing table, unsupported + * statement, permission denied), it sends a {@code QUERY_ERROR} frame rather + * than data. The client delivers this via {@link QwpColumnBatchHandler#onError}, + * skipping {@code onBatch} / {@code onEnd} entirely. + *

+ * Status codes mirror the ingress namespace. For egress the common ones are: + *

+ * SQL-level errors carry the position embedded in the message, using QuestDB's + * standard "{@code [pos] text}" format, so you can point the user directly at + * the offending token. + */ +public class ErrorHandlingExample { + + public static void main(String[] args) { + try (QwpQueryClient client = QwpQueryClient.newPlainText("localhost", 9000)) { + client.connect(); + + // Malformed SQL — triggers a parse error at position 14 (just past "FROM"). + runAndReport(client, "SELECT * FROM"); + + // Nonexistent table — also reported as PARSE_ERROR with a "does not exist" message. + runAndReport(client, "SELECT * FROM nowhere"); + + // DDL sent over the read endpoint — Phase 1 restricts /read/v1 to SELECT. + runAndReport(client, "DROP TABLE trades"); + } + } + + private static void runAndReport(QwpQueryClient client, final String sql) { + System.out.println("-- executing: " + sql); + client.execute(sql, new QwpColumnBatchHandler() { + @Override + public void onBatch(QwpColumnBatch batch) { + System.out.println("(unexpected) received " + batch.getRowCount() + " rows"); + } + + @Override + public void onEnd(long totalRows) { + System.out.println("query succeeded: rows=" + totalRows); + } + + @Override + public void onError(byte status, String message) { + System.out.printf("query failed: status=0x%02X, message=%s%n", status & 0xFF, message); + } + }); + } +} diff --git a/examples/src/main/java/com/example/query/LargeResultStreamingExample.java b/examples/src/main/java/com/example/query/LargeResultStreamingExample.java new file mode 100644 index 00000000..ef0f9447 --- /dev/null +++ b/examples/src/main/java/com/example/query/LargeResultStreamingExample.java @@ -0,0 +1,68 @@ +package com.example.query; + +import io.questdb.client.cutlass.qwp.client.QwpColumnBatch; +import io.questdb.client.cutlass.qwp.client.QwpColumnBatchHandler; +import io.questdb.client.cutlass.qwp.client.QwpQueryClient; + +/** + * Streaming over a large result set. + *

+ * For result sets that don't fit in memory, the column-batch consumer is the + * right entry point: each {@code onBatch} callback sees one {@code RESULT_BATCH} + * frame (up to a few thousand rows), letting you process the data incrementally + * without the whole result set ever being materialised in the client. + *

+ * The server streams continuously until the cursor is exhausted; batches arrive + * on the calling thread inside {@code client.execute(...)} as the WebSocket + * yields frames. Nothing you write on the client influences pacing in Phase 1 + * (credit-based flow control is a follow-up). + */ +public class LargeResultStreamingExample { + + public static void main(String[] args) { + try (QwpQueryClient client = QwpQueryClient.newPlainText("localhost", 9000)) { + client.connect(); + + // Running totals we accumulate across all batches without ever holding + // the whole result set in memory. + final long[] rowsSeen = {0}; + final long[] batchCount = {0}; + final double[] priceSum = {0.0}; + + client.execute( + "SELECT ts, price FROM trades WHERE ts > dateadd('d', -7, now())", + new QwpColumnBatchHandler() { + @Override + public void onBatch(QwpColumnBatch batch) { + batchCount[0]++; + int rows = batch.getRowCount(); + for (int r = 0; r < rows; r++) { + priceSum[0] += batch.getDouble(1, r); + } + rowsSeen[0] += rows; + + // Per-batch progress marker for long-running queries. + if (batchCount[0] % 10 == 0) { + System.out.println("received " + rowsSeen[0] + " rows so far"); + } + } + + @Override + public void onEnd(long totalRows) { + System.out.printf( + "done: rows=%d batches=%d priceSum=%.2f%n", + rowsSeen[0], batchCount[0], priceSum[0] + ); + } + + @Override + public void onError(byte status, String message) { + throw new RuntimeException( + "query failed (status=" + status + "): " + message + ); + } + } + ); + } + } +} diff --git a/examples/src/main/java/com/example/query/TypedResultExample.java b/examples/src/main/java/com/example/query/TypedResultExample.java new file mode 100644 index 00000000..ae50875c --- /dev/null +++ b/examples/src/main/java/com/example/query/TypedResultExample.java @@ -0,0 +1,113 @@ +package com.example.query; + +import io.questdb.client.cutlass.qwp.client.QwpColumnBatch; +import io.questdb.client.cutlass.qwp.client.QwpColumnBatchHandler; +import io.questdb.client.cutlass.qwp.client.QwpQueryClient; +import io.questdb.client.cutlass.qwp.protocol.QwpConstants; + +/** + * Reading every supported wire type from a {@link QwpColumnBatch}. + *

+ * The batch exposes per-cell typed accessors and a {@code getColumnWireType(col)} + * helper so you can dispatch generically when the query's column set isn't known + * at compile time (e.g., a generic query runner). + *

+ * Assumes a table containing a representative set of columns, for example: + *

+ *   CREATE TABLE demo (
+ *       b BOOLEAN, bt BYTE, sh SHORT, ch CHAR,
+ *       i INT, l LONG, f FLOAT, d DOUBLE,
+ *       dt DATE, ts TIMESTAMP,
+ *       s STRING, v VARCHAR, sy SYMBOL,
+ *       u UUID, l256 LONG256,
+ *       g GEOHASH(20b),
+ *       d64 DECIMAL(18,2)
+ *   );
+ * 
+ */ +public class TypedResultExample { + + public static void main(String[] args) { + try (QwpQueryClient client = QwpQueryClient.newPlainText("localhost", 9000)) { + client.connect(); + client.execute("SELECT * FROM demo LIMIT 5", new QwpColumnBatchHandler() { + @Override + public void onBatch(QwpColumnBatch batch) { + int cols = batch.getColumnCount(); + int rows = batch.getRowCount(); + for (int row = 0; row < rows; row++) { + StringBuilder line = new StringBuilder(); + for (int col = 0; col < cols; col++) { + if (col > 0) line.append(" | "); + line.append(batch.getColumnName(col)).append('='); + if (batch.isNull(col, row)) { + line.append("NULL"); + } else { + appendCell(line, batch, col, row); + } + } + System.out.println(line); + } + } + + @Override + public void onEnd(long totalRows) { + } + + @Override + public void onError(byte status, String message) { + System.err.println("query error: " + message); + } + }); + } + } + + /** + * Appends a typed value to the builder using the column's wire type to pick + * the right accessor. The set of wire type codes is in {@link QwpConstants}. + */ + private static void appendCell(StringBuilder out, QwpColumnBatch batch, int col, int row) { + byte type = batch.getColumnWireType(col); + if (type == QwpConstants.TYPE_BOOLEAN) { + out.append(((Boolean) batch.getValue(col, row)).booleanValue()); + } else if (type == QwpConstants.TYPE_BYTE + || type == QwpConstants.TYPE_SHORT + || type == QwpConstants.TYPE_CHAR + || type == QwpConstants.TYPE_INT + || type == QwpConstants.TYPE_LONG + || type == QwpConstants.TYPE_DATE + || type == QwpConstants.TYPE_TIMESTAMP + || type == QwpConstants.TYPE_TIMESTAMP_NANOS + || type == QwpConstants.TYPE_DECIMAL64) { + out.append(batch.getLong(col, row)); + } else if (type == QwpConstants.TYPE_FLOAT) { + out.append(batch.getFloat(col, row)); + } else if (type == QwpConstants.TYPE_DOUBLE) { + out.append(batch.getDouble(col, row)); + } else if (type == QwpConstants.TYPE_STRING || type == QwpConstants.TYPE_SYMBOL) { + out.append(batch.getString(col, row)); + } else if (type == QwpConstants.TYPE_VARCHAR) { + out.append(new String(batch.getVarchar(col, row))); + } else if (type == QwpConstants.TYPE_UUID) { + long[] parts = batch.getLongArray(col, row); // [lo, hi] + out.append(String.format("%016x-%016x", parts[1], parts[0])); + } else if (type == QwpConstants.TYPE_LONG256) { + long[] parts = batch.getLongArray(col, row); // 4 longs LSB-first + out.append(String.format("0x%016x%016x%016x%016x", + parts[3], parts[2], parts[1], parts[0])); + } else if (type == QwpConstants.TYPE_GEOHASH) { + out.append("geohash(").append(batch.getGeohashPrecisionBits(col)) + .append("b)=0x").append(Long.toHexString(batch.getLong(col, row))); + } else if (type == QwpConstants.TYPE_DECIMAL128 || type == QwpConstants.TYPE_DECIMAL256) { + long[] parts = batch.getLongArray(col, row); + out.append("decimal("); + for (int i = 0; i < parts.length; i++) { + if (i > 0) out.append(','); + out.append(parts[i]); + } + out.append(')'); + } else { + out.append("(type 0x").append(Integer.toHexString(type & 0xFF)).append(")"); + } + } +} From 4065597e18cda45c0b420c50ed2a8fa0e493d828 Mon Sep 17 00:00:00 2001 From: bluestreak Date: Sat, 18 Apr 2026 18:00:50 +0100 Subject: [PATCH 02/44] wip --- .../cutlass/qwp/client/QwpColumnBatch.java | 138 +++++ .../qwp/client/QwpColumnBatchHandler.java | 61 +++ .../qwp/client/QwpDecodeException.java | 35 ++ .../qwp/client/QwpEgressColumnInfo.java | 42 ++ .../cutlass/qwp/client/QwpEgressMsgKind.java | 42 ++ .../cutlass/qwp/client/QwpQueryClient.java | 282 ++++++++++ .../qwp/client/QwpResultBatchDecoder.java | 493 ++++++++++++++++++ .../com/example/query/BasicQueryExample.java | 59 +++ 8 files changed, 1152 insertions(+) create mode 100644 core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpColumnBatch.java create mode 100644 core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpColumnBatchHandler.java create mode 100644 core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpDecodeException.java create mode 100644 core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpEgressColumnInfo.java create mode 100644 core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpEgressMsgKind.java create mode 100644 core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpQueryClient.java create mode 100644 core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpResultBatchDecoder.java create mode 100644 examples/src/main/java/com/example/query/BasicQueryExample.java diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpColumnBatch.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpColumnBatch.java new file mode 100644 index 00000000..a3cac4dc --- /dev/null +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpColumnBatch.java @@ -0,0 +1,138 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +package io.questdb.client.cutlass.qwp.client; + +import io.questdb.client.std.ObjList; + +/** + * Column-major view over one decoded {@code RESULT_BATCH}. The view becomes + * stale after the next {@code decode()} call on the owning + * {@link QwpResultBatchDecoder}. + *

+ * Values are returned boxed for Phase-1 simplicity. Primitive accessors + * ({@link #getLong}, {@link #getDouble}, etc.) unbox with {@link #isNull} + * returning {@code true} for NULL rows (primitives return 0/NaN in that case). + */ +public class QwpColumnBatch { + + ObjList columns; + /** + * columnValues[columnIndex][rowIndex] = boxed value (null for NULL rows). + * Visibility: package-private so {@link QwpResultBatchDecoder} populates directly. + */ + final ObjList columnValues = new ObjList<>(); + long batchSeq; + int columnCount; + long requestId; + int rowCount; + + public int getColumnCount() { + return columnCount; + } + + public String getColumnName(int col) { + return columns.getQuick(col).name; + } + + public byte getColumnWireType(int col) { + return columns.getQuick(col).wireType; + } + + public double getDouble(int col, int row) { + Object v = columnValues.getQuick(col)[row]; + return v == null ? Double.NaN : (Double) v; + } + + public float getFloat(int col, int row) { + Object v = columnValues.getQuick(col)[row]; + return v == null ? Float.NaN : (Float) v; + } + + public int getGeohashPrecisionBits(int col) { + return columns.getQuick(col).precisionBits; + } + + public long getLong(int col, int row) { + Object v = columnValues.getQuick(col)[row]; + return v == null ? 0L : (Long) v; + } + + public long[] getLongArray(int col, int row) { + return (long[]) columnValues.getQuick(col)[row]; + } + + public int getRowCount() { + return rowCount; + } + + public String getString(int col, int row) { + return (String) columnValues.getQuick(col)[row]; + } + + public Object getValue(int col, int row) { + return columnValues.getQuick(col)[row]; + } + + public byte[] getVarchar(int col, int row) { + return (byte[]) columnValues.getQuick(col)[row]; + } + + public boolean isNull(int col, int row) { + return columnValues.getQuick(col)[row] == null; + } + + public long requestId() { + return requestId; + } + + public long batchSeq() { + return batchSeq; + } + + void reset(long requestId, long batchSeq, int rowCount, int columnCount, ObjList columns) { + this.requestId = requestId; + this.batchSeq = batchSeq; + this.rowCount = rowCount; + this.columnCount = columnCount; + this.columns = columns; + while (columnValues.size() < columnCount) { + columnValues.add(new Object[0]); + } + for (int ci = 0; ci < columnCount; ci++) { + Object[] arr = columnValues.getQuick(ci); + if (arr.length < rowCount) { + arr = new Object[rowCount]; + columnValues.setQuick(ci, arr); + } else { + // Clear trailing slots from previous batches + for (int r = rowCount; r < arr.length; r++) arr[r] = null; + } + } + } + + Object[] columnValues(int col) { + return columnValues.getQuick(col); + } +} diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpColumnBatchHandler.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpColumnBatchHandler.java new file mode 100644 index 00000000..19789a12 --- /dev/null +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpColumnBatchHandler.java @@ -0,0 +1,61 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +package io.questdb.client.cutlass.qwp.client; + +/** + * Callback interface for consuming a streamed QWP egress query result. + *

+ * Invoked by {@link QwpQueryClient#execute(String, QwpColumnBatchHandler)}: + * once per {@code RESULT_BATCH} frame via {@link #onBatch(QwpColumnBatch)}, + * then exactly once via either {@link #onEnd(long)} or + * {@link #onError(byte, String)}. + *

+ * The {@link QwpColumnBatch} passed to {@link #onBatch} is valid only for the + * duration of the callback. Copy any values you need to retain. + */ +public interface QwpColumnBatchHandler { + + /** + * Invoked for each {@code RESULT_BATCH} received. + * + * @param batch column-major view over the batch; valid until {@code onBatch} returns + */ + void onBatch(QwpColumnBatch batch); + + /** + * Invoked exactly once after the last batch, upon successful completion of the query. + * + * @param totalRows server-reported total row count (0 if not tracked) + */ + void onEnd(long totalRows); + + /** + * Invoked exactly once if the query fails at any point. + * + * @param status one of the QWP status codes (e.g., {@code STATUS_PARSE_ERROR}) + * @param message server-supplied error message (may be empty) + */ + void onError(byte status, String message); +} diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpDecodeException.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpDecodeException.java new file mode 100644 index 00000000..3926914f --- /dev/null +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpDecodeException.java @@ -0,0 +1,35 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +package io.questdb.client.cutlass.qwp.client; + +/** + * Thrown by {@link QwpResultBatchDecoder} when an inbound frame is malformed + * or references unknown connection state. + */ +public class QwpDecodeException extends Exception { + public QwpDecodeException(String message) { + super(message); + } +} diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpEgressColumnInfo.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpEgressColumnInfo.java new file mode 100644 index 00000000..bba64e02 --- /dev/null +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpEgressColumnInfo.java @@ -0,0 +1,42 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +package io.questdb.client.cutlass.qwp.client; + +/** + * Per-column metadata recorded when a schema is registered on a client connection. + */ +public class QwpEgressColumnInfo { + public String name; + public int precisionBits; // valid only for GEOHASH + public int scale; // valid only for DECIMAL* + public byte wireType; + + public void of(String name, byte wireType, int scale, int precisionBits) { + this.name = name; + this.wireType = wireType; + this.scale = scale; + this.precisionBits = precisionBits; + } +} diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpEgressMsgKind.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpEgressMsgKind.java new file mode 100644 index 00000000..0a3f952c --- /dev/null +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpEgressMsgKind.java @@ -0,0 +1,42 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +package io.questdb.client.cutlass.qwp.client; + +/** + * QWP egress message-kind discriminator bytes. Mirrors the server-side constants + * in {@code io.questdb.cutlass.qwp.codec.QwpEgressMsgKind}. First byte of every + * egress payload identifies which of the egress message types it carries. + */ +public final class QwpEgressMsgKind { + public static final byte CANCEL = 0x14; + public static final byte CREDIT = 0x15; + public static final byte QUERY_ERROR = 0x13; + public static final byte QUERY_REQUEST = 0x10; + public static final byte RESULT_BATCH = 0x11; + public static final byte RESULT_END = 0x12; + + private QwpEgressMsgKind() { + } +} diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpQueryClient.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpQueryClient.java new file mode 100644 index 00000000..7215aa49 --- /dev/null +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpQueryClient.java @@ -0,0 +1,282 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +package io.questdb.client.cutlass.qwp.client; + +import io.questdb.client.cutlass.http.client.WebSocketClient; +import io.questdb.client.cutlass.http.client.WebSocketClientFactory; +import io.questdb.client.cutlass.http.client.WebSocketFrameHandler; +import io.questdb.client.cutlass.qwp.protocol.QwpConstants; +import io.questdb.client.std.Misc; +import io.questdb.client.std.QuietCloseable; +import io.questdb.client.std.Unsafe; + +import java.nio.charset.StandardCharsets; + +/** + * QWP egress (query results) client. Phase-1 skeleton: connects to /read/v1, + * negotiates the QWP protocol version, and closes cleanly. + *

+ * Thread safety: not thread-safe. A single instance should be used from one thread. + *

+ * Query execution wiring (QUERY_REQUEST encoding, RESULT_BATCH decoding, column-batch + * handler dispatch) is added in subsequent commits; this skeleton exists so the + * WebSocket upgrade to /read/v1 can be exercised end-to-end against the server. + */ +public class QwpQueryClient implements QuietCloseable { + + /** + * Default endpoint path for QWP egress on the QuestDB HTTP server. + */ + public static final String DEFAULT_ENDPOINT_PATH = "/read/v1"; + + /** + * Default QWP protocol version requested by this client. + */ + public static final int QWP_MAX_VERSION = 1; + + private final QwpResultBatchDecoder decoder = new QwpResultBatchDecoder(); + private final QwpFrameRouter frameRouter = new QwpFrameRouter(); + private final CharSequence host; + private final int port; + private final NativeBufferWriter sendScratch = new NativeBufferWriter(); + private String authorizationHeader; + private boolean connected; + private int defaultTimeoutMillis = 30_000; + private String endpointPath = DEFAULT_ENDPOINT_PATH; + private int negotiatedQwpVersion; + private long nextRequestId = 1; + private WebSocketClient webSocketClient; + + private QwpQueryClient(CharSequence host, int port) { + this.host = host; + this.port = port; + } + + /** + * Creates a plain-text (non-TLS) QWP query client. + */ + public static QwpQueryClient newPlainText(CharSequence host, int port) { + return new QwpQueryClient(host, port); + } + + @Override + public void close() { + connected = false; + Misc.free(sendScratch); + if (webSocketClient != null) { + webSocketClient.close(); + webSocketClient = null; + } + } + + /** + * Executes {@code sql} against the server and delivers result batches to the handler. + *

+ * Blocks until the server sends either {@code RESULT_END} or {@code QUERY_ERROR}. + * The handler's {@code onBatch}, {@code onEnd}, {@code onError} callbacks run on + * the calling thread during {@code receiveFrame} processing. + *

+ * Phase 1: no bind parameters, no CREDIT (server streams unbounded). + */ + public void execute(String sql, QwpColumnBatchHandler handler) { + if (!connected) { + throw new IllegalStateException("QwpQueryClient not connected; call connect() first"); + } + long requestId = nextRequestId++; + writeQueryRequest(sql, requestId); + webSocketClient.sendBinary(sendScratch.getBufferPtr(), sendScratch.getPosition()); + sendScratch.reset(); + + frameRouter.of(handler, decoder, requestId); + while (!frameRouter.isDone()) { + boolean got = webSocketClient.receiveFrame(frameRouter, defaultTimeoutMillis); + if (!got) { + handler.onError((byte) 0, "timeout waiting for server response"); + break; + } + } + } + + /** + * Opens the TCP connection and performs the WebSocket upgrade handshake. + * Must be called before any query is submitted. + */ + public void connect() { + if (connected) { + return; + } + webSocketClient = WebSocketClientFactory.newPlainTextInstance(); + webSocketClient.setQwpMaxVersion(QWP_MAX_VERSION); + webSocketClient.setQwpClientId(defaultClientId()); + webSocketClient.connect(host, port); + webSocketClient.upgrade(endpointPath, authorizationHeader); + negotiatedQwpVersion = webSocketClient.getServerQwpVersion(); + connected = true; + } + + public int getNegotiatedQwpVersion() { + return negotiatedQwpVersion; + } + + public boolean isConnected() { + return connected; + } + + /** + * Sets the HTTP Authorization header used during the upgrade handshake. + * Must be called before {@link #connect()}. + */ + public QwpQueryClient withAuthorization(String authorizationHeader) { + this.authorizationHeader = authorizationHeader; + return this; + } + + /** + * Overrides the default egress endpoint path ({@value #DEFAULT_ENDPOINT_PATH}). + * Must be called before {@link #connect()}. + */ + public QwpQueryClient withEndpointPath(String endpointPath) { + this.endpointPath = endpointPath; + return this; + } + + private static String defaultClientId() { + return "questdb-java-egress/1.0.0"; + } + + /** + * Encodes a {@code QUERY_REQUEST} into {@link #sendScratch} at position 0. + * Layout: msg_kind + request_id + sql_len (varint) + sql (UTF-8) + + * initial_credit (varint) + bind_count (varint). + */ + private void writeQueryRequest(String sql, long requestId) { + byte[] sqlBytes = sql.getBytes(StandardCharsets.UTF_8); + sendScratch.reset(); + sendScratch.putByte(QwpEgressMsgKind.QUERY_REQUEST); + sendScratch.putLong(requestId); + sendScratch.putVarint(sqlBytes.length); + for (byte b : sqlBytes) { + sendScratch.putByte(b); + } + sendScratch.putVarint(0); // initial_credit = 0 (unbounded) + sendScratch.putVarint(0); // bind_count = 0 + } + + /** + * WebSocket frame handler that decodes QWP egress responses and dispatches to the + * user-supplied {@link QwpColumnBatchHandler}. Reused across {@code execute()} calls. + */ + private static final class QwpFrameRouter implements WebSocketFrameHandler { + private QwpResultBatchDecoder decoder; + private boolean done; + private QwpColumnBatchHandler handler; + private long requestId; + + public boolean isDone() { + return done; + } + + @Override + public void onBinaryMessage(long payloadPtr, int payloadLen) { + if (payloadLen < QwpConstants.HEADER_SIZE + 1) { + handler.onError((byte) 0, "server sent short frame (" + payloadLen + " bytes)"); + done = true; + return; + } + byte msgKind = Unsafe.getUnsafe().getByte(payloadPtr + QwpConstants.HEADER_SIZE); + if (msgKind == QwpEgressMsgKind.RESULT_BATCH) { + try { + decoder.decode(payloadPtr, payloadLen); + handler.onBatch(decoder.getBatch()); + } catch (QwpDecodeException e) { + handler.onError((byte) 0, e.getMessage()); + done = true; + } + } else if (msgKind == QwpEgressMsgKind.RESULT_END) { + long totalRows = decodeResultEnd(payloadPtr, payloadLen); + handler.onEnd(totalRows); + done = true; + } else if (msgKind == QwpEgressMsgKind.QUERY_ERROR) { + decodeQueryError(payloadPtr, payloadLen); + done = true; + } else { + handler.onError((byte) 0, "unknown msg_kind 0x" + Integer.toHexString(msgKind & 0xFF)); + done = true; + } + } + + @Override + public void onClose(int code, String reason) { + if (!done) { + handler.onError((byte) 0, "server closed connection: code=" + code + " reason=" + reason); + done = true; + } + } + + public void of(QwpColumnBatchHandler handler, QwpResultBatchDecoder decoder, long requestId) { + this.handler = handler; + this.decoder = decoder; + this.requestId = requestId; + this.done = false; + } + + private void decodeQueryError(long payload, int payloadLen) { + // Body: msg_kind(1) + requestId(8) + status(1) + msgLen(u16) + msgBytes + long p = payload + QwpConstants.HEADER_SIZE + 1 /* kind */ + 8 /* reqId */; + byte status = Unsafe.getUnsafe().getByte(p); + p += 1; + int msgLen = Unsafe.getUnsafe().getShort(p) & 0xFFFF; + p += 2; + byte[] bytes = new byte[msgLen]; + for (int i = 0; i < msgLen; i++) { + bytes[i] = Unsafe.getUnsafe().getByte(p + i); + } + handler.onError(status, new String(bytes, StandardCharsets.UTF_8)); + } + + /** + * RESULT_END body: msg_kind(1) + requestId(8) + final_seq(varint) + total_rows(varint). + * We only need total_rows, so walk past the first two varints. + */ + private long decodeResultEnd(long payload, int payloadLen) { + long p = payload + QwpConstants.HEADER_SIZE + 1 /* kind */ + 8 /* reqId */; + long limit = payload + payloadLen; + // Skip final_seq varint. + while (p < limit && (Unsafe.getUnsafe().getByte(p++) & 0x80) != 0) { + // continuation + } + // Decode total_rows varint. + long total = 0; + int shift = 0; + while (p < limit) { + byte b = Unsafe.getUnsafe().getByte(p++); + total |= (long) (b & 0x7F) << shift; + if ((b & 0x80) == 0) break; + shift += 7; + } + return total; + } + } +} diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpResultBatchDecoder.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpResultBatchDecoder.java new file mode 100644 index 00000000..87e4d7e1 --- /dev/null +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpResultBatchDecoder.java @@ -0,0 +1,493 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +package io.questdb.client.cutlass.qwp.client; + +import io.questdb.client.cutlass.qwp.protocol.QwpConstants; +import io.questdb.client.std.ObjList; +import io.questdb.client.std.Unsafe; + +import java.nio.charset.StandardCharsets; + +/** + * Stateful decoder for inbound QWP egress frames (server → client). + *

+ * Reusable across batches on a single connection. Holds the connection-scoped + * schema registry so schema-reference batches (mode 0x01) can be resolved back + * to the full column list. Decoded values are materialised onto the heap for + * Phase-1 simplicity; see the public getters on {@link QwpColumnBatch}. + */ +public class QwpResultBatchDecoder { + + /** + * Sentinel used as the per-row "NULL" marker in the decoded value arrays. + */ + public static final Object NULL = new Object(); + + private final QwpColumnBatch batch = new QwpColumnBatch(); + // Registry indexed by schemaId. null = not registered. Non-null ObjList is the column list + // for that schema. Schema ids are server-assigned and small (monotonic from 0). + private final ObjList> schemaRegistry = new ObjList<>(); + + /** + * Clears the per-connection schema registry. Call when reconnecting. + */ + public void clearRegistry() { + schemaRegistry.clear(); + } + + /** + * Decodes a RESULT_BATCH frame payload (starting with msg_kind=0x11 at {@code payload}). + * After success, {@link #getBatch()} returns a view over the decoded data valid until + * the next {@code decode()} call. + */ + public void decode(long payload, int payloadLen) throws QwpDecodeException { + if (payloadLen < QwpConstants.HEADER_SIZE + 10 /* msg_kind + reqId + min varint */) { + throw new QwpDecodeException("RESULT_BATCH payload too short: " + payloadLen); + } + // Message header + int magic = Unsafe.getUnsafe().getInt(payload); + if (magic != QwpConstants.MAGIC_MESSAGE) { + throw new QwpDecodeException("bad magic 0x" + Integer.toHexString(magic)); + } + byte version = Unsafe.getUnsafe().getByte(payload + 4); + if (version != QwpConstants.VERSION_1) { + throw new QwpDecodeException("unsupported version " + (version & 0xFF)); + } + // flags and table_count informational for Phase 1 + long p = payload + QwpConstants.HEADER_SIZE; + long limit = payload + payloadLen; + + byte msgKind = Unsafe.getUnsafe().getByte(p++); + if (msgKind != (byte) 0x11) { + throw new QwpDecodeException("expected RESULT_BATCH (0x11), got 0x" + Integer.toHexString(msgKind & 0xFF)); + } + if (p + 8 > limit) throw new QwpDecodeException("truncated request_id"); + long requestId = Unsafe.getUnsafe().getLong(p); + p += 8; + long[] varint = new long[2]; // [value, nextPos] + decodeVarint(p, limit, varint); + long batchSeq = varint[0]; + p = varint[1]; + + // Table block: name_length, name, row_count, column_count, schema, columns + decodeVarint(p, limit, varint); + long nameLen = varint[0]; + p = varint[1]; + if (p + nameLen > limit) throw new QwpDecodeException("truncated table name"); + // Skip name — result sets carry empty names. + p += nameLen; + + decodeVarint(p, limit, varint); + int rowCount = (int) varint[0]; + p = varint[1]; + decodeVarint(p, limit, varint); + int columnCount = (int) varint[0]; + p = varint[1]; + + // Schema section + if (p >= limit) throw new QwpDecodeException("truncated schema mode"); + byte schemaMode = Unsafe.getUnsafe().getByte(p++); + decodeVarint(p, limit, varint); + int schemaId = (int) varint[0]; + p = varint[1]; + + ObjList columns; + if (schemaMode == QwpConstants.SCHEMA_MODE_FULL) { + columns = ensureSchemaSlot(schemaId, columnCount); + for (int i = 0; i < columnCount; i++) { + decodeVarint(p, limit, varint); + int colNameLen = (int) varint[0]; + p = varint[1]; + if (p + colNameLen + 1 > limit) throw new QwpDecodeException("truncated column def"); + String colName = readUtf8(p, colNameLen); + p += colNameLen; + byte wireType = Unsafe.getUnsafe().getByte(p++); + // Scale/precision are NOT in the schema — they're wire-level prefixes + // inside each column's data block. Placeholders here. + columns.getQuick(i).of(colName, wireType, 0, 0); + } + } else if (schemaMode == QwpConstants.SCHEMA_MODE_REFERENCE) { + if (schemaId >= schemaRegistry.size() || schemaRegistry.getQuick(schemaId) == null) { + throw new QwpDecodeException("schema id " + schemaId + " not registered on this connection"); + } + columns = schemaRegistry.getQuick(schemaId); + if (columns.size() != columnCount) { + throw new QwpDecodeException("schema id " + schemaId + " column count mismatch"); + } + } else { + throw new QwpDecodeException("unknown schema mode 0x" + Integer.toHexString(schemaMode & 0xFF)); + } + + // Column data + batch.reset(requestId, batchSeq, rowCount, columnCount, columns); + for (int ci = 0; ci < columnCount; ci++) { + QwpEgressColumnInfo info = columns.getQuick(ci); + p = decodeColumn(p, limit, info, rowCount, batch.columnValues(ci)); + } + } + + /** + * Returns the view over the most recently decoded batch. Valid until the next + * {@link #decode(long, int)} call. + */ + public QwpColumnBatch getBatch() { + return batch; + } + + // ----------------------------------------------------------------------------- + // Varint / bitmap helpers + // ----------------------------------------------------------------------------- + + private static void decodeVarint(long p, long limit, long[] out) throws QwpDecodeException { + long value = 0; + int shift = 0; + long cur = p; + while (true) { + if (cur >= limit) throw new QwpDecodeException("truncated varint"); + byte b = Unsafe.getUnsafe().getByte(cur++); + value |= (long) (b & 0x7F) << shift; + if ((b & 0x80) == 0) break; + shift += 7; + if (shift > 63) throw new QwpDecodeException("varint overflow"); + } + out[0] = value; + out[1] = cur; + } + + private static String readUtf8(long p, long len) { + byte[] bytes = new byte[(int) len]; + for (int i = 0; i < len; i++) { + bytes[i] = Unsafe.getUnsafe().getByte(p + i); + } + return new String(bytes, StandardCharsets.UTF_8); + } + + /** + * Reads the null flag byte and (if present) the bitmap. Populates the given boolean + * array (length = rowCount) with row-is-null flags. Returns the new position. + */ + private static long readNullBitmap(long p, long limit, int rowCount, boolean[] nullFlags) throws QwpDecodeException { + if (p >= limit) throw new QwpDecodeException("truncated null flag"); + byte flag = Unsafe.getUnsafe().getByte(p++); + java.util.Arrays.fill(nullFlags, 0, rowCount, false); + if (flag == 0) return p; + int bytes = (rowCount + 7) >>> 3; + if (p + bytes > limit) throw new QwpDecodeException("truncated null bitmap"); + for (int i = 0; i < rowCount; i++) { + int bi = i >>> 3; + int bit = i & 7; + byte bm = Unsafe.getUnsafe().getByte(p + bi); + if ((bm & (1 << bit)) != 0) { + nullFlags[i] = true; + } + } + return p + bytes; + } + + // ----------------------------------------------------------------------------- + // Per-column decoders + // ----------------------------------------------------------------------------- + + private long decodeColumn(long p, long limit, QwpEgressColumnInfo info, int rowCount, Object[] values) + throws QwpDecodeException { + boolean[] nullFlags = new boolean[rowCount]; + p = readNullBitmap(p, limit, rowCount, nullFlags); + byte wt = info.wireType; + if (wt == QwpConstants.TYPE_BOOLEAN) { + int nonNull = 0; + for (int i = 0; i < rowCount; i++) if (!nullFlags[i]) nonNull++; + int bytes = (nonNull + 7) >>> 3; + if (p + bytes > limit) throw new QwpDecodeException("truncated BOOLEAN"); + int bitIdx = 0; + for (int i = 0; i < rowCount; i++) { + if (nullFlags[i]) { + values[i] = null; + continue; + } + byte bm = Unsafe.getUnsafe().getByte(p + (bitIdx >>> 3)); + values[i] = ((bm & (1 << (bitIdx & 7))) != 0) ? Boolean.TRUE : Boolean.FALSE; + bitIdx++; + } + return p + bytes; + } + if (wt == QwpConstants.TYPE_BYTE) return decodeFixed(p, limit, rowCount, 1, nullFlags, values); + if (wt == QwpConstants.TYPE_SHORT || wt == QwpConstants.TYPE_CHAR) { + return decodeFixed(p, limit, rowCount, 2, nullFlags, values); + } + if (wt == QwpConstants.TYPE_INT) return decodeFixed(p, limit, rowCount, 4, nullFlags, values); + if (wt == QwpConstants.TYPE_FLOAT) return decodeFloat(p, limit, rowCount, nullFlags, values); + if (wt == QwpConstants.TYPE_LONG || wt == QwpConstants.TYPE_DATE + || wt == QwpConstants.TYPE_TIMESTAMP || wt == QwpConstants.TYPE_TIMESTAMP_NANOS) { + return decodeFixed(p, limit, rowCount, 8, nullFlags, values); + } + if (wt == QwpConstants.TYPE_DOUBLE) return decodeDouble(p, limit, rowCount, nullFlags, values); + if (wt == QwpConstants.TYPE_STRING || wt == QwpConstants.TYPE_VARCHAR) { + return decodeString(p, limit, rowCount, nullFlags, values, wt == QwpConstants.TYPE_STRING); + } + if (wt == QwpConstants.TYPE_SYMBOL) return decodeSymbol(p, limit, rowCount, nullFlags, values); + if (wt == QwpConstants.TYPE_UUID) return decodeFixedPair(p, limit, rowCount, nullFlags, values); + if (wt == QwpConstants.TYPE_LONG256) return decodeFixedQuad(p, limit, rowCount, nullFlags, values); + if (wt == QwpConstants.TYPE_GEOHASH) { + long[] varint = new long[2]; + decodeVarint(p, limit, varint); + info.precisionBits = (int) varint[0]; + p = varint[1]; + int bytesPerValue = (info.precisionBits + 7) >>> 3; + for (int i = 0; i < rowCount; i++) { + if (nullFlags[i]) { + values[i] = null; + continue; + } + if (p + bytesPerValue > limit) throw new QwpDecodeException("truncated GEOHASH"); + long bits = 0; + for (int b = 0; b < bytesPerValue; b++) { + bits |= ((long) (Unsafe.getUnsafe().getByte(p + b) & 0xFF)) << (b * 8); + } + values[i] = bits; + p += bytesPerValue; + } + return p; + } + if (wt == QwpConstants.TYPE_DECIMAL64) { + if (p >= limit) throw new QwpDecodeException("truncated DECIMAL64 scale"); + info.scale = Unsafe.getUnsafe().getByte(p++) & 0xFF; + return decodeFixed(p, limit, rowCount, 8, nullFlags, values); + } + if (wt == QwpConstants.TYPE_DECIMAL128) { + if (p >= limit) throw new QwpDecodeException("truncated DECIMAL128 scale"); + info.scale = Unsafe.getUnsafe().getByte(p++) & 0xFF; + return decodeFixedPair(p, limit, rowCount, nullFlags, values); + } + if (wt == QwpConstants.TYPE_DECIMAL256) { + if (p >= limit) throw new QwpDecodeException("truncated DECIMAL256 scale"); + info.scale = Unsafe.getUnsafe().getByte(p++) & 0xFF; + return decodeFixedQuad(p, limit, rowCount, nullFlags, values); + } + if (wt == QwpConstants.TYPE_DOUBLE_ARRAY || wt == QwpConstants.TYPE_LONG_ARRAY) { + return decodeArray(p, limit, rowCount, nullFlags, values); + } + throw new QwpDecodeException("unsupported wire type 0x" + Integer.toHexString(wt & 0xFF)); + } + + private long decodeFixed(long p, long limit, int rowCount, int sizeBytes, boolean[] nullFlags, Object[] values) + throws QwpDecodeException { + for (int i = 0; i < rowCount; i++) { + if (nullFlags[i]) { + values[i] = null; + continue; + } + if (p + sizeBytes > limit) throw new QwpDecodeException("truncated fixed column"); + long v; + switch (sizeBytes) { + case 1: v = Unsafe.getUnsafe().getByte(p); break; + case 2: v = Unsafe.getUnsafe().getShort(p); break; + case 4: v = Unsafe.getUnsafe().getInt(p); break; + case 8: v = Unsafe.getUnsafe().getLong(p); break; + default: throw new IllegalStateException(); + } + values[i] = v; + p += sizeBytes; + } + return p; + } + + private long decodeFloat(long p, long limit, int rowCount, boolean[] nullFlags, Object[] values) + throws QwpDecodeException { + for (int i = 0; i < rowCount; i++) { + if (nullFlags[i]) { + values[i] = null; + continue; + } + if (p + 4 > limit) throw new QwpDecodeException("truncated FLOAT"); + values[i] = Float.intBitsToFloat(Unsafe.getUnsafe().getInt(p)); + p += 4; + } + return p; + } + + private long decodeDouble(long p, long limit, int rowCount, boolean[] nullFlags, Object[] values) + throws QwpDecodeException { + for (int i = 0; i < rowCount; i++) { + if (nullFlags[i]) { + values[i] = null; + continue; + } + if (p + 8 > limit) throw new QwpDecodeException("truncated DOUBLE"); + values[i] = Double.longBitsToDouble(Unsafe.getUnsafe().getLong(p)); + p += 8; + } + return p; + } + + private long decodeFixedPair(long p, long limit, int rowCount, boolean[] nullFlags, Object[] values) + throws QwpDecodeException { + for (int i = 0; i < rowCount; i++) { + if (nullFlags[i]) { + values[i] = null; + continue; + } + if (p + 16 > limit) throw new QwpDecodeException("truncated 16-byte value"); + long lo = Unsafe.getUnsafe().getLong(p); + long hi = Unsafe.getUnsafe().getLong(p + 8); + values[i] = new long[]{lo, hi}; + p += 16; + } + return p; + } + + private long decodeFixedQuad(long p, long limit, int rowCount, boolean[] nullFlags, Object[] values) + throws QwpDecodeException { + for (int i = 0; i < rowCount; i++) { + if (nullFlags[i]) { + values[i] = null; + continue; + } + if (p + 32 > limit) throw new QwpDecodeException("truncated 32-byte value"); + values[i] = new long[]{ + Unsafe.getUnsafe().getLong(p), + Unsafe.getUnsafe().getLong(p + 8), + Unsafe.getUnsafe().getLong(p + 16), + Unsafe.getUnsafe().getLong(p + 24) + }; + p += 32; + } + return p; + } + + private long decodeString(long p, long limit, int rowCount, boolean[] nullFlags, Object[] values, boolean utf16) + throws QwpDecodeException { + int nonNull = 0; + for (int i = 0; i < rowCount; i++) if (!nullFlags[i]) nonNull++; + int offsetBytes = 4 * (nonNull + 1); + if (p + offsetBytes > limit) throw new QwpDecodeException("truncated string offsets"); + long offsetsAddr = p; + long bytesStart = p + offsetBytes; + + int nonNullIdx = 0; + for (int i = 0; i < rowCount; i++) { + if (nullFlags[i]) { + values[i] = null; + continue; + } + int startOff = Unsafe.getUnsafe().getInt(offsetsAddr + 4L * nonNullIdx); + int endOff = Unsafe.getUnsafe().getInt(offsetsAddr + 4L * (nonNullIdx + 1)); + int len = endOff - startOff; + if (bytesStart + endOff > limit || len < 0) throw new QwpDecodeException("truncated string bytes"); + if (utf16) { + values[i] = readUtf8(bytesStart + startOff, len); + } else { + byte[] raw = new byte[len]; + for (int b = 0; b < len; b++) { + raw[b] = Unsafe.getUnsafe().getByte(bytesStart + startOff + b); + } + values[i] = raw; + } + nonNullIdx++; + } + int totalStringBytes = nonNull == 0 ? 0 : Unsafe.getUnsafe().getInt(offsetsAddr + 4L * nonNull); + return bytesStart + totalStringBytes; + } + + private long decodeSymbol(long p, long limit, int rowCount, boolean[] nullFlags, Object[] values) + throws QwpDecodeException { + long[] varint = new long[2]; + decodeVarint(p, limit, varint); + int dictSize = (int) varint[0]; + p = varint[1]; + String[] dict = new String[dictSize]; + for (int e = 0; e < dictSize; e++) { + decodeVarint(p, limit, varint); + int entryLen = (int) varint[0]; + p = varint[1]; + if (p + entryLen > limit) throw new QwpDecodeException("truncated symbol entry"); + dict[e] = readUtf8(p, entryLen); + p += entryLen; + } + for (int i = 0; i < rowCount; i++) { + if (nullFlags[i]) { + values[i] = null; + continue; + } + decodeVarint(p, limit, varint); + int idx = (int) varint[0]; + p = varint[1]; + if (idx < 0 || idx >= dictSize) throw new QwpDecodeException("symbol index out of range: " + idx); + values[i] = dict[idx]; + } + return p; + } + + private long decodeArray(long p, long limit, int rowCount, boolean[] nullFlags, Object[] values) + throws QwpDecodeException { + for (int i = 0; i < rowCount; i++) { + if (nullFlags[i]) { + values[i] = null; + continue; + } + if (p + 1 > limit) throw new QwpDecodeException("truncated ARRAY"); + int nDims = Unsafe.getUnsafe().getByte(p) & 0xFF; + long headerEnd = p + 1 + 4L * nDims; + if (headerEnd > limit) throw new QwpDecodeException("truncated ARRAY dims"); + int elements = 1; + for (int d = 0; d < nDims; d++) { + int dl = Unsafe.getUnsafe().getInt(p + 1 + 4L * d); + elements *= dl; + } + long payloadEnd = headerEnd + 8L * elements; + if (payloadEnd > limit) throw new QwpDecodeException("truncated ARRAY payload"); + int totalLen = (int) (payloadEnd - p); + byte[] raw = new byte[totalLen]; + for (int b = 0; b < totalLen; b++) { + raw[b] = Unsafe.getUnsafe().getByte(p + b); + } + values[i] = raw; + p = payloadEnd; + } + return p; + } + + private ObjList ensureSchemaSlot(int schemaId, int columnCount) { + while (schemaRegistry.size() <= schemaId) { + schemaRegistry.add(null); + } + ObjList slot = schemaRegistry.getQuick(schemaId); + if (slot == null) { + slot = new ObjList<>(); + schemaRegistry.setQuick(schemaId, slot); + } + int currentPos = slot.size(); + if (columnCount > currentPos) { + slot.setPos(columnCount); + for (int i = currentPos; i < columnCount; i++) { + if (slot.getQuick(i) == null) { + slot.setQuick(i, new QwpEgressColumnInfo()); + } + } + } else { + slot.setPos(columnCount); + } + return slot; + } +} diff --git a/examples/src/main/java/com/example/query/BasicQueryExample.java b/examples/src/main/java/com/example/query/BasicQueryExample.java new file mode 100644 index 00000000..4ef17ac4 --- /dev/null +++ b/examples/src/main/java/com/example/query/BasicQueryExample.java @@ -0,0 +1,59 @@ +package com.example.query; + +import io.questdb.client.cutlass.qwp.client.QwpColumnBatch; +import io.questdb.client.cutlass.qwp.client.QwpColumnBatchHandler; +import io.questdb.client.cutlass.qwp.client.QwpQueryClient; + +/** + * Minimal QWP egress query example. + *

+ * Connects to a QuestDB server over the /read/v1 WebSocket endpoint, + * runs a SELECT query, and prints each row as the batches arrive. + *

+ * Assumes a table exists: + *

+ *   CREATE TABLE trades (ts TIMESTAMP, sym SYMBOL, price DOUBLE, qty LONG)
+ *       TIMESTAMP(ts) PARTITION BY DAY WAL;
+ * 
+ */ +public class BasicQueryExample { + + public static void main(String[] args) { + try (QwpQueryClient client = QwpQueryClient.newPlainText("localhost", 9000)) { + client.connect(); + + client.execute( + "SELECT ts, sym, price, qty FROM trades WHERE sym = 'AAPL' LIMIT 1000", + new QwpColumnBatchHandler() { + @Override + public void onBatch(QwpColumnBatch batch) { + // The batch is a column-major view of one RESULT_BATCH frame. + // It is valid only for the duration of this callback — copy out + // anything you need to retain after the method returns. + for (int row = 0; row < batch.getRowCount(); row++) { + long timestamp = batch.getLong(0, row); // TIMESTAMP → microseconds since epoch + String symbol = batch.getString(1, row); // SYMBOL → String + double price = batch.getDouble(2, row); // DOUBLE + long qty = batch.getLong(3, row); // LONG + + System.out.printf( + "ts=%d sym=%s price=%.4f qty=%d%n", + timestamp, symbol, price, qty + ); + } + } + + @Override + public void onEnd(long totalRows) { + System.out.println("query finished"); + } + + @Override + public void onError(byte status, String message) { + System.err.println("query failed: status=" + status + " msg=" + message); + } + } + ); + } + } +} From 0ccbae1e031039fb0c0f2de19a54f98202cd6854 Mon Sep 17 00:00:00 2001 From: bluestreak Date: Sat, 18 Apr 2026 21:14:05 +0100 Subject: [PATCH 03/44] wip 2 --- .../client/cutlass/qwp/client/QueryEvent.java | 65 +++ .../cutlass/qwp/client/QwpBatchBuffer.java | 97 ++++ .../cutlass/qwp/client/QwpColumnBatch.java | 427 ++++++++++++-- .../cutlass/qwp/client/QwpColumnLayout.java | 113 ++++ .../cutlass/qwp/client/QwpEgressIoThread.java | 261 +++++++++ .../cutlass/qwp/client/QwpQueryClient.java | 255 +++----- .../qwp/client/QwpResultBatchDecoder.java | 548 ++++++++---------- 7 files changed, 1252 insertions(+), 514 deletions(-) create mode 100644 core/src/main/java/io/questdb/client/cutlass/qwp/client/QueryEvent.java create mode 100644 core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpBatchBuffer.java create mode 100644 core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpColumnLayout.java create mode 100644 core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpEgressIoThread.java diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QueryEvent.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QueryEvent.java new file mode 100644 index 00000000..46fcd63f --- /dev/null +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QueryEvent.java @@ -0,0 +1,65 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +package io.questdb.client.cutlass.qwp.client; + +/** + * Tagged event pushed from the I/O thread onto the user-facing event queue. + * One event per {@code RESULT_BATCH} / {@code RESULT_END} / {@code QUERY_ERROR} + * received from the server, plus a synthetic error event if the connection drops + * mid-query. + */ +public class QueryEvent { + + public static final int KIND_BATCH = 0; + public static final int KIND_END = 1; + public static final int KIND_ERROR = 2; + + public QwpBatchBuffer buffer; // valid for KIND_BATCH (must be released to pool by consumer) + public byte errorStatus; // valid for KIND_ERROR + public String errorMessage; // valid for KIND_ERROR + public int kind; + public long totalRows; // valid for KIND_END + + public QueryEvent asBatch(QwpBatchBuffer buffer) { + this.kind = KIND_BATCH; + this.buffer = buffer; + return this; + } + + public QueryEvent asEnd(long totalRows) { + this.kind = KIND_END; + this.buffer = null; + this.totalRows = totalRows; + return this; + } + + public QueryEvent asError(byte status, String message) { + this.kind = KIND_ERROR; + this.buffer = null; + this.errorStatus = status; + this.errorMessage = message; + return this; + } +} diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpBatchBuffer.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpBatchBuffer.java new file mode 100644 index 00000000..55aebc8e --- /dev/null +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpBatchBuffer.java @@ -0,0 +1,97 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +package io.questdb.client.cutlass.qwp.client; + +import io.questdb.client.std.MemoryTag; +import io.questdb.client.std.ObjList; +import io.questdb.client.std.QuietCloseable; +import io.questdb.client.std.Unsafe; + +/** + * Pooled per-batch container owned by the client's I/O thread. A buffer holds a + * native scratch region that a received {@code RESULT_BATCH} payload is memcpy'd + * into, plus the per-column {@link QwpColumnLayout} pool used while decoding and + * the {@link QwpColumnBatch} view that the user's handler sees. + *

+ * Lifecycle: I/O thread takes a buffer from the free pool → copies the frame + * payload in → hands the decoder the buffer → pushes the resulting batch onto + * the event queue. User thread pops, invokes the handler, releases the buffer + * back to the pool. While the user thread owns the buffer the I/O thread is + * free to take a different buffer and decode the next frame. + */ +public class QwpBatchBuffer implements QuietCloseable { + + /** + * Per-column layout pool scoped to this buffer. Sized to the max column + * count observed on this buffer across batches; layouts are reused. + */ + final ObjList layoutPool = new ObjList<>(); + final QwpColumnBatch batch = new QwpColumnBatch(); + private int payloadLen; + private long scratchAddr; + private int scratchCapacity; + + public QwpBatchBuffer(int initialCapacity) { + this.scratchCapacity = initialCapacity; + this.scratchAddr = Unsafe.malloc(initialCapacity, MemoryTag.NATIVE_DEFAULT); + } + + @Override + public void close() { + if (scratchAddr != 0) { + Unsafe.free(scratchAddr, scratchCapacity, MemoryTag.NATIVE_DEFAULT); + scratchAddr = 0; + scratchCapacity = 0; + } + } + + /** + * Copies {@code len} bytes starting at {@code srcAddr} into this buffer's + * native scratch, growing if needed. Call once per incoming frame before + * handing the buffer to the decoder. + */ + public void copyFromPayload(long srcAddr, int len) { + ensureCapacity(len); + Unsafe.getUnsafe().copyMemory(srcAddr, scratchAddr, len); + payloadLen = len; + } + + public int getPayloadLen() { + return payloadLen; + } + + public long getScratchAddr() { + return scratchAddr; + } + + private void ensureCapacity(int required) { + if (required <= scratchCapacity) return; + int newCap = scratchCapacity; + while (newCap < required) newCap *= 2; + long newAddr = Unsafe.realloc(scratchAddr, scratchCapacity, newCap, MemoryTag.NATIVE_DEFAULT); + scratchAddr = newAddr; + scratchCapacity = newCap; + } +} diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpColumnBatch.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpColumnBatch.java index a3cac4dc..4fa79f1f 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpColumnBatch.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpColumnBatch.java @@ -24,29 +24,77 @@ package io.questdb.client.cutlass.qwp.client; +import io.questdb.client.cutlass.qwp.protocol.QwpConstants; import io.questdb.client.std.ObjList; +import io.questdb.client.std.Unsafe; +import io.questdb.client.std.str.DirectUtf8Sequence; +import io.questdb.client.std.str.DirectUtf8String; + +import java.nio.charset.StandardCharsets; /** - * Column-major view over one decoded {@code RESULT_BATCH}. The view becomes - * stale after the next {@code decode()} call on the owning - * {@link QwpResultBatchDecoder}. + * Column-major view over one decoded {@code RESULT_BATCH}. Valid only during the + * {@link QwpColumnBatchHandler#onBatch} callback; becomes stale once control returns + * to the decoder. + *

+ * Accessors are designed for zero-allocation on the hot path: fixed-width values + * are read straight from the native WebSocket payload buffer, and string/varchar + * access returns a reusable {@link DirectUtf8Sequence} view ({@link #getStrA} / + * {@link #getStrB}) that re-points at the underlying bytes with each call. *

- * Values are returned boxed for Phase-1 simplicity. Primitive accessors - * ({@link #getLong}, {@link #getDouble}, etc.) unbox with {@link #isNull} - * returning {@code true} for NULL rows (primitives return 0/NaN in that case). + * Convenience accessors that materialise heap objects ({@link #getString}, + * {@link #getVarchar}, {@link #getLongArray}, {@link #getValue}) are provided for + * ergonomics but allocate; use the native-view accessors on the hot path. */ public class QwpColumnBatch { - ObjList columns; - /** - * columnValues[columnIndex][rowIndex] = boxed value (null for NULL rows). - * Visibility: package-private so {@link QwpResultBatchDecoder} populates directly. - */ - final ObjList columnValues = new ObjList<>(); + final ObjList columnLayouts = new ObjList<>(); long batchSeq; int columnCount; + ObjList columns; + long payloadAddr; + long payloadLimit; long requestId; int rowCount; + // Reusable views for zero-alloc UTF-8 access. strA and strB are dual views + // (same pattern as QuestDB Record.getStrA/getStrB) so callers can compare + // two cells without one overwriting the other. + private final DirectUtf8String strA = new DirectUtf8String(); + private final DirectUtf8String strB = new DirectUtf8String(); + private final DirectUtf8String varcharA = new DirectUtf8String(); + private final DirectUtf8String varcharB = new DirectUtf8String(); + + public long batchSeq() { + return batchSeq; + } + + public boolean getBool(int col, int row) { + QwpColumnLayout l = columnLayouts.getQuick(col); + if (isLayoutNull(l, row)) return false; + int denseIdx = l.nonNullIdx[row]; + // Bit-packed: 8 values per byte, LSB-first + byte b = Unsafe.getUnsafe().getByte(l.valuesAddr + (denseIdx >>> 3)); + return (b & (1 << (denseIdx & 7))) != 0; + } + + /** + * Returns a single BYTE value without the type-dispatch branch in {@link #getLong}. + * The caller must know the column is BYTE. + */ + public byte getByteValue(int col, int row) { + QwpColumnLayout l = columnLayouts.getQuick(col); + if (isLayoutNull(l, row)) return 0; + return Unsafe.getUnsafe().getByte(l.valuesAddr + l.nonNullIdx[row]); + } + + /** + * Returns a single CHAR value. Caller must know the column is CHAR. + */ + public char getCharValue(int col, int row) { + QwpColumnLayout l = columnLayouts.getQuick(col); + if (isLayoutNull(l, row)) return 0; + return (char) Unsafe.getUnsafe().getShort(l.valuesAddr + 2L * l.nonNullIdx[row]); + } public int getColumnCount() { return columnCount; @@ -60,79 +108,362 @@ public byte getColumnWireType(int col) { return columns.getQuick(col).wireType; } + public int getDecimalScale(int col) { + return columns.getQuick(col).scale; + } + + /** + * Returns the high 64 bits of a DECIMAL128 value. Combine with {@link #getDecimal128Low}. + */ + public long getDecimal128High(int col, int row) { + QwpColumnLayout l = columnLayouts.getQuick(col); + if (isLayoutNull(l, row)) return 0L; + return Unsafe.getUnsafe().getLong(l.valuesAddr + 16L * l.nonNullIdx[row] + 8L); + } + + /** + * Returns the low 64 bits of a DECIMAL128 value. + */ + public long getDecimal128Low(int col, int row) { + QwpColumnLayout l = columnLayouts.getQuick(col); + if (isLayoutNull(l, row)) return 0L; + return Unsafe.getUnsafe().getLong(l.valuesAddr + 16L * l.nonNullIdx[row]); + } + public double getDouble(int col, int row) { - Object v = columnValues.getQuick(col)[row]; - return v == null ? Double.NaN : (Double) v; + QwpColumnLayout l = columnLayouts.getQuick(col); + if (isLayoutNull(l, row)) return Double.NaN; + return Unsafe.getUnsafe().getDouble(l.valuesAddr + 8L * l.nonNullIdx[row]); } public float getFloat(int col, int row) { - Object v = columnValues.getQuick(col)[row]; - return v == null ? Float.NaN : (Float) v; + QwpColumnLayout l = columnLayouts.getQuick(col); + if (isLayoutNull(l, row)) return Float.NaN; + return Unsafe.getUnsafe().getFloat(l.valuesAddr + 4L * l.nonNullIdx[row]); + } + + /** + * Returns a single INT value without type dispatch. Caller must know the + * column is INT or IPv4. Returns 0 for NULL rows. + */ + public int getIntValue(int col, int row) { + QwpColumnLayout l = columnLayouts.getQuick(col); + if (isLayoutNull(l, row)) return 0; + return Unsafe.getUnsafe().getInt(l.valuesAddr + 4L * l.nonNullIdx[row]); } public int getGeohashPrecisionBits(int col) { return columns.getQuick(col).precisionBits; } + /** + * Returns a LONG / INT / SHORT / BYTE / CHAR / TIMESTAMP / DATE / DECIMAL64 / GEOHASH value, + * dispatching by the column's wire type. Convenience for schema-agnostic code. + * Hot loops should call the type-specific accessors ({@link #getLongValue}, + * {@link #getIntValue}, {@link #getShortValue}, {@link #getByteValue}, {@link #getCharValue}) + * to skip the branch chain. + * Returns 0 for NULL rows; use {@link #isNull} first when that matters. + */ public long getLong(int col, int row) { - Object v = columnValues.getQuick(col)[row]; - return v == null ? 0L : (Long) v; + QwpColumnLayout l = columnLayouts.getQuick(col); + if (isLayoutNull(l, row)) return 0L; + byte wt = l.info.wireType; + int denseIdx = l.nonNullIdx[row]; + if (wt == QwpConstants.TYPE_LONG || wt == QwpConstants.TYPE_DATE + || wt == QwpConstants.TYPE_TIMESTAMP || wt == QwpConstants.TYPE_TIMESTAMP_NANOS + || wt == QwpConstants.TYPE_DECIMAL64) { + return Unsafe.getUnsafe().getLong(l.valuesAddr + 8L * denseIdx); + } + if (wt == QwpConstants.TYPE_INT) { + return Unsafe.getUnsafe().getInt(l.valuesAddr + 4L * denseIdx); + } + if (wt == QwpConstants.TYPE_SHORT || wt == QwpConstants.TYPE_CHAR) { + return Unsafe.getUnsafe().getShort(l.valuesAddr + 2L * denseIdx); + } + if (wt == QwpConstants.TYPE_BYTE) { + return Unsafe.getUnsafe().getByte(l.valuesAddr + denseIdx); + } + if (wt == QwpConstants.TYPE_GEOHASH) { + int precBits = l.info.precisionBits; + int bytesPerValue = (precBits + 7) >>> 3; + long p = l.valuesAddr + (long) bytesPerValue * denseIdx; + long bits = 0; + for (int b = 0; b < bytesPerValue; b++) { + bits |= ((long) (Unsafe.getUnsafe().getByte(p + b) & 0xFF)) << (b * 8); + } + return bits; + } + throw new IllegalStateException("getLong() not applicable for wire type 0x" + + Integer.toHexString(wt & 0xFF)); + } + + /** + * Returns an 8-byte LONG / TIMESTAMP / DATE / DECIMAL64 value without type dispatch. + * Caller must know the column is a LONG-family type. Returns 0 for NULL rows. + */ + public long getLongValue(int col, int row) { + QwpColumnLayout l = columnLayouts.getQuick(col); + if (isLayoutNull(l, row)) return 0L; + return Unsafe.getUnsafe().getLong(l.valuesAddr + 8L * l.nonNullIdx[row]); } + /** + * Returns a SHORT value without type dispatch. Caller must know the column is SHORT. + */ + public short getShortValue(int col, int row) { + QwpColumnLayout l = columnLayouts.getQuick(col); + if (isLayoutNull(l, row)) return 0; + return Unsafe.getUnsafe().getShort(l.valuesAddr + 2L * l.nonNullIdx[row]); + } + + /** + * Convenience: returns a length-{@code N} long array with the components of a UUID, + * LONG256, DECIMAL128, or DECIMAL256 value. Allocates — avoid on the hot path; + * use {@link #getUuidLo}/{@link #getUuidHi} or {@link #getLong256Word} instead. + */ public long[] getLongArray(int col, int row) { - return (long[]) columnValues.getQuick(col)[row]; + QwpColumnLayout l = columnLayouts.getQuick(col); + if (isLayoutNull(l, row)) return null; + byte wt = l.info.wireType; + int denseIdx = l.nonNullIdx[row]; + if (wt == QwpConstants.TYPE_UUID || wt == QwpConstants.TYPE_DECIMAL128) { + long base = l.valuesAddr + 16L * denseIdx; + return new long[]{Unsafe.getUnsafe().getLong(base), Unsafe.getUnsafe().getLong(base + 8)}; + } + if (wt == QwpConstants.TYPE_LONG256 || wt == QwpConstants.TYPE_DECIMAL256) { + long base = l.valuesAddr + 32L * denseIdx; + return new long[]{ + Unsafe.getUnsafe().getLong(base), + Unsafe.getUnsafe().getLong(base + 8), + Unsafe.getUnsafe().getLong(base + 16), + Unsafe.getUnsafe().getLong(base + 24) + }; + } + throw new IllegalStateException("getLongArray() not applicable for wire type 0x" + + Integer.toHexString(wt & 0xFF)); + } + + /** + * Returns one of the four 64-bit words of a LONG256 or DECIMAL256 value. + * {@code wordIndex} 0 is least significant, 3 is most significant. + */ + public long getLong256Word(int col, int row, int wordIndex) { + QwpColumnLayout l = columnLayouts.getQuick(col); + if (isLayoutNull(l, row)) return 0L; + return Unsafe.getUnsafe().getLong(l.valuesAddr + 32L * l.nonNullIdx[row] + 8L * wordIndex); + } + + // ============================================================================= + // Raw column-address API — for zero-branch hot inner loops. + // + // Typical usage: + // long base = batch.valuesAddr(col); + // int[] idx = batch.nonNullIndex(col); + // for (int r = 0; r < rowCount; r++) { + // if (idx[r] < 0) continue; // NULL + // long v = Unsafe.getLong(base + 8L * idx[r]); + // ... + // } + // + // All four accessors return constant-time views; no allocation. + // ============================================================================= + + /** + * Number of non-null rows in this column, i.e. the count of entries in the + * dense values array pointed to by {@link #valuesAddr(int)}. + */ + public int nonNullCount(int col) { + return columnLayouts.getQuick(col).nonNullCount; + } + + /** + * Per-row lookup table. {@code result[row]} is the dense index within the + * column's non-null values, or -1 if the row is NULL. Array length equals + * {@link #getRowCount()}. Valid only during the current {@code onBatch} + * callback; do not retain. + */ + public int[] nonNullIndex(int col) { + return columnLayouts.getQuick(col).nonNullIdx; + } + + /** + * Address of the column's null bitmap, or {@code 0} if the column has no NULL rows. + * Bitmap is {@code ceil(rowCount / 8)} bytes, LSB-first; bit = 1 means NULL. + */ + public long nullBitmapAddr(int col) { + return columnLayouts.getQuick(col).nullBitmapAddr; + } + + /** + * Address of the column's packed non-null values in the payload buffer. + * Layout depends on the wire type: + *

    + *
  • Fixed-width (LONG, INT, DOUBLE, UUID, LONG256, etc.): contiguous values, index by {@code nonNullIndex(col)[row] * sizeBytes}.
  • + *
  • BOOLEAN: bit-packed, 8 values per byte, LSB-first; index by {@code nonNullIndex(col)[row]}.
  • + *
  • STRING / VARCHAR: points to the (N+1) × uint32 offsets array; use {@link #stringBytesAddr(int)} for the bytes region.
  • + *
  • GEOHASH: {@code ceil(precisionBits / 8)} bytes per value; index by {@code nonNullIndex(col)[row] * bytesPerValue}.
  • + *
  • DECIMAL64/128/256: the scale byte has already been consumed; this is the first unscaled value.
  • + *
  • SYMBOL: not meaningful — use {@link #getStrA} / {@link #getStrB} instead.
  • + *
  • ARRAY: not meaningful — use the per-row {@code arrayRowAddr} accessors (forthcoming).
  • + *
+ */ + public long valuesAddr(int col) { + return columnLayouts.getQuick(col).valuesAddr; + } + + /** + * For STRING / VARCHAR columns, the address of the concatenated UTF-8 bytes + * (immediately after the offsets array pointed to by {@link #valuesAddr}). + * Combined with the offsets array, lets you read every string without + * going through {@link #getStrA}. + */ + public long stringBytesAddr(int col) { + return columnLayouts.getQuick(col).stringBytesAddr; } public int getRowCount() { return rowCount; } + /** + * Zero-allocation UTF-8 view over the STRING / VARCHAR / SYMBOL value at + * {@code (col, row)}. The returned view is invalidated by the next call to + * {@code getStrA} / {@code getStrB} / {@code getVarcharA} / {@code getVarcharB} + * or once the enclosing {@code onBatch} callback returns. + */ + public DirectUtf8Sequence getStrA(int col, int row) { + return lookupStringBytes(col, row, strA); + } + + /** + * Dual of {@link #getStrA}; use when you need to hold two string views concurrently. + */ + public DirectUtf8Sequence getStrB(int col, int row) { + return lookupStringBytes(col, row, strB); + } + + /** + * Heap-allocating convenience. Returns a {@link String} for STRING / SYMBOL + * columns, or the UTF-8 bytes as a {@link String} for VARCHAR. Returns {@code null} + * for NULL rows. Allocates; on the hot path prefer {@link #getStrA}. + */ public String getString(int col, int row) { - return (String) columnValues.getQuick(col)[row]; + DirectUtf8Sequence v = lookupStringBytes(col, row, strA); + if (v == null) return null; + int size = v.size(); + byte[] bytes = new byte[size]; + for (int i = 0; i < size; i++) { + bytes[i] = v.byteAt(i); + } + return new String(bytes, StandardCharsets.UTF_8); } + /** + * Returns the high 64 bits of a UUID value. + */ + public long getUuidHi(int col, int row) { + QwpColumnLayout l = columnLayouts.getQuick(col); + if (isLayoutNull(l, row)) return 0L; + return Unsafe.getUnsafe().getLong(l.valuesAddr + 16L * l.nonNullIdx[row] + 8L); + } + + /** + * Returns the low 64 bits of a UUID value. + */ + public long getUuidLo(int col, int row) { + QwpColumnLayout l = columnLayouts.getQuick(col); + if (isLayoutNull(l, row)) return 0L; + return Unsafe.getUnsafe().getLong(l.valuesAddr + 16L * l.nonNullIdx[row]); + } + + /** + * Heap-allocating convenience: returns the boxed raw value using the same rules + * as the legacy API (Boolean, Long, Float, Double, String, byte[], long[]). + * Allocates per call. Prefer the typed accessors. + */ public Object getValue(int col, int row) { - return columnValues.getQuick(col)[row]; + if (isNull(col, row)) return null; + QwpColumnLayout l = columnLayouts.getQuick(col); + byte wt = l.info.wireType; + if (wt == QwpConstants.TYPE_BOOLEAN) return getBool(col, row); + if (wt == QwpConstants.TYPE_FLOAT) return getFloat(col, row); + if (wt == QwpConstants.TYPE_DOUBLE) return getDouble(col, row); + if (wt == QwpConstants.TYPE_STRING || wt == QwpConstants.TYPE_SYMBOL) return getString(col, row); + if (wt == QwpConstants.TYPE_VARCHAR) return getVarchar(col, row); + if (wt == QwpConstants.TYPE_UUID || wt == QwpConstants.TYPE_DECIMAL128 + || wt == QwpConstants.TYPE_LONG256 || wt == QwpConstants.TYPE_DECIMAL256) { + return getLongArray(col, row); + } + return getLong(col, row); } + /** + * Heap-allocating convenience. Returns the raw UTF-8 bytes of a VARCHAR value, + * or {@code null} for NULL rows. Allocates; on the hot path prefer + * {@link #getVarcharA}. + */ public byte[] getVarchar(int col, int row) { - return (byte[]) columnValues.getQuick(col)[row]; + DirectUtf8Sequence v = lookupStringBytes(col, row, varcharA); + if (v == null) return null; + int size = v.size(); + byte[] bytes = new byte[size]; + for (int i = 0; i < size; i++) { + bytes[i] = v.byteAt(i); + } + return bytes; + } + + /** + * Zero-allocation VARCHAR view. Semantically identical to {@link #getStrA} on + * VARCHAR columns but conventionally paired with {@link #getVarcharB}. + */ + public DirectUtf8Sequence getVarcharA(int col, int row) { + return lookupStringBytes(col, row, varcharA); + } + + public DirectUtf8Sequence getVarcharB(int col, int row) { + return lookupStringBytes(col, row, varcharB); } public boolean isNull(int col, int row) { - return columnValues.getQuick(col)[row] == null; + return isLayoutNull(columnLayouts.getQuick(col), row); } - public long requestId() { - return requestId; + /** + * Fast null check once the layout is in hand. Inlining pattern used by all the + * typed accessors: load layout once, check bitmap, read value. Eliminates the + * second {@code ObjList.getQuick(col)} that separate {@code isNull(col,row)} would cost. + */ + private static boolean isLayoutNull(QwpColumnLayout l, int row) { + if (l.nullBitmapAddr == 0) return false; + byte bm = Unsafe.getUnsafe().getByte(l.nullBitmapAddr + (row >>> 3)); + return (bm & (1 << (row & 7))) != 0; } - public long batchSeq() { - return batchSeq; + public long requestId() { + return requestId; } - void reset(long requestId, long batchSeq, int rowCount, int columnCount, ObjList columns) { - this.requestId = requestId; - this.batchSeq = batchSeq; - this.rowCount = rowCount; - this.columnCount = columnCount; - this.columns = columns; - while (columnValues.size() < columnCount) { - columnValues.add(new Object[0]); + /** + * Resolves the {@code (col, row)} cell for STRING / VARCHAR / SYMBOL columns and + * points the supplied view at the underlying bytes in the payload buffer. + * Returns {@code null} for NULL rows or unsupported wire types. + */ + private DirectUtf8Sequence lookupStringBytes(int col, int row, DirectUtf8String view) { + if (isNull(col, row)) return null; + QwpColumnLayout l = columnLayouts.getQuick(col); + byte wt = l.info.wireType; + int denseIdx = l.nonNullIdx[row]; + if (wt == QwpConstants.TYPE_STRING || wt == QwpConstants.TYPE_VARCHAR) { + int startOff = Unsafe.getUnsafe().getInt(l.valuesAddr + 4L * denseIdx); + int endOff = Unsafe.getUnsafe().getInt(l.valuesAddr + 4L * (denseIdx + 1)); + return view.of(l.stringBytesAddr + startOff, l.stringBytesAddr + endOff); } - for (int ci = 0; ci < columnCount; ci++) { - Object[] arr = columnValues.getQuick(ci); - if (arr.length < rowCount) { - arr = new Object[rowCount]; - columnValues.setQuick(ci, arr); - } else { - // Clear trailing slots from previous batches - for (int r = rowCount; r < arr.length; r++) arr[r] = null; - } + if (wt == QwpConstants.TYPE_SYMBOL) { + int dictIdx = l.symbolRowIds[row]; + DirectUtf8String entry = l.symbolDict.getQuick(dictIdx); + return view.of(entry.ptr(), entry.ptr() + entry.size()); } - } - - Object[] columnValues(int col) { - return columnValues.getQuick(col); + return null; } } diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpColumnLayout.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpColumnLayout.java new file mode 100644 index 00000000..72ceb9b8 --- /dev/null +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpColumnLayout.java @@ -0,0 +1,113 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +package io.questdb.client.cutlass.qwp.client; + +import io.questdb.client.std.ObjList; +import io.questdb.client.std.str.DirectUtf8String; + +/** + * Per-column parsed layout for one batch. Holds native pointers INTO the + * currently-active WS payload buffer plus pre-computed per-row indices for + * O(1) access. Reused across batches to eliminate allocations on the hot path + * (pooled arrays grow to max observed size and never shrink). + */ +public class QwpColumnLayout { + + /** + * Schema column metadata (name, wire type, scale, precisionBits). + */ + QwpEgressColumnInfo info; + + /** + * Absolute payload address where this column's non-null values start. For + * fixed-width types this is the dense values array. For strings/varchars + * it's the offsets array. For symbols it's where the dict starts; the + * per-row IDs are materialised into {@link #symbolRowIds} during parse. + */ + long valuesAddr; + + /** + * Absolute payload address of the null bitmap, or 0 if the column has no NULL rows. + */ + long nullBitmapAddr; + + /** + * Count of non-null rows in this column. + */ + int nonNullCount; + + /** + * Per-row lookup: {@code nonNullIdx[row]} is the dense index of row {@code row} within + * the non-null values, or -1 if the row is NULL. Sized to {@code rowCount}. + * Pool-owned; re-used across batches. + */ + int[] nonNullIdx; + + /** + * STRING / VARCHAR: absolute address of the concatenated UTF-8 bytes (right after the offsets array). + */ + long stringBytesAddr; + + /** + * SYMBOL: decoded dictionary entries as reusable native views into the payload. + */ + final ObjList symbolDict = new ObjList<>(); + + /** + * SYMBOL: number of valid entries in {@link #symbolDict} for this batch. + */ + int symbolDictSize; + + /** + * SYMBOL: per-row dictionary ID. Sized to {@code rowCount}; NULL rows are + * left with stale values — use {@link #nonNullIdx}/null-check first. + */ + int[] symbolRowIds; + + /** + * ARRAY: per-row starting offset (absolute address) of the array bytes. -1 for NULL rows. + */ + long[] arrayRowAddr; + + /** + * ARRAY: per-row length in bytes of the array payload. + */ + int[] arrayRowLen; + + /** + * Absolute address of the first byte after this column's data — used to walk to the next column. + */ + long nextAddr; + + public void clear() { + info = null; + valuesAddr = 0; + nullBitmapAddr = 0; + nonNullCount = 0; + stringBytesAddr = 0; + symbolDictSize = 0; + nextAddr = 0; + } +} diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpEgressIoThread.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpEgressIoThread.java new file mode 100644 index 00000000..b2ecc1a5 --- /dev/null +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpEgressIoThread.java @@ -0,0 +1,261 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +package io.questdb.client.cutlass.qwp.client; + +import io.questdb.client.cutlass.http.client.WebSocketClient; +import io.questdb.client.cutlass.http.client.WebSocketFrameHandler; +import io.questdb.client.cutlass.qwp.protocol.QwpConstants; +import io.questdb.client.std.Misc; +import io.questdb.client.std.ObjList; +import io.questdb.client.std.Unsafe; + +import java.nio.charset.StandardCharsets; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.TimeUnit; + +/** + * Dedicated I/O thread that owns the client's {@link WebSocketClient} and drives + * receive + decode off the user thread. The user thread submits a query via + * {@link #submitQuery} and drains events via {@link #takeEvent} / {@link #releaseBuffer}; + * meanwhile the I/O thread is free to read and decode the next batch in parallel. + *

+ * A small pool of {@link QwpBatchBuffer} instances (default: 4) holds decoded + * batches in flight. When the pool is exhausted the I/O thread blocks on + * {@link #freeBuffers} until the user releases a buffer. This gives natural + * back-pressure — if the consumer is slow, the I/O thread stops reading and + * the kernel's TCP window closes on the server side. + */ +public class QwpEgressIoThread implements Runnable, WebSocketFrameHandler { + + private static final int DEFAULT_BUFFER_CAPACITY = 64 * 1024; + private static final long POLL_TIMEOUT_MS = 100; + // Pool of pre-allocated buffers. I/O thread takes, user thread releases. + private final BlockingQueue freeBuffers; + private final QwpResultBatchDecoder decoder = new QwpResultBatchDecoder(); + // Events delivered from I/O thread to user thread (RESULT_BATCH / RESULT_END / QUERY_ERROR). + private final BlockingQueue events; + // Single-slot request queue (Phase-1 allows one in-flight query). + private final BlockingQueue requests = new ArrayBlockingQueue<>(1); + private final NativeBufferWriter sendScratch = new NativeBufferWriter(); + private final WebSocketClient wsClient; + // Per-query state; accessed only from the I/O thread. + private long currentRequestId; + private boolean currentQueryDone; + private volatile Throwable fatalError; + private volatile boolean shutdown; + + public QwpEgressIoThread(WebSocketClient wsClient, int bufferPoolSize) { + this.wsClient = wsClient; + this.freeBuffers = new ArrayBlockingQueue<>(bufferPoolSize); + this.events = new ArrayBlockingQueue<>(bufferPoolSize + 2); + for (int i = 0; i < bufferPoolSize; i++) { + freeBuffers.offer(new QwpBatchBuffer(DEFAULT_BUFFER_CAPACITY)); + } + } + + /** + * Releases a buffer back to the I/O thread pool. Call after the user + * handler finishes processing a {@code KIND_BATCH} event. + */ + public void releaseBuffer(QwpBatchBuffer buffer) { + freeBuffers.offer(buffer); + } + + @Override + public void onBinaryMessage(long payloadPtr, int payloadLen) { + if (payloadLen < QwpConstants.HEADER_SIZE + 1) { + emitError((byte) 0, "server sent truncated frame"); + return; + } + byte msgKind = Unsafe.getUnsafe().getByte(payloadPtr + QwpConstants.HEADER_SIZE); + if (msgKind == QwpEgressMsgKind.RESULT_BATCH) { + handleResultBatch(payloadPtr, payloadLen); + } else if (msgKind == QwpEgressMsgKind.RESULT_END) { + long total = decodeResultEnd(payloadPtr, payloadLen); + events.offer(new QueryEvent().asEnd(total)); + currentQueryDone = true; + } else if (msgKind == QwpEgressMsgKind.QUERY_ERROR) { + decodeAndEmitError(payloadPtr, payloadLen); + currentQueryDone = true; + } else { + emitError((byte) 0, "unknown msg_kind 0x" + Integer.toHexString(msgKind & 0xFF)); + currentQueryDone = true; + } + } + + @Override + public void onClose(int code, String reason) { + if (!currentQueryDone) { + emitError((byte) 0, "server closed connection: code=" + code + " reason=" + reason); + currentQueryDone = true; + } + } + + @Override + public void run() { + try { + while (!shutdown) { + QueryRequest req; + try { + req = requests.poll(POLL_TIMEOUT_MS, TimeUnit.MILLISECONDS); + } catch (InterruptedException ie) { + return; + } + if (req == null) continue; + + currentRequestId = req.requestId; + currentQueryDone = false; + sendQueryRequest(req); + + while (!currentQueryDone && !shutdown) { + // onBinaryMessage (on this same thread) sets currentQueryDone. + wsClient.receiveFrame(this, (int) POLL_TIMEOUT_MS); + } + } + } catch (Throwable t) { + fatalError = t; + emitError((byte) 0, "I/O thread failure: " + t.getMessage()); + } + } + + /** + * Blocking pop of the next event. Called by the user thread during {@code execute()}. + */ + public QueryEvent takeEvent() throws InterruptedException { + return events.take(); + } + + /** + * Signals shutdown. Does not join the thread — caller handles that. + */ + public void shutdown() { + shutdown = true; + } + + /** + * Blocking submission of a query. Called by the user thread. + */ + public void submitQuery(String sql, long requestId) throws InterruptedException { + requests.put(new QueryRequest(sql, requestId)); + } + + /** + * Frees native scratch owned by the pool. Call after the thread has terminated. + */ + void closePool() { + Misc.free(sendScratch); + for (QwpBatchBuffer b : freeBuffers) { + b.close(); + } + freeBuffers.clear(); + } + + private void decodeAndEmitError(long payload, int payloadLen) { + // Body: msg_kind(1) + requestId(8) + status(1) + msgLen(u16) + msgBytes + long p = payload + QwpConstants.HEADER_SIZE + 1 + 8; + byte status = Unsafe.getUnsafe().getByte(p); + p += 1; + int msgLen = Unsafe.getUnsafe().getShort(p) & 0xFFFF; + p += 2; + byte[] bytes = new byte[msgLen]; + for (int i = 0; i < msgLen; i++) { + bytes[i] = Unsafe.getUnsafe().getByte(p + i); + } + events.offer(new QueryEvent().asError(status, new String(bytes, StandardCharsets.UTF_8))); + } + + /** + * RESULT_END body: msg_kind(1) + requestId(8) + final_seq(varint) + total_rows(varint). + * We only need total_rows. + */ + private long decodeResultEnd(long payload, int payloadLen) { + long p = payload + QwpConstants.HEADER_SIZE + 1 + 8; + long limit = payload + payloadLen; + while (p < limit && (Unsafe.getUnsafe().getByte(p++) & 0x80) != 0) { + // skip final_seq continuation bytes + } + long total = 0; + int shift = 0; + while (p < limit) { + byte b = Unsafe.getUnsafe().getByte(p++); + total |= (long) (b & 0x7F) << shift; + if ((b & 0x80) == 0) break; + shift += 7; + } + return total; + } + + private void emitError(byte status, String message) { + events.offer(new QueryEvent().asError(status, message)); + } + + private void handleResultBatch(long payloadPtr, int payloadLen) { + QwpBatchBuffer buf; + try { + buf = freeBuffers.take(); + } catch (InterruptedException ie) { + return; + } + buf.copyFromPayload(payloadPtr, payloadLen); + try { + decoder.decode(buf); + } catch (QwpDecodeException e) { + freeBuffers.offer(buf); + emitError((byte) 0, "decode failure: " + e.getMessage()); + currentQueryDone = true; + return; + } + events.offer(new QueryEvent().asBatch(buf)); + } + + /** + * Builds and transmits a QUERY_REQUEST frame on the WebSocket. + */ + private void sendQueryRequest(QueryRequest req) { + byte[] sqlBytes = req.sql.getBytes(StandardCharsets.UTF_8); + sendScratch.reset(); + sendScratch.putByte(QwpEgressMsgKind.QUERY_REQUEST); + sendScratch.putLong(req.requestId); + sendScratch.putVarint(sqlBytes.length); + for (byte b : sqlBytes) { + sendScratch.putByte(b); + } + sendScratch.putVarint(0); // initial_credit = 0 (unbounded) + sendScratch.putVarint(0); // bind_count = 0 + wsClient.sendBinary(sendScratch.getBufferPtr(), sendScratch.getPosition()); + sendScratch.reset(); + } + + private static final class QueryRequest { + final long requestId; + final String sql; + + QueryRequest(String sql, long requestId) { + this.sql = sql; + this.requestId = requestId; + } + } +} diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpQueryClient.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpQueryClient.java index 7215aa49..724f4028 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpQueryClient.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpQueryClient.java @@ -26,45 +26,34 @@ import io.questdb.client.cutlass.http.client.WebSocketClient; import io.questdb.client.cutlass.http.client.WebSocketClientFactory; -import io.questdb.client.cutlass.http.client.WebSocketFrameHandler; -import io.questdb.client.cutlass.qwp.protocol.QwpConstants; -import io.questdb.client.std.Misc; import io.questdb.client.std.QuietCloseable; -import io.questdb.client.std.Unsafe; - -import java.nio.charset.StandardCharsets; /** - * QWP egress (query results) client. Phase-1 skeleton: connects to /read/v1, - * negotiates the QWP protocol version, and closes cleanly. + * QWP egress (query results) client. *

- * Thread safety: not thread-safe. A single instance should be used from one thread. + * Connection shape: one WebSocket to {@code /read/v1}, one dedicated I/O thread + * that owns the socket and the decoder. The user thread submits a query and + * drains result batches via the supplied {@link QwpColumnBatchHandler}; the + * I/O thread reads and decodes ahead so that decoding of batch {@code N+1} + * overlaps with the user's processing of batch {@code N}. *

- * Query execution wiring (QUERY_REQUEST encoding, RESULT_BATCH decoding, column-batch - * handler dispatch) is added in subsequent commits; this skeleton exists so the - * WebSocket upgrade to /read/v1 can be exercised end-to-end against the server. + * Thread safety: not thread-safe for concurrent queries on the same client. + * One {@link #execute} at a time. Opening one client per query-issuing thread + * is the recommended pattern. */ public class QwpQueryClient implements QuietCloseable { - /** - * Default endpoint path for QWP egress on the QuestDB HTTP server. - */ public static final String DEFAULT_ENDPOINT_PATH = "/read/v1"; - - /** - * Default QWP protocol version requested by this client. - */ public static final int QWP_MAX_VERSION = 1; - - private final QwpResultBatchDecoder decoder = new QwpResultBatchDecoder(); - private final QwpFrameRouter frameRouter = new QwpFrameRouter(); + private static final int DEFAULT_IO_BUFFER_POOL_SIZE = 4; private final CharSequence host; private final int port; - private final NativeBufferWriter sendScratch = new NativeBufferWriter(); private String authorizationHeader; + private int bufferPoolSize = DEFAULT_IO_BUFFER_POOL_SIZE; private boolean connected; - private int defaultTimeoutMillis = 30_000; private String endpointPath = DEFAULT_ENDPOINT_PATH; + private QwpEgressIoThread ioThread; + private Thread ioThreadHandle; private int negotiatedQwpVersion; private long nextRequestId = 1; private WebSocketClient webSocketClient; @@ -84,7 +73,19 @@ public static QwpQueryClient newPlainText(CharSequence host, int port) { @Override public void close() { connected = false; - Misc.free(sendScratch); + if (ioThread != null) { + ioThread.shutdown(); + if (ioThreadHandle != null) { + try { + ioThreadHandle.join(5_000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + ioThread.closePool(); + ioThread = null; + ioThreadHandle = null; + } if (webSocketClient != null) { webSocketClient.close(); webSocketClient = null; @@ -92,35 +93,7 @@ public void close() { } /** - * Executes {@code sql} against the server and delivers result batches to the handler. - *

- * Blocks until the server sends either {@code RESULT_END} or {@code QUERY_ERROR}. - * The handler's {@code onBatch}, {@code onEnd}, {@code onError} callbacks run on - * the calling thread during {@code receiveFrame} processing. - *

- * Phase 1: no bind parameters, no CREDIT (server streams unbounded). - */ - public void execute(String sql, QwpColumnBatchHandler handler) { - if (!connected) { - throw new IllegalStateException("QwpQueryClient not connected; call connect() first"); - } - long requestId = nextRequestId++; - writeQueryRequest(sql, requestId); - webSocketClient.sendBinary(sendScratch.getBufferPtr(), sendScratch.getPosition()); - sendScratch.reset(); - - frameRouter.of(handler, decoder, requestId); - while (!frameRouter.isDone()) { - boolean got = webSocketClient.receiveFrame(frameRouter, defaultTimeoutMillis); - if (!got) { - handler.onError((byte) 0, "timeout waiting for server response"); - break; - } - } - } - - /** - * Opens the TCP connection and performs the WebSocket upgrade handshake. + * Opens the TCP connection, performs the WebSocket upgrade, and spawns the I/O thread. * Must be called before any query is submitted. */ public void connect() { @@ -133,9 +106,55 @@ public void connect() { webSocketClient.connect(host, port); webSocketClient.upgrade(endpointPath, authorizationHeader); negotiatedQwpVersion = webSocketClient.getServerQwpVersion(); + + ioThread = new QwpEgressIoThread(webSocketClient, bufferPoolSize); + ioThreadHandle = new Thread(ioThread, "qwp-egress-io"); + ioThreadHandle.setDaemon(true); + ioThreadHandle.start(); connected = true; } + /** + * Executes {@code sql} and drives the supplied handler through the result stream. + *

+ * Blocks the calling thread until the server sends {@code RESULT_END} or + * {@code QUERY_ERROR}. While the user thread is inside {@code handler.onBatch}, + * the I/O thread keeps reading and decoding ahead up to the configured buffer-pool depth. + */ + public void execute(String sql, QwpColumnBatchHandler handler) { + if (!connected) { + throw new IllegalStateException("QwpQueryClient not connected; call connect() first"); + } + long requestId = nextRequestId++; + try { + ioThread.submitQuery(sql, requestId); + while (true) { + QueryEvent ev = ioThread.takeEvent(); + switch (ev.kind) { + case QueryEvent.KIND_BATCH: + try { + handler.onBatch(ev.buffer.batch); + } finally { + ioThread.releaseBuffer(ev.buffer); + } + break; + case QueryEvent.KIND_END: + handler.onEnd(ev.totalRows); + return; + case QueryEvent.KIND_ERROR: + handler.onError(ev.errorStatus, ev.errorMessage); + return; + default: + handler.onError((byte) 0, "unknown event kind " + ev.kind); + return; + } + } + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + handler.onError((byte) 0, "interrupted while waiting for server response"); + } + } + public int getNegotiatedQwpVersion() { return negotiatedQwpVersion; } @@ -145,18 +164,22 @@ public boolean isConnected() { } /** - * Sets the HTTP Authorization header used during the upgrade handshake. + * Overrides the default I/O buffer pool depth (4). Larger pools let the + * I/O thread decode further ahead of the consumer at the cost of memory; + * smaller pools reduce memory but may stall the I/O thread on slow consumers. * Must be called before {@link #connect()}. */ + public QwpQueryClient withBufferPoolSize(int size) { + if (size < 1) throw new IllegalArgumentException("bufferPoolSize must be >= 1"); + this.bufferPoolSize = size; + return this; + } + public QwpQueryClient withAuthorization(String authorizationHeader) { this.authorizationHeader = authorizationHeader; return this; } - /** - * Overrides the default egress endpoint path ({@value #DEFAULT_ENDPOINT_PATH}). - * Must be called before {@link #connect()}. - */ public QwpQueryClient withEndpointPath(String endpointPath) { this.endpointPath = endpointPath; return this; @@ -165,118 +188,4 @@ public QwpQueryClient withEndpointPath(String endpointPath) { private static String defaultClientId() { return "questdb-java-egress/1.0.0"; } - - /** - * Encodes a {@code QUERY_REQUEST} into {@link #sendScratch} at position 0. - * Layout: msg_kind + request_id + sql_len (varint) + sql (UTF-8) + - * initial_credit (varint) + bind_count (varint). - */ - private void writeQueryRequest(String sql, long requestId) { - byte[] sqlBytes = sql.getBytes(StandardCharsets.UTF_8); - sendScratch.reset(); - sendScratch.putByte(QwpEgressMsgKind.QUERY_REQUEST); - sendScratch.putLong(requestId); - sendScratch.putVarint(sqlBytes.length); - for (byte b : sqlBytes) { - sendScratch.putByte(b); - } - sendScratch.putVarint(0); // initial_credit = 0 (unbounded) - sendScratch.putVarint(0); // bind_count = 0 - } - - /** - * WebSocket frame handler that decodes QWP egress responses and dispatches to the - * user-supplied {@link QwpColumnBatchHandler}. Reused across {@code execute()} calls. - */ - private static final class QwpFrameRouter implements WebSocketFrameHandler { - private QwpResultBatchDecoder decoder; - private boolean done; - private QwpColumnBatchHandler handler; - private long requestId; - - public boolean isDone() { - return done; - } - - @Override - public void onBinaryMessage(long payloadPtr, int payloadLen) { - if (payloadLen < QwpConstants.HEADER_SIZE + 1) { - handler.onError((byte) 0, "server sent short frame (" + payloadLen + " bytes)"); - done = true; - return; - } - byte msgKind = Unsafe.getUnsafe().getByte(payloadPtr + QwpConstants.HEADER_SIZE); - if (msgKind == QwpEgressMsgKind.RESULT_BATCH) { - try { - decoder.decode(payloadPtr, payloadLen); - handler.onBatch(decoder.getBatch()); - } catch (QwpDecodeException e) { - handler.onError((byte) 0, e.getMessage()); - done = true; - } - } else if (msgKind == QwpEgressMsgKind.RESULT_END) { - long totalRows = decodeResultEnd(payloadPtr, payloadLen); - handler.onEnd(totalRows); - done = true; - } else if (msgKind == QwpEgressMsgKind.QUERY_ERROR) { - decodeQueryError(payloadPtr, payloadLen); - done = true; - } else { - handler.onError((byte) 0, "unknown msg_kind 0x" + Integer.toHexString(msgKind & 0xFF)); - done = true; - } - } - - @Override - public void onClose(int code, String reason) { - if (!done) { - handler.onError((byte) 0, "server closed connection: code=" + code + " reason=" + reason); - done = true; - } - } - - public void of(QwpColumnBatchHandler handler, QwpResultBatchDecoder decoder, long requestId) { - this.handler = handler; - this.decoder = decoder; - this.requestId = requestId; - this.done = false; - } - - private void decodeQueryError(long payload, int payloadLen) { - // Body: msg_kind(1) + requestId(8) + status(1) + msgLen(u16) + msgBytes - long p = payload + QwpConstants.HEADER_SIZE + 1 /* kind */ + 8 /* reqId */; - byte status = Unsafe.getUnsafe().getByte(p); - p += 1; - int msgLen = Unsafe.getUnsafe().getShort(p) & 0xFFFF; - p += 2; - byte[] bytes = new byte[msgLen]; - for (int i = 0; i < msgLen; i++) { - bytes[i] = Unsafe.getUnsafe().getByte(p + i); - } - handler.onError(status, new String(bytes, StandardCharsets.UTF_8)); - } - - /** - * RESULT_END body: msg_kind(1) + requestId(8) + final_seq(varint) + total_rows(varint). - * We only need total_rows, so walk past the first two varints. - */ - private long decodeResultEnd(long payload, int payloadLen) { - long p = payload + QwpConstants.HEADER_SIZE + 1 /* kind */ + 8 /* reqId */; - long limit = payload + payloadLen; - // Skip final_seq varint. - while (p < limit && (Unsafe.getUnsafe().getByte(p++) & 0x80) != 0) { - // continuation - } - // Decode total_rows varint. - long total = 0; - int shift = 0; - while (p < limit) { - byte b = Unsafe.getUnsafe().getByte(p++); - total |= (long) (b & 0x7F) << shift; - if ((b & 0x80) == 0) break; - shift += 7; - } - return total; - } - } } diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpResultBatchDecoder.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpResultBatchDecoder.java index 87e4d7e1..c9530328 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpResultBatchDecoder.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpResultBatchDecoder.java @@ -27,28 +27,35 @@ import io.questdb.client.cutlass.qwp.protocol.QwpConstants; import io.questdb.client.std.ObjList; import io.questdb.client.std.Unsafe; +import io.questdb.client.std.str.DirectUtf8String; import java.nio.charset.StandardCharsets; /** - * Stateful decoder for inbound QWP egress frames (server → client). + * Zero-alloc (after warmup) decoder for inbound QWP egress {@code RESULT_BATCH} frames. *

- * Reusable across batches on a single connection. Holds the connection-scoped - * schema registry so schema-reference batches (mode 0x01) can be resolved back - * to the full column list. Decoded values are materialised onto the heap for - * Phase-1 simplicity; see the public getters on {@link QwpColumnBatch}. + * The decoder parses the payload in-place — no values are copied out of the + * WebSocket receive buffer. It maintains pooled {@link QwpColumnLayout} slots, + * per-column {@code int[]} index arrays, and per-column {@link DirectUtf8String} + * dict entries that are reused across batches. After the connection has seen + * its peak schema width and row count, decoding a batch allocates nothing on + * the JVM heap. + *

+ * The produced {@link QwpColumnBatch} is valid only during the surrounding + * {@code onBatch} callback because its pointers refer into the caller's native + * payload buffer. */ public class QwpResultBatchDecoder { - /** - * Sentinel used as the per-row "NULL" marker in the decoded value arrays. - */ - public static final Object NULL = new Object(); - - private final QwpColumnBatch batch = new QwpColumnBatch(); - // Registry indexed by schemaId. null = not registered. Non-null ObjList is the column list - // for that schema. Schema ids are server-assigned and small (monotonic from 0). + // Connection-scoped state (safe to share across buffers — reused across batches + // of the same query and across queries on the same connection). + // Registry indexed by schemaId. null = not registered. Schema ids are server-assigned + // and small (monotonic from 0). private final ObjList> schemaRegistry = new ObjList<>(); + // Reusable varint decode state: value in varintValue, new position in varintPos. + // Instance-level so no {@code long[2]} scratch is allocated per call. + private long varintPos; + private long varintValue; /** * Clears the per-connection schema registry. Call when reconnecting. @@ -58,12 +65,16 @@ public void clearRegistry() { } /** - * Decodes a RESULT_BATCH frame payload (starting with msg_kind=0x11 at {@code payload}). - * After success, {@link #getBatch()} returns a view over the decoded data valid until - * the next {@code decode()} call. + * Decodes the RESULT_BATCH frame whose payload has been copied into {@code buffer}. + * Populates {@code buffer.batch} and {@code buffer.layoutPool}. The resulting + * batch view stays valid as long as the buffer is not reused. */ - public void decode(long payload, int payloadLen) throws QwpDecodeException { - if (payloadLen < QwpConstants.HEADER_SIZE + 10 /* msg_kind + reqId + min varint */) { + public void decode(QwpBatchBuffer buffer) throws QwpDecodeException { + decodePayload(buffer, buffer.getScratchAddr(), buffer.getPayloadLen()); + } + + private void decodePayload(QwpBatchBuffer buffer, long payload, int payloadLen) throws QwpDecodeException { + if (payloadLen < QwpConstants.HEADER_SIZE + 10) { throw new QwpDecodeException("RESULT_BATCH payload too short: " + payloadLen); } // Message header @@ -75,7 +86,6 @@ public void decode(long payload, int payloadLen) throws QwpDecodeException { if (version != QwpConstants.VERSION_1) { throw new QwpDecodeException("unsupported version " + (version & 0xFF)); } - // flags and table_count informational for Phase 1 long p = payload + QwpConstants.HEADER_SIZE; long limit = payload + payloadLen; @@ -86,46 +96,42 @@ public void decode(long payload, int payloadLen) throws QwpDecodeException { if (p + 8 > limit) throw new QwpDecodeException("truncated request_id"); long requestId = Unsafe.getUnsafe().getLong(p); p += 8; - long[] varint = new long[2]; // [value, nextPos] - decodeVarint(p, limit, varint); - long batchSeq = varint[0]; - p = varint[1]; + decodeVarint(p, limit); + long batchSeq = varintValue; + p = varintPos; // Table block: name_length, name, row_count, column_count, schema, columns - decodeVarint(p, limit, varint); - long nameLen = varint[0]; - p = varint[1]; + decodeVarint(p, limit); + long nameLen = varintValue; + p = varintPos; if (p + nameLen > limit) throw new QwpDecodeException("truncated table name"); - // Skip name — result sets carry empty names. p += nameLen; - decodeVarint(p, limit, varint); - int rowCount = (int) varint[0]; - p = varint[1]; - decodeVarint(p, limit, varint); - int columnCount = (int) varint[0]; - p = varint[1]; + decodeVarint(p, limit); + int rowCount = (int) varintValue; + p = varintPos; + decodeVarint(p, limit); + int columnCount = (int) varintValue; + p = varintPos; // Schema section if (p >= limit) throw new QwpDecodeException("truncated schema mode"); byte schemaMode = Unsafe.getUnsafe().getByte(p++); - decodeVarint(p, limit, varint); - int schemaId = (int) varint[0]; - p = varint[1]; + decodeVarint(p, limit); + int schemaId = (int) varintValue; + p = varintPos; ObjList columns; if (schemaMode == QwpConstants.SCHEMA_MODE_FULL) { columns = ensureSchemaSlot(schemaId, columnCount); for (int i = 0; i < columnCount; i++) { - decodeVarint(p, limit, varint); - int colNameLen = (int) varint[0]; - p = varint[1]; + decodeVarint(p, limit); + int colNameLen = (int) varintValue; + p = varintPos; if (p + colNameLen + 1 > limit) throw new QwpDecodeException("truncated column def"); String colName = readUtf8(p, colNameLen); p += colNameLen; byte wireType = Unsafe.getUnsafe().getByte(p++); - // Scale/precision are NOT in the schema — they're wire-level prefixes - // inside each column's data block. Placeholders here. columns.getQuick(i).of(colName, wireType, 0, 0); } } else if (schemaMode == QwpConstants.SCHEMA_MODE_REFERENCE) { @@ -140,27 +146,70 @@ public void decode(long payload, int payloadLen) throws QwpDecodeException { throw new QwpDecodeException("unknown schema mode 0x" + Integer.toHexString(schemaMode & 0xFF)); } - // Column data - batch.reset(requestId, batchSeq, rowCount, columnCount, columns); + // Reset batch view and parse columns into per-column layouts owned by the buffer. + resetBatch(buffer, requestId, batchSeq, rowCount, columnCount, columns, payload, limit); for (int ci = 0; ci < columnCount; ci++) { - QwpEgressColumnInfo info = columns.getQuick(ci); - p = decodeColumn(p, limit, info, rowCount, batch.columnValues(ci)); + QwpColumnLayout layout = borrowLayout(buffer.layoutPool, ci); + layout.clear(); + layout.info = columns.getQuick(ci); + p = parseColumn(layout, rowCount, p, limit); } } - /** - * Returns the view over the most recently decoded batch. Valid until the next - * {@link #decode(long, int)} call. - */ - public QwpColumnBatch getBatch() { - return batch; + // ----------------------------------------------------------------------------- + // Pool helpers + // ----------------------------------------------------------------------------- + + private static QwpColumnLayout borrowLayout(ObjList layoutPool, int colIdx) { + while (layoutPool.size() <= colIdx) { + layoutPool.add(new QwpColumnLayout()); + } + return layoutPool.getQuick(colIdx); + } + + private static int[] ensureIntArray(int[] current, int size) { + if (current != null && current.length >= size) return current; + return new int[Math.max(size, current == null ? 16 : current.length * 2)]; + } + + private static long[] ensureLongArray(long[] current, int size) { + if (current != null && current.length >= size) return current; + return new long[Math.max(size, current == null ? 16 : current.length * 2)]; + } + + private ObjList ensureSchemaSlot(int schemaId, int columnCount) { + while (schemaRegistry.size() <= schemaId) { + schemaRegistry.add(null); + } + ObjList slot = schemaRegistry.getQuick(schemaId); + if (slot == null) { + slot = new ObjList<>(); + schemaRegistry.setQuick(schemaId, slot); + } + int currentPos = slot.size(); + if (columnCount > currentPos) { + slot.setPos(columnCount); + for (int i = currentPos; i < columnCount; i++) { + if (slot.getQuick(i) == null) { + slot.setQuick(i, new QwpEgressColumnInfo()); + } + } + } else { + slot.setPos(columnCount); + } + return slot; } // ----------------------------------------------------------------------------- - // Varint / bitmap helpers + // Varint / string helpers // ----------------------------------------------------------------------------- - private static void decodeVarint(long p, long limit, long[] out) throws QwpDecodeException { + /** + * Decodes a varint starting at {@code p}. Stores the decoded value in + * {@link #varintValue} and the position just past the varint in + * {@link #varintPos}. Caller reads both before issuing the next varint call. + */ + private void decodeVarint(long p, long limit) throws QwpDecodeException { long value = 0; int shift = 0; long cur = p; @@ -172,8 +221,8 @@ private static void decodeVarint(long p, long limit, long[] out) throws QwpDecod shift += 7; if (shift > 63) throw new QwpDecodeException("varint overflow"); } - out[0] = value; - out[1] = cur; + varintValue = value; + varintPos = cur; } private static String readUtf8(long p, long len) { @@ -184,269 +233,178 @@ private static String readUtf8(long p, long len) { return new String(bytes, StandardCharsets.UTF_8); } + // ----------------------------------------------------------------------------- + // Per-column parse: advances through wire bytes, populates layout pointers, + // precomputes nonNullIdx for O(1) per-row access. + // ----------------------------------------------------------------------------- + /** - * Reads the null flag byte and (if present) the bitmap. Populates the given boolean - * array (length = rowCount) with row-is-null flags. Returns the new position. + * Reads the null flag and bitmap, populates {@code layout.nullBitmapAddr} and + * {@code layout.nonNullCount}, and fills {@code layout.nonNullIdx[0..rowCount)} + * with dense indices (or -1 for NULL rows). Returns the position just past + * the null section. */ - private static long readNullBitmap(long p, long limit, int rowCount, boolean[] nullFlags) throws QwpDecodeException { + private long parseNullSection(QwpColumnLayout layout, int rowCount, long p, long limit) throws QwpDecodeException { if (p >= limit) throw new QwpDecodeException("truncated null flag"); byte flag = Unsafe.getUnsafe().getByte(p++); - java.util.Arrays.fill(nullFlags, 0, rowCount, false); - if (flag == 0) return p; - int bytes = (rowCount + 7) >>> 3; - if (p + bytes > limit) throw new QwpDecodeException("truncated null bitmap"); + layout.nonNullIdx = ensureIntArray(layout.nonNullIdx, rowCount); + if (flag == 0) { + layout.nullBitmapAddr = 0; + layout.nonNullCount = rowCount; + for (int i = 0; i < rowCount; i++) layout.nonNullIdx[i] = i; + return p; + } + int bitmapBytes = (rowCount + 7) >>> 3; + if (p + bitmapBytes > limit) throw new QwpDecodeException("truncated null bitmap"); + layout.nullBitmapAddr = p; + int denseIdx = 0; for (int i = 0; i < rowCount; i++) { int bi = i >>> 3; int bit = i & 7; byte bm = Unsafe.getUnsafe().getByte(p + bi); if ((bm & (1 << bit)) != 0) { - nullFlags[i] = true; + layout.nonNullIdx[i] = -1; + } else { + layout.nonNullIdx[i] = denseIdx++; } } - return p + bytes; + layout.nonNullCount = denseIdx; + return p + bitmapBytes; } - // ----------------------------------------------------------------------------- - // Per-column decoders - // ----------------------------------------------------------------------------- - - private long decodeColumn(long p, long limit, QwpEgressColumnInfo info, int rowCount, Object[] values) - throws QwpDecodeException { - boolean[] nullFlags = new boolean[rowCount]; - p = readNullBitmap(p, limit, rowCount, nullFlags); - byte wt = info.wireType; + private long parseColumn(QwpColumnLayout layout, int rowCount, long p, long limit) throws QwpDecodeException { + p = parseNullSection(layout, rowCount, p, limit); + byte wt = layout.info.wireType; if (wt == QwpConstants.TYPE_BOOLEAN) { - int nonNull = 0; - for (int i = 0; i < rowCount; i++) if (!nullFlags[i]) nonNull++; - int bytes = (nonNull + 7) >>> 3; + layout.valuesAddr = p; + int bytes = (layout.nonNullCount + 7) >>> 3; if (p + bytes > limit) throw new QwpDecodeException("truncated BOOLEAN"); - int bitIdx = 0; - for (int i = 0; i < rowCount; i++) { - if (nullFlags[i]) { - values[i] = null; - continue; - } - byte bm = Unsafe.getUnsafe().getByte(p + (bitIdx >>> 3)); - values[i] = ((bm & (1 << (bitIdx & 7))) != 0) ? Boolean.TRUE : Boolean.FALSE; - bitIdx++; - } return p + bytes; } - if (wt == QwpConstants.TYPE_BYTE) return decodeFixed(p, limit, rowCount, 1, nullFlags, values); - if (wt == QwpConstants.TYPE_SHORT || wt == QwpConstants.TYPE_CHAR) { - return decodeFixed(p, limit, rowCount, 2, nullFlags, values); - } - if (wt == QwpConstants.TYPE_INT) return decodeFixed(p, limit, rowCount, 4, nullFlags, values); - if (wt == QwpConstants.TYPE_FLOAT) return decodeFloat(p, limit, rowCount, nullFlags, values); - if (wt == QwpConstants.TYPE_LONG || wt == QwpConstants.TYPE_DATE + if (wt == QwpConstants.TYPE_BYTE) return advanceFixed(layout, p, limit, 1); + if (wt == QwpConstants.TYPE_SHORT || wt == QwpConstants.TYPE_CHAR) return advanceFixed(layout, p, limit, 2); + if (wt == QwpConstants.TYPE_INT || wt == QwpConstants.TYPE_FLOAT) return advanceFixed(layout, p, limit, 4); + if (wt == QwpConstants.TYPE_LONG || wt == QwpConstants.TYPE_DOUBLE + || wt == QwpConstants.TYPE_DATE || wt == QwpConstants.TYPE_TIMESTAMP || wt == QwpConstants.TYPE_TIMESTAMP_NANOS) { - return decodeFixed(p, limit, rowCount, 8, nullFlags, values); - } - if (wt == QwpConstants.TYPE_DOUBLE) return decodeDouble(p, limit, rowCount, nullFlags, values); - if (wt == QwpConstants.TYPE_STRING || wt == QwpConstants.TYPE_VARCHAR) { - return decodeString(p, limit, rowCount, nullFlags, values, wt == QwpConstants.TYPE_STRING); - } - if (wt == QwpConstants.TYPE_SYMBOL) return decodeSymbol(p, limit, rowCount, nullFlags, values); - if (wt == QwpConstants.TYPE_UUID) return decodeFixedPair(p, limit, rowCount, nullFlags, values); - if (wt == QwpConstants.TYPE_LONG256) return decodeFixedQuad(p, limit, rowCount, nullFlags, values); - if (wt == QwpConstants.TYPE_GEOHASH) { - long[] varint = new long[2]; - decodeVarint(p, limit, varint); - info.precisionBits = (int) varint[0]; - p = varint[1]; - int bytesPerValue = (info.precisionBits + 7) >>> 3; - for (int i = 0; i < rowCount; i++) { - if (nullFlags[i]) { - values[i] = null; - continue; - } - if (p + bytesPerValue > limit) throw new QwpDecodeException("truncated GEOHASH"); - long bits = 0; - for (int b = 0; b < bytesPerValue; b++) { - bits |= ((long) (Unsafe.getUnsafe().getByte(p + b) & 0xFF)) << (b * 8); - } - values[i] = bits; - p += bytesPerValue; - } - return p; + return advanceFixed(layout, p, limit, 8); } if (wt == QwpConstants.TYPE_DECIMAL64) { if (p >= limit) throw new QwpDecodeException("truncated DECIMAL64 scale"); - info.scale = Unsafe.getUnsafe().getByte(p++) & 0xFF; - return decodeFixed(p, limit, rowCount, 8, nullFlags, values); + layout.info.scale = Unsafe.getUnsafe().getByte(p++) & 0xFF; + return advanceFixed(layout, p, limit, 8); } + if (wt == QwpConstants.TYPE_UUID) return advanceFixed(layout, p, limit, 16); if (wt == QwpConstants.TYPE_DECIMAL128) { if (p >= limit) throw new QwpDecodeException("truncated DECIMAL128 scale"); - info.scale = Unsafe.getUnsafe().getByte(p++) & 0xFF; - return decodeFixedPair(p, limit, rowCount, nullFlags, values); + layout.info.scale = Unsafe.getUnsafe().getByte(p++) & 0xFF; + return advanceFixed(layout, p, limit, 16); } + if (wt == QwpConstants.TYPE_LONG256) return advanceFixed(layout, p, limit, 32); if (wt == QwpConstants.TYPE_DECIMAL256) { if (p >= limit) throw new QwpDecodeException("truncated DECIMAL256 scale"); - info.scale = Unsafe.getUnsafe().getByte(p++) & 0xFF; - return decodeFixedQuad(p, limit, rowCount, nullFlags, values); + layout.info.scale = Unsafe.getUnsafe().getByte(p++) & 0xFF; + return advanceFixed(layout, p, limit, 32); } - if (wt == QwpConstants.TYPE_DOUBLE_ARRAY || wt == QwpConstants.TYPE_LONG_ARRAY) { - return decodeArray(p, limit, rowCount, nullFlags, values); + if (wt == QwpConstants.TYPE_STRING || wt == QwpConstants.TYPE_VARCHAR) { + return parseStringColumn(layout, p, limit); } - throw new QwpDecodeException("unsupported wire type 0x" + Integer.toHexString(wt & 0xFF)); - } - - private long decodeFixed(long p, long limit, int rowCount, int sizeBytes, boolean[] nullFlags, Object[] values) - throws QwpDecodeException { - for (int i = 0; i < rowCount; i++) { - if (nullFlags[i]) { - values[i] = null; - continue; - } - if (p + sizeBytes > limit) throw new QwpDecodeException("truncated fixed column"); - long v; - switch (sizeBytes) { - case 1: v = Unsafe.getUnsafe().getByte(p); break; - case 2: v = Unsafe.getUnsafe().getShort(p); break; - case 4: v = Unsafe.getUnsafe().getInt(p); break; - case 8: v = Unsafe.getUnsafe().getLong(p); break; - default: throw new IllegalStateException(); - } - values[i] = v; - p += sizeBytes; + if (wt == QwpConstants.TYPE_SYMBOL) { + return parseSymbolColumn(layout, rowCount, p, limit); } - return p; - } - - private long decodeFloat(long p, long limit, int rowCount, boolean[] nullFlags, Object[] values) - throws QwpDecodeException { - for (int i = 0; i < rowCount; i++) { - if (nullFlags[i]) { - values[i] = null; - continue; - } - if (p + 4 > limit) throw new QwpDecodeException("truncated FLOAT"); - values[i] = Float.intBitsToFloat(Unsafe.getUnsafe().getInt(p)); - p += 4; + if (wt == QwpConstants.TYPE_GEOHASH) { + decodeVarint(p, limit); + layout.info.precisionBits = (int) varintValue; + p = varintPos; + int bytesPerValue = (layout.info.precisionBits + 7) >>> 3; + layout.valuesAddr = p; + long total = (long) bytesPerValue * layout.nonNullCount; + if (p + total > limit) throw new QwpDecodeException("truncated GEOHASH"); + return p + total; } - return p; - } - - private long decodeDouble(long p, long limit, int rowCount, boolean[] nullFlags, Object[] values) - throws QwpDecodeException { - for (int i = 0; i < rowCount; i++) { - if (nullFlags[i]) { - values[i] = null; - continue; - } - if (p + 8 > limit) throw new QwpDecodeException("truncated DOUBLE"); - values[i] = Double.longBitsToDouble(Unsafe.getUnsafe().getLong(p)); - p += 8; + if (wt == QwpConstants.TYPE_DOUBLE_ARRAY || wt == QwpConstants.TYPE_LONG_ARRAY) { + return parseArrayColumn(layout, rowCount, p, limit); } - return p; + throw new QwpDecodeException("unsupported wire type 0x" + Integer.toHexString(wt & 0xFF)); } - private long decodeFixedPair(long p, long limit, int rowCount, boolean[] nullFlags, Object[] values) - throws QwpDecodeException { - for (int i = 0; i < rowCount; i++) { - if (nullFlags[i]) { - values[i] = null; - continue; - } - if (p + 16 > limit) throw new QwpDecodeException("truncated 16-byte value"); - long lo = Unsafe.getUnsafe().getLong(p); - long hi = Unsafe.getUnsafe().getLong(p + 8); - values[i] = new long[]{lo, hi}; - p += 16; - } - return p; + private static long advanceFixed(QwpColumnLayout layout, long p, long limit, int sizeBytes) throws QwpDecodeException { + layout.valuesAddr = p; + long total = (long) sizeBytes * layout.nonNullCount; + if (p + total > limit) throw new QwpDecodeException("truncated fixed-width column"); + return p + total; } - private long decodeFixedQuad(long p, long limit, int rowCount, boolean[] nullFlags, Object[] values) - throws QwpDecodeException { - for (int i = 0; i < rowCount; i++) { - if (nullFlags[i]) { - values[i] = null; - continue; - } - if (p + 32 > limit) throw new QwpDecodeException("truncated 32-byte value"); - values[i] = new long[]{ - Unsafe.getUnsafe().getLong(p), - Unsafe.getUnsafe().getLong(p + 8), - Unsafe.getUnsafe().getLong(p + 16), - Unsafe.getUnsafe().getLong(p + 24) - }; - p += 32; + /** + * STRING / VARCHAR: the offsets array is (nonNullCount+1) × uint32 starting at {@code p}, + * followed by the concatenated UTF-8 bytes. + */ + private static long parseStringColumn(QwpColumnLayout layout, long p, long limit) throws QwpDecodeException { + int nonNull = layout.nonNullCount; + long offsetsSize = 4L * (nonNull + 1); + if (p + offsetsSize > limit) throw new QwpDecodeException("truncated string offsets"); + layout.valuesAddr = p; + layout.stringBytesAddr = p + offsetsSize; + int totalBytes = nonNull == 0 ? 0 : Unsafe.getUnsafe().getInt(p + 4L * nonNull); + if (layout.stringBytesAddr + totalBytes > limit) { + throw new QwpDecodeException("truncated string bytes"); } - return p; + return layout.stringBytesAddr + totalBytes; } - private long decodeString(long p, long limit, int rowCount, boolean[] nullFlags, Object[] values, boolean utf16) - throws QwpDecodeException { - int nonNull = 0; - for (int i = 0; i < rowCount; i++) if (!nullFlags[i]) nonNull++; - int offsetBytes = 4 * (nonNull + 1); - if (p + offsetBytes > limit) throw new QwpDecodeException("truncated string offsets"); - long offsetsAddr = p; - long bytesStart = p + offsetBytes; - - int nonNullIdx = 0; - for (int i = 0; i < rowCount; i++) { - if (nullFlags[i]) { - values[i] = null; - continue; - } - int startOff = Unsafe.getUnsafe().getInt(offsetsAddr + 4L * nonNullIdx); - int endOff = Unsafe.getUnsafe().getInt(offsetsAddr + 4L * (nonNullIdx + 1)); - int len = endOff - startOff; - if (bytesStart + endOff > limit || len < 0) throw new QwpDecodeException("truncated string bytes"); - if (utf16) { - values[i] = readUtf8(bytesStart + startOff, len); - } else { - byte[] raw = new byte[len]; - for (int b = 0; b < len; b++) { - raw[b] = Unsafe.getUnsafe().getByte(bytesStart + startOff + b); - } - values[i] = raw; - } - nonNullIdx++; + /** + * SYMBOL: per-table dictionary (dict_size varint, then len+bytes per entry), + * then per-non-null-row varint indices into the dict. + */ + private long parseSymbolColumn(QwpColumnLayout layout, int rowCount, long p, long limit) throws QwpDecodeException { + decodeVarint(p, limit); + int dictSize = (int) varintValue; + p = varintPos; + // Ensure pool size + while (layout.symbolDict.size() < dictSize) { + layout.symbolDict.add(new DirectUtf8String()); } - int totalStringBytes = nonNull == 0 ? 0 : Unsafe.getUnsafe().getInt(offsetsAddr + 4L * nonNull); - return bytesStart + totalStringBytes; - } - - private long decodeSymbol(long p, long limit, int rowCount, boolean[] nullFlags, Object[] values) - throws QwpDecodeException { - long[] varint = new long[2]; - decodeVarint(p, limit, varint); - int dictSize = (int) varint[0]; - p = varint[1]; - String[] dict = new String[dictSize]; for (int e = 0; e < dictSize; e++) { - decodeVarint(p, limit, varint); - int entryLen = (int) varint[0]; - p = varint[1]; + decodeVarint(p, limit); + int entryLen = (int) varintValue; + p = varintPos; if (p + entryLen > limit) throw new QwpDecodeException("truncated symbol entry"); - dict[e] = readUtf8(p, entryLen); + layout.symbolDict.getQuick(e).of(p, p + entryLen); p += entryLen; } + layout.symbolDictSize = dictSize; + // Materialise per-row IDs into int[rowCount] so random access is O(1). + layout.symbolRowIds = ensureIntArray(layout.symbolRowIds, rowCount); for (int i = 0; i < rowCount; i++) { - if (nullFlags[i]) { - values[i] = null; - continue; + int denseIdx = layout.nonNullIdx[i]; + if (denseIdx < 0) continue; // NULL row; leave slot stale + decodeVarint(p, limit); + p = varintPos; + int id = (int) varintValue; + if (id < 0 || id >= dictSize) { + throw new QwpDecodeException("symbol index out of range: " + id); } - decodeVarint(p, limit, varint); - int idx = (int) varint[0]; - p = varint[1]; - if (idx < 0 || idx >= dictSize) throw new QwpDecodeException("symbol index out of range: " + idx); - values[i] = dict[idx]; + layout.symbolRowIds[i] = id; } + layout.valuesAddr = 0; // Not applicable; accessors use symbolRowIds + symbolDict. return p; } - private long decodeArray(long p, long limit, int rowCount, boolean[] nullFlags, Object[] values) - throws QwpDecodeException { + /** + * DOUBLE_ARRAY / LONG_ARRAY: each non-null row stores nDims (u8) + dimLens (nDims × i32) + * + flattened values (8 bytes each). We precompute per-row (addr, len) for O(1) access. + */ + private long parseArrayColumn(QwpColumnLayout layout, int rowCount, long p, long limit) throws QwpDecodeException { + layout.arrayRowAddr = ensureLongArray(layout.arrayRowAddr, rowCount); + layout.arrayRowLen = ensureIntArray(layout.arrayRowLen, rowCount); + layout.valuesAddr = p; for (int i = 0; i < rowCount; i++) { - if (nullFlags[i]) { - values[i] = null; + if (layout.nonNullIdx[i] < 0) { + layout.arrayRowAddr[i] = 0; + layout.arrayRowLen[i] = 0; continue; } - if (p + 1 > limit) throw new QwpDecodeException("truncated ARRAY"); + if (p + 1 > limit) throw new QwpDecodeException("truncated ARRAY header"); int nDims = Unsafe.getUnsafe().getByte(p) & 0xFF; long headerEnd = p + 1 + 4L * nDims; if (headerEnd > limit) throw new QwpDecodeException("truncated ARRAY dims"); @@ -455,39 +413,43 @@ private long decodeArray(long p, long limit, int rowCount, boolean[] nullFlags, int dl = Unsafe.getUnsafe().getInt(p + 1 + 4L * d); elements *= dl; } - long payloadEnd = headerEnd + 8L * elements; - if (payloadEnd > limit) throw new QwpDecodeException("truncated ARRAY payload"); - int totalLen = (int) (payloadEnd - p); - byte[] raw = new byte[totalLen]; - for (int b = 0; b < totalLen; b++) { - raw[b] = Unsafe.getUnsafe().getByte(p + b); - } - values[i] = raw; - p = payloadEnd; + long rowEnd = headerEnd + 8L * elements; + if (rowEnd > limit) throw new QwpDecodeException("truncated ARRAY payload"); + layout.arrayRowAddr[i] = p; + layout.arrayRowLen[i] = (int) (rowEnd - p); + p = rowEnd; } return p; } - private ObjList ensureSchemaSlot(int schemaId, int columnCount) { - while (schemaRegistry.size() <= schemaId) { - schemaRegistry.add(null); - } - ObjList slot = schemaRegistry.getQuick(schemaId); - if (slot == null) { - slot = new ObjList<>(); - schemaRegistry.setQuick(schemaId, slot); + // ----------------------------------------------------------------------------- + // Batch reset + // ----------------------------------------------------------------------------- + + private void resetBatch( + QwpBatchBuffer buffer, + long requestId, + long batchSeq, + int rowCount, + int columnCount, + ObjList columns, + long payloadAddr, + long payloadLimit + ) { + QwpColumnBatch batch = buffer.batch; + batch.requestId = requestId; + batch.batchSeq = batchSeq; + batch.rowCount = rowCount; + batch.columnCount = columnCount; + batch.columns = columns; + batch.payloadAddr = payloadAddr; + batch.payloadLimit = payloadLimit; + // Surface the buffer-owned layouts to the batch view + while (batch.columnLayouts.size() < columnCount) { + batch.columnLayouts.add(null); } - int currentPos = slot.size(); - if (columnCount > currentPos) { - slot.setPos(columnCount); - for (int i = currentPos; i < columnCount; i++) { - if (slot.getQuick(i) == null) { - slot.setQuick(i, new QwpEgressColumnInfo()); - } - } - } else { - slot.setPos(columnCount); + for (int i = 0; i < columnCount; i++) { + batch.columnLayouts.setQuick(i, borrowLayout(buffer.layoutPool, i)); } - return slot; } } From 8c5eb08d8aa7bc7b4dbcfd13c814361313e18a72 Mon Sep 17 00:00:00 2001 From: bluestreak Date: Sat, 18 Apr 2026 21:47:52 +0100 Subject: [PATCH 04/44] wip 3 --- .../cutlass/qwp/client/QwpColumnBatch.java | 2 -- .../cutlass/qwp/client/QwpEgressIoThread.java | 3 +++ .../qwp/client/QwpResultBatchDecoder.java | 22 +++++++++++-------- 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpColumnBatch.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpColumnBatch.java index 4fa79f1f..cb745de4 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpColumnBatch.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpColumnBatch.java @@ -253,7 +253,6 @@ public long getLong256Word(int col, int row, int wordIndex) { return Unsafe.getUnsafe().getLong(l.valuesAddr + 32L * l.nonNullIdx[row] + 8L * wordIndex); } - // ============================================================================= // Raw column-address API — for zero-branch hot inner loops. // // Typical usage: @@ -266,7 +265,6 @@ public long getLong256Word(int col, int row, int wordIndex) { // } // // All four accessors return constant-time views; no allocation. - // ============================================================================= /** * Number of non-null rows in this column, i.e. the count of entries in the diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpEgressIoThread.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpEgressIoThread.java index b2ecc1a5..ba4eaa07 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpEgressIoThread.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpEgressIoThread.java @@ -88,6 +88,9 @@ public void releaseBuffer(QwpBatchBuffer buffer) { public void onBinaryMessage(long payloadPtr, int payloadLen) { if (payloadLen < QwpConstants.HEADER_SIZE + 1) { emitError((byte) 0, "server sent truncated frame"); + // Stop the receive loop; the framing is broken and any further bytes + // would be misinterpreted relative to the expected message boundary. + currentQueryDone = true; return; } byte msgKind = Unsafe.getUnsafe().getByte(payloadPtr + QwpConstants.HEADER_SIZE); diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpResultBatchDecoder.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpResultBatchDecoder.java index c9530328..d55988c6 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpResultBatchDecoder.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpResultBatchDecoder.java @@ -156,9 +156,7 @@ private void decodePayload(QwpBatchBuffer buffer, long payload, int payloadLen) } } - // ----------------------------------------------------------------------------- // Pool helpers - // ----------------------------------------------------------------------------- private static QwpColumnLayout borrowLayout(ObjList layoutPool, int colIdx) { while (layoutPool.size() <= colIdx) { @@ -200,9 +198,7 @@ private ObjList ensureSchemaSlot(int schemaId, int columnCo return slot; } - // ----------------------------------------------------------------------------- // Varint / string helpers - // ----------------------------------------------------------------------------- /** * Decodes a varint starting at {@code p}. Stores the decoded value in @@ -233,10 +229,8 @@ private static String readUtf8(long p, long len) { return new String(bytes, StandardCharsets.UTF_8); } - // ----------------------------------------------------------------------------- // Per-column parse: advances through wire bytes, populates layout pointers, // precomputes nonNullIdx for O(1) per-row access. - // ----------------------------------------------------------------------------- /** * Reads the null flag and bitmap, populates {@code layout.nullBitmapAddr} and @@ -394,6 +388,13 @@ private long parseSymbolColumn(QwpColumnLayout layout, int rowCount, long p, lon * DOUBLE_ARRAY / LONG_ARRAY: each non-null row stores nDims (u8) + dimLens (nDims × i32) * + flattened values (8 bytes each). We precompute per-row (addr, len) for O(1) access. */ + /** + * Cap on per-row ARRAY element count. 8 bytes per element × this ≈ 256 MB max payload, + * which fits in {@code int} once {@code rowEnd - p} is computed. A malicious or buggy + * server cannot push a negative or wrap-around length past this guard. + */ + private static final long MAX_ARRAY_ELEMENTS = (Integer.MAX_VALUE - 1024) / 8L; + private long parseArrayColumn(QwpColumnLayout layout, int rowCount, long p, long limit) throws QwpDecodeException { layout.arrayRowAddr = ensureLongArray(layout.arrayRowAddr, rowCount); layout.arrayRowLen = ensureIntArray(layout.arrayRowLen, rowCount); @@ -408,10 +409,15 @@ private long parseArrayColumn(QwpColumnLayout layout, int rowCount, long p, long int nDims = Unsafe.getUnsafe().getByte(p) & 0xFF; long headerEnd = p + 1 + 4L * nDims; if (headerEnd > limit) throw new QwpDecodeException("truncated ARRAY dims"); - int elements = 1; + long elements = 1; for (int d = 0; d < nDims; d++) { int dl = Unsafe.getUnsafe().getInt(p + 1 + 4L * d); + if (dl < 0) throw new QwpDecodeException("ARRAY dim " + d + " is negative: " + dl); elements *= dl; + if (elements > MAX_ARRAY_ELEMENTS) { + throw new QwpDecodeException("ARRAY element count exceeds limit (" + + elements + " > " + MAX_ARRAY_ELEMENTS + ")"); + } } long rowEnd = headerEnd + 8L * elements; if (rowEnd > limit) throw new QwpDecodeException("truncated ARRAY payload"); @@ -422,9 +428,7 @@ private long parseArrayColumn(QwpColumnLayout layout, int rowCount, long p, long return p; } - // ----------------------------------------------------------------------------- // Batch reset - // ----------------------------------------------------------------------------- private void resetBatch( QwpBatchBuffer buffer, From 782f1dd890925f2470dde31c0df05424fc31c201 Mon Sep 17 00:00:00 2001 From: bluestreak Date: Sat, 18 Apr 2026 22:29:32 +0100 Subject: [PATCH 05/44] wip 4 --- .../cutlass/qwp/client/QwpQueryClient.java | 122 +++++++++++++++++- 1 file changed, 121 insertions(+), 1 deletion(-) diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpQueryClient.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpQueryClient.java index 724f4028..58f47253 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpQueryClient.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpQueryClient.java @@ -26,7 +26,10 @@ import io.questdb.client.cutlass.http.client.WebSocketClient; import io.questdb.client.cutlass.http.client.WebSocketClientFactory; +import io.questdb.client.impl.ConfStringParser; +import io.questdb.client.std.Chars; import io.questdb.client.std.QuietCloseable; +import io.questdb.client.std.str.StringSink; /** * QWP egress (query results) client. @@ -44,12 +47,14 @@ public class QwpQueryClient implements QuietCloseable { public static final String DEFAULT_ENDPOINT_PATH = "/read/v1"; + public static final int DEFAULT_WS_PORT = 9000; public static final int QWP_MAX_VERSION = 1; private static final int DEFAULT_IO_BUFFER_POOL_SIZE = 4; private final CharSequence host; private final int port; private String authorizationHeader; private int bufferPoolSize = DEFAULT_IO_BUFFER_POOL_SIZE; + private String clientId; private boolean connected; private String endpointPath = DEFAULT_ENDPOINT_PATH; private QwpEgressIoThread ioThread; @@ -63,6 +68,112 @@ private QwpQueryClient(CharSequence host, int port) { this.port = port; } + /** + * Builds a query client from a connection-string of the same shape used by + * {@link io.questdb.client.Sender#fromConfig(CharSequence)}: {@code ::key=value;key=value;...}. + *

+ * Supported schemas: + *

    + *
  • {@code ws::} — plain WebSocket (matches QWP egress today; TLS not yet supported).
  • + *
+ * Supported keys: + *
    + *
  • {@code addr=host[:port]} — required. Default port is {@value #DEFAULT_WS_PORT}.
  • + *
  • {@code path=/read/v1} — egress endpoint. Default {@value #DEFAULT_ENDPOINT_PATH}.
  • + *
  • {@code auth=} — sent as the HTTP {@code Authorization} header during the upgrade handshake.
  • + *
  • {@code client_id=} — sent as the {@code X-QWP-Client-Id} header.
  • + *
  • {@code buffer_pool_size=N} — depth of the I/O thread's batch buffer pool. Default 4.
  • + *
+ * Examples: + *
+     *   ws::addr=localhost:9000;
+     *   ws::addr=db.internal:9000;path=/read/v1;auth=Bearer abc123;client_id=dashboard/2.0;
+     * 
+ */ + public static QwpQueryClient fromConfig(CharSequence configurationString) { + if (configurationString == null || configurationString.length() == 0) { + throw new IllegalArgumentException("configuration string cannot be empty"); + } + StringSink sink = new StringSink(); + int pos = ConfStringParser.of(configurationString, sink); + if (pos < 0) { + throw new IllegalArgumentException("invalid configuration string: " + sink); + } + if (Chars.equals("wss", sink)) { + throw new IllegalArgumentException("wss:: (TLS) is not supported by QwpQueryClient yet"); + } + if (!Chars.equals("ws", sink)) { + throw new IllegalArgumentException( + "unsupported schema [schema=" + sink + ", supported-schemas=[ws]]"); + } + + String addrHost = null; + int addrPort = DEFAULT_WS_PORT; + String path = DEFAULT_ENDPOINT_PATH; + String auth = null; + String cid = null; + int poolSize = DEFAULT_IO_BUFFER_POOL_SIZE; + + while (ConfStringParser.hasNext(configurationString, pos)) { + pos = ConfStringParser.nextKey(configurationString, pos, sink); + if (pos < 0) { + throw new IllegalArgumentException("invalid configuration string [error=" + sink + "]"); + } + String key = sink.toString(); + pos = ConfStringParser.value(configurationString, pos, sink); + if (pos < 0) { + throw new IllegalArgumentException("invalid configuration string [error=" + sink + "]"); + } + String value = sink.toString(); + switch (key) { + case "addr": { + int colon = value.indexOf(':'); + if (colon < 0) { + addrHost = value; + } else { + addrHost = value.substring(0, colon); + try { + addrPort = Integer.parseInt(value.substring(colon + 1)); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("invalid port in addr: " + value); + } + } + break; + } + case "path": + path = value; + break; + case "auth": + auth = value; + break; + case "client_id": + cid = value; + break; + case "buffer_pool_size": + try { + poolSize = Integer.parseInt(value); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("invalid buffer_pool_size: " + value); + } + if (poolSize < 1) { + throw new IllegalArgumentException("buffer_pool_size must be >= 1"); + } + break; + default: + throw new IllegalArgumentException("unknown configuration key: " + key); + } + } + if (addrHost == null) { + throw new IllegalArgumentException("missing required key: addr"); + } + QwpQueryClient client = new QwpQueryClient(addrHost, addrPort) + .withEndpointPath(path) + .withBufferPoolSize(poolSize); + if (auth != null) client.withAuthorization(auth); + if (cid != null) client.withClientId(cid); + return client; + } + /** * Creates a plain-text (non-TLS) QWP query client. */ @@ -102,7 +213,7 @@ public void connect() { } webSocketClient = WebSocketClientFactory.newPlainTextInstance(); webSocketClient.setQwpMaxVersion(QWP_MAX_VERSION); - webSocketClient.setQwpClientId(defaultClientId()); + webSocketClient.setQwpClientId(clientId != null ? clientId : defaultClientId()); webSocketClient.connect(host, port); webSocketClient.upgrade(endpointPath, authorizationHeader); negotiatedQwpVersion = webSocketClient.getServerQwpVersion(); @@ -180,6 +291,15 @@ public QwpQueryClient withAuthorization(String authorizationHeader) { return this; } + /** + * Overrides the {@code X-QWP-Client-Id} header sent during the upgrade handshake. + * Must be called before {@link #connect()}. + */ + public QwpQueryClient withClientId(String clientId) { + this.clientId = clientId; + return this; + } + public QwpQueryClient withEndpointPath(String endpointPath) { this.endpointPath = endpointPath; return this; From f523d2244a5e705e962d7087b35e3a7b932d7087 Mon Sep 17 00:00:00 2001 From: bluestreak Date: Sat, 18 Apr 2026 23:21:58 +0100 Subject: [PATCH 06/44] wip 5 --- .../cutlass/qwp/client/QwpColumnBatch.java | 11 +++++ .../cutlass/qwp/client/QwpQueryClient.java | 49 ++++++++++++++++++- 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpColumnBatch.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpColumnBatch.java index cb745de4..ca1b4ba2 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpColumnBatch.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpColumnBatch.java @@ -423,6 +423,17 @@ public DirectUtf8Sequence getVarcharB(int col, int row) { return lookupStringBytes(col, row, varcharB); } + /** + * True if the cell is NULL on the wire. + *

+ * Note on type-specific sentinels (see {@code docs/QWP_EGRESS_EXTENSION.md} §11.5): + * QuestDB stores NULL as a sentinel value for several types — {@code Long.MIN_VALUE} + * for LONG/INT/etc., {@code 0.0.0.0} for IPv4, {@code -1} for GEOHASH, and crucially + * {@code NaN} for FLOAT and DOUBLE. Egress preserves these conventions: a row carrying + * NaN in a DOUBLE column will return {@code true} from this method. Callers who need + * to distinguish "real NaN" from "explicit NULL" cannot do so over the wire — both + * map to the same null bitmap bit. + */ public boolean isNull(int col, int row) { return isLayoutNull(columnLayouts.getQuick(col), row); } diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpQueryClient.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpQueryClient.java index 58f47253..26246c87 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpQueryClient.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpQueryClient.java @@ -50,6 +50,13 @@ public class QwpQueryClient implements QuietCloseable { public static final int DEFAULT_WS_PORT = 9000; public static final int QWP_MAX_VERSION = 1; private static final int DEFAULT_IO_BUFFER_POOL_SIZE = 4; + /** + * Maximum time {@link #close()} will wait for the I/O thread to exit before giving up + * and leaking the (daemon) thread + its native buffer pool + WebSocket socket. 5 seconds + * is generous given the I/O thread polls on a 100 ms cadence; if it overshoots this, + * something is seriously wrong (e.g., user handler stuck in onBatch). + */ + private static final long SHUTDOWN_JOIN_MS = 5_000; private final CharSequence host; private final int port; private String authorizationHeader; @@ -59,6 +66,7 @@ public class QwpQueryClient implements QuietCloseable { private String endpointPath = DEFAULT_ENDPOINT_PATH; private QwpEgressIoThread ioThread; private Thread ioThreadHandle; + private boolean lastCloseTimedOut; private int negotiatedQwpVersion; private long nextRequestId = 1; private WebSocketClient webSocketClient; @@ -181,16 +189,45 @@ public static QwpQueryClient newPlainText(CharSequence host, int port) { return new QwpQueryClient(host, port); } + /** + * Shutdown order: signal the I/O thread, interrupt it to wake it from any blocking + * {@code wsClient.receiveFrame(...)} or queue poll, wait for it to exit, then free + * the buffer pool and close the underlying socket. + *

+ * If the I/O thread fails to exit within {@link #SHUTDOWN_JOIN_MS} (default 5 s), this + * method does not free the buffer pool or close the WebSocket — both are + * still in use by the thread, and freeing them would race into a JVM-killing + * use-after-free. The thread is a daemon, so the JVM still exits normally; the + * resources leak for the lifetime of the process. A warning is recorded by setting + * {@link #lastCloseTimedOut} (queryable via {@link #wasLastCloseTimedOut}) so callers + * can detect and report the condition. + */ @Override public void close() { connected = false; + lastCloseTimedOut = false; if (ioThread != null) { ioThread.shutdown(); + // Wake the thread from any blocking poll / recv so it sees the shutdown flag promptly. if (ioThreadHandle != null) { + ioThreadHandle.interrupt(); + boolean joined = false; try { - ioThreadHandle.join(5_000); + ioThreadHandle.join(SHUTDOWN_JOIN_MS); + joined = !ioThreadHandle.isAlive(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); + // Don't free anything — preserve clean shutdown semantics on the next attempt. + return; + } + if (!joined) { + // Daemon thread is still running — buffer pool and WebSocketClient may + // be in use. Leak them rather than risk a SIGSEGV by freeing under it. + lastCloseTimedOut = true; + ioThread = null; + ioThreadHandle = null; + webSocketClient = null; + return; } } ioThread.closePool(); @@ -203,6 +240,16 @@ public void close() { } } + /** + * Returns true if the most recent {@link #close()} call abandoned the I/O thread + * because it failed to exit within the join timeout. The native buffer pool and + * WebSocket socket are leaked for the lifetime of the JVM; the daemon I/O thread + * keeps running until process exit. + */ + public boolean wasLastCloseTimedOut() { + return lastCloseTimedOut; + } + /** * Opens the TCP connection, performs the WebSocket upgrade, and spawns the I/O thread. * Must be called before any query is submitted. From 4136fe27ed6ef338ea9d1e0a4cf1ea24ff514076 Mon Sep 17 00:00:00 2001 From: bluestreak Date: Sun, 19 Apr 2026 01:30:43 +0100 Subject: [PATCH 07/44] wip 6 --- .../cutlass/qwp/client/QwpBatchBuffer.java | 7 +- .../cutlass/qwp/client/QwpColumnBatch.java | 272 ++++++++------ .../cutlass/qwp/client/QwpColumnLayout.java | 4 +- .../cutlass/qwp/client/QwpEgressIoThread.java | 139 ++++--- .../cutlass/qwp/client/QwpQueryClient.java | 36 +- .../qwp/client/QwpResultBatchDecoder.java | 335 +++++++++-------- .../qwp/client/QwpWebSocketSender.java | 4 +- .../cutlass/qwp/protocol/QwpConstants.java | 51 +-- .../QwpResultBatchDecoderHardeningTest.java | 343 ++++++++++++++++++ 9 files changed, 852 insertions(+), 339 deletions(-) create mode 100644 core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpResultBatchDecoderHardeningTest.java diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpBatchBuffer.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpBatchBuffer.java index 55aebc8e..e31fb8aa 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpBatchBuffer.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpBatchBuffer.java @@ -35,8 +35,8 @@ * into, plus the per-column {@link QwpColumnLayout} pool used while decoding and * the {@link QwpColumnBatch} view that the user's handler sees. *

- * Lifecycle: I/O thread takes a buffer from the free pool → copies the frame - * payload in → hands the decoder the buffer → pushes the resulting batch onto + * Lifecycle: I/O thread takes a buffer from the free pool -> copies the frame + * payload in -> hands the decoder the buffer -> pushes the resulting batch onto * the event queue. User thread pops, invokes the handler, releases the buffer * back to the pool. While the user thread owns the buffer the I/O thread is * free to take a different buffer and decode the next frame. @@ -90,8 +90,7 @@ private void ensureCapacity(int required) { if (required <= scratchCapacity) return; int newCap = scratchCapacity; while (newCap < required) newCap *= 2; - long newAddr = Unsafe.realloc(scratchAddr, scratchCapacity, newCap, MemoryTag.NATIVE_DEFAULT); - scratchAddr = newAddr; + scratchAddr = Unsafe.realloc(scratchAddr, scratchCapacity, newCap, MemoryTag.NATIVE_DEFAULT); scratchCapacity = newCap; } } diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpColumnBatch.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpColumnBatch.java index ca1b4ba2..f46b0578 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpColumnBatch.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpColumnBatch.java @@ -27,6 +27,7 @@ import io.questdb.client.cutlass.qwp.protocol.QwpConstants; import io.questdb.client.std.ObjList; import io.questdb.client.std.Unsafe; +import io.questdb.client.std.bytes.DirectByteSequence; import io.questdb.client.std.str.DirectUtf8Sequence; import io.questdb.client.std.str.DirectUtf8String; @@ -49,13 +50,9 @@ public class QwpColumnBatch { final ObjList columnLayouts = new ObjList<>(); - long batchSeq; - int columnCount; - ObjList columns; - long payloadAddr; - long payloadLimit; - long requestId; - int rowCount; + // BINARY views -- re-pointed per call, never re-allocated. + private final io.questdb.client.std.bytes.DirectByteSlice binaryA = new io.questdb.client.std.bytes.DirectByteSlice(); + private final io.questdb.client.std.bytes.DirectByteSlice binaryB = new io.questdb.client.std.bytes.DirectByteSlice(); // Reusable views for zero-alloc UTF-8 access. strA and strB are dual views // (same pattern as QuestDB Record.getStrA/getStrB) so callers can compare // two cells without one overwriting the other. @@ -63,11 +60,61 @@ public class QwpColumnBatch { private final DirectUtf8String strB = new DirectUtf8String(); private final DirectUtf8String varcharA = new DirectUtf8String(); private final DirectUtf8String varcharB = new DirectUtf8String(); + long batchSeq; + int columnCount; + ObjList columns; + long payloadAddr; + long payloadLimit; + long requestId; + int rowCount; public long batchSeq() { return batchSeq; } + /** + * Returns the dimensionality of the ARRAY value at {@code (col, row)}, or 0 if + * the row is null. Caller must know the column is an ARRAY type. + */ + public int getArrayNDims(int col, int row) { + QwpColumnLayout l = columnLayouts.getQuick(col); + if (isLayoutNull(l, row)) return 0; + return Unsafe.getUnsafe().getByte(l.arrayRowAddr[row]) & 0xFF; + } + + /** + * Heap-allocating convenience. Returns the raw bytes of a BINARY value, or {@code null} + * for NULL rows. Allocates a new {@code byte[]} per call; on the hot path prefer + * {@link #getBinaryA} which returns a reusable native view. + */ + public byte[] getBinary(int col, int row) { + io.questdb.client.std.bytes.DirectByteSequence v = lookupBinaryBytes(col, row, binaryA); + if (v == null) return null; + int size = v.size(); + byte[] bytes = new byte[size]; + for (int i = 0; i < size; i++) { + bytes[i] = v.byteAt(i); + } + return bytes; + } + + /** + * Zero-allocation BINARY view. Returns a {@link DirectByteSequence} + * pointing into the WebSocket payload buffer. The view is invalidated by the next call to + * {@link #getBinaryB} on this batch or once the enclosing + * {@code onBatch} callback returns. + */ + public DirectByteSequence getBinaryA(int col, int row) { + return lookupBinaryBytes(col, row, binaryA); + } + + /** + * Dual of {@link #getBinaryA}; use when you need to hold two binary views concurrently. + */ + public io.questdb.client.std.bytes.DirectByteSequence getBinaryB(int col, int row) { + return lookupBinaryBytes(col, row, binaryB); + } + public boolean getBool(int col, int row) { QwpColumnLayout l = columnLayouts.getQuick(col); if (isLayoutNull(l, row)) return false; @@ -108,10 +155,6 @@ public byte getColumnWireType(int col) { return columns.getQuick(col).wireType; } - public int getDecimalScale(int col) { - return columns.getQuick(col).scale; - } - /** * Returns the high 64 bits of a DECIMAL128 value. Combine with {@link #getDecimal128Low}. */ @@ -130,18 +173,49 @@ public long getDecimal128Low(int col, int row) { return Unsafe.getUnsafe().getLong(l.valuesAddr + 16L * l.nonNullIdx[row]); } + public int getDecimalScale(int col) { + return columns.getQuick(col).scale; + } + public double getDouble(int col, int row) { QwpColumnLayout l = columnLayouts.getQuick(col); if (isLayoutNull(l, row)) return Double.NaN; return Unsafe.getUnsafe().getDouble(l.valuesAddr + 8L * l.nonNullIdx[row]); } + /** + * Returns the flattened elements of a DOUBLE_ARRAY value in row-major order, or + * {@code null} for NULL rows. Heap-allocating convenience -- on the hot path, use + * {@link #getArrayNDims}/ and read directly from the + * wire via {@code arrayRowAddr}. + */ + public double[] getDoubleArrayElements(int col, int row) { + QwpColumnLayout l = columnLayouts.getQuick(col); + if (isLayoutNull(l, row)) return null; + long rowAddr = l.arrayRowAddr[row]; + int nDims = Unsafe.getUnsafe().getByte(rowAddr) & 0xFF; + int elements = 1; + for (int d = 0; d < nDims; d++) { + elements *= Unsafe.getUnsafe().getInt(rowAddr + 1 + 4L * d); + } + double[] out = new double[elements]; + long base = rowAddr + 1 + 4L * nDims; + for (int i = 0; i < elements; i++) { + out[i] = Unsafe.getUnsafe().getDouble(base + 8L * i); + } + return out; + } + public float getFloat(int col, int row) { QwpColumnLayout l = columnLayouts.getQuick(col); if (isLayoutNull(l, row)) return Float.NaN; return Unsafe.getUnsafe().getFloat(l.valuesAddr + 4L * l.nonNullIdx[row]); } + public int getGeohashPrecisionBits(int col) { + return columns.getQuick(col).precisionBits; + } + /** * Returns a single INT value without type dispatch. Caller must know the * column is INT or IPv4. Returns 0 for NULL rows. @@ -152,10 +226,6 @@ public int getIntValue(int col, int row) { return Unsafe.getUnsafe().getInt(l.valuesAddr + 4L * l.nonNullIdx[row]); } - public int getGeohashPrecisionBits(int col) { - return columns.getQuick(col).precisionBits; - } - /** * Returns a LONG / INT / SHORT / BYTE / CHAR / TIMESTAMP / DATE / DECIMAL64 / GEOHASH value, * dispatching by the column's wire type. Convenience for schema-agnostic code. @@ -174,7 +244,7 @@ public long getLong(int col, int row) { || wt == QwpConstants.TYPE_DECIMAL64) { return Unsafe.getUnsafe().getLong(l.valuesAddr + 8L * denseIdx); } - if (wt == QwpConstants.TYPE_INT) { + if (wt == QwpConstants.TYPE_INT || wt == QwpConstants.TYPE_IPv4) { return Unsafe.getUnsafe().getInt(l.valuesAddr + 4L * denseIdx); } if (wt == QwpConstants.TYPE_SHORT || wt == QwpConstants.TYPE_CHAR) { @@ -198,27 +268,31 @@ public long getLong(int col, int row) { } /** - * Returns an 8-byte LONG / TIMESTAMP / DATE / DECIMAL64 value without type dispatch. - * Caller must know the column is a LONG-family type. Returns 0 for NULL rows. + * Returns one of the four 64-bit words of a LONG256 or DECIMAL256 value. + * {@code wordIndex} 0 is least significant, 3 is most significant. */ - public long getLongValue(int col, int row) { + public long getLong256Word(int col, int row, int wordIndex) { QwpColumnLayout l = columnLayouts.getQuick(col); if (isLayoutNull(l, row)) return 0L; - return Unsafe.getUnsafe().getLong(l.valuesAddr + 8L * l.nonNullIdx[row]); + return Unsafe.getUnsafe().getLong(l.valuesAddr + 32L * l.nonNullIdx[row] + 8L * wordIndex); } - /** - * Returns a SHORT value without type dispatch. Caller must know the column is SHORT. - */ - public short getShortValue(int col, int row) { - QwpColumnLayout l = columnLayouts.getQuick(col); - if (isLayoutNull(l, row)) return 0; - return Unsafe.getUnsafe().getShort(l.valuesAddr + 2L * l.nonNullIdx[row]); - } + // Raw column-address API -- for zero-branch hot inner loops. + // + // Typical usage: + // long base = batch.valuesAddr(col); + // int[] idx = batch.nonNullIndex(col); + // for (int r = 0; r < rowCount; r++) { + // if (idx[r] < 0) continue; // NULL + // long v = Unsafe.getLong(base + 8L * idx[r]); + // ... + // } + // + // All four accessors return constant-time views; no allocation. /** * Convenience: returns a length-{@code N} long array with the components of a UUID, - * LONG256, DECIMAL128, or DECIMAL256 value. Allocates — avoid on the hot path; + * LONG256, DECIMAL128, or DECIMAL256 value. Allocates -- avoid on the hot path; * use {@link #getUuidLo}/{@link #getUuidHi} or {@link #getLong256Word} instead. */ public long[] getLongArray(int col, int row) { @@ -244,83 +318,26 @@ public long[] getLongArray(int col, int row) { } /** - * Returns one of the four 64-bit words of a LONG256 or DECIMAL256 value. - * {@code wordIndex} 0 is least significant, 3 is most significant. + * Returns an 8-byte LONG / TIMESTAMP / DATE / DECIMAL64 value without type dispatch. + * Caller must know the column is a LONG-family type. Returns 0 for NULL rows. */ - public long getLong256Word(int col, int row, int wordIndex) { + public long getLongValue(int col, int row) { QwpColumnLayout l = columnLayouts.getQuick(col); if (isLayoutNull(l, row)) return 0L; - return Unsafe.getUnsafe().getLong(l.valuesAddr + 32L * l.nonNullIdx[row] + 8L * wordIndex); - } - - // Raw column-address API — for zero-branch hot inner loops. - // - // Typical usage: - // long base = batch.valuesAddr(col); - // int[] idx = batch.nonNullIndex(col); - // for (int r = 0; r < rowCount; r++) { - // if (idx[r] < 0) continue; // NULL - // long v = Unsafe.getLong(base + 8L * idx[r]); - // ... - // } - // - // All four accessors return constant-time views; no allocation. - - /** - * Number of non-null rows in this column, i.e. the count of entries in the - * dense values array pointed to by {@link #valuesAddr(int)}. - */ - public int nonNullCount(int col) { - return columnLayouts.getQuick(col).nonNullCount; - } - - /** - * Per-row lookup table. {@code result[row]} is the dense index within the - * column's non-null values, or -1 if the row is NULL. Array length equals - * {@link #getRowCount()}. Valid only during the current {@code onBatch} - * callback; do not retain. - */ - public int[] nonNullIndex(int col) { - return columnLayouts.getQuick(col).nonNullIdx; - } - - /** - * Address of the column's null bitmap, or {@code 0} if the column has no NULL rows. - * Bitmap is {@code ceil(rowCount / 8)} bytes, LSB-first; bit = 1 means NULL. - */ - public long nullBitmapAddr(int col) { - return columnLayouts.getQuick(col).nullBitmapAddr; + return Unsafe.getUnsafe().getLong(l.valuesAddr + 8L * l.nonNullIdx[row]); } - /** - * Address of the column's packed non-null values in the payload buffer. - * Layout depends on the wire type: - *

    - *
  • Fixed-width (LONG, INT, DOUBLE, UUID, LONG256, etc.): contiguous values, index by {@code nonNullIndex(col)[row] * sizeBytes}.
  • - *
  • BOOLEAN: bit-packed, 8 values per byte, LSB-first; index by {@code nonNullIndex(col)[row]}.
  • - *
  • STRING / VARCHAR: points to the (N+1) × uint32 offsets array; use {@link #stringBytesAddr(int)} for the bytes region.
  • - *
  • GEOHASH: {@code ceil(precisionBits / 8)} bytes per value; index by {@code nonNullIndex(col)[row] * bytesPerValue}.
  • - *
  • DECIMAL64/128/256: the scale byte has already been consumed; this is the first unscaled value.
  • - *
  • SYMBOL: not meaningful — use {@link #getStrA} / {@link #getStrB} instead.
  • - *
  • ARRAY: not meaningful — use the per-row {@code arrayRowAddr} accessors (forthcoming).
  • - *
- */ - public long valuesAddr(int col) { - return columnLayouts.getQuick(col).valuesAddr; + public int getRowCount() { + return rowCount; } /** - * For STRING / VARCHAR columns, the address of the concatenated UTF-8 bytes - * (immediately after the offsets array pointed to by {@link #valuesAddr}). - * Combined with the offsets array, lets you read every string without - * going through {@link #getStrA}. + * Returns a SHORT value without type dispatch. Caller must know the column is SHORT. */ - public long stringBytesAddr(int col) { - return columnLayouts.getQuick(col).stringBytesAddr; - } - - public int getRowCount() { - return rowCount; + public short getShortValue(int col, int row) { + QwpColumnLayout l = columnLayouts.getQuick(col); + if (isLayoutNull(l, row)) return 0; + return Unsafe.getUnsafe().getShort(l.valuesAddr + 2L * l.nonNullIdx[row]); } /** @@ -426,18 +443,57 @@ public DirectUtf8Sequence getVarcharB(int col, int row) { /** * True if the cell is NULL on the wire. *

- * Note on type-specific sentinels (see {@code docs/QWP_EGRESS_EXTENSION.md} §11.5): - * QuestDB stores NULL as a sentinel value for several types — {@code Long.MIN_VALUE} + * Note on type-specific sentinels (see {@code docs/QWP_EGRESS_EXTENSION.md} sec 11.5): + * QuestDB stores NULL as a sentinel value for several types -- {@code Long.MIN_VALUE} * for LONG/INT/etc., {@code 0.0.0.0} for IPv4, {@code -1} for GEOHASH, and crucially * {@code NaN} for FLOAT and DOUBLE. Egress preserves these conventions: a row carrying * NaN in a DOUBLE column will return {@code true} from this method. Callers who need - * to distinguish "real NaN" from "explicit NULL" cannot do so over the wire — both + * to distinguish "real NaN" from "explicit NULL" cannot do so over the wire -- both * map to the same null bitmap bit. */ public boolean isNull(int col, int row) { return isLayoutNull(columnLayouts.getQuick(col), row); } + /** + * Number of non-null rows in this column, i.e. the count of entries in the + * dense values array pointed to by {@link #valuesAddr(int)}. + */ + public int nonNullCount(int col) { + return columnLayouts.getQuick(col).nonNullCount; + } + + /** + * Per-row lookup table. {@code result[row]} is the dense index within the + * column's non-null values, or -1 if the row is NULL. Array length equals + * {@link #getRowCount()}. Valid only during the current {@code onBatch} + * callback; do not retain. + */ + public int[] nonNullIndex(int col) { + return columnLayouts.getQuick(col).nonNullIdx; + } + + public long requestId() { + return requestId; + } + + /** + * Address of the column's packed non-null values in the payload buffer. + * Layout depends on the wire type: + *

    + *
  • Fixed-width (LONG, INT, DOUBLE, UUID, LONG256, etc.): contiguous values, index by {@code nonNullIndex(col)[row] * sizeBytes}.
  • + *
  • BOOLEAN: bit-packed, 8 values per byte, LSB-first; index by {@code nonNullIndex(col)[row]}.
  • + *
  • STRING / VARCHAR: points to the (N+1) x uint32 offsets array; use for the bytes region.
  • + *
  • GEOHASH: {@code ceil(precisionBits / 8)} bytes per value; index by {@code nonNullIndex(col)[row] * bytesPerValue}.
  • + *
  • DECIMAL64/128/256: the scale byte has already been consumed; this is the first unscaled value.
  • + *
  • SYMBOL: not meaningful -- use {@link #getStrA} / {@link #getStrB} instead.
  • + *
  • ARRAY: not meaningful -- use the per-row {@code arrayRowAddr} accessors (forthcoming).
  • + *
+ */ + public long valuesAddr(int col) { + return columnLayouts.getQuick(col).valuesAddr; + } + /** * Fast null check once the layout is in hand. Inlining pattern used by all the * typed accessors: load layout once, check bitmap, read value. Eliminates the @@ -449,8 +505,20 @@ private static boolean isLayoutNull(QwpColumnLayout l, int row) { return (bm & (1 << (row & 7))) != 0; } - public long requestId() { - return requestId; + /** + * Resolves the {@code (col, row)} cell for a BINARY column and points the supplied + * slice at the underlying bytes in the payload buffer. Returns {@code null} for NULL + * rows or if the column is not BINARY. + */ + private io.questdb.client.std.bytes.DirectByteSequence lookupBinaryBytes( + int col, int row, io.questdb.client.std.bytes.DirectByteSlice view) { + if (isNull(col, row)) return null; + QwpColumnLayout l = columnLayouts.getQuick(col); + if (l.info.wireType != QwpConstants.TYPE_BINARY) return null; + int denseIdx = l.nonNullIdx[row]; + int startOff = Unsafe.getUnsafe().getInt(l.valuesAddr + 4L * denseIdx); + int endOff = Unsafe.getUnsafe().getInt(l.valuesAddr + 4L * (denseIdx + 1)); + return view.of(l.stringBytesAddr + startOff, endOff - startOff); } /** diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpColumnLayout.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpColumnLayout.java index 72ceb9b8..3370a113 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpColumnLayout.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpColumnLayout.java @@ -82,7 +82,7 @@ public class QwpColumnLayout { /** * SYMBOL: per-row dictionary ID. Sized to {@code rowCount}; NULL rows are - * left with stale values — use {@link #nonNullIdx}/null-check first. + * left with stale values -- use {@link #nonNullIdx}/null-check first. */ int[] symbolRowIds; @@ -97,7 +97,7 @@ public class QwpColumnLayout { int[] arrayRowLen; /** - * Absolute address of the first byte after this column's data — used to walk to the next column. + * Absolute address of the first byte after this column's data -- used to walk to the next column. */ long nextAddr; diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpEgressIoThread.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpEgressIoThread.java index ba4eaa07..62ad41ed 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpEgressIoThread.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpEgressIoThread.java @@ -28,7 +28,6 @@ import io.questdb.client.cutlass.http.client.WebSocketFrameHandler; import io.questdb.client.cutlass.qwp.protocol.QwpConstants; import io.questdb.client.std.Misc; -import io.questdb.client.std.ObjList; import io.questdb.client.std.Unsafe; import java.nio.charset.StandardCharsets; @@ -45,26 +44,23 @@ * A small pool of {@link QwpBatchBuffer} instances (default: 4) holds decoded * batches in flight. When the pool is exhausted the I/O thread blocks on * {@link #freeBuffers} until the user releases a buffer. This gives natural - * back-pressure — if the consumer is slow, the I/O thread stops reading and + * back-pressure -- if the consumer is slow, the I/O thread stops reading and * the kernel's TCP window closes on the server side. */ public class QwpEgressIoThread implements Runnable, WebSocketFrameHandler { private static final int DEFAULT_BUFFER_CAPACITY = 64 * 1024; private static final long POLL_TIMEOUT_MS = 100; - // Pool of pre-allocated buffers. I/O thread takes, user thread releases. - private final BlockingQueue freeBuffers; private final QwpResultBatchDecoder decoder = new QwpResultBatchDecoder(); // Events delivered from I/O thread to user thread (RESULT_BATCH / RESULT_END / QUERY_ERROR). private final BlockingQueue events; + // Pool of pre-allocated buffers. I/O thread takes, user thread releases. + private final BlockingQueue freeBuffers; // Single-slot request queue (Phase-1 allows one in-flight query). private final BlockingQueue requests = new ArrayBlockingQueue<>(1); private final NativeBufferWriter sendScratch = new NativeBufferWriter(); private final WebSocketClient wsClient; - // Per-query state; accessed only from the I/O thread. - private long currentRequestId; private boolean currentQueryDone; - private volatile Throwable fatalError; private volatile boolean shutdown; public QwpEgressIoThread(WebSocketClient wsClient, int bufferPoolSize) { @@ -77,11 +73,31 @@ public QwpEgressIoThread(WebSocketClient wsClient, int bufferPoolSize) { } /** - * Releases a buffer back to the I/O thread pool. Call after the user - * handler finishes processing a {@code KIND_BATCH} event. + * Decodes a QUERY_ERROR payload into a {@link QueryEvent}. Visible for testing. + * Bound-checks msgLen against the actual payload: a hostile or buggy server + * can encode msgLen=0xFFFF with a tiny payload, which would otherwise read + * up to 65 KiB of native memory beyond the frame and surface it to the user + * callback as a String. */ - public void releaseBuffer(QwpBatchBuffer buffer) { - freeBuffers.offer(buffer); + public static QueryEvent decodeError(long payload, int payloadLen) { + long payloadEnd = payload + payloadLen; + long p = payload + QwpConstants.HEADER_SIZE + 1 + 8; + if (p + 1 + 2 > payloadEnd) { + return new QueryEvent().asError((byte) 0, "QUERY_ERROR frame truncated before msg_len"); + } + byte status = Unsafe.getUnsafe().getByte(p); + p += 1; + int msgLen = Unsafe.getUnsafe().getShort(p) & 0xFFFF; + p += 2; + if (p + msgLen > payloadEnd) { + return new QueryEvent().asError((byte) 0, + "QUERY_ERROR msg_len " + msgLen + " exceeds frame remainder " + (payloadEnd - p)); + } + byte[] bytes = new byte[msgLen]; + for (int i = 0; i < msgLen; i++) { + bytes[i] = Unsafe.getUnsafe().getByte(p + i); + } + return new QueryEvent().asError(status, new String(bytes, StandardCharsets.UTF_8)); } @Override @@ -117,6 +133,14 @@ public void onClose(int code, String reason) { } } + /** + * Releases a buffer back to the I/O thread pool. Call after the user + * handler finishes processing a {@code KIND_BATCH} event. + */ + public void releaseBuffer(QwpBatchBuffer buffer) { + freeBuffers.offer(buffer); + } + @Override public void run() { try { @@ -125,11 +149,11 @@ public void run() { try { req = requests.poll(POLL_TIMEOUT_MS, TimeUnit.MILLISECONDS); } catch (InterruptedException ie) { - return; + break; } if (req == null) continue; - currentRequestId = req.requestId; + // Per-query state; accessed only from the I/O thread. currentQueryDone = false; sendQueryRequest(req); @@ -139,20 +163,23 @@ public void run() { } } } catch (Throwable t) { - fatalError = t; - emitError((byte) 0, "I/O thread failure: " + t.getMessage()); + emitErrorBlocking((byte) 0, "I/O thread failure: " + t.getMessage()); + } finally { + // Wake any user thread blocked on events.take(). Without this, a close() + // (or any abnormal exit) while a user thread is mid-execute() would let + // takeEvent() block forever -- once the I/O thread is gone, no further + // events arrive on the queue. + if (!currentQueryDone) { + emitErrorBlocking((byte) 0, shutdown + ? "I/O thread shut down with query in flight" + : "I/O thread terminated with query in flight"); + currentQueryDone = true; + } } } /** - * Blocking pop of the next event. Called by the user thread during {@code execute()}. - */ - public QueryEvent takeEvent() throws InterruptedException { - return events.take(); - } - - /** - * Signals shutdown. Does not join the thread — caller handles that. + * Signals shutdown. Does not join the thread -- caller handles that. */ public void shutdown() { shutdown = true; @@ -166,28 +193,15 @@ public void submitQuery(String sql, long requestId) throws InterruptedException } /** - * Frees native scratch owned by the pool. Call after the thread has terminated. + * Blocking pop of the next event. Called by the user thread during {@code execute()}. */ - void closePool() { - Misc.free(sendScratch); - for (QwpBatchBuffer b : freeBuffers) { - b.close(); - } - freeBuffers.clear(); + public QueryEvent takeEvent() throws InterruptedException { + return events.take(); } private void decodeAndEmitError(long payload, int payloadLen) { - // Body: msg_kind(1) + requestId(8) + status(1) + msgLen(u16) + msgBytes - long p = payload + QwpConstants.HEADER_SIZE + 1 + 8; - byte status = Unsafe.getUnsafe().getByte(p); - p += 1; - int msgLen = Unsafe.getUnsafe().getShort(p) & 0xFFFF; - p += 2; - byte[] bytes = new byte[msgLen]; - for (int i = 0; i < msgLen; i++) { - bytes[i] = Unsafe.getUnsafe().getByte(p + i); - } - events.offer(new QueryEvent().asError(status, new String(bytes, StandardCharsets.UTF_8))); + QueryEvent ev = decodeError(payload, payloadLen); + events.offer(ev); } /** @@ -215,6 +229,19 @@ private void emitError(byte status, String message) { events.offer(new QueryEvent().asError(status, message)); } + /** + * Like {@link #emitError} but blocks until the event is enqueued. Used on shutdown + * and fatal-error paths where dropping the event would leave the user thread + * blocked on {@link #takeEvent} indefinitely. + */ + private void emitErrorBlocking(byte status, String message) { + try { + events.put(new QueryEvent().asError(status, message)); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + } + } + private void handleResultBatch(long payloadPtr, int payloadLen) { QwpBatchBuffer buf; try { @@ -252,6 +279,36 @@ private void sendQueryRequest(QueryRequest req) { sendScratch.reset(); } + /** + * Frees native scratch owned by the pool. Call after the thread has terminated. + *

+ * Drains any unconsumed batch events still in the events queue and closes + * their buffers. Without this, a close() that races with an in-flight query + * would leak the {@link QwpBatchBuffer} native scratches that were enqueued + * but never consumed. + *

+ * Pushes a final sentinel error onto the events queue so any user thread + * blocked on {@link #takeEvent} (or that returns from a handler after the + * pool has been drained) wakes up with a clear error rather than blocking + * forever on an empty queue. + */ + void closePool() { + Misc.free(sendScratch); + QueryEvent ev; + while ((ev = events.poll()) != null) { + if (ev.kind == QueryEvent.KIND_BATCH && ev.buffer != null) { + ev.buffer.close(); + } + } + for (QwpBatchBuffer b : freeBuffers) { + b.close(); + } + freeBuffers.clear(); + // The events queue capacity is bufferPoolSize + 2 with no consumer competing + // for slots after the I/O thread has joined, so offer is guaranteed to succeed. + events.offer(new QueryEvent().asError((byte) 0, "QwpQueryClient closed")); + } + private static final class QueryRequest { final long requestId; final String sql; diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpQueryClient.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpQueryClient.java index 26246c87..1d3296a4 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpQueryClient.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpQueryClient.java @@ -82,15 +82,15 @@ private QwpQueryClient(CharSequence host, int port) { *

* Supported schemas: *

    - *
  • {@code ws::} — plain WebSocket (matches QWP egress today; TLS not yet supported).
  • + *
  • {@code ws::} -- plain WebSocket (matches QWP egress today; TLS not yet supported).
  • *
* Supported keys: *
    - *
  • {@code addr=host[:port]} — required. Default port is {@value #DEFAULT_WS_PORT}.
  • - *
  • {@code path=/read/v1} — egress endpoint. Default {@value #DEFAULT_ENDPOINT_PATH}.
  • - *
  • {@code auth=} — sent as the HTTP {@code Authorization} header during the upgrade handshake.
  • - *
  • {@code client_id=} — sent as the {@code X-QWP-Client-Id} header.
  • - *
  • {@code buffer_pool_size=N} — depth of the I/O thread's batch buffer pool. Default 4.
  • + *
  • {@code addr=host[:port]} -- required. Default port is {@value #DEFAULT_WS_PORT}.
  • + *
  • {@code path=/read/v1} -- egress endpoint. Default {@value #DEFAULT_ENDPOINT_PATH}.
  • + *
  • {@code auth=} -- sent as the HTTP {@code Authorization} header during the upgrade handshake.
  • + *
  • {@code client_id=} -- sent as the {@code X-QWP-Client-Id} header.
  • + *
  • {@code buffer_pool_size=N} -- depth of the I/O thread's batch buffer pool. Default 4.
  • *
* Examples: *
@@ -195,7 +195,7 @@ public static QwpQueryClient newPlainText(CharSequence host, int port) {
      * the buffer pool and close the underlying socket.
      * 

* If the I/O thread fails to exit within {@link #SHUTDOWN_JOIN_MS} (default 5 s), this - * method does not free the buffer pool or close the WebSocket — both are + * method does not free the buffer pool or close the WebSocket -- both are * still in use by the thread, and freeing them would race into a JVM-killing * use-after-free. The thread is a daemon, so the JVM still exits normally; the * resources leak for the lifetime of the process. A warning is recorded by setting @@ -211,17 +211,17 @@ public void close() { // Wake the thread from any blocking poll / recv so it sees the shutdown flag promptly. if (ioThreadHandle != null) { ioThreadHandle.interrupt(); - boolean joined = false; + boolean joined; try { ioThreadHandle.join(SHUTDOWN_JOIN_MS); joined = !ioThreadHandle.isAlive(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); - // Don't free anything — preserve clean shutdown semantics on the next attempt. + // Don't free anything -- preserve clean shutdown semantics on the next attempt. return; } if (!joined) { - // Daemon thread is still running — buffer pool and WebSocketClient may + // Daemon thread is still running -- buffer pool and WebSocketClient may // be in use. Leak them rather than risk a SIGSEGV by freeing under it. lastCloseTimedOut = true; ioThread = null; @@ -283,17 +283,27 @@ public void execute(String sql, QwpColumnBatchHandler handler) { if (!connected) { throw new IllegalStateException("QwpQueryClient not connected; call connect() first"); } + // Cache the I/O thread reference at entry: close() may null the field while + // we are inside this loop, so reading the field per-iteration would NPE + // exactly when the user is mid-execute() and close() races. The queue and + // pool the cached reference owns are still drained safely by closePool() + // before close() returns. + QwpEgressIoThread io = ioThread; + if (io == null) { + handler.onError((byte) 0, "QwpQueryClient is closed"); + return; + } long requestId = nextRequestId++; try { - ioThread.submitQuery(sql, requestId); + io.submitQuery(sql, requestId); while (true) { - QueryEvent ev = ioThread.takeEvent(); + QueryEvent ev = io.takeEvent(); switch (ev.kind) { case QueryEvent.KIND_BATCH: try { handler.onBatch(ev.buffer.batch); } finally { - ioThread.releaseBuffer(ev.buffer); + io.releaseBuffer(ev.buffer); } break; case QueryEvent.KIND_END: diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpResultBatchDecoder.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpResultBatchDecoder.java index d55988c6..7ba34f3f 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpResultBatchDecoder.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpResultBatchDecoder.java @@ -34,7 +34,7 @@ /** * Zero-alloc (after warmup) decoder for inbound QWP egress {@code RESULT_BATCH} frames. *

- * The decoder parses the payload in-place — no values are copied out of the + * The decoder parses the payload in-place -- no values are copied out of the * WebSocket receive buffer. It maintains pooled {@link QwpColumnLayout} slots, * per-column {@code int[]} index arrays, and per-column {@link DirectUtf8String} * dict entries that are reused across batches. After the connection has seen @@ -47,7 +47,30 @@ */ public class QwpResultBatchDecoder { - // Connection-scoped state (safe to share across buffers — reused across batches + /** + * Cap on per-row ARRAY element count. 8 bytes per element x this ~ 256 MB max payload, + * which fits in {@code int} once {@code rowEnd - p} is computed. A malicious or buggy + * server cannot push a negative or wrap-around length past this guard. + */ + private static final long MAX_ARRAY_ELEMENTS = (Integer.MAX_VALUE - 1024) / 8L; + /** + * Hard cap on {@code row_count} per batch. Matches the server's MAX_ROWS_PER_BATCH. + * A hostile server could otherwise encode row_count = Integer.MAX_VALUE; ensureIntArray + * would then try to allocate an {@code int[Integer.MAX_VALUE]} (~8 GB) before any + * wire-length bounds check fires. Cap two orders of magnitude above the batch size + * to leave head-room for future server-side batch enlargement without breaking clients. + */ + private static final int MAX_ROWS_PER_BATCH = 1_048_576; + /** + * Hard cap on registered schema ids per connection. Matches + * {@code QwpConstants.DEFAULT_MAX_SCHEMAS_PER_CONNECTION} on the server side. + * Capping protects the client from a hostile or buggy server that could + * otherwise force unbounded {@code schemaRegistry} growth (or AIOOBE on a + * negative schema id) by encoding {@code schemaId = Integer.MAX_VALUE} (or + * a negative varint that long-to-int casts negative). + */ + private static final int MAX_SCHEMAS_PER_CONNECTION = 65_535; + // Connection-scoped state (safe to share across buffers -- reused across batches // of the same query and across queries on the same connection). // Registry indexed by schemaId. null = not registered. Schema ids are server-assigned // and small (monotonic from 0). @@ -57,13 +80,6 @@ public class QwpResultBatchDecoder { private long varintPos; private long varintValue; - /** - * Clears the per-connection schema registry. Call when reconnecting. - */ - public void clearRegistry() { - schemaRegistry.clear(); - } - /** * Decodes the RESULT_BATCH frame whose payload has been copied into {@code buffer}. * Populates {@code buffer.batch} and {@code buffer.layoutPool}. The resulting @@ -73,6 +89,66 @@ public void decode(QwpBatchBuffer buffer) throws QwpDecodeException { decodePayload(buffer, buffer.getScratchAddr(), buffer.getPayloadLen()); } + // Pool helpers + + private static long advanceFixed(QwpColumnLayout layout, long p, long limit, int sizeBytes) throws QwpDecodeException { + layout.valuesAddr = p; + long total = (long) sizeBytes * layout.nonNullCount; + if (p + total > limit) throw new QwpDecodeException("truncated fixed-width column"); + return p + total; + } + + private static QwpColumnLayout borrowLayout(ObjList layoutPool, int colIdx) { + while (layoutPool.size() <= colIdx) { + layoutPool.add(new QwpColumnLayout()); + } + return layoutPool.getQuick(colIdx); + } + + private static int[] ensureIntArray(int[] current, int size) { + if (current != null && current.length >= size) return current; + return new int[Math.max(size, current == null ? 16 : current.length * 2)]; + } + + private static long[] ensureLongArray(long[] current, int size) { + if (current != null && current.length >= size) return current; + return new long[Math.max(size, current == null ? 16 : current.length * 2)]; + } + + // Varint / string helpers + + /** + * STRING / VARCHAR: the offsets array is (nonNullCount+1) x uint32 starting at {@code p}, + * followed by the concatenated UTF-8 bytes. + */ + private static long parseStringColumn(QwpColumnLayout layout, long p, long limit) throws QwpDecodeException { + int nonNull = layout.nonNullCount; + long offsetsSize = 4L * (nonNull + 1); + if (p + offsetsSize > limit) throw new QwpDecodeException("truncated string offsets"); + layout.valuesAddr = p; + layout.stringBytesAddr = p + offsetsSize; + int totalBytes = nonNull == 0 ? 0 : Unsafe.getUnsafe().getInt(p + 4L * nonNull); + // totalBytes is signed int32 read from the wire. A negative value passes the + // "addr + totalBytes > limit" check (the sum stays below limit) and would + // return a position before stringBytesAddr -- subsequent column parsing would + // then read native memory backwards. Reject it explicitly. + if (totalBytes < 0 || layout.stringBytesAddr + totalBytes > limit) { + throw new QwpDecodeException("invalid string column total bytes: " + totalBytes); + } + return layout.stringBytesAddr + totalBytes; + } + + private static String readUtf8(long p, long len) { + byte[] bytes = new byte[(int) len]; + for (int i = 0; i < len; i++) { + bytes[i] = Unsafe.getUnsafe().getByte(p + i); + } + return new String(bytes, StandardCharsets.UTF_8); + } + + // Per-column parse: advances through wire bytes, populates layout pointers, + // precomputes nonNullIdx for O(1) per-row access. + private void decodePayload(QwpBatchBuffer buffer, long payload, int payloadLen) throws QwpDecodeException { if (payloadLen < QwpConstants.HEADER_SIZE + 10) { throw new QwpDecodeException("RESULT_BATCH payload too short: " + payloadLen); @@ -108,9 +184,19 @@ private void decodePayload(QwpBatchBuffer buffer, long payload, int payloadLen) p += nameLen; decodeVarint(p, limit); + // Reject row counts that would force multi-GB allocations in ensureIntArray/ensureLongArray + // before the per-column bounds checks fire. A hostile varint with the high bit set also + // casts negative, which would silently flip bitmapBytes = (rowCount + 7) >>> 3 into a huge + // positive int via unsigned shift. + if (varintValue < 0 || varintValue > MAX_ROWS_PER_BATCH) { + throw new QwpDecodeException("row_count out of range: " + varintValue); + } int rowCount = (int) varintValue; p = varintPos; decodeVarint(p, limit); + if (varintValue < 0 || varintValue > QwpConstants.MAX_COLUMNS_PER_TABLE) { + throw new QwpDecodeException("column_count out of range: " + varintValue); + } int columnCount = (int) varintValue; p = varintPos; @@ -118,6 +204,12 @@ private void decodePayload(QwpBatchBuffer buffer, long payload, int payloadLen) if (p >= limit) throw new QwpDecodeException("truncated schema mode"); byte schemaMode = Unsafe.getUnsafe().getByte(p++); decodeVarint(p, limit); + // Reject schema ids that wouldn't fit in our registry (or that cast negative + // from a hostile high varint). Without this guard, ensureSchemaSlot would + // either OOM appending billions of nulls or AIOOBE on a negative index. + if (varintValue < 0 || varintValue >= MAX_SCHEMAS_PER_CONNECTION) { + throw new QwpDecodeException("schema_id out of range: " + varintValue); + } int schemaId = (int) varintValue; p = varintPos; @@ -156,23 +248,25 @@ private void decodePayload(QwpBatchBuffer buffer, long payload, int payloadLen) } } - // Pool helpers - - private static QwpColumnLayout borrowLayout(ObjList layoutPool, int colIdx) { - while (layoutPool.size() <= colIdx) { - layoutPool.add(new QwpColumnLayout()); + /** + * Decodes a varint starting at {@code p}. Stores the decoded value in + * {@link #varintValue} and the position just past the varint in + * {@link #varintPos}. Caller reads both before issuing the next varint call. + */ + private void decodeVarint(long p, long limit) throws QwpDecodeException { + long value = 0; + int shift = 0; + long cur = p; + while (true) { + if (cur >= limit) throw new QwpDecodeException("truncated varint"); + byte b = Unsafe.getUnsafe().getByte(cur++); + value |= (long) (b & 0x7F) << shift; + if ((b & 0x80) == 0) break; + shift += 7; + if (shift > 63) throw new QwpDecodeException("varint overflow"); } - return layoutPool.getQuick(colIdx); - } - - private static int[] ensureIntArray(int[] current, int size) { - if (current != null && current.length >= size) return current; - return new int[Math.max(size, current == null ? 16 : current.length * 2)]; - } - - private static long[] ensureLongArray(long[] current, int size) { - if (current != null && current.length >= size) return current; - return new long[Math.max(size, current == null ? 16 : current.length * 2)]; + varintValue = value; + varintPos = cur; } private ObjList ensureSchemaSlot(int schemaId, int columnCount) { @@ -198,72 +292,37 @@ private ObjList ensureSchemaSlot(int schemaId, int columnCo return slot; } - // Varint / string helpers - - /** - * Decodes a varint starting at {@code p}. Stores the decoded value in - * {@link #varintValue} and the position just past the varint in - * {@link #varintPos}. Caller reads both before issuing the next varint call. - */ - private void decodeVarint(long p, long limit) throws QwpDecodeException { - long value = 0; - int shift = 0; - long cur = p; - while (true) { - if (cur >= limit) throw new QwpDecodeException("truncated varint"); - byte b = Unsafe.getUnsafe().getByte(cur++); - value |= (long) (b & 0x7F) << shift; - if ((b & 0x80) == 0) break; - shift += 7; - if (shift > 63) throw new QwpDecodeException("varint overflow"); - } - varintValue = value; - varintPos = cur; - } - - private static String readUtf8(long p, long len) { - byte[] bytes = new byte[(int) len]; - for (int i = 0; i < len; i++) { - bytes[i] = Unsafe.getUnsafe().getByte(p + i); - } - return new String(bytes, StandardCharsets.UTF_8); - } - - // Per-column parse: advances through wire bytes, populates layout pointers, - // precomputes nonNullIdx for O(1) per-row access. - - /** - * Reads the null flag and bitmap, populates {@code layout.nullBitmapAddr} and - * {@code layout.nonNullCount}, and fills {@code layout.nonNullIdx[0..rowCount)} - * with dense indices (or -1 for NULL rows). Returns the position just past - * the null section. - */ - private long parseNullSection(QwpColumnLayout layout, int rowCount, long p, long limit) throws QwpDecodeException { - if (p >= limit) throw new QwpDecodeException("truncated null flag"); - byte flag = Unsafe.getUnsafe().getByte(p++); - layout.nonNullIdx = ensureIntArray(layout.nonNullIdx, rowCount); - if (flag == 0) { - layout.nullBitmapAddr = 0; - layout.nonNullCount = rowCount; - for (int i = 0; i < rowCount; i++) layout.nonNullIdx[i] = i; - return p; - } - int bitmapBytes = (rowCount + 7) >>> 3; - if (p + bitmapBytes > limit) throw new QwpDecodeException("truncated null bitmap"); - layout.nullBitmapAddr = p; - int denseIdx = 0; + private long parseArrayColumn(QwpColumnLayout layout, int rowCount, long p, long limit) throws QwpDecodeException { + layout.arrayRowAddr = ensureLongArray(layout.arrayRowAddr, rowCount); + layout.arrayRowLen = ensureIntArray(layout.arrayRowLen, rowCount); + layout.valuesAddr = p; for (int i = 0; i < rowCount; i++) { - int bi = i >>> 3; - int bit = i & 7; - byte bm = Unsafe.getUnsafe().getByte(p + bi); - if ((bm & (1 << bit)) != 0) { - layout.nonNullIdx[i] = -1; - } else { - layout.nonNullIdx[i] = denseIdx++; + if (layout.nonNullIdx[i] < 0) { + layout.arrayRowAddr[i] = 0; + layout.arrayRowLen[i] = 0; + continue; + } + if (p + 1 > limit) throw new QwpDecodeException("truncated ARRAY header"); + int nDims = Unsafe.getUnsafe().getByte(p) & 0xFF; + long headerEnd = p + 1 + 4L * nDims; + if (headerEnd > limit) throw new QwpDecodeException("truncated ARRAY dims"); + long elements = 1; + for (int d = 0; d < nDims; d++) { + int dl = Unsafe.getUnsafe().getInt(p + 1 + 4L * d); + if (dl < 0) throw new QwpDecodeException("ARRAY dim " + d + " is negative: " + dl); + elements *= dl; + if (elements > MAX_ARRAY_ELEMENTS) { + throw new QwpDecodeException("ARRAY element count exceeds limit (" + + elements + " > " + MAX_ARRAY_ELEMENTS + ")"); + } } + long rowEnd = headerEnd + 8L * elements; + if (rowEnd > limit) throw new QwpDecodeException("truncated ARRAY payload"); + layout.arrayRowAddr[i] = p; + layout.arrayRowLen[i] = (int) (rowEnd - p); + p = rowEnd; } - layout.nonNullCount = denseIdx; - return p + bitmapBytes; + return p; } private long parseColumn(QwpColumnLayout layout, int rowCount, long p, long limit) throws QwpDecodeException { @@ -277,7 +336,8 @@ private long parseColumn(QwpColumnLayout layout, int rowCount, long p, long limi } if (wt == QwpConstants.TYPE_BYTE) return advanceFixed(layout, p, limit, 1); if (wt == QwpConstants.TYPE_SHORT || wt == QwpConstants.TYPE_CHAR) return advanceFixed(layout, p, limit, 2); - if (wt == QwpConstants.TYPE_INT || wt == QwpConstants.TYPE_FLOAT) return advanceFixed(layout, p, limit, 4); + if (wt == QwpConstants.TYPE_INT || wt == QwpConstants.TYPE_FLOAT + || wt == QwpConstants.TYPE_IPv4) return advanceFixed(layout, p, limit, 4); if (wt == QwpConstants.TYPE_LONG || wt == QwpConstants.TYPE_DOUBLE || wt == QwpConstants.TYPE_DATE || wt == QwpConstants.TYPE_TIMESTAMP || wt == QwpConstants.TYPE_TIMESTAMP_NANOS) { @@ -300,7 +360,10 @@ private long parseColumn(QwpColumnLayout layout, int rowCount, long p, long limi layout.info.scale = Unsafe.getUnsafe().getByte(p++) & 0xFF; return advanceFixed(layout, p, limit, 32); } - if (wt == QwpConstants.TYPE_STRING || wt == QwpConstants.TYPE_VARCHAR) { + if (wt == QwpConstants.TYPE_STRING || wt == QwpConstants.TYPE_VARCHAR + || wt == QwpConstants.TYPE_BINARY) { + // STRING/VARCHAR/BINARY all share the (N+1) x uint32 offsets + concatenated bytes layout. + // BINARY differs only in that the bytes are opaque (no UTF-8 contract). return parseStringColumn(layout, p, limit); } if (wt == QwpConstants.TYPE_SYMBOL) { @@ -322,28 +385,38 @@ private long parseColumn(QwpColumnLayout layout, int rowCount, long p, long limi throw new QwpDecodeException("unsupported wire type 0x" + Integer.toHexString(wt & 0xFF)); } - private static long advanceFixed(QwpColumnLayout layout, long p, long limit, int sizeBytes) throws QwpDecodeException { - layout.valuesAddr = p; - long total = (long) sizeBytes * layout.nonNullCount; - if (p + total > limit) throw new QwpDecodeException("truncated fixed-width column"); - return p + total; - } - /** - * STRING / VARCHAR: the offsets array is (nonNullCount+1) × uint32 starting at {@code p}, - * followed by the concatenated UTF-8 bytes. + * Reads the null flag and bitmap, populates {@code layout.nullBitmapAddr} and + * {@code layout.nonNullCount}, and fills {@code layout.nonNullIdx[0..rowCount)} + * with dense indices (or -1 for NULL rows). Returns the position just past + * the null section. */ - private static long parseStringColumn(QwpColumnLayout layout, long p, long limit) throws QwpDecodeException { - int nonNull = layout.nonNullCount; - long offsetsSize = 4L * (nonNull + 1); - if (p + offsetsSize > limit) throw new QwpDecodeException("truncated string offsets"); - layout.valuesAddr = p; - layout.stringBytesAddr = p + offsetsSize; - int totalBytes = nonNull == 0 ? 0 : Unsafe.getUnsafe().getInt(p + 4L * nonNull); - if (layout.stringBytesAddr + totalBytes > limit) { - throw new QwpDecodeException("truncated string bytes"); + private long parseNullSection(QwpColumnLayout layout, int rowCount, long p, long limit) throws QwpDecodeException { + if (p >= limit) throw new QwpDecodeException("truncated null flag"); + byte flag = Unsafe.getUnsafe().getByte(p++); + layout.nonNullIdx = ensureIntArray(layout.nonNullIdx, rowCount); + if (flag == 0) { + layout.nullBitmapAddr = 0; + layout.nonNullCount = rowCount; + for (int i = 0; i < rowCount; i++) layout.nonNullIdx[i] = i; + return p; } - return layout.stringBytesAddr + totalBytes; + int bitmapBytes = (rowCount + 7) >>> 3; + if (p + bitmapBytes > limit) throw new QwpDecodeException("truncated null bitmap"); + layout.nullBitmapAddr = p; + int denseIdx = 0; + for (int i = 0; i < rowCount; i++) { + int bi = i >>> 3; + int bit = i & 7; + byte bm = Unsafe.getUnsafe().getByte(p + bi); + if ((bm & (1 << bit)) != 0) { + layout.nonNullIdx[i] = -1; + } else { + layout.nonNullIdx[i] = denseIdx++; + } + } + layout.nonNullCount = denseIdx; + return p + bitmapBytes; } /** @@ -384,50 +457,6 @@ private long parseSymbolColumn(QwpColumnLayout layout, int rowCount, long p, lon return p; } - /** - * DOUBLE_ARRAY / LONG_ARRAY: each non-null row stores nDims (u8) + dimLens (nDims × i32) - * + flattened values (8 bytes each). We precompute per-row (addr, len) for O(1) access. - */ - /** - * Cap on per-row ARRAY element count. 8 bytes per element × this ≈ 256 MB max payload, - * which fits in {@code int} once {@code rowEnd - p} is computed. A malicious or buggy - * server cannot push a negative or wrap-around length past this guard. - */ - private static final long MAX_ARRAY_ELEMENTS = (Integer.MAX_VALUE - 1024) / 8L; - - private long parseArrayColumn(QwpColumnLayout layout, int rowCount, long p, long limit) throws QwpDecodeException { - layout.arrayRowAddr = ensureLongArray(layout.arrayRowAddr, rowCount); - layout.arrayRowLen = ensureIntArray(layout.arrayRowLen, rowCount); - layout.valuesAddr = p; - for (int i = 0; i < rowCount; i++) { - if (layout.nonNullIdx[i] < 0) { - layout.arrayRowAddr[i] = 0; - layout.arrayRowLen[i] = 0; - continue; - } - if (p + 1 > limit) throw new QwpDecodeException("truncated ARRAY header"); - int nDims = Unsafe.getUnsafe().getByte(p) & 0xFF; - long headerEnd = p + 1 + 4L * nDims; - if (headerEnd > limit) throw new QwpDecodeException("truncated ARRAY dims"); - long elements = 1; - for (int d = 0; d < nDims; d++) { - int dl = Unsafe.getUnsafe().getInt(p + 1 + 4L * d); - if (dl < 0) throw new QwpDecodeException("ARRAY dim " + d + " is negative: " + dl); - elements *= dl; - if (elements > MAX_ARRAY_ELEMENTS) { - throw new QwpDecodeException("ARRAY element count exceeds limit (" - + elements + " > " + MAX_ARRAY_ELEMENTS + ")"); - } - } - long rowEnd = headerEnd + 8L * elements; - if (rowEnd > limit) throw new QwpDecodeException("truncated ARRAY payload"); - layout.arrayRowAddr[i] = p; - layout.arrayRowLen[i] = (int) (rowEnd - p); - p = rowEnd; - } - return p; - } - // Batch reset private void resetBatch( diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java index cc4f8d72..6c52ed62 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java @@ -1279,7 +1279,7 @@ private void flushPendingRows() { return; } - // Invalidate cached column references — table buffers will be reset below + // Invalidate cached column references -- table buffers will be reset below cachedTimestampColumn = null; cachedTimestampNanosColumn = null; @@ -1386,7 +1386,7 @@ private void flushSync() { return; } - // Invalidate cached column references — table buffers will be reset below + // Invalidate cached column references -- table buffers will be reset below cachedTimestampColumn = null; cachedTimestampNanosColumn = null; diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/protocol/QwpConstants.java b/core/src/main/java/io/questdb/client/cutlass/qwp/protocol/QwpConstants.java index 3c1423be..bf3e45cc 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/protocol/QwpConstants.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/protocol/QwpConstants.java @@ -71,6 +71,11 @@ public final class QwpConstants { * Schema mode: Schema reference (ID lookup). */ public static final byte SCHEMA_MODE_REFERENCE = 0x01; + /** + * Column type: BINARY (length-prefixed opaque bytes). + * Wire format: identical to VARCHAR — (N+1) x uint32 offsets + concatenated bytes. + */ + public static final byte TYPE_BINARY = 0x17; /** * Column type: BOOLEAN (1 bit per value, packed). */ @@ -123,6 +128,11 @@ public final class QwpConstants { * Column type: INT (int32, little-endian). */ public static final byte TYPE_INT = 0x04; + /** + * Column type: IPv4 (32-bit address). Wire format: 4 bytes LE, identical to INT. + * NULL is signalled via the standard null bitmap. + */ + public static final byte TYPE_IPv4 = 0x18; /** * Column type: LONG (int64, little-endian). */ @@ -191,8 +201,7 @@ private QwpConstants() { * @return size in bytes, 0 for bit-packed (BOOLEAN), or -1 for variable-width types */ public static int getFixedTypeSize(byte typeCode) { - int code = typeCode; - switch (code) { + switch ((int) typeCode) { case TYPE_BOOLEAN: return 0; // Special: bit-packed case TYPE_BYTE: @@ -230,9 +239,8 @@ public static int getFixedTypeSize(byte typeCode) { * @return type name */ public static String getTypeName(byte typeCode) { - int code = typeCode; String name; - switch (code) { + switch ((int) typeCode) { case TYPE_BOOLEAN: name = "BOOLEAN"; break; @@ -300,7 +308,7 @@ public static String getTypeName(byte typeCode) { name = "DECIMAL256"; break; default: - name = "UNKNOWN(" + code + ")"; + name = "UNKNOWN(" + (int) typeCode + ")"; break; } return name; @@ -313,22 +321,21 @@ public static String getTypeName(byte typeCode) { * @return true if fixed-width */ public static boolean isFixedWidthType(byte typeCode) { - int code = typeCode; - return code == TYPE_BOOLEAN || - code == TYPE_BYTE || - code == TYPE_SHORT || - code == TYPE_CHAR || - code == TYPE_INT || - code == TYPE_LONG || - code == TYPE_FLOAT || - code == TYPE_DOUBLE || - code == TYPE_TIMESTAMP || - code == TYPE_TIMESTAMP_NANOS || - code == TYPE_DATE || - code == TYPE_UUID || - code == TYPE_LONG256 || - code == TYPE_DECIMAL64 || - code == TYPE_DECIMAL128 || - code == TYPE_DECIMAL256; + return (int) typeCode == TYPE_BOOLEAN || + (int) typeCode == TYPE_BYTE || + (int) typeCode == TYPE_SHORT || + (int) typeCode == TYPE_CHAR || + (int) typeCode == TYPE_INT || + (int) typeCode == TYPE_LONG || + (int) typeCode == TYPE_FLOAT || + (int) typeCode == TYPE_DOUBLE || + (int) typeCode == TYPE_TIMESTAMP || + (int) typeCode == TYPE_TIMESTAMP_NANOS || + (int) typeCode == TYPE_DATE || + (int) typeCode == TYPE_UUID || + (int) typeCode == TYPE_LONG256 || + (int) typeCode == TYPE_DECIMAL64 || + (int) typeCode == TYPE_DECIMAL128 || + (int) typeCode == TYPE_DECIMAL256; } } diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpResultBatchDecoderHardeningTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpResultBatchDecoderHardeningTest.java new file mode 100644 index 00000000..e8ca6e60 --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpResultBatchDecoderHardeningTest.java @@ -0,0 +1,343 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +package io.questdb.client.test.cutlass.qwp.client; + +import io.questdb.client.cutlass.qwp.client.QueryEvent; +import io.questdb.client.cutlass.qwp.client.QwpBatchBuffer; +import io.questdb.client.cutlass.qwp.client.QwpDecodeException; +import io.questdb.client.cutlass.qwp.client.QwpEgressIoThread; +import io.questdb.client.cutlass.qwp.client.QwpResultBatchDecoder; +import io.questdb.client.cutlass.qwp.protocol.QwpConstants; +import io.questdb.client.std.MemoryTag; +import io.questdb.client.std.Unsafe; +import org.junit.Assert; +import org.junit.Test; + +/** + * Hardening tests for {@link QwpResultBatchDecoder} against malformed RESULT_BATCH + * frames from a hostile or buggy server. Each test crafts a wire payload directly + * in native memory and asserts that the decoder rejects it cleanly with a + * {@link QwpDecodeException} rather than reading out of bounds, growing the + * schema registry without bound, or returning negative offsets that propagate + * into accessors. + */ +public class QwpResultBatchDecoderHardeningTest { + + /** + * Regression for C5: a server-supplied {@code schema_id} above the per-connection + * cap must be rejected. Without the fix, {@code ensureSchemaSlot} would happily + * append nulls until OOM (or AIOOBE for negative ids cast from a high varint). + */ + @Test + public void testHugeSchemaIdIsRejected() { + QwpResultBatchDecoder decoder = new QwpResultBatchDecoder(); + QwpBatchBuffer buffer = new QwpBatchBuffer(256); + long staging = Unsafe.malloc(256, MemoryTag.NATIVE_DEFAULT); + try { + // schema_id = 1_000_000_000, well above the 65_535 cap. + int len = writeMinimalResultBatch(staging, /*schemaId=*/ 1_000_000_000L); + buffer.copyFromPayload(staging, len); + try { + decoder.decode(buffer); + Assert.fail("decoder must reject huge schema_id"); + } catch (QwpDecodeException expected) { + Assert.assertTrue("error message should mention schema_id: " + expected.getMessage(), + expected.getMessage().contains("schema_id")); + } + } finally { + Unsafe.free(staging, 256, MemoryTag.NATIVE_DEFAULT); + buffer.close(); + } + } + + /** + * Regression for C5: a varint that long-to-int casts to a negative value + * (a hostile high varint with the sign bit set after the cast) must be + * rejected, not silently passed to {@code getQuick(negativeIndex)}. + */ + @Test + public void testNegativeSchemaIdIsRejected() { + QwpResultBatchDecoder decoder = new QwpResultBatchDecoder(); + QwpBatchBuffer buffer = new QwpBatchBuffer(256); + long staging = Unsafe.malloc(256, MemoryTag.NATIVE_DEFAULT); + try { + // 5-byte varint encoding 0x80000000 (which casts to Integer.MIN_VALUE). + // varint bytes for 0x80000000: + // value bits 7..0: 0x00 -> byte: 0x80 (continuation) + // value bits 14..8: 0x00 -> byte: 0x80 + // value bits 21..15:0x00 -> byte: 0x80 + // value bits 28..22:0x00 -> byte: 0x80 + // value bits 35..29:0x08 -> byte: 0x08 (no continuation) + int len = writeMinimalResultBatchWithRawSchemaIdVarint( + staging, new byte[]{(byte) 0x80, (byte) 0x80, (byte) 0x80, (byte) 0x80, (byte) 0x08}); + buffer.copyFromPayload(staging, len); + try { + decoder.decode(buffer); + Assert.fail("decoder must reject huge/negative schema_id"); + } catch (QwpDecodeException expected) { + Assert.assertTrue("error message should mention schema_id: " + expected.getMessage(), + expected.getMessage().contains("schema_id")); + } + } finally { + Unsafe.free(staging, 256, MemoryTag.NATIVE_DEFAULT); + buffer.close(); + } + } + + /** + * Regression for C3: a hostile or buggy server can send a QUERY_ERROR frame + * that claims a 65535-byte message but supplies a tiny payload. Without the + * fix, the client reads up to ~65 KiB of native memory beyond the frame and + * surfaces it to the user callback as a String. With the fix, the client + * detects the overrun and reports a bounded error. + */ + @Test + public void testQueryErrorMsgLenOverrunIsRejected() { + // Frame contents: + // 12 bytes header (uninspected by decodeError) + // 1 byte msg_kind + // 8 bytes request_id + // 1 byte status + // 2 bytes msgLen (we set 0xFFFF) + // 0 bytes of actual message body + // Total payload: 24 bytes; msgLen would otherwise force reading 65535 bytes. + int payloadLen = 12 + 1 + 8 + 1 + 2; + long buf = Unsafe.malloc(payloadLen, MemoryTag.NATIVE_DEFAULT); + try { + // Zero out + for (int i = 0; i < payloadLen; i++) Unsafe.getUnsafe().putByte(buf + i, (byte) 0); + // Write an obviously bogus msgLen at the right offset (header + msg_kind + reqId + status). + long msgLenOffset = buf + 12 + 1 + 8 + 1; + Unsafe.getUnsafe().putShort(msgLenOffset, (short) 0xFFFF); + + QueryEvent ev = QwpEgressIoThread.decodeError(buf, payloadLen); + Assert.assertEquals(QueryEvent.KIND_ERROR, ev.kind); + Assert.assertNotNull(ev.errorMessage); + Assert.assertTrue("error must mention msg_len overrun: " + ev.errorMessage, + ev.errorMessage.contains("msg_len") && ev.errorMessage.contains("exceeds")); + } finally { + Unsafe.free(buf, payloadLen, MemoryTag.NATIVE_DEFAULT); + } + } + + /** + * Regression for C3: a QUERY_ERROR frame with valid msgLen and bytes must be + * decoded correctly. Pins the wire format so the rejection test above is + * confirming a real defensive guard, not a broken decoder. + */ + @Test + public void testQueryErrorValidMessageDecodes() { + byte[] msgBytes = "boom".getBytes(java.nio.charset.StandardCharsets.UTF_8); + int payloadLen = 12 + 1 + 8 + 1 + 2 + msgBytes.length; + long buf = Unsafe.malloc(payloadLen, MemoryTag.NATIVE_DEFAULT); + try { + for (int i = 0; i < payloadLen; i++) Unsafe.getUnsafe().putByte(buf + i, (byte) 0); + long statusOffset = buf + 12 + 1 + 8; + Unsafe.getUnsafe().putByte(statusOffset, (byte) 0x05); + long msgLenOffset = statusOffset + 1; + Unsafe.getUnsafe().putShort(msgLenOffset, (short) msgBytes.length); + long bytesOffset = msgLenOffset + 2; + for (int i = 0; i < msgBytes.length; i++) { + Unsafe.getUnsafe().putByte(bytesOffset + i, msgBytes[i]); + } + + QueryEvent ev = QwpEgressIoThread.decodeError(buf, payloadLen); + Assert.assertEquals(QueryEvent.KIND_ERROR, ev.kind); + Assert.assertEquals((byte) 0x05, ev.errorStatus); + Assert.assertEquals("boom", ev.errorMessage); + } finally { + Unsafe.free(buf, payloadLen, MemoryTag.NATIVE_DEFAULT); + } + } + + /** + * Regression for C4: STRING column with a negative {@code totalBytes} field. + * Without the fix, "stringBytesAddr + totalBytes > limit" passes (the sum + * stays below limit), and {@code parseStringColumn} returns a position + * before {@code stringBytesAddr} — subsequent column parsing reads native + * memory backwards. + */ + @Test + public void testStringColumnNegativeTotalBytesIsRejected() { + QwpResultBatchDecoder decoder = new QwpResultBatchDecoder(); + QwpBatchBuffer buffer = new QwpBatchBuffer(512); + long staging = Unsafe.malloc(512, MemoryTag.NATIVE_DEFAULT); + try { + int len = writeStringResultBatch(staging, /*nonNull=*/ 1, /*totalBytes=*/ -1); + buffer.copyFromPayload(staging, len); + try { + decoder.decode(buffer); + Assert.fail("decoder must reject negative totalBytes"); + } catch (QwpDecodeException expected) { + Assert.assertTrue("error message should describe invalid total bytes: " + expected.getMessage(), + expected.getMessage().contains("total bytes")); + } + } finally { + Unsafe.free(staging, 512, MemoryTag.NATIVE_DEFAULT); + buffer.close(); + } + } + + /** + * Sanity: with a sane (non-negative, in-range) {@code totalBytes}, the same + * wire layout decodes successfully (no exception). Pins the wire format so + * the negative-value rejection above is testing the right code path. + */ + @Test + public void testStringColumnValidTotalBytesIsAccepted() throws QwpDecodeException { + QwpResultBatchDecoder decoder = new QwpResultBatchDecoder(); + QwpBatchBuffer buffer = new QwpBatchBuffer(512); + long staging = Unsafe.malloc(512, MemoryTag.NATIVE_DEFAULT); + try { + int len = writeStringResultBatch(staging, /*nonNull=*/ 1, /*totalBytes=*/ 5); + buffer.copyFromPayload(staging, len); + decoder.decode(buffer); + // no exception => the decoder accepts the valid wire bytes + } finally { + Unsafe.free(staging, 512, MemoryTag.NATIVE_DEFAULT); + buffer.close(); + } + } + + // ----------------------------------------------------------------------- + // Wire-format helpers: write a minimal RESULT_BATCH frame to native memory. + // Layout (matches QwpResultBatchDecoder.decodePayload + parseStringColumn): + // header (12 bytes) + // msg_kind (0x11) + // request_id (8 bytes) + // batch_seq (varint) + // table-block: + // name_len (varint), name bytes (none) + // row_count (varint) + // column_count (varint) + // schema_mode (1 byte) + schema_id (varint) + // [if FULL] per column: name_len varint, name bytes, wire_type byte + // per column: null_flag byte (+optional bitmap), then column body + // ----------------------------------------------------------------------- + + private static long putByte(long p, byte v) { + Unsafe.getUnsafe().putByte(p, v); + return p + 1; + } + + private static long putInt(long p, int v) { + Unsafe.getUnsafe().putInt(p, v); + return p + 4; + } + + private static long putLong(long p, long v) { + Unsafe.getUnsafe().putLong(p, v); + return p + 8; + } + + private static long putVarint(long p, long value) { + while ((value & ~0x7FL) != 0) { + Unsafe.getUnsafe().putByte(p++, (byte) ((value & 0x7F) | 0x80)); + value >>>= 7; + } + Unsafe.getUnsafe().putByte(p++, (byte) (value & 0x7F)); + return p; + } + + private static int writeMinimalResultBatch(long buf, long schemaId) { + long p = buf; + p = putInt(p, QwpConstants.MAGIC_MESSAGE); + p = putByte(p, QwpConstants.VERSION_1); + p = putByte(p, (byte) 0); + p = putByte(p, (byte) 0); + p = putByte(p, (byte) 1); + p = putInt(p, 0); + p = putByte(p, (byte) 0x11); + p = putLong(p, 1L); + p = putVarint(p, 0L); // batch_seq + p = putVarint(p, 0L); // table_name_len + p = putVarint(p, 0L); // row_count = 0 (no body needed) + p = putVarint(p, 0L); // column_count = 0 + p = putByte(p, QwpConstants.SCHEMA_MODE_FULL); + p = putVarint(p, schemaId); + return (int) (p - buf); + } + + /** + * Variant that writes a custom raw varint sequence for schema_id. Lets us + * inject a multi-byte varint that decodes to a value with the int sign bit + * set after long-to-int truncation. + */ + private static int writeMinimalResultBatchWithRawSchemaIdVarint(long buf, byte[] schemaIdVarint) { + long p = buf; + p = putInt(p, QwpConstants.MAGIC_MESSAGE); + p = putByte(p, QwpConstants.VERSION_1); + p = putByte(p, (byte) 0); + p = putByte(p, (byte) 0); + p = putByte(p, (byte) 1); + p = putInt(p, 0); + p = putByte(p, (byte) 0x11); + p = putLong(p, 1L); + p = putVarint(p, 0L); + p = putVarint(p, 0L); + p = putVarint(p, 0L); + p = putVarint(p, 0L); + p = putByte(p, QwpConstants.SCHEMA_MODE_FULL); + for (byte b : schemaIdVarint) p = putByte(p, b); + return (int) (p - buf); + } + + private static int writeStringResultBatch(long buf, int nonNull, int totalBytes) { + long p = buf; + // Header: magic + version + msg_kind + flags + table_count + payload_length + p = putInt(p, QwpConstants.MAGIC_MESSAGE); // 4 + p = putByte(p, QwpConstants.VERSION_1); // 1 + p = putByte(p, (byte) 0); // msg_kind in header (unused by client) + p = putByte(p, (byte) 0); // flags + p = putByte(p, (byte) 1); // table_count + p = putInt(p, 0); // payload_length placeholder (unused) + + // Body: + p = putByte(p, (byte) 0x11); // msg_kind = RESULT_BATCH + p = putLong(p, 7L); // request_id + p = putVarint(p, 0L); // batch_seq + p = putVarint(p, 0L); // table_name_len = 0 + p = putVarint(p, nonNull); // row_count + p = putVarint(p, 1L); // column_count + p = putByte(p, QwpConstants.SCHEMA_MODE_FULL); + p = putVarint(p, 0L); // schema_id + // Schema entries (full): one column "s" of TYPE_STRING + p = putVarint(p, 1L); // column name length + p = putByte(p, (byte) 's'); + p = putByte(p, QwpConstants.TYPE_STRING); + // Column body: null_flag = 0 (no nulls), offsets[nonNull+1] u32, then bytes. + p = putByte(p, (byte) 0); // null_flag + for (int i = 0; i < nonNull; i++) { + p = putInt(p, i * 5); // offset[i] + } + p = putInt(p, totalBytes); // offset[nonNull] = totalBytes + // Followed by 'totalBytes' string bytes — for the success case we write "hello" + // (5 bytes). For the negative-totalBytes case we still write 5 bytes; the + // decoder must reject before consuming them. + byte[] s = "hello".getBytes(java.nio.charset.StandardCharsets.UTF_8); + for (byte b : s) p = putByte(p, b); + return (int) (p - buf); + } +} From 7450d92c7e2b4efbff3a8dcb0dfe237effed36c7 Mon Sep 17 00:00:00 2001 From: bluestreak Date: Sun, 19 Apr 2026 02:26:44 +0100 Subject: [PATCH 08/44] wip 7 --- .../cutlass/qwp/client/QwpColumnBatch.java | 17 ++++++++++++ .../cutlass/qwp/client/QwpEgressIoThread.java | 26 +++++++++++++++++-- .../qwp/client/QwpResultBatchDecoder.java | 11 ++++++++ 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpColumnBatch.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpColumnBatch.java index f46b0578..3a1b7ffa 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpColumnBatch.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpColumnBatch.java @@ -473,6 +473,23 @@ public int[] nonNullIndex(int col) { return columnLayouts.getQuick(col).nonNullIdx; } + /** + * Starting address of the RESULT_BATCH payload this view was decoded from. + * Equal to the WebSocket payload pointer when in-place decode is in use. + * Intended for accounting (byte counters) rather than data access. + */ + public long payloadAddr() { + return payloadAddr; + } + + /** + * Exclusive upper bound of the RESULT_BATCH payload bytes. See + * {@link #payloadAddr()}. + */ + public long payloadLimit() { + return payloadLimit; + } + public long requestId() { return requestId; } diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpEgressIoThread.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpEgressIoThread.java index 62ad41ed..07da27dc 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpEgressIoThread.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpEgressIoThread.java @@ -51,11 +51,17 @@ public class QwpEgressIoThread implements Runnable, WebSocketFrameHandler { private static final int DEFAULT_BUFFER_CAPACITY = 64 * 1024; private static final long POLL_TIMEOUT_MS = 100; + private static final Object RELEASE_TOKEN = new Object(); private final QwpResultBatchDecoder decoder = new QwpResultBatchDecoder(); // Events delivered from I/O thread to user thread (RESULT_BATCH / RESULT_END / QUERY_ERROR). private final BlockingQueue events; // Pool of pre-allocated buffers. I/O thread takes, user thread releases. private final BlockingQueue freeBuffers; + // One-slot release latch: user thread offers a token from releaseBuffer, I/O + // thread drains it before returning from onBinaryMessage. Holds the payload + // bytes in the WebSocket recv buffer steady for the duration of the user + // handler, since in-place decode makes batch pointers alias those bytes. + private final BlockingQueue pendingRelease = new ArrayBlockingQueue<>(1); // Single-slot request queue (Phase-1 allows one in-flight query). private final BlockingQueue requests = new ArrayBlockingQueue<>(1); private final NativeBufferWriter sendScratch = new NativeBufferWriter(); @@ -136,9 +142,14 @@ public void onClose(int code, String reason) { /** * Releases a buffer back to the I/O thread pool. Call after the user * handler finishes processing a {@code KIND_BATCH} event. + *

+ * Also signals the release latch so the I/O thread, parked at the end of + * {@code handleResultBatch}, can resume and let the WebSocket recv buffer + * compact past the consumed frame. */ public void releaseBuffer(QwpBatchBuffer buffer) { freeBuffers.offer(buffer); + pendingRelease.offer(RELEASE_TOKEN); } @Override @@ -249,9 +260,10 @@ private void handleResultBatch(long payloadPtr, int payloadLen) { } catch (InterruptedException ie) { return; } - buf.copyFromPayload(payloadPtr, payloadLen); + // Decode in place: column layouts reference payloadPtr (the WebSocket recv + // buffer) directly, skipping the previous per-batch memcpy into buf.scratchAddr. try { - decoder.decode(buf); + decoder.decode(buf, payloadPtr, payloadLen); } catch (QwpDecodeException e) { freeBuffers.offer(buf); emitError((byte) 0, "decode failure: " + e.getMessage()); @@ -259,6 +271,16 @@ private void handleResultBatch(long payloadPtr, int payloadLen) { return; } events.offer(new QueryEvent().asBatch(buf)); + // Park on the release latch. Returning sooner would let receiveFrame + // compact the WebSocket recv buffer, overwriting the bytes that the + // user-visible column pointers still reference. User thread's + // releaseBuffer offers the token that unblocks this take. + try { + pendingRelease.take(); + } catch (InterruptedException ie) { + // Shutdown path: leave the batch to the user thread; they'll see + // either the in-progress batch or a subsequent close event. + } } /** diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpResultBatchDecoder.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpResultBatchDecoder.java index 7ba34f3f..615ec4cb 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpResultBatchDecoder.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpResultBatchDecoder.java @@ -89,6 +89,17 @@ public void decode(QwpBatchBuffer buffer) throws QwpDecodeException { decodePayload(buffer, buffer.getScratchAddr(), buffer.getPayloadLen()); } + /** + * In-place decode: parses the frame whose bytes live at {@code payloadPtr} (e.g. the + * WebSocket recv buffer) without copying into {@code buffer}'s native scratch. + * {@code buffer} contributes only its reusable layout pool and batch view; all + * column pointers produced reference {@code payloadPtr}, so the caller must keep + * those bytes stable until it's done reading the {@link QwpColumnBatch}. + */ + public void decode(QwpBatchBuffer buffer, long payloadPtr, int payloadLen) throws QwpDecodeException { + decodePayload(buffer, payloadPtr, payloadLen); + } + // Pool helpers private static long advanceFixed(QwpColumnLayout layout, long p, long limit, int sizeBytes) throws QwpDecodeException { From e399c627304bd7e21d4d548c9d678ae68a449c0b Mon Sep 17 00:00:00 2001 From: bluestreak Date: Sun, 19 Apr 2026 12:34:58 +0100 Subject: [PATCH 09/44] wip 11 --- .../cutlass/qwp/client/QwpColumnLayout.java | 10 +- .../cutlass/qwp/client/QwpEgressIoThread.java | 1 + .../qwp/client/QwpResultBatchDecoder.java | 146 ++++++++++++++++-- 3 files changed, 140 insertions(+), 17 deletions(-) diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpColumnLayout.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpColumnLayout.java index 3370a113..0769f764 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpColumnLayout.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpColumnLayout.java @@ -71,9 +71,15 @@ public class QwpColumnLayout { long stringBytesAddr; /** - * SYMBOL: decoded dictionary entries as reusable native views into the payload. + * SYMBOL: decoded dictionary entries as reusable native views. + *

+ * Without {@code FLAG_DELTA_SYMBOL_DICT}, this is a per-batch list of + * {@link DirectUtf8String}s pointing INTO the current payload buffer (valid + * only for the lifetime of that buffer). With the flag set, the decoder + * replaces this reference with its connection-scoped list, whose entries + * point into a heap owned by the decoder that survives across batches. */ - final ObjList symbolDict = new ObjList<>(); + ObjList symbolDict = new ObjList<>(); /** * SYMBOL: number of valid entries in {@link #symbolDict} for this batch. diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpEgressIoThread.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpEgressIoThread.java index 07da27dc..72bfbcdc 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpEgressIoThread.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpEgressIoThread.java @@ -316,6 +316,7 @@ private void sendQueryRequest(QueryRequest req) { */ void closePool() { Misc.free(sendScratch); + Misc.free(decoder); QueryEvent ev; while ((ev = events.poll()) != null) { if (ev.kind == QueryEvent.KIND_BATCH && ev.buffer != null) { diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpResultBatchDecoder.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpResultBatchDecoder.java index 615ec4cb..d5d44834 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpResultBatchDecoder.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpResultBatchDecoder.java @@ -25,7 +25,9 @@ package io.questdb.client.cutlass.qwp.client; import io.questdb.client.cutlass.qwp.protocol.QwpConstants; +import io.questdb.client.std.MemoryTag; import io.questdb.client.std.ObjList; +import io.questdb.client.std.QuietCloseable; import io.questdb.client.std.Unsafe; import io.questdb.client.std.str.DirectUtf8String; @@ -45,7 +47,7 @@ * {@code onBatch} callback because its pointers refer into the caller's native * payload buffer. */ -public class QwpResultBatchDecoder { +public class QwpResultBatchDecoder implements QuietCloseable { /** * Cap on per-row ARRAY element count. 8 bytes per element x this ~ 256 MB max payload, @@ -70,16 +72,40 @@ public class QwpResultBatchDecoder { * a negative varint that long-to-int casts negative). */ private static final int MAX_SCHEMAS_PER_CONNECTION = 65_535; + private static final int CONN_DICT_INITIAL_BYTES = 4096; // Connection-scoped state (safe to share across buffers -- reused across batches // of the same query and across queries on the same connection). + // Connection-scoped SYMBOL dictionary. Populated by {@link #parseDeltaSymbolDict} + // from the per-message delta section; the bytes live in a native heap owned by + // the decoder ({@link #connDictHeapAddr}) so DirectUtf8String views stay valid + // across batches (unlike per-batch flyweights which expire when the WS recv + // buffer recycles). Grows but never shrinks; freed on {@link #close}. + private final ObjList connSymDict = new ObjList<>(); // Registry indexed by schemaId. null = not registered. Schema ids are server-assigned // and small (monotonic from 0). private final ObjList> schemaRegistry = new ObjList<>(); + private long connDictHeapAddr; + private int connDictHeapCapacity; + private int connDictHeapPos; + // True when the current message carries {@code FLAG_DELTA_SYMBOL_DICT}. Read by + // {@link #parseSymbolColumn} to decide whether to consume a per-column dict. + private boolean deltaMode; // Reusable varint decode state: value in varintValue, new position in varintPos. // Instance-level so no {@code long[2]} scratch is allocated per call. private long varintPos; private long varintValue; + @Override + public void close() { + if (connDictHeapAddr != 0) { + Unsafe.free(connDictHeapAddr, connDictHeapCapacity, MemoryTag.NATIVE_DEFAULT); + connDictHeapAddr = 0; + connDictHeapCapacity = 0; + connDictHeapPos = 0; + } + connSymDict.clear(); + } + /** * Decodes the RESULT_BATCH frame whose payload has been copied into {@code buffer}. * Populates {@code buffer.batch} and {@code buffer.layoutPool}. The resulting @@ -126,6 +152,26 @@ private static long[] ensureLongArray(long[] current, int size) { return new long[Math.max(size, current == null ? 16 : current.length * 2)]; } + private void ensureConnDictHeapCapacity(int required) { + if (connDictHeapCapacity >= required) return; + int newCap = Math.max(connDictHeapCapacity * 2, Math.max(CONN_DICT_INITIAL_BYTES, required)); + connDictHeapAddr = Unsafe.realloc(connDictHeapAddr, connDictHeapCapacity, newCap, MemoryTag.NATIVE_DEFAULT); + // Any existing DirectUtf8String views that point into the old memory are + // invalidated by realloc (the OS may move the mapping). Repoint every + // entry at its original offset in the fresh heap. + if (connDictHeapCapacity != newCap) { + long base = connDictHeapAddr; + int prevEnd = 0; + for (int i = 0, n = connSymDict.size(); i < n; i++) { + DirectUtf8String entry = connSymDict.getQuick(i); + int entryLen = entry.size(); + entry.of(base + prevEnd, base + prevEnd + entryLen); + prevEnd += entryLen; + } + } + connDictHeapCapacity = newCap; + } + // Varint / string helpers /** @@ -173,6 +219,8 @@ private void decodePayload(QwpBatchBuffer buffer, long payload, int payloadLen) if (version != QwpConstants.VERSION_1) { throw new QwpDecodeException("unsupported version " + (version & 0xFF)); } + byte flags = Unsafe.getUnsafe().getByte(payload + QwpConstants.HEADER_OFFSET_FLAGS); + deltaMode = (flags & QwpConstants.FLAG_DELTA_SYMBOL_DICT) != 0; long p = payload + QwpConstants.HEADER_SIZE; long limit = payload + payloadLen; @@ -187,6 +235,13 @@ private void decodePayload(QwpBatchBuffer buffer, long payload, int payloadLen) long batchSeq = varintValue; p = varintPos; + // Delta section (if enabled) sits right after the prelude and before the + // table block. We consume it first so that SYMBOL columns inside the + // table block resolve indices against the freshly-updated connection dict. + if (deltaMode) { + p = parseDeltaSymbolDict(p, limit); + } + // Table block: name_length, name, row_count, column_count, schema, columns decodeVarint(p, limit); long nameLen = varintValue; @@ -396,6 +451,56 @@ private long parseColumn(QwpColumnLayout layout, int rowCount, long p, long limi throw new QwpDecodeException("unsupported wire type 0x" + Integer.toHexString(wt & 0xFF)); } + /** + * Parses the message-level delta symbol dictionary section present when + * {@code FLAG_DELTA_SYMBOL_DICT} is set. Copies newly-seen symbol bytes into + * the decoder's connection-scoped native heap and appends {@code DirectUtf8String} + * views over them to {@link #connSymDict}. + *

+     *   [deltaStartId: varint]
+     *   [deltaCount:   varint]
+     *   for each new entry: [length: varint][UTF-8 bytes]
+     * 
+ * The server is required to emit {@code deltaStartId == connSymDict.size()} + * (otherwise the two ends are out of sync and we bail rather than silently + * corrupt the dict). Returns the wire position just past the section. + */ + private long parseDeltaSymbolDict(long p, long limit) throws QwpDecodeException { + decodeVarint(p, limit); + long deltaStart = varintValue; + p = varintPos; + decodeVarint(p, limit); + long deltaCount = varintValue; + p = varintPos; + if (deltaStart < 0 || deltaCount < 0 + || deltaStart + deltaCount > Integer.MAX_VALUE) { + throw new QwpDecodeException("delta symbol section out of range: start=" + + deltaStart + ", count=" + deltaCount); + } + if (deltaStart != connSymDict.size()) { + throw new QwpDecodeException("delta symbol dict out of sync: expected start=" + + connSymDict.size() + ", got=" + deltaStart); + } + for (long i = 0; i < deltaCount; i++) { + decodeVarint(p, limit); + long entryLen = varintValue; + p = varintPos; + if (entryLen < 0 || p + entryLen > limit) { + throw new QwpDecodeException("truncated delta symbol entry"); + } + int len = (int) entryLen; + ensureConnDictHeapCapacity(connDictHeapPos + len); + long dst = connDictHeapAddr + connDictHeapPos; + Unsafe.getUnsafe().copyMemory(p, dst, len); + DirectUtf8String entry = new DirectUtf8String(); + entry.of(dst, dst + len); + connSymDict.add(entry); + connDictHeapPos += len; + p += len; + } + return p; + } + /** * Reads the null flag and bitmap, populates {@code layout.nullBitmapAddr} and * {@code layout.nonNullCount}, and fills {@code layout.nonNullIdx[0..rowCount)} @@ -431,24 +536,35 @@ private long parseNullSection(QwpColumnLayout layout, int rowCount, long p, long } /** - * SYMBOL: per-table dictionary (dict_size varint, then len+bytes per entry), - * then per-non-null-row varint indices into the dict. + * SYMBOL: in delta mode (always, with the current server) there's no per-column + * dictionary -- indices reference the connection-scoped {@link #connSymDict} + * populated by {@link #parseDeltaSymbolDict}. In non-delta mode the column + * carries its own dict: (dict_size varint, then len+bytes per entry), followed + * by per-non-null-row varint indices. */ private long parseSymbolColumn(QwpColumnLayout layout, int rowCount, long p, long limit) throws QwpDecodeException { - decodeVarint(p, limit); - int dictSize = (int) varintValue; - p = varintPos; - // Ensure pool size - while (layout.symbolDict.size() < dictSize) { - layout.symbolDict.add(new DirectUtf8String()); - } - for (int e = 0; e < dictSize; e++) { + final int dictSize; + if (deltaMode) { + // Point the column's dict at the connection-scoped list; size is + // whatever the dict has grown to across all batches on this connection. + layout.symbolDict = connSymDict; + dictSize = connSymDict.size(); + } else { decodeVarint(p, limit); - int entryLen = (int) varintValue; + dictSize = (int) varintValue; p = varintPos; - if (p + entryLen > limit) throw new QwpDecodeException("truncated symbol entry"); - layout.symbolDict.getQuick(e).of(p, p + entryLen); - p += entryLen; + // Ensure pool size + while (layout.symbolDict.size() < dictSize) { + layout.symbolDict.add(new DirectUtf8String()); + } + for (int e = 0; e < dictSize; e++) { + decodeVarint(p, limit); + int entryLen = (int) varintValue; + p = varintPos; + if (p + entryLen > limit) throw new QwpDecodeException("truncated symbol entry"); + layout.symbolDict.getQuick(e).of(p, p + entryLen); + p += entryLen; + } } layout.symbolDictSize = dictSize; // Materialise per-row IDs into int[rowCount] so random access is O(1). From 167d1758ccec0e2a802b7d5e053543cc9d787395 Mon Sep 17 00:00:00 2001 From: bluestreak Date: Sun, 19 Apr 2026 13:20:27 +0100 Subject: [PATCH 10/44] wip 12 --- .../client/cutlass/qwp/client/QueryEvent.java | 11 ++ .../qwp/client/QwpColumnBatchHandler.java | 16 +++ .../cutlass/qwp/client/QwpEgressIoThread.java | 30 ++++ .../cutlass/qwp/client/QwpEgressMsgKind.java | 5 + .../cutlass/qwp/client/QwpQueryClient.java | 3 + .../example/query/ErrorHandlingExample.java | 16 ++- .../example/query/ExecStatementExample.java | 128 ++++++++++++++++++ 7 files changed, 206 insertions(+), 3 deletions(-) create mode 100644 examples/src/main/java/com/example/query/ExecStatementExample.java diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QueryEvent.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QueryEvent.java index 46fcd63f..bb6f3211 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QueryEvent.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QueryEvent.java @@ -35,11 +35,14 @@ public class QueryEvent { public static final int KIND_BATCH = 0; public static final int KIND_END = 1; public static final int KIND_ERROR = 2; + public static final int KIND_EXEC_DONE = 3; public QwpBatchBuffer buffer; // valid for KIND_BATCH (must be released to pool by consumer) public byte errorStatus; // valid for KIND_ERROR public String errorMessage; // valid for KIND_ERROR public int kind; + public short opType; // valid for KIND_EXEC_DONE (matches CompiledQuery.SELECT/INSERT/etc.) + public long rowsAffected; // valid for KIND_EXEC_DONE public long totalRows; // valid for KIND_END public QueryEvent asBatch(QwpBatchBuffer buffer) { @@ -62,4 +65,12 @@ public QueryEvent asError(byte status, String message) { this.errorMessage = message; return this; } + + public QueryEvent asExecDone(short opType, long rowsAffected) { + this.kind = KIND_EXEC_DONE; + this.buffer = null; + this.opType = opType; + this.rowsAffected = rowsAffected; + return this; + } } diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpColumnBatchHandler.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpColumnBatchHandler.java index 19789a12..d8c8d7bc 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpColumnBatchHandler.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpColumnBatchHandler.java @@ -58,4 +58,20 @@ public interface QwpColumnBatchHandler { * @param message server-supplied error message (may be empty) */ void onError(byte status, String message); + + /** + * Invoked in place of {@link #onBatch} + {@link #onEnd} when the query was + * a non-SELECT (DDL, INSERT, UPDATE, etc.). No batches are delivered for + * such queries -- the server executes the statement and replies with a + * single {@code EXEC_DONE}. + * + * @param opType matches one of {@code CompiledQuery.SELECT} / {@code INSERT} / + * {@code UPDATE} / {@code CREATE_TABLE} / etc. (server-side constants) + * @param rowsAffected rows inserted / updated / deleted; 0 for pure DDL + */ + default void onExecDone(short opType, long rowsAffected) { + // Default no-op lets existing SELECT-only implementations stay source- + // compatible. A handler that ever sees a non-SELECT query should + // override this method. + } } diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpEgressIoThread.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpEgressIoThread.java index 72bfbcdc..a6ec3625 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpEgressIoThread.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpEgressIoThread.java @@ -122,6 +122,9 @@ public void onBinaryMessage(long payloadPtr, int payloadLen) { long total = decodeResultEnd(payloadPtr, payloadLen); events.offer(new QueryEvent().asEnd(total)); currentQueryDone = true; + } else if (msgKind == QwpEgressMsgKind.EXEC_DONE) { + decodeAndEmitExecDone(payloadPtr, payloadLen); + currentQueryDone = true; } else if (msgKind == QwpEgressMsgKind.QUERY_ERROR) { decodeAndEmitError(payloadPtr, payloadLen); currentQueryDone = true; @@ -215,6 +218,33 @@ private void decodeAndEmitError(long payload, int payloadLen) { events.offer(ev); } + /** + * EXEC_DONE body: msg_kind(1) + requestId(8) + op_type(1) + rows_affected(varint). + * Parses all fields, surfaces as a {@link QueryEvent#KIND_EXEC_DONE} event. + */ + private void decodeAndEmitExecDone(long payload, int payloadLen) { + long p = payload + QwpConstants.HEADER_SIZE + 1 + 8; + long limit = payload + payloadLen; + if (p + 1 > limit) { + emitError((byte) 0, "EXEC_DONE frame truncated before op_type"); + return; + } + byte opType = Unsafe.getUnsafe().getByte(p++); + long rowsAffected = 0; + int shift = 0; + while (p < limit) { + byte b = Unsafe.getUnsafe().getByte(p++); + rowsAffected |= (long) (b & 0x7F) << shift; + if ((b & 0x80) == 0) break; + shift += 7; + if (shift > 63) { + emitError((byte) 0, "EXEC_DONE rows_affected varint overflow"); + return; + } + } + events.offer(new QueryEvent().asExecDone(opType, rowsAffected)); + } + /** * RESULT_END body: msg_kind(1) + requestId(8) + final_seq(varint) + total_rows(varint). * We only need total_rows. diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpEgressMsgKind.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpEgressMsgKind.java index 0a3f952c..7fbf6420 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpEgressMsgKind.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpEgressMsgKind.java @@ -32,6 +32,11 @@ public final class QwpEgressMsgKind { public static final byte CANCEL = 0x14; public static final byte CREDIT = 0x15; + /** + * Server -> client. Ack for a successful non-SELECT query. Body: + * {@code request_id:u64, op_type:u8, rows_affected:varint}. + */ + public static final byte EXEC_DONE = 0x16; public static final byte QUERY_ERROR = 0x13; public static final byte QUERY_REQUEST = 0x10; public static final byte RESULT_BATCH = 0x11; diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpQueryClient.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpQueryClient.java index 1d3296a4..e1065e02 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpQueryClient.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpQueryClient.java @@ -309,6 +309,9 @@ public void execute(String sql, QwpColumnBatchHandler handler) { case QueryEvent.KIND_END: handler.onEnd(ev.totalRows); return; + case QueryEvent.KIND_EXEC_DONE: + handler.onExecDone(ev.opType, ev.rowsAffected); + return; case QueryEvent.KIND_ERROR: handler.onError(ev.errorStatus, ev.errorMessage); return; diff --git a/examples/src/main/java/com/example/query/ErrorHandlingExample.java b/examples/src/main/java/com/example/query/ErrorHandlingExample.java index 2330dc48..c3bc7707 100644 --- a/examples/src/main/java/com/example/query/ErrorHandlingExample.java +++ b/examples/src/main/java/com/example/query/ErrorHandlingExample.java @@ -15,7 +15,7 @@ * Status codes mirror the ingress namespace. For egress the common ones are: *
    *
  • {@code 0x03 SCHEMA_MISMATCH} — bind parameter type doesn't match the placeholder
  • - *
  • {@code 0x05 PARSE_ERROR} — SQL syntax error OR non-SELECT statement
  • + *
  • {@code 0x05 PARSE_ERROR} — SQL syntax error OR unsupported statement on the endpoint
  • *
  • {@code 0x06 INTERNAL_ERROR} — unexpected server-side failure
  • *
  • {@code 0x08 SECURITY_ERROR} — authorization failure
  • *
  • {@code 0x0A CANCELLED} — query terminated in response to CANCEL
  • @@ -24,6 +24,10 @@ * SQL-level errors carry the position embedded in the message, using QuestDB's * standard "{@code [pos] text}" format, so you can point the user directly at * the offending token. + *

    + * Note: DDL / INSERT / UPDATE are not errors over {@code /read/v1} -- + * the server executes them and replies with {@code EXEC_DONE}, surfaced via + * {@link QwpColumnBatchHandler#onExecDone}. See {@link ExecStatementExample}. */ public class ErrorHandlingExample { @@ -37,8 +41,9 @@ public static void main(String[] args) { // Nonexistent table — also reported as PARSE_ERROR with a "does not exist" message. runAndReport(client, "SELECT * FROM nowhere"); - // DDL sent over the read endpoint — Phase 1 restricts /read/v1 to SELECT. - runAndReport(client, "DROP TABLE trades"); + // COPY ... FROM is the one non-SELECT still rejected on egress: + // bulk load belongs on the /write/v4 ingress endpoint. + runAndReport(client, "COPY trades FROM '/tmp/missing.csv'"); } } @@ -59,6 +64,11 @@ public void onEnd(long totalRows) { public void onError(byte status, String message) { System.out.printf("query failed: status=0x%02X, message=%s%n", status & 0xFF, message); } + + @Override + public void onExecDone(short opType, long rowsAffected) { + System.out.printf("(unexpected) exec ok: opType=%d, rows=%d%n", opType, rowsAffected); + } }); } } diff --git a/examples/src/main/java/com/example/query/ExecStatementExample.java b/examples/src/main/java/com/example/query/ExecStatementExample.java new file mode 100644 index 00000000..1776b784 --- /dev/null +++ b/examples/src/main/java/com/example/query/ExecStatementExample.java @@ -0,0 +1,128 @@ +package com.example.query; + +import io.questdb.client.cutlass.qwp.client.QwpColumnBatch; +import io.questdb.client.cutlass.qwp.client.QwpColumnBatchHandler; +import io.questdb.client.cutlass.qwp.client.QwpQueryClient; + +/** + * Running DDL / INSERT / UPDATE statements over QWP egress. + *

    + * The {@code /read/v1} endpoint accepts any SQL statement the compiler + * understands, not just {@code SELECT}. Non-SELECT statements skip the + * {@code onBatch} / {@code onEnd} callbacks entirely -- the server executes + * the statement and replies with a single {@code EXEC_DONE} frame that the + * client surfaces via {@link QwpColumnBatchHandler#onExecDone}. + *

    + * The callback carries two pieces of information: + *

      + *
    • {@code opType} -- one of {@code CompiledQuery.SELECT} / + * {@code INSERT} / {@code UPDATE} / {@code CREATE_TABLE} / + * {@code DROP} / etc. (see {@code io.questdb.griffin.CompiledQuery} + * for the full enum).
    • + *
    • {@code rowsAffected} -- number of rows inserted / updated / deleted. + * Pure DDL (CREATE / DROP / ALTER / RENAME / TRUNCATE / ...) reports 0.
    • + *
    + */ +public class ExecStatementExample { + + // Op-type codes, copied from io.questdb.griffin.CompiledQuery so this + // example stays compilable without the server-side module on the classpath. + // The comments are just for readability -- the server sends the numeric + // value in the EXEC_DONE frame and the client hands it to onExecDone as-is. + private static final short CREATE_TABLE = 9; + private static final short DROP = 7; + private static final short INSERT = 2; + private static final short UPDATE = 14; + + public static void main(String[] args) { + try (QwpQueryClient client = QwpQueryClient.newPlainText("localhost", 9000)) { + client.connect(); + + // 1. DDL: CREATE TABLE. rowsAffected is 0 for pure DDL. + runExec(client, "CREATE TABLE trades_example (" + + "ts TIMESTAMP, sym SYMBOL, price DOUBLE, qty LONG" + + ") TIMESTAMP(ts) PARTITION BY DAY WAL", CREATE_TABLE); + + // 2. INSERT with multi-row VALUES. rowsAffected = 3. + runExec(client, + "INSERT INTO trades_example VALUES " + + "(0, 'AAPL', 150.25, 100), " + + "(1_000_000, 'AAPL', 150.30, 200), " + + "(2_000_000, 'MSFT', 420.10, 50)", + INSERT); + + // 3. UPDATE with a predicate. Server reports how many rows matched. + runExec(client, + "UPDATE trades_example SET qty = qty * 2 WHERE sym = 'AAPL'", + UPDATE); + + // 4. SELECT the data back to confirm the UPDATE landed. Uses the + // standard batch-streaming path (onBatch + onEnd). + System.out.println("-- verifying UPDATE via SELECT"); + client.execute( + "SELECT ts, sym, qty FROM trades_example", + new QwpColumnBatchHandler() { + @Override + public void onBatch(QwpColumnBatch batch) { + for (int row = 0; row < batch.getRowCount(); row++) { + System.out.printf( + " ts=%d sym=%s qty=%d%n", + batch.getLong(0, row), + batch.getString(1, row), + batch.getLong(2, row) + ); + } + } + + @Override + public void onEnd(long totalRows) { + } + + @Override + public void onError(byte status, String message) { + System.err.println("SELECT failed: " + message); + } + } + ); + + // 5. Clean up with DROP. Also pure DDL, rowsAffected=0. + runExec(client, "DROP TABLE trades_example", DROP); + } + } + + /** + * Executes a non-SELECT statement and prints the server's ack. Fails + * fast if the server unexpectedly streams rows or the op type doesn't + * match what we asked for. + */ + private static void runExec(QwpQueryClient client, String sql, short expectedOpType) { + System.out.println("-- executing: " + sql); + client.execute(sql, new QwpColumnBatchHandler() { + @Override + public void onBatch(QwpColumnBatch batch) { + System.err.println("(unexpected) batch with " + batch.getRowCount() + " rows"); + } + + @Override + public void onEnd(long totalRows) { + System.err.println("(unexpected) onEnd for a non-SELECT; totalRows=" + totalRows); + } + + @Override + public void onError(byte status, String message) { + System.err.printf(" failed: status=0x%02X, message=%s%n", status & 0xFF, message); + } + + @Override + public void onExecDone(short opType, long rowsAffected) { + System.out.printf( + " done: opType=%d (expected=%d), rowsAffected=%d%n", + opType, expectedOpType, rowsAffected + ); + if (opType != expectedOpType) { + System.err.println(" !! op type mismatch"); + } + } + }); + } +} From a06fa02106d4dd1583e1475aae036b4b93743488 Mon Sep 17 00:00:00 2001 From: bluestreak Date: Sun, 19 Apr 2026 15:32:01 +0100 Subject: [PATCH 11/44] wip 13 --- .../cutlass/qwp/protocol/QwpBitReader.java | 146 ++++++++++++++++++ .../qwp/protocol/QwpGorillaDecoder.java | 102 ++++++++++++ 2 files changed, 248 insertions(+) create mode 100644 core/src/main/java/io/questdb/client/cutlass/qwp/protocol/QwpBitReader.java create mode 100644 core/src/main/java/io/questdb/client/cutlass/qwp/protocol/QwpGorillaDecoder.java diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/protocol/QwpBitReader.java b/core/src/main/java/io/questdb/client/cutlass/qwp/protocol/QwpBitReader.java new file mode 100644 index 00000000..dcd47dea --- /dev/null +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/protocol/QwpBitReader.java @@ -0,0 +1,146 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +package io.questdb.client.cutlass.qwp.protocol; + +import io.questdb.client.cutlass.qwp.client.QwpDecodeException; +import io.questdb.client.std.Unsafe; + +/** + * Client-side bit-level reader for QWP v1 Gorilla-compressed columns. Mirrors + * the server-side reader. Bits are read LSB-first; the buffer lazily pulls + * bytes from the underlying native address as needed. + *

    + * Overflow surfaces as {@link QwpDecodeException} so a malformed server frame + * is reported to the user handler via {@code onError}, not an uncaught error. + */ +public class QwpBitReader { + + // Buffer for reading bits + private long bitBuffer; + // Number of bits currently available in the buffer (0-64) + private int bitsInBuffer; + private long currentAddress; + private long endAddress; + // Total bits available for reading (from reset) + private long totalBitsAvailable; + // Total bits already consumed + private long totalBitsRead; + + public QwpBitReader() { + } + + public long getBitPosition() { + return totalBitsRead; + } + + /** + * Reads a single bit. + */ + public int readBit() throws QwpDecodeException { + if (totalBitsRead >= totalBitsAvailable) { + throw new QwpDecodeException("QwpBitReader: read past end"); + } + if (!ensureBits(1)) { + throw new QwpDecodeException("QwpBitReader: read past end"); + } + + int bit = (int) (bitBuffer & 1); + bitBuffer >>>= 1; + bitsInBuffer--; + totalBitsRead++; + return bit; + } + + /** + * Reads multiple bits and returns them as a long (unsigned, LSB-aligned). + */ + public long readBits(int numBits) throws QwpDecodeException { + if (numBits <= 0) { + return 0; + } + if (numBits > 64) { + throw new AssertionError("Asked to read more than 64 bits into a long"); + } + if (totalBitsRead + numBits > totalBitsAvailable) { + throw new QwpDecodeException("QwpBitReader: read past end"); + } + + long result = 0; + int bitsRemaining = numBits; + int resultShift = 0; + + while (bitsRemaining > 0) { + if (bitsInBuffer == 0) { + if (!ensureBits(Math.min(bitsRemaining, 64))) { + throw new QwpDecodeException("QwpBitReader: read past end"); + } + } + + int bitsToTake = Math.min(bitsRemaining, bitsInBuffer); + long mask = bitsToTake == 64 ? -1L : (1L << bitsToTake) - 1; + result |= (bitBuffer & mask) << resultShift; + + bitBuffer >>>= bitsToTake; + bitsInBuffer -= bitsToTake; + bitsRemaining -= bitsToTake; + resultShift += bitsToTake; + } + + totalBitsRead += numBits; + return result; + } + + /** + * Reads multiple bits and interprets them as a signed value (two's complement). + */ + public long readSigned(int numBits) throws QwpDecodeException { + long unsigned = readBits(numBits); + if (numBits < 64 && (unsigned & (1L << (numBits - 1))) != 0) { + unsigned |= -1L << numBits; + } + return unsigned; + } + + /** + * Resets the reader to read from {@code length} bytes starting at {@code address}. + */ + public void reset(long address, long length) { + this.currentAddress = address; + this.endAddress = address + length; + this.bitBuffer = 0; + this.bitsInBuffer = 0; + this.totalBitsAvailable = length * 8L; + this.totalBitsRead = 0; + } + + private boolean ensureBits(int bitsNeeded) { + while (bitsInBuffer < bitsNeeded && bitsInBuffer <= 56 && currentAddress < endAddress) { + byte b = Unsafe.getUnsafe().getByte(currentAddress++); + bitBuffer |= (long) (b & 0xFF) << bitsInBuffer; + bitsInBuffer += 8; + } + return bitsInBuffer >= bitsNeeded; + } +} diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/protocol/QwpGorillaDecoder.java b/core/src/main/java/io/questdb/client/cutlass/qwp/protocol/QwpGorillaDecoder.java new file mode 100644 index 00000000..70c3d10f --- /dev/null +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/protocol/QwpGorillaDecoder.java @@ -0,0 +1,102 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +package io.questdb.client.cutlass.qwp.protocol; + +import io.questdb.client.cutlass.qwp.client.QwpDecodeException; + +/** + * Client-side Gorilla delta-of-delta decoder for timestamp columns in QWP + * egress {@code RESULT_BATCH} frames. Mirrors the server-side decoder and + * reads the bitstream produced by {@code QwpGorillaEncoder}. + *

    + * Encoding buckets: + *

    + *   '0'                     -> DoD = 0                 (1 bit)
    + *   '10' + 7-bit signed     -> DoD in [-64, 63]        (9 bits)
    + *   '110' + 9-bit signed    -> DoD in [-256, 255]     (12 bits)
    + *   '1110' + 12-bit signed  -> DoD in [-2048, 2047]   (16 bits)
    + *   '1111' + 32-bit signed  -> any other DoD          (36 bits)
    + * 
    + */ +public class QwpGorillaDecoder { + + private final QwpBitReader bitReader = new QwpBitReader(); + private long prevDelta; + private long prevTimestamp; + + public QwpGorillaDecoder() { + } + + /** + * Decodes the next timestamp from the bit stream. + */ + public long decodeNext() throws QwpDecodeException { + long deltaOfDelta = decodeDoD(); + long delta = prevDelta + deltaOfDelta; + long timestamp = prevTimestamp + delta; + + prevDelta = delta; + prevTimestamp = timestamp; + return timestamp; + } + + /** + * Returns the current bit position (bits read since reset). + */ + public long getBitPosition() { + return bitReader.getBitPosition(); + } + + /** + * Resets the decoder. First two timestamps are always shipped uncompressed + * at the head of the column's wire bytes; the address + length here point + * at the bitstream that follows them. + */ + public void reset(long firstTimestamp, long secondTimestamp, long address, long length) { + this.prevTimestamp = secondTimestamp; + this.prevDelta = secondTimestamp - firstTimestamp; + bitReader.reset(address, length); + } + + private long decodeDoD() throws QwpDecodeException { + int bit = bitReader.readBit(); + if (bit == 0) { + return 0; + } + bit = bitReader.readBit(); + if (bit == 0) { + return bitReader.readSigned(7); + } + bit = bitReader.readBit(); + if (bit == 0) { + return bitReader.readSigned(9); + } + bit = bitReader.readBit(); + if (bit == 0) { + return bitReader.readSigned(12); + } + return bitReader.readSigned(32); + } +} From 96a403ac7966e5baba3db738ae398278a082342e Mon Sep 17 00:00:00 2001 From: bluestreak Date: Sun, 19 Apr 2026 17:02:48 +0100 Subject: [PATCH 12/44] wip 16 --- .../cutlass/qwp/client/QwpBatchBuffer.java | 8 +- .../cutlass/qwp/client/QwpColumnBatch.java | 8 +- .../cutlass/qwp/client/QwpColumnLayout.java | 106 +++++++++----- .../qwp/client/QwpResultBatchDecoder.java | 137 ++++++++++-------- 4 files changed, 156 insertions(+), 103 deletions(-) diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpBatchBuffer.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpBatchBuffer.java index e31fb8aa..8b7d4d01 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpBatchBuffer.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpBatchBuffer.java @@ -43,12 +43,12 @@ */ public class QwpBatchBuffer implements QuietCloseable { + final QwpColumnBatch batch = new QwpColumnBatch(); /** * Per-column layout pool scoped to this buffer. Sized to the max column * count observed on this buffer across batches; layouts are reused. */ final ObjList layoutPool = new ObjList<>(); - final QwpColumnBatch batch = new QwpColumnBatch(); private int payloadLen; private long scratchAddr; private int scratchCapacity; @@ -65,6 +65,12 @@ public void close() { scratchAddr = 0; scratchCapacity = 0; } + // Layouts own native entries buffers in non-delta SYMBOL mode. Free them + // before the buffer itself is discarded so the allocations don't leak. + for (int i = 0, n = layoutPool.size(); i < n; i++) { + layoutPool.getQuick(i).close(); + } + layoutPool.clear(); } /** diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpColumnBatch.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpColumnBatch.java index 3a1b7ffa..31f91070 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpColumnBatch.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpColumnBatch.java @@ -555,8 +555,12 @@ private DirectUtf8Sequence lookupStringBytes(int col, int row, DirectUtf8String } if (wt == QwpConstants.TYPE_SYMBOL) { int dictIdx = l.symbolRowIds[row]; - DirectUtf8String entry = l.symbolDict.getQuick(dictIdx); - return view.of(entry.ptr(), entry.ptr() + entry.size()); + // Single 64-bit load: low 32 bits = offset into dict heap, high 32 = length. + // No ObjList.getQuick, no DirectUtf8String deref -- pure pointer arithmetic. + long packed = Unsafe.getUnsafe().getLong(l.symbolDictEntriesAddr + ((long) dictIdx << 3)); + long start = l.symbolDictHeapAddr + (packed & 0xFFFFFFFFL); + long end = start + (packed >>> 32); + return view.of(start, end); } return null; } diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpColumnLayout.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpColumnLayout.java index 0769f764..aaf74464 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpColumnLayout.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpColumnLayout.java @@ -24,8 +24,9 @@ package io.questdb.client.cutlass.qwp.client; -import io.questdb.client.std.ObjList; -import io.questdb.client.std.str.DirectUtf8String; +import io.questdb.client.std.MemoryTag; +import io.questdb.client.std.QuietCloseable; +import io.questdb.client.std.Unsafe; /** * Per-column parsed layout for one batch. Holds native pointers INTO the @@ -33,79 +34,79 @@ * O(1) access. Reused across batches to eliminate allocations on the hot path * (pooled arrays grow to max observed size and never shrink). */ -public class QwpColumnLayout { +public class QwpColumnLayout implements QuietCloseable { /** - * Schema column metadata (name, wire type, scale, precisionBits). + * ARRAY: per-row starting offset (absolute address) of the array bytes. -1 for NULL rows. */ - QwpEgressColumnInfo info; - + long[] arrayRowAddr; /** - * Absolute payload address where this column's non-null values start. For - * fixed-width types this is the dense values array. For strings/varchars - * it's the offsets array. For symbols it's where the dict starts; the - * per-row IDs are materialised into {@link #symbolRowIds} during parse. + * ARRAY: per-row length in bytes of the array payload. */ - long valuesAddr; - + int[] arrayRowLen; /** - * Absolute payload address of the null bitmap, or 0 if the column has no NULL rows. + * Schema column metadata (name, wire type, scale, precisionBits). */ - long nullBitmapAddr; - + QwpEgressColumnInfo info; + /** + * Absolute address of the first byte after this column's data -- used to walk to the next column. + */ + long nextAddr; /** * Count of non-null rows in this column. */ int nonNullCount; - /** * Per-row lookup: {@code nonNullIdx[row]} is the dense index of row {@code row} within * the non-null values, or -1 if the row is NULL. Sized to {@code rowCount}. * Pool-owned; re-used across batches. */ int[] nonNullIdx; - + /** + * Absolute payload address of the null bitmap, or 0 if the column has no NULL rows. + */ + long nullBitmapAddr; /** * STRING / VARCHAR: absolute address of the concatenated UTF-8 bytes (right after the offsets array). */ long stringBytesAddr; - /** - * SYMBOL: decoded dictionary entries as reusable native views. - *

    - * Without {@code FLAG_DELTA_SYMBOL_DICT}, this is a per-batch list of - * {@link DirectUtf8String}s pointing INTO the current payload buffer (valid - * only for the lifetime of that buffer). With the flag set, the decoder - * replaces this reference with its connection-scoped list, whose entries - * point into a heap owned by the decoder that survives across batches. + * SYMBOL: absolute address of the native entries array. Each entry is a packed + * 8-byte pair {@code (offset:i32 | length:i32<<32)} relative to + * {@link #symbolDictHeapAddr}. Access in the hot path is a single 64-bit + * load + two int extractions, no object dereferences. */ - ObjList symbolDict = new ObjList<>(); - + long symbolDictEntriesAddr; + /** + * SYMBOL: absolute address of the UTF-8 bytes heap holding all dict entries. In + * delta mode this points into the decoder's connection-scoped native heap; in + * non-delta mode it points into the payload buffer (wire bytes directly). + */ + long symbolDictHeapAddr; /** - * SYMBOL: number of valid entries in {@link #symbolDict} for this batch. + * SYMBOL: number of valid entries referenced by {@link #symbolDictEntriesAddr}. */ int symbolDictSize; - /** * SYMBOL: per-row dictionary ID. Sized to {@code rowCount}; NULL rows are * left with stale values -- use {@link #nonNullIdx}/null-check first. */ int[] symbolRowIds; - - /** - * ARRAY: per-row starting offset (absolute address) of the array bytes. -1 for NULL rows. - */ - long[] arrayRowAddr; - /** - * ARRAY: per-row length in bytes of the array payload. + * Absolute payload address where this column's non-null values start. For + * fixed-width types this is the dense values array. For strings/varchars + * it's the offsets array. For symbols it's where the dict starts; the + * per-row IDs are materialised into {@link #symbolRowIds} during parse. */ - int[] arrayRowLen; - + long valuesAddr; /** - * Absolute address of the first byte after this column's data -- used to walk to the next column. + * SYMBOL non-delta only: native buffer owned by this layout that holds the + * per-batch packed entries when the column carries its own dict. In delta + * mode this is 0 and {@link #symbolDictEntriesAddr} points at the decoder's + * shared array instead. */ - long nextAddr; + private long ownedEntriesAddr; + private int ownedEntriesCapacity; public void clear() { info = null; @@ -113,7 +114,32 @@ public void clear() { nullBitmapAddr = 0; nonNullCount = 0; stringBytesAddr = 0; + symbolDictHeapAddr = 0; + symbolDictEntriesAddr = 0; symbolDictSize = 0; nextAddr = 0; } + + @Override + public void close() { + if (ownedEntriesAddr != 0) { + Unsafe.free(ownedEntriesAddr, ownedEntriesCapacity, MemoryTag.NATIVE_DEFAULT); + ownedEntriesAddr = 0; + ownedEntriesCapacity = 0; + } + } + + /** + * Ensures the per-layout owned entries buffer is at least {@code requiredBytes} + * and returns its address. Used by non-delta mode where the column carries its + * own dictionary inline and we need a dedicated buffer for the packed entries. + */ + long ensureOwnedEntriesAddr(int requiredBytes) { + if (ownedEntriesCapacity < requiredBytes) { + int newCap = Math.max(ownedEntriesCapacity * 2, Math.max(64, requiredBytes)); + ownedEntriesAddr = Unsafe.realloc(ownedEntriesAddr, ownedEntriesCapacity, newCap, MemoryTag.NATIVE_DEFAULT); + ownedEntriesCapacity = newCap; + } + return ownedEntriesAddr; + } } diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpResultBatchDecoder.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpResultBatchDecoder.java index d5d44834..9d533ba1 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpResultBatchDecoder.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpResultBatchDecoder.java @@ -29,7 +29,6 @@ import io.questdb.client.std.ObjList; import io.questdb.client.std.QuietCloseable; import io.questdb.client.std.Unsafe; -import io.questdb.client.std.str.DirectUtf8String; import java.nio.charset.StandardCharsets; @@ -38,10 +37,10 @@ *

    * The decoder parses the payload in-place -- no values are copied out of the * WebSocket receive buffer. It maintains pooled {@link QwpColumnLayout} slots, - * per-column {@code int[]} index arrays, and per-column {@link DirectUtf8String} - * dict entries that are reused across batches. After the connection has seen - * its peak schema width and row count, decoding a batch allocates nothing on - * the JVM heap. + * per-column {@code int[]} index arrays, and a native SYMBOL dictionary + * (UTF-8 heap + packed offset/length entries) that is reused across batches. + * After the connection has seen its peak schema width and row count, decoding + * a batch allocates nothing on the JVM heap. *

    * The produced {@link QwpColumnBatch} is valid only during the surrounding * {@code onBatch} callback because its pointers refer into the caller's native @@ -49,6 +48,8 @@ */ public class QwpResultBatchDecoder implements QuietCloseable { + private static final int CONN_DICT_INITIAL_BYTES = 4096; + private static final int CONN_DICT_INITIAL_ENTRIES = 512; /** * Cap on per-row ARRAY element count. 8 bytes per element x this ~ 256 MB max payload, * which fits in {@code int} once {@code rowEnd - p} is computed. A malicious or buggy @@ -72,21 +73,25 @@ public class QwpResultBatchDecoder implements QuietCloseable { * a negative varint that long-to-int casts negative). */ private static final int MAX_SCHEMAS_PER_CONNECTION = 65_535; - private static final int CONN_DICT_INITIAL_BYTES = 4096; // Connection-scoped state (safe to share across buffers -- reused across batches // of the same query and across queries on the same connection). // Connection-scoped SYMBOL dictionary. Populated by {@link #parseDeltaSymbolDict} - // from the per-message delta section; the bytes live in a native heap owned by - // the decoder ({@link #connDictHeapAddr}) so DirectUtf8String views stay valid - // across batches (unlike per-batch flyweights which expire when the WS recv - // buffer recycles). Grows but never shrinks; freed on {@link #close}. - private final ObjList connSymDict = new ObjList<>(); + // from the per-message delta section. Two native buffers: + // - {@link #connDictHeapAddr} holds the concatenated UTF-8 bytes of every entry. + // - {@link #connDictEntriesAddr} holds one packed 8-byte pair per entry: + // {@code (offset:i32 | length:i32<<32)} relative to the heap base. + // Storing offsets (rather than absolute addresses) means heap reallocs don't + // invalidate entries; the hot-path accessor does one 64-bit load + base add, + // no object dereferences. Grows but never shrinks; freed on {@link #close}. // Registry indexed by schemaId. null = not registered. Schema ids are server-assigned // and small (monotonic from 0). private final ObjList> schemaRegistry = new ObjList<>(); + private long connDictEntriesAddr; + private int connDictEntriesCapacity; private long connDictHeapAddr; private int connDictHeapCapacity; private int connDictHeapPos; + private int connDictSize; // True when the current message carries {@code FLAG_DELTA_SYMBOL_DICT}. Read by // {@link #parseSymbolColumn} to decide whether to consume a per-column dict. private boolean deltaMode; @@ -103,7 +108,12 @@ public void close() { connDictHeapCapacity = 0; connDictHeapPos = 0; } - connSymDict.clear(); + if (connDictEntriesAddr != 0) { + Unsafe.free(connDictEntriesAddr, connDictEntriesCapacity, MemoryTag.NATIVE_DEFAULT); + connDictEntriesAddr = 0; + connDictEntriesCapacity = 0; + } + connDictSize = 0; } /** @@ -152,28 +162,6 @@ private static long[] ensureLongArray(long[] current, int size) { return new long[Math.max(size, current == null ? 16 : current.length * 2)]; } - private void ensureConnDictHeapCapacity(int required) { - if (connDictHeapCapacity >= required) return; - int newCap = Math.max(connDictHeapCapacity * 2, Math.max(CONN_DICT_INITIAL_BYTES, required)); - connDictHeapAddr = Unsafe.realloc(connDictHeapAddr, connDictHeapCapacity, newCap, MemoryTag.NATIVE_DEFAULT); - // Any existing DirectUtf8String views that point into the old memory are - // invalidated by realloc (the OS may move the mapping). Repoint every - // entry at its original offset in the fresh heap. - if (connDictHeapCapacity != newCap) { - long base = connDictHeapAddr; - int prevEnd = 0; - for (int i = 0, n = connSymDict.size(); i < n; i++) { - DirectUtf8String entry = connSymDict.getQuick(i); - int entryLen = entry.size(); - entry.of(base + prevEnd, base + prevEnd + entryLen); - prevEnd += entryLen; - } - } - connDictHeapCapacity = newCap; - } - - // Varint / string helpers - /** * STRING / VARCHAR: the offsets array is (nonNullCount+1) x uint32 starting at {@code p}, * followed by the concatenated UTF-8 bytes. @@ -203,8 +191,7 @@ private static String readUtf8(long p, long len) { return new String(bytes, StandardCharsets.UTF_8); } - // Per-column parse: advances through wire bytes, populates layout pointers, - // precomputes nonNullIdx for O(1) per-row access. + // Varint / string helpers private void decodePayload(QwpBatchBuffer buffer, long payload, int payloadLen) throws QwpDecodeException { if (payloadLen < QwpConstants.HEADER_SIZE + 10) { @@ -335,6 +322,26 @@ private void decodeVarint(long p, long limit) throws QwpDecodeException { varintPos = cur; } + // Per-column parse: advances through wire bytes, populates layout pointers, + // precomputes nonNullIdx for O(1) per-row access. + + private void ensureConnDictEntriesCapacity(int requiredEntries) { + int requiredBytes = requiredEntries * 8; + if (connDictEntriesCapacity >= requiredBytes) return; + int newCap = Math.max(connDictEntriesCapacity * 2, Math.max(CONN_DICT_INITIAL_ENTRIES * 8, requiredBytes)); + connDictEntriesAddr = Unsafe.realloc(connDictEntriesAddr, connDictEntriesCapacity, newCap, MemoryTag.NATIVE_DEFAULT); + connDictEntriesCapacity = newCap; + } + + private void ensureConnDictHeapCapacity(int required) { + if (connDictHeapCapacity >= required) return; + int newCap = Math.max(connDictHeapCapacity * 2, Math.max(CONN_DICT_INITIAL_BYTES, required)); + connDictHeapAddr = Unsafe.realloc(connDictHeapAddr, connDictHeapCapacity, newCap, MemoryTag.NATIVE_DEFAULT); + connDictHeapCapacity = newCap; + // No re-pointing needed: entries store offsets into the heap, not absolute + // addresses, so the hot-path accessor resolves against the current heap base. + } + private ObjList ensureSchemaSlot(int schemaId, int columnCount) { while (schemaRegistry.size() <= schemaId) { schemaRegistry.add(null); @@ -454,14 +461,14 @@ private long parseColumn(QwpColumnLayout layout, int rowCount, long p, long limi /** * Parses the message-level delta symbol dictionary section present when * {@code FLAG_DELTA_SYMBOL_DICT} is set. Copies newly-seen symbol bytes into - * the decoder's connection-scoped native heap and appends {@code DirectUtf8String} - * views over them to {@link #connSymDict}. + * the decoder's connection-scoped native heap and appends packed + * {@code (offset:i32 | length:i32<<32)} entries to {@link #connDictEntriesAddr}. *

          *   [deltaStartId: varint]
          *   [deltaCount:   varint]
          *   for each new entry: [length: varint][UTF-8 bytes]
          * 
    - * The server is required to emit {@code deltaStartId == connSymDict.size()} + * The server is required to emit {@code deltaStartId == connDictSize} * (otherwise the two ends are out of sync and we bail rather than silently * corrupt the dict). Returns the wire position just past the section. */ @@ -477,10 +484,12 @@ private long parseDeltaSymbolDict(long p, long limit) throws QwpDecodeException throw new QwpDecodeException("delta symbol section out of range: start=" + deltaStart + ", count=" + deltaCount); } - if (deltaStart != connSymDict.size()) { + if (deltaStart != connDictSize) { throw new QwpDecodeException("delta symbol dict out of sync: expected start=" - + connSymDict.size() + ", got=" + deltaStart); + + connDictSize + ", got=" + deltaStart); } + int newSize = connDictSize + (int) deltaCount; + ensureConnDictEntriesCapacity(newSize); for (long i = 0; i < deltaCount; i++) { decodeVarint(p, limit); long entryLen = varintValue; @@ -490,11 +499,14 @@ private long parseDeltaSymbolDict(long p, long limit) throws QwpDecodeException } int len = (int) entryLen; ensureConnDictHeapCapacity(connDictHeapPos + len); - long dst = connDictHeapAddr + connDictHeapPos; - Unsafe.getUnsafe().copyMemory(p, dst, len); - DirectUtf8String entry = new DirectUtf8String(); - entry.of(dst, dst + len); - connSymDict.add(entry); + int offset = connDictHeapPos; + Unsafe.getUnsafe().copyMemory(p, connDictHeapAddr + offset, len); + // Pack (offset, length) into one 8-byte entry. Low 32 bits = offset, + // high 32 bits = length. Single 64-bit load + two int extractions in + // the hot-path accessor. + long packed = (offset & 0xFFFFFFFFL) | ((long) len << 32); + Unsafe.getUnsafe().putLong(connDictEntriesAddr + 8L * connDictSize, packed); + connDictSize++; connDictHeapPos += len; p += len; } @@ -537,34 +549,39 @@ private long parseNullSection(QwpColumnLayout layout, int rowCount, long p, long /** * SYMBOL: in delta mode (always, with the current server) there's no per-column - * dictionary -- indices reference the connection-scoped {@link #connSymDict} - * populated by {@link #parseDeltaSymbolDict}. In non-delta mode the column - * carries its own dict: (dict_size varint, then len+bytes per entry), followed - * by per-non-null-row varint indices. + * dictionary -- indices reference the connection-scoped dict built up by + * {@link #parseDeltaSymbolDict}. In non-delta mode the column carries its own + * dict: (dict_size varint, then len+bytes per entry), followed by per-non-null-row + * varint indices. */ private long parseSymbolColumn(QwpColumnLayout layout, int rowCount, long p, long limit) throws QwpDecodeException { final int dictSize; if (deltaMode) { - // Point the column's dict at the connection-scoped list; size is - // whatever the dict has grown to across all batches on this connection. - layout.symbolDict = connSymDict; - dictSize = connSymDict.size(); + // Point the column's dict at the connection-scoped native arrays; size + // is whatever the dict has grown to across all batches on this connection. + layout.symbolDictHeapAddr = connDictHeapAddr; + layout.symbolDictEntriesAddr = connDictEntriesAddr; + dictSize = connDictSize; } else { decodeVarint(p, limit); dictSize = (int) varintValue; p = varintPos; - // Ensure pool size - while (layout.symbolDict.size() < dictSize) { - layout.symbolDict.add(new DirectUtf8String()); - } + // Non-delta: dict entries point directly into the payload buffer. Build + // a per-layout packed-entries array keyed by offset-from-the-dict-base. + long dictBase = p; + long entriesAddr = layout.ensureOwnedEntriesAddr(dictSize * 8); for (int e = 0; e < dictSize; e++) { decodeVarint(p, limit); int entryLen = (int) varintValue; p = varintPos; if (p + entryLen > limit) throw new QwpDecodeException("truncated symbol entry"); - layout.symbolDict.getQuick(e).of(p, p + entryLen); + int offset = (int) (p - dictBase); + long packed = (offset & 0xFFFFFFFFL) | ((long) entryLen << 32); + Unsafe.getUnsafe().putLong(entriesAddr + 8L * e, packed); p += entryLen; } + layout.symbolDictHeapAddr = dictBase; + layout.symbolDictEntriesAddr = entriesAddr; } layout.symbolDictSize = dictSize; // Materialise per-row IDs into int[rowCount] so random access is O(1). @@ -580,7 +597,7 @@ private long parseSymbolColumn(QwpColumnLayout layout, int rowCount, long p, lon } layout.symbolRowIds[i] = id; } - layout.valuesAddr = 0; // Not applicable; accessors use symbolRowIds + symbolDict. + layout.valuesAddr = 0; // Not applicable; accessors use symbolRowIds + symbolDictEntriesAddr. return p; } From 81237f099bdca9d494bfd3ad64a2b99307fa47de Mon Sep 17 00:00:00 2001 From: bluestreak Date: Sun, 19 Apr 2026 17:58:01 +0100 Subject: [PATCH 13/44] wip 19 --- .../cutlass/qwp/client/QwpColumnLayout.java | 27 +++++++ .../qwp/client/QwpResultBatchDecoder.java | 71 ++++++++++++++++++- 2 files changed, 95 insertions(+), 3 deletions(-) diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpColumnLayout.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpColumnLayout.java index aaf74464..e1030f6e 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpColumnLayout.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpColumnLayout.java @@ -107,6 +107,15 @@ public class QwpColumnLayout implements QuietCloseable { */ private long ownedEntriesAddr; private int ownedEntriesCapacity; + /** + * TIMESTAMP / TIMESTAMP_NANOS / DATE Gorilla decode buffer owned by this + * layout. Populated when the per-column encoding discriminator is + * {@code 0x01}; {@link #valuesAddr} is then pointed at this buffer so the + * user-facing {@code getLong(col, row)} path sees decoded int64s. Unused + * (0) when the column was shipped uncompressed. + */ + private long timestampDecodeAddr; + private int timestampDecodeCapacity; public void clear() { info = null; @@ -127,6 +136,11 @@ public void close() { ownedEntriesAddr = 0; ownedEntriesCapacity = 0; } + if (timestampDecodeAddr != 0) { + Unsafe.free(timestampDecodeAddr, timestampDecodeCapacity, MemoryTag.NATIVE_DEFAULT); + timestampDecodeAddr = 0; + timestampDecodeCapacity = 0; + } } /** @@ -142,4 +156,17 @@ long ensureOwnedEntriesAddr(int requiredBytes) { } return ownedEntriesAddr; } + + /** + * Ensures the per-layout Gorilla decode buffer is at least {@code requiredBytes} + * and returns its address. + */ + long ensureTimestampDecodeAddr(int requiredBytes) { + if (timestampDecodeCapacity < requiredBytes) { + int newCap = Math.max(timestampDecodeCapacity * 2, Math.max(64, requiredBytes)); + timestampDecodeAddr = Unsafe.realloc(timestampDecodeAddr, timestampDecodeCapacity, newCap, MemoryTag.NATIVE_DEFAULT); + timestampDecodeCapacity = newCap; + } + return timestampDecodeAddr; + } } diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpResultBatchDecoder.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpResultBatchDecoder.java index 9d533ba1..6e262721 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpResultBatchDecoder.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpResultBatchDecoder.java @@ -25,6 +25,7 @@ package io.questdb.client.cutlass.qwp.client; import io.questdb.client.cutlass.qwp.protocol.QwpConstants; +import io.questdb.client.cutlass.qwp.protocol.QwpGorillaDecoder; import io.questdb.client.std.MemoryTag; import io.questdb.client.std.ObjList; import io.questdb.client.std.QuietCloseable; @@ -73,6 +74,11 @@ public class QwpResultBatchDecoder implements QuietCloseable { * a negative varint that long-to-int casts negative). */ private static final int MAX_SCHEMAS_PER_CONNECTION = 65_535; + // Reused for Gorilla-encoded TIMESTAMP / TIMESTAMP_NANOS / DATE columns. + // Stateful per-column -- {@link #parseTimestampColumn} calls {@code reset} + // before decoding each column so residue from a previous column never bleeds + // in. + private final QwpGorillaDecoder gorillaDecoder = new QwpGorillaDecoder(); // Connection-scoped state (safe to share across buffers -- reused across batches // of the same query and across queries on the same connection). // Connection-scoped SYMBOL dictionary. Populated by {@link #parseDeltaSymbolDict} @@ -95,6 +101,10 @@ public class QwpResultBatchDecoder implements QuietCloseable { // True when the current message carries {@code FLAG_DELTA_SYMBOL_DICT}. Read by // {@link #parseSymbolColumn} to decide whether to consume a per-column dict. private boolean deltaMode; + // True when the current message carries {@code FLAG_GORILLA}. When set, + // TIMESTAMP / TIMESTAMP_NANOS / DATE columns are prefixed by a 1-byte + // encoding discriminator (0x00 raw, 0x01 Gorilla). + private boolean gorillaMode; // Reusable varint decode state: value in varintValue, new position in varintPos. // Instance-level so no {@code long[2]} scratch is allocated per call. private long varintPos; @@ -208,6 +218,7 @@ private void decodePayload(QwpBatchBuffer buffer, long payload, int payloadLen) } byte flags = Unsafe.getUnsafe().getByte(payload + QwpConstants.HEADER_OFFSET_FLAGS); deltaMode = (flags & QwpConstants.FLAG_DELTA_SYMBOL_DICT) != 0; + gorillaMode = (flags & QwpConstants.FLAG_GORILLA) != 0; long p = payload + QwpConstants.HEADER_SIZE; long limit = payload + payloadLen; @@ -411,11 +422,14 @@ private long parseColumn(QwpColumnLayout layout, int rowCount, long p, long limi if (wt == QwpConstants.TYPE_SHORT || wt == QwpConstants.TYPE_CHAR) return advanceFixed(layout, p, limit, 2); if (wt == QwpConstants.TYPE_INT || wt == QwpConstants.TYPE_FLOAT || wt == QwpConstants.TYPE_IPv4) return advanceFixed(layout, p, limit, 4); - if (wt == QwpConstants.TYPE_LONG || wt == QwpConstants.TYPE_DOUBLE - || wt == QwpConstants.TYPE_DATE - || wt == QwpConstants.TYPE_TIMESTAMP || wt == QwpConstants.TYPE_TIMESTAMP_NANOS) { + if (wt == QwpConstants.TYPE_LONG || wt == QwpConstants.TYPE_DOUBLE) { return advanceFixed(layout, p, limit, 8); } + if (wt == QwpConstants.TYPE_DATE + || wt == QwpConstants.TYPE_TIMESTAMP + || wt == QwpConstants.TYPE_TIMESTAMP_NANOS) { + return parseTimestampColumn(layout, p, limit); + } if (wt == QwpConstants.TYPE_DECIMAL64) { if (p >= limit) throw new QwpDecodeException("truncated DECIMAL64 scale"); layout.info.scale = Unsafe.getUnsafe().getByte(p++) & 0xFF; @@ -601,6 +615,57 @@ private long parseSymbolColumn(QwpColumnLayout layout, int rowCount, long p, lon return p; } + /** + * TIMESTAMP / TIMESTAMP_NANOS / DATE: with {@code FLAG_GORILLA} set on the + * message, the column is prefixed with a 1-byte encoding discriminator + * ({@code 0x00} uncompressed, {@code 0x01} Gorilla bitstream). Without the + * flag the column is plain 8-byte fixed-width -- the pre-Gorilla format. + *

    + * Uncompressed: {@link QwpColumnLayout#valuesAddr} points directly at the + * wire bytes. Gorilla: we decode into a per-layout native i64 buffer and + * point {@code valuesAddr} at it, so the caller's {@code getLong(col, row)} + * path is identical in both cases. + */ + private long parseTimestampColumn(QwpColumnLayout layout, long p, long limit) throws QwpDecodeException { + int nonNull = layout.nonNullCount; + if (!gorillaMode) { + return advanceFixed(layout, p, limit, 8); + } + if (p >= limit) throw new QwpDecodeException("truncated TIMESTAMP encoding byte"); + byte encoding = Unsafe.getUnsafe().getByte(p++); + if (encoding == 0x00) { + // Uncompressed: bytes sit inline just like advanceFixed. + long total = (long) nonNull * 8L; + if (p + total > limit) throw new QwpDecodeException("truncated TIMESTAMP raw values"); + layout.valuesAddr = p; + return p + total; + } + if (encoding != 0x01) { + throw new QwpDecodeException("unknown TIMESTAMP encoding 0x" + Integer.toHexString(encoding & 0xFF)); + } + // Gorilla: first two timestamps are uncompressed (16 bytes), the rest is + // a bitstream. Server shortcuts nonNull<3 to the uncompressed branch, so + // we always have at least 3 values here. + if (nonNull < 3) throw new QwpDecodeException("Gorilla-encoded column with nonNull<3: " + nonNull); + if (p + 16L > limit) throw new QwpDecodeException("truncated Gorilla prefix"); + long firstTs = Unsafe.getUnsafe().getLong(p); + long secondTs = Unsafe.getUnsafe().getLong(p + 8L); + long bitstreamStart = p + 16L; + long decodeAddr = layout.ensureTimestampDecodeAddr(nonNull * 8); + Unsafe.getUnsafe().putLong(decodeAddr, firstTs); + Unsafe.getUnsafe().putLong(decodeAddr + 8L, secondTs); + gorillaDecoder.reset(firstTs, secondTs, bitstreamStart, limit - bitstreamStart); + for (int i = 2; i < nonNull; i++) { + Unsafe.getUnsafe().putLong(decodeAddr + (long) i * 8L, gorillaDecoder.decodeNext()); + } + layout.valuesAddr = decodeAddr; + long bitPos = gorillaDecoder.getBitPosition(); + long bitstreamBytes = (bitPos + 7L) >>> 3; + long end = bitstreamStart + bitstreamBytes; + if (end > limit) throw new QwpDecodeException("truncated Gorilla bitstream"); + return end; + } + // Batch reset private void resetBatch( From 90c8ff5a599531d1345c8c361bc99341f2c8f6b4 Mon Sep 17 00:00:00 2001 From: bluestreak Date: Sun, 19 Apr 2026 22:22:12 +0100 Subject: [PATCH 14/44] wip 21 --- .../cutlass/qwp/client/QwpEgressIoThread.java | 56 ++++++++++++++++++- .../cutlass/qwp/client/QwpEgressMsgKind.java | 13 +++++ .../cutlass/qwp/client/QwpQueryClient.java | 53 +++++++++++++----- 3 files changed, 106 insertions(+), 16 deletions(-) diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpEgressIoThread.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpEgressIoThread.java index a6ec3625..9ef68683 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpEgressIoThread.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpEgressIoThread.java @@ -34,6 +34,7 @@ import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.BlockingQueue; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; /** * Dedicated I/O thread that owns the client's {@link WebSocketClient} and drives @@ -57,6 +58,12 @@ public class QwpEgressIoThread implements Runnable, WebSocketFrameHandler { private final BlockingQueue events; // Pool of pre-allocated buffers. I/O thread takes, user thread releases. private final BlockingQueue freeBuffers; + // Pending CANCEL requestId set by the user thread via {@link #requestCancel}. + // The I/O thread polls this between {@code receiveFrame} iterations; when + // non-negative, it emits a CANCEL frame for that requestId and resets to -1. + // Using AtomicLong (not volatile long) to guarantee 64-bit atomicity on all + // supported JVMs. + private final AtomicLong pendingCancelRequestId = new AtomicLong(-1L); // One-slot release latch: user thread offers a token from releaseBuffer, I/O // thread drains it before returning from onBinaryMessage. Holds the payload // bytes in the WebSocket recv buffer steady for the duration of the user @@ -155,6 +162,17 @@ public void releaseBuffer(QwpBatchBuffer buffer) { pendingRelease.offer(RELEASE_TOKEN); } + /** + * Queues a CANCEL frame for {@code requestId} to be sent by the I/O thread + * between the next two {@code receiveFrame} iterations (typically within + * {@link #POLL_TIMEOUT_MS}). Safe to call from any thread. If a CANCEL for + * the same (or another) requestId is already pending, the newer id wins -- + * multiple concurrent cancels coalesce into one send. + */ + public void requestCancel(long requestId) { + pendingCancelRequestId.set(requestId); + } + @Override public void run() { try { @@ -174,6 +192,7 @@ public void run() { while (!currentQueryDone && !shutdown) { // onBinaryMessage (on this same thread) sets currentQueryDone. wsClient.receiveFrame(this, (int) POLL_TIMEOUT_MS); + drainPendingCancel(); } } } catch (Throwable t) { @@ -266,6 +285,19 @@ private long decodeResultEnd(long payload, int payloadLen) { return total; } + /** + * Flushes the pending cancel (if any) to the wire. Called from the I/O + * thread at every loop boundary so a cancel set by a user thread reaches + * the server regardless of whether the I/O thread was waiting on a frame + * or on a free buffer. + */ + private void drainPendingCancel() { + long id = pendingCancelRequestId.getAndSet(-1L); + if (id >= 0L) { + sendCancel(id); + } + } + private void emitError(byte status, String message) { events.offer(new QueryEvent().asError(status, message)); } @@ -286,7 +318,17 @@ private void emitErrorBlocking(byte status, String message) { private void handleResultBatch(long payloadPtr, int payloadLen) { QwpBatchBuffer buf; try { - buf = freeBuffers.take(); + // Poll rather than take so a pending cancel set by the user thread + // still gets flushed while the I/O thread is waiting for the user + // to release a buffer. Without this, an app that never releases + // (or sleeps in its handler) would wedge the cancel path -- the + // server never sees the CANCEL and keeps on streaming. + buf = freeBuffers.poll(POLL_TIMEOUT_MS, TimeUnit.MILLISECONDS); + while (buf == null && !shutdown) { + drainPendingCancel(); + buf = freeBuffers.poll(POLL_TIMEOUT_MS, TimeUnit.MILLISECONDS); + } + if (buf == null) return; } catch (InterruptedException ie) { return; } @@ -313,6 +355,18 @@ private void handleResultBatch(long payloadPtr, int payloadLen) { } } + /** + * Builds and transmits a CANCEL frame on the WebSocket. Wire format: + * {@code msg_kind(0x14) + request_id(u64)}. + */ + private void sendCancel(long requestId) { + sendScratch.reset(); + sendScratch.putByte(QwpEgressMsgKind.CANCEL); + sendScratch.putLong(requestId); + wsClient.sendBinary(sendScratch.getBufferPtr(), sendScratch.getPosition()); + sendScratch.reset(); + } + /** * Builds and transmits a QUERY_REQUEST frame on the WebSocket. */ diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpEgressMsgKind.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpEgressMsgKind.java index 7fbf6420..14b58221 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpEgressMsgKind.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpEgressMsgKind.java @@ -42,6 +42,19 @@ public final class QwpEgressMsgKind { public static final byte RESULT_BATCH = 0x11; public static final byte RESULT_END = 0x12; + // Egress-specific QUERY_ERROR status codes. Extend the ingress + // QwpConstants.STATUS_* namespace (0x00-0x09). + /** + * Status byte on a {@code QUERY_ERROR} frame: the query was cancelled, + * either by a client {@code CANCEL} frame or by explicit server-side cancel. + */ + public static final byte STATUS_CANCELLED = 0x0A; + /** + * Status byte on a {@code QUERY_ERROR} frame: a server-side limit was hit + * (query timeout, memory cap, circuit breaker). + */ + public static final byte STATUS_LIMIT_EXCEEDED = 0x0B; + private QwpEgressMsgKind() { } } diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpQueryClient.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpQueryClient.java index e1065e02..a274bc89 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpQueryClient.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpQueryClient.java @@ -63,6 +63,11 @@ public class QwpQueryClient implements QuietCloseable { private int bufferPoolSize = DEFAULT_IO_BUFFER_POOL_SIZE; private String clientId; private boolean connected; + // Written on the user thread at entry to {@link #execute} and cleared on exit. + // Read by {@link #cancel} from any thread. {@code volatile} to guarantee the + // user thread's write is visible to a concurrent cancel caller; 64-bit writes + // are atomic under {@code volatile long}. + private volatile long currentRequestId = -1L; private String endpointPath = DEFAULT_ENDPOINT_PATH; private QwpEgressIoThread ioThread; private Thread ioThreadHandle; @@ -189,6 +194,21 @@ public static QwpQueryClient newPlainText(CharSequence host, int port) { return new QwpQueryClient(host, port); } + /** + * Asks the server to cancel the currently executing query. No-op if no query + * is in flight. Safe to call from a thread other than the one blocked inside + * {@link #execute}. The server replies to the active query with a + * {@code QUERY_ERROR} whose status byte is {@code STATUS_CANCELLED}; the + * handler's {@code onError} (on the execute-ing thread) will see it. + */ + public void cancel() { + QwpEgressIoThread io = ioThread; + long id = currentRequestId; + if (io != null && id >= 0L) { + io.requestCancel(id); + } + } + /** * Shutdown order: signal the I/O thread, interrupt it to wake it from any blocking * {@code wsClient.receiveFrame(...)} or queue poll, wait for it to exit, then free @@ -240,16 +260,6 @@ public void close() { } } - /** - * Returns true if the most recent {@link #close()} call abandoned the I/O thread - * because it failed to exit within the join timeout. The native buffer pool and - * WebSocket socket are leaked for the lifetime of the JVM; the daemon I/O thread - * keeps running until process exit. - */ - public boolean wasLastCloseTimedOut() { - return lastCloseTimedOut; - } - /** * Opens the TCP connection, performs the WebSocket upgrade, and spawns the I/O thread. * Must be called before any query is submitted. @@ -294,6 +304,7 @@ public void execute(String sql, QwpColumnBatchHandler handler) { return; } long requestId = nextRequestId++; + currentRequestId = requestId; try { io.submitQuery(sql, requestId); while (true) { @@ -323,6 +334,8 @@ public void execute(String sql, QwpColumnBatchHandler handler) { } catch (InterruptedException ie) { Thread.currentThread().interrupt(); handler.onError((byte) 0, "interrupted while waiting for server response"); + } finally { + currentRequestId = -1L; } } @@ -334,6 +347,21 @@ public boolean isConnected() { return connected; } + /** + * Returns true if the most recent {@link #close()} call abandoned the I/O thread + * because it failed to exit within the join timeout. The native buffer pool and + * WebSocket socket are leaked for the lifetime of the JVM; the daemon I/O thread + * keeps running until process exit. + */ + public boolean wasLastCloseTimedOut() { + return lastCloseTimedOut; + } + + public QwpQueryClient withAuthorization(String authorizationHeader) { + this.authorizationHeader = authorizationHeader; + return this; + } + /** * Overrides the default I/O buffer pool depth (4). Larger pools let the * I/O thread decode further ahead of the consumer at the cost of memory; @@ -346,11 +374,6 @@ public QwpQueryClient withBufferPoolSize(int size) { return this; } - public QwpQueryClient withAuthorization(String authorizationHeader) { - this.authorizationHeader = authorizationHeader; - return this; - } - /** * Overrides the {@code X-QWP-Client-Id} header sent during the upgrade handshake. * Must be called before {@link #connect()}. From 0e7ee8ad8929096c565abe772197b92263e4b985 Mon Sep 17 00:00:00 2001 From: bluestreak Date: Sun, 19 Apr 2026 23:33:38 +0100 Subject: [PATCH 15/44] wip 22 --- .../cutlass/qwp/client/QwpEgressIoThread.java | 43 ++++++++-- .../cutlass/qwp/client/QwpQueryClient.java | 24 +++++- .../query/CreditFlowControlExample.java | 79 +++++++++++++++++++ .../query/LargeResultStreamingExample.java | 6 +- 4 files changed, 144 insertions(+), 8 deletions(-) create mode 100644 examples/src/main/java/com/example/query/CreditFlowControlExample.java diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpEgressIoThread.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpEgressIoThread.java index 9ef68683..d5bbb3cf 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpEgressIoThread.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpEgressIoThread.java @@ -73,6 +73,11 @@ public class QwpEgressIoThread implements Runnable, WebSocketFrameHandler { private final BlockingQueue requests = new ArrayBlockingQueue<>(1); private final NativeBufferWriter sendScratch = new NativeBufferWriter(); private final WebSocketClient wsClient; + // Per-query credit state (accessed only from the I/O thread). + // creditEnabled == (initialCredit > 0); controls whether we emit CREDIT + // replenish frames after each batch release. + private boolean creditEnabled; + private long currentRequestId = -1L; private boolean currentQueryDone; private volatile boolean shutdown; @@ -187,6 +192,8 @@ public void run() { // Per-query state; accessed only from the I/O thread. currentQueryDone = false; + currentRequestId = req.requestId; + creditEnabled = req.initialCredit > 0L; sendQueryRequest(req); while (!currentQueryDone && !shutdown) { @@ -219,10 +226,14 @@ public void shutdown() { } /** - * Blocking submission of a query. Called by the user thread. + * Blocking submission of a query. Called by the user thread. {@code initialCredit} + * is the client-advertised send-ahead budget in bytes -- 0 means "unbounded" + * (server streams without flow control, Phase-1 default); any positive value + * asks the server to suspend once it has emitted that many bytes and wait + * for a CREDIT frame. */ - public void submitQuery(String sql, long requestId) throws InterruptedException { - requests.put(new QueryRequest(sql, requestId)); + public void submitQuery(String sql, long requestId, long initialCredit) throws InterruptedException { + requests.put(new QueryRequest(sql, requestId, initialCredit)); } /** @@ -352,9 +363,29 @@ private void handleResultBatch(long payloadPtr, int payloadLen) { } catch (InterruptedException ie) { // Shutdown path: leave the batch to the user thread; they'll see // either the in-progress batch or a subsequent close event. + return; + } + // Credit replenish: the user is done with the batch, so the recv-buffer + // bytes are free. Tell the server it can stream {@code payloadLen} more + // bytes. No-op when the query wasn't started under flow control. + if (creditEnabled) { + sendCredit(currentRequestId, payloadLen); } } + /** + * Builds and transmits a CREDIT frame on the WebSocket. Wire format: + * {@code msg_kind(0x15) + request_id(u64) + additional_bytes(varint)}. + */ + private void sendCredit(long requestId, long additionalBytes) { + sendScratch.reset(); + sendScratch.putByte(QwpEgressMsgKind.CREDIT); + sendScratch.putLong(requestId); + sendScratch.putVarint(additionalBytes); + wsClient.sendBinary(sendScratch.getBufferPtr(), sendScratch.getPosition()); + sendScratch.reset(); + } + /** * Builds and transmits a CANCEL frame on the WebSocket. Wire format: * {@code msg_kind(0x14) + request_id(u64)}. @@ -379,7 +410,7 @@ private void sendQueryRequest(QueryRequest req) { for (byte b : sqlBytes) { sendScratch.putByte(b); } - sendScratch.putVarint(0); // initial_credit = 0 (unbounded) + sendScratch.putVarint(req.initialCredit); // 0 = unbounded (Phase-1 default) sendScratch.putVarint(0); // bind_count = 0 wsClient.sendBinary(sendScratch.getBufferPtr(), sendScratch.getPosition()); sendScratch.reset(); @@ -417,12 +448,14 @@ void closePool() { } private static final class QueryRequest { + final long initialCredit; final long requestId; final String sql; - QueryRequest(String sql, long requestId) { + QueryRequest(String sql, long requestId, long initialCredit) { this.sql = sql; this.requestId = requestId; + this.initialCredit = initialCredit; } } } diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpQueryClient.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpQueryClient.java index a274bc89..7ea5ea0a 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpQueryClient.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpQueryClient.java @@ -69,6 +69,12 @@ public class QwpQueryClient implements QuietCloseable { // are atomic under {@code volatile long}. private volatile long currentRequestId = -1L; private String endpointPath = DEFAULT_ENDPOINT_PATH; + // Credit-flow send-ahead budget. 0 = unbounded (Phase-1 default, no CREDIT + // bookkeeping on either side). A positive value puts the stream under byte- + // based flow control: the server emits at most this many bytes of result + // payload before it parks, and the client auto-replenishes by the size of + // each batch as the user releases it. + private long initialCreditBytes; private QwpEgressIoThread ioThread; private Thread ioThreadHandle; private boolean lastCloseTimedOut; @@ -306,7 +312,7 @@ public void execute(String sql, QwpColumnBatchHandler handler) { long requestId = nextRequestId++; currentRequestId = requestId; try { - io.submitQuery(sql, requestId); + io.submitQuery(sql, requestId, initialCreditBytes); while (true) { QueryEvent ev = io.takeEvent(); switch (ev.kind) { @@ -388,6 +394,22 @@ public QwpQueryClient withEndpointPath(String endpointPath) { return this; } + /** + * Opts the next {@link #execute} into credit-based flow control with + * {@code bytes} of initial send-ahead budget. The server streams at most + * {@code bytes} of result payload before pausing; the client auto- + * replenishes by the size of each batch after the user's handler releases + * it. Passing {@code 0} (the default) disables flow control entirely + * (unbounded -- Phase-1 behaviour). + *

    + * Must be called before {@link #connect}. + */ + public QwpQueryClient withInitialCredit(long bytes) { + if (bytes < 0) throw new IllegalArgumentException("initial credit must be >= 0"); + this.initialCreditBytes = bytes; + return this; + } + private static String defaultClientId() { return "questdb-java-egress/1.0.0"; } diff --git a/examples/src/main/java/com/example/query/CreditFlowControlExample.java b/examples/src/main/java/com/example/query/CreditFlowControlExample.java new file mode 100644 index 00000000..0acd7a9a --- /dev/null +++ b/examples/src/main/java/com/example/query/CreditFlowControlExample.java @@ -0,0 +1,79 @@ +package com.example.query; + +import io.questdb.client.cutlass.qwp.client.QwpColumnBatch; +import io.questdb.client.cutlass.qwp.client.QwpColumnBatchHandler; +import io.questdb.client.cutlass.qwp.client.QwpQueryClient; + +/** + * Byte-budgeted flow control for large streaming queries. + *

    + * When a consumer is slow (throttled network, expensive per-row processing, + * transient back-pressure in a downstream sink), you can put the stream under + * explicit flow control by calling {@link QwpQueryClient#withInitialCredit} + * before {@code connect}. The server then streams at most that many bytes of + * result payload before pausing; the client's I/O thread auto-replenishes by + * the size of each batch as soon as the user's {@code onBatch} handler returns. + *

    + * Benefits: + *

      + *
    • Bounded client-side memory -- the client never holds more than + * roughly {@code initialCredit} bytes of unread result data, even if + * the handler pauses, sleeps, or does blocking I/O.
    • + *
    • Bounded server-side outstanding work -- the server parks its cursor + * when credit is exhausted instead of blasting bytes into a kernel + * buffer that a slow consumer can't drain.
    • + *
    • Back-pressure propagation that's explicit on the wire rather than + * relying solely on TCP window behaviour -- useful across proxies, + * load balancers, or WebSocket gateways that may or may not propagate + * TCP-level back-pressure cleanly.
    • + *
    + *

    + * Defaults ({@code withInitialCredit} not called, or {@code 0}): the stream + * is unbounded; no CREDIT frames are emitted. This is the Phase-1 behaviour + * and is the right choice for local / fast-network consumers that can keep + * up with whatever the server emits. + *

    + * Sizing guidance: make {@code initialCredit} at least a few times the batch + * size (server emits batches up to a few hundred KB). A value like + * {@code 256 * 1024} (256 KiB) is a reasonable starting point -- large enough + * that the server can send a couple of batches ahead, small enough to cap + * memory tightly on slow consumers. + */ +public class CreditFlowControlExample { + + public static void main(String[] args) { + try (QwpQueryClient client = QwpQueryClient.newPlainText("localhost", 9000) + // 256 KiB send-ahead budget. Server pauses once it has streamed + // that many bytes of result payload; client auto-replenishes by + // the byte-size of each batch once the handler releases it. + .withInitialCredit(256 * 1024)) { + client.connect(); + + final long[] rowsSeen = {0}; + client.execute( + "SELECT ts, price FROM trades WHERE ts > dateadd('d', -30, now())", + new QwpColumnBatchHandler() { + @Override + public void onBatch(QwpColumnBatch batch) { + // Process the batch. If you block here (e.g. writing to a + // slow downstream), the server naturally parks until this + // returns and the credit-replenish frame catches up. + rowsSeen[0] += batch.getRowCount(); + } + + @Override + public void onEnd(long totalRows) { + System.out.printf("done: rows=%d%n", rowsSeen[0]); + } + + @Override + public void onError(byte status, String message) { + throw new RuntimeException( + "query failed (status=" + status + "): " + message + ); + } + } + ); + } + } +} diff --git a/examples/src/main/java/com/example/query/LargeResultStreamingExample.java b/examples/src/main/java/com/example/query/LargeResultStreamingExample.java index ef0f9447..65afccc0 100644 --- a/examples/src/main/java/com/example/query/LargeResultStreamingExample.java +++ b/examples/src/main/java/com/example/query/LargeResultStreamingExample.java @@ -14,8 +14,10 @@ *

    * The server streams continuously until the cursor is exhausted; batches arrive * on the calling thread inside {@code client.execute(...)} as the WebSocket - * yields frames. Nothing you write on the client influences pacing in Phase 1 - * (credit-based flow control is a follow-up). + * yields frames. Pacing naturally follows TCP back-pressure -- if the consumer + * is slower than the network, the kernel send buffer fills and the server + * parks between batches. For explicit byte-level flow control on top of that, + * see {@link CreditFlowControlExample}. */ public class LargeResultStreamingExample { From c756825f81ede01d1b449095c6d16ba81199b4d8 Mon Sep 17 00:00:00 2001 From: GitHub Actions - Rebuild Native Libraries Date: Mon, 20 Apr 2026 00:40:53 +0000 Subject: [PATCH 16/44] Rebuild CXX libraries --- .../bin/darwin-aarch64/libquestdb.dylib | Bin 38688 -> 38688 bytes .../client/bin/darwin-x86-64/libquestdb.dylib | Bin 44504 -> 44504 bytes .../client/bin/linux-aarch64/libquestdb.so | Bin 208576 -> 208576 bytes .../client/bin/linux-x86-64/libquestdb.so | Bin 62184 -> 62184 bytes .../client/bin/windows-x86-64/libquestdb.dll | Bin 38912 -> 38912 bytes 5 files changed, 0 insertions(+), 0 deletions(-) diff --git a/core/src/main/resources/io/questdb/client/bin/darwin-aarch64/libquestdb.dylib b/core/src/main/resources/io/questdb/client/bin/darwin-aarch64/libquestdb.dylib index 38f76b86334312a2ad0988a896aacb6b5ef511db..15dc9a5785a7f69835e81e8a115d1d4a63d27d55 100644 GIT binary patch delta 2154 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 AasU7T diff --git a/core/src/main/resources/io/questdb/client/bin/darwin-x86-64/libquestdb.dylib b/core/src/main/resources/io/questdb/client/bin/darwin-x86-64/libquestdb.dylib index 72d228a183138372ad2b9d9eb367f016dea1ed5f..987af3ad490a60b67101e7c02a85397d11330506 100644 GIT binary patch delta 9135 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 E0BKx^z5oCK diff --git a/core/src/main/resources/io/questdb/client/bin/linux-aarch64/libquestdb.so b/core/src/main/resources/io/questdb/client/bin/linux-aarch64/libquestdb.so index 76158e167fee131403af79b61a477f0b5714af2a..bd4e7d0ee71b05f36efd5b2a6cd5705701d10eef 100644 GIT binary patch delta 2417 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 diff --git a/core/src/main/resources/io/questdb/client/bin/linux-x86-64/libquestdb.so b/core/src/main/resources/io/questdb/client/bin/linux-x86-64/libquestdb.so index 426586bf94a759f6e8eded39349061445461539e..a690a805534be2182c0ba53e3269b0c7a037c810 100644 GIT binary patch delta 11804 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 delta 11798 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 delta 5462 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 From de85c22934357b3b232da9d579113dab40ad4f51 Mon Sep 17 00:00:00 2001 From: bluestreak Date: Mon, 20 Apr 2026 01:57:23 +0100 Subject: [PATCH 17/44] zstd --- .gitmodules | 3 + core/CMakeLists.txt | 50 +++++++ core/src/main/c/share/zstd | 1 + core/src/main/c/share/zstd_jni.c | 70 +++++++++ .../cutlass/http/client/WebSocketClient.java | 23 ++- .../cutlass/qwp/client/QwpQueryClient.java | 138 ++++++++++++++++-- .../qwp/client/QwpResultBatchDecoder.java | 77 +++++++++- .../cutlass/qwp/protocol/QwpConstants.java | 6 + .../main/java/io/questdb/client/std/Zstd.java | 49 +++++++ .../com/example/query/CompressionExample.java | 87 +++++++++++ 10 files changed, 492 insertions(+), 12 deletions(-) create mode 100644 .gitmodules create mode 160000 core/src/main/c/share/zstd create mode 100644 core/src/main/c/share/zstd_jni.c create mode 100644 core/src/main/java/io/questdb/client/std/Zstd.java create mode 100644 examples/src/main/java/com/example/query/CompressionExample.java diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..ee5adfb6 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "core/src/main/c/share/zstd"] + path = core/src/main/c/share/zstd + url = https://github.com/facebook/zstd.git diff --git a/core/CMakeLists.txt b/core/CMakeLists.txt index 87ebb009..887d4b85 100644 --- a/core/CMakeLists.txt +++ b/core/CMakeLists.txt @@ -49,6 +49,42 @@ set( src/main/c/share/byte_sink.h ) +# libzstd is included via a git submodule at src/main/c/share/zstd (pinned to +# upstream tag v1.5.7). Covers the client side of the QWP egress compression +# feature; the server-side compressor lives in the Rust qdbr crate and isn't +# linked into this library. Only the decompress-only subset of upstream is +# compiled -- the compress/ directory is left out entirely. zstd_jni.c is our +# JNI glue and lives alongside the submodule (not inside) so upstream resets +# don't disturb it. +if (NOT EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/src/main/c/share/zstd/lib/zstd.h) + message(FATAL_ERROR + "libzstd submodule not initialised. Run:\n" + " git submodule update --init --recursive\n" + "from java-questdb-client/.") +endif () +set( + ZSTD_FILES + src/main/c/share/zstd_jni.c + src/main/c/share/zstd/lib/common/debug.c + src/main/c/share/zstd/lib/common/entropy_common.c + src/main/c/share/zstd/lib/common/error_private.c + src/main/c/share/zstd/lib/common/fse_decompress.c + src/main/c/share/zstd/lib/common/pool.c + src/main/c/share/zstd/lib/common/threading.c + src/main/c/share/zstd/lib/common/xxhash.c + src/main/c/share/zstd/lib/common/zstd_common.c + src/main/c/share/zstd/lib/decompress/huf_decompress.c + src/main/c/share/zstd/lib/decompress/zstd_ddict.c + src/main/c/share/zstd/lib/decompress/zstd_decompress_block.c + src/main/c/share/zstd/lib/decompress/zstd_decompress.c +) +# x86_64-only hand-tuned Huffman decoder; C fallback kicks in when +# ZSTD_DISABLE_ASM is set. +if (ARCH_AMD64 AND NOT WIN32) + list(APPEND ZSTD_FILES src/main/c/share/zstd/lib/decompress/huf_decompress_amd64.S) +endif () +list(APPEND SOURCE_FILES ${ZSTD_FILES}) + # JNI includes include_directories($ENV{JAVA_HOME}/include/) @@ -111,6 +147,20 @@ set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${OUTPUT}) add_library(questdb SHARED ${SOURCE_FILES}) +# libzstd public header is at zstd/lib/zstd.h; internal headers live under +# zstd/lib/common/. Both directories go on the include path so zstd_jni.c can +# use the short include "zstd.h" and the upstream .c files can find their own +# siblings without patching. +target_include_directories(questdb PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/src/main/c/share/zstd/lib + ${CMAKE_CURRENT_SOURCE_DIR}/src/main/c/share/zstd/lib/common) + +# Drop the zstd-internal hand-written amd64 assembly on platforms that can't +# assemble it; libzstd falls back to a C implementation when this is set. +if (NOT ARCH_AMD64 OR WIN32) + target_compile_definitions(questdb PRIVATE ZSTD_DISABLE_ASM=1) +endif () + set(COMMON_OPTIONS "-Wno-gnu-anonymous-struct;-Wno-nested-anon-types;-Wno-unused-parameter;-fPIC;-fno-rtti;-fno-exceptions") set(DEBUG_OPTIONS "-Wall;-pedantic;-Wextra;-g;-O0") diff --git a/core/src/main/c/share/zstd b/core/src/main/c/share/zstd new file mode 160000 index 00000000..f8745da6 --- /dev/null +++ b/core/src/main/c/share/zstd @@ -0,0 +1 @@ +Subproject commit f8745da6ff1ad1e7bab384bd1f9d742439278e99 diff --git a/core/src/main/c/share/zstd_jni.c b/core/src/main/c/share/zstd_jni.c new file mode 100644 index 00000000..e811fbd0 --- /dev/null +++ b/core/src/main/c/share/zstd_jni.c @@ -0,0 +1,70 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +/* + * JNI wrapper over the bundled libzstd (decompression only). The server ships + * compression support in the Rust qdbr crate; this file covers the client + * decompression path so RESULT_BATCH frames with FLAG_ZSTD can be decoded + * without any external native dependency. + * + * libzstd is vendored as a git submodule at share/zstd/ pinned to v1.5.7; + * this file lives alongside (not inside) the submodule so upstream resets + * don't nuke our JNI glue. + */ + +#include +#include +#include "zstd.h" + +JNIEXPORT jlong JNICALL Java_io_questdb_client_std_Zstd_createDCtx( + JNIEnv *env, jclass cls) { + return (jlong) (uintptr_t) ZSTD_createDCtx(); +} + +JNIEXPORT void JNICALL Java_io_questdb_client_std_Zstd_freeDCtx( + JNIEnv *env, jclass cls, jlong ptr) { + if (ptr != 0) { + ZSTD_freeDCtx((ZSTD_DCtx *) (uintptr_t) ptr); + } +} + +JNIEXPORT jlong JNICALL Java_io_questdb_client_std_Zstd_decompress( + JNIEnv *env, jclass cls, + jlong ctx, + jlong src_addr, jlong src_len, + jlong dst_addr, jlong dst_cap) { + if (ctx == 0) { + return -1; + } + ZSTD_DCtx *dctx = (ZSTD_DCtx *) (uintptr_t) ctx; + size_t n = ZSTD_decompressDCtx( + dctx, + (void *) (uintptr_t) dst_addr, (size_t) dst_cap, + (const void *) (uintptr_t) src_addr, (size_t) src_len); + if (ZSTD_isError(n)) { + unsigned code = ZSTD_getErrorCode(n); + return -(jlong) code; + } + return (jlong) n; +} diff --git a/core/src/main/java/io/questdb/client/cutlass/http/client/WebSocketClient.java b/core/src/main/java/io/questdb/client/cutlass/http/client/WebSocketClient.java index fc93a1e5..60ee70a8 100644 --- a/core/src/main/java/io/questdb/client/cutlass/http/client/WebSocketClient.java +++ b/core/src/main/java/io/questdb/client/cutlass/http/client/WebSocketClient.java @@ -105,6 +105,12 @@ public abstract class WebSocketClient implements QuietCloseable { private CharSequence host; private int port; // QWP version negotiation + // Verbatim header value sent as X-QWP-Accept-Encoding during upgrade, e.g. + // "zstd;level=3,raw". When null, the header is omitted and the server ships + // batches uncompressed. The echoed X-QWP-Content-Encoding response header + // is intentionally not parsed: the RESULT_BATCH decoder branches on + // FLAG_ZSTD in every frame, which is the authoritative signal. + private String qwpAcceptEncoding; private String qwpClientId; private int qwpMaxVersion = 1; // Receive buffer (native memory) @@ -376,6 +382,16 @@ public void sendPing(int timeout) { } } + /** + * Sets the value sent as the {@code X-QWP-Accept-Encoding} upgrade header, + * e.g. {@code "zstd;level=3,raw"}. Pass {@code null} to omit the header + * entirely (server ships uncompressed batches). Must be called before + * {@link #upgrade}. + */ + public void setQwpAcceptEncoding(String acceptEncoding) { + this.qwpAcceptEncoding = acceptEncoding; + } + /** * Sets the QWP client identifier sent in the X-QWP-Client-Id upgrade header. */ @@ -476,6 +492,11 @@ public void upgrade(CharSequence path, int timeout, CharSequence authorizationHe sendBuffer.putAscii(qwpClientId); sendBuffer.putAscii("\r\n"); } + if (qwpAcceptEncoding != null) { + sendBuffer.putAscii("X-QWP-Accept-Encoding: "); + sendBuffer.putAscii(qwpAcceptEncoding); + sendBuffer.putAscii("\r\n"); + } if (authorizationHeader != null) { sendBuffer.putAscii("Authorization: "); sendBuffer.putAscii(authorizationHeader); @@ -958,7 +979,7 @@ private void validateUpgradeResponse(int headerEnd) { throw new HttpClientException("Invalid Sec-WebSocket-Accept header"); } - // Extract X-QWP-Version (optional — defaults to 1 if absent) + // Extract X-QWP-Version (optional, defaults to 1 if absent) serverQwpVersion = extractQwpVersion(response); } diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpQueryClient.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpQueryClient.java index 7ea5ea0a..ce681f28 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpQueryClient.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpQueryClient.java @@ -24,12 +24,16 @@ package io.questdb.client.cutlass.qwp.client; +import io.questdb.client.cutlass.http.client.HttpClientException; import io.questdb.client.cutlass.http.client.WebSocketClient; import io.questdb.client.cutlass.http.client.WebSocketClientFactory; import io.questdb.client.impl.ConfStringParser; import io.questdb.client.std.Chars; import io.questdb.client.std.QuietCloseable; +import io.questdb.client.std.Zstd; import io.questdb.client.std.str.StringSink; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * QWP egress (query results) client. @@ -50,18 +54,18 @@ public class QwpQueryClient implements QuietCloseable { public static final int DEFAULT_WS_PORT = 9000; public static final int QWP_MAX_VERSION = 1; private static final int DEFAULT_IO_BUFFER_POOL_SIZE = 4; - /** - * Maximum time {@link #close()} will wait for the I/O thread to exit before giving up - * and leaking the (daemon) thread + its native buffer pool + WebSocket socket. 5 seconds - * is generous given the I/O thread polls on a 100 ms cadence; if it overshoots this, - * something is seriously wrong (e.g., user handler stuck in onBatch). - */ - private static final long SHUTDOWN_JOIN_MS = 5_000; + private static final Logger LOG = LoggerFactory.getLogger(QwpQueryClient.class); private final CharSequence host; private final int port; private String authorizationHeader; private int bufferPoolSize = DEFAULT_IO_BUFFER_POOL_SIZE; private String clientId; + private int compressionLevel = 3; + // User-facing compression preference from the connection string. "auto" is + // the default and advertises "zstd,raw" to the server. The actual codec + // used on the wire is whatever the server echoes back in the 101 response; + // if the server ignores the header or picks raw, decompression stays off. + private String compressionPreference = "auto"; private boolean connected; // Written on the user thread at entry to {@link #execute} and cleared on exit. // Read by {@link #cancel} from any thread. {@code volatile} to guarantee the @@ -80,6 +84,14 @@ public class QwpQueryClient implements QuietCloseable { private boolean lastCloseTimedOut; private int negotiatedQwpVersion; private long nextRequestId = 1; + // Maximum time close() will wait for the I/O thread to exit before giving up + // and leaking the (daemon) thread + its native buffer pool + WebSocket socket. + // 5 seconds is generous given the I/O thread polls on a 100 ms cadence; if + // it overshoots this, something is seriously wrong (e.g., user handler stuck + // in onBatch). Volatile (not final) so tests can reflectively shorten it to + // hit the timeout branch in under a second instead of spending five. + @SuppressWarnings("FieldMayBeFinal") + private volatile long shutdownJoinMs = 5_000; private WebSocketClient webSocketClient; private QwpQueryClient(CharSequence host, int port) { @@ -102,6 +114,12 @@ private QwpQueryClient(CharSequence host, int port) { *

  • {@code auth=} -- sent as the HTTP {@code Authorization} header during the upgrade handshake.
  • *
  • {@code client_id=} -- sent as the {@code X-QWP-Client-Id} header.
  • *
  • {@code buffer_pool_size=N} -- depth of the I/O thread's batch buffer pool. Default 4.
  • + *
  • {@code compression=zstd|raw|auto} -- compression codec the client + * asks the server to use for RESULT_BATCH bodies. {@code auto} + * (default) advertises {@code zstd,raw} so the server picks zstd + * when it supports it and falls back to raw otherwise.
  • + *
  • {@code compression_level=N} -- zstd level hint, clamped server-side + * to [1, 9]. Default 3. Ignored when {@code compression=raw}.
  • *
* Examples: *
@@ -132,6 +150,8 @@ public static QwpQueryClient fromConfig(CharSequence configurationString) {
         String auth = null;
         String cid = null;
         int poolSize = DEFAULT_IO_BUFFER_POOL_SIZE;
+        String compression = "auto";
+        int compressionLevel = 3;
 
         while (ConfStringParser.hasNext(configurationString, pos)) {
             pos = ConfStringParser.nextKey(configurationString, pos, sink);
@@ -178,6 +198,23 @@ public static QwpQueryClient fromConfig(CharSequence configurationString) {
                         throw new IllegalArgumentException("buffer_pool_size must be >= 1");
                     }
                     break;
+                case "compression":
+                    if (!"zstd".equals(value) && !"raw".equals(value) && !"auto".equals(value)) {
+                        throw new IllegalArgumentException(
+                                "unsupported compression: " + value + " (expected zstd, raw, or auto)");
+                    }
+                    compression = value;
+                    break;
+                case "compression_level":
+                    try {
+                        compressionLevel = Integer.parseInt(value);
+                    } catch (NumberFormatException e) {
+                        throw new IllegalArgumentException("invalid compression_level: " + value);
+                    }
+                    if (compressionLevel < 1 || compressionLevel > 22) {
+                        throw new IllegalArgumentException("compression_level must be in [1, 22]");
+                    }
+                    break;
                 default:
                     throw new IllegalArgumentException("unknown configuration key: " + key);
             }
@@ -187,7 +224,8 @@ public static QwpQueryClient fromConfig(CharSequence configurationString) {
         }
         QwpQueryClient client = new QwpQueryClient(addrHost, addrPort)
                 .withEndpointPath(path)
-                .withBufferPoolSize(poolSize);
+                .withBufferPoolSize(poolSize)
+                .withCompression(compression, compressionLevel);
         if (auth != null) client.withAuthorization(auth);
         if (cid != null) client.withClientId(cid);
         return client;
@@ -220,7 +258,7 @@ public void cancel() {
      * {@code wsClient.receiveFrame(...)} or queue poll, wait for it to exit, then free
      * the buffer pool and close the underlying socket.
      * 

- * If the I/O thread fails to exit within {@link #SHUTDOWN_JOIN_MS} (default 5 s), this + * If the I/O thread fails to exit within {@link #shutdownJoinMs} (default 5 s), this * method does not free the buffer pool or close the WebSocket -- both are * still in use by the thread, and freeing them would race into a JVM-killing * use-after-free. The thread is a daemon, so the JVM still exits normally; the @@ -239,7 +277,7 @@ public void close() { ioThreadHandle.interrupt(); boolean joined; try { - ioThreadHandle.join(SHUTDOWN_JOIN_MS); + ioThreadHandle.join(shutdownJoinMs); joined = !ioThreadHandle.isAlive(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); @@ -249,6 +287,13 @@ public void close() { if (!joined) { // Daemon thread is still running -- buffer pool and WebSocketClient may // be in use. Leak them rather than risk a SIGSEGV by freeing under it. + // Log at ERROR so operators notice; wasLastCloseTimedOut() is the + // programmatic hook for monitoring or tests that want to assert on it. + LOG.error("QwpQueryClient close timed out after {} ms; leaking I/O thread, " + + "buffer pool, and WebSocket to avoid freeing them from under " + + "a running daemon. Common cause: a batch handler that never " + + "returns (e.g. blocking I/O or deadlock).", + shutdownJoinMs); lastCloseTimedOut = true; ioThread = null; ioThreadHandle = null; @@ -277,10 +322,22 @@ public void connect() { webSocketClient = WebSocketClientFactory.newPlainTextInstance(); webSocketClient.setQwpMaxVersion(QWP_MAX_VERSION); webSocketClient.setQwpClientId(clientId != null ? clientId : defaultClientId()); + webSocketClient.setQwpAcceptEncoding(buildAcceptEncodingHeader()); webSocketClient.connect(host, port); webSocketClient.upgrade(endpointPath, authorizationHeader); negotiatedQwpVersion = webSocketClient.getServerQwpVersion(); + // Early probe: if we told the server we can accept zstd, make sure the + // bundled native library actually provides the decompression symbols + // before we start accepting batches. Without this, a client jar built + // without the zstd submodule would only discover the missing symbols + // mid-stream when it hits the first FLAG_ZSTD frame, and the error + // would surface as an opaque "I/O thread failure: ..." callback on the + // user handler. Fail loud here instead so the cause is obvious. + if (!"raw".equals(compressionPreference)) { + probeZstdAvailable(); + } + ioThread = new QwpEgressIoThread(webSocketClient, bufferPoolSize); ioThreadHandle = new Thread(ioThread, "qwp-egress-io"); ioThreadHandle.setDaemon(true); @@ -389,6 +446,26 @@ public QwpQueryClient withClientId(String clientId) { return this; } + /** + * Programmatic equivalent of the {@code compression=} / {@code compression_level=} + * connection-string keys. {@code preference} is one of {@code zstd}, + * {@code raw}, or {@code auto} (default). {@code level} is the zstd + * compression level hint passed to the server; clamped server-side to + * [1, 9]. Must be called before {@link #connect}. + */ + public QwpQueryClient withCompression(String preference, int level) { + if (!"zstd".equals(preference) && !"raw".equals(preference) && !"auto".equals(preference)) { + throw new IllegalArgumentException( + "unsupported compression: " + preference + " (expected zstd, raw, or auto)"); + } + if (level < 1 || level > 22) { + throw new IllegalArgumentException("compression level must be in [1, 22]"); + } + this.compressionPreference = preference; + this.compressionLevel = level; + return this; + } + public QwpQueryClient withEndpointPath(String endpointPath) { this.endpointPath = endpointPath; return this; @@ -413,4 +490,45 @@ public QwpQueryClient withInitialCredit(long bytes) { private static String defaultClientId() { return "questdb-java-egress/1.0.0"; } + + /** + * Builds the {@code X-QWP-Accept-Encoding} header value from the user's + * preference. {@code raw} omits the header entirely so servers that don't + * know about compression see an unchanged handshake. {@code zstd} asks for + * zstd first and falls back to raw. {@code auto} is the default and + * behaves like {@code zstd}. + */ + private String buildAcceptEncodingHeader() { + if ("raw".equals(compressionPreference)) { + return null; + } + return "zstd;level=" + compressionLevel + ",raw"; + } + + /** + * Allocates and immediately frees a {@code ZSTD_DCtx} so that any + * {@link UnsatisfiedLinkError} from a client build that doesn't include + * the bundled libzstd surfaces synchronously on the user thread at + * {@code connect()} time. Closes the just-opened WebSocket on failure so + * the caller doesn't inherit a half-open socket. + */ + private void probeZstdAvailable() { + try { + long dctx = Zstd.createDCtx(); + if (dctx != 0) { + Zstd.freeDCtx(dctx); + } + } catch (UnsatisfiedLinkError e) { + LOG.error("zstd JNI symbols missing from libquestdb; aborting connect", e); + if (webSocketClient != null) { + webSocketClient.close(); + webSocketClient = null; + } + throw new HttpClientException("this client build does not support zstd compression -- " + + "libquestdb was built without the zstd submodule. Rebuild the native library " + + "with 'git submodule update --init --recursive' and 'cmake --build', or set " + + "compression=raw on the connection string to skip the probe. " + + "[cause=" + e.getMessage() + "]"); + } + } } diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpResultBatchDecoder.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpResultBatchDecoder.java index 6e262721..1ef69a26 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpResultBatchDecoder.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpResultBatchDecoder.java @@ -30,6 +30,7 @@ import io.questdb.client.std.ObjList; import io.questdb.client.std.QuietCloseable; import io.questdb.client.std.Unsafe; +import io.questdb.client.std.Zstd; import java.nio.charset.StandardCharsets; @@ -98,6 +99,15 @@ public class QwpResultBatchDecoder implements QuietCloseable { private int connDictHeapCapacity; private int connDictHeapPos; private int connDictSize; + // Native ZSTD_DCtx pointer, lazy-allocated on the first {@code FLAG_ZSTD} + // batch. One per decoder instance (which in turn is one per IoThread), reused + // for every subsequent compressed batch on the same connection. + private long dctx; + // Growable scratch holding the decompressed body between the zstd call and + // the downstream parse. Starts small and doubles on demand when the decoder + // reports a destination-too-small error. + private long decompressScratchAddr; + private int decompressScratchCapacity; // True when the current message carries {@code FLAG_DELTA_SYMBOL_DICT}. Read by // {@link #parseSymbolColumn} to decide whether to consume a per-column dict. private boolean deltaMode; @@ -123,6 +133,15 @@ public void close() { connDictEntriesAddr = 0; connDictEntriesCapacity = 0; } + if (decompressScratchAddr != 0) { + Unsafe.free(decompressScratchAddr, decompressScratchCapacity, MemoryTag.NATIVE_DEFAULT); + decompressScratchAddr = 0; + decompressScratchCapacity = 0; + } + if (dctx != 0) { + Zstd.freeDCtx(dctx); + dctx = 0; + } connDictSize = 0; } @@ -233,6 +252,62 @@ private void decodePayload(QwpBatchBuffer buffer, long payload, int payloadLen) long batchSeq = varintValue; p = varintPos; + // Zstd-compressed body: the region from here to {@code limit} is a + // single zstd frame covering the delta section + table block. Decode + // into the decoder-owned scratch buffer and rebind {@code p} / + // {@code limit} so downstream parsers see the plain bytes without any + // structural awareness of compression. {@code payloadAddr} exposed + // on the batch view also follows {@code p} here so size-measuring + // callers ({@code batch.payloadLimit() - batch.payloadAddr()}) report + // the uncompressed-equivalent body size rather than an arbitrary + // pointer delta between two native allocations. + long batchViewAddr = payload; + if ((flags & QwpConstants.FLAG_ZSTD) != 0) { + long srcLen = limit - p; + if (dctx == 0) { + try { + dctx = Zstd.createDCtx(); + } catch (UnsatisfiedLinkError e) { + // The early probe in QwpQueryClient.connect() should have + // caught this already. If we end up here, something went + // around that probe (custom client wiring, direct use of + // QwpResultBatchDecoder, etc.) -- surface a message that + // names the actual cause instead of letting the JNI symbol + // name leak into the user callback. + throw new QwpDecodeException("server sent a zstd-compressed batch but this " + + "client build does not include zstd -- libquestdb was built without " + + "the zstd submodule. Rebuild the native library with the submodule " + + "initialised, or set compression=raw on the connection string " + + "[cause=" + e.getMessage() + "]"); + } + if (dctx == 0) { + throw new QwpDecodeException("failed to allocate zstd decompression context"); + } + } + // Try the current scratch; on dst-too-small, grow once up to a hard + // cap. The cap exists so that a corrupted or hostile frame advertising + // a huge content size cannot be used to trigger unbounded allocation. + final int MAX_SCRATCH = 64 * 1024 * 1024; + if (decompressScratchCapacity == 0) { + decompressScratchCapacity = 1024 * 1024; + decompressScratchAddr = Unsafe.malloc(decompressScratchCapacity, MemoryTag.NATIVE_DEFAULT); + } + long decLen = Zstd.decompress(dctx, p, srcLen, decompressScratchAddr, decompressScratchCapacity); + while (decLen < 0 && decompressScratchCapacity < MAX_SCRATCH) { + int newCap = Math.min(decompressScratchCapacity * 2, MAX_SCRATCH); + Unsafe.free(decompressScratchAddr, decompressScratchCapacity, MemoryTag.NATIVE_DEFAULT); + decompressScratchCapacity = newCap; + decompressScratchAddr = Unsafe.malloc(decompressScratchCapacity, MemoryTag.NATIVE_DEFAULT); + decLen = Zstd.decompress(dctx, p, srcLen, decompressScratchAddr, decompressScratchCapacity); + } + if (decLen < 0) { + throw new QwpDecodeException("zstd decompression failed [code=" + (-decLen) + "]"); + } + p = decompressScratchAddr; + limit = decompressScratchAddr + decLen; + batchViewAddr = decompressScratchAddr; + } + // Delta section (if enabled) sits right after the prelude and before the // table block. We consume it first so that SYMBOL columns inside the // table block resolve indices against the freshly-updated connection dict. @@ -303,7 +378,7 @@ private void decodePayload(QwpBatchBuffer buffer, long payload, int payloadLen) } // Reset batch view and parse columns into per-column layouts owned by the buffer. - resetBatch(buffer, requestId, batchSeq, rowCount, columnCount, columns, payload, limit); + resetBatch(buffer, requestId, batchSeq, rowCount, columnCount, columns, batchViewAddr, limit); for (int ci = 0; ci < columnCount; ci++) { QwpColumnLayout layout = borrowLayout(buffer.layoutPool, ci); layout.clear(); diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/protocol/QwpConstants.java b/core/src/main/java/io/questdb/client/cutlass/qwp/protocol/QwpConstants.java index bf3e45cc..409ee268 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/protocol/QwpConstants.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/protocol/QwpConstants.java @@ -45,6 +45,12 @@ public final class QwpConstants { * Flag bit: Gorilla timestamp encoding enabled. */ public static final byte FLAG_GORILLA = 0x04; + /** + * Flag bit: payload region after the prelude is zstd-compressed. Set only + * when the handshake negotiated zstd compression. Mirror of the server-side + * constant; see the server QwpConstants for the full description. + */ + public static final byte FLAG_ZSTD = 0x10; /** * Offset of flags byte in header. */ diff --git a/core/src/main/java/io/questdb/client/std/Zstd.java b/core/src/main/java/io/questdb/client/std/Zstd.java new file mode 100644 index 00000000..22af1666 --- /dev/null +++ b/core/src/main/java/io/questdb/client/std/Zstd.java @@ -0,0 +1,49 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +package io.questdb.client.std; + +/** + * Client-side JNI wrapper over libzstd's decompression API. QWP's RESULT_BATCH + * frames can carry zstd-compressed bodies when the handshake negotiated + * {@code X-QWP-Accept-Encoding: zstd}; this class is the decoder-side entry + * point. The native implementation lives in + * {@code share/zstd/zstd_jni.c} (bundled libzstd, decompress only). + */ +public final class Zstd { + + private Zstd() { + } + + public static native long createDCtx(); + + /** + * Decompresses {@code srcLen} bytes at {@code srcAddr} into the buffer at + * {@code dstAddr} (capacity {@code dstCap}). Returns the number of bytes + * written on success; a negative value encodes a libzstd error code. + */ + public static native long decompress(long ctx, long srcAddr, long srcLen, long dstAddr, long dstCap); + + public static native void freeDCtx(long ptr); +} diff --git a/examples/src/main/java/com/example/query/CompressionExample.java b/examples/src/main/java/com/example/query/CompressionExample.java new file mode 100644 index 00000000..f9d6e8c4 --- /dev/null +++ b/examples/src/main/java/com/example/query/CompressionExample.java @@ -0,0 +1,87 @@ +package com.example.query; + +import io.questdb.client.cutlass.qwp.client.QwpColumnBatch; +import io.questdb.client.cutlass.qwp.client.QwpColumnBatchHandler; +import io.questdb.client.cutlass.qwp.client.QwpQueryClient; + +/** + * Opting into zstd compression of {@code RESULT_BATCH} frames. + *

+ * QWP can compress the body of each result batch with zstd. Compression is + * negotiated once at WebSocket upgrade time via the + * {@code X-QWP-Accept-Encoding} header; once agreed, every batch on the + * connection ships compressed (unless the compressed form would be larger + * than the raw form, in which case the server sends that specific batch + * raw). + *

+ * Supported {@code compression=} values: + *

    + *
  • {@code auto} (default) -- advertise {@code zstd,raw}. The server + * uses zstd when it supports it and falls back to raw otherwise. + * Good default for most apps.
  • + *
  • {@code zstd} -- same advertisement as {@code auto}. Makes intent + * explicit in the connection string.
  • + *
  • {@code raw} -- skip compression entirely. No {@code X-QWP-Accept-Encoding} + * header is sent; the server ships batches uncompressed.
  • + *
+ *

+ * {@code compression_level=N} is a hint passed to the server; the server + * clamps it to {@code [1, 9]} because zstd levels 10 and above drop below + * ~20 MB/s compress speed and would pin a worker thread under a heavy load. + * The default is 3 -- roughly 400 MB/s compress, halves DOUBLE / LONG + * traffic on typical time-series data. + *

+ * When to turn it on: + *

    + *
  • Cross-region, cellular, or otherwise bandwidth-constrained clients.
  • + *
  • Highly repetitive data: rotating symbols, low-cardinality enums, + * adjacent timestamps. Expect 3-10x shrinkage.
  • + *
  • You'd rather spend server CPU than wire bandwidth.
  • + *
+ * When to leave it off ({@code compression=raw}): + *
    + *
  • Co-located client + server (localhost, same rack) -- the NIC is not + * the bottleneck; zstd eats CPU you could spend parsing results.
  • + *
  • Pre-compressed or encrypted-looking payloads -- BINARY columns + * holding images, compressed JSON, etc. zstd won't shrink them and + * you pay the CPU for nothing.
  • + *
+ */ +public final class CompressionExample { + + private CompressionExample() { + } + + public static void main(String[] args) { + // compression=zstd at a mid-tier level for a typical WAN consumer. + try (QwpQueryClient client = QwpQueryClient.fromConfig( + "ws::addr=127.0.0.1:9000;compression=zstd;compression_level=3;")) { + client.connect(); + + long[] rows = {0}; + + client.execute( + "SELECT symbol, price, timestamp " + + "FROM trades WHERE timestamp IN yesterday()", + new QwpColumnBatchHandler() { + @Override + public void onBatch(QwpColumnBatch batch) { + // The decoder hands us a view over the decompressed + // bytes; the rest of the row-consuming code is + // identical whether or not compression is on. + rows[0] += batch.getRowCount(); + } + + @Override + public void onEnd(long totalRows) { + System.out.printf("done: %d rows%n", totalRows); + } + + @Override + public void onError(byte status, String message) { + System.err.printf("query failed [status=%d]: %s%n", status, message); + } + }); + } + } +} From c783aecf0a5386a50e91a00058f1ad275b954712 Mon Sep 17 00:00:00 2001 From: bluestreak Date: Mon, 20 Apr 2026 03:04:57 +0100 Subject: [PATCH 18/44] Enable ASM language for zstd huf_decompress_amd64.S Without enable_language(ASM) in CMakeLists, CMake silently drops the .S file from the build tree. On x86-64 Linux and macOS the C side of libzstd still references the symbols exported by the assembly file (HUF_decompress4X1_usingDTable_internal_fast_asm_loop and its 4X2 sibling), so the final link fails with "Undefined symbols for architecture x86_64" whenever the host matches ARCH_AMD64 AND NOT WIN32. Linux x86-64 shows the same failure once the build actually reaches the link step. ARM64 and Windows are unaffected because they skip the .S file and set ZSTD_DISABLE_ASM=1, routing everything through the C fallback. The asmlib subdirectory's enable_language(ASM_NASM) is separate -- NASM is Intel-syntax and only handles Agner Fog's .asm files. --- core/CMakeLists.txt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/core/CMakeLists.txt b/core/CMakeLists.txt index 887d4b85..b3176673 100644 --- a/core/CMakeLists.txt +++ b/core/CMakeLists.txt @@ -1,6 +1,12 @@ cmake_minimum_required(VERSION 3.5) project(questdb) +# Required for zstd's huf_decompress_amd64.S to be assembled. Without this, +# CMake silently drops the .S file from the build and the link fails at +# _HUF_decompress4X1_usingDTable_internal_fast_asm_loop etc. (The asmlib +# subdirectory enables ASM_NASM separately for Agner Fog's .asm files.) +enable_language(ASM) + include(ExternalProject) set(CMAKE_CXX_STANDARD 17) From 9197da55980bcbc0a68de7736d07839ac531c3f4 Mon Sep 17 00:00:00 2001 From: bluestreak Date: Mon, 20 Apr 2026 03:08:56 +0100 Subject: [PATCH 19/44] Include stdint.h in zstd_jni for uintptr_t macOS clang pulls stdint.h in transitively via stdlib.h, so the build was passing on darwin but failing on the manylinux2014 GCC that the linux-x86-64 and linux-aarch64 workflow jobs use. Add the explicit include so every supported toolchain compiles cleanly. --- core/src/main/c/share/zstd_jni.c | 1 + 1 file changed, 1 insertion(+) diff --git a/core/src/main/c/share/zstd_jni.c b/core/src/main/c/share/zstd_jni.c index e811fbd0..ab13efaf 100644 --- a/core/src/main/c/share/zstd_jni.c +++ b/core/src/main/c/share/zstd_jni.c @@ -34,6 +34,7 @@ */ #include +#include #include #include "zstd.h" From f42254b19a538235ceb1352d2f35c959dda04256 Mon Sep 17 00:00:00 2001 From: GitHub Actions - Rebuild Native Libraries Date: Mon, 20 Apr 2026 02:11:12 +0000 Subject: [PATCH 20/44] Rebuild CXX libraries --- .../bin/darwin-aarch64/libquestdb.dylib | Bin 38688 -> 128864 bytes .../client/bin/darwin-x86-64/libquestdb.dylib | Bin 44504 -> 193352 bytes .../client/bin/linux-aarch64/libquestdb.so | Bin 208576 -> 286384 bytes .../client/bin/linux-x86-64/libquestdb.so | Bin 62184 -> 235104 bytes .../client/bin/windows-x86-64/libquestdb.dll | Bin 38912 -> 210944 bytes 5 files changed, 0 insertions(+), 0 deletions(-) diff --git a/core/src/main/resources/io/questdb/client/bin/darwin-aarch64/libquestdb.dylib b/core/src/main/resources/io/questdb/client/bin/darwin-aarch64/libquestdb.dylib index 15dc9a5785a7f69835e81e8a115d1d4a63d27d55..c9d009c4ae0f62e586ab4d8f77243bfbe4fa30e4 100644 GIT binary patch literal 128864 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 diff --git a/core/src/main/resources/io/questdb/client/bin/darwin-x86-64/libquestdb.dylib b/core/src/main/resources/io/questdb/client/bin/darwin-x86-64/libquestdb.dylib index 987af3ad490a60b67101e7c02a85397d11330506..e4e3915e8a59ed41ace1397d5f8f862209c222ee 100644 GIT binary patch literal 193352 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 JFp^NQ|37g35mf*H delta 12732 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 diff --git a/core/src/main/resources/io/questdb/client/bin/linux-aarch64/libquestdb.so b/core/src/main/resources/io/questdb/client/bin/linux-aarch64/libquestdb.so index bd4e7d0ee71b05f36efd5b2a6cd5705701d10eef..1f2f6e640ac43ccfa3e8e6a15d208c80438961b5 100644 GIT binary patch literal 286384 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