diff --git a/ci/validate-pr-title/validate.js b/ci/validate-pr-title/validate.js index 3ca8c9ca..f3e018fe 100644 --- a/ci/validate-pr-title/validate.js +++ b/ci/validate-pr-title/validate.js @@ -16,6 +16,7 @@ const allowedSubTypes = [ "log", "core", "ilp", + "qwp", "http", "conf", "utils", diff --git a/core/src/main/java/io/questdb/client/Completion.java b/core/src/main/java/io/questdb/client/Completion.java new file mode 100644 index 00000000..0888370d --- /dev/null +++ b/core/src/main/java/io/questdb/client/Completion.java @@ -0,0 +1,79 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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; + +import java.util.concurrent.TimeUnit; + +/** + * Async handle for a submitted {@link Query}. Returned by {@link Query#submit()}. + *

+ * Lifecycle: the Completion is allocated once as a field on the per-thread + * {@link Query} instance and is reused on every {@code submit()}. It is + * single-flight: a new {@code submit()} cannot be issued on the same {@link Query} + * until the previous Completion resolves (via {@link #await()}, + * {@link #await(long, TimeUnit)} returning {@code true}, or an explicit + * {@link #cancel()} that races to terminal). + *

+ * Signaling: the Completion is signaled from the I/O thread of the pooled + * query client when the handler's terminal callback ({@code onEnd}, + * {@code onError}, or {@code onExecDone}) returns. + */ +public interface Completion { + + /** + * Blocks until the query completes. Rethrows any server-reported failure + * as a {@link QueryException}. Returns normally on success. + * + * @throws QueryException if the server reported an error or + * {@link #cancel()} won the race + * @throws InterruptedException if the calling thread is interrupted + * while waiting + */ + void await() throws InterruptedException; + + /** + * Blocks up to the given timeout. Returns {@code true} if the query + * completed, {@code false} on timeout. + * + * @throws QueryException if the server reported an error or + * {@link #cancel()} won the race + * @throws InterruptedException if the calling thread is interrupted + * while waiting + */ + boolean await(long timeout, TimeUnit unit) throws InterruptedException; + + /** + * Requests cancellation of the in-flight query. The handler's + * {@code onError} fires with a cancellation status. No-op if the query + * has already completed. + */ + void cancel(); + + /** + * Returns true once the query has terminated (success, error, or cancel + * acknowledged). + */ + boolean isDone(); +} diff --git a/core/src/main/java/io/questdb/client/Query.java b/core/src/main/java/io/questdb/client/Query.java new file mode 100644 index 00000000..f6832e84 --- /dev/null +++ b/core/src/main/java/io/questdb/client/Query.java @@ -0,0 +1,75 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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; + +import io.questdb.client.cutlass.qwp.client.QwpBindSetter; +import io.questdb.client.cutlass.qwp.client.QwpColumnBatchHandler; + +/** + * Per-thread, reusable builder for one query. Obtained from + * {@link QuestDB#query()}: every call on the same thread returns the same + * instance, reset to empty. + *

+ * Lifecycle: configure with {@link #sql}, optional {@link #binds}, and + * {@link #handler}, then call {@link #submit()} to obtain a {@link Completion}. + * After the Completion terminates, the next {@code QuestDB.query()} call on + * the same thread returns this same instance with its state reset. + *

+ * Thread safety: not thread-safe. One in-flight query per thread. + */ +public interface Query { + + /** Discards the current configuration without submitting. */ + void abandon(); + + /** + * Sets the bind-value setter, invoked by the pooled query client when the + * QUERY_REQUEST frame is being prepared. Pass a reusable + * {@link QwpBindSetter} instance (or a stateless lambda hoisted to a + * field) to keep submission zero-allocation. + */ + Query binds(QwpBindSetter binds); + + /** + * Sets the result-batch handler. The handler is invoked on the pooled + * query client's I/O thread; if it touches caller state, it is + * responsible for its own synchronization. + */ + Query handler(QwpColumnBatchHandler handler); + + /** + * Sets the SQL text. The buffer is not retained past {@link #submit()}. + */ + Query sql(CharSequence sql); + + /** + * Submits the query for execution. Returns the {@link Completion} field + * cached on this instance; never allocates. Blocks up to the builder's + * configured acquire timeout if the query pool is exhausted. + * + * @return the single-flight Completion bound to this Query instance + */ + Completion submit(); +} diff --git a/core/src/main/java/io/questdb/client/QueryException.java b/core/src/main/java/io/questdb/client/QueryException.java new file mode 100644 index 00000000..a3fdedbd --- /dev/null +++ b/core/src/main/java/io/questdb/client/QueryException.java @@ -0,0 +1,59 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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; + +/** + * Thrown from {@link Completion#await()} / {@link Completion#await(long, java.util.concurrent.TimeUnit)} + * when the server reported an error for the corresponding {@link Query}, + * when {@link Completion#cancel()} won the race, or when an unrecoverable + * transport failure occurred during submission. + *

+ * The original wire-level status byte is exposed via {@link #getStatus()} so + * callers can distinguish cancellation from schema errors etc. without + * string-matching the message. + */ +public class QueryException extends RuntimeException { + + private final byte status; + + public QueryException(byte status, String message) { + super(message); + this.status = status; + } + + public QueryException(byte status, String message, Throwable cause) { + super(message, cause); + this.status = status; + } + + /** + * Returns the server-reported wire status byte (see QWP protocol + * definitions), or {@code 0} if this exception was raised by the client + * (for example, transport failure before any server response). + */ + public byte getStatus() { + return status; + } +} diff --git a/core/src/main/java/io/questdb/client/QuestDB.java b/core/src/main/java/io/questdb/client/QuestDB.java new file mode 100644 index 00000000..b90e66fd --- /dev/null +++ b/core/src/main/java/io/questdb/client/QuestDB.java @@ -0,0 +1,186 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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; + +import io.questdb.client.cutlass.qwp.client.QwpColumnBatchHandler; + +import java.io.Closeable; + +/** + * High-level handle to a QuestDB deployment. Owns connection pools for both + * ingest (via {@link Sender}) and egress (via {@link Query}). Construct once, + * share across threads. + *

+ * Steady-state allocation is zero: pooled instances are pre-allocated and + * reused, the per-thread {@link Query} handle is cached in a {@code ThreadLocal}, + * and the {@link Completion} associated with each query is a field on that + * cached handle. + *

+ * Configuration: use {@link #connect(CharSequence)} when the same address list + * and credentials serve both ingest and egress -- the most common case. + * Use {@link #connect(CharSequence, CharSequence)} or {@link #builder()} when + * ingest and egress endpoints differ. + *

+ * Thread safety: instances are safe to share. {@link #borrowSender()} and + * {@link #query()} may be called concurrently from any thread; the pool + * guarantees mutual exclusion of pooled resources. + */ +public interface QuestDB extends Closeable { + + /** + * Builder for advanced configuration (pool sizes, acquisition timeouts, + * differing ingest/egress configs). + */ + static QuestDBBuilder builder() { + return new QuestDBBuilder(); + } + + /** + * Connects with a single configuration string used for both ingest and + * egress. The schema must be {@code http}, {@code https}, {@code ws} or + * {@code wss}; the other half of the deployment is derived by schema + * translation ({@code http}<->{@code ws}, {@code https}<->{@code wss}). + *

+ * Use {@link #connect(CharSequence, CharSequence)} or {@link #builder()} + * for ingest transports other than HTTP/HTTPS, or when ingest and egress + * use different addresses. + * + * @param configurationString a Sender- or QwpQueryClient-style config + * string (see {@link Sender#fromConfig} or + * {@link io.questdb.client.cutlass.qwp.client.QwpQueryClient#fromConfig}) + * @return a connected QuestDB handle + */ + static QuestDB connect(CharSequence configurationString) { + return builder().fromConfig(configurationString).build(); + } + + /** + * Connects with explicit ingest and egress configuration strings. + * + * @param ingestConfigurationString config for the {@link Sender} pool + * ({@link Sender#fromConfig} format) + * @param queryConfigurationString config for the query pool + * ({@link io.questdb.client.cutlass.qwp.client.QwpQueryClient#fromConfig} format) + * @return a connected QuestDB handle + */ + static QuestDB connect(CharSequence ingestConfigurationString, CharSequence queryConfigurationString) { + return builder() + .ingestConfig(ingestConfigurationString) + .queryConfig(queryConfigurationString) + .build(); + } + + /** + * Borrows a {@link Sender} from the pool. The caller MUST call + * {@link Sender#close()} on the returned instance to release it back to + * the pool. {@code close()} on a pooled Sender flushes pending rows + * before returning to the pool; a real disconnect only happens at + * {@link #close()} on this {@code QuestDB} handle. + *

+ * Allocation: zero at steady state -- the returned instance is a + * pre-allocated decorator backed by a pre-allocated underlying Sender. + *

+ * Blocking: blocks up to the builder's + * {@link QuestDBBuilder#acquireTimeoutMillis(long) acquire timeout} when + * the pool is exhausted; throws on timeout. + * + * @return a Sender leased from the pool; release with {@link Sender#close()} + * @throws io.questdb.client.cutlass.line.LineSenderException if the pool + * is exhausted + * beyond the + * acquire + * timeout, or + * if this + * handle is + * closed + */ + Sender borrowSender(); + + /** + * Shuts down the pools, closing every underlying {@link Sender} and + * query client. Idempotent. Threads currently blocked in + * {@link #borrowSender()} or {@link Query#submit()} are released with an + * error. + */ + @Override + void close(); + + /** + * One-shot convenience for queries with no bind parameters. Equivalent to + * {@code query().sql(sql).handler(handler).submit()}. Returns the same + * thread-local {@link Completion} instance that {@link #query()} would, + * so this method is also zero-allocation at steady state. + * + * @param sql the SQL text; the buffer is not retained after submit + * @param handler the result-batch handler; invoked on the pooled query + * client's I/O thread + * @return a single-flight handle for the in-flight query + */ + Completion executeSql(CharSequence sql, QwpColumnBatchHandler handler); + + /** + * Allocates a fresh {@link Query} handle. Unlike {@link #query()}, this + * does NOT return the per-thread cached instance; every call allocates. + *

+ * Use this when one thread needs to hold multiple in-flight queries + * concurrently (each {@code submit()} acquires its own worker from the + * query pool, so up to {@code queryPoolSize} concurrent queries on a + * single thread is fine). For the common case of one query at a time, + * prefer {@link #query()} -- it is allocation-free. + */ + Query newQuery(); + + /** + * Opens a query builder for the calling thread. Returns the same + * thread-local instance on every call: callers do not need to cache it + * themselves. The returned {@code Query} is in a reset state and is not + * thread-safe -- one in-flight query per thread. + *

+ * For multiple concurrent in-flight queries from a single thread, use + * {@link #newQuery()} instead. + */ + Query query(); + + /** + * Releases the thread-affine {@link Sender} (if any) currently attached + * to the calling thread back to the pool. Call this on threads borrowed + * from pools you do not own (for example, Netty event loops) before they + * are recycled, to avoid pinning a {@link Sender} for the lifetime of + * a thread that no longer needs it. + */ + void releaseSender(); + + /** + * Returns a {@link Sender} pinned to the calling thread. First call on + * a thread takes one from the pool and pins it; subsequent calls on the + * same thread return the same instance. The pin is released by + * {@link #releaseSender()} or by {@link #close()} on this handle. + *

+ * Use this for long-lived, dedicated producer threads where borrow/return + * overhead would dominate. For short-lived or event-loop callers, prefer + * {@link #borrowSender()}. + */ + Sender sender(); +} diff --git a/core/src/main/java/io/questdb/client/QuestDBBuilder.java b/core/src/main/java/io/questdb/client/QuestDBBuilder.java new file mode 100644 index 00000000..7a037698 --- /dev/null +++ b/core/src/main/java/io/questdb/client/QuestDBBuilder.java @@ -0,0 +1,268 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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; + +import io.questdb.client.impl.ConfigStringTranslator; +import io.questdb.client.impl.QuestDBImpl; + +/** + * Builder for {@link QuestDB}. Most callers use {@link QuestDB#connect(CharSequence)}; + * this builder is for pool sizing, idle/lifetime knobs, acquire timeout, + * and the case where ingest and egress configs differ. + */ +public final class QuestDBBuilder { + + static final long DEFAULT_ACQUIRE_TIMEOUT_MILLIS = 5_000; + static final long DEFAULT_HOUSEKEEPER_INTERVAL_MILLIS = 5_000; + static final long DEFAULT_IDLE_TIMEOUT_MILLIS = 60_000; + static final long DEFAULT_MAX_LIFETIME_MILLIS = 30 * 60_000L; + static final int DEFAULT_POOL_MAX = 4; + static final int DEFAULT_POOL_MIN = 1; + + private long acquireTimeoutMillis = DEFAULT_ACQUIRE_TIMEOUT_MILLIS; + private long housekeeperIntervalMillis = DEFAULT_HOUSEKEEPER_INTERVAL_MILLIS; + private long idleTimeoutMillis = DEFAULT_IDLE_TIMEOUT_MILLIS; + private String ingestConfig; + private long maxLifetimeMillis = DEFAULT_MAX_LIFETIME_MILLIS; + private String queryConfig; + private int queryPoolMax = DEFAULT_POOL_MAX; + private int queryPoolMin = DEFAULT_POOL_MIN; + private int senderPoolMax = DEFAULT_POOL_MAX; + private int senderPoolMin = DEFAULT_POOL_MIN; + + QuestDBBuilder() { + } + + /** + * Maximum time {@link QuestDB#borrowSender()} and {@link Query#submit()} + * block when the pool is exhausted (every slot in use and {@code max} + * already reached) before throwing. Defaults to 5000ms. + */ + public QuestDBBuilder acquireTimeoutMillis(long millis) { + if (millis < 0) { + throw new IllegalArgumentException("acquireTimeoutMillis must be >= 0"); + } + this.acquireTimeoutMillis = millis; + return this; + } + + /** + * Builds the {@link QuestDB} handle. Eagerly creates {@code min} + * connections in each pool; further slots are allocated lazily up to + * {@code max} when load demands and reaped back to {@code min} when + * idle. + */ + public QuestDB build() { + if (ingestConfig == null) { + throw new IllegalStateException("ingest configuration is required; call fromConfig() or ingestConfig()"); + } + if (queryConfig == null) { + throw new IllegalStateException("query configuration is required; call fromConfig() or queryConfig()"); + } + return new QuestDBImpl( + ingestConfig, + queryConfig, + senderPoolMin, + senderPoolMax, + queryPoolMin, + queryPoolMax, + acquireTimeoutMillis, + idleTimeoutMillis, + maxLifetimeMillis, + housekeeperIntervalMillis + ); + } + + /** + * Sets a single unified configuration string used to derive both the + * ingest and the egress config. Schema must be {@code http}, {@code https}, + * {@code ws} or {@code wss}; the other half is derived by schema + * translation. + */ + public QuestDBBuilder fromConfig(CharSequence configurationString) { + ConfigStringTranslator.Bundle bundle = ConfigStringTranslator.deriveBothSides(configurationString); + this.ingestConfig = bundle.ingestConfig; + this.queryConfig = bundle.queryConfig; + ConfigStringTranslator.PoolConfig pc = bundle.poolConfig; + // Apply pool keys carried in the string. Explicit builder calls AFTER + // fromConfig() will overwrite these -- last write wins. + if (pc.senderPoolMin != ConfigStringTranslator.PoolConfig.UNSET) { + senderPoolMin(pc.senderPoolMin); + } + if (pc.senderPoolMax != ConfigStringTranslator.PoolConfig.UNSET) { + senderPoolMax(pc.senderPoolMax); + } + if (pc.queryPoolMin != ConfigStringTranslator.PoolConfig.UNSET) { + queryPoolMin(pc.queryPoolMin); + } + if (pc.queryPoolMax != ConfigStringTranslator.PoolConfig.UNSET) { + queryPoolMax(pc.queryPoolMax); + } + if (pc.acquireTimeoutMillis != ConfigStringTranslator.PoolConfig.UNSET) { + acquireTimeoutMillis(pc.acquireTimeoutMillis); + } + if (pc.idleTimeoutMillis != ConfigStringTranslator.PoolConfig.UNSET) { + idleTimeoutMillis(pc.idleTimeoutMillis); + } + if (pc.maxLifetimeMillis != ConfigStringTranslator.PoolConfig.UNSET) { + maxLifetimeMillis(pc.maxLifetimeMillis); + } + if (pc.housekeeperIntervalMillis != ConfigStringTranslator.PoolConfig.UNSET) { + housekeeperIntervalMillis(pc.housekeeperIntervalMillis); + } + return this; + } + + /** + * Sweep interval for the daemon housekeeper that reaps idle and over-age + * pool slots. Defaults to 5000ms. Reduce if you set very short + * {@link #idleTimeoutMillis} values; otherwise the default is fine. + */ + public QuestDBBuilder housekeeperIntervalMillis(long millis) { + if (millis < 100) { + throw new IllegalArgumentException("housekeeperIntervalMillis must be >= 100"); + } + this.housekeeperIntervalMillis = millis; + return this; + } + + /** + * How long a connection may remain idle in the pool before the + * housekeeper closes it. {@code minSize} is always respected -- the pool + * never shrinks below it. Defaults to 60000ms. + */ + public QuestDBBuilder idleTimeoutMillis(long millis) { + if (millis < 0) { + throw new IllegalArgumentException("idleTimeoutMillis must be >= 0"); + } + this.idleTimeoutMillis = millis == 0 ? Long.MAX_VALUE : millis; + return this; + } + + /** + * Sets the ingest-side configuration in {@link Sender#fromConfig} format. + */ + public QuestDBBuilder ingestConfig(CharSequence configurationString) { + this.ingestConfig = configurationString.toString(); + return this; + } + + /** + * Maximum age of a pooled connection before the housekeeper recycles it + * (next time it is idle). Useful for picking up DNS / load-balancer + * changes and bounding leaked server state. Defaults to 30 minutes. + */ + public QuestDBBuilder maxLifetimeMillis(long millis) { + if (millis < 0) { + throw new IllegalArgumentException("maxLifetimeMillis must be >= 0"); + } + this.maxLifetimeMillis = millis == 0 ? Long.MAX_VALUE : millis; + return this; + } + + /** + * Sets the query-side configuration in + * {@link io.questdb.client.cutlass.qwp.client.QwpQueryClient#fromConfig} + * format. + */ + public QuestDBBuilder queryConfig(CharSequence configurationString) { + this.queryConfig = configurationString.toString(); + return this; + } + + /** + * Maximum query-pool size. Defaults to 4. + */ + public QuestDBBuilder queryPoolMax(int max) { + if (max < 1) { + throw new IllegalArgumentException("queryPoolMax must be >= 1"); + } + this.queryPoolMax = max; + return this; + } + + /** + * Minimum query-pool size (always kept warm). Defaults to 1. Set to 0 + * to allow the pool to drain fully when idle. + */ + public QuestDBBuilder queryPoolMin(int min) { + if (min < 0) { + throw new IllegalArgumentException("queryPoolMin must be >= 0"); + } + this.queryPoolMin = min; + return this; + } + + /** + * Fixed query-pool size shortcut: equivalent to + * {@code queryPoolMin(size).queryPoolMax(size)}. Eager allocation, + * no growth or reaping -- matches the original (non-elastic) behavior. + */ + public QuestDBBuilder queryPoolSize(int size) { + if (size < 1) { + throw new IllegalArgumentException("queryPoolSize must be >= 1"); + } + this.queryPoolMin = size; + this.queryPoolMax = size; + return this; + } + + /** + * Maximum sender-pool size. Defaults to 4. + */ + public QuestDBBuilder senderPoolMax(int max) { + if (max < 1) { + throw new IllegalArgumentException("senderPoolMax must be >= 1"); + } + this.senderPoolMax = max; + return this; + } + + /** + * Minimum sender-pool size (always kept warm). Defaults to 1. Set to 0 + * to allow the pool to drain fully when idle. + */ + public QuestDBBuilder senderPoolMin(int min) { + if (min < 0) { + throw new IllegalArgumentException("senderPoolMin must be >= 0"); + } + this.senderPoolMin = min; + return this; + } + + /** + * Fixed sender-pool size shortcut: equivalent to + * {@code senderPoolMin(size).senderPoolMax(size)}. Eager allocation, + * no growth or reaping -- matches the original (non-elastic) behavior. + */ + public QuestDBBuilder senderPoolSize(int size) { + if (size < 1) { + throw new IllegalArgumentException("senderPoolSize must be >= 1"); + } + this.senderPoolMin = size; + this.senderPoolMax = size; + return this; + } +} diff --git a/core/src/main/java/io/questdb/client/Sender.java b/core/src/main/java/io/questdb/client/Sender.java index 990afb11..896113b0 100644 --- a/core/src/main/java/io/questdb/client/Sender.java +++ b/core/src/main/java/io/questdb/client/Sender.java @@ -262,6 +262,27 @@ static Sender fromEnv() { */ void atNow(); + /** + * Block until the server has acknowledged every frame up to {@code targetFsn}, + * or until {@code timeoutMillis} elapses. Pair with {@link #flushAndGetSequence()} + * to obtain {@code targetFsn} for a specific flush. + *
+ * When {@code request_durable_ack=on} (Enterprise primary replication), {@code targetFsn} + * advances after durable upload to object storage, not on the ordinary commit ACK. + *
+ * Only the WebSocket QWP transport tracks frame sequence numbers. On other transports + * (HTTP, TCP, UDP) the call returns immediately: {@code true} when {@code targetFsn < 0} + * (nothing to wait for), {@code false} otherwise. + * + * @param targetFsn FSN to wait for; typically the return value of {@link #flushAndGetSequence()} + * @param timeoutMillis upper bound on the wait; {@code <= 0} returns the current state without blocking + * @return {@code true} if the server has acknowledged up to {@code targetFsn} on return, {@code false} on timeout + * @throws LineSenderException if the transport has latched a terminal error + */ + default boolean awaitAckedFsn(long targetFsn, long timeoutMillis) { + return targetFsn < 0L; + } + /** * Add a BINARY column value as a byte array. The bytes are written verbatim * with no encoding or transformation. To mark the value NULL, do not call @@ -445,6 +466,29 @@ default Sender decimalColumn(CharSequence name, CharSequence value) { */ Sender doubleColumn(CharSequence name, double value); + /** + * Convenience: flush every buffered row and block until the server has + * acknowledged the resulting frame, or until {@code timeoutMillis} elapses. + * Equivalent to {@code awaitAckedFsn(flushAndGetSequence(), timeoutMillis)}, + * which is the same shape as the implicit drain {@link #close()} runs -- + * with the caller controlling the timeout per call-site rather than + * relying on the builder-time {@code close_flush_timeout_millis}. + *
+ * Returns immediately on transports that do not track frame sequence + * numbers ({@code HTTP}, {@code TCP}, {@code UDP}): the flush still + * happens, the wait is a no-op, and the return value is {@code true}. + * + * @param timeoutMillis upper bound on the wait; {@code <= 0} returns the + * current state without blocking (the flush still + * happens before the check) + * @return {@code true} if the server has acknowledged every published + * frame on return, {@code false} on timeout + * @throws LineSenderException if the transport has latched a terminal error + */ + default boolean drain(long timeoutMillis) { + return awaitAckedFsn(flushAndGetSequence(), timeoutMillis); + } + /** * Add a column with a 32-bit floating point value. * @@ -474,6 +518,26 @@ default Sender floatColumn(CharSequence name, float value) { */ void flush(); + /** + * Same as {@link #flush()} but returns the highest frame sequence number (FSN) the + * call published. Producer-side correlation handle: log + * {@code (returnedFsn, domainContext)} alongside the data, then join to the + * {@link SenderError#getFromFsn()} / {@link SenderError#getToFsn()} span when an + * async error is delivered, or pass it to {@link #awaitAckedFsn(long, long)} for + * a bounded blocking wait. + *
+ * Returns {@code -1} when nothing was published by this call, and on transports that + * do not track frame sequence numbers (HTTP, TCP, UDP). + * + * @return highest FSN published by this call, or {@code -1} if no data was published + * or the transport does not expose FSNs + * @throws LineSenderException under the same conditions as {@link #flush()} + */ + default long flushAndGetSequence() { + flush(); + return -1L; + } + /** * Add a GEOHASH column value from pre-packed bits and an explicit bit precision. *

@@ -523,6 +587,21 @@ default Sender geoHashColumn(CharSequence name, CharSequence value) { throw new LineSenderException("current protocol version does not support geohash"); } + /** + * Highest frame sequence number (FSN) the server has acknowledged, or that the sender + * has skipped past on a {@link SenderError.Policy#DROP_AND_CONTINUE} rejection. + * Returns {@code -1} when no batch has been published yet, and on transports that + * do not track FSNs (HTTP, TCP, UDP). + *
+ * Snapshot accessor: for a bounded blocking wait, use + * {@link #awaitAckedFsn(long, long)}. + * + * @return highest acknowledged FSN, or {@code -1} if none or unsupported + */ + default long getAckedFsn() { + return -1L; + } + /** * Add a column with a 32-bit signed integer value. * @@ -846,10 +925,17 @@ final class LineSenderBuilder { private static final int DEFAULT_AUTO_FLUSH_INTERVAL_MILLIS = 1_000; private static final int DEFAULT_AUTO_FLUSH_ROWS = 75_000; private static final int DEFAULT_BUFFER_CAPACITY = 64 * 1024; - // Default close() drain timeout: block up to 5s waiting for the + // Default close() drain timeout: block up to 60s waiting for the // server to ACK everything published into the engine before - // shutting down the I/O loop. - private static final long DEFAULT_CLOSE_FLUSH_TIMEOUT_MILLIS = 5_000L; + // shutting down the I/O loop. The wide default reflects what real + // workloads need on the close path -- catch-up replicas, slow + // consumers, and small server send buffers under chunky payloads + // all routinely take tens of seconds to acknowledge a backlog, + // and silently dropping unacked rows in close() is a much worse + // default than spending the wall-clock to wait. Callers who want + // a tighter close budget either set close_flush_timeout_millis + // explicitly or call the new drain(timeoutMillis) before close(). + private static final long DEFAULT_CLOSE_FLUSH_TIMEOUT_MILLIS = 60_000L; private static final int DEFAULT_HTTP_PORT = 9000; private static final int DEFAULT_HTTP_TIMEOUT = 30_000; private static final int DEFAULT_MAXIMUM_BUFFER_CAPACITY = 100 * 1024 * 1024; @@ -1548,7 +1634,12 @@ public Sender build() { * close() drain timeout in milliseconds. The sender's {@code close()} * method blocks up to this many millis waiting for the server to ACK * every batch already published into the engine before shutting down - * the I/O loop. Default {@code 5000}. + * the I/O loop. Default {@code 60000} (60 s) -- generous enough to + * survive real-workload backlogs (slow consumers, catch-up replicas, + * chunky payloads on small server send buffers) without silently + * dropping unacked rows; callers that need a longer pre-close wait + * for a specific submission can call + * {@link Sender#drain(long)} explicitly before close(). *

* Set to {@code 0} or {@code -1} to opt out — close() will not wait * at all (fast close). Pending data is then lost in memory mode and diff --git a/core/src/main/java/io/questdb/client/cutlass/http/client/WebSocketSendBuffer.java b/core/src/main/java/io/questdb/client/cutlass/http/client/WebSocketSendBuffer.java index cc25beda..b9598c78 100644 --- a/core/src/main/java/io/questdb/client/cutlass/http/client/WebSocketSendBuffer.java +++ b/core/src/main/java/io/questdb/client/cutlass/http/client/WebSocketSendBuffer.java @@ -312,8 +312,8 @@ public void putShort(short value) { * Writes a length-prefixed UTF-8 string. */ @Override - public void putString(String value) { - if (value == null || value.isEmpty()) { + public void putString(CharSequence value) { + if (value == null || value.length() == 0) { putVarint(0); return; } @@ -326,8 +326,8 @@ public void putString(String value) { * Writes UTF-8 encoded bytes directly without length prefix. */ @Override - public void putUtf8(String value) { - if (value == null || value.isEmpty()) { + public void putUtf8(CharSequence value) { + if (value == null || value.length() == 0) { return; } diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/ColumnView.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/ColumnView.java index ecef52d1..db7a51ba 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/ColumnView.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/ColumnView.java @@ -25,6 +25,9 @@ package io.questdb.client.cutlass.qwp.client; import io.questdb.client.cutlass.qwp.protocol.QwpConstants; +import io.questdb.client.std.Decimal128; +import io.questdb.client.std.Decimal256; +import io.questdb.client.std.Decimal64; import io.questdb.client.std.Long256Sink; import io.questdb.client.std.Unsafe; import io.questdb.client.std.Uuid; @@ -178,6 +181,13 @@ public char getCharValue(int row) { return (char) Unsafe.getUnsafe().getShort(layout.valuesAddr + 2L * layout.denseIndex(row)); } + /** + * @see QwpColumnBatch#getDecimal128(int, int, Decimal128) + */ + public boolean getDecimal128(int row, Decimal128 sink) { + return batch.getDecimal128(col, row, sink); + } + /** * @see QwpColumnBatch#getDecimal128High(int, int) */ @@ -194,6 +204,20 @@ public long getDecimal128Low(int row) { return Unsafe.getUnsafe().getLong(layout.valuesAddr + 16L * layout.denseIndex(row)); } + /** + * @see QwpColumnBatch#getDecimal256(int, int, Decimal256) + */ + public boolean getDecimal256(int row, Decimal256 sink) { + return batch.getDecimal256(col, row, sink); + } + + /** + * @see QwpColumnBatch#getDecimal64(int, int, Decimal64) + */ + public boolean getDecimal64(int row, Decimal64 sink) { + return batch.getDecimal64(col, row, sink); + } + /** * @see QwpColumnBatch#getDoubleArrayElements(int, int) */ diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/NativeBufferWriter.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/NativeBufferWriter.java index 274ce5eb..add22fb0 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/NativeBufferWriter.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/NativeBufferWriter.java @@ -61,7 +61,7 @@ public NativeBufferWriter(int initialCapacity) { * @param s the string (may be null) * @return the number of bytes needed to encode the string as UTF-8 */ - public static int utf8Length(String s) { + public static int utf8Length(CharSequence s) { return s == null ? 0 : Utf8s.utf8Bytes(s); } @@ -225,8 +225,8 @@ public void putShort(short value) { * Writes a length-prefixed UTF-8 string. */ @Override - public void putString(String value) { - if (value == null || value.isEmpty()) { + public void putString(CharSequence value) { + if (value == null || value.length() == 0) { putVarint(0); return; } @@ -266,8 +266,8 @@ public void putString(String value) { * Writes UTF-8 bytes directly without length prefix. */ @Override - public void putUtf8(String value) { - if (value == null || value.isEmpty()) { + public void putUtf8(CharSequence value) { + if (value == null || value.length() == 0) { return; } @@ -338,7 +338,7 @@ private static void writeVarintDirect(long addr, long value) { Unsafe.getUnsafe().putByte(addr, (byte) value); } - private void encodeUtf8(String value, int utf8Len) { + private void encodeUtf8(CharSequence value, int utf8Len) { position += Utf8s.strCpyUtf8(value, bufferPtr + position, utf8Len); } } diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpBufferWriter.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpBufferWriter.java index 5aa72ae6..d3baf938 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpBufferWriter.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpBufferWriter.java @@ -113,14 +113,14 @@ public interface QwpBufferWriter extends ArrayBufferAppender { * * @param value the string to write (may be null or empty) */ - void putString(String value); + void putString(CharSequence value); /** * Writes UTF-8 encoded bytes directly without length prefix. * * @param value the string to encode (may be null or empty) */ - void putUtf8(String value); + void putUtf8(CharSequence value); /** * Writes an unsigned variable-length integer (LEB128 encoding). 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 87e8dafa..20f0e82a 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 @@ -25,6 +25,9 @@ package io.questdb.client.cutlass.qwp.client; import io.questdb.client.cutlass.qwp.protocol.QwpConstants; +import io.questdb.client.std.Decimal128; +import io.questdb.client.std.Decimal256; +import io.questdb.client.std.Decimal64; import io.questdb.client.std.Long256Sink; import io.questdb.client.std.ObjList; import io.questdb.client.std.Unsafe; @@ -267,6 +270,24 @@ public byte getColumnWireType(int col) { return columns.getQuick(col).wireType; } + /** + * Zero-allocation read of a DECIMAL128 value into a caller-supplied + * {@link Decimal128} sink. Sets the sink's high, low, and scale in a single + * call. Returns {@code true} on a hit, {@code false} for NULL rows (the + * sink is left untouched). + */ + public boolean getDecimal128(int col, int row, Decimal128 sink) { + QwpColumnLayout l = columnLayouts.getQuick(col); + if (isLayoutNull(l, row)) return false; + long base = l.valuesAddr + 16L * l.denseIndex(row); + sink.of( + Unsafe.getUnsafe().getLong(base + 8L), + Unsafe.getUnsafe().getLong(base), + columns.getQuick(col).scale + ); + return true; + } + /** * Returns the high 64 bits of a DECIMAL128 value. Combine with {@link #getDecimal128Low}. */ @@ -285,6 +306,40 @@ public long getDecimal128Low(int col, int row) { return Unsafe.getUnsafe().getLong(l.valuesAddr + 16L * l.denseIndex(row)); } + /** + * Zero-allocation read of a DECIMAL256 value into a caller-supplied + * {@link Decimal256} sink. Sets all four 64-bit words and the scale in a + * single call. Returns {@code true} on a hit, {@code false} for NULL rows + * (the sink is left untouched). + */ + public boolean getDecimal256(int col, int row, Decimal256 sink) { + QwpColumnLayout l = columnLayouts.getQuick(col); + if (isLayoutNull(l, row)) return false; + long base = l.valuesAddr + 32L * l.denseIndex(row); + sink.of( + Unsafe.getUnsafe().getLong(base + 24L), + Unsafe.getUnsafe().getLong(base + 16L), + Unsafe.getUnsafe().getLong(base + 8L), + Unsafe.getUnsafe().getLong(base), + columns.getQuick(col).scale + ); + return true; + } + + /** + * Zero-allocation read of a DECIMAL64 value into a caller-supplied + * {@link Decimal64} sink. Sets the unscaled value and scale in a single + * call. Returns {@code true} on a hit, {@code false} for NULL rows (the + * sink is left untouched). + */ + public boolean getDecimal64(int col, int row, Decimal64 sink) { + QwpColumnLayout l = columnLayouts.getQuick(col); + if (isLayoutNull(l, row)) return false; + long value = Unsafe.getUnsafe().getLong(l.valuesAddr + 8L * l.denseIndex(row)); + sink.of(value, columns.getQuick(col).scale); + return true; + } + /** * Scale (number of fractional digits) for DECIMAL64 / DECIMAL128 / DECIMAL256 * columns. Same across every row in the batch. Undefined for non-DECIMAL columns. 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 eb418b75..a4ac77ee 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 @@ -57,6 +57,20 @@ public interface QwpColumnBatchHandler { */ void onEnd(long totalRows); + /** + * Correlation-aware overload of {@link #onEnd(long)}. Receives the + * client-assigned request id of the just-completed query so callers can + * join completion records to earlier {@code onBatch} or log entries. + * The default delegates to {@link #onEnd(long)} for backwards + * compatibility; override this method when you need the request id. + * + * @param requestId client-assigned request id (matches {@link QwpColumnBatch#requestId()}) + * @param totalRows server-reported total row count (0 if not tracked) + */ + default void onEnd(long requestId, long totalRows) { + onEnd(totalRows); + } + /** * Invoked exactly once if the query fails at any point, instead of * {@link #onEnd} / {@link #onExecDone}. @@ -66,6 +80,26 @@ public interface QwpColumnBatchHandler { */ void onError(byte status, String message); + /** + * Correlation-aware overload of {@link #onError(byte, String)}. Receives + * the client-assigned request id of the failed query so callers can join + * the error to earlier {@code onBatch} or log entries without having to + * stash the id from a prior callback. The default delegates to + * {@link #onError(byte, String)} for backwards compatibility; override + * this method when you need the request id. + *

+ * {@code requestId} is {@code -1} when the failure is raised before a + * request was assigned -- e.g. a {@code QwpQueryClient} that is already + * closed. + * + * @param requestId client-assigned request id, or {@code -1} if no request was in flight + * @param status one of the QWP status codes (e.g., {@code STATUS_PARSE_ERROR}) + * @param message server-supplied error message (may be empty) + */ + default void onError(long requestId, byte status, String message) { + onError(status, message); + } + /** * Invoked when {@link QwpQueryClient#execute} has transparently reconnected * to another endpoint after a transport failure and is about to re-submit @@ -86,6 +120,19 @@ public interface QwpColumnBatchHandler { default void onFailoverReset(QwpServerInfo newNode) { } + /** + * Correlation-aware overload of {@link #onFailoverReset(QwpServerInfo)}. + * Receives the client-assigned request id of the query that is about to + * be re-submitted. The default delegates to + * {@link #onFailoverReset(QwpServerInfo)} for backwards compatibility. + * + * @param requestId client-assigned request id of the query being replayed + * @param newNode the endpoint just bound to, or {@code null} if the new server negotiated v1 + */ + default void onFailoverReset(long requestId, QwpServerInfo newNode) { + onFailoverReset(newNode); + } + /** * Invoked in place of {@link #onBatch} + {@link #onEnd} when the query was * a non-SELECT (DDL, INSERT, UPDATE, etc.). No batches are delivered for @@ -103,4 +150,18 @@ default void onFailoverReset(QwpServerInfo newNode) { */ default void onExecDone(short opType, long rowsAffected) { } + + /** + * Correlation-aware overload of {@link #onExecDone(short, long)}. Receives + * the client-assigned request id of the completed non-SELECT query. The + * default delegates to {@link #onExecDone(short, long)} for backwards + * compatibility. + * + * @param requestId client-assigned request id (matches {@link QwpColumnBatch#requestId()}) + * @param opType server-side opType constant (CompiledQuery.SELECT / INSERT / UPDATE / CREATE_TABLE / etc.) + * @param rowsAffected rows inserted / updated / deleted; 0 for pure DDL + */ + default void onExecDone(long requestId, short opType, long rowsAffected) { + onExecDone(opType, rowsAffected); + } } 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 5d961abd..9752ace8 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 @@ -84,6 +84,14 @@ public class QwpEgressIoThread implements Runnable, WebSocketFrameHandler { // the I/O thread typically parks on this for the duration of the user's // onBatch callback, which is microseconds for a no-op consumer. private final QwpSpscQueue pendingRelease = new QwpSpscQueue<>(1); + // Reusable request holder. The user thread mutates fields and offers the + // same instance into {@link #requests} on every submit; the I/O thread + // reads the fields synchronously in {@link #sendQueryRequest} and does not + // retain a reference past that call. Reuse avoids a per-submit allocation + // -- one in-flight query per client makes this safe: the worker that + // mutates pendingRequest is blocked on the events queue until the I/O + // thread has finished consuming the previous instance. + private final QueryRequest pendingRequest = new QueryRequest(); // Single-slot request queue (Phase-1 allows one in-flight query). private final BlockingQueue requests = new ArrayBlockingQueue<>(1); private final NativeBufferWriter sendScratch = new NativeBufferWriter(); @@ -368,14 +376,20 @@ public void shutdown() { * payload contains; zero when the user supplied no binds. */ public void submitQuery( - String sql, + CharSequence sql, long requestId, long initialCredit, int bindCount, long bindPayloadPtr, long bindPayloadLen ) throws InterruptedException { - requests.put(new QueryRequest(sql, requestId, initialCredit, bindCount, bindPayloadPtr, bindPayloadLen)); + pendingRequest.sql = sql; + pendingRequest.requestId = requestId; + pendingRequest.initialCredit = initialCredit; + pendingRequest.bindCount = bindCount; + pendingRequest.bindPayloadPtr = bindPayloadPtr; + pendingRequest.bindPayloadLen = bindPayloadLen; + requests.put(pendingRequest); } /** @@ -687,14 +701,12 @@ private void sendCredit(long requestId, long additionalBytes) { * happens on this thread. */ 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); - } + // putString writes varint(utf8 length) + utf8 bytes in one pass, + // straight into the native send buffer -- no intermediate byte[]. + sendScratch.putString(req.sql); sendScratch.putVarint(req.initialCredit); // 0 = unbounded (Phase-1 default) sendScratch.putVarint(req.bindCount); if (req.bindCount > 0 && req.bindPayloadLen > 0) { @@ -746,21 +758,19 @@ public interface TerminalFailureListener { void onTerminalFailure(byte status, String message); } + /** + * Mutable request holder reused across submits. Safe to reuse because at + * most one query is in flight per client: the worker thread mutates fields + * and offers the instance into {@link #requests}, then blocks on the events + * queue until the I/O thread has fully consumed the previous instance and + * delivered a terminal event. + */ private static final class QueryRequest { - final int bindCount; - final long bindPayloadLen; - final long bindPayloadPtr; - final long initialCredit; - final long requestId; - final String sql; - - QueryRequest(String sql, long requestId, long initialCredit, int bindCount, long bindPayloadPtr, long bindPayloadLen) { - this.sql = sql; - this.requestId = requestId; - this.initialCredit = initialCredit; - this.bindCount = bindCount; - this.bindPayloadPtr = bindPayloadPtr; - this.bindPayloadLen = bindPayloadLen; - } + int bindCount; + long bindPayloadLen; + long bindPayloadPtr; + long initialCredit; + long requestId; + CharSequence 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 c1c7a1ff..d21aeffd 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 @@ -35,6 +35,7 @@ import io.questdb.client.std.QuietCloseable; import io.questdb.client.std.Zstd; import io.questdb.client.std.str.StringSink; +import org.jetbrains.annotations.TestOnly; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -252,6 +253,14 @@ public class QwpQueryClient implements QuietCloseable { // re-clamp) so a misconfigured server is observable from user code. private int negotiatedZstdLevel; private long nextRequestId = 1; + // Cancel intent latched between {@link #cancel} and the point where + // {@link #executeOnce} assigns {@link #currentRequestId}. Without this + // latch, a cancel arriving in the dispatch window (after the user thread's + // submit() returned but before the worker reached the requestId write) is + // dropped silently because cancel()'s wire-send is gated on a non-negative + // currentRequestId. Volatile so a cancel from any thread is visible to the + // worker thread's post-requestId read. + private volatile boolean pendingCancel; // Decoded SERVER_INFO from the current connection's handshake. Null before // connect() has succeeded, and on connections that negotiated v1 (which // doesn't emit the frame). Volatile so the I/O thread's read on the @@ -713,6 +722,13 @@ public static QwpQueryClient newPlainText(CharSequence host, int port) { * handler's {@code onError} (on the execute-ing thread) will see it. */ public void cancel() { + // Latch FIRST so a cancel arriving in the dispatch window (after + // execute() cleared the latch but before executeOnce() wrote + // currentRequestId) is observed on the worker thread's + // post-requestId read. Without this, cancel() reads + // currentRequestId == -1, the wire-send is skipped, and the user's + // intent is silently dropped. + pendingCancel = true; QwpEgressIoThread io = ioThread; long id = currentRequestId; if (io != null && id >= 0L) { @@ -947,7 +963,7 @@ public synchronized void connect() { * batches begin arriving on the new connection, and any rows delivered * before the reset should be discarded by the handler. */ - public void execute(String sql, QwpColumnBatchHandler handler) { + public void execute(CharSequence sql, QwpColumnBatchHandler handler) { execute(sql, null, handler); } @@ -962,11 +978,17 @@ public void execute(String sql, QwpColumnBatchHandler handler) { * supply the per-call values. Interpolating values into the SQL string * defeats this reuse. */ - public void execute(String sql, QwpBindSetter binds, QwpColumnBatchHandler handler) { + public void execute(CharSequence sql, QwpBindSetter binds, QwpColumnBatchHandler handler) { if (!executing.compareAndSet(false, true)) { throw new IllegalStateException( "QwpQueryClient.execute called while another execute is in flight; one query at a time per client"); } + // Drop any cancel latched between calls (e.g., a watchdog that fired + // while no request was in flight, or a previous pooled user that + // released the client without ever calling execute). Failover retries + // inside this execute() must still honor a fresh cancel, so the latch + // is intentionally NOT cleared inside executeOnce(). + pendingCancel = false; try { executeImpl(sql, binds, handler); } finally { @@ -1051,6 +1073,17 @@ public boolean isConnected() { return connected; } + /** + * Test-only view of the dispatch-window cancel latch. Returns {@code true} + * when {@link #cancel} was invoked after the outermost {@link #execute} + * cleared the latch and before {@link #execute} has consumed it on the + * {@code currentRequestId = requestId} write. + */ + @TestOnly + public boolean isPendingCancelForTest() { + return pendingCancel; + } + /** * Test-only / diagnostics hook: injects a synthetic terminal failure * through the current generation's listener. Production code does not @@ -1600,7 +1633,7 @@ private void connectToEndpoint(Endpoint ep) { } } - private void executeImpl(String sql, QwpBindSetter binds, QwpColumnBatchHandler handler) { + private void executeImpl(CharSequence sql, QwpBindSetter binds, QwpColumnBatchHandler handler) { if (closedFlag.get()) { throw new IllegalStateException("QwpQueryClient is closed"); } @@ -1629,12 +1662,12 @@ private void executeImpl(String sql, QwpBindSetter binds, QwpColumnBatchHandler return; } if (!failoverEnabled) { - handler.onError(probe.interceptedStatus, probe.interceptedMessage); + handler.onError(probe.interceptedRequestId, probe.interceptedStatus, probe.interceptedMessage); return; } if (attempt >= failoverMaxAttempts || System.nanoTime() - failoverDeadlineNanos >= 0) { int failovers = Math.max(0, attempt - 1); - handler.onError(probe.interceptedStatus, + handler.onError(probe.interceptedRequestId, probe.interceptedStatus, "transport failure after " + attempt + " execute attempt" + (attempt == 1 ? "" : "s") + " (" + failovers + " failover reconnect" @@ -1664,7 +1697,7 @@ private void executeImpl(String sql, QwpBindSetter binds, QwpColumnBatchHandler long remaining = remainingNanos <= 0L ? 0L : remainingNanos / 1_000_000L; if (remainingNanos <= 0L) { int failovers = Math.max(0, attempt - 1); - handler.onError(probe.interceptedStatus, + handler.onError(probe.interceptedRequestId, probe.interceptedStatus, "transport failure after " + attempt + " execute attempt" + (attempt == 1 ? "" : "s") + " (" + failovers + " failover reconnect" @@ -1680,7 +1713,7 @@ private void executeImpl(String sql, QwpBindSetter binds, QwpColumnBatchHandler Thread.sleep(delay); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); - handler.onError(probe.interceptedStatus, + handler.onError(probe.interceptedRequestId, probe.interceptedStatus, "failover interrupted while backing off after attempt " + attempt + "; last error: " + probe.interceptedMessage); return; @@ -1694,21 +1727,21 @@ private void executeImpl(String sql, QwpBindSetter binds, QwpColumnBatchHandler // Credentials are cluster-wide, so retrying floods server logs // without recovery. Surface a distinct message so monitoring // can pull auth incidents apart from generic transport failures. - handler.onError(probe.interceptedStatus, + handler.onError(probe.interceptedRequestId, probe.interceptedStatus, "auth failure during failover reconnect [host=" + authErr.getHost() + ':' + authErr.getPort() + ", status=" + authErr.getStatusCode() + ", last error: " + probe.interceptedMessage + ']'); return; } catch (RuntimeException reconnectErr) { - handler.onError(probe.interceptedStatus, + handler.onError(probe.interceptedRequestId, probe.interceptedStatus, "failover reconnect failed after " + attempt + " attempt" + (attempt == 1 ? "" : "s") + " [last error: " + probe.interceptedMessage + ", reconnect error: " + reconnectErr.getMessage() + ']'); return; } - handler.onFailoverReset(serverInfo); + handler.onFailoverReset(probe.interceptedRequestId, serverInfo); } } @@ -1717,7 +1750,7 @@ private void executeImpl(String sql, QwpBindSetter binds, QwpColumnBatchHandler * the user's handler in a {@link FailoverProbeHandler} so that the outer * loop can intercept transport failures before they reach the user. */ - private void executeOnce(String sql, QwpBindSetter binds, FailoverProbeHandler probe) { + private void executeOnce(CharSequence sql, QwpBindSetter binds, FailoverProbeHandler probe) { // 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 @@ -1725,13 +1758,13 @@ private void executeOnce(String sql, QwpBindSetter binds, FailoverProbeHandler p // before close() returns. QwpEgressIoThread io = ioThread; if (io == null) { - probe.onError(WebSocketResponse.STATUS_INTERNAL_ERROR, "QwpQueryClient is closed"); + probe.onError(-1L, WebSocketResponse.STATUS_INTERNAL_ERROR, "QwpQueryClient is closed"); return; } GenerationListener listener = currentGenerationListener; TerminalFailure tf = listener != null ? listener.get() : null; if (tf != null) { - probe.markTransportFailure(tf.status, tf.message); + probe.markTransportFailure(-1L, tf.status, tf.message); return; } bindValues.reset(); @@ -1746,13 +1779,20 @@ private void executeOnce(String sql, QwpBindSetter binds, FailoverProbeHandler p // transport failures -- they're deterministic on the user thread -- // so they must not trigger failover. bindValues.reset(); - probe.deliverFinal( + probe.deliverFinal(-1L, "bind encoding failed: " + e.getMessage()); return; } } long requestId = nextRequestId++; currentRequestId = requestId; + // Honor a cancel that arrived during the dispatch window. The latch + // is intentionally not cleared here: if this attempt fails over, + // the retry must also be cancelled. The latch is cleared once at + // the outermost execute() entry. + if (pendingCancel) { + io.requestCancel(requestId); + } try { io.submitQuery(sql, requestId, initialCreditBytes, bindValues.count(), bindValues.bufferPtr(), bindValues.bufferLen()); while (true) { @@ -1767,19 +1807,19 @@ private void executeOnce(String sql, QwpBindSetter binds, FailoverProbeHandler p } break; case QueryEvent.KIND_END: - probe.onEnd(ev.totalRows); + probe.onEnd(requestId, ev.totalRows); return; case QueryEvent.KIND_EXEC_DONE: - probe.onExecDone(ev.opType, ev.rowsAffected); + probe.onExecDone(requestId, ev.opType, ev.rowsAffected); return; case QueryEvent.KIND_ERROR: - probe.onError(ev.errorStatus, ev.errorMessage); + probe.onError(requestId, ev.errorStatus, ev.errorMessage); return; case QueryEvent.KIND_TRANSPORT_ERROR: - probe.markTransportFailure(ev.errorStatus, ev.errorMessage); + probe.markTransportFailure(requestId, ev.errorStatus, ev.errorMessage); return; default: - probe.onError(WebSocketResponse.STATUS_INTERNAL_ERROR, "unknown event kind " + ev.kind); + probe.onError(requestId, WebSocketResponse.STATUS_INTERNAL_ERROR, "unknown event kind " + ev.kind); return; } } finally { @@ -1793,7 +1833,7 @@ private void executeOnce(String sql, QwpBindSetter binds, FailoverProbeHandler p } catch (InterruptedException ie) { Thread.currentThread().interrupt(); // Interrupt on the user thread is not a transport failure; surface directly. - probe.deliverFinal("interrupted while waiting for server response"); + probe.deliverFinal(requestId, "interrupted while waiting for server response"); } finally { currentRequestId = -1L; } @@ -2021,6 +2061,7 @@ private static final class Endpoint { private static final class FailoverProbeHandler implements QwpColumnBatchHandler { final QwpColumnBatchHandler delegate; String interceptedMessage; + long interceptedRequestId = -1L; byte interceptedStatus; boolean transportFailureIntercepted; @@ -2038,6 +2079,11 @@ public void onEnd(long totalRows) { delegate.onEnd(totalRows); } + @Override + public void onEnd(long requestId, long totalRows) { + delegate.onEnd(requestId, totalRows); + } + @Override public void onError(byte status, String message) { // Server-emitted QUERY_ERROR. Pass straight through. Transport @@ -2045,18 +2091,38 @@ public void onError(byte status, String message) { delegate.onError(status, message); } + @Override + public void onError(long requestId, byte status, String message) { + delegate.onError(requestId, status, message); + } + @Override public void onExecDone(short opType, long rowsAffected) { delegate.onExecDone(opType, rowsAffected); } + @Override + public void onExecDone(long requestId, short opType, long rowsAffected) { + delegate.onExecDone(requestId, opType, rowsAffected); + } + + @Override + public void onFailoverReset(QwpServerInfo newNode) { + delegate.onFailoverReset(newNode); + } + + @Override + public void onFailoverReset(long requestId, QwpServerInfo newNode) { + delegate.onFailoverReset(requestId, newNode); + } + /** * Bypass the interception logic and deliver the error straight to the * user. Used for failures that are not transport-related (bind-encode * errors, interrupts) so they don't trigger a spurious failover. */ - void deliverFinal(String message) { - delegate.onError(WebSocketResponse.STATUS_INTERNAL_ERROR, message); + void deliverFinal(long requestId, String message) { + delegate.onError(requestId, WebSocketResponse.STATUS_INTERNAL_ERROR, message); } /** @@ -2066,8 +2132,9 @@ void deliverFinal(String message) { * (failover=on + reconnect succeeds) or a single final {@code onError} * (failover=off or reconnect exhausted). */ - void markTransportFailure(byte status, String message) { + void markTransportFailure(long requestId, byte status, String message) { transportFailureIntercepted = true; + interceptedRequestId = requestId; interceptedStatus = status; interceptedMessage = message; } 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 fbf66d47..8f01c6e2 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 @@ -39,6 +39,7 @@ import io.questdb.client.cutlass.line.LineSenderException; import io.questdb.client.cutlass.line.array.DoubleArray; import io.questdb.client.cutlass.line.array.LongArray; +import io.questdb.client.cutlass.qwp.client.sf.cursor.BackgroundDrainerPool; import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorSendEngine; import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorWebSocketSendLoop; import io.questdb.client.cutlass.qwp.client.sf.cursor.DefaultSenderConnectionListener; @@ -66,7 +67,6 @@ import java.time.Instant; import java.time.temporal.ChronoUnit; -import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.concurrent.TimeUnit; @@ -196,8 +196,7 @@ public class QwpWebSocketSender implements Sender { // Orphan-slot drainer pool. Non-null only when the builder requested // drain_orphans=true AND we have a slot path to scan against. Closed // alongside the cursor send loop in close(). - private io.questdb.client.cutlass.qwp.client.sf.cursor.BackgroundDrainerPool - drainerPool; + private BackgroundDrainerPool drainerPool; // Keepalive PING cadence used by the I/O loop while // request_durable_ack=on AND there are pending durable-ack // confirmations. Default mirrors the loop's spec value; 0 or negative @@ -281,7 +280,7 @@ private QwpWebSocketSender( if (endpoints == null || endpoints.isEmpty()) { throw new IllegalArgumentException("endpoints must be non-empty"); } - this.endpoints = Collections.unmodifiableList(new ArrayList<>(endpoints)); + this.endpoints = List.copyOf(endpoints); this.hostTracker = new QwpHostHealthTracker(this.endpoints.size()); this.authorizationHeader = authorizationHeader; this.tlsConfig = tlsConfig; @@ -770,6 +769,7 @@ public void atNow() { * @return {@code true} if {@code ackedFsn() >= targetFsn} on return, {@code false} on timeout * @throws LineSenderException if the I/O loop has latched a terminal error */ + @Override public boolean awaitAckedFsn(long targetFsn, long timeoutMillis) { checkNotClosed(); if (cursorEngine == null) { @@ -1360,6 +1360,7 @@ public void flush() { * * @return highest FSN published into the engine, or {@code -1} if no data */ + @Override public long flushAndGetSequence() { checkNotClosed(); ensureNoInProgressRow(); @@ -1459,6 +1460,7 @@ public QwpWebSocketSender geoHashColumn(CharSequence columnName, CharSequence va * Snapshot accessor — for a bounded wait, use * {@link #awaitAckedFsn(long, long)}. */ + @Override public long getAckedFsn() { return cursorEngine != null ? cursorEngine.ackedFsn() : -1L; } @@ -1471,7 +1473,7 @@ public long getAckedFsn() { * moments ago may still count for a few ms. */ public int getActiveBackgroundDrainers() { - io.questdb.client.cutlass.qwp.client.sf.cursor.BackgroundDrainerPool pool = drainerPool; + BackgroundDrainerPool pool = drainerPool; return pool == null ? 0 : pool.getActiveCount(); } @@ -1496,6 +1498,34 @@ public int getAutoFlushRows() { return autoFlushRows; } + /** + * Number of {@link SenderConnectionEvent} notifications dropped because + * the bounded inbox was full. Non-zero means the user-supplied + * {@link SenderConnectionListener} cannot keep up. Returns 0 if the + * dispatcher has not been allocated yet. + */ + public long getDroppedConnectionNotifications() { + SenderConnectionDispatcher d = connectionDispatcher; + return d == null ? 0L : d.getDroppedNotifications(); + } + + /** + * Number of {@link SenderError} notifications dropped because the + * bounded inbox was full. Non-zero means the user-supplied + * {@link SenderErrorHandler} cannot keep up. Returns 0 if the error + * dispatcher has not been allocated yet. + */ + public long getDroppedErrorNotifications() { + SenderErrorDispatcher d = errorDispatcher; + return d == null ? 0L : d.getDroppedNotifications(); + } + + /** Returns the live byte budget the auto-flush path actually enforces. */ + @TestOnly + public int getEffectiveAutoFlushBytes() { + return effectiveAutoFlushBytes; + } + /** * Snapshot of the typed payload for the latched terminal server-rejection error, * or {@code null} if the I/O loop has not latched a server-rejection terminal @@ -1522,6 +1552,26 @@ public int getOrAddGlobalSymbol(CharSequence symbol) { return globalId; } + /** + * Running tally the row builder maintains so auto-flush thresholds can be + * evaluated without re-walking every table per row. Exposed for tests + * that compare this incremental counter against a ground-truth walk. + */ + @TestOnly + public long getPendingBytes() { + return pendingBytes; + } + + /** + * Server-advertised cap on the per-batch raw byte size. Zero before the + * first connect; updated by every successful reconnect via + * {@link #applyServerBatchSizeLimit(int)}. + */ + @TestOnly + public int getServerMaxBatchSize() { + return serverMaxBatchSize; + } + @TestOnly public QwpTableBuffer getTableBuffer(String tableName) { QwpTableBuffer buffer = tableBuffers.get(tableName); @@ -1535,38 +1585,6 @@ public QwpTableBuffer getTableBuffer(String tableName) { return buffer; } - /** - * Number of {@link SenderConnectionEvent} notifications dropped because - * the bounded inbox was full. Non-zero means the user-supplied - * {@link SenderConnectionListener} cannot keep up. Returns 0 if the - * dispatcher has not been allocated yet. - */ - public long getDroppedConnectionNotifications() { - SenderConnectionDispatcher d = connectionDispatcher; - return d == null ? 0L : d.getDroppedNotifications(); - } - - /** - * Number of {@link SenderError} notifications dropped because the - * bounded inbox was full. Non-zero means the user-supplied - * {@link SenderErrorHandler} cannot keep up. Returns 0 if the error - * dispatcher has not been allocated yet. - */ - public long getDroppedErrorNotifications() { - SenderErrorDispatcher d = errorDispatcher; - return d == null ? 0L : d.getDroppedNotifications(); - } - - /** - * Number of {@link SenderConnectionEvent} notifications delivered to the - * user listener since this sender started. Counts every delivery attempt, - * including those where the listener threw. - */ - public long getTotalConnectionEventsDelivered() { - SenderConnectionDispatcher d = connectionDispatcher; - return d == null ? 0L : d.getTotalDelivered(); - } - /** * Total binary frames whose ACKs have been received and applied. */ @@ -1582,7 +1600,7 @@ public long getTotalAcks() { * slots were discovered at startup. */ public long getTotalBackgroundDrainersFailed() { - io.questdb.client.cutlass.qwp.client.sf.cursor.BackgroundDrainerPool pool = drainerPool; + BackgroundDrainerPool pool = drainerPool; return pool == null ? 0L : pool.getTotalFailed(); } @@ -1593,7 +1611,7 @@ public long getTotalBackgroundDrainersFailed() { * slots were discovered at startup. */ public long getTotalBackgroundDrainersSucceeded() { - io.questdb.client.cutlass.qwp.client.sf.cursor.BackgroundDrainerPool pool = drainerPool; + BackgroundDrainerPool pool = drainerPool; return pool == null ? 0L : pool.getTotalSucceeded(); } @@ -1608,6 +1626,16 @@ public long getTotalBackpressureStalls() { return e == null ? 0L : e.getTotalBackpressureStalls(); } + /** + * Number of {@link SenderConnectionEvent} notifications delivered to the + * user listener since this sender started. Counts every delivery attempt, + * including those where the listener threw. + */ + public long getTotalConnectionEventsDelivered() { + SenderConnectionDispatcher d = connectionDispatcher; + return d == null ? 0L : d.getTotalDelivered(); + } + /** * Number of {@link SenderError} notifications delivered to the user * handler since this sender started. Counts every delivery attempt, @@ -1911,22 +1939,6 @@ public void reset() { cachedTimestampNanosColumn = null; } - /** - * Attach a {@link CursorSendEngine} for store-and-forward. Must be called - * before the first send. - */ - public void setCursorEngine(CursorSendEngine engine, boolean takeOwnership) { - if (closed) { - throw new LineSenderException("Sender is closed"); - } - if (connected) { - throw new LineSenderException( - "setCursorEngine must be called before the first send"); - } - this.cursorEngine = engine; - this.ownsCursorEngine = takeOwnership && engine != null; - } - /** * Register an async listener for connection-state transitions: initial * connect, primary failover, endpoint attempt failures, the full address @@ -1942,6 +1954,17 @@ public void setCursorEngine(CursorSendEngine engine, boolean takeOwnership) { * publishing or reconnect. See {@link SenderConnectionListener} for the * full delivery contract. */ + /** + * Forces the {@code connected} flag without going through the real + * connect handshake. Lets unit tests exercise post-connect code paths + * (auto-flush bookkeeping, batch-size guards, ack tracking) on a + * sender that never opened a socket. Never call from production code. + */ + @TestOnly + public void setConnectedForTest(boolean connected) { + this.connected = connected; + } + public void setConnectionListener(SenderConnectionListener listener) { SenderConnectionListener effective = listener != null ? listener : DefaultSenderConnectionListener.INSTANCE; this.connectionListener = effective; @@ -1963,6 +1986,22 @@ public void setConnectionListenerInboxCapacity(int capacity) { this.connectionListenerInboxCapacity = capacity; } + /** + * Attach a {@link CursorSendEngine} for store-and-forward. Must be called + * before the first send. + */ + public void setCursorEngine(CursorSendEngine engine, boolean takeOwnership) { + if (closed) { + throw new LineSenderException("Sender is closed"); + } + if (connected) { + throw new LineSenderException( + "setCursorEngine must be called before the first send"); + } + this.cursorEngine = engine; + this.ownsCursorEngine = takeOwnership && engine != null; + } + /** * Configure the user-supplied error handler. May be called either before * or after {@code connect()} — when called after, the change propagates @@ -2260,26 +2299,6 @@ private static List singleEndpoint(String host, int port) { return Collections.singletonList(new Endpoint(host, port)); } - private void atMicros(long timestampMicros) { - // Add designated timestamp column (empty name for designated timestamp) - // Use cached reference to avoid hashmap lookup per row - if (cachedTimestampColumn == null) { - cachedTimestampColumn = currentTableBuffer.getOrCreateDesignatedTimestampColumn(QwpConstants.TYPE_TIMESTAMP); - } - cachedTimestampColumn.addLong(timestampMicros); - sendRow(); - } - - private void atNanos(long timestampNanos) { - // Add designated timestamp column (empty name for designated timestamp) - // Use cached reference to avoid hashmap lookup per row - if (cachedTimestampNanosColumn == null) { - cachedTimestampNanosColumn = currentTableBuffer.getOrCreateDesignatedTimestampColumn(QwpConstants.TYPE_TIMESTAMP_NANOS); - } - cachedTimestampNanosColumn.addLong(timestampNanos); - sendRow(); - } - /** * Clamps the soft-flush byte budget to fit under the server's advertised * X-QWP-Max-Batch-Size minus a safety margin for encoding overhead @@ -2295,7 +2314,8 @@ private void atNanos(long timestampNanos) { * preserved even when the server advertises a cap, so applications that * opted out keep the contract they asked for. */ - private void applyServerBatchSizeLimit(int advertisedMaxBatchSize) { + @TestOnly + public void applyServerBatchSizeLimit(int advertisedMaxBatchSize) { serverMaxBatchSize = advertisedMaxBatchSize; if (autoFlushBytes <= 0) { // User opted out of byte-based auto-flush; respect that even when @@ -2320,6 +2340,26 @@ private void applyServerBatchSizeLimit(int advertisedMaxBatchSize) { } } + private void atMicros(long timestampMicros) { + // Add designated timestamp column (empty name for designated timestamp) + // Use cached reference to avoid hashmap lookup per row + if (cachedTimestampColumn == null) { + cachedTimestampColumn = currentTableBuffer.getOrCreateDesignatedTimestampColumn(QwpConstants.TYPE_TIMESTAMP); + } + cachedTimestampColumn.addLong(timestampMicros); + sendRow(); + } + + private void atNanos(long timestampNanos) { + // Add designated timestamp column (empty name for designated timestamp) + // Use cached reference to avoid hashmap lookup per row + if (cachedTimestampNanosColumn == null) { + cachedTimestampNanosColumn = currentTableBuffer.getOrCreateDesignatedTimestampColumn(QwpConstants.TYPE_TIMESTAMP_NANOS); + } + cachedTimestampNanosColumn.addLong(timestampNanos); + sendRow(); + } + private synchronized WebSocketClient buildAndConnect(ReconnectSupplier ctx) { int previousIdx = ctx.previousIdx; if (previousIdx >= 0) { @@ -2403,7 +2443,7 @@ private synchronized WebSocketClient buildAndConnect(ReconnectSupplier ctx) { if (terminalUpgradeError == null && ( classified instanceof QwpVersionMismatchException || (classified instanceof WebSocketUpgradeException - && !((WebSocketUpgradeException) classified).isRoleMismatch()))) { + && !((WebSocketUpgradeException) classified).isRoleMismatch()))) { terminalUpgradeError = classified; } hostTracker.recordTransportError(idx); @@ -2897,7 +2937,6 @@ private void flushPendingRows() { // re-batch with fewer rows per flush. close()'s drain path // also relies on this being a clean reset to avoid re-throwing. if (serverMaxBatchSize > 0 && messageSize > serverMaxBatchSize) { - int oversize = messageSize; int droppedRows = pendingRowCount; for (int i = 0, n = keys.size(); i < n; i++) { CharSequence tableName = keys.getQuick(i); @@ -2916,7 +2955,7 @@ private void flushPendingRows() { pendingRowCount = 0; firstPendingRowTimeNanos = 0; throw new LineSenderException("batch too large for server batch cap") - .put(" [messageSize=").put(oversize) + .put(" [messageSize=").put(messageSize) .put(", serverMaxBatchSize=").put(serverMaxBatchSize) .put(", droppedRows=").put(droppedRows).put(']'); } @@ -2951,10 +2990,6 @@ private void flushPendingRows() { firstPendingRowTimeNanos = 0; } - private long getPendingBytes() { - return pendingBytes; - } - private void resetSchemaStateForNewConnection() { maxSentSchemaId = -1; nextSchemaId = 0; @@ -3132,7 +3167,17 @@ private long toMicros(long value, ChronoUnit unit) { } } - private long totalBufferedBytes() { + /** + * Total buffered bytes across every per-table column buffer. Sums the + * tableBuffers map with the same null-tolerant walk the old sendRow path + * used. Currently dead in production code (the sendRow accounting was + * inlined for tighter bookkeeping) but kept so + * {@code QwpWebSocketSenderTest} can exercise the walk shape directly + * and {@code QwpTotalBufferedBytesBenchmark} can quote it as the + * baseline it benchmarks. Removing it would break both. + */ + @TestOnly + public long totalBufferedBytes() { long total = 0; ObjList keys = tableBuffers.keys(); for (int i = 0, n = keys.size(); i < n; i++) { diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/RowView.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/RowView.java index 6405374b..13ec5add 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/RowView.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/RowView.java @@ -24,6 +24,9 @@ package io.questdb.client.cutlass.qwp.client; +import io.questdb.client.std.Decimal128; +import io.questdb.client.std.Decimal256; +import io.questdb.client.std.Decimal64; import io.questdb.client.std.Long256Sink; import io.questdb.client.std.Uuid; import io.questdb.client.std.bytes.DirectByteSequence; @@ -124,6 +127,13 @@ public char getCharValue(int col) { return batch.getCharValue(col, row); } + /** + * @see QwpColumnBatch#getDecimal128(int, int, Decimal128) + */ + public boolean getDecimal128(int col, Decimal128 sink) { + return batch.getDecimal128(col, row, sink); + } + /** * @see QwpColumnBatch#getDecimal128High(int, int) */ @@ -138,6 +148,20 @@ public long getDecimal128Low(int col) { return batch.getDecimal128Low(col, row); } + /** + * @see QwpColumnBatch#getDecimal256(int, int, Decimal256) + */ + public boolean getDecimal256(int col, Decimal256 sink) { + return batch.getDecimal256(col, row, sink); + } + + /** + * @see QwpColumnBatch#getDecimal64(int, int, Decimal64) + */ + public boolean getDecimal64(int col, Decimal64 sink) { + return batch.getDecimal64(col, row, sink); + } + /** * @see QwpColumnBatch#getDoubleArrayElements(int, int) */ diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/SegmentedNativeBufferWriter.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/SegmentedNativeBufferWriter.java index 54d81b79..458908f8 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/SegmentedNativeBufferWriter.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/SegmentedNativeBufferWriter.java @@ -139,12 +139,12 @@ public void putShort(short value) { } @Override - public void putString(String value) { + public void putString(CharSequence value) { currentChunk.putString(value); } @Override - public void putUtf8(String value) { + public void putUtf8(CharSequence value) { currentChunk.putUtf8(value); } diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentRing.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentRing.java index e4eafb47..09d91888 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentRing.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentRing.java @@ -27,6 +27,7 @@ import io.questdb.client.std.Files; import io.questdb.client.std.ObjList; import io.questdb.client.std.QuietCloseable; +import org.jetbrains.annotations.TestOnly; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -62,6 +63,13 @@ public final class SegmentRing implements QuietCloseable { /** Sentinel: append failed because the payload doesn't fit in a fresh segment. */ public static final long PAYLOAD_TOO_LARGE = -2L; private static final Logger LOG = LoggerFactory.getLogger(SegmentRing.class); + // Tally of baseSeq comparisons performed by sortByBaseSeq across every + // openExisting() recovery on this JVM. Used by SegmentRingTest to + // assert the sort stays O(N log N) without relying on wall-clock time + // (CI runner variance makes elapsed-millisecond bounds flaky). Cheap + // in production: one volatile-free add per partition pass, dwarfed by + // the mmap I/O the recovery does on every segment. + private static long sortComparisons; private final long maxBytesPerSegment; // Sealed segments in baseSeq order, oldest first. Active is held separately. // Single-writer (producer thread, on rotation); single-reader at trim time @@ -652,6 +660,27 @@ public synchronized long totalSegmentBytes() { return total; } + /** + * Returns the cumulative count of baseSeq comparisons performed by + * {@link #sortByBaseSeq} since the last {@link #resetSortComparisons()} + * (or process start). The count is incremented once per partition pass + * for the median-of-three pivot pick plus once per element compared + * against the pivot, so a clean run on N segments adds roughly + * {@code 3 + (hi - lo - 1)} per recursive frame, summing to O(N log N). + * Exposed for {@code SegmentRingTest} to detect O(N²) regressions + * deterministically. + */ + @TestOnly + public static long getSortComparisons() { + return sortComparisons; + } + + /** Zeroes the counter exposed via {@link #getSortComparisons()}. */ + @TestOnly + public static void resetSortComparisons() { + sortComparisons = 0; + } + /** * In-place quicksort over {@code list[lo, hi)} keyed by ascending * {@code baseSeq}. Median-of-three pivot avoids the pathological O(N²) @@ -666,7 +695,11 @@ private static void sortByBaseSeq(ObjList list, int lo, int hi) { long a = list.get(lo).baseSeq(); long b = list.get(mid).baseSeq(); long c = list.get(hi - 1).baseSeq(); - // Median of {a, b, c} → pivot index. + // Median of {a, b, c} → pivot index. Three compareUnsigned calls + // worst case; bumping by a constant 3 keeps the counter cheap and + // still strictly upper-bounds the true work (some short-circuit + // out after 1-2 compares). + sortComparisons += 3L + (hi - lo - 1); int pivotIdx; if (Long.compareUnsigned(a, b) < 0) { if (Long.compareUnsigned(b, c) < 0) pivotIdx = mid; diff --git a/core/src/main/java/io/questdb/client/impl/ConfigStringTranslator.java b/core/src/main/java/io/questdb/client/impl/ConfigStringTranslator.java new file mode 100644 index 00000000..5888a775 --- /dev/null +++ b/core/src/main/java/io/questdb/client/impl/ConfigStringTranslator.java @@ -0,0 +1,376 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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.impl; + +import io.questdb.client.std.Chars; +import io.questdb.client.std.str.StringSink; + +/** + * Translates a unified configuration string into the three things needed to + * build a {@code QuestDB}: an ingest-side config (Sender), an egress-side + * config (QwpQueryClient), and an optional pool-tuning bundle. + *

+ * Pool-tuning keys are stripped from the connection-config + * strings (neither downstream parser accepts them) and surfaced separately + * via {@link PoolConfig}: + *

    + *
  • {@code sender_pool_min}, {@code sender_pool_max}
  • + *
  • {@code query_pool_min}, {@code query_pool_max}
  • + *
  • {@code acquire_timeout_ms}
  • + *
  • {@code idle_timeout_ms}
  • + *
  • {@code max_lifetime_ms}
  • + *
  • {@code housekeeper_interval_ms}
  • + *
+ *

+ * Schema translation: http<->ws, https<->wss. + * A curated subset of keys carries over to the derived side (addr, token / + * auth, TLS); everything else stays on the input side only. + *

+ * The parser runs once at {@code QuestDB.connect(...)} time. Allocation here + * is one-shot startup cost; the hot borrow / submit paths never see it. + */ +public final class ConfigStringTranslator { + + private ConfigStringTranslator() { + } + + /** + * Returns the ingest and query configuration strings plus the pool config + * extracted from a unified input. + */ + public static Bundle deriveBothSides(CharSequence config) { + if (config == null || config.length() == 0) { + throw new IllegalArgumentException("configuration string cannot be empty"); + } + StringSink sink = new StringSink(); + int pos = ConfStringParser.of(config, sink); + if (pos < 0) { + throw new IllegalArgumentException("invalid configuration string: " + sink); + } + boolean isHttp; + boolean isTls; + if (Chars.equals("http", sink)) { + isHttp = true; + isTls = false; + } else if (Chars.equals("https", sink)) { + isHttp = true; + isTls = true; + } else if (Chars.equals("ws", sink)) { + isHttp = false; + isTls = false; + } else if (Chars.equals("wss", sink)) { + isHttp = false; + isTls = true; + } else { + throw new IllegalArgumentException( + "QuestDB.connect(single config) supports schemas [http, https, ws, wss]; got: " + sink + + ". Use QuestDB.connect(ingestConfig, queryConfig) for other transports."); + } + + // Curated keys are mirrored to the derived side too. + StringSink addr = new StringSink(); + StringSink token = new StringSink(); + StringSink username = new StringSink(); + StringSink password = new StringSink(); + StringSink auth = new StringSink(); + StringSink tlsRoots = new StringSink(); + StringSink tlsRootsPassword = new StringSink(); + StringSink tlsVerify = new StringSink(); + boolean hasAddr = false; + boolean hasToken = false; + boolean hasUsername = false; + boolean hasPassword = false; + boolean hasAuth = false; + boolean hasTlsRoots = false; + boolean hasTlsRootsPassword = false; + boolean hasTlsVerify = false; + + // Input-side passthrough: schema:: + every non-pool key encountered. + // We always re-serialize rather than pass the raw string through, so + // pool keys can be stripped cleanly even when they sit between two + // unrelated keys. + StringSink inputPassthrough = new StringSink(); + inputPassthrough.put(isHttp ? (isTls ? "https::" : "http::") : (isTls ? "wss::" : "ws::")); + + PoolConfig poolConfig = new PoolConfig(); + + while (ConfStringParser.hasNext(config, pos)) { + pos = ConfStringParser.nextKey(config, pos, sink); + if (pos < 0) { + throw new IllegalArgumentException("invalid configuration string: " + sink); + } + String key = sink.toString(); + pos = ConfStringParser.value(config, pos, sink); + if (pos < 0) { + throw new IllegalArgumentException("invalid configuration string: " + sink); + } + // First, try to consume as a pool key. If matched, do NOT echo to + // the passthrough (downstream parsers reject these). + if (consumePoolKey(key, sink, poolConfig)) { + continue; + } + // Capture curated keys for the derived-side rebuild, but also echo + // them to the input-side passthrough (the matching parser still + // needs to see them). + switch (key) { + case "addr": + addr.clear(); + addr.put(sink); + hasAddr = true; + break; + case "token": + token.clear(); + token.put(sink); + hasToken = true; + break; + case "username": + username.clear(); + username.put(sink); + hasUsername = true; + break; + case "password": + password.clear(); + password.put(sink); + hasPassword = true; + break; + case "auth": + auth.clear(); + auth.put(sink); + hasAuth = true; + break; + case "tls_roots": + tlsRoots.clear(); + tlsRoots.put(sink); + hasTlsRoots = true; + break; + case "tls_roots_password": + tlsRootsPassword.clear(); + tlsRootsPassword.put(sink); + hasTlsRootsPassword = true; + break; + case "tls_verify": + tlsVerify.clear(); + tlsVerify.put(sink); + hasTlsVerify = true; + break; + default: + break; + } + appendKv(inputPassthrough, key, sink); + } + if (!hasAddr) { + throw new IllegalArgumentException("configuration string is missing 'addr'"); + } + + String ingest; + String query; + if (isHttp) { + ingest = inputPassthrough.toString(); + query = buildQueryConfig(isTls, addr, hasToken, token, hasUsername, + hasPassword, hasAuth, auth, + hasTlsRoots, tlsRoots, hasTlsRootsPassword, tlsRootsPassword, + hasTlsVerify, tlsVerify); + } else { + query = inputPassthrough.toString(); + ingest = buildIngestConfig(isTls, addr, hasToken, token, hasUsername, username, + hasPassword, password, hasAuth, auth, + hasTlsRoots, tlsRoots, hasTlsRootsPassword, tlsRootsPassword, + hasTlsVerify, tlsVerify); + } + return new Bundle(ingest, query, poolConfig); + } + + private static void appendKv(StringSink out, String key, CharSequence value) { + out.put(key).put('='); + // Values may contain ';' which must be doubled (per ConfStringParser). + for (int i = 0, n = value.length(); i < n; i++) { + char c = value.charAt(i); + out.put(c); + if (c == ';') { + out.put(';'); + } + } + out.put(';'); + } + + private static String buildIngestConfig( + boolean isTls, + CharSequence addr, + boolean hasToken, CharSequence token, + boolean hasUsername, CharSequence username, + boolean hasPassword, CharSequence password, + boolean hasAuth, CharSequence auth, + boolean hasTlsRoots, CharSequence tlsRoots, + boolean hasTlsRootsPassword, CharSequence tlsRootsPassword, + boolean hasTlsVerify, CharSequence tlsVerify + ) { + StringSink out = new StringSink(); + out.put(isTls ? "https::" : "http::"); + appendKv(out, "addr", addr); + if (hasToken) { + appendKv(out, "token", token); + } + if (hasUsername) { + appendKv(out, "username", username); + } + if (hasPassword) { + appendKv(out, "password", password); + } + if (hasAuth && !hasToken && !hasUsername) { + appendKv(out, "auth", auth); + } + if (hasTlsRoots) { + appendKv(out, "tls_roots", tlsRoots); + } + if (hasTlsRootsPassword) { + appendKv(out, "tls_roots_password", tlsRootsPassword); + } + if (hasTlsVerify) { + appendKv(out, "tls_verify", tlsVerify); + } + return out.toString(); + } + + private static String buildQueryConfig( + boolean isTls, + CharSequence addr, + boolean hasToken, CharSequence token, + boolean hasUsername, + boolean hasPassword, + boolean hasAuth, CharSequence auth, + boolean hasTlsRoots, CharSequence tlsRoots, + boolean hasTlsRootsPassword, CharSequence tlsRootsPassword, + boolean hasTlsVerify, CharSequence tlsVerify + ) { + StringSink out = new StringSink(); + out.put(isTls ? "wss::" : "ws::"); + appendKv(out, "addr", addr); + if (hasAuth) { + appendKv(out, "auth", auth); + } else if (hasToken) { + StringSink bearer = new StringSink(); + bearer.put("Bearer ").put(token); + appendKv(out, "auth", bearer); + } else if (hasUsername && hasPassword) { + throw new IllegalArgumentException( + "username/password auth is not supported in unified config for ws/wss derivation; " + + "pass auth=Basic directly, or use the builder with explicit queryConfig()"); + } + if (isTls) { + if (hasTlsRoots) { + appendKv(out, "tls_roots", tlsRoots); + } + if (hasTlsRootsPassword) { + appendKv(out, "tls_roots_password", tlsRootsPassword); + } + if (hasTlsVerify) { + appendKv(out, "tls_verify", tlsVerify); + } + } + return out.toString(); + } + + private static boolean consumePoolKey(String key, CharSequence value, PoolConfig out) { + switch (key) { + case "sender_pool_min": + out.senderPoolMin = parseInt(key, value); + return true; + case "sender_pool_max": + out.senderPoolMax = parseInt(key, value); + return true; + case "query_pool_min": + out.queryPoolMin = parseInt(key, value); + return true; + case "query_pool_max": + out.queryPoolMax = parseInt(key, value); + return true; + case "acquire_timeout_ms": + out.acquireTimeoutMillis = parseLong(key, value); + return true; + case "idle_timeout_ms": + out.idleTimeoutMillis = parseLong(key, value); + return true; + case "max_lifetime_ms": + out.maxLifetimeMillis = parseLong(key, value); + return true; + case "housekeeper_interval_ms": + out.housekeeperIntervalMillis = parseLong(key, value); + return true; + default: + return false; + } + } + + private static int parseInt(String key, CharSequence value) { + try { + return Integer.parseInt(value.toString()); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("invalid " + key + ": " + value); + } + } + + private static long parseLong(String key, CharSequence value) { + try { + return Long.parseLong(value.toString()); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("invalid " + key + ": " + value); + } + } + + /** + * The full result of translating a single connect string: an ingest-side + * config, an egress-side config, and any pool-tuning values that the + * string carried (or all-unset {@link PoolConfig} if it carried none). + */ + public static final class Bundle { + public final String ingestConfig; + public final PoolConfig poolConfig; + public final String queryConfig; + + Bundle(String ingestConfig, String queryConfig, PoolConfig poolConfig) { + this.ingestConfig = ingestConfig; + this.queryConfig = queryConfig; + this.poolConfig = poolConfig; + } + } + + /** + * Pool tuning extracted from the connect string. Each field starts at + * {@link #UNSET} (-1); the builder applies only those that were actually + * present in the string, leaving the rest at the builder defaults. + */ + public static final class PoolConfig { + public static final long UNSET = -1L; + + public long acquireTimeoutMillis = UNSET; + public long housekeeperIntervalMillis = UNSET; + public long idleTimeoutMillis = UNSET; + public long maxLifetimeMillis = UNSET; + public int queryPoolMax = (int) UNSET; + public int queryPoolMin = (int) UNSET; + public int senderPoolMax = (int) UNSET; + public int senderPoolMin = (int) UNSET; + } +} diff --git a/core/src/main/java/io/questdb/client/impl/PoolHousekeeper.java b/core/src/main/java/io/questdb/client/impl/PoolHousekeeper.java new file mode 100644 index 00000000..ecdd6fc9 --- /dev/null +++ b/core/src/main/java/io/questdb/client/impl/PoolHousekeeper.java @@ -0,0 +1,92 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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.impl; + +/** + * Daemon thread that periodically asks both pools to reap idle / over-age + * slots. Owned by {@link QuestDBImpl}; one instance per {@code QuestDB} + * handle. + */ +final class PoolHousekeeper { + + private final long intervalMillis; + private final QueryClientPool queryPool; + private final SenderPool senderPool; + private final Object signalLock = new Object(); + private final Thread thread; + private volatile boolean stop; + + PoolHousekeeper(SenderPool senderPool, QueryClientPool queryPool, long intervalMillis) { + this.senderPool = senderPool; + this.queryPool = queryPool; + this.intervalMillis = intervalMillis; + this.thread = new Thread(this::runLoop, "questdb-pool-housekeeper"); + this.thread.setDaemon(true); + } + + void start() { + thread.start(); + } + + void stop() { + stop = true; + synchronized (signalLock) { + signalLock.notifyAll(); + } + try { + thread.join(2_000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + private void runLoop() { + while (!stop) { + synchronized (signalLock) { + if (stop) { + return; + } + try { + signalLock.wait(intervalMillis); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + } + if (stop) { + return; + } + try { + senderPool.reapIdle(); + } catch (RuntimeException ignored) { + // Reaping must not propagate -- it's best-effort housekeeping. + } + try { + queryPool.reapIdle(); + } catch (RuntimeException ignored) { + } + } + } +} diff --git a/core/src/main/java/io/questdb/client/impl/PooledSender.java b/core/src/main/java/io/questdb/client/impl/PooledSender.java new file mode 100644 index 00000000..9e2dbbb6 --- /dev/null +++ b/core/src/main/java/io/questdb/client/impl/PooledSender.java @@ -0,0 +1,402 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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.impl; + +import io.questdb.client.Sender; +import io.questdb.client.cutlass.line.array.DoubleArray; +import io.questdb.client.cutlass.line.array.LongArray; +import io.questdb.client.std.Decimal128; +import io.questdb.client.std.Decimal256; +import io.questdb.client.std.Decimal64; +import io.questdb.client.std.bytes.DirectByteSlice; +import org.jetbrains.annotations.NotNull; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; + +/** + * Decorator that lends a real {@link Sender} from {@link SenderPool}. The + * decorator is pre-allocated once per pool slot and reused for every borrow. + *

+ * Behavior difference from a raw Sender: {@link #close()} on a pooled Sender + * flushes the buffer and returns the decorator to the pool. The underlying + * Sender is only truly closed when {@link io.questdb.client.QuestDB#close()} + * shuts down the pool. + */ +public final class PooledSender implements Sender { + + private final long createdAtMillis; + private final Sender delegate; + private final SenderPool pool; + private volatile long idleSinceMillis; + private volatile boolean inUse; + private volatile boolean invalidated; + + PooledSender(Sender delegate, SenderPool pool) { + this.delegate = delegate; + this.pool = pool; + this.createdAtMillis = System.currentTimeMillis(); + this.idleSinceMillis = this.createdAtMillis; + } + + @Override + public void at(long timestamp, ChronoUnit unit) { + delegate.at(timestamp, unit); + } + + @Override + public void at(Instant timestamp) { + delegate.at(timestamp); + } + + @Override + public void atNow() { + delegate.atNow(); + } + + @Override + public boolean awaitAckedFsn(long targetFsn, long timeoutMillis) { + return delegate.awaitAckedFsn(targetFsn, timeoutMillis); + } + + @Override + public Sender binaryColumn(CharSequence name, byte[] value) { + delegate.binaryColumn(name, value); + return this; + } + + @Override + public Sender binaryColumn(CharSequence name, long ptr, long len) { + delegate.binaryColumn(name, ptr, len); + return this; + } + + @Override + public Sender binaryColumn(CharSequence name, DirectByteSlice slice) { + delegate.binaryColumn(name, slice); + return this; + } + + @Override + public Sender boolColumn(CharSequence name, boolean value) { + delegate.boolColumn(name, value); + return this; + } + + @Override + public DirectByteSlice bufferView() { + return delegate.bufferView(); + } + + @Override + public Sender byteColumn(CharSequence name, byte value) { + delegate.byteColumn(name, value); + return this; + } + + @Override + public void cancelRow() { + delegate.cancelRow(); + } + + @Override + public Sender charColumn(CharSequence name, char value) { + delegate.charColumn(name, value); + return this; + } + + /** + * Flushes pending rows and returns this decorator to the pool. Does not + * actually close the underlying {@link Sender}; that only happens when + * the owning {@code QuestDB} is closed. + *

+ * Idempotent: a second call after a return is a no-op. + *

+ * Clears the current thread's pin (if any) before the slot becomes + * borrowable again. Without this step a thread that pinned this + * wrapper and then closed it via the public {@link Sender#close()} + * (the natural try-with-resources idiom) would still hold the pin + * in its {@link ThreadLocal}; a subsequent {@code QuestDB.sender()} + * call on that thread would return the cached wrapper even though + * another consumer has since borrowed the slot, and the two + * consumers would write to the same underlying delegate. + */ + @Override + public void close() { + if (!inUse) { + return; + } + boolean broken = false; + try { + delegate.flush(); + } catch (RuntimeException e) { + // Sender does not clear its buffer on flush failure (see + // Sender Javadoc), and WebSocket transport latches the failure + // for good. Either way, the wrapper is unsafe to recycle: the + // next borrower would inherit the failed rows or a dead + // connection. + broken = true; + throw e; + } finally { + inUse = false; + // Clear the pin BEFORE returning the slot. If we cleared + // after giveBack(), a concurrent borrower could grab the + // slot while this thread's pin still references it, and a + // re-pin on this thread would return the (now in-use) + // wrapper -- the same race this clear is meant to close. + pool.clearPinIfCurrent(this); + if (broken) { + pool.discardBroken(this); + } else { + pool.giveBack(this); + } + } + } + + @Override + public Sender decimalColumn(CharSequence name, Decimal256 value) { + delegate.decimalColumn(name, value); + return this; + } + + @Override + public Sender decimalColumn(CharSequence name, Decimal128 value) { + delegate.decimalColumn(name, value); + return this; + } + + @Override + public Sender decimalColumn(CharSequence name, Decimal64 value) { + delegate.decimalColumn(name, value); + return this; + } + + @Override + public Sender decimalColumn(CharSequence name, CharSequence value) { + delegate.decimalColumn(name, value); + return this; + } + + @Override + public Sender doubleArray(@NotNull CharSequence name, double[] values) { + delegate.doubleArray(name, values); + return this; + } + + @Override + public Sender doubleArray(@NotNull CharSequence name, double[][] values) { + delegate.doubleArray(name, values); + return this; + } + + @Override + public Sender doubleArray(@NotNull CharSequence name, double[][][] values) { + delegate.doubleArray(name, values); + return this; + } + + @Override + public Sender doubleArray(CharSequence name, DoubleArray array) { + delegate.doubleArray(name, array); + return this; + } + + @Override + public Sender doubleColumn(CharSequence name, double value) { + delegate.doubleColumn(name, value); + return this; + } + + @Override + public boolean drain(long timeoutMillis) { + return delegate.drain(timeoutMillis); + } + + @Override + public Sender floatColumn(CharSequence name, float value) { + delegate.floatColumn(name, value); + return this; + } + + @Override + public void flush() { + delegate.flush(); + } + + @Override + public long flushAndGetSequence() { + return delegate.flushAndGetSequence(); + } + + @Override + public Sender geoHashColumn(CharSequence name, long bits, int precisionBits) { + delegate.geoHashColumn(name, bits, precisionBits); + return this; + } + + @Override + public Sender geoHashColumn(CharSequence name, CharSequence value) { + delegate.geoHashColumn(name, value); + return this; + } + + @Override + public long getAckedFsn() { + return delegate.getAckedFsn(); + } + + @Override + public Sender intColumn(CharSequence name, int value) { + delegate.intColumn(name, value); + return this; + } + + @Override + public Sender ipv4Column(CharSequence name, int address) { + delegate.ipv4Column(name, address); + return this; + } + + @Override + public Sender ipv4Column(CharSequence name, CharSequence address) { + delegate.ipv4Column(name, address); + return this; + } + + @Override + public Sender long256Column(CharSequence name, long l0, long l1, long l2, long l3) { + delegate.long256Column(name, l0, l1, l2, l3); + return this; + } + + @Override + public Sender longArray(@NotNull CharSequence name, long[] values) { + delegate.longArray(name, values); + return this; + } + + @Override + public Sender longArray(@NotNull CharSequence name, long[][] values) { + delegate.longArray(name, values); + return this; + } + + @Override + public Sender longArray(@NotNull CharSequence name, long[][][] values) { + delegate.longArray(name, values); + return this; + } + + @Override + public Sender longArray(@NotNull CharSequence name, LongArray values) { + delegate.longArray(name, values); + return this; + } + + @Override + public Sender longColumn(CharSequence name, long value) { + delegate.longColumn(name, value); + return this; + } + + @Override + public void reset() { + delegate.reset(); + } + + @Override + public Sender shortColumn(CharSequence name, short value) { + delegate.shortColumn(name, value); + return this; + } + + @Override + public Sender stringColumn(CharSequence name, CharSequence value) { + delegate.stringColumn(name, value); + return this; + } + + @Override + public Sender symbol(CharSequence name, CharSequence value) { + delegate.symbol(name, value); + return this; + } + + @Override + public Sender table(CharSequence table) { + delegate.table(table); + return this; + } + + @Override + public Sender timestampColumn(CharSequence name, long value, ChronoUnit unit) { + delegate.timestampColumn(name, value, unit); + return this; + } + + @Override + public Sender timestampColumn(CharSequence name, Instant value) { + delegate.timestampColumn(name, value); + return this; + } + + @Override + public Sender uuidColumn(CharSequence name, long lo, long hi) { + delegate.uuidColumn(name, lo, hi); + return this; + } + + long createdAtMillis() { + return createdAtMillis; + } + + Sender delegate() { + return delegate; + } + + long idleSinceMillis() { + return idleSinceMillis; + } + + boolean isInUse() { + return inUse; + } + + boolean isInvalidated() { + return invalidated; + } + + void markIdleAt(long nowMillis) { + idleSinceMillis = nowMillis; + } + + void markInUse() { + inUse = true; + } + + void markInvalidated() { + invalidated = true; + } +} diff --git a/core/src/main/java/io/questdb/client/impl/QueryClientPool.java b/core/src/main/java/io/questdb/client/impl/QueryClientPool.java new file mode 100644 index 00000000..8191f162 --- /dev/null +++ b/core/src/main/java/io/questdb/client/impl/QueryClientPool.java @@ -0,0 +1,260 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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.impl; + +import io.questdb.client.QueryException; +import io.questdb.client.cutlass.qwp.client.QwpQueryClient; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.locks.Condition; +import java.util.concurrent.locks.ReentrantLock; + +/** + * Elastic pool of {@link QueryWorker}s. Each worker pairs one + * {@link QwpQueryClient} (one WebSocket, one I/O thread) with one daemon + * dispatch thread. The pool keeps at least {@code minSize} workers warm and + * grows up to {@code maxSize} on demand; idle and over-age workers are + * reaped by the housekeeper. + *

+ * Worker creation involves a WebSocket upgrade (slow), so it happens + * outside the pool lock; an {@code inFlightCreations} counter keeps the + * cap check honest under concurrent acquires. + */ +public final class QueryClientPool { + + private final long acquireTimeoutMillis; + private final ArrayList all; + private final ArrayDeque available; + private final String configurationString; + private final long idleTimeoutMillis; + private final ReentrantLock lock = new ReentrantLock(); + private final long maxLifetimeMillis; + private final int maxSize; + private final int minSize; + private final AtomicInteger nextSlotIndex = new AtomicInteger(); + private final Condition workerReleased; + private volatile boolean closed; + private int inFlightCreations; + + public QueryClientPool( + String configurationString, + int minSize, + int maxSize, + long acquireTimeoutMillis, + long idleTimeoutMillis, + long maxLifetimeMillis + ) { + if (minSize < 0 || maxSize < 1 || minSize > maxSize) { + throw new IllegalArgumentException("invalid pool sizing: min=" + minSize + ", max=" + maxSize); + } + this.configurationString = configurationString; + this.minSize = minSize; + this.maxSize = maxSize; + this.acquireTimeoutMillis = acquireTimeoutMillis; + this.idleTimeoutMillis = idleTimeoutMillis; + this.maxLifetimeMillis = maxLifetimeMillis; + this.all = new ArrayList<>(maxSize); + this.available = new ArrayDeque<>(maxSize); + this.workerReleased = lock.newCondition(); + int built = 0; + try { + for (int i = 0; i < minSize; i++) { + QueryWorker w = createUnlocked(); + w.start(); + all.add(w); + available.add(w); + built++; + } + } catch (RuntimeException e) { + for (int i = 0; i < built; i++) { + try { + all.get(i).shutdown(); + } catch (RuntimeException ignored) { + } + } + throw e; + } + } + + public QueryWorker acquire() { + // Track remaining wait via awaitNanos's return value (canonical pattern): + // awaitNanos consumes from the budget on each wait and reports what is + // left; <= 0 means the budget is exhausted. + long remainingNanos = TimeUnit.MILLISECONDS.toNanos(acquireTimeoutMillis); + lock.lock(); + try { + while (true) { + if (closed) { + throw new QueryException((byte) 0, "QuestDB handle is closed"); + } + if (!available.isEmpty()) { + return available.pollFirst(); + } + if (all.size() + inFlightCreations < maxSize) { + inFlightCreations++; + lock.unlock(); + QueryWorker created; + try { + created = createUnlocked(); + created.start(); + } catch (RuntimeException e) { + lock.lock(); + inFlightCreations--; + workerReleased.signal(); + lock.unlock(); + throw new QueryException((byte) 0, + "failed to create query client: " + e.getMessage(), e); + } + lock.lock(); + inFlightCreations--; + if (closed) { + try { + created.shutdown(); + } catch (RuntimeException ignored) { + } + throw new QueryException((byte) 0, "QuestDB handle is closed"); + } + all.add(created); + return created; + } + if (remainingNanos <= 0) { + throw new QueryException((byte) 0, + "timed out waiting for a query client from the pool after " + + acquireTimeoutMillis + "ms"); + } + try { + remainingNanos = workerReleased.awaitNanos(remainingNanos); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new QueryException((byte) 0, + "interrupted while waiting for a query client from the pool"); + } + } + } finally { + if (lock.isHeldByCurrentThread()) { + lock.unlock(); + } + } + } + + public void close() { + ArrayList snapshot; + lock.lock(); + try { + if (closed) { + return; + } + closed = true; + workerReleased.signalAll(); + snapshot = new ArrayList<>(all); + } finally { + lock.unlock(); + } + // Cancel any in-flight queries first so execute() returns promptly, then + // join the worker threads and close their clients. Done outside the lock + // so a slow join doesn't keep the pool latched. + for (int i = 0; i < snapshot.size(); i++) { + snapshot.get(i).shutdown(); + } + } + + void reapIdle() { + if (closed) { + return; + } + long now = System.currentTimeMillis(); + ArrayList toShutdown = null; + lock.lock(); + try { + if (closed) { + return; + } + Iterator it = available.iterator(); + while (it.hasNext() && all.size() > minSize) { + QueryWorker w = it.next(); + boolean idleExpired = idleTimeoutMillis < Long.MAX_VALUE + && (now - w.idleSinceMillis()) >= idleTimeoutMillis; + boolean overAge = maxLifetimeMillis < Long.MAX_VALUE + && (now - w.createdAtMillis()) >= maxLifetimeMillis; + if (idleExpired || overAge) { + it.remove(); + all.remove(w); + if (toShutdown == null) { + toShutdown = new ArrayList<>(); + } + toShutdown.add(w); + } + } + } finally { + lock.unlock(); + } + if (toShutdown != null) { + for (int i = 0, n = toShutdown.size(); i < n; i++) { + try { + toShutdown.get(i).shutdown(); + } catch (RuntimeException ignored) { + } + } + } + } + + void release(QueryWorker w) { + long now = System.currentTimeMillis(); + w.markIdleAt(now); + lock.lock(); + try { + if (closed) { + return; + } + available.addLast(w); + workerReleased.signal(); + } finally { + lock.unlock(); + } + } + + private QueryWorker createUnlocked() { + QwpQueryClient client = QwpQueryClient.fromConfig(configurationString); + try { + client.connect(); + } catch (RuntimeException e) { + // connect() may throw after QwpQueryClient.fromConfig() has already + // allocated native scratch (the QwpBindValues NativeBufferWriter is + // field-initialised). Close the half-built client so its allocations + // are released, otherwise every connect failure during pool growth + // leaks NATIVE_DEFAULT bytes. + try { + client.close(); + } catch (RuntimeException ignored) { + } + throw e; + } + return new QueryWorker(client, this, nextSlotIndex.getAndIncrement()); + } +} diff --git a/core/src/main/java/io/questdb/client/impl/QueryImpl.java b/core/src/main/java/io/questdb/client/impl/QueryImpl.java new file mode 100644 index 00000000..fc80d263 --- /dev/null +++ b/core/src/main/java/io/questdb/client/impl/QueryImpl.java @@ -0,0 +1,316 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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.impl; + +import io.questdb.client.Completion; +import io.questdb.client.Query; +import io.questdb.client.QueryException; +import io.questdb.client.cutlass.qwp.client.QwpBindSetter; +import io.questdb.client.cutlass.qwp.client.QwpBindValues; +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.client.QwpServerInfo; +import io.questdb.client.std.str.StringSink; + +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.Condition; +import java.util.concurrent.locks.ReentrantLock; + +/** + * Per-thread implementation of {@link Query}. Holds the configured query + * state (SQL, optional binds, handler), an inner {@link Completion}, and a + * wrapping {@link QwpColumnBatchHandler} that forwards callbacks to the user + * handler and signals the Completion on terminal events. + *

+ * Lifecycle: {@link QuestDBImpl#query()} returns a per-thread instance, reset + * to empty if it was in a terminal state. {@link #submit()} acquires a + * worker, dispatches, and returns the cached {@link Completion}. + */ +final class QueryImpl implements Query { + + private final InnerCompletion completion = new InnerCompletion(); + private final Condition doneCondition; + private final ReentrantLock doneLock = new ReentrantLock(); + private final QueryClientPool pool; + private final StringSink sqlBuffer = new StringSink(); + private final WrappingHandler wrappingHandler = new WrappingHandler(); + private volatile QueryWorker currentWorker; + private volatile boolean done = true; + private volatile String resultMessage; + private volatile byte resultStatus; + private volatile Throwable unexpectedError; + private QwpBindSetter userBinds; + private final QwpBindSetter wireBinds = this::applyBinds; + private QwpColumnBatchHandler userHandler; + + QueryImpl(QueryClientPool pool) { + this.pool = pool; + this.doneCondition = doneLock.newCondition(); + } + + @Override + public void abandon() { + if (!done) { + throw new IllegalStateException("a previous submit() is still in flight; await the Completion first"); + } + userBinds = null; + userHandler = null; + sqlBuffer.clear(); + } + + @Override + public Query binds(QwpBindSetter binds) { + this.userBinds = binds; + return this; + } + + @Override + public Query handler(QwpColumnBatchHandler handler) { + this.userHandler = handler; + return this; + } + + @Override + public Query sql(CharSequence sql) { + sqlBuffer.clear(); + sqlBuffer.put(sql); + return this; + } + + @Override + public Completion submit() { + if (sqlBuffer.length() == 0) { + throw new IllegalStateException("sql is required"); + } + if (userHandler == null) { + throw new IllegalStateException("handler is required"); + } + if (!done) { + throw new IllegalStateException("a previous submit() is still in flight; await the Completion first"); + } + QueryWorker w = pool.acquire(); + // Reset terminal state under the lock so a stale signal from a prior + // run can't be observed by the upcoming await(). + doneLock.lock(); + try { + done = false; + resultStatus = 0; + resultMessage = null; + unexpectedError = null; + currentWorker = w; + } finally { + doneLock.unlock(); + } + w.dispatch(this); + return completion; + } + + private void applyBinds(QwpBindValues binds) { + QwpBindSetter setter = userBinds; + if (setter != null) { + setter.apply(binds); + } + } + + private void signalDone(byte status, String message, Throwable unexpected) { + doneLock.lock(); + try { + if (done) { + return; + } + this.resultStatus = status; + this.resultMessage = message; + this.unexpectedError = unexpected; + this.done = true; + this.currentWorker = null; + doneCondition.signalAll(); + } finally { + doneLock.unlock(); + } + } + + /** + * Drops any prior builder state (SQL, binds, handler) if no submit is + * currently in flight. {@link QuestDBImpl#query()} invokes this before + * returning the per-thread instance so callers see the "reset to empty" + * contract documented on {@link io.questdb.client.Query} regardless of + * whether the previous use ended at a terminal handler callback or at + * {@link #abandon()}. + */ + void resetIfDone() { + if (done) { + userBinds = null; + userHandler = null; + sqlBuffer.clear(); + } + } + + void runOn(QwpQueryClient client) { + // Pass the StringSink directly as a CharSequence -- the wire encoder + // reads chars and writes UTF-8 bytes straight into the send buffer. + // sqlBuffer is stable for the duration of execute(): the calling + // worker thread is blocked here until a terminal event arrives, and + // sql(...) cannot be invoked again until done==true. + client.execute(sqlBuffer, wireBinds, wrappingHandler); + } + + /** + * Signals an unexpected error from the worker thread (for example, an + * exception escaping {@code execute()} before any handler callback). + */ + void signalUnexpected(Throwable t) { + signalDone((byte) 0, t.getMessage() != null ? t.getMessage() : t.getClass().getSimpleName(), t); + } + + private final class InnerCompletion implements Completion { + + @Override + public void await() throws InterruptedException { + doneLock.lock(); + try { + while (!done) { + doneCondition.await(); + } + } finally { + doneLock.unlock(); + } + throwIfFailed(); + } + + @Override + public boolean await(long timeout, TimeUnit unit) throws InterruptedException { + long remaining = unit.toNanos(timeout); + doneLock.lock(); + try { + while (!done) { + if (remaining <= 0) { + return false; + } + remaining = doneCondition.awaitNanos(remaining); + } + } finally { + doneLock.unlock(); + } + throwIfFailed(); + return true; + } + + @Override + public void cancel() { + QueryWorker w = currentWorker; + if (w != null && !done) { + w.cancelInFlight(); + } + } + + @Override + public boolean isDone() { + return done; + } + + private void throwIfFailed() { + Throwable unexpected = unexpectedError; + if (unexpected != null) { + throw new QueryException(resultStatus, resultMessage, unexpected); + } + if (resultStatus != 0) { + throw new QueryException(resultStatus, resultMessage); + } + } + } + + private final class WrappingHandler implements QwpColumnBatchHandler { + + @Override + public void onBatch(QwpColumnBatch batch) { + userHandler.onBatch(batch); + } + + @Override + public void onEnd(long totalRows) { + try { + userHandler.onEnd(totalRows); + } finally { + signalDone((byte) 0, null, null); + } + } + + @Override + public void onEnd(long requestId, long totalRows) { + try { + userHandler.onEnd(requestId, totalRows); + } finally { + signalDone((byte) 0, null, null); + } + } + + @Override + public void onError(byte status, String message) { + try { + userHandler.onError(status, message); + } finally { + signalDone(status, message, null); + } + } + + @Override + public void onError(long requestId, byte status, String message) { + try { + userHandler.onError(requestId, status, message); + } finally { + signalDone(status, message, null); + } + } + + @Override + public void onExecDone(short opType, long rowsAffected) { + try { + userHandler.onExecDone(opType, rowsAffected); + } finally { + signalDone((byte) 0, null, null); + } + } + + @Override + public void onExecDone(long requestId, short opType, long rowsAffected) { + try { + userHandler.onExecDone(requestId, opType, rowsAffected); + } finally { + signalDone((byte) 0, null, null); + } + } + + @Override + public void onFailoverReset(QwpServerInfo newNode) { + userHandler.onFailoverReset(newNode); + } + + @Override + public void onFailoverReset(long requestId, QwpServerInfo newNode) { + userHandler.onFailoverReset(requestId, newNode); + } + } +} diff --git a/core/src/main/java/io/questdb/client/impl/QueryWorker.java b/core/src/main/java/io/questdb/client/impl/QueryWorker.java new file mode 100644 index 00000000..4b251431 --- /dev/null +++ b/core/src/main/java/io/questdb/client/impl/QueryWorker.java @@ -0,0 +1,185 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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.impl; + +import io.questdb.client.QueryException; +import io.questdb.client.cutlass.qwp.client.QwpQueryClient; + +import java.util.concurrent.locks.Condition; +import java.util.concurrent.locks.ReentrantLock; + +/** + * Pairs one {@link QwpQueryClient} with one dedicated thread. The worker thread + * loops, waiting for {@link #dispatch} to hand it a {@link QueryImpl}, then + * runs {@code execute()} synchronously and releases itself back to the pool + * when the call returns. + *

+ * The pooled query client's own I/O thread continues to drive the wire; the + * worker thread exists only to keep {@code execute()} off the application's + * submitting thread. Handler callbacks ({@code onBatch}, {@code onEnd}, + * {@code onError}) still run on the client's I/O thread. + */ +public final class QueryWorker { + + static final long SHUTDOWN_JOIN_MILLIS = 5_000; + private final QwpQueryClient client; + private final long createdAtMillis; + private final QueryClientPool pool; + private final Condition signalCondition; + private final ReentrantLock signalLock = new ReentrantLock(); + private final Thread thread; + private volatile QueryImpl current; + private volatile long idleSinceMillis; + private volatile boolean shuttingDown; + + public QueryWorker(QwpQueryClient client, QueryClientPool pool, int slotIndex) { + this.client = client; + this.pool = pool; + this.signalCondition = signalLock.newCondition(); + this.thread = new Thread(this::runLoop, "questdb-query-worker-" + slotIndex); + this.thread.setDaemon(true); + this.createdAtMillis = System.currentTimeMillis(); + this.idleSinceMillis = this.createdAtMillis; + } + + long createdAtMillis() { + return createdAtMillis; + } + + long idleSinceMillis() { + return idleSinceMillis; + } + + void markIdleAt(long nowMillis) { + idleSinceMillis = nowMillis; + } + + /** + * Cancels the in-flight query on this worker's client. Safe to call from + * any thread; harmless if the worker is idle. + */ + void cancelInFlight() { + try { + client.cancel(); + } catch (RuntimeException ignored) { + // cancel() is best-effort; an already-completed query is fine. + } + } + + /** + * Returns the {@link QwpQueryClient} this worker drives. Exposed for + * introspection and tests; callers must not invoke {@code execute()} on + * it directly because that would race the worker's own dispatch loop. + */ + public QwpQueryClient client() { + return client; + } + + void shutdown() { + shuttingDown = true; + signalLock.lock(); + try { + signalCondition.signalAll(); + } finally { + signalLock.unlock(); + } + // If a query is in flight on this worker, ask the client to abort so + // execute() returns promptly and the thread can exit before join times + // out. cancel() is documented as thread-safe and is a no-op when idle. + try { + client.cancel(); + } catch (RuntimeException ignored) { + } + try { + thread.join(SHUTDOWN_JOIN_MILLIS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + try { + client.close(); + } catch (RuntimeException ignored) { + } + } + + void start() { + thread.start(); + } + + /** + * Hands a configured {@link QueryImpl} to this worker. The caller must + * have just acquired this worker via QueryClientPool#acquire(long). + */ + void dispatch(QueryImpl q) { + signalLock.lock(); + try { + if (shuttingDown) { + // shutdown() has already flipped the flag and signalled the + // worker thread, so the run loop will not pick this up. Signal + // the caller directly so its Completion.await() does not hang. + q.signalUnexpected(new QueryException((byte) 0, "QuestDB handle is closed")); + return; + } + current = q; + signalCondition.signal(); + } finally { + signalLock.unlock(); + } + } + + private void runLoop() { + while (!shuttingDown) { + QueryImpl q; + signalLock.lock(); + try { + while (current == null && !shuttingDown) { + signalCondition.awaitUninterruptibly(); + } + if (shuttingDown) { + // shutdown() raced an in-flight dispatch(): current was set + // by a caller but the loop never got to runOn(). Signal the + // caller so its Completion.await() does not hang. + QueryImpl stranded = current; + current = null; + if (stranded != null) { + stranded.signalUnexpected( + new QueryException((byte) 0, "QuestDB handle is closed")); + } + return; + } + q = current; + } finally { + signalLock.unlock(); + } + try { + q.runOn(client); + } catch (Throwable t) { + q.signalUnexpected(t); + } finally { + current = null; + pool.release(this); + } + } + } +} diff --git a/core/src/main/java/io/questdb/client/impl/QuestDBImpl.java b/core/src/main/java/io/questdb/client/impl/QuestDBImpl.java new file mode 100644 index 00000000..cc974ac1 --- /dev/null +++ b/core/src/main/java/io/questdb/client/impl/QuestDBImpl.java @@ -0,0 +1,131 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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.impl; + +import io.questdb.client.Completion; +import io.questdb.client.QuestDB; +import io.questdb.client.Query; +import io.questdb.client.Sender; +import io.questdb.client.cutlass.qwp.client.QwpColumnBatchHandler; + +/** + * Implementation of {@link QuestDB}. Owns the elastic {@link SenderPool} + * and {@link QueryClientPool}, a {@link PoolHousekeeper} that reaps idle + * slots, and a {@link ThreadLocal} of {@link QueryImpl} instances so that + * {@link #query()} is allocation-free after the first call on each thread. + */ +public final class QuestDBImpl implements QuestDB { + + private final PoolHousekeeper housekeeper; + private final QueryClientPool queryPool; + private final ThreadLocal queryThreadLocal; + private final SenderPool senderPool; + private volatile boolean closed; + + public QuestDBImpl( + String ingestConfig, + String queryConfig, + int senderMin, + int senderMax, + int queryMin, + int queryMax, + long acquireTimeoutMillis, + long idleTimeoutMillis, + long maxLifetimeMillis, + long housekeeperIntervalMillis + ) { + SenderPool builtSenderPool = null; + QueryClientPool builtQueryPool = null; + PoolHousekeeper builtHousekeeper = null; + try { + builtSenderPool = new SenderPool( + ingestConfig, senderMin, senderMax, acquireTimeoutMillis, + idleTimeoutMillis, maxLifetimeMillis); + builtQueryPool = new QueryClientPool( + queryConfig, queryMin, queryMax, acquireTimeoutMillis, + idleTimeoutMillis, maxLifetimeMillis); + builtHousekeeper = new PoolHousekeeper(builtSenderPool, builtQueryPool, housekeeperIntervalMillis); + builtHousekeeper.start(); + } catch (RuntimeException e) { + if (builtHousekeeper != null) { + builtHousekeeper.stop(); + } + if (builtQueryPool != null) { + builtQueryPool.close(); + } + if (builtSenderPool != null) { + builtSenderPool.close(); + } + throw e; + } + this.senderPool = builtSenderPool; + this.queryPool = builtQueryPool; + this.housekeeper = builtHousekeeper; + this.queryThreadLocal = ThreadLocal.withInitial(() -> new QueryImpl(queryPool)); + } + + @Override + public Sender borrowSender() { + return senderPool.borrow(); + } + + @Override + public void close() { + if (closed) { + return; + } + closed = true; + housekeeper.stop(); + queryPool.close(); + senderPool.close(); + } + + @Override + public Completion executeSql(CharSequence sql, QwpColumnBatchHandler handler) { + return query().sql(sql).handler(handler).submit(); + } + + @Override + public Query newQuery() { + return new QueryImpl(queryPool); + } + + @Override + public Query query() { + QueryImpl q = queryThreadLocal.get(); + q.resetIfDone(); + return q; + } + + @Override + public void releaseSender() { + senderPool.releaseCurrentThread(); + } + + @Override + public Sender sender() { + return senderPool.pinToCurrentThread(); + } +} diff --git a/core/src/main/java/io/questdb/client/impl/SenderPool.java b/core/src/main/java/io/questdb/client/impl/SenderPool.java new file mode 100644 index 00000000..61b6ac69 --- /dev/null +++ b/core/src/main/java/io/questdb/client/impl/SenderPool.java @@ -0,0 +1,373 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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.impl; + +import io.questdb.client.Sender; +import io.questdb.client.cutlass.line.LineSenderException; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.Condition; +import java.util.concurrent.locks.ReentrantLock; + +/** + * Elastic pool of {@link Sender} instances, each wrapped in a + * {@link PooledSender} decorator. The pool keeps at least {@code minSize} + * connections warm, grows on demand up to {@code maxSize}, and lets the + * housekeeper reap slots that have idled longer than {@code idleTimeoutMillis} + * or aged past {@code maxLifetimeMillis} (with {@code minSize} respected at + * all times). + *

+ * The hot borrow / return path takes a {@link ReentrantLock} but does no + * per-call allocation; the underlying {@link ArrayDeque} of free decorators + * is pre-sized to {@code maxSize}. + *

+ * Connection creation happens outside the lock so a slow connect (TLS + * handshake, DNS) does not block other borrowers or the housekeeper. The + * pool tracks in-flight creations via {@code inFlightCreations} so the cap + * check ({@code allSize + inFlightCreations < maxSize}) stays correct under + * concurrent borrows. + */ +public final class SenderPool implements AutoCloseable { + + private final long acquireTimeoutMillis; + private final ArrayList all; + private final ArrayDeque available; + private final String configurationString; + private final long idleTimeoutMillis; + private final ReentrantLock lock = new ReentrantLock(); + private final long maxLifetimeMillis; + private final int maxSize; + private final int minSize; + private final Condition slotReleased; + private final ThreadLocal threadAffine = new ThreadLocal<>(); + private volatile boolean closed; + private int inFlightCreations; + + public SenderPool( + String configurationString, + int minSize, + int maxSize, + long acquireTimeoutMillis, + long idleTimeoutMillis, + long maxLifetimeMillis + ) { + if (minSize < 0 || maxSize < 1 || minSize > maxSize) { + throw new IllegalArgumentException("invalid pool sizing: min=" + minSize + ", max=" + maxSize); + } + this.configurationString = configurationString; + this.minSize = minSize; + this.maxSize = maxSize; + this.acquireTimeoutMillis = acquireTimeoutMillis; + this.idleTimeoutMillis = idleTimeoutMillis; + this.maxLifetimeMillis = maxLifetimeMillis; + this.all = new ArrayList<>(maxSize); + this.available = new ArrayDeque<>(maxSize); + this.slotReleased = lock.newCondition(); + // Pre-warm minSize connections. + int built = 0; + try { + for (int i = 0; i < minSize; i++) { + PooledSender ps = createUnlocked(); + all.add(ps); + available.add(ps); + built++; + } + } catch (RuntimeException e) { + for (int i = 0; i < built; i++) { + try { + all.get(i).delegate().close(); + } catch (RuntimeException ignored) { + } + } + throw e; + } + } + + public PooledSender borrow() { + // Track remaining wait via awaitNanos's return value (canonical pattern): + // awaitNanos consumes from the budget on each wait and reports what is + // left; <= 0 means the budget is exhausted. + long remainingNanos = TimeUnit.MILLISECONDS.toNanos(acquireTimeoutMillis); + lock.lock(); + try { + while (true) { + if (closed) { + throw new LineSenderException("QuestDB handle is closed"); + } + if (!available.isEmpty()) { + PooledSender s = available.pollFirst(); + s.markInUse(); + return s; + } + if (all.size() + inFlightCreations < maxSize) { + inFlightCreations++; + lock.unlock(); + PooledSender created; + try { + created = createUnlocked(); + } catch (RuntimeException e) { + lock.lock(); + inFlightCreations--; + slotReleased.signal(); + lock.unlock(); + throw e; + } + lock.lock(); + inFlightCreations--; + if (closed) { + // Pool was closed mid-creation -- destroy the new connection + // rather than leaking it. Other waiters have been signaled + // by close() already. + try { + created.delegate().close(); + } catch (RuntimeException ignored) { + } + throw new LineSenderException("QuestDB handle is closed"); + } + all.add(created); + created.markInUse(); + return created; + } + if (remainingNanos <= 0) { + throw new LineSenderException( + "timed out waiting for a Sender from the pool after " + acquireTimeoutMillis + "ms"); + } + try { + remainingNanos = slotReleased.awaitNanos(remainingNanos); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new LineSenderException("interrupted while waiting for a Sender from the pool"); + } + } + } finally { + if (lock.isHeldByCurrentThread()) { + lock.unlock(); + } + } + } + + @Override + public void close() { + PooledSender[] snapshot; + lock.lock(); + try { + if (closed) { + return; + } + closed = true; + // Mark every pooled wrapper invalidated so pinToCurrentThread() + // on other threads -- which never takes this lock -- can detect + // that its cached entry no longer wraps a live delegate. Removing + // the calling thread's ThreadLocal only clears one slot; other + // threads' slots survive until they read the flag. + for (int i = 0; i < all.size(); i++) { + all.get(i).markInvalidated(); + } + // Snapshot under the lock so the delegate-close loop below is + // immune to concurrent mutation of `all`. discardBroken running + // on another thread can still bail thanks to the `closed` check + // it now performs; the snapshot is belt-and-braces for any + // future code path that mutates `all` outside this lock's + // happens-before chain. + snapshot = all.toArray(new PooledSender[0]); + threadAffine.remove(); + slotReleased.signalAll(); + } finally { + lock.unlock(); + } + // Close each delegate from the snapshot, outside the lock so a slow + // real-close() doesn't keep the pool latched. + for (int i = 0; i < snapshot.length; i++) { + try { + snapshot[i].delegate().close(); + } catch (RuntimeException ignored) { + } + } + } + + /** + * Clears the current thread's pin if it currently references {@code s}. + * Invoked from {@link PooledSender#close()} before the wrapper is + * returned to the pool, so a subsequent {@link #pinToCurrentThread()} + * on this thread cannot hand the wrapper back after another consumer + * has borrowed the slot. No-op when the caller never pinned, or pinned + * a different wrapper. + */ + void clearPinIfCurrent(PooledSender s) { + if (threadAffine.get() == s) { + threadAffine.remove(); + } + } + + /** + * Evicts a slot whose delegate has failed (typically a {@code flush()} + * failure observed in {@link PooledSender#close()}). The wrapper is + * marked invalidated so any thread-pinned reference gets rejected on the + * next {@link #pinToCurrentThread()} call; the slot is removed from + * {@code all} so the pool can grow back into a fresh slot on demand. The + * underlying delegate is closed outside the lock so a slow real-close + * does not stall other borrowers. + *

+ * Bails when the pool is already closed: {@link #close()} owns the + * teardown of every delegate via its snapshot loop, so mutating + * {@code all} here would race that iteration on a non-thread-safe + * {@code ArrayList} and the {@code delegate.close()} below would be a + * double-close on a delegate {@code close()} has already shut down. + */ + void discardBroken(PooledSender s) { + s.markInvalidated(); + lock.lock(); + try { + if (closed) { + return; + } + all.remove(s); + // Wake one waiter -- the cap check in borrow() uses all.size(), + // so a freed slot may now allow a creation attempt. + slotReleased.signal(); + } finally { + lock.unlock(); + } + try { + s.delegate().close(); + } catch (RuntimeException ignored) { + } + } + + public void giveBack(PooledSender s) { + long now = System.currentTimeMillis(); + s.markIdleAt(now); + lock.lock(); + try { + if (closed) { + // Pool already shut down: don't requeue; let close() finish destroying. + return; + } + available.addLast(s); + slotReleased.signal(); + } finally { + lock.unlock(); + } + } + + public PooledSender pinToCurrentThread() { + PooledSender pinned = threadAffine.get(); + if (pinned != null && !pinned.isInvalidated()) { + return pinned; + } + if (pinned != null) { + threadAffine.remove(); + } + PooledSender s = borrow(); + threadAffine.set(s); + return s; + } + + /** + * Closes idle slots that have exceeded {@code idleTimeoutMillis} or that + * have aged past {@code maxLifetimeMillis}. Never shrinks below + * {@code minSize}. Called by the {@link PoolHousekeeper} on its tick. + */ + public void reapIdle() { + if (closed) { + return; + } + long now = System.currentTimeMillis(); + ArrayList toClose = null; + lock.lock(); + try { + if (closed) { + return; + } + Iterator it = available.iterator(); + while (it.hasNext() && all.size() > minSize) { + PooledSender s = it.next(); + boolean idleExpired = idleTimeoutMillis < Long.MAX_VALUE + && (now - s.idleSinceMillis()) >= idleTimeoutMillis; + boolean overAge = maxLifetimeMillis < Long.MAX_VALUE + && (now - s.createdAtMillis()) >= maxLifetimeMillis; + if (idleExpired || overAge) { + it.remove(); + all.remove(s); + if (toClose == null) { + toClose = new ArrayList<>(); + } + toClose.add(s); + } + } + } finally { + lock.unlock(); + } + if (toClose != null) { + for (int i = 0, n = toClose.size(); i < n; i++) { + try { + toClose.get(i).delegate().close(); + } catch (RuntimeException ignored) { + } + } + } + } + + /** Snapshot of the number of idle slots. For tests and introspection. */ + public int availableSize() { + lock.lock(); + try { + return available.size(); + } finally { + lock.unlock(); + } + } + + /** Snapshot of the total number of live slots (idle + in-use). For tests and introspection. */ + public int totalSize() { + lock.lock(); + try { + return all.size(); + } finally { + lock.unlock(); + } + } + + public void releaseCurrentThread() { + PooledSender pinned = threadAffine.get(); + if (pinned == null) { + return; + } + threadAffine.remove(); + if (pinned.isInvalidated()) { + // Pool was closed: delegate is already closed, skip flush/giveBack. + return; + } + pinned.close(); + } + + private PooledSender createUnlocked() { + Sender raw = Sender.fromConfig(configurationString); + return new PooledSender(raw, this); + } +} diff --git a/core/src/test/java/io/questdb/client/test/QuestDBBuilderTest.java b/core/src/test/java/io/questdb/client/test/QuestDBBuilderTest.java new file mode 100644 index 00000000..69cb4645 --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/QuestDBBuilderTest.java @@ -0,0 +1,135 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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; + +import io.questdb.client.QuestDB; +import org.junit.Assert; +import org.junit.Test; + +public class QuestDBBuilderTest { + + @Test + public void testMissingIngestConfigThrows() { + try { + QuestDB.builder().queryConfig("ws::addr=h:9000;").build().close(); + Assert.fail(); + } catch (IllegalStateException e) { + Assert.assertTrue(e.getMessage().contains("ingest")); + } + } + + @Test + public void testMissingQueryConfigThrows() { + try { + QuestDB.builder().ingestConfig("http::addr=h:9000;").build().close(); + Assert.fail(); + } catch (IllegalStateException e) { + Assert.assertTrue(e.getMessage().contains("query")); + } + } + + @Test + public void testNegativeAcquireTimeoutRejected() { + try { + QuestDB.builder().acquireTimeoutMillis(-1); + Assert.fail(); + } catch (IllegalArgumentException ignored) { + } + } + + @Test + public void testNegativePoolSizesRejected() { + try { + QuestDB.builder().senderPoolSize(0); + Assert.fail(); + } catch (IllegalArgumentException ignored) { + } + try { + QuestDB.builder().queryPoolSize(0); + Assert.fail(); + } catch (IllegalArgumentException ignored) { + } + } + + @Test + public void testBuilderCallAfterFromConfigOverridesPoolKeysFromString() { + // Build to a dead address with a forced exhaustion timeout so we can read + // the timeout off the resulting LineSenderException. fromConfig() sets + // acquire_timeout_ms=10000; subsequent acquireTimeoutMillis(150) wins + // because the builder applies last-write-wins. + try (io.questdb.client.QuestDB ignored = QuestDB.builder() + .fromConfig("http::addr=127.0.0.1:1;protocol_version=2;auto_flush=off;" + + "sender_pool_min=1;sender_pool_max=1;query_pool_min=1;query_pool_max=1;" + + "acquire_timeout_ms=10000;idle_timeout_ms=0;max_lifetime_ms=0;") + .queryConfig("ws::addr=127.0.0.1:1;auth_timeout_ms=50;failover=off;query_pool_min=0;query_pool_max=0;") + .acquireTimeoutMillis(150) + .build()) { + Assert.fail("expected build to fail (no live server)"); + } catch (RuntimeException expected) { + // Either sender or query pool build fails -- both are fine, both prove the + // builder is wired through. The pool-config keys in the strings did not + // crash the parsers (test would have thrown InvalidArgument earlier). + } + } + + @Test + public void testConnectStringWithPoolKeysAppliedToBuilder() { + // Build will fail (dead address) but we can verify the timeout came from + // the connect string by measuring how long borrowSender blocks would take. + // Easier: just assert the build path doesn't choke on the pool keys. + try (io.questdb.client.QuestDB ignored = QuestDB.builder() + .ingestConfig("http::addr=127.0.0.1:1;protocol_version=2;auto_flush=off;") + .queryConfig("ws::addr=127.0.0.1:1;auth_timeout_ms=100;failover=off;") + .senderPoolSize(1) + .queryPoolSize(1) + .acquireTimeoutMillis(100) + .build()) { + Assert.fail("build should fail with dead query address"); + } catch (RuntimeException expected) { + // Validated by absence of an IllegalArgumentException for pool keys. + } + } + + @Test + public void testQueryPoolBuildFailureUnwindsSenderPool() { + // Sender pool builds fine (http connects lazily); query pool fails because + // ws::127.0.0.1:1 is not a live QuestDB. The handle must clean up the + // already-built sender pool rather than leaking N Senders. + try { + QuestDB.builder() + .ingestConfig("http::addr=127.0.0.1:1;protocol_version=2;auto_flush=off;") + .queryConfig("ws::addr=127.0.0.1:1;auth_timeout_ms=200;failover=off;") + .senderPoolSize(2) + .queryPoolSize(2) + .acquireTimeoutMillis(500) + .build() + .close(); + Assert.fail("expected build to fail when query pool cannot connect"); + } catch (RuntimeException expected) { + // The exact exception type comes from QwpQueryClient.connect(); + // we only assert the build failed so we know cleanup ran. + } + } +} diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/CloseDrainTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/CloseDrainTest.java index 13168f60..d836b9c0 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/CloseDrainTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/CloseDrainTest.java @@ -52,8 +52,8 @@ public class CloseDrainTest { @Test public void testCloseBlocksUntilAckArrives() throws Exception { // Server delays every ACK by 800ms. With the default - // close_flush_timeout_millis=5000, close() must wait for that ACK - // before returning. Pre-fix close() returned within milliseconds. + // close_flush_timeout_millis=60000, close() must wait for that + // ACK before returning. Pre-fix close() returned within milliseconds. int port = TestPorts.findUnusedPort(); long ackDelayMs = 800; DelayingAckHandler handler = new DelayingAckHandler(ackDelayMs); @@ -113,7 +113,7 @@ public void testCloseFastWhenTimeoutIsMinusOne() throws Exception { // // Currently fails because -1 collides with the PARAMETER_NOT_SET_EXPLICITLY // sentinel in LineSenderBuilder, so the build path silently substitutes - // DEFAULT_CLOSE_FLUSH_TIMEOUT_MILLIS (5000ms) and close() blocks for the + // DEFAULT_CLOSE_FLUSH_TIMEOUT_MILLIS (60s) and close() blocks for the // full ACK delay instead of returning fast. int port = TestPorts.findUnusedPort(); long ackDelayMs = 1500; @@ -177,6 +177,95 @@ public void testCloseDrainTimesOutWhenAcksNeverArrive() throws Exception { } } + @Test + public void testDrainBlocksUntilAckArrivesAndReturnsTrue() throws Exception { + // Public drain(timeoutMillis): explicit pre-close drain that the + // caller controls per call-site. Same delayed-ACK server as + // testCloseBlocksUntilAckArrives, but the wait happens inside the + // explicit drain() call. The subsequent close() should be a near- + // instant no-op because everything is already acked. + int port = TestPorts.findUnusedPort(); + long ackDelayMs = 600; + DelayingAckHandler handler = new DelayingAckHandler(ackDelayMs); + try (TestWebSocketServer server = new TestWebSocketServer(port, handler)) { + server.start(); + Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); + + String cfg = "ws::addr=localhost:" + port + ";"; + try (Sender sender = Sender.fromConfig(cfg)) { + sender.table("foo").longColumn("v", 1L).atNow(); + long t0 = System.nanoTime(); + boolean drained = sender.drain(5_000); + long drainElapsedMs = (System.nanoTime() - t0) / 1_000_000; + Assert.assertTrue("drain(5000) must return true when the ACK arrives within budget", + drained); + Assert.assertTrue("drain returned too fast (no actual wait): " + drainElapsedMs + "ms", + drainElapsedMs >= ackDelayMs / 2); + + long c0 = System.nanoTime(); + sender.close(); + long closeElapsedMs = (System.nanoTime() - c0) / 1_000_000; + Assert.assertTrue("close() after drained sender should be near-instant, was " + + closeElapsedMs + "ms", + closeElapsedMs < ackDelayMs); + } + } + } + + @Test + public void testDrainReturnsFalseOnTimeoutAndSenderStillUsable() throws Exception { + // Server never ACKs. drain() with a small timeout must return false + // rather than throw (unlike close()'s implicit drain, which + // converts a timeout into a LineSenderException). The sender stays + // usable for further row writes after a false return; the + // outstanding frames remain pending and close()'s own drain still + // runs. + int port = TestPorts.findUnusedPort(); + SilentHandler handler = new SilentHandler(); + try (TestWebSocketServer server = new TestWebSocketServer(port, handler)) { + server.start(); + Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); + + String cfg = "ws::addr=localhost:" + port + ";close_flush_timeout_millis=0;"; + try (Sender sender = Sender.fromConfig(cfg)) { + sender.table("foo").longColumn("v", 1L).atNow(); + long t0 = System.nanoTime(); + boolean drained = sender.drain(200); + long elapsedMs = (System.nanoTime() - t0) / 1_000_000; + Assert.assertFalse("drain must return false when the server never acks", drained); + Assert.assertTrue("drain returned far past the timeout: " + elapsedMs + "ms", + elapsedMs >= 150 && elapsedMs < 2_000); + // Sender must still be usable: write another row and flush + // without observing the latched error from the silent peer. + sender.table("foo").longColumn("v", 2L).atNow(); + sender.flush(); + } + } + } + + @Test + public void testDrainNonZeroTimeoutOnFastServerReturnsImmediately() throws Exception { + // Fast server: every frame is acked promptly. drain(longTimeout) + // must return true quickly -- no spurious wait when there is + // nothing to wait for. + int port = TestPorts.findUnusedPort(); + DelayingAckHandler handler = new DelayingAckHandler(0); + try (TestWebSocketServer server = new TestWebSocketServer(port, handler)) { + server.start(); + Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); + + String cfg = "ws::addr=localhost:" + port + ";"; + try (Sender sender = Sender.fromConfig(cfg)) { + sender.table("foo").longColumn("v", 1L).atNow(); + long t0 = System.nanoTime(); + Assert.assertTrue(sender.drain(5_000)); + long elapsedMs = (System.nanoTime() - t0) / 1_000_000; + Assert.assertTrue("drain on a fast server must return promptly, took " + elapsedMs + "ms", + elapsedMs < 2_000); + } + } + } + @Test public void testAsyncCloseDrainSucceedsWhenServerStartsDuringDrain() throws Exception { int port = TestPorts.findUnusedPort(); diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpQueryClientUnitTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpQueryClientUnitTest.java index f5ee7bdb..4456949a 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpQueryClientUnitTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpQueryClientUnitTest.java @@ -124,6 +124,46 @@ public void testExecuteWithBindsBeforeConnectThrowsIllegalState() { } } + @Test + public void testCancelDuringDispatchWindowLatchesPendingIntent() { + // The dispatch window is the gap between the user thread's submit() + // returning and the worker thread reaching the + // `currentRequestId = requestId` write inside executeOnce(). A cancel() + // during that window currently sees currentRequestId == -1 and returns + // silently; the user's intent is lost. With the latch in place, + // cancel() must record the pending intent so executeOnce() honors it + // as soon as the request id is assigned. + try (QwpQueryClient c = QwpQueryClient.newPlainText("localhost", 9000)) { + Assert.assertFalse("a fresh client must not start with a pending cancel", + c.isPendingCancelForTest()); + c.cancel(); + Assert.assertTrue("cancel() before currentRequestId is assigned must latch the intent", + c.isPendingCancelForTest()); + } + } + + @Test + public void testExecuteEntryClearsStalePendingCancel() { + // A cancel latched on this client (e.g., by a previous user that + // returned the client to a pool without ever submitting) must not + // bleed into the next execute() call. execute() must clear the latch + // at entry, even when the call ultimately fails on the + // "not connected" guard inside executeImpl(). + try (QwpQueryClient c = QwpQueryClient.newPlainText("localhost", 9000)) { + c.cancel(); + Assert.assertTrue("precondition: latch is set", + c.isPendingCancelForTest()); + try { + c.execute("SELECT 1", NOOP_HANDLER); + Assert.fail("execute() before connect() must throw"); + } catch (IllegalStateException expected) { + // expected: client never connected + } + Assert.assertFalse("execute() entry must clear stale pending cancel", + c.isPendingCancelForTest()); + } + } + @Test public void testCancelOnFreshClientIsNoOp() { // cancel() must be safe to call before connect (e.g., a watchdog timer diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpWebSocketSenderTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpWebSocketSenderTest.java index e26d30f4..ecc8eed5 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpWebSocketSenderTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpWebSocketSenderTest.java @@ -36,9 +36,6 @@ import org.junit.Assert; import org.junit.Test; -import java.lang.reflect.Field; -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Method; import java.time.Instant; import java.time.temporal.ChronoUnit; @@ -60,10 +57,10 @@ public void testApplyServerBatchSizeLimit_advertisedClampsLargerConfigured() thr /*autoFlushIntervalNanos*/ 0L)) { // Server advertises 16 MB. Configured 32 MB is over the cap, // so the effective budget should drop to 90% of 16 MB. - invokeApplyServerBatchSizeLimit(sender, 16 * 1024 * 1024); - int effective = getEffectiveAutoFlushBytes(sender); + sender.applyServerBatchSizeLimit(16 * 1024 * 1024); + int effective = sender.getEffectiveAutoFlushBytes(); Assert.assertEquals((long) (16 * 1024 * 1024) * 9 / 10, effective); - Assert.assertEquals(16 * 1024 * 1024, getServerMaxBatchSize(sender)); + Assert.assertEquals(16 * 1024 * 1024, sender.getServerMaxBatchSize()); } }); } @@ -78,9 +75,9 @@ public void testApplyServerBatchSizeLimit_advertisedZeroKeepsConfigured() throws /*autoFlushIntervalNanos*/ 0L)) { // 0 advertisement = older server. Effective budget must equal // the configured value verbatim so the sender keeps working. - invokeApplyServerBatchSizeLimit(sender, 0); - Assert.assertEquals(2 * 1024 * 1024, getEffectiveAutoFlushBytes(sender)); - Assert.assertEquals(0, getServerMaxBatchSize(sender)); + sender.applyServerBatchSizeLimit(0); + Assert.assertEquals(2 * 1024 * 1024, sender.getEffectiveAutoFlushBytes()); + Assert.assertEquals(0, sender.getServerMaxBatchSize()); } }); } @@ -95,8 +92,8 @@ public void testApplyServerBatchSizeLimit_configuredSmallerThanAdvertisedWins() /*autoFlushIntervalNanos*/ 0L)) { // Server advertises 16 MB; configured 2 MB is well below. // Keep the user's tighter budget rather than overriding it. - invokeApplyServerBatchSizeLimit(sender, 16 * 1024 * 1024); - Assert.assertEquals(2 * 1024 * 1024, getEffectiveAutoFlushBytes(sender)); + sender.applyServerBatchSizeLimit(16 * 1024 * 1024); + Assert.assertEquals(2 * 1024 * 1024, sender.getEffectiveAutoFlushBytes()); } }); } @@ -112,9 +109,9 @@ public void testApplyServerBatchSizeLimit_optOutPreservedDespiteAdvertisement() // User explicitly disabled the byte trigger. The server's // advertised cap must update serverMaxBatchSize (for the // single-row guard) but must not re-enable byte flushing. - invokeApplyServerBatchSizeLimit(sender, 16 * 1024 * 1024); - Assert.assertEquals(0, getEffectiveAutoFlushBytes(sender)); - Assert.assertEquals(16 * 1024 * 1024, getServerMaxBatchSize(sender)); + sender.applyServerBatchSizeLimit(16 * 1024 * 1024); + Assert.assertEquals(0, sender.getEffectiveAutoFlushBytes()); + Assert.assertEquals(16 * 1024 * 1024, sender.getServerMaxBatchSize()); } }); } @@ -529,7 +526,7 @@ public void testPendingBytesMatchesGroundTruthAcrossTableSwitches() throws Excep // Bypass ensureConnected: sendRow only short-circuits on // connected==true and we don't drive any path that touches // the cursor engine in this test. - setConnected(sender); + sender.setConnectedForTest(true); // Round-robin three tables to exercise the per-row delta // logic across switches. The running pendingBytes must @@ -541,8 +538,8 @@ public void testPendingBytesMatchesGroundTruthAcrossTableSwitches() throws Excep sender.atNow(); Assert.assertEquals( "row " + i + ": pendingBytes diverged from ground truth", - invokeTotalBufferedBytes(sender), - getPendingBytes(sender)); + sender.totalBufferedBytes(), + sender.getPendingBytes()); } } }); @@ -556,19 +553,19 @@ public void testPendingBytesUnchangedByCancelRow() throws Exception { /*autoFlushRows*/ Integer.MAX_VALUE, /*autoFlushBytes*/ 0, /*autoFlushIntervalNanos*/ 0L)) { - setConnected(sender); + sender.setConnectedForTest(true); sender.table("t0").longColumn("a", 1).longColumn("b", 2).atNow(); - long committed = getPendingBytes(sender); - Assert.assertEquals(invokeTotalBufferedBytes(sender), committed); + long committed = sender.getPendingBytes(); + Assert.assertEquals(sender.totalBufferedBytes(), committed); // Begin a row, then cancel. The committed bytes must not // change and pendingBytes must still match ground truth. sender.table("t0").longColumn("a", 99); sender.cancelRow(); - Assert.assertEquals(committed, getPendingBytes(sender)); - Assert.assertEquals(invokeTotalBufferedBytes(sender), getPendingBytes(sender)); + Assert.assertEquals(committed, sender.getPendingBytes()); + Assert.assertEquals(sender.totalBufferedBytes(), sender.getPendingBytes()); } }); } @@ -708,64 +705,6 @@ private static void assertClosed(Runnable r) { } } - private static int getEffectiveAutoFlushBytes(QwpWebSocketSender sender) throws Exception { - Field field = QwpWebSocketSender.class.getDeclaredField("effectiveAutoFlushBytes"); - field.setAccessible(true); - return field.getInt(sender); - } - - private static long getPendingBytes(QwpWebSocketSender sender) throws Exception { - Field field = QwpWebSocketSender.class.getDeclaredField("pendingBytes"); - field.setAccessible(true); - return field.getLong(sender); - } - - private static int getServerMaxBatchSize(QwpWebSocketSender sender) throws Exception { - Field field = QwpWebSocketSender.class.getDeclaredField("serverMaxBatchSize"); - field.setAccessible(true); - return field.getInt(sender); - } - - private static void invokeApplyServerBatchSizeLimit(QwpWebSocketSender sender, int advertised) throws Exception { - Method m = QwpWebSocketSender.class.getDeclaredMethod("applyServerBatchSizeLimit", int.class); - m.setAccessible(true); - try { - m.invoke(sender, advertised); - } catch (InvocationTargetException e) { - Throwable cause = e.getCause(); - if (cause instanceof Exception) { - throw (Exception) cause; - } - if (cause instanceof Error) { - throw (Error) cause; - } - throw new RuntimeException(cause); - } - } - - private static long invokeTotalBufferedBytes(QwpWebSocketSender sender) throws Exception { - Method m = QwpWebSocketSender.class.getDeclaredMethod("totalBufferedBytes"); - m.setAccessible(true); - try { - return (long) m.invoke(sender); - } catch (InvocationTargetException e) { - Throwable cause = e.getCause(); - if (cause instanceof Exception) { - throw (Exception) cause; - } - if (cause instanceof Error) { - throw (Error) cause; - } - throw new RuntimeException(cause); - } - } - - private static void setConnected(QwpWebSocketSender sender) throws Exception { - Field field = QwpWebSocketSender.class.getDeclaredField("connected"); - field.setAccessible(true); - field.setBoolean(sender, true); - } - /** * Creates a sender without connecting. * For unit tests that don't need actual connectivity. diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/ReconnectTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/ReconnectTest.java index cb2f34df..1e9d99d1 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/ReconnectTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/ReconnectTest.java @@ -101,15 +101,14 @@ public void testReconnectAfterServerInducedDisconnect() throws Exception { } @Test - public void testReconnectGivesUpAfterCap() throws Exception { + public void testReconnectGivesUpAfterCap() { // Server is up at first (initial connect succeeds + ACKs batch 1), // then we tear it down — subsequent reconnect attempts get TCP // connection-refused and accumulate against the budget. With a // 500ms cap, the loop should give up well inside the test's 5s // poll window and the next user-thread flush() must throw. int port = TestPorts.findUnusedPort(); - TestWebSocketServer server = new TestWebSocketServer(port, new AckHandler()); - try { + try (TestWebSocketServer server = new TestWebSocketServer(port, new AckHandler())) { server.start(); Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); @@ -118,8 +117,7 @@ public void testReconnectGivesUpAfterCap() throws Exception { + ";reconnect_initial_backoff_millis=10" + ";reconnect_max_backoff_millis=50" + ";close_flush_timeout_millis=0;"; - Sender sender = Sender.fromConfig(cfg); - try { + try (Sender sender = Sender.fromConfig(cfg)) { sender.table("foo").longColumn("v", 1L).atNow(); sender.flush(); @@ -131,7 +129,7 @@ public void testReconnectGivesUpAfterCap() throws Exception { Throwable observed = null; long deadline = System.currentTimeMillis() + 5_000; long iter = 0; - while (System.currentTimeMillis() < deadline && observed == null) { + while (System.currentTimeMillis() < deadline) { iter++; try { sender.table("foo").longColumn("v", iter).atNow(); @@ -151,20 +149,12 @@ public void testReconnectGivesUpAfterCap() throws Exception { msg.contains("reconnect failed") || msg.contains("I/O thread failed") || msg.contains("Failed to connect")); - } finally { - // close() rethrows the latched terminal reconnect-cap error - // (commit 052f6ee). Already observed and asserted above. - try { - sender.close(); - } catch (LineSenderException ignored) { - } - } - } finally { - try { - server.close(); - } catch (Exception ignored) { - // already closed + } catch (LineSenderException ignored) { } + // close() rethrows the latched terminal reconnect-cap error + // (commit 052f6ee). Already observed and asserted above. + } catch (Exception ignored) { + // already closed } } @@ -184,8 +174,7 @@ public void testTerminalUpgradeErrorAbortsReconnect() throws Exception { String cfg = "ws::addr=localhost:" + port + ";reconnect_max_duration_millis=10000" + ";close_flush_timeout_millis=0;"; - Sender sender = Sender.fromConfig(cfg); - try { + try (Sender sender = Sender.fromConfig(cfg)) { sender.table("foo").longColumn("v", 1L).atNow(); sender.flush(); // Wait for first connection to ACK + close @@ -194,7 +183,7 @@ public void testTerminalUpgradeErrorAbortsReconnect() throws Exception { long t0 = System.nanoTime(); Throwable observed = null; long deadline = System.currentTimeMillis() + 5_000; - while (System.currentTimeMillis() < deadline && observed == null) { + while (System.currentTimeMillis() < deadline) { try { sender.table("foo").longColumn("v", 2L).atNow(); sender.flush(); @@ -217,14 +206,10 @@ public void testTerminalUpgradeErrorAbortsReconnect() throws Exception { msg.contains("WebSocket upgrade failed") || msg.contains("I/O thread failed") || msg.contains("401")); - } finally { - // close() rethrows the latched terminal upgrade error - // (commit 052f6ee). Already observed and asserted above. - try { - sender.close(); - } catch (LineSenderException ignored) { - } + } catch (LineSenderException ignored) { } + // close() rethrows the latched terminal upgrade error + // (commit 052f6ee). Already observed and asserted above. } } @@ -427,7 +412,7 @@ private void handleClient(Socket s, boolean firstConnection) { BufferedReader in = new BufferedReader(new InputStreamReader( s.getInputStream(), StandardCharsets.US_ASCII)); OutputStream out = s.getOutputStream(); - String requestLine = in.readLine(); + in.readLine(); String secKey = null; String line; while ((line = in.readLine()) != null && !line.isEmpty()) { @@ -535,19 +520,6 @@ public void close() { } } - /** Closes every connection right after receiving the first frame. */ - private static class AlwaysDisconnectHandler implements TestWebSocketServer.WebSocketServerHandler { - @Override - public void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] data) { - try { - Thread.sleep(10); - client.close(); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - } - } - /** Acks every binary frame so the sender doesn't hang. */ private static class AckHandler implements TestWebSocketServer.WebSocketServerHandler { private final AtomicLong nextSeq = new AtomicLong(0); diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/TestPorts.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/TestPorts.java index 0f384817..43b3e8e0 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/TestPorts.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/TestPorts.java @@ -25,6 +25,7 @@ package io.questdb.client.test.cutlass.qwp.client; import java.io.IOException; +import java.net.InetAddress; import java.net.ServerSocket; public final class TestPorts { @@ -33,33 +34,10 @@ private TestPorts() { } public static int findUnusedPort() { - try (ServerSocket s = new ServerSocket(0)) { + try (ServerSocket s = new ServerSocket(0, 50, InetAddress.getLoopbackAddress())) { return s.getLocalPort(); } catch (IOException e) { throw new RuntimeException("failed to allocate an ephemeral port", e); } } - - public static int[] findUnusedPorts(int n) { - ServerSocket[] sockets = new ServerSocket[n]; - int[] ports = new int[n]; - try { - for (int i = 0; i < n; i++) { - sockets[i] = new ServerSocket(0); - ports[i] = sockets[i].getLocalPort(); - } - } catch (IOException e) { - throw new RuntimeException("failed to allocate ephemeral ports", e); - } finally { - for (ServerSocket s : sockets) { - if (s != null) { - try { - s.close(); - } catch (IOException ignore) { - } - } - } - } - return ports; - } } diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentRingTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentRingTest.java index 0043e521..f1a0fcde 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentRingTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentRingTest.java @@ -565,10 +565,10 @@ public void testLargeSegmentCountReopensInOrder() throws Exception { } } - long startMs = System.currentTimeMillis(); + SegmentRing.resetSortComparisons(); try (SegmentRing ring = SegmentRing.openExisting(tmpDir, MmapSegment.HEADER_SIZE + MmapSegment.FRAME_HEADER_SIZE + 16)) { - long elapsed = System.currentTimeMillis() - startMs; + long comparisons = SegmentRing.getSortComparisons(); assertNotNull("recovery must produce a ring", ring); // After recovery, the ring's nextSeqHint is one past the // last frame on disk. With one frame per segment numbered @@ -577,14 +577,22 @@ public void testLargeSegmentCountReopensInOrder() throws Exception { n, ring.nextSeqHint()); // publishedFsn = n - 1 (last frame visible). assertEquals(n - 1, ring.publishedFsn()); - // 5s is comfortably above the quicksort path (sub-second on - // any modern machine) and well below the seconds-of-CPU the - // production-ceiling O(N²) regression would produce. Tight - // enough to fire if the algorithm regresses, loose enough - // to survive a slow CI runner. - assertTrue("recovery took " + elapsed + " ms (expected < 5000); " - + "regression suggests the segment sort is back to O(N²)", - elapsed < 5_000); + // O(N log N) quicksort with good pivots does ~2-3 * N * log2(N) + // comparisons; the partition-pass + median-of-three counter we + // increment per recursive frame upper-bounds this at roughly + // 3 N log2(N). The naive O(N²) regression on already-sorted + // input would do ~N(N-1)/2 -- 2.1M at N=2048 vs the ~67k a + // healthy sort produces. The 5x N log2(N) bound below sits + // about 30x below the O(N²) value and 1.5x above the + // expected count, so it fires on a real regression without + // flapping on harmless implementation drift. Comparison + // counts are deterministic across CI hardware, unlike the + // wall-clock bound this assertion used to carry. + long bound = 5L * n * (long) (Math.log(n) / Math.log(2)); + assertTrue("sort took " + comparisons + " comparisons (expected < " + bound + + " = 5 * N * log2(N) for N=" + n + "); " + + "regression suggests the segment sort is back to O(N^2)", + comparisons < bound); } } finally { Unsafe.free(buf, 16, MemoryTag.NATIVE_DEFAULT); diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/websocket/TestWebSocketServer.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/websocket/TestWebSocketServer.java index a50300dc..3da4f829 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/websocket/TestWebSocketServer.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/websocket/TestWebSocketServer.java @@ -61,10 +61,6 @@ public class TestWebSocketServer implements Closeable { private final AtomicBoolean running = new AtomicBoolean(false); private final CountDownLatch startLatch = new CountDownLatch(1); private Thread acceptThread; - // X-QWP-Max-Batch-Size value to emit on the 101 handshake response. - // 0 = omit the header (legacy behavior). Tests that exercise the - // batch-size-clamp path on the client set this to a positive value. - private volatile int advertisedMaxBatchSize; // X-QuestDB-Role value to emit on handshake responses. null = omit the // header (legacy behavior for tests written before role-aware failover). // The server emits the header on both the 101 success path and (when @@ -148,14 +144,6 @@ public void close() { } } - /** - * Sets the X-QWP-Max-Batch-Size header value emitted on subsequent - * handshake responses. 0 (the default) omits the header. - */ - public void setAdvertisedMaxBatchSize(int maxBatchSize) { - this.advertisedMaxBatchSize = maxBatchSize; - } - /** * Replaces the advertised role for subsequent handshakes (live update). */ @@ -190,7 +178,13 @@ public void start() throws IOException { return; } - serverSocket = new ServerSocket(port); + // Bind explicitly to the loopback address. The wildcard 0.0.0.0 + // default lets a leftover process holding 127.0.0.1:port coexist + // on the same port under BSD/macOS SO_REUSEADDR semantics, and + // client connections to "localhost" then route to the more-specific + // listener instead of this mock. Pinning to loopback forces the + // kernel to detect the conflict and pick a different ephemeral port. + serverSocket = new ServerSocket(port, 50, java.net.InetAddress.getLoopbackAddress()); serverSocket.setSoTimeout(100); acceptThread = new Thread(() -> { @@ -269,10 +263,6 @@ public synchronized void sendClose(int code, String reason) throws IOException { writeFrame(WebSocketOpcode.CLOSE, payload, payload.length); } - public synchronized void sendPing(byte[] data) throws IOException { - writeFrame(WebSocketOpcode.PING, data, data.length); - } - private String computeAcceptKey(String key) { try { MessageDigest sha1 = MessageDigest.getInstance("SHA-1"); @@ -439,10 +429,6 @@ private boolean performHandshake() throws IOException { if (role != null) { sb.append("X-QuestDB-Role: ").append(role).append("\r\n"); } - int maxBatch = advertisedMaxBatchSize; - if (maxBatch > 0) { - sb.append("X-QWP-Max-Batch-Size: ").append(maxBatch).append("\r\n"); - } sb.append("\r\n"); out.write(sb.toString().getBytes(StandardCharsets.US_ASCII)); out.flush(); diff --git a/core/src/test/java/io/questdb/client/test/example/QuestDBExamples.java b/core/src/test/java/io/questdb/client/test/example/QuestDBExamples.java new file mode 100644 index 00000000..3c4b6374 --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/example/QuestDBExamples.java @@ -0,0 +1,199 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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.example; + +import io.questdb.client.Completion; +import io.questdb.client.QuestDB; +import io.questdb.client.Query; +import io.questdb.client.Sender; +import io.questdb.client.cutlass.qwp.client.QwpColumnBatch; +import io.questdb.client.cutlass.qwp.client.QwpColumnBatchHandler; + +import java.util.concurrent.TimeUnit; + +/** + * Examples for the {@link QuestDB} facade -- the high-level handle that + * pools both Senders (ingest) and query clients (egress). + *

+ * Create one {@code QuestDB} per deployment, share it across threads, and + * close it at shutdown. Borrows and releases are zero-allocation at steady + * state; the per-thread {@link Query} handle is cached in a ThreadLocal. + */ +public class QuestDBExamples { + + public static void main(String[] args) throws Exception { + // 1. Connect with a single configuration string. The same server list + // serves both ingest (HTTP) and egress (WebSocket on the same port); + // QuestDB derives the egress URL automatically. + try (QuestDB db = QuestDB.connect("http::addr=localhost:9000;")) { + ingestWithBorrowedSender(db); + ingestWithThreadAffineSender(db); + queryOneShot(db); + queryWithBinds(db); + cancelExample(db); + } + + // 2. Authenticated connect: token auth is translated to a Bearer + // Authorization header on the egress side. + try (QuestDB db = QuestDB.connect( + "http::addr=db.questdb.cloud:9000;token=YOUR_TOKEN_HERE;")) { + // ... use db ... + db.executeSql("SELECT 1", new PrintingHandler()).await(); + } + + // 3. Custom pool sizing and timeouts via the builder. Use this when + // ingest and egress configs differ (different transports, separate + // address lists), or when you need to override defaults. + try (QuestDB db = QuestDB.builder() + .ingestConfig("http::addr=ingest.cluster:9000;") + .queryConfig("ws::addr=read-replica.cluster:9000;") + .senderPoolSize(8) + .queryPoolSize(4) + .acquireTimeoutMillis(10_000) + .build()) { + // ... use db ... + db.executeSql("SELECT 1", new PrintingHandler()).await(); + } + } + + /** + * Cancel mid-query: the handler observes {@code onError} with the cancel + * status, and {@code await()} throws {@code QueryException} with that + * status. If the query completed before cancel landed, {@code await()} + * returns normally; either way the Completion reaches a terminal state. + */ + static void cancelExample(QuestDB db) { + Completion c = db.executeSql( + "SELECT * FROM big_table ORDER BY ts", + new PrintingHandler()); + // ... some condition decides to abort ... + c.cancel(); + try { + c.await(); + } catch (Exception cancelled) { + // expected when cancel won the race + } + } + + /** + * Borrowed Sender: leases one from the pool, flushes pending rows on + * close(), returns to the pool. Use this for short-lived or event-loop + * callers where pinning a Sender to a thread is not appropriate. + */ + static void ingestWithBorrowedSender(QuestDB db) { + try (Sender s = db.borrowSender()) { + s.table("trades") + .symbol("symbol", "BTC-USD") + .doubleColumn("price", 42_500.50) + .longColumn("size", 100) + .atNow(); + // close() flushes -- no need to call flush() yourself. + } + } + + /** + * Thread-affine Sender: the first call on a thread leases one and pins it; + * subsequent calls on the same thread return the same instance with zero + * borrow overhead. Best for long-lived dedicated producer threads. + *

+ * Call {@link QuestDB#releaseSender()} on threads borrowed from pools you + * don't own (Netty event loops, etc.) before they're recycled. + */ + static void ingestWithThreadAffineSender(QuestDB db) { + Sender s = db.sender(); + for (int i = 0; i < 1_000; i++) { + s.table("trades") + .symbol("symbol", "BTC-USD") + .doubleColumn("price", 42_500.50 + i) + .longColumn("size", 100) + .atNow(); + } + s.flush(); + // Not strictly required: db.close() reaps pinned Senders. Call it + // only when handing this thread back to a foreign pool. + // db.releaseSender(); + } + + /** + * One-shot query, no bind parameters. {@link QuestDB#executeSql} returns + * a {@link Completion} that you can {@code await()} synchronously, time + * out on, or cancel. + */ + static void queryOneShot(QuestDB db) throws InterruptedException { + Completion c = db.executeSql( + "SELECT price FROM trades WHERE symbol = 'BTC-USD' LIMIT 10", + new PrintingHandler()); + c.await(); + } + + /** + * Query with bind parameters. Use {@link QuestDB#query()} to get the + * per-thread Query builder, then set SQL, binds (via QwpBindSetter), and + * handler. + *

+ * The same SQL text reuses the server's compiled-factory cache -- bind + * values supply the per-call inputs. Interpolating values into the SQL + * string defeats that cache. + */ + static void queryWithBinds(QuestDB db) throws InterruptedException { + Query q = db.query() + .sql("SELECT price FROM trades WHERE symbol = $1 LIMIT $2") + .binds(binds -> { + binds.setVarchar(0, "BTC-USD"); + binds.setLong(1, 10L); + }) + .handler(new PrintingHandler()); + Completion c = q.submit(); + // Optional timeout: returns false if the query is still in flight. + if (!c.await(5, TimeUnit.SECONDS)) { + c.cancel(); + c.await(); + } + } + + /** + * Minimal handler: prints each row's first column. Real applications + * implement a stateful handler that aggregates batches; the same + * instance can be reused across submits for zero allocation. + */ + private static final class PrintingHandler implements QwpColumnBatchHandler { + @Override + public void onBatch(QwpColumnBatch batch) { + for (int r = 0; r < batch.getRowCount(); r++) { + System.out.println(batch.getDoubleValue(0, r)); + } + } + + @Override + public void onEnd(long totalRows) { + System.out.println("done: " + totalRows + " rows"); + } + + @Override + public void onError(byte status, String message) { + System.err.println("egress error: status=" + status + ", message=" + message); + } + } +} diff --git a/core/src/test/java/io/questdb/client/test/impl/ConfigStringTranslatorTest.java b/core/src/test/java/io/questdb/client/test/impl/ConfigStringTranslatorTest.java new file mode 100644 index 00000000..7fdb6e4b --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/impl/ConfigStringTranslatorTest.java @@ -0,0 +1,159 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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.impl; + +import io.questdb.client.impl.ConfigStringTranslator; +import org.junit.Assert; +import org.junit.Test; + +public class ConfigStringTranslatorTest { + + @Test + public void testEmptyConfigIsRejected() { + try { + ConfigStringTranslator.deriveBothSides(""); + Assert.fail(); + } catch (IllegalArgumentException e) { + Assert.assertTrue(e.getMessage().contains("empty")); + } + } + + @Test + public void testHttpInputPassesThroughAndDerivesWs() { + ConfigStringTranslator.Bundle bundle = ConfigStringTranslator.deriveBothSides( + "http::addr=db.host:9000;token=secret;"); + Assert.assertEquals("http::addr=db.host:9000;token=secret;", bundle.ingestConfig); + Assert.assertEquals("ws::addr=db.host:9000;auth=Bearer secret;", bundle.queryConfig); + // No pool keys -> all defaults preserved. + Assert.assertEquals(ConfigStringTranslator.PoolConfig.UNSET, bundle.poolConfig.senderPoolMin); + Assert.assertEquals(ConfigStringTranslator.PoolConfig.UNSET, bundle.poolConfig.acquireTimeoutMillis); + } + + @Test + public void testHttpsInputDerivesWss() { + ConfigStringTranslator.Bundle bundle = ConfigStringTranslator.deriveBothSides( + "https::addr=db.host:9000;tls_verify=on;"); + Assert.assertEquals("https::addr=db.host:9000;tls_verify=on;", bundle.ingestConfig); + Assert.assertEquals("wss::addr=db.host:9000;tls_verify=on;", bundle.queryConfig); + } + + @Test + public void testInvalidPoolValueIsRejected() { + try { + ConfigStringTranslator.deriveBothSides("http::addr=h:9000;sender_pool_max=notanumber;"); + Assert.fail(); + } catch (IllegalArgumentException e) { + Assert.assertTrue(e.getMessage().contains("sender_pool_max")); + } + } + + @Test + public void testMissingAddrIsRejected() { + try { + ConfigStringTranslator.deriveBothSides("http::token=x;"); + Assert.fail(); + } catch (IllegalArgumentException e) { + Assert.assertTrue(e.getMessage().contains("addr")); + } + } + + @Test + public void testPoolKeysAreExtractedAndStripped() { + ConfigStringTranslator.Bundle bundle = ConfigStringTranslator.deriveBothSides( + "http::addr=db.host:9000;sender_pool_min=2;sender_pool_max=16;" + + "query_pool_min=1;query_pool_max=4;acquire_timeout_ms=10000;" + + "idle_timeout_ms=30000;max_lifetime_ms=600000;housekeeper_interval_ms=2000;"); + + // Pool keys must be stripped from both config strings so the downstream + // Sender / QwpQueryClient parsers never see them. + Assert.assertFalse(bundle.ingestConfig.contains("sender_pool")); + Assert.assertFalse(bundle.ingestConfig.contains("query_pool")); + Assert.assertFalse(bundle.ingestConfig.contains("timeout_ms")); + Assert.assertFalse(bundle.queryConfig.contains("sender_pool")); + Assert.assertFalse(bundle.queryConfig.contains("query_pool")); + Assert.assertFalse(bundle.queryConfig.contains("timeout_ms")); + + // addr must survive on both sides. + Assert.assertTrue(bundle.ingestConfig.contains("addr=db.host:9000")); + Assert.assertTrue(bundle.queryConfig.contains("addr=db.host:9000")); + + // Pool values must surface on the PoolConfig. + Assert.assertEquals(2, bundle.poolConfig.senderPoolMin); + Assert.assertEquals(16, bundle.poolConfig.senderPoolMax); + Assert.assertEquals(1, bundle.poolConfig.queryPoolMin); + Assert.assertEquals(4, bundle.poolConfig.queryPoolMax); + Assert.assertEquals(10_000L, bundle.poolConfig.acquireTimeoutMillis); + Assert.assertEquals(30_000L, bundle.poolConfig.idleTimeoutMillis); + Assert.assertEquals(600_000L, bundle.poolConfig.maxLifetimeMillis); + Assert.assertEquals(2_000L, bundle.poolConfig.housekeeperIntervalMillis); + } + + @Test + public void testPoolKeysInterleavedWithRegularKeys() { + // Pool keys at arbitrary positions must still be stripped and the + // surviving keys must remain in the original order. + ConfigStringTranslator.Bundle bundle = ConfigStringTranslator.deriveBothSides( + "http::sender_pool_max=8;addr=h:9000;query_pool_max=2;token=t;idle_timeout_ms=5000;"); + Assert.assertTrue(bundle.ingestConfig.contains("addr=h:9000")); + Assert.assertTrue(bundle.ingestConfig.contains("token=t")); + Assert.assertFalse(bundle.ingestConfig.contains("pool")); + Assert.assertFalse(bundle.ingestConfig.contains("idle")); + Assert.assertEquals(8, bundle.poolConfig.senderPoolMax); + Assert.assertEquals(2, bundle.poolConfig.queryPoolMax); + Assert.assertEquals(5_000L, bundle.poolConfig.idleTimeoutMillis); + } + + @Test + public void testTcpSchemaIsRejected() { + try { + ConfigStringTranslator.deriveBothSides("tcp::addr=h:9009;"); + Assert.fail(); + } catch (IllegalArgumentException e) { + Assert.assertTrue(e.getMessage().contains("supports schemas")); + } + } + + @Test + public void testUsernamePasswordRejectedForWsDerivation() { + try { + ConfigStringTranslator.deriveBothSides( + "http::addr=h:9000;username=u;password=p;"); + Assert.fail(); + } catch (IllegalArgumentException e) { + Assert.assertTrue(e.getMessage().contains("username/password")); + } + } + + @Test + public void testWsInputPassesThroughAndDerivesHttp() { + ConfigStringTranslator.Bundle bundle = ConfigStringTranslator.deriveBothSides( + "ws::addr=db.host:9000;auth=Bearer foo;"); + Assert.assertEquals("ws::addr=db.host:9000;auth=Bearer foo;", bundle.queryConfig); + Assert.assertTrue( + "expected ingest config to start with http::; got: " + bundle.ingestConfig, + bundle.ingestConfig.startsWith("http::")); + Assert.assertTrue(bundle.ingestConfig.contains("addr=db.host:9000")); + } +} diff --git a/core/src/test/java/io/questdb/client/test/impl/QueryClientPoolLeakTest.java b/core/src/test/java/io/questdb/client/test/impl/QueryClientPoolLeakTest.java new file mode 100644 index 00000000..53def4b5 --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/impl/QueryClientPoolLeakTest.java @@ -0,0 +1,179 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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.impl; + +import io.questdb.client.impl.QueryClientPool; +import io.questdb.client.std.MemoryTag; +import io.questdb.client.std.Unsafe; +import org.junit.Assert; +import org.junit.Test; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.atomic.AtomicInteger; + +public class QueryClientPoolLeakTest { + + // QwpBindValues holds a NativeBufferWriter whose default buffer is + // 8192 bytes tagged NATIVE_DEFAULT. It is allocated at QwpQueryClient + // field-init time, which means QwpQueryClient.fromConfig() commits this + // allocation before connect() is ever called. + // + // Both of these tests construct a scenario where connect() reliably throws: + // a FakeStatusServer returns HTTP 421 with X-QuestDB-Role: REPLICA, and the + // client requests target=primary. The walk rejects the only endpoint and + // connect() throws HttpClientException. + // + // If QueryClientPool.createUnlocked() does not close the half-built client + // when connect() throws, the NATIVE_DEFAULT counter ends higher than it + // started. The assertion compares before/after directly: no leak means + // delta == 0. + + @Test(timeout = 10_000) + public void acquireDoesNotLeakNativeScratchOnConnectFailure() throws Exception { + try (FakeStatusServer rejecter = new FakeStatusServer(421, "X-QuestDB-Role: REPLICA")) { + rejecter.start(); + String cfg = "ws::addr=127.0.0.1:" + rejecter.port() + + ";target=primary;failover=off;auth_timeout_ms=1000;"; + + QueryClientPool pool = new QueryClientPool( + cfg, 0, 1, 1_000, Long.MAX_VALUE, Long.MAX_VALUE); + try { + long baseline = Unsafe.getMemUsedByTag(MemoryTag.NATIVE_DEFAULT); + try { + pool.acquire(); + Assert.fail("expected acquire() to throw on connect rejection"); + } catch (RuntimeException expected) { + // QueryException wrapping the underlying connect failure. + } + long after = Unsafe.getMemUsedByTag(MemoryTag.NATIVE_DEFAULT); + Assert.assertEquals( + "acquire() leaked NATIVE_DEFAULT bytes on connect failure", + baseline, after); + } finally { + pool.close(); + } + } + } + + @Test(timeout = 10_000) + public void preWarmDoesNotLeakNativeScratchOnConnectFailure() throws Exception { + try (FakeStatusServer rejecter = new FakeStatusServer(421, "X-QuestDB-Role: REPLICA")) { + rejecter.start(); + String cfg = "ws::addr=127.0.0.1:" + rejecter.port() + + ";target=primary;failover=off;auth_timeout_ms=1000;"; + + long baseline = Unsafe.getMemUsedByTag(MemoryTag.NATIVE_DEFAULT); + try { + new QueryClientPool(cfg, 1, 1, 1_000, Long.MAX_VALUE, Long.MAX_VALUE); + Assert.fail("expected QueryClientPool ctor to throw on connect rejection"); + } catch (RuntimeException expected) { + // target=primary against role=REPLICA yields a connect failure + // out of createUnlocked(). + } + long after = Unsafe.getMemUsedByTag(MemoryTag.NATIVE_DEFAULT); + Assert.assertEquals( + "pool ctor leaked NATIVE_DEFAULT bytes on connect failure", + baseline, after); + } + } + + private static final class FakeStatusServer implements AutoCloseable { + final AtomicInteger connections = new AtomicInteger(); + private final String roleHeader; + private final ServerSocket socket; + private final int statusCode; + private volatile boolean running = true; + + FakeStatusServer(int statusCode, String roleHeader) throws IOException { + this.statusCode = statusCode; + this.roleHeader = roleHeader; + this.socket = new ServerSocket(0, 50, InetAddress.getLoopbackAddress()); + } + + @Override + public void close() throws IOException { + running = false; + socket.close(); + } + + int port() { + return socket.getLocalPort(); + } + + void start() { + Thread t = new Thread(this::loop, "fake-status-" + statusCode); + t.setDaemon(true); + t.start(); + } + + private void handle(Socket s) { + try (Socket sock = s) { + connections.incrementAndGet(); + byte[] discard = new byte[8192]; + int n = sock.getInputStream().read(discard); + if (n < 0) return; + StringBuilder resp = new StringBuilder(); + resp.append("HTTP/1.1 ").append(statusCode).append(' ').append(reason(statusCode)).append("\r\n"); + if (roleHeader != null) { + resp.append(roleHeader).append("\r\n"); + } + resp.append("Content-Length: 0\r\nConnection: close\r\n\r\n"); + OutputStream out = sock.getOutputStream(); + out.write(resp.toString().getBytes(StandardCharsets.US_ASCII)); + out.flush(); + } catch (Exception ignored) { + } + } + + private void loop() { + while (running) { + try { + Socket s = socket.accept(); + Thread h = new Thread(() -> handle(s), "fake-status-handler-" + statusCode); + h.setDaemon(true); + h.start(); + } catch (IOException e) { + if (!running) return; + } + } + } + + private static String reason(int code) { + switch (code) { + case 401: + return "Unauthorized"; + case 421: + return "Misdirected Request"; + default: + return "Status"; + } + } + } +} diff --git a/core/src/test/java/io/questdb/client/test/impl/QueryImplResetTest.java b/core/src/test/java/io/questdb/client/test/impl/QueryImplResetTest.java new file mode 100644 index 00000000..1ff33b76 --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/impl/QueryImplResetTest.java @@ -0,0 +1,180 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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.impl; + +import io.questdb.client.Query; +import io.questdb.client.cutlass.qwp.client.QwpBindSetter; +import io.questdb.client.cutlass.qwp.client.QwpColumnBatch; +import io.questdb.client.cutlass.qwp.client.QwpColumnBatchHandler; +import io.questdb.client.cutlass.qwp.client.QwpServerInfo; +import org.junit.Assert; +import org.junit.Test; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.Method; + +public class QueryImplResetTest { + + /** + * Regression test for the state-carryover bug between consecutive + * submits on the per-thread {@code QuestDB#query()} handle. + *

+ * The Javadoc on both {@code Query} and {@code QuestDB#query()} promises + * that the returned instance is "reset to empty" / "in a reset state". + * Before the fix, {@code QuestDBImpl.query()} returned the bare + * thread-local without nulling {@code userHandler} / {@code userBinds}, + * so the second call below would silently reuse {@code h1}: + *

+     *   db.query().sql("SELECT 1").handler(h1).submit().await();
+     *   db.query().sql("SELECT 2").submit();    // no .handler() -- reuses h1
+     * 
+ * The {@code if (userHandler == null)} check in {@code submit()} could + * not catch the misuse because the field was still set from the prior + * submit. + *

+ * The fix is {@code QueryImpl.resetIfDone()}, invoked from + * {@code QuestDBImpl.query()} before the per-thread handle is returned. + * This test reaches into {@code QueryImpl} via reflection (the class is + * package-private and lives in a different package from this test) and + * asserts the reset clears all three configured fields when the prior + * run is in a terminal state. + */ + @Test + public void testResetIfDoneClearsBuilderStateInTerminalState() throws Exception { + Class queryImplClass = Class.forName("io.questdb.client.impl.QueryImpl"); + Class poolClass = Class.forName("io.questdb.client.impl.QueryClientPool"); + + Constructor ctor = queryImplClass.getDeclaredConstructor(poolClass); + ctor.setAccessible(true); + // QueryImpl never dereferences the pool outside of submit(); a null + // pool is fine for this state-only test. + Query q = (Query) ctor.newInstance(new Object[]{null}); + + // Mirror the post-submit().await() state: builder fields set, + // done flag true (the constructor default). + QwpColumnBatchHandler h = new NoopHandler(); + QwpBindSetter b = values -> { + // no-op + }; + q.sql("SELECT 1").binds(b).handler(h); + + Method reset = queryImplClass.getDeclaredMethod("resetIfDone"); + reset.setAccessible(true); + reset.invoke(q); + + Field handlerF = queryImplClass.getDeclaredField("userHandler"); + Field bindsF = queryImplClass.getDeclaredField("userBinds"); + Field sqlBufF = queryImplClass.getDeclaredField("sqlBuffer"); + handlerF.setAccessible(true); + bindsF.setAccessible(true); + sqlBufF.setAccessible(true); + + Assert.assertNull("userHandler must be cleared so a follow-up submit() without .handler() fails fast", + handlerF.get(q)); + Assert.assertNull("userBinds must be cleared so a follow-up submit() without .binds() does not reuse the prior setter", + bindsF.get(q)); + CharSequence sqlBuffer = (CharSequence) sqlBufF.get(q); + Assert.assertEquals("sqlBuffer must be empty so a follow-up submit() without .sql() throws 'sql is required'", + 0, sqlBuffer.length()); + } + + /** + * Symmetric guard: when a submit is in flight ({@code done == false}), + * {@code resetIfDone()} must NOT touch the configured fields. The + * dispatched worker thread is reading {@code sqlBuffer} in + * {@code runOn()} and {@code userHandler} via the wrapping handler; + * clearing them mid-flight would race. + */ + @Test + public void testResetIfDoneIsNoOpWhileSubmitInFlight() throws Exception { + Class queryImplClass = Class.forName("io.questdb.client.impl.QueryImpl"); + Class poolClass = Class.forName("io.questdb.client.impl.QueryClientPool"); + + Constructor ctor = queryImplClass.getDeclaredConstructor(poolClass); + ctor.setAccessible(true); + Query q = (Query) ctor.newInstance(new Object[]{null}); + + QwpColumnBatchHandler h = new NoopHandler(); + QwpBindSetter b = values -> { + // no-op + }; + q.sql("SELECT 1").binds(b).handler(h); + + // Flip the in-flight flag by setting done=false directly. + Field doneF = queryImplClass.getDeclaredField("done"); + doneF.setAccessible(true); + doneF.setBoolean(q, false); + + Method reset = queryImplClass.getDeclaredMethod("resetIfDone"); + reset.setAccessible(true); + reset.invoke(q); + + Field handlerF = queryImplClass.getDeclaredField("userHandler"); + Field bindsF = queryImplClass.getDeclaredField("userBinds"); + Field sqlBufF = queryImplClass.getDeclaredField("sqlBuffer"); + handlerF.setAccessible(true); + bindsF.setAccessible(true); + sqlBufF.setAccessible(true); + + Assert.assertSame("userHandler must survive resetIfDone() while a submit is in flight", + h, handlerF.get(q)); + Assert.assertSame("userBinds must survive resetIfDone() while a submit is in flight", + b, bindsF.get(q)); + CharSequence sqlBuffer = (CharSequence) sqlBufF.get(q); + Assert.assertEquals("sqlBuffer must survive resetIfDone() while a submit is in flight", + "SELECT 1", sqlBuffer.toString()); + } + + private static final class NoopHandler implements QwpColumnBatchHandler { + @Override + public void onBatch(QwpColumnBatch batch) { + } + + @Override + public void onEnd(long totalRows) { + } + + @Override + public void onEnd(long requestId, long totalRows) { + } + + @Override + public void onError(byte status, String message) { + } + + @Override + public void onError(long requestId, byte status, String message) { + } + + @Override + public void onExecDone(long requestId, short opType, long rowsAffected) { + } + + @Override + public void onFailoverReset(long requestId, QwpServerInfo newNode) { + } + } +} diff --git a/core/src/test/java/io/questdb/client/test/impl/QueryWorkerTest.java b/core/src/test/java/io/questdb/client/test/impl/QueryWorkerTest.java new file mode 100644 index 00000000..e9041448 --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/impl/QueryWorkerTest.java @@ -0,0 +1,164 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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.impl; + +import io.questdb.client.Completion; +import io.questdb.client.cutlass.qwp.client.QwpQueryClient; +import io.questdb.client.impl.QueryWorker; +import org.junit.Assert; +import org.junit.Test; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.Condition; +import java.util.concurrent.locks.ReentrantLock; + +public class QueryWorkerTest { + + /** + * Exercises {@link QueryWorker#client()} -- a pure getter exposed for + * introspection. The worker is constructed but never started, so no + * connect is needed; {@code newPlainText} only allocates the client. + */ + @Test + public void testClientGetterReturnsConstructorInstance() { + try (QwpQueryClient client = QwpQueryClient.newPlainText("localhost", 9000)) { + QueryWorker worker = new QueryWorker(client, null, 0); + Assert.assertSame("client() must return the instance passed to the constructor", + client, worker.client()); + // Idempotent across calls -- the field is final. + Assert.assertSame(worker.client(), worker.client()); + } + } + + /** + * Regression test for the shutdown-vs-dispatch race in + * {@code QueryWorker.runLoop()}. If {@code shuttingDown} flips to true + * after {@code dispatch()} has set {@code current = q} but before the + * worker thread observes the wakeup, the run loop returns at the + * {@code if (shuttingDown) return;} branch without ever invoking + * {@code runOn(client)} or {@code signalUnexpected(...)}. The caller's + * {@link Completion#await()} would then block forever because + * {@code signalDone} is never called. + *

+ * Rather than try to win a timing race, this test reproduces the buggy + * state directly: it parks the worker on its condition, then takes the + * worker's own {@code signalLock} and atomically sets both + * {@code current} and {@code shuttingDown} before signalling. After the + * worker thread exits, the test asserts the {@link Completion} has been + * signalled. Today the assertion fails because the run loop's early + * return strands the {@code QueryImpl}. + */ + @Test(timeout = 30_000) + public void testShutdownRacingDispatchMustNotStrandCaller() throws Exception { + Class queryImplClass = Class.forName("io.questdb.client.impl.QueryImpl"); + Class poolClass = Class.forName("io.questdb.client.impl.QueryClientPool"); + + Field lockF = QueryWorker.class.getDeclaredField("signalLock"); + Field condF = QueryWorker.class.getDeclaredField("signalCondition"); + Field currentF = QueryWorker.class.getDeclaredField("current"); + Field shuttingF = QueryWorker.class.getDeclaredField("shuttingDown"); + Field threadF = QueryWorker.class.getDeclaredField("thread"); + for (Field f : new Field[]{lockF, condF, currentF, shuttingF, threadF}) { + f.setAccessible(true); + } + + Field doneF = queryImplClass.getDeclaredField("done"); + Field completionF = queryImplClass.getDeclaredField("completion"); + doneF.setAccessible(true); + completionF.setAccessible(true); + + // No QwpQueryClient is constructed here: runLoop exits at the + // shuttingDown check before reaching the first reference to + // {@code client} or {@code pool}, so passing null for both is fine + // and keeps the test cleanly isolated from any network or socket state. + QueryWorker worker = new QueryWorker(null, null, 0); + Thread t = (Thread) threadF.get(worker); + t.start(); + + ReentrantLock lock = (ReentrantLock) lockF.get(worker); + Condition cond = (Condition) condF.get(worker); + + // Wait until the worker thread is parked on its signalCondition. + long deadlineNanos = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (true) { + boolean parked; + lock.lock(); + try { + parked = lock.hasWaiters(cond); + } finally { + lock.unlock(); + } + if (parked) { + break; + } + if (System.nanoTime() > deadlineNanos) { + Assert.fail("worker thread never parked on its signalCondition"); + } + Thread.sleep(1); + } + + // Construct a QueryImpl with done=false, mimicking the state set up + // by QueryImpl.submit() just before it calls worker.dispatch(). + Constructor ctor = queryImplClass.getDeclaredConstructor(poolClass); + ctor.setAccessible(true); + Object queryImpl = ctor.newInstance(new Object[]{null}); + doneF.setBoolean(queryImpl, false); + Completion completion = (Completion) completionF.get(queryImpl); + + // Atomically force the racy state under the worker's own lock: + // current set AND shuttingDown set before the worker wakes. + lock.lock(); + try { + currentF.set(worker, queryImpl); + shuttingF.setBoolean(worker, true); + cond.signalAll(); + } finally { + lock.unlock(); + } + + // The worker thread must exit (it has observed shuttingDown). + t.join(5_000); + Assert.assertFalse("worker thread did not exit after shuttingDown=true", + t.isAlive()); + + // The Completion must have been signalled. Without the fix, await(2s) + // returns false because signalDone is never called. + boolean completed; + try { + completed = completion.await(2, TimeUnit.SECONDS); + } catch (RuntimeException expectedAfterFix) { + // Once fixed, the worker is expected to call signalUnexpected + // with a QueryException("QuestDB handle is closed") which + // await() rethrows. Either form of "completed" is acceptable; + // the bug is the silent hang. + completed = true; + } + Assert.assertTrue("BUG: QueryWorker.runLoop returned with shuttingDown=true " + + "while current!=null, never invoking runOn or signalUnexpected. " + + "The caller's Completion.await() hangs forever.", completed); + } +} diff --git a/core/src/test/java/io/questdb/client/test/impl/SenderPoolTest.java b/core/src/test/java/io/questdb/client/test/impl/SenderPoolTest.java new file mode 100644 index 00000000..7c58943a --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/impl/SenderPoolTest.java @@ -0,0 +1,490 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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.impl; + +import io.questdb.client.Sender; +import io.questdb.client.cutlass.line.LineSenderException; +import io.questdb.client.impl.SenderPool; +import org.junit.Assert; +import org.junit.Test; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Unit tests for the {@link SenderPool} borrow/return semantics. Uses the + * {@code http} schema with a dead address: HTTP Senders connect lazily on + * first request, so the pool builds without I/O and the tests can exercise + * borrow/return without needing a real server. + *

+ * Tests never call methods that would attempt a network round-trip + * (no {@code flush}, no row builders that auto-flush). Pooled + * {@link io.questdb.client.impl.PooledSender#close()} does call + * {@code delegate.flush()}, but on an empty buffer that path is a no-op for + * HTTP transport. + */ +public class SenderPoolTest { + + private static final String DEAD_HTTP_CONFIG = + "http::addr=127.0.0.1:1;protocol_version=2;auto_flush=off;"; + + @Test + public void testBorrowReturnRecyclesSameDecorator() { + try (SenderPool pool = new SenderPool(DEAD_HTTP_CONFIG, 1, 1, 1_000, Long.MAX_VALUE, Long.MAX_VALUE)) { + Sender first = pool.borrow(); + first.close(); + Sender second = pool.borrow(); + Assert.assertSame("returned decorator should be reused after close()", first, second); + second.close(); + } + } + + @Test + public void testBrokenSenderIsNotReturnedToPool() { + // Borrowing, buffering a row, and then closing forces flush() against + // the unreachable address, which throws. The broken wrapper must not + // be returned to the pool: its delegate's buffer still holds the + // failed row, and on transports with terminal-failure semantics the + // delegate is also unusable. Either way, the next borrower must get + // a fresh wrapper. + try (SenderPool pool = new SenderPool(DEAD_HTTP_CONFIG, 1, 1, 1_000, Long.MAX_VALUE, Long.MAX_VALUE)) { + Sender first = pool.borrow(); + first.table("t").longColumn("v", 1).atNow(); + try { + first.close(); + Assert.fail("close() with buffered rows against an unreachable host must throw"); + } catch (LineSenderException ignored) { + // expected + } + Sender second = pool.borrow(); + try { + Assert.assertNotSame("broken sender must not be handed back to next borrower", + first, second); + } finally { + if (second != first) { + second.close(); + } + } + } + } + + @Test + public void testCloseIdempotent() { + SenderPool pool = new SenderPool(DEAD_HTTP_CONFIG, 2, 2, 1_000, Long.MAX_VALUE, Long.MAX_VALUE); + pool.close(); + pool.close(); + } + + @Test + public void testDiscardBrokenAfterCloseDoesNotMutatePool() { + // Race: pool.close() iterates `all` outside the lock to close each + // delegate. Concurrently, a borrower's PooledSender.close() sees its + // delegate already closed (closed by pool.close()), the flush throws, + // broken=true routes to SenderPool.discardBroken -- which previously + // called all.remove(s) and delegate.close() unconditionally, + // racing the iteration in pool.close() on a non-thread-safe + // ArrayList. Possible outcomes: IndexOutOfBoundsException out of + // pool.close(), skipped delegate close (native handle leak), or + // two threads simultaneously inside delegate.close(). + // + // The fix gates discardBroken on `closed`: once the pool is shutting + // down, close()'s teardown loop owns the delegate close and + // discardBroken bails before touching `all`. + // + // Deterministic reproduction: serialise the race onto one thread by + // calling pool.close() first (which closes the delegate of every + // pooled wrapper), then driving sender.close() second. Without the + // fix, sender.close() routes to discardBroken, which removes the + // wrapper from `all` post-close -- visible as totalSize dropping + // from 1 to 0. With the fix, discardBroken sees closed=true and + // bails, leaving `all` untouched. + try (SenderPool pool = new SenderPool(DEAD_HTTP_CONFIG, 1, 1, 1_000, Long.MAX_VALUE, Long.MAX_VALUE)) { + Sender s = pool.borrow(); + // A row in the buffer forces flush() to attempt a real HTTP + // request on close(); against the unreachable DEAD_HTTP target + // (and now also against the closed delegate below) flush throws + // and routes the wrapper to discardBroken. + s.table("t").longColumn("v", 1L).atNow(); + + pool.close(); + Assert.assertEquals( + "pool.close() must not clear `all` -- the teardown loop closes delegates in place", + 1, pool.totalSize() + ); + + // sender.close() now hits a closed delegate; flush throws; the + // PooledSender.close() finally routes to discardBroken. + try { + s.close(); + } catch (LineSenderException expected) { + // expected: flush against a closed delegate throws. + } catch (RuntimeException expected) { + // some Sender implementations wrap differently; either is fine. + } + + Assert.assertEquals( + "discardBroken called after pool close must NOT mutate `all`", + 1, pool.totalSize() + ); + } + } + + @Test + public void testCloseRejectsSubsequentBorrow() { + SenderPool pool = new SenderPool(DEAD_HTTP_CONFIG, 1, 1, 1_000, Long.MAX_VALUE, Long.MAX_VALUE); + pool.close(); + try { + pool.borrow(); + Assert.fail("borrow after close must throw"); + } catch (LineSenderException ignored) { + } + } + + @Test + public void testExhaustionTimeoutThrows() { + try (SenderPool pool = new SenderPool(DEAD_HTTP_CONFIG, 1, 1, 100, Long.MAX_VALUE, Long.MAX_VALUE)) { + long start = System.nanoTime(); + try (Sender ignored = pool.borrow()) { + pool.borrow(); + Assert.fail("expected timeout"); + } catch (LineSenderException e) { + long elapsedMs = (System.nanoTime() - start) / 1_000_000; + Assert.assertTrue("error should mention timeout, was: " + e.getMessage(), + e.getMessage().contains("timed out")); + Assert.assertTrue("should have waited close to the timeout, elapsed=" + elapsedMs, + elapsedMs >= 90); + } + } + } + + @Test + public void testPoolBuildsRequestedNumberOfSenders() { + try (SenderPool pool = new SenderPool(DEAD_HTTP_CONFIG, 3, 3, 1_000, Long.MAX_VALUE, Long.MAX_VALUE)) { + Sender a = pool.borrow(); + Sender b = pool.borrow(); + Sender c = pool.borrow(); + Assert.assertNotSame(a, b); + Assert.assertNotSame(b, c); + Assert.assertNotSame(a, c); + a.close(); + b.close(); + c.close(); + } + } + + @Test + public void testElasticGrowsUpToMax() { + // min=1, max=3 -- starts at 1, grows on demand to 3. + try (SenderPool pool = new SenderPool(DEAD_HTTP_CONFIG, 1, 3, 1_000, Long.MAX_VALUE, Long.MAX_VALUE)) { + Assert.assertEquals("pre-warm to min", 1, pool.totalSize()); + Sender a = pool.borrow(); + Assert.assertEquals(1, pool.totalSize()); + Sender b = pool.borrow(); + Assert.assertEquals("borrowing past min grows", 2, pool.totalSize()); + Sender c = pool.borrow(); + Assert.assertEquals(3, pool.totalSize()); + // At max: next borrow times out. + try { + pool.borrow(); + Assert.fail("4th borrow must time out at max=3"); + } catch (LineSenderException ignored) { + } + a.close(); + b.close(); + c.close(); + Assert.assertEquals("size unchanged on return", 3, pool.totalSize()); + } + } + + @Test + public void testAvailableSizeTracksBorrowAndReturn() throws InterruptedException { + // min=2, max=4. Walk the full lifecycle and assert availableSize() and + // totalSize() stay in sync at every step: pre-warm, borrow shrinks + // available, growth doesn't change available (the new slot goes + // straight to the caller), return restores availability, reap shrinks + // total back toward min but never below. + try (SenderPool pool = new SenderPool(DEAD_HTTP_CONFIG, 2, 4, 1_000, 100, Long.MAX_VALUE)) { + // Pre-warmed to min=2; everything is idle. + Assert.assertEquals(2, pool.totalSize()); + Assert.assertEquals(2, pool.availableSize()); + + // Borrowing from the warm slots leaves total unchanged but consumes one available. + Sender a = pool.borrow(); + Assert.assertEquals(2, pool.totalSize()); + Assert.assertEquals(1, pool.availableSize()); + + Sender b = pool.borrow(); + Assert.assertEquals(2, pool.totalSize()); + Assert.assertEquals(0, pool.availableSize()); + + // Borrowing past min grows the pool. The new slot goes straight to + // the caller, so availableSize stays at 0. + Sender c = pool.borrow(); + Assert.assertEquals(3, pool.totalSize()); + Assert.assertEquals(0, pool.availableSize()); + + // Returning two restores availability without touching total. + a.close(); + b.close(); + Assert.assertEquals(3, pool.totalSize()); + Assert.assertEquals(2, pool.availableSize()); + + // Reaping idle slots over min closes them; available counts the + // remaining idle ones. Total shrinks; min=2 is respected so we end + // up with min=2 total and (min - in-use)=1 available. + Thread.sleep(150); + pool.reapIdle(); + Assert.assertEquals(2, pool.totalSize()); + Assert.assertEquals(1, pool.availableSize()); + + c.close(); + Assert.assertEquals(2, pool.totalSize()); + Assert.assertEquals(2, pool.availableSize()); + } + } + + @Test + public void testAvailableSizeZeroAfterClose() { + SenderPool pool = new SenderPool(DEAD_HTTP_CONFIG, 2, 2, 1_000, Long.MAX_VALUE, Long.MAX_VALUE); + Assert.assertEquals(2, pool.availableSize()); + pool.close(); + // close() destroys every underlying Sender; the available queue is no + // longer being added to, but the snapshot read is still safe. The + // exact value (0 or stale) is less important than the call not + // throwing on a closed pool. + int snapshot = pool.availableSize(); + Assert.assertTrue("availableSize on closed pool must be a non-negative snapshot, got " + snapshot, + snapshot >= 0); + } + + @Test + public void testReapIdleShrinksToMin() throws InterruptedException { + // Short idle timeout; reapIdle() drives the sweep deterministically. + try (SenderPool pool = new SenderPool(DEAD_HTTP_CONFIG, 1, 3, 1_000, 100, Long.MAX_VALUE)) { + Sender a = pool.borrow(); + Sender b = pool.borrow(); + Sender c = pool.borrow(); + Assert.assertEquals(3, pool.totalSize()); + a.close(); + b.close(); + c.close(); + // All idle; wait until idle threshold passes, then sweep. + Thread.sleep(150); + pool.reapIdle(); + Assert.assertEquals("reap must shrink to min", 1, pool.totalSize()); + } + } + + @Test + public void testReapIdleRespectsMinSize() throws InterruptedException { + // min=2: two slots must stay even after long idle. + try (SenderPool pool = new SenderPool(DEAD_HTTP_CONFIG, 2, 4, 1_000, 50, Long.MAX_VALUE)) { + Sender a = pool.borrow(); + Sender b = pool.borrow(); + Sender c = pool.borrow(); + a.close(); + b.close(); + c.close(); + Thread.sleep(100); + pool.reapIdle(); + Assert.assertEquals("min=2 must be preserved", 2, pool.totalSize()); + } + } + + @Test + public void testPinAfterCloseRejectsStaleEntry() throws Exception { + // Pin from a worker thread, close the pool from main. The worker's + // ThreadLocal still references its PooledSender, but the underlying + // delegate has been closed. The next pinToCurrentThread() on the + // worker must reject the stale entry instead of handing it back. + SenderPool pool = new SenderPool(DEAD_HTTP_CONFIG, 1, 1, 1_000, Long.MAX_VALUE, Long.MAX_VALUE); + CountDownLatch pinned = new CountDownLatch(1); + CountDownLatch closed = new CountDownLatch(1); + AtomicReference secondCallError = new AtomicReference<>(); + Thread worker = new Thread(() -> { + try { + pool.pinToCurrentThread(); + pinned.countDown(); + Assert.assertTrue(closed.await(2, TimeUnit.SECONDS)); + try { + pool.pinToCurrentThread(); + secondCallError.set(new AssertionError("pinToCurrentThread after close must throw")); + } catch (LineSenderException e) { + // expected + } + } catch (Throwable t) { + secondCallError.set(t); + } + }); + worker.start(); + Assert.assertTrue(pinned.await(2, TimeUnit.SECONDS)); + pool.close(); + closed.countDown(); + worker.join(2_000); + if (secondCallError.get() != null) { + throw new AssertionError(secondCallError.get()); + } + } + + @Test + public void testPinAfterUserCloseDoesNotShareWrapper() { + // Same-thread reproducer for the pinToCurrentThread() sharing bug. + // The user closes a pinned Sender (the natural try-with-resources + // idiom on the public Sender API), then another consumer borrows + // the slot. pinToCurrentThread() must not hand that wrapper back: + // it is now owned by the second consumer. + // + // Pool size 1 collapses the race window into a linear sequence: + // the second borrower deterministically receives the same slot + // that was just returned, so the bug is observable at the + // wrapper-identity level without timing. + try (SenderPool pool = new SenderPool(DEAD_HTTP_CONFIG, 1, 1, 100, Long.MAX_VALUE, Long.MAX_VALUE)) { + Sender pinned = pool.pinToCurrentThread(); + pinned.close(); // pool slot returned; ThreadLocal still points at it + Sender stolen = pool.borrow(); // pollFirst hands the same wrapper to a new consumer + try { + Sender repinned = pool.pinToCurrentThread(); + Assert.fail("pinToCurrentThread() returned wrapper " + repinned + + " already borrowed by another consumer " + stolen); + } catch (LineSenderException expected) { + // After fix: TL cleared (or owner-thread invalidated) on close; + // re-pin tries to borrow, pool is empty, acquireTimeout fires. + } finally { + stolen.close(); + } + } + } + + @Test + public void testPinAfterUserCloseDoesNotShareWrapperCrossThread() throws InterruptedException { + // Cross-thread variant of the same bug, mirroring the originally + // reported trigger: Thread A pins, closes, then re-pins while + // Thread B has borrowed the slot in between. A's ThreadLocal still + // references the wrapper, and pinToCurrentThread() hands it back -- + // so A and B end up writing to the same underlying Sender. + try (SenderPool pool = new SenderPool(DEAD_HTTP_CONFIG, 1, 1, 100, Long.MAX_VALUE, Long.MAX_VALUE)) { + CountDownLatch aClosed = new CountDownLatch(1); + CountDownLatch bBorrowed = new CountDownLatch(1); + AtomicReference bSender = new AtomicReference<>(); + AtomicReference failure = new AtomicReference<>(); + + Thread a = new Thread(() -> { + try { + Sender s = pool.pinToCurrentThread(); + s.close(); + aClosed.countDown(); + Assert.assertTrue(bBorrowed.await(2, TimeUnit.SECONDS)); + try { + Sender repinned = pool.pinToCurrentThread(); + failure.compareAndSet(null, new AssertionError( + "pinToCurrentThread() returned wrapper " + repinned + + " already borrowed by another thread " + bSender.get())); + } catch (LineSenderException expected) { + // After fix: re-pin tries to borrow, pool is empty, times out. + } + } catch (Throwable t) { + failure.compareAndSet(null, t); + } + }); + Thread b = new Thread(() -> { + try { + Assert.assertTrue(aClosed.await(2, TimeUnit.SECONDS)); + bSender.set(pool.borrow()); + } catch (Throwable t) { + failure.compareAndSet(null, t); + } finally { + bBorrowed.countDown(); + } + }); + + a.start(); + b.start(); + a.join(4_000); + b.join(4_000); + + if (bSender.get() != null) { + bSender.get().close(); + } + if (failure.get() != null) { + throw new AssertionError(failure.get()); + } + } + } + + @Test + public void testReleaseAfterCloseIsSafe() throws Exception { + // Same setup as the pin test, but exercise releaseCurrentThread() + // instead. With a closed delegate underneath, the release path must + // not invoke flush() on the dead Sender. + SenderPool pool = new SenderPool(DEAD_HTTP_CONFIG, 1, 1, 1_000, Long.MAX_VALUE, Long.MAX_VALUE); + CountDownLatch pinned = new CountDownLatch(1); + CountDownLatch closed = new CountDownLatch(1); + AtomicReference releaseError = new AtomicReference<>(); + Thread worker = new Thread(() -> { + try { + pool.pinToCurrentThread(); + pinned.countDown(); + Assert.assertTrue(closed.await(2, TimeUnit.SECONDS)); + pool.releaseCurrentThread(); + } catch (Throwable t) { + releaseError.set(t); + } + }); + worker.start(); + Assert.assertTrue(pinned.await(2, TimeUnit.SECONDS)); + pool.close(); + closed.countDown(); + worker.join(2_000); + if (releaseError.get() != null) { + throw new AssertionError(releaseError.get()); + } + } + + @Test + public void testThreadAffinityIsPerThread() throws InterruptedException { + try (SenderPool pool = new SenderPool(DEAD_HTTP_CONFIG, 2, 2, 1_000, Long.MAX_VALUE, Long.MAX_VALUE)) { + Sender mainPinned = pool.pinToCurrentThread(); + Assert.assertSame("re-pin on same thread returns same instance", + mainPinned, pool.pinToCurrentThread()); + + AtomicReference otherPinned = new AtomicReference<>(); + CountDownLatch done = new CountDownLatch(1); + Thread t = new Thread(() -> { + try { + otherPinned.set(pool.pinToCurrentThread()); + } finally { + done.countDown(); + } + }); + t.start(); + Assert.assertTrue(done.await(2, TimeUnit.SECONDS)); + Assert.assertNotSame("different threads must get different pinned Senders", + mainPinned, otherPinned.get()); + + pool.releaseCurrentThread(); + } + } +}