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(); + } +}