diff --git a/core/src/main/java/io/questdb/client/QuestDB.java b/core/src/main/java/io/questdb/client/QuestDB.java
index b90e66fd..a608e12f 100644
--- a/core/src/main/java/io/questdb/client/QuestDB.java
+++ b/core/src/main/java/io/questdb/client/QuestDB.java
@@ -59,16 +59,15 @@ static QuestDBBuilder builder() {
/**
* 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}).
+ * egress. The schema must be {@code ws} or {@code wss}: QuestDB ingests and
+ * queries over QWP (the QuestDB WebSocket protocol), so one string
+ * configures both clients.
*
* Use {@link #connect(CharSequence, CharSequence)} or {@link #builder()}
- * for ingest transports other than HTTP/HTTPS, or when ingest and egress
- * use different addresses.
+ * when ingest and egress use different addresses or credentials.
*
- * @param configurationString a Sender- or QwpQueryClient-style config
- * string (see {@link Sender#fromConfig} or
+ * @param configurationString a {@code ws}/{@code wss} config string (see
+ * {@link Sender#fromConfig} or
* {@link io.questdb.client.cutlass.qwp.client.QwpQueryClient#fromConfig})
* @return a connected QuestDB handle
*/
diff --git a/core/src/main/java/io/questdb/client/QuestDBBuilder.java b/core/src/main/java/io/questdb/client/QuestDBBuilder.java
index 0b73541b..cae00942 100644
--- a/core/src/main/java/io/questdb/client/QuestDBBuilder.java
+++ b/core/src/main/java/io/questdb/client/QuestDBBuilder.java
@@ -24,13 +24,25 @@
package io.questdb.client;
-import io.questdb.client.impl.ConfigStringTranslator;
+import io.questdb.client.cutlass.qwp.client.QwpQueryClient;
+import io.questdb.client.impl.ConfigString;
+import io.questdb.client.impl.ConfigView;
import io.questdb.client.impl.QuestDBImpl;
+import org.jetbrains.annotations.TestOnly;
+
+import java.util.function.IntConsumer;
+import java.util.function.LongConsumer;
/**
* 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.
+ *
+ * Both configs must use the {@code ws} or {@code wss} schema (QWP over
+ * WebSocket). A pool key (e.g. {@code sender_pool_min}) may be carried in the
+ * connect string or set with an explicit builder call; an explicit call always
+ * wins. When both connect strings carry the same pool key with different values,
+ * {@link #build()} fails.
*/
public final class QuestDBBuilder {
@@ -41,16 +53,21 @@ public final class QuestDBBuilder {
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;
+ // Every valid pool value is >= 0, so -1 unambiguously marks "not set
+ // explicitly". The public pool setters are the only writers of these
+ // fields, so field != UNSET is exactly the "set explicitly" bit.
+ private static final int UNSET = -1;
+
+ private long acquireTimeoutMillis = UNSET;
+ private long housekeeperIntervalMillis = UNSET;
+ private long idleTimeoutMillis = UNSET;
private String ingestConfig;
- private long maxLifetimeMillis = DEFAULT_MAX_LIFETIME_MILLIS;
+ private long maxLifetimeMillis = UNSET;
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;
+ private int queryPoolMax = UNSET;
+ private int queryPoolMin = UNSET;
+ private int senderPoolMax = UNSET;
+ private int senderPoolMin = UNSET;
QuestDBBuilder() {
}
@@ -69,7 +86,9 @@ public QuestDBBuilder acquireTimeoutMillis(long millis) {
}
/**
- * Builds the {@link QuestDB} handle. Eagerly creates {@code min}
+ * Builds the {@link QuestDB} handle. Validates both connect strings up
+ * front -- so a malformed config fails here even when both pools have
+ * {@code min == 0} and nothing connects -- then 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.
@@ -88,6 +107,30 @@ public QuestDB build() {
if (queryConfig == null) {
throw new IllegalStateException("query configuration is required; call fromConfig() or queryConfig()");
}
+ ConfigString ingestCs = ConfigString.parse(ingestConfig);
+ ConfigString queryCs = ConfigString.parse(queryConfig);
+ ConfigView ingestView = new ConfigView(ingestCs);
+ ConfigView queryView = new ConfigView(queryCs);
+ // Validate both connect strings exactly as the pools will, but without
+ // connecting. The ingest string runs the full Sender parse plus
+ // validateParameters -- ingress value keys are registry-STRING, so only
+ // the real parse validates their values. The egress string runs the
+ // typed validateConfig. A malformed config therefore fails here even
+ // when a pool min is 0 and nothing connects.
+ Sender.LineSenderBuilder.validateWsConfigString(ingestConfig);
+ QwpQueryClient.validateConfig(queryView, "wss".equals(queryCs.schema()));
+
+ // A view carries no side; getInt/getLong read any key, so the ingest
+ // and query views also serve the POOL reads.
+ resolvePoolInt(senderPoolMin, "sender_pool_min", ingestView, queryView, DEFAULT_POOL_MIN, this::senderPoolMin);
+ resolvePoolInt(senderPoolMax, "sender_pool_max", ingestView, queryView, DEFAULT_POOL_MAX, this::senderPoolMax);
+ resolvePoolInt(queryPoolMin, "query_pool_min", ingestView, queryView, DEFAULT_POOL_MIN, this::queryPoolMin);
+ resolvePoolInt(queryPoolMax, "query_pool_max", ingestView, queryView, DEFAULT_POOL_MAX, this::queryPoolMax);
+ resolvePoolLong(acquireTimeoutMillis, "acquire_timeout_ms", ingestView, queryView, DEFAULT_ACQUIRE_TIMEOUT_MILLIS, this::acquireTimeoutMillis);
+ resolvePoolLong(idleTimeoutMillis, "idle_timeout_ms", ingestView, queryView, DEFAULT_IDLE_TIMEOUT_MILLIS, this::idleTimeoutMillis);
+ resolvePoolLong(maxLifetimeMillis, "max_lifetime_ms", ingestView, queryView, DEFAULT_MAX_LIFETIME_MILLIS, this::maxLifetimeMillis);
+ resolvePoolLong(housekeeperIntervalMillis, "housekeeper_interval_ms", ingestView, queryView, DEFAULT_HOUSEKEEPER_INTERVAL_MILLIS, this::housekeeperIntervalMillis);
+
return new QuestDBImpl(
ingestConfig,
queryConfig,
@@ -103,42 +146,14 @@ public QuestDB build() {
}
/**
- * 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.
+ * Sets a single configuration string used for both ingest and egress. The
+ * schema must be {@code ws} or {@code wss}.
*/
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);
- }
+ requireWebSocketSchema(configurationString, "connection");
+ String s = configurationString.toString();
+ this.ingestConfig = s;
+ this.queryConfig = s;
return this;
}
@@ -169,9 +184,11 @@ public QuestDBBuilder idleTimeoutMillis(long millis) {
}
/**
- * Sets the ingest-side configuration in {@link Sender#fromConfig} format.
+ * Sets the ingest-side configuration. The schema must be {@code ws} or
+ * {@code wss}.
*/
public QuestDBBuilder ingestConfig(CharSequence configurationString) {
+ requireWebSocketSchema(configurationString, "ingest");
this.ingestConfig = configurationString.toString();
return this;
}
@@ -190,11 +207,11 @@ public QuestDBBuilder maxLifetimeMillis(long millis) {
}
/**
- * Sets the query-side configuration in
- * {@link io.questdb.client.cutlass.qwp.client.QwpQueryClient#fromConfig}
- * format.
+ * Sets the query-side configuration. The schema must be {@code ws} or
+ * {@code wss}.
*/
public QuestDBBuilder queryConfig(CharSequence configurationString) {
+ requireWebSocketSchema(configurationString, "query");
this.queryConfig = configurationString.toString();
return this;
}
@@ -272,4 +289,81 @@ public QuestDBBuilder senderPoolSize(int size) {
this.senderPoolMax = size;
return this;
}
+
+ /**
+ * Snapshot of the resolved pool config, keyed by connect-string key name.
+ * Valid after {@link #build()} has run pool-key resolution. Drives the
+ * per-key "honored" guard test.
+ */
+ @TestOnly
+ public java.util.Map poolConfigSnapshotForTest() {
+ java.util.Map m = new java.util.HashMap<>();
+ m.put("sender_pool_min", senderPoolMin);
+ m.put("sender_pool_max", senderPoolMax);
+ m.put("query_pool_min", queryPoolMin);
+ m.put("query_pool_max", queryPoolMax);
+ m.put("acquire_timeout_ms", acquireTimeoutMillis);
+ m.put("idle_timeout_ms", idleTimeoutMillis);
+ m.put("max_lifetime_ms", maxLifetimeMillis);
+ m.put("housekeeper_interval_ms", housekeeperIntervalMillis);
+ return m;
+ }
+
+ private static void requireWebSocketSchema(CharSequence config, String role) {
+ String schema = ConfigString.parse(config).schema();
+ if (!"ws".equals(schema) && !"wss".equals(schema)) {
+ throw new IllegalArgumentException(
+ role + " configuration must use the ws or wss schema; got: " + schema);
+ }
+ }
+
+ private void resolvePoolInt(int current, String key, ConfigView ingest, ConfigView query, int dflt, IntConsumer setter) {
+ if (current != UNSET) {
+ return; // explicit builder call wins; skip the conflict check
+ }
+ boolean inIngest = ingest.has(key);
+ boolean inQuery = query.has(key);
+ int value;
+ if (inIngest && inQuery) {
+ int vi = ingest.getInt(key, UNSET);
+ int vq = query.getInt(key, UNSET);
+ if (vi != vq) {
+ throw new IllegalArgumentException(
+ "conflicting pool config: " + key + " (ingest=" + vi + ", query=" + vq + ")");
+ }
+ value = vi;
+ } else if (inIngest) {
+ value = ingest.getInt(key, UNSET);
+ } else if (inQuery) {
+ value = query.getInt(key, UNSET);
+ } else {
+ value = dflt;
+ }
+ setter.accept(value);
+ }
+
+ private void resolvePoolLong(long current, String key, ConfigView ingest, ConfigView query, long dflt, LongConsumer setter) {
+ if (current != UNSET) {
+ return; // explicit builder call wins; skip the conflict check
+ }
+ boolean inIngest = ingest.has(key);
+ boolean inQuery = query.has(key);
+ long value;
+ if (inIngest && inQuery) {
+ long vi = ingest.getLong(key, UNSET);
+ long vq = query.getLong(key, UNSET);
+ if (vi != vq) {
+ throw new IllegalArgumentException(
+ "conflicting pool config: " + key + " (ingest=" + vi + ", query=" + vq + ")");
+ }
+ value = vi;
+ } else if (inIngest) {
+ value = ingest.getLong(key, UNSET);
+ } else if (inQuery) {
+ value = query.getLong(key, UNSET);
+ } else {
+ value = dflt;
+ }
+ setter.accept(value);
+ }
}
diff --git a/core/src/main/java/io/questdb/client/Sender.java b/core/src/main/java/io/questdb/client/Sender.java
index bd536e6b..604f45d5 100644
--- a/core/src/main/java/io/questdb/client/Sender.java
+++ b/core/src/main/java/io/questdb/client/Sender.java
@@ -39,6 +39,8 @@
import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorSendEngine;
import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorWebSocketSendLoop;
import io.questdb.client.impl.ConfStringParser;
+import io.questdb.client.impl.ConfigString;
+import io.questdb.client.impl.ConfigView;
import io.questdb.client.network.NetworkFacade;
import io.questdb.client.network.NetworkFacadeImpl;
import io.questdb.client.std.Chars;
@@ -53,6 +55,7 @@
import io.questdb.client.std.bytes.DirectByteSlice;
import io.questdb.client.std.str.StringSink;
import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.TestOnly;
import javax.security.auth.DestroyFailedException;
import java.io.Closeable;
@@ -1038,7 +1041,6 @@ final class LineSenderBuilder {
// Bounded inbox capacity for the async error dispatcher.
// PARAMETER_NOT_SET_EXPLICITLY → spec default (256).
private int errorInboxCapacity = PARAMETER_NOT_SET_EXPLICITLY;
- private boolean gorillaEnabled = true;
private String httpPath;
private String httpSettingsPath;
private int httpTimeout = PARAMETER_NOT_SET_EXPLICITLY;
@@ -1402,16 +1404,11 @@ public Sender build() {
);
}
- // Cursor is the only async ingest path. Setting sfDir enables
- // store-and-forward (mmap'd, recoverable across sender restarts);
- // omitting it gives memory-only mode (same lock-free architecture,
- // no disk involvement). sf_durability != memory is a planned
- // feature; throw today instead of silently downgrading.
- if (sfDurability != SfDurability.MEMORY) {
- throw new LineSenderException(
- "sf_durability=" + sfDurability.name().toLowerCase()
- + " is not yet supported (deferred follow-up; use sf_durability=memory)");
- }
+ // Setting sfDir enables store-and-forward (mmap'd, recoverable
+ // across sender restarts); omitting it gives memory-only mode
+ // (same lock-free architecture, no disk involvement). The
+ // sf_durability != memory rejection lives in validateParameters
+ // so it is reached by build() and by no-connect validation alike.
long actualSfMaxBytes = sfMaxBytes == PARAMETER_NOT_SET_EXPLICITLY
? DEFAULT_SEGMENT_BYTES
: sfMaxBytes;
@@ -1534,7 +1531,6 @@ public Sender build() {
actualErrorInboxCapacity,
actualDurableAckKeepaliveIntervalMillis,
authTimeoutMillis,
- gorillaEnabled,
connectionListener,
actualConnectionListenerInboxCapacity
);
@@ -1893,14 +1889,6 @@ public LineSenderBuilder errorInboxCapacity(int capacity) {
return this;
}
- public LineSenderBuilder gorilla(boolean enabled) {
- if (protocol != PARAMETER_NOT_SET_EXPLICITLY && protocol != PROTOCOL_WEBSOCKET) {
- throw new LineSenderException("gorilla is only supported for WebSocket transport");
- }
- this.gorillaEnabled = enabled;
- return this;
- }
-
/**
* Path component of the HTTP URL.
*
@@ -2874,6 +2862,11 @@ private void addAddressEntry(CharSequence src, int start, int end, int defaultPo
ports.add(effectivePort);
}
+ private void appendAddress(String host, int port) {
+ hosts.add(host);
+ ports.add(port);
+ }
+
private String buildWebSocketAuthHeader() {
if (username != null && password != null) {
String credentials = username + ":" + password;
@@ -2950,26 +2943,6 @@ private LineSenderBuilder fromConfig(CharSequence configurationString) {
if (pos < 0) {
throw new LineSenderException("invalid configuration string: ").put(sink);
}
- if (protocol != PARAMETER_NOT_SET_EXPLICITLY) {
- String protocolName;
- switch (protocol) {
- case PROTOCOL_HTTP:
- protocolName = "http";
- break;
- case PROTOCOL_UDP:
- protocolName = "udp";
- break;
- case PROTOCOL_WEBSOCKET:
- protocolName = "websocket";
- break;
- default:
- protocolName = "tcp";
- break;
- }
- throw new LineSenderException("protocol was already configured ")
- .put("[protocol=")
- .put(protocolName).put("]");
- }
if (Chars.equals("http", sink)) {
if (tlsEnabled) {
throw new LineSenderException("cannot use http protocol when TLS is enabled. use https instead");
@@ -3002,6 +2975,10 @@ private LineSenderBuilder fromConfig(CharSequence configurationString) {
throw new LineSenderException("invalid schema [schema=").put(sink).put(", supported-schemas=[http, https, tcp, tcps, ws, wss, udp]]");
}
+ if (protocol == PROTOCOL_WEBSOCKET) {
+ return fromConfigWebSocket(configurationString);
+ }
+
String tcpToken = null;
String user = null;
String password = null;
@@ -3265,18 +3242,6 @@ private LineSenderBuilder fromConfig(CharSequence configurationString) {
}
pos = getValue(configurationString, pos, sink, "auth_timeout_ms");
authTimeoutMillis(parseLongValue(sink, "auth_timeout_ms"));
- } else if (Chars.equals("gorilla", sink)) {
- if (protocol != PROTOCOL_WEBSOCKET) {
- throw new LineSenderException("gorilla is only supported for WebSocket transport");
- }
- pos = getValue(configurationString, pos, sink, "gorilla");
- if (Chars.equals("on", sink) || Chars.equals("true", sink)) {
- gorilla(true);
- } else if (Chars.equals("off", sink) || Chars.equals("false", sink)) {
- gorilla(false);
- } else {
- throw new LineSenderException("invalid gorilla [value=").put(sink).put(", allowed=[on, off]]");
- }
} else if (Chars.equals("durable_ack_keepalive_interval_millis", sink)) {
if (protocol != PROTOCOL_WEBSOCKET) {
throw new LineSenderException(
@@ -3366,8 +3331,7 @@ private LineSenderBuilder fromConfig(CharSequence configurationString) {
// zone-blind (pinned to v1) and silently accepts the key so
// the same connect string works on both sides.
pos = getValue(configurationString, pos, sink, "zone");
- } else if (Chars.equals("auth", sink)
- || Chars.equals("buffer_pool_size", sink)
+ } else if (Chars.equals("buffer_pool_size", sink)
|| Chars.equals("client_id", sink)
|| Chars.equals("compression", sink)
|| Chars.equals("compression_level", sink)
@@ -3378,7 +3342,6 @@ private LineSenderBuilder fromConfig(CharSequence configurationString) {
|| Chars.equals("failover_max_duration_ms", sink)
|| Chars.equals("initial_credit", sink)
|| Chars.equals("max_batch_rows", sink)
- || Chars.equals("path", sink)
|| Chars.equals("target", sink)) {
// connect-string.md "Query client keys" and "Multi-host failover":
// these keys configure the QwpQueryClient (egress) only. The
@@ -3390,10 +3353,21 @@ private LineSenderBuilder fromConfig(CharSequence configurationString) {
// genuine value-parse error names the offending key.
String egressKey = Chars.toString(sink);
pos = getValue(configurationString, pos, sink, egressKey);
- } else if (Chars.equals("in_flight_window", sink)) {
- // Accepted as a no-op for backward compatibility. The
- // store-and-forward mechanism replaces the in-flight window.
- pos = getValue(configurationString, pos, sink, "in_flight_window");
+ } else if (Chars.equals("on_internal_error", sink)
+ || Chars.equals("on_parse_error", sink)
+ || Chars.equals("on_schema_error", sink)
+ || Chars.equals("on_security_error", sink)
+ || Chars.equals("on_server_error", sink)
+ || Chars.equals("on_write_error", sink)) {
+ // connect-string.md "Error handling": the on_*_error keys select
+ // the per-category error policy. The spec reserves them and
+ // directs new client implementations to accept them in the
+ // connect string. The Sender does not wire them to a policy yet,
+ // so it consumes them as an accepted no-op rather than rejecting
+ // them. Capture the key name before getValue clears the sink so a
+ // genuine value-parse error names the offending key.
+ String reservedKey = Chars.toString(sink);
+ pos = getValue(configurationString, pos, sink, reservedKey);
} else {
// sf-client.md §4.6: parser must reject unknown keys.
// Forward-compat is via the spec, not silent ignore — silent
@@ -3427,6 +3401,314 @@ private LineSenderBuilder fromConfig(CharSequence configurationString) {
return this;
}
+ /**
+ * Configures the WebSocket (QWP) ingress path from a {@code ws}/{@code wss}
+ * connect string, driven by {@link ConfigView} over the {@link ConfigSchema} registry.
+ * The reject pass surfaces unknown keys (with a relocated-key hint for
+ * legacy http/tcp/udp keys); {@link #validateWsConfig} runs the cross-key
+ * checks; the rest applies through the existing fluent setters, feeding
+ * the {@code PROTOCOL_WEBSOCKET} build path. Duplicate keys resolve
+ * last-write-wins. {@link ConfigView}'s {@link IllegalArgumentException}s
+ * surface as {@link LineSenderException} to keep the Sender contract.
+ */
+ private LineSenderBuilder fromConfigWebSocket(CharSequence configurationString) {
+ try {
+ ConfigString cs = ConfigString.parse(configurationString);
+ ConfigView view = new ConfigView(cs);
+ validateWsConfig(view, tlsEnabled);
+
+ view.getHostPorts("addr", DEFAULT_WEBSOCKET_PORT, this::appendAddress);
+
+ StringSink v = new StringSink();
+ String s;
+
+ String token = view.getStr("token");
+ if (token != null) {
+ httpToken(token);
+ }
+ String user = view.getStr("username");
+ if (user != null) {
+ httpUsernamePassword(user, view.getStr("password"));
+ }
+
+ s = view.getEnum("tls_verify");
+ if (s != null) {
+ tlsValidationMode = "on".equals(s) ? TlsValidationMode.DEFAULT : TlsValidationMode.INSECURE;
+ }
+ s = view.getStr("tls_roots");
+ if (s != null) {
+ trustStorePath = s;
+ }
+ s = view.getStr("tls_roots_password");
+ if (s != null) {
+ trustStorePassword = s.toCharArray();
+ }
+ if (view.has("auth_timeout_ms")) {
+ authTimeoutMillis(view.getLong("auth_timeout_ms", 0));
+ }
+
+ s = view.getStr("auto_flush_rows");
+ if (s != null) {
+ int rows;
+ if (s.equalsIgnoreCase("off")) {
+ rows = 0;
+ } else {
+ v.clear();
+ v.put(s);
+ rows = parseIntValue(v, "auto_flush_rows");
+ if (rows < 1) {
+ throw new LineSenderException("invalid auto_flush_rows [value=").put(rows).put("]");
+ }
+ }
+ autoFlushRows(rows);
+ }
+ s = view.getStr("auto_flush_interval");
+ if (s != null) {
+ int interval;
+ if (s.equalsIgnoreCase("off")) {
+ interval = Integer.MAX_VALUE;
+ } else {
+ v.clear();
+ v.put(s);
+ interval = parseIntValue(v, "auto_flush_interval");
+ if (interval < 1) {
+ throw new LineSenderException("invalid auto_flush_interval [value=").put(interval).put("]");
+ }
+ }
+ autoFlushIntervalMillis(interval);
+ }
+ s = view.getStr("auto_flush_bytes");
+ if (s != null) {
+ if (s.equalsIgnoreCase("off")) {
+ autoFlushBytes(0);
+ } else {
+ v.clear();
+ v.put(s);
+ autoFlushBytes(parseIntValue(v, "auto_flush_bytes"));
+ }
+ }
+ s = view.getStr("auto_flush");
+ if (s != null) {
+ if (s.equalsIgnoreCase("off")) {
+ disableAutoFlush();
+ } else if (!s.equalsIgnoreCase("on")) {
+ throw new LineSenderException("invalid auto_flush [value=").put(s).put(", allowed-values=[on, off]]");
+ }
+ }
+
+ if (view.has("max_name_len")) {
+ maxNameLength(wsInt(view, v, "max_name_len"));
+ }
+ if (view.has("max_background_drainers")) {
+ maxBackgroundDrainers(wsInt(view, v, "max_background_drainers"));
+ }
+ if (view.has("error_inbox_capacity")) {
+ errorInboxCapacity(wsInt(view, v, "error_inbox_capacity"));
+ }
+ if (view.has("connection_listener_inbox_capacity")) {
+ connectionListenerInboxCapacity(wsInt(view, v, "connection_listener_inbox_capacity"));
+ }
+ if (view.has("close_flush_timeout_millis")) {
+ closeFlushTimeoutMillis(wsLong(view, v, "close_flush_timeout_millis"));
+ }
+ if (view.has("durable_ack_keepalive_interval_millis")) {
+ durableAckKeepaliveIntervalMillis(wsLong(view, v, "durable_ack_keepalive_interval_millis"));
+ }
+ if (view.has("reconnect_max_duration_millis")) {
+ reconnectMaxDurationMillis(wsLong(view, v, "reconnect_max_duration_millis"));
+ }
+ if (view.has("reconnect_initial_backoff_millis")) {
+ reconnectInitialBackoffMillis(wsLong(view, v, "reconnect_initial_backoff_millis"));
+ }
+ if (view.has("reconnect_max_backoff_millis")) {
+ reconnectMaxBackoffMillis(wsLong(view, v, "reconnect_max_backoff_millis"));
+ }
+ if (view.has("sf_append_deadline_millis")) {
+ sfAppendDeadlineMillis(wsLong(view, v, "sf_append_deadline_millis"));
+ }
+ if (view.has("sf_max_bytes")) {
+ storeAndForwardMaxBytes(wsSize(view, v, "sf_max_bytes"));
+ }
+ if (view.has("sf_max_total_bytes")) {
+ storeAndForwardMaxTotalBytes(wsSize(view, v, "sf_max_total_bytes"));
+ }
+
+ s = view.getStr("sf_dir");
+ if (s != null) {
+ storeAndForwardDir(s);
+ }
+ s = view.getStr("sender_id");
+ if (s != null) {
+ senderId(s);
+ }
+ s = view.getStr("sf_durability");
+ if (s != null) {
+ v.clear();
+ v.put(s);
+ storeAndForwardDurability(parseDurabilityValue(v));
+ }
+ s = view.getStr("transaction");
+ if (s != null) {
+ if (s.equalsIgnoreCase("on")) {
+ transactional(true);
+ } else if (s.equalsIgnoreCase("off")) {
+ transactional(false);
+ } else {
+ throw new LineSenderException("invalid transaction [value=").put(s).put(", allowed-values=[on, off]]");
+ }
+ }
+ s = view.getStr("request_durable_ack");
+ if (s != null) {
+ if (s.equalsIgnoreCase("on")) {
+ requestDurableAck(true);
+ } else if (s.equalsIgnoreCase("off")) {
+ requestDurableAck(false);
+ } else {
+ throw new LineSenderException("invalid request_durable_ack [value=").put(s).put(", allowed-values=[on, off]]");
+ }
+ }
+ s = view.getStr("drain_orphans");
+ if (s != null) {
+ if (s.equalsIgnoreCase("on") || s.equalsIgnoreCase("true")) {
+ drainOrphans(true);
+ } else if (s.equalsIgnoreCase("off") || s.equalsIgnoreCase("false")) {
+ drainOrphans(false);
+ } else {
+ throw new LineSenderException("invalid drain_orphans [value=").put(s).put(", allowed-values=[on, off, true, false]]");
+ }
+ }
+ s = view.getStr("initial_connect_retry");
+ if (s != null) {
+ if (s.equalsIgnoreCase("on") || s.equalsIgnoreCase("true") || s.equalsIgnoreCase("sync")) {
+ initialConnectMode(InitialConnectMode.SYNC);
+ } else if (s.equalsIgnoreCase("off") || s.equalsIgnoreCase("false")) {
+ initialConnectMode(InitialConnectMode.OFF);
+ } else if (s.equalsIgnoreCase("async")) {
+ initialConnectMode(InitialConnectMode.ASYNC);
+ } else {
+ throw new LineSenderException("invalid initial_connect_retry [value=").put(s).put(", allowed-values=[on, off, true, false, sync, async]]");
+ }
+ }
+ return this;
+ } catch (IllegalArgumentException e) {
+ throw new LineSenderException(e.getMessage());
+ }
+ }
+
+ /**
+ * Validates the cross-key invariants of a WebSocket {@code ws}/{@code wss}
+ * config without constructing a Sender. Shared by {@link #fromConfigWebSocket}
+ * and the {@code QuestDB} facade's fail-fast build path. {@code tls} is true
+ * for the {@code wss} schema. Mirrors the decisions the fluent build path
+ * makes, so the ingress and egress sides reject the same config with the
+ * same message.
+ */
+ static void validateWsConfig(ConfigView view, boolean tls) {
+ view.getHostPorts("addr", DEFAULT_WEBSOCKET_PORT, (host, port) -> {
+ });
+ if (!view.has("addr")) {
+ throw new IllegalArgumentException("missing required key: addr");
+ }
+ String user = view.getStr("username");
+ String password = view.getStr("password");
+ String token = view.getStr("token");
+ // Basic auth needs both halves; reject either half alone with the same
+ // message the egress QwpQueryClient uses, so a shared ws/wss string
+ // fails identically on both sides.
+ if ((user == null) != (password == null)) {
+ throw new IllegalArgumentException("username and password must be provided together");
+ }
+ if (token != null && (user != null || password != null)) {
+ throw new IllegalArgumentException("cannot use both token and username/password authentication");
+ }
+ String tlsVerify = view.getStr("tls_verify");
+ String tlsRoots = view.getStr("tls_roots");
+ String tlsRootsPassword = view.getStr("tls_roots_password");
+ if (!tls && (tlsVerify != null || tlsRoots != null || tlsRootsPassword != null)) {
+ throw new IllegalArgumentException("tls_verify/tls_roots/tls_roots_password require the wss:: schema");
+ }
+ if ((tlsRoots == null) != (tlsRootsPassword == null)) {
+ throw new IllegalArgumentException("tls_roots and tls_roots_password must be provided together");
+ }
+ }
+
+ /**
+ * Fully validates a {@code ws}/{@code wss} connect string the same way
+ * {@link #build()} does, but without connecting: it parses every value
+ * through the real fluent setters and then runs {@link #configureDefaults}
+ * and {@link #validateParameters}, exactly the prefix {@code build()} runs
+ * before opening a socket. The {@code QuestDB} facade calls this so a
+ * malformed ingest config fails at its {@code build()} time even when the
+ * sender pool min is 0 and nothing connects. Ingress value keys are
+ * registry-{@code STRING}, so only this real parse -- not the typed
+ * {@link ConfigView} getters -- validates their values. Throws
+ * {@link LineSenderException} on any malformed key or value.
+ */
+ static void validateWsConfigString(CharSequence configurationString) {
+ LineSenderBuilder builder = new LineSenderBuilder();
+ builder.fromConfig(configurationString);
+ builder.configureDefaults();
+ builder.validateParameters();
+ }
+
+ private static int wsInt(ConfigView view, StringSink v, String key) {
+ v.clear();
+ v.put(view.getStr(key));
+ return parseIntValue(v, key);
+ }
+
+ private static long wsLong(ConfigView view, StringSink v, String key) {
+ v.clear();
+ v.put(view.getStr(key));
+ return parseLongValue(v, key);
+ }
+
+ private static long wsSize(ConfigView view, StringSink v, String key) {
+ v.clear();
+ v.put(view.getStr(key));
+ return parseSizeValue(v, key);
+ }
+
+ /**
+ * Snapshot of the WebSocket (QWP) config this builder applied, keyed by
+ * connect-string key name. Drives the per-key "honored" guard test --
+ * proves each ws/wss key read from a config string reaches the builder.
+ */
+ @TestOnly
+ public java.util.Map wsConfigSnapshotForTest() {
+ java.util.Map m = new java.util.HashMap<>();
+ m.put("auto_flush_rows", autoFlushRows);
+ m.put("auto_flush_bytes", autoFlushBytes);
+ m.put("auto_flush_interval", autoFlushIntervalMillis);
+ m.put("max_name_len", maxNameLength);
+ m.put("transaction", transactional);
+ m.put("request_durable_ack", requestDurableAck);
+ m.put("sender_id", senderId);
+ m.put("sf_dir", sfDir);
+ m.put("sf_max_bytes", sfMaxBytes);
+ m.put("sf_max_total_bytes", sfMaxTotalBytes);
+ m.put("sf_durability", sfDurability == null ? null : sfDurability.name());
+ m.put("sf_append_deadline_millis", sfAppendDeadlineMillis);
+ m.put("close_flush_timeout_millis", closeFlushTimeoutMillis);
+ m.put("durable_ack_keepalive_interval_millis", durableAckKeepaliveIntervalMillis);
+ m.put("initial_connect_retry", initialConnectMode == null ? null : initialConnectMode.name());
+ m.put("reconnect_max_duration_millis", reconnectMaxDurationMillis);
+ m.put("reconnect_initial_backoff_millis", reconnectInitialBackoffMillis);
+ m.put("reconnect_max_backoff_millis", reconnectMaxBackoffMillis);
+ m.put("drain_orphans", drainOrphans);
+ m.put("max_background_drainers", maxBackgroundDrainers);
+ m.put("error_inbox_capacity", errorInboxCapacity);
+ m.put("connection_listener_inbox_capacity", connectionListenerInboxCapacity);
+ m.put("token", httpToken);
+ m.put("auth_timeout_ms", authTimeoutMillis);
+ m.put("username", username);
+ m.put("password", password);
+ m.put("tls_verify", tlsValidationMode == null ? null : tlsValidationMode.name());
+ m.put("tls_roots", trustStorePath);
+ m.put("tls_roots_password", trustStorePassword == null ? null : new String(trustStorePassword));
+ return m;
+ }
+
/**
* Use HTTP protocol as transport.
*
@@ -3609,6 +3891,15 @@ private void validateParameters() {
if (autoFlushIntervalMillis == Integer.MAX_VALUE) {
throw new LineSenderException("disabling auto-flush is not supported for WebSocket protocol");
}
+ // The cursor send path does not fsync yet, so any sf_durability
+ // other than memory is rejected rather than silently downgraded.
+ // Validating it here (rather than at connect time) lets a
+ // no-connect config check reject it as a full build() does.
+ if (sfDurability != SfDurability.MEMORY) {
+ throw new LineSenderException(
+ "sf_durability=" + sfDurability.name().toLowerCase()
+ + " is not yet supported (deferred follow-up; use sf_durability=memory)");
+ }
} else {
throw new LineSenderException("unsupported protocol ")
.put("[protocol=").put(protocol).put("]");
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 4051e1ac..1706401e 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
@@ -30,11 +30,11 @@
import io.questdb.client.cutlass.http.client.WebSocketClientFactory;
import io.questdb.client.cutlass.http.client.WebSocketFrameHandler;
import io.questdb.client.cutlass.qwp.protocol.QwpConstants;
-import io.questdb.client.impl.ConfStringParser;
+import io.questdb.client.impl.ConfigString;
+import io.questdb.client.impl.ConfigView;
import io.questdb.client.std.Chars;
import io.questdb.client.std.QuietCloseable;
import io.questdb.client.std.Zstd;
-import io.questdb.client.std.str.StringSink;
import org.jetbrains.annotations.TestOnly;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -205,7 +205,6 @@ public class QwpQueryClient implements QuietCloseable {
// user thread's write is visible to a concurrent cancel caller; 64-bit writes
// are atomic under {@code volatile long}.
private volatile long currentRequestId = -1L;
- private String endpointPath = DEFAULT_ENDPOINT_PATH;
// True by default: on transport failure during execute(), reconnect to
// another endpoint and replay the query. Callers that prefer to see the
// error themselves opt out via {@code failover=off} in the connection
@@ -318,15 +317,13 @@ private QwpQueryClient(String host, int port) {
* {@link #execute}, reconnect to another endpoint and re-submit the query.
* The user handler sees {@link QwpColumnBatchHandler#onFailoverReset} before
* replayed batches begin arriving (batch_seq restarts at 0 on the new node).
- * {@code path=/read/v1} -- egress endpoint. Default {@value #DEFAULT_ENDPOINT_PATH}.
- * {@code auth=} -- sent verbatim as the HTTP {@code Authorization} header during the upgrade handshake.
- * Mutually exclusive with {@code username}/{@code password} and {@code token}.
- * {@code username=;password=} -- HTTP Basic authentication. Server verifies the credentials
+ * {@code username=;password=} -- HTTP Basic authentication. The client builds the
+ * {@code Authorization: Basic } header from these. Server verifies the credentials
* against the same user store the Postgres wire protocol uses, so a user created via
* {@code CREATE USER ... WITH PASSWORD ...} can log in unchanged.
- * Both keys must be present together; mutually exclusive with {@code auth} and {@code token}.
+ * Both keys must be present together; mutually exclusive with {@code token}.
* {@code token=} -- HTTP Bearer authentication with an OIDC access token (sent as
- * {@code Authorization: Bearer }). Mutually exclusive with {@code auth} and
+ * {@code Authorization: Bearer }). Mutually exclusive with
* {@code username}/{@code password}.
* {@code client_id=} -- sent as the {@code X-QWP-Client-Id} header.
* {@code buffer_pool_size=N} -- depth of the I/O thread's batch buffer pool. Default 4.
@@ -355,350 +352,194 @@ private QwpQueryClient(String host, int port) {
* Examples:
*
* ws::addr=localhost:9000;
- * ws::addr=db.internal:9000;path=/read/v1;auth=Bearer abc123;client_id=dashboard/2.0;
+ * ws::addr=db.internal:9000;token=abc123;client_id=dashboard/2.0;
* ws::addr=db-a:9000,db-b:9000,db-c:9000;target=primary;failover=on;
*
*/
public static QwpQueryClient fromConfig(CharSequence configurationString) {
- if (configurationString == null || configurationString.length() == 0) {
- throw new IllegalArgumentException("configuration string cannot be empty");
- }
- StringSink sink = new StringSink();
- int pos = ConfStringParser.of(configurationString, sink);
- if (pos < 0) {
- throw new IllegalArgumentException("invalid configuration string: " + sink);
- }
+ ConfigString cs = ConfigString.parse(configurationString);
boolean tls;
- if (Chars.equals("ws", sink)) {
+ if ("ws".equals(cs.schema())) {
tls = false;
- } else if (Chars.equals("wss", sink)) {
+ } else if ("wss".equals(cs.schema())) {
tls = true;
} else {
throw new IllegalArgumentException(
- "unsupported schema [schema=" + sink + ", supported-schemas=[ws, wss]]");
+ "unsupported schema [schema=" + cs.schema() + ", supported-schemas=[ws, wss]]");
}
+ ConfigView view = new ConfigView(cs);
+ validateConfig(view, tls);
List parsedEndpoints = new ArrayList<>();
- String path = DEFAULT_ENDPOINT_PATH;
- String target = TARGET_ANY;
- Boolean failover = null;
- Integer failoverMaxAttempts = null;
- Long failoverBackoffInitialMs = null;
- Long failoverBackoffMaxMs = null;
- Long failoverMaxDurationMs = null;
- Long authTimeoutMs = null;
- Long initialCredit = null;
- String auth = null;
- String username = null;
- String password = null;
- String token = null;
- String cid = null;
- int poolSize = DEFAULT_IO_BUFFER_POOL_SIZE;
- // Default matches the field initializer in QwpQueryClient: raw wire,
- // zstd opt-in.
- String compression = "raw";
- int compressionLevel = 1;
- int maxBatchRows = 0; // 0 = omit header, server uses its default
- // TLS validation mode: null means "unset in config". Explicit values kick in only when tls is true.
+ view.getHostPorts("addr", DEFAULT_WS_PORT, (h, p) -> parsedEndpoints.add(new Endpoint(h, p)));
+
+ String target = view.getEnum("target");
+ if (target == null) {
+ target = TARGET_ANY;
+ }
+ Boolean failover = view.has("failover") ? view.getBoolOnOff("failover", false) : null;
+ Integer failoverMaxAttempts = view.has("failover_max_attempts")
+ ? view.getInt("failover_max_attempts", 0) : null;
+ Long failoverBackoffInitialMs = view.has("failover_backoff_initial_ms")
+ ? view.getLong("failover_backoff_initial_ms", 0) : null;
+ Long failoverBackoffMaxMs = view.has("failover_backoff_max_ms")
+ ? view.getLong("failover_backoff_max_ms", 0) : null;
+ Long failoverMaxDurationMs = view.has("failover_max_duration_ms")
+ ? view.getLong("failover_max_duration_ms", 0) : null;
+ Long authTimeoutMs = view.has("auth_timeout_ms") ? view.getLong("auth_timeout_ms", 0) : null;
+ Long initialCredit = view.has("initial_credit") ? view.getLong("initial_credit", 0) : null;
+ int poolSize = view.getInt("buffer_pool_size", DEFAULT_IO_BUFFER_POOL_SIZE);
+ String compression = view.getEnum("compression");
+ if (compression == null) {
+ compression = "raw";
+ }
+ int compressionLevel = view.getInt("compression_level", 1);
+ int maxBatchRows = view.getInt("max_batch_rows", 0);
+ String username = view.getStr("username");
+ String password = view.getStr("password");
+ String token = view.getStr("token");
+ String cid = view.getStr("client_id");
+ String zone = view.getStr("zone");
+ String tlsRoots = view.getStr("tls_roots");
+ String tlsRootsPassword = view.getStr("tls_roots_password");
Integer tlsValidation = null;
- String tlsRoots = null;
- String tlsRootsPassword = null;
- String zone = null;
-
- while (ConfStringParser.hasNext(configurationString, pos)) {
- pos = ConfStringParser.nextKey(configurationString, pos, sink);
- if (pos < 0) {
- throw new IllegalArgumentException("invalid configuration string [error=" + sink + "]");
+ String tlsVerify = view.getEnum("tls_verify");
+ if ("on".equals(tlsVerify)) {
+ tlsValidation = ClientTlsConfiguration.TLS_VALIDATION_MODE_FULL;
+ } else if ("unsafe_off".equals(tlsVerify)) {
+ tlsValidation = ClientTlsConfiguration.TLS_VALIDATION_MODE_NONE;
+ }
+ boolean hasBasic = username != null || password != null;
+ Endpoint first = parsedEndpoints.get(0);
+ QwpQueryClient client = new QwpQueryClient(first.host, first.port);
+ // The constructor allocated native scratch (bindValues); close it if a
+ // setter below rejects its input so a config error cannot leak it.
+ // validateConfig above already rejects every value these setters check,
+ // so this is a safety net against future drift, not a reachable path today.
+ try {
+ for (int i = 1; i < parsedEndpoints.size(); i++) {
+ client.endpoints.add(parsedEndpoints.get(i));
}
- String key = sink.toString();
- pos = ConfStringParser.value(configurationString, pos, sink);
- if (pos < 0) {
- throw new IllegalArgumentException("invalid configuration string [error=" + sink + "]");
+ client.withTarget(target);
+ if (failover != null) {
+ client.withFailover(failover);
}
- String value = sink.toString();
- switch (key) {
- case "addr":
- // failover.md §1: comma syntax and repeated addr= keys must
- // accumulate. parseEndpointList rejects empty entries.
- parsedEndpoints.addAll(parseEndpointList(value));
- break;
- case "target":
- if (!TARGET_ANY.equals(value) && !TARGET_PRIMARY.equals(value) && !TARGET_REPLICA.equals(value)) {
- throw new IllegalArgumentException(
- "invalid target: " + value + " (expected any, primary, or replica)");
- }
- target = value;
- break;
- case "failover":
- if ("on".equals(value)) {
- failover = Boolean.TRUE;
- } else if ("off".equals(value)) {
- failover = Boolean.FALSE;
- } else {
- throw new IllegalArgumentException("invalid failover: " + value + " (expected on or off)");
- }
- break;
- case "failover_max_attempts":
- try {
- failoverMaxAttempts = Integer.parseInt(value);
- } catch (NumberFormatException e) {
- throw new IllegalArgumentException("invalid failover_max_attempts: " + value);
- }
- if (failoverMaxAttempts < 1) {
- throw new IllegalArgumentException("failover_max_attempts must be >= 1");
- }
- break;
- case "failover_backoff_initial_ms":
- try {
- failoverBackoffInitialMs = Long.parseLong(value);
- } catch (NumberFormatException e) {
- throw new IllegalArgumentException("invalid failover_backoff_initial_ms: " + value);
- }
- if (failoverBackoffInitialMs < 0L) {
- throw new IllegalArgumentException("failover_backoff_initial_ms must be >= 0");
- }
- break;
- case "failover_backoff_max_ms":
- try {
- failoverBackoffMaxMs = Long.parseLong(value);
- } catch (NumberFormatException e) {
- throw new IllegalArgumentException("invalid failover_backoff_max_ms: " + value);
- }
- if (failoverBackoffMaxMs < 0L) {
- throw new IllegalArgumentException("failover_backoff_max_ms must be >= 0");
- }
- break;
- case "failover_max_duration_ms": {
- long parsed;
- try {
- parsed = Long.parseLong(value);
- } catch (NumberFormatException e) {
- throw new IllegalArgumentException("invalid failover_max_duration_ms: " + value);
- }
- if (parsed < 0L) {
- throw new IllegalArgumentException("failover_max_duration_ms must be >= 0");
- }
- failoverMaxDurationMs = parsed;
- break;
- }
- case "auth_timeout_ms": {
- long parsed;
- try {
- parsed = Long.parseLong(value);
- } catch (NumberFormatException e) {
- throw new IllegalArgumentException("invalid auth_timeout_ms: " + value);
- }
- if (parsed <= 0L) {
- throw new IllegalArgumentException("auth_timeout_ms must be > 0");
- }
- authTimeoutMs = parsed;
- break;
+ if (failoverMaxAttempts != null) {
+ client.withFailoverMaxAttempts(failoverMaxAttempts);
+ }
+ if (failoverBackoffInitialMs != null || failoverBackoffMaxMs != null) {
+ long initial = failoverBackoffInitialMs != null
+ ? failoverBackoffInitialMs
+ : DEFAULT_FAILOVER_INITIAL_BACKOFF_MS;
+ long max = failoverBackoffMaxMs != null
+ ? failoverBackoffMaxMs
+ : Math.max(initial, DEFAULT_FAILOVER_MAX_BACKOFF_MS);
+ client.withFailoverBackoff(initial, max);
+ }
+ if (failoverMaxDurationMs != null) {
+ client.withFailoverMaxDuration(failoverMaxDurationMs);
+ }
+ if (authTimeoutMs != null) {
+ client.withAuthTimeout(authTimeoutMs);
+ }
+ if (initialCredit != null) {
+ client.withInitialCredit(initialCredit);
+ }
+ client.withBufferPoolSize(poolSize);
+ client.withCompression(compression, compressionLevel);
+ if (tls) {
+ if (tlsRoots != null) {
+ client.withTrustStore(tlsRoots, tlsRootsPassword.toCharArray());
+ } else if (tlsValidation != null && tlsValidation == ClientTlsConfiguration.TLS_VALIDATION_MODE_NONE) {
+ client.withInsecureTls();
+ } else {
+ client.withTls();
}
- case "path":
- path = value;
- break;
- case "auth":
- auth = value;
- break;
- case "username":
- username = value;
- break;
- case "password":
- password = value;
- break;
- case "token":
- token = value;
- break;
- case "client_id":
- cid = value;
- break;
- case "buffer_pool_size":
- try {
- poolSize = Integer.parseInt(value);
- } catch (NumberFormatException e) {
- throw new IllegalArgumentException("invalid buffer_pool_size: " + value);
- }
- if (poolSize < 1) {
- throw new IllegalArgumentException("buffer_pool_size must be >= 1");
- }
- break;
- case "initial_credit":
- try {
- initialCredit = Long.parseLong(value);
- } catch (NumberFormatException e) {
- throw new IllegalArgumentException("invalid initial_credit: " + value);
- }
- if (initialCredit < 0L) {
- throw new IllegalArgumentException("initial_credit must be >= 0");
- }
- break;
- case "compression":
- if (!"zstd".equals(value) && !"raw".equals(value) && !"auto".equals(value)) {
- throw new IllegalArgumentException(
- "unsupported compression: " + value + " (expected zstd, raw, or auto)");
- }
- compression = value;
- break;
- case "compression_level":
- try {
- compressionLevel = Integer.parseInt(value);
- } catch (NumberFormatException e) {
- throw new IllegalArgumentException("invalid compression_level: " + value);
- }
- if (compressionLevel < 1 || compressionLevel > 22) {
- throw new IllegalArgumentException("compression_level must be in [1, 22]");
- }
- break;
- case "max_batch_rows":
- try {
- maxBatchRows = Integer.parseInt(value);
- } catch (NumberFormatException e) {
- throw new IllegalArgumentException("invalid max_batch_rows: " + value);
- }
- if (maxBatchRows < 1 || maxBatchRows > MAX_BATCH_ROWS_UPPER_BOUND) {
- throw new IllegalArgumentException(
- "max_batch_rows must be in [1, " + MAX_BATCH_ROWS_UPPER_BOUND + "]");
- }
- break;
- case "tls_verify":
- if ("on".equals(value)) {
- tlsValidation = ClientTlsConfiguration.TLS_VALIDATION_MODE_FULL;
- } else if ("unsafe_off".equals(value)) {
- tlsValidation = ClientTlsConfiguration.TLS_VALIDATION_MODE_NONE;
- } else {
- throw new IllegalArgumentException(
- "invalid tls_verify: " + value + " (expected on or unsafe_off)");
- }
- break;
- case "tls_roots":
- tlsRoots = value;
- break;
- case "tls_roots_password":
- tlsRootsPassword = value;
- break;
- case "zone":
- zone = value;
- break;
- // connect-string.md "Auto-flushing", "Buffer sizing", "Store-and-forward",
- // "Durable ACK", "Reconnect and failover", "Error handling", and
- // legacy ILP aliases: these keys configure the Sender (ingress) only.
- // The QwpQueryClient silently consumes them so the same connect string
- // can be shared between the Sender and the QwpQueryClient without an
- // "unknown configuration key" error. Validation and effect are the
- // Sender parser's job; the egress parser does not interpret the value.
- case "auto_flush":
- case "auto_flush_bytes":
- case "auto_flush_interval":
- case "auto_flush_rows":
- case "close_flush_timeout_millis":
- case "connection_listener_inbox_capacity":
- case "drain_orphans":
- case "durable_ack_keepalive_interval_millis":
- case "error_inbox_capacity":
- case "in_flight_window":
- case "init_buf_size":
- case "initial_connect_retry":
- case "max_background_drainers":
- case "max_buf_size":
- case "max_datagram_size":
- case "max_name_len":
- case "multicast_ttl":
- case "pass":
- case "protocol_version":
- case "reconnect_initial_backoff_millis":
- case "reconnect_max_backoff_millis":
- case "reconnect_max_duration_millis":
- case "request_durable_ack":
- case "request_min_throughput":
- case "request_timeout":
- case "retry_timeout":
- case "sender_id":
- case "sf_append_deadline_millis":
- case "sf_dir":
- case "sf_durability":
- case "sf_max_bytes":
- case "sf_max_total_bytes":
- case "user":
- break;
- default:
- throw new IllegalArgumentException("unknown configuration key: " + key);
}
+ if (hasBasic) client.withBasicAuth(username, password);
+ if (token != null) client.withBearerToken(token);
+ if (cid != null) client.withClientId(cid);
+ if (maxBatchRows > 0) client.withMaxBatchRows(maxBatchRows);
+ if (zone != null) client.withZone(zone);
+ return client;
+ } catch (RuntimeException e) {
+ client.close();
+ throw e;
}
- if (parsedEndpoints.isEmpty()) {
+ }
+
+ /**
+ * Validates the cross-key invariants of an egress {@code ws}/{@code wss}
+ * config without constructing a client. Shared by {@link #fromConfig} and
+ * the {@code QuestDB} facade's fail-fast build path. {@code tls} is true for
+ * the {@code wss} schema.
+ */
+ public static void validateConfig(ConfigView view, boolean tls) {
+ view.getHostPorts("addr", DEFAULT_WS_PORT, (h, p) -> {
+ });
+ if (!view.has("addr")) {
throw new IllegalArgumentException("missing required key: addr");
}
+ // Trigger range/enum validation of every typed value.
+ view.getEnum("target");
+ view.getEnum("compression");
+ view.getEnum("tls_verify");
+ view.getBoolOnOff("failover", false);
+ view.getInt("failover_max_attempts", 0);
+ view.getInt("max_batch_rows", 0);
+ view.getInt("buffer_pool_size", 0);
+ view.getInt("compression_level", 0);
+ boolean hasBackoffInitial = view.has("failover_backoff_initial_ms");
+ boolean hasBackoffMax = view.has("failover_backoff_max_ms");
+ // getLong also range-validates the value; call it even for an absent key.
+ long backoffInitial = view.getLong("failover_backoff_initial_ms", -1);
+ long backoffMax = view.getLong("failover_backoff_max_ms", -1);
+ view.getLong("failover_max_duration_ms", -1);
+ view.getLong("initial_credit", -1);
+ view.getLong("auth_timeout_ms", -1);
+ String username = view.getStr("username");
+ String password = view.getStr("password");
+ String token = view.getStr("token");
+ // A present-but-blank credential is rejected up front, matching the
+ // ingress Sender, so a shared ws/wss string fails the same way on both
+ // sides and the client never builds an empty Authorization header.
+ if (username != null && Chars.isBlank(username)) {
+ throw new IllegalArgumentException("username cannot be empty nor null");
+ }
+ if (password != null && Chars.isBlank(password)) {
+ throw new IllegalArgumentException("password cannot be empty nor null");
+ }
+ if (token != null && Chars.isBlank(token)) {
+ throw new IllegalArgumentException("token cannot be empty nor null");
+ }
boolean hasBasic = username != null || password != null;
if (hasBasic && (username == null || password == null)) {
- throw new IllegalArgumentException("both username and password must be provided together");
+ throw new IllegalArgumentException("username and password must be provided together");
}
- int authModesSet = (auth != null ? 1 : 0) + (hasBasic ? 1 : 0) + (token != null ? 1 : 0);
- if (authModesSet > 1) {
- throw new IllegalArgumentException(
- "auth, username/password, and token are mutually exclusive");
+ if (hasBasic && token != null) {
+ throw new IllegalArgumentException("cannot use both token and username/password authentication");
}
- if (!tls && (tlsValidation != null || tlsRoots != null || tlsRootsPassword != null)) {
+ String tlsVerify = view.getStr("tls_verify");
+ String tlsRoots = view.getStr("tls_roots");
+ String tlsRootsPassword = view.getStr("tls_roots_password");
+ if (!tls && (tlsVerify != null || tlsRoots != null || tlsRootsPassword != null)) {
throw new IllegalArgumentException(
"tls_verify/tls_roots/tls_roots_password require the wss:: schema");
}
if ((tlsRoots == null) != (tlsRootsPassword == null)) {
- throw new IllegalArgumentException(
- "tls_roots and tls_roots_password must be provided together");
- }
- if (failoverBackoffInitialMs != null
- && failoverBackoffMaxMs != null
- && failoverBackoffMaxMs < failoverBackoffInitialMs) {
- throw new IllegalArgumentException(
- "failover_backoff_max_ms must be >= failover_backoff_initial_ms");
- }
- Endpoint first = parsedEndpoints.get(0);
- QwpQueryClient client = new QwpQueryClient(first.host, first.port);
- for (int i = 1; i < parsedEndpoints.size(); i++) {
- client.endpoints.add(parsedEndpoints.get(i));
- }
- client.withTarget(target);
- if (failover != null) {
- client.withFailover(failover);
- }
- if (failoverMaxAttempts != null) {
- client.withFailoverMaxAttempts(failoverMaxAttempts);
- }
- if (failoverBackoffInitialMs != null || failoverBackoffMaxMs != null) {
- long initial = failoverBackoffInitialMs != null
- ? failoverBackoffInitialMs
- : DEFAULT_FAILOVER_INITIAL_BACKOFF_MS;
- long max = failoverBackoffMaxMs != null
- ? failoverBackoffMaxMs
- : Math.max(initial, DEFAULT_FAILOVER_MAX_BACKOFF_MS);
- client.withFailoverBackoff(initial, max);
- }
- if (failoverMaxDurationMs != null) {
- client.withFailoverMaxDuration(failoverMaxDurationMs);
- }
- if (authTimeoutMs != null) {
- client.withAuthTimeout(authTimeoutMs);
- }
- if (initialCredit != null) {
- client.withInitialCredit(initialCredit);
- }
- client.withEndpointPath(path);
- client.withBufferPoolSize(poolSize);
- client.withCompression(compression, compressionLevel);
- if (tls) {
- if (tlsRoots != null) {
- client.withTrustStore(tlsRoots, tlsRootsPassword.toCharArray());
- } else if (tlsValidation != null && tlsValidation == ClientTlsConfiguration.TLS_VALIDATION_MODE_NONE) {
- client.withInsecureTls();
- } else {
- client.withTls();
+ throw new IllegalArgumentException("tls_roots and tls_roots_password must be provided together");
+ }
+ // Mirror fromConfig's effective values: a missing bound takes its
+ // default, so the ordering is enforced even when only one key is set
+ // (e.g. failover_backoff_max_ms alone, below the default initial backoff).
+ if (hasBackoffInitial || hasBackoffMax) {
+ long effectiveInitial = hasBackoffInitial ? backoffInitial : DEFAULT_FAILOVER_INITIAL_BACKOFF_MS;
+ long effectiveMax = hasBackoffMax ? backoffMax : Math.max(effectiveInitial, DEFAULT_FAILOVER_MAX_BACKOFF_MS);
+ if (effectiveMax < effectiveInitial) {
+ throw new IllegalArgumentException(
+ "failover_backoff_max_ms must be >= failover_backoff_initial_ms");
}
}
- if (auth != null) client.withAuthorization(auth);
- if (hasBasic) client.withBasicAuth(username, password);
- if (token != null) client.withBearerToken(token);
- if (cid != null) client.withClientId(cid);
- if (maxBatchRows > 0) client.withMaxBatchRows(maxBatchRows);
- if (zone != null) client.withZone(zone);
- return client;
}
/**
@@ -994,6 +835,45 @@ public int getCompressionLevelForTest() {
return compressionLevel;
}
+ /**
+ * Test-only hook: the synthesized {@code Authorization} header value
+ * ({@code Basic ...} or {@code Bearer ...}), or null when no credentials
+ * were configured.
+ */
+ @TestOnly
+ public String getAuthorizationHeaderForTest() {
+ return authorizationHeader;
+ }
+
+ /**
+ * Snapshot of the egress config this client applied, keyed by connect-string
+ * key name. Drives the per-key "honored" guard test -- proves each egress key
+ * read from a config string reaches the client.
+ */
+ @TestOnly
+ public java.util.Map configSnapshotForTest() {
+ java.util.Map m = new java.util.HashMap<>();
+ m.put("target", target);
+ m.put("failover", failoverEnabled);
+ m.put("failover_max_attempts", failoverMaxAttempts);
+ m.put("failover_backoff_initial_ms", failoverInitialBackoffMs);
+ m.put("failover_backoff_max_ms", failoverMaxBackoffMs);
+ m.put("failover_max_duration_ms", failoverMaxDurationMs);
+ m.put("max_batch_rows", maxBatchRows);
+ m.put("initial_credit", initialCreditBytes);
+ m.put("buffer_pool_size", bufferPoolSize);
+ m.put("compression", compressionPreference);
+ m.put("compression_level", compressionLevel);
+ m.put("client_id", clientId);
+ m.put("zone", clientZone);
+ m.put("auth_timeout_ms", authTimeoutMs);
+ m.put("authorization_header", authorizationHeader);
+ m.put("tls_verify", tlsValidationMode);
+ m.put("tls_roots", trustStorePath);
+ m.put("tls_roots_password", trustStorePassword == null ? null : new String(trustStorePassword));
+ return m;
+ }
+
/**
* Returns the current compression preference: one of {@code raw} (the
* library default, no compression), {@code zstd} (demand zstd), or
@@ -1114,11 +994,6 @@ public QwpQueryClient withAuthTimeout(long authTimeoutMs) {
return this;
}
- public void withAuthorization(String authorizationHeader) {
- checkPreConnect("withAuthorization");
- this.authorizationHeader = authorizationHeader;
- }
-
/**
* Configures HTTP Basic authentication for the WebSocket upgrade request.
* The server verifies the credentials against the same user store the
@@ -1193,11 +1068,6 @@ public void withCompression(String preference, int level) {
this.compressionLevel = level;
}
- public void withEndpointPath(String endpointPath) {
- checkPreConnect("withEndpointPath");
- this.endpointPath = endpointPath;
- }
-
/**
* Programmatic equivalent of the {@code failover=} connection-string key.
* Default is {@code true}: transport failures during {@link #execute} are
@@ -1402,96 +1272,6 @@ private static boolean matchesTarget(byte role, String target) {
return true;
}
- /**
- * Parses an {@code addr=} value that may be a single {@code host[:port]}
- * or a comma-separated list of such entries. A single entry without a
- * port falls back to {@link #DEFAULT_WS_PORT}. The port (when present)
- * must be in {@code [1, 65535]}.
- *
- * IPv6 addresses must be wrapped in brackets when carrying a port, per
- * RFC 3986: {@code [::1]:9000}, {@code [fe80::1]}. An unbracketed entry
- * containing more than one colon is treated as a bare IPv6 host with
- * the default port (no syntactic way to distinguish {@code host:port}
- * from a bare IPv6 address otherwise; users wanting a custom port on
- * IPv6 must bracket).
- */
- private static List parseEndpointList(String value) {
- List list = new ArrayList<>();
- int start = 0;
- int len = value.length();
- for (int i = 0; i <= len; i++) {
- if (i == len || value.charAt(i) == ',') {
- if (i == start) {
- throw new IllegalArgumentException("empty addr entry");
- }
- String entry = value.substring(start, i).trim();
- if (entry.isEmpty()) {
- throw new IllegalArgumentException("empty addr entry");
- }
- String host;
- int port;
- if (entry.charAt(0) == '[') {
- // Bracketed IPv6: [host] or [host]:port.
- int closeBracket = entry.indexOf(']');
- if (closeBracket < 0) {
- throw new IllegalArgumentException(
- "missing closing ']' in IPv6 addr entry: " + entry);
- }
- host = entry.substring(1, closeBracket);
- if (closeBracket == entry.length() - 1) {
- port = DEFAULT_WS_PORT;
- } else if (entry.charAt(closeBracket + 1) != ':') {
- throw new IllegalArgumentException(
- "expected ':' after ']' in IPv6 addr entry: " + entry);
- } else {
- port = parsePort(entry.substring(closeBracket + 2), entry);
- }
- } else if (entry.indexOf(':') != entry.lastIndexOf(':')) {
- // Multi-colon, unbracketed: treat as bare IPv6 host with
- // the default port. Custom port on IPv6 requires brackets.
- host = entry;
- port = DEFAULT_WS_PORT;
- } else {
- int colon = entry.indexOf(':');
- if (colon < 0) {
- host = entry;
- port = DEFAULT_WS_PORT;
- } else {
- host = entry.substring(0, colon).trim();
- port = parsePort(entry.substring(colon + 1), entry);
- }
- }
- if (host.isEmpty()) {
- throw new IllegalArgumentException("empty host in addr entry: " + entry);
- }
- list.add(new Endpoint(host, port));
- start = i + 1;
- }
- }
- return list;
- }
-
- /**
- * Parses {@code portStr} into a TCP port in the inclusive range
- * {@code [1, 65535]}. Surrounding whitespace is tolerated so config
- * strings hand-edited around the {@code :} don't surface as opaque
- * "invalid port" errors. {@code entry} is the full
- * {@code host[:port]} fragment, used only for the error message.
- */
- private static int parsePort(String portStr, String entry) {
- int port;
- try {
- port = Integer.parseInt(portStr.trim());
- } catch (NumberFormatException e) {
- throw new IllegalArgumentException("invalid port in addr: " + entry);
- }
- if (port < 1 || port > 65535) {
- throw new IllegalArgumentException(
- "port out of range in addr: " + entry + " (must be 1-65535)");
- }
- return port;
- }
-
/**
* Builds the {@code X-QWP-Accept-Encoding} header value from the user's
* preference. {@code raw} (the library default) omits the header entirely
@@ -1968,7 +1748,7 @@ private void runUpgradeWithTimeout(Endpoint ep) {
int timeoutMs = (int) Math.min(authTimeoutMs, Integer.MAX_VALUE);
try {
webSocketClient.connect(ep.host, ep.port);
- webSocketClient.upgrade(endpointPath, timeoutMs, authorizationHeader);
+ webSocketClient.upgrade(DEFAULT_ENDPOINT_PATH, timeoutMs, authorizationHeader);
} catch (HttpClientException ex) {
if (ex.isTimeout()) {
HttpClientException timeout = new HttpClientException("WebSocket upgrade to ")
diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketEncoder.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketEncoder.java
index 85db3f0d..ced1a1b5 100644
--- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketEncoder.java
+++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketEncoder.java
@@ -39,22 +39,23 @@ public class QwpWebSocketEncoder implements QuietCloseable {
private final QwpColumnWriter columnWriter = new QwpColumnWriter();
private NativeBufferWriter buffer;
- private byte flags;
+ // QWP ingress always advertises Gorilla timestamp encoding. The column
+ // writer still emits a per-column encoding byte and falls back to raw
+ // values when delta-of-delta overflows int32.
+ private byte flags = FLAG_GORILLA;
private int payloadStart;
private byte version = VERSION;
public QwpWebSocketEncoder() {
this.buffer = new NativeBufferWriter();
- this.flags = 0;
}
public QwpWebSocketEncoder(int bufferSize) {
this.buffer = new NativeBufferWriter(bufferSize);
- this.flags = 0;
}
public void addTable(QwpTableBuffer tableBuffer) {
- columnWriter.encodeTable(tableBuffer, true, isGorillaEnabled());
+ columnWriter.encodeTable(tableBuffer, true, true);
}
public void beginMessage(
@@ -94,7 +95,7 @@ public int encode(QwpTableBuffer tableBuffer) {
writeHeader(1, 0);
int payloadStart = buffer.getPosition();
columnWriter.setBuffer(buffer);
- columnWriter.encodeTable(tableBuffer, false, isGorillaEnabled());
+ columnWriter.encodeTable(tableBuffer, false, true);
int payloadLength = buffer.getPosition() - payloadStart;
buffer.patchInt(8, payloadLength);
return buffer.getPosition();
@@ -121,10 +122,6 @@ public QwpBufferWriter getBuffer() {
return buffer;
}
- public boolean isGorillaEnabled() {
- return (flags & FLAG_GORILLA) != 0;
- }
-
public void setDeferCommit(boolean defer) {
if (defer) {
flags |= FLAG_DEFER_COMMIT;
@@ -133,14 +130,6 @@ public void setDeferCommit(boolean defer) {
}
}
- public void setGorillaEnabled(boolean enabled) {
- if (enabled) {
- flags |= FLAG_GORILLA;
- } else {
- flags &= ~FLAG_GORILLA;
- }
- }
-
public void setVersion(byte version) {
this.version = version;
}
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 6735e7ac..9b9cc45d 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
@@ -218,7 +218,6 @@ public class QwpWebSocketSender implements Sender {
private SenderErrorHandler errorHandler = DefaultSenderErrorHandler.INSTANCE;
private int errorInboxCapacity = SenderErrorDispatcher.DEFAULT_CAPACITY;
private long firstPendingRowTimeNanos;
- private boolean gorillaEnabled = true;
private boolean hasDeferredMessages;
// Stickys true once any successful connect has happened. Drives the
// CONNECTED-vs-RECONNECTED-vs-FAILED_OVER classification at the success
@@ -540,7 +539,7 @@ public static QwpWebSocketSender connect(
closeFlushTimeoutMillis, reconnectMaxDurationMillis,
reconnectInitialBackoffMillis, reconnectMaxBackoffMillis,
initialConnectMode, errorHandler, errorInboxCapacity,
- durableAckKeepaliveIntervalMillis, DEFAULT_AUTH_TIMEOUT_MS, true);
+ durableAckKeepaliveIntervalMillis, DEFAULT_AUTH_TIMEOUT_MS);
}
/**
@@ -569,8 +568,7 @@ public static QwpWebSocketSender connect(
SenderErrorHandler errorHandler,
int errorInboxCapacity,
long durableAckKeepaliveIntervalMillis,
- long authTimeoutMs,
- boolean gorillaEnabled
+ long authTimeoutMs
) {
return connect(endpoints, tlsConfig, autoFlushRows, autoFlushBytes,
autoFlushIntervalNanos, authorizationHeader,
@@ -578,7 +576,7 @@ public static QwpWebSocketSender connect(
closeFlushTimeoutMillis, reconnectMaxDurationMillis,
reconnectInitialBackoffMillis, reconnectMaxBackoffMillis,
initialConnectMode, errorHandler, errorInboxCapacity,
- durableAckKeepaliveIntervalMillis, authTimeoutMs, gorillaEnabled,
+ durableAckKeepaliveIntervalMillis, authTimeoutMs,
null, SenderConnectionDispatcher.DEFAULT_CAPACITY);
}
@@ -604,7 +602,6 @@ public static QwpWebSocketSender connect(
int errorInboxCapacity,
long durableAckKeepaliveIntervalMillis,
long authTimeoutMs,
- boolean gorillaEnabled,
SenderConnectionListener connectionListener,
int connectionListenerInboxCapacity
) {
@@ -616,8 +613,6 @@ public static QwpWebSocketSender connect(
try {
sender.requestDurableAck = requestDurableAck;
sender.authTimeoutMs = authTimeoutMs;
- sender.gorillaEnabled = gorillaEnabled;
- sender.encoder.setGorillaEnabled(gorillaEnabled);
sender.closeFlushTimeoutMillis = closeFlushTimeoutMillis;
sender.reconnectMaxDurationMillis = reconnectMaxDurationMillis;
sender.reconnectInitialBackoffMillis = reconnectInitialBackoffMillis;
@@ -1838,13 +1833,6 @@ public QwpWebSocketSender ipv4Column(CharSequence columnName, CharSequence addre
return ipv4Column(columnName, packed);
}
- /**
- * Returns whether Gorilla encoding is enabled.
- */
- public boolean isGorillaEnabled() {
- return gorillaEnabled;
- }
-
/**
* Adds a LONG256 column value to the current row.
*
@@ -2076,14 +2064,6 @@ public void setErrorInboxCapacity(int capacity) {
this.errorInboxCapacity = capacity;
}
- /**
- * Sets whether to use Gorilla timestamp encoding.
- */
- public void setGorillaEnabled(boolean enabled) {
- this.gorillaEnabled = enabled;
- this.encoder.setGorillaEnabled(enabled);
- }
-
public void setTransactional(boolean transactional) {
this.transactional = transactional;
}
diff --git a/core/src/main/java/io/questdb/client/impl/ConfigSchema.java b/core/src/main/java/io/questdb/client/impl/ConfigSchema.java
new file mode 100644
index 00000000..b36f3207
--- /dev/null
+++ b/core/src/main/java/io/questdb/client/impl/ConfigSchema.java
@@ -0,0 +1,226 @@
+/*+*****************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * 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.ObjList;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Layer 2 of the QWP connect-string parser: the key registry. This is the
+ * {@code ws}/{@code wss} vocabulary -- every key the WebSocket {@code Sender},
+ * the {@code QwpQueryClient}, and the facade pool accept, with its owning
+ * {@link Side}, value type, numeric range, enum values, and alias.
+ *
+ * {@link ConfigView} drives validation off this registry; the consumers read
+ * the keys their side owns. There is one vocabulary, so the registry is static.
+ */
+public final class ConfigSchema {
+
+ private static final long OPEN = Long.MIN_VALUE; // open lower bound
+ private static final long OPEN_MAX = Long.MAX_VALUE; // open upper bound
+ private static final Map BY_NAME = new HashMap<>();
+
+ static {
+ // COMMON -- both clients apply.
+ hostPort("addr");
+ str("username", Side.COMMON);
+ str("password", Side.COMMON);
+ alias("user", "username");
+ alias("pass", "password");
+ str("token", Side.COMMON);
+ enumKey("tls_verify", Side.COMMON, "on", "unsafe_off");
+ str("tls_roots", Side.COMMON);
+ str("tls_roots_password", Side.COMMON);
+ longRange("auth_timeout_ms", Side.COMMON, 0, OPEN_MAX, true, false); // > 0
+
+ // INGRESS -- the WebSocket Sender applies. STRING in the registry; the
+ // Sender parses suffix/mode values (off/on, 64k, durability) with its
+ // own helpers, byte-for-byte.
+ str("auto_flush", Side.INGRESS);
+ str("auto_flush_bytes", Side.INGRESS);
+ str("auto_flush_interval", Side.INGRESS);
+ str("auto_flush_rows", Side.INGRESS);
+ str("close_flush_timeout_millis", Side.INGRESS);
+ str("connection_listener_inbox_capacity", Side.INGRESS);
+ str("drain_orphans", Side.INGRESS);
+ str("durable_ack_keepalive_interval_millis", Side.INGRESS);
+ str("error_inbox_capacity", Side.INGRESS);
+ str("initial_connect_retry", Side.INGRESS);
+ str("max_background_drainers", Side.INGRESS);
+ str("max_name_len", Side.INGRESS);
+ str("reconnect_initial_backoff_millis", Side.INGRESS);
+ str("reconnect_max_backoff_millis", Side.INGRESS);
+ str("reconnect_max_duration_millis", Side.INGRESS);
+ str("request_durable_ack", Side.INGRESS);
+ str("sender_id", Side.INGRESS);
+ str("sf_append_deadline_millis", Side.INGRESS);
+ str("sf_dir", Side.INGRESS);
+ str("sf_durability", Side.INGRESS);
+ str("sf_max_bytes", Side.INGRESS);
+ str("sf_max_total_bytes", Side.INGRESS);
+ str("transaction", Side.INGRESS);
+
+ // EGRESS -- the QwpQueryClient applies. Typed where there is a range or
+ // enum, so ConfigView enforces it with the colon-dialect message.
+ enumKey("target", Side.EGRESS, "any", "primary", "replica");
+ boolOnOff("failover", Side.EGRESS);
+ intRange("failover_max_attempts", Side.EGRESS, 1, OPEN_MAX, false, false); // >= 1
+ longRange("failover_backoff_initial_ms", Side.EGRESS, 0, OPEN_MAX, false, false); // >= 0
+ longRange("failover_backoff_max_ms", Side.EGRESS, 0, OPEN_MAX, false, false); // >= 0
+ longRange("failover_max_duration_ms", Side.EGRESS, 0, OPEN_MAX, false, false); // >= 0
+ intRange("max_batch_rows", Side.EGRESS, 1, 1_048_576, false, false); // [1, 1048576]
+ longRange("initial_credit", Side.EGRESS, 0, OPEN_MAX, false, false); // >= 0
+ intRange("buffer_pool_size", Side.EGRESS, 1, OPEN_MAX, false, false); // >= 1
+ enumKey("compression", Side.EGRESS, "zstd", "raw", "auto");
+ intRange("compression_level", Side.EGRESS, 1, 22, false, false); // [1, 22]
+ str("client_id", Side.EGRESS);
+ str("zone", Side.EGRESS);
+
+ // POOL -- the facade applies; the two clients ignore. Open ranges: the
+ // facade feeds the value through the existing builder setter, which owns
+ // the range message.
+ intRange("sender_pool_min", Side.POOL, OPEN, OPEN_MAX, false, false);
+ intRange("sender_pool_max", Side.POOL, OPEN, OPEN_MAX, false, false);
+ intRange("query_pool_min", Side.POOL, OPEN, OPEN_MAX, false, false);
+ intRange("query_pool_max", Side.POOL, OPEN, OPEN_MAX, false, false);
+ longRange("acquire_timeout_ms", Side.POOL, OPEN, OPEN_MAX, false, false);
+ longRange("idle_timeout_ms", Side.POOL, OPEN, OPEN_MAX, false, false);
+ longRange("max_lifetime_ms", Side.POOL, OPEN, OPEN_MAX, false, false);
+ longRange("housekeeper_interval_ms", Side.POOL, OPEN, OPEN_MAX, false, false);
+
+ // RESERVED -- accepted no-op (error-policy keys reserved by the spec).
+ str("on_internal_error", Side.RESERVED);
+ str("on_parse_error", Side.RESERVED);
+ str("on_schema_error", Side.RESERVED);
+ str("on_security_error", Side.RESERVED);
+ str("on_server_error", Side.RESERVED);
+ str("on_write_error", Side.RESERVED);
+ }
+
+ private ConfigSchema() {
+ }
+
+ /**
+ * Every key spec in the registry, including alias entries ({@code user},
+ * {@code pass}). For the guard test.
+ */
+ public static Iterable all() {
+ return BY_NAME.values();
+ }
+
+ /**
+ * The spec for {@code name}, or null if the key is not in the registry.
+ * Includes alias entries ({@code user}, {@code pass}).
+ */
+ public static KeySpec spec(String name) {
+ return BY_NAME.get(name);
+ }
+
+ private static void add(KeySpec spec) {
+ BY_NAME.put(spec.name, spec);
+ }
+
+ private static void alias(String name, String canonical) {
+ add(new KeySpec(name, Side.COMMON, OPEN, OPEN_MAX, false, false, null, canonical));
+ }
+
+ private static void boolOnOff(String name, Side side) {
+ add(new KeySpec(name, side, OPEN, OPEN_MAX, false, false, null, name));
+ }
+
+ private static void enumKey(String name, Side side, String... values) {
+ ObjList list = new ObjList<>();
+ for (int i = 0; i < values.length; i++) {
+ list.add(values[i]);
+ }
+ add(new KeySpec(name, side, OPEN, OPEN_MAX, false, false, list, name));
+ }
+
+ private static void hostPort(String name) {
+ add(new KeySpec(name, Side.COMMON, OPEN, OPEN_MAX, false, false, null, name));
+ }
+
+ private static void intRange(String name, Side side, long min, long max, boolean minOpen, boolean maxOpen) {
+ add(new KeySpec(name, side, min, max, minOpen, maxOpen, null, name));
+ }
+
+ private static void longRange(String name, Side side, long min, long max, boolean minOpen, boolean maxOpen) {
+ add(new KeySpec(name, side, min, max, minOpen, maxOpen, null, name));
+ }
+
+ private static void str(String name, Side side) {
+ add(new KeySpec(name, side, OPEN, OPEN_MAX, false, false, null, name));
+ }
+
+ /**
+ * One key's contract. {@code min == Long.MIN_VALUE} means no lower bound,
+ * {@code max == Long.MAX_VALUE} means no upper bound. {@code minOpen} /
+ * {@code maxOpen} render and enforce a strict ({@code >} / {@code <}) rather
+ * than inclusive ({@code >=} / {@code <=}) bound.
+ */
+ public static final class KeySpec {
+ final String canonical;
+ final ObjList enumValues;
+ final long max;
+ final boolean maxOpen;
+ final long min;
+ final boolean minOpen;
+ final String name;
+ final Side side;
+
+ KeySpec(
+ String name, Side side,
+ long min, long max, boolean minOpen, boolean maxOpen,
+ ObjList enumValues, String canonical
+ ) {
+ this.name = name;
+ this.side = side;
+ this.min = min;
+ this.max = max;
+ this.minOpen = minOpen;
+ this.maxOpen = maxOpen;
+ this.enumValues = enumValues;
+ this.canonical = canonical;
+ }
+
+ public String canonical() {
+ return canonical;
+ }
+
+ public ObjList enumValues() {
+ return enumValues;
+ }
+
+ public String name() {
+ return name;
+ }
+
+ public Side side() {
+ return side;
+ }
+ }
+}
diff --git a/core/src/main/java/io/questdb/client/impl/ConfigString.java b/core/src/main/java/io/questdb/client/impl/ConfigString.java
new file mode 100644
index 00000000..bfbfd58a
--- /dev/null
+++ b/core/src/main/java/io/questdb/client/impl/ConfigString.java
@@ -0,0 +1,96 @@
+/*+*****************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * 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.ObjList;
+import io.questdb.client.std.str.StringSink;
+
+/**
+ * Layer 1 of the QWP connect-string parser: a thin tokenizer over
+ * {@link ConfStringParser}. It splits {@code schema::k=v;k=v} into the schema
+ * and an ordered list of key/value pairs, preserving repeats so a multivalue
+ * {@code addr} survives. Non-multi keys are resolved last-write-wins by
+ * {@link ConfigView}.
+ *
+ * Tokenizing (the {@code ;;} escaping, empty values, trailing {@code ;},
+ * control-character rejection) is exactly {@link ConfStringParser}'s, so the
+ * QWP consumers parse identically to the hand-rolled loops they replace.
+ */
+public final class ConfigString {
+
+ private final ObjList keys = new ObjList<>();
+ private final String schema;
+ private final ObjList values = new ObjList<>();
+
+ private ConfigString(String schema) {
+ this.schema = schema;
+ }
+
+ /**
+ * Tokenizes {@code input}. Throws {@link IllegalArgumentException} with a
+ * colon-dialect message on a malformed string.
+ */
+ public static ConfigString parse(CharSequence input) {
+ if (input == null || input.length() == 0) {
+ throw new IllegalArgumentException("configuration string cannot be empty");
+ }
+ StringSink sink = new StringSink();
+ int pos = ConfStringParser.of(input, sink);
+ if (pos < 0) {
+ throw new IllegalArgumentException("invalid configuration string: " + sink);
+ }
+ ConfigString cs = new ConfigString(sink.toString());
+ while (ConfStringParser.hasNext(input, pos)) {
+ pos = ConfStringParser.nextKey(input, pos, sink);
+ if (pos < 0) {
+ throw new IllegalArgumentException("invalid configuration string: " + sink);
+ }
+ String key = sink.toString();
+ pos = ConfStringParser.value(input, pos, sink);
+ if (pos < 0) {
+ throw new IllegalArgumentException("invalid configuration string: " + sink);
+ }
+ cs.keys.add(key);
+ cs.values.add(sink.toString());
+ }
+ return cs;
+ }
+
+ public String key(int i) {
+ return keys.getQuick(i);
+ }
+
+ public String schema() {
+ return schema;
+ }
+
+ public int size() {
+ return keys.size();
+ }
+
+ public String value(int i) {
+ return values.getQuick(i);
+ }
+}
diff --git a/core/src/main/java/io/questdb/client/impl/ConfigStringTranslator.java b/core/src/main/java/io/questdb/client/impl/ConfigStringTranslator.java
deleted file mode 100644
index 5888a775..00000000
--- a/core/src/main/java/io/questdb/client/impl/ConfigStringTranslator.java
+++ /dev/null
@@ -1,376 +0,0 @@
-/*+*****************************************************************************
- * ___ _ ____ ____
- * / _ \ _ _ ___ ___| |_| _ \| __ )
- * | | | | | | |/ _ \/ __| __| | | | _ \
- * | |_| | |_| | __/\__ \ |_| |_| | |_) |
- * \__\_\\__,_|\___||___/\__|____/|____/
- *
- * 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/ConfigView.java b/core/src/main/java/io/questdb/client/impl/ConfigView.java
new file mode 100644
index 00000000..1160c2d6
--- /dev/null
+++ b/core/src/main/java/io/questdb/client/impl/ConfigView.java
@@ -0,0 +1,300 @@
+/*+*****************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * 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.Numbers;
+import io.questdb.client.std.NumericException;
+import io.questdb.client.std.ObjList;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+
+/**
+ * Layer 3 of the QWP connect-string parser: a typed, validated view over a
+ * {@link ConfigString} for the {@link ConfigSchema} registry. The constructor
+ * runs the reject pass once -- any key absent from the schema throws
+ * {@code unknown configuration key: }, plus a relocated-key hint for keys
+ * that belong to the legacy http/tcp/udp transports.
+ *
+ * Found keys are recorded alias-normalized ({@code user}->{@code username},
+ * {@code pass}->{@code password}), so a consumer pulling the canonical name sees
+ * a value written under either. Non-multi keys resolve last-write-wins. The view
+ * does not filter by {@link Side}: each consumer reads the keys it needs, and a
+ * key owned by another consumer is accepted syntactically here and validated by
+ * its owning consumer.
+ */
+public final class ConfigView {
+
+ /**
+ * Keys that live in the legacy http/tcp/udp vocabulary. On a {@code ws}/
+ * {@code wss} string they reject with a hint pointing at the right place.
+ */
+ private static final Map RELOCATED_HINTS;
+
+ static {
+ Map hints = new HashMap<>();
+ hints.put("retry_timeout", "(use reconnect_max_duration_millis on ws/wss)");
+ hints.put("protocol_version", "(QWP negotiates the protocol version during the WebSocket upgrade)");
+ hints.put("init_buf_size", "(applies to legacy http/tcp/udp transports only)");
+ hints.put("max_buf_size", "(applies to legacy http/tcp/udp transports only)");
+ hints.put("request_timeout", "(applies to legacy http/tcp/udp transports only)");
+ hints.put("request_min_throughput", "(applies to legacy http/tcp/udp transports only)");
+ hints.put("max_datagram_size", "(applies to legacy http/tcp/udp transports only)");
+ hints.put("multicast_ttl", "(applies to legacy http/tcp/udp transports only)");
+ RELOCATED_HINTS = Collections.unmodifiableMap(hints);
+ }
+
+ private final ObjList normKeys = new ObjList<>();
+ private final ObjList normValues = new ObjList<>();
+
+ public ConfigView(ConfigString cs) {
+ for (int i = 0, n = cs.size(); i < n; i++) {
+ String raw = cs.key(i);
+ ConfigSchema.KeySpec spec = ConfigSchema.spec(raw);
+ if (spec == null) {
+ String hint = RELOCATED_HINTS.get(raw);
+ if (hint != null) {
+ throw new IllegalArgumentException("unknown configuration key: " + raw + " " + hint);
+ }
+ throw new IllegalArgumentException("unknown configuration key: " + raw);
+ }
+ normKeys.add(spec.canonical);
+ normValues.add(cs.value(i));
+ }
+ }
+
+ /**
+ * Returns the relocated-key hint for {@code key}, or null. Exposed for the
+ * guard test that pins the hint table.
+ */
+ public static String relocatedHint(String key) {
+ return RELOCATED_HINTS.get(key);
+ }
+
+ public boolean getBoolOnOff(String key, boolean dflt) {
+ String v = getStr(key);
+ if (v == null) {
+ return dflt;
+ }
+ if ("on".equals(v)) {
+ return true;
+ }
+ if ("off".equals(v)) {
+ return false;
+ }
+ throw new IllegalArgumentException("invalid " + key + ": " + v + " (expected on, off)");
+ }
+
+ /**
+ * The value of an enum key, or null if absent. Validated against the
+ * registry's allowed values.
+ */
+ public String getEnum(String key) {
+ String v = getStr(key);
+ if (v == null) {
+ return null;
+ }
+ ObjList allowed = ConfigSchema.spec(key).enumValues;
+ for (int i = 0, n = allowed.size(); i < n; i++) {
+ if (allowed.getQuick(i).equals(v)) {
+ return v;
+ }
+ }
+ throw new IllegalArgumentException("invalid " + key + ": " + v + " (expected " + join(allowed) + ")");
+ }
+
+ /**
+ * Splits the address list under {@code key} (multivalue, IPv6-aware),
+ * resolves each port (explicit, else {@code defaultPort}), rejects duplicate
+ * {@code (host, port)} pairs, and emits each unique pair to {@code sink}.
+ */
+ public void getHostPorts(String key, int defaultPort, HostPortSink sink) {
+ HashSet seen = new HashSet<>();
+ for (int i = 0, n = normKeys.size(); i < n; i++) {
+ if (!normKeys.getQuick(i).equals(key)) {
+ continue;
+ }
+ String value = normValues.getQuick(i);
+ int start = 0;
+ int len = value.length();
+ for (int j = 0; j <= len; j++) {
+ if (j == len || value.charAt(j) == ',') {
+ String entry = value.substring(start, j).trim();
+ if (entry.isEmpty()) {
+ throw new IllegalArgumentException("empty addr entry");
+ }
+ parseEntry(entry, defaultPort, seen, sink);
+ start = j + 1;
+ }
+ }
+ }
+ }
+
+ public int getInt(String key, int unset) {
+ String v = getStr(key);
+ if (v == null) {
+ return unset;
+ }
+ int parsed;
+ try {
+ parsed = Numbers.parseInt(v);
+ } catch (NumericException e) {
+ throw new IllegalArgumentException("invalid " + key + ": " + v);
+ }
+ ConfigSchema.KeySpec spec = ConfigSchema.spec(key);
+ if (outOfRange(spec, parsed)) {
+ throw new IllegalArgumentException(rangeMessage(spec));
+ }
+ return parsed;
+ }
+
+ public long getLong(String key, long unset) {
+ String v = getStr(key);
+ if (v == null) {
+ return unset;
+ }
+ long parsed;
+ try {
+ parsed = Numbers.parseLong(v);
+ } catch (NumericException e) {
+ throw new IllegalArgumentException("invalid " + key + ": " + v);
+ }
+ ConfigSchema.KeySpec spec = ConfigSchema.spec(key);
+ if (outOfRange(spec, parsed)) {
+ throw new IllegalArgumentException(rangeMessage(spec));
+ }
+ return parsed;
+ }
+
+ /**
+ * The last value written for {@code key} (last-write-wins), or null if
+ * absent. {@code key} is the canonical name; values written under an alias
+ * are found too.
+ */
+ public String getStr(String key) {
+ for (int i = normKeys.size() - 1; i >= 0; i--) {
+ if (normKeys.getQuick(i).equals(key)) {
+ return normValues.getQuick(i);
+ }
+ }
+ return null;
+ }
+
+ public boolean has(String key) {
+ for (int i = 0, n = normKeys.size(); i < n; i++) {
+ if (normKeys.getQuick(i).equals(key)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static String join(ObjList values) {
+ StringBuilder sb = new StringBuilder();
+ for (int i = 0, n = values.size(); i < n; i++) {
+ if (i > 0) {
+ sb.append(", ");
+ }
+ sb.append(values.getQuick(i));
+ }
+ return sb.toString();
+ }
+
+ private static boolean outOfRange(ConfigSchema.KeySpec spec, long v) {
+ if (spec.min != Long.MIN_VALUE && (spec.minOpen ? v <= spec.min : v < spec.min)) {
+ return true;
+ }
+ return spec.max != Long.MAX_VALUE && (spec.maxOpen ? v >= spec.max : v > spec.max);
+ }
+
+ private static int parsePort(String portStr, String entry) {
+ int port;
+ try {
+ port = Numbers.parseInt(portStr.trim());
+ } catch (NumericException e) {
+ throw new IllegalArgumentException("invalid port in addr: " + entry);
+ }
+ if (port < 1 || port > 65535) {
+ throw new IllegalArgumentException("port out of range in addr: " + entry + " (must be 1-65535)");
+ }
+ return port;
+ }
+
+ private static String rangeMessage(ConfigSchema.KeySpec spec) {
+ boolean hasMin = spec.min != Long.MIN_VALUE;
+ boolean hasMax = spec.max != Long.MAX_VALUE;
+ if (hasMin && hasMax) {
+ return spec.name + " must be in [" + spec.min + ", " + spec.max + "]";
+ }
+ if (hasMin) {
+ return spec.name + " must be " + (spec.minOpen ? "> " : ">= ") + spec.min;
+ }
+ return spec.name + " must be " + (spec.maxOpen ? "< " : "<= ") + spec.max;
+ }
+
+ private void parseEntry(String entry, int defaultPort, HashSet seen, HostPortSink sink) {
+ String host;
+ int port;
+ if (entry.charAt(0) == '[') {
+ int closeBracket = entry.indexOf(']');
+ if (closeBracket < 0) {
+ throw new IllegalArgumentException("missing closing ']' in IPv6 addr entry: " + entry);
+ }
+ host = entry.substring(1, closeBracket);
+ if (closeBracket == entry.length() - 1) {
+ port = defaultPort;
+ } else if (entry.charAt(closeBracket + 1) != ':') {
+ throw new IllegalArgumentException("expected ':' after ']' in IPv6 addr entry: " + entry);
+ } else {
+ port = parsePort(entry.substring(closeBracket + 2), entry);
+ }
+ } else if (entry.indexOf(':') != entry.lastIndexOf(':')) {
+ // Unbracketed multi-colon: bare IPv6 host, default port. A custom
+ // port on IPv6 requires brackets.
+ host = entry;
+ port = defaultPort;
+ } else {
+ int colon = entry.indexOf(':');
+ if (colon < 0) {
+ host = entry;
+ port = defaultPort;
+ } else {
+ host = entry.substring(0, colon).trim();
+ port = parsePort(entry.substring(colon + 1), entry);
+ }
+ }
+ if (host.isEmpty()) {
+ throw new IllegalArgumentException("empty host in addr entry: " + entry);
+ }
+ // port is numeric and neither hostnames nor IPv6 literals contain '/',
+ // so this key is unique per (host, port).
+ if (!seen.add(port + "/" + host)) {
+ throw new IllegalArgumentException("duplicate addr entry: " + entry);
+ }
+ sink.accept(host, port);
+ }
+}
diff --git a/core/src/main/java/io/questdb/client/impl/HostPortSink.java b/core/src/main/java/io/questdb/client/impl/HostPortSink.java
new file mode 100644
index 00000000..f9ef31b8
--- /dev/null
+++ b/core/src/main/java/io/questdb/client/impl/HostPortSink.java
@@ -0,0 +1,35 @@
+/*+*****************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * Copyright (c) 2014-2019 Appsicle
+ * Copyright (c) 2019-2026 QuestDB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ ******************************************************************************/
+
+package io.questdb.client.impl;
+
+/**
+ * Receives each unique {@code (host, port)} pair that
+ * {@link ConfigView#getHostPorts(String, int, HostPortSink)} resolves from an
+ * address list. {@code host} is a fresh {@link String}, safe to store.
+ */
+@FunctionalInterface
+public interface HostPortSink {
+ void accept(String host, int port);
+}
diff --git a/core/src/main/java/io/questdb/client/impl/Side.java b/core/src/main/java/io/questdb/client/impl/Side.java
new file mode 100644
index 00000000..b29d2aa2
--- /dev/null
+++ b/core/src/main/java/io/questdb/client/impl/Side.java
@@ -0,0 +1,56 @@
+/*+*****************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * 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;
+
+/**
+ * Which consumer owns a connect-string key in the {@link ConfigSchema} registry.
+ * It records intent and drives the per-key "honored" guard tests; it is not a
+ * runtime filter. {@link ConfigView} does not gate reads by side -- each consumer
+ * reads the keys it needs, and a key owned by another consumer is accepted
+ * syntactically and validated by its owning consumer.
+ */
+public enum Side {
+ /**
+ * Applied by every consumer (both clients and the facade pool).
+ */
+ COMMON,
+ /**
+ * Applied by the WebSocket {@code Sender} (ingress).
+ */
+ INGRESS,
+ /**
+ * Applied by the {@code QwpQueryClient} (egress).
+ */
+ EGRESS,
+ /**
+ * Applied by the facade pool, ignored by the two clients.
+ */
+ POOL,
+ /**
+ * Accepted as a no-op by every consumer (reserved by the spec, not yet
+ * wired to behavior).
+ */
+ RESERVED
+}
diff --git a/core/src/test/java/io/questdb/client/test/QuestDBBuilderTest.java b/core/src/test/java/io/questdb/client/test/QuestDBBuilderTest.java
index 69cb4645..1734360b 100644
--- a/core/src/test/java/io/questdb/client/test/QuestDBBuilderTest.java
+++ b/core/src/test/java/io/questdb/client/test/QuestDBBuilderTest.java
@@ -25,11 +25,192 @@
package io.questdb.client.test;
import io.questdb.client.QuestDB;
+import io.questdb.client.QuestDBBuilder;
+import io.questdb.client.test.cutlass.qwp.websocket.TestWebSocketServer;
import org.junit.Assert;
import org.junit.Test;
+import java.util.concurrent.TimeUnit;
+import java.util.function.BooleanSupplier;
+
public class QuestDBBuilderTest {
+ @Test
+ public void testBuilderCallAfterFromConfigOverridesPoolKeysFromString() {
+ // A pool key carried in the string is overridden by a later explicit
+ // builder call (last-write-wins). min=0 so build() does only parse-only
+ // validation -- nothing connects.
+ QuestDBBuilder b = QuestDB.builder()
+ .fromConfig("ws::addr=127.0.0.1:1;sender_pool_min=0;sender_pool_max=2;"
+ + "query_pool_min=0;query_pool_max=2;acquire_timeout_ms=10000;")
+ .acquireTimeoutMillis(150);
+ try (QuestDB ignored = b.build()) {
+ Assert.assertNotNull(ignored);
+ }
+ // The explicit acquireTimeoutMillis(150) wins over the string's 10000.
+ Assert.assertEquals(150L, b.poolConfigSnapshotForTest().get("acquire_timeout_ms"));
+ }
+
+ @Test
+ public void testConflictingIntPoolKeyAcrossSidesRejected() {
+ // Both sides carry sender_pool_max (an int pool key) with different
+ // values -> build fails via resolvePoolInt's conflict check. The long
+ // pool keys are covered by testConflictingPoolKeysAcrossSidesRejected;
+ // this guards the separate int code path.
+ try (QuestDB ignored = QuestDB.builder()
+ .ingestConfig("ws::addr=127.0.0.1:1;sender_pool_min=0;sender_pool_max=2;")
+ .queryConfig("ws::addr=127.0.0.1:1;query_pool_min=0;sender_pool_max=5;")
+ .build()) {
+ Assert.fail("expected conflicting pool config");
+ } catch (IllegalArgumentException e) {
+ Assert.assertTrue(e.getMessage(), e.getMessage().contains("conflicting pool config: sender_pool_max"));
+ }
+ }
+
+ @Test
+ public void testConflictingPoolKeysAcrossSidesRejected() {
+ // Both sides carry acquire_timeout_ms with different values -> build fails.
+ try (QuestDB ignored = QuestDB.builder()
+ .ingestConfig("ws::addr=127.0.0.1:1;sender_pool_min=0;acquire_timeout_ms=1000;")
+ .queryConfig("ws::addr=127.0.0.1:1;query_pool_min=0;acquire_timeout_ms=2000;")
+ .build()) {
+ Assert.fail("expected conflicting pool config");
+ } catch (IllegalArgumentException e) {
+ Assert.assertTrue(e.getMessage(), e.getMessage().contains("conflicting pool config: acquire_timeout_ms"));
+ }
+ }
+
+ @Test
+ public void testConnectRejectsNonWsSchemaOnSingleString() {
+ // QuestDB.connect(single string) must enforce the ws/wss schema, just
+ // like the builder's fromConfig().
+ assertSchemaRejected(() -> QuestDB.connect("http::addr=h:9000;"));
+ }
+
+ @Test
+ public void testConnectRejectsNonWsSchemaOnTwoArg() {
+ // QuestDB.connect(ingest, query) rejects a non-ws schema on either side.
+ assertSchemaRejected(() -> QuestDB.connect("tcp::addr=h:9009;", "ws::addr=h:9000;"));
+ assertSchemaRejected(() -> QuestDB.connect("ws::addr=h:9000;", "udp::addr=h:9009;"));
+ }
+
+ @Test
+ public void testConnectSingleStringValidatesAndBuilds() {
+ // QuestDB.connect(single string) hands the same ws:: string to both the
+ // ingest and query sides. min=0 on both pools validates both clients
+ // without connecting, so build() returns a live handle.
+ try (QuestDB ignored = QuestDB.connect(
+ "ws::addr=127.0.0.1:1;sender_pool_min=0;query_pool_min=0;")) {
+ Assert.assertNotNull(ignored);
+ }
+ }
+
+ @Test
+ public void testConnectStringWithPoolKeysAppliedToBuilder() {
+ // Pool keys supplied via separate ingest/query strings are accepted;
+ // min=0 so nothing connects.
+ try (QuestDB ignored = QuestDB.builder()
+ .ingestConfig("ws::addr=127.0.0.1:1;sender_pool_min=0;sender_pool_max=1;")
+ .queryConfig("ws::addr=127.0.0.1:1;query_pool_min=0;query_pool_max=1;")
+ .build()) {
+ Assert.assertNotNull(ignored);
+ }
+ }
+
+ @Test
+ public void testConnectTwoArgValidatesAndBuilds() {
+ // QuestDB.connect(ingest, query) sets the two sides independently;
+ // min=0 on each validates both clients without connecting.
+ try (QuestDB ignored = QuestDB.connect(
+ "ws::addr=127.0.0.1:1;sender_pool_min=0;",
+ "ws::addr=127.0.0.1:1;query_pool_min=0;")) {
+ Assert.assertNotNull(ignored);
+ }
+ }
+
+ @Test
+ public void testExplicitPoolKeyWinsOverConflictingStrings() {
+ // The two strings disagree on acquire_timeout_ms, but an explicit builder
+ // call sets it: explicit wins and the conflict check is skipped, whether
+ // the explicit call comes after or before the config strings. The resolved
+ // value is the explicit 500, not either string's value.
+ QuestDBBuilder after = QuestDB.builder()
+ .ingestConfig("ws::addr=127.0.0.1:1;sender_pool_min=0;acquire_timeout_ms=1000;")
+ .queryConfig("ws::addr=127.0.0.1:1;query_pool_min=0;acquire_timeout_ms=2000;")
+ .acquireTimeoutMillis(500);
+ try (QuestDB ignored = after.build()) {
+ Assert.assertNotNull(ignored);
+ }
+ Assert.assertEquals(500L, after.poolConfigSnapshotForTest().get("acquire_timeout_ms"));
+
+ QuestDBBuilder before = QuestDB.builder()
+ .acquireTimeoutMillis(500)
+ .ingestConfig("ws::addr=127.0.0.1:1;sender_pool_min=0;acquire_timeout_ms=1000;")
+ .queryConfig("ws::addr=127.0.0.1:1;query_pool_min=0;acquire_timeout_ms=2000;");
+ try (QuestDB ignored = before.build()) {
+ Assert.assertNotNull(ignored);
+ }
+ Assert.assertEquals(500L, before.poolConfigSnapshotForTest().get("acquire_timeout_ms"));
+ }
+
+ @Test
+ public void testHttpIngestConfigRejected() {
+ assertSchemaRejected(() -> QuestDB.builder().ingestConfig("http::addr=h:9000;"));
+ }
+
+ @Test
+ public void testHttpSingleConfigRejected() {
+ assertSchemaRejected(() -> QuestDB.builder().fromConfig("http::addr=h:9000;"));
+ }
+
+ @Test
+ public void testMalformedEgressConfigRejectedAtBuildWithMinZero() {
+ // query_pool_min=0 pre-warms nothing, so build() never constructs a
+ // QwpQueryClient -- yet it must still reject a malformed query config up
+ // front via QwpQueryClient.validateConfig, mirroring the ingress side.
+ // Covers a typed enum (compression) and a bounded int (compression_level).
+ assertEgressBuildRejected(
+ "ws::addr=127.0.0.1:1;compression=gzip;query_pool_min=0;query_pool_max=2;", "compression");
+ assertEgressBuildRejected(
+ "ws::addr=127.0.0.1:1;compression_level=99;query_pool_min=0;query_pool_max=2;", "compression_level");
+ }
+
+ @Test
+ public void testMalformedIngressConfigRejectedAtBuildWithMinZero() {
+ // sender_pool_min=0 pre-warms nothing, so build() never constructs a
+ // Sender -- yet it must still reject a malformed ingest config up front,
+ // matching the egress side. Covers a typed enum (tls_verify), a
+ // registry-STRING value that only the real Sender parse validates
+ // (auto_flush_rows), and WebSocket build-time checks that only the full
+ // no-connect validation reaches: auto_flush=off and auto_flush_interval=off
+ // both disable auto-flush (unsupported on WebSocket), and sf_durability=flush
+ // is not yet supported.
+ assertIngressBuildRejected(
+ "wss::addr=127.0.0.1:1;tls_verify=strict;sender_pool_min=0;sender_pool_max=2;", "tls_verify");
+ assertIngressBuildRejected(
+ "ws::addr=127.0.0.1:1;auto_flush_rows=abc;sender_pool_min=0;sender_pool_max=2;", "auto_flush_rows");
+ assertIngressBuildRejected(
+ "ws::addr=127.0.0.1:1;auto_flush_interval=off;sender_pool_min=0;sender_pool_max=2;", "auto-flush");
+ assertIngressBuildRejected(
+ "ws::addr=127.0.0.1:1;auto_flush=off;sender_pool_min=0;sender_pool_max=2;", "auto-flush");
+ assertIngressBuildRejected(
+ "ws::addr=127.0.0.1:1;sf_durability=flush;sender_pool_min=0;sender_pool_max=2;", "not yet supported");
+ }
+
+ @Test
+ public void testMalformedPoolValueRejectedAtBuild() {
+ // A non-numeric pool value is rejected at build()'s pool-key resolution,
+ // even with min=0. sender_pool_max is read through ConfigView.getInt,
+ // whose error names the offending key.
+ try (QuestDB ignored = QuestDB.builder()
+ .fromConfig("ws::addr=127.0.0.1:1;sender_pool_min=0;sender_pool_max=notanumber;")
+ .build()) {
+ Assert.fail("expected build to reject the malformed pool value");
+ } catch (IllegalArgumentException e) {
+ Assert.assertTrue(e.getMessage(), e.getMessage().contains("sender_pool_max"));
+ }
+ }
+
@Test
public void testMissingIngestConfigThrows() {
try {
@@ -43,7 +224,7 @@ public void testMissingIngestConfigThrows() {
@Test
public void testMissingQueryConfigThrows() {
try {
- QuestDB.builder().ingestConfig("http::addr=h:9000;").build().close();
+ QuestDB.builder().ingestConfig("ws::addr=h:9000;").build().close();
Assert.fail();
} catch (IllegalStateException e) {
Assert.assertTrue(e.getMessage().contains("query"));
@@ -74,62 +255,169 @@ public void testNegativePoolSizesRejected() {
}
@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)
+ public void testQueryPoolBuildFailureUnwindsSenderPool() throws Exception {
+ // Sender pool builds against a healthy ws ingest endpoint; the query
+ // pool fails on a dead address. The handle must close the already-built
+ // sender pool (its connected senders) rather than leak them.
+ try (TestWebSocketServer ingest = new TestWebSocketServer(new TestWebSocketServer.WebSocketServerHandler() {
+ })) {
+ ingest.start();
+ Assert.assertTrue(ingest.awaitStart(5, TimeUnit.SECONDS));
+ int port = ingest.getPort();
+ try {
+ QuestDB.builder()
+ .ingestConfig("ws::addr=localhost:" + port + ";")
+ .queryConfig("ws::addr=127.0.0.1:1;auth_timeout_ms=200;")
+ .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 comes from QwpQueryClient.connect(). The
+ // build failing only proves the query pool gave up; the
+ // assertions below prove the unwind closed the senders the
+ // sender pool had already connected, rather than leaking them.
+ }
+ // The sender pool eagerly warmed senderPoolSize(2), so the server
+ // saw two ingest handshakes (proving the senders connected and the
+ // assertion below is not vacuous)...
+ awaitTrue("sender pool should have connected two ingest senders",
+ () -> ingest.handshakeCount() >= 2);
+ // ...and the failed build() must have closed every one of them, so
+ // no sender connection is left live on the server. The server
+ // observes the client-side socket close asynchronously, so poll.
+ awaitTrue("failed build() must close the already-built sender pool, leaving no live connection",
+ () -> ingest.liveConnectionCount() == 0);
+ }
+ }
+
+ @Test
+ public void testSamePoolKeyValueAcrossSidesOk() {
+ // The same key at the same value on both sides builds cleanly.
+ try (QuestDB ignored = QuestDB.builder()
+ .ingestConfig("ws::addr=127.0.0.1:1;sender_pool_min=0;query_pool_min=0;acquire_timeout_ms=1500;")
+ .queryConfig("ws::addr=127.0.0.1:1;sender_pool_min=0;query_pool_min=0;acquire_timeout_ms=1500;")
.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).
+ Assert.assertNotNull(ignored);
}
}
@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)
+ public void testSharedVocabularyConnectsBothPoolsLive() throws Exception {
+ // The headline use case: one connect-string vocabulary carrying BOTH
+ // ingress-only keys (auto_flush_rows, sender_id) and egress-only keys
+ // (compression, max_batch_rows, target, failover) drives both LIVE
+ // clients through the facade -- each side applies the keys it owns and
+ // silently ignores the rest. Other tests cover this validate-only
+ // (min=0) or on a single side; this one pre-warms min=1 so both pools
+ // actually connect.
+ //
+ // The mock serves ingest (ACK) and query (SERVER_INFO) semantics on
+ // separate sockets, so ingest and query connect to separate servers. A
+ // single ws:: address serving both is exercised end-to-end against a
+ // real server in the parent repo.
+ try (TestWebSocketServer ingest = new TestWebSocketServer(new TestWebSocketServer.WebSocketServerHandler() {
+ });
+ TestWebSocketServer query = new TestWebSocketServer(new TestWebSocketServer.WebSocketServerHandler() {
+ })) {
+ ingest.start();
+ query.setSendServerInfo(true); // the egress client's connect() waits for SERVER_INFO
+ query.start();
+ Assert.assertTrue(ingest.awaitStart(5, TimeUnit.SECONDS));
+ Assert.assertTrue(query.awaitStart(5, TimeUnit.SECONDS));
+
+ // Identical vocabulary on both sides, differing only in addr -- the
+ // same mixed key set a single-string connect() would hand to both
+ // clients. The pool keys carry the same value on both sides, so the
+ // builder's cross-string conflict check passes.
+ String shared = "auto_flush_rows=100;sender_id=probe-1;" // ingress-only
+ + "compression=auto;max_batch_rows=512;target=any;failover=off;" // egress-only
+ + "auth_timeout_ms=2000;" // COMMON
+ + "sender_pool_min=1;sender_pool_max=2;query_pool_min=1;query_pool_max=2;"; // pool
+ try (QuestDB db = QuestDB.builder()
+ .ingestConfig("ws::addr=localhost:" + ingest.getPort() + ";" + shared)
+ .queryConfig("ws::addr=localhost:" + query.getPort() + ";" + shared)
+ .build()) {
+ // build() returned, so both pools pre-warmed their min=1 slot:
+ // the shared vocabulary connected a live sender AND a live query
+ // client, not merely validated.
+ Assert.assertNotNull(db.borrowSender());
+ Assert.assertNotNull(db.query());
+ }
+ }
+ }
+
+ @Test
+ public void testSharedWsConfigWithPoolKeys() {
+ // A shared ws:: string carries pool keys; min=0 so build does only
+ // parse-only validation (no connect).
+ try (QuestDB ignored = QuestDB.builder()
+ .fromConfig("ws::addr=127.0.0.1:1;sender_pool_min=0;sender_pool_max=3;"
+ + "query_pool_min=0;query_pool_max=2;acquire_timeout_ms=1234;")
.build()) {
- Assert.fail("build should fail with dead query address");
- } catch (RuntimeException expected) {
- // Validated by absence of an IllegalArgumentException for pool keys.
+ Assert.assertNotNull(ignored);
}
}
@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.
+ public void testTcpIngestConfigRejected() {
+ assertSchemaRejected(() -> QuestDB.builder().ingestConfig("tcp::addr=h:9009;"));
+ }
+
+ @Test
+ public void testUdpIngestConfigRejected() {
+ assertSchemaRejected(() -> QuestDB.builder().queryConfig("udp::addr=h:9009;"));
+ }
+
+ private static void assertEgressBuildRejected(String query, String expectedFragment) {
+ try {
+ QuestDB.builder()
+ .ingestConfig("ws::addr=127.0.0.1:1;sender_pool_min=0;sender_pool_max=2;")
+ .queryConfig(query)
+ .build()
+ .close();
+ Assert.fail("expected build() to reject the malformed query config: " + query);
+ } catch (RuntimeException e) {
+ Assert.assertNotNull(e.getMessage());
+ Assert.assertTrue(e.getMessage(), e.getMessage().contains(expectedFragment));
+ }
+ }
+
+ private static void assertIngressBuildRejected(String ingest, String expectedFragment) {
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)
+ .ingestConfig(ingest)
+ .queryConfig("ws::addr=127.0.0.1:1;query_pool_min=0;query_pool_max=2;")
.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.
+ Assert.fail("expected build() to reject the malformed ingest config: " + ingest);
+ } catch (RuntimeException e) {
+ // Ingress value errors surface as LineSenderException; both it and the
+ // egress IllegalArgumentException are RuntimeException.
+ Assert.assertNotNull(e.getMessage());
+ Assert.assertTrue(e.getMessage(), e.getMessage().contains(expectedFragment));
+ }
+ }
+
+ private static void assertSchemaRejected(Runnable action) {
+ try {
+ action.run();
+ Assert.fail("expected the ws/wss schema requirement to reject this config");
+ } catch (IllegalArgumentException e) {
+ Assert.assertTrue(e.getMessage(), e.getMessage().contains("ws or wss"));
+ }
+ }
+
+ private static void awaitTrue(String message, BooleanSupplier condition) throws InterruptedException {
+ long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
+ while (System.nanoTime() < deadline) {
+ if (condition.getAsBoolean()) {
+ return;
+ }
+ Thread.sleep(20);
}
+ Assert.assertTrue(message, condition.getAsBoolean());
}
}
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/line/LineSenderAddrParsingTest.java b/core/src/test/java/io/questdb/client/test/cutlass/line/LineSenderAddrParsingTest.java
index bf8c7981..16e11c4e 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/line/LineSenderAddrParsingTest.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/line/LineSenderAddrParsingTest.java
@@ -131,12 +131,12 @@ public void testAddrInvalidPortInSecondEntryRejected() {
// The first entry parses cleanly; the failure must come from the
// second entry's port, proving the comma-separated walk reaches it.
assertConfStrError("ws::addr=h1:9000,h2:notaport;",
- "cannot parse a port from the address");
+ "invalid port in addr");
}
@Test
public void testAddrPortOutOfRangeInSecondEntryRejected() {
- assertConfStrError("ws::addr=h1:9000,h2:0;", "invalid port [port=0]");
+ assertConfStrError("ws::addr=h1:9000,h2:0;", "port out of range in addr");
}
@Test
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/line/LineSenderBuilderTest.java b/core/src/test/java/io/questdb/client/test/cutlass/line/LineSenderBuilderTest.java
index 9e1d45e4..30a66f98 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/line/LineSenderBuilderTest.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/line/LineSenderBuilderTest.java
@@ -193,6 +193,8 @@ public void testConfStringValidation() throws Exception {
assertConfStrError("http::addr=localhost;username=foo;", "password cannot be empty nor null");
assertConfStrError("http::addr=localhost;pass=foo;", "password is configured, but username is missing");
assertConfStrError("http::addr=localhost;password=foo;", "password is configured, but username is missing");
+ assertConfStrError("http::addr=localhost;auth=Bearer xyz;", "unknown configuration key [key=auth]");
+ assertConfStrError("http::addr=localhost;path=/read/v1;", "unknown configuration key [key=path]");
assertConfStrError("tcp::addr=localhost;pass=foo;", "password is not supported for TCP protocol");
assertConfStrError("tcp::addr=localhost;password=foo;", "password is not supported for TCP protocol");
assertConfStrError("tcp::addr=localhost;retry_timeout=;", "retry_timeout cannot be empty");
@@ -222,10 +224,10 @@ public void testConfStringValidation() throws Exception {
assertConfStrError("http::addr=localhost;auto_flush_bytes=1024;", "auto_flush_bytes is only supported for TCP and WebSocket transport");
assertConfStrError("http::addr=localhost;protocol_version=10", "current client only supports protocol version 1(text format for all datatypes), 2(binary format for part datatypes), 3(decimal datatype) or explicitly unset");
assertConfStrError("http::addr=localhost:48884;max_name_len=10;", "max_name_len must be at least 16 bytes [max_name_len=10]");
- assertConfStrError("ws::addr=localhost;max_buf_size=1000000;", "maximum buffer capacity is not supported for WebSocket transport");
- assertConfStrError("wss::addr=localhost;tls_verify=unsafe_off;max_buf_size=1000000;", "maximum buffer capacity is not supported for WebSocket transport");
- assertConfStrError("ws::addr=localhost;init_buf_size=1024;", "buffer capacity is not supported for WebSocket transport");
- assertConfStrError("wss::addr=localhost;tls_verify=unsafe_off;init_buf_size=1024;", "buffer capacity is not supported for WebSocket transport");
+ assertConfStrError("ws::addr=localhost;max_buf_size=1000000;", "unknown configuration key: max_buf_size (applies to legacy http/tcp/udp transports only)");
+ assertConfStrError("wss::addr=localhost;tls_verify=unsafe_off;max_buf_size=1000000;", "unknown configuration key: max_buf_size (applies to legacy http/tcp/udp transports only)");
+ assertConfStrError("ws::addr=localhost;init_buf_size=1024;", "unknown configuration key: init_buf_size (applies to legacy http/tcp/udp transports only)");
+ assertConfStrError("wss::addr=localhost;tls_verify=unsafe_off;init_buf_size=1024;", "unknown configuration key: init_buf_size (applies to legacy http/tcp/udp transports only)");
assertConfStrOk("addr=localhost:8080", "auto_flush_rows=100", "protocol_version=1");
assertConfStrOk("addr=localhost:8080", "auto_flush=on", "auto_flush_rows=100", "protocol_version=2");
@@ -268,17 +270,17 @@ public void testConfStringValidation() throws Exception {
// let language clients drift on the same connect string.
assertConfStrError("http::addr=localhost;not_a_real_key=foo;", "unknown configuration key [key=not_a_real_key]");
assertConfStrError("tcp::addr=localhost;not_a_real_key=foo;", "unknown configuration key [key=not_a_real_key]");
- assertConfStrError("ws::addr=localhost;not_a_real_key=foo;", "unknown configuration key [key=not_a_real_key]");
+ assertConfStrError("ws::addr=localhost;not_a_real_key=foo;", "unknown configuration key: not_a_real_key");
assertConfStrError("udp::addr=localhost;not_a_real_key=foo;", "unknown configuration key [key=not_a_real_key]");
// The unknown-key error must surface even when the value would
// itself be malformed -- the key is the reportable defect.
assertConfStrError("http::addr=localhost;not_a_real_key=", "unknown configuration key [key=not_a_real_key]");
- // in_flight_window is silently accepted as a no-op for backward
- // compatibility. The store-and-forward mechanism replaces it.
- assertConfStrOk("http::addr=localhost;in_flight_window=10000;protocol_version=2;");
- assertConfStrOk("udp::addr=localhost;in_flight_window=10000;");
- assertConfStrOk("http::addr=localhost;in_flight_window=;protocol_version=2;");
+ // in_flight_window has been removed; it is now rejected like any
+ // other unknown configuration key.
+ assertConfStrError("http::addr=localhost;in_flight_window=10000;protocol_version=2;", "unknown configuration key [key=in_flight_window]");
+ assertConfStrError("udp::addr=localhost;in_flight_window=10000;", "unknown configuration key [key=in_flight_window]");
+ assertConfStrError("http::addr=localhost;in_flight_window=;protocol_version=2;", "unknown configuration key [key=in_flight_window]");
});
}
@@ -360,7 +362,6 @@ public void testEgressOnlyQueryClientKeysSilentlyAcceptedOnIngress() throws Exce
// Each egress-only key on its own with a representative happy-path
// value. Covers query-client knobs and per-Execute failover knobs.
String[] keys = {
- "auth=Bearer xyz",
"client_id=batch-job/42",
"compression=zstd",
"compression_level=5",
@@ -371,7 +372,6 @@ public void testEgressOnlyQueryClientKeysSilentlyAcceptedOnIngress() throws Exce
"failover_max_duration_ms=30000",
"initial_credit=1048576",
"max_batch_rows=10000",
- "path=/api/v2/query",
"target=primary",
};
StringBuilder all = new StringBuilder("http::addr=").append(LOCALHOST).append(";protocol_version=2;");
@@ -379,7 +379,7 @@ public void testEgressOnlyQueryClientKeysSilentlyAcceptedOnIngress() throws Exce
assertConfStrOk("http::addr=" + LOCALHOST + ";" + kv + ";protocol_version=2;");
all.append(kv).append(';');
}
- // All 13 keys at once -- a typical shared-config connect string.
+ // All 11 keys at once -- a typical shared-config connect string.
assertConfStrOk(all.toString());
// Out-of-range / malformed values are silently consumed too -- the
@@ -394,7 +394,6 @@ public void testEgressOnlyQueryClientKeysSilentlyAcceptedOnIngress() throws Exce
// Empty values are well-formed and silently consumed.
assertConfStrOk("http::addr=" + LOCALHOST + ";compression=;protocol_version=2;");
assertConfStrOk("http::addr=" + LOCALHOST + ";target=;protocol_version=2;");
- assertConfStrOk("http::addr=" + LOCALHOST + ";path=;protocol_version=2;");
});
}
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/LineSenderBuilderWebSocketTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/LineSenderBuilderWebSocketTest.java
index 6889b8c3..46cbac0c 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/LineSenderBuilderWebSocketTest.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/LineSenderBuilderWebSocketTest.java
@@ -368,50 +368,6 @@ public void testAuthTimeoutBuilder_notSupportedForTcp() {
.authTimeoutMillis(1000));
}
- @Test
- public void testGorillaConfig_acceptsOn() {
- Sender.LineSenderBuilder builder = Sender.builder("ws::addr=localhost:9000;gorilla=on;");
- Assert.assertNotNull(builder);
- }
-
- @Test
- public void testGorillaConfig_acceptsOff() {
- Sender.LineSenderBuilder builder = Sender.builder("ws::addr=localhost:9000;gorilla=off;");
- Assert.assertNotNull(builder);
- }
-
- @Test
- public void testGorillaConfig_acceptsTrue() {
- Sender.LineSenderBuilder builder = Sender.builder("ws::addr=localhost:9000;gorilla=true;");
- Assert.assertNotNull(builder);
- }
-
- @Test
- public void testGorillaConfig_acceptsFalse() {
- Sender.LineSenderBuilder builder = Sender.builder("ws::addr=localhost:9000;gorilla=false;");
- Assert.assertNotNull(builder);
- }
-
- @Test
- public void testGorillaConfig_unknownValueRejected() {
- assertBadConfig("ws::addr=localhost:9000;gorilla=maybe;",
- "invalid gorilla [value=maybe");
- }
-
- @Test
- public void testGorillaConfig_notSupportedForHttp() {
- assertBadConfig("http::addr=localhost:9000;gorilla=on;",
- "gorilla is only supported for WebSocket transport");
- }
-
- @Test
- public void testGorillaBuilder_notSupportedForTcp() {
- assertThrows("gorilla is only supported for WebSocket transport",
- () -> Sender.builder(Sender.Transport.TCP)
- .address(LOCALHOST)
- .gorilla(false));
- }
-
@Test
public void testWsConfigString_emptyHost_fails() {
assertBadConfig("ws::addr=:9000;", "empty host in addr entry");
@@ -419,17 +375,17 @@ public void testWsConfigString_emptyHost_fails() {
@Test
public void testWsConfigString_dupAddr_explicitThenDefaultPort_fails() {
- assertBadConfig("ws::addr=a:9000,a;", "duplicated addresses are not allowed");
+ assertBadConfig("ws::addr=a:9000,a;", "duplicate addr entry");
}
@Test
public void testWsConfigString_dupAddr_defaultThenExplicitPort_fails() {
- assertBadConfig("ws::addr=a,a:9000;", "duplicated addresses are not allowed");
+ assertBadConfig("ws::addr=a,a:9000;", "duplicate addr entry");
}
@Test
public void testWsConfigString_dupAddr_bothDefaultPort_fails() {
- assertBadConfig("ws::addr=a,a;", "duplicated addresses are not allowed");
+ assertBadConfig("ws::addr=a,a;", "duplicate addr entry");
}
@Test
@@ -767,7 +723,7 @@ public void testWsConfigString_missingAddr_fails() throws Exception {
// sf-client.md §4.6 now rejects unknown keys, so a valid key
// (user=) is used to drive the parser past key parsing and
// surface the missing-addr error on its own.
- assertBadConfig("ws::user=foo;", "addr is missing");
+ assertBadConfig("ws::user=foo;", "missing required key: addr");
});
}
@@ -802,8 +758,13 @@ public void testWsConfigString_withAutoFlushBytes() {
}
@Test
- public void testWsConfigString_withAutoFlushBytesDoubleSet_fails() {
- assertBadConfig("ws::addr=localhost:9000;auto_flush_bytes=1024;auto_flush_bytes=2048;", "already configured");
+ public void testWsConfigString_withAutoFlushBytesDoubleSet_lastWriteWins() {
+ // Duplicate keys resolve last-write-wins on the QWP path: a repeated
+ // auto_flush_bytes is accepted, not rejected. Sender.builder() parses
+ // without connecting, so a successful parse returns a builder.
+ Sender.LineSenderBuilder builder =
+ Sender.builder("ws::addr=localhost:9000;auto_flush_bytes=1024;auto_flush_bytes=2048;");
+ Assert.assertNotNull(builder);
}
@Test
@@ -811,14 +772,23 @@ public void testWsConfigString_withAutoFlushBytesInvalid_fails() {
assertBadConfig("ws::addr=localhost:9000;auto_flush_bytes=-1;", "cannot be negative");
}
+ @Test
+ public void testWsConfigString_withGorilla_fails() {
+ // gorilla has been removed; QWP ingestion always uses Gorilla timestamp
+ // encoding. The Sender rejects the key on a ws:: string as an unknown
+ // key, matching the QwpQueryClient (egress).
+ assertBadConfig("ws::addr=localhost:9000;gorilla=off;", "unknown configuration key: gorilla");
+ assertBadConfig("ws::addr=localhost:9000;gorilla=on;", "unknown configuration key: gorilla");
+ }
+
@Test
public void testWsConfigString_withInitBufSize_fails() {
- assertBadConfig("ws::addr=localhost:9000;init_buf_size=1024;", "buffer capacity is not supported for WebSocket transport");
+ assertBadConfig("ws::addr=localhost:9000;init_buf_size=1024;", "unknown configuration key: init_buf_size (applies to legacy http/tcp/udp transports only)");
}
@Test
public void testWsConfigString_withMaxBufSize_fails() {
- assertBadConfig("ws::addr=localhost:9000;max_buf_size=1000000;", "maximum buffer capacity is not supported for WebSocket transport");
+ assertBadConfig("ws::addr=localhost:9000;max_buf_size=1000000;", "unknown configuration key: max_buf_size (applies to legacy http/tcp/udp transports only)");
}
@Test
@@ -827,7 +797,160 @@ public void testWsConfigString_withMaxSchemasPerConnection_fails() {
// mechanism. The connection string used to accept it; it must now be
// rejected as an unknown key rather than silently swallowed.
assertBadConfig("ws::addr=localhost:9000;max_schemas_per_connection=1024;",
- "unknown configuration key [key=max_schemas_per_connection]");
+ "unknown configuration key: max_schemas_per_connection");
+ }
+
+ @Test
+ public void testWsConfigString_withPath_fails() {
+ // path is an egress endpoint that the QWP read client no longer accepts.
+ // The Sender rejects it on a ws:: string as an unknown key, matching the
+ // QwpQueryClient (egress).
+ assertBadConfig("ws::addr=localhost:9000;path=/read/v1;",
+ "unknown configuration key: path");
+ }
+
+ @Test
+ public void testWsConfigString_withEgressOnlyKeysSilentlyAccepted() {
+ // connect-string.md "Query client keys" and "Multi-host failover": these
+ // keys configure the QwpQueryClient (egress) only. A ws:: / wss:: connect
+ // string is shared by the Sender and the QwpQueryClient, so the Sender
+ // must silently consume the egress-only keys rather than reject them as
+ // unknown. The Sender does not interpret the value -- validation is the
+ // egress parser's job, so even an out-of-range value parses here.
+ String[] keys = {
+ "buffer_pool_size=8",
+ "client_id=batch-job/42",
+ "compression=zstd",
+ "compression_level=5",
+ "failover=on",
+ "failover_backoff_initial_ms=50",
+ "failover_backoff_max_ms=1000",
+ "failover_max_attempts=8",
+ "failover_max_duration_ms=30000",
+ "initial_credit=1048576",
+ "max_batch_rows=512",
+ "target=primary",
+ };
+ StringBuilder all = new StringBuilder("ws::addr=localhost:9000;");
+ for (String kv : keys) {
+ Assert.assertNotNull(Sender.builder("ws::addr=localhost:9000;" + kv + ";"));
+ all.append(kv).append(';');
+ }
+ // All egress-only keys at once -- a typical shared-config connect string.
+ Assert.assertNotNull(Sender.builder(all.toString()));
+ // The Sender does not validate egress-only values; an out-of-range one
+ // parses without complaint.
+ Assert.assertNotNull(Sender.builder("ws::addr=localhost:9000;buffer_pool_size=-1;"));
+ }
+
+ @Test
+ public void testWsConfigString_withReservedErrorPolicyKeysSilentlyAccepted() {
+ // connect-string.md "Error handling": the on_*_error keys are reserved by
+ // the spec, which directs new clients to accept them in the connect
+ // string. The Sender does not wire them to a policy yet, so it consumes
+ // them as an accepted no-op -- it must not reject them as unknown keys.
+ // Mirror of the QwpQueryClient (egress) behavior so one connect string
+ // carrying these keys configures both clients.
+ String[] keys = {
+ "on_internal_error=halt",
+ "on_parse_error=halt",
+ "on_schema_error=drop",
+ "on_security_error=halt",
+ "on_server_error=auto",
+ "on_write_error=drop",
+ };
+ StringBuilder all = new StringBuilder("ws::addr=localhost:9000;");
+ for (String kv : keys) {
+ Assert.assertNotNull(Sender.builder("ws::addr=localhost:9000;" + kv + ";"));
+ all.append(kv).append(';');
+ }
+ Assert.assertNotNull(Sender.builder(all.toString()));
+ }
+
+ @Test
+ public void testWsConfigString_withMaxDatagramSize_fails() {
+ // max_datagram_size applies to the UDP transport only; it is absent
+ // from the QWP connect-string vocabulary shared with the egress client.
+ assertBadConfig("ws::addr=localhost:9000;max_datagram_size=1400;",
+ "unknown configuration key: max_datagram_size (applies to legacy http/tcp/udp transports only)");
+ }
+
+ @Test
+ public void testWsConfigString_withMulticastTtl_fails() {
+ // multicast_ttl applies to the UDP transport only; it is absent from
+ // the QWP connect-string vocabulary shared with the egress client.
+ assertBadConfig("ws::addr=localhost:9000;multicast_ttl=4;",
+ "unknown configuration key: multicast_ttl (applies to legacy http/tcp/udp transports only)");
+ }
+
+ @Test
+ public void testWsConfigString_withProtocolVersionAuto_fails() {
+ // protocol_version is not part of the QWP connect-string vocabulary at
+ // all; even the no-op "auto" value is rejected on ws::, matching the
+ // egress QwpQueryClient and the other language clients.
+ assertBadConfig("ws::addr=localhost:9000;protocol_version=auto;",
+ "unknown configuration key: protocol_version (QWP negotiates the protocol version during the WebSocket upgrade)");
+ }
+
+ @Test
+ public void testWsConfigString_withProtocolVersion_fails() {
+ // protocol_version is a legacy ILP key, not part of the QWP
+ // connect-string vocabulary; QWP negotiates its version at handshake.
+ assertBadConfig("ws::addr=localhost:9000;protocol_version=2;",
+ "unknown configuration key: protocol_version (QWP negotiates the protocol version during the WebSocket upgrade)");
+ }
+
+ @Test
+ public void testWsConfigString_withRequestMinThroughput_fails() {
+ // request_min_throughput is an HTTP-only key, absent from the QWP
+ // connect-string vocabulary.
+ assertBadConfig("ws::addr=localhost:9000;request_min_throughput=102400;",
+ "unknown configuration key: request_min_throughput (applies to legacy http/tcp/udp transports only)");
+ }
+
+ @Test
+ public void testWsConfigString_withRequestTimeout_fails() {
+ // request_timeout is an HTTP-only key, absent from the QWP
+ // connect-string vocabulary.
+ assertBadConfig("ws::addr=localhost:9000;request_timeout=10000;",
+ "unknown configuration key: request_timeout (applies to legacy http/tcp/udp transports only)");
+ }
+
+ @Test
+ public void testWsConfigString_withRetryTimeout_fails() {
+ // retry_timeout is an HTTP-only key; the QWP analogue is the per-outage
+ // reconnect budget (reconnect_max_duration_millis).
+ assertBadConfig("ws::addr=localhost:9000;retry_timeout=10000;",
+ "unknown configuration key: retry_timeout (use reconnect_max_duration_millis on ws/wss)");
+ }
+
+ @Test
+ public void testWsConfigString_usernameWithoutPassword_fails() {
+ // The ingress ws path rejects a username with no password up front in
+ // validateWsConfig, with the same message the egress QwpQueryClient uses,
+ // so a shared ws/wss string fails identically on both sides.
+ assertBadConfig("ws::addr=localhost:9000;username=alice;", "username and password must be provided together");
+ }
+
+ @Test
+ public void testWsConfigString_passwordWithoutUsername_fails() {
+ // The reverse half-credential is rejected with the same message.
+ assertBadConfig("ws::addr=localhost:9000;password=secret;", "username and password must be provided together");
+ }
+
+ @Test
+ public void testWsConfigString_tokenWithBasicAuth_fails() {
+ // token and username/password are mutually exclusive on the ingress side.
+ assertBadConfig("ws::addr=localhost:9000;token=ey.abc;username=alice;password=secret;",
+ "cannot use both token and username/password authentication");
+ }
+
+ @Test
+ public void testWsConfigString_tlsKeysOnNonTlsSchema_fails() {
+ // tls_verify/tls_roots/tls_roots_password require the wss schema; on a
+ // plain ws string validateWsConfig rejects them.
+ assertBadConfig("ws::addr=localhost:9000;tls_verify=on;", "require the wss:: schema");
+ assertBadConfig("ws::addr=localhost:9000;tls_roots=/ca.p12;", "require the wss:: schema");
}
@Test
@@ -864,12 +987,12 @@ public void testWssConfigString_withAutoFlushBytes() {
@Test
public void testWssConfigString_withInitBufSize_fails() {
- assertBadConfig("wss::addr=localhost:9000;tls_verify=unsafe_off;init_buf_size=1024;", "buffer capacity is not supported for WebSocket transport");
+ assertBadConfig("wss::addr=localhost:9000;tls_verify=unsafe_off;init_buf_size=1024;", "unknown configuration key: init_buf_size (applies to legacy http/tcp/udp transports only)");
}
@Test
public void testWssConfigString_withMaxBufSize_fails() {
- assertBadConfig("wss::addr=localhost:9000;tls_verify=unsafe_off;max_buf_size=1000000;", "maximum buffer capacity is not supported for WebSocket transport");
+ assertBadConfig("wss::addr=localhost:9000;tls_verify=unsafe_off;max_buf_size=1000000;", "unknown configuration key: max_buf_size (applies to legacy http/tcp/udp transports only)");
}
@Test
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpQueryClientFromConfigTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpQueryClientFromConfigTest.java
index d18baf05..45f34fef 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpQueryClientFromConfigTest.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpQueryClientFromConfigTest.java
@@ -211,27 +211,11 @@ public void testAddrSingleWhitespaceTrimmedAroundHostPort() {
}
@Test
- public void testAuthAndBasicMutuallyExclusive() {
- assertReject(
- "ws::addr=db:9000;auth=Bearer xyz;username=admin;password=quest;",
- "auth, username/password, and token are mutually exclusive"
- );
- }
-
- @Test
- public void testAuthAndTokenMutuallyExclusive() {
- assertReject(
- "ws::addr=db:9000;auth=Bearer xyz;token=ey.xyz;",
- "auth, username/password, and token are mutually exclusive"
- );
- }
-
- @Test
- public void testAuthHeaderAcceptedAlone() {
- // Each of the three auth modes has a dedicated mutual-exclusion test;
- // the positive happy path is asserted here so the parser's per-key
- // dispatch and the post-loop "no auth set" path both have coverage.
- assertParses("ws::addr=db:9000;auth=Bearer xyz;");
+ public void testAuthKeyRejected() {
+ // The raw auth= header key is removed. Credentials are supplied as
+ // token= or username=/password=, from which the client synthesizes the
+ // Authorization header downstream.
+ assertReject("ws::addr=db:9000;auth=Bearer xyz;", "unknown configuration key: auth");
}
@Test
@@ -289,7 +273,26 @@ public void testBasicAuthAcceptedAlone() {
public void testBasicAuthAndTokenMutuallyExclusive() {
assertReject(
"ws::addr=db:9000;username=admin;password=quest;token=ey.xyz;",
- "auth, username/password, and token are mutually exclusive"
+ "cannot use both token and username/password authentication"
+ );
+ }
+
+ @Test
+ public void testBasicAuthEmptyPasswordRejected() {
+ // A present-but-blank password is rejected up front, matching the
+ // ingress Sender, so a shared ws/wss string fails the same way on both
+ // sides instead of building a degenerate Basic auth header.
+ assertReject(
+ "ws::addr=db:9000;username=alice;password=;",
+ "password cannot be empty nor null"
+ );
+ }
+
+ @Test
+ public void testBasicAuthEmptyUsernameRejected() {
+ assertReject(
+ "ws::addr=db:9000;username=;password=secret;",
+ "username cannot be empty nor null"
);
}
@@ -297,7 +300,7 @@ public void testBasicAuthAndTokenMutuallyExclusive() {
public void testBasicAuthWithPasswordOnlyRejected() {
assertReject(
"ws::addr=db:9000;password=quest;",
- "both username and password must be provided together"
+ "username and password must be provided together"
);
}
@@ -305,7 +308,37 @@ public void testBasicAuthWithPasswordOnlyRejected() {
public void testBasicAuthWithUsernameOnlyRejected() {
assertReject(
"ws::addr=db:9000;username=admin;",
- "both username and password must be provided together"
+ "username and password must be provided together"
+ );
+ }
+
+ @Test
+ public void testUserPassAliasesAuthenticate() {
+ // user/pass are aliases of username/password: they synthesize the same
+ // Basic auth header.
+ try (QwpQueryClient viaAlias = QwpQueryClient.fromConfig("ws::addr=db:9000;user=alice;pass=secret;");
+ QwpQueryClient viaCanonical = QwpQueryClient.fromConfig("ws::addr=db:9000;username=alice;password=secret;")) {
+ Assert.assertNotNull(viaAlias.getAuthorizationHeaderForTest());
+ Assert.assertEquals(
+ viaCanonical.getAuthorizationHeaderForTest(),
+ viaAlias.getAuthorizationHeaderForTest());
+ }
+ }
+
+ @Test
+ public void testUserAliasAloneRejected() {
+ // user is an alias of username, so user-alone trips the both-or-neither rule.
+ assertReject(
+ "ws::addr=db:9000;user=alice;",
+ "username and password must be provided together"
+ );
+ }
+
+ @Test
+ public void testPassAliasAloneRejected() {
+ assertReject(
+ "ws::addr=db:9000;pass=secret;",
+ "username and password must be provided together"
);
}
@@ -354,7 +387,7 @@ public void testCompressionDefaultIsRaw() {
public void testCompressionInvalidRejected() {
assertReject(
"ws::addr=db:9000;compression=gzip;",
- "unsupported compression: gzip (expected zstd, raw, or auto)"
+ "invalid compression: gzip (expected zstd, raw, auto)"
);
}
@@ -471,6 +504,19 @@ public void testFailoverBackoffInitialNonNumericRejected() {
);
}
+ @Test
+ public void testFailoverBackoffMaxAloneBelowDefaultInitialRejected() {
+ // failover_backoff_max_ms alone, below the 50 ms default initial backoff,
+ // makes the effective max < initial once fromConfig fills the missing
+ // initial with its default. validateConfig enforces the ordering against
+ // those effective values, so it is rejected up front (and the facade's
+ // fail-fast build path rejects it without constructing a client).
+ assertReject(
+ "ws::addr=db:9000;failover_backoff_max_ms=10;",
+ "failover_backoff_max_ms must be >= failover_backoff_initial_ms"
+ );
+ }
+
@Test
public void testFailoverBackoffMaxAndInitialBothAccepted() {
assertParses("ws::addr=db:9000;failover_backoff_initial_ms=100;failover_backoff_max_ms=500;");
@@ -511,7 +557,7 @@ public void testFailoverDefaultIsOn() {
public void testFailoverInvalidRejected() {
assertReject(
"ws::addr=db:9000;failover=maybe;",
- "invalid failover: maybe (expected on or off)"
+ "invalid failover: maybe (expected on, off)"
);
}
@@ -616,7 +662,7 @@ public void testFullKitchenSinkAccepted() {
// Verifies the parser's cross-key validation doesn't reject an otherwise
// legal combination, and that the happy-path client construction works.
String conf = "wss::addr=a.internal:9443,b.internal:9443,c.internal:9443;"
- + "path=/read/v1;target=primary;failover=on;"
+ + "target=primary;failover=on;"
+ "username=admin;password=quest;"
+ "client_id=batch-job/42;buffer_pool_size=8;"
+ "compression=zstd;compression_level=5;"
@@ -625,6 +671,21 @@ public void testFullKitchenSinkAccepted() {
assertParses(conf);
}
+ @Test
+ public void testGorillaKeyRejected() {
+ // gorilla has been removed; QWP ingestion always uses Gorilla timestamp
+ // encoding. The egress client rejects the key like any other unknown key.
+ assertReject("ws::addr=db:9000;gorilla=off;", "unknown configuration key: gorilla");
+ assertReject("ws::addr=db:9000;gorilla=on;", "unknown configuration key: gorilla");
+ }
+
+ @Test
+ public void testInFlightWindowKeyRejected() {
+ // in_flight_window has been removed; the egress client rejects it like
+ // any other unknown key.
+ assertReject("ws::addr=db:9000;in_flight_window=10000;", "unknown configuration key: in_flight_window");
+ }
+
@Test
public void testIngressOnlyKeysSilentlyAcceptedOnEgress() {
// connect-string.md: these keys configure the Sender (ingress) only.
@@ -648,30 +709,20 @@ public void testIngressOnlyKeysSilentlyAcceptedOnEgress() {
"drain_orphans=on",
"durable_ack_keepalive_interval_millis=200",
"error_inbox_capacity=256",
- "in_flight_window=10000",
- "init_buf_size=65536",
"initial_connect_retry=on",
"max_background_drainers=4",
- "max_buf_size=100m",
- "max_datagram_size=1400",
"max_name_len=127",
- "multicast_ttl=1",
- "pass=secret",
- "protocol_version=2",
"reconnect_initial_backoff_millis=100",
"reconnect_max_backoff_millis=5000",
"reconnect_max_duration_millis=300000",
"request_durable_ack=on",
- "request_min_throughput=102400",
- "request_timeout=10000",
- "retry_timeout=10000",
"sender_id=ingest-1",
"sf_append_deadline_millis=30000",
"sf_dir=/var/lib/qdb-sf",
"sf_durability=memory",
"sf_max_bytes=4m",
"sf_max_total_bytes=10g",
- "user=alice",
+ "transaction=on",
};
StringBuilder all = new StringBuilder("ws::addr=db:9000;");
for (String kv : keys) {
@@ -684,7 +735,6 @@ public void testIngressOnlyKeysSilentlyAcceptedOnEgress() {
// Out-of-range / malformed values are silently consumed too -- the
// egress parser does not validate ingress-only keys.
assertParses("ws::addr=db:9000;auto_flush_rows=-1;");
- assertParses("ws::addr=db:9000;init_buf_size=garbage;");
assertParses("ws::addr=db:9000;reconnect_max_duration_millis=banana;");
// Empty values are well-formed and silently consumed.
@@ -816,14 +866,77 @@ public void testMissingSchemaRejected() {
}
}
+ @Test
+ public void testNonQwpKeysRejectedOnEgress() {
+ // request_timeout, retry_timeout, request_min_throughput, and
+ // protocol_version are legacy ILP HTTP/TCP keys, absent from the QWP
+ // connect-string vocabulary (connect-string.md Key index). The
+ // QwpQueryClient is QWP-only, so a ws:: string carrying them is
+ // malformed -- the parser rejects them as unknown rather than
+ // silently consuming them.
+ // Legacy keys reject with a relocated-key hint pointing at the right place.
+ assertReject("ws::addr=db:9000;request_timeout=10000;",
+ "unknown configuration key: request_timeout (applies to legacy http/tcp/udp transports only)");
+ assertReject("ws::addr=db:9000;retry_timeout=10000;",
+ "unknown configuration key: retry_timeout (use reconnect_max_duration_millis on ws/wss)");
+ assertReject("ws::addr=db:9000;request_min_throughput=102400;",
+ "unknown configuration key: request_min_throughput (applies to legacy http/tcp/udp transports only)");
+ assertReject("ws::addr=db:9000;protocol_version=2;",
+ "unknown configuration key: protocol_version (QWP negotiates the protocol version during the WebSocket upgrade)");
+ // protocol_version is rejected regardless of value: the egress side
+ // has no "auto" pass-through.
+ assertReject("ws::addr=db:9000;protocol_version=auto;",
+ "unknown configuration key: protocol_version (QWP negotiates the protocol version during the WebSocket upgrade)");
+ // max_datagram_size and multicast_ttl apply to the UDP transport only;
+ // the QWP ws:: vocabulary does not include them, so the egress parser
+ // rejects them as unknown.
+ assertReject("ws::addr=db:9000;max_datagram_size=1400;",
+ "unknown configuration key: max_datagram_size (applies to legacy http/tcp/udp transports only)");
+ assertReject("ws::addr=db:9000;multicast_ttl=4;",
+ "unknown configuration key: multicast_ttl (applies to legacy http/tcp/udp transports only)");
+ // init_buf_size and max_buf_size size the legacy http/tcp ingest buffer;
+ // the ws Sender has fixed framing, so they are legacy-only and the egress
+ // parser rejects them with the hint rather than silently consuming them.
+ assertReject("ws::addr=db:9000;init_buf_size=65536;",
+ "unknown configuration key: init_buf_size (applies to legacy http/tcp/udp transports only)");
+ assertReject("ws::addr=db:9000;max_buf_size=100m;",
+ "unknown configuration key: max_buf_size (applies to legacy http/tcp/udp transports only)");
+ }
+
@Test
public void testNullStringRejected() {
assertReject(null, "configuration string cannot be empty");
}
@Test
- public void testPathOverrideAccepted() {
- assertParses("ws::addr=db:9000;path=/custom/read;");
+ public void testPathKeyRejected() {
+ // path has been removed; the egress client rejects it like any other
+ // unknown key.
+ assertReject("ws::addr=db:9000;path=/custom/read;", "unknown configuration key: path");
+ }
+
+ @Test
+ public void testReservedErrorPolicyKeysSilentlyAccepted() {
+ // connect-string.md "Error handling": the on_*_error keys are reserved
+ // by the spec, which directs new clients to accept them in the connect
+ // string. The Java client does not wire them to a policy yet, so the
+ // egress parser consumes them as an accepted no-op -- it must not reject
+ // them as unknown keys. Mirror of the Sender (ingress) behavior so one
+ // connect string carrying these keys configures both clients.
+ String[] keys = {
+ "on_internal_error=halt",
+ "on_parse_error=halt",
+ "on_schema_error=drop",
+ "on_security_error=halt",
+ "on_server_error=auto",
+ "on_write_error=drop",
+ };
+ StringBuilder all = new StringBuilder("ws::addr=db:9000;");
+ for (String kv : keys) {
+ assertParses("ws::addr=db:9000;" + kv + ";");
+ all.append(kv).append(';');
+ }
+ assertParses(all.toString());
}
@Test
@@ -835,7 +948,7 @@ public void testTargetAnyAccepted() {
public void testTargetInvalidRejected() {
assertReject(
"ws::addr=db:9000;target=leader;",
- "invalid target: leader (expected any, primary, or replica)"
+ "invalid target: leader (expected any, primary, replica)"
);
}
@@ -882,7 +995,7 @@ public void testTlsRootsWithoutPasswordRejected() {
public void testTlsVerifyInvalidRejected() {
assertReject(
"wss::addr=db:9000;tls_verify=strict;",
- "invalid tls_verify: strict (expected on or unsafe_off)"
+ "invalid tls_verify: strict (expected on, unsafe_off)"
);
}
@@ -909,6 +1022,13 @@ public void testTokenAcceptedAlone() {
assertParses("ws::addr=db:9000;token=ey.payload.sig;");
}
+ @Test
+ public void testTokenEmptyRejected() {
+ // A present-but-blank token is rejected up front, matching the ingress
+ // Sender, so the client never sends an empty Bearer header.
+ assertReject("ws::addr=db:9000;token=;", "token cannot be empty nor null");
+ }
+
@Test
public void testTokenRequestEncodesAsBearer() {
// We can't easily snoop the request header without a server, but the
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpQueryClientPostConnectGuardTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpQueryClientPostConnectGuardTest.java
index e0c6d22b..d4ad155e 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpQueryClientPostConnectGuardTest.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpQueryClientPostConnectGuardTest.java
@@ -47,8 +47,6 @@ public class QwpQueryClientPostConnectGuardTest {
@Test
public void testAllSettersRejectAfterConnect() throws Exception {
- // withAuthorization
- assertRejects(c -> c.withAuthorization("Bearer x"), "withAuthorization");
// withBasicAuth
assertRejects(c -> c.withBasicAuth("u", "p"), "withBasicAuth");
// withBearerToken
@@ -59,8 +57,6 @@ public void testAllSettersRejectAfterConnect() throws Exception {
assertRejects(c -> c.withClientId("id"), "withClientId");
// withCompression
assertRejects(c -> c.withCompression("zstd", 3), "withCompression");
- // withEndpointPath
- assertRejects(c -> c.withEndpointPath("/x"), "withEndpointPath");
// withFailover
assertRejects(c -> c.withFailover(false), "withFailover");
// withFailoverBackoff
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpWebSocketEncoderTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpWebSocketEncoderTest.java
index b7318a87..4d7d7931 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpWebSocketEncoderTest.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpWebSocketEncoderTest.java
@@ -1056,8 +1056,6 @@ public void testGorillaEncoding_compressionRatio() throws Exception {
assertMemoryLeak(() -> {
try (QwpWebSocketEncoder encoder = new QwpWebSocketEncoder();
QwpTableBuffer buffer = new QwpTableBuffer("metrics")) {
- encoder.setGorillaEnabled(true);
-
// Add many timestamps with constant delta - best case for Gorilla
QwpTableBuffer.ColumnBuffer col = buffer.getOrCreateColumn("ts", TYPE_TIMESTAMP, true);
for (int i = 0; i < 1000; i++) {
@@ -1067,28 +1065,11 @@ public void testGorillaEncoding_compressionRatio() throws Exception {
int sizeWithGorilla = encoder.encode(buffer);
- // Calculate theoretical minimum size for Gorilla:
- // - Header: 12 bytes
- // - Table header, column schema, etc.
- // - First timestamp: 8 bytes
- // - Second timestamp: 8 bytes
- // - Remaining 998 timestamps: 998 bits (1 bit each for DoD=0) = ~125 bytes
-
- // Calculate size without Gorilla (1000 * 8 = 8000 bytes just for timestamps)
- encoder.setGorillaEnabled(false);
- buffer.reset();
- col = buffer.getOrCreateColumn("ts", TYPE_TIMESTAMP, true);
- for (int i = 0; i < 1000; i++) {
- col.addLong(1000000000L + i * 1000L);
- buffer.nextRow();
- }
-
- int sizeWithoutGorilla = encoder.encode(buffer);
-
- // For constant delta, Gorilla should achieve significant compression
- double compressionRatio = (double) sizeWithGorilla / sizeWithoutGorilla;
+ // Uncompressed, the timestamps alone take 1000 * 8 = 8000 bytes.
+ // For constant delta, Gorilla compresses to well under a fifth of that.
+ int uncompressedTimestampBytes = 1000 * 8;
Assert.assertTrue("Compression ratio should be < 0.2 for constant delta",
- compressionRatio < 0.2);
+ sizeWithGorilla < uncompressedTimestampBytes / 5);
}
});
}
@@ -1098,8 +1079,6 @@ public void testGorillaEncoding_multipleTimestampColumns() throws Exception {
assertMemoryLeak(() -> {
try (QwpWebSocketEncoder encoder = new QwpWebSocketEncoder();
QwpTableBuffer buffer = new QwpTableBuffer("test")) {
- encoder.setGorillaEnabled(true);
-
// Add multiple timestamp columns
for (int i = 0; i < 50; i++) {
QwpTableBuffer.ColumnBuffer ts1Col = buffer.getOrCreateColumn("ts1", TYPE_TIMESTAMP, true);
@@ -1113,23 +1092,11 @@ public void testGorillaEncoding_multipleTimestampColumns() throws Exception {
int sizeWithGorilla = encoder.encode(buffer);
- // Compare with uncompressed
- encoder.setGorillaEnabled(false);
- buffer.reset();
- for (int i = 0; i < 50; i++) {
- QwpTableBuffer.ColumnBuffer ts1Col = buffer.getOrCreateColumn("ts1", TYPE_TIMESTAMP, true);
- ts1Col.addLong(1000000000L + i * 1000L);
-
- QwpTableBuffer.ColumnBuffer ts2Col = buffer.getOrCreateColumn("ts2", TYPE_TIMESTAMP, true);
- ts2Col.addLong(2000000000L + i * 2000L);
-
- buffer.nextRow();
- }
-
- int sizeWithoutGorilla = encoder.encode(buffer);
-
+ // Two constant-delta timestamp columns of 50 rows take
+ // 2 * 50 * 8 = 800 bytes uncompressed; Gorilla compresses both.
+ int uncompressedTimestampBytes = 2 * 50 * 8;
Assert.assertTrue("Gorilla should compress multiple timestamp columns",
- sizeWithGorilla < sizeWithoutGorilla);
+ sizeWithGorilla < uncompressedTimestampBytes);
}
});
}
@@ -1139,8 +1106,6 @@ public void testGorillaEncoding_multipleTimestamps_usesGorillaEncoding() throws
assertMemoryLeak(() -> {
try (QwpWebSocketEncoder encoder = new QwpWebSocketEncoder();
QwpTableBuffer buffer = new QwpTableBuffer("test")) {
- encoder.setGorillaEnabled(true);
-
// Add multiple timestamps with constant delta (best compression)
QwpTableBuffer.ColumnBuffer col = buffer.getOrCreateDesignatedTimestampColumn(TYPE_TIMESTAMP);
for (int i = 0; i < 100; i++) {
@@ -1150,20 +1115,11 @@ public void testGorillaEncoding_multipleTimestamps_usesGorillaEncoding() throws
int sizeWithGorilla = encoder.encode(buffer);
- // Now encode without Gorilla
- encoder.setGorillaEnabled(false);
- buffer.reset();
- col = buffer.getOrCreateDesignatedTimestampColumn(TYPE_TIMESTAMP);
- for (int i = 0; i < 100; i++) {
- col.addLong(1000000000L + i * 1000L);
- buffer.nextRow();
- }
-
- int sizeWithoutGorilla = encoder.encode(buffer);
-
- // Gorilla should produce smaller output for constant-delta timestamps
+ // 100 constant-delta timestamps take 100 * 8 = 800 bytes
+ // uncompressed; Gorilla produces a much smaller payload.
+ int uncompressedTimestampBytes = 100 * 8;
Assert.assertTrue("Gorilla encoding should be smaller",
- sizeWithGorilla < sizeWithoutGorilla);
+ sizeWithGorilla < uncompressedTimestampBytes);
}
});
}
@@ -1173,8 +1129,6 @@ public void testGorillaEncoding_nanosTimestamps() throws Exception {
assertMemoryLeak(() -> {
try (QwpWebSocketEncoder encoder = new QwpWebSocketEncoder();
QwpTableBuffer buffer = new QwpTableBuffer("test")) {
- encoder.setGorillaEnabled(true);
-
// Use TYPE_TIMESTAMP_NANOS
QwpTableBuffer.ColumnBuffer col = buffer.getOrCreateColumn("ts", TYPE_TIMESTAMP_NANOS, true);
for (int i = 0; i < 100; i++) {
@@ -1198,8 +1152,6 @@ public void testGorillaEncoding_singleTimestamp_usesUncompressed() throws Except
assertMemoryLeak(() -> {
try (QwpWebSocketEncoder encoder = new QwpWebSocketEncoder();
QwpTableBuffer buffer = new QwpTableBuffer("test")) {
- encoder.setGorillaEnabled(true);
-
// Single timestamp - should use uncompressed
QwpTableBuffer.ColumnBuffer col = buffer.getOrCreateDesignatedTimestampColumn(TYPE_TIMESTAMP);
col.addLong(1000000L);
@@ -1216,8 +1168,6 @@ public void testGorillaEncoding_twoTimestamps_usesUncompressed() throws Exceptio
assertMemoryLeak(() -> {
try (QwpWebSocketEncoder encoder = new QwpWebSocketEncoder();
QwpTableBuffer buffer = new QwpTableBuffer("test")) {
- encoder.setGorillaEnabled(true);
-
// Only 2 timestamps - should use uncompressed (Gorilla needs 3+)
QwpTableBuffer.ColumnBuffer col = buffer.getOrCreateDesignatedTimestampColumn(TYPE_TIMESTAMP);
col.addLong(1000000L);
@@ -1241,8 +1191,6 @@ public void testGorillaEncoding_varyingDelta() throws Exception {
assertMemoryLeak(() -> {
try (QwpWebSocketEncoder encoder = new QwpWebSocketEncoder();
QwpTableBuffer buffer = new QwpTableBuffer("test")) {
- encoder.setGorillaEnabled(true);
-
// Varying deltas that exercise different buckets
QwpTableBuffer.ColumnBuffer col = buffer.getOrCreateColumn("ts", TYPE_TIMESTAMP, true);
long[] timestamps = {
@@ -1271,42 +1219,17 @@ public void testGorillaEncoding_varyingDelta() throws Exception {
}
@Test
- public void testGorillaFlagDisabled() throws Exception {
- assertMemoryLeak(() -> {
- try (QwpWebSocketEncoder encoder = new QwpWebSocketEncoder();
- QwpTableBuffer buffer = new QwpTableBuffer("test")) {
- encoder.setGorillaEnabled(false);
- Assert.assertFalse(encoder.isGorillaEnabled());
-
- QwpTableBuffer.ColumnBuffer col = buffer.getOrCreateColumn("ts", TYPE_TIMESTAMP, true);
- col.addLong(1000000L);
- buffer.nextRow();
-
- encoder.encode(buffer);
-
- // Check flags byte doesn't have Gorilla bit set
- QwpBufferWriter buf = encoder.getBuffer();
- byte flags = Unsafe.getUnsafe().getByte(buf.getBufferPtr() + 5);
- Assert.assertEquals(0, flags & FLAG_GORILLA);
- }
- });
- }
-
- @Test
- public void testGorillaFlagEnabled() throws Exception {
+ public void testGorillaFlagAlwaysSet() throws Exception {
assertMemoryLeak(() -> {
try (QwpWebSocketEncoder encoder = new QwpWebSocketEncoder();
QwpTableBuffer buffer = new QwpTableBuffer("test")) {
- encoder.setGorillaEnabled(true);
- Assert.assertTrue(encoder.isGorillaEnabled());
-
QwpTableBuffer.ColumnBuffer col = buffer.getOrCreateColumn("ts", TYPE_TIMESTAMP, true);
col.addLong(1000000L);
buffer.nextRow();
encoder.encode(buffer);
- // Check flags byte has Gorilla bit set
+ // The Gorilla flag is always set on QWP ingress messages.
QwpBufferWriter buf = encoder.getBuffer();
byte flags = Unsafe.getUnsafe().getByte(buf.getBufferPtr() + 5);
Assert.assertEquals(FLAG_GORILLA, (byte) (flags & FLAG_GORILLA));
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 f7bb4598..f5805bb9 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
@@ -404,15 +404,6 @@ public void testGeoHashColumnStringAfterCloseThrows() throws Exception {
});
}
- @Test
- public void testGorillaEnabledByDefault() throws Exception {
- assertMemoryLeak(() -> {
- try (QwpWebSocketSender sender = createUnconnectedSender()) {
- Assert.assertTrue(sender.isGorillaEnabled());
- }
- });
- }
-
@Test
public void testIpv4ColumnStringNullReturnsThis() throws Exception {
// A null reference is the explicit "skip the setter for this row"
@@ -633,18 +624,6 @@ public void testResetAfterCloseThrows() throws Exception {
});
}
- @Test
- public void testSetGorillaEnabled() throws Exception {
- assertMemoryLeak(() -> {
- try (QwpWebSocketSender sender = createUnconnectedSender()) {
- sender.setGorillaEnabled(false);
- Assert.assertFalse(sender.isGorillaEnabled());
- sender.setGorillaEnabled(true);
- Assert.assertTrue(sender.isGorillaEnabled());
- }
- });
- }
-
@Test
public void testStringColumnAfterCloseThrows() throws Exception {
assertMemoryLeak(() -> {
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 4db34d44..806d3750 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
@@ -46,6 +46,7 @@
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
/**
* A simple WebSocket server for client integration testing.
@@ -57,10 +58,19 @@ public class TestWebSocketServer implements Closeable {
private final List clients = new CopyOnWriteArrayList<>();
private final boolean emitDurableAckHeader;
private final WebSocketServerHandler handler;
+ // Count of WebSocket connections currently live from the server's view:
+ // incremented when a handshake completes, decremented when that connection's
+ // read thread exits (the client closed its socket). Lets a test assert that a
+ // client-side pool actually closed the connections it opened.
+ private final AtomicInteger liveConnections = new AtomicInteger();
private final int port;
private final AtomicBoolean running = new AtomicBoolean(false);
private final ServerSocket serverSocket;
private final CountDownLatch startLatch = new CountDownLatch(1);
+ // Monotonic count of completed handshakes over the server's lifetime. Unlike
+ // liveConnections it never decrements, so a test can confirm how many clients
+ // connected even after they have all disconnected.
+ private final AtomicInteger totalHandshakes = new AtomicInteger();
private Thread acceptThread;
// X-QuestDB-Role value to emit on handshake responses. null = omit the
// header (legacy behavior for tests written before role-aware failover).
@@ -164,6 +174,22 @@ public int getPort() {
return port;
}
+ /**
+ * Number of handshakes the server has completed over its lifetime
+ * (monotonic; never decreases when clients disconnect).
+ */
+ public int handshakeCount() {
+ return totalHandshakes.get();
+ }
+
+ /**
+ * Number of WebSocket connections currently live from the server's view.
+ * Drops back to zero once every client has closed its socket.
+ */
+ public int liveConnectionCount() {
+ return liveConnections.get();
+ }
+
/**
* Replaces the advertised role for subsequent handshakes (live update).
*/
@@ -536,35 +562,41 @@ void start() {
LOG.error("Handshake failed");
return;
}
+ totalHandshakes.incrementAndGet();
+ liveConnections.incrementAndGet();
- if (sendServerInfo) {
- sendBinary(buildServerInfoFrame(roleByte(advertisedRole)));
- }
-
- byte[] readBuf = new byte[8192];
-
- while (running.get() && !isClosed) {
- int read;
- try {
- read = in.read(readBuf);
- } catch (SocketTimeoutException e) {
- continue;
- }
- if (read <= 0) {
- break;
+ try {
+ if (sendServerInfo) {
+ sendBinary(buildServerInfoFrame(roleByte(advertisedRole)));
}
- // append to recvBuffer
- recvBuffer.compact();
- if (recvBuffer.remaining() < read) {
- // should not happen with 64k buffer in tests
- LOG.error("Receive buffer overflow");
- break;
+ byte[] readBuf = new byte[8192];
+
+ while (running.get() && !isClosed) {
+ int read;
+ try {
+ read = in.read(readBuf);
+ } catch (SocketTimeoutException e) {
+ continue;
+ }
+ if (read <= 0) {
+ break;
+ }
+
+ // append to recvBuffer
+ recvBuffer.compact();
+ if (recvBuffer.remaining() < read) {
+ // should not happen with 64k buffer in tests
+ LOG.error("Receive buffer overflow");
+ break;
+ }
+ recvBuffer.put(readBuf, 0, read);
+ recvBuffer.flip();
+
+ handleRead();
}
- recvBuffer.put(readBuf, 0, read);
- recvBuffer.flip();
-
- handleRead();
+ } finally {
+ liveConnections.decrementAndGet();
}
} catch (IOException e) {
if (running.get()) {
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
index 3c4b6374..bd3e944a 100644
--- a/core/src/test/java/io/questdb/client/test/example/QuestDBExamples.java
+++ b/core/src/test/java/io/questdb/client/test/example/QuestDBExamples.java
@@ -44,10 +44,9 @@
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;")) {
+ // 1. Connect with a single configuration string. Both sides run over
+ // QWP/WebSocket, so one ws:: string configures ingest and egress.
+ try (QuestDB db = QuestDB.connect("ws::addr=localhost:9000;")) {
ingestWithBorrowedSender(db);
ingestWithThreadAffineSender(db);
queryOneShot(db);
@@ -55,19 +54,19 @@ public static void main(String[] args) throws Exception {
cancelExample(db);
}
- // 2. Authenticated connect: token auth is translated to a Bearer
- // Authorization header on the egress side.
+ // 2. Authenticated connect: token auth becomes a Bearer Authorization
+ // header on both the ingest and egress WebSocket upgrades.
try (QuestDB db = QuestDB.connect(
- "http::addr=db.questdb.cloud:9000;token=YOUR_TOKEN_HERE;")) {
+ "wss::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.
+ // ingest and egress use separate address lists, or when you need to
+ // override defaults.
try (QuestDB db = QuestDB.builder()
- .ingestConfig("http::addr=ingest.cluster:9000;")
+ .ingestConfig("ws::addr=ingest.cluster:9000;")
.queryConfig("ws::addr=read-replica.cluster:9000;")
.senderPoolSize(8)
.queryPoolSize(4)
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
deleted file mode 100644
index 7fdb6e4b..00000000
--- a/core/src/test/java/io/questdb/client/test/impl/ConfigStringTranslatorTest.java
+++ /dev/null
@@ -1,159 +0,0 @@
-/*+*****************************************************************************
- * ___ _ ____ ____
- * / _ \ _ _ ___ ___| |_| _ \| __ )
- * | | | | | | |/ _ \/ __| __| | | | _ \
- * | |_| | |_| | __/\__ \ |_| |_| | |_) |
- * \__\_\\__,_|\___||___/\__|____/|____/
- *
- * 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/ConfigViewTest.java b/core/src/test/java/io/questdb/client/test/impl/ConfigViewTest.java
new file mode 100644
index 00000000..38891719
--- /dev/null
+++ b/core/src/test/java/io/questdb/client/test/impl/ConfigViewTest.java
@@ -0,0 +1,205 @@
+/*+*****************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * 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.ConfigString;
+import io.questdb.client.impl.ConfigView;
+import org.junit.Assert;
+import org.junit.Test;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class ConfigViewTest {
+
+ @Test
+ public void testAddrBareIpv6GetsDefaultPort() {
+ Assert.assertEquals(list("fe80::1:9000"), hostPorts("ws::addr=fe80::1;"));
+ }
+
+ @Test
+ public void testAddrBracketedIpv6() {
+ Assert.assertEquals(list("::1:9000"), hostPorts("ws::addr=[::1];"));
+ Assert.assertEquals(list("::1:9001"), hostPorts("ws::addr=[::1]:9001;"));
+ }
+
+ @Test
+ public void testAddrCommaListAndDefaultPort() {
+ Assert.assertEquals(list("a:9000", "b:9001"), hostPorts("ws::addr=a,b:9001;"));
+ }
+
+ @Test
+ public void testAddrDuplicateRejected() {
+ assertParseError("ws::addr=a,a;", "duplicate addr entry");
+ assertParseError("ws::addr=a:9000,a;", "duplicate addr entry");
+ }
+
+ @Test
+ public void testAddrEmptyEntryRejected() {
+ assertParseError("ws::addr=a,,b;", "empty addr entry");
+ }
+
+ @Test
+ public void testAddrPortAcceptsUnderscoreSeparator() {
+ // Numeric config keys parse with Numbers.parseInt, which treats '_' as
+ // a digit-group separator; the addr port must stay consistent.
+ Assert.assertEquals(list("h:9000"), hostPorts("ws::addr=h:9_000;"));
+ Assert.assertEquals(list("::1:9001"), hostPorts("ws::addr=[::1]:9_001;"));
+ }
+
+ @Test
+ public void testAddrPortOutOfRangeRejected() {
+ assertParseError("ws::addr=h:0;", "port out of range in addr");
+ assertParseError("ws::addr=h:70000;", "port out of range in addr");
+ }
+
+ @Test
+ public void testAddrRepeatedKeysAccumulate() {
+ Assert.assertEquals(list("a:1", "b:2"), hostPorts("ws::addr=a:1;addr=b:2;"));
+ }
+
+ @Test
+ public void testAliasNormalization() {
+ ConfigView v = view("ws::addr=h:9000;user=alice;pass=secret;");
+ Assert.assertEquals("alice", v.getStr("username"));
+ Assert.assertEquals("secret", v.getStr("password"));
+ }
+
+ @Test
+ public void testEnumMessage() {
+ assertParseError("ws::addr=h:9000;compression=gzip;",
+ v -> v.getEnum("compression"),
+ "invalid compression: gzip (expected zstd, raw, auto)");
+ }
+
+ @Test
+ public void testGetIntRangeBounded() {
+ assertParseError("ws::addr=h:9000;compression_level=99;",
+ v -> v.getInt("compression_level", -1),
+ "compression_level must be in [1, 22]");
+ }
+
+ @Test
+ public void testGetIntRangeOneSided() {
+ assertParseError("ws::addr=h:9000;buffer_pool_size=0;",
+ v -> v.getInt("buffer_pool_size", -1),
+ "buffer_pool_size must be >= 1");
+ }
+
+ @Test
+ public void testGetLongStrictLowerBound() {
+ assertParseError("ws::addr=h:9000;auth_timeout_ms=0;",
+ v -> v.getLong("auth_timeout_ms", -1),
+ "auth_timeout_ms must be > 0");
+ }
+
+ @Test
+ public void testGetIntNonNumericRejected() {
+ assertParseError("ws::addr=h:9000;compression_level=abc;",
+ v -> v.getInt("compression_level", -1),
+ "invalid compression_level: abc");
+ }
+
+ @Test
+ public void testGetLongNonNumericRejected() {
+ assertParseError("ws::addr=h:9000;auth_timeout_ms=abc;",
+ v -> v.getLong("auth_timeout_ms", -1),
+ "invalid auth_timeout_ms: abc");
+ }
+
+ @Test
+ public void testGetBoolOnOffInvalidRejected() {
+ assertParseError("ws::addr=h:9000;failover=maybe;",
+ v -> v.getBoolOnOff("failover", false),
+ "invalid failover: maybe (expected on, off)");
+ }
+
+ @Test
+ public void testLastWriteWins() {
+ ConfigView v = view("ws::addr=h:9000;client_id=a;client_id=b;");
+ Assert.assertEquals("b", v.getStr("client_id"));
+ }
+
+ @Test
+ public void testRepeatedTlsVerifyResolvesLastWriteWins() {
+ ConfigView v = view("wss::addr=h:9000;tls_verify=on;tls_verify=unsafe_off;");
+ Assert.assertEquals("unsafe_off", v.getEnum("tls_verify"));
+ }
+
+ @Test
+ public void testTokenizerSemicolonEscaping() {
+ // ConfStringParser escapes ';' as ';;' inside a value.
+ ConfigView v = view("ws::addr=h:9000;client_id=a;;b;");
+ Assert.assertEquals("a;b", v.getStr("client_id"));
+ }
+
+ @Test
+ public void testUnknownKeyRejectedWithHint() {
+ assertParseError("ws::addr=h:9000;init_buf_size=1024;", v -> {
+ }, "unknown configuration key: init_buf_size (applies to legacy http/tcp/udp transports only)");
+ }
+
+ @Test
+ public void testUnknownKeyRejectedWithoutHint() {
+ assertParseError("ws::addr=h:9000;bogus=1;", v -> {
+ }, "unknown configuration key: bogus");
+ }
+
+ private static void assertParseError(String cfg, String expected) {
+ // addr-value errors (duplicate, empty, port range) surface in
+ // getHostPorts, not in the constructor's reject pass.
+ assertParseError(cfg, v -> v.getHostPorts("addr", 9000, (h, p) -> {
+ }), expected);
+ }
+
+ private static void assertParseError(String cfg, java.util.function.Consumer use, String expected) {
+ try {
+ ConfigView v = view(cfg);
+ use.accept(v);
+ Assert.fail("expected error containing: " + expected);
+ } catch (IllegalArgumentException e) {
+ Assert.assertTrue("'" + e.getMessage() + "' should contain '" + expected + "'",
+ e.getMessage().contains(expected));
+ }
+ }
+
+ private static List hostPorts(String cfg) {
+ List got = new ArrayList<>();
+ view(cfg).getHostPorts("addr", 9000, (h, p) -> got.add(h + ":" + p));
+ return got;
+ }
+
+ private static List list(String... items) {
+ List l = new ArrayList<>();
+ for (int i = 0; i < items.length; i++) {
+ l.add(items[i]);
+ }
+ return l;
+ }
+
+ private static ConfigView view(String cfg) {
+ return new ConfigView(ConfigString.parse(cfg));
+ }
+}
diff --git a/core/src/test/java/io/questdb/client/test/impl/PoolConfigHonoredTest.java b/core/src/test/java/io/questdb/client/test/impl/PoolConfigHonoredTest.java
new file mode 100644
index 00000000..34ba4d1a
--- /dev/null
+++ b/core/src/test/java/io/questdb/client/test/impl/PoolConfigHonoredTest.java
@@ -0,0 +1,82 @@
+/*+*****************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * 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.QuestDB;
+import io.questdb.client.QuestDBBuilder;
+import io.questdb.client.impl.ConfigSchema;
+import io.questdb.client.impl.Side;
+import org.junit.Assert;
+import org.junit.Test;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * Proves every POOL key carried in a {@code ws}/{@code wss} connect string is
+ * resolved into the facade's pool config -- not merely accepted. Uses
+ * {@code min=0} so {@code build()} runs resolution without connecting.
+ * {@link #testHonoredCasesCoverEveryPoolRegistryKey} guards against drift.
+ */
+public class PoolConfigHonoredTest {
+
+ @Test
+ public void testEveryPoolKeyIsHonored() {
+ // Drive both the value assertions and the drift guard from one map, so the
+ // coverage check cannot drift from what is actually asserted. min=0 keys
+ // let build() resolve the pool keys without pre-warming/connecting. Pool
+ // sizes resolve to int, the timeouts to long (the snapshot's boxed types).
+ Map expected = new LinkedHashMap<>();
+ expected.put("sender_pool_min", 0);
+ expected.put("sender_pool_max", 7);
+ expected.put("query_pool_min", 0);
+ expected.put("query_pool_max", 5);
+ expected.put("acquire_timeout_ms", 1234L);
+ expected.put("idle_timeout_ms", 4321L);
+ expected.put("max_lifetime_ms", 98765L);
+ expected.put("housekeeper_interval_ms", 222L);
+
+ StringBuilder cfg = new StringBuilder("ws::addr=127.0.0.1:1;");
+ for (Map.Entry e : expected.entrySet()) {
+ cfg.append(e.getKey()).append('=').append(e.getValue()).append(';');
+ }
+ QuestDBBuilder b = QuestDB.builder().fromConfig(cfg.toString());
+ b.build().close();
+
+ Map snap = b.poolConfigSnapshotForTest();
+ for (Map.Entry e : expected.entrySet()) {
+ Assert.assertEquals("pool key '" + e.getKey() + "' not honored", e.getValue(), snap.get(e.getKey()));
+ }
+
+ // Drift guard: every POOL registry key must appear in the map that drove
+ // the assertions above, so a new pool key with no assertion trips this.
+ for (ConfigSchema.KeySpec spec : ConfigSchema.all()) {
+ if (spec.side() == Side.POOL) {
+ Assert.assertTrue("registry pool key '" + spec.name() + "' has no honored assertion",
+ expected.containsKey(spec.name()));
+ }
+ }
+ }
+}
diff --git a/core/src/test/java/io/questdb/client/test/impl/QwpConfigKeysTest.java b/core/src/test/java/io/questdb/client/test/impl/QwpConfigKeysTest.java
new file mode 100644
index 00000000..b0706189
--- /dev/null
+++ b/core/src/test/java/io/questdb/client/test/impl/QwpConfigKeysTest.java
@@ -0,0 +1,163 @@
+/*+*****************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * 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.qwp.client.QwpQueryClient;
+import io.questdb.client.impl.ConfigSchema;
+import io.questdb.client.impl.ConfigView;
+import org.junit.Assert;
+import org.junit.Test;
+
+/**
+ * Guard test for the single QWP key registry ({@link ConfigSchema}): every key
+ * it lists is recognized by both ws clients; the legacy http/tcp/udp keys are
+ * absent and reject with the relocated-key hint; {@code token_x}/{@code token_y}
+ * reject as plain unknowns; and the hint table is exactly the legacy keys.
+ */
+public class QwpConfigKeysTest {
+
+ @Test
+ public void testEverySchemaKeyIsRecognizedByBothClients() {
+ for (ConfigSchema.KeySpec spec : ConfigSchema.all()) {
+ String cfg = "ws::addr=h:9000;" + spec.name() + "=" + sampleValue(spec) + ";";
+ // A key may still fail a cross-key or range check; it must NOT fail
+ // as an unknown key -- that would mean it is missing from the
+ // registry (or that a consumer rejects a key it should ignore).
+ assertNotUnknown(spec.name(), () -> Sender.builder(cfg));
+ assertNotUnknown(spec.name(), () -> QwpQueryClient.fromConfig(cfg).close());
+ }
+ }
+
+ @Test
+ public void testJunkKeyRejectedOnBoth() {
+ assertRejected("ws::addr=h:9000;not_a_real_key=foo;",
+ "unknown configuration key: not_a_real_key");
+ }
+
+ @Test
+ public void testLegacyKeysRejectedWithHintOnBoth() {
+ String legacyHint = "(applies to legacy http/tcp/udp transports only)";
+ assertRejected("ws::addr=h:9000;init_buf_size=1024;",
+ "unknown configuration key: init_buf_size", legacyHint);
+ assertRejected("ws::addr=h:9000;max_buf_size=1024;",
+ "unknown configuration key: max_buf_size", legacyHint);
+ assertRejected("ws::addr=h:9000;request_timeout=1000;",
+ "unknown configuration key: request_timeout", legacyHint);
+ assertRejected("ws::addr=h:9000;request_min_throughput=1000;",
+ "unknown configuration key: request_min_throughput", legacyHint);
+ assertRejected("ws::addr=h:9000;max_datagram_size=1400;",
+ "unknown configuration key: max_datagram_size", legacyHint);
+ assertRejected("ws::addr=h:9000;multicast_ttl=4;",
+ "unknown configuration key: multicast_ttl", legacyHint);
+ assertRejected("ws::addr=h:9000;retry_timeout=1000;",
+ "unknown configuration key: retry_timeout", "(use reconnect_max_duration_millis on ws/wss)");
+ assertRejected("ws::addr=h:9000;protocol_version=2;",
+ "unknown configuration key: protocol_version", "(QWP negotiates the protocol version during the WebSocket upgrade)");
+ }
+
+ @Test
+ public void testRelocatedHintTableIsExactlyTheLegacyKeys() {
+ String legacyHint = "(applies to legacy http/tcp/udp transports only)";
+ Assert.assertEquals(legacyHint, ConfigView.relocatedHint("init_buf_size"));
+ Assert.assertEquals(legacyHint, ConfigView.relocatedHint("max_buf_size"));
+ Assert.assertEquals(legacyHint, ConfigView.relocatedHint("request_timeout"));
+ Assert.assertEquals(legacyHint, ConfigView.relocatedHint("request_min_throughput"));
+ Assert.assertEquals(legacyHint, ConfigView.relocatedHint("max_datagram_size"));
+ Assert.assertEquals(legacyHint, ConfigView.relocatedHint("multicast_ttl"));
+ Assert.assertEquals("(use reconnect_max_duration_millis on ws/wss)", ConfigView.relocatedHint("retry_timeout"));
+ Assert.assertEquals("(QWP negotiates the protocol version during the WebSocket upgrade)", ConfigView.relocatedHint("protocol_version"));
+
+ // No registry key (including POOL keys) carries a relocated hint.
+ for (ConfigSchema.KeySpec spec : ConfigSchema.all()) {
+ Assert.assertNull("registry key '" + spec.name() + "' must not be in the hint table",
+ ConfigView.relocatedHint(spec.name()));
+ }
+ // ECDSA keys are plain unknowns (only the C client handles them).
+ Assert.assertNull(ConfigView.relocatedHint("token_x"));
+ Assert.assertNull(ConfigView.relocatedHint("token_y"));
+ Assert.assertNull(ConfigView.relocatedHint("not_a_real_key"));
+ }
+
+ @Test
+ public void testTokenXYRejectedWithoutHintOnBoth() {
+ assertRejectedNoHint("ws::addr=h:9000;token_x=abc;", "token_x");
+ assertRejectedNoHint("ws::addr=h:9000;token_y=def;", "token_y");
+ }
+
+ private static void assertNotUnknown(String key, Runnable action) {
+ try {
+ action.run();
+ } catch (RuntimeException e) {
+ String msg = e.getMessage();
+ if (msg != null && msg.contains("unknown configuration key")) {
+ Assert.fail("key '" + key + "' rejected as unknown: " + msg);
+ }
+ }
+ }
+
+ private static void assertRejected(String cfg, String... expectedSubstrings) {
+ assertRejected(() -> Sender.builder(cfg), expectedSubstrings);
+ assertRejected(() -> QwpQueryClient.fromConfig(cfg).close(), expectedSubstrings);
+ }
+
+ private static void assertRejected(Runnable action, String... expectedSubstrings) {
+ try {
+ action.run();
+ Assert.fail("expected rejection");
+ } catch (RuntimeException e) {
+ String msg = e.getMessage();
+ Assert.assertNotNull(msg);
+ for (int i = 0; i < expectedSubstrings.length; i++) {
+ Assert.assertTrue("'" + msg + "' should contain '" + expectedSubstrings[i] + "'",
+ msg.contains(expectedSubstrings[i]));
+ }
+ }
+ }
+
+ private static void assertRejectedNoHint(String cfg, String key) {
+ assertRejectedNoHint(() -> Sender.builder(cfg), key);
+ assertRejectedNoHint(() -> QwpQueryClient.fromConfig(cfg).close(), key);
+ }
+
+ private static void assertRejectedNoHint(Runnable action, String key) {
+ try {
+ action.run();
+ Assert.fail("expected rejection of " + key);
+ } catch (RuntimeException e) {
+ String msg = e.getMessage();
+ Assert.assertNotNull(msg);
+ Assert.assertTrue("'" + msg + "' should reject " + key,
+ msg.contains("unknown configuration key: " + key));
+ Assert.assertFalse("'" + msg + "' should carry no hint", msg.contains("("));
+ }
+ }
+
+ private static String sampleValue(ConfigSchema.KeySpec spec) {
+ // The reject pass keys off the name, not the value, so any value proves
+ // recognition; a valid enum member keeps the sample honest for enum keys.
+ return spec.enumValues() != null ? spec.enumValues().getQuick(0) : "1";
+ }
+}
diff --git a/core/src/test/java/io/questdb/client/test/impl/QwpQueryClientConfigHonoredTest.java b/core/src/test/java/io/questdb/client/test/impl/QwpQueryClientConfigHonoredTest.java
new file mode 100644
index 00000000..c5c5edb7
--- /dev/null
+++ b/core/src/test/java/io/questdb/client/test/impl/QwpQueryClientConfigHonoredTest.java
@@ -0,0 +1,128 @@
+/*+*****************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * 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.ClientTlsConfiguration;
+import io.questdb.client.cutlass.qwp.client.QwpQueryClient;
+import io.questdb.client.impl.ConfigSchema;
+import io.questdb.client.impl.Side;
+import org.junit.Assert;
+import org.junit.Test;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Proves every egress key read from a {@code ws}/{@code wss} config string is
+ * actually applied to the {@code QwpQueryClient} -- not merely accepted. The
+ * COMMON credential keys are verified via the synthesized Authorization header,
+ * the COMMON TLS keys via the trust-store snapshot. {@link #testEveryEgressKeyIsHonored}
+ * ends with a drift guard, driven by the keys the assertions record, that fails
+ * if a registry egress key has no honored assertion.
+ */
+public class QwpQueryClientConfigHonoredTest {
+
+ private final Set honored = new HashSet<>();
+
+ @Test
+ public void testEveryEgressKeyIsHonored() {
+ assertHonored("target=primary", "target", "primary");
+ assertHonored("failover=off", "failover", false);
+ assertHonored("failover_max_attempts=9", "failover_max_attempts", 9);
+ assertHonored("failover_backoff_initial_ms=120", "failover_backoff_initial_ms", 120L);
+ assertHonored("failover_backoff_max_ms=99999", "failover_backoff_max_ms", 99999L);
+ assertHonored("failover_max_duration_ms=56000", "failover_max_duration_ms", 56000L);
+ assertHonored("max_batch_rows=512", "max_batch_rows", 512);
+ assertHonored("initial_credit=65536", "initial_credit", 65536L);
+ assertHonored("buffer_pool_size=3", "buffer_pool_size", 3);
+ assertHonored("compression=zstd", "compression", "zstd");
+ assertHonored("compression_level=9", "compression_level", 9);
+ assertHonored("client_id=probe/1.0", "client_id", "probe/1.0");
+ assertHonored("zone=us-east", "zone", "us-east");
+ // COMMON applied by egress.
+ assertHonored("auth_timeout_ms=7777", "auth_timeout_ms", 7777L);
+
+ // Credentials become the Authorization header, including the user/pass aliases.
+ String basic = "Basic " + Base64.getEncoder()
+ .encodeToString("alice:secret".getBytes(StandardCharsets.UTF_8));
+ Assert.assertEquals(basic, snapshot("ws::addr=h:9000;username=alice;password=secret;").get("authorization_header"));
+ Assert.assertEquals(basic, snapshot("ws::addr=h:9000;user=alice;pass=secret;").get("authorization_header"));
+ Assert.assertEquals("Bearer ey.abc", snapshot("ws::addr=h:9000;token=ey.abc;").get("authorization_header"));
+ markHonored("username", "password", "token");
+
+ // COMMON TLS keys applied by egress (require the wss schema). tls_verify
+ // drives the validation mode; tls_roots/tls_roots_password set the trust
+ // store. All three read back from the snapshot.
+ Assert.assertEquals(ClientTlsConfiguration.TLS_VALIDATION_MODE_NONE,
+ snapshot("wss::addr=h:9000;tls_verify=unsafe_off;").get("tls_verify"));
+ Map tls = snapshot("wss::addr=h:9000;tls_roots=/ca.p12;tls_roots_password=pw;");
+ Assert.assertEquals("/ca.p12", tls.get("tls_roots"));
+ Assert.assertEquals("pw", tls.get("tls_roots_password"));
+ markHonored("tls_verify", "tls_roots", "tls_roots_password");
+
+ // Drift guard: every egress-applied registry key must have an assertion
+ // above. The honored set is populated by the assertions themselves, so
+ // deleting one trips this -- unlike a hand-maintained list, it cannot
+ // silently drift from what is actually asserted.
+ for (ConfigSchema.KeySpec spec : ConfigSchema.all()) {
+ if (!spec.name().equals(spec.canonical())) {
+ continue; // alias (user/pass) -- covered via its canonical key
+ }
+ // The egress client applies its own EGRESS keys plus the COMMON keys
+ // (credentials, TLS, auth_timeout_ms). addr is the endpoint list (the
+ // connection target), not a snapshot value, so it is excluded.
+ boolean egressApplied = spec.side() == Side.EGRESS
+ || (spec.side() == Side.COMMON && !spec.name().equals("addr"));
+ if (egressApplied) {
+ Assert.assertTrue("registry egress key '" + spec.name() + "' has no honored assertion",
+ honored.contains(spec.name()));
+ }
+ }
+ }
+
+ private void assertHonored(String kv, String snapKey, Object expected) {
+ markHonored(keyOf(kv));
+ Assert.assertEquals("config '" + kv + "' not honored at '" + snapKey + "'",
+ expected, snapshot("ws::addr=h:9000;" + kv + ";").get(snapKey));
+ }
+
+ private void markHonored(String... keys) {
+ honored.addAll(Arrays.asList(keys));
+ }
+
+ private static String keyOf(String kv) {
+ return kv.substring(0, kv.indexOf('='));
+ }
+
+ private static Map snapshot(String cfg) {
+ try (QwpQueryClient c = QwpQueryClient.fromConfig(cfg)) {
+ return c.configSnapshotForTest();
+ }
+ }
+}
diff --git a/core/src/test/java/io/questdb/client/test/impl/WsSenderConfigHonoredTest.java b/core/src/test/java/io/questdb/client/test/impl/WsSenderConfigHonoredTest.java
new file mode 100644
index 00000000..69453c77
--- /dev/null
+++ b/core/src/test/java/io/questdb/client/test/impl/WsSenderConfigHonoredTest.java
@@ -0,0 +1,139 @@
+/*+*****************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * 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.impl.ConfigSchema;
+import io.questdb.client.impl.Side;
+import org.junit.Assert;
+import org.junit.Test;
+
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Proves every ingress (and ingress-applied COMMON) key read from a {@code ws}/
+ * {@code wss} config string is actually applied to the WebSocket Sender -- not
+ * merely accepted. {@link #testEveryIngressKeyIsHonored} ends with a drift guard,
+ * driven by the keys the assertions themselves record, that fails if a registry
+ * ingress key has no honored assertion.
+ */
+public class WsSenderConfigHonoredTest {
+
+ private final Set honored = new HashSet<>();
+
+ @Test
+ public void testEveryIngressKeyIsHonored() {
+ assertHonored("auto_flush_rows=7", "auto_flush_rows", 7);
+ assertHonored("auto_flush_bytes=8192", "auto_flush_bytes", 8192);
+ assertHonored("auto_flush_interval=250", "auto_flush_interval", 250);
+ // auto_flush=off reaches disableAutoFlush(), which sets the interval to
+ // MAX_VALUE; auto_flush_rows=off leaves the interval unset, so this field
+ // distinguishes the two. That config is build-rejected on WebSocket; see
+ // QuestDBBuilderTest.testMalformedIngressConfigRejectedAtBuildWithMinZero.
+ assertHonored("auto_flush=off", "auto_flush_interval", Integer.MAX_VALUE);
+ assertHonored("max_name_len=99", "max_name_len", 99);
+ assertHonored("transaction=on", "transaction", true);
+ assertHonored("request_durable_ack=on", "request_durable_ack", true);
+ assertHonored("sender_id=probe-1", "sender_id", "probe-1");
+ assertHonored("sf_dir=/var/probe", "sf_dir", "/var/probe");
+ assertHonored("sf_max_bytes=4096", "sf_max_bytes", 4096L);
+ assertHonored("sf_max_total_bytes=8192", "sf_max_total_bytes", 8192L);
+ assertHonored("sf_durability=flush", "sf_durability", "FLUSH");
+ assertHonored("sf_append_deadline_millis=1500", "sf_append_deadline_millis", 1500L);
+ assertHonored("close_flush_timeout_millis=2500", "close_flush_timeout_millis", 2500L);
+ assertHonored("durable_ack_keepalive_interval_millis=900", "durable_ack_keepalive_interval_millis", 900L);
+ assertHonored("initial_connect_retry=async", "initial_connect_retry", "ASYNC");
+ assertHonored("reconnect_max_duration_millis=12345", "reconnect_max_duration_millis", 12345L);
+ assertHonored("reconnect_initial_backoff_millis=111", "reconnect_initial_backoff_millis", 111L);
+ assertHonored("reconnect_max_backoff_millis=2222", "reconnect_max_backoff_millis", 2222L);
+ assertHonored("drain_orphans=on", "drain_orphans", true);
+ assertHonored("max_background_drainers=6", "max_background_drainers", 6);
+ assertHonored("error_inbox_capacity=128", "error_inbox_capacity", 128);
+ assertHonored("connection_listener_inbox_capacity=64", "connection_listener_inbox_capacity", 64);
+ assertHonored("token=ey.abc", "token", "ey.abc");
+ assertHonored("auth_timeout_ms=4321", "auth_timeout_ms", 4321L);
+
+ // username/password together (both-or-neither), and the user/pass aliases.
+ Map creds = snapshot("ws::addr=h:9000;username=alice;password=secret;");
+ Assert.assertEquals("alice", creds.get("username"));
+ Assert.assertEquals("secret", creds.get("password"));
+ Map aliasCreds = snapshot("ws::addr=h:9000;user=bob;pass=pw;");
+ Assert.assertEquals("bob", aliasCreds.get("username"));
+ Assert.assertEquals("pw", aliasCreds.get("password"));
+ markHonored("username", "password");
+
+ // tls keys require wss; tls_roots must be paired with its password.
+ assertHonoredWss("tls_verify=unsafe_off", "tls_verify", "INSECURE");
+ Map tls = snapshot("wss::addr=h:9000;tls_roots=/ca.p12;tls_roots_password=pw;");
+ Assert.assertEquals("/ca.p12", tls.get("tls_roots"));
+ Assert.assertEquals("pw", tls.get("tls_roots_password"));
+ markHonored("tls_roots", "tls_roots_password");
+
+ // Drift guard: every ingress-applied registry key must have an assertion
+ // above. The honored set is populated by the assertions themselves, so
+ // deleting one trips this -- unlike a hand-maintained list, it cannot
+ // silently drift from what is actually asserted.
+ for (ConfigSchema.KeySpec spec : ConfigSchema.all()) {
+ if (!spec.name().equals(spec.canonical())) {
+ continue; // alias (user/pass) -- covered via its canonical key
+ }
+ // INGRESS keys plus the COMMON keys the sender applies; addr is the
+ // endpoint list (the connection target), not an applied config value.
+ boolean ingressApplied = spec.side() == Side.INGRESS
+ || (spec.side() == Side.COMMON && !spec.name().equals("addr"));
+ if (ingressApplied) {
+ Assert.assertTrue("registry ingress key '" + spec.name() + "' has no honored assertion",
+ honored.contains(spec.name()));
+ }
+ }
+ }
+
+ private void assertHonored(String kv, String snapKey, Object expected) {
+ markHonored(keyOf(kv));
+ Assert.assertEquals("config '" + kv + "' not honored at '" + snapKey + "'",
+ expected, snapshot("ws::addr=h:9000;" + kv + ";").get(snapKey));
+ }
+
+ private void assertHonoredWss(String kv, String snapKey, Object expected) {
+ markHonored(keyOf(kv));
+ Assert.assertEquals("config '" + kv + "' not honored at '" + snapKey + "'",
+ expected, snapshot("wss::addr=h:9000;" + kv + ";").get(snapKey));
+ }
+
+ private void markHonored(String... keys) {
+ honored.addAll(Arrays.asList(keys));
+ }
+
+ private static String keyOf(String kv) {
+ return kv.substring(0, kv.indexOf('='));
+ }
+
+ private static Map snapshot(String cfg) {
+ return Sender.builder(cfg).wsConfigSnapshotForTest();
+ }
+}