From 6527575879c109e596ef27e292a227c94bd0ab87 Mon Sep 17 00:00:00 2001 From: Marko Topolnik Date: Thu, 18 Jun 2026 10:04:34 +0200 Subject: [PATCH 01/20] Remove the auth config parameter Drop the `auth` configuration-string key, which accepted a pre-formed HTTP Authorization header value, in favor of the structured `token`, `username`, and `password` keys. The Authorization header is now synthesized in one place downstream: QwpQueryClient builds it from the structured credentials via withBasicAuth/withBearerToken, the same way the Sender already does. The unified QuestDB.connect(...) translator no longer builds an `auth=Bearer ` header for the derived ws/wss side. Instead it mirrors `token` / `username` / `password` verbatim and lets the egress parser build the header. This also lifts the previous limitation where username/password could not be carried to the derived ws/wss side. The `auth` key is now rejected as an unknown configuration key by both the ingress (Sender) and egress (QwpQueryClient) parsers. The programmatic QwpQueryClient.withAuthorization(String) raw-header setter is removed as well, so there is no remaining path that accepts a pre-formed header; credentials must be supplied as token or username/password. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../main/java/io/questdb/client/Sender.java | 3 +- .../cutlass/qwp/client/QwpQueryClient.java | 26 +++------- .../client/impl/ConfigStringTranslator.java | 48 ++++++++----------- .../cutlass/line/LineSenderBuilderTest.java | 4 +- .../client/QwpQueryClientFromConfigTest.java | 28 +++-------- .../QwpQueryClientPostConnectGuardTest.java | 2 - .../test/impl/ConfigStringTranslatorTest.java | 22 ++++----- 7 files changed, 46 insertions(+), 87 deletions(-) diff --git a/core/src/main/java/io/questdb/client/Sender.java b/core/src/main/java/io/questdb/client/Sender.java index 8e9513b1..bd33874f 100644 --- a/core/src/main/java/io/questdb/client/Sender.java +++ b/core/src/main/java/io/questdb/client/Sender.java @@ -3284,8 +3284,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) 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..a1ce1288 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 @@ -319,14 +319,13 @@ private QwpQueryClient(String host, int port) { * 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,7 +354,7 @@ 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;path=/read/v1;token=abc123;client_id=dashboard/2.0;
          *   ws::addr=db-a:9000,db-b:9000,db-c:9000;target=primary;failover=on;
          * 
    */ @@ -388,7 +387,6 @@ public static QwpQueryClient fromConfig(CharSequence configurationString) { Long failoverMaxDurationMs = null; Long authTimeoutMs = null; Long initialCredit = null; - String auth = null; String username = null; String password = null; String token = null; @@ -497,9 +495,6 @@ public static QwpQueryClient fromConfig(CharSequence configurationString) { case "path": path = value; break; - case "auth": - auth = value; - break; case "username": username = value; break; @@ -631,10 +626,9 @@ public static QwpQueryClient fromConfig(CharSequence configurationString) { if (hasBasic && (username == null || password == null)) { throw new IllegalArgumentException("both username and password must be provided together"); } - int authModesSet = (auth != null ? 1 : 0) + (hasBasic ? 1 : 0) + (token != null ? 1 : 0); - if (authModesSet > 1) { + if (hasBasic && token != null) { throw new IllegalArgumentException( - "auth, username/password, and token are mutually exclusive"); + "username/password and token are mutually exclusive"); } if (!tls && (tlsValidation != null || tlsRoots != null || tlsRootsPassword != null)) { throw new IllegalArgumentException( @@ -692,7 +686,6 @@ public static QwpQueryClient fromConfig(CharSequence configurationString) { client.withTls(); } } - if (auth != null) client.withAuthorization(auth); if (hasBasic) client.withBasicAuth(username, password); if (token != null) client.withBearerToken(token); if (cid != null) client.withClientId(cid); @@ -1114,11 +1107,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 diff --git a/core/src/main/java/io/questdb/client/impl/ConfigStringTranslator.java b/core/src/main/java/io/questdb/client/impl/ConfigStringTranslator.java index 5888a775..bf1fe4be 100644 --- a/core/src/main/java/io/questdb/client/impl/ConfigStringTranslator.java +++ b/core/src/main/java/io/questdb/client/impl/ConfigStringTranslator.java @@ -45,8 +45,9 @@ * *

    * 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. + * A curated subset of keys carries over to the derived side (addr, + * credentials -- token or username/password -- and TLS settings); 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. @@ -94,7 +95,6 @@ public static Bundle deriveBothSides(CharSequence config) { 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(); @@ -102,7 +102,6 @@ public static Bundle deriveBothSides(CharSequence config) { boolean hasToken = false; boolean hasUsername = false; boolean hasPassword = false; - boolean hasAuth = false; boolean hasTlsRoots = false; boolean hasTlsRootsPassword = false; boolean hasTlsVerify = false; @@ -155,11 +154,6 @@ public static Bundle deriveBothSides(CharSequence config) { password.put(sink); hasPassword = true; break; - case "auth": - auth.clear(); - auth.put(sink); - hasAuth = true; - break; case "tls_roots": tlsRoots.clear(); tlsRoots.put(sink); @@ -188,14 +182,14 @@ public static Bundle deriveBothSides(CharSequence config) { String query; if (isHttp) { ingest = inputPassthrough.toString(); - query = buildQueryConfig(isTls, addr, hasToken, token, hasUsername, - hasPassword, hasAuth, auth, + query = buildQueryConfig(isTls, addr, hasToken, token, + hasUsername, username, hasPassword, password, hasTlsRoots, tlsRoots, hasTlsRootsPassword, tlsRootsPassword, hasTlsVerify, tlsVerify); } else { query = inputPassthrough.toString(); ingest = buildIngestConfig(isTls, addr, hasToken, token, hasUsername, username, - hasPassword, password, hasAuth, auth, + hasPassword, password, hasTlsRoots, tlsRoots, hasTlsRootsPassword, tlsRootsPassword, hasTlsVerify, tlsVerify); } @@ -221,7 +215,6 @@ private static String buildIngestConfig( 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 @@ -238,9 +231,6 @@ private static String buildIngestConfig( if (hasPassword) { appendKv(out, "password", password); } - if (hasAuth && !hasToken && !hasUsername) { - appendKv(out, "auth", auth); - } if (hasTlsRoots) { appendKv(out, "tls_roots", tlsRoots); } @@ -257,9 +247,8 @@ private static String buildQueryConfig( boolean isTls, CharSequence addr, boolean hasToken, CharSequence token, - boolean hasUsername, - boolean hasPassword, - boolean hasAuth, CharSequence auth, + boolean hasUsername, CharSequence username, + boolean hasPassword, CharSequence password, boolean hasTlsRoots, CharSequence tlsRoots, boolean hasTlsRootsPassword, CharSequence tlsRootsPassword, boolean hasTlsVerify, CharSequence tlsVerify @@ -267,16 +256,17 @@ private static String buildQueryConfig( 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()"); + // Mirror the structured credentials; QwpQueryClient synthesizes the + // Authorization header from them downstream (Bearer from token, Basic + // from username/password). + if (hasToken) { + appendKv(out, "token", token); + } + if (hasUsername) { + appendKv(out, "username", username); + } + if (hasPassword) { + appendKv(out, "password", password); } if (isTls) { if (hasTlsRoots) { 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..80e54b7c 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,7 @@ 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("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"); @@ -360,7 +361,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", @@ -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 12 keys at once -- a typical shared-config connect string. assertConfStrOk(all.toString()); // Out-of-range / malformed values are silently consumed too -- the 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..d8c684a7 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,7 @@ 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" + "username/password and token are mutually exclusive" ); } 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..6270a2a9 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 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 index 7fdb6e4b..b03c895f 100644 --- a/core/src/test/java/io/questdb/client/test/impl/ConfigStringTranslatorTest.java +++ b/core/src/test/java/io/questdb/client/test/impl/ConfigStringTranslatorTest.java @@ -45,7 +45,7 @@ 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); + Assert.assertEquals("ws::addr=db.host:9000;token=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); @@ -136,24 +136,24 @@ public void testTcpSchemaIsRejected() { } @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")); - } + public void testUsernamePasswordMirroredToWsDerivation() { + // Structured Basic-auth credentials carry over to the derived ws side; + // QwpQueryClient synthesizes the Authorization header from them. + ConfigStringTranslator.Bundle bundle = ConfigStringTranslator.deriveBothSides( + "http::addr=h:9000;username=u;password=p;"); + Assert.assertEquals("http::addr=h:9000;username=u;password=p;", bundle.ingestConfig); + Assert.assertEquals("ws::addr=h:9000;username=u;password=p;", bundle.queryConfig); } @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); + "ws::addr=db.host:9000;token=foo;"); + Assert.assertEquals("ws::addr=db.host:9000;token=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")); + Assert.assertTrue(bundle.ingestConfig.contains("token=foo")); } } From 75df997431762dc05dd6190a5373c9c3a3fde0c3 Mon Sep 17 00:00:00 2001 From: Marko Topolnik Date: Thu, 18 Jun 2026 12:24:22 +0200 Subject: [PATCH 02/20] Remove the in_flight_window parameter Drop `in_flight_window` from the QWP config-string parsers. The ingress Sender accepted it as a no-op for backward compatibility and the egress QwpQueryClient silently consumed it; both now reject it as an unknown configuration key, like any other unsupported parameter. The cursor / store-and-forward architecture governs backpressure via the segment ring and append deadline, so the in-flight window count had no effect. There was no programmatic setter to remove. Co-Authored-By: Claude Opus 4.8 (1M context) --- core/src/main/java/io/questdb/client/Sender.java | 4 ---- .../client/cutlass/qwp/client/QwpQueryClient.java | 1 - .../test/cutlass/line/LineSenderBuilderTest.java | 10 +++++----- .../qwp/client/QwpQueryClientFromConfigTest.java | 8 +++++++- 4 files changed, 12 insertions(+), 11 deletions(-) diff --git a/core/src/main/java/io/questdb/client/Sender.java b/core/src/main/java/io/questdb/client/Sender.java index bd33874f..28a0931e 100644 --- a/core/src/main/java/io/questdb/client/Sender.java +++ b/core/src/main/java/io/questdb/client/Sender.java @@ -3307,10 +3307,6 @@ 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 { // sf-client.md §4.6: parser must reject unknown keys. // Forward-compat is via the spec, not silent ignore — silent 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 a1ce1288..999e3821 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 @@ -590,7 +590,6 @@ public static QwpQueryClient fromConfig(CharSequence configurationString) { 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": 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 80e54b7c..07b36865 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 @@ -275,11 +275,11 @@ public void testConfStringValidation() throws Exception { // 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]"); }); } 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 d8c684a7..c38a3abe 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 @@ -609,6 +609,13 @@ public void testFullKitchenSinkAccepted() { assertParses(conf); } + @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. @@ -632,7 +639,6 @@ 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", From fc16eb6634fa0ff1d80111c87a2895fca7a0276b Mon Sep 17 00:00:00 2001 From: Marko Topolnik Date: Thu, 18 Jun 2026 14:29:32 +0200 Subject: [PATCH 03/20] Remove the gorilla ingress parameter Drop the gorilla=on|off config key, the LineSenderBuilder.gorilla() method, and setGorillaEnabled/isGorillaEnabled on QwpWebSocketSender and QwpWebSocketEncoder, along with the connect() plumbing that threaded the flag through. The WebSocket encoder now always sets FLAG_GORILLA and passes useGorilla=true to the column writer. The UDP sender keeps useGorilla=false (it never used Gorilla), so the shared QwpColumnWriter retains its useGorilla parameter. The egress QwpResultBatchDecoder is unchanged and still handles both forms. Update the encoder, sender, and builder tests for the always-on behavior; the egress decoder tests are untouched. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../main/java/io/questdb/client/Sender.java | 22 ---- .../qwp/client/QwpWebSocketEncoder.java | 23 +--- .../qwp/client/QwpWebSocketSender.java | 26 +---- .../LineSenderBuilderWebSocketTest.java | 44 -------- .../qwp/client/QwpWebSocketEncoderTest.java | 105 +++--------------- .../qwp/client/QwpWebSocketSenderTest.java | 21 ---- 6 files changed, 23 insertions(+), 218 deletions(-) diff --git a/core/src/main/java/io/questdb/client/Sender.java b/core/src/main/java/io/questdb/client/Sender.java index 28a0931e..439ddd7a 100644 --- a/core/src/main/java/io/questdb/client/Sender.java +++ b/core/src/main/java/io/questdb/client/Sender.java @@ -1029,7 +1029,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; @@ -1517,7 +1516,6 @@ public Sender build() { actualErrorInboxCapacity, actualDurableAckKeepaliveIntervalMillis, authTimeoutMillis, - gorillaEnabled, connectionListener, actualConnectionListenerInboxCapacity ); @@ -1876,14 +1874,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. *
    @@ -3183,18 +3173,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( 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 e34a1923..62126e31 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 @@ -217,7 +217,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 @@ -533,7 +532,7 @@ public static QwpWebSocketSender connect( closeFlushTimeoutMillis, reconnectMaxDurationMillis, reconnectInitialBackoffMillis, reconnectMaxBackoffMillis, initialConnectMode, errorHandler, errorInboxCapacity, - durableAckKeepaliveIntervalMillis, DEFAULT_AUTH_TIMEOUT_MS, true); + durableAckKeepaliveIntervalMillis, DEFAULT_AUTH_TIMEOUT_MS); } /** @@ -562,8 +561,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, @@ -571,7 +569,7 @@ public static QwpWebSocketSender connect( closeFlushTimeoutMillis, reconnectMaxDurationMillis, reconnectInitialBackoffMillis, reconnectMaxBackoffMillis, initialConnectMode, errorHandler, errorInboxCapacity, - durableAckKeepaliveIntervalMillis, authTimeoutMs, gorillaEnabled, + durableAckKeepaliveIntervalMillis, authTimeoutMs, null, SenderConnectionDispatcher.DEFAULT_CAPACITY); } @@ -597,7 +595,6 @@ public static QwpWebSocketSender connect( int errorInboxCapacity, long durableAckKeepaliveIntervalMillis, long authTimeoutMs, - boolean gorillaEnabled, SenderConnectionListener connectionListener, int connectionListenerInboxCapacity ) { @@ -609,8 +606,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; @@ -1817,13 +1812,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. * @@ -2055,14 +2043,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/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..02afa318 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"); 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 271501b5..7c65ae77 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(() -> { From 7bc3725559a27bf5a575945171c761371bdef49a Mon Sep 17 00:00:00 2001 From: Marko Topolnik Date: Thu, 18 Jun 2026 15:22:04 +0200 Subject: [PATCH 04/20] Accept transaction key in QWP query client config The transaction connect-string key configures transactional ingestion and applies to the Sender (ingress) only; it requires the WebSocket transport. A single connect string must be shareable between the Sender and the QwpQueryClient, so the egress parser has to silently consume the ingress-only keys instead of rejecting them. transaction was missing from QwpQueryClient.fromConfig's passthrough list, so a shared connect string carrying it fell through to the default branch and failed with "unknown configuration key". Add the case alongside the other ingress-only keys and extend the lock-in test that enumerates them. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../io/questdb/client/cutlass/qwp/client/QwpQueryClient.java | 1 + .../test/cutlass/qwp/client/QwpQueryClientFromConfigTest.java | 1 + 2 files changed, 2 insertions(+) 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 999e3821..ed6f75b7 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 @@ -612,6 +612,7 @@ public static QwpQueryClient fromConfig(CharSequence configurationString) { case "sf_durability": case "sf_max_bytes": case "sf_max_total_bytes": + case "transaction": case "user": break; default: 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 c38a3abe..7c8240d9 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 @@ -661,6 +661,7 @@ public void testIngressOnlyKeysSilentlyAcceptedOnEgress() { "sf_durability=memory", "sf_max_bytes=4m", "sf_max_total_bytes=10g", + "transaction=on", "user=alice", }; StringBuilder all = new StringBuilder("ws::addr=db:9000;"); From 98644406fed2b86dbb9c40050a218a4e29ec0925 Mon Sep 17 00:00:00 2001 From: Marko Topolnik Date: Thu, 18 Jun 2026 15:54:27 +0200 Subject: [PATCH 05/20] Reject non-QWP keys on ws:: connect strings 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). A ws:: / wss:: string is shared by the Sender and the QwpQueryClient, so both must treat these keys the same way. The Sender (ingress) parser now rejects them on the WebSocket transport while still accepting them on http/tcp. The QwpQueryClient (egress) parser drops them from its ingress-key ignore-list, so they surface as unknown keys. protocol_version is rejected for any value, including the no-op "auto", matching the egress side and the other language clients. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../main/java/io/questdb/client/Sender.java | 12 ++++++ .../cutlass/qwp/client/QwpQueryClient.java | 4 -- .../LineSenderBuilderWebSocketTest.java | 41 +++++++++++++++++++ .../client/QwpQueryClientFromConfigTest.java | 26 ++++++++++-- 4 files changed, 75 insertions(+), 8 deletions(-) diff --git a/core/src/main/java/io/questdb/client/Sender.java b/core/src/main/java/io/questdb/client/Sender.java index 439ddd7a..6c43cdc5 100644 --- a/core/src/main/java/io/questdb/client/Sender.java +++ b/core/src/main/java/io/questdb/client/Sender.java @@ -3013,6 +3013,9 @@ private LineSenderBuilder fromConfig(CharSequence configurationString) { httpToken(sink.toString()); } } else if (Chars.equals("retry_timeout", sink)) { + if (protocol == PROTOCOL_WEBSOCKET) { + throw new LineSenderException("retry_timeout is not supported for WebSocket transport; use reconnect_max_duration_millis for the per-outage reconnect budget"); + } pos = getValue(configurationString, pos, sink, "retry_timeout"); int timeout = parseIntValue(sink, "retry_timeout"); retryTimeoutMillis(timeout); @@ -3094,15 +3097,24 @@ private LineSenderBuilder fromConfig(CharSequence configurationString) { throw new LineSenderException("invalid auto_flush [value=").put(sink).put(", allowed-values=[on, off]]"); } } else if (Chars.equals("request_timeout", sink)) { + if (protocol == PROTOCOL_WEBSOCKET) { + throw new LineSenderException("request_timeout is not supported for WebSocket transport"); + } pos = getValue(configurationString, pos, sink, "request_timeout"); int requestTimeout = parseIntValue(sink, "request_timeout"); httpTimeoutMillis(requestTimeout); } else if (Chars.equals("request_min_throughput", sink)) { + if (protocol == PROTOCOL_WEBSOCKET) { + throw new LineSenderException("request_min_throughput is not supported for WebSocket transport"); + } pos = getValue(configurationString, pos, sink, "request_min_throughput"); int requestMinThroughput = parseIntValue(sink, "request_min_throughput"); minRequestThroughput(requestMinThroughput); } else if (Chars.equals("protocol_version", sink)) { pos = getValue(configurationString, pos, sink, "protocol_version"); + if (protocol == PROTOCOL_WEBSOCKET) { + throw new LineSenderException("protocol_version is not supported for WebSocket transport; QWP negotiates the protocol version during the WebSocket upgrade"); + } if (!Chars.equalsIgnoreCase("auto", sink)) { int protocolVersion = parseIntValue(sink, "protocol_version"); protocolVersion(protocolVersion); 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 ed6f75b7..4e7c26b5 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 @@ -598,14 +598,10 @@ public static QwpQueryClient fromConfig(CharSequence configurationString) { 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": 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 02afa318..e84b97ae 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 @@ -786,6 +786,47 @@ public void testWsConfigString_withMaxSchemasPerConnection_fails() { "unknown configuration key [key=max_schemas_per_connection]"); } + @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;", + "protocol_version is not supported for WebSocket transport"); + } + + @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;", + "protocol_version is not supported for WebSocket transport"); + } + + @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;", + "request_min_throughput is not supported for WebSocket transport"); + } + + @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;", + "request_timeout is not supported for WebSocket transport"); + } + + @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;", + "retry_timeout is not supported for WebSocket transport"); + } + @Test public void testWsConfigString_withToken() throws Exception { assertMemoryLeak(() -> { 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 7c8240d9..f5fd7d3b 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 @@ -647,14 +647,10 @@ public void testIngressOnlyKeysSilentlyAcceptedOnEgress() { "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", @@ -807,6 +803,28 @@ 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. + assertReject("ws::addr=db:9000;request_timeout=10000;", + "unknown configuration key: request_timeout"); + assertReject("ws::addr=db:9000;retry_timeout=10000;", + "unknown configuration key: retry_timeout"); + assertReject("ws::addr=db:9000;request_min_throughput=102400;", + "unknown configuration key: request_min_throughput"); + assertReject("ws::addr=db:9000;protocol_version=2;", + "unknown configuration key: protocol_version"); + // 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"); + } + @Test public void testNullStringRejected() { assertReject(null, "configuration string cannot be empty"); From 091c6353ea51be9745426d17163991e2d39a6174 Mon Sep 17 00:00:00 2001 From: Marko Topolnik Date: Thu, 18 Jun 2026 17:22:02 +0200 Subject: [PATCH 06/20] Tighten QWP connect-string key validation A single ws:: connect string is shared by the Sender (ingress) and the QwpQueryClient (egress), so both parsers must treat every key the same way. Both parsers now accept the reserved on_*error keys (on_internal_error, on_parse_error, on_schema_error, on_security_error, on_server_error, on_write_error) as no-ops, matching the connect-string spec guidance that clients accept them. max_datagram_size and multicast_ttl configure the UDP transport only. The Sender rejects them on the WebSocket transport while still accepting them on udp::, and the QwpQueryClient drops them from its ingress-key ignore list so they surface as unknown keys. This keeps the shared ws:: vocabulary free of UDP-only knobs. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../main/java/io/questdb/client/Sender.java | 21 ++++++ .../cutlass/qwp/client/QwpQueryClient.java | 14 +++- .../LineSenderBuilderWebSocketTest.java | 75 +++++++++++++++++++ .../client/QwpQueryClientFromConfigTest.java | 33 +++++++- 4 files changed, 139 insertions(+), 4 deletions(-) diff --git a/core/src/main/java/io/questdb/client/Sender.java b/core/src/main/java/io/questdb/client/Sender.java index 6c43cdc5..b918f09d 100644 --- a/core/src/main/java/io/questdb/client/Sender.java +++ b/core/src/main/java/io/questdb/client/Sender.java @@ -3262,10 +3262,16 @@ private LineSenderBuilder fromConfig(CharSequence configurationString) { pos = getValue(configurationString, pos, sink, "reconnect_max_backoff_millis"); reconnectMaxBackoffMillis(parseLongValue(sink, "reconnect_max_backoff_millis")); } else if (Chars.equals("max_datagram_size", sink)) { + if (protocol == PROTOCOL_WEBSOCKET) { + throw new LineSenderException("max_datagram_size is not supported for WebSocket transport; it applies to the UDP transport only"); + } pos = getValue(configurationString, pos, sink, "max_datagram_size"); int mds = parseIntValue(sink, "max_datagram_size"); maxDatagramSize(mds); } else if (Chars.equals("multicast_ttl", sink)) { + if (protocol == PROTOCOL_WEBSOCKET) { + throw new LineSenderException("multicast_ttl is not supported for WebSocket transport; it applies to the UDP transport only"); + } pos = getValue(configurationString, pos, sink, "multicast_ttl"); int ttl = parseIntValue(sink, "multicast_ttl"); multicastTtl(ttl); @@ -3297,6 +3303,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("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 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 4e7c26b5..b3d29b4e 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 @@ -594,9 +594,19 @@ public static QwpQueryClient fromConfig(CharSequence configurationString) { case "initial_connect_retry": case "max_background_drainers": case "max_buf_size": - case "max_datagram_size": case "max_name_len": - case "multicast_ttl": + // 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 Java client does not wire them to a policy + // yet, so the egress parser consumes them as an accepted no-op + // rather than rejecting them. + case "on_internal_error": + case "on_parse_error": + case "on_schema_error": + case "on_security_error": + case "on_server_error": + case "on_write_error": case "pass": case "reconnect_initial_backoff_millis": case "reconnect_max_backoff_millis": 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 e84b97ae..b35b622e 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 @@ -786,6 +786,81 @@ public void testWsConfigString_withMaxSchemasPerConnection_fails() { "unknown configuration key [key=max_schemas_per_connection]"); } + @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", + "path=/read/v1", + "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;", + "max_datagram_size is not supported for WebSocket transport"); + } + + @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;", + "multicast_ttl is not supported for WebSocket transport"); + } + @Test public void testWsConfigString_withProtocolVersionAuto_fails() { // protocol_version is not part of the QWP connect-string vocabulary at 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 f5fd7d3b..1313c707 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 @@ -643,9 +643,7 @@ public void testIngressOnlyKeysSilentlyAcceptedOnEgress() { "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", "reconnect_initial_backoff_millis=100", "reconnect_max_backoff_millis=5000", @@ -823,6 +821,13 @@ public void testNonQwpKeysRejectedOnEgress() { // has no "auto" pass-through. assertReject("ws::addr=db:9000;protocol_version=auto;", "unknown configuration key: protocol_version"); + // 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"); + assertReject("ws::addr=db:9000;multicast_ttl=4;", + "unknown configuration key: multicast_ttl"); } @Test @@ -835,6 +840,30 @@ public void testPathOverrideAccepted() { assertParses("ws::addr=db:9000;path=/custom/read;"); } + @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 public void testTargetAnyAccepted() { assertParses("ws::addr=db:9000;target=any;"); From 8843a495ba611158be0cb3cf7b6597888e65cabb Mon Sep 17 00:00:00 2001 From: Marko Topolnik Date: Fri, 19 Jun 2026 18:48:47 +0200 Subject: [PATCH 07/20] Remove the path parameter Drop the `path` key from the QWP config-string parsers and remove the `withEndpointPath` setter from `QwpQueryClient`. The key chose the URI of the egress WebSocket upgrade, defaulting to /read/v1. The server accepts only /read/v1 and its /api/v1/read alias for that endpoint, so the key selected between two synonyms and added no real configurability. The egress QwpQueryClient now rejects `path` as an unknown key, and the ingress Sender drops it from its egress-key ignore list, so both parsers treat it the same way on a shared ws:: connect string. The egress WebSocket upgrade uses the fixed /read/v1 constant (DEFAULT_ENDPOINT_PATH) directly. The ingress write endpoint stays fixed at /write/v4, and the legacy ILP httpPath() / httpSettingPath() builder setters are unrelated and remain in place. Co-Authored-By: Claude Opus 4.8 (1M context) --- core/src/main/java/io/questdb/client/Sender.java | 1 - .../cutlass/qwp/client/QwpQueryClient.java | 16 ++-------------- .../test/cutlass/line/LineSenderBuilderTest.java | 4 +--- .../client/LineSenderBuilderWebSocketTest.java | 10 +++++++++- .../qwp/client/QwpQueryClientFromConfigTest.java | 8 +++++--- .../QwpQueryClientPostConnectGuardTest.java | 2 -- 6 files changed, 17 insertions(+), 24 deletions(-) diff --git a/core/src/main/java/io/questdb/client/Sender.java b/core/src/main/java/io/questdb/client/Sender.java index b918f09d..702e0fe9 100644 --- a/core/src/main/java/io/questdb/client/Sender.java +++ b/core/src/main/java/io/questdb/client/Sender.java @@ -3291,7 +3291,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 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 b3d29b4e..a249827f 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 @@ -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,7 +317,6 @@ 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 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 @@ -354,7 +352,7 @@ private QwpQueryClient(String host, int port) { * Examples: *
          *   ws::addr=localhost:9000;
    -     *   ws::addr=db.internal:9000;path=/read/v1;token=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;
          * 
    */ @@ -378,7 +376,6 @@ public static QwpQueryClient fromConfig(CharSequence configurationString) { } List parsedEndpoints = new ArrayList<>(); - String path = DEFAULT_ENDPOINT_PATH; String target = TARGET_ANY; Boolean failover = null; Integer failoverMaxAttempts = null; @@ -492,9 +489,6 @@ public static QwpQueryClient fromConfig(CharSequence configurationString) { authTimeoutMs = parsed; break; } - case "path": - path = value; - break; case "username": username = value; break; @@ -680,7 +674,6 @@ public static QwpQueryClient fromConfig(CharSequence configurationString) { if (initialCredit != null) { client.withInitialCredit(initialCredit); } - client.withEndpointPath(path); client.withBufferPoolSize(poolSize); client.withCompression(compression, compressionLevel); if (tls) { @@ -1187,11 +1180,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 @@ -1962,7 +1950,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/test/java/io/questdb/client/test/cutlass/line/LineSenderBuilderTest.java b/core/src/test/java/io/questdb/client/test/cutlass/line/LineSenderBuilderTest.java index 07b36865..8a1612c3 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 @@ -371,7 +371,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 +378,7 @@ public void testEgressOnlyQueryClientKeysSilentlyAcceptedOnIngress() throws Exce assertConfStrOk("http::addr=" + LOCALHOST + ";" + kv + ";protocol_version=2;"); all.append(kv).append(';'); } - // All 12 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 +393,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 b35b622e..557fa667 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 @@ -786,6 +786,15 @@ public void testWsConfigString_withMaxSchemasPerConnection_fails() { "unknown configuration key [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 [key=path]"); + } + @Test public void testWsConfigString_withEgressOnlyKeysSilentlyAccepted() { // connect-string.md "Query client keys" and "Multi-host failover": these @@ -806,7 +815,6 @@ public void testWsConfigString_withEgressOnlyKeysSilentlyAccepted() { "failover_max_duration_ms=30000", "initial_credit=1048576", "max_batch_rows=512", - "path=/read/v1", "target=primary", }; StringBuilder all = new StringBuilder("ws::addr=localhost:9000;"); 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 1313c707..073cd6e2 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 @@ -600,7 +600,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;" @@ -836,8 +836,10 @@ public void testNullStringRejected() { } @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 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 6270a2a9..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 @@ -57,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 From e7147fca0b3ad18731071f5bdd712bb8054df178 Mon Sep 17 00:00:00 2001 From: Marko Topolnik Date: Tue, 23 Jun 2026 13:39:18 +0200 Subject: [PATCH 08/20] Make QuestDB facade QWP-over-WebSocket only The QuestDB facade now speaks only QWP over WebSocket. It previously routed single-string ingest to HTTP via a schema-translation step, which was a mistake. The facade (single-string connect, two-arg connect, and the builder's ingestConfig/queryConfig) now requires the ws or wss schema and hands the connect string verbatim to both clients. A new parser package (io.questdb.client.impl) replaces the translator: - ConfigString tokenizes schema::k=v;k=v over ConfStringParser. - ConfigSchema is the static ws/wss key registry (one vocabulary), carrying each key's side, type, range, enum, and alias. - ConfigView runs the reject pass (unknown key, plus a relocated-key hint for legacy http/tcp/udp keys), the typed getters, and an IPv6-aware addr parser (getHostPorts) with duplicate rejection. QwpQueryClient.fromConfig reparses over ConfigView and now honors the user/pass aliases; its shared validateConfig is reused by the facade fail-fast path. Sender.fromConfig gates ws/wss to a new fromConfigWebSocket driven by ConfigView; the legacy http/tcp/udp loop is left byte-for-byte untouched, so standalone Sender.fromConfig keeps those transports. QuestDBBuilder validates both strings up front (even when a pool min is 0 and nothing connects) and resolves pool keys with explicit-wins precedence and cross-string conflict detection. ConfigStringTranslator and its test are removed. New ConfigViewTest and QwpConfigKeysTest cover the parser and guard the registry; the ws, egress, and facade suites are updated for the colon-dialect messages, relocated-key hints, and last-write-wins behavior. The full client test suite passes (2351 tests). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../main/java/io/questdb/client/QuestDB.java | 13 +- .../io/questdb/client/QuestDBBuilder.java | 168 +++++-- .../main/java/io/questdb/client/Sender.java | 258 ++++++++++ .../cutlass/qwp/client/QwpQueryClient.java | 476 ++++-------------- .../io/questdb/client/impl/ConfigSchema.java | 234 +++++++++ .../io/questdb/client/impl/ConfigString.java | 96 ++++ .../client/impl/ConfigStringTranslator.java | 366 -------------- .../io/questdb/client/impl/ConfigView.java | 299 +++++++++++ .../io/questdb/client/impl/HostPortSink.java | 35 ++ .../java/io/questdb/client/impl/Side.java | 54 ++ .../client/test/QuestDBBuilderTest.java | 174 +++++-- .../line/LineSenderAddrParsingTest.java | 4 +- .../cutlass/line/LineSenderBuilderTest.java | 10 +- .../LineSenderBuilderWebSocketTest.java | 43 +- .../client/QwpQueryClientFromConfigTest.java | 65 ++- .../client/test/example/QuestDBExamples.java | 19 +- .../test/impl/ConfigStringTranslatorTest.java | 159 ------ .../client/test/impl/ConfigViewTest.java | 178 +++++++ .../client/test/impl/QwpConfigKeysTest.java | 173 +++++++ 19 files changed, 1775 insertions(+), 1049 deletions(-) create mode 100644 core/src/main/java/io/questdb/client/impl/ConfigSchema.java create mode 100644 core/src/main/java/io/questdb/client/impl/ConfigString.java delete mode 100644 core/src/main/java/io/questdb/client/impl/ConfigStringTranslator.java create mode 100644 core/src/main/java/io/questdb/client/impl/ConfigView.java create mode 100644 core/src/main/java/io/questdb/client/impl/HostPortSink.java create mode 100644 core/src/main/java/io/questdb/client/impl/Side.java delete mode 100644 core/src/test/java/io/questdb/client/test/impl/ConfigStringTranslatorTest.java create mode 100644 core/src/test/java/io/questdb/client/test/impl/ConfigViewTest.java create mode 100644 core/src/test/java/io/questdb/client/test/impl/QwpConfigKeysTest.java 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 7a037698..4b23635f 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 io.questdb.client.impl.Side; + +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,10 +86,11 @@ 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. + * {@code max} when load demands and reaped back to {@code min} when idle. */ public QuestDB build() { if (ingestConfig == null) { @@ -81,6 +99,24 @@ 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, Side.INGRESS); + ConfigView queryView = new ConfigView(queryCs, Side.EGRESS); + Sender.LineSenderBuilder.validateWsConfig(ingestView, "wss".equals(ingestCs.schema())); + QwpQueryClient.validateConfig(queryView, "wss".equals(queryCs.schema())); + + // getInt/getLong ignore the view's side, so the INGRESS/EGRESS 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, @@ -96,42 +132,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; } @@ -162,9 +170,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; } @@ -183,11 +193,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; } @@ -265,4 +275,62 @@ public QuestDBBuilder senderPoolSize(int size) { this.senderPoolMax = size; return this; } + + 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 702e0fe9..0f3581e8 100644 --- a/core/src/main/java/io/questdb/client/Sender.java +++ b/core/src/main/java/io/questdb/client/Sender.java @@ -39,6 +39,9 @@ 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.impl.Side; import io.questdb.client.network.NetworkFacade; import io.questdb.client.network.NetworkFacadeImpl; import io.questdb.client.std.Chars; @@ -2782,6 +2785,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; @@ -2910,6 +2918,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; @@ -3350,6 +3362,252 @@ 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, Side.INGRESS); + 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 (including its leniencies -- {@code username} without + * {@code password} does not throw). + */ + 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"); + if (user == null && password != null) { + throw new IllegalArgumentException("password is configured, but username is missing"); + } + 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"); + } + } + + 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); + } + /** * Use HTTP protocol as transport. *
    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 a249827f..3b6dcf4a 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.std.Chars; +import io.questdb.client.impl.ConfigString; +import io.questdb.client.impl.ConfigView; +import io.questdb.client.impl.Side; 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; @@ -357,293 +357,59 @@ private QwpQueryClient(String host, int port) { * */ 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, Side.EGRESS); + validateConfig(view, tls); List parsedEndpoints = new ArrayList<>(); - 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 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 key = sink.toString(); - pos = ConfStringParser.value(configurationString, pos, sink); - if (pos < 0) { - throw new IllegalArgumentException("invalid configuration string [error=" + sink + "]"); - } - String value = sink.toString(); - switch (key) { - case "addr": - // 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; - } - 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 "init_buf_size": - case "initial_connect_retry": - case "max_background_drainers": - case "max_buf_size": - case "max_name_len": - // 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 Java client does not wire them to a policy - // yet, so the egress parser consumes them as an accepted no-op - // rather than rejecting them. - case "on_internal_error": - case "on_parse_error": - case "on_schema_error": - case "on_security_error": - case "on_server_error": - case "on_write_error": - case "pass": - case "reconnect_initial_backoff_millis": - case "reconnect_max_backoff_millis": - case "reconnect_max_duration_millis": - case "request_durable_ack": - case "sender_id": - case "sf_append_deadline_millis": - case "sf_dir": - case "sf_durability": - case "sf_max_bytes": - case "sf_max_total_bytes": - case "transaction": - case "user": - break; - default: - throw new IllegalArgumentException("unknown configuration key: " + key); - } - } - if (parsedEndpoints.isEmpty()) { - throw new IllegalArgumentException("missing required key: addr"); + 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; - if (hasBasic && (username == null || password == null)) { - throw new IllegalArgumentException("both username and password must be provided together"); - } - if (hasBasic && token != null) { - throw new IllegalArgumentException( - "username/password and token are mutually exclusive"); - } - if (!tls && (tlsValidation != 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++) { @@ -693,6 +459,58 @@ public static QwpQueryClient fromConfig(CharSequence configurationString) { return client; } + /** + * 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); + 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"); + boolean hasBasic = username != null || password != null; + if (hasBasic && (username == null || password == null)) { + throw new IllegalArgumentException("both username and password must be provided together"); + } + if (hasBasic && token != null) { + throw new IllegalArgumentException("username/password and token are mutually exclusive"); + } + 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 (backoffInitial != -1 && backoffMax != -1 && backoffMax < backoffInitial) { + throw new IllegalArgumentException( + "failover_backoff_max_ms must be >= failover_backoff_initial_ms"); + } + } + /** * Creates a plain-text (non-TLS) QWP query client against {@code host:port} * with all other settings at their defaults. For TLS, authentication, @@ -986,6 +804,16 @@ 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; + } + /** * Returns the current compression preference: one of {@code raw} (the * library default, no compression), {@code zstd} (demand zstd), or @@ -1384,96 +1212,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 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..3acb1193 --- /dev/null +++ b/core/src/main/java/io/questdb/client/impl/ConfigSchema.java @@ -0,0 +1,234 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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, ValueType.STRING, false, OPEN, OPEN_MAX, false, false, null, canonical)); + } + + private static void boolOnOff(String name, Side side) { + add(new KeySpec(name, side, ValueType.BOOL_ON_OFF, false, 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, ValueType.ENUM, false, OPEN, OPEN_MAX, false, false, list, name)); + } + + private static void hostPort(String name) { + add(new KeySpec(name, Side.COMMON, ValueType.HOST_PORT_LIST, true, 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, ValueType.INT, false, 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, ValueType.LONG, false, min, max, minOpen, maxOpen, null, name)); + } + + private static void str(String name, Side side) { + add(new KeySpec(name, side, ValueType.STRING, false, OPEN, OPEN_MAX, false, false, null, name)); + } + + public enum ValueType { + STRING, INT, LONG, BOOL_ON_OFF, ENUM, HOST_PORT_LIST + } + + /** + * 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 boolean multi; + final String name; + final Side side; + final ValueType type; + + KeySpec( + String name, Side side, ValueType type, boolean multi, + long min, long max, boolean minOpen, boolean maxOpen, + ObjList enumValues, String canonical + ) { + this.name = name; + this.side = side; + this.type = type; + this.multi = multi; + this.min = min; + this.max = max; + this.minOpen = minOpen; + this.maxOpen = maxOpen; + this.enumValues = enumValues; + this.canonical = canonical; + } + + public ObjList enumValues() { + return enumValues; + } + + public String name() { + return name; + } + + public Side side() { + return side; + } + + public ValueType type() { + return type; + } + } +} 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 bf1fe4be..00000000 --- a/core/src/main/java/io/questdb/client/impl/ConfigStringTranslator.java +++ /dev/null @@ -1,366 +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, - * credentials -- token or username/password -- and TLS settings); 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 tlsRoots = new StringSink(); - StringSink tlsRootsPassword = new StringSink(); - StringSink tlsVerify = new StringSink(); - boolean hasAddr = false; - boolean hasToken = false; - boolean hasUsername = false; - boolean hasPassword = 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 "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, username, hasPassword, password, - hasTlsRoots, tlsRoots, hasTlsRootsPassword, tlsRootsPassword, - hasTlsVerify, tlsVerify); - } else { - query = inputPassthrough.toString(); - ingest = buildIngestConfig(isTls, addr, hasToken, token, hasUsername, username, - hasPassword, password, - 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 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 (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, CharSequence username, - boolean hasPassword, CharSequence password, - 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); - // Mirror the structured credentials; QwpQueryClient synthesizes the - // Authorization header from them downstream (Bearer from token, Basic - // from username/password). - if (hasToken) { - appendKv(out, "token", token); - } - if (hasUsername) { - appendKv(out, "username", username); - } - if (hasPassword) { - appendKv(out, "password", password); - } - 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..3dcb298f --- /dev/null +++ b/core/src/main/java/io/questdb/client/impl/ConfigView.java @@ -0,0 +1,299 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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.HashSet; +import java.util.Map; + +/** + * Layer 3 of the QWP connect-string parser: a typed, validated view over a + * {@link ConfigString} for a given {@link ConfigSchema} and consumer + * {@link Side}. 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. A + * consumer reads only the keys its side owns; foreign values are accepted + * syntactically and validated by their 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 = Map.of( + "retry_timeout", "(use reconnect_max_duration_millis on ws/wss)", + "protocol_version", "(QWP negotiates the protocol version during the WebSocket upgrade)", + "init_buf_size", "(applies to legacy http/tcp/udp transports only)", + "max_buf_size", "(applies to legacy http/tcp/udp transports only)", + "request_timeout", "(applies to legacy http/tcp/udp transports only)", + "request_min_throughput", "(applies to legacy http/tcp/udp transports only)", + "max_datagram_size", "(applies to legacy http/tcp/udp transports only)", + "multicast_ttl", "(applies to legacy http/tcp/udp transports only)" + ); + + private final ObjList normKeys = new ObjList<>(); + private final ObjList normValues = new ObjList<>(); + private final Side selfSide; + + public ConfigView(ConfigString cs, Side selfSide) { + this.selfSide = selfSide; + 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; + } + + public Side selfSide() { + return selfSide; + } + + 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 = 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; + } + + 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..c5b8bf0a --- /dev/null +++ b/core/src/main/java/io/questdb/client/impl/Side.java @@ -0,0 +1,54 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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. A {@link ConfigView} built for a + * given side applies keys whose side is its own or {@link #COMMON}, and ignores + * the rest. + */ +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..49595ce1 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,84 @@ package io.questdb.client.test; import io.questdb.client.QuestDB; +import io.questdb.client.test.cutlass.qwp.websocket.TestWebSocketServer; import org.junit.Assert; import org.junit.Test; +import java.util.concurrent.TimeUnit; + 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. + try (QuestDB ignored = 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) + .build()) { + Assert.assertNotNull(ignored); + } + } + + @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 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 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. + 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;") + .acquireTimeoutMillis(500) + .build()) { + Assert.assertNotNull(ignored); + } + try (QuestDB ignored = 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;") + .build()) { + Assert.assertNotNull(ignored); + } + } + + @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 testMissingIngestConfigThrows() { try { @@ -43,7 +116,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 +147,71 @@ 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 tells us the sender-pool unwind ran. + } + } + } + + @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 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 assertSchemaRejected(Runnable action) { try { - QuestDB.builder() - .ingestConfig("http::addr=127.0.0.1:1;protocol_version=2;auto_flush=off;") - .queryConfig("ws::addr=127.0.0.1:1;auth_timeout_ms=200;failover=off;") - .senderPoolSize(2) - .queryPoolSize(2) - .acquireTimeoutMillis(500) - .build() - .close(); - Assert.fail("expected build to fail when query pool cannot connect"); - } catch (RuntimeException expected) { - // The exact exception type comes from QwpQueryClient.connect(); - // we only assert the build failed so we know cleanup ran. + 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")); } } } 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 8a1612c3..36a25ce2 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 @@ -223,10 +223,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"); @@ -269,7 +269,7 @@ 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. 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 557fa667..7a3987d5 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 @@ -375,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 @@ -723,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"); }); } @@ -758,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 @@ -769,12 +774,12 @@ public void testWsConfigString_withAutoFlushBytesInvalid_fails() { @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 @@ -783,7 +788,7 @@ 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 @@ -792,7 +797,7 @@ public void testWsConfigString_withPath_fails() { // 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 [key=path]"); + "unknown configuration key: path"); } @Test @@ -858,7 +863,7 @@ 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;", - "max_datagram_size is not supported for WebSocket transport"); + "unknown configuration key: max_datagram_size (applies to legacy http/tcp/udp transports only)"); } @Test @@ -866,7 +871,7 @@ 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;", - "multicast_ttl is not supported for WebSocket transport"); + "unknown configuration key: multicast_ttl (applies to legacy http/tcp/udp transports only)"); } @Test @@ -875,7 +880,7 @@ public void testWsConfigString_withProtocolVersionAuto_fails() { // 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;", - "protocol_version is not supported for WebSocket transport"); + "unknown configuration key: protocol_version (QWP negotiates the protocol version during the WebSocket upgrade)"); } @Test @@ -883,7 +888,7 @@ 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;", - "protocol_version is not supported for WebSocket transport"); + "unknown configuration key: protocol_version (QWP negotiates the protocol version during the WebSocket upgrade)"); } @Test @@ -891,7 +896,7 @@ 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;", - "request_min_throughput is not supported for WebSocket transport"); + "unknown configuration key: request_min_throughput (applies to legacy http/tcp/udp transports only)"); } @Test @@ -899,7 +904,7 @@ 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;", - "request_timeout is not supported for WebSocket transport"); + "unknown configuration key: request_timeout (applies to legacy http/tcp/udp transports only)"); } @Test @@ -907,7 +912,7 @@ 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;", - "retry_timeout is not supported for WebSocket transport"); + "unknown configuration key: retry_timeout (use reconnect_max_duration_millis on ws/wss)"); } @Test @@ -944,12 +949,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 073cd6e2..212c971c 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 @@ -293,6 +293,36 @@ public void testBasicAuthWithUsernameOnlyRejected() { ); } + @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;", + "both username and password must be provided together" + ); + } + + @Test + public void testPassAliasAloneRejected() { + assertReject( + "ws::addr=db:9000;pass=secret;", + "both username and password must be provided together" + ); + } + @Test public void testBufferPoolSizeLowerBoundRejected() { assertReject("ws::addr=db:9000;buffer_pool_size=0;", "buffer_pool_size must be >= 1"); @@ -338,7 +368,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)" ); } @@ -495,7 +525,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)" ); } @@ -639,12 +669,9 @@ public void testIngressOnlyKeysSilentlyAcceptedOnEgress() { "drain_orphans=on", "durable_ack_keepalive_interval_millis=200", "error_inbox_capacity=256", - "init_buf_size=65536", "initial_connect_retry=on", "max_background_drainers=4", - "max_buf_size=100m", "max_name_len=127", - "pass=secret", "reconnect_initial_backoff_millis=100", "reconnect_max_backoff_millis=5000", "reconnect_max_duration_millis=300000", @@ -656,7 +683,6 @@ public void testIngressOnlyKeysSilentlyAcceptedOnEgress() { "sf_max_bytes=4m", "sf_max_total_bytes=10g", "transaction=on", - "user=alice", }; StringBuilder all = new StringBuilder("ws::addr=db:9000;"); for (String kv : keys) { @@ -669,7 +695,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. @@ -809,25 +834,33 @@ public void testNonQwpKeysRejectedOnEgress() { // 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"); + "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"); + "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"); + "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"); + "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"); + "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"); + "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"); + "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 @@ -875,7 +908,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)" ); } @@ -922,7 +955,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)" ); } 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 b03c895f..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;token=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 testUsernamePasswordMirroredToWsDerivation() { - // Structured Basic-auth credentials carry over to the derived ws side; - // QwpQueryClient synthesizes the Authorization header from them. - ConfigStringTranslator.Bundle bundle = ConfigStringTranslator.deriveBothSides( - "http::addr=h:9000;username=u;password=p;"); - Assert.assertEquals("http::addr=h:9000;username=u;password=p;", bundle.ingestConfig); - Assert.assertEquals("ws::addr=h:9000;username=u;password=p;", bundle.queryConfig); - } - - @Test - public void testWsInputPassesThroughAndDerivesHttp() { - ConfigStringTranslator.Bundle bundle = ConfigStringTranslator.deriveBothSides( - "ws::addr=db.host:9000;token=foo;"); - Assert.assertEquals("ws::addr=db.host:9000;token=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")); - Assert.assertTrue(bundle.ingestConfig.contains("token=foo")); - } -} 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..f98325c1 --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/impl/ConfigViewTest.java @@ -0,0 +1,178 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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.ConfigSchema; +import io.questdb.client.impl.ConfigString; +import io.questdb.client.impl.ConfigView; +import io.questdb.client.impl.Side; +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 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;", Side.EGRESS); + Assert.assertEquals("alice", v.getStr("username")); + Assert.assertEquals("secret", v.getStr("password")); + } + + @Test + public void testEnumMessage() { + assertParseError("ws::addr=h:9000;compression=gzip;", Side.EGRESS, + v -> v.getEnum("compression"), + "invalid compression: gzip (expected zstd, raw, auto)"); + } + + @Test + public void testGetIntRangeBounded() { + assertParseError("ws::addr=h:9000;compression_level=99;", Side.EGRESS, + 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;", Side.EGRESS, + 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;", Side.EGRESS, + v -> v.getLong("auth_timeout_ms", -1), + "auth_timeout_ms must be > 0"); + } + + @Test + public void testLastWriteWins() { + ConfigView v = view("ws::addr=h:9000;client_id=a;client_id=b;", Side.EGRESS); + 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;", Side.EGRESS); + 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;", Side.EGRESS); + Assert.assertEquals("a;b", v.getStr("client_id")); + } + + @Test + public void testUnknownKeyRejectedWithHint() { + assertParseError("ws::addr=h:9000;init_buf_size=1024;", Side.INGRESS, 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;", Side.INGRESS, 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, Side.INGRESS, v -> v.getHostPorts("addr", 9000, (h, p) -> { + }), expected); + } + + private static void assertParseError(String cfg, Side side, java.util.function.Consumer use, String expected) { + try { + ConfigView v = view(cfg, side); + 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, Side.INGRESS).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, Side side) { + return new ConfigView(ConfigString.parse(cfg), side); + } +} 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..0c9f824f --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/impl/QwpConfigKeysTest.java @@ -0,0 +1,173 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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) { + switch (spec.type()) { + case INT: + case LONG: + return "1"; + case BOOL_ON_OFF: + return "on"; + case ENUM: + return spec.enumValues().getQuick(0); + case HOST_PORT_LIST: + return "h2:9001"; + default: + return "x"; + } + } +} From ff4d25764236121bad26014d6ac3232fe9a59eca Mon Sep 17 00:00:00 2001 From: Marko Topolnik Date: Tue, 23 Jun 2026 14:34:30 +0200 Subject: [PATCH 09/20] Add tests that every config key is honored The recognition test (QwpConfigKeysTest) proves every ws/wss key is accepted, but not that its value is applied. Add a per-key "honored" suite that closes that gap for the ingress Sender, the egress QwpQueryClient, and the facade pool, plus a drift guard per side that fails if a registry key ever lacks a honored case. Each consumer gains one @TestOnly snapshot method keyed by connect-string key name (LineSenderBuilder.wsConfigSnapshotForTest, QwpQueryClient.configSnapshotForTest, QuestDBBuilder.poolConfigSnapshotForTest); KeySpec gains a canonical() accessor for the alias check. Each test sets a key in a config string and asserts the snapshot reflects the applied value -- credentials via the synthesized Authorization header, including the user/pass aliases. addr is left to the addr-parsing tests, RESERVED keys are no-ops by design, and egress TLS state is enforced by validateConfig; everything else is asserted honored. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../io/questdb/client/QuestDBBuilder.java | 20 +++ .../main/java/io/questdb/client/Sender.java | 42 ++++++ .../cutlass/qwp/client/QwpQueryClient.java | 26 ++++ .../io/questdb/client/impl/ConfigSchema.java | 4 + .../test/impl/PoolConfigHonoredTest.java | 79 +++++++++++ .../impl/QwpQueryClientConfigHonoredTest.java | 101 ++++++++++++++ .../test/impl/WsSenderConfigHonoredTest.java | 128 ++++++++++++++++++ 7 files changed, 400 insertions(+) create mode 100644 core/src/test/java/io/questdb/client/test/impl/PoolConfigHonoredTest.java create mode 100644 core/src/test/java/io/questdb/client/test/impl/QwpQueryClientConfigHonoredTest.java create mode 100644 core/src/test/java/io/questdb/client/test/impl/WsSenderConfigHonoredTest.java diff --git a/core/src/main/java/io/questdb/client/QuestDBBuilder.java b/core/src/main/java/io/questdb/client/QuestDBBuilder.java index 4b23635f..41391cfc 100644 --- a/core/src/main/java/io/questdb/client/QuestDBBuilder.java +++ b/core/src/main/java/io/questdb/client/QuestDBBuilder.java @@ -29,6 +29,7 @@ import io.questdb.client.impl.ConfigView; import io.questdb.client.impl.QuestDBImpl; import io.questdb.client.impl.Side; +import org.jetbrains.annotations.TestOnly; import java.util.function.IntConsumer; import java.util.function.LongConsumer; @@ -276,6 +277,25 @@ public QuestDBBuilder senderPoolSize(int 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)) { diff --git a/core/src/main/java/io/questdb/client/Sender.java b/core/src/main/java/io/questdb/client/Sender.java index 0f3581e8..31fee0da 100644 --- a/core/src/main/java/io/questdb/client/Sender.java +++ b/core/src/main/java/io/questdb/client/Sender.java @@ -56,6 +56,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; @@ -3608,6 +3609,47 @@ private static long wsSize(ConfigView view, StringSink v, String 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("auto_flush_disabled", autoFlushRows == AUTO_FLUSH_DISABLED); + 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. *
    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 3b6dcf4a..c31f7ac8 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 @@ -814,6 +814,32 @@ 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); + return m; + } + /** * Returns the current compression preference: one of {@code raw} (the * library default, no compression), {@code zstd} (demand zstd), or diff --git a/core/src/main/java/io/questdb/client/impl/ConfigSchema.java b/core/src/main/java/io/questdb/client/impl/ConfigSchema.java index 3acb1193..ba4d67d1 100644 --- a/core/src/main/java/io/questdb/client/impl/ConfigSchema.java +++ b/core/src/main/java/io/questdb/client/impl/ConfigSchema.java @@ -215,6 +215,10 @@ public static final class KeySpec { this.canonical = canonical; } + public String canonical() { + return canonical; + } + public ObjList enumValues() { return enumValues; } 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..ec0e30a8 --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/impl/PoolConfigHonoredTest.java @@ -0,0 +1,79 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +package io.questdb.client.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.Arrays; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +/** + * 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 { + + private static final Set COVERED = new HashSet<>(Arrays.asList( + "sender_pool_min", "sender_pool_max", "query_pool_min", "query_pool_max", + "acquire_timeout_ms", "idle_timeout_ms", "max_lifetime_ms", "housekeeper_interval_ms" + )); + + @Test + public void testEveryPoolKeyIsHonored() { + String cfg = "ws::addr=127.0.0.1:1;sender_pool_min=0;sender_pool_max=7;query_pool_min=0;query_pool_max=5;" + + "acquire_timeout_ms=1234;idle_timeout_ms=4321;max_lifetime_ms=98765;housekeeper_interval_ms=222;"; + QuestDBBuilder b = QuestDB.builder().fromConfig(cfg); + // min=0 -> build() resolves the pool keys but pre-warms/connects nothing. + b.build().close(); + Map snap = b.poolConfigSnapshotForTest(); + Assert.assertEquals(0, snap.get("sender_pool_min")); + Assert.assertEquals(7, snap.get("sender_pool_max")); + Assert.assertEquals(0, snap.get("query_pool_min")); + Assert.assertEquals(5, snap.get("query_pool_max")); + Assert.assertEquals(1234L, snap.get("acquire_timeout_ms")); + Assert.assertEquals(4321L, snap.get("idle_timeout_ms")); + Assert.assertEquals(98765L, snap.get("max_lifetime_ms")); + Assert.assertEquals(222L, snap.get("housekeeper_interval_ms")); + } + + @Test + public void testHonoredCasesCoverEveryPoolRegistryKey() { + for (ConfigSchema.KeySpec spec : ConfigSchema.all()) { + if (spec.side() == Side.POOL) { + Assert.assertTrue("registry pool key '" + spec.name() + "' has no honored case", + COVERED.contains(spec.name())); + } + } + } +} 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..11c14b37 --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/impl/QwpQueryClientConfigHonoredTest.java @@ -0,0 +1,101 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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.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. + * {@link #testHonoredCasesCoverEveryEgressRegistryKey} guards against drift. + */ +public class QwpQueryClientConfigHonoredTest { + + private static final Set EGRESS_COVERED = new HashSet<>(Arrays.asList( + "target", "failover", "failover_max_attempts", "failover_backoff_initial_ms", + "failover_backoff_max_ms", "failover_max_duration_ms", "max_batch_rows", + "initial_credit", "buffer_pool_size", "compression", "compression_level", + "client_id", "zone" + )); + + @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")); + } + + @Test + public void testHonoredCasesCoverEveryEgressRegistryKey() { + for (ConfigSchema.KeySpec spec : ConfigSchema.all()) { + if (spec.side() == Side.EGRESS) { + Assert.assertTrue("registry egress key '" + spec.name() + "' has no honored case", + EGRESS_COVERED.contains(spec.name())); + } + } + } + + private static void assertHonored(String kv, String snapKey, Object expected) { + Assert.assertEquals("config '" + kv + "' not honored at '" + snapKey + "'", + expected, snapshot("ws::addr=h:9000;" + kv + ";").get(snapKey)); + } + + 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..2e659792 --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/impl/WsSenderConfigHonoredTest.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.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 #testHonoredCasesCoverEveryIngressRegistryKey} fails + * if a registry key gains coverage drift (a new key with no honored case). + */ +public class WsSenderConfigHonoredTest { + + private static final Set COVERED = new HashSet<>(Arrays.asList( + "auto_flush", "auto_flush_rows", "auto_flush_bytes", "auto_flush_interval", + "max_name_len", "transaction", "request_durable_ack", "sender_id", "sf_dir", + "sf_max_bytes", "sf_max_total_bytes", "sf_durability", "sf_append_deadline_millis", + "close_flush_timeout_millis", "durable_ack_keepalive_interval_millis", + "initial_connect_retry", "reconnect_max_duration_millis", "reconnect_initial_backoff_millis", + "reconnect_max_backoff_millis", "drain_orphans", "max_background_drainers", + "error_inbox_capacity", "connection_listener_inbox_capacity", + "token", "auth_timeout_ms", "username", "password", "tls_verify", "tls_roots", "tls_roots_password" + )); + + @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); + assertHonored("auto_flush=off", "auto_flush_disabled", true); + 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")); + + // 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")); + } + + @Test + public void testHonoredCasesCoverEveryIngressRegistryKey() { + for (ConfigSchema.KeySpec spec : ConfigSchema.all()) { + if (!spec.name().equals(spec.canonical())) { + continue; // alias (user/pass) -- covered via its canonical key + } + boolean ingressApplied = spec.side() == Side.INGRESS + || (spec.side() == Side.COMMON && spec.type() != ConfigSchema.ValueType.HOST_PORT_LIST); + if (ingressApplied) { + Assert.assertTrue("registry ingress key '" + spec.name() + "' has no honored case", + COVERED.contains(spec.name())); + } + } + } + + private static void assertHonored(String kv, String snapKey, Object expected) { + Assert.assertEquals("config '" + kv + "' not honored at '" + snapKey + "'", + expected, snapshot("ws::addr=h:9000;" + kv + ";").get(snapKey)); + } + + private static void assertHonoredWss(String kv, String snapKey, Object expected) { + Assert.assertEquals("config '" + kv + "' not honored at '" + snapKey + "'", + expected, snapshot("wss::addr=h:9000;" + kv + ";").get(snapKey)); + } + + private static Map snapshot(String cfg) { + return Sender.builder(cfg).wsConfigSnapshotForTest(); + } +} From 15b39013d4f2e232abada6354014b722d96b515e Mon Sep 17 00:00:00 2001 From: Marko Topolnik Date: Tue, 23 Jun 2026 18:43:13 +0200 Subject: [PATCH 10/20] Fail fast on malformed config in build() QuestDB.builder().build() promised to validate both connect strings up front -- failing even when a pool min is 0 and nothing connects -- but the ingress check was incomplete. It used a hand-written cross-key replica that did not validate the tls_verify enum or any of the registry-STRING ingress value keys, and with sender_pool_min=0 the sender pool pre-warmed nothing, so the ingest string was never parsed until the first borrowSender(). A malformed ingest config therefore slipped past build(). build() now validates the ingest string the same way the pool will, minus the socket: it runs the real Sender parse on a throwaway builder (fromConfig + configureDefaults + validateParameters), exactly the prefix build() runs before opening a connection. This catches the tls_verify enum, every STRING-typed value through the real setters, and the WebSocket build-time checks such as auto_flush_interval=off. It is zero-drift because it is the real validation, and it neither connects nor allocates native memory. On the egress side, validateConfig now checks the failover backoff ordering against the effective, default-filled initial/max, mirroring fromConfig's withFailoverBackoff. failover_backoff_max_ms set alone below the default initial backoff is now rejected during validation rather than only when a client is constructed. Add a facade test that a malformed ingest value (a typed enum, a STRING value, and the auto_flush_interval=off build-time check) is rejected at build() with sender_pool_min=0, and an egress test for the max-alone failover backoff case. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../io/questdb/client/QuestDBBuilder.java | 8 ++++- .../main/java/io/questdb/client/Sender.java | 19 +++++++++++ .../cutlass/qwp/client/QwpQueryClient.java | 16 +++++++-- .../client/test/QuestDBBuilderTest.java | 33 +++++++++++++++++++ .../client/QwpQueryClientFromConfigTest.java | 13 ++++++++ 5 files changed, 85 insertions(+), 4 deletions(-) diff --git a/core/src/main/java/io/questdb/client/QuestDBBuilder.java b/core/src/main/java/io/questdb/client/QuestDBBuilder.java index 41391cfc..233b4d97 100644 --- a/core/src/main/java/io/questdb/client/QuestDBBuilder.java +++ b/core/src/main/java/io/questdb/client/QuestDBBuilder.java @@ -104,7 +104,13 @@ public QuestDB build() { ConfigString queryCs = ConfigString.parse(queryConfig); ConfigView ingestView = new ConfigView(ingestCs, Side.INGRESS); ConfigView queryView = new ConfigView(queryCs, Side.EGRESS); - Sender.LineSenderBuilder.validateWsConfig(ingestView, "wss".equals(ingestCs.schema())); + // 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())); // getInt/getLong ignore the view's side, so the INGRESS/EGRESS views diff --git a/core/src/main/java/io/questdb/client/Sender.java b/core/src/main/java/io/questdb/client/Sender.java index 31fee0da..42fcc07e 100644 --- a/core/src/main/java/io/questdb/client/Sender.java +++ b/core/src/main/java/io/questdb/client/Sender.java @@ -3591,6 +3591,25 @@ static void validateWsConfig(ConfigView view, boolean tls) { } } + /** + * 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)); 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 c31f7ac8..b8bfed50 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 @@ -480,6 +480,9 @@ public static void validateConfig(ConfigView view, boolean tls) { 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); @@ -505,9 +508,16 @@ public static void validateConfig(ConfigView view, boolean tls) { if ((tlsRoots == null) != (tlsRootsPassword == null)) { throw new IllegalArgumentException("tls_roots and tls_roots_password must be provided together"); } - if (backoffInitial != -1 && backoffMax != -1 && backoffMax < backoffInitial) { - throw new IllegalArgumentException( - "failover_backoff_max_ms must be >= failover_backoff_initial_ms"); + // 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"); + } } } 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 49595ce1..03199c03 100644 --- a/core/src/test/java/io/questdb/client/test/QuestDBBuilderTest.java +++ b/core/src/test/java/io/questdb/client/test/QuestDBBuilderTest.java @@ -103,6 +103,23 @@ public void testHttpSingleConfigRejected() { assertSchemaRejected(() -> QuestDB.builder().fromConfig("http::addr=h:9000;")); } + @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 a WebSocket build-time check that only the full + // no-connect validation reaches (auto_flush_interval=off disables + // auto-flush, which the WebSocket transport does not support). + 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"); + } + @Test public void testMissingIngestConfigThrows() { try { @@ -206,6 +223,22 @@ public void testUdpIngestConfigRejected() { assertSchemaRejected(() -> QuestDB.builder().queryConfig("udp::addr=h:9009;")); } + private static void assertIngressBuildRejected(String ingest, String expectedFragment) { + try { + QuestDB.builder() + .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 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(); 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 212c971c..cc831c3a 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 @@ -485,6 +485,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;"); From f4479a1a906c597963912255bb2984f832871062 Mon Sep 17 00:00:00 2001 From: Marko Topolnik Date: Tue, 23 Jun 2026 18:50:08 +0200 Subject: [PATCH 11/20] Close the query client on a fromConfig failure QwpQueryClient's constructor allocates native scratch (bindValues, backed by a NativeBufferWriter malloc), freed only by close(). In fromConfig the constructor runs before the chain of with*() setters that apply the parsed config, with no cleanup if one of them rejects its input -- the half-built client, and its native buffer, would leak. Wrap the setter chain in a try/catch that closes the client and rethrows. validateConfig already runs first and rejects every value these setters check, so no connect string reaches a throwing setter today; this is a safety net for future drift (a new setter, or a setter whose bound diverges from validateConfig), keeping fromConfig leak-safe on every error path. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../cutlass/qwp/client/QwpQueryClient.java | 95 ++++++++++--------- 1 file changed, 52 insertions(+), 43 deletions(-) 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 b8bfed50..ae109729 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 @@ -412,51 +412,60 @@ public static QwpQueryClient fromConfig(CharSequence configurationString) { boolean hasBasic = username != null || password != null; 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.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(); + // 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)); + } + 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.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(); + } } + 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 (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; } /** From ad9d04a43a58c7ba892e842ee569f20dbd7a6e6d Mon Sep 17 00:00:00 2001 From: Marko Topolnik Date: Tue, 23 Jun 2026 19:52:27 +0200 Subject: [PATCH 12/20] Reject blank ws credentials and remove dead code Follow-up cleanups from the connect-string review. Egress credential parity: QwpQueryClient.validateConfig now rejects a present-but-blank username, password, or token, matching the ingress Sender. A shared ws/wss string fails the same way on both sides, and the query client no longer builds an empty Authorization header. Dead code: remove the six unreachable PROTOCOL_WEBSOCKET rejection branches in the legacy fromConfig loop. The ws/wss schema returns early to fromConfigWebSocket, so those branches never ran; ConfigView's reject pass already emits the relocated-key hints the tests assert. Drop the unused ConfigView.selfSide field, its accessor, and the constructor's Side parameter. ConfigView never filtered by side, so the Side and ConfigView javadocs now describe Side as registry and guard-test metadata rather than a runtime filter. Tests: cover the int pool-key conflict path, egress fail-fast on a malformed query config at build() with min 0, a malformed pool value, username-without-password on the ws Sender path, and the ConfigView non-numeric and invalid-bool getters. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../io/questdb/client/QuestDBBuilder.java | 9 ++- .../main/java/io/questdb/client/Sender.java | 21 +------ .../cutlass/qwp/client/QwpQueryClient.java | 16 +++++- .../io/questdb/client/impl/ConfigView.java | 23 +++----- .../java/io/questdb/client/impl/Side.java | 8 ++- .../client/test/QuestDBBuilderTest.java | 56 +++++++++++++++++++ .../LineSenderBuilderWebSocketTest.java | 8 +++ .../client/QwpQueryClientFromConfigTest.java | 26 +++++++++ .../client/test/impl/ConfigViewTest.java | 55 ++++++++++++------ 9 files changed, 160 insertions(+), 62 deletions(-) diff --git a/core/src/main/java/io/questdb/client/QuestDBBuilder.java b/core/src/main/java/io/questdb/client/QuestDBBuilder.java index 233b4d97..b79404c2 100644 --- a/core/src/main/java/io/questdb/client/QuestDBBuilder.java +++ b/core/src/main/java/io/questdb/client/QuestDBBuilder.java @@ -28,7 +28,6 @@ import io.questdb.client.impl.ConfigString; import io.questdb.client.impl.ConfigView; import io.questdb.client.impl.QuestDBImpl; -import io.questdb.client.impl.Side; import org.jetbrains.annotations.TestOnly; import java.util.function.IntConsumer; @@ -102,8 +101,8 @@ public QuestDB build() { } ConfigString ingestCs = ConfigString.parse(ingestConfig); ConfigString queryCs = ConfigString.parse(queryConfig); - ConfigView ingestView = new ConfigView(ingestCs, Side.INGRESS); - ConfigView queryView = new ConfigView(queryCs, Side.EGRESS); + 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 @@ -113,8 +112,8 @@ public QuestDB build() { Sender.LineSenderBuilder.validateWsConfigString(ingestConfig); QwpQueryClient.validateConfig(queryView, "wss".equals(queryCs.schema())); - // getInt/getLong ignore the view's side, so the INGRESS/EGRESS views - // also serve the POOL reads. + // 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); diff --git a/core/src/main/java/io/questdb/client/Sender.java b/core/src/main/java/io/questdb/client/Sender.java index 42fcc07e..0e2ce4f6 100644 --- a/core/src/main/java/io/questdb/client/Sender.java +++ b/core/src/main/java/io/questdb/client/Sender.java @@ -41,7 +41,6 @@ import io.questdb.client.impl.ConfStringParser; import io.questdb.client.impl.ConfigString; import io.questdb.client.impl.ConfigView; -import io.questdb.client.impl.Side; import io.questdb.client.network.NetworkFacade; import io.questdb.client.network.NetworkFacadeImpl; import io.questdb.client.std.Chars; @@ -3026,9 +3025,6 @@ private LineSenderBuilder fromConfig(CharSequence configurationString) { httpToken(sink.toString()); } } else if (Chars.equals("retry_timeout", sink)) { - if (protocol == PROTOCOL_WEBSOCKET) { - throw new LineSenderException("retry_timeout is not supported for WebSocket transport; use reconnect_max_duration_millis for the per-outage reconnect budget"); - } pos = getValue(configurationString, pos, sink, "retry_timeout"); int timeout = parseIntValue(sink, "retry_timeout"); retryTimeoutMillis(timeout); @@ -3110,24 +3106,15 @@ private LineSenderBuilder fromConfig(CharSequence configurationString) { throw new LineSenderException("invalid auto_flush [value=").put(sink).put(", allowed-values=[on, off]]"); } } else if (Chars.equals("request_timeout", sink)) { - if (protocol == PROTOCOL_WEBSOCKET) { - throw new LineSenderException("request_timeout is not supported for WebSocket transport"); - } pos = getValue(configurationString, pos, sink, "request_timeout"); int requestTimeout = parseIntValue(sink, "request_timeout"); httpTimeoutMillis(requestTimeout); } else if (Chars.equals("request_min_throughput", sink)) { - if (protocol == PROTOCOL_WEBSOCKET) { - throw new LineSenderException("request_min_throughput is not supported for WebSocket transport"); - } pos = getValue(configurationString, pos, sink, "request_min_throughput"); int requestMinThroughput = parseIntValue(sink, "request_min_throughput"); minRequestThroughput(requestMinThroughput); } else if (Chars.equals("protocol_version", sink)) { pos = getValue(configurationString, pos, sink, "protocol_version"); - if (protocol == PROTOCOL_WEBSOCKET) { - throw new LineSenderException("protocol_version is not supported for WebSocket transport; QWP negotiates the protocol version during the WebSocket upgrade"); - } if (!Chars.equalsIgnoreCase("auto", sink)) { int protocolVersion = parseIntValue(sink, "protocol_version"); protocolVersion(protocolVersion); @@ -3275,16 +3262,10 @@ private LineSenderBuilder fromConfig(CharSequence configurationString) { pos = getValue(configurationString, pos, sink, "reconnect_max_backoff_millis"); reconnectMaxBackoffMillis(parseLongValue(sink, "reconnect_max_backoff_millis")); } else if (Chars.equals("max_datagram_size", sink)) { - if (protocol == PROTOCOL_WEBSOCKET) { - throw new LineSenderException("max_datagram_size is not supported for WebSocket transport; it applies to the UDP transport only"); - } pos = getValue(configurationString, pos, sink, "max_datagram_size"); int mds = parseIntValue(sink, "max_datagram_size"); maxDatagramSize(mds); } else if (Chars.equals("multicast_ttl", sink)) { - if (protocol == PROTOCOL_WEBSOCKET) { - throw new LineSenderException("multicast_ttl is not supported for WebSocket transport; it applies to the UDP transport only"); - } pos = getValue(configurationString, pos, sink, "multicast_ttl"); int ttl = parseIntValue(sink, "multicast_ttl"); multicastTtl(ttl); @@ -3376,7 +3357,7 @@ private LineSenderBuilder fromConfig(CharSequence configurationString) { private LineSenderBuilder fromConfigWebSocket(CharSequence configurationString) { try { ConfigString cs = ConfigString.parse(configurationString); - ConfigView view = new ConfigView(cs, Side.INGRESS); + ConfigView view = new ConfigView(cs); validateWsConfig(view, tlsEnabled); view.getHostPorts("addr", DEFAULT_WEBSOCKET_PORT, this::appendAddress); 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 ae109729..3897ded8 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 @@ -32,7 +32,7 @@ import io.questdb.client.cutlass.qwp.protocol.QwpConstants; import io.questdb.client.impl.ConfigString; import io.questdb.client.impl.ConfigView; -import io.questdb.client.impl.Side; +import io.questdb.client.std.Chars; import io.questdb.client.std.QuietCloseable; import io.questdb.client.std.Zstd; import org.jetbrains.annotations.TestOnly; @@ -367,7 +367,7 @@ public static QwpQueryClient fromConfig(CharSequence configurationString) { throw new IllegalArgumentException( "unsupported schema [schema=" + cs.schema() + ", supported-schemas=[ws, wss]]"); } - ConfigView view = new ConfigView(cs, Side.EGRESS); + ConfigView view = new ConfigView(cs); validateConfig(view, tls); List parsedEndpoints = new ArrayList<>(); @@ -500,6 +500,18 @@ public static void validateConfig(ConfigView view, boolean tls) { 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"); diff --git a/core/src/main/java/io/questdb/client/impl/ConfigView.java b/core/src/main/java/io/questdb/client/impl/ConfigView.java index 3dcb298f..1eaffdce 100644 --- a/core/src/main/java/io/questdb/client/impl/ConfigView.java +++ b/core/src/main/java/io/questdb/client/impl/ConfigView.java @@ -33,16 +33,17 @@ /** * Layer 3 of the QWP connect-string parser: a typed, validated view over a - * {@link ConfigString} for a given {@link ConfigSchema} and consumer - * {@link Side}. 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. + * {@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. A - * consumer reads only the keys its side owns; foreign values are accepted - * syntactically and validated by their owning consumer. + * 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 { @@ -63,10 +64,8 @@ public final class ConfigView { private final ObjList normKeys = new ObjList<>(); private final ObjList normValues = new ObjList<>(); - private final Side selfSide; - public ConfigView(ConfigString cs, Side selfSide) { - this.selfSide = selfSide; + 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); @@ -208,10 +207,6 @@ public boolean has(String key) { return false; } - public Side selfSide() { - return selfSide; - } - private static String join(ObjList values) { StringBuilder sb = new StringBuilder(); for (int i = 0, n = values.size(); i < n; i++) { diff --git a/core/src/main/java/io/questdb/client/impl/Side.java b/core/src/main/java/io/questdb/client/impl/Side.java index c5b8bf0a..b29d2aa2 100644 --- a/core/src/main/java/io/questdb/client/impl/Side.java +++ b/core/src/main/java/io/questdb/client/impl/Side.java @@ -25,9 +25,11 @@ package io.questdb.client.impl; /** - * Which consumer owns a connect-string key. A {@link ConfigView} built for a - * given side applies keys whose side is its own or {@link #COMMON}, and ignores - * the rest. + * 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 { /** 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 03199c03..24d8f43d 100644 --- a/core/src/test/java/io/questdb/client/test/QuestDBBuilderTest.java +++ b/core/src/test/java/io/questdb/client/test/QuestDBBuilderTest.java @@ -47,6 +47,22 @@ public void testBuilderCallAfterFromConfigOverridesPoolKeysFromString() { } } + @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. @@ -103,6 +119,18 @@ 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 @@ -120,6 +148,20 @@ public void testMalformedIngressConfigRejectedAtBuildWithMinZero() { "ws::addr=127.0.0.1:1;auto_flush_interval=off;sender_pool_min=0;sender_pool_max=2;", "auto-flush"); } + @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 { @@ -223,6 +265,20 @@ 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() 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 7a3987d5..8fed5fc8 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 @@ -915,6 +915,14 @@ public void testWsConfigString_withRetryTimeout_fails() { "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 + // (httpUsernamePassword requires a non-blank password), matching the + // egress QwpQueryClient so a shared ws/wss string fails on both sides. + assertBadConfig("ws::addr=localhost:9000;username=alice;", "password cannot be empty nor null"); + } + @Test public void testWsConfigString_withToken() throws Exception { assertMemoryLeak(() -> { 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 cc831c3a..06ee9c6a 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 @@ -277,6 +277,25 @@ public void testBasicAuthAndTokenMutuallyExclusive() { ); } + @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" + ); + } + @Test public void testBasicAuthWithPasswordOnlyRejected() { assertReject( @@ -995,6 +1014,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/impl/ConfigViewTest.java b/core/src/test/java/io/questdb/client/test/impl/ConfigViewTest.java index f98325c1..bf4527c9 100644 --- a/core/src/test/java/io/questdb/client/test/impl/ConfigViewTest.java +++ b/core/src/test/java/io/questdb/client/test/impl/ConfigViewTest.java @@ -24,10 +24,8 @@ package io.questdb.client.test.impl; -import io.questdb.client.impl.ConfigSchema; import io.questdb.client.impl.ConfigString; import io.questdb.client.impl.ConfigView; -import io.questdb.client.impl.Side; import org.junit.Assert; import org.junit.Test; @@ -76,80 +74,101 @@ public void testAddrRepeatedKeysAccumulate() { @Test public void testAliasNormalization() { - ConfigView v = view("ws::addr=h:9000;user=alice;pass=secret;", Side.EGRESS); + 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;", Side.EGRESS, + 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;", Side.EGRESS, + 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;", Side.EGRESS, + 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;", Side.EGRESS, + 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;", Side.EGRESS); + 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;", Side.EGRESS); + 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;", Side.EGRESS); + 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;", Side.INGRESS, v -> { + 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;", Side.INGRESS, v -> { + 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, Side.INGRESS, v -> v.getHostPorts("addr", 9000, (h, p) -> { + assertParseError(cfg, v -> v.getHostPorts("addr", 9000, (h, p) -> { }), expected); } - private static void assertParseError(String cfg, Side side, java.util.function.Consumer use, String expected) { + private static void assertParseError(String cfg, java.util.function.Consumer use, String expected) { try { - ConfigView v = view(cfg, side); + ConfigView v = view(cfg); use.accept(v); Assert.fail("expected error containing: " + expected); } catch (IllegalArgumentException e) { @@ -160,7 +179,7 @@ private static void assertParseError(String cfg, Side side, java.util.function.C private static List hostPorts(String cfg) { List got = new ArrayList<>(); - view(cfg, Side.INGRESS).getHostPorts("addr", 9000, (h, p) -> got.add(h + ":" + p)); + view(cfg).getHostPorts("addr", 9000, (h, p) -> got.add(h + ":" + p)); return got; } @@ -172,7 +191,7 @@ private static List list(String... items) { return l; } - private static ConfigView view(String cfg, Side side) { - return new ConfigView(ConfigString.parse(cfg), side); + private static ConfigView view(String cfg) { + return new ConfigView(ConfigString.parse(cfg)); } } From ad7e1cdd5b9774dd0e31d3d85e6390170421bdb2 Mon Sep 17 00:00:00 2001 From: Marko Topolnik Date: Tue, 23 Jun 2026 19:54:19 +0200 Subject: [PATCH 13/20] Remove unreachable protocol-already-set guard Sender.fromConfig() opened with a guard that threw "protocol was already configured" when the builder's protocol was set on entry. That case cannot occur: the private fromConfig() runs only on a fresh LineSenderBuilder() -- from builder(CharSequence), fromEnv(), and validateWsConfigString() -- so protocol is always PARAMETER_NOT_SET_EXPLICITLY there. builder(Transport) presets the protocol but hands that builder to the caller and never routes it through fromConfig(). The reachable duplicate-protocol guards stay in the http(), tcp(), udp(), and websocket() transport setters. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../main/java/io/questdb/client/Sender.java | 20 ------------------- 1 file changed, 20 deletions(-) diff --git a/core/src/main/java/io/questdb/client/Sender.java b/core/src/main/java/io/questdb/client/Sender.java index 0e2ce4f6..a07f1bd2 100644 --- a/core/src/main/java/io/questdb/client/Sender.java +++ b/core/src/main/java/io/questdb/client/Sender.java @@ -2866,26 +2866,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"); From 1fcc6cb2c636e1d77a0f64ec4227fd6e5214b907 Mon Sep 17 00:00:00 2001 From: Marko Topolnik Date: Wed, 24 Jun 2026 10:59:04 +0200 Subject: [PATCH 14/20] Close config test gaps and drop unused field These are follow-ups from the connect-string parser review. ConfigSchema.KeySpec carried a `multi` flag that nothing ever read: getHostPorts accumulates every addr entry unconditionally and the scalar getters are always last-write-wins, so the flag described an invariant no code enforced. Drop the field, its constructor parameter, and the argument from every factory. The egress "honored" guard only iterated Side.EGRESS, so the COMMON keys the QwpQueryClient also applies (credentials, TLS, auth_timeout_ms) were asserted but not drift-protected -- removing their application from fromConfig would still pass the guard. Make the guard symmetric with the ingress one (EGRESS, or COMMON minus the addr host-port list, skipping aliases), expose the egress TLS state in configSnapshotForTest, and assert tls_verify/tls_roots/tls_roots_password are honored. Add an integration test that builds the facade from a single shared vocabulary carrying both ingress-only and egress-only keys and pre-warms min=1 on each pool, so both clients actually connect rather than merely validate. Ingest and query use separate mock servers because the test server serves ACK and SERVER_INFO semantics on separate sockets; a single address serving both is covered against a real server in the parent repo. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../cutlass/qwp/client/QwpQueryClient.java | 3 ++ .../io/questdb/client/impl/ConfigSchema.java | 18 ++++---- .../client/test/QuestDBBuilderTest.java | 45 +++++++++++++++++++ .../impl/QwpQueryClientConfigHonoredTest.java | 36 ++++++++++++--- 4 files changed, 87 insertions(+), 15 deletions(-) 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 3897ded8..773b0e35 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 @@ -868,6 +868,9 @@ public java.util.Map configSnapshotForTest() { 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; } diff --git a/core/src/main/java/io/questdb/client/impl/ConfigSchema.java b/core/src/main/java/io/questdb/client/impl/ConfigSchema.java index ba4d67d1..0a728669 100644 --- a/core/src/main/java/io/questdb/client/impl/ConfigSchema.java +++ b/core/src/main/java/io/questdb/client/impl/ConfigSchema.java @@ -145,11 +145,11 @@ private static void add(KeySpec spec) { } private static void alias(String name, String canonical) { - add(new KeySpec(name, Side.COMMON, ValueType.STRING, false, OPEN, OPEN_MAX, false, false, null, canonical)); + add(new KeySpec(name, Side.COMMON, ValueType.STRING, OPEN, OPEN_MAX, false, false, null, canonical)); } private static void boolOnOff(String name, Side side) { - add(new KeySpec(name, side, ValueType.BOOL_ON_OFF, false, OPEN, OPEN_MAX, false, false, null, name)); + add(new KeySpec(name, side, ValueType.BOOL_ON_OFF, OPEN, OPEN_MAX, false, false, null, name)); } private static void enumKey(String name, Side side, String... values) { @@ -157,23 +157,23 @@ private static void enumKey(String name, Side side, String... values) { for (int i = 0; i < values.length; i++) { list.add(values[i]); } - add(new KeySpec(name, side, ValueType.ENUM, false, OPEN, OPEN_MAX, false, false, list, name)); + add(new KeySpec(name, side, ValueType.ENUM, OPEN, OPEN_MAX, false, false, list, name)); } private static void hostPort(String name) { - add(new KeySpec(name, Side.COMMON, ValueType.HOST_PORT_LIST, true, OPEN, OPEN_MAX, false, false, null, name)); + add(new KeySpec(name, Side.COMMON, ValueType.HOST_PORT_LIST, 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, ValueType.INT, false, min, max, minOpen, maxOpen, null, name)); + add(new KeySpec(name, side, ValueType.INT, 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, ValueType.LONG, false, min, max, minOpen, maxOpen, null, name)); + add(new KeySpec(name, side, ValueType.LONG, min, max, minOpen, maxOpen, null, name)); } private static void str(String name, Side side) { - add(new KeySpec(name, side, ValueType.STRING, false, OPEN, OPEN_MAX, false, false, null, name)); + add(new KeySpec(name, side, ValueType.STRING, OPEN, OPEN_MAX, false, false, null, name)); } public enum ValueType { @@ -193,20 +193,18 @@ public static final class KeySpec { final boolean maxOpen; final long min; final boolean minOpen; - final boolean multi; final String name; final Side side; final ValueType type; KeySpec( - String name, Side side, ValueType type, boolean multi, + String name, Side side, ValueType type, long min, long max, boolean minOpen, boolean maxOpen, ObjList enumValues, String canonical ) { this.name = name; this.side = side; this.type = type; - this.multi = multi; this.min = min; this.max = max; this.minOpen = minOpen; 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 24d8f43d..e260ae5c 100644 --- a/core/src/test/java/io/questdb/client/test/QuestDBBuilderTest.java +++ b/core/src/test/java/io/questdb/client/test/QuestDBBuilderTest.java @@ -243,6 +243,51 @@ public void testSamePoolKeyValueAcrossSidesOk() { } } + @Test + 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 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 index 11c14b37..c8627015 100644 --- a/core/src/test/java/io/questdb/client/test/impl/QwpQueryClientConfigHonoredTest.java +++ b/core/src/test/java/io/questdb/client/test/impl/QwpQueryClientConfigHonoredTest.java @@ -24,6 +24,7 @@ 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; @@ -40,16 +41,24 @@ /** * 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. + * COMMON credential keys are verified via the synthesized Authorization header, + * the COMMON TLS keys via the trust-store snapshot. * {@link #testHonoredCasesCoverEveryEgressRegistryKey} guards against drift. */ public class QwpQueryClientConfigHonoredTest { - private static final Set EGRESS_COVERED = new HashSet<>(Arrays.asList( + // Every EGRESS key plus the COMMON keys the egress client applies + // (credentials, TLS, auth_timeout_ms). addr is COMMON but HOST_PORT_LIST -- + // applied as the endpoint list rather than a snapshot value -- so it is + // excluded, matching the guard formula in + // testHonoredCasesCoverEveryEgressRegistryKey. + private static final Set COVERED = new HashSet<>(Arrays.asList( "target", "failover", "failover_max_attempts", "failover_backoff_initial_ms", "failover_backoff_max_ms", "failover_max_duration_ms", "max_batch_rows", "initial_credit", "buffer_pool_size", "compression", "compression_level", - "client_id", "zone" + "client_id", "zone", + "username", "password", "token", "tls_verify", "tls_roots", "tls_roots_password", + "auth_timeout_ms" )); @Test @@ -76,14 +85,31 @@ public void testEveryEgressKeyIsHonored() { 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")); + + // 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")); } @Test public void testHonoredCasesCoverEveryEgressRegistryKey() { for (ConfigSchema.KeySpec spec : ConfigSchema.all()) { - if (spec.side() == Side.EGRESS) { + 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 HOST_PORT_LIST + // endpoint list, not a snapshot value, so it is excluded. + boolean egressApplied = spec.side() == Side.EGRESS + || (spec.side() == Side.COMMON && spec.type() != ConfigSchema.ValueType.HOST_PORT_LIST); + if (egressApplied) { Assert.assertTrue("registry egress key '" + spec.name() + "' has no honored case", - EGRESS_COVERED.contains(spec.name())); + COVERED.contains(spec.name())); } } } From 1e2462b1c74b3ca38880bf2adfd2fb8911b79c70 Mon Sep 17 00:00:00 2001 From: Marko Topolnik Date: Wed, 24 Jun 2026 11:13:34 +0200 Subject: [PATCH 15/20] Drop the ValueType tag from the key registry The KeySpec.type tag (ValueType: STRING/INT/LONG/BOOL_ON_OFF/ENUM/ HOST_PORT_LIST) recorded a key's shape but drove no runtime decision. ConfigView never branched on it: the consumer picks getInt vs getStr vs getEnum directly, enum membership comes from enumValues != null, and addr's address-list handling is keyed on the literal name inside getHostPorts. The only readers were the guard tests. Remove the field, its constructor parameter, the accessor, and the ValueType enum, and drop the argument from every factory. The two honored-guard formulas swap their `type != HOST_PORT_LIST` check for `!name.equals("addr")` -- addr was the sole host-port key -- and QwpConfigKeysTest.sampleValue collapses to "first enum member, else 1", since the reject pass keys off the name rather than the value, so the per-type sample was unnecessary. This trims metadata the registry no longer needs now that it serves only the ws/wss vocabulary. It leaves Side, which the per-side honored guards still rely on, and the numeric range, enum, and alias fields, which do drive validation. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../io/questdb/client/impl/ConfigSchema.java | 26 ++++++------------- .../client/test/impl/QwpConfigKeysTest.java | 16 +++--------- .../impl/QwpQueryClientConfigHonoredTest.java | 6 ++--- .../test/impl/WsSenderConfigHonoredTest.java | 4 ++- 4 files changed, 17 insertions(+), 35 deletions(-) diff --git a/core/src/main/java/io/questdb/client/impl/ConfigSchema.java b/core/src/main/java/io/questdb/client/impl/ConfigSchema.java index 0a728669..b36f3207 100644 --- a/core/src/main/java/io/questdb/client/impl/ConfigSchema.java +++ b/core/src/main/java/io/questdb/client/impl/ConfigSchema.java @@ -145,11 +145,11 @@ private static void add(KeySpec spec) { } private static void alias(String name, String canonical) { - add(new KeySpec(name, Side.COMMON, ValueType.STRING, OPEN, OPEN_MAX, false, false, null, 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, ValueType.BOOL_ON_OFF, OPEN, OPEN_MAX, false, false, null, name)); + add(new KeySpec(name, side, OPEN, OPEN_MAX, false, false, null, name)); } private static void enumKey(String name, Side side, String... values) { @@ -157,27 +157,23 @@ private static void enumKey(String name, Side side, String... values) { for (int i = 0; i < values.length; i++) { list.add(values[i]); } - add(new KeySpec(name, side, ValueType.ENUM, OPEN, OPEN_MAX, false, false, list, name)); + add(new KeySpec(name, side, OPEN, OPEN_MAX, false, false, list, name)); } private static void hostPort(String name) { - add(new KeySpec(name, Side.COMMON, ValueType.HOST_PORT_LIST, OPEN, OPEN_MAX, false, false, null, 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, ValueType.INT, min, max, minOpen, maxOpen, null, name)); + 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, ValueType.LONG, min, max, minOpen, maxOpen, null, name)); + add(new KeySpec(name, side, min, max, minOpen, maxOpen, null, name)); } private static void str(String name, Side side) { - add(new KeySpec(name, side, ValueType.STRING, OPEN, OPEN_MAX, false, false, null, name)); - } - - public enum ValueType { - STRING, INT, LONG, BOOL_ON_OFF, ENUM, HOST_PORT_LIST + add(new KeySpec(name, side, OPEN, OPEN_MAX, false, false, null, name)); } /** @@ -195,16 +191,14 @@ public static final class KeySpec { final boolean minOpen; final String name; final Side side; - final ValueType type; KeySpec( - String name, Side side, ValueType type, + String name, Side side, long min, long max, boolean minOpen, boolean maxOpen, ObjList enumValues, String canonical ) { this.name = name; this.side = side; - this.type = type; this.min = min; this.max = max; this.minOpen = minOpen; @@ -228,9 +222,5 @@ public String name() { public Side side() { return side; } - - public ValueType type() { - return type; - } } } 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 index 0c9f824f..b0706189 100644 --- a/core/src/test/java/io/questdb/client/test/impl/QwpConfigKeysTest.java +++ b/core/src/test/java/io/questdb/client/test/impl/QwpConfigKeysTest.java @@ -156,18 +156,8 @@ private static void assertRejectedNoHint(Runnable action, String key) { } private static String sampleValue(ConfigSchema.KeySpec spec) { - switch (spec.type()) { - case INT: - case LONG: - return "1"; - case BOOL_ON_OFF: - return "on"; - case ENUM: - return spec.enumValues().getQuick(0); - case HOST_PORT_LIST: - return "h2:9001"; - default: - return "x"; - } + // 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 index c8627015..b1198931 100644 --- a/core/src/test/java/io/questdb/client/test/impl/QwpQueryClientConfigHonoredTest.java +++ b/core/src/test/java/io/questdb/client/test/impl/QwpQueryClientConfigHonoredTest.java @@ -103,10 +103,10 @@ public void testHonoredCasesCoverEveryEgressRegistryKey() { 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 HOST_PORT_LIST - // endpoint list, not a snapshot value, so it is excluded. + // (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.type() != ConfigSchema.ValueType.HOST_PORT_LIST); + || (spec.side() == Side.COMMON && !spec.name().equals("addr")); if (egressApplied) { Assert.assertTrue("registry egress key '" + spec.name() + "' has no honored case", COVERED.contains(spec.name())); 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 index 2e659792..5f69c3ee 100644 --- a/core/src/test/java/io/questdb/client/test/impl/WsSenderConfigHonoredTest.java +++ b/core/src/test/java/io/questdb/client/test/impl/WsSenderConfigHonoredTest.java @@ -103,8 +103,10 @@ public void testHonoredCasesCoverEveryIngressRegistryKey() { 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.type() != ConfigSchema.ValueType.HOST_PORT_LIST); + || (spec.side() == Side.COMMON && !spec.name().equals("addr")); if (ingressApplied) { Assert.assertTrue("registry ingress key '" + spec.name() + "' has no honored case", COVERED.contains(spec.name())); From 8a7ff39b708f85c13988ded620dedb0ca74e5dac Mon Sep 17 00:00:00 2001 From: Marko Topolnik Date: Wed, 24 Jun 2026 12:11:57 +0200 Subject: [PATCH 16/20] Fail fast on sf_durability in config validation The sf_durability != memory rejection lived in build()'s WebSocket construction block, after validateParameters(). The QuestDB facade's fail-fast path (validateWsConfigString) stops at validateParameters, so a ws/wss config carrying sf_durability=flush with pool min=0 built a handle that only threw on the first borrowSender(). Move the check into the WEBSOCKET branch of validateParameters() so both build() and the no-connect facade validation reject it up front. Existing build()-time rejections are unaffected because build() still runs validateParameters. Also close test gaps found in review: - QuestDB.connect(), the facade's primary entry point, had no test. Add validate-and-build and ws/wss schema-rejection coverage for both the single-string and two-argument overloads. - The removed gorilla key had no rejection test, unlike auth, path, and in_flight_window. Add one on both the Sender and the QwpQueryClient. - Add an sf_durability=flush case to the facade's ingress-reject test. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../main/java/io/questdb/client/Sender.java | 24 +++++----- .../client/test/QuestDBBuilderTest.java | 45 +++++++++++++++++-- .../LineSenderBuilderWebSocketTest.java | 9 ++++ .../client/QwpQueryClientFromConfigTest.java | 8 ++++ 4 files changed, 73 insertions(+), 13 deletions(-) diff --git a/core/src/main/java/io/questdb/client/Sender.java b/core/src/main/java/io/questdb/client/Sender.java index a07f1bd2..297ea122 100644 --- a/core/src/main/java/io/questdb/client/Sender.java +++ b/core/src/main/java/io/questdb/client/Sender.java @@ -1395,16 +1395,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; @@ -3812,6 +3807,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/test/java/io/questdb/client/test/QuestDBBuilderTest.java b/core/src/test/java/io/questdb/client/test/QuestDBBuilderTest.java index e260ae5c..522b39bd 100644 --- a/core/src/test/java/io/questdb/client/test/QuestDBBuilderTest.java +++ b/core/src/test/java/io/questdb/client/test/QuestDBBuilderTest.java @@ -76,6 +76,31 @@ public void testConflictingPoolKeysAcrossSidesRejected() { } } + @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; @@ -88,6 +113,17 @@ public void testConnectStringWithPoolKeysAppliedToBuilder() { } } + @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 @@ -137,15 +173,18 @@ public void testMalformedIngressConfigRejectedAtBuildWithMinZero() { // 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 a WebSocket build-time check that only the full - // no-connect validation reaches (auto_flush_interval=off disables - // auto-flush, which the WebSocket transport does not support). + // (auto_flush_rows), and two WebSocket build-time checks that only the + // full no-connect validation reaches (auto_flush_interval=off disables + // auto-flush, and sf_durability=flush is not yet supported -- the + // WebSocket transport rejects both). 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;sf_durability=flush;sender_pool_min=0;sender_pool_max=2;", "not yet supported"); } @Test 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 8fed5fc8..2ef321cc 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 @@ -772,6 +772,15 @@ 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;", "unknown configuration key: init_buf_size (applies to legacy http/tcp/udp transports only)"); 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 06ee9c6a..8f82cd2f 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 @@ -671,6 +671,14 @@ 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 From 4d75c79c918380a2db9712e9fe2972acaecc7dcf Mon Sep 17 00:00:00 2001 From: Marko Topolnik Date: Wed, 24 Jun 2026 13:38:38 +0200 Subject: [PATCH 17/20] Align ws auth error messages and close test gaps validateWsConfig now rejects a half-specified basic credential (username without password, or the reverse) up front with the same message the egress QwpQueryClient uses, instead of letting it fall through to a misleading "password cannot be empty" error downstream. The egress side drops "both" from the both-or-neither message and adopts the ingress wording for the token-vs-basic conflict, so a shared ws/wss string now fails identically on both sides. Drop the dead leniency comment that claimed username-without-password did not throw. Remove the ambiguous auto_flush_disabled snapshot field, which read true for both auto_flush=off and auto_flush_rows=off. Tests: - Retarget the auto_flush=off honored case to the unambiguous auto_flush_interval field and add a build-rejection test, since the config disables auto-flush, which WebSocket rejects. - Add ingress rejection tests for half-credentials, token plus basic auth, and tls keys on a non-tls ws schema; add a legacy path= test. - Drive the honored drift guards off the keys the assertions record (or a single expected map) so the coverage check cannot silently drift from what is actually asserted. - Assert the resolved pool value, not just build success, in the explicit-wins precedence tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../main/java/io/questdb/client/Sender.java | 12 +++-- .../cutlass/qwp/client/QwpQueryClient.java | 4 +- .../client/test/QuestDBBuilderTest.java | 37 ++++++++----- .../cutlass/line/LineSenderBuilderTest.java | 1 + .../LineSenderBuilderWebSocketTest.java | 29 ++++++++-- .../client/QwpQueryClientFromConfigTest.java | 10 ++-- .../test/impl/PoolConfigHonoredTest.java | 53 ++++++++++--------- .../impl/QwpQueryClientConfigHonoredTest.java | 43 +++++++-------- .../test/impl/WsSenderConfigHonoredTest.java | 49 ++++++++++------- 9 files changed, 142 insertions(+), 96 deletions(-) diff --git a/core/src/main/java/io/questdb/client/Sender.java b/core/src/main/java/io/questdb/client/Sender.java index 297ea122..d0107087 100644 --- a/core/src/main/java/io/questdb/client/Sender.java +++ b/core/src/main/java/io/questdb/client/Sender.java @@ -3518,8 +3518,8 @@ private LineSenderBuilder fromConfigWebSocket(CharSequence configurationString) * 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 (including its leniencies -- {@code username} without - * {@code password} does not throw). + * 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) -> { @@ -3530,8 +3530,11 @@ static void validateWsConfig(ConfigView view, boolean tls) { String user = view.getStr("username"); String password = view.getStr("password"); String token = view.getStr("token"); - if (user == null && password != null) { - throw new IllegalArgumentException("password is configured, but username is missing"); + // 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"); @@ -3595,7 +3598,6 @@ public java.util.Map wsConfigSnapshotForTest() { m.put("auto_flush_rows", autoFlushRows); m.put("auto_flush_bytes", autoFlushBytes); m.put("auto_flush_interval", autoFlushIntervalMillis); - m.put("auto_flush_disabled", autoFlushRows == AUTO_FLUSH_DISABLED); m.put("max_name_len", maxNameLength); m.put("transaction", transactional); m.put("request_durable_ack", requestDurableAck); 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 773b0e35..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 @@ -514,10 +514,10 @@ public static void validateConfig(ConfigView view, boolean tls) { } 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"); } if (hasBasic && token != null) { - throw new IllegalArgumentException("username/password and token are mutually exclusive"); + throw new IllegalArgumentException("cannot use both token and username/password authentication"); } String tlsVerify = view.getStr("tls_verify"); String tlsRoots = view.getStr("tls_roots"); 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 522b39bd..4f8cd460 100644 --- a/core/src/test/java/io/questdb/client/test/QuestDBBuilderTest.java +++ b/core/src/test/java/io/questdb/client/test/QuestDBBuilderTest.java @@ -25,6 +25,7 @@ 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; @@ -38,13 +39,15 @@ 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. - try (QuestDB ignored = QuestDB.builder() + 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) - .build()) { + .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 @@ -128,21 +131,25 @@ public void testConnectTwoArgValidatesAndBuilds() { 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. - try (QuestDB ignored = QuestDB.builder() + // 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) - .build()) { + .acquireTimeoutMillis(500); + try (QuestDB ignored = after.build()) { Assert.assertNotNull(ignored); } - try (QuestDB ignored = QuestDB.builder() + 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;") - .build()) { + .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 @@ -173,16 +180,18 @@ public void testMalformedIngressConfigRejectedAtBuildWithMinZero() { // 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 two WebSocket build-time checks that only the - // full no-connect validation reaches (auto_flush_interval=off disables - // auto-flush, and sf_durability=flush is not yet supported -- the - // WebSocket transport rejects both). + // (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"); } 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 36a25ce2..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 @@ -194,6 +194,7 @@ public void testConfStringValidation() throws Exception { 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"); 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 2ef321cc..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 @@ -926,10 +926,31 @@ public void testWsConfigString_withRetryTimeout_fails() { @Test public void testWsConfigString_usernameWithoutPassword_fails() { - // The ingress ws path rejects a username with no password - // (httpUsernamePassword requires a non-blank password), matching the - // egress QwpQueryClient so a shared ws/wss string fails on both sides. - assertBadConfig("ws::addr=localhost:9000;username=alice;", "password cannot be empty nor null"); + // 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 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 8f82cd2f..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 @@ -273,7 +273,7 @@ public void testBasicAuthAcceptedAlone() { public void testBasicAuthAndTokenMutuallyExclusive() { assertReject( "ws::addr=db:9000;username=admin;password=quest;token=ey.xyz;", - "username/password and token are mutually exclusive" + "cannot use both token and username/password authentication" ); } @@ -300,7 +300,7 @@ public void testBasicAuthEmptyUsernameRejected() { 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" ); } @@ -308,7 +308,7 @@ 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" ); } @@ -330,7 +330,7 @@ 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;", - "both username and password must be provided together" + "username and password must be provided together" ); } @@ -338,7 +338,7 @@ public void testUserAliasAloneRejected() { public void testPassAliasAloneRejected() { assertReject( "ws::addr=db:9000;pass=secret;", - "both username and password must be provided together" + "username and password must be provided together" ); } 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 index ec0e30a8..34ba4d1a 100644 --- a/core/src/test/java/io/questdb/client/test/impl/PoolConfigHonoredTest.java +++ b/core/src/test/java/io/questdb/client/test/impl/PoolConfigHonoredTest.java @@ -31,10 +31,8 @@ import org.junit.Assert; import org.junit.Test; -import java.util.Arrays; -import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.Map; -import java.util.Set; /** * Proves every POOL key carried in a {@code ws}/{@code wss} connect string is @@ -44,35 +42,40 @@ */ public class PoolConfigHonoredTest { - private static final Set COVERED = new HashSet<>(Arrays.asList( - "sender_pool_min", "sender_pool_max", "query_pool_min", "query_pool_max", - "acquire_timeout_ms", "idle_timeout_ms", "max_lifetime_ms", "housekeeper_interval_ms" - )); - @Test public void testEveryPoolKeyIsHonored() { - String cfg = "ws::addr=127.0.0.1:1;sender_pool_min=0;sender_pool_max=7;query_pool_min=0;query_pool_max=5;" - + "acquire_timeout_ms=1234;idle_timeout_ms=4321;max_lifetime_ms=98765;housekeeper_interval_ms=222;"; - QuestDBBuilder b = QuestDB.builder().fromConfig(cfg); - // min=0 -> build() resolves the pool keys but pre-warms/connects nothing. + // 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(); - Assert.assertEquals(0, snap.get("sender_pool_min")); - Assert.assertEquals(7, snap.get("sender_pool_max")); - Assert.assertEquals(0, snap.get("query_pool_min")); - Assert.assertEquals(5, snap.get("query_pool_max")); - Assert.assertEquals(1234L, snap.get("acquire_timeout_ms")); - Assert.assertEquals(4321L, snap.get("idle_timeout_ms")); - Assert.assertEquals(98765L, snap.get("max_lifetime_ms")); - Assert.assertEquals(222L, snap.get("housekeeper_interval_ms")); - } + for (Map.Entry e : expected.entrySet()) { + Assert.assertEquals("pool key '" + e.getKey() + "' not honored", e.getValue(), snap.get(e.getKey())); + } - @Test - public void testHonoredCasesCoverEveryPoolRegistryKey() { + // 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 case", - COVERED.contains(spec.name())); + 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/QwpQueryClientConfigHonoredTest.java b/core/src/test/java/io/questdb/client/test/impl/QwpQueryClientConfigHonoredTest.java index b1198931..c5c5edb7 100644 --- a/core/src/test/java/io/questdb/client/test/impl/QwpQueryClientConfigHonoredTest.java +++ b/core/src/test/java/io/questdb/client/test/impl/QwpQueryClientConfigHonoredTest.java @@ -42,24 +42,13 @@ * 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 #testHonoredCasesCoverEveryEgressRegistryKey} guards against drift. + * 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 { - // Every EGRESS key plus the COMMON keys the egress client applies - // (credentials, TLS, auth_timeout_ms). addr is COMMON but HOST_PORT_LIST -- - // applied as the endpoint list rather than a snapshot value -- so it is - // excluded, matching the guard formula in - // testHonoredCasesCoverEveryEgressRegistryKey. - private static final Set COVERED = new HashSet<>(Arrays.asList( - "target", "failover", "failover_max_attempts", "failover_backoff_initial_ms", - "failover_backoff_max_ms", "failover_max_duration_ms", "max_batch_rows", - "initial_credit", "buffer_pool_size", "compression", "compression_level", - "client_id", "zone", - "username", "password", "token", "tls_verify", "tls_roots", "tls_roots_password", - "auth_timeout_ms" - )); + private final Set honored = new HashSet<>(); @Test public void testEveryEgressKeyIsHonored() { @@ -85,6 +74,7 @@ public void testEveryEgressKeyIsHonored() { 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 @@ -94,10 +84,12 @@ public void testEveryEgressKeyIsHonored() { 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"); - @Test - public void testHonoredCasesCoverEveryEgressRegistryKey() { + // 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 @@ -108,17 +100,26 @@ public void testHonoredCasesCoverEveryEgressRegistryKey() { 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 case", - COVERED.contains(spec.name())); + Assert.assertTrue("registry egress key '" + spec.name() + "' has no honored assertion", + honored.contains(spec.name())); } } } - private static void assertHonored(String kv, String snapKey, Object expected) { + 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 index 5f69c3ee..69453c77 100644 --- a/core/src/test/java/io/questdb/client/test/impl/WsSenderConfigHonoredTest.java +++ b/core/src/test/java/io/questdb/client/test/impl/WsSenderConfigHonoredTest.java @@ -38,28 +38,24 @@ /** * 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 #testHonoredCasesCoverEveryIngressRegistryKey} fails - * if a registry key gains coverage drift (a new key with no honored case). + * 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 static final Set COVERED = new HashSet<>(Arrays.asList( - "auto_flush", "auto_flush_rows", "auto_flush_bytes", "auto_flush_interval", - "max_name_len", "transaction", "request_durable_ack", "sender_id", "sf_dir", - "sf_max_bytes", "sf_max_total_bytes", "sf_durability", "sf_append_deadline_millis", - "close_flush_timeout_millis", "durable_ack_keepalive_interval_millis", - "initial_connect_retry", "reconnect_max_duration_millis", "reconnect_initial_backoff_millis", - "reconnect_max_backoff_millis", "drain_orphans", "max_background_drainers", - "error_inbox_capacity", "connection_listener_inbox_capacity", - "token", "auth_timeout_ms", "username", "password", "tls_verify", "tls_roots", "tls_roots_password" - )); + 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); - assertHonored("auto_flush=off", "auto_flush_disabled", true); + // 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); @@ -89,16 +85,19 @@ public void testEveryIngressKeyIsHonored() { 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"); - @Test - public void testHonoredCasesCoverEveryIngressRegistryKey() { + // 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 @@ -108,22 +107,32 @@ public void testHonoredCasesCoverEveryIngressRegistryKey() { 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 case", - COVERED.contains(spec.name())); + Assert.assertTrue("registry ingress key '" + spec.name() + "' has no honored assertion", + honored.contains(spec.name())); } } } - private static void assertHonored(String kv, String snapKey, Object expected) { + 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 static void assertHonoredWss(String kv, String snapKey, Object expected) { + 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(); } From 99e2ecda9132c69c61ad0d2617f569f650799b92 Mon Sep 17 00:00:00 2001 From: Marko Topolnik Date: Wed, 24 Jun 2026 14:42:01 +0200 Subject: [PATCH 18/20] Prove sender-pool unwind in build-failure test testQueryPoolBuildFailureUnwindsSenderPool previously asserted only that build() threw when the query pool could not connect. That cannot catch the bug it targets: if build() threw and also leaked the already-built sender pool's connected senders, the test still passed. TestWebSocketServer now tracks connections from the server's view. It increments a live counter when a handshake completes and decrements it when that connection's read thread exits (the client closed its socket), and keeps a monotonic handshake counter. The read loop runs inside a try/finally so the decrement fires on every exit path. The test now proves the unwind two ways: the server saw two ingest handshakes (the senders connected, so the next check is not vacuous), and the live connection count returns to zero after the failed build (the unwind closed every sender). The live count is polled because the server observes the client-side close asynchronously. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../client/test/QuestDBBuilderTest.java | 28 ++++++- .../qwp/websocket/TestWebSocketServer.java | 82 +++++++++++++------ 2 files changed, 83 insertions(+), 27 deletions(-) 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 4f8cd460..1734360b 100644 --- a/core/src/test/java/io/questdb/client/test/QuestDBBuilderTest.java +++ b/core/src/test/java/io/questdb/client/test/QuestDBBuilderTest.java @@ -31,6 +31,7 @@ import org.junit.Test; import java.util.concurrent.TimeUnit; +import java.util.function.BooleanSupplier; public class QuestDBBuilderTest { @@ -274,9 +275,21 @@ public void testQueryPoolBuildFailureUnwindsSenderPool() throws Exception { .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 tells us the sender-pool unwind ran. + // 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); } } @@ -396,4 +409,15 @@ private static void assertSchemaRejected(Runnable action) { 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/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()) { From 98269c8c1dad079de2772380ce937f4ff2e9a72e Mon Sep 17 00:00:00 2001 From: Vlad Ilyushchenko Date: Wed, 24 Jun 2026 17:02:42 +0100 Subject: [PATCH 19/20] Use Java 8-compatible map init in ConfigView The merge brought in the JDK 8 CI build job, which flagged Map.of() (Java 9+) in ConfigView's RELOCATED_HINTS. Replace it with a static initializer over a HashMap wrapped in Collections.unmodifiableMap so the source compiles under JDK 8. --- .../io/questdb/client/impl/ConfigView.java | 26 ++++++++++++------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/core/src/main/java/io/questdb/client/impl/ConfigView.java b/core/src/main/java/io/questdb/client/impl/ConfigView.java index 1eaffdce..1d1c0643 100644 --- a/core/src/main/java/io/questdb/client/impl/ConfigView.java +++ b/core/src/main/java/io/questdb/client/impl/ConfigView.java @@ -28,6 +28,8 @@ 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; @@ -51,16 +53,20 @@ 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 = Map.of( - "retry_timeout", "(use reconnect_max_duration_millis on ws/wss)", - "protocol_version", "(QWP negotiates the protocol version during the WebSocket upgrade)", - "init_buf_size", "(applies to legacy http/tcp/udp transports only)", - "max_buf_size", "(applies to legacy http/tcp/udp transports only)", - "request_timeout", "(applies to legacy http/tcp/udp transports only)", - "request_min_throughput", "(applies to legacy http/tcp/udp transports only)", - "max_datagram_size", "(applies to legacy http/tcp/udp transports only)", - "multicast_ttl", "(applies to legacy http/tcp/udp transports only)" - ); + 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<>(); From fceb60d4406d208aff3aed785927f1902063a585 Mon Sep 17 00:00:00 2001 From: Vlad Ilyushchenko Date: Wed, 24 Jun 2026 17:19:10 +0100 Subject: [PATCH 20/20] fix(config): accept '_' digit separator in addr port parsePort used Integer.parseInt, which rejects underscores, while every other numeric config key parses via Numbers.parseInt/parseLong, which treat '_' as a digit-group separator. The merge-base ingress parser (Sender.addAddressEntry) also used Numbers.parseInt, so addr=host:9_000 parsed before this refactor and regressed to rejection. Switch parsePort to Numbers.parseInt (catching NumericException) to restore prior behaviour and align addr with the other numeric keys. --- core/src/main/java/io/questdb/client/impl/ConfigView.java | 4 ++-- .../java/io/questdb/client/test/impl/ConfigViewTest.java | 8 ++++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/core/src/main/java/io/questdb/client/impl/ConfigView.java b/core/src/main/java/io/questdb/client/impl/ConfigView.java index 1d1c0643..1160c2d6 100644 --- a/core/src/main/java/io/questdb/client/impl/ConfigView.java +++ b/core/src/main/java/io/questdb/client/impl/ConfigView.java @@ -234,8 +234,8 @@ private static boolean outOfRange(ConfigSchema.KeySpec spec, long v) { private static int parsePort(String portStr, String entry) { int port; try { - port = Integer.parseInt(portStr.trim()); - } catch (NumberFormatException e) { + port = Numbers.parseInt(portStr.trim()); + } catch (NumericException e) { throw new IllegalArgumentException("invalid port in addr: " + entry); } if (port < 1 || port > 65535) { 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 index bf4527c9..38891719 100644 --- a/core/src/test/java/io/questdb/client/test/impl/ConfigViewTest.java +++ b/core/src/test/java/io/questdb/client/test/impl/ConfigViewTest.java @@ -61,6 +61,14 @@ 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");