Skip to content

feat(qwp): add browser and Node.js QWP client - #62

Open
glasstiger wants to merge 239 commits into
mainfrom
ia_node_qwp
Open

feat(qwp): add browser and Node.js QWP client#62
glasstiger wants to merge 239 commits into
mainfrom
ia_node_qwp

Conversation

@glasstiger

@glasstiger glasstiger commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

Summary

Add a complete QWP client surface that works in both browsers and Node.js while leaving the existing ILP transports Node-only.

QWP support ships as a preview: QWP.md documents the compatibility baseline for the first QWP release, and imports from internal source paths are never supported.

Entry points

The repository now builds two published packages from a shared private core, each exposing its complete API from its package root.

Package Runtime Use it for
@questdb/nodejs-client Node.js Existing Sender (including QWP ingress selected with ws::, wss::, or udp::), QWP codecs, egress, TLS, and persistent store-and-forward
@questdb/browser-client Browser Browser-safe QWP ingress, egress, authentication bootstrap, sessions, and codecs

@questdb/nodejs-client keeps the existing Node.js transports and dependencies, so nothing about the current client changes for existing consumers. @questdb/browser-client has no Node.js imports, Node.js typings, Node engine requirement, undici, or ws, so supporting browsers does not require compromising the Node.js build.

Ingress

  • browser-safe QWP codecs and WebSocket sessions
  • high-level Sender integration with fluent rows, batching, byte/interval auto-flush, commits, transactions, and ACK watermarks
  • compiled object-row table writers (sender.writer(table, schema).row({...})) for repeated rows on one schema, with the full QWP column-type set
  • fire-and-forget QWP v1 over IPv4 UDP (udp::, Node-only) behind the same fluent row API
  • automatic symbol-dictionary deltas and server batch-cap splitting
  • durable ACK negotiation, backpressure, reconnect/failover, topology-aware routing, endpoint health, and replay semantics
  • Node-only store-and-forward using the cross-client SFA persistence format, segmented replay, orphan draining, durability policies, bounded memory, and background segment maintenance. Slot locking is pure JavaScript, so a Node.js and a Java client must not use one persistence directory concurrently (see Compatibility)
  • a rejected value discards the row in progress — both its columns and its table selection — so a half-built row can never reach QuestDB

Egress

  • typed SQL binds, cancellation, query deadlines, and automatic credit replenishment
  • zstd negotiation/decompression and bounded result buffering
  • reusable column-major and row-major result views
  • reconnect/failover with server-role validation and pooled concurrent query sessions

Observability

  • immutable metrics snapshots for polling, plus onProgress, onError, and the Java-parity onSenderError rejection stream for event-driven telemetry
  • callbacks run on bounded asynchronous inboxes and never inside ACK, reconnect, or orphan-recovery protocol stacks; overflow is counted in the metrics snapshot
  • unobserved retriable rejections, terminal rejections, and abandoned data are logged by default, so background store-and-forward failures are never silent

API and platform integration

  • unified browser and Node cluster configuration with conflict validation
  • pooled QuestDB facade combining ingress and egress
  • browser authentication bootstrap for qdb_session
  • dual CJS/ESM package exports, public API contract tests, examples, migration guidance, and QWP reference documentation
  • a diagnostic benchmark suite (benchmarks/) covering encoder floors, the high-level sender, egress views, store-and-forward persistence policies, and a live end-to-end lane
  • CI: a Playwright/Chromium job that drives the built browser bundle against a local mock server, and a dormant-by-default dispatch job that queues the Enterprise JavaScript-client E2E pipeline for the exact client commit. Tests requiring a live QuestDB are owned by the server repositories, which hold the topology and authentication fixtures; the browser negotiation and session-auth contracts are covered there by QwpBrowserSessionAuthTest, QwpIngressUpgradeProcessorOnHeadersReadyTest, and QwpEgressMaxBatchRowsTest, plus the Enterprise REST/OIDC login suites

Compatibility

  • Connection strings, options, and authentication behavior of the existing http/https/tcp/tcps senders are unchanged, with the two exceptions below.
  • ILP TCP authentication is fixed on Node.js 26. auth: {keyId, token} supplies only the private scalar, and the JWK was completed with a hardcoded public point unrelated to it. Node.js accepted that inconsistent pair without validating it up to v24 and rejects it from v26 with ERR_CRYPTO_INVALID_JWK, so TCP auth failed outright on that runtime. The point is now derived from the private key. Signing only ever used the private scalar, so signatures, credentials, and auth outcomes are unchanged on every Node.js version; callers passing a complete jwk object were never affected.
  • Nullish column values changed on those senders (Client should skip columns if value is null #28). null and undefined now omit the column, which QuestDB records as NULL; most column methods previously threw a type error. On protocol v2 this also changes the wire bytes for arrayColumn(name, null), which used to emit an explicit NULL-array marker — QuestDB rejects that encoding with ARRAY_INVALID_TYPE (verified against 9.4.3), so omitting it is itself a fix. A row whose every value is nullish now fails when the row is closed rather than at the column call. Code that relied on the throw as a data-quality guard should validate before calling the sender.
  • Browser mode does not provide disk persistence; store-and-forward is Node-only.
  • A Node.js client and a Java client must not use one store-and-forward directory at the same time. The Java client guards slots with flock/LockFileEx; the Node.js client uses a pure-JavaScript directory lock and cannot participate in those kernel locks, so neither sees the other. The persistence format stays cross-client for sequential handoff — a directory written by one runtime can be opened by the other once the first has closed it — and two Node.js processes still exclude each other. Depending on a native addon for kernel locks was the alternative, and it left store-and-forward broken on any platform or Node.js major without a prebuilt binary.
  • Browser-only capabilities are negotiated in protocol messages because browser WebSocket APIs cannot set custom upgrade headers.

Dependencies

  • new runtime dependency: ws (Node WebSocket transport). There is no native dependency; store-and-forward locking is pure JavaScript
  • fzstd is bundled into the build output for egress decompression; THIRD_PARTY_NOTICES.md records its license

Resolved issues

Validation

  • full test suite, including containerized integration coverage: 30 files / 756 tests passed
  • targeted reconnect and SFA interoperability suite: 156 tests passed
  • benchmark self-checks (pnpm vitest run benchmarks): 3 files / 14 tests passed
  • pnpm typecheck
  • pnpm typecheck:qwp-browser
  • pnpm typecheck:test
  • pnpm typecheck:bench
  • pnpm eslint
  • pnpm lint:bench
  • pnpm build
  • pnpm test:dist (loads both built packages through their exports maps): 3 files / 30 tests passed
  • pnpm typecheck:dist
  • pnpm check:packages

Dependencies and provenance

glasstiger and others added 30 commits August 27, 2026 00:43
The ingress and egress sessions close the connection for its side effect
when a server message cannot be decoded, and discarded the returned
promise with `void`. A reconnecting ingress transport's close() awaits
the replay store outside any catch, and QwpNodeFileReplayStore.close()
rethrows a periodic-checkpoint, segment-handle or advisory-lock release
failure. A read-only or full journal volume therefore turned a malformed
server response into an unhandled rejection, which terminates the host
process by default from Node 15 on.

Attach the same `.catch(() => undefined)` every sibling call site already
uses. The protocol error itself still reaches the caller through the
rejected send and through fail(); only the secondary teardown failure is
absorbed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5WuAdKhXeFBSS9mswuZgG
Both bytes are defined in QWP_STATUS but had no case in
qwpSenderErrorCategory, so they resolved to UNKNOWN -- indistinguishable
from an unassigned byte. The replay transport treats UNKNOWN as
poison-strike exempt so that an unrecognised future status retries
forever rather than being blamed on the frame, which meant a frame
rejected for exceeding the server's cap replayed without bound and
stalled the pipeline behind it, since ACKs are cumulative.

Give both a category. LIMIT_EXCEEDED stays RETRIABLE rather than
TERMINAL: it is deterministic under byte-identical replay, but routing
it through the poison detector ends it via the quarantine path, which
preserves the rows for retryQwpNodeOrphanSlot() instead of failing the
producer outright.

Add a test that asserts every defined status byte outside the three
success responses is classified, so a future status cannot silently
inherit the exempt path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5WuAdKhXeFBSS9mswuZgG
Three problems in the replay poison detector, all of which decided the
fate of a running store-and-forward producer:

- WRITE_ERROR and INTERNAL_ERROR are RETRIABLE by policy, yet consumed
  poison strikes. With the escalation window defaulting to 5 seconds and
  the reconnect backoff capped at maxBackoffMs, four strikes accumulated
  well inside that window, so a concurrent DDL, a checkpoint or a briefly
  full server volume permanently killed the producer. Raise the default
  window to 5 minutes, which is what actually separates "this frame is
  poison" from "the server cannot write right now".

- A DICTIONARY_GAP is the server asking for symbol catch-up, not a
  verdict on the frame. Charging a strike condemned the frame before the
  recovery it asked for had even been attempted. Exempt the status, not
  just frames already flagged dictionaryCatchup.

- An intervening connection-establishment failure wiped the whole
  episode. That made the canonical poison case unreachable: a frame that
  takes the server down guarantees the next connect fails, so the count
  reset before it could ever meet maxFrameRejections and the frame
  replayed without bound. Keep the strikes and bank the outage instead,
  so the escalation window measures connected dwell only -- which is the
  behaviour the original comment described wanting.

Left alone deliberately: the 1000/1001/1012/1013 close-code exemption.
It lets a peer's close code influence policy, but removing it would
charge poison strikes to innocent frames during a rolling restart, and
it errs toward retrying rather than abandoning data.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5WuAdKhXeFBSS9mswuZgG
addColumn() validated the column name, type and scale but never the
column count, so a row with more than QWP_MAX_COLUMNS_PER_TABLE distinct
columns staged cleanly and atNow() accepted it. The cap was then enforced
inside QwpTableBuffer during buildTable(), which runs in flushNow()
before anything reaches the transport -- so the throw escaped before
releaseStagedRows() and the rows stayed staged. Every subsequent flush()
rebuilt the same table and threw again, and so did close(), which sent
nothing. One over-wide row therefore made the sender permanently
unusable and stranded every row staged before it, in that table and in
every other, with reset() the only escape and it drops everything.

Check the count in addColumn() where the new column is added. The
existing try/catch routes it through failRow(), which discards just the
row in progress and its table selection, so the sender stays usable and
earlier rows still flush. Apply the same bound to compileWriterSchema(),
which had no column-count check either.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5WuAdKhXeFBSS9mswuZgG
Sizing a column and writing it independently derived the same three
things. The Gorilla path was the worst: columnPayloadSize() rebuilt the
bigint array and ran qwpGorillaSize(), writeColumn() rebuilt and ran it
again, and encodeQwpGorilla() ran it a third time before encoding --
four passes and two N-element bigint arrays where one of each will do.
inlineSymbolDictionary() rebuilt its Map and row-id array in both
passes, and nullCount() rescanned the null vector in both.

Compute a per-column plan once and share it between the passes through
ColumnEncodeOptions. The map is scoped to a single encodeQwpIngressFrame()
call, so a column mutated between calls can never be sized from a stale
plan.

Measured on the repository's own benchmark workloads, 10k rows, 100
iterations after 20 warmup, gorilla on:

  trades   1.34 ms -> 1.17 ms   (1.15x)
  sparse   2.92 ms -> 1.98 ms   (1.47x)
  wide    39.7  ms -> 38.0  ms   (1.04x)

Output is byte-identical: the SHA-256 of each encoded frame is unchanged
across all three workloads with gorilla both on and off.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5WuAdKhXeFBSS9mswuZgG
The sender promises that a rejected value discards the row in progress,
including its table selection, so a half-built row can never reach
QuestDB. Two setters escaped that guarantee.

binaryColumn() built its defensive copy in addColumn()'s argument list,
outside addColumn()'s own try. A Uint8Array whose ArrayBuffer has been
transferred away -- structuredClone with `transfer`, or
worker.postMessage -- makes that copy throw, so the throw escaped past
failRow(): table() then reported "Table name has already been set" and
the next atNow() published the row without its binary column. Copy
inside a guard, the way uuidColumn() already does. It is the only one of
the 22 addColumn() call sites that converted a value outside a try.

decimalColumn() returned early for a zero-length Int8Array -- the
documented byte-array spelling of NULL -- before reaching addColumn(),
which is where a non-nullish call gets its availability, row-state and
column-name checks. That spelling therefore accepted a call made before
table(), a name with illegal characters, and a non-string name, all of
which the null spelling and the ILP v3 equivalent reject. Route it
through omitsNullish(name, null), as long256Column's all-absent path
already does for the same reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5WuAdKhXeFBSS9mswuZgG
…lished

A QWP column carries one decimal scale per frame, so the fluent row API
locks the scale on the first value staged for a column and rescales
later values onto it. That lock lived in table.schema, which
releaseStagedRows() never cleared, so it outlived every flush and held
for the sender's lifetime.

The consequence is a data-shape trap: a stream whose first decimal
happens to be integral locks scale 0, and every more precise value
afterwards fails with "cannot rescale decimal ... without precision
loss" -- forever, and across flushes. The identical sequence on
http::...;protocol_version=3 is ingested in full, so the same
application code silently drops rows on QWP and not on ILP.

The cited Java parity is QwpTableBuffer.ColumnBuffer, which is per-frame.
Drop the decimal entries from a table's schema once its staged rows have
been published, so the next frame's first value sets the scale afresh.
Within a frame the lock is unchanged: rows still share one scale and a
value that cannot be rescaled exactly still fails its row.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5WuAdKhXeFBSS9mswuZgG
When close() ran while the first connect was still in flight, closeNow()
aborted it and then fired the teardown without awaiting it. The advisory
lock, journal handles and socket were therefore released on a detached
promise chain, after close()'s own promise had already resolved.

A sender created on the same sf_dir immediately after `await
sender.close()` failed with QwpReplayStoreLockedError naming its own
process -- the shape a SIGTERM handler that closes and reopens, or a test
that reuses a directory, actually has. The existing regression test only
required the lock to be gone within three seconds, so it did not catch
the window.

Await the teardown, bounded by the same close deadline the publication
step uses, so a connection factory that ignores the abort signal cannot
make close() wait out its connect timeout instead. Tighten the test to
reopen the slot with no polling window at all; it fails against the old
detached chain.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5WuAdKhXeFBSS9mswuZgG
connectNow() closed the whole client when prewarm rejected, and connect()
memoized the resulting promise. One transient outage during prewarm --
a rolling restart, a load-balancer warm-up, a 502 from a proxy -- was
therefore fatal: close() latches closing/closed irreversibly, the retry
returned the original rejection without reaching the server again, and
every borrowSender()/borrowQuery() afterwards threw QwpClientClosedError.
The only recovery was to discard the client and build a new one, which
QWP.md never said.

Closing was there to avoid leaving half-warmed entries behind, but
prewarm() already releases every entry it did acquire back into the pool
before it rethrows, so what it leaves are healthy warm connections. Let
the rejection reach the caller instead, and clear the memoized attempt on
rejection so the next connect() starts a new one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5WuAdKhXeFBSS9mswuZgG
Durable ACK is negotiated on the /write/v4 ingress route. The unified
config parser spreads the typed `webSocket` block into both sides, so a
`{ webSocket: { requestDurableAck: true } }` override reached `egress`
as well; connectQwpNodeEndpoint() then sent X-QWP-Request-Durable-Ack on
/read/v1 and rejected the session with QwpDurableAckUnavailableError
because no response carries x-qwp-durable-ack there. Ingest worked while
every pooled query session failed to connect, and the failure is not
retryable, so the query pool went terminal.

The connect-string key request_durable_ack was always ingress-only, so
the two spellings disagreed.

Drop it from the shared block -- the ingress block sets it explicitly
anyway -- and strip it in egressTransportOptions() alongside the other
ingress-only fields, so the typed object form is covered too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5WuAdKhXeFBSS9mswuZgG
Each was checked against the parser or the export surface:

- QWP.md named `connectQwpNodeQuery()` as a connect-string entry point.
  No such export exists; it is `connectQwpNodeEgress()`.
- The sf_dir defaults paragraph claimed a 60-second close drain. The
  parser produces 5000 ms, which the key table and the store-and-forward
  section both already stated correctly.
- The same paragraph claimed "4 MiB frame/segment batches". `sf_dir`
  alone defaults the segment size but leaves the ingress frame cap
  unset, so say what it actually sizes and point at the two ways to
  bound a frame before the server advertises its cap.
- `sf_max_segment_bytes` was documented only as journal segment size. It
  installs an ingress frame cap as well, with or without `sf_dir`, so a
  user who sets it alone gets QwpBatchTooLargeError on larger batches.
- The Pool table said a standalone sender or query client ignores every
  key in it. `query_close_timeout_ms` is parsed into `egressSession` and
  bounds the CANCEL drain of any egress session built from it.
- "Both keys apply to ingress and egress alike" is true on Node.js only:
  browser ingress plumbs neither `target` nor `zone`, and cannot, since
  the WebSocket API hides the upgrade response that carries role and zone.
- QwpNodeIngressOptions.senderId's TSDoc claimed a `default` default.
  That is the connect-string parser's; the typed API has no default, and
  pooled slots derive `sender-<slot>`.

config-docs.test.ts checked key names but never function names, which is
how the first of these survived. Add a check that every backticked
connect/create entry point QWP.md names is exported by one of the two
package roots.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5WuAdKhXeFBSS9mswuZgG
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

State-machine builder Client should skip columns if value is null

1 participant