feat(qwp): make the QuestDB facade a WebSocket (QWP) entry point and clean up the connect-string keys#55
Conversation
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 <token>` 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
|
Tandem parent PR: questdb/questdb#7314 — these must merge together (the parent's submodule pointer references this branch). |
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
|
Reviewed at level 3 (full mission-critical pass: change-surface map, parser-correctness, ingress parity, egress parity + resource safety, test/coverage/conventions, and a fresh-context adversarial sweep, with per-finding source verification). This is high-stakes territory (public API + connect-string parsing), so level 3 is appropriate. CriticalNone. No correctness defect, resource leak, or undefined behavior was found in the client module. The two independent deep passes (structured parity agents + a no-checklist adversarial agent) converged on the same conclusion: parsing, validation, pool resolution, native-memory cleanup, and the always-on-Gorilla flag are all sound. Specifically verified correct (not just "looks fine"):
ModerateM1 — The PR's headline public API Minorm1 — m2 — m3 — m4 — Pool-precedence tests assert only m5 — Honored drift guards protect a hand-maintained m6 — PR labels are incomplete. Only Cross-repo coordination (verified handled — not a defect in this PR)Removing the client's SummaryVerdict: Approve — no blocking correctness, concurrency, or resource issues in the client module; the cross-repo break is verified handled by tandem PR #7314.
Recommended before merge (all quick): add m1 (or relax the javadoc), m2 (gorilla rejection test), and ideally M1 (a |
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
|
Reviewing at level 3 (full mission-critical pass: 10-agent matrix — Rust agent N/A, no Verdict: Approve with minor changes. This is a clean, well-tested refactor. The 3-layer parser ( CriticalNone. ModerateM1 — The test's stated purpose is to prove that when the query pool fails to build, the already-warmed sender pool is closed rather than leaked. But it only asserts The underlying unwind logic is correct ( Minorm1 — The PR advertises "numeric values now accept underscore separators ( It is also a verified ingress regression: the old ws ingress addr parser ( m2 — Alias + canonical silently collide by position; duplicate keys validate only the last occurrence (in-diff) —
m3 — PR description's "Stable ILP is unaffected" contradicts a later bullet (metadata) — The "Behavior changes / tradeoffs" section leads with "Stable m4 — PR under-labeled (metadata) — Only m5 — Test gaps (test) — All Minor:
Nits
Summary
|
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) <noreply@anthropic.com>
# Conflicts: # core/src/main/java/io/questdb/client/QuestDBBuilder.java
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.
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.
[PR Coverage check]😍 pass : 675 / 691 (97.68%) file detail
|
bluestreak01
left a comment
There was a problem hiding this comment.
Approved after a level-3 mission-critical pass (full source verification + local test run + CI confirmation on HEAD).
No critical, concurrency, or resource issues. Verified:
- Always-on Gorilla flag never lost (init FLAG_GORILLA, beginMessage saves/restores, no reset clears it).
- UNSET=-1 pool sentinel is unproducible by any setter; timeouts map only 0->MAX.
- fromConfig native (bindValues) cleanup on every throw path; validateConfig pre-validates before construction.
- Single-string facade port parity (ingress/egress both 9000) and blank-credential parity (matching messages on both sides).
- JDK 8 floor: no Java 9+ APIs in changed files; the one Map.of slip was caught by CI and fixed (98269c8). CI green on HEAD across both JDK fronts.
- 388+ targeted tests green locally.
All prior-review findings (port underscore, gorilla rejection test, connect() smoke test, observable sender-pool unwind, pool-precedence value assertion, sf_durability fail-fast) are resolved in later commits.
Minor/optional nits only: @testonly getter ordering in QwpQueryClient (getAuthorizationHeaderForTest/configSnapshotForTest out of alphabetical order) and a defensive null-guard in ConfigView.getEnum.
Reminder: must merge together with the tandem parent PR questdb/questdb#7314, whose submodule pointer must reference this PR's final merged SHA.
What
This branch makes the
QuestDBfacade QWP-over-WebSocket only and, ahead ofthat, cleans up the
ws/wssconnect-string vocabulary so a single string isparsed identically by the ingest
Senderand the egressQwpQueryClient.Facade rewrite
The facade (single-string
connect, two-argconnect, and the builder'singestConfig/queryConfig) now requires thewsorwssschema and handsthe connect string verbatim to both clients. It previously routed single-string
ingest to HTTP via a schema-translation step, which was incorrect.
http,https,tcp,tcps, andudpare now rejected with a clear message.A new parser package (
io.questdb.client.impl) replacesConfigStringTranslator:ConfigStringtokenizesschema::k=v;k=vover the existingConfStringParser.ConfigSchemais the single staticws/wsskey registry, carrying eachkey's side, type, range, enum, and alias.
ConfigViewruns the reject pass (unknown key, plus a relocated-key hint forlegacy
http/tcp/udpkeys), the typed getters, and an IPv6-awareaddrparser with duplicate rejection.
QwpQueryClient.fromConfigreparses overConfigViewand now honors theuser/passaliases; theusername/passwordandtokencredentialsauthenticate on both sides. Its
validateConfigis reused by the facade'sfail-fast build path.
Sender.fromConfigroutesws/wssto a newfromConfigWebSocketdriven byConfigView.QuestDBBuildervalidates bothstrings up front (even when a pool
minis 0 and nothing connects) and resolvespool keys with explicit-wins precedence and cross-string conflict detection.
Connect-string vocabulary cleanup
All of this is QWP-only. QWP is unreleased, so none of it touches stable ILP.
Four keys are removed:
auth-- the pre-formed HTTPAuthorizationheader value. Credentials arenow supplied only as the structured
tokenorusername/passwordkeys, andthe header is synthesized downstream (
QwpQueryClientbuilds it the same waythe
Senderalready did). The programmaticQwpQueryClient.withAuthorization(String)raw-header setter is removed too, so no path accepts a pre-formed header.
path-- chose the egress upgrade URI (default/read/v1). The serveraccepts only
/read/v1(and its/api/v1/readalias), so the key selectedbetween two synonyms and added no real configurability. The egress upgrade now
uses the fixed
/read/v1; thewithEndpointPathsetter is removed.in_flight_window-- a QWP store-and-forward concept that the parser onlyever accepted as a no-op (it landed during the QWiP store-and-forward work, in
the 1.1.0 beta line). The cursor / store-and-forward segment ring and append
deadline govern backpressure, so the count had no effect. There was no
programmatic setter.
gorilla(gorilla=on|off,LineSenderBuilder.gorilla(), and thesetGorillaEnabled/isGorillaEnabledplumbing) -- WebSocket ingestion nowalways uses Gorilla timestamp encoding (the encoder always sets
FLAG_GORILLA);the column writer still falls back to raw values per column when delta-of-delta
overflows int32. The UDP sender keeps its non-Gorilla path.
Key validation is tightened so the Sender and the QwpQueryClient treat every
key on a shared
ws/wssstring the same way:transaction(an ingress-only key) is now accepted and consumed by the egressparser, so a shared string carrying it no longer fails as unknown.
on_internal_error,on_parse_error,on_schema_error,on_security_error,on_server_error, andon_write_errorare accepted as no-ops on both sides, per the connect-string spec.
request_timeout,retry_timeout,request_min_throughput, andprotocol_version, and the UDP-only keysmax_datagram_sizeandmulticast_ttl, are rejected onws/wss(with arelocated-key hint) while remaining valid on their own transports.
Behavior changes / tradeoffs
ws/wssconnect-string surface are unreleased, so requiringws/wssonthe facade (and every vocabulary change above) is beta churn, not a break.
http/tcp/udpILP is unaffected. StandaloneSender.fromConfigkeeps every stable ILP key parsing exactly as before. The four removed keys are
all QWP vocabulary; on those transports they were only ever tolerated as silent
no-op passthroughs (
auth,path,in_flight_window, so a shared QWP connectstring would not error) or already rejected (
gorilla, which wasWebSocket-only and threw). They never affected ILP behavior there. After this
change
auth/path/in_flight_windoware rejected as unknown keys on thosetransports, and the reserved
on_*_errorkeys are additionally accepted asno-ops.
gorilla=off.This path was WebSocket-only and unreleased.
last-write-wins, and the egress side now rejects duplicate addresses instead of
silently accumulating them.
Numbersparser,so they accept underscore separators (e.g.
1_000) and a trailingLonlongs, which the previous
Integer/Long.parseIntrejected.Test plan
ConfigViewTestandQwpConfigKeysTest(every registry key recognized byboth clients; the relocated-key hint table is exactly the legacy keys;
token_x/token_ystay plain unknowns).WsSenderConfigHonoredTest,QwpQueryClientConfigHonoredTest,PoolConfigHonoredTest-- assert each configkey's value is applied (not merely accepted), each with a drift guard over the
registry.
messages, relocated-key hints, and last-write-wins behavior;
ConfigStringTranslatorTestremoved.🤖 Generated with Claude Code