From 417ccbd660a4c7acf3bb95184fe77dae4e705622 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Mon, 17 Aug 2026 13:32:57 +0100 Subject: [PATCH 001/265] chore: add pull request review skill --- .claude/skills/review-pr/SKILL.md | 364 ++++++++++++++++++++++++++++++ 1 file changed, 364 insertions(+) create mode 100644 .claude/skills/review-pr/SKILL.md diff --git a/.claude/skills/review-pr/SKILL.md b/.claude/skills/review-pr/SKILL.md new file mode 100644 index 0000000..0f182a1 --- /dev/null +++ b/.claude/skills/review-pr/SKILL.md @@ -0,0 +1,364 @@ +--- +name: review-pr +description: Review a GitHub pull request against @questdb/nodejs-client (TypeScript ILP client) coding standards. Performs an adversarial, blocking, mission-critical code review covering correctness, buffer/byte-encoding safety, ILP wire format, transport/auth/TLS, async & resource lifecycle, performance, test coverage, and TypeScript API conventions, then verifies every finding against source before reporting. +argument-hint: [PR number or URL] [--level=0..3] +allowed-tools: Bash(gh *), Bash(git *), Read, Grep, Glob, Agent +--- + +Review the pull request `$ARGUMENTS`. + +## Review mindset + +You are a senior QuestDB engineer performing a blocking code review. `@questdb/nodejs-client` is mission-critical software: a TypeScript client that serializes rows into the QuestDB **InfluxDB Line Protocol (ILP)** wire format and ships them over HTTP/HTTPS (Undici or Node stdlib) or TCP/TCPS, and is used to ingest production data from customer Node.js applications. A bug here causes **silent data corruption on the wire** (a mis-encoded byte, a wrong column separator, a truncated buffer), **dropped or duplicated rows** (a flush that discards data on failure, a retry that re-sends), or a client that wedges a worker thread. The runtime is managed — there are no segfaults — but a corrupt ILP line, a lost flush, or a buffer written past its reserved capacity are the mission-critical failures here, and QuestDB cannot un-ingest bad data after it lands. Be critical, thorough, and opinionated. Your job is to catch problems before they ship, not to be nice. + +- **Assume nothing is correct until you've verified it.** Read surrounding code to understand context — don't just look at the diff in isolation. +- **The diff is a hint, not the boundary of the review.** The highest-value bugs almost always live at callsites outside the diff that depend on contracts the diff quietly changed (a `checkCapacity` reservation that no longer matches the bytes written, a buffer state-machine transition, a `SenderBufferBase` method inherited by v1/v2/v3, an option name consumed by `resolveDeprecated`). Treat the diff as the entry point, not the scope. +- **Flag every issue you find**, no matter how small. Do not soften language or hedge. Say "this is wrong" not "this might be an issue". +- **Do not praise the code.** Skip "looks good", "nice work", "clever approach". Focus entirely on problems and risks. +- **Think adversarially.** For each change, work through: + - Inputs: which values break this? `null`/`undefined` where a value is expected, empty strings, empty arrays (`[]` is truthy and has a `null` element type), `NaN`/`Infinity` floats, a `number` LONG beyond `2^53` (silently imprecise), `bigint` vs `number` at the timestamp boundary, max-length table/column names, non-ASCII/multi-byte UTF-8, strings containing the ILP delimiters (space, comma, `=`, `\n`, `\r`, `"`, `\`), irregular or non-homogeneous nested arrays. + - Wire format: does the serialized byte sequence match what the server expects for the negotiated protocol version (v1 text, v2 binary doubles + arrays, v3 decimals)? Column separators (leading space vs `,`), escaping, little-endian doubles/ints, array dimension headers, two's-complement decimal payloads. + - Buffer capacity: does every `checkCapacity(data, base)` reserve **at least** the exact number of bytes the following `write`/`writeByte`/`writeInt`/`writeDouble` calls emit? An under-reservation is silent corruption (`Buffer.write` short-writes at the allocation boundary) or a `RangeError` (`writeInt8`/`writeInt32LE`/`writeDoubleLE` throw past the end). + - Async & failure modes: connection drop mid-flush, HTTP 5xx, a retry after an uncertain send (duplicate rows?), TLS handshake failure, auth rejection — does the `Buffer` end in a usable state, and are rows lost or double-sent? Is every Promise awaited? + - Resource: is every socket, Undici pool/agent, `AbortController` timer, and TLS connection released on the error path as well as the happy path? Does the Sender close an `agent` the user passed in (which it must not)? +- **Check what's missing**, not just what's there. Missing tests, missing error handling, missing edge cases, missing `README.md`/`docs` updates for public API changes, a new option that `resolveDeprecated`/`resolveAuto`/the config parser doesn't handle, a new public symbol not exported from `src/index.ts`. +- **Verify every claim.** If the PR title says "fix", verify the bug actually existed and the fix is correct. If it says "improve performance", reason about the per-row hot path or look for a benchmark. If it says "simplify", verify the new code is actually simpler and doesn't drop behavior (a dropped escape, a lost capacity check, a removed `await`). Treat the PR description as an unverified hypothesis. +- **Read the full context of changed files** when the diff alone is ambiguous. Use Read/Grep/Glob to inspect surrounding code, callers, and related tests. +- **Assess reachability before reporting.** For every potential bug, trace the actual callers and inputs. If a problem requires physically impossible conditions (a buffer larger than `max_buf_size` which is already guarded, a value no caller can produce), it is not a real finding — drop it. Focus on bugs real workloads trigger, not theoretical edge cases the code already rejects upstream. +- **Never review generated or build artifacts.** `dist/cjs/**` and `dist/es/**` are `bunchee` build outputs, and `docs/**` is generated by `typedoc`. The source of truth is `src/**/*.ts` and `test/**/*.ts`. If the diff contains build output, review the `src` change that produced it, not the artifact. + +## Review level + +Parse `$ARGUMENTS` for a level token: `--level=N`, `-lN`, or a bare single digit `0`-`3`. **If no level is given, default to 0.** Strip the level token before feeding the remainder (PR number or URL) to `gh` commands. + +The level controls how much of the review below actually runs. Lower levels keep the same review *spirit* — adversarial, blocking, no praise — but cut the breadth of the analysis. Higher levels have significantly higher token cost; reserve level 3 for high-stakes PRs (anything touching the ILP wire format in `src/buffer/**`, buffer capacity/resize math, a transport in `src/transport/**`, auth/TLS, protocol-version negotiation in `src/options.ts`, or the public API surface in `src/index.ts`). + +| Level | What runs | +|-------|-----------| +| **0 (default)** | Steps 1, 2, 4. Skip Steps 2.5a-d, but still run Step 2.5e (build & runtime profile — mandatory at every level). Skip Step 3 — no agent spawn; review the diff inline in the main loop, using Read/Grep on demand to resolve ambiguities. Skip Step 3b — verify each finding inline as you write it. Single-pass review covering correctness, buffer/byte-encoding safety, ILP wire format, `null`/`undefined` handling, tests, and coding standards on the diff itself. | +| **1** | Adds Step 2.5a (semantic delta only — skip 2.5b/2.5c/2.5d; Step 2.5e still runs, as at every level). In Step 3, launch only Agent 1 (correctness), Agent 2 (buffer & byte-encoding safety), and Agent 7 (tests) in parallel. Skip all other agents. Skip Step 3b — verify findings inline as you draft the report. | +| **2** | Full Step 2.5, but in 2.5b restrict the callsite inventory to symbols exported from `src/index.ts`, plus every `protected`/`abstract` member of `SenderBufferBase`/the transport interfaces, plus every configuration option name. In Step 3, launch Agents 1-8. Skip Agent 9 (cross-context) and Agent 10 (adversarial fresh-context). Step 3b uses a single batched verification agent for all findings instead of one per finding. | +| **3** | Every step below as written, all 10 agents, per-finding verification. The full mission-critical pass. | + +State the chosen level in one line at the start of the review so the user knows what they're getting (e.g., "Reviewing PR #58 at level 2"). If the level was defaulted, mention that level 3 exists for full review. + +## Step 1: Gather PR context + +Capture the PR identifier in `$PR` (the part of `$ARGUMENTS` left after stripping the level token), then fetch metadata, diff, and review comments in a single bash call so `$PR` is in scope for all three `gh` invocations: + +```bash +PR='' +gh pr view "$PR" --json number,title,body,labels,state +gh pr diff "$PR" +gh pr view "$PR" --comments +``` + +If the diff modifies the ILP serialization in `src/buffer/**`, a transport in `src/transport/**`, or the protocol/auth/TLS options in `src/options.ts`, note it now — a wire-format or transport change is the highest-risk class of change in this repo and forces level-3 scrutiny regardless of the requested level. + +## Step 2: PR title and description + +Check against the repo's conventions (`CONTRIBUTING.md` mandates Conventional Commits): +- Title follows Conventional Commits: `type(scope): description` (e.g., `feat: support for DECIMAL type`, `fix: array null handling`) +- Description speaks to end-user impact, not just implementation internals +- If fixing an issue, `Fixes #NNN` (or a link to the issue) is present +- Tone is level-headed and analytical, no superlatives +- For public API changes (a new/changed method on `Sender`/`SenderBuffer`, a new/renamed/removed configuration option, a changed default, a new export in `src/index.ts`), the description calls out the API change explicitly, and `README.md` / the TSDoc / any relevant `docs` are updated +- For a new configuration option, the description states the option name, its default, and whether it deprecates an existing one (which must be wired through `SenderOptions.resolveDeprecated`) + +## Step 2.5: Map the change surface + +Before launching review agents, produce a structured change surface map. This step is mandatory and must use Grep/Glob — do not reason about callsites from memory. The output of this step is required input for every Step 3 agent except Agent 10 (the fresh-context adversarial agent, which deliberately works from the diff alone). + +### 2.5a Semantic delta per changed symbol + +For every modified or added function, method, class, `abstract`/`protected` member, exported constant, type/interface, or configuration option, write: + +- **Symbol:** fully-qualified name (e.g., `SenderBufferBase.writeColumn`, `SenderBufferV2.arrayColumn`, `Sender.flush`, `SenderOptions.resolveAuto`, the `protocol_version` option) +- **Before:** signature, return type (and **sync vs `async`/`Promise`** — a function that becomes `async` changes every caller's awaiting), what it throws and on which inputs, which buffer state it mutates (`hasTable`/`hasSymbols`/`hasColumns`/`position`/`endOfLastRow`), allocation behavior, which protocol versions it applies to (v1/v2/v3), the exact bytes it writes to the wire +- **After:** same fields +- **Delta:** one line stating what semantically changed + +"Refactored", "cleaned up", "improved", "simplified" are not acceptable deltas. State the actual behavioral difference. If nothing semantically changed, write "no behavioral change" — but only after checking, not as a default. + +### 2.5b Callsite inventory + +For every changed symbol that is exported from `src/index.ts`, a `public`/`protected`/`abstract` member of a base class, a shared helper in `src/utils.ts`/`src/validation.ts`, a transport-interface method, or a configuration option name, run Grep across the repository to find every callsite, override, or reference outside the diff. + +Produce a list grouped by file. Search at minimum: + +- **Source:** `grep -rn 'symbolName' src/` +- **Public API surface:** `grep -rn 'symbolName' src/index.ts` (is it exported? is the export still consistent?) +- **Buffer subclasses:** for a changed `SenderBufferBase` member, check `src/buffer/bufferv1.ts`, `bufferv2.ts`, `bufferv3.ts` for overrides and callers — a base-class change silently reaches all three protocol versions +- **Transport implementations:** for a changed transport-interface method, check `src/transport/http/base.ts`, `http/undici.ts`, `http/stdlib.ts`, `tcp.ts` — all four protocols implement the same contract +- **Configuration options:** for a changed/added option name, `grep -rn 'option_name' src/options.ts src/ test/` and confirm it is parsed, validated, defaulted, and (if it replaces one) handled by `resolveDeprecated` +- **Tests:** `grep -rn 'symbolName' test/` +- **README & examples:** `grep -rn 'symbolName' README.md` + +A changed exported/`protected`/helper symbol with zero recorded Grep calls in the trace is a skill violation. The model is not allowed to assert "this is only used here" without showing the search. + +### 2.5c Implicit contract list + +For each changed symbol, walk this checklist and write one line per item, stating before vs after: + +- **Throws-what:** which inputs cause a thrown `Error`, and which callers catch vs propagate. The public builder methods (`table`/`symbol`/`*Column`/`at`/`atNow`) throw synchronously; changing what throws changes caller error handling. +- **`null`/`undefined` handling:** does the symbol accept, reject, or silently omit `null`/`undefined`? (An omitted column must not write a separator — see the row state machine.) Note that `strictNullChecks` is **off** (see 2.5e), so the type signature does not enforce non-nullability — every parameter is nullable at runtime. +- **Buffer capacity contract:** does the code reserve via `checkCapacity(data, base)` exactly the bytes the subsequent `write*` calls emit? State the reserved count vs the actual bytes for the changed path. +- **Buffer state machine:** does it read or transition `hasTable`/`hasSymbols`/`hasColumns`/`endOfLastRow`/`position`? Does a row that ends up empty still get closed by `at`/`atNow` (which throw on an empty row)? +- **Sync/async:** does it return a value or a `Promise`? Is every caller awaiting it? Did it change between the two? +- **Wire-format bytes:** any change to the ILP bytes produced — column separators, escaping, entity-type/column-type marker bytes (v2/v3), little-endian encoding, array dimension headers, timestamp units (v1 truncates ns→us; v2+ preserves ns), two's-complement decimal payload. +- **Protocol-version applicability:** does the change apply to v1, v2, v3, or all? Is a v2/v3-only feature guarded so v1 rejects it cleanly? +- **Transport contract:** connection lifecycle (`connect`/`send`/`close`), auto-flush row-count default (`getDefaultAutoFlushRows`), retry/idempotency, credential handling, TLS. +- **Number precision:** does a `number` carry a value that needs `bigint` (LONG beyond `2^53`, nanosecond timestamps)? `Number.isInteger` accepts imprecise large integers. +- **Configuration/deprecation:** did an option name, default, or validation rule change, and is `resolveDeprecated`/`resolveAuto`/the parser updated in lockstep? + +### 2.5d Cross-context exposure list + +End this step with an explicit list of "places this change is visible from but the diff does not touch". This is the highest-priority input for the bug-hunting agents in Step 3. + +Group the callsites from 2.5b by execution context. Typical contexts in this codebase: + +- **Per-row buffer-build hot path:** `SenderBufferBase.table`/`symbol`/`stringColumn`/`booleanColumn`/`intColumn`/`timestampColumn`/`writeColumn`/`writeEscaped`/`checkCapacity`, and the v1/v2/v3 `floatColumn`/`arrayColumn`/`decimalColumn` overrides +- **Protocol-version fan-out:** every `SenderBufferBase` member is inherited by `SenderBufferV1`/`V2`/`V3`; `createBuffer` selects the implementation by `protocol_version` +- **Transport fan-out:** every transport-interface method is implemented by `UndiciTransport`, `HttpTransport` (stdlib), and `TcpTransport`; `createTransport` selects by protocol +- **Flush & auto-flush path:** `Sender.flush`/`at`/`atNow`/`tryFlush`/`resetAutoFlush`, and `buffer.toBufferNew` (which compacts/discards on read) +- **Protocol negotiation:** `SenderOptions.resolveAuto` (HTTP round-trip at setup) and `resolveDeprecated` +- **Config parsing:** the connection-string parser in `src/options.ts`, `Sender.fromConfig`/`fromEnv` +- **Auth & TLS:** HTTP Basic (`username`/`password`) / Bearer (`token`) → `Authorization` header; TCP JWK challenge-response; `tls_verify`/`tls_ca`/`tls_roots` +- **Worker-thread usage:** each worker needs its own `Sender` (shared buffer state is not concurrency-safe — see `README.md` worker-threads example) +- **Public API / type surface:** `src/index.ts` exports and the emitted `.d.ts` +- **Tests:** unit (`test/sender.buffer.test.ts`, `sender.config.test.ts`, `sender.transport.test.ts`, `options.test.ts`, `utils.decimal.test.ts`, `logging.test.ts`), integration (`test/sender.integration.test.ts`, TestContainers), and mock helpers (`test/util/mockhttp.ts`, `mockproxy.ts`, `proxy.ts`) +- **Docs & examples:** `README.md` + +Every entry on this list must be reviewed in Step 3. + +### 2.5e Build & runtime profile facts + +**This sub-step runs at every level, including levels 0 and 1 where the rest of Step 2.5 is skipped.** A single tsconfig flag, a Node/Undici version floor, or the ESM/CJS dual build can flip the safety story for the whole client; agents must reason from the actual profile, not from defaults. + +Record, with file:line citations: + +- **TypeScript strictness** (`tsconfig.json`): note whether `strict`/`strictNullChecks`/`noImplicitAny`/`noUncheckedIndexedAccess` are set. As of this writing **none are** — `strictNullChecks` is **off**, so the compiler does **not** flag `null`/`undefined` flowing into a non-nullable parameter, and does not flag possibly-`undefined` array/index access. Agents must treat every value as potentially `null`/`undefined` at runtime regardless of its declared type, and must not assume the type checker caught a nullability or index bug. (This is the reason a `null` array reaching `arrayColumn` compiles cleanly.) +- **`Buffer.write` / `writeInt*` semantics:** `buffer.write(str, pos)` writes only up to the allocation boundary and returns the actual byte count, so a short write **silently truncates** and mis-advances `position` (corrupt wire data, no throw). `buffer.writeInt8`/`writeInt32LE`/`writeDoubleLE` **throw `RangeError`** when `pos` is past the end. Therefore an incorrect `checkCapacity` reservation is a silent-corruption *or* crash surface, not a guarded no-op. `writeInt8` also throws for values outside `-128..127` — a marker byte in `128..255` must be sign-folded (see `bufferv3` `byte -= 256`). +- **Node version floor:** the client requires **Node v20+** (built-in `fetch`/Undici, `worker_threads`), and `@types/node` is `^22`. Code using a Node API newer than the v20 floor breaks the oldest supported runtime — state the floor. +- **`undici` dependency** (`^7`): the default HTTP transport is Undici; `stdlib_http=on` switches to Node's `http`/`https`. Behavior must match across both implementations. Note the pinned major. +- **Dual ESM + CJS build** (`bunchee`, `package.json` `exports`): both `dist/es` (`.mjs`) and `dist/cjs` (`.js`) are shipped. A construct that only works in one module system (`__dirname`/`require` in ESM, top-level `await` in CJS) breaks a supported consumer. +- **Protocol-version default is `auto`:** for HTTP, `resolveAuto` negotiates the version with the server at setup; for TCP the version must be set explicitly. `createBuffer` builds the serializer for the resolved version — a serializer/version mismatch corrupts the wire. + +A review without this section is incomplete. State the relevant facts (strictness, `Buffer` semantics, Node floor, protocol default) in one line at the top of every Step 3 agent prompt (except Agent 10's, which works from the diff alone) so the agent reasons from the right premise. + +## Step 3: Parallel review + +Every agent except Agent 10 receives: +1. The PR diff +2. The full change surface map from Step 2.5 (semantic deltas, callsite inventory, implicit contracts, cross-context exposure list, build & runtime profile facts) + +### Anti-anchoring directive (applies to all agents) + +- **Bugs at callsites outside the diff outrank bugs inside the diff.** A confirmed bug in a file the PR did not touch but that calls a changed symbol is a P0 finding. +- **"Looks correct in isolation" is not a valid conclusion.** Before clearing a changed symbol, the agent must walk the callsite inventory from 2.5b and explicitly state, per callsite, whether the new behavior is still correct there. +- **The diff is the entry point, not the scope.** If the change surface map shows the symbol is reachable from N other files, the review covers N+1 files. +- **Base classes and factories fan out.** A change to a `SenderBufferBase` member retroactively changes v1/v2/v3; a change to a transport-interface method changes all four protocols; a change to a config option changes `resolveDeprecated`/`resolveAuto` and the parser. When a base member, interface method, or option appears in the diff, the review covers the whole fan-out, not just the touched lines. +- A single finding of the form "in `bufferv2.ts` the new behavior of `writeColumn` writes the wrong separator when the previous column was omitted" is worth more than five findings inside the diff. + +### Agents + +Launch the following agents in parallel. + +**Agent 1 — Correctness & bugs:** `null`/`undefined`/omitted-column handling; `number` vs `bigint` (LONG beyond `2^53` silently loses precision even though `Number.isInteger` returns true; nanosecond timestamps require `bigint`); `Number.isInteger`/type-guard correctness; timestamp unit conversion (`timestampToMicros`/`timestampToNanos`, the v1-always-micros vs v2+-nanos rule); `NaN`/`Infinity` floats; ILP wire-format correctness across v1 (text), v2 (binary doubles + arrays), v3 (decimals); the column separator (leading space for the first column/symbol, `,` thereafter) staying correct when a column is omitted; `writeEscaped` covering every delimiter (space, `,`, `=`, `\n`, `\r`, `"`, `\`) in both quoted and unquoted modes; array validation (`getDimensions`/`validateArray` — irregular shape, non-homogeneous elements, empty arrays with a `null` element type); off-by-one and operator precedence. Cross-reference every changed symbol against its callsite inventory and verify the new behavior is correct at each callsite. + +**Agent 2 — Buffer & byte-encoding safety:** This is the memory-safety analog for a byte-buffer serializer — a mis-encoded or truncated buffer is silent data corruption on the wire. State the `Buffer.write`/`writeInt*` facts from 2.5e in the agent's first sentence and evaluate every finding under them. Flag every reachable instance of: + +- **Capacity under-reservation:** every `write`/`writeByte`/`writeInt`/`writeDouble` must be covered by a preceding `checkCapacity(data, base)` whose `base` (raw bytes) plus the UTF-8 byte length of each string in `data` is **≥** the bytes actually emitted. An under-count causes a silent short write (corrupt/misaligned line) or a `RangeError`. Watch for escaping that expands a string (`\` doubling, delimiter escaping) beyond the reserved length, and for a type-suffix byte (`i` for int, `t`/`f` for boolean, marker bytes for v2/v3) not counted in `base`. +- **`writeByte` range:** `writeInt8` throws outside `-128..127`; a marker/entity/column-type byte in `128..255` must be sign-folded before `writeByte` (`byte -= 256`), not passed raw. +- **Little-endian encoding:** doubles (`writeDoubleLE`), int32 (`writeInt32LE`), and array dimension headers must be written in the byte order and width the server expects for v2/v3. +- **Buffer view vs copy:** `toBufferView` returns a `subarray` that **aliases** the live buffer — it becomes stale or corrupt after any further write and is test-only; `toBufferNew` returns a copy and **compacts** the source. A caller that holds a view across a mutation, or that expects `toBufferNew` not to mutate, is a bug. +- **Compaction & resize:** `compact()` does an overlapping self-copy (`buffer.copy(buffer, 0, endOfLastRow, position)`) — verify the ranges. `resize()` doubles until it fits, enforces `max_buf_size`, and copies old content — verify the growth loop terminates and the `max_buf_size` guard is not bypassed. +- **Two's-complement / big-endian decimal payloads** (`bigintToTwosComplementBytes`, v3): minimal-width sign-preserving encoding, scale/length bounds (`0..32` bytes, scale `0..76`), invalid-byte rejection. +- **Position accounting:** `position` must advance by exactly the bytes written; `write` relies on `buffer.write`'s return value, `writeByte`/`writeInt`/`writeDouble` on the `writeInt*` return. A path that advances `position` by an assumed rather than actual count corrupts everything after it. + +**Agent 3 — Transport, protocol negotiation & auth:** Check every network-facing path. Verify: +- **Protocol negotiation:** `resolveAuto` picks a version the server supports; `createBuffer` builds the matching serializer; TCP (which cannot negotiate) requires an explicit `protocol_version`. A serializer/version mismatch corrupts the wire. +- **HTTP retry & idempotency:** which status codes/errors are retriable; whether `retry_timeout` and backoff are honored; and — critically — whether re-sending the same buffer after an **uncertain** send (server received it but the response was lost) can **duplicate rows**. Confirm retries are confined to cases where the server has not durably accepted the data. +- **Undici vs stdlib parity:** `UndiciTransport` and `HttpTransport` must apply the same auth, TLS, timeout, and retry behavior — flag any divergence. +- **Auth:** HTTP Basic (`username`/`password`) and Bearer (`token`) build the `Authorization` header correctly; TCP JWK challenge-response signs correctly. **Credentials must never appear in log output, error messages, or thrown `Error` strings.** +- **TLS:** `tls_verify`/`tls_ca`/`tls_roots` wired correctly; verification is only disabled when explicitly requested; custom CA/roots actually applied. +- **Timeouts & lifecycle:** `request_timeout`/`retry_timeout` enforced; `connect`/`close` are only called on TCP transports (HTTP transports must no-op or reject per the interface docs). + +**Agent 4 — Async, concurrency & flush semantics:** Verify: +- **Every Promise is awaited.** A missing `await` on `flush`/`send`/`connect`/`tryFlush` yields an unhandled rejection, out-of-order sends, or a lost error. The builder `at`/`atNow` are `async` (they may auto-flush) — callers must await them. +- **Flush data-loss window:** `Sender.flush` calls `buffer.toBufferNew()` which **compacts (discards) the rows before** `await transport.send(...)`. If the send rejects, those rows are already gone and are **not re-queued**. Confirm the change does not widen this window or drop data on a new error path; flag if a fix is expected to preserve data on failure but doesn't. +- **Auto-flush semantics:** both the row-count (`auto_flush_rows`) and interval (`auto_flush_interval`) triggers are evaluated **lazily inside `tryFlush`** on each `at`/`atNow`. There is **no background timer** — a producer that stops adding rows never auto-flushes on the interval alone. Verify any change respects this (and does not, e.g., assume a timer fires). +- **Concurrency:** the `Sender`/`SenderBuffer` hold mutable buffer state and are **not** safe for concurrent row building; each worker thread needs its own `Sender` (per `README.md`). Flag any change that invites shared use or interleaves buffer mutation across awaits within one Sender. + +**Agent 5 — Resource management & lifecycle:** Leaks and dangling handles on all code paths (especially errors). Check: +- **Transport teardown:** `close()` releases the TCP socket / the Undici pool/agent. A Sender-owned agent must be destroyed on close; a **user-supplied `agent`** (passed via `extraOptions`) must **not** be destroyed by the Sender. +- **Timers & aborts:** `fetchJson` pairs `setTimeout`/`AbortController` with `clearTimeout` in a `finally` — verify any new async network helper does the same and cannot leak a timer or an un-aborted request. +- **Error-path cleanup:** a failed `connect`/`send`/TLS handshake must not leave a half-open socket, an un-freed pool, or a listener attached. +- **Buffer lifecycle:** the internal `Buffer` is reused across rows; verify `reset`/`compact` leave it in a consistent state and nothing retains a stale `subarray` view. + +Walk every callsite from 2.5b that constructs, owns, or transfers a transport/socket/agent and verify cleanup on success, error, and early-return paths. + +**Agent 6 — Performance & allocations:** The hot path is the per-row buffer build (`table`/`symbol`/`*Column`/`at`/`atNow`) and, for wide rows, the per-cell inner work. Flag: per-row/per-cell allocations that should be amortized; `value.toString()` churn; string concatenation on the write path; repeated `Buffer.byteLength` re-scans of the same string; per-character `buffer.write` in `writeEscaped` where a bulk path exists; buffer `resize` thrashing (the doubling strategy repeatedly copying a large buffer); needless `Buffer` copies. Analyze scaling: millions of rows per flush, wide rows, large arrays. Setup-path costs (Sender construction, `resolveAuto`'s HTTP round-trip, config parsing) are acceptable; per-row/per-cell costs are not. + +**Agent 7 — Test review & coverage (adversarial):** Coverage gaps *and* test efficacy. Check: +- **Coverage** across the matrix: protocol versions (v1/v2/v3), transports (Undici HTTP, stdlib HTTP, TCP/TCPS), auth methods (Basic/Bearer/JWK), TLS, auto-flush (row-count and interval), buffer resize and `max_buf_size`, escaping, `null`/`undefined`, empty arrays, `bigint`/`number`, `NaN`/`Infinity`, timestamp units, retry/error paths. +- **Test files:** unit (`test/sender.buffer.test.ts`, `sender.config.test.ts`, `sender.transport.test.ts`, `options.test.ts`, `utils.decimal.test.ts`, `logging.test.ts`), integration against a real QuestDB via TestContainers (`test/sender.integration.test.ts`), and mock helpers (`test/util/mockhttp.ts`, `mockproxy.ts`, `proxy.ts`). +- **Byte-level assertions:** buffer tests assert exact bytes via the `bufferContentHex`/`toHex` helpers. Verify the expected hex actually encodes the intended wire bytes (separators, escaping, marker bytes, little-endian payloads) — a test that asserts stale or hand-mis-computed bytes locks in a bug. +- **Efficacy:** flag assertions that cannot fail, tests whose assertion passes whether or not the production change is present (trace the data flow from the changed symbol to the assertion), and happy-path-only tests with no error/`null`/edge coverage the change introduced. +- **Regression tests:** if the PR fixes a bug, a test must reproduce it and fail without the fix. + +Cross-reference 2.5d: every cross-context exposure should have a test that exercises the changed symbol from that context. A new wire-format path without a byte-level assertion, or a new transport/auth path without a transport test, is a high-priority finding. + +**Agent 8 — Code quality & API design:** Public API ergonomics and consistency. The public surface is what `src/index.ts` re-exports — a new public symbol must be exported there, and a removed/renamed one is a breaking change. Verify TSDoc on public classes/methods (the repo uses `@microsoft/tsdoc`); TypeScript types are accurate and not laundered through unsound casts (`as unknown as ...`) that hide real type errors (recall `strictNullChecks` is off, so casts and non-null assumptions are not caught by the compiler); backward compatibility of the `Sender`/`SenderBuffer`/`SenderOptions` API (renamed/removed methods, changed defaults, renamed options must go through `resolveDeprecated` with a warning); `README.md`/`docs` updated for user-visible changes; no dead code or unused `import`s; ESLint (`typescript-eslint` recommended set) and Prettier (`.prettierrc`) clean; naming and member ordering consistent with the surrounding code. + +**Agent 9 — Cross-context caller impact:** Walk the callsite inventory from 2.5b. For every callsite, fetch the surrounding code (the calling function plus its callers up two levels) and answer: + +- Does this caller pass inputs the new behavior handles incorrectly (`null`/`undefined`, `bigint` vs `number`, an empty array, a delimiter-containing string)? +- Does this caller depend on a contract from the implicit contract list (2.5c) that the change broke — the old capacity reservation, the old buffer state-machine transition, the old sync/async shape, the old set of thrown errors, the old wire bytes? +- Is this caller in a context (the per-row hot path, a v1/v2/v3 subclass, one of the four transports, the flush/auto-flush path, an error/retry path, a worker thread) where the new behavior misbehaves even if the inputs are valid? +- For a changed `SenderBufferBase` member: do the `SenderBufferV1`/`V2`/`V3` overrides and inherited callers still satisfy the new contract? +- For a changed transport-interface method: do `UndiciTransport`, `HttpTransport`, and `TcpTransport` all still satisfy it? +- For a changed config option: do `resolveAuto`, `resolveDeprecated`, the parser, and every reader agree on name/default/validation? + +This agent's output is structured per callsite, not per failure mode. Each callsite gets a verdict: SAFE / BROKEN / NEEDS VERIFICATION. Every BROKEN entry is a P0 finding regardless of whether the file is in the diff. + +This agent is not optional even when the diff is small. Small diffs to widely-used symbols (`writeColumn`, `checkCapacity`, `Sender.flush`, a transport method, a base-class member) have the largest blast radius. + +**Agent 10 — Fresh-context adversarial:** Dispatched separately from agents 1-9 to escape checklist anchoring. This agent operates under different rules from the rest: + +- It receives ONLY the PR diff and the names of the changed files. It does NOT receive the change surface map from Step 2.5, the implicit contract list, the cross-context exposure list, or any of the review checklists below. +- Its sole instruction: "find ways this code is wrong". No category list, no failure-mode taxonomy, no project-specific style guide. +- It is free to use Read, Grep, and Glob to explore the repository however it wants. +- Findings are not pre-classified by category. Each finding states: what's wrong, why it's wrong, and the code path that demonstrates it. + +The point of this agent is to surface bugs the structured agents cannot see because they are reasoning inside the same frame. A finding here that none of agents 1-9 produced is high signal — it means the structured review missed it. A finding here that overlaps with agents 1-9 is corroboration. + +Run this agent in parallel with agents 1-9. It is mandatory regardless of diff size. + +Combine all agent findings into a single deduplicated **draft** report. Do NOT present this draft to the user yet — it goes straight into verification. + +## Step 3b: Verify every finding against source code + +The parallel review agents work from the diff plus the change surface map and frequently produce false positives — especially around buffer capacity math, the row state machine, protocol-version fan-out, async/await, and retry idempotency. Every finding MUST be verified before it is reported. + +For each finding in the draft report: + +1. **Read the actual source code** at the exact lines cited (in `src/**/*.ts`, never the generated `dist/**` output). Do not rely on the agent's description alone. +2. **Trace the full code path:** follow callers and overrides. Remember the inheritance fan-out — a method called on a `SenderBuffer` reference may dispatch to `SenderBufferV1`/`V2`/`V3`; a transport call dispatches to Undici/stdlib/TCP. +3. **For capacity/byte-encoding claims:** count the bytes actually written against the `checkCapacity(data, base)` reservation, accounting for UTF-8 multi-byte expansion and escaping. Confirm the direction of the error (under-reservation corrupts/throws; over-reservation is harmless). A claim that the reservation is wrong is a false positive if the arithmetic actually covers the writes. +4. **For `null`/`undefined` claims:** since `strictNullChecks` is off, verify at the *runtime* level — trace whether a caller can actually pass the nullish value and what the code does with it, not what the type says. +5. **For wire-format claims:** reconstruct the expected byte sequence for the relevant protocol version and compare against what the code emits and what the byte-level test asserts. +6. **For flush/data-loss and async claims:** re-read `Sender.flush`/`tryFlush` and confirm the ordering of `toBufferNew` (compaction) vs `await transport.send`, and whether the claimed loss/duplication is reachable on the cited path. +7. **For retry/idempotency claims:** trace which errors/status codes trigger a resend and whether the server could have durably accepted the data before the resend — only a resend after durable acceptance duplicates rows. +8. **For resource-leak claims:** trace every socket/agent/timer to its close/clear on all paths (success, error, early return), and confirm a user-supplied `agent` is *not* destroyed by the Sender. +9. **For performance claims:** confirm the cost is on the per-row/per-cell hot path and material relative to the surrounding work/I-O. Downgrade negligible savings to a nit. Exception: a per-row allocation on the buffer-build path is always worth flagging. +10. **For cross-context findings (Agent 9):** re-read the callsite in full, including callers up two levels, and confirm the broken behavior is reachable from production or from tests users will exercise. +11. **For test-efficacy findings (Agent 7):** re-read the cited assertion in full context and confirm it truly cannot fail or truly fails to reach the change — a "vacuous assertion" claim is a false positive if the production code actually recomputes the asserted value; a "wrong hex" claim requires reconstructing the correct bytes. + +**Classify each finding** as: +- **CONFIRMED in-diff** — the bug is real and inside the diff +- **CONFIRMED at out-of-diff callsite** — the bug is in an unchanged file because the changed symbol is used there in a way that's now broken (cite the file and the contract from 2.5c that was violated) +- **FALSE POSITIVE** — the code is actually correct (explain why) +- **CONFIRMED with nuance** — the issue exists but is less severe than stated (explain) + +**Move false positives to a separate "Downgraded" section** at the end of the report. For each, give a one-line explanation of why it was dismissed. This lets the PR author verify the reasoning and catch verification mistakes. + +Launch verification agents in parallel where findings are independent. Each verification agent should read surrounding source files, not just the diff. + +## Review checklists + +Review the diff for: + +### Correctness & bugs +- `null`/`undefined`/omitted-column handling at API boundaries (and remember `strictNullChecks` is off — the compiler didn't check it) +- Edge cases and error paths +- `number` vs `bigint`: LONG values beyond `2^53` silently lose precision though `Number.isInteger` returns true; nanosecond timestamps require `bigint` +- Float edge cases (`NaN`, `Infinity`); timestamp unit conversions (v1 truncates ns→us; v2+ preserves ns) +- Correct ILP wire format (v1 text / v2 binary / v3 decimals): column separators, escaping, little-endian payloads, array headers, marker bytes +- Array validation: irregular shape, non-homogeneous elements, empty arrays (element type `null`), `null`/`undefined` arrays omitted (not written as a NULL marker) +- Logic errors, off-by-one, wrong operator precedence + +### Buffer & byte-encoding safety +- Every `write*` covered by a `checkCapacity` that reserves ≥ the bytes emitted (account for escaping expansion and the type-suffix/marker byte) +- `writeByte`/`writeInt8` values within `-128..127` (sign-fold `128..255`) +- Little-endian doubles/int32/dimension headers match the server's expectation +- `toBufferView` (aliasing, test-only) not held across a mutation; `toBufferNew` (copy + compact) callers aware it mutates the source +- `compact()` overlapping self-copy ranges correct; `resize()` growth terminates and respects `max_buf_size` +- Two's-complement/big-endian decimal payloads and their bounds (unscaled `0..32` bytes, scale `0..76`) +- `position` advanced by the actual bytes written, never an assumed count + +### Transport, protocol & auth +- Serializer matches the negotiated protocol version; TCP has an explicit version +- Retriable vs non-retriable classification correct; a retry after uncertain acceptance cannot duplicate rows +- Undici and stdlib HTTP transports behave identically (auth, TLS, timeouts, retry) +- Auth headers/JWK signing correct; credentials never logged, thrown, or otherwise leaked +- TLS verification only disabled when explicitly requested; custom CA/roots applied +- `request_timeout`/`retry_timeout` enforced; `connect`/`close` only meaningful on TCP + +### Async, concurrency & resources +- Every Promise awaited; `at`/`atNow` awaited by callers; no unhandled rejection +- Flush ordering understood: rows are compacted out of the buffer before the awaited send, so a send failure loses them unless explicitly handled +- Auto-flush is lazy (no background timer); the interval only fires on the next `at`/`atNow` +- One `Sender` per worker thread; no shared buffer mutation across awaits +- Sockets/pools/agents/timers released on all paths; a user-supplied `agent` is not destroyed by the Sender + +### Performance +- No per-row/per-cell allocations, `toString` churn, string concatenation, or repeated `Buffer.byteLength` scans on the buffer-build path that belong hoisted to setup +- No buffer `resize` thrashing or needless `Buffer` copies +- No O(n²) over rows/cells at realistic scale (millions of rows, wide rows, large arrays) +- Setup-path cost (construction, `resolveAuto`, config parsing) acceptable; per-row cost is not + +### Code quality & API design +- New public symbols exported from `src/index.ts`; removed/renamed ones treated as breaking and called out +- TSDoc on public classes/methods; types accurate and not laundered through unsound `as` casts +- Backward compatibility: renamed options wired through `resolveDeprecated` with a warning; changed defaults intentional and documented +- `README.md`/`docs` updated for user-visible changes +- No dead code or unused imports; ESLint and Prettier clean; naming/ordering consistent + +### Test review +- **Coverage gaps:** every new/changed path (per protocol version, transport, auth method) has a test; flag missing ones explicitly as "missing test for X" +- **Cross-context coverage:** every entry in 2.5d has a test exercising the changed symbol from that context — especially a new wire-format path (byte-level assertion) or a new transport/auth path (transport/integration test) +- **Byte-level assertions** (`bufferContentHex`/`toHex`) encode the intended wire bytes, not stale/hand-mis-computed ones +- **Error-path coverage:** connection drops, 5xx, retries, TLS/auth failures, buffer overflow vs `max_buf_size`, invalid inputs — not just the happy path +- **Edge-case tests:** `null`/`undefined`, empty and irregular arrays, zero-length and delimiter-containing strings, boundary integers, `bigint`, `NaN`/`Infinity`, each timestamp unit +- **Efficacy:** assertions can actually fail and actually reach the changed code; no happy-path-only gaps +- **Regression tests:** a bug fix has a test that reproduces the bug and fails without the fix + +### Unresolved TODOs and FIXMEs +- Scan the diff for `TODO`, `FIXME`, `HACK`, `XXX`, `WORKAROUND`. For each: + - Pre-existing (just moved/reformatted) or newly introduced in this PR? + - If new: unfinished work that should block merge, or an acceptable known limitation? Flag deferred bugs or incomplete implementations. + - If it references a ticket/issue, verify the reference exists. + +### Commit messages +- Conventional Commits `type(scope): description` (per `CONTRIBUTING.md`) +- Clear, descriptive; end-user impact in the body where relevant + +## Step 4: Output + +Present ONLY verified findings (false positives are excluded from Critical/Moderate/Minor). Structure as: + +### Critical +Issues that must be fixed before merge. Each must include: +- Exact file path and line numbers (including out-of-diff files) +- Whether the finding is **in-diff** or **out-of-diff** +- Code path trace showing why the bug is real +- For out-of-diff findings: the contract from 2.5c that was violated and the callsite that triggers it +- Suggested fix + +### Moderate +Issues worth addressing but not blocking. + +### Minor +Style nits and suggestions. + +### Downgraded (false positives) +Findings from the initial review that were dismissed after source code verification. For each, state: +- The original claim (one line) +- Why it was dismissed (one line, citing the specific code that disproves it) + +### Summary +- One-line verdict: approve, request changes, or needs discussion +- Highlight any regressions or tradeoffs +- State how many draft findings were verified vs dropped as false positives (e.g., "8 findings verified, 4 false positives removed") +- State the in-diff vs out-of-diff split (e.g., "5 findings in-diff, 3 findings out-of-diff"). If the diff is non-trivial and out-of-diff is zero, the cross-context pass likely underran — re-invoke Agent 9 with a wider grep before finalizing. From b3fdbaa31f056bee40a465af7b86db122659150d Mon Sep 17 00:00:00 2001 From: glasstiger Date: Mon, 17 Aug 2026 13:33:05 +0100 Subject: [PATCH 002/265] fix: omit nullish column values --- src/buffer/base.ts | 81 ++++++++++++++++++++++----- src/buffer/bufferv1.ts | 22 ++++++-- src/buffer/bufferv2.ts | 31 ++++++----- src/buffer/bufferv3.ts | 15 ++++- src/buffer/index.ts | 39 +++++++------ src/index.ts | 1 + src/sender.ts | 38 +++++++------ test/sender.buffer.test.ts | 109 ++++++++++++++++++++++++++++++++++--- 8 files changed, 258 insertions(+), 78 deletions(-) diff --git a/src/buffer/base.ts b/src/buffer/base.ts index 3fcf6de..46a86ec 100644 --- a/src/buffer/base.ts +++ b/src/buffer/base.ts @@ -152,10 +152,14 @@ abstract class SenderBufferBase implements SenderBuffer { * Use it to insert into SYMBOL columns. * * @param {string} name - Symbol name. - * @param {unknown} value - Symbol value, toString() is called to extract the actual symbol value from the parameter. + * @param {unknown} value - Symbol value, toString() is called to extract the actual symbol value from the parameter. A null or undefined value omits the symbol entirely (stored as NULL). * @return {SenderBuffer} Returns with a reference to this buffer. */ symbol(name: string, value: unknown): SenderBuffer { + // A null or undefined value omits the symbol entirely (see issue #28). + if (this.isNullOrUndefined(value)) { + return this; + } if (typeof name !== "string") { throw new Error(`Symbol name must be a string, received ${typeof name}`); } @@ -180,10 +184,14 @@ abstract class SenderBufferBase implements SenderBuffer { * Use it to insert into VARCHAR and STRING columns. * * @param {string} name - Column name. - * @param {string} value - Column value, accepts only string values. + * @param {string | null | undefined} value - Column value, accepts only string values. A null or undefined value omits the column entirely (stored as NULL). * @return {SenderBuffer} Returns with a reference to this buffer. */ - stringColumn(name: string, value: string): SenderBuffer { + stringColumn(name: string, value: string | null | undefined): SenderBuffer { + // A null or undefined value omits the column entirely (see issue #28). + if (this.isNullOrUndefined(value)) { + return this; + } this.writeColumn( name, value, @@ -203,10 +211,14 @@ abstract class SenderBufferBase implements SenderBuffer { * Use it to insert into BOOLEAN columns. * * @param {string} name - Column name. - * @param {boolean} value - Column value, accepts only boolean values. + * @param {boolean | null | undefined} value - Column value, accepts only boolean values. A null or undefined value omits the column entirely (stored as NULL). * @return {SenderBuffer} Returns with a reference to this buffer. */ - booleanColumn(name: string, value: boolean): SenderBuffer { + booleanColumn(name: string, value: boolean | null | undefined): SenderBuffer { + // A null or undefined value omits the column entirely (see issue #28). + if (this.isNullOrUndefined(value)) { + return this; + } this.writeColumn( name, value, @@ -224,34 +236,44 @@ abstract class SenderBufferBase implements SenderBuffer { * Use it to insert into DOUBLE or FLOAT database columns. * * @param {string} name - Column name. - * @param {number} value - Column value, accepts only number values. + * @param {number | null | undefined} value - Column value, accepts only number values. A null or undefined value omits the column entirely (stored as NULL). * @return {SenderBuffer} Returns with a reference to this buffer. */ - abstract floatColumn(name: string, value: number): SenderBuffer; + abstract floatColumn( + name: string, + value: number | null | undefined, + ): SenderBuffer; /** * Writes an array column with its values into the buffer. * * @param {string} name - Column name - * @param {unknown[]} value - Array values to write (currently supports double arrays) + * @param {unknown[] | null | undefined} value - Array values to write (currently supports double arrays). A null or undefined value omits the column entirely, storing NULL. * @returns {SenderBuffer} Returns with a reference to this buffer. * @throws Error if arrays are not supported by the buffer implementation, or array validation fails: * - value is not an array * - or the shape of the array is irregular: the length of sub-arrays are different * - or the array is not homogeneous: its elements are not all the same type */ - abstract arrayColumn(name: string, value: unknown[]): SenderBuffer; + abstract arrayColumn( + name: string, + value: unknown[] | null | undefined, + ): SenderBuffer; /** * Writes a 64-bit signed integer into the buffer.
* Use it to insert into LONG, INT, SHORT and BYTE columns. * * @param {string} name - Column name. - * @param {number} value - Column value, accepts only number values. + * @param {number | null | undefined} value - Column value, accepts only number values. A null or undefined value omits the column entirely (stored as NULL). * @return {SenderBuffer} Returns with a reference to this buffer. * @throws Error if the value is not an integer */ - intColumn(name: string, value: number): SenderBuffer { + intColumn(name: string, value: number | null | undefined): SenderBuffer { + // A null or undefined value omits the column entirely (see issue #28). + if (this.isNullOrUndefined(value)) { + return this; + } if (!Number.isInteger(value)) { throw new Error(`Value must be an integer, received ${value}`); } @@ -283,7 +305,7 @@ abstract class SenderBufferBase implements SenderBuffer { * Always uses microsecond precision, even if the timestamp is specified in nanoseconds. * * @param {string} name - The column name. - * @param {number | bigint} value - The epoch timestamp. Must be an integer or a `BigInt`. + * @param {number | bigint | null | undefined} value - The epoch timestamp. Must be an integer or a `BigInt`. A null or undefined value omits the column entirely (stored as NULL). * @param {'ns' | 'us' | 'ms'} [unit='us'] - The time unit of the timestamp. * Supported values: * - `'ns'` — nanoseconds (requires `BigInt`) @@ -297,9 +319,13 @@ abstract class SenderBufferBase implements SenderBuffer { */ timestampColumn( name: string, - value: number | bigint, + value: number | bigint | null | undefined, unit: TimestampUnit = "us", ): SenderBuffer { + // A null or undefined value omits the column entirely (see issue #28). + if (this.isNullOrUndefined(value)) { + return this; + } if (typeof value !== "bigint" && !Number.isInteger(value)) { throw new Error( `Timestamp value must be an integer or BigInt, received ${value}`, @@ -416,6 +442,20 @@ abstract class SenderBufferBase implements SenderBuffer { } } + /** + * @ignore + * Determines whether a column value is null or undefined.
+ * Such values cause the column (or symbol) to be omitted from the row + * entirely, which QuestDB records as NULL. This mirrors the Python client + * and resolves https://github.com/questdb/nodejs-questdb-client/issues/28 + * + * @param value - The column or symbol value to test. + * @returns True if the value is null or undefined. + */ + protected isNullOrUndefined(value: unknown): value is null | undefined { + return value === null || value === undefined; + } + /** * @ignore * Common logic for writing column data to the buffer. @@ -538,7 +578,14 @@ abstract class SenderBufferBase implements SenderBuffer { * Possible validation errors: * - The provided string is not a valid decimal representation. */ - decimalColumnText(name: string, value: string | number): SenderBuffer { + decimalColumnText( + name: string, + value: string | number | null | undefined, + ): SenderBuffer { + // A null or undefined value omits the column entirely (see issue #28). + if (this.isNullOrUndefined(value)) { + return this; + } throw new Error("Decimals are not supported in protocol v1/v2"); } @@ -563,9 +610,13 @@ abstract class SenderBufferBase implements SenderBuffer { */ decimalColumn( name: string, - unscaled: bigint | Int8Array, + unscaled: bigint | Int8Array | null | undefined, scale: number, ): SenderBuffer { + // A null or undefined value omits the column entirely (see issue #28). + if (this.isNullOrUndefined(unscaled)) { + return this; + } throw new Error("Decimals are not supported in protocol v1/v2"); } /* eslint-enable @typescript-eslint/no-unused-vars */ diff --git a/src/buffer/bufferv1.ts b/src/buffer/bufferv1.ts index 0f54d51..34335a7 100644 --- a/src/buffer/bufferv1.ts +++ b/src/buffer/bufferv1.ts @@ -24,10 +24,14 @@ class SenderBufferV1 extends SenderBufferBase { * Use it to insert into DOUBLE or FLOAT database columns. * * @param {string} name - Column name. - * @param {number} value - Column value, accepts only number values. + * @param {number | null | undefined} value - Column value, accepts only number values. A null or undefined value omits the column entirely (stored as NULL). * @return {Sender} Returns with a reference to this sender. */ - floatColumn(name: string, value: number): SenderBuffer { + floatColumn(name: string, value: number | null | undefined): SenderBuffer { + // A null or undefined value omits the column entirely (see issue #28). + if (this.isNullOrUndefined(value)) { + return this; + } this.writeColumn( name, value, @@ -59,11 +63,21 @@ class SenderBufferV1 extends SenderBufferBase { } /** - * Array columns are not supported in protocol v1. + * Array columns are not supported in protocol v1.
+ * A null or undefined value omits the column entirely (stored as NULL), + * consistent with the other column methods; any actual array throws. * + * @param {string} name - Column name. + * @param {unknown[] | null | undefined} value - Array values. Only null or + * undefined is accepted in v1 (which skips the column). + * @returns {SenderBuffer} Returns with a reference to this buffer. * @throws Error indicating arrays are not supported in v1 */ - arrayColumn(): SenderBuffer { + arrayColumn(name: string, value: unknown[] | null | undefined): SenderBuffer { + // A null or undefined value omits the column entirely (see issue #28). + if (this.isNullOrUndefined(value)) { + return this; + } throw new Error("Arrays are not supported in protocol v1"); } } diff --git a/src/buffer/bufferv2.ts b/src/buffer/bufferv2.ts index b24c176..ec488c2 100644 --- a/src/buffer/bufferv2.ts +++ b/src/buffer/bufferv2.ts @@ -10,9 +10,8 @@ import { validateArray, } from "../utils"; -// Column type constants for protocol v2. +// Column type constant for protocol v2. const COLUMN_TYPE_DOUBLE: number = 10; -const COLUMN_TYPE_NULL: number = 33; // Entity type constants for protocol v2. const ENTITY_TYPE_ARRAY: number = 14; @@ -41,10 +40,14 @@ class SenderBufferV2 extends SenderBufferBase { * Use it to insert into DOUBLE or FLOAT database columns. * * @param {string} name - Column name. - * @param {number} value - Column value, accepts only number values. + * @param {number | null | undefined} value - Column value, accepts only number values. A null or undefined value omits the column entirely (stored as NULL). * @returns {Sender} Returns with a reference to this buffer. */ - floatColumn(name: string, value: number): SenderBuffer { + floatColumn(name: string, value: number | null | undefined): SenderBuffer { + // A null or undefined value omits the column entirely (see issue #28). + if (this.isNullOrUndefined(value)) { + return this; + } this.writeColumn( name, value, @@ -85,17 +88,22 @@ class SenderBufferV2 extends SenderBufferBase { * Write an array column with its values into the buffer using v2 format. * * @param {string} name - Column name - * @param {unknown[]} value - Array values to write (currently supports double arrays) + * @param {unknown[] | null | undefined} value - Array values to write (currently supports double arrays). A null or undefined value omits the column entirely, storing NULL. * @returns {Sender} Returns with a reference to this buffer. * @throws Error if array validation fails: * - value is not an array * - or the shape of the array is irregular: the length of sub-arrays are different * - or the array is not homogeneous: its elements are not all the same type */ - arrayColumn(name: string, value: unknown[]): SenderBuffer { + arrayColumn(name: string, value: unknown[] | null | undefined): SenderBuffer { + // A null or undefined value omits the column entirely (see issue #28). + if (this.isNullOrUndefined(value)) { + return this; + } + const dimensions = getDimensions(value); const type = validateArray(value, dimensions); - // only number arrays and NULL supported for now + // only number arrays supported for now (empty arrays have a null element type) if (type !== "number" && type !== null) { throw new Error(`Unsupported array type [type=${type}]`); } @@ -104,13 +112,8 @@ class SenderBufferV2 extends SenderBufferBase { this.checkCapacity([], 3); this.writeByte(EQUALS_SIGN); this.writeByte(ENTITY_TYPE_ARRAY); - - if (!value) { - this.writeByte(COLUMN_TYPE_NULL); - } else { - this.writeByte(COLUMN_TYPE_DOUBLE); - this.writeArray(value, dimensions, type); - } + this.writeByte(COLUMN_TYPE_DOUBLE); + this.writeArray(value, dimensions, type); }); return this; } diff --git a/src/buffer/bufferv3.ts b/src/buffer/bufferv3.ts index 2d75c14..d84b23a 100644 --- a/src/buffer/bufferv3.ts +++ b/src/buffer/bufferv3.ts @@ -42,7 +42,14 @@ class SenderBufferV3 extends SenderBufferV2 { * Possible validation errors: * - The provided string is not a valid decimal representation. */ - decimalColumnText(name: string, value: string | number): SenderBuffer { + decimalColumnText( + name: string, + value: string | number | null | undefined, + ): SenderBuffer { + // A null or undefined value omits the column entirely (see issue #28). + if (this.isNullOrUndefined(value)) { + return this; + } let str = ""; if (typeof value === "string") { validateDecimalText(value); @@ -81,9 +88,13 @@ class SenderBufferV3 extends SenderBufferV2 { */ decimalColumn( name: string, - unscaled: bigint | Int8Array, + unscaled: bigint | Int8Array | null | undefined, scale: number, ): SenderBuffer { + // A null or undefined value omits the column entirely (see issue #28). + if (this.isNullOrUndefined(unscaled)) { + return this; + } if (scale < 0 || scale > 76) { throw new RangeError("Scale must be between 0 and 76"); } diff --git a/src/buffer/index.ts b/src/buffer/index.ts index 0b61c94..34aa6bb 100644 --- a/src/buffer/index.ts +++ b/src/buffer/index.ts @@ -89,7 +89,7 @@ interface SenderBuffer { * Writes a symbol name and value into the buffer. * Use it to insert into SYMBOL columns. * @param name - Symbol name. - * @param value - Symbol value, toString() is called to extract the actual symbol value from the parameter. + * @param value - Symbol value, toString() is called to extract the actual symbol value from the parameter. A null or undefined value omits the symbol entirely (stored as NULL). * @returns Returns with a reference to this buffer. */ symbol(name: string, value: unknown): SenderBuffer; @@ -98,50 +98,50 @@ interface SenderBuffer { * Writes a string column with its value into the buffer. * Use it to insert into VARCHAR and STRING columns. * @param name - Column name. - * @param value - Column value, accepts only string values. + * @param value - Column value, accepts only string values. A null or undefined value omits the column entirely (stored as NULL). * @returns Returns with a reference to this buffer. */ - stringColumn(name: string, value: string): SenderBuffer; + stringColumn(name: string, value: string | null | undefined): SenderBuffer; /** * Writes a boolean column with its value into the buffer. * Use it to insert into BOOLEAN columns. * @param name - Column name. - * @param value - Column value, accepts only boolean values. + * @param value - Column value, accepts only boolean values. A null or undefined value omits the column entirely (stored as NULL). * @returns Returns with a reference to this buffer. */ - booleanColumn(name: string, value: boolean): SenderBuffer; + booleanColumn(name: string, value: boolean | null | undefined): SenderBuffer; /** * Writes a 64-bit floating point value into the buffer. * Use it to insert into DOUBLE or FLOAT database columns. * @param name - Column name. - * @param value - Column value, accepts only number values. + * @param value - Column value, accepts only number values. A null or undefined value omits the column entirely (stored as NULL). * @returns Returns with a reference to this buffer. */ - floatColumn(name: string, value: number): SenderBuffer; + floatColumn(name: string, value: number | null | undefined): SenderBuffer; /** * Writes an array column with its values into the buffer. * @param name - Column name - * @param value - Array values to write (currently supports double arrays) + * @param value - Array values to write (currently supports double arrays). A null or undefined value omits the column entirely, storing NULL. * @returns Returns with a reference to this buffer. * @throws Error if arrays are not supported by the buffer implementation, or array validation fails: * - value is not an array * - or the shape of the array is irregular: the length of sub-arrays are different * - or the array is not homogeneous: its elements are not all the same type */ - arrayColumn(name: string, value: unknown[]): SenderBuffer; + arrayColumn(name: string, value: unknown[] | null | undefined): SenderBuffer; /** * Writes a 64-bit signed integer into the buffer. * Use it to insert into LONG, INT, SHORT and BYTE columns. * @param name - Column name. - * @param value - Column value, accepts only number values. + * @param value - Column value, accepts only number values. A null or undefined value omits the column entirely (stored as NULL). * @returns Returns with a reference to this buffer. * @throws Error if the value is not an integer */ - intColumn(name: string, value: number): SenderBuffer; + intColumn(name: string, value: number | null | undefined): SenderBuffer; /** * Writes a timestamp column and its value into the buffer. @@ -156,7 +156,7 @@ interface SenderBuffer { * Always uses microsecond precision, even if the timestamp is specified in nanoseconds. * * @param {string} name - The column name. - * @param {number | bigint} value - The epoch timestamp. Must be an integer or a `BigInt`. + * @param {number | bigint | null | undefined} value - The epoch timestamp. Must be an integer or a `BigInt`. A null or undefined value omits the column entirely (stored as NULL). * @param {'ns' | 'us' | 'ms'} [unit='us'] - The time unit of the timestamp. * Supported values: * - `'ns'` — nanoseconds (requires `BigInt`) @@ -170,7 +170,7 @@ interface SenderBuffer { */ timestampColumn( name: string, - value: number | bigint, + value: number | bigint | null | undefined, unit: TimestampUnit, ): SenderBuffer; @@ -180,15 +180,19 @@ interface SenderBuffer { * Use it to insert into DECIMAL database columns. * * @param {string} name - Column name. - * @param {string | number} value - The decimal value to write. + * @param {string | number | null | undefined} value - The decimal value to write. * - Accepts either a `number` or a `string` containing a valid decimal representation. * - String values should follow standard decimal notation (e.g., `"123.45"` or `"-0.001"`). + * - A null or undefined value omits the column entirely (stored as NULL). * @returns {Sender} Returns with a reference to this buffer. * @throws Error If decimals are not supported by the buffer implementation, or validation fails. * Possible validation errors: * - The provided string is not a valid decimal representation. */ - decimalColumnText(name: string, value: string | number): SenderBuffer; + decimalColumnText( + name: string, + value: string | number | null | undefined, + ): SenderBuffer; /** * Writes a decimal value into the buffer using its binary format. @@ -196,11 +200,12 @@ interface SenderBuffer { * Use it to insert into DECIMAL database columns. * * @param {string} name - Column name. - * @param {bigint | Int8Array} unscaled - The unscaled integer portion of the decimal value. + * @param {bigint | Int8Array | null | undefined} unscaled - The unscaled integer portion of the decimal value. * - If a `bigint` is provided, it will be converted automatically. * - If an `Int8Array` is provided, it must contain the two’s complement representation * of the unscaled value in **big-endian** byte order. * - An empty `Int8Array` represents a `NULL` value. + * - A null or undefined value omits the column entirely (stored as NULL). * @param {number} scale - The number of fractional digits (the scale) of the decimal value. * @returns {SenderBuffer} Returns with a reference to this buffer. * @throws {Error} If decimals are not supported by the buffer implementation, or validation fails. @@ -211,7 +216,7 @@ interface SenderBuffer { */ decimalColumn( name: string, - unscaled: bigint | Int8Array, + unscaled: bigint | Int8Array | null | undefined, scale: number, ): SenderBuffer; diff --git a/src/index.ts b/src/index.ts index bc8e514..3fc63c0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,7 @@ export type { SenderBuffer } from "./buffer"; export { createBuffer } from "./buffer"; export { SenderBufferV1 } from "./buffer/bufferv1"; export { SenderBufferV2 } from "./buffer/bufferv2"; +export { SenderBufferV3 } from "./buffer/bufferv3"; export type { SenderTransport } from "./transport"; export { createTransport } from "./transport"; export { TcpTransport } from "./transport/tcp"; diff --git a/src/sender.ts b/src/sender.ts index 4a63a3b..fb3690b 100644 --- a/src/sender.ts +++ b/src/sender.ts @@ -231,7 +231,7 @@ class Sender { * Use it to insert into SYMBOL columns. * * @param {string} name - Symbol name. - * @param {unknown} value - Symbol value, toString() is called to extract the actual symbol value from the parameter. + * @param {unknown} value - Symbol value, toString() is called to extract the actual symbol value from the parameter. A null or undefined value omits the symbol entirely (stored as NULL). * @return {Sender} Returns with a reference to this sender. */ symbol(name: string, value: unknown): Sender { @@ -244,10 +244,10 @@ class Sender { * Use it to insert into VARCHAR and STRING columns. * * @param {string} name - Column name. - * @param {string} value - Column value, accepts only string values. + * @param {string | null | undefined} value - Column value, accepts only string values. A null or undefined value omits the column entirely (stored as NULL). * @return {Sender} Returns with a reference to this sender. */ - stringColumn(name: string, value: string): Sender { + stringColumn(name: string, value: string | null | undefined): Sender { this.buffer.stringColumn(name, value); return this; } @@ -257,10 +257,10 @@ class Sender { * Use it to insert into BOOLEAN columns. * * @param {string} name - Column name. - * @param {boolean} value - Column value, accepts only boolean values. + * @param {boolean | null | undefined} value - Column value, accepts only boolean values. A null or undefined value omits the column entirely (stored as NULL). * @return {Sender} Returns with a reference to this sender. */ - booleanColumn(name: string, value: boolean): Sender { + booleanColumn(name: string, value: boolean | null | undefined): Sender { this.buffer.booleanColumn(name, value); return this; } @@ -270,10 +270,10 @@ class Sender { * Use it to insert into DOUBLE or FLOAT database columns. * * @param {string} name - Column name. - * @param {number} value - Column value, accepts only number values. + * @param {number | null | undefined} value - Column value, accepts only number values. A null or undefined value omits the column entirely (stored as NULL). * @return {Sender} Returns with a reference to this sender. */ - floatColumn(name: string, value: number): Sender { + floatColumn(name: string, value: number | null | undefined): Sender { this.buffer.floatColumn(name, value); return this; } @@ -282,14 +282,14 @@ class Sender { * Writes an array column with its values into the buffer of the sender. * * @param {string} name - Column name - * @param {unknown[]} value - Array values to write (currently supports double arrays) + * @param {unknown[] | null | undefined} value - Array values to write (currently supports double arrays). A null or undefined value omits the column entirely, storing NULL. * @returns {Sender} Returns with a reference to this sender. * @throws Error if arrays are not supported by the buffer implementation, or array validation fails: * - value is not an array * - or the shape of the array is irregular: the length of sub-arrays are different * - or the array is not homogeneous: its elements are not all the same type */ - arrayColumn(name: string, value: unknown[]): Sender { + arrayColumn(name: string, value: unknown[] | null | undefined): Sender { this.buffer.arrayColumn(name, value); return this; } @@ -299,11 +299,11 @@ class Sender { * Use it to insert into LONG, INT, SHORT and BYTE columns. * * @param {string} name - Column name. - * @param {number} value - Column value, accepts only number values. + * @param {number | null | undefined} value - Column value, accepts only number values. A null or undefined value omits the column entirely (stored as NULL). * @return {Sender} Returns with a reference to this sender. * @throws Error if the value is not an integer */ - intColumn(name: string, value: number): Sender { + intColumn(name: string, value: number | null | undefined): Sender { this.buffer.intColumn(name, value); return this; } @@ -321,7 +321,7 @@ class Sender { * Always uses microsecond precision, even if the timestamp is specified in nanoseconds. * * @param {string} name - The column name. - * @param {number | bigint} value - The epoch timestamp. Must be an integer or a `BigInt`. + * @param {number | bigint | null | undefined} value - The epoch timestamp. Must be an integer or a `BigInt`. A null or undefined value omits the column entirely (stored as NULL). * @param {'ns' | 'us' | 'ms'} [unit='us'] - The time unit of the timestamp. * Supported values: * - `'ns'` — nanoseconds (requires `BigInt`) @@ -335,7 +335,7 @@ class Sender { */ timestampColumn( name: string, - value: number | bigint, + value: number | bigint | null | undefined, unit: TimestampUnit = "us", ): Sender { this.buffer.timestampColumn(name, value, unit); @@ -348,12 +348,15 @@ class Sender { * Use it to insert into DECIMAL database columns. * * @param {string} name - Column name. - * @param {number} value - Column value, accepts only number/string values. + * @param {string | number | null | undefined} value - Column value, accepts only number/string values. A null or undefined value omits the column entirely (stored as NULL). * @returns {Sender} Returns with a reference to this buffer. * @throws Error if decimals are not supported by the buffer implementation, or decimal validation fails: * - string value is not a valid decimal representation */ - decimalColumnText(name: string, value: string | number): Sender { + decimalColumnText( + name: string, + value: string | number | null | undefined, + ): Sender { this.buffer.decimalColumnText(name, value); return this; } @@ -364,9 +367,10 @@ class Sender { * Use it to insert into DECIMAL database columns. * * @param {string} name - Column name. - * @param {number} unscaled - The unscaled value of the decimal in two's + * @param {Int8Array | bigint | null | undefined} unscaled - The unscaled value of the decimal in two's * complement representation and big-endian byte order. * An empty array represents the NULL value. + * A null or undefined value omits the column entirely (stored as NULL). * @param {number} scale - The scale of the decimal value. * @returns {Sender} Returns with a reference to this buffer. * @throws Error if decimals are not supported by the buffer implementation, or decimal validation fails: @@ -376,7 +380,7 @@ class Sender { */ decimalColumn( name: string, - unscaled: Int8Array | bigint, + unscaled: Int8Array | bigint | null | undefined, scale: number, ): Sender { this.buffer.decimalColumn(name, unscaled, scale); diff --git a/test/sender.buffer.test.ts b/test/sender.buffer.test.ts index 9b0cc96..ac0e820 100644 --- a/test/sender.buffer.test.ts +++ b/test/sender.buffer.test.ts @@ -437,31 +437,122 @@ describe("Sender message builder test suite (anything not covered in client inte await sender.close(); }); - it("supports arrays with NULL value", async function () { + it("omits array columns with NULL value", async function () { const sender = new Sender({ protocol: "http", protocol_version: "2", host: "host", init_buf_size: 1024, }); + // A null or undefined array column is omitted from the row entirely: in ILP + // a NULL value is represented by not sending the field. Column separators + // stay correct whether the omitted column is leading or in the middle. await sender .table("tableName") - .arrayColumn("arrayCol", undefined as unknown as unknown[]) + .arrayColumn("undefCol", undefined) + .intColumn("i", 42) + .arrayColumn("nullCol", null) + .intColumn("j", 7) .atNow(); + expect(bufferContentHex(sender)).toBe(toHex("tableName i=42i,j=7i\n")); + await sender.close(); + + // A row whose only columns are NULL arrays has no fields and cannot be closed. + const emptySender = new Sender({ + protocol: "http", + protocol_version: "2", + host: "host", + init_buf_size: 1024, + }); + await expect( + async () => + await emptySender + .table("tableName") + .arrayColumn("nullCol", null) + .atNow(), + ).rejects.toThrow( + "The row must have a symbol or column set before it is closed", + ); + await emptySender.close(); + }); + + it("omits columns and symbols with null or undefined value", async function () { + const sender = new Sender({ + protocol: "tcp", + protocol_version: "1", + host: "host", + auto_flush: false, + init_buf_size: 1024, + }); + // null and undefined values are skipped entirely (recorded as NULL by the + // server), matching the Python client. See issue #28. The kept columns keep + // their separators correctly regardless of which values were skipped. await sender .table("tableName") - .arrayColumn("arrayCol", null as unknown as unknown[]) + .symbol("skippedSym1", null) + .symbol("skippedSym2", undefined) + .symbol("keptSym", "sv") + .stringColumn("skippedStr", null) + .stringColumn("keptStr", "hello") + .floatColumn("skippedFloat", undefined) + .floatColumn("keptFloat", 1.5) + .intColumn("skippedInt", null) + .intColumn("keptInt", 42) + .booleanColumn("skippedBool", undefined) + .booleanColumn("keptBool", true) + .timestampColumn("skippedTs", null) + .timestampColumn("keptTs", 1000) .atNow(); - expect(bufferContentHex(sender)).toBe( - toHex("tableName arrayCol==") + - " 0e 21 " + - toHex("\ntableName arrayCol==") + - " 0e 21 " + - toHex("\n"), + expect(bufferContent(sender)).toBe( + 'tableName,keptSym=sv keptStr="hello",keptFloat=1.5,keptInt=42i,keptBool=t,keptTs=1000t\n', ); await sender.close(); }); + it("omits decimal columns with null or undefined value", async function () { + const sender = new Sender({ + protocol: "tcp", + protocol_version: "3", + host: "host", + init_buf_size: 1024, + }); + await sender + .table("fx") + .decimalColumnText("skippedText", null) + .decimalColumnText("keptText", "1.5") + .decimalColumn("skippedBin", undefined, 2) + .intColumn("keptInt", 7) + .atNow(); + expect(bufferContent(sender)).toBe("fx keptText=1.5d,keptInt=7i\n"); + await sender.close(); + }); + + it("skips null/undefined array columns regardless of protocol version", async function () { + // v1 does not support arrays, but a null or undefined value is a no-op skip + // (consistent with every other column type) rather than an error. + const sender = new Sender({ + protocol: "tcp", + protocol_version: "1", + host: "host", + auto_flush: false, + init_buf_size: 1024, + }); + await sender + .table("tableName") + .arrayColumn("skippedArr1", null) + .arrayColumn("skippedArr2", undefined) + .intColumn("keptInt", 1) + .atNow(); + expect(bufferContent(sender)).toBe("tableName keptInt=1i\n"); + + // An actual array value still throws on v1. + sender.reset(); + expect(() => + sender.table("tableName").arrayColumn("arr", [1, 2, 3]), + ).toThrow("Arrays are not supported in protocol v1"); + await sender.close(); + }); + it("throws on invalid timestamp unit", async function () { const sender = new Sender({ protocol: "tcp", From 8d2629ff9f70fc08ab63da4b6ec45f93cd5197ae Mon Sep 17 00:00:00 2001 From: glasstiger Date: Mon, 17 Aug 2026 13:52:42 +0100 Subject: [PATCH 003/265] chore: update pull request review skill --- .claude/skills/review-pr/SKILL.md | 892 ++++++++++++++++++++---------- 1 file changed, 615 insertions(+), 277 deletions(-) diff --git a/.claude/skills/review-pr/SKILL.md b/.claude/skills/review-pr/SKILL.md index 0f182a1..10059c9 100644 --- a/.claude/skills/review-pr/SKILL.md +++ b/.claude/skills/review-pr/SKILL.md @@ -1,364 +1,702 @@ --- name: review-pr -description: Review a GitHub pull request against @questdb/nodejs-client (TypeScript ILP client) coding standards. Performs an adversarial, blocking, mission-critical code review covering correctness, buffer/byte-encoding safety, ILP wire format, transport/auth/TLS, async & resource lifecycle, performance, test coverage, and TypeScript API conventions, then verifies every finding against source before reporting. -argument-hint: [PR number or URL] [--level=0..3] -allowed-tools: Bash(gh *), Bash(git *), Read, Grep, Glob, Agent +description: Review a GitHub pull request or local Git range against @questdb/nodejs-client TypeScript ILP client coding standards +argument-hint: "[PR number or URL | --range=..] [--level=0..3]" +allowed-tools: Bash, Read, Grep, Glob, Agent --- -Review the pull request `$ARGUMENTS`. +# Review a Node.js client pull request + +**Usage:** `/review-pr [PR number or URL | --range=..] [--level=0..3]` + +Review the PR or local range identified by the invocation arguments. When this skill +is run as `/skill:review-pr `, the `` are appended as a `User:` message; +treat that text as `$ARGUMENTS`. Parse exactly one review target: a PR number/URL, +or `--range=..`. The range head may be omitted (`--range=..`) to +review the working tree, including uncommitted changes. If both targets are supplied, +stop and ask which was intended. If neither is supplied, ask for one. + +Use `Bash` only for read-only `gh` and Git queries, plus repository validation +commands when evidence requires them. Use `Read`, `Grep`, `Glob`, and fresh-context +agents through the Agent tool. Do not edit the primary working tree, push, post +comments, or mutate the PR. Step 3b may create an isolated temporary worktree solely +to verify a regression test against reverted production hunks; remove it afterward. ## Review mindset -You are a senior QuestDB engineer performing a blocking code review. `@questdb/nodejs-client` is mission-critical software: a TypeScript client that serializes rows into the QuestDB **InfluxDB Line Protocol (ILP)** wire format and ships them over HTTP/HTTPS (Undici or Node stdlib) or TCP/TCPS, and is used to ingest production data from customer Node.js applications. A bug here causes **silent data corruption on the wire** (a mis-encoded byte, a wrong column separator, a truncated buffer), **dropped or duplicated rows** (a flush that discards data on failure, a retry that re-sends), or a client that wedges a worker thread. The runtime is managed — there are no segfaults — but a corrupt ILP line, a lost flush, or a buffer written past its reserved capacity are the mission-critical failures here, and QuestDB cannot un-ingest bad data after it lands. Be critical, thorough, and opinionated. Your job is to catch problems before they ship, not to be nice. - -- **Assume nothing is correct until you've verified it.** Read surrounding code to understand context — don't just look at the diff in isolation. -- **The diff is a hint, not the boundary of the review.** The highest-value bugs almost always live at callsites outside the diff that depend on contracts the diff quietly changed (a `checkCapacity` reservation that no longer matches the bytes written, a buffer state-machine transition, a `SenderBufferBase` method inherited by v1/v2/v3, an option name consumed by `resolveDeprecated`). Treat the diff as the entry point, not the scope. -- **Flag every issue you find**, no matter how small. Do not soften language or hedge. Say "this is wrong" not "this might be an issue". -- **Do not praise the code.** Skip "looks good", "nice work", "clever approach". Focus entirely on problems and risks. -- **Think adversarially.** For each change, work through: - - Inputs: which values break this? `null`/`undefined` where a value is expected, empty strings, empty arrays (`[]` is truthy and has a `null` element type), `NaN`/`Infinity` floats, a `number` LONG beyond `2^53` (silently imprecise), `bigint` vs `number` at the timestamp boundary, max-length table/column names, non-ASCII/multi-byte UTF-8, strings containing the ILP delimiters (space, comma, `=`, `\n`, `\r`, `"`, `\`), irregular or non-homogeneous nested arrays. - - Wire format: does the serialized byte sequence match what the server expects for the negotiated protocol version (v1 text, v2 binary doubles + arrays, v3 decimals)? Column separators (leading space vs `,`), escaping, little-endian doubles/ints, array dimension headers, two's-complement decimal payloads. - - Buffer capacity: does every `checkCapacity(data, base)` reserve **at least** the exact number of bytes the following `write`/`writeByte`/`writeInt`/`writeDouble` calls emit? An under-reservation is silent corruption (`Buffer.write` short-writes at the allocation boundary) or a `RangeError` (`writeInt8`/`writeInt32LE`/`writeDoubleLE` throw past the end). - - Async & failure modes: connection drop mid-flush, HTTP 5xx, a retry after an uncertain send (duplicate rows?), TLS handshake failure, auth rejection — does the `Buffer` end in a usable state, and are rows lost or double-sent? Is every Promise awaited? - - Resource: is every socket, Undici pool/agent, `AbortController` timer, and TLS connection released on the error path as well as the happy path? Does the Sender close an `agent` the user passed in (which it must not)? -- **Check what's missing**, not just what's there. Missing tests, missing error handling, missing edge cases, missing `README.md`/`docs` updates for public API changes, a new option that `resolveDeprecated`/`resolveAuto`/the config parser doesn't handle, a new public symbol not exported from `src/index.ts`. -- **Verify every claim.** If the PR title says "fix", verify the bug actually existed and the fix is correct. If it says "improve performance", reason about the per-row hot path or look for a benchmark. If it says "simplify", verify the new code is actually simpler and doesn't drop behavior (a dropped escape, a lost capacity check, a removed `await`). Treat the PR description as an unverified hypothesis. -- **Read the full context of changed files** when the diff alone is ambiguous. Use Read/Grep/Glob to inspect surrounding code, callers, and related tests. -- **Assess reachability before reporting.** For every potential bug, trace the actual callers and inputs. If a problem requires physically impossible conditions (a buffer larger than `max_buf_size` which is already guarded, a value no caller can produce), it is not a real finding — drop it. Focus on bugs real workloads trigger, not theoretical edge cases the code already rejects upstream. -- **Never review generated or build artifacts.** `dist/cjs/**` and `dist/es/**` are `bunchee` build outputs, and `docs/**` is generated by `typedoc`. The source of truth is `src/**/*.ts` and `test/**/*.ts`. If the diff contains build output, review the `src` change that produced it, not the artifact. +You are a senior QuestDB engineer performing a blocking code review. +`@questdb/nodejs-client` is mission-critical software: it serializes rows into the +QuestDB InfluxDB Line Protocol (ILP) and sends them over HTTP/HTTPS or TCP/TCPS. +A bug can silently corrupt bytes, drop or duplicate rows, leak credentials, exhaust +resources, or break supported Node.js consumers. + +**A review that blocks on everything blocks on nothing.** Every finding costs an +author and CI round-trip. Reserve blocking severity for defects with a real user +consequence, report other issues at the severity their evidence earns, and approve +when the gates pass. Zero findings is a successful outcome. + +- **Assume nothing is correct until verified.** Read surrounding source and tests; + do not review the diff in isolation. +- **Treat the diff as the entry point, not the boundary.** Contract changes often + break unchanged callers, overrides, transports, protocol versions, or generated + type consumers. +- **Discovery is not a finding.** Every concern, including agent output, is an + untrusted hypothesis until it passes Step 3b. Omit anything unproved. +- **Falsify before explaining.** Search for guards, validation, retries, alternate + callers, unsupported configurations, and identical base behavior before building + a failure narrative. Failure to disprove is not proof. +- **Keep the PR blast radius small.** The PR owns defects it introduces or exposes. + Pre-existing behavior that is unchanged from base does not block it; a fully proved + pre-existing bug may leave as an adjacent issue draft. +- **Do not praise the code.** Focus on defects, risks, and missing evidence. +- **Think adversarially.** Exercise `null`/`undefined`, empty strings and arrays, + `NaN`/`Infinity`, imprecise `number` integers, `bigint`, multi-byte UTF-8, all ILP + delimiters, maximum buffer sizes, retries after uncertain sends, connection drops, + TLS/auth failures, and every negotiated protocol version. +- **Demand efficient hot paths.** Per-row and per-cell work scales to millions of + rows. Avoid allocations, repeated scans, redundant conversions, extra buffer copies, + and suboptimal algorithms there. Bounded setup/configuration work is less severe. +- **Check what is missing.** Look for absent error handling, cleanup, tests, public + exports, TSDoc, README changes, deprecation wiring, and cross-transport parity. +- **Untested behavior is a coverage risk, not proof of a defect.** A missing test is + Critical only when a supported, reachable regression could cause material user harm + and existing safeguards do not contain it. +- **Verify every PR claim.** Reproduce fixes where practical, check performance claims + against the actual multiplier, and treat the PR description as a hypothesis. +- **Assess reachability before reporting.** Drop theoretical paths that callers, + validation, configuration, or buffer bounds make impossible. +- **Never review generated artifacts as source.** `dist/cjs/**`, `dist/es/**`, and + `docs/**` are generated. Review their `src/**/*.ts` or documentation source instead. ## Review level -Parse `$ARGUMENTS` for a level token: `--level=N`, `-lN`, or a bare single digit `0`-`3`. **If no level is given, default to 0.** Strip the level token before feeding the remainder (PR number or URL) to `gh` commands. - -The level controls how much of the review below actually runs. Lower levels keep the same review *spirit* — adversarial, blocking, no praise — but cut the breadth of the analysis. Higher levels have significantly higher token cost; reserve level 3 for high-stakes PRs (anything touching the ILP wire format in `src/buffer/**`, buffer capacity/resize math, a transport in `src/transport/**`, auth/TLS, protocol-version negotiation in `src/options.ts`, or the public API surface in `src/index.ts`). +Parse `$ARGUMENTS` for `--level=N`, `-lN`, or a bare digit `0`-`3`. Default to +level 0. Strip the level token and any `--range=` token before passing a PR target +to `gh`. | Level | What runs | |-------|-----------| -| **0 (default)** | Steps 1, 2, 4. Skip Steps 2.5a-d, but still run Step 2.5e (build & runtime profile — mandatory at every level). Skip Step 3 — no agent spawn; review the diff inline in the main loop, using Read/Grep on demand to resolve ambiguities. Skip Step 3b — verify each finding inline as you write it. Single-pass review covering correctness, buffer/byte-encoding safety, ILP wire format, `null`/`undefined` handling, tests, and coding standards on the diff itself. | -| **1** | Adds Step 2.5a (semantic delta only — skip 2.5b/2.5c/2.5d; Step 2.5e still runs, as at every level). In Step 3, launch only Agent 1 (correctness), Agent 2 (buffer & byte-encoding safety), and Agent 7 (tests) in parallel. Skip all other agents. Skip Step 3b — verify findings inline as you draft the report. | -| **2** | Full Step 2.5, but in 2.5b restrict the callsite inventory to symbols exported from `src/index.ts`, plus every `protected`/`abstract` member of `SenderBufferBase`/the transport interfaces, plus every configuration option name. In Step 3, launch Agents 1-8. Skip Agent 9 (cross-context) and Agent 10 (adversarial fresh-context). Step 3b uses a single batched verification agent for all findings instead of one per finding. | -| **3** | Every step below as written, all 10 agents, per-finding verification. The full mission-critical pass. | +| **0 (default)** | Steps 1, 2, 2.4, 2.5f, 2.6, and 4. Review inline without agent fanout. Build a compact coverage map and apply the Step 3b admission gate inline from a blank evidence form. | +| **1** | Add Steps 2.5a and 2.5e when tests change. Run Agent 1 plus at most two applicable roles from Agents 2-7 and 9-13. Independently falsify each surviving atomic candidate. | +| **2** | Run all of Step 2.5, restricting 2.5b to exported/public/protected symbols, transport interfaces, shared helpers, and configuration options. Run Agent 1 plus at most four change-relevant roles. Independently falsify each surviving candidate. | +| **3** | Run the full workflow. Select at most six applicable discovery roles: Agent 1 always; Agent 8 when changed symbols have out-of-diff callers; Agents 2-7 when their domains are touched; Agents 9-13 for changed tests or a fix claim; Agent 10 only when a distinct adversarial pass is warranted. Depth comes from evidence, not agent count. | -State the chosen level in one line at the start of the review so the user knows what they're getting (e.g., "Reviewing PR #58 at level 2"). If the level was defaulted, mention that level 3 exists for full review. +State the selected level at the start of the review. If defaulted, mention that level +3 exists for a full mission-critical pass. Changes to `src/buffer/**`, transport/auth/ +TLS, protocol negotiation, flush semantics, or `src/index.ts` are high risk; recommend +level 3, but honor an explicit lower level and state the limitation. -## Step 1: Gather PR context +## Spawning review agents -Capture the PR identifier in `$PR` (the part of `$ARGUMENTS` left after stripping the level token), then fetch metadata, diff, and review comments in a single bash call so `$PR` is in scope for all three `gh` invocations: +Steps 3 and 3b use fresh-context, read-only Agent tasks. Discovery tasks receive the +diff, Step 2.4 gitlink verdicts, the Step 2.5 surface map, the Step 2.6 coverage map, +the chosen role, and the candidate contract. Agents 10 and Step 3b falsifiers are +deliberate reduced-context exceptions. -```bash -PR='' -gh pr view "$PR" --json number,title,body,labels,state -gh pr diff "$PR" -gh pr view "$PR" --comments -``` +Use a shared temporary artifact for large maps instead of pasting them into every +prompt. Never pass a discovery narrative, proposed severity/fix, votes, or verification +claims to a falsifier. The parent owns role selection, the private candidate ledger, +admission, severity, deduplication, and the final report. -If the diff modifies the ILP serialization in `src/buffer/**`, a transport in `src/transport/**`, or the protocol/auth/TLS options in `src/options.ts`, note it now — a wire-format or transport change is the highest-risk class of change in this repo and forces level-3 scrutiny regardless of the requested level. +## Step 1: Gather review context -## Step 2: PR title and description +Every mode must end with `$BASE` and `$HEAD` identified. Behavioral findings require +the same trigger at both revisions unless the surface is genuinely new. -Check against the repo's conventions (`CONTRIBUTING.md` mandates Conventional Commits): -- Title follows Conventional Commits: `type(scope): description` (e.g., `feat: support for DECIMAL type`, `fix: array null handling`) -- Description speaks to end-user impact, not just implementation internals -- If fixing an issue, `Fixes #NNN` (or a link to the issue) is present -- Tone is level-headed and analytical, no superlatives -- For public API changes (a new/changed method on `Sender`/`SenderBuffer`, a new/renamed/removed configuration option, a changed default, a new export in `src/index.ts`), the description calls out the API change explicitly, and `README.md` / the TSDoc / any relevant `docs` are updated -- For a new configuration option, the description states the option name, its default, and whether it deprecates an existing one (which must be wired through `SenderOptions.resolveDeprecated`) - -## Step 2.5: Map the change surface +### GitHub PR -Before launching review agents, produce a structured change surface map. This step is mandatory and must use Grep/Glob — do not reason about callsites from memory. The output of this step is required input for every Step 3 agent except Agent 10 (the fresh-context adversarial agent, which deliberately works from the diff alone). +```bash +PR='' +gh pr view "$PR" --json number,title,body,labels,state,baseRefOid,headRefOid +gh pr diff "$PR" +gh pr view "$PR" --comments +BASE=$(gh pr view "$PR" --json baseRefOid --jq .baseRefOid) +HEAD=$(gh pr view "$PR" --json headRefOid --jq .headRefOid) +``` -### 2.5a Semantic delta per changed symbol +Also inspect the commit subjects with a read-only query when available. Do not check +out the PR into the primary working tree merely to review it. -For every modified or added function, method, class, `abstract`/`protected` member, exported constant, type/interface, or configuration option, write: +### Local range (`--range`) -- **Symbol:** fully-qualified name (e.g., `SenderBufferBase.writeColumn`, `SenderBufferV2.arrayColumn`, `Sender.flush`, `SenderOptions.resolveAuto`, the `protocol_version` option) -- **Before:** signature, return type (and **sync vs `async`/`Promise`** — a function that becomes `async` changes every caller's awaiting), what it throws and on which inputs, which buffer state it mutates (`hasTable`/`hasSymbols`/`hasColumns`/`position`/`endOfLastRow`), allocation behavior, which protocol versions it applies to (v1/v2/v3), the exact bytes it writes to the wire -- **After:** same fields -- **Delta:** one line stating what semantically changed +```bash +BASE='' +HEAD='' +git diff "$BASE"${HEAD:+"...$HEAD"} --stat +git diff "$BASE"${HEAD:+"...$HEAD"} +git diff "$BASE"${HEAD:+"...$HEAD"} --name-only +git status --porcelain +``` -"Refactored", "cleaned up", "improved", "simplified" are not acceptable deltas. State the actual behavioral difference. If nothing semantically changed, write "no behavioral change" — but only after checking, not as a default. +With an empty head, include staged and unstaged tracked changes. `git diff` omits +untracked files, so read any untracked source/test files that belong to the change. +In range mode skip Step 2 because there is no PR metadata, state that fact, and run +all other selected steps normally. -### 2.5b Callsite inventory +## Step 2: PR title and description -For every changed symbol that is exported from `src/index.ts`, a `public`/`protected`/`abstract` member of a base class, a shared helper in `src/utils.ts`/`src/validation.ts`, a transport-interface method, or a configuration option name, run Grep across the repository to find every callsite, override, or reference outside the diff. +Skip this step in range mode. -Produce a list grouped by file. Search at minimum: +Check the repository conventions in `CONTRIBUTING.md` and recent accepted PRs: -- **Source:** `grep -rn 'symbolName' src/` -- **Public API surface:** `grep -rn 'symbolName' src/index.ts` (is it exported? is the export still consistent?) -- **Buffer subclasses:** for a changed `SenderBufferBase` member, check `src/buffer/bufferv1.ts`, `bufferv2.ts`, `bufferv3.ts` for overrides and callers — a base-class change silently reaches all three protocol versions -- **Transport implementations:** for a changed transport-interface method, check `src/transport/http/base.ts`, `http/undici.ts`, `http/stdlib.ts`, `tcp.ts` — all four protocols implement the same contract -- **Configuration options:** for a changed/added option name, `grep -rn 'option_name' src/options.ts src/ test/` and confirm it is parsed, validated, defaulted, and (if it replaces one) handled by `resolveDeprecated` -- **Tests:** `grep -rn 'symbolName' test/` -- **README & examples:** `grep -rn 'symbolName' README.md` +- Title follows Conventional Commits: `type(scope): description`. +- Description explains end-user impact, not only implementation details. +- A bug fix links or closes its issue. +- Tone is analytical and avoids superlatives. +- Public API, option/default, export, or compatibility changes are explicit. +- README/TSDoc updates accompany user-visible behavior where needed. +- New or renamed options document their defaults and deprecation path through + `SenderOptions.resolveDeprecated`. -A changed exported/`protected`/helper symbol with zero recorded Grep calls in the trace is a skill violation. The model is not allowed to assert "this is only used here" without showing the search. +## Step 2.4: Submodule boundaries (mandatory at every level) -### 2.5c Implicit contract list +Treat submodule gitlink changes as opaque. Detect mode `160000` pointer moves, record +the path and old/new hashes, and classify each as exactly: -For each changed symbol, walk this checklist and write one line per item, stating before vs after: +```bash +git diff --raw "$BASE"${HEAD:+"...$HEAD"} | awk '$1 ~ /^:160000/ || $2 == "160000"' +``` -- **Throws-what:** which inputs cause a thrown `Error`, and which callers catch vs propagate. The public builder methods (`table`/`symbol`/`*Column`/`at`/`atNow`) throw synchronously; changing what throws changes caller error handling. -- **`null`/`undefined` handling:** does the symbol accept, reject, or silently omit `null`/`undefined`? (An omitted column must not write a separator — see the row state machine.) Note that `strictNullChecks` is **off** (see 2.5e), so the type signature does not enforce non-nullability — every parameter is nullable at runtime. -- **Buffer capacity contract:** does the code reserve via `checkCapacity(data, base)` exactly the bytes the subsequent `write*` calls emit? State the reserved count vs the actual bytes for the changed path. -- **Buffer state machine:** does it read or transition `hasTable`/`hasSymbols`/`hasColumns`/`endOfLastRow`/`position`? Does a row that ends up empty still get closed by `at`/`atNow` (which throw on an empty row)? -- **Sync/async:** does it return a value or a `Promise`? Is every caller awaiting it? Did it change between the two? -- **Wire-format bytes:** any change to the ILP bytes produced — column separators, escaping, entity-type/column-type marker bytes (v2/v3), little-endian encoding, array dimension headers, timestamp units (v1 truncates ns→us; v2+ preserves ns), two's-complement decimal payload. -- **Protocol-version applicability:** does the change apply to v1, v2, v3, or all? Is a v2/v3-only feature guarded so v1 rejects it cleanly? -- **Transport contract:** connection lifecycle (`connect`/`send`/`close`), auto-flush row-count default (`getDefaultAutoFlushRows`), retry/idempotency, credential handling, TLS. -- **Number precision:** does a `number` carry a value that needs `bigint` (LONG beyond `2^53`, nanosecond timestamps)? `Number.isInteger` accepts imprecise large integers. -- **Configuration/deprecation:** did an option name, default, or validation rule change, and is `resolveDeprecated`/`resolveAuto`/the parser updated in lockstep? +- **OPAQUE** — the superproject changes only the gitlink. Do not enter the submodule, + fetch its branches, expand the commit range, inspect its files, attribute upstream + behavior changes to this PR, or report findings from its contents. Assume the + referenced changes were already merged and reviewed upstream. -### 2.5d Cross-context exposure list +Review submodule contents only when the user explicitly requests that as an independent +task. A genuine integration defect remains in scope only when code in the superproject +diff calls or configures the bumped submodule incorrectly; file the finding at that +superproject callsite and do not use an expanded submodule range as evidence. -End this step with an explicit list of "places this change is visible from but the diff does not touch". This is the highest-priority input for the bug-hunting agents in Step 3. +Repeat each `OPAQUE` verdict in Step 4 so the scope decision is auditable. If no +gitlinks changed, state `Submodules: none` in the summary. -Group the callsites from 2.5b by execution context. Typical contexts in this codebase: +## Step 2.5: Map the change surface -- **Per-row buffer-build hot path:** `SenderBufferBase.table`/`symbol`/`stringColumn`/`booleanColumn`/`intColumn`/`timestampColumn`/`writeColumn`/`writeEscaped`/`checkCapacity`, and the v1/v2/v3 `floatColumn`/`arrayColumn`/`decimalColumn` overrides -- **Protocol-version fan-out:** every `SenderBufferBase` member is inherited by `SenderBufferV1`/`V2`/`V3`; `createBuffer` selects the implementation by `protocol_version` -- **Transport fan-out:** every transport-interface method is implemented by `UndiciTransport`, `HttpTransport` (stdlib), and `TcpTransport`; `createTransport` selects by protocol -- **Flush & auto-flush path:** `Sender.flush`/`at`/`atNow`/`tryFlush`/`resetAutoFlush`, and `buffer.toBufferNew` (which compacts/discards on read) -- **Protocol negotiation:** `SenderOptions.resolveAuto` (HTTP round-trip at setup) and `resolveDeprecated` -- **Config parsing:** the connection-string parser in `src/options.ts`, `Sender.fromConfig`/`fromEnv` -- **Auth & TLS:** HTTP Basic (`username`/`password`) / Bearer (`token`) → `Authorization` header; TCP JWK challenge-response; `tls_verify`/`tls_ca`/`tls_roots` -- **Worker-thread usage:** each worker needs its own `Sender` (shared buffer state is not concurrency-safe — see `README.md` worker-threads example) -- **Public API / type surface:** `src/index.ts` exports and the emitted `.d.ts` -- **Tests:** unit (`test/sender.buffer.test.ts`, `sender.config.test.ts`, `sender.transport.test.ts`, `options.test.ts`, `utils.decimal.test.ts`, `logging.test.ts`), integration (`test/sender.integration.test.ts`, TestContainers), and mock helpers (`test/util/mockhttp.ts`, `mockproxy.ts`, `proxy.ts`) -- **Docs & examples:** `README.md` +Use `rg` and `rg --files` (or Grep/Glob equivalents) rather than reasoning about +callers from memory. The resulting map is input to every normal Step 3 agent. -Every entry on this list must be reviewed in Step 3. +### 2.5a Semantic delta per changed symbol -### 2.5e Build & runtime profile facts +For every modified or added function, method, class, abstract/protected member, +exported type/constant, interface, and configuration option, record: -**This sub-step runs at every level, including levels 0 and 1 where the rest of Step 2.5 is skipped.** A single tsconfig flag, a Node/Undici version floor, or the ESM/CJS dual build can flip the safety story for the whole client; agents must reason from the actual profile, not from defaults. +- **Symbol:** fully qualified name. +- **Before:** signature, sync/async return shape, thrown errors and inputs, state + mutation (`hasTable`, `hasSymbols`, `hasColumns`, `position`, `endOfLastRow`), + allocation behavior, protocol versions, and exact wire bytes where applicable. +- **After:** the same fields. +- **Delta:** the concrete behavioral difference. Use `no behavioral change` only + after checking; words such as “refactored” or “simplified” are insufficient. -Record, with file:line citations: +### 2.5b Callsite inventory -- **TypeScript strictness** (`tsconfig.json`): note whether `strict`/`strictNullChecks`/`noImplicitAny`/`noUncheckedIndexedAccess` are set. As of this writing **none are** — `strictNullChecks` is **off**, so the compiler does **not** flag `null`/`undefined` flowing into a non-nullable parameter, and does not flag possibly-`undefined` array/index access. Agents must treat every value as potentially `null`/`undefined` at runtime regardless of its declared type, and must not assume the type checker caught a nullability or index bug. (This is the reason a `null` array reaching `arrayColumn` compiles cleanly.) -- **`Buffer.write` / `writeInt*` semantics:** `buffer.write(str, pos)` writes only up to the allocation boundary and returns the actual byte count, so a short write **silently truncates** and mis-advances `position` (corrupt wire data, no throw). `buffer.writeInt8`/`writeInt32LE`/`writeDoubleLE` **throw `RangeError`** when `pos` is past the end. Therefore an incorrect `checkCapacity` reservation is a silent-corruption *or* crash surface, not a guarded no-op. `writeInt8` also throws for values outside `-128..127` — a marker byte in `128..255` must be sign-folded (see `bufferv3` `byte -= 256`). -- **Node version floor:** the client requires **Node v20+** (built-in `fetch`/Undici, `worker_threads`), and `@types/node` is `^22`. Code using a Node API newer than the v20 floor breaks the oldest supported runtime — state the floor. -- **`undici` dependency** (`^7`): the default HTTP transport is Undici; `stdlib_http=on` switches to Node's `http`/`https`. Behavior must match across both implementations. Note the pinned major. -- **Dual ESM + CJS build** (`bunchee`, `package.json` `exports`): both `dist/es` (`.mjs`) and `dist/cjs` (`.js`) are shipped. A construct that only works in one module system (`__dirname`/`require` in ESM, top-level `await` in CJS) breaks a supported consumer. -- **Protocol-version default is `auto`:** for HTTP, `resolveAuto` negotiates the version with the server at setup; for TCP the version must be set explicitly. `createBuffer` builds the serializer for the resolved version — a serializer/version mismatch corrupts the wire. +For every changed exported/public/protected symbol, base-class member, shared helper, +transport-interface method, or option name, search all source, tests, README/examples, +and exports. Group results by file and include overrides and implementations. -A review without this section is incomplete. State the relevant facts (strictness, `Buffer` semantics, Node floor, protocol default) in one line at the top of every Step 3 agent prompt (except Agent 10's, which works from the diff alone) so the agent reasons from the right premise. +At minimum check: -## Step 3: Parallel review +- `src/index.ts` and emitted public type implications. +- `SenderBufferBase` plus `SenderBufferV1`/`V2`/`V3` overrides and `createBuffer`. +- `SenderTransport` plus Undici, stdlib HTTP, and TCP implementations. +- `SenderOptions.resolveAuto`, `resolveDeprecated`, config parsing, `fromConfig`, and + `fromEnv` for option changes. +- Unit/integration tests and test helpers. +- `README.md` and examples for public symbols/options. -Every agent except Agent 10 receives: -1. The PR diff -2. The full change surface map from Step 2.5 (semantic deltas, callsite inventory, implicit contracts, cross-context exposure list, build & runtime profile facts) +A changed shared symbol with no recorded `rg` command is a skill violation. Never +assert “only used here” without the search trace. -### Anti-anchoring directive (applies to all agents) +### 2.5c Implicit contract list -- **Bugs at callsites outside the diff outrank bugs inside the diff.** A confirmed bug in a file the PR did not touch but that calls a changed symbol is a P0 finding. -- **"Looks correct in isolation" is not a valid conclusion.** Before clearing a changed symbol, the agent must walk the callsite inventory from 2.5b and explicitly state, per callsite, whether the new behavior is still correct there. -- **The diff is the entry point, not the scope.** If the change surface map shows the symbol is reachable from N other files, the review covers N+1 files. -- **Base classes and factories fan out.** A change to a `SenderBufferBase` member retroactively changes v1/v2/v3; a change to a transport-interface method changes all four protocols; a change to a config option changes `resolveDeprecated`/`resolveAuto` and the parser. When a base member, interface method, or option appears in the diff, the review covers the whole fan-out, not just the touched lines. -- A single finding of the form "in `bufferv2.ts` the new behavior of `writeColumn` writes the wrong separator when the previous column was omitted" is worth more than five findings inside the diff. +For each changed symbol, record before versus after for every applicable contract: + +- Inputs that throw synchronously and which callers catch or propagate. +- `null`/`undefined`: accept, reject, or omit. `strictNullChecks` is off, so validate + runtime behavior rather than trusting the signature. +- Buffer capacity: bytes reserved by `checkCapacity(data, base)` versus bytes emitted. +- Row state: reads/transitions of `hasTable`, `hasSymbols`, `hasColumns`, `position`, + and `endOfLastRow`, including empty-row closure. +- Sync/async shape and whether every caller awaits it. +- ILP bytes: separators, escaping, marker bytes, byte order, arrays, timestamp units, + decimal payloads, and protocol-version applicability. +- Transport lifecycle, retry/idempotency, auto-flush behavior, auth, TLS, and cleanup. +- Number precision: `number` versus `bigint`, especially LONG and nanosecond values. +- Configuration name/default/validation/deprecation behavior. +- Allocation and complexity on setup, per-row, and per-cell paths. -### Agents +### 2.5d Cross-context exposure list -Launch the following agents in parallel. +List places where the change is visible but the diff does not touch, grouped by: + +- Per-row/per-cell buffer-build hot path. +- Protocol-version fanout (v1/v2/v3). +- Transport fanout (Undici, stdlib HTTP, TCP/TCPS). +- Flush, retry, and lazy auto-flush paths. +- Protocol negotiation and configuration parsing. +- Auth/TLS and resource lifecycle. +- Worker-thread use (one mutable `Sender` per worker). +- Public ESM/CJS/type surface. +- Tests, helpers, README, and examples. + +Every listed context must be checked in Step 3. + +### 2.5e Test surface and helper inventory + +Run when tests are added or changed. Use repository searches to record: + +- Existing setup/teardown, fixtures, mock HTTP/proxy helpers, buffer hex helpers, + custom matchers, and parameterized-test patterns the change could reuse. +- Callers of any changed shared test helper or fixture. +- The production symbols each changed test actually exercises. +- Whether the assertion observes public behavior, exact wire bytes, transport calls, + resource cleanup, or only implementation details. + +### 2.5f Build and runtime profile (mandatory at every level) + +Record current facts with file/line citations; do not rely on this list becoming stale: + +- TypeScript flags from `tsconfig.json`, especially `strictNullChecks`, + `noImplicitAny`, and `noUncheckedIndexedAccess`. +- Node.js version floor and `@types/node` version. +- `undici` major and the `stdlib_http` alternative. +- Dual ESM/CJS build and `package.json` exports. +- Protocol default/negotiation and TCP's explicit-version requirement. +- `Buffer.write` versus `writeInt*` boundary semantics. A short `Buffer.write` can + silently truncate, while numeric writes throw out of bounds; `writeInt8` requires + `-128..127` and marker bytes above 127 must be sign-folded. + +Put the relevant facts at the top of normal Step 3 prompts. Agent 10 receives only +the reduced context defined below. + +## Step 2.6: Test coverage map (mandatory at every level) + +For every production behavioral change, including each new branch/error/NULL/boundary +path, build an internal row containing: + +- **Change:** symbol and exact behavior/path. +- **Test:** exact test file and name found through recorded `rg`/`rg --files` searches. +- **Failure link:** assertion and why it fails if the behavior regresses. +- **Reachability/population:** supported API/configuration/event and affected users. +- **Credible consequence:** concrete recurrence and observed harm. +- **Change risk:** complexity, caller breadth, state/resource sensitivity, safeguards. +- **Stable test design:** least invasive meaningful unit/integration/fault-injection + assertion and observation seam. +- **Effort/fragility evidence:** concrete setup, nondeterminism, platform, or production + seam costs; “hard to test” alone is not evidence. +- **Dimensions:** applicable protocol, transport, happy/error, NULL, boundary, + concurrency, retry, and resource-cleanup dimensions. +- **Disposition:** `COVERED`, `CRITICAL GAP`, `MODERATE GAP`, `ACCEPTED GAP`, or `EXEMPT`. + +Mark rows with no effective assertion `UNTESTED` before classification. Missing tests +alone never establish Critical severity: + +- **Critical gap:** a supported reachable regression can cause data loss/corruption, + a security failure, outage/hang, compatibility break, unbounded resource loss, or + similarly material harm; safeguards do not contain it; Step 3b admits it. +- **Moderate gap:** meaningful but bounded exposure, including most bug fixes without + an effective regression test. +- **Accepted gap:** localized low-risk behavior where a stable test is demonstrably + disproportionate or more fragile than the code and existing safeguards are strong. +- **Exempt:** verified non-behavioral source, documentation, generated-output, or CI + changes. + +Publish only admitted gaps. Keep covered, accepted, exempt, and omitted rows private +unless the user asks for the complete map. + +## Step 3: Change-specific candidate discovery + +Use fresh-context, read-only Agent tasks. Select only roles materially touched by the +change and obey the review-level cap. Agent count is never evidence. + +Every normal discovery task receives the diff, change-surface map, coverage map, and +these candidate rules: + +- Generate atomic, falsifiable hypotheses; do not assign severity, propose fixes, + write persuasive titles, or claim verification. +- Cite the exact changed hunk or unchanged callsite contract allegedly broken. +- Name the supported-state producer: exact public API call, option, protocol, server + response, runtime, or event that creates every trigger. Use `producer: unknown` + rather than inventing one. +- Give reachability, head observation, same-trigger base observation, user symptom, + evidence commands/artifacts, and strongest counterevidence. Mark unchecked fields + `unknown`. +- Universal claims such as “never”, “only”, “no retry”, or “all transports” require + an exhaustive caller/event-source inventory. +- Do not split supporting mechanisms into findings without independent consequences. +- Pre-existing unchanged behavior is not a PR finding. Fully proved pre-existing bugs + may be proposed as adjacent issues only after Step 3b. +- Returning no candidate is valid and preferred to speculation. + +### Agent roles + +**Agent 1 — Correctness and ILP semantics:** Check nullish omission, separators, +escaping, input validation, integer precision, timestamp conversion, float edge cases, +array shape/type/emptiness, decimal encoding, error paths, and exact v1/v2/v3 wire +behavior. Check every changed symbol against its callers and overrides. + +**Agent 2 — Buffer and byte-encoding safety:** Reconstruct bytes and capacity math. +Check every write against `checkCapacity`, UTF-8/escaping expansion, signed marker +bytes, little-endian numeric/dimension encoding, `position`, overlapping compaction, +resize/max-size behavior, `toBufferView` aliasing, `toBufferNew` mutation, and decimal +two's-complement bounds. + +**Agent 3 — Transport, negotiation, auth, and TLS:** Check serializer negotiation, +TCP explicit versions, retry classification/idempotency, Undici/stdlib parity, Basic/ +Bearer/JWK credentials, secret exposure, TLS verification/custom roots, timeouts, and +connect/send/close behavior. + +**Agent 4 — Async, concurrency, and flush semantics:** Check every Promise/`await`, +ordering across `at`/`atNow`/`tryFlush`/`flush`, row loss after `toBufferNew` compaction, +uncertain-send duplication, lazy interval/row-count auto-flush, and unsafe sharing or +interleaving of mutable Sender state. + +**Agent 5 — Resource management and lifecycle:** Trace sockets, Undici pools/agents, +user-supplied versus owned agents, timers, abort controllers, listeners, and buffer +views on success, failure, and early return. Verify failed connect/send/TLS paths close +or preserve ownership correctly. + +**Agent 6 — Performance and algorithmic optimality:** For each loop, scan, allocation, +copy, conversion, and data structure, state complexity and the best feasible approach. +Focus on per-row/per-cell `toString`, string concatenation, repeated `Buffer.byteLength`, +per-character writes, resize copying, large arrays, and avoidable buffer copies. Every +candidate must state its multiplier or fixed bound and whether users wait on the path. + +**Agent 7 — Public API, compatibility, and code quality:** Check `src/index.ts`, ESM/ +CJS exports, `.d.ts` implications, TSDoc, option defaults/deprecations, supported Node +APIs, README/examples, unsound casts, dead code/imports, ESLint, Prettier, naming, and +member ordering. Separate compatibility defects from cosmetics. + +**Agent 8 — Cross-context caller impact:** Walk every 2.5b callsite with callers up to +two levels. For each, return `SAFE`, `CANDIDATE`, or `INSUFFICIENT_EVIDENCE` and state +whether the new contract breaks valid inputs, row state, bytes, sync/async shape, +protocol subclasses, transports, config readers, error/retry paths, or worker contexts. + +**Agent 9 — Test coverage:** Recheck every Step 2.6 test and failure link, add missed +behavior rows, and mutation-spot-check the most dangerous changed conditions. Check +the matrix of protocols, transports, auth/TLS, auto-flush, resize, escaping, nullish +values, arrays, precision, timestamps, retry/error, and resource cleanup. + +**Agent 10 — Fresh-context adversarial:** Receive only the diff and changed filenames. +Instruction: “Generate a small set of falsifiable ways this code could be wrong and +try to disprove each before returning it.” It may inspect the repository but receives +no surface map, checklists, prior candidates, severities, or fixes. + +**Agent 11 — Test efficacy and correctness:** Trace each changed test from production +symbol to assertion. Find vacuous assertions, tests that do not reach the changed path, +wrong/stale expected wire bytes, happy-path-only coverage, swallowed asynchronous +assertion failures, timing-dependent synchronization, and cleanup failures. + +**Agent 12 — Test-code quality:** Search the 2.5e inventory before flagging duplicated +setup or helpers. Check parameterization opportunities, misleading names, copy/paste +residue, debug output, commented code, unjustified skipped tests, brittle implementation +assertions, and unnecessary casts. Name a real reusable alternative for each complaint. + +**Agent 13 — Regression-test efficacy:** For a bug-fix claim, identify which production +hunk each test depends on. A candidate survives only if the test passes at head and +fails when the production fix is reverted in an isolated scratch worktree. + +Combine outputs into a private candidate ledger. Split compound narratives into atomic +propositions, deduplicate by proposition plus evidence, and record dependencies. Do not +draft severity, fixes, or report prose yet. + +## Step 3b: Independently falsify, prove, and admit candidates + +Use this state machine without shortcuts: + +`HYPOTHESIS → FALSIFYING → PROVEN → ADMITTED` + +Missing proof, unresolved contradiction, failed reproduction, unsupported producer, +or dependency on an omitted premise ends at `OMITTED`. “Could not disprove” is not +`PROVEN`, and there is no public downgraded/false-positive section. + +At levels 1-3, launch one fresh-context falsifier per atomic candidate. Give it only: + +1. The neutral proposition. +2. Repository plus base/head identities (or captured working-tree diff hash) and + relevant filenames. +3. Raw evidence/artifact paths. + +Do not send the discovery narrative, severity, fix, author identity, votes, or claims +that anyone verified it. At level 0, apply the same protocol inline from a blank form. + +The falsifier first constructs the strongest disproof: missing producer, unsupported +configuration, impossible version pairing, omitted caller, retry, guard, validation, +cleanup, downstream containment, or identical/better base behavior. Only a surviving +candidate receives affirmative proof. + +Admit a behavioral candidate only when every applicable field has cited evidence: + +- **Attribution:** changed hunk, or unchanged callsite plus changed contract. +- **Supported-state producer:** exact supported API/config/protocol/runtime/event. +- **Reachability:** complete producer-to-symptom path, including guards, retries, + dispatch, ownership, and cleanup. +- **Head observation:** executed trigger and observed result at the reviewed revision. +- **Base observation:** identical trigger/result at `$BASE`, or `N/A — genuinely new + surface` with proof. +- **User symptom:** independently observable consequence. +- **Counterevidence search:** strongest disproof and why it does not apply. +- **Artifact:** command/test, output, environment/configuration, and revision identity. + +Runtime-shape, race, ordering, retry, restart, resource-lifetime, compatibility, and +wire-format claims require executed artifacts; static reading alone cannot admit them. +For fully static compile errors or standards violations, mark runtime-only fields +`N/A — static` and cite the complete source proof. Coverage searches prove absence of +a test, not the reachability or impact needed for a Critical gap. + +Apply these special burdens: + +- Universal negatives require an exhaustive inventory and executed probe. +- Concurrency/order candidates must force or observe the interleaving. +- Regression-test candidates must run green at head and red with the production fix + reverted in a scratch worktree, never the primary working tree. +- If execution is impossible, record the limitation privately and omit the behavioral + candidate rather than replacing evidence with confident prose. +- If a parent premise is omitted, omit every dependent candidate. + +Then independently verify Node-client specifics: + +1. Read exact source lines in `src/**/*.ts`, not generated output, and trace callers, + interfaces, factories, and v1/v2/v3 overrides. +2. Count every emitted byte against capacity, including escaped multi-byte UTF-8, + separators, suffixes, marker bytes, dimension headers, and decimal payloads. +3. Reconstruct expected wire bytes and compare them with both production output and + byte-level test expectations. +4. Validate nullish behavior at runtime because TypeScript nullability may be disabled. +5. Trace `toBufferNew`/compaction relative to awaited sends for loss/duplication claims. +6. Trace retry classes and whether the server could have durably accepted an uncertain + send before replay. +7. Trace every socket, agent, timer, abort controller, listener, and buffer view through + success/error/early return; never destroy a user-supplied agent. +8. For performance, prove complexity, hot/cold placement, call frequency, multiplier + or fixed bound, and a materially better feasible implementation. +9. For public API/config claims, check every export, parser, default, deprecation path, + README example, ESM/CJS output implication, and supported Node version. +10. For test efficacy, prove the assertion reaches the change and would fail under the + claimed regression. Recompute expected hex/bytes rather than trusting fixtures. +11. Derive a fix only after admission, then verify it compiles and closes all admitted + paths without creating a compatibility, ownership, or retry defect. + +### Net user impact and ledger classification + +Before assigning severity, answer in order: + +- **Population:** named supported API/config/protocol/runtime population. +- **Delta vs base:** observed difference for the identical trigger. +- **Magnitude/frequency:** per cell, row, flush, request, Sender lifetime, or once. +- **Offsets:** validation, retry, server rejection, type/build gate, operational process, + or other containment before the user sees harm. +- **Net:** `net-negative`, `net-neutral`, or `net-positive`. Only net-negative behavioral + candidates may be findings. + +Classify ledger entries as: + +- **ADMITTED in-diff** — proved defect inside the diff. +- **ADMITTED out-of-diff-breakage** — proved unchanged caller broken by this PR's + changed contract. +- **OMITTED pre-existing/not-attributed** — same or worse behavior exists at base and + this PR does not expose a new path. +- **OMITTED false** — counterevidence disproves it. +- **OMITTED unverified** — required producer, path, observation, artifact, or dependency + is missing. + +Keep omitted candidates and disproofs private. A fully proved pre-existing bug may +become an adjacent issue draft; false or unverified candidates never do. Verify every +enumerated instance independently rather than sampling and generalizing. -**Agent 1 — Correctness & bugs:** `null`/`undefined`/omitted-column handling; `number` vs `bigint` (LONG beyond `2^53` silently loses precision even though `Number.isInteger` returns true; nanosecond timestamps require `bigint`); `Number.isInteger`/type-guard correctness; timestamp unit conversion (`timestampToMicros`/`timestampToNanos`, the v1-always-micros vs v2+-nanos rule); `NaN`/`Infinity` floats; ILP wire-format correctness across v1 (text), v2 (binary doubles + arrays), v3 (decimals); the column separator (leading space for the first column/symbol, `,` thereafter) staying correct when a column is omitted; `writeEscaped` covering every delimiter (space, `,`, `=`, `\n`, `\r`, `"`, `\`) in both quoted and unquoted modes; array validation (`getDimensions`/`validateArray` — irregular shape, non-homogeneous elements, empty arrays with a `null` element type); off-by-one and operator precedence. Cross-reference every changed symbol against its callsite inventory and verify the new behavior is correct at each callsite. +## Review checklists -**Agent 2 — Buffer & byte-encoding safety:** This is the memory-safety analog for a byte-buffer serializer — a mis-encoded or truncated buffer is silent data corruption on the wire. State the `Buffer.write`/`writeInt*` facts from 2.5e in the agent's first sentence and evaluate every finding under them. Flag every reachable instance of: +### Correctness and wire format -- **Capacity under-reservation:** every `write`/`writeByte`/`writeInt`/`writeDouble` must be covered by a preceding `checkCapacity(data, base)` whose `base` (raw bytes) plus the UTF-8 byte length of each string in `data` is **≥** the bytes actually emitted. An under-count causes a silent short write (corrupt/misaligned line) or a `RangeError`. Watch for escaping that expands a string (`\` doubling, delimiter escaping) beyond the reserved length, and for a type-suffix byte (`i` for int, `t`/`f` for boolean, marker bytes for v2/v3) not counted in `base`. -- **`writeByte` range:** `writeInt8` throws outside `-128..127`; a marker/entity/column-type byte in `128..255` must be sign-folded before `writeByte` (`byte -= 256`), not passed raw. -- **Little-endian encoding:** doubles (`writeDoubleLE`), int32 (`writeInt32LE`), and array dimension headers must be written in the byte order and width the server expects for v2/v3. -- **Buffer view vs copy:** `toBufferView` returns a `subarray` that **aliases** the live buffer — it becomes stale or corrupt after any further write and is test-only; `toBufferNew` returns a copy and **compacts** the source. A caller that holds a view across a mutation, or that expects `toBufferNew` not to mutate, is a bug. -- **Compaction & resize:** `compact()` does an overlapping self-copy (`buffer.copy(buffer, 0, endOfLastRow, position)`) — verify the ranges. `resize()` doubles until it fits, enforces `max_buf_size`, and copies old content — verify the growth loop terminates and the `max_buf_size` guard is not bypassed. -- **Two's-complement / big-endian decimal payloads** (`bigintToTwosComplementBytes`, v3): minimal-width sign-preserving encoding, scale/length bounds (`0..32` bytes, scale `0..76`), invalid-byte rejection. -- **Position accounting:** `position` must advance by exactly the bytes written; `write` relies on `buffer.write`'s return value, `writeByte`/`writeInt`/`writeDouble` on the `writeInt*` return. A path that advances `position` by an assumed rather than actual count corrupts everything after it. +- Nullish omission must not emit a separator or leave invalid row state. +- `number` LONG values beyond `2^53` lose precision; nanosecond timestamps require + `bigint`; v1 timestamps use microseconds while v2+ preserve nanoseconds. +- Reject or intentionally encode `NaN`, `Infinity`, invalid units, invalid types, and + unsupported protocol features. +- Verify table/symbol/column escaping for space, comma, equals, newline, carriage + return, quote, backslash, and multi-byte UTF-8. +- Validate irregular/non-homogeneous/empty arrays and v2 dimension/type bytes. +- Verify v3 decimal sign, scale, length, two's complement, and big-endian payload. -**Agent 3 — Transport, protocol negotiation & auth:** Check every network-facing path. Verify: -- **Protocol negotiation:** `resolveAuto` picks a version the server supports; `createBuffer` builds the matching serializer; TCP (which cannot negotiate) requires an explicit `protocol_version`. A serializer/version mismatch corrupts the wire. -- **HTTP retry & idempotency:** which status codes/errors are retriable; whether `retry_timeout` and backoff are honored; and — critically — whether re-sending the same buffer after an **uncertain** send (server received it but the response was lost) can **duplicate rows**. Confirm retries are confined to cases where the server has not durably accepted the data. -- **Undici vs stdlib parity:** `UndiciTransport` and `HttpTransport` must apply the same auth, TLS, timeout, and retry behavior — flag any divergence. -- **Auth:** HTTP Basic (`username`/`password`) and Bearer (`token`) build the `Authorization` header correctly; TCP JWK challenge-response signs correctly. **Credentials must never appear in log output, error messages, or thrown `Error` strings.** -- **TLS:** `tls_verify`/`tls_ca`/`tls_roots` wired correctly; verification is only disabled when explicitly requested; custom CA/roots actually applied. -- **Timeouts & lifecycle:** `request_timeout`/`retry_timeout` enforced; `connect`/`close` are only called on TCP transports (HTTP transports must no-op or reject per the interface docs). +### Buffer and byte safety -**Agent 4 — Async, concurrency & flush semantics:** Verify: -- **Every Promise is awaited.** A missing `await` on `flush`/`send`/`connect`/`tryFlush` yields an unhandled rejection, out-of-order sends, or a lost error. The builder `at`/`atNow` are `async` (they may auto-flush) — callers must await them. -- **Flush data-loss window:** `Sender.flush` calls `buffer.toBufferNew()` which **compacts (discards) the rows before** `await transport.send(...)`. If the send rejects, those rows are already gone and are **not re-queued**. Confirm the change does not widen this window or drop data on a new error path; flag if a fix is expected to preserve data on failure but doesn't. -- **Auto-flush semantics:** both the row-count (`auto_flush_rows`) and interval (`auto_flush_interval`) triggers are evaluated **lazily inside `tryFlush`** on each `at`/`atNow`. There is **no background timer** — a producer that stops adding rows never auto-flushes on the interval alone. Verify any change respects this (and does not, e.g., assume a timer fires). -- **Concurrency:** the `Sender`/`SenderBuffer` hold mutable buffer state and are **not** safe for concurrent row building; each worker thread needs its own `Sender` (per `README.md`). Flag any change that invites shared use or interleaves buffer mutation across awaits within one Sender. +- Every write has capacity for actual escaped UTF-8 bytes and suffix/marker bytes. +- `writeInt8` values stay in `-128..127`; sign-fold unsigned marker bytes. +- Doubles, int32 values, and dimensions use correct little-endian width/order. +- Do not retain `toBufferView` across mutation; account for `toBufferNew` compaction. +- Verify overlapping compact copies, growth termination, `max_buf_size`, and exact + `position` advancement. -**Agent 5 — Resource management & lifecycle:** Leaks and dangling handles on all code paths (especially errors). Check: -- **Transport teardown:** `close()` releases the TCP socket / the Undici pool/agent. A Sender-owned agent must be destroyed on close; a **user-supplied `agent`** (passed via `extraOptions`) must **not** be destroyed by the Sender. -- **Timers & aborts:** `fetchJson` pairs `setTimeout`/`AbortController` with `clearTimeout` in a `finally` — verify any new async network helper does the same and cannot leak a timer or an un-aborted request. -- **Error-path cleanup:** a failed `connect`/`send`/TLS handshake must not leave a half-open socket, an un-freed pool, or a listener attached. -- **Buffer lifecycle:** the internal `Buffer` is reused across rows; verify `reset`/`compact` leave it in a consistent state and nothing retains a stale `subarray` view. +### Transport, protocol, auth, and TLS -Walk every callsite from 2.5b that constructs, owns, or transfers a transport/socket/agent and verify cleanup on success, error, and early-return paths. +- Negotiated serializer matches the server; TCP requires an explicit version. +- Retriable classification, backoff, and time budgets are correct; uncertain replay + cannot silently duplicate accepted rows. +- Undici and stdlib HTTP agree on auth, TLS, timeout, retry, and response handling. +- Basic/Bearer/JWK credentials are correct and never logged or included in errors. +- Verification is disabled only explicitly; custom CA/roots are applied. -**Agent 6 — Performance & allocations:** The hot path is the per-row buffer build (`table`/`symbol`/`*Column`/`at`/`atNow`) and, for wide rows, the per-cell inner work. Flag: per-row/per-cell allocations that should be amortized; `value.toString()` churn; string concatenation on the write path; repeated `Buffer.byteLength` re-scans of the same string; per-character `buffer.write` in `writeEscaped` where a bulk path exists; buffer `resize` thrashing (the doubling strategy repeatedly copying a large buffer); needless `Buffer` copies. Analyze scaling: millions of rows per flush, wide rows, large arrays. Setup-path costs (Sender construction, `resolveAuto`'s HTTP round-trip, config parsing) are acceptable; per-row/per-cell costs are not. +### Async, concurrency, and resources -**Agent 7 — Test review & coverage (adversarial):** Coverage gaps *and* test efficacy. Check: -- **Coverage** across the matrix: protocol versions (v1/v2/v3), transports (Undici HTTP, stdlib HTTP, TCP/TCPS), auth methods (Basic/Bearer/JWK), TLS, auto-flush (row-count and interval), buffer resize and `max_buf_size`, escaping, `null`/`undefined`, empty arrays, `bigint`/`number`, `NaN`/`Infinity`, timestamp units, retry/error paths. -- **Test files:** unit (`test/sender.buffer.test.ts`, `sender.config.test.ts`, `sender.transport.test.ts`, `options.test.ts`, `utils.decimal.test.ts`, `logging.test.ts`), integration against a real QuestDB via TestContainers (`test/sender.integration.test.ts`), and mock helpers (`test/util/mockhttp.ts`, `mockproxy.ts`, `proxy.ts`). -- **Byte-level assertions:** buffer tests assert exact bytes via the `bufferContentHex`/`toHex` helpers. Verify the expected hex actually encodes the intended wire bytes (separators, escaping, marker bytes, little-endian payloads) — a test that asserts stale or hand-mis-computed bytes locks in a bug. -- **Efficacy:** flag assertions that cannot fail, tests whose assertion passes whether or not the production change is present (trace the data flow from the changed symbol to the assertion), and happy-path-only tests with no error/`null`/edge coverage the change introduced. -- **Regression tests:** if the PR fixes a bug, a test must reproduce it and fail without the fix. +- Await every Promise; preserve send order and error propagation. +- Understand that compaction precedes the awaited send and auto-flush is lazy. +- Do not invite concurrent mutation or share a Sender across workers. +- Close owned sockets/pools/agents/timers/listeners on every path; preserve user-owned + agents; do not retain stale buffer views. -Cross-reference 2.5d: every cross-context exposure should have a test that exercises the changed symbol from that context. A new wire-format path without a byte-level assertion, or a new transport/auth path without a transport test, is a high-priority finding. +### Performance -**Agent 8 — Code quality & API design:** Public API ergonomics and consistency. The public surface is what `src/index.ts` re-exports — a new public symbol must be exported there, and a removed/renamed one is a breaking change. Verify TSDoc on public classes/methods (the repo uses `@microsoft/tsdoc`); TypeScript types are accurate and not laundered through unsound casts (`as unknown as ...`) that hide real type errors (recall `strictNullChecks` is off, so casts and non-null assumptions are not caught by the compiler); backward compatibility of the `Sender`/`SenderBuffer`/`SenderOptions` API (renamed/removed methods, changed defaults, renamed options must go through `resolveDeprecated` with a warning); `README.md`/`docs` updated for user-visible changes; no dead code or unused `import`s; ESLint (`typescript-eslint` recommended set) and Prettier (`.prettierrc`) clean; naming and member ordering consistent with the surrounding code. +- Avoid per-row/per-cell allocations, repeated `toString`/`Buffer.byteLength`, string + concatenation, and avoidable conversions/scans. +- Avoid per-character writes where safe bulk copying exists, resize thrashing, needless + buffer copies, and O(n²) work over rows/cells/array elements. +- State the data multiplier for hot-path findings; bounded setup costs are Moderate at + most unless they create an outage or compatibility failure. + +### Public API and code quality + +- Export new public symbols; treat removals/renames/signature/default changes as + compatibility changes. +- Keep TSDoc/types accurate and avoid casts that hide runtime null/type problems. +- Wire renamed options through parsing, validation, `resolveDeprecated`, `resolveAuto`, + `fromConfig`, and `fromEnv` as applicable. +- Update README/examples for user-visible behavior. +- Keep ESLint/Prettier clean; remove dead code/imports; follow local naming/order. + +### Tests + +- Cover each changed protocol/transport/auth/TLS/configuration path that behaves + differently, plus error, nullish, boundary, resize, retry, and cleanup paths. +- Use byte-level assertions for serializer changes and transport-level assertions for + network/auth changes. +- Recompute expected hex/bytes and ensure assertions can fail and reach production code. +- A bug fix needs a regression test that fails without the fix unless the Step 2.6 + proportionality analysis admits a non-Critical gap. +- Prefer existing helpers and deterministic synchronization; avoid brittle timing, + debug residue, misleading names, and implementation-only assertions. + +### TODOs and commit messages + +- Scan added/changed lines for `TODO`, `FIXME`, `HACK`, `XXX`, and `WORKAROUND`. + Distinguish moved comments from newly deferred work and verify referenced issues. +- Check Conventional Commit subjects against `CONTRIBUTING.md`; descriptions should + state user impact where relevant. -**Agent 9 — Cross-context caller impact:** Walk the callsite inventory from 2.5b. For every callsite, fetch the surrounding code (the calling function plus its callers up two levels) and answer: +## Step 4: Output -- Does this caller pass inputs the new behavior handles incorrectly (`null`/`undefined`, `bigint` vs `number`, an empty array, a delimiter-containing string)? -- Does this caller depend on a contract from the implicit contract list (2.5c) that the change broke — the old capacity reservation, the old buffer state-machine transition, the old sync/async shape, the old set of thrown errors, the old wire bytes? -- Is this caller in a context (the per-row hot path, a v1/v2/v3 subclass, one of the four transports, the flush/auto-flush path, an error/retry path, a worker thread) where the new behavior misbehaves even if the inputs are valid? -- For a changed `SenderBufferBase` member: do the `SenderBufferV1`/`V2`/`V3` overrides and inherited callers still satisfy the new contract? -- For a changed transport-interface method: do `UndiciTransport`, `HttpTransport`, and `TcpTransport` all still satisfy it? -- For a changed config option: do `resolveAuto`, `resolveDeprecated`, the parser, and every reader agree on name/default/validation? +Present only **ADMITTED** findings. Omitted hypotheses, disproofs, retractions, agent +counts, candidate counts, and the private ledger never appear. Do not publish a concern +and retract it later. Keep the report actionable; if a normal PR produces more than +about seven findings, rerun admission and remove dependent, duplicate, not-attributed, +or low-value items. -This agent's output is structured per callsite, not per failure mode. Each callsite gets a verdict: SAFE / BROKEN / NEEDS VERIFICATION. Every BROKEN entry is a P0 finding regardless of whether the file is in the diff. +Every Critical and Moderate finding begins with three lines written from the completed +admission form: -This agent is not optional even when the diff is small. Small diffs to widely-used symbols (`writeColumn`, `checkCapacity`, `Sender.flush`, a transport method, a base-class member) have the largest blast radius. +- **Problem:** what is wrong, at most 12 words. +- **Net impact:** supported population and magnitude, at most 12 words. +- **Evidence:** decisive artifact/static proof and reviewed revision identity. -**Agent 10 — Fresh-context adversarial:** Dispatched separately from agents 1-9 to escape checklist anchoring. This agent operates under different rules from the rest: +Then provide only the minimal producer → path → symptom trace, base comparison, exact +file/line, in-diff versus out-of-diff-breakage classification, and suggested fix. -- It receives ONLY the PR diff and the names of the changed files. It does NOT receive the change surface map from Step 2.5, the implicit contract list, the cross-context exposure list, or any of the review checklists below. -- Its sole instruction: "find ways this code is wrong". No category list, no failure-mode taxonomy, no project-specific style guide. -- It is free to use Read, Grep, and Glob to explore the repository however it wants. -- Findings are not pre-classified by category. Each finding states: what's wrong, why it's wrong, and the code path that demonstrates it. +### Severity classification -The point of this agent is to surface bugs the structured agents cannot see because they are reasoning inside the same frame. A finding here that none of agents 1-9 produced is high signal — it means the structured review missed it. A finding here that overlaps with agents 1-9 is corroboration. +Severity is determined by reachable user consequence, not checklist category. -Run this agent in parallel with agents 1-9. It is mandatory regardless of diff size. +**Critical** requires a supported trigger and one of: -Combine all agent findings into a single deduplicated **draft** report. Do NOT present this draft to the user yet — it goes straight into verification. +- Wrong/missing/duplicated/corrupted data or ILP wire bytes. +- Crash, hang, outage, unbounded loop, OOM, or unbounded socket/timer/listener leak. +- Credential exposure, auth/TLS bypass, or another security failure. +- Silent/misleading failure that makes ingestion appear successful or undiagnosable. +- Public API, config, runtime, module-system, protocol, or rolling-version compatibility + break affecting existing supported consumers. +- User-observable throughput/latency/network regression multiplied per row/cell/request. +- An admitted Critical coverage gap meeting Step 2.6's full reachability/impact burden. -## Step 3b: Verify every finding against source code +Every behavioral Critical must complete: “user does X → sees Y,” with an executed +same-trigger base comparison. A performance Critical states the multiplier. A theory +without a supported trigger is omitted, not preserved as Moderate. -The parallel review agents work from the diff plus the change surface map and frequently produce false positives — especially around buffer capacity math, the row state machine, protocol-version fan-out, async/await, and retry idempotency. Every finding MUST be verified before it is reported. +**Moderate** covers admitted attributable issues with bounded/developer-facing impact: +proved weak tests, missing internal-path coverage, documentation defects, concrete +standards violations, or bounded setup/configuration costs. Dynamic speculation and +unchanged hardening opportunities are omitted. -For each finding in the draft report: +**Minor** covers concrete cosmetics on changed lines: naming, ordering, formatting, +or comment wording. -1. **Read the actual source code** at the exact lines cited (in `src/**/*.ts`, never the generated `dist/**` output). Do not rely on the agent's description alone. -2. **Trace the full code path:** follow callers and overrides. Remember the inheritance fan-out — a method called on a `SenderBuffer` reference may dispatch to `SenderBufferV1`/`V2`/`V3`; a transport call dispatches to Undici/stdlib/TCP. -3. **For capacity/byte-encoding claims:** count the bytes actually written against the `checkCapacity(data, base)` reservation, accounting for UTF-8 multi-byte expansion and escaping. Confirm the direction of the error (under-reservation corrupts/throws; over-reservation is harmless). A claim that the reservation is wrong is a false positive if the arithmetic actually covers the writes. -4. **For `null`/`undefined` claims:** since `strictNullChecks` is off, verify at the *runtime* level — trace whether a caller can actually pass the nullish value and what the code does with it, not what the type says. -5. **For wire-format claims:** reconstruct the expected byte sequence for the relevant protocol version and compare against what the code emits and what the byte-level test asserts. -6. **For flush/data-loss and async claims:** re-read `Sender.flush`/`tryFlush` and confirm the ordering of `toBufferNew` (compaction) vs `await transport.send`, and whether the claimed loss/duplication is reachable on the cited path. -7. **For retry/idempotency claims:** trace which errors/status codes trigger a resend and whether the server could have durably accepted the data before the resend — only a resend after durable acceptance duplicates rows. -8. **For resource-leak claims:** trace every socket/agent/timer to its close/clear on all paths (success, error, early return), and confirm a user-supplied `agent` is *not* destroyed by the Sender. -9. **For performance claims:** confirm the cost is on the per-row/per-cell hot path and material relative to the surrounding work/I-O. Downgrade negligible savings to a nit. Exception: a per-row allocation on the buffer-build path is always worth flagging. -10. **For cross-context findings (Agent 9):** re-read the callsite in full, including callers up two levels, and confirm the broken behavior is reachable from production or from tests users will exercise. -11. **For test-efficacy findings (Agent 7):** re-read the cited assertion in full context and confirm it truly cannot fail or truly fails to reach the change — a "vacuous assertion" claim is a false positive if the production code actually recomputes the asserted value; a "wrong hex" claim requires reconstructing the correct bytes. +Exclude merge mechanics, tautologies true of every similar PR, deliberate project +decisions without evidence they are wrong, generated artifacts as source, and all +contents behind `OPAQUE` submodule gitlink bumps. -**Classify each finding** as: -- **CONFIRMED in-diff** — the bug is real and inside the diff -- **CONFIRMED at out-of-diff callsite** — the bug is in an unchanged file because the changed symbol is used there in a way that's now broken (cite the file and the contract from 2.5c that was violated) -- **FALSE POSITIVE** — the code is actually correct (explain why) -- **CONFIRMED with nuance** — the issue exists but is less severe than stated (explain) +### Critical -**Move false positives to a separate "Downgraded" section** at the end of the report. For each, give a one-line explanation of why it was dismissed. This lets the PR author verify the reasoning and catch verification mistakes. +List blocking admitted issues in descending user impact. Include the three summary +lines, population/base delta/magnitude/offsets/net-negative determination, exact file +and lines, supported trigger and symptom, executed artifacts, classification, contract +and caller for out-of-diff breakage, and a fix scoped to this PR. -Launch verification agents in parallel where findings are independent. Each verification agent should read surrounding source files, not just the diff. +### Moderate -## Review checklists +List non-blocking admitted issues with the three summary lines and decisive evidence. -Review the diff for: - -### Correctness & bugs -- `null`/`undefined`/omitted-column handling at API boundaries (and remember `strictNullChecks` is off — the compiler didn't check it) -- Edge cases and error paths -- `number` vs `bigint`: LONG values beyond `2^53` silently lose precision though `Number.isInteger` returns true; nanosecond timestamps require `bigint` -- Float edge cases (`NaN`, `Infinity`); timestamp unit conversions (v1 truncates ns→us; v2+ preserves ns) -- Correct ILP wire format (v1 text / v2 binary / v3 decimals): column separators, escaping, little-endian payloads, array headers, marker bytes -- Array validation: irregular shape, non-homogeneous elements, empty arrays (element type `null`), `null`/`undefined` arrays omitted (not written as a NULL marker) -- Logic errors, off-by-one, wrong operator precedence - -### Buffer & byte-encoding safety -- Every `write*` covered by a `checkCapacity` that reserves ≥ the bytes emitted (account for escaping expansion and the type-suffix/marker byte) -- `writeByte`/`writeInt8` values within `-128..127` (sign-fold `128..255`) -- Little-endian doubles/int32/dimension headers match the server's expectation -- `toBufferView` (aliasing, test-only) not held across a mutation; `toBufferNew` (copy + compact) callers aware it mutates the source -- `compact()` overlapping self-copy ranges correct; `resize()` growth terminates and respects `max_buf_size` -- Two's-complement/big-endian decimal payloads and their bounds (unscaled `0..32` bytes, scale `0..76`) -- `position` advanced by the actual bytes written, never an assumed count - -### Transport, protocol & auth -- Serializer matches the negotiated protocol version; TCP has an explicit version -- Retriable vs non-retriable classification correct; a retry after uncertain acceptance cannot duplicate rows -- Undici and stdlib HTTP transports behave identically (auth, TLS, timeouts, retry) -- Auth headers/JWK signing correct; credentials never logged, thrown, or otherwise leaked -- TLS verification only disabled when explicitly requested; custom CA/roots applied -- `request_timeout`/`retry_timeout` enforced; `connect`/`close` only meaningful on TCP - -### Async, concurrency & resources -- Every Promise awaited; `at`/`atNow` awaited by callers; no unhandled rejection -- Flush ordering understood: rows are compacted out of the buffer before the awaited send, so a send failure loses them unless explicitly handled -- Auto-flush is lazy (no background timer); the interval only fires on the next `at`/`atNow` -- One `Sender` per worker thread; no shared buffer mutation across awaits -- Sockets/pools/agents/timers released on all paths; a user-supplied `agent` is not destroyed by the Sender +### Minor -### Performance -- No per-row/per-cell allocations, `toString` churn, string concatenation, or repeated `Buffer.byteLength` scans on the buffer-build path that belong hoisted to setup -- No buffer `resize` thrashing or needless `Buffer` copies -- No O(n²) over rows/cells at realistic scale (millions of rows, wide rows, large arrays) -- Setup-path cost (construction, `resolveAuto`, config parsing) acceptable; per-row cost is not - -### Code quality & API design -- New public symbols exported from `src/index.ts`; removed/renamed ones treated as breaking and called out -- TSDoc on public classes/methods; types accurate and not laundered through unsound `as` casts -- Backward compatibility: renamed options wired through `resolveDeprecated` with a warning; changed defaults intentional and documented -- `README.md`/`docs` updated for user-visible changes -- No dead code or unused imports; ESLint and Prettier clean; naming/ordering consistent - -### Test review -- **Coverage gaps:** every new/changed path (per protocol version, transport, auth method) has a test; flag missing ones explicitly as "missing test for X" -- **Cross-context coverage:** every entry in 2.5d has a test exercising the changed symbol from that context — especially a new wire-format path (byte-level assertion) or a new transport/auth path (transport/integration test) -- **Byte-level assertions** (`bufferContentHex`/`toHex`) encode the intended wire bytes, not stale/hand-mis-computed ones -- **Error-path coverage:** connection drops, 5xx, retries, TLS/auth failures, buffer overflow vs `max_buf_size`, invalid inputs — not just the happy path -- **Edge-case tests:** `null`/`undefined`, empty and irregular arrays, zero-length and delimiter-containing strings, boundary integers, `bigint`, `NaN`/`Infinity`, each timestamp unit -- **Efficacy:** assertions can actually fail and actually reach the changed code; no happy-path-only gaps -- **Regression tests:** a bug fix has a test that reproduces the bug and fails without the fix - -### Unresolved TODOs and FIXMEs -- Scan the diff for `TODO`, `FIXME`, `HACK`, `XXX`, `WORKAROUND`. For each: - - Pre-existing (just moved/reformatted) or newly introduced in this PR? - - If new: unfinished work that should block merge, or an acceptable known limitation? Flag deferred bugs or incomplete implementations. - - If it references a ticket/issue, verify the reference exists. - -### Commit messages -- Conventional Commits `type(scope): description` (per `CONTRIBUTING.md`) -- Clear, descriptive; end-user impact in the body where relevant +List optional, concrete cosmetics. Omit the section when empty. -## Step 4: Output +### Adjacent findings (not blocking — file as GitHub issues) -Present ONLY verified findings (false positives are excluded from Critical/Moderate/Minor). Structure as: +Include only fully proved pre-existing bugs encountered in changed files or mapped +callers that this PR does not introduce, expose, or worsen. They never affect the +verdict and are never proposed as changes to this PR. For each provide: -### Critical -Issues that must be fixed before merge. Each must include: -- Exact file path and line numbers (including out-of-diff files) -- Whether the finding is **in-diff** or **out-of-diff** -- Code path trace showing why the bug is real -- For out-of-diff findings: the contract from 2.5c that was violated and the callsite that triggers it -- Suggested fix +- **Problem:** issue-title-length summary. +- **Net impact:** population and magnitude. +- **Location:** exact file and lines. +- **Symptom/reachability:** observed path, or named guard if latent. +- **Suggested fix:** one or two lines. +- **Standalone severity:** Critical, Moderate, or Minor. -### Moderate -Issues worth addressing but not blocking. +Offer to file them; never file without permission. -### Minor -Style nits and suggestions. +### Coverage map -### Downgraded (false positives) -Findings from the initial review that were dismissed after source code verification. For each, state: -- The original claim (one line) -- Why it was dismissed (one line, citing the specific code that disproves it) +State the test-gate result and number of admitted coverage gaps. Render admitted gap +rows with their recorded search and failure link. Do not expose covered/accepted/exempt +rows or omitted-candidate counts unless asked. ### Summary -- One-line verdict: approve, request changes, or needs discussion -- Highlight any regressions or tradeoffs -- State how many draft findings were verified vs dropped as false positives (e.g., "8 findings verified, 4 false positives removed") -- State the in-diff vs out-of-diff split (e.g., "5 findings in-diff, 3 findings out-of-diff"). If the diff is non-trivial and out-of-diff is zero, the cross-context pass likely underran — re-invoke Agent 9 with a wider grep before finalizing. + +Choose exactly one verdict: + +- **approve** — no open Critical findings and the test gate passes. +- **approve with comments** — both gates pass, but named Moderate items remain. +- **request changes** — at least one Critical finding is open or the test gate fails. +- **needs discussion** — product, architecture, or compatibility decision is required. + +Apply these hard gates: + +- **Correctness gate:** any admitted Critical requires `request changes`. Omitted + hypotheses never affect the verdict. +- **Test gate:** fails only for admitted Critical coverage gaps. Zero test changes or + missing regression coverage alone does not fail it. +- Before finalizing, re-audit each rendered behavioral finding for strongest disproof, + supported producer, independent falsifier context, dynamic head/base evidence, + dependency survival, net-negative user impact, and post-admission severity. +- If both gates pass, approve plainly; Moderate/Minor items do not justify withholding + approval. + +Also state: + +- Test-gate result and admitted gap count. +- Regressions or tradeoffs. +- Submodule verdicts (`path: OPAQUE — contents excluded`) or `Submodules: none`. +- Admitted split: in-diff / out-of-diff-breakage. +- Severity distribution. +- At levels 0-1, the callsite-analysis limitation rather than implying exhaustive + out-of-diff coverage. + +Do not state agent counts, candidate counts, rejected/false-positive counts, or +retraction history. From 757fe1776a2452a3fe42a8ceea41edba1482703c Mon Sep 17 00:00:00 2001 From: glasstiger Date: Mon, 17 Aug 2026 14:47:30 +0100 Subject: [PATCH 004/265] feat(qwp): add browser-safe protocol core --- package.json | 25 +- src/qwp/core/bytes.ts | 307 +++++++++++++++++ src/qwp/core/constants.ts | 92 +++++ src/qwp/core/egress.ts | 237 +++++++++++++ src/qwp/core/errors.ts | 7 + src/qwp/core/frame.ts | 72 ++++ src/qwp/core/gorilla.ts | 101 ++++++ src/qwp/core/index.ts | 10 + src/qwp/core/ingress.ts | 554 ++++++++++++++++++++++++++++++ src/qwp/core/symbol-dictionary.ts | 47 +++ src/qwp/core/table.ts | 211 ++++++++++++ src/qwp/core/varint.ts | 85 +++++ src/qwp/index.ts | 9 + test/qwp/core.test.ts | 261 ++++++++++++++ tsconfig.qwp-browser.json | 12 + 15 files changed, 2024 insertions(+), 6 deletions(-) create mode 100644 src/qwp/core/bytes.ts create mode 100644 src/qwp/core/constants.ts create mode 100644 src/qwp/core/egress.ts create mode 100644 src/qwp/core/errors.ts create mode 100644 src/qwp/core/frame.ts create mode 100644 src/qwp/core/gorilla.ts create mode 100644 src/qwp/core/index.ts create mode 100644 src/qwp/core/ingress.ts create mode 100644 src/qwp/core/symbol-dictionary.ts create mode 100644 src/qwp/core/table.ts create mode 100644 src/qwp/core/varint.ts create mode 100644 src/qwp/index.ts create mode 100644 test/qwp/core.test.ts create mode 100644 tsconfig.qwp-browser.json diff --git a/package.json b/package.json index 2290156..a3082f9 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "build": "bunchee", "eslint": "eslint src/**", "typecheck": "tsc --noEmit", + "typecheck:qwp-browser": "tsc --noEmit -p tsconfig.qwp-browser.json", "format": "prettier --write '{src,test}/**/*.{ts,js,json}'", "docs": "typedoc --out docs src/index.ts", "preview:docs": "serve docs" @@ -19,13 +20,25 @@ "module": "dist/es/index.mjs", "types": "dist/cjs/index.d.ts", "exports": { - "import": { - "types": "./dist/es/index.d.mts", - "default": "./dist/es/index.mjs" + ".": { + "import": { + "types": "./dist/es/index.d.mts", + "default": "./dist/es/index.mjs" + }, + "require": { + "types": "./dist/cjs/index.d.ts", + "default": "./dist/cjs/index.js" + } }, - "require": { - "types": "./dist/cjs/index.d.ts", - "default": "./dist/cjs/index.js" + "./qwp": { + "import": { + "types": "./dist/es/qwp/index.d.mts", + "default": "./dist/es/qwp/index.mjs" + }, + "require": { + "types": "./dist/cjs/qwp/index.d.ts", + "default": "./dist/cjs/qwp/index.js" + } } }, "repository": { diff --git a/src/qwp/core/bytes.ts b/src/qwp/core/bytes.ts new file mode 100644 index 0000000..eb67ae3 --- /dev/null +++ b/src/qwp/core/bytes.ts @@ -0,0 +1,307 @@ +import { QwpProtocolError } from "./errors"; + +const UTF8_ENCODER = new TextEncoder(); +const UTF8_DECODER = new TextDecoder("utf-8", { fatal: true }); + +export function encodeUtf8(value: string): Uint8Array { + return UTF8_ENCODER.encode(value); +} + +export function utf8Length(value: string): number { + return encodeUtf8(value).length; +} + +export function decodeUtf8(value: Uint8Array): string { + try { + return UTF8_DECODER.decode(value); + } catch (error) { + throw new QwpProtocolError( + `invalid UTF-8 payload: ${(error as Error).message}`, + ); + } +} + +export function concatBytes(parts: readonly Uint8Array[]): Uint8Array { + let length = 0; + for (const part of parts) length += part.length; + const result = new Uint8Array(length); + let offset = 0; + for (const part of parts) { + result.set(part, offset); + offset += part.length; + } + return result; +} + +function checkedLength(value: number, label: string): number { + if (!Number.isSafeInteger(value) || value < 0) { + throw new RangeError(`${label} must be a non-negative safe integer`); + } + return value; +} + +/** A growable, runtime-neutral little-endian byte writer. */ +export class QwpByteWriter { + private bytes: Uint8Array; + private view: DataView; + private cursor = 0; + + constructor(initialCapacity = 128) { + checkedLength(initialCapacity, "initialCapacity"); + this.bytes = new Uint8Array(Math.max(initialCapacity, 1)); + this.view = new DataView(this.bytes.buffer); + } + + get length(): number { + return this.cursor; + } + + private ensure(additional: number): void { + checkedLength(additional, "additional byte count"); + const required = this.cursor + additional; + if (required <= this.bytes.length) return; + let capacity = this.bytes.length; + while (capacity < required) capacity = Math.max(capacity * 2, required); + const next = new Uint8Array(capacity); + next.set(this.bytes.subarray(0, this.cursor)); + this.bytes = next; + this.view = new DataView(next.buffer); + } + + writeUint8(value: number): this { + this.ensure(1); + this.view.setUint8(this.cursor, value); + this.cursor++; + return this; + } + + writeInt8(value: number): this { + this.ensure(1); + this.view.setInt8(this.cursor, value); + this.cursor++; + return this; + } + + writeUint16(value: number): this { + this.ensure(2); + this.view.setUint16(this.cursor, value, true); + this.cursor += 2; + return this; + } + + writeInt16(value: number): this { + this.ensure(2); + this.view.setInt16(this.cursor, value, true); + this.cursor += 2; + return this; + } + + writeUint32(value: number): this { + this.ensure(4); + this.view.setUint32(this.cursor, value, true); + this.cursor += 4; + return this; + } + + writeInt32(value: number): this { + this.ensure(4); + this.view.setInt32(this.cursor, value, true); + this.cursor += 4; + return this; + } + + writeBigUint64(value: bigint): this { + this.ensure(8); + this.view.setBigUint64(this.cursor, BigInt.asUintN(64, value), true); + this.cursor += 8; + return this; + } + + writeBigInt64(value: bigint): this { + this.ensure(8); + this.view.setBigInt64(this.cursor, BigInt.asIntN(64, value), true); + this.cursor += 8; + return this; + } + + writeFloat32(value: number): this { + this.ensure(4); + this.view.setFloat32(this.cursor, value, true); + this.cursor += 4; + return this; + } + + writeFloat64(value: number): this { + this.ensure(8); + this.view.setFloat64(this.cursor, value, true); + this.cursor += 8; + return this; + } + + writeBytes(value: Uint8Array): this { + this.ensure(value.length); + this.bytes.set(value, this.cursor); + this.cursor += value.length; + return this; + } + + writeUtf8(value: string): this { + return this.writeBytes(encodeUtf8(value)); + } + + writeZeroes(count: number): this { + this.ensure(count); + this.bytes.fill(0, this.cursor, this.cursor + count); + this.cursor += count; + return this; + } + + patchUint8(offset: number, value: number): void { + if (offset < 0 || offset >= this.cursor) { + throw new RangeError(`patch offset ${offset} is outside written bytes`); + } + this.view.setUint8(offset, value); + } + + patchUint16(offset: number, value: number): void { + if (offset < 0 || offset + 2 > this.cursor) { + throw new RangeError(`patch offset ${offset} is outside written bytes`); + } + this.view.setUint16(offset, value, true); + } + + patchUint32(offset: number, value: number): void { + if (offset < 0 || offset + 4 > this.cursor) { + throw new RangeError(`patch offset ${offset} is outside written bytes`); + } + this.view.setUint32(offset, value, true); + } + + toUint8Array(): Uint8Array { + return this.bytes.slice(0, this.cursor); + } +} + +/** A bounds-checked, runtime-neutral little-endian byte reader. */ +export class QwpByteReader { + private readonly view: DataView; + private cursor: number; + private readonly end: number; + + constructor( + readonly bytes: Uint8Array, + offset = 0, + length = bytes.length - offset, + ) { + checkedLength(offset, "offset"); + checkedLength(length, "length"); + if (offset + length > bytes.length) { + throw new QwpProtocolError("reader range exceeds payload length"); + } + this.cursor = offset; + this.end = offset + length; + this.view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + } + + get position(): number { + return this.cursor; + } + + get remaining(): number { + return this.end - this.cursor; + } + + private ensureAvailable(length: number, label: string): void { + if (length < 0 || this.cursor + length > this.end) { + throw new QwpProtocolError( + `truncated QWP payload while reading ${label}`, + ); + } + } + + readUint8(label = "uint8"): number { + this.ensureAvailable(1, label); + return this.view.getUint8(this.cursor++); + } + + readInt8(label = "int8"): number { + this.ensureAvailable(1, label); + return this.view.getInt8(this.cursor++); + } + + readUint16(label = "uint16"): number { + this.ensureAvailable(2, label); + const value = this.view.getUint16(this.cursor, true); + this.cursor += 2; + return value; + } + + readInt16(label = "int16"): number { + this.ensureAvailable(2, label); + const value = this.view.getInt16(this.cursor, true); + this.cursor += 2; + return value; + } + + readUint32(label = "uint32"): number { + this.ensureAvailable(4, label); + const value = this.view.getUint32(this.cursor, true); + this.cursor += 4; + return value; + } + + readInt32(label = "int32"): number { + this.ensureAvailable(4, label); + const value = this.view.getInt32(this.cursor, true); + this.cursor += 4; + return value; + } + + readBigUint64(label = "uint64"): bigint { + this.ensureAvailable(8, label); + const value = this.view.getBigUint64(this.cursor, true); + this.cursor += 8; + return value; + } + + readBigInt64(label = "int64"): bigint { + this.ensureAvailable(8, label); + const value = this.view.getBigInt64(this.cursor, true); + this.cursor += 8; + return value; + } + + readFloat32(label = "float32"): number { + this.ensureAvailable(4, label); + const value = this.view.getFloat32(this.cursor, true); + this.cursor += 4; + return value; + } + + readFloat64(label = "float64"): number { + this.ensureAvailable(8, label); + const value = this.view.getFloat64(this.cursor, true); + this.cursor += 8; + return value; + } + + readBytes(length: number, label = "bytes"): Uint8Array { + checkedLength(length, "byte length"); + this.ensureAvailable(length, label); + const value = this.bytes.subarray(this.cursor, this.cursor + length); + this.cursor += length; + return value; + } + + readUtf8(length: number, label = "UTF-8 string"): string { + return decodeUtf8(this.readBytes(length, label)); + } + + expectEnd(label = "QWP payload"): void { + if (this.remaining !== 0) { + throw new QwpProtocolError( + `${label} has ${this.remaining} unexpected trailing byte(s)`, + ); + } + } +} diff --git a/src/qwp/core/constants.ts b/src/qwp/core/constants.ts new file mode 100644 index 0000000..8ce17f8 --- /dev/null +++ b/src/qwp/core/constants.ts @@ -0,0 +1,92 @@ +/** ASCII `QWP1`, represented as its little-endian uint32 value. */ +export const QWP_MAGIC = 0x31505751; +export const QWP_VERSION = 1; +export const QWP_HEADER_SIZE = 12; + +export const QWP_FLAG_DEFER_COMMIT = 0x01; +export const QWP_FLAG_GORILLA = 0x04; +export const QWP_FLAG_DELTA_SYMBOL_DICTIONARY = 0x08; +export const QWP_FLAG_ZSTD = 0x10; + +export const QWP_COLUMN_TYPE = { + BOOLEAN: 0x01, + BYTE: 0x02, + SHORT: 0x03, + INT: 0x04, + LONG: 0x05, + FLOAT: 0x06, + DOUBLE: 0x07, + SYMBOL: 0x09, + TIMESTAMP: 0x0a, + DATE: 0x0b, + UUID: 0x0c, + LONG256: 0x0d, + GEOHASH: 0x0e, + VARCHAR: 0x0f, + TIMESTAMP_NANOS: 0x10, + DOUBLE_ARRAY: 0x11, + LONG_ARRAY: 0x12, + DECIMAL64: 0x13, + DECIMAL128: 0x14, + DECIMAL256: 0x15, + CHAR: 0x16, + BINARY: 0x17, + IPV4: 0x18, +} as const; + +export type QwpColumnType = + (typeof QWP_COLUMN_TYPE)[keyof typeof QWP_COLUMN_TYPE]; + +export const QWP_ENCODING_UNCOMPRESSED = 0x00; +export const QWP_ENCODING_GORILLA = 0x01; + +export const QWP_STATUS = { + OK: 0x00, + DURABLE_ACK: 0x02, + SCHEMA_MISMATCH: 0x03, + PARSE_ERROR: 0x05, + INTERNAL_ERROR: 0x06, + SECURITY_ERROR: 0x08, + WRITE_ERROR: 0x09, + CANCELLED: 0x0a, + LIMIT_EXCEEDED: 0x0b, + NOT_WRITABLE: 0x0c, + DICTIONARY_GAP: 0x0d, +} as const; + +export const QWP_EGRESS_MESSAGE = { + QUERY_REQUEST: 0x10, + RESULT_BATCH: 0x11, + RESULT_END: 0x12, + QUERY_ERROR: 0x13, + CANCEL: 0x14, + CREDIT: 0x15, + EXEC_DONE: 0x16, + CACHE_RESET: 0x17, + SERVER_INFO: 0x18, +} as const; + +export const QWP_EGRESS_CAPABILITY = { + ZONE: 0x00000001, + QUERY_FLAGS: 0x00000002, +} as const; + +export const QWP_QUERY_FLAG_RESET_DICTIONARY = 0x01; +export const QWP_RESET_MASK_DICTIONARY = 0x01; + +export const QWP_SERVER_ROLE = { + STANDALONE: 0, + PRIMARY: 1, + REPLICA: 2, + PRIMARY_CATCHUP: 3, +} as const; + +export const QWP_MAX_COLUMNS_PER_TABLE = 2048; +export const QWP_MAX_COLUMN_NAME_LENGTH = 127; +export const QWP_MAX_TABLE_NAME_LENGTH = 127; +export const QWP_MAX_ROWS_PER_TABLE = 1_000_000; +export const QWP_MAX_SYMBOL_DICTIONARY_SIZE = 1_000_000; +export const QWP_MAX_ERROR_MESSAGE_LENGTH = 1024; + +export const QWP_INGRESS_PATH = "/write/v4"; +export const QWP_EGRESS_PATH = "/read/v1"; diff --git a/src/qwp/core/egress.ts b/src/qwp/core/egress.ts new file mode 100644 index 0000000..8ab9315 --- /dev/null +++ b/src/qwp/core/egress.ts @@ -0,0 +1,237 @@ +import { encodeUtf8, QwpByteReader, QwpByteWriter } from "./bytes"; +import { QWP_EGRESS_CAPABILITY, QWP_EGRESS_MESSAGE } from "./constants"; +import { decodeQwpFrame, QwpFrameHeader } from "./frame"; +import { QwpProtocolError } from "./errors"; +import { readQwpVarint, writeQwpVarint } from "./varint"; + +export interface QwpQueryRequest { + requestId: number | bigint; + sql: string; + /** Zero means unbounded. */ + initialCredit?: number | bigint; + bindCount?: number; + /** Pre-encoded positional bind payload. */ + bindPayload?: Uint8Array; + /** Append only after SERVER_INFO advertises QUERY_FLAGS. */ + queryFlags?: number | bigint; +} + +export interface QwpServerInfoMessage { + kind: "server-info"; + role: number; + epoch: bigint; + capabilities: number; + serverWallNanoseconds: bigint; + clusterId: string; + nodeId: string; + zoneId: string | null; +} + +export interface QwpResultBatchMessage { + kind: "result-batch"; + requestId: bigint; + batchSequence: bigint; + /** Delta dictionary and columnar table block; decoded by the batch decoder. */ + body: Uint8Array; +} + +export interface QwpResultEndMessage { + kind: "result-end"; + requestId: bigint; + finalSequence: bigint; + totalRows: bigint; +} + +export interface QwpQueryErrorMessage { + kind: "query-error"; + requestId: bigint; + status: number; + message: string; +} + +export interface QwpExecDoneMessage { + kind: "exec-done"; + requestId: bigint; + operationType: number; + rowsAffected: bigint; +} + +export interface QwpCacheResetMessage { + kind: "cache-reset"; + resetMask: number; +} + +export type QwpEgressMessage = ( + | QwpServerInfoMessage + | QwpResultBatchMessage + | QwpResultEndMessage + | QwpQueryErrorMessage + | QwpExecDoneMessage + | QwpCacheResetMessage +) & + QwpFrameHeader; + +function requestId(value: number | bigint): bigint { + if (typeof value === "number") { + if (!Number.isSafeInteger(value) || value < 0) { + throw new RangeError("requestId must be a non-negative safe integer"); + } + return BigInt(value); + } + if (value < 0n || value > 0xffffffffffffffffn) { + throw new RangeError("requestId must fit in uint64"); + } + return value; +} + +/** Encodes the unframed client-to-server QUERY_REQUEST payload. */ +export function encodeQwpQueryRequest(request: QwpQueryRequest): Uint8Array { + const bindCount = request.bindCount ?? 0; + if (!Number.isSafeInteger(bindCount) || bindCount < 0) { + throw new RangeError("bindCount must be a non-negative safe integer"); + } + const bindPayload = request.bindPayload ?? new Uint8Array(); + if (bindCount === 0 && bindPayload.length !== 0) { + throw new Error("bindPayload requires a non-zero bindCount"); + } + + const sql = encodeUtf8(request.sql); + const writer = new QwpByteWriter(32 + sql.length + bindPayload.length); + writer.writeUint8(QWP_EGRESS_MESSAGE.QUERY_REQUEST); + writer.writeBigUint64(requestId(request.requestId)); + writeQwpVarint(writer, sql.length); + writer.writeBytes(sql); + writeQwpVarint(writer, request.initialCredit ?? 0); + writeQwpVarint(writer, bindCount); + writer.writeBytes(bindPayload); + if ((request.queryFlags ?? 0) !== 0) { + writeQwpVarint(writer, request.queryFlags!); + } + return writer.toUint8Array(); +} + +/** Encodes the unframed client-to-server CANCEL payload. */ +export function encodeQwpCancel(request: number | bigint): Uint8Array { + const writer = new QwpByteWriter(9); + writer.writeUint8(QWP_EGRESS_MESSAGE.CANCEL); + writer.writeBigUint64(requestId(request)); + return writer.toUint8Array(); +} + +/** Encodes the unframed client-to-server CREDIT payload. */ +export function encodeQwpCredit( + request: number | bigint, + additionalBytes: number | bigint, +): Uint8Array { + const writer = new QwpByteWriter(19); + writer.writeUint8(QWP_EGRESS_MESSAGE.CREDIT); + writer.writeBigUint64(requestId(request)); + writeQwpVarint(writer, additionalBytes); + return writer.toUint8Array(); +} + +function readUint16Utf8(reader: QwpByteReader, label: string): string { + const length = reader.readUint16(`${label} length`); + return reader.readUtf8(length, label); +} + +/** Decodes one QWP-framed server-to-client egress message. */ +export function decodeQwpEgressMessage(bytes: Uint8Array): QwpEgressMessage { + const frame = decodeQwpFrame(bytes); + const reader = new QwpByteReader(frame.payload); + const messageKind = reader.readUint8("egress message kind"); + const header: QwpFrameHeader = { + version: frame.version, + flags: frame.flags, + tableCount: frame.tableCount, + payloadLength: frame.payloadLength, + }; + + switch (messageKind) { + case QWP_EGRESS_MESSAGE.SERVER_INFO: { + const role = reader.readUint8("server role"); + const epoch = reader.readBigUint64("server epoch"); + const capabilities = reader.readUint32("server capabilities"); + const serverWallNanoseconds = reader.readBigInt64("server wall clock"); + const clusterId = readUint16Utf8(reader, "cluster ID"); + const nodeId = readUint16Utf8(reader, "node ID"); + const zoneId = + (capabilities & QWP_EGRESS_CAPABILITY.ZONE) !== 0 + ? readUint16Utf8(reader, "zone ID") + : null; + reader.expectEnd("SERVER_INFO"); + return { + ...header, + kind: "server-info", + role, + epoch, + capabilities, + serverWallNanoseconds, + clusterId, + nodeId, + zoneId, + }; + } + case QWP_EGRESS_MESSAGE.RESULT_BATCH: { + const requestId = reader.readBigUint64("result request ID"); + const batchSequence = readQwpVarint(reader); + const body = reader.readBytes(reader.remaining, "result batch body"); + return { + ...header, + kind: "result-batch", + requestId, + batchSequence, + body, + }; + } + case QWP_EGRESS_MESSAGE.RESULT_END: { + const requestId = reader.readBigUint64("result request ID"); + const finalSequence = readQwpVarint(reader); + const totalRows = readQwpVarint(reader); + reader.expectEnd("RESULT_END"); + return { + ...header, + kind: "result-end", + requestId, + finalSequence, + totalRows, + }; + } + case QWP_EGRESS_MESSAGE.QUERY_ERROR: { + const requestId = reader.readBigUint64("query error request ID"); + const status = reader.readUint8("query error status"); + const length = reader.readUint16("query error message length"); + const message = reader.readUtf8(length, "query error message"); + reader.expectEnd("QUERY_ERROR"); + return { + ...header, + kind: "query-error", + requestId, + status, + message, + }; + } + case QWP_EGRESS_MESSAGE.EXEC_DONE: { + const requestId = reader.readBigUint64("exec request ID"); + const operationType = reader.readUint8("operation type"); + const rowsAffected = readQwpVarint(reader); + reader.expectEnd("EXEC_DONE"); + return { + ...header, + kind: "exec-done", + requestId, + operationType, + rowsAffected, + }; + } + case QWP_EGRESS_MESSAGE.CACHE_RESET: { + const resetMask = reader.readUint8("cache reset mask"); + reader.expectEnd("CACHE_RESET"); + return { ...header, kind: "cache-reset", resetMask }; + } + default: + throw new QwpProtocolError( + `unsupported QWP egress message kind 0x${messageKind.toString(16)}`, + ); + } +} diff --git a/src/qwp/core/errors.ts b/src/qwp/core/errors.ts new file mode 100644 index 0000000..58bd697 --- /dev/null +++ b/src/qwp/core/errors.ts @@ -0,0 +1,7 @@ +/** Raised when a QWP payload is malformed, truncated, or unsupported. */ +export class QwpProtocolError extends Error { + constructor(message: string) { + super(message); + this.name = "QwpProtocolError"; + } +} diff --git a/src/qwp/core/frame.ts b/src/qwp/core/frame.ts new file mode 100644 index 0000000..b00a5b5 --- /dev/null +++ b/src/qwp/core/frame.ts @@ -0,0 +1,72 @@ +import { QwpByteReader, QwpByteWriter } from "./bytes"; +import { QWP_HEADER_SIZE, QWP_MAGIC, QWP_VERSION } from "./constants"; +import { QwpProtocolError } from "./errors"; + +export interface QwpFrameHeader { + version: number; + flags: number; + tableCount: number; + payloadLength: number; +} + +export interface QwpFrame extends QwpFrameHeader { + payload: Uint8Array; +} + +export function writeQwpFrameHeader( + writer: QwpByteWriter, + header: Omit & { version?: number }, +): void { + writer.writeUint32(QWP_MAGIC); + writer.writeUint8(header.version ?? QWP_VERSION); + writer.writeUint8(header.flags); + writer.writeUint16(header.tableCount); + writer.writeUint32(header.payloadLength); +} + +export function encodeQwpFrame( + payload: Uint8Array, + flags = 0, + tableCount = 0, +): Uint8Array { + const writer = new QwpByteWriter(QWP_HEADER_SIZE + payload.length); + writeQwpFrameHeader(writer, { + flags, + tableCount, + payloadLength: payload.length, + }); + writer.writeBytes(payload); + return writer.toUint8Array(); +} + +export function decodeQwpFrame(bytes: Uint8Array): QwpFrame { + if (bytes.length < QWP_HEADER_SIZE) { + throw new QwpProtocolError("QWP frame is shorter than its 12-byte header"); + } + const reader = new QwpByteReader(bytes); + const magic = reader.readUint32("QWP magic"); + if (magic !== QWP_MAGIC) { + throw new QwpProtocolError( + `invalid QWP magic 0x${magic.toString(16).padStart(8, "0")}`, + ); + } + const version = reader.readUint8("QWP version"); + if (version !== QWP_VERSION) { + throw new QwpProtocolError(`unsupported QWP version ${version}`); + } + const flags = reader.readUint8("QWP flags"); + const tableCount = reader.readUint16("QWP table count"); + const payloadLength = reader.readUint32("QWP payload length"); + if (payloadLength !== reader.remaining) { + throw new QwpProtocolError( + `QWP payload length mismatch [declared=${payloadLength}, actual=${reader.remaining}]`, + ); + } + return { + version, + flags, + tableCount, + payloadLength, + payload: reader.readBytes(payloadLength, "QWP payload"), + }; +} diff --git a/src/qwp/core/gorilla.ts b/src/qwp/core/gorilla.ts new file mode 100644 index 0000000..7e8d138 --- /dev/null +++ b/src/qwp/core/gorilla.ts @@ -0,0 +1,101 @@ +import { QwpByteWriter } from "./bytes"; + +const INT32_MIN = -2147483648n; +const INT32_MAX = 2147483647n; + +class QwpBitWriter { + private readonly bytes: Uint8Array; + private byteIndex = 0; + private bitIndex = 0; + + constructor(capacity: number) { + this.bytes = new Uint8Array(capacity); + } + + writeBits(value: number, count: number): void { + for (let index = 0; index < count; index++) { + if ((value >>> index) & 1) { + this.bytes[this.byteIndex] |= 1 << this.bitIndex; + } + this.bitIndex++; + if (this.bitIndex === 8) { + this.bitIndex = 0; + this.byteIndex++; + } + } + } + + finish(): Uint8Array { + const length = this.byteIndex + (this.bitIndex > 0 ? 1 : 0); + return this.bytes.slice(0, length); + } +} + +function encodedDeltaBits(deltaOfDelta: bigint): number { + if (deltaOfDelta === 0n) return 1; + if (deltaOfDelta >= -64n && deltaOfDelta <= 63n) return 9; + if (deltaOfDelta >= -256n && deltaOfDelta <= 255n) return 12; + if (deltaOfDelta >= -2048n && deltaOfDelta <= 2047n) return 16; + return 36; +} + +/** Encoded byte count, or -1 when a delta-of-delta leaves int32 range. */ +export function qwpGorillaSize(timestamps: readonly bigint[]): number { + if (timestamps.length === 0) return 0; + if (timestamps.length === 1) return 8; + if (timestamps.length === 2) return 16; + let previousTimestamp = timestamps[1]; + let previousDelta = timestamps[1] - timestamps[0]; + let bits = 0; + for (let index = 2; index < timestamps.length; index++) { + const delta = timestamps[index] - previousTimestamp; + const deltaOfDelta = delta - previousDelta; + if (deltaOfDelta < INT32_MIN || deltaOfDelta > INT32_MAX) return -1; + bits += encodedDeltaBits(deltaOfDelta); + previousDelta = delta; + previousTimestamp = timestamps[index]; + } + return 16 + Math.ceil(bits / 8); +} + +/** Encodes timestamps with the QWP LSB-first Gorilla variant. */ +export function encodeQwpGorilla(timestamps: readonly bigint[]): Uint8Array { + const size = qwpGorillaSize(timestamps); + if (size < 0) { + throw new Error("Gorilla delta-of-delta is outside the int32 range"); + } + const writer = new QwpByteWriter(Math.max(size, 1)); + if (timestamps.length === 0) return writer.toUint8Array(); + writer.writeBigInt64(timestamps[0]); + if (timestamps.length === 1) return writer.toUint8Array(); + writer.writeBigInt64(timestamps[1]); + if (timestamps.length === 2) return writer.toUint8Array(); + + const bits = new QwpBitWriter(size - 16); + let previousTimestamp = timestamps[1]; + let previousDelta = timestamps[1] - timestamps[0]; + for (let index = 2; index < timestamps.length; index++) { + const delta = timestamps[index] - previousTimestamp; + const deltaOfDelta = delta - previousDelta; + // Prefixes are bit-reversed because QWP packs bits least-significant first. + if (deltaOfDelta === 0n) { + bits.writeBits(0, 1); + } else if (deltaOfDelta >= -64n && deltaOfDelta <= 63n) { + bits.writeBits(0b01, 2); + bits.writeBits(Number(deltaOfDelta & 0x7fn), 7); + } else if (deltaOfDelta >= -256n && deltaOfDelta <= 255n) { + bits.writeBits(0b011, 3); + bits.writeBits(Number(deltaOfDelta & 0x1ffn), 9); + } else if (deltaOfDelta >= -2048n && deltaOfDelta <= 2047n) { + bits.writeBits(0b0111, 4); + bits.writeBits(Number(deltaOfDelta & 0xfffn), 12); + } else { + bits.writeBits(0b1111, 4); + bits.writeBits(Number(deltaOfDelta & 0xffffffffn), 32); + } + previousDelta = delta; + previousTimestamp = timestamps[index]; + } + writer.writeBytes(bits.finish()); + return writer.toUint8Array(); +} diff --git a/src/qwp/core/index.ts b/src/qwp/core/index.ts new file mode 100644 index 0000000..dc634a7 --- /dev/null +++ b/src/qwp/core/index.ts @@ -0,0 +1,10 @@ +export * from "./bytes"; +export * from "./constants"; +export * from "./egress"; +export * from "./errors"; +export * from "./frame"; +export * from "./gorilla"; +export * from "./ingress"; +export * from "./symbol-dictionary"; +export * from "./table"; +export * from "./varint"; diff --git a/src/qwp/core/ingress.ts b/src/qwp/core/ingress.ts new file mode 100644 index 0000000..e0ce3dc --- /dev/null +++ b/src/qwp/core/ingress.ts @@ -0,0 +1,554 @@ +import { encodeUtf8, QwpByteReader, QwpByteWriter, utf8Length } from "./bytes"; +import { + QWP_COLUMN_TYPE, + QWP_ENCODING_GORILLA, + QWP_ENCODING_UNCOMPRESSED, + QWP_FLAG_DEFER_COMMIT, + QWP_FLAG_DELTA_SYMBOL_DICTIONARY, + QWP_FLAG_GORILLA, + QWP_HEADER_SIZE, + QWP_MAX_ERROR_MESSAGE_LENGTH, + QWP_MAX_ROWS_PER_TABLE, + QWP_STATUS, + QwpColumnType, +} from "./constants"; +import { writeQwpFrameHeader } from "./frame"; +import { encodeQwpGorilla, qwpGorillaSize } from "./gorilla"; +import { QwpSymbolDictionary } from "./symbol-dictionary"; +import { + QwpArrayValue, + QwpColumnBuffer, + QwpSymbolValue, + QwpTableBuffer, +} from "./table"; +import { qwpVarintSize, writeQwpVarint } from "./varint"; + +export interface QwpIngressEncodeOptions { + gorilla?: boolean; + /** Present means connection-scoped delta dictionary mode. */ + dictionary?: QwpSymbolDictionary; + /** Highest global symbol ID already confirmed by the server. */ + confirmedMaxSymbolId?: number; + deferCommit?: boolean; +} + +interface ColumnEncodeOptions { + gorilla: boolean; + deltaSymbols: boolean; +} + +export interface QwpIngressTableResult { + name: string; + sequenceTransaction: bigint; +} + +export interface QwpIngressResponse { + status: number; + sequence: bigint | null; + tables: QwpIngressTableResult[]; + errorMessage?: string; +} + +function symbolText(value: unknown): string { + return typeof value === "string" ? value : (value as QwpSymbolValue).text; +} + +function symbolId(value: unknown): number { + return typeof value === "number" ? value : (value as QwpSymbolValue).id; +} + +function nullCount(column: QwpColumnBuffer): number { + let count = 0; + for (const value of column.nulls) if (value) count++; + return count; +} + +function fixedWidth(type: QwpColumnType): number | undefined { + switch (type) { + case QWP_COLUMN_TYPE.BYTE: + return 1; + case QWP_COLUMN_TYPE.SHORT: + case QWP_COLUMN_TYPE.CHAR: + return 2; + case QWP_COLUMN_TYPE.INT: + case QWP_COLUMN_TYPE.FLOAT: + case QWP_COLUMN_TYPE.IPV4: + return 4; + case QWP_COLUMN_TYPE.LONG: + case QWP_COLUMN_TYPE.DOUBLE: + case QWP_COLUMN_TYPE.DATE: + return 8; + case QWP_COLUMN_TYPE.UUID: + return 16; + case QWP_COLUMN_TYPE.LONG256: + return 32; + default: + return undefined; + } +} + +function qwpStringSize(value: string): number { + const length = utf8Length(value); + return qwpVarintSize(length) + length; +} + +function writeQwpString(writer: QwpByteWriter, value: string): void { + const bytes = encodeUtf8(value); + writeQwpVarint(writer, bytes.length); + writer.writeBytes(bytes); +} + +function binaryValue(value: unknown, width?: number): Uint8Array { + if (!(value instanceof Uint8Array)) { + throw new Error("QWP binary values must be Uint8Array instances"); + } + if (width !== undefined && value.length !== width) { + throw new Error( + `QWP binary value has length ${value.length}; expected ${width}`, + ); + } + return value; +} + +function columnPayloadSize( + column: QwpColumnBuffer, + rowCount: number, + options: ColumnEncodeOptions, +): number { + let size = 1; + if (nullCount(column) > 0) size += Math.ceil(rowCount / 8); + const valueCount = column.values.length; + + if (column.type === QWP_COLUMN_TYPE.BOOLEAN) { + return size + Math.ceil(valueCount / 8); + } + + if ( + column.type === QWP_COLUMN_TYPE.TIMESTAMP || + column.type === QWP_COLUMN_TYPE.TIMESTAMP_NANOS + ) { + if (!options.gorilla) return size + valueCount * 8; + const timestamps = column.values.map((value) => BigInt(value as bigint)); + const gorillaSize = timestamps.length > 2 ? qwpGorillaSize(timestamps) : -1; + return size + 1 + (gorillaSize > 0 ? gorillaSize : valueCount * 8); + } + + const width = fixedWidth(column.type); + if (width !== undefined) return size + valueCount * width; + + if (column.type === QWP_COLUMN_TYPE.SYMBOL) { + if (options.deltaSymbols) { + for (const value of column.values) size += qwpVarintSize(symbolId(value)); + return size; + } + const dictionary = [ + ...new Set(column.values.map((value) => symbolText(value))), + ]; + size += qwpVarintSize(dictionary.length); + for (const value of dictionary) size += qwpStringSize(value); + for (const value of column.values) { + size += qwpVarintSize(dictionary.indexOf(symbolText(value))); + } + return size; + } + + if ( + column.type === QWP_COLUMN_TYPE.VARCHAR || + column.type === QWP_COLUMN_TYPE.BINARY + ) { + let dataLength = 0; + for (const value of column.values) { + dataLength += + column.type === QWP_COLUMN_TYPE.VARCHAR + ? utf8Length(value as string) + : binaryValue(value).length; + } + return size + (valueCount + 1) * 4 + dataLength; + } + + if ( + column.type === QWP_COLUMN_TYPE.DOUBLE_ARRAY || + column.type === QWP_COLUMN_TYPE.LONG_ARRAY + ) { + for (const value of column.values) { + const array = value as QwpArrayValue; + size += 1 + array.dimensions.length * 4 + array.values.length * 8; + } + return size; + } + + if (column.type === QWP_COLUMN_TYPE.GEOHASH) { + const precision = column.geohashPrecision ?? 1; + return ( + size + qwpVarintSize(precision) + valueCount * Math.ceil(precision / 8) + ); + } + + if (column.type === QWP_COLUMN_TYPE.DECIMAL64) { + return size + 1 + valueCount * 8; + } + if (column.type === QWP_COLUMN_TYPE.DECIMAL128) { + return size + 1 + valueCount * 16; + } + if (column.type === QWP_COLUMN_TYPE.DECIMAL256) { + return size + 1 + valueCount * 32; + } + + throw new Error(`unsupported QWP column type 0x${column.type.toString(16)}`); +} + +function writeNullHeader( + writer: QwpByteWriter, + column: QwpColumnBuffer, + rowCount: number, +): void { + if (nullCount(column) === 0) { + writer.writeUint8(0); + return; + } + writer.writeUint8(1); + const bitmap = new Uint8Array(Math.ceil(rowCount / 8)); + for (let row = 0; row < rowCount; row++) { + if (column.nulls[row]) bitmap[row >>> 3] |= 1 << (row & 7); + } + writer.writeBytes(bitmap); +} + +function writeSignedLittleEndian( + writer: QwpByteWriter, + value: bigint, + width: number, +): void { + let remaining = BigInt.asIntN(width * 8, value); + for (let index = 0; index < width; index++) { + writer.writeUint8(Number(remaining & 0xffn)); + remaining >>= 8n; + } +} + +function writeColumn( + writer: QwpByteWriter, + column: QwpColumnBuffer, + rowCount: number, + options: ColumnEncodeOptions, +): void { + writeNullHeader(writer, column, rowCount); + + switch (column.type) { + case QWP_COLUMN_TYPE.BOOLEAN: { + const bitmap = new Uint8Array(Math.ceil(column.values.length / 8)); + column.values.forEach((value, index) => { + if (value) bitmap[index >>> 3] |= 1 << (index & 7); + }); + writer.writeBytes(bitmap); + return; + } + case QWP_COLUMN_TYPE.BYTE: + for (const value of column.values) writer.writeInt8(Number(value)); + return; + case QWP_COLUMN_TYPE.SHORT: + for (const value of column.values) writer.writeInt16(Number(value)); + return; + case QWP_COLUMN_TYPE.CHAR: + for (const value of column.values) { + const text = value as string; + if (text.length !== 1) { + throw new Error("QWP CHAR values must contain one UTF-16 code unit"); + } + writer.writeUint16(text.charCodeAt(0)); + } + return; + case QWP_COLUMN_TYPE.INT: + for (const value of column.values) writer.writeInt32(Number(value)); + return; + case QWP_COLUMN_TYPE.IPV4: + for (const value of column.values) + writer.writeUint32(Number(value) >>> 0); + return; + case QWP_COLUMN_TYPE.FLOAT: + for (const value of column.values) writer.writeFloat32(Number(value)); + return; + case QWP_COLUMN_TYPE.LONG: + case QWP_COLUMN_TYPE.DATE: + for (const value of column.values) { + writer.writeBigInt64(BigInt(value as number | bigint)); + } + return; + case QWP_COLUMN_TYPE.TIMESTAMP: + case QWP_COLUMN_TYPE.TIMESTAMP_NANOS: { + const timestamps = column.values.map((value) => BigInt(value as bigint)); + if (!options.gorilla) { + for (const timestamp of timestamps) writer.writeBigInt64(timestamp); + return; + } + const gorillaSize = + timestamps.length > 2 ? qwpGorillaSize(timestamps) : -1; + if (gorillaSize > 0) { + writer.writeUint8(QWP_ENCODING_GORILLA); + writer.writeBytes(encodeQwpGorilla(timestamps)); + } else { + writer.writeUint8(QWP_ENCODING_UNCOMPRESSED); + for (const timestamp of timestamps) writer.writeBigInt64(timestamp); + } + return; + } + case QWP_COLUMN_TYPE.DOUBLE: + for (const value of column.values) writer.writeFloat64(Number(value)); + return; + case QWP_COLUMN_TYPE.UUID: + for (const value of column.values) { + writer.writeBytes(binaryValue(value, 16)); + } + return; + case QWP_COLUMN_TYPE.LONG256: + for (const value of column.values) { + writer.writeBytes(binaryValue(value, 32)); + } + return; + case QWP_COLUMN_TYPE.SYMBOL: { + if (options.deltaSymbols) { + for (const value of column.values) + writeQwpVarint(writer, symbolId(value)); + return; + } + const dictionary = [ + ...new Set(column.values.map((value) => symbolText(value))), + ]; + writeQwpVarint(writer, dictionary.length); + for (const value of dictionary) writeQwpString(writer, value); + for (const value of column.values) { + writeQwpVarint(writer, dictionary.indexOf(symbolText(value))); + } + return; + } + case QWP_COLUMN_TYPE.VARCHAR: + case QWP_COLUMN_TYPE.BINARY: { + const parts = column.values.map((value) => + column.type === QWP_COLUMN_TYPE.VARCHAR + ? encodeUtf8(value as string) + : binaryValue(value), + ); + let cumulative = 0; + writer.writeUint32(0); + for (const part of parts) { + cumulative += part.length; + writer.writeUint32(cumulative); + } + for (const part of parts) writer.writeBytes(part); + return; + } + case QWP_COLUMN_TYPE.DOUBLE_ARRAY: + case QWP_COLUMN_TYPE.LONG_ARRAY: + for (const value of column.values) { + const array = value as QwpArrayValue; + writer.writeUint8(array.dimensions.length); + for (const dimension of array.dimensions) writer.writeUint32(dimension); + for (const item of array.values) { + if (column.type === QWP_COLUMN_TYPE.DOUBLE_ARRAY) { + writer.writeFloat64(Number(item)); + } else { + writer.writeBigInt64(BigInt(item)); + } + } + } + return; + case QWP_COLUMN_TYPE.GEOHASH: { + const precision = column.geohashPrecision ?? 1; + writeQwpVarint(writer, precision); + const width = Math.ceil(precision / 8); + for (const value of column.values) { + let remaining = BigInt(value as bigint); + for (let index = 0; index < width; index++) { + writer.writeUint8(Number(remaining & 0xffn)); + remaining >>= 8n; + } + } + return; + } + case QWP_COLUMN_TYPE.DECIMAL64: + case QWP_COLUMN_TYPE.DECIMAL128: + case QWP_COLUMN_TYPE.DECIMAL256: { + writer.writeUint8(column.decimalScale ?? 0); + const width = + column.type === QWP_COLUMN_TYPE.DECIMAL64 + ? 8 + : column.type === QWP_COLUMN_TYPE.DECIMAL128 + ? 16 + : 32; + for (const value of column.values) { + writeSignedLittleEndian(writer, BigInt(value as bigint), width); + } + return; + } + default: + throw new Error("unsupported QWP column type"); + } +} + +function tableSize( + table: QwpTableBuffer, + options: ColumnEncodeOptions, +): number { + let size = + qwpStringSize(table.name) + + qwpVarintSize(table.rowCount) + + qwpVarintSize(table.columns.length); + for (const column of table.columns) size += qwpStringSize(column.name) + 1; + for (const column of table.columns) { + size += columnPayloadSize(column, table.rowCount, options); + } + return size; +} + +function validateTableForEncoding(table: QwpTableBuffer): void { + for (const column of table.columns) { + if ( + column.size !== table.rowCount || + column.nulls.length !== table.rowCount + ) { + throw new Error( + `table '${table.name}' has an unfinished row in column '${column.name}'`, + ); + } + let nonNullCount = 0; + for (const isNull of column.nulls) if (!isNull) nonNullCount++; + if (nonNullCount !== column.values.length) { + throw new Error( + `table '${table.name}' column '${column.name}' has ${nonNullCount} non-null row(s) but ${column.values.length} value(s)`, + ); + } + } +} + +/** Encodes one QWP v1 ingress message. */ +export function encodeQwpIngressFrame( + tables: readonly QwpTableBuffer[], + options: QwpIngressEncodeOptions = {}, +): Uint8Array { + if (tables.length > 0xffff) { + throw new Error("QWP frame contains more than 65535 tables"); + } + for (const table of tables) { + validateTableForEncoding(table); + if (table.rowCount > QWP_MAX_ROWS_PER_TABLE) { + throw new Error( + `table '${table.name}' contains ${table.rowCount} rows; maximum is ${QWP_MAX_ROWS_PER_TABLE}`, + ); + } + } + + const gorilla = options.gorilla ?? true; + const deltaSymbols = options.dictionary !== undefined; + const deltaStart = deltaSymbols + ? (options.confirmedMaxSymbolId ?? -1) + 1 + : 0; + const dictionaryEntries = deltaSymbols + ? options.dictionary!.entriesFrom(deltaStart) + : []; + const columnOptions = { gorilla, deltaSymbols }; + + let flags = 0; + if (gorilla) flags |= QWP_FLAG_GORILLA; + if (deltaSymbols) flags |= QWP_FLAG_DELTA_SYMBOL_DICTIONARY; + if (options.deferCommit) flags |= QWP_FLAG_DEFER_COMMIT; + + let payloadLength = 0; + if (deltaSymbols) { + payloadLength += + qwpVarintSize(deltaStart) + qwpVarintSize(dictionaryEntries.length); + for (const entry of dictionaryEntries) + payloadLength += qwpStringSize(entry); + } + for (const table of tables) payloadLength += tableSize(table, columnOptions); + + const writer = new QwpByteWriter(QWP_HEADER_SIZE + payloadLength); + writeQwpFrameHeader(writer, { + flags, + tableCount: tables.length, + payloadLength, + }); + if (deltaSymbols) { + writeQwpVarint(writer, deltaStart); + writeQwpVarint(writer, dictionaryEntries.length); + for (const entry of dictionaryEntries) writeQwpString(writer, entry); + } + for (const table of tables) { + writeQwpString(writer, table.name); + writeQwpVarint(writer, table.rowCount); + writeQwpVarint(writer, table.columns.length); + for (const column of table.columns) { + writeQwpString(writer, column.name); + writer.writeUint8(column.type); + } + for (const column of table.columns) { + writeColumn(writer, column, table.rowCount, columnOptions); + } + } + const result = writer.toUint8Array(); + if (result.length !== QWP_HEADER_SIZE + payloadLength) { + throw new Error( + `QWP frame size mismatch [expected=${QWP_HEADER_SIZE + payloadLength}, actual=${result.length}]`, + ); + } + return result; +} + +export function encodeQwpIngressCommitFrame( + dictionary?: QwpSymbolDictionary, + confirmedMaxSymbolId = -1, +): Uint8Array { + return encodeQwpIngressFrame([], { + gorilla: false, + dictionary, + confirmedMaxSymbolId, + }); +} + +function readIngressTables( + reader: QwpByteReader, + count: number, +): QwpIngressTableResult[] { + const tables: QwpIngressTableResult[] = []; + for (let index = 0; index < count; index++) { + const nameLength = reader.readUint16("ingress table name length"); + const name = reader.readUtf8(nameLength, "ingress table name"); + const sequenceTransaction = reader.readBigInt64( + "ingress table sequence transaction", + ); + tables.push({ name, sequenceTransaction }); + } + return tables; +} + +/** Decodes an ingress ACK, durable ACK, or NACK WebSocket payload. */ +export function decodeQwpIngressResponse( + payload: Uint8Array, +): QwpIngressResponse { + const reader = new QwpByteReader(payload); + const status = reader.readUint8("ingress response status"); + + if (status === QWP_STATUS.DURABLE_ACK) { + const count = reader.readUint16("durable ACK table count"); + const tables = readIngressTables(reader, count); + reader.expectEnd("durable ACK"); + return { status, sequence: null, tables }; + } + + const sequence = reader.readBigUint64("ingress response sequence"); + if (status === QWP_STATUS.OK) { + const count = reader.readUint16("ACK table count"); + const tables = readIngressTables(reader, count); + reader.expectEnd("ingress ACK"); + return { status, sequence, tables }; + } + + const messageLength = reader.readUint16("NACK message length"); + if (messageLength > QWP_MAX_ERROR_MESSAGE_LENGTH) { + throw new Error( + `QWP error message exceeds ${QWP_MAX_ERROR_MESSAGE_LENGTH} bytes`, + ); + } + const errorMessage = reader.readUtf8(messageLength, "NACK message"); + reader.expectEnd("ingress NACK"); + return { status, sequence, tables: [], errorMessage }; +} diff --git a/src/qwp/core/symbol-dictionary.ts b/src/qwp/core/symbol-dictionary.ts new file mode 100644 index 0000000..4e4f775 --- /dev/null +++ b/src/qwp/core/symbol-dictionary.ts @@ -0,0 +1,47 @@ +import { QWP_MAX_SYMBOL_DICTIONARY_SIZE } from "./constants"; + +/** Connection-scoped QWP symbol dictionary. IDs are dense from zero. */ +export class QwpSymbolDictionary { + private readonly ids = new Map(); + private readonly values: string[] = []; + + get size(): number { + return this.values.length; + } + + getOrAdd(value: string): number { + const existing = this.ids.get(value); + if (existing !== undefined) return existing; + if (this.values.length >= QWP_MAX_SYMBOL_DICTIONARY_SIZE) { + throw new Error( + `symbol dictionary exceeds maximum size ${QWP_MAX_SYMBOL_DICTIONARY_SIZE}`, + ); + } + const id = this.values.length; + this.ids.set(value, id); + this.values.push(value); + return id; + } + + /** Appends positionally without de-duplicating recovered entries. */ + addRecovered(value: string): number { + if (this.values.length >= QWP_MAX_SYMBOL_DICTIONARY_SIZE) { + throw new Error( + `symbol dictionary exceeds maximum size ${QWP_MAX_SYMBOL_DICTIONARY_SIZE}`, + ); + } + const id = this.values.length; + this.values.push(value); + this.ids.set(value, id); + return id; + } + + entriesFrom(startId: number): string[] { + return this.values.slice(Math.max(0, startId)); + } + + reset(): void { + this.ids.clear(); + this.values.length = 0; + } +} diff --git a/src/qwp/core/table.ts b/src/qwp/core/table.ts new file mode 100644 index 0000000..8bdf880 --- /dev/null +++ b/src/qwp/core/table.ts @@ -0,0 +1,211 @@ +import { + QWP_COLUMN_TYPE, + QWP_MAX_COLUMNS_PER_TABLE, + QWP_MAX_COLUMN_NAME_LENGTH, + QWP_MAX_TABLE_NAME_LENGTH, + QwpColumnType, +} from "./constants"; +import { utf8Length } from "./bytes"; + +export interface QwpSymbolValue { + id: number; + text: string; +} + +export interface QwpArrayValue { + dimensions: number[]; + values: (number | bigint)[]; +} + +export interface QwpColumnBuffer { + name: string; + type: QwpColumnType; + /** Non-null values only; QWP compacts values around the null bitmap. */ + values: unknown[]; + /** One entry per row; true means NULL. */ + nulls: boolean[]; + /** Rows accounted for so far, including nulls. */ + size: number; + geohashPrecision?: number; + decimalScale?: number; +} + +/** Mutable columnar staging area for one QWP ingress table. */ +export class QwpTableBuffer { + readonly name: string; + private readonly columnList: QwpColumnBuffer[] = []; + private readonly columnsByName = new Map(); + private rows = 0; + + constructor(name: string) { + if (!name) throw new Error("table name cannot be empty"); + if (utf8Length(name) > QWP_MAX_TABLE_NAME_LENGTH) { + throw new Error( + `table name too long [maxLength=${QWP_MAX_TABLE_NAME_LENGTH}]`, + ); + } + this.name = name; + } + + get rowCount(): number { + return this.rows; + } + + get columns(): readonly QwpColumnBuffer[] { + return this.columnList; + } + + /** + * Returns null when the current row already contains this column. The first + * value wins, matching the existing Sender API. + */ + getOrCreateColumn(name: string, type: QwpColumnType): QwpColumnBuffer | null { + const designatedTimestamp = + name.length === 0 && + (type === QWP_COLUMN_TYPE.TIMESTAMP || + type === QWP_COLUMN_TYPE.TIMESTAMP_NANOS); + if (!name && !designatedTimestamp) { + throw new Error("column name cannot be empty"); + } + + const existing = this.columnsByName.get(name); + if (existing) { + if (existing.type !== type) { + throw new Error( + `column type mismatch for '${name}' [existing=${existing.type}, received=${type}]`, + ); + } + if (existing.size > this.rows) return null; + existing.nulls.push(false); + existing.size++; + return existing; + } + + if (utf8Length(name) > QWP_MAX_COLUMN_NAME_LENGTH) { + throw new Error( + `column name too long [maxLength=${QWP_MAX_COLUMN_NAME_LENGTH}]`, + ); + } + if (this.columnList.length >= QWP_MAX_COLUMNS_PER_TABLE) { + throw new Error( + `column count exceeds maximum ${QWP_MAX_COLUMNS_PER_TABLE}`, + ); + } + + const column: QwpColumnBuffer = { + name, + type, + values: [], + nulls: new Array(this.rows).fill(true), + size: this.rows, + }; + column.nulls.push(false); + column.size++; + this.columnList.push(column); + this.columnsByName.set(name, column); + return column; + } + + /** Closes the current row and back-fills missing columns with nulls. */ + nextRow(): void { + this.rows++; + for (const column of this.columnList) { + while (column.size < this.rows) { + column.nulls.push(true); + column.size++; + } + } + } + + setGeohashPrecision(column: QwpColumnBuffer, precision: number): void { + if (column.type !== QWP_COLUMN_TYPE.GEOHASH) { + throw new Error("geohash precision can only be set on a GEOHASH column"); + } + if (!Number.isInteger(precision) || precision < 1 || precision > 60) { + throw new Error( + `invalid geohash precision ${precision}; expected 1 through 60`, + ); + } + if (column.geohashPrecision === undefined) { + column.geohashPrecision = precision; + } else if (column.geohashPrecision !== precision) { + throw new Error( + `geohash precision mismatch [existing=${column.geohashPrecision}, received=${precision}]`, + ); + } + } + + setDecimalScale(column: QwpColumnBuffer, scale: number): number { + const maximum = + column.type === QWP_COLUMN_TYPE.DECIMAL64 + ? 18 + : column.type === QWP_COLUMN_TYPE.DECIMAL128 + ? 38 + : column.type === QWP_COLUMN_TYPE.DECIMAL256 + ? 76 + : undefined; + if (maximum === undefined) { + throw new Error("decimal scale can only be set on a DECIMAL column"); + } + if (!Number.isInteger(scale) || scale < 0 || scale > maximum) { + throw new Error( + `invalid decimal scale ${scale}; expected 0 through ${maximum}`, + ); + } + if (column.decimalScale === undefined) column.decimalScale = scale; + return column.decimalScale; + } + + /** Truncates every column back to the last completed row. */ + rollbackRow(): void { + for (const column of this.columnList) { + while (column.size > this.rows) { + const wasNull = column.nulls.pop(); + column.size--; + if (wasNull === false) column.values.pop(); + } + } + for (let index = this.columnList.length - 1; index >= 0; index--) { + const column = this.columnList[index]; + if (this.rows === 0 && column.size === 0) { + this.columnsByName.delete(column.name); + this.columnList.splice(index, 1); + } + } + } + + reset(): void { + this.columnList.length = 0; + this.columnsByName.clear(); + this.rows = 0; + } +} + +export function flattenQwpArray(value: unknown[]): QwpArrayValue { + const dimensions: number[] = []; + let level: unknown = value; + while (Array.isArray(level)) { + dimensions.push(level.length); + level = level[0]; + } + if (dimensions.length === 0 || dimensions.length > 255) { + throw new Error("QWP array must have between 1 and 255 dimensions"); + } + + const values: (number | bigint)[] = []; + const walk = (node: unknown, depth: number): void => { + if (depth === dimensions.length) { + if (typeof node !== "number" && typeof node !== "bigint") { + throw new Error("QWP array elements must be numbers or bigints"); + } + values.push(node); + return; + } + if (!Array.isArray(node) || node.length !== dimensions[depth]) { + throw new Error("irregular QWP array shape"); + } + for (const child of node) walk(child, depth + 1); + }; + walk(value, 0); + return { dimensions, values }; +} diff --git a/src/qwp/core/varint.ts b/src/qwp/core/varint.ts new file mode 100644 index 0000000..bb73d3f --- /dev/null +++ b/src/qwp/core/varint.ts @@ -0,0 +1,85 @@ +import { QwpByteReader, QwpByteWriter } from "./bytes"; +import { QwpProtocolError } from "./errors"; + +const MAX_UINT64 = 0xffffffffffffffffn; + +function toBigInt(value: number | bigint): bigint { + if (typeof value === "number") { + if (!Number.isSafeInteger(value) || value < 0) { + throw new RangeError( + `varint requires a non-negative safe integer, got ${value}`, + ); + } + return BigInt(value); + } + if (value < 0n || value > MAX_UINT64) { + throw new RangeError(`varint is outside the uint64 range: ${value}`); + } + return value; +} + +/** Returns the encoded byte count of an unsigned LEB128 uint64. */ +export function qwpVarintSize(value: number | bigint): number { + let remaining = toBigInt(value); + let size = 1; + while (remaining >= 0x80n) { + remaining >>= 7n; + size++; + } + return size; +} + +/** Writes an unsigned LEB128 uint64. */ +export function writeQwpVarint( + writer: QwpByteWriter, + value: number | bigint, +): void { + let remaining = toBigInt(value); + while (remaining >= 0x80n) { + writer.writeUint8(Number(remaining & 0x7fn) | 0x80); + remaining >>= 7n; + } + writer.writeUint8(Number(remaining)); +} + +/** Reads an unsigned LEB128 uint64. */ +export function readQwpVarint(reader: QwpByteReader): bigint { + let value = 0n; + for (let index = 0; index < 10; index++) { + const byte = reader.readUint8("varint"); + if (index === 9 && (byte & 0xfe) !== 0) { + throw new QwpProtocolError("QWP varint exceeds uint64 range"); + } + value |= BigInt(byte & 0x7f) << BigInt(index * 7); + if ((byte & 0x80) === 0) return value; + } + throw new QwpProtocolError("QWP varint exceeds 10 bytes"); +} + +export function readQwpVarintNumber( + reader: QwpByteReader, + label = "varint", +): number { + const value = readQwpVarint(reader); + if (value > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new QwpProtocolError( + `${label} exceeds JavaScript's safe integer range`, + ); + } + return Number(value); +} + +export function encodeQwpVarint(value: number | bigint): Uint8Array { + const writer = new QwpByteWriter(qwpVarintSize(value)); + writeQwpVarint(writer, value); + return writer.toUint8Array(); +} + +export function decodeQwpVarint( + bytes: Uint8Array, + offset = 0, +): { value: bigint; offset: number } { + const reader = new QwpByteReader(bytes, offset); + const value = readQwpVarint(reader); + return { value, offset: reader.position }; +} diff --git a/src/qwp/index.ts b/src/qwp/index.ts new file mode 100644 index 0000000..fe0279f --- /dev/null +++ b/src/qwp/index.ts @@ -0,0 +1,9 @@ +/** + * Browser-safe QuestDB Wire Protocol primitives. + * + * This entry point intentionally contains no Node.js imports. Higher-level + * browser and Node WebSocket clients will be layered on top of this module. + * + * @packageDocumentation + */ +export * from "./core"; diff --git a/test/qwp/core.test.ts b/test/qwp/core.test.ts new file mode 100644 index 0000000..ce7d1c6 --- /dev/null +++ b/test/qwp/core.test.ts @@ -0,0 +1,261 @@ +import { describe, expect, it } from "vitest"; +import { + decodeQwpEgressMessage, + decodeQwpFrame, + decodeQwpIngressResponse, + decodeQwpVarint, + encodeQwpCancel, + encodeQwpCredit, + encodeQwpFrame, + encodeQwpGorilla, + encodeQwpIngressFrame, + encodeQwpQueryRequest, + encodeQwpVarint, + QWP_COLUMN_TYPE, + QWP_EGRESS_CAPABILITY, + QWP_EGRESS_MESSAGE, + QWP_FLAG_GORILLA, + QWP_HEADER_SIZE, + QWP_MAGIC, + QWP_STATUS, + QwpByteReader, + QwpByteWriter, + QwpTableBuffer, + qwpGorillaSize, + qwpVarintSize, + readQwpVarint, + writeQwpVarint, +} from "../../src/qwp"; + +function dataView(bytes: Uint8Array): DataView { + return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); +} + +function writeU16String(writer: QwpByteWriter, value: string): void { + const bytes = new TextEncoder().encode(value); + writer.writeUint16(bytes.length).writeBytes(bytes); +} + +describe("QWP browser-safe byte core", () => { + it("round-trips little-endian scalars without Buffer", () => { + const writer = new QwpByteWriter(1); + writer + .writeUint16(0x1234) + .writeInt32(-7) + .writeBigUint64(0xffffffffffffffffn) + .writeFloat64(1.5); + + const reader = new QwpByteReader(writer.toUint8Array()); + expect(reader.readUint16()).toBe(0x1234); + expect(reader.readInt32()).toBe(-7); + expect(reader.readBigUint64()).toBe(0xffffffffffffffffn); + expect(reader.readFloat64()).toBe(1.5); + reader.expectEnd(); + }); + + it("round-trips uint64 LEB128 values and rejects overflow", () => { + for (const value of [0n, 127n, 128n, 300n, 1_000_000n, 2n ** 63n]) { + const encoded = encodeQwpVarint(value); + expect(encoded.length).toBe(qwpVarintSize(value)); + expect(decodeQwpVarint(encoded)).toEqual({ + value, + offset: encoded.length, + }); + } + expect(() => + decodeQwpVarint(Uint8Array.from([0x80, 0x80, 0x80, 0x80, 0x80])), + ).toThrow(/truncated/i); + expect(() => + decodeQwpVarint( + Uint8Array.from([ + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x02, + ]), + ), + ).toThrow(/uint64/i); + }); +}); + +describe("QWP frame envelope", () => { + it("writes and validates the common 12-byte header", () => { + const encoded = encodeQwpFrame(Uint8Array.from([1, 2, 3]), 4, 2); + const view = dataView(encoded); + expect(view.getUint32(0, true)).toBe(QWP_MAGIC); + expect(encoded.length).toBe(QWP_HEADER_SIZE + 3); + expect(decodeQwpFrame(encoded)).toMatchObject({ + version: 1, + flags: 4, + tableCount: 2, + payloadLength: 3, + }); + }); + + it("rejects bad magic and payload length mismatches", () => { + const badMagic = encodeQwpFrame(new Uint8Array()); + badMagic[0] = 0; + expect(() => decodeQwpFrame(badMagic)).toThrow(/magic/i); + + const badLength = encodeQwpFrame(Uint8Array.of(1)); + dataView(badLength).setUint32(8, 2, true); + expect(() => decodeQwpFrame(badLength)).toThrow(/length mismatch/i); + }); +}); + +describe("QWP ingress codec", () => { + it("encodes a compacted LONG column with an LSB-first null bitmap", () => { + const table = new QwpTableBuffer("t"); + table.getOrCreateColumn("a", QWP_COLUMN_TYPE.LONG)!.values.push(1n); + table.nextRow(); + table.nextRow(); + + const frame = decodeQwpFrame( + encodeQwpIngressFrame([table], { gorilla: false }), + ); + const reader = new QwpByteReader(frame.payload); + expect(readQwpVarint(reader)).toBe(1n); + expect(reader.readUtf8(1)).toBe("t"); + expect(readQwpVarint(reader)).toBe(2n); + expect(readQwpVarint(reader)).toBe(1n); + expect(readQwpVarint(reader)).toBe(1n); + expect(reader.readUtf8(1)).toBe("a"); + expect(reader.readUint8()).toBe(QWP_COLUMN_TYPE.LONG); + expect(reader.readUint8()).toBe(1); + expect(reader.readUint8()).toBe(0b00000010); + expect(reader.readBigInt64()).toBe(1n); + reader.expectEnd(); + }); + + it("sets the Gorilla flag and emits the donor-compatible timestamp prefix", () => { + const table = new QwpTableBuffer("events"); + const timestamps = [1000n, 2000n, 3000n, 4000n]; + for (const timestamp of timestamps) { + table + .getOrCreateColumn("ts", QWP_COLUMN_TYPE.TIMESTAMP)! + .values.push(timestamp); + table.nextRow(); + } + const encoded = encodeQwpIngressFrame([table]); + expect(encoded[5] & QWP_FLAG_GORILLA).toBe(QWP_FLAG_GORILLA); + expect(qwpGorillaSize(timestamps)).toBe(17); + const gorilla = encodeQwpGorilla(timestamps); + expect(dataView(gorilla).getBigInt64(0, true)).toBe(1000n); + expect(dataView(gorilla).getBigInt64(8, true)).toBe(2000n); + expect(gorilla[16]).toBe(0); + }); + + it("refuses to encode incomplete column state", () => { + const table = new QwpTableBuffer("broken"); + table.getOrCreateColumn("value", QWP_COLUMN_TYPE.LONG); + table.nextRow(); + expect(() => encodeQwpIngressFrame([table])).toThrow(/non-null row/i); + + const unfinished = new QwpTableBuffer("unfinished"); + unfinished + .getOrCreateColumn("value", QWP_COLUMN_TYPE.LONG)! + .values.push(1n); + expect(() => encodeQwpIngressFrame([unfinished])).toThrow( + /unfinished row/i, + ); + }); + + it("decodes ACK, durable ACK, and NACK payloads", () => { + const ack = new QwpByteWriter(); + ack.writeUint8(QWP_STATUS.OK).writeBigUint64(7n).writeUint16(1); + writeU16String(ack, "trades"); + ack.writeBigInt64(42n); + expect(decodeQwpIngressResponse(ack.toUint8Array())).toEqual({ + status: QWP_STATUS.OK, + sequence: 7n, + tables: [{ name: "trades", sequenceTransaction: 42n }], + }); + + const durable = new QwpByteWriter(); + durable.writeUint8(QWP_STATUS.DURABLE_ACK).writeUint16(0); + expect(decodeQwpIngressResponse(durable.toUint8Array())).toEqual({ + status: QWP_STATUS.DURABLE_ACK, + sequence: null, + tables: [], + }); + + const nack = new QwpByteWriter(); + nack.writeUint8(QWP_STATUS.WRITE_ERROR).writeBigUint64(8n); + writeU16String(nack, "boom"); + expect(decodeQwpIngressResponse(nack.toUint8Array())).toMatchObject({ + status: QWP_STATUS.WRITE_ERROR, + sequence: 8n, + errorMessage: "boom", + }); + }); +}); + +describe("QWP egress codec", () => { + it("encodes QUERY_REQUEST, CANCEL, and CREDIT payloads", () => { + const query = encodeQwpQueryRequest({ + requestId: 9n, + sql: "select 42", + initialCredit: 1024, + queryFlags: 1, + }); + const reader = new QwpByteReader(query); + expect(reader.readUint8()).toBe(QWP_EGRESS_MESSAGE.QUERY_REQUEST); + expect(reader.readBigUint64()).toBe(9n); + const sqlLength = Number(readQwpVarint(reader)); + expect(reader.readUtf8(sqlLength)).toBe("select 42"); + expect(readQwpVarint(reader)).toBe(1024n); + expect(readQwpVarint(reader)).toBe(0n); + expect(readQwpVarint(reader)).toBe(1n); + reader.expectEnd(); + + expect(encodeQwpCancel(9n)).toEqual( + Uint8Array.from([QWP_EGRESS_MESSAGE.CANCEL, 9, 0, 0, 0, 0, 0, 0, 0]), + ); + const credit = new QwpByteReader(encodeQwpCredit(9n, 300)); + expect(credit.readUint8()).toBe(QWP_EGRESS_MESSAGE.CREDIT); + expect(credit.readBigUint64()).toBe(9n); + expect(readQwpVarint(credit)).toBe(300n); + }); + + it("decodes SERVER_INFO including the optional zone", () => { + const payload = new QwpByteWriter(); + payload + .writeUint8(QWP_EGRESS_MESSAGE.SERVER_INFO) + .writeUint8(1) + .writeBigUint64(3n) + .writeUint32(QWP_EGRESS_CAPABILITY.ZONE) + .writeBigInt64(123n); + writeU16String(payload, "cluster-a"); + writeU16String(payload, "node-1"); + writeU16String(payload, "eu-west-1a"); + + expect( + decodeQwpEgressMessage(encodeQwpFrame(payload.toUint8Array())), + ).toMatchObject({ + kind: "server-info", + role: 1, + epoch: 3n, + clusterId: "cluster-a", + nodeId: "node-1", + zoneId: "eu-west-1a", + }); + }); + + it("decodes RESULT_END and rejects truncated control frames", () => { + const payload = new QwpByteWriter(); + payload.writeUint8(QWP_EGRESS_MESSAGE.RESULT_END).writeBigUint64(11n); + writeQwpVarint(payload, 4); + writeQwpVarint(payload, 123); + expect( + decodeQwpEgressMessage(encodeQwpFrame(payload.toUint8Array())), + ).toMatchObject({ + kind: "result-end", + requestId: 11n, + finalSequence: 4n, + totalRows: 123n, + }); + + expect(() => + decodeQwpEgressMessage( + encodeQwpFrame(Uint8Array.of(QWP_EGRESS_MESSAGE.QUERY_ERROR)), + ), + ).toThrow(/truncated/i); + }); +}); diff --git a/tsconfig.qwp-browser.json b/tsconfig.qwp-browser.json new file mode 100644 index 0000000..6b14c07 --- /dev/null +++ b/tsconfig.qwp-browser.json @@ -0,0 +1,12 @@ +{ + "include": ["src/qwp/**/*.ts"], + "compilerOptions": { + "moduleResolution": "bundler", + "module": "ESNext", + "target": "ES2020", + "lib": ["ES2020", "DOM"], + "types": [], + "noEmit": true, + "strict": true + } +} From f233db2f1432b185980ef8668a2b9d853505a2b4 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Mon, 17 Aug 2026 15:15:21 +0100 Subject: [PATCH 005/265] feat(qwp): add websocket ingress sessions --- package.json | 20 ++ src/qwp/browser.ts | 60 ++++++ src/qwp/index.ts | 2 + src/qwp/ingress-session.ts | 211 ++++++++++++++++++++ src/qwp/internal/async-queue.ts | 61 ++++++ src/qwp/internal/websocket-connection.ts | 172 ++++++++++++++++ src/qwp/node.ts | 79 ++++++++ src/qwp/transport.ts | 27 +++ test/qwp/session.test.ts | 244 +++++++++++++++++++++++ tsconfig.qwp-browser.json | 1 + 10 files changed, 877 insertions(+) create mode 100644 src/qwp/browser.ts create mode 100644 src/qwp/ingress-session.ts create mode 100644 src/qwp/internal/async-queue.ts create mode 100644 src/qwp/internal/websocket-connection.ts create mode 100644 src/qwp/node.ts create mode 100644 src/qwp/transport.ts create mode 100644 test/qwp/session.test.ts diff --git a/package.json b/package.json index a3082f9..719fde3 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,26 @@ "types": "./dist/cjs/qwp/index.d.ts", "default": "./dist/cjs/qwp/index.js" } + }, + "./qwp/browser": { + "import": { + "types": "./dist/es/qwp/browser.d.mts", + "default": "./dist/es/qwp/browser.mjs" + }, + "require": { + "types": "./dist/cjs/qwp/browser.d.ts", + "default": "./dist/cjs/qwp/browser.js" + } + }, + "./qwp/node": { + "import": { + "types": "./dist/es/qwp/node.d.mts", + "default": "./dist/es/qwp/node.mjs" + }, + "require": { + "types": "./dist/cjs/qwp/node.d.ts", + "default": "./dist/cjs/qwp/node.js" + } } }, "repository": { diff --git a/src/qwp/browser.ts b/src/qwp/browser.ts new file mode 100644 index 0000000..606d62c --- /dev/null +++ b/src/qwp/browser.ts @@ -0,0 +1,60 @@ +/** Browser WebSocket adapter and browser-safe QWP protocol/session APIs. */ +export * from "./index"; + +import { + openQwpWebSocket, + QwpWebSocketLike, +} from "./internal/websocket-connection"; +import { QwpBinaryConnection, QwpWebSocketConnectOptions } from "./transport"; +import { QwpIngressSession, QwpIngressSessionOptions } from "./ingress-session"; + +export type { QwpWebSocketLike } from "./internal/websocket-connection"; + +export interface QwpBrowserWebSocketOptions extends QwpWebSocketConnectOptions { + /** Test or framework hook; defaults to the browser's global WebSocket. */ + webSocketFactory?: ( + url: string | URL, + protocols?: string | string[], + ) => QwpWebSocketLike; +} + +/** + * Opens a QWP-capable browser WebSocket. + * + * Browsers cannot set Authorization or X-QWP-* upgrade headers. The server or + * gateway must therefore support the browser QWP handshake (Origin policy and + * browser-compatible authentication/version negotiation). + */ +export function connectQwpBrowserWebSocket( + options: QwpBrowserWebSocketOptions, +): Promise { + const factory = + options.webSocketFactory ?? + ((url: string | URL, protocols?: string | string[]) => { + const WebSocketConstructor = ( + globalThis as unknown as { + WebSocket?: new ( + url: string | URL, + protocols?: string | string[], + ) => QwpWebSocketLike; + } + ).WebSocket; + if (!WebSocketConstructor) { + throw new Error("WebSocket is not available in this browser runtime"); + } + return new WebSocketConstructor(url, protocols); + }); + const socket = factory(options.url, options.protocols); + return openQwpWebSocket(socket, options.connectTimeoutMs); +} + +/** Opens a browser WebSocket and starts an ingress ACK/NACK session. */ +export async function connectQwpBrowserIngress( + options: QwpBrowserWebSocketOptions, + sessionOptions: QwpIngressSessionOptions = {}, +): Promise { + return new QwpIngressSession( + await connectQwpBrowserWebSocket(options), + sessionOptions, + ); +} diff --git a/src/qwp/index.ts b/src/qwp/index.ts index fe0279f..3b23628 100644 --- a/src/qwp/index.ts +++ b/src/qwp/index.ts @@ -7,3 +7,5 @@ * @packageDocumentation */ export * from "./core"; +export * from "./ingress-session"; +export * from "./transport"; diff --git a/src/qwp/ingress-session.ts b/src/qwp/ingress-session.ts new file mode 100644 index 0000000..2c2caf6 --- /dev/null +++ b/src/qwp/ingress-session.ts @@ -0,0 +1,211 @@ +import { + decodeQwpIngressResponse, + encodeQwpIngressFrame, + QWP_STATUS, + QwpIngressEncodeOptions, + QwpIngressResponse, + QwpProtocolError, + QwpTableBuffer, +} from "./core"; +import { + QwpBinaryConnection, + QwpConnectionCloseInfo, + QwpConnectionFactory, +} from "./transport"; + +export interface QwpIngressSessionOptions { + ackTimeoutMs?: number; + onResponse?: (response: QwpIngressResponse) => void; + onDurableAck?: (response: QwpIngressResponse) => void; +} + +interface PendingResponse { + resolve: (response: QwpIngressResponse) => void; + reject: (error: unknown) => void; + timer?: ReturnType; +} + +export class QwpIngressNackError extends Error { + constructor(readonly response: QwpIngressResponse) { + super( + response.errorMessage ?? + `QuestDB rejected QWP frame [status=0x${response.status.toString(16)}]`, + ); + this.name = "QwpIngressNackError"; + } +} + +export class QwpIngressSessionClosedError extends Error { + constructor(readonly closeInfo?: QwpConnectionCloseInfo) { + super( + closeInfo + ? `QWP ingress connection closed [code=${closeInfo.code}, reason=${closeInfo.reason}]` + : "QWP ingress session is closed", + ); + this.name = "QwpIngressSessionClosedError"; + } +} + +/** + * Connection-scoped ingress sequencer. + * + * One promise is registered before each WebSocket send, preventing a fast ACK + * from racing its waiter. Calls are serialized to preserve the server's + * zero-based wire sequence. + */ +export class QwpIngressSession { + private readonly pending = new Map(); + private nextSequence = 0n; + private sendTail: Promise = Promise.resolve(); + private failure?: Error; + private closing = false; + private readonly receiveLoop: Promise; + + constructor( + private readonly connection: QwpBinaryConnection, + private readonly options: QwpIngressSessionOptions = {}, + ) { + const timeout = options.ackTimeoutMs ?? 15_000; + if (!Number.isFinite(timeout) || timeout <= 0) { + throw new RangeError("ackTimeoutMs must be a positive finite number"); + } + this.receiveLoop = this.consumeMessages(); + } + + static async connect( + factory: QwpConnectionFactory, + options: QwpIngressSessionOptions = {}, + ): Promise { + return new QwpIngressSession(await factory(), options); + } + + get closed(): Promise { + return this.connection.closed; + } + + sendTables( + tables: readonly QwpTableBuffer[], + encodeOptions: QwpIngressEncodeOptions = {}, + ): Promise { + return this.sendFrame(encodeQwpIngressFrame(tables, encodeOptions)); + } + + sendFrame(frame: Uint8Array): Promise { + this.throwIfUnavailable(); + const sequence = this.nextSequence++; + let pending!: PendingResponse; + const response = new Promise((resolve, reject) => { + pending = { resolve, reject }; + }); + pending.timer = setTimeout(() => { + if (!this.pending.delete(sequence)) return; + pending.reject( + new Error(`timed out waiting for QWP ACK [sequence=${sequence}]`), + ); + }, this.options.ackTimeoutMs ?? 15_000); + this.pending.set(sequence, pending); + + const sending = this.sendTail.then(async () => { + this.throwIfUnavailable(); + await this.connection.send(frame); + }); + this.sendTail = sending.catch((error: unknown) => { + this.fail(error); + }); + void sending.catch((error: unknown) => { + const current = this.pending.get(sequence); + if (current !== pending) return; + this.pending.delete(sequence); + if (pending.timer) clearTimeout(pending.timer); + pending.reject(error); + }); + return response; + } + + async close(code = 1000, reason = ""): Promise { + if (this.closing) { + await this.connection.closed; + return; + } + this.closing = true; + this.rejectAll(new QwpIngressSessionClosedError()); + await this.sendTail; + await this.connection.close(code, reason); + await this.receiveLoop; + } + + private async consumeMessages(): Promise { + try { + for await (const payload of this.connection.messages) { + this.handleResponse(decodeQwpIngressResponse(payload)); + } + if (!this.closing) { + this.fail( + new QwpIngressSessionClosedError(await this.connection.closed), + ); + } + } catch (error) { + this.fail(error); + if (error instanceof QwpProtocolError) { + void this.connection.close(1002, "invalid QWP response"); + } + } + } + + private handleResponse(response: QwpIngressResponse): void { + this.invokeCallback(this.options.onResponse, response); + if (response.status === QWP_STATUS.DURABLE_ACK) { + this.invokeCallback(this.options.onDurableAck, response); + return; + } + if (response.sequence === null) { + throw new QwpProtocolError("QWP response is missing its wire sequence"); + } + const pending = this.pending.get(response.sequence); + if (!pending) { + // A late response after timeout, or a duplicate ACK, is harmless. + return; + } + this.pending.delete(response.sequence); + if (pending.timer) clearTimeout(pending.timer); + if (response.status === QWP_STATUS.OK) { + pending.resolve(response); + } else { + pending.reject(new QwpIngressNackError(response)); + } + } + + private invokeCallback( + callback: ((response: QwpIngressResponse) => void) | undefined, + response: QwpIngressResponse, + ): void { + if (!callback) return; + try { + callback(response); + } catch { + // Observability callbacks must not break protocol progress. + } + } + + private throwIfUnavailable(): void { + if (this.failure) throw this.failure; + if (this.closing) throw new QwpIngressSessionClosedError(); + } + + private fail(error: unknown): void { + if (this.failure) return; + this.failure = + error instanceof Error + ? error + : new Error(`QWP ingress failed: ${error}`); + this.rejectAll(this.failure); + } + + private rejectAll(error: Error): void { + for (const pending of this.pending.values()) { + if (pending.timer) clearTimeout(pending.timer); + pending.reject(error); + } + this.pending.clear(); + } +} diff --git a/src/qwp/internal/async-queue.ts b/src/qwp/internal/async-queue.ts new file mode 100644 index 0000000..72aed1d --- /dev/null +++ b/src/qwp/internal/async-queue.ts @@ -0,0 +1,61 @@ +interface PendingNext { + resolve: (result: IteratorResult) => void; + reject: (error: unknown) => void; +} + +/** Single-consumer async queue used to preserve WebSocket message ordering. */ +export class QwpAsyncQueue implements AsyncIterable { + private readonly values: T[] = []; + private readonly pending: PendingNext[] = []; + private ended = false; + private failure: unknown; + private iteratorCreated = false; + + push(value: T): void { + if (this.ended || this.failure !== undefined) return; + const pending = this.pending.shift(); + if (pending) { + pending.resolve({ value, done: false }); + } else { + this.values.push(value); + } + } + + end(): void { + if (this.ended || this.failure !== undefined) return; + this.ended = true; + for (const pending of this.pending.splice(0)) { + pending.resolve({ value: undefined, done: true }); + } + } + + fail(error: unknown): void { + if (this.ended || this.failure !== undefined) return; + this.failure = error; + for (const pending of this.pending.splice(0)) pending.reject(error); + } + + [Symbol.asyncIterator](): AsyncIterator { + if (this.iteratorCreated) { + throw new Error("QWP message streams support only one consumer"); + } + this.iteratorCreated = true; + return { + next: () => this.next(), + }; + } + + private next(): Promise> { + const value = this.values.shift(); + if (value !== undefined) { + return Promise.resolve({ value, done: false }); + } + if (this.failure !== undefined) return Promise.reject(this.failure); + if (this.ended) { + return Promise.resolve({ value: undefined, done: true }); + } + return new Promise((resolve, reject) => { + this.pending.push({ resolve, reject }); + }); + } +} diff --git a/src/qwp/internal/websocket-connection.ts b/src/qwp/internal/websocket-connection.ts new file mode 100644 index 0000000..bc2d41d --- /dev/null +++ b/src/qwp/internal/websocket-connection.ts @@ -0,0 +1,172 @@ +import { QwpProtocolError } from "../core"; +import { QwpBinaryConnection, QwpConnectionCloseInfo } from "../transport"; +import { QwpAsyncQueue } from "./async-queue"; + +interface QwpWebSocketMessageEvent { + data: unknown; +} + +interface QwpWebSocketCloseEvent { + code?: number; + reason?: string; + wasClean?: boolean; +} + +export interface QwpWebSocketLike { + binaryType: string; + readonly readyState: number; + send(data: Uint8Array): void; + close(code?: number, reason?: string): void; + addEventListener( + type: "open", + listener: (event: unknown) => void, + options?: { once?: boolean }, + ): void; + addEventListener( + type: "message", + listener: (event: QwpWebSocketMessageEvent) => void, + ): void; + addEventListener( + type: "error", + listener: (event: unknown) => void, + options?: { once?: boolean }, + ): void; + addEventListener( + type: "close", + listener: (event: QwpWebSocketCloseEvent) => void, + options?: { once?: boolean }, + ): void; +} + +const WEBSOCKET_OPEN = 1; +const WEBSOCKET_CLOSED = 3; + +async function normalizeBinaryMessage(data: unknown): Promise { + if (data instanceof ArrayBuffer) return new Uint8Array(data); + if (ArrayBuffer.isView(data)) { + return new Uint8Array( + data.buffer, + data.byteOffset, + data.byteLength, + ).slice(); + } + if (typeof Blob !== "undefined" && data instanceof Blob) { + return new Uint8Array(await data.arrayBuffer()); + } + throw new QwpProtocolError("QWP WebSocket received a non-binary message"); +} + +/** Wraps a WHATWG-style WebSocket and resolves once its opening handshake succeeds. */ +export function openQwpWebSocket( + socket: QwpWebSocketLike, + connectTimeoutMs = 15_000, +): Promise { + if (!Number.isFinite(connectTimeoutMs) || connectTimeoutMs <= 0) { + return Promise.reject( + new RangeError("connectTimeoutMs must be a positive finite number"), + ); + } + + const messages = new QwpAsyncQueue(); + let resolveClosed!: (info: QwpConnectionCloseInfo) => void; + const closed = new Promise((resolve) => { + resolveClosed = resolve; + }); + let opened = false; + let openingSettled = false; + let messageTail: Promise = Promise.resolve(); + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + if (openingSettled) return; + openingSettled = true; + try { + socket.close(1000, "QWP connection timeout"); + } catch { + // Some implementations throw when close() races an opening handshake. + } + reject(new Error("QWP WebSocket connection timed out")); + }, connectTimeoutMs); + + const failOpening = (error: Error): void => { + if (openingSettled) return; + openingSettled = true; + clearTimeout(timeout); + reject(error); + }; + + socket.binaryType = "arraybuffer"; + socket.addEventListener( + "open", + () => { + if (openingSettled) return; + openingSettled = true; + opened = true; + clearTimeout(timeout); + resolve({ + messages, + closed, + async send(payload: Uint8Array): Promise { + if (socket.readyState !== WEBSOCKET_OPEN) { + throw new Error("QWP WebSocket is not open"); + } + socket.send(payload); + }, + async close(code = 1000, reason = ""): Promise { + if (socket.readyState === WEBSOCKET_CLOSED) return; + socket.close(code, reason); + await closed; + }, + }); + }, + { once: true }, + ); + + socket.addEventListener("message", (event) => { + messageTail = messageTail + .then(async () => + messages.push(await normalizeBinaryMessage(event.data)), + ) + .catch((error: unknown) => { + messages.fail(error); + try { + socket.close(1002, "invalid QWP payload"); + } catch { + // The error still reaches the message iterator when close() fails. + } + }); + }); + + socket.addEventListener("error", () => { + const error = new Error("QWP WebSocket transport error"); + if (!opened) { + failOpening(error); + } else { + messages.fail(error); + } + }); + + socket.addEventListener( + "close", + (event) => { + clearTimeout(timeout); + const info = { + code: event.code ?? 1006, + reason: event.reason ?? "", + wasClean: event.wasClean ?? false, + }; + resolveClosed(info); + if (!opened) { + failOpening( + new Error( + `QWP WebSocket closed during handshake [code=${info.code}, reason=${info.reason}]`, + ), + ); + return; + } + void messageTail.finally(() => messages.end()); + }, + { once: true }, + ); + }); +} diff --git a/src/qwp/node.ts b/src/qwp/node.ts new file mode 100644 index 0000000..221a433 --- /dev/null +++ b/src/qwp/node.ts @@ -0,0 +1,79 @@ +/** Node.js WebSocket adapter and shared QWP protocol/session APIs. */ +export * from "./index"; + +import { Dispatcher, WebSocket } from "undici"; +import { + openQwpWebSocket, + QwpWebSocketLike, +} from "./internal/websocket-connection"; +import { QwpBinaryConnection, QwpWebSocketConnectOptions } from "./transport"; +import { QwpIngressSession, QwpIngressSessionOptions } from "./ingress-session"; + +export type { QwpWebSocketLike } from "./internal/websocket-connection"; + +export interface QwpNodeWebSocketOptions extends QwpWebSocketConnectOptions { + headers?: Record; + dispatcher?: Dispatcher; + authorization?: string; + clientId?: string; + maxVersion?: number; + requestDurableAck?: boolean; + /** Test hook; defaults to Undici's WebSocket implementation. */ + webSocketFactory?: ( + url: string | URL, + options: { + protocols?: string | string[]; + dispatcher?: Dispatcher; + headers: Record; + }, + ) => QwpWebSocketLike; +} + +/** Opens a Node QWP WebSocket with the upgrade headers required by QuestDB. */ +export function connectQwpNodeWebSocket( + options: QwpNodeWebSocketOptions, +): Promise { + const headers: Record = { + "X-QWP-Max-Version": String(options.maxVersion ?? 1), + "X-QWP-Client-Id": options.clientId ?? "typescript/1.0.0", + ...options.headers, + }; + if (options.authorization) headers.Authorization = options.authorization; + if (options.requestDurableAck) { + headers["X-QWP-Request-Durable-Ack"] = "true"; + } + + const factory = + options.webSocketFactory ?? + (( + url: string | URL, + init: { + protocols?: string | string[]; + dispatcher?: Dispatcher; + headers: Record; + }, + ) => + new WebSocket(url, { + protocols: init.protocols, + dispatcher: init.dispatcher, + headers: init.headers, + }) as unknown as QwpWebSocketLike); + + const socket = factory(options.url, { + protocols: options.protocols, + dispatcher: options.dispatcher, + headers, + }); + return openQwpWebSocket(socket, options.connectTimeoutMs); +} + +/** Opens a Node WebSocket and starts an ingress ACK/NACK session. */ +export async function connectQwpNodeIngress( + options: QwpNodeWebSocketOptions, + sessionOptions: QwpIngressSessionOptions = {}, +): Promise { + return new QwpIngressSession( + await connectQwpNodeWebSocket(options), + sessionOptions, + ); +} diff --git a/src/qwp/transport.ts b/src/qwp/transport.ts new file mode 100644 index 0000000..e7dbcd6 --- /dev/null +++ b/src/qwp/transport.ts @@ -0,0 +1,27 @@ +export interface QwpConnectionCloseInfo { + code: number; + reason: string; + wasClean: boolean; +} + +/** + * Normalized binary connection consumed by QWP sessions. + * + * Adapters buffer messages until the single async iterator consumes them, so + * unsolicited frames such as egress SERVER_INFO cannot race session startup. + */ +export interface QwpBinaryConnection { + readonly messages: AsyncIterable; + readonly closed: Promise; + + send(payload: Uint8Array): Promise; + close(code?: number, reason?: string): Promise; +} + +export interface QwpWebSocketConnectOptions { + url: string | URL; + protocols?: string | string[]; + connectTimeoutMs?: number; +} + +export type QwpConnectionFactory = () => Promise; diff --git a/test/qwp/session.test.ts b/test/qwp/session.test.ts new file mode 100644 index 0000000..7e63cef --- /dev/null +++ b/test/qwp/session.test.ts @@ -0,0 +1,244 @@ +import { describe, expect, it, vi } from "vitest"; +import { + connectQwpBrowserWebSocket, + QwpWebSocketLike, +} from "../../src/qwp/browser"; +import { connectQwpNodeWebSocket } from "../../src/qwp/node"; +import { + QWP_STATUS, + QwpByteWriter, + QwpIngressNackError, + QwpIngressSession, +} from "../../src/qwp"; + +type Listener = (event: unknown) => void; + +class FakeWebSocket { + binaryType = "blob"; + readyState = 0; + readonly sent: Uint8Array[] = []; + readonly closeCalls: { code?: number; reason?: string }[] = []; + onSend?: (payload: Uint8Array) => void; + private readonly listeners = new Map(); + + addEventListener(type: string, listener: Listener): void { + const listeners = this.listeners.get(type) ?? []; + listeners.push(listener); + this.listeners.set(type, listeners); + } + + send(payload: Uint8Array): void { + this.sent.push(payload.slice()); + this.onSend?.(payload); + } + + close(code?: number, reason?: string): void { + this.closeCalls.push({ code, reason }); + if (this.readyState === 3) return; + this.readyState = 3; + this.emit("close", { + code: code ?? 1000, + reason: reason ?? "", + wasClean: true, + }); + } + + open(): void { + this.readyState = 1; + this.emit("open", {}); + } + + message(data: unknown): void { + this.emit("message", { data }); + } + + error(): void { + this.emit("error", {}); + } + + private emit(type: string, event: unknown): void { + for (const listener of this.listeners.get(type) ?? []) listener(event); + } +} + +function asQwpSocket(socket: FakeWebSocket): QwpWebSocketLike { + return socket as unknown as QwpWebSocketLike; +} + +function ingressResponse( + status: number, + sequence: bigint, + message?: string, +): Uint8Array { + const writer = new QwpByteWriter(); + writer.writeUint8(status).writeBigUint64(sequence); + if (status === QWP_STATUS.OK) { + writer.writeUint16(0); + } else { + const encoded = new TextEncoder().encode(message ?? "rejected"); + writer.writeUint16(encoded.length).writeBytes(encoded); + } + return writer.toUint8Array(); +} + +describe("QWP WebSocket adapters", () => { + it("buffers browser messages until a consumer is attached", async () => { + const socket = new FakeWebSocket(); + const connecting = connectQwpBrowserWebSocket({ + url: "ws://localhost:9000/write/v4", + webSocketFactory: () => asQwpSocket(socket), + }); + socket.open(); + const connection = await connecting; + expect(socket.binaryType).toBe("arraybuffer"); + + socket.message(Uint8Array.from([1, 2, 3]).buffer); + const iterator = connection.messages[Symbol.asyncIterator](); + await expect(iterator.next()).resolves.toEqual({ + value: Uint8Array.from([1, 2, 3]), + done: false, + }); + await connection.close(); + }); + + it("adds Node-only QWP upgrade headers", async () => { + const socket = new FakeWebSocket(); + let capturedHeaders: Record | undefined; + const connecting = connectQwpNodeWebSocket({ + url: "ws://localhost:9000/write/v4", + authorization: "Basic token", + clientId: "typescript/test", + requestDurableAck: true, + webSocketFactory: (_url, options) => { + capturedHeaders = options.headers; + return asQwpSocket(socket); + }, + }); + socket.open(); + const connection = await connecting; + expect(capturedHeaders).toMatchObject({ + "X-QWP-Max-Version": "1", + "X-QWP-Client-Id": "typescript/test", + "X-QWP-Request-Durable-Ack": "true", + Authorization: "Basic token", + }); + await connection.close(); + }); + + it("rejects a connection that does not open before its deadline", async () => { + vi.useFakeTimers(); + try { + const socket = new FakeWebSocket(); + const connecting = connectQwpBrowserWebSocket({ + url: "ws://localhost:9000/write/v4", + connectTimeoutMs: 25, + webSocketFactory: () => asQwpSocket(socket), + }); + const rejected = expect(connecting).rejects.toThrow(/timed out/i); + await vi.advanceTimersByTimeAsync(25); + await rejected; + expect(socket.closeCalls).toHaveLength(1); + } finally { + vi.useRealTimers(); + } + }); + + it("rejects text frames and closes with a protocol error", async () => { + const socket = new FakeWebSocket(); + const connecting = connectQwpBrowserWebSocket({ + url: "ws://localhost:9000/write/v4", + webSocketFactory: () => asQwpSocket(socket), + }); + socket.open(); + const connection = await connecting; + const next = connection.messages[Symbol.asyncIterator]().next(); + const rejected = expect(next).rejects.toThrow(/non-binary/i); + socket.message("not binary"); + await rejected; + expect(socket.closeCalls).toContainEqual({ + code: 1002, + reason: "invalid QWP payload", + }); + }); +}); + +describe("QwpIngressSession", () => { + it("registers ACK waiters before sending and preserves call order", async () => { + const socket = new FakeWebSocket(); + const connecting = connectQwpBrowserWebSocket({ + url: "ws://localhost:9000/write/v4", + webSocketFactory: () => asQwpSocket(socket), + }); + socket.open(); + const connection = await connecting; + const session = new QwpIngressSession(connection); + let sequence = 0n; + socket.onSend = () => { + socket.message(ingressResponse(QWP_STATUS.OK, sequence++)); + }; + + const first = session.sendFrame(Uint8Array.of(1)); + const second = session.sendFrame(Uint8Array.of(2)); + await expect(Promise.all([first, second])).resolves.toMatchObject([ + { status: QWP_STATUS.OK, sequence: 0n }, + { status: QWP_STATUS.OK, sequence: 1n }, + ]); + expect(socket.sent).toEqual([Uint8Array.of(1), Uint8Array.of(2)]); + await session.close(); + }); + + it("rejects the matching frame on NACK without breaking later ACKs", async () => { + const socket = new FakeWebSocket(); + const connecting = connectQwpBrowserWebSocket({ + url: "ws://localhost:9000/write/v4", + webSocketFactory: () => asQwpSocket(socket), + }); + socket.open(); + const session = new QwpIngressSession(await connecting); + let sequence = 0n; + socket.onSend = () => { + const current = sequence++; + socket.message( + ingressResponse( + current === 0n ? QWP_STATUS.WRITE_ERROR : QWP_STATUS.OK, + current, + "write failed", + ), + ); + }; + + await expect(session.sendFrame(Uint8Array.of(1))).rejects.toMatchObject({ + name: "QwpIngressNackError", + response: { sequence: 0n, errorMessage: "write failed" }, + } satisfies Partial); + await expect(session.sendFrame(Uint8Array.of(2))).resolves.toMatchObject({ + sequence: 1n, + status: QWP_STATUS.OK, + }); + await session.close(); + }); + + it("times out an ACK without losing session closeability", async () => { + vi.useFakeTimers(); + try { + const socket = new FakeWebSocket(); + const connecting = connectQwpBrowserWebSocket({ + url: "ws://localhost:9000/write/v4", + webSocketFactory: () => asQwpSocket(socket), + }); + socket.open(); + const session = new QwpIngressSession(await connecting, { + ackTimeoutMs: 25, + }); + const response = session.sendFrame(Uint8Array.of(1)); + const rejected = expect(response).rejects.toThrow( + /timed out.*sequence=0/i, + ); + await vi.advanceTimersByTimeAsync(25); + await rejected; + await session.close(); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/tsconfig.qwp-browser.json b/tsconfig.qwp-browser.json index 6b14c07..f796f63 100644 --- a/tsconfig.qwp-browser.json +++ b/tsconfig.qwp-browser.json @@ -1,5 +1,6 @@ { "include": ["src/qwp/**/*.ts"], + "exclude": ["src/qwp/node.ts"], "compilerOptions": { "moduleResolution": "bundler", "module": "ESNext", From a44587d604cbc08aa344fa71fb115ec9bbfc7092 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Mon, 17 Aug 2026 15:47:38 +0100 Subject: [PATCH 006/265] feat(qwp): add egress query sessions --- src/qwp/browser.ts | 20 +- src/qwp/core/egress.ts | 18 +- src/qwp/core/index.ts | 1 + src/qwp/core/result-batch.ts | 624 +++++++++++++++++++++++++++++++++++ src/qwp/egress-session.ts | 352 ++++++++++++++++++++ src/qwp/index.ts | 1 + src/qwp/node.ts | 12 + test/qwp/egress.test.ts | 330 ++++++++++++++++++ 8 files changed, 1345 insertions(+), 13 deletions(-) create mode 100644 src/qwp/core/result-batch.ts create mode 100644 src/qwp/egress-session.ts create mode 100644 test/qwp/egress.test.ts diff --git a/src/qwp/browser.ts b/src/qwp/browser.ts index 606d62c..5d3c25b 100644 --- a/src/qwp/browser.ts +++ b/src/qwp/browser.ts @@ -6,6 +6,7 @@ import { QwpWebSocketLike, } from "./internal/websocket-connection"; import { QwpBinaryConnection, QwpWebSocketConnectOptions } from "./transport"; +import { QwpEgressSession, QwpEgressSessionOptions } from "./egress-session"; import { QwpIngressSession, QwpIngressSessionOptions } from "./ingress-session"; export type { QwpWebSocketLike } from "./internal/websocket-connection"; @@ -21,9 +22,11 @@ export interface QwpBrowserWebSocketOptions extends QwpWebSocketConnectOptions { /** * Opens a QWP-capable browser WebSocket. * - * Browsers cannot set Authorization or X-QWP-* upgrade headers. The server or - * gateway must therefore support the browser QWP handshake (Origin policy and - * browser-compatible authentication/version negotiation). + * Browsers cannot set Authorization or X-QWP-* upgrade headers. QuestDB accepts + * browser upgrades when Origin and Host have the same authority, so serve the + * app from the QuestDB origin or route QWP through a same-origin reverse proxy. + * When authentication is enabled, the deployment must provide a + * browser-compatible authentication mechanism. */ export function connectQwpBrowserWebSocket( options: QwpBrowserWebSocketOptions, @@ -58,3 +61,14 @@ export async function connectQwpBrowserIngress( sessionOptions, ); } + +/** Opens a browser WebSocket and waits for the egress SERVER_INFO handshake. */ +export async function connectQwpBrowserEgress( + options: QwpBrowserWebSocketOptions, + sessionOptions: QwpEgressSessionOptions = {}, +): Promise { + return QwpEgressSession.connect( + () => connectQwpBrowserWebSocket(options), + sessionOptions, + ); +} diff --git a/src/qwp/core/egress.ts b/src/qwp/core/egress.ts index 8ab9315..3d1ccea 100644 --- a/src/qwp/core/egress.ts +++ b/src/qwp/core/egress.ts @@ -16,7 +16,7 @@ export interface QwpQueryRequest { queryFlags?: number | bigint; } -export interface QwpServerInfoMessage { +export interface QwpServerInfoMessage extends QwpFrameHeader { kind: "server-info"; role: number; epoch: bigint; @@ -27,7 +27,7 @@ export interface QwpServerInfoMessage { zoneId: string | null; } -export interface QwpResultBatchMessage { +export interface QwpResultBatchMessage extends QwpFrameHeader { kind: "result-batch"; requestId: bigint; batchSequence: bigint; @@ -35,41 +35,39 @@ export interface QwpResultBatchMessage { body: Uint8Array; } -export interface QwpResultEndMessage { +export interface QwpResultEndMessage extends QwpFrameHeader { kind: "result-end"; requestId: bigint; finalSequence: bigint; totalRows: bigint; } -export interface QwpQueryErrorMessage { +export interface QwpQueryErrorMessage extends QwpFrameHeader { kind: "query-error"; requestId: bigint; status: number; message: string; } -export interface QwpExecDoneMessage { +export interface QwpExecDoneMessage extends QwpFrameHeader { kind: "exec-done"; requestId: bigint; operationType: number; rowsAffected: bigint; } -export interface QwpCacheResetMessage { +export interface QwpCacheResetMessage extends QwpFrameHeader { kind: "cache-reset"; resetMask: number; } -export type QwpEgressMessage = ( +export type QwpEgressMessage = | QwpServerInfoMessage | QwpResultBatchMessage | QwpResultEndMessage | QwpQueryErrorMessage | QwpExecDoneMessage - | QwpCacheResetMessage -) & - QwpFrameHeader; + | QwpCacheResetMessage; function requestId(value: number | bigint): bigint { if (typeof value === "number") { diff --git a/src/qwp/core/index.ts b/src/qwp/core/index.ts index dc634a7..7e983b2 100644 --- a/src/qwp/core/index.ts +++ b/src/qwp/core/index.ts @@ -5,6 +5,7 @@ export * from "./errors"; export * from "./frame"; export * from "./gorilla"; export * from "./ingress"; +export * from "./result-batch"; export * from "./symbol-dictionary"; export * from "./table"; export * from "./varint"; diff --git a/src/qwp/core/result-batch.ts b/src/qwp/core/result-batch.ts new file mode 100644 index 0000000..fb0766e --- /dev/null +++ b/src/qwp/core/result-batch.ts @@ -0,0 +1,624 @@ +import { decodeUtf8, QwpByteReader } from "./bytes"; +import { + QWP_COLUMN_TYPE, + QWP_FLAG_DELTA_SYMBOL_DICTIONARY, + QWP_FLAG_GORILLA, + QWP_FLAG_ZSTD, + QWP_MAX_COLUMN_NAME_LENGTH, + QWP_MAX_COLUMNS_PER_TABLE, + QWP_MAX_TABLE_NAME_LENGTH, + QWP_RESET_MASK_DICTIONARY, + QwpColumnType, +} from "./constants"; +import { QwpResultBatchMessage } from "./egress"; +import { QwpProtocolError } from "./errors"; +import { readQwpVarint } from "./varint"; + +const MAX_ARRAY_DIMENSION_LENGTH = (1 << 28) - 1; +const MAX_ARRAY_ELEMENTS = 268_435_327; +const MAX_CONNECTION_SYMBOLS = 8_388_608; +const MAX_ROWS_PER_BATCH = 1_048_576; + +export interface QwpDecimalValue { + unscaled: bigint; + scale: number; +} + +export interface QwpUuidValue { + low: bigint; + high: bigint; +} + +export interface QwpLong256Value { + /** Little-endian 64-bit words; word 0 is least significant. */ + words: readonly [bigint, bigint, bigint, bigint]; +} + +export interface QwpGeohashValue { + bits: bigint; + precisionBits: number; +} + +export interface QwpResultArrayValue { + dimensions: readonly number[]; + values: readonly number[] | readonly bigint[]; +} + +export type QwpResultValue = + | boolean + | number + | bigint + | string + | Uint8Array + | QwpDecimalValue + | QwpUuidValue + | QwpLong256Value + | QwpGeohashValue + | QwpResultArrayValue + | null; + +export interface QwpResultColumnSchema { + name: string; + type: QwpColumnType; +} + +export interface QwpResultColumn extends QwpResultColumnSchema { + values: readonly QwpResultValue[]; + scale?: number; + precisionBits?: number; +} + +export class QwpResultBatch { + constructor( + readonly requestId: bigint, + readonly batchSequence: bigint, + readonly tableName: string, + readonly rowCount: number, + readonly columns: readonly QwpResultColumn[], + ) {} + + get(rowIndex: number, columnIndex: number): QwpResultValue { + if ( + !Number.isInteger(rowIndex) || + rowIndex < 0 || + rowIndex >= this.rowCount + ) { + throw new RangeError(`row index out of range: ${rowIndex}`); + } + const column = this.columns[columnIndex]; + if (!column) + throw new RangeError(`column index out of range: ${columnIndex}`); + return column.values[rowIndex]; + } + + *rows(): IterableIterator { + for (let row = 0; row < this.rowCount; row++) { + yield this.columns.map((column) => column.values[row]); + } + } +} + +interface NullLayout { + nulls: boolean[]; + nonNullCount: number; +} + +class QwpBitReader { + private bitPosition = 0; + + constructor(private readonly bytes: Uint8Array) {} + + get bytesConsumed(): number { + return Math.ceil(this.bitPosition / 8); + } + + readBit(): number { + if (this.bitPosition >= this.bytes.length * 8) { + throw new QwpProtocolError("truncated QWP Gorilla bitstream"); + } + const result = + (this.bytes[this.bitPosition >>> 3] >>> (this.bitPosition & 7)) & 1; + this.bitPosition++; + return result; + } + + readSigned(bitCount: number): bigint { + let value = 0n; + for (let bit = 0; bit < bitCount; bit++) { + if (this.readBit() !== 0) value |= 1n << BigInt(bit); + } + const sign = 1n << BigInt(bitCount - 1); + return (value & sign) === 0n ? value : value - (1n << BigInt(bitCount)); + } +} + +function readCount( + reader: QwpByteReader, + maximum: number, + label: string, +): number { + const value = readQwpVarint(reader); + if (value > BigInt(maximum)) { + throw new QwpProtocolError(`${label} out of range: ${value}`); + } + return Number(value); +} + +function readNullLayout(reader: QwpByteReader, rowCount: number): NullLayout { + const flag = reader.readUint8("column null flag"); + if (flag !== 0 && flag !== 1) { + throw new QwpProtocolError(`invalid column null flag: ${flag}`); + } + const nulls = new Array(rowCount).fill(false); + if (flag === 0) return { nulls, nonNullCount: rowCount }; + + const bitmap = reader.readBytes( + Math.ceil(rowCount / 8), + "column null bitmap", + ); + let nonNullCount = rowCount; + for (let row = 0; row < rowCount; row++) { + if ((bitmap[row >>> 3] & (1 << (row & 7))) !== 0) { + nulls[row] = true; + nonNullCount--; + } + } + return { nulls, nonNullCount }; +} + +function expandNulls( + dense: readonly T[], + layout: NullLayout, +): QwpResultValue[] { + const values = new Array(layout.nulls.length); + let denseIndex = 0; + for (let row = 0; row < layout.nulls.length; row++) { + values[row] = layout.nulls[row] ? null : dense[denseIndex++]; + } + return values; +} + +function readSignedLittleEndian( + reader: QwpByteReader, + byteCount: number, + label: string, +): bigint { + const bytes = reader.readBytes(byteCount, label); + let value = 0n; + for (let index = 0; index < byteCount; index++) { + value |= BigInt(bytes[index]) << BigInt(index * 8); + } + const bits = BigInt(byteCount * 8); + const sign = 1n << (bits - 1n); + return (value & sign) === 0n ? value : value - (1n << bits); +} + +function readStringValues( + reader: QwpByteReader, + count: number, + binary: boolean, +): (string | Uint8Array)[] { + const offsets = new Array(count + 1); + for (let index = 0; index <= count; index++) { + offsets[index] = reader.readUint32("variable-width column offset"); + } + if (offsets[0] !== 0) { + throw new QwpProtocolError( + "variable-width column must start at offset zero", + ); + } + for (let index = 1; index < offsets.length; index++) { + if (offsets[index] < offsets[index - 1]) { + throw new QwpProtocolError( + `variable-width column offsets are not monotonic at index ${index}`, + ); + } + } + const bytes = reader.readBytes(offsets[count], "variable-width column data"); + const values = new Array(count); + for (let index = 0; index < count; index++) { + const value = bytes.subarray(offsets[index], offsets[index + 1]); + values[index] = binary ? value.slice() : decodeUtf8(value); + } + return values; +} + +function decodeGorillaValues(reader: QwpByteReader, count: number): bigint[] { + if (count < 3) { + throw new QwpProtocolError( + `Gorilla-encoded column has fewer than three values: ${count}`, + ); + } + const first = reader.readBigInt64("first Gorilla timestamp"); + const second = reader.readBigInt64("second Gorilla timestamp"); + const values = [first, second]; + const bits = new QwpBitReader( + reader.bytes.subarray(reader.position, reader.position + reader.remaining), + ); + let previousTimestamp = second; + let previousDelta = BigInt.asIntN(64, second - first); + for (let index = 2; index < count; index++) { + let deltaOfDelta: bigint; + let prefixOnes = 0; + while (prefixOnes < 4 && bits.readBit() !== 0) prefixOnes++; + switch (prefixOnes) { + case 0: + deltaOfDelta = 0n; + break; + case 1: + deltaOfDelta = bits.readSigned(7); + break; + case 2: + deltaOfDelta = bits.readSigned(9); + break; + case 3: + deltaOfDelta = bits.readSigned(12); + break; + default: + deltaOfDelta = bits.readSigned(32); + } + const delta = BigInt.asIntN(64, previousDelta + deltaOfDelta); + const timestamp = BigInt.asIntN(64, previousTimestamp + delta); + values.push(timestamp); + previousDelta = delta; + previousTimestamp = timestamp; + } + reader.readBytes(bits.bytesConsumed, "Gorilla bitstream"); + return values; +} + +function readTimestampValues( + reader: QwpByteReader, + count: number, + gorilla: boolean, +): bigint[] { + if (!gorilla) { + return Array.from({ length: count }, () => + reader.readBigInt64("timestamp value"), + ); + } + const encoding = reader.readUint8("timestamp encoding"); + if (encoding === 0) { + return Array.from({ length: count }, () => + reader.readBigInt64("timestamp value"), + ); + } + if (encoding !== 1) { + throw new QwpProtocolError(`unknown timestamp encoding: ${encoding}`); + } + return decodeGorillaValues(reader, count); +} + +function readArrayValue( + reader: QwpByteReader, + type: QwpColumnType, +): QwpResultArrayValue { + const dimensions = reader.readUint8("array dimension count"); + if (dimensions < 1 || dimensions > 32) { + throw new QwpProtocolError( + `array dimension count out of range: ${dimensions}`, + ); + } + const shape = new Array(dimensions); + let elementCount = 1; + for (let index = 0; index < dimensions; index++) { + const length = reader.readInt32("array dimension length"); + if (length < 0 || length > MAX_ARRAY_DIMENSION_LENGTH) { + throw new QwpProtocolError( + `array dimension length out of range: ${length}`, + ); + } + shape[index] = length; + elementCount *= length; + if (elementCount > MAX_ARRAY_ELEMENTS) { + throw new QwpProtocolError( + `array element count exceeds ${MAX_ARRAY_ELEMENTS}`, + ); + } + } + if (elementCount > Math.floor(reader.remaining / 8)) { + throw new QwpProtocolError("truncated array payload"); + } + if (type === QWP_COLUMN_TYPE.DOUBLE_ARRAY) { + return { + dimensions: shape, + values: Array.from({ length: elementCount }, () => + reader.readFloat64("double array element"), + ), + }; + } + return { + dimensions: shape, + values: Array.from({ length: elementCount }, () => + reader.readBigInt64("long array element"), + ), + }; +} + +/** Stateful decoder for connection-scoped QWP result batches. */ +export class QwpResultBatchDecoder { + private readonly symbolDictionary: string[] = []; + private schema?: QwpResultColumnSchema[]; + private expectedBatchSequence = 0n; + + resetQuerySchema(): void { + this.schema = undefined; + this.expectedBatchSequence = 0n; + } + + applyCacheReset(resetMask: number): void { + if ((resetMask & QWP_RESET_MASK_DICTIONARY) !== 0) { + this.symbolDictionary.length = 0; + } + } + + decode(message: QwpResultBatchMessage): QwpResultBatch { + if ((message.flags & QWP_FLAG_ZSTD) !== 0) { + throw new QwpProtocolError( + "zstd-compressed QWP result batches are not supported by this runtime-neutral decoder", + ); + } + if (message.tableCount !== 1) { + throw new QwpProtocolError( + `RESULT_BATCH must contain exactly one table, got ${message.tableCount}`, + ); + } + if (message.batchSequence !== this.expectedBatchSequence) { + throw new QwpProtocolError( + `unexpected RESULT_BATCH sequence [expected=${this.expectedBatchSequence}, actual=${message.batchSequence}]`, + ); + } + + const reader = new QwpByteReader(message.body); + const deltaMode = (message.flags & QWP_FLAG_DELTA_SYMBOL_DICTIONARY) !== 0; + if (deltaMode) this.readDeltaDictionary(reader); + + const tableNameLength = readCount( + reader, + QWP_MAX_TABLE_NAME_LENGTH, + "table name length", + ); + const tableName = reader.readUtf8(tableNameLength, "table name"); + const rowCount = readCount(reader, MAX_ROWS_PER_BATCH, "result row count"); + + if (message.batchSequence === 0n) { + const columnCount = readCount( + reader, + QWP_MAX_COLUMNS_PER_TABLE, + "result column count", + ); + this.schema = Array.from({ length: columnCount }, () => { + const nameLength = readCount( + reader, + QWP_MAX_COLUMN_NAME_LENGTH, + "column name length", + ); + const name = reader.readUtf8(nameLength, "column name"); + const type = reader.readUint8("column type") as QwpColumnType; + if (!Object.values(QWP_COLUMN_TYPE).includes(type)) { + throw new QwpProtocolError( + `unsupported QWP result column type: 0x${type.toString(16)}`, + ); + } + return { name, type }; + }); + } else if (!this.schema) { + throw new QwpProtocolError( + "continuation RESULT_BATCH arrived before its schema-bearing batch", + ); + } + + const columns = this.schema!.map((column) => + this.readColumn(reader, column, rowCount, deltaMode, message.flags), + ); + reader.expectEnd("RESULT_BATCH"); + this.expectedBatchSequence++; + return new QwpResultBatch( + message.requestId, + message.batchSequence, + tableName, + rowCount, + columns, + ); + } + + private readColumn( + reader: QwpByteReader, + schema: QwpResultColumnSchema, + rowCount: number, + deltaMode: boolean, + flags: number, + ): QwpResultColumn { + const layout = readNullLayout(reader, rowCount); + const count = layout.nonNullCount; + let dense: QwpResultValue[]; + let scale: number | undefined; + let precisionBits: number | undefined; + + switch (schema.type) { + case QWP_COLUMN_TYPE.BOOLEAN: { + const bytes = reader.readBytes(Math.ceil(count / 8), "boolean values"); + dense = Array.from( + { length: count }, + (_, index) => (bytes[index >>> 3] & (1 << (index & 7))) !== 0, + ); + break; + } + case QWP_COLUMN_TYPE.BYTE: + dense = Array.from({ length: count }, () => + reader.readInt8("byte value"), + ); + break; + case QWP_COLUMN_TYPE.SHORT: + dense = Array.from({ length: count }, () => + reader.readInt16("short value"), + ); + break; + case QWP_COLUMN_TYPE.CHAR: + dense = Array.from({ length: count }, () => + String.fromCharCode(reader.readUint16("char value")), + ); + break; + case QWP_COLUMN_TYPE.INT: + case QWP_COLUMN_TYPE.IPV4: + dense = Array.from({ length: count }, () => + reader.readInt32("int value"), + ); + break; + case QWP_COLUMN_TYPE.FLOAT: + dense = Array.from({ length: count }, () => + reader.readFloat32("float value"), + ); + break; + case QWP_COLUMN_TYPE.DOUBLE: + dense = Array.from({ length: count }, () => + reader.readFloat64("double value"), + ); + break; + case QWP_COLUMN_TYPE.LONG: + dense = Array.from({ length: count }, () => + reader.readBigInt64("long value"), + ); + break; + case QWP_COLUMN_TYPE.DATE: + case QWP_COLUMN_TYPE.TIMESTAMP: + case QWP_COLUMN_TYPE.TIMESTAMP_NANOS: + dense = readTimestampValues( + reader, + count, + (flags & QWP_FLAG_GORILLA) !== 0, + ); + break; + case QWP_COLUMN_TYPE.VARCHAR: + dense = readStringValues(reader, count, false); + break; + case QWP_COLUMN_TYPE.BINARY: + dense = readStringValues(reader, count, true); + break; + case QWP_COLUMN_TYPE.SYMBOL: + dense = this.readSymbols(reader, count, rowCount, deltaMode); + break; + case QWP_COLUMN_TYPE.UUID: + dense = Array.from({ length: count }, () => ({ + low: reader.readBigUint64("UUID low bits"), + high: reader.readBigUint64("UUID high bits"), + })); + break; + case QWP_COLUMN_TYPE.LONG256: + dense = Array.from({ length: count }, () => ({ + words: [ + reader.readBigInt64("LONG256 word 0"), + reader.readBigInt64("LONG256 word 1"), + reader.readBigInt64("LONG256 word 2"), + reader.readBigInt64("LONG256 word 3"), + ] as const, + })); + break; + case QWP_COLUMN_TYPE.DECIMAL64: + case QWP_COLUMN_TYPE.DECIMAL128: + case QWP_COLUMN_TYPE.DECIMAL256: { + scale = reader.readUint8("decimal scale"); + const bytes = + schema.type === QWP_COLUMN_TYPE.DECIMAL64 + ? 8 + : schema.type === QWP_COLUMN_TYPE.DECIMAL128 + ? 16 + : 32; + dense = Array.from({ length: count }, () => ({ + unscaled: readSignedLittleEndian(reader, bytes, "decimal value"), + scale: scale!, + })); + break; + } + case QWP_COLUMN_TYPE.GEOHASH: { + precisionBits = readCount(reader, 60, "geohash precision"); + if (precisionBits < 1) { + throw new QwpProtocolError( + `geohash precision out of range: ${precisionBits}`, + ); + } + const byteCount = Math.ceil(precisionBits / 8); + dense = Array.from({ length: count }, () => { + const bytes = reader.readBytes(byteCount, "geohash value"); + let bits = 0n; + for (let index = 0; index < bytes.length; index++) { + bits |= BigInt(bytes[index]) << BigInt(index * 8); + } + return { bits, precisionBits: precisionBits! }; + }); + break; + } + case QWP_COLUMN_TYPE.DOUBLE_ARRAY: + case QWP_COLUMN_TYPE.LONG_ARRAY: + dense = Array.from({ length: count }, () => + readArrayValue(reader, schema.type), + ); + break; + default: + throw new QwpProtocolError( + `unsupported QWP result column type: ${String(schema.type)}`, + ); + } + + return { + ...schema, + values: expandNulls(dense, layout), + ...(scale === undefined ? {} : { scale }), + ...(precisionBits === undefined ? {} : { precisionBits }), + }; + } + + private readDeltaDictionary(reader: QwpByteReader): void { + const start = readCount( + reader, + MAX_CONNECTION_SYMBOLS, + "delta dictionary start", + ); + const count = readCount( + reader, + MAX_CONNECTION_SYMBOLS, + "delta dictionary count", + ); + if (start !== this.symbolDictionary.length) { + throw new QwpProtocolError( + `delta symbol dictionary is out of sync [expected=${this.symbolDictionary.length}, actual=${start}]`, + ); + } + if (start + count > MAX_CONNECTION_SYMBOLS) { + throw new QwpProtocolError( + `symbol dictionary exceeds ${MAX_CONNECTION_SYMBOLS} entries`, + ); + } + for (let index = 0; index < count; index++) { + const length = readCount(reader, reader.remaining, "symbol length"); + this.symbolDictionary.push(reader.readUtf8(length, "symbol")); + } + } + + private readSymbols( + reader: QwpByteReader, + count: number, + rowCount: number, + deltaMode: boolean, + ): string[] { + let dictionary: readonly string[]; + if (deltaMode) { + dictionary = this.symbolDictionary; + } else { + const size = readCount(reader, rowCount, "symbol dictionary size"); + const local = new Array(size); + for (let index = 0; index < size; index++) { + const length = readCount(reader, reader.remaining, "symbol length"); + local[index] = reader.readUtf8(length, "symbol"); + } + dictionary = local; + } + return Array.from({ length: count }, () => { + const id = readCount(reader, dictionary.length, "symbol ID"); + if (id >= dictionary.length) { + throw new QwpProtocolError(`symbol ID out of range: ${id}`); + } + return dictionary[id]; + }); + } +} diff --git a/src/qwp/egress-session.ts b/src/qwp/egress-session.ts new file mode 100644 index 0000000..c161536 --- /dev/null +++ b/src/qwp/egress-session.ts @@ -0,0 +1,352 @@ +import { + decodeQwpEgressMessage, + encodeQwpCancel, + encodeQwpCredit, + encodeQwpQueryRequest, + QWP_EGRESS_CAPABILITY, + QWP_QUERY_FLAG_RESET_DICTIONARY, + QwpExecDoneMessage, + QwpProtocolError, + QwpQueryRequest, + QwpResultBatch, + QwpResultBatchDecoder, + QwpResultEndMessage, + QwpServerInfoMessage, +} from "./core"; +import { QwpAsyncQueue } from "./internal/async-queue"; +import { + QwpBinaryConnection, + QwpConnectionCloseInfo, + QwpConnectionFactory, +} from "./transport"; + +export interface QwpEgressSessionOptions { + serverInfoTimeoutMs?: number; +} + +export interface QwpEgressQueryOptions { + /** Zero means the server may stream without credit accounting. */ + initialCredit?: number | bigint; + bindCount?: number; + /** Pre-encoded positional bind payload. */ + bindPayload?: Uint8Array; + /** Ask a capable server to reset its connection-scoped symbol dictionary. */ + resetDictionary?: boolean; +} + +export type QwpQueryCompletion = QwpResultEndMessage | QwpExecDoneMessage; + +export class QwpEgressQueryError extends Error { + constructor( + readonly requestId: bigint, + readonly status: number, + message: string, + ) { + super(message); + this.name = "QwpEgressQueryError"; + } +} + +export class QwpEgressSessionClosedError extends Error { + constructor(readonly closeInfo?: QwpConnectionCloseInfo) { + super( + closeInfo + ? `QWP egress connection closed [code=${closeInfo.code}, reason=${closeInfo.reason}]` + : "QWP egress session is closed", + ); + this.name = "QwpEgressSessionClosedError"; + } +} + +interface QwpEgressQueryControl { + cancel(requestId: bigint): Promise; + grantCredit( + requestId: bigint, + additionalBytes: number | bigint, + ): Promise; +} + +/** One QWP query/statement and its stream of materialized result batches. */ +export class QwpEgressQuery implements AsyncIterable { + private readonly batches = new QwpAsyncQueue(); + private readonly resolveCompletion: (value: QwpQueryCompletion) => void; + private readonly rejectCompletion: (error: unknown) => void; + readonly completion: Promise; + + constructor( + readonly requestId: bigint, + private readonly control: QwpEgressQueryControl, + ) { + let resolve!: (value: QwpQueryCompletion) => void; + let reject!: (error: unknown) => void; + this.completion = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + // Consumers commonly use only `for await`; keep the parallel completion + // rejection from becoming an unhandled promise while preserving awaitability. + void this.completion.catch(() => undefined); + this.resolveCompletion = resolve; + this.rejectCompletion = reject; + } + + [Symbol.asyncIterator](): AsyncIterator { + return this.batches[Symbol.asyncIterator](); + } + + cancel(): Promise { + return this.control.cancel(this.requestId); + } + + grantCredit(additionalBytes: number | bigint): Promise { + return this.control.grantCredit(this.requestId, additionalBytes); + } + + /** @internal */ + push(batch: QwpResultBatch): void { + this.batches.push(batch); + } + + /** @internal */ + finish(completion: QwpQueryCompletion): void { + this.batches.end(); + this.resolveCompletion(completion); + } + + /** @internal */ + fail(error: unknown): void { + this.batches.fail(error); + this.rejectCompletion(error); + } +} + +/** + * Browser-safe QWP egress session. + * + * The server currently executes one query at a time per connection, so this + * session deliberately rejects overlapping query calls. A completed query's + * materialized batches may still be consumed while the next query runs. + */ +export class QwpEgressSession implements QwpEgressQueryControl { + private readonly decoder = new QwpResultBatchDecoder(); + private readonly receiveLoop: Promise; + private readonly resolveServerInfo: (value: QwpServerInfoMessage) => void; + private readonly rejectServerInfo: (error: unknown) => void; + private readonly serverInfoTimer: ReturnType; + private active?: QwpEgressQuery; + private nextRequestId = 0n; + private sendTail: Promise = Promise.resolve(); + private serverInfo?: QwpServerInfoMessage; + private failure?: Error; + private closing = false; + readonly ready: Promise; + + constructor( + private readonly connection: QwpBinaryConnection, + options: QwpEgressSessionOptions = {}, + ) { + const timeout = options.serverInfoTimeoutMs ?? 15_000; + if (!Number.isFinite(timeout) || timeout <= 0) { + throw new RangeError( + "serverInfoTimeoutMs must be a positive finite number", + ); + } + let resolve!: (value: QwpServerInfoMessage) => void; + let reject!: (error: unknown) => void; + this.ready = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + void this.ready.catch(() => undefined); + this.resolveServerInfo = resolve; + this.rejectServerInfo = reject; + this.serverInfoTimer = setTimeout(() => { + this.fail(new Error("timed out waiting for QWP SERVER_INFO")); + }, timeout); + this.receiveLoop = this.consumeMessages(); + } + + static async connect( + factory: QwpConnectionFactory, + options: QwpEgressSessionOptions = {}, + ): Promise { + const session = new QwpEgressSession(await factory(), options); + try { + await session.ready; + return session; + } catch (error) { + await session + .close(1002, "missing QWP SERVER_INFO") + .catch(() => undefined); + throw error; + } + } + + get closed(): Promise { + return this.connection.closed; + } + + async query( + sql: string, + options: QwpEgressQueryOptions = {}, + ): Promise { + await this.ready; + this.throwIfUnavailable(); + if (this.active) { + throw new Error("a QWP query is already active on this connection"); + } + if ( + options.resetDictionary && + (this.serverInfo!.capabilities & QWP_EGRESS_CAPABILITY.QUERY_FLAGS) === 0 + ) { + throw new Error("the QWP server does not support query flags"); + } + + const requestId = this.nextRequestId++; + const query = new QwpEgressQuery(requestId, this); + this.decoder.resetQuerySchema(); + this.active = query; + const request: QwpQueryRequest = { + requestId, + sql, + initialCredit: options.initialCredit, + bindCount: options.bindCount, + bindPayload: options.bindPayload, + queryFlags: options.resetDictionary + ? QWP_QUERY_FLAG_RESET_DICTIONARY + : undefined, + }; + try { + await this.send(encodeQwpQueryRequest(request)); + } catch (error) { + if (this.active === query) this.active = undefined; + query.fail(error); + throw error; + } + return query; + } + + cancel(requestId: bigint): Promise { + this.requireActive(requestId); + return this.send(encodeQwpCancel(requestId)); + } + + grantCredit( + requestId: bigint, + additionalBytes: number | bigint, + ): Promise { + this.requireActive(requestId); + return this.send(encodeQwpCredit(requestId, additionalBytes)); + } + + async close(code = 1000, reason = ""): Promise { + if (this.closing) { + await this.connection.closed; + return; + } + this.closing = true; + clearTimeout(this.serverInfoTimer); + const error = new QwpEgressSessionClosedError(); + this.rejectServerInfo(error); + this.active?.fail(error); + this.active = undefined; + await this.sendTail; + await this.connection.close(code, reason); + await this.receiveLoop; + } + + private async consumeMessages(): Promise { + try { + for await (const payload of this.connection.messages) { + const message = decodeQwpEgressMessage(payload); + switch (message.kind) { + case "server-info": + if (this.serverInfo) { + throw new QwpProtocolError("received duplicate QWP SERVER_INFO"); + } + this.serverInfo = message; + clearTimeout(this.serverInfoTimer); + this.resolveServerInfo(message); + break; + case "cache-reset": + this.decoder.applyCacheReset(message.resetMask); + break; + case "result-batch": { + const query = this.requireActive(message.requestId); + query.push(this.decoder.decode(message)); + break; + } + case "result-end": { + const query = this.requireActive(message.requestId); + this.active = undefined; + query.finish(message); + break; + } + case "exec-done": { + const query = this.requireActive(message.requestId); + this.active = undefined; + query.finish(message); + break; + } + case "query-error": { + const query = this.requireActive(message.requestId); + this.active = undefined; + query.fail( + new QwpEgressQueryError( + message.requestId, + message.status, + message.message, + ), + ); + break; + } + } + } + if (!this.closing) { + this.fail( + new QwpEgressSessionClosedError(await this.connection.closed), + ); + } + } catch (error) { + this.fail(error); + if (error instanceof QwpProtocolError) { + void this.connection.close(1002, "invalid QWP egress message"); + } + } + } + + private requireActive(requestId: bigint): QwpEgressQuery { + this.throwIfUnavailable(); + if (!this.active || this.active.requestId !== requestId) { + throw new QwpProtocolError( + `QWP response references inactive request ID ${requestId}`, + ); + } + return this.active; + } + + private send(payload: Uint8Array): Promise { + this.throwIfUnavailable(); + const sending = this.sendTail.then(async () => { + this.throwIfUnavailable(); + await this.connection.send(payload); + }); + this.sendTail = sending.catch((error: unknown) => this.fail(error)); + return sending; + } + + private throwIfUnavailable(): void { + if (this.failure) throw this.failure; + if (this.closing) throw new QwpEgressSessionClosedError(); + } + + private fail(error: unknown): void { + if (this.failure) return; + clearTimeout(this.serverInfoTimer); + this.failure = + error instanceof Error ? error : new Error(`QWP egress failed: ${error}`); + this.rejectServerInfo(this.failure); + this.active?.fail(this.failure); + this.active = undefined; + } +} diff --git a/src/qwp/index.ts b/src/qwp/index.ts index 3b23628..ec8a690 100644 --- a/src/qwp/index.ts +++ b/src/qwp/index.ts @@ -7,5 +7,6 @@ * @packageDocumentation */ export * from "./core"; +export * from "./egress-session"; export * from "./ingress-session"; export * from "./transport"; diff --git a/src/qwp/node.ts b/src/qwp/node.ts index 221a433..e7e1d6a 100644 --- a/src/qwp/node.ts +++ b/src/qwp/node.ts @@ -7,6 +7,7 @@ import { QwpWebSocketLike, } from "./internal/websocket-connection"; import { QwpBinaryConnection, QwpWebSocketConnectOptions } from "./transport"; +import { QwpEgressSession, QwpEgressSessionOptions } from "./egress-session"; import { QwpIngressSession, QwpIngressSessionOptions } from "./ingress-session"; export type { QwpWebSocketLike } from "./internal/websocket-connection"; @@ -77,3 +78,14 @@ export async function connectQwpNodeIngress( sessionOptions, ); } + +/** Opens a Node WebSocket and waits for the egress SERVER_INFO handshake. */ +export async function connectQwpNodeEgress( + options: QwpNodeWebSocketOptions, + sessionOptions: QwpEgressSessionOptions = {}, +): Promise { + return QwpEgressSession.connect( + () => connectQwpNodeWebSocket(options), + sessionOptions, + ); +} diff --git a/test/qwp/egress.test.ts b/test/qwp/egress.test.ts new file mode 100644 index 0000000..b8c57fd --- /dev/null +++ b/test/qwp/egress.test.ts @@ -0,0 +1,330 @@ +import { describe, expect, it } from "vitest"; +import { + decodeQwpEgressMessage, + encodeQwpFrame, + encodeQwpGorilla, + QWP_COLUMN_TYPE, + QWP_EGRESS_CAPABILITY, + QWP_EGRESS_MESSAGE, + QWP_FLAG_DELTA_SYMBOL_DICTIONARY, + QWP_FLAG_GORILLA, + QWP_STATUS, + QwpBinaryConnection, + QwpByteReader, + QwpByteWriter, + QwpConnectionCloseInfo, + QwpEgressQueryError, + QwpEgressSession, + QwpResultBatchDecoder, + readQwpVarint, + writeQwpVarint, +} from "../../src/qwp"; +import { QwpAsyncQueue } from "../../src/qwp/internal/async-queue"; + +const RESULT_FLAGS = QWP_FLAG_DELTA_SYMBOL_DICTIONARY | QWP_FLAG_GORILLA; + +function writeString(writer: QwpByteWriter, value: string): void { + const bytes = new TextEncoder().encode(value); + writeQwpVarint(writer, bytes.length); + writer.writeBytes(bytes); +} + +function writeU16String(writer: QwpByteWriter, value: string): void { + const bytes = new TextEncoder().encode(value); + writer.writeUint16(bytes.length).writeBytes(bytes); +} + +function serverInfo(): Uint8Array { + const payload = new QwpByteWriter(); + payload + .writeUint8(QWP_EGRESS_MESSAGE.SERVER_INFO) + .writeUint8(0) + .writeBigUint64(1n) + .writeUint32(QWP_EGRESS_CAPABILITY.QUERY_FLAGS) + .writeBigInt64(123n); + writeU16String(payload, "cluster"); + writeU16String(payload, "node"); + return encodeQwpFrame(payload.toUint8Array()); +} + +function firstResultBatch(requestId = 0n): Uint8Array { + const payload = new QwpByteWriter(); + payload.writeUint8(QWP_EGRESS_MESSAGE.RESULT_BATCH).writeBigUint64(requestId); + writeQwpVarint(payload, 0); + + // Connection-scoped SYMBOL delta: [alpha, beta]. + writeQwpVarint(payload, 0); + writeQwpVarint(payload, 2); + writeString(payload, "alpha"); + writeString(payload, "beta"); + + writeQwpVarint(payload, 0); // table name + writeQwpVarint(payload, 3); // rows + writeQwpVarint(payload, 4); // columns + for (const [name, type] of [ + ["id", QWP_COLUMN_TYPE.INT], + ["name", QWP_COLUMN_TYPE.VARCHAR], + ["sym", QWP_COLUMN_TYPE.SYMBOL], + ["ts", QWP_COLUMN_TYPE.TIMESTAMP], + ] as const) { + writeString(payload, name); + payload.writeUint8(type); + } + + payload.writeUint8(1).writeUint8(0b00000010); // id row 1 is NULL + payload.writeInt32(7).writeInt32(9); + + payload.writeUint8(0); // name has no nulls + payload.writeUint32(0).writeUint32(1).writeUint32(3).writeUint32(3); + payload.writeUtf8("abb"); + + payload.writeUint8(0); // symbols reference the connection dictionary + writeQwpVarint(payload, 0); + writeQwpVarint(payload, 1); + writeQwpVarint(payload, 0); + + payload.writeUint8(0).writeUint8(1); // Gorilla timestamp column + payload.writeBytes(encodeQwpGorilla([100n, 200n, 300n])); + + return encodeQwpFrame(payload.toUint8Array(), RESULT_FLAGS, 1); +} + +function resultEnd(requestId = 0n): Uint8Array { + const payload = new QwpByteWriter(); + payload.writeUint8(QWP_EGRESS_MESSAGE.RESULT_END).writeBigUint64(requestId); + writeQwpVarint(payload, 1); + writeQwpVarint(payload, 3); + return encodeQwpFrame(payload.toUint8Array()); +} + +function scalarResultBatch(): Uint8Array { + const payload = new QwpByteWriter(); + payload.writeUint8(QWP_EGRESS_MESSAGE.RESULT_BATCH).writeBigUint64(0n); + writeQwpVarint(payload, 0); // batch sequence + writeQwpVarint(payload, 0); // empty delta start + writeQwpVarint(payload, 0); // empty delta count + writeQwpVarint(payload, 0); // table name + writeQwpVarint(payload, 1); // rows + const schema = [ + ["bool", QWP_COLUMN_TYPE.BOOLEAN], + ["byte", QWP_COLUMN_TYPE.BYTE], + ["short", QWP_COLUMN_TYPE.SHORT], + ["char", QWP_COLUMN_TYPE.CHAR], + ["long", QWP_COLUMN_TYPE.LONG], + ["float", QWP_COLUMN_TYPE.FLOAT], + ["double", QWP_COLUMN_TYPE.DOUBLE], + ["date", QWP_COLUMN_TYPE.DATE], + ["uuid", QWP_COLUMN_TYPE.UUID], + ["long256", QWP_COLUMN_TYPE.LONG256], + ["geohash", QWP_COLUMN_TYPE.GEOHASH], + ["nanos", QWP_COLUMN_TYPE.TIMESTAMP_NANOS], + ["doubles", QWP_COLUMN_TYPE.DOUBLE_ARRAY], + ["longs", QWP_COLUMN_TYPE.LONG_ARRAY], + ["dec64", QWP_COLUMN_TYPE.DECIMAL64], + ["dec128", QWP_COLUMN_TYPE.DECIMAL128], + ["dec256", QWP_COLUMN_TYPE.DECIMAL256], + ["binary", QWP_COLUMN_TYPE.BINARY], + ["ipv4", QWP_COLUMN_TYPE.IPV4], + ] as const; + writeQwpVarint(payload, schema.length); + for (const [name, type] of schema) { + writeString(payload, name); + payload.writeUint8(type); + } + + payload.writeUint8(0).writeUint8(1); // BOOLEAN + payload.writeUint8(0).writeInt8(-2); + payload.writeUint8(0).writeInt16(-3); + payload.writeUint8(0).writeUint16("Q".charCodeAt(0)); + payload.writeUint8(0).writeBigInt64(-4n); + payload.writeUint8(0).writeFloat32(1.5); + payload.writeUint8(0).writeFloat64(-2.5); + payload.writeUint8(0).writeUint8(0).writeBigInt64(123n); // DATE raw + payload.writeUint8(0).writeBigUint64(1n).writeBigUint64(2n); + payload + .writeUint8(0) + .writeBigInt64(1n) + .writeBigInt64(2n) + .writeBigInt64(3n) + .writeBigInt64(4n); + payload.writeUint8(0); + writeQwpVarint(payload, 5); + payload.writeUint8(0b10101); + payload.writeUint8(0).writeUint8(0).writeBigInt64(456n); // NANOS raw + payload + .writeUint8(0) + .writeUint8(2) + .writeInt32(1) + .writeInt32(2) + .writeFloat64(1.25) + .writeFloat64(2.5); + payload + .writeUint8(0) + .writeUint8(1) + .writeInt32(2) + .writeBigInt64(10n) + .writeBigInt64(20n); + payload.writeUint8(0).writeUint8(2).writeBigInt64(1234n); + payload.writeUint8(0).writeUint8(3).writeBigInt64(123456n).writeBigInt64(0n); + payload + .writeUint8(0) + .writeUint8(4) + .writeBigInt64(987654n) + .writeBigInt64(0n) + .writeBigInt64(0n) + .writeBigInt64(0n); + payload.writeUint8(0).writeUint32(0).writeUint32(3); + payload.writeBytes(Uint8Array.of(1, 2, 3)); + payload.writeUint8(0).writeInt32(-1); + + return encodeQwpFrame(payload.toUint8Array(), RESULT_FLAGS, 1); +} + +function queryError(requestId: bigint, message: string): Uint8Array { + const bytes = new TextEncoder().encode(message); + const payload = new QwpByteWriter(); + payload + .writeUint8(QWP_EGRESS_MESSAGE.QUERY_ERROR) + .writeBigUint64(requestId) + .writeUint8(QWP_STATUS.PARSE_ERROR) + .writeUint16(bytes.length) + .writeBytes(bytes); + return encodeQwpFrame(payload.toUint8Array()); +} + +class FakeConnection implements QwpBinaryConnection { + private readonly incoming = new QwpAsyncQueue(); + private readonly resolveClosed: (info: QwpConnectionCloseInfo) => void; + readonly messages = this.incoming; + readonly sent: Uint8Array[] = []; + readonly closed: Promise; + + constructor() { + let resolve!: (info: QwpConnectionCloseInfo) => void; + this.closed = new Promise((res) => { + resolve = res; + }); + this.resolveClosed = resolve; + } + + send(payload: Uint8Array): Promise { + this.sent.push(payload.slice()); + return Promise.resolve(); + } + + close(code = 1000, reason = ""): Promise { + this.incoming.end(); + this.resolveClosed({ code, reason, wasClean: true }); + return Promise.resolve(); + } + + receive(payload: Uint8Array): void { + this.incoming.push(payload); + } +} + +describe("QWP result batch decoder", () => { + it("decodes nullable, variable-width, symbol, and Gorilla columns", () => { + const message = decodeQwpEgressMessage(firstResultBatch()); + expect(message.kind).toBe("result-batch"); + if (message.kind !== "result-batch") throw new Error("unexpected message"); + + const batch = new QwpResultBatchDecoder().decode(message); + expect(batch.rowCount).toBe(3); + expect(batch.columns.map((column) => column.name)).toEqual([ + "id", + "name", + "sym", + "ts", + ]); + expect(batch.columns[0].values).toEqual([7, null, 9]); + expect(batch.columns[1].values).toEqual(["a", "bb", ""]); + expect(batch.columns[2].values).toEqual(["alpha", "beta", "alpha"]); + expect(batch.columns[3].values).toEqual([100n, 200n, 300n]); + expect([...batch.rows()]).toEqual([ + [7, "a", "alpha", 100n], + [null, "bb", "beta", 200n], + [9, "", "alpha", 300n], + ]); + }); + + it("rejects a continuation batch before a schema-bearing batch", () => { + const bytes = firstResultBatch(); + // RESULT_BATCH sequence is the byte immediately after kind + request ID. + bytes[12 + 1 + 8] = 1; + const message = decodeQwpEgressMessage(bytes); + if (message.kind !== "result-batch") throw new Error("unexpected message"); + expect(() => new QwpResultBatchDecoder().decode(message)).toThrow( + /sequence|schema/i, + ); + }); + + it("decodes the remaining scalar, decimal, binary, and array types", () => { + const message = decodeQwpEgressMessage(scalarResultBatch()); + if (message.kind !== "result-batch") throw new Error("unexpected message"); + const batch = new QwpResultBatchDecoder().decode(message); + const row = [...batch.rows()][0]; + + expect(row.slice(0, 8)).toEqual([true, -2, -3, "Q", -4n, 1.5, -2.5, 123n]); + expect(row[8]).toEqual({ low: 1n, high: 2n }); + expect(row[9]).toEqual({ words: [1n, 2n, 3n, 4n] }); + expect(row[10]).toEqual({ bits: 21n, precisionBits: 5 }); + expect(row[11]).toBe(456n); + expect(row[12]).toEqual({ dimensions: [1, 2], values: [1.25, 2.5] }); + expect(row[13]).toEqual({ dimensions: [2], values: [10n, 20n] }); + expect(row.slice(14, 17)).toEqual([ + { unscaled: 1234n, scale: 2 }, + { unscaled: 123456n, scale: 3 }, + { unscaled: 987654n, scale: 4 }, + ]); + expect(row[17]).toEqual(Uint8Array.of(1, 2, 3)); + expect(row[18]).toBe(-1); + }); +}); + +describe("QwpEgressSession", () => { + it("waits for SERVER_INFO and streams a typed query result", async () => { + const connection = new FakeConnection(); + const session = new QwpEgressSession(connection); + connection.receive(serverInfo()); + await expect(session.ready).resolves.toMatchObject({ + kind: "server-info", + clusterId: "cluster", + }); + + const query = await session.query("select * from x"); + const request = new QwpByteReader(connection.sent[0]); + expect(request.readUint8()).toBe(QWP_EGRESS_MESSAGE.QUERY_REQUEST); + expect(request.readBigUint64()).toBe(0n); + const sqlLength = Number(readQwpVarint(request)); + expect(request.readUtf8(sqlLength)).toBe("select * from x"); + + connection.receive(firstResultBatch()); + connection.receive(resultEnd()); + const batches = []; + for await (const batch of query) batches.push(batch); + expect(batches).toHaveLength(1); + await expect(query.completion).resolves.toMatchObject({ + kind: "result-end", + totalRows: 3n, + }); + await session.close(); + }); + + it("surfaces QUERY_ERROR to iteration and completion", async () => { + const connection = new FakeConnection(); + const session = new QwpEgressSession(connection); + connection.receive(serverInfo()); + const query = await session.query("broken sql"); + connection.receive(queryError(query.requestId, "bad syntax")); + + const next = query[Symbol.asyncIterator]().next(); + await expect(next).rejects.toMatchObject({ + name: "QwpEgressQueryError", + status: QWP_STATUS.PARSE_ERROR, + message: "bad syntax", + } satisfies Partial); + await expect(query.completion).rejects.toBeInstanceOf(QwpEgressQueryError); + await session.close(); + }); +}); From 253994b3503acad62b1840c2e06a726205d76353 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Mon, 17 Aug 2026 17:35:23 +0100 Subject: [PATCH 007/265] test(qwp): add authenticated browser e2e --- .github/workflows/build.yml | 28 +++- package.json | 2 + pnpm-lock.yaml | 29 ++++ src/qwp/ingress-session.ts | 21 ++- test/qwp/browser.e2e.ts | 269 +++++++++++++++++++++++++++++++++++ test/qwp/session.test.ts | 25 ++++ vitest.qwp-browser.config.ts | 9 ++ 7 files changed, 375 insertions(+), 8 deletions(-) create mode 100644 test/qwp/browser.e2e.ts create mode 100644 vitest.qwp-browser.config.ts diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7c87581..19bb5c2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -6,7 +6,7 @@ on: - main pull_request: schedule: - - cron: '15 2,10,18 * * *' + - cron: "15 2,10,18 * * *" jobs: test: @@ -39,3 +39,29 @@ jobs: - name: Tests run: pnpm test + + qwp-browser-e2e: + name: QWP browser E2E + runs-on: ubuntu-latest + timeout-minutes: 15 + env: + QWP_BROWSER_E2E_IMAGE: ${{ vars.QWP_BROWSER_E2E_IMAGE || 'questdb/questdb:nightly' }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup node + uses: actions/setup-node@v4 + with: + node-version: 20 + + - uses: pnpm/action-setup@v4 + with: + version: 9 + run_install: true + + - name: Install Chromium + run: pnpm exec playwright install --with-deps chromium + + - name: Authenticated ingress and egress + run: pnpm test:qwp-browser-e2e diff --git a/package.json b/package.json index 719fde3..7e84065 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "description": "QuestDB Node.js Client", "scripts": { "test": "vitest", + "test:qwp-browser-e2e": "pnpm build && vitest run --config vitest.qwp-browser.config.ts", "build": "bunchee", "eslint": "eslint src/**", "typecheck": "tsc --noEmit", @@ -77,6 +78,7 @@ "@types/node": "^22.15.17", "bunchee": "^6.5.1", "eslint": "^9.26.0", + "playwright": "^1.62.1", "prettier": "^3.5.3", "serve": "^14.2.4", "testcontainers": "^10.25.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d824b63..ec9bdd4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,6 +27,9 @@ importers: eslint: specifier: ^9.26.0 version: 9.26.0 + playwright: + specifier: ^1.62.1 + version: 1.62.1 prettier: specifier: ^3.5.3 version: 3.5.3 @@ -1397,6 +1400,11 @@ packages: fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1868,6 +1876,16 @@ packages: resolution: {integrity: sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==} engines: {node: '>=16.20.0'} + playwright-core@1.62.1: + resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.62.1: + resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==} + engines: {node: '>=20'} + hasBin: true + postcss@8.4.49: resolution: {integrity: sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==} engines: {node: ^10 || ^12 || >=14} @@ -3762,6 +3780,9 @@ snapshots: fs-constants@1.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -4149,6 +4170,14 @@ snapshots: pkce-challenge@5.0.0: {} + playwright-core@1.62.1: {} + + playwright@1.62.1: + dependencies: + playwright-core: 1.62.1 + optionalDependencies: + fsevents: 2.3.2 + postcss@8.4.49: dependencies: nanoid: 3.3.8 diff --git a/src/qwp/ingress-session.ts b/src/qwp/ingress-session.ts index 2c2caf6..b513181 100644 --- a/src/qwp/ingress-session.ts +++ b/src/qwp/ingress-session.ts @@ -51,7 +51,8 @@ export class QwpIngressSessionClosedError extends Error { * * One promise is registered before each WebSocket send, preventing a fast ACK * from racing its waiter. Calls are serialized to preserve the server's - * zero-based wire sequence. + * zero-based wire sequence. Successful ACKs are cumulative, so an ACK for + * sequence N resolves every outstanding send through N. */ export class QwpIngressSession { private readonly pending = new Map(); @@ -161,18 +162,24 @@ export class QwpIngressSession { if (response.sequence === null) { throw new QwpProtocolError("QWP response is missing its wire sequence"); } + if (response.status === QWP_STATUS.OK) { + for (const [sequence, pending] of this.pending) { + if (sequence > response.sequence) break; + this.pending.delete(sequence); + if (pending.timer) clearTimeout(pending.timer); + pending.resolve(response); + } + return; + } + const pending = this.pending.get(response.sequence); if (!pending) { - // A late response after timeout, or a duplicate ACK, is harmless. + // A late response after timeout, or a duplicate response, is harmless. return; } this.pending.delete(response.sequence); if (pending.timer) clearTimeout(pending.timer); - if (response.status === QWP_STATUS.OK) { - pending.resolve(response); - } else { - pending.reject(new QwpIngressNackError(response)); - } + pending.reject(new QwpIngressNackError(response)); } private invokeCallback( diff --git a/test/qwp/browser.e2e.ts b/test/qwp/browser.e2e.ts new file mode 100644 index 0000000..632af8e --- /dev/null +++ b/test/qwp/browser.e2e.ts @@ -0,0 +1,269 @@ +import { createServer, Server } from "node:http"; +import { readFile } from "node:fs/promises"; +import { AddressInfo } from "node:net"; +import path from "node:path"; +import { Browser, chromium } from "playwright"; +import { GenericContainer, StartedTestContainer } from "testcontainers"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +const USER = process.env.QWP_BROWSER_E2E_USER ?? "admin"; +const PASSWORD = process.env.QWP_BROWSER_E2E_PASSWORD ?? "quest"; +const QUESTDB_HTTP_PORT = 9000; +const WRITE_BATCH_SIZE = 8; + +function listen(server: Server): Promise { + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + server.off("error", reject); + resolve(); + }); + }); +} + +function close(server: Server): Promise { + return new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); +} + +function createModuleServer(): Server { + const moduleRoot = path.resolve(process.cwd(), "dist/es/qwp"); + return createServer(async (request, response) => { + try { + const requestUrl = new URL(request.url ?? "/", "http://127.0.0.1"); + const file = path.resolve(moduleRoot, `.${requestUrl.pathname}`); + if (!file.startsWith(`${moduleRoot}${path.sep}`)) { + response.writeHead(403).end(); + return; + } + const body = await readFile(file); + response.writeHead(200, { + "Access-Control-Allow-Origin": "*", + "Content-Type": "text/javascript; charset=utf-8", + }); + response.end(body); + } catch { + response.writeHead(404).end(); + } + }); +} + +function websocketUrl(httpUrl: string, pathname: string): string { + const url = new URL(pathname, httpUrl); + url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; + return url.toString(); +} + +describe("QWP in a real browser against QuestDB", () => { + let assetServer: Server; + let assetUrl: string; + let browser: Browser; + let container: StartedTestContainer | undefined; + let questdbUrl: string; + + beforeAll(async () => { + const configuredUrl = process.env.QWP_BROWSER_E2E_URL; + if (configuredUrl) { + questdbUrl = new URL(configuredUrl).toString(); + } else { + container = await new GenericContainer( + process.env.QWP_BROWSER_E2E_IMAGE ?? "questdb/questdb:nightly", + ) + .withEnvironment({ + QDB_HTTP_USER: USER, + QDB_HTTP_PASSWORD: PASSWORD, + }) + .withExposedPorts(QUESTDB_HTTP_PORT) + .start(); + questdbUrl = new URL( + `http://${container.getHost()}:${container.getMappedPort(QUESTDB_HTTP_PORT)}`, + ).toString(); + } + + assetServer = createModuleServer(); + await listen(assetServer); + const address = assetServer.address() as AddressInfo; + assetUrl = `http://127.0.0.1:${address.port}/browser.mjs`; + + browser = await chromium.launch({ + channel: process.env.QWP_BROWSER_CHANNEL, + executablePath: process.env.QWP_BROWSER_EXECUTABLE_PATH, + headless: true, + }); + }); + + afterAll(async () => { + await browser?.close(); + if (assetServer) await close(assetServer); + await container?.stop(); + }); + + it("authenticates ingress and egress with the browser session cookie", async () => { + const context = await browser.newContext({ bypassCSP: true }); + const page = await context.newPage(); + const tableName = `qwp_browser_e2e_${Date.now()}`; + const ingressUrl = websocketUrl(questdbUrl, "/write/v4"); + const egressUrl = websocketUrl(questdbUrl, "/read/v1"); + + try { + await page.goto(questdbUrl, { waitUntil: "domcontentloaded" }); + + const anonymousUpgrades = await page.evaluate( + async ({ moduleUrl, ingress, egress }) => { + const importModule = new Function("url", "return import(url)") as ( + url: string, + ) => Promise>; + const qwp = await importModule(moduleUrl); + const canConnect = async ( + connect: (options: { + url: string; + }) => Promise<{ close(): Promise }>, + url: string, + ) => { + try { + const session = await connect({ url }); + await session.close(); + return true; + } catch { + return false; + } + }; + return { + ingress: await canConnect(qwp.connectQwpBrowserIngress, ingress), + egress: await canConnect(qwp.connectQwpBrowserEgress, egress), + }; + }, + { moduleUrl: assetUrl, ingress: ingressUrl, egress: egressUrl }, + ); + expect(anonymousUpgrades).toEqual({ ingress: false, egress: false }); + + const login = await page.evaluate( + async ({ username, password, table }) => { + const query = + `create table ${table} (value long, ts timestamp) ` + + "timestamp(ts) partition by day wal"; + const response = await fetch( + `/exec?query=${encodeURIComponent(query)}&session=true`, + { + credentials: "include", + headers: { + Authorization: `Basic ${btoa(`${username}:${password}`)}`, + }, + }, + ); + return { status: response.status, body: await response.text() }; + }, + { username: USER, password: PASSWORD, table: tableName }, + ); + expect(login.status, login.body).toBe(200); + + const cookies = await context.cookies(questdbUrl); + expect(cookies).toContainEqual( + expect.objectContaining({ name: "qdb_session", httpOnly: true }), + ); + + const ingressResult = await page.evaluate( + async ({ moduleUrl, url, table, batchSize }) => { + const importModule = new Function("url", "return import(url)") as ( + url: string, + ) => Promise>; + const qwp = await importModule(moduleUrl); + const buffer = new qwp.QwpTableBuffer(table); + buffer + .getOrCreateColumn("value", qwp.QWP_COLUMN_TYPE.LONG) + .values.push(42n); + buffer + .getOrCreateColumn("", qwp.QWP_COLUMN_TYPE.TIMESTAMP) + .values.push(BigInt(Date.now()) * 1_000n); + buffer.nextRow(); + + const session = await qwp.connectQwpBrowserIngress({ url }); + try { + const responses = await Promise.all( + Array.from({ length: batchSize }, () => + session.sendTables([buffer]), + ), + ); + return responses.map((response) => ({ + status: response.status, + sequence: String(response.sequence), + })); + } finally { + await session.close(); + } + }, + { + moduleUrl: assetUrl, + url: ingressUrl, + table: tableName, + batchSize: WRITE_BATCH_SIZE, + }, + ); + expect(ingressResult).toHaveLength(WRITE_BATCH_SIZE); + ingressResult.forEach((response, requestSequence) => { + expect(response.status).toBe(0); + expect(BigInt(response.sequence)).toBeGreaterThanOrEqual( + BigInt(requestSequence), + ); + }); + expect(ingressResult.at(-1)?.sequence).toBe(String(WRITE_BATCH_SIZE - 1)); + + await expect + .poll( + () => + page.evaluate(async (table) => { + const response = await fetch( + `/exec?query=${encodeURIComponent(`select count() from ${table}`)}`, + { credentials: "include" }, + ); + if (!response.ok) return -1; + const result = await response.json(); + return result.dataset[0][0] as number; + }, tableName), + { timeout: 30_000, interval: 250 }, + ) + .toBe(WRITE_BATCH_SIZE); + + const egressResult = await page.evaluate( + async ({ moduleUrl, url, table }) => { + const importModule = new Function("url", "return import(url)") as ( + url: string, + ) => Promise>; + const qwp = await importModule(moduleUrl); + const session = await qwp.connectQwpBrowserEgress({ url }); + try { + const query = await session.query( + `select value from ${table} order by ts`, + ); + const values: string[] = []; + for await (const batch of query) { + for (const row of batch.rows()) values.push(String(row[0])); + } + const completion = await query.completion; + return { values, completion: completion.kind }; + } finally { + await session.close(); + } + }, + { moduleUrl: assetUrl, url: egressUrl, table: tableName }, + ); + expect(egressResult).toEqual({ + values: Array.from({ length: WRITE_BATCH_SIZE }, () => "42"), + completion: "result-end", + }); + } finally { + await page + .evaluate(async (table) => { + await fetch( + `/exec?query=${encodeURIComponent(`drop table ${table}`)}`, + { + credentials: "include", + }, + ); + }, tableName) + .catch(() => undefined); + await context.close(); + } + }); +}); diff --git a/test/qwp/session.test.ts b/test/qwp/session.test.ts index 7e63cef..9cb37fa 100644 --- a/test/qwp/session.test.ts +++ b/test/qwp/session.test.ts @@ -187,6 +187,31 @@ describe("QwpIngressSession", () => { await session.close(); }); + it("resolves every covered waiter from a cumulative ACK", async () => { + const socket = new FakeWebSocket(); + const connecting = connectQwpBrowserWebSocket({ + url: "ws://localhost:9000/write/v4", + webSocketFactory: () => asQwpSocket(socket), + }); + socket.open(); + const session = new QwpIngressSession(await connecting); + socket.onSend = () => { + if (socket.sent.length === 8) { + socket.message(ingressResponse(QWP_STATUS.OK, 7n)); + } + }; + + const sends = Array.from({ length: 8 }, (_, index) => + session.sendFrame(Uint8Array.of(index)), + ); + await expect(Promise.all(sends)).resolves.toEqual( + Array.from({ length: 8 }, () => + expect.objectContaining({ status: QWP_STATUS.OK, sequence: 7n }), + ), + ); + await session.close(); + }); + it("rejects the matching frame on NACK without breaking later ACKs", async () => { const socket = new FakeWebSocket(); const connecting = connectQwpBrowserWebSocket({ diff --git a/vitest.qwp-browser.config.ts b/vitest.qwp-browser.config.ts new file mode 100644 index 0000000..0b392e6 --- /dev/null +++ b/vitest.qwp-browser.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["test/qwp/browser.e2e.ts"], + hookTimeout: 300_000, + testTimeout: 120_000, + }, +}); From eaa1705f3b474f105589ef77332f224fa1ae5ec1 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Mon, 17 Aug 2026 17:49:58 +0100 Subject: [PATCH 008/265] feat(qwp): support durable acknowledgements --- package.json | 4 +- pnpm-lock.yaml | 27 ++++ src/qwp/ingress-session.ts | 167 ++++++++++++++++++++++- src/qwp/internal/websocket-connection.ts | 33 ++++- src/qwp/node.ts | 69 ++++++++-- src/qwp/transport.ts | 2 + test/qwp/browser.e2e.ts | 70 ++++++++++ test/qwp/node-transport.test.ts | 99 ++++++++++++++ test/qwp/session.test.ts | 114 +++++++++++++++- 9 files changed, 564 insertions(+), 21 deletions(-) create mode 100644 test/qwp/node-transport.test.ts diff --git a/package.json b/package.json index 7e84065..5e29f0a 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "@eslint/js": "^9.16.0", "@microsoft/tsdoc": "^0.15.1", "@types/node": "^22.15.17", + "@types/ws": "^8.18.1", "bunchee": "^6.5.1", "eslint": "^9.26.0", "playwright": "^1.62.1", @@ -88,6 +89,7 @@ "vitest": "^3.1.3" }, "dependencies": { - "undici": "^7.8.0" + "undici": "^7.8.0", + "ws": "^8.21.3" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ec9bdd4..1415f3e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: undici: specifier: ^7.8.0 version: 7.8.0 + ws: + specifier: ^8.21.3 + version: 8.21.3 devDependencies: '@eslint/js': specifier: ^9.16.0 @@ -21,6 +24,9 @@ importers: '@types/node': specifier: ^22.15.17 version: 22.15.17 + '@types/ws': + specifier: ^8.18.1 + version: 8.18.1 bunchee: specifier: ^6.5.1 version: 6.5.1(typescript@5.7.2) @@ -729,6 +735,9 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@typescript-eslint/eslint-plugin@8.32.0': resolution: {integrity: sha512-/jU9ettcntkBFmWUzzGgsClEi2ZFiikMX5eEQsmxIAWMOn4H3D4rvHssstmAHGVvrYnaMqdWWWg0b5M6IN/MTQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2425,6 +2434,18 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -2988,6 +3009,10 @@ snapshots: '@types/unist@3.0.3': {} + '@types/ws@8.18.1': + dependencies: + '@types/node': 22.15.17 + '@typescript-eslint/eslint-plugin@8.32.0(@typescript-eslint/parser@8.32.0(eslint@9.26.0)(typescript@5.7.2))(eslint@9.26.0)(typescript@5.7.2)': dependencies: '@eslint-community/regexpp': 4.12.1 @@ -4826,6 +4851,8 @@ snapshots: wrappy@1.0.2: {} + ws@8.21.3: {} + y18n@5.0.8: {} yaml@2.6.1: {} diff --git a/src/qwp/ingress-session.ts b/src/qwp/ingress-session.ts index b513181..49515f2 100644 --- a/src/qwp/ingress-session.ts +++ b/src/qwp/ingress-session.ts @@ -15,6 +15,12 @@ import { export interface QwpIngressSessionOptions { ackTimeoutMs?: number; + /** + * Enables durable-ACK tracking and sends Node WebSocket PING frames while + * committed table transactions are still awaiting durable upload. Zero + * keeps tracking enabled but disables automatic PINGs. + */ + durableAckKeepaliveMs?: number; onResponse?: (response: QwpIngressResponse) => void; onDurableAck?: (response: QwpIngressResponse) => void; } @@ -25,6 +31,13 @@ interface PendingResponse { timer?: ReturnType; } +interface PendingDurableResponse { + readonly targets: Map; + resolve: () => void; + reject: (error: unknown) => void; + timer?: ReturnType; +} + export class QwpIngressNackError extends Error { constructor(readonly response: QwpIngressResponse) { super( @@ -56,8 +69,12 @@ export class QwpIngressSessionClosedError extends Error { */ export class QwpIngressSession { private readonly pending = new Map(); + private readonly durableWatermarks = new Map(); + private readonly pendingDurableTargets = new Map(); + private readonly durableWaiters = new Set(); private nextSequence = 0n; private sendTail: Promise = Promise.resolve(); + private durablePingTimer?: ReturnType; private failure?: Error; private closing = false; private readonly receiveLoop: Promise; @@ -70,6 +87,20 @@ export class QwpIngressSession { if (!Number.isFinite(timeout) || timeout <= 0) { throw new RangeError("ackTimeoutMs must be a positive finite number"); } + const keepalive = options.durableAckKeepaliveMs; + if ( + keepalive !== undefined && + (!Number.isFinite(keepalive) || keepalive < 0) + ) { + throw new RangeError( + "durableAckKeepaliveMs must be a non-negative finite number", + ); + } + if (keepalive !== undefined && keepalive > 0 && !connection.ping) { + throw new Error( + "durable ACK keepalive requires a WebSocket transport with PING support", + ); + } this.receiveLoop = this.consumeMessages(); } @@ -77,7 +108,13 @@ export class QwpIngressSession { factory: QwpConnectionFactory, options: QwpIngressSessionOptions = {}, ): Promise { - return new QwpIngressSession(await factory(), options); + const connection = await factory(); + try { + return new QwpIngressSession(connection, options); + } catch (error) { + await connection.close().catch(() => undefined); + throw error; + } } get closed(): Promise { @@ -123,12 +160,51 @@ export class QwpIngressSession { return response; } + /** + * Waits until a durable ACK covers every table transaction in an OK ACK. + * Durable tracking must have been enabled with durableAckKeepaliveMs. + */ + waitForDurable( + response: QwpIngressResponse, + timeoutMs = this.options.ackTimeoutMs ?? 15_000, + ): Promise { + if (this.options.durableAckKeepaliveMs === undefined) { + return Promise.reject( + new Error("durable ACK tracking is not enabled for this session"), + ); + } + if (response.status !== QWP_STATUS.OK) { + return Promise.reject( + new Error("only a successful QWP ACK can be awaited for durability"), + ); + } + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { + return Promise.reject( + new RangeError("durable ACK timeout must be a positive finite number"), + ); + } + const targets = new Map( + response.tables.map((table) => [table.name, table.sequenceTransaction]), + ); + if (this.areDurableTargetsCovered(targets)) return Promise.resolve(); + + return new Promise((resolve, reject) => { + const pending: PendingDurableResponse = { targets, resolve, reject }; + pending.timer = setTimeout(() => { + if (!this.durableWaiters.delete(pending)) return; + reject(new Error("timed out waiting for QWP durable ACK")); + }, timeoutMs); + this.durableWaiters.add(pending); + }); + } + async close(code = 1000, reason = ""): Promise { if (this.closing) { await this.connection.closed; return; } this.closing = true; + this.clearDurablePing(); this.rejectAll(new QwpIngressSessionClosedError()); await this.sendTail; await this.connection.close(code, reason); @@ -156,6 +232,7 @@ export class QwpIngressSession { private handleResponse(response: QwpIngressResponse): void { this.invokeCallback(this.options.onResponse, response); if (response.status === QWP_STATUS.DURABLE_ACK) { + this.applyDurableAck(response); this.invokeCallback(this.options.onDurableAck, response); return; } @@ -163,6 +240,7 @@ export class QwpIngressSession { throw new QwpProtocolError("QWP response is missing its wire sequence"); } if (response.status === QWP_STATUS.OK) { + this.trackDurableTargets(response); for (const [sequence, pending] of this.pending) { if (sequence > response.sequence) break; this.pending.delete(sequence); @@ -194,6 +272,87 @@ export class QwpIngressSession { } } + private trackDurableTargets(response: QwpIngressResponse): void { + if (this.options.durableAckKeepaliveMs === undefined) return; + for (const table of response.tables) { + const durable = this.durableWatermarks.get(table.name); + if (durable !== undefined && durable >= table.sequenceTransaction) { + continue; + } + const pending = this.pendingDurableTargets.get(table.name); + if (pending === undefined || table.sequenceTransaction > pending) { + this.pendingDurableTargets.set(table.name, table.sequenceTransaction); + } + } + this.scheduleDurablePing(); + } + + private applyDurableAck(response: QwpIngressResponse): void { + for (const table of response.tables) { + const watermark = this.durableWatermarks.get(table.name); + if (watermark === undefined || table.sequenceTransaction > watermark) { + this.durableWatermarks.set(table.name, table.sequenceTransaction); + } + const target = this.pendingDurableTargets.get(table.name); + if (target !== undefined && table.sequenceTransaction >= target) { + this.pendingDurableTargets.delete(table.name); + } + } + + for (const waiter of this.durableWaiters) { + if (!this.areDurableTargetsCovered(waiter.targets)) continue; + this.durableWaiters.delete(waiter); + if (waiter.timer) clearTimeout(waiter.timer); + waiter.resolve(); + } + if (this.pendingDurableTargets.size === 0) { + this.clearDurablePing(); + } else { + this.scheduleDurablePing(); + } + } + + private areDurableTargetsCovered( + targets: ReadonlyMap, + ): boolean { + for (const [table, target] of targets) { + const watermark = this.durableWatermarks.get(table); + if (watermark === undefined || watermark < target) return false; + } + return true; + } + + private scheduleDurablePing(): void { + const interval = this.options.durableAckKeepaliveMs; + if ( + interval === undefined || + interval === 0 || + this.pendingDurableTargets.size === 0 || + this.durablePingTimer + ) { + return; + } + this.durablePingTimer = setTimeout(() => { + this.durablePingTimer = undefined; + if ( + this.closing || + this.failure || + this.pendingDurableTargets.size === 0 + ) { + return; + } + void this.connection.ping!() + .then(() => this.scheduleDurablePing()) + .catch((error: unknown) => this.fail(error)); + }, interval); + } + + private clearDurablePing(): void { + if (!this.durablePingTimer) return; + clearTimeout(this.durablePingTimer); + this.durablePingTimer = undefined; + } + private throwIfUnavailable(): void { if (this.failure) throw this.failure; if (this.closing) throw new QwpIngressSessionClosedError(); @@ -201,6 +360,7 @@ export class QwpIngressSession { private fail(error: unknown): void { if (this.failure) return; + this.clearDurablePing(); this.failure = error instanceof Error ? error @@ -214,5 +374,10 @@ export class QwpIngressSession { pending.reject(error); } this.pending.clear(); + for (const pending of this.durableWaiters) { + if (pending.timer) clearTimeout(pending.timer); + pending.reject(error); + } + this.durableWaiters.clear(); } } diff --git a/src/qwp/internal/websocket-connection.ts b/src/qwp/internal/websocket-connection.ts index bc2d41d..4d2d4ff 100644 --- a/src/qwp/internal/websocket-connection.ts +++ b/src/qwp/internal/websocket-connection.ts @@ -16,6 +16,8 @@ export interface QwpWebSocketLike { binaryType: string; readonly readyState: number; send(data: Uint8Array): void; + /** Node WebSocket implementations may expose control-frame PING. */ + ping?(): void; close(code?: number, reason?: string): void; addEventListener( type: "open", @@ -60,6 +62,7 @@ async function normalizeBinaryMessage(data: unknown): Promise { export function openQwpWebSocket( socket: QwpWebSocketLike, connectTimeoutMs = 15_000, + validateOpen?: () => void, ): Promise { if (!Number.isFinite(connectTimeoutMs) || connectTimeoutMs <= 0) { return Promise.reject( @@ -100,10 +103,27 @@ export function openQwpWebSocket( "open", () => { if (openingSettled) return; + try { + validateOpen?.(); + } catch (error) { + openingSettled = true; + clearTimeout(timeout); + try { + socket.close(1000, "QWP upgrade validation failed"); + } catch { + // The validation error is more useful than a close race. + } + reject( + error instanceof Error + ? error + : new Error("QWP WebSocket upgrade validation failed"), + ); + return; + } openingSettled = true; opened = true; clearTimeout(timeout); - resolve({ + const connection: QwpBinaryConnection = { messages, closed, async send(payload: Uint8Array): Promise { @@ -117,7 +137,16 @@ export function openQwpWebSocket( socket.close(code, reason); await closed; }, - }); + }; + if (socket.ping) { + connection.ping = async (): Promise => { + if (socket.readyState !== WEBSOCKET_OPEN) { + throw new Error("QWP WebSocket is not open"); + } + socket.ping!(); + }; + } + resolve(connection); }, { once: true }, ); diff --git a/src/qwp/node.ts b/src/qwp/node.ts index e7e1d6a..6dc30f0 100644 --- a/src/qwp/node.ts +++ b/src/qwp/node.ts @@ -1,7 +1,9 @@ /** Node.js WebSocket adapter and shared QWP protocol/session APIs. */ export * from "./index"; -import { Dispatcher, WebSocket } from "undici"; +import type { Agent } from "node:http"; +import type { IncomingHttpHeaders } from "node:http"; +import WebSocket from "ws"; import { openQwpWebSocket, QwpWebSocketLike, @@ -12,20 +14,31 @@ import { QwpIngressSession, QwpIngressSessionOptions } from "./ingress-session"; export type { QwpWebSocketLike } from "./internal/websocket-connection"; +export class QwpDurableAckUnavailableError extends Error { + constructor(readonly url: string | URL) { + super( + `QWP durable ACK was requested, but the server did not advertise support [url=${url}]`, + ); + this.name = "QwpDurableAckUnavailableError"; + } +} + export interface QwpNodeWebSocketOptions extends QwpWebSocketConnectOptions { headers?: Record; - dispatcher?: Dispatcher; + /** Optional HTTP(S) agent used for the WebSocket upgrade. */ + agent?: Agent; authorization?: string; clientId?: string; maxVersion?: number; requestDurableAck?: boolean; - /** Test hook; defaults to Undici's WebSocket implementation. */ + /** Test hook; defaults to the Node-only `ws` implementation. */ webSocketFactory?: ( url: string | URL, options: { protocols?: string | string[]; - dispatcher?: Dispatcher; + agent?: Agent; headers: Record; + onUpgrade: (headers: IncomingHttpHeaders) => void; }, ) => QwpWebSocketLike; } @@ -50,22 +63,42 @@ export function connectQwpNodeWebSocket( url: string | URL, init: { protocols?: string | string[]; - dispatcher?: Dispatcher; + agent?: Agent; headers: Record; + onUpgrade: (headers: IncomingHttpHeaders) => void; }, - ) => - new WebSocket(url, { - protocols: init.protocols, - dispatcher: init.dispatcher, + ) => { + const wsOptions: WebSocket.ClientOptions = { + agent: init.agent, headers: init.headers, - }) as unknown as QwpWebSocketLike); + perMessageDeflate: false, + }; + const socket = init.protocols + ? new WebSocket(url, init.protocols, wsOptions) + : new WebSocket(url, wsOptions); + socket.once("upgrade", (response) => init.onUpgrade(response.headers)); + return socket as unknown as QwpWebSocketLike; + }); + let upgradeHeaders: IncomingHttpHeaders | undefined; const socket = factory(options.url, { protocols: options.protocols, - dispatcher: options.dispatcher, + agent: options.agent, headers, + onUpgrade: (receivedHeaders) => { + upgradeHeaders = receivedHeaders; + }, + }); + return openQwpWebSocket(socket, options.connectTimeoutMs, () => { + if (!options.requestDurableAck) return; + const confirmation = upgradeHeaders?.["x-qwp-durable-ack"]; + if ( + typeof confirmation !== "string" || + confirmation.toLowerCase() !== "enabled" + ) { + throw new QwpDurableAckUnavailableError(options.url); + } }); - return openQwpWebSocket(socket, options.connectTimeoutMs); } /** Opens a Node WebSocket and starts an ingress ACK/NACK session. */ @@ -73,9 +106,15 @@ export async function connectQwpNodeIngress( options: QwpNodeWebSocketOptions, sessionOptions: QwpIngressSessionOptions = {}, ): Promise { - return new QwpIngressSession( - await connectQwpNodeWebSocket(options), - sessionOptions, + const effectiveSessionOptions = options.requestDurableAck + ? { + ...sessionOptions, + durableAckKeepaliveMs: sessionOptions.durableAckKeepaliveMs ?? 200, + } + : sessionOptions; + return QwpIngressSession.connect( + () => connectQwpNodeWebSocket(options), + effectiveSessionOptions, ); } diff --git a/src/qwp/transport.ts b/src/qwp/transport.ts index e7dbcd6..9c29a89 100644 --- a/src/qwp/transport.ts +++ b/src/qwp/transport.ts @@ -15,6 +15,8 @@ export interface QwpBinaryConnection { readonly closed: Promise; send(payload: Uint8Array): Promise; + /** Sends an RFC 6455 PING when the underlying runtime supports it. */ + ping?(): Promise; close(code?: number, reason?: string): Promise; } diff --git a/test/qwp/browser.e2e.ts b/test/qwp/browser.e2e.ts index 632af8e..69fc764 100644 --- a/test/qwp/browser.e2e.ts +++ b/test/qwp/browser.e2e.ts @@ -5,9 +5,18 @@ import path from "node:path"; import { Browser, chromium } from "playwright"; import { GenericContainer, StartedTestContainer } from "testcontainers"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + connectQwpNodeIngress, + QWP_COLUMN_TYPE, + QWP_STATUS, + QwpDurableAckUnavailableError, + QwpIngressSession, + QwpTableBuffer, +} from "../../src/qwp/node"; const USER = process.env.QWP_BROWSER_E2E_USER ?? "admin"; const PASSWORD = process.env.QWP_BROWSER_E2E_PASSWORD ?? "quest"; +const DURABLE_E2E_URL = process.env.QWP_DURABLE_E2E_URL; const QUESTDB_HTTP_PORT = 9000; const WRITE_BATCH_SIZE = 8; @@ -55,6 +64,14 @@ function websocketUrl(httpUrl: string, pathname: string): string { return url.toString(); } +async function executeSql(questdbUrl: string, sql: string): Promise { + return fetch(new URL(`/exec?query=${encodeURIComponent(sql)}`, questdbUrl), { + headers: { + Authorization: `Basic ${Buffer.from(`${USER}:${PASSWORD}`).toString("base64")}`, + }, + }); +} + describe("QWP in a real browser against QuestDB", () => { let assetServer: Server; let assetUrl: string; @@ -266,4 +283,57 @@ describe("QWP in a real browser against QuestDB", () => { await context.close(); } }); + + it("rejects durable ACK opt-in when the server does not advertise it", async () => { + if (DURABLE_E2E_URL) return; + await expect( + connectQwpNodeIngress({ + url: websocketUrl(questdbUrl, "/write/v4"), + authorization: `Basic ${Buffer.from(`${USER}:${PASSWORD}`).toString("base64")}`, + requestDurableAck: true, + }), + ).rejects.toBeInstanceOf(QwpDurableAckUnavailableError); + }); + + it.runIf(DURABLE_E2E_URL)( + "waits for Enterprise ingress to reach its durable table watermark", + async () => { + const durableUrl = DURABLE_E2E_URL!; + const tableName = `qwp_durable_e2e_${Date.now()}`; + const create = await executeSql( + durableUrl, + `create table ${tableName} (value long, ts timestamp) ` + + "timestamp(ts) partition by day wal", + ); + expect(create.status, await create.text()).toBe(200); + + const table = new QwpTableBuffer(tableName); + table.getOrCreateColumn("value", QWP_COLUMN_TYPE.LONG)!.values.push(42n); + table + .getOrCreateColumn("", QWP_COLUMN_TYPE.TIMESTAMP)! + .values.push(BigInt(Date.now()) * 1_000n); + table.nextRow(); + + let session: QwpIngressSession | undefined; + try { + session = await connectQwpNodeIngress( + { + url: websocketUrl(durableUrl, "/write/v4"), + authorization: `Basic ${Buffer.from(`${USER}:${PASSWORD}`).toString("base64")}`, + requestDurableAck: true, + }, + { durableAckKeepaliveMs: 25 }, + ); + const ack = await session.sendTables([table]); + expect(ack.status).toBe(QWP_STATUS.OK); + expect(ack.tables).toContainEqual( + expect.objectContaining({ name: tableName }), + ); + await session.waitForDurable(ack, 30_000); + } finally { + await session?.close(); + await executeSql(durableUrl, `drop table ${tableName}`); + } + }, + ); }); diff --git a/test/qwp/node-transport.test.ts b/test/qwp/node-transport.test.ts new file mode 100644 index 0000000..124bf96 --- /dev/null +++ b/test/qwp/node-transport.test.ts @@ -0,0 +1,99 @@ +import type { AddressInfo } from "node:net"; +import { WebSocketServer } from "ws"; +import { afterEach, describe, expect, it } from "vitest"; +import { + connectQwpNodeIngress, + QWP_STATUS, + QwpByteWriter, +} from "../../src/qwp/node"; + +function writeTable( + writer: QwpByteWriter, + name: string, + sequenceTransaction: bigint, +): void { + const encoded = new TextEncoder().encode(name); + writer + .writeUint16(encoded.length) + .writeBytes(encoded) + .writeBigInt64(sequenceTransaction); +} + +function okResponse( + sequence: bigint, + table: string, + sequenceTransaction: bigint, +): Uint8Array { + const writer = new QwpByteWriter() + .writeUint8(QWP_STATUS.OK) + .writeBigUint64(sequence) + .writeUint16(1); + writeTable(writer, table, sequenceTransaction); + return writer.toUint8Array(); +} + +function durableResponse( + table: string, + sequenceTransaction: bigint, +): Uint8Array { + const writer = new QwpByteWriter() + .writeUint8(QWP_STATUS.DURABLE_ACK) + .writeUint16(1); + writeTable(writer, table, sequenceTransaction); + return writer.toUint8Array(); +} + +describe("QWP Node transport", () => { + let server: WebSocketServer | undefined; + + afterEach(async () => { + await new Promise((resolve, reject) => { + if (!server) return resolve(); + server.close((error) => (error ? reject(error) : resolve())); + }); + server = undefined; + }); + + it("negotiates durable ACK and polls progress with a WebSocket PING", async () => { + const table = "trades"; + const sequenceTransaction = 7n; + let requestedDurableAck: string | undefined; + let pingCount = 0; + + server = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + server.on("headers", (headers) => { + headers.push("X-QWP-Durable-Ack: enabled"); + }); + server.on("connection", (socket, request) => { + requestedDurableAck = request.headers["x-qwp-request-durable-ack"]; + socket.once("message", () => { + socket.send(okResponse(0n, table, sequenceTransaction)); + }); + socket.once("ping", () => { + pingCount++; + socket.send(durableResponse(table, sequenceTransaction)); + }); + }); + await new Promise((resolve, reject) => { + server!.once("listening", resolve); + server!.once("error", reject); + }); + + const address = server.address() as AddressInfo; + const session = await connectQwpNodeIngress( + { + url: `ws://127.0.0.1:${address.port}/write/v4`, + requestDurableAck: true, + }, + { durableAckKeepaliveMs: 10 }, + ); + try { + const ack = await session.sendFrame(Uint8Array.of(1)); + await session.waitForDurable(ack, 1_000); + expect(requestedDurableAck).toBe("true"); + expect(pingCount).toBe(1); + } finally { + await session.close(); + } + }); +}); diff --git a/test/qwp/session.test.ts b/test/qwp/session.test.ts index 9cb37fa..c24dc2b 100644 --- a/test/qwp/session.test.ts +++ b/test/qwp/session.test.ts @@ -3,7 +3,10 @@ import { connectQwpBrowserWebSocket, QwpWebSocketLike, } from "../../src/qwp/browser"; -import { connectQwpNodeWebSocket } from "../../src/qwp/node"; +import { + connectQwpNodeWebSocket, + QwpDurableAckUnavailableError, +} from "../../src/qwp/node"; import { QWP_STATUS, QwpByteWriter, @@ -61,6 +64,16 @@ class FakeWebSocket { } } +class FakePingWebSocket extends FakeWebSocket { + pingCalls = 0; + onPing?: () => void; + + ping(): void { + this.pingCalls++; + this.onPing?.(); + } +} + function asQwpSocket(socket: FakeWebSocket): QwpWebSocketLike { return socket as unknown as QwpWebSocketLike; } @@ -69,11 +82,12 @@ function ingressResponse( status: number, sequence: bigint, message?: string, + tables: readonly [string, bigint][] = [], ): Uint8Array { const writer = new QwpByteWriter(); writer.writeUint8(status).writeBigUint64(sequence); if (status === QWP_STATUS.OK) { - writer.writeUint16(0); + writeIngressTables(writer, tables); } else { const encoded = new TextEncoder().encode(message ?? "rejected"); writer.writeUint16(encoded.length).writeBytes(encoded); @@ -81,6 +95,26 @@ function ingressResponse( return writer.toUint8Array(); } +function durableResponse(tables: readonly [string, bigint][]): Uint8Array { + const writer = new QwpByteWriter().writeUint8(QWP_STATUS.DURABLE_ACK); + writeIngressTables(writer, tables); + return writer.toUint8Array(); +} + +function writeIngressTables( + writer: QwpByteWriter, + tables: readonly [string, bigint][], +): void { + writer.writeUint16(tables.length); + for (const [name, sequenceTransaction] of tables) { + const encoded = new TextEncoder().encode(name); + writer + .writeUint16(encoded.length) + .writeBytes(encoded) + .writeBigInt64(sequenceTransaction); + } +} + describe("QWP WebSocket adapters", () => { it("buffers browser messages until a consumer is attached", async () => { const socket = new FakeWebSocket(); @@ -111,6 +145,7 @@ describe("QWP WebSocket adapters", () => { requestDurableAck: true, webSocketFactory: (_url, options) => { capturedHeaders = options.headers; + options.onUpgrade({ "x-qwp-durable-ack": "enabled" }); return asQwpSocket(socket); }, }); @@ -125,6 +160,24 @@ describe("QWP WebSocket adapters", () => { await connection.close(); }); + it("rejects durable ACK opt-in when the server omits confirmation", async () => { + const socket = new FakeWebSocket(); + const connecting = connectQwpNodeWebSocket({ + url: "ws://localhost:9000/write/v4", + requestDurableAck: true, + webSocketFactory: (_url, options) => { + options.onUpgrade({}); + return asQwpSocket(socket); + }, + }); + socket.open(); + + await expect(connecting).rejects.toBeInstanceOf( + QwpDurableAckUnavailableError, + ); + expect(socket.closeCalls).toHaveLength(1); + }); + it("rejects a connection that does not open before its deadline", async () => { vi.useFakeTimers(); try { @@ -212,6 +265,63 @@ describe("QwpIngressSession", () => { await session.close(); }); + it("pings an idle durable session until its table targets are covered", async () => { + vi.useFakeTimers(); + try { + const socket = new FakePingWebSocket(); + const connecting = connectQwpBrowserWebSocket({ + url: "ws://localhost:9000/write/v4", + webSocketFactory: () => asQwpSocket(socket), + }); + socket.open(); + const session = new QwpIngressSession(await connecting, { + ackTimeoutMs: 100, + durableAckKeepaliveMs: 25, + }); + socket.onSend = () => { + socket.message( + ingressResponse(QWP_STATUS.OK, 0n, undefined, [["trades", 42n]]), + ); + }; + socket.onPing = () => { + socket.message( + durableResponse([["trades", socket.pingCalls === 1 ? 41n : 42n]]), + ); + }; + + const ack = await session.sendFrame(Uint8Array.of(1)); + const durable = session.waitForDurable(ack); + await vi.advanceTimersByTimeAsync(25); + expect(socket.pingCalls).toBe(1); + await vi.advanceTimersByTimeAsync(25); + await expect(durable).resolves.toBeUndefined(); + expect(socket.pingCalls).toBe(2); + + await vi.advanceTimersByTimeAsync(100); + expect(socket.pingCalls).toBe(2); + await session.close(); + } finally { + vi.useRealTimers(); + } + }); + + it("rejects durable keepalive on a transport without PING support", async () => { + const socket = new FakeWebSocket(); + const connecting = connectQwpBrowserWebSocket({ + url: "ws://localhost:9000/write/v4", + webSocketFactory: () => asQwpSocket(socket), + }); + socket.open(); + const connection = await connecting; + expect( + () => + new QwpIngressSession(connection, { + durableAckKeepaliveMs: 25, + }), + ).toThrow(/PING support/); + await connection.close(); + }); + it("rejects the matching frame on NACK without breaking later ACKs", async () => { const socket = new FakeWebSocket(); const connecting = connectQwpBrowserWebSocket({ From 22cd5fd400fc570d32b2aabbf9f38304ed61874c Mon Sep 17 00:00:00 2001 From: glasstiger Date: Mon, 17 Aug 2026 18:10:20 +0100 Subject: [PATCH 009/265] feat(qwp): negotiate server batch limits --- src/qwp/browser.ts | 9 ++- src/qwp/egress-session.ts | 5 ++ src/qwp/ingress-session.ts | 53 +++++++++++++ src/qwp/internal/websocket-connection.ts | 12 ++- src/qwp/node.ts | 83 ++++++++++++++++++-- src/qwp/transport.ts | 15 ++++ test/qwp/browser.e2e.ts | 14 ++++ test/qwp/egress.test.ts | 1 + test/qwp/node-transport.test.ts | 10 +++ test/qwp/session.test.ts | 98 +++++++++++++++++++++++- 10 files changed, 285 insertions(+), 15 deletions(-) diff --git a/src/qwp/browser.ts b/src/qwp/browser.ts index 5d3c25b..317728d 100644 --- a/src/qwp/browser.ts +++ b/src/qwp/browser.ts @@ -5,6 +5,7 @@ import { openQwpWebSocket, QwpWebSocketLike, } from "./internal/websocket-connection"; +import { QWP_VERSION } from "./core"; import { QwpBinaryConnection, QwpWebSocketConnectOptions } from "./transport"; import { QwpEgressSession, QwpEgressSessionOptions } from "./egress-session"; import { QwpIngressSession, QwpIngressSessionOptions } from "./ingress-session"; @@ -48,7 +49,9 @@ export function connectQwpBrowserWebSocket( return new WebSocketConstructor(url, protocols); }); const socket = factory(options.url, options.protocols); - return openQwpWebSocket(socket, options.connectTimeoutMs); + return openQwpWebSocket(socket, options.connectTimeoutMs, () => ({ + qwpVersion: QWP_VERSION, + })); } /** Opens a browser WebSocket and starts an ingress ACK/NACK session. */ @@ -56,8 +59,8 @@ export async function connectQwpBrowserIngress( options: QwpBrowserWebSocketOptions, sessionOptions: QwpIngressSessionOptions = {}, ): Promise { - return new QwpIngressSession( - await connectQwpBrowserWebSocket(options), + return QwpIngressSession.connect( + () => connectQwpBrowserWebSocket(options), sessionOptions, ); } diff --git a/src/qwp/egress-session.ts b/src/qwp/egress-session.ts index c161536..2b1e638 100644 --- a/src/qwp/egress-session.ts +++ b/src/qwp/egress-session.ts @@ -18,6 +18,7 @@ import { QwpBinaryConnection, QwpConnectionCloseInfo, QwpConnectionFactory, + QwpHandshakeMetadata, } from "./transport"; export interface QwpEgressSessionOptions { @@ -186,6 +187,10 @@ export class QwpEgressSession implements QwpEgressQueryControl { return this.connection.closed; } + get handshake(): QwpHandshakeMetadata { + return this.connection.handshake; + } + async query( sql: string, options: QwpEgressQueryOptions = {}, diff --git a/src/qwp/ingress-session.ts b/src/qwp/ingress-session.ts index 49515f2..3fc75ea 100644 --- a/src/qwp/ingress-session.ts +++ b/src/qwp/ingress-session.ts @@ -11,10 +11,17 @@ import { QwpBinaryConnection, QwpConnectionCloseInfo, QwpConnectionFactory, + QwpHandshakeMetadata, } from "./transport"; export interface QwpIngressSessionOptions { ackTimeoutMs?: number; + /** + * Optional local ingress frame cap. Browsers cannot read WebSocket upgrade + * headers, so browser applications should set this to the server's configured + * QWP cap. When the server also advertises a cap, the smaller value wins. + */ + maxBatchSizeBytes?: number; /** * Enables durable-ACK tracking and sends Node WebSocket PING frames while * committed table transactions are still awaiting durable upload. Zero @@ -59,6 +66,18 @@ export class QwpIngressSessionClosedError extends Error { } } +export class QwpBatchTooLargeError extends RangeError { + constructor( + readonly batchSizeBytes: number, + readonly maxBatchSizeBytes: number, + ) { + super( + `QWP batch exceeds the negotiated limit [size=${batchSizeBytes}, max=${maxBatchSizeBytes}]`, + ); + this.name = "QwpBatchTooLargeError"; + } +} + /** * Connection-scoped ingress sequencer. * @@ -75,6 +94,7 @@ export class QwpIngressSession { private nextSequence = 0n; private sendTail: Promise = Promise.resolve(); private durablePingTimer?: ReturnType; + private readonly effectiveMaxBatchSizeBytes?: number; private failure?: Error; private closing = false; private readonly receiveLoop: Promise; @@ -87,6 +107,20 @@ export class QwpIngressSession { if (!Number.isFinite(timeout) || timeout <= 0) { throw new RangeError("ackTimeoutMs must be a positive finite number"); } + const localBatchCap = options.maxBatchSizeBytes; + if ( + localBatchCap !== undefined && + (!Number.isSafeInteger(localBatchCap) || localBatchCap <= 0) + ) { + throw new RangeError("maxBatchSizeBytes must be a positive safe integer"); + } + const serverBatchCap = connection.handshake.maxBatchSizeBytes; + this.effectiveMaxBatchSizeBytes = + localBatchCap === undefined + ? serverBatchCap + : serverBatchCap === undefined + ? localBatchCap + : Math.min(localBatchCap, serverBatchCap); const keepalive = options.durableAckKeepaliveMs; if ( keepalive !== undefined && @@ -121,6 +155,14 @@ export class QwpIngressSession { return this.connection.closed; } + get handshake(): QwpHandshakeMetadata { + return this.connection.handshake; + } + + get maxBatchSizeBytes(): number | undefined { + return this.effectiveMaxBatchSizeBytes; + } + sendTables( tables: readonly QwpTableBuffer[], encodeOptions: QwpIngressEncodeOptions = {}, @@ -130,6 +172,17 @@ export class QwpIngressSession { sendFrame(frame: Uint8Array): Promise { this.throwIfUnavailable(); + if ( + this.effectiveMaxBatchSizeBytes !== undefined && + frame.byteLength > this.effectiveMaxBatchSizeBytes + ) { + return Promise.reject( + new QwpBatchTooLargeError( + frame.byteLength, + this.effectiveMaxBatchSizeBytes, + ), + ); + } const sequence = this.nextSequence++; let pending!: PendingResponse; const response = new Promise((resolve, reject) => { diff --git a/src/qwp/internal/websocket-connection.ts b/src/qwp/internal/websocket-connection.ts index 4d2d4ff..873dbf1 100644 --- a/src/qwp/internal/websocket-connection.ts +++ b/src/qwp/internal/websocket-connection.ts @@ -1,5 +1,9 @@ import { QwpProtocolError } from "../core"; -import { QwpBinaryConnection, QwpConnectionCloseInfo } from "../transport"; +import { + QwpBinaryConnection, + QwpConnectionCloseInfo, + QwpHandshakeMetadata, +} from "../transport"; import { QwpAsyncQueue } from "./async-queue"; interface QwpWebSocketMessageEvent { @@ -62,7 +66,7 @@ async function normalizeBinaryMessage(data: unknown): Promise { export function openQwpWebSocket( socket: QwpWebSocketLike, connectTimeoutMs = 15_000, - validateOpen?: () => void, + completeHandshake: () => QwpHandshakeMetadata, ): Promise { if (!Number.isFinite(connectTimeoutMs) || connectTimeoutMs <= 0) { return Promise.reject( @@ -103,8 +107,9 @@ export function openQwpWebSocket( "open", () => { if (openingSettled) return; + let handshake: QwpHandshakeMetadata; try { - validateOpen?.(); + handshake = Object.freeze({ ...completeHandshake() }); } catch (error) { openingSettled = true; clearTimeout(timeout); @@ -126,6 +131,7 @@ export function openQwpWebSocket( const connection: QwpBinaryConnection = { messages, closed, + handshake, async send(payload: Uint8Array): Promise { if (socket.readyState !== WEBSOCKET_OPEN) { throw new Error("QWP WebSocket is not open"); diff --git a/src/qwp/node.ts b/src/qwp/node.ts index 6dc30f0..a870553 100644 --- a/src/qwp/node.ts +++ b/src/qwp/node.ts @@ -4,11 +4,16 @@ export * from "./index"; import type { Agent } from "node:http"; import type { IncomingHttpHeaders } from "node:http"; import WebSocket from "ws"; +import { QWP_VERSION } from "./core"; import { openQwpWebSocket, QwpWebSocketLike, } from "./internal/websocket-connection"; -import { QwpBinaryConnection, QwpWebSocketConnectOptions } from "./transport"; +import { + QwpBinaryConnection, + QwpHandshakeMetadata, + QwpWebSocketConnectOptions, +} from "./transport"; import { QwpEgressSession, QwpEgressSessionOptions } from "./egress-session"; import { QwpIngressSession, QwpIngressSessionOptions } from "./ingress-session"; @@ -23,6 +28,46 @@ export class QwpDurableAckUnavailableError extends Error { } } +export class QwpVersionMismatchError extends Error { + constructor( + readonly serverVersion: number, + readonly clientMaxVersion: number, + ) { + super( + `QWP server advertised unsupported version ${serverVersion} [client max=${clientMaxVersion}]`, + ); + this.name = "QwpVersionMismatchError"; + } +} + +function headerValue( + headers: IncomingHttpHeaders | undefined, + name: string, +): string | undefined { + const value = headers?.[name]; + const first = Array.isArray(value) ? value[0] : value; + const trimmed = first?.trim(); + return trimmed ? trimmed : undefined; +} + +function parseQwpVersion(headers: IncomingHttpHeaders | undefined): number { + const value = headerValue(headers, "x-qwp-version"); + if (!value || !/^\d+$/.test(value)) return QWP_VERSION; + const parsed = Number(value); + return Number.isSafeInteger(parsed) ? parsed : QWP_VERSION; +} + +function parseMaxBatchSize( + headers: IncomingHttpHeaders | undefined, +): number | undefined { + const value = headerValue(headers, "x-qwp-max-batch-size"); + if (!value || !/^\d+$/.test(value)) return undefined; + const parsed = Number(value); + return Number.isSafeInteger(parsed) && parsed > 0 && parsed <= 0x7fffffff + ? parsed + : undefined; +} + export interface QwpNodeWebSocketOptions extends QwpWebSocketConnectOptions { headers?: Record; /** Optional HTTP(S) agent used for the WebSocket upgrade. */ @@ -47,8 +92,20 @@ export interface QwpNodeWebSocketOptions extends QwpWebSocketConnectOptions { export function connectQwpNodeWebSocket( options: QwpNodeWebSocketOptions, ): Promise { + const clientMaxVersion = options.maxVersion ?? QWP_VERSION; + if ( + !Number.isSafeInteger(clientMaxVersion) || + clientMaxVersion < 1 || + clientMaxVersion > QWP_VERSION + ) { + return Promise.reject( + new RangeError( + `maxVersion must be an integer between 1 and ${QWP_VERSION}`, + ), + ); + } const headers: Record = { - "X-QWP-Max-Version": String(options.maxVersion ?? 1), + "X-QWP-Max-Version": String(clientMaxVersion), "X-QWP-Client-Id": options.clientId ?? "typescript/1.0.0", ...options.headers, }; @@ -90,14 +147,24 @@ export function connectQwpNodeWebSocket( }, }); return openQwpWebSocket(socket, options.connectTimeoutMs, () => { - if (!options.requestDurableAck) return; - const confirmation = upgradeHeaders?.["x-qwp-durable-ack"]; - if ( - typeof confirmation !== "string" || - confirmation.toLowerCase() !== "enabled" - ) { + const qwpVersion = parseQwpVersion(upgradeHeaders); + if (qwpVersion < 1 || qwpVersion > clientMaxVersion) { + throw new QwpVersionMismatchError(qwpVersion, clientMaxVersion); + } + const durableAckEnabled = + headerValue(upgradeHeaders, "x-qwp-durable-ack")?.toLowerCase() === + "enabled"; + if (options.requestDurableAck && !durableAckEnabled) { throw new QwpDurableAckUnavailableError(options.url); } + const handshake: QwpHandshakeMetadata = { + qwpVersion, + maxBatchSizeBytes: parseMaxBatchSize(upgradeHeaders), + contentEncoding: headerValue(upgradeHeaders, "x-qwp-content-encoding"), + durableAckEnabled, + serverRole: headerValue(upgradeHeaders, "x-questdb-role"), + }; + return handshake; }); } diff --git a/src/qwp/transport.ts b/src/qwp/transport.ts index 9c29a89..a563f56 100644 --- a/src/qwp/transport.ts +++ b/src/qwp/transport.ts @@ -4,6 +4,20 @@ export interface QwpConnectionCloseInfo { wasClean: boolean; } +/** Metadata negotiated during the QWP WebSocket upgrade. */ +export interface QwpHandshakeMetadata { + /** QWP protocol version selected by the server. */ + readonly qwpVersion: number; + /** Server's hard ingress WebSocket-payload cap, when advertised. */ + readonly maxBatchSizeBytes?: number; + /** Server-selected egress content encoding, when advertised. */ + readonly contentEncoding?: string; + /** Whether the server confirmed durable-ACK support. */ + readonly durableAckEnabled?: boolean; + /** Server role advertised on a successful upgrade, when available. */ + readonly serverRole?: string; +} + /** * Normalized binary connection consumed by QWP sessions. * @@ -13,6 +27,7 @@ export interface QwpConnectionCloseInfo { export interface QwpBinaryConnection { readonly messages: AsyncIterable; readonly closed: Promise; + readonly handshake: QwpHandshakeMetadata; send(payload: Uint8Array): Promise; /** Sends an RFC 6455 PING when the underlying runtime supports it. */ diff --git a/test/qwp/browser.e2e.ts b/test/qwp/browser.e2e.ts index 69fc764..2e8f626 100644 --- a/test/qwp/browser.e2e.ts +++ b/test/qwp/browser.e2e.ts @@ -7,6 +7,7 @@ import { GenericContainer, StartedTestContainer } from "testcontainers"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { connectQwpNodeIngress, + connectQwpNodeWebSocket, QWP_COLUMN_TYPE, QWP_STATUS, QwpDurableAckUnavailableError, @@ -295,6 +296,19 @@ describe("QWP in a real browser against QuestDB", () => { ).rejects.toBeInstanceOf(QwpDurableAckUnavailableError); }); + it("negotiates the server QWP version and ingress batch cap", async () => { + const connection = await connectQwpNodeWebSocket({ + url: websocketUrl(questdbUrl, "/write/v4"), + authorization: `Basic ${Buffer.from(`${USER}:${PASSWORD}`).toString("base64")}`, + }); + try { + expect(connection.handshake.qwpVersion).toBe(1); + expect(connection.handshake.maxBatchSizeBytes).toBeGreaterThan(12); + } finally { + await connection.close(); + } + }); + it.runIf(DURABLE_E2E_URL)( "waits for Enterprise ingress to reach its durable table watermark", async () => { diff --git a/test/qwp/egress.test.ts b/test/qwp/egress.test.ts index b8c57fd..d3dd8c6 100644 --- a/test/qwp/egress.test.ts +++ b/test/qwp/egress.test.ts @@ -193,6 +193,7 @@ function queryError(requestId: bigint, message: string): Uint8Array { } class FakeConnection implements QwpBinaryConnection { + readonly handshake = { qwpVersion: 1 }; private readonly incoming = new QwpAsyncQueue(); private readonly resolveClosed: (info: QwpConnectionCloseInfo) => void; readonly messages = this.incoming; diff --git a/test/qwp/node-transport.test.ts b/test/qwp/node-transport.test.ts index 124bf96..21336aa 100644 --- a/test/qwp/node-transport.test.ts +++ b/test/qwp/node-transport.test.ts @@ -62,6 +62,9 @@ describe("QWP Node transport", () => { server = new WebSocketServer({ host: "127.0.0.1", port: 0 }); server.on("headers", (headers) => { + headers.push("X-QWP-Version: 1"); + headers.push("X-QWP-Max-Batch-Size: 64"); + headers.push("X-QuestDB-Role: primary"); headers.push("X-QWP-Durable-Ack: enabled"); }); server.on("connection", (socket, request) => { @@ -88,6 +91,13 @@ describe("QWP Node transport", () => { { durableAckKeepaliveMs: 10 }, ); try { + expect(session.handshake).toMatchObject({ + qwpVersion: 1, + maxBatchSizeBytes: 64, + durableAckEnabled: true, + serverRole: "primary", + }); + expect(session.maxBatchSizeBytes).toBe(64); const ack = await session.sendFrame(Uint8Array.of(1)); await session.waitForDurable(ack, 1_000); expect(requestedDurableAck).toBe("true"); diff --git a/test/qwp/session.test.ts b/test/qwp/session.test.ts index c24dc2b..3ba2ca3 100644 --- a/test/qwp/session.test.ts +++ b/test/qwp/session.test.ts @@ -6,9 +6,11 @@ import { import { connectQwpNodeWebSocket, QwpDurableAckUnavailableError, + QwpVersionMismatchError, } from "../../src/qwp/node"; import { QWP_STATUS, + QwpBatchTooLargeError, QwpByteWriter, QwpIngressNackError, QwpIngressSession, @@ -145,7 +147,13 @@ describe("QWP WebSocket adapters", () => { requestDurableAck: true, webSocketFactory: (_url, options) => { capturedHeaders = options.headers; - options.onUpgrade({ "x-qwp-durable-ack": "enabled" }); + options.onUpgrade({ + "x-qwp-version": "1", + "x-qwp-max-batch-size": "4096", + "x-qwp-content-encoding": "raw", + "x-qwp-durable-ack": "enabled", + "x-questdb-role": "primary", + }); return asQwpSocket(socket); }, }); @@ -157,9 +165,67 @@ describe("QWP WebSocket adapters", () => { "X-QWP-Request-Durable-Ack": "true", Authorization: "Basic token", }); + expect(connection.handshake).toEqual({ + qwpVersion: 1, + maxBatchSizeBytes: 4096, + contentEncoding: "raw", + durableAckEnabled: true, + serverRole: "primary", + }); + const session = new QwpIngressSession(connection, { + maxBatchSizeBytes: 8192, + }); + expect(session.maxBatchSizeBytes).toBe(4096); + await expect( + session.sendFrame(new Uint8Array(4097)), + ).rejects.toBeInstanceOf(QwpBatchTooLargeError); + await session.close(); + }); + + it("uses the legacy handshake defaults when optional headers are absent", async () => { + const socket = new FakeWebSocket(); + const connecting = connectQwpNodeWebSocket({ + url: "ws://localhost:9000/write/v4", + webSocketFactory: (_url, options) => { + options.onUpgrade({ + "x-qwp-version": "not-a-number", + "x-qwp-max-batch-size": "not-a-number", + }); + return asQwpSocket(socket); + }, + }); + socket.open(); + + const connection = await connecting; + expect(connection.handshake).toEqual({ + qwpVersion: 1, + maxBatchSizeBytes: undefined, + contentEncoding: undefined, + durableAckEnabled: false, + serverRole: undefined, + }); await connection.close(); }); + it("rejects an unsupported server QWP version", async () => { + const socket = new FakeWebSocket(); + const connecting = connectQwpNodeWebSocket({ + url: "ws://localhost:9000/write/v4", + webSocketFactory: (_url, options) => { + options.onUpgrade({ "x-qwp-version": "2" }); + return asQwpSocket(socket); + }, + }); + socket.open(); + + await expect(connecting).rejects.toMatchObject({ + name: "QwpVersionMismatchError", + serverVersion: 2, + clientMaxVersion: 1, + } satisfies Partial); + expect(socket.closeCalls).toHaveLength(1); + }); + it("rejects durable ACK opt-in when the server omits confirmation", async () => { const socket = new FakeWebSocket(); const connecting = connectQwpNodeWebSocket({ @@ -216,6 +282,36 @@ describe("QWP WebSocket adapters", () => { }); describe("QwpIngressSession", () => { + it("rejects an oversized batch locally without consuming its sequence", async () => { + const socket = new FakeWebSocket(); + const connecting = connectQwpBrowserWebSocket({ + url: "ws://localhost:9000/write/v4", + webSocketFactory: () => asQwpSocket(socket), + }); + socket.open(); + const session = new QwpIngressSession(await connecting, { + maxBatchSizeBytes: 3, + }); + expect(session.maxBatchSizeBytes).toBe(3); + + await expect(session.sendFrame(Uint8Array.of(1, 2, 3, 4))).rejects.toEqual( + expect.objectContaining({ + name: "QwpBatchTooLargeError", + batchSizeBytes: 4, + maxBatchSizeBytes: 3, + } satisfies Partial), + ); + expect(socket.sent).toHaveLength(0); + + socket.onSend = () => { + socket.message(ingressResponse(QWP_STATUS.OK, 0n)); + }; + await expect( + session.sendFrame(Uint8Array.of(1, 2, 3)), + ).resolves.toMatchObject({ sequence: 0n }); + await session.close(); + }); + it("registers ACK waiters before sending and preserves call order", async () => { const socket = new FakeWebSocket(); const connecting = connectQwpBrowserWebSocket({ From e2adc3bb04c7794cd8e936dac983c31c5da82cbf Mon Sep 17 00:00:00 2001 From: glasstiger Date: Mon, 17 Aug 2026 18:22:28 +0100 Subject: [PATCH 010/265] feat(qwp): classify websocket upgrade failures --- src/qwp/browser.ts | 9 +- src/qwp/internal/websocket-connection.ts | 79 ++++++++++++-- src/qwp/node.ts | 118 +++++++++++++++++---- src/qwp/transport.ts | 75 ++++++++++++++ test/qwp/browser.e2e.ts | 41 ++++++-- test/qwp/node-transport.test.ts | 37 +++++++ test/qwp/session.test.ts | 125 ++++++++++++++++++++++- 7 files changed, 438 insertions(+), 46 deletions(-) diff --git a/src/qwp/browser.ts b/src/qwp/browser.ts index 317728d..cd550db 100644 --- a/src/qwp/browser.ts +++ b/src/qwp/browser.ts @@ -49,9 +49,12 @@ export function connectQwpBrowserWebSocket( return new WebSocketConstructor(url, protocols); }); const socket = factory(options.url, options.protocols); - return openQwpWebSocket(socket, options.connectTimeoutMs, () => ({ - qwpVersion: QWP_VERSION, - })); + return openQwpWebSocket(socket, { + url: options.url, + connectTimeoutMs: options.connectTimeoutMs, + completeHandshake: () => ({ qwpVersion: QWP_VERSION }), + opaqueErrors: true, + }); } /** Opens a browser WebSocket and starts an ingress ACK/NACK session. */ diff --git a/src/qwp/internal/websocket-connection.ts b/src/qwp/internal/websocket-connection.ts index 873dbf1..58edc20 100644 --- a/src/qwp/internal/websocket-connection.ts +++ b/src/qwp/internal/websocket-connection.ts @@ -1,8 +1,10 @@ import { QwpProtocolError } from "../core"; import { + QWP_UPGRADE_ERROR_KIND, QwpBinaryConnection, QwpConnectionCloseInfo, QwpHandshakeMetadata, + QwpUpgradeError, } from "../transport"; import { QwpAsyncQueue } from "./async-queue"; @@ -44,6 +46,16 @@ export interface QwpWebSocketLike { ): void; } +export interface QwpWebSocketOpenOptions { + url: string | URL; + connectTimeoutMs?: number; + completeHandshake: () => QwpHandshakeMetadata; + /** Node adapters use this to surface non-101 HTTP responses from `ws`. */ + openingFailure?: Promise; + /** Browsers hide the HTTP response behind a generic WebSocket error event. */ + opaqueErrors?: boolean; +} + const WEBSOCKET_OPEN = 1; const WEBSOCKET_CLOSED = 3; @@ -65,9 +77,9 @@ async function normalizeBinaryMessage(data: unknown): Promise { /** Wraps a WHATWG-style WebSocket and resolves once its opening handshake succeeds. */ export function openQwpWebSocket( socket: QwpWebSocketLike, - connectTimeoutMs = 15_000, - completeHandshake: () => QwpHandshakeMetadata, + options: QwpWebSocketOpenOptions, ): Promise { + const connectTimeoutMs = options.connectTimeoutMs ?? 15_000; if (!Number.isFinite(connectTimeoutMs) || connectTimeoutMs <= 0) { return Promise.reject( new RangeError("connectTimeoutMs must be a positive finite number"), @@ -92,7 +104,14 @@ export function openQwpWebSocket( } catch { // Some implementations throw when close() races an opening handshake. } - reject(new Error("QWP WebSocket connection timed out")); + reject( + new QwpUpgradeError("QWP WebSocket connection timed out", { + kind: QWP_UPGRADE_ERROR_KIND.TIMEOUT, + retryable: true, + tryNextEndpoint: true, + url: options.url, + }), + ); }, connectTimeoutMs); const failOpening = (error: Error): void => { @@ -109,7 +128,7 @@ export function openQwpWebSocket( if (openingSettled) return; let handshake: QwpHandshakeMetadata; try { - handshake = Object.freeze({ ...completeHandshake() }); + handshake = Object.freeze({ ...options.completeHandshake() }); } catch (error) { openingSettled = true; clearTimeout(timeout); @@ -172,13 +191,42 @@ export function openQwpWebSocket( }); }); - socket.addEventListener("error", () => { - const error = new Error("QWP WebSocket transport error"); - if (!opened) { - failOpening(error); - } else { - messages.fail(error); + options.openingFailure?.catch((error: unknown) => { + failOpening( + error instanceof Error + ? error + : new QwpUpgradeError("QWP WebSocket upgrade failed", { + kind: QWP_UPGRADE_ERROR_KIND.TRANSPORT, + retryable: true, + tryNextEndpoint: true, + url: options.url, + cause: error, + }), + ); + }); + + socket.addEventListener("error", (event) => { + if (opened) { + messages.fail(new Error("QWP WebSocket transport error")); + return; } + const opaque = options.opaqueErrors === true; + const eventError = (event as { error?: unknown }).error; + const error = new QwpUpgradeError( + opaque + ? "QWP WebSocket upgrade failed; the browser did not expose the HTTP response" + : "QWP WebSocket transport error during upgrade", + { + kind: opaque + ? QWP_UPGRADE_ERROR_KIND.OPAQUE + : QWP_UPGRADE_ERROR_KIND.TRANSPORT, + retryable: opaque ? undefined : true, + tryNextEndpoint: opaque ? undefined : true, + url: options.url, + cause: eventError ?? event, + }, + ); + failOpening(error); }); socket.addEventListener( @@ -193,8 +241,17 @@ export function openQwpWebSocket( resolveClosed(info); if (!opened) { failOpening( - new Error( + new QwpUpgradeError( `QWP WebSocket closed during handshake [code=${info.code}, reason=${info.reason}]`, + { + kind: options.opaqueErrors + ? QWP_UPGRADE_ERROR_KIND.OPAQUE + : QWP_UPGRADE_ERROR_KIND.TRANSPORT, + retryable: options.opaqueErrors ? undefined : true, + tryNextEndpoint: options.opaqueErrors ? undefined : true, + url: options.url, + closeCode: info.code, + }, ), ); return; diff --git a/src/qwp/node.ts b/src/qwp/node.ts index a870553..3387d31 100644 --- a/src/qwp/node.ts +++ b/src/qwp/node.ts @@ -10,8 +10,10 @@ import { QwpWebSocketLike, } from "./internal/websocket-connection"; import { + QWP_UPGRADE_ERROR_KIND, QwpBinaryConnection, QwpHandshakeMetadata, + QwpUpgradeError, QwpWebSocketConnectOptions, } from "./transport"; import { QwpEgressSession, QwpEgressSessionOptions } from "./egress-session"; @@ -19,27 +21,75 @@ import { QwpIngressSession, QwpIngressSessionOptions } from "./ingress-session"; export type { QwpWebSocketLike } from "./internal/websocket-connection"; -export class QwpDurableAckUnavailableError extends Error { +export class QwpDurableAckUnavailableError extends QwpUpgradeError { constructor(readonly url: string | URL) { super( `QWP durable ACK was requested, but the server did not advertise support [url=${url}]`, + { + kind: QWP_UPGRADE_ERROR_KIND.CAPABILITY_MISMATCH, + retryable: false, + tryNextEndpoint: true, + url, + }, ); this.name = "QwpDurableAckUnavailableError"; } } -export class QwpVersionMismatchError extends Error { +export class QwpVersionMismatchError extends QwpUpgradeError { constructor( readonly serverVersion: number, readonly clientMaxVersion: number, + url?: string | URL, ) { super( `QWP server advertised unsupported version ${serverVersion} [client max=${clientMaxVersion}]`, + { + kind: QWP_UPGRADE_ERROR_KIND.VERSION_MISMATCH, + retryable: true, + tryNextEndpoint: true, + url, + }, ); this.name = "QwpVersionMismatchError"; } } +export interface QwpNodeUpgradeRejection { + statusCode: number; + statusMessage?: string; + headers: IncomingHttpHeaders; +} + +function classifyUpgradeRejection( + url: string | URL, + rejection: QwpNodeUpgradeRejection, +): QwpUpgradeError { + const { statusCode, statusMessage, headers } = rejection; + const serverRole = headerValue(headers, "x-questdb-role"); + const serverZone = headerValue(headers, "x-questdb-zone"); + const kind = + statusCode === 401 || statusCode === 403 + ? QWP_UPGRADE_ERROR_KIND.AUTHENTICATION + : statusCode === 421 + ? QWP_UPGRADE_ERROR_KIND.ROLE_REJECTED + : QWP_UPGRADE_ERROR_KIND.HTTP_REJECTED; + const suffix = statusMessage ? ` ${statusMessage}` : ""; + return new QwpUpgradeError( + `QWP WebSocket upgrade rejected with HTTP ${statusCode}${suffix}`, + { + kind, + retryable: statusCode === 421, + tryNextEndpoint: statusCode !== 401 && statusCode !== 403, + url, + statusCode, + statusMessage, + serverRole, + serverZone, + }, + ); +} + function headerValue( headers: IncomingHttpHeaders | undefined, name: string, @@ -84,6 +134,7 @@ export interface QwpNodeWebSocketOptions extends QwpWebSocketConnectOptions { agent?: Agent; headers: Record; onUpgrade: (headers: IncomingHttpHeaders) => void; + onUpgradeRejected: (rejection: QwpNodeUpgradeRejection) => void; }, ) => QwpWebSocketLike; } @@ -123,6 +174,7 @@ export function connectQwpNodeWebSocket( agent?: Agent; headers: Record; onUpgrade: (headers: IncomingHttpHeaders) => void; + onUpgradeRejected: (rejection: QwpNodeUpgradeRejection) => void; }, ) => { const wsOptions: WebSocket.ClientOptions = { @@ -134,10 +186,22 @@ export function connectQwpNodeWebSocket( ? new WebSocket(url, init.protocols, wsOptions) : new WebSocket(url, wsOptions); socket.once("upgrade", (response) => init.onUpgrade(response.headers)); + socket.once("unexpected-response", (_request, response) => { + init.onUpgradeRejected({ + statusCode: response.statusCode ?? 0, + statusMessage: response.statusMessage, + headers: response.headers, + }); + response.resume(); + }); return socket as unknown as QwpWebSocketLike; }); let upgradeHeaders: IncomingHttpHeaders | undefined; + let rejectOpening!: (error: QwpUpgradeError) => void; + const openingFailure = new Promise((_resolve, reject) => { + rejectOpening = reject; + }); const socket = factory(options.url, { protocols: options.protocols, agent: options.agent, @@ -145,26 +209,38 @@ export function connectQwpNodeWebSocket( onUpgrade: (receivedHeaders) => { upgradeHeaders = receivedHeaders; }, + onUpgradeRejected: (rejection) => { + rejectOpening(classifyUpgradeRejection(options.url, rejection)); + }, }); - return openQwpWebSocket(socket, options.connectTimeoutMs, () => { - const qwpVersion = parseQwpVersion(upgradeHeaders); - if (qwpVersion < 1 || qwpVersion > clientMaxVersion) { - throw new QwpVersionMismatchError(qwpVersion, clientMaxVersion); - } - const durableAckEnabled = - headerValue(upgradeHeaders, "x-qwp-durable-ack")?.toLowerCase() === - "enabled"; - if (options.requestDurableAck && !durableAckEnabled) { - throw new QwpDurableAckUnavailableError(options.url); - } - const handshake: QwpHandshakeMetadata = { - qwpVersion, - maxBatchSizeBytes: parseMaxBatchSize(upgradeHeaders), - contentEncoding: headerValue(upgradeHeaders, "x-qwp-content-encoding"), - durableAckEnabled, - serverRole: headerValue(upgradeHeaders, "x-questdb-role"), - }; - return handshake; + return openQwpWebSocket(socket, { + url: options.url, + connectTimeoutMs: options.connectTimeoutMs, + openingFailure, + completeHandshake: () => { + const qwpVersion = parseQwpVersion(upgradeHeaders); + if (qwpVersion < 1 || qwpVersion > clientMaxVersion) { + throw new QwpVersionMismatchError( + qwpVersion, + clientMaxVersion, + options.url, + ); + } + const durableAckEnabled = + headerValue(upgradeHeaders, "x-qwp-durable-ack")?.toLowerCase() === + "enabled"; + if (options.requestDurableAck && !durableAckEnabled) { + throw new QwpDurableAckUnavailableError(options.url); + } + const handshake: QwpHandshakeMetadata = { + qwpVersion, + maxBatchSizeBytes: parseMaxBatchSize(upgradeHeaders), + contentEncoding: headerValue(upgradeHeaders, "x-qwp-content-encoding"), + durableAckEnabled, + serverRole: headerValue(upgradeHeaders, "x-questdb-role"), + }; + return handshake; + }, }); } diff --git a/src/qwp/transport.ts b/src/qwp/transport.ts index a563f56..ffa9013 100644 --- a/src/qwp/transport.ts +++ b/src/qwp/transport.ts @@ -4,6 +4,81 @@ export interface QwpConnectionCloseInfo { wasClean: boolean; } +export const QWP_UPGRADE_ERROR_KIND = { + AUTHENTICATION: "authentication", + ROLE_REJECTED: "role-rejected", + HTTP_REJECTED: "http-rejected", + VERSION_MISMATCH: "version-mismatch", + CAPABILITY_MISMATCH: "capability-mismatch", + TIMEOUT: "timeout", + TRANSPORT: "transport", + /** Browser WebSocket APIs do not expose the rejected HTTP upgrade. */ + OPAQUE: "opaque", +} as const; + +export type QwpUpgradeErrorKind = + (typeof QWP_UPGRADE_ERROR_KIND)[keyof typeof QWP_UPGRADE_ERROR_KIND]; + +export interface QwpUpgradeErrorDetails { + kind: QwpUpgradeErrorKind; + /** Whether a later retry against the configured endpoint set may recover. */ + retryable?: boolean; + /** Whether failover code should try another endpoint before surfacing this. */ + tryNextEndpoint?: boolean; + url?: string | URL; + statusCode?: number; + statusMessage?: string; + serverRole?: string; + serverZone?: string; + closeCode?: number; + cause?: unknown; +} + +/** A failure while establishing or validating a QWP WebSocket upgrade. */ +export class QwpUpgradeError extends Error { + readonly kind: QwpUpgradeErrorKind; + readonly retryable?: boolean; + readonly tryNextEndpoint?: boolean; + readonly url?: string | URL; + readonly statusCode?: number; + readonly statusMessage?: string; + readonly serverRole?: string; + readonly serverZone?: string; + readonly closeCode?: number; + readonly cause?: unknown; + + constructor(message: string, details: QwpUpgradeErrorDetails) { + super(message); + this.name = "QwpUpgradeError"; + this.kind = details.kind; + this.retryable = details.retryable; + this.tryNextEndpoint = details.tryNextEndpoint; + this.url = details.url; + this.statusCode = details.statusCode; + this.statusMessage = details.statusMessage; + this.serverRole = details.serverRole; + this.serverZone = details.serverZone; + this.closeCode = details.closeCode; + this.cause = details.cause; + } + + /** True for a 421 response from a read-only replica. */ + get isTopologicalRoleReject(): boolean { + return ( + this.kind === QWP_UPGRADE_ERROR_KIND.ROLE_REJECTED && + this.serverRole?.toUpperCase() === "REPLICA" + ); + } + + /** True for a 421 response from a primary still completing catch-up. */ + get isTransientRoleReject(): boolean { + return ( + this.kind === QWP_UPGRADE_ERROR_KIND.ROLE_REJECTED && + this.serverRole?.toUpperCase() === "PRIMARY_CATCHUP" + ); + } +} + /** Metadata negotiated during the QWP WebSocket upgrade. */ export interface QwpHandshakeMetadata { /** QWP protocol version selected by the server. */ diff --git a/test/qwp/browser.e2e.ts b/test/qwp/browser.e2e.ts index 2e8f626..b2ca3c9 100644 --- a/test/qwp/browser.e2e.ts +++ b/test/qwp/browser.e2e.ts @@ -133,7 +133,7 @@ describe("QWP in a real browser against QuestDB", () => { url: string, ) => Promise>; const qwp = await importModule(moduleUrl); - const canConnect = async ( + const tryConnect = async ( connect: (options: { url: string; }) => Promise<{ close(): Promise }>, @@ -142,19 +142,46 @@ describe("QWP in a real browser against QuestDB", () => { try { const session = await connect({ url }); await session.close(); - return true; - } catch { - return false; + return { connected: true }; + } catch (error) { + const failure = error as { + name?: string; + kind?: string; + retryable?: boolean; + statusCode?: number; + }; + return { + connected: false, + name: failure.name, + kind: failure.kind, + retryable: failure.retryable ?? null, + statusCode: failure.statusCode ?? null, + }; } }; return { - ingress: await canConnect(qwp.connectQwpBrowserIngress, ingress), - egress: await canConnect(qwp.connectQwpBrowserEgress, egress), + ingress: await tryConnect(qwp.connectQwpBrowserIngress, ingress), + egress: await tryConnect(qwp.connectQwpBrowserEgress, egress), }; }, { moduleUrl: assetUrl, ingress: ingressUrl, egress: egressUrl }, ); - expect(anonymousUpgrades).toEqual({ ingress: false, egress: false }); + expect(anonymousUpgrades).toEqual({ + ingress: { + connected: false, + name: "QwpUpgradeError", + kind: "opaque", + retryable: null, + statusCode: null, + }, + egress: { + connected: false, + name: "QwpUpgradeError", + kind: "opaque", + retryable: null, + statusCode: null, + }, + }); const login = await page.evaluate( async ({ username, password, table }) => { diff --git a/test/qwp/node-transport.test.ts b/test/qwp/node-transport.test.ts index 21336aa..d3d2d62 100644 --- a/test/qwp/node-transport.test.ts +++ b/test/qwp/node-transport.test.ts @@ -2,9 +2,12 @@ import type { AddressInfo } from "node:net"; import { WebSocketServer } from "ws"; import { afterEach, describe, expect, it } from "vitest"; import { + connectQwpNodeWebSocket, connectQwpNodeIngress, QWP_STATUS, + QWP_UPGRADE_ERROR_KIND, QwpByteWriter, + QwpUpgradeError, } from "../../src/qwp/node"; function writeTable( @@ -106,4 +109,38 @@ describe("QWP Node transport", () => { await session.close(); } }); + + it("classifies a real role-rejected HTTP upgrade", async () => { + server = new WebSocketServer({ + host: "127.0.0.1", + port: 0, + verifyClient: (_info, done) => { + done(false, 421, "Misdirected Request", { + "X-QuestDB-Role": "PRIMARY_CATCHUP", + "X-QuestDB-Zone": "eu-west-2", + }); + }, + }); + await new Promise((resolve, reject) => { + server!.once("listening", resolve); + server!.once("error", reject); + }); + + const address = server.address() as AddressInfo; + const connecting = connectQwpNodeWebSocket({ + url: `ws://127.0.0.1:${address.port}/write/v4`, + }); + const error = await connecting.catch((caught: unknown) => caught); + expect(error).toMatchObject({ + name: "QwpUpgradeError", + kind: QWP_UPGRADE_ERROR_KIND.ROLE_REJECTED, + retryable: true, + tryNextEndpoint: true, + statusCode: 421, + serverRole: "PRIMARY_CATCHUP", + serverZone: "eu-west-2", + isTopologicalRoleReject: false, + isTransientRoleReject: true, + } satisfies Partial); + }); }); diff --git a/test/qwp/session.test.ts b/test/qwp/session.test.ts index 3ba2ca3..bbe0265 100644 --- a/test/qwp/session.test.ts +++ b/test/qwp/session.test.ts @@ -10,10 +10,12 @@ import { } from "../../src/qwp/node"; import { QWP_STATUS, + QWP_UPGRADE_ERROR_KIND, QwpBatchTooLargeError, QwpByteWriter, QwpIngressNackError, QwpIngressSession, + QwpUpgradeError, } from "../../src/qwp"; type Listener = (event: unknown) => void; @@ -222,6 +224,10 @@ describe("QWP WebSocket adapters", () => { name: "QwpVersionMismatchError", serverVersion: 2, clientMaxVersion: 1, + kind: QWP_UPGRADE_ERROR_KIND.VERSION_MISMATCH, + retryable: true, + tryNextEndpoint: true, + url: "ws://localhost:9000/write/v4", } satisfies Partial); expect(socket.closeCalls).toHaveLength(1); }); @@ -238,12 +244,118 @@ describe("QWP WebSocket adapters", () => { }); socket.open(); - await expect(connecting).rejects.toBeInstanceOf( - QwpDurableAckUnavailableError, - ); + await expect(connecting).rejects.toMatchObject({ + name: "QwpDurableAckUnavailableError", + kind: QWP_UPGRADE_ERROR_KIND.CAPABILITY_MISMATCH, + retryable: false, + tryNextEndpoint: true, + } satisfies Partial); expect(socket.closeCalls).toHaveLength(1); }); + it.each([ + { + statusCode: 401, + statusMessage: "Unauthorized", + headers: {}, + kind: QWP_UPGRADE_ERROR_KIND.AUTHENTICATION, + retryable: false, + tryNextEndpoint: false, + }, + { + statusCode: 421, + statusMessage: "Misdirected Request", + headers: { + "x-questdb-role": "REPLICA", + "x-questdb-zone": "eu-west-1", + }, + kind: QWP_UPGRADE_ERROR_KIND.ROLE_REJECTED, + retryable: true, + tryNextEndpoint: true, + }, + { + statusCode: 503, + statusMessage: "Service Unavailable", + headers: {}, + kind: QWP_UPGRADE_ERROR_KIND.HTTP_REJECTED, + retryable: false, + tryNextEndpoint: true, + }, + ])( + "classifies an HTTP $statusCode upgrade rejection", + async ({ + statusCode, + statusMessage, + headers, + kind, + retryable, + tryNextEndpoint, + }) => { + const socket = new FakeWebSocket(); + const connecting = connectQwpNodeWebSocket({ + url: "ws://localhost:9000/write/v4", + webSocketFactory: (_url, options) => { + options.onUpgradeRejected({ statusCode, statusMessage, headers }); + return asQwpSocket(socket); + }, + }); + + const error = await connecting.catch((caught: unknown) => caught); + expect(error).toMatchObject({ + name: "QwpUpgradeError", + kind, + retryable, + tryNextEndpoint, + statusCode, + statusMessage, + url: "ws://localhost:9000/write/v4", + } satisfies Partial); + if (statusCode === 421) { + expect(error).toMatchObject({ + serverRole: "REPLICA", + serverZone: "eu-west-1", + isTopologicalRoleReject: true, + isTransientRoleReject: false, + } satisfies Partial); + } + }, + ); + + it("reports browser upgrade failures as opaque", async () => { + const socket = new FakeWebSocket(); + const connecting = connectQwpBrowserWebSocket({ + url: "ws://localhost:9000/write/v4", + webSocketFactory: () => asQwpSocket(socket), + }); + socket.error(); + + await expect(connecting).rejects.toMatchObject({ + name: "QwpUpgradeError", + kind: QWP_UPGRADE_ERROR_KIND.OPAQUE, + retryable: undefined, + tryNextEndpoint: undefined, + statusCode: undefined, + serverRole: undefined, + } satisfies Partial); + }); + + it("classifies Node opening errors as retriable transport failures", async () => { + const socket = new FakeWebSocket(); + const connecting = connectQwpNodeWebSocket({ + url: "ws://localhost:9000/write/v4", + webSocketFactory: () => asQwpSocket(socket), + }); + socket.error(); + + await expect(connecting).rejects.toMatchObject({ + name: "QwpUpgradeError", + kind: QWP_UPGRADE_ERROR_KIND.TRANSPORT, + retryable: true, + tryNextEndpoint: true, + statusCode: undefined, + } satisfies Partial); + }); + it("rejects a connection that does not open before its deadline", async () => { vi.useFakeTimers(); try { @@ -253,7 +365,12 @@ describe("QWP WebSocket adapters", () => { connectTimeoutMs: 25, webSocketFactory: () => asQwpSocket(socket), }); - const rejected = expect(connecting).rejects.toThrow(/timed out/i); + const rejected = expect(connecting).rejects.toMatchObject({ + name: "QwpUpgradeError", + kind: QWP_UPGRADE_ERROR_KIND.TIMEOUT, + retryable: true, + tryNextEndpoint: true, + } satisfies Partial); await vi.advanceTimersByTimeAsync(25); await rejected; expect(socket.closeCalls).toHaveLength(1); From 63527207fc5bd1d8f7f4af832aca4daf807fc551 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Mon, 17 Aug 2026 18:36:45 +0100 Subject: [PATCH 011/265] feat(qwp): apply websocket send backpressure --- src/qwp/browser.ts | 1 + src/qwp/ingress-session.ts | 18 ++- src/qwp/internal/websocket-connection.ts | 153 ++++++++++++++++++- src/qwp/node.ts | 7 +- src/qwp/transport.ts | 42 +++++ test/qwp/session.test.ts | 187 +++++++++++++++++++++++ 6 files changed, 396 insertions(+), 12 deletions(-) diff --git a/src/qwp/browser.ts b/src/qwp/browser.ts index cd550db..b7832cb 100644 --- a/src/qwp/browser.ts +++ b/src/qwp/browser.ts @@ -52,6 +52,7 @@ export function connectQwpBrowserWebSocket( return openQwpWebSocket(socket, { url: options.url, connectTimeoutMs: options.connectTimeoutMs, + sendTimeoutMs: options.sendTimeoutMs, completeHandshake: () => ({ qwpVersion: QWP_VERSION }), opaqueErrors: true, }); diff --git a/src/qwp/ingress-session.ts b/src/qwp/ingress-session.ts index 3fc75ea..2a5ecde 100644 --- a/src/qwp/ingress-session.ts +++ b/src/qwp/ingress-session.ts @@ -188,12 +188,6 @@ export class QwpIngressSession { const response = new Promise((resolve, reject) => { pending = { resolve, reject }; }); - pending.timer = setTimeout(() => { - if (!this.pending.delete(sequence)) return; - pending.reject( - new Error(`timed out waiting for QWP ACK [sequence=${sequence}]`), - ); - }, this.options.ackTimeoutMs ?? 15_000); this.pending.set(sequence, pending); const sending = this.sendTail.then(async () => { @@ -203,6 +197,18 @@ export class QwpIngressSession { this.sendTail = sending.catch((error: unknown) => { this.fail(error); }); + void sending.then( + () => { + if (this.pending.get(sequence) !== pending) return; + pending.timer = setTimeout(() => { + if (!this.pending.delete(sequence)) return; + pending.reject( + new Error(`timed out waiting for QWP ACK [sequence=${sequence}]`), + ); + }, this.options.ackTimeoutMs ?? 15_000); + }, + () => undefined, + ); void sending.catch((error: unknown) => { const current = this.pending.get(sequence); if (current !== pending) return; diff --git a/src/qwp/internal/websocket-connection.ts b/src/qwp/internal/websocket-connection.ts index 58edc20..4c4cd13 100644 --- a/src/qwp/internal/websocket-connection.ts +++ b/src/qwp/internal/websocket-connection.ts @@ -4,6 +4,9 @@ import { QwpBinaryConnection, QwpConnectionCloseInfo, QwpHandshakeMetadata, + QwpSendClosedError, + QwpSendError, + QwpSendTimeoutError, QwpUpgradeError, } from "../transport"; import { QwpAsyncQueue } from "./async-queue"; @@ -21,9 +24,15 @@ interface QwpWebSocketCloseEvent { export interface QwpWebSocketLike { binaryType: string; readonly readyState: number; + /** Number of application bytes queued by WHATWG-compatible WebSockets. */ + readonly bufferedAmount?: number; send(data: Uint8Array): void; + /** Node adapter hook for the `ws.send(data, callback)` completion signal. */ + sendWithCallback?(data: Uint8Array, callback: (error?: Error) => void): void; /** Node WebSocket implementations may expose control-frame PING. */ ping?(): void; + /** Node WebSocket implementations may support immediate termination. */ + terminate?(): void; close(code?: number, reason?: string): void; addEventListener( type: "open", @@ -49,6 +58,7 @@ export interface QwpWebSocketLike { export interface QwpWebSocketOpenOptions { url: string | URL; connectTimeoutMs?: number; + sendTimeoutMs?: number; completeHandshake: () => QwpHandshakeMetadata; /** Node adapters use this to surface non-101 HTTP responses from `ws`. */ openingFailure?: Promise; @@ -58,6 +68,7 @@ export interface QwpWebSocketOpenOptions { const WEBSOCKET_OPEN = 1; const WEBSOCKET_CLOSED = 3; +const BUFFERED_AMOUNT_POLL_MS = 4; async function normalizeBinaryMessage(data: unknown): Promise { if (data instanceof ArrayBuffer) return new Uint8Array(data); @@ -85,6 +96,12 @@ export function openQwpWebSocket( new RangeError("connectTimeoutMs must be a positive finite number"), ); } + const sendTimeoutMs = options.sendTimeoutMs ?? 15_000; + if (!Number.isFinite(sendTimeoutMs) || sendTimeoutMs <= 0) { + return Promise.reject( + new RangeError("sendTimeoutMs must be a positive finite number"), + ); + } const messages = new QwpAsyncQueue(); let resolveClosed!: (info: QwpConnectionCloseInfo) => void; @@ -94,6 +111,125 @@ export function openQwpWebSocket( let opened = false; let openingSettled = false; let messageTail: Promise = Promise.resolve(); + let sendTail: Promise = Promise.resolve(); + let terminalSendError: QwpSendError | undefined; + let rejectActiveSend: ((error: QwpSendError) => void) | undefined; + + const failSends = (error: QwpSendError): QwpSendError => { + terminalSendError ??= error; + rejectActiveSend?.(terminalSendError); + return terminalSendError; + }; + + const abortAfterSendFailure = (): void => { + try { + if (socket.terminate) { + socket.terminate(); + } else if (socket.readyState !== WEBSOCKET_CLOSED) { + socket.close(1011, "QWP send failed"); + } + } catch { + // The send error remains authoritative if shutdown races the transport. + } + }; + + const sendWithBackpressure = (payload: Uint8Array): Promise => { + if (terminalSendError) return Promise.reject(terminalSendError); + if (socket.readyState !== WEBSOCKET_OPEN) { + return Promise.reject(failSends(new QwpSendClosedError())); + } + + return new Promise((resolveSend, rejectSend) => { + let settled = false; + let drainPoll: ReturnType | undefined; + + const settle = (error?: QwpSendError): void => { + if (settled) return; + settled = true; + if (drainPoll) clearTimeout(drainPoll); + clearTimeout(sendTimeout); + if (rejectActiveSend === rejectPending) rejectActiveSend = undefined; + if (error) rejectSend(error); + else resolveSend(); + }; + const rejectPending = (error: QwpSendError): void => settle(error); + const failSend = (error: QwpSendError): void => { + settle(failSends(error)); + abortAfterSendFailure(); + }; + + rejectActiveSend = rejectPending; + const sendTimeout = setTimeout(() => { + const bufferedAmount = socket.bufferedAmount; + failSend( + new QwpSendTimeoutError( + sendTimeoutMs, + typeof bufferedAmount === "number" ? bufferedAmount : undefined, + ), + ); + }, sendTimeoutMs); + + if (socket.sendWithCallback) { + try { + socket.sendWithCallback(payload, (error) => { + if (error) { + failSend( + new QwpSendError( + "QWP WebSocket send failed; delivery outcome is unknown", + error, + ), + ); + } else { + settle(); + } + }); + } catch (error) { + failSend( + new QwpSendError( + "QWP WebSocket send failed before it could be queued", + error, + ), + ); + } + return; + } + + const initialBufferedAmount = socket.bufferedAmount; + try { + socket.send(payload); + } catch (error) { + failSend( + new QwpSendError( + "QWP WebSocket send failed before it could be queued", + error, + ), + ); + return; + } + + if (typeof initialBufferedAmount !== "number") { + // Backwards compatibility for custom adapters without a drain signal. + settle(); + return; + } + + const waitForDrain = (): void => { + if (socket.readyState !== WEBSOCKET_OPEN) { + settle(failSends(new QwpSendClosedError())); + return; + } + if ( + typeof socket.bufferedAmount !== "number" || + socket.bufferedAmount <= initialBufferedAmount + ) { + settle(); + return; + } + drainPoll = setTimeout(waitForDrain, BUFFERED_AMOUNT_POLL_MS); + }; + waitForDrain(); + }); + }; return new Promise((resolve, reject) => { const timeout = setTimeout(() => { @@ -151,11 +287,10 @@ export function openQwpWebSocket( messages, closed, handshake, - async send(payload: Uint8Array): Promise { - if (socket.readyState !== WEBSOCKET_OPEN) { - throw new Error("QWP WebSocket is not open"); - } - socket.send(payload); + send(payload: Uint8Array): Promise { + const sending = sendTail.then(() => sendWithBackpressure(payload)); + sendTail = sending.catch(() => undefined); + return sending; }, async close(code = 1000, reason = ""): Promise { if (socket.readyState === WEBSOCKET_CLOSED) return; @@ -207,6 +342,13 @@ export function openQwpWebSocket( socket.addEventListener("error", (event) => { if (opened) { + const eventError = (event as { error?: unknown }).error; + failSends( + new QwpSendError( + "QWP WebSocket transport error while sending", + eventError ?? event, + ), + ); messages.fail(new Error("QWP WebSocket transport error")); return; } @@ -256,6 +398,7 @@ export function openQwpWebSocket( ); return; } + failSends(new QwpSendClosedError(info)); void messageTail.finally(() => messages.end()); }, { once: true }, diff --git a/src/qwp/node.ts b/src/qwp/node.ts index 3387d31..4a1bf96 100644 --- a/src/qwp/node.ts +++ b/src/qwp/node.ts @@ -194,7 +194,11 @@ export function connectQwpNodeWebSocket( }); response.resume(); }); - return socket as unknown as QwpWebSocketLike; + const qwpSocket = socket as unknown as QwpWebSocketLike; + qwpSocket.sendWithCallback = (data, callback) => { + socket.send(data, callback); + }; + return qwpSocket; }); let upgradeHeaders: IncomingHttpHeaders | undefined; @@ -216,6 +220,7 @@ export function connectQwpNodeWebSocket( return openQwpWebSocket(socket, { url: options.url, connectTimeoutMs: options.connectTimeoutMs, + sendTimeoutMs: options.sendTimeoutMs, openingFailure, completeHandshake: () => { const qwpVersion = parseQwpVersion(upgradeHeaders); diff --git a/src/qwp/transport.ts b/src/qwp/transport.ts index ffa9013..8ab1abc 100644 --- a/src/qwp/transport.ts +++ b/src/qwp/transport.ts @@ -4,6 +4,46 @@ export interface QwpConnectionCloseInfo { wasClean: boolean; } +/** A failure while handing a QWP frame to the WebSocket transport. */ +export class QwpSendError extends Error { + readonly cause?: unknown; + + constructor(message: string, cause?: unknown) { + super(message); + this.name = "QwpSendError"; + this.cause = cause; + } +} + +/** The WebSocket did not drain a QWP frame before its send deadline. */ +export class QwpSendTimeoutError extends QwpSendError { + constructor( + readonly timeoutMs: number, + readonly bufferedAmountBytes?: number, + ) { + super( + `QWP WebSocket send timed out after ${timeoutMs}ms; delivery outcome is unknown${ + bufferedAmountBytes === undefined + ? "" + : ` [bufferedAmount=${bufferedAmountBytes}]` + }`, + ); + this.name = "QwpSendTimeoutError"; + } +} + +/** A QWP send was rejected because its WebSocket closed. */ +export class QwpSendClosedError extends QwpSendError { + constructor(readonly closeInfo?: QwpConnectionCloseInfo) { + super( + closeInfo + ? `QWP WebSocket closed while sending [code=${closeInfo.code}, reason=${closeInfo.reason}]` + : "QWP WebSocket is not open", + ); + this.name = "QwpSendClosedError"; + } +} + export const QWP_UPGRADE_ERROR_KIND = { AUTHENTICATION: "authentication", ROLE_REJECTED: "role-rejected", @@ -114,6 +154,8 @@ export interface QwpWebSocketConnectOptions { url: string | URL; protocols?: string | string[]; connectTimeoutMs?: number; + /** Maximum time a send may remain queued by the WebSocket. Defaults to 15s. */ + sendTimeoutMs?: number; } export type QwpConnectionFactory = () => Promise; diff --git a/test/qwp/session.test.ts b/test/qwp/session.test.ts index bbe0265..57ab73b 100644 --- a/test/qwp/session.test.ts +++ b/test/qwp/session.test.ts @@ -15,6 +15,8 @@ import { QwpByteWriter, QwpIngressNackError, QwpIngressSession, + QwpSendClosedError, + QwpSendTimeoutError, QwpUpgradeError, } from "../../src/qwp"; @@ -23,6 +25,7 @@ type Listener = (event: unknown) => void; class FakeWebSocket { binaryType = "blob"; readyState = 0; + bufferedAmount = 0; readonly sent: Uint8Array[] = []; readonly closeCalls: { code?: number; reason?: string }[] = []; onSend?: (payload: Uint8Array) => void; @@ -68,6 +71,35 @@ class FakeWebSocket { } } +class FakeBackpressuredWebSocket extends FakeWebSocket { + send(payload: Uint8Array): void { + this.bufferedAmount += payload.byteLength; + super.send(payload); + } + + drain(bytes = this.bufferedAmount): void { + this.bufferedAmount = Math.max(0, this.bufferedAmount - bytes); + } +} + +class FakeCallbackWebSocket extends FakeWebSocket { + readonly sendCallbacks: ((error?: Error) => void)[] = []; + + sendWithCallback( + payload: Uint8Array, + callback: (error?: Error) => void, + ): void { + super.send(payload); + this.sendCallbacks.push(callback); + } + + completeSend(error?: Error): void { + const callback = this.sendCallbacks.shift(); + if (!callback) throw new Error("no pending WebSocket send"); + callback(error); + } +} + class FakePingWebSocket extends FakeWebSocket { pingCalls = 0; onPing?: () => void; @@ -379,6 +411,124 @@ describe("QWP WebSocket adapters", () => { } }); + it("serializes browser sends until buffered bytes drain", async () => { + vi.useFakeTimers(); + try { + const socket = new FakeBackpressuredWebSocket(); + const connecting = connectQwpBrowserWebSocket({ + url: "ws://localhost:9000/write/v4", + sendTimeoutMs: 100, + webSocketFactory: () => asQwpSocket(socket), + }); + socket.open(); + const connection = await connecting; + + const first = connection.send(Uint8Array.of(1)); + const second = connection.send(Uint8Array.of(2)); + await vi.advanceTimersByTimeAsync(0); + expect(socket.sent).toEqual([Uint8Array.of(1)]); + + socket.drain(); + await vi.advanceTimersByTimeAsync(4); + await expect(first).resolves.toBeUndefined(); + expect(socket.sent).toEqual([Uint8Array.of(1), Uint8Array.of(2)]); + + socket.drain(); + await vi.advanceTimersByTimeAsync(4); + await expect(second).resolves.toBeUndefined(); + await connection.close(); + } finally { + vi.useRealTimers(); + } + }); + + it("times out a browser send that remains buffered", async () => { + vi.useFakeTimers(); + try { + const socket = new FakeBackpressuredWebSocket(); + const connecting = connectQwpBrowserWebSocket({ + url: "ws://localhost:9000/write/v4", + sendTimeoutMs: 25, + webSocketFactory: () => asQwpSocket(socket), + }); + socket.open(); + const connection = await connecting; + + const sending = connection.send(Uint8Array.of(1, 2, 3)); + const caught = sending.catch((error: unknown) => error); + await vi.advanceTimersByTimeAsync(25); + const error = await caught; + expect(error).toMatchObject({ + name: "QwpSendTimeoutError", + timeoutMs: 25, + bufferedAmountBytes: 3, + } satisfies Partial); + expect(socket.closeCalls).toContainEqual({ + code: 1011, + reason: "QWP send failed", + }); + await expect(connection.send(Uint8Array.of(4))).rejects.toBe(error); + expect(socket.sent).toHaveLength(1); + } finally { + vi.useRealTimers(); + } + }); + + it("rejects a buffered send when the WebSocket closes", async () => { + vi.useFakeTimers(); + try { + const socket = new FakeBackpressuredWebSocket(); + const connecting = connectQwpBrowserWebSocket({ + url: "ws://localhost:9000/write/v4", + sendTimeoutMs: 100, + webSocketFactory: () => asQwpSocket(socket), + }); + socket.open(); + const connection = await connecting; + + const caught = connection + .send(Uint8Array.of(1)) + .catch((error: unknown) => error); + await vi.advanceTimersByTimeAsync(0); + socket.close(1001, "server shutdown"); + await expect(caught).resolves.toMatchObject({ + name: "QwpSendClosedError", + closeInfo: { + code: 1001, + reason: "server shutdown", + wasClean: true, + }, + } satisfies Partial); + } finally { + vi.useRealTimers(); + } + }); + + it("awaits Node send callbacks and preserves send order", async () => { + const socket = new FakeCallbackWebSocket(); + const connecting = connectQwpNodeWebSocket({ + url: "ws://localhost:9000/write/v4", + webSocketFactory: (_url, options) => { + options.onUpgrade({}); + return asQwpSocket(socket); + }, + }); + socket.open(); + const connection = await connecting; + + const first = connection.send(Uint8Array.of(1)); + const second = connection.send(Uint8Array.of(2)); + await vi.waitFor(() => expect(socket.sent).toEqual([Uint8Array.of(1)])); + socket.completeSend(); + await expect(first).resolves.toBeUndefined(); + await vi.waitFor(() => + expect(socket.sent).toEqual([Uint8Array.of(1), Uint8Array.of(2)]), + ); + socket.completeSend(); + await expect(second).resolves.toBeUndefined(); + await connection.close(); + }); + it("rejects text frames and closes with a protocol error", async () => { const socket = new FakeWebSocket(); const connecting = connectQwpBrowserWebSocket({ @@ -453,6 +603,43 @@ describe("QwpIngressSession", () => { await session.close(); }); + it("starts the ingress ACK deadline after send backpressure clears", async () => { + vi.useFakeTimers(); + try { + const socket = new FakeBackpressuredWebSocket(); + const connecting = connectQwpBrowserWebSocket({ + url: "ws://localhost:9000/write/v4", + sendTimeoutMs: 100, + webSocketFactory: () => asQwpSocket(socket), + }); + socket.open(); + const session = new QwpIngressSession(await connecting, { + ackTimeoutMs: 25, + }); + let settled = false; + const outcome = session.sendFrame(Uint8Array.of(1)).catch((error) => { + settled = true; + return error; + }); + + await vi.advanceTimersByTimeAsync(25); + expect(settled).toBe(false); + socket.drain(); + await vi.advanceTimersByTimeAsync(4); + await vi.advanceTimersByTimeAsync(23); + expect(settled).toBe(false); + await vi.advanceTimersByTimeAsync(2); + await expect(outcome).resolves.toEqual( + expect.objectContaining({ + message: expect.stringMatching(/timed out.*sequence=0/i), + }), + ); + await session.close(); + } finally { + vi.useRealTimers(); + } + }); + it("resolves every covered waiter from a cumulative ACK", async () => { const socket = new FakeWebSocket(); const connecting = connectQwpBrowserWebSocket({ From 75c4484189fe66a7c9117f3c18168ed460a4570e Mon Sep 17 00:00:00 2001 From: glasstiger Date: Mon, 17 Aug 2026 20:49:56 +0100 Subject: [PATCH 012/265] feat(qwp): add reconnect failover and replay --- src/qwp-node/file-replay-store.ts | 353 ++++++++ src/qwp/browser.ts | 33 +- src/qwp/egress-session.ts | 49 +- src/qwp/ingress-session.ts | 60 +- src/qwp/internal/async-queue.ts | 57 +- src/qwp/internal/failover.ts | 38 + .../reconnecting-egress-connection.ts | 566 +++++++++++++ .../reconnecting-ingress-connection.ts | 753 ++++++++++++++++++ src/qwp/internal/websocket-connection.ts | 1 + src/qwp/node.ts | 87 +- src/qwp/transport.ts | 123 +++ test/qwp/node-transport.test.ts | 74 ++ test/qwp/reconnect.test.ts | 624 +++++++++++++++ 13 files changed, 2777 insertions(+), 41 deletions(-) create mode 100644 src/qwp-node/file-replay-store.ts create mode 100644 src/qwp/internal/failover.ts create mode 100644 src/qwp/internal/reconnecting-egress-connection.ts create mode 100644 src/qwp/internal/reconnecting-ingress-connection.ts create mode 100644 test/qwp/reconnect.test.ts diff --git a/src/qwp-node/file-replay-store.ts b/src/qwp-node/file-replay-store.ts new file mode 100644 index 0000000..da433be --- /dev/null +++ b/src/qwp-node/file-replay-store.ts @@ -0,0 +1,353 @@ +import { createHash, randomUUID } from "node:crypto"; +import { + mkdir, + open, + readdir, + readFile, + rename, + unlink, +} from "node:fs/promises"; +import { join } from "node:path"; +import { + QwpIngressReplayRecord, + QwpIngressReplayStore, +} from "../qwp/transport"; + +const MAGIC = Buffer.from("QWPR"); +const FORMAT_VERSION = 1; +const HEADER_SIZE = 52; +const SHA256_SIZE = 32; +const MAX_FRAME_SEQUENCE = 0xffffffffffffffffn; +const RECORD_SUFFIX = ".qwp"; +const TEMP_MARKER = ".tmp-"; + +interface StoredRecord { + readonly path: string; + readonly size: number; +} + +export interface QwpNodeFileReplayStoreOptions { + /** Exclusive directory used by one ingress session. */ + directory: string; + /** Maximum journal size including record headers. Defaults to 1 GiB. */ + maxBytes?: number; +} + +export class QwpReplayStoreError extends Error { + readonly cause?: unknown; + + constructor(message: string, cause?: unknown) { + super(message); + this.name = "QwpReplayStoreError"; + this.cause = cause; + } +} + +export class QwpReplayStoreFullError extends QwpReplayStoreError { + constructor( + readonly maxBytes: number, + readonly requiredBytes: number, + ) { + super( + `QWP store-and-forward journal is full [maxBytes=${maxBytes}, requiredBytes=${requiredBytes}]`, + ); + this.name = "QwpReplayStoreFullError"; + } +} + +/** + * Crash-safe Node store-and-forward journal. + * + * Each frame is fsynced under a temporary name before an atomic rename. An ACK + * removes its covered files and fsyncs the directory. A crash between the + * server ACK and local deletion can therefore cause at-least-once replay, but + * cannot silently lose an unacknowledged frame. + */ +export class QwpNodeFileReplayStore implements QwpIngressReplayStore { + private readonly directory: string; + private readonly maxBytes: number; + private readonly records = new Map(); + private operationTail: Promise = Promise.resolve(); + private totalBytes = 0; + private loaded = false; + private closing = false; + private closed = false; + + constructor(options: QwpNodeFileReplayStoreOptions) { + const directory = options.directory.trim(); + if (!directory) { + throw new RangeError("store-and-forward directory must not be empty"); + } + const maxBytes = options.maxBytes ?? 1024 * 1024 * 1024; + if (!Number.isSafeInteger(maxBytes) || maxBytes <= HEADER_SIZE) { + throw new RangeError( + `store-and-forward maxBytes must be a safe integer greater than ${HEADER_SIZE}`, + ); + } + this.directory = directory; + this.maxBytes = maxBytes; + } + + load(): Promise { + if (this.closing || this.closed) return Promise.reject(this.closedError()); + return this.enqueue(async () => { + this.assertOpen(); + if (this.loaded) { + throw new QwpReplayStoreError( + "QWP store-and-forward journal has already been loaded", + ); + } + await mkdir(this.directory, { recursive: true }); + const entries = await readdir(this.directory, { withFileTypes: true }); + const recordNames: string[] = []; + let removedTemporaryFile = false; + for (const entry of entries) { + if (!entry.isFile()) continue; + if (entry.name.includes(TEMP_MARKER)) { + await ignoreMissing(unlink(join(this.directory, entry.name))); + removedTemporaryFile = true; + } else if (entry.name.endsWith(RECORD_SUFFIX)) { + recordNames.push(entry.name); + } + } + if (removedTemporaryFile) await syncDirectory(this.directory); + recordNames.sort(); + + const recovered: QwpIngressReplayRecord[] = []; + let previous = -1n; + for (const name of recordNames) { + const path = join(this.directory, name); + let bytes: Buffer; + try { + bytes = await readFile(path); + } catch (error) { + throw new QwpReplayStoreError( + `could not read QWP store-and-forward record [file=${name}]`, + error, + ); + } + const record = decodeRecord(bytes, name); + if (record.frameSequence <= previous) { + throw new QwpReplayStoreError( + `QWP store-and-forward sequence is not strictly increasing [file=${name}]`, + ); + } + const expectedName = recordFileName(record.frameSequence); + if (name !== expectedName) { + throw new QwpReplayStoreError( + `QWP store-and-forward filename does not match its sequence [file=${name}, expected=${expectedName}]`, + ); + } + this.totalBytes += bytes.byteLength; + if (this.totalBytes > this.maxBytes) { + throw new QwpReplayStoreFullError(this.maxBytes, this.totalBytes); + } + this.records.set(record.frameSequence, { + path, + size: bytes.byteLength, + }); + recovered.push(record); + previous = record.frameSequence; + } + this.loaded = true; + return recovered; + }); + } + + append(record: QwpIngressReplayRecord): Promise { + if (this.closing || this.closed) return Promise.reject(this.closedError()); + return this.enqueue(async () => { + this.assertReady(); + validateFrameSequence(record.frameSequence); + if (this.records.has(record.frameSequence)) { + throw new QwpReplayStoreError( + `QWP store-and-forward sequence already exists [frameSequence=${record.frameSequence}]`, + ); + } + const bytes = encodeRecord(record); + const requiredBytes = this.totalBytes + bytes.byteLength; + if (requiredBytes > this.maxBytes) { + throw new QwpReplayStoreFullError(this.maxBytes, requiredBytes); + } + + const name = recordFileName(record.frameSequence); + const finalPath = join(this.directory, name); + const temporaryPath = join( + this.directory, + `${name}${TEMP_MARKER}${process.pid}-${randomUUID()}`, + ); + try { + const file = await open(temporaryPath, "wx", 0o600); + try { + await file.writeFile(bytes); + await file.sync(); + } finally { + await file.close(); + } + await rename(temporaryPath, finalPath); + await syncDirectory(this.directory); + } catch (error) { + await ignoreMissing(unlink(temporaryPath)); + throw new QwpReplayStoreError( + `could not persist QWP store-and-forward record [frameSequence=${record.frameSequence}]`, + error, + ); + } + this.records.set(record.frameSequence, { + path: finalPath, + size: bytes.byteLength, + }); + this.totalBytes = requiredBytes; + }); + } + + acknowledgeThrough(frameSequence: bigint): Promise { + if (this.closing || this.closed) return Promise.reject(this.closedError()); + return this.enqueue(async () => { + this.assertReady(); + let changed = false; + for (const [sequence, record] of this.records) { + if (sequence > frameSequence) break; + try { + await ignoreMissing(unlink(record.path)); + } catch (error) { + throw new QwpReplayStoreError( + `could not acknowledge QWP store-and-forward record [frameSequence=${sequence}]`, + error, + ); + } + this.records.delete(sequence); + this.totalBytes -= record.size; + changed = true; + } + if (changed) await syncDirectory(this.directory); + }); + } + + async close(): Promise { + if (this.closed) return; + this.closing = true; + await this.operationTail; + this.closed = true; + } + + private enqueue(operation: () => Promise): Promise { + const result = this.operationTail.then(operation); + this.operationTail = result.then( + () => undefined, + () => undefined, + ); + return result; + } + + private assertOpen(): void { + if (this.closed) throw this.closedError(); + } + + private assertReady(): void { + this.assertOpen(); + if (!this.loaded) { + throw new QwpReplayStoreError( + "QWP store-and-forward journal must be loaded before use", + ); + } + } + + private closedError(): QwpReplayStoreError { + return new QwpReplayStoreError("QWP store-and-forward journal is closed"); + } +} + +function encodeRecord(record: QwpIngressReplayRecord): Buffer { + validateFrameSequence(record.frameSequence); + if (record.payload.byteLength > 0xffffffff) { + throw new QwpReplayStoreError( + `QWP frame is too large for the store-and-forward format [size=${record.payload.byteLength}]`, + ); + } + const bytes = Buffer.allocUnsafe(HEADER_SIZE + record.payload.byteLength); + MAGIC.copy(bytes, 0); + bytes.writeUInt8(FORMAT_VERSION, 4); + bytes.fill(0, 5, 8); + bytes.writeBigUInt64LE(record.frameSequence, 8); + bytes.writeUInt32LE(record.payload.byteLength, 16); + const digest = createHash("sha256").update(record.payload).digest(); + digest.copy(bytes, 20); + Buffer.from( + record.payload.buffer, + record.payload.byteOffset, + record.payload.byteLength, + ).copy(bytes, HEADER_SIZE); + return bytes; +} + +function decodeRecord(bytes: Buffer, name: string): QwpIngressReplayRecord { + if (bytes.byteLength < HEADER_SIZE) { + throw corruptRecord(name, "record is shorter than its header"); + } + if (!bytes.subarray(0, MAGIC.byteLength).equals(MAGIC)) { + throw corruptRecord(name, "invalid magic"); + } + if (bytes.readUInt8(4) !== FORMAT_VERSION) { + throw corruptRecord(name, `unsupported version ${bytes.readUInt8(4)}`); + } + const frameSequence = bytes.readBigUInt64LE(8); + const payloadLength = bytes.readUInt32LE(16); + if (HEADER_SIZE + payloadLength !== bytes.byteLength) { + throw corruptRecord(name, "payload length does not match file size"); + } + const payload = bytes.subarray(HEADER_SIZE); + const expectedDigest = bytes.subarray(20, 20 + SHA256_SIZE); + const actualDigest = createHash("sha256").update(payload).digest(); + if (!actualDigest.equals(expectedDigest)) { + throw corruptRecord(name, "payload checksum mismatch"); + } + return { frameSequence, payload: new Uint8Array(payload) }; +} + +function corruptRecord(name: string, reason: string): QwpReplayStoreError { + return new QwpReplayStoreError( + `corrupt QWP store-and-forward record [file=${name}]: ${reason}`, + ); +} + +function validateFrameSequence(frameSequence: bigint): void { + if (frameSequence < 0n || frameSequence > MAX_FRAME_SEQUENCE) { + throw new QwpReplayStoreError( + `QWP store-and-forward sequence is outside uint64 range [frameSequence=${frameSequence}]`, + ); + } +} + +function recordFileName(frameSequence: bigint): string { + return `${frameSequence.toString().padStart(20, "0")}${RECORD_SUFFIX}`; +} + +async function syncDirectory(directory: string): Promise { + let handle; + try { + handle = await open(directory, "r"); + await handle.sync(); + } catch (error) { + const code = nodeErrorCode(error); + if (code !== "EINVAL" && code !== "ENOTSUP" && code !== "EISDIR") { + throw error; + } + } finally { + await handle?.close(); + } +} + +async function ignoreMissing(operation: Promise): Promise { + try { + await operation; + } catch (error) { + if (nodeErrorCode(error) !== "ENOENT") throw error; + } +} + +function nodeErrorCode(error: unknown): string | undefined { + return error && typeof error === "object" && "code" in error + ? String(error.code) + : undefined; +} diff --git a/src/qwp/browser.ts b/src/qwp/browser.ts index b7832cb..8ebe74c 100644 --- a/src/qwp/browser.ts +++ b/src/qwp/browser.ts @@ -5,8 +5,13 @@ import { openQwpWebSocket, QwpWebSocketLike, } from "./internal/websocket-connection"; +import { createQwpFailoverConnectionFactory } from "./internal/failover"; import { QWP_VERSION } from "./core"; -import { QwpBinaryConnection, QwpWebSocketConnectOptions } from "./transport"; +import { + QwpBinaryConnection, + QwpConnectionFactory, + QwpWebSocketConnectOptions, +} from "./transport"; import { QwpEgressSession, QwpEgressSessionOptions } from "./egress-session"; import { QwpIngressSession, QwpIngressSessionOptions } from "./ingress-session"; @@ -31,6 +36,24 @@ export interface QwpBrowserWebSocketOptions extends QwpWebSocketConnectOptions { */ export function connectQwpBrowserWebSocket( options: QwpBrowserWebSocketOptions, +): Promise { + return createQwpBrowserConnectionFactory(options)(); +} + +/** Creates a stateful browser endpoint walker suitable for session reconnects. */ +export function createQwpBrowserConnectionFactory( + options: QwpBrowserWebSocketOptions, +): QwpConnectionFactory { + return createQwpFailoverConnectionFactory( + options.url, + options.failoverUrls, + (endpoint) => connectQwpBrowserEndpoint(options, endpoint), + ); +} + +function connectQwpBrowserEndpoint( + options: QwpBrowserWebSocketOptions, + endpoint: string | URL, ): Promise { const factory = options.webSocketFactory ?? @@ -48,9 +71,9 @@ export function connectQwpBrowserWebSocket( } return new WebSocketConstructor(url, protocols); }); - const socket = factory(options.url, options.protocols); + const socket = factory(endpoint, options.protocols); return openQwpWebSocket(socket, { - url: options.url, + url: endpoint, connectTimeoutMs: options.connectTimeoutMs, sendTimeoutMs: options.sendTimeoutMs, completeHandshake: () => ({ qwpVersion: QWP_VERSION }), @@ -64,7 +87,7 @@ export async function connectQwpBrowserIngress( sessionOptions: QwpIngressSessionOptions = {}, ): Promise { return QwpIngressSession.connect( - () => connectQwpBrowserWebSocket(options), + createQwpBrowserConnectionFactory(options), sessionOptions, ); } @@ -75,7 +98,7 @@ export async function connectQwpBrowserEgress( sessionOptions: QwpEgressSessionOptions = {}, ): Promise { return QwpEgressSession.connect( - () => connectQwpBrowserWebSocket(options), + createQwpBrowserConnectionFactory(options), sessionOptions, ); } diff --git a/src/qwp/egress-session.ts b/src/qwp/egress-session.ts index 2b1e638..3058303 100644 --- a/src/qwp/egress-session.ts +++ b/src/qwp/egress-session.ts @@ -5,6 +5,7 @@ import { encodeQwpQueryRequest, QWP_EGRESS_CAPABILITY, QWP_QUERY_FLAG_RESET_DICTIONARY, + QWP_RESET_MASK_DICTIONARY, QwpExecDoneMessage, QwpProtocolError, QwpQueryRequest, @@ -14,15 +15,26 @@ import { QwpServerInfoMessage, } from "./core"; import { QwpAsyncQueue } from "./internal/async-queue"; +import { QwpReconnectingEgressConnection } from "./internal/reconnecting-egress-connection"; import { QwpBinaryConnection, QwpConnectionCloseInfo, QwpConnectionFactory, + QwpEgressReplayResetEvent, QwpHandshakeMetadata, + QwpReconnectOptions, } from "./transport"; export interface QwpEgressSessionOptions { serverInfoTimeoutMs?: number; + /** Enables bounded reconnects. Active operations replay only with onReplayReset. */ + reconnect?: QwpReconnectOptions; + /** + * Explicitly opts into at-least-once re-execution after a disconnect. The + * query's not-yet-consumed batches are discarded before this callback, and + * callers must discard any result prefix they already consumed. + */ + onReplayReset?: (event: QwpEgressReplayResetEvent) => void | Promise; } export interface QwpEgressQueryOptions { @@ -119,6 +131,11 @@ export class QwpEgressQuery implements AsyncIterable { this.batches.fail(error); this.rejectCompletion(error); } + + /** @internal */ + resetForReplay(): void { + this.batches.clear(); + } } /** @@ -146,6 +163,14 @@ export class QwpEgressSession implements QwpEgressQueryControl { private readonly connection: QwpBinaryConnection, options: QwpEgressSessionOptions = {}, ) { + if ( + options.reconnect && + !(connection instanceof QwpReconnectingEgressConnection) + ) { + throw new Error( + "egress reconnect options require QwpEgressSession.connect(factory, options)", + ); + } const timeout = options.serverInfoTimeoutMs ?? 15_000; if (!Number.isFinite(timeout) || timeout <= 0) { throw new RangeError( @@ -171,7 +196,23 @@ export class QwpEgressSession implements QwpEgressQueryControl { factory: QwpConnectionFactory, options: QwpEgressSessionOptions = {}, ): Promise { - const session = new QwpEgressSession(await factory(), options); + const timeout = options.serverInfoTimeoutMs ?? 15_000; + const state: { session?: QwpEgressSession } = {}; + const connection = options.reconnect + ? await QwpReconnectingEgressConnection.connect( + factory, + options.reconnect, + timeout, + () => state.session?.prepareConnectionReset(), + options.onReplayReset + ? async (event) => { + await options.onReplayReset!(event); + } + : undefined, + ) + : await factory(); + const session = new QwpEgressSession(connection, options); + state.session = session; try { await session.ready; return session; @@ -330,6 +371,12 @@ export class QwpEgressSession implements QwpEgressQueryControl { return this.active; } + private prepareConnectionReset(): void { + this.decoder.applyCacheReset(QWP_RESET_MASK_DICTIONARY); + this.decoder.resetQuerySchema(); + this.active?.resetForReplay(); + } + private send(payload: Uint8Array): Promise { this.throwIfUnavailable(); const sending = this.sendTail.then(async () => { diff --git a/src/qwp/ingress-session.ts b/src/qwp/ingress-session.ts index 2a5ecde..85ae9e0 100644 --- a/src/qwp/ingress-session.ts +++ b/src/qwp/ingress-session.ts @@ -12,10 +12,25 @@ import { QwpConnectionCloseInfo, QwpConnectionFactory, QwpHandshakeMetadata, + QwpIngressReplayStore, + QwpReconnectOptions, } from "./transport"; +import { QwpReconnectingIngressConnection } from "./internal/reconnecting-ingress-connection"; export interface QwpIngressSessionOptions { ackTimeoutMs?: number; + /** + * Enables bounded reconnection and at-least-once replay of unacknowledged + * frames. Browser replay is memory-only. Node connectors require a + * persistent store-and-forward directory when this is enabled. + * + * An ACK lost during disconnect can cause a frame to be replayed after the + * server accepted it; configure server-side deduplication when duplicates + * are not acceptable. + */ + reconnect?: QwpReconnectOptions; + /** @internal Node adapter hook for persistent store-and-forward. */ + replayStore?: QwpIngressReplayStore; /** * Optional local ingress frame cap. Browsers cannot read WebSocket upgrade * headers, so browser applications should set this to the server's configured @@ -94,7 +109,7 @@ export class QwpIngressSession { private nextSequence = 0n; private sendTail: Promise = Promise.resolve(); private durablePingTimer?: ReturnType; - private readonly effectiveMaxBatchSizeBytes?: number; + private readonly localMaxBatchSizeBytes?: number; private failure?: Error; private closing = false; private readonly receiveLoop: Promise; @@ -103,6 +118,14 @@ export class QwpIngressSession { private readonly connection: QwpBinaryConnection, private readonly options: QwpIngressSessionOptions = {}, ) { + if ( + options.reconnect && + !(connection instanceof QwpReconnectingIngressConnection) + ) { + throw new Error( + "ingress reconnect options require QwpIngressSession.connect(factory, options)", + ); + } const timeout = options.ackTimeoutMs ?? 15_000; if (!Number.isFinite(timeout) || timeout <= 0) { throw new RangeError("ackTimeoutMs must be a positive finite number"); @@ -114,13 +137,7 @@ export class QwpIngressSession { ) { throw new RangeError("maxBatchSizeBytes must be a positive safe integer"); } - const serverBatchCap = connection.handshake.maxBatchSizeBytes; - this.effectiveMaxBatchSizeBytes = - localBatchCap === undefined - ? serverBatchCap - : serverBatchCap === undefined - ? localBatchCap - : Math.min(localBatchCap, serverBatchCap); + this.localMaxBatchSizeBytes = localBatchCap; const keepalive = options.durableAckKeepaliveMs; if ( keepalive !== undefined && @@ -142,7 +159,16 @@ export class QwpIngressSession { factory: QwpConnectionFactory, options: QwpIngressSessionOptions = {}, ): Promise { - const connection = await factory(); + if (options.replayStore && !options.reconnect) { + throw new RangeError("a QWP replayStore requires reconnect options"); + } + const connection = options.reconnect + ? await QwpReconnectingIngressConnection.connect( + factory, + options.reconnect, + options.replayStore, + ) + : await factory(); try { return new QwpIngressSession(connection, options); } catch (error) { @@ -160,7 +186,12 @@ export class QwpIngressSession { } get maxBatchSizeBytes(): number | undefined { - return this.effectiveMaxBatchSizeBytes; + const serverBatchCap = this.connection.handshake.maxBatchSizeBytes; + return this.localMaxBatchSizeBytes === undefined + ? serverBatchCap + : serverBatchCap === undefined + ? this.localMaxBatchSizeBytes + : Math.min(this.localMaxBatchSizeBytes, serverBatchCap); } sendTables( @@ -173,14 +204,11 @@ export class QwpIngressSession { sendFrame(frame: Uint8Array): Promise { this.throwIfUnavailable(); if ( - this.effectiveMaxBatchSizeBytes !== undefined && - frame.byteLength > this.effectiveMaxBatchSizeBytes + this.maxBatchSizeBytes !== undefined && + frame.byteLength > this.maxBatchSizeBytes ) { return Promise.reject( - new QwpBatchTooLargeError( - frame.byteLength, - this.effectiveMaxBatchSizeBytes, - ), + new QwpBatchTooLargeError(frame.byteLength, this.maxBatchSizeBytes), ); } const sequence = this.nextSequence++; diff --git a/src/qwp/internal/async-queue.ts b/src/qwp/internal/async-queue.ts index 72aed1d..2f07c6d 100644 --- a/src/qwp/internal/async-queue.ts +++ b/src/qwp/internal/async-queue.ts @@ -3,9 +3,20 @@ interface PendingNext { reject: (error: unknown) => void; } +interface QueueBarrier { + readonly kind: "barrier"; + readonly resolve: () => void; + readonly reject: (error: unknown) => void; +} + +interface QueueValue { + readonly kind: "value"; + readonly value: T; +} + /** Single-consumer async queue used to preserve WebSocket message ordering. */ export class QwpAsyncQueue implements AsyncIterable { - private readonly values: T[] = []; + private readonly values: (QueueValue | QueueBarrier)[] = []; private readonly pending: PendingNext[] = []; private ended = false; private failure: unknown; @@ -17,13 +28,14 @@ export class QwpAsyncQueue implements AsyncIterable { if (pending) { pending.resolve({ value, done: false }); } else { - this.values.push(value); + this.values.push({ kind: "value", value }); } } end(): void { if (this.ended || this.failure !== undefined) return; this.ended = true; + this.settleBarriers(); for (const pending of this.pending.splice(0)) { pending.resolve({ value: undefined, done: true }); } @@ -32,9 +44,27 @@ export class QwpAsyncQueue implements AsyncIterable { fail(error: unknown): void { if (this.ended || this.failure !== undefined) return; this.failure = error; + this.settleBarriers(error); for (const pending of this.pending.splice(0)) pending.reject(error); } + /** Drops values not yet handed to the single consumer. */ + clear(): void { + for (const entry of this.values.splice(0)) { + if (entry.kind === "barrier") entry.resolve(); + } + } + + /** Resolves once the consumer asks for the item after this queue position. */ + barrier(): Promise { + if (this.ended || this.failure !== undefined || this.pending.length > 0) { + return Promise.resolve(); + } + return new Promise((resolve, reject) => { + this.values.push({ kind: "barrier", resolve, reject }); + }); + } + [Symbol.asyncIterator](): AsyncIterator { if (this.iteratorCreated) { throw new Error("QWP message streams support only one consumer"); @@ -46,9 +76,13 @@ export class QwpAsyncQueue implements AsyncIterable { } private next(): Promise> { - const value = this.values.shift(); - if (value !== undefined) { - return Promise.resolve({ value, done: false }); + while (true) { + const entry = this.values.shift(); + if (!entry) break; + if (entry.kind === "value") { + return Promise.resolve({ value: entry.value, done: false }); + } + entry.resolve(); } if (this.failure !== undefined) return Promise.reject(this.failure); if (this.ended) { @@ -58,4 +92,17 @@ export class QwpAsyncQueue implements AsyncIterable { this.pending.push({ resolve, reject }); }); } + + private settleBarriers(error?: unknown): void { + const entries = this.values.splice(0); + for (const entry of entries) { + if (entry.kind === "value") { + this.values.push(entry); + } else if (error === undefined) { + entry.resolve(); + } else { + entry.reject(error); + } + } + } } diff --git a/src/qwp/internal/failover.ts b/src/qwp/internal/failover.ts new file mode 100644 index 0000000..6f52c8d --- /dev/null +++ b/src/qwp/internal/failover.ts @@ -0,0 +1,38 @@ +import { + QwpBinaryConnection, + QwpConnectionFactory, + QwpFailoverAttempt, + QwpFailoverError, + QwpUpgradeError, +} from "../transport"; + +/** Creates a stateful endpoint walker that rotates away from the last success. */ +export function createQwpFailoverConnectionFactory( + preferredUrl: string | URL, + failoverUrls: readonly (string | URL)[] | undefined, + connect: (endpoint: string | URL) => Promise, +): QwpConnectionFactory { + const endpoints = [preferredUrl, ...(failoverUrls ?? [])]; + let nextStart = 0; + + return async (): Promise => { + const attempts: QwpFailoverAttempt[] = []; + const start = nextStart; + for (let offset = 0; offset < endpoints.length; offset++) { + const index = (start + offset) % endpoints.length; + const endpoint = endpoints[index]; + try { + const connection = await connect(endpoint); + nextStart = (index + 1) % endpoints.length; + return connection; + } catch (error) { + attempts.push({ endpoint, error }); + if (error instanceof QwpUpgradeError && !error.tryNextEndpoint) { + throw error; + } + } + } + if (attempts.length === 1) throw attempts[0].error; + throw new QwpFailoverError(attempts); + }; +} diff --git a/src/qwp/internal/reconnecting-egress-connection.ts b/src/qwp/internal/reconnecting-egress-connection.ts new file mode 100644 index 0000000..23d0a71 --- /dev/null +++ b/src/qwp/internal/reconnecting-egress-connection.ts @@ -0,0 +1,566 @@ +import { + decodeQwpEgressMessage, + QWP_EGRESS_MESSAGE, + QwpProtocolError, + QwpServerInfoMessage, +} from "../core"; +import { + QWP_RECONNECT_EVENT_KIND, + QWP_UPGRADE_ERROR_KIND, + QwpBinaryConnection, + QwpConnectionCloseInfo, + QwpConnectionFactory, + QwpEgressReplayRequiredError, + QwpEgressReplayResetEvent, + QwpFailoverError, + QwpHandshakeMetadata, + QwpReconnectEvent, + QwpReconnectExhaustedError, + QwpReconnectOptions, + QwpSendClosedError, + QwpUpgradeError, +} from "../transport"; +import { QwpAsyncQueue } from "./async-queue"; + +type ReplayResetHandler = ( + event: QwpEgressReplayResetEvent, +) => void | Promise; +type ConnectionResetHandler = () => void | Promise; + +class ReplayResetCallbackError extends Error { + readonly cause: unknown; + + constructor(cause: unknown) { + super("QWP egress replay reset callback failed"); + this.name = "ReplayResetCallbackError"; + this.cause = cause; + } +} + +/** + * Reconnects an egress wire and, only with an explicit reset handler, replays + * the in-flight request and its control messages. Statements may therefore be + * executed more than once when their outcome was lost with the connection. + */ +export class QwpReconnectingEgressConnection implements QwpBinaryConnection { + private readonly messagesQueue = new QwpAsyncQueue(); + private readonly maxAttempts: number; + private readonly initialBackoffMs: number; + private readonly maxBackoffMs: number; + private readonly maxDurationMs: number; + private readonly resolveClosed: (info: QwpConnectionCloseInfo) => void; + private connection?: QwpBinaryConnection; + private connectingCandidate?: QwpBinaryConnection; + private lastHandshake?: QwpHandshakeMetadata; + private lastEndpoint?: string | URL; + private initialServerInfo?: QwpServerInfoMessage; + private outboundReplay: Uint8Array[] = []; + private generation = 0; + private sendTail: Promise = Promise.resolve(); + private reconnectTask?: Promise; + private terminalError?: Error; + private cancelBackoff?: () => void; + private closing = false; + private closedSettled = false; + readonly messages: AsyncIterable = this.messagesQueue; + readonly closed: Promise; + + private constructor( + private readonly factory: QwpConnectionFactory, + private readonly reconnectOptions: QwpReconnectOptions, + private readonly serverInfoTimeoutMs: number, + private readonly onConnectionReset: ConnectionResetHandler, + private readonly onReplayReset?: ReplayResetHandler, + ) { + this.maxAttempts = reconnectOptions.maxAttempts ?? 3; + this.initialBackoffMs = reconnectOptions.initialBackoffMs ?? 100; + this.maxBackoffMs = reconnectOptions.maxBackoffMs ?? 5_000; + this.maxDurationMs = reconnectOptions.maxDurationMs ?? 30_000; + validateReconnectPolicy( + this.maxAttempts, + this.initialBackoffMs, + this.maxBackoffMs, + this.maxDurationMs, + ); + let resolveClosed!: (info: QwpConnectionCloseInfo) => void; + this.closed = new Promise((resolve) => { + resolveClosed = resolve; + }); + this.resolveClosed = resolveClosed; + } + + static async connect( + factory: QwpConnectionFactory, + reconnectOptions: QwpReconnectOptions, + serverInfoTimeoutMs: number, + onConnectionReset: ConnectionResetHandler, + onReplayReset?: ReplayResetHandler, + ): Promise { + const reconnecting = new QwpReconnectingEgressConnection( + factory, + reconnectOptions, + serverInfoTimeoutMs, + onConnectionReset, + onReplayReset, + ); + try { + await reconnecting.connectLoop(undefined, false); + return reconnecting; + } catch (error) { + await reconnecting.close().catch(() => undefined); + throw error; + } + } + + get handshake(): QwpHandshakeMetadata { + if (!this.lastHandshake) + throw new Error("QWP connection is not established"); + return this.lastHandshake; + } + + get endpoint(): string | URL | undefined { + return this.lastEndpoint; + } + + send(payload: Uint8Array): Promise { + if (this.terminalError) return Promise.reject(this.terminalError); + if (this.closing) return Promise.reject(new QwpSendClosedError()); + const copy = payload.slice(); + const sending = this.sendTail.then(async () => { + this.throwIfUnavailable(); + const connection = await this.requireConnection(); + this.trackOutbound(copy); + try { + await connection.send(copy); + } catch (error) { + await this.requestReconnect(error, connection); + } + }); + this.sendTail = sending.catch(() => undefined); + return sending; + } + + async close(code = 1000, reason = ""): Promise { + if (this.closing) { + await this.closed; + return; + } + this.closing = true; + this.cancelBackoff?.(); + this.messagesQueue.end(); + const connection = this.connection; + const connectingCandidate = this.connectingCandidate; + this.connection = undefined; + this.connectingCandidate = undefined; + let closeInfo: QwpConnectionCloseInfo = { + code, + reason, + wasClean: code === 1000, + }; + if (connection) { + try { + await connection.close(code, reason); + closeInfo = await connection.closed; + } catch { + // Preserve the requested close result when transport shutdown races. + } + } + if (connectingCandidate && connectingCandidate !== connection) { + await connectingCandidate.close(code, reason).catch(() => undefined); + } + this.settleClosed(closeInfo); + } + + private async connectLoop( + initialCause: unknown, + reconnecting: boolean, + ): Promise { + const outageStarted = Date.now(); + const previousEndpoint = this.lastEndpoint; + let attempt = 0; + let backoffMs = this.initialBackoffMs; + let lastError = initialCause; + if (reconnecting) { + this.emitEvent({ + kind: QWP_RECONNECT_EVENT_KIND.RECONNECTING, + attempt: 0, + previousEndpoint, + cause: initialCause, + }); + } + + while (!this.closing) { + if (attempt > 0 && backoffMs > 0) { + await this.waitForBackoff(backoffMs); + backoffMs = Math.min(Math.max(backoffMs * 2, 1), this.maxBackoffMs); + } + this.throwIfUnavailable(); + attempt++; + let candidate: QwpBinaryConnection | undefined; + try { + candidate = await this.factory(); + this.connectingCandidate = candidate; + if (this.closing) { + await candidate.close().catch(() => undefined); + throw new QwpSendClosedError(); + } + const iterator = candidate.messages[Symbol.asyncIterator](); + const serverInfoPayload = await this.readServerInfo( + iterator, + candidate, + ); + const serverInfo = decodeQwpEgressMessage(serverInfoPayload); + if (serverInfo.kind !== "server-info") { + throw new QwpProtocolError( + "QWP egress connection did not begin with SERVER_INFO", + ); + } + if (reconnecting) { + this.validateServerInfo(serverInfo, candidate); + await this.replayInto(candidate, previousEndpoint, initialCause); + } else { + this.initialServerInfo = serverInfo; + this.messagesQueue.push(serverInfoPayload); + } + if (this.closing) throw new QwpSendClosedError(); + this.install(candidate, iterator); + this.connectingCandidate = undefined; + if (reconnecting) { + this.emitEvent({ + kind: + previousEndpoint !== undefined && + String(previousEndpoint) !== String(candidate.endpoint) + ? QWP_RECONNECT_EVENT_KIND.FAILED_OVER + : QWP_RECONNECT_EVENT_KIND.RECONNECTED, + attempt, + endpoint: candidate.endpoint, + previousEndpoint, + }); + } + return; + } catch (error) { + lastError = error; + if (this.connectingCandidate === candidate) { + this.connectingCandidate = undefined; + } + if (candidate) await candidate.close().catch(() => undefined); + this.emitEvent({ + kind: QWP_RECONNECT_EVENT_KIND.ATTEMPT_FAILED, + attempt, + endpoint: candidate?.endpoint, + previousEndpoint, + cause: error, + }); + if (!isRetryableReconnectError(error)) throw error; + const attemptsExhausted = + this.maxAttempts > 0 && attempt >= this.maxAttempts; + const durationExhausted = + this.maxDurationMs > 0 && + Date.now() - outageStarted >= this.maxDurationMs; + if (attemptsExhausted || durationExhausted) { + throw new QwpReconnectExhaustedError(attempt, lastError); + } + } + } + throw new QwpSendClosedError(); + } + + private install( + connection: QwpBinaryConnection, + iterator: AsyncIterator, + ): void { + this.connection = connection; + this.lastHandshake = connection.handshake; + this.lastEndpoint = connection.endpoint; + const generation = ++this.generation; + void this.pump(connection, iterator, generation); + } + + private async pump( + connection: QwpBinaryConnection, + iterator: AsyncIterator, + generation: number, + ): Promise { + try { + while (true) { + const next = await iterator.next(); + if (next.done) break; + if (this.closing || this.connection !== connection) return; + const message = decodeQwpEgressMessage(next.value); + if (message.kind === "server-info") { + throw new QwpProtocolError("received duplicate QWP SERVER_INFO"); + } else if ( + message.kind === "result-end" || + message.kind === "exec-done" || + message.kind === "query-error" + ) { + this.outboundReplay = []; + } + this.messagesQueue.push(next.value); + } + if (this.closing || this.connection !== connection) return; + await this.requestReconnect( + new QwpSendClosedError(await connection.closed), + connection, + ).catch((reconnectError) => this.failTerminal(reconnectError)); + return; + } catch (error) { + if ( + this.closing || + this.connection !== connection || + generation !== this.generation + ) { + return; + } + if (error instanceof QwpProtocolError) { + this.failTerminal(error); + await connection + .close(1002, "invalid QWP egress message") + .catch(() => undefined); + return; + } + await this.requestReconnect(error, connection).catch((reconnectError) => { + this.failTerminal(reconnectError); + }); + } + } + + private async readServerInfo( + iterator: AsyncIterator, + connection: QwpBinaryConnection, + ): Promise { + let timer: ReturnType | undefined; + const timeout = new Promise((_resolve, reject) => { + timer = setTimeout( + () => + reject(new Error("timed out waiting for QWP reconnect SERVER_INFO")), + this.serverInfoTimeoutMs, + ); + }); + try { + const result = await Promise.race([iterator.next(), timeout]); + if (result.done) { + throw new QwpSendClosedError(await connection.closed); + } + return result.value; + } finally { + if (timer) clearTimeout(timer); + } + } + + private validateServerInfo( + serverInfo: QwpServerInfoMessage, + connection: QwpBinaryConnection, + ): void { + const initial = this.initialServerInfo; + if (!initial) { + throw new QwpProtocolError( + "QWP reconnect started before the initial SERVER_INFO was received", + ); + } + const missingCapabilities = initial.capabilities & ~serverInfo.capabilities; + if (missingCapabilities !== 0) { + throw new QwpUpgradeError( + `QWP reconnect target lacks required egress capabilities [missing=0x${missingCapabilities.toString(16)}]`, + { + kind: QWP_UPGRADE_ERROR_KIND.CAPABILITY_MISMATCH, + retryable: true, + tryNextEndpoint: true, + url: connection.endpoint, + }, + ); + } + if ( + initial.clusterId && + serverInfo.clusterId && + initial.clusterId !== serverInfo.clusterId + ) { + throw new QwpUpgradeError( + `QWP reconnect target belongs to a different cluster [expected=${initial.clusterId}, actual=${serverInfo.clusterId}]`, + { + kind: QWP_UPGRADE_ERROR_KIND.CAPABILITY_MISMATCH, + retryable: true, + tryNextEndpoint: true, + url: connection.endpoint, + }, + ); + } + } + + private async replayInto( + connection: QwpBinaryConnection, + previousEndpoint: string | URL | undefined, + cause: unknown, + ): Promise { + await this.messagesQueue.barrier(); + await this.onConnectionReset(); + if (this.outboundReplay.length === 0) return; + const requestId = replayRequestId(this.outboundReplay); + if (!this.onReplayReset) { + throw new QwpEgressReplayRequiredError(requestId); + } + try { + await this.onReplayReset({ + requestId, + previousEndpoint, + endpoint: connection.endpoint, + cause, + }); + } catch (error) { + throw new ReplayResetCallbackError(error); + } + for (const payload of this.outboundReplay) await connection.send(payload); + } + + private trackOutbound(payload: Uint8Array): void { + switch (payload[0]) { + case QWP_EGRESS_MESSAGE.QUERY_REQUEST: + this.outboundReplay = [payload]; + break; + case QWP_EGRESS_MESSAGE.CREDIT: + case QWP_EGRESS_MESSAGE.CANCEL: + if (this.outboundReplay.length > 0) this.outboundReplay.push(payload); + break; + } + } + + private async requireConnection(): Promise { + if (this.reconnectTask) await this.reconnectTask; + this.throwIfUnavailable(); + if (!this.connection) throw new QwpSendClosedError(); + return this.connection; + } + + private async requestReconnect( + cause: unknown, + failedConnection: QwpBinaryConnection, + ): Promise { + if (this.closing) throw new QwpSendClosedError(); + if (this.connection && this.connection !== failedConnection) return; + if (this.reconnectTask) { + const activeReconnect = this.reconnectTask; + await activeReconnect; + if (this.connection === failedConnection && !this.closing) { + await this.requestReconnect(cause, failedConnection); + } + return; + } + + this.connection = undefined; + void failedConnection.close().catch(() => undefined); + const reconnecting = this.connectLoop(cause, true); + this.reconnectTask = reconnecting; + try { + await reconnecting; + } finally { + if (this.reconnectTask === reconnecting) this.reconnectTask = undefined; + } + } + + private async waitForBackoff(delayMs: number): Promise { + await new Promise((resolve) => { + const timer = setTimeout(() => { + if (this.cancelBackoff === cancel) this.cancelBackoff = undefined; + resolve(); + }, delayMs); + const cancel = (): void => { + clearTimeout(timer); + if (this.cancelBackoff === cancel) this.cancelBackoff = undefined; + resolve(); + }; + this.cancelBackoff = cancel; + }); + } + + private emitEvent(event: QwpReconnectEvent): void { + try { + this.reconnectOptions.onEvent?.(event); + } catch { + // Connection observers must not interfere with replay progress. + } + } + + private throwIfUnavailable(): void { + if (this.terminalError) throw this.terminalError; + if (this.closing) throw new QwpSendClosedError(); + } + + private failTerminal(error: unknown): void { + if (this.terminalError) return; + this.terminalError = + error instanceof Error + ? error + : new Error(`QWP reconnect failed: ${error}`); + this.cancelBackoff?.(); + this.messagesQueue.fail(this.terminalError); + this.settleClosed({ + code: 1011, + reason: this.terminalError.message, + wasClean: false, + }); + void this.connection + ?.close(1011, "QWP reconnect failed") + .catch(() => undefined); + } + + private settleClosed(info: QwpConnectionCloseInfo): void { + if (this.closedSettled) return; + this.closedSettled = true; + this.resolveClosed(info); + } +} + +function replayRequestId(payloads: readonly Uint8Array[]): bigint | undefined { + const query = payloads.find( + (payload) => payload[0] === QWP_EGRESS_MESSAGE.QUERY_REQUEST, + ); + if (!query || query.byteLength < 9) return undefined; + return new DataView( + query.buffer, + query.byteOffset, + query.byteLength, + ).getBigUint64(1, true); +} + +function validateReconnectPolicy( + maxAttempts: number, + initialBackoffMs: number, + maxBackoffMs: number, + maxDurationMs: number, +): void { + if (!Number.isSafeInteger(maxAttempts) || maxAttempts < 0) { + throw new RangeError( + "reconnect maxAttempts must be a non-negative safe integer", + ); + } + for (const [name, value] of [ + ["initialBackoffMs", initialBackoffMs], + ["maxBackoffMs", maxBackoffMs], + ["maxDurationMs", maxDurationMs], + ] as const) { + if (!Number.isFinite(value) || value < 0) { + throw new RangeError( + `reconnect ${name} must be a non-negative finite number`, + ); + } + } + if (maxBackoffMs < initialBackoffMs) { + throw new RangeError( + "reconnect maxBackoffMs must be greater than or equal to initialBackoffMs", + ); + } +} + +function isRetryableReconnectError(error: unknown): boolean { + if (error instanceof QwpUpgradeError) return error.retryable !== false; + if (error instanceof QwpFailoverError) { + return error.attempts.some((attempt) => + isRetryableReconnectError(attempt.error), + ); + } + return !( + error instanceof QwpEgressReplayRequiredError || + error instanceof ReplayResetCallbackError || + error instanceof QwpProtocolError + ); +} diff --git a/src/qwp/internal/reconnecting-ingress-connection.ts b/src/qwp/internal/reconnecting-ingress-connection.ts new file mode 100644 index 0000000..299f7d5 --- /dev/null +++ b/src/qwp/internal/reconnecting-ingress-connection.ts @@ -0,0 +1,753 @@ +import { + decodeQwpIngressResponse, + QWP_STATUS, + QwpProtocolError, +} from "../core"; +import { + QWP_RECONNECT_EVENT_KIND, + QwpBinaryConnection, + QwpConnectionCloseInfo, + QwpConnectionFactory, + QwpFailoverError, + QwpHandshakeMetadata, + QwpIngressReplayRecord, + QwpIngressReplayStore, + QwpReconnectEvent, + QwpReconnectExhaustedError, + QwpReconnectOptions, + QwpReplayRejectedError, + QwpSendClosedError, + QwpUpgradeError, +} from "../transport"; +import { QwpAsyncQueue } from "./async-queue"; + +interface ReplayFrame extends QwpIngressReplayRecord { + readonly clientSequence?: bigint; + ackDelivered: boolean; + transmitted: boolean; + durableTargets?: Map; +} + +class RetriableIngressNackError extends Error { + constructor( + readonly frameSequence: bigint, + readonly status: number, + readonly retryDelayMs: number, + message?: string, + ) { + super( + `QuestDB temporarily rejected QWP frame [frameSequence=${frameSequence}, status=0x${status.toString(16)}]${ + message ? `: ${message}` : "" + }`, + ); + this.name = "RetriableIngressNackError"; + } +} + +class QwpMemoryReplayStore implements QwpIngressReplayStore { + private readonly records = new Map(); + + async load(): Promise { + return Array.from(this.records, ([frameSequence, payload]) => ({ + frameSequence, + payload: payload.slice(), + })); + } + + async append(record: QwpIngressReplayRecord): Promise { + this.records.set(record.frameSequence, record.payload.slice()); + } + + async acknowledgeThrough(frameSequence: bigint): Promise { + for (const sequence of this.records.keys()) { + if (sequence > frameSequence) break; + this.records.delete(sequence); + } + } + + async close(): Promise {} +} + +/** + * Reconnects an ingress wire and translates its per-connection ACK sequence + * back to stable replay records. Replay is deliberately at-least-once: a frame + * accepted by the server whose ACK was lost may be sent again. + */ +export class QwpReconnectingIngressConnection implements QwpBinaryConnection { + private readonly messagesQueue = new QwpAsyncQueue(); + private readonly frames = new Map(); + private readonly durableWatermarks = new Map(); + private readonly store: QwpIngressReplayStore; + private readonly maxAttempts: number; + private readonly initialBackoffMs: number; + private readonly maxBackoffMs: number; + private readonly maxDurationMs: number; + private readonly maxFrameRejections: number; + private readonly resolveClosed: (info: QwpConnectionCloseInfo) => void; + private connection?: QwpBinaryConnection; + private connectingCandidate?: QwpBinaryConnection; + private lastHandshake?: QwpHandshakeMetadata; + private lastEndpoint?: string | URL; + private wireFrames: ReplayFrame[] = []; + private nextFrameSequence = 0n; + private nextClientSequence = 0n; + private rejectedFrameSequence?: bigint; + private rejectionCount = 0; + private generation = 0; + private sendTail: Promise = Promise.resolve(); + private reconnectTask?: Promise; + private terminalError?: Error; + private cancelBackoff?: () => void; + private closing = false; + private closedSettled = false; + readonly messages: AsyncIterable = this.messagesQueue; + readonly closed: Promise; + ping?: () => Promise; + + private constructor( + private readonly factory: QwpConnectionFactory, + private readonly reconnectOptions: QwpReconnectOptions, + store: QwpIngressReplayStore, + records: readonly QwpIngressReplayRecord[], + ) { + this.store = store; + this.maxAttempts = reconnectOptions.maxAttempts ?? 3; + this.initialBackoffMs = reconnectOptions.initialBackoffMs ?? 100; + this.maxBackoffMs = reconnectOptions.maxBackoffMs ?? 5_000; + this.maxDurationMs = reconnectOptions.maxDurationMs ?? 30_000; + this.maxFrameRejections = reconnectOptions.maxFrameRejections ?? 4; + validateReconnectPolicy( + this.maxAttempts, + this.initialBackoffMs, + this.maxBackoffMs, + this.maxDurationMs, + this.maxFrameRejections, + ); + let resolveClosed!: (info: QwpConnectionCloseInfo) => void; + this.closed = new Promise((resolve) => { + resolveClosed = resolve; + }); + this.resolveClosed = resolveClosed; + + let previous = -1n; + for (const record of records) { + if (record.frameSequence < 0n || record.frameSequence <= previous) { + throw new Error( + "QWP replay store records must have strictly increasing non-negative sequences", + ); + } + const frame: ReplayFrame = { + frameSequence: record.frameSequence, + payload: record.payload.slice(), + ackDelivered: true, + transmitted: true, + }; + this.frames.set(frame.frameSequence, frame); + previous = frame.frameSequence; + } + this.nextFrameSequence = previous + 1n; + } + + static async connect( + factory: QwpConnectionFactory, + reconnectOptions: QwpReconnectOptions, + replayStore?: QwpIngressReplayStore, + ): Promise { + const store = replayStore ?? new QwpMemoryReplayStore(); + let connection: QwpReconnectingIngressConnection | undefined; + try { + const records = await store.load(); + connection = new QwpReconnectingIngressConnection( + factory, + reconnectOptions, + store, + [...records].sort((a, b) => + a.frameSequence < b.frameSequence + ? -1 + : a.frameSequence > b.frameSequence + ? 1 + : 0, + ), + ); + await connection.connectLoop(undefined, false); + return connection; + } catch (error) { + await connection?.close().catch(() => undefined); + if (!connection) await store.close().catch(() => undefined); + throw error; + } + } + + get handshake(): QwpHandshakeMetadata { + if (!this.lastHandshake) + throw new Error("QWP connection is not established"); + return this.lastHandshake; + } + + get endpoint(): string | URL | undefined { + return this.lastEndpoint; + } + + send(payload: Uint8Array): Promise { + if (this.terminalError) return Promise.reject(this.terminalError); + if (this.closing) return Promise.reject(new QwpSendClosedError()); + const frame: ReplayFrame = { + frameSequence: this.nextFrameSequence++, + clientSequence: this.nextClientSequence++, + payload: payload.slice(), + ackDelivered: false, + transmitted: false, + }; + const sending = this.sendTail.then(async () => { + this.throwIfUnavailable(); + await this.store.append(frame); + this.frames.set(frame.frameSequence, frame); + try { + await this.transmit(frame); + } catch (error) { + this.failTerminal(error); + throw error; + } + }); + this.sendTail = sending.catch(() => undefined); + return sending; + } + + async close(code = 1000, reason = ""): Promise { + if (this.closing) { + await this.closed; + return; + } + this.closing = true; + this.cancelBackoff?.(); + this.messagesQueue.end(); + const connection = this.connection; + const connectingCandidate = this.connectingCandidate; + this.connection = undefined; + this.connectingCandidate = undefined; + let closeInfo: QwpConnectionCloseInfo = { + code, + reason, + wasClean: code === 1000, + }; + if (connection) { + try { + await connection.close(code, reason); + closeInfo = await connection.closed; + } catch { + // The persistent store still has to close after a transport close race. + } + } + if (connectingCandidate && connectingCandidate !== connection) { + await connectingCandidate.close(code, reason).catch(() => undefined); + } + await this.store.close(); + this.settleClosed(closeInfo); + } + + private async connectLoop( + initialCause: unknown, + reconnecting: boolean, + ): Promise { + const outageStarted = Date.now(); + const previousEndpoint = this.lastEndpoint; + let attempt = 0; + let backoffMs = this.initialBackoffMs; + let lastError = initialCause; + if (reconnecting) { + this.emitEvent({ + kind: QWP_RECONNECT_EVENT_KIND.RECONNECTING, + attempt: 0, + previousEndpoint, + cause: initialCause, + }); + } + + if ( + initialCause instanceof RetriableIngressNackError && + initialCause.retryDelayMs > 0 + ) { + await this.waitForBackoff(initialCause.retryDelayMs); + } + + while (!this.closing) { + if (attempt > 0 && backoffMs > 0) { + await this.waitForBackoff(backoffMs); + backoffMs = Math.min(Math.max(backoffMs * 2, 1), this.maxBackoffMs); + } + this.throwIfUnavailable(); + attempt++; + let candidate: QwpBinaryConnection | undefined; + try { + candidate = await this.factory(); + this.connectingCandidate = candidate; + if (this.closing) { + await candidate.close().catch(() => undefined); + throw new QwpSendClosedError(); + } + const replayed = await this.replayInto(candidate); + if (this.closing) throw new QwpSendClosedError(); + this.install(candidate, replayed); + this.connectingCandidate = undefined; + if (reconnecting) { + this.emitEvent({ + kind: + previousEndpoint !== undefined && + String(previousEndpoint) !== String(candidate.endpoint) + ? QWP_RECONNECT_EVENT_KIND.FAILED_OVER + : QWP_RECONNECT_EVENT_KIND.RECONNECTED, + attempt, + endpoint: candidate.endpoint, + previousEndpoint, + }); + } + return; + } catch (error) { + lastError = error; + if (this.connectingCandidate === candidate) { + this.connectingCandidate = undefined; + } + if (candidate) await candidate.close().catch(() => undefined); + this.emitEvent({ + kind: QWP_RECONNECT_EVENT_KIND.ATTEMPT_FAILED, + attempt, + endpoint: candidate?.endpoint, + previousEndpoint, + cause: error, + }); + if (!isRetryableReconnectError(error)) throw error; + const attemptsExhausted = + this.maxAttempts > 0 && attempt >= this.maxAttempts; + const durationExhausted = + this.maxDurationMs > 0 && + Date.now() - outageStarted >= this.maxDurationMs; + if (attemptsExhausted || durationExhausted) { + throw new QwpReconnectExhaustedError(attempt, lastError); + } + } + } + throw new QwpSendClosedError(); + } + + private async replayInto( + connection: QwpBinaryConnection, + ): Promise { + const replayed: ReplayFrame[] = []; + const cap = connection.handshake.maxBatchSizeBytes; + this.durableWatermarks.clear(); + for (const frame of this.frames.values()) { + if (!frame.transmitted) continue; + frame.durableTargets = undefined; + if (cap !== undefined && frame.payload.byteLength > cap) { + throw new RangeError( + `persisted QWP frame exceeds reconnect target batch cap [size=${frame.payload.byteLength}, max=${cap}]`, + ); + } + replayed.push(frame); + await connection.send(frame.payload); + } + return replayed; + } + + private install( + connection: QwpBinaryConnection, + wireFrames: ReplayFrame[], + ): void { + this.connection = connection; + this.lastHandshake = connection.handshake; + this.lastEndpoint = connection.endpoint; + this.wireFrames = wireFrames; + if (connection.ping && !this.ping) { + // Assigned only when the initial transport supports PING so browser + // connections keep the optional capability genuinely absent. + this.ping = () => this.pingWithReconnect(); + } + const generation = ++this.generation; + void this.pump(connection, generation); + } + + private async pump( + connection: QwpBinaryConnection, + generation: number, + ): Promise { + try { + for await (const payload of connection.messages) { + if (this.closing || this.connection !== connection) return; + const translated = await this.translateResponse(payload); + if (translated) this.messagesQueue.push(translated); + if (this.terminalError) return; + } + if (this.closing || this.connection !== connection) return; + const info = await connection.closed; + await this.requestReconnect( + new QwpSendClosedError(info), + connection, + ).catch((reconnectError) => this.failTerminal(reconnectError)); + return; + } catch (error) { + if ( + this.closing || + this.connection !== connection || + generation !== this.generation + ) { + return; + } + if ( + error instanceof QwpProtocolError || + error instanceof QwpReplayRejectedError + ) { + this.failTerminal(error); + await connection + .close(1002, "terminal QWP response") + .catch(() => undefined); + return; + } + await this.requestReconnect(error, connection).catch((reconnectError) => { + this.failTerminal(reconnectError); + }); + } + } + + private async translateResponse( + payload: Uint8Array, + ): Promise { + const response = decodeQwpIngressResponse(payload); + if (response.status === QWP_STATUS.DURABLE_ACK) { + for (const table of response.tables) { + const current = this.durableWatermarks.get(table.name); + if (current === undefined || table.sequenceTransaction > current) { + this.durableWatermarks.set(table.name, table.sequenceTransaction); + } + } + await this.trimDurablePrefix(); + return payload; + } + if (response.sequence === null) { + throw new QwpProtocolError("QWP response is missing its wire sequence"); + } + if ( + response.sequence < 0n || + response.sequence > BigInt(Number.MAX_SAFE_INTEGER) + ) { + throw new QwpProtocolError( + `QWP response sequence is outside the safe range: ${response.sequence}`, + ); + } + const wireIndex = Number(response.sequence); + const frame = this.wireFrames[wireIndex]; + if (!frame) return undefined; + + if (response.status === QWP_STATUS.OK) { + const covered = this.wireFrames.slice(0, wireIndex + 1); + const clientTarget = findLastClientFrame(covered); + const shouldDeliver = covered.some( + (candidate) => + candidate.clientSequence !== undefined && !candidate.ackDelivered, + ); + for (const candidate of covered) candidate.ackDelivered = true; + if ( + this.rejectedFrameSequence !== undefined && + frame.frameSequence >= this.rejectedFrameSequence + ) { + this.rejectedFrameSequence = undefined; + this.rejectionCount = 0; + } + if (this.handshake.durableAckEnabled) { + frame.durableTargets = new Map( + response.tables.map((table) => [ + table.name, + table.sequenceTransaction, + ]), + ); + await this.trimDurablePrefix(); + } else { + await this.acknowledgeThrough(frame.frameSequence); + } + if (!shouldDeliver || clientTarget?.clientSequence === undefined) { + return undefined; + } + return rewriteResponseSequence(payload, clientTarget.clientSequence); + } + + if (isRetriableIngressStatus(response.status)) { + const sameFrame = this.rejectedFrameSequence === frame.frameSequence; + this.rejectedFrameSequence = frame.frameSequence; + this.rejectionCount = sameFrame ? this.rejectionCount + 1 : 1; + const notWritable = response.status === QWP_STATUS.NOT_WRITABLE; + if (!notWritable && this.rejectionCount >= this.maxFrameRejections) { + throw new QwpReplayRejectedError( + frame.frameSequence, + response.status, + `frame remained rejected after ${this.rejectionCount} attempts${ + response.errorMessage ? `: ${response.errorMessage}` : "" + }`, + ); + } + const exponent = notWritable + ? Math.max(this.rejectionCount - 2, 0) + : this.rejectionCount - 1; + const retryDelayMs = + notWritable && this.rejectionCount === 1 + ? 0 + : cappedExponentialBackoff( + this.initialBackoffMs, + this.maxBackoffMs, + exponent, + ); + throw new RetriableIngressNackError( + frame.frameSequence, + response.status, + retryDelayMs, + response.errorMessage, + ); + } + + const replayError = new QwpReplayRejectedError( + frame.frameSequence, + response.status, + response.errorMessage, + ); + if (frame.clientSequence === undefined) { + this.failTerminal(replayError); + return undefined; + } + const translated = rewriteResponseSequence(payload, frame.clientSequence); + this.messagesQueue.push(translated); + this.failTerminal(replayError); + return undefined; + } + + private async trimDurablePrefix(): Promise { + let lastCovered: bigint | undefined; + for (const frame of this.frames.values()) { + if (!frame.durableTargets) break; + if (!areTargetsCovered(frame.durableTargets, this.durableWatermarks)) { + break; + } + lastCovered = frame.frameSequence; + } + if (lastCovered !== undefined) await this.acknowledgeThrough(lastCovered); + } + + private async acknowledgeThrough(frameSequence: bigint): Promise { + await this.store.acknowledgeThrough(frameSequence); + for (const sequence of this.frames.keys()) { + if (sequence > frameSequence) break; + this.frames.delete(sequence); + } + } + + private async transmit(frame: ReplayFrame): Promise { + const connection = await this.requireConnection(); + const cap = connection.handshake.maxBatchSizeBytes; + if (cap !== undefined && frame.payload.byteLength > cap) { + throw new RangeError( + `QWP frame exceeds reconnect target batch cap [size=${frame.payload.byteLength}, max=${cap}]`, + ); + } + frame.transmitted = true; + this.wireFrames.push(frame); + try { + await connection.send(frame.payload); + } catch (error) { + await this.requestReconnect(error, connection); + } + } + + private async requireConnection(): Promise { + if (this.reconnectTask) await this.reconnectTask; + this.throwIfUnavailable(); + if (!this.connection) throw new QwpSendClosedError(); + return this.connection; + } + + private async requestReconnect( + cause: unknown, + failedConnection: QwpBinaryConnection, + ): Promise { + if (this.closing) throw new QwpSendClosedError(); + if (this.connection && this.connection !== failedConnection) return; + if (this.reconnectTask) { + const activeReconnect = this.reconnectTask; + await activeReconnect; + if (this.connection === failedConnection && !this.closing) { + await this.requestReconnect(cause, failedConnection); + } + return; + } + + this.connection = undefined; + void failedConnection.close().catch(() => undefined); + const reconnecting = this.connectLoop(cause, true); + this.reconnectTask = reconnecting; + try { + await reconnecting; + } finally { + if (this.reconnectTask === reconnecting) this.reconnectTask = undefined; + } + } + + private async pingWithReconnect(): Promise { + const connection = await this.requireConnection(); + if (!connection.ping) { + throw new Error("QWP reconnect target does not support WebSocket PING"); + } + try { + await connection.ping(); + } catch (error) { + await this.requestReconnect(error, connection); + const replacement = await this.requireConnection(); + if (!replacement.ping) { + throw new Error("QWP reconnect target does not support WebSocket PING"); + } + await replacement.ping(); + } + } + + private async waitForBackoff(delayMs: number): Promise { + await new Promise((resolve) => { + const timer = setTimeout(() => { + if (this.cancelBackoff === cancel) this.cancelBackoff = undefined; + resolve(); + }, delayMs); + const cancel = (): void => { + clearTimeout(timer); + if (this.cancelBackoff === cancel) this.cancelBackoff = undefined; + resolve(); + }; + this.cancelBackoff = cancel; + }); + } + + private emitEvent(event: QwpReconnectEvent): void { + try { + this.reconnectOptions.onEvent?.(event); + } catch { + // Connection observers must not interfere with replay progress. + } + } + + private throwIfUnavailable(): void { + if (this.terminalError) throw this.terminalError; + if (this.closing) throw new QwpSendClosedError(); + } + + private failTerminal(error: unknown): void { + if (this.terminalError) return; + this.terminalError = + error instanceof Error + ? error + : new Error(`QWP reconnect failed: ${error}`); + this.cancelBackoff?.(); + this.messagesQueue.fail(this.terminalError); + this.settleClosed({ + code: 1011, + reason: this.terminalError.message, + wasClean: false, + }); + void this.connection + ?.close(1011, "QWP reconnect failed") + .catch(() => undefined); + } + + private settleClosed(info: QwpConnectionCloseInfo): void { + if (this.closedSettled) return; + this.closedSettled = true; + this.resolveClosed(info); + } +} + +function validateReconnectPolicy( + maxAttempts: number, + initialBackoffMs: number, + maxBackoffMs: number, + maxDurationMs: number, + maxFrameRejections: number, +): void { + if (!Number.isSafeInteger(maxAttempts) || maxAttempts < 0) { + throw new RangeError( + "reconnect maxAttempts must be a non-negative safe integer", + ); + } + for (const [name, value] of [ + ["initialBackoffMs", initialBackoffMs], + ["maxBackoffMs", maxBackoffMs], + ["maxDurationMs", maxDurationMs], + ] as const) { + if (!Number.isFinite(value) || value < 0) { + throw new RangeError( + `reconnect ${name} must be a non-negative finite number`, + ); + } + } + if (maxBackoffMs < initialBackoffMs) { + throw new RangeError( + "reconnect maxBackoffMs must be greater than or equal to initialBackoffMs", + ); + } + if (!Number.isSafeInteger(maxFrameRejections) || maxFrameRejections < 1) { + throw new RangeError( + "reconnect maxFrameRejections must be a positive safe integer", + ); + } +} + +function isRetryableReconnectError(error: unknown): boolean { + if (error instanceof QwpUpgradeError) return error.retryable !== false; + if (error instanceof QwpFailoverError) { + return error.attempts.some((attempt) => + isRetryableReconnectError(attempt.error), + ); + } + return !(error instanceof QwpReplayRejectedError); +} + +function isRetriableIngressStatus(status: number): boolean { + return ( + status !== QWP_STATUS.SCHEMA_MISMATCH && + status !== QWP_STATUS.PARSE_ERROR && + status !== QWP_STATUS.SECURITY_ERROR + ); +} + +function cappedExponentialBackoff( + initialMs: number, + maximumMs: number, + exponent: number, +): number { + if (initialMs === 0 || maximumMs === 0) return 0; + return Math.min(initialMs * 2 ** Math.min(exponent, 52), maximumMs); +} + +function findLastClientFrame( + frames: readonly ReplayFrame[], +): ReplayFrame | undefined { + for (let index = frames.length - 1; index >= 0; index--) { + if (frames[index].clientSequence !== undefined) return frames[index]; + } + return undefined; +} + +function rewriteResponseSequence( + payload: Uint8Array, + sequence: bigint, +): Uint8Array { + const translated = payload.slice(); + new DataView( + translated.buffer, + translated.byteOffset, + translated.byteLength, + ).setBigUint64(1, sequence, true); + return translated; +} + +function areTargetsCovered( + targets: ReadonlyMap, + watermarks: ReadonlyMap, +): boolean { + for (const [table, target] of targets) { + const watermark = watermarks.get(table); + if (watermark === undefined || watermark < target) return false; + } + return true; +} diff --git a/src/qwp/internal/websocket-connection.ts b/src/qwp/internal/websocket-connection.ts index 4c4cd13..3bd7881 100644 --- a/src/qwp/internal/websocket-connection.ts +++ b/src/qwp/internal/websocket-connection.ts @@ -287,6 +287,7 @@ export function openQwpWebSocket( messages, closed, handshake, + endpoint: options.url, send(payload: Uint8Array): Promise { const sending = sendTail.then(() => sendWithBackpressure(payload)); sendTail = sending.catch(() => undefined); diff --git a/src/qwp/node.ts b/src/qwp/node.ts index 4a1bf96..513f12c 100644 --- a/src/qwp/node.ts +++ b/src/qwp/node.ts @@ -9,15 +9,26 @@ import { openQwpWebSocket, QwpWebSocketLike, } from "./internal/websocket-connection"; +import { createQwpFailoverConnectionFactory } from "./internal/failover"; import { QWP_UPGRADE_ERROR_KIND, QwpBinaryConnection, + QwpConnectionFactory, QwpHandshakeMetadata, QwpUpgradeError, QwpWebSocketConnectOptions, } from "./transport"; import { QwpEgressSession, QwpEgressSessionOptions } from "./egress-session"; import { QwpIngressSession, QwpIngressSessionOptions } from "./ingress-session"; +import { QwpNodeFileReplayStore } from "../qwp-node/file-replay-store"; +import type { QwpNodeFileReplayStoreOptions } from "../qwp-node/file-replay-store"; + +export { + QwpNodeFileReplayStore, + QwpReplayStoreError, + QwpReplayStoreFullError, +} from "../qwp-node/file-replay-store"; +export type { QwpNodeFileReplayStoreOptions } from "../qwp-node/file-replay-store"; export type { QwpWebSocketLike } from "./internal/websocket-connection"; @@ -139,9 +150,35 @@ export interface QwpNodeWebSocketOptions extends QwpWebSocketConnectOptions { ) => QwpWebSocketLike; } +export interface QwpNodeIngressOptions extends QwpNodeWebSocketOptions { + /** + * Enables persistent Node store-and-forward and ingress reconnection. Use a + * directory owned exclusively by this ingress session. + */ + storeAndForward?: QwpNodeFileReplayStoreOptions; +} + /** Opens a Node QWP WebSocket with the upgrade headers required by QuestDB. */ export function connectQwpNodeWebSocket( options: QwpNodeWebSocketOptions, +): Promise { + return createQwpNodeConnectionFactory(options)(); +} + +/** Creates a stateful Node endpoint walker suitable for session reconnects. */ +export function createQwpNodeConnectionFactory( + options: QwpNodeWebSocketOptions, +): QwpConnectionFactory { + return createQwpFailoverConnectionFactory( + options.url, + options.failoverUrls, + (endpoint) => connectQwpNodeEndpoint(options, endpoint), + ); +} + +function connectQwpNodeEndpoint( + options: QwpNodeWebSocketOptions, + endpoint: string | URL, ): Promise { const clientMaxVersion = options.maxVersion ?? QWP_VERSION; if ( @@ -206,7 +243,7 @@ export function connectQwpNodeWebSocket( const openingFailure = new Promise((_resolve, reject) => { rejectOpening = reject; }); - const socket = factory(options.url, { + const socket = factory(endpoint, { protocols: options.protocols, agent: options.agent, headers, @@ -214,11 +251,11 @@ export function connectQwpNodeWebSocket( upgradeHeaders = receivedHeaders; }, onUpgradeRejected: (rejection) => { - rejectOpening(classifyUpgradeRejection(options.url, rejection)); + rejectOpening(classifyUpgradeRejection(endpoint, rejection)); }, }); return openQwpWebSocket(socket, { - url: options.url, + url: endpoint, connectTimeoutMs: options.connectTimeoutMs, sendTimeoutMs: options.sendTimeoutMs, openingFailure, @@ -228,14 +265,14 @@ export function connectQwpNodeWebSocket( throw new QwpVersionMismatchError( qwpVersion, clientMaxVersion, - options.url, + endpoint, ); } const durableAckEnabled = headerValue(upgradeHeaders, "x-qwp-durable-ack")?.toLowerCase() === "enabled"; if (options.requestDurableAck && !durableAckEnabled) { - throw new QwpDurableAckUnavailableError(options.url); + throw new QwpDurableAckUnavailableError(endpoint); } const handshake: QwpHandshakeMetadata = { qwpVersion, @@ -251,17 +288,39 @@ export function connectQwpNodeWebSocket( /** Opens a Node WebSocket and starts an ingress ACK/NACK session. */ export async function connectQwpNodeIngress( - options: QwpNodeWebSocketOptions, + options: QwpNodeIngressOptions, sessionOptions: QwpIngressSessionOptions = {}, ): Promise { - const effectiveSessionOptions = options.requestDurableAck - ? { - ...sessionOptions, - durableAckKeepaliveMs: sessionOptions.durableAckKeepaliveMs ?? 200, - } - : sessionOptions; + if (options.storeAndForward && sessionOptions.replayStore) { + throw new RangeError( + "storeAndForward and a custom replayStore cannot both be configured", + ); + } + if ( + sessionOptions.reconnect && + !options.storeAndForward && + !sessionOptions.replayStore + ) { + throw new RangeError( + "Node QWP ingress reconnection requires a persistent storeAndForward directory", + ); + } + const replayStore = options.storeAndForward + ? new QwpNodeFileReplayStore(options.storeAndForward) + : sessionOptions.replayStore; + const reconnect = options.storeAndForward + ? (sessionOptions.reconnect ?? {}) + : sessionOptions.reconnect; + const effectiveSessionOptions: QwpIngressSessionOptions = { + ...sessionOptions, + reconnect, + replayStore, + durableAckKeepaliveMs: options.requestDurableAck + ? (sessionOptions.durableAckKeepaliveMs ?? 200) + : sessionOptions.durableAckKeepaliveMs, + }; return QwpIngressSession.connect( - () => connectQwpNodeWebSocket(options), + createQwpNodeConnectionFactory(options), effectiveSessionOptions, ); } @@ -272,7 +331,7 @@ export async function connectQwpNodeEgress( sessionOptions: QwpEgressSessionOptions = {}, ): Promise { return QwpEgressSession.connect( - () => connectQwpNodeWebSocket(options), + createQwpNodeConnectionFactory(options), sessionOptions, ); } diff --git a/src/qwp/transport.ts b/src/qwp/transport.ts index 8ab1abc..883ac93 100644 --- a/src/qwp/transport.ts +++ b/src/qwp/transport.ts @@ -44,6 +44,125 @@ export class QwpSendClosedError extends QwpSendError { } } +export interface QwpFailoverAttempt { + readonly endpoint: string | URL; + readonly error: unknown; +} + +/** Every eligible QWP endpoint in one connection sweep failed. */ +export class QwpFailoverError extends Error { + readonly cause?: unknown; + + constructor(readonly attempts: readonly QwpFailoverAttempt[]) { + const last = attempts[attempts.length - 1]; + super( + `all QWP endpoints failed [count=${attempts.length}]${ + last ? `; last endpoint=${last.endpoint}` : "" + }`, + ); + this.name = "QwpFailoverError"; + this.cause = last?.error; + } +} + +/** A configured QWP reconnect policy exhausted its retry boundary. */ +export class QwpReconnectExhaustedError extends Error { + readonly cause: unknown; + + constructor( + readonly attempts: number, + cause: unknown, + ) { + super(`QWP reconnect attempts exhausted [attempts=${attempts}]`); + this.name = "QwpReconnectExhaustedError"; + this.cause = cause; + } +} + +/** A replayed ingress frame was rejected and remains in persistent storage. */ +export class QwpReplayRejectedError extends Error { + constructor( + readonly frameSequence: bigint, + readonly status: number, + message?: string, + ) { + super( + `QWP replay frame was rejected and retained [frameSequence=${frameSequence}, status=0x${status.toString(16)}]${ + message ? `: ${message}` : "" + }`, + ); + this.name = "QwpReplayRejectedError"; + } +} + +/** An active egress operation cannot be safely replayed without an explicit reset hook. */ +export class QwpEgressReplayRequiredError extends Error { + constructor(readonly requestId?: bigint) { + super( + `QWP egress connection was lost with an operation in flight${ + requestId === undefined ? "" : ` [requestId=${requestId}]` + }; configure onReplayReset to opt into at-least-once re-execution`, + ); + this.name = "QwpEgressReplayRequiredError"; + } +} + +export interface QwpIngressReplayRecord { + readonly frameSequence: bigint; + readonly payload: Uint8Array; +} + +/** Browser-safe abstraction; Node supplies a persistent filesystem implementation. */ +export interface QwpIngressReplayStore { + load(): Promise; + append(record: QwpIngressReplayRecord): Promise; + acknowledgeThrough(frameSequence: bigint): Promise; + close(): Promise; +} + +export const QWP_RECONNECT_EVENT_KIND = { + RECONNECTING: "reconnecting", + ATTEMPT_FAILED: "attempt-failed", + RECONNECTED: "reconnected", + FAILED_OVER: "failed-over", +} as const; + +export type QwpReconnectEventKind = + (typeof QWP_RECONNECT_EVENT_KIND)[keyof typeof QWP_RECONNECT_EVENT_KIND]; + +export interface QwpReconnectEvent { + readonly kind: QwpReconnectEventKind; + /** One-based reconnect sweep number within the current outage. */ + readonly attempt: number; + readonly endpoint?: string | URL; + readonly previousEndpoint?: string | URL; + readonly cause?: unknown; +} + +export interface QwpReconnectOptions { + /** Maximum connection sweeps per outage. Defaults to 3; zero is unlimited. */ + maxAttempts?: number; + /** Backoff before the first failed sweep is retried. Defaults to 100ms. */ + initialBackoffMs?: number; + /** Exponential-backoff ceiling. Defaults to 5s. */ + maxBackoffMs?: number; + /** Total reconnect deadline. Defaults to 30s; zero disables the deadline. */ + maxDurationMs?: number; + /** + * Consecutive retriable rejections of one ingress frame before it is treated + * as poison and retained for inspection. Defaults to 4. + */ + maxFrameRejections?: number; + onEvent?: (event: QwpReconnectEvent) => void; +} + +export interface QwpEgressReplayResetEvent { + readonly requestId?: bigint; + readonly previousEndpoint?: string | URL; + readonly endpoint?: string | URL; + readonly cause?: unknown; +} + export const QWP_UPGRADE_ERROR_KIND = { AUTHENTICATION: "authentication", ROLE_REJECTED: "role-rejected", @@ -143,6 +262,8 @@ export interface QwpBinaryConnection { readonly messages: AsyncIterable; readonly closed: Promise; readonly handshake: QwpHandshakeMetadata; + /** Endpoint backing this connection, when supplied by its adapter. */ + readonly endpoint?: string | URL; send(payload: Uint8Array): Promise; /** Sends an RFC 6455 PING when the underlying runtime supports it. */ @@ -152,6 +273,8 @@ export interface QwpBinaryConnection { export interface QwpWebSocketConnectOptions { url: string | URL; + /** Additional endpoints attempted in order when the preferred endpoint fails. */ + failoverUrls?: readonly (string | URL)[]; protocols?: string | string[]; connectTimeoutMs?: number; /** Maximum time a send may remain queued by the WebSocket. Defaults to 15s. */ diff --git a/test/qwp/node-transport.test.ts b/test/qwp/node-transport.test.ts index d3d2d62..6237ddb 100644 --- a/test/qwp/node-transport.test.ts +++ b/test/qwp/node-transport.test.ts @@ -1,4 +1,7 @@ import type { AddressInfo } from "node:net"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { WebSocketServer } from "ws"; import { afterEach, describe, expect, it } from "vitest"; import { @@ -143,4 +146,75 @@ describe("QWP Node transport", () => { isTransientRoleReject: true, } satisfies Partial); }); + + it("fails over and replays an unacknowledged frame through the public Node API", async () => { + const primary = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + const secondary = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + const directory = await mkdtemp(join(tmpdir(), "qwp-node-failover-")); + const primaryFrames: Uint8Array[] = []; + const secondaryFrames: Uint8Array[] = []; + for (const endpoint of [primary, secondary]) { + endpoint.on("headers", (headers) => { + headers.push("X-QWP-Version: 1"); + }); + } + primary.on("connection", (socket) => { + socket.once("message", (payload) => { + primaryFrames.push(new Uint8Array(payload as Buffer)); + socket.terminate(); + }); + }); + secondary.on("connection", (socket) => { + socket.once("message", (payload) => { + secondaryFrames.push(new Uint8Array(payload as Buffer)); + socket.send(okResponse(0n, "trades", 1n)); + }); + }); + await Promise.all([listen(primary), listen(secondary)]); + + const primaryAddress = primary.address() as AddressInfo; + const secondaryAddress = secondary.address() as AddressInfo; + const session = await connectQwpNodeIngress( + { + url: `ws://127.0.0.1:${primaryAddress.port}/write/v4`, + failoverUrls: [`ws://127.0.0.1:${secondaryAddress.port}/write/v4`], + storeAndForward: { directory }, + }, + { + ackTimeoutMs: 2_000, + reconnect: { + maxAttempts: 1, + initialBackoffMs: 0, + maxBackoffMs: 0, + }, + }, + ); + try { + const response = await session.sendFrame(Uint8Array.of(1, 2, 3)); + expect(response).toMatchObject({ status: QWP_STATUS.OK, sequence: 0n }); + expect(primaryFrames).toEqual([Uint8Array.of(1, 2, 3)]); + expect(secondaryFrames).toEqual([Uint8Array.of(1, 2, 3)]); + } finally { + await session.close(); + await Promise.all([closeServer(primary), closeServer(secondary)]); + await rm(directory, { recursive: true, force: true }); + } + }); }); + +function listen(server: WebSocketServer): Promise { + return new Promise((resolve, reject) => { + if (server.address()) { + resolve(); + return; + } + server.once("listening", resolve); + server.once("error", reject); + }); +} + +function closeServer(server: WebSocketServer): Promise { + return new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); +} diff --git a/test/qwp/reconnect.test.ts b/test/qwp/reconnect.test.ts new file mode 100644 index 0000000..2275d8a --- /dev/null +++ b/test/qwp/reconnect.test.ts @@ -0,0 +1,624 @@ +import { mkdtemp, readdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + connectQwpNodeIngress, + QwpNodeFileReplayStore, + QwpReplayStoreError, + QwpReplayStoreFullError, +} from "../../src/qwp/node"; +import { + QWP_RECONNECT_EVENT_KIND, + QWP_EGRESS_CAPABILITY, + QWP_EGRESS_MESSAGE, + QWP_STATUS, + QWP_UPGRADE_ERROR_KIND, + QwpBinaryConnection, + QwpByteWriter, + QwpConnectionCloseInfo, + QwpEgressReplayRequiredError, + QwpEgressSession, + QwpIngressSession, + QwpHandshakeMetadata, + QwpReconnectEvent, + QwpReconnectExhaustedError, + QwpReplayRejectedError, + QwpUpgradeError, + encodeQwpFrame, + writeQwpVarint, +} from "../../src/qwp"; +import { QwpAsyncQueue } from "../../src/qwp/internal/async-queue"; +import { createQwpFailoverConnectionFactory } from "../../src/qwp/internal/failover"; + +function ingressResponse( + status: number, + sequence: bigint, + tables: readonly [string, bigint][] = [], +): Uint8Array { + const writer = new QwpByteWriter() + .writeUint8(status) + .writeBigUint64(sequence); + if (status === QWP_STATUS.OK) writeIngressTables(writer, tables); + else writer.writeUint16(0); + return writer.toUint8Array(); +} + +function durableResponse(tables: readonly [string, bigint][]): Uint8Array { + const writer = new QwpByteWriter().writeUint8(QWP_STATUS.DURABLE_ACK); + writeIngressTables(writer, tables); + return writer.toUint8Array(); +} + +function writeIngressTables( + writer: QwpByteWriter, + tables: readonly [string, bigint][], +): void { + writer.writeUint16(tables.length); + for (const [name, transaction] of tables) { + const bytes = new TextEncoder().encode(name); + writer + .writeUint16(bytes.length) + .writeBytes(bytes) + .writeBigInt64(transaction); + } +} + +function writeUint16String(writer: QwpByteWriter, value: string): void { + const bytes = new TextEncoder().encode(value); + writer.writeUint16(bytes.length).writeBytes(bytes); +} + +function serverInfo(node: string): Uint8Array { + const payload = new QwpByteWriter() + .writeUint8(QWP_EGRESS_MESSAGE.SERVER_INFO) + .writeUint8(0) + .writeBigUint64(1n) + .writeUint32(QWP_EGRESS_CAPABILITY.QUERY_FLAGS) + .writeBigInt64(123n); + writeUint16String(payload, "cluster"); + writeUint16String(payload, node); + return encodeQwpFrame(payload.toUint8Array()); +} + +function emptyResultBatch(requestId = 0n, batchSequence = 0): Uint8Array { + const payload = new QwpByteWriter() + .writeUint8(QWP_EGRESS_MESSAGE.RESULT_BATCH) + .writeBigUint64(requestId); + writeQwpVarint(payload, batchSequence); + writeQwpVarint(payload, 0); // table name + writeQwpVarint(payload, 0); // row count + if (batchSequence === 0) writeQwpVarint(payload, 0); // column count + return encodeQwpFrame(payload.toUint8Array(), 0, 1); +} + +function resultEnd(requestId = 0n): Uint8Array { + const payload = new QwpByteWriter() + .writeUint8(QWP_EGRESS_MESSAGE.RESULT_END) + .writeBigUint64(requestId); + writeQwpVarint(payload, 1); + writeQwpVarint(payload, 0); + return encodeQwpFrame(payload.toUint8Array()); +} + +class FakeConnection implements QwpBinaryConnection { + readonly messages: AsyncIterable; + readonly sent: Uint8Array[] = []; + readonly closed: Promise; + private readonly incoming = new QwpAsyncQueue(); + private readonly resolveClosed: (info: QwpConnectionCloseInfo) => void; + private closedSettled = false; + + constructor( + readonly endpoint: string, + readonly handshake: QwpHandshakeMetadata = { qwpVersion: 1 }, + ) { + this.messages = this.incoming; + let resolveClosed!: (info: QwpConnectionCloseInfo) => void; + this.closed = new Promise((resolve) => { + resolveClosed = resolve; + }); + this.resolveClosed = resolveClosed; + } + + send(payload: Uint8Array): Promise { + this.sent.push(payload.slice()); + return Promise.resolve(); + } + + close(code = 1000, reason = ""): Promise { + this.finish({ code, reason, wasClean: code === 1000 }); + return Promise.resolve(); + } + + receive(payload: Uint8Array): void { + this.incoming.push(payload); + } + + drop(): void { + this.finish({ code: 1006, reason: "connection lost", wasClean: false }); + } + + private finish(info: QwpConnectionCloseInfo): void { + if (this.closedSettled) return; + this.closedSettled = true; + this.incoming.end(); + this.resolveClosed(info); + } +} + +describe("QWP endpoint failover", () => { + it("walks all endpoints and rotates away from the last successful one", async () => { + const attempts: string[] = []; + let primaryAvailable = false; + const factory = createQwpFailoverConnectionFactory( + "primary", + ["secondary"], + async (endpoint) => { + attempts.push(String(endpoint)); + if (endpoint === "primary" && !primaryAvailable) { + throw new QwpUpgradeError("primary unavailable", { + kind: QWP_UPGRADE_ERROR_KIND.TRANSPORT, + retryable: true, + tryNextEndpoint: true, + }); + } + return new FakeConnection(String(endpoint)); + }, + ); + + await expect(factory()).resolves.toMatchObject({ endpoint: "secondary" }); + primaryAvailable = true; + await expect(factory()).resolves.toMatchObject({ endpoint: "primary" }); + expect(attempts).toEqual(["primary", "secondary", "primary"]); + }); + + it("does not leak invalid credentials to another endpoint", async () => { + const attempts: string[] = []; + const authenticationError = new QwpUpgradeError("unauthorized", { + kind: QWP_UPGRADE_ERROR_KIND.AUTHENTICATION, + retryable: false, + tryNextEndpoint: false, + }); + const factory = createQwpFailoverConnectionFactory( + "primary", + ["secondary"], + async (endpoint) => { + attempts.push(String(endpoint)); + throw authenticationError; + }, + ); + + await expect(factory()).rejects.toBe(authenticationError); + expect(attempts).toEqual(["primary"]); + }); +}); + +describe("QWP ingress reconnect and replay", () => { + it("replays only unacknowledged browser frames and translates wire ACKs", async () => { + const first = new FakeConnection("primary"); + const second = new FakeConnection("secondary"); + const connections = [first, second]; + const events: QwpReconnectEvent[] = []; + const session = await QwpIngressSession.connect( + async () => { + const connection = connections.shift(); + if (!connection) throw new Error("no connection available"); + return connection; + }, + { + ackTimeoutMs: 1_000, + reconnect: { + maxAttempts: 1, + initialBackoffMs: 0, + maxBackoffMs: 0, + onEvent: (event) => events.push(event), + }, + }, + ); + + const acknowledged = session.sendFrame(Uint8Array.of(1)); + await vi.waitFor(() => expect(first.sent).toHaveLength(1)); + first.receive(ingressResponse(QWP_STATUS.OK, 0n)); + await expect(acknowledged).resolves.toMatchObject({ sequence: 0n }); + + const pending = session.sendFrame(Uint8Array.of(2)); + await vi.waitFor(() => expect(first.sent).toHaveLength(2)); + first.drop(); + await vi.waitFor(() => expect(second.sent).toEqual([Uint8Array.of(2)])); + second.receive(ingressResponse(QWP_STATUS.OK, 0n)); + + await expect(pending).resolves.toMatchObject({ sequence: 1n }); + expect(events.map((event) => event.kind)).toEqual([ + QWP_RECONNECT_EVENT_KIND.RECONNECTING, + QWP_RECONNECT_EVENT_KIND.FAILED_OVER, + ]); + await session.close(); + }); + + it("does not double-send a frame queued while replay is connecting", async () => { + const first = new FakeConnection("primary"); + const second = new FakeConnection("secondary"); + let releaseSecond!: () => void; + const secondReady = new Promise((resolve) => { + releaseSecond = resolve; + }); + let factoryCalls = 0; + const session = await QwpIngressSession.connect( + async () => { + if (factoryCalls++ === 0) return first; + await secondReady; + return second; + }, + { + reconnect: { + maxAttempts: 1, + initialBackoffMs: 0, + maxBackoffMs: 0, + }, + }, + ); + + const ambiguous = session.sendFrame(Uint8Array.of(1)); + await vi.waitFor(() => expect(first.sent).toEqual([Uint8Array.of(1)])); + first.drop(); + await vi.waitFor(() => expect(factoryCalls).toBe(2)); + const queued = session.sendFrame(Uint8Array.of(2)); + releaseSecond(); + + await vi.waitFor(() => + expect(second.sent).toEqual([Uint8Array.of(1), Uint8Array.of(2)]), + ); + second.receive(ingressResponse(QWP_STATUS.OK, 1n)); + await expect(Promise.all([ambiguous, queued])).resolves.toEqual([ + expect.objectContaining({ sequence: 1n }), + expect.objectContaining({ sequence: 1n }), + ]); + await session.close(); + }); + + it("fails pending sends with a typed reconnect exhaustion error", async () => { + const first = new FakeConnection("primary"); + let factoryCalls = 0; + const session = await QwpIngressSession.connect( + async () => { + if (factoryCalls++ === 0) return first; + throw new QwpUpgradeError("offline", { + kind: QWP_UPGRADE_ERROR_KIND.TRANSPORT, + retryable: true, + tryNextEndpoint: true, + }); + }, + { + reconnect: { + maxAttempts: 2, + initialBackoffMs: 0, + maxBackoffMs: 0, + }, + }, + ); + const pending = session.sendFrame(Uint8Array.of(1)); + await vi.waitFor(() => expect(first.sent).toHaveLength(1)); + first.drop(); + + await expect(pending).rejects.toBeInstanceOf(QwpReconnectExhaustedError); + expect(factoryCalls).toBe(3); + await session.close(); + }); + + it("reconnects and replays a transient ingress NACK without advancing", async () => { + const first = new FakeConnection("primary"); + const second = new FakeConnection("secondary"); + const connections = [first, second]; + const session = await QwpIngressSession.connect( + async () => { + const connection = connections.shift(); + if (!connection) throw new Error("no connection available"); + return connection; + }, + { + reconnect: { + maxAttempts: 1, + initialBackoffMs: 0, + maxBackoffMs: 0, + }, + }, + ); + const pending = session.sendFrame(Uint8Array.of(9)); + await vi.waitFor(() => expect(first.sent).toHaveLength(1)); + first.receive(ingressResponse(QWP_STATUS.WRITE_ERROR, 0n)); + await vi.waitFor(() => expect(second.sent).toEqual([Uint8Array.of(9)])); + second.receive(ingressResponse(QWP_STATUS.OK, 0n)); + + await expect(pending).resolves.toMatchObject({ + status: QWP_STATUS.OK, + sequence: 0n, + }); + await session.close(); + }); + + it("stops replaying a repeatedly rejected poison frame", async () => { + const first = new FakeConnection("primary"); + const second = new FakeConnection("secondary"); + const connections = [first, second]; + const session = await QwpIngressSession.connect( + async () => { + const connection = connections.shift(); + if (!connection) throw new Error("no connection available"); + return connection; + }, + { + reconnect: { + maxAttempts: 1, + maxFrameRejections: 2, + initialBackoffMs: 0, + maxBackoffMs: 0, + }, + }, + ); + const pending = session.sendFrame(Uint8Array.of(9)); + await vi.waitFor(() => expect(first.sent).toHaveLength(1)); + first.receive(ingressResponse(QWP_STATUS.WRITE_ERROR, 0n)); + await vi.waitFor(() => expect(second.sent).toHaveLength(1)); + second.receive(ingressResponse(QWP_STATUS.WRITE_ERROR, 0n)); + + await expect(pending).rejects.toBeInstanceOf(QwpReplayRejectedError); + expect(connections).toHaveLength(0); + await session.close(); + }); + + it("recovers a Node journal before new frames and removes it after ACK", async () => { + const directory = await createTemporaryDirectory(); + const seed = new QwpNodeFileReplayStore({ directory }); + await seed.load(); + await seed.append({ frameSequence: 5n, payload: Uint8Array.of(5) }); + await seed.close(); + + const connection = new FakeConnection("primary"); + const session = await QwpIngressSession.connect(async () => connection, { + reconnect: { maxAttempts: 1 }, + replayStore: new QwpNodeFileReplayStore({ directory }), + }); + expect(connection.sent).toEqual([Uint8Array.of(5)]); + + connection.receive(ingressResponse(QWP_STATUS.OK, 0n)); + const current = session.sendFrame(Uint8Array.of(6)); + await vi.waitFor(() => expect(connection.sent).toHaveLength(2)); + connection.receive(ingressResponse(QWP_STATUS.OK, 1n)); + await expect(current).resolves.toMatchObject({ sequence: 0n }); + await session.close(); + + const verify = new QwpNodeFileReplayStore({ directory }); + await expect(verify.load()).resolves.toEqual([]); + await verify.close(); + await rm(directory, { recursive: true, force: true }); + }); + + it("retains Node journal records until a negotiated durable ACK", async () => { + const directory = await createTemporaryDirectory(); + const connection = new FakeConnection("primary", { + qwpVersion: 1, + durableAckEnabled: true, + }); + const session = await QwpIngressSession.connect(async () => connection, { + reconnect: { maxAttempts: 1 }, + replayStore: new QwpNodeFileReplayStore({ directory }), + durableAckKeepaliveMs: 0, + }); + const pending = session.sendFrame(Uint8Array.of(7)); + await vi.waitFor(() => expect(connection.sent).toHaveLength(1)); + connection.receive(ingressResponse(QWP_STATUS.OK, 0n, [["trades", 42n]])); + await expect(pending).resolves.toMatchObject({ sequence: 0n }); + expect( + (await readdir(directory)).filter((name) => name.endsWith(".qwp")), + ).toHaveLength(1); + + connection.receive(durableResponse([["trades", 42n]])); + await vi.waitFor(async () => + expect( + (await readdir(directory)).filter((name) => name.endsWith(".qwp")), + ).toEqual([]), + ); + await session.close(); + await rm(directory, { recursive: true, force: true }); + }); +}); + +describe("QWP egress reconnect and replay", () => { + it("retries the initial connection until one provides SERVER_INFO", async () => { + const first = new FakeConnection("primary"); + const second = new FakeConnection("secondary"); + const connections = [first, second]; + const connecting = QwpEgressSession.connect( + async () => { + const connection = connections.shift(); + if (!connection) throw new Error("no connection available"); + queueMicrotask(() => { + if (connection === first) connection.drop(); + else connection.receive(serverInfo("two")); + }); + return connection; + }, + { + serverInfoTimeoutMs: 100, + reconnect: { + maxAttempts: 2, + initialBackoffMs: 0, + maxBackoffMs: 0, + }, + }, + ); + + await expect(connecting).resolves.toMatchObject({ + handshake: { qwpVersion: 1 }, + }); + const session = await connecting; + await session.close(); + }); + + it("discards queued batches, invokes reset, and replays an opted-in query", async () => { + const first = new FakeConnection("primary"); + const second = new FakeConnection("secondary"); + const connections = [first, second]; + const resets: bigint[] = []; + const session = await QwpEgressSession.connect( + async () => { + const connection = connections.shift(); + if (!connection) throw new Error("no connection available"); + queueMicrotask(() => + connection.receive( + serverInfo(connection.endpoint === "primary" ? "one" : "two"), + ), + ); + return connection; + }, + { + reconnect: { + maxAttempts: 1, + initialBackoffMs: 0, + maxBackoffMs: 0, + }, + onReplayReset: (event) => resets.push(event.requestId!), + }, + ); + const query = await session.query("select * from x"); + expect(first.sent).toHaveLength(1); + + // Leave this batch queued; reconnect must discard it before replay. + first.receive(emptyResultBatch()); + const iterator = query[Symbol.asyncIterator](); + await expect(iterator.next()).resolves.toMatchObject({ done: false }); + // Queue another stale prefix batch to exercise queue clearing. + first.receive(emptyResultBatch(0n, 1)); + first.drop(); + + await vi.waitFor(() => expect(resets).toEqual([0n])); + await vi.waitFor(() => expect(second.sent).toEqual(first.sent)); + second.receive(emptyResultBatch()); + second.receive(resultEnd()); + + await expect(iterator.next()).resolves.toMatchObject({ done: false }); + await expect(iterator.next()).resolves.toEqual({ + value: undefined, + done: true, + }); + await expect(query.completion).resolves.toMatchObject({ + kind: "result-end", + }); + await session.close(); + }); + + it("fails rather than silently replaying an active operation without reset", async () => { + const first = new FakeConnection("primary"); + const second = new FakeConnection("secondary"); + const connections = [first, second]; + const session = await QwpEgressSession.connect( + async () => { + const connection = connections.shift(); + if (!connection) throw new Error("no connection available"); + queueMicrotask(() => + connection.receive(serverInfo(connection.endpoint)), + ); + return connection; + }, + { + reconnect: { + maxAttempts: 1, + initialBackoffMs: 0, + maxBackoffMs: 0, + }, + }, + ); + const query = await session.query("update x set n = n + 1"); + first.drop(); + + await expect(query.completion).rejects.toBeInstanceOf( + QwpEgressReplayRequiredError, + ); + await session.close(); + }); +}); + +describe("QWP Node file replay store", () => { + const directories: string[] = []; + + afterEach(async () => { + await Promise.all( + directories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })), + ); + }); + + async function trackedDirectory(): Promise { + const directory = await createTemporaryDirectory(); + directories.push(directory); + return directory; + } + + it("survives restart and deletes only the acknowledged prefix", async () => { + const directory = await trackedDirectory(); + const first = new QwpNodeFileReplayStore({ directory }); + await expect(first.load()).resolves.toEqual([]); + await first.append({ frameSequence: 0n, payload: Uint8Array.of(1, 2) }); + await first.append({ frameSequence: 1n, payload: Uint8Array.of(3, 4) }); + await first.close(); + + const second = new QwpNodeFileReplayStore({ directory }); + await expect(second.load()).resolves.toEqual([ + { frameSequence: 0n, payload: Uint8Array.of(1, 2) }, + { frameSequence: 1n, payload: Uint8Array.of(3, 4) }, + ]); + await second.acknowledgeThrough(0n); + await second.close(); + + const third = new QwpNodeFileReplayStore({ directory }); + await expect(third.load()).resolves.toEqual([ + { frameSequence: 1n, payload: Uint8Array.of(3, 4) }, + ]); + await third.close(); + }); + + it("enforces its configured disk budget before writing", async () => { + const directory = await trackedDirectory(); + const store = new QwpNodeFileReplayStore({ + directory, + maxBytes: 54, + }); + await store.load(); + await expect( + store.append({ frameSequence: 0n, payload: Uint8Array.of(1, 2, 3) }), + ).rejects.toBeInstanceOf(QwpReplayStoreFullError); + expect(await readdir(directory)).toEqual([]); + await store.close(); + }); + + it("fails closed when a persisted record is corrupt", async () => { + const directory = await trackedDirectory(); + const first = new QwpNodeFileReplayStore({ directory }); + await first.load(); + await first.append({ frameSequence: 0n, payload: Uint8Array.of(1) }); + await first.close(); + const [record] = (await readdir(directory)).filter((name) => + name.endsWith(".qwp"), + ); + await writeFile(join(directory, record), Uint8Array.of(0)); + + const recovered = new QwpNodeFileReplayStore({ directory }); + await expect(recovered.load()).rejects.toBeInstanceOf(QwpReplayStoreError); + await recovered.close(); + }); + + it("requires persistence when Node ingress reconnection is enabled", async () => { + await expect( + connectQwpNodeIngress( + { url: "ws://127.0.0.1:1/write/v4" }, + { reconnect: { maxAttempts: 1 } }, + ), + ).rejects.toThrow(/persistent storeAndForward directory/); + }); +}); + +async function createTemporaryDirectory(): Promise { + return mkdtemp(join(tmpdir(), "qwp-replay-")); +} From c98135a7cc824c95a4108c0036c7c7a46a0f315a Mon Sep 17 00:00:00 2001 From: glasstiger Date: Mon, 17 Aug 2026 21:04:41 +0100 Subject: [PATCH 013/265] feat(qwp): add typed query binds --- src/qwp/core/binds.ts | 482 ++++++++++++++++++++++++++++++++++++++ src/qwp/core/egress.ts | 38 ++- src/qwp/core/index.ts | 1 + src/qwp/egress-session.ts | 7 +- test/qwp/binds.test.ts | 237 +++++++++++++++++++ test/qwp/browser.e2e.ts | 83 ++++++- 6 files changed, 839 insertions(+), 9 deletions(-) create mode 100644 src/qwp/core/binds.ts create mode 100644 test/qwp/binds.test.ts diff --git a/src/qwp/core/binds.ts b/src/qwp/core/binds.ts new file mode 100644 index 0000000..fbaeb7b --- /dev/null +++ b/src/qwp/core/binds.ts @@ -0,0 +1,482 @@ +import { encodeUtf8, QwpByteWriter } from "./bytes"; +import { QWP_COLUMN_TYPE, QWP_MAX_COLUMNS_PER_TABLE } from "./constants"; +import { writeQwpVarint } from "./varint"; + +const INT64_MIN = -(1n << 63n); +const INT64_MAX = (1n << 63n) - 1n; +const UINT64_MAX = (1n << 64n) - 1n; +const DECIMAL64_MAX_SCALE = 18; +const DECIMAL128_MAX_SCALE = 38; +const DECIMAL256_MAX_SCALE = 76; +const GEOHASH_MIN_BITS = 1; +const GEOHASH_MAX_BITS = 60; +const NULL_FLAG = 0x01; +const NULL_BITMAP = 0x01; +const NON_NULL_FLAG = 0x00; + +export type QwpInt64 = number | bigint; + +/** Phase-1 scalar bind types exposed by the Java reference client. */ +export type QwpBindType = + | typeof QWP_COLUMN_TYPE.BOOLEAN + | typeof QWP_COLUMN_TYPE.BYTE + | typeof QWP_COLUMN_TYPE.SHORT + | typeof QWP_COLUMN_TYPE.INT + | typeof QWP_COLUMN_TYPE.LONG + | typeof QWP_COLUMN_TYPE.FLOAT + | typeof QWP_COLUMN_TYPE.DOUBLE + | typeof QWP_COLUMN_TYPE.TIMESTAMP + | typeof QWP_COLUMN_TYPE.DATE + | typeof QWP_COLUMN_TYPE.UUID + | typeof QWP_COLUMN_TYPE.LONG256 + | typeof QWP_COLUMN_TYPE.GEOHASH + | typeof QWP_COLUMN_TYPE.VARCHAR + | typeof QWP_COLUMN_TYPE.TIMESTAMP_NANOS + | typeof QWP_COLUMN_TYPE.DECIMAL64 + | typeof QWP_COLUMN_TYPE.DECIMAL128 + | typeof QWP_COLUMN_TYPE.DECIMAL256 + | typeof QWP_COLUMN_TYPE.CHAR; + +export type QwpBindSetter = (binds: QwpBindValues) => void; + +export interface QwpEncodedBinds { + count: number; + payload: Uint8Array; +} + +function checkedIndex(value: number): number { + if (!Number.isSafeInteger(value) || value < 0) { + throw new RangeError("bind index must be a non-negative safe integer"); + } + return value; +} + +function checkedInteger( + value: number, + minimum: number, + maximum: number, + label: string, +): number { + if (!Number.isInteger(value) || value < minimum || value > maximum) { + throw new RangeError( + `${label} must be an integer between ${minimum} and ${maximum}`, + ); + } + return value; +} + +function checkedInt64(value: QwpInt64, label: string): bigint { + let integer: bigint; + if (typeof value === "number") { + if (!Number.isSafeInteger(value)) { + throw new RangeError(`${label} must be a safe integer or bigint`); + } + integer = BigInt(value); + } else if (typeof value === "bigint") { + integer = value; + } else { + throw new TypeError(`${label} must be a safe integer or bigint`); + } + if (integer < INT64_MIN || integer > INT64_MAX) { + throw new RangeError(`${label} must fit in int64`); + } + return integer; +} + +function checkedUint64Bits(value: QwpInt64, label: string): bigint { + let integer: bigint; + if (typeof value === "number") { + if (!Number.isSafeInteger(value)) { + throw new RangeError(`${label} must be a safe integer or bigint`); + } + integer = BigInt(value); + } else if (typeof value === "bigint") { + integer = value; + } else { + throw new TypeError(`${label} must be a safe integer or bigint`); + } + if (integer < INT64_MIN || integer > UINT64_MAX) { + throw new RangeError(`${label} must fit in 64 bits`); + } + return BigInt.asUintN(64, integer); +} + +function checkedScale(value: number, maximum: number, label: string): number { + return checkedInteger(value, 0, maximum, `${label} scale`); +} + +/** + * Browser-safe typed positional bind encoder. + * + * Setters must be called in ascending zero-based index order. SQL placeholders + * are one-based, so index 0 binds `$1`, index 1 binds `$2`, and so on. + */ +export class QwpBindValues { + private writer = new QwpByteWriter(); + private expectedIndex = 0; + + get count(): number { + return this.expectedIndex; + } + + reset(): this { + this.writer = new QwpByteWriter(); + this.expectedIndex = 0; + return this; + } + + setBoolean(index: number, value: boolean): this { + if (typeof value !== "boolean") { + throw new TypeError("BOOLEAN bind value must be a boolean"); + } + this.advance(index); + this.writeHeader(QWP_COLUMN_TYPE.BOOLEAN, false); + this.writer.writeUint8(value ? 1 : 0); + return this; + } + + setByte(index: number, value: number): this { + const checked = checkedInteger(value, -0x80, 0x7f, "BYTE bind"); + this.advance(index); + this.writeHeader(QWP_COLUMN_TYPE.BYTE, false); + this.writer.writeInt8(checked); + return this; + } + + setShort(index: number, value: number): this { + const checked = checkedInteger(value, -0x8000, 0x7fff, "SHORT bind"); + this.advance(index); + this.writeHeader(QWP_COLUMN_TYPE.SHORT, false); + this.writer.writeInt16(checked); + return this; + } + + setChar(index: number, value: string): this { + if (typeof value !== "string" || value.length !== 1) { + throw new TypeError("CHAR bind value must be one UTF-16 code unit"); + } + this.advance(index); + this.writeHeader(QWP_COLUMN_TYPE.CHAR, false); + this.writer.writeUint16(value.charCodeAt(0)); + return this; + } + + setInt(index: number, value: number): this { + const checked = checkedInteger(value, -0x80000000, 0x7fffffff, "INT bind"); + this.advance(index); + this.writeHeader(QWP_COLUMN_TYPE.INT, false); + this.writer.writeInt32(checked); + return this; + } + + setLong(index: number, value: QwpInt64): this { + const checked = checkedInt64(value, "LONG bind"); + this.advance(index); + this.writeHeader(QWP_COLUMN_TYPE.LONG, false); + this.writer.writeBigInt64(checked); + return this; + } + + setFloat(index: number, value: number): this { + if (typeof value !== "number") { + throw new TypeError("FLOAT bind value must be a number"); + } + this.advance(index); + this.writeHeader(QWP_COLUMN_TYPE.FLOAT, false); + this.writer.writeFloat32(value); + return this; + } + + setDouble(index: number, value: number): this { + if (typeof value !== "number") { + throw new TypeError("DOUBLE bind value must be a number"); + } + this.advance(index); + this.writeHeader(QWP_COLUMN_TYPE.DOUBLE, false); + this.writer.writeFloat64(value); + return this; + } + + /** Binds a DATE expressed as milliseconds since the Unix epoch. */ + setDate(index: number, millisecondsSinceEpoch: QwpInt64): this { + const checked = checkedInt64(millisecondsSinceEpoch, "DATE bind"); + this.advance(index); + this.writeHeader(QWP_COLUMN_TYPE.DATE, false); + this.writer.writeBigInt64(checked); + return this; + } + + /** Binds a TIMESTAMP expressed as microseconds since the Unix epoch. */ + setTimestampMicros(index: number, microsecondsSinceEpoch: QwpInt64): this { + const checked = checkedInt64(microsecondsSinceEpoch, "TIMESTAMP bind"); + this.advance(index); + this.writeHeader(QWP_COLUMN_TYPE.TIMESTAMP, false); + this.writer.writeBigInt64(checked); + return this; + } + + /** Binds a TIMESTAMP_NS expressed as nanoseconds since the Unix epoch. */ + setTimestampNanos(index: number, nanosecondsSinceEpoch: QwpInt64): this { + const checked = checkedInt64(nanosecondsSinceEpoch, "TIMESTAMP_NANOS bind"); + this.advance(index); + this.writeHeader(QWP_COLUMN_TYPE.TIMESTAMP_NANOS, false); + this.writer.writeBigInt64(checked); + return this; + } + + setVarchar(index: number, value: string | null): this { + if (value === null) return this.setNull(index, QWP_COLUMN_TYPE.VARCHAR); + if (typeof value !== "string") { + throw new TypeError("VARCHAR bind value must be a string or null"); + } + const utf8 = encodeUtf8(value); + if (utf8.length > 0x7fffffff) { + throw new RangeError("VARCHAR bind exceeds the int32 wire length limit"); + } + this.advance(index); + this.writeHeader(QWP_COLUMN_TYPE.VARCHAR, false); + this.writer.writeUint32(0).writeUint32(utf8.length).writeBytes(utf8); + return this; + } + + setUuid(index: number, value: string | null): this; + setUuid(index: number, low: QwpInt64, high: QwpInt64): this; + setUuid( + index: number, + valueOrLow: string | null | QwpInt64, + high?: QwpInt64, + ): this { + if (valueOrLow === null) return this.setNull(index, QWP_COLUMN_TYPE.UUID); + let lowBits: bigint; + let highBits: bigint; + if (typeof valueOrLow === "string") { + const match = + /^([0-9a-f]{8})-([0-9a-f]{4})-([0-9a-f]{4})-([0-9a-f]{4})-([0-9a-f]{12})$/i.exec( + valueOrLow, + ); + if (!match) { + throw new TypeError("UUID bind value must use canonical UUID syntax"); + } + const hex = match.slice(1).join(""); + highBits = BigInt(`0x${hex.slice(0, 16)}`); + lowBits = BigInt(`0x${hex.slice(16)}`); + } else { + if (high === undefined) { + throw new TypeError("UUID limb form requires both low and high limbs"); + } + lowBits = checkedUint64Bits(valueOrLow, "UUID low limb"); + highBits = checkedUint64Bits(high, "UUID high limb"); + } + this.advance(index); + this.writeHeader(QWP_COLUMN_TYPE.UUID, false); + this.writer.writeBigUint64(lowBits).writeBigUint64(highBits); + return this; + } + + setLong256( + index: number, + word0: QwpInt64, + word1: QwpInt64, + word2: QwpInt64, + word3: QwpInt64, + ): this { + const words = [word0, word1, word2, word3].map((word, wordIndex) => + checkedInt64(word, `LONG256 word ${wordIndex}`), + ); + this.advance(index); + this.writeHeader(QWP_COLUMN_TYPE.LONG256, false); + for (const word of words) this.writer.writeBigInt64(word); + return this; + } + + setGeohash(index: number, precisionBits: number, value: QwpInt64): this { + const precision = checkedInteger( + precisionBits, + GEOHASH_MIN_BITS, + GEOHASH_MAX_BITS, + "GEOHASH precision", + ); + const mask = (1n << BigInt(precision)) - 1n; + let bits = checkedInt64(value, "GEOHASH bind") & mask; + this.advance(index); + this.writeHeader(QWP_COLUMN_TYPE.GEOHASH, false); + writeQwpVarint(this.writer, precision); + const byteCount = Math.ceil(precision / 8); + for (let byteIndex = 0; byteIndex < byteCount; byteIndex++) { + this.writer.writeUint8(Number(bits & 0xffn)); + bits >>= 8n; + } + return this; + } + + setDecimal64(index: number, scale: number, unscaled: QwpInt64): this { + const checked = checkedScale(scale, DECIMAL64_MAX_SCALE, "DECIMAL64"); + const value = checkedInt64(unscaled, "DECIMAL64 unscaled value"); + this.advance(index); + this.writeHeader(QWP_COLUMN_TYPE.DECIMAL64, false); + this.writer.writeUint8(checked).writeBigInt64(value); + return this; + } + + setDecimal128( + index: number, + scale: number, + low: QwpInt64, + high: QwpInt64, + ): this { + const checked = checkedScale(scale, DECIMAL128_MAX_SCALE, "DECIMAL128"); + const lowBits = checkedInt64(low, "DECIMAL128 low limb"); + const highBits = checkedInt64(high, "DECIMAL128 high limb"); + this.advance(index); + this.writeHeader(QWP_COLUMN_TYPE.DECIMAL128, false); + this.writer + .writeUint8(checked) + .writeBigInt64(lowBits) + .writeBigInt64(highBits); + return this; + } + + setDecimal256( + index: number, + scale: number, + lowLow: QwpInt64, + lowHigh: QwpInt64, + highLow: QwpInt64, + highHigh: QwpInt64, + ): this { + const checked = checkedScale(scale, DECIMAL256_MAX_SCALE, "DECIMAL256"); + const limbs = [lowLow, lowHigh, highLow, highHigh].map((limb, limbIndex) => + checkedInt64(limb, `DECIMAL256 limb ${limbIndex}`), + ); + this.advance(index); + this.writeHeader(QWP_COLUMN_TYPE.DECIMAL256, false); + this.writer.writeUint8(checked); + for (const limb of limbs) this.writer.writeBigInt64(limb); + return this; + } + + setNull(index: number, type: QwpBindType): this { + this.assertBindType(type); + switch (type) { + case QWP_COLUMN_TYPE.DECIMAL64: + return this.setNullDecimal64(index, 0); + case QWP_COLUMN_TYPE.DECIMAL128: + return this.setNullDecimal128(index, 0); + case QWP_COLUMN_TYPE.DECIMAL256: + return this.setNullDecimal256(index, 0); + case QWP_COLUMN_TYPE.GEOHASH: + return this.setNullGeohash(index, GEOHASH_MIN_BITS); + default: + this.advance(index); + this.writeHeader(type, true); + return this; + } + } + + setNullDecimal64(index: number, scale: number): this { + const checked = checkedScale(scale, DECIMAL64_MAX_SCALE, "DECIMAL64"); + this.advance(index); + this.writeHeader(QWP_COLUMN_TYPE.DECIMAL64, true); + this.writer.writeUint8(checked); + return this; + } + + setNullDecimal128(index: number, scale: number): this { + const checked = checkedScale(scale, DECIMAL128_MAX_SCALE, "DECIMAL128"); + this.advance(index); + this.writeHeader(QWP_COLUMN_TYPE.DECIMAL128, true); + this.writer.writeUint8(checked); + return this; + } + + setNullDecimal256(index: number, scale: number): this { + const checked = checkedScale(scale, DECIMAL256_MAX_SCALE, "DECIMAL256"); + this.advance(index); + this.writeHeader(QWP_COLUMN_TYPE.DECIMAL256, true); + this.writer.writeUint8(checked); + return this; + } + + setNullGeohash(index: number, precisionBits: number): this { + const precision = checkedInteger( + precisionBits, + GEOHASH_MIN_BITS, + GEOHASH_MAX_BITS, + "GEOHASH precision", + ); + this.advance(index); + this.writeHeader(QWP_COLUMN_TYPE.GEOHASH, true); + writeQwpVarint(this.writer, precision); + return this; + } + + toUint8Array(): Uint8Array { + return this.writer.toUint8Array(); + } + + private advance(index: number): void { + const checked = checkedIndex(index); + if (checked !== this.expectedIndex) { + throw new Error( + `bind index out of order: expected ${this.expectedIndex}, got ${checked}`, + ); + } + if (this.expectedIndex >= QWP_MAX_COLUMNS_PER_TABLE) { + throw new RangeError( + `too many binds: exceeds ${QWP_MAX_COLUMNS_PER_TABLE}`, + ); + } + this.expectedIndex++; + } + + private assertBindType(type: number): asserts type is QwpBindType { + switch (type) { + case QWP_COLUMN_TYPE.BOOLEAN: + case QWP_COLUMN_TYPE.BYTE: + case QWP_COLUMN_TYPE.SHORT: + case QWP_COLUMN_TYPE.CHAR: + case QWP_COLUMN_TYPE.INT: + case QWP_COLUMN_TYPE.LONG: + case QWP_COLUMN_TYPE.FLOAT: + case QWP_COLUMN_TYPE.DOUBLE: + case QWP_COLUMN_TYPE.DATE: + case QWP_COLUMN_TYPE.TIMESTAMP: + case QWP_COLUMN_TYPE.TIMESTAMP_NANOS: + case QWP_COLUMN_TYPE.UUID: + case QWP_COLUMN_TYPE.LONG256: + case QWP_COLUMN_TYPE.GEOHASH: + case QWP_COLUMN_TYPE.VARCHAR: + case QWP_COLUMN_TYPE.DECIMAL64: + case QWP_COLUMN_TYPE.DECIMAL128: + case QWP_COLUMN_TYPE.DECIMAL256: + return; + default: + throw new RangeError( + `unsupported QWP bind type 0x${type.toString(16)}`, + ); + } + } + + private writeHeader(type: QwpBindType, isNull: boolean): void { + this.writer.writeUint8(type).writeUint8(isNull ? NULL_FLAG : NON_NULL_FLAG); + if (isNull) this.writer.writeUint8(NULL_BITMAP); + } +} + +/** Runs a setter callback and returns the exact QUERY_REQUEST bind section. */ +export function encodeQwpBinds(setter: QwpBindSetter): QwpEncodedBinds { + if (typeof setter !== "function") { + throw new TypeError("binds must be a function"); + } + const values = new QwpBindValues(); + const result = setter(values) as unknown; + if ( + result !== null && + (typeof result === "object" || typeof result === "function") && + "then" in result && + typeof result.then === "function" + ) { + throw new TypeError("binds callback must be synchronous"); + } + return { count: values.count, payload: values.toUint8Array() }; +} diff --git a/src/qwp/core/egress.ts b/src/qwp/core/egress.ts index 3d1ccea..d1b8ae6 100644 --- a/src/qwp/core/egress.ts +++ b/src/qwp/core/egress.ts @@ -1,5 +1,10 @@ import { encodeUtf8, QwpByteReader, QwpByteWriter } from "./bytes"; -import { QWP_EGRESS_CAPABILITY, QWP_EGRESS_MESSAGE } from "./constants"; +import { encodeQwpBinds, QwpBindSetter } from "./binds"; +import { + QWP_EGRESS_CAPABILITY, + QWP_EGRESS_MESSAGE, + QWP_MAX_COLUMNS_PER_TABLE, +} from "./constants"; import { decodeQwpFrame, QwpFrameHeader } from "./frame"; import { QwpProtocolError } from "./errors"; import { readQwpVarint, writeQwpVarint } from "./varint"; @@ -9,8 +14,11 @@ export interface QwpQueryRequest { sql: string; /** Zero means unbounded. */ initialCredit?: number | bigint; + /** Browser-safe typed positional binds. */ + binds?: QwpBindSetter; + /** Advanced escape hatch for an already encoded bind section. */ bindCount?: number; - /** Pre-encoded positional bind payload. */ + /** Advanced escape hatch for an already encoded bind section. */ bindPayload?: Uint8Array; /** Append only after SERVER_INFO advertises QUERY_FLAGS. */ queryFlags?: number | bigint; @@ -84,11 +92,29 @@ function requestId(value: number | bigint): bigint { /** Encodes the unframed client-to-server QUERY_REQUEST payload. */ export function encodeQwpQueryRequest(request: QwpQueryRequest): Uint8Array { - const bindCount = request.bindCount ?? 0; - if (!Number.isSafeInteger(bindCount) || bindCount < 0) { - throw new RangeError("bindCount must be a non-negative safe integer"); + if ( + request.binds !== undefined && + (request.bindCount !== undefined || request.bindPayload !== undefined) + ) { + throw new Error( + "typed binds cannot be mixed with raw bindCount/bindPayload", + ); } - const bindPayload = request.bindPayload ?? new Uint8Array(); + const encodedBinds = request.binds + ? encodeQwpBinds(request.binds) + : undefined; + const bindCount = encodedBinds?.count ?? request.bindCount ?? 0; + if ( + !Number.isSafeInteger(bindCount) || + bindCount < 0 || + bindCount > QWP_MAX_COLUMNS_PER_TABLE + ) { + throw new RangeError( + `bindCount must be an integer between 0 and ${QWP_MAX_COLUMNS_PER_TABLE}`, + ); + } + const bindPayload = + encodedBinds?.payload ?? request.bindPayload ?? new Uint8Array(); if (bindCount === 0 && bindPayload.length !== 0) { throw new Error("bindPayload requires a non-zero bindCount"); } diff --git a/src/qwp/core/index.ts b/src/qwp/core/index.ts index 7e983b2..801a3bc 100644 --- a/src/qwp/core/index.ts +++ b/src/qwp/core/index.ts @@ -1,4 +1,5 @@ export * from "./bytes"; +export * from "./binds"; export * from "./constants"; export * from "./egress"; export * from "./errors"; diff --git a/src/qwp/egress-session.ts b/src/qwp/egress-session.ts index 3058303..bf05309 100644 --- a/src/qwp/egress-session.ts +++ b/src/qwp/egress-session.ts @@ -6,6 +6,7 @@ import { QWP_EGRESS_CAPABILITY, QWP_QUERY_FLAG_RESET_DICTIONARY, QWP_RESET_MASK_DICTIONARY, + QwpBindSetter, QwpExecDoneMessage, QwpProtocolError, QwpQueryRequest, @@ -40,8 +41,11 @@ export interface QwpEgressSessionOptions { export interface QwpEgressQueryOptions { /** Zero means the server may stream without credit accounting. */ initialCredit?: number | bigint; + /** Sets typed positional parameters; index 0 maps to SQL placeholder `$1`. */ + binds?: QwpBindSetter; + /** Advanced escape hatch for an already encoded bind section. */ bindCount?: number; - /** Pre-encoded positional bind payload. */ + /** Advanced escape hatch for an already encoded bind section. */ bindPayload?: Uint8Array; /** Ask a capable server to reset its connection-scoped symbol dictionary. */ resetDictionary?: boolean; @@ -256,6 +260,7 @@ export class QwpEgressSession implements QwpEgressQueryControl { requestId, sql, initialCredit: options.initialCredit, + binds: options.binds, bindCount: options.bindCount, bindPayload: options.bindPayload, queryFlags: options.resetDictionary diff --git a/test/qwp/binds.test.ts b/test/qwp/binds.test.ts new file mode 100644 index 0000000..44774c3 --- /dev/null +++ b/test/qwp/binds.test.ts @@ -0,0 +1,237 @@ +import { describe, expect, it } from "vitest"; +import { + encodeQwpBinds, + encodeQwpQueryRequest, + QWP_COLUMN_TYPE, + QWP_EGRESS_MESSAGE, + QWP_MAX_COLUMNS_PER_TABLE, + QwpBindValues, + QwpByteReader, + readQwpVarint, +} from "../../src/qwp"; + +function expectNonNullHeader(reader: QwpByteReader, type: number): void { + expect(reader.readUint8()).toBe(type); + expect(reader.readUint8()).toBe(0); +} + +function expectNullHeader(reader: QwpByteReader, type: number): void { + expect(reader.readUint8()).toBe(type); + expect(reader.readUint8()).toBe(1); + expect(reader.readUint8()).toBe(1); +} + +describe("QWP typed query binds", () => { + it("encodes every supported non-null scalar in positional order", () => { + const encoded = encodeQwpBinds((binds) => + binds + .setBoolean(0, true) + .setByte(1, -128) + .setShort(2, -1234) + .setChar(3, "Q") + .setInt(4, -2_000_000) + .setLong(5, 9_000_000_000n) + .setFloat(6, 3.25) + .setDouble(7, -2.5) + .setDate(8, 1_700_000_000_000n) + .setTimestampMicros(9, 1_700_000_000_000_000n) + .setTimestampNanos(10, 1_700_000_000_123_456_789n) + .setVarchar(11, "café") + .setUuid(12, "123e4567-e89b-12d3-a456-426614174000") + .setLong256(13, 1n, 2n, 3n, 4n) + .setGeohash(14, 5, 0xffn) + .setDecimal64(15, 4, 123_456_789n) + .setDecimal128(16, 6, 123_456_789_123_456n, 0n) + .setDecimal256(17, 10, 420_000_000_000n, 0n, 0n, 0n), + ); + + expect(encoded.count).toBe(18); + const reader = new QwpByteReader(encoded.payload); + + expectNonNullHeader(reader, QWP_COLUMN_TYPE.BOOLEAN); + expect(reader.readUint8()).toBe(1); + expectNonNullHeader(reader, QWP_COLUMN_TYPE.BYTE); + expect(reader.readInt8()).toBe(-128); + expectNonNullHeader(reader, QWP_COLUMN_TYPE.SHORT); + expect(reader.readInt16()).toBe(-1234); + expectNonNullHeader(reader, QWP_COLUMN_TYPE.CHAR); + expect(reader.readUint16()).toBe("Q".charCodeAt(0)); + expectNonNullHeader(reader, QWP_COLUMN_TYPE.INT); + expect(reader.readInt32()).toBe(-2_000_000); + expectNonNullHeader(reader, QWP_COLUMN_TYPE.LONG); + expect(reader.readBigInt64()).toBe(9_000_000_000n); + expectNonNullHeader(reader, QWP_COLUMN_TYPE.FLOAT); + expect(reader.readFloat32()).toBe(3.25); + expectNonNullHeader(reader, QWP_COLUMN_TYPE.DOUBLE); + expect(reader.readFloat64()).toBe(-2.5); + expectNonNullHeader(reader, QWP_COLUMN_TYPE.DATE); + expect(reader.readBigInt64()).toBe(1_700_000_000_000n); + expectNonNullHeader(reader, QWP_COLUMN_TYPE.TIMESTAMP); + expect(reader.readBigInt64()).toBe(1_700_000_000_000_000n); + expectNonNullHeader(reader, QWP_COLUMN_TYPE.TIMESTAMP_NANOS); + expect(reader.readBigInt64()).toBe(1_700_000_000_123_456_789n); + + expectNonNullHeader(reader, QWP_COLUMN_TYPE.VARCHAR); + expect(reader.readUint32()).toBe(0); + const varcharLength = reader.readUint32(); + expect(varcharLength).toBe(5); + expect(reader.readUtf8(varcharLength)).toBe("café"); + + expectNonNullHeader(reader, QWP_COLUMN_TYPE.UUID); + expect(reader.readBigUint64()).toBe(0xa456426614174000n); + expect(reader.readBigUint64()).toBe(0x123e4567e89b12d3n); + + expectNonNullHeader(reader, QWP_COLUMN_TYPE.LONG256); + expect([ + reader.readBigInt64(), + reader.readBigInt64(), + reader.readBigInt64(), + reader.readBigInt64(), + ]).toEqual([1n, 2n, 3n, 4n]); + + expectNonNullHeader(reader, QWP_COLUMN_TYPE.GEOHASH); + expect(readQwpVarint(reader)).toBe(5n); + expect(reader.readUint8()).toBe(0x1f); + + expectNonNullHeader(reader, QWP_COLUMN_TYPE.DECIMAL64); + expect(reader.readUint8()).toBe(4); + expect(reader.readBigInt64()).toBe(123_456_789n); + expectNonNullHeader(reader, QWP_COLUMN_TYPE.DECIMAL128); + expect(reader.readUint8()).toBe(6); + expect(reader.readBigInt64()).toBe(123_456_789_123_456n); + expect(reader.readBigInt64()).toBe(0n); + expectNonNullHeader(reader, QWP_COLUMN_TYPE.DECIMAL256); + expect(reader.readUint8()).toBe(10); + expect([ + reader.readBigInt64(), + reader.readBigInt64(), + reader.readBigInt64(), + reader.readBigInt64(), + ]).toEqual([420_000_000_000n, 0n, 0n, 0n]); + reader.expectEnd(); + }); + + it("preserves explicit null types and decimal/geohash metadata", () => { + const encoded = encodeQwpBinds((binds) => + binds + .setNull(0, QWP_COLUMN_TYPE.BOOLEAN) + .setVarchar(1, null) + .setUuid(2, null) + .setNullDecimal64(3, 4) + .setNullDecimal128(4, 18) + .setNullDecimal256(5, 76) + .setNullGeohash(6, 60), + ); + const reader = new QwpByteReader(encoded.payload); + + expectNullHeader(reader, QWP_COLUMN_TYPE.BOOLEAN); + expectNullHeader(reader, QWP_COLUMN_TYPE.VARCHAR); + expectNullHeader(reader, QWP_COLUMN_TYPE.UUID); + expectNullHeader(reader, QWP_COLUMN_TYPE.DECIMAL64); + expect(reader.readUint8()).toBe(4); + expectNullHeader(reader, QWP_COLUMN_TYPE.DECIMAL128); + expect(reader.readUint8()).toBe(18); + expectNullHeader(reader, QWP_COLUMN_TYPE.DECIMAL256); + expect(reader.readUint8()).toBe(76); + expectNullHeader(reader, QWP_COLUMN_TYPE.GEOHASH); + expect(readQwpVarint(reader)).toBe(60n); + reader.expectEnd(); + }); + + it("places typed binds into QUERY_REQUEST without exposing raw bytes", () => { + const request = encodeQwpQueryRequest({ + requestId: 7, + sql: "select $1::long, $2::varchar", + binds: (binds) => binds.setLong(0, 42n).setVarchar(1, "browser"), + }); + const reader = new QwpByteReader(request); + expect(reader.readUint8()).toBe(QWP_EGRESS_MESSAGE.QUERY_REQUEST); + expect(reader.readBigUint64()).toBe(7n); + const sqlLength = Number(readQwpVarint(reader)); + expect(reader.readUtf8(sqlLength)).toBe("select $1::long, $2::varchar"); + expect(readQwpVarint(reader)).toBe(0n); + expect(readQwpVarint(reader)).toBe(2n); + expectNonNullHeader(reader, QWP_COLUMN_TYPE.LONG); + expect(reader.readBigInt64()).toBe(42n); + expectNonNullHeader(reader, QWP_COLUMN_TYPE.VARCHAR); + expect(reader.readUint32()).toBe(0); + const length = reader.readUint32(); + expect(reader.readUtf8(length)).toBe("browser"); + reader.expectEnd(); + }); + + it("rejects invalid order, ranges, types, UUIDs, and raw/typed mixing", () => { + expect(() => encodeQwpBinds((binds) => binds.setLong(1, 1n))).toThrow( + /expected 0, got 1/, + ); + expect(() => encodeQwpBinds((binds) => binds.setByte(0, 128))).toThrow( + /BYTE/, + ); + expect(() => + encodeQwpBinds((binds) => binds.setLong(0, Number.MAX_SAFE_INTEGER + 1)), + ).toThrow(/safe integer/); + expect(() => encodeQwpBinds((binds) => binds.setChar(0, "😀"))).toThrow( + /UTF-16/, + ); + expect(() => + encodeQwpBinds((binds) => binds.setGeohash(0, 61, 1n)), + ).toThrow(/GEOHASH precision/); + expect(() => + encodeQwpBinds((binds) => binds.setDecimal64(0, 19, 1n)), + ).toThrow(/DECIMAL64 scale/); + expect(() => + encodeQwpBinds((binds) => binds.setDecimal128(0, 39, 1n, 0n)), + ).toThrow(/DECIMAL128 scale/); + expect(() => + encodeQwpBinds((binds) => binds.setUuid(0, "not-a-uuid")), + ).toThrow(/canonical UUID/); + expect(() => + encodeQwpBinds(async (binds) => { + binds.setInt(0, 1); + }), + ).toThrow(/synchronous/); + expect(() => + new QwpBindValues().setNull(0, QWP_COLUMN_TYPE.BINARY as never), + ).toThrow(/unsupported QWP bind type/); + expect(() => + encodeQwpQueryRequest({ + requestId: 0, + sql: "select $1", + binds: (binds) => binds.setInt(0, 1), + bindCount: 1, + }), + ).toThrow(/cannot be mixed/); + expect(() => + encodeQwpQueryRequest({ + requestId: 0, + sql: "select 1", + bindCount: QWP_MAX_COLUMNS_PER_TABLE + 1, + }), + ).toThrow(/bindCount/); + + const reusable = new QwpBindValues(); + expect(() => reusable.setInt(0, 0x80000000)).toThrow(/INT/); + expect(() => reusable.setInt(0, 7)).not.toThrow(); + }); + + it("can be reset and enforces the server bind-count cap", () => { + const binds = new QwpBindValues().setInt(0, 1).reset().setLong(0, 2n); + expect(binds.count).toBe(1); + + const uuidBits = encodeQwpBinds((values) => + values.setUuid(0, 0xffffffffffffffffn, 0x8000000000000000n), + ); + const uuidReader = new QwpByteReader(uuidBits.payload); + expectNonNullHeader(uuidReader, QWP_COLUMN_TYPE.UUID); + expect(uuidReader.readBigUint64()).toBe(0xffffffffffffffffn); + expect(uuidReader.readBigUint64()).toBe(0x8000000000000000n); + + expect(() => + encodeQwpBinds((values) => { + for (let index = 0; index <= QWP_MAX_COLUMNS_PER_TABLE; index++) { + values.setBoolean(index, true); + } + }), + ).toThrow(/too many binds/); + }); +}); diff --git a/test/qwp/browser.e2e.ts b/test/qwp/browser.e2e.ts index b2ca3c9..8c901fb 100644 --- a/test/qwp/browser.e2e.ts +++ b/test/qwp/browser.e2e.ts @@ -279,14 +279,73 @@ describe("QWP in a real browser against QuestDB", () => { const session = await qwp.connectQwpBrowserEgress({ url }); try { const query = await session.query( - `select value from ${table} order by ts`, + `select value from ${table} where value = $1 and ts >= $2 order by ts`, + { + binds: (binds: any) => + binds.setLong(0, 42n).setTimestampMicros(1, 0n), + }, ); const values: string[] = []; for await (const batch of query) { for (const row of batch.rows()) values.push(String(row[0])); } const completion = await query.completion; - return { values, completion: completion.kind }; + + const typedQuery = await session.query( + "select " + + "$1::boolean, $2::byte, $3::short, $4::char, " + + "$5::int, $6::long, $7::float, $8::double, " + + "$9::date, $10::timestamp, $11::timestamp_ns, " + + "$12::varchar, $13::uuid, $14::long256, " + + "cast($15 as geohash(60b)), $16::decimal(18, 4), " + + "$17::decimal(38, 6), $18::decimal(76, 10) " + + "from long_sequence(1)", + { + binds: (binds: any) => + binds + .setBoolean(0, true) + .setByte(1, 42) + .setShort(2, 1234) + .setChar(3, "Q") + .setInt(4, 2_000_000) + .setLong(5, 9_000_000_000n) + .setFloat(6, 3.25) + .setDouble(7, 2.5) + .setDate(8, 1_700_000_000_000n) + .setTimestampMicros(9, 1_700_000_000_000_000n) + .setTimestampNanos(10, 1_700_000_000_123_456_789n) + .setVarchar(11, "café") + .setUuid(12, "123e4567-e89b-12d3-a456-426614174000") + .setLong256(13, 1n, 2n, 3n, 4n) + .setGeohash(14, 60, 0x0fffffffffffffffn) + .setDecimal64(15, 4, 123_456_789n) + .setDecimal128(16, 6, 123_456_789_123_456n, 0n) + .setDecimal256(17, 10, 420_000_000_000n, 0n, 0n, 0n), + }, + ); + let typedRow: any[] | undefined; + for await (const batch of typedQuery) { + typedRow = [...batch.rows()][0]; + } + await typedQuery.completion; + const normalize = (value: any): any => { + if (typeof value === "bigint") return value.toString(); + if (Array.isArray(value)) return value.map(normalize); + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value).map(([key, nested]) => [ + key, + normalize(nested), + ]), + ); + } + return value; + }; + return { + values, + completion: completion.kind, + typedRow: typedRow?.map(normalize), + }; } finally { await session.close(); } @@ -296,6 +355,26 @@ describe("QWP in a real browser against QuestDB", () => { expect(egressResult).toEqual({ values: Array.from({ length: WRITE_BATCH_SIZE }, () => "42"), completion: "result-end", + typedRow: [ + true, + 42, + 1234, + "Q", + 2_000_000, + "9000000000", + 3.25, + 2.5, + "1700000000000", + "1700000000000000", + "1700000000123456789", + "café", + { low: "11841725276408463360", high: "1314564453825188563" }, + { words: ["1", "2", "3", "4"] }, + { bits: "1152921504606846975", precisionBits: 60 }, + { unscaled: "123456789", scale: 4 }, + { unscaled: "123456789123456", scale: 6 }, + { unscaled: "420000000000", scale: 10 }, + ], }); } finally { await page From 020d5d556c9cd0fd640bc96ccdeb07b8c330577b Mon Sep 17 00:00:00 2001 From: glasstiger Date: Mon, 17 Aug 2026 21:41:37 +0100 Subject: [PATCH 014/265] ci(qwp): dispatch Enterprise durable ack e2e --- .github/workflows/build.yml | 54 +++++++++++++++++++++++++++++++++++++ test/qwp/browser.e2e.ts | 45 ------------------------------- 2 files changed, 54 insertions(+), 45 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 19bb5c2..d4e3f71 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -65,3 +65,57 @@ jobs: - name: Authenticated ingress and egress run: pnpm test:qwp-browser-e2e + + enterprise-qwp-e2e: + name: Dispatch Enterprise QWP E2E + runs-on: ubuntu-latest + # Azure credentials are not exposed to fork PRs. The repository variable + # keeps this dormant until the Enterprise pipeline and PAT are configured. + if: >- + vars.ENTERPRISE_E2E_ENABLED == 'true' && + (github.event_name != 'pull_request' || + github.event.pull_request.head.repo.full_name == github.repository) + steps: + - name: Queue Enterprise TypeScript-client E2E + env: + ENT_DISPATCH_PAT: ${{ secrets.ENT_DISPATCH_PAT }} + CLIENT_COMMIT: ${{ github.event.pull_request.head.sha || github.sha }} + CLIENT_PR_NUMBER: ${{ github.event.pull_request.number || '' }} + CLIENT_BRANCH: ${{ github.head_ref || github.ref_name }} + run: | + set -euo pipefail + + if [ -z "${ENT_DISPATCH_PAT:-}" ]; then + echo "ENT_DISPATCH_PAT is not configured" >&2 + exit 1 + fi + + ORG_URL="https://dev.azure.com/questdb/" + PROJECT="questdb-enterprise" + PIPELINE_NAME="build-and-test-e2e-typescript-client" + PIPELINES=$(curl -fsS -u ":${ENT_DISPATCH_PAT}" \ + "${ORG_URL}${PROJECT}/_apis/pipelines?api-version=7.0") + PIPELINE_ID=$(echo "$PIPELINES" | jq -r --arg name "$PIPELINE_NAME" \ + '.value[] | select(.name == $name) | .id' | head -1) + if [ -z "$PIPELINE_ID" ] || [ "$PIPELINE_ID" = "null" ]; then + echo "Enterprise pipeline '$PIPELINE_NAME' is not registered" >&2 + exit 1 + fi + + BODY=$(jq -n \ + --arg commit "$CLIENT_COMMIT" \ + --arg pr "$CLIENT_PR_NUMBER" \ + --arg branch "$CLIENT_BRANCH" \ + '{ + templateParameters: { + typescriptClientCommit: $commit, + typescriptClientPrNumber: $pr, + clientBranch: $branch + } + }') + RESPONSE=$(curl -fsS -u ":${ENT_DISPATCH_PAT}" \ + -H "Content-Type: application/json" \ + -X POST \ + -d "$BODY" \ + "${ORG_URL}${PROJECT}/_apis/pipelines/${PIPELINE_ID}/runs?api-version=7.0") + echo "Enterprise E2E queued: $(echo "$RESPONSE" | jq -r '._links.web.href')" diff --git a/test/qwp/browser.e2e.ts b/test/qwp/browser.e2e.ts index 8c901fb..b4d57d5 100644 --- a/test/qwp/browser.e2e.ts +++ b/test/qwp/browser.e2e.ts @@ -11,13 +11,11 @@ import { QWP_COLUMN_TYPE, QWP_STATUS, QwpDurableAckUnavailableError, - QwpIngressSession, QwpTableBuffer, } from "../../src/qwp/node"; const USER = process.env.QWP_BROWSER_E2E_USER ?? "admin"; const PASSWORD = process.env.QWP_BROWSER_E2E_PASSWORD ?? "quest"; -const DURABLE_E2E_URL = process.env.QWP_DURABLE_E2E_URL; const QUESTDB_HTTP_PORT = 9000; const WRITE_BATCH_SIZE = 8; @@ -392,7 +390,6 @@ describe("QWP in a real browser against QuestDB", () => { }); it("rejects durable ACK opt-in when the server does not advertise it", async () => { - if (DURABLE_E2E_URL) return; await expect( connectQwpNodeIngress({ url: websocketUrl(questdbUrl, "/write/v4"), @@ -414,46 +411,4 @@ describe("QWP in a real browser against QuestDB", () => { await connection.close(); } }); - - it.runIf(DURABLE_E2E_URL)( - "waits for Enterprise ingress to reach its durable table watermark", - async () => { - const durableUrl = DURABLE_E2E_URL!; - const tableName = `qwp_durable_e2e_${Date.now()}`; - const create = await executeSql( - durableUrl, - `create table ${tableName} (value long, ts timestamp) ` + - "timestamp(ts) partition by day wal", - ); - expect(create.status, await create.text()).toBe(200); - - const table = new QwpTableBuffer(tableName); - table.getOrCreateColumn("value", QWP_COLUMN_TYPE.LONG)!.values.push(42n); - table - .getOrCreateColumn("", QWP_COLUMN_TYPE.TIMESTAMP)! - .values.push(BigInt(Date.now()) * 1_000n); - table.nextRow(); - - let session: QwpIngressSession | undefined; - try { - session = await connectQwpNodeIngress( - { - url: websocketUrl(durableUrl, "/write/v4"), - authorization: `Basic ${Buffer.from(`${USER}:${PASSWORD}`).toString("base64")}`, - requestDurableAck: true, - }, - { durableAckKeepaliveMs: 25 }, - ); - const ack = await session.sendTables([table]); - expect(ack.status).toBe(QWP_STATUS.OK); - expect(ack.tables).toContainEqual( - expect.objectContaining({ name: tableName }), - ); - await session.waitForDurable(ack, 30_000); - } finally { - await session?.close(); - await executeSql(durableUrl, `drop table ${tableName}`); - } - }, - ); }); From dd775a73e072bdd6a553079631097d8235882a9b Mon Sep 17 00:00:00 2001 From: glasstiger Date: Mon, 17 Aug 2026 22:04:10 +0100 Subject: [PATCH 015/265] feat(qwp): add high-level sender API --- README.md | 33 + examples/qwp-basic.ts | 19 + examples/qwp-browser.ts | 15 + src/index.ts | 2 +- src/options.ts | 46 +- src/qwp/browser.ts | 27 + src/qwp/index.ts | 1 + src/qwp/node.ts | 35 + src/qwp/sender.ts | 886 +++++++++++++++++++++++ src/sender.ts | 138 +++- test/options.test.ts | 29 +- test/qwp/browser.e2e.ts | 41 +- test/qwp/sender-node-integration.test.ts | 77 ++ test/qwp/sender.test.ts | 185 +++++ 14 files changed, 1472 insertions(+), 62 deletions(-) create mode 100644 examples/qwp-basic.ts create mode 100644 examples/qwp-browser.ts create mode 100644 src/qwp/sender.ts create mode 100644 test/qwp/sender-node-integration.test.ts create mode 100644 test/qwp/sender.test.ts diff --git a/README.md b/README.md index 0ce8d45..637827e 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,39 @@ async function run() { run().then(console.log).catch(console.error); ``` +### QWP ingress from Node.js or a browser + +Node.js applications can select QWP through the regular `Sender` API: + +```typescript +import { Sender } from "@questdb/nodejs-client"; + +const sender = await Sender.fromConfig("ws::addr=127.0.0.1:9000"); +await sender.connect(); +await sender + .table("trades") + .symbol("symbol", "ETH-USD") + .floatColumn("price", 2615.54) + .at(Date.now(), "ms"); +await sender.flush(); +await sender.close(); +``` + +Browser applications use the browser entry point, which has no Node.js +dependencies. Cookies are supplied by the browser during a same-origin +WebSocket upgrade. + +```typescript +import { connectQwpBrowserSender } from "@questdb/nodejs-client/qwp/browser"; + +const url = new URL("/write/v4", location.href); +url.protocol = location.protocol === "https:" ? "wss:" : "ws:"; +const sender = await connectQwpBrowserSender({ url }, { autoFlush: false }); +await sender.table("events").longColumn("value", 42n).atNow(); +await sender.flush(); +await sender.close(); +``` + ### Authentication and secure connection #### Username and password authentication with HTTP transport diff --git a/examples/qwp-basic.ts b/examples/qwp-basic.ts new file mode 100644 index 0000000..c0ba737 --- /dev/null +++ b/examples/qwp-basic.ts @@ -0,0 +1,19 @@ +import { Sender } from "@questdb/nodejs-client"; + +async function main(): Promise { + const sender = await Sender.fromConfig("ws::addr=localhost:9000"); + await sender.connect(); + try { + await sender + .table("trades") + .symbol("symbol", "ETH-USD") + .floatColumn("price", 2_615.54) + .floatColumn("amount", 0.00044) + .at(Date.now(), "ms"); + await sender.flush(); + } finally { + await sender.close(); + } +} + +void main(); diff --git a/examples/qwp-browser.ts b/examples/qwp-browser.ts new file mode 100644 index 0000000..7203fee --- /dev/null +++ b/examples/qwp-browser.ts @@ -0,0 +1,15 @@ +import { connectQwpBrowserSender } from "@questdb/nodejs-client/qwp/browser"; + +async function main(): Promise { + const url = new URL("/write/v4", location.href); + url.protocol = location.protocol === "https:" ? "wss:" : "ws:"; + const sender = await connectQwpBrowserSender({ url }, { autoFlush: false }); + try { + await sender.table("events").longColumn("value", 42n).atNow(); + await sender.flush(); + } finally { + await sender.close(); + } +} + +void main(); diff --git a/src/index.ts b/src/index.ts index 3fc63c0..9e1d3ea 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +5,7 @@ export { Sender } from "./sender"; export { SenderOptions } from "./options"; -export type { ExtraOptions } from "./options"; +export type { ExtraOptions, QwpExtraOptions } from "./options"; export type { TimestampUnit } from "./utils"; export type { SenderBuffer } from "./buffer"; export { createBuffer } from "./buffer"; diff --git a/src/options.ts b/src/options.ts index f09cc6c..229763e 100644 --- a/src/options.ts +++ b/src/options.ts @@ -7,14 +7,22 @@ import * as https from "https"; import { Logger } from "./logging"; import { fetchJson, isBoolean, isInteger } from "./utils"; import { DEFAULT_REQUEST_TIMEOUT } from "./transport/http/base"; +import type { + QwpNodeIngressOptions, + QwpIngressSessionOptions, + QwpSenderOptions, +} from "./qwp/node"; const HTTP_PORT = 9000; const TCP_PORT = 9009; +const QWP_PORT = 9000; const HTTP = "http"; const HTTPS = "https"; const TCP = "tcp"; const TCPS = "tcps"; +const WS = "ws"; +const WSS = "wss"; const ON = "on"; const OFF = "off"; @@ -27,9 +35,19 @@ const PROTOCOL_VERSION_V3 = "3"; const LINE_PROTO_SUPPORT_VERSION = "line.proto.support.versions"; +type QwpExtraOptions = { + /** Node WebSocket and persistent store-and-forward options. */ + webSocket?: Omit; + /** Ingress ACK, durable-ACK, and reconnect options. */ + session?: QwpIngressSessionOptions; + /** High-level buffering and auto-flush options. */ + sender?: QwpSenderOptions; +}; + type ExtraOptions = { log?: Logger; agent?: Agent | http.Agent | https.Agent; + qwp?: QwpExtraOptions; }; type DeprecatedOptions = { @@ -51,8 +69,8 @@ type DeprecatedOptions = { *
* Connection and protocol options *
    - *
  • protocol: enum, accepted values: http, https, tcp, tcps - The protocol used to communicate with the server.
    - * When https or tcps used, the connection is secured with TLS encryption. + *
  • protocol: enum, accepted values: http, https, tcp, tcps, ws, wss - The protocol used to communicate with the server.
    + * WS/WSS select QWP ingress. When https, tcps, or wss is used, the connection is secured with TLS encryption. *
  • *
  • protocol_version: enum, accepted values: auto, 1, 2 - The protocol version used for data serialization.
    * Version 1 uses text-based serialization for all data types. Version 2 uses binary encoding for doubles and arrays.
    @@ -182,6 +200,8 @@ class SenderOptions { stdlib_http?: boolean; + qwp?: QwpExtraOptions; + auth?: { username?: string; keyId?: string; @@ -219,6 +239,7 @@ class SenderOptions { throw new Error("Invalid HTTP agent"); } this.agent = extraOptions.agent; + this.qwp = extraOptions.qwp; } } @@ -467,16 +488,26 @@ function parseProtocol(options: SenderOptions, configString: string) { case HTTPS: case TCP: case TCPS: + case WS: + case WSS: break; default: throw new Error( - `Invalid protocol: '${options.protocol}', accepted protocols: 'http', 'https', 'tcp', 'tcps'`, + `Invalid protocol: '${options.protocol}', accepted protocols: 'http', 'https', 'tcp', 'tcps', 'ws', 'wss'`, ); } return index + 2; } function parseProtocolVersion(options: SenderOptions) { + if (options.protocol === WS || options.protocol === WSS) { + if (options.protocol_version !== undefined) { + throw new Error( + "'protocol_version' is not used by the QWP ws/wss protocols", + ); + } + return; + } const protocol_version = options.protocol_version ?? PROTOCOL_VERSION_AUTO; switch (protocol_version) { case PROTOCOL_VERSION_AUTO: @@ -518,9 +549,13 @@ function parseAddress(options: SenderOptions) { case TCPS: options.port = TCP_PORT; return; + case WS: + case WSS: + options.port = QWP_PORT; + return; default: throw new Error( - `Invalid protocol: '${options.protocol}', accepted protocols: 'http', 'https', 'tcp', 'tcps'`, + `Invalid protocol: '${options.protocol}', accepted protocols: 'http', 'https', 'tcp', 'tcps', 'ws', 'wss'`, ); } } @@ -625,10 +660,13 @@ function parseInteger( export { SenderOptions, ExtraOptions, + QwpExtraOptions, HTTP, HTTPS, TCP, TCPS, + WS, + WSS, PROTOCOL_VERSION_AUTO, PROTOCOL_VERSION_V1, PROTOCOL_VERSION_V2, diff --git a/src/qwp/browser.ts b/src/qwp/browser.ts index 8ebe74c..73770d7 100644 --- a/src/qwp/browser.ts +++ b/src/qwp/browser.ts @@ -14,6 +14,7 @@ import { } from "./transport"; import { QwpEgressSession, QwpEgressSessionOptions } from "./egress-session"; import { QwpIngressSession, QwpIngressSessionOptions } from "./ingress-session"; +import { QwpSender, QwpSenderOptions } from "./sender"; export type { QwpWebSocketLike } from "./internal/websocket-connection"; @@ -92,6 +93,32 @@ export async function connectQwpBrowserIngress( ); } +/** + * Creates a browser-safe fluent QWP sender without opening the WebSocket yet. + * Call connect(), or let the first flush connect lazily. + */ +export function createQwpBrowserSender( + options: QwpBrowserWebSocketOptions, + senderOptions: QwpSenderOptions = {}, + sessionOptions: QwpIngressSessionOptions = {}, +): QwpSender { + return new QwpSender( + () => connectQwpBrowserIngress(options, sessionOptions), + senderOptions, + ); +} + +/** Opens a browser QWP connection and returns a fluent sender. */ +export async function connectQwpBrowserSender( + options: QwpBrowserWebSocketOptions, + senderOptions: QwpSenderOptions = {}, + sessionOptions: QwpIngressSessionOptions = {}, +): Promise { + const sender = createQwpBrowserSender(options, senderOptions, sessionOptions); + await sender.connect(); + return sender; +} + /** Opens a browser WebSocket and waits for the egress SERVER_INFO handshake. */ export async function connectQwpBrowserEgress( options: QwpBrowserWebSocketOptions, diff --git a/src/qwp/index.ts b/src/qwp/index.ts index ec8a690..22efcf4 100644 --- a/src/qwp/index.ts +++ b/src/qwp/index.ts @@ -9,4 +9,5 @@ export * from "./core"; export * from "./egress-session"; export * from "./ingress-session"; +export * from "./sender"; export * from "./transport"; diff --git a/src/qwp/node.ts b/src/qwp/node.ts index 513f12c..bdf90b8 100644 --- a/src/qwp/node.ts +++ b/src/qwp/node.ts @@ -20,6 +20,7 @@ import { } from "./transport"; import { QwpEgressSession, QwpEgressSessionOptions } from "./egress-session"; import { QwpIngressSession, QwpIngressSessionOptions } from "./ingress-session"; +import { QwpSender, QwpSenderOptions } from "./sender"; import { QwpNodeFileReplayStore } from "../qwp-node/file-replay-store"; import type { QwpNodeFileReplayStoreOptions } from "../qwp-node/file-replay-store"; @@ -325,6 +326,40 @@ export async function connectQwpNodeIngress( ); } +/** + * Creates a fluent Node QWP sender without opening the WebSocket yet. + * Call connect(), or let the first flush connect lazily. + */ +export function createQwpNodeSender( + options: QwpNodeIngressOptions, + senderOptions: QwpSenderOptions = {}, + sessionOptions: QwpIngressSessionOptions = {}, +): QwpSender { + return new QwpSender( + () => + connectQwpNodeIngress( + { + ...options, + requestDurableAck: + options.requestDurableAck ?? senderOptions.awaitDurableAck, + }, + sessionOptions, + ), + senderOptions, + ); +} + +/** Opens a Node QWP connection and returns a fluent sender. */ +export async function connectQwpNodeSender( + options: QwpNodeIngressOptions, + senderOptions: QwpSenderOptions = {}, + sessionOptions: QwpIngressSessionOptions = {}, +): Promise { + const sender = createQwpNodeSender(options, senderOptions, sessionOptions); + await sender.connect(); + return sender; +} + /** Opens a Node WebSocket and waits for the egress SERVER_INFO handshake. */ export async function connectQwpNodeEgress( options: QwpNodeWebSocketOptions, diff --git a/src/qwp/sender.ts b/src/qwp/sender.ts new file mode 100644 index 0000000..0cd82a6 --- /dev/null +++ b/src/qwp/sender.ts @@ -0,0 +1,886 @@ +import { + QWP_COLUMN_TYPE, + QwpColumnType, + QwpIngressEncodeOptions, + QwpIngressResponse, + QwpTableBuffer, + flattenQwpArray, +} from "./core"; + +export type QwpTimestampUnit = "ns" | "us" | "ms"; + +export type QwpSenderLogger = ( + level: "error" | "warn" | "info" | "debug", + message: string | Error, +) => void; + +/** Options for the browser-safe, fluent QWP sender. */ +export interface QwpSenderOptions { + autoFlush?: boolean; + autoFlushRows?: number; + autoFlushIntervalMs?: number; + /** Wait for durable upload after every successful ingress ACK. */ + awaitDurableAck?: boolean; + durableAckTimeoutMs?: number; + /** QWP frame encoding options supported by the high-level sender. */ + encode?: Pick; + log?: QwpSenderLogger; +} + +/** The subset of QwpIngressSession used by QwpSender. */ +export interface QwpSenderSession { + sendTables( + tables: readonly QwpTableBuffer[], + options?: QwpIngressEncodeOptions, + ): Promise; + waitForDurable( + response: QwpIngressResponse, + timeoutMs?: number, + ): Promise; + close(code?: number, reason?: string): Promise; +} + +export type QwpSenderSessionFactory = () => Promise; + +interface StagedColumn { + name: string; + type: QwpColumnType; + value: unknown; + geohashPrecision?: number; + decimalScale?: number; +} + +interface StagedTable { + name: string; + rows: Map[]; + schema: Map< + string, + Pick + >; +} + +const DEFAULT_AUTO_FLUSH_ROWS = 1_000; +const DEFAULT_AUTO_FLUSH_INTERVAL_MS = 100; + +function validateNonNegativeInteger(value: number, name: string): void { + if (!Number.isSafeInteger(value) || value < 0) { + throw new RangeError(`${name} must be a non-negative safe integer`); + } +} + +function checkedInteger(value: number, name: string): number { + if (!Number.isSafeInteger(value)) { + throw new TypeError(`${name} must be a safe integer`); + } + return value; +} + +function checkedRange( + value: number, + minimum: number, + maximum: number, + name: string, +): number { + const integer = checkedInteger(value, name); + if (integer < minimum || integer > maximum) { + throw new RangeError(`${name} must be between ${minimum} and ${maximum}`); + } + return integer; +} + +function checkedBigInt( + value: number | bigint, + name: string, + requireBigInt = false, +): bigint { + if (requireBigInt && typeof value !== "bigint") { + throw new TypeError(`${name} must be a bigint`); + } + return typeof value === "bigint" + ? value + : BigInt(checkedInteger(value, name)); +} + +function checkedInt64( + value: number | bigint, + name: string, + requireBigInt = false, +): bigint { + const result = checkedBigInt(value, name, requireBigInt); + if (!fitsSigned(result, 64)) + throw new RangeError(`${name} exceeds signed int64`); + return result; +} + +function timestampValue( + value: number | bigint, + unit: QwpTimestampUnit, +): { type: QwpColumnType; value: bigint } { + switch (unit) { + case "ns": + return { + type: QWP_COLUMN_TYPE.TIMESTAMP_NANOS, + value: checkedInt64(value, "nanosecond timestamp", true), + }; + case "us": + return { + type: QWP_COLUMN_TYPE.TIMESTAMP, + value: checkedInt64(value, "microsecond timestamp"), + }; + case "ms": { + const micros = checkedBigInt(value, "millisecond timestamp") * 1_000n; + if (!fitsSigned(micros, 64)) { + throw new RangeError( + "millisecond timestamp exceeds signed int64 micros", + ); + } + return { + type: QWP_COLUMN_TYPE.TIMESTAMP, + value: micros, + }; + } + default: + throw new TypeError(`unsupported timestamp unit '${String(unit)}'`); + } +} + +function signedBigEndianToBigInt(bytes: Int8Array): bigint { + if (bytes.length === 0) return 0n; + let result = 0n; + for (const byte of bytes) result = (result << 8n) | BigInt(byte & 0xff); + if ((bytes[0] & 0x80) !== 0) result -= 1n << BigInt(bytes.length * 8); + return result; +} + +function fitsSigned(value: bigint, bits: number): boolean { + return BigInt.asIntN(bits, value) === value; +} + +function decimalType(value: bigint, scale: number): QwpColumnType { + if (scale <= 18 && fitsSigned(value, 64)) return QWP_COLUMN_TYPE.DECIMAL64; + if (scale <= 38 && fitsSigned(value, 128)) return QWP_COLUMN_TYPE.DECIMAL128; + if (scale <= 76 && fitsSigned(value, 256)) return QWP_COLUMN_TYPE.DECIMAL256; + throw new RangeError("decimal value or scale exceeds DECIMAL256 capacity"); +} + +function parseDecimal(value: string | number): { + unscaled: bigint; + scale: number; +} { + const text = String(value); + const match = /^([+-]?)(\d+)(?:\.(\d+))?$/.exec(text); + if (!match) throw new TypeError(`invalid decimal value '${text}'`); + const fraction = match[3] ?? ""; + const magnitude = BigInt(`${match[2]}${fraction}`); + return { + unscaled: match[1] === "-" ? -magnitude : magnitude, + scale: fraction.length, + }; +} + +function littleEndianWords(words: readonly bigint[]): Uint8Array { + const bytes = new Uint8Array(words.length * 8); + const view = new DataView(bytes.buffer); + words.forEach((word, index) => view.setBigInt64(index * 8, word, true)); + return bytes; +} + +function uuidBytes(value: string | Uint8Array): Uint8Array { + if (value instanceof Uint8Array) { + if (value.length !== 16) { + throw new RangeError("UUID byte value must contain exactly 16 bytes"); + } + return new Uint8Array(value); + } + const match = + /^([0-9a-f]{8})-([0-9a-f]{4})-([0-9a-f]{4})-([0-9a-f]{4})-([0-9a-f]{12})$/i.exec( + value, + ); + if (!match) throw new TypeError("UUID value must use canonical UUID syntax"); + const hex = match.slice(1).join(""); + const high = BigInt(`0x${hex.slice(0, 16)}`); + const low = BigInt(`0x${hex.slice(16)}`); + const bytes = new Uint8Array(16); + const view = new DataView(bytes.buffer); + view.setBigUint64(0, low, true); + view.setBigUint64(8, high, true); + return bytes; +} + +function parseIpv4(value: string | number): number { + if (typeof value === "number") { + return checkedRange(value, 1, 0xffffffff, "IPv4 value"); + } + const parts = value.split("."); + if (parts.length !== 4) + throw new TypeError(`invalid IPv4 address '${value}'`); + let packed = 0; + for (const part of parts) { + if (!/^\d{1,3}$/.test(part)) { + throw new TypeError(`invalid IPv4 address '${value}'`); + } + const octet = Number(part); + if (octet > 255) throw new TypeError(`invalid IPv4 address '${value}'`); + packed = packed * 256 + octet; + } + if (packed === 0) { + throw new RangeError("0.0.0.0 is QuestDB's IPv4 NULL sentinel"); + } + return packed; +} + +/** + * Browser-safe high-level QWP ingress API. + * + * Applications normally obtain this class through create/connectQwpNodeSender + * or create/connectQwpBrowserSender, rather than constructing sessions and + * QwpTableBuffer instances themselves. + */ +export class QwpSender { + private readonly tables: StagedTable[] = []; + private readonly tablesByName = new Map(); + private current?: StagedTable; + private currentRow = new Map(); + private pendingRowCount = 0; + private lastFlushTime = Date.now(); + private sessionPromise?: Promise; + private flushTail: Promise = Promise.resolve(); + private closePromise?: Promise; + private closing = false; + private closed = false; + + private readonly autoFlush: boolean; + private readonly autoFlushRows: number; + private readonly autoFlushIntervalMs: number; + private readonly log: QwpSenderLogger; + + constructor( + private readonly sessionFactory: QwpSenderSessionFactory, + private readonly options: QwpSenderOptions = {}, + ) { + this.autoFlush = options.autoFlush ?? true; + this.autoFlushRows = options.autoFlushRows ?? DEFAULT_AUTO_FLUSH_ROWS; + this.autoFlushIntervalMs = + options.autoFlushIntervalMs ?? DEFAULT_AUTO_FLUSH_INTERVAL_MS; + validateNonNegativeInteger(this.autoFlushRows, "autoFlushRows"); + validateNonNegativeInteger(this.autoFlushIntervalMs, "autoFlushIntervalMs"); + if ( + options.durableAckTimeoutMs !== undefined && + (!Number.isFinite(options.durableAckTimeoutMs) || + options.durableAckTimeoutMs <= 0) + ) { + throw new RangeError("durableAckTimeoutMs must be a positive number"); + } + this.log = options.log ?? (() => undefined); + } + + async connect(): Promise { + this.throwIfUnavailable(); + await this.getSession(); + return true; + } + + reset(): QwpSender { + this.throwIfUnavailable(); + this.tables.length = 0; + this.tablesByName.clear(); + this.current = undefined; + this.currentRow.clear(); + this.resetAutoFlush(); + return this; + } + + table(name: string): QwpSender { + this.throwIfUnavailable(); + if (this.current) throw new Error("Table name has already been set"); + // Validate eagerly rather than waiting for flush. + new QwpTableBuffer(name); + let table = this.tablesByName.get(name); + if (!table) { + table = { name, rows: [], schema: new Map() }; + this.tablesByName.set(name, table); + this.tables.push(table); + } + this.current = table; + return this; + } + + symbol(name: string, value: unknown): QwpSender { + if (value === null || value === undefined) return this; + return this.addColumn(name, QWP_COLUMN_TYPE.SYMBOL, String(value)); + } + + stringColumn(name: string, value: string | null | undefined): QwpSender { + if (value === null || value === undefined) return this; + if (typeof value !== "string") { + return this.failRow(new TypeError("stringColumn accepts only strings")); + } + return this.addColumn(name, QWP_COLUMN_TYPE.VARCHAR, value); + } + + booleanColumn(name: string, value: boolean | null | undefined): QwpSender { + if (value === null || value === undefined) return this; + if (typeof value !== "boolean") { + return this.failRow(new TypeError("booleanColumn accepts only booleans")); + } + return this.addColumn(name, QWP_COLUMN_TYPE.BOOLEAN, value); + } + + floatColumn(name: string, value: number | null | undefined): QwpSender { + if (value === null || value === undefined) return this; + if (typeof value !== "number") { + return this.failRow(new TypeError("floatColumn accepts only numbers")); + } + return this.addColumn(name, QWP_COLUMN_TYPE.DOUBLE, value); + } + + doubleColumn(name: string, value: number | null | undefined): QwpSender { + return this.floatColumn(name, value); + } + + float32Column(name: string, value: number | null | undefined): QwpSender { + if (value === null || value === undefined) return this; + if (typeof value !== "number") { + return this.failRow(new TypeError("float32Column accepts only numbers")); + } + return this.addColumn(name, QWP_COLUMN_TYPE.FLOAT, value); + } + + byteColumn(name: string, value: number | null | undefined): QwpSender { + if (value === null || value === undefined) return this; + try { + return this.addColumn( + name, + QWP_COLUMN_TYPE.BYTE, + checkedRange(value, -128, 127, "byteColumn value"), + ); + } catch (error) { + return this.failRow(error); + } + } + + shortColumn(name: string, value: number | null | undefined): QwpSender { + if (value === null || value === undefined) return this; + try { + return this.addColumn( + name, + QWP_COLUMN_TYPE.SHORT, + checkedRange(value, -32_768, 32_767, "shortColumn value"), + ); + } catch (error) { + return this.failRow(error); + } + } + + int32Column(name: string, value: number | null | undefined): QwpSender { + if (value === null || value === undefined) return this; + try { + return this.addColumn( + name, + QWP_COLUMN_TYPE.INT, + checkedRange(value, -2_147_483_648, 2_147_483_647, "int32Column value"), + ); + } catch (error) { + return this.failRow(error); + } + } + + intColumn(name: string, value: number | null | undefined): QwpSender { + if (value === null || value === undefined) return this; + try { + return this.addColumn( + name, + QWP_COLUMN_TYPE.LONG, + BigInt(checkedInteger(value, "intColumn value")), + ); + } catch (error) { + return this.failRow(error); + } + } + + longColumn( + name: string, + value: number | bigint | null | undefined, + ): QwpSender { + if (value === null || value === undefined) return this; + try { + return this.addColumn( + name, + QWP_COLUMN_TYPE.LONG, + checkedInt64(value, "longColumn value"), + ); + } catch (error) { + return this.failRow(error); + } + } + + arrayColumn(name: string, value: unknown[] | null | undefined): QwpSender { + if (value === null || value === undefined) return this; + try { + const array = flattenQwpArray(value); + if (array.values.some((item) => typeof item !== "number")) { + throw new TypeError("arrayColumn accepts only number arrays"); + } + return this.addColumn(name, QWP_COLUMN_TYPE.DOUBLE_ARRAY, array); + } catch (error) { + return this.failRow(error); + } + } + + longArrayColumn( + name: string, + value: unknown[] | null | undefined, + ): QwpSender { + if (value === null || value === undefined) return this; + try { + const array = flattenQwpArray(value); + array.values = array.values.map((item) => + checkedInt64(item, "long array value"), + ); + return this.addColumn(name, QWP_COLUMN_TYPE.LONG_ARRAY, array); + } catch (error) { + return this.failRow(error); + } + } + + timestampColumn( + name: string, + value: number | bigint | null | undefined, + unit: QwpTimestampUnit = "us", + ): QwpSender { + if (value === null || value === undefined) return this; + try { + const timestamp = timestampValue(value, unit); + return this.addColumn(name, timestamp.type, timestamp.value); + } catch (error) { + return this.failRow(error); + } + } + + dateColumn( + name: string, + millisecondsSinceEpoch: number | bigint | null | undefined, + ): QwpSender { + if ( + millisecondsSinceEpoch === null || + millisecondsSinceEpoch === undefined + ) { + return this; + } + try { + return this.addColumn( + name, + QWP_COLUMN_TYPE.DATE, + checkedInt64(millisecondsSinceEpoch, "dateColumn value"), + ); + } catch (error) { + return this.failRow(error); + } + } + + binaryColumn(name: string, value: Uint8Array | null | undefined): QwpSender { + if (value === null || value === undefined) return this; + if (!(value instanceof Uint8Array)) { + return this.failRow( + new TypeError("binaryColumn accepts only Uint8Array values"), + ); + } + return this.addColumn(name, QWP_COLUMN_TYPE.BINARY, new Uint8Array(value)); + } + + charColumn(name: string, value: string | null | undefined): QwpSender { + if (value === null || value === undefined) return this; + if (typeof value !== "string" || value.length !== 1) { + return this.failRow( + new TypeError("charColumn accepts one UTF-16 code unit"), + ); + } + return this.addColumn(name, QWP_COLUMN_TYPE.CHAR, value); + } + + uuidColumn( + name: string, + value: string | Uint8Array | null | undefined, + ): QwpSender { + if (value === null || value === undefined) return this; + try { + return this.addColumn(name, QWP_COLUMN_TYPE.UUID, uuidBytes(value)); + } catch (error) { + return this.failRow(error); + } + } + + long256Column( + name: string, + word0: bigint, + word1: bigint, + word2: bigint, + word3: bigint, + ): QwpSender { + try { + const words = [word0, word1, word2, word3]; + for (const [index, word] of words.entries()) { + if (BigInt.asIntN(64, word) !== word) { + throw new RangeError(`LONG256 word ${index} exceeds signed int64`); + } + } + return this.addColumn( + name, + QWP_COLUMN_TYPE.LONG256, + littleEndianWords(words), + ); + } catch (error) { + return this.failRow(error); + } + } + + ipv4Column( + name: string, + value: string | number | null | undefined, + ): QwpSender { + if (value === null || value === undefined) return this; + try { + return this.addColumn(name, QWP_COLUMN_TYPE.IPV4, parseIpv4(value)); + } catch (error) { + return this.failRow(error); + } + } + + decimalColumnText( + name: string, + value: string | number | null | undefined, + ): QwpSender { + if (value === null || value === undefined) return this; + try { + const decimal = parseDecimal(value); + if (decimal.scale > 76 || !fitsSigned(decimal.unscaled, 256)) { + throw new RangeError( + "decimal value or scale exceeds DECIMAL256 capacity", + ); + } + return this.addColumn( + name, + QWP_COLUMN_TYPE.DECIMAL256, + decimal.unscaled, + { decimalScale: decimal.scale }, + ); + } catch (error) { + return this.failRow(error); + } + } + + decimalColumn( + name: string, + unscaled: Int8Array | bigint | null | undefined, + scale: number, + ): QwpSender { + if (unscaled === null || unscaled === undefined) return this; + try { + if (!Number.isSafeInteger(scale) || scale < 0 || scale > 76) { + throw new RangeError("decimal scale must be between 0 and 76"); + } + if (unscaled instanceof Int8Array && unscaled.length === 0) return this; + if (unscaled instanceof Int8Array && unscaled.length > 32) { + throw new RangeError("decimal unscaled value cannot exceed 32 bytes"); + } + const value = + typeof unscaled === "bigint" + ? unscaled + : signedBigEndianToBigInt(unscaled); + return this.addColumn(name, decimalType(value, scale), value, { + decimalScale: scale, + }); + } catch (error) { + return this.failRow(error); + } + } + + decimal64Column( + name: string, + unscaled: bigint | null | undefined, + scale: number, + ): QwpSender { + return this.fixedDecimalColumn( + name, + unscaled, + scale, + QWP_COLUMN_TYPE.DECIMAL64, + 64, + 18, + ); + } + + decimal128Column( + name: string, + unscaled: bigint | null | undefined, + scale: number, + ): QwpSender { + return this.fixedDecimalColumn( + name, + unscaled, + scale, + QWP_COLUMN_TYPE.DECIMAL128, + 128, + 38, + ); + } + + decimal256Column( + name: string, + unscaled: bigint | null | undefined, + scale: number, + ): QwpSender { + return this.fixedDecimalColumn( + name, + unscaled, + scale, + QWP_COLUMN_TYPE.DECIMAL256, + 256, + 76, + ); + } + + geohashColumn( + name: string, + value: bigint | null | undefined, + precision: number, + ): QwpSender { + if (value === null || value === undefined) return this; + if (!Number.isSafeInteger(precision) || precision < 1 || precision > 60) { + return this.failRow( + new RangeError("geohash precision must be between 1 and 60"), + ); + } + if (value < 0n || value >= 1n << BigInt(precision)) { + return this.failRow( + new RangeError("geohash value does not fit the requested precision"), + ); + } + return this.addColumn(name, QWP_COLUMN_TYPE.GEOHASH, value, { + geohashPrecision: precision, + }); + } + + cancelRow(): QwpSender { + this.throwIfUnavailable(); + this.currentRow.clear(); + return this; + } + + async at( + value: number | bigint, + unit: QwpTimestampUnit = "us", + ): Promise { + try { + const timestamp = timestampValue(value, unit); + this.addColumn("", timestamp.type, timestamp.value); + this.finishRow(); + } catch (error) { + this.failRow(error); + } + await this.tryFlush(); + } + + async atNow(): Promise { + this.throwIfUnavailable(); + this.requireTable(); + this.finishRow(); + await this.tryFlush(); + } + + flush(): Promise { + this.throwIfUnavailable(); + const flushing = this.flushTail.then(() => this.flushNow()); + this.flushTail = flushing.then( + () => undefined, + () => undefined, + ); + return flushing; + } + + close(): Promise { + if (!this.closePromise) this.closePromise = this.closeNow(); + return this.closePromise; + } + + private async closeNow(): Promise { + if (this.closed) return; + this.closing = true; + await this.flushTail; + if (this.pendingRowCount > 0 || this.currentRow.size > 0) { + this.log( + "warn", + `QWP sender contains ${this.pendingRowCount} completed row(s) and ${this.currentRow.size} unfinished column(s) which will be lost`, + ); + } + this.closed = true; + if (this.sessionPromise) { + const session = await this.sessionPromise; + await session.close(); + } + } + + private fixedDecimalColumn( + name: string, + unscaled: bigint | null | undefined, + scale: number, + type: QwpColumnType, + bits: number, + maximumScale: number, + ): QwpSender { + if (unscaled === null || unscaled === undefined) return this; + try { + if (!Number.isSafeInteger(scale) || scale < 0 || scale > maximumScale) { + throw new RangeError( + `decimal scale must be between 0 and ${maximumScale}`, + ); + } + if (!fitsSigned(unscaled, bits)) { + throw new RangeError(`decimal value exceeds signed int${bits}`); + } + return this.addColumn(name, type, unscaled, { decimalScale: scale }); + } catch (error) { + return this.failRow(error); + } + } + + private addColumn( + name: string, + type: QwpColumnType, + value: unknown, + metadata: Pick = {}, + ): QwpSender { + try { + this.throwIfUnavailable(); + const table = this.requireTable(); + if (typeof name !== "string") { + throw new TypeError("column name must be a string"); + } + const existingSchema = table.schema.get(name); + if ( + existingSchema && + (existingSchema.type !== type || + existingSchema.geohashPrecision !== metadata.geohashPrecision || + existingSchema.decimalScale !== metadata.decimalScale) + ) { + throw new Error(`column type mismatch for '${name}'`); + } + if (this.currentRow.has(name)) return this; + table.schema.set(name, { type, ...metadata }); + this.currentRow.set(name, { name, type, value, ...metadata }); + return this; + } catch (error) { + return this.failRow(error); + } + } + + private finishRow(): void { + const table = this.requireTable(); + table.rows.push(this.currentRow); + this.currentRow = new Map(); + this.current = undefined; + this.pendingRowCount++; + this.log("debug", `Pending QWP row count: ${this.pendingRowCount}`); + } + + private requireTable(): StagedTable { + if (!this.current) { + throw new Error("table name must be set before adding columns"); + } + return this.current; + } + + private failRow(error: unknown): never { + this.currentRow.clear(); + throw error; + } + + private async tryFlush(): Promise { + if ( + this.autoFlush && + this.pendingRowCount > 0 && + ((this.autoFlushRows > 0 && this.pendingRowCount >= this.autoFlushRows) || + (this.autoFlushIntervalMs > 0 && + Date.now() - this.lastFlushTime >= this.autoFlushIntervalMs)) + ) { + await this.flush(); + } + } + + private async flushNow(): Promise { + if (this.pendingRowCount === 0) return false; + const session = await this.getSession(); + const snapshots = this.tables + .filter((table) => table.rows.length > 0) + .map((table) => ({ table, rows: table.rows.slice() })); + if (snapshots.length === 0) return false; + + const wireTables = snapshots.map(({ table, rows }) => + this.buildTable(table.name, rows), + ); + // sendTables encodes synchronously. Do not compact staging if encoding + // throws, but transfer ownership once the frame has entered the session. + const response = session.sendTables(wireTables, this.options.encode); + for (const { table, rows } of snapshots) table.rows.splice(0, rows.length); + const sentRows = snapshots.reduce( + (count, item) => count + item.rows.length, + 0, + ); + this.pendingRowCount -= sentRows; + this.lastFlushTime = Date.now(); + this.log("debug", `Flushing ${sentRows} QWP row(s)`); + + const ack = await response; + if (this.options.awaitDurableAck) { + await session.waitForDurable(ack, this.options.durableAckTimeoutMs); + } + return true; + } + + private buildTable( + name: string, + rows: readonly Map[], + ): QwpTableBuffer { + const result = new QwpTableBuffer(name); + for (const row of rows) { + for (const column of row.values()) { + const target = result.getOrCreateColumn(column.name, column.type); + if (!target) continue; + if (column.geohashPrecision !== undefined) { + result.setGeohashPrecision(target, column.geohashPrecision); + } + if (column.decimalScale !== undefined) { + result.setDecimalScale(target, column.decimalScale); + } + target.values.push(column.value); + } + result.nextRow(); + } + return result; + } + + private getSession(): Promise { + if (!this.sessionPromise) { + const connecting = this.sessionFactory().catch((error: unknown) => { + if (this.sessionPromise === connecting) this.sessionPromise = undefined; + throw error; + }); + this.sessionPromise = connecting; + } + return this.sessionPromise; + } + + private resetAutoFlush(): void { + this.pendingRowCount = 0; + this.lastFlushTime = Date.now(); + } + + private throwIfClosed(): void { + if (this.closed) throw new Error("QWP sender is closed"); + } + + private throwIfUnavailable(): void { + this.throwIfClosed(); + if (this.closing) throw new Error("QWP sender is closing"); + } +} diff --git a/src/sender.ts b/src/sender.ts index fb3690b..abb71a8 100644 --- a/src/sender.ts +++ b/src/sender.ts @@ -1,9 +1,14 @@ // @ts-check +import { readFileSync } from "node:fs"; +import * as http from "node:http"; +import * as https from "node:https"; import { log, Logger } from "./logging"; -import { SenderOptions, ExtraOptions } from "./options"; +import { SenderOptions, ExtraOptions, WS, WSS } from "./options"; import { SenderTransport, createTransport } from "./transport"; import { SenderBuffer, createBuffer } from "./buffer"; import { isBoolean, isInteger, TimestampUnit } from "./utils"; +import { QWP_INGRESS_PATH } from "./qwp/core"; +import { createQwpNodeSender, QwpSender } from "./qwp/node"; const DEFAULT_AUTO_FLUSH_INTERVAL = 1000; // 1 sec @@ -19,6 +24,7 @@ const DEFAULT_AUTO_FLUSH_INTERVAL = 1000; // 1 sec * Supports certificate validation and custom CA certificates.
  • *
  • TCP: Direct TCP connection, provides persistent connections. Uses JWK token-based authentication.
  • *
  • TCPS: Secure TCP transport with TLS encryption.
  • + *
  • WS/WSS: QWP ingress over WebSocket, including browser-compatible wire encoding and QWP ACKs.
  • *
*

*

@@ -61,6 +67,7 @@ const DEFAULT_AUTO_FLUSH_INTERVAL = 1000; // 1 sec *

  • HTTPS with authentication: Sender.fromConfig("https::addr=localhost:9000;username=admin;password=secret")
  • *
  • TCP: Sender.fromConfig("tcp::addr=localhost:9009")
  • *
  • TCPS with authentication: Sender.fromConfig("tcps::addr=localhost:9009;username=user;token=private_key")
  • + *
  • QWP: Sender.fromConfig("ws::addr=localhost:9000")
  • * *

    *

    @@ -84,9 +91,11 @@ const DEFAULT_AUTO_FLUSH_INTERVAL = 1000; // 1 sec *

    */ class Sender { - private readonly transport: SenderTransport; + private readonly transport?: SenderTransport; - private readonly buffer: SenderBuffer; + private readonly buffer?: SenderBuffer; + + private readonly qwpSender?: QwpSender; private readonly autoFlush: boolean; private readonly autoFlushRows: number; @@ -103,11 +112,18 @@ class Sender { * See SenderOptions documentation for detailed description of configuration options. */ constructor(options: SenderOptions) { + this.log = options && typeof options.log === "function" ? options.log : log; + if (options?.protocol === WS || options?.protocol === WSS) { + this.qwpSender = createConfiguredQwpSender(options, this.log); + this.autoFlush = false; + this.autoFlushRows = 0; + this.autoFlushInterval = 0; + this.resetAutoFlush(); + return; + } this.transport = createTransport(options); this.buffer = createBuffer(options); - this.log = typeof options.log === "function" ? options.log : log; - this.autoFlush = isBoolean(options.auto_flush) ? options.auto_flush : true; this.autoFlushRows = isInteger(options.auto_flush_rows, 0) ? options.auto_flush_rows @@ -164,7 +180,11 @@ class Sender { * @return {Sender} Returns with a reference to this sender. */ reset(): Sender { - this.buffer.reset(); + if (this.qwpSender) { + this.qwpSender.reset(); + return this; + } + this.buffer!.reset(); this.resetAutoFlush(); return this; } @@ -175,7 +195,9 @@ class Sender { * @return {Promise} Resolves to true if the client is connected. */ connect(): Promise { - return this.transport.connect(); + return this.qwpSender + ? this.qwpSender.connect() + : this.transport!.connect(); } /** @@ -185,7 +207,8 @@ class Sender { * @return {Promise} Resolves to true when there was data in the buffer to send, and it was sent successfully. */ async flush(): Promise { - const dataToSend: Buffer = this.buffer.toBufferNew(); + if (this.qwpSender) return this.qwpSender.flush(); + const dataToSend: Buffer = this.buffer!.toBufferNew(); if (!dataToSend) { return false; // Nothing to send } @@ -196,7 +219,7 @@ class Sender { ); this.resetAutoFlush(); - await this.transport.send(dataToSend); + await this.transport!.send(dataToSend); return true; } @@ -205,14 +228,15 @@ class Sender { * Data sitting in the Sender's buffer will be lost unless flush() is called before close(). */ async close(): Promise { - const pos = this.buffer.currentPosition(); + if (this.qwpSender) return this.qwpSender.close(); + const pos = this.buffer!.currentPosition(); if (pos > 0) { this.log( "warn", `Buffer contains data which has not been flushed before closing the sender, and it will be lost [position=${pos}]`, ); } - return this.transport.close(); + return this.transport!.close(); } /** @@ -222,7 +246,8 @@ class Sender { * @return {Sender} Returns with a reference to this sender. */ table(table: string): Sender { - this.buffer.table(table); + if (this.qwpSender) this.qwpSender.table(table); + else this.buffer!.table(table); return this; } @@ -235,7 +260,8 @@ class Sender { * @return {Sender} Returns with a reference to this sender. */ symbol(name: string, value: unknown): Sender { - this.buffer.symbol(name, value); + if (this.qwpSender) this.qwpSender.symbol(name, value); + else this.buffer!.symbol(name, value); return this; } @@ -248,7 +274,8 @@ class Sender { * @return {Sender} Returns with a reference to this sender. */ stringColumn(name: string, value: string | null | undefined): Sender { - this.buffer.stringColumn(name, value); + if (this.qwpSender) this.qwpSender.stringColumn(name, value); + else this.buffer!.stringColumn(name, value); return this; } @@ -261,7 +288,8 @@ class Sender { * @return {Sender} Returns with a reference to this sender. */ booleanColumn(name: string, value: boolean | null | undefined): Sender { - this.buffer.booleanColumn(name, value); + if (this.qwpSender) this.qwpSender.booleanColumn(name, value); + else this.buffer!.booleanColumn(name, value); return this; } @@ -274,7 +302,8 @@ class Sender { * @return {Sender} Returns with a reference to this sender. */ floatColumn(name: string, value: number | null | undefined): Sender { - this.buffer.floatColumn(name, value); + if (this.qwpSender) this.qwpSender.floatColumn(name, value); + else this.buffer!.floatColumn(name, value); return this; } @@ -290,7 +319,8 @@ class Sender { * - or the array is not homogeneous: its elements are not all the same type */ arrayColumn(name: string, value: unknown[] | null | undefined): Sender { - this.buffer.arrayColumn(name, value); + if (this.qwpSender) this.qwpSender.arrayColumn(name, value); + else this.buffer!.arrayColumn(name, value); return this; } @@ -304,7 +334,8 @@ class Sender { * @throws Error if the value is not an integer */ intColumn(name: string, value: number | null | undefined): Sender { - this.buffer.intColumn(name, value); + if (this.qwpSender) this.qwpSender.intColumn(name, value); + else this.buffer!.intColumn(name, value); return this; } @@ -338,7 +369,8 @@ class Sender { value: number | bigint | null | undefined, unit: TimestampUnit = "us", ): Sender { - this.buffer.timestampColumn(name, value, unit); + if (this.qwpSender) this.qwpSender.timestampColumn(name, value, unit); + else this.buffer!.timestampColumn(name, value, unit); return this; } @@ -357,7 +389,8 @@ class Sender { name: string, value: string | number | null | undefined, ): Sender { - this.buffer.decimalColumnText(name, value); + if (this.qwpSender) this.qwpSender.decimalColumnText(name, value); + else this.buffer!.decimalColumnText(name, value); return this; } @@ -383,7 +416,8 @@ class Sender { unscaled: Int8Array | bigint | null | undefined, scale: number, ): Sender { - this.buffer.decimalColumn(name, unscaled, scale); + if (this.qwpSender) this.qwpSender.decimalColumn(name, unscaled, scale); + else this.buffer!.decimalColumn(name, unscaled, scale); return this; } @@ -413,7 +447,8 @@ class Sender { timestamp: number | bigint, unit: TimestampUnit = "us", ): Promise { - this.buffer.at(timestamp, unit); + if (this.qwpSender) return this.qwpSender.at(timestamp, unit); + this.buffer!.at(timestamp, unit); this.pendingRowCount++; this.log("debug", `Pending row count: ${this.pendingRowCount}`); await this.tryFlush(); @@ -424,7 +459,8 @@ class Sender { * Designated timestamp will be populated by the server on this record. */ async atNow(): Promise { - this.buffer.atNow(); + if (this.qwpSender) return this.qwpSender.atNow(); + this.buffer!.atNow(); this.pendingRowCount++; this.log("debug", `Pending row count: ${this.pendingRowCount}`); await this.tryFlush(); @@ -449,4 +485,60 @@ class Sender { } } +function createConfiguredQwpSender( + options: SenderOptions, + logger: Logger, +): QwpSender { + if (!options.host || !options.port) { + throw new Error("The 'host' and 'port' options are mandatory for QWP"); + } + const configuredWebSocket = options.qwp?.webSocket ?? {}; + const configuredSender = options.qwp?.sender ?? {}; + let agent = configuredWebSocket.agent; + if (!agent && options.agent instanceof http.Agent) agent = options.agent; + if (!agent && options.protocol === WSS) { + agent = new https.Agent({ + ca: options.tls_ca ? readFileSync(options.tls_ca) : undefined, + rejectUnauthorized: options.tls_verify ?? true, + }); + } + const authorization = + configuredWebSocket.authorization ?? qwpAuthorization(options); + return createQwpNodeSender( + { + ...configuredWebSocket, + url: `${options.protocol}://${options.host}:${options.port}${QWP_INGRESS_PATH}`, + agent, + authorization, + }, + { + ...configuredSender, + autoFlush: isBoolean(options.auto_flush) + ? options.auto_flush + : configuredSender.autoFlush, + autoFlushRows: isInteger(options.auto_flush_rows, 0) + ? options.auto_flush_rows + : configuredSender.autoFlushRows, + autoFlushIntervalMs: isInteger(options.auto_flush_interval, 0) + ? options.auto_flush_interval + : configuredSender.autoFlushIntervalMs, + log: logger, + }, + options.qwp?.session, + ); +} + +function qwpAuthorization(options: SenderOptions): string | undefined { + if (options.token) return `Bearer ${options.token}`; + if (options.username !== undefined || options.password !== undefined) { + if (!options.username || options.password === undefined) { + throw new Error( + "QWP Basic authentication requires both 'username' and 'password'", + ); + } + return `Basic ${Buffer.from(`${options.username}:${options.password}`, "utf8").toString("base64")}`; + } + return undefined; +} + export { Sender }; diff --git a/test/options.test.ts b/test/options.test.ts index 125df3c..a302b65 100644 --- a/test/options.test.ts +++ b/test/options.test.ts @@ -64,36 +64,42 @@ describe("Configuration string parser suite", function () { ); expect(options.protocol).toBe("https"); + options = await SenderOptions.fromConfig("ws::addr=host"); + expect(options.protocol).toBe("ws"); + + options = await SenderOptions.fromConfig("wss::addr=host"); + expect(options.protocol).toBe("wss"); + await expect( async () => await SenderOptions.fromConfig("HTTP::"), ).rejects.toThrow( - "Invalid protocol: 'HTTP', accepted protocols: 'http', 'https', 'tcp', 'tcps'", + "Invalid protocol: 'HTTP', accepted protocols: 'http', 'https', 'tcp', 'tcps', 'ws', 'wss'", ); await expect( async () => await SenderOptions.fromConfig("Http::"), ).rejects.toThrow( - "Invalid protocol: 'Http', accepted protocols: 'http', 'https', 'tcp', 'tcps'", + "Invalid protocol: 'Http', accepted protocols: 'http', 'https', 'tcp', 'tcps', 'ws', 'wss'", ); await expect( async () => await SenderOptions.fromConfig("HtTps::"), ).rejects.toThrow( - "Invalid protocol: 'HtTps', accepted protocols: 'http', 'https', 'tcp', 'tcps'", + "Invalid protocol: 'HtTps', accepted protocols: 'http', 'https', 'tcp', 'tcps', 'ws', 'wss'", ); await expect( async () => await SenderOptions.fromConfig("TCP::"), ).rejects.toThrow( - "Invalid protocol: 'TCP', accepted protocols: 'http', 'https', 'tcp', 'tcps'", + "Invalid protocol: 'TCP', accepted protocols: 'http', 'https', 'tcp', 'tcps', 'ws', 'wss'", ); await expect( async () => await SenderOptions.fromConfig("TcP::"), ).rejects.toThrow( - "Invalid protocol: 'TcP', accepted protocols: 'http', 'https', 'tcp', 'tcps'", + "Invalid protocol: 'TcP', accepted protocols: 'http', 'https', 'tcp', 'tcps', 'ws', 'wss'", ); await expect( async () => await SenderOptions.fromConfig("Tcps::"), ).rejects.toThrow( - "Invalid protocol: 'Tcps', accepted protocols: 'http', 'https', 'tcp', 'tcps'", + "Invalid protocol: 'Tcps', accepted protocols: 'http', 'https', 'tcp', 'tcps', 'ws', 'wss'", ); }); @@ -230,9 +236,20 @@ describe("Configuration string parser suite", function () { expect(options.port).toBe(9009); expect(options.username).toBe("user1"); expect(options.token).toBe("jwkprivkey123"); + + options = await SenderOptions.fromConfig("ws::addr=hostname"); + expect(options.host).toBe("hostname"); + expect(options.port).toBe(9000); + expect(options.protocol_version).toBeUndefined(); }); it("can parse protocol version", async function () { + await expect( + SenderOptions.fromConfig("ws::addr=hostname;protocol_version=1"), + ).rejects.toThrow( + "'protocol_version' is not used by the QWP ws/wss protocols", + ); + // invalid protocol version await expect( async () => diff --git a/test/qwp/browser.e2e.ts b/test/qwp/browser.e2e.ts index b4d57d5..d6372bc 100644 --- a/test/qwp/browser.e2e.ts +++ b/test/qwp/browser.e2e.ts @@ -212,28 +212,20 @@ describe("QWP in a real browser against QuestDB", () => { url: string, ) => Promise>; const qwp = await importModule(moduleUrl); - const buffer = new qwp.QwpTableBuffer(table); - buffer - .getOrCreateColumn("value", qwp.QWP_COLUMN_TYPE.LONG) - .values.push(42n); - buffer - .getOrCreateColumn("", qwp.QWP_COLUMN_TYPE.TIMESTAMP) - .values.push(BigInt(Date.now()) * 1_000n); - buffer.nextRow(); - - const session = await qwp.connectQwpBrowserIngress({ url }); + const sender = await qwp.connectQwpBrowserSender( + { url }, + { autoFlush: false }, + ); try { - const responses = await Promise.all( - Array.from({ length: batchSize }, () => - session.sendTables([buffer]), - ), - ); - return responses.map((response) => ({ - status: response.status, - sequence: String(response.sequence), - })); + for (let index = 0; index < batchSize; index++) { + await sender + .table(table) + .longColumn("value", 42n) + .at(BigInt(Date.now()) * 1_000n); + } + return { flushed: await sender.flush() }; } finally { - await session.close(); + await sender.close(); } }, { @@ -243,14 +235,7 @@ describe("QWP in a real browser against QuestDB", () => { batchSize: WRITE_BATCH_SIZE, }, ); - expect(ingressResult).toHaveLength(WRITE_BATCH_SIZE); - ingressResult.forEach((response, requestSequence) => { - expect(response.status).toBe(0); - expect(BigInt(response.sequence)).toBeGreaterThanOrEqual( - BigInt(requestSequence), - ); - }); - expect(ingressResult.at(-1)?.sequence).toBe(String(WRITE_BATCH_SIZE - 1)); + expect(ingressResult).toEqual({ flushed: true }); await expect .poll( diff --git a/test/qwp/sender-node-integration.test.ts b/test/qwp/sender-node-integration.test.ts new file mode 100644 index 0000000..070d8ef --- /dev/null +++ b/test/qwp/sender-node-integration.test.ts @@ -0,0 +1,77 @@ +import type { AddressInfo } from "node:net"; +import { WebSocketServer } from "ws"; +import { afterEach, describe, expect, it } from "vitest"; +import { Sender } from "../../src"; +import { QWP_MAGIC, QWP_STATUS, QwpByteWriter } from "../../src/qwp/node"; + +function okResponse(sequence: bigint, table: string): Uint8Array { + const encodedTable = new TextEncoder().encode(table); + return new QwpByteWriter() + .writeUint8(QWP_STATUS.OK) + .writeBigUint64(sequence) + .writeUint16(1) + .writeUint16(encodedTable.length) + .writeBytes(encodedTable) + .writeBigInt64(1n) + .toUint8Array(); +} + +describe("Sender QWP integration", () => { + let server: WebSocketServer | undefined; + + afterEach(async () => { + if (!server) return; + await new Promise((resolve, reject) => { + server!.close((error) => (error ? reject(error) : resolve())); + }); + server = undefined; + }); + + it("uses ws:: configuration, bearer authentication, and fluent rows", async () => { + const frames: Uint8Array[] = []; + let authorization: string | undefined; + let requestPath: string | undefined; + server = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + server.on("headers", (headers) => { + headers.push("X-QWP-Version: 1"); + headers.push("X-QWP-Max-Batch-Size: 1048576"); + }); + server.on("connection", (socket, request) => { + authorization = request.headers.authorization; + requestPath = request.url; + socket.on("message", (payload) => { + frames.push(new Uint8Array(payload as Buffer)); + socket.send(okResponse(BigInt(frames.length - 1), "trades")); + }); + }); + await new Promise((resolve, reject) => { + server!.once("listening", resolve); + server!.once("error", reject); + }); + const { port } = server.address() as AddressInfo; + + const sender = await Sender.fromConfig( + `ws::addr=127.0.0.1:${port};token=secret;auto_flush=off`, + ); + await sender.connect(); + await sender + .table("trades") + .symbol("symbol", "ETH-USD") + .floatColumn("price", 2_615.54) + .intColumn("amount", 2) + .atNow(); + await expect(sender.flush()).resolves.toBe(true); + await sender.close(); + + expect(authorization).toBe("Bearer secret"); + expect(requestPath).toBe("/write/v4"); + expect(frames).toHaveLength(1); + expect( + new DataView( + frames[0].buffer, + frames[0].byteOffset, + frames[0].byteLength, + ).getUint32(0, true), + ).toBe(QWP_MAGIC); + }); +}); diff --git a/test/qwp/sender.test.ts b/test/qwp/sender.test.ts new file mode 100644 index 0000000..3ac750d --- /dev/null +++ b/test/qwp/sender.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, it } from "vitest"; +import { + QWP_COLUMN_TYPE, + QWP_STATUS, + QwpIngressEncodeOptions, + QwpIngressResponse, + QwpSender, + QwpSenderSession, + QwpTableBuffer, + encodeQwpIngressFrame, +} from "../../src/qwp"; + +class RecordingSession implements QwpSenderSession { + readonly sends: { + tables: readonly QwpTableBuffer[]; + options?: QwpIngressEncodeOptions; + }[] = []; + readonly durable: QwpIngressResponse[] = []; + closeCount = 0; + + async sendTables( + tables: readonly QwpTableBuffer[], + options?: QwpIngressEncodeOptions, + ): Promise { + this.sends.push({ tables, options }); + return { + status: QWP_STATUS.OK, + sequence: BigInt(this.sends.length - 1), + tables: tables.map((table) => ({ + name: table.name, + sequenceTransaction: BigInt(table.rowCount), + })), + }; + } + + async waitForDurable(response: QwpIngressResponse): Promise { + this.durable.push(response); + } + + async close(): Promise { + this.closeCount++; + } +} + +function column(table: QwpTableBuffer, name: string) { + const result = table.columns.find((candidate) => candidate.name === name); + if (!result) throw new Error(`missing column '${name}'`); + return result; +} + +describe("QWP high-level sender", () => { + it("uses the existing Sender fluent API and preserves an unfinished row", async () => { + const session = new RecordingSession(); + const sender = new QwpSender(async () => session, { autoFlush: false }); + + await sender + .table("trades") + .symbol("symbol", "ETH-USD") + .floatColumn("price", 2_615.54) + .intColumn("amount", 2) + .at(1_700_000_000_000, "ms"); + sender.table("trades").intColumn("amount", 3); + + await expect(sender.flush()).resolves.toBe(true); + expect(session.sends).toHaveLength(1); + const first = session.sends[0].tables[0]; + expect(first.name).toBe("trades"); + expect(first.rowCount).toBe(1); + expect(column(first, "symbol")).toMatchObject({ + type: QWP_COLUMN_TYPE.SYMBOL, + values: ["ETH-USD"], + }); + expect(column(first, "price")).toMatchObject({ + type: QWP_COLUMN_TYPE.DOUBLE, + values: [2_615.54], + }); + expect(column(first, "amount")).toMatchObject({ + type: QWP_COLUMN_TYPE.LONG, + values: [2n], + }); + expect(column(first, "")).toMatchObject({ + type: QWP_COLUMN_TYPE.TIMESTAMP, + values: [1_700_000_000_000_000n], + }); + + await sender.atNow(); + await expect(sender.flush()).resolves.toBe(true); + expect(session.sends[1].tables[0].rowCount).toBe(1); + expect(column(session.sends[1].tables[0], "amount").values).toEqual([3n]); + await sender.close(); + expect(session.closeCount).toBe(1); + }); + + it("supports QWP-specific types without exposing QwpTableBuffer", async () => { + const session = new RecordingSession(); + const sender = new QwpSender(async () => session, { autoFlush: false }); + + await sender + .table("typed") + .byteColumn("byte_value", 7) + .shortColumn("short_value", 12_000) + .int32Column("int_value", 2_000_000) + .longColumn("long_value", 9_000_000_000n) + .float32Column("float_value", 1.5) + .doubleColumn("double_value", 2.5) + .longArrayColumn("longs", [1n, 2n, 3n]) + .binaryColumn("bytes", Uint8Array.of(1, 2, 3)) + .charColumn("letter", "Q") + .decimalColumnText("price", "123.4500") + .decimal64Column("precise_price", 1_234_500n, 4) + .geohashColumn("location", 7n, 12) + .dateColumn("created_date", 1_700_000_000_000n) + .timestampColumn("created_ns", 1_700_000_000_123_456_789n, "ns") + .uuidColumn("id", "123e4567-e89b-12d3-a456-426614174000") + .long256Column("hash", 1n, 2n, 3n, 4n) + .ipv4Column("ip", "192.168.0.1") + .atNow(); + await sender.flush(); + + const table = session.sends[0].tables[0]; + expect(column(table, "byte_value").type).toBe(QWP_COLUMN_TYPE.BYTE); + expect(column(table, "short_value").type).toBe(QWP_COLUMN_TYPE.SHORT); + expect(column(table, "int_value").type).toBe(QWP_COLUMN_TYPE.INT); + expect(column(table, "long_value").type).toBe(QWP_COLUMN_TYPE.LONG); + expect(column(table, "float_value").type).toBe(QWP_COLUMN_TYPE.FLOAT); + expect(column(table, "double_value").type).toBe(QWP_COLUMN_TYPE.DOUBLE); + expect(column(table, "longs").type).toBe(QWP_COLUMN_TYPE.LONG_ARRAY); + expect(column(table, "bytes").type).toBe(QWP_COLUMN_TYPE.BINARY); + expect(column(table, "letter").type).toBe(QWP_COLUMN_TYPE.CHAR); + expect(column(table, "price")).toMatchObject({ + type: QWP_COLUMN_TYPE.DECIMAL256, + decimalScale: 4, + values: [1_234_500n], + }); + expect(column(table, "precise_price")).toMatchObject({ + type: QWP_COLUMN_TYPE.DECIMAL64, + decimalScale: 4, + }); + expect(column(table, "location")).toMatchObject({ + type: QWP_COLUMN_TYPE.GEOHASH, + geohashPrecision: 12, + }); + expect(column(table, "created_ns")).toMatchObject({ + type: QWP_COLUMN_TYPE.TIMESTAMP_NANOS, + values: [1_700_000_000_123_456_789n], + }); + expect(column(table, "created_date").type).toBe(QWP_COLUMN_TYPE.DATE); + expect(column(table, "id").type).toBe(QWP_COLUMN_TYPE.UUID); + expect(column(table, "hash").type).toBe(QWP_COLUMN_TYPE.LONG256); + expect(column(table, "ip")).toMatchObject({ + type: QWP_COLUMN_TYPE.IPV4, + values: [0xc0a80001], + }); + expect(() => encodeQwpIngressFrame([table])).not.toThrow(); + }); + + it("rolls back the whole current row when a setter fails", async () => { + const session = new RecordingSession(); + const sender = new QwpSender(async () => session, { autoFlush: false }); + + sender.table("events").floatColumn("discarded", 1.5); + expect(() => sender.stringColumn("bad", 42 as unknown as string)).toThrow( + /only strings/, + ); + await sender.longColumn("kept", 7n).atNow(); + await sender.flush(); + + const table = session.sends[0].tables[0]; + expect(table.columns.map((item) => item.name)).toEqual(["kept"]); + }); + + it("can await durable ACKs and auto-flush by row count", async () => { + const session = new RecordingSession(); + const sender = new QwpSender(async () => session, { + autoFlushRows: 1, + autoFlushIntervalMs: 0, + awaitDurableAck: true, + }); + + await sender.table("events").longColumn("value", 42n).atNow(); + expect(session.sends).toHaveLength(1); + expect(session.durable).toHaveLength(1); + await expect(sender.flush()).resolves.toBe(false); + }); +}); From e7e39539a2720950a9763d0a8926744de6a321fa Mon Sep 17 00:00:00 2001 From: glasstiger Date: Mon, 17 Aug 2026 22:51:38 +0100 Subject: [PATCH 016/265] feat(qwp): complete ingress symbol dictionary deltas --- src/qwp-node/file-replay-store.ts | 282 ++++++++++++++++++ src/qwp/core/ingress.ts | 135 ++++++++- src/qwp/core/symbol-dictionary.ts | 15 + src/qwp/ingress-session.ts | 65 +++- .../reconnecting-ingress-connection.ts | 202 ++++++++++++- src/qwp/sender.ts | 19 +- src/qwp/transport.ts | 20 ++ test/qwp/core.test.ts | 59 ++++ test/qwp/reconnect.test.ts | 199 ++++++++++++ test/qwp/sender-node-integration.test.ts | 15 +- test/qwp/sender.test.ts | 23 ++ test/qwp/session.test.ts | 33 ++ 12 files changed, 1052 insertions(+), 15 deletions(-) diff --git a/src/qwp-node/file-replay-store.ts b/src/qwp-node/file-replay-store.ts index da433be..484c728 100644 --- a/src/qwp-node/file-replay-store.ts +++ b/src/qwp-node/file-replay-store.ts @@ -8,6 +8,7 @@ import { unlink, } from "node:fs/promises"; import { join } from "node:path"; +import { QWP_MAX_SYMBOL_DICTIONARY_SIZE } from "../qwp/core"; import { QwpIngressReplayRecord, QwpIngressReplayStore, @@ -20,6 +21,11 @@ const SHA256_SIZE = 32; const MAX_FRAME_SEQUENCE = 0xffffffffffffffffn; const RECORD_SUFFIX = ".qwp"; const TEMP_MARKER = ".tmp-"; +const DICTIONARY_MAGIC = Buffer.from("QWPD"); +const DICTIONARY_FILE = "symbols.qwpdict"; +const DICTIONARY_HEADER_SIZE = 8; +const DICTIONARY_BLOCK_HEADER_SIZE = 44; +const UTF8_DECODER = new TextDecoder("utf-8", { fatal: true }); interface StoredRecord { readonly path: string; @@ -67,8 +73,11 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { private readonly directory: string; private readonly maxBytes: number; private readonly records = new Map(); + private readonly symbols: string[] = []; + private readonly symbolValues = new Set(); private operationTail: Promise = Promise.resolve(); private totalBytes = 0; + private dictionaryFileSize = 0; private loaded = false; private closing = false; private closed = false; @@ -149,6 +158,7 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { recovered.push(record); previous = record.frameSequence; } + await this.loadDictionaryFile(); this.loaded = true; return recovered; }); @@ -224,6 +234,97 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { }); } + loadSymbolDictionary(): Promise { + if (this.closing || this.closed) return Promise.reject(this.closedError()); + return this.enqueue(async () => { + this.assertReady(); + return this.symbols.slice(); + }); + } + + appendSymbolDictionary( + startId: number, + entries: readonly string[], + ): Promise { + if (this.closing || this.closed) return Promise.reject(this.closedError()); + return this.enqueue(async () => { + this.assertReady(); + if (startId !== this.symbols.length) { + throw new QwpReplayStoreError( + `QWP symbol dictionary is not dense [expected=${this.symbols.length}, received=${startId}]`, + ); + } + if (startId + entries.length > QWP_MAX_SYMBOL_DICTIONARY_SIZE) { + throw new QwpReplayStoreError( + `QWP symbol dictionary exceeds maximum size ${QWP_MAX_SYMBOL_DICTIONARY_SIZE}`, + ); + } + if (entries.length === 0) return; + const additions = new Set(); + for (const entry of entries) { + if (this.symbolValues.has(entry) || additions.has(entry)) { + throw new QwpReplayStoreError( + `QWP symbol dictionary contains a duplicate value: '${entry}'`, + ); + } + additions.add(entry); + } + const block = encodeDictionaryBlock(startId, entries); + const initial = this.dictionaryFileSize === 0; + const addedBytes = + block.byteLength + (initial ? DICTIONARY_HEADER_SIZE : 0); + const requiredBytes = this.totalBytes + addedBytes; + if (requiredBytes > this.maxBytes) { + throw new QwpReplayStoreFullError(this.maxBytes, requiredBytes); + } + const finalPath = join(this.directory, DICTIONARY_FILE); + if (initial) { + const temporaryPath = join( + this.directory, + `${DICTIONARY_FILE}${TEMP_MARKER}${process.pid}-${randomUUID()}`, + ); + try { + const file = await open(temporaryPath, "wx", 0o600); + try { + await file.writeFile( + Buffer.concat([encodeDictionaryHeader(), block]), + ); + await file.sync(); + } finally { + await file.close(); + } + await rename(temporaryPath, finalPath); + await syncDirectory(this.directory); + } catch (error) { + await ignoreMissing(unlink(temporaryPath)); + throw new QwpReplayStoreError( + `could not create QWP symbol dictionary [startId=${startId}]`, + error, + ); + } + } else { + try { + const file = await open(finalPath, "a", 0o600); + try { + await file.writeFile(block); + await file.sync(); + } finally { + await file.close(); + } + } catch (error) { + throw new QwpReplayStoreError( + `could not append QWP symbol dictionary [startId=${startId}]`, + error, + ); + } + } + this.symbols.push(...entries); + for (const entry of entries) this.symbolValues.add(entry); + this.dictionaryFileSize += addedBytes; + this.totalBytes = requiredBytes; + }); + } + async close(): Promise { if (this.closed) return; this.closing = true; @@ -244,6 +345,106 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { if (this.closed) throw this.closedError(); } + private async loadDictionaryFile(): Promise { + const path = join(this.directory, DICTIONARY_FILE); + let bytes: Buffer; + try { + bytes = await readFile(path); + } catch (error) { + if (nodeErrorCode(error) === "ENOENT") return; + throw new QwpReplayStoreError( + "could not read QWP symbol dictionary", + error, + ); + } + if (bytes.byteLength < DICTIONARY_HEADER_SIZE) { + throw corruptDictionary("file is shorter than its header"); + } + if (!bytes.subarray(0, 4).equals(DICTIONARY_MAGIC)) { + throw corruptDictionary("invalid magic"); + } + if (bytes.readUInt8(4) !== FORMAT_VERSION) { + throw corruptDictionary(`unsupported version ${bytes.readUInt8(4)}`); + } + let offset = DICTIONARY_HEADER_SIZE; + while (offset < bytes.byteLength) { + if (bytes.byteLength - offset < DICTIONARY_BLOCK_HEADER_SIZE) { + await truncateDictionaryTail(path, offset, this.directory); + break; + } + const startId = bytes.readUInt32LE(offset); + const count = bytes.readUInt32LE(offset + 4); + const payloadLength = bytes.readUInt32LE(offset + 8); + const blockEnd = offset + DICTIONARY_BLOCK_HEADER_SIZE + payloadLength; + if (blockEnd > bytes.byteLength) { + await truncateDictionaryTail(path, offset, this.directory); + break; + } + if (startId !== this.symbols.length) { + throw corruptDictionary( + `dictionary is not dense [expected=${this.symbols.length}, received=${startId}]`, + ); + } + if (startId + count > QWP_MAX_SYMBOL_DICTIONARY_SIZE) { + throw corruptDictionary( + `dictionary exceeds maximum size ${QWP_MAX_SYMBOL_DICTIONARY_SIZE}`, + ); + } + const payload = bytes.subarray( + offset + DICTIONARY_BLOCK_HEADER_SIZE, + blockEnd, + ); + const expectedDigest = bytes.subarray(offset + 12, offset + 44); + const actualDigest = createHash("sha256") + .update(bytes.subarray(offset, offset + 12)) + .update(payload) + .digest(); + if (!actualDigest.equals(expectedDigest)) { + if (blockEnd === bytes.byteLength) { + await truncateDictionaryTail(path, offset, this.directory); + break; + } + throw corruptDictionary(`checksum mismatch at ID ${startId}`); + } + let payloadOffset = 0; + for (let index = 0; index < count; index++) { + if (payloadOffset + 4 > payload.byteLength) { + throw corruptDictionary(`entry ${startId + index} is truncated`); + } + const length = payload.readUInt32LE(payloadOffset); + payloadOffset += 4; + if (payloadOffset + length > payload.byteLength) { + throw corruptDictionary(`entry ${startId + index} is truncated`); + } + let entry: string; + try { + entry = UTF8_DECODER.decode( + payload.subarray(payloadOffset, payloadOffset + length), + ); + } catch { + throw corruptDictionary(`entry ${startId + index} is not UTF-8`); + } + payloadOffset += length; + if (this.symbolValues.has(entry)) { + throw corruptDictionary( + `duplicate value at ID ${startId + index}: '${entry}'`, + ); + } + this.symbolValues.add(entry); + this.symbols.push(entry); + } + if (payloadOffset !== payload.byteLength) { + throw corruptDictionary(`block at ID ${startId} has trailing bytes`); + } + offset = blockEnd; + } + this.dictionaryFileSize = offset; + this.totalBytes += offset; + if (this.totalBytes > this.maxBytes) { + throw new QwpReplayStoreFullError(this.maxBytes, this.totalBytes); + } + } + private assertReady(): void { this.assertOpen(); if (!this.loaded) { @@ -305,6 +506,87 @@ function decodeRecord(bytes: Buffer, name: string): QwpIngressReplayRecord { return { frameSequence, payload: new Uint8Array(payload) }; } +function encodeDictionaryHeader(): Buffer { + const header = Buffer.alloc(DICTIONARY_HEADER_SIZE); + DICTIONARY_MAGIC.copy(header, 0); + header.writeUInt8(FORMAT_VERSION, 4); + return header; +} + +function encodeDictionaryBlock( + startId: number, + entries: readonly string[], +): Buffer { + if (!Number.isSafeInteger(startId) || startId < 0 || startId > 0xffffffff) { + throw new QwpReplayStoreError( + `QWP symbol dictionary start ID is outside uint32 range [startId=${startId}]`, + ); + } + if (startId + entries.length > QWP_MAX_SYMBOL_DICTIONARY_SIZE) { + throw new QwpReplayStoreError( + `QWP symbol dictionary exceeds maximum size ${QWP_MAX_SYMBOL_DICTIONARY_SIZE}`, + ); + } + if (entries.length > 0xffffffff) { + throw new QwpReplayStoreError("QWP symbol dictionary block is too large"); + } + const encoded = entries.map((entry) => { + if (typeof entry !== "string") { + throw new QwpReplayStoreError( + "QWP symbol dictionary values must be strings", + ); + } + return Buffer.from(entry, "utf8"); + }); + let payloadLength = 0; + for (const entry of encoded) { + payloadLength += 4 + entry.byteLength; + if (payloadLength > 0xffffffff) { + throw new QwpReplayStoreError( + "QWP symbol dictionary block payload is too large", + ); + } + } + const block = Buffer.allocUnsafe( + DICTIONARY_BLOCK_HEADER_SIZE + payloadLength, + ); + block.writeUInt32LE(startId, 0); + block.writeUInt32LE(entries.length, 4); + block.writeUInt32LE(payloadLength, 8); + let offset = DICTIONARY_BLOCK_HEADER_SIZE; + for (const entry of encoded) { + block.writeUInt32LE(entry.byteLength, offset); + offset += 4; + entry.copy(block, offset); + offset += entry.byteLength; + } + const digest = createHash("sha256") + .update(block.subarray(0, 12)) + .update(block.subarray(DICTIONARY_BLOCK_HEADER_SIZE)) + .digest(); + digest.copy(block, 12); + return block; +} + +function corruptDictionary(reason: string): QwpReplayStoreError { + return new QwpReplayStoreError(`corrupt QWP symbol dictionary: ${reason}`); +} + +async function truncateDictionaryTail( + path: string, + size: number, + directory: string, +): Promise { + const file = await open(path, "r+"); + try { + await file.truncate(size); + await file.sync(); + } finally { + await file.close(); + } + await syncDirectory(directory); +} + function corruptRecord(name: string, reason: string): QwpReplayStoreError { return new QwpReplayStoreError( `corrupt QWP store-and-forward record [file=${name}]: ${reason}`, diff --git a/src/qwp/core/ingress.ts b/src/qwp/core/ingress.ts index e0ce3dc..0a2f96f 100644 --- a/src/qwp/core/ingress.ts +++ b/src/qwp/core/ingress.ts @@ -9,10 +9,12 @@ import { QWP_HEADER_SIZE, QWP_MAX_ERROR_MESSAGE_LENGTH, QWP_MAX_ROWS_PER_TABLE, + QWP_MAX_SYMBOL_DICTIONARY_SIZE, QWP_STATUS, QwpColumnType, } from "./constants"; -import { writeQwpFrameHeader } from "./frame"; +import { decodeQwpFrame, writeQwpFrameHeader } from "./frame"; +import { QwpProtocolError } from "./errors"; import { encodeQwpGorilla, qwpGorillaSize } from "./gorilla"; import { QwpSymbolDictionary } from "./symbol-dictionary"; import { @@ -21,13 +23,13 @@ import { QwpSymbolValue, QwpTableBuffer, } from "./table"; -import { qwpVarintSize, writeQwpVarint } from "./varint"; +import { qwpVarintSize, readQwpVarintNumber, writeQwpVarint } from "./varint"; export interface QwpIngressEncodeOptions { gorilla?: boolean; /** Present means connection-scoped delta dictionary mode. */ dictionary?: QwpSymbolDictionary; - /** Highest global symbol ID already confirmed by the server. */ + /** Highest global symbol ID already published on this logical connection. */ confirmedMaxSymbolId?: number; deferCommit?: boolean; } @@ -35,6 +37,7 @@ export interface QwpIngressEncodeOptions { interface ColumnEncodeOptions { gorilla: boolean; deltaSymbols: boolean; + dictionary?: QwpSymbolDictionary; } export interface QwpIngressTableResult { @@ -53,8 +56,21 @@ function symbolText(value: unknown): string { return typeof value === "string" ? value : (value as QwpSymbolValue).text; } -function symbolId(value: unknown): number { - return typeof value === "number" ? value : (value as QwpSymbolValue).id; +function symbolId(value: unknown, dictionary: QwpSymbolDictionary): number { + if (typeof value === "string") return dictionary.getOrAdd(value); + const id = typeof value === "number" ? value : (value as QwpSymbolValue).id; + if (!Number.isSafeInteger(id) || id < 0 || id >= dictionary.size) { + throw new Error(`QWP symbol ID is outside the dictionary: ${id}`); + } + if (typeof value !== "number") { + const symbol = value as QwpSymbolValue; + if (dictionary.valueAt(id) !== symbol.text) { + throw new Error( + `QWP symbol value does not match dictionary ID ${id}: '${symbol.text}'`, + ); + } + } + return id; } function nullCount(column: QwpColumnBuffer): number { @@ -138,7 +154,9 @@ function columnPayloadSize( if (column.type === QWP_COLUMN_TYPE.SYMBOL) { if (options.deltaSymbols) { - for (const value of column.values) size += qwpVarintSize(symbolId(value)); + for (const value of column.values) { + size += qwpVarintSize(symbolId(value, options.dictionary!)); + } return size; } const dictionary = [ @@ -307,8 +325,9 @@ function writeColumn( return; case QWP_COLUMN_TYPE.SYMBOL: { if (options.deltaSymbols) { - for (const value of column.values) - writeQwpVarint(writer, symbolId(value)); + for (const value of column.values) { + writeQwpVarint(writer, symbolId(value, options.dictionary!)); + } return; } const dictionary = [ @@ -424,6 +443,20 @@ function validateTableForEncoding(table: QwpTableBuffer): void { export function encodeQwpIngressFrame( tables: readonly QwpTableBuffer[], options: QwpIngressEncodeOptions = {}, +): Uint8Array { + const dictionarySize = options.dictionary?.size; + try { + return encodeQwpIngressFrameInternal(tables, options); + } catch (error) { + if (dictionarySize !== undefined) + options.dictionary!.truncate(dictionarySize); + throw error; + } +} + +function encodeQwpIngressFrameInternal( + tables: readonly QwpTableBuffer[], + options: QwpIngressEncodeOptions, ): Uint8Array { if (tables.length > 0xffff) { throw new Error("QWP frame contains more than 65535 tables"); @@ -439,13 +472,40 @@ export function encodeQwpIngressFrame( const gorilla = options.gorilla ?? true; const deltaSymbols = options.dictionary !== undefined; + if (deltaSymbols) { + const published = options.confirmedMaxSymbolId ?? -1; + if ( + !Number.isSafeInteger(published) || + published < -1 || + published >= options.dictionary!.size + ) { + throw new RangeError( + `published symbol dictionary ID is out of range [id=${published}, size=${options.dictionary!.size}]`, + ); + } + } + if (deltaSymbols) { + // Resolve string values before calculating the delta prefix and frame size. + for (const table of tables) { + for (const column of table.columns) { + if (column.type !== QWP_COLUMN_TYPE.SYMBOL) continue; + for (const value of column.values) { + if (typeof value === "string") options.dictionary!.getOrAdd(value); + } + } + } + } const deltaStart = deltaSymbols ? (options.confirmedMaxSymbolId ?? -1) + 1 : 0; const dictionaryEntries = deltaSymbols ? options.dictionary!.entriesFrom(deltaStart) : []; - const columnOptions = { gorilla, deltaSymbols }; + const columnOptions = { + gorilla, + deltaSymbols, + dictionary: options.dictionary, + }; let flags = 0; if (gorilla) flags |= QWP_FLAG_GORILLA; @@ -493,6 +553,63 @@ export function encodeQwpIngressFrame( return result; } +export interface QwpIngressSymbolDictionaryDelta { + readonly startId: number; + readonly entries: readonly string[]; +} + +/** Reads the connection-scoped dictionary prefix from a delta ingress frame. */ +export function decodeQwpIngressSymbolDictionaryDelta( + bytes: Uint8Array, +): QwpIngressSymbolDictionaryDelta | undefined { + const frame = decodeQwpFrame(bytes); + if ((frame.flags & QWP_FLAG_DELTA_SYMBOL_DICTIONARY) === 0) return undefined; + const reader = new QwpByteReader(frame.payload); + const startId = readQwpVarintNumber(reader, "symbol dictionary start ID"); + const count = readQwpVarintNumber(reader, "symbol dictionary entry count"); + if (startId + count > QWP_MAX_SYMBOL_DICTIONARY_SIZE) { + throw new QwpProtocolError( + `QWP symbol dictionary exceeds maximum size ${QWP_MAX_SYMBOL_DICTIONARY_SIZE}`, + ); + } + const entries: string[] = []; + for (let index = 0; index < count; index++) { + const length = readQwpVarintNumber( + reader, + "symbol dictionary entry length", + ); + entries.push(reader.readUtf8(length, "symbol dictionary entry")); + } + return { startId, entries }; +} + +/** Encodes a table-less committed dictionary catch-up frame. */ +export function encodeQwpIngressSymbolDictionaryFrame( + startId: number, + entries: readonly string[], +): Uint8Array { + if (!Number.isSafeInteger(startId) || startId < 0) { + throw new RangeError("symbol dictionary start ID must be non-negative"); + } + if (startId + entries.length > QWP_MAX_SYMBOL_DICTIONARY_SIZE) { + throw new RangeError( + `symbol dictionary exceeds maximum size ${QWP_MAX_SYMBOL_DICTIONARY_SIZE}`, + ); + } + let payloadLength = qwpVarintSize(startId) + qwpVarintSize(entries.length); + for (const entry of entries) payloadLength += qwpStringSize(entry); + const writer = new QwpByteWriter(QWP_HEADER_SIZE + payloadLength); + writeQwpFrameHeader(writer, { + flags: QWP_FLAG_DELTA_SYMBOL_DICTIONARY, + tableCount: 0, + payloadLength, + }); + writeQwpVarint(writer, startId); + writeQwpVarint(writer, entries.length); + for (const entry of entries) writeQwpString(writer, entry); + return writer.toUint8Array(); +} + export function encodeQwpIngressCommitFrame( dictionary?: QwpSymbolDictionary, confirmedMaxSymbolId = -1, diff --git a/src/qwp/core/symbol-dictionary.ts b/src/qwp/core/symbol-dictionary.ts index 4e4f775..cfa70b0 100644 --- a/src/qwp/core/symbol-dictionary.ts +++ b/src/qwp/core/symbol-dictionary.ts @@ -23,6 +23,10 @@ export class QwpSymbolDictionary { return id; } + valueAt(id: number): string | undefined { + return this.values[id]; + } + /** Appends positionally without de-duplicating recovered entries. */ addRecovered(value: string): number { if (this.values.length >= QWP_MAX_SYMBOL_DICTIONARY_SIZE) { @@ -40,6 +44,17 @@ export class QwpSymbolDictionary { return this.values.slice(Math.max(0, startId)); } + /** Rolls back entries added while preparing a frame that was not published. */ + truncate(size: number): void { + if (!Number.isSafeInteger(size) || size < 0 || size > this.values.length) { + throw new RangeError(`invalid symbol dictionary size ${size}`); + } + if (size === this.values.length) return; + this.values.length = size; + this.ids.clear(); + this.values.forEach((value, id) => this.ids.set(value, id)); + } + reset(): void { this.ids.clear(); this.values.length = 0; diff --git a/src/qwp/ingress-session.ts b/src/qwp/ingress-session.ts index 85ae9e0..d3b9e71 100644 --- a/src/qwp/ingress-session.ts +++ b/src/qwp/ingress-session.ts @@ -5,6 +5,7 @@ import { QwpIngressEncodeOptions, QwpIngressResponse, QwpProtocolError, + QwpSymbolDictionary, QwpTableBuffer, } from "./core"; import { @@ -110,6 +111,9 @@ export class QwpIngressSession { private sendTail: Promise = Promise.resolve(); private durablePingTimer?: ReturnType; private readonly localMaxBatchSizeBytes?: number; + private readonly symbolDictionary = new QwpSymbolDictionary(); + private publishedMaxSymbolId = -1; + private deltaSymbolsPublished = false; private failure?: Error; private closing = false; private readonly receiveLoop: Promise; @@ -138,6 +142,11 @@ export class QwpIngressSession { throw new RangeError("maxBatchSizeBytes must be a positive safe integer"); } this.localMaxBatchSizeBytes = localBatchCap; + for (const entry of connection.ingressSymbolDictionary ?? []) { + this.symbolDictionary.addRecovered(entry); + } + this.publishedMaxSymbolId = this.symbolDictionary.size - 1; + this.deltaSymbolsPublished = this.symbolDictionary.size > 0; const keepalive = options.durableAckKeepaliveMs; if ( keepalive !== undefined && @@ -167,6 +176,7 @@ export class QwpIngressSession { factory, options.reconnect, options.replayStore, + options.maxBatchSizeBytes, ) : await factory(); try { @@ -201,6 +211,50 @@ export class QwpIngressSession { return this.sendFrame(encodeQwpIngressFrame(tables, encodeOptions)); } + /** + * Sends tables using the session's connection-scoped symbol dictionary. + * String symbol values are assigned stable IDs automatically. + */ + sendTablesDelta( + tables: readonly QwpTableBuffer[], + encodeOptions: Pick< + QwpIngressEncodeOptions, + "gorilla" | "deferCommit" + > = {}, + ): Promise { + this.throwIfUnavailable(); + const previousSize = this.symbolDictionary.size; + let frame: Uint8Array; + try { + frame = encodeQwpIngressFrame(tables, { + ...encodeOptions, + dictionary: this.symbolDictionary, + confirmedMaxSymbolId: this.publishedMaxSymbolId, + }); + } catch (error) { + this.symbolDictionary.truncate(previousSize); + throw error; + } + if ( + this.maxBatchSizeBytes !== undefined && + frame.byteLength > this.maxBatchSizeBytes + ) { + this.symbolDictionary.truncate(previousSize); + return Promise.reject( + new QwpBatchTooLargeError(frame.byteLength, this.maxBatchSizeBytes), + ); + } + this.publishedMaxSymbolId = this.symbolDictionary.size - 1; + this.deltaSymbolsPublished = true; + try { + return this.sendFrame(frame); + } catch (error) { + this.symbolDictionary.truncate(previousSize); + this.publishedMaxSymbolId = previousSize - 1; + throw error; + } + } + sendFrame(frame: Uint8Array): Promise { this.throwIfUnavailable(); if ( @@ -344,7 +398,16 @@ export class QwpIngressSession { } this.pending.delete(response.sequence); if (pending.timer) clearTimeout(pending.timer); - pending.reject(new QwpIngressNackError(response)); + const error = new QwpIngressNackError(response); + pending.reject(error); + if ( + this.deltaSymbolsPublished && + response.status === QWP_STATUS.DICTIONARY_GAP + ) { + // This wire cannot repair a missing prefix without reconnect catch-up. + this.fail(error); + void this.connection.close(1002, "QWP symbol dictionary gap"); + } } private invokeCallback( diff --git a/src/qwp/internal/reconnecting-ingress-connection.ts b/src/qwp/internal/reconnecting-ingress-connection.ts index 299f7d5..c7688a6 100644 --- a/src/qwp/internal/reconnecting-ingress-connection.ts +++ b/src/qwp/internal/reconnecting-ingress-connection.ts @@ -1,7 +1,13 @@ import { decodeQwpIngressResponse, + decodeQwpIngressSymbolDictionaryDelta, + encodeQwpIngressSymbolDictionaryFrame, + QWP_FLAG_DELTA_SYMBOL_DICTIONARY, + QWP_HEADER_SIZE, QWP_STATUS, QwpProtocolError, + qwpVarintSize, + utf8Length, } from "../core"; import { QWP_RECONNECT_EVENT_KIND, @@ -15,6 +21,7 @@ import { QwpReconnectEvent, QwpReconnectExhaustedError, QwpReconnectOptions, + QwpReplayDictionaryError, QwpReplayRejectedError, QwpSendClosedError, QwpUpgradeError, @@ -26,6 +33,7 @@ interface ReplayFrame extends QwpIngressReplayRecord { ackDelivered: boolean; transmitted: boolean; durableTargets?: Map; + dictionaryCatchup?: boolean; } class RetriableIngressNackError extends Error { @@ -46,6 +54,7 @@ class RetriableIngressNackError extends Error { class QwpMemoryReplayStore implements QwpIngressReplayStore { private readonly records = new Map(); + private readonly symbols: string[] = []; async load(): Promise { return Array.from(this.records, ([frameSequence, payload]) => ({ @@ -65,6 +74,22 @@ class QwpMemoryReplayStore implements QwpIngressReplayStore { } } + async loadSymbolDictionary(): Promise { + return this.symbols.slice(); + } + + async appendSymbolDictionary( + startId: number, + entries: readonly string[], + ): Promise { + if (startId !== this.symbols.length) { + throw new QwpReplayDictionaryError( + `memory replay dictionary is not dense [expected=${this.symbols.length}, received=${startId}]`, + ); + } + this.symbols.push(...entries); + } + async close(): Promise {} } @@ -77,12 +102,14 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { private readonly messagesQueue = new QwpAsyncQueue(); private readonly frames = new Map(); private readonly durableWatermarks = new Map(); + private readonly symbolDictionary: string[]; private readonly store: QwpIngressReplayStore; private readonly maxAttempts: number; private readonly initialBackoffMs: number; private readonly maxBackoffMs: number; private readonly maxDurationMs: number; private readonly maxFrameRejections: number; + private readonly localMaxBatchSizeBytes?: number; private readonly resolveClosed: (info: QwpConnectionCloseInfo) => void; private connection?: QwpBinaryConnection; private connectingCandidate?: QwpBinaryConnection; @@ -109,8 +136,12 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { private readonly reconnectOptions: QwpReconnectOptions, store: QwpIngressReplayStore, records: readonly QwpIngressReplayRecord[], + symbolDictionary: readonly string[], + localMaxBatchSizeBytes?: number, ) { this.store = store; + this.symbolDictionary = [...symbolDictionary]; + this.localMaxBatchSizeBytes = localMaxBatchSizeBytes; this.maxAttempts = reconnectOptions.maxAttempts ?? 3; this.initialBackoffMs = reconnectOptions.initialBackoffMs ?? 100; this.maxBackoffMs = reconnectOptions.maxBackoffMs ?? 5_000; @@ -152,11 +183,16 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { factory: QwpConnectionFactory, reconnectOptions: QwpReconnectOptions, replayStore?: QwpIngressReplayStore, + localMaxBatchSizeBytes?: number, ): Promise { const store = replayStore ?? new QwpMemoryReplayStore(); let connection: QwpReconnectingIngressConnection | undefined; try { const records = await store.load(); + const symbolDictionary = store.loadSymbolDictionary + ? await store.loadSymbolDictionary() + : []; + validateRecoveredDictionary(records, symbolDictionary, store); connection = new QwpReconnectingIngressConnection( factory, reconnectOptions, @@ -168,6 +204,8 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { ? 1 : 0, ), + symbolDictionary, + localMaxBatchSizeBytes, ); await connection.connectLoop(undefined, false); return connection; @@ -188,6 +226,10 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { return this.lastEndpoint; } + get ingressSymbolDictionary(): readonly string[] { + return this.symbolDictionary.slice(); + } + send(payload: Uint8Array): Promise { if (this.terminalError) return Promise.reject(this.terminalError); if (this.closing) return Promise.reject(new QwpSendClosedError()); @@ -200,6 +242,8 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { }; const sending = this.sendTail.then(async () => { this.throwIfUnavailable(); + const delta = readSymbolDictionaryDelta(frame.payload); + if (delta) await this.persistSymbolDictionaryDelta(delta); await this.store.append(frame); this.frames.set(frame.frameSequence, frame); try { @@ -333,8 +377,22 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { connection: QwpBinaryConnection, ): Promise { const replayed: ReplayFrame[] = []; - const cap = connection.handshake.maxBatchSizeBytes; + const cap = minimumDefined( + connection.handshake.maxBatchSizeBytes, + this.localMaxBatchSizeBytes, + ); this.durableWatermarks.clear(); + for (const payload of dictionaryCatchupFrames(this.symbolDictionary, cap)) { + const frame: ReplayFrame = { + frameSequence: -1n, + payload, + ackDelivered: true, + transmitted: true, + dictionaryCatchup: true, + }; + replayed.push(frame); + await connection.send(payload); + } for (const frame of this.frames.values()) { if (!frame.transmitted) continue; frame.durableTargets = undefined; @@ -438,6 +496,7 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { if (!frame) return undefined; if (response.status === QWP_STATUS.OK) { + if (frame.dictionaryCatchup) return undefined; const covered = this.wireFrames.slice(0, wireIndex + 1); const clientTarget = findLastClientFrame(covered); const shouldDeliver = covered.some( @@ -537,9 +596,51 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { } } + private async persistSymbolDictionaryDelta( + delta: NonNullable>, + ): Promise { + if ( + !this.store.loadSymbolDictionary || + !this.store.appendSymbolDictionary + ) { + throw new QwpReplayDictionaryError( + "QWP delta symbol dictionaries require a replay store with dictionary persistence", + ); + } + if (delta.startId > this.symbolDictionary.length) { + throw new QwpReplayDictionaryError( + `QWP symbol dictionary has a gap [expectedAtMost=${this.symbolDictionary.length}, received=${delta.startId}]`, + ); + } + const overlap = Math.min( + this.symbolDictionary.length - delta.startId, + delta.entries.length, + ); + for (let index = 0; index < overlap; index++) { + const id = delta.startId + index; + if (this.symbolDictionary[id] !== delta.entries[index]) { + throw new QwpReplayDictionaryError( + `QWP symbol dictionary conflicts at ID ${id}`, + ); + } + } + const firstNewEntry = Math.max( + this.symbolDictionary.length - delta.startId, + 0, + ); + const newEntries = delta.entries.slice(firstNewEntry); + if (newEntries.length === 0) return; + const startId = this.symbolDictionary.length; + await this.store.appendSymbolDictionary(startId, newEntries); + this.symbolDictionary.push(...newEntries); + } + private async transmit(frame: ReplayFrame): Promise { const connection = await this.requireConnection(); - const cap = connection.handshake.maxBatchSizeBytes; + const cap = minimumDefined( + connection.handshake.maxBatchSizeBytes, + this.localMaxBatchSizeBytes, + ); if (cap !== undefined && frame.payload.byteLength > cap) { throw new RangeError( `QWP frame exceeds reconnect target batch cap [size=${frame.payload.byteLength}, max=${cap}]`, @@ -657,6 +758,103 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { } } +function readSymbolDictionaryDelta(payload: Uint8Array) { + // Preserve support for opaque/custom payloads used with the low-level API. + if ( + payload.byteLength < QWP_HEADER_SIZE || + (payload[5] & QWP_FLAG_DELTA_SYMBOL_DICTIONARY) === 0 + ) { + return undefined; + } + return decodeQwpIngressSymbolDictionaryDelta(payload); +} + +function validateRecoveredDictionary( + records: readonly QwpIngressReplayRecord[], + dictionary: readonly string[], + store: QwpIngressReplayStore, +): void { + const hasDictionaryPersistence = + store.loadSymbolDictionary !== undefined && + store.appendSymbolDictionary !== undefined; + for (const record of records) { + const delta = readSymbolDictionaryDelta(record.payload); + if (!delta) continue; + if (!hasDictionaryPersistence) { + throw new QwpReplayDictionaryError( + "persisted QWP delta frames require a replay store with dictionary persistence", + ); + } + if (delta.startId + delta.entries.length > dictionary.length) { + throw new QwpReplayDictionaryError( + `persisted QWP frame references an incomplete symbol dictionary [startId=${delta.startId}, count=${delta.entries.length}, dictionarySize=${dictionary.length}]`, + ); + } + delta.entries.forEach((entry, index) => { + const id = delta.startId + index; + if (dictionary[id] !== entry) { + throw new QwpReplayDictionaryError( + `persisted QWP frame conflicts with symbol dictionary at ID ${id}`, + ); + } + }); + } +} + +function dictionaryCatchupFrames( + entries: readonly string[], + maxBatchSizeBytes?: number, +): Uint8Array[] { + if (entries.length === 0) return []; + if (maxBatchSizeBytes === undefined) { + return [encodeQwpIngressSymbolDictionaryFrame(0, entries)]; + } + const result: Uint8Array[] = []; + let startId = 0; + while (startId < entries.length) { + let count = 0; + let entriesSize = 0; + while (startId + count < entries.length) { + const entryLength = utf8Length(entries[startId + count]); + const nextEntriesSize = + entriesSize + qwpVarintSize(entryLength) + entryLength; + const nextCount = count + 1; + const size = + QWP_HEADER_SIZE + + qwpVarintSize(startId) + + qwpVarintSize(nextCount) + + nextEntriesSize; + if (size > maxBatchSizeBytes) break; + count = nextCount; + entriesSize = nextEntriesSize; + } + if (count === 0) { + throw new RangeError( + `symbol dictionary entry exceeds reconnect target batch cap [id=${startId}, max=${maxBatchSizeBytes}]`, + ); + } + result.push( + encodeQwpIngressSymbolDictionaryFrame( + startId, + entries.slice(startId, startId + count), + ), + ); + startId += count; + } + return result; +} + +function minimumDefined( + first: number | undefined, + second: number | undefined, +): number | undefined { + return first === undefined + ? second + : second === undefined + ? first + : Math.min(first, second); +} + function validateReconnectPolicy( maxAttempts: number, initialBackoffMs: number, diff --git a/src/qwp/sender.ts b/src/qwp/sender.ts index 0cd82a6..cd58425 100644 --- a/src/qwp/sender.ts +++ b/src/qwp/sender.ts @@ -14,6 +14,12 @@ export type QwpSenderLogger = ( message: string | Error, ) => void; +export interface QwpSenderEncodeOptions + extends Pick { + /** Connection-scoped deltas are the default; use `full` to opt out. */ + symbolDictionary?: "delta" | "full"; +} + /** Options for the browser-safe, fluent QWP sender. */ export interface QwpSenderOptions { autoFlush?: boolean; @@ -23,7 +29,7 @@ export interface QwpSenderOptions { awaitDurableAck?: boolean; durableAckTimeoutMs?: number; /** QWP frame encoding options supported by the high-level sender. */ - encode?: Pick; + encode?: QwpSenderEncodeOptions; log?: QwpSenderLogger; } @@ -33,6 +39,10 @@ export interface QwpSenderSession { tables: readonly QwpTableBuffer[], options?: QwpIngressEncodeOptions, ): Promise; + sendTablesDelta?( + tables: readonly QwpTableBuffer[], + options?: Pick, + ): Promise; waitForDurable( response: QwpIngressResponse, timeoutMs?: number, @@ -820,7 +830,12 @@ export class QwpSender { ); // sendTables encodes synchronously. Do not compact staging if encoding // throws, but transfer ownership once the frame has entered the session. - const response = session.sendTables(wireTables, this.options.encode); + const encode = this.options.encode; + const response = + (encode?.symbolDictionary ?? "delta") === "delta" && + session.sendTablesDelta + ? session.sendTablesDelta(wireTables, { gorilla: encode?.gorilla }) + : session.sendTables(wireTables, { gorilla: encode?.gorilla }); for (const { table, rows } of snapshots) table.rows.splice(0, rows.length); const sentRows = snapshots.reduce( (count, item) => count + item.rows.length, diff --git a/src/qwp/transport.ts b/src/qwp/transport.ts index 883ac93..5a0a69a 100644 --- a/src/qwp/transport.ts +++ b/src/qwp/transport.ts @@ -95,6 +95,17 @@ export class QwpReplayRejectedError extends Error { } } +/** A replay store cannot preserve the dictionary required by delta frames. */ +export class QwpReplayDictionaryError extends Error { + readonly cause?: unknown; + + constructor(message: string, cause?: unknown) { + super(message); + this.name = "QwpReplayDictionaryError"; + this.cause = cause; + } +} + /** An active egress operation cannot be safely replayed without an explicit reset hook. */ export class QwpEgressReplayRequiredError extends Error { constructor(readonly requestId?: bigint) { @@ -117,6 +128,13 @@ export interface QwpIngressReplayStore { load(): Promise; append(record: QwpIngressReplayRecord): Promise; acknowledgeThrough(frameSequence: bigint): Promise; + /** Loads the durable, dense symbol prefix used by persisted delta frames. */ + loadSymbolDictionary?(): Promise; + /** Persists new dense entries before a delta frame is made replayable. */ + appendSymbolDictionary?( + startId: number, + entries: readonly string[], + ): Promise; close(): Promise; } @@ -262,6 +280,8 @@ export interface QwpBinaryConnection { readonly messages: AsyncIterable; readonly closed: Promise; readonly handshake: QwpHandshakeMetadata; + /** @internal Recovered ingress dictionary supplied by replay connections. */ + readonly ingressSymbolDictionary?: readonly string[]; /** Endpoint backing this connection, when supplied by its adapter. */ readonly endpoint?: string | URL; diff --git a/test/qwp/core.test.ts b/test/qwp/core.test.ts index ce7d1c6..2d7bc86 100644 --- a/test/qwp/core.test.ts +++ b/test/qwp/core.test.ts @@ -3,6 +3,7 @@ import { decodeQwpEgressMessage, decodeQwpFrame, decodeQwpIngressResponse, + decodeQwpIngressSymbolDictionaryDelta, decodeQwpVarint, encodeQwpCancel, encodeQwpCredit, @@ -20,6 +21,7 @@ import { QWP_STATUS, QwpByteReader, QwpByteWriter, + QwpSymbolDictionary, QwpTableBuffer, qwpGorillaSize, qwpVarintSize, @@ -142,6 +144,63 @@ describe("QWP ingress codec", () => { expect(gorilla[16]).toBe(0); }); + it("assigns string symbols stable global IDs and emits only new deltas", () => { + const dictionary = new QwpSymbolDictionary(); + const first = new QwpTableBuffer("trades"); + for (const symbol of ["ETH-USD", "BTC-USD"]) { + first + .getOrCreateColumn("symbol", QWP_COLUMN_TYPE.SYMBOL)! + .values.push(symbol); + first.nextRow(); + } + const firstFrame = encodeQwpIngressFrame([first], { + dictionary, + confirmedMaxSymbolId: -1, + }); + expect(decodeQwpIngressSymbolDictionaryDelta(firstFrame)).toEqual({ + startId: 0, + entries: ["ETH-USD", "BTC-USD"], + }); + + const second = new QwpTableBuffer("trades"); + for (const symbol of ["BTC-USD", "SOL-USD"]) { + second + .getOrCreateColumn("symbol", QWP_COLUMN_TYPE.SYMBOL)! + .values.push(symbol); + second.nextRow(); + } + const secondFrame = encodeQwpIngressFrame([second], { + dictionary, + confirmedMaxSymbolId: 1, + }); + expect(decodeQwpIngressSymbolDictionaryDelta(secondFrame)).toEqual({ + startId: 2, + entries: ["SOL-USD"], + }); + expect(dictionary.entriesFrom(0)).toEqual([ + "ETH-USD", + "BTC-USD", + "SOL-USD", + ]); + }); + + it("rolls back tentative symbols when frame encoding fails", () => { + const dictionary = new QwpSymbolDictionary(); + const table = new QwpTableBuffer("broken"); + table + .getOrCreateColumn("symbol", QWP_COLUMN_TYPE.SYMBOL)! + .values.push("ETH-USD"); + table + .getOrCreateColumn("payload", QWP_COLUMN_TYPE.BINARY)! + .values.push("not binary"); + table.nextRow(); + + expect(() => encodeQwpIngressFrame([table], { dictionary })).toThrow( + /Uint8Array/, + ); + expect(dictionary.size).toBe(0); + }); + it("refuses to encode incomplete column state", () => { const table = new QwpTableBuffer("broken"); table.getOrCreateColumn("value", QWP_COLUMN_TYPE.LONG); diff --git a/test/qwp/reconnect.test.ts b/test/qwp/reconnect.test.ts index 2275d8a..50ea5d9 100644 --- a/test/qwp/reconnect.test.ts +++ b/test/qwp/reconnect.test.ts @@ -10,6 +10,7 @@ import { } from "../../src/qwp/node"; import { QWP_RECONNECT_EVENT_KIND, + QWP_COLUMN_TYPE, QWP_EGRESS_CAPABILITY, QWP_EGRESS_MESSAGE, QWP_STATUS, @@ -21,11 +22,15 @@ import { QwpEgressSession, QwpIngressSession, QwpHandshakeMetadata, + QwpSymbolDictionary, + QwpTableBuffer, QwpReconnectEvent, QwpReconnectExhaustedError, QwpReplayRejectedError, QwpUpgradeError, encodeQwpFrame, + encodeQwpIngressFrame, + decodeQwpIngressSymbolDictionaryDelta, writeQwpVarint, } from "../../src/qwp"; import { QwpAsyncQueue } from "../../src/qwp/internal/async-queue"; @@ -101,6 +106,15 @@ function resultEnd(requestId = 0n): Uint8Array { return encodeQwpFrame(payload.toUint8Array()); } +function symbolTable(symbol: string): QwpTableBuffer { + const table = new QwpTableBuffer("trades"); + table + .getOrCreateColumn("symbol", QWP_COLUMN_TYPE.SYMBOL)! + .values.push(symbol); + table.nextRow(); + return table; +} + class FakeConnection implements QwpBinaryConnection { readonly messages: AsyncIterable; readonly sent: Uint8Array[] = []; @@ -236,6 +250,99 @@ describe("QWP ingress reconnect and replay", () => { await session.close(); }); + it("restores browser-memory symbol dictionaries before replay", async () => { + const first = new FakeConnection("primary"); + const second = new FakeConnection("secondary"); + const connections = [first, second]; + const session = await QwpIngressSession.connect( + async () => { + const connection = connections.shift(); + if (!connection) throw new Error("no connection available"); + return connection; + }, + { + ackTimeoutMs: 1_000, + reconnect: { + maxAttempts: 1, + initialBackoffMs: 0, + maxBackoffMs: 0, + }, + }, + ); + + const firstTable = symbolTable("ETH-USD"); + const acknowledged = session.sendTablesDelta([firstTable]); + await vi.waitFor(() => expect(first.sent).toHaveLength(1)); + expect(decodeQwpIngressSymbolDictionaryDelta(first.sent[0])).toEqual({ + startId: 0, + entries: ["ETH-USD"], + }); + first.receive(ingressResponse(QWP_STATUS.OK, 0n)); + await acknowledged; + + const pending = session.sendTablesDelta([symbolTable("BTC-USD")]); + await vi.waitFor(() => expect(first.sent).toHaveLength(2)); + expect(decodeQwpIngressSymbolDictionaryDelta(first.sent[1])).toEqual({ + startId: 1, + entries: ["BTC-USD"], + }); + first.drop(); + + await vi.waitFor(() => expect(second.sent).toHaveLength(2)); + expect(decodeQwpIngressSymbolDictionaryDelta(second.sent[0])).toEqual({ + startId: 0, + entries: ["ETH-USD", "BTC-USD"], + }); + expect(second.sent[1]).toEqual(first.sent[1]); + second.receive(ingressResponse(QWP_STATUS.OK, 0n)); + second.receive(ingressResponse(QWP_STATUS.OK, 1n)); + await expect(pending).resolves.toMatchObject({ sequence: 1n }); + await session.close(); + }); + + it("chunks reconnect dictionary catch-up under the negotiated batch cap", async () => { + const first = new FakeConnection("primary"); + const second = new FakeConnection("secondary", { + qwpVersion: 1, + maxBatchSizeBytes: 22, + }); + const connections = [first, second]; + const session = await QwpIngressSession.connect( + async () => { + const connection = connections.shift(); + if (!connection) throw new Error("no connection available"); + return connection; + }, + { + ackTimeoutMs: 1_000, + reconnect: { + maxAttempts: 1, + initialBackoffMs: 0, + maxBackoffMs: 0, + }, + }, + ); + for (const [index, symbol] of ["ETH-USD", "BTC-USD"].entries()) { + const pending = session.sendTablesDelta([symbolTable(symbol)]); + await vi.waitFor(() => expect(first.sent).toHaveLength(index + 1)); + first.receive(ingressResponse(QWP_STATUS.OK, BigInt(index))); + await pending; + } + + first.drop(); + await vi.waitFor(() => expect(second.sent).toHaveLength(2)); + expect(second.sent.every((frame) => frame.byteLength <= 22)).toBe(true); + expect(decodeQwpIngressSymbolDictionaryDelta(second.sent[0])).toEqual({ + startId: 0, + entries: ["ETH-USD"], + }); + expect(decodeQwpIngressSymbolDictionaryDelta(second.sent[1])).toEqual({ + startId: 1, + entries: ["BTC-USD"], + }); + await session.close(); + }); + it("does not double-send a frame queued while replay is connecting", async () => { const first = new FakeConnection("primary"); const second = new FakeConnection("secondary"); @@ -367,6 +474,67 @@ describe("QWP ingress reconnect and replay", () => { await session.close(); }); + it("recovers a persisted Node dictionary before replay and continues its IDs", async () => { + const directory = await createTemporaryDirectory(); + const dictionary = new QwpSymbolDictionary(); + const seededTable = new QwpTableBuffer("trades"); + for (const symbol of ["ETH-USD", "BTC-USD"]) { + seededTable + .getOrCreateColumn("symbol", QWP_COLUMN_TYPE.SYMBOL)! + .values.push(symbol); + seededTable.nextRow(); + } + const replayFrame = encodeQwpIngressFrame([seededTable], { + dictionary, + confirmedMaxSymbolId: -1, + }); + const seed = new QwpNodeFileReplayStore({ directory }); + await seed.load(); + await seed.appendSymbolDictionary(0, dictionary.entriesFrom(0)); + await seed.append({ frameSequence: 5n, payload: replayFrame }); + await seed.close(); + + const connection = new FakeConnection("primary"); + const session = await QwpIngressSession.connect(async () => connection, { + ackTimeoutMs: 1_000, + reconnect: { maxAttempts: 1 }, + replayStore: new QwpNodeFileReplayStore({ directory }), + }); + expect(connection.sent).toHaveLength(2); + expect(decodeQwpIngressSymbolDictionaryDelta(connection.sent[0])).toEqual({ + startId: 0, + entries: ["ETH-USD", "BTC-USD"], + }); + expect(connection.sent[1]).toEqual(replayFrame); + connection.receive(ingressResponse(QWP_STATUS.OK, 0n)); + connection.receive(ingressResponse(QWP_STATUS.OK, 1n)); + await vi.waitFor(async () => + expect( + (await readdir(directory)).filter((name) => name.endsWith(".qwp")), + ).toEqual([]), + ); + + const current = session.sendTablesDelta([symbolTable("SOL-USD")]); + await vi.waitFor(() => expect(connection.sent).toHaveLength(3)); + expect(decodeQwpIngressSymbolDictionaryDelta(connection.sent[2])).toEqual({ + startId: 2, + entries: ["SOL-USD"], + }); + connection.receive(ingressResponse(QWP_STATUS.OK, 2n)); + await expect(current).resolves.toMatchObject({ sequence: 0n }); + await session.close(); + + const verify = new QwpNodeFileReplayStore({ directory }); + await expect(verify.load()).resolves.toEqual([]); + await expect(verify.loadSymbolDictionary()).resolves.toEqual([ + "ETH-USD", + "BTC-USD", + "SOL-USD", + ]); + await verify.close(); + await rm(directory, { recursive: true, force: true }); + }); + it("recovers a Node journal before new frames and removes it after ACK", async () => { const directory = await createTemporaryDirectory(); const seed = new QwpNodeFileReplayStore({ directory }); @@ -579,6 +747,37 @@ describe("QWP Node file replay store", () => { await third.close(); }); + it("recovers a persisted dictionary and truncates a torn append tail", async () => { + const directory = await trackedDirectory(); + const first = new QwpNodeFileReplayStore({ directory }); + await first.load(); + await first.appendSymbolDictionary(0, ["ETH-USD", "BTC-USD"]); + await first.close(); + await writeFile( + join(directory, "symbols.qwpdict"), + Uint8Array.of(1, 2, 3), + { flag: "a" }, + ); + + const recovered = new QwpNodeFileReplayStore({ directory }); + await recovered.load(); + await expect(recovered.loadSymbolDictionary()).resolves.toEqual([ + "ETH-USD", + "BTC-USD", + ]); + await recovered.appendSymbolDictionary(2, ["SOL-USD"]); + await recovered.close(); + + const verify = new QwpNodeFileReplayStore({ directory }); + await verify.load(); + await expect(verify.loadSymbolDictionary()).resolves.toEqual([ + "ETH-USD", + "BTC-USD", + "SOL-USD", + ]); + await verify.close(); + }); + it("enforces its configured disk budget before writing", async () => { const directory = await trackedDirectory(); const store = new QwpNodeFileReplayStore({ diff --git a/test/qwp/sender-node-integration.test.ts b/test/qwp/sender-node-integration.test.ts index 070d8ef..629550a 100644 --- a/test/qwp/sender-node-integration.test.ts +++ b/test/qwp/sender-node-integration.test.ts @@ -2,7 +2,13 @@ import type { AddressInfo } from "node:net"; import { WebSocketServer } from "ws"; import { afterEach, describe, expect, it } from "vitest"; import { Sender } from "../../src"; -import { QWP_MAGIC, QWP_STATUS, QwpByteWriter } from "../../src/qwp/node"; +import { + QWP_FLAG_DELTA_SYMBOL_DICTIONARY, + QWP_MAGIC, + QWP_STATUS, + QwpByteWriter, + decodeQwpIngressSymbolDictionaryDelta, +} from "../../src/qwp/node"; function okResponse(sequence: bigint, table: string): Uint8Array { const encodedTable = new TextEncoder().encode(table); @@ -66,6 +72,13 @@ describe("Sender QWP integration", () => { expect(authorization).toBe("Bearer secret"); expect(requestPath).toBe("/write/v4"); expect(frames).toHaveLength(1); + expect(frames[0][5] & QWP_FLAG_DELTA_SYMBOL_DICTIONARY).toBe( + QWP_FLAG_DELTA_SYMBOL_DICTIONARY, + ); + expect(decodeQwpIngressSymbolDictionaryDelta(frames[0])).toEqual({ + startId: 0, + entries: ["ETH-USD"], + }); expect( new DataView( frames[0].buffer, diff --git a/test/qwp/sender.test.ts b/test/qwp/sender.test.ts index 3ac750d..735e61e 100644 --- a/test/qwp/sender.test.ts +++ b/test/qwp/sender.test.ts @@ -16,6 +16,7 @@ class RecordingSession implements QwpSenderSession { options?: QwpIngressEncodeOptions; }[] = []; readonly durable: QwpIngressResponse[] = []; + deltaSendCount = 0; closeCount = 0; async sendTables( @@ -33,6 +34,14 @@ class RecordingSession implements QwpSenderSession { }; } + sendTablesDelta( + tables: readonly QwpTableBuffer[], + options?: Pick, + ): Promise { + this.deltaSendCount++; + return this.sendTables(tables, options); + } + async waitForDurable(response: QwpIngressResponse): Promise { this.durable.push(response); } @@ -63,6 +72,7 @@ describe("QWP high-level sender", () => { await expect(sender.flush()).resolves.toBe(true); expect(session.sends).toHaveLength(1); + expect(session.deltaSendCount).toBe(1); const first = session.sends[0].tables[0]; expect(first.name).toBe("trades"); expect(first.rowCount).toBe(1); @@ -182,4 +192,17 @@ describe("QWP high-level sender", () => { expect(session.durable).toHaveLength(1); await expect(sender.flush()).resolves.toBe(false); }); + + it("allows the high-level sender to opt out of symbol deltas", async () => { + const session = new RecordingSession(); + const sender = new QwpSender(async () => session, { + autoFlush: false, + encode: { symbolDictionary: "full" }, + }); + await sender.table("trades").symbol("symbol", "ETH-USD").atNow(); + await sender.flush(); + expect(session.deltaSendCount).toBe(0); + expect(session.sends).toHaveLength(1); + await sender.close(); + }); }); diff --git a/test/qwp/session.test.ts b/test/qwp/session.test.ts index 57ab73b..5910f2b 100644 --- a/test/qwp/session.test.ts +++ b/test/qwp/session.test.ts @@ -9,12 +9,14 @@ import { QwpVersionMismatchError, } from "../../src/qwp/node"; import { + QWP_COLUMN_TYPE, QWP_STATUS, QWP_UPGRADE_ERROR_KIND, QwpBatchTooLargeError, QwpByteWriter, QwpIngressNackError, QwpIngressSession, + QwpTableBuffer, QwpSendClosedError, QwpSendTimeoutError, QwpUpgradeError, @@ -753,6 +755,37 @@ describe("QwpIngressSession", () => { await session.close(); }); + it("fails a direct delta session after a dictionary gap", async () => { + const socket = new FakeWebSocket(); + const connecting = connectQwpBrowserWebSocket({ + url: "ws://localhost:9000/write/v4", + webSocketFactory: () => asQwpSocket(socket), + }); + socket.open(); + const session = new QwpIngressSession(await connecting); + socket.onSend = () => { + socket.message( + ingressResponse(QWP_STATUS.DICTIONARY_GAP, 0n, "missing prefix"), + ); + }; + const table = new QwpTableBuffer("trades"); + table + .getOrCreateColumn("symbol", QWP_COLUMN_TYPE.SYMBOL)! + .values.push("ETH-USD"); + table.nextRow(); + + await expect(session.sendTablesDelta([table])).rejects.toMatchObject({ + name: "QwpIngressNackError", + response: { status: QWP_STATUS.DICTIONARY_GAP }, + }); + expect(() => session.sendFrame(Uint8Array.of(2))).toThrow(/missing prefix/); + expect(socket.closeCalls).toContainEqual({ + code: 1002, + reason: "QWP symbol dictionary gap", + }); + await session.close(); + }); + it("times out an ACK without losing session closeability", async () => { vi.useFakeTimers(); try { From c32175015cbcc932dc76b908bc910b1e129163d5 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Mon, 17 Aug 2026 23:12:30 +0100 Subject: [PATCH 017/265] feat(qwp): harden resource cleanup and timeouts --- src/qwp/browser.ts | 3 + src/qwp/egress-session.ts | 97 +++-- src/qwp/ingress-session.ts | 117 ++++-- .../reconnecting-ingress-connection.ts | 16 +- src/qwp/internal/websocket-connection.ts | 352 ++++++++++++------ src/qwp/node.ts | 3 + src/qwp/sender.ts | 28 +- src/qwp/transport.ts | 2 + test/qwp/egress.test.ts | 68 +++- test/qwp/reconnect.test.ts | 32 ++ test/qwp/sender.test.ts | 33 ++ test/qwp/session.test.ts | 226 +++++++++++ 12 files changed, 777 insertions(+), 200 deletions(-) diff --git a/src/qwp/browser.ts b/src/qwp/browser.ts index 73770d7..26e5ac1 100644 --- a/src/qwp/browser.ts +++ b/src/qwp/browser.ts @@ -4,6 +4,7 @@ export * from "./index"; import { openQwpWebSocket, QwpWebSocketLike, + validateQwpWebSocketTimeouts, } from "./internal/websocket-connection"; import { createQwpFailoverConnectionFactory } from "./internal/failover"; import { QWP_VERSION } from "./core"; @@ -56,6 +57,7 @@ function connectQwpBrowserEndpoint( options: QwpBrowserWebSocketOptions, endpoint: string | URL, ): Promise { + validateQwpWebSocketTimeouts(options); const factory = options.webSocketFactory ?? ((url: string | URL, protocols?: string | string[]) => { @@ -77,6 +79,7 @@ function connectQwpBrowserEndpoint( url: endpoint, connectTimeoutMs: options.connectTimeoutMs, sendTimeoutMs: options.sendTimeoutMs, + closeTimeoutMs: options.closeTimeoutMs, completeHandshake: () => ({ qwpVersion: QWP_VERSION }), opaqueErrors: true, }); diff --git a/src/qwp/egress-session.ts b/src/qwp/egress-session.ts index bf05309..c9525ed 100644 --- a/src/qwp/egress-session.ts +++ b/src/qwp/egress-session.ts @@ -51,6 +51,18 @@ export interface QwpEgressQueryOptions { resetDictionary?: boolean; } +function validateEgressSessionOptions( + options: QwpEgressSessionOptions, +): number { + const timeout = options.serverInfoTimeoutMs ?? 15_000; + if (!Number.isFinite(timeout) || timeout <= 0) { + throw new RangeError( + "serverInfoTimeoutMs must be a positive finite number", + ); + } + return timeout; +} + export type QwpQueryCompletion = QwpResultEndMessage | QwpExecDoneMessage; export class QwpEgressQueryError extends Error { @@ -161,25 +173,33 @@ export class QwpEgressSession implements QwpEgressQueryControl { private serverInfo?: QwpServerInfoMessage; private failure?: Error; private closing = false; + private closePromise?: Promise; readonly ready: Promise; constructor( private readonly connection: QwpBinaryConnection, options: QwpEgressSessionOptions = {}, ) { - if ( - options.reconnect && - !(connection instanceof QwpReconnectingEgressConnection) - ) { - throw new Error( - "egress reconnect options require QwpEgressSession.connect(factory, options)", - ); - } - const timeout = options.serverInfoTimeoutMs ?? 15_000; - if (!Number.isFinite(timeout) || timeout <= 0) { - throw new RangeError( - "serverInfoTimeoutMs must be a positive finite number", - ); + let timeout: number; + try { + if ( + options.reconnect && + !(connection instanceof QwpReconnectingEgressConnection) + ) { + throw new Error( + "egress reconnect options require QwpEgressSession.connect(factory, options)", + ); + } + timeout = validateEgressSessionOptions(options); + } catch (error) { + try { + void connection + .close(1002, "invalid QWP egress session options") + .catch(() => undefined); + } catch { + // Preserve the configuration error when transport cleanup also fails. + } + throw error; } let resolve!: (value: QwpServerInfoMessage) => void; let reject!: (error: unknown) => void; @@ -191,7 +211,11 @@ export class QwpEgressSession implements QwpEgressQueryControl { this.resolveServerInfo = resolve; this.rejectServerInfo = reject; this.serverInfoTimer = setTimeout(() => { - this.fail(new Error("timed out waiting for QWP SERVER_INFO")); + const error = new Error("timed out waiting for QWP SERVER_INFO"); + this.fail(error); + void this.connection + .close(1002, "missing QWP SERVER_INFO") + .catch(() => undefined); }, timeout); this.receiveLoop = this.consumeMessages(); } @@ -200,7 +224,7 @@ export class QwpEgressSession implements QwpEgressQueryControl { factory: QwpConnectionFactory, options: QwpEgressSessionOptions = {}, ): Promise { - const timeout = options.serverInfoTimeoutMs ?? 15_000; + const timeout = validateEgressSessionOptions(options); const state: { session?: QwpEgressSession } = {}; const connection = options.reconnect ? await QwpReconnectingEgressConnection.connect( @@ -215,15 +239,22 @@ export class QwpEgressSession implements QwpEgressQueryControl { : undefined, ) : await factory(); - const session = new QwpEgressSession(connection, options); - state.session = session; + let session: QwpEgressSession; try { + session = new QwpEgressSession(connection, options); + state.session = session; await session.ready; return session; } catch (error) { - await session - .close(1002, "missing QWP SERVER_INFO") - .catch(() => undefined); + if (state.session) { + await state.session + .close(1002, "missing QWP SERVER_INFO") + .catch(() => undefined); + } else { + await connection + .close(1002, "invalid QWP egress session") + .catch(() => undefined); + } throw error; } } @@ -290,20 +321,30 @@ export class QwpEgressSession implements QwpEgressQueryControl { return this.send(encodeQwpCredit(requestId, additionalBytes)); } - async close(code = 1000, reason = ""): Promise { - if (this.closing) { - await this.connection.closed; - return; - } + close(code = 1000, reason = ""): Promise { + if (!this.closePromise) this.closePromise = this.closeNow(code, reason); + return this.closePromise; + } + + private async closeNow(code: number, reason: string): Promise { this.closing = true; clearTimeout(this.serverInfoTimer); const error = new QwpEgressSessionClosedError(); this.rejectServerInfo(error); this.active?.fail(error); this.active = undefined; - await this.sendTail; - await this.connection.close(code, reason); - await this.receiveLoop; + let transportClose: Promise; + try { + transportClose = this.connection.close(code, reason); + } catch (closeError) { + transportClose = Promise.reject(closeError); + } + const [, closeResult] = await Promise.allSettled([ + this.sendTail, + transportClose, + this.receiveLoop, + ]); + if (closeResult.status === "rejected") throw closeResult.reason; } private async consumeMessages(): Promise { diff --git a/src/qwp/ingress-session.ts b/src/qwp/ingress-session.ts index d3b9e71..ebf25df 100644 --- a/src/qwp/ingress-session.ts +++ b/src/qwp/ingress-session.ts @@ -94,6 +94,42 @@ export class QwpBatchTooLargeError extends RangeError { } } +function validateIngressSessionOptions( + options: QwpIngressSessionOptions, + connection?: QwpBinaryConnection, +): void { + const timeout = options.ackTimeoutMs ?? 15_000; + if (!Number.isFinite(timeout) || timeout <= 0) { + throw new RangeError("ackTimeoutMs must be a positive finite number"); + } + const localBatchCap = options.maxBatchSizeBytes; + if ( + localBatchCap !== undefined && + (!Number.isSafeInteger(localBatchCap) || localBatchCap <= 0) + ) { + throw new RangeError("maxBatchSizeBytes must be a positive safe integer"); + } + const keepalive = options.durableAckKeepaliveMs; + if ( + keepalive !== undefined && + (!Number.isFinite(keepalive) || keepalive < 0) + ) { + throw new RangeError( + "durableAckKeepaliveMs must be a non-negative finite number", + ); + } + if ( + connection && + keepalive !== undefined && + keepalive > 0 && + !connection.ping + ) { + throw new Error( + "durable ACK keepalive requires a WebSocket transport with PING support", + ); + } +} + /** * Connection-scoped ingress sequencer. * @@ -116,51 +152,39 @@ export class QwpIngressSession { private deltaSymbolsPublished = false; private failure?: Error; private closing = false; + private closePromise?: Promise; private readonly receiveLoop: Promise; constructor( private readonly connection: QwpBinaryConnection, private readonly options: QwpIngressSessionOptions = {}, ) { - if ( - options.reconnect && - !(connection instanceof QwpReconnectingIngressConnection) - ) { - throw new Error( - "ingress reconnect options require QwpIngressSession.connect(factory, options)", - ); - } - const timeout = options.ackTimeoutMs ?? 15_000; - if (!Number.isFinite(timeout) || timeout <= 0) { - throw new RangeError("ackTimeoutMs must be a positive finite number"); - } - const localBatchCap = options.maxBatchSizeBytes; - if ( - localBatchCap !== undefined && - (!Number.isSafeInteger(localBatchCap) || localBatchCap <= 0) - ) { - throw new RangeError("maxBatchSizeBytes must be a positive safe integer"); + try { + if ( + options.reconnect && + !(connection instanceof QwpReconnectingIngressConnection) + ) { + throw new Error( + "ingress reconnect options require QwpIngressSession.connect(factory, options)", + ); + } + validateIngressSessionOptions(options, connection); + } catch (error) { + try { + void connection + .close(1002, "invalid QWP ingress session options") + .catch(() => undefined); + } catch { + // Preserve the configuration error when transport cleanup also fails. + } + throw error; } - this.localMaxBatchSizeBytes = localBatchCap; + this.localMaxBatchSizeBytes = options.maxBatchSizeBytes; for (const entry of connection.ingressSymbolDictionary ?? []) { this.symbolDictionary.addRecovered(entry); } this.publishedMaxSymbolId = this.symbolDictionary.size - 1; this.deltaSymbolsPublished = this.symbolDictionary.size > 0; - const keepalive = options.durableAckKeepaliveMs; - if ( - keepalive !== undefined && - (!Number.isFinite(keepalive) || keepalive < 0) - ) { - throw new RangeError( - "durableAckKeepaliveMs must be a non-negative finite number", - ); - } - if (keepalive !== undefined && keepalive > 0 && !connection.ping) { - throw new Error( - "durable ACK keepalive requires a WebSocket transport with PING support", - ); - } this.receiveLoop = this.consumeMessages(); } @@ -168,6 +192,7 @@ export class QwpIngressSession { factory: QwpConnectionFactory, options: QwpIngressSessionOptions = {}, ): Promise { + validateIngressSessionOptions(options); if (options.replayStore && !options.reconnect) { throw new RangeError("a QWP replayStore requires reconnect options"); } @@ -339,17 +364,27 @@ export class QwpIngressSession { }); } - async close(code = 1000, reason = ""): Promise { - if (this.closing) { - await this.connection.closed; - return; - } + close(code = 1000, reason = ""): Promise { + if (!this.closePromise) this.closePromise = this.closeNow(code, reason); + return this.closePromise; + } + + private async closeNow(code: number, reason: string): Promise { this.closing = true; this.clearDurablePing(); this.rejectAll(new QwpIngressSessionClosedError()); - await this.sendTail; - await this.connection.close(code, reason); - await this.receiveLoop; + let transportClose: Promise; + try { + transportClose = this.connection.close(code, reason); + } catch (error) { + transportClose = Promise.reject(error); + } + const [, closeResult] = await Promise.allSettled([ + this.sendTail, + transportClose, + this.receiveLoop, + ]); + if (closeResult.status === "rejected") throw closeResult.reason; } private async consumeMessages(): Promise { diff --git a/src/qwp/internal/reconnecting-ingress-connection.ts b/src/qwp/internal/reconnecting-ingress-connection.ts index c7688a6..d9d278a 100644 --- a/src/qwp/internal/reconnecting-ingress-connection.ts +++ b/src/qwp/internal/reconnecting-ingress-connection.ts @@ -123,6 +123,7 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { private generation = 0; private sendTail: Promise = Promise.resolve(); private reconnectTask?: Promise; + private storeClosePromise?: Promise; private terminalError?: Error; private cancelBackoff?: () => void; private closing = false; @@ -285,8 +286,11 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { if (connectingCandidate && connectingCandidate !== connection) { await connectingCandidate.close(code, reason).catch(() => undefined); } - await this.store.close(); - this.settleClosed(closeInfo); + try { + await this.closeStore(); + } finally { + this.settleClosed(closeInfo); + } } private async connectLoop( @@ -746,6 +750,7 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { reason: this.terminalError.message, wasClean: false, }); + void this.closeStore().catch(() => undefined); void this.connection ?.close(1011, "QWP reconnect failed") .catch(() => undefined); @@ -756,6 +761,13 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { this.closedSettled = true; this.resolveClosed(info); } + + private closeStore(): Promise { + if (!this.storeClosePromise) { + this.storeClosePromise = Promise.resolve().then(() => this.store.close()); + } + return this.storeClosePromise; + } } function readSymbolDictionaryDelta(payload: Uint8Array) { diff --git a/src/qwp/internal/websocket-connection.ts b/src/qwp/internal/websocket-connection.ts index 3bd7881..679562a 100644 --- a/src/qwp/internal/websocket-connection.ts +++ b/src/qwp/internal/websocket-connection.ts @@ -53,12 +53,24 @@ export interface QwpWebSocketLike { listener: (event: QwpWebSocketCloseEvent) => void, options?: { once?: boolean }, ): void; + /** Optional cleanup hook implemented by browser WebSocket and Node `ws`. */ + removeEventListener?(type: "open", listener: (event: unknown) => void): void; + removeEventListener?( + type: "message", + listener: (event: QwpWebSocketMessageEvent) => void, + ): void; + removeEventListener?(type: "error", listener: (event: unknown) => void): void; + removeEventListener?( + type: "close", + listener: (event: QwpWebSocketCloseEvent) => void, + ): void; } export interface QwpWebSocketOpenOptions { url: string | URL; connectTimeoutMs?: number; sendTimeoutMs?: number; + closeTimeoutMs?: number; completeHandshake: () => QwpHandshakeMetadata; /** Node adapters use this to surface non-101 HTTP responses from `ws`. */ openingFailure?: Promise; @@ -69,6 +81,23 @@ export interface QwpWebSocketOpenOptions { const WEBSOCKET_OPEN = 1; const WEBSOCKET_CLOSED = 3; const BUFFERED_AMOUNT_POLL_MS = 4; +const DEFAULT_TIMEOUT_MS = 15_000; + +export function validateQwpWebSocketTimeouts(options: { + connectTimeoutMs?: number; + sendTimeoutMs?: number; + closeTimeoutMs?: number; +}): void { + for (const [name, value] of [ + ["connectTimeoutMs", options.connectTimeoutMs], + ["sendTimeoutMs", options.sendTimeoutMs], + ["closeTimeoutMs", options.closeTimeoutMs], + ] as const) { + if (value !== undefined && (!Number.isFinite(value) || value <= 0)) { + throw new RangeError(`${name} must be a positive finite number`); + } + } +} async function normalizeBinaryMessage(data: unknown): Promise { if (data instanceof ArrayBuffer) return new Uint8Array(data); @@ -90,18 +119,20 @@ export function openQwpWebSocket( socket: QwpWebSocketLike, options: QwpWebSocketOpenOptions, ): Promise { - const connectTimeoutMs = options.connectTimeoutMs ?? 15_000; - if (!Number.isFinite(connectTimeoutMs) || connectTimeoutMs <= 0) { - return Promise.reject( - new RangeError("connectTimeoutMs must be a positive finite number"), - ); - } - const sendTimeoutMs = options.sendTimeoutMs ?? 15_000; - if (!Number.isFinite(sendTimeoutMs) || sendTimeoutMs <= 0) { - return Promise.reject( - new RangeError("sendTimeoutMs must be a positive finite number"), - ); + try { + validateQwpWebSocketTimeouts(options); + } catch (error) { + try { + if (socket.terminate) socket.terminate(); + else if (socket.readyState !== WEBSOCKET_CLOSED) socket.close(); + } catch { + // Configuration validation remains authoritative. + } + return Promise.reject(error); } + const connectTimeoutMs = options.connectTimeoutMs ?? DEFAULT_TIMEOUT_MS; + const sendTimeoutMs = options.sendTimeoutMs ?? DEFAULT_TIMEOUT_MS; + const closeTimeoutMs = options.closeTimeoutMs ?? DEFAULT_TIMEOUT_MS; const messages = new QwpAsyncQueue(); let resolveClosed!: (info: QwpConnectionCloseInfo) => void; @@ -114,6 +145,10 @@ export function openQwpWebSocket( let sendTail: Promise = Promise.resolve(); let terminalSendError: QwpSendError | undefined; let rejectActiveSend: ((error: QwpSendError) => void) | undefined; + let closeSettled = false; + let closeTask: Promise | undefined; + let cleanupTask: Promise = Promise.resolve(); + let removeSocketListeners = (): void => undefined; const failSends = (error: QwpSendError): QwpSendError => { terminalSendError ??= error; @@ -121,16 +156,85 @@ export function openQwpWebSocket( return terminalSendError; }; - const abortAfterSendFailure = (): void => { - try { - if (socket.terminate) { - socket.terminate(); - } else if (socket.readyState !== WEBSOCKET_CLOSED) { - socket.close(1011, "QWP send failed"); + const settleClosed = (info: QwpConnectionCloseInfo): void => { + if (closeSettled) return; + closeSettled = true; + resolveClosed(info); + if (opened) failSends(new QwpSendClosedError(info)); + removeSocketListeners(); + cleanupTask = (async () => { + let timer: ReturnType | undefined; + const timedOut = await Promise.race([ + messageTail.then( + () => false, + () => false, + ), + new Promise((resolve) => { + timer = setTimeout(() => resolve(true), closeTimeoutMs); + }), + ]); + if (timer) clearTimeout(timer); + if (timedOut) messageTail = Promise.resolve(); + messages.end(); + })(); + }; + + const closeSocket = (code = 1000, reason = ""): Promise => { + if (closeTask) return closeTask; + closeTask = (async () => { + const requestedInfo: QwpConnectionCloseInfo = { + code, + reason, + wasClean: code === 1000, + }; + if (opened) failSends(new QwpSendClosedError(requestedInfo)); + if (socket.readyState === WEBSOCKET_CLOSED) { + settleClosed(requestedInfo); + } else { + try { + socket.close(code, reason); + } catch { + try { + socket.terminate?.(); + } catch { + // The synthetic close below still releases local resources. + } + settleClosed({ + code: 1006, + reason: "QWP WebSocket close failed", + wasClean: false, + }); + } } - } catch { - // The send error remains authoritative if shutdown races the transport. - } + if (!closeSettled) { + let timer: ReturnType | undefined; + const timedOut = await Promise.race([ + closed.then(() => false), + new Promise((resolve) => { + timer = setTimeout(() => resolve(true), closeTimeoutMs); + }), + ]); + if (timer) clearTimeout(timer); + if (timedOut && !closeSettled) { + try { + socket.terminate?.(); + } catch { + // Local state must still settle when forced termination throws. + } + settleClosed({ + code: 1006, + reason: `QWP WebSocket close timed out after ${closeTimeoutMs}ms`, + wasClean: false, + }); + } + } + await cleanupTask; + })(); + return closeTask; + }; + + const abortAfterSendFailure = (): void => { + void closeSocket(1011, "QWP send failed"); }; const sendWithBackpressure = (payload: Uint8Array): Promise => { @@ -233,99 +337,84 @@ export function openQwpWebSocket( return new Promise((resolve, reject) => { const timeout = setTimeout(() => { - if (openingSettled) return; - openingSettled = true; - try { - socket.close(1000, "QWP connection timeout"); - } catch { - // Some implementations throw when close() races an opening handshake. - } - reject( + failOpening( new QwpUpgradeError("QWP WebSocket connection timed out", { kind: QWP_UPGRADE_ERROR_KIND.TIMEOUT, retryable: true, tryNextEndpoint: true, url: options.url, }), + 1000, + "QWP connection timeout", ); }, connectTimeoutMs); - const failOpening = (error: Error): void => { + const failOpening = ( + error: Error, + closeCode = 1000, + closeReason = "QWP upgrade failed", + ): void => { if (openingSettled) return; openingSettled = true; clearTimeout(timeout); + void closeSocket(closeCode, closeReason); reject(error); }; - socket.binaryType = "arraybuffer"; - socket.addEventListener( - "open", - () => { - if (openingSettled) return; - let handshake: QwpHandshakeMetadata; - try { - handshake = Object.freeze({ ...options.completeHandshake() }); - } catch (error) { - openingSettled = true; - clearTimeout(timeout); - try { - socket.close(1000, "QWP upgrade validation failed"); - } catch { - // The validation error is more useful than a close race. + const onOpen = (): void => { + if (openingSettled) return; + let handshake: QwpHandshakeMetadata; + try { + handshake = Object.freeze({ ...options.completeHandshake() }); + } catch (error) { + failOpening( + error instanceof Error + ? error + : new Error("QWP WebSocket upgrade validation failed"), + 1000, + "QWP upgrade validation failed", + ); + return; + } + openingSettled = true; + opened = true; + clearTimeout(timeout); + const connection: QwpBinaryConnection = { + messages, + closed, + handshake, + endpoint: options.url, + send(payload: Uint8Array): Promise { + const sending = sendTail.then(() => sendWithBackpressure(payload)); + sendTail = sending.catch(() => undefined); + return sending; + }, + async close(code = 1000, reason = ""): Promise { + await closeSocket(code, reason); + }, + }; + if (socket.ping) { + connection.ping = async (): Promise => { + if (socket.readyState !== WEBSOCKET_OPEN) { + throw new Error("QWP WebSocket is not open"); } - reject( - error instanceof Error - ? error - : new Error("QWP WebSocket upgrade validation failed"), - ); - return; - } - openingSettled = true; - opened = true; - clearTimeout(timeout); - const connection: QwpBinaryConnection = { - messages, - closed, - handshake, - endpoint: options.url, - send(payload: Uint8Array): Promise { - const sending = sendTail.then(() => sendWithBackpressure(payload)); - sendTail = sending.catch(() => undefined); - return sending; - }, - async close(code = 1000, reason = ""): Promise { - if (socket.readyState === WEBSOCKET_CLOSED) return; - socket.close(code, reason); - await closed; - }, + socket.ping!(); }; - if (socket.ping) { - connection.ping = async (): Promise => { - if (socket.readyState !== WEBSOCKET_OPEN) { - throw new Error("QWP WebSocket is not open"); - } - socket.ping!(); - }; - } - resolve(connection); - }, - { once: true }, - ); + } + resolve(connection); + }; - socket.addEventListener("message", (event) => { + const onMessage = (event: QwpWebSocketMessageEvent): void => { + if (openingSettled && !opened) return; messageTail = messageTail .then(async () => messages.push(await normalizeBinaryMessage(event.data)), ) .catch((error: unknown) => { messages.fail(error); - try { - socket.close(1002, "invalid QWP payload"); - } catch { - // The error still reaches the message iterator when close() fails. - } + void closeSocket(1002, "invalid QWP payload"); }); - }); + }; options.openingFailure?.catch((error: unknown) => { failOpening( @@ -341,7 +430,7 @@ export function openQwpWebSocket( ); }); - socket.addEventListener("error", (event) => { + const onError = (event: unknown): void => { if (opened) { const eventError = (event as { error?: unknown }).error; failSends( @@ -351,6 +440,7 @@ export function openQwpWebSocket( ), ); messages.fail(new Error("QWP WebSocket transport error")); + abortAfterSendFailure(); return; } const opaque = options.opaqueErrors === true; @@ -370,39 +460,57 @@ export function openQwpWebSocket( }, ); failOpening(error); - }); + }; - socket.addEventListener( - "close", - (event) => { - clearTimeout(timeout); - const info = { - code: event.code ?? 1006, - reason: event.reason ?? "", - wasClean: event.wasClean ?? false, - }; - resolveClosed(info); - if (!opened) { - failOpening( - new QwpUpgradeError( - `QWP WebSocket closed during handshake [code=${info.code}, reason=${info.reason}]`, - { - kind: options.opaqueErrors - ? QWP_UPGRADE_ERROR_KIND.OPAQUE - : QWP_UPGRADE_ERROR_KIND.TRANSPORT, - retryable: options.opaqueErrors ? undefined : true, - tryNextEndpoint: options.opaqueErrors ? undefined : true, - url: options.url, - closeCode: info.code, - }, - ), - ); - return; - } - failSends(new QwpSendClosedError(info)); - void messageTail.finally(() => messages.end()); - }, - { once: true }, - ); + const onClose = (event: QwpWebSocketCloseEvent): void => { + clearTimeout(timeout); + const info = { + code: event.code ?? 1006, + reason: event.reason ?? "", + wasClean: event.wasClean ?? false, + }; + settleClosed(info); + if (!opened) { + failOpening( + new QwpUpgradeError( + `QWP WebSocket closed during handshake [code=${info.code}, reason=${info.reason}]`, + { + kind: options.opaqueErrors + ? QWP_UPGRADE_ERROR_KIND.OPAQUE + : QWP_UPGRADE_ERROR_KIND.TRANSPORT, + retryable: options.opaqueErrors ? undefined : true, + tryNextEndpoint: options.opaqueErrors ? undefined : true, + url: options.url, + closeCode: info.code, + }, + ), + ); + return; + } + }; + + removeSocketListeners = (): void => { + try { + socket.removeEventListener?.("open", onOpen); + socket.removeEventListener?.("message", onMessage); + socket.removeEventListener?.("error", onError); + socket.removeEventListener?.("close", onClose); + } catch { + // Transport cleanup must not make connection close reject. + } + }; + try { + socket.binaryType = "arraybuffer"; + socket.addEventListener("open", onOpen, { once: true }); + socket.addEventListener("message", onMessage); + socket.addEventListener("error", onError); + socket.addEventListener("close", onClose, { once: true }); + } catch (error) { + failOpening( + error instanceof Error + ? error + : new Error("failed to configure QWP WebSocket listeners"), + ); + } }); } diff --git a/src/qwp/node.ts b/src/qwp/node.ts index bdf90b8..26e006b 100644 --- a/src/qwp/node.ts +++ b/src/qwp/node.ts @@ -8,6 +8,7 @@ import { QWP_VERSION } from "./core"; import { openQwpWebSocket, QwpWebSocketLike, + validateQwpWebSocketTimeouts, } from "./internal/websocket-connection"; import { createQwpFailoverConnectionFactory } from "./internal/failover"; import { @@ -181,6 +182,7 @@ function connectQwpNodeEndpoint( options: QwpNodeWebSocketOptions, endpoint: string | URL, ): Promise { + validateQwpWebSocketTimeouts(options); const clientMaxVersion = options.maxVersion ?? QWP_VERSION; if ( !Number.isSafeInteger(clientMaxVersion) || @@ -259,6 +261,7 @@ function connectQwpNodeEndpoint( url: endpoint, connectTimeoutMs: options.connectTimeoutMs, sendTimeoutMs: options.sendTimeoutMs, + closeTimeoutMs: options.closeTimeoutMs, openingFailure, completeHandshake: () => { const qwpVersion = parseQwpVersion(upgradeHeaders); diff --git a/src/qwp/sender.ts b/src/qwp/sender.ts index cd58425..df69300 100644 --- a/src/qwp/sender.ts +++ b/src/qwp/sender.ts @@ -716,7 +716,27 @@ export class QwpSender { private async closeNow(): Promise { if (this.closed) return; this.closing = true; - await this.flushTail; + // Let a flush already queued in this turn enter getSession() so it can be + // cancelled through the session instead of making close wait for its ACK. + await Promise.resolve(); + let sessionClose: Promise | undefined; + let sessionFailure: { reason: unknown } | undefined; + if (this.sessionPromise) { + try { + const session = await this.sessionPromise; + try { + sessionClose = session.close(); + } catch (error) { + sessionClose = Promise.reject(error); + } + } catch (error) { + sessionFailure = { reason: error }; + } + } + const [, closeResult] = await Promise.allSettled([ + this.flushTail, + sessionClose ?? Promise.resolve(), + ]); if (this.pendingRowCount > 0 || this.currentRow.size > 0) { this.log( "warn", @@ -724,10 +744,8 @@ export class QwpSender { ); } this.closed = true; - if (this.sessionPromise) { - const session = await this.sessionPromise; - await session.close(); - } + if (sessionFailure) throw sessionFailure.reason; + if (closeResult.status === "rejected") throw closeResult.reason; } private fixedDecimalColumn( diff --git a/src/qwp/transport.ts b/src/qwp/transport.ts index 5a0a69a..72ca072 100644 --- a/src/qwp/transport.ts +++ b/src/qwp/transport.ts @@ -299,6 +299,8 @@ export interface QwpWebSocketConnectOptions { connectTimeoutMs?: number; /** Maximum time a send may remain queued by the WebSocket. Defaults to 15s. */ sendTimeoutMs?: number; + /** Maximum time allowed for a graceful WebSocket close. Defaults to 15s. */ + closeTimeoutMs?: number; } export type QwpConnectionFactory = () => Promise; diff --git a/test/qwp/egress.test.ts b/test/qwp/egress.test.ts index d3dd8c6..83b5b50 100644 --- a/test/qwp/egress.test.ts +++ b/test/qwp/egress.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { decodeQwpEgressMessage, encodeQwpFrame, @@ -198,7 +198,10 @@ class FakeConnection implements QwpBinaryConnection { private readonly resolveClosed: (info: QwpConnectionCloseInfo) => void; readonly messages = this.incoming; readonly sent: Uint8Array[] = []; + readonly closeCalls: { code: number; reason: string }[] = []; readonly closed: Promise; + onSend?: (payload: Uint8Array) => Promise; + onClose?: () => void; constructor() { let resolve!: (info: QwpConnectionCloseInfo) => void; @@ -210,10 +213,12 @@ class FakeConnection implements QwpBinaryConnection { send(payload: Uint8Array): Promise { this.sent.push(payload.slice()); - return Promise.resolve(); + return this.onSend?.(payload) ?? Promise.resolve(); } close(code = 1000, reason = ""): Promise { + this.closeCalls.push({ code, reason }); + this.onClose?.(); this.incoming.end(); this.resolveClosed({ code, reason, wasClean: true }); return Promise.resolve(); @@ -284,6 +289,65 @@ describe("QWP result batch decoder", () => { }); describe("QwpEgressSession", () => { + it("validates SERVER_INFO timeouts before invoking its factory", async () => { + let factoryCalls = 0; + await expect( + QwpEgressSession.connect( + async () => { + factoryCalls++; + return new FakeConnection(); + }, + { serverInfoTimeoutMs: 0 }, + ), + ).rejects.toThrow("serverInfoTimeoutMs must be a positive finite number"); + expect(factoryCalls).toBe(0); + }); + + it("closes the transport when SERVER_INFO does not arrive", async () => { + vi.useFakeTimers(); + try { + const connection = new FakeConnection(); + const session = new QwpEgressSession(connection, { + serverInfoTimeoutMs: 25, + }); + const ready = session.ready.catch((error: unknown) => error); + + await vi.advanceTimersByTimeAsync(25); + await expect(ready).resolves.toEqual( + expect.objectContaining({ + message: "timed out waiting for QWP SERVER_INFO", + }), + ); + expect(connection.closeCalls).toEqual([ + { code: 1002, reason: "missing QWP SERVER_INFO" }, + ]); + await expect(session.closed).resolves.toMatchObject({ code: 1002 }); + expect(vi.getTimerCount()).toBe(0); + } finally { + vi.useRealTimers(); + } + }); + + it("close interrupts an egress request whose send has not settled", async () => { + const connection = new FakeConnection(); + const session = new QwpEgressSession(connection); + connection.receive(serverInfo()); + await session.ready; + let rejectSend!: (error: Error) => void; + connection.onSend = () => + new Promise((_resolve, reject) => { + rejectSend = reject; + }); + connection.onClose = () => rejectSend(new Error("transport closed")); + const querying = session.query("select 1").catch((error: unknown) => error); + await vi.waitFor(() => expect(connection.sent).toHaveLength(1)); + + await expect(session.close()).resolves.toBeUndefined(); + await expect(querying).resolves.toEqual( + expect.objectContaining({ message: "transport closed" }), + ); + }); + it("waits for SERVER_INFO and streams a typed query result", async () => { const connection = new FakeConnection(); const session = new QwpEgressSession(connection); diff --git a/test/qwp/reconnect.test.ts b/test/qwp/reconnect.test.ts index 50ea5d9..3fa2f02 100644 --- a/test/qwp/reconnect.test.ts +++ b/test/qwp/reconnect.test.ts @@ -21,6 +21,8 @@ import { QwpEgressReplayRequiredError, QwpEgressSession, QwpIngressSession, + QwpIngressReplayRecord, + QwpIngressReplayStore, QwpHandshakeMetadata, QwpSymbolDictionary, QwpTableBuffer, @@ -161,6 +163,32 @@ class FakeConnection implements QwpBinaryConnection { } } +class TrackingReplayStore implements QwpIngressReplayStore { + readonly records = new Map(); + closeCount = 0; + + async load(): Promise { + return Array.from(this.records, ([frameSequence, payload]) => ({ + frameSequence, + payload, + })); + } + + async append(record: QwpIngressReplayRecord): Promise { + this.records.set(record.frameSequence, record.payload.slice()); + } + + async acknowledgeThrough(frameSequence: bigint): Promise { + for (const sequence of this.records.keys()) { + if (sequence <= frameSequence) this.records.delete(sequence); + } + } + + async close(): Promise { + this.closeCount++; + } +} + describe("QWP endpoint failover", () => { it("walks all endpoints and rotates away from the last successful one", async () => { const attempts: string[] = []; @@ -386,6 +414,7 @@ describe("QWP ingress reconnect and replay", () => { it("fails pending sends with a typed reconnect exhaustion error", async () => { const first = new FakeConnection("primary"); + const replayStore = new TrackingReplayStore(); let factoryCalls = 0; const session = await QwpIngressSession.connect( async () => { @@ -397,6 +426,7 @@ describe("QWP ingress reconnect and replay", () => { }); }, { + replayStore, reconnect: { maxAttempts: 2, initialBackoffMs: 0, @@ -410,7 +440,9 @@ describe("QWP ingress reconnect and replay", () => { await expect(pending).rejects.toBeInstanceOf(QwpReconnectExhaustedError); expect(factoryCalls).toBe(3); + await vi.waitFor(() => expect(replayStore.closeCount).toBe(1)); await session.close(); + expect(replayStore.closeCount).toBe(1); }); it("reconnects and replays a transient ingress NACK without advancing", async () => { diff --git a/test/qwp/sender.test.ts b/test/qwp/sender.test.ts index 735e61e..3f1a7dd 100644 --- a/test/qwp/sender.test.ts +++ b/test/qwp/sender.test.ts @@ -51,6 +51,25 @@ class RecordingSession implements QwpSenderSession { } } +class ClosingUnblocksSession extends RecordingSession { + private rejectSend?: (error: Error) => void; + + sendTables( + tables: readonly QwpTableBuffer[], + options?: QwpIngressEncodeOptions, + ): Promise { + this.sends.push({ tables, options }); + return new Promise((_resolve, reject) => { + this.rejectSend = reject; + }); + } + + async close(): Promise { + this.closeCount++; + this.rejectSend?.(new Error("session closed")); + } +} + function column(table: QwpTableBuffer, name: string) { const result = table.columns.find((candidate) => candidate.name === name); if (!result) throw new Error(`missing column '${name}'`); @@ -58,6 +77,20 @@ function column(table: QwpTableBuffer, name: string) { } describe("QWP high-level sender", () => { + it("closes its session before waiting for an in-flight flush", async () => { + const session = new ClosingUnblocksSession(); + const sender = new QwpSender(async () => session, { autoFlush: false }); + await sender.table("events").longColumn("value", 42n).atNow(); + const flushing = sender.flush().catch((error: unknown) => error); + await Promise.resolve(); + + await expect(sender.close()).resolves.toBeUndefined(); + await expect(flushing).resolves.toEqual( + expect.objectContaining({ message: "session closed" }), + ); + expect(session.closeCount).toBe(1); + }); + it("uses the existing Sender fluent API and preserves an unfinished row", async () => { const session = new RecordingSession(); const sender = new QwpSender(async () => session, { autoFlush: false }); diff --git a/test/qwp/session.test.ts b/test/qwp/session.test.ts index 5910f2b..d5db420 100644 --- a/test/qwp/session.test.ts +++ b/test/qwp/session.test.ts @@ -16,6 +16,7 @@ import { QwpByteWriter, QwpIngressNackError, QwpIngressSession, + QwpIngressSessionClosedError, QwpTableBuffer, QwpSendClosedError, QwpSendTimeoutError, @@ -39,6 +40,20 @@ class FakeWebSocket { this.listeners.set(type, listeners); } + removeEventListener(type: string, listener: Listener): void { + const listeners = this.listeners.get(type); + if (!listeners) return; + const index = listeners.indexOf(listener); + if (index >= 0) listeners.splice(index, 1); + if (listeners.length === 0) this.listeners.delete(type); + } + + listenerCount(): number { + let count = 0; + for (const listeners of this.listeners.values()) count += listeners.length; + return count; + } + send(payload: Uint8Array): void { this.sent.push(payload.slice()); this.onSend?.(payload); @@ -73,6 +88,21 @@ class FakeWebSocket { } } +class FakeStuckCloseWebSocket extends FakeWebSocket { + close(code?: number, reason?: string): void { + this.closeCalls.push({ code, reason }); + } +} + +class FakeStuckCloseNodeWebSocket extends FakeStuckCloseWebSocket { + terminateCalls = 0; + + terminate(): void { + this.terminateCalls++; + this.readyState = 3; + } +} + class FakeBackpressuredWebSocket extends FakeWebSocket { send(payload: Uint8Array): void { this.bufferedAmount += payload.byteLength; @@ -154,6 +184,34 @@ function writeIngressTables( } describe("QWP WebSocket adapters", () => { + it.each(["browser", "node"] as const)( + "validates %s timeouts before creating a WebSocket", + async (runtime) => { + let factoryCalls = 0; + const factory = (): QwpWebSocketLike => { + factoryCalls++; + return asQwpSocket(new FakeWebSocket()); + }; + const connecting = + runtime === "browser" + ? connectQwpBrowserWebSocket({ + url: "ws://localhost:9000/write/v4", + closeTimeoutMs: 0, + webSocketFactory: factory, + }) + : connectQwpNodeWebSocket({ + url: "ws://localhost:9000/write/v4", + closeTimeoutMs: 0, + webSocketFactory: factory, + }); + + await expect(connecting).rejects.toThrow( + "closeTimeoutMs must be a positive finite number", + ); + expect(factoryCalls).toBe(0); + }, + ); + it("buffers browser messages until a consumer is attached", async () => { const socket = new FakeWebSocket(); const connecting = connectQwpBrowserWebSocket({ @@ -371,6 +429,8 @@ describe("QWP WebSocket adapters", () => { statusCode: undefined, serverRole: undefined, } satisfies Partial); + await vi.waitFor(() => expect(socket.listenerCount()).toBe(0)); + expect(socket.closeCalls).toHaveLength(1); }); it("classifies Node opening errors as retriable transport failures", async () => { @@ -413,6 +473,87 @@ describe("QWP WebSocket adapters", () => { } }); + it("bounds browser close when the peer never emits a close event", async () => { + vi.useFakeTimers(); + try { + const socket = new FakeStuckCloseWebSocket(); + const connecting = connectQwpBrowserWebSocket({ + url: "ws://localhost:9000/write/v4", + closeTimeoutMs: 25, + webSocketFactory: () => asQwpSocket(socket), + }); + socket.open(); + const connection = await connecting; + + const closing = connection.close(1000, "client shutdown"); + await vi.advanceTimersByTimeAsync(25); + await expect(closing).resolves.toBeUndefined(); + await expect(connection.closed).resolves.toEqual({ + code: 1006, + reason: "QWP WebSocket close timed out after 25ms", + wasClean: false, + }); + expect(socket.listenerCount()).toBe(0); + } finally { + vi.useRealTimers(); + } + }); + + it("bounds cleanup when a browser Blob conversion never settles", async () => { + vi.useFakeTimers(); + try { + class NeverSettlingBlob extends Blob { + arrayBuffer(): Promise { + return new Promise(() => undefined); + } + } + const socket = new FakeWebSocket(); + const connecting = connectQwpBrowserWebSocket({ + url: "ws://localhost:9000/write/v4", + closeTimeoutMs: 25, + webSocketFactory: () => asQwpSocket(socket), + }); + socket.open(); + const connection = await connecting; + const next = connection.messages[Symbol.asyncIterator]().next(); + socket.message(new NeverSettlingBlob()); + await vi.advanceTimersByTimeAsync(0); + + const closing = connection.close(); + await vi.advanceTimersByTimeAsync(25); + await expect(closing).resolves.toBeUndefined(); + await expect(next).resolves.toEqual({ value: undefined, done: true }); + expect(socket.listenerCount()).toBe(0); + } finally { + vi.useRealTimers(); + } + }); + + it("terminates a stuck Node WebSocket after the close deadline", async () => { + vi.useFakeTimers(); + try { + const socket = new FakeStuckCloseNodeWebSocket(); + const connecting = connectQwpNodeWebSocket({ + url: "ws://localhost:9000/write/v4", + closeTimeoutMs: 25, + webSocketFactory: (_url, options) => { + options.onUpgrade({}); + return asQwpSocket(socket); + }, + }); + socket.open(); + const connection = await connecting; + + const closing = connection.close(); + await vi.advanceTimersByTimeAsync(25); + await closing; + expect(socket.terminateCalls).toBe(1); + expect(socket.listenerCount()).toBe(0); + } finally { + vi.useRealTimers(); + } + }); + it("serializes browser sends until buffered bytes drain", async () => { vi.useFakeTimers(); try { @@ -506,6 +647,48 @@ describe("QWP WebSocket adapters", () => { } }); + it("close interrupts a backpressured send and clears its timers", async () => { + vi.useFakeTimers(); + try { + const socket = new FakeBackpressuredWebSocket(); + const connecting = connectQwpBrowserWebSocket({ + url: "ws://localhost:9000/write/v4", + sendTimeoutMs: 60_000, + webSocketFactory: () => asQwpSocket(socket), + }); + socket.open(); + const connection = await connecting; + const sending = connection + .send(Uint8Array.of(1)) + .catch((error: unknown) => error); + await vi.advanceTimersByTimeAsync(0); + + await expect(connection.close()).resolves.toBeUndefined(); + await expect(sending).resolves.toBeInstanceOf(QwpSendClosedError); + expect(vi.getTimerCount()).toBe(0); + expect(socket.listenerCount()).toBe(0); + } finally { + vi.useRealTimers(); + } + }); + + it("settles and cleans up after a post-upgrade transport error", async () => { + const socket = new FakeWebSocket(); + const connecting = connectQwpBrowserWebSocket({ + url: "ws://localhost:9000/write/v4", + webSocketFactory: () => asQwpSocket(socket), + }); + socket.open(); + const connection = await connecting; + const next = connection.messages[Symbol.asyncIterator]().next(); + + socket.error(); + await expect(next).rejects.toThrow("QWP WebSocket transport error"); + await expect(connection.closed).resolves.toMatchObject({ code: 1011 }); + await connection.close(); + expect(socket.listenerCount()).toBe(0); + }); + it("awaits Node send callbacks and preserves send order", async () => { const socket = new FakeCallbackWebSocket(); const connecting = connectQwpNodeWebSocket({ @@ -551,6 +734,48 @@ describe("QWP WebSocket adapters", () => { }); describe("QwpIngressSession", () => { + it("validates session timeouts before invoking its connection factory", async () => { + let factoryCalls = 0; + await expect( + QwpIngressSession.connect( + async () => { + factoryCalls++; + throw new Error("must not connect"); + }, + { ackTimeoutMs: Number.NaN }, + ), + ).rejects.toThrow("ackTimeoutMs must be a positive finite number"); + expect(factoryCalls).toBe(0); + }); + + it("close aborts a send blocked by browser backpressure", async () => { + vi.useFakeTimers(); + try { + const socket = new FakeBackpressuredWebSocket(); + const connecting = connectQwpBrowserWebSocket({ + url: "ws://localhost:9000/write/v4", + sendTimeoutMs: 60_000, + webSocketFactory: () => asQwpSocket(socket), + }); + socket.open(); + const session = new QwpIngressSession(await connecting, { + ackTimeoutMs: 60_000, + }); + const sending = session + .sendFrame(Uint8Array.of(1)) + .catch((error: unknown) => error); + await vi.advanceTimersByTimeAsync(0); + + await expect(session.close()).resolves.toBeUndefined(); + await expect(sending).resolves.toBeInstanceOf( + QwpIngressSessionClosedError, + ); + expect(vi.getTimerCount()).toBe(0); + } finally { + vi.useRealTimers(); + } + }); + it("rejects an oversized batch locally without consuming its sequence", async () => { const socket = new FakeWebSocket(); const connecting = connectQwpBrowserWebSocket({ @@ -721,6 +946,7 @@ describe("QwpIngressSession", () => { durableAckKeepaliveMs: 25, }), ).toThrow(/PING support/); + expect(socket.closeCalls).toHaveLength(1); await connection.close(); }); From 331e6a486fcdc2d813d1da4cfa531ed74c03800e Mon Sep 17 00:00:00 2001 From: glasstiger Date: Mon, 17 Aug 2026 23:40:32 +0100 Subject: [PATCH 018/265] feat(qwp): add zstd egress decompression --- README.md | 28 ++++++ THIRD_PARTY_NOTICES.md | 23 +++++ package.json | 2 + pnpm-lock.yaml | 8 ++ src/qwp/core/egress.ts | 5 +- src/qwp/core/index.ts | 1 + src/qwp/core/result-batch.ts | 12 +-- src/qwp/core/zstd.ts | 189 +++++++++++++++++++++++++++++++++++ src/qwp/node.ts | 49 ++++++++- test/qwp/browser.e2e.ts | 38 +++++++ test/qwp/egress.test.ts | 116 ++++++++++++++++++++- test/qwp/session.test.ts | 84 ++++++++++++++++ 12 files changed, 544 insertions(+), 11 deletions(-) create mode 100644 THIRD_PARTY_NOTICES.md create mode 100644 src/qwp/core/zstd.ts diff --git a/README.md b/README.md index 637827e..50465eb 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,34 @@ await sender.flush(); await sender.close(); ``` +### Zstd-compressed QWP egress + +Node.js egress clients can opt into compressed result batches during the +WebSocket upgrade. Raw batches remain the default for compatibility. + +```typescript +import { connectQwpNodeEgress } from "@questdb/nodejs-client/qwp/node"; + +const session = await connectQwpNodeEgress({ + url: "ws://127.0.0.1:9000/read/v1", + compression: "zstd", + compressionLevel: 3, +}); +try { + const query = await session.query("select * from trades"); + for await (const batch of query) { + for (const row of batch.rows()) console.log(row); + } + await query.completion; +} finally { + await session.close(); +} +``` + +Zstd decoding is also included in the browser entry point. Browsers cannot set +the `X-QWP-Accept-Encoding` upgrade header themselves, so a same-origin reverse +proxy must add it when browser clients should opt into compression. + ### Authentication and secure connection #### Username and password authentication with HTTP transport diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..9d10e92 --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,23 @@ +# Third-party notices + +This product bundles `fzstd` 0.1.1, which is available under the MIT License: + +Copyright (c) 2020 Arjun Barrett + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/package.json b/package.json index 5e29f0a..0c58e1f 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "preview:docs": "serve docs" }, "files": [ + "THIRD_PARTY_NOTICES.md", "dist/cjs", "dist/es" ], @@ -79,6 +80,7 @@ "@types/ws": "^8.18.1", "bunchee": "^6.5.1", "eslint": "^9.26.0", + "fzstd": "0.1.1", "playwright": "^1.62.1", "prettier": "^3.5.3", "serve": "^14.2.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1415f3e..e026261 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -33,6 +33,9 @@ importers: eslint: specifier: ^9.26.0 version: 9.26.0 + fzstd: + specifier: 0.1.1 + version: 0.1.1 playwright: specifier: ^1.62.1 version: 1.62.1 @@ -1422,6 +1425,9 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + fzstd@0.1.1: + resolution: {integrity: sha512-dkuVSOKKwh3eas5VkJy1AW1vFpet8TA/fGmVA5krThl8YcOVE/8ZIoEA1+U1vEn5ckxxhLirSdY837azmbaNHA==} + get-caller-file@2.0.5: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} @@ -3813,6 +3819,8 @@ snapshots: function-bind@1.1.2: {} + fzstd@0.1.1: {} + get-caller-file@2.0.5: {} get-east-asian-width@1.3.0: {} diff --git a/src/qwp/core/egress.ts b/src/qwp/core/egress.ts index d1b8ae6..d3fb438 100644 --- a/src/qwp/core/egress.ts +++ b/src/qwp/core/egress.ts @@ -39,7 +39,10 @@ export interface QwpResultBatchMessage extends QwpFrameHeader { kind: "result-batch"; requestId: bigint; batchSequence: bigint; - /** Delta dictionary and columnar table block; decoded by the batch decoder. */ + /** + * Raw or Zstd-compressed delta dictionary and columnar table block; decoded + * by the batch decoder according to the frame flags. + */ body: Uint8Array; } diff --git a/src/qwp/core/index.ts b/src/qwp/core/index.ts index 801a3bc..a856170 100644 --- a/src/qwp/core/index.ts +++ b/src/qwp/core/index.ts @@ -10,3 +10,4 @@ export * from "./result-batch"; export * from "./symbol-dictionary"; export * from "./table"; export * from "./varint"; +export * from "./zstd"; diff --git a/src/qwp/core/result-batch.ts b/src/qwp/core/result-batch.ts index fb0766e..be89431 100644 --- a/src/qwp/core/result-batch.ts +++ b/src/qwp/core/result-batch.ts @@ -13,6 +13,7 @@ import { import { QwpResultBatchMessage } from "./egress"; import { QwpProtocolError } from "./errors"; import { readQwpVarint } from "./varint"; +import { decompressQwpZstdFrame } from "./zstd"; const MAX_ARRAY_DIMENSION_LENGTH = (1 << 28) - 1; const MAX_ARRAY_ELEMENTS = 268_435_327; @@ -353,11 +354,6 @@ export class QwpResultBatchDecoder { } decode(message: QwpResultBatchMessage): QwpResultBatch { - if ((message.flags & QWP_FLAG_ZSTD) !== 0) { - throw new QwpProtocolError( - "zstd-compressed QWP result batches are not supported by this runtime-neutral decoder", - ); - } if (message.tableCount !== 1) { throw new QwpProtocolError( `RESULT_BATCH must contain exactly one table, got ${message.tableCount}`, @@ -369,7 +365,11 @@ export class QwpResultBatchDecoder { ); } - const reader = new QwpByteReader(message.body); + const body = + (message.flags & QWP_FLAG_ZSTD) !== 0 + ? decompressQwpZstdFrame(message.body) + : message.body; + const reader = new QwpByteReader(body); const deltaMode = (message.flags & QWP_FLAG_DELTA_SYMBOL_DICTIONARY) !== 0; if (deltaMode) this.readDeltaDictionary(reader); diff --git a/src/qwp/core/zstd.ts b/src/qwp/core/zstd.ts new file mode 100644 index 0000000..018b63d --- /dev/null +++ b/src/qwp/core/zstd.ts @@ -0,0 +1,189 @@ +import { Decompress } from "fzstd"; +import { QwpProtocolError } from "./errors"; + +/** Matches the Java client's per-connection decompression safety cap. */ +export const QWP_MAX_ZSTD_DECOMPRESSED_SIZE = 64 * 1024 * 1024; + +const ZSTD_MAGIC = 0xfd2fb528; +const ZSTD_MAX_BLOCK_SIZE = 128 * 1024; + +interface ZstdFrameInfo { + readonly contentSize: number; + readonly dataOffset: number; + readonly checksum: boolean; +} + +function requireAvailable( + bytes: Uint8Array, + offset: number, + length: number, + label: string, +): void { + if (offset < 0 || length < 0 || offset + length > bytes.byteLength) { + throw new QwpProtocolError(`truncated zstd ${label}`); + } +} + +function readLittleEndian( + bytes: Uint8Array, + offset: number, + length: number, +): bigint { + requireAvailable(bytes, offset, length, "frame header"); + let value = 0n; + for (let index = 0; index < length; index++) { + value |= BigInt(bytes[offset + index]) << BigInt(index * 8); + } + return value; +} + +function inspectZstdFrame(frame: Uint8Array): ZstdFrameInfo { + if (frame.byteLength > QWP_MAX_ZSTD_DECOMPRESSED_SIZE) { + throw new QwpProtocolError( + `zstd frame size ${frame.byteLength} exceeds client cap ${QWP_MAX_ZSTD_DECOMPRESSED_SIZE}`, + ); + } + requireAvailable(frame, 0, 5, "frame header"); + if (Number(readLittleEndian(frame, 0, 4)) !== ZSTD_MAGIC) { + throw new QwpProtocolError("invalid zstd frame magic"); + } + + const descriptor = frame[4]; + if ((descriptor & 0x08) !== 0) { + throw new QwpProtocolError("zstd frame uses its reserved descriptor bit"); + } + const singleSegment = (descriptor & 0x20) !== 0; + const checksum = (descriptor & 0x04) !== 0; + const dictionaryIdFlag = descriptor & 0x03; + const contentSizeFlag = descriptor >>> 6; + let offset = 5; + + let windowSize: bigint | undefined; + if (!singleSegment) { + requireAvailable(frame, offset, 1, "window descriptor"); + const windowDescriptor = frame[offset++]; + const base = 1n << BigInt(10 + (windowDescriptor >>> 3)); + windowSize = base + (base >> 3n) * BigInt(windowDescriptor & 0x07); + } + + const dictionaryIdSize = dictionaryIdFlag === 3 ? 4 : dictionaryIdFlag; + requireAvailable(frame, offset, dictionaryIdSize, "dictionary ID"); + if (dictionaryIdSize !== 0) { + throw new QwpProtocolError( + "zstd frames using an external dictionary are not supported", + ); + } + offset += dictionaryIdSize; + + const contentSizeBytes = + contentSizeFlag === 0 ? (singleSegment ? 1 : 0) : 1 << contentSizeFlag; + if (contentSizeBytes === 0) { + throw new QwpProtocolError( + "zstd frame is missing its declared content size", + ); + } + let contentSize = readLittleEndian(frame, offset, contentSizeBytes); + offset += contentSizeBytes; + if (contentSizeFlag === 1) contentSize += 256n; + + const cap = BigInt(QWP_MAX_ZSTD_DECOMPRESSED_SIZE); + if (contentSize > cap) { + throw new QwpProtocolError( + `zstd frame content size ${contentSize} exceeds client cap ${cap}`, + ); + } + if (windowSize !== undefined && windowSize > cap) { + throw new QwpProtocolError( + `zstd frame window size ${windowSize} exceeds client cap ${cap}`, + ); + } + return { contentSize: Number(contentSize), dataOffset: offset, checksum }; +} + +function validateSingleZstdFrame(frame: Uint8Array, info: ZstdFrameInfo): void { + let offset = info.dataOffset; + let lastBlock = false; + while (!lastBlock) { + requireAvailable(frame, offset, 3, "block header"); + const header = + frame[offset] | (frame[offset + 1] << 8) | (frame[offset + 2] << 16); + offset += 3; + lastBlock = (header & 1) !== 0; + const blockType = (header >>> 1) & 0x03; + if (blockType === 3) { + throw new QwpProtocolError("zstd frame contains a reserved block type"); + } + const blockSize = header >>> 3; + if (blockSize > ZSTD_MAX_BLOCK_SIZE) { + throw new QwpProtocolError( + `zstd block size ${blockSize} exceeds format maximum ${ZSTD_MAX_BLOCK_SIZE}`, + ); + } + const encodedSize = blockType === 1 ? 1 : blockSize; + requireAvailable(frame, offset, encodedSize, "block body"); + offset += encodedSize; + } + if (info.checksum) { + requireAvailable(frame, offset, 4, "content checksum"); + offset += 4; + } + if (offset !== frame.byteLength) { + throw new QwpProtocolError( + `zstd body must contain exactly one frame [frameBytes=${offset}, actual=${frame.byteLength}]`, + ); + } +} + +function frameWithProbeContentSize( + frame: Uint8Array, + info: ZstdFrameInfo, +): Uint8Array { + // fzstd uses the frame content size as its output allocation and otherwise + // truncates a corrupt frame whose real output is larger. Reframe the same + // blocks with one extra byte of capacity so our callback can detect that + // overflow. A single-segment window of expected size + 1 is sufficient for + // all valid frames because no match can refer before the decoded content. + const headerSize = 4 + 1 + 8; + const blocks = frame.subarray(info.dataOffset); + const probe = new Uint8Array(headerSize + blocks.byteLength); + probe.set(frame.subarray(0, 4)); + probe[4] = 0xe0 | (info.checksum ? 0x04 : 0); + let size = BigInt(info.contentSize + 1); + for (let index = 0; index < 8; index++) { + probe[5 + index] = Number(size & 0xffn); + size >>= 8n; + } + probe.set(blocks, headerSize); + return probe; +} + +/** Decompresses the single bounded Zstd frame carried by a RESULT_BATCH. */ +export function decompressQwpZstdFrame(frame: Uint8Array): Uint8Array { + const info = inspectZstdFrame(frame); + validateSingleZstdFrame(frame, info); + const probeFrame = frameWithProbeContentSize(frame, info); + const output = new Uint8Array(info.contentSize); + let written = 0; + try { + const decoder = new Decompress((chunk) => { + if (written + chunk.byteLength > output.byteLength) { + throw new QwpProtocolError( + `zstd output exceeds declared content size ${info.contentSize}`, + ); + } + output.set(chunk, written); + written += chunk.byteLength; + }); + decoder.push(probeFrame, true); + } catch (error) { + if (error instanceof QwpProtocolError) throw error; + const detail = error instanceof Error ? `: ${error.message}` : ""; + throw new QwpProtocolError(`zstd decompression failed${detail}`); + } + if (written !== info.contentSize) { + throw new QwpProtocolError( + `zstd decompressed size ${written} does not match frame content size ${info.contentSize}`, + ); + } + return output; +} diff --git a/src/qwp/node.ts b/src/qwp/node.ts index 26e006b..1b5f8f5 100644 --- a/src/qwp/node.ts +++ b/src/qwp/node.ts @@ -160,6 +160,51 @@ export interface QwpNodeIngressOptions extends QwpNodeWebSocketOptions { storeAndForward?: QwpNodeFileReplayStoreOptions; } +export type QwpEgressCompression = "raw" | "zstd" | "auto"; + +export interface QwpNodeEgressOptions extends QwpNodeWebSocketOptions { + /** + * Requests Zstd-compressed result batches. The default is `raw`, which + * preserves compatibility with servers that predate QWP compression. + * `auto` currently advertises the same ordered preference as `zstd`. + */ + compression?: QwpEgressCompression; + /** Zstd level hint sent to the server. Must be between 1 and 22. */ + compressionLevel?: number; +} + +function egressTransportOptions( + options: QwpNodeEgressOptions, +): QwpNodeWebSocketOptions { + const { compression, compressionLevel = 1, ...transport } = options; + const preference = compression ?? "raw"; + if (preference !== "raw" && preference !== "zstd" && preference !== "auto") { + throw new RangeError("compression must be one of raw, zstd, or auto"); + } + if ( + !Number.isSafeInteger(compressionLevel) || + compressionLevel < 1 || + compressionLevel > 22 + ) { + throw new RangeError( + "compressionLevel must be an integer between 1 and 22", + ); + } + + // Keep the low-level headers escape hatch backwards compatible unless the + // typed compression option was explicitly selected. + if (compression === undefined) return transport; + + const headers = { ...transport.headers }; + for (const name of Object.keys(headers)) { + if (name.toLowerCase() === "x-qwp-accept-encoding") delete headers[name]; + } + if (preference !== "raw") { + headers["X-QWP-Accept-Encoding"] = `zstd;level=${compressionLevel},raw`; + } + return { ...transport, headers }; +} + /** Opens a Node QWP WebSocket with the upgrade headers required by QuestDB. */ export function connectQwpNodeWebSocket( options: QwpNodeWebSocketOptions, @@ -365,11 +410,11 @@ export async function connectQwpNodeSender( /** Opens a Node WebSocket and waits for the egress SERVER_INFO handshake. */ export async function connectQwpNodeEgress( - options: QwpNodeWebSocketOptions, + options: QwpNodeEgressOptions, sessionOptions: QwpEgressSessionOptions = {}, ): Promise { return QwpEgressSession.connect( - createQwpNodeConnectionFactory(options), + createQwpNodeConnectionFactory(egressTransportOptions(options)), sessionOptions, ); } diff --git a/test/qwp/browser.e2e.ts b/test/qwp/browser.e2e.ts index d6372bc..1a5f52f 100644 --- a/test/qwp/browser.e2e.ts +++ b/test/qwp/browser.e2e.ts @@ -115,6 +115,44 @@ describe("QWP in a real browser against QuestDB", () => { await container?.stop(); }); + it("decompresses a Zstd result batch in the browser bundle", async () => { + const page = await browser.newPage(); + try { + await page.goto(assetUrl); + const result = await page.evaluate(async (moduleUrl) => { + const importModule = new Function("url", "return import(url)") as ( + url: string, + ) => Promise>; + const qwp = await importModule(moduleUrl); + const compressedBody = Uint8Array.from([ + 40, 181, 47, 253, 96, 153, 0, 157, 0, 0, 96, 0, 0, 0, 100, 1, 1, 120, + 4, 0, 42, 0, 0, 1, 0, 138, 171, 46, 9, + ]); + const payload = new qwp.QwpByteWriter() + .writeUint8(qwp.QWP_EGRESS_MESSAGE.RESULT_BATCH) + .writeBigUint64(7n); + qwp.writeQwpVarint(payload, 0); + payload.writeBytes(compressedBody); + const frame = qwp.encodeQwpFrame( + payload.toUint8Array(), + qwp.QWP_FLAG_DELTA_SYMBOL_DICTIONARY | qwp.QWP_FLAG_ZSTD, + 1, + ); + const message = qwp.decodeQwpEgressMessage(frame); + const batch = new qwp.QwpResultBatchDecoder().decode(message); + return { + requestId: String(batch.requestId), + rowCount: batch.rowCount, + lastValue: batch.get(99, 0), + }; + }, assetUrl); + + expect(result).toEqual({ requestId: "7", rowCount: 100, lastValue: 42 }); + } finally { + await page.close(); + } + }); + it("authenticates ingress and egress with the browser session cookie", async () => { const context = await browser.newContext({ bypassCSP: true }); const page = await context.newPage(); diff --git a/test/qwp/egress.test.ts b/test/qwp/egress.test.ts index 83b5b50..8edc0c3 100644 --- a/test/qwp/egress.test.ts +++ b/test/qwp/egress.test.ts @@ -8,6 +8,8 @@ import { QWP_EGRESS_MESSAGE, QWP_FLAG_DELTA_SYMBOL_DICTIONARY, QWP_FLAG_GORILLA, + QWP_FLAG_ZSTD, + QWP_MAX_ZSTD_DECOMPRESSED_SIZE, QWP_STATUS, QwpBinaryConnection, QwpByteReader, @@ -23,6 +25,13 @@ import { QwpAsyncQueue } from "../../src/qwp/internal/async-queue"; const RESULT_FLAGS = QWP_FLAG_DELTA_SYMBOL_DICTIONARY | QWP_FLAG_GORILLA; +// One standard Zstd frame with a declared 409-byte content size and an actual +// compressed block. Its body is a 100-row QWP table of INT values equal to 42. +const COMPRESSED_INT_RESULT_BODY = Uint8Array.from([ + 40, 181, 47, 253, 96, 153, 0, 157, 0, 0, 96, 0, 0, 0, 100, 1, 1, 120, 4, 0, + 42, 0, 0, 1, 0, 138, 171, 46, 9, +]); + function writeString(writer: QwpByteWriter, value: string): void { const bytes = new TextEncoder().encode(value); writeQwpVarint(writer, bytes.length); @@ -89,14 +98,26 @@ function firstResultBatch(requestId = 0n): Uint8Array { return encodeQwpFrame(payload.toUint8Array(), RESULT_FLAGS, 1); } -function resultEnd(requestId = 0n): Uint8Array { +function resultEnd(requestId = 0n, totalRows = 3n): Uint8Array { const payload = new QwpByteWriter(); payload.writeUint8(QWP_EGRESS_MESSAGE.RESULT_END).writeBigUint64(requestId); writeQwpVarint(payload, 1); - writeQwpVarint(payload, 3); + writeQwpVarint(payload, totalRows); return encodeQwpFrame(payload.toUint8Array()); } +function compressedIntResultBatch(requestId = 0n): Uint8Array { + const payload = new QwpByteWriter(); + payload.writeUint8(QWP_EGRESS_MESSAGE.RESULT_BATCH).writeBigUint64(requestId); + writeQwpVarint(payload, 0); + payload.writeBytes(COMPRESSED_INT_RESULT_BODY); + return encodeQwpFrame( + payload.toUint8Array(), + QWP_FLAG_DELTA_SYMBOL_DICTIONARY | QWP_FLAG_ZSTD, + 1, + ); +} + function scalarResultBatch(): Uint8Array { const payload = new QwpByteWriter(); payload.writeUint8(QWP_EGRESS_MESSAGE.RESULT_BATCH).writeBigUint64(0n); @@ -286,6 +307,79 @@ describe("QWP result batch decoder", () => { expect(row[17]).toEqual(Uint8Array.of(1, 2, 3)); expect(row[18]).toBe(-1); }); + + it("decompresses a Zstd RESULT_BATCH body", () => { + const message = decodeQwpEgressMessage(compressedIntResultBatch(7n)); + if (message.kind !== "result-batch") throw new Error("unexpected message"); + + expect(message.body).toEqual(COMPRESSED_INT_RESULT_BODY); + const batch = new QwpResultBatchDecoder().decode(message); + expect(batch.requestId).toBe(7n); + expect(batch.rowCount).toBe(100); + expect(batch.columns[0]).toEqual({ + name: "x", + type: QWP_COLUMN_TYPE.INT, + values: new Array(100).fill(42), + }); + expect(batch.get(99, 0)).toBe(42); + }); + + it("requires a bounded, single Zstd frame", () => { + const decodeBody = (body: Uint8Array) => { + const bytes = compressedIntResultBatch(); + const message = decodeQwpEgressMessage(bytes); + if (message.kind !== "result-batch") { + throw new Error("unexpected message"); + } + return new QwpResultBatchDecoder().decode({ ...message, body }); + }; + + expect(() => decodeBody(Uint8Array.of(1, 2, 3, 4, 5))).toThrow( + /zstd frame magic/i, + ); + expect(() => decodeBody(Uint8Array.of(40, 181, 47, 253, 0, 0))).toThrow( + /declared content size/i, + ); + + const overCap = BigInt(QWP_MAX_ZSTD_DECOMPRESSED_SIZE + 1); + expect(() => + decodeBody( + Uint8Array.of( + 40, + 181, + 47, + 253, + 0xa0, + Number(overCap & 0xffn), + Number((overCap >> 8n) & 0xffn), + Number((overCap >> 16n) & 0xffn), + Number((overCap >> 24n) & 0xffn), + ), + ), + ).toThrow(/exceeds client cap/i); + + const withTrailingData = new Uint8Array( + COMPRESSED_INT_RESULT_BODY.byteLength + 1, + ); + withTrailingData.set(COMPRESSED_INT_RESULT_BODY); + expect(() => decodeBody(withTrailingData)).toThrow(/exactly one frame/i); + + const wrongDeclaredSize = COMPRESSED_INT_RESULT_BODY.slice(); + wrongDeclaredSize[5]++; + expect(() => decodeBody(wrongDeclaredSize)).toThrow( + /does not match frame content size/i, + ); + + const tooSmallDeclaredSize = COMPRESSED_INT_RESULT_BODY.slice(); + tooSmallDeclaredSize[5]--; + expect(() => decodeBody(tooSmallDeclaredSize)).toThrow( + /output exceeds declared content size/i, + ); + + const reservedBlock = COMPRESSED_INT_RESULT_BODY.slice(); + reservedBlock[7] = (reservedBlock[7] & ~0x06) | 0x06; + expect(() => decodeBody(reservedBlock)).toThrow(/reserved block type/i); + }); }); describe("QwpEgressSession", () => { @@ -376,6 +470,24 @@ describe("QwpEgressSession", () => { await session.close(); }); + it("streams a Zstd-compressed result through the high-level session", async () => { + const connection = new FakeConnection(); + const session = new QwpEgressSession(connection); + connection.receive(serverInfo()); + const query = await session.query("select 42"); + + connection.receive(compressedIntResultBatch(query.requestId)); + connection.receive(resultEnd(query.requestId, 100n)); + const batches = []; + for await (const batch of query) batches.push(batch); + + expect(batches).toHaveLength(1); + expect(batches[0].rowCount).toBe(100); + expect(batches[0].get(99, 0)).toBe(42); + await expect(query.completion).resolves.toMatchObject({ totalRows: 100n }); + await session.close(); + }); + it("surfaces QUERY_ERROR to iteration and completion", async () => { const connection = new FakeConnection(); const session = new QwpEgressSession(connection); diff --git a/test/qwp/session.test.ts b/test/qwp/session.test.ts index d5db420..05f5d3b 100644 --- a/test/qwp/session.test.ts +++ b/test/qwp/session.test.ts @@ -4,16 +4,19 @@ import { QwpWebSocketLike, } from "../../src/qwp/browser"; import { + connectQwpNodeEgress, connectQwpNodeWebSocket, QwpDurableAckUnavailableError, QwpVersionMismatchError, } from "../../src/qwp/node"; import { QWP_COLUMN_TYPE, + QWP_EGRESS_MESSAGE, QWP_STATUS, QWP_UPGRADE_ERROR_KIND, QwpBatchTooLargeError, QwpByteWriter, + encodeQwpFrame, QwpIngressNackError, QwpIngressSession, QwpIngressSessionClosedError, @@ -183,6 +186,19 @@ function writeIngressTables( } } +function serverInfoFrame(): Uint8Array { + const writer = new QwpByteWriter(); + writer + .writeUint8(QWP_EGRESS_MESSAGE.SERVER_INFO) + .writeUint8(0) + .writeBigUint64(1n) + .writeUint32(0) + .writeBigInt64(123n) + .writeUint16(0) + .writeUint16(0); + return encodeQwpFrame(writer.toUint8Array()); +} + describe("QWP WebSocket adapters", () => { it.each(["browser", "node"] as const)( "validates %s timeouts before creating a WebSocket", @@ -276,6 +292,74 @@ describe("QWP WebSocket adapters", () => { await session.close(); }); + it.each(["zstd", "auto"] as const)( + "negotiates %s compression for Node egress", + async (compression) => { + const socket = new FakeWebSocket(); + let capturedHeaders: Record | undefined; + const connecting = connectQwpNodeEgress({ + url: "ws://localhost:9000/read/v1", + compression, + compressionLevel: 5, + webSocketFactory: (_url, options) => { + capturedHeaders = options.headers; + options.onUpgrade({ + "x-qwp-content-encoding": "zstd;level=5", + }); + return asQwpSocket(socket); + }, + }); + socket.open(); + socket.message(serverInfoFrame()); + + const session = await connecting; + expect(capturedHeaders).toMatchObject({ + "X-QWP-Accept-Encoding": "zstd;level=5,raw", + }); + expect(session.handshake.contentEncoding).toBe("zstd;level=5"); + await session.close(); + }, + ); + + it("keeps raw egress compatible with custom low-level headers", async () => { + const socket = new FakeWebSocket(); + let capturedHeaders: Record | undefined; + const connecting = connectQwpNodeEgress({ + url: "ws://localhost:9000/read/v1", + headers: { "x-qwp-accept-encoding": "custom" }, + webSocketFactory: (_url, options) => { + capturedHeaders = options.headers; + options.onUpgrade({}); + return asQwpSocket(socket); + }, + }); + socket.open(); + socket.message(serverInfoFrame()); + + const session = await connecting; + expect(capturedHeaders?.["x-qwp-accept-encoding"]).toBe("custom"); + await session.close(); + }); + + it.each([ + { compression: "zstd" as const, compressionLevel: 0 }, + { compression: "zstd" as const, compressionLevel: 23 }, + { compression: "invalid" as "zstd", compressionLevel: 1 }, + ])("rejects invalid egress compression options", async (options) => { + let factoryCalls = 0; + await expect( + connectQwpNodeEgress({ + url: "ws://localhost:9000/read/v1", + ...options, + webSocketFactory: () => { + factoryCalls++; + return asQwpSocket(new FakeWebSocket()); + }, + }), + ).rejects.toBeInstanceOf(RangeError); + expect(factoryCalls).toBe(0); + }); + it("uses the legacy handshake defaults when optional headers are absent", async () => { const socket = new FakeWebSocket(); const connecting = connectQwpNodeWebSocket({ From d14f7548beeebff2a083255623a9f1b9cda7ae69 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 18 Aug 2026 00:02:05 +0100 Subject: [PATCH 019/265] feat(qwp): complete compression negotiation and tuning --- README.md | 10 ++++++ src/qwp/core/compression.ts | 63 +++++++++++++++++++++++++++++++++ src/qwp/core/index.ts | 1 + src/qwp/egress-session.ts | 12 +++++++ src/qwp/node.ts | 33 ++++++++--------- src/qwp/transport.ts | 4 +++ test/qwp/core.test.ts | 44 +++++++++++++++++++++++ test/qwp/node-transport.test.ts | 51 +++++++++++++++++++++++++- test/qwp/reconnect.test.ts | 37 +++++++++++++++++++ test/qwp/session.test.ts | 12 +++++++ 10 files changed, 247 insertions(+), 20 deletions(-) create mode 100644 src/qwp/core/compression.ts diff --git a/README.md b/README.md index 50465eb..859297a 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,7 @@ const session = await connectQwpNodeEgress({ }); try { const query = await session.query("select * from trades"); + console.log("effective Zstd level", session.negotiatedZstdLevel); for await (const batch of query) { for (const row of batch.rows()) console.log(row); } @@ -126,6 +127,15 @@ Zstd decoding is also included in the browser entry point. Browsers cannot set the `X-QWP-Accept-Encoding` upgrade header themselves, so a same-origin reverse proxy must add it when browser clients should opt into compression. +Level `1` is the lowest-CPU default and is usually the right starting point. +Higher values trade server CPU for wire size; the client accepts levels 1–22, +while the server may clamp the request or apply an operator-configured level. +`session.negotiatedCompression` and `session.negotiatedZstdLevel` report what +the active server actually selected and refresh after reconnection or failover. +Both `"zstd"` and `"auto"` advertise Zstd followed by raw fallback, and the +server still sends an individual batch raw when compression would make it +larger. + ### Authentication and secure connection #### Username and password authentication with HTTP transport diff --git a/src/qwp/core/compression.ts b/src/qwp/core/compression.ts new file mode 100644 index 0000000..892fca5 --- /dev/null +++ b/src/qwp/core/compression.ts @@ -0,0 +1,63 @@ +export const QWP_ZSTD_MIN_COMPRESSION_LEVEL = 1; +export const QWP_ZSTD_MAX_COMPRESSION_LEVEL = 22; + +export type QwpEgressCompression = "raw" | "zstd" | "auto"; + +export type QwpNegotiatedEgressCompression = + | { + readonly codec: "raw"; + readonly level: 0; + } + | { + readonly codec: "zstd"; + readonly level: number; + } + | { + readonly codec: "unknown"; + readonly level: 0; + readonly contentEncoding: string; + }; + +/** Builds the Node upgrade header for an egress compression preference. */ +export function encodeQwpAcceptEncoding( + preference: QwpEgressCompression, + level = QWP_ZSTD_MIN_COMPRESSION_LEVEL, +): string | undefined { + if (preference !== "raw" && preference !== "zstd" && preference !== "auto") { + throw new RangeError("compression must be one of raw, zstd, or auto"); + } + if ( + !Number.isSafeInteger(level) || + level < QWP_ZSTD_MIN_COMPRESSION_LEVEL || + level > QWP_ZSTD_MAX_COMPRESSION_LEVEL + ) { + throw new RangeError( + `compressionLevel must be an integer between ${QWP_ZSTD_MIN_COMPRESSION_LEVEL} and ${QWP_ZSTD_MAX_COMPRESSION_LEVEL}`, + ); + } + return preference === "raw" ? undefined : `zstd;level=${level},raw`; +} + +/** + * Parses the server's `X-QWP-Content-Encoding` response. Unknown values remain + * observable but do not claim that Zstd was negotiated; RESULT_BATCH flags + * remain authoritative for each individual batch. + */ +export function decodeQwpContentEncoding( + value: string | undefined, +): QwpNegotiatedEgressCompression { + const contentEncoding = value?.trim(); + if (!contentEncoding) return { codec: "raw", level: 0 }; + if (/^(?:raw|identity)$/i.test(contentEncoding)) { + return { codec: "raw", level: 0 }; + } + + const match = /^zstd\s*;\s*level\s*=\s*(\d+)$/i.exec(contentEncoding); + if (match) { + const level = Number(match[1]); + if (Number.isSafeInteger(level) && level > 0) { + return { codec: "zstd", level }; + } + } + return { codec: "unknown", level: 0, contentEncoding }; +} diff --git a/src/qwp/core/index.ts b/src/qwp/core/index.ts index a856170..e64ea20 100644 --- a/src/qwp/core/index.ts +++ b/src/qwp/core/index.ts @@ -1,5 +1,6 @@ export * from "./bytes"; export * from "./binds"; +export * from "./compression"; export * from "./constants"; export * from "./egress"; export * from "./errors"; diff --git a/src/qwp/egress-session.ts b/src/qwp/egress-session.ts index c9525ed..6887e40 100644 --- a/src/qwp/egress-session.ts +++ b/src/qwp/egress-session.ts @@ -8,6 +8,7 @@ import { QWP_RESET_MASK_DICTIONARY, QwpBindSetter, QwpExecDoneMessage, + type QwpNegotiatedEgressCompression, QwpProtocolError, QwpQueryRequest, QwpResultBatch, @@ -267,6 +268,17 @@ export class QwpEgressSession implements QwpEgressQueryControl { return this.connection.handshake; } + /** Effective codec and level echoed by the server on the active endpoint. */ + get negotiatedCompression(): QwpNegotiatedEgressCompression | undefined { + return this.connection.handshake.negotiatedCompression; + } + + /** Effective Zstd level, or zero for raw, unknown, or browser-hidden negotiation. */ + get negotiatedZstdLevel(): number { + const compression = this.negotiatedCompression; + return compression?.codec === "zstd" ? compression.level : 0; + } + async query( sql: string, options: QwpEgressQueryOptions = {}, diff --git a/src/qwp/node.ts b/src/qwp/node.ts index 1b5f8f5..73f3e68 100644 --- a/src/qwp/node.ts +++ b/src/qwp/node.ts @@ -4,7 +4,12 @@ export * from "./index"; import type { Agent } from "node:http"; import type { IncomingHttpHeaders } from "node:http"; import WebSocket from "ws"; -import { QWP_VERSION } from "./core"; +import { + decodeQwpContentEncoding, + encodeQwpAcceptEncoding, + QWP_VERSION, + type QwpEgressCompression, +} from "./core"; import { openQwpWebSocket, QwpWebSocketLike, @@ -160,8 +165,6 @@ export interface QwpNodeIngressOptions extends QwpNodeWebSocketOptions { storeAndForward?: QwpNodeFileReplayStoreOptions; } -export type QwpEgressCompression = "raw" | "zstd" | "auto"; - export interface QwpNodeEgressOptions extends QwpNodeWebSocketOptions { /** * Requests Zstd-compressed result batches. The default is `raw`, which @@ -178,18 +181,7 @@ function egressTransportOptions( ): QwpNodeWebSocketOptions { const { compression, compressionLevel = 1, ...transport } = options; const preference = compression ?? "raw"; - if (preference !== "raw" && preference !== "zstd" && preference !== "auto") { - throw new RangeError("compression must be one of raw, zstd, or auto"); - } - if ( - !Number.isSafeInteger(compressionLevel) || - compressionLevel < 1 || - compressionLevel > 22 - ) { - throw new RangeError( - "compressionLevel must be an integer between 1 and 22", - ); - } + const acceptEncoding = encodeQwpAcceptEncoding(preference, compressionLevel); // Keep the low-level headers escape hatch backwards compatible unless the // typed compression option was explicitly selected. @@ -199,9 +191,7 @@ function egressTransportOptions( for (const name of Object.keys(headers)) { if (name.toLowerCase() === "x-qwp-accept-encoding") delete headers[name]; } - if (preference !== "raw") { - headers["X-QWP-Accept-Encoding"] = `zstd;level=${compressionLevel},raw`; - } + if (acceptEncoding) headers["X-QWP-Accept-Encoding"] = acceptEncoding; return { ...transport, headers }; } @@ -323,10 +313,15 @@ function connectQwpNodeEndpoint( if (options.requestDurableAck && !durableAckEnabled) { throw new QwpDurableAckUnavailableError(endpoint); } + const contentEncoding = headerValue( + upgradeHeaders, + "x-qwp-content-encoding", + ); const handshake: QwpHandshakeMetadata = { qwpVersion, maxBatchSizeBytes: parseMaxBatchSize(upgradeHeaders), - contentEncoding: headerValue(upgradeHeaders, "x-qwp-content-encoding"), + contentEncoding, + negotiatedCompression: decodeQwpContentEncoding(contentEncoding), durableAckEnabled, serverRole: headerValue(upgradeHeaders, "x-questdb-role"), }; diff --git a/src/qwp/transport.ts b/src/qwp/transport.ts index 72ca072..c044424 100644 --- a/src/qwp/transport.ts +++ b/src/qwp/transport.ts @@ -1,3 +1,5 @@ +import type { QwpNegotiatedEgressCompression } from "./core/compression"; + export interface QwpConnectionCloseInfo { code: number; reason: string; @@ -264,6 +266,8 @@ export interface QwpHandshakeMetadata { readonly maxBatchSizeBytes?: number; /** Server-selected egress content encoding, when advertised. */ readonly contentEncoding?: string; + /** Parsed effective egress codec and level selected by the server. */ + readonly negotiatedCompression?: QwpNegotiatedEgressCompression; /** Whether the server confirmed durable-ACK support. */ readonly durableAckEnabled?: boolean; /** Server role advertised on a successful upgrade, when available. */ diff --git a/test/qwp/core.test.ts b/test/qwp/core.test.ts index 2d7bc86..d559dbd 100644 --- a/test/qwp/core.test.ts +++ b/test/qwp/core.test.ts @@ -1,11 +1,13 @@ import { describe, expect, it } from "vitest"; import { decodeQwpEgressMessage, + decodeQwpContentEncoding, decodeQwpFrame, decodeQwpIngressResponse, decodeQwpIngressSymbolDictionaryDelta, decodeQwpVarint, encodeQwpCancel, + encodeQwpAcceptEncoding, encodeQwpCredit, encodeQwpFrame, encodeQwpGorilla, @@ -77,6 +79,48 @@ describe("QWP browser-safe byte core", () => { }); }); +describe("QWP egress compression negotiation", () => { + it("builds raw and Zstd upgrade preferences", () => { + expect(encodeQwpAcceptEncoding("raw", 1)).toBeUndefined(); + expect(encodeQwpAcceptEncoding("zstd", 1)).toBe("zstd;level=1,raw"); + expect(encodeQwpAcceptEncoding("auto", 22)).toBe("zstd;level=22,raw"); + }); + + it.each([0, 23, 1.5, Number.NaN])( + "rejects invalid Zstd tuning level %s", + (level) => { + expect(() => encodeQwpAcceptEncoding("zstd", level)).toThrow( + /between 1 and 22/, + ); + }, + ); + + it("parses the effective server codec and level", () => { + expect(decodeQwpContentEncoding(undefined)).toEqual({ + codec: "raw", + level: 0, + }); + expect(decodeQwpContentEncoding(" identity ")).toEqual({ + codec: "raw", + level: 0, + }); + expect(decodeQwpContentEncoding("ZSTD; level = 7")).toEqual({ + codec: "zstd", + level: 7, + }); + expect(decodeQwpContentEncoding("zstd;level=bogus")).toEqual({ + codec: "unknown", + level: 0, + contentEncoding: "zstd;level=bogus", + }); + expect(decodeQwpContentEncoding("br")).toEqual({ + codec: "unknown", + level: 0, + contentEncoding: "br", + }); + }); +}); + describe("QWP frame envelope", () => { it("writes and validates the common 12-byte header", () => { const encoded = encodeQwpFrame(Uint8Array.from([1, 2, 3]), 4, 2); diff --git a/test/qwp/node-transport.test.ts b/test/qwp/node-transport.test.ts index 6237ddb..e5f717c 100644 --- a/test/qwp/node-transport.test.ts +++ b/test/qwp/node-transport.test.ts @@ -5,14 +5,29 @@ import { join } from "node:path"; import { WebSocketServer } from "ws"; import { afterEach, describe, expect, it } from "vitest"; import { - connectQwpNodeWebSocket, + connectQwpNodeEgress, connectQwpNodeIngress, + connectQwpNodeWebSocket, + encodeQwpFrame, + QWP_EGRESS_MESSAGE, QWP_STATUS, QWP_UPGRADE_ERROR_KIND, QwpByteWriter, QwpUpgradeError, } from "../../src/qwp/node"; +function serverInfo(): Uint8Array { + const payload = new QwpByteWriter() + .writeUint8(QWP_EGRESS_MESSAGE.SERVER_INFO) + .writeUint8(0) + .writeBigUint64(1n) + .writeUint32(0) + .writeBigInt64(123n) + .writeUint16(0) + .writeUint16(0); + return encodeQwpFrame(payload.toUint8Array()); +} + function writeTable( writer: QwpByteWriter, name: string, @@ -113,6 +128,40 @@ describe("QWP Node transport", () => { } }); + it("surfaces the server-clamped Zstd level from a real upgrade", async () => { + let acceptEncoding: string | undefined; + server = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + server.on("headers", (headers) => { + headers.push("X-QWP-Version: 1"); + headers.push("X-QWP-Content-Encoding: zstd;level=9"); + }); + server.on("connection", (socket, request) => { + acceptEncoding = request.headers["x-qwp-accept-encoding"]; + socket.send(serverInfo()); + }); + await new Promise((resolve, reject) => { + server!.once("listening", resolve); + server!.once("error", reject); + }); + + const address = server.address() as AddressInfo; + const session = await connectQwpNodeEgress({ + url: `ws://127.0.0.1:${address.port}/read/v1`, + compression: "auto", + compressionLevel: 22, + }); + try { + expect(acceptEncoding).toBe("zstd;level=22,raw"); + expect(session.negotiatedCompression).toEqual({ + codec: "zstd", + level: 9, + }); + expect(session.negotiatedZstdLevel).toBe(9); + } finally { + await session.close(); + } + }); + it("classifies a real role-rejected HTTP upgrade", async () => { server = new WebSocketServer({ host: "127.0.0.1", diff --git a/test/qwp/reconnect.test.ts b/test/qwp/reconnect.test.ts index 3fa2f02..19e9dc2 100644 --- a/test/qwp/reconnect.test.ts +++ b/test/qwp/reconnect.test.ts @@ -656,6 +656,43 @@ describe("QWP egress reconnect and replay", () => { await session.close(); }); + it("refreshes the negotiated Zstd level after failover", async () => { + const first = new FakeConnection("primary", { + qwpVersion: 1, + contentEncoding: "zstd;level=5", + negotiatedCompression: { codec: "zstd", level: 5 }, + }); + const second = new FakeConnection("secondary", { + qwpVersion: 1, + contentEncoding: "zstd;level=1", + negotiatedCompression: { codec: "zstd", level: 1 }, + }); + const connections = [first, second]; + const session = await QwpEgressSession.connect( + async () => { + const connection = connections.shift(); + if (!connection) throw new Error("no connection available"); + queueMicrotask(() => + connection.receive(serverInfo(connection.endpoint)), + ); + return connection; + }, + { + reconnect: { + maxAttempts: 1, + initialBackoffMs: 0, + maxBackoffMs: 0, + }, + }, + ); + expect(session.negotiatedZstdLevel).toBe(5); + + first.drop(); + await vi.waitFor(() => expect(session.negotiatedZstdLevel).toBe(1)); + expect(session.handshake.contentEncoding).toBe("zstd;level=1"); + await session.close(); + }); + it("discards queued batches, invokes reset, and replays an opted-in query", async () => { const first = new FakeConnection("primary"); const second = new FakeConnection("secondary"); diff --git a/test/qwp/session.test.ts b/test/qwp/session.test.ts index 05f5d3b..74504ee 100644 --- a/test/qwp/session.test.ts +++ b/test/qwp/session.test.ts @@ -279,6 +279,7 @@ describe("QWP WebSocket adapters", () => { qwpVersion: 1, maxBatchSizeBytes: 4096, contentEncoding: "raw", + negotiatedCompression: { codec: "raw", level: 0 }, durableAckEnabled: true, serverRole: "primary", }); @@ -317,6 +318,11 @@ describe("QWP WebSocket adapters", () => { "X-QWP-Accept-Encoding": "zstd;level=5,raw", }); expect(session.handshake.contentEncoding).toBe("zstd;level=5"); + expect(session.negotiatedCompression).toEqual({ + codec: "zstd", + level: 5, + }); + expect(session.negotiatedZstdLevel).toBe(5); await session.close(); }, ); @@ -338,6 +344,11 @@ describe("QWP WebSocket adapters", () => { const session = await connecting; expect(capturedHeaders?.["x-qwp-accept-encoding"]).toBe("custom"); + expect(session.negotiatedCompression).toEqual({ + codec: "raw", + level: 0, + }); + expect(session.negotiatedZstdLevel).toBe(0); await session.close(); }); @@ -379,6 +390,7 @@ describe("QWP WebSocket adapters", () => { qwpVersion: 1, maxBatchSizeBytes: undefined, contentEncoding: undefined, + negotiatedCompression: { codec: "raw", level: 0 }, durableAckEnabled: false, serverRole: undefined, }); From a337486c8f46a1785eae423c04d54f7fb1a614e6 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 18 Aug 2026 00:27:22 +0100 Subject: [PATCH 020/265] feat(qwp): negotiate durable ACKs in browsers --- README.md | 17 + src/qwp/browser.ts | 47 +- src/qwp/core/constants.ts | 2 + src/qwp/core/durable-ack.ts | 27 + src/qwp/core/index.ts | 1 + src/qwp/core/ingress.ts | 12 + src/qwp/ingress-session.ts | 56 +- src/qwp/internal/websocket-connection.ts | 2 + src/qwp/node.ts | 16 +- src/qwp/transport.ts | 16 + test/qwp/browser.e2e.ts | 617 +++++++++++++---------- test/qwp/core.test.ts | 37 ++ test/qwp/session.test.ts | 129 ++++- 13 files changed, 641 insertions(+), 338 deletions(-) create mode 100644 src/qwp/core/durable-ack.ts diff --git a/README.md b/README.md index 859297a..3c654e8 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,23 @@ await sender.flush(); await sender.close(); ``` +Browsers can request durable ingress acknowledgements without custom HTTP +headers. The client offers a QWP WebSocket subprotocol and verifies that the +server selected it before sending data. Browser keepalives use side-effect-free, +table-less QWP poll frames because the WebSocket API does not expose +protocol-level PING frames. + +```typescript +const sender = await connectQwpBrowserSender( + { url, requestDurableAck: true }, + { autoFlush: false, awaitDurableAck: true }, +); +``` + +Browser durable ACKs are an in-memory delivery confirmation only. Persistent +store-and-forward remains available exclusively through the Node.js entry +point. + ### Zstd-compressed QWP egress Node.js egress clients can opt into compressed result batches during the diff --git a/src/qwp/browser.ts b/src/qwp/browser.ts index 26e5ac1..0eebba6 100644 --- a/src/qwp/browser.ts +++ b/src/qwp/browser.ts @@ -7,10 +7,15 @@ import { validateQwpWebSocketTimeouts, } from "./internal/websocket-connection"; import { createQwpFailoverConnectionFactory } from "./internal/failover"; -import { QWP_VERSION } from "./core"; +import { + addQwpDurableAckWebSocketProtocol, + isQwpDurableAckWebSocketProtocol, + QWP_VERSION, +} from "./core"; import { QwpBinaryConnection, QwpConnectionFactory, + QwpDurableAckUnavailableError, QwpWebSocketConnectOptions, } from "./transport"; import { QwpEgressSession, QwpEgressSessionOptions } from "./egress-session"; @@ -20,6 +25,11 @@ import { QwpSender, QwpSenderOptions } from "./sender"; export type { QwpWebSocketLike } from "./internal/websocket-connection"; export interface QwpBrowserWebSocketOptions extends QwpWebSocketConnectOptions { + /** + * Requests durable ingress ACKs through browser-visible WebSocket + * subprotocol negotiation. + */ + requestDurableAck?: boolean; /** Test or framework hook; defaults to the browser's global WebSocket. */ webSocketFactory?: ( url: string | URL, @@ -74,13 +84,26 @@ function connectQwpBrowserEndpoint( } return new WebSocketConstructor(url, protocols); }); - const socket = factory(endpoint, options.protocols); + const protocols = options.requestDurableAck + ? addQwpDurableAckWebSocketProtocol(options.protocols) + : options.protocols; + const socket = factory(endpoint, protocols); return openQwpWebSocket(socket, { url: endpoint, connectTimeoutMs: options.connectTimeoutMs, sendTimeoutMs: options.sendTimeoutMs, closeTimeoutMs: options.closeTimeoutMs, - completeHandshake: () => ({ qwpVersion: QWP_VERSION }), + completeHandshake: () => { + const durableAckEnabled = isQwpDurableAckWebSocketProtocol( + socket.protocol, + ); + if (options.requestDurableAck && !durableAckEnabled) { + throw new QwpDurableAckUnavailableError(endpoint); + } + return durableAckEnabled + ? { qwpVersion: QWP_VERSION, durableAckEnabled: true } + : { qwpVersion: QWP_VERSION }; + }, opaqueErrors: true, }); } @@ -90,9 +113,15 @@ export async function connectQwpBrowserIngress( options: QwpBrowserWebSocketOptions, sessionOptions: QwpIngressSessionOptions = {}, ): Promise { + const effectiveSessionOptions: QwpIngressSessionOptions = { + ...sessionOptions, + durableAckKeepaliveMs: options.requestDurableAck + ? (sessionOptions.durableAckKeepaliveMs ?? 200) + : sessionOptions.durableAckKeepaliveMs, + }; return QwpIngressSession.connect( createQwpBrowserConnectionFactory(options), - sessionOptions, + effectiveSessionOptions, ); } @@ -106,7 +135,15 @@ export function createQwpBrowserSender( sessionOptions: QwpIngressSessionOptions = {}, ): QwpSender { return new QwpSender( - () => connectQwpBrowserIngress(options, sessionOptions), + () => + connectQwpBrowserIngress( + { + ...options, + requestDurableAck: + options.requestDurableAck ?? senderOptions.awaitDurableAck, + }, + sessionOptions, + ), senderOptions, ); } diff --git a/src/qwp/core/constants.ts b/src/qwp/core/constants.ts index 8ce17f8..08ffe3e 100644 --- a/src/qwp/core/constants.ts +++ b/src/qwp/core/constants.ts @@ -4,6 +4,8 @@ export const QWP_VERSION = 1; export const QWP_HEADER_SIZE = 12; export const QWP_FLAG_DEFER_COMMIT = 0x01; +/** Table-less ingress control frame that polls negotiated durable-ACK progress. */ +export const QWP_FLAG_DURABLE_ACK_POLL = 0x02; export const QWP_FLAG_GORILLA = 0x04; export const QWP_FLAG_DELTA_SYMBOL_DICTIONARY = 0x08; export const QWP_FLAG_ZSTD = 0x10; diff --git a/src/qwp/core/durable-ack.ts b/src/qwp/core/durable-ack.ts new file mode 100644 index 0000000..0aba5f8 --- /dev/null +++ b/src/qwp/core/durable-ack.ts @@ -0,0 +1,27 @@ +/** + * Browser-visible WebSocket subprotocol used to request and confirm durable + * ingress acknowledgements. Browsers cannot set or inspect X-QWP-* headers. + */ +export const QWP_DURABLE_ACK_WEBSOCKET_PROTOCOL = "questdb.qwp.durable-ack.v1"; + +/** Adds the durable-ACK capability token without mutating user options. */ +export function addQwpDurableAckWebSocketProtocol( + protocols: string | readonly string[] | undefined, +): string | string[] { + if (protocols === undefined) return QWP_DURABLE_ACK_WEBSOCKET_PROTOCOL; + if (typeof protocols === "string") { + return protocols === QWP_DURABLE_ACK_WEBSOCKET_PROTOCOL + ? protocols + : [protocols, QWP_DURABLE_ACK_WEBSOCKET_PROTOCOL]; + } + return protocols.includes(QWP_DURABLE_ACK_WEBSOCKET_PROTOCOL) + ? [...protocols] + : [...protocols, QWP_DURABLE_ACK_WEBSOCKET_PROTOCOL]; +} + +/** True when the server selected the browser durable-ACK subprotocol. */ +export function isQwpDurableAckWebSocketProtocol( + protocol: string | undefined, +): boolean { + return protocol === QWP_DURABLE_ACK_WEBSOCKET_PROTOCOL; +} diff --git a/src/qwp/core/index.ts b/src/qwp/core/index.ts index e64ea20..e7c3db3 100644 --- a/src/qwp/core/index.ts +++ b/src/qwp/core/index.ts @@ -2,6 +2,7 @@ export * from "./bytes"; export * from "./binds"; export * from "./compression"; export * from "./constants"; +export * from "./durable-ack"; export * from "./egress"; export * from "./errors"; export * from "./frame"; diff --git a/src/qwp/core/ingress.ts b/src/qwp/core/ingress.ts index 0a2f96f..be5c0ce 100644 --- a/src/qwp/core/ingress.ts +++ b/src/qwp/core/ingress.ts @@ -5,6 +5,7 @@ import { QWP_ENCODING_UNCOMPRESSED, QWP_FLAG_DEFER_COMMIT, QWP_FLAG_DELTA_SYMBOL_DICTIONARY, + QWP_FLAG_DURABLE_ACK_POLL, QWP_FLAG_GORILLA, QWP_HEADER_SIZE, QWP_MAX_ERROR_MESSAGE_LENGTH, @@ -621,6 +622,17 @@ export function encodeQwpIngressCommitFrame( }); } +/** Encodes a negotiated, side-effect-free durable-ACK progress poll. */ +export function encodeQwpDurableAckPollFrame(): Uint8Array { + const writer = new QwpByteWriter(QWP_HEADER_SIZE); + writeQwpFrameHeader(writer, { + flags: QWP_FLAG_DURABLE_ACK_POLL, + tableCount: 0, + payloadLength: 0, + }); + return writer.toUint8Array(); +} + function readIngressTables( reader: QwpByteReader, count: number, diff --git a/src/qwp/ingress-session.ts b/src/qwp/ingress-session.ts index ebf25df..87160d3 100644 --- a/src/qwp/ingress-session.ts +++ b/src/qwp/ingress-session.ts @@ -1,5 +1,6 @@ import { decodeQwpIngressResponse, + encodeQwpDurableAckPollFrame, encodeQwpIngressFrame, QWP_STATUS, QwpIngressEncodeOptions, @@ -39,9 +40,10 @@ export interface QwpIngressSessionOptions { */ maxBatchSizeBytes?: number; /** - * Enables durable-ACK tracking and sends Node WebSocket PING frames while - * committed table transactions are still awaiting durable upload. Zero - * keeps tracking enabled but disables automatic PINGs. + * Enables durable-ACK tracking. While committed table transactions await + * durable upload, Node transports send WebSocket PING frames and browser + * transports send table-less QWP commit frames. Zero keeps tracking enabled + * but disables automatic polling. */ durableAckKeepaliveMs?: number; onResponse?: (response: QwpIngressResponse) => void; @@ -96,7 +98,6 @@ export class QwpBatchTooLargeError extends RangeError { function validateIngressSessionOptions( options: QwpIngressSessionOptions, - connection?: QwpBinaryConnection, ): void { const timeout = options.ackTimeoutMs ?? 15_000; if (!Number.isFinite(timeout) || timeout <= 0) { @@ -118,16 +119,6 @@ function validateIngressSessionOptions( "durableAckKeepaliveMs must be a non-negative finite number", ); } - if ( - connection && - keepalive !== undefined && - keepalive > 0 && - !connection.ping - ) { - throw new Error( - "durable ACK keepalive requires a WebSocket transport with PING support", - ); - } } /** @@ -145,7 +136,7 @@ export class QwpIngressSession { private readonly durableWaiters = new Set(); private nextSequence = 0n; private sendTail: Promise = Promise.resolve(); - private durablePingTimer?: ReturnType; + private durablePollTimer?: ReturnType; private readonly localMaxBatchSizeBytes?: number; private readonly symbolDictionary = new QwpSymbolDictionary(); private publishedMaxSymbolId = -1; @@ -168,7 +159,7 @@ export class QwpIngressSession { "ingress reconnect options require QwpIngressSession.connect(factory, options)", ); } - validateIngressSessionOptions(options, connection); + validateIngressSessionOptions(options); } catch (error) { try { void connection @@ -371,7 +362,7 @@ export class QwpIngressSession { private async closeNow(code: number, reason: string): Promise { this.closing = true; - this.clearDurablePing(); + this.clearDurablePoll(); this.rejectAll(new QwpIngressSessionClosedError()); let transportClose: Promise; try { @@ -469,7 +460,7 @@ export class QwpIngressSession { this.pendingDurableTargets.set(table.name, table.sequenceTransaction); } } - this.scheduleDurablePing(); + this.scheduleDurablePoll(); } private applyDurableAck(response: QwpIngressResponse): void { @@ -491,9 +482,9 @@ export class QwpIngressSession { waiter.resolve(); } if (this.pendingDurableTargets.size === 0) { - this.clearDurablePing(); + this.clearDurablePoll(); } else { - this.scheduleDurablePing(); + this.scheduleDurablePoll(); } } @@ -507,18 +498,18 @@ export class QwpIngressSession { return true; } - private scheduleDurablePing(): void { + private scheduleDurablePoll(): void { const interval = this.options.durableAckKeepaliveMs; if ( interval === undefined || interval === 0 || this.pendingDurableTargets.size === 0 || - this.durablePingTimer + this.durablePollTimer ) { return; } - this.durablePingTimer = setTimeout(() => { - this.durablePingTimer = undefined; + this.durablePollTimer = setTimeout(() => { + this.durablePollTimer = undefined; if ( this.closing || this.failure || @@ -526,16 +517,19 @@ export class QwpIngressSession { ) { return; } - void this.connection.ping!() - .then(() => this.scheduleDurablePing()) + const poll = this.connection.ping + ? this.connection.ping() + : this.sendFrame(encodeQwpDurableAckPollFrame()).then(() => undefined); + void poll + .then(() => this.scheduleDurablePoll()) .catch((error: unknown) => this.fail(error)); }, interval); } - private clearDurablePing(): void { - if (!this.durablePingTimer) return; - clearTimeout(this.durablePingTimer); - this.durablePingTimer = undefined; + private clearDurablePoll(): void { + if (!this.durablePollTimer) return; + clearTimeout(this.durablePollTimer); + this.durablePollTimer = undefined; } private throwIfUnavailable(): void { @@ -545,7 +539,7 @@ export class QwpIngressSession { private fail(error: unknown): void { if (this.failure) return; - this.clearDurablePing(); + this.clearDurablePoll(); this.failure = error instanceof Error ? error diff --git a/src/qwp/internal/websocket-connection.ts b/src/qwp/internal/websocket-connection.ts index 679562a..53bc3f4 100644 --- a/src/qwp/internal/websocket-connection.ts +++ b/src/qwp/internal/websocket-connection.ts @@ -24,6 +24,8 @@ interface QwpWebSocketCloseEvent { export interface QwpWebSocketLike { binaryType: string; readonly readyState: number; + /** WebSocket subprotocol selected by the server, or an empty string. */ + readonly protocol?: string; /** Number of application bytes queued by WHATWG-compatible WebSockets. */ readonly bufferedAmount?: number; send(data: Uint8Array): void; diff --git a/src/qwp/node.ts b/src/qwp/node.ts index 73f3e68..acebdf8 100644 --- a/src/qwp/node.ts +++ b/src/qwp/node.ts @@ -20,6 +20,7 @@ import { QWP_UPGRADE_ERROR_KIND, QwpBinaryConnection, QwpConnectionFactory, + QwpDurableAckUnavailableError, QwpHandshakeMetadata, QwpUpgradeError, QwpWebSocketConnectOptions, @@ -39,21 +40,6 @@ export type { QwpNodeFileReplayStoreOptions } from "../qwp-node/file-replay-stor export type { QwpWebSocketLike } from "./internal/websocket-connection"; -export class QwpDurableAckUnavailableError extends QwpUpgradeError { - constructor(readonly url: string | URL) { - super( - `QWP durable ACK was requested, but the server did not advertise support [url=${url}]`, - { - kind: QWP_UPGRADE_ERROR_KIND.CAPABILITY_MISMATCH, - retryable: false, - tryNextEndpoint: true, - url, - }, - ); - this.name = "QwpDurableAckUnavailableError"; - } -} - export class QwpVersionMismatchError extends QwpUpgradeError { constructor( readonly serverVersion: number, diff --git a/src/qwp/transport.ts b/src/qwp/transport.ts index c044424..efad4e2 100644 --- a/src/qwp/transport.ts +++ b/src/qwp/transport.ts @@ -258,6 +258,22 @@ export class QwpUpgradeError extends Error { } } +/** A requested durable-ACK capability was not confirmed by the server. */ +export class QwpDurableAckUnavailableError extends QwpUpgradeError { + constructor(readonly url: string | URL) { + super( + `QWP durable ACK was requested, but the server did not advertise support [url=${url}]`, + { + kind: QWP_UPGRADE_ERROR_KIND.CAPABILITY_MISMATCH, + retryable: false, + tryNextEndpoint: true, + url, + }, + ); + this.name = "QwpDurableAckUnavailableError"; + } +} + /** Metadata negotiated during the QWP WebSocket upgrade. */ export interface QwpHandshakeMetadata { /** QWP protocol version selected by the server. */ diff --git a/test/qwp/browser.e2e.ts b/test/qwp/browser.e2e.ts index 1a5f52f..ca8eb90 100644 --- a/test/qwp/browser.e2e.ts +++ b/test/qwp/browser.e2e.ts @@ -5,10 +5,12 @@ import path from "node:path"; import { Browser, chromium } from "playwright"; import { GenericContainer, StartedTestContainer } from "testcontainers"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { WebSocketServer } from "ws"; import { connectQwpNodeIngress, connectQwpNodeWebSocket, QWP_COLUMN_TYPE, + QWP_DURABLE_ACK_WEBSOCKET_PROTOCOL, QWP_STATUS, QwpDurableAckUnavailableError, QwpTableBuffer, @@ -35,6 +37,23 @@ function close(server: Server): Promise { }); } +function waitForWebSocketServer(server: WebSocketServer): Promise { + if (server.address()) return Promise.resolve(); + return new Promise((resolve, reject) => { + server.once("error", reject); + server.once("listening", () => { + server.off("error", reject); + resolve(); + }); + }); +} + +function closeWebSocketServer(server: WebSocketServer): Promise { + return new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); +} + function createModuleServer(): Server { const moduleRoot = path.resolve(process.cwd(), "dist/es/qwp"); return createServer(async (request, response) => { @@ -71,7 +90,7 @@ async function executeSql(questdbUrl: string, sql: string): Promise { }); } -describe("QWP in a real browser against QuestDB", () => { +describe("QWP in a real browser", () => { let assetServer: Server; let assetUrl: string; let browser: Browser; @@ -79,24 +98,6 @@ describe("QWP in a real browser against QuestDB", () => { let questdbUrl: string; beforeAll(async () => { - const configuredUrl = process.env.QWP_BROWSER_E2E_URL; - if (configuredUrl) { - questdbUrl = new URL(configuredUrl).toString(); - } else { - container = await new GenericContainer( - process.env.QWP_BROWSER_E2E_IMAGE ?? "questdb/questdb:nightly", - ) - .withEnvironment({ - QDB_HTTP_USER: USER, - QDB_HTTP_PASSWORD: PASSWORD, - }) - .withExposedPorts(QUESTDB_HTTP_PORT) - .start(); - questdbUrl = new URL( - `http://${container.getHost()}:${container.getMappedPort(QUESTDB_HTTP_PORT)}`, - ).toString(); - } - assetServer = createModuleServer(); await listen(assetServer); const address = assetServer.address() as AddressInfo; @@ -112,7 +113,6 @@ describe("QWP in a real browser against QuestDB", () => { afterAll(async () => { await browser?.close(); if (assetServer) await close(assetServer); - await container?.stop(); }); it("decompresses a Zstd result batch in the browser bundle", async () => { @@ -153,285 +153,358 @@ describe("QWP in a real browser against QuestDB", () => { } }); - it("authenticates ingress and egress with the browser session cookie", async () => { - const context = await browser.newContext({ bypassCSP: true }); - const page = await context.newPage(); - const tableName = `qwp_browser_e2e_${Date.now()}`; - const ingressUrl = websocketUrl(questdbUrl, "/write/v4"); - const egressUrl = websocketUrl(questdbUrl, "/read/v1"); - + it("negotiates durable ACKs through the real browser WebSocket API", async () => { + const offeredProtocols: string[] = []; + const server = new WebSocketServer({ + host: "127.0.0.1", + port: 0, + handleProtocols: (protocols) => { + offeredProtocols.push(...protocols); + return protocols.has(QWP_DURABLE_ACK_WEBSOCKET_PROTOCOL) + ? QWP_DURABLE_ACK_WEBSOCKET_PROTOCOL + : false; + }, + }); + await waitForWebSocketServer(server); + const address = server.address() as AddressInfo; + const page = await browser.newPage(); try { - await page.goto(questdbUrl, { waitUntil: "domcontentloaded" }); - - const anonymousUpgrades = await page.evaluate( - async ({ moduleUrl, ingress, egress }) => { - const importModule = new Function("url", "return import(url)") as ( - url: string, - ) => Promise>; - const qwp = await importModule(moduleUrl); - const tryConnect = async ( - connect: (options: { - url: string; - }) => Promise<{ close(): Promise }>, - url: string, - ) => { - try { - const session = await connect({ url }); - await session.close(); - return { connected: true }; - } catch (error) { - const failure = error as { - name?: string; - kind?: string; - retryable?: boolean; - statusCode?: number; - }; - return { - connected: false, - name: failure.name, - kind: failure.kind, - retryable: failure.retryable ?? null, - statusCode: failure.statusCode ?? null, - }; - } - }; - return { - ingress: await tryConnect(qwp.connectQwpBrowserIngress, ingress), - egress: await tryConnect(qwp.connectQwpBrowserEgress, egress), - }; - }, - { moduleUrl: assetUrl, ingress: ingressUrl, egress: egressUrl }, - ); - expect(anonymousUpgrades).toEqual({ - ingress: { - connected: false, - name: "QwpUpgradeError", - kind: "opaque", - retryable: null, - statusCode: null, - }, - egress: { - connected: false, - name: "QwpUpgradeError", - kind: "opaque", - retryable: null, - statusCode: null, - }, - }); - - const login = await page.evaluate( - async ({ username, password, table }) => { - const query = - `create table ${table} (value long, ts timestamp) ` + - "timestamp(ts) partition by day wal"; - const response = await fetch( - `/exec?query=${encodeURIComponent(query)}&session=true`, - { - credentials: "include", - headers: { - Authorization: `Basic ${btoa(`${username}:${password}`)}`, - }, - }, - ); - return { status: response.status, body: await response.text() }; - }, - { username: USER, password: PASSWORD, table: tableName }, - ); - expect(login.status, login.body).toBe(200); - - const cookies = await context.cookies(questdbUrl); - expect(cookies).toContainEqual( - expect.objectContaining({ name: "qdb_session", httpOnly: true }), - ); - - const ingressResult = await page.evaluate( - async ({ moduleUrl, url, table, batchSize }) => { + await page.goto(assetUrl); + const result = await page.evaluate( + async ({ moduleUrl, url }) => { const importModule = new Function("url", "return import(url)") as ( url: string, ) => Promise>; const qwp = await importModule(moduleUrl); - const sender = await qwp.connectQwpBrowserSender( - { url }, - { autoFlush: false }, - ); + const connection = await qwp.connectQwpBrowserWebSocket({ + url, + requestDurableAck: true, + }); try { - for (let index = 0; index < batchSize; index++) { - await sender - .table(table) - .longColumn("value", 42n) - .at(BigInt(Date.now()) * 1_000n); - } - return { flushed: await sender.flush() }; + return connection.handshake; } finally { - await sender.close(); + await connection.close(); } }, { moduleUrl: assetUrl, - url: ingressUrl, - table: tableName, - batchSize: WRITE_BATCH_SIZE, + url: `ws://127.0.0.1:${address.port}/write/v4`, }, ); - expect(ingressResult).toEqual({ flushed: true }); - await expect - .poll( - () => - page.evaluate(async (table) => { - const response = await fetch( - `/exec?query=${encodeURIComponent(`select count() from ${table}`)}`, - { credentials: "include" }, - ); - if (!response.ok) return -1; - const result = await response.json(); - return result.dataset[0][0] as number; - }, tableName), - { timeout: 30_000, interval: 250 }, + expect(offeredProtocols).toContain(QWP_DURABLE_ACK_WEBSOCKET_PROTOCOL); + expect(result).toEqual({ qwpVersion: 1, durableAckEnabled: true }); + } finally { + await page.close(); + await closeWebSocketServer(server); + } + }); + + describe("against QuestDB", () => { + beforeAll(async () => { + const configuredUrl = process.env.QWP_BROWSER_E2E_URL; + if (configuredUrl) { + questdbUrl = new URL(configuredUrl).toString(); + } else { + container = await new GenericContainer( + process.env.QWP_BROWSER_E2E_IMAGE ?? "questdb/questdb:nightly", ) - .toBe(WRITE_BATCH_SIZE); + .withEnvironment({ + QDB_HTTP_USER: USER, + QDB_HTTP_PASSWORD: PASSWORD, + }) + .withExposedPorts(QUESTDB_HTTP_PORT) + .start(); + questdbUrl = new URL( + `http://${container.getHost()}:${container.getMappedPort(QUESTDB_HTTP_PORT)}`, + ).toString(); + } + }); - const egressResult = await page.evaluate( - async ({ moduleUrl, url, table }) => { - const importModule = new Function("url", "return import(url)") as ( - url: string, - ) => Promise>; - const qwp = await importModule(moduleUrl); - const session = await qwp.connectQwpBrowserEgress({ url }); - try { - const query = await session.query( - `select value from ${table} where value = $1 and ts >= $2 order by ts`, + afterAll(async () => { + await container?.stop(); + }); + + it("authenticates ingress and egress with the browser session cookie", async () => { + const context = await browser.newContext({ bypassCSP: true }); + const page = await context.newPage(); + const tableName = `qwp_browser_e2e_${Date.now()}`; + const ingressUrl = websocketUrl(questdbUrl, "/write/v4"); + const egressUrl = websocketUrl(questdbUrl, "/read/v1"); + + try { + await page.goto(questdbUrl, { waitUntil: "domcontentloaded" }); + + const anonymousUpgrades = await page.evaluate( + async ({ moduleUrl, ingress, egress }) => { + const importModule = new Function("url", "return import(url)") as ( + url: string, + ) => Promise>; + const qwp = await importModule(moduleUrl); + const tryConnect = async ( + connect: (options: { + url: string; + }) => Promise<{ close(): Promise }>, + url: string, + ) => { + try { + const session = await connect({ url }); + await session.close(); + return { connected: true }; + } catch (error) { + const failure = error as { + name?: string; + kind?: string; + retryable?: boolean; + statusCode?: number; + }; + return { + connected: false, + name: failure.name, + kind: failure.kind, + retryable: failure.retryable ?? null, + statusCode: failure.statusCode ?? null, + }; + } + }; + return { + ingress: await tryConnect(qwp.connectQwpBrowserIngress, ingress), + egress: await tryConnect(qwp.connectQwpBrowserEgress, egress), + }; + }, + { moduleUrl: assetUrl, ingress: ingressUrl, egress: egressUrl }, + ); + expect(anonymousUpgrades).toEqual({ + ingress: { + connected: false, + name: "QwpUpgradeError", + kind: "opaque", + retryable: null, + statusCode: null, + }, + egress: { + connected: false, + name: "QwpUpgradeError", + kind: "opaque", + retryable: null, + statusCode: null, + }, + }); + + const login = await page.evaluate( + async ({ username, password, table }) => { + const query = + `create table ${table} (value long, ts timestamp) ` + + "timestamp(ts) partition by day wal"; + const response = await fetch( + `/exec?query=${encodeURIComponent(query)}&session=true`, { - binds: (binds: any) => - binds.setLong(0, 42n).setTimestampMicros(1, 0n), + credentials: "include", + headers: { + Authorization: `Basic ${btoa(`${username}:${password}`)}`, + }, }, ); - const values: string[] = []; - for await (const batch of query) { - for (const row of batch.rows()) values.push(String(row[0])); - } - const completion = await query.completion; + return { status: response.status, body: await response.text() }; + }, + { username: USER, password: PASSWORD, table: tableName }, + ); + expect(login.status, login.body).toBe(200); - const typedQuery = await session.query( - "select " + - "$1::boolean, $2::byte, $3::short, $4::char, " + - "$5::int, $6::long, $7::float, $8::double, " + - "$9::date, $10::timestamp, $11::timestamp_ns, " + - "$12::varchar, $13::uuid, $14::long256, " + - "cast($15 as geohash(60b)), $16::decimal(18, 4), " + - "$17::decimal(38, 6), $18::decimal(76, 10) " + - "from long_sequence(1)", - { - binds: (binds: any) => - binds - .setBoolean(0, true) - .setByte(1, 42) - .setShort(2, 1234) - .setChar(3, "Q") - .setInt(4, 2_000_000) - .setLong(5, 9_000_000_000n) - .setFloat(6, 3.25) - .setDouble(7, 2.5) - .setDate(8, 1_700_000_000_000n) - .setTimestampMicros(9, 1_700_000_000_000_000n) - .setTimestampNanos(10, 1_700_000_000_123_456_789n) - .setVarchar(11, "café") - .setUuid(12, "123e4567-e89b-12d3-a456-426614174000") - .setLong256(13, 1n, 2n, 3n, 4n) - .setGeohash(14, 60, 0x0fffffffffffffffn) - .setDecimal64(15, 4, 123_456_789n) - .setDecimal128(16, 6, 123_456_789_123_456n, 0n) - .setDecimal256(17, 10, 420_000_000_000n, 0n, 0n, 0n), - }, + const cookies = await context.cookies(questdbUrl); + expect(cookies).toContainEqual( + expect.objectContaining({ name: "qdb_session", httpOnly: true }), + ); + + const ingressResult = await page.evaluate( + async ({ moduleUrl, url, table, batchSize }) => { + const importModule = new Function("url", "return import(url)") as ( + url: string, + ) => Promise>; + const qwp = await importModule(moduleUrl); + const sender = await qwp.connectQwpBrowserSender( + { url }, + { autoFlush: false }, ); - let typedRow: any[] | undefined; - for await (const batch of typedQuery) { - typedRow = [...batch.rows()][0]; + try { + for (let index = 0; index < batchSize; index++) { + await sender + .table(table) + .longColumn("value", 42n) + .at(BigInt(Date.now()) * 1_000n); + } + return { flushed: await sender.flush() }; + } finally { + await sender.close(); } - await typedQuery.completion; - const normalize = (value: any): any => { - if (typeof value === "bigint") return value.toString(); - if (Array.isArray(value)) return value.map(normalize); - if (value && typeof value === "object") { - return Object.fromEntries( - Object.entries(value).map(([key, nested]) => [ - key, - normalize(nested), - ]), + }, + { + moduleUrl: assetUrl, + url: ingressUrl, + table: tableName, + batchSize: WRITE_BATCH_SIZE, + }, + ); + expect(ingressResult).toEqual({ flushed: true }); + + await expect + .poll( + () => + page.evaluate(async (table) => { + const response = await fetch( + `/exec?query=${encodeURIComponent(`select count() from ${table}`)}`, + { credentials: "include" }, ); + if (!response.ok) return -1; + const result = await response.json(); + return result.dataset[0][0] as number; + }, tableName), + { timeout: 30_000, interval: 250 }, + ) + .toBe(WRITE_BATCH_SIZE); + + const egressResult = await page.evaluate( + async ({ moduleUrl, url, table }) => { + const importModule = new Function("url", "return import(url)") as ( + url: string, + ) => Promise>; + const qwp = await importModule(moduleUrl); + const session = await qwp.connectQwpBrowserEgress({ url }); + try { + const query = await session.query( + `select value from ${table} where value = $1 and ts >= $2 order by ts`, + { + binds: (binds: any) => + binds.setLong(0, 42n).setTimestampMicros(1, 0n), + }, + ); + const values: string[] = []; + for await (const batch of query) { + for (const row of batch.rows()) values.push(String(row[0])); } - return value; - }; - return { - values, - completion: completion.kind, - typedRow: typedRow?.map(normalize), - }; - } finally { - await session.close(); - } - }, - { moduleUrl: assetUrl, url: egressUrl, table: tableName }, - ); - expect(egressResult).toEqual({ - values: Array.from({ length: WRITE_BATCH_SIZE }, () => "42"), - completion: "result-end", - typedRow: [ - true, - 42, - 1234, - "Q", - 2_000_000, - "9000000000", - 3.25, - 2.5, - "1700000000000", - "1700000000000000", - "1700000000123456789", - "café", - { low: "11841725276408463360", high: "1314564453825188563" }, - { words: ["1", "2", "3", "4"] }, - { bits: "1152921504606846975", precisionBits: 60 }, - { unscaled: "123456789", scale: 4 }, - { unscaled: "123456789123456", scale: 6 }, - { unscaled: "420000000000", scale: 10 }, - ], - }); - } finally { - await page - .evaluate(async (table) => { - await fetch( - `/exec?query=${encodeURIComponent(`drop table ${table}`)}`, - { - credentials: "include", - }, - ); - }, tableName) - .catch(() => undefined); - await context.close(); - } - }); + const completion = await query.completion; + + const typedQuery = await session.query( + "select " + + "$1::boolean, $2::byte, $3::short, $4::char, " + + "$5::int, $6::long, $7::float, $8::double, " + + "$9::date, $10::timestamp, $11::timestamp_ns, " + + "$12::varchar, $13::uuid, $14::long256, " + + "cast($15 as geohash(60b)), $16::decimal(18, 4), " + + "$17::decimal(38, 6), $18::decimal(76, 10) " + + "from long_sequence(1)", + { + binds: (binds: any) => + binds + .setBoolean(0, true) + .setByte(1, 42) + .setShort(2, 1234) + .setChar(3, "Q") + .setInt(4, 2_000_000) + .setLong(5, 9_000_000_000n) + .setFloat(6, 3.25) + .setDouble(7, 2.5) + .setDate(8, 1_700_000_000_000n) + .setTimestampMicros(9, 1_700_000_000_000_000n) + .setTimestampNanos(10, 1_700_000_000_123_456_789n) + .setVarchar(11, "café") + .setUuid(12, "123e4567-e89b-12d3-a456-426614174000") + .setLong256(13, 1n, 2n, 3n, 4n) + .setGeohash(14, 60, 0x0fffffffffffffffn) + .setDecimal64(15, 4, 123_456_789n) + .setDecimal128(16, 6, 123_456_789_123_456n, 0n) + .setDecimal256(17, 10, 420_000_000_000n, 0n, 0n, 0n), + }, + ); + let typedRow: any[] | undefined; + for await (const batch of typedQuery) { + typedRow = [...batch.rows()][0]; + } + await typedQuery.completion; + const normalize = (value: any): any => { + if (typeof value === "bigint") return value.toString(); + if (Array.isArray(value)) return value.map(normalize); + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value).map(([key, nested]) => [ + key, + normalize(nested), + ]), + ); + } + return value; + }; + return { + values, + completion: completion.kind, + typedRow: typedRow?.map(normalize), + }; + } finally { + await session.close(); + } + }, + { moduleUrl: assetUrl, url: egressUrl, table: tableName }, + ); + expect(egressResult).toEqual({ + values: Array.from({ length: WRITE_BATCH_SIZE }, () => "42"), + completion: "result-end", + typedRow: [ + true, + 42, + 1234, + "Q", + 2_000_000, + "9000000000", + 3.25, + 2.5, + "1700000000000", + "1700000000000000", + "1700000000123456789", + "café", + { low: "11841725276408463360", high: "1314564453825188563" }, + { words: ["1", "2", "3", "4"] }, + { bits: "1152921504606846975", precisionBits: 60 }, + { unscaled: "123456789", scale: 4 }, + { unscaled: "123456789123456", scale: 6 }, + { unscaled: "420000000000", scale: 10 }, + ], + }); + } finally { + await page + .evaluate(async (table) => { + await fetch( + `/exec?query=${encodeURIComponent(`drop table ${table}`)}`, + { + credentials: "include", + }, + ); + }, tableName) + .catch(() => undefined); + await context.close(); + } + }); + + it("rejects durable ACK opt-in when the server does not advertise it", async () => { + await expect( + connectQwpNodeIngress({ + url: websocketUrl(questdbUrl, "/write/v4"), + authorization: `Basic ${Buffer.from(`${USER}:${PASSWORD}`).toString("base64")}`, + requestDurableAck: true, + }), + ).rejects.toBeInstanceOf(QwpDurableAckUnavailableError); + }); - it("rejects durable ACK opt-in when the server does not advertise it", async () => { - await expect( - connectQwpNodeIngress({ + it("negotiates the server QWP version and ingress batch cap", async () => { + const connection = await connectQwpNodeWebSocket({ url: websocketUrl(questdbUrl, "/write/v4"), authorization: `Basic ${Buffer.from(`${USER}:${PASSWORD}`).toString("base64")}`, - requestDurableAck: true, - }), - ).rejects.toBeInstanceOf(QwpDurableAckUnavailableError); - }); - - it("negotiates the server QWP version and ingress batch cap", async () => { - const connection = await connectQwpNodeWebSocket({ - url: websocketUrl(questdbUrl, "/write/v4"), - authorization: `Basic ${Buffer.from(`${USER}:${PASSWORD}`).toString("base64")}`, + }); + try { + expect(connection.handshake.qwpVersion).toBe(1); + expect(connection.handshake.maxBatchSizeBytes).toBeGreaterThan(12); + } finally { + await connection.close(); + } }); - try { - expect(connection.handshake.qwpVersion).toBe(1); - expect(connection.handshake.maxBatchSizeBytes).toBeGreaterThan(12); - } finally { - await connection.close(); - } }); }); diff --git a/test/qwp/core.test.ts b/test/qwp/core.test.ts index d559dbd..6a64ed9 100644 --- a/test/qwp/core.test.ts +++ b/test/qwp/core.test.ts @@ -6,18 +6,22 @@ import { decodeQwpIngressResponse, decodeQwpIngressSymbolDictionaryDelta, decodeQwpVarint, + addQwpDurableAckWebSocketProtocol, encodeQwpCancel, encodeQwpAcceptEncoding, encodeQwpCredit, + encodeQwpDurableAckPollFrame, encodeQwpFrame, encodeQwpGorilla, encodeQwpIngressFrame, encodeQwpQueryRequest, encodeQwpVarint, QWP_COLUMN_TYPE, + QWP_DURABLE_ACK_WEBSOCKET_PROTOCOL, QWP_EGRESS_CAPABILITY, QWP_EGRESS_MESSAGE, QWP_FLAG_GORILLA, + QWP_FLAG_DURABLE_ACK_POLL, QWP_HEADER_SIZE, QWP_MAGIC, QWP_STATUS, @@ -79,6 +83,39 @@ describe("QWP browser-safe byte core", () => { }); }); +describe("QWP browser durable-ACK negotiation", () => { + it("adds the capability token without mutating or duplicating protocols", () => { + expect(addQwpDurableAckWebSocketProtocol(undefined)).toBe( + QWP_DURABLE_ACK_WEBSOCKET_PROTOCOL, + ); + expect(addQwpDurableAckWebSocketProtocol("application.v1")).toEqual([ + "application.v1", + QWP_DURABLE_ACK_WEBSOCKET_PROTOCOL, + ]); + const protocols = ["application.v1"]; + expect(addQwpDurableAckWebSocketProtocol(protocols)).toEqual([ + "application.v1", + QWP_DURABLE_ACK_WEBSOCKET_PROTOCOL, + ]); + expect(protocols).toEqual(["application.v1"]); + expect( + addQwpDurableAckWebSocketProtocol([ + "application.v1", + QWP_DURABLE_ACK_WEBSOCKET_PROTOCOL, + ]), + ).toEqual(["application.v1", QWP_DURABLE_ACK_WEBSOCKET_PROTOCOL]); + }); + + it("encodes a side-effect-free table-less durable progress poll", () => { + expect(decodeQwpFrame(encodeQwpDurableAckPollFrame())).toMatchObject({ + flags: QWP_FLAG_DURABLE_ACK_POLL, + tableCount: 0, + payloadLength: 0, + payload: new Uint8Array(), + }); + }); +}); + describe("QWP egress compression negotiation", () => { it("builds raw and Zstd upgrade preferences", () => { expect(encodeQwpAcceptEncoding("raw", 1)).toBeUndefined(); diff --git a/test/qwp/session.test.ts b/test/qwp/session.test.ts index 74504ee..3b6e47c 100644 --- a/test/qwp/session.test.ts +++ b/test/qwp/session.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it, vi } from "vitest"; import { + connectQwpBrowserIngress, connectQwpBrowserWebSocket, + createQwpBrowserSender, QwpWebSocketLike, } from "../../src/qwp/browser"; import { @@ -16,7 +18,9 @@ import { QWP_UPGRADE_ERROR_KIND, QwpBatchTooLargeError, QwpByteWriter, + encodeQwpDurableAckPollFrame, encodeQwpFrame, + QWP_DURABLE_ACK_WEBSOCKET_PROTOCOL, QwpIngressNackError, QwpIngressSession, QwpIngressSessionClosedError, @@ -31,6 +35,7 @@ type Listener = (event: unknown) => void; class FakeWebSocket { binaryType = "blob"; readyState = 0; + protocol = ""; bufferedAmount = 0; readonly sent: Uint8Array[] = []; readonly closeCalls: { code?: number; reason?: string }[] = []; @@ -247,6 +252,74 @@ describe("QWP WebSocket adapters", () => { await connection.close(); }); + it("negotiates durable ACKs through a browser WebSocket subprotocol", async () => { + const socket = new FakeWebSocket(); + socket.protocol = QWP_DURABLE_ACK_WEBSOCKET_PROTOCOL; + let capturedProtocols: string | string[] | undefined; + const connecting = connectQwpBrowserWebSocket({ + url: "ws://localhost:9000/write/v4", + protocols: ["application.v1"], + requestDurableAck: true, + webSocketFactory: (_url, protocols) => { + capturedProtocols = protocols; + return asQwpSocket(socket); + }, + }); + socket.open(); + + const connection = await connecting; + expect(capturedProtocols).toEqual([ + "application.v1", + QWP_DURABLE_ACK_WEBSOCKET_PROTOCOL, + ]); + expect(connection.handshake).toEqual({ + qwpVersion: 1, + durableAckEnabled: true, + }); + await connection.close(); + }); + + it("rejects browser durable ACK opt-in without subprotocol confirmation", async () => { + const socket = new FakeWebSocket(); + const connecting = connectQwpBrowserWebSocket({ + url: "ws://localhost:9000/write/v4", + requestDurableAck: true, + webSocketFactory: () => asQwpSocket(socket), + }); + socket.open(); + + await expect(connecting).rejects.toMatchObject({ + name: "QwpDurableAckUnavailableError", + kind: QWP_UPGRADE_ERROR_KIND.CAPABILITY_MISMATCH, + retryable: false, + tryNextEndpoint: true, + url: "ws://localhost:9000/write/v4", + } satisfies Partial); + expect(socket.closeCalls).toHaveLength(1); + }); + + it("requests browser durable ACKs when the high-level sender awaits them", async () => { + const socket = new FakeWebSocket(); + socket.protocol = QWP_DURABLE_ACK_WEBSOCKET_PROTOCOL; + let capturedProtocols: string | string[] | undefined; + const sender = createQwpBrowserSender( + { + url: "ws://localhost:9000/write/v4", + webSocketFactory: (_url, protocols) => { + capturedProtocols = protocols; + return asQwpSocket(socket); + }, + }, + { awaitDurableAck: true }, + ); + const connecting = sender.connect(); + socket.open(); + + await expect(connecting).resolves.toBe(true); + expect(capturedProtocols).toBe(QWP_DURABLE_ACK_WEBSOCKET_PROTOCOL); + await sender.close(); + }); + it("adds Node-only QWP upgrade headers", async () => { const socket = new FakeWebSocket(); let capturedHeaders: Record | undefined; @@ -1028,22 +1101,48 @@ describe("QwpIngressSession", () => { } }); - it("rejects durable keepalive on a transport without PING support", async () => { - const socket = new FakeWebSocket(); - const connecting = connectQwpBrowserWebSocket({ - url: "ws://localhost:9000/write/v4", - webSocketFactory: () => asQwpSocket(socket), - }); - socket.open(); - const connection = await connecting; - expect( - () => - new QwpIngressSession(connection, { + it("polls durable progress with table-less QWP frames in browsers", async () => { + vi.useFakeTimers(); + try { + const socket = new FakeWebSocket(); + socket.protocol = QWP_DURABLE_ACK_WEBSOCKET_PROTOCOL; + const connecting = connectQwpBrowserIngress( + { + url: "ws://localhost:9000/write/v4", + requestDurableAck: true, + webSocketFactory: () => asQwpSocket(socket), + }, + { + ackTimeoutMs: 100, durableAckKeepaliveMs: 25, - }), - ).toThrow(/PING support/); - expect(socket.closeCalls).toHaveLength(1); - await connection.close(); + }, + ); + socket.open(); + const session = await connecting; + socket.onSend = () => { + if (socket.sent.length === 1) { + socket.message( + ingressResponse(QWP_STATUS.OK, 0n, undefined, [["trades", 42n]]), + ); + return; + } + socket.message(durableResponse([["trades", 42n]])); + socket.message(ingressResponse(QWP_STATUS.OK, 1n)); + }; + + const ack = await session.sendFrame(Uint8Array.of(1)); + const durable = session.waitForDurable(ack); + await vi.advanceTimersByTimeAsync(25); + await expect(durable).resolves.toBeUndefined(); + expect(socket.sent).toHaveLength(2); + expect(socket.sent[1]).toEqual(encodeQwpDurableAckPollFrame()); + + await vi.advanceTimersByTimeAsync(100); + expect(socket.sent).toHaveLength(2); + await session.close(); + } finally { + vi.useRealTimers(); + } }); it("rejects the matching frame on NACK without breaking later ACKs", async () => { From c27e72e69d4accb5ae80b5835528dfadc7d64692 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 18 Aug 2026 00:47:27 +0100 Subject: [PATCH 021/265] feat(qwp): automate egress credit and timeouts --- README.md | 31 +++++-- src/qwp/egress-session.ts | 170 +++++++++++++++++++++++++++++++++---- test/qwp/browser.e2e.ts | 161 +++++++++++++++++++++++++++++++++++ test/qwp/egress.test.ts | 173 +++++++++++++++++++++++++++++++++++++- 4 files changed, 511 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 3c654e8..ff4611d 100644 --- a/README.md +++ b/README.md @@ -123,13 +123,20 @@ WebSocket upgrade. Raw batches remain the default for compatibility. ```typescript import { connectQwpNodeEgress } from "@questdb/nodejs-client/qwp/node"; -const session = await connectQwpNodeEgress({ - url: "ws://127.0.0.1:9000/read/v1", - compression: "zstd", - compressionLevel: 3, -}); +const session = await connectQwpNodeEgress( + { + url: "ws://127.0.0.1:9000/read/v1", + compression: "zstd", + compressionLevel: 3, + }, + { + queryTimeoutMs: 30_000, + }, +); try { - const query = await session.query("select * from trades"); + const query = await session.query("select * from trades", { + initialCredit: 1024 * 1024, + }); console.log("effective Zstd level", session.negotiatedZstdLevel); for await (const batch of query) { for (const row of batch.rows()) console.log(row); @@ -153,6 +160,18 @@ Both `"zstd"` and `"auto"` advertise Zstd followed by raw fallback, and the server still sends an individual batch raw when compression would make it larger. +A positive `initialCredit` enables byte-based egress flow control. The client +automatically replenishes the exact wire size of each result batch after the +async iterator advances past it, so a slow Node.js or browser consumer naturally +limits how far the server can stream ahead. Set `autoCredit: false` to manage +credit explicitly through `query.grantCredit()`. + +`queryTimeoutMs` sets the session's default query deadline; a per-query +`timeoutMs` overrides it, and zero disables the deadline. When a deadline +expires, the client rejects iteration and `query.completion` with +`QwpEgressQueryTimeoutError`, sends QWP `CANCEL`, and waits for the terminal +server response before accepting another query on that connection. + ### Authentication and secure connection #### Username and password authentication with HTTP transport diff --git a/src/qwp/egress-session.ts b/src/qwp/egress-session.ts index 6887e40..d6c4cb6 100644 --- a/src/qwp/egress-session.ts +++ b/src/qwp/egress-session.ts @@ -29,6 +29,8 @@ import { export interface QwpEgressSessionOptions { serverInfoTimeoutMs?: number; + /** Default per-query deadline. Zero or undefined disables query deadlines. */ + queryTimeoutMs?: number; /** Enables bounded reconnects. Active operations replay only with onReplayReset. */ reconnect?: QwpReconnectOptions; /** @@ -42,6 +44,13 @@ export interface QwpEgressSessionOptions { export interface QwpEgressQueryOptions { /** Zero means the server may stream without credit accounting. */ initialCredit?: number | bigint; + /** + * Replenishes positive initial credit by each RESULT_BATCH wire size after + * the async iterator advances past that batch. Defaults to true. + */ + autoCredit?: boolean; + /** Per-query deadline overriding the session default. Zero disables it. */ + timeoutMs?: number; /** Sets typed positional parameters; index 0 maps to SQL placeholder `$1`. */ binds?: QwpBindSetter; /** Advanced escape hatch for an already encoded bind section. */ @@ -52,16 +61,38 @@ export interface QwpEgressQueryOptions { resetDictionary?: boolean; } +interface QwpValidatedEgressSessionOptions { + readonly serverInfoTimeoutMs: number; + readonly queryTimeoutMs: number; +} + +function validateOptionalTimeout( + value: number | undefined, + name: string, +): number { + const timeout = value ?? 0; + if (!Number.isFinite(timeout) || timeout < 0) { + throw new RangeError(`${name} must be a non-negative finite number`); + } + return timeout; +} + function validateEgressSessionOptions( options: QwpEgressSessionOptions, -): number { - const timeout = options.serverInfoTimeoutMs ?? 15_000; - if (!Number.isFinite(timeout) || timeout <= 0) { +): QwpValidatedEgressSessionOptions { + const serverInfoTimeoutMs = options.serverInfoTimeoutMs ?? 15_000; + if (!Number.isFinite(serverInfoTimeoutMs) || serverInfoTimeoutMs <= 0) { throw new RangeError( "serverInfoTimeoutMs must be a positive finite number", ); } - return timeout; + return { + serverInfoTimeoutMs, + queryTimeoutMs: validateOptionalTimeout( + options.queryTimeoutMs, + "queryTimeoutMs", + ), + }; } export type QwpQueryCompletion = QwpResultEndMessage | QwpExecDoneMessage; @@ -77,6 +108,17 @@ export class QwpEgressQueryError extends Error { } } +/** A client-side query deadline expired and a QWP CANCEL was sent. */ +export class QwpEgressQueryTimeoutError extends Error { + constructor( + readonly requestId: bigint, + readonly timeoutMs: number, + ) { + super(`QWP query timed out after ${timeoutMs}ms [requestId=${requestId}]`); + this.name = "QwpEgressQueryTimeoutError"; + } +} + export class QwpEgressSessionClosedError extends Error { constructor(readonly closeInfo?: QwpConnectionCloseInfo) { super( @@ -94,18 +136,28 @@ interface QwpEgressQueryControl { requestId: bigint, additionalBytes: number | bigint, ): Promise; + expire(requestId: bigint, timeoutMs: number): void; +} + +interface QwpQueuedResultBatch { + readonly batch: QwpResultBatch; + readonly creditBytes: number; } /** One QWP query/statement and its stream of materialized result batches. */ export class QwpEgressQuery implements AsyncIterable { - private readonly batches = new QwpAsyncQueue(); + private readonly batches = new QwpAsyncQueue(); private readonly resolveCompletion: (value: QwpQueryCompletion) => void; private readonly rejectCompletion: (error: unknown) => void; + private deliveredCreditBytes = 0; + private terminal = false; + private timeoutTimer?: ReturnType; readonly completion: Promise; constructor( readonly requestId: bigint, private readonly control: QwpEgressQueryControl, + private readonly autoCredit: boolean, ) { let resolve!: (value: QwpQueryCompletion) => void; let reject!: (error: unknown) => void; @@ -121,7 +173,16 @@ export class QwpEgressQuery implements AsyncIterable { } [Symbol.asyncIterator](): AsyncIterator { - return this.batches[Symbol.asyncIterator](); + const iterator = this.batches[Symbol.asyncIterator](); + return { + next: async () => { + await this.releaseDeliveredCredit(); + const result = await iterator.next(); + if (result.done) return { value: undefined, done: true }; + this.deliveredCreditBytes = result.value.creditBytes; + return { value: result.value.batch, done: false }; + }, + }; } cancel(): Promise { @@ -132,27 +193,72 @@ export class QwpEgressQuery implements AsyncIterable { return this.control.grantCredit(this.requestId, additionalBytes); } + /** @internal Starts the deadline after QUERY_REQUEST reaches the transport. */ + armTimeout(timeoutMs: number): void { + if (timeoutMs === 0 || this.terminal) return; + this.timeoutTimer = setTimeout(() => { + this.timeoutTimer = undefined; + this.control.expire(this.requestId, timeoutMs); + }, timeoutMs); + } + /** @internal */ - push(batch: QwpResultBatch): void { - this.batches.push(batch); + push(batch: QwpResultBatch, creditBytes: number): void { + if (this.terminal) return; + this.batches.push({ batch, creditBytes }); } /** @internal */ finish(completion: QwpQueryCompletion): void { + if (this.terminal) return; + this.terminal = true; + this.clearTimeout(); + this.deliveredCreditBytes = 0; this.batches.end(); this.resolveCompletion(completion); } /** @internal */ fail(error: unknown): void { + if (this.terminal) return; + this.terminal = true; + this.clearTimeout(); + this.deliveredCreditBytes = 0; this.batches.fail(error); this.rejectCompletion(error); } + /** @internal Discards queued results and surfaces a deadline immediately. */ + expire(error: QwpEgressQueryTimeoutError): void { + if (this.terminal) return; + this.batches.clear(); + this.fail(error); + } + /** @internal */ resetForReplay(): void { + this.deliveredCreditBytes = 0; this.batches.clear(); } + + private clearTimeout(): void { + if (!this.timeoutTimer) return; + clearTimeout(this.timeoutTimer); + this.timeoutTimer = undefined; + } + + private async releaseDeliveredCredit(): Promise { + const creditBytes = this.deliveredCreditBytes; + this.deliveredCreditBytes = 0; + if (!this.autoCredit || this.terminal || creditBytes === 0) return; + try { + await this.control.grantCredit(this.requestId, creditBytes); + } catch (error) { + // Transport failures fail the query through the session send tail. If a + // terminal response won the race, no replenishment is needed anymore. + if (!this.terminal) throw error; + } + } } /** @@ -168,6 +274,7 @@ export class QwpEgressSession implements QwpEgressQueryControl { private readonly resolveServerInfo: (value: QwpServerInfoMessage) => void; private readonly rejectServerInfo: (error: unknown) => void; private readonly serverInfoTimer: ReturnType; + private readonly defaultQueryTimeoutMs: number; private active?: QwpEgressQuery; private nextRequestId = 0n; private sendTail: Promise = Promise.resolve(); @@ -181,7 +288,7 @@ export class QwpEgressSession implements QwpEgressQueryControl { private readonly connection: QwpBinaryConnection, options: QwpEgressSessionOptions = {}, ) { - let timeout: number; + let validated: QwpValidatedEgressSessionOptions; try { if ( options.reconnect && @@ -191,7 +298,7 @@ export class QwpEgressSession implements QwpEgressQueryControl { "egress reconnect options require QwpEgressSession.connect(factory, options)", ); } - timeout = validateEgressSessionOptions(options); + validated = validateEgressSessionOptions(options); } catch (error) { try { void connection @@ -202,6 +309,7 @@ export class QwpEgressSession implements QwpEgressQueryControl { } throw error; } + this.defaultQueryTimeoutMs = validated.queryTimeoutMs; let resolve!: (value: QwpServerInfoMessage) => void; let reject!: (error: unknown) => void; this.ready = new Promise((res, rej) => { @@ -217,7 +325,7 @@ export class QwpEgressSession implements QwpEgressQueryControl { void this.connection .close(1002, "missing QWP SERVER_INFO") .catch(() => undefined); - }, timeout); + }, validated.serverInfoTimeoutMs); this.receiveLoop = this.consumeMessages(); } @@ -225,13 +333,13 @@ export class QwpEgressSession implements QwpEgressQueryControl { factory: QwpConnectionFactory, options: QwpEgressSessionOptions = {}, ): Promise { - const timeout = validateEgressSessionOptions(options); + const validated = validateEgressSessionOptions(options); const state: { session?: QwpEgressSession } = {}; const connection = options.reconnect ? await QwpReconnectingEgressConnection.connect( factory, options.reconnect, - timeout, + validated.serverInfoTimeoutMs, () => state.session?.prepareConnectionReset(), options.onReplayReset ? async (event) => { @@ -283,6 +391,16 @@ export class QwpEgressSession implements QwpEgressQueryControl { sql: string, options: QwpEgressQueryOptions = {}, ): Promise { + const timeoutMs = validateOptionalTimeout( + options.timeoutMs ?? this.defaultQueryTimeoutMs, + "timeoutMs", + ); + if ( + options.autoCredit !== undefined && + typeof options.autoCredit !== "boolean" + ) { + throw new TypeError("autoCredit must be a boolean"); + } await this.ready; this.throwIfUnavailable(); if (this.active) { @@ -296,13 +414,22 @@ export class QwpEgressSession implements QwpEgressQueryControl { } const requestId = this.nextRequestId++; - const query = new QwpEgressQuery(requestId, this); + const initialCredit = options.initialCredit ?? 0; + const creditEnabled = + typeof initialCredit === "bigint" + ? initialCredit > 0n + : initialCredit > 0; + const query = new QwpEgressQuery( + requestId, + this, + creditEnabled && (options.autoCredit ?? true), + ); this.decoder.resetQuerySchema(); this.active = query; const request: QwpQueryRequest = { requestId, sql, - initialCredit: options.initialCredit, + initialCredit, binds: options.binds, bindCount: options.bindCount, bindPayload: options.bindPayload, @@ -317,6 +444,7 @@ export class QwpEgressSession implements QwpEgressQueryControl { query.fail(error); throw error; } + query.armTimeout(timeoutMs); return query; } @@ -333,6 +461,16 @@ export class QwpEgressSession implements QwpEgressQueryControl { return this.send(encodeQwpCredit(requestId, additionalBytes)); } + expire(requestId: bigint, timeoutMs: number): void { + if (!this.active || this.active.requestId !== requestId) return; + this.active.expire(new QwpEgressQueryTimeoutError(requestId, timeoutMs)); + try { + void this.send(encodeQwpCancel(requestId)).catch(() => undefined); + } catch (error) { + this.fail(error); + } + } + close(code = 1000, reason = ""): Promise { if (!this.closePromise) this.closePromise = this.closeNow(code, reason); return this.closePromise; @@ -377,7 +515,7 @@ export class QwpEgressSession implements QwpEgressQueryControl { break; case "result-batch": { const query = this.requireActive(message.requestId); - query.push(this.decoder.decode(message)); + query.push(this.decoder.decode(message), payload.byteLength); break; } case "result-end": { diff --git a/test/qwp/browser.e2e.ts b/test/qwp/browser.e2e.ts index ca8eb90..ec54046 100644 --- a/test/qwp/browser.e2e.ts +++ b/test/qwp/browser.e2e.ts @@ -9,11 +9,17 @@ import { WebSocketServer } from "ws"; import { connectQwpNodeIngress, connectQwpNodeWebSocket, + encodeQwpFrame, QWP_COLUMN_TYPE, QWP_DURABLE_ACK_WEBSOCKET_PROTOCOL, + QWP_EGRESS_MESSAGE, QWP_STATUS, + QwpByteReader, + QwpByteWriter, QwpDurableAckUnavailableError, QwpTableBuffer, + readQwpVarint, + writeQwpVarint, } from "../../src/qwp/node"; const USER = process.env.QWP_BROWSER_E2E_USER ?? "admin"; @@ -90,6 +96,54 @@ async function executeSql(questdbUrl: string, sql: string): Promise { }); } +function writeU16String(writer: QwpByteWriter, value: string): void { + const bytes = new TextEncoder().encode(value); + writer.writeUint16(bytes.length).writeBytes(bytes); +} + +function browserServerInfo(): Uint8Array { + const payload = new QwpByteWriter(); + payload + .writeUint8(QWP_EGRESS_MESSAGE.SERVER_INFO) + .writeUint8(0) + .writeBigUint64(1n) + .writeUint32(0) + .writeBigInt64(0n); + writeU16String(payload, "browser-test-cluster"); + writeU16String(payload, "browser-test-node"); + return encodeQwpFrame(payload.toUint8Array()); +} + +function browserEmptyResultBatch(requestId: bigint): Uint8Array { + const payload = new QwpByteWriter(); + payload.writeUint8(QWP_EGRESS_MESSAGE.RESULT_BATCH).writeBigUint64(requestId); + writeQwpVarint(payload, 0); // batch sequence + writeQwpVarint(payload, 0); // table name + writeQwpVarint(payload, 0); // row count + writeQwpVarint(payload, 0); // column count + return encodeQwpFrame(payload.toUint8Array(), 0, 1); +} + +function browserResultEnd(requestId: bigint): Uint8Array { + const payload = new QwpByteWriter(); + payload.writeUint8(QWP_EGRESS_MESSAGE.RESULT_END).writeBigUint64(requestId); + writeQwpVarint(payload, 0); + writeQwpVarint(payload, 0); + return encodeQwpFrame(payload.toUint8Array()); +} + +function browserCancelled(requestId: bigint): Uint8Array { + const message = new TextEncoder().encode("cancelled by client deadline"); + const payload = new QwpByteWriter(); + payload + .writeUint8(QWP_EGRESS_MESSAGE.QUERY_ERROR) + .writeBigUint64(requestId) + .writeUint8(QWP_STATUS.CANCELLED) + .writeUint16(message.length) + .writeBytes(message); + return encodeQwpFrame(payload.toUint8Array()); +} + describe("QWP in a real browser", () => { let assetServer: Server; let assetUrl: string; @@ -200,6 +254,113 @@ describe("QWP in a real browser", () => { } }); + it("replenishes egress credit and cancels deadlines in a real browser", async () => { + const received: Uint8Array[] = []; + const resultBatch = browserEmptyResultBatch(0n); + const server = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + server.on("connection", (socket) => { + socket.send(browserServerInfo()); + socket.on("message", (data) => { + const payload = new Uint8Array(data as Buffer).slice(); + received.push(payload); + const reader = new QwpByteReader(payload); + const kind = reader.readUint8(); + const requestId = reader.readBigUint64(); + if (kind === QWP_EGRESS_MESSAGE.QUERY_REQUEST && requestId === 0n) { + socket.send(resultBatch); + } else if (kind === QWP_EGRESS_MESSAGE.CREDIT && requestId === 0n) { + socket.send(browserResultEnd(requestId)); + } else if (kind === QWP_EGRESS_MESSAGE.CANCEL && requestId === 1n) { + socket.send(browserCancelled(requestId)); + } + }); + }); + await waitForWebSocketServer(server); + const address = server.address() as AddressInfo; + const page = await browser.newPage(); + try { + await page.goto(assetUrl); + const result = await page.evaluate( + async ({ moduleUrl, url }) => { + const importModule = new Function("url", "return import(url)") as ( + url: string, + ) => Promise>; + const qwp = await importModule(moduleUrl); + const session = await qwp.connectQwpBrowserEgress( + { url }, + { queryTimeoutMs: 25 }, + ); + try { + const flowing = await session.query("select 1", { + initialCredit: 1, + }); + const iterator = flowing[Symbol.asyncIterator](); + const batch = await iterator.next(); + const done = await iterator.next(); + await flowing.completion; + + const expiring = await session.query("select sleep(1000)"); + let timeout: { + name?: string; + requestId?: string; + timeoutMs?: number; + }; + try { + await expiring.completion; + timeout = {}; + } catch (error) { + const failure = error as { + name?: string; + requestId?: bigint; + timeoutMs?: number; + }; + timeout = { + name: failure.name, + requestId: failure.requestId?.toString(), + timeoutMs: failure.timeoutMs, + }; + } + await new Promise((resolve) => setTimeout(resolve, 25)); + return { + batchRows: batch.value.rowCount, + done: done.done, + timeout, + }; + } finally { + await session.close(); + } + }, + { + moduleUrl: assetUrl, + url: `ws://127.0.0.1:${address.port}/read/v1`, + }, + ); + + expect(result).toEqual({ + batchRows: 0, + done: true, + timeout: { + name: "QwpEgressQueryTimeoutError", + requestId: "1", + timeoutMs: 25, + }, + }); + expect(received.map((payload) => payload[0])).toEqual([ + QWP_EGRESS_MESSAGE.QUERY_REQUEST, + QWP_EGRESS_MESSAGE.CREDIT, + QWP_EGRESS_MESSAGE.QUERY_REQUEST, + QWP_EGRESS_MESSAGE.CANCEL, + ]); + const credit = new QwpByteReader(received[1]); + expect(credit.readUint8()).toBe(QWP_EGRESS_MESSAGE.CREDIT); + expect(credit.readBigUint64()).toBe(0n); + expect(readQwpVarint(credit)).toBe(BigInt(resultBatch.byteLength)); + } finally { + await page.close(); + await closeWebSocketServer(server); + } + }); + describe("against QuestDB", () => { beforeAll(async () => { const configuredUrl = process.env.QWP_BROWSER_E2E_URL; diff --git a/test/qwp/egress.test.ts b/test/qwp/egress.test.ts index 8edc0c3..bb67a31 100644 --- a/test/qwp/egress.test.ts +++ b/test/qwp/egress.test.ts @@ -16,6 +16,7 @@ import { QwpByteWriter, QwpConnectionCloseInfo, QwpEgressQueryError, + QwpEgressQueryTimeoutError, QwpEgressSession, QwpResultBatchDecoder, readQwpVarint, @@ -201,13 +202,17 @@ function scalarResultBatch(): Uint8Array { return encodeQwpFrame(payload.toUint8Array(), RESULT_FLAGS, 1); } -function queryError(requestId: bigint, message: string): Uint8Array { +function queryError( + requestId: bigint, + message: string, + status = QWP_STATUS.PARSE_ERROR, +): Uint8Array { const bytes = new TextEncoder().encode(message); const payload = new QwpByteWriter(); payload .writeUint8(QWP_EGRESS_MESSAGE.QUERY_ERROR) .writeBigUint64(requestId) - .writeUint8(QWP_STATUS.PARSE_ERROR) + .writeUint8(status) .writeUint16(bytes.length) .writeBytes(bytes); return encodeQwpFrame(payload.toUint8Array()); @@ -395,6 +400,17 @@ describe("QwpEgressSession", () => { ), ).rejects.toThrow("serverInfoTimeoutMs must be a positive finite number"); expect(factoryCalls).toBe(0); + + await expect( + QwpEgressSession.connect( + async () => { + factoryCalls++; + return new FakeConnection(); + }, + { queryTimeoutMs: -1 }, + ), + ).rejects.toThrow("queryTimeoutMs must be a non-negative finite number"); + expect(factoryCalls).toBe(0); }); it("closes the transport when SERVER_INFO does not arrive", async () => { @@ -470,6 +486,159 @@ describe("QwpEgressSession", () => { await session.close(); }); + it("automatically replenishes credit after the consumer advances", async () => { + const connection = new FakeConnection(); + const session = new QwpEgressSession(connection); + connection.receive(serverInfo()); + const query = await session.query("select * from x", { + initialCredit: 64, + }); + const resultFrame = firstResultBatch(query.requestId); + connection.receive(resultFrame); + + const iterator = query[Symbol.asyncIterator](); + const first = await iterator.next(); + expect(first.done).toBe(false); + expect(first.value?.rowCount).toBe(3); + expect(connection.sent).toHaveLength(1); + + const next = iterator.next(); + await vi.waitFor(() => expect(connection.sent).toHaveLength(2)); + const credit = new QwpByteReader(connection.sent[1]); + expect(credit.readUint8()).toBe(QWP_EGRESS_MESSAGE.CREDIT); + expect(credit.readBigUint64()).toBe(query.requestId); + expect(readQwpVarint(credit)).toBe(BigInt(resultFrame.byteLength)); + expect(credit.remaining).toBe(0); + + connection.receive(resultEnd(query.requestId)); + await expect(next).resolves.toEqual({ value: undefined, done: true }); + await query.completion; + await session.close(); + }); + + it("uses compressed RESULT_BATCH wire bytes for automatic credit", async () => { + const connection = new FakeConnection(); + const session = new QwpEgressSession(connection); + connection.receive(serverInfo()); + const query = await session.query("select 42", { initialCredit: 1 }); + const resultFrame = compressedIntResultBatch(query.requestId); + connection.receive(resultFrame); + + const iterator = query[Symbol.asyncIterator](); + await iterator.next(); + const next = iterator.next(); + await vi.waitFor(() => expect(connection.sent).toHaveLength(2)); + const credit = new QwpByteReader(connection.sent[1]); + expect(credit.readUint8()).toBe(QWP_EGRESS_MESSAGE.CREDIT); + expect(credit.readBigUint64()).toBe(query.requestId); + expect(readQwpVarint(credit)).toBe(BigInt(resultFrame.byteLength)); + + connection.receive(resultEnd(query.requestId, 100n)); + await next; + await query.completion; + await session.close(); + }); + + it("allows automatic credit replenishment to be disabled", async () => { + const connection = new FakeConnection(); + const session = new QwpEgressSession(connection); + connection.receive(serverInfo()); + const query = await session.query("select * from x", { + initialCredit: 64, + autoCredit: false, + }); + connection.receive(firstResultBatch(query.requestId)); + + const iterator = query[Symbol.asyncIterator](); + await iterator.next(); + const next = iterator.next(); + await Promise.resolve(); + expect(connection.sent).toHaveLength(1); + + await query.grantCredit(64); + expect(connection.sent).toHaveLength(2); + connection.receive(resultEnd(query.requestId)); + await next; + await query.completion; + await session.close(); + }); + + it("times out a query, sends CANCEL, and drains the terminal response", async () => { + vi.useFakeTimers(); + try { + const connection = new FakeConnection(); + const session = new QwpEgressSession(connection, { + queryTimeoutMs: 25, + }); + connection.receive(serverInfo()); + const query = await session.query( + "select * from long_sequence(1000000)", + { + initialCredit: 64, + }, + ); + const next = query[Symbol.asyncIterator]() + .next() + .catch((error: unknown) => error); + + await vi.advanceTimersByTimeAsync(25); + + await expect(next).resolves.toMatchObject({ + name: "QwpEgressQueryTimeoutError", + requestId: query.requestId, + timeoutMs: 25, + } satisfies Partial); + await expect(query.completion).rejects.toBeInstanceOf( + QwpEgressQueryTimeoutError, + ); + expect(connection.sent).toHaveLength(2); + const cancel = new QwpByteReader(connection.sent[1]); + expect(cancel.readUint8()).toBe(QWP_EGRESS_MESSAGE.CANCEL); + expect(cancel.readBigUint64()).toBe(query.requestId); + expect(cancel.remaining).toBe(0); + + await expect(session.query("select 2")).rejects.toThrow( + "a QWP query is already active", + ); + connection.receive( + queryError( + query.requestId, + "cancelled by client", + QWP_STATUS.CANCELLED, + ), + ); + await Promise.resolve(); + await Promise.resolve(); + + const nextQuery = await session.query("select 2", { timeoutMs: 0 }); + connection.receive(resultEnd(nextQuery.requestId, 0n)); + await nextQuery.completion; + expect(vi.getTimerCount()).toBe(0); + await session.close(); + } finally { + vi.useRealTimers(); + } + }); + + it("clears a query deadline when the query completes", async () => { + vi.useFakeTimers(); + try { + const connection = new FakeConnection(); + const session = new QwpEgressSession(connection); + connection.receive(serverInfo()); + const query = await session.query("select 1", { timeoutMs: 25 }); + connection.receive(resultEnd(query.requestId, 0n)); + await query.completion; + expect(vi.getTimerCount()).toBe(0); + + await vi.advanceTimersByTimeAsync(25); + expect(connection.sent).toHaveLength(1); + await session.close(); + } finally { + vi.useRealTimers(); + } + }); + it("streams a Zstd-compressed result through the high-level session", async () => { const connection = new FakeConnection(); const session = new QwpEgressSession(connection); From 05f6c98db8c443481332a6f738ea15da3bf54def Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 18 Aug 2026 08:30:01 +0100 Subject: [PATCH 022/265] feat(qwp): split ingress batches to server cap --- src/qwp/core/table.ts | 44 +++++++ src/qwp/ingress-session.ts | 189 ++++++++++++++++++++++++++-- test/qwp/core.test.ts | 20 +++ test/qwp/session.test.ts | 245 +++++++++++++++++++++++++++++++++++++ 4 files changed, 487 insertions(+), 11 deletions(-) diff --git a/src/qwp/core/table.ts b/src/qwp/core/table.ts index 8bdf880..d1a4530 100644 --- a/src/qwp/core/table.ts +++ b/src/qwp/core/table.ts @@ -174,6 +174,50 @@ export class QwpTableBuffer { } } + /** + * Copies a completed half-open row range into an independent table buffer. + * Compact column values and their null bitmaps are sliced together, so the + * result can be encoded without materialising rows first. + */ + sliceRows(start: number, end: number): QwpTableBuffer { + if ( + !Number.isSafeInteger(start) || + !Number.isSafeInteger(end) || + start < 0 || + end < start || + end > this.rows + ) { + throw new RangeError( + `invalid QWP table row range [start=${start}, end=${end}, rows=${this.rows}]`, + ); + } + + const result = new QwpTableBuffer(this.name); + result.rows = end - start; + for (const column of this.columnList) { + let valueStart = 0; + for (let row = 0; row < start; row++) { + if (!column.nulls[row]) valueStart++; + } + let valueEnd = valueStart; + for (let row = start; row < end; row++) { + if (!column.nulls[row]) valueEnd++; + } + const sliced: QwpColumnBuffer = { + name: column.name, + type: column.type, + values: column.values.slice(valueStart, valueEnd), + nulls: column.nulls.slice(start, end), + size: end - start, + geohashPrecision: column.geohashPrecision, + decimalScale: column.decimalScale, + }; + result.columnList.push(sliced); + result.columnsByName.set(sliced.name, sliced); + } + return result; + } + reset(): void { this.columnList.length = 0; this.columnsByName.clear(); diff --git a/src/qwp/ingress-session.ts b/src/qwp/ingress-session.ts index 87160d3..8981445 100644 --- a/src/qwp/ingress-session.ts +++ b/src/qwp/ingress-session.ts @@ -2,6 +2,7 @@ import { decodeQwpIngressResponse, encodeQwpDurableAckPollFrame, encodeQwpIngressFrame, + QWP_FLAG_DEFER_COMMIT, QWP_STATUS, QwpIngressEncodeOptions, QwpIngressResponse, @@ -19,6 +20,126 @@ import { } from "./transport"; import { QwpReconnectingIngressConnection } from "./internal/reconnecting-ingress-connection"; +const QWP_FLAGS_OFFSET = 5; + +interface PlannedIngressFrames { + readonly frames: Uint8Array[]; +} + +function splitUnitCount(tables: readonly QwpTableBuffer[]): number { + return tables.reduce( + (total, table) => total + Math.max(1, table.rowCount), + 0, + ); +} + +function splitTablesAtUnit( + tables: readonly QwpTableBuffer[], + leftUnitCount: number, +): [QwpTableBuffer[], QwpTableBuffer[]] { + const left: QwpTableBuffer[] = []; + const right: QwpTableBuffer[] = []; + let remaining = leftUnitCount; + + for (const table of tables) { + if (remaining <= 0) { + right.push(table); + } else if (table.rowCount === 0) { + left.push(table); + remaining--; + } else if (remaining >= table.rowCount) { + left.push(table); + remaining -= table.rowCount; + } else { + left.push(table.sliceRows(0, remaining)); + right.push(table.sliceRows(remaining, table.rowCount)); + remaining = 0; + } + } + return [left, right]; +} + +/** + * Preflights a logical ingress flush without publishing any frame. Oversized + * candidates are bisected in table/row order. Accepted candidates advance a + * delta dictionary transactionally; any terminal failure restores its initial + * size. Non-final frames defer commit so the final frame closes the group. + */ +function planIngressFrames( + tables: readonly QwpTableBuffer[], + encodeOptions: QwpIngressEncodeOptions, + maxBatchSizeBytes: number, +): PlannedIngressFrames { + const dictionary = encodeOptions.dictionary; + const initialDictionarySize = dictionary?.size; + let confirmedMaxSymbolId = encodeOptions.confirmedMaxSymbolId ?? -1; + const frames: Uint8Array[] = []; + + const plan = (candidate: readonly QwpTableBuffer[]): void => { + const dictionarySize = dictionary?.size; + const frame = encodeQwpIngressFrame(candidate, { + ...encodeOptions, + deferCommit: false, + dictionary, + confirmedMaxSymbolId: dictionary + ? confirmedMaxSymbolId + : encodeOptions.confirmedMaxSymbolId, + }); + if (frame.byteLength <= maxBatchSizeBytes) { + frames.push(frame); + if (dictionary) confirmedMaxSymbolId = dictionary.size - 1; + return; + } + + if (dictionarySize !== undefined) dictionary!.truncate(dictionarySize); + const units = splitUnitCount(candidate); + if (units <= 1) { + throw new QwpBatchTooLargeError(frame.byteLength, maxBatchSizeBytes); + } + const [left, right] = splitTablesAtUnit(candidate, Math.ceil(units / 2)); + plan(left); + plan(right); + }; + + try { + plan(tables); + const deferAll = encodeOptions.deferCommit ?? false; + frames.forEach((frame, index) => { + if (deferAll || index < frames.length - 1) { + frame[QWP_FLAGS_OFFSET] |= QWP_FLAG_DEFER_COMMIT; + } + }); + return { frames }; + } catch (error) { + if (initialDictionarySize !== undefined) { + dictionary!.truncate(initialDictionarySize); + } + throw error; + } +} + +function mergeIngressResponses( + responses: readonly QwpIngressResponse[], +): QwpIngressResponse { + const last = responses[responses.length - 1]; + const tables = new Map(); + for (const response of responses) { + for (const table of response.tables) { + const previous = tables.get(table.name); + if (previous === undefined || table.sequenceTransaction > previous) { + tables.set(table.name, table.sequenceTransaction); + } + } + } + return { + ...last, + tables: [...tables].map(([name, sequenceTransaction]) => ({ + name, + sequenceTransaction, + })), + }; +} + export interface QwpIngressSessionOptions { ackTimeoutMs?: number; /** @@ -37,6 +158,8 @@ export interface QwpIngressSessionOptions { * Optional local ingress frame cap. Browsers cannot read WebSocket upgrade * headers, so browser applications should set this to the server's configured * QWP cap. When the server also advertises a cap, the smaller value wins. + * Table batches are split at row boundaries automatically; an individual row + * that cannot fit is rejected with QwpBatchTooLargeError before it is sent. */ maxBatchSizeBytes?: number; /** @@ -224,7 +347,19 @@ export class QwpIngressSession { tables: readonly QwpTableBuffer[], encodeOptions: QwpIngressEncodeOptions = {}, ): Promise { - return this.sendFrame(encodeQwpIngressFrame(tables, encodeOptions)); + this.throwIfUnavailable(); + const cap = this.maxBatchSizeBytes; + if (cap === undefined) { + return this.sendFrame(encodeQwpIngressFrame(tables, encodeOptions)); + } + let planned: PlannedIngressFrames; + try { + planned = planIngressFrames(tables, encodeOptions, cap); + } catch (error) { + if (error instanceof QwpBatchTooLargeError) return Promise.reject(error); + throw error; + } + return this.sendPlannedFrames(planned.frames); } /** @@ -240,6 +375,38 @@ export class QwpIngressSession { ): Promise { this.throwIfUnavailable(); const previousSize = this.symbolDictionary.size; + const previousPublishedMaxSymbolId = this.publishedMaxSymbolId; + const previousDeltaSymbolsPublished = this.deltaSymbolsPublished; + const cap = this.maxBatchSizeBytes; + if (cap !== undefined) { + let planned: PlannedIngressFrames; + try { + planned = planIngressFrames( + tables, + { + ...encodeOptions, + dictionary: this.symbolDictionary, + confirmedMaxSymbolId: this.publishedMaxSymbolId, + }, + cap, + ); + } catch (error) { + if (error instanceof QwpBatchTooLargeError) + return Promise.reject(error); + throw error; + } + this.publishedMaxSymbolId = this.symbolDictionary.size - 1; + this.deltaSymbolsPublished = true; + try { + return this.sendPlannedFrames(planned.frames); + } catch (error) { + this.symbolDictionary.truncate(previousSize); + this.publishedMaxSymbolId = previousPublishedMaxSymbolId; + this.deltaSymbolsPublished = previousDeltaSymbolsPublished; + throw error; + } + } + let frame: Uint8Array; try { frame = encodeQwpIngressFrame(tables, { @@ -251,22 +418,14 @@ export class QwpIngressSession { this.symbolDictionary.truncate(previousSize); throw error; } - if ( - this.maxBatchSizeBytes !== undefined && - frame.byteLength > this.maxBatchSizeBytes - ) { - this.symbolDictionary.truncate(previousSize); - return Promise.reject( - new QwpBatchTooLargeError(frame.byteLength, this.maxBatchSizeBytes), - ); - } this.publishedMaxSymbolId = this.symbolDictionary.size - 1; this.deltaSymbolsPublished = true; try { return this.sendFrame(frame); } catch (error) { this.symbolDictionary.truncate(previousSize); - this.publishedMaxSymbolId = previousSize - 1; + this.publishedMaxSymbolId = previousPublishedMaxSymbolId; + this.deltaSymbolsPublished = previousDeltaSymbolsPublished; throw error; } } @@ -317,6 +476,14 @@ export class QwpIngressSession { return response; } + private sendPlannedFrames( + frames: readonly Uint8Array[], + ): Promise { + const responses = frames.map((frame) => this.sendFrame(frame)); + if (responses.length === 1) return responses[0]; + return Promise.all(responses).then(mergeIngressResponses); + } + /** * Waits until a durable ACK covers every table transaction in an OK ACK. * Durable tracking must have been enabled with durableAckKeepaliveMs. diff --git a/test/qwp/core.test.ts b/test/qwp/core.test.ts index 6a64ed9..7fad6b6 100644 --- a/test/qwp/core.test.ts +++ b/test/qwp/core.test.ts @@ -184,6 +184,26 @@ describe("QWP frame envelope", () => { }); describe("QWP ingress codec", () => { + it("slices compacted table rows without losing null positions", () => { + const table = new QwpTableBuffer("events"); + table.getOrCreateColumn("value", QWP_COLUMN_TYPE.LONG)!.values.push(10n); + table.nextRow(); + table.nextRow(); + table.getOrCreateColumn("value", QWP_COLUMN_TYPE.LONG)!.values.push(30n); + table.nextRow(); + + const sliced = table.sliceRows(1, 3); + expect(sliced.rowCount).toBe(2); + expect(sliced.columns[0]).toMatchObject({ + name: "value", + values: [30n], + nulls: [true, false], + size: 2, + }); + expect(() => encodeQwpIngressFrame([sliced])).not.toThrow(); + expect(() => table.sliceRows(-1, 2)).toThrow(/invalid.*row range/i); + }); + it("encodes a compacted LONG column with an LSB-first null bitmap", () => { const table = new QwpTableBuffer("t"); table.getOrCreateColumn("a", QWP_COLUMN_TYPE.LONG)!.values.push(1n); diff --git a/test/qwp/session.test.ts b/test/qwp/session.test.ts index 3b6e47c..93c63e6 100644 --- a/test/qwp/session.test.ts +++ b/test/qwp/session.test.ts @@ -14,12 +14,18 @@ import { import { QWP_COLUMN_TYPE, QWP_EGRESS_MESSAGE, + QWP_FLAG_DEFER_COMMIT, + QWP_FLAG_DELTA_SYMBOL_DICTIONARY, QWP_STATUS, QWP_UPGRADE_ERROR_KIND, QwpBatchTooLargeError, + QwpByteReader, QwpByteWriter, + decodeQwpFrame, + decodeQwpIngressSymbolDictionaryDelta, encodeQwpDurableAckPollFrame, encodeQwpFrame, + encodeQwpIngressFrame, QWP_DURABLE_ACK_WEBSOCKET_PROTOCOL, QwpIngressNackError, QwpIngressSession, @@ -28,6 +34,8 @@ import { QwpSendClosedError, QwpSendTimeoutError, QwpUpgradeError, + QwpSymbolDictionary, + readQwpVarintNumber, } from "../../src/qwp"; type Listener = (event: unknown) => void; @@ -191,6 +199,42 @@ function writeIngressTables( } } +function firstIngressTableRowCount(payload: Uint8Array): number { + const frame = decodeQwpFrame(payload); + const reader = new QwpByteReader(frame.payload); + if ((frame.flags & QWP_FLAG_DELTA_SYMBOL_DICTIONARY) !== 0) { + readQwpVarintNumber(reader, "dictionary start ID"); + const entries = readQwpVarintNumber(reader, "dictionary entry count"); + for (let index = 0; index < entries; index++) { + const length = readQwpVarintNumber(reader, "dictionary entry length"); + reader.readBytes(length, "dictionary entry"); + } + } + const nameLength = readQwpVarintNumber(reader, "table name length"); + reader.readBytes(nameLength, "table name"); + return readQwpVarintNumber(reader, "row count"); +} + +function longTable(name: string, values: readonly bigint[]): QwpTableBuffer { + const table = new QwpTableBuffer(name); + for (const value of values) { + table.getOrCreateColumn("value", QWP_COLUMN_TYPE.LONG)!.values.push(value); + table.nextRow(); + } + return table; +} + +function symbolTable(name: string, values: readonly string[]): QwpTableBuffer { + const table = new QwpTableBuffer(name); + for (const value of values) { + table + .getOrCreateColumn("symbol", QWP_COLUMN_TYPE.SYMBOL)! + .values.push(value); + table.nextRow(); + } + return table; +} + function serverInfoFrame(): Uint8Array { const writer = new QwpByteWriter(); writer @@ -320,6 +364,40 @@ describe("QWP WebSocket adapters", () => { await sender.close(); }); + it("automatically splits fluent browser sender rows under its configured cap", async () => { + const socket = new FakeWebSocket(); + const sizingDictionary = new QwpSymbolDictionary(); + const cap = encodeQwpIngressFrame([longTable("events", [1n])], { + gorilla: false, + dictionary: sizingDictionary, + confirmedMaxSymbolId: -1, + }).byteLength; + const sender = createQwpBrowserSender( + { + url: "ws://localhost:9000/write/v4", + webSocketFactory: () => asQwpSocket(socket), + }, + { autoFlush: false, encode: { gorilla: false } }, + { maxBatchSizeBytes: cap }, + ); + const connecting = sender.connect(); + socket.open(); + await connecting; + for (const value of [1n, 2n, 3n]) { + await sender.table("events").longColumn("value", value).atNow(); + } + socket.onSend = () => { + const sequence = BigInt(socket.sent.length - 1); + socket.message(ingressResponse(QWP_STATUS.OK, sequence)); + }; + + await expect(sender.flush()).resolves.toBe(true); + expect(socket.sent).toHaveLength(3); + expect(socket.sent.every((frame) => frame.byteLength <= cap)).toBe(true); + expect(socket.sent.map(firstIngressTableRowCount)).toEqual([1, 1, 1]); + await sender.close(); + }); + it("adds Node-only QWP upgrade headers", async () => { const socket = new FakeWebSocket(); let capturedHeaders: Record | undefined; @@ -363,6 +441,20 @@ describe("QWP WebSocket adapters", () => { await expect( session.sendFrame(new Uint8Array(4097)), ).rejects.toBeInstanceOf(QwpBatchTooLargeError); + socket.onSend = () => { + const sequence = BigInt(socket.sent.length - 1); + socket.message(ingressResponse(QWP_STATUS.OK, sequence)); + }; + const table = new QwpTableBuffer("events"); + for (const suffix of ["a", "b"]) { + table + .getOrCreateColumn("payload", QWP_COLUMN_TYPE.VARCHAR)! + .values.push(suffix.repeat(3_000)); + table.nextRow(); + } + await session.sendTables([table]); + expect(socket.sent).toHaveLength(2); + expect(socket.sent.every((frame) => frame.byteLength <= 4096)).toBe(true); await session.close(); }); @@ -975,6 +1067,159 @@ describe("QwpIngressSession", () => { await session.close(); }); + it("splits an oversized ingress flush at row boundaries under the negotiated cap", async () => { + const socket = new FakeWebSocket(); + const connecting = connectQwpBrowserWebSocket({ + url: "ws://localhost:9000/write/v4", + webSocketFactory: () => asQwpSocket(socket), + }); + socket.open(); + const rows = longTable("events", [10n, 20n, 30n, 40n]); + const cap = encodeQwpIngressFrame([rows.sliceRows(0, 1)], { + gorilla: false, + }).byteLength; + const session = new QwpIngressSession(await connecting, { + maxBatchSizeBytes: cap, + }); + socket.onSend = () => { + const sequence = BigInt(socket.sent.length - 1); + socket.message( + ingressResponse(QWP_STATUS.OK, sequence, undefined, [ + ["events", sequence + 1n], + ]), + ); + }; + + await expect( + session.sendTables([rows], { gorilla: false }), + ).resolves.toMatchObject({ + sequence: 3n, + tables: [{ name: "events", sequenceTransaction: 4n }], + }); + expect(socket.sent).toHaveLength(4); + expect(socket.sent.every((frame) => frame.byteLength <= cap)).toBe(true); + expect(socket.sent.map(firstIngressTableRowCount)).toEqual([1, 1, 1, 1]); + expect( + socket.sent.map( + (frame) => decodeQwpFrame(frame).flags & QWP_FLAG_DEFER_COMMIT, + ), + ).toEqual([ + QWP_FLAG_DEFER_COMMIT, + QWP_FLAG_DEFER_COMMIT, + QWP_FLAG_DEFER_COMMIT, + 0, + ]); + + await session.sendTables([longTable("events", [50n, 60n])], { + gorilla: false, + deferCommit: true, + }); + expect( + socket.sent + .slice(4) + .map((frame) => decodeQwpFrame(frame).flags & QWP_FLAG_DEFER_COMMIT), + ).toEqual([QWP_FLAG_DEFER_COMMIT, QWP_FLAG_DEFER_COMMIT]); + await session.close(); + }); + + it("advances automatic symbol deltas across split ingress frames", async () => { + const socket = new FakeWebSocket(); + const connecting = connectQwpBrowserWebSocket({ + url: "ws://localhost:9000/write/v4", + webSocketFactory: () => asQwpSocket(socket), + }); + socket.open(); + const symbols = ["symbol-0000", "symbol-1111", "symbol-2222"]; + const rows = symbolTable("trades", symbols); + const sizingDictionary = new QwpSymbolDictionary(); + const cap = encodeQwpIngressFrame([rows.sliceRows(0, 1)], { + dictionary: sizingDictionary, + confirmedMaxSymbolId: -1, + }).byteLength; + const session = new QwpIngressSession(await connecting, { + maxBatchSizeBytes: cap, + }); + socket.onSend = () => { + const sequence = BigInt(socket.sent.length - 1); + socket.message( + ingressResponse(QWP_STATUS.OK, sequence, undefined, [ + ["trades", sequence + 1n], + ]), + ); + }; + + await session.sendTablesDelta([rows]); + expect(socket.sent).toHaveLength(3); + expect(socket.sent.every((frame) => frame.byteLength <= cap)).toBe(true); + expect( + socket.sent.map( + (frame) => + decodeQwpFrame(frame).flags & QWP_FLAG_DELTA_SYMBOL_DICTIONARY, + ), + ).toEqual([ + QWP_FLAG_DELTA_SYMBOL_DICTIONARY, + QWP_FLAG_DELTA_SYMBOL_DICTIONARY, + QWP_FLAG_DELTA_SYMBOL_DICTIONARY, + ]); + expect( + socket.sent.map((frame) => decodeQwpIngressSymbolDictionaryDelta(frame)), + ).toEqual([ + { startId: 0, entries: [symbols[0]] }, + { startId: 1, entries: [symbols[1]] }, + { startId: 2, entries: [symbols[2]] }, + ]); + + await expect( + session.sendTablesDelta([symbolTable("trades", ["x".repeat(cap)])]), + ).rejects.toBeInstanceOf(QwpBatchTooLargeError); + expect(socket.sent).toHaveLength(3); + + await session.sendTablesDelta([symbolTable("trades", [symbols[0]])]); + expect(decodeQwpIngressSymbolDictionaryDelta(socket.sent[3])).toEqual({ + startId: 3, + entries: [], + }); + await session.sendTablesDelta([symbolTable("trades", ["symbol-3333"])]); + expect(decodeQwpIngressSymbolDictionaryDelta(socket.sent[4])).toEqual({ + startId: 3, + entries: ["symbol-3333"], + }); + await session.close(); + }); + + it("rejects an unsplittable ingress row before consuming a sequence", async () => { + const socket = new FakeWebSocket(); + const connecting = connectQwpBrowserWebSocket({ + url: "ws://localhost:9000/write/v4", + webSocketFactory: () => asQwpSocket(socket), + }); + socket.open(); + const small = longTable("events", [1n]); + const cap = encodeQwpIngressFrame([small]).byteLength; + const session = new QwpIngressSession(await connecting, { + maxBatchSizeBytes: cap, + }); + const oversized = new QwpTableBuffer("events"); + oversized + .getOrCreateColumn("payload", QWP_COLUMN_TYPE.VARCHAR)! + .values.push("x".repeat(cap)); + oversized.nextRow(); + + await expect(session.sendTables([oversized])).rejects.toMatchObject({ + name: "QwpBatchTooLargeError", + maxBatchSizeBytes: cap, + } satisfies Partial); + expect(socket.sent).toHaveLength(0); + + socket.onSend = () => { + socket.message(ingressResponse(QWP_STATUS.OK, 0n)); + }; + await expect(session.sendTables([small])).resolves.toMatchObject({ + sequence: 0n, + }); + await session.close(); + }); + it("registers ACK waiters before sending and preserves call order", async () => { const socket = new FakeWebSocket(); const connecting = connectQwpBrowserWebSocket({ From 49975c6c879b4b5aaa3e072dd841ea36ed1ad903 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 18 Aug 2026 08:41:05 +0100 Subject: [PATCH 023/265] feat(qwp): bootstrap browser authentication sessions --- README.md | 49 ++++++++ src/qwp/browser.ts | 248 ++++++++++++++++++++++++++++++++++++++- test/qwp/browser.e2e.ts | 33 ++++-- test/qwp/session.test.ts | 124 ++++++++++++++++++++ 4 files changed, 441 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index ff4611d..66abb0d 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,55 @@ await sender.flush(); await sender.close(); ``` +When QuestDB authentication is enabled, establish the browser's HttpOnly +`qdb_session` cookie over REST before opening a QWP WebSocket. A QuestDB REST +token and an OIDC access token both use the `bearer` form. The application is +responsible for obtaining an OIDC token from its identity provider; the client +does not run an interactive OIDC authorization flow. + +```typescript +import { + bootstrapQwpBrowserSession, + connectQwpBrowserSender, +} from "@questdb/nodejs-client/qwp/browser"; + +await bootstrapQwpBrowserSession({ + url: new URL("/exec", location.href), + authentication: { type: "bearer", token: oidcOrRestAccessToken }, + // QuestDB Enterprise only; omit to use the authenticated principal. + serviceAccount: "market_data_writer", +}); + +const sender = await connectQwpBrowserSender({ url }, { autoFlush: false }); +``` + +The bootstrap can also be attached to the connection options. It then runs +before each initial, reconnect, or failover WebSocket attempt: + +```typescript +const sender = await connectQwpBrowserSender( + { + url, + sessionBootstrap: { + authentication: { + type: "basic", + username: "admin", + password: "quest", + }, + }, + }, + { autoFlush: false }, +); +``` + +The REST request uses `credentials: "include"`. The default bootstrap URL is +`/exec` beside `/write/v4` or `/read/v1`; set `sessionBootstrap.url` explicitly +when a reverse proxy exposes a different REST path. The REST and WebSocket +routes should be served from the same browser origin (or configured with +credentialed CORS), otherwise the browser may decline to store or send the +HttpOnly cookies. JavaScript deliberately never reads `qdb_session` or the +Enterprise `qdbServiceAccount` cookie. + Browsers can request durable ingress acknowledgements without custom HTTP headers. The client offers a QWP WebSocket subprotocol and verifies that the server selected it before sending data. Browser keepalives use side-effect-free, diff --git a/src/qwp/browser.ts b/src/qwp/browser.ts index 0eebba6..5134ed7 100644 --- a/src/qwp/browser.ts +++ b/src/qwp/browser.ts @@ -16,6 +16,8 @@ import { QwpBinaryConnection, QwpConnectionFactory, QwpDurableAckUnavailableError, + QWP_UPGRADE_ERROR_KIND, + QwpUpgradeError, QwpWebSocketConnectOptions, } from "./transport"; import { QwpEgressSession, QwpEgressSessionOptions } from "./egress-session"; @@ -24,12 +26,246 @@ import { QwpSender, QwpSenderOptions } from "./sender"; export type { QwpWebSocketLike } from "./internal/websocket-connection"; +export type QwpBrowserSessionAuthentication = + | { + /** HTTP Basic authentication. */ + type: "basic"; + username: string; + password: string; + } + | { + /** QuestDB REST token or OIDC access token. */ + type: "bearer"; + token: string; + }; + +export type QwpBrowserFetch = ( + input: string | URL, + init?: RequestInit, +) => Promise; + +export interface QwpBrowserSessionBootstrapOptions { + /** Exact QuestDB `/exec` HTTP(S) URL used to create the session cookie. */ + url: string | URL; + authentication: QwpBrowserSessionAuthentication; + /** Optional Enterprise service account to assume for subsequent QWP use. */ + serviceAccount?: string; + /** Cancels only the REST bootstrap request. */ + signal?: AbortSignal; + /** Test or framework hook; defaults to the browser's global fetch. */ + fetch?: QwpBrowserFetch; +} + +export interface QwpBrowserSessionBootstrapResult { + readonly url: string; + readonly status: number; + readonly serviceAccount?: string; +} + +export type QwpBrowserSessionBootstrapConfig = Omit< + QwpBrowserSessionBootstrapOptions, + "url" +> & { + /** Defaults to `/exec` on the current QWP endpoint's HTTP origin. */ + url?: string | URL; +}; + +/** An HTTP rejection while creating a browser `qdb_session` cookie. */ +export class QwpBrowserSessionBootstrapError extends QwpUpgradeError { + constructor( + readonly responseBody: string, + url: string | URL, + statusCode: number, + statusMessage: string, + ) { + const authenticationFailure = statusCode === 401 || statusCode === 403; + const suffix = statusMessage ? ` ${statusMessage}` : ""; + const detail = responseBody ? `: ${responseBody}` : ""; + super( + `QWP browser session bootstrap rejected with HTTP ${statusCode}${suffix}${detail}`, + { + kind: authenticationFailure + ? QWP_UPGRADE_ERROR_KIND.AUTHENTICATION + : QWP_UPGRADE_ERROR_KIND.HTTP_REJECTED, + retryable: !authenticationFailure && statusCode >= 500, + tryNextEndpoint: !authenticationFailure, + url, + statusCode, + statusMessage, + }, + ); + this.name = "QwpBrowserSessionBootstrapError"; + } +} + +function validateAuthentication( + authentication: QwpBrowserSessionAuthentication, +): void { + if (authentication.type === "basic") { + if (!authentication.username) { + throw new TypeError("browser session username cannot be empty"); + } + if (authentication.username.includes(":")) { + throw new TypeError("browser session username cannot contain ':'"); + } + if (/\r|\n/.test(authentication.username + authentication.password)) { + throw new TypeError( + "browser session credentials cannot contain CR or LF", + ); + } + return; + } + if (authentication.type === "bearer") { + if (!authentication.token) { + throw new TypeError("browser session bearer token cannot be empty"); + } + if (/\r|\n/.test(authentication.token)) { + throw new TypeError( + "browser session bearer token cannot contain CR or LF", + ); + } + return; + } + throw new TypeError( + `unsupported browser session authentication type '${String((authentication as { type?: unknown }).type)}'`, + ); +} + +function encodeBase64Utf8(value: string): string { + const alphabet = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + const bytes = new TextEncoder().encode(value); + let result = ""; + for (let index = 0; index < bytes.length; index += 3) { + const first = bytes[index]; + const second = bytes[index + 1]; + const third = bytes[index + 2]; + result += alphabet[first >>> 2]; + result += alphabet[((first & 0x03) << 4) | ((second ?? 0) >>> 4)]; + result += + second === undefined + ? "=" + : alphabet[((second & 0x0f) << 2) | ((third ?? 0) >>> 6)]; + result += third === undefined ? "=" : alphabet[third & 0x3f]; + } + return result; +} + +function authorizationHeader( + authentication: QwpBrowserSessionAuthentication, +): string { + validateAuthentication(authentication); + return authentication.type === "basic" + ? `Basic ${encodeBase64Utf8(`${authentication.username}:${authentication.password}`)}` + : `Bearer ${authentication.token}`; +} + +function resolveHttpUrl(value: string | URL): URL { + const base = globalThis.location?.href; + const url = value instanceof URL ? new URL(value) : new URL(value, base); + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new TypeError( + `browser session bootstrap URL must use HTTP or HTTPS: ${url}`, + ); + } + return url; +} + +function serviceAccountSql(serviceAccount: string | undefined): string { + if (serviceAccount === undefined) return "select 1"; + if (!serviceAccount.trim()) { + throw new TypeError("browser session serviceAccount cannot be empty"); + } + return `assume service account '${serviceAccount.replace(/'/g, "''")}'`; +} + +function defaultBootstrapUrl(endpoint: string | URL): URL { + const base = globalThis.location?.href; + const url = + endpoint instanceof URL ? new URL(endpoint) : new URL(endpoint, base); + if (url.protocol === "ws:") url.protocol = "http:"; + else if (url.protocol === "wss:") url.protocol = "https:"; + else { + throw new TypeError(`QWP browser URL must use WS or WSS: ${url}`); + } + const suffix = /\/(?:write\/v4|read\/v1)\/?$/; + url.pathname = suffix.test(url.pathname) + ? url.pathname.replace(suffix, "/exec") + : "/exec"; + url.search = ""; + url.hash = ""; + return url; +} + +/** + * Authenticates over REST and asks QuestDB to issue the HttpOnly cookies a + * browser needs before opening QWP WebSockets. REST and OIDC tokens both use + * Bearer authentication. When `serviceAccount` is present the same request + * also creates Enterprise's `qdbServiceAccount` impersonation cookie. + */ +export async function bootstrapQwpBrowserSession( + options: QwpBrowserSessionBootstrapOptions, +): Promise { + const requestUrl = resolveHttpUrl(options.url); + requestUrl.searchParams.set( + "query", + serviceAccountSql(options.serviceAccount), + ); + requestUrl.searchParams.set("session", "true"); + requestUrl.hash = ""; + const fetcher = options.fetch ?? globalThis.fetch; + if (!fetcher) { + throw new Error("fetch is not available in this browser runtime"); + } + const response = await fetcher(requestUrl, { + method: "GET", + credentials: "include", + headers: { + Accept: "application/json", + Authorization: authorizationHeader(options.authentication), + "Cache-Control": "no-store", + }, + signal: options.signal, + }); + let responseBody = ""; + try { + responseBody = await response.text(); + } catch (error) { + if (response.ok) { + return { + url: requestUrl.toString(), + status: response.status, + serviceAccount: options.serviceAccount, + }; + } + responseBody = error instanceof Error ? error.message : String(error); + } + if (!response.ok) { + throw new QwpBrowserSessionBootstrapError( + responseBody.slice(0, 1_024), + requestUrl, + response.status, + response.statusText, + ); + } + return { + url: requestUrl.toString(), + status: response.status, + serviceAccount: options.serviceAccount, + }; +} + export interface QwpBrowserWebSocketOptions extends QwpWebSocketConnectOptions { /** * Requests durable ingress ACKs through browser-visible WebSocket * subprotocol negotiation. */ requestDurableAck?: boolean; + /** + * Authenticates over REST before every WebSocket connection attempt so the + * browser can attach QuestDB's HttpOnly session cookies to the upgrade. + */ + sessionBootstrap?: QwpBrowserSessionBootstrapConfig; /** Test or framework hook; defaults to the browser's global WebSocket. */ webSocketFactory?: ( url: string | URL, @@ -43,8 +279,8 @@ export interface QwpBrowserWebSocketOptions extends QwpWebSocketConnectOptions { * Browsers cannot set Authorization or X-QWP-* upgrade headers. QuestDB accepts * browser upgrades when Origin and Host have the same authority, so serve the * app from the QuestDB origin or route QWP through a same-origin reverse proxy. - * When authentication is enabled, the deployment must provide a - * browser-compatible authentication mechanism. + * When authentication is enabled, pass sessionBootstrap or call + * bootstrapQwpBrowserSession first so the browser can attach qdb_session. */ export function connectQwpBrowserWebSocket( options: QwpBrowserWebSocketOptions, @@ -63,11 +299,17 @@ export function createQwpBrowserConnectionFactory( ); } -function connectQwpBrowserEndpoint( +async function connectQwpBrowserEndpoint( options: QwpBrowserWebSocketOptions, endpoint: string | URL, ): Promise { validateQwpWebSocketTimeouts(options); + if (options.sessionBootstrap) { + await bootstrapQwpBrowserSession({ + ...options.sessionBootstrap, + url: options.sessionBootstrap.url ?? defaultBootstrapUrl(endpoint), + }); + } const factory = options.webSocketFactory ?? ((url: string | URL, protocols?: string | string[]) => { diff --git a/test/qwp/browser.e2e.ts b/test/qwp/browser.e2e.ts index ec54046..31c64b9 100644 --- a/test/qwp/browser.e2e.ts +++ b/test/qwp/browser.e2e.ts @@ -453,23 +453,36 @@ describe("QWP in a real browser", () => { }); const login = await page.evaluate( - async ({ username, password, table }) => { + async ({ moduleUrl, username, password, table }) => { + const importModule = new Function("url", "return import(url)") as ( + url: string, + ) => Promise>; + const qwp = await importModule(moduleUrl); + const bootstrap = await qwp.bootstrapQwpBrowserSession({ + url: new URL("/exec", location.href), + authentication: { type: "basic", username, password }, + }); const query = `create table ${table} (value long, ts timestamp) ` + "timestamp(ts) partition by day wal"; const response = await fetch( - `/exec?query=${encodeURIComponent(query)}&session=true`, - { - credentials: "include", - headers: { - Authorization: `Basic ${btoa(`${username}:${password}`)}`, - }, - }, + `/exec?query=${encodeURIComponent(query)}`, + { credentials: "include" }, ); - return { status: response.status, body: await response.text() }; + return { + bootstrapStatus: bootstrap.status, + status: response.status, + body: await response.text(), + }; + }, + { + moduleUrl: assetUrl, + username: USER, + password: PASSWORD, + table: tableName, }, - { username: USER, password: PASSWORD, table: tableName }, ); + expect(login.bootstrapStatus).toBe(200); expect(login.status, login.body).toBe(200); const cookies = await context.cookies(questdbUrl); diff --git a/test/qwp/session.test.ts b/test/qwp/session.test.ts index 93c63e6..9375789 100644 --- a/test/qwp/session.test.ts +++ b/test/qwp/session.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it, vi } from "vitest"; import { + bootstrapQwpBrowserSession, connectQwpBrowserIngress, connectQwpBrowserWebSocket, createQwpBrowserSender, + QwpBrowserSessionBootstrapError, QwpWebSocketLike, } from "../../src/qwp/browser"; import { @@ -277,6 +279,128 @@ describe("QWP WebSocket adapters", () => { }, ); + it("bootstraps a browser qdb_session with Basic authentication", async () => { + let requestedUrl: URL | undefined; + let requestedInit: RequestInit | undefined; + const result = await bootstrapQwpBrowserSession({ + url: "https://questdb.example/exec?tenant=blue", + authentication: { + type: "basic", + username: "admin", + password: "quest", + }, + fetch: async (input, init) => { + requestedUrl = new URL(input); + requestedInit = init; + return new Response('{"dataset":[[1]]}', { + status: 200, + statusText: "OK", + }); + }, + }); + + expect(result).toMatchObject({ status: 200 }); + expect(requestedUrl?.pathname).toBe("/exec"); + expect(requestedUrl?.searchParams.get("tenant")).toBe("blue"); + expect(requestedUrl?.searchParams.get("query")).toBe("select 1"); + expect(requestedUrl?.searchParams.get("session")).toBe("true"); + expect(requestedInit).toMatchObject({ + method: "GET", + credentials: "include", + headers: { + Accept: "application/json", + Authorization: "Basic YWRtaW46cXVlc3Q=", + "Cache-Control": "no-store", + }, + }); + }); + + it("bootstraps REST/OIDC bearer auth and safely quotes a service account", async () => { + let requestedUrl: URL | undefined; + let requestedInit: RequestInit | undefined; + const result = await bootstrapQwpBrowserSession({ + url: "https://questdb.example/exec", + authentication: { type: "bearer", token: "access-token" }, + serviceAccount: "market'maker", + fetch: async (input, init) => { + requestedUrl = new URL(input); + requestedInit = init; + return new Response("{}", { status: 200 }); + }, + }); + + expect(result).toMatchObject({ + status: 200, + serviceAccount: "market'maker", + }); + expect(requestedUrl?.searchParams.get("query")).toBe( + "assume service account 'market''maker'", + ); + expect(requestedInit).toMatchObject({ + credentials: "include", + headers: { Authorization: "Bearer access-token" }, + }); + }); + + it("classifies rejected browser session credentials without failing over", async () => { + const requestedHosts: string[] = []; + await expect( + connectQwpBrowserWebSocket({ + url: "wss://primary.example/write/v4", + failoverUrls: ["wss://secondary.example/write/v4"], + sessionBootstrap: { + authentication: { type: "bearer", token: "invalid" }, + fetch: async (input) => { + requestedHosts.push(new URL(input).host); + return new Response("invalid token", { + status: 401, + statusText: "Unauthorized", + }); + }, + }, + webSocketFactory: () => { + throw new Error("WebSocket must not open after failed login"); + }, + }), + ).rejects.toMatchObject({ + name: "QwpBrowserSessionBootstrapError", + kind: QWP_UPGRADE_ERROR_KIND.AUTHENTICATION, + retryable: false, + tryNextEndpoint: false, + statusCode: 401, + responseBody: "invalid token", + } satisfies Partial); + expect(requestedHosts).toEqual(["primary.example"]); + }); + + it("completes the browser session bootstrap before opening WebSocket", async () => { + const socket = new FakeWebSocket(); + const events: string[] = []; + const connecting = connectQwpBrowserWebSocket({ + url: "wss://questdb.example/proxy/write/v4", + sessionBootstrap: { + authentication: { type: "bearer", token: "rest-token" }, + fetch: async (input) => { + events.push(`fetch:${new URL(input).toString()}`); + return new Response("{}", { status: 200 }); + }, + }, + webSocketFactory: () => { + events.push("websocket"); + queueMicrotask(() => socket.open()); + return asQwpSocket(socket); + }, + }); + + const connection = await connecting; + expect(events).toHaveLength(2); + expect(events[0]).toContain( + "https://questdb.example/proxy/exec?query=select+1&session=true", + ); + expect(events[1]).toBe("websocket"); + await connection.close(); + }); + it("buffers browser messages until a consumer is attached", async () => { const socket = new FakeWebSocket(); const connecting = connectQwpBrowserWebSocket({ From d5b64549fd459813b04f611f88e75e5959bd6f18 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 18 Aug 2026 08:52:07 +0100 Subject: [PATCH 024/265] feat(qwp): add transactional sender mode --- README.md | 33 +++++++++++++ src/qwp/ingress-session.ts | 8 ++++ src/qwp/sender.ts | 77 ++++++++++++++++++++++++++---- test/qwp/sender.test.ts | 96 +++++++++++++++++++++++++++++++++++++- test/qwp/session.test.ts | 42 +++++++++++++++++ 5 files changed, 246 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 66abb0d..e37e9b0 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,39 @@ await sender.flush(); await sender.close(); ``` +For batches larger than the automatic flush threshold, transactional mode +keeps each auto-flushed frame in an open server-side transaction. An explicit +`flush()` (or its `commit()` alias) sends the group-closing frame and waits for +the cumulative ACK. QuestDB guarantees this atomicity per table; a flush that +contains multiple tables is not one cross-table transaction. + +```typescript +const sender = await connectQwpBrowserSender( + { url }, + { + autoFlushRows: 10_000, + transactional: true, + }, +); + +for (const event of events) { + await sender + .table("events") + .symbol("source", event.source) + .longColumn("value", event.value) + .at(event.timestamp, "ms"); +} +await sender.commit(); +await sender.close(); +``` + +The server intentionally withholds ACKs for deferred frames until commit. The +sender pipelines transactional auto-flushes without waiting for those ACKs, +then waits for all of them at `flush()`/`commit()`. If durable ACK waiting is +enabled, it starts only after the transaction commits. Closing without an +explicit commit abandons the open transaction and logs a warning; QuestDB +rolls it back when the WebSocket disconnects. + When QuestDB authentication is enabled, establish the browser's HttpOnly `qdb_session` cookie over REST before opening a QWP WebSocket. A QuestDB REST token and an OIDC access token both use the `bearer` form. The application is diff --git a/src/qwp/ingress-session.ts b/src/qwp/ingress-session.ts index 8981445..e9176b3 100644 --- a/src/qwp/ingress-session.ts +++ b/src/qwp/ingress-session.ts @@ -432,6 +432,9 @@ export class QwpIngressSession { sendFrame(frame: Uint8Array): Promise { this.throwIfUnavailable(); + const ackDeferredUntilCommit = + frame.byteLength > QWP_FLAGS_OFFSET && + (frame[QWP_FLAGS_OFFSET] & QWP_FLAG_DEFER_COMMIT) !== 0; if ( this.maxBatchSizeBytes !== undefined && frame.byteLength > this.maxBatchSizeBytes @@ -457,6 +460,11 @@ export class QwpIngressSession { void sending.then( () => { if (this.pending.get(sequence) !== pending) return; + // QuestDB deliberately sends no ACK for a deferred frame. The later + // group-closing frame has its own deadline and cumulatively resolves + // this waiter, so starting a per-frame timer here would make valid + // transactions fail merely because they stayed open for ackTimeoutMs. + if (ackDeferredUntilCommit) return; pending.timer = setTimeout(() => { if (!this.pending.delete(sequence)) return; pending.reject( diff --git a/src/qwp/sender.ts b/src/qwp/sender.ts index df69300..608fd13 100644 --- a/src/qwp/sender.ts +++ b/src/qwp/sender.ts @@ -25,6 +25,12 @@ export interface QwpSenderOptions { autoFlush?: boolean; autoFlushRows?: number; autoFlushIntervalMs?: number; + /** + * Keep auto-flushed rows in an open server-side transaction. An explicit + * flush()/commit() closes the transaction. QWP transactions are atomic per + * table, rather than across every table in a multi-table flush. + */ + transactional?: boolean; /** Wait for durable upload after every successful ingress ACK. */ awaitDurableAck?: boolean; durableAckTimeoutMs?: number; @@ -41,7 +47,7 @@ export interface QwpSenderSession { ): Promise; sendTablesDelta?( tables: readonly QwpTableBuffer[], - options?: Pick, + options?: Pick, ): Promise; waitForDurable( response: QwpIngressResponse, @@ -258,10 +264,14 @@ export class QwpSender { private closePromise?: Promise; private closing = false; private closed = false; + private hasDeferredMessages = false; + private deferredRowCount = 0; + private readonly deferredAcks: Promise[] = []; private readonly autoFlush: boolean; private readonly autoFlushRows: number; private readonly autoFlushIntervalMs: number; + private readonly transactional: boolean; private readonly log: QwpSenderLogger; constructor( @@ -272,6 +282,7 @@ export class QwpSender { this.autoFlushRows = options.autoFlushRows ?? DEFAULT_AUTO_FLUSH_ROWS; this.autoFlushIntervalMs = options.autoFlushIntervalMs ?? DEFAULT_AUTO_FLUSH_INTERVAL_MS; + this.transactional = options.transactional ?? false; validateNonNegativeInteger(this.autoFlushRows, "autoFlushRows"); validateNonNegativeInteger(this.autoFlushIntervalMs, "autoFlushIntervalMs"); if ( @@ -699,8 +710,21 @@ export class QwpSender { } flush(): Promise { + return this.enqueueFlush(false); + } + + /** + * Commits rows previously sent by transactional auto-flush. This is an + * ergonomic alias for flush(); pending local rows are included in the same + * group-closing frame. + */ + commit(): Promise { + return this.flush(); + } + + private enqueueFlush(deferCommit: boolean): Promise { this.throwIfUnavailable(); - const flushing = this.flushTail.then(() => this.flushNow()); + const flushing = this.flushTail.then(() => this.flushNow(deferCommit)); this.flushTail = flushing.then( () => undefined, () => undefined, @@ -743,6 +767,12 @@ export class QwpSender { `QWP sender contains ${this.pendingRowCount} completed row(s) and ${this.currentRow.size} unfinished column(s) which will be lost`, ); } + if (this.hasDeferredMessages) { + this.log( + "warn", + `QWP sender is closing with ${this.deferredRowCount} auto-flushed row(s) awaiting commit; QuestDB will roll the open transaction back`, + ); + } this.closed = true; if (sessionFailure) throw sessionFailure.reason; if (closeResult.status === "rejected") throw closeResult.reason; @@ -831,17 +861,22 @@ export class QwpSender { (this.autoFlushIntervalMs > 0 && Date.now() - this.lastFlushTime >= this.autoFlushIntervalMs)) ) { - await this.flush(); + await this.enqueueFlush(this.transactional); } } - private async flushNow(): Promise { - if (this.pendingRowCount === 0) return false; + private async flushNow(deferCommit: boolean): Promise { + if ( + this.pendingRowCount === 0 && + (deferCommit || !this.hasDeferredMessages) + ) { + return false; + } const session = await this.getSession(); const snapshots = this.tables .filter((table) => table.rows.length > 0) .map((table) => ({ table, rows: table.rows.slice() })); - if (snapshots.length === 0) return false; + if (snapshots.length === 0 && !this.hasDeferredMessages) return false; const wireTables = snapshots.map(({ table, rows }) => this.buildTable(table.name, rows), @@ -852,8 +887,14 @@ export class QwpSender { const response = (encode?.symbolDictionary ?? "delta") === "delta" && session.sendTablesDelta - ? session.sendTablesDelta(wireTables, { gorilla: encode?.gorilla }) - : session.sendTables(wireTables, { gorilla: encode?.gorilla }); + ? session.sendTablesDelta(wireTables, { + gorilla: encode?.gorilla, + deferCommit, + }) + : session.sendTables(wireTables, { + gorilla: encode?.gorilla, + deferCommit, + }); for (const { table, rows } of snapshots) table.rows.splice(0, rows.length); const sentRows = snapshots.reduce( (count, item) => count + item.rows.length, @@ -861,9 +902,27 @@ export class QwpSender { ); this.pendingRowCount -= sentRows; this.lastFlushTime = Date.now(); - this.log("debug", `Flushing ${sentRows} QWP row(s)`); + this.log( + "debug", + `${deferCommit ? "Auto-flushing" : "Flushing"} ${sentRows} QWP row(s)${deferCommit ? " with commit deferred" : ""}`, + ); + + if (deferCommit) { + this.hasDeferredMessages = true; + this.deferredRowCount += sentRows; + this.deferredAcks.push(response); + // The server intentionally withholds this ACK until a later commit. + // Observe rejection now so abandoning an open transaction during close + // never creates an unhandled rejection; flush()/commit() still awaits it. + void response.catch(() => undefined); + return true; + } const ack = await response; + const deferredAcks = this.deferredAcks.splice(0); + this.hasDeferredMessages = false; + this.deferredRowCount = 0; + await Promise.all(deferredAcks); if (this.options.awaitDurableAck) { await session.waitForDurable(ack, this.options.durableAckTimeoutMs); } diff --git a/test/qwp/sender.test.ts b/test/qwp/sender.test.ts index 3f1a7dd..ca40b12 100644 --- a/test/qwp/sender.test.ts +++ b/test/qwp/sender.test.ts @@ -36,7 +36,7 @@ class RecordingSession implements QwpSenderSession { sendTablesDelta( tables: readonly QwpTableBuffer[], - options?: Pick, + options?: Pick, ): Promise { this.deltaSendCount++; return this.sendTables(tables, options); @@ -51,6 +51,32 @@ class RecordingSession implements QwpSenderSession { } } +class CommitAwareSession extends RecordingSession { + private readonly deferred: { + resolve: (response: QwpIngressResponse) => void; + }[] = []; + + override sendTables( + tables: readonly QwpTableBuffer[], + options?: QwpIngressEncodeOptions, + ): Promise { + this.sends.push({ tables, options }); + const response = { + status: QWP_STATUS.OK, + sequence: BigInt(this.sends.length - 1), + tables: tables.map((table) => ({ + name: table.name, + sequenceTransaction: BigInt(table.rowCount), + })), + } satisfies QwpIngressResponse; + if (options?.deferCommit) { + return new Promise((resolve) => this.deferred.push({ resolve })); + } + for (const pending of this.deferred.splice(0)) pending.resolve(response); + return Promise.resolve(response); + } +} + class ClosingUnblocksSession extends RecordingSession { private rejectSend?: (error: Error) => void; @@ -226,6 +252,74 @@ describe("QWP high-level sender", () => { await expect(sender.flush()).resolves.toBe(false); }); + it("defers transactional auto-flush and commits without waiting on its withheld ACK", async () => { + const session = new CommitAwareSession(); + const sender = new QwpSender(async () => session, { + autoFlushRows: 1, + autoFlushIntervalMs: 0, + transactional: true, + awaitDurableAck: true, + }); + + await expect( + sender.table("events").longColumn("value", 42n).atNow(), + ).resolves.toBeUndefined(); + expect(session.sends).toHaveLength(1); + expect(session.sends[0]).toMatchObject({ + options: { deferCommit: true }, + }); + expect(session.durable).toHaveLength(0); + + await expect(sender.commit()).resolves.toBe(true); + expect(session.sends).toHaveLength(2); + expect(session.sends[1].tables).toHaveLength(0); + expect(session.sends[1]).toMatchObject({ + options: { deferCommit: false }, + }); + expect(session.durable).toHaveLength(1); + await expect(sender.flush()).resolves.toBe(false); + }); + + it("uses an explicit data flush to close a deferred transaction", async () => { + const session = new CommitAwareSession(); + const sender = new QwpSender(async () => session, { + autoFlushRows: 2, + autoFlushIntervalMs: 0, + transactional: true, + }); + + await sender.table("events").longColumn("value", 1n).atNow(); + await sender.table("events").longColumn("value", 2n).atNow(); + await sender.table("events").longColumn("value", 3n).atNow(); + + expect(session.sends).toHaveLength(1); + expect(session.sends[0].options?.deferCommit).toBe(true); + expect(session.sends[0].tables[0].rowCount).toBe(2); + await expect(sender.flush()).resolves.toBe(true); + expect(session.sends[1].tables[0].rowCount).toBe(1); + expect(session.sends[1].options?.deferCommit).toBe(false); + }); + + it("warns when close abandons an uncommitted transactional auto-flush", async () => { + const session = new CommitAwareSession(); + const messages: (string | Error)[] = []; + const sender = new QwpSender(async () => session, { + autoFlushRows: 1, + autoFlushIntervalMs: 0, + transactional: true, + log: (level, message) => { + if (level === "warn") messages.push(message); + }, + }); + + await sender.table("events").longColumn("value", 42n).atNow(); + await sender.close(); + expect(session.sends).toHaveLength(1); + expect(messages).toEqual([ + expect.stringContaining("1 auto-flushed row(s) awaiting commit"), + ]); + }); + it("allows the high-level sender to opt out of symbol deltas", async () => { const session = new RecordingSession(); const sender = new QwpSender(async () => session, { diff --git a/test/qwp/session.test.ts b/test/qwp/session.test.ts index 9375789..e9ffac9 100644 --- a/test/qwp/session.test.ts +++ b/test/qwp/session.test.ts @@ -522,6 +522,48 @@ describe("QWP WebSocket adapters", () => { await sender.close(); }); + it("pipelines transactional browser auto-flush until an explicit commit ACK", async () => { + const socket = new FakeWebSocket(); + const sender = createQwpBrowserSender( + { + url: "ws://localhost:9000/write/v4", + webSocketFactory: () => asQwpSocket(socket), + }, + { + autoFlushRows: 1, + autoFlushIntervalMs: 0, + transactional: true, + }, + { ackTimeoutMs: 10 }, + ); + socket.onSend = () => { + if (socket.sent.length === 2) { + socket.message( + ingressResponse(QWP_STATUS.OK, 1n, undefined, [["events", 1n]]), + ); + } + }; + const connecting = sender.connect(); + socket.open(); + await connecting; + + const autoFlush = sender + .table("events") + .longColumn("value", 42n) + .atNow(); + await expect(autoFlush).resolves.toBeUndefined(); + expect(socket.sent).toHaveLength(1); + expect(decodeQwpFrame(socket.sent[0]).flags & QWP_FLAG_DEFER_COMMIT).toBe( + QWP_FLAG_DEFER_COMMIT, + ); + await new Promise((resolve) => setTimeout(resolve, 25)); + + await expect(sender.flush()).resolves.toBe(true); + expect(socket.sent).toHaveLength(2); + expect(decodeQwpFrame(socket.sent[1]).flags & QWP_FLAG_DEFER_COMMIT).toBe(0); + await sender.close(); + }); + it("adds Node-only QWP upgrade headers", async () => { const socket = new FakeWebSocket(); let capturedHeaders: Record | undefined; From 62f9bf5ddc19e77faa6380bd8a059f2644cad5e1 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 18 Aug 2026 09:28:38 +0100 Subject: [PATCH 025/265] feat(qwp): add ingress observability metrics --- README.md | 40 ++++ src/qwp/ingress-session.ts | 212 ++++++++++++++++-- .../reconnecting-egress-connection.ts | 10 +- .../reconnecting-ingress-connection.ts | 86 ++++++- src/qwp/sender.ts | 67 +++++- src/qwp/transport.ts | 27 ++- test/qwp/reconnect.test.ts | 26 +++ test/qwp/sender.test.ts | 15 ++ test/qwp/session.test.ts | 95 +++++++- 9 files changed, 537 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index e37e9b0..ab00bd7 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,46 @@ enabled, it starts only after the transaction commits. Closing without an explicit commit abandons the open transaction and logs a warning; QuestDB rolls it back when the WebSocket disconnects. +Ingress sessions expose browser-safe progress/error callbacks and immutable +metrics snapshots. Reconnect events remain on `reconnect.onEvent`, keeping +connection topology separate from batch acceptance and durable progress. + +```typescript +import { + QWP_INGRESS_PROGRESS_KIND, + createQwpBrowserSender, +} from "@questdb/nodejs-client/qwp/browser"; + +const sender = createQwpBrowserSender( + { url }, + { autoFlush: false }, + { + reconnect: { + onEvent: (event) => console.info("QWP connection", event), + }, + onProgress: (event) => { + if (event.kind === QWP_INGRESS_PROGRESS_KIND.ACKNOWLEDGED) { + console.info("accepted through", event.sequence); + } + }, + onError: (event) => console.error("QWP ingress", event.error), + }, +); + +await sender.connect(); +const snapshot = sender.metrics; +console.info( + snapshot.totalRowsPublished, + snapshot.ingress?.totalFramesReplayed, +); +``` + +Snapshots distinguish the client-session acceptance sequence from persistent +replay watermarks. With durable ACKs, `replayAcknowledgedFrameSequence` +advances only after the durable watermark covers a frame. Observer exceptions +are contained so they cannot fail the session, but callbacks should remain +lightweight because browser and Node JavaScript share the event loop. + When QuestDB authentication is enabled, establish the browser's HttpOnly `qdb_session` cookie over REST before opening a QWP WebSocket. A QuestDB REST token and an OIDC access token both use the `bearer` form. The application is diff --git a/src/qwp/ingress-session.ts b/src/qwp/ingress-session.ts index e9176b3..8796661 100644 --- a/src/qwp/ingress-session.ts +++ b/src/qwp/ingress-session.ts @@ -171,11 +171,74 @@ export interface QwpIngressSessionOptions { durableAckKeepaliveMs?: number; onResponse?: (response: QwpIngressResponse) => void; onDurableAck?: (response: QwpIngressResponse) => void; + /** Monotonic send/accept/durability notifications. Callback errors are ignored. */ + onProgress?: (event: QwpIngressProgressEvent) => void; + /** Server rejections, deadlines, and terminal session failures. */ + onError?: (event: QwpIngressErrorEvent) => void; +} + +export const QWP_INGRESS_PROGRESS_KIND = { + PUBLISHED: "published", + ACKNOWLEDGED: "acknowledged", + DURABLE_ACKNOWLEDGED: "durable-acknowledged", +} as const; + +export type QwpIngressProgressKind = + (typeof QWP_INGRESS_PROGRESS_KIND)[keyof typeof QWP_INGRESS_PROGRESS_KIND]; + +/** Immutable point-in-time ingress telemetry, safe in browsers and Node.js. */ +export interface QwpIngressMetrics { + /** Highest client-session sequence allocated, or -1 before the first send. */ + readonly publishedSequence: bigint; + /** Highest client-session sequence covered by a successful cumulative ACK. */ + readonly acknowledgedSequence: bigint; + readonly pendingResponses: number; + readonly pendingResponseBytes: number; + readonly pendingDurableTables: number; + readonly totalFramesPublished: number; + readonly totalBytesPublished: number; + /** Physical sends; includes replay and dictionary catch-up when available. */ + readonly totalFramesSent: number; + readonly totalBytesSent: number; + readonly totalFramesReplayed: number; + readonly totalBytesReplayed: number; + readonly totalAcks: number; + readonly totalNacks: number; + readonly totalDurableAcks: number; + readonly totalErrors: number; + readonly totalReconnectAttempts: number; + readonly totalReconnectsSucceeded: number; + readonly totalFailovers: number; + readonly totalReconnectErrors: number; + /** Stable store-and-forward watermark; absent without reconnect/replay. */ + readonly replayPublishedFrameSequence?: bigint; + /** Trim watermark; in durable-ACK mode it advances only after durability. */ + readonly replayAcknowledgedFrameSequence?: bigint; + readonly pendingReplayFrames: number; + readonly pendingReplayBytes: number; + readonly lastError?: Error; +} + +export interface QwpIngressProgressEvent { + readonly kind: QwpIngressProgressKind; + readonly timestampMs: number; + readonly sequence?: bigint; + readonly response?: QwpIngressResponse; + readonly metrics: QwpIngressMetrics; +} + +export interface QwpIngressErrorEvent { + readonly error: Error; + readonly terminal: boolean; + readonly timestampMs: number; + readonly response?: QwpIngressResponse; + readonly metrics: QwpIngressMetrics; } interface PendingResponse { resolve: (response: QwpIngressResponse) => void; reject: (error: unknown) => void; + readonly payloadBytes: number; timer?: ReturnType; } @@ -264,6 +327,16 @@ export class QwpIngressSession { private readonly symbolDictionary = new QwpSymbolDictionary(); private publishedMaxSymbolId = -1; private deltaSymbolsPublished = false; + private acknowledgedSequence = -1n; + private totalFramesPublished = 0; + private totalBytesPublished = 0; + private totalFramesSent = 0; + private totalBytesSent = 0; + private totalAcks = 0; + private totalNacks = 0; + private totalDurableAcks = 0; + private totalErrors = 0; + private lastError?: Error; private failure?: Error; private closing = false; private closePromise?: Promise; @@ -343,6 +416,40 @@ export class QwpIngressSession { : Math.min(this.localMaxBatchSizeBytes, serverBatchCap); } + get metrics(): QwpIngressMetrics { + const transport = this.connection.getIngressMetrics?.(); + let pendingResponseBytes = 0; + for (const pending of this.pending.values()) { + pendingResponseBytes += pending.payloadBytes; + } + return Object.freeze({ + publishedSequence: this.nextSequence - 1n, + acknowledgedSequence: this.acknowledgedSequence, + pendingResponses: this.pending.size, + pendingResponseBytes, + pendingDurableTables: this.pendingDurableTargets.size, + totalFramesPublished: this.totalFramesPublished, + totalBytesPublished: this.totalBytesPublished, + totalFramesSent: transport?.totalFramesSent ?? this.totalFramesSent, + totalBytesSent: transport?.totalBytesSent ?? this.totalBytesSent, + totalFramesReplayed: transport?.totalFramesReplayed ?? 0, + totalBytesReplayed: transport?.totalBytesReplayed ?? 0, + totalAcks: this.totalAcks, + totalNacks: transport?.totalServerNacks ?? this.totalNacks, + totalDurableAcks: this.totalDurableAcks, + totalErrors: this.totalErrors, + totalReconnectAttempts: transport?.totalReconnectAttempts ?? 0, + totalReconnectsSucceeded: transport?.totalReconnectsSucceeded ?? 0, + totalFailovers: transport?.totalFailovers ?? 0, + totalReconnectErrors: transport?.totalReconnectErrors ?? 0, + replayPublishedFrameSequence: transport?.publishedFrameSequence, + replayAcknowledgedFrameSequence: transport?.acknowledgedFrameSequence, + pendingReplayFrames: transport?.pendingReplayFrames ?? 0, + pendingReplayBytes: transport?.pendingReplayBytes ?? 0, + lastError: this.lastError, + }); + } + sendTables( tables: readonly QwpTableBuffer[], encodeOptions: QwpIngressEncodeOptions = {}, @@ -446,9 +553,11 @@ export class QwpIngressSession { const sequence = this.nextSequence++; let pending!: PendingResponse; const response = new Promise((resolve, reject) => { - pending = { resolve, reject }; + pending = { resolve, reject, payloadBytes: frame.byteLength }; }); this.pending.set(sequence, pending); + this.totalFramesPublished++; + this.totalBytesPublished += frame.byteLength; const sending = this.sendTail.then(async () => { this.throwIfUnavailable(); @@ -457,8 +566,13 @@ export class QwpIngressSession { this.sendTail = sending.catch((error: unknown) => { this.fail(error); }); + // Publish the callback only after sendTail owns this frame so a callback + // that queues another frame cannot reorder it ahead of this sequence. + this.emitProgress(QWP_INGRESS_PROGRESS_KIND.PUBLISHED, sequence); void sending.then( () => { + this.totalFramesSent++; + this.totalBytesSent += frame.byteLength; if (this.pending.get(sequence) !== pending) return; // QuestDB deliberately sends no ACK for a deferred frame. The later // group-closing frame has its own deadline and cumulatively resolves @@ -467,9 +581,11 @@ export class QwpIngressSession { if (ackDeferredUntilCommit) return; pending.timer = setTimeout(() => { if (!this.pending.delete(sequence)) return; - pending.reject( - new Error(`timed out waiting for QWP ACK [sequence=${sequence}]`), + const error = new Error( + `timed out waiting for QWP ACK [sequence=${sequence}]`, ); + pending.reject(error); + this.recordError(error, false); }, this.options.ackTimeoutMs ?? 15_000); }, () => undefined, @@ -524,7 +640,9 @@ export class QwpIngressSession { const pending: PendingDurableResponse = { targets, resolve, reject }; pending.timer = setTimeout(() => { if (!this.durableWaiters.delete(pending)) return; - reject(new Error("timed out waiting for QWP durable ACK")); + const error = new Error("timed out waiting for QWP durable ACK"); + reject(error); + this.recordError(error, false); }, timeoutMs); this.durableWaiters.add(pending); }); @@ -574,14 +692,23 @@ export class QwpIngressSession { private handleResponse(response: QwpIngressResponse): void { this.invokeCallback(this.options.onResponse, response); if (response.status === QWP_STATUS.DURABLE_ACK) { - this.applyDurableAck(response); + this.totalDurableAcks++; + const advanced = this.applyDurableAck(response); this.invokeCallback(this.options.onDurableAck, response); + if (advanced) { + this.emitProgress( + QWP_INGRESS_PROGRESS_KIND.DURABLE_ACKNOWLEDGED, + undefined, + response, + ); + } return; } if (response.sequence === null) { throw new QwpProtocolError("QWP response is missing its wire sequence"); } if (response.status === QWP_STATUS.OK) { + this.totalAcks++; this.trackDurableTargets(response); for (const [sequence, pending] of this.pending) { if (sequence > response.sequence) break; @@ -589,9 +716,18 @@ export class QwpIngressSession { if (pending.timer) clearTimeout(pending.timer); pending.resolve(response); } + if (response.sequence > this.acknowledgedSequence) { + this.acknowledgedSequence = response.sequence; + this.emitProgress( + QWP_INGRESS_PROGRESS_KIND.ACKNOWLEDGED, + response.sequence, + response, + ); + } return; } + this.totalNacks++; const pending = this.pending.get(response.sequence); if (!pending) { // A late response after timeout, or a duplicate response, is harmless. @@ -601,28 +737,64 @@ export class QwpIngressSession { if (pending.timer) clearTimeout(pending.timer); const error = new QwpIngressNackError(response); pending.reject(error); - if ( + const dictionaryGap = this.deltaSymbolsPublished && - response.status === QWP_STATUS.DICTIONARY_GAP - ) { + response.status === QWP_STATUS.DICTIONARY_GAP; + this.recordError(error, dictionaryGap, response); + if (dictionaryGap) { // This wire cannot repair a missing prefix without reconnect catch-up. - this.fail(error); + this.fail(error, true); void this.connection.close(1002, "QWP symbol dictionary gap"); } } - private invokeCallback( - callback: ((response: QwpIngressResponse) => void) | undefined, - response: QwpIngressResponse, + private invokeCallback( + callback: ((event: T) => void) | undefined, + event: T, ): void { if (!callback) return; try { - callback(response); + callback(event); } catch { // Observability callbacks must not break protocol progress. } } + private emitProgress( + kind: QwpIngressProgressKind, + sequence?: bigint, + response?: QwpIngressResponse, + ): void { + this.invokeCallback(this.options.onProgress, { + kind, + timestampMs: Date.now(), + sequence, + response, + metrics: this.metrics, + }); + } + + private recordError( + error: unknown, + terminal: boolean, + response?: QwpIngressResponse, + ): Error { + const observed = + error instanceof Error + ? error + : new Error(`QWP ingress failed: ${error}`); + this.lastError = observed; + this.totalErrors++; + this.invokeCallback(this.options.onError, { + error: observed, + terminal, + timestampMs: Date.now(), + response, + metrics: this.metrics, + }); + return observed; + } + private trackDurableTargets(response: QwpIngressResponse): void { if (this.options.durableAckKeepaliveMs === undefined) return; for (const table of response.tables) { @@ -638,11 +810,13 @@ export class QwpIngressSession { this.scheduleDurablePoll(); } - private applyDurableAck(response: QwpIngressResponse): void { + private applyDurableAck(response: QwpIngressResponse): boolean { + let advanced = false; for (const table of response.tables) { const watermark = this.durableWatermarks.get(table.name); if (watermark === undefined || table.sequenceTransaction > watermark) { this.durableWatermarks.set(table.name, table.sequenceTransaction); + advanced = true; } const target = this.pendingDurableTargets.get(table.name); if (target !== undefined && table.sequenceTransaction >= target) { @@ -661,6 +835,7 @@ export class QwpIngressSession { } else { this.scheduleDurablePoll(); } + return advanced; } private areDurableTargetsCovered( @@ -712,13 +887,14 @@ export class QwpIngressSession { if (this.closing) throw new QwpIngressSessionClosedError(); } - private fail(error: unknown): void { + private fail(error: unknown, alreadyObserved = false): void { if (this.failure) return; this.clearDurablePoll(); - this.failure = - error instanceof Error + this.failure = alreadyObserved + ? error instanceof Error ? error - : new Error(`QWP ingress failed: ${error}`); + : new Error(`QWP ingress failed: ${error}`) + : this.recordError(error, true); this.rejectAll(this.failure); } diff --git a/src/qwp/internal/reconnecting-egress-connection.ts b/src/qwp/internal/reconnecting-egress-connection.ts index 23d0a71..3867c83 100644 --- a/src/qwp/internal/reconnecting-egress-connection.ts +++ b/src/qwp/internal/reconnecting-egress-connection.ts @@ -236,6 +236,12 @@ export class QwpReconnectingEgressConnection implements QwpBinaryConnection { endpoint: candidate.endpoint, previousEndpoint, }); + } else { + this.emitEvent({ + kind: QWP_RECONNECT_EVENT_KIND.CONNECTED, + attempt: 0, + endpoint: candidate.endpoint, + }); } return; } catch (error) { @@ -472,9 +478,9 @@ export class QwpReconnectingEgressConnection implements QwpBinaryConnection { }); } - private emitEvent(event: QwpReconnectEvent): void { + private emitEvent(event: Omit): void { try { - this.reconnectOptions.onEvent?.(event); + this.reconnectOptions.onEvent?.({ ...event, timestampMs: Date.now() }); } catch { // Connection observers must not interfere with replay progress. } diff --git a/src/qwp/internal/reconnecting-ingress-connection.ts b/src/qwp/internal/reconnecting-ingress-connection.ts index d9d278a..6164161 100644 --- a/src/qwp/internal/reconnecting-ingress-connection.ts +++ b/src/qwp/internal/reconnecting-ingress-connection.ts @@ -18,6 +18,7 @@ import { QwpHandshakeMetadata, QwpIngressReplayRecord, QwpIngressReplayStore, + QwpIngressTransportMetrics, QwpReconnectEvent, QwpReconnectExhaustedError, QwpReconnectOptions, @@ -118,6 +119,7 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { private wireFrames: ReplayFrame[] = []; private nextFrameSequence = 0n; private nextClientSequence = 0n; + private acknowledgedFrameSequence = -1n; private rejectedFrameSequence?: bigint; private rejectionCount = 0; private generation = 0; @@ -128,6 +130,15 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { private cancelBackoff?: () => void; private closing = false; private closedSettled = false; + private totalFramesSent = 0; + private totalBytesSent = 0; + private totalFramesReplayed = 0; + private totalBytesReplayed = 0; + private totalReconnectAttempts = 0; + private totalReconnectsSucceeded = 0; + private totalFailovers = 0; + private totalReconnectErrors = 0; + private totalServerNacks = 0; readonly messages: AsyncIterable = this.messagesQueue; readonly closed: Promise; ping?: () => Promise; @@ -177,6 +188,9 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { this.frames.set(frame.frameSequence, frame); previous = frame.frameSequence; } + if (records.length > 0) { + this.acknowledgedFrameSequence = records[0].frameSequence - 1n; + } this.nextFrameSequence = previous + 1n; } @@ -231,6 +245,28 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { return this.symbolDictionary.slice(); } + getIngressMetrics(): QwpIngressTransportMetrics { + let pendingReplayBytes = 0; + for (const frame of this.frames.values()) { + pendingReplayBytes += frame.payload.byteLength; + } + return Object.freeze({ + publishedFrameSequence: this.nextFrameSequence - 1n, + acknowledgedFrameSequence: this.acknowledgedFrameSequence, + pendingReplayFrames: this.frames.size, + pendingReplayBytes, + totalFramesSent: this.totalFramesSent, + totalBytesSent: this.totalBytesSent, + totalFramesReplayed: this.totalFramesReplayed, + totalBytesReplayed: this.totalBytesReplayed, + totalReconnectAttempts: this.totalReconnectAttempts, + totalReconnectsSucceeded: this.totalReconnectsSucceeded, + totalFailovers: this.totalFailovers, + totalReconnectErrors: this.totalReconnectErrors, + totalServerNacks: this.totalServerNacks, + }); + } + send(payload: Uint8Array): Promise { if (this.terminalError) return Promise.reject(this.terminalError); if (this.closing) return Promise.reject(new QwpSendClosedError()); @@ -325,6 +361,7 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { } this.throwIfUnavailable(); attempt++; + if (reconnecting) this.totalReconnectAttempts++; let candidate: QwpBinaryConnection | undefined; try { candidate = await this.factory(); @@ -338,19 +375,29 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { this.install(candidate, replayed); this.connectingCandidate = undefined; if (reconnecting) { + this.totalReconnectsSucceeded++; + const failedOver = + previousEndpoint !== undefined && + String(previousEndpoint) !== String(candidate.endpoint); + if (failedOver) this.totalFailovers++; this.emitEvent({ - kind: - previousEndpoint !== undefined && - String(previousEndpoint) !== String(candidate.endpoint) - ? QWP_RECONNECT_EVENT_KIND.FAILED_OVER - : QWP_RECONNECT_EVENT_KIND.RECONNECTED, + kind: failedOver + ? QWP_RECONNECT_EVENT_KIND.FAILED_OVER + : QWP_RECONNECT_EVENT_KIND.RECONNECTED, attempt, endpoint: candidate.endpoint, previousEndpoint, }); + } else { + this.emitEvent({ + kind: QWP_RECONNECT_EVENT_KIND.CONNECTED, + attempt: 0, + endpoint: candidate.endpoint, + }); } return; } catch (error) { + if (reconnecting) this.totalReconnectErrors++; lastError = error; if (this.connectingCandidate === candidate) { this.connectingCandidate = undefined; @@ -395,7 +442,7 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { dictionaryCatchup: true, }; replayed.push(frame); - await connection.send(payload); + await this.sendPhysical(connection, payload, false); } for (const frame of this.frames.values()) { if (!frame.transmitted) continue; @@ -406,7 +453,7 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { ); } replayed.push(frame); - await connection.send(frame.payload); + await this.sendPhysical(connection, frame.payload, true); } return replayed; } @@ -532,6 +579,8 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { return rewriteResponseSequence(payload, clientTarget.clientSequence); } + this.totalServerNacks++; + if (isRetriableIngressStatus(response.status)) { const sameFrame = this.rejectedFrameSequence === frame.frameSequence; this.rejectedFrameSequence = frame.frameSequence; @@ -598,6 +647,9 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { if (sequence > frameSequence) break; this.frames.delete(sequence); } + if (frameSequence > this.acknowledgedFrameSequence) { + this.acknowledgedFrameSequence = frameSequence; + } } private async persistSymbolDictionaryDelta( @@ -653,12 +705,26 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { frame.transmitted = true; this.wireFrames.push(frame); try { - await connection.send(frame.payload); + await this.sendPhysical(connection, frame.payload, false); } catch (error) { await this.requestReconnect(error, connection); } } + private async sendPhysical( + connection: QwpBinaryConnection, + payload: Uint8Array, + replayed: boolean, + ): Promise { + this.totalFramesSent++; + this.totalBytesSent += payload.byteLength; + if (replayed) { + this.totalFramesReplayed++; + this.totalBytesReplayed += payload.byteLength; + } + await connection.send(payload); + } + private async requireConnection(): Promise { if (this.reconnectTask) await this.reconnectTask; this.throwIfUnavailable(); @@ -724,9 +790,9 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { }); } - private emitEvent(event: QwpReconnectEvent): void { + private emitEvent(event: Omit): void { try { - this.reconnectOptions.onEvent?.(event); + this.reconnectOptions.onEvent?.({ ...event, timestampMs: Date.now() }); } catch { // Connection observers must not interfere with replay progress. } diff --git a/src/qwp/sender.ts b/src/qwp/sender.ts index 608fd13..2832898 100644 --- a/src/qwp/sender.ts +++ b/src/qwp/sender.ts @@ -6,6 +6,7 @@ import { QwpTableBuffer, flattenQwpArray, } from "./core"; +import type { QwpIngressMetrics } from "./ingress-session"; export type QwpTimestampUnit = "ns" | "us" | "ms"; @@ -41,6 +42,7 @@ export interface QwpSenderOptions { /** The subset of QwpIngressSession used by QwpSender. */ export interface QwpSenderSession { + readonly metrics?: QwpIngressMetrics; sendTables( tables: readonly QwpTableBuffer[], options?: QwpIngressEncodeOptions, @@ -58,6 +60,22 @@ export interface QwpSenderSession { export type QwpSenderSessionFactory = () => Promise; +/** Immutable high-level sender counters plus the active ingress snapshot. */ +export interface QwpSenderMetrics { + readonly totalRowsStaged: number; + /** Rows whose encoded frames have entered the ingress session. */ + readonly totalRowsPublished: number; + readonly totalFlushes: number; + readonly totalFlushFailures: number; + readonly totalTransactionsCommitted: number; + readonly pendingRows: number; + readonly deferredRows: number; + readonly connected: boolean; + readonly closing: boolean; + readonly closed: boolean; + readonly ingress?: QwpIngressMetrics; +} + interface StagedColumn { name: string; type: QwpColumnType; @@ -260,6 +278,7 @@ export class QwpSender { private pendingRowCount = 0; private lastFlushTime = Date.now(); private sessionPromise?: Promise; + private activeSession?: QwpSenderSession; private flushTail: Promise = Promise.resolve(); private closePromise?: Promise; private closing = false; @@ -267,6 +286,11 @@ export class QwpSender { private hasDeferredMessages = false; private deferredRowCount = 0; private readonly deferredAcks: Promise[] = []; + private totalRowsStaged = 0; + private totalRowsPublished = 0; + private totalFlushes = 0; + private totalFlushFailures = 0; + private totalTransactionsCommitted = 0; private readonly autoFlush: boolean; private readonly autoFlushRows: number; @@ -301,6 +325,23 @@ export class QwpSender { return true; } + get metrics(): QwpSenderMetrics { + return Object.freeze({ + totalRowsStaged: this.totalRowsStaged, + totalRowsPublished: this.totalRowsPublished, + totalFlushes: this.totalFlushes, + totalFlushFailures: this.totalFlushFailures, + totalTransactionsCommitted: this.totalTransactionsCommitted, + pendingRows: this.pendingRowCount, + deferredRows: this.deferredRowCount, + connected: + this.activeSession !== undefined && !this.closing && !this.closed, + closing: this.closing, + closed: this.closed, + ingress: this.activeSession?.metrics, + }); + } + reset(): QwpSender { this.throwIfUnavailable(); this.tables.length = 0; @@ -725,6 +766,9 @@ export class QwpSender { private enqueueFlush(deferCommit: boolean): Promise { this.throwIfUnavailable(); const flushing = this.flushTail.then(() => this.flushNow(deferCommit)); + void flushing.catch(() => { + this.totalFlushFailures++; + }); this.flushTail = flushing.then( () => undefined, () => undefined, @@ -838,6 +882,7 @@ export class QwpSender { this.currentRow = new Map(); this.current = undefined; this.pendingRowCount++; + this.totalRowsStaged++; this.log("debug", `Pending QWP row count: ${this.pendingRowCount}`); } @@ -881,6 +926,7 @@ export class QwpSender { const wireTables = snapshots.map(({ table, rows }) => this.buildTable(table.name, rows), ); + const closesDeferredTransaction = this.hasDeferredMessages; // sendTables encodes synchronously. Do not compact staging if encoding // throws, but transfer ownership once the frame has entered the session. const encode = this.options.encode; @@ -895,12 +941,14 @@ export class QwpSender { gorilla: encode?.gorilla, deferCommit, }); + this.totalFlushes++; for (const { table, rows } of snapshots) table.rows.splice(0, rows.length); const sentRows = snapshots.reduce( (count, item) => count + item.rows.length, 0, ); this.pendingRowCount -= sentRows; + this.totalRowsPublished += sentRows; this.lastFlushTime = Date.now(); this.log( "debug", @@ -923,6 +971,9 @@ export class QwpSender { this.hasDeferredMessages = false; this.deferredRowCount = 0; await Promise.all(deferredAcks); + if (this.transactional && (closesDeferredTransaction || sentRows > 0)) { + this.totalTransactionsCommitted++; + } if (this.options.awaitDurableAck) { await session.waitForDurable(ack, this.options.durableAckTimeoutMs); } @@ -953,11 +1004,17 @@ export class QwpSender { private getSession(): Promise { if (!this.sessionPromise) { - const connecting = this.sessionFactory().catch((error: unknown) => { - if (this.sessionPromise === connecting) this.sessionPromise = undefined; - throw error; - }); - this.sessionPromise = connecting; + const connecting = this.sessionFactory(); + const tracked = connecting + .then((session) => { + this.activeSession = session; + return session; + }) + .catch((error: unknown) => { + if (this.sessionPromise === tracked) this.sessionPromise = undefined; + throw error; + }); + this.sessionPromise = tracked; } return this.sessionPromise; } diff --git a/src/qwp/transport.ts b/src/qwp/transport.ts index efad4e2..87d9c1a 100644 --- a/src/qwp/transport.ts +++ b/src/qwp/transport.ts @@ -140,7 +140,28 @@ export interface QwpIngressReplayStore { close(): Promise; } +/** Physical ingress delivery counters maintained by reconnecting transports. */ +export interface QwpIngressTransportMetrics { + /** Highest stable replay-frame sequence handed to the transport. */ + readonly publishedFrameSequence: bigint; + /** Highest replay-frame sequence removed from store-and-forward. */ + readonly acknowledgedFrameSequence: bigint; + readonly pendingReplayFrames: number; + readonly pendingReplayBytes: number; + /** Physical WebSocket sends, including replay and dictionary catch-up. */ + readonly totalFramesSent: number; + readonly totalBytesSent: number; + readonly totalFramesReplayed: number; + readonly totalBytesReplayed: number; + readonly totalReconnectAttempts: number; + readonly totalReconnectsSucceeded: number; + readonly totalFailovers: number; + readonly totalReconnectErrors: number; + readonly totalServerNacks: number; +} + export const QWP_RECONNECT_EVENT_KIND = { + CONNECTED: "connected", RECONNECTING: "reconnecting", ATTEMPT_FAILED: "attempt-failed", RECONNECTED: "reconnected", @@ -152,8 +173,9 @@ export type QwpReconnectEventKind = export interface QwpReconnectEvent { readonly kind: QwpReconnectEventKind; - /** One-based reconnect sweep number within the current outage. */ + /** One-based reconnect sweep number; zero for lifecycle-only events. */ readonly attempt: number; + readonly timestampMs: number; readonly endpoint?: string | URL; readonly previousEndpoint?: string | URL; readonly cause?: unknown; @@ -305,6 +327,9 @@ export interface QwpBinaryConnection { /** Endpoint backing this connection, when supplied by its adapter. */ readonly endpoint?: string | URL; + /** @internal Physical delivery metrics exposed by replaying transports. */ + getIngressMetrics?(): QwpIngressTransportMetrics; + send(payload: Uint8Array): Promise; /** Sends an RFC 6455 PING when the underlying runtime supports it. */ ping?(): Promise; diff --git a/test/qwp/reconnect.test.ts b/test/qwp/reconnect.test.ts index 19e9dc2..3ea37bf 100644 --- a/test/qwp/reconnect.test.ts +++ b/test/qwp/reconnect.test.ts @@ -272,9 +272,28 @@ describe("QWP ingress reconnect and replay", () => { await expect(pending).resolves.toMatchObject({ sequence: 1n }); expect(events.map((event) => event.kind)).toEqual([ + QWP_RECONNECT_EVENT_KIND.CONNECTED, QWP_RECONNECT_EVENT_KIND.RECONNECTING, QWP_RECONNECT_EVENT_KIND.FAILED_OVER, ]); + expect(events.every((event) => event.timestampMs > 0)).toBe(true); + expect(session.metrics).toMatchObject({ + publishedSequence: 1n, + acknowledgedSequence: 1n, + totalFramesPublished: 2, + totalFramesSent: 3, + totalBytesSent: 3, + totalFramesReplayed: 1, + totalBytesReplayed: 1, + totalReconnectAttempts: 1, + totalReconnectsSucceeded: 1, + totalFailovers: 1, + totalReconnectErrors: 0, + replayPublishedFrameSequence: 1n, + replayAcknowledgedFrameSequence: 1n, + pendingReplayFrames: 0, + pendingReplayBytes: 0, + }); await session.close(); }); @@ -473,6 +492,13 @@ describe("QWP ingress reconnect and replay", () => { status: QWP_STATUS.OK, sequence: 0n, }); + expect(session.metrics).toMatchObject({ + totalNacks: 1, + totalFramesSent: 2, + totalFramesReplayed: 1, + totalReconnectAttempts: 1, + totalReconnectsSucceeded: 1, + }); await session.close(); }); diff --git a/test/qwp/sender.test.ts b/test/qwp/sender.test.ts index ca40b12..b8f8425 100644 --- a/test/qwp/sender.test.ts +++ b/test/qwp/sender.test.ts @@ -115,6 +115,8 @@ describe("QWP high-level sender", () => { expect.objectContaining({ message: "session closed" }), ); expect(session.closeCount).toBe(1); + expect(sender.metrics.totalFlushFailures).toBe(1); + expect(sender.metrics.connected).toBe(false); }); it("uses the existing Sender fluent API and preserves an unfinished row", async () => { @@ -277,6 +279,19 @@ describe("QWP high-level sender", () => { options: { deferCommit: false }, }); expect(session.durable).toHaveLength(1); + expect(sender.metrics).toMatchObject({ + totalRowsStaged: 1, + totalRowsPublished: 1, + totalFlushes: 2, + totalFlushFailures: 0, + totalTransactionsCommitted: 1, + pendingRows: 0, + deferredRows: 0, + connected: true, + closing: false, + closed: false, + }); + expect(Object.isFrozen(sender.metrics)).toBe(true); await expect(sender.flush()).resolves.toBe(false); }); diff --git a/test/qwp/session.test.ts b/test/qwp/session.test.ts index e9ffac9..7a67ad5 100644 --- a/test/qwp/session.test.ts +++ b/test/qwp/session.test.ts @@ -18,6 +18,7 @@ import { QWP_EGRESS_MESSAGE, QWP_FLAG_DEFER_COMMIT, QWP_FLAG_DELTA_SYMBOL_DICTIONARY, + QWP_INGRESS_PROGRESS_KIND, QWP_STATUS, QWP_UPGRADE_ERROR_KIND, QwpBatchTooLargeError, @@ -547,10 +548,7 @@ describe("QWP WebSocket adapters", () => { socket.open(); await connecting; - const autoFlush = sender - .table("events") - .longColumn("value", 42n) - .atNow(); + const autoFlush = sender.table("events").longColumn("value", 42n).atNow(); await expect(autoFlush).resolves.toBeUndefined(); expect(socket.sent).toHaveLength(1); expect(decodeQwpFrame(socket.sent[0]).flags & QWP_FLAG_DEFER_COMMIT).toBe( @@ -560,7 +558,22 @@ describe("QWP WebSocket adapters", () => { await expect(sender.flush()).resolves.toBe(true); expect(socket.sent).toHaveLength(2); - expect(decodeQwpFrame(socket.sent[1]).flags & QWP_FLAG_DEFER_COMMIT).toBe(0); + expect(decodeQwpFrame(socket.sent[1]).flags & QWP_FLAG_DEFER_COMMIT).toBe( + 0, + ); + expect(sender.metrics).toMatchObject({ + totalRowsStaged: 1, + totalRowsPublished: 1, + totalFlushes: 2, + totalTransactionsCommitted: 1, + ingress: { + publishedSequence: 1n, + acknowledgedSequence: 1n, + totalFramesPublished: 2, + totalFramesSent: 2, + totalAcks: 1, + }, + }); await sender.close(); }); @@ -1410,6 +1423,78 @@ describe("QwpIngressSession", () => { await session.close(); }); + it("reports immutable ingress metrics, progress, and protected error callbacks", async () => { + const socket = new FakeWebSocket(); + const connecting = connectQwpBrowserWebSocket({ + url: "ws://localhost:9000/write/v4", + webSocketFactory: () => asQwpSocket(socket), + }); + socket.open(); + const progress: string[] = []; + const errors: { terminal: boolean; message: string }[] = []; + const session = new QwpIngressSession(await connecting, { + durableAckKeepaliveMs: 0, + onProgress: (event) => progress.push(event.kind), + onError: (event) => { + errors.push({ + terminal: event.terminal, + message: event.error.message, + }); + throw new Error("observer failure must be contained"); + }, + }); + socket.onSend = () => { + const sequence = BigInt(socket.sent.length - 1); + socket.message( + sequence === 0n + ? ingressResponse(QWP_STATUS.OK, sequence, undefined, [ + ["events", 7n], + ]) + : ingressResponse(QWP_STATUS.WRITE_ERROR, sequence, "write failed"), + ); + }; + + await expect(session.sendFrame(Uint8Array.of(1))).resolves.toMatchObject({ + sequence: 0n, + }); + socket.message(durableResponse([["events", 7n]])); + await vi.waitFor(() => + expect(progress).toContain( + QWP_INGRESS_PROGRESS_KIND.DURABLE_ACKNOWLEDGED, + ), + ); + await expect(session.sendFrame(Uint8Array.of(2))).rejects.toMatchObject({ + name: "QwpIngressNackError", + }); + + expect(progress).toEqual([ + QWP_INGRESS_PROGRESS_KIND.PUBLISHED, + QWP_INGRESS_PROGRESS_KIND.ACKNOWLEDGED, + QWP_INGRESS_PROGRESS_KIND.DURABLE_ACKNOWLEDGED, + QWP_INGRESS_PROGRESS_KIND.PUBLISHED, + ]); + expect(errors).toEqual([{ terminal: false, message: "write failed" }]); + expect(session.metrics).toMatchObject({ + publishedSequence: 1n, + acknowledgedSequence: 0n, + pendingResponses: 0, + pendingResponseBytes: 0, + pendingDurableTables: 0, + totalFramesPublished: 2, + totalBytesPublished: 2, + totalFramesSent: 2, + totalBytesSent: 2, + totalFramesReplayed: 0, + totalAcks: 1, + totalNacks: 1, + totalDurableAcks: 1, + totalErrors: 1, + lastError: expect.objectContaining({ name: "QwpIngressNackError" }), + }); + expect(Object.isFrozen(session.metrics)).toBe(true); + await session.close(); + }); + it("starts the ingress ACK deadline after send backpressure clears", async () => { vi.useFakeTimers(); try { From a635a5ce7b2455bb5217f934e2e97e90dedaf641 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 18 Aug 2026 09:38:21 +0100 Subject: [PATCH 026/265] docs(qwp): document public API and migration --- QWP.md | 454 ++++++++++++++++++++++++++++++++ README.md | 3 + package.json | 1 + test/qwp/public-api-contract.ts | 115 ++++++++ test/qwp/public-api.test.ts | 80 ++++++ tsconfig.json | 2 +- 6 files changed, 654 insertions(+), 1 deletion(-) create mode 100644 QWP.md create mode 100644 test/qwp/public-api-contract.ts create mode 100644 test/qwp/public-api.test.ts diff --git a/QWP.md b/QWP.md new file mode 100644 index 0000000..d7b4eaf --- /dev/null +++ b/QWP.md @@ -0,0 +1,454 @@ +# QuestDB Wire Protocol (QWP) + +This guide covers QWP ingress and egress from Node.js and browser applications. +It describes the supported public entry points, delivery semantics, authentication, +failure handling, and migration from the existing Node.js sender and the low-level +QWP API. + +QWP support is currently a preview. The documented exports are the compatibility +baseline for the first QWP release, but may still change before that release. Once +released, changes to this documented surface follow the package's semantic-versioning +policy. Imports from internal source paths are never supported. + +## Choose an entry point + +| Entry point | Runtime | Use it for | +| ------------------------------------ | ------------------ | ----------------------------------------------------------------------------------------- | +| `@questdb/nodejs-client` | Node.js | Existing `Sender`, including QWP ingress selected with `ws::` or `wss::` | +| `@questdb/nodejs-client/qwp/browser` | Browser | Browser-safe QWP ingress, egress, authentication bootstrap, sessions, and codecs | +| `@questdb/nodejs-client/qwp/node` | Node.js | QWP ingress and egress with upgrade headers, TLS agents, and persistent store-and-forward | +| `@questdb/nodejs-client/qwp` | Browser or Node.js | Shared protocol codecs and low-level session abstractions for advanced integrations | + +Do not import the package root from browser code. It retains the existing Node.js +transports and dependencies for backward compatibility. The browser entry point has +no Node.js imports. Node-only features remain in `qwp/node`, so supporting browsers +does not require redesigning or breaking the existing client. + +QWP uses `/write/v4` for ingress and `/read/v1` for egress. A server must expose +these WebSocket routes; optional features are enabled only when negotiation confirms +that the server supports them. + +## Ingress + +### Node.js through the existing `Sender` + +Changing `http::` or `tcp::` to `ws::` selects QWP while preserving the familiar +fluent row API: + +```typescript +import { Sender } from "@questdb/nodejs-client"; + +const sender = await Sender.fromConfig( + "wss::addr=questdb.example:9000;token=REST_OR_OIDC_TOKEN;auto_flush=off", +); +await sender.connect(); + +try { + await sender + .table("trades") + .symbol("symbol", "ETH-USD") + .floatColumn("price", 2_615.54) + .timestampColumn("received_at", Date.now(), "ms") + .at(Date.now(), "ms"); + await sender.flush(); +} finally { + await sender.close(); +} +``` + +`username` plus `password` selects HTTP Basic authentication for the WebSocket +upgrade. `token` selects Bearer authentication. Use `wss::` in production. + +Advanced QWP options are accepted in the second argument: + +```typescript +const sender = await Sender.fromConfig( + "wss::addr=questdb.example:9000;token=REST_OR_OIDC_TOKEN", + { + qwp: { + webSocket: { + requestDurableAck: true, + failoverUrls: ["wss://questdb-dr.example:9000/write/v4"], + storeAndForward: { + directory: "/var/lib/my-service/qwp-replay", + maxBytes: 512 * 1024 * 1024, + }, + }, + sender: { + awaitDurableAck: true, + autoFlushRows: 10_000, + }, + session: { + reconnect: { + maxAttempts: 0, + maxDurationMs: 0, + }, + }, + }, + }, +); +``` + +Give each active sender its own store-and-forward directory. The Node.js journal +persists frames and their symbol dictionary before sending. A crash after the server +accepts a frame but before local acknowledgement cleanup can replay that frame, so +delivery is at least once. Applications that require exactly-once effects should use +their own stable event key or another idempotency strategy. + +### Direct high-level API + +Use `QwpSender` directly when QWP-only column types or detailed session controls are +needed: + +```typescript +import { connectQwpNodeSender } from "@questdb/nodejs-client/qwp/node"; + +const sender = await connectQwpNodeSender( + { + url: "wss://questdb.example:9000/write/v4", + authorization: `Bearer ${token}`, + }, + { + autoFlushRows: 5_000, + autoFlushIntervalMs: 1_000, + encode: { symbolDictionary: "delta", gorilla: true }, + }, +); + +try { + await sender + .table("telemetry") + .symbol("device", "sensor-7") + .longColumn("sequence", 42n) + .uuidColumn("event_id", "9f1c96b2-54b8-4d85-bb24-e82c6f1ac120") + .at(1_775_000_000_000, "ms"); + await sender.flush(); +} finally { + await sender.close(); +} +``` + +Rows are staged until an auto-flush boundary or an explicit `flush()`. A `null` or +`undefined` column value omits that column from the row. `atNow()` asks QuestDB to +assign the designated timestamp; `at(value, unit)` sends an explicit `ns`, `us`, or +`ms` timestamp. `close()` does not flush pending rows. + +The sender automatically maintains connection-scoped symbol IDs, emits dictionary +deltas, tracks acknowledgements, and splits multi-row batches at the smaller of the +client cap and the server-advertised cap. One row that cannot fit is rejected with +`QwpBatchTooLargeError` before it is sent. + +### Browser ingress + +Browser applications must use the browser entry point and a same-origin WebSocket +route (directly or through a reverse proxy): + +```typescript +import { connectQwpBrowserSender } from "@questdb/nodejs-client/qwp/browser"; + +const url = new URL("/write/v4", location.href); +url.protocol = location.protocol === "https:" ? "wss:" : "ws:"; + +const sender = await connectQwpBrowserSender({ url }, { autoFlush: false }); + +try { + await sender.table("page_events").symbol("kind", "view").atNow(); + await sender.flush(); +} finally { + await sender.close(); +} +``` + +The browser WebSocket API cannot set `Authorization` or arbitrary `X-QWP-*` +upgrade headers. When authentication is enabled, create QuestDB's HttpOnly session +cookies over REST before opening the WebSocket: + +```typescript +import { + bootstrapQwpBrowserSession, + connectQwpBrowserSender, +} from "@questdb/nodejs-client/qwp/browser"; + +await bootstrapQwpBrowserSession({ + url: new URL("/exec", location.href), + authentication: { type: "bearer", token: oidcOrRestAccessToken }, + // QuestDB Enterprise only; omit this to use the logged-in principal. + serviceAccount: "market_data_writer", +}); + +const sender = await connectQwpBrowserSender({ url }); +``` + +Basic authentication is also accepted as `{ type: "basic", username, password }`. +The application obtains OIDC tokens from its identity provider; this package does +not run an interactive OIDC flow. The bootstrap request uses +`credentials: "include"`. REST and WebSocket endpoints therefore need the same +browser origin, or correctly configured credentialed CORS and cookie attributes. +JavaScript never reads `qdb_session` or the Enterprise `qdbServiceAccount` cookie. + +Set `sessionBootstrap` on the WebSocket options to repeat bootstrap before every +initial, reconnect, and failover attempt: + +```typescript +const sender = await connectQwpBrowserSender({ + url, + sessionBootstrap: { + authentication: { type: "bearer", token: oidcOrRestAccessToken }, + serviceAccount: "market_data_writer", + }, +}); +``` + +### Transactions and durable acknowledgement + +Transactional auto-flush keeps automatically emitted frames in an open server-side +transaction. `commit()` (an alias for `flush()`) closes the group and waits for its +cumulative acknowledgement: + +```typescript +const sender = await connectQwpBrowserSender( + { url, requestDurableAck: true }, + { + transactional: true, + autoFlushRows: 10_000, + awaitDurableAck: true, + durableAckTimeoutMs: 30_000, + }, +); + +for (const event of events) { + await sender + .table("events") + .symbol("source", event.source) + .longColumn("value", event.value) + .at(event.timestamp, "ms"); +} +await sender.commit(); +``` + +Transactions are atomic per table, not across all tables in one flush. Closing a +sender with uncommitted transactional auto-flushes rolls the open server transaction +back. The sender logs a warning in this case. + +In browsers, durable ACK capability is negotiated with a WebSocket subprotocol; +Node.js uses upgrade headers. Setting `awaitDurableAck` automatically requests the +capability unless `requestDurableAck` was set explicitly. The connection fails with +`QwpDurableAckUnavailableError` when the server does not confirm it. Browser durable +tracking is in memory only. Persistent store-and-forward is intentionally Node-only. + +### Reconnect, failover, and roles + +`failoverUrls` are attempted in order after the preferred URL. `reconnect` controls +bounded exponential backoff and emits lifecycle events. Node ingress requires a +persistent replay store when reconnect is enabled; browser ingress can only replay +from memory for the lifetime of the page. + +Node.js sees the rejected upgrade status and `X-QuestDB-Role`, so a read-only replica +or catching-up primary can be classified and skipped. Browsers deliberately expose +an opaque upgrade error because their WebSocket API hides the HTTP response. Avoid +placing ingress replica endpoints in a browser endpoint list unless the proxy routes +writers to a primary. + +### Observability + +Use immutable metrics snapshots for polling and callbacks for event-driven telemetry: + +```typescript +import { + QWP_INGRESS_PROGRESS_KIND, + createQwpNodeSender, +} from "@questdb/nodejs-client/qwp/node"; + +const sender = createQwpNodeSender( + { url: "ws://localhost:9000/write/v4" }, + {}, + { + reconnect: { + onEvent: (event) => console.info("QWP connection", event), + }, + onProgress: (event) => { + if (event.kind === QWP_INGRESS_PROGRESS_KIND.ACKNOWLEDGED) { + console.info("accepted through", event.sequence); + } + }, + onError: (event) => console.error("QWP ingress", event.error), + }, +); + +await sender.connect(); +console.info(sender.metrics); +``` + +Callback failures are contained and cannot fail the session. Keep callbacks short; +Node.js and browsers run them on the JavaScript event loop. The ingress snapshot +separates client-session sequences from persistent replay watermarks and reports +published, sent, replayed, acknowledged, durable, reconnect, and error counters. + +## Egress + +QWP egress streams typed result batches. One connection executes one active query at +a time. + +```typescript +import { connectQwpNodeEgress } from "@questdb/nodejs-client/qwp/node"; + +const session = await connectQwpNodeEgress( + { + url: "wss://questdb.example:9000/read/v1", + authorization: `Bearer ${token}`, + compression: "zstd", + compressionLevel: 3, + }, + { queryTimeoutMs: 30_000 }, +); + +try { + const query = await session.query( + "select timestamp, symbol, price from trades where symbol = $1", + { + binds: (binds) => binds.setVarchar(0, "ETH-USD"), + initialCredit: 1024 * 1024, + }, + ); + + for await (const batch of query) { + console.info(batch.columns); + for (const row of batch.rows()) console.info(row); + } + + const completion = await query.completion; + console.info(completion); +} finally { + await session.close(); +} +``` + +Bind indexes are zero-based in the client: index `0` is SQL placeholder `$1`. +`QwpBindValues` supports booleans, integer and floating-point values, dates, +microsecond and nanosecond timestamps, strings, UUIDs, LONG256, geohashes, +decimals, and typed nulls. Set values in ascending index order. `bindPayload` and +`bindCount` remain advanced escape hatches for pre-encoded data. + +Positive `initialCredit` enables byte-based flow control. By default, the client +replenishes the exact wire size of each batch when iteration advances beyond it, so +a slow consumer limits server read-ahead. Set `autoCredit: false` and call +`query.grantCredit()` for manual control. + +A session `queryTimeoutMs` supplies the default deadline; per-query `timeoutMs` +overrides it, and zero disables it. Expiry rejects iteration and `completion` with +`QwpEgressQueryTimeoutError`, sends QWP `CANCEL`, and drains the terminal response +before the connection accepts another query. Call `query.cancel()` for explicit +cancellation. + +Node.js can request Zstd with `compression: "zstd"` or `"auto"` and a level from 1 +through 22. Raw remains the compatibility default. Check +`session.negotiatedCompression` after the handshake. The decoder handles raw and +Zstd batches in both runtimes, but browsers cannot advertise +`X-QWP-Accept-Encoding`; a same-origin proxy must add that header to opt a browser +into compressed responses. + +Egress reconnect never silently resumes a partially consumed result. Configure +`onReplayReset` to opt into at-least-once query re-execution, discard any rows from +the previous attempt in that callback, and rebuild downstream state. Without that +hook, losing a connection with an operation in flight raises +`QwpEgressReplayRequiredError`. + +Browser egress uses the same session API: + +```typescript +import { connectQwpBrowserEgress } from "@questdb/nodejs-client/qwp/browser"; + +const readUrl = new URL("/read/v1", location.href); +readUrl.protocol = location.protocol === "https:" ? "wss:" : "ws:"; + +const session = await connectQwpBrowserEgress({ + url: readUrl, + sessionBootstrap: { + authentication: { type: "bearer", token: oidcOrRestAccessToken }, + }, +}); +``` + +## Error handling and cleanup + +The public error classes preserve enough context for policy decisions: + +| Error | Meaning | +| ------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| `QwpUpgradeError` | Classified authentication, role, version, capability, timeout, transport, or browser-opaque upgrade failure | +| `QwpDurableAckUnavailableError` | Durable acknowledgement was required but not negotiated | +| `QwpSendTimeoutError` | A send did not drain before its deadline; delivery is unknown | +| `QwpIngressNackError` | QuestDB rejected an ingress frame | +| `QwpBatchTooLargeError` | One encoded row cannot fit the effective ingress cap | +| `QwpReconnectExhaustedError` | The configured reconnect boundary was reached | +| `QwpReplayRejectedError` | A replayed frame was rejected and retained for inspection | +| `QwpReplayStoreFullError` | The Node.js replay journal reached its configured size | +| `QwpEgressQueryError` | QuestDB returned a terminal query error | +| `QwpEgressQueryTimeoutError` | The client deadline expired and cancellation began | +| `QwpEgressReplayRequiredError` | Re-execution needs an explicit reset callback | + +Always close senders and sessions in `finally`. Closing is idempotent and bounded by +`closeTimeoutMs`. `connectTimeoutMs`, `sendTimeoutMs`, acknowledgement timeouts, and +query deadlines cover separate lifecycle phases; configure each according to the +deployment rather than using one very large catch-all value. + +## Migration guide + +### Existing Node.js `Sender` + +For the common fluent API, migration is primarily a transport change: + +```diff +- const sender = await Sender.fromConfig("http::addr=localhost:9000"); ++ const sender = await Sender.fromConfig("ws::addr=localhost:9000"); +``` + +Review these behavioral differences before rollout: + +- QWP `flush()` waits for a protocol ACK; optionally it also waits for durable upload. +- QWP symbol dictionaries are connection-scoped and automatic. +- Large batches are split to the negotiated WebSocket payload cap. +- QWP transactional auto-flush is per table and must be explicitly committed. +- Node reconnection requires store-and-forward and has at-least-once replay semantics. +- Existing HTTP, TCP, and TLS options do not automatically apply to QWP; put QWP-only + connection and session controls under `extraOptions.qwp`. + +Roll out `ws::` per sender instance so the existing protocols can remain in service +during migration. + +### Low-level QWP ingress + +Code that manually creates `QwpTableBuffer` and calls +`QwpIngressSession.sendTables()` can normally move to `connectQwpNodeSender()` or +`connectQwpBrowserSender()`. Keep low-level sessions only when an application needs +to produce encoded table buffers itself. The high-level sender owns batching, symbol +deltas, ACK tracking, auto-flush, transactions, and durable waits. + +### Java client concepts + +The TypeScript high-level sender follows the Java client's core model—fluent rows, +automatic batching, connection-scoped symbol dictionaries, negotiated caps, durable +acknowledgement, and persistent replay—but uses runtime-specific connection factories: + +| Java client concept | TypeScript API | +| ---------------------------- | ------------------------------------------------------------- | +| Sender/builder configuration | `Sender.fromConfig()` in Node.js, or `connectQwp*Sender()` | +| Fluent table row | `table()`, typed column methods, `at()` / `atNow()` | +| Explicit drain/commit | `flush()` / `commit()` | +| Durable delivery | `requestDurableAck` plus `awaitDurableAck` | +| Store-and-forward | Node `storeAndForward`; intentionally unavailable in browsers | +| Query parameters | `session.query(sql, { binds })` | +| Result batches | `for await (const batch of query)` | + +Do not translate Java threading assumptions directly: callbacks, WebSocket delivery, +and iteration all share the JavaScript event loop. + +## Public API policy + +Only the four package entry points listed at the top are public. In particular, +paths containing `internal`, `qwp-node`, or `src` are implementation details even if +a bundler can resolve them. The compatibility contract checks the documented +high-level constructors, session classes, errors, constants, and option signatures +from the shared, browser, and Node entry points. Additional low-level codec exports +from `qwp` are intended for advanced integrations; prefer high-level APIs when no +custom encoder or transport is required. diff --git a/README.md b/README.md index ab00bd7..056593d 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,9 @@ run().then(console.log).catch(console.error); ### QWP ingress from Node.js or a browser +See the [complete QWP guide](./QWP.md) for ingress and egress APIs, browser +authentication, delivery semantics, migration guidance, and the public API policy. + Node.js applications can select QWP through the regular `Sender` API: ```typescript diff --git a/package.json b/package.json index 0c58e1f..137e3f1 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "preview:docs": "serve docs" }, "files": [ + "QWP.md", "THIRD_PARTY_NOTICES.md", "dist/cjs", "dist/es" diff --git a/test/qwp/public-api-contract.ts b/test/qwp/public-api-contract.ts new file mode 100644 index 0000000..ddb902d --- /dev/null +++ b/test/qwp/public-api-contract.ts @@ -0,0 +1,115 @@ +import { Sender } from "../../src"; +import type { ExtraOptions, QwpExtraOptions } from "../../src"; +import { + bootstrapQwpBrowserSession, + connectQwpBrowserEgress, + connectQwpBrowserIngress, + connectQwpBrowserSender, +} from "../../src/qwp/browser"; +import type { + QwpBrowserSessionBootstrapOptions, + QwpBrowserSessionBootstrapResult, + QwpBrowserWebSocketOptions, +} from "../../src/qwp/browser"; +import { + connectQwpNodeEgress, + connectQwpNodeIngress, + connectQwpNodeSender, + connectQwpNodeWebSocket, +} from "../../src/qwp/node"; +import type { + QwpNodeEgressOptions, + QwpNodeIngressOptions, + QwpNodeWebSocketOptions, +} from "../../src/qwp/node"; +import type { + QwpBinaryConnection, + QwpEgressQueryOptions, + QwpEgressSession, + QwpEgressSessionOptions, + QwpIngressSession, + QwpIngressSessionOptions, + QwpSender, + QwpSenderOptions, +} from "../../src/qwp"; + +// This file is part of the repository typecheck. Assignments deliberately +// capture the documented call shapes, so removing or changing a public +// signature fails compilation even though TypeScript types do not exist at +// runtime. +const browserSenderSignature: ( + options: QwpBrowserWebSocketOptions, + senderOptions?: QwpSenderOptions, + sessionOptions?: QwpIngressSessionOptions, +) => Promise = connectQwpBrowserSender; + +const browserIngressSignature: ( + options: QwpBrowserWebSocketOptions, + sessionOptions?: QwpIngressSessionOptions, +) => Promise = connectQwpBrowserIngress; + +const browserEgressSignature: ( + options: QwpBrowserWebSocketOptions, + sessionOptions?: QwpEgressSessionOptions, +) => Promise = connectQwpBrowserEgress; + +const bootstrapSignature: ( + options: QwpBrowserSessionBootstrapOptions, +) => Promise = bootstrapQwpBrowserSession; + +const nodeSenderSignature: ( + options: QwpNodeIngressOptions, + senderOptions?: QwpSenderOptions, + sessionOptions?: QwpIngressSessionOptions, +) => Promise = connectQwpNodeSender; + +const nodeIngressSignature: ( + options: QwpNodeIngressOptions, + sessionOptions?: QwpIngressSessionOptions, +) => Promise = connectQwpNodeIngress; + +const nodeEgressSignature: ( + options: QwpNodeEgressOptions, + sessionOptions?: QwpEgressSessionOptions, +) => Promise = connectQwpNodeEgress; + +const nodeWebSocketSignature: ( + options: QwpNodeWebSocketOptions, +) => Promise = connectQwpNodeWebSocket; + +const queryOptionsContract: QwpEgressQueryOptions = { + initialCredit: 1024, + autoCredit: true, + timeoutMs: 30_000, + binds: (binds) => binds.setVarchar(0, "ETH-USD"), +}; + +const qwpExtraOptionsContract: QwpExtraOptions = { + webSocket: { + requestDurableAck: true, + storeAndForward: { directory: "/tmp/qwp-public-api-contract" }, + }, + sender: { + transactional: true, + awaitDurableAck: true, + }, + session: { + reconnect: { maxAttempts: 3 }, + }, +}; + +const rootExtraOptionsContract: ExtraOptions = { + qwp: qwpExtraOptionsContract, +}; + +void browserSenderSignature; +void browserIngressSignature; +void browserEgressSignature; +void bootstrapSignature; +void nodeSenderSignature; +void nodeIngressSignature; +void nodeEgressSignature; +void nodeWebSocketSignature; +void queryOptionsContract; +void rootExtraOptionsContract; +void Sender; diff --git a/test/qwp/public-api.test.ts b/test/qwp/public-api.test.ts new file mode 100644 index 0000000..a1ec5f8 --- /dev/null +++ b/test/qwp/public-api.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; + +import * as browser from "../../src/qwp/browser"; +import * as node from "../../src/qwp/node"; +import * as shared from "../../src/qwp"; + +const sharedRuntimeContract = [ + "QWP_INGRESS_PROGRESS_KIND", + "QWP_RECONNECT_EVENT_KIND", + "QWP_UPGRADE_ERROR_KIND", + "QWP_VERSION", + "QwpBatchTooLargeError", + "QwpBindValues", + "QwpDurableAckUnavailableError", + "QwpEgressQuery", + "QwpEgressQueryError", + "QwpEgressQueryTimeoutError", + "QwpEgressReplayRequiredError", + "QwpEgressSession", + "QwpIngressNackError", + "QwpIngressSession", + "QwpProtocolError", + "QwpReconnectExhaustedError", + "QwpReplayRejectedError", + "QwpResultBatch", + "QwpSendTimeoutError", + "QwpSender", + "QwpUpgradeError", +] as const; + +const browserRuntimeContract = [ + "QwpBrowserSessionBootstrapError", + "bootstrapQwpBrowserSession", + "connectQwpBrowserEgress", + "connectQwpBrowserIngress", + "connectQwpBrowserSender", + "connectQwpBrowserWebSocket", + "createQwpBrowserConnectionFactory", + "createQwpBrowserSender", +] as const; + +const nodeRuntimeContract = [ + "QwpNodeFileReplayStore", + "QwpReplayStoreError", + "QwpReplayStoreFullError", + "QwpVersionMismatchError", + "connectQwpNodeEgress", + "connectQwpNodeIngress", + "connectQwpNodeSender", + "connectQwpNodeWebSocket", + "createQwpNodeConnectionFactory", + "createQwpNodeSender", +] as const; + +function assertRuntimeContract( + module: Record, + contract: readonly string[], +): void { + for (const name of contract) { + expect(module, `missing public runtime export ${name}`).toHaveProperty( + name, + ); + } +} + +describe("QWP public API contract", () => { + it("keeps the documented shared runtime exports", () => { + assertRuntimeContract(shared, sharedRuntimeContract); + }); + + it("keeps the documented browser runtime exports", () => { + assertRuntimeContract(browser, sharedRuntimeContract); + assertRuntimeContract(browser, browserRuntimeContract); + }); + + it("keeps the documented Node.js runtime exports", () => { + assertRuntimeContract(node, sharedRuntimeContract); + assertRuntimeContract(node, nodeRuntimeContract); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 10a49a8..fd1098f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,5 +1,5 @@ { - "include": ["src"], + "include": ["src", "test/qwp/public-api-contract.ts"], "compilerOptions": { "moduleResolution": "bundler", "module": "ESNext", From 0a9c0bcd0934d31e83efe34faf5c2f560d0c9018 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 18 Aug 2026 10:06:27 +0100 Subject: [PATCH 027/265] fix(qwp): discard recovered deferred transaction tails --- .../reconnecting-ingress-connection.ts | 100 +++++++++++-- test/qwp/reconnect.test.ts | 134 ++++++++++++++++++ 2 files changed, 226 insertions(+), 8 deletions(-) diff --git a/src/qwp/internal/reconnecting-ingress-connection.ts b/src/qwp/internal/reconnecting-ingress-connection.ts index 6164161..513fe8a 100644 --- a/src/qwp/internal/reconnecting-ingress-connection.ts +++ b/src/qwp/internal/reconnecting-ingress-connection.ts @@ -1,8 +1,11 @@ import { + decodeQwpFrame, decodeQwpIngressResponse, decodeQwpIngressSymbolDictionaryDelta, encodeQwpIngressSymbolDictionaryFrame, + QWP_FLAG_DEFER_COMMIT, QWP_FLAG_DELTA_SYMBOL_DICTIONARY, + QWP_FLAG_DURABLE_ACK_POLL, QWP_HEADER_SIZE, QWP_STATUS, QwpProtocolError, @@ -37,6 +40,12 @@ interface ReplayFrame extends QwpIngressReplayRecord { dictionaryCatchup?: boolean; } +interface RecoveredDiscardTail { + readonly startSequence: bigint; + readonly tipSequence: bigint; + readonly predecessorSequence?: bigint; +} + class RetriableIngressNackError extends Error { constructor( readonly frameSequence: bigint, @@ -121,6 +130,7 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { private nextClientSequence = 0n; private acknowledgedFrameSequence = -1n; private rejectedFrameSequence?: bigint; + private recoveredDiscardTail?: RecoveredDiscardTail; private rejectionCount = 0; private generation = 0; private sendTail: Promise = Promise.resolve(); @@ -149,10 +159,12 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { store: QwpIngressReplayStore, records: readonly QwpIngressReplayRecord[], symbolDictionary: readonly string[], + recoveredDiscardTail: RecoveredDiscardTail | undefined, localMaxBatchSizeBytes?: number, ) { this.store = store; this.symbolDictionary = [...symbolDictionary]; + this.recoveredDiscardTail = recoveredDiscardTail; this.localMaxBatchSizeBytes = localMaxBatchSizeBytes; this.maxAttempts = reconnectOptions.maxAttempts ?? 3; this.initialBackoffMs = reconnectOptions.initialBackoffMs ?? 100; @@ -204,24 +216,27 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { let connection: QwpReconnectingIngressConnection | undefined; try { const records = await store.load(); + const sortedRecords = [...records].sort((a, b) => + a.frameSequence < b.frameSequence + ? -1 + : a.frameSequence > b.frameSequence + ? 1 + : 0, + ); const symbolDictionary = store.loadSymbolDictionary ? await store.loadSymbolDictionary() : []; - validateRecoveredDictionary(records, symbolDictionary, store); + validateRecoveredDictionary(sortedRecords, symbolDictionary, store); connection = new QwpReconnectingIngressConnection( factory, reconnectOptions, store, - [...records].sort((a, b) => - a.frameSequence < b.frameSequence - ? -1 - : a.frameSequence > b.frameSequence - ? 1 - : 0, - ), + sortedRecords, symbolDictionary, + analyzeRecoveredDiscardTail(sortedRecords), localMaxBatchSizeBytes, ); + await connection.retireRecoveredDiscardTailIfReady(); await connection.connectLoop(undefined, false); return connection; } catch (error) { @@ -446,6 +461,7 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { } for (const frame of this.frames.values()) { if (!frame.transmitted) continue; + if (this.isRecoveredDiscardFrame(frame.frameSequence)) continue; frame.durableTargets = undefined; if (cap !== undefined && frame.payload.byteLength > cap) { throw new RangeError( @@ -642,6 +658,13 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { } private async acknowledgeThrough(frameSequence: bigint): Promise { + await this.acknowledgeStoredFramesThrough(frameSequence); + await this.retireRecoveredDiscardTailIfReady(); + } + + private async acknowledgeStoredFramesThrough( + frameSequence: bigint, + ): Promise { await this.store.acknowledgeThrough(frameSequence); for (const sequence of this.frames.keys()) { if (sequence > frameSequence) break; @@ -652,6 +675,28 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { } } + private isRecoveredDiscardFrame(frameSequence: bigint): boolean { + const tail = this.recoveredDiscardTail; + return ( + tail !== undefined && + frameSequence >= tail.startSequence && + frameSequence <= tail.tipSequence + ); + } + + private async retireRecoveredDiscardTailIfReady(): Promise { + const tail = this.recoveredDiscardTail; + if (!tail) return; + if ( + tail.predecessorSequence !== undefined && + this.frames.has(tail.predecessorSequence) + ) { + return; + } + await this.acknowledgeStoredFramesThrough(tail.tipSequence); + this.recoveredDiscardTail = undefined; + } + private async persistSymbolDictionaryDelta( delta: NonNullable>, ): Promise { @@ -847,6 +892,45 @@ function readSymbolDictionaryDelta(payload: Uint8Array) { return decodeQwpIngressSymbolDictionaryDelta(payload); } +function analyzeRecoveredDiscardTail( + records: readonly QwpIngressReplayRecord[], +): RecoveredDiscardTail | undefined { + let boundaryIndex = -1; + for (let index = 0; index < records.length; index++) { + if (isRecoveredCommitBarrier(records[index].payload)) { + boundaryIndex = index; + } + } + if (boundaryIndex === records.length - 1) return undefined; + return { + startSequence: records[boundaryIndex + 1].frameSequence, + tipSequence: records[records.length - 1].frameSequence, + predecessorSequence: + boundaryIndex < 0 ? undefined : records[boundaryIndex].frameSequence, + }; +} + +function isRecoveredCommitBarrier(payload: Uint8Array): boolean { + try { + const frame = decodeQwpFrame(payload); + if ((frame.flags & QWP_FLAG_DEFER_COMMIT) !== 0) return false; + // A durable-ACK poll is side-effect-free and cannot cover deferred data + // before it. Treat an exact poll as transparent during the recovery scan. + if ( + frame.flags === QWP_FLAG_DURABLE_ACK_POLL && + frame.tableCount === 0 && + frame.payloadLength === 0 + ) { + return false; + } + return true; + } catch { + // Opaque low-level payloads and malformed QWP records are never silently + // retired. They remain replay barriers and preserve the existing behavior. + return true; + } +} + function validateRecoveredDictionary( records: readonly QwpIngressReplayRecord[], dictionary: readonly string[], diff --git a/test/qwp/reconnect.test.ts b/test/qwp/reconnect.test.ts index 3ea37bf..39c3c0c 100644 --- a/test/qwp/reconnect.test.ts +++ b/test/qwp/reconnect.test.ts @@ -31,6 +31,7 @@ import { QwpReplayRejectedError, QwpUpgradeError, encodeQwpFrame, + encodeQwpDurableAckPollFrame, encodeQwpIngressFrame, decodeQwpIngressSymbolDictionaryDelta, writeQwpVarint, @@ -620,6 +621,139 @@ describe("QWP ingress reconnect and replay", () => { await rm(directory, { recursive: true, force: true }); }); + it("retires a wholly deferred recovered transaction without replaying it", async () => { + const directory = await createTemporaryDirectory(); + const firstDeferred = encodeQwpIngressFrame([symbolTable("ETH-USD")], { + deferCommit: true, + }); + const secondDeferred = encodeQwpIngressFrame([symbolTable("BTC-USD")], { + deferCommit: true, + }); + const seed = new QwpNodeFileReplayStore({ directory }); + await seed.load(); + await seed.append({ frameSequence: 5n, payload: firstDeferred }); + await seed.append({ frameSequence: 6n, payload: secondDeferred }); + await seed.append({ + frameSequence: 7n, + payload: encodeQwpDurableAckPollFrame(), + }); + await seed.close(); + + const connection = new FakeConnection("primary"); + const session = await QwpIngressSession.connect(async () => connection, { + reconnect: { maxAttempts: 1 }, + replayStore: new QwpNodeFileReplayStore({ directory }), + }); + expect(connection.sent).toEqual([]); + expect( + (await readdir(directory)).filter((name) => name.endsWith(".qwp")), + ).toEqual([]); + expect(session.metrics).toMatchObject({ + replayPublishedFrameSequence: 7n, + replayAcknowledgedFrameSequence: 7n, + pendingReplayFrames: 0, + totalFramesReplayed: 0, + }); + + const currentFrame = encodeQwpIngressFrame([symbolTable("SOL-USD")]); + const current = session.sendFrame(currentFrame); + await vi.waitFor(() => expect(connection.sent).toEqual([currentFrame])); + connection.receive(ingressResponse(QWP_STATUS.OK, 0n)); + await expect(current).resolves.toMatchObject({ sequence: 0n }); + await session.close(); + + const verify = new QwpNodeFileReplayStore({ directory }); + await expect(verify.load()).resolves.toEqual([]); + await verify.close(); + await rm(directory, { recursive: true, force: true }); + }); + + it("replays a committed prefix before retiring its deferred recovery tail", async () => { + const directory = await createTemporaryDirectory(); + const committed = encodeQwpIngressFrame([symbolTable("ETH-USD")]); + const deferred = encodeQwpIngressFrame([symbolTable("BTC-USD")], { + deferCommit: true, + }); + const seed = new QwpNodeFileReplayStore({ directory }); + await seed.load(); + await seed.append({ frameSequence: 5n, payload: committed }); + await seed.append({ frameSequence: 6n, payload: deferred }); + await seed.append({ + frameSequence: 7n, + payload: encodeQwpDurableAckPollFrame(), + }); + await seed.close(); + + const connection = new FakeConnection("primary", { + qwpVersion: 1, + durableAckEnabled: true, + }); + const session = await QwpIngressSession.connect(async () => connection, { + reconnect: { maxAttempts: 1 }, + replayStore: new QwpNodeFileReplayStore({ directory }), + durableAckKeepaliveMs: 0, + }); + expect(connection.sent).toEqual([committed]); + + connection.receive(ingressResponse(QWP_STATUS.OK, 0n, [["trades", 42n]])); + await vi.waitFor(() => expect(session.metrics.pendingReplayFrames).toBe(3)); + connection.receive(durableResponse([["trades", 42n]])); + await vi.waitFor(async () => + expect( + (await readdir(directory)).filter((name) => name.endsWith(".qwp")), + ).toEqual([]), + ); + expect(session.metrics).toMatchObject({ + replayAcknowledgedFrameSequence: 7n, + pendingReplayFrames: 0, + totalFramesReplayed: 1, + }); + + const currentFrame = encodeQwpIngressFrame([symbolTable("SOL-USD")]); + const current = session.sendFrame(currentFrame); + await vi.waitFor(() => + expect(connection.sent).toEqual([committed, currentFrame]), + ); + connection.receive(ingressResponse(QWP_STATUS.OK, 1n, [["trades", 43n]])); + await expect(current).resolves.toMatchObject({ sequence: 0n }); + connection.receive(durableResponse([["trades", 43n]])); + await vi.waitFor(async () => + expect( + (await readdir(directory)).filter((name) => name.endsWith(".qwp")), + ).toEqual([]), + ); + await session.close(); + await rm(directory, { recursive: true, force: true }); + }); + + it("replays deferred recovery frames when a commit frame covers them", async () => { + const directory = await createTemporaryDirectory(); + const deferred = encodeQwpIngressFrame([symbolTable("ETH-USD")], { + deferCommit: true, + }); + const commit = encodeQwpIngressFrame([symbolTable("BTC-USD")]); + const seed = new QwpNodeFileReplayStore({ directory }); + await seed.load(); + await seed.append({ frameSequence: 5n, payload: deferred }); + await seed.append({ frameSequence: 6n, payload: commit }); + await seed.close(); + + const connection = new FakeConnection("primary"); + const session = await QwpIngressSession.connect(async () => connection, { + reconnect: { maxAttempts: 1 }, + replayStore: new QwpNodeFileReplayStore({ directory }), + }); + expect(connection.sent).toEqual([deferred, commit]); + connection.receive(ingressResponse(QWP_STATUS.OK, 1n)); + await vi.waitFor(async () => + expect( + (await readdir(directory)).filter((name) => name.endsWith(".qwp")), + ).toEqual([]), + ); + await session.close(); + await rm(directory, { recursive: true, force: true }); + }); + it("retains Node journal records until a negotiated durable ACK", async () => { const directory = await createTemporaryDirectory(); const connection = new FakeConnection("primary", { From 0098ed3b6fbe9df801b6dc405d81597ccc214df5 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 18 Aug 2026 10:11:11 +0100 Subject: [PATCH 028/265] fix(qwp): trim cumulative durable transaction ACKs --- .../reconnecting-ingress-connection.ts | 5 +- test/qwp/reconnect.test.ts | 59 ++++++++++++++++++- 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/src/qwp/internal/reconnecting-ingress-connection.ts b/src/qwp/internal/reconnecting-ingress-connection.ts index 513fe8a..c9db6cf 100644 --- a/src/qwp/internal/reconnecting-ingress-connection.ts +++ b/src/qwp/internal/reconnecting-ingress-connection.ts @@ -648,7 +648,10 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { private async trimDurablePrefix(): Promise { let lastCovered: bigint | undefined; for (const frame of this.frames.values()) { - if (!frame.durableTargets) break; + // Successful ingress ACKs are cumulative. Deferred frames therefore + // have no checkpoint of their own; a later commit-bearing ACK covers + // them and its durable targets retire the whole preceding range. + if (!frame.durableTargets) continue; if (!areTargetsCovered(frame.durableTargets, this.durableWatermarks)) { break; } diff --git a/test/qwp/reconnect.test.ts b/test/qwp/reconnect.test.ts index 39c3c0c..129b5c5 100644 --- a/test/qwp/reconnect.test.ts +++ b/test/qwp/reconnect.test.ts @@ -533,6 +533,51 @@ describe("QWP ingress reconnect and replay", () => { await session.close(); }); + it("durably trims cumulative transaction ranges at ordered ACK checkpoints", async () => { + const connection = new FakeConnection("primary", { + qwpVersion: 1, + durableAckEnabled: true, + }); + const replayStore = new TrackingReplayStore(); + const session = await QwpIngressSession.connect(async () => connection, { + ackTimeoutMs: 1_000, + durableAckKeepaliveMs: 0, + reconnect: { maxAttempts: 1 }, + replayStore, + }); + const deferred = encodeQwpIngressFrame([symbolTable("ETH-USD")], { + deferCommit: true, + }); + const transactionCommit = encodeQwpIngressFrame([symbolTable("BTC-USD")]); + const laterCommit = encodeQwpIngressFrame([symbolTable("SOL-USD")]); + + const responses = [ + session.sendFrame(deferred), + session.sendFrame(transactionCommit), + session.sendFrame(laterCommit), + ]; + await vi.waitFor(() => expect(connection.sent).toHaveLength(3)); + connection.receive(ingressResponse(QWP_STATUS.OK, 1n, [["trades", 42n]])); + connection.receive(ingressResponse(QWP_STATUS.OK, 2n, [["trades", 50n]])); + await expect(Promise.all(responses)).resolves.toHaveLength(3); + expect(Array.from(replayStore.records.keys())).toEqual([0n, 1n, 2n]); + + connection.receive(durableResponse([["trades", 41n]])); + await vi.waitFor(() => expect(session.metrics.totalDurableAcks).toBe(1)); + expect(Array.from(replayStore.records.keys())).toEqual([0n, 1n, 2n]); + + connection.receive(durableResponse([["trades", 42n]])); + await vi.waitFor(() => + expect(Array.from(replayStore.records.keys())).toEqual([2n]), + ); + expect(session.metrics.replayAcknowledgedFrameSequence).toBe(1n); + + connection.receive(durableResponse([["trades", 50n]])); + await vi.waitFor(() => expect(replayStore.records.size).toBe(0)); + expect(session.metrics.replayAcknowledgedFrameSequence).toBe(2n); + await session.close(); + }); + it("recovers a persisted Node dictionary before replay and continues its IDs", async () => { const directory = await createTemporaryDirectory(); const dictionary = new QwpSymbolDictionary(); @@ -738,13 +783,23 @@ describe("QWP ingress reconnect and replay", () => { await seed.append({ frameSequence: 6n, payload: commit }); await seed.close(); - const connection = new FakeConnection("primary"); + const connection = new FakeConnection("primary", { + qwpVersion: 1, + durableAckEnabled: true, + }); const session = await QwpIngressSession.connect(async () => connection, { reconnect: { maxAttempts: 1 }, replayStore: new QwpNodeFileReplayStore({ directory }), + durableAckKeepaliveMs: 0, }); expect(connection.sent).toEqual([deferred, commit]); - connection.receive(ingressResponse(QWP_STATUS.OK, 1n)); + connection.receive(ingressResponse(QWP_STATUS.OK, 1n, [["trades", 42n]])); + await vi.waitFor(async () => + expect( + (await readdir(directory)).filter((name) => name.endsWith(".qwp")), + ).toHaveLength(2), + ); + connection.receive(durableResponse([["trades", 42n]])); await vi.waitFor(async () => expect( (await readdir(directory)).filter((name) => name.endsWith(".qwp")), From 7f96403c0f762bd796dad8a6fb0beadbc154ea29 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 18 Aug 2026 10:26:40 +0100 Subject: [PATCH 029/265] feat(qwp): add offline store-and-forward publishing --- QWP.md | 35 ++++- README.md | 7 + src/qwp/ingress-session.ts | 121 ++++++++++++++++++ .../reconnecting-ingress-connection.ts | 43 ++++++- src/qwp/node.ts | 17 ++- src/qwp/sender.ts | 68 ++++++++-- test/qwp/node-transport.test.ts | 59 ++++++++- test/qwp/reconnect.test.ts | 121 ++++++++++++++++++ test/qwp/sender.test.ts | 76 +++++++++++ 9 files changed, 521 insertions(+), 26 deletions(-) diff --git a/QWP.md b/QWP.md index d7b4eaf..3c37f18 100644 --- a/QWP.md +++ b/QWP.md @@ -90,10 +90,28 @@ const sender = await Sender.fromConfig( ``` Give each active sender its own store-and-forward directory. The Node.js journal -persists frames and their symbol dictionary before sending. A crash after the server -accepts a frame but before local acknowledgement cleanup can replay that frame, so -delivery is at least once. Applications that require exactly-once effects should use -their own stable event key or another idempotency strategy. +persists frames and their symbol dictionary before sending. Persistent senders can +start while every endpoint is offline and reconnect indefinitely by default. Unless +`awaitServerAck: true` or `awaitDurableAck: true` is selected, `flush()` resolves once +the complete logical flush is durable in the local journal; a background drainer then +sends it in order. Applications can therefore keep publishing during an outage until +the configured `maxBytes` applies backpressure. A failed journal publication leaves +the high-level rows staged so the caller can retry. + +An offline sender cannot inspect the server-advertised batch cap before its first +publication. Set `qwp.session.maxBatchSizeBytes` to a value no greater than the +smallest target node's cap when offline startup is required. + +Set `awaitServerAck: true` when a particular flush must observe QuestDB's protocol ACK +before returning. `awaitDurableAck: true` implies server-ACK waiting and additionally +waits for replicated/durable progress. Browser senders continue to default to their +existing ACK-waiting behavior and do not offer persistent publication. + +A crash after the server accepts a frame but before local acknowledgement cleanup can +replay that frame, so delivery is at least once. Applications that require exactly-once +effects should use their own stable event key or another idempotency strategy. Closing +a persistent sender stops its drainer but preserves published, unacknowledged frames for +the next sender using that directory. ### Direct high-level API @@ -138,6 +156,11 @@ deltas, tracks acknowledgements, and splits multi-row batches at the smaller of client cap and the server-advertised cap. One row that cannot fit is rejected with `QwpBatchTooLargeError` before it is sent. +Low-level Node sessions expose `publishFrame()`, `publishTables()`, and +`publishTablesDelta()` for local-publication semantics. Their `send*()` counterparts +continue to return the server ACK. Use the publication methods only with persistent +store-and-forward when local durability is the intended completion boundary. + ### Browser ingress Browser applications must use the browser entry point and a same-origin WebSocket @@ -405,7 +428,9 @@ For the common fluent API, migration is primarily a transport change: Review these behavioral differences before rollout: -- QWP `flush()` waits for a protocol ACK; optionally it also waits for durable upload. +- QWP `flush()` waits for a protocol ACK by default. With Node persistent + store-and-forward it defaults to local durable publication; set `awaitServerAck` to + restore ACK waiting, or `awaitDurableAck` to wait through durable upload. - QWP symbol dictionaries are connection-scoped and automatic. - Large batches are split to the negotiated WebSocket payload cap. - QWP transactional auto-flush is per table and must be explicitly committed. diff --git a/README.md b/README.md index 056593d..03cfecb 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,13 @@ await sender.flush(); await sender.close(); ``` +When Node QWP is configured with `qwp.webSocket.storeAndForward`, the sender +can start and accept flushes while QuestDB is offline. `flush()` then resolves +after local durable journal publication and a background drainer reconnects +and sends in order. Set `qwp.sender.awaitServerAck: true` to wait for the +QuestDB ACK instead, or `awaitDurableAck: true` to wait through durable upload. +This persistent mode is Node-only; browser senders continue to default to ACK waiting. + Browser applications use the browser entry point, which has no Node.js dependencies. Cookies are supplied by the browser during a same-origin WebSocket upgrade. diff --git a/src/qwp/ingress-session.ts b/src/qwp/ingress-session.ts index 8796661..2dd1fa4 100644 --- a/src/qwp/ingress-session.ts +++ b/src/qwp/ingress-session.ts @@ -154,6 +154,8 @@ export interface QwpIngressSessionOptions { reconnect?: QwpReconnectOptions; /** @internal Node adapter hook for persistent store-and-forward. */ replayStore?: QwpIngressReplayStore; + /** @internal Starts the Node persistent drainer without waiting for a server. */ + backgroundStoreAndForward?: boolean; /** * Optional local ingress frame cap. Browsers cannot read WebSocket upgrade * headers, so browser applications should set this to the server's configured @@ -383,12 +385,18 @@ export class QwpIngressSession { if (options.replayStore && !options.reconnect) { throw new RangeError("a QWP replayStore requires reconnect options"); } + if (options.backgroundStoreAndForward && !options.replayStore) { + throw new RangeError( + "background QWP store-and-forward requires a replayStore", + ); + } const connection = options.reconnect ? await QwpReconnectingIngressConnection.connect( factory, options.reconnect, options.replayStore, options.maxBatchSizeBytes, + options.backgroundStoreAndForward, ) : await factory(); try { @@ -469,6 +477,31 @@ export class QwpIngressSession { return this.sendPlannedFrames(planned.frames); } + /** + * Encodes and publishes tables without waiting for their server ACK. With + * Node store-and-forward this resolves only after every frame is durable in + * the local journal; browser and non-persistent transports resolve after the + * WebSocket accepts the frames. + */ + publishTables( + tables: readonly QwpTableBuffer[], + encodeOptions: QwpIngressEncodeOptions = {}, + ): Promise { + this.throwIfUnavailable(); + const cap = this.maxBatchSizeBytes; + if (cap === undefined) { + return this.publishFrame(encodeQwpIngressFrame(tables, encodeOptions)); + } + let planned: PlannedIngressFrames; + try { + planned = planIngressFrames(tables, encodeOptions, cap); + } catch (error) { + if (error instanceof QwpBatchTooLargeError) return Promise.reject(error); + throw error; + } + return this.publishPlannedFrames(planned.frames); + } + /** * Sends tables using the session's connection-scoped symbol dictionary. * String symbol values are assigned stable IDs automatically. @@ -537,6 +570,88 @@ export class QwpIngressSession { } } + /** Publishes tables with the automatic connection-scoped symbol dictionary. */ + async publishTablesDelta( + tables: readonly QwpTableBuffer[], + encodeOptions: Pick< + QwpIngressEncodeOptions, + "gorilla" | "deferCommit" + > = {}, + ): Promise { + this.throwIfUnavailable(); + const previousSize = this.symbolDictionary.size; + const previousPublishedMaxSymbolId = this.publishedMaxSymbolId; + const previousDeltaSymbolsPublished = this.deltaSymbolsPublished; + try { + const cap = this.maxBatchSizeBytes; + if (cap !== undefined) { + const planned = planIngressFrames( + tables, + { + ...encodeOptions, + dictionary: this.symbolDictionary, + confirmedMaxSymbolId: this.publishedMaxSymbolId, + }, + cap, + ); + this.publishedMaxSymbolId = this.symbolDictionary.size - 1; + this.deltaSymbolsPublished = true; + await this.publishPlannedFrames(planned.frames); + return; + } + + const frame = encodeQwpIngressFrame(tables, { + ...encodeOptions, + dictionary: this.symbolDictionary, + confirmedMaxSymbolId: this.publishedMaxSymbolId, + }); + this.publishedMaxSymbolId = this.symbolDictionary.size - 1; + this.deltaSymbolsPublished = true; + await this.publishFrame(frame); + } catch (error) { + this.symbolDictionary.truncate(previousSize); + this.publishedMaxSymbolId = previousPublishedMaxSymbolId; + this.deltaSymbolsPublished = previousDeltaSymbolsPublished; + throw error; + } + } + + /** + * Publishes one pre-encoded frame without allocating an ACK waiter. + * Applications can observe later acceptance through progress callbacks. + */ + publishFrame(frame: Uint8Array): Promise { + this.throwIfUnavailable(); + if ( + this.maxBatchSizeBytes !== undefined && + frame.byteLength > this.maxBatchSizeBytes + ) { + return Promise.reject( + new QwpBatchTooLargeError(frame.byteLength, this.maxBatchSizeBytes), + ); + } + const sequence = this.nextSequence++; + this.totalFramesPublished++; + this.totalBytesPublished += frame.byteLength; + const publishing = this.sendTail.then(async () => { + this.throwIfUnavailable(); + await this.connection.send(frame); + }); + // A local store-capacity failure is backpressure, not a terminal session + // failure. Keep the publication queue usable so callers can retry after + // the background drainer frees journal capacity. + this.sendTail = publishing.catch(() => undefined); + this.emitProgress(QWP_INGRESS_PROGRESS_KIND.PUBLISHED, sequence); + void publishing.then( + () => { + this.totalFramesSent++; + this.totalBytesSent += frame.byteLength; + }, + () => undefined, + ); + return publishing; + } + sendFrame(frame: Uint8Array): Promise { this.throwIfUnavailable(); const ackDeferredUntilCommit = @@ -608,6 +723,12 @@ export class QwpIngressSession { return Promise.all(responses).then(mergeIngressResponses); } + private async publishPlannedFrames( + frames: readonly Uint8Array[], + ): Promise { + for (const frame of frames) await this.publishFrame(frame); + } + /** * Waits until a durable ACK covers every table transaction in an OK ACK. * Durable tracking must have been enabled with durableAckKeepaliveMs. diff --git a/src/qwp/internal/reconnecting-ingress-connection.ts b/src/qwp/internal/reconnecting-ingress-connection.ts index c9db6cf..ebe5a73 100644 --- a/src/qwp/internal/reconnecting-ingress-connection.ts +++ b/src/qwp/internal/reconnecting-ingress-connection.ts @@ -134,6 +134,7 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { private rejectionCount = 0; private generation = 0; private sendTail: Promise = Promise.resolve(); + private drainTail: Promise = Promise.resolve(); private reconnectTask?: Promise; private storeClosePromise?: Promise; private terminalError?: Error; @@ -161,6 +162,7 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { symbolDictionary: readonly string[], recoveredDiscardTail: RecoveredDiscardTail | undefined, localMaxBatchSizeBytes?: number, + private readonly backgroundStoreAndForward = false, ) { this.store = store; this.symbolDictionary = [...symbolDictionary]; @@ -211,6 +213,7 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { reconnectOptions: QwpReconnectOptions, replayStore?: QwpIngressReplayStore, localMaxBatchSizeBytes?: number, + backgroundStoreAndForward = false, ): Promise { const store = replayStore ?? new QwpMemoryReplayStore(); let connection: QwpReconnectingIngressConnection | undefined; @@ -235,9 +238,11 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { symbolDictionary, analyzeRecoveredDiscardTail(sortedRecords), localMaxBatchSizeBytes, + backgroundStoreAndForward, ); await connection.retireRecoveredDiscardTailIfReady(); - await connection.connectLoop(undefined, false); + if (backgroundStoreAndForward) connection.startBackgroundConnect(); + else await connection.connectLoop(undefined, false); return connection; } catch (error) { await connection?.close().catch(() => undefined); @@ -247,8 +252,10 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { } get handshake(): QwpHandshakeMetadata { - if (!this.lastHandshake) + if (!this.lastHandshake) { + if (this.backgroundStoreAndForward) return { qwpVersion: 1 }; throw new Error("QWP connection is not established"); + } return this.lastHandshake; } @@ -292,12 +299,16 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { ackDelivered: false, transmitted: false, }; - const sending = this.sendTail.then(async () => { + const publishing = this.sendTail.then(async () => { this.throwIfUnavailable(); const delta = readSymbolDictionaryDelta(frame.payload); if (delta) await this.persistSymbolDictionaryDelta(delta); await this.store.append(frame); this.frames.set(frame.frameSequence, frame); + if (this.backgroundStoreAndForward) { + this.enqueueDrain(frame); + return; + } try { await this.transmit(frame); } catch (error) { @@ -305,8 +316,30 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { throw error; } }); - this.sendTail = sending.catch(() => undefined); - return sending; + this.sendTail = publishing.catch(() => undefined); + return publishing; + } + + private enqueueDrain(frame: ReplayFrame): void { + const draining = this.drainTail.then(async () => { + if (this.closing) return; + await this.transmit(frame); + }); + this.drainTail = draining.catch((error: unknown) => { + if (!this.closing) this.failTerminal(error); + }); + } + + private startBackgroundConnect(): void { + const connecting = this.connectLoop(undefined, false); + this.reconnectTask = connecting; + void connecting + .catch((error: unknown) => { + if (!this.closing) this.failTerminal(error); + }) + .finally(() => { + if (this.reconnectTask === connecting) this.reconnectTask = undefined; + }); } async close(code = 1000, reason = ""): Promise { diff --git a/src/qwp/node.ts b/src/qwp/node.ts index acebdf8..cb07af4 100644 --- a/src/qwp/node.ts +++ b/src/qwp/node.ts @@ -339,12 +339,17 @@ export async function connectQwpNodeIngress( ? new QwpNodeFileReplayStore(options.storeAndForward) : sessionOptions.replayStore; const reconnect = options.storeAndForward - ? (sessionOptions.reconnect ?? {}) + ? { + maxAttempts: 0, + maxDurationMs: 0, + ...sessionOptions.reconnect, + } : sessionOptions.reconnect; const effectiveSessionOptions: QwpIngressSessionOptions = { ...sessionOptions, reconnect, replayStore, + backgroundStoreAndForward: options.storeAndForward !== undefined, durableAckKeepaliveMs: options.requestDurableAck ? (sessionOptions.durableAckKeepaliveMs ?? 200) : sessionOptions.durableAckKeepaliveMs, @@ -364,17 +369,23 @@ export function createQwpNodeSender( senderOptions: QwpSenderOptions = {}, sessionOptions: QwpIngressSessionOptions = {}, ): QwpSender { + const effectiveSenderOptions: QwpSenderOptions = { + ...senderOptions, + awaitServerAck: + senderOptions.awaitServerAck ?? + (options.storeAndForward ? senderOptions.awaitDurableAck === true : true), + }; return new QwpSender( () => connectQwpNodeIngress( { ...options, requestDurableAck: - options.requestDurableAck ?? senderOptions.awaitDurableAck, + options.requestDurableAck ?? effectiveSenderOptions.awaitDurableAck, }, sessionOptions, ), - senderOptions, + effectiveSenderOptions, ); } diff --git a/src/qwp/sender.ts b/src/qwp/sender.ts index 2832898..10ec051 100644 --- a/src/qwp/sender.ts +++ b/src/qwp/sender.ts @@ -32,6 +32,13 @@ export interface QwpSenderOptions { * table, rather than across every table in a multi-table flush. */ transactional?: boolean; + /** + * Wait for the server's protocol ACK before flush()/commit() resolves. + * Defaults to true. Node persistent store-and-forward defaults this to false + * so a flush resolves after local durable publication and drains in the + * background. + */ + awaitServerAck?: boolean; /** Wait for durable upload after every successful ingress ACK. */ awaitDurableAck?: boolean; durableAckTimeoutMs?: number; @@ -51,6 +58,14 @@ export interface QwpSenderSession { tables: readonly QwpTableBuffer[], options?: Pick, ): Promise; + publishTables?( + tables: readonly QwpTableBuffer[], + options?: QwpIngressEncodeOptions, + ): Promise; + publishTablesDelta?( + tables: readonly QwpTableBuffer[], + options?: Pick, + ): Promise; waitForDurable( response: QwpIngressResponse, timeoutMs?: number, @@ -296,6 +311,7 @@ export class QwpSender { private readonly autoFlushRows: number; private readonly autoFlushIntervalMs: number; private readonly transactional: boolean; + private readonly awaitServerAck: boolean; private readonly log: QwpSenderLogger; constructor( @@ -307,6 +323,7 @@ export class QwpSender { this.autoFlushIntervalMs = options.autoFlushIntervalMs ?? DEFAULT_AUTO_FLUSH_INTERVAL_MS; this.transactional = options.transactional ?? false; + this.awaitServerAck = options.awaitServerAck ?? true; validateNonNegativeInteger(this.autoFlushRows, "autoFlushRows"); validateNonNegativeInteger(this.autoFlushIntervalMs, "autoFlushIntervalMs"); if ( @@ -316,6 +333,11 @@ export class QwpSender { ) { throw new RangeError("durableAckTimeoutMs must be a positive number"); } + if (!this.awaitServerAck && options.awaitDurableAck) { + throw new RangeError( + "awaitDurableAck requires awaitServerAck to be enabled", + ); + } this.log = options.log ?? (() => undefined); } @@ -930,10 +952,14 @@ export class QwpSender { // sendTables encodes synchronously. Do not compact staging if encoding // throws, but transfer ownership once the frame has entered the session. const encode = this.options.encode; - const response = + const useDelta = (encode?.symbolDictionary ?? "delta") === "delta" && - session.sendTablesDelta - ? session.sendTablesDelta(wireTables, { + session.sendTablesDelta; + let response: Promise | undefined; + let publication: Promise | undefined; + if (this.awaitServerAck) { + response = useDelta + ? session.sendTablesDelta!(wireTables, { gorilla: encode?.gorilla, deferCommit, }) @@ -941,7 +967,25 @@ export class QwpSender { gorilla: encode?.gorilla, deferCommit, }); + } else { + const publisher = useDelta + ? session.publishTablesDelta + : session.publishTables; + if (!publisher) { + throw new Error( + "this QWP ingress session does not support publication-only flushes", + ); + } + publication = publisher.call(session, wireTables, { + gorilla: encode?.gorilla, + deferCommit, + }); + } this.totalFlushes++; + // Publication-only Node store-and-forward transfers row ownership only + // after every frame is durable locally. A disk-capacity or I/O failure + // therefore leaves the staged rows available for retry. + if (publication) await publication; for (const { table, rows } of snapshots) table.rows.splice(0, rows.length); const sentRows = snapshots.reduce( (count, item) => count + item.rows.length, @@ -958,23 +1002,25 @@ export class QwpSender { if (deferCommit) { this.hasDeferredMessages = true; this.deferredRowCount += sentRows; - this.deferredAcks.push(response); - // The server intentionally withholds this ACK until a later commit. - // Observe rejection now so abandoning an open transaction during close - // never creates an unhandled rejection; flush()/commit() still awaits it. - void response.catch(() => undefined); + if (response) { + this.deferredAcks.push(response); + // The server intentionally withholds this ACK until a later commit. + // Observe rejection now so abandoning an open transaction during close + // never creates an unhandled rejection; flush()/commit() still awaits it. + void response.catch(() => undefined); + } return true; } - const ack = await response; const deferredAcks = this.deferredAcks.splice(0); this.hasDeferredMessages = false; this.deferredRowCount = 0; - await Promise.all(deferredAcks); + const ack = response ? await response : undefined; + if (deferredAcks.length > 0) await Promise.all(deferredAcks); if (this.transactional && (closesDeferredTransaction || sentRows > 0)) { this.totalTransactionsCommitted++; } - if (this.options.awaitDurableAck) { + if (this.options.awaitDurableAck && ack) { await session.waitForDurable(ack, this.options.durableAckTimeoutMs); } return true; diff --git a/test/qwp/node-transport.test.ts b/test/qwp/node-transport.test.ts index e5f717c..b1bb955 100644 --- a/test/qwp/node-transport.test.ts +++ b/test/qwp/node-transport.test.ts @@ -1,13 +1,14 @@ import type { AddressInfo } from "node:net"; -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdtemp, readdir, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { WebSocketServer } from "ws"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { connectQwpNodeEgress, connectQwpNodeIngress, connectQwpNodeWebSocket, + createQwpNodeSender, encodeQwpFrame, QWP_EGRESS_MESSAGE, QWP_STATUS, @@ -249,6 +250,60 @@ describe("QWP Node transport", () => { await rm(directory, { recursive: true, force: true }); } }); + + it("publishes through the high-level sender before an endpoint is online", async () => { + const reservation = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + await listen(reservation); + const port = (reservation.address() as AddressInfo).port; + await closeServer(reservation); + const directory = await mkdtemp(join(tmpdir(), "qwp-node-offline-")); + const sender = createQwpNodeSender( + { + url: `ws://127.0.0.1:${port}/write/v4`, + connectTimeoutMs: 100, + storeAndForward: { directory }, + }, + { autoFlush: false }, + { + reconnect: { + initialBackoffMs: 10, + maxBackoffMs: 10, + }, + }, + ); + + try { + await expect(sender.connect()).resolves.toBe(true); + await sender.table("trades").symbol("symbol", "ETH-USD").atNow(); + await expect(sender.flush()).resolves.toBe(true); + expect( + (await readdir(directory)).filter((name) => name.endsWith(".qwp")), + ).toHaveLength(1); + + server = new WebSocketServer({ host: "127.0.0.1", port }); + server.on("headers", (headers) => { + headers.push("X-QWP-Version: 1"); + }); + server.on("connection", (socket) => { + let sequence = 0n; + socket.on("message", () => { + socket.send(okResponse(sequence++, "trades", 1n)); + }); + }); + await listen(server); + + await vi.waitFor( + async () => + expect( + (await readdir(directory)).filter((name) => name.endsWith(".qwp")), + ).toEqual([]), + { timeout: 2_000 }, + ); + } finally { + await sender.close(); + await rm(directory, { recursive: true, force: true }); + } + }); }); function listen(server: WebSocketServer): Promise { diff --git a/test/qwp/reconnect.test.ts b/test/qwp/reconnect.test.ts index 129b5c5..79e5784 100644 --- a/test/qwp/reconnect.test.ts +++ b/test/qwp/reconnect.test.ts @@ -190,6 +190,29 @@ class TrackingReplayStore implements QwpIngressReplayStore { } } +class FailOnceDictionaryReplayStore extends TrackingReplayStore { + readonly symbols: string[] = []; + appendAttempts = 0; + + override async append(record: QwpIngressReplayRecord): Promise { + this.appendAttempts++; + if (this.appendAttempts === 1) throw new Error("journal is full"); + await super.append(record); + } + + async loadSymbolDictionary(): Promise { + return this.symbols.slice(); + } + + async appendSymbolDictionary( + startId: number, + entries: readonly string[], + ): Promise { + if (startId !== this.symbols.length) throw new Error("dictionary gap"); + this.symbols.push(...entries); + } +} + describe("QWP endpoint failover", () => { it("walks all endpoints and rotates away from the last successful one", async () => { const attempts: string[] = []; @@ -238,6 +261,104 @@ describe("QWP endpoint failover", () => { }); describe("QWP ingress reconnect and replay", () => { + it("publishes while initially offline and drains after a background connection", async () => { + const connection = new FakeConnection("primary"); + const replayStore = new TrackingReplayStore(); + let releaseOnline!: () => void; + const online = new Promise((resolve) => { + releaseOnline = resolve; + }); + let factoryCalls = 0; + const session = await QwpIngressSession.connect( + async () => { + if (factoryCalls++ === 0) { + throw new QwpUpgradeError("offline", { + kind: QWP_UPGRADE_ERROR_KIND.TRANSPORT, + retryable: true, + tryNextEndpoint: true, + }); + } + await online; + return connection; + }, + { + backgroundStoreAndForward: true, + reconnect: { + maxAttempts: 0, + maxDurationMs: 0, + initialBackoffMs: 0, + maxBackoffMs: 0, + }, + replayStore, + }, + ); + + await expect( + session.publishFrame(Uint8Array.of(1)), + ).resolves.toBeUndefined(); + await expect( + session.publishFrame(Uint8Array.of(2)), + ).resolves.toBeUndefined(); + expect(Array.from(replayStore.records.keys())).toEqual([0n, 1n]); + expect(connection.sent).toEqual([]); + expect(session.metrics).toMatchObject({ + pendingResponses: 0, + pendingReplayFrames: 2, + totalFramesSent: 0, + }); + + releaseOnline(); + await vi.waitFor(() => + expect(connection.sent).toEqual([Uint8Array.of(1), Uint8Array.of(2)]), + ); + connection.receive(ingressResponse(QWP_STATUS.OK, 1n)); + await vi.waitFor(() => expect(replayStore.records.size).toBe(0)); + expect(session.metrics).toMatchObject({ + acknowledgedSequence: 1n, + pendingReplayFrames: 0, + totalFramesSent: 2, + }); + await session.close(); + }); + + it("retries a delta publication after journal backpressure", async () => { + const replayStore = new FailOnceDictionaryReplayStore(); + const session = await QwpIngressSession.connect( + async () => { + throw new QwpUpgradeError("offline", { + kind: QWP_UPGRADE_ERROR_KIND.TRANSPORT, + retryable: true, + tryNextEndpoint: true, + }); + }, + { + backgroundStoreAndForward: true, + reconnect: { + maxAttempts: 0, + maxDurationMs: 0, + initialBackoffMs: 10_000, + maxBackoffMs: 10_000, + }, + replayStore, + }, + ); + + await expect( + session.publishTablesDelta([symbolTable("ETH-USD")]), + ).rejects.toThrow("journal is full"); + expect(replayStore.symbols).toEqual(["ETH-USD"]); + expect(replayStore.records.size).toBe(0); + + await expect( + session.publishTablesDelta([symbolTable("ETH-USD")]), + ).resolves.toBeUndefined(); + expect(replayStore.appendAttempts).toBe(2); + expect( + decodeQwpIngressSymbolDictionaryDelta(replayStore.records.get(1n)!), + ).toEqual({ startId: 0, entries: ["ETH-USD"] }); + await session.close(); + }); + it("replays only unacknowledged browser frames and translates wire ACKs", async () => { const first = new FakeConnection("primary"); const second = new FakeConnection("secondary"); diff --git a/test/qwp/sender.test.ts b/test/qwp/sender.test.ts index b8f8425..cf5e75d 100644 --- a/test/qwp/sender.test.ts +++ b/test/qwp/sender.test.ts @@ -96,6 +96,28 @@ class ClosingUnblocksSession extends RecordingSession { } } +class PublishingSession extends RecordingSession { + publicationAttempts = 0; + failPublication = false; + + async publishTables( + tables: readonly QwpTableBuffer[], + options?: QwpIngressEncodeOptions, + ): Promise { + this.publicationAttempts++; + this.sends.push({ tables, options }); + if (this.failPublication) throw new Error("journal is full"); + } + + publishTablesDelta( + tables: readonly QwpTableBuffer[], + options?: Pick, + ): Promise { + this.deltaSendCount++; + return this.publishTables(tables, options); + } +} + function column(table: QwpTableBuffer, name: string) { const result = table.columns.find((candidate) => candidate.name === name); if (!result) throw new Error(`missing column '${name}'`); @@ -103,6 +125,60 @@ function column(table: QwpTableBuffer, name: string) { } describe("QWP high-level sender", () => { + it("retains rows until publication-only flush succeeds", async () => { + const session = new PublishingSession(); + const sender = new QwpSender(async () => session, { + autoFlush: false, + awaitServerAck: false, + }); + await sender.table("events").longColumn("value", 42n).atNow(); + + session.failPublication = true; + await expect(sender.flush()).rejects.toThrow("journal is full"); + expect(sender.metrics).toMatchObject({ + pendingRows: 1, + totalRowsPublished: 0, + totalFlushFailures: 1, + }); + + session.failPublication = false; + await expect(sender.flush()).resolves.toBe(true); + expect(session.publicationAttempts).toBe(2); + expect(session.deltaSendCount).toBe(2); + expect(sender.metrics).toMatchObject({ + pendingRows: 0, + totalRowsPublished: 1, + totalFlushes: 2, + }); + await sender.close(); + }); + + it("publishes transactional auto-flushes without waiting for ACKs", async () => { + const session = new PublishingSession(); + const sender = new QwpSender(async () => session, { + autoFlushRows: 1, + autoFlushIntervalMs: 0, + awaitServerAck: false, + transactional: true, + }); + + await sender.table("events").longColumn("value", 1n).atNow(); + expect(session.sends[0].options).toMatchObject({ deferCommit: true }); + expect(sender.metrics).toMatchObject({ + deferredRows: 1, + pendingRows: 0, + }); + + await expect(sender.commit()).resolves.toBe(true); + expect(session.sends[1].options).toMatchObject({ deferCommit: false }); + expect(session.sends[1].tables).toEqual([]); + expect(sender.metrics).toMatchObject({ + deferredRows: 0, + totalTransactionsCommitted: 1, + }); + await sender.close(); + }); + it("closes its session before waiting for an in-flight flush", async () => { const session = new ClosingUnblocksSession(); const sender = new QwpSender(async () => session, { autoFlush: false }); From c8f9135c30b950833ee2a9347e19745de1d6203f Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 18 Aug 2026 10:35:30 +0100 Subject: [PATCH 030/265] fix(qwp): lock replay directories exclusively --- QWP.md | 8 + src/qwp-node/file-replay-store.ts | 356 +++++++++++++++++++++++++----- src/qwp/node.ts | 1 + test/qwp/public-api.test.ts | 1 + test/qwp/reconnect.test.ts | 70 +++++- 5 files changed, 377 insertions(+), 59 deletions(-) diff --git a/QWP.md b/QWP.md index 3c37f18..158b36e 100644 --- a/QWP.md +++ b/QWP.md @@ -98,6 +98,13 @@ sends it in order. Applications can therefore keep publishing during an outage u the configured `maxBytes` applies backpressure. A failed journal publication leaves the high-level rows staged so the caller can retry. +The journal takes an exclusive lock when it is loaded and holds it until the sender +or session closes. A second live process using the same directory fails with +`QwpReplayStoreLockedError` before recovery or cleanup can mutate journal contents. +Locks left by a terminated process on the same host are recovered automatically; +locks owned by a live local process, another host, or an unidentifiable owner fail +closed. + An offline sender cannot inspect the server-advertised batch cap before its first publication. Set `qwp.session.maxBatchSizeBytes` to a value no greater than the smallest target node's cap when offline startup is required. @@ -406,6 +413,7 @@ The public error classes preserve enough context for policy decisions: | `QwpReconnectExhaustedError` | The configured reconnect boundary was reached | | `QwpReplayRejectedError` | A replayed frame was rejected and retained for inspection | | `QwpReplayStoreFullError` | The Node.js replay journal reached its configured size | +| `QwpReplayStoreLockedError` | Another process owns the configured Node.js replay directory | | `QwpEgressQueryError` | QuestDB returned a terminal query error | | `QwpEgressQueryTimeoutError` | The client deadline expired and cancellation began | | `QwpEgressReplayRequiredError` | Re-execution needs an explicit reset callback | diff --git a/src/qwp-node/file-replay-store.ts b/src/qwp-node/file-replay-store.ts index 484c728..eeb568c 100644 --- a/src/qwp-node/file-replay-store.ts +++ b/src/qwp-node/file-replay-store.ts @@ -5,8 +5,11 @@ import { readdir, readFile, rename, + rm, + rmdir, unlink, } from "node:fs/promises"; +import { hostname } from "node:os"; import { join } from "node:path"; import { QWP_MAX_SYMBOL_DICTIONARY_SIZE } from "../qwp/core"; import { @@ -25,6 +28,10 @@ const DICTIONARY_MAGIC = Buffer.from("QWPD"); const DICTIONARY_FILE = "symbols.qwpdict"; const DICTIONARY_HEADER_SIZE = 8; const DICTIONARY_BLOCK_HEADER_SIZE = 44; +const LOCK_DIRECTORY = ".qwp.lock"; +const LOCK_OWNER_FILE = "owner.json"; +const LOCK_RECOVERY_FILE = "recovery.json"; +const ABANDONED_LOCK_PREFIX = ".qwp.lock.abandoned-"; const UTF8_DECODER = new TextDecoder("utf-8", { fatal: true }); interface StoredRecord { @@ -32,6 +39,14 @@ interface StoredRecord { readonly size: number; } +interface ReplayStoreLockOwner { + readonly version: 1; + readonly token: string; + readonly pid: number; + readonly hostname: string; + readonly createdAtMs: number; +} + export interface QwpNodeFileReplayStoreOptions { /** Exclusive directory used by one ingress session. */ directory: string; @@ -61,13 +76,31 @@ export class QwpReplayStoreFullError extends QwpReplayStoreError { } } +export class QwpReplayStoreLockedError extends QwpReplayStoreError { + constructor( + readonly directory: string, + readonly holderPid?: number, + readonly holderHostname?: string, + ) { + const holder = + holderPid === undefined + ? "unknown" + : `${holderPid}${holderHostname ? `@${holderHostname}` : ""}`; + super( + `QWP store-and-forward directory is already in use [directory=${directory}, holder=${holder}]`, + ); + this.name = "QwpReplayStoreLockedError"; + } +} + /** * Crash-safe Node store-and-forward journal. * * Each frame is fsynced under a temporary name before an atomic rename. An ACK * removes its covered files and fsyncs the directory. A crash between the * server ACK and local deletion can therefore cause at-least-once replay, but - * cannot silently lose an unacknowledged frame. + * cannot silently lose an unacknowledged frame. An exclusive, lifetime lock + * prevents another process from recovering or mutating the same directory. */ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { private readonly directory: string; @@ -78,6 +111,8 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { private operationTail: Promise = Promise.resolve(); private totalBytes = 0; private dictionaryFileSize = 0; + private lockOwner?: ReplayStoreLockOwner; + private closePromise?: Promise; private loaded = false; private closing = false; private closed = false; @@ -107,60 +142,67 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { ); } await mkdir(this.directory, { recursive: true }); - const entries = await readdir(this.directory, { withFileTypes: true }); - const recordNames: string[] = []; - let removedTemporaryFile = false; - for (const entry of entries) { - if (!entry.isFile()) continue; - if (entry.name.includes(TEMP_MARKER)) { - await ignoreMissing(unlink(join(this.directory, entry.name))); - removedTemporaryFile = true; - } else if (entry.name.endsWith(RECORD_SUFFIX)) { - recordNames.push(entry.name); - } - } - if (removedTemporaryFile) await syncDirectory(this.directory); - recordNames.sort(); - - const recovered: QwpIngressReplayRecord[] = []; - let previous = -1n; - for (const name of recordNames) { - const path = join(this.directory, name); - let bytes: Buffer; - try { - bytes = await readFile(path); - } catch (error) { - throw new QwpReplayStoreError( - `could not read QWP store-and-forward record [file=${name}]`, - error, - ); - } - const record = decodeRecord(bytes, name); - if (record.frameSequence <= previous) { - throw new QwpReplayStoreError( - `QWP store-and-forward sequence is not strictly increasing [file=${name}]`, - ); - } - const expectedName = recordFileName(record.frameSequence); - if (name !== expectedName) { - throw new QwpReplayStoreError( - `QWP store-and-forward filename does not match its sequence [file=${name}, expected=${expectedName}]`, - ); + let loadSucceeded = false; + try { + await this.acquireDirectoryLock(); + const entries = await readdir(this.directory, { withFileTypes: true }); + const recordNames: string[] = []; + let removedTemporaryFile = false; + for (const entry of entries) { + if (!entry.isFile()) continue; + if (entry.name.includes(TEMP_MARKER)) { + await ignoreMissing(unlink(join(this.directory, entry.name))); + removedTemporaryFile = true; + } else if (entry.name.endsWith(RECORD_SUFFIX)) { + recordNames.push(entry.name); + } } - this.totalBytes += bytes.byteLength; - if (this.totalBytes > this.maxBytes) { - throw new QwpReplayStoreFullError(this.maxBytes, this.totalBytes); + if (removedTemporaryFile) await syncDirectory(this.directory); + recordNames.sort(); + + const recovered: QwpIngressReplayRecord[] = []; + let previous = -1n; + for (const name of recordNames) { + const path = join(this.directory, name); + let bytes: Buffer; + try { + bytes = await readFile(path); + } catch (error) { + throw new QwpReplayStoreError( + `could not read QWP store-and-forward record [file=${name}]`, + error, + ); + } + const record = decodeRecord(bytes, name); + if (record.frameSequence <= previous) { + throw new QwpReplayStoreError( + `QWP store-and-forward sequence is not strictly increasing [file=${name}]`, + ); + } + const expectedName = recordFileName(record.frameSequence); + if (name !== expectedName) { + throw new QwpReplayStoreError( + `QWP store-and-forward filename does not match its sequence [file=${name}, expected=${expectedName}]`, + ); + } + this.totalBytes += bytes.byteLength; + if (this.totalBytes > this.maxBytes) { + throw new QwpReplayStoreFullError(this.maxBytes, this.totalBytes); + } + this.records.set(record.frameSequence, { + path, + size: bytes.byteLength, + }); + recovered.push(record); + previous = record.frameSequence; } - this.records.set(record.frameSequence, { - path, - size: bytes.byteLength, - }); - recovered.push(record); - previous = record.frameSequence; + await this.loadDictionaryFile(); + this.loaded = true; + loadSucceeded = true; + return recovered; + } finally { + if (!loadSucceeded) await this.releaseDirectoryLock(); } - await this.loadDictionaryFile(); - this.loaded = true; - return recovered; }); } @@ -325,11 +367,17 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { }); } - async close(): Promise { - if (this.closed) return; + close(): Promise { + if (this.closePromise) return this.closePromise; this.closing = true; - await this.operationTail; - this.closed = true; + this.closePromise = this.operationTail.then(async () => { + try { + await this.releaseDirectoryLock(); + } finally { + this.closed = true; + } + }); + return this.closePromise; } private enqueue(operation: () => Promise): Promise { @@ -345,6 +393,143 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { if (this.closed) throw this.closedError(); } + private async acquireDirectoryLock(): Promise { + const lockPath = join(this.directory, LOCK_DIRECTORY); + const ownerPath = join(lockPath, LOCK_OWNER_FILE); + const owner: ReplayStoreLockOwner = { + version: 1, + token: randomUUID(), + pid: process.pid, + hostname: hostname(), + createdAtMs: Date.now(), + }; + + for (;;) { + try { + await mkdir(lockPath, { mode: 0o700 }); + } catch (error) { + if (nodeErrorCode(error) !== "EEXIST") { + throw new QwpReplayStoreError( + `could not acquire QWP store-and-forward directory lock [directory=${this.directory}]`, + error, + ); + } + const holder = await readLockOwner(ownerPath); + if (!holder) { + throw new QwpReplayStoreLockedError(this.directory); + } + if (!isDefinitelyDeadLockOwner(holder)) { + throw new QwpReplayStoreLockedError( + this.directory, + holder.pid, + holder.hostname, + ); + } + + // Claim recovery inside the directory before renaming it. This keeps + // simultaneous starters that observed the same dead PID from both + // adopting the stale pathname. Re-read the owner after the claim so a + // process that arrived after another recovery cannot move the new lock. + const recoveryPath = join(lockPath, LOCK_RECOVERY_FILE); + try { + await writeLockOwner(recoveryPath, owner); + } catch (claimError) { + const code = nodeErrorCode(claimError); + if (code === "ENOENT") continue; + if (code === "EEXIST") { + throw new QwpReplayStoreLockedError( + this.directory, + holder.pid, + holder.hostname, + ); + } + throw new QwpReplayStoreError( + `could not claim abandoned QWP store-and-forward directory lock [directory=${this.directory}]`, + claimError, + ); + } + const claimedHolder = await readLockOwner(ownerPath); + if (!claimedHolder || claimedHolder.token !== holder.token) { + await ignoreMissing(unlink(recoveryPath)); + if (!claimedHolder) continue; + throw new QwpReplayStoreLockedError( + this.directory, + claimedHolder.pid, + claimedHolder.hostname, + ); + } + + const abandonedPath = join( + this.directory, + `${ABANDONED_LOCK_PREFIX}${randomUUID()}`, + ); + try { + await rename(lockPath, abandonedPath); + } catch (renameError) { + if (nodeErrorCode(renameError) === "ENOENT") { + await ignoreMissing(unlink(recoveryPath)); + continue; + } + await ignoreMissing(unlink(recoveryPath)); + throw new QwpReplayStoreError( + `could not recover abandoned QWP store-and-forward directory lock [directory=${this.directory}]`, + renameError, + ); + } + try { + await rm(abandonedPath, { recursive: true, force: true }); + await syncDirectory(this.directory); + } catch (cleanupError) { + throw new QwpReplayStoreError( + `could not remove abandoned QWP store-and-forward directory lock [directory=${this.directory}]`, + cleanupError, + ); + } + continue; + } + + try { + await writeLockOwner(ownerPath, owner); + await syncDirectory(lockPath); + await syncDirectory(this.directory); + this.lockOwner = owner; + return; + } catch (error) { + await rm(lockPath, { recursive: true, force: true }).catch( + () => undefined, + ); + throw new QwpReplayStoreError( + `could not initialize QWP store-and-forward directory lock [directory=${this.directory}]`, + error, + ); + } + } + } + + private async releaseDirectoryLock(): Promise { + const owner = this.lockOwner; + if (!owner) return; + const lockPath = join(this.directory, LOCK_DIRECTORY); + const ownerPath = join(lockPath, LOCK_OWNER_FILE); + const persistedOwner = await readLockOwner(ownerPath); + if (!persistedOwner || persistedOwner.token !== owner.token) { + throw new QwpReplayStoreError( + `refusing to release a QWP store-and-forward directory lock owned by another process [directory=${this.directory}]`, + ); + } + try { + await unlink(ownerPath); + await rmdir(lockPath); + await syncDirectory(this.directory); + this.lockOwner = undefined; + } catch (error) { + throw new QwpReplayStoreError( + `could not release QWP store-and-forward directory lock [directory=${this.directory}]`, + error, + ); + } + } + private async loadDictionaryFile(): Promise { const path = join(this.directory, DICTIONARY_FILE); let bytes: Buffer; @@ -633,3 +818,62 @@ function nodeErrorCode(error: unknown): string | undefined { ? String(error.code) : undefined; } + +async function readLockOwner( + ownerPath: string, +): Promise { + let parsed: unknown; + try { + parsed = JSON.parse(await readFile(ownerPath, "utf8")); + } catch (error) { + const code = nodeErrorCode(error); + if (code === "ENOENT" || error instanceof SyntaxError) return undefined; + throw new QwpReplayStoreError( + `could not read QWP store-and-forward directory lock [file=${ownerPath}]`, + error, + ); + } + if (!parsed || typeof parsed !== "object") return undefined; + const owner = parsed as Partial; + if ( + owner.version !== 1 || + typeof owner.token !== "string" || + owner.token.length === 0 || + !Number.isSafeInteger(owner.pid) || + (owner.pid ?? 0) <= 0 || + typeof owner.hostname !== "string" || + owner.hostname.length === 0 || + !Number.isSafeInteger(owner.createdAtMs) || + (owner.createdAtMs ?? 0) < 0 + ) { + return undefined; + } + return owner as ReplayStoreLockOwner; +} + +async function writeLockOwner( + path: string, + owner: ReplayStoreLockOwner, +): Promise { + const file = await open(path, "wx", 0o600); + try { + await file.writeFile(`${JSON.stringify(owner)}\n`, "utf8"); + await file.sync(); + } finally { + await file.close(); + } +} + +function isDefinitelyDeadLockOwner( + owner: ReplayStoreLockOwner, +): boolean { + if (owner.hostname !== hostname() || owner.pid === process.pid) { + return false; + } + try { + process.kill(owner.pid, 0); + return false; + } catch (error) { + return nodeErrorCode(error) === "ESRCH"; + } +} diff --git a/src/qwp/node.ts b/src/qwp/node.ts index cb07af4..bdc69f3 100644 --- a/src/qwp/node.ts +++ b/src/qwp/node.ts @@ -35,6 +35,7 @@ export { QwpNodeFileReplayStore, QwpReplayStoreError, QwpReplayStoreFullError, + QwpReplayStoreLockedError, } from "../qwp-node/file-replay-store"; export type { QwpNodeFileReplayStoreOptions } from "../qwp-node/file-replay-store"; diff --git a/test/qwp/public-api.test.ts b/test/qwp/public-api.test.ts index a1ec5f8..05bfe39 100644 --- a/test/qwp/public-api.test.ts +++ b/test/qwp/public-api.test.ts @@ -43,6 +43,7 @@ const nodeRuntimeContract = [ "QwpNodeFileReplayStore", "QwpReplayStoreError", "QwpReplayStoreFullError", + "QwpReplayStoreLockedError", "QwpVersionMismatchError", "connectQwpNodeEgress", "connectQwpNodeIngress", diff --git a/test/qwp/reconnect.test.ts b/test/qwp/reconnect.test.ts index 79e5784..6ee86b6 100644 --- a/test/qwp/reconnect.test.ts +++ b/test/qwp/reconnect.test.ts @@ -1,5 +1,5 @@ -import { mkdtemp, readdir, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; +import { mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises"; +import { hostname, tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { @@ -7,6 +7,7 @@ import { QwpNodeFileReplayStore, QwpReplayStoreError, QwpReplayStoreFullError, + QwpReplayStoreLockedError, } from "../../src/qwp/node"; import { QWP_RECONNECT_EVENT_KIND, @@ -1152,6 +1153,69 @@ describe("QWP Node file replay store", () => { await third.close(); }); + it("holds an exclusive directory lock for the store lifetime", async () => { + const directory = await trackedDirectory(); + const first = new QwpNodeFileReplayStore({ directory }); + await first.load(); + + const second = new QwpNodeFileReplayStore({ directory }); + await expect(second.load()).rejects.toMatchObject({ + name: "QwpReplayStoreLockedError", + directory, + holderPid: process.pid, + holderHostname: hostname(), + } satisfies Partial); + + await first.append({ frameSequence: 0n, payload: Uint8Array.of(7) }); + await first.close(); + await expect(second.load()).resolves.toEqual([ + { frameSequence: 0n, payload: Uint8Array.of(7) }, + ]); + await second.close(); + }); + + it("recovers a lock left by a terminated local process", async () => { + const directory = await trackedDirectory(); + const lockDirectory = join(directory, ".qwp.lock"); + await mkdir(lockDirectory); + await writeFile( + join(lockDirectory, "owner.json"), + JSON.stringify({ + version: 1, + token: "abandoned", + pid: 2_147_483_647, + hostname: hostname(), + createdAtMs: 0, + }), + ); + + const stores = [ + new QwpNodeFileReplayStore({ directory }), + new QwpNodeFileReplayStore({ directory }), + ]; + const outcomes = await Promise.allSettled( + stores.map((store) => store.load()), + ); + const winner = outcomes.findIndex( + (outcome) => outcome.status === "fulfilled", + ); + const loser = winner === 0 ? 1 : 0; + expect(winner).not.toBe(-1); + expect(outcomes[loser]).toMatchObject({ + status: "rejected", + reason: { name: "QwpReplayStoreLockedError" }, + }); + expect( + (await readdir(directory)).filter((name) => + name.startsWith(".qwp.lock.abandoned-"), + ), + ).toEqual([]); + await stores[winner].close(); + await expect(stores[loser].load()).resolves.toEqual([]); + await stores[loser].close(); + expect(await readdir(directory)).toEqual([]); + }); + it("recovers a persisted dictionary and truncates a torn append tail", async () => { const directory = await trackedDirectory(); const first = new QwpNodeFileReplayStore({ directory }); @@ -1193,7 +1257,7 @@ describe("QWP Node file replay store", () => { await expect( store.append({ frameSequence: 0n, payload: Uint8Array.of(1, 2, 3) }), ).rejects.toBeInstanceOf(QwpReplayStoreFullError); - expect(await readdir(directory)).toEqual([]); + expect(await readdir(directory)).toEqual([".qwp.lock"]); await store.close(); }); From eff6f148b6442bcce0f6401f370d432873c4a8ac Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 18 Aug 2026 10:44:26 +0100 Subject: [PATCH 031/265] fix(qwp): bound egress cancellation draining --- QWP.md | 42 +++++--- README.md | 5 +- src/qwp/egress-session.ts | 185 ++++++++++++++++++++++++++++++-- src/qwp/internal/async-queue.ts | 9 +- test/qwp/egress.test.ts | 96 +++++++++++++++++ test/qwp/public-api-contract.ts | 6 ++ test/qwp/public-api.test.ts | 2 + 7 files changed, 316 insertions(+), 29 deletions(-) diff --git a/QWP.md b/QWP.md index 158b36e..3bc142e 100644 --- a/QWP.md +++ b/QWP.md @@ -367,8 +367,16 @@ a slow consumer limits server read-ahead. Set `autoCredit: false` and call A session `queryTimeoutMs` supplies the default deadline; per-query `timeoutMs` overrides it, and zero disables it. Expiry rejects iteration and `completion` with `QwpEgressQueryTimeoutError`, sends QWP `CANCEL`, and drains the terminal response -before the connection accepts another query. Call `query.cancel()` for explicit -cancellation. +before the connection accepts another query. Breaking out of `for await` early also +discards buffered batches, restores their flow-control credit, sends `CANCEL`, and +rejects `completion` with `QwpEgressQueryAbandonedError`. Call `query.cancel()` for +explicit cancellation. + +Cancellation draining is bounded by `cancelDrainTimeoutMs` (5 seconds by default). +Late batches are decoded and credited while the terminal response is pending. If the +server does not terminate the query within the bound, the client fails with +`QwpEgressQueryCancelTimeoutError` and closes the unusable connection instead of +leaving the session permanently occupied. Node.js can request Zstd with `compression: "zstd"` or `"auto"` and a level from 1 through 22. Raw remains the compatibility default. Check @@ -403,20 +411,22 @@ const session = await connectQwpBrowserEgress({ The public error classes preserve enough context for policy decisions: -| Error | Meaning | -| ------------------------------- | ----------------------------------------------------------------------------------------------------------- | -| `QwpUpgradeError` | Classified authentication, role, version, capability, timeout, transport, or browser-opaque upgrade failure | -| `QwpDurableAckUnavailableError` | Durable acknowledgement was required but not negotiated | -| `QwpSendTimeoutError` | A send did not drain before its deadline; delivery is unknown | -| `QwpIngressNackError` | QuestDB rejected an ingress frame | -| `QwpBatchTooLargeError` | One encoded row cannot fit the effective ingress cap | -| `QwpReconnectExhaustedError` | The configured reconnect boundary was reached | -| `QwpReplayRejectedError` | A replayed frame was rejected and retained for inspection | -| `QwpReplayStoreFullError` | The Node.js replay journal reached its configured size | -| `QwpReplayStoreLockedError` | Another process owns the configured Node.js replay directory | -| `QwpEgressQueryError` | QuestDB returned a terminal query error | -| `QwpEgressQueryTimeoutError` | The client deadline expired and cancellation began | -| `QwpEgressReplayRequiredError` | Re-execution needs an explicit reset callback | +| Error | Meaning | +| ---------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| `QwpUpgradeError` | Classified authentication, role, version, capability, timeout, transport, or browser-opaque upgrade failure | +| `QwpDurableAckUnavailableError` | Durable acknowledgement was required but not negotiated | +| `QwpSendTimeoutError` | A send did not drain before its deadline; delivery is unknown | +| `QwpIngressNackError` | QuestDB rejected an ingress frame | +| `QwpBatchTooLargeError` | One encoded row cannot fit the effective ingress cap | +| `QwpReconnectExhaustedError` | The configured reconnect boundary was reached | +| `QwpReplayRejectedError` | A replayed frame was rejected and retained for inspection | +| `QwpReplayStoreFullError` | The Node.js replay journal reached its configured size | +| `QwpReplayStoreLockedError` | Another process owns the configured Node.js replay directory | +| `QwpEgressQueryError` | QuestDB returned a terminal query error | +| `QwpEgressQueryAbandonedError` | Result iteration ended before the server completed the query | +| `QwpEgressQueryTimeoutError` | The client deadline expired and cancellation began | +| `QwpEgressQueryCancelTimeoutError` | A cancelled query did not produce a terminal server response before the drain deadline | +| `QwpEgressReplayRequiredError` | Re-execution needs an explicit reset callback | Always close senders and sessions in `finally`. Closing is idempotent and bounded by `closeTimeoutMs`. `connectTimeoutMs`, `sendTimeoutMs`, acknowledgement timeouts, and diff --git a/README.md b/README.md index 03cfecb..7cfbd59 100644 --- a/README.md +++ b/README.md @@ -302,7 +302,10 @@ credit explicitly through `query.grantCredit()`. `timeoutMs` overrides it, and zero disables the deadline. When a deadline expires, the client rejects iteration and `query.completion` with `QwpEgressQueryTimeoutError`, sends QWP `CANCEL`, and waits for the terminal -server response before accepting another query on that connection. +server response before accepting another query on that connection. Breaking out +of `for await` early cancels the query too. `cancelDrainTimeoutMs` bounds that +wait (5 seconds by default); an unresponsive cancellation closes the connection +with `QwpEgressQueryCancelTimeoutError` instead of wedging the session. ### Authentication and secure connection diff --git a/src/qwp/egress-session.ts b/src/qwp/egress-session.ts index d6c4cb6..8cda874 100644 --- a/src/qwp/egress-session.ts +++ b/src/qwp/egress-session.ts @@ -31,6 +31,8 @@ export interface QwpEgressSessionOptions { serverInfoTimeoutMs?: number; /** Default per-query deadline. Zero or undefined disables query deadlines. */ queryTimeoutMs?: number; + /** Maximum wait for a terminal response after CANCEL. Defaults to 5 seconds. */ + cancelDrainTimeoutMs?: number; /** Enables bounded reconnects. Active operations replay only with onReplayReset. */ reconnect?: QwpReconnectOptions; /** @@ -64,6 +66,7 @@ export interface QwpEgressQueryOptions { interface QwpValidatedEgressSessionOptions { readonly serverInfoTimeoutMs: number; readonly queryTimeoutMs: number; + readonly cancelDrainTimeoutMs: number; } function validateOptionalTimeout( @@ -92,9 +95,20 @@ function validateEgressSessionOptions( options.queryTimeoutMs, "queryTimeoutMs", ), + cancelDrainTimeoutMs: validatePositiveTimeout( + options.cancelDrainTimeoutMs ?? 5_000, + "cancelDrainTimeoutMs", + ), }; } +function validatePositiveTimeout(value: number, name: string): number { + if (!Number.isFinite(value) || value <= 0) { + throw new RangeError(`${name} must be a positive finite number`); + } + return value; +} + export type QwpQueryCompletion = QwpResultEndMessage | QwpExecDoneMessage; export class QwpEgressQueryError extends Error { @@ -119,6 +133,27 @@ export class QwpEgressQueryTimeoutError extends Error { } } +/** Result iteration ended before the server completed the query. */ +export class QwpEgressQueryAbandonedError extends Error { + constructor(readonly requestId: bigint) { + super(`QWP query result was abandoned [requestId=${requestId}]`); + this.name = "QwpEgressQueryAbandonedError"; + } +} + +/** The server did not terminate a cancelled query within the drain deadline. */ +export class QwpEgressQueryCancelTimeoutError extends Error { + constructor( + readonly requestId: bigint, + readonly timeoutMs: number, + ) { + super( + `QWP cancelled query did not terminate after ${timeoutMs}ms [requestId=${requestId}]`, + ); + this.name = "QwpEgressQueryCancelTimeoutError"; + } +} + export class QwpEgressSessionClosedError extends Error { constructor(readonly closeInfo?: QwpConnectionCloseInfo) { super( @@ -132,6 +167,7 @@ export class QwpEgressSessionClosedError extends Error { interface QwpEgressQueryControl { cancel(requestId: bigint): Promise; + abandon(requestId: bigint): Promise; grantCredit( requestId: bigint, additionalBytes: number | bigint, @@ -157,6 +193,7 @@ export class QwpEgressQuery implements AsyncIterable { constructor( readonly requestId: bigint, private readonly control: QwpEgressQueryControl, + private readonly creditEnabled: boolean, private readonly autoCredit: boolean, ) { let resolve!: (value: QwpQueryCompletion) => void; @@ -182,6 +219,11 @@ export class QwpEgressQuery implements AsyncIterable { this.deliveredCreditBytes = result.value.creditBytes; return { value: result.value.batch, done: false }; }, + return: async () => { + if (this.terminal) this.discardBufferedResults(); + else await this.control.abandon(this.requestId); + return { value: undefined, done: true }; + }, }; } @@ -228,11 +270,22 @@ export class QwpEgressQuery implements AsyncIterable { this.rejectCompletion(error); } - /** @internal Discards queued results and surfaces a deadline immediately. */ - expire(error: QwpEgressQueryTimeoutError): void { - if (this.terminal) return; - this.batches.clear(); + /** @internal Discards queued results and retires the consumer immediately. */ + retire(error: Error): number { + if (this.terminal) return 0; + const discardedCredit = this.discardBufferedResults(); this.fail(error); + return discardedCredit; + } + + /** @internal Whether the consumer has retired while the wire still drains. */ + get retired(): boolean { + return this.terminal; + } + + /** @internal Credit needed to discard a late batch while cancellation drains. */ + lateBatchCredit(creditBytes: number): number { + return this.creditEnabled ? creditBytes : 0; } /** @internal */ @@ -247,6 +300,15 @@ export class QwpEgressQuery implements AsyncIterable { this.timeoutTimer = undefined; } + private discardBufferedResults(): number { + let creditBytes = this.deliveredCreditBytes; + this.deliveredCreditBytes = 0; + for (const queued of this.batches.clear()) { + creditBytes += queued.creditBytes; + } + return this.creditEnabled ? creditBytes : 0; + } + private async releaseDeliveredCredit(): Promise { const creditBytes = this.deliveredCreditBytes; this.deliveredCreditBytes = 0; @@ -275,6 +337,7 @@ export class QwpEgressSession implements QwpEgressQueryControl { private readonly rejectServerInfo: (error: unknown) => void; private readonly serverInfoTimer: ReturnType; private readonly defaultQueryTimeoutMs: number; + private readonly cancelDrainTimeoutMs: number; private active?: QwpEgressQuery; private nextRequestId = 0n; private sendTail: Promise = Promise.resolve(); @@ -282,6 +345,8 @@ export class QwpEgressSession implements QwpEgressQueryControl { private failure?: Error; private closing = false; private closePromise?: Promise; + private cancelDrainRequestId?: bigint; + private cancelDrainTimer?: ReturnType; readonly ready: Promise; constructor( @@ -310,6 +375,7 @@ export class QwpEgressSession implements QwpEgressQueryControl { throw error; } this.defaultQueryTimeoutMs = validated.queryTimeoutMs; + this.cancelDrainTimeoutMs = validated.cancelDrainTimeoutMs; let resolve!: (value: QwpServerInfoMessage) => void; let reject!: (error: unknown) => void; this.ready = new Promise((res, rej) => { @@ -422,6 +488,7 @@ export class QwpEgressSession implements QwpEgressQueryControl { const query = new QwpEgressQuery( requestId, this, + creditEnabled, creditEnabled && (options.autoCredit ?? true), ); this.decoder.resetQuerySchema(); @@ -450,7 +517,15 @@ export class QwpEgressSession implements QwpEgressQueryControl { cancel(requestId: bigint): Promise { this.requireActive(requestId); - return this.send(encodeQwpCancel(requestId)); + return this.cancelAndDrain(requestId, 0); + } + + abandon(requestId: bigint): Promise { + const query = this.requireActive(requestId); + const discardedCredit = query.retire( + new QwpEgressQueryAbandonedError(requestId), + ); + return this.cancelAndDrain(requestId, discardedCredit); } grantCredit( @@ -458,14 +533,21 @@ export class QwpEgressSession implements QwpEgressQueryControl { additionalBytes: number | bigint, ): Promise { this.requireActive(requestId); - return this.send(encodeQwpCredit(requestId, additionalBytes)); + return this.sendWhileActive( + requestId, + encodeQwpCredit(requestId, additionalBytes), + ); } expire(requestId: bigint, timeoutMs: number): void { if (!this.active || this.active.requestId !== requestId) return; - this.active.expire(new QwpEgressQueryTimeoutError(requestId, timeoutMs)); + const discardedCredit = this.active.retire( + new QwpEgressQueryTimeoutError(requestId, timeoutMs), + ); try { - void this.send(encodeQwpCancel(requestId)).catch(() => undefined); + void this.cancelAndDrain(requestId, discardedCredit).catch( + () => undefined, + ); } catch (error) { this.fail(error); } @@ -479,6 +561,7 @@ export class QwpEgressSession implements QwpEgressQueryControl { private async closeNow(code: number, reason: string): Promise { this.closing = true; clearTimeout(this.serverInfoTimer); + this.clearCancelDrain(); const error = new QwpEgressSessionClosedError(); this.rejectServerInfo(error); this.active?.fail(error); @@ -515,24 +598,38 @@ export class QwpEgressSession implements QwpEgressQueryControl { break; case "result-batch": { const query = this.requireActive(message.requestId); - query.push(this.decoder.decode(message), payload.byteLength); + const batch = this.decoder.decode(message); + if (query.retired) { + const creditBytes = query.lateBatchCredit(payload.byteLength); + if (creditBytes > 0) { + void this.sendWhileActive( + message.requestId, + encodeQwpCredit(message.requestId, creditBytes), + ).catch(() => undefined); + } + } else { + query.push(batch, payload.byteLength); + } break; } case "result-end": { const query = this.requireActive(message.requestId); this.active = undefined; + this.clearCancelDrain(message.requestId); query.finish(message); break; } case "exec-done": { const query = this.requireActive(message.requestId); this.active = undefined; + this.clearCancelDrain(message.requestId); query.finish(message); break; } case "query-error": { const query = this.requireActive(message.requestId); this.active = undefined; + this.clearCancelDrain(message.requestId); query.fail( new QwpEgressQueryError( message.requestId, @@ -573,6 +670,61 @@ export class QwpEgressSession implements QwpEgressQueryControl { this.active?.resetForReplay(); } + private cancelAndDrain( + requestId: bigint, + discardedCredit: number, + ): Promise { + this.armCancelDrain(requestId); + const cancelling = this.sendWhileActive( + requestId, + encodeQwpCancel(requestId), + ); + if (discardedCredit === 0) return cancelling; + return cancelling.then(() => + this.sendWhileActive( + requestId, + encodeQwpCredit(requestId, discardedCredit), + ), + ); + } + + private armCancelDrain(requestId: bigint): void { + if (this.cancelDrainRequestId === requestId && this.cancelDrainTimer) + return; + this.clearCancelDrain(); + this.cancelDrainRequestId = requestId; + this.cancelDrainTimer = setTimeout(() => { + this.cancelDrainTimer = undefined; + this.cancelDrainRequestId = undefined; + if (!this.active || this.active.requestId !== requestId) return; + const error = new QwpEgressQueryCancelTimeoutError( + requestId, + this.cancelDrainTimeoutMs, + ); + this.fail(error); + try { + void this.connection + .close(1011, "QWP cancellation drain timed out") + .catch(() => undefined); + } catch { + // The typed cancellation failure remains the session's terminal error. + } + }, this.cancelDrainTimeoutMs); + } + + private clearCancelDrain(requestId?: bigint): void { + if ( + requestId !== undefined && + this.cancelDrainRequestId !== undefined && + requestId !== this.cancelDrainRequestId + ) { + return; + } + if (this.cancelDrainTimer) clearTimeout(this.cancelDrainTimer); + this.cancelDrainTimer = undefined; + this.cancelDrainRequestId = undefined; + } + private send(payload: Uint8Array): Promise { this.throwIfUnavailable(); const sending = this.sendTail.then(async () => { @@ -583,6 +735,20 @@ export class QwpEgressSession implements QwpEgressQueryControl { return sending; } + private sendWhileActive( + requestId: bigint, + payload: Uint8Array, + ): Promise { + this.throwIfUnavailable(); + const sending = this.sendTail.then(async () => { + this.throwIfUnavailable(); + if (!this.active || this.active.requestId !== requestId) return; + await this.connection.send(payload); + }); + this.sendTail = sending.catch((error: unknown) => this.fail(error)); + return sending; + } + private throwIfUnavailable(): void { if (this.failure) throw this.failure; if (this.closing) throw new QwpEgressSessionClosedError(); @@ -591,6 +757,7 @@ export class QwpEgressSession implements QwpEgressQueryControl { private fail(error: unknown): void { if (this.failure) return; clearTimeout(this.serverInfoTimer); + this.clearCancelDrain(); this.failure = error instanceof Error ? error : new Error(`QWP egress failed: ${error}`); this.rejectServerInfo(this.failure); diff --git a/src/qwp/internal/async-queue.ts b/src/qwp/internal/async-queue.ts index 2f07c6d..42a4a28 100644 --- a/src/qwp/internal/async-queue.ts +++ b/src/qwp/internal/async-queue.ts @@ -48,11 +48,14 @@ export class QwpAsyncQueue implements AsyncIterable { for (const pending of this.pending.splice(0)) pending.reject(error); } - /** Drops values not yet handed to the single consumer. */ - clear(): void { + /** Drops and returns values not yet handed to the single consumer. */ + clear(): T[] { + const dropped: T[] = []; for (const entry of this.values.splice(0)) { - if (entry.kind === "barrier") entry.resolve(); + if (entry.kind === "value") dropped.push(entry.value); + else entry.resolve(); } + return dropped; } /** Resolves once the consumer asks for the item after this queue position. */ diff --git a/test/qwp/egress.test.ts b/test/qwp/egress.test.ts index bb67a31..6a6c222 100644 --- a/test/qwp/egress.test.ts +++ b/test/qwp/egress.test.ts @@ -15,6 +15,8 @@ import { QwpByteReader, QwpByteWriter, QwpConnectionCloseInfo, + QwpEgressQueryAbandonedError, + QwpEgressQueryCancelTimeoutError, QwpEgressQueryError, QwpEgressQueryTimeoutError, QwpEgressSession, @@ -411,6 +413,17 @@ describe("QwpEgressSession", () => { ), ).rejects.toThrow("queryTimeoutMs must be a non-negative finite number"); expect(factoryCalls).toBe(0); + + await expect( + QwpEgressSession.connect( + async () => { + factoryCalls++; + return new FakeConnection(); + }, + { cancelDrainTimeoutMs: 0 }, + ), + ).rejects.toThrow("cancelDrainTimeoutMs must be a positive finite number"); + expect(factoryCalls).toBe(0); }); it("closes the transport when SERVER_INFO does not arrive", async () => { @@ -563,6 +576,50 @@ describe("QwpEgressSession", () => { await session.close(); }); + it("cancels and retires a query when result iteration is abandoned", async () => { + const connection = new FakeConnection(); + const session = new QwpEgressSession(connection); + connection.receive(serverInfo()); + const query = await session.query("select * from x", { + initialCredit: 64, + }); + const resultFrame = firstResultBatch(query.requestId); + connection.receive(resultFrame); + + let batches = 0; + for await (const _batch of query) { + batches++; + break; + } + + expect(batches).toBe(1); + await expect(query.completion).rejects.toMatchObject({ + name: "QwpEgressQueryAbandonedError", + requestId: query.requestId, + } satisfies Partial); + expect(connection.sent).toHaveLength(3); + const cancel = new QwpByteReader(connection.sent[1]); + expect(cancel.readUint8()).toBe(QWP_EGRESS_MESSAGE.CANCEL); + expect(cancel.readBigUint64()).toBe(query.requestId); + const credit = new QwpByteReader(connection.sent[2]); + expect(credit.readUint8()).toBe(QWP_EGRESS_MESSAGE.CREDIT); + expect(credit.readBigUint64()).toBe(query.requestId); + expect(readQwpVarint(credit)).toBe(BigInt(resultFrame.byteLength)); + + await expect(session.query("select 2")).rejects.toThrow( + "a QWP query is already active", + ); + connection.receive( + queryError(query.requestId, "cancelled by client", QWP_STATUS.CANCELLED), + ); + await Promise.resolve(); + await Promise.resolve(); + const nextQuery = await session.query("select 2"); + connection.receive(resultEnd(nextQuery.requestId, 0n)); + await nextQuery.completion; + await session.close(); + }); + it("times out a query, sends CANCEL, and drains the terminal response", async () => { vi.useFakeTimers(); try { @@ -600,6 +657,13 @@ describe("QwpEgressSession", () => { await expect(session.query("select 2")).rejects.toThrow( "a QWP query is already active", ); + const lateBatch = firstResultBatch(query.requestId); + connection.receive(lateBatch); + await vi.waitFor(() => expect(connection.sent).toHaveLength(3)); + const drainCredit = new QwpByteReader(connection.sent[2]); + expect(drainCredit.readUint8()).toBe(QWP_EGRESS_MESSAGE.CREDIT); + expect(drainCredit.readBigUint64()).toBe(query.requestId); + expect(readQwpVarint(drainCredit)).toBe(BigInt(lateBatch.byteLength)); connection.receive( queryError( query.requestId, @@ -620,6 +684,38 @@ describe("QwpEgressSession", () => { } }); + it("fails and closes a session when cancellation never terminates", async () => { + vi.useFakeTimers(); + try { + const connection = new FakeConnection(); + const session = new QwpEgressSession(connection, { + queryTimeoutMs: 25, + cancelDrainTimeoutMs: 50, + }); + connection.receive(serverInfo()); + const query = await session.query("select * from long_sequence(1000000)"); + + await vi.advanceTimersByTimeAsync(25); + await expect(query.completion).rejects.toBeInstanceOf( + QwpEgressQueryTimeoutError, + ); + await vi.advanceTimersByTimeAsync(50); + + expect(connection.closeCalls).toEqual([ + { code: 1011, reason: "QWP cancellation drain timed out" }, + ]); + await expect(session.query("select 2")).rejects.toMatchObject({ + name: "QwpEgressQueryCancelTimeoutError", + requestId: query.requestId, + timeoutMs: 50, + } satisfies Partial); + expect(vi.getTimerCount()).toBe(0); + await session.close(); + } finally { + vi.useRealTimers(); + } + }); + it("clears a query deadline when the query completes", async () => { vi.useFakeTimers(); try { diff --git a/test/qwp/public-api-contract.ts b/test/qwp/public-api-contract.ts index ddb902d..5f1e171 100644 --- a/test/qwp/public-api-contract.ts +++ b/test/qwp/public-api-contract.ts @@ -84,6 +84,11 @@ const queryOptionsContract: QwpEgressQueryOptions = { binds: (binds) => binds.setVarchar(0, "ETH-USD"), }; +const egressSessionOptionsContract: QwpEgressSessionOptions = { + queryTimeoutMs: 30_000, + cancelDrainTimeoutMs: 5_000, +}; + const qwpExtraOptionsContract: QwpExtraOptions = { webSocket: { requestDurableAck: true, @@ -111,5 +116,6 @@ void nodeIngressSignature; void nodeEgressSignature; void nodeWebSocketSignature; void queryOptionsContract; +void egressSessionOptionsContract; void rootExtraOptionsContract; void Sender; diff --git a/test/qwp/public-api.test.ts b/test/qwp/public-api.test.ts index 05bfe39..ad9a03d 100644 --- a/test/qwp/public-api.test.ts +++ b/test/qwp/public-api.test.ts @@ -13,6 +13,8 @@ const sharedRuntimeContract = [ "QwpBindValues", "QwpDurableAckUnavailableError", "QwpEgressQuery", + "QwpEgressQueryAbandonedError", + "QwpEgressQueryCancelTimeoutError", "QwpEgressQueryError", "QwpEgressQueryTimeoutError", "QwpEgressReplayRequiredError", From 69f5eaf7c1541e6a20306aa5ddadd86ea60c2073 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 18 Aug 2026 10:48:30 +0100 Subject: [PATCH 032/265] fix(qwp): bound egress buffering by default --- QWP.md | 10 +++--- README.md | 11 ++++--- src/qwp/egress-session.ts | 37 +++++++++++++++++++-- test/qwp/browser.e2e.ts | 11 +++++++ test/qwp/egress.test.ts | 58 +++++++++++++++++++++++++++++++++ test/qwp/public-api-contract.ts | 1 + test/qwp/public-api.test.ts | 1 + 7 files changed, 118 insertions(+), 11 deletions(-) diff --git a/QWP.md b/QWP.md index 3bc142e..78ef056 100644 --- a/QWP.md +++ b/QWP.md @@ -359,10 +359,12 @@ microsecond and nanosecond timestamps, strings, UUIDs, LONG256, geohashes, decimals, and typed nulls. Set values in ascending index order. `bindPayload` and `bindCount` remain advanced escape hatches for pre-encoded data. -Positive `initialCredit` enables byte-based flow control. By default, the client -replenishes the exact wire size of each batch when iteration advances beyond it, so -a slow consumer limits server read-ahead. Set `autoCredit: false` and call -`query.grantCredit()` for manual control. +The high-level client defaults `initialCredit` to 256 KiB, bounding unread wire data +to roughly that window plus at most one server batch. The exact wire size of each +batch is replenished when iteration advances beyond it, so a slow consumer limits +server read-ahead in Node.js and browsers. Set a session-level `initialCredit` to tune +the default, override it per query, or explicitly set zero for legacy unbounded +streaming. Set `autoCredit: false` and call `query.grantCredit()` for manual control. A session `queryTimeoutMs` supplies the default deadline; per-query `timeoutMs` overrides it, and zero disables it. Expiry rejects iteration and `completion` with diff --git a/README.md b/README.md index 7cfbd59..1f76307 100644 --- a/README.md +++ b/README.md @@ -292,11 +292,12 @@ Both `"zstd"` and `"auto"` advertise Zstd followed by raw fallback, and the server still sends an individual batch raw when compression would make it larger. -A positive `initialCredit` enables byte-based egress flow control. The client -automatically replenishes the exact wire size of each result batch after the -async iterator advances past it, so a slow Node.js or browser consumer naturally -limits how far the server can stream ahead. Set `autoCredit: false` to manage -credit explicitly through `query.grantCredit()`. +Egress queries use a bounded 256 KiB `initialCredit` window by default. The client +automatically replenishes the exact wire size of each result batch after the async +iterator advances past it, so a slow Node.js or browser consumer limits how far the +server can stream ahead. Tune `initialCredit` on the session or individual query; +set it to zero only to opt into legacy unbounded streaming. Set `autoCredit: false` +to manage credit explicitly through `query.grantCredit()`. `queryTimeoutMs` sets the session's default query deadline; a per-query `timeoutMs` overrides it, and zero disables the deadline. When a deadline diff --git a/src/qwp/egress-session.ts b/src/qwp/egress-session.ts index 8cda874..70c6abf 100644 --- a/src/qwp/egress-session.ts +++ b/src/qwp/egress-session.ts @@ -29,6 +29,8 @@ import { export interface QwpEgressSessionOptions { serverInfoTimeoutMs?: number; + /** Default per-query send-ahead credit. Defaults to 256 KiB; zero is unbounded. */ + initialCredit?: number | bigint; /** Default per-query deadline. Zero or undefined disables query deadlines. */ queryTimeoutMs?: number; /** Maximum wait for a terminal response after CANCEL. Defaults to 5 seconds. */ @@ -44,7 +46,7 @@ export interface QwpEgressSessionOptions { } export interface QwpEgressQueryOptions { - /** Zero means the server may stream without credit accounting. */ + /** Overrides session send-ahead credit. Zero explicitly disables flow control. */ initialCredit?: number | bigint; /** * Replenishes positive initial credit by each RESULT_BATCH wire size after @@ -65,10 +67,16 @@ export interface QwpEgressQueryOptions { interface QwpValidatedEgressSessionOptions { readonly serverInfoTimeoutMs: number; + readonly initialCredit: number | bigint; readonly queryTimeoutMs: number; readonly cancelDrainTimeoutMs: number; } +/** Default bounded send-ahead window used by high-level egress queries. */ +export const QWP_DEFAULT_EGRESS_INITIAL_CREDIT = 256 * 1024; + +const MAX_UINT64 = 0xffffffffffffffffn; + function validateOptionalTimeout( value: number | undefined, name: string, @@ -91,6 +99,10 @@ function validateEgressSessionOptions( } return { serverInfoTimeoutMs, + initialCredit: validateInitialCredit( + options.initialCredit ?? QWP_DEFAULT_EGRESS_INITIAL_CREDIT, + "initialCredit", + ), queryTimeoutMs: validateOptionalTimeout( options.queryTimeoutMs, "queryTimeoutMs", @@ -102,6 +114,22 @@ function validateEgressSessionOptions( }; } +function validateInitialCredit( + value: number | bigint, + name: string, +): number | bigint { + if (typeof value === "number") { + if (!Number.isSafeInteger(value) || value < 0) { + throw new RangeError(`${name} must be a non-negative safe integer`); + } + return value; + } + if (typeof value !== "bigint" || value < 0n || value > MAX_UINT64) { + throw new RangeError(`${name} must fit in uint64`); + } + return value; +} + function validatePositiveTimeout(value: number, name: string): number { if (!Number.isFinite(value) || value <= 0) { throw new RangeError(`${name} must be a positive finite number`); @@ -337,6 +365,7 @@ export class QwpEgressSession implements QwpEgressQueryControl { private readonly rejectServerInfo: (error: unknown) => void; private readonly serverInfoTimer: ReturnType; private readonly defaultQueryTimeoutMs: number; + private readonly defaultInitialCredit: number | bigint; private readonly cancelDrainTimeoutMs: number; private active?: QwpEgressQuery; private nextRequestId = 0n; @@ -375,6 +404,7 @@ export class QwpEgressSession implements QwpEgressQueryControl { throw error; } this.defaultQueryTimeoutMs = validated.queryTimeoutMs; + this.defaultInitialCredit = validated.initialCredit; this.cancelDrainTimeoutMs = validated.cancelDrainTimeoutMs; let resolve!: (value: QwpServerInfoMessage) => void; let reject!: (error: unknown) => void; @@ -461,6 +491,10 @@ export class QwpEgressSession implements QwpEgressQueryControl { options.timeoutMs ?? this.defaultQueryTimeoutMs, "timeoutMs", ); + const initialCredit = validateInitialCredit( + options.initialCredit ?? this.defaultInitialCredit, + "initialCredit", + ); if ( options.autoCredit !== undefined && typeof options.autoCredit !== "boolean" @@ -480,7 +514,6 @@ export class QwpEgressSession implements QwpEgressQueryControl { } const requestId = this.nextRequestId++; - const initialCredit = options.initialCredit ?? 0; const creditEnabled = typeof initialCredit === "bigint" ? initialCredit > 0n diff --git a/test/qwp/browser.e2e.ts b/test/qwp/browser.e2e.ts index 31c64b9..fc2d7d4 100644 --- a/test/qwp/browser.e2e.ts +++ b/test/qwp/browser.e2e.ts @@ -12,6 +12,7 @@ import { encodeQwpFrame, QWP_COLUMN_TYPE, QWP_DURABLE_ACK_WEBSOCKET_PROTOCOL, + QWP_DEFAULT_EGRESS_INITIAL_CREDIT, QWP_EGRESS_MESSAGE, QWP_STATUS, QwpByteReader, @@ -355,6 +356,16 @@ describe("QWP in a real browser", () => { expect(credit.readUint8()).toBe(QWP_EGRESS_MESSAGE.CREDIT); expect(credit.readBigUint64()).toBe(0n); expect(readQwpVarint(credit)).toBe(BigInt(resultBatch.byteLength)); + const defaultCreditRequest = new QwpByteReader(received[2]); + expect(defaultCreditRequest.readUint8()).toBe( + QWP_EGRESS_MESSAGE.QUERY_REQUEST, + ); + expect(defaultCreditRequest.readBigUint64()).toBe(1n); + const sqlLength = Number(readQwpVarint(defaultCreditRequest)); + defaultCreditRequest.readBytes(sqlLength); + expect(readQwpVarint(defaultCreditRequest)).toBe( + BigInt(QWP_DEFAULT_EGRESS_INITIAL_CREDIT), + ); } finally { await page.close(); await closeWebSocketServer(server); diff --git a/test/qwp/egress.test.ts b/test/qwp/egress.test.ts index 6a6c222..a01564b 100644 --- a/test/qwp/egress.test.ts +++ b/test/qwp/egress.test.ts @@ -9,6 +9,7 @@ import { QWP_FLAG_DELTA_SYMBOL_DICTIONARY, QWP_FLAG_GORILLA, QWP_FLAG_ZSTD, + QWP_DEFAULT_EGRESS_INITIAL_CREDIT, QWP_MAX_ZSTD_DECOMPRESSED_SIZE, QWP_STATUS, QwpBinaryConnection, @@ -403,6 +404,17 @@ describe("QwpEgressSession", () => { ).rejects.toThrow("serverInfoTimeoutMs must be a positive finite number"); expect(factoryCalls).toBe(0); + await expect( + QwpEgressSession.connect( + async () => { + factoryCalls++; + return new FakeConnection(); + }, + { initialCredit: -1 }, + ), + ).rejects.toThrow("initialCredit must be a non-negative safe integer"); + expect(factoryCalls).toBe(0); + await expect( QwpEgressSession.connect( async () => { @@ -529,6 +541,52 @@ describe("QwpEgressSession", () => { await session.close(); }); + it("uses bounded credit by default and allows a session-level override", async () => { + const connection = new FakeConnection(); + const session = new QwpEgressSession(connection); + connection.receive(serverInfo()); + const query = await session.query("select * from x"); + const request = new QwpByteReader(connection.sent[0]); + expect(request.readUint8()).toBe(QWP_EGRESS_MESSAGE.QUERY_REQUEST); + expect(request.readBigUint64()).toBe(query.requestId); + const sqlLength = Number(readQwpVarint(request)); + request.readBytes(sqlLength); + expect(readQwpVarint(request)).toBe( + BigInt(QWP_DEFAULT_EGRESS_INITIAL_CREDIT), + ); + + const resultFrame = firstResultBatch(query.requestId); + connection.receive(resultFrame); + const iterator = query[Symbol.asyncIterator](); + await iterator.next(); + const next = iterator.next(); + await vi.waitFor(() => expect(connection.sent).toHaveLength(2)); + const credit = new QwpByteReader(connection.sent[1]); + expect(credit.readUint8()).toBe(QWP_EGRESS_MESSAGE.CREDIT); + expect(credit.readBigUint64()).toBe(query.requestId); + expect(readQwpVarint(credit)).toBe(BigInt(resultFrame.byteLength)); + connection.receive(resultEnd(query.requestId)); + await next; + await query.completion; + await session.close(); + + const unboundedConnection = new FakeConnection(); + const unbounded = new QwpEgressSession(unboundedConnection, { + initialCredit: 0, + }); + unboundedConnection.receive(serverInfo()); + const unboundedQuery = await unbounded.query("select 1"); + const unboundedRequest = new QwpByteReader(unboundedConnection.sent[0]); + unboundedRequest.readUint8(); + unboundedRequest.readBigUint64(); + const unboundedSqlLength = Number(readQwpVarint(unboundedRequest)); + unboundedRequest.readBytes(unboundedSqlLength); + expect(readQwpVarint(unboundedRequest)).toBe(0n); + unboundedConnection.receive(resultEnd(unboundedQuery.requestId)); + await unboundedQuery.completion; + await unbounded.close(); + }); + it("uses compressed RESULT_BATCH wire bytes for automatic credit", async () => { const connection = new FakeConnection(); const session = new QwpEgressSession(connection); diff --git a/test/qwp/public-api-contract.ts b/test/qwp/public-api-contract.ts index 5f1e171..849db2e 100644 --- a/test/qwp/public-api-contract.ts +++ b/test/qwp/public-api-contract.ts @@ -85,6 +85,7 @@ const queryOptionsContract: QwpEgressQueryOptions = { }; const egressSessionOptionsContract: QwpEgressSessionOptions = { + initialCredit: 256 * 1024, queryTimeoutMs: 30_000, cancelDrainTimeoutMs: 5_000, }; diff --git a/test/qwp/public-api.test.ts b/test/qwp/public-api.test.ts index ad9a03d..6dca95e 100644 --- a/test/qwp/public-api.test.ts +++ b/test/qwp/public-api.test.ts @@ -6,6 +6,7 @@ import * as shared from "../../src/qwp"; const sharedRuntimeContract = [ "QWP_INGRESS_PROGRESS_KIND", + "QWP_DEFAULT_EGRESS_INITIAL_CREDIT", "QWP_RECONNECT_EVENT_KIND", "QWP_UPGRADE_ERROR_KIND", "QWP_VERSION", From ffd7e7d0adbde4a9acd4c3ce4e527bd035e5b250 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 18 Aug 2026 11:05:13 +0100 Subject: [PATCH 033/265] fix(qwp): detect poisoned ingress replays --- QWP.md | 7 + src/qwp/core/ingress.ts | 2 +- .../reconnecting-ingress-connection.ts | 290 +++++++++++++++--- src/qwp/transport.ts | 6 + test/qwp/reconnect.test.ts | 180 +++++++++++ 5 files changed, 438 insertions(+), 47 deletions(-) diff --git a/QWP.md b/QWP.md index 78ef056..f97f2c7 100644 --- a/QWP.md +++ b/QWP.md @@ -273,6 +273,13 @@ bounded exponential backoff and emits lifecycle events. Node ingress requires a persistent replay store when reconnect is enabled; browser ingress can only replay from memory for the lifetime of the page. +Ingress also detects a replay head that is repeatedly NACKed or followed by a +non-orderly WebSocket close. `maxFrameRejections` controls the strike threshold and +`poisonMinEscalationWindowMs` (5 seconds by default) prevents a brief outage from +being mistaken for a deterministic poison frame. Normal and going-away closes, +`NOT_WRITABLE`, and retriable symbol-dictionary catch-up rejections are retried with +pacing but do not count as poison strikes. + Node.js sees the rejected upgrade status and `X-QuestDB-Role`, so a read-only replica or catching-up primary can be classified and skipped. Browsers deliberately expose an opaque upgrade error because their WebSocket API hides the HTTP response. Avoid diff --git a/src/qwp/core/ingress.ts b/src/qwp/core/ingress.ts index be5c0ce..2b01964 100644 --- a/src/qwp/core/ingress.ts +++ b/src/qwp/core/ingress.ts @@ -673,7 +673,7 @@ export function decodeQwpIngressResponse( const messageLength = reader.readUint16("NACK message length"); if (messageLength > QWP_MAX_ERROR_MESSAGE_LENGTH) { - throw new Error( + throw new QwpProtocolError( `QWP error message exceeds ${QWP_MAX_ERROR_MESSAGE_LENGTH} bytes`, ); } diff --git a/src/qwp/internal/reconnecting-ingress-connection.ts b/src/qwp/internal/reconnecting-ingress-connection.ts index ebe5a73..1e16e4b 100644 --- a/src/qwp/internal/reconnecting-ingress-connection.ts +++ b/src/qwp/internal/reconnecting-ingress-connection.ts @@ -62,6 +62,23 @@ class RetriableIngressNackError extends Error { } } +class RetriableIngressConnectionError extends Error { + readonly cause: unknown; + + constructor( + readonly retryDelayMs: number, + cause: unknown, + ) { + super( + cause instanceof Error + ? cause.message + : `QWP ingress connection was lost: ${cause}`, + ); + this.name = "RetriableIngressConnectionError"; + this.cause = cause; + } +} + class QwpMemoryReplayStore implements QwpIngressReplayStore { private readonly records = new Map(); private readonly symbols: string[] = []; @@ -119,6 +136,7 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { private readonly maxBackoffMs: number; private readonly maxDurationMs: number; private readonly maxFrameRejections: number; + private readonly poisonMinEscalationWindowMs: number; private readonly localMaxBatchSizeBytes?: number; private readonly resolveClosed: (info: QwpConnectionCloseInfo) => void; private connection?: QwpBinaryConnection; @@ -129,9 +147,13 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { private nextFrameSequence = 0n; private nextClientSequence = 0n; private acknowledgedFrameSequence = -1n; - private rejectedFrameSequence?: bigint; + private highestOkFrameSequence = -1n; + private poisonFrameSequence?: bigint; + private poisonFirstStrikeMs = 0; + private poisonStrikes = 0; + private progressAtLastExemptRecycle = -1n; + private zeroProgressRecycles = 0; private recoveredDiscardTail?: RecoveredDiscardTail; - private rejectionCount = 0; private generation = 0; private sendTail: Promise = Promise.resolve(); private drainTail: Promise = Promise.resolve(); @@ -173,12 +195,15 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { this.maxBackoffMs = reconnectOptions.maxBackoffMs ?? 5_000; this.maxDurationMs = reconnectOptions.maxDurationMs ?? 30_000; this.maxFrameRejections = reconnectOptions.maxFrameRejections ?? 4; + this.poisonMinEscalationWindowMs = + reconnectOptions.poisonMinEscalationWindowMs ?? 5_000; validateReconnectPolicy( this.maxAttempts, this.initialBackoffMs, this.maxBackoffMs, this.maxDurationMs, this.maxFrameRejections, + this.poisonMinEscalationWindowMs, ); let resolveClosed!: (info: QwpConnectionCloseInfo) => void; this.closed = new Promise((resolve) => { @@ -395,11 +420,9 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { }); } - if ( - initialCause instanceof RetriableIngressNackError && - initialCause.retryDelayMs > 0 - ) { - await this.waitForBackoff(initialCause.retryDelayMs); + const initialRetryDelayMs = reconnectDelayMs(initialCause); + if (initialRetryDelayMs > 0) { + await this.waitForBackoff(initialRetryDelayMs); } while (!this.closing) { @@ -530,17 +553,58 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { ): Promise { try { for await (const payload of connection.messages) { - if (this.closing || this.connection !== connection) return; - const translated = await this.translateResponse(payload); + if ( + this.closing || + this.connection !== connection || + generation !== this.generation + ) { + return; + } + let translated: Uint8Array | undefined; + try { + translated = await this.translateResponse(payload); + } catch (error) { + if ( + error instanceof RetriableIngressNackError || + error instanceof QwpProtocolError || + error instanceof QwpReplayRejectedError + ) { + throw error; + } + // The wire payload decoded successfully. Failures from this point + // are local replay-store/bookkeeping failures, not evidence that + // the server rejected the head frame. + this.failTerminal(error); + await connection + .close(1011, "QWP ingress response processing failed") + .catch(() => undefined); + return; + } if (translated) this.messagesQueue.push(translated); if (this.terminalError) return; } - if (this.closing || this.connection !== connection) return; + if ( + this.closing || + this.connection !== connection || + generation !== this.generation + ) { + return; + } const info = await connection.closed; - await this.requestReconnect( + const cause = this.classifyConnectionLoss( new QwpSendClosedError(info), - connection, - ).catch((reconnectError) => this.failTerminal(reconnectError)); + info, + ); + if (cause instanceof QwpProtocolError) { + this.failTerminal(cause); + await connection + .close(1002, "poisoned QWP ingress frame") + .catch(() => undefined); + return; + } + await this.requestReconnect(cause, connection).catch((reconnectError) => + this.failTerminal(reconnectError), + ); return; } catch (error) { if ( @@ -560,7 +624,21 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { .catch(() => undefined); return; } - await this.requestReconnect(error, connection).catch((reconnectError) => { + const cause = + error instanceof RetriableIngressNackError + ? error + : this.classifyConnectionLoss( + error, + error instanceof QwpSendClosedError ? error.closeInfo : undefined, + ); + if (cause instanceof QwpProtocolError) { + this.failTerminal(cause); + await connection + .close(1002, "poisoned QWP ingress frame") + .catch(() => undefined); + return; + } + await this.requestReconnect(cause, connection).catch((reconnectError) => { this.failTerminal(reconnectError); }); } @@ -583,17 +661,35 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { if (response.sequence === null) { throw new QwpProtocolError("QWP response is missing its wire sequence"); } - if ( - response.sequence < 0n || - response.sequence > BigInt(Number.MAX_SAFE_INTEGER) - ) { + if (response.sequence < 0n) { + throw new QwpProtocolError( + `QWP response sequence is negative: ${response.sequence}`, + ); + } + if (this.wireFrames.length === 0) { + if (response.status === QWP_STATUS.OK) return undefined; + this.totalServerNacks++; + if (isRetriableIngressStatus(response.status)) { + throw new RetriableIngressNackError( + -1n, + response.status, + this.nextExemptRecycleDelay(), + response.errorMessage, + ); + } throw new QwpProtocolError( - `QWP response sequence is outside the safe range: ${response.sequence}`, + `QuestDB rejected ingress before any frame was sent [status=0x${response.status.toString(16)}]${ + response.errorMessage ? `: ${response.errorMessage}` : "" + }`, ); } - const wireIndex = Number(response.sequence); + const highestWireIndex = this.wireFrames.length - 1; + const wireIndex = Number( + response.sequence > BigInt(highestWireIndex) + ? BigInt(highestWireIndex) + : response.sequence, + ); const frame = this.wireFrames[wireIndex]; - if (!frame) return undefined; if (response.status === QWP_STATUS.OK) { if (frame.dictionaryCatchup) return undefined; @@ -604,13 +700,10 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { candidate.clientSequence !== undefined && !candidate.ackDelivered, ); for (const candidate of covered) candidate.ackDelivered = true; - if ( - this.rejectedFrameSequence !== undefined && - frame.frameSequence >= this.rejectedFrameSequence - ) { - this.rejectedFrameSequence = undefined; - this.rejectionCount = 0; + if (frame.frameSequence > this.highestOkFrameSequence) { + this.highestOkFrameSequence = frame.frameSequence; } + this.clearPoisonThrough(frame.frameSequence); if (this.handshake.durableAckEnabled) { frame.durableTargets = new Map( response.tables.map((table) => [ @@ -631,38 +724,47 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { this.totalServerNacks++; if (isRetriableIngressStatus(response.status)) { - const sameFrame = this.rejectedFrameSequence === frame.frameSequence; - this.rejectedFrameSequence = frame.frameSequence; - this.rejectionCount = sameFrame ? this.rejectionCount + 1 : 1; - const notWritable = response.status === QWP_STATUS.NOT_WRITABLE; - if (!notWritable && this.rejectionCount >= this.maxFrameRejections) { + const exempt = + frame.dictionaryCatchup || response.status === QWP_STATUS.NOT_WRITABLE; + if (exempt) { + throw new RetriableIngressNackError( + frame.frameSequence, + response.status, + this.nextExemptRecycleDelay(), + response.errorMessage, + ); + } + if (this.recordPoisonStrike(frame.frameSequence)) { throw new QwpReplayRejectedError( frame.frameSequence, response.status, - `frame remained rejected after ${this.rejectionCount} attempts${ + `frame remained rejected after ${this.poisonStrikes} attempts${ response.errorMessage ? `: ${response.errorMessage}` : "" }`, ); } - const exponent = notWritable - ? Math.max(this.rejectionCount - 2, 0) - : this.rejectionCount - 1; - const retryDelayMs = - notWritable && this.rejectionCount === 1 - ? 0 - : cappedExponentialBackoff( - this.initialBackoffMs, - this.maxBackoffMs, - exponent, - ); throw new RetriableIngressNackError( frame.frameSequence, response.status, - retryDelayMs, + cappedExponentialBackoff( + this.initialBackoffMs, + this.maxBackoffMs, + this.poisonStrikes - 1, + ), response.errorMessage, ); } + if (frame.dictionaryCatchup) { + const error = new QwpProtocolError( + `QuestDB rejected QWP symbol dictionary catch-up [status=0x${response.status.toString(16)}]${ + response.errorMessage ? `: ${response.errorMessage}` : "" + }`, + ); + this.failTerminal(error); + return undefined; + } + const replayError = new QwpReplayRejectedError( frame.frameSequence, response.status, @@ -693,6 +795,91 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { if (lastCovered !== undefined) await this.acknowledgeThrough(lastCovered); } + private clearPoisonThrough(frameSequence: bigint): void { + if ( + this.poisonFrameSequence === undefined || + frameSequence < this.poisonFrameSequence + ) { + return; + } + this.poisonFrameSequence = undefined; + this.poisonFirstStrikeMs = 0; + this.poisonStrikes = 0; + } + + private recordPoisonStrike(frameSequence: bigint): boolean { + const now = Date.now(); + if (this.poisonFrameSequence === frameSequence) { + this.poisonStrikes++; + } else { + this.poisonFrameSequence = frameSequence; + this.poisonStrikes = 1; + this.poisonFirstStrikeMs = now; + } + return ( + this.poisonStrikes >= this.maxFrameRejections && + now - this.poisonFirstStrikeMs >= this.poisonMinEscalationWindowMs + ); + } + + private classifyConnectionLoss( + cause: unknown, + closeInfo?: QwpConnectionCloseInfo, + ): Error { + const orderly = closeInfo?.code === 1000 || closeInfo?.code === 1001; + const head = orderly ? undefined : this.currentPoisonHead(); + if (!head) { + return new RetriableIngressConnectionError( + this.nextExemptRecycleDelay(), + cause, + ); + } + if (this.recordPoisonStrike(head.frameSequence)) { + const closeDetail = closeInfo + ? `code=${closeInfo.code}, reason=${closeInfo.reason}` + : "transport ended without an orderly close"; + return new QwpProtocolError( + `QWP ingress frame repeatedly caused a non-orderly connection loss [frameSequence=${head.frameSequence}, strikes=${this.poisonStrikes}, ${closeDetail}]`, + ); + } + return new RetriableIngressConnectionError( + cappedExponentialBackoff( + this.initialBackoffMs, + this.maxBackoffMs, + this.poisonStrikes - 1, + ), + cause, + ); + } + + private currentPoisonHead(): ReplayFrame | undefined { + const progress = + this.highestOkFrameSequence > this.acknowledgedFrameSequence + ? this.highestOkFrameSequence + : this.acknowledgedFrameSequence; + return this.wireFrames.find( + (frame) => !frame.dictionaryCatchup && frame.frameSequence > progress, + ); + } + + private nextExemptRecycleDelay(): number { + const progress = + this.highestOkFrameSequence > this.acknowledgedFrameSequence + ? this.highestOkFrameSequence + : this.acknowledgedFrameSequence; + if (progress > this.progressAtLastExemptRecycle) { + this.zeroProgressRecycles = 0; + } + this.progressAtLastExemptRecycle = progress; + const level = this.zeroProgressRecycles++; + if (level === 0) return 0; + return cappedExponentialBackoff( + this.initialBackoffMs, + this.maxBackoffMs, + level - 1, + ); + } + private async acknowledgeThrough(frameSequence: bigint): Promise { await this.acknowledgeStoredFramesThrough(frameSequence); await this.retireRecoveredDiscardTailIfReady(); @@ -1059,6 +1246,7 @@ function validateReconnectPolicy( maxBackoffMs: number, maxDurationMs: number, maxFrameRejections: number, + poisonMinEscalationWindowMs: number, ): void { if (!Number.isSafeInteger(maxAttempts) || maxAttempts < 0) { throw new RangeError( @@ -1069,6 +1257,7 @@ function validateReconnectPolicy( ["initialBackoffMs", initialBackoffMs], ["maxBackoffMs", maxBackoffMs], ["maxDurationMs", maxDurationMs], + ["poisonMinEscalationWindowMs", poisonMinEscalationWindowMs], ] as const) { if (!Number.isFinite(value) || value < 0) { throw new RangeError( @@ -1095,7 +1284,16 @@ function isRetryableReconnectError(error: unknown): boolean { isRetryableReconnectError(attempt.error), ); } - return !(error instanceof QwpReplayRejectedError); + return !( + error instanceof QwpReplayRejectedError || error instanceof QwpProtocolError + ); +} + +function reconnectDelayMs(error: unknown): number { + return error instanceof RetriableIngressNackError || + error instanceof RetriableIngressConnectionError + ? error.retryDelayMs + : 0; } function isRetriableIngressStatus(status: number): boolean { diff --git a/src/qwp/transport.ts b/src/qwp/transport.ts index 87d9c1a..9e1638f 100644 --- a/src/qwp/transport.ts +++ b/src/qwp/transport.ts @@ -195,6 +195,12 @@ export interface QwpReconnectOptions { * as poison and retained for inspection. Defaults to 4. */ maxFrameRejections?: number; + /** + * Minimum time the same ingress frame must remain suspect before repeated + * rejections or non-orderly closes become terminal. Defaults to 5s; zero + * escalates as soon as maxFrameRejections is reached. + */ + poisonMinEscalationWindowMs?: number; onEvent?: (event: QwpReconnectEvent) => void; } diff --git a/test/qwp/reconnect.test.ts b/test/qwp/reconnect.test.ts index 6ee86b6..d9b0795 100644 --- a/test/qwp/reconnect.test.ts +++ b/test/qwp/reconnect.test.ts @@ -25,6 +25,7 @@ import { QwpIngressReplayRecord, QwpIngressReplayStore, QwpHandshakeMetadata, + QwpProtocolError, QwpSymbolDictionary, QwpTableBuffer, QwpReconnectEvent, @@ -639,6 +640,7 @@ describe("QWP ingress reconnect and replay", () => { reconnect: { maxAttempts: 1, maxFrameRejections: 2, + poisonMinEscalationWindowMs: 0, initialBackoffMs: 0, maxBackoffMs: 0, }, @@ -655,6 +657,184 @@ describe("QWP ingress reconnect and replay", () => { await session.close(); }); + it("allows a suspect frame to recover inside the poison dwell window", async () => { + const first = new FakeConnection("primary"); + const second = new FakeConnection("secondary"); + const third = new FakeConnection("primary"); + const connections = [first, second, third]; + const session = await QwpIngressSession.connect( + async () => { + const connection = connections.shift(); + if (!connection) throw new Error("no connection available"); + return connection; + }, + { + reconnect: { + maxAttempts: 1, + maxFrameRejections: 2, + poisonMinEscalationWindowMs: 10_000, + initialBackoffMs: 0, + maxBackoffMs: 0, + }, + }, + ); + const pending = session.sendFrame(Uint8Array.of(9)); + await vi.waitFor(() => expect(first.sent).toHaveLength(1)); + first.receive(ingressResponse(QWP_STATUS.WRITE_ERROR, 0n)); + await vi.waitFor(() => expect(second.sent).toHaveLength(1)); + second.receive(ingressResponse(QWP_STATUS.WRITE_ERROR, 0n)); + await vi.waitFor(() => expect(third.sent).toHaveLength(1)); + third.receive(ingressResponse(QWP_STATUS.OK, 0n)); + + await expect(pending).resolves.toMatchObject({ + status: QWP_STATUS.OK, + sequence: 0n, + }); + await session.close(); + }); + + it("does not count NOT_WRITABLE as a poison-frame strike", async () => { + const first = new FakeConnection("replica"); + const second = new FakeConnection("primary"); + const connections = [first, second]; + const session = await QwpIngressSession.connect( + async () => { + const connection = connections.shift(); + if (!connection) throw new Error("no connection available"); + return connection; + }, + { + reconnect: { + maxAttempts: 1, + maxFrameRejections: 1, + poisonMinEscalationWindowMs: 0, + initialBackoffMs: 0, + maxBackoffMs: 0, + }, + }, + ); + const pending = session.sendFrame(Uint8Array.of(9)); + await vi.waitFor(() => expect(first.sent).toHaveLength(1)); + first.receive(ingressResponse(QWP_STATUS.NOT_WRITABLE, 0n)); + await vi.waitFor(() => expect(second.sent).toHaveLength(1)); + second.receive(ingressResponse(QWP_STATUS.OK, 0n)); + + await expect(pending).resolves.toMatchObject({ + status: QWP_STATUS.OK, + sequence: 0n, + }); + await session.close(); + }); + + it("stops replaying a head frame that repeatedly causes non-orderly closes", async () => { + const first = new FakeConnection("primary"); + const second = new FakeConnection("secondary"); + const connections = [first, second]; + const replayStore = new TrackingReplayStore(); + const session = await QwpIngressSession.connect( + async () => { + const connection = connections.shift(); + if (!connection) throw new Error("no connection available"); + return connection; + }, + { + replayStore, + reconnect: { + maxAttempts: 1, + maxFrameRejections: 2, + poisonMinEscalationWindowMs: 0, + initialBackoffMs: 0, + maxBackoffMs: 0, + }, + }, + ); + const pending = session.sendFrame(Uint8Array.of(9)); + await vi.waitFor(() => expect(first.sent).toHaveLength(1)); + first.drop(); + await vi.waitFor(() => expect(second.sent).toHaveLength(1)); + second.drop(); + + await expect(pending).rejects.toThrow(/frameSequence=0, strikes=2/); + await expect(pending).rejects.toBeInstanceOf(QwpProtocolError); + expect(connections).toHaveLength(0); + expect(Array.from(replayStore.records.keys())).toEqual([0n]); + await session.close(); + }); + + it("does not count orderly ingress closes as poison-frame strikes", async () => { + const first = new FakeConnection("primary"); + const second = new FakeConnection("secondary"); + const connections = [first, second]; + const session = await QwpIngressSession.connect( + async () => { + const connection = connections.shift(); + if (!connection) throw new Error("no connection available"); + return connection; + }, + { + reconnect: { + maxAttempts: 1, + maxFrameRejections: 1, + poisonMinEscalationWindowMs: 0, + initialBackoffMs: 0, + maxBackoffMs: 0, + }, + }, + ); + const pending = session.sendFrame(Uint8Array.of(9)); + await vi.waitFor(() => expect(first.sent).toHaveLength(1)); + await first.close(1001, "rolling restart"); + await vi.waitFor(() => expect(second.sent).toHaveLength(1)); + second.receive(ingressResponse(QWP_STATUS.OK, 0n)); + + await expect(pending).resolves.toMatchObject({ + status: QWP_STATUS.OK, + sequence: 0n, + }); + await session.close(); + }); + + it("does not reconnect after a malformed ingress response", async () => { + const connection = new FakeConnection("primary"); + let factoryCalls = 0; + const session = await QwpIngressSession.connect( + async () => { + factoryCalls++; + return connection; + }, + { + reconnect: { + maxAttempts: 3, + initialBackoffMs: 0, + maxBackoffMs: 0, + }, + }, + ); + const pending = session.sendFrame(Uint8Array.of(9)); + await vi.waitFor(() => expect(connection.sent).toHaveLength(1)); + connection.receive(Uint8Array.of(QWP_STATUS.OK)); + + await expect(pending).rejects.toBeInstanceOf(QwpProtocolError); + expect(factoryCalls).toBe(1); + await session.close(); + }); + + it("clamps an ingress ACK to the highest wire sequence sent", async () => { + const connection = new FakeConnection("primary"); + const session = await QwpIngressSession.connect(async () => connection, { + reconnect: { maxAttempts: 1 }, + }); + const pending = session.sendFrame(Uint8Array.of(9)); + await vi.waitFor(() => expect(connection.sent).toHaveLength(1)); + connection.receive(ingressResponse(QWP_STATUS.OK, 999n)); + + await expect(pending).resolves.toMatchObject({ + status: QWP_STATUS.OK, + sequence: 0n, + }); + await session.close(); + }); + it("durably trims cumulative transaction ranges at ordered ACK checkpoints", async () => { const connection = new FakeConnection("primary", { qwpVersion: 1, From 777f8ca29fb8d9c62a907be65961de3061992cce Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 18 Aug 2026 11:12:00 +0100 Subject: [PATCH 034/265] fix(qwp): preserve replay journal liveness --- QWP.md | 8 +++++++ src/qwp-node/file-replay-store.ts | 31 +++++++++++++++++------- test/qwp/reconnect.test.ts | 40 +++++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 8 deletions(-) diff --git a/QWP.md b/QWP.md index f97f2c7..3f2d442 100644 --- a/QWP.md +++ b/QWP.md @@ -98,6 +98,14 @@ sends it in order. Applications can therefore keep publishing during an outage u the configured `maxBytes` applies backpressure. A failed journal publication leaves the high-level rows staged so the caller can retry. +The persisted symbol dictionary is lifetime-monotonic and cannot be reclaimed by an +ACK. It counts toward the `maxBytes` target, but the journal preserves up to 32 MiB +(or the configured target when smaller) for live frame records if dictionary growth +uses all remaining headroom. Dictionary persistence itself is never rejected by the +target, so actual disk usage can exceed it by the non-reclaimable dictionary +overshoot. Frame growth beyond the liveness allowance remains backpressured until +ACK trimming frees record files. + The journal takes an exclusive lock when it is loaded and holds it until the sender or session closes. A second live process using the same directory fails with `QwpReplayStoreLockedError` before recovery or cleanup can mutate journal contents. diff --git a/src/qwp-node/file-replay-store.ts b/src/qwp-node/file-replay-store.ts index eeb568c..4d7078c 100644 --- a/src/qwp-node/file-replay-store.ts +++ b/src/qwp-node/file-replay-store.ts @@ -32,6 +32,10 @@ const LOCK_DIRECTORY = ".qwp.lock"; const LOCK_OWNER_FILE = "owner.json"; const LOCK_RECOVERY_FILE = "recovery.json"; const ABANDONED_LOCK_PREFIX = ".qwp.lock.abandoned-"; +// The file-per-frame journal has no fixed segment working set. Preserve two +// default-sized QWP batches instead, mirroring Java's active+spare liveness +// floor when the lifetime-monotonic dictionary consumes the configured cap. +const DEFAULT_LIVE_FRAME_BYTES = 2 * 16 * 1024 * 1024; const UTF8_DECODER = new TextDecoder("utf-8", { fatal: true }); interface StoredRecord { @@ -50,7 +54,11 @@ interface ReplayStoreLockOwner { export interface QwpNodeFileReplayStoreOptions { /** Exclusive directory used by one ingress session. */ directory: string; - /** Maximum journal size including record headers. Defaults to 1 GiB. */ + /** + * Target maximum journal size including record headers and symbol metadata. + * Defaults to 1 GiB. The non-reclaimable symbol dictionary may exceed this + * target so it cannot permanently consume the journal's live frame budget. + */ maxBytes?: number; } @@ -105,6 +113,7 @@ export class QwpReplayStoreLockedError extends QwpReplayStoreError { export class QwpNodeFileReplayStore implements QwpIngressReplayStore { private readonly directory: string; private readonly maxBytes: number; + private readonly liveFrameBytes: number; private readonly records = new Map(); private readonly symbols: string[] = []; private readonly symbolValues = new Set(); @@ -130,6 +139,7 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { } this.directory = directory; this.maxBytes = maxBytes; + this.liveFrameBytes = Math.min(maxBytes, DEFAULT_LIVE_FRAME_BYTES); } load(): Promise { @@ -218,7 +228,15 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { } const bytes = encodeRecord(record); const requiredBytes = this.totalBytes + bytes.byteLength; - if (requiredBytes > this.maxBytes) { + const frameBytes = this.totalBytes - this.dictionaryFileSize; + const requiredFrameBytes = frameBytes + bytes.byteLength; + const preservesLiveness = + this.dictionaryFileSize > 0 && + (requiredFrameBytes <= this.liveFrameBytes || frameBytes === 0); + if ( + bytes.byteLength > this.maxBytes || + (requiredBytes > this.maxBytes && !preservesLiveness) + ) { throw new QwpReplayStoreFullError(this.maxBytes, requiredBytes); } @@ -316,9 +334,6 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { const addedBytes = block.byteLength + (initial ? DICTIONARY_HEADER_SIZE : 0); const requiredBytes = this.totalBytes + addedBytes; - if (requiredBytes > this.maxBytes) { - throw new QwpReplayStoreFullError(this.maxBytes, requiredBytes); - } const finalPath = join(this.directory, DICTIONARY_FILE); if (initial) { const temporaryPath = join( @@ -625,9 +640,9 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { } this.dictionaryFileSize = offset; this.totalBytes += offset; - if (this.totalBytes > this.maxBytes) { - throw new QwpReplayStoreFullError(this.maxBytes, this.totalBytes); - } + // Dictionary bytes are lifetime-monotonic and ACK trimming cannot reclaim + // them. Loading a valid journal above the target is therefore safe; frame + // appends remain backpressured except for the bounded liveness floor. } private assertReady(): void { diff --git a/test/qwp/reconnect.test.ts b/test/qwp/reconnect.test.ts index d9b0795..2a7aaa4 100644 --- a/test/qwp/reconnect.test.ts +++ b/test/qwp/reconnect.test.ts @@ -1441,6 +1441,46 @@ describe("QWP Node file replay store", () => { await store.close(); }); + it("preserves a live frame budget after dictionary growth exhausts the target", async () => { + const directory = await trackedDirectory(); + const first = new QwpNodeFileReplayStore({ + directory, + maxBytes: 60, + }); + await first.load(); + // Header + block metadata + this entry occupy 66 bytes, already above + // the configured target. Unlike frame bytes, this prefix never shrinks. + await first.appendSymbolDictionary(0, ["abcdefghij"]); + await expect( + first.append({ frameSequence: 0n, payload: Uint8Array.of(1) }), + ).resolves.toBeUndefined(); + await expect( + first.append({ frameSequence: 1n, payload: Uint8Array.of(2) }), + ).rejects.toBeInstanceOf(QwpReplayStoreFullError); + + await first.acknowledgeThrough(0n); + await expect( + first.append({ frameSequence: 1n, payload: Uint8Array.of(2) }), + ).resolves.toBeUndefined(); + await first.close(); + + const recovered = new QwpNodeFileReplayStore({ + directory, + maxBytes: 60, + }); + await expect(recovered.load()).resolves.toEqual([ + { frameSequence: 1n, payload: Uint8Array.of(2) }, + ]); + await expect(recovered.loadSymbolDictionary()).resolves.toEqual([ + "abcdefghij", + ]); + await recovered.acknowledgeThrough(1n); + await expect( + recovered.append({ frameSequence: 2n, payload: Uint8Array.of(3) }), + ).resolves.toBeUndefined(); + await recovered.close(); + }); + it("fails closed when a persisted record is corrupt", async () => { const directory = await trackedDirectory(); const first = new QwpNodeFileReplayStore({ directory }); From 88b2139c5368d2fc4e124d17b913463e5d29a6ba Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 18 Aug 2026 11:27:43 +0100 Subject: [PATCH 035/265] feat(qwp): add topology-aware endpoint routing --- QWP.md | 31 +++- src/qwp/browser.ts | 17 ++- src/qwp/internal/egress-routing.ts | 132 ++++++++++++++++ src/qwp/internal/failover.ts | 237 ++++++++++++++++++++++++++++- src/qwp/node.ts | 24 ++- src/qwp/transport.ts | 42 +++++ test/qwp/node-transport.test.ts | 52 ++++++- test/qwp/public-api-contract.ts | 18 ++- test/qwp/public-api.test.ts | 2 + test/qwp/reconnect.test.ts | 145 +++++++++++++++++- 10 files changed, 673 insertions(+), 27 deletions(-) create mode 100644 src/qwp/internal/egress-routing.ts diff --git a/QWP.md b/QWP.md index 3f2d442..191c808 100644 --- a/QWP.md +++ b/QWP.md @@ -276,10 +276,15 @@ tracking is in memory only. Persistent store-and-forward is intentionally Node-o ### Reconnect, failover, and roles -`failoverUrls` are attempted in order after the preferred URL. `reconnect` controls -bounded exponential backoff and emits lifecycle events. Node ingress requires a -persistent replay store when reconnect is enabled; browser ingress can only replay -from memory for the lifetime of the page. +The preferred URL and `failoverUrls` form one endpoint set. Endpoints are ranked by +observed health (`healthy`, unknown, transient rejection, transport error, topology +rejection) and then by zone affinity; configuration order breaks ties. Health outranks +zone, so a known healthy cross-zone node is preferred to an untried local node. Every +connection sweep can still try every endpoint, allowing role and health changes to +recover. A non-orderly close demotes the selected endpoint before the next sweep. +`reconnect` controls bounded exponential backoff and emits lifecycle events. Node +ingress requires a persistent replay store when reconnect is enabled; browser ingress +can only replay from memory for the lifetime of the page. Ingress also detects a replay head that is repeatedly NACKed or followed by a non-orderly WebSocket close. `maxFrameRejections` controls the strike threshold and @@ -340,6 +345,12 @@ import { connectQwpNodeEgress } from "@questdb/nodejs-client/qwp/node"; const session = await connectQwpNodeEgress( { url: "wss://questdb.example:9000/read/v1", + failoverUrls: [ + "wss://questdb-replica-2.example:9000/read/v1", + "wss://questdb-primary.example:9000/read/v1", + ], + target: "replica", + zone: "eu-west-1a", authorization: `Bearer ${token}`, compression: "zstd", compressionLevel: 3, @@ -368,6 +379,14 @@ try { } ``` +`target` accepts `any` (the default), `primary`, or `replica`. Primary routing also +accepts standalone servers and a primary completing catch-up, matching the Java +client. `zone` is an opaque, case-insensitive preference for `any` and `replica`; +cross-zone endpoints remain eligible. It is ignored for `primary`, which must be +followed across zones. The client validates the authoritative role and zone from the +first QWP `SERVER_INFO` frame before accepting an endpoint, so the same guarantees +work in browsers even though browser WebSocket APIs hide upgrade response headers. + Bind indexes are zero-based in the client: index `0` is SQL placeholder `$1`. `QwpBindValues` supports booleans, integer and floating-point values, dates, microsecond and nanosecond timestamps, strings, UUIDs, LONG256, geohashes, @@ -418,6 +437,9 @@ readUrl.protocol = location.protocol === "https:" ? "wss:" : "ws:"; const session = await connectQwpBrowserEgress({ url: readUrl, + failoverUrls: ["wss://replica-2.example/read/v1"], + target: "replica", + zone: "eu-west-1a", sessionBootstrap: { authentication: { type: "bearer", token: oidcOrRestAccessToken }, }, @@ -431,6 +453,7 @@ The public error classes preserve enough context for policy decisions: | Error | Meaning | | ---------------------------------- | ----------------------------------------------------------------------------------------------------------- | | `QwpUpgradeError` | Classified authentication, role, version, capability, timeout, transport, or browser-opaque upgrade failure | +| `QwpRoleMismatchError` | A connected endpoint's advertised role does not satisfy the requested egress target | | `QwpDurableAckUnavailableError` | Durable acknowledgement was required but not negotiated | | `QwpSendTimeoutError` | A send did not drain before its deadline; delivery is unknown | | `QwpIngressNackError` | QuestDB rejected an ingress frame | diff --git a/src/qwp/browser.ts b/src/qwp/browser.ts index 5134ed7..bcf90d3 100644 --- a/src/qwp/browser.ts +++ b/src/qwp/browser.ts @@ -7,6 +7,7 @@ import { validateQwpWebSocketTimeouts, } from "./internal/websocket-connection"; import { createQwpFailoverConnectionFactory } from "./internal/failover"; +import { createQwpEgressFailoverConnectionFactory } from "./internal/egress-routing"; import { addQwpDurableAckWebSocketProtocol, isQwpDurableAckWebSocketProtocol, @@ -16,6 +17,7 @@ import { QwpBinaryConnection, QwpConnectionFactory, QwpDurableAckUnavailableError, + QwpEgressRoutingOptions, QWP_UPGRADE_ERROR_KIND, QwpUpgradeError, QwpWebSocketConnectOptions, @@ -273,6 +275,11 @@ export interface QwpBrowserWebSocketOptions extends QwpWebSocketConnectOptions { ) => QwpWebSocketLike; } +/** Browser WebSocket options plus protocol-level egress topology routing. */ +export interface QwpBrowserEgressOptions + extends QwpBrowserWebSocketOptions, + QwpEgressRoutingOptions {} + /** * Opens a QWP-capable browser WebSocket. * @@ -403,11 +410,17 @@ export async function connectQwpBrowserSender( /** Opens a browser WebSocket and waits for the egress SERVER_INFO handshake. */ export async function connectQwpBrowserEgress( - options: QwpBrowserWebSocketOptions, + options: QwpBrowserEgressOptions, sessionOptions: QwpEgressSessionOptions = {}, ): Promise { return QwpEgressSession.connect( - createQwpBrowserConnectionFactory(options), + createQwpEgressFailoverConnectionFactory( + options.url, + options.failoverUrls, + (endpoint) => connectQwpBrowserEndpoint(options, endpoint), + { target: options.target, zone: options.zone }, + sessionOptions.serverInfoTimeoutMs ?? 15_000, + ), sessionOptions, ); } diff --git a/src/qwp/internal/egress-routing.ts b/src/qwp/internal/egress-routing.ts new file mode 100644 index 0000000..a8846ca --- /dev/null +++ b/src/qwp/internal/egress-routing.ts @@ -0,0 +1,132 @@ +import { + decodeQwpEgressMessage, + QWP_SERVER_ROLE, + QwpProtocolError, +} from "../core"; +import { + QwpBinaryConnection, + QwpConnectionFactory, + QwpEgressRoutingOptions, + QwpSendClosedError, +} from "../transport"; +import { + createQwpFailoverConnectionFactory, + QwpValidatedConnection, +} from "./failover"; + +/** + * Creates an egress endpoint walker that validates authoritative SERVER_INFO + * topology before exposing a connection. Reading the frame here works in both + * Node and browsers; the frame is replayed to the normal session consumer. + */ +export function createQwpEgressFailoverConnectionFactory( + preferredUrl: string | URL, + failoverUrls: readonly (string | URL)[] | undefined, + connect: (endpoint: string | URL) => Promise, + routing: QwpEgressRoutingOptions, + serverInfoTimeoutMs: number, +): QwpConnectionFactory { + return createQwpFailoverConnectionFactory( + preferredUrl, + failoverUrls, + connect, + { + ...routing, + validateConnection: (connection) => + readAndReplayServerInfo(connection, serverInfoTimeoutMs), + }, + ); +} + +async function readAndReplayServerInfo( + connection: QwpBinaryConnection, + timeoutMs: number, +): Promise { + const iterator = connection.messages[Symbol.asyncIterator](); + let timer: ReturnType | undefined; + const timeout = new Promise((_resolve, reject) => { + timer = setTimeout( + () => reject(new Error("timed out waiting for QWP SERVER_INFO")), + timeoutMs, + ); + }); + try { + const result = await Promise.race([iterator.next(), timeout]); + if (result.done) { + throw new QwpSendClosedError(await connection.closed); + } + const serverInfo = decodeQwpEgressMessage(result.value); + if (serverInfo.kind !== "server-info") { + throw new QwpProtocolError( + "QWP egress connection did not begin with SERVER_INFO", + ); + } + const serverRole = serverRoleName(serverInfo.role); + const serverZone = + serverInfo.zoneId ?? connection.handshake.serverZone ?? undefined; + return { + connection: prependMessage(connection, result.value, iterator, { + serverRole, + serverZone, + }), + serverRole, + serverZone, + }; + } finally { + if (timer) clearTimeout(timer); + } +} + +function serverRoleName(role: number): string { + switch (role) { + case QWP_SERVER_ROLE.STANDALONE: + return "STANDALONE"; + case QWP_SERVER_ROLE.PRIMARY: + return "PRIMARY"; + case QWP_SERVER_ROLE.REPLICA: + return "REPLICA"; + case QWP_SERVER_ROLE.PRIMARY_CATCHUP: + return "PRIMARY_CATCHUP"; + default: + return `UNKNOWN(${role})`; + } +} + +function prependMessage( + connection: QwpBinaryConnection, + first: Uint8Array, + iterator: AsyncIterator, + topology: { readonly serverRole: string; readonly serverZone?: string }, +): QwpBinaryConnection { + let consumed = false; + const messages: AsyncIterable = { + async *[Symbol.asyncIterator]() { + if (consumed) { + throw new QwpProtocolError( + "QWP connection messages already have a consumer", + ); + } + consumed = true; + yield first; + while (true) { + const result = await iterator.next(); + if (result.done) return; + yield result.value; + } + }, + }; + const wrapped: QwpBinaryConnection = { + messages, + closed: connection.closed, + handshake: { ...connection.handshake, ...topology }, + endpoint: connection.endpoint, + ingressSymbolDictionary: connection.ingressSymbolDictionary, + send: (payload) => connection.send(payload), + close: (code, reason) => connection.close(code, reason), + }; + if (connection.ping) wrapped.ping = () => connection.ping!(); + if (connection.getIngressMetrics) { + wrapped.getIngressMetrics = () => connection.getIngressMetrics!(); + } + return wrapped; +} diff --git a/src/qwp/internal/failover.ts b/src/qwp/internal/failover.ts index 6f52c8d..86c9c48 100644 --- a/src/qwp/internal/failover.ts +++ b/src/qwp/internal/failover.ts @@ -1,32 +1,120 @@ import { + QWP_TARGET, + QWP_UPGRADE_ERROR_KIND, QwpBinaryConnection, QwpConnectionFactory, + QwpEgressRoutingOptions, QwpFailoverAttempt, QwpFailoverError, + QwpRoleMismatchError, + QwpTarget, QwpUpgradeError, } from "../transport"; -/** Creates a stateful endpoint walker that rotates away from the last success. */ +const HOST_STATE = { + HEALTHY: 0, + UNKNOWN: 1, + TRANSIENT_REJECT: 2, + TRANSPORT_ERROR: 3, + TOPOLOGY_REJECT: 4, +} as const; + +type HostState = (typeof HOST_STATE)[keyof typeof HOST_STATE]; + +const ZONE_TIER = { + SAME: 0, + UNKNOWN: 1, + OTHER: 2, +} as const; + +type ZoneTier = (typeof ZONE_TIER)[keyof typeof ZONE_TIER]; + +interface QwpEndpointHealth { + state: HostState; + zoneTier: ZoneTier; +} + +export interface QwpValidatedConnection { + readonly connection: QwpBinaryConnection; + readonly serverRole?: string; + readonly serverZone?: string; +} + +export interface QwpFailoverSelectionOptions extends QwpEgressRoutingOptions { + /** @internal Reads protocol-level topology metadata when headers are hidden. */ + validateConnection?: ( + connection: QwpBinaryConnection, + ) => Promise; +} + +/** + * Creates a stateful endpoint walker ordered by health and then zone affinity. + * Every invocation still performs a complete sweep, so stale role/health data + * can never permanently exclude an endpoint whose state has changed. + */ export function createQwpFailoverConnectionFactory( preferredUrl: string | URL, failoverUrls: readonly (string | URL)[] | undefined, connect: (endpoint: string | URL) => Promise, + options: QwpFailoverSelectionOptions = {}, ): QwpConnectionFactory { const endpoints = [preferredUrl, ...(failoverUrls ?? [])]; - let nextStart = 0; + const target = normalizeTarget(options.target); + const configuredZone = normalizeZone(options.zone); + const zoneBlind = + configuredZone === undefined || target === QWP_TARGET.PRIMARY; + const health: QwpEndpointHealth[] = endpoints.map(() => ({ + state: HOST_STATE.UNKNOWN, + zoneTier: zoneBlind ? ZONE_TIER.SAME : ZONE_TIER.UNKNOWN, + })); return async (): Promise => { const attempts: QwpFailoverAttempt[] = []; - const start = nextStart; - for (let offset = 0; offset < endpoints.length; offset++) { - const index = (start + offset) % endpoints.length; + const attempted = new Set(); + + while (attempted.size < endpoints.length) { + const index = pickNextEndpoint(health, attempted); + attempted.add(index); const endpoint = endpoints[index]; + let candidate: QwpBinaryConnection | undefined; try { - const connection = await connect(endpoint); - nextStart = (index + 1) % endpoints.length; - return connection; + candidate = await connect(endpoint); + let validated: QwpValidatedConnection = { + connection: candidate, + serverRole: candidate.handshake.serverRole, + serverZone: candidate.handshake.serverZone, + }; + if (options.validateConnection) { + const protocolValidated = await options.validateConnection(candidate); + validated = { + connection: protocolValidated.connection, + serverRole: + protocolValidated.serverRole ?? candidate.handshake.serverRole, + serverZone: + protocolValidated.serverZone ?? candidate.handshake.serverZone, + }; + } + candidate = validated.connection; + recordZone( + health[index], + configuredZone, + zoneBlind, + validated.serverZone, + ); + if (!matchesTarget(validated.serverRole, target)) { + throw new QwpRoleMismatchError( + target, + validated.serverRole, + endpoint, + validated.serverZone, + ); + } + health[index].state = HOST_STATE.HEALTHY; + return observeConnectionHealth(candidate, health[index]); } catch (error) { + recordFailure(health[index], configuredZone, zoneBlind, error); attempts.push({ endpoint, error }); + if (candidate) await candidate.close().catch(() => undefined); if (error instanceof QwpUpgradeError && !error.tryNextEndpoint) { throw error; } @@ -36,3 +124,136 @@ export function createQwpFailoverConnectionFactory( throw new QwpFailoverError(attempts); }; } + +function normalizeTarget(target: QwpTarget | undefined): QwpTarget { + const effective = target ?? QWP_TARGET.ANY; + if ( + effective !== QWP_TARGET.ANY && + effective !== QWP_TARGET.PRIMARY && + effective !== QWP_TARGET.REPLICA + ) { + throw new RangeError("target must be one of: any, primary, replica"); + } + return effective; +} + +function normalizeZone(zone: string | undefined): string | undefined { + const normalized = zone?.trim().toLowerCase(); + return normalized || undefined; +} + +function normalizeRole(role: string | undefined): string | undefined { + const normalized = role?.trim().toUpperCase().replace(/-/g, "_"); + return normalized || undefined; +} + +function matchesTarget(role: string | undefined, target: QwpTarget): boolean { + if (target === QWP_TARGET.ANY) return true; + const normalized = normalizeRole(role); + if (target === QWP_TARGET.REPLICA) return normalized === "REPLICA"; + return ( + normalized === "PRIMARY" || + normalized === "PRIMARY_CATCHUP" || + normalized === "STANDALONE" + ); +} + +function pickNextEndpoint( + health: readonly QwpEndpointHealth[], + attempted: ReadonlySet, +): number { + let selected = -1; + for (let index = 0; index < health.length; index++) { + if (attempted.has(index)) continue; + if (selected < 0 || compareHealth(health[index], health[selected]) < 0) { + selected = index; + } + } + return selected; +} + +function compareHealth( + left: QwpEndpointHealth, + right: QwpEndpointHealth, +): number { + if (left.state !== right.state) return left.state - right.state; + if (left.zoneTier !== right.zoneTier) return left.zoneTier - right.zoneTier; + return 0; +} + +function recordZone( + health: QwpEndpointHealth, + configuredZone: string | undefined, + zoneBlind: boolean, + serverZone: string | undefined, +): void { + const normalized = normalizeZone(serverZone); + if (!normalized) return; + health.zoneTier = + zoneBlind || normalized === configuredZone + ? ZONE_TIER.SAME + : ZONE_TIER.OTHER; +} + +function recordFailure( + health: QwpEndpointHealth, + configuredZone: string | undefined, + zoneBlind: boolean, + error: unknown, +): void { + if (error instanceof QwpUpgradeError) { + recordZone(health, configuredZone, zoneBlind, error.serverZone); + if (error.kind === QWP_UPGRADE_ERROR_KIND.ROLE_REJECTED) { + health.state = + normalizeRole(error.serverRole) === "PRIMARY_CATCHUP" + ? HOST_STATE.TRANSIENT_REJECT + : HOST_STATE.TOPOLOGY_REJECT; + return; + } + } + health.state = HOST_STATE.TRANSPORT_ERROR; +} + +function observeConnectionHealth( + connection: QwpBinaryConnection, + health: QwpEndpointHealth, +): QwpBinaryConnection { + const demote = (): void => { + if (health.state === HOST_STATE.HEALTHY) { + health.state = HOST_STATE.TRANSPORT_ERROR; + } + }; + void connection.closed.then((info) => { + if (!info.wasClean) demote(); + }, demote); + const observed: QwpBinaryConnection = { + messages: connection.messages, + closed: connection.closed, + handshake: connection.handshake, + endpoint: connection.endpoint, + ingressSymbolDictionary: connection.ingressSymbolDictionary, + send: async (payload) => { + try { + await connection.send(payload); + } catch (error) { + demote(); + throw error; + } + }, + close: (code, reason) => connection.close(code, reason), + }; + if (connection.ping) { + observed.ping = async () => { + try { + await connection.ping!(); + } catch (error) { + demote(); + throw error; + } + }; + } + if (connection.getIngressMetrics) { + observed.getIngressMetrics = () => connection.getIngressMetrics!(); + } + return observed; +} diff --git a/src/qwp/node.ts b/src/qwp/node.ts index bdc69f3..cdda9ae 100644 --- a/src/qwp/node.ts +++ b/src/qwp/node.ts @@ -16,11 +16,13 @@ import { validateQwpWebSocketTimeouts, } from "./internal/websocket-connection"; import { createQwpFailoverConnectionFactory } from "./internal/failover"; +import { createQwpEgressFailoverConnectionFactory } from "./internal/egress-routing"; import { QWP_UPGRADE_ERROR_KIND, QwpBinaryConnection, QwpConnectionFactory, QwpDurableAckUnavailableError, + QwpEgressRoutingOptions, QwpHandshakeMetadata, QwpUpgradeError, QwpWebSocketConnectOptions, @@ -152,7 +154,9 @@ export interface QwpNodeIngressOptions extends QwpNodeWebSocketOptions { storeAndForward?: QwpNodeFileReplayStoreOptions; } -export interface QwpNodeEgressOptions extends QwpNodeWebSocketOptions { +export interface QwpNodeEgressOptions + extends QwpNodeWebSocketOptions, + QwpEgressRoutingOptions { /** * Requests Zstd-compressed result batches. The default is `raw`, which * preserves compatibility with servers that predate QWP compression. @@ -166,7 +170,13 @@ export interface QwpNodeEgressOptions extends QwpNodeWebSocketOptions { function egressTransportOptions( options: QwpNodeEgressOptions, ): QwpNodeWebSocketOptions { - const { compression, compressionLevel = 1, ...transport } = options; + const compression = options.compression; + const compressionLevel = options.compressionLevel ?? 1; + const transport = { ...options }; + delete transport.compression; + delete transport.compressionLevel; + delete transport.target; + delete transport.zone; const preference = compression ?? "raw"; const acceptEncoding = encodeQwpAcceptEncoding(preference, compressionLevel); @@ -311,6 +321,7 @@ function connectQwpNodeEndpoint( negotiatedCompression: decodeQwpContentEncoding(contentEncoding), durableAckEnabled, serverRole: headerValue(upgradeHeaders, "x-questdb-role"), + serverZone: headerValue(upgradeHeaders, "x-questdb-zone"), }; return handshake; }, @@ -406,8 +417,15 @@ export async function connectQwpNodeEgress( options: QwpNodeEgressOptions, sessionOptions: QwpEgressSessionOptions = {}, ): Promise { + const transport = egressTransportOptions(options); return QwpEgressSession.connect( - createQwpNodeConnectionFactory(egressTransportOptions(options)), + createQwpEgressFailoverConnectionFactory( + transport.url, + transport.failoverUrls, + (endpoint) => connectQwpNodeEndpoint(transport, endpoint), + { target: options.target, zone: options.zone }, + sessionOptions.serverInfoTimeoutMs ?? 15_000, + ), sessionOptions, ); } diff --git a/src/qwp/transport.ts b/src/qwp/transport.ts index 9e1638f..a1debed 100644 --- a/src/qwp/transport.ts +++ b/src/qwp/transport.ts @@ -241,6 +241,23 @@ export interface QwpUpgradeErrorDetails { cause?: unknown; } +export const QWP_TARGET = { + ANY: "any", + PRIMARY: "primary", + REPLICA: "replica", +} as const; + +/** Server role accepted by an egress connection. Defaults to `any`. */ +export type QwpTarget = (typeof QWP_TARGET)[keyof typeof QWP_TARGET]; + +/** Browser-safe endpoint-routing controls used by QWP egress clients. */ +export interface QwpEgressRoutingOptions { + /** Selects any readable node, a primary/standalone node, or a replica. */ + target?: QwpTarget; + /** Opaque, case-insensitive preferred zone; cross-zone fallback stays enabled. */ + zone?: string; +} + /** A failure while establishing or validating a QWP WebSocket upgrade. */ export class QwpUpgradeError extends Error { readonly kind: QwpUpgradeErrorKind; @@ -286,6 +303,29 @@ export class QwpUpgradeError extends Error { } } +/** A connected endpoint advertised a role that does not satisfy `target`. */ +export class QwpRoleMismatchError extends QwpUpgradeError { + constructor( + readonly target: QwpTarget, + serverRole: string | undefined, + url?: string | URL, + serverZone?: string, + ) { + super( + `QWP endpoint role does not match target [target=${target}, role=${serverRole ?? "unknown"}]`, + { + kind: QWP_UPGRADE_ERROR_KIND.ROLE_REJECTED, + retryable: true, + tryNextEndpoint: true, + url, + serverRole, + serverZone, + }, + ); + this.name = "QwpRoleMismatchError"; + } +} + /** A requested durable-ACK capability was not confirmed by the server. */ export class QwpDurableAckUnavailableError extends QwpUpgradeError { constructor(readonly url: string | URL) { @@ -316,6 +356,8 @@ export interface QwpHandshakeMetadata { readonly durableAckEnabled?: boolean; /** Server role advertised on a successful upgrade, when available. */ readonly serverRole?: string; + /** Server zone advertised on a successful upgrade, when available. */ + readonly serverZone?: string; } /** diff --git a/test/qwp/node-transport.test.ts b/test/qwp/node-transport.test.ts index b1bb955..baafadc 100644 --- a/test/qwp/node-transport.test.ts +++ b/test/qwp/node-transport.test.ts @@ -10,22 +10,32 @@ import { connectQwpNodeWebSocket, createQwpNodeSender, encodeQwpFrame, + QWP_EGRESS_CAPABILITY, QWP_EGRESS_MESSAGE, + QWP_SERVER_ROLE, QWP_STATUS, QWP_UPGRADE_ERROR_KIND, QwpByteWriter, QwpUpgradeError, } from "../../src/qwp/node"; -function serverInfo(): Uint8Array { +function serverInfo( + role = QWP_SERVER_ROLE.STANDALONE, + zone?: string, +): Uint8Array { + const capabilities = zone === undefined ? 0 : QWP_EGRESS_CAPABILITY.ZONE; const payload = new QwpByteWriter() .writeUint8(QWP_EGRESS_MESSAGE.SERVER_INFO) - .writeUint8(0) + .writeUint8(role) .writeBigUint64(1n) - .writeUint32(0) + .writeUint32(capabilities) .writeBigInt64(123n) .writeUint16(0) .writeUint16(0); + if (zone !== undefined) { + const encodedZone = new TextEncoder().encode(zone); + payload.writeUint16(encodedZone.length).writeBytes(encodedZone); + } return encodeQwpFrame(payload.toUint8Array()); } @@ -87,6 +97,7 @@ describe("QWP Node transport", () => { headers.push("X-QWP-Version: 1"); headers.push("X-QWP-Max-Batch-Size: 64"); headers.push("X-QuestDB-Role: primary"); + headers.push("X-QuestDB-Zone: eu-west-1a"); headers.push("X-QWP-Durable-Ack: enabled"); }); server.on("connection", (socket, request) => { @@ -118,6 +129,7 @@ describe("QWP Node transport", () => { maxBatchSizeBytes: 64, durableAckEnabled: true, serverRole: "primary", + serverZone: "eu-west-1a", }); expect(session.maxBatchSizeBytes).toBe(64); const ack = await session.sendFrame(Uint8Array.of(1)); @@ -197,6 +209,40 @@ describe("QWP Node transport", () => { } satisfies Partial); }); + it("routes egress to the requested role using SERVER_INFO", async () => { + const primary = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + const replica = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + primary.on("connection", (socket) => { + socket.send(serverInfo(QWP_SERVER_ROLE.PRIMARY, "zone-b")); + }); + replica.on("connection", (socket) => { + socket.send(serverInfo(QWP_SERVER_ROLE.REPLICA, "zone-a")); + }); + await Promise.all([listen(primary), listen(replica)]); + + const primaryAddress = primary.address() as AddressInfo; + const replicaAddress = replica.address() as AddressInfo; + const session = await connectQwpNodeEgress({ + url: `ws://127.0.0.1:${primaryAddress.port}/read/v1`, + failoverUrls: [`ws://127.0.0.1:${replicaAddress.port}/read/v1`], + target: "replica", + zone: "ZONE-A", + }); + try { + await expect(session.ready).resolves.toMatchObject({ + role: QWP_SERVER_ROLE.REPLICA, + zoneId: "zone-a", + }); + expect(session.handshake).toMatchObject({ + serverRole: "REPLICA", + serverZone: "zone-a", + }); + } finally { + await session.close(); + await Promise.all([closeServer(primary), closeServer(replica)]); + } + }); + it("fails over and replays an unacknowledged frame through the public Node API", async () => { const primary = new WebSocketServer({ host: "127.0.0.1", port: 0 }); const secondary = new WebSocketServer({ host: "127.0.0.1", port: 0 }); diff --git a/test/qwp/public-api-contract.ts b/test/qwp/public-api-contract.ts index 849db2e..47d0e1e 100644 --- a/test/qwp/public-api-contract.ts +++ b/test/qwp/public-api-contract.ts @@ -9,6 +9,7 @@ import { import type { QwpBrowserSessionBootstrapOptions, QwpBrowserSessionBootstrapResult, + QwpBrowserEgressOptions, QwpBrowserWebSocketOptions, } from "../../src/qwp/browser"; import { @@ -49,7 +50,7 @@ const browserIngressSignature: ( ) => Promise = connectQwpBrowserIngress; const browserEgressSignature: ( - options: QwpBrowserWebSocketOptions, + options: QwpBrowserEgressOptions, sessionOptions?: QwpEgressSessionOptions, ) => Promise = connectQwpBrowserEgress; @@ -90,6 +91,19 @@ const egressSessionOptionsContract: QwpEgressSessionOptions = { cancelDrainTimeoutMs: 5_000, }; +const browserEgressOptionsContract: QwpBrowserEgressOptions = { + url: "wss://node-1.example/read/v1", + failoverUrls: ["wss://node-2.example/read/v1"], + target: "replica", + zone: "eu-west-1a", +}; + +const nodeEgressOptionsContract: QwpNodeEgressOptions = { + url: "wss://node-1.example/read/v1", + failoverUrls: ["wss://node-2.example/read/v1"], + target: "primary", +}; + const qwpExtraOptionsContract: QwpExtraOptions = { webSocket: { requestDurableAck: true, @@ -118,5 +132,7 @@ void nodeEgressSignature; void nodeWebSocketSignature; void queryOptionsContract; void egressSessionOptionsContract; +void browserEgressOptionsContract; +void nodeEgressOptionsContract; void rootExtraOptionsContract; void Sender; diff --git a/test/qwp/public-api.test.ts b/test/qwp/public-api.test.ts index 6dca95e..b6fa32e 100644 --- a/test/qwp/public-api.test.ts +++ b/test/qwp/public-api.test.ts @@ -8,6 +8,7 @@ const sharedRuntimeContract = [ "QWP_INGRESS_PROGRESS_KIND", "QWP_DEFAULT_EGRESS_INITIAL_CREDIT", "QWP_RECONNECT_EVENT_KIND", + "QWP_TARGET", "QWP_UPGRADE_ERROR_KIND", "QWP_VERSION", "QwpBatchTooLargeError", @@ -24,6 +25,7 @@ const sharedRuntimeContract = [ "QwpIngressSession", "QwpProtocolError", "QwpReconnectExhaustedError", + "QwpRoleMismatchError", "QwpReplayRejectedError", "QwpResultBatch", "QwpSendTimeoutError", diff --git a/test/qwp/reconnect.test.ts b/test/qwp/reconnect.test.ts index 2a7aaa4..6cfe095 100644 --- a/test/qwp/reconnect.test.ts +++ b/test/qwp/reconnect.test.ts @@ -14,6 +14,7 @@ import { QWP_COLUMN_TYPE, QWP_EGRESS_CAPABILITY, QWP_EGRESS_MESSAGE, + QWP_SERVER_ROLE, QWP_STATUS, QWP_UPGRADE_ERROR_KIND, QwpBinaryConnection, @@ -39,6 +40,7 @@ import { writeQwpVarint, } from "../../src/qwp"; import { QwpAsyncQueue } from "../../src/qwp/internal/async-queue"; +import { createQwpEgressFailoverConnectionFactory } from "../../src/qwp/internal/egress-routing"; import { createQwpFailoverConnectionFactory } from "../../src/qwp/internal/failover"; function ingressResponse( @@ -79,15 +81,23 @@ function writeUint16String(writer: QwpByteWriter, value: string): void { writer.writeUint16(bytes.length).writeBytes(bytes); } -function serverInfo(node: string): Uint8Array { +function serverInfo( + node: string, + role = QWP_SERVER_ROLE.STANDALONE, + zone?: string, +): Uint8Array { + const capabilities = + QWP_EGRESS_CAPABILITY.QUERY_FLAGS | + (zone === undefined ? 0 : QWP_EGRESS_CAPABILITY.ZONE); const payload = new QwpByteWriter() .writeUint8(QWP_EGRESS_MESSAGE.SERVER_INFO) - .writeUint8(0) + .writeUint8(role) .writeBigUint64(1n) - .writeUint32(QWP_EGRESS_CAPABILITY.QUERY_FLAGS) + .writeUint32(capabilities) .writeBigInt64(123n); writeUint16String(payload, "cluster"); writeUint16String(payload, node); + if (zone !== undefined) writeUint16String(payload, zone); return encodeQwpFrame(payload.toUint8Array()); } @@ -216,9 +226,10 @@ class FailOnceDictionaryReplayStore extends TrackingReplayStore { } describe("QWP endpoint failover", () => { - it("walks all endpoints and rotates away from the last successful one", async () => { + it("keeps a healthy endpoint sticky until a mid-stream failure", async () => { const attempts: string[] = []; let primaryAvailable = false; + let lastSecondary: FakeConnection | undefined; const factory = createQwpFailoverConnectionFactory( "primary", ["secondary"], @@ -231,14 +242,136 @@ describe("QWP endpoint failover", () => { tryNextEndpoint: true, }); } - return new FakeConnection(String(endpoint)); + const connection = new FakeConnection(String(endpoint)); + if (endpoint === "secondary") lastSecondary = connection; + return connection; }, ); await expect(factory()).resolves.toMatchObject({ endpoint: "secondary" }); primaryAvailable = true; + const healthy = await factory(); + expect(healthy).toMatchObject({ endpoint: "secondary" }); + lastSecondary!.drop(); + await Promise.resolve(); await expect(factory()).resolves.toMatchObject({ endpoint: "primary" }); - expect(attempts).toEqual(["primary", "secondary", "primary"]); + expect(attempts).toEqual(["primary", "secondary", "secondary", "primary"]); + }); + + it("validates target roles and continues the same endpoint sweep", async () => { + const attempts: string[] = []; + const primary = new FakeConnection("primary", { + qwpVersion: 1, + serverRole: "PRIMARY", + serverZone: "eu-west-1b", + }); + const replica = new FakeConnection("replica", { + qwpVersion: 1, + serverRole: "REPLICA", + serverZone: "eu-west-1a", + }); + const factory = createQwpFailoverConnectionFactory( + "primary", + ["replica"], + async (endpoint) => { + attempts.push(String(endpoint)); + return endpoint === "primary" ? primary : replica; + }, + { target: "replica", zone: "EU-WEST-1A" }, + ); + + await expect(factory()).resolves.toMatchObject({ endpoint: "replica" }); + await expect(primary.closed).resolves.toMatchObject({ code: 1000 }); + expect(attempts).toEqual(["primary", "replica"]); + }); + + it("ranks health before zone and zone before endpoint order", async () => { + const attempts: string[] = []; + const factory = createQwpFailoverConnectionFactory( + "remote", + ["local"], + async (endpoint) => { + attempts.push(String(endpoint)); + return new FakeConnection(String(endpoint), { + qwpVersion: 1, + serverRole: "REPLICA", + serverZone: endpoint === "remote" ? "eu-west-1b" : "eu-west-1a", + }); + }, + { target: "replica", zone: "eu-west-1a" }, + ); + + await expect(factory()).resolves.toMatchObject({ endpoint: "remote" }); + await expect(factory()).resolves.toMatchObject({ endpoint: "remote" }); + expect(attempts).toEqual(["remote", "remote"]); + + const rejectedAttempts: string[] = []; + const rejected = createQwpFailoverConnectionFactory( + "remote", + ["local"], + async (endpoint) => { + rejectedAttempts.push(String(endpoint)); + throw new QwpUpgradeError("role rejected", { + kind: QWP_UPGRADE_ERROR_KIND.ROLE_REJECTED, + retryable: true, + tryNextEndpoint: true, + serverRole: "PRIMARY", + serverZone: endpoint === "remote" ? "eu-west-1b" : "eu-west-1a", + }); + }, + { target: "replica", zone: "eu-west-1a" }, + ); + await expect(rejected()).rejects.toBeDefined(); + rejectedAttempts.length = 0; + await expect(rejected()).rejects.toBeDefined(); + expect(rejectedAttempts).toEqual(["local", "remote"]); + }); + + it("demotes an endpoint when a send fails before the socket closes", async () => { + const attempts: string[] = []; + const primary = new FakeConnection("primary"); + vi.spyOn(primary, "send").mockRejectedValueOnce(new Error("send failed")); + const factory = createQwpFailoverConnectionFactory( + "primary", + ["secondary"], + async (endpoint) => { + attempts.push(String(endpoint)); + return endpoint === "primary" + ? primary + : new FakeConnection("secondary"); + }, + ); + + const connection = await factory(); + await expect(connection.send(Uint8Array.of(1))).rejects.toThrow( + "send failed", + ); + await expect(factory()).resolves.toMatchObject({ endpoint: "secondary" }); + expect(attempts).toEqual(["primary", "secondary"]); + }); + + it("uses SERVER_INFO for browser-compatible role validation", async () => { + const primary = new FakeConnection("primary"); + primary.receive(serverInfo("primary", QWP_SERVER_ROLE.PRIMARY, "zone-b")); + const replica = new FakeConnection("replica"); + replica.receive(serverInfo("replica", QWP_SERVER_ROLE.REPLICA, "zone-a")); + const factory = createQwpEgressFailoverConnectionFactory( + "primary", + ["replica"], + async (endpoint) => (endpoint === "primary" ? primary : replica), + { target: "replica", zone: "zone-a" }, + 100, + ); + + const connection = await factory(); + expect(connection.endpoint).toBe("replica"); + expect(connection.handshake).toMatchObject({ + serverRole: "REPLICA", + serverZone: "zone-a", + }); + const first = await connection.messages[Symbol.asyncIterator]().next(); + expect(first.done).toBe(false); + await expect(primary.closed).resolves.toMatchObject({ code: 1000 }); }); it("does not leak invalid credentials to another endpoint", async () => { From b765899dd185aa68e86b4ea057c0850c134f039c Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 18 Aug 2026 11:59:58 +0100 Subject: [PATCH 036/265] feat(qwp): add pooled client facade --- QWP.md | 83 +++++ README.md | 53 +-- src/qwp/browser.ts | 47 +++ src/qwp/client.ts | 558 ++++++++++++++++++++++++++++++++ src/qwp/egress-session.ts | 48 ++- src/qwp/index.ts | 1 + src/qwp/node.ts | 77 +++++ src/qwp/sender.ts | 18 ++ test/qwp/client.test.ts | 318 ++++++++++++++++++ test/qwp/node-transport.test.ts | 66 ++++ test/qwp/public-api-contract.ts | 15 + test/qwp/public-api.test.ts | 9 + 12 files changed, 1261 insertions(+), 32 deletions(-) create mode 100644 src/qwp/client.ts create mode 100644 test/qwp/client.test.ts diff --git a/QWP.md b/QWP.md index 191c808..9bb62c6 100644 --- a/QWP.md +++ b/QWP.md @@ -446,6 +446,86 @@ const session = await connectQwpBrowserEgress({ }); ``` +## Combined pooled client + +Use `QwpClient` when one long-lived application component needs both ingestion +and concurrent queries. The Node and browser entry points provide configured +factories; each borrowed handle exclusively owns one pooled WebSocket until its +`close()` returns it: + +```typescript +import { connectQwpNodeClient } from "@questdb/nodejs-client/qwp/node"; + +const db = await connectQwpNodeClient({ + ingress: { + url: "wss://questdb.example:9000/write/v4", + authorization: `Bearer ${token}`, + }, + egress: { + url: "wss://questdb.example:9000/read/v1", + authorization: `Bearer ${token}`, + target: "replica", + zone: "eu-west-1a", + }, + pool: { + senderPoolMin: 1, + senderPoolMax: 2, + queryPoolMin: 1, + queryPoolMax: 8, + acquireTimeoutMs: 5_000, + }, +}); + +try { + const sender = await db.borrowSender(); + try { + await sender.table("trades").symbol("symbol", "ETH-USD").atNow(); + } finally { + // Flushes completed rows and returns the sender; the socket stays pooled. + await sender.close(); + } + + const [prices, volumes] = await Promise.all([ + db.borrowQuery(), + db.borrowQuery(), + ]); + try { + // These use independent egress WebSockets and may execute concurrently. + const drain = async (lease, sql) => { + const query = await lease.query(sql); + for await (const batch of query) consume(batch); + await query.completion; + }; + await Promise.all([ + drain(prices, "select * from latest_prices"), + drain(volumes, "select * from hourly_volumes"), + ]); + } finally { + await Promise.all([prices.close(), volumes.close()]); + } +} finally { + await db.close(); +} +``` + +`connectQwpNodeClient()` and `connectQwpBrowserClient()` prewarm each configured +pool minimum. Their `createQwp*Client()` counterparts are lazy. Pools grow to +their maximum under concurrent borrows and apply one FIFO acquisition deadline; +exhaustion raises `QwpPoolAcquireTimeoutError`. Query handles are single-flight, +but separate borrowed handles run concurrently. Returning a handle with an active +query sends `CANCEL` and waits for the session's bounded cancellation drain; a +connection that cannot drain is closed instead of being handed to another borrower. +Call `QwpClient.close()` only after returning application-owned leases; shutdown +rejects queued borrowers and closes every pooled connection, including one still +leased by a caller. + +Pooled sender `close()` flushes completed rows, discards an unfinished row with a +warning, and resets staging before reuse. With Node store-and-forward enabled, the +configured directory is treated as a pool root and each stable sender slot owns a +`sender-N` child directory, avoiding journal lock conflicts. A connected pooled +client prewarms every persistent sender slot (overriding `senderPoolMin`) so journals +left by previously busy slots are recovered even when current traffic is lower. + ## Error handling and cleanup The public error classes preserve enough context for policy decisions: @@ -454,6 +534,9 @@ The public error classes preserve enough context for policy decisions: | ---------------------------------- | ----------------------------------------------------------------------------------------------------------- | | `QwpUpgradeError` | Classified authentication, role, version, capability, timeout, transport, or browser-opaque upgrade failure | | `QwpRoleMismatchError` | A connected endpoint's advertised role does not satisfy the requested egress target | +| `QwpPoolAcquireTimeoutError` | Every pooled connection is leased beyond the configured acquisition deadline | +| `QwpPoolResourceError` | Creating a new pooled sender or query connection failed | +| `QwpClientClosedError` | The pooled client or an individual returned lease is already closed | | `QwpDurableAckUnavailableError` | Durable acknowledgement was required but not negotiated | | `QwpSendTimeoutError` | A send did not drain before its deadline; delivery is unknown | | `QwpIngressNackError` | QuestDB rejected an ingress frame | diff --git a/README.md b/README.md index 1f76307..c2a7fda 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ pnpm add @questdb/nodejs-client ## Compatibility table | QuestDB client version | Supported Node.js versions | Default HTTP Agent | -|------------------------|----------------------------|---------------------| +| ---------------------- | -------------------------- | ------------------- | | ^4.0.0 | v20 and above | Undici Http Agent | | ^3.0.0 | v16 and above | Standard Http Agent | @@ -67,8 +67,9 @@ run().then(console.log).catch(console.error); ### QWP ingress from Node.js or a browser -See the [complete QWP guide](./QWP.md) for ingress and egress APIs, browser -authentication, delivery semantics, migration guidance, and the public API policy. +See the [complete QWP guide](./QWP.md) for ingress and egress APIs, the combined +pooled client, browser authentication, delivery semantics, migration guidance, and +the public API policy. Node.js applications can select QWP through the regular `Sender` API: @@ -323,7 +324,7 @@ async function run() { // pass the authentication details to the sender // for secure connection use 'https' protocol instead of 'http' const sender = await Sender.fromConfig( - `http::addr=127.0.0.1:9000;username=${USER};password=${PWD}` + `http::addr=127.0.0.1:9000;username=${USER};password=${PWD}`, ); // add rows to the buffer of the sender @@ -357,7 +358,7 @@ async function run() { // pass the authentication details to the sender // for secure connection use 'https' protocol instead of 'http' const sender = await Sender.fromConfig( - `http::addr=127.0.0.1:9000;token=${TOKEN}` + `http::addr=127.0.0.1:9000;token=${TOKEN}`, ); // add rows to the buffer of the sender @@ -391,7 +392,7 @@ async function run() { // pass the authentication details to the sender const sender = await Sender.fromConfig( - `tcp::addr=127.0.0.1:9009;username=${CLIENT_ID};token=${PRIVATE_KEY}` + `tcp::addr=127.0.0.1:9009;username=${CLIENT_ID};token=${PRIVATE_KEY}`, ); await sender.connect(); @@ -421,42 +422,42 @@ import { Sender } from "@questdb/nodejs-client"; async function run() { // create a sender - const sender = await Sender.fromConfig('http::addr=localhost:9000'); + const sender = await Sender.fromConfig("http::addr=localhost:9000"); // order book snapshots to ingest const orderBooks = [ { - symbol: 'BTC-USD', - exchange: 'Coinbase', + symbol: "BTC-USD", + exchange: "Coinbase", timestamp: Date.now(), - bidPrices: [50100.25, 50100.20, 50100.15, 50100.10, 50100.05], + bidPrices: [50100.25, 50100.2, 50100.15, 50100.1, 50100.05], bidSizes: [0.5, 1.2, 2.1, 0.8, 3.5], - askPrices: [50100.30, 50100.35, 50100.40, 50100.45, 50100.50], - askSizes: [0.6, 1.5, 1.8, 2.2, 4.0] + askPrices: [50100.3, 50100.35, 50100.4, 50100.45, 50100.5], + askSizes: [0.6, 1.5, 1.8, 2.2, 4.0], }, { - symbol: 'ETH-USD', - exchange: 'Coinbase', + symbol: "ETH-USD", + exchange: "Coinbase", timestamp: Date.now(), - bidPrices: [2850.50, 2850.45, 2850.40, 2850.35, 2850.30], + bidPrices: [2850.5, 2850.45, 2850.4, 2850.35, 2850.3], bidSizes: [5.0, 8.2, 12.5, 6.8, 15.0], - askPrices: [2850.55, 2850.60, 2850.65, 2850.70, 2850.75], - askSizes: [4.5, 7.8, 10.2, 8.5, 20.0] - } + askPrices: [2850.55, 2850.6, 2850.65, 2850.7, 2850.75], + askSizes: [4.5, 7.8, 10.2, 8.5, 20.0], + }, ]; try { // add rows to the buffer of the sender for (const orderBook of orderBooks) { await sender - .table('order_book_l2') - .symbol('symbol', orderBook.symbol) - .symbol('exchange', orderBook.exchange) - .arrayColumn('bid_prices', orderBook.bidPrices) - .arrayColumn('bid_sizes', orderBook.bidSizes) - .arrayColumn('ask_prices', orderBook.askPrices) - .arrayColumn('ask_sizes', orderBook.askSizes) - .at(orderBook.timestamp, 'ms'); + .table("order_book_l2") + .symbol("symbol", orderBook.symbol) + .symbol("exchange", orderBook.exchange) + .arrayColumn("bid_prices", orderBook.bidPrices) + .arrayColumn("bid_sizes", orderBook.bidSizes) + .arrayColumn("ask_prices", orderBook.askPrices) + .arrayColumn("ask_sizes", orderBook.askSizes) + .at(orderBook.timestamp, "ms"); } // flush the buffer of the sender, sending the data to QuestDB diff --git a/src/qwp/browser.ts b/src/qwp/browser.ts index bcf90d3..3168663 100644 --- a/src/qwp/browser.ts +++ b/src/qwp/browser.ts @@ -25,6 +25,7 @@ import { import { QwpEgressSession, QwpEgressSessionOptions } from "./egress-session"; import { QwpIngressSession, QwpIngressSessionOptions } from "./ingress-session"; import { QwpSender, QwpSenderOptions } from "./sender"; +import { QwpClient, QwpClientPoolOptions } from "./client"; export type { QwpWebSocketLike } from "./internal/websocket-connection"; @@ -280,6 +281,16 @@ export interface QwpBrowserEgressOptions extends QwpBrowserWebSocketOptions, QwpEgressRoutingOptions {} +/** Browser configuration for a combined pooled QWP ingress/egress client. */ +export interface QwpBrowserClientOptions { + ingress: QwpBrowserWebSocketOptions; + egress: QwpBrowserEgressOptions; + sender?: QwpSenderOptions; + ingressSession?: QwpIngressSessionOptions; + egressSession?: QwpEgressSessionOptions; + pool?: QwpClientPoolOptions; +} + /** * Opens a QWP-capable browser WebSocket. * @@ -424,3 +435,39 @@ export async function connectQwpBrowserEgress( sessionOptions, ); } + +/** Creates a lazy browser QWP client with bounded sender and query pools. */ +export function createQwpBrowserClient( + options: QwpBrowserClientOptions, +): QwpClient { + return new QwpClient( + { + createSender: async () => { + const sender = createQwpBrowserSender( + options.ingress, + options.sender, + options.ingressSession, + ); + try { + await sender.connect(); + return sender; + } catch (error) { + await sender.close().catch(() => undefined); + throw error; + } + }, + createQuerySession: () => + connectQwpBrowserEgress(options.egress, options.egressSession), + }, + options.pool, + ); +} + +/** Creates and prewarms a combined browser QWP ingress/egress client. */ +export async function connectQwpBrowserClient( + options: QwpBrowserClientOptions, +): Promise { + const client = createQwpBrowserClient(options); + await client.connect(); + return client; +} diff --git a/src/qwp/client.ts b/src/qwp/client.ts new file mode 100644 index 0000000..b2160ce --- /dev/null +++ b/src/qwp/client.ts @@ -0,0 +1,558 @@ +import { + QwpEgressQuery, + QwpEgressQueryOptions, + QwpEgressSession, +} from "./egress-session"; +import { QwpSender } from "./sender"; +import { QwpHandshakeMetadata } from "./transport"; +import type { + QwpNegotiatedEgressCompression, + QwpServerInfoMessage, +} from "./core"; + +const DEFAULT_POOL_MIN = 1; +const DEFAULT_POOL_MAX = 4; +const DEFAULT_ACQUIRE_TIMEOUT_MS = 5_000; +const MAX_CLOSE_CREATION_WAIT_MS = 5_000; + +export interface QwpClientPoolOptions { + /** Warm ingress connections created by connect(). Defaults to 1. */ + senderPoolMin?: number; + /** Maximum concurrently borrowed ingress senders. Defaults to 4. */ + senderPoolMax?: number; + /** Warm egress connections created by connect(). Defaults to 1. */ + queryPoolMin?: number; + /** Maximum concurrently borrowed query connections. Defaults to 4. */ + queryPoolMax?: number; + /** Maximum wait for a returned pool slot. Defaults to 5 seconds. */ + acquireTimeoutMs?: number; +} + +export interface QwpClientFactories { + createSender(slot: number): Promise; + createQuerySession(slot: number): Promise; +} + +export interface QwpResourcePoolMetrics { + readonly minimum: number; + readonly maximum: number; + readonly total: number; + readonly available: number; + readonly leased: number; + readonly creating: number; + readonly waiting: number; +} + +export interface QwpClientMetrics { + readonly senders: QwpResourcePoolMetrics; + readonly queries: QwpResourcePoolMetrics; + readonly closing: boolean; + readonly closed: boolean; +} + +/** A bounded QWP pool could not provide a connection before its deadline. */ +export class QwpPoolAcquireTimeoutError extends Error { + constructor( + readonly resource: "sender" | "query", + readonly timeoutMs: number, + ) { + super( + `timed out waiting for a QWP ${resource} from the pool after ${timeoutMs}ms`, + ); + this.name = "QwpPoolAcquireTimeoutError"; + } +} + +/** A pooled resource failed while a new slot was being connected. */ +export class QwpPoolResourceError extends Error { + readonly cause: unknown; + + constructor( + readonly resource: "sender" | "query", + cause: unknown, + ) { + super( + `failed to create pooled QWP ${resource}${ + cause instanceof Error ? `: ${cause.message}` : "" + }`, + ); + this.name = "QwpPoolResourceError"; + this.cause = cause; + } +} + +/** The owning QWP client, or one of its returned lease handles, is closed. */ +export class QwpClientClosedError extends Error { + constructor(message = "QWP client is closed") { + super(message); + this.name = "QwpClientClosedError"; + } +} + +interface ValidatedPoolOptions { + readonly senderPoolMin: number; + readonly senderPoolMax: number; + readonly queryPoolMin: number; + readonly queryPoolMax: number; + readonly acquireTimeoutMs: number; +} + +interface PoolEntry { + readonly slot: number; + readonly value: T; + leased: boolean; + destroyPromise?: Promise; +} + +interface PoolWaiter { + readonly resolve: () => void; + readonly reject: (error: unknown) => void; + readonly timer?: ReturnType; +} + +class QwpResourcePool { + private readonly all = new Map>(); + private readonly available: PoolEntry[] = []; + private readonly creatingSlots = new Set(); + private readonly creationOperations = new Set>(); + private readonly waiters = new Set(); + private closePromise?: Promise; + private closed = false; + + constructor( + private readonly resource: "sender" | "query", + private readonly minimum: number, + private readonly maximum: number, + private readonly acquireTimeoutMs: number, + private readonly createResource: (slot: number) => Promise, + private readonly destroyResource: (resource: T) => Promise, + ) {} + + get metrics(): QwpResourcePoolMetrics { + return Object.freeze({ + minimum: this.minimum, + maximum: this.maximum, + total: this.all.size, + available: this.available.length, + leased: Array.from(this.all.values()).filter((entry) => entry.leased) + .length, + creating: this.creatingSlots.size, + waiting: this.waiters.size, + }); + } + + async prewarm(): Promise { + const needed = Math.max( + 0, + this.minimum - this.all.size - this.creatingSlots.size, + ); + const acquired = await Promise.allSettled( + Array.from({ length: needed }, () => this.acquire()), + ); + await Promise.all( + acquired.map((result) => + result.status === "fulfilled" + ? this.release(result.value, true) + : Promise.resolve(), + ), + ); + const failure = acquired.find( + (result): result is PromiseRejectedResult => result.status === "rejected", + ); + if (failure) throw failure.reason; + } + + async acquire(): Promise> { + const deadline = Date.now() + this.acquireTimeoutMs; + while (true) { + this.throwIfClosed(); + const available = this.available.shift(); + if (available) { + available.leased = true; + return available; + } + const slot = this.reserveSlot(); + if (slot !== undefined) return this.createLeased(slot); + const remaining = deadline - Date.now(); + if (remaining <= 0) { + throw new QwpPoolAcquireTimeoutError( + this.resource, + this.acquireTimeoutMs, + ); + } + await this.waitForChange(remaining); + } + } + + async release(entry: PoolEntry, reusable: boolean): Promise { + if (!entry.leased) return; + entry.leased = false; + if (this.closed || !reusable || this.all.get(entry.slot) !== entry) { + if (this.all.get(entry.slot) === entry) this.all.delete(entry.slot); + await this.destroy(entry); + this.wakeWaiters(); + return; + } + this.available.push(entry); + this.wakeWaiters(); + } + + close(): Promise { + if (!this.closePromise) this.closePromise = this.closeNow(); + return this.closePromise; + } + + private async closeNow(): Promise { + if (this.closed) return; + this.closed = true; + for (const waiter of this.waiters) { + if (waiter.timer) clearTimeout(waiter.timer); + waiter.reject(new QwpClientClosedError()); + } + this.waiters.clear(); + const entries = Array.from(this.all.values()); + this.all.clear(); + this.available.length = 0; + await Promise.all(entries.map((entry) => this.destroy(entry))); + const creations = Array.from(this.creationOperations); + if (creations.length === 0) return; + const waitMs = Math.min(this.acquireTimeoutMs, MAX_CLOSE_CREATION_WAIT_MS); + let timer: ReturnType | undefined; + try { + await Promise.race([ + Promise.allSettled(creations), + new Promise((resolve) => { + timer = setTimeout(resolve, waitMs); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } + } + + private reserveSlot(): number | undefined { + if (this.all.size + this.creatingSlots.size >= this.maximum) { + return undefined; + } + for (let slot = 0; slot < this.maximum; slot++) { + if (!this.all.has(slot) && !this.creatingSlots.has(slot)) { + this.creatingSlots.add(slot); + return slot; + } + } + return undefined; + } + + private async createLeased(slot: number): Promise> { + let finishCreation!: () => void; + const operation = new Promise((resolve) => { + finishCreation = resolve; + }); + this.creationOperations.add(operation); + try { + let value: T; + try { + value = await this.createResource(slot); + } catch (error) { + throw new QwpPoolResourceError(this.resource, error); + } + if (this.closed) { + await this.destroyResource(value).catch(() => undefined); + throw new QwpClientClosedError(); + } + const entry: PoolEntry = { slot, value, leased: true }; + this.all.set(slot, entry); + return entry; + } finally { + this.creatingSlots.delete(slot); + finishCreation(); + this.creationOperations.delete(operation); + this.wakeWaiters(); + } + } + + private waitForChange(timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.waiters.delete(waiter); + reject( + new QwpPoolAcquireTimeoutError(this.resource, this.acquireTimeoutMs), + ); + }, timeoutMs); + const waiter: PoolWaiter = { resolve, reject, timer }; + this.waiters.add(waiter); + }); + } + + private wakeWaiters(): void { + for (const waiter of this.waiters) { + this.waiters.delete(waiter); + if (waiter.timer) clearTimeout(waiter.timer); + waiter.resolve(); + } + } + + private destroy(entry: PoolEntry): Promise { + if (!entry.destroyPromise) { + entry.destroyPromise = this.destroyResource(entry.value).catch( + () => undefined, + ); + } + return entry.destroyPromise; + } + + private throwIfClosed(): void { + if (this.closed) throw new QwpClientClosedError(); + } +} + +/** One exclusively borrowed egress session from a QwpClient query pool. */ +export class QwpQueryLease { + private closePromise?: Promise; + private released = false; + + /** SERVER_INFO for the endpoint selected by this pooled session. */ + readonly ready: Promise; + + /** @internal */ + constructor( + private readonly session: QwpEgressSession, + private readonly releaseSession: (reusable: boolean) => Promise, + ) { + this.ready = session.ready; + } + + get handshake(): QwpHandshakeMetadata { + this.throwIfReleased(); + return this.session.handshake; + } + + get negotiatedCompression(): QwpNegotiatedEgressCompression | undefined { + this.throwIfReleased(); + return this.session.negotiatedCompression; + } + + get negotiatedZstdLevel(): number { + this.throwIfReleased(); + return this.session.negotiatedZstdLevel; + } + + query( + sql: string, + options: QwpEgressQueryOptions = {}, + ): Promise { + this.throwIfReleased(); + return this.session.query(sql, options); + } + + close(): Promise { + if (!this.closePromise) this.closePromise = this.closeNow(); + return this.closePromise; + } + + private async closeNow(): Promise { + if (this.released) return; + this.released = true; + let reusable = false; + try { + reusable = await this.session.prepareForPoolRelease(); + } finally { + await this.releaseSession(reusable); + } + } + + private throwIfReleased(): void { + if (this.released) { + throw new QwpClientClosedError("QWP query lease is closed"); + } + } +} + +/** + * Browser-safe facade owning bounded ingress and egress connection pools. + * Borrowed handles are exclusive; separate query leases execute concurrently. + */ +export class QwpClient { + private readonly senderPool: QwpResourcePool; + private readonly queryPool: QwpResourcePool; + private connectPromise?: Promise; + private closePromise?: Promise; + private closing = false; + private closed = false; + + constructor( + factories: QwpClientFactories, + options: QwpClientPoolOptions = {}, + ) { + const validated = validatePoolOptions(options); + this.senderPool = new QwpResourcePool( + "sender", + validated.senderPoolMin, + validated.senderPoolMax, + validated.acquireTimeoutMs, + factories.createSender, + (sender) => sender.close(), + ); + this.queryPool = new QwpResourcePool( + "query", + validated.queryPoolMin, + validated.queryPoolMax, + validated.acquireTimeoutMs, + factories.createQuerySession, + (session) => session.close(), + ); + } + + /** Pre-connects the configured minimum sender and query pool sizes. */ + connect(): Promise { + if (!this.connectPromise) this.connectPromise = this.connectNow(); + return this.connectPromise; + } + + get metrics(): QwpClientMetrics { + return Object.freeze({ + senders: this.senderPool.metrics, + queries: this.queryPool.metrics, + closing: this.closing, + closed: this.closed, + }); + } + + /** Borrows an exclusive fluent sender; close() flushes and returns its slot. */ + async borrowSender(): Promise { + this.throwIfUnavailable(); + const entry = await this.senderPool.acquire(); + return createSenderLease(entry.value, async (reusable) => { + await this.senderPool.release(entry, reusable); + }); + } + + /** Borrows one exclusive egress connection for one or more serial queries. */ + async borrowQuery(): Promise { + this.throwIfUnavailable(); + const entry = await this.queryPool.acquire(); + return new QwpQueryLease(entry.value, async (reusable) => { + await this.queryPool.release(entry, reusable); + }); + } + + close(): Promise { + if (!this.closePromise) this.closePromise = this.closeNow(); + return this.closePromise; + } + + private async connectNow(): Promise { + this.throwIfUnavailable(); + try { + await Promise.all([this.senderPool.prewarm(), this.queryPool.prewarm()]); + return this; + } catch (error) { + await this.close(); + throw error; + } + } + + private async closeNow(): Promise { + if (this.closed) return; + this.closing = true; + await Promise.all([this.queryPool.close(), this.senderPool.close()]); + this.closed = true; + } + + private throwIfUnavailable(): void { + if (this.closing || this.closed) throw new QwpClientClosedError(); + } +} + +function createSenderLease( + sender: QwpSender, + releaseSender: (reusable: boolean) => Promise, +): QwpSender { + let released = false; + let closePromise: Promise | undefined; + const methods = new Map unknown>(); + + const release = (): Promise => { + if (closePromise) return closePromise; + released = true; + closePromise = (async () => { + let reusable = false; + let releaseError: unknown; + try { + await sender.prepareForPoolRelease(); + reusable = true; + } catch (error) { + releaseError = error; + } finally { + await releaseSender(reusable); + } + if (releaseError) throw releaseError; + })(); + return closePromise; + }; + + const proxy = new Proxy(sender, { + get(target, property) { + if (property === "close") return release; + if (released) { + throw new QwpClientClosedError("QWP sender lease is closed"); + } + const value = Reflect.get(target, property, target); + if (typeof value !== "function") return value; + let wrapped = methods.get(property); + if (!wrapped) { + wrapped = (...args: unknown[]) => { + if (released) { + throw new QwpClientClosedError("QWP sender lease is closed"); + } + const result = Reflect.apply(value, target, args); + return result === target ? proxy : result; + }; + methods.set(property, wrapped); + } + return wrapped; + }, + }); + return proxy; +} + +function validatePoolOptions( + options: QwpClientPoolOptions, +): ValidatedPoolOptions { + const validated: ValidatedPoolOptions = { + senderPoolMin: options.senderPoolMin ?? DEFAULT_POOL_MIN, + senderPoolMax: options.senderPoolMax ?? DEFAULT_POOL_MAX, + queryPoolMin: options.queryPoolMin ?? DEFAULT_POOL_MIN, + queryPoolMax: options.queryPoolMax ?? DEFAULT_POOL_MAX, + acquireTimeoutMs: options.acquireTimeoutMs ?? DEFAULT_ACQUIRE_TIMEOUT_MS, + }; + validatePoolBounds( + validated.senderPoolMin, + validated.senderPoolMax, + "sender", + ); + validatePoolBounds(validated.queryPoolMin, validated.queryPoolMax, "query"); + if ( + !Number.isFinite(validated.acquireTimeoutMs) || + validated.acquireTimeoutMs < 0 + ) { + throw new RangeError("acquireTimeoutMs must be a non-negative number"); + } + return validated; +} + +function validatePoolBounds( + minimum: number, + maximum: number, + resource: string, +): void { + if (!Number.isSafeInteger(minimum) || minimum < 0) { + throw new RangeError(`${resource}PoolMin must be a non-negative integer`); + } + if (!Number.isSafeInteger(maximum) || maximum < 1) { + throw new RangeError(`${resource}PoolMax must be a positive integer`); + } + if (minimum > maximum) { + throw new RangeError(`${resource}PoolMin cannot exceed ${resource}PoolMax`); + } +} diff --git a/src/qwp/egress-session.ts b/src/qwp/egress-session.ts index 70c6abf..0058ee4 100644 --- a/src/qwp/egress-session.ts +++ b/src/qwp/egress-session.ts @@ -367,6 +367,7 @@ export class QwpEgressSession implements QwpEgressQueryControl { private readonly defaultQueryTimeoutMs: number; private readonly defaultInitialCredit: number | bigint; private readonly cancelDrainTimeoutMs: number; + private readonly idleWaiters = new Set<() => void>(); private active?: QwpEgressQuery; private nextRequestId = 0n; private sendTail: Promise = Promise.resolve(); @@ -540,7 +541,7 @@ export class QwpEgressSession implements QwpEgressQueryControl { try { await this.send(encodeQwpQueryRequest(request)); } catch (error) { - if (this.active === query) this.active = undefined; + this.clearActive(query); query.fail(error); throw error; } @@ -591,6 +592,28 @@ export class QwpEgressSession implements QwpEgressQueryControl { return this.closePromise; } + /** + * Cancels and drains an active operation before a pooled lease is returned. + * False means the physical session is no longer safe to reuse. + * + * @internal + */ + async prepareForPoolRelease(): Promise { + if (this.failure || this.closing) return false; + const active = this.active; + if (!active) return true; + const idle = this.waitUntilIdle(); + if (!active.retired) { + try { + await this.abandon(active.requestId); + } catch (error) { + this.fail(error); + } + } + await idle; + return !this.failure && !this.closing; + } + private async closeNow(code: number, reason: string): Promise { this.closing = true; clearTimeout(this.serverInfoTimer); @@ -598,7 +621,7 @@ export class QwpEgressSession implements QwpEgressQueryControl { const error = new QwpEgressSessionClosedError(); this.rejectServerInfo(error); this.active?.fail(error); - this.active = undefined; + this.clearActive(); let transportClose: Promise; try { transportClose = this.connection.close(code, reason); @@ -647,21 +670,21 @@ export class QwpEgressSession implements QwpEgressQueryControl { } case "result-end": { const query = this.requireActive(message.requestId); - this.active = undefined; + this.clearActive(query); this.clearCancelDrain(message.requestId); query.finish(message); break; } case "exec-done": { const query = this.requireActive(message.requestId); - this.active = undefined; + this.clearActive(query); this.clearCancelDrain(message.requestId); query.finish(message); break; } case "query-error": { const query = this.requireActive(message.requestId); - this.active = undefined; + this.clearActive(query); this.clearCancelDrain(message.requestId); query.fail( new QwpEgressQueryError( @@ -787,6 +810,19 @@ export class QwpEgressSession implements QwpEgressQueryControl { if (this.closing) throw new QwpEgressSessionClosedError(); } + private clearActive(expected?: QwpEgressQuery): void { + if (expected && this.active !== expected) return; + if (!this.active) return; + this.active = undefined; + for (const resolve of this.idleWaiters) resolve(); + this.idleWaiters.clear(); + } + + private waitUntilIdle(): Promise { + if (!this.active) return Promise.resolve(); + return new Promise((resolve) => this.idleWaiters.add(resolve)); + } + private fail(error: unknown): void { if (this.failure) return; clearTimeout(this.serverInfoTimer); @@ -795,6 +831,6 @@ export class QwpEgressSession implements QwpEgressQueryControl { error instanceof Error ? error : new Error(`QWP egress failed: ${error}`); this.rejectServerInfo(this.failure); this.active?.fail(this.failure); - this.active = undefined; + this.clearActive(); } } diff --git a/src/qwp/index.ts b/src/qwp/index.ts index 22efcf4..460f765 100644 --- a/src/qwp/index.ts +++ b/src/qwp/index.ts @@ -7,6 +7,7 @@ * @packageDocumentation */ export * from "./core"; +export * from "./client"; export * from "./egress-session"; export * from "./ingress-session"; export * from "./sender"; diff --git a/src/qwp/node.ts b/src/qwp/node.ts index cdda9ae..3836bce 100644 --- a/src/qwp/node.ts +++ b/src/qwp/node.ts @@ -3,6 +3,7 @@ export * from "./index"; import type { Agent } from "node:http"; import type { IncomingHttpHeaders } from "node:http"; +import { join } from "node:path"; import WebSocket from "ws"; import { decodeQwpContentEncoding, @@ -30,6 +31,7 @@ import { import { QwpEgressSession, QwpEgressSessionOptions } from "./egress-session"; import { QwpIngressSession, QwpIngressSessionOptions } from "./ingress-session"; import { QwpSender, QwpSenderOptions } from "./sender"; +import { QwpClient, QwpClientPoolOptions } from "./client"; import { QwpNodeFileReplayStore } from "../qwp-node/file-replay-store"; import type { QwpNodeFileReplayStoreOptions } from "../qwp-node/file-replay-store"; @@ -167,6 +169,16 @@ export interface QwpNodeEgressOptions compressionLevel?: number; } +/** Node configuration for a combined pooled QWP ingress/egress client. */ +export interface QwpNodeClientOptions { + ingress: QwpNodeIngressOptions; + egress: QwpNodeEgressOptions; + sender?: QwpSenderOptions; + ingressSession?: QwpIngressSessionOptions; + egressSession?: QwpEgressSessionOptions; + pool?: QwpClientPoolOptions; +} + function egressTransportOptions( options: QwpNodeEgressOptions, ): QwpNodeWebSocketOptions { @@ -429,3 +441,68 @@ export async function connectQwpNodeEgress( sessionOptions, ); } + +/** Creates a lazy Node QWP client with bounded sender and query pools. */ +export function createQwpNodeClient(options: QwpNodeClientOptions): QwpClient { + return new QwpClient( + { + createSender: async (slot) => { + const ingress = pooledNodeIngressOptions(options.ingress, slot); + const sender = createQwpNodeSender( + ingress, + options.sender, + options.ingressSession, + ); + try { + await sender.connect(); + return sender; + } catch (error) { + await sender.close().catch(() => undefined); + throw error; + } + }, + createQuerySession: () => + connectQwpNodeEgress(options.egress, options.egressSession), + }, + pooledNodeClientOptions(options), + ); +} + +/** Creates and prewarms a combined Node QWP ingress/egress client. */ +export async function connectQwpNodeClient( + options: QwpNodeClientOptions, +): Promise { + const client = createQwpNodeClient(options); + await client.connect(); + return client; +} + +function pooledNodeClientOptions( + options: QwpNodeClientOptions, +): QwpClientPoolOptions | undefined { + if (!options.ingress.storeAndForward) return options.pool; + const senderPoolMax = options.pool?.senderPoolMax ?? 4; + return { + ...options.pool, + senderPoolMin: senderPoolMax, + senderPoolMax, + }; +} + +function pooledNodeIngressOptions( + options: QwpNodeIngressOptions, + slot: number, +): QwpNodeIngressOptions { + if (!options.storeAndForward) return options; + const rootDirectory = options.storeAndForward.directory.trim(); + if (!rootDirectory) { + throw new RangeError("storeAndForward directory must not be empty"); + } + return { + ...options, + storeAndForward: { + ...options.storeAndForward, + directory: join(rootDirectory, `sender-${slot}`), + }, + }; +} diff --git a/src/qwp/sender.ts b/src/qwp/sender.ts index 10ec051..4ad2f80 100644 --- a/src/qwp/sender.ts +++ b/src/qwp/sender.ts @@ -803,6 +803,24 @@ export class QwpSender { return this.closePromise; } + /** + * Flushes completed rows and resets borrower-local staging without closing + * the physical session. Used by the pooled QWP client when a lease returns. + * + * @internal + */ + async prepareForPoolRelease(): Promise { + this.throwIfUnavailable(); + await this.flush(); + if (this.currentRow.size > 0) { + this.log( + "warn", + `QWP pooled sender is releasing an unfinished row with ${this.currentRow.size} column(s); the row will be discarded`, + ); + } + this.reset(); + } + private async closeNow(): Promise { if (this.closed) return; this.closing = true; diff --git a/test/qwp/client.test.ts b/test/qwp/client.test.ts new file mode 100644 index 0000000..726ba86 --- /dev/null +++ b/test/qwp/client.test.ts @@ -0,0 +1,318 @@ +import { describe, expect, it } from "vitest"; +import { + encodeQwpFrame, + QWP_EGRESS_MESSAGE, + QWP_SERVER_ROLE, + QWP_STATUS, + QwpBinaryConnection, + QwpByteWriter, + QwpClient, + QwpClientClosedError, + QwpConnectionCloseInfo, + QwpEgressSession, + QwpEgressSessionOptions, + QwpHandshakeMetadata, + QwpIngressResponse, + QwpPoolAcquireTimeoutError, + QwpSender, + QwpSenderSession, +} from "../../src/qwp"; +import { QwpAsyncQueue } from "../../src/qwp/internal/async-queue"; + +function writeString(writer: QwpByteWriter, value: string): void { + const encoded = new TextEncoder().encode(value); + writer.writeUint16(encoded.length).writeBytes(encoded); +} + +function serverInfo(nodeId: string): Uint8Array { + const payload = new QwpByteWriter() + .writeUint8(QWP_EGRESS_MESSAGE.SERVER_INFO) + .writeUint8(QWP_SERVER_ROLE.STANDALONE) + .writeBigUint64(1n) + .writeUint32(0) + .writeBigInt64(123n); + writeString(payload, "cluster"); + writeString(payload, nodeId); + return encodeQwpFrame(payload.toUint8Array()); +} + +function queryError(requestId: bigint): Uint8Array { + const payload = new QwpByteWriter() + .writeUint8(QWP_EGRESS_MESSAGE.QUERY_ERROR) + .writeBigUint64(requestId) + .writeUint8(QWP_STATUS.CANCELLED); + writeString(payload, "cancelled"); + return encodeQwpFrame(payload.toUint8Array()); +} + +class FakeConnection implements QwpBinaryConnection { + readonly handshake: QwpHandshakeMetadata = { qwpVersion: 1 }; + readonly messages: AsyncIterable; + readonly sent: Uint8Array[] = []; + readonly closed: Promise; + private readonly incoming = new QwpAsyncQueue(); + private readonly resolveClosed: (info: QwpConnectionCloseInfo) => void; + private closedSettled = false; + + constructor(readonly endpoint: string) { + this.messages = this.incoming; + let resolveClosed!: (info: QwpConnectionCloseInfo) => void; + this.closed = new Promise((resolve) => { + resolveClosed = resolve; + }); + this.resolveClosed = resolveClosed; + } + + send(payload: Uint8Array): Promise { + this.sent.push(payload.slice()); + return Promise.resolve(); + } + + close(code = 1000, reason = ""): Promise { + if (!this.closedSettled) { + this.closedSettled = true; + this.incoming.end(); + this.resolveClosed({ code, reason, wasClean: code === 1000 }); + } + return Promise.resolve(); + } + + receive(payload: Uint8Array): void { + this.incoming.push(payload); + } +} + +class FakeSenderSession implements QwpSenderSession { + flushes = 0; + closes = 0; + + sendTables(): Promise { + this.flushes++; + return Promise.resolve({ + status: QWP_STATUS.OK, + sequence: BigInt(this.flushes - 1), + tables: [], + }); + } + + waitForDurable(): Promise { + return Promise.resolve(); + } + + close(): Promise { + this.closes++; + return Promise.resolve(); + } +} + +async function createQuerySession( + slot: number, + connections: FakeConnection[], + options: QwpEgressSessionOptions = {}, +): Promise { + const connection = new FakeConnection(`query-${slot}`); + connections.push(connection); + const session = new QwpEgressSession(connection, options); + connection.receive(serverInfo(`node-${slot}`)); + await session.ready; + return session; +} + +describe("QWP pooled client", () => { + it("flushes and reuses an exclusively borrowed sender", async () => { + const senderSessions: FakeSenderSession[] = []; + let senderCreations = 0; + const client = new QwpClient( + { + createSender: async () => { + senderCreations++; + const session = new FakeSenderSession(); + senderSessions.push(session); + const sender = new QwpSender(async () => session, { + autoFlush: false, + }); + await sender.connect(); + return sender; + }, + createQuerySession: async () => { + throw new Error("query factory should not run"); + }, + }, + { + senderPoolMin: 1, + senderPoolMax: 1, + queryPoolMin: 0, + queryPoolMax: 1, + }, + ); + await client.connect(); + + const first = await client.borrowSender(); + await first.table("trades").symbol("symbol", "ETH-USD").atNow(); + await first.close(); + expect(senderSessions[0].flushes).toBe(1); + expect(senderSessions[0].closes).toBe(0); + expect(() => first.table("late")).toThrow(QwpClientClosedError); + + const second = await client.borrowSender(); + expect(senderCreations).toBe(1); + await second.close(); + expect(client.metrics.senders).toMatchObject({ + total: 1, + available: 1, + leased: 0, + }); + + await client.close(); + expect(senderSessions[0].closes).toBe(1); + }); + + it("runs independently borrowed query connections concurrently", async () => { + const connections: FakeConnection[] = []; + let queryCreations = 0; + const client = new QwpClient( + { + createSender: async () => { + throw new Error("sender factory should not run"); + }, + createQuerySession: async (slot) => { + queryCreations++; + return createQuerySession(slot, connections); + }, + }, + { + senderPoolMin: 0, + senderPoolMax: 1, + queryPoolMin: 0, + queryPoolMax: 2, + acquireTimeoutMs: 500, + }, + ); + + const [first, second] = await Promise.all([ + client.borrowQuery(), + client.borrowQuery(), + ]); + const [firstInfo, secondInfo] = await Promise.all([ + first.ready, + second.ready, + ]); + expect(new Set([firstInfo.nodeId, secondInfo.nodeId])).toEqual( + new Set(["node-0", "node-1"]), + ); + expect(queryCreations).toBe(2); + + let thirdResolved = false; + const thirdBorrow = client.borrowQuery().then((lease) => { + thirdResolved = true; + return lease; + }); + await Promise.resolve(); + expect(thirdResolved).toBe(false); + await first.close(); + const third = await thirdBorrow; + expect(queryCreations).toBe(2); + expect(client.metrics.queries.leased).toBe(2); + + await Promise.all([second.close(), third.close()]); + await client.close(); + expect(connections).toHaveLength(2); + }); + + it("times out when every query connection is leased", async () => { + const connections: FakeConnection[] = []; + const client = new QwpClient( + { + createSender: async () => { + throw new Error("sender factory should not run"); + }, + createQuerySession: (slot) => createQuerySession(slot, connections), + }, + { + senderPoolMin: 0, + senderPoolMax: 1, + queryPoolMin: 0, + queryPoolMax: 1, + acquireTimeoutMs: 10, + }, + ); + const lease = await client.borrowQuery(); + await expect(client.borrowQuery()).rejects.toMatchObject({ + name: "QwpPoolAcquireTimeoutError", + resource: "query", + timeoutMs: 10, + } satisfies Partial); + await lease.close(); + await client.close(); + }); + + it("cancels and drains an active query before returning its connection", async () => { + const connections: FakeConnection[] = []; + let queryCreations = 0; + const client = new QwpClient( + { + createSender: async () => { + throw new Error("sender factory should not run"); + }, + createQuerySession: async (slot) => { + queryCreations++; + return createQuerySession(slot, connections); + }, + }, + { + senderPoolMin: 0, + senderPoolMax: 1, + queryPoolMin: 0, + queryPoolMax: 1, + acquireTimeoutMs: 500, + }, + ); + const lease = await client.borrowQuery(); + const query = await lease.query("select * from long_running()"); + const releasing = lease.close(); + await Promise.resolve(); + expect(client.metrics.queries.leased).toBe(1); + expect(connections[0].sent).toHaveLength(2); + + connections[0].receive(queryError(query.requestId)); + await releasing; + const reused = await client.borrowQuery(); + expect(queryCreations).toBe(1); + await reused.close(); + await client.close(); + }); + + it("discards a query connection that cannot drain before lease return", async () => { + const connections: FakeConnection[] = []; + let queryCreations = 0; + const client = new QwpClient( + { + createSender: async () => { + throw new Error("sender factory should not run"); + }, + createQuerySession: async (slot) => { + queryCreations++; + return createQuerySession(slot, connections, { + cancelDrainTimeoutMs: 10, + }); + }, + }, + { + senderPoolMin: 0, + senderPoolMax: 1, + queryPoolMin: 0, + queryPoolMax: 1, + acquireTimeoutMs: 500, + }, + ); + const lease = await client.borrowQuery(); + await lease.query("select * from never_finishes()"); + await lease.close(); + expect(client.metrics.queries.total).toBe(0); + + const replacement = await client.borrowQuery(); + expect(queryCreations).toBe(2); + await replacement.close(); + await client.close(); + }); +}); diff --git a/test/qwp/node-transport.test.ts b/test/qwp/node-transport.test.ts index baafadc..0e3e3c3 100644 --- a/test/qwp/node-transport.test.ts +++ b/test/qwp/node-transport.test.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import { WebSocketServer } from "ws"; import { afterEach, describe, expect, it, vi } from "vitest"; import { + connectQwpNodeClient, connectQwpNodeEgress, connectQwpNodeIngress, connectQwpNodeWebSocket, @@ -17,6 +18,7 @@ import { QWP_UPGRADE_ERROR_KIND, QwpByteWriter, QwpUpgradeError, + writeQwpVarint, } from "../../src/qwp/node"; function serverInfo( @@ -75,6 +77,15 @@ function durableResponse( return writer.toUint8Array(); } +function resultEnd(requestId = 0n): Uint8Array { + const payload = new QwpByteWriter() + .writeUint8(QWP_EGRESS_MESSAGE.RESULT_END) + .writeBigUint64(requestId); + writeQwpVarint(payload, 0); + writeQwpVarint(payload, 0); + return encodeQwpFrame(payload.toUint8Array()); +} + describe("QWP Node transport", () => { let server: WebSocketServer | undefined; @@ -243,6 +254,61 @@ describe("QWP Node transport", () => { } }); + it("combines pooled ingress with concurrent borrowed query connections", async () => { + const endpoint = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + endpoint.on("connection", (socket, request) => { + if (request.url === "/read/v1") { + socket.send(serverInfo()); + socket.on("message", () => socket.send(resultEnd())); + } else { + socket.on("message", () => socket.send(okResponse(0n, "trades", 1n))); + } + }); + await listen(endpoint); + const address = endpoint.address() as AddressInfo; + const client = await connectQwpNodeClient({ + ingress: { + url: `ws://127.0.0.1:${address.port}/write/v4`, + }, + egress: { + url: `ws://127.0.0.1:${address.port}/read/v1`, + }, + sender: { autoFlush: false }, + pool: { + senderPoolMin: 1, + senderPoolMax: 1, + queryPoolMin: 1, + queryPoolMax: 2, + }, + }); + try { + const sender = await client.borrowSender(); + await sender.table("trades").symbol("symbol", "ETH-USD").atNow(); + await sender.close(); + + const [first, second] = await Promise.all([ + client.borrowQuery(), + client.borrowQuery(), + ]); + try { + const [firstQuery, secondQuery] = await Promise.all([ + first.query("select 1"), + second.query("select 2"), + ]); + await Promise.all([firstQuery.completion, secondQuery.completion]); + expect(client.metrics.queries).toMatchObject({ + total: 2, + leased: 2, + }); + } finally { + await Promise.all([first.close(), second.close()]); + } + } finally { + await client.close(); + await closeServer(endpoint); + } + }); + it("fails over and replays an unacknowledged frame through the public Node API", async () => { const primary = new WebSocketServer({ host: "127.0.0.1", port: 0 }); const secondary = new WebSocketServer({ host: "127.0.0.1", port: 0 }); diff --git a/test/qwp/public-api-contract.ts b/test/qwp/public-api-contract.ts index 47d0e1e..2cb9b1f 100644 --- a/test/qwp/public-api-contract.ts +++ b/test/qwp/public-api-contract.ts @@ -2,11 +2,13 @@ import { Sender } from "../../src"; import type { ExtraOptions, QwpExtraOptions } from "../../src"; import { bootstrapQwpBrowserSession, + connectQwpBrowserClient, connectQwpBrowserEgress, connectQwpBrowserIngress, connectQwpBrowserSender, } from "../../src/qwp/browser"; import type { + QwpBrowserClientOptions, QwpBrowserSessionBootstrapOptions, QwpBrowserSessionBootstrapResult, QwpBrowserEgressOptions, @@ -14,17 +16,20 @@ import type { } from "../../src/qwp/browser"; import { connectQwpNodeEgress, + connectQwpNodeClient, connectQwpNodeIngress, connectQwpNodeSender, connectQwpNodeWebSocket, } from "../../src/qwp/node"; import type { + QwpNodeClientOptions, QwpNodeEgressOptions, QwpNodeIngressOptions, QwpNodeWebSocketOptions, } from "../../src/qwp/node"; import type { QwpBinaryConnection, + QwpClient, QwpEgressQueryOptions, QwpEgressSession, QwpEgressSessionOptions, @@ -58,6 +63,10 @@ const bootstrapSignature: ( options: QwpBrowserSessionBootstrapOptions, ) => Promise = bootstrapQwpBrowserSession; +const browserClientSignature: ( + options: QwpBrowserClientOptions, +) => Promise = connectQwpBrowserClient; + const nodeSenderSignature: ( options: QwpNodeIngressOptions, senderOptions?: QwpSenderOptions, @@ -78,6 +87,10 @@ const nodeWebSocketSignature: ( options: QwpNodeWebSocketOptions, ) => Promise = connectQwpNodeWebSocket; +const nodeClientSignature: ( + options: QwpNodeClientOptions, +) => Promise = connectQwpNodeClient; + const queryOptionsContract: QwpEgressQueryOptions = { initialCredit: 1024, autoCredit: true, @@ -126,10 +139,12 @@ void browserSenderSignature; void browserIngressSignature; void browserEgressSignature; void bootstrapSignature; +void browserClientSignature; void nodeSenderSignature; void nodeIngressSignature; void nodeEgressSignature; void nodeWebSocketSignature; +void nodeClientSignature; void queryOptionsContract; void egressSessionOptionsContract; void browserEgressOptionsContract; diff --git a/test/qwp/public-api.test.ts b/test/qwp/public-api.test.ts index b6fa32e..1a8a252 100644 --- a/test/qwp/public-api.test.ts +++ b/test/qwp/public-api.test.ts @@ -13,6 +13,8 @@ const sharedRuntimeContract = [ "QWP_VERSION", "QwpBatchTooLargeError", "QwpBindValues", + "QwpClient", + "QwpClientClosedError", "QwpDurableAckUnavailableError", "QwpEgressQuery", "QwpEgressQueryAbandonedError", @@ -24,10 +26,13 @@ const sharedRuntimeContract = [ "QwpIngressNackError", "QwpIngressSession", "QwpProtocolError", + "QwpPoolAcquireTimeoutError", + "QwpPoolResourceError", "QwpReconnectExhaustedError", "QwpRoleMismatchError", "QwpReplayRejectedError", "QwpResultBatch", + "QwpQueryLease", "QwpSendTimeoutError", "QwpSender", "QwpUpgradeError", @@ -38,9 +43,11 @@ const browserRuntimeContract = [ "bootstrapQwpBrowserSession", "connectQwpBrowserEgress", "connectQwpBrowserIngress", + "connectQwpBrowserClient", "connectQwpBrowserSender", "connectQwpBrowserWebSocket", "createQwpBrowserConnectionFactory", + "createQwpBrowserClient", "createQwpBrowserSender", ] as const; @@ -52,9 +59,11 @@ const nodeRuntimeContract = [ "QwpVersionMismatchError", "connectQwpNodeEgress", "connectQwpNodeIngress", + "connectQwpNodeClient", "connectQwpNodeSender", "connectQwpNodeWebSocket", "createQwpNodeConnectionFactory", + "createQwpNodeClient", "createQwpNodeSender", ] as const; From 3a7a431a7dfbe908782c571a8f6b8e6d3237d6dd Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 18 Aug 2026 12:53:46 +0100 Subject: [PATCH 037/265] feat(qwp): drain orphaned replay slots --- QWP.md | 26 +- README.md | 8 +- src/qwp-node/orphan-drainer.ts | 409 ++++++++++++++++++++++++++++++++ src/qwp/client.ts | 33 ++- src/qwp/ingress-session.ts | 27 +++ src/qwp/node.ts | 166 ++++++++++++- test/qwp/client.test.ts | 32 +++ test/qwp/node-transport.test.ts | 77 ++++++ test/qwp/orphan-drainer.test.ts | 222 +++++++++++++++++ test/qwp/public-api-contract.ts | 23 ++ test/qwp/public-api.test.ts | 5 + 11 files changed, 1020 insertions(+), 8 deletions(-) create mode 100644 src/qwp-node/orphan-drainer.ts create mode 100644 test/qwp/orphan-drainer.test.ts diff --git a/QWP.md b/QWP.md index 9bb62c6..fad517f 100644 --- a/QWP.md +++ b/QWP.md @@ -70,8 +70,10 @@ const sender = await Sender.fromConfig( requestDurableAck: true, failoverUrls: ["wss://questdb-dr.example:9000/write/v4"], storeAndForward: { - directory: "/var/lib/my-service/qwp-replay", + directory: "/var/lib/my-service/qwp-replay/producer-a", maxBytes: 512 * 1024 * 1024, + drainOrphans: true, + maxBackgroundDrainers: 4, }, }, sender: { @@ -113,6 +115,22 @@ Locks left by a terminated process on the same host are recovered automatically; locks owned by a live local process, another host, or an unidentifiable owner fail closed. +For a standalone sender, `drainOrphans: true` scans sibling directories beneath the +configured journal directory's parent, excludes the sender's own directory, and +adopts record-bearing slots left by failed producers. Adoption is lock-protected and +uses an independent QWP connection per slot, bounded by `maxBackgroundDrainers` (4 by +default). The scanner runs immediately and then every 30 seconds; set +`orphanScanIntervalMs: 0` for a startup-only scan. Terminal recovery failures create +`.qwp.failed` in the slot so a corrupt or permanently rejected head cannot cause a hot +retry loop. After inspection or repair, call `retryQwpNodeOrphanSlot(slotDirectory)` +to make it eligible again. `onOrphanDrainEvent` reports discovery, drain, lock +contention, quarantine, and scanner failures without allowing callback exceptions to +interrupt recovery. + +Keep sibling adoption off unless the parent is a dedicated store-and-forward group: +every record-bearing child directory that is not the foreground slot is considered +eligible. Browser senders never scan or persist local slots. + An offline sender cannot inspect the server-advertised batch cap before its first publication. Set `qwp.session.maxBatchSizeBytes` to a value no greater than the smallest target node's cap when offline startup is required. @@ -524,7 +542,11 @@ warning, and resets staging before reuse. With Node store-and-forward enabled, t configured directory is treated as a pool root and each stable sender slot owns a `sender-N` child directory, avoiding journal lock conflicts. A connected pooled client prewarms every persistent sender slot (overriding `senderPoolMin`) so journals -left by previously busy slots are recovered even when current traffic is lower. +left by previously busy slots are recovered even when current traffic is lower. A +client-level orphan scanner also drains canonical `sender-N` slots outside the current +pool range, covering restarts where `senderPoolMax` was reduced. This managed-slot +recovery is automatic; `drainOrphans: true` additionally adopts noncanonical/legacy +sibling slots beneath the pool root. ## Error handling and cleanup diff --git a/README.md b/README.md index c2a7fda..79e695d 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,13 @@ can start and accept flushes while QuestDB is offline. `flush()` then resolves after local durable journal publication and a background drainer reconnects and sends in order. Set `qwp.sender.awaitServerAck: true` to wait for the QuestDB ACK instead, or `awaitDurableAck: true` to wait through durable upload. -This persistent mode is Node-only; browser senders continue to default to ACK waiting. +Set `drainOrphans: true` when sibling journal directories share a dedicated parent: +the Node client scans and drains slots left by failed producer processes with bounded +concurrency. Pooled QWP clients recover out-of-range `sender-N` slots automatically, +including leftovers after `senderPoolMax` is reduced. Terminally bad slots are marked +`.qwp.failed` for inspection and can be re-enabled with +`retryQwpNodeOrphanSlot()`. This persistent mode is Node-only; browser senders +continue to default to ACK waiting. Browser applications use the browser entry point, which has no Node.js dependencies. Cookies are supplied by the browser during a same-origin diff --git a/src/qwp-node/orphan-drainer.ts b/src/qwp-node/orphan-drainer.ts new file mode 100644 index 0000000..ab62243 --- /dev/null +++ b/src/qwp-node/orphan-drainer.ts @@ -0,0 +1,409 @@ +import { readdir, unlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import type { + QwpConnectionCloseInfo, + QwpIngressTransportMetrics, +} from "../qwp/transport"; +import { QwpReplayStoreLockedError } from "./file-replay-store"; + +const RECORD_SUFFIX = ".qwp"; +const DEFAULT_MAX_CONCURRENT = 4; +const DEFAULT_SCAN_INTERVAL_MS = 30_000; +const DEFAULT_PROGRESS_POLL_MS = 50; + +/** A terminal orphan-drain failure marker. Remove it to retry the slot. */ +export const QWP_ORPHAN_FAILED_SENTINEL = ".qwp.failed"; + +export const QWP_ORPHAN_DRAIN_EVENT_KIND = { + DISCOVERED: "discovered", + STARTED: "started", + DRAINED: "drained", + LOCKED: "locked", + FAILED: "failed", + SCAN_FAILED: "scan-failed", +} as const; + +export type QwpNodeOrphanDrainEventKind = + (typeof QWP_ORPHAN_DRAIN_EVENT_KIND)[keyof typeof QWP_ORPHAN_DRAIN_EVENT_KIND]; + +export interface QwpNodeOrphanDrainEvent { + readonly kind: QwpNodeOrphanDrainEventKind; + readonly timestampMs: number; + readonly directory?: string; + readonly error?: Error; + readonly metrics: QwpNodeOrphanDrainerMetrics; +} + +export interface QwpNodeOrphanDrainerMetrics { + readonly scans: number; + readonly discovered: number; + readonly queued: number; + readonly active: number; + readonly drained: number; + readonly locked: number; + readonly failed: number; + readonly scanFailures: number; + readonly closing: boolean; + readonly closed: boolean; +} + +/** Minimal session surface used by the Node orphan drainer. */ +export interface QwpNodeOrphanDrainSession { + readonly closed: Promise; + readonly metrics: Pick< + QwpIngressTransportMetrics, + "pendingReplayFrames" | "pendingReplayBytes" + > & { + readonly lastError?: Error; + }; + /** Prompts durable-ACK progress when the adopted slot requires it. */ + pollDurableAck?(): Promise; + close(code?: number, reason?: string): Promise; +} + +export interface QwpNodeOrphanDrainerOptions { + /** Directory whose child directories are independent replay slots. */ + rootDirectory: string; + /** Slot names owned by the foreground producer/pool and never adoptable. */ + excludeSlot?: (slotName: string) => boolean; + /** Creates one independent replay session for an adopted slot. */ + createSession(directory: string): Promise; + /** Maximum slots drained concurrently. Defaults to 4. */ + maxConcurrent?: number; + /** Rescan cadence; zero performs only the startup scan. Defaults to 30s. */ + scanIntervalMs?: number; + /** Durable-ACK prompt cadence for adopted sessions. Zero disables it. */ + durableAckPollIntervalMs?: number; + onEvent?: (event: QwpNodeOrphanDrainEvent) => void; +} + +/** + * Returns child replay slots containing unacknowledged records. + * + * The scan is deliberately read-only and does not inspect lock ownership. + * Adoption obtains the replay store's exclusive lock, closing the race with a + * live foreground producer or another drainer. + */ +export async function scanQwpNodeOrphanSlots( + rootDirectory: string, + excludeSlot?: (slotName: string) => boolean, +): Promise { + let entries; + try { + entries = await readdir(rootDirectory, { withFileTypes: true }); + } catch (error) { + if (nodeErrorCode(error) === "ENOENT") return []; + throw error; + } + + const candidates: string[] = []; + for (const entry of entries) { + if (!entry.isDirectory() || excludeSlot?.(entry.name)) continue; + const directory = join(rootDirectory, entry.name); + let children; + try { + children = await readdir(directory, { withFileTypes: true }); + } catch { + // A disappearing or unreadable sibling must not starve later slots in + // the same group. A future periodic scan can observe it if it recovers. + continue; + } + if ( + children.some( + (child) => child.isFile() && child.name === QWP_ORPHAN_FAILED_SENTINEL, + ) + ) { + continue; + } + if ( + children.some( + (child) => child.isFile() && child.name.endsWith(RECORD_SUFFIX), + ) + ) { + candidates.push(directory); + } + } + candidates.sort(); + return candidates; +} + +/** + * Bounded Node-only scanner and background drainer for replay slots left by + * terminated producer processes. Each adopted slot uses its own connection. + */ +export class QwpNodeOrphanDrainer { + private readonly rootDirectory: string; + private readonly excludeSlot?: (slotName: string) => boolean; + private readonly createSession: ( + directory: string, + ) => Promise; + private readonly maxConcurrent: number; + private readonly scanIntervalMs: number; + private readonly durableAckPollIntervalMs: number; + private readonly onEvent?: (event: QwpNodeOrphanDrainEvent) => void; + private readonly known = new Set(); + private readonly queue: string[] = []; + private readonly active = new Map(); + private readonly workers = new Set>(); + private scanTimer?: ReturnType; + private scanPromise?: Promise; + private closePromise?: Promise; + private started = false; + private closing = false; + private closed = false; + private scans = 0; + private discovered = 0; + private drained = 0; + private locked = 0; + private failed = 0; + private scanFailures = 0; + + constructor(options: QwpNodeOrphanDrainerOptions) { + const rootDirectory = options.rootDirectory.trim(); + if (!rootDirectory) { + throw new RangeError("QWP orphan-drain root directory must not be empty"); + } + const maxConcurrent = options.maxConcurrent ?? DEFAULT_MAX_CONCURRENT; + if (!Number.isSafeInteger(maxConcurrent) || maxConcurrent < 1) { + throw new RangeError( + "QWP orphan-drain maxConcurrent must be a positive safe integer", + ); + } + const scanIntervalMs = options.scanIntervalMs ?? DEFAULT_SCAN_INTERVAL_MS; + if (!Number.isFinite(scanIntervalMs) || scanIntervalMs < 0) { + throw new RangeError( + "QWP orphan-drain scanIntervalMs must be a non-negative finite number", + ); + } + const durableAckPollIntervalMs = + options.durableAckPollIntervalMs ?? DEFAULT_PROGRESS_POLL_MS; + if ( + !Number.isFinite(durableAckPollIntervalMs) || + durableAckPollIntervalMs < 0 + ) { + throw new RangeError( + "QWP orphan-drain durableAckPollIntervalMs must be a non-negative finite number", + ); + } + this.rootDirectory = rootDirectory; + this.excludeSlot = options.excludeSlot; + this.createSession = options.createSession; + this.maxConcurrent = maxConcurrent; + this.scanIntervalMs = scanIntervalMs; + this.durableAckPollIntervalMs = durableAckPollIntervalMs; + this.onEvent = options.onEvent; + } + + get metrics(): QwpNodeOrphanDrainerMetrics { + return Object.freeze({ + scans: this.scans, + discovered: this.discovered, + queued: this.queue.length, + active: this.active.size, + drained: this.drained, + locked: this.locked, + failed: this.failed, + scanFailures: this.scanFailures, + closing: this.closing, + closed: this.closed, + }); + } + + /** Starts an immediate scan and the optional periodic scanner. */ + start(): void { + if (this.started || this.closing || this.closed) return; + this.started = true; + this.scanPromise = this.scanOnce(); + } + + close(): Promise { + if (!this.closePromise) this.closePromise = this.closeNow(); + return this.closePromise; + } + + private async scanOnce(): Promise { + if (this.closing) return; + this.scans++; + try { + const candidates = await scanQwpNodeOrphanSlots( + this.rootDirectory, + this.excludeSlot, + ); + for (const directory of candidates) { + if (this.closing || this.known.has(directory)) continue; + this.known.add(directory); + this.queue.push(directory); + this.discovered++; + this.emit(QWP_ORPHAN_DRAIN_EVENT_KIND.DISCOVERED, directory); + } + this.pump(); + } catch (error) { + this.scanFailures++; + this.emit( + QWP_ORPHAN_DRAIN_EVENT_KIND.SCAN_FAILED, + undefined, + toError(error, "QWP orphan-slot scan failed"), + ); + } finally { + if (!this.closing && this.scanIntervalMs > 0) { + this.scanTimer = setTimeout(() => { + this.scanTimer = undefined; + this.scanPromise = this.scanOnce(); + }, this.scanIntervalMs); + this.scanTimer.unref?.(); + } + } + } + + private pump(): void { + while ( + !this.closing && + this.workers.size < this.maxConcurrent && + this.queue.length > 0 + ) { + const directory = this.queue.shift()!; + const worker = this.drainOne(directory).finally(() => { + this.workers.delete(worker); + this.known.delete(directory); + this.pump(); + }); + this.workers.add(worker); + } + } + + private async drainOne(directory: string): Promise { + let session: QwpNodeOrphanDrainSession | undefined; + try { + session = await this.createSession(directory); + if (this.closing) { + await session.close(1001, "QWP orphan drainer is closing"); + return; + } + this.active.set(directory, session); + this.emit(QWP_ORPHAN_DRAIN_EVENT_KIND.STARTED, directory); + await this.waitUntilDrained(session); + if (this.closing) return; + this.drained++; + this.emit(QWP_ORPHAN_DRAIN_EVENT_KIND.DRAINED, directory); + } catch (error) { + if (this.closing) return; + if (error instanceof QwpReplayStoreLockedError) { + this.locked++; + this.emit(QWP_ORPHAN_DRAIN_EVENT_KIND.LOCKED, directory, error); + return; + } + const failure = toError(error, "QWP orphan drain failed"); + this.failed++; + await markFailed(directory, failure).catch(() => undefined); + this.emit(QWP_ORPHAN_DRAIN_EVENT_KIND.FAILED, directory, failure); + } finally { + if (session) { + this.active.delete(directory); + await session + .close(1000, "QWP orphan slot drained") + .catch(() => undefined); + } + } + } + + private async waitUntilDrained( + session: QwpNodeOrphanDrainSession, + ): Promise { + const terminal = session.closed.then(() => "closed" as const); + let nextDurablePoll = + this.durableAckPollIntervalMs > 0 + ? Date.now() + this.durableAckPollIntervalMs + : Number.POSITIVE_INFINITY; + while (!this.closing) { + if (session.metrics.pendingReplayFrames === 0) return; + const outcome = await Promise.race([ + terminal, + delay(DEFAULT_PROGRESS_POLL_MS).then(() => "poll" as const), + ]); + if (outcome === "closed") { + throw ( + session.metrics.lastError ?? + new Error( + "QWP orphan drain session closed before its replay slot drained", + ) + ); + } + if ( + session.pollDurableAck && + this.durableAckPollIntervalMs > 0 && + Date.now() >= nextDurablePoll + ) { + await session.pollDurableAck(); + nextDurablePoll = Date.now() + this.durableAckPollIntervalMs; + } + } + } + + private async closeNow(): Promise { + if (this.closed) return; + this.closing = true; + if (this.scanTimer) clearTimeout(this.scanTimer); + this.scanTimer = undefined; + this.queue.length = 0; + await this.scanPromise?.catch(() => undefined); + await Promise.all( + Array.from(this.active.values(), (session) => + session + .close(1001, "QWP orphan drainer is closing") + .catch(() => undefined), + ), + ); + await Promise.allSettled(Array.from(this.workers)); + this.known.clear(); + this.closed = true; + } + + private emit( + kind: QwpNodeOrphanDrainEventKind, + directory?: string, + error?: Error, + ): void { + try { + this.onEvent?.({ + kind, + timestampMs: Date.now(), + directory, + error, + metrics: this.metrics, + }); + } catch { + // Observers must not interfere with durable recovery. + } + } +} + +async function markFailed(directory: string, error: Error): Promise { + await writeFile( + join(directory, QWP_ORPHAN_FAILED_SENTINEL), + `${new Date().toISOString()} ${error.name}: ${error.message}\n`, + { encoding: "utf8", flag: "w", mode: 0o600 }, + ); +} + +/** Removes a terminal marker so an operator-approved slot can be retried. */ +export async function retryQwpNodeOrphanSlot(directory: string): Promise { + try { + await unlink(join(directory, QWP_ORPHAN_FAILED_SENTINEL)); + } catch (error) { + if (nodeErrorCode(error) !== "ENOENT") throw error; + } +} + +function delay(milliseconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + +function toError(error: unknown, fallback: string): Error { + return error instanceof Error ? error : new Error(fallback, { cause: error }); +} + +function nodeErrorCode(error: unknown): string | undefined { + return error && typeof error === "object" && "code" in error + ? String(error.code) + : undefined; +} diff --git a/src/qwp/client.ts b/src/qwp/client.ts index b2160ce..9127da6 100644 --- a/src/qwp/client.ts +++ b/src/qwp/client.ts @@ -31,6 +31,10 @@ export interface QwpClientPoolOptions { export interface QwpClientFactories { createSender(slot: number): Promise; createQuerySession(slot: number): Promise; + /** @internal Starts runtime-specific background services on first use. */ + start?(): void | Promise; + /** @internal Stops runtime-specific background services during close. */ + close?(): void | Promise; } export interface QwpResourcePoolMetrics { @@ -376,7 +380,10 @@ export class QwpClient { private readonly senderPool: QwpResourcePool; private readonly queryPool: QwpResourcePool; private connectPromise?: Promise; + private startPromise?: Promise; private closePromise?: Promise; + private readonly startFactories?: () => void | Promise; + private readonly closeFactories?: () => void | Promise; private closing = false; private closed = false; @@ -401,6 +408,8 @@ export class QwpClient { factories.createQuerySession, (session) => session.close(), ); + this.startFactories = factories.start; + this.closeFactories = factories.close; } /** Pre-connects the configured minimum sender and query pool sizes. */ @@ -420,6 +429,8 @@ export class QwpClient { /** Borrows an exclusive fluent sender; close() flushes and returns its slot. */ async borrowSender(): Promise { + this.throwIfUnavailable(); + await this.ensureStarted(); this.throwIfUnavailable(); const entry = await this.senderPool.acquire(); return createSenderLease(entry.value, async (reusable) => { @@ -429,6 +440,8 @@ export class QwpClient { /** Borrows one exclusive egress connection for one or more serial queries. */ async borrowQuery(): Promise { + this.throwIfUnavailable(); + await this.ensureStarted(); this.throwIfUnavailable(); const entry = await this.queryPool.acquire(); return new QwpQueryLease(entry.value, async (reusable) => { @@ -444,6 +457,8 @@ export class QwpClient { private async connectNow(): Promise { this.throwIfUnavailable(); try { + await this.ensureStarted(); + this.throwIfUnavailable(); await Promise.all([this.senderPool.prewarm(), this.queryPool.prewarm()]); return this; } catch (error) { @@ -455,8 +470,22 @@ export class QwpClient { private async closeNow(): Promise { if (this.closed) return; this.closing = true; - await Promise.all([this.queryPool.close(), this.senderPool.close()]); - this.closed = true; + await this.startPromise?.catch(() => undefined); + try { + await Promise.resolve() + .then(() => this.closeFactories?.()) + .catch(() => undefined); + await Promise.all([this.queryPool.close(), this.senderPool.close()]); + } finally { + this.closed = true; + } + } + + private ensureStarted(): Promise { + if (!this.startPromise) { + this.startPromise = Promise.resolve().then(() => this.startFactories?.()); + } + return this.startPromise; } private throwIfUnavailable(): void { diff --git a/src/qwp/ingress-session.ts b/src/qwp/ingress-session.ts index 2dd1fa4..43667df 100644 --- a/src/qwp/ingress-session.ts +++ b/src/qwp/ingress-session.ts @@ -342,6 +342,7 @@ export class QwpIngressSession { private failure?: Error; private closing = false; private closePromise?: Promise; + private readonly closeHooks: (() => void | Promise)[] = []; private readonly receiveLoop: Promise; constructor( @@ -769,6 +770,26 @@ export class QwpIngressSession { }); } + /** + * Prompts the server to publish its latest durable-ingress watermarks. + * Node transports use a WebSocket PING; browsers send the protocol-level + * table-less durable-ACK poll frame. + */ + pollDurableAck(): Promise { + this.throwIfUnavailable(); + return this.connection.ping + ? this.connection.ping() + : this.sendFrame(encodeQwpDurableAckPollFrame()).then(() => undefined); + } + + /** @internal Registers runtime-specific cleanup owned by this session. */ + registerCloseHook(hook: () => void | Promise): void { + if (this.closing) { + throw new QwpIngressSessionClosedError(); + } + this.closeHooks.push(hook); + } + close(code = 1000, reason = ""): Promise { if (!this.closePromise) this.closePromise = this.closeNow(code, reason); return this.closePromise; @@ -778,6 +799,11 @@ export class QwpIngressSession { this.closing = true; this.clearDurablePoll(); this.rejectAll(new QwpIngressSessionClosedError()); + const closeHooks = this.closeHooks.splice(0).map((hook) => + Promise.resolve() + .then(hook) + .catch(() => undefined), + ); let transportClose: Promise; try { transportClose = this.connection.close(code, reason); @@ -788,6 +814,7 @@ export class QwpIngressSession { this.sendTail, transportClose, this.receiveLoop, + ...closeHooks, ]); if (closeResult.status === "rejected") throw closeResult.reason; } diff --git a/src/qwp/node.ts b/src/qwp/node.ts index 3836bce..9bfbf48 100644 --- a/src/qwp/node.ts +++ b/src/qwp/node.ts @@ -3,7 +3,7 @@ export * from "./index"; import type { Agent } from "node:http"; import type { IncomingHttpHeaders } from "node:http"; -import { join } from "node:path"; +import { basename, dirname, join } from "node:path"; import WebSocket from "ws"; import { decodeQwpContentEncoding, @@ -34,6 +34,10 @@ import { QwpSender, QwpSenderOptions } from "./sender"; import { QwpClient, QwpClientPoolOptions } from "./client"; import { QwpNodeFileReplayStore } from "../qwp-node/file-replay-store"; import type { QwpNodeFileReplayStoreOptions } from "../qwp-node/file-replay-store"; +import { + QwpNodeOrphanDrainer, + type QwpNodeOrphanDrainEvent, +} from "../qwp-node/orphan-drainer"; export { QwpNodeFileReplayStore, @@ -42,6 +46,19 @@ export { QwpReplayStoreLockedError, } from "../qwp-node/file-replay-store"; export type { QwpNodeFileReplayStoreOptions } from "../qwp-node/file-replay-store"; +export { + QWP_ORPHAN_DRAIN_EVENT_KIND, + QWP_ORPHAN_FAILED_SENTINEL, + QwpNodeOrphanDrainer, + retryQwpNodeOrphanSlot, + scanQwpNodeOrphanSlots, +} from "../qwp-node/orphan-drainer"; +export type { + QwpNodeOrphanDrainEvent, + QwpNodeOrphanDrainEventKind, + QwpNodeOrphanDrainerMetrics, + QwpNodeOrphanDrainerOptions, +} from "../qwp-node/orphan-drainer"; export type { QwpWebSocketLike } from "./internal/websocket-connection"; @@ -153,7 +170,24 @@ export interface QwpNodeIngressOptions extends QwpNodeWebSocketOptions { * Enables persistent Node store-and-forward and ingress reconnection. Use a * directory owned exclusively by this ingress session. */ - storeAndForward?: QwpNodeFileReplayStoreOptions; + storeAndForward?: QwpNodeStoreAndForwardOptions; +} + +/** Node store-and-forward controls layered on the crash-safe replay journal. */ +export interface QwpNodeStoreAndForwardOptions + extends QwpNodeFileReplayStoreOptions { + /** + * Adopts sibling replay slots left by terminated producers. Standalone + * senders default this to false; pooled clients always recover their own + * out-of-range `sender-N` slots after a pool-size reduction. + */ + drainOrphans?: boolean; + /** Maximum sibling slots drained concurrently. Defaults to 4. */ + maxBackgroundDrainers?: number; + /** Rescan cadence; zero scans only at startup. Defaults to 30 seconds. */ + orphanScanIntervalMs?: number; + /** Receives isolated scanner and drainer lifecycle notifications. */ + onOrphanDrainEvent?: (event: QwpNodeOrphanDrainEvent) => void; } export interface QwpNodeEgressOptions @@ -344,6 +378,14 @@ function connectQwpNodeEndpoint( export async function connectQwpNodeIngress( options: QwpNodeIngressOptions, sessionOptions: QwpIngressSessionOptions = {}, +): Promise { + return connectQwpNodeIngressInternal(options, sessionOptions, true); +} + +async function connectQwpNodeIngressInternal( + options: QwpNodeIngressOptions, + sessionOptions: QwpIngressSessionOptions, + startOrphanDrainer: boolean, ): Promise { if (options.storeAndForward && sessionOptions.replayStore) { throw new RangeError( @@ -378,10 +420,19 @@ export async function connectQwpNodeIngress( ? (sessionOptions.durableAckKeepaliveMs ?? 200) : sessionOptions.durableAckKeepaliveMs, }; - return QwpIngressSession.connect( + const orphanDrainer = + startOrphanDrainer && options.storeAndForward?.drainOrphans === true + ? createStandaloneOrphanDrainer(options, sessionOptions) + : undefined; + const session = await QwpIngressSession.connect( createQwpNodeConnectionFactory(options), effectiveSessionOptions, ); + if (orphanDrainer) { + session.registerCloseHook(() => orphanDrainer.close()); + orphanDrainer.start(); + } + return session; } /** @@ -444,6 +495,7 @@ export async function connectQwpNodeEgress( /** Creates a lazy Node QWP client with bounded sender and query pools. */ export function createQwpNodeClient(options: QwpNodeClientOptions): QwpClient { + const orphanDrainer = createPooledOrphanDrainer(options); return new QwpClient( { createSender: async (slot) => { @@ -463,6 +515,8 @@ export function createQwpNodeClient(options: QwpNodeClientOptions): QwpClient { }, createQuerySession: () => connectQwpNodeEgress(options.egress, options.egressSession), + start: () => orphanDrainer?.start(), + close: () => orphanDrainer?.close(), }, pooledNodeClientOptions(options), ); @@ -503,6 +557,112 @@ function pooledNodeIngressOptions( storeAndForward: { ...options.storeAndForward, directory: join(rootDirectory, `sender-${slot}`), + // The client-level drainer owns sibling adoption. Per-sender scanners + // would contend with other managed pool slots during prewarm/borrows. + drainOrphans: false, }, }; } + +function createStandaloneOrphanDrainer( + options: QwpNodeIngressOptions, + sessionOptions: QwpIngressSessionOptions, +): QwpNodeOrphanDrainer { + const storeAndForward = options.storeAndForward!; + const ownDirectory = storeAndForward.directory.trim(); + return createNodeOrphanDrainer( + options, + sessionOptions, + dirname(ownDirectory), + (slotName) => slotName === basename(ownDirectory), + ); +} + +function createPooledOrphanDrainer( + options: QwpNodeClientOptions, +): QwpNodeOrphanDrainer | undefined { + const storeAndForward = options.ingress.storeAndForward; + if (!storeAndForward) return undefined; + const rootDirectory = storeAndForward.directory.trim(); + if (!rootDirectory) { + throw new RangeError("storeAndForward directory must not be empty"); + } + const managedSlotCount = options.pool?.senderPoolMax ?? 4; + return createNodeOrphanDrainer( + options.ingress, + options.ingressSession ?? {}, + rootDirectory, + (slotName) => { + const managedIndex = parseCanonicalSenderSlot(slotName); + if (managedIndex !== undefined && managedIndex < managedSlotCount) { + return true; + } + // Same-base slots outside the new pool range are always recovered. A + // caller must opt in before unrelated/legacy sibling names are adopted. + return ( + managedIndex === undefined && storeAndForward.drainOrphans !== true + ); + }, + ); +} + +function createNodeOrphanDrainer( + options: QwpNodeIngressOptions, + sessionOptions: QwpIngressSessionOptions, + rootDirectory: string, + excludeSlot: (slotName: string) => boolean, +): QwpNodeOrphanDrainer { + const storeAndForward = options.storeAndForward!; + return new QwpNodeOrphanDrainer({ + rootDirectory, + excludeSlot, + maxConcurrent: storeAndForward.maxBackgroundDrainers, + scanIntervalMs: storeAndForward.orphanScanIntervalMs, + durableAckPollIntervalMs: options.requestDurableAck + ? (sessionOptions.durableAckKeepaliveMs ?? 200) + : 0, + onEvent: storeAndForward.onOrphanDrainEvent, + createSession: (directory) => + connectQwpNodeIngressInternal( + { + ...options, + storeAndForward: { + directory, + maxBytes: storeAndForward.maxBytes, + drainOrphans: false, + }, + }, + orphanIngressSessionOptions(sessionOptions), + false, + ), + }); +} + +function orphanIngressSessionOptions( + options: QwpIngressSessionOptions, +): QwpIngressSessionOptions { + return { + ...options, + // No foreground caller remains to retry orphan bytes, so transport + // outages stay retryable for the drainer's lifetime. Authentication, + // protocol, and poison-frame failures remain terminal and quarantined. + reconnect: { + ...options.reconnect, + maxAttempts: 0, + maxDurationMs: 0, + }, + replayStore: undefined, + backgroundStoreAndForward: undefined, + onResponse: undefined, + onDurableAck: undefined, + onProgress: undefined, + onError: undefined, + }; +} + +function parseCanonicalSenderSlot(name: string): number | undefined { + const match = /^sender-(0|[1-9]\d*)$/.exec(name); + if (!match) return undefined; + const index = Number(match[1]); + return Number.isSafeInteger(index) ? index : undefined; +} diff --git a/test/qwp/client.test.ts b/test/qwp/client.test.ts index 726ba86..fcf7e83 100644 --- a/test/qwp/client.test.ts +++ b/test/qwp/client.test.ts @@ -119,6 +119,38 @@ async function createQuerySession( } describe("QWP pooled client", () => { + it("starts and stops runtime background services exactly once", async () => { + let starts = 0; + let closes = 0; + const client = new QwpClient( + { + createSender: async () => { + throw new Error("sender factory should not run"); + }, + createQuerySession: async () => { + throw new Error("query factory should not run"); + }, + start: async () => { + starts++; + }, + close: async () => { + closes++; + }, + }, + { + senderPoolMin: 0, + senderPoolMax: 1, + queryPoolMin: 0, + queryPoolMax: 1, + }, + ); + + await Promise.all([client.connect(), client.connect()]); + expect(starts).toBe(1); + await Promise.all([client.close(), client.close()]); + expect(closes).toBe(1); + }); + it("flushes and reuses an exclusively borrowed sender", async () => { const senderSessions: FakeSenderSession[] = []; let senderCreations = 0; diff --git a/test/qwp/node-transport.test.ts b/test/qwp/node-transport.test.ts index 0e3e3c3..af3b005 100644 --- a/test/qwp/node-transport.test.ts +++ b/test/qwp/node-transport.test.ts @@ -17,6 +17,7 @@ import { QWP_STATUS, QWP_UPGRADE_ERROR_KIND, QwpByteWriter, + QwpNodeFileReplayStore, QwpUpgradeError, writeQwpVarint, } from "../../src/qwp/node"; @@ -309,6 +310,82 @@ describe("QWP Node transport", () => { } }); + it("background-drains an out-of-range pooled slot left by a failed producer", async () => { + const endpoint = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + endpoint.on("headers", (headers) => { + headers.push("X-QWP-Version: 1"); + headers.push("X-QWP-Durable-Ack: enabled"); + }); + const received: Uint8Array[] = []; + let pingCount = 0; + endpoint.on("connection", (socket) => { + let sequence = 0n; + socket.on("message", (payload) => { + received.push(new Uint8Array(payload as Buffer)); + socket.send(okResponse(sequence++, "trades", 1n)); + }); + socket.on("ping", () => { + pingCount++; + socket.send(durableResponse("trades", 1n)); + }); + }); + await listen(endpoint); + const address = endpoint.address() as AddressInfo; + const rootDirectory = await mkdtemp(join(tmpdir(), "qwp-node-pool-")); + const orphanDirectory = join(rootDirectory, "sender-3"); + const orphan = new QwpNodeFileReplayStore({ + directory: orphanDirectory, + }); + await orphan.load(); + await orphan.append({ + frameSequence: 0n, + payload: Uint8Array.of(4, 5, 6), + }); + await orphan.close(); + + const events: string[] = []; + const client = await connectQwpNodeClient({ + ingress: { + url: `ws://127.0.0.1:${address.port}/write/v4`, + requestDurableAck: true, + storeAndForward: { + directory: rootDirectory, + orphanScanIntervalMs: 0, + onOrphanDrainEvent: (event) => events.push(event.kind), + }, + }, + ingressSession: { durableAckKeepaliveMs: 10 }, + egress: { + url: `ws://127.0.0.1:${address.port}/read/v1`, + }, + pool: { + senderPoolMin: 1, + senderPoolMax: 1, + queryPoolMin: 0, + queryPoolMax: 1, + }, + }); + try { + await vi.waitFor( + async () => { + expect( + (await readdir(orphanDirectory)).filter((name) => + name.endsWith(".qwp"), + ), + ).toEqual([]); + expect(events).toContain("drained"); + }, + { timeout: 2_000 }, + ); + expect(received).toContainEqual(Uint8Array.of(4, 5, 6)); + expect(pingCount).toBeGreaterThan(0); + } finally { + await client.close(); + await closeServer(endpoint); + await rm(rootDirectory, { recursive: true, force: true }); + } + }); + it("fails over and replays an unacknowledged frame through the public Node API", async () => { const primary = new WebSocketServer({ host: "127.0.0.1", port: 0 }); const secondary = new WebSocketServer({ host: "127.0.0.1", port: 0 }); diff --git a/test/qwp/orphan-drainer.test.ts b/test/qwp/orphan-drainer.test.ts new file mode 100644 index 0000000..9721c16 --- /dev/null +++ b/test/qwp/orphan-drainer.test.ts @@ -0,0 +1,222 @@ +import { mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + QWP_ORPHAN_DRAIN_EVENT_KIND, + QWP_ORPHAN_FAILED_SENTINEL, + QwpNodeOrphanDrainer, + QwpReplayStoreLockedError, + retryQwpNodeOrphanSlot, + scanQwpNodeOrphanSlots, + type QwpNodeOrphanDrainSession, +} from "../../src/qwp/node"; + +class FakeDrainSession implements QwpNodeOrphanDrainSession { + pendingReplayFrames = 1; + readonly closed: Promise<{ + code: number; + reason: string; + wasClean: boolean; + }>; + private resolveClosed!: (info: { + code: number; + reason: string; + wasClean: boolean; + }) => void; + lastError?: Error; + closes = 0; + + constructor() { + this.closed = new Promise((resolve) => { + this.resolveClosed = resolve; + }); + } + + get metrics() { + return { + pendingReplayFrames: this.pendingReplayFrames, + pendingReplayBytes: this.pendingReplayFrames, + lastError: this.lastError, + }; + } + + pollDurableAck(): Promise { + this.pendingReplayFrames = 0; + return Promise.resolve(); + } + + close(code = 1000, reason = ""): Promise { + if (this.closes++ === 0) { + this.resolveClosed({ code, reason, wasClean: code === 1000 }); + } + return Promise.resolve(); + } + + fail(error: Error): void { + this.lastError = error; + this.resolveClosed({ code: 1011, reason: error.message, wasClean: false }); + } +} + +describe("QWP Node orphan drainer", () => { + const roots: string[] = []; + + afterEach(async () => { + await Promise.all( + roots.splice(0).map((root) => rm(root, { recursive: true, force: true })), + ); + }); + + async function root(): Promise { + const directory = await mkdtemp(join(tmpdir(), "qwp-orphans-")); + roots.push(directory); + return directory; + } + + async function recordSlot( + rootDirectory: string, + name: string, + ): Promise { + const directory = join(rootDirectory, name); + await mkdir(directory); + await writeFile(join(directory, "00000000000000000000.qwp"), "frame"); + return directory; + } + + it("finds record-bearing child slots while excluding live and failed slots", async () => { + const rootDirectory = await root(); + const orphan = await recordSlot(rootDirectory, "orphan"); + await recordSlot(rootDirectory, "live"); + const failed = await recordSlot(rootDirectory, "failed"); + await writeFile(join(failed, QWP_ORPHAN_FAILED_SENTINEL), "inspect me"); + await mkdir(join(rootDirectory, "empty")); + + await expect( + scanQwpNodeOrphanSlots(rootDirectory, (name) => name === "live"), + ).resolves.toEqual([orphan]); + await expect( + scanQwpNodeOrphanSlots(join(rootDirectory, "missing")), + ).resolves.toEqual([]); + }); + + it("adopts and drains discovered slots with bounded background workers", async () => { + const rootDirectory = await root(); + const first = await recordSlot(rootDirectory, "first"); + const second = await recordSlot(rootDirectory, "second"); + const sessions = new Map(); + const events: string[] = []; + let activeCreations = 0; + let maximumCreations = 0; + const drainer = new QwpNodeOrphanDrainer({ + rootDirectory, + maxConcurrent: 1, + scanIntervalMs: 0, + durableAckPollIntervalMs: 1, + createSession: async (directory) => { + activeCreations++; + maximumCreations = Math.max(maximumCreations, activeCreations); + const session = new FakeDrainSession(); + sessions.set(directory, session); + const close = session.close.bind(session); + session.close = async (code, reason) => { + await close(code, reason); + activeCreations--; + }; + return session; + }, + onEvent: (event) => events.push(`${event.kind}:${event.directory}`), + }); + + drainer.start(); + await vi.waitFor(() => expect(drainer.metrics.drained).toBe(2)); + expect(new Set(sessions.keys())).toEqual(new Set([first, second])); + expect(maximumCreations).toBe(1); + expect(events).toContain(`${QWP_ORPHAN_DRAIN_EVENT_KIND.DRAINED}:${first}`); + expect(events).toContain( + `${QWP_ORPHAN_DRAIN_EVENT_KIND.DRAINED}:${second}`, + ); + await drainer.close(); + expect(drainer.metrics).toMatchObject({ active: 0, closed: true }); + }); + + it("discovers a slot orphaned after the startup scan", async () => { + const rootDirectory = await root(); + const drainer = new QwpNodeOrphanDrainer({ + rootDirectory, + scanIntervalMs: 10, + durableAckPollIntervalMs: 1, + createSession: async (directory) => { + const session = new FakeDrainSession(); + session.pollDurableAck = async () => { + session.pendingReplayFrames = 0; + await rm(join(directory, "00000000000000000000.qwp")); + }; + return session; + }, + }); + drainer.start(); + await vi.waitFor(() => expect(drainer.metrics.scans).toBeGreaterThan(0)); + + await recordSlot(rootDirectory, "late-producer"); + await vi.waitFor(() => expect(drainer.metrics.drained).toBe(1)); + expect(drainer.metrics.scans).toBeGreaterThan(1); + await drainer.close(); + }); + + it("skips live locked slots without quarantining them", async () => { + const rootDirectory = await root(); + const directory = await recordSlot(rootDirectory, "live"); + const drainer = new QwpNodeOrphanDrainer({ + rootDirectory, + scanIntervalMs: 0, + createSession: async () => { + throw new QwpReplayStoreLockedError(directory, process.pid); + }, + }); + drainer.start(); + await vi.waitFor(() => expect(drainer.metrics.locked).toBe(1)); + expect(await readdir(directory)).not.toContain(QWP_ORPHAN_FAILED_SENTINEL); + await drainer.close(); + }); + + it("quarantines terminal failures until an operator explicitly retries", async () => { + const rootDirectory = await root(); + const directory = await recordSlot(rootDirectory, "corrupt"); + const terminal = new Error("corrupt replay record"); + const drainer = new QwpNodeOrphanDrainer({ + rootDirectory, + scanIntervalMs: 0, + createSession: async () => { + throw terminal; + }, + }); + drainer.start(); + await vi.waitFor(() => expect(drainer.metrics.failed).toBe(1)); + expect(await readdir(directory)).toContain(QWP_ORPHAN_FAILED_SENTINEL); + await expect(scanQwpNodeOrphanSlots(rootDirectory)).resolves.toEqual([]); + + await retryQwpNodeOrphanSlot(directory); + await expect(scanQwpNodeOrphanSlots(rootDirectory)).resolves.toEqual([ + directory, + ]); + await drainer.close(); + }); + + it("stops active sessions when the owning client closes", async () => { + const rootDirectory = await root(); + await recordSlot(rootDirectory, "offline"); + const session = new FakeDrainSession(); + session.pollDurableAck = () => Promise.resolve(); + const drainer = new QwpNodeOrphanDrainer({ + rootDirectory, + scanIntervalMs: 0, + createSession: async () => session, + }); + drainer.start(); + await vi.waitFor(() => expect(drainer.metrics.active).toBe(1)); + await drainer.close(); + expect(session.closes).toBeGreaterThan(0); + expect(drainer.metrics.closed).toBe(true); + }); +}); diff --git a/test/qwp/public-api-contract.ts b/test/qwp/public-api-contract.ts index 2cb9b1f..be501b5 100644 --- a/test/qwp/public-api-contract.ts +++ b/test/qwp/public-api-contract.ts @@ -20,11 +20,15 @@ import { connectQwpNodeIngress, connectQwpNodeSender, connectQwpNodeWebSocket, + retryQwpNodeOrphanSlot, + scanQwpNodeOrphanSlots, } from "../../src/qwp/node"; import type { QwpNodeClientOptions, QwpNodeEgressOptions, QwpNodeIngressOptions, + QwpNodeOrphanDrainEvent, + QwpNodeStoreAndForwardOptions, QwpNodeWebSocketOptions, } from "../../src/qwp/node"; import type { @@ -91,6 +95,22 @@ const nodeClientSignature: ( options: QwpNodeClientOptions, ) => Promise = connectQwpNodeClient; +const nodeOrphanScanSignature: ( + rootDirectory: string, + excludeSlot?: (slotName: string) => boolean, +) => Promise = scanQwpNodeOrphanSlots; + +const nodeOrphanRetrySignature: (directory: string) => Promise = + retryQwpNodeOrphanSlot; + +const nodeStoreAndForwardContract: QwpNodeStoreAndForwardOptions = { + directory: "/tmp/qwp-public-api-contract", + drainOrphans: true, + maxBackgroundDrainers: 2, + orphanScanIntervalMs: 30_000, + onOrphanDrainEvent: (event: QwpNodeOrphanDrainEvent) => void event.metrics, +}; + const queryOptionsContract: QwpEgressQueryOptions = { initialCredit: 1024, autoCredit: true, @@ -145,6 +165,9 @@ void nodeIngressSignature; void nodeEgressSignature; void nodeWebSocketSignature; void nodeClientSignature; +void nodeOrphanScanSignature; +void nodeOrphanRetrySignature; +void nodeStoreAndForwardContract; void queryOptionsContract; void egressSessionOptionsContract; void browserEgressOptionsContract; diff --git a/test/qwp/public-api.test.ts b/test/qwp/public-api.test.ts index 1a8a252..12c9dfd 100644 --- a/test/qwp/public-api.test.ts +++ b/test/qwp/public-api.test.ts @@ -52,7 +52,10 @@ const browserRuntimeContract = [ ] as const; const nodeRuntimeContract = [ + "QWP_ORPHAN_DRAIN_EVENT_KIND", + "QWP_ORPHAN_FAILED_SENTINEL", "QwpNodeFileReplayStore", + "QwpNodeOrphanDrainer", "QwpReplayStoreError", "QwpReplayStoreFullError", "QwpReplayStoreLockedError", @@ -65,6 +68,8 @@ const nodeRuntimeContract = [ "createQwpNodeConnectionFactory", "createQwpNodeClient", "createQwpNodeSender", + "retryQwpNodeOrphanSlot", + "scanQwpNodeOrphanSlots", ] as const; function assertRuntimeContract( From 402e98edc84d0fac20f68de1c90235ebce8f89c5 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 18 Aug 2026 14:00:35 +0100 Subject: [PATCH 038/265] feat(qwp): expose ingress ack watermarks --- QWP.md | 25 +++ src/qwp/ingress-session.ts | 186 ++++++++++++++++++++++- src/qwp/sender.ts | 154 +++++++++++++++++-- src/sender.ts | 46 ++++++ test/qwp/public-api-contract.ts | 33 ++++ test/qwp/public-api.test.ts | 1 + test/qwp/reconnect.test.ts | 18 +++ test/qwp/sender-node-integration.test.ts | 15 +- test/qwp/sender.test.ts | 92 ++++++++++- test/qwp/session.test.ts | 158 +++++++++++++++++++ 10 files changed, 704 insertions(+), 24 deletions(-) diff --git a/QWP.md b/QWP.md index fad517f..f96f763 100644 --- a/QWP.md +++ b/QWP.md @@ -179,6 +179,30 @@ try { } ``` +For producer-controlled acknowledgement barriers, publish first and wait for the +cumulative ACK watermark separately: + +```typescript +await sender + .table("telemetry") + .symbol("device", "sensor-7") + .longColumn("sequence", 43n) + .atNow(); + +const sequence = await sender.flushAndGetSequence(); +await sender.waitForAcknowledged(sequence, 5_000); +``` + +`flushAndGetSequence()` always resolves at the publication boundary, independently +of `awaitServerAck`, and returns the highest stable frame sequence published by that +call. It returns `-1n` when there was nothing to publish. `publishedSequence` and +`acknowledgedSequence` expose the current immutable watermarks. ACK waits are +cumulative, so one later acknowledgement resolves all covered waits and callers may +wait for different sequences concurrently. When durable ACK was negotiated, the +acknowledged watermark advances only after QuestDB reports durable progress; +otherwise it follows ordinary protocol OK responses. A deadline failure raises +`QwpIngressAckTimeoutError` without closing an otherwise healthy session. + Rows are staged until an auto-flush boundary or an explicit `flush()`. A `null` or `undefined` column value omits that column from the row. `atNow()` asks QuestDB to assign the designated timestamp; `at(value, unit)` sends an explicit `ns`, `us`, or @@ -562,6 +586,7 @@ The public error classes preserve enough context for policy decisions: | `QwpDurableAckUnavailableError` | Durable acknowledgement was required but not negotiated | | `QwpSendTimeoutError` | A send did not drain before its deadline; delivery is unknown | | `QwpIngressNackError` | QuestDB rejected an ingress frame | +| `QwpIngressAckTimeoutError` | The cumulative ingress ACK watermark did not reach the requested sequence before its deadline | | `QwpBatchTooLargeError` | One encoded row cannot fit the effective ingress cap | | `QwpReconnectExhaustedError` | The configured reconnect boundary was reached | | `QwpReplayRejectedError` | A replayed frame was rejected and retained for inspection | diff --git a/src/qwp/ingress-session.ts b/src/qwp/ingress-session.ts index 43667df..5b18bc2 100644 --- a/src/qwp/ingress-session.ts +++ b/src/qwp/ingress-session.ts @@ -251,6 +251,13 @@ interface PendingDurableResponse { timer?: ReturnType; } +interface PendingAcknowledgedSequence { + readonly targetSequence: bigint; + resolve: () => void; + reject: (error: unknown) => void; + timer?: ReturnType; +} + export class QwpIngressNackError extends Error { constructor(readonly response: QwpIngressResponse) { super( @@ -272,6 +279,20 @@ export class QwpIngressSessionClosedError extends Error { } } +/** The ingress ACK watermark did not reach the requested frame in time. */ +export class QwpIngressAckTimeoutError extends Error { + constructor( + readonly targetSequence: bigint, + readonly acknowledgedSequence: bigint, + readonly timeoutMs: number, + ) { + super( + `timed out waiting for QWP ACK watermark [targetSequence=${targetSequence}, acknowledgedSequence=${acknowledgedSequence}, timeoutMs=${timeoutMs}]`, + ); + this.name = "QwpIngressAckTimeoutError"; + } +} + export class QwpBatchTooLargeError extends RangeError { constructor( readonly batchSizeBytes: number, @@ -322,6 +343,16 @@ export class QwpIngressSession { private readonly durableWatermarks = new Map(); private readonly pendingDurableTargets = new Map(); private readonly durableWaiters = new Set(); + private readonly acknowledgedSequenceWaiters = + new Set(); + private readonly durableFrameTargets = new Map< + bigint, + ReadonlyMap + >(); + private acknowledgementRejection?: { + readonly sequence: bigint; + readonly error: QwpIngressNackError; + }; private nextSequence = 0n; private sendTail: Promise = Promise.resolve(); private durablePollTimer?: ReturnType; @@ -330,6 +361,7 @@ export class QwpIngressSession { private publishedMaxSymbolId = -1; private deltaSymbolsPublished = false; private acknowledgedSequence = -1n; + private durableAcknowledgedSequence = -1n; private totalFramesPublished = 0; private totalBytesPublished = 0; private totalFramesSent = 0; @@ -425,6 +457,26 @@ export class QwpIngressSession { : Math.min(this.localMaxBatchSizeBytes, serverBatchCap); } + /** Highest stable frame sequence published by this session/transport. */ + get publishedFrameSequence(): bigint { + return ( + this.connection.getIngressMetrics?.().publishedFrameSequence ?? + this.nextSequence - 1n + ); + } + + /** + * Highest cumulative ACK watermark. When durable ACK was negotiated this + * advances only after durability; otherwise it follows ordinary OK ACKs. + */ + get acknowledgedFrameSequence(): bigint { + const transport = this.connection.getIngressMetrics?.(); + if (transport) return transport.acknowledgedFrameSequence; + return this.connection.handshake.durableAckEnabled + ? this.durableAcknowledgedSequence + : this.acknowledgedSequence; + } + get metrics(): QwpIngressMetrics { const transport = this.connection.getIngressMetrics?.(); let pendingResponseBytes = 0; @@ -730,6 +782,57 @@ export class QwpIngressSession { for (const frame of frames) await this.publishFrame(frame); } + /** + * Waits independently for the cumulative frame ACK watermark. A negative + * target is already satisfied, but still surfaces a latched session error. + */ + waitForAcknowledged( + targetSequence: bigint, + timeoutMs = this.options.ackTimeoutMs ?? 15_000, + ): Promise { + this.throwIfUnavailable(); + if (typeof targetSequence !== "bigint") { + return Promise.reject( + new TypeError("QWP ACK target sequence must be a bigint"), + ); + } + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { + return Promise.reject( + new RangeError("QWP ACK watermark timeout must be positive and finite"), + ); + } + const rejection = this.acknowledgementFailure(targetSequence); + if (rejection) return Promise.reject(rejection); + if ( + targetSequence < 0n || + this.acknowledgedFrameSequence >= targetSequence + ) { + return Promise.resolve(); + } + + return new Promise((resolve, reject) => { + const pending: PendingAcknowledgedSequence = { + targetSequence, + resolve, + reject, + }; + pending.timer = setTimeout(() => { + if (!this.acknowledgedSequenceWaiters.delete(pending)) return; + const error = new QwpIngressAckTimeoutError( + targetSequence, + this.acknowledgedFrameSequence, + timeoutMs, + ); + reject(error); + this.recordError(error, false); + }, timeoutMs); + this.acknowledgedSequenceWaiters.add(pending); + // Close the ACK-before-registration race. JavaScript is single-threaded, + // but a custom connection can synchronously enqueue a response callback. + this.resolveAcknowledgedSequenceWaiters(); + }); + } + /** * Waits until a durable ACK covers every table transaction in an OK ACK. * Durable tracking must have been enabled with durableAckKeepaliveMs. @@ -857,6 +960,7 @@ export class QwpIngressSession { } if (response.status === QWP_STATUS.OK) { this.totalAcks++; + this.trackDurableFrame(response); this.trackDurableTargets(response); for (const [sequence, pending] of this.pending) { if (sequence > response.sequence) break; @@ -872,19 +976,25 @@ export class QwpIngressSession { response, ); } + this.resolveAcknowledgedSequenceWaiters(); return; } this.totalNacks++; const pending = this.pending.get(response.sequence); - if (!pending) { - // A late response after timeout, or a duplicate response, is harmless. - return; - } - this.pending.delete(response.sequence); - if (pending.timer) clearTimeout(pending.timer); const error = new QwpIngressNackError(response); - pending.reject(error); + if ( + !this.acknowledgementRejection || + response.sequence < this.acknowledgementRejection.sequence + ) { + this.acknowledgementRejection = { sequence: response.sequence, error }; + } + if (pending) { + this.pending.delete(response.sequence); + if (pending.timer) clearTimeout(pending.timer); + pending.reject(error); + } + this.rejectAcknowledgedSequenceWaitersThrough(response.sequence, error); const dictionaryGap = this.deltaSymbolsPublished && response.status === QWP_STATUS.DICTIONARY_GAP; @@ -958,6 +1068,17 @@ export class QwpIngressSession { this.scheduleDurablePoll(); } + private trackDurableFrame(response: QwpIngressResponse): void { + if (!this.connection.handshake.durableAckEnabled) return; + this.durableFrameTargets.set( + response.sequence!, + new Map( + response.tables.map((table) => [table.name, table.sequenceTransaction]), + ), + ); + this.advanceDurableFrameWatermark(); + } + private applyDurableAck(response: QwpIngressResponse): boolean { let advanced = false; for (const table of response.tables) { @@ -978,14 +1099,60 @@ export class QwpIngressSession { if (waiter.timer) clearTimeout(waiter.timer); waiter.resolve(); } + const frameAdvanced = this.advanceDurableFrameWatermark(); + this.resolveAcknowledgedSequenceWaiters(); if (this.pendingDurableTargets.size === 0) { this.clearDurablePoll(); } else { this.scheduleDurablePoll(); } + return advanced || frameAdvanced; + } + + private advanceDurableFrameWatermark(): boolean { + let advanced = false; + for (const [sequence, targets] of this.durableFrameTargets) { + if (!this.areDurableTargetsCovered(targets)) break; + this.durableFrameTargets.delete(sequence); + if (sequence > this.durableAcknowledgedSequence) { + this.durableAcknowledgedSequence = sequence; + advanced = true; + } + } return advanced; } + private resolveAcknowledgedSequenceWaiters(): void { + const acknowledged = this.acknowledgedFrameSequence; + for (const pending of this.acknowledgedSequenceWaiters) { + if (pending.targetSequence > acknowledged) continue; + this.acknowledgedSequenceWaiters.delete(pending); + if (pending.timer) clearTimeout(pending.timer); + pending.resolve(); + } + } + + private acknowledgementFailure( + targetSequence: bigint, + ): QwpIngressNackError | undefined { + const rejection = this.acknowledgementRejection; + return rejection && rejection.sequence <= targetSequence + ? rejection.error + : undefined; + } + + private rejectAcknowledgedSequenceWaitersThrough( + sequence: bigint, + error: Error, + ): void { + for (const pending of this.acknowledgedSequenceWaiters) { + if (pending.targetSequence < sequence) continue; + this.acknowledgedSequenceWaiters.delete(pending); + if (pending.timer) clearTimeout(pending.timer); + pending.reject(error); + } + } + private areDurableTargetsCovered( targets: ReadonlyMap, ): boolean { @@ -1057,5 +1224,10 @@ export class QwpIngressSession { pending.reject(error); } this.durableWaiters.clear(); + for (const pending of this.acknowledgedSequenceWaiters) { + if (pending.timer) clearTimeout(pending.timer); + pending.reject(error); + } + this.acknowledgedSequenceWaiters.clear(); } } diff --git a/src/qwp/sender.ts b/src/qwp/sender.ts index 4ad2f80..e235932 100644 --- a/src/qwp/sender.ts +++ b/src/qwp/sender.ts @@ -50,6 +50,8 @@ export interface QwpSenderOptions { /** The subset of QwpIngressSession used by QwpSender. */ export interface QwpSenderSession { readonly metrics?: QwpIngressMetrics; + readonly publishedFrameSequence?: bigint; + readonly acknowledgedFrameSequence?: bigint; sendTables( tables: readonly QwpTableBuffer[], options?: QwpIngressEncodeOptions, @@ -66,6 +68,10 @@ export interface QwpSenderSession { tables: readonly QwpTableBuffer[], options?: Pick, ): Promise; + waitForAcknowledged?( + targetSequence: bigint, + timeoutMs?: number, + ): Promise; waitForDurable( response: QwpIngressResponse, timeoutMs?: number, @@ -108,6 +114,11 @@ interface StagedTable { >; } +interface QwpSenderFlushResult { + readonly flushed: boolean; + readonly sequence: bigint; +} + const DEFAULT_AUTO_FLUSH_ROWS = 1_000; const DEFAULT_AUTO_FLUSH_INTERVAL_MS = 100; @@ -776,6 +787,60 @@ export class QwpSender { return this.enqueueFlush(false); } + /** + * Publishes pending rows without waiting for their server ACK and returns + * the highest frame sequence produced by this call, or -1n when empty. + * Pass the result to waitForAcknowledged() when an explicit delivery + * barrier is needed. + */ + flushAndGetSequence(): Promise { + return this.enqueueSequenceFlush(false); + } + + /** Highest cumulative ACK watermark, or -1n before acknowledgement. */ + get acknowledgedSequence(): bigint { + return this.activeSession + ? sessionAcknowledgedSequence(this.activeSession) + : -1n; + } + + /** Highest stable frame sequence published by this sender. */ + get publishedSequence(): bigint { + return this.activeSession + ? sessionPublishedSequence(this.activeSession) + : -1n; + } + + /** Independently waits until the cumulative ACK watermark covers a frame. */ + async waitForAcknowledged( + targetSequence: bigint, + timeoutMs?: number, + ): Promise { + this.throwIfUnavailable(); + if (typeof targetSequence !== "bigint") { + throw new TypeError("QWP ACK target sequence must be a bigint"); + } + if ( + timeoutMs !== undefined && + (!Number.isFinite(timeoutMs) || timeoutMs <= 0) + ) { + throw new RangeError( + "QWP ACK watermark timeout must be positive and finite", + ); + } + const session = + targetSequence < 0n && !this.activeSession + ? undefined + : await this.getSession(); + if (!session) return; + if (!session.waitForAcknowledged) { + throw new Error( + "this QWP ingress session does not expose an ACK watermark", + ); + } + await session.waitForAcknowledged(targetSequence, timeoutMs); + } + /** * Commits rows previously sent by transactional auto-flush. This is an * ergonomic alias for flush(); pending local rows are included in the same @@ -786,8 +851,25 @@ export class QwpSender { } private enqueueFlush(deferCommit: boolean): Promise { + return this.enqueueFlushResult(deferCommit, false).then( + (result) => result.flushed, + ); + } + + private enqueueSequenceFlush(deferCommit: boolean): Promise { + return this.enqueueFlushResult(deferCommit, true).then( + (result) => result.sequence, + ); + } + + private enqueueFlushResult( + deferCommit: boolean, + publicationOnly: boolean, + ): Promise { this.throwIfUnavailable(); - const flushing = this.flushTail.then(() => this.flushNow(deferCommit)); + const flushing = this.flushTail.then(() => + this.flushNow(deferCommit, publicationOnly), + ); void flushing.catch(() => { this.totalFlushFailures++; }); @@ -950,18 +1032,26 @@ export class QwpSender { } } - private async flushNow(deferCommit: boolean): Promise { + private async flushNow( + deferCommit: boolean, + publicationOnly: boolean, + ): Promise { if ( this.pendingRowCount === 0 && (deferCommit || !this.hasDeferredMessages) ) { - return false; + if (this.activeSession?.waitForAcknowledged) { + await this.activeSession.waitForAcknowledged(-1n); + } + return { flushed: false, sequence: -1n }; } const session = await this.getSession(); const snapshots = this.tables .filter((table) => table.rows.length > 0) .map((table) => ({ table, rows: table.rows.slice() })); - if (snapshots.length === 0 && !this.hasDeferredMessages) return false; + if (snapshots.length === 0 && !this.hasDeferredMessages) { + return { flushed: false, sequence: -1n }; + } const wireTables = snapshots.map(({ table, rows }) => this.buildTable(table.name, rows), @@ -973,9 +1063,12 @@ export class QwpSender { const useDelta = (encode?.symbolDictionary ?? "delta") === "delta" && session.sendTablesDelta; + const beforeSequence = sessionPublishedSequence(session); let response: Promise | undefined; let publication: Promise | undefined; - if (this.awaitServerAck) { + let publishedSequence = -1n; + const waitForServerAck = this.awaitServerAck && !publicationOnly; + if (waitForServerAck) { response = useDelta ? session.sendTablesDelta!(wireTables, { gorilla: encode?.gorilla, @@ -994,10 +1087,17 @@ export class QwpSender { "this QWP ingress session does not support publication-only flushes", ); } - publication = publisher.call(session, wireTables, { - gorilla: encode?.gorilla, - deferCommit, - }); + publication = publisher + .call(session, wireTables, { + gorilla: encode?.gorilla, + deferCommit, + }) + .then(() => { + publishedSequence = advancedSequence( + beforeSequence, + sessionPublishedSequence(session), + ); + }); } this.totalFlushes++; // Publication-only Node store-and-forward transfers row ownership only @@ -1027,21 +1127,29 @@ export class QwpSender { // never creates an unhandled rejection; flush()/commit() still awaits it. void response.catch(() => undefined); } - return true; + return { flushed: true, sequence: publishedSequence }; } const deferredAcks = this.deferredAcks.splice(0); this.hasDeferredMessages = false; this.deferredRowCount = 0; const ack = response ? await response : undefined; - if (deferredAcks.length > 0) await Promise.all(deferredAcks); + if (response) { + publishedSequence = advancedSequence( + beforeSequence, + sessionPublishedSequence(session), + ); + } + if (!publicationOnly && deferredAcks.length > 0) { + await Promise.all(deferredAcks); + } if (this.transactional && (closesDeferredTransaction || sentRows > 0)) { this.totalTransactionsCommitted++; } if (this.options.awaitDurableAck && ack) { await session.waitForDurable(ack, this.options.durableAckTimeoutMs); } - return true; + return { flushed: true, sequence: publishedSequence }; } private buildTable( @@ -1097,3 +1205,25 @@ export class QwpSender { if (this.closing) throw new Error("QWP sender is closing"); } } + +function sessionPublishedSequence(session: QwpSenderSession): bigint { + return ( + session.publishedFrameSequence ?? + session.metrics?.replayPublishedFrameSequence ?? + session.metrics?.publishedSequence ?? + -1n + ); +} + +function sessionAcknowledgedSequence(session: QwpSenderSession): bigint { + return ( + session.acknowledgedFrameSequence ?? + session.metrics?.replayAcknowledgedFrameSequence ?? + session.metrics?.acknowledgedSequence ?? + -1n + ); +} + +function advancedSequence(before: bigint, after: bigint): bigint { + return after > before ? after : -1n; +} diff --git a/src/sender.ts b/src/sender.ts index abb71a8..585da0a 100644 --- a/src/sender.ts +++ b/src/sender.ts @@ -223,6 +223,52 @@ class Sender { return true; } + /** + * Flushes pending rows and returns the highest QWP frame sequence published + * by this call. Non-QWP transports flush normally and return -1n because + * they do not expose frame sequences. + */ + async flushAndGetSequence(): Promise { + if (this.qwpSender) return this.qwpSender.flushAndGetSequence(); + await this.flush(); + return -1n; + } + + /** Highest stable QWP frame sequence published, or -1n when unavailable. */ + get publishedSequence(): bigint { + return this.qwpSender?.publishedSequence ?? -1n; + } + + /** Highest cumulative QWP ACK watermark, or -1n when unavailable. */ + get acknowledgedSequence(): bigint { + return this.qwpSender?.acknowledgedSequence ?? -1n; + } + + /** Waits independently for a cumulative QWP ACK watermark. */ + async waitForAcknowledged( + targetSequence: bigint, + timeoutMs?: number, + ): Promise { + if (this.qwpSender) { + return this.qwpSender.waitForAcknowledged(targetSequence, timeoutMs); + } + if (typeof targetSequence !== "bigint") { + throw new TypeError("QWP ACK target sequence must be a bigint"); + } + if ( + timeoutMs !== undefined && + (!Number.isFinite(timeoutMs) || timeoutMs <= 0) + ) { + throw new RangeError( + "QWP ACK watermark timeout must be positive and finite", + ); + } + if (targetSequence < 0n) return; + throw new Error( + "ACK sequence watermarks are available only with the QWP WebSocket transport", + ); + } + /** * Closes the connection to the database.
    * Data sitting in the Sender's buffer will be lost unless flush() is called before close(). diff --git a/test/qwp/public-api-contract.ts b/test/qwp/public-api-contract.ts index be501b5..6ce00e6 100644 --- a/test/qwp/public-api-contract.ts +++ b/test/qwp/public-api-contract.ts @@ -151,6 +151,37 @@ const qwpExtraOptionsContract: QwpExtraOptions = { }, }; +function senderSequenceContract( + sender: QwpSender, + session: QwpIngressSession, +): void { + const published: Promise = sender.flushAndGetSequence(); + const senderWait: Promise = sender.waitForAcknowledged(0n, 5_000); + const senderPublished: bigint = sender.publishedSequence; + const senderAcknowledged: bigint = sender.acknowledgedSequence; + const sessionWait: Promise = session.waitForAcknowledged(0n, 5_000); + const sessionPublished: bigint = session.publishedFrameSequence; + const sessionAcknowledged: bigint = session.acknowledgedFrameSequence; + void published; + void senderWait; + void senderPublished; + void senderAcknowledged; + void sessionWait; + void sessionPublished; + void sessionAcknowledged; +} + +function rootSenderSequenceContract(sender: Sender): void { + const published: Promise = sender.flushAndGetSequence(); + const wait: Promise = sender.waitForAcknowledged(0n, 5_000); + const publishedWatermark: bigint = sender.publishedSequence; + const acknowledgedWatermark: bigint = sender.acknowledgedSequence; + void published; + void wait; + void publishedWatermark; + void acknowledgedWatermark; +} + const rootExtraOptionsContract: ExtraOptions = { qwp: qwpExtraOptionsContract, }; @@ -173,4 +204,6 @@ void egressSessionOptionsContract; void browserEgressOptionsContract; void nodeEgressOptionsContract; void rootExtraOptionsContract; +void senderSequenceContract; +void rootSenderSequenceContract; void Sender; diff --git a/test/qwp/public-api.test.ts b/test/qwp/public-api.test.ts index 12c9dfd..7de78f2 100644 --- a/test/qwp/public-api.test.ts +++ b/test/qwp/public-api.test.ts @@ -24,6 +24,7 @@ const sharedRuntimeContract = [ "QwpEgressReplayRequiredError", "QwpEgressSession", "QwpIngressNackError", + "QwpIngressAckTimeoutError", "QwpIngressSession", "QwpProtocolError", "QwpPoolAcquireTimeoutError", diff --git a/test/qwp/reconnect.test.ts b/test/qwp/reconnect.test.ts index 6cfe095..0380327 100644 --- a/test/qwp/reconnect.test.ts +++ b/test/qwp/reconnect.test.ts @@ -441,13 +441,18 @@ describe("QWP ingress reconnect and replay", () => { pendingReplayFrames: 2, totalFramesSent: 0, }); + expect(session.publishedFrameSequence).toBe(1n); + expect(session.acknowledgedFrameSequence).toBe(-1n); + const acknowledged = session.waitForAcknowledged(1n, 1_000); releaseOnline(); await vi.waitFor(() => expect(connection.sent).toEqual([Uint8Array.of(1), Uint8Array.of(2)]), ); connection.receive(ingressResponse(QWP_STATUS.OK, 1n)); + await expect(acknowledged).resolves.toBeUndefined(); await vi.waitFor(() => expect(replayStore.records.size).toBe(0)); + expect(session.acknowledgedFrameSequence).toBe(1n); expect(session.metrics).toMatchObject({ acknowledgedSequence: 1n, pendingReplayFrames: 0, @@ -996,20 +1001,29 @@ describe("QWP ingress reconnect and replay", () => { connection.receive(ingressResponse(QWP_STATUS.OK, 2n, [["trades", 50n]])); await expect(Promise.all(responses)).resolves.toHaveLength(3); expect(Array.from(replayStore.records.keys())).toEqual([0n, 1n, 2n]); + expect(session.acknowledgedFrameSequence).toBe(-1n); + let watermarkSettled = false; + const watermark = session.waitForAcknowledged(2n, 1_000).then(() => { + watermarkSettled = true; + }); connection.receive(durableResponse([["trades", 41n]])); await vi.waitFor(() => expect(session.metrics.totalDurableAcks).toBe(1)); expect(Array.from(replayStore.records.keys())).toEqual([0n, 1n, 2n]); + expect(watermarkSettled).toBe(false); connection.receive(durableResponse([["trades", 42n]])); await vi.waitFor(() => expect(Array.from(replayStore.records.keys())).toEqual([2n]), ); expect(session.metrics.replayAcknowledgedFrameSequence).toBe(1n); + expect(watermarkSettled).toBe(false); connection.receive(durableResponse([["trades", 50n]])); + await watermark; await vi.waitFor(() => expect(replayStore.records.size).toBe(0)); expect(session.metrics.replayAcknowledgedFrameSequence).toBe(2n); + expect(session.acknowledgedFrameSequence).toBe(2n); await session.close(); }); @@ -1188,12 +1202,15 @@ describe("QWP ingress reconnect and replay", () => { pendingReplayFrames: 0, totalFramesReplayed: 1, }); + expect(session.publishedFrameSequence).toBe(7n); + expect(session.acknowledgedFrameSequence).toBe(7n); const currentFrame = encodeQwpIngressFrame([symbolTable("SOL-USD")]); const current = session.sendFrame(currentFrame); await vi.waitFor(() => expect(connection.sent).toEqual([committed, currentFrame]), ); + expect(session.publishedFrameSequence).toBe(8n); connection.receive(ingressResponse(QWP_STATUS.OK, 1n, [["trades", 43n]])); await expect(current).resolves.toMatchObject({ sequence: 0n }); connection.receive(durableResponse([["trades", 43n]])); @@ -1202,6 +1219,7 @@ describe("QWP ingress reconnect and replay", () => { (await readdir(directory)).filter((name) => name.endsWith(".qwp")), ).toEqual([]), ); + expect(session.acknowledgedFrameSequence).toBe(8n); await session.close(); await rm(directory, { recursive: true, force: true }); }); diff --git a/test/qwp/sender-node-integration.test.ts b/test/qwp/sender-node-integration.test.ts index 629550a..301ec9f 100644 --- a/test/qwp/sender-node-integration.test.ts +++ b/test/qwp/sender-node-integration.test.ts @@ -1,6 +1,6 @@ import type { AddressInfo } from "node:net"; import { WebSocketServer } from "ws"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { Sender } from "../../src"; import { QWP_FLAG_DELTA_SYMBOL_DICTIONARY, @@ -35,6 +35,7 @@ describe("Sender QWP integration", () => { it("uses ws:: configuration, bearer authentication, and fluent rows", async () => { const frames: Uint8Array[] = []; + let acknowledge: (() => void) | undefined; let authorization: string | undefined; let requestPath: string | undefined; server = new WebSocketServer({ host: "127.0.0.1", port: 0 }); @@ -47,7 +48,8 @@ describe("Sender QWP integration", () => { requestPath = request.url; socket.on("message", (payload) => { frames.push(new Uint8Array(payload as Buffer)); - socket.send(okResponse(BigInt(frames.length - 1), "trades")); + acknowledge = () => + socket.send(okResponse(BigInt(frames.length - 1), "trades")); }); }); await new Promise((resolve, reject) => { @@ -66,7 +68,14 @@ describe("Sender QWP integration", () => { .floatColumn("price", 2_615.54) .intColumn("amount", 2) .atNow(); - await expect(sender.flush()).resolves.toBe(true); + await expect(sender.flushAndGetSequence()).resolves.toBe(0n); + expect(sender.publishedSequence).toBe(0n); + expect(sender.acknowledgedSequence).toBe(-1n); + await vi.waitFor(() => expect(acknowledge).toBeTypeOf("function")); + const acknowledged = sender.waitForAcknowledged(0n, 1_000); + acknowledge!(); + await expect(acknowledged).resolves.toBeUndefined(); + expect(sender.acknowledgedSequence).toBe(0n); await sender.close(); expect(authorization).toBe("Bearer secret"); diff --git a/test/qwp/sender.test.ts b/test/qwp/sender.test.ts index cf5e75d..9e7fa4d 100644 --- a/test/qwp/sender.test.ts +++ b/test/qwp/sender.test.ts @@ -18,15 +18,19 @@ class RecordingSession implements QwpSenderSession { readonly durable: QwpIngressResponse[] = []; deltaSendCount = 0; closeCount = 0; + publishedFrameSequence = -1n; + acknowledgedFrameSequence = -1n; async sendTables( tables: readonly QwpTableBuffer[], options?: QwpIngressEncodeOptions, ): Promise { this.sends.push({ tables, options }); + const sequence = ++this.publishedFrameSequence; + this.acknowledgedFrameSequence = sequence; return { status: QWP_STATUS.OK, - sequence: BigInt(this.sends.length - 1), + sequence, tables: tables.map((table) => ({ name: table.name, sequenceTransaction: BigInt(table.rowCount), @@ -61,9 +65,10 @@ class CommitAwareSession extends RecordingSession { options?: QwpIngressEncodeOptions, ): Promise { this.sends.push({ tables, options }); + const sequence = ++this.publishedFrameSequence; const response = { status: QWP_STATUS.OK, - sequence: BigInt(this.sends.length - 1), + sequence, tables: tables.map((table) => ({ name: table.name, sequenceTransaction: BigInt(table.rowCount), @@ -72,6 +77,7 @@ class CommitAwareSession extends RecordingSession { if (options?.deferCommit) { return new Promise((resolve) => this.deferred.push({ resolve })); } + this.acknowledgedFrameSequence = sequence; for (const pending of this.deferred.splice(0)) pending.resolve(response); return Promise.resolve(response); } @@ -107,6 +113,7 @@ class PublishingSession extends RecordingSession { this.publicationAttempts++; this.sends.push({ tables, options }); if (this.failPublication) throw new Error("journal is full"); + this.publishedFrameSequence++; } publishTablesDelta( @@ -118,6 +125,42 @@ class PublishingSession extends RecordingSession { } } +class WatermarkSession extends PublishingSession { + private readonly waiters = new Set<{ + target: bigint; + resolve: () => void; + }>(); + + waitForAcknowledged(target: bigint): Promise { + if (target < 0n || this.acknowledgedFrameSequence >= target) { + return Promise.resolve(); + } + return new Promise((resolve) => this.waiters.add({ target, resolve })); + } + + acknowledgeThrough(sequence: bigint): void { + this.acknowledgedFrameSequence = sequence; + for (const waiter of this.waiters) { + if (waiter.target > sequence) continue; + this.waiters.delete(waiter); + waiter.resolve(); + } + } +} + +class DeferredWatermarkSession extends PublishingSession { + override sendTablesDelta( + tables: readonly QwpTableBuffer[], + options?: Pick, + ): Promise { + if (!options?.deferCommit) return super.sendTablesDelta(tables, options); + this.deltaSendCount++; + this.sends.push({ tables, options }); + this.publishedFrameSequence++; + return new Promise(() => undefined); + } +} + function column(table: QwpTableBuffer, name: string) { const result = table.columns.find((candidate) => candidate.name === name); if (!result) throw new Error(`missing column '${name}'`); @@ -125,6 +168,51 @@ function column(table: QwpTableBuffer, name: string) { } describe("QWP high-level sender", () => { + it("returns a publication sequence and waits for its ACK independently", async () => { + const session = new WatermarkSession(); + const sender = new QwpSender(async () => session, { + autoFlush: false, + awaitServerAck: true, + }); + await sender.table("events").longColumn("value", 42n).atNow(); + + await expect(sender.flushAndGetSequence()).resolves.toBe(0n); + expect(sender.publishedSequence).toBe(0n); + expect(sender.acknowledgedSequence).toBe(-1n); + + let acknowledged = false; + const waiting = sender.waitForAcknowledged(0n, 1_000).then(() => { + acknowledged = true; + }); + await Promise.resolve(); + expect(acknowledged).toBe(false); + + session.acknowledgeThrough(0n); + await waiting; + expect(sender.acknowledgedSequence).toBe(0n); + await expect(sender.flushAndGetSequence()).resolves.toBe(-1n); + await sender.close(); + }); + + it("returns the commit sequence without awaiting deferred transaction ACKs", async () => { + const session = new DeferredWatermarkSession(); + const sender = new QwpSender(async () => session, { + autoFlushRows: 1, + autoFlushIntervalMs: 0, + awaitServerAck: true, + transactional: true, + }); + + await sender.table("events").longColumn("value", 42n).atNow(); + expect(sender.publishedSequence).toBe(0n); + await expect(sender.flushAndGetSequence()).resolves.toBe(1n); + expect(session.sends[1]).toMatchObject({ + tables: [], + options: { deferCommit: false }, + }); + await sender.close(); + }); + it("retains rows until publication-only flush succeeds", async () => { const session = new PublishingSession(); const sender = new QwpSender(async () => session, { diff --git a/test/qwp/session.test.ts b/test/qwp/session.test.ts index 7a67ad5..05e720f 100644 --- a/test/qwp/session.test.ts +++ b/test/qwp/session.test.ts @@ -30,6 +30,7 @@ import { encodeQwpFrame, encodeQwpIngressFrame, QWP_DURABLE_ACK_WEBSOCKET_PROTOCOL, + QwpIngressAckTimeoutError, QwpIngressNackError, QwpIngressSession, QwpIngressSessionClosedError, @@ -1174,6 +1175,163 @@ describe("QWP WebSocket adapters", () => { }); describe("QwpIngressSession", () => { + it("returns the highest split-frame sequence from the browser sender", async () => { + const socket = new FakeWebSocket(); + const cap = encodeQwpIngressFrame([longTable("events", [1n])], { + gorilla: false, + }).byteLength; + const sender = createQwpBrowserSender( + { + url: "ws://localhost:9000/write/v4", + webSocketFactory: () => asQwpSocket(socket), + }, + { + autoFlush: false, + encode: { symbolDictionary: "full", gorilla: false }, + }, + { maxBatchSizeBytes: cap }, + ); + const connecting = sender.connect(); + socket.open(); + await connecting; + for (const value of [1n, 2n, 3n, 4n]) { + await sender.table("events").longColumn("value", value).atNow(); + } + + await expect(sender.flushAndGetSequence()).resolves.toBe(3n); + expect(sender.publishedSequence).toBe(3n); + expect(socket.sent.map(firstIngressTableRowCount)).toEqual([1, 1, 1, 1]); + const acknowledged = sender.waitForAcknowledged(3n, 1_000); + socket.message( + ingressResponse(QWP_STATUS.OK, 3n, undefined, [["events", 4n]]), + ); + await expect(acknowledged).resolves.toBeUndefined(); + expect(sender.acknowledgedSequence).toBe(3n); + await sender.close(); + }); + + it("publishes frame sequences and resolves cumulative ACK waits independently", async () => { + const socket = new FakeWebSocket(); + const connecting = connectQwpBrowserWebSocket({ + url: "ws://localhost:9000/write/v4", + webSocketFactory: () => asQwpSocket(socket), + }); + socket.open(); + const session = new QwpIngressSession(await connecting); + + await session.publishFrame(Uint8Array.of(1)); + expect(session.publishedFrameSequence).toBe(0n); + await session.publishFrame(Uint8Array.of(2)); + expect(session.publishedFrameSequence).toBe(1n); + expect(session.acknowledgedFrameSequence).toBe(-1n); + + const first = session.waitForAcknowledged(0n, 1_000); + const second = session.waitForAcknowledged(1n, 1_000); + socket.message(ingressResponse(QWP_STATUS.OK, 1n)); + await expect(Promise.all([first, second])).resolves.toEqual([ + undefined, + undefined, + ]); + expect(session.acknowledgedFrameSequence).toBe(1n); + await expect(session.waitForAcknowledged(-1n)).resolves.toBeUndefined(); + await session.close(); + }); + + it("times out an independent ACK watermark wait without closing the session", async () => { + vi.useFakeTimers(); + try { + const socket = new FakeWebSocket(); + const connecting = connectQwpBrowserWebSocket({ + url: "ws://localhost:9000/write/v4", + webSocketFactory: () => asQwpSocket(socket), + }); + socket.open(); + const session = new QwpIngressSession(await connecting); + await session.publishFrame(Uint8Array.of(1)); + const sequence = session.publishedFrameSequence; + const waiting = session.waitForAcknowledged(sequence, 25); + const timedOut = expect(waiting).rejects.toEqual( + expect.objectContaining({ + name: "QwpIngressAckTimeoutError", + targetSequence: 0n, + acknowledgedSequence: -1n, + timeoutMs: 25, + } satisfies Partial), + ); + + await vi.advanceTimersByTimeAsync(25); + await timedOut; + expect(session.metrics.lastError).toBeInstanceOf( + QwpIngressAckTimeoutError, + ); + await expect( + session.publishFrame(Uint8Array.of(2)), + ).resolves.toBeUndefined(); + await session.close(); + } finally { + vi.useRealTimers(); + } + }); + + it("uses the durable watermark when durable ACKs are negotiated", async () => { + const socket = new FakeWebSocket(); + socket.protocol = QWP_DURABLE_ACK_WEBSOCKET_PROTOCOL; + const connecting = connectQwpBrowserIngress( + { + url: "ws://localhost:9000/write/v4", + requestDurableAck: true, + webSocketFactory: () => asQwpSocket(socket), + }, + { durableAckKeepaliveMs: 0 }, + ); + socket.open(); + const session = await connecting; + socket.onSend = () => { + socket.message( + ingressResponse(QWP_STATUS.OK, 0n, undefined, [["trades", 42n]]), + ); + }; + + await session.publishFrame(Uint8Array.of(1)); + const sequence = session.publishedFrameSequence; + await vi.waitFor(() => + expect(session.metrics.acknowledgedSequence).toBe(0n), + ); + expect(session.acknowledgedFrameSequence).toBe(-1n); + let settled = false; + const waiting = session.waitForAcknowledged(sequence, 1_000).then(() => { + settled = true; + }); + await Promise.resolve(); + expect(settled).toBe(false); + + socket.message(durableResponse([["trades", 42n]])); + await waiting; + expect(session.acknowledgedFrameSequence).toBe(0n); + await session.close(); + }); + + it("latches publication-only NACKs for later ACK watermark waits", async () => { + const socket = new FakeWebSocket(); + const connecting = connectQwpBrowserWebSocket({ + url: "ws://localhost:9000/write/v4", + webSocketFactory: () => asQwpSocket(socket), + }); + socket.open(); + const session = new QwpIngressSession(await connecting); + await session.publishFrame(Uint8Array.of(1)); + await session.publishFrame(Uint8Array.of(2)); + socket.message(ingressResponse(QWP_STATUS.WRITE_ERROR, 0n, "write failed")); + await vi.waitFor(() => expect(session.metrics.totalNacks).toBe(1)); + + await expect(session.waitForAcknowledged(1n, 1_000)).rejects.toMatchObject({ + name: "QwpIngressNackError", + response: { sequence: 0n, errorMessage: "write failed" }, + } satisfies Partial); + await expect(session.waitForAcknowledged(-1n)).resolves.toBeUndefined(); + await session.close(); + }); + it("validates session timeouts before invoking its connection factory", async () => { let factoryCalls = 0; await expect( From 3a4897142fabd119162b243cf7b1bdb28c96864d Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 18 Aug 2026 14:14:31 +0100 Subject: [PATCH 039/265] feat(qwp): add store-forward durability policies --- QWP.md | 34 +- src/qwp-node/file-replay-store.ts | 494 ++++++++++++++++++++++++++---- src/qwp/node.ts | 13 +- test/qwp/public-api-contract.ts | 4 + test/qwp/public-api.test.ts | 4 + test/qwp/reconnect.test.ts | 203 +++++++++++- 6 files changed, 680 insertions(+), 72 deletions(-) diff --git a/QWP.md b/QWP.md index f96f763..25504b9 100644 --- a/QWP.md +++ b/QWP.md @@ -72,6 +72,10 @@ const sender = await Sender.fromConfig( storeAndForward: { directory: "/var/lib/my-service/qwp-replay/producer-a", maxBytes: 512 * 1024 * 1024, + durability: "periodic", + checkpointIntervalMs: 5_000, + backpressurePolicy: "wait", + appendDeadlineMs: 30_000, drainOrphans: true, maxBackgroundDrainers: 4, }, @@ -95,10 +99,30 @@ Give each active sender its own store-and-forward directory. The Node.js journal persists frames and their symbol dictionary before sending. Persistent senders can start while every endpoint is offline and reconnect indefinitely by default. Unless `awaitServerAck: true` or `awaitDurableAck: true` is selected, `flush()` resolves once -the complete logical flush is durable in the local journal; a background drainer then -sends it in order. Applications can therefore keep publishing during an outage until -the configured `maxBytes` applies backpressure. A failed journal publication leaves -the high-level rows staged so the caller can retry. +the complete logical flush reaches the configured local journal boundary; a background +drainer then sends it in order. The default `"append"` boundary is locally durable, +while `"periodic"` and `"memory"` trade that immediate guarantee for throughput. +Applications can therefore keep publishing during an outage until the configured +`maxBytes` applies backpressure. A failed journal publication leaves the high-level +rows staged so the caller can retry. + +`durability` controls the local persistence barrier: + +- `"append"` (the backwards-compatible default) fsyncs every frame and its atomic + directory rename before publication resolves. +- `"periodic"` checkpoints frame files, symbol metadata, and directory changes in the + background. The default interval is 5 seconds, and `close()` performs a final + checkpoint. A power failure can lose the most recent checkpoint window. +- `"memory"` relies on operating-system writeback. It survives an orderly close and + normally a process failure, but it makes no power-loss durability promise. + +`backpressurePolicy: "error"` preserves the existing immediate +`QwpReplayStoreFullError` behavior. Set it to `"wait"` to pause publication until an +ACK deletes record files. `appendDeadlineMs` bounds each such pause (30 seconds by +default) and expiry raises `QwpReplayStoreAppendTimeoutError`. Waiting appenders do +not hold the journal mutation queue, so ACK cleanup can continue. Direct users of +`QwpNodeFileReplayStore` can inspect `metrics` for pending checkpoint work, +checkpoints, checkpoint failures, active waiters, stalls, and timeouts. The persisted symbol dictionary is lifetime-monotonic and cannot be reclaimed by an ACK. It counts toward the `maxBytes` target, but the journal preserves up to 32 MiB @@ -591,6 +615,8 @@ The public error classes preserve enough context for policy decisions: | `QwpReconnectExhaustedError` | The configured reconnect boundary was reached | | `QwpReplayRejectedError` | A replayed frame was rejected and retained for inspection | | `QwpReplayStoreFullError` | The Node.js replay journal reached its configured size | +| `QwpReplayStoreAppendTimeoutError` | The Node.js replay journal did not regain capacity before the configured append deadline | +| `QwpReplayStoreCheckpointError` | A periodic Node.js replay-journal checkpoint failed; operations fail closed until a retry succeeds | | `QwpReplayStoreLockedError` | Another process owns the configured Node.js replay directory | | `QwpEgressQueryError` | QuestDB returned a terminal query error | | `QwpEgressQueryAbandonedError` | Result iteration ended before the server completed the query | diff --git a/src/qwp-node/file-replay-store.ts b/src/qwp-node/file-replay-store.ts index 4d7078c..25cb9b9 100644 --- a/src/qwp-node/file-replay-store.ts +++ b/src/qwp-node/file-replay-store.ts @@ -36,13 +36,39 @@ const ABANDONED_LOCK_PREFIX = ".qwp.lock.abandoned-"; // default-sized QWP batches instead, mirroring Java's active+spare liveness // floor when the lifetime-monotonic dictionary consumes the configured cap. const DEFAULT_LIVE_FRAME_BYTES = 2 * 16 * 1024 * 1024; +const DEFAULT_CHECKPOINT_INTERVAL_MS = 5_000; +const DEFAULT_APPEND_DEADLINE_MS = 30_000; +const MAX_TIMER_DELAY_MS = 0x7fffffff; const UTF8_DECODER = new TextDecoder("utf-8", { fatal: true }); +export const QWP_SF_DURABILITY = { + MEMORY: "memory", + PERIODIC: "periodic", + APPEND: "append", +} as const; + +export type QwpSfDurability = + (typeof QWP_SF_DURABILITY)[keyof typeof QWP_SF_DURABILITY]; + +export const QWP_SF_BACKPRESSURE_POLICY = { + ERROR: "error", + WAIT: "wait", +} as const; + +export type QwpSfBackpressurePolicy = + (typeof QWP_SF_BACKPRESSURE_POLICY)[keyof typeof QWP_SF_BACKPRESSURE_POLICY]; + interface StoredRecord { readonly path: string; readonly size: number; } +interface PendingCapacity { + resolve: () => void; + reject: (error: Error) => void; + timer?: ReturnType; +} + interface ReplayStoreLockOwner { readonly version: 1; readonly token: string; @@ -60,6 +86,37 @@ export interface QwpNodeFileReplayStoreOptions { * target so it cannot permanently consume the journal's live frame budget. */ maxBytes?: number; + /** + * Local persistence barrier. `append` preserves the existing fsync-per-frame + * behavior, `periodic` checkpoints dirty files in the background, and + * `memory` relies on OS page-cache writeback. Defaults to `append`. + */ + durability?: QwpSfDurability; + /** Periodic durability checkpoint cadence. Defaults to 5 seconds. */ + checkpointIntervalMs?: number; + /** + * Behavior when maxBytes is exhausted. `error` fails immediately; `wait` + * pauses the append until ACK trimming frees space or its deadline expires. + * Defaults to `error` for backwards compatibility. + */ + backpressurePolicy?: QwpSfBackpressurePolicy; + /** Per-append disk-capacity wait deadline. Defaults to 30 seconds. */ + appendDeadlineMs?: number; +} + +export interface QwpNodeFileReplayStoreMetrics { + readonly durability: QwpSfDurability; + readonly backpressurePolicy: QwpSfBackpressurePolicy; + readonly pendingRecords: number; + readonly totalBytes: number; + readonly dirtyRecords: number; + readonly checkpointPending: boolean; + readonly waitingAppends: number; + readonly totalCheckpoints: number; + readonly totalCheckpointFailures: number; + readonly totalBackpressureStalls: number; + readonly totalAppendTimeouts: number; + readonly lastCheckpointError?: QwpReplayStoreCheckpointError; } export class QwpReplayStoreError extends Error { @@ -84,6 +141,32 @@ export class QwpReplayStoreFullError extends QwpReplayStoreError { } } +export class QwpReplayStoreAppendTimeoutError extends QwpReplayStoreError { + constructor( + readonly maxBytes: number, + readonly requiredBytes: number, + readonly timeoutMs: number, + ) { + super( + `QWP store-and-forward append remained backpressured for ${timeoutMs} ms [maxBytes=${maxBytes}, requiredBytes=${requiredBytes}]`, + ); + this.name = "QwpReplayStoreAppendTimeoutError"; + } +} + +export class QwpReplayStoreCheckpointError extends QwpReplayStoreError { + constructor( + readonly directory: string, + cause?: unknown, + ) { + super( + `could not checkpoint QWP store-and-forward journal [directory=${directory}]`, + cause, + ); + this.name = "QwpReplayStoreCheckpointError"; + } +} + export class QwpReplayStoreLockedError extends QwpReplayStoreError { constructor( readonly directory: string, @@ -102,24 +185,39 @@ export class QwpReplayStoreLockedError extends QwpReplayStoreError { } /** - * Crash-safe Node store-and-forward journal. + * Node store-and-forward journal with configurable local durability. * - * Each frame is fsynced under a temporary name before an atomic rename. An ACK - * removes its covered files and fsyncs the directory. A crash between the - * server ACK and local deletion can therefore cause at-least-once replay, but - * cannot silently lose an unacknowledged frame. An exclusive, lifetime lock - * prevents another process from recovering or mutating the same directory. + * `append` fsyncs each frame before its atomic rename, while `periodic` batches + * those barriers and `memory` relies on OS writeback. An ACK removes its + * covered files. A crash between the server ACK and local deletion can cause + * at-least-once replay. An exclusive, lifetime lock prevents another process + * from recovering or mutating the same directory. */ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { private readonly directory: string; private readonly maxBytes: number; private readonly liveFrameBytes: number; + private readonly durability: QwpSfDurability; + private readonly checkpointIntervalMs: number; + private readonly backpressurePolicy: QwpSfBackpressurePolicy; + private readonly appendDeadlineMs: number; private readonly records = new Map(); private readonly symbols: string[] = []; private readonly symbolValues = new Set(); + private readonly dirtyRecordPaths = new Set(); + private readonly capacityWaiters = new Set(); private operationTail: Promise = Promise.resolve(); private totalBytes = 0; private dictionaryFileSize = 0; + private dictionaryDirty = false; + private directoryDirty = false; + private capacityGeneration = 0; + private checkpointTimer?: ReturnType; + private checkpointFailure?: QwpReplayStoreCheckpointError; + private totalCheckpoints = 0; + private totalCheckpointFailures = 0; + private totalBackpressureStalls = 0; + private totalAppendTimeouts = 0; private lockOwner?: ReplayStoreLockOwner; private closePromise?: Promise; private loaded = false; @@ -140,6 +238,48 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { this.directory = directory; this.maxBytes = maxBytes; this.liveFrameBytes = Math.min(maxBytes, DEFAULT_LIVE_FRAME_BYTES); + this.durability = validateDurability( + options.durability ?? QWP_SF_DURABILITY.APPEND, + ); + this.backpressurePolicy = validateBackpressurePolicy( + options.backpressurePolicy ?? QWP_SF_BACKPRESSURE_POLICY.ERROR, + ); + this.checkpointIntervalMs = validateTimerDelay( + options.checkpointIntervalMs ?? DEFAULT_CHECKPOINT_INTERVAL_MS, + "store-and-forward checkpointIntervalMs", + ); + if ( + options.checkpointIntervalMs !== undefined && + this.durability !== QWP_SF_DURABILITY.PERIODIC + ) { + throw new RangeError( + "store-and-forward checkpointIntervalMs requires durability='periodic'", + ); + } + this.appendDeadlineMs = validateTimerDelay( + options.appendDeadlineMs ?? DEFAULT_APPEND_DEADLINE_MS, + "store-and-forward appendDeadlineMs", + ); + } + + get metrics(): QwpNodeFileReplayStoreMetrics { + return Object.freeze({ + durability: this.durability, + backpressurePolicy: this.backpressurePolicy, + pendingRecords: this.records.size, + totalBytes: this.totalBytes, + dirtyRecords: this.dirtyRecordPaths.size, + checkpointPending: + this.dirtyRecordPaths.size > 0 || + this.dictionaryDirty || + this.directoryDirty, + waitingAppends: this.capacityWaiters.size, + totalCheckpoints: this.totalCheckpoints, + totalCheckpointFailures: this.totalCheckpointFailures, + totalBackpressureStalls: this.totalBackpressureStalls, + totalAppendTimeouts: this.totalAppendTimeouts, + lastCheckpointError: this.checkpointFailure, + }); } load(): Promise { @@ -209,6 +349,7 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { await this.loadDictionaryFile(); this.loaded = true; loadSucceeded = true; + this.scheduleCheckpoint(); return recovered; } finally { if (!loadSucceeded) await this.releaseDirectoryLock(); @@ -218,57 +359,13 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { append(record: QwpIngressReplayRecord): Promise { if (this.closing || this.closed) return Promise.reject(this.closedError()); - return this.enqueue(async () => { - this.assertReady(); - validateFrameSequence(record.frameSequence); - if (this.records.has(record.frameSequence)) { - throw new QwpReplayStoreError( - `QWP store-and-forward sequence already exists [frameSequence=${record.frameSequence}]`, - ); - } - const bytes = encodeRecord(record); - const requiredBytes = this.totalBytes + bytes.byteLength; - const frameBytes = this.totalBytes - this.dictionaryFileSize; - const requiredFrameBytes = frameBytes + bytes.byteLength; - const preservesLiveness = - this.dictionaryFileSize > 0 && - (requiredFrameBytes <= this.liveFrameBytes || frameBytes === 0); - if ( - bytes.byteLength > this.maxBytes || - (requiredBytes > this.maxBytes && !preservesLiveness) - ) { - throw new QwpReplayStoreFullError(this.maxBytes, requiredBytes); - } - - const name = recordFileName(record.frameSequence); - const finalPath = join(this.directory, name); - const temporaryPath = join( - this.directory, - `${name}${TEMP_MARKER}${process.pid}-${randomUUID()}`, + const bytes = encodeRecord(record); + if (bytes.byteLength > this.maxBytes) { + return Promise.reject( + new QwpReplayStoreFullError(this.maxBytes, bytes.byteLength), ); - try { - const file = await open(temporaryPath, "wx", 0o600); - try { - await file.writeFile(bytes); - await file.sync(); - } finally { - await file.close(); - } - await rename(temporaryPath, finalPath); - await syncDirectory(this.directory); - } catch (error) { - await ignoreMissing(unlink(temporaryPath)); - throw new QwpReplayStoreError( - `could not persist QWP store-and-forward record [frameSequence=${record.frameSequence}]`, - error, - ); - } - this.records.set(record.frameSequence, { - path: finalPath, - size: bytes.byteLength, - }); - this.totalBytes = requiredBytes; - }); + } + return this.appendWithBackpressure(record, bytes); } acknowledgeThrough(frameSequence: bigint): Promise { @@ -287,10 +384,17 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { ); } this.records.delete(sequence); + this.dirtyRecordPaths.delete(record.path); this.totalBytes -= record.size; changed = true; } - if (changed) await syncDirectory(this.directory); + if (!changed) return; + if (this.durability === QWP_SF_DURABILITY.APPEND) { + await syncDirectory(this.directory); + } else if (this.durability === QWP_SF_DURABILITY.PERIODIC) { + this.directoryDirty = true; + } + this.signalCapacity(); }); } @@ -346,12 +450,19 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { await file.writeFile( Buffer.concat([encodeDictionaryHeader(), block]), ); - await file.sync(); + if (this.durability === QWP_SF_DURABILITY.APPEND) { + await file.sync(); + } } finally { await file.close(); } await rename(temporaryPath, finalPath); - await syncDirectory(this.directory); + if (this.durability === QWP_SF_DURABILITY.APPEND) { + await syncDirectory(this.directory); + } else if (this.durability === QWP_SF_DURABILITY.PERIODIC) { + this.dictionaryDirty = true; + this.directoryDirty = true; + } } catch (error) { await ignoreMissing(unlink(temporaryPath)); throw new QwpReplayStoreError( @@ -364,7 +475,11 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { const file = await open(finalPath, "a", 0o600); try { await file.writeFile(block); - await file.sync(); + if (this.durability === QWP_SF_DURABILITY.APPEND) { + await file.sync(); + } else if (this.durability === QWP_SF_DURABILITY.PERIODIC) { + this.dictionaryDirty = true; + } } finally { await file.close(); } @@ -385,16 +500,225 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { close(): Promise { if (this.closePromise) return this.closePromise; this.closing = true; + if (this.checkpointTimer) clearTimeout(this.checkpointTimer); + this.checkpointTimer = undefined; + this.rejectCapacityWaiters(this.closedError()); this.closePromise = this.operationTail.then(async () => { + let failure: unknown; + try { + if (this.durability === QWP_SF_DURABILITY.PERIODIC) { + await this.checkpointDirty(); + } + if (this.checkpointFailure) throw this.checkpointFailure; + } catch (error) { + failure = error; + } try { await this.releaseDirectoryLock(); + } catch (error) { + failure ??= error; } finally { this.closed = true; } + if (failure) throw failure; }); return this.closePromise; } + private async appendWithBackpressure( + record: QwpIngressReplayRecord, + bytes: Buffer, + ): Promise { + let deadline = 0; + let stalled = false; + for (;;) { + if (this.closing || this.closed) throw this.closedError(); + const capacityGeneration = this.capacityGeneration; + try { + await this.enqueue(() => this.appendOnce(record, bytes)); + return; + } catch (error) { + if (!(error instanceof QwpReplayStoreFullError)) throw error; + if (this.backpressurePolicy === QWP_SF_BACKPRESSURE_POLICY.ERROR) { + throw error; + } + if (!stalled) { + stalled = true; + deadline = Date.now() + this.appendDeadlineMs; + this.totalBackpressureStalls++; + } + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) { + this.totalAppendTimeouts++; + throw new QwpReplayStoreAppendTimeoutError( + this.maxBytes, + error.requiredBytes, + this.appendDeadlineMs, + ); + } + await this.waitForCapacity(capacityGeneration, remainingMs, error); + } + } + } + + private async appendOnce( + record: QwpIngressReplayRecord, + bytes: Buffer, + ): Promise { + this.assertReady(); + validateFrameSequence(record.frameSequence); + if (this.records.has(record.frameSequence)) { + throw new QwpReplayStoreError( + `QWP store-and-forward sequence already exists [frameSequence=${record.frameSequence}]`, + ); + } + const requiredBytes = this.totalBytes + bytes.byteLength; + const frameBytes = this.totalBytes - this.dictionaryFileSize; + const requiredFrameBytes = frameBytes + bytes.byteLength; + const preservesLiveness = + this.dictionaryFileSize > 0 && + (requiredFrameBytes <= this.liveFrameBytes || frameBytes === 0); + if (requiredBytes > this.maxBytes && !preservesLiveness) { + throw new QwpReplayStoreFullError(this.maxBytes, requiredBytes); + } + + const name = recordFileName(record.frameSequence); + const finalPath = join(this.directory, name); + const temporaryPath = join( + this.directory, + `${name}${TEMP_MARKER}${process.pid}-${randomUUID()}`, + ); + try { + const file = await open(temporaryPath, "wx", 0o600); + try { + await file.writeFile(bytes); + if (this.durability === QWP_SF_DURABILITY.APPEND) { + await file.sync(); + } + } finally { + await file.close(); + } + await rename(temporaryPath, finalPath); + if (this.durability === QWP_SF_DURABILITY.APPEND) { + await syncDirectory(this.directory); + } else if (this.durability === QWP_SF_DURABILITY.PERIODIC) { + this.dirtyRecordPaths.add(finalPath); + this.directoryDirty = true; + } + } catch (error) { + await ignoreMissing(unlink(temporaryPath)); + throw new QwpReplayStoreError( + `could not persist QWP store-and-forward record [frameSequence=${record.frameSequence}]`, + error, + ); + } + this.records.set(record.frameSequence, { + path: finalPath, + size: bytes.byteLength, + }); + this.totalBytes = requiredBytes; + } + + private waitForCapacity( + capacityGeneration: number, + timeoutMs: number, + full: QwpReplayStoreFullError, + ): Promise { + if (this.checkpointFailure) { + return Promise.reject(this.checkpointFailure); + } + if (capacityGeneration !== this.capacityGeneration) { + return Promise.resolve(); + } + return new Promise((resolve, reject) => { + const pending: PendingCapacity = { resolve, reject }; + pending.timer = setTimeout(() => { + if (!this.capacityWaiters.delete(pending)) return; + this.totalAppendTimeouts++; + reject( + new QwpReplayStoreAppendTimeoutError( + this.maxBytes, + full.requiredBytes, + this.appendDeadlineMs, + ), + ); + }, timeoutMs); + this.capacityWaiters.add(pending); + if (capacityGeneration !== this.capacityGeneration) { + this.capacityWaiters.delete(pending); + clearTimeout(pending.timer); + resolve(); + } + }); + } + + private signalCapacity(): void { + this.capacityGeneration++; + for (const pending of this.capacityWaiters) { + this.capacityWaiters.delete(pending); + if (pending.timer) clearTimeout(pending.timer); + pending.resolve(); + } + } + + private rejectCapacityWaiters(error: Error): void { + for (const pending of this.capacityWaiters) { + this.capacityWaiters.delete(pending); + if (pending.timer) clearTimeout(pending.timer); + pending.reject(error); + } + } + + private scheduleCheckpoint(): void { + if ( + this.durability !== QWP_SF_DURABILITY.PERIODIC || + !this.loaded || + this.closing || + this.closed || + this.checkpointTimer + ) { + return; + } + this.checkpointTimer = setTimeout(() => { + this.checkpointTimer = undefined; + if (this.closing || this.closed) return; + const checkpoint = this.enqueue(() => this.checkpointDirty()); + void checkpoint.then( + () => this.scheduleCheckpoint(), + () => this.scheduleCheckpoint(), + ); + }, this.checkpointIntervalMs); + this.checkpointTimer.unref?.(); + } + + private async checkpointDirty(): Promise { + if ( + this.dirtyRecordPaths.size === 0 && + !this.dictionaryDirty && + !this.directoryDirty + ) { + return; + } + try { + for (const path of this.dirtyRecordPaths) await syncFile(path); + if (this.dictionaryDirty) { + await syncFile(join(this.directory, DICTIONARY_FILE)); + } + if (this.directoryDirty) await syncDirectory(this.directory); + this.dirtyRecordPaths.clear(); + this.dictionaryDirty = false; + this.directoryDirty = false; + this.checkpointFailure = undefined; + this.totalCheckpoints++; + } catch (cause) { + const error = new QwpReplayStoreCheckpointError(this.directory, cause); + this.checkpointFailure = error; + this.totalCheckpointFailures++; + this.rejectCapacityWaiters(error); + throw error; + } + } + private enqueue(operation: () => Promise): Promise { const result = this.operationTail.then(operation); this.operationTail = result.then( @@ -652,6 +976,7 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { "QWP store-and-forward journal must be loaded before use", ); } + if (this.checkpointFailure) throw this.checkpointFailure; } private closedError(): QwpReplayStoreError { @@ -820,6 +1145,51 @@ async function syncDirectory(directory: string): Promise { } } +async function syncFile(path: string): Promise { + const handle = await open(path, "r"); + try { + await handle.sync(); + } finally { + await handle.close(); + } +} + +function validateDurability(value: string): QwpSfDurability { + if ( + value === QWP_SF_DURABILITY.MEMORY || + value === QWP_SF_DURABILITY.PERIODIC || + value === QWP_SF_DURABILITY.APPEND + ) { + return value; + } + throw new RangeError(`unsupported store-and-forward durability '${value}'`); +} + +function validateBackpressurePolicy(value: string): QwpSfBackpressurePolicy { + if ( + value === QWP_SF_BACKPRESSURE_POLICY.ERROR || + value === QWP_SF_BACKPRESSURE_POLICY.WAIT + ) { + return value; + } + throw new RangeError( + `unsupported store-and-forward backpressurePolicy '${value}'`, + ); +} + +function validateTimerDelay(value: number, name: string): number { + if ( + !Number.isSafeInteger(value) || + value <= 0 || + value > MAX_TIMER_DELAY_MS + ) { + throw new RangeError( + `${name} must be a positive safe integer no greater than ${MAX_TIMER_DELAY_MS}`, + ); + } + return value; +} + async function ignoreMissing(operation: Promise): Promise { try { await operation; @@ -879,9 +1249,7 @@ async function writeLockOwner( } } -function isDefinitelyDeadLockOwner( - owner: ReplayStoreLockOwner, -): boolean { +function isDefinitelyDeadLockOwner(owner: ReplayStoreLockOwner): boolean { if (owner.hostname !== hostname() || owner.pid === process.pid) { return false; } diff --git a/src/qwp/node.ts b/src/qwp/node.ts index 9bfbf48..0dee681 100644 --- a/src/qwp/node.ts +++ b/src/qwp/node.ts @@ -40,12 +40,21 @@ import { } from "../qwp-node/orphan-drainer"; export { + QWP_SF_BACKPRESSURE_POLICY, + QWP_SF_DURABILITY, QwpNodeFileReplayStore, + QwpReplayStoreAppendTimeoutError, + QwpReplayStoreCheckpointError, QwpReplayStoreError, QwpReplayStoreFullError, QwpReplayStoreLockedError, } from "../qwp-node/file-replay-store"; -export type { QwpNodeFileReplayStoreOptions } from "../qwp-node/file-replay-store"; +export type { + QwpNodeFileReplayStoreMetrics, + QwpNodeFileReplayStoreOptions, + QwpSfBackpressurePolicy, + QwpSfDurability, +} from "../qwp-node/file-replay-store"; export { QWP_ORPHAN_DRAIN_EVENT_KIND, QWP_ORPHAN_FAILED_SENTINEL, @@ -627,8 +636,8 @@ function createNodeOrphanDrainer( { ...options, storeAndForward: { + ...storeAndForward, directory, - maxBytes: storeAndForward.maxBytes, drainOrphans: false, }, }, diff --git a/test/qwp/public-api-contract.ts b/test/qwp/public-api-contract.ts index 6ce00e6..82a5ace 100644 --- a/test/qwp/public-api-contract.ts +++ b/test/qwp/public-api-contract.ts @@ -105,6 +105,10 @@ const nodeOrphanRetrySignature: (directory: string) => Promise = const nodeStoreAndForwardContract: QwpNodeStoreAndForwardOptions = { directory: "/tmp/qwp-public-api-contract", + durability: "periodic", + checkpointIntervalMs: 5_000, + backpressurePolicy: "wait", + appendDeadlineMs: 30_000, drainOrphans: true, maxBackgroundDrainers: 2, orphanScanIntervalMs: 30_000, diff --git a/test/qwp/public-api.test.ts b/test/qwp/public-api.test.ts index 7de78f2..aef5c82 100644 --- a/test/qwp/public-api.test.ts +++ b/test/qwp/public-api.test.ts @@ -55,8 +55,12 @@ const browserRuntimeContract = [ const nodeRuntimeContract = [ "QWP_ORPHAN_DRAIN_EVENT_KIND", "QWP_ORPHAN_FAILED_SENTINEL", + "QWP_SF_BACKPRESSURE_POLICY", + "QWP_SF_DURABILITY", "QwpNodeFileReplayStore", "QwpNodeOrphanDrainer", + "QwpReplayStoreAppendTimeoutError", + "QwpReplayStoreCheckpointError", "QwpReplayStoreError", "QwpReplayStoreFullError", "QwpReplayStoreLockedError", diff --git a/test/qwp/reconnect.test.ts b/test/qwp/reconnect.test.ts index 0380327..9ddaba7 100644 --- a/test/qwp/reconnect.test.ts +++ b/test/qwp/reconnect.test.ts @@ -1,10 +1,21 @@ -import { mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises"; +import { + mkdir, + mkdtemp, + readdir, + rm, + unlink, + writeFile, +} from "node:fs/promises"; import { hostname, tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { connectQwpNodeIngress, + QWP_SF_BACKPRESSURE_POLICY, + QWP_SF_DURABILITY, QwpNodeFileReplayStore, + QwpReplayStoreAppendTimeoutError, + QwpReplayStoreCheckpointError, QwpReplayStoreError, QwpReplayStoreFullError, QwpReplayStoreLockedError, @@ -1203,7 +1214,7 @@ describe("QWP ingress reconnect and replay", () => { totalFramesReplayed: 1, }); expect(session.publishedFrameSequence).toBe(7n); - expect(session.acknowledgedFrameSequence).toBe(7n); + await vi.waitFor(() => expect(session.acknowledgedFrameSequence).toBe(7n)); const currentFrame = encodeQwpIngressFrame([symbolTable("SOL-USD")]); const current = session.sendFrame(currentFrame); @@ -1219,7 +1230,7 @@ describe("QWP ingress reconnect and replay", () => { (await readdir(directory)).filter((name) => name.endsWith(".qwp")), ).toEqual([]), ); - expect(session.acknowledgedFrameSequence).toBe(8n); + await vi.waitFor(() => expect(session.acknowledgedFrameSequence).toBe(8n)); await session.close(); await rm(directory, { recursive: true, force: true }); }); @@ -1461,6 +1472,48 @@ describe("QWP Node file replay store", () => { return directory; } + it("validates durability, checkpoint, and disk-backpressure controls", async () => { + const directory = await trackedDirectory(); + + expect( + () => + new QwpNodeFileReplayStore({ + directory, + durability: "unsupported" as "append", + }), + ).toThrow(/unsupported store-and-forward durability/); + expect( + () => + new QwpNodeFileReplayStore({ + directory, + backpressurePolicy: "unsupported" as "error", + }), + ).toThrow(/unsupported store-and-forward backpressurePolicy/); + expect( + () => new QwpNodeFileReplayStore({ directory, checkpointIntervalMs: 1 }), + ).toThrow(/requires durability='periodic'/); + expect( + () => + new QwpNodeFileReplayStore({ + directory, + durability: QWP_SF_DURABILITY.PERIODIC, + checkpointIntervalMs: 0, + }), + ).toThrow(/checkpointIntervalMs must be a positive safe integer/); + expect( + () => new QwpNodeFileReplayStore({ directory, appendDeadlineMs: 0 }), + ).toThrow(/appendDeadlineMs must be a positive safe integer/); + + const defaults = new QwpNodeFileReplayStore({ directory }); + expect(defaults.metrics).toMatchObject({ + durability: QWP_SF_DURABILITY.APPEND, + backpressurePolicy: QWP_SF_BACKPRESSURE_POLICY.ERROR, + totalCheckpoints: 0, + totalBackpressureStalls: 0, + }); + await defaults.close(); + }); + it("survives restart and deletes only the acknowledged prefix", async () => { const directory = await trackedDirectory(); const first = new QwpNodeFileReplayStore({ directory }); @@ -1592,6 +1645,150 @@ describe("QWP Node file replay store", () => { await store.close(); }); + it("checkpoints periodic frame and dictionary writes", async () => { + const directory = await trackedDirectory(); + const store = new QwpNodeFileReplayStore({ + directory, + durability: QWP_SF_DURABILITY.PERIODIC, + checkpointIntervalMs: 25, + }); + await store.load(); + await store.append({ frameSequence: 0n, payload: Uint8Array.of(1) }); + await store.appendSymbolDictionary(0, ["BTC-USD"]); + expect(store.metrics.checkpointPending).toBe(true); + + await vi.waitFor(() => { + expect(store.metrics.dirtyRecords).toBe(0); + expect(store.metrics.checkpointPending).toBe(false); + expect(store.metrics.totalCheckpoints).toBeGreaterThan(0); + expect(store.metrics.totalCheckpointFailures).toBe(0); + }); + await store.close(); + + const recovered = new QwpNodeFileReplayStore({ directory }); + await expect(recovered.load()).resolves.toEqual([ + { frameSequence: 0n, payload: Uint8Array.of(1) }, + ]); + await expect(recovered.loadSymbolDictionary()).resolves.toEqual([ + "BTC-USD", + ]); + await recovered.close(); + }); + + it("supports memory durability without running checkpoints", async () => { + const directory = await trackedDirectory(); + const store = new QwpNodeFileReplayStore({ + directory, + durability: QWP_SF_DURABILITY.MEMORY, + }); + await store.load(); + await store.append({ frameSequence: 0n, payload: Uint8Array.of(7) }); + await store.appendSymbolDictionary(0, ["ETH-USD"]); + expect(store.metrics).toMatchObject({ + durability: QWP_SF_DURABILITY.MEMORY, + dirtyRecords: 0, + checkpointPending: false, + totalCheckpoints: 0, + }); + await store.close(); + }); + + it("fails waiting appends closed when a periodic checkpoint fails", async () => { + const directory = await trackedDirectory(); + const store = new QwpNodeFileReplayStore({ + directory, + maxBytes: 106, + durability: QWP_SF_DURABILITY.PERIODIC, + checkpointIntervalMs: 250, + backpressurePolicy: QWP_SF_BACKPRESSURE_POLICY.WAIT, + appendDeadlineMs: 2_000, + }); + await store.load(); + await store.append({ frameSequence: 0n, payload: Uint8Array.of(1) }); + await store.append({ frameSequence: 1n, payload: Uint8Array.of(2) }); + const record = (await readdir(directory)).find((name) => + name.endsWith(".qwp"), + ); + expect(record).toBeDefined(); + await unlink(join(directory, record!)); + + const blocked = store.append({ + frameSequence: 2n, + payload: Uint8Array.of(3), + }); + await vi.waitFor(() => expect(store.metrics.waitingAppends).toBe(1)); + await expect(blocked).rejects.toBeInstanceOf(QwpReplayStoreCheckpointError); + expect(store.metrics).toMatchObject({ + waitingAppends: 0, + totalCheckpointFailures: 1, + totalAppendTimeouts: 0, + }); + await expect(store.close()).rejects.toBeInstanceOf( + QwpReplayStoreCheckpointError, + ); + expect(await readdir(directory)).not.toContain(".qwp.lock"); + }); + + it("waits for ACK trimming without blocking the acknowledgement queue", async () => { + const directory = await trackedDirectory(); + const store = new QwpNodeFileReplayStore({ + directory, + maxBytes: 106, + backpressurePolicy: QWP_SF_BACKPRESSURE_POLICY.WAIT, + appendDeadlineMs: 1_000, + }); + await store.load(); + await store.append({ frameSequence: 0n, payload: Uint8Array.of(1) }); + await store.append({ frameSequence: 1n, payload: Uint8Array.of(2) }); + + const blocked = store.append({ + frameSequence: 2n, + payload: Uint8Array.of(3), + }); + await vi.waitFor(() => expect(store.metrics.waitingAppends).toBe(1)); + await store.acknowledgeThrough(0n); + await expect(blocked).resolves.toBeUndefined(); + expect(store.metrics).toMatchObject({ + pendingRecords: 2, + waitingAppends: 0, + totalBackpressureStalls: 1, + totalAppendTimeouts: 0, + }); + await store.close(); + }); + + it("bounds disk-backpressure waits with a typed append timeout", async () => { + const directory = await trackedDirectory(); + const store = new QwpNodeFileReplayStore({ + directory, + maxBytes: 106, + backpressurePolicy: QWP_SF_BACKPRESSURE_POLICY.WAIT, + appendDeadlineMs: 100, + }); + await store.load(); + await store.append({ frameSequence: 0n, payload: Uint8Array.of(1) }); + await store.append({ frameSequence: 1n, payload: Uint8Array.of(2) }); + + const blocked = store.append({ + frameSequence: 2n, + payload: Uint8Array.of(3), + }); + const rejection = expect(blocked).rejects.toMatchObject({ + name: "QwpReplayStoreAppendTimeoutError", + maxBytes: 106, + requiredBytes: 159, + timeoutMs: 100, + } satisfies Partial); + await vi.waitFor(() => expect(store.metrics.waitingAppends).toBe(1)); + await rejection; + expect(store.metrics).toMatchObject({ + waitingAppends: 0, + totalBackpressureStalls: 1, + totalAppendTimeouts: 1, + }); + await store.close(); + }); + it("preserves a live frame budget after dictionary growth exhausts the target", async () => { const directory = await trackedDirectory(); const first = new QwpNodeFileReplayStore({ From 9652483920a36a883014ca9be51a357b079b2dc3 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 18 Aug 2026 14:25:11 +0100 Subject: [PATCH 040/265] feat(qwp): add byte-based auto flush --- QWP.md | 13 +++ README.md | 1 + src/options.ts | 19 ++++ src/qwp/sender.ts | 112 +++++++++++++++++++-- src/sender.ts | 3 + test/options.test.ts | 21 ++++ test/qwp/public-api-contract.ts | 1 + test/qwp/sender-node-integration.test.ts | 33 +++++++ test/qwp/sender.test.ts | 120 +++++++++++++++++++++++ 9 files changed, 315 insertions(+), 8 deletions(-) diff --git a/QWP.md b/QWP.md index 25504b9..942aabf 100644 --- a/QWP.md +++ b/QWP.md @@ -83,6 +83,7 @@ const sender = await Sender.fromConfig( sender: { awaitDurableAck: true, autoFlushRows: 10_000, + autoFlushBytes: 4 * 1024 * 1024, }, session: { reconnect: { @@ -185,6 +186,7 @@ const sender = await connectQwpNodeSender( }, { autoFlushRows: 5_000, + autoFlushBytes: 4 * 1024 * 1024, autoFlushIntervalMs: 1_000, encode: { symbolDictionary: "delta", gorilla: true }, }, @@ -232,6 +234,17 @@ Rows are staged until an auto-flush boundary or an explicit `flush()`. A `null` assign the designated timestamp; `at(value, unit)` sends an explicit `ns`, `us`, or `ms` timestamp. `close()` does not flush pending rows. +`autoFlushBytes` is a soft threshold over estimated raw column-buffer storage and is +disabled by default (`0`). It combines with `autoFlushRows` and +`autoFlushIntervalMs`: reaching any enabled threshold flushes after the completed row, +so one row of overshoot is possible. Once connected, an enabled byte threshold is +clamped to 90% of the server-advertised batch cap. Schema and symbol-dictionary +overhead make this an estimate; exact encoded-size enforcement and automatic frame +splitting remain the ingress session's responsibility. `sender.metrics.pendingBytes` +and `sender.metrics.effectiveAutoFlushBytes` expose the live estimate and applied +threshold. Configuration strings use `auto_flush_bytes=N`; `off` is equivalent to +zero. + The sender automatically maintains connection-scoped symbol IDs, emits dictionary deltas, tracks acknowledgements, and splits multi-row batches at the smaller of the client cap and the server-advertised cap. One row that cannot fit is rejected with diff --git a/README.md b/README.md index 79e695d..18a1f46 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,7 @@ const sender = await connectQwpBrowserSender( { url }, { autoFlushRows: 10_000, + autoFlushBytes: 4 * 1024 * 1024, transactional: true, }, ); diff --git a/src/options.ts b/src/options.ts index 229763e..13ecb1a 100644 --- a/src/options.ts +++ b/src/options.ts @@ -122,6 +122,9 @@ type DeprecatedOptions = { *
  • auto_flush_rows: integer - The number of rows that will trigger a flush. When set to 0, row-based flushing is disabled.
    * The Sender will default this parameter to 75000 rows when HTTP protocol is used, and to 600 in case of TCP protocol. *
  • + *
  • auto_flush_bytes: integer or off - QWP WebSocket buffered-byte threshold. Defaults to off.
    + * Reaching the threshold flushes after the completed row. This option is supported by ws/wss only. + *
  • *
  • auto_flush_interval: integer - The number of milliseconds that will trigger a flush, default value is 1000. * When set to 0, interval-based flushing is disabled.
    * Note that the setting is checked only when a new row is added to the buffer. There is no timer registered to flush the buffer automatically. @@ -178,6 +181,7 @@ class SenderOptions { auto_flush?: boolean; auto_flush_rows?: number; + auto_flush_bytes?: number; auto_flush_interval?: number; request_min_throughput?: number; @@ -440,6 +444,7 @@ const ValidConfigKeys = [ "token_y", "auto_flush", "auto_flush_rows", + "auto_flush_bytes", "auto_flush_interval", "request_min_throughput", "request_timeout", @@ -586,6 +591,20 @@ function parseBufferSizes(options: SenderOptions) { function parseAutoFlushOptions(options: SenderOptions) { parseBoolean(options, "auto_flush", "auto flush"); parseInteger(options, "auto_flush_rows", "auto flush rows", 0); + if ((options.auto_flush_bytes as unknown) === OFF) { + options.auto_flush_bytes = 0; + } else { + parseInteger(options, "auto_flush_bytes", "auto flush bytes", 0); + } + if ( + options.auto_flush_bytes !== undefined && + options.protocol !== WS && + options.protocol !== WSS + ) { + throw new Error( + "auto_flush_bytes is only supported for QWP ws/wss transport", + ); + } parseInteger(options, "auto_flush_interval", "auto flush interval", 0); } diff --git a/src/qwp/sender.ts b/src/qwp/sender.ts index e235932..5423114 100644 --- a/src/qwp/sender.ts +++ b/src/qwp/sender.ts @@ -5,6 +5,8 @@ import { QwpIngressResponse, QwpTableBuffer, flattenQwpArray, + utf8Length, + type QwpArrayValue, } from "./core"; import type { QwpIngressMetrics } from "./ingress-session"; @@ -25,6 +27,12 @@ export interface QwpSenderEncodeOptions export interface QwpSenderOptions { autoFlush?: boolean; autoFlushRows?: number; + /** + * Soft threshold for estimated buffered column bytes. Zero disables the byte + * trigger. Defaults to zero and is clamped below a connected server's batch + * cap; exact encoded frames remain subject to the protocol batch limit. + */ + autoFlushBytes?: number; autoFlushIntervalMs?: number; /** * Keep auto-flushed rows in an open server-side transaction. An explicit @@ -50,6 +58,7 @@ export interface QwpSenderOptions { /** The subset of QwpIngressSession used by QwpSender. */ export interface QwpSenderSession { readonly metrics?: QwpIngressMetrics; + readonly maxBatchSizeBytes?: number; readonly publishedFrameSequence?: bigint; readonly acknowledgedFrameSequence?: bigint; sendTables( @@ -90,6 +99,10 @@ export interface QwpSenderMetrics { readonly totalFlushFailures: number; readonly totalTransactionsCommitted: number; readonly pendingRows: number; + /** Estimated raw column-buffer bytes currently staged. */ + readonly pendingBytes: number; + readonly autoFlushBytes: number; + readonly effectiveAutoFlushBytes: number; readonly deferredRows: number; readonly connected: boolean; readonly closing: boolean; @@ -107,19 +120,25 @@ interface StagedColumn { interface StagedTable { name: string; - rows: Map[]; + rows: StagedRow[]; schema: Map< string, Pick >; } +interface StagedRow { + readonly columns: Map; + readonly estimatedBytes: number; +} + interface QwpSenderFlushResult { readonly flushed: boolean; readonly sequence: bigint; } const DEFAULT_AUTO_FLUSH_ROWS = 1_000; +const DEFAULT_AUTO_FLUSH_BYTES = 0; const DEFAULT_AUTO_FLUSH_INTERVAL_MS = 100; function validateNonNegativeInteger(value: number, name: string): void { @@ -216,6 +235,52 @@ function fitsSigned(value: bigint, bits: number): boolean { return BigInt.asIntN(bits, value) === value; } +/** Mirrors the Java QWP sender's raw column-buffer byte accounting. */ +function stagedColumnBytes(column: StagedColumn): number { + switch (column.type) { + case QWP_COLUMN_TYPE.BOOLEAN: + case QWP_COLUMN_TYPE.BYTE: + return 1; + case QWP_COLUMN_TYPE.SHORT: + case QWP_COLUMN_TYPE.CHAR: + return 2; + case QWP_COLUMN_TYPE.INT: + case QWP_COLUMN_TYPE.FLOAT: + case QWP_COLUMN_TYPE.IPV4: + case QWP_COLUMN_TYPE.SYMBOL: + return 4; + case QWP_COLUMN_TYPE.LONG: + case QWP_COLUMN_TYPE.DOUBLE: + case QWP_COLUMN_TYPE.TIMESTAMP: + case QWP_COLUMN_TYPE.TIMESTAMP_NANOS: + case QWP_COLUMN_TYPE.DATE: + case QWP_COLUMN_TYPE.DECIMAL64: + case QWP_COLUMN_TYPE.GEOHASH: + return 8; + case QWP_COLUMN_TYPE.UUID: + case QWP_COLUMN_TYPE.DECIMAL128: + return 16; + case QWP_COLUMN_TYPE.LONG256: + case QWP_COLUMN_TYPE.DECIMAL256: + return 32; + case QWP_COLUMN_TYPE.VARCHAR: + return 4 + utf8Length(column.value as string); + case QWP_COLUMN_TYPE.BINARY: + return 4 + (column.value as Uint8Array).byteLength; + case QWP_COLUMN_TYPE.DOUBLE_ARRAY: + case QWP_COLUMN_TYPE.LONG_ARRAY: { + const array = column.value as QwpArrayValue; + return array.values.length * 8; + } + } +} + +function stagedRowBytes(columns: ReadonlyMap): number { + let bytes = 0; + for (const column of columns.values()) bytes += stagedColumnBytes(column); + return bytes; +} + function decimalType(value: bigint, scale: number): QwpColumnType { if (scale <= 18 && fitsSigned(value, 64)) return QWP_COLUMN_TYPE.DECIMAL64; if (scale <= 38 && fitsSigned(value, 128)) return QWP_COLUMN_TYPE.DECIMAL128; @@ -302,6 +367,7 @@ export class QwpSender { private current?: StagedTable; private currentRow = new Map(); private pendingRowCount = 0; + private pendingByteCount = 0; private lastFlushTime = Date.now(); private sessionPromise?: Promise; private activeSession?: QwpSenderSession; @@ -320,6 +386,7 @@ export class QwpSender { private readonly autoFlush: boolean; private readonly autoFlushRows: number; + private readonly autoFlushBytes: number; private readonly autoFlushIntervalMs: number; private readonly transactional: boolean; private readonly awaitServerAck: boolean; @@ -331,11 +398,13 @@ export class QwpSender { ) { this.autoFlush = options.autoFlush ?? true; this.autoFlushRows = options.autoFlushRows ?? DEFAULT_AUTO_FLUSH_ROWS; + this.autoFlushBytes = options.autoFlushBytes ?? DEFAULT_AUTO_FLUSH_BYTES; this.autoFlushIntervalMs = options.autoFlushIntervalMs ?? DEFAULT_AUTO_FLUSH_INTERVAL_MS; this.transactional = options.transactional ?? false; this.awaitServerAck = options.awaitServerAck ?? true; validateNonNegativeInteger(this.autoFlushRows, "autoFlushRows"); + validateNonNegativeInteger(this.autoFlushBytes, "autoFlushBytes"); validateNonNegativeInteger(this.autoFlushIntervalMs, "autoFlushIntervalMs"); if ( options.durableAckTimeoutMs !== undefined && @@ -366,6 +435,9 @@ export class QwpSender { totalFlushFailures: this.totalFlushFailures, totalTransactionsCommitted: this.totalTransactionsCommitted, pendingRows: this.pendingRowCount, + pendingBytes: this.pendingByteCount, + autoFlushBytes: this.autoFlushBytes, + effectiveAutoFlushBytes: this.effectiveAutoFlushByteThreshold(), deferredRows: this.deferredRowCount, connected: this.activeSession !== undefined && !this.closing && !this.closed, @@ -1000,12 +1072,17 @@ export class QwpSender { private finishRow(): void { const table = this.requireTable(); - table.rows.push(this.currentRow); + const estimatedBytes = stagedRowBytes(this.currentRow); + table.rows.push({ columns: this.currentRow, estimatedBytes }); this.currentRow = new Map(); this.current = undefined; this.pendingRowCount++; + this.pendingByteCount += estimatedBytes; this.totalRowsStaged++; - this.log("debug", `Pending QWP row count: ${this.pendingRowCount}`); + this.log( + "debug", + `Pending QWP rows: ${this.pendingRowCount}, estimated bytes: ${this.pendingByteCount}`, + ); } private requireTable(): StagedTable { @@ -1021,10 +1098,12 @@ export class QwpSender { } private async tryFlush(): Promise { + const byteThreshold = this.effectiveAutoFlushByteThreshold(); if ( this.autoFlush && this.pendingRowCount > 0 && ((this.autoFlushRows > 0 && this.pendingRowCount >= this.autoFlushRows) || + (byteThreshold > 0 && this.pendingByteCount >= byteThreshold) || (this.autoFlushIntervalMs > 0 && Date.now() - this.lastFlushTime >= this.autoFlushIntervalMs)) ) { @@ -1109,7 +1188,16 @@ export class QwpSender { (count, item) => count + item.rows.length, 0, ); + const sentBytes = snapshots.reduce( + (total, item) => + total + + item.rows.reduce((tableTotal, row) => { + return tableTotal + row.estimatedBytes; + }, 0), + 0, + ); this.pendingRowCount -= sentRows; + this.pendingByteCount -= sentBytes; this.totalRowsPublished += sentRows; this.lastFlushTime = Date.now(); this.log( @@ -1152,13 +1240,10 @@ export class QwpSender { return { flushed: true, sequence: publishedSequence }; } - private buildTable( - name: string, - rows: readonly Map[], - ): QwpTableBuffer { + private buildTable(name: string, rows: readonly StagedRow[]): QwpTableBuffer { const result = new QwpTableBuffer(name); for (const row of rows) { - for (const column of row.values()) { + for (const column of row.columns.values()) { const target = result.getOrCreateColumn(column.name, column.type); if (!target) continue; if (column.geohashPrecision !== undefined) { @@ -1193,9 +1278,20 @@ export class QwpSender { private resetAutoFlush(): void { this.pendingRowCount = 0; + this.pendingByteCount = 0; this.lastFlushTime = Date.now(); } + private effectiveAutoFlushByteThreshold(): number { + if (this.autoFlushBytes === 0) return 0; + const cap = this.activeSession?.maxBatchSizeBytes; + if (cap === undefined || !Number.isSafeInteger(cap) || cap <= 0) { + return this.autoFlushBytes; + } + const safeServerBudget = Math.max(1, Math.floor((cap * 9) / 10)); + return Math.min(this.autoFlushBytes, safeServerBudget); + } + private throwIfClosed(): void { if (this.closed) throw new Error("QWP sender is closed"); } diff --git a/src/sender.ts b/src/sender.ts index 585da0a..1dd5421 100644 --- a/src/sender.ts +++ b/src/sender.ts @@ -565,6 +565,9 @@ function createConfiguredQwpSender( autoFlushRows: isInteger(options.auto_flush_rows, 0) ? options.auto_flush_rows : configuredSender.autoFlushRows, + autoFlushBytes: isInteger(options.auto_flush_bytes, 0) + ? options.auto_flush_bytes + : configuredSender.autoFlushBytes, autoFlushIntervalMs: isInteger(options.auto_flush_interval, 0) ? options.auto_flush_interval : configuredSender.autoFlushIntervalMs, diff --git a/test/options.test.ts b/test/options.test.ts index a302b65..3543011 100644 --- a/test/options.test.ts +++ b/test/options.test.ts @@ -807,6 +807,27 @@ describe("Configuration string parser suite", function () { ).rejects.toThrow("Invalid auto flush rows option, not a number: '1w23'"); }); + it("parses auto_flush_bytes only for QWP WebSocket", async function () { + let options = await SenderOptions.fromConfig( + "ws::addr=host:9000;auto_flush_bytes=123;", + ); + expect(options.auto_flush_bytes).toBe(123); + + options = await SenderOptions.fromConfig( + "wss::addr=host:9000;auto_flush_bytes=off;", + ); + expect(options.auto_flush_bytes).toBe(0); + + await expect( + SenderOptions.fromConfig("ws::addr=host:9000;auto_flush_bytes=-1;"), + ).rejects.toThrow("Invalid auto flush bytes option: -1"); + await expect( + SenderOptions.fromConfig("http::addr=host:9000;auto_flush_bytes=123;"), + ).rejects.toThrow( + "auto_flush_bytes is only supported for QWP ws/wss transport", + ); + }); + it("can parse auto_flush_interval config", async function () { let options = await SenderOptions.fromConfig( "http::addr=host:9000;protocol_version=2;auto_flush_interval=30", diff --git a/test/qwp/public-api-contract.ts b/test/qwp/public-api-contract.ts index 82a5ace..b061b07 100644 --- a/test/qwp/public-api-contract.ts +++ b/test/qwp/public-api-contract.ts @@ -148,6 +148,7 @@ const qwpExtraOptionsContract: QwpExtraOptions = { }, sender: { transactional: true, + autoFlushBytes: 4 * 1024 * 1024, awaitDurableAck: true, }, session: { diff --git a/test/qwp/sender-node-integration.test.ts b/test/qwp/sender-node-integration.test.ts index 301ec9f..c391dbb 100644 --- a/test/qwp/sender-node-integration.test.ts +++ b/test/qwp/sender-node-integration.test.ts @@ -96,4 +96,37 @@ describe("Sender QWP integration", () => { ).getUint32(0, true), ).toBe(QWP_MAGIC); }); + + it("honors auto_flush_bytes from the ws:: configuration string", async () => { + const frames: Uint8Array[] = []; + server = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + server.on("headers", (headers) => { + headers.push("X-QWP-Version: 1"); + headers.push("X-QWP-Max-Batch-Size: 1048576"); + }); + server.on("connection", (socket) => { + socket.on("message", (payload) => { + frames.push(new Uint8Array(payload as Buffer)); + socket.send(okResponse(BigInt(frames.length - 1), "events")); + }); + }); + await new Promise((resolve, reject) => { + server!.once("listening", resolve); + server!.once("error", reject); + }); + const { port } = server.address() as AddressInfo; + + const sender = await Sender.fromConfig( + `ws::addr=127.0.0.1:${port};auto_flush_rows=0;auto_flush_interval=0;auto_flush_bytes=8`, + ); + try { + await sender.connect(); + await sender.table("events").intColumn("value", 42).atNow(); + + expect(frames).toHaveLength(1); + await expect(sender.flush()).resolves.toBe(false); + } finally { + await sender.close(); + } + }); }); diff --git a/test/qwp/sender.test.ts b/test/qwp/sender.test.ts index 9e7fa4d..10569b1 100644 --- a/test/qwp/sender.test.ts +++ b/test/qwp/sender.test.ts @@ -168,6 +168,22 @@ function column(table: QwpTableBuffer, name: string) { } describe("QWP high-level sender", () => { + it("validates the byte auto-flush threshold", () => { + const session = new RecordingSession(); + expect( + () => + new QwpSender(async () => session, { + autoFlushBytes: -1, + }), + ).toThrow(/autoFlushBytes must be a non-negative safe integer/); + expect( + () => + new QwpSender(async () => session, { + autoFlushBytes: 1.5, + }), + ).toThrow(/autoFlushBytes must be a non-negative safe integer/); + }); + it("returns a publication sequence and waits for its ACK independently", async () => { const session = new WatermarkSession(); const sender = new QwpSender(async () => session, { @@ -418,6 +434,110 @@ describe("QWP high-level sender", () => { await expect(sender.flush()).resolves.toBe(false); }); + it("auto-flushes by estimated buffered bytes", async () => { + const session = new RecordingSession(); + const sender = new QwpSender(async () => session, { + autoFlushRows: 0, + autoFlushBytes: 16, + autoFlushIntervalMs: 0, + }); + + await sender.table("events").longColumn("value", 1n).atNow(); + expect(session.sends).toHaveLength(0); + expect(sender.metrics).toMatchObject({ + pendingRows: 1, + pendingBytes: 8, + autoFlushBytes: 16, + effectiveAutoFlushBytes: 16, + }); + + await sender.table("events").longColumn("value", 2n).atNow(); + expect(session.sends).toHaveLength(1); + expect(session.sends[0].tables[0].rowCount).toBe(2); + expect(sender.metrics).toMatchObject({ pendingRows: 0, pendingBytes: 0 }); + await sender.close(); + }); + + it("counts variable-width values by UTF-8 and binary payload bytes", async () => { + const session = new RecordingSession(); + const sender = new QwpSender(async () => session, { + autoFlushRows: 0, + autoFlushBytes: 13, + autoFlushIntervalMs: 0, + }); + + await sender + .table("events") + .stringColumn("message", "é") + .binaryColumn("payload", Uint8Array.of(1, 2, 3)) + .atNow(); + + expect(session.sends).toHaveLength(1); + expect(sender.metrics.pendingBytes).toBe(0); + await sender.close(); + }); + + it("clamps an enabled byte trigger below the connected server batch cap", async () => { + const session = Object.assign(new RecordingSession(), { + maxBatchSizeBytes: 20, + }); + const sender = new QwpSender(async () => session, { + autoFlushRows: 0, + autoFlushBytes: 100, + autoFlushIntervalMs: 0, + }); + await sender.connect(); + expect(sender.metrics.effectiveAutoFlushBytes).toBe(18); + + await sender.table("events").longColumn("value", 1n).atNow(); + await sender.table("events").longColumn("value", 2n).atNow(); + expect(session.sends).toHaveLength(0); + await sender.table("events").longColumn("value", 3n).atNow(); + expect(session.sends).toHaveLength(1); + expect(session.sends[0].tables[0].rowCount).toBe(3); + await sender.close(); + }); + + it("does not let a server batch cap enable an opted-out byte trigger", async () => { + const session = Object.assign(new RecordingSession(), { + maxBatchSizeBytes: 20, + }); + const sender = new QwpSender(async () => session, { + autoFlushRows: 0, + autoFlushBytes: 0, + autoFlushIntervalMs: 0, + }); + await sender.connect(); + expect(sender.metrics.effectiveAutoFlushBytes).toBe(0); + + await sender.table("events").longColumn("value", 1n).atNow(); + expect(session.sends).toHaveLength(0); + expect(sender.metrics).toMatchObject({ pendingRows: 1, pendingBytes: 8 }); + await sender.flush(); + await sender.close(); + }); + + it("preserves pending byte accounting when publication fails", async () => { + const session = new PublishingSession(); + session.failPublication = true; + const sender = new QwpSender(async () => session, { + autoFlushRows: 0, + autoFlushBytes: 8, + autoFlushIntervalMs: 0, + awaitServerAck: false, + }); + + await expect( + sender.table("events").longColumn("value", 1n).atNow(), + ).rejects.toThrow("journal is full"); + expect(sender.metrics).toMatchObject({ pendingRows: 1, pendingBytes: 8 }); + + session.failPublication = false; + await expect(sender.flush()).resolves.toBe(true); + expect(sender.metrics).toMatchObject({ pendingRows: 0, pendingBytes: 0 }); + await sender.close(); + }); + it("defers transactional auto-flush and commits without waiting on its withheld ACK", async () => { const session = new CommitAwareSession(); const sender = new QwpSender(async () => session, { From 0d9c23bd62bd7a6fa02305c07f2fc5938a5703e3 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 18 Aug 2026 14:36:51 +0100 Subject: [PATCH 041/265] feat(qwp): drain ingress on sender close --- QWP.md | 15 +- README.md | 5 + src/options.ts | 19 +++ src/qwp/sender.ts | 180 ++++++++++++++++++++--- src/sender.ts | 8 +- test/options.test.ts | 25 ++++ test/qwp/public-api-contract.ts | 1 + test/qwp/public-api.test.ts | 1 + test/qwp/sender-node-integration.test.ts | 68 +++++++++ test/qwp/sender.test.ts | 120 ++++++++++++++- 10 files changed, 411 insertions(+), 31 deletions(-) diff --git a/QWP.md b/QWP.md index 942aabf..dd40cc3 100644 --- a/QWP.md +++ b/QWP.md @@ -232,7 +232,10 @@ otherwise it follows ordinary protocol OK responses. A deadline failure raises Rows are staged until an auto-flush boundary or an explicit `flush()`. A `null` or `undefined` column value omits that column from the row. `atNow()` asks QuestDB to assign the designated timestamp; `at(value, unit)` sends an explicit `ns`, `us`, or -`ms` timestamp. `close()` does not flush pending rows. +`ms` timestamp. `close()` publishes completed rows and waits for the committed-frame +ACK watermark for up to `closeFlushTimeoutMs` (5 seconds by default). Set it to `0` +to publish without the ACK drain. An unfinished row is still discarded with a warning. +The configuration-string equivalent is `close_flush_timeout_millis`. `autoFlushBytes` is a soft threshold over estimated raw column-buffer storage and is disabled by default (`0`). It combines with `autoFlushRows` and @@ -344,8 +347,8 @@ await sender.commit(); ``` Transactions are atomic per table, not across all tables in one flush. Closing a -sender with uncommitted transactional auto-flushes rolls the open server transaction -back. The sender logs a warning in this case. +sender publishes locally staged transactional rows but does not implicitly commit; +QuestDB rolls the open server transaction back. The sender logs a warning in this case. In browsers, durable ACK capability is negotiated with a WebSocket subprotocol; Node.js uses upgrade headers. Setting `awaitDurableAck` automatically requests the @@ -622,6 +625,7 @@ The public error classes preserve enough context for policy decisions: | `QwpClientClosedError` | The pooled client or an individual returned lease is already closed | | `QwpDurableAckUnavailableError` | Durable acknowledgement was required but not negotiated | | `QwpSendTimeoutError` | A send did not drain before its deadline; delivery is unknown | +| `QwpSenderCloseTimeoutError` | Sender shutdown could not publish and ACK-drain all committed ingress frames within its deadline | | `QwpIngressNackError` | QuestDB rejected an ingress frame | | `QwpIngressAckTimeoutError` | The cumulative ingress ACK watermark did not reach the requested sequence before its deadline | | `QwpBatchTooLargeError` | One encoded row cannot fit the effective ingress cap | @@ -637,8 +641,9 @@ The public error classes preserve enough context for policy decisions: | `QwpEgressQueryCancelTimeoutError` | A cancelled query did not produce a terminal server response before the drain deadline | | `QwpEgressReplayRequiredError` | Re-execution needs an explicit reset callback | -Always close senders and sessions in `finally`. Closing is idempotent and bounded by -`closeTimeoutMs`. `connectTimeoutMs`, `sendTimeoutMs`, acknowledgement timeouts, and +Always close senders and sessions in `finally`. Sender publication plus ACK draining is +bounded by `closeFlushTimeoutMs`; the subsequent WebSocket closing handshake is bounded +by `closeTimeoutMs`. `connectTimeoutMs`, `sendTimeoutMs`, acknowledgement timeouts, and query deadlines cover separate lifecycle phases; configure each according to the deployment rather than using one very large catch-all value. diff --git a/README.md b/README.md index 18a1f46..145483f 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,11 @@ await sender.commit(); await sender.close(); ``` +QWP `close()` publishes completed rows and waits up to 5 seconds for their +committed-frame ACK watermark. Configure `closeFlushTimeoutMs` (or +`close_flush_timeout_millis` in a `ws::` string); `0` publishes without waiting. +An unfinished row is not completed implicitly. + The server intentionally withholds ACKs for deferred frames until commit. The sender pipelines transactional auto-flushes without waiting for those ACKs, then waits for all of them at `flush()`/`commit()`. If durable ACK waiting is diff --git a/src/options.ts b/src/options.ts index 13ecb1a..19f5109 100644 --- a/src/options.ts +++ b/src/options.ts @@ -129,6 +129,9 @@ type DeprecatedOptions = { * When set to 0, interval-based flushing is disabled.
    * Note that the setting is checked only when a new row is added to the buffer. There is no timer registered to flush the buffer automatically. *
  • + *
  • close_flush_timeout_millis: integer - Maximum time QWP close waits for committed rows to be acknowledged. + * Defaults to 5000; 0 publishes pending rows but skips the ACK drain. This option is supported by ws/wss only. + *
  • * *
    * Buffer sizing options @@ -183,6 +186,7 @@ class SenderOptions { auto_flush_rows?: number; auto_flush_bytes?: number; auto_flush_interval?: number; + close_flush_timeout_millis?: number; request_min_throughput?: number; request_timeout?: number; @@ -385,6 +389,7 @@ function parseConfigurationString( parseAddress(options); parseBufferSizes(options); parseAutoFlushOptions(options); + parseCloseFlushOptions(options); parseTlsOptions(options); parseRequestTimeoutOptions(options); parseMaxNameLength(options); @@ -446,6 +451,7 @@ const ValidConfigKeys = [ "auto_flush_rows", "auto_flush_bytes", "auto_flush_interval", + "close_flush_timeout_millis", "request_min_throughput", "request_timeout", "retry_timeout", @@ -608,6 +614,19 @@ function parseAutoFlushOptions(options: SenderOptions) { parseInteger(options, "auto_flush_interval", "auto flush interval", 0); } +function parseCloseFlushOptions(options: SenderOptions) { + parseInteger(options, "close_flush_timeout_millis", "close flush timeout", 0); + if ( + options.close_flush_timeout_millis !== undefined && + options.protocol !== WS && + options.protocol !== WSS + ) { + throw new Error( + "close_flush_timeout_millis is only supported for QWP ws/wss transport", + ); + } +} + function parseTlsOptions(options: SenderOptions) { parseBoolean(options, "tls_verify", "TLS verify", UNSAFE_OFF); diff --git a/src/qwp/sender.ts b/src/qwp/sender.ts index 5423114..c827dc2 100644 --- a/src/qwp/sender.ts +++ b/src/qwp/sender.ts @@ -8,7 +8,10 @@ import { utf8Length, type QwpArrayValue, } from "./core"; -import type { QwpIngressMetrics } from "./ingress-session"; +import { + QwpIngressAckTimeoutError, + type QwpIngressMetrics, +} from "./ingress-session"; export type QwpTimestampUnit = "ns" | "us" | "ms"; @@ -50,11 +53,37 @@ export interface QwpSenderOptions { /** Wait for durable upload after every successful ingress ACK. */ awaitDurableAck?: boolean; durableAckTimeoutMs?: number; + /** + * Maximum time close() spends publishing queued rows and waiting for the + * server ACK watermark. Zero skips the drain. Defaults to 5 seconds. + */ + closeFlushTimeoutMs?: number; /** QWP frame encoding options supported by the high-level sender. */ encode?: QwpSenderEncodeOptions; log?: QwpSenderLogger; } +/** close() could not publish and acknowledge all committed ingress frames. */ +export class QwpSenderCloseTimeoutError extends Error { + readonly timeoutMs: number; + readonly targetSequence: bigint; + readonly acknowledgedSequence: bigint; + + constructor( + timeoutMs: number, + targetSequence: bigint, + acknowledgedSequence: bigint, + ) { + super( + `QWP sender close timed out after ${timeoutMs}ms [targetSequence=${targetSequence}, acknowledgedSequence=${acknowledgedSequence}]; pending data may be lost`, + ); + this.name = "QwpSenderCloseTimeoutError"; + this.timeoutMs = timeoutMs; + this.targetSequence = targetSequence; + this.acknowledgedSequence = acknowledgedSequence; + } +} + /** The subset of QwpIngressSession used by QwpSender. */ export interface QwpSenderSession { readonly metrics?: QwpIngressMetrics; @@ -140,6 +169,7 @@ interface QwpSenderFlushResult { const DEFAULT_AUTO_FLUSH_ROWS = 1_000; const DEFAULT_AUTO_FLUSH_BYTES = 0; const DEFAULT_AUTO_FLUSH_INTERVAL_MS = 100; +const DEFAULT_CLOSE_FLUSH_TIMEOUT_MS = 5_000; function validateNonNegativeInteger(value: number, name: string): void { if (!Number.isSafeInteger(value) || value < 0) { @@ -383,6 +413,7 @@ export class QwpSender { private totalFlushes = 0; private totalFlushFailures = 0; private totalTransactionsCommitted = 0; + private lastCommitBoundarySequence = -1n; private readonly autoFlush: boolean; private readonly autoFlushRows: number; @@ -390,6 +421,7 @@ export class QwpSender { private readonly autoFlushIntervalMs: number; private readonly transactional: boolean; private readonly awaitServerAck: boolean; + private readonly closeFlushTimeoutMs: number; private readonly log: QwpSenderLogger; constructor( @@ -403,9 +435,12 @@ export class QwpSender { options.autoFlushIntervalMs ?? DEFAULT_AUTO_FLUSH_INTERVAL_MS; this.transactional = options.transactional ?? false; this.awaitServerAck = options.awaitServerAck ?? true; + this.closeFlushTimeoutMs = + options.closeFlushTimeoutMs ?? DEFAULT_CLOSE_FLUSH_TIMEOUT_MS; validateNonNegativeInteger(this.autoFlushRows, "autoFlushRows"); validateNonNegativeInteger(this.autoFlushBytes, "autoFlushBytes"); validateNonNegativeInteger(this.autoFlushIntervalMs, "autoFlushIntervalMs"); + validateNonNegativeInteger(this.closeFlushTimeoutMs, "closeFlushTimeoutMs"); if ( options.durableAckTimeoutMs !== undefined && (!Number.isFinite(options.durableAckTimeoutMs) || @@ -978,27 +1013,79 @@ export class QwpSender { private async closeNow(): Promise { if (this.closed) return; this.closing = true; - // Let a flush already queued in this turn enter getSession() so it can be - // cancelled through the session instead of making close wait for its ACK. - await Promise.resolve(); - let sessionClose: Promise | undefined; - let sessionFailure: { reason: unknown } | undefined; - if (this.sessionPromise) { - try { - const session = await this.sessionPromise; + const deadline = + this.closeFlushTimeoutMs > 0 + ? Date.now() + this.closeFlushTimeoutMs + : undefined; + let terminalError: unknown; + + try { + // Serialize behind public flushes so symbol dictionaries, transaction + // boundaries, and staging ownership cannot race. close() itself uses a + // publication-only flush and applies one bounded ACK watermark wait. + const closeFlush = this.flushTail.then(async () => { + if ( + this.pendingRowCount === 0 && + (this.transactional || !this.hasDeferredMessages) + ) { + return; + } + try { + await this.flushNow(this.transactional, true); + } catch (error) { + this.totalFlushFailures++; + throw error; + } + }); + await this.withCloseDeadline(closeFlush, deadline); + + const session = this.activeSession; + const target = this.lastCommitBoundarySequence; + if ( + deadline !== undefined && + session && + target >= 0n && + sessionAcknowledgedSequence(session) < target + ) { + if (!session.waitForAcknowledged) { + throw new Error( + "this QWP ingress session does not expose an ACK watermark", + ); + } + const remaining = deadline - Date.now(); + if (remaining <= 0) throw this.closeTimeoutError(); try { - sessionClose = session.close(); + await this.withCloseDeadline( + session.waitForAcknowledged(target, remaining), + deadline, + ); } catch (error) { - sessionClose = Promise.reject(error); + if (error instanceof QwpIngressAckTimeoutError) { + throw this.closeTimeoutError(); + } + throw error; } + } + } catch (error) { + terminalError = error; + } + + let closeError: unknown; + const session = this.activeSession; + if (session) { + try { + await session.close(); } catch (error) { - sessionFailure = { reason: error }; + closeError = error; } + } else if (this.sessionPromise) { + // A close deadline can expire while the connection factory is still in + // flight. Attach cleanup so a late connection cannot leak its socket. + void this.sessionPromise + .then((connected) => connected.close()) + .catch(() => undefined); } - const [, closeResult] = await Promise.allSettled([ - this.flushTail, - sessionClose ?? Promise.resolve(), - ]); + if (this.pendingRowCount > 0 || this.currentRow.size > 0) { this.log( "warn", @@ -1008,12 +1095,49 @@ export class QwpSender { if (this.hasDeferredMessages) { this.log( "warn", - `QWP sender is closing with ${this.deferredRowCount} auto-flushed row(s) awaiting commit; QuestDB will roll the open transaction back`, + `QWP sender is closing with ${this.deferredRowCount} deferred row(s) awaiting commit; QuestDB will roll the open transaction back`, ); } this.closed = true; - if (sessionFailure) throw sessionFailure.reason; - if (closeResult.status === "rejected") throw closeResult.reason; + if (terminalError !== undefined) { + if (closeError !== undefined) { + this.log( + "error", + closeError instanceof Error ? closeError : String(closeError), + ); + } + throw terminalError; + } + if (closeError !== undefined) throw closeError; + } + + private closeTimeoutError(): QwpSenderCloseTimeoutError { + const session = this.activeSession; + return new QwpSenderCloseTimeoutError( + this.closeFlushTimeoutMs, + this.lastCommitBoundarySequence, + session ? sessionAcknowledgedSequence(session) : -1n, + ); + } + + private async withCloseDeadline( + operation: Promise, + deadline: number | undefined, + ): Promise { + if (deadline === undefined) return operation; + const remaining = deadline - Date.now(); + if (remaining <= 0) throw this.closeTimeoutError(); + let timer: ReturnType | undefined; + try { + return await Promise.race([ + operation, + new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(this.closeTimeoutError()), remaining); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } } private fixedDecimalColumn( @@ -1178,6 +1302,10 @@ export class QwpSender { ); }); } + publishedSequence = advancedSequence( + beforeSequence, + sessionPublishedSequence(session), + ); this.totalFlushes++; // Publication-only Node store-and-forward transfers row ownership only // after every frame is durable locally. A disk-capacity or I/O failure @@ -1204,6 +1332,9 @@ export class QwpSender { "debug", `${deferCommit ? "Auto-flushing" : "Flushing"} ${sentRows} QWP row(s)${deferCommit ? " with commit deferred" : ""}`, ); + if (!deferCommit && publishedSequence >= 0n) { + this.lastCommitBoundarySequence = publishedSequence; + } if (deferCommit) { this.hasDeferredMessages = true; @@ -1223,10 +1354,19 @@ export class QwpSender { this.deferredRowCount = 0; const ack = response ? await response : undefined; if (response) { - publishedSequence = advancedSequence( + const observedSequence = advancedSequence( beforeSequence, sessionPublishedSequence(session), ); + publishedSequence = + observedSequence >= 0n + ? observedSequence + : typeof ack?.sequence === "bigint" + ? ack.sequence + : -1n; + } + if (publishedSequence >= 0n) { + this.lastCommitBoundarySequence = publishedSequence; } if (!publicationOnly && deferredAcks.length > 0) { await Promise.all(deferredAcks); diff --git a/src/sender.ts b/src/sender.ts index 1dd5421..1dd30df 100644 --- a/src/sender.ts +++ b/src/sender.ts @@ -270,8 +270,9 @@ class Sender { } /** - * Closes the connection to the database.
    - * Data sitting in the Sender's buffer will be lost unless flush() is called before close(). + * Closes the connection to the database. QWP publishes completed rows and + * performs a bounded acknowledgement drain first. Other transports retain + * their legacy behavior and require an explicit flush(). */ async close(): Promise { if (this.qwpSender) return this.qwpSender.close(); @@ -571,6 +572,9 @@ function createConfiguredQwpSender( autoFlushIntervalMs: isInteger(options.auto_flush_interval, 0) ? options.auto_flush_interval : configuredSender.autoFlushIntervalMs, + closeFlushTimeoutMs: isInteger(options.close_flush_timeout_millis, 0) + ? options.close_flush_timeout_millis + : configuredSender.closeFlushTimeoutMs, log: logger, }, options.qwp?.session, diff --git a/test/options.test.ts b/test/options.test.ts index 3543011..0d3b6b2 100644 --- a/test/options.test.ts +++ b/test/options.test.ts @@ -828,6 +828,31 @@ describe("Configuration string parser suite", function () { ); }); + it("parses close_flush_timeout_millis only for QWP WebSocket", async function () { + let options = await SenderOptions.fromConfig( + "ws::addr=host:9000;close_flush_timeout_millis=123;", + ); + expect(options.close_flush_timeout_millis).toBe(123); + + options = await SenderOptions.fromConfig( + "wss::addr=host:9000;close_flush_timeout_millis=0;", + ); + expect(options.close_flush_timeout_millis).toBe(0); + + await expect( + SenderOptions.fromConfig( + "ws::addr=host:9000;close_flush_timeout_millis=-1;", + ), + ).rejects.toThrow("Invalid close flush timeout option: -1"); + await expect( + SenderOptions.fromConfig( + "http::addr=host:9000;close_flush_timeout_millis=123;", + ), + ).rejects.toThrow( + "close_flush_timeout_millis is only supported for QWP ws/wss transport", + ); + }); + it("can parse auto_flush_interval config", async function () { let options = await SenderOptions.fromConfig( "http::addr=host:9000;protocol_version=2;auto_flush_interval=30", diff --git a/test/qwp/public-api-contract.ts b/test/qwp/public-api-contract.ts index b061b07..03bea22 100644 --- a/test/qwp/public-api-contract.ts +++ b/test/qwp/public-api-contract.ts @@ -149,6 +149,7 @@ const qwpExtraOptionsContract: QwpExtraOptions = { sender: { transactional: true, autoFlushBytes: 4 * 1024 * 1024, + closeFlushTimeoutMs: 5_000, awaitDurableAck: true, }, session: { diff --git a/test/qwp/public-api.test.ts b/test/qwp/public-api.test.ts index aef5c82..6758e94 100644 --- a/test/qwp/public-api.test.ts +++ b/test/qwp/public-api.test.ts @@ -36,6 +36,7 @@ const sharedRuntimeContract = [ "QwpQueryLease", "QwpSendTimeoutError", "QwpSender", + "QwpSenderCloseTimeoutError", "QwpUpgradeError", ] as const; diff --git a/test/qwp/sender-node-integration.test.ts b/test/qwp/sender-node-integration.test.ts index c391dbb..9366b13 100644 --- a/test/qwp/sender-node-integration.test.ts +++ b/test/qwp/sender-node-integration.test.ts @@ -129,4 +129,72 @@ describe("Sender QWP integration", () => { await sender.close(); } }); + + it("publishes pending rows and drains their ACK on close", async () => { + const frames: Uint8Array[] = []; + let ackSent = false; + server = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + server.on("headers", (headers) => { + headers.push("X-QWP-Version: 1"); + headers.push("X-QWP-Max-Batch-Size: 1048576"); + }); + server.on("connection", (socket) => { + socket.on("message", (payload) => { + frames.push(new Uint8Array(payload as Buffer)); + setTimeout(() => { + ackSent = true; + socket.send(okResponse(0n, "events")); + }, 25); + }); + }); + await new Promise((resolve, reject) => { + server!.once("listening", resolve); + server!.once("error", reject); + }); + const { port } = server.address() as AddressInfo; + + const sender = await Sender.fromConfig( + `ws::addr=127.0.0.1:${port};auto_flush=off;close_flush_timeout_millis=1000`, + ); + await sender.connect(); + await sender.table("events").intColumn("value", 42).atNow(); + + await expect(sender.close()).resolves.toBeUndefined(); + expect(frames).toHaveLength(1); + expect(ackSent).toBe(true); + expect(sender.acknowledgedSequence).toBe(0n); + }); + + it("closes the socket and reports a bounded close-drain timeout", async () => { + const frames: Uint8Array[] = []; + server = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + server.on("headers", (headers) => { + headers.push("X-QWP-Version: 1"); + headers.push("X-QWP-Max-Batch-Size: 1048576"); + }); + server.on("connection", (socket) => { + socket.on("message", (payload) => { + frames.push(new Uint8Array(payload as Buffer)); + }); + }); + await new Promise((resolve, reject) => { + server!.once("listening", resolve); + server!.once("error", reject); + }); + const { port } = server.address() as AddressInfo; + + const sender = await Sender.fromConfig( + `ws::addr=127.0.0.1:${port};auto_flush=off;close_flush_timeout_millis=25`, + ); + await sender.connect(); + await sender.table("events").intColumn("value", 42).atNow(); + + await expect(sender.close()).rejects.toMatchObject({ + name: "QwpSenderCloseTimeoutError", + timeoutMs: 25, + targetSequence: 0n, + acknowledgedSequence: -1n, + }); + expect(frames).toHaveLength(1); + }); }); diff --git a/test/qwp/sender.test.ts b/test/qwp/sender.test.ts index 10569b1..c33f3a0 100644 --- a/test/qwp/sender.test.ts +++ b/test/qwp/sender.test.ts @@ -5,6 +5,7 @@ import { QwpIngressEncodeOptions, QwpIngressResponse, QwpSender, + QwpSenderCloseTimeoutError, QwpSenderSession, QwpTableBuffer, encodeQwpIngressFrame, @@ -123,6 +124,12 @@ class PublishingSession extends RecordingSession { this.deltaSendCount++; return this.publishTables(tables, options); } + + async waitForAcknowledged(target: bigint): Promise { + if (target > this.acknowledgedFrameSequence) { + this.acknowledgedFrameSequence = target; + } + } } class WatermarkSession extends PublishingSession { @@ -184,6 +191,22 @@ describe("QWP high-level sender", () => { ).toThrow(/autoFlushBytes must be a non-negative safe integer/); }); + it("validates the close flush timeout", () => { + const session = new RecordingSession(); + expect( + () => + new QwpSender(async () => session, { + closeFlushTimeoutMs: -1, + }), + ).toThrow(/closeFlushTimeoutMs must be a non-negative safe integer/); + expect( + () => + new QwpSender(async () => session, { + closeFlushTimeoutMs: 1.5, + }), + ).toThrow(/closeFlushTimeoutMs must be a non-negative safe integer/); + }); + it("returns a publication sequence and waits for its ACK independently", async () => { const session = new WatermarkSession(); const sender = new QwpSender(async () => session, { @@ -283,14 +306,19 @@ describe("QWP high-level sender", () => { await sender.close(); }); - it("closes its session before waiting for an in-flight flush", async () => { + it("bounds an in-flight flush before closing its session", async () => { const session = new ClosingUnblocksSession(); - const sender = new QwpSender(async () => session, { autoFlush: false }); + const sender = new QwpSender(async () => session, { + autoFlush: false, + closeFlushTimeoutMs: 10, + }); await sender.table("events").longColumn("value", 42n).atNow(); const flushing = sender.flush().catch((error: unknown) => error); await Promise.resolve(); - await expect(sender.close()).resolves.toBeUndefined(); + await expect(sender.close()).rejects.toBeInstanceOf( + QwpSenderCloseTimeoutError, + ); await expect(flushing).resolves.toEqual( expect.objectContaining({ message: "session closed" }), ); @@ -299,6 +327,66 @@ describe("QWP high-level sender", () => { expect(sender.metrics.connected).toBe(false); }); + it("publishes completed rows and drains their ACK on close", async () => { + const session = new WatermarkSession(); + const sender = new QwpSender(async () => session, { + autoFlush: false, + closeFlushTimeoutMs: 1_000, + }); + await sender.table("events").longColumn("value", 42n).atNow(); + + let closed = false; + const closing = sender.close().then(() => { + closed = true; + }); + await expect.poll(() => session.sends.length).toBe(1); + expect(session.sends[0].tables[0].rowCount).toBe(1); + expect(sender.metrics.pendingRows).toBe(0); + expect(closed).toBe(false); + + session.acknowledgeThrough(0n); + await closing; + expect(session.closeCount).toBe(1); + expect(sender.metrics.closed).toBe(true); + }); + + it("closes and reports when the close ACK drain times out", async () => { + const session = new WatermarkSession(); + const sender = new QwpSender(async () => session, { + autoFlush: false, + closeFlushTimeoutMs: 10, + }); + await sender.table("events").longColumn("value", 42n).atNow(); + + await expect(sender.close()).rejects.toMatchObject({ + name: "QwpSenderCloseTimeoutError", + timeoutMs: 10, + targetSequence: 0n, + acknowledgedSequence: -1n, + } satisfies Partial); + expect(session.sends).toHaveLength(1); + expect(session.closeCount).toBe(1); + expect(sender.metrics).toMatchObject({ + pendingRows: 0, + connected: false, + closed: true, + }); + }); + + it("publishes on close without draining when the timeout is zero", async () => { + const session = new WatermarkSession(); + const sender = new QwpSender(async () => session, { + autoFlush: false, + closeFlushTimeoutMs: 0, + }); + await sender.table("events").longColumn("value", 42n).atNow(); + + await expect(sender.close()).resolves.toBeUndefined(); + expect(session.sends).toHaveLength(1); + expect(session.acknowledgedFrameSequence).toBe(-1n); + expect(session.closeCount).toBe(1); + }); + it("uses the existing Sender fluent API and preserves an unfinished row", async () => { const session = new RecordingSession(); const sender = new QwpSender(async () => session, { autoFlush: false }); @@ -615,7 +703,31 @@ describe("QWP high-level sender", () => { await sender.close(); expect(session.sends).toHaveLength(1); expect(messages).toEqual([ - expect.stringContaining("1 auto-flushed row(s) awaiting commit"), + expect.stringContaining("1 deferred row(s) awaiting commit"), + ]); + }); + + it("publishes staged transactional rows without implicitly committing them", async () => { + const session = new PublishingSession(); + const messages: (string | Error)[] = []; + const sender = new QwpSender(async () => session, { + autoFlush: false, + transactional: true, + closeFlushTimeoutMs: 0, + log: (level, message) => { + if (level === "warn") messages.push(message); + }, + }); + await sender.table("events").longColumn("value", 42n).atNow(); + + await sender.close(); + expect(session.sends).toHaveLength(1); + expect(session.sends[0]).toMatchObject({ + options: { deferCommit: true }, + }); + expect(session.sends[0].tables[0].rowCount).toBe(1); + expect(messages).toEqual([ + expect.stringContaining("1 deferred row(s) awaiting commit"), ]); }); From 3b96563306399030a4a62f740e3b463d7007e75b Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 18 Aug 2026 14:58:59 +0100 Subject: [PATCH 042/265] feat(qwp): add reusable egress column views --- QWP.md | 48 +- README.md | 7 + src/qwp/client.ts | 11 + src/qwp/core/result-batch.ts | 1123 ++++++++++++++++++++++++++++++- src/qwp/egress-session.ts | 95 ++- test/qwp/client.test.ts | 39 ++ test/qwp/egress.test.ts | 199 ++++++ test/qwp/public-api-contract.ts | 29 + test/qwp/public-api.test.ts | 2 + 9 files changed, 1541 insertions(+), 12 deletions(-) diff --git a/QWP.md b/QWP.md index dd40cc3..75d02c3 100644 --- a/QWP.md +++ b/QWP.md @@ -461,6 +461,51 @@ try { } ``` +### Bounded reusable result views + +`query()` keeps its convenient materialized batches. For hot paths, `queryViews()` +avoids allocating a JavaScript value array for every column and delivers one +reusable batch view through an awaited callback: + +```typescript +const query = await session.queryViews( + "select timestamp, symbol, price from trades", + async (batch) => { + const timestamp = batch.column(0); + const symbol = batch.column(1); + const price = batch.column(2); + + // Fixed-width values are read directly from the QWP little-endian bytes. + for (let row = 0; row < batch.rowCount; row++) { + if (!price.isNull(row)) { + consume( + timestamp.getLong(row), + symbol.getSymbol(row), + price.getDouble(row), + ); + } + } + + // Raw views are available for vectorized consumers. + consumePackedDoubles(price.valuesBytes()!); + }, + { initialCredit: 256 * 1024 }, +); +await query.completion; +``` + +The batch, its column objects, and every `Uint8Array`/`Int32Array` returned by a +column are valid only until the callback settles. The decoder reuses those objects +and its NULL-index, symbol-ID, array-offset, and Gorilla-timestamp scratch storage +for later batches. Copy an individual byte view with `.slice()`, or call +`batch.materialize()` inside the callback, when data must be retained. + +Raw fixed-width, NULL, VARCHAR/BINARY, and array data views point into the current +decoded frame; Zstd results point into that batch's decompressed buffer. Accessors +such as `getString()` and `get()` decode or construct only the requested cell. The +callback is awaited before automatic credit is replenished, so the configured +credit window bounds server read-ahead while application work is in progress. + `target` accepts `any` (the default), `primary`, or `replica`. Primary routing also accepts standalone servers and a primary completing catch-up, matching the Java client. `zone` is an opaque, case-insensitive preference for `any` and `replica`; @@ -695,7 +740,8 @@ acknowledgement, and persistent replay—but uses runtime-specific connection fa | Durable delivery | `requestDurableAck` plus `awaitDurableAck` | | Store-and-forward | Node `storeAndForward`; intentionally unavailable in browsers | | Query parameters | `session.query(sql, { binds })` | -| Result batches | `for await (const batch of query)` | +| Materialized result batches | `for await (const batch of query)` | +| Reusable result views | `session.queryViews(sql, onBatch)` | Do not translate Java threading assumptions directly: callbacks, WebSocket delivery, and iteration all share the JavaScript event loop. diff --git a/README.md b/README.md index 145483f..063694d 100644 --- a/README.md +++ b/README.md @@ -312,6 +312,13 @@ server can stream ahead. Tune `initialCredit` on the session or individual query set it to zero only to opt into legacy unbounded streaming. Set `autoCredit: false` to manage credit explicitly through `query.grantCredit()`. +For allocation-sensitive consumers, `session.queryViews(sql, onBatch)` supplies +bounded, reusable column views instead of materializing every value into JavaScript +arrays. Typed accessors read fixed-width values directly from QWP bytes, and raw +byte views are available for vectorized processing. The callback is awaited before +credit is replenished. Views are invalid when it returns; copy a byte view with +`.slice()` or call `batch.materialize()` inside the callback to retain data. + `queryTimeoutMs` sets the session's default query deadline; a per-query `timeoutMs` overrides it, and zero disables the deadline. When a deadline expires, the client rejects iteration and `query.completion` with diff --git a/src/qwp/client.ts b/src/qwp/client.ts index 9127da6..3669b23 100644 --- a/src/qwp/client.ts +++ b/src/qwp/client.ts @@ -2,6 +2,8 @@ import { QwpEgressQuery, QwpEgressQueryOptions, QwpEgressSession, + QwpEgressViewQuery, + QwpResultBatchViewHandler, } from "./egress-session"; import { QwpSender } from "./sender"; import { QwpHandshakeMetadata } from "./transport"; @@ -349,6 +351,15 @@ export class QwpQueryLease { return this.session.query(sql, options); } + queryViews( + sql: string, + onBatch: QwpResultBatchViewHandler, + options: QwpEgressQueryOptions = {}, + ): Promise { + this.throwIfReleased(); + return this.session.queryViews(sql, onBatch, options); + } + close(): Promise { if (!this.closePromise) this.closePromise = this.closeNow(); return this.closePromise; diff --git a/src/qwp/core/result-batch.ts b/src/qwp/core/result-batch.ts index be89431..77ce6db 100644 --- a/src/qwp/core/result-batch.ts +++ b/src/qwp/core/result-batch.ts @@ -99,6 +99,758 @@ export class QwpResultBatch { } } +class QwpResultColumnViewLayout { + schema!: QwpResultColumnSchema; + rowCount = 0; + nonNullCount = 0; + nullBitmap?: Uint8Array; + nonNullIndexes?: Int32Array; + values?: Uint8Array; + valuesView?: DataView; + stringBytes?: Uint8Array; + symbolDictionary?: readonly string[]; + symbolRowIds?: Int32Array; + arrayOffsets?: Int32Array; + arrayLengths?: Int32Array; + scale?: number; + precisionBits?: number; + private timestampStorage?: Uint8Array; + readonly localSymbols: string[] = []; + + reset(schema: QwpResultColumnSchema, rowCount: number): void { + this.schema = schema; + this.rowCount = rowCount; + this.nonNullCount = 0; + this.nullBitmap = undefined; + this.values = undefined; + this.valuesView = undefined; + this.stringBytes = undefined; + this.symbolDictionary = undefined; + this.scale = undefined; + this.precisionBits = undefined; + this.localSymbols.length = 0; + } + + release(): void { + // Drop frame-backed references immediately. Capacity-bearing scratch + // arrays remain attached to the layout for the next batch. + this.nullBitmap = undefined; + this.values = undefined; + this.valuesView = undefined; + this.stringBytes = undefined; + this.symbolDictionary = undefined; + this.localSymbols.length = 0; + } + + setValues(bytes: Uint8Array): void { + this.values = bytes; + this.valuesView = new DataView( + bytes.buffer, + bytes.byteOffset, + bytes.byteLength, + ); + } + + ensureNonNullIndexes(size: number): Int32Array { + this.nonNullIndexes = ensureInt32Capacity(this.nonNullIndexes, size); + return this.nonNullIndexes; + } + + ensureSymbolRowIds(size: number): Int32Array { + this.symbolRowIds = ensureInt32Capacity(this.symbolRowIds, size); + return this.symbolRowIds; + } + + ensureArrayOffsets(size: number): Int32Array { + this.arrayOffsets = ensureInt32Capacity(this.arrayOffsets, size); + return this.arrayOffsets; + } + + ensureArrayLengths(size: number): Int32Array { + this.arrayLengths = ensureInt32Capacity(this.arrayLengths, size); + return this.arrayLengths; + } + + timestampBytes(size: number): Uint8Array { + if (!this.timestampStorage || this.timestampStorage.byteLength < size) { + let capacity = Math.max(64, this.timestampStorage?.byteLength ?? 0); + while (capacity < size) capacity *= 2; + this.timestampStorage = new Uint8Array(capacity); + } + return this.timestampStorage.subarray(0, size); + } + + isNull(row: number): boolean { + const bitmap = this.nullBitmap; + return bitmap !== undefined && (bitmap[row >>> 3] & (1 << (row & 7))) !== 0; + } + + denseIndex(row: number): number { + return this.nullBitmap ? this.nonNullIndexes![row] : row; + } +} + +function ensureInt32Capacity( + current: Int32Array | undefined, + size: number, +): Int32Array { + if (current && current.length >= size) return current; + let capacity = Math.max(16, current?.length ?? 0); + while (capacity < size) capacity *= 2; + return new Int32Array(capacity); +} + +/** + * Reusable, zero-copy view over one QWP result column. + * + * The view and every byte slice returned from it are valid only while the + * surrounding queryViews() callback is running. Copy data that must outlive + * the callback. + */ +export class QwpResultColumnView { + /** @internal */ + constructor( + private readonly batch: QwpResultBatchView, + readonly columnIndex: number, + ) {} + + get name(): string { + return this.layout().schema.name; + } + + get type(): QwpColumnType { + return this.layout().schema.type; + } + + get rowCount(): number { + return this.layout().rowCount; + } + + get nonNullCount(): number { + return this.layout().nonNullCount; + } + + get scale(): number | undefined { + return this.layout().scale; + } + + get precisionBits(): number | undefined { + return this.layout().precisionBits; + } + + /** Fixed-width stride, zero for bit-packed BOOLEAN, or -1 when variable. */ + get bytesPerValue(): number { + const layout = this.layout(); + switch (layout.schema.type) { + case QWP_COLUMN_TYPE.BOOLEAN: + return 0; + case QWP_COLUMN_TYPE.BYTE: + return 1; + case QWP_COLUMN_TYPE.SHORT: + case QWP_COLUMN_TYPE.CHAR: + return 2; + case QWP_COLUMN_TYPE.INT: + case QWP_COLUMN_TYPE.FLOAT: + case QWP_COLUMN_TYPE.IPV4: + return 4; + case QWP_COLUMN_TYPE.LONG: + case QWP_COLUMN_TYPE.DOUBLE: + case QWP_COLUMN_TYPE.DATE: + case QWP_COLUMN_TYPE.TIMESTAMP: + case QWP_COLUMN_TYPE.TIMESTAMP_NANOS: + case QWP_COLUMN_TYPE.DECIMAL64: + return 8; + case QWP_COLUMN_TYPE.UUID: + case QWP_COLUMN_TYPE.DECIMAL128: + return 16; + case QWP_COLUMN_TYPE.LONG256: + case QWP_COLUMN_TYPE.DECIMAL256: + return 32; + case QWP_COLUMN_TYPE.GEOHASH: + return Math.ceil(layout.precisionBits! / 8); + default: + return -1; + } + } + + isNull(rowIndex: number): boolean { + const layout = this.checkedLayout(rowIndex); + return layout.isNull(rowIndex); + } + + nonNullIndex(rowIndex: number): number { + const layout = this.checkedLayout(rowIndex); + return layout.isNull(rowIndex) ? -1 : layout.denseIndex(rowIndex); + } + + /** Raw per-row NULL bitmap, without copying. Undefined means no NULLs. */ + nullBitmapBytes(): Uint8Array | undefined { + return this.layout().nullBitmap; + } + + /** + * Raw packed non-null values. Fixed-width values use QWP little-endian + * layout; booleans are bit-packed and variable-width columns contain their + * uint32 offset table. SYMBOL returns undefined because IDs are varints. + */ + valuesBytes(): Uint8Array | undefined { + return this.layout().values; + } + + /** Concatenated VARCHAR/BINARY payload bytes, without copying. */ + stringBytes(): Uint8Array | undefined { + return this.layout().stringBytes; + } + + /** Reusable dense-index table; only the first rowCount entries are valid. */ + nonNullIndexView(): Int32Array | undefined { + const layout = this.layout(); + return layout.nullBitmap + ? layout.nonNullIndexes!.subarray(0, layout.rowCount) + : undefined; + } + + /** Reusable per-row SYMBOL IDs; NULL-row entries are unspecified. */ + symbolIdView(): Int32Array | undefined { + const layout = this.layout(); + this.requireType(layout, QWP_COLUMN_TYPE.SYMBOL); + return layout.symbolRowIds?.subarray(0, layout.rowCount); + } + + getBoolean(rowIndex: number): boolean { + const { layout, dense } = this.valuePosition( + rowIndex, + QWP_COLUMN_TYPE.BOOLEAN, + ); + if (dense < 0) return false; + return (layout.values![dense >>> 3] & (1 << (dense & 7))) !== 0; + } + + getByte(rowIndex: number): number { + const { layout, dense } = this.valuePosition( + rowIndex, + QWP_COLUMN_TYPE.BYTE, + ); + return dense < 0 ? 0 : layout.valuesView!.getInt8(dense); + } + + getShort(rowIndex: number): number { + const { layout, dense } = this.valuePosition( + rowIndex, + QWP_COLUMN_TYPE.SHORT, + ); + return dense < 0 ? 0 : layout.valuesView!.getInt16(dense * 2, true); + } + + getChar(rowIndex: number): string { + const { layout, dense } = this.valuePosition( + rowIndex, + QWP_COLUMN_TYPE.CHAR, + ); + return dense < 0 + ? "\0" + : String.fromCharCode(layout.valuesView!.getUint16(dense * 2, true)); + } + + getInt(rowIndex: number): number { + const { layout, dense } = this.valuePosition( + rowIndex, + QWP_COLUMN_TYPE.INT, + QWP_COLUMN_TYPE.IPV4, + ); + return dense < 0 ? 0 : layout.valuesView!.getInt32(dense * 4, true); + } + + getFloat(rowIndex: number): number { + const { layout, dense } = this.valuePosition( + rowIndex, + QWP_COLUMN_TYPE.FLOAT, + ); + return dense < 0 + ? Number.NaN + : layout.valuesView!.getFloat32(dense * 4, true); + } + + getDouble(rowIndex: number): number { + const { layout, dense } = this.valuePosition( + rowIndex, + QWP_COLUMN_TYPE.DOUBLE, + ); + return dense < 0 + ? Number.NaN + : layout.valuesView!.getFloat64(dense * 8, true); + } + + getLong(rowIndex: number): bigint { + const { layout, dense } = this.valuePosition( + rowIndex, + QWP_COLUMN_TYPE.LONG, + QWP_COLUMN_TYPE.DATE, + QWP_COLUMN_TYPE.TIMESTAMP, + QWP_COLUMN_TYPE.TIMESTAMP_NANOS, + ); + return dense < 0 ? 0n : layout.valuesView!.getBigInt64(dense * 8, true); + } + + /** Zero-copy UTF-8 bytes for a VARCHAR value. */ + getUtf8View(rowIndex: number): Uint8Array | null { + const { layout, dense } = this.valuePosition( + rowIndex, + QWP_COLUMN_TYPE.VARCHAR, + ); + return dense < 0 ? null : variableWidthValue(layout, dense); + } + + getString(rowIndex: number): string | null { + const layout = this.checkedLayout(rowIndex); + if (layout.schema.type === QWP_COLUMN_TYPE.SYMBOL) { + return this.getSymbol(rowIndex); + } + this.requireType(layout, QWP_COLUMN_TYPE.VARCHAR); + if (layout.isNull(rowIndex)) return null; + return decodeUtf8(variableWidthValue(layout, layout.denseIndex(rowIndex))); + } + + /** Zero-copy BINARY bytes. */ + getBinaryView(rowIndex: number): Uint8Array | null { + const { layout, dense } = this.valuePosition( + rowIndex, + QWP_COLUMN_TYPE.BINARY, + ); + return dense < 0 ? null : variableWidthValue(layout, dense); + } + + getSymbolId(rowIndex: number): number { + const layout = this.checkedLayout(rowIndex); + this.requireType(layout, QWP_COLUMN_TYPE.SYMBOL); + return layout.isNull(rowIndex) ? -1 : layout.symbolRowIds![rowIndex]; + } + + getSymbol(rowIndex: number): string | null { + const layout = this.checkedLayout(rowIndex); + this.requireType(layout, QWP_COLUMN_TYPE.SYMBOL); + return layout.isNull(rowIndex) + ? null + : layout.symbolDictionary![layout.symbolRowIds![rowIndex]]; + } + + getSymbolForId(symbolId: number): string { + const layout = this.layout(); + this.requireType(layout, QWP_COLUMN_TYPE.SYMBOL); + const dictionary = layout.symbolDictionary!; + if ( + !Number.isInteger(symbolId) || + symbolId < 0 || + symbolId >= dictionary.length + ) { + throw new RangeError(`symbol ID out of range: ${symbolId}`); + } + return dictionary[symbolId]; + } + + get symbolDictionarySize(): number { + const layout = this.layout(); + this.requireType(layout, QWP_COLUMN_TYPE.SYMBOL); + return layout.symbolDictionary!.length; + } + + getUuidLow(rowIndex: number): bigint { + const { layout, dense } = this.valuePosition( + rowIndex, + QWP_COLUMN_TYPE.UUID, + ); + return dense < 0 ? 0n : layout.valuesView!.getBigUint64(dense * 16, true); + } + + getUuidHigh(rowIndex: number): bigint { + const { layout, dense } = this.valuePosition( + rowIndex, + QWP_COLUMN_TYPE.UUID, + ); + return dense < 0 + ? 0n + : layout.valuesView!.getBigUint64(dense * 16 + 8, true); + } + + getLong256Word(rowIndex: number, wordIndex: number): bigint { + if (!Number.isInteger(wordIndex) || wordIndex < 0 || wordIndex > 3) { + throw new RangeError(`LONG256 word index out of range: ${wordIndex}`); + } + const { layout, dense } = this.valuePosition( + rowIndex, + QWP_COLUMN_TYPE.LONG256, + ); + return dense < 0 + ? 0n + : layout.valuesView!.getBigInt64(dense * 32 + wordIndex * 8, true); + } + + getDecimalUnscaled(rowIndex: number): bigint { + const { layout, dense } = this.valuePosition( + rowIndex, + QWP_COLUMN_TYPE.DECIMAL64, + QWP_COLUMN_TYPE.DECIMAL128, + QWP_COLUMN_TYPE.DECIMAL256, + ); + if (dense < 0) return 0n; + const width = fixedTypeWidth(layout.schema.type); + return signedLittleEndianValue(layout.values!, dense * width, width); + } + + getGeohashBits(rowIndex: number): bigint { + const { layout, dense } = this.valuePosition( + rowIndex, + QWP_COLUMN_TYPE.GEOHASH, + ); + if (dense < 0) return 0n; + const width = Math.ceil(layout.precisionBits! / 8); + return unsignedLittleEndianValue(layout.values!, dense * width, width); + } + + /** Zero-copy encoded ARRAY row, including dimension header. */ + getArrayView(rowIndex: number): Uint8Array | null { + const layout = this.checkedLayout(rowIndex); + this.requireType( + layout, + QWP_COLUMN_TYPE.DOUBLE_ARRAY, + QWP_COLUMN_TYPE.LONG_ARRAY, + ); + if (layout.isNull(rowIndex)) return null; + const offset = layout.arrayOffsets![rowIndex]; + return layout.values!.subarray( + offset, + offset + layout.arrayLengths![rowIndex], + ); + } + + getArrayDimensionCount(rowIndex: number): number { + const layout = this.checkedLayout(rowIndex); + this.requireType( + layout, + QWP_COLUMN_TYPE.DOUBLE_ARRAY, + QWP_COLUMN_TYPE.LONG_ARRAY, + ); + return layout.isNull(rowIndex) + ? 0 + : layout.values![layout.arrayOffsets![rowIndex]]; + } + + /** Lazily materializes one cell; prefer typed/raw accessors on hot paths. */ + get(rowIndex: number): QwpResultValue { + const layout = this.checkedLayout(rowIndex); + if (layout.isNull(rowIndex)) return null; + const dense = layout.denseIndex(rowIndex); + const view = layout.valuesView; + switch (layout.schema.type) { + case QWP_COLUMN_TYPE.BOOLEAN: + return (layout.values![dense >>> 3] & (1 << (dense & 7))) !== 0; + case QWP_COLUMN_TYPE.BYTE: + return view!.getInt8(dense); + case QWP_COLUMN_TYPE.SHORT: + return view!.getInt16(dense * 2, true); + case QWP_COLUMN_TYPE.CHAR: + return String.fromCharCode(view!.getUint16(dense * 2, true)); + case QWP_COLUMN_TYPE.INT: + case QWP_COLUMN_TYPE.IPV4: + return view!.getInt32(dense * 4, true); + case QWP_COLUMN_TYPE.FLOAT: + return view!.getFloat32(dense * 4, true); + case QWP_COLUMN_TYPE.DOUBLE: + return view!.getFloat64(dense * 8, true); + case QWP_COLUMN_TYPE.LONG: + case QWP_COLUMN_TYPE.DATE: + case QWP_COLUMN_TYPE.TIMESTAMP: + case QWP_COLUMN_TYPE.TIMESTAMP_NANOS: + return view!.getBigInt64(dense * 8, true); + case QWP_COLUMN_TYPE.VARCHAR: + return decodeUtf8(variableWidthValue(layout, dense)); + case QWP_COLUMN_TYPE.BINARY: + return variableWidthValue(layout, dense); + case QWP_COLUMN_TYPE.SYMBOL: + return layout.symbolDictionary![layout.symbolRowIds![rowIndex]]; + case QWP_COLUMN_TYPE.UUID: + return { + low: view!.getBigUint64(dense * 16, true), + high: view!.getBigUint64(dense * 16 + 8, true), + }; + case QWP_COLUMN_TYPE.LONG256: + return { + words: [ + view!.getBigInt64(dense * 32, true), + view!.getBigInt64(dense * 32 + 8, true), + view!.getBigInt64(dense * 32 + 16, true), + view!.getBigInt64(dense * 32 + 24, true), + ], + }; + case QWP_COLUMN_TYPE.DECIMAL64: + case QWP_COLUMN_TYPE.DECIMAL128: + case QWP_COLUMN_TYPE.DECIMAL256: { + const width = fixedTypeWidth(layout.schema.type); + return { + unscaled: signedLittleEndianValue( + layout.values!, + dense * width, + width, + ), + scale: layout.scale!, + }; + } + case QWP_COLUMN_TYPE.GEOHASH: { + const width = Math.ceil(layout.precisionBits! / 8); + return { + bits: unsignedLittleEndianValue(layout.values!, dense * width, width), + precisionBits: layout.precisionBits!, + }; + } + case QWP_COLUMN_TYPE.DOUBLE_ARRAY: + case QWP_COLUMN_TYPE.LONG_ARRAY: + return readArrayValue( + new QwpByteReader(this.getArrayView(rowIndex)!), + layout.schema.type, + ); + default: + throw new QwpProtocolError( + `unsupported QWP result column type: ${String(layout.schema.type)}`, + ); + } + } + + private layout(): QwpResultColumnViewLayout { + return this.batch.layout(this.columnIndex); + } + + private checkedLayout(rowIndex: number): QwpResultColumnViewLayout { + const layout = this.layout(); + if ( + !Number.isInteger(rowIndex) || + rowIndex < 0 || + rowIndex >= layout.rowCount + ) { + throw new RangeError(`row index out of range: ${rowIndex}`); + } + return layout; + } + + private requireType( + layout: QwpResultColumnViewLayout, + type1: QwpColumnType, + type2?: QwpColumnType, + type3?: QwpColumnType, + type4?: QwpColumnType, + ): void { + const actual = layout.schema.type; + if ( + actual !== type1 && + actual !== type2 && + actual !== type3 && + actual !== type4 + ) { + throw new TypeError( + `column '${layout.schema.name}' has QWP type 0x${actual.toString(16)}`, + ); + } + } + + private valuePosition( + rowIndex: number, + type1: QwpColumnType, + type2?: QwpColumnType, + type3?: QwpColumnType, + type4?: QwpColumnType, + ): { layout: QwpResultColumnViewLayout; dense: number } { + const layout = this.checkedLayout(rowIndex); + this.requireType(layout, type1, type2, type3, type4); + return { + layout, + dense: layout.isNull(rowIndex) ? -1 : layout.denseIndex(rowIndex), + }; + } +} + +/** + * Batch-owned reusable view delivered by QwpEgressSession.queryViews(). + * Access is invalid after the callback returns. materialize() creates an + * independently owned QwpResultBatch when retention is required. + */ +export class QwpResultBatchView { + private active = false; + private _requestId = -1n; + private _batchSequence = -1n; + private _tableName = ""; + private _rowCount = 0; + private layouts: QwpResultColumnViewLayout[] = []; + private readonly columnViews: QwpResultColumnView[] = []; + private readonly columnViewPool: QwpResultColumnView[] = []; + + get valid(): boolean { + return this.active; + } + + get requestId(): bigint { + this.assertValid(); + return this._requestId; + } + + get batchSequence(): bigint { + this.assertValid(); + return this._batchSequence; + } + + get tableName(): string { + this.assertValid(); + return this._tableName; + } + + get rowCount(): number { + this.assertValid(); + return this._rowCount; + } + + get columnCount(): number { + this.assertValid(); + return this.layouts.length; + } + + get columns(): readonly QwpResultColumnView[] { + this.assertValid(); + return this.columnViews; + } + + column(columnIndex: number): QwpResultColumnView { + this.assertValid(); + const column = this.columnViews[columnIndex]; + if (!column) { + throw new RangeError(`column index out of range: ${columnIndex}`); + } + return column; + } + + get(rowIndex: number, columnIndex: number): QwpResultValue { + return this.column(columnIndex).get(rowIndex); + } + + materialize(): QwpResultBatch { + this.assertValid(); + return new QwpResultBatch( + this._requestId, + this._batchSequence, + this._tableName, + this._rowCount, + this.columnViews.map((column) => ({ + name: column.name, + type: column.type, + values: Array.from({ length: this._rowCount }, (_, row) => { + const value = column.get(row); + // Binary values are zero-copy slices in the view API. materialize() + // promises independently owned data, so detach those slices here. + return value instanceof Uint8Array ? value.slice() : value; + }), + ...(column.scale === undefined ? {} : { scale: column.scale }), + ...(column.precisionBits === undefined + ? {} + : { precisionBits: column.precisionBits }), + })), + ); + } + + /** Invalidates the view. Normally called automatically after queryViews(). */ + release(): void { + if (!this.active) return; + this.active = false; + for (const layout of this.layouts) layout.release(); + } + + /** @internal */ + reset( + requestId: bigint, + batchSequence: bigint, + tableName: string, + rowCount: number, + layouts: QwpResultColumnViewLayout[], + ): this { + this._requestId = requestId; + this._batchSequence = batchSequence; + this._tableName = tableName; + this._rowCount = rowCount; + this.layouts = layouts; + while (this.columnViewPool.length < layouts.length) { + this.columnViewPool.push( + new QwpResultColumnView(this, this.columnViewPool.length), + ); + } + this.columnViews.length = layouts.length; + for (let index = 0; index < layouts.length; index++) { + this.columnViews[index] = this.columnViewPool[index]; + } + this.active = true; + return this; + } + + /** @internal */ + layout(columnIndex: number): QwpResultColumnViewLayout { + this.assertValid(); + const layout = this.layouts[columnIndex]; + if (!layout) { + throw new RangeError(`column index out of range: ${columnIndex}`); + } + return layout; + } + + private assertValid(): void { + if (!this.active) { + throw new Error( + "QWP result batch view is no longer valid; copy or materialize values inside the queryViews callback", + ); + } + } +} + +function variableWidthValue( + layout: QwpResultColumnViewLayout, + denseIndex: number, +): Uint8Array { + const offsets = layout.valuesView!; + const start = offsets.getUint32(denseIndex * 4, true); + const end = offsets.getUint32((denseIndex + 1) * 4, true); + return layout.stringBytes!.subarray(start, end); +} + +function fixedTypeWidth(type: QwpColumnType): number { + switch (type) { + case QWP_COLUMN_TYPE.DECIMAL64: + return 8; + case QWP_COLUMN_TYPE.DECIMAL128: + return 16; + case QWP_COLUMN_TYPE.DECIMAL256: + return 32; + default: + throw new TypeError(`QWP type 0x${type.toString(16)} is not decimal`); + } +} + +function unsignedLittleEndianValue( + bytes: Uint8Array, + offset = 0, + length = bytes.length - offset, +): bigint { + let value = 0n; + for (let index = 0; index < length; index++) { + value |= BigInt(bytes[offset + index]) << BigInt(index * 8); + } + return value; +} + +function signedLittleEndianValue( + bytes: Uint8Array, + offset = 0, + length = bytes.length - offset, +): bigint { + const value = unsignedLittleEndianValue(bytes, offset, length); + const bits = BigInt(length * 8); + const sign = 1n << (bits - 1n); + return (value & sign) === 0n ? value : value - (1n << bits); +} + interface NullLayout { nulls: boolean[]; nonNullCount: number; @@ -336,13 +1088,24 @@ function readArrayValue( }; } +interface PreparedResultBatch { + readonly reader: QwpByteReader; + readonly tableName: string; + readonly rowCount: number; + readonly deltaMode: boolean; +} + /** Stateful decoder for connection-scoped QWP result batches. */ export class QwpResultBatchDecoder { private readonly symbolDictionary: string[] = []; + private readonly viewBatch = new QwpResultBatchView(); + private readonly viewLayouts: QwpResultColumnViewLayout[] = []; + private readonly viewLayoutPool: QwpResultColumnViewLayout[] = []; private schema?: QwpResultColumnSchema[]; private expectedBatchSequence = 0n; resetQuerySchema(): void { + this.viewBatch.release(); this.schema = undefined; this.expectedBatchSequence = 0n; } @@ -354,6 +1117,52 @@ export class QwpResultBatchDecoder { } decode(message: QwpResultBatchMessage): QwpResultBatch { + const { reader, tableName, rowCount, deltaMode } = this.prepare(message); + + const columns = this.schema!.map((column) => + this.readColumn(reader, column, rowCount, deltaMode, message.flags), + ); + reader.expectEnd("RESULT_BATCH"); + this.expectedBatchSequence++; + return new QwpResultBatch( + message.requestId, + message.batchSequence, + tableName, + rowCount, + columns, + ); + } + + /** + * Decodes into one reusable batch/column-view set without materializing a + * JavaScript value array. A subsequent decode invalidates the prior view. + */ + decodeView(message: QwpResultBatchMessage): QwpResultBatchView { + this.viewBatch.release(); + const { reader, tableName, rowCount, deltaMode } = this.prepare(message); + const schema = this.schema!; + while (this.viewLayoutPool.length < schema.length) { + this.viewLayoutPool.push(new QwpResultColumnViewLayout()); + } + this.viewLayouts.length = schema.length; + for (let index = 0; index < schema.length; index++) { + const layout = this.viewLayoutPool[index]; + this.viewLayouts[index] = layout; + layout.reset(schema[index], rowCount); + this.readColumnView(reader, layout, deltaMode, message.flags); + } + reader.expectEnd("RESULT_BATCH"); + this.expectedBatchSequence++; + return this.viewBatch.reset( + message.requestId, + message.batchSequence, + tableName, + rowCount, + this.viewLayouts, + ); + } + + private prepare(message: QwpResultBatchMessage): PreparedResultBatch { if (message.tableCount !== 1) { throw new QwpProtocolError( `RESULT_BATCH must contain exactly one table, got ${message.tableCount}`, @@ -407,21 +1216,315 @@ export class QwpResultBatchDecoder { "continuation RESULT_BATCH arrived before its schema-bearing batch", ); } + return { reader, tableName, rowCount, deltaMode }; + } - const columns = this.schema!.map((column) => - this.readColumn(reader, column, rowCount, deltaMode, message.flags), + private readColumnView( + reader: QwpByteReader, + layout: QwpResultColumnViewLayout, + deltaMode: boolean, + flags: number, + ): void { + this.readNullView(reader, layout); + const count = layout.nonNullCount; + const type = layout.schema.type; + switch (type) { + case QWP_COLUMN_TYPE.BOOLEAN: + layout.setValues( + reader.readBytes(Math.ceil(count / 8), "boolean values"), + ); + return; + case QWP_COLUMN_TYPE.BYTE: + this.readFixedView(reader, layout, count, 1, "byte values"); + return; + case QWP_COLUMN_TYPE.SHORT: + case QWP_COLUMN_TYPE.CHAR: + this.readFixedView(reader, layout, count, 2, "short values"); + return; + case QWP_COLUMN_TYPE.INT: + case QWP_COLUMN_TYPE.FLOAT: + case QWP_COLUMN_TYPE.IPV4: + this.readFixedView(reader, layout, count, 4, "int values"); + return; + case QWP_COLUMN_TYPE.LONG: + case QWP_COLUMN_TYPE.DOUBLE: + this.readFixedView(reader, layout, count, 8, "long values"); + return; + case QWP_COLUMN_TYPE.DATE: + case QWP_COLUMN_TYPE.TIMESTAMP: + case QWP_COLUMN_TYPE.TIMESTAMP_NANOS: + this.readTimestampView(reader, layout, flags); + return; + case QWP_COLUMN_TYPE.VARCHAR: + case QWP_COLUMN_TYPE.BINARY: + this.readVariableWidthView(reader, layout); + return; + case QWP_COLUMN_TYPE.SYMBOL: + this.readSymbolView(reader, layout, deltaMode); + return; + case QWP_COLUMN_TYPE.UUID: + this.readFixedView(reader, layout, count, 16, "UUID values"); + return; + case QWP_COLUMN_TYPE.LONG256: + this.readFixedView(reader, layout, count, 32, "LONG256 values"); + return; + case QWP_COLUMN_TYPE.DECIMAL64: + case QWP_COLUMN_TYPE.DECIMAL128: + case QWP_COLUMN_TYPE.DECIMAL256: { + layout.scale = reader.readUint8("decimal scale"); + this.readFixedView( + reader, + layout, + count, + fixedTypeWidth(type), + "decimal values", + ); + return; + } + case QWP_COLUMN_TYPE.GEOHASH: { + layout.precisionBits = readCount(reader, 60, "geohash precision"); + if (layout.precisionBits < 1) { + throw new QwpProtocolError( + `geohash precision out of range: ${layout.precisionBits}`, + ); + } + this.readFixedView( + reader, + layout, + count, + Math.ceil(layout.precisionBits / 8), + "geohash values", + ); + return; + } + case QWP_COLUMN_TYPE.DOUBLE_ARRAY: + case QWP_COLUMN_TYPE.LONG_ARRAY: + this.readArrayView(reader, layout); + return; + default: + throw new QwpProtocolError( + `unsupported QWP result column type: ${String(type)}`, + ); + } + } + + private readNullView( + reader: QwpByteReader, + layout: QwpResultColumnViewLayout, + ): void { + const flag = reader.readUint8("column null flag"); + if (flag !== 0 && flag !== 1) { + throw new QwpProtocolError(`invalid column null flag: ${flag}`); + } + if (flag === 0) { + layout.nonNullCount = layout.rowCount; + return; + } + const bitmap = reader.readBytes( + Math.ceil(layout.rowCount / 8), + "column null bitmap", ); - reader.expectEnd("RESULT_BATCH"); - this.expectedBatchSequence++; - return new QwpResultBatch( - message.requestId, - message.batchSequence, - tableName, - rowCount, - columns, + layout.nullBitmap = bitmap; + const indexes = layout.ensureNonNullIndexes(layout.rowCount); + let dense = 0; + for (let row = 0; row < layout.rowCount; row++) { + if ((bitmap[row >>> 3] & (1 << (row & 7))) !== 0) { + indexes[row] = -1; + } else { + indexes[row] = dense++; + } + } + layout.nonNullCount = dense; + } + + private readFixedView( + reader: QwpByteReader, + layout: QwpResultColumnViewLayout, + count: number, + width: number, + label: string, + ): void { + layout.setValues(reader.readBytes(count * width, label)); + } + + private readVariableWidthView( + reader: QwpByteReader, + layout: QwpResultColumnViewLayout, + ): void { + const count = layout.nonNullCount; + const offsets = reader.readBytes( + (count + 1) * 4, + "variable-width column offsets", + ); + const view = new DataView( + offsets.buffer, + offsets.byteOffset, + offsets.byteLength, + ); + if (view.getUint32(0, true) !== 0) { + throw new QwpProtocolError( + "variable-width column must start at offset zero", + ); + } + let previous = 0; + for (let index = 1; index <= count; index++) { + const offset = view.getUint32(index * 4, true); + if (offset < previous) { + throw new QwpProtocolError( + `variable-width column offsets are not monotonic at index ${index}`, + ); + } + previous = offset; + } + layout.setValues(offsets); + layout.stringBytes = reader.readBytes( + previous, + "variable-width column data", ); } + private readSymbolView( + reader: QwpByteReader, + layout: QwpResultColumnViewLayout, + deltaMode: boolean, + ): void { + let dictionary: readonly string[]; + if (deltaMode) { + dictionary = this.symbolDictionary; + } else { + const size = readCount(reader, layout.rowCount, "symbol dictionary size"); + const local = layout.localSymbols; + for (let index = 0; index < size; index++) { + const length = readCount(reader, reader.remaining, "symbol length"); + local.push(reader.readUtf8(length, "symbol")); + } + dictionary = local; + } + layout.symbolDictionary = dictionary; + const ids = layout.ensureSymbolRowIds(layout.rowCount); + for (let row = 0; row < layout.rowCount; row++) { + if (layout.isNull(row)) continue; + const id = readCount(reader, dictionary.length, "symbol ID"); + if (id >= dictionary.length) { + throw new QwpProtocolError(`symbol ID out of range: ${id}`); + } + ids[row] = id; + } + } + + private readArrayView( + reader: QwpByteReader, + layout: QwpResultColumnViewLayout, + ): void { + const start = reader.position; + const offsets = layout.ensureArrayOffsets(layout.rowCount); + const lengths = layout.ensureArrayLengths(layout.rowCount); + for (let row = 0; row < layout.rowCount; row++) { + if (layout.isNull(row)) { + offsets[row] = 0; + lengths[row] = 0; + continue; + } + const rowStart = reader.position; + const dimensions = reader.readUint8("array dimension count"); + if (dimensions < 1 || dimensions > 32) { + throw new QwpProtocolError( + `array dimension count out of range: ${dimensions}`, + ); + } + let elementCount = 1; + for (let index = 0; index < dimensions; index++) { + const length = reader.readInt32("array dimension length"); + if (length < 0 || length > MAX_ARRAY_DIMENSION_LENGTH) { + throw new QwpProtocolError( + `array dimension length out of range: ${length}`, + ); + } + elementCount *= length; + if (elementCount > MAX_ARRAY_ELEMENTS) { + throw new QwpProtocolError( + `array element count exceeds ${MAX_ARRAY_ELEMENTS}`, + ); + } + } + reader.readBytes(elementCount * 8, "array payload"); + offsets[row] = rowStart - start; + lengths[row] = reader.position - rowStart; + } + layout.setValues(reader.bytes.subarray(start, reader.position)); + } + + private readTimestampView( + reader: QwpByteReader, + layout: QwpResultColumnViewLayout, + flags: number, + ): void { + const count = layout.nonNullCount; + if ((flags & QWP_FLAG_GORILLA) === 0) { + this.readFixedView(reader, layout, count, 8, "timestamp values"); + return; + } + const encoding = reader.readUint8("timestamp encoding"); + if (encoding === 0) { + this.readFixedView(reader, layout, count, 8, "timestamp values"); + return; + } + if (encoding !== 1) { + throw new QwpProtocolError(`unknown timestamp encoding: ${encoding}`); + } + if (count < 3) { + throw new QwpProtocolError( + `Gorilla-encoded column has fewer than three values: ${count}`, + ); + } + const bytes = layout.timestampBytes(count * 8); + const decoded = new DataView( + bytes.buffer, + bytes.byteOffset, + bytes.byteLength, + ); + const first = reader.readBigInt64("first Gorilla timestamp"); + const second = reader.readBigInt64("second Gorilla timestamp"); + decoded.setBigInt64(0, first, true); + decoded.setBigInt64(8, second, true); + const bits = new QwpBitReader( + reader.bytes.subarray( + reader.position, + reader.position + reader.remaining, + ), + ); + let previousTimestamp = second; + let previousDelta = BigInt.asIntN(64, second - first); + for (let index = 2; index < count; index++) { + let deltaOfDelta: bigint; + let prefixOnes = 0; + while (prefixOnes < 4 && bits.readBit() !== 0) prefixOnes++; + switch (prefixOnes) { + case 0: + deltaOfDelta = 0n; + break; + case 1: + deltaOfDelta = bits.readSigned(7); + break; + case 2: + deltaOfDelta = bits.readSigned(9); + break; + case 3: + deltaOfDelta = bits.readSigned(12); + break; + default: + deltaOfDelta = bits.readSigned(32); + } + const delta = BigInt.asIntN(64, previousDelta + deltaOfDelta); + const timestamp = BigInt.asIntN(64, previousTimestamp + delta); + decoded.setBigInt64(index * 8, timestamp, true); + previousDelta = delta; + previousTimestamp = timestamp; + } + reader.readBytes(bits.bytesConsumed, "Gorilla bitstream"); + layout.setValues(bytes); + } + private readColumn( reader: QwpByteReader, schema: QwpResultColumnSchema, diff --git a/src/qwp/egress-session.ts b/src/qwp/egress-session.ts index 0058ee4..0c851d1 100644 --- a/src/qwp/egress-session.ts +++ b/src/qwp/egress-session.ts @@ -13,6 +13,7 @@ import { QwpQueryRequest, QwpResultBatch, QwpResultBatchDecoder, + QwpResultBatchView, QwpResultEndMessage, QwpServerInfoMessage, } from "./core"; @@ -201,6 +202,7 @@ interface QwpEgressQueryControl { additionalBytes: number | bigint, ): Promise; expire(requestId: bigint, timeoutMs: number): void; + rejectView(requestId: bigint, error: Error): Promise; } interface QwpQueuedResultBatch { @@ -208,6 +210,23 @@ interface QwpQueuedResultBatch { readonly creditBytes: number; } +/** Control handle returned by queryViews(). */ +export interface QwpEgressViewQuery { + readonly requestId: bigint; + readonly completion: Promise; + cancel(): Promise; + grantCredit(additionalBytes: number | bigint): Promise; +} + +/** + * Runs while one reusable batch view is valid. Do not retain the batch, + * columns, or raw byte slices after the callback settles. + */ +export type QwpResultBatchViewHandler = ( + batch: QwpResultBatchView, + query: QwpEgressViewQuery, +) => void | Promise; + /** One QWP query/statement and its stream of materialized result batches. */ export class QwpEgressQuery implements AsyncIterable { private readonly batches = new QwpAsyncQueue(); @@ -223,6 +242,7 @@ export class QwpEgressQuery implements AsyncIterable { private readonly control: QwpEgressQueryControl, private readonly creditEnabled: boolean, private readonly autoCredit: boolean, + private readonly viewHandler?: QwpResultBatchViewHandler, ) { let resolve!: (value: QwpQueryCompletion) => void; let reject!: (error: unknown) => void; @@ -238,6 +258,11 @@ export class QwpEgressQuery implements AsyncIterable { } [Symbol.asyncIterator](): AsyncIterator { + if (this.viewHandler) { + throw new Error( + "queryViews() delivers batches through its callback and is not async-iterable", + ); + } const iterator = this.batches[Symbol.asyncIterator](); return { next: async () => { @@ -278,6 +303,38 @@ export class QwpEgressQuery implements AsyncIterable { this.batches.push({ batch, creditBytes }); } + /** @internal */ + async pushView( + batch: QwpResultBatchView, + creditBytes: number, + ): Promise { + if (this.terminal) { + batch.release(); + return; + } + let handlerError: Error | undefined; + try { + await this.viewHandler!(batch, this); + } catch (error) { + handlerError = error instanceof Error ? error : new Error(String(error)); + } finally { + batch.release(); + } + if (handlerError) { + await this.control + .rejectView(this.requestId, handlerError) + .catch(() => undefined); + return; + } + if (!this.autoCredit || this.terminal || creditBytes === 0) return; + await this.control.grantCredit(this.requestId, creditBytes); + } + + /** @internal */ + get usesViews(): boolean { + return this.viewHandler !== undefined; + } + /** @internal */ finish(completion: QwpQueryCompletion): void { if (this.terminal) return; @@ -487,6 +544,30 @@ export class QwpEgressSession implements QwpEgressQueryControl { async query( sql: string, options: QwpEgressQueryOptions = {}, + ): Promise { + return this.startQuery(sql, options); + } + + /** + * Executes a query through a bounded, reusable, zero-copy batch callback. + * The callback is awaited before its batch is invalidated and flow-control + * credit is replenished. + */ + async queryViews( + sql: string, + onBatch: QwpResultBatchViewHandler, + options: QwpEgressQueryOptions = {}, + ): Promise { + if (typeof onBatch !== "function") { + throw new TypeError("queryViews onBatch must be a function"); + } + return this.startQuery(sql, options, onBatch); + } + + private async startQuery( + sql: string, + options: QwpEgressQueryOptions, + viewHandler?: QwpResultBatchViewHandler, ): Promise { const timeoutMs = validateOptionalTimeout( options.timeoutMs ?? this.defaultQueryTimeoutMs, @@ -524,6 +605,7 @@ export class QwpEgressSession implements QwpEgressQueryControl { this, creditEnabled, creditEnabled && (options.autoCredit ?? true), + viewHandler, ); this.decoder.resetQuerySchema(); this.active = query; @@ -587,6 +669,12 @@ export class QwpEgressSession implements QwpEgressQueryControl { } } + async rejectView(requestId: bigint, error: Error): Promise { + const query = this.requireActive(requestId); + const discardedCredit = query.retire(error); + await this.cancelAndDrain(requestId, discardedCredit); + } + close(code = 1000, reason = ""): Promise { if (!this.closePromise) this.closePromise = this.closeNow(code, reason); return this.closePromise; @@ -654,8 +742,11 @@ export class QwpEgressSession implements QwpEgressQueryControl { break; case "result-batch": { const query = this.requireActive(message.requestId); - const batch = this.decoder.decode(message); + const batch = query.usesViews + ? this.decoder.decodeView(message) + : this.decoder.decode(message); if (query.retired) { + if (batch instanceof QwpResultBatchView) batch.release(); const creditBytes = query.lateBatchCredit(payload.byteLength); if (creditBytes > 0) { void this.sendWhileActive( @@ -663,6 +754,8 @@ export class QwpEgressSession implements QwpEgressQueryControl { encodeQwpCredit(message.requestId, creditBytes), ).catch(() => undefined); } + } else if (batch instanceof QwpResultBatchView) { + await query.pushView(batch, payload.byteLength); } else { query.push(batch, payload.byteLength); } diff --git a/test/qwp/client.test.ts b/test/qwp/client.test.ts index fcf7e83..ef7fd4c 100644 --- a/test/qwp/client.test.ts +++ b/test/qwp/client.test.ts @@ -45,6 +45,17 @@ function queryError(requestId: bigint): Uint8Array { return encodeQwpFrame(payload.toUint8Array()); } +function resultEnd(requestId: bigint): Uint8Array { + return encodeQwpFrame( + new QwpByteWriter() + .writeUint8(QWP_EGRESS_MESSAGE.RESULT_END) + .writeBigUint64(requestId) + .writeUint8(0) + .writeUint8(0) + .toUint8Array(), + ); +} + class FakeConnection implements QwpBinaryConnection { readonly handshake: QwpHandshakeMetadata = { qwpVersion: 1 }; readonly messages: AsyncIterable; @@ -251,6 +262,34 @@ describe("QWP pooled client", () => { expect(connections).toHaveLength(2); }); + it("runs reusable view queries through a pooled query lease", async () => { + const connections: FakeConnection[] = []; + const client = new QwpClient( + { + createSender: async () => { + throw new Error("sender factory should not run"); + }, + createQuerySession: (slot) => createQuerySession(slot, connections), + }, + { + senderPoolMin: 0, + senderPoolMax: 1, + queryPoolMin: 0, + queryPoolMax: 1, + }, + ); + + const lease = await client.borrowQuery(); + const query = await lease.queryViews("select 1", () => { + throw new Error("a RESULT_BATCH was not expected"); + }); + connections[0].receive(resultEnd(query.requestId)); + await expect(query.completion).resolves.toMatchObject({ totalRows: 0n }); + await lease.close(); + expect(client.metrics.queries).toMatchObject({ available: 1, leased: 0 }); + await client.close(); + }); + it("times out when every query connection is leased", async () => { const connections: FakeConnection[] = []; const client = new QwpClient( diff --git a/test/qwp/egress.test.ts b/test/qwp/egress.test.ts index a01564b..9bac146 100644 --- a/test/qwp/egress.test.ts +++ b/test/qwp/egress.test.ts @@ -22,6 +22,7 @@ import { QwpEgressQueryTimeoutError, QwpEgressSession, QwpResultBatchDecoder, + QwpResultBatchView, readQwpVarint, writeQwpVarint, } from "../../src/qwp"; @@ -283,6 +284,114 @@ describe("QWP result batch decoder", () => { ]); }); + it("exposes bounded zero-copy column views without value arrays", () => { + const message = decodeQwpEgressMessage(firstResultBatch()); + if (message.kind !== "result-batch") throw new Error("unexpected message"); + + const batch = new QwpResultBatchDecoder().decodeView(message); + expect(batch).toBeInstanceOf(QwpResultBatchView); + expect(batch.valid).toBe(true); + expect(batch.rowCount).toBe(3); + expect(batch.columns.map((column) => column.name)).toEqual([ + "id", + "name", + "sym", + "ts", + ]); + + const id = batch.column(0); + expect(id.valuesBytes()!.buffer).toBe(message.body.buffer); + expect(id.nullBitmapBytes()!.buffer).toBe(message.body.buffer); + expect(id.nonNullIndexView()).toEqual(Int32Array.of(0, -1, 1)); + expect(id.getInt(0)).toBe(7); + expect(id.isNull(1)).toBe(true); + expect(id.getInt(1)).toBe(0); + expect(id.getInt(2)).toBe(9); + + const name = batch.column(1); + expect(new TextDecoder().decode(name.getUtf8View(1)!)).toBe("bb"); + expect(new TextDecoder().decode(name.stringBytes()!)).toBe("abb"); + const symbol = batch.column(2); + expect(symbol.symbolIdView()).toEqual(Int32Array.of(0, 1, 0)); + expect(symbol.symbolDictionarySize).toBe(2); + expect(symbol.getSymbolId(1)).toBe(1); + expect(symbol.getSymbolForId(1)).toBe("beta"); + expect(symbol.getSymbol(2)).toBe("alpha"); + const timestamp = batch.column(3); + expect(timestamp.valuesBytes()).toHaveLength(24); + expect([0, 1, 2].map((row) => timestamp.getLong(row))).toEqual([ + 100n, + 200n, + 300n, + ]); + + const retained = batch.materialize(); + batch.release(); + expect(batch.valid).toBe(false); + expect(() => batch.rowCount).toThrow(/no longer valid/i); + expect(() => id.getInt(0)).toThrow(/no longer valid/i); + expect([...retained.rows()]).toEqual([ + [7, "a", "alpha", 100n], + [null, "bb", "beta", 200n], + [9, "", "alpha", 300n], + ]); + }); + + it("lazily reads every result type and detaches materialized binary", () => { + const frame = scalarResultBatch(); + const message = decodeQwpEgressMessage(frame); + if (message.kind !== "result-batch") throw new Error("unexpected message"); + const batch = new QwpResultBatchDecoder().decodeView(message); + const row = batch.columns.map((column) => column.get(0)); + + expect(row.slice(0, 8)).toEqual([true, -2, -3, "Q", -4n, 1.5, -2.5, 123n]); + expect(row[8]).toEqual({ low: 1n, high: 2n }); + expect(row[9]).toEqual({ words: [1n, 2n, 3n, 4n] }); + expect(row[10]).toEqual({ bits: 21n, precisionBits: 5 }); + expect(row[11]).toBe(456n); + expect(row[12]).toEqual({ dimensions: [1, 2], values: [1.25, 2.5] }); + expect(row[13]).toEqual({ dimensions: [2], values: [10n, 20n] }); + expect(row.slice(14, 17)).toEqual([ + { unscaled: 1234n, scale: 2 }, + { unscaled: 123456n, scale: 3 }, + { unscaled: 987654n, scale: 4 }, + ]); + expect(batch.column(8).getUuidLow(0)).toBe(1n); + expect(batch.column(8).getUuidHigh(0)).toBe(2n); + expect(batch.column(9).getLong256Word(0, 3)).toBe(4n); + expect(batch.column(10).getGeohashBits(0)).toBe(21n); + expect(batch.column(12).getArrayDimensionCount(0)).toBe(2); + expect(batch.column(14).getDecimalUnscaled(0)).toBe(1234n); + expect(batch.column(14).bytesPerValue).toBe(8); + expect(batch.column(17).getBinaryView(0)).toEqual(Uint8Array.of(1, 2, 3)); + expect(batch.column(18).getInt(0)).toBe(-1); + + const retained = batch.materialize(); + batch.column(17).getBinaryView(0)![0] = 99; + expect(retained.get(0, 17)).toEqual(Uint8Array.of(1, 2, 3)); + }); + + it("reuses batch and column view objects across decodes", () => { + const decoder = new QwpResultBatchDecoder(); + const firstMessage = decodeQwpEgressMessage(scalarResultBatch()); + if (firstMessage.kind !== "result-batch") { + throw new Error("unexpected message"); + } + const first = decoder.decodeView(firstMessage); + const firstColumn = first.column(0); + first.release(); + decoder.resetQuerySchema(); + + const secondMessage = decodeQwpEgressMessage(scalarResultBatch()); + if (secondMessage.kind !== "result-batch") { + throw new Error("unexpected message"); + } + const second = decoder.decodeView(secondMessage); + expect(second).toBe(first); + expect(second.column(0)).toBe(firstColumn); + expect(second.column(0).getBoolean(0)).toBe(true); + }); + it("rejects a continuation batch before a schema-bearing batch", () => { const bytes = firstResultBatch(); // RESULT_BATCH sequence is the byte immediately after kind + request ID. @@ -332,6 +441,17 @@ describe("QWP result batch decoder", () => { expect(batch.get(99, 0)).toBe(42); }); + it("exposes a reusable view over a Zstd RESULT_BATCH", () => { + const message = decodeQwpEgressMessage(compressedIntResultBatch(7n)); + if (message.kind !== "result-batch") throw new Error("unexpected message"); + + const batch = new QwpResultBatchDecoder().decodeView(message); + expect(batch.requestId).toBe(7n); + expect(batch.rowCount).toBe(100); + expect(batch.column(0).valuesBytes()).toHaveLength(400); + expect(batch.column(0).getInt(99)).toBe(42); + }); + it("requires a bounded, single Zstd frame", () => { const decodeBody = (body: Uint8Array) => { const bytes = compressedIntResultBatch(); @@ -541,6 +661,85 @@ describe("QwpEgressSession", () => { await session.close(); }); + it("bounds reusable views to an awaited callback and then replenishes credit", async () => { + const connection = new FakeConnection(); + const session = new QwpEgressSession(connection); + connection.receive(serverInfo()); + + let enterHandler!: () => void; + const handlerEntered = new Promise((resolve) => { + enterHandler = resolve; + }); + let releaseHandler!: () => void; + const handlerReleased = new Promise((resolve) => { + releaseHandler = resolve; + }); + let delivered: QwpResultBatchView | undefined; + let retainedRows: readonly (readonly unknown[])[] = []; + const query = await session.queryViews( + "select * from x", + async (batch, control) => { + delivered = batch; + expect(control.requestId).toBe(batch.requestId); + expect(batch.valid).toBe(true); + expect(batch.column(0).getInt(2)).toBe(9); + retainedRows = [...batch.materialize().rows()]; + enterHandler(); + await handlerReleased; + expect(batch.valid).toBe(true); + }, + { initialCredit: 64 }, + ); + const resultFrame = firstResultBatch(query.requestId); + connection.receive(resultFrame); + + await handlerEntered; + expect(connection.sent).toHaveLength(1); + releaseHandler(); + await vi.waitFor(() => expect(connection.sent).toHaveLength(2)); + expect(delivered!.valid).toBe(false); + expect(() => delivered!.column(0)).toThrow(/no longer valid/i); + expect(retainedRows[2]).toEqual([9, "", "alpha", 300n]); + + const credit = new QwpByteReader(connection.sent[1]); + expect(credit.readUint8()).toBe(QWP_EGRESS_MESSAGE.CREDIT); + expect(credit.readBigUint64()).toBe(query.requestId); + expect(readQwpVarint(credit)).toBe(BigInt(resultFrame.byteLength)); + connection.receive(resultEnd(query.requestId)); + await expect(query.completion).resolves.toMatchObject({ totalRows: 3n }); + await session.close(); + }); + + it("cancels and drains when a result-view callback fails", async () => { + const connection = new FakeConnection(); + const session = new QwpEgressSession(connection); + connection.receive(serverInfo()); + const handlerError = new Error("consumer failed"); + let delivered: QwpResultBatchView | undefined; + const query = await session.queryViews("select * from x", (batch) => { + delivered = batch; + throw handlerError; + }); + + connection.receive(firstResultBatch(query.requestId)); + await expect(query.completion).rejects.toBe(handlerError); + await vi.waitFor(() => expect(connection.sent).toHaveLength(2)); + expect(delivered!.valid).toBe(false); + const cancel = new QwpByteReader(connection.sent[1]); + expect(cancel.readUint8()).toBe(QWP_EGRESS_MESSAGE.CANCEL); + expect(cancel.readBigUint64()).toBe(query.requestId); + + connection.receive( + queryError(query.requestId, "cancelled by client", QWP_STATUS.CANCELLED), + ); + await Promise.resolve(); + await Promise.resolve(); + const next = await session.query("select 2"); + connection.receive(resultEnd(next.requestId, 0n)); + await next.completion; + await session.close(); + }); + it("uses bounded credit by default and allows a session-level override", async () => { const connection = new FakeConnection(); const session = new QwpEgressSession(connection); diff --git a/test/qwp/public-api-contract.ts b/test/qwp/public-api-contract.ts index 03bea22..6f861c0 100644 --- a/test/qwp/public-api-contract.ts +++ b/test/qwp/public-api-contract.ts @@ -37,8 +37,12 @@ import type { QwpEgressQueryOptions, QwpEgressSession, QwpEgressSessionOptions, + QwpEgressViewQuery, QwpIngressSession, QwpIngressSessionOptions, + QwpQueryLease, + QwpResultBatchView, + QwpResultBatchViewHandler, QwpSender, QwpSenderOptions, } from "../../src/qwp"; @@ -188,6 +192,30 @@ function rootSenderSequenceContract(sender: Sender): void { void acknowledgedWatermark; } +function queryViewContract( + session: QwpEgressSession, + lease: QwpQueryLease, +): void { + const handler: QwpResultBatchViewHandler = (batch, query) => { + const typedBatch: QwpResultBatchView = batch; + const requestId: bigint = query.requestId; + const rawValues: Uint8Array | undefined = batch.column(0).valuesBytes(); + void typedBatch; + void requestId; + void rawValues; + }; + const direct: Promise = session.queryViews( + "select * from trades", + handler, + ); + const pooled: Promise = lease.queryViews( + "select * from trades", + handler, + ); + void direct; + void pooled; +} + const rootExtraOptionsContract: ExtraOptions = { qwp: qwpExtraOptionsContract, }; @@ -212,4 +240,5 @@ void nodeEgressOptionsContract; void rootExtraOptionsContract; void senderSequenceContract; void rootSenderSequenceContract; +void queryViewContract; void Sender; diff --git a/test/qwp/public-api.test.ts b/test/qwp/public-api.test.ts index 6758e94..b9ab725 100644 --- a/test/qwp/public-api.test.ts +++ b/test/qwp/public-api.test.ts @@ -33,6 +33,8 @@ const sharedRuntimeContract = [ "QwpRoleMismatchError", "QwpReplayRejectedError", "QwpResultBatch", + "QwpResultBatchView", + "QwpResultColumnView", "QwpQueryLease", "QwpSendTimeoutError", "QwpSender", From 9cfc95dc9aefb49e5979c46ce45d0fe40ed78335 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 18 Aug 2026 15:32:30 +0100 Subject: [PATCH 043/265] fix(qwp): reconcile durable symbol dictionary on publish failure --- src/qwp/ingress-session.ts | 24 +++++++++++++++++++++++- test/qwp/reconnect.test.ts | 7 ++++--- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/src/qwp/ingress-session.ts b/src/qwp/ingress-session.ts index 5b18bc2..78d916c 100644 --- a/src/qwp/ingress-session.ts +++ b/src/qwp/ingress-session.ts @@ -662,13 +662,35 @@ export class QwpIngressSession { this.deltaSymbolsPublished = true; await this.publishFrame(frame); } catch (error) { - this.symbolDictionary.truncate(previousSize); + this.restoreDeltaStateAfterPublishFailure(previousSize); this.publishedMaxSymbolId = previousPublishedMaxSymbolId; this.deltaSymbolsPublished = previousDeltaSymbolsPublished; throw error; } } + /** + * Restores the dictionary ID allocator after a failed asynchronous publish. + * + * A replay transport persists new dictionary entries before it appends the + * frame that uses them. If that frame append fails, the persisted dictionary + * is authoritative even though the frame-publication watermark must roll + * back. Keeping those IDs prevents a changed retry from assigning a + * different symbol to an already durable ID. The unchanged published + * watermark makes the retry include the durable-but-unpublished prefix. + */ + private restoreDeltaStateAfterPublishFailure(previousSize: number): void { + if (!(this.connection instanceof QwpReconnectingIngressConnection)) { + this.symbolDictionary.truncate(previousSize); + return; + } + const recovered = this.connection.ingressSymbolDictionary; + this.symbolDictionary.reset(); + for (const entry of recovered) { + this.symbolDictionary.addRecovered(entry); + } + } + /** * Publishes one pre-encoded frame without allocating an ACK waiter. * Applications can observe later acceptance through progress callbacks. diff --git a/test/qwp/reconnect.test.ts b/test/qwp/reconnect.test.ts index 9ddaba7..cb4793e 100644 --- a/test/qwp/reconnect.test.ts +++ b/test/qwp/reconnect.test.ts @@ -472,7 +472,7 @@ describe("QWP ingress reconnect and replay", () => { await session.close(); }); - it("retries a delta publication after journal backpressure", async () => { + it("preserves durable dictionary IDs after frame journal backpressure", async () => { const replayStore = new FailOnceDictionaryReplayStore(); const session = await QwpIngressSession.connect( async () => { @@ -501,12 +501,13 @@ describe("QWP ingress reconnect and replay", () => { expect(replayStore.records.size).toBe(0); await expect( - session.publishTablesDelta([symbolTable("ETH-USD")]), + session.publishTablesDelta([symbolTable("BTC-USD")]), ).resolves.toBeUndefined(); expect(replayStore.appendAttempts).toBe(2); + expect(replayStore.symbols).toEqual(["ETH-USD", "BTC-USD"]); expect( decodeQwpIngressSymbolDictionaryDelta(replayStore.records.get(1n)!), - ).toEqual({ startId: 0, entries: ["ETH-USD"] }); + ).toEqual({ startId: 0, entries: ["ETH-USD", "BTC-USD"] }); await session.close(); }); From 5a8c71b3bf6003fbbe979a4f7bf48592d3a4f203 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 18 Aug 2026 15:46:24 +0100 Subject: [PATCH 044/265] fix(qwp): fall back when symbol persistence fails --- src/qwp/ingress-session.ts | 21 ++++- src/qwp/internal/egress-routing.ts | 7 +- src/qwp/internal/failover.ts | 7 +- .../reconnecting-ingress-connection.ts | 25 +++++- src/qwp/transport.ts | 17 ++++ test/qwp/reconnect.test.ts | 83 +++++++++++++++++++ 6 files changed, 154 insertions(+), 6 deletions(-) diff --git a/src/qwp/ingress-session.ts b/src/qwp/ingress-session.ts index 78d916c..eab2c46 100644 --- a/src/qwp/ingress-session.ts +++ b/src/qwp/ingress-session.ts @@ -17,6 +17,7 @@ import { QwpHandshakeMetadata, QwpIngressReplayStore, QwpReconnectOptions, + QwpReplayDictionaryPersistenceError, } from "./transport"; import { QwpReconnectingIngressConnection } from "./internal/reconnecting-ingress-connection"; @@ -558,6 +559,8 @@ export class QwpIngressSession { /** * Sends tables using the session's connection-scoped symbol dictionary. * String symbol values are assigned stable IDs automatically. + * If a replay dictionary append fails, that call rejects with + * QwpReplayDictionaryPersistenceError; retrying uses full inline symbols. */ sendTablesDelta( tables: readonly QwpTableBuffer[], @@ -567,6 +570,9 @@ export class QwpIngressSession { > = {}, ): Promise { this.throwIfUnavailable(); + if (this.connection.ingressDeltaSymbolDictionaryEnabled === false) { + return this.sendTables(tables, encodeOptions); + } const previousSize = this.symbolDictionary.size; const previousPublishedMaxSymbolId = this.publishedMaxSymbolId; const previousDeltaSymbolsPublished = this.deltaSymbolsPublished; @@ -623,7 +629,11 @@ export class QwpIngressSession { } } - /** Publishes tables with the automatic connection-scoped symbol dictionary. */ + /** + * Publishes tables with the automatic connection-scoped symbol dictionary. + * After a replay dictionary persistence error, retries use full inline + * symbols and no longer depend on the failed sidecar. + */ async publishTablesDelta( tables: readonly QwpTableBuffer[], encodeOptions: Pick< @@ -632,6 +642,9 @@ export class QwpIngressSession { > = {}, ): Promise { this.throwIfUnavailable(); + if (this.connection.ingressDeltaSymbolDictionaryEnabled === false) { + return this.publishTables(tables, encodeOptions); + } const previousSize = this.symbolDictionary.size; const previousPublishedMaxSymbolId = this.publishedMaxSymbolId; const previousDeltaSymbolsPublished = this.deltaSymbolsPublished; @@ -754,7 +767,11 @@ export class QwpIngressSession { await this.connection.send(frame); }); this.sendTail = sending.catch((error: unknown) => { - this.fail(error); + if (error instanceof QwpReplayDictionaryPersistenceError) { + this.recordError(error, false); + } else { + this.fail(error); + } }); // Publish the callback only after sendTail owns this frame so a callback // that queues another frame cannot reorder it ahead of this sequence. diff --git a/src/qwp/internal/egress-routing.ts b/src/qwp/internal/egress-routing.ts index a8846ca..69b8116 100644 --- a/src/qwp/internal/egress-routing.ts +++ b/src/qwp/internal/egress-routing.ts @@ -120,7 +120,12 @@ function prependMessage( closed: connection.closed, handshake: { ...connection.handshake, ...topology }, endpoint: connection.endpoint, - ingressSymbolDictionary: connection.ingressSymbolDictionary, + get ingressSymbolDictionary() { + return connection.ingressSymbolDictionary; + }, + get ingressDeltaSymbolDictionaryEnabled() { + return connection.ingressDeltaSymbolDictionaryEnabled; + }, send: (payload) => connection.send(payload), close: (code, reason) => connection.close(code, reason), }; diff --git a/src/qwp/internal/failover.ts b/src/qwp/internal/failover.ts index 86c9c48..c1c2ed3 100644 --- a/src/qwp/internal/failover.ts +++ b/src/qwp/internal/failover.ts @@ -231,7 +231,12 @@ function observeConnectionHealth( closed: connection.closed, handshake: connection.handshake, endpoint: connection.endpoint, - ingressSymbolDictionary: connection.ingressSymbolDictionary, + get ingressSymbolDictionary() { + return connection.ingressSymbolDictionary; + }, + get ingressDeltaSymbolDictionaryEnabled() { + return connection.ingressDeltaSymbolDictionaryEnabled; + }, send: async (payload) => { try { await connection.send(payload); diff --git a/src/qwp/internal/reconnecting-ingress-connection.ts b/src/qwp/internal/reconnecting-ingress-connection.ts index 1e16e4b..2df6dff 100644 --- a/src/qwp/internal/reconnecting-ingress-connection.ts +++ b/src/qwp/internal/reconnecting-ingress-connection.ts @@ -26,6 +26,7 @@ import { QwpReconnectExhaustedError, QwpReconnectOptions, QwpReplayDictionaryError, + QwpReplayDictionaryPersistenceError, QwpReplayRejectedError, QwpSendClosedError, QwpUpgradeError, @@ -172,6 +173,7 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { private totalFailovers = 0; private totalReconnectErrors = 0; private totalServerNacks = 0; + private deltaSymbolDictionaryEnabled: boolean; readonly messages: AsyncIterable = this.messagesQueue; readonly closed: Promise; ping?: () => Promise; @@ -188,6 +190,9 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { ) { this.store = store; this.symbolDictionary = [...symbolDictionary]; + this.deltaSymbolDictionaryEnabled = + store.loadSymbolDictionary !== undefined && + store.appendSymbolDictionary !== undefined; this.recoveredDiscardTail = recoveredDiscardTail; this.localMaxBatchSizeBytes = localMaxBatchSizeBytes; this.maxAttempts = reconnectOptions.maxAttempts ?? 3; @@ -292,6 +297,10 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { return this.symbolDictionary.slice(); } + get ingressDeltaSymbolDictionaryEnabled(): boolean { + return this.deltaSymbolDictionaryEnabled; + } + getIngressMetrics(): QwpIngressTransportMetrics { let pendingReplayBytes = 0; for (const frame of this.frames.values()) { @@ -327,7 +336,14 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { const publishing = this.sendTail.then(async () => { this.throwIfUnavailable(); const delta = readSymbolDictionaryDelta(frame.payload); - if (delta) await this.persistSymbolDictionaryDelta(delta); + if (delta) { + if (!this.deltaSymbolDictionaryEnabled) { + throw new QwpReplayDictionaryError( + "QWP delta symbol dictionaries are disabled because replay dictionary persistence is unavailable; encode symbols with full inline dictionaries", + ); + } + await this.persistSymbolDictionaryDelta(delta); + } await this.store.append(frame); this.frames.set(frame.frameSequence, frame); if (this.backgroundStoreAndForward) { @@ -955,7 +971,12 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { const newEntries = delta.entries.slice(firstNewEntry); if (newEntries.length === 0) return; const startId = this.symbolDictionary.length; - await this.store.appendSymbolDictionary(startId, newEntries); + try { + await this.store.appendSymbolDictionary(startId, newEntries); + } catch (error) { + this.deltaSymbolDictionaryEnabled = false; + throw new QwpReplayDictionaryPersistenceError(error); + } this.symbolDictionary.push(...newEntries); } diff --git a/src/qwp/transport.ts b/src/qwp/transport.ts index a1debed..d68c70c 100644 --- a/src/qwp/transport.ts +++ b/src/qwp/transport.ts @@ -108,6 +108,21 @@ export class QwpReplayDictionaryError extends Error { } } +/** + * A replay dictionary sidecar rejected an append before its delta frame was + * published. The reconnecting transport has permanently switched to full, + * self-contained symbol encoding; retrying the logical batch is safe. + */ +export class QwpReplayDictionaryPersistenceError extends QwpReplayDictionaryError { + constructor(cause: unknown) { + super( + "failed to persist the QWP symbol dictionary before publication; delta dictionaries are disabled for this connection -- retry the batch", + cause, + ); + this.name = "QwpReplayDictionaryPersistenceError"; + } +} + /** An active egress operation cannot be safely replayed without an explicit reset hook. */ export class QwpEgressReplayRequiredError extends Error { constructor(readonly requestId?: bigint) { @@ -372,6 +387,8 @@ export interface QwpBinaryConnection { readonly handshake: QwpHandshakeMetadata; /** @internal Recovered ingress dictionary supplied by replay connections. */ readonly ingressSymbolDictionary?: readonly string[]; + /** @internal False after replay dictionary persistence becomes unavailable. */ + readonly ingressDeltaSymbolDictionaryEnabled?: boolean; /** Endpoint backing this connection, when supplied by its adapter. */ readonly endpoint?: string | URL; diff --git a/test/qwp/reconnect.test.ts b/test/qwp/reconnect.test.ts index cb4793e..92376ec 100644 --- a/test/qwp/reconnect.test.ts +++ b/test/qwp/reconnect.test.ts @@ -43,6 +43,7 @@ import { QwpReconnectEvent, QwpReconnectExhaustedError, QwpReplayRejectedError, + QwpReplayDictionaryPersistenceError, QwpUpgradeError, encodeQwpFrame, encodeQwpDurableAckPollFrame, @@ -236,6 +237,19 @@ class FailOnceDictionaryReplayStore extends TrackingReplayStore { } } +class FailingDictionaryPersistenceReplayStore extends TrackingReplayStore { + appendSymbolDictionaryCalls = 0; + + async loadSymbolDictionary(): Promise { + return []; + } + + async appendSymbolDictionary(): Promise { + this.appendSymbolDictionaryCalls++; + throw new Error("symbol dictionary disk is full"); + } +} + describe("QWP endpoint failover", () => { it("keeps a healthy endpoint sticky until a mid-stream failure", async () => { const attempts: string[] = []; @@ -511,6 +525,75 @@ describe("QWP ingress reconnect and replay", () => { await session.close(); }); + it("falls back to full symbols after dictionary persistence fails", async () => { + const connection = new FakeConnection("primary"); + const replayStore = new FailingDictionaryPersistenceReplayStore(); + const session = await QwpIngressSession.connect(async () => connection, { + ackTimeoutMs: 1_000, + reconnect: { maxAttempts: 1 }, + replayStore, + }); + + await expect( + session.publishTablesDelta([symbolTable("ETH-USD")]), + ).rejects.toBeInstanceOf(QwpReplayDictionaryPersistenceError); + expect(replayStore.appendSymbolDictionaryCalls).toBe(1); + expect(replayStore.records.size).toBe(0); + expect(connection.sent).toEqual([]); + + await expect( + session.publishTablesDelta([symbolTable("BTC-USD")]), + ).resolves.toBeUndefined(); + expect(connection.sent).toHaveLength(1); + expect(decodeQwpIngressSymbolDictionaryDelta(connection.sent[0])).toBe( + undefined, + ); + expect(replayStore.appendSymbolDictionaryCalls).toBe(1); + expect(replayStore.records.size).toBe(1); + await session.close(); + }); + + it("keeps an ACK-waiting session usable after dictionary persistence fails", async () => { + const connection = new FakeConnection("primary"); + const replayStore = new FailingDictionaryPersistenceReplayStore(); + const session = await QwpIngressSession.connect(async () => connection, { + ackTimeoutMs: 1_000, + reconnect: { maxAttempts: 1 }, + replayStore, + }); + + await expect( + session.sendTablesDelta([symbolTable("ETH-USD")]), + ).rejects.toBeInstanceOf(QwpReplayDictionaryPersistenceError); + const retried = session.sendTablesDelta([symbolTable("BTC-USD")]); + await vi.waitFor(() => expect(connection.sent).toHaveLength(1)); + expect(decodeQwpIngressSymbolDictionaryDelta(connection.sent[0])).toBe( + undefined, + ); + connection.receive(ingressResponse(QWP_STATUS.OK, 0n)); + await expect(retried).resolves.toMatchObject({ sequence: 1n }); + await session.close(); + }); + + it("uses full symbols when a replay store has no dictionary sidecar", async () => { + const connection = new FakeConnection("primary"); + const replayStore = new TrackingReplayStore(); + const session = await QwpIngressSession.connect(async () => connection, { + reconnect: { maxAttempts: 1 }, + replayStore, + }); + + await expect( + session.publishTablesDelta([symbolTable("ETH-USD")]), + ).resolves.toBeUndefined(); + expect(connection.sent).toHaveLength(1); + expect(decodeQwpIngressSymbolDictionaryDelta(connection.sent[0])).toBe( + undefined, + ); + expect(replayStore.records.size).toBe(1); + await session.close(); + }); + it("replays only unacknowledged browser frames and translates wire ACKs", async () => { const first = new FakeConnection("primary"); const second = new FakeConnection("secondary"); From 85e9e665d9d3394a3583a5b93061681acadcea55 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 18 Aug 2026 15:51:49 +0100 Subject: [PATCH 045/265] fix(qwp): preserve borrowed resources during client close --- QWP.md | 9 ++-- src/qwp/client.ts | 101 +++++++++++++++++++++++++++++++++------- test/qwp/client.test.ts | 79 +++++++++++++++++++++++++++++++ 3 files changed, 168 insertions(+), 21 deletions(-) diff --git a/QWP.md b/QWP.md index 75d02c3..e4cabac 100644 --- a/QWP.md +++ b/QWP.md @@ -642,9 +642,12 @@ exhaustion raises `QwpPoolAcquireTimeoutError`. Query handles are single-flight, but separate borrowed handles run concurrently. Returning a handle with an active query sends `CANCEL` and waits for the session's bounded cancellation drain; a connection that cannot drain is closed instead of being handed to another borrower. -Call `QwpClient.close()` only after returning application-owned leases; shutdown -rejects queued borrowers and closes every pooled connection, including one still -leased by a caller. +Prefer returning application-owned leases before calling `QwpClient.close()`. +If shutdown races a borrower, it rejects queued borrowers, closes idle connections, +and waits up to `acquireTimeoutMs` (capped at five seconds) for active leases to +return. It never closes a connection underneath its borrower. A lease returned +during or after shutdown is closed instead of re-entering the pool; a lease that is +never returned retains its connection. Pooled sender `close()` flushes completed rows, discards an unfinished row with a warning, and resets staging before reuse. With Node store-and-forward enabled, the diff --git a/src/qwp/client.ts b/src/qwp/client.ts index 3669b23..36c2f89 100644 --- a/src/qwp/client.ts +++ b/src/qwp/client.ts @@ -16,6 +16,7 @@ const DEFAULT_POOL_MIN = 1; const DEFAULT_POOL_MAX = 4; const DEFAULT_ACQUIRE_TIMEOUT_MS = 5_000; const MAX_CLOSE_CREATION_WAIT_MS = 5_000; +const MAX_CLOSE_LEASE_WAIT_MS = 5_000; export interface QwpClientPoolOptions { /** Warm ingress connections created by connect(). Defaults to 1. */ @@ -26,7 +27,10 @@ export interface QwpClientPoolOptions { queryPoolMin?: number; /** Maximum concurrently borrowed query connections. Defaults to 4. */ queryPoolMax?: number; - /** Maximum wait for a returned pool slot. Defaults to 5 seconds. */ + /** + * Maximum wait for a returned pool slot and for leases during shutdown. + * The shutdown wait is capped at 5 seconds. Defaults to 5 seconds. + */ acquireTimeoutMs?: number; } @@ -116,13 +120,20 @@ interface PoolWaiter { readonly timer?: ReturnType; } +interface PoolCloseWaiter { + readonly resolve: () => void; + readonly timer: ReturnType; +} + class QwpResourcePool { private readonly all = new Map>(); private readonly available: PoolEntry[] = []; private readonly creatingSlots = new Set(); private readonly creationOperations = new Set>(); private readonly waiters = new Set(); + private readonly closeWaiters = new Set(); private closePromise?: Promise; + private pendingLeaseTeardowns = 0; private closed = false; constructor( @@ -195,12 +206,19 @@ class QwpResourcePool { entry.leased = false; if (this.closed || !reusable || this.all.get(entry.slot) !== entry) { if (this.all.get(entry.slot) === entry) this.all.delete(entry.slot); - await this.destroy(entry); - this.wakeWaiters(); + this.pendingLeaseTeardowns++; + try { + await this.destroy(entry); + } finally { + this.pendingLeaseTeardowns--; + this.wakeWaiters(); + this.wakeCloseWaiters(); + } return; } this.available.push(entry); this.wakeWaiters(); + this.wakeCloseWaiters(); } close(): Promise { @@ -216,24 +234,33 @@ class QwpResourcePool { waiter.reject(new QwpClientClosedError()); } this.waiters.clear(); - const entries = Array.from(this.all.values()); - this.all.clear(); - this.available.length = 0; + // Only idle entries belong to the closing thread. Borrowed entries stay in + // `all` so their exclusive owners can keep using them and retire them from + // release(), even after this bounded close has returned. + const entries = this.available.splice(0); + for (const entry of entries) this.all.delete(entry.slot); await Promise.all(entries.map((entry) => this.destroy(entry))); const creations = Array.from(this.creationOperations); - if (creations.length === 0) return; - const waitMs = Math.min(this.acquireTimeoutMs, MAX_CLOSE_CREATION_WAIT_MS); - let timer: ReturnType | undefined; - try { - await Promise.race([ - Promise.allSettled(creations), - new Promise((resolve) => { - timer = setTimeout(resolve, waitMs); - }), - ]); - } finally { - if (timer) clearTimeout(timer); + if (creations.length > 0) { + const waitMs = Math.min( + this.acquireTimeoutMs, + MAX_CLOSE_CREATION_WAIT_MS, + ); + let timer: ReturnType | undefined; + try { + await Promise.race([ + Promise.allSettled(creations), + new Promise((resolve) => { + timer = setTimeout(resolve, waitMs); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } } + await this.waitForLeases( + Math.min(this.acquireTimeoutMs, MAX_CLOSE_LEASE_WAIT_MS), + ); } private reserveSlot(): number | undefined { @@ -298,6 +325,40 @@ class QwpResourcePool { } } + private wakeCloseWaiters(): void { + for (const waiter of this.closeWaiters) { + this.closeWaiters.delete(waiter); + clearTimeout(waiter.timer); + waiter.resolve(); + } + } + + private outstandingLeases(): number { + let count = this.pendingLeaseTeardowns; + for (const entry of this.all.values()) { + if (entry.leased) count++; + } + return count; + } + + private async waitForLeases(timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (this.outstandingLeases() > 0) { + const remaining = deadline - Date.now(); + if (remaining <= 0) return; + await new Promise((resolve) => { + const waiter: PoolCloseWaiter = { + resolve, + timer: setTimeout(() => { + this.closeWaiters.delete(waiter); + resolve(); + }, remaining), + }; + this.closeWaiters.add(waiter); + }); + } + } + private destroy(entry: PoolEntry): Promise { if (!entry.destroyPromise) { entry.destroyPromise = this.destroyResource(entry.value).catch( @@ -460,6 +521,10 @@ export class QwpClient { }); } + /** + * Rejects new borrows, closes idle resources, and waits boundedly for active + * leases. A lease that outlives the wait remains usable and owns its teardown. + */ close(): Promise { if (!this.closePromise) this.closePromise = this.closeNow(); return this.closePromise; diff --git a/test/qwp/client.test.ts b/test/qwp/client.test.ts index ef7fd4c..d60e8fc 100644 --- a/test/qwp/client.test.ts +++ b/test/qwp/client.test.ts @@ -60,6 +60,7 @@ class FakeConnection implements QwpBinaryConnection { readonly handshake: QwpHandshakeMetadata = { qwpVersion: 1 }; readonly messages: AsyncIterable; readonly sent: Uint8Array[] = []; + closeCount = 0; readonly closed: Promise; private readonly incoming = new QwpAsyncQueue(); private readonly resolveClosed: (info: QwpConnectionCloseInfo) => void; @@ -80,6 +81,7 @@ class FakeConnection implements QwpBinaryConnection { } close(code = 1000, reason = ""): Promise { + this.closeCount++; if (!this.closedSettled) { this.closedSettled = true; this.incoming.end(); @@ -210,6 +212,47 @@ describe("QWP pooled client", () => { expect(senderSessions[0].closes).toBe(1); }); + it("waits for a borrowed sender without closing it underneath its owner", async () => { + const senderSessions: FakeSenderSession[] = []; + const client = new QwpClient( + { + createSender: async () => { + const session = new FakeSenderSession(); + senderSessions.push(session); + const sender = new QwpSender(async () => session, { + autoFlush: false, + }); + await sender.connect(); + return sender; + }, + createQuerySession: async () => { + throw new Error("query factory should not run"); + }, + }, + { + senderPoolMin: 0, + senderPoolMax: 1, + queryPoolMin: 0, + queryPoolMax: 1, + acquireTimeoutMs: 500, + }, + ); + const sender = await client.borrowSender(); + let closeSettled = false; + const closing = client.close().then(() => { + closeSettled = true; + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(closeSettled).toBe(false); + expect(senderSessions[0].closes).toBe(0); + await sender.table("trades").symbol("symbol", "ETH-USD").atNow(); + await sender.close(); + await closing; + expect(senderSessions[0].flushes).toBe(1); + expect(senderSessions[0].closes).toBe(1); + }); + it("runs independently borrowed query connections concurrently", async () => { const connections: FakeConnection[] = []; let queryCreations = 0; @@ -262,6 +305,42 @@ describe("QWP pooled client", () => { expect(connections).toHaveLength(2); }); + it("leaves a timed-out query lease alive and closes it on late return", async () => { + const connections: FakeConnection[] = []; + const client = new QwpClient( + { + createSender: async () => { + throw new Error("sender factory should not run"); + }, + createQuerySession: (slot) => createQuerySession(slot, connections), + }, + { + senderPoolMin: 0, + senderPoolMax: 1, + queryPoolMin: 0, + queryPoolMax: 1, + acquireTimeoutMs: 10, + }, + ); + const lease = await client.borrowQuery(); + + await client.close(); + expect(connections[0].closeCount).toBe(0); + expect(lease.handshake).toMatchObject({ qwpVersion: 1 }); + const query = await lease.query("select 1"); + connections[0].receive(resultEnd(query.requestId)); + await expect(query.completion).resolves.toMatchObject({ totalRows: 0n }); + expect(client.metrics).toMatchObject({ + closing: true, + closed: true, + queries: { total: 1, leased: 1 }, + }); + + await lease.close(); + expect(connections[0].closeCount).toBe(1); + expect(client.metrics.queries).toMatchObject({ total: 0, leased: 0 }); + }); + it("runs reusable view queries through a pooled query lease", async () => { const connections: FakeConnection[] = []; const client = new QwpClient( From 6e0f83b9d42852386a3e2b944c6903d8bf0e8389 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 18 Aug 2026 16:37:33 +0100 Subject: [PATCH 046/265] feat(qwp): negotiate browser caps and compression --- QWP.md | 23 +++-- src/qwp/browser.ts | 212 +++++++++++++++++++++++++++++++++++--- src/qwp/core/constants.ts | 7 ++ src/qwp/core/egress.ts | 12 +++ src/qwp/core/ingress.ts | 19 ++++ src/qwp/egress-session.ts | 20 +++- test/qwp/browser.e2e.ts | 172 ++++++++++++++++++++++++++++++- test/qwp/core.test.ts | 20 +++- test/qwp/session.test.ts | 102 +++++++++++++++++- 9 files changed, 559 insertions(+), 28 deletions(-) diff --git a/QWP.md b/QWP.md index e4cabac..b4c7540 100644 --- a/QWP.md +++ b/QWP.md @@ -356,6 +356,13 @@ capability unless `requestDurableAck` was set explicitly. The connection fails w `QwpDurableAckUnavailableError` when the server does not confirm it. Browser durable tracking is in memory only. Persistent store-and-forward is intentionally Node-only. +Browser ingress adds `qwp_browser_handshake=v1` to the WebSocket URL. Compatible +servers send a small `SERVER_INFO` message immediately after the upgrade, and the +sender uses its exact ingress payload cap for automatic splitting. Older servers +ignore the query parameter; after a bounded 250 ms negotiation window the client +continues in unknown-cap mode. Set `ingressNegotiationTimeoutMs` to tune that window, +or keep using `maxBatchSizeBytes` as a local compatibility limit. + ### Reconnect, failover, and roles The preferred URL and `failoverUrls` form one endpoint set. Endpoints are ranked by @@ -541,12 +548,14 @@ server does not terminate the query within the bound, the client fails with `QwpEgressQueryCancelTimeoutError` and closes the unusable connection instead of leaving the session permanently occupied. -Node.js can request Zstd with `compression: "zstd"` or `"auto"` and a level from 1 -through 22. Raw remains the compatibility default. Check -`session.negotiatedCompression` after the handshake. The decoder handles raw and -Zstd batches in both runtimes, but browsers cannot advertise -`X-QWP-Accept-Encoding`; a same-origin proxy must add that header to opt a browser -into compressed responses. +Node.js and browsers can request Zstd with `compression: "zstd"` or `"auto"` and a +level from 1 through 22. Raw remains the compatibility default. Node uses +`X-QWP-Accept-Encoding`; browsers send the same preference in the URL's +`qwp_accept_encoding` parameter. Compatible servers report the effective codec and +operator-forced level in the existing egress `SERVER_INFO` message. Check +`session.negotiatedCompression` after the handshake. Older servers ignore the query +parameter and safely remain raw. The decoder handles raw and Zstd batches in both +runtimes. Egress reconnect never silently resumes a partially consumed result. Configure `onReplayReset` to opt into at-least-once query re-execution, discard any rows from @@ -567,6 +576,8 @@ const session = await connectQwpBrowserEgress({ failoverUrls: ["wss://replica-2.example/read/v1"], target: "replica", zone: "eu-west-1a", + compression: "zstd", + compressionLevel: 3, sessionBootstrap: { authentication: { type: "bearer", token: oidcOrRestAccessToken }, }, diff --git a/src/qwp/browser.ts b/src/qwp/browser.ts index 3168663..6e40fe5 100644 --- a/src/qwp/browser.ts +++ b/src/qwp/browser.ts @@ -10,7 +10,10 @@ import { createQwpFailoverConnectionFactory } from "./internal/failover"; import { createQwpEgressFailoverConnectionFactory } from "./internal/egress-routing"; import { addQwpDurableAckWebSocketProtocol, + decodeQwpIngressServerInfo, + encodeQwpAcceptEncoding, isQwpDurableAckWebSocketProtocol, + QwpEgressCompression, QWP_VERSION, } from "./core"; import { @@ -264,6 +267,11 @@ export interface QwpBrowserWebSocketOptions extends QwpWebSocketConnectOptions { * subprotocol negotiation. */ requestDurableAck?: boolean; + /** + * Time allowed for the optional ingress SERVER_INFO message. Defaults to + * 250ms; zero disables the initial wait while retaining late negotiation. + */ + ingressNegotiationTimeoutMs?: number; /** * Authenticates over REST before every WebSocket connection attempt so the * browser can attach QuestDB's HttpOnly session cookies to the upgrade. @@ -279,7 +287,15 @@ export interface QwpBrowserWebSocketOptions extends QwpWebSocketConnectOptions { /** Browser WebSocket options plus protocol-level egress topology routing. */ export interface QwpBrowserEgressOptions extends QwpBrowserWebSocketOptions, - QwpEgressRoutingOptions {} + QwpEgressRoutingOptions { + /** + * Requests Zstd-compressed result batches through browser-visible URL + * negotiation. Defaults to raw for compatibility. + */ + compression?: QwpEgressCompression; + /** Zstd level hint. Must be between 1 and 22. */ + compressionLevel?: number; +} /** Browser configuration for a combined pooled QWP ingress/egress client. */ export interface QwpBrowserClientOptions { @@ -303,7 +319,11 @@ export interface QwpBrowserClientOptions { export function connectQwpBrowserWebSocket( options: QwpBrowserWebSocketOptions, ): Promise { - return createQwpBrowserConnectionFactory(options)(); + return createQwpFailoverConnectionFactory( + options.url, + options.failoverUrls, + (endpoint) => connectQwpBrowserRawEndpoint(options, endpoint), + )(); } /** Creates a stateful browser endpoint walker suitable for session reconnects. */ @@ -313,13 +333,18 @@ export function createQwpBrowserConnectionFactory( return createQwpFailoverConnectionFactory( options.url, options.failoverUrls, - (endpoint) => connectQwpBrowserEndpoint(options, endpoint), + (endpoint) => connectQwpBrowserIngressEndpoint(options, endpoint), ); } async function connectQwpBrowserEndpoint( options: QwpBrowserWebSocketOptions, endpoint: string | URL, + requestEndpoint: string | URL, + protocols: string | string[] | undefined, + completeHandshake: ( + selectedProtocol: string | undefined, + ) => QwpBinaryConnection["handshake"], ): Promise { validateQwpWebSocketTimeouts(options); if (options.sessionBootstrap) { @@ -344,19 +369,45 @@ async function connectQwpBrowserEndpoint( } return new WebSocketConstructor(url, protocols); }); - const protocols = options.requestDurableAck - ? addQwpDurableAckWebSocketProtocol(options.protocols) - : options.protocols; - const socket = factory(endpoint, protocols); + const socket = factory(requestEndpoint, protocols); return openQwpWebSocket(socket, { url: endpoint, connectTimeoutMs: options.connectTimeoutMs, sendTimeoutMs: options.sendTimeoutMs, closeTimeoutMs: options.closeTimeoutMs, - completeHandshake: () => { - const durableAckEnabled = isQwpDurableAckWebSocketProtocol( - socket.protocol, - ); + completeHandshake: () => completeHandshake(socket.protocol), + opaqueErrors: true, + }); +} + +function browserNegotiationUrl( + endpoint: string | URL, + name: string, + value: string, +): URL { + const url = + endpoint instanceof URL + ? new URL(endpoint) + : new URL(endpoint, globalThis.location?.href); + url.searchParams.set(name, value); + return url; +} + +function connectQwpBrowserRawEndpoint( + options: QwpBrowserWebSocketOptions, + endpoint: string | URL, +): Promise { + const protocols = options.requestDurableAck + ? addQwpDurableAckWebSocketProtocol(options.protocols) + : options.protocols; + return connectQwpBrowserEndpoint( + options, + endpoint, + endpoint, + protocols, + (selectedProtocol) => { + const durableAckEnabled = + isQwpDurableAckWebSocketProtocol(selectedProtocol); if (options.requestDurableAck && !durableAckEnabled) { throw new QwpDurableAckUnavailableError(endpoint); } @@ -364,8 +415,141 @@ async function connectQwpBrowserEndpoint( ? { qwpVersion: QWP_VERSION, durableAckEnabled: true } : { qwpVersion: QWP_VERSION }; }, - opaqueErrors: true, - }); + ); +} + +async function applyQwpBrowserIngressHandshake( + connection: QwpBinaryConnection, + timeoutMs: number, +): Promise { + const iterator = connection.messages[Symbol.asyncIterator](); + const pendingFirst = iterator.next(); + const timeout = Symbol("QWP browser ingress negotiation timeout"); + let timer: ReturnType | undefined; + const outcome = + timeoutMs === 0 + ? timeout + : await Promise.race([ + pendingFirst, + new Promise((resolve) => { + timer = setTimeout(resolve, timeoutMs, timeout); + }), + ]); + if (timer !== undefined) clearTimeout(timer); + + const handshake: { + qwpVersion: number; + maxBatchSizeBytes?: number; + contentEncoding?: string; + negotiatedCompression?: QwpBinaryConnection["handshake"]["negotiatedCompression"]; + durableAckEnabled?: boolean; + serverRole?: string; + serverZone?: string; + } = { ...connection.handshake }; + let firstResult: IteratorResult | undefined; + let pendingResult: Promise> | undefined; + if (outcome === timeout) { + pendingResult = pendingFirst; + } else if (!outcome.done) { + const maxBatchSizeBytes = decodeQwpIngressServerInfo(outcome.value); + if (maxBatchSizeBytes === undefined) firstResult = outcome; + else handshake.maxBatchSizeBytes = maxBatchSizeBytes; + } + + const messages: AsyncIterable = { + async *[Symbol.asyncIterator]() { + let result = + firstResult ?? + (pendingResult === undefined + ? await iterator.next() + : await pendingResult); + while (!result.done) { + const maxBatchSizeBytes = decodeQwpIngressServerInfo(result.value); + if (maxBatchSizeBytes === undefined) yield result.value; + else handshake.maxBatchSizeBytes = maxBatchSizeBytes; + result = await iterator.next(); + } + }, + }; + + return { + messages, + handshake, + closed: connection.closed, + endpoint: connection.endpoint, + ingressSymbolDictionary: connection.ingressSymbolDictionary, + ingressDeltaSymbolDictionaryEnabled: + connection.ingressDeltaSymbolDictionaryEnabled, + getIngressMetrics: connection.getIngressMetrics + ? () => connection.getIngressMetrics!() + : undefined, + send: (payload) => connection.send(payload), + ping: connection.ping ? () => connection.ping!() : undefined, + close: (code, reason) => connection.close(code, reason), + }; +} + +async function connectQwpBrowserIngressEndpoint( + options: QwpBrowserWebSocketOptions, + endpoint: string | URL, +): Promise { + const timeoutMs = options.ingressNegotiationTimeoutMs ?? 250; + if (!Number.isFinite(timeoutMs) || timeoutMs < 0) { + throw new RangeError( + "ingressNegotiationTimeoutMs must be a non-negative finite number", + ); + } + const connection = await connectQwpBrowserEndpoint( + options, + endpoint, + browserNegotiationUrl(endpoint, "qwp_browser_handshake", "v1"), + options.requestDurableAck + ? addQwpDurableAckWebSocketProtocol(options.protocols) + : options.protocols, + (selectedProtocol) => { + const durableAckEnabled = + isQwpDurableAckWebSocketProtocol(selectedProtocol); + if (options.requestDurableAck && !durableAckEnabled) { + throw new QwpDurableAckUnavailableError(endpoint); + } + return durableAckEnabled + ? { qwpVersion: QWP_VERSION, durableAckEnabled: true } + : { qwpVersion: QWP_VERSION }; + }, + ); + try { + return await applyQwpBrowserIngressHandshake(connection, timeoutMs); + } catch (error) { + await connection + .close(1002, "invalid QWP ingress SERVER_INFO") + .catch(() => undefined); + throw error; + } +} + +function connectQwpBrowserEgressEndpoint( + options: QwpBrowserEgressOptions, + endpoint: string | URL, +): Promise { + const compression = options.compression ?? "raw"; + const acceptEncoding = encodeQwpAcceptEncoding( + compression, + options.compressionLevel ?? 1, + ); + const requestEndpoint = + acceptEncoding === undefined + ? endpoint + : browserNegotiationUrl(endpoint, "qwp_accept_encoding", acceptEncoding); + return connectQwpBrowserEndpoint( + options, + endpoint, + requestEndpoint, + options.protocols, + () => ({ + qwpVersion: QWP_VERSION, + negotiatedCompression: { codec: "raw", level: 0 }, + }), + ); } /** Opens a browser WebSocket and starts an ingress ACK/NACK session. */ @@ -428,7 +612,7 @@ export async function connectQwpBrowserEgress( createQwpEgressFailoverConnectionFactory( options.url, options.failoverUrls, - (endpoint) => connectQwpBrowserEndpoint(options, endpoint), + (endpoint) => connectQwpBrowserEgressEndpoint(options, endpoint), { target: options.target, zone: options.zone }, sessionOptions.serverInfoTimeoutMs ?? 15_000, ), diff --git a/src/qwp/core/constants.ts b/src/qwp/core/constants.ts index 08ffe3e..b9694d8 100644 --- a/src/qwp/core/constants.ts +++ b/src/qwp/core/constants.ts @@ -44,6 +44,7 @@ export const QWP_ENCODING_GORILLA = 0x01; export const QWP_STATUS = { OK: 0x00, + SERVER_INFO: 0x01, DURABLE_ACK: 0x02, SCHEMA_MISMATCH: 0x03, PARSE_ERROR: 0x05, @@ -71,6 +72,12 @@ export const QWP_EGRESS_MESSAGE = { export const QWP_EGRESS_CAPABILITY = { ZONE: 0x00000001, QUERY_FLAGS: 0x00000002, + COMPRESSION: 0x00000004, +} as const; + +export const QWP_COMPRESSION_CODEC = { + RAW: 0, + ZSTD: 1, } as const; export const QWP_QUERY_FLAG_RESET_DICTIONARY = 0x01; diff --git a/src/qwp/core/egress.ts b/src/qwp/core/egress.ts index d3fb438..abdd08c 100644 --- a/src/qwp/core/egress.ts +++ b/src/qwp/core/egress.ts @@ -33,6 +33,8 @@ export interface QwpServerInfoMessage extends QwpFrameHeader { clusterId: string; nodeId: string; zoneId: string | null; + compressionCodec: number | null; + compressionLevel: number | null; } export interface QwpResultBatchMessage extends QwpFrameHeader { @@ -186,6 +188,14 @@ export function decodeQwpEgressMessage(bytes: Uint8Array): QwpEgressMessage { (capabilities & QWP_EGRESS_CAPABILITY.ZONE) !== 0 ? readUint16Utf8(reader, "zone ID") : null; + const compressionCodec = + (capabilities & QWP_EGRESS_CAPABILITY.COMPRESSION) !== 0 + ? reader.readUint8("egress compression codec") + : null; + const compressionLevel = + compressionCodec !== null + ? reader.readUint8("egress compression level") + : null; reader.expectEnd("SERVER_INFO"); return { ...header, @@ -197,6 +207,8 @@ export function decodeQwpEgressMessage(bytes: Uint8Array): QwpEgressMessage { clusterId, nodeId, zoneId, + compressionCodec, + compressionLevel, }; } case QWP_EGRESS_MESSAGE.RESULT_BATCH: { diff --git a/src/qwp/core/ingress.ts b/src/qwp/core/ingress.ts index 2b01964..3052580 100644 --- a/src/qwp/core/ingress.ts +++ b/src/qwp/core/ingress.ts @@ -53,6 +53,25 @@ export interface QwpIngressResponse { errorMessage?: string; } +/** Decodes the browser-requested ingress SERVER_INFO payload when present. */ +export function decodeQwpIngressServerInfo( + payload: Uint8Array, +): number | undefined { + if (payload[0] !== QWP_STATUS.SERVER_INFO) return undefined; + if (payload.byteLength !== 5) { + throw new QwpProtocolError("invalid QWP ingress SERVER_INFO length"); + } + const maxBatchSizeBytes = new DataView( + payload.buffer, + payload.byteOffset, + payload.byteLength, + ).getUint32(1, true); + if (maxBatchSizeBytes === 0) { + throw new QwpProtocolError("invalid QWP ingress SERVER_INFO batch cap"); + } + return maxBatchSizeBytes; +} + function symbolText(value: unknown): string { return typeof value === "string" ? value : (value as QwpSymbolValue).text; } diff --git a/src/qwp/egress-session.ts b/src/qwp/egress-session.ts index 0c851d1..3202f76 100644 --- a/src/qwp/egress-session.ts +++ b/src/qwp/egress-session.ts @@ -3,6 +3,7 @@ import { encodeQwpCancel, encodeQwpCredit, encodeQwpQueryRequest, + QWP_COMPRESSION_CODEC, QWP_EGRESS_CAPABILITY, QWP_QUERY_FLAG_RESET_DICTIONARY, QWP_RESET_MASK_DICTIONARY, @@ -532,10 +533,27 @@ export class QwpEgressSession implements QwpEgressQueryControl { /** Effective codec and level echoed by the server on the active endpoint. */ get negotiatedCompression(): QwpNegotiatedEgressCompression | undefined { + const serverInfo = this.serverInfo; + if ( + serverInfo?.compressionCodec === QWP_COMPRESSION_CODEC.ZSTD && + serverInfo.compressionLevel !== null + ) { + return { codec: "zstd", level: serverInfo.compressionLevel }; + } + if (serverInfo?.compressionCodec === QWP_COMPRESSION_CODEC.RAW) { + return { codec: "raw", level: 0 }; + } + if (serverInfo?.compressionCodec !== null && serverInfo !== undefined) { + return { + codec: "unknown", + level: 0, + contentEncoding: `codec=${serverInfo.compressionCodec};level=${serverInfo.compressionLevel ?? 0}`, + }; + } return this.connection.handshake.negotiatedCompression; } - /** Effective Zstd level, or zero for raw, unknown, or browser-hidden negotiation. */ + /** Effective Zstd level, or zero for raw or unknown negotiation. */ get negotiatedZstdLevel(): number { const compression = this.negotiatedCompression; return compression?.codec === "zstd" ? compression.level : 0; diff --git a/test/qwp/browser.e2e.ts b/test/qwp/browser.e2e.ts index fc2d7d4..6e5f272 100644 --- a/test/qwp/browser.e2e.ts +++ b/test/qwp/browser.e2e.ts @@ -11,8 +11,10 @@ import { connectQwpNodeWebSocket, encodeQwpFrame, QWP_COLUMN_TYPE, + QWP_COMPRESSION_CODEC, QWP_DURABLE_ACK_WEBSOCKET_PROTOCOL, QWP_DEFAULT_EGRESS_INITIAL_CREDIT, + QWP_EGRESS_CAPABILITY, QWP_EGRESS_MESSAGE, QWP_STATUS, QwpByteReader, @@ -102,19 +104,32 @@ function writeU16String(writer: QwpByteWriter, value: string): void { writer.writeUint16(bytes.length).writeBytes(bytes); } -function browserServerInfo(): Uint8Array { +function browserServerInfo(compression?: { + codec: number; + level: number; +}): Uint8Array { const payload = new QwpByteWriter(); payload .writeUint8(QWP_EGRESS_MESSAGE.SERVER_INFO) .writeUint8(0) .writeBigUint64(1n) - .writeUint32(0) + .writeUint32(compression ? QWP_EGRESS_CAPABILITY.COMPRESSION : 0) .writeBigInt64(0n); writeU16String(payload, "browser-test-cluster"); writeU16String(payload, "browser-test-node"); + if (compression) { + payload.writeUint8(compression.codec).writeUint8(compression.level); + } return encodeQwpFrame(payload.toUint8Array()); } +function browserIngressServerInfo(maxBatchSizeBytes: number): Uint8Array { + return new QwpByteWriter() + .writeUint8(QWP_STATUS.SERVER_INFO) + .writeUint32(maxBatchSizeBytes) + .toUint8Array(); +} + function browserEmptyResultBatch(requestId: bigint): Uint8Array { const payload = new QwpByteWriter(); payload.writeUint8(QWP_EGRESS_MESSAGE.RESULT_BATCH).writeBigUint64(requestId); @@ -210,6 +225,7 @@ describe("QWP in a real browser", () => { it("negotiates durable ACKs through the real browser WebSocket API", async () => { const offeredProtocols: string[] = []; + let requestedPath: string | undefined; const server = new WebSocketServer({ host: "127.0.0.1", port: 0, @@ -220,6 +236,10 @@ describe("QWP in a real browser", () => { : false; }, }); + server.on("connection", (socket, request) => { + requestedPath = request.url; + socket.send(browserIngressServerInfo(1_048_576)); + }); await waitForWebSocketServer(server); const address = server.address() as AddressInfo; const page = await browser.newPage(); @@ -231,7 +251,7 @@ describe("QWP in a real browser", () => { url: string, ) => Promise>; const qwp = await importModule(moduleUrl); - const connection = await qwp.connectQwpBrowserWebSocket({ + const connection = await qwp.connectQwpBrowserIngress({ url, requestDurableAck: true, }); @@ -248,7 +268,151 @@ describe("QWP in a real browser", () => { ); expect(offeredProtocols).toContain(QWP_DURABLE_ACK_WEBSOCKET_PROTOCOL); - expect(result).toEqual({ qwpVersion: 1, durableAckEnabled: true }); + expect( + new URL(requestedPath!, "http://localhost").searchParams.get( + "qwp_browser_handshake", + ), + ).toBe("v1"); + expect(result).toEqual({ + qwpVersion: 1, + durableAckEnabled: true, + maxBatchSizeBytes: 1_048_576, + }); + } finally { + await page.close(); + await closeWebSocketServer(server); + } + }); + + it("falls back to raw when an older egress server ignores compression", async () => { + const server = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + server.on("connection", (socket) => socket.send(browserServerInfo())); + await waitForWebSocketServer(server); + const address = server.address() as AddressInfo; + const page = await browser.newPage(); + try { + await page.goto(assetUrl); + const result = await page.evaluate( + async ({ moduleUrl, url }) => { + const importModule = new Function("url", "return import(url)") as ( + url: string, + ) => Promise>; + const qwp = await importModule(moduleUrl); + const session = await qwp.connectQwpBrowserEgress({ + url, + compression: "zstd", + compressionLevel: 7, + }); + try { + return session.negotiatedCompression; + } finally { + await session.close(); + } + }, + { + moduleUrl: assetUrl, + url: `ws://127.0.0.1:${address.port}/read/v1`, + }, + ); + + expect(result).toEqual({ codec: "raw", level: 0 }); + } finally { + await page.close(); + await closeWebSocketServer(server); + } + }); + + it("falls back cleanly when an older ingress server sends no cap", async () => { + const server = new WebSocketServer({ + host: "127.0.0.1", + port: 0, + }); + await waitForWebSocketServer(server); + const address = server.address() as AddressInfo; + const page = await browser.newPage(); + try { + await page.goto(assetUrl); + const result = await page.evaluate( + async ({ moduleUrl, url }) => { + const importModule = new Function("url", "return import(url)") as ( + url: string, + ) => Promise>; + const qwp = await importModule(moduleUrl); + const connection = await qwp.connectQwpBrowserIngress({ + url, + ingressNegotiationTimeoutMs: 10, + }); + try { + return connection.handshake; + } finally { + await connection.close(); + } + }, + { + moduleUrl: assetUrl, + url: `ws://127.0.0.1:${address.port}/write/v4`, + }, + ); + + expect(result).toEqual({ qwpVersion: 1 }); + } finally { + await page.close(); + await closeWebSocketServer(server); + } + }); + + it("negotiates Zstd through the real browser WebSocket API", async () => { + let requestedPath: string | undefined; + const server = new WebSocketServer({ + host: "127.0.0.1", + port: 0, + }); + server.on("connection", (socket, request) => { + requestedPath = request.url; + socket.send( + browserServerInfo({ codec: QWP_COMPRESSION_CODEC.ZSTD, level: 3 }), + ); + }); + await waitForWebSocketServer(server); + const address = server.address() as AddressInfo; + const page = await browser.newPage(); + try { + await page.goto(assetUrl); + const result = await page.evaluate( + async ({ moduleUrl, url }) => { + const importModule = new Function("url", "return import(url)") as ( + url: string, + ) => Promise>; + const qwp = await importModule(moduleUrl); + const session = await qwp.connectQwpBrowserEgress({ + url, + compression: "zstd", + compressionLevel: 7, + }); + try { + return { + compression: session.negotiatedCompression, + level: session.negotiatedZstdLevel, + }; + } finally { + await session.close(); + } + }, + { + moduleUrl: assetUrl, + url: `ws://127.0.0.1:${address.port}/read/v1`, + }, + ); + + expect( + new URL(requestedPath!, "http://localhost").searchParams.get( + "qwp_accept_encoding", + ), + ).toBe("zstd;level=7,raw"); + expect(result).toEqual({ + compression: { codec: "zstd", level: 3 }, + level: 3, + }); } finally { await page.close(); await closeWebSocketServer(server); diff --git a/test/qwp/core.test.ts b/test/qwp/core.test.ts index 7fad6b6..1c85e7e 100644 --- a/test/qwp/core.test.ts +++ b/test/qwp/core.test.ts @@ -4,6 +4,7 @@ import { decodeQwpContentEncoding, decodeQwpFrame, decodeQwpIngressResponse, + decodeQwpIngressServerInfo, decodeQwpIngressSymbolDictionaryDelta, decodeQwpVarint, addQwpDurableAckWebSocketProtocol, @@ -17,6 +18,7 @@ import { encodeQwpQueryRequest, encodeQwpVarint, QWP_COLUMN_TYPE, + QWP_COMPRESSION_CODEC, QWP_DURABLE_ACK_WEBSOCKET_PROTOCOL, QWP_EGRESS_CAPABILITY, QWP_EGRESS_MESSAGE, @@ -114,6 +116,17 @@ describe("QWP browser durable-ACK negotiation", () => { payload: new Uint8Array(), }); }); + + it("decodes the browser ingress SERVER_INFO batch cap", () => { + const payload = new QwpByteWriter() + .writeUint8(QWP_STATUS.SERVER_INFO) + .writeUint32(1_048_576) + .toUint8Array(); + expect(decodeQwpIngressServerInfo(payload)).toBe(1_048_576); + expect( + decodeQwpIngressServerInfo(Uint8Array.from([QWP_STATUS.OK])), + ).toBeUndefined(); + }); }); describe("QWP egress compression negotiation", () => { @@ -380,11 +393,14 @@ describe("QWP egress codec", () => { .writeUint8(QWP_EGRESS_MESSAGE.SERVER_INFO) .writeUint8(1) .writeBigUint64(3n) - .writeUint32(QWP_EGRESS_CAPABILITY.ZONE) + .writeUint32( + QWP_EGRESS_CAPABILITY.ZONE | QWP_EGRESS_CAPABILITY.COMPRESSION, + ) .writeBigInt64(123n); writeU16String(payload, "cluster-a"); writeU16String(payload, "node-1"); writeU16String(payload, "eu-west-1a"); + payload.writeUint8(QWP_COMPRESSION_CODEC.ZSTD).writeUint8(3); expect( decodeQwpEgressMessage(encodeQwpFrame(payload.toUint8Array())), @@ -395,6 +411,8 @@ describe("QWP egress codec", () => { clusterId: "cluster-a", nodeId: "node-1", zoneId: "eu-west-1a", + compressionCodec: QWP_COMPRESSION_CODEC.ZSTD, + compressionLevel: 3, }); }); diff --git a/test/qwp/session.test.ts b/test/qwp/session.test.ts index 05e720f..cb89f25 100644 --- a/test/qwp/session.test.ts +++ b/test/qwp/session.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import { bootstrapQwpBrowserSession, + connectQwpBrowserEgress, connectQwpBrowserIngress, connectQwpBrowserWebSocket, createQwpBrowserSender, @@ -15,6 +16,8 @@ import { } from "../../src/qwp/node"; import { QWP_COLUMN_TYPE, + QWP_COMPRESSION_CODEC, + QWP_EGRESS_CAPABILITY, QWP_EGRESS_MESSAGE, QWP_FLAG_DEFER_COMMIT, QWP_FLAG_DELTA_SYMBOL_DICTIONARY, @@ -239,19 +242,32 @@ function symbolTable(name: string, values: readonly string[]): QwpTableBuffer { return table; } -function serverInfoFrame(): Uint8Array { +function serverInfoFrame(compression?: { + codec: number; + level: number; +}): Uint8Array { const writer = new QwpByteWriter(); writer .writeUint8(QWP_EGRESS_MESSAGE.SERVER_INFO) .writeUint8(0) .writeBigUint64(1n) - .writeUint32(0) + .writeUint32(compression ? QWP_EGRESS_CAPABILITY.COMPRESSION : 0) .writeBigInt64(123n) .writeUint16(0) .writeUint16(0); + if (compression) { + writer.writeUint8(compression.codec).writeUint8(compression.level); + } return encodeQwpFrame(writer.toUint8Array()); } +function ingressServerInfo(maxBatchSizeBytes: number): Uint8Array { + return new QwpByteWriter() + .writeUint8(QWP_STATUS.SERVER_INFO) + .writeUint32(maxBatchSizeBytes) + .toUint8Array(); +} + describe("QWP WebSocket adapters", () => { it.each(["browser", "node"] as const)( "validates %s timeouts before creating a WebSocket", @@ -490,6 +506,87 @@ describe("QWP WebSocket adapters", () => { await sender.close(); }); + it("uses the browser-selected ingress batch cap automatically", async () => { + const socket = new FakeWebSocket(); + let capturedUrl: string | URL | undefined; + const connecting = connectQwpBrowserIngress({ + url: "ws://localhost:9000/write/v4", + webSocketFactory: (url) => { + capturedUrl = url; + return asQwpSocket(socket); + }, + }); + socket.open(); + socket.message(ingressServerInfo(128)); + + const session = await connecting; + expect( + new URL(capturedUrl!).searchParams.get("qwp_browser_handshake"), + ).toBe("v1"); + expect(session.handshake.maxBatchSizeBytes).toBe(128); + expect(session.maxBatchSizeBytes).toBe(128); + await session.close(); + }); + + it("splits fluent browser rows under the negotiated server cap", async () => { + const socket = new FakeWebSocket(); + const sender = createQwpBrowserSender( + { + url: "ws://localhost:9000/write/v4", + webSocketFactory: () => asQwpSocket(socket), + }, + { autoFlush: false, encode: { gorilla: false } }, + ); + const connecting = sender.connect(); + socket.open(); + socket.message(ingressServerInfo(128)); + await connecting; + for (let value = 0; value < 50; value++) { + await sender.table("events").longColumn("value", value).atNow(); + } + socket.onSend = () => { + const sequence = BigInt(socket.sent.length - 1); + socket.message(ingressResponse(QWP_STATUS.OK, sequence)); + }; + + await expect(sender.flush()).resolves.toBe(true); + expect(socket.sent.length).toBeGreaterThan(1); + expect(socket.sent.every((frame) => frame.byteLength <= 128)).toBe(true); + await sender.close(); + }); + + it("negotiates browser Zstd and exposes the effective selected level", async () => { + const socket = new FakeWebSocket(); + let capturedUrl: string | URL | undefined; + let capturedProtocols: string | string[] | undefined; + const connecting = connectQwpBrowserEgress({ + url: "ws://localhost:9000/read/v1", + compression: "zstd", + compressionLevel: 7, + webSocketFactory: (url, protocols) => { + capturedUrl = url; + capturedProtocols = protocols; + return asQwpSocket(socket); + }, + }); + socket.open(); + socket.message( + serverInfoFrame({ codec: QWP_COMPRESSION_CODEC.ZSTD, level: 3 }), + ); + + const session = await connecting; + expect(capturedProtocols).toBeUndefined(); + expect(new URL(capturedUrl!).searchParams.get("qwp_accept_encoding")).toBe( + "zstd;level=7,raw", + ); + expect(session.negotiatedCompression).toEqual({ + codec: "zstd", + level: 3, + }); + expect(session.negotiatedZstdLevel).toBe(3); + await session.close(); + }); + it("automatically splits fluent browser sender rows under its configured cap", async () => { const socket = new FakeWebSocket(); const sizingDictionary = new QwpSymbolDictionary(); @@ -1764,6 +1861,7 @@ describe("QwpIngressSession", () => { { url: "ws://localhost:9000/write/v4", requestDurableAck: true, + ingressNegotiationTimeoutMs: 0, webSocketFactory: () => asQwpSocket(socket), }, { From 4d636355cb4e0a2076c17d767fbb47d35ae1245f Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 18 Aug 2026 16:55:16 +0100 Subject: [PATCH 047/265] feat(qwp): align store-forward retry policies --- QWP.md | 34 ++- README.md | 4 + src/options.ts | 60 +++++ src/qwp/ingress-session.ts | 10 + .../reconnecting-ingress-connection.ts | 193 ++++++++++++++++- src/qwp/node.ts | 48 +++- src/qwp/transport.ts | 16 ++ src/sender.ts | 22 ++ test/options.test.ts | 53 +++++ test/qwp/public-api-contract.ts | 6 +- test/qwp/reconnect.test.ts | 205 ++++++++++++++++++ test/qwp/sender-node-integration.test.ts | 41 ++++ 12 files changed, 673 insertions(+), 19 deletions(-) diff --git a/QWP.md b/QWP.md index b4c7540..2358a35 100644 --- a/QWP.md +++ b/QWP.md @@ -63,7 +63,7 @@ Advanced QWP options are accepted in the second argument: ```typescript const sender = await Sender.fromConfig( - "wss::addr=questdb.example:9000;token=REST_OR_OIDC_TOKEN", + "wss::addr=questdb.example:9000;token=REST_OR_OIDC_TOKEN;initial_connect_retry=async", { qwp: { webSocket: { @@ -76,6 +76,7 @@ const sender = await Sender.fromConfig( checkpointIntervalMs: 5_000, backpressurePolicy: "wait", appendDeadlineMs: 30_000, + catchUpCapGapMinEscalationWindowMs: 300_000, drainOrphans: true, maxBackgroundDrainers: 4, }, @@ -107,6 +108,22 @@ Applications can therefore keep publishing during an outage until the configured `maxBytes` applies backpressure. A failed journal publication leaves the high-level rows staged so the caller can retry. +`initialConnectMode` selects persistent startup behavior: `"off"` makes one +fail-fast attempt, `"sync"` retries on the caller within the configured reconnect +budget, and `"async"` (the backwards-compatible default) returns immediately while +the background replay loop connects. `Sender.fromConfig()` also accepts +`initial_connect_retry=off|sync|async` when `qwp.webSocket.storeAndForward` is +supplied. Initial authentication, upgrade, and capability failures remain terminal. +After a foreground persistent sender has connected successfully at least once, the +same failures are retried indefinitely so credential rotation and rolling capability +changes cannot strand its journal. The configured reconnect attempt/duration budget +therefore bounds `"sync"` startup and non-persistent reconnects, not steady-state +foreground store-and-forward recovery. + +The connect-string key +`catch_up_cap_gap_min_escalation_window_millis` is the equivalent of +`catchUpCapGapMinEscalationWindowMs`. + `durability` controls the local persistence barrier: - `"append"` (the backwards-compatible default) fsyncs every frame and its atomic @@ -152,6 +169,13 @@ to make it eligible again. `onOrphanDrainEvent` reports discovery, drain, lock contention, quarantine, and scanner failures without allowing callback exceptions to interrupt recovery. +A foreground sender retries a symbol-dictionary catch-up entry that is too large for +the current target forever because a larger-cap node may return. An orphan drainer +quarantines that slot only after 16 consecutive incompatible-cap observations and a +minimum five-minute dwell. Tune the dwell with +`catchUpCapGapMinEscalationWindowMs`; an unrelated transport or upgrade failure resets +the episode so outage time cannot accidentally satisfy it. + Keep sibling adoption off unless the parent is a dedicated store-and-forward group: every record-bearing child directory that is not the foreground slot is considered eligible. Browser senders never scan or persist local slots. @@ -371,9 +395,11 @@ rejection) and then by zone affinity; configuration order breaks ties. Health ou zone, so a known healthy cross-zone node is preferred to an untried local node. Every connection sweep can still try every endpoint, allowing role and health changes to recover. A non-orderly close demotes the selected endpoint before the next sweep. -`reconnect` controls bounded exponential backoff and emits lifecycle events. Node -ingress requires a persistent replay store when reconnect is enabled; browser ingress -can only replay from memory for the lifetime of the page. +`reconnect` controls exponential backoff and emits lifecycle events. Its attempt and +duration bounds apply to browser/memory reconnect and Node `"sync"` startup. A Node +foreground store-and-forward replay loop remains unbounded after startup. Node ingress +requires a persistent replay store when reconnect is enabled; browser ingress can only +replay from memory for the lifetime of the page. Ingress also detects a replay head that is repeatedly NACKed or followed by a non-orderly WebSocket close. `maxFrameRejections` controls the strike threshold and diff --git a/README.md b/README.md index 063694d..a8682dc 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,10 @@ can start and accept flushes while QuestDB is offline. `flush()` then resolves after local durable journal publication and a background drainer reconnects and sends in order. Set `qwp.sender.awaitServerAck: true` to wait for the QuestDB ACK instead, or `awaitDurableAck: true` to wait through durable upload. +Set `initialConnectMode` to `"off"`, `"sync"`, or `"async"` (the default) to +choose fail-fast, bounded blocking, or background startup. The configuration-string +equivalent is `initial_connect_retry`, used together with the store-and-forward +options in `extraOptions.qwp`. Set `drainOrphans: true` when sibling journal directories share a dedicated parent: the Node client scans and drains slots left by failed producer processes with bounded concurrency. Pooled QWP clients recover out-of-range `sender-N` slots automatically, diff --git a/src/options.ts b/src/options.ts index 19f5109..1070a03 100644 --- a/src/options.ts +++ b/src/options.ts @@ -9,6 +9,7 @@ import { fetchJson, isBoolean, isInteger } from "./utils"; import { DEFAULT_REQUEST_TIMEOUT } from "./transport/http/base"; import type { QwpNodeIngressOptions, + QwpInitialConnectMode, QwpIngressSessionOptions, QwpSenderOptions, } from "./qwp/node"; @@ -132,6 +133,13 @@ type DeprecatedOptions = { *
  • close_flush_timeout_millis: integer - Maximum time QWP close waits for committed rows to be acknowledged. * Defaults to 5000; 0 publishes pending rows but skips the ACK drain. This option is supported by ws/wss only. *
  • + *
  • initial_connect_retry: enum, accepted values: off, sync, async - QWP persistent + * store-and-forward startup policy. Requires qwp.webSocket.storeAndForward. + *
  • + *
  • catch_up_cap_gap_min_escalation_window_millis: integer - Minimum dwell + * before an orphan symbol-dictionary cap gap can be quarantined. Defaults to 300000. + * Requires qwp.webSocket.storeAndForward. + *
  • * *
    * Buffer sizing options @@ -187,6 +195,8 @@ class SenderOptions { auto_flush_bytes?: number; auto_flush_interval?: number; close_flush_timeout_millis?: number; + initial_connect_retry?: QwpInitialConnectMode; + catch_up_cap_gap_min_escalation_window_millis?: number; request_min_throughput?: number; request_timeout?: number; @@ -390,6 +400,8 @@ function parseConfigurationString( parseBufferSizes(options); parseAutoFlushOptions(options); parseCloseFlushOptions(options); + parseInitialConnectOptions(options); + parseCatchUpCapGapOptions(options); parseTlsOptions(options); parseRequestTimeoutOptions(options); parseMaxNameLength(options); @@ -452,6 +464,8 @@ const ValidConfigKeys = [ "auto_flush_bytes", "auto_flush_interval", "close_flush_timeout_millis", + "initial_connect_retry", + "catch_up_cap_gap_min_escalation_window_millis", "request_min_throughput", "request_timeout", "retry_timeout", @@ -627,6 +641,52 @@ function parseCloseFlushOptions(options: SenderOptions) { } } +function parseInitialConnectOptions(options: SenderOptions) { + const value = options.initial_connect_retry as unknown; + if (value === undefined) return; + if (options.protocol !== WS && options.protocol !== WSS) { + throw new Error( + "initial_connect_retry is only supported for QWP ws/wss transport", + ); + } + switch (value) { + case "on": + case "true": + case "sync": + options.initial_connect_retry = "sync"; + return; + case "off": + case "false": + options.initial_connect_retry = "off"; + return; + case "async": + options.initial_connect_retry = "async"; + return; + default: + throw new Error( + `Invalid initial_connect_retry: '${String(value)}', accepted values: 'off', 'sync', 'async'`, + ); + } +} + +function parseCatchUpCapGapOptions(options: SenderOptions) { + parseInteger( + options, + "catch_up_cap_gap_min_escalation_window_millis", + "catch-up cap-gap minimum escalation window", + 0, + ); + if ( + options.catch_up_cap_gap_min_escalation_window_millis !== undefined && + options.protocol !== WS && + options.protocol !== WSS + ) { + throw new Error( + "catch_up_cap_gap_min_escalation_window_millis is only supported for QWP ws/wss transport", + ); + } +} + function parseTlsOptions(options: SenderOptions) { parseBoolean(options, "tls_verify", "TLS verify", UNSAFE_OFF); diff --git a/src/qwp/ingress-session.ts b/src/qwp/ingress-session.ts index eab2c46..1f2ca99 100644 --- a/src/qwp/ingress-session.ts +++ b/src/qwp/ingress-session.ts @@ -15,6 +15,7 @@ import { QwpConnectionCloseInfo, QwpConnectionFactory, QwpHandshakeMetadata, + QwpInitialConnectMode, QwpIngressReplayStore, QwpReconnectOptions, QwpReplayDictionaryPersistenceError, @@ -157,6 +158,12 @@ export interface QwpIngressSessionOptions { replayStore?: QwpIngressReplayStore; /** @internal Starts the Node persistent drainer without waiting for a server. */ backgroundStoreAndForward?: boolean; + /** @internal Initial connection policy supplied by the Node SF adapter. */ + initialConnectMode?: QwpInitialConnectMode; + /** @internal Orphan sessions may quarantine persistent catch-up cap gaps. */ + orphanStoreAndForward?: boolean; + /** @internal Minimum cap-gap dwell before an orphan can be quarantined. */ + catchUpCapGapMinEscalationWindowMs?: number; /** * Optional local ingress frame cap. Browsers cannot read WebSocket upgrade * headers, so browser applications should set this to the server's configured @@ -431,6 +438,9 @@ export class QwpIngressSession { options.replayStore, options.maxBatchSizeBytes, options.backgroundStoreAndForward, + options.initialConnectMode, + options.orphanStoreAndForward, + options.catchUpCapGapMinEscalationWindowMs, ) : await factory(); try { diff --git a/src/qwp/internal/reconnecting-ingress-connection.ts b/src/qwp/internal/reconnecting-ingress-connection.ts index 2df6dff..8fe0016 100644 --- a/src/qwp/internal/reconnecting-ingress-connection.ts +++ b/src/qwp/internal/reconnecting-ingress-connection.ts @@ -13,6 +13,7 @@ import { utf8Length, } from "../core"; import { + QWP_INITIAL_CONNECT_MODE, QWP_RECONNECT_EVENT_KIND, QwpBinaryConnection, QwpConnectionCloseInfo, @@ -22,6 +23,7 @@ import { QwpIngressReplayRecord, QwpIngressReplayStore, QwpIngressTransportMetrics, + QwpInitialConnectMode, QwpReconnectEvent, QwpReconnectExhaustedError, QwpReconnectOptions, @@ -33,6 +35,37 @@ import { } from "../transport"; import { QwpAsyncQueue } from "./async-queue"; +const DEFAULT_CATCH_UP_CAP_GAP_MIN_ESCALATION_WINDOW_MS = 300_000; +const MAX_CATCH_UP_CAP_GAP_ATTEMPTS = 16; + +type ConnectAttemptPolicy = "single" | "configured" | "unbounded"; + +class QwpCatchUpCapGapError extends RangeError { + constructor( + readonly symbolId: number, + readonly frameLength: number, + readonly maxBatchSizeBytes: number, + details?: { + attempt: number; + episodeMs: number; + minEscalationWindowMs: number; + exhausted: boolean; + }, + ) { + super( + `symbol dictionary entry exceeds reconnect target batch cap [id=${symbolId}, frameLength=${frameLength}, max=${maxBatchSizeBytes}` + + (details + ? `, attempt=${details.attempt}/${MAX_CATCH_UP_CAP_GAP_ATTEMPTS}, episodeMs=${details.episodeMs}/${details.minEscalationWindowMs}]${ + details.exhausted + ? "; the data must be resent after the cap is raised" + : "; retrying because a larger-cap node may return" + }` + : "]"), + ); + this.name = "QwpCatchUpCapGapError"; + } +} + interface ReplayFrame extends QwpIngressReplayRecord { readonly clientSequence?: bigint; ackDelivered: boolean; @@ -138,6 +171,7 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { private readonly maxDurationMs: number; private readonly maxFrameRejections: number; private readonly poisonMinEscalationWindowMs: number; + private readonly catchUpCapGapMinEscalationWindowMs: number; private readonly localMaxBatchSizeBytes?: number; private readonly resolveClosed: (info: QwpConnectionCloseInfo) => void; private connection?: QwpBinaryConnection; @@ -152,6 +186,8 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { private poisonFrameSequence?: bigint; private poisonFirstStrikeMs = 0; private poisonStrikes = 0; + private catchUpCapGapAttempts = 0; + private catchUpCapGapFirstMs = 0; private progressAtLastExemptRecycle = -1n; private zeroProgressRecycles = 0; private recoveredDiscardTail?: RecoveredDiscardTail; @@ -173,6 +209,7 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { private totalFailovers = 0; private totalReconnectErrors = 0; private totalServerNacks = 0; + private hasEverConnected = false; private deltaSymbolDictionaryEnabled: boolean; readonly messages: AsyncIterable = this.messagesQueue; readonly closed: Promise; @@ -187,6 +224,8 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { recoveredDiscardTail: RecoveredDiscardTail | undefined, localMaxBatchSizeBytes?: number, private readonly backgroundStoreAndForward = false, + private readonly orphanStoreAndForward = false, + catchUpCapGapMinEscalationWindowMs = DEFAULT_CATCH_UP_CAP_GAP_MIN_ESCALATION_WINDOW_MS, ) { this.store = store; this.symbolDictionary = [...symbolDictionary]; @@ -202,6 +241,8 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { this.maxFrameRejections = reconnectOptions.maxFrameRejections ?? 4; this.poisonMinEscalationWindowMs = reconnectOptions.poisonMinEscalationWindowMs ?? 5_000; + this.catchUpCapGapMinEscalationWindowMs = + catchUpCapGapMinEscalationWindowMs; validateReconnectPolicy( this.maxAttempts, this.initialBackoffMs, @@ -209,6 +250,7 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { this.maxDurationMs, this.maxFrameRejections, this.poisonMinEscalationWindowMs, + this.catchUpCapGapMinEscalationWindowMs, ); let resolveClosed!: (info: QwpConnectionCloseInfo) => void; this.closed = new Promise((resolve) => { @@ -244,6 +286,11 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { replayStore?: QwpIngressReplayStore, localMaxBatchSizeBytes?: number, backgroundStoreAndForward = false, + initialConnectMode: QwpInitialConnectMode = backgroundStoreAndForward + ? QWP_INITIAL_CONNECT_MODE.ASYNC + : QWP_INITIAL_CONNECT_MODE.SYNC, + orphanStoreAndForward = false, + catchUpCapGapMinEscalationWindowMs = DEFAULT_CATCH_UP_CAP_GAP_MIN_ESCALATION_WINDOW_MS, ): Promise { const store = replayStore ?? new QwpMemoryReplayStore(); let connection: QwpReconnectingIngressConnection | undefined; @@ -269,10 +316,41 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { analyzeRecoveredDiscardTail(sortedRecords), localMaxBatchSizeBytes, backgroundStoreAndForward, + orphanStoreAndForward, + catchUpCapGapMinEscalationWindowMs, ); await connection.retireRecoveredDiscardTailIfReady(); - if (backgroundStoreAndForward) connection.startBackgroundConnect(); - else await connection.connectLoop(undefined, false); + if ( + backgroundStoreAndForward && + initialConnectMode === QWP_INITIAL_CONNECT_MODE.ASYNC + ) { + connection.startBackgroundConnect(); + } else { + try { + await connection.connectLoop( + undefined, + false, + backgroundStoreAndForward && + initialConnectMode === QWP_INITIAL_CONNECT_MODE.OFF + ? "single" + : "configured", + ); + } catch (error) { + if ( + backgroundStoreAndForward && + !orphanStoreAndForward && + error instanceof QwpCatchUpCapGapError + ) { + // Java returns the foreground sender once the wire has connected, + // then moves recovered-dictionary catch-up to its unbounded I/O + // loop. Do the same instead of making OFF/SYNC construction wait + // forever for a larger-cap node. + connection.startBackgroundConnect(); + } else { + throw error; + } + } + } return connection; } catch (error) { await connection?.close().catch(() => undefined); @@ -372,7 +450,7 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { } private startBackgroundConnect(): void { - const connecting = this.connectLoop(undefined, false); + const connecting = this.connectLoop(undefined, false, "unbounded"); this.reconnectTask = connecting; void connecting .catch((error: unknown) => { @@ -421,6 +499,9 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { private async connectLoop( initialCause: unknown, reconnecting: boolean, + attemptPolicy: ConnectAttemptPolicy = this.backgroundStoreAndForward + ? "unbounded" + : "configured", ): Promise { const outageStarted = Date.now(); const previousEndpoint = this.lastEndpoint; @@ -452,6 +533,7 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { let candidate: QwpBinaryConnection | undefined; try { candidate = await this.factory(); + this.hasEverConnected = true; this.connectingCandidate = candidate; if (this.closing) { await candidate.close().catch(() => undefined); @@ -460,6 +542,7 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { const replayed = await this.replayInto(candidate); if (this.closing) throw new QwpSendClosedError(); this.install(candidate, replayed); + this.resetCatchUpCapGapEpisode(); this.connectingCandidate = undefined; if (reconnecting) { this.totalReconnectsSucceeded++; @@ -497,13 +580,31 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { previousEndpoint, cause: error, }); - if (!isRetryableReconnectError(error)) throw error; + const capGapError = + error instanceof QwpCatchUpCapGapError + ? this.applyCatchUpCapGapPolicy(error) + : undefined; + if (!capGapError) this.resetCatchUpCapGapEpisode(); + if (capGapError?.exhausted) throw capGapError.error; + if ( + capGapError && + !this.orphanStoreAndForward && + attemptPolicy !== "unbounded" + ) { + throw capGapError.error; + } + if (!this.isRetryableReconnectError(error)) throw error; const attemptsExhausted = - this.maxAttempts > 0 && attempt >= this.maxAttempts; + attemptPolicy === "single" || + (attemptPolicy === "configured" && + this.maxAttempts > 0 && + attempt >= this.maxAttempts); const durationExhausted = + attemptPolicy === "configured" && this.maxDurationMs > 0 && Date.now() - outageStarted >= this.maxDurationMs; if (attemptsExhausted || durationExhausted) { + if (attemptPolicy === "single") throw error; throw new QwpReconnectExhaustedError(attempt, lastError); } } @@ -511,6 +612,56 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { throw new QwpSendClosedError(); } + private applyCatchUpCapGapPolicy(error: QwpCatchUpCapGapError): { + exhausted: boolean; + error: QwpCatchUpCapGapError; + } { + // Foreground SF owns producer data and must wait for a larger-cap node. + if (!this.orphanStoreAndForward) { + return { exhausted: false, error }; + } + const now = monotonicNowMs(); + if (this.catchUpCapGapAttempts === 0) { + this.catchUpCapGapFirstMs = now; + } + this.catchUpCapGapAttempts++; + const episodeMs = Math.max(0, now - this.catchUpCapGapFirstMs); + const exhausted = + this.catchUpCapGapAttempts >= MAX_CATCH_UP_CAP_GAP_ATTEMPTS && + episodeMs >= this.catchUpCapGapMinEscalationWindowMs; + return { + exhausted, + error: new QwpCatchUpCapGapError( + error.symbolId, + error.frameLength, + error.maxBatchSizeBytes, + { + attempt: this.catchUpCapGapAttempts, + episodeMs, + minEscalationWindowMs: this.catchUpCapGapMinEscalationWindowMs, + exhausted, + }, + ), + }; + } + + private resetCatchUpCapGapEpisode(): void { + this.catchUpCapGapAttempts = 0; + this.catchUpCapGapFirstMs = 0; + } + + private isRetryableReconnectError(error: unknown): boolean { + if ( + this.backgroundStoreAndForward && + !this.orphanStoreAndForward && + this.hasEverConnected && + isEndpointPolicyFailure(error) + ) { + return true; + } + return isRetryableReconnectError(error); + } + private async replayInto( connection: QwpBinaryConnection, ): Promise { @@ -550,6 +701,7 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { connection: QwpBinaryConnection, wireFrames: ReplayFrame[], ): void { + this.hasEverConnected = true; this.connection = connection; this.lastHandshake = connection.handshake; this.lastEndpoint = connection.endpoint; @@ -1038,7 +1190,11 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { this.connection = undefined; void failedConnection.close().catch(() => undefined); - const reconnecting = this.connectLoop(cause, true); + const reconnecting = this.connectLoop( + cause, + true, + this.backgroundStoreAndForward ? "unbounded" : "configured", + ); this.reconnectTask = reconnecting; try { await reconnecting; @@ -1235,9 +1391,14 @@ function dictionaryCatchupFrames( entriesSize = nextEntriesSize; } if (count === 0) { - throw new RangeError( - `symbol dictionary entry exceeds reconnect target batch cap [id=${startId}, max=${maxBatchSizeBytes}]`, - ); + const entryLength = utf8Length(entries[startId]); + const frameLength = + QWP_HEADER_SIZE + + qwpVarintSize(startId) + + qwpVarintSize(1) + + qwpVarintSize(entryLength) + + entryLength; + throw new QwpCatchUpCapGapError(startId, frameLength, maxBatchSizeBytes); } result.push( encodeQwpIngressSymbolDictionaryFrame( @@ -1261,6 +1422,10 @@ function minimumDefined( : Math.min(first, second); } +function monotonicNowMs(): number { + return typeof performance === "undefined" ? Date.now() : performance.now(); +} + function validateReconnectPolicy( maxAttempts: number, initialBackoffMs: number, @@ -1268,6 +1433,7 @@ function validateReconnectPolicy( maxDurationMs: number, maxFrameRejections: number, poisonMinEscalationWindowMs: number, + catchUpCapGapMinEscalationWindowMs: number, ): void { if (!Number.isSafeInteger(maxAttempts) || maxAttempts < 0) { throw new RangeError( @@ -1279,6 +1445,7 @@ function validateReconnectPolicy( ["maxBackoffMs", maxBackoffMs], ["maxDurationMs", maxDurationMs], ["poisonMinEscalationWindowMs", poisonMinEscalationWindowMs], + ["catchUpCapGapMinEscalationWindowMs", catchUpCapGapMinEscalationWindowMs], ] as const) { if (!Number.isFinite(value) || value < 0) { throw new RangeError( @@ -1310,6 +1477,14 @@ function isRetryableReconnectError(error: unknown): boolean { ); } +function isEndpointPolicyFailure(error: unknown): boolean { + if (error instanceof QwpUpgradeError) return true; + return ( + error instanceof QwpFailoverError && + error.attempts.some((attempt) => isEndpointPolicyFailure(attempt.error)) + ); +} + function reconnectDelayMs(error: unknown): number { return error instanceof RetriableIngressNackError || error instanceof RetriableIngressConnectionError diff --git a/src/qwp/node.ts b/src/qwp/node.ts index 0dee681..4a1c4cc 100644 --- a/src/qwp/node.ts +++ b/src/qwp/node.ts @@ -19,12 +19,14 @@ import { import { createQwpFailoverConnectionFactory } from "./internal/failover"; import { createQwpEgressFailoverConnectionFactory } from "./internal/egress-routing"; import { + QWP_INITIAL_CONNECT_MODE, QWP_UPGRADE_ERROR_KIND, QwpBinaryConnection, QwpConnectionFactory, QwpDurableAckUnavailableError, QwpEgressRoutingOptions, QwpHandshakeMetadata, + QwpInitialConnectMode, QwpUpgradeError, QwpWebSocketConnectOptions, } from "./transport"; @@ -185,6 +187,17 @@ export interface QwpNodeIngressOptions extends QwpNodeWebSocketOptions { /** Node store-and-forward controls layered on the crash-safe replay journal. */ export interface QwpNodeStoreAndForwardOptions extends QwpNodeFileReplayStoreOptions { + /** + * Initial server connection policy. Defaults to `async` for compatibility: + * persisted rows may be published before an endpoint is online. + */ + initialConnectMode?: QwpInitialConnectMode; + /** + * Minimum time an orphan slot's symbol catch-up cap gap must persist before + * it is quarantined. The gap must also be observed 16 times. Defaults to + * five minutes; zero uses the observation threshold alone. + */ + catchUpCapGapMinEscalationWindowMs?: number; /** * Adopts sibling replay slots left by terminated producers. Standalone * senders default this to false; pooled clients always recover their own @@ -414,17 +427,22 @@ async function connectQwpNodeIngressInternal( ? new QwpNodeFileReplayStore(options.storeAndForward) : sessionOptions.replayStore; const reconnect = options.storeAndForward - ? { - maxAttempts: 0, - maxDurationMs: 0, - ...sessionOptions.reconnect, - } + ? (sessionOptions.reconnect ?? {}) : sessionOptions.reconnect; + const initialConnectMode = options.storeAndForward + ? validateInitialConnectMode( + options.storeAndForward.initialConnectMode ?? + QWP_INITIAL_CONNECT_MODE.ASYNC, + ) + : undefined; const effectiveSessionOptions: QwpIngressSessionOptions = { ...sessionOptions, reconnect, replayStore, backgroundStoreAndForward: options.storeAndForward !== undefined, + initialConnectMode, + catchUpCapGapMinEscalationWindowMs: + options.storeAndForward?.catchUpCapGapMinEscalationWindowMs, durableAckKeepaliveMs: options.requestDurableAck ? (sessionOptions.durableAckKeepaliveMs ?? 200) : sessionOptions.durableAckKeepaliveMs, @@ -639,6 +657,9 @@ function createNodeOrphanDrainer( ...storeAndForward, directory, drainOrphans: false, + // Orphan adoption is always non-blocking. Terminal endpoint-policy + // failures and cap-gap quarantine are selected below. + initialConnectMode: QWP_INITIAL_CONNECT_MODE.ASYNC, }, }, orphanIngressSessionOptions(sessionOptions), @@ -662,6 +683,8 @@ function orphanIngressSessionOptions( }, replayStore: undefined, backgroundStoreAndForward: undefined, + initialConnectMode: undefined, + orphanStoreAndForward: true, onResponse: undefined, onDurableAck: undefined, onProgress: undefined, @@ -675,3 +698,18 @@ function parseCanonicalSenderSlot(name: string): number | undefined { const index = Number(match[1]); return Number.isSafeInteger(index) ? index : undefined; } + +function validateInitialConnectMode( + value: QwpInitialConnectMode, +): QwpInitialConnectMode { + if ( + value !== QWP_INITIAL_CONNECT_MODE.OFF && + value !== QWP_INITIAL_CONNECT_MODE.SYNC && + value !== QWP_INITIAL_CONNECT_MODE.ASYNC + ) { + throw new RangeError( + "store-and-forward initialConnectMode must be 'off', 'sync', or 'async'", + ); + } + return value; +} diff --git a/src/qwp/transport.ts b/src/qwp/transport.ts index d68c70c..42e36bc 100644 --- a/src/qwp/transport.ts +++ b/src/qwp/transport.ts @@ -196,6 +196,22 @@ export interface QwpReconnectEvent { readonly cause?: unknown; } +/** + * Initial connection policy for a persistent store-and-forward ingress + * session. Browser and memory-only reconnect transports do not use it. + */ +export const QWP_INITIAL_CONNECT_MODE = { + /** Try once on the caller and fail immediately. */ + OFF: "off", + /** Retry on the caller within the configured reconnect budget. */ + SYNC: "sync", + /** Return immediately and connect on the background replay loop. */ + ASYNC: "async", +} as const; + +export type QwpInitialConnectMode = + (typeof QWP_INITIAL_CONNECT_MODE)[keyof typeof QWP_INITIAL_CONNECT_MODE]; + export interface QwpReconnectOptions { /** Maximum connection sweeps per outage. Defaults to 3; zero is unlimited. */ maxAttempts?: number; diff --git a/src/sender.ts b/src/sender.ts index 1dd30df..ef6a97b 100644 --- a/src/sender.ts +++ b/src/sender.ts @@ -551,9 +551,31 @@ function createConfiguredQwpSender( } const authorization = configuredWebSocket.authorization ?? qwpAuthorization(options); + if ( + (options.initial_connect_retry !== undefined || + options.catch_up_cap_gap_min_escalation_window_millis !== undefined) && + !configuredWebSocket.storeAndForward + ) { + throw new Error( + "initial_connect_retry and catch_up_cap_gap_min_escalation_window_millis require qwp.webSocket.storeAndForward", + ); + } + const storeAndForward = configuredWebSocket.storeAndForward + ? { + ...configuredWebSocket.storeAndForward, + initialConnectMode: + options.initial_connect_retry ?? + configuredWebSocket.storeAndForward.initialConnectMode, + catchUpCapGapMinEscalationWindowMs: + options.catch_up_cap_gap_min_escalation_window_millis ?? + configuredWebSocket.storeAndForward + .catchUpCapGapMinEscalationWindowMs, + } + : undefined; return createQwpNodeSender( { ...configuredWebSocket, + storeAndForward, url: `${options.protocol}://${options.host}:${options.port}${QWP_INGRESS_PATH}`, agent, authorization, diff --git a/test/options.test.ts b/test/options.test.ts index 0d3b6b2..2332f38 100644 --- a/test/options.test.ts +++ b/test/options.test.ts @@ -853,6 +853,59 @@ describe("Configuration string parser suite", function () { ); }); + it("parses initial_connect_retry only for QWP WebSocket", async function () { + await expect( + SenderOptions.fromConfig("ws::addr=host:9000;initial_connect_retry=off;"), + ).resolves.toMatchObject({ initial_connect_retry: "off" }); + await expect( + SenderOptions.fromConfig( + "wss::addr=host:9000;initial_connect_retry=sync;", + ), + ).resolves.toMatchObject({ initial_connect_retry: "sync" }); + await expect( + SenderOptions.fromConfig( + "ws::addr=host:9000;initial_connect_retry=async;", + ), + ).resolves.toMatchObject({ initial_connect_retry: "async" }); + await expect( + SenderOptions.fromConfig("ws::addr=host:9000;initial_connect_retry=on;"), + ).resolves.toMatchObject({ initial_connect_retry: "sync" }); + await expect( + SenderOptions.fromConfig( + "http::addr=host:9000;initial_connect_retry=sync;", + ), + ).rejects.toThrow( + "initial_connect_retry is only supported for QWP ws/wss transport", + ); + await expect( + SenderOptions.fromConfig( + "ws::addr=host:9000;initial_connect_retry=eventually;", + ), + ).rejects.toThrow("Invalid initial_connect_retry"); + }); + + it("parses orphan catch-up cap-gap dwell only for QWP WebSocket", async function () { + await expect( + SenderOptions.fromConfig( + "ws::addr=host:9000;catch_up_cap_gap_min_escalation_window_millis=300000;", + ), + ).resolves.toMatchObject({ + catch_up_cap_gap_min_escalation_window_millis: 300_000, + }); + await expect( + SenderOptions.fromConfig( + "ws::addr=host:9000;catch_up_cap_gap_min_escalation_window_millis=-1;", + ), + ).rejects.toThrow("Invalid catch-up cap-gap minimum escalation window"); + await expect( + SenderOptions.fromConfig( + "http::addr=host:9000;catch_up_cap_gap_min_escalation_window_millis=1;", + ), + ).rejects.toThrow( + "catch_up_cap_gap_min_escalation_window_millis is only supported for QWP ws/wss transport", + ); + }); + it("can parse auto_flush_interval config", async function () { let options = await SenderOptions.fromConfig( "http::addr=host:9000;protocol_version=2;auto_flush_interval=30", diff --git a/test/qwp/public-api-contract.ts b/test/qwp/public-api-contract.ts index 6f861c0..1777f1c 100644 --- a/test/qwp/public-api-contract.ts +++ b/test/qwp/public-api-contract.ts @@ -148,7 +148,11 @@ const nodeEgressOptionsContract: QwpNodeEgressOptions = { const qwpExtraOptionsContract: QwpExtraOptions = { webSocket: { requestDurableAck: true, - storeAndForward: { directory: "/tmp/qwp-public-api-contract" }, + storeAndForward: { + directory: "/tmp/qwp-public-api-contract", + initialConnectMode: "sync", + catchUpCapGapMinEscalationWindowMs: 300_000, + }, }, sender: { transactional: true, diff --git a/test/qwp/reconnect.test.ts b/test/qwp/reconnect.test.ts index 92376ec..8b58375 100644 --- a/test/qwp/reconnect.test.ts +++ b/test/qwp/reconnect.test.ts @@ -421,6 +421,63 @@ describe("QWP endpoint failover", () => { }); describe("QWP ingress reconnect and replay", () => { + it("supports fail-fast and bounded blocking persistent startup", async () => { + const failFastStore = new TrackingReplayStore(); + let failFastCalls = 0; + await expect( + QwpIngressSession.connect( + async () => { + failFastCalls++; + throw new QwpUpgradeError("offline", { + kind: QWP_UPGRADE_ERROR_KIND.TRANSPORT, + retryable: true, + tryNextEndpoint: true, + }); + }, + { + backgroundStoreAndForward: true, + initialConnectMode: "off", + reconnect: { + maxAttempts: 5, + initialBackoffMs: 0, + maxBackoffMs: 0, + }, + replayStore: failFastStore, + }, + ), + ).rejects.toThrow("offline"); + expect(failFastCalls).toBe(1); + + const connected = new FakeConnection("primary"); + const synchronousStore = new TrackingReplayStore(); + let synchronousCalls = 0; + const session = await QwpIngressSession.connect( + async () => { + if (synchronousCalls++ === 0) { + throw new QwpUpgradeError("starting", { + kind: QWP_UPGRADE_ERROR_KIND.TRANSPORT, + retryable: true, + tryNextEndpoint: true, + }); + } + return connected; + }, + { + backgroundStoreAndForward: true, + initialConnectMode: "sync", + reconnect: { + maxAttempts: 2, + initialBackoffMs: 0, + maxBackoffMs: 0, + }, + replayStore: synchronousStore, + }, + ); + expect(synchronousCalls).toBe(2); + expect(session.handshake).toEqual({ qwpVersion: 1 }); + await session.close(); + }); + it("publishes while initially offline and drains after a background connection", async () => { const connection = new FakeConnection("primary"); const replayStore = new TrackingReplayStore(); @@ -486,6 +543,154 @@ describe("QWP ingress reconnect and replay", () => { await session.close(); }); + it("keeps an asynchronous initial authentication rejection terminal", async () => { + const replayStore = new TrackingReplayStore(); + let factoryCalls = 0; + const session = await QwpIngressSession.connect( + async () => { + factoryCalls++; + throw new QwpUpgradeError("unauthorized", { + kind: QWP_UPGRADE_ERROR_KIND.AUTHENTICATION, + retryable: false, + tryNextEndpoint: false, + }); + }, + { + backgroundStoreAndForward: true, + initialConnectMode: "async", + reconnect: { + maxAttempts: 0, + maxDurationMs: 0, + initialBackoffMs: 0, + maxBackoffMs: 0, + }, + replayStore, + }, + ); + await session.closed; + await vi.waitFor(() => + expect(session.metrics.lastError?.message).toBe("unauthorized"), + ); + expect(factoryCalls).toBe(1); + await session.close(); + }); + + it("retries endpoint-policy failures forever after foreground SF connected once", async () => { + const first = new FakeConnection("primary"); + const replacement = new FakeConnection("primary"); + const replayStore = new TrackingReplayStore(); + let factoryCalls = 0; + const session = await QwpIngressSession.connect( + async () => { + factoryCalls++; + if (factoryCalls === 1) return first; + if (factoryCalls === 2) { + throw new QwpUpgradeError("credentials are rotating", { + kind: QWP_UPGRADE_ERROR_KIND.AUTHENTICATION, + retryable: false, + tryNextEndpoint: false, + }); + } + return replacement; + }, + { + backgroundStoreAndForward: true, + initialConnectMode: "off", + reconnect: { + // This bounds initial SYNC/non-SF reconnects, but steady foreground + // SF recovery must keep owning the durable replay record. + maxAttempts: 1, + initialBackoffMs: 0, + maxBackoffMs: 0, + }, + replayStore, + }, + ); + + await session.publishFrame(Uint8Array.of(7)); + await vi.waitFor(() => expect(first.sent).toEqual([Uint8Array.of(7)])); + first.drop(); + await vi.waitFor(() => { + expect(factoryCalls).toBe(3); + expect(replacement.sent).toEqual([Uint8Array.of(7)]); + }); + replacement.receive(ingressResponse(QWP_STATUS.OK, 0n)); + await vi.waitFor(() => expect(replayStore.records.size).toBe(0)); + await session.close(); + }); + + it("quarantines only orphan symbol catch-up cap gaps after count and dwell", async () => { + const foregroundStore = new FailOnceDictionaryReplayStore(); + foregroundStore.symbols.push("x".repeat(64)); + foregroundStore.records.set(0n, Uint8Array.of(1)); + let foregroundCalls = 0; + let recovered!: FakeConnection; + const foreground = await QwpIngressSession.connect( + async () => { + foregroundCalls++; + const cap = foregroundCalls <= 16 ? 16 : 1024; + const candidate = new FakeConnection("primary", { + qwpVersion: 1, + maxBatchSizeBytes: cap, + }); + if (cap === 1024) recovered = candidate; + return candidate; + }, + { + backgroundStoreAndForward: true, + // A blocking startup returns after its first successful WebSocket + // connection, even when recovered dictionary catch-up must move to + // the unbounded foreground replay loop. + initialConnectMode: "sync", + catchUpCapGapMinEscalationWindowMs: 0, + reconnect: { + initialBackoffMs: 0, + maxBackoffMs: 0, + }, + replayStore: foregroundStore, + }, + ); + await vi.waitFor(() => { + expect(foregroundCalls).toBe(17); + expect(recovered.sent).toHaveLength(2); + }); + expect(foreground.metrics.lastError).toBeUndefined(); + await foreground.close(); + + const orphanStore = new FailOnceDictionaryReplayStore(); + orphanStore.symbols.push("x".repeat(64)); + orphanStore.records.set(0n, Uint8Array.of(1)); + let orphanCalls = 0; + const orphan = await QwpIngressSession.connect( + async () => { + orphanCalls++; + return new FakeConnection("primary", { + qwpVersion: 1, + maxBatchSizeBytes: 16, + }); + }, + { + backgroundStoreAndForward: true, + initialConnectMode: "async", + orphanStoreAndForward: true, + catchUpCapGapMinEscalationWindowMs: 0, + reconnect: { + initialBackoffMs: 0, + maxBackoffMs: 0, + }, + replayStore: orphanStore, + }, + ); + await orphan.closed; + await vi.waitFor(() => + expect(orphan.metrics.lastError?.message).toMatch( + /attempt=16\/16.*data must be resent/, + ), + ); + expect(orphanCalls).toBe(16); + await orphan.close(); + }); + it("preserves durable dictionary IDs after frame journal backpressure", async () => { const replayStore = new FailOnceDictionaryReplayStore(); const session = await QwpIngressSession.connect( diff --git a/test/qwp/sender-node-integration.test.ts b/test/qwp/sender-node-integration.test.ts index 9366b13..139adcd 100644 --- a/test/qwp/sender-node-integration.test.ts +++ b/test/qwp/sender-node-integration.test.ts @@ -1,4 +1,7 @@ +import { mkdtemp, rm } from "node:fs/promises"; import type { AddressInfo } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { WebSocketServer } from "ws"; import { afterEach, describe, expect, it, vi } from "vitest"; import { Sender } from "../../src"; @@ -33,6 +36,44 @@ describe("Sender QWP integration", () => { server = undefined; }); + it("applies fail-fast persistent startup from the configuration string", async () => { + const reservation = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + await new Promise((resolve, reject) => { + reservation.once("listening", resolve); + reservation.once("error", reject); + }); + const port = (reservation.address() as AddressInfo).port; + await new Promise((resolve, reject) => + reservation.close((error) => (error ? reject(error) : resolve())), + ); + const directory = await mkdtemp(join(tmpdir(), "qwp-sender-startup-")); + const sender = await Sender.fromConfig( + `ws::addr=127.0.0.1:${port};initial_connect_retry=off`, + { + qwp: { + webSocket: { + connectTimeoutMs: 100, + storeAndForward: { directory }, + }, + session: { + reconnect: { + maxAttempts: 0, + maxDurationMs: 0, + initialBackoffMs: 0, + maxBackoffMs: 0, + }, + }, + }, + }, + ); + try { + await expect(sender.connect()).rejects.toThrow(); + } finally { + await sender.close().catch(() => undefined); + await rm(directory, { recursive: true, force: true }); + } + }); + it("uses ws:: configuration, bearer authentication, and fluent rows", async () => { const frames: Uint8Array[] = []; let acknowledge: (() => void) | undefined; From 806e7a0297ba2f6b41f6c433ae1c4984addfd2b1 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 18 Aug 2026 17:01:56 +0100 Subject: [PATCH 048/265] fix(qwp): retire drained symbol dictionaries --- QWP.md | 17 ++++---- src/qwp-node/file-replay-store.ts | 48 ++++++++++++++++++--- test/qwp/reconnect.test.ts | 70 ++++++++++++++++++++++++++++--- 3 files changed, 116 insertions(+), 19 deletions(-) diff --git a/QWP.md b/QWP.md index 2358a35..a19964f 100644 --- a/QWP.md +++ b/QWP.md @@ -142,13 +142,16 @@ not hold the journal mutation queue, so ACK cleanup can continue. Direct users o `QwpNodeFileReplayStore` can inspect `metrics` for pending checkpoint work, checkpoints, checkpoint failures, active waiters, stalls, and timeouts. -The persisted symbol dictionary is lifetime-monotonic and cannot be reclaimed by an -ACK. It counts toward the `maxBytes` target, but the journal preserves up to 32 MiB -(or the configured target when smaller) for live frame records if dictionary growth -uses all remaining headroom. Dictionary persistence itself is never rejected by the -target, so actual disk usage can exceed it by the non-reclaimable dictionary -overshoot. Frame growth beyond the liveness allowance remains backpressured until -ACK trimming frees record files. +The persisted symbol dictionary is monotonic for one open journal generation and +cannot be reclaimed by an ACK alone. It counts toward the `maxBytes` target, but the +journal preserves up to 32 MiB (or the configured target when smaller) for live frame +records if dictionary growth uses all remaining headroom. Dictionary persistence +itself is never rejected by the target, so actual disk usage can exceed it by the +current dictionary overshoot. Frame growth beyond the liveness allowance remains +backpressured until ACK trimming frees record files. Once every frame is acknowledged, +`close()` removes the dictionary under the journal lock; the next clean start uses a +fresh symbol-ID space. A partially drained close retains the dictionary required by +the surviving frames. The journal takes an exclusive lock when it is loaded and holds it until the sender or session closes. A second live process using the same directory fails with diff --git a/src/qwp-node/file-replay-store.ts b/src/qwp-node/file-replay-store.ts index 25cb9b9..7b360b1 100644 --- a/src/qwp-node/file-replay-store.ts +++ b/src/qwp-node/file-replay-store.ts @@ -34,7 +34,7 @@ const LOCK_RECOVERY_FILE = "recovery.json"; const ABANDONED_LOCK_PREFIX = ".qwp.lock.abandoned-"; // The file-per-frame journal has no fixed segment working set. Preserve two // default-sized QWP batches instead, mirroring Java's active+spare liveness -// floor when the lifetime-monotonic dictionary consumes the configured cap. +// floor when the current dictionary generation consumes the configured cap. const DEFAULT_LIVE_FRAME_BYTES = 2 * 16 * 1024 * 1024; const DEFAULT_CHECKPOINT_INTERVAL_MS = 5_000; const DEFAULT_APPEND_DEADLINE_MS = 30_000; @@ -82,8 +82,9 @@ export interface QwpNodeFileReplayStoreOptions { directory: string; /** * Target maximum journal size including record headers and symbol metadata. - * Defaults to 1 GiB. The non-reclaimable symbol dictionary may exceed this - * target so it cannot permanently consume the journal's live frame budget. + * Defaults to 1 GiB. The current symbol dictionary may exceed this target so + * it cannot consume the journal's live frame budget before a drained close + * retires that dictionary generation. */ maxBytes?: number; /** @@ -510,6 +511,7 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { await this.checkpointDirty(); } if (this.checkpointFailure) throw this.checkpointFailure; + await this.retireDrainedDictionary(); } catch (error) { failure = error; } @@ -719,6 +721,39 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { } } + /** + * Retires the dictionary generation only after every operation has settled + * and no replay frame remains. Doing this in acknowledgeThrough() would be + * unsafe: an ACK may arrive after a new dictionary suffix is persisted but + * before the frame that references it is appended. + */ + private async retireDrainedDictionary(): Promise { + if ( + !this.loaded || + this.records.size !== 0 || + this.dictionaryFileSize === 0 + ) { + return; + } + const path = join(this.directory, DICTIONARY_FILE); + try { + await ignoreMissing(unlink(path)); + if (this.durability !== QWP_SF_DURABILITY.MEMORY) { + await syncDirectory(this.directory); + } + } catch (error) { + throw new QwpReplayStoreError( + `could not retire fully drained QWP symbol dictionary [file=${path}]`, + error, + ); + } + this.totalBytes -= this.dictionaryFileSize; + this.dictionaryFileSize = 0; + this.dictionaryDirty = false; + this.symbols.length = 0; + this.symbolValues.clear(); + } + private enqueue(operation: () => Promise): Promise { const result = this.operationTail.then(operation); this.operationTail = result.then( @@ -964,9 +999,10 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { } this.dictionaryFileSize = offset; this.totalBytes += offset; - // Dictionary bytes are lifetime-monotonic and ACK trimming cannot reclaim - // them. Loading a valid journal above the target is therefore safe; frame - // appends remain backpressured except for the bounded liveness floor. + // Dictionary bytes are generation-monotonic and ACK trimming cannot + // reclaim them while the store remains open. A fully drained close retires + // the generation. Loading a valid journal above the target is therefore + // safe; frame appends retain the bounded liveness floor until then. } private assertReady(): void { diff --git a/test/qwp/reconnect.test.ts b/test/qwp/reconnect.test.ts index 8b58375..14a8e54 100644 --- a/test/qwp/reconnect.test.ts +++ b/test/qwp/reconnect.test.ts @@ -1327,7 +1327,7 @@ describe("QWP ingress reconnect and replay", () => { await session.close(); }); - it("recovers a persisted Node dictionary before replay and continues its IDs", async () => { + it("continues recovered dictionary IDs until a drained close retires them", async () => { const directory = await createTemporaryDirectory(); const dictionary = new QwpSymbolDictionary(); const seededTable = new QwpTableBuffer("trades"); @@ -1379,12 +1379,12 @@ describe("QWP ingress reconnect and replay", () => { const verify = new QwpNodeFileReplayStore({ directory }); await expect(verify.load()).resolves.toEqual([]); - await expect(verify.loadSymbolDictionary()).resolves.toEqual([ - "ETH-USD", - "BTC-USD", - "SOL-USD", - ]); + await expect(verify.loadSymbolDictionary()).resolves.toEqual([]); + await expect( + verify.appendSymbolDictionary(0, ["BTC-USD"]), + ).resolves.toBeUndefined(); await verify.close(); + expect(await readdir(directory)).toEqual([]); await rm(directory, { recursive: true, force: true }); }); @@ -1826,6 +1826,62 @@ describe("QWP Node file replay store", () => { await third.close(); }); + it.each([ + QWP_SF_DURABILITY.APPEND, + QWP_SF_DURABILITY.PERIODIC, + QWP_SF_DURABILITY.MEMORY, + ])( + "retires a fully drained %s dictionary generation on close", + async (durability) => { + const directory = await trackedDirectory(); + const first = new QwpNodeFileReplayStore({ directory, durability }); + await first.load(); + await first.appendSymbolDictionary(0, ["ETH-USD"]); + await first.append({ frameSequence: 0n, payload: Uint8Array.of(1) }); + await first.acknowledgeThrough(0n); + + // Keep the generation intact while the store is open. An ACK may race + // between this suffix and the frame that will reference it. + await first.appendSymbolDictionary(1, ["BTC-USD"]); + await expect(first.loadSymbolDictionary()).resolves.toEqual([ + "ETH-USD", + "BTC-USD", + ]); + expect(await readdir(directory)).toContain("symbols.qwpdict"); + await first.close(); + expect(await readdir(directory)).toEqual([]); + + const second = new QwpNodeFileReplayStore({ directory, durability }); + await expect(second.load()).resolves.toEqual([]); + await expect(second.loadSymbolDictionary()).resolves.toEqual([]); + await expect( + second.appendSymbolDictionary(0, ["BTC-USD"]), + ).resolves.toBeUndefined(); + await second.close(); + expect(await readdir(directory)).toEqual([]); + }, + ); + + it("retains the dictionary when a close leaves replay frames behind", async () => { + const directory = await trackedDirectory(); + const first = new QwpNodeFileReplayStore({ directory }); + await first.load(); + await first.appendSymbolDictionary(0, ["ETH-USD"]); + await first.append({ frameSequence: 0n, payload: Uint8Array.of(1) }); + await first.append({ frameSequence: 1n, payload: Uint8Array.of(2) }); + await first.acknowledgeThrough(0n); + await first.close(); + + const second = new QwpNodeFileReplayStore({ directory }); + await expect(second.load()).resolves.toEqual([ + { frameSequence: 1n, payload: Uint8Array.of(2) }, + ]); + await expect(second.loadSymbolDictionary()).resolves.toEqual(["ETH-USD"]); + await second.acknowledgeThrough(1n); + await second.close(); + expect(await readdir(directory)).toEqual([]); + }); + it("holds an exclusive directory lock for the store lifetime", async () => { const directory = await trackedDirectory(); const first = new QwpNodeFileReplayStore({ directory }); @@ -1894,6 +1950,7 @@ describe("QWP Node file replay store", () => { const first = new QwpNodeFileReplayStore({ directory }); await first.load(); await first.appendSymbolDictionary(0, ["ETH-USD", "BTC-USD"]); + await first.append({ frameSequence: 0n, payload: Uint8Array.of(1) }); await first.close(); await writeFile( join(directory, "symbols.qwpdict"), @@ -1917,6 +1974,7 @@ describe("QWP Node file replay store", () => { "BTC-USD", "SOL-USD", ]); + await verify.acknowledgeThrough(0n); await verify.close(); }); From 209c7e813d0484f469ef21d9594aa8d0c898211e Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 18 Aug 2026 17:21:24 +0100 Subject: [PATCH 049/265] feat(qwp): bound egress result batches --- QWP.md | 20 ++- src/qwp/browser.ts | 23 +++- src/qwp/core/constants.ts | 2 + src/qwp/egress-session.ts | 128 ++++++++++++++++-- src/qwp/internal/egress-limits.ts | 17 +++ .../reconnecting-egress-connection.ts | 13 +- src/qwp/node.ts | 21 ++- test/qwp/browser.e2e.ts | 7 + test/qwp/egress.test.ts | 81 +++++++++++ test/qwp/public-api-contract.ts | 3 + test/qwp/public-api.test.ts | 2 + test/qwp/reconnect.test.ts | 61 +++++++++ test/qwp/session.test.ts | 40 ++++++ 13 files changed, 392 insertions(+), 26 deletions(-) create mode 100644 src/qwp/internal/egress-limits.ts diff --git a/QWP.md b/QWP.md index a19964f..de52f13 100644 --- a/QWP.md +++ b/QWP.md @@ -472,8 +472,9 @@ const session = await connectQwpNodeEgress( authorization: `Bearer ${token}`, compression: "zstd", compressionLevel: 3, + maxBatchRows: 4096, }, - { queryTimeoutMs: 30_000 }, + { queryTimeoutMs: 30_000, bufferPoolSize: 4 }, ); try { @@ -563,6 +564,16 @@ server read-ahead in Node.js and browsers. Set a session-level `initialCredit` t the default, override it per query, or explicitly set zero for legacy unbounded streaming. Set `autoCredit: false` and call `query.grantCredit()` for manual control. +Materialized `query()` results also use a client-side decoded-batch pool with four +slots by default. Set the session-level `bufferPoolSize` to tune this bound. Once the +pool fills, decoding pauses until iteration requests another batch; callers must +consume a multi-batch SELECT before awaiting its terminal `completion`. This bound is +independent of QWP credit, so `initialCredit: 0` no longer permits an unbounded queue +of materialized JavaScript value arrays. Protocol credit remains the stronger +end-to-end bound, particularly in browsers where the WebSocket implementation may +buffer raw frames before JavaScript reads them. `queryViews()` already has a single +reusable decoded batch and does not consume materialized-pool slots. + A session `queryTimeoutMs` supplies the default deadline; per-query `timeoutMs` overrides it, and zero disables it. Expiry rejects iteration and `completion` with `QwpEgressQueryTimeoutError`, sends QWP `CANCEL`, and drains the terminal response @@ -586,6 +597,12 @@ operator-forced level in the existing egress `SERVER_INFO` message. Check parameter and safely remain raw. The decoder handles raw and Zstd batches in both runtimes. +Set transport-level `maxBatchRows` from 1 through 1,048,576 to ask QuestDB for +smaller `RESULT_BATCH` messages. The server clamps the request to its hard cap. Node +sends `X-QWP-Max-Batch-Rows`; browsers use the `qwp_max_batch_rows` URL parameter, +which requires a server that supports browser QWP negotiation. Older servers ignore +the browser parameter and keep their configured batch size. + Egress reconnect never silently resumes a partially consumed result. Configure `onReplayReset` to opt into at-least-once query re-execution, discard any rows from the previous attempt in that callback, and rebuild downstream state. Without that @@ -785,6 +802,7 @@ acknowledgement, and persistent replay—but uses runtime-specific connection fa | Query parameters | `session.query(sql, { binds })` | | Materialized result batches | `for await (const batch of query)` | | Reusable result views | `session.queryViews(sql, onBatch)` | +| Egress row/buffer bounds | `maxBatchRows` and session `bufferPoolSize` | Do not translate Java threading assumptions directly: callbacks, WebSocket delivery, and iteration all share the JavaScript event loop. diff --git a/src/qwp/browser.ts b/src/qwp/browser.ts index 6e40fe5..7af7d76 100644 --- a/src/qwp/browser.ts +++ b/src/qwp/browser.ts @@ -8,6 +8,7 @@ import { } from "./internal/websocket-connection"; import { createQwpFailoverConnectionFactory } from "./internal/failover"; import { createQwpEgressFailoverConnectionFactory } from "./internal/egress-routing"; +import { validateQwpMaxBatchRows } from "./internal/egress-limits"; import { addQwpDurableAckWebSocketProtocol, decodeQwpIngressServerInfo, @@ -295,6 +296,8 @@ export interface QwpBrowserEgressOptions compression?: QwpEgressCompression; /** Zstd level hint. Must be between 1 and 22. */ compressionLevel?: number; + /** Requests a server-side RESULT_BATCH row cap. */ + maxBatchRows?: number; } /** Browser configuration for a combined pooled QWP ingress/egress client. */ @@ -536,10 +539,22 @@ function connectQwpBrowserEgressEndpoint( compression, options.compressionLevel ?? 1, ); - const requestEndpoint = - acceptEncoding === undefined - ? endpoint - : browserNegotiationUrl(endpoint, "qwp_accept_encoding", acceptEncoding); + const maxBatchRows = validateQwpMaxBatchRows(options.maxBatchRows); + let requestEndpoint: string | URL = endpoint; + if (acceptEncoding !== undefined) { + requestEndpoint = browserNegotiationUrl( + requestEndpoint, + "qwp_accept_encoding", + acceptEncoding, + ); + } + if (maxBatchRows !== undefined) { + requestEndpoint = browserNegotiationUrl( + requestEndpoint, + "qwp_max_batch_rows", + String(maxBatchRows), + ); + } return connectQwpBrowserEndpoint( options, endpoint, diff --git a/src/qwp/core/constants.ts b/src/qwp/core/constants.ts index b9694d8..619493d 100644 --- a/src/qwp/core/constants.ts +++ b/src/qwp/core/constants.ts @@ -96,6 +96,8 @@ export const QWP_MAX_TABLE_NAME_LENGTH = 127; export const QWP_MAX_ROWS_PER_TABLE = 1_000_000; export const QWP_MAX_SYMBOL_DICTIONARY_SIZE = 1_000_000; export const QWP_MAX_ERROR_MESSAGE_LENGTH = 1024; +/** Largest client-requested egress RESULT_BATCH row cap. */ +export const QWP_MAX_BATCH_ROWS_UPPER_BOUND = 1_048_576; export const QWP_INGRESS_PATH = "/write/v4"; export const QWP_EGRESS_PATH = "/read/v1"; diff --git a/src/qwp/egress-session.ts b/src/qwp/egress-session.ts index 3202f76..c49e296 100644 --- a/src/qwp/egress-session.ts +++ b/src/qwp/egress-session.ts @@ -33,6 +33,8 @@ export interface QwpEgressSessionOptions { serverInfoTimeoutMs?: number; /** Default per-query send-ahead credit. Defaults to 256 KiB; zero is unbounded. */ initialCredit?: number | bigint; + /** Maximum decoded materialized batches waiting for a consumer. Defaults to 4. */ + bufferPoolSize?: number; /** Default per-query deadline. Zero or undefined disables query deadlines. */ queryTimeoutMs?: number; /** Maximum wait for a terminal response after CANCEL. Defaults to 5 seconds. */ @@ -70,12 +72,15 @@ export interface QwpEgressQueryOptions { interface QwpValidatedEgressSessionOptions { readonly serverInfoTimeoutMs: number; readonly initialCredit: number | bigint; + readonly bufferPoolSize: number; readonly queryTimeoutMs: number; readonly cancelDrainTimeoutMs: number; } /** Default bounded send-ahead window used by high-level egress queries. */ export const QWP_DEFAULT_EGRESS_INITIAL_CREDIT = 256 * 1024; +/** Default decoded materialized-result queue depth, matching the Java client. */ +export const QWP_DEFAULT_EGRESS_BUFFER_POOL_SIZE = 4; const MAX_UINT64 = 0xffffffffffffffffn; @@ -105,6 +110,9 @@ function validateEgressSessionOptions( options.initialCredit ?? QWP_DEFAULT_EGRESS_INITIAL_CREDIT, "initialCredit", ), + bufferPoolSize: validateBufferPoolSize( + options.bufferPoolSize ?? QWP_DEFAULT_EGRESS_BUFFER_POOL_SIZE, + ), queryTimeoutMs: validateOptionalTimeout( options.queryTimeoutMs, "queryTimeoutMs", @@ -116,6 +124,13 @@ function validateEgressSessionOptions( }; } +function validateBufferPoolSize(value: number): number { + if (!Number.isSafeInteger(value) || value < 1) { + throw new RangeError("bufferPoolSize must be a positive safe integer"); + } + return value; +} + function validateInitialCredit( value: number | bigint, name: string, @@ -211,6 +226,8 @@ interface QwpQueuedResultBatch { readonly creditBytes: number; } +type QwpBatchReservation = "reserved" | "retired" | "reset"; + /** Control handle returned by queryViews(). */ export interface QwpEgressViewQuery { readonly requestId: bigint; @@ -234,6 +251,11 @@ export class QwpEgressQuery implements AsyncIterable { private readonly resolveCompletion: (value: QwpQueryCompletion) => void; private readonly rejectCompletion: (error: unknown) => void; private deliveredCreditBytes = 0; + private bufferedBatchCount = 0; + private bufferGeneration = 0; + private readonly bufferWaiters = new Set<() => void>(); + private viewInProgress = false; + private readonly viewReleaseWaiters = new Set<() => void>(); private terminal = false; private timeoutTimer?: ReturnType; readonly completion: Promise; @@ -243,6 +265,7 @@ export class QwpEgressQuery implements AsyncIterable { private readonly control: QwpEgressQueryControl, private readonly creditEnabled: boolean, private readonly autoCredit: boolean, + private readonly bufferPoolSize: number, private readonly viewHandler?: QwpResultBatchViewHandler, ) { let resolve!: (value: QwpQueryCompletion) => void; @@ -270,6 +293,7 @@ export class QwpEgressQuery implements AsyncIterable { await this.releaseDeliveredCredit(); const result = await iterator.next(); if (result.done) return { value: undefined, done: true }; + this.releaseBufferedBatches(1); this.deliveredCreditBytes = result.value.creditBytes; return { value: result.value.batch, done: false }; }, @@ -298,12 +322,36 @@ export class QwpEgressQuery implements AsyncIterable { }, timeoutMs); } - /** @internal */ - push(batch: QwpResultBatch, creditBytes: number): void { - if (this.terminal) return; + /** @internal Waits for one decoded materialized-batch slot. */ + async reserveMaterializedBatch(): Promise { + const generation = this.bufferGeneration; + while ( + !this.terminal && + generation === this.bufferGeneration && + this.bufferedBatchCount >= this.bufferPoolSize + ) { + await new Promise((resolve) => this.bufferWaiters.add(resolve)); + } + if (this.terminal) return "retired"; + if (generation !== this.bufferGeneration) return "reset"; + this.bufferedBatchCount++; + return "reserved"; + } + + /** @internal Publishes a batch after reserveMaterializedBatch(). */ + pushReserved(batch: QwpResultBatch, creditBytes: number): void { + if (this.terminal) { + this.releaseBufferedBatches(1); + return; + } this.batches.push({ batch, creditBytes }); } + /** @internal Releases a reservation when decoding fails. */ + releaseMaterializedBatch(): void { + this.releaseBufferedBatches(1); + } + /** @internal */ async pushView( batch: QwpResultBatchView, @@ -313,6 +361,8 @@ export class QwpEgressQuery implements AsyncIterable { batch.release(); return; } + const generation = this.bufferGeneration; + this.viewInProgress = true; let handlerError: Error | undefined; try { await this.viewHandler!(batch, this); @@ -320,7 +370,11 @@ export class QwpEgressQuery implements AsyncIterable { handlerError = error instanceof Error ? error : new Error(String(error)); } finally { batch.release(); + this.viewInProgress = false; + for (const resolve of this.viewReleaseWaiters) resolve(); + this.viewReleaseWaiters.clear(); } + if (generation !== this.bufferGeneration) return; if (handlerError) { await this.control .rejectView(this.requestId, handlerError) @@ -340,6 +394,7 @@ export class QwpEgressQuery implements AsyncIterable { finish(completion: QwpQueryCompletion): void { if (this.terminal) return; this.terminal = true; + this.wakeBufferWaiters(); this.clearTimeout(); this.deliveredCreditBytes = 0; this.batches.end(); @@ -350,6 +405,7 @@ export class QwpEgressQuery implements AsyncIterable { fail(error: unknown): void { if (this.terminal) return; this.terminal = true; + this.wakeBufferWaiters(); this.clearTimeout(); this.deliveredCreditBytes = 0; this.batches.fail(error); @@ -375,9 +431,16 @@ export class QwpEgressQuery implements AsyncIterable { } /** @internal */ - resetForReplay(): void { + async resetForReplay(): Promise { this.deliveredCreditBytes = 0; - this.batches.clear(); + this.bufferGeneration++; + this.releaseBufferedBatches(this.batches.clear().length); + this.wakeBufferWaiters(); + if (this.viewInProgress) { + await new Promise((resolve) => + this.viewReleaseWaiters.add(resolve), + ); + } } private clearTimeout(): void { @@ -389,12 +452,26 @@ export class QwpEgressQuery implements AsyncIterable { private discardBufferedResults(): number { let creditBytes = this.deliveredCreditBytes; this.deliveredCreditBytes = 0; - for (const queued of this.batches.clear()) { + const dropped = this.batches.clear(); + this.releaseBufferedBatches(dropped.length); + for (const queued of dropped) { creditBytes += queued.creditBytes; } return this.creditEnabled ? creditBytes : 0; } + private releaseBufferedBatches(count: number): void { + if (count > 0) { + this.bufferedBatchCount = Math.max(0, this.bufferedBatchCount - count); + } + this.wakeBufferWaiters(); + } + + private wakeBufferWaiters(): void { + for (const resolve of this.bufferWaiters) resolve(); + this.bufferWaiters.clear(); + } + private async releaseDeliveredCredit(): Promise { const creditBytes = this.deliveredCreditBytes; this.deliveredCreditBytes = 0; @@ -424,6 +501,7 @@ export class QwpEgressSession implements QwpEgressQueryControl { private readonly serverInfoTimer: ReturnType; private readonly defaultQueryTimeoutMs: number; private readonly defaultInitialCredit: number | bigint; + private readonly bufferPoolSize: number; private readonly cancelDrainTimeoutMs: number; private readonly idleWaiters = new Set<() => void>(); private active?: QwpEgressQuery; @@ -464,6 +542,7 @@ export class QwpEgressSession implements QwpEgressQueryControl { } this.defaultQueryTimeoutMs = validated.queryTimeoutMs; this.defaultInitialCredit = validated.initialCredit; + this.bufferPoolSize = validated.bufferPoolSize; this.cancelDrainTimeoutMs = validated.cancelDrainTimeoutMs; let resolve!: (value: QwpServerInfoMessage) => void; let reject!: (error: unknown) => void; @@ -623,6 +702,7 @@ export class QwpEgressSession implements QwpEgressQueryControl { this, creditEnabled, creditEnabled && (options.autoCredit ?? true), + this.bufferPoolSize, viewHandler, ); this.decoder.resetQuerySchema(); @@ -760,11 +840,7 @@ export class QwpEgressSession implements QwpEgressQueryControl { break; case "result-batch": { const query = this.requireActive(message.requestId); - const batch = query.usesViews - ? this.decoder.decodeView(message) - : this.decoder.decode(message); if (query.retired) { - if (batch instanceof QwpResultBatchView) batch.release(); const creditBytes = query.lateBatchCredit(payload.byteLength); if (creditBytes > 0) { void this.sendWhileActive( @@ -772,10 +848,32 @@ export class QwpEgressSession implements QwpEgressQueryControl { encodeQwpCredit(message.requestId, creditBytes), ).catch(() => undefined); } - } else if (batch instanceof QwpResultBatchView) { - await query.pushView(batch, payload.byteLength); + } else if (query.usesViews) { + await query.pushView( + this.decoder.decodeView(message), + payload.byteLength, + ); } else { - query.push(batch, payload.byteLength); + const reservation = await query.reserveMaterializedBatch(); + if (reservation === "retired") { + const creditBytes = query.lateBatchCredit(payload.byteLength); + if (creditBytes > 0) { + void this.sendWhileActive( + message.requestId, + encodeQwpCredit(message.requestId, creditBytes), + ).catch(() => undefined); + } + } else if (reservation === "reserved") { + try { + query.pushReserved( + this.decoder.decode(message), + payload.byteLength, + ); + } catch (error) { + query.releaseMaterializedBatch(); + throw error; + } + } } break; } @@ -831,10 +929,10 @@ export class QwpEgressSession implements QwpEgressQueryControl { return this.active; } - private prepareConnectionReset(): void { + private async prepareConnectionReset(): Promise { + await this.active?.resetForReplay(); this.decoder.applyCacheReset(QWP_RESET_MASK_DICTIONARY); this.decoder.resetQuerySchema(); - this.active?.resetForReplay(); } private cancelAndDrain( diff --git a/src/qwp/internal/egress-limits.ts b/src/qwp/internal/egress-limits.ts new file mode 100644 index 0000000..f58c070 --- /dev/null +++ b/src/qwp/internal/egress-limits.ts @@ -0,0 +1,17 @@ +import { QWP_MAX_BATCH_ROWS_UPPER_BOUND } from "../core"; + +export function validateQwpMaxBatchRows( + value: number | undefined, +): number | undefined { + if (value === undefined) return undefined; + if ( + !Number.isSafeInteger(value) || + value < 1 || + value > QWP_MAX_BATCH_ROWS_UPPER_BOUND + ) { + throw new RangeError( + `maxBatchRows must be an integer between 1 and ${QWP_MAX_BATCH_ROWS_UPPER_BOUND}`, + ); + } + return value; +} diff --git a/src/qwp/internal/reconnecting-egress-connection.ts b/src/qwp/internal/reconnecting-egress-connection.ts index 3867c83..192029c 100644 --- a/src/qwp/internal/reconnecting-egress-connection.ts +++ b/src/qwp/internal/reconnecting-egress-connection.ts @@ -398,9 +398,18 @@ export class QwpReconnectingEgressConnection implements QwpBinaryConnection { previousEndpoint: string | URL | undefined, cause: unknown, ): Promise { - await this.messagesQueue.barrier(); + if (this.outboundReplay.length === 0) { + // A terminal response may already be queued. Let the bounded session + // consume it before resetting connection-scoped decoder state. + await this.messagesQueue.barrier(); + await this.onConnectionReset(); + return; + } + // An active operation will be replayed from its request. Drop raw stale + // messages before resetting the decoded queue; waiting for a barrier here + // can deadlock when that queue is deliberately at its client-side bound. + this.messagesQueue.clear(); await this.onConnectionReset(); - if (this.outboundReplay.length === 0) return; const requestId = replayRequestId(this.outboundReplay); if (!this.onReplayReset) { throw new QwpEgressReplayRequiredError(requestId); diff --git a/src/qwp/node.ts b/src/qwp/node.ts index 4a1c4cc..4d775f6 100644 --- a/src/qwp/node.ts +++ b/src/qwp/node.ts @@ -18,6 +18,7 @@ import { } from "./internal/websocket-connection"; import { createQwpFailoverConnectionFactory } from "./internal/failover"; import { createQwpEgressFailoverConnectionFactory } from "./internal/egress-routing"; +import { validateQwpMaxBatchRows } from "./internal/egress-limits"; import { QWP_INITIAL_CONNECT_MODE, QWP_UPGRADE_ERROR_KIND, @@ -223,6 +224,8 @@ export interface QwpNodeEgressOptions compression?: QwpEgressCompression; /** Zstd level hint sent to the server. Must be between 1 and 22. */ compressionLevel?: number; + /** Requests a server-side RESULT_BATCH row cap. */ + maxBatchRows?: number; } /** Node configuration for a combined pooled QWP ingress/egress client. */ @@ -240,9 +243,11 @@ function egressTransportOptions( ): QwpNodeWebSocketOptions { const compression = options.compression; const compressionLevel = options.compressionLevel ?? 1; + const maxBatchRows = validateQwpMaxBatchRows(options.maxBatchRows); const transport = { ...options }; delete transport.compression; delete transport.compressionLevel; + delete transport.maxBatchRows; delete transport.target; delete transport.zone; const preference = compression ?? "raw"; @@ -250,13 +255,21 @@ function egressTransportOptions( // Keep the low-level headers escape hatch backwards compatible unless the // typed compression option was explicitly selected. - if (compression === undefined) return transport; + if (compression === undefined && maxBatchRows === undefined) return transport; const headers = { ...transport.headers }; - for (const name of Object.keys(headers)) { - if (name.toLowerCase() === "x-qwp-accept-encoding") delete headers[name]; + if (compression !== undefined) { + for (const name of Object.keys(headers)) { + if (name.toLowerCase() === "x-qwp-accept-encoding") delete headers[name]; + } + if (acceptEncoding) headers["X-QWP-Accept-Encoding"] = acceptEncoding; + } + if (maxBatchRows !== undefined) { + for (const name of Object.keys(headers)) { + if (name.toLowerCase() === "x-qwp-max-batch-rows") delete headers[name]; + } + headers["X-QWP-Max-Batch-Rows"] = String(maxBatchRows); } - if (acceptEncoding) headers["X-QWP-Accept-Encoding"] = acceptEncoding; return { ...transport, headers }; } diff --git a/test/qwp/browser.e2e.ts b/test/qwp/browser.e2e.ts index 6e5f272..5dcd64f 100644 --- a/test/qwp/browser.e2e.ts +++ b/test/qwp/browser.e2e.ts @@ -302,6 +302,7 @@ describe("QWP in a real browser", () => { url, compression: "zstd", compressionLevel: 7, + maxBatchRows: 512, }); try { return session.negotiatedCompression; @@ -388,6 +389,7 @@ describe("QWP in a real browser", () => { url, compression: "zstd", compressionLevel: 7, + maxBatchRows: 512, }); try { return { @@ -409,6 +411,11 @@ describe("QWP in a real browser", () => { "qwp_accept_encoding", ), ).toBe("zstd;level=7,raw"); + expect( + new URL(requestedPath!, "http://localhost").searchParams.get( + "qwp_max_batch_rows", + ), + ).toBe("512"); expect(result).toEqual({ compression: { codec: "zstd", level: 3 }, level: 3, diff --git a/test/qwp/egress.test.ts b/test/qwp/egress.test.ts index 9bac146..b077aa6 100644 --- a/test/qwp/egress.test.ts +++ b/test/qwp/egress.test.ts @@ -103,6 +103,25 @@ function firstResultBatch(requestId = 0n): Uint8Array { return encodeQwpFrame(payload.toUint8Array(), RESULT_FLAGS, 1); } +function emptyResultBatch( + requestId: bigint, + batchSequence: number, +): Uint8Array { + const payload = new QwpByteWriter(); + payload.writeUint8(QWP_EGRESS_MESSAGE.RESULT_BATCH).writeBigUint64(requestId); + writeQwpVarint(payload, batchSequence); + writeQwpVarint(payload, 0); // empty dictionary delta start + writeQwpVarint(payload, 0); // empty dictionary delta count + writeQwpVarint(payload, 0); // table name + writeQwpVarint(payload, 0); // rows + if (batchSequence === 0) writeQwpVarint(payload, 0); // initial schema + return encodeQwpFrame( + payload.toUint8Array(), + QWP_FLAG_DELTA_SYMBOL_DICTIONARY, + 1, + ); +} + function resultEnd(requestId = 0n, totalRows = 3n): Uint8Array { const payload = new QwpByteWriter(); payload.writeUint8(QWP_EGRESS_MESSAGE.RESULT_END).writeBigUint64(requestId); @@ -535,6 +554,17 @@ describe("QwpEgressSession", () => { ).rejects.toThrow("initialCredit must be a non-negative safe integer"); expect(factoryCalls).toBe(0); + await expect( + QwpEgressSession.connect( + async () => { + factoryCalls++; + return new FakeConnection(); + }, + { bufferPoolSize: 0 }, + ), + ).rejects.toThrow("bufferPoolSize must be a positive safe integer"); + expect(factoryCalls).toBe(0); + await expect( QwpEgressSession.connect( async () => { @@ -786,6 +816,57 @@ describe("QwpEgressSession", () => { await unbounded.close(); }); + it("bounds decoded materialized batches when wire credit is unbounded", async () => { + const connection = new FakeConnection(); + const session = new QwpEgressSession(connection, { + initialCredit: 0, + bufferPoolSize: 2, + }); + connection.receive(serverInfo()); + const query = await session.query("select * from x"); + connection.receive(emptyResultBatch(query.requestId, 0)); + connection.receive(emptyResultBatch(query.requestId, 1)); + connection.receive(emptyResultBatch(query.requestId, 2)); + connection.receive(resultEnd(query.requestId, 0n)); + + let completed = false; + void query.completion.then(() => { + completed = true; + }); + await Promise.resolve(); + await Promise.resolve(); + expect(completed).toBe(false); + + const iterator = query[Symbol.asyncIterator](); + await expect(iterator.next()).resolves.toMatchObject({ done: false }); + await expect(query.completion).resolves.toMatchObject({ totalRows: 0n }); + await expect(iterator.next()).resolves.toMatchObject({ done: false }); + await expect(iterator.next()).resolves.toMatchObject({ done: false }); + await expect(iterator.next()).resolves.toEqual({ + value: undefined, + done: true, + }); + expect(connection.sent).toHaveLength(1); + await session.close(); + }); + + it("interrupts a materialized-buffer wait during close", async () => { + const connection = new FakeConnection(); + const session = new QwpEgressSession(connection, { + initialCredit: 0, + bufferPoolSize: 1, + }); + connection.receive(serverInfo()); + const query = await session.query("select * from x"); + connection.receive(emptyResultBatch(query.requestId, 0)); + connection.receive(emptyResultBatch(query.requestId, 1)); + + await expect(session.close()).resolves.toBeUndefined(); + await expect(query.completion).rejects.toMatchObject({ + name: "QwpEgressSessionClosedError", + }); + }); + it("uses compressed RESULT_BATCH wire bytes for automatic credit", async () => { const connection = new FakeConnection(); const session = new QwpEgressSession(connection); diff --git a/test/qwp/public-api-contract.ts b/test/qwp/public-api-contract.ts index 1777f1c..6534a56 100644 --- a/test/qwp/public-api-contract.ts +++ b/test/qwp/public-api-contract.ts @@ -128,6 +128,7 @@ const queryOptionsContract: QwpEgressQueryOptions = { const egressSessionOptionsContract: QwpEgressSessionOptions = { initialCredit: 256 * 1024, + bufferPoolSize: 4, queryTimeoutMs: 30_000, cancelDrainTimeoutMs: 5_000, }; @@ -137,12 +138,14 @@ const browserEgressOptionsContract: QwpBrowserEgressOptions = { failoverUrls: ["wss://node-2.example/read/v1"], target: "replica", zone: "eu-west-1a", + maxBatchRows: 512, }; const nodeEgressOptionsContract: QwpNodeEgressOptions = { url: "wss://node-1.example/read/v1", failoverUrls: ["wss://node-2.example/read/v1"], target: "primary", + maxBatchRows: 512, }; const qwpExtraOptionsContract: QwpExtraOptions = { diff --git a/test/qwp/public-api.test.ts b/test/qwp/public-api.test.ts index b9ab725..cf9d56d 100644 --- a/test/qwp/public-api.test.ts +++ b/test/qwp/public-api.test.ts @@ -7,6 +7,8 @@ import * as shared from "../../src/qwp"; const sharedRuntimeContract = [ "QWP_INGRESS_PROGRESS_KIND", "QWP_DEFAULT_EGRESS_INITIAL_CREDIT", + "QWP_DEFAULT_EGRESS_BUFFER_POOL_SIZE", + "QWP_MAX_BATCH_ROWS_UPPER_BOUND", "QWP_RECONNECT_EVENT_KIND", "QWP_TARGET", "QWP_UPGRADE_ERROR_KIND", diff --git a/test/qwp/reconnect.test.ts b/test/qwp/reconnect.test.ts index 14a8e54..e01acac 100644 --- a/test/qwp/reconnect.test.ts +++ b/test/qwp/reconnect.test.ts @@ -1683,6 +1683,7 @@ describe("QWP egress reconnect and replay", () => { initialBackoffMs: 0, maxBackoffMs: 0, }, + bufferPoolSize: 1, onReplayReset: (event) => resets.push(event.requestId!), }, ); @@ -1695,6 +1696,11 @@ describe("QWP egress reconnect and replay", () => { await expect(iterator.next()).resolves.toMatchObject({ done: false }); // Queue another stale prefix batch to exercise queue clearing. first.receive(emptyResultBatch(0n, 1)); + // Fill the decoded pool, then block the receive loop on one more stale + // batch. Reset must wake the waiter without publishing either batch. + first.receive(emptyResultBatch(0n, 2)); + await Promise.resolve(); + await Promise.resolve(); first.drop(); await vi.waitFor(() => expect(resets).toEqual([0n])); @@ -1713,6 +1719,61 @@ describe("QWP egress reconnect and replay", () => { await session.close(); }); + it("waits for an active reusable view before resetting it for replay", async () => { + const first = new FakeConnection("primary"); + const second = new FakeConnection("secondary"); + const connections = [first, second]; + const resets: bigint[] = []; + let releaseFirstView!: () => void; + const firstViewReleased = new Promise((resolve) => { + releaseFirstView = resolve; + }); + let viewCalls = 0; + const session = await QwpEgressSession.connect( + async () => { + const connection = connections.shift(); + if (!connection) throw new Error("no connection available"); + queueMicrotask(() => + connection.receive( + serverInfo(connection.endpoint === "primary" ? "one" : "two"), + ), + ); + return connection; + }, + { + reconnect: { + maxAttempts: 1, + initialBackoffMs: 0, + maxBackoffMs: 0, + }, + onReplayReset: (event) => resets.push(event.requestId!), + }, + ); + const query = await session.queryViews("select * from x", async () => { + viewCalls++; + if (viewCalls === 1) await firstViewReleased; + }); + first.receive(emptyResultBatch()); + await vi.waitFor(() => expect(viewCalls).toBe(1)); + + first.drop(); + await Promise.resolve(); + expect(resets).toEqual([]); + expect(second.sent).toEqual([]); + releaseFirstView(); + + await vi.waitFor(() => expect(resets).toEqual([0n])); + await vi.waitFor(() => expect(second.sent.length).toBeGreaterThan(0)); + expect(second.sent[0]).toEqual(first.sent[0]); + second.receive(emptyResultBatch()); + second.receive(resultEnd()); + await expect(query.completion).resolves.toMatchObject({ + kind: "result-end", + }); + expect(viewCalls).toBe(2); + await session.close(); + }); + it("fails rather than silently replaying an active operation without reset", async () => { const first = new FakeConnection("primary"); const second = new FakeConnection("secondary"); diff --git a/test/qwp/session.test.ts b/test/qwp/session.test.ts index cb89f25..aa27764 100644 --- a/test/qwp/session.test.ts +++ b/test/qwp/session.test.ts @@ -563,6 +563,7 @@ describe("QWP WebSocket adapters", () => { url: "ws://localhost:9000/read/v1", compression: "zstd", compressionLevel: 7, + maxBatchRows: 512, webSocketFactory: (url, protocols) => { capturedUrl = url; capturedProtocols = protocols; @@ -579,6 +580,9 @@ describe("QWP WebSocket adapters", () => { expect(new URL(capturedUrl!).searchParams.get("qwp_accept_encoding")).toBe( "zstd;level=7,raw", ); + expect(new URL(capturedUrl!).searchParams.get("qwp_max_batch_rows")).toBe( + "512", + ); expect(session.negotiatedCompression).toEqual({ codec: "zstd", level: 3, @@ -744,6 +748,7 @@ describe("QWP WebSocket adapters", () => { url: "ws://localhost:9000/read/v1", compression, compressionLevel: 5, + maxBatchRows: 512, webSocketFactory: (_url, options) => { capturedHeaders = options.headers; options.onUpgrade({ @@ -758,6 +763,7 @@ describe("QWP WebSocket adapters", () => { const session = await connecting; expect(capturedHeaders).toMatchObject({ "X-QWP-Accept-Encoding": "zstd;level=5,raw", + "X-QWP-Max-Batch-Rows": "512", }); expect(session.handshake.contentEncoding).toBe("zstd;level=5"); expect(session.negotiatedCompression).toEqual({ @@ -813,6 +819,40 @@ describe("QWP WebSocket adapters", () => { expect(factoryCalls).toBe(0); }); + it.each([0, 1_048_577, 1.5])( + "rejects invalid egress maxBatchRows %s before opening a socket", + async (maxBatchRows) => { + let factoryCalls = 0; + await expect( + connectQwpNodeEgress({ + url: "ws://localhost:9000/read/v1", + maxBatchRows, + webSocketFactory: () => { + factoryCalls++; + return asQwpSocket(new FakeWebSocket()); + }, + }), + ).rejects.toThrow( + "maxBatchRows must be an integer between 1 and 1048576", + ); + expect(factoryCalls).toBe(0); + + await expect( + connectQwpBrowserEgress({ + url: "ws://localhost:9000/read/v1", + maxBatchRows, + webSocketFactory: () => { + factoryCalls++; + return asQwpSocket(new FakeWebSocket()); + }, + }), + ).rejects.toThrow( + "maxBatchRows must be an integer between 1 and 1048576", + ); + expect(factoryCalls).toBe(0); + }, + ); + it("uses the legacy handshake defaults when optional headers are absent", async () => { const socket = new FakeWebSocket(); const connecting = connectQwpNodeWebSocket({ From c298ec766e89e6fae857a3b71357870495a642ca Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 18 Aug 2026 17:29:06 +0100 Subject: [PATCH 050/265] feat(qwp): add reusable egress row views --- QWP.md | 29 +++++- src/qwp/core/result-batch.ts | 163 ++++++++++++++++++++++++++++++++ test/qwp/browser.e2e.ts | 29 +++++- test/qwp/egress.test.ts | 87 +++++++++++++++++ test/qwp/public-api-contract.ts | 11 +++ test/qwp/public-api.test.ts | 1 + 6 files changed, 312 insertions(+), 8 deletions(-) diff --git a/QWP.md b/QWP.md index de52f13..13340a8 100644 --- a/QWP.md +++ b/QWP.md @@ -531,10 +531,31 @@ const query = await session.queryViews( await query.completion; ``` +For conventional row-major processing, the same batch also owns one reusable +`QwpResultRowView`: + +```typescript +batch.forEachRow((row) => { + if (!row.isNull(2)) { + consume(row.getLong(0), row.getSymbol(1), row.getDouble(2)); + } +}); + +// Direct indexed access uses the same flyweight. +const first = batch.row(0); +consume(first.rowIndex, first.getString(1)); +``` + +`forEachRow()` is synchronous, visits rows in index order, propagates callback +exceptions, and re-points the same row object on every iteration. Do not retain +the row object or any zero-copy value returned from it; copy the value inside the +current invocation when it must survive. Calling `batch.row(index)` also returns +that shared object, re-pointed to the requested row. + The batch, its column objects, and every `Uint8Array`/`Int32Array` returned by a -column are valid only until the callback settles. The decoder reuses those objects -and its NULL-index, symbol-ID, array-offset, and Gorilla-timestamp scratch storage -for later batches. Copy an individual byte view with `.slice()`, or call +column or row are valid only until the callback settles. The decoder reuses those +objects and its NULL-index, symbol-ID, array-offset, and Gorilla-timestamp scratch +storage for later batches. Copy an individual byte view with `.slice()`, or call `batch.materialize()` inside the callback, when data must be retained. Raw fixed-width, NULL, VARCHAR/BINARY, and array data views point into the current @@ -801,7 +822,7 @@ acknowledgement, and persistent replay—but uses runtime-specific connection fa | Store-and-forward | Node `storeAndForward`; intentionally unavailable in browsers | | Query parameters | `session.query(sql, { binds })` | | Materialized result batches | `for await (const batch of query)` | -| Reusable result views | `session.queryViews(sql, onBatch)` | +| Reusable result views | `queryViews()` with column views or `forEachRow()` row views | | Egress row/buffer bounds | `maxBatchRows` and session `bufferPoolSize` | Do not translate Java threading assumptions directly: callbacks, WebSocket delivery, diff --git a/src/qwp/core/result-batch.ts b/src/qwp/core/result-batch.ts index 77ce6db..5133f69 100644 --- a/src/qwp/core/result-batch.ts +++ b/src/qwp/core/result-batch.ts @@ -667,6 +667,142 @@ export class QwpResultColumnView { } } +/** Callback invoked by QwpResultBatchView.forEachRow(). */ +export type QwpResultRowViewCallback = (row: QwpResultRowView) => void; + +/** + * Reusable row-pinned facade over a QwpResultBatchView. + * + * The batch owns one instance and re-points it in place. It is valid only + * while the surrounding queryViews() callback is running, and must not be + * retained across forEachRow() iterations. Byte and array views returned by + * its accessors remain zero-copy and have the same lifetime. + */ +export class QwpResultRowView { + private _rowIndex = -1; + + /** @internal */ + constructor(private readonly parent: QwpResultBatchView) {} + + /** Parent batch, primarily for column metadata. */ + get batch(): QwpResultBatchView { + // Validate the shared batch before exposing it through a retained row. + void this.parent.rowCount; + return this.parent; + } + + /** Zero-based row currently pinned by this reusable view. */ + get rowIndex(): number { + void this.parent.rowCount; + return this._rowIndex; + } + + /** Re-points this flyweight at a row and returns the same instance. */ + of(rowIndex: number): this { + const rowCount = this.parent.rowCount; + if (!Number.isInteger(rowIndex) || rowIndex < 0 || rowIndex >= rowCount) { + throw new RangeError(`row index out of range: ${rowIndex}`); + } + this._rowIndex = rowIndex; + return this; + } + + isNull(columnIndex: number): boolean { + return this.column(columnIndex).isNull(this._rowIndex); + } + + get(columnIndex: number): QwpResultValue { + return this.column(columnIndex).get(this._rowIndex); + } + + getBoolean(columnIndex: number): boolean { + return this.column(columnIndex).getBoolean(this._rowIndex); + } + + getByte(columnIndex: number): number { + return this.column(columnIndex).getByte(this._rowIndex); + } + + getShort(columnIndex: number): number { + return this.column(columnIndex).getShort(this._rowIndex); + } + + getChar(columnIndex: number): string { + return this.column(columnIndex).getChar(this._rowIndex); + } + + getInt(columnIndex: number): number { + return this.column(columnIndex).getInt(this._rowIndex); + } + + getFloat(columnIndex: number): number { + return this.column(columnIndex).getFloat(this._rowIndex); + } + + getDouble(columnIndex: number): number { + return this.column(columnIndex).getDouble(this._rowIndex); + } + + getLong(columnIndex: number): bigint { + return this.column(columnIndex).getLong(this._rowIndex); + } + + /** Zero-copy UTF-8 bytes for a VARCHAR value. */ + getUtf8View(columnIndex: number): Uint8Array | null { + return this.column(columnIndex).getUtf8View(this._rowIndex); + } + + getString(columnIndex: number): string | null { + return this.column(columnIndex).getString(this._rowIndex); + } + + /** Zero-copy BINARY bytes. */ + getBinaryView(columnIndex: number): Uint8Array | null { + return this.column(columnIndex).getBinaryView(this._rowIndex); + } + + getSymbolId(columnIndex: number): number { + return this.column(columnIndex).getSymbolId(this._rowIndex); + } + + getSymbol(columnIndex: number): string | null { + return this.column(columnIndex).getSymbol(this._rowIndex); + } + + getUuidLow(columnIndex: number): bigint { + return this.column(columnIndex).getUuidLow(this._rowIndex); + } + + getUuidHigh(columnIndex: number): bigint { + return this.column(columnIndex).getUuidHigh(this._rowIndex); + } + + getLong256Word(columnIndex: number, wordIndex: number): bigint { + return this.column(columnIndex).getLong256Word(this._rowIndex, wordIndex); + } + + getDecimalUnscaled(columnIndex: number): bigint { + return this.column(columnIndex).getDecimalUnscaled(this._rowIndex); + } + + getGeohashBits(columnIndex: number): bigint { + return this.column(columnIndex).getGeohashBits(this._rowIndex); + } + + /** Zero-copy encoded ARRAY row, including its dimension header. */ + getArrayView(columnIndex: number): Uint8Array | null { + return this.column(columnIndex).getArrayView(this._rowIndex); + } + + getArrayDimensionCount(columnIndex: number): number { + return this.column(columnIndex).getArrayDimensionCount(this._rowIndex); + } + + private column(columnIndex: number): QwpResultColumnView { + return this.parent.column(columnIndex); + } +} + /** * Batch-owned reusable view delivered by QwpEgressSession.queryViews(). * Access is invalid after the callback returns. materialize() creates an @@ -681,6 +817,7 @@ export class QwpResultBatchView { private layouts: QwpResultColumnViewLayout[] = []; private readonly columnViews: QwpResultColumnView[] = []; private readonly columnViewPool: QwpResultColumnView[] = []; + private rowView?: QwpResultRowView; get valid(): boolean { return this.active; @@ -729,6 +866,28 @@ export class QwpResultBatchView { return this.column(columnIndex).get(rowIndex); } + /** + * Returns the batch-owned reusable row view pinned to rowIndex. Every call + * returns the same object re-pointed at the requested row. + */ + row(rowIndex: number): QwpResultRowView { + this.assertValid(); + return this.reusableRowView().of(rowIndex); + } + + /** + * Visits rows in index order with one re-pointed row view. The callback is + * synchronous; copy values that must survive the current invocation. + */ + forEachRow(callback: QwpResultRowViewCallback): void { + this.assertValid(); + if (this._rowCount === 0) return; + const rowView = this.reusableRowView(); + for (let rowIndex = 0; rowIndex < this._rowCount; rowIndex++) { + callback(rowView.of(rowIndex)); + } + } + materialize(): QwpResultBatch { this.assertValid(); return new QwpResultBatch( @@ -803,6 +962,10 @@ export class QwpResultBatchView { ); } } + + private reusableRowView(): QwpResultRowView { + return (this.rowView ??= new QwpResultRowView(this)); + } } function variableWidthValue( diff --git a/test/qwp/browser.e2e.ts b/test/qwp/browser.e2e.ts index 5dcd64f..43d39f2 100644 --- a/test/qwp/browser.e2e.ts +++ b/test/qwp/browser.e2e.ts @@ -185,7 +185,7 @@ describe("QWP in a real browser", () => { if (assetServer) await close(assetServer); }); - it("decompresses a Zstd result batch in the browser bundle", async () => { + it("decompresses Zstd and reuses row views in the browser bundle", async () => { const page = await browser.newPage(); try { await page.goto(assetUrl); @@ -209,15 +209,36 @@ describe("QWP in a real browser", () => { 1, ); const message = qwp.decodeQwpEgressMessage(frame); - const batch = new qwp.QwpResultBatchDecoder().decode(message); + const batch = new qwp.QwpResultBatchDecoder().decodeView(message); + let sharedRow: any; + let reusesRow = true; + let visits = 0; + batch.forEachRow((row: any) => { + sharedRow ??= row; + reusesRow &&= sharedRow === row; + visits++; + }); + const lastRow = batch.row(99); return { requestId: String(batch.requestId), rowCount: batch.rowCount, - lastValue: batch.get(99, 0), + lastValue: lastRow.getInt(0), + lastRowIndex: lastRow.rowIndex, + reusesRow, + rowViewExported: lastRow instanceof qwp.QwpResultRowView, + visits, }; }, assetUrl); - expect(result).toEqual({ requestId: "7", rowCount: 100, lastValue: 42 }); + expect(result).toEqual({ + requestId: "7", + rowCount: 100, + lastValue: 42, + lastRowIndex: 99, + reusesRow: true, + rowViewExported: true, + visits: 100, + }); } finally { await page.close(); } diff --git a/test/qwp/egress.test.ts b/test/qwp/egress.test.ts index b077aa6..ff0582a 100644 --- a/test/qwp/egress.test.ts +++ b/test/qwp/egress.test.ts @@ -23,6 +23,7 @@ import { QwpEgressSession, QwpResultBatchDecoder, QwpResultBatchView, + QwpResultRowView, readQwpVarint, writeQwpVarint, } from "../../src/qwp"; @@ -356,6 +357,71 @@ describe("QWP result batch decoder", () => { ]); }); + it("reuses one row-major view for row() and forEachRow()", () => { + const message = decodeQwpEgressMessage(firstResultBatch()); + if (message.kind !== "result-batch") throw new Error("unexpected message"); + + const batch = new QwpResultBatchDecoder().decodeView(message); + const first = batch.row(0); + expect(first).toBeInstanceOf(QwpResultRowView); + expect(first.batch).toBe(batch); + expect(first.rowIndex).toBe(0); + expect(first.getInt(0)).toBe(7); + expect(new TextDecoder().decode(first.getUtf8View(1)!)).toBe("a"); + expect(first.getSymbolId(2)).toBe(0); + expect(first.getSymbol(2)).toBe("alpha"); + expect(first.getLong(3)).toBe(100n); + + const second = batch.row(1); + expect(second).toBe(first); + expect(first.rowIndex).toBe(1); + expect(first.isNull(0)).toBe(true); + expect(first.getInt(0)).toBe(0); + expect(first.getString(1)).toBe("bb"); + + const identities = new Set(); + const rows: unknown[][] = []; + batch.forEachRow((row) => { + identities.add(row); + rows.push([ + row.rowIndex, + row.get(0), + row.getString(1), + row.getSymbol(2), + row.getLong(3), + ]); + }); + expect(identities.size).toBe(1); + expect(rows).toEqual([ + [0, 7, "a", "alpha", 100n], + [1, null, "bb", "beta", 200n], + [2, 9, "", "alpha", 300n], + ]); + + let visited = 0; + expect(() => + batch.forEachRow((row) => { + visited++; + if (row.rowIndex === 1) throw new Error("stop rows"); + }), + ).toThrow("stop rows"); + expect(visited).toBe(2); + + batch.release(); + expect(() => first.rowIndex).toThrow(/no longer valid/i); + expect(() => first.getInt(0)).toThrow(/no longer valid/i); + }); + + it("does not invoke forEachRow for an empty batch", () => { + const message = decodeQwpEgressMessage(emptyResultBatch(0n, 0)); + if (message.kind !== "result-batch") throw new Error("unexpected message"); + const batch = new QwpResultBatchDecoder().decodeView(message); + const callback = vi.fn(); + batch.forEachRow(callback); + expect(callback).not.toHaveBeenCalled(); + expect(() => batch.row(0)).toThrow("row index out of range: 0"); + }); + it("lazily reads every result type and detaches materialized binary", () => { const frame = scalarResultBatch(); const message = decodeQwpEgressMessage(frame); @@ -385,6 +451,24 @@ describe("QWP result batch decoder", () => { expect(batch.column(17).getBinaryView(0)).toEqual(Uint8Array.of(1, 2, 3)); expect(batch.column(18).getInt(0)).toBe(-1); + const rowView = batch.row(0); + expect(rowView.getBoolean(0)).toBe(true); + expect(rowView.getByte(1)).toBe(-2); + expect(rowView.getShort(2)).toBe(-3); + expect(rowView.getChar(3)).toBe("Q"); + expect(rowView.getLong(4)).toBe(-4n); + expect(rowView.getFloat(5)).toBe(1.5); + expect(rowView.getDouble(6)).toBe(-2.5); + expect(rowView.getUuidLow(8)).toBe(1n); + expect(rowView.getUuidHigh(8)).toBe(2n); + expect(rowView.getLong256Word(9, 3)).toBe(4n); + expect(rowView.getGeohashBits(10)).toBe(21n); + expect(rowView.getArrayDimensionCount(12)).toBe(2); + expect(rowView.getArrayView(12)).toBeInstanceOf(Uint8Array); + expect(rowView.getDecimalUnscaled(14)).toBe(1234n); + expect(rowView.getBinaryView(17)).toEqual(Uint8Array.of(1, 2, 3)); + expect(rowView.getInt(18)).toBe(-1); + const retained = batch.materialize(); batch.column(17).getBinaryView(0)![0] = 99; expect(retained.get(0, 17)).toEqual(Uint8Array.of(1, 2, 3)); @@ -398,6 +482,7 @@ describe("QWP result batch decoder", () => { } const first = decoder.decodeView(firstMessage); const firstColumn = first.column(0); + const firstRow = first.row(0); first.release(); decoder.resetQuerySchema(); @@ -408,7 +493,9 @@ describe("QWP result batch decoder", () => { const second = decoder.decodeView(secondMessage); expect(second).toBe(first); expect(second.column(0)).toBe(firstColumn); + expect(second.row(0)).toBe(firstRow); expect(second.column(0).getBoolean(0)).toBe(true); + expect(second.row(0).getBoolean(0)).toBe(true); }); it("rejects a continuation batch before a schema-bearing batch", () => { diff --git a/test/qwp/public-api-contract.ts b/test/qwp/public-api-contract.ts index 6534a56..17eee7f 100644 --- a/test/qwp/public-api-contract.ts +++ b/test/qwp/public-api-contract.ts @@ -43,6 +43,8 @@ import type { QwpQueryLease, QwpResultBatchView, QwpResultBatchViewHandler, + QwpResultRowView, + QwpResultRowViewCallback, QwpSender, QwpSenderOptions, } from "../../src/qwp"; @@ -207,9 +209,18 @@ function queryViewContract( const typedBatch: QwpResultBatchView = batch; const requestId: bigint = query.requestId; const rawValues: Uint8Array | undefined = batch.column(0).valuesBytes(); + const directRow: QwpResultRowView = batch.row(0); + const rowCallback: QwpResultRowViewCallback = (row) => { + const rowIndex: number = row.rowIndex; + const value: bigint = row.getLong(0); + void rowIndex; + void value; + }; + batch.forEachRow(rowCallback); void typedBatch; void requestId; void rawValues; + void directRow; }; const direct: Promise = session.queryViews( "select * from trades", diff --git a/test/qwp/public-api.test.ts b/test/qwp/public-api.test.ts index cf9d56d..17eec51 100644 --- a/test/qwp/public-api.test.ts +++ b/test/qwp/public-api.test.ts @@ -37,6 +37,7 @@ const sharedRuntimeContract = [ "QwpResultBatch", "QwpResultBatchView", "QwpResultColumnView", + "QwpResultRowView", "QwpQueryLease", "QwpSendTimeoutError", "QwpSender", From 58bf575fc1ce630f12dee88f2598b96a3982dedb Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 18 Aug 2026 17:34:00 +0100 Subject: [PATCH 051/265] fix(qwp): omit unsupported query flags --- QWP.md | 6 ++++ src/qwp/egress-session.ts | 20 ++++++------ test/qwp/browser.e2e.ts | 55 +++++++++++++++++++++++++++++++++ test/qwp/egress.test.ts | 41 ++++++++++++++++++++++-- test/qwp/public-api-contract.ts | 1 + 5 files changed, 111 insertions(+), 12 deletions(-) diff --git a/QWP.md b/QWP.md index 13340a8..ae166e6 100644 --- a/QWP.md +++ b/QWP.md @@ -578,6 +578,12 @@ microsecond and nanosecond timestamps, strings, UUIDs, LONG256, geohashes, decimals, and typed nulls. Set values in ascending index order. `bindPayload` and `bindCount` remain advanced escape hatches for pre-encoded data. +Set per-query `resetDictionary: true` to ask the server to reset its +connection-scoped egress symbol dictionary before execution. The client sends the +flag only when `SERVER_INFO` advertises `QUERY_FLAGS`; older servers receive the +same flag-free request as the default path, so this option remains safe during a +rolling upgrade. + The high-level client defaults `initialCredit` to 256 KiB, bounding unread wire data to roughly that window plus at most one server batch. The exact wire size of each batch is replenished when iteration advances beyond it, so a slow consumer limits diff --git a/src/qwp/egress-session.ts b/src/qwp/egress-session.ts index c49e296..7d96cb2 100644 --- a/src/qwp/egress-session.ts +++ b/src/qwp/egress-session.ts @@ -65,7 +65,10 @@ export interface QwpEgressQueryOptions { bindCount?: number; /** Advanced escape hatch for an already encoded bind section. */ bindPayload?: Uint8Array; - /** Ask a capable server to reset its connection-scoped symbol dictionary. */ + /** + * Ask a capable server to reset its connection-scoped symbol dictionary. + * Silently omitted when the server lacks QUERY_FLAGS for rolling upgrades. + */ resetDictionary?: boolean; } @@ -685,12 +688,8 @@ export class QwpEgressSession implements QwpEgressQueryControl { if (this.active) { throw new Error("a QWP query is already active on this connection"); } - if ( - options.resetDictionary && - (this.serverInfo!.capabilities & QWP_EGRESS_CAPABILITY.QUERY_FLAGS) === 0 - ) { - throw new Error("the QWP server does not support query flags"); - } + const supportsQueryFlags = + (this.serverInfo!.capabilities & QWP_EGRESS_CAPABILITY.QUERY_FLAGS) !== 0; const requestId = this.nextRequestId++; const creditEnabled = @@ -714,9 +713,10 @@ export class QwpEgressSession implements QwpEgressQueryControl { binds: options.binds, bindCount: options.bindCount, bindPayload: options.bindPayload, - queryFlags: options.resetDictionary - ? QWP_QUERY_FLAG_RESET_DICTIONARY - : undefined, + queryFlags: + options.resetDictionary && supportsQueryFlags + ? QWP_QUERY_FLAG_RESET_DICTIONARY + : undefined, }; try { await this.send(encodeQwpQueryRequest(request)); diff --git a/test/qwp/browser.e2e.ts b/test/qwp/browser.e2e.ts index 43d39f2..3e75214 100644 --- a/test/qwp/browser.e2e.ts +++ b/test/qwp/browser.e2e.ts @@ -344,6 +344,61 @@ describe("QWP in a real browser", () => { } }); + it("omits resetDictionary for an older egress server in a real browser", async () => { + let requestPayload: Uint8Array | undefined; + const server = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + server.on("connection", (socket) => { + socket.send(browserServerInfo()); + socket.on("message", (data) => { + requestPayload = new Uint8Array(data as Buffer).slice(); + const reader = new QwpByteReader(requestPayload); + expect(reader.readUint8()).toBe(QWP_EGRESS_MESSAGE.QUERY_REQUEST); + socket.send(browserResultEnd(reader.readBigUint64())); + }); + }); + await waitForWebSocketServer(server); + const address = server.address() as AddressInfo; + const page = await browser.newPage(); + try { + await page.goto(assetUrl); + await page.evaluate( + async ({ moduleUrl, url }) => { + const importModule = new Function("url", "return import(url)") as ( + url: string, + ) => Promise>; + const qwp = await importModule(moduleUrl); + const session = await qwp.connectQwpBrowserEgress({ url }); + try { + const query = await session.query("select 1", { + resetDictionary: true, + }); + await query.completion; + } finally { + await session.close(); + } + }, + { + moduleUrl: assetUrl, + url: `ws://127.0.0.1:${address.port}/read/v1`, + }, + ); + + const request = new QwpByteReader(requestPayload!); + expect(request.readUint8()).toBe(QWP_EGRESS_MESSAGE.QUERY_REQUEST); + expect(request.readBigUint64()).toBe(0n); + const sqlLength = Number(readQwpVarint(request)); + expect(request.readUtf8(sqlLength)).toBe("select 1"); + expect(readQwpVarint(request)).toBe( + BigInt(QWP_DEFAULT_EGRESS_INITIAL_CREDIT), + ); + expect(readQwpVarint(request)).toBe(0n); + expect(request.remaining).toBe(0); + } finally { + await page.close(); + await closeWebSocketServer(server); + } + }); + it("falls back cleanly when an older ingress server sends no cap", async () => { const server = new WebSocketServer({ host: "127.0.0.1", diff --git a/test/qwp/egress.test.ts b/test/qwp/egress.test.ts index ff0582a..fdf484c 100644 --- a/test/qwp/egress.test.ts +++ b/test/qwp/egress.test.ts @@ -11,6 +11,7 @@ import { QWP_FLAG_ZSTD, QWP_DEFAULT_EGRESS_INITIAL_CREDIT, QWP_MAX_ZSTD_DECOMPRESSED_SIZE, + QWP_QUERY_FLAG_RESET_DICTIONARY, QWP_STATUS, QwpBinaryConnection, QwpByteReader, @@ -49,13 +50,15 @@ function writeU16String(writer: QwpByteWriter, value: string): void { writer.writeUint16(bytes.length).writeBytes(bytes); } -function serverInfo(): Uint8Array { +function serverInfo( + capabilities = QWP_EGRESS_CAPABILITY.QUERY_FLAGS, +): Uint8Array { const payload = new QwpByteWriter(); payload .writeUint8(QWP_EGRESS_MESSAGE.SERVER_INFO) .writeUint8(0) .writeBigUint64(1n) - .writeUint32(QWP_EGRESS_CAPABILITY.QUERY_FLAGS) + .writeUint32(capabilities) .writeBigInt64(123n); writeU16String(payload, "cluster"); writeU16String(payload, "node"); @@ -748,6 +751,40 @@ describe("QwpEgressSession", () => { await session.close(); }); + it("silently omits resetDictionary when QUERY_FLAGS is unavailable", async () => { + const captureRequest = async ( + capabilities: number, + resetDictionary: boolean, + ): Promise => { + const connection = new FakeConnection(); + const session = new QwpEgressSession(connection); + connection.receive(serverInfo(capabilities)); + const query = await session.query("select 1", { resetDictionary }); + connection.receive(resultEnd(query.requestId, 0n)); + await query.completion; + await session.close(); + return connection.sent[0]; + }; + + const legacyBaseline = await captureRequest(0, false); + const legacyReset = await captureRequest(0, true); + expect(legacyReset).toEqual(legacyBaseline); + + const capableBaseline = await captureRequest( + QWP_EGRESS_CAPABILITY.QUERY_FLAGS, + false, + ); + const capableReset = await captureRequest( + QWP_EGRESS_CAPABILITY.QUERY_FLAGS, + true, + ); + expect(capableReset).toHaveLength(capableBaseline.byteLength + 1); + expect(capableReset.subarray(0, capableBaseline.byteLength)).toEqual( + capableBaseline, + ); + expect(capableReset.at(-1)).toBe(QWP_QUERY_FLAG_RESET_DICTIONARY); + }); + it("automatically replenishes credit after the consumer advances", async () => { const connection = new FakeConnection(); const session = new QwpEgressSession(connection); diff --git a/test/qwp/public-api-contract.ts b/test/qwp/public-api-contract.ts index 17eee7f..851097d 100644 --- a/test/qwp/public-api-contract.ts +++ b/test/qwp/public-api-contract.ts @@ -125,6 +125,7 @@ const queryOptionsContract: QwpEgressQueryOptions = { initialCredit: 1024, autoCredit: true, timeoutMs: 30_000, + resetDictionary: true, binds: (binds) => binds.setVarchar(0, "ETH-USD"), }; From a7e56111fc2cb6e5b954f2de897e16b7370774d2 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 18 Aug 2026 18:27:28 +0100 Subject: [PATCH 052/265] fix(qwp): re-encode queries after failover --- src/qwp/egress-session.ts | 94 +++++++++++--- .../reconnecting-egress-connection.ts | 78 ++++++++--- test/qwp/reconnect.test.ts | 121 +++++++++++++++++- 3 files changed, 254 insertions(+), 39 deletions(-) diff --git a/src/qwp/egress-session.ts b/src/qwp/egress-session.ts index 7d96cb2..a8eeaff 100644 --- a/src/qwp/egress-session.ts +++ b/src/qwp/egress-session.ts @@ -1,5 +1,6 @@ import { decodeQwpEgressMessage, + encodeQwpBinds, encodeQwpCancel, encodeQwpCredit, encodeQwpQueryRequest, @@ -11,7 +12,6 @@ import { QwpExecDoneMessage, type QwpNegotiatedEgressCompression, QwpProtocolError, - QwpQueryRequest, QwpResultBatch, QwpResultBatchDecoder, QwpResultBatchView, @@ -80,6 +80,15 @@ interface QwpValidatedEgressSessionOptions { readonly cancelDrainTimeoutMs: number; } +interface QwpReplayableQueryRequest { + readonly requestId: bigint; + readonly sql: string; + readonly initialCredit: number | bigint; + readonly bindCount?: number; + readonly bindPayload?: Uint8Array; + readonly resetDictionary: boolean; +} + /** Default bounded send-ahead window used by high-level egress queries. */ export const QWP_DEFAULT_EGRESS_INITIAL_CREDIT = 256 * 1024; /** Default decoded materialized-result queue depth, matching the Java client. */ @@ -508,6 +517,7 @@ export class QwpEgressSession implements QwpEgressQueryControl { private readonly cancelDrainTimeoutMs: number; private readonly idleWaiters = new Set<() => void>(); private active?: QwpEgressQuery; + private activeRequest?: QwpReplayableQueryRequest; private nextRequestId = 0n; private sendTail: Promise = Promise.resolve(); private serverInfo?: QwpServerInfoMessage; @@ -577,7 +587,16 @@ export class QwpEgressSession implements QwpEgressQueryControl { factory, options.reconnect, validated.serverInfoTimeoutMs, - () => state.session?.prepareConnectionReset(), + (serverInfo) => state.session?.prepareConnectionReset(serverInfo), + (serverInfo, requestId) => { + const session = state.session; + if (!session) { + throw new QwpProtocolError( + "QWP egress session is unavailable while encoding a query", + ); + } + return session.encodeActiveQueryRequest(serverInfo, requestId); + }, options.onReplayReset ? async (event) => { await options.onReplayReset!(event); @@ -688,9 +707,6 @@ export class QwpEgressSession implements QwpEgressQueryControl { if (this.active) { throw new Error("a QWP query is already active on this connection"); } - const supportsQueryFlags = - (this.serverInfo!.capabilities & QWP_EGRESS_CAPABILITY.QUERY_FLAGS) !== 0; - const requestId = this.nextRequestId++; const creditEnabled = typeof initialCredit === "bigint" @@ -704,22 +720,30 @@ export class QwpEgressSession implements QwpEgressQueryControl { this.bufferPoolSize, viewHandler, ); - this.decoder.resetQuerySchema(); - this.active = query; - const request: QwpQueryRequest = { + if ( + options.binds !== undefined && + (options.bindCount !== undefined || options.bindPayload !== undefined) + ) { + throw new Error( + "typed binds cannot be mixed with raw bindCount/bindPayload", + ); + } + const encodedBinds = options.binds + ? encodeQwpBinds(options.binds) + : undefined; + const request: QwpReplayableQueryRequest = { requestId, sql, initialCredit, - binds: options.binds, - bindCount: options.bindCount, - bindPayload: options.bindPayload, - queryFlags: - options.resetDictionary && supportsQueryFlags - ? QWP_QUERY_FLAG_RESET_DICTIONARY - : undefined, + bindCount: encodedBinds?.count ?? options.bindCount, + bindPayload: (encodedBinds?.payload ?? options.bindPayload)?.slice(), + resetDictionary: options.resetDictionary === true, }; + this.decoder.resetQuerySchema(); + this.active = query; + this.activeRequest = request; try { - await this.send(encodeQwpQueryRequest(request)); + await this.send(this.encodeQueryRequest(request, this.serverInfo!)); } catch (error) { this.clearActive(query); query.fail(error); @@ -929,12 +953,47 @@ export class QwpEgressSession implements QwpEgressQueryControl { return this.active; } - private async prepareConnectionReset(): Promise { + private async prepareConnectionReset( + serverInfo: QwpServerInfoMessage, + ): Promise { + this.serverInfo = serverInfo; await this.active?.resetForReplay(); this.decoder.applyCacheReset(QWP_RESET_MASK_DICTIONARY); this.decoder.resetQuerySchema(); } + private encodeActiveQueryRequest( + serverInfo: QwpServerInfoMessage, + requestId: bigint, + ): Uint8Array { + const request = this.activeRequest; + if (!request || request.requestId !== requestId) { + throw new QwpProtocolError( + `QWP egress replay references inactive request ID ${requestId}`, + ); + } + return this.encodeQueryRequest(request, serverInfo); + } + + private encodeQueryRequest( + request: QwpReplayableQueryRequest, + serverInfo: QwpServerInfoMessage, + ): Uint8Array { + const supportsQueryFlags = + (serverInfo.capabilities & QWP_EGRESS_CAPABILITY.QUERY_FLAGS) !== 0; + return encodeQwpQueryRequest({ + requestId: request.requestId, + sql: request.sql, + initialCredit: request.initialCredit, + bindCount: request.bindCount, + bindPayload: request.bindPayload, + queryFlags: + request.resetDictionary && supportsQueryFlags + ? QWP_QUERY_FLAG_RESET_DICTIONARY + : undefined, + }); + } + private cancelAndDrain( requestId: bigint, discardedCredit: number, @@ -1023,6 +1082,7 @@ export class QwpEgressSession implements QwpEgressQueryControl { if (expected && this.active !== expected) return; if (!this.active) return; this.active = undefined; + this.activeRequest = undefined; for (const resolve of this.idleWaiters) resolve(); this.idleWaiters.clear(); } diff --git a/src/qwp/internal/reconnecting-egress-connection.ts b/src/qwp/internal/reconnecting-egress-connection.ts index 192029c..f1103c0 100644 --- a/src/qwp/internal/reconnecting-egress-connection.ts +++ b/src/qwp/internal/reconnecting-egress-connection.ts @@ -25,7 +25,13 @@ import { QwpAsyncQueue } from "./async-queue"; type ReplayResetHandler = ( event: QwpEgressReplayResetEvent, ) => void | Promise; -type ConnectionResetHandler = () => void | Promise; +type ConnectionResetHandler = ( + serverInfo: QwpServerInfoMessage, +) => void | Promise; +type QueryRequestEncoder = ( + serverInfo: QwpServerInfoMessage, + requestId: bigint, +) => Uint8Array | Promise; class ReplayResetCallbackError extends Error { readonly cause: unknown; @@ -54,6 +60,7 @@ export class QwpReconnectingEgressConnection implements QwpBinaryConnection { private lastHandshake?: QwpHandshakeMetadata; private lastEndpoint?: string | URL; private initialServerInfo?: QwpServerInfoMessage; + private currentServerInfo?: QwpServerInfoMessage; private outboundReplay: Uint8Array[] = []; private generation = 0; private sendTail: Promise = Promise.resolve(); @@ -70,6 +77,7 @@ export class QwpReconnectingEgressConnection implements QwpBinaryConnection { private readonly reconnectOptions: QwpReconnectOptions, private readonly serverInfoTimeoutMs: number, private readonly onConnectionReset: ConnectionResetHandler, + private readonly encodeQueryRequest: QueryRequestEncoder, private readonly onReplayReset?: ReplayResetHandler, ) { this.maxAttempts = reconnectOptions.maxAttempts ?? 3; @@ -94,6 +102,7 @@ export class QwpReconnectingEgressConnection implements QwpBinaryConnection { reconnectOptions: QwpReconnectOptions, serverInfoTimeoutMs: number, onConnectionReset: ConnectionResetHandler, + encodeQueryRequest: QueryRequestEncoder, onReplayReset?: ReplayResetHandler, ): Promise { const reconnecting = new QwpReconnectingEgressConnection( @@ -101,6 +110,7 @@ export class QwpReconnectingEgressConnection implements QwpBinaryConnection { reconnectOptions, serverInfoTimeoutMs, onConnectionReset, + encodeQueryRequest, onReplayReset, ); try { @@ -129,9 +139,10 @@ export class QwpReconnectingEgressConnection implements QwpBinaryConnection { const sending = this.sendTail.then(async () => { this.throwIfUnavailable(); const connection = await this.requireConnection(); - this.trackOutbound(copy); + const prepared = await this.prepareOutboundQuery(copy); + this.trackOutbound(prepared); try { - await connection.send(copy); + await connection.send(prepared); } catch (error) { await this.requestReconnect(error, connection); } @@ -217,12 +228,19 @@ export class QwpReconnectingEgressConnection implements QwpBinaryConnection { } if (reconnecting) { this.validateServerInfo(serverInfo, candidate); - await this.replayInto(candidate, previousEndpoint, initialCause); + await this.replayInto( + candidate, + serverInfo, + previousEndpoint, + initialCause, + ); } else { this.initialServerInfo = serverInfo; + this.currentServerInfo = serverInfo; this.messagesQueue.push(serverInfoPayload); } if (this.closing) throw new QwpSendClosedError(); + this.currentServerInfo = serverInfo; this.install(candidate, iterator); this.connectingCandidate = undefined; if (reconnecting) { @@ -364,18 +382,6 @@ export class QwpReconnectingEgressConnection implements QwpBinaryConnection { "QWP reconnect started before the initial SERVER_INFO was received", ); } - const missingCapabilities = initial.capabilities & ~serverInfo.capabilities; - if (missingCapabilities !== 0) { - throw new QwpUpgradeError( - `QWP reconnect target lacks required egress capabilities [missing=0x${missingCapabilities.toString(16)}]`, - { - kind: QWP_UPGRADE_ERROR_KIND.CAPABILITY_MISMATCH, - retryable: true, - tryNextEndpoint: true, - url: connection.endpoint, - }, - ); - } if ( initial.clusterId && serverInfo.clusterId && @@ -395,6 +401,7 @@ export class QwpReconnectingEgressConnection implements QwpBinaryConnection { private async replayInto( connection: QwpBinaryConnection, + serverInfo: QwpServerInfoMessage, previousEndpoint: string | URL | undefined, cause: unknown, ): Promise { @@ -402,15 +409,20 @@ export class QwpReconnectingEgressConnection implements QwpBinaryConnection { // A terminal response may already be queued. Let the bounded session // consume it before resetting connection-scoped decoder state. await this.messagesQueue.barrier(); - await this.onConnectionReset(); + await this.onConnectionReset(serverInfo); return; } // An active operation will be replayed from its request. Drop raw stale // messages before resetting the decoded queue; waiting for a barrier here // can deadlock when that queue is deliberately at its client-side bound. this.messagesQueue.clear(); - await this.onConnectionReset(); + await this.onConnectionReset(serverInfo); const requestId = replayRequestId(this.outboundReplay); + if (requestId === undefined) { + throw new QwpProtocolError( + "QWP egress replay is missing its QUERY_REQUEST", + ); + } if (!this.onReplayReset) { throw new QwpEgressReplayRequiredError(requestId); } @@ -424,9 +436,27 @@ export class QwpReconnectingEgressConnection implements QwpBinaryConnection { } catch (error) { throw new ReplayResetCallbackError(error); } + const request = await this.encodeQueryRequest(serverInfo, requestId); + validateEncodedRequest(request, requestId); + const preparedRequest = request.slice(); + this.outboundReplay[0] = preparedRequest; for (const payload of this.outboundReplay) await connection.send(payload); } + private async prepareOutboundQuery(payload: Uint8Array): Promise { + if (payload[0] !== QWP_EGRESS_MESSAGE.QUERY_REQUEST) return payload; + const requestId = replayRequestId([payload]); + const serverInfo = this.currentServerInfo; + if (requestId === undefined || !serverInfo) { + throw new QwpProtocolError( + "QWP QUERY_REQUEST cannot be prepared before SERVER_INFO", + ); + } + const encoded = await this.encodeQueryRequest(serverInfo, requestId); + validateEncodedRequest(encoded, requestId); + return encoded.slice(); + } + private trackOutbound(payload: Uint8Array): void { switch (payload[0]) { case QWP_EGRESS_MESSAGE.QUERY_REQUEST: @@ -537,6 +567,18 @@ function replayRequestId(payloads: readonly Uint8Array[]): bigint | undefined { ).getBigUint64(1, true); } +function validateEncodedRequest( + payload: Uint8Array, + expectedRequestId: bigint, +): void { + const requestId = replayRequestId([payload]); + if (requestId !== expectedRequestId) { + throw new QwpProtocolError( + `QWP query encoder returned the wrong request [expected=${expectedRequestId}, actual=${requestId ?? "missing"}]`, + ); + } +} + function validateReconnectPolicy( maxAttempts: number, initialBackoffMs: number, diff --git a/test/qwp/reconnect.test.ts b/test/qwp/reconnect.test.ts index e01acac..a07ff50 100644 --- a/test/qwp/reconnect.test.ts +++ b/test/qwp/reconnect.test.ts @@ -25,6 +25,7 @@ import { QWP_COLUMN_TYPE, QWP_EGRESS_CAPABILITY, QWP_EGRESS_MESSAGE, + QWP_QUERY_FLAG_RESET_DICTIONARY, QWP_SERVER_ROLE, QWP_STATUS, QWP_UPGRADE_ERROR_KIND, @@ -48,6 +49,7 @@ import { encodeQwpFrame, encodeQwpDurableAckPollFrame, encodeQwpIngressFrame, + encodeQwpQueryRequest, decodeQwpIngressSymbolDictionaryDelta, writeQwpVarint, } from "../../src/qwp"; @@ -97,15 +99,15 @@ function serverInfo( node: string, role = QWP_SERVER_ROLE.STANDALONE, zone?: string, + capabilities = QWP_EGRESS_CAPABILITY.QUERY_FLAGS, ): Uint8Array { - const capabilities = - QWP_EGRESS_CAPABILITY.QUERY_FLAGS | - (zone === undefined ? 0 : QWP_EGRESS_CAPABILITY.ZONE); + const advertisedCapabilities = + capabilities | (zone === undefined ? 0 : QWP_EGRESS_CAPABILITY.ZONE); const payload = new QwpByteWriter() .writeUint8(QWP_EGRESS_MESSAGE.SERVER_INFO) .writeUint8(role) .writeBigUint64(1n) - .writeUint32(capabilities) + .writeUint32(advertisedCapabilities) .writeBigInt64(123n); writeUint16String(payload, "cluster"); writeUint16String(payload, node); @@ -1661,6 +1663,117 @@ describe("QWP egress reconnect and replay", () => { await session.close(); }); + it("re-encodes a query queued during a capability downgrade", async () => { + const first = new FakeConnection("primary"); + const second = new FakeConnection("secondary"); + const connections = [first, second]; + let releaseSecondInfo!: () => void; + const secondInfoReady = new Promise((resolve) => { + releaseSecondInfo = resolve; + }); + const session = await QwpEgressSession.connect( + async () => { + const connection = connections.shift(); + if (!connection) throw new Error("no connection available"); + if (connection === second) await secondInfoReady; + queueMicrotask(() => + connection.receive( + serverInfo( + connection.endpoint, + QWP_SERVER_ROLE.STANDALONE, + undefined, + connection === first ? QWP_EGRESS_CAPABILITY.QUERY_FLAGS : 0, + ), + ), + ); + return connection; + }, + { + reconnect: { + maxAttempts: 1, + initialBackoffMs: 0, + maxBackoffMs: 0, + }, + }, + ); + + first.drop(); + await vi.waitFor(() => expect(connections).toHaveLength(0)); + const querying = session.query("select 1", { + initialCredit: 0, + resetDictionary: true, + }); + releaseSecondInfo(); + const query = await querying; + expect(second.sent).toEqual([ + encodeQwpQueryRequest({ + requestId: 0n, + sql: "select 1", + initialCredit: 0, + }), + ]); + second.receive(resultEnd()); + await expect(query.completion).resolves.toMatchObject({ + kind: "result-end", + }); + await session.close(); + }); + + it("re-encodes an active query after a capability downgrade", async () => { + const first = new FakeConnection("primary"); + const second = new FakeConnection("secondary"); + const connections = [first, second]; + const resets: bigint[] = []; + let bindCalls = 0; + const session = await QwpEgressSession.connect( + async () => { + const connection = connections.shift(); + if (!connection) throw new Error("no connection available"); + queueMicrotask(() => + connection.receive( + serverInfo( + connection.endpoint, + QWP_SERVER_ROLE.STANDALONE, + undefined, + connection === first ? QWP_EGRESS_CAPABILITY.QUERY_FLAGS : 0, + ), + ), + ); + return connection; + }, + { + reconnect: { + maxAttempts: 1, + initialBackoffMs: 0, + maxBackoffMs: 0, + }, + onReplayReset: (event) => resets.push(event.requestId!), + }, + ); + const query = await session.query("select $1", { + initialCredit: 0, + resetDictionary: true, + binds: (binds) => { + bindCalls++; + binds.setInt(0, 42); + }, + }); + expect(first.sent).toHaveLength(1); + expect(first.sent[0].at(-1)).toBe(QWP_QUERY_FLAG_RESET_DICTIONARY); + + first.drop(); + await vi.waitFor(() => expect(resets).toEqual([0n])); + await vi.waitFor(() => expect(second.sent).toHaveLength(1)); + expect(second.sent[0]).toEqual(first.sent[0].subarray(0, -1)); + expect(bindCalls).toBe(1); + + second.receive(resultEnd()); + await expect(query.completion).resolves.toMatchObject({ + kind: "result-end", + }); + await session.close(); + }); + it("discards queued batches, invokes reset, and replays an opted-in query", async () => { const first = new FakeConnection("primary"); const second = new FakeConnection("secondary"); From 1e27bc48251e42bcb54e91f3134894545291c832 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 18 Aug 2026 18:33:10 +0100 Subject: [PATCH 053/265] feat(qwp): expose failover server info --- src/qwp/egress-session.ts | 3 ++- src/qwp/internal/reconnecting-egress-connection.ts | 1 + src/qwp/transport.ts | 6 +++++- test/qwp/reconnect.test.ts | 12 +++++++++--- 4 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/qwp/egress-session.ts b/src/qwp/egress-session.ts index a8eeaff..a9b1d30 100644 --- a/src/qwp/egress-session.ts +++ b/src/qwp/egress-session.ts @@ -44,7 +44,8 @@ export interface QwpEgressSessionOptions { /** * Explicitly opts into at-least-once re-execution after a disconnect. The * query's not-yet-consumed batches are discarded before this callback, and - * callers must discard any result prefix they already consumed. + * callers must discard any result prefix they already consumed. The event + * includes the authoritative SERVER_INFO for the replacement endpoint. */ onReplayReset?: (event: QwpEgressReplayResetEvent) => void | Promise; } diff --git a/src/qwp/internal/reconnecting-egress-connection.ts b/src/qwp/internal/reconnecting-egress-connection.ts index f1103c0..76f05b0 100644 --- a/src/qwp/internal/reconnecting-egress-connection.ts +++ b/src/qwp/internal/reconnecting-egress-connection.ts @@ -429,6 +429,7 @@ export class QwpReconnectingEgressConnection implements QwpBinaryConnection { try { await this.onReplayReset({ requestId, + serverInfo, previousEndpoint, endpoint: connection.endpoint, cause, diff --git a/src/qwp/transport.ts b/src/qwp/transport.ts index 42e36bc..c022f7a 100644 --- a/src/qwp/transport.ts +++ b/src/qwp/transport.ts @@ -1,4 +1,5 @@ import type { QwpNegotiatedEgressCompression } from "./core/compression"; +import type { QwpServerInfoMessage } from "./core/egress"; export interface QwpConnectionCloseInfo { code: number; @@ -236,7 +237,10 @@ export interface QwpReconnectOptions { } export interface QwpEgressReplayResetEvent { - readonly requestId?: bigint; + /** Client request being re-executed on the replacement connection. */ + readonly requestId: bigint; + /** Authoritative SERVER_INFO received from the replacement endpoint. */ + readonly serverInfo: QwpServerInfoMessage; readonly previousEndpoint?: string | URL; readonly endpoint?: string | URL; readonly cause?: unknown; diff --git a/test/qwp/reconnect.test.ts b/test/qwp/reconnect.test.ts index a07ff50..d907fb8 100644 --- a/test/qwp/reconnect.test.ts +++ b/test/qwp/reconnect.test.ts @@ -1747,7 +1747,13 @@ describe("QWP egress reconnect and replay", () => { initialBackoffMs: 0, maxBackoffMs: 0, }, - onReplayReset: (event) => resets.push(event.requestId!), + onReplayReset: (event) => { + resets.push(event.requestId); + expect(event.serverInfo).toMatchObject({ + nodeId: "secondary", + capabilities: 0, + }); + }, }, ); const query = await session.query("select $1", { @@ -1797,7 +1803,7 @@ describe("QWP egress reconnect and replay", () => { maxBackoffMs: 0, }, bufferPoolSize: 1, - onReplayReset: (event) => resets.push(event.requestId!), + onReplayReset: (event) => resets.push(event.requestId), }, ); const query = await session.query("select * from x"); @@ -1859,7 +1865,7 @@ describe("QWP egress reconnect and replay", () => { initialBackoffMs: 0, maxBackoffMs: 0, }, - onReplayReset: (event) => resets.push(event.requestId!), + onReplayReset: (event) => resets.push(event.requestId), }, ); const query = await session.queryViews("select * from x", async () => { From 3fbe982861f7be0c4ff1f1955b82ea7b0c6f1728 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 18 Aug 2026 18:39:05 +0100 Subject: [PATCH 054/265] feat(qwp): reap aged pooled connections --- QWP.md | 7 ++ src/qwp/client.ts | 136 +++++++++++++++++++++- test/qwp/client.test.ts | 200 +++++++++++++++++++++++++++++++- test/qwp/public-api-contract.ts | 13 +++ 4 files changed, 350 insertions(+), 6 deletions(-) diff --git a/QWP.md b/QWP.md index ae166e6..440476f 100644 --- a/QWP.md +++ b/QWP.md @@ -684,6 +684,9 @@ const db = await connectQwpNodeClient({ queryPoolMin: 1, queryPoolMax: 8, acquireTimeoutMs: 5_000, + idleTimeoutMs: 60_000, + maxLifetimeMs: 30 * 60_000, + housekeepingIntervalMs: 5_000, }, }); @@ -726,6 +729,10 @@ exhaustion raises `QwpPoolAcquireTimeoutError`. Query handles are single-flight, but separate borrowed handles run concurrently. Returning a handle with an active query sends `CANCEL` and waits for the session's bounded cancellation drain; a connection that cannot drain is closed instead of being handed to another borrower. +The shared housekeeper closes excess connections after `idleTimeoutMs` and recycles +connections older than `maxLifetimeMs` once they are idle, while always retaining +each configured pool minimum. Set either timeout to zero to disable that policy; +`housekeepingIntervalMs` controls how quickly an expired idle connection is noticed. Prefer returning application-owned leases before calling `QwpClient.close()`. If shutdown races a borrower, it rejects queued borrowers, closes idle connections, and waits up to `acquireTimeoutMs` (capped at five seconds) for active leases to diff --git a/src/qwp/client.ts b/src/qwp/client.ts index 36c2f89..dcf94f7 100644 --- a/src/qwp/client.ts +++ b/src/qwp/client.ts @@ -15,6 +15,10 @@ import type { const DEFAULT_POOL_MIN = 1; const DEFAULT_POOL_MAX = 4; const DEFAULT_ACQUIRE_TIMEOUT_MS = 5_000; +const DEFAULT_IDLE_TIMEOUT_MS = 60_000; +const DEFAULT_MAX_LIFETIME_MS = 30 * 60_000; +const DEFAULT_HOUSEKEEPING_INTERVAL_MS = 5_000; +const MIN_HOUSEKEEPING_INTERVAL_MS = 100; const MAX_CLOSE_CREATION_WAIT_MS = 5_000; const MAX_CLOSE_LEASE_WAIT_MS = 5_000; @@ -27,6 +31,12 @@ export interface QwpClientPoolOptions { queryPoolMin?: number; /** Maximum concurrently borrowed query connections. Defaults to 4. */ queryPoolMax?: number; + /** Idle time before an excess pooled connection is closed. Defaults to 60s; zero disables. */ + idleTimeoutMs?: number; + /** Maximum pooled connection age before recycling it while idle. Defaults to 30m; zero disables. */ + maxLifetimeMs?: number; + /** Idle/lifetime sweep interval. Defaults to 5s and must be at least 100ms. */ + housekeepingIntervalMs?: number; /** * Maximum wait for a returned pool slot and for leases during shutdown. * The shutdown wait is capped at 5 seconds. Defaults to 5 seconds. @@ -105,11 +115,16 @@ interface ValidatedPoolOptions { readonly queryPoolMin: number; readonly queryPoolMax: number; readonly acquireTimeoutMs: number; + readonly idleTimeoutMs: number; + readonly maxLifetimeMs: number; + readonly housekeepingIntervalMs: number; } interface PoolEntry { readonly slot: number; readonly value: T; + readonly createdAtMs: number; + idleSinceMs: number; leased: boolean; destroyPromise?: Promise; } @@ -129,6 +144,7 @@ class QwpResourcePool { private readonly all = new Map>(); private readonly available: PoolEntry[] = []; private readonly creatingSlots = new Set(); + private readonly destroyingSlots = new Set(); private readonly creationOperations = new Set>(); private readonly waiters = new Set(); private readonly closeWaiters = new Set(); @@ -141,6 +157,8 @@ class QwpResourcePool { private readonly minimum: number, private readonly maximum: number, private readonly acquireTimeoutMs: number, + private readonly idleTimeoutMs: number, + private readonly maxLifetimeMs: number, private readonly createResource: (slot: number) => Promise, private readonly destroyResource: (resource: T) => Promise, ) {} @@ -208,7 +226,7 @@ class QwpResourcePool { if (this.all.get(entry.slot) === entry) this.all.delete(entry.slot); this.pendingLeaseTeardowns++; try { - await this.destroy(entry); + await this.destroyRetired(entry); } finally { this.pendingLeaseTeardowns--; this.wakeWaiters(); @@ -216,6 +234,7 @@ class QwpResourcePool { } return; } + entry.idleSinceMs = Date.now(); this.available.push(entry); this.wakeWaiters(); this.wakeCloseWaiters(); @@ -226,6 +245,30 @@ class QwpResourcePool { return this.closePromise; } + async reapIdle(nowMs = Date.now()): Promise { + if (this.closed || this.all.size <= this.minimum) return; + const reaped: PoolEntry[] = []; + let index = 0; + while (index < this.available.length && this.all.size > this.minimum) { + const entry = this.available[index]; + const idleExpired = + this.idleTimeoutMs > 0 && + nowMs - entry.idleSinceMs >= this.idleTimeoutMs; + const lifetimeExpired = + this.maxLifetimeMs > 0 && + nowMs - entry.createdAtMs >= this.maxLifetimeMs; + if (!idleExpired && !lifetimeExpired) { + index++; + continue; + } + this.available.splice(index, 1); + this.all.delete(entry.slot); + reaped.push(entry); + } + if (reaped.length === 0) return; + await Promise.all(reaped.map((entry) => this.destroyRetired(entry))); + } + private async closeNow(): Promise { if (this.closed) return; this.closed = true; @@ -264,11 +307,18 @@ class QwpResourcePool { } private reserveSlot(): number | undefined { - if (this.all.size + this.creatingSlots.size >= this.maximum) { + if ( + this.all.size + this.creatingSlots.size + this.destroyingSlots.size >= + this.maximum + ) { return undefined; } for (let slot = 0; slot < this.maximum; slot++) { - if (!this.all.has(slot) && !this.creatingSlots.has(slot)) { + if ( + !this.all.has(slot) && + !this.creatingSlots.has(slot) && + !this.destroyingSlots.has(slot) + ) { this.creatingSlots.add(slot); return slot; } @@ -293,7 +343,14 @@ class QwpResourcePool { await this.destroyResource(value).catch(() => undefined); throw new QwpClientClosedError(); } - const entry: PoolEntry = { slot, value, leased: true }; + const nowMs = Date.now(); + const entry: PoolEntry = { + slot, + value, + createdAtMs: nowMs, + idleSinceMs: nowMs, + leased: true, + }; this.all.set(slot, entry); return entry; } finally { @@ -368,6 +425,17 @@ class QwpResourcePool { return entry.destroyPromise; } + private async destroyRetired(entry: PoolEntry): Promise { + this.destroyingSlots.add(entry.slot); + try { + await this.destroy(entry); + } finally { + this.destroyingSlots.delete(entry.slot); + this.wakeWaiters(); + this.wakeCloseWaiters(); + } + } + private throwIfClosed(): void { if (this.closed) throw new QwpClientClosedError(); } @@ -456,6 +524,9 @@ export class QwpClient { private closePromise?: Promise; private readonly startFactories?: () => void | Promise; private readonly closeFactories?: () => void | Promise; + private readonly housekeepingIntervalMs: number; + private housekeepingTask: Promise = Promise.resolve(); + private housekeeperTimer?: ReturnType; private closing = false; private closed = false; @@ -469,6 +540,8 @@ export class QwpClient { validated.senderPoolMin, validated.senderPoolMax, validated.acquireTimeoutMs, + validated.idleTimeoutMs, + validated.maxLifetimeMs, factories.createSender, (sender) => sender.close(), ); @@ -477,11 +550,14 @@ export class QwpClient { validated.queryPoolMin, validated.queryPoolMax, validated.acquireTimeoutMs, + validated.idleTimeoutMs, + validated.maxLifetimeMs, factories.createQuerySession, (session) => session.close(), ); this.startFactories = factories.start; this.closeFactories = factories.close; + this.housekeepingIntervalMs = validated.housekeepingIntervalMs; } /** Pre-connects the configured minimum sender and query pool sizes. */ @@ -546,7 +622,9 @@ export class QwpClient { private async closeNow(): Promise { if (this.closed) return; this.closing = true; + this.stopHousekeeper(); await this.startPromise?.catch(() => undefined); + await this.housekeepingTask; try { await Promise.resolve() .then(() => this.closeFactories?.()) @@ -559,11 +637,39 @@ export class QwpClient { private ensureStarted(): Promise { if (!this.startPromise) { - this.startPromise = Promise.resolve().then(() => this.startFactories?.()); + this.startPromise = Promise.resolve() + .then(() => this.startFactories?.()) + .then(() => { + if (!this.closing && !this.closed) this.startHousekeeper(); + }); } return this.startPromise; } + private startHousekeeper(): void { + if (this.housekeeperTimer) return; + this.housekeeperTimer = setInterval(() => { + this.housekeepingTask = this.housekeepingTask + .then(async () => { + if (this.closing || this.closed) return; + const nowMs = Date.now(); + await Promise.all([ + this.senderPool.reapIdle(nowMs), + this.queryPool.reapIdle(nowMs), + ]); + }) + .catch(() => undefined); + }, this.housekeepingIntervalMs); + const timer = this.housekeeperTimer as unknown as { unref?: () => void }; + timer.unref?.(); + } + + private stopHousekeeper(): void { + if (!this.housekeeperTimer) return; + clearInterval(this.housekeeperTimer); + this.housekeeperTimer = undefined; + } + private throwIfUnavailable(): void { if (this.closing || this.closed) throw new QwpClientClosedError(); } @@ -630,6 +736,10 @@ function validatePoolOptions( queryPoolMin: options.queryPoolMin ?? DEFAULT_POOL_MIN, queryPoolMax: options.queryPoolMax ?? DEFAULT_POOL_MAX, acquireTimeoutMs: options.acquireTimeoutMs ?? DEFAULT_ACQUIRE_TIMEOUT_MS, + idleTimeoutMs: options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS, + maxLifetimeMs: options.maxLifetimeMs ?? DEFAULT_MAX_LIFETIME_MS, + housekeepingIntervalMs: + options.housekeepingIntervalMs ?? DEFAULT_HOUSEKEEPING_INTERVAL_MS, }; validatePoolBounds( validated.senderPoolMin, @@ -643,9 +753,25 @@ function validatePoolOptions( ) { throw new RangeError("acquireTimeoutMs must be a non-negative number"); } + validateOptionalPoolTimeout(validated.idleTimeoutMs, "idleTimeoutMs"); + validateOptionalPoolTimeout(validated.maxLifetimeMs, "maxLifetimeMs"); + if ( + !Number.isFinite(validated.housekeepingIntervalMs) || + validated.housekeepingIntervalMs < MIN_HOUSEKEEPING_INTERVAL_MS + ) { + throw new RangeError( + `housekeepingIntervalMs must be at least ${MIN_HOUSEKEEPING_INTERVAL_MS}`, + ); + } return validated; } +function validateOptionalPoolTimeout(value: number, name: string): void { + if (!Number.isFinite(value) || value < 0) { + throw new RangeError(`${name} must be a non-negative number`); + } +} + function validatePoolBounds( minimum: number, maximum: number, diff --git a/test/qwp/client.test.ts b/test/qwp/client.test.ts index d60e8fc..c506aaf 100644 --- a/test/qwp/client.test.ts +++ b/test/qwp/client.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { encodeQwpFrame, QWP_EGRESS_MESSAGE, @@ -132,6 +132,26 @@ async function createQuerySession( } describe("QWP pooled client", () => { + it("validates idle, lifetime, and housekeeping options", () => { + const factories = { + createSender: async () => { + throw new Error("sender factory should not run"); + }, + createQuerySession: async () => { + throw new Error("query factory should not run"); + }, + }; + expect(() => new QwpClient(factories, { idleTimeoutMs: -1 })).toThrow( + "idleTimeoutMs must be a non-negative number", + ); + expect( + () => new QwpClient(factories, { maxLifetimeMs: Number.NaN }), + ).toThrow("maxLifetimeMs must be a non-negative number"); + expect( + () => new QwpClient(factories, { housekeepingIntervalMs: 99 }), + ).toThrow("housekeepingIntervalMs must be at least 100"); + }); + it("starts and stops runtime background services exactly once", async () => { let starts = 0; let closes = 0; @@ -305,6 +325,184 @@ describe("QWP pooled client", () => { expect(connections).toHaveLength(2); }); + it("reaps idle excess connections without shrinking below pool minimums", async () => { + vi.useFakeTimers(); + try { + const senderSessions: FakeSenderSession[] = []; + const connections: FakeConnection[] = []; + let queryCreations = 0; + const client = new QwpClient( + { + createSender: async () => { + const session = new FakeSenderSession(); + senderSessions.push(session); + const sender = new QwpSender(async () => session, { + autoFlush: false, + }); + await sender.connect(); + return sender; + }, + createQuerySession: async (slot) => { + queryCreations++; + return createQuerySession(slot, connections); + }, + }, + { + senderPoolMin: 0, + senderPoolMax: 1, + queryPoolMin: 1, + queryPoolMax: 2, + idleTimeoutMs: 200, + maxLifetimeMs: 0, + housekeepingIntervalMs: 100, + }, + ); + await client.connect(); + + const sender = await client.borrowSender(); + const [first, second] = await Promise.all([ + client.borrowQuery(), + client.borrowQuery(), + ]); + await Promise.all([sender.close(), first.close(), second.close()]); + expect(client.metrics).toMatchObject({ + senders: { total: 1, available: 1 }, + queries: { total: 2, available: 2 }, + }); + + await vi.advanceTimersByTimeAsync(200); + expect(client.metrics).toMatchObject({ + senders: { total: 0, available: 0 }, + queries: { total: 1, available: 1 }, + }); + expect(senderSessions[0].closes).toBe(1); + expect(connections.reduce((sum, item) => sum + item.closeCount, 0)).toBe( + 1, + ); + + const retained = await client.borrowQuery(); + expect(queryCreations).toBe(2); + await retained.close(); + await client.close(); + expect(connections.reduce((sum, item) => sum + item.closeCount, 0)).toBe( + 2, + ); + } finally { + vi.useRealTimers(); + } + }); + + it("recycles over-age connections after their active lease returns", async () => { + vi.useFakeTimers(); + try { + const connections: FakeConnection[] = []; + let queryCreations = 0; + const client = new QwpClient( + { + createSender: async () => { + throw new Error("sender factory should not run"); + }, + createQuerySession: async (slot) => { + queryCreations++; + return createQuerySession(slot, connections); + }, + }, + { + senderPoolMin: 0, + senderPoolMax: 1, + queryPoolMin: 0, + queryPoolMax: 1, + idleTimeoutMs: 0, + maxLifetimeMs: 250, + housekeepingIntervalMs: 100, + }, + ); + + const first = await client.borrowQuery(); + await first.close(); + await vi.advanceTimersByTimeAsync(200); + const active = await client.borrowQuery(); + expect(queryCreations).toBe(1); + + await vi.advanceTimersByTimeAsync(100); + expect(connections[0].closeCount).toBe(0); + await active.close(); + await vi.advanceTimersByTimeAsync(100); + expect(connections[0].closeCount).toBe(1); + expect(client.metrics.queries.total).toBe(0); + + const replacement = await client.borrowQuery(); + expect(queryCreations).toBe(2); + await replacement.close(); + await client.close(); + } finally { + vi.useRealTimers(); + } + }); + + it("does not reuse a reaped slot until its teardown completes", async () => { + vi.useFakeTimers(); + try { + const connections: FakeConnection[] = []; + let queryCreations = 0; + let releaseClose!: () => void; + const closeReleased = new Promise((resolve) => { + releaseClose = resolve; + }); + const client = new QwpClient( + { + createSender: async () => { + throw new Error("sender factory should not run"); + }, + createQuerySession: async (slot) => { + queryCreations++; + const session = await createQuerySession(slot, connections); + if (queryCreations === 1) { + const close = session.close.bind(session); + vi.spyOn(session, "close").mockImplementation(async () => { + await closeReleased; + await close(); + }); + } + return session; + }, + }, + { + senderPoolMin: 0, + senderPoolMax: 1, + queryPoolMin: 0, + queryPoolMax: 1, + acquireTimeoutMs: 1_000, + idleTimeoutMs: 100, + maxLifetimeMs: 0, + housekeepingIntervalMs: 100, + }, + ); + + const first = await client.borrowQuery(); + await first.close(); + await vi.advanceTimersByTimeAsync(100); + expect(client.metrics.queries.total).toBe(0); + + let replacementResolved = false; + const borrowing = client.borrowQuery().then((lease) => { + replacementResolved = true; + return lease; + }); + await Promise.resolve(); + expect(replacementResolved).toBe(false); + expect(queryCreations).toBe(1); + + releaseClose(); + const replacement = await borrowing; + expect(queryCreations).toBe(2); + await replacement.close(); + await client.close(); + } finally { + vi.useRealTimers(); + } + }); + it("leaves a timed-out query lease alive and closes it on late return", async () => { const connections: FakeConnection[] = []; const client = new QwpClient( diff --git a/test/qwp/public-api-contract.ts b/test/qwp/public-api-contract.ts index 851097d..c89fb83 100644 --- a/test/qwp/public-api-contract.ts +++ b/test/qwp/public-api-contract.ts @@ -34,6 +34,7 @@ import type { import type { QwpBinaryConnection, QwpClient, + QwpClientPoolOptions, QwpEgressQueryOptions, QwpEgressSession, QwpEgressSessionOptions, @@ -101,6 +102,17 @@ const nodeClientSignature: ( options: QwpNodeClientOptions, ) => Promise = connectQwpNodeClient; +const poolOptionsContract: QwpClientPoolOptions = { + senderPoolMin: 1, + senderPoolMax: 2, + queryPoolMin: 1, + queryPoolMax: 8, + acquireTimeoutMs: 5_000, + idleTimeoutMs: 60_000, + maxLifetimeMs: 30 * 60_000, + housekeepingIntervalMs: 5_000, +}; + const nodeOrphanScanSignature: ( rootDirectory: string, excludeSlot?: (slotName: string) => boolean, @@ -249,6 +261,7 @@ void nodeIngressSignature; void nodeEgressSignature; void nodeWebSocketSignature; void nodeClientSignature; +void poolOptionsContract; void nodeOrphanScanSignature; void nodeOrphanRetrySignature; void nodeStoreAndForwardContract; From 4a57aa36781df187945e728e6b558c71dfffe57c Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 18 Aug 2026 18:55:09 +0100 Subject: [PATCH 055/265] fix(qwp): close borrowed query sessions --- QWP.md | 11 +++++++---- src/qwp/client.ts | 35 ++++++++++++++++++++++++++++------- src/qwp/egress-session.ts | 21 +++++++++++++++++++++ test/qwp/client.test.ts | 32 +++++++++++++++++++++++--------- 4 files changed, 79 insertions(+), 20 deletions(-) diff --git a/QWP.md b/QWP.md index 440476f..4247763 100644 --- a/QWP.md +++ b/QWP.md @@ -735,10 +735,13 @@ each configured pool minimum. Set either timeout to zero to disable that policy; `housekeepingIntervalMs` controls how quickly an expired idle connection is noticed. Prefer returning application-owned leases before calling `QwpClient.close()`. If shutdown races a borrower, it rejects queued borrowers, closes idle connections, -and waits up to `acquireTimeoutMs` (capped at five seconds) for active leases to -return. It never closes a connection underneath its borrower. A lease returned -during or after shutdown is closed instead of re-entering the pool; a lease that is -never returned retains its connection. +and cancels active queries before closing every borrowed query connection. A query +lease that is never returned therefore cannot retain a WebSocket after client +shutdown; subsequent operations on it fail as closed. Borrowed senders remain under +their producer's ownership: shutdown waits up to `acquireTimeoutMs` (capped at five +seconds) for them to return and never closes a sender underneath its borrower. A +sender returned during or after shutdown is closed instead of re-entering the pool, +while a sender that outlives the bounded wait owns its eventual teardown. Pooled sender `close()` flushes completed rows, discards an unfinished row with a warning, and resets staging before reuse. With Node store-and-forward enabled, the diff --git a/src/qwp/client.ts b/src/qwp/client.ts index dcf94f7..e02261c 100644 --- a/src/qwp/client.ts +++ b/src/qwp/client.ts @@ -161,6 +161,7 @@ class QwpResourcePool { private readonly maxLifetimeMs: number, private readonly createResource: (slot: number) => Promise, private readonly destroyResource: (resource: T) => Promise, + private readonly closeLeasedOnShutdown = false, ) {} get metrics(): QwpResourcePoolMetrics { @@ -277,12 +278,25 @@ class QwpResourcePool { waiter.reject(new QwpClientClosedError()); } this.waiters.clear(); - // Only idle entries belong to the closing thread. Borrowed entries stay in - // `all` so their exclusive owners can keep using them and retire them from - // release(), even after this bounded close has returned. + // Idle entries always belong to the closing thread. Borrowed senders remain + // owner-managed, while borrowed query sessions are retired with them below. const entries = this.available.splice(0); for (const entry of entries) this.all.delete(entry.slot); - await Promise.all(entries.map((entry) => this.destroy(entry))); + const idleTeardown = Promise.all( + entries.map((entry) => this.destroy(entry)), + ); + let leasedTeardown: Promise | undefined; + if (this.closeLeasedOnShutdown) { + const leased = Array.from(this.all.values()).filter( + (entry) => entry.leased, + ); + for (const entry of leased) this.all.delete(entry.slot); + // Invoke every query teardown before awaiting any one WebSocket's bounded + // close handshake, so one slow idle socket cannot delay active-query + // cancellation on the other pool entries. + leasedTeardown = Promise.all(leased.map((entry) => this.destroy(entry))); + } + await idleTeardown; const creations = Array.from(this.creationOperations); if (creations.length > 0) { const waitMs = Math.min( @@ -301,6 +315,11 @@ class QwpResourcePool { if (timer) clearTimeout(timer); } } + if (leasedTeardown) { + await leasedTeardown; + this.wakeCloseWaiters(); + return; + } await this.waitForLeases( Math.min(this.acquireTimeoutMs, MAX_CLOSE_LEASE_WAIT_MS), ); @@ -553,7 +572,8 @@ export class QwpClient { validated.idleTimeoutMs, validated.maxLifetimeMs, factories.createQuerySession, - (session) => session.close(), + (session) => session.shutdownForClientClose(), + true, ); this.startFactories = factories.start; this.closeFactories = factories.close; @@ -598,8 +618,9 @@ export class QwpClient { } /** - * Rejects new borrows, closes idle resources, and waits boundedly for active - * leases. A lease that outlives the wait remains usable and owns its teardown. + * Rejects new borrows and closes idle resources. Borrowed query sessions are + * cancelled and closed; borrowed senders retain ownership during a bounded + * drain and own their teardown if they outlive it. */ close(): Promise { if (!this.closePromise) this.closePromise = this.closeNow(); diff --git a/src/qwp/egress-session.ts b/src/qwp/egress-session.ts index a9b1d30..bd79098 100644 --- a/src/qwp/egress-session.ts +++ b/src/qwp/egress-session.ts @@ -803,6 +803,27 @@ export class QwpEgressSession implements QwpEgressQueryControl { return this.closePromise; } + /** + * Best-effort cancellation followed by physical connection teardown for + * facade shutdown. Unlike pooled lease return, this does not wait for the + * server to finish draining the cancelled query. + * + * @internal + */ + shutdownForClientClose(): Promise { + const active = this.active; + if (active && !active.retired && !this.closing && !this.failure) { + try { + void this.connection + .send(encodeQwpCancel(active.requestId)) + .catch(() => undefined); + } catch { + // Cancellation is advisory; physical teardown is authoritative. + } + } + return this.close(1001, "QWP client shutting down"); + } + /** * Cancels and drains an active operation before a pooled lease is returned. * False means the physical session is no longer safe to reuse. diff --git a/test/qwp/client.test.ts b/test/qwp/client.test.ts index c506aaf..2f39c0f 100644 --- a/test/qwp/client.test.ts +++ b/test/qwp/client.test.ts @@ -10,6 +10,7 @@ import { QwpClientClosedError, QwpConnectionCloseInfo, QwpEgressSession, + QwpEgressSessionClosedError, QwpEgressSessionOptions, QwpHandshakeMetadata, QwpIngressResponse, @@ -503,7 +504,7 @@ describe("QWP pooled client", () => { } }); - it("leaves a timed-out query lease alive and closes it on late return", async () => { + it("cancels and closes every borrowed query session during client shutdown", async () => { const connections: FakeConnection[] = []; const client = new QwpClient( { @@ -516,27 +517,40 @@ describe("QWP pooled client", () => { senderPoolMin: 0, senderPoolMax: 1, queryPoolMin: 0, - queryPoolMax: 1, + queryPoolMax: 2, acquireTimeoutMs: 10, }, ); const lease = await client.borrowQuery(); + const idleLease = await client.borrowQuery(); + const query = await lease.query("select 1"); + const completion = expect(query.completion).rejects.toBeInstanceOf( + QwpEgressSessionClosedError, + ); await client.close(); - expect(connections[0].closeCount).toBe(0); - expect(lease.handshake).toMatchObject({ qwpVersion: 1 }); - const query = await lease.query("select 1"); - connections[0].receive(resultEnd(query.requestId)); - await expect(query.completion).resolves.toMatchObject({ totalRows: 0n }); + await completion; + expect(connections[0].sent).toHaveLength(2); + expect(connections[0].sent[1][0]).toBe(QWP_EGRESS_MESSAGE.CANCEL); + expect(connections[0].closeCount).toBe(1); + expect(connections[1].sent).toHaveLength(0); + expect(connections[1].closeCount).toBe(1); + await expect(lease.query("select 2")).rejects.toBeInstanceOf( + QwpEgressSessionClosedError, + ); + await expect(idleLease.query("select 3")).rejects.toBeInstanceOf( + QwpEgressSessionClosedError, + ); expect(client.metrics).toMatchObject({ closing: true, closed: true, - queries: { total: 1, leased: 1 }, + queries: { total: 0, leased: 0 }, }); await lease.close(); + await idleLease.close(); expect(connections[0].closeCount).toBe(1); - expect(client.metrics.queries).toMatchObject({ total: 0, leased: 0 }); + expect(connections[1].closeCount).toBe(1); }); it("runs reusable view queries through a pooled query lease", async () => { From 61fcd149244137d8140f01e3ca202c13be350569 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 18 Aug 2026 19:10:33 +0100 Subject: [PATCH 056/265] feat(qwp): split Node connection timeouts --- QWP.md | 18 +++++- src/qwp/internal/websocket-connection.ts | 81 +++++++++++++++++++---- src/qwp/node.ts | 31 +++++++++ src/qwp/transport.ts | 17 +++++ test/qwp/node-transport.test.ts | 40 +++++++++++- test/qwp/public-api-contract.ts | 7 ++ test/qwp/session.test.ts | 82 ++++++++++++++++++++++++ 7 files changed, 258 insertions(+), 18 deletions(-) diff --git a/QWP.md b/QWP.md index 4247763..51d1aac 100644 --- a/QWP.md +++ b/QWP.md @@ -68,6 +68,8 @@ const sender = await Sender.fromConfig( qwp: { webSocket: { requestDurableAck: true, + connectTimeoutMs: 5_000, + authTimeoutMs: 15_000, failoverUrls: ["wss://questdb-dr.example:9000/write/v4"], storeAndForward: { directory: "/var/lib/my-service/qwp-replay/producer-a", @@ -97,6 +99,15 @@ const sender = await Sender.fromConfig( ); ``` +Node bounds connection establishment in two phases. `connectTimeoutMs` covers +DNS plus the TCP/TLS connection; after that succeeds, `authTimeoutMs` independently +covers the authenticated HTTP request and WebSocket upgrade. Both default to 15 +seconds, so one endpoint attempt can take up to their sum. A timeout is reported as +`QwpUpgradeError` with `timeoutPhase` set to `"connect"` or `"authentication"`. +Browsers cannot observe the transport boundary, so their `connectTimeoutMs` continues +to cover the complete WebSocket opening lifecycle and they do not expose +`authTimeoutMs`. + Give each active sender its own store-and-forward directory. The Node.js journal persists frames and their symbol dictionary before sending. Persistent senders can start while every endpoint is offline and reconnect indefinitely by default. Unless @@ -785,9 +796,10 @@ The public error classes preserve enough context for policy decisions: Always close senders and sessions in `finally`. Sender publication plus ACK draining is bounded by `closeFlushTimeoutMs`; the subsequent WebSocket closing handshake is bounded -by `closeTimeoutMs`. `connectTimeoutMs`, `sendTimeoutMs`, acknowledgement timeouts, and -query deadlines cover separate lifecycle phases; configure each according to the -deployment rather than using one very large catch-all value. +by `closeTimeoutMs`. In Node, `connectTimeoutMs` and `authTimeoutMs` independently +bound transport connection and authenticated upgrade. `sendTimeoutMs`, acknowledgement +timeouts, and query deadlines cover later lifecycle phases; configure each according +to the deployment rather than using one very large catch-all value. ## Migration guide diff --git a/src/qwp/internal/websocket-connection.ts b/src/qwp/internal/websocket-connection.ts index 53bc3f4..1171705 100644 --- a/src/qwp/internal/websocket-connection.ts +++ b/src/qwp/internal/websocket-connection.ts @@ -1,6 +1,7 @@ import { QwpProtocolError } from "../core"; import { QWP_UPGRADE_ERROR_KIND, + QWP_UPGRADE_TIMEOUT_PHASE, QwpBinaryConnection, QwpConnectionCloseInfo, QwpHandshakeMetadata, @@ -71,6 +72,10 @@ export interface QwpWebSocketLike { export interface QwpWebSocketOpenOptions { url: string | URL; connectTimeoutMs?: number; + /** Node-only HTTP authentication and WebSocket upgrade deadline. */ + authTimeoutMs?: number; + /** Resolves after the Node TCP/TLS transport has connected. */ + transportConnected?: Promise; sendTimeoutMs?: number; closeTimeoutMs?: number; completeHandshake: () => QwpHandshakeMetadata; @@ -87,11 +92,13 @@ const DEFAULT_TIMEOUT_MS = 15_000; export function validateQwpWebSocketTimeouts(options: { connectTimeoutMs?: number; + authTimeoutMs?: number; sendTimeoutMs?: number; closeTimeoutMs?: number; }): void { for (const [name, value] of [ ["connectTimeoutMs", options.connectTimeoutMs], + ["authTimeoutMs", options.authTimeoutMs], ["sendTimeoutMs", options.sendTimeoutMs], ["closeTimeoutMs", options.closeTimeoutMs], ] as const) { @@ -133,6 +140,7 @@ export function openQwpWebSocket( return Promise.reject(error); } const connectTimeoutMs = options.connectTimeoutMs ?? DEFAULT_TIMEOUT_MS; + const authTimeoutMs = options.authTimeoutMs ?? DEFAULT_TIMEOUT_MS; const sendTimeoutMs = options.sendTimeoutMs ?? DEFAULT_TIMEOUT_MS; const closeTimeoutMs = options.closeTimeoutMs ?? DEFAULT_TIMEOUT_MS; @@ -338,18 +346,33 @@ export function openQwpWebSocket( }; return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - failOpening( - new QwpUpgradeError("QWP WebSocket connection timed out", { - kind: QWP_UPGRADE_ERROR_KIND.TIMEOUT, - retryable: true, - tryNextEndpoint: true, - url: options.url, - }), - 1000, - "QWP connection timeout", - ); - }, connectTimeoutMs); + let timeout: ReturnType | undefined; + + const armOpeningTimeout = ( + timeoutMs: number, + phase?: "connect" | "authentication", + ): void => { + if (timeout) clearTimeout(timeout); + timeout = setTimeout(() => { + const message = + phase === QWP_UPGRADE_TIMEOUT_PHASE.CONNECT + ? `QWP TCP/TLS connection timed out after ${timeoutMs}ms` + : phase === QWP_UPGRADE_TIMEOUT_PHASE.AUTHENTICATION + ? `QWP authentication/WebSocket upgrade timed out after ${timeoutMs}ms` + : `QWP WebSocket connection timed out after ${timeoutMs}ms`; + failOpening( + new QwpUpgradeError(message, { + kind: QWP_UPGRADE_ERROR_KIND.TIMEOUT, + retryable: true, + tryNextEndpoint: true, + url: options.url, + timeoutPhase: phase, + }), + 1000, + "QWP connection timeout", + ); + }, timeoutMs); + }; const failOpening = ( error: Error, @@ -358,11 +381,41 @@ export function openQwpWebSocket( ): void => { if (openingSettled) return; openingSettled = true; - clearTimeout(timeout); + if (timeout) clearTimeout(timeout); void closeSocket(closeCode, closeReason); reject(error); }; + armOpeningTimeout( + connectTimeoutMs, + options.transportConnected + ? QWP_UPGRADE_TIMEOUT_PHASE.CONNECT + : undefined, + ); + void options.transportConnected?.then( + () => { + if (openingSettled) return; + armOpeningTimeout( + authTimeoutMs, + QWP_UPGRADE_TIMEOUT_PHASE.AUTHENTICATION, + ); + }, + (error: unknown) => { + failOpening( + new QwpUpgradeError( + "QWP TCP/TLS transport failed while establishing a connection", + { + kind: QWP_UPGRADE_ERROR_KIND.TRANSPORT, + retryable: true, + tryNextEndpoint: true, + url: options.url, + cause: error, + }, + ), + ); + }, + ); + const onOpen = (): void => { if (openingSettled) return; let handshake: QwpHandshakeMetadata; @@ -380,7 +433,7 @@ export function openQwpWebSocket( } openingSettled = true; opened = true; - clearTimeout(timeout); + if (timeout) clearTimeout(timeout); const connection: QwpBinaryConnection = { messages, closed, diff --git a/src/qwp/node.ts b/src/qwp/node.ts index 4d775f6..1f74ca6 100644 --- a/src/qwp/node.ts +++ b/src/qwp/node.ts @@ -160,6 +160,11 @@ export interface QwpNodeWebSocketOptions extends QwpWebSocketConnectOptions { headers?: Record; /** Optional HTTP(S) agent used for the WebSocket upgrade. */ agent?: Agent; + /** + * Time allowed after TCP/TLS connection for HTTP authentication and the + * WebSocket upgrade. Defaults to 15s. + */ + authTimeoutMs?: number; authorization?: string; clientId?: string; maxVersion?: number; @@ -171,6 +176,8 @@ export interface QwpNodeWebSocketOptions extends QwpWebSocketConnectOptions { protocols?: string | string[]; agent?: Agent; headers: Record; + /** Must be called when the underlying TCP/TLS transport is connected. */ + onConnected: () => void; onUpgrade: (headers: IncomingHttpHeaders) => void; onUpgradeRejected: (rejection: QwpNodeUpgradeRejection) => void; }, @@ -326,6 +333,7 @@ function connectQwpNodeEndpoint( protocols?: string | string[]; agent?: Agent; headers: Record; + onConnected: () => void; onUpgrade: (headers: IncomingHttpHeaders) => void; onUpgradeRejected: (rejection: QwpNodeUpgradeRejection) => void; }, @@ -334,6 +342,22 @@ function connectQwpNodeEndpoint( agent: init.agent, headers: init.headers, perMessageDeflate: false, + finishRequest: (request) => { + request.once("socket", (socket) => { + if (!socket.connecting) { + init.onConnected(); + return; + } + const protocol = new URL(url).protocol; + socket.once( + protocol === "wss:" || protocol === "https:" + ? "secureConnect" + : "connect", + init.onConnected, + ); + }); + request.end(); + }, }; const socket = init.protocols ? new WebSocket(url, init.protocols, wsOptions) @@ -355,6 +379,10 @@ function connectQwpNodeEndpoint( }); let upgradeHeaders: IncomingHttpHeaders | undefined; + let resolveConnected!: () => void; + const transportConnected = new Promise((resolve) => { + resolveConnected = resolve; + }); let rejectOpening!: (error: QwpUpgradeError) => void; const openingFailure = new Promise((_resolve, reject) => { rejectOpening = reject; @@ -363,6 +391,7 @@ function connectQwpNodeEndpoint( protocols: options.protocols, agent: options.agent, headers, + onConnected: resolveConnected, onUpgrade: (receivedHeaders) => { upgradeHeaders = receivedHeaders; }, @@ -373,6 +402,8 @@ function connectQwpNodeEndpoint( return openQwpWebSocket(socket, { url: endpoint, connectTimeoutMs: options.connectTimeoutMs, + authTimeoutMs: options.authTimeoutMs, + transportConnected, sendTimeoutMs: options.sendTimeoutMs, closeTimeoutMs: options.closeTimeoutMs, openingFailure, diff --git a/src/qwp/transport.ts b/src/qwp/transport.ts index c022f7a..428f3a6 100644 --- a/src/qwp/transport.ts +++ b/src/qwp/transport.ts @@ -261,6 +261,15 @@ export const QWP_UPGRADE_ERROR_KIND = { export type QwpUpgradeErrorKind = (typeof QWP_UPGRADE_ERROR_KIND)[keyof typeof QWP_UPGRADE_ERROR_KIND]; +export const QWP_UPGRADE_TIMEOUT_PHASE = { + CONNECT: "connect", + AUTHENTICATION: "authentication", +} as const; + +/** Opening phase whose Node QWP deadline expired. */ +export type QwpUpgradeTimeoutPhase = + (typeof QWP_UPGRADE_TIMEOUT_PHASE)[keyof typeof QWP_UPGRADE_TIMEOUT_PHASE]; + export interface QwpUpgradeErrorDetails { kind: QwpUpgradeErrorKind; /** Whether a later retry against the configured endpoint set may recover. */ @@ -273,6 +282,7 @@ export interface QwpUpgradeErrorDetails { serverRole?: string; serverZone?: string; closeCode?: number; + timeoutPhase?: QwpUpgradeTimeoutPhase; cause?: unknown; } @@ -304,6 +314,8 @@ export class QwpUpgradeError extends Error { readonly serverRole?: string; readonly serverZone?: string; readonly closeCode?: number; + /** Node opening phase that exceeded its deadline. */ + readonly timeoutPhase?: QwpUpgradeTimeoutPhase; readonly cause?: unknown; constructor(message: string, details: QwpUpgradeErrorDetails) { @@ -318,6 +330,7 @@ export class QwpUpgradeError extends Error { this.serverRole = details.serverRole; this.serverZone = details.serverZone; this.closeCode = details.closeCode; + this.timeoutPhase = details.timeoutPhase; this.cause = details.cause; } @@ -426,6 +439,10 @@ export interface QwpWebSocketConnectOptions { /** Additional endpoints attempted in order when the preferred endpoint fails. */ failoverUrls?: readonly (string | URL)[]; protocols?: string | string[]; + /** + * Node TCP/TLS connection deadline, or the complete opening deadline in a + * browser. Defaults to 15s. + */ connectTimeoutMs?: number; /** Maximum time a send may remain queued by the WebSocket. Defaults to 15s. */ sendTimeoutMs?: number; diff --git a/test/qwp/node-transport.test.ts b/test/qwp/node-transport.test.ts index af3b005..f46a506 100644 --- a/test/qwp/node-transport.test.ts +++ b/test/qwp/node-transport.test.ts @@ -1,4 +1,5 @@ -import type { AddressInfo } from "node:net"; +import type { AddressInfo, Socket } from "node:net"; +import { createServer as createTcpServer } from "node:net"; import { mkdtemp, readdir, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -16,6 +17,7 @@ import { QWP_SERVER_ROLE, QWP_STATUS, QWP_UPGRADE_ERROR_KIND, + QWP_UPGRADE_TIMEOUT_PHASE, QwpByteWriter, QwpNodeFileReplayStore, QwpUpgradeError, @@ -98,6 +100,42 @@ describe("QWP Node transport", () => { server = undefined; }); + it("times out authentication separately after a real TCP connection", async () => { + const sockets = new Set(); + const tcpServer = createTcpServer((socket) => { + sockets.add(socket); + socket.once("close", () => sockets.delete(socket)); + // Accept the HTTP upgrade request but deliberately never answer it. + socket.resume(); + }); + await new Promise((resolve, reject) => { + tcpServer.once("error", reject); + tcpServer.listen(0, "127.0.0.1", resolve); + }); + + try { + const address = tcpServer.address() as AddressInfo; + await expect( + connectQwpNodeWebSocket({ + url: `ws://127.0.0.1:${address.port}/write/v4`, + connectTimeoutMs: 1_000, + authTimeoutMs: 25, + closeTimeoutMs: 25, + }), + ).rejects.toMatchObject({ + name: "QwpUpgradeError", + kind: QWP_UPGRADE_ERROR_KIND.TIMEOUT, + timeoutPhase: QWP_UPGRADE_TIMEOUT_PHASE.AUTHENTICATION, + message: "QWP authentication/WebSocket upgrade timed out after 25ms", + } satisfies Partial); + } finally { + for (const socket of sockets) socket.destroy(); + await new Promise((resolve, reject) => { + tcpServer.close((error) => (error ? reject(error) : resolve())); + }); + } + }); + it("negotiates durable ACK and polls progress with a WebSocket PING", async () => { const table = "trades"; const sequenceTransaction = 7n; diff --git a/test/qwp/public-api-contract.ts b/test/qwp/public-api-contract.ts index c89fb83..e61eb9c 100644 --- a/test/qwp/public-api-contract.ts +++ b/test/qwp/public-api-contract.ts @@ -98,6 +98,12 @@ const nodeWebSocketSignature: ( options: QwpNodeWebSocketOptions, ) => Promise = connectQwpNodeWebSocket; +const nodeWebSocketOptionsContract: QwpNodeWebSocketOptions = { + url: "wss://node-1.example/write/v4", + connectTimeoutMs: 5_000, + authTimeoutMs: 15_000, +}; + const nodeClientSignature: ( options: QwpNodeClientOptions, ) => Promise = connectQwpNodeClient; @@ -260,6 +266,7 @@ void nodeSenderSignature; void nodeIngressSignature; void nodeEgressSignature; void nodeWebSocketSignature; +void nodeWebSocketOptionsContract; void nodeClientSignature; void poolOptionsContract; void nodeOrphanScanSignature; diff --git a/test/qwp/session.test.ts b/test/qwp/session.test.ts index aa27764..c1a0fd6 100644 --- a/test/qwp/session.test.ts +++ b/test/qwp/session.test.ts @@ -24,6 +24,7 @@ import { QWP_INGRESS_PROGRESS_KIND, QWP_STATUS, QWP_UPGRADE_ERROR_KIND, + QWP_UPGRADE_TIMEOUT_PHASE, QwpBatchTooLargeError, QwpByteReader, QwpByteWriter, @@ -1051,6 +1052,87 @@ describe("QWP WebSocket adapters", () => { } }); + it("separately bounds Node transport connection and authenticated upgrade", async () => { + vi.useFakeTimers(); + try { + const connectSocket = new FakeWebSocket(); + const connecting = connectQwpNodeWebSocket({ + url: "ws://localhost:9000/write/v4", + connectTimeoutMs: 25, + authTimeoutMs: 100, + webSocketFactory: () => asQwpSocket(connectSocket), + }); + const connectRejected = expect(connecting).rejects.toMatchObject({ + name: "QwpUpgradeError", + kind: QWP_UPGRADE_ERROR_KIND.TIMEOUT, + timeoutPhase: QWP_UPGRADE_TIMEOUT_PHASE.CONNECT, + message: "QWP TCP/TLS connection timed out after 25ms", + } satisfies Partial); + await vi.advanceTimersByTimeAsync(25); + await connectRejected; + + const upgradeSocket = new FakeWebSocket(); + let markUpgradeTransportConnected!: () => void; + const upgrading = connectQwpNodeWebSocket({ + url: "ws://localhost:9000/write/v4", + connectTimeoutMs: 100, + authTimeoutMs: 25, + webSocketFactory: (_url, options) => { + markUpgradeTransportConnected = options.onConnected; + return asQwpSocket(upgradeSocket); + }, + }); + const upgradeRejected = expect(upgrading).rejects.toMatchObject({ + name: "QwpUpgradeError", + kind: QWP_UPGRADE_ERROR_KIND.TIMEOUT, + timeoutPhase: QWP_UPGRADE_TIMEOUT_PHASE.AUTHENTICATION, + message: "QWP authentication/WebSocket upgrade timed out after 25ms", + } satisfies Partial); + markUpgradeTransportConnected(); + await vi.advanceTimersByTimeAsync(25); + await upgradeRejected; + + const phasedSocket = new FakeWebSocket(); + let markPhasedTransportConnected!: () => void; + const phased = connectQwpNodeWebSocket({ + url: "ws://localhost:9000/write/v4", + connectTimeoutMs: 25, + authTimeoutMs: 25, + webSocketFactory: (_url, options) => { + markPhasedTransportConnected = options.onConnected; + options.onUpgrade({}); + return asQwpSocket(phasedSocket); + }, + }); + await vi.advanceTimersByTimeAsync(20); + markPhasedTransportConnected(); + await vi.advanceTimersByTimeAsync(20); + phasedSocket.open(); + const phasedConnection = await phased; + expect(phasedConnection).toMatchObject({ + handshake: { qwpVersion: 1 }, + }); + await phasedConnection.close(); + } finally { + vi.useRealTimers(); + } + }); + + it("validates the Node authentication/upgrade timeout before opening", async () => { + let factoryCalls = 0; + await expect( + connectQwpNodeWebSocket({ + url: "ws://localhost:9000/write/v4", + authTimeoutMs: 0, + webSocketFactory: () => { + factoryCalls++; + return asQwpSocket(new FakeWebSocket()); + }, + }), + ).rejects.toThrow("authTimeoutMs must be a positive finite number"); + expect(factoryCalls).toBe(0); + }); + it("bounds browser close when the peer never emits a close event", async () => { vi.useFakeTimers(); try { From 9c72dd3ab423e3dc360c7990ee4601ba62ca4254 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 18 Aug 2026 20:47:05 +0100 Subject: [PATCH 057/265] feat(qwp): jitter reconnect backoff --- QWP.md | 6 +- src/qwp/internal/reconnect-backoff.ts | 9 ++ .../reconnecting-egress-connection.ts | 7 +- .../reconnecting-ingress-connection.ts | 8 +- src/qwp/transport.ts | 4 +- test/qwp/reconnect.test.ts | 86 +++++++++++++++++++ 6 files changed, 113 insertions(+), 7 deletions(-) create mode 100644 src/qwp/internal/reconnect-backoff.ts diff --git a/QWP.md b/QWP.md index 51d1aac..cd22883 100644 --- a/QWP.md +++ b/QWP.md @@ -409,8 +409,10 @@ rejection) and then by zone affinity; configuration order breaks ties. Health ou zone, so a known healthy cross-zone node is preferred to an untried local node. Every connection sweep can still try every endpoint, allowing role and health changes to recover. A non-orderly close demotes the selected endpoint before the next sweep. -`reconnect` controls exponential backoff and emits lifecycle events. Its attempt and -duration bounds apply to browser/memory reconnect and Node `"sync"` startup. A Node +`reconnect` controls full-jitter exponential backoff and emits lifecycle events. Each +retry delay is selected between zero and the current exponential ceiling, preventing +clients disconnected together from retrying in lockstep. Its attempt and duration +bounds apply to browser/memory reconnect and Node `"sync"` startup. A Node foreground store-and-forward replay loop remains unbounded after startup. Node ingress requires a persistent replay store when reconnect is enabled; browser ingress can only replay from memory for the lifetime of the page. diff --git a/src/qwp/internal/reconnect-backoff.ts b/src/qwp/internal/reconnect-backoff.ts new file mode 100644 index 0000000..2089829 --- /dev/null +++ b/src/qwp/internal/reconnect-backoff.ts @@ -0,0 +1,9 @@ +/** + * Applies full jitter to an exponential-backoff ceiling. Full jitter keeps the + * configured maximum a hard upper bound while spreading clients throughout + * every retry window after a shared outage. + */ +export function jitterReconnectDelayMs(ceilingMs: number): number { + if (ceilingMs <= 0) return 0; + return Math.floor(Math.random() * ceilingMs); +} diff --git a/src/qwp/internal/reconnecting-egress-connection.ts b/src/qwp/internal/reconnecting-egress-connection.ts index 76f05b0..175b1ab 100644 --- a/src/qwp/internal/reconnecting-egress-connection.ts +++ b/src/qwp/internal/reconnecting-egress-connection.ts @@ -21,6 +21,7 @@ import { QwpUpgradeError, } from "../transport"; import { QwpAsyncQueue } from "./async-queue"; +import { jitterReconnectDelayMs } from "./reconnect-backoff"; type ReplayResetHandler = ( event: QwpEgressReplayResetEvent, @@ -198,11 +199,15 @@ export class QwpReconnectingEgressConnection implements QwpBinaryConnection { previousEndpoint, cause: initialCause, }); + if (backoffMs > 0) { + await this.waitForBackoff(jitterReconnectDelayMs(backoffMs)); + backoffMs = Math.min(Math.max(backoffMs * 2, 1), this.maxBackoffMs); + } } while (!this.closing) { if (attempt > 0 && backoffMs > 0) { - await this.waitForBackoff(backoffMs); + await this.waitForBackoff(jitterReconnectDelayMs(backoffMs)); backoffMs = Math.min(Math.max(backoffMs * 2, 1), this.maxBackoffMs); } this.throwIfUnavailable(); diff --git a/src/qwp/internal/reconnecting-ingress-connection.ts b/src/qwp/internal/reconnecting-ingress-connection.ts index 8fe0016..123de44 100644 --- a/src/qwp/internal/reconnecting-ingress-connection.ts +++ b/src/qwp/internal/reconnecting-ingress-connection.ts @@ -34,6 +34,7 @@ import { QwpUpgradeError, } from "../transport"; import { QwpAsyncQueue } from "./async-queue"; +import { jitterReconnectDelayMs } from "./reconnect-backoff"; const DEFAULT_CATCH_UP_CAP_GAP_MIN_ESCALATION_WINDOW_MS = 300_000; const MAX_CATCH_UP_CAP_GAP_ATTEMPTS = 16; @@ -519,12 +520,15 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { const initialRetryDelayMs = reconnectDelayMs(initialCause); if (initialRetryDelayMs > 0) { - await this.waitForBackoff(initialRetryDelayMs); + await this.waitForBackoff(jitterReconnectDelayMs(initialRetryDelayMs)); + } else if (reconnecting && backoffMs > 0) { + await this.waitForBackoff(jitterReconnectDelayMs(backoffMs)); + backoffMs = Math.min(Math.max(backoffMs * 2, 1), this.maxBackoffMs); } while (!this.closing) { if (attempt > 0 && backoffMs > 0) { - await this.waitForBackoff(backoffMs); + await this.waitForBackoff(jitterReconnectDelayMs(backoffMs)); backoffMs = Math.min(Math.max(backoffMs * 2, 1), this.maxBackoffMs); } this.throwIfUnavailable(); diff --git a/src/qwp/transport.ts b/src/qwp/transport.ts index 428f3a6..3e8098e 100644 --- a/src/qwp/transport.ts +++ b/src/qwp/transport.ts @@ -216,9 +216,9 @@ export type QwpInitialConnectMode = export interface QwpReconnectOptions { /** Maximum connection sweeps per outage. Defaults to 3; zero is unlimited. */ maxAttempts?: number; - /** Backoff before the first failed sweep is retried. Defaults to 100ms. */ + /** Full-jitter ceiling before the first failed sweep is retried. Defaults to 100ms. */ initialBackoffMs?: number; - /** Exponential-backoff ceiling. Defaults to 5s. */ + /** Full-jitter exponential-backoff ceiling. Defaults to 5s. */ maxBackoffMs?: number; /** Total reconnect deadline. Defaults to 30s; zero disables the deadline. */ maxDurationMs?: number; diff --git a/test/qwp/reconnect.test.ts b/test/qwp/reconnect.test.ts index d907fb8..bab5668 100644 --- a/test/qwp/reconnect.test.ts +++ b/test/qwp/reconnect.test.ts @@ -423,6 +423,48 @@ describe("QWP endpoint failover", () => { }); describe("QWP ingress reconnect and replay", () => { + it("applies full jitter to ingress reconnect backoff", async () => { + vi.useFakeTimers(); + const random = vi.spyOn(Math, "random").mockReturnValue(0.25); + try { + const connection = new FakeConnection("primary"); + let factoryCalls = 0; + const connecting = QwpIngressSession.connect( + async () => { + factoryCalls++; + if (factoryCalls === 1) { + throw new QwpUpgradeError("offline", { + kind: QWP_UPGRADE_ERROR_KIND.TRANSPORT, + retryable: true, + tryNextEndpoint: true, + }); + } + return connection; + }, + { + reconnect: { + maxAttempts: 2, + initialBackoffMs: 100, + maxBackoffMs: 100, + }, + }, + ); + + await vi.advanceTimersByTimeAsync(0); + expect(factoryCalls).toBe(1); + await vi.advanceTimersByTimeAsync(24); + expect(factoryCalls).toBe(1); + await vi.advanceTimersByTimeAsync(1); + const session = await connecting; + expect(factoryCalls).toBe(2); + expect(random).toHaveBeenCalledTimes(1); + await session.close(); + } finally { + random.mockRestore(); + vi.useRealTimers(); + } + }); + it("supports fail-fast and bounded blocking persistent startup", async () => { const failFastStore = new TrackingReplayStore(); let failFastCalls = 0; @@ -1595,6 +1637,50 @@ describe("QWP ingress reconnect and replay", () => { }); describe("QWP egress reconnect and replay", () => { + it("applies full jitter to egress reconnect backoff", async () => { + vi.useFakeTimers(); + const random = vi.spyOn(Math, "random").mockReturnValue(0.25); + try { + const connection = new FakeConnection("primary"); + let factoryCalls = 0; + const connecting = QwpEgressSession.connect( + async () => { + factoryCalls++; + if (factoryCalls === 1) { + throw new QwpUpgradeError("offline", { + kind: QWP_UPGRADE_ERROR_KIND.TRANSPORT, + retryable: true, + tryNextEndpoint: true, + }); + } + queueMicrotask(() => connection.receive(serverInfo("primary"))); + return connection; + }, + { + serverInfoTimeoutMs: 1_000, + reconnect: { + maxAttempts: 2, + initialBackoffMs: 100, + maxBackoffMs: 100, + }, + }, + ); + + await vi.advanceTimersByTimeAsync(0); + expect(factoryCalls).toBe(1); + await vi.advanceTimersByTimeAsync(24); + expect(factoryCalls).toBe(1); + await vi.advanceTimersByTimeAsync(1); + const session = await connecting; + expect(factoryCalls).toBe(2); + expect(random).toHaveBeenCalledTimes(1); + await session.close(); + } finally { + random.mockRestore(); + vi.useRealTimers(); + } + }); + it("retries the initial connection until one provides SERVER_INFO", async () => { const first = new FakeConnection("primary"); const second = new FakeConnection("secondary"); From 30f0c2f9aef3450e0e8ea9231b81e8c29b955018 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 18 Aug 2026 21:12:05 +0100 Subject: [PATCH 058/265] feat(qwp): unify Node client configuration --- QWP.md | 40 ++ src/qwp-node/client-config.ts | 812 ++++++++++++++++++++++++++++ src/qwp/node.ts | 113 +++- test/qwp/node-client-config.test.ts | 249 +++++++++ test/qwp/public-api-contract.ts | 12 + 5 files changed, 1223 insertions(+), 3 deletions(-) create mode 100644 src/qwp-node/client-config.ts create mode 100644 test/qwp/node-client-config.test.ts diff --git a/QWP.md b/QWP.md index cd22883..a0af570 100644 --- a/QWP.md +++ b/QWP.md @@ -677,6 +677,46 @@ and concurrent queries. The Node and browser entry points provide configured factories; each borrowed handle exclusively owns one pooled WebSocket until its `close()` returns it: +For Node, the recommended common-case API accepts one Java-style +`ws::`/`wss::` cluster string. Every `addr` entry is shared by ingress and +egress; the facade derives `/write/v4` and `/read/v1`, applies the same +authentication and TLS configuration to both sides, and validates ingress, +egress, and pool settings before opening a socket: + +```typescript +import { connectQwpNodeClient } from "@questdb/nodejs-client/qwp/node"; + +const db = await connectQwpNodeClient( + "wss::" + + "addr=node-a.example:9000,node-b.example:9000;" + + `token=${token};` + + "target=replica;zone=eu-west-1a;" + + "sender_pool_max=2;query_pool_max=8;", +); +``` + +Repeated `addr=` keys also accumulate endpoints. Programmatic overrides for +callbacks, custom agents, store-and-forward, sender/session settings, and pool +sizes may be passed as the second argument. The whole string is still validated +before overrides are applied, matching the Java builder's fail-fast behavior. + +Set `lazy_connect=on` to tolerate an unavailable cluster during startup. In the +TypeScript client this requires `sf_dir`: ingress uses persistent +store-and-forward with `initial_connect_retry=async`, while egress uses +`query_pool_min=0` and connects on the first query. Explicit +`initial_connect_retry=off|sync` or a positive `query_pool_min` conflicts with +`lazy_connect` and is rejected before the client is created: + +```typescript +const db = await connectQwpNodeClient( + "wss::addr=node-a.example,node-b.example;" + + "sf_dir=/var/lib/my-app/qwp;lazy_connect=on;", +); +``` + +The object form remains available for cases where constructing the two sides +separately is useful: + ```typescript import { connectQwpNodeClient } from "@questdb/nodejs-client/qwp/node"; diff --git a/src/qwp-node/client-config.ts b/src/qwp-node/client-config.ts new file mode 100644 index 0000000..713863d --- /dev/null +++ b/src/qwp-node/client-config.ts @@ -0,0 +1,812 @@ +import { readFileSync } from "node:fs"; +import { Agent as HttpsAgent } from "node:https"; +import type { + QwpNodeClientConfigOptions, + QwpNodeClientOptions, + QwpNodeEgressOptions, + QwpNodeIngressOptions, + QwpNodeStoreAndForwardOptions, +} from "../qwp/node"; +import type { QwpClientPoolOptions } from "../qwp/client"; +import type { QwpEgressSessionOptions } from "../qwp/egress-session"; +import type { QwpIngressSessionOptions } from "../qwp/ingress-session"; +import type { QwpSenderOptions } from "../qwp/sender"; +import type { QwpReconnectOptions, QwpTarget } from "../qwp/transport"; + +const DEFAULT_QWP_PORT = 9000; +const MAX_BATCH_ROWS = 1_048_576; + +const SUPPORTED_KEYS = new Set([ + "addr", + "username", + "password", + "user", + "pass", + "token", + "tls_verify", + "tls_roots", + "auth_timeout_ms", + "connect_timeout", + "auto_flush", + "auto_flush_bytes", + "auto_flush_interval", + "auto_flush_rows", + "close_flush_timeout_millis", + "drain_orphans", + "durable_ack_keepalive_interval_millis", + "initial_connect_retry", + "max_background_drainers", + "max_frame_rejections", + "poison_min_escalation_window_millis", + "catch_up_cap_gap_min_escalation_window_millis", + "reconnect_initial_backoff_millis", + "reconnect_max_backoff_millis", + "reconnect_max_duration_millis", + "request_durable_ack", + "sf_append_deadline_millis", + "sf_dir", + "sf_durability", + "sf_max_total_bytes", + "sf_sync_interval_millis", + "transaction", + "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", + "sender_pool_min", + "sender_pool_max", + "query_pool_min", + "query_pool_max", + "acquire_timeout_ms", + "query_close_timeout_ms", + "idle_timeout_ms", + "max_lifetime_ms", + "housekeeper_interval_ms", + "lazy_connect", + // Reserved by the shared QWP configuration vocabulary. They are accepted + // as intentional no-ops until the TypeScript client exposes these policies. + "on_internal_error", + "on_parse_error", + "on_schema_error", + "on_security_error", + "on_server_error", + "on_write_error", +]); + +const UNSUPPORTED_KEYS = new Set([ + "tls_roots_password", + "connection_listener_inbox_capacity", + "error_inbox_capacity", + "max_name_len", + "sender_id", + "sf_max_segment_bytes", +]); + +interface ParsedConfig { + readonly schema: "ws" | "wss"; + readonly values: ReadonlyMap; +} + +/** Parses one ws/wss cluster string into the combined Node facade options. */ +export function resolveQwpNodeClientConfig( + configurationString: string, + extraOptions: QwpNodeClientConfigOptions = {}, +): QwpNodeClientOptions { + const parsed = parseConfigurationString(configurationString); + const value = (key: string): string | undefined => + parsed.values.get(key)?.[0]; + const endpoints = parseEndpoints(parsed); + + validateAuthentication(parsed.values); + validateTls(parsed); + + const authorization = createAuthorization(parsed.values); + const configuredAgent = createTlsAgent(parsed); + + const common = { + ...extraOptions.webSocket, + connectTimeoutMs: + extraOptions.webSocket?.connectTimeoutMs ?? + optionalPositiveInteger(value("connect_timeout"), "connect_timeout"), + authTimeoutMs: + extraOptions.webSocket?.authTimeoutMs ?? + optionalPositiveInteger(value("auth_timeout_ms"), "auth_timeout_ms"), + clientId: extraOptions.webSocket?.clientId ?? value("client_id"), + authorization: extraOptions.webSocket?.authorization ?? authorization, + agent: extraOptions.webSocket?.agent ?? configuredAgent, + }; + + const ingressReconnect = parseIngressReconnect(parsed.values); + const egressReconnect = parseEgressReconnect(parsed.values); + const configuredStoreAndForward = parseStoreAndForward( + parsed.values, + extraOptions.storeAndForward?.directory, + ); + const storeAndForward = extraOptions.storeAndForward + ? { ...configuredStoreAndForward, ...extraOptions.storeAndForward } + : configuredStoreAndForward; + validateStoreAndForwardDependencies(parsed.values, storeAndForward); + + const sender: QwpSenderOptions = { + autoFlush: optionalBoolean(value("auto_flush"), "auto_flush"), + autoFlushRows: optionalInteger( + value("auto_flush_rows"), + "auto_flush_rows", + 0, + ), + autoFlushBytes: optionalSize( + value("auto_flush_bytes"), + "auto_flush_bytes", + 0, + true, + ), + autoFlushIntervalMs: optionalInteger( + value("auto_flush_interval"), + "auto_flush_interval", + 0, + ), + closeFlushTimeoutMs: optionalInteger( + value("close_flush_timeout_millis"), + "close_flush_timeout_millis", + 0, + ), + transactional: optionalBoolean(value("transaction"), "transaction"), + ...extraOptions.sender, + }; + + const ingressSession: QwpIngressSessionOptions = { + reconnect: ingressReconnect, + durableAckKeepaliveMs: optionalInteger( + value("durable_ack_keepalive_interval_millis"), + "durable_ack_keepalive_interval_millis", + 0, + ), + ...extraOptions.ingressSession, + }; + const egressSession: QwpEgressSessionOptions = { + reconnect: egressReconnect, + initialCredit: optionalInteger( + value("initial_credit"), + "initial_credit", + 0, + ), + bufferPoolSize: optionalInteger( + value("buffer_pool_size"), + "buffer_pool_size", + 1, + ), + cancelDrainTimeoutMs: optionalInteger( + value("query_close_timeout_ms"), + "query_close_timeout_ms", + 0, + ), + ...extraOptions.egressSession, + }; + + const pool: QwpClientPoolOptions = { + senderPoolMin: optionalInteger( + value("sender_pool_min"), + "sender_pool_min", + 0, + ), + senderPoolMax: optionalInteger( + value("sender_pool_max"), + "sender_pool_max", + 1, + ), + queryPoolMin: optionalInteger(value("query_pool_min"), "query_pool_min", 0), + queryPoolMax: optionalInteger(value("query_pool_max"), "query_pool_max", 1), + acquireTimeoutMs: optionalInteger( + value("acquire_timeout_ms"), + "acquire_timeout_ms", + 0, + ), + idleTimeoutMs: optionalInteger( + value("idle_timeout_ms"), + "idle_timeout_ms", + 0, + ), + maxLifetimeMs: optionalInteger( + value("max_lifetime_ms"), + "max_lifetime_ms", + 0, + ), + housekeepingIntervalMs: optionalInteger( + value("housekeeper_interval_ms"), + "housekeeper_interval_ms", + 100, + ), + ...extraOptions.pool, + }; + validatePool(pool); + + const ingress: QwpNodeIngressOptions = { + ...common, + url: withPath(endpoints[0], "/write/v4"), + failoverUrls: endpoints + .slice(1) + .map((endpoint) => withPath(endpoint, "/write/v4")), + requestDurableAck: + extraOptions.webSocket?.requestDurableAck ?? + optionalBoolean(value("request_durable_ack"), "request_durable_ack"), + storeAndForward, + }; + const egress: QwpNodeEgressOptions = { + ...common, + url: withPath(endpoints[0], "/read/v1"), + failoverUrls: endpoints + .slice(1) + .map((endpoint) => withPath(endpoint, "/read/v1")), + target: optionalEnum(value("target"), "target", [ + "any", + "primary", + "replica", + ] as const) as QwpTarget | undefined, + zone: value("zone"), + compression: optionalEnum(value("compression"), "compression", [ + "raw", + "zstd", + "auto", + ] as const), + compressionLevel: optionalInteger( + value("compression_level"), + "compression_level", + 1, + 22, + ), + maxBatchRows: optionalInteger( + value("max_batch_rows"), + "max_batch_rows", + 1, + MAX_BATCH_ROWS, + ), + ...extraOptions.egress, + }; + + return { + ingress, + egress, + sender, + ingressSession, + egressSession, + pool, + lazyConnect: + optionalBoolean(value("lazy_connect"), "lazy_connect") ?? false, + }; +} + +function parseConfigurationString(configurationString: string): ParsedConfig { + if (!configurationString) { + throw new Error("QWP cluster configuration string is missing or empty"); + } + const separator = configurationString.indexOf("::"); + if (separator < 0) { + throw new Error( + "Missing schema, QWP cluster configuration format: 'ws::addr=host:port;key=value'", + ); + } + const schema = configurationString.slice(0, separator); + if (schema !== "ws" && schema !== "wss") { + throw new Error( + `QWP cluster configuration must use the ws or wss schema; got: '${schema}'`, + ); + } + + const values = new Map(); + for (const setting of splitSettings(configurationString, separator + 2)) { + const equals = setting.indexOf("="); + if (equals < 0) throw new Error(`Missing '=' sign in '${setting}'`); + const rawKey = setting.slice(0, equals); + const rawValue = setting.slice(equals + 1); + validateConfigText(rawKey, rawValue); + if (UNSUPPORTED_KEYS.has(rawKey)) { + throw new Error( + `QWP cluster configuration key '${rawKey}' is not supported by the TypeScript client`, + ); + } + if (!SUPPORTED_KEYS.has(rawKey)) { + throw new Error(`Unknown QWP cluster configuration key: '${rawKey}'`); + } + const key = + rawKey === "user" ? "username" : rawKey === "pass" ? "password" : rawKey; + const existing = values.get(key); + if (existing && key !== "addr") { + throw new Error(`Duplicate QWP cluster configuration key: '${key}'`); + } + if (existing) existing.push(rawValue); + else values.set(key, [rawValue]); + } + if (!values.has("addr")) { + throw new Error("Invalid QWP cluster configuration: 'addr' is required"); + } + return { schema, values }; +} + +function splitSettings(config: string, start: number): string[] { + const settings: string[] = []; + let setting = ""; + for (let i = start; i < config.length; i++) { + const character = config[i]; + if (character !== ";") { + setting += character; + continue; + } + if (config[i + 1] === ";") { + setting += ";"; + i++; + continue; + } + if (setting) settings.push(setting); + setting = ""; + } + if (setting) settings.push(setting); + return settings; +} + +function validateConfigText(key: string, value: string): void { + if (!key) throw new Error("QWP cluster configuration key must not be empty"); + if (!/^[a-z][a-z0-9_]*$/.test(key)) { + throw new Error(`Invalid QWP cluster configuration key: '${key}'`); + } + if (!value) { + throw new Error( + `Invalid QWP cluster configuration, value is not set for '${key}'`, + ); + } + for (let i = 0; i < value.length; i++) { + const codePoint = value.codePointAt(i)!; + if (codePoint < 0x20 || (codePoint > 0x7e && codePoint < 0xa0)) { + throw new Error( + `Invalid QWP cluster configuration, control characters are not allowed in '${key}'`, + ); + } + } +} + +function parseEndpoints(parsed: ParsedConfig): URL[] { + const endpoints: URL[] = []; + for (const addressList of parsed.values.get("addr") ?? []) { + for (const address of addressList.split(",")) { + if (!address || address.trim() !== address) { + throw new Error(`Invalid QWP cluster address entry: '${address}'`); + } + const authority = addressHasPort(address) + ? address + : `${address}:${DEFAULT_QWP_PORT}`; + let endpoint: URL; + try { + endpoint = new URL(`${parsed.schema}://${authority}`); + } catch { + throw new Error(`Invalid QWP cluster address: '${address}'`); + } + if ( + !endpoint.hostname || + endpoint.username || + endpoint.password || + endpoint.pathname !== "/" || + endpoint.search || + endpoint.hash + ) { + throw new Error(`Invalid QWP cluster address: '${address}'`); + } + endpoints.push(endpoint); + } + } + return endpoints; +} + +function addressHasPort(address: string): boolean { + if (address.startsWith("[")) { + const closingBracket = address.indexOf("]"); + if (closingBracket < 0) { + throw new Error(`Invalid QWP cluster address: '${address}'`); + } + if (closingBracket === address.length - 1) return false; + if (address[closingBracket + 1] !== ":") { + throw new Error(`Invalid QWP cluster address: '${address}'`); + } + validateAddressPort(address, address.slice(closingBracket + 2)); + return true; + } + const colons = address.match(/:/g)?.length ?? 0; + if (colons > 1) { + throw new Error( + `Invalid QWP cluster address: '${address}'; IPv6 addresses must be enclosed in brackets`, + ); + } + if (colons === 0) return false; + validateAddressPort(address, address.slice(address.indexOf(":") + 1)); + return true; +} + +function validateAddressPort(address: string, port: string): void { + if (!/^\d+$/.test(port)) { + throw new Error(`Invalid QWP cluster address: '${address}'`); + } + const parsed = Number(port); + if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > 65_535) { + throw new RangeError( + `Invalid QWP cluster address port: '${port}'; expected 1 through 65535`, + ); + } +} + +function withPath(endpoint: URL, path: string): URL { + const result = new URL(endpoint); + result.pathname = path; + return result; +} + +function validateAuthentication( + values: ReadonlyMap, +): void { + const username = values.get("username")?.[0]; + const password = values.get("password")?.[0]; + const token = values.get("token")?.[0]; + if ((username === undefined) !== (password === undefined)) { + throw new Error( + "QWP Basic authentication requires both 'username' and 'password'", + ); + } + if (token !== undefined && username !== undefined) { + throw new Error( + "QWP 'token' authentication cannot be combined with 'username'/'password'", + ); + } +} + +function createAuthorization( + values: ReadonlyMap, +): string | undefined { + const token = values.get("token")?.[0]; + if (token !== undefined) return `Bearer ${token}`; + const username = values.get("username")?.[0]; + const password = values.get("password")?.[0]; + return username === undefined + ? undefined + : `Basic ${Buffer.from(`${username}:${password}`, "utf8").toString("base64")}`; +} + +function validateTls(parsed: ParsedConfig): void { + const tlsVerify = parsed.values.get("tls_verify")?.[0]; + if (tlsVerify !== undefined) { + optionalEnum(tlsVerify, "tls_verify", ["on", "unsafe_off"] as const); + } + if ( + parsed.schema === "ws" && + (tlsVerify !== undefined || parsed.values.has("tls_roots")) + ) { + throw new Error( + "tls_verify and tls_roots are only supported by the wss schema", + ); + } +} + +function createTlsAgent(parsed: ParsedConfig): HttpsAgent | undefined { + const tlsVerify = parsed.values.get("tls_verify")?.[0]; + const tlsRoots = parsed.values.get("tls_roots")?.[0]; + if (tlsVerify === undefined && tlsRoots === undefined) return undefined; + return new HttpsAgent({ + ca: tlsRoots ? readFileSync(tlsRoots) : undefined, + rejectUnauthorized: tlsVerify !== "unsafe_off", + }); +} + +function parseIngressReconnect( + values: ReadonlyMap, +): QwpReconnectOptions | undefined { + const reconnect: QwpReconnectOptions = { + initialBackoffMs: optionalInteger( + values.get("reconnect_initial_backoff_millis")?.[0], + "reconnect_initial_backoff_millis", + 0, + ), + maxBackoffMs: optionalInteger( + values.get("reconnect_max_backoff_millis")?.[0], + "reconnect_max_backoff_millis", + 0, + ), + maxDurationMs: optionalInteger( + values.get("reconnect_max_duration_millis")?.[0], + "reconnect_max_duration_millis", + 0, + ), + maxFrameRejections: optionalInteger( + values.get("max_frame_rejections")?.[0], + "max_frame_rejections", + 1, + ), + poisonMinEscalationWindowMs: optionalInteger( + values.get("poison_min_escalation_window_millis")?.[0], + "poison_min_escalation_window_millis", + 0, + ), + }; + return hasDefinedValue(reconnect) ? reconnect : undefined; +} + +function parseEgressReconnect( + values: ReadonlyMap, +): QwpReconnectOptions | undefined { + const failover = optionalBoolean(values.get("failover")?.[0], "failover"); + const reconnect: QwpReconnectOptions = { + maxAttempts: optionalInteger( + values.get("failover_max_attempts")?.[0], + "failover_max_attempts", + 1, + ), + initialBackoffMs: optionalInteger( + values.get("failover_backoff_initial_ms")?.[0], + "failover_backoff_initial_ms", + 0, + ), + maxBackoffMs: optionalInteger( + values.get("failover_backoff_max_ms")?.[0], + "failover_backoff_max_ms", + 0, + ), + maxDurationMs: optionalInteger( + values.get("failover_max_duration_ms")?.[0], + "failover_max_duration_ms", + 0, + ), + }; + validateReconnectBounds(reconnect, "QWP egress failover"); + if (failover === false) return undefined; + // The Java facade defaults egress failover to on for cluster strings. + return reconnect; +} + +function parseStoreAndForward( + values: ReadonlyMap, + fallbackDirectory?: string, +): QwpNodeStoreAndForwardOptions | undefined { + const directory = values.get("sf_dir")?.[0] ?? fallbackDirectory; + if (!directory) return undefined; + const durability = optionalEnum( + values.get("sf_durability")?.[0], + "sf_durability", + ["memory", "periodic", "append"] as const, + ); + return { + directory, + maxBytes: optionalSize( + values.get("sf_max_total_bytes")?.[0], + "sf_max_total_bytes", + 1, + ), + durability, + checkpointIntervalMs: optionalInteger( + values.get("sf_sync_interval_millis")?.[0], + "sf_sync_interval_millis", + 0, + ), + backpressurePolicy: values.has("sf_append_deadline_millis") + ? "wait" + : undefined, + appendDeadlineMs: optionalPositiveInteger( + values.get("sf_append_deadline_millis")?.[0], + "sf_append_deadline_millis", + ), + initialConnectMode: optionalInitialConnectMode( + values.get("initial_connect_retry")?.[0], + ), + catchUpCapGapMinEscalationWindowMs: optionalInteger( + values.get("catch_up_cap_gap_min_escalation_window_millis")?.[0], + "catch_up_cap_gap_min_escalation_window_millis", + 0, + ), + drainOrphans: optionalBoolean( + values.get("drain_orphans")?.[0], + "drain_orphans", + true, + ), + maxBackgroundDrainers: optionalInteger( + values.get("max_background_drainers")?.[0], + "max_background_drainers", + 1, + ), + }; +} + +function validateStoreAndForwardDependencies( + values: ReadonlyMap, + storeAndForward: QwpNodeStoreAndForwardOptions | undefined, +): void { + const sfOnlyKeys = [ + "initial_connect_retry", + "reconnect_initial_backoff_millis", + "reconnect_max_backoff_millis", + "reconnect_max_duration_millis", + "max_frame_rejections", + "poison_min_escalation_window_millis", + "catch_up_cap_gap_min_escalation_window_millis", + "drain_orphans", + "max_background_drainers", + "sf_append_deadline_millis", + "sf_durability", + "sf_max_total_bytes", + "sf_sync_interval_millis", + ]; + const configured = sfOnlyKeys.find((key) => values.has(key)); + if (configured && !storeAndForward) { + throw new Error(`QWP '${configured}' requires an sf_dir`); + } + if ( + storeAndForward?.checkpointIntervalMs !== undefined && + storeAndForward.durability !== "periodic" + ) { + throw new Error( + "QWP sf_sync_interval_millis requires sf_durability=periodic", + ); + } + validateReconnectBounds( + parseIngressReconnect(values), + "QWP ingress reconnect", + ); + validateReconnectBounds(parseEgressReconnect(values), "QWP egress failover"); +} + +function validateReconnectBounds( + reconnect: QwpReconnectOptions | undefined, + name: string, +): void { + if (!reconnect) return; + const initialBackoffMs = reconnect.initialBackoffMs ?? 100; + const maxBackoffMs = reconnect.maxBackoffMs ?? 5_000; + if (maxBackoffMs < initialBackoffMs) { + throw new RangeError( + `${name} maximum backoff must be greater than or equal to its initial backoff`, + ); + } +} + +function validatePool(pool: QwpClientPoolOptions): void { + const senderPoolMin = pool.senderPoolMin ?? 1; + const senderPoolMax = pool.senderPoolMax ?? 4; + const queryPoolMin = pool.queryPoolMin ?? 1; + const queryPoolMax = pool.queryPoolMax ?? 4; + validatePoolBounds(senderPoolMin, senderPoolMax, "sender"); + validatePoolBounds(queryPoolMin, queryPoolMax, "query"); + for (const [name, value] of [ + ["acquireTimeoutMs", pool.acquireTimeoutMs], + ["idleTimeoutMs", pool.idleTimeoutMs], + ["maxLifetimeMs", pool.maxLifetimeMs], + ] as const) { + if (value !== undefined && (!Number.isFinite(value) || value < 0)) { + throw new RangeError(`${name} must be a non-negative number`); + } + } + if ( + pool.housekeepingIntervalMs !== undefined && + (!Number.isFinite(pool.housekeepingIntervalMs) || + pool.housekeepingIntervalMs < 100) + ) { + throw new RangeError("housekeepingIntervalMs must be at least 100"); + } +} + +function validatePoolBounds( + minimum: number, + maximum: number, + resource: string, +): void { + if (!Number.isSafeInteger(minimum) || minimum < 0) { + throw new RangeError(`${resource}PoolMin must be a non-negative integer`); + } + if (!Number.isSafeInteger(maximum) || maximum < 1) { + throw new RangeError(`${resource}PoolMax must be a positive integer`); + } + if (minimum > maximum) { + throw new RangeError(`${resource}PoolMin cannot exceed ${resource}PoolMax`); + } +} + +function optionalInitialConnectMode( + value: string | undefined, +): "off" | "sync" | "async" | undefined { + if (value === undefined) return undefined; + switch (value) { + case "off": + case "false": + return "off"; + case "on": + case "true": + case "sync": + return "sync"; + case "async": + return "async"; + default: + throw new Error( + `Invalid initial_connect_retry: '${value}', accepted values: 'off', 'sync', 'async'`, + ); + } +} + +function optionalBoolean( + value: string | undefined, + key: string, + acceptTrueFalse = false, +): boolean | undefined { + if (value === undefined) return undefined; + if (value === "on" || (acceptTrueFalse && value === "true")) return true; + if (value === "off" || (acceptTrueFalse && value === "false")) return false; + throw new Error( + `Invalid ${key}: '${value}', accepted values: 'on', 'off'${ + acceptTrueFalse ? ", 'true', 'false'" : "" + }`, + ); +} + +function optionalInteger( + value: string | undefined, + key: string, + minimum: number, + maximum = Number.MAX_SAFE_INTEGER, +): number | undefined { + if (value === undefined) return undefined; + if (!/^\d+$/.test(value)) throw new Error(`Invalid ${key}: '${value}'`); + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) { + throw new RangeError( + `${key} must be an integer between ${minimum} and ${maximum}`, + ); + } + return parsed; +} + +function optionalPositiveInteger( + value: string | undefined, + key: string, +): number | undefined { + return optionalInteger(value, key, 1); +} + +function optionalSize( + value: string | undefined, + key: string, + minimum: number, + acceptOff = false, +): number | undefined { + if (value === undefined) return undefined; + if (acceptOff && value === "off") return 0; + const match = /^(\d+)([kmgt])?$/i.exec(value); + if (!match) throw new Error(`Invalid ${key}: '${value}'`); + const exponent = match[2] + ? ["k", "m", "g", "t"].indexOf(match[2].toLowerCase()) + 1 + : 0; + const parsed = Number(match[1]) * 1024 ** exponent; + if (!Number.isSafeInteger(parsed) || parsed < minimum) { + throw new RangeError( + `${key} must be a safe integer of at least ${minimum}`, + ); + } + return parsed; +} + +function optionalEnum( + value: string | undefined, + key: string, + accepted: T, +): T[number] | undefined { + if (value === undefined) return undefined; + if ((accepted as readonly string[]).includes(value)) { + return value as T[number]; + } + throw new Error( + `Invalid ${key}: '${value}', accepted values: ${accepted.map((item) => `'${item}'`).join(", ")}`, + ); +} + +function hasDefinedValue(value: object): boolean { + return Object.values(value).some((item) => item !== undefined); +} diff --git a/src/qwp/node.ts b/src/qwp/node.ts index 1f74ca6..1561eef 100644 --- a/src/qwp/node.ts +++ b/src/qwp/node.ts @@ -19,6 +19,7 @@ import { import { createQwpFailoverConnectionFactory } from "./internal/failover"; import { createQwpEgressFailoverConnectionFactory } from "./internal/egress-routing"; import { validateQwpMaxBatchRows } from "./internal/egress-limits"; +import { resolveQwpNodeClientConfig } from "../qwp-node/client-config"; import { QWP_INITIAL_CONNECT_MODE, QWP_UPGRADE_ERROR_KIND, @@ -243,6 +244,35 @@ export interface QwpNodeClientOptions { ingressSession?: QwpIngressSessionOptions; egressSession?: QwpEgressSessionOptions; pool?: QwpClientPoolOptions; + /** + * Coordinates a non-blocking startup: persistent ingress connects in the + * background and the egress pool remains cold until the first query. Requires + * ingress store-and-forward and conflicts with a positive queryPoolMin or a + * non-async initialConnectMode. + */ + lazyConnect?: boolean; +} + +/** + * Programmatic hooks layered over a unified ws/wss cluster string. Values in + * this object take precedence after the complete string has been validated. + */ +export interface QwpNodeClientConfigOptions { + /** Shared transport overrides applied to both ingress and egress. */ + webSocket?: Partial>; + /** Optional persistent ingress configuration; may supply/override sf_dir. */ + storeAndForward?: QwpNodeStoreAndForwardOptions; + /** Egress-only routing and compression overrides. */ + egress?: Partial< + Pick< + QwpNodeEgressOptions, + "target" | "zone" | "compression" | "compressionLevel" | "maxBatchRows" + > + >; + sender?: QwpSenderOptions; + ingressSession?: QwpIngressSessionOptions; + egressSession?: QwpEgressSessionOptions; + pool?: QwpClientPoolOptions; } function egressTransportOptions( @@ -564,8 +594,30 @@ export async function connectQwpNodeEgress( ); } +/** Resolves and validates one ws/wss configuration string for both QWP sides. */ +export function parseQwpNodeClientConfig( + configurationString: string, + extraOptions: QwpNodeClientConfigOptions = {}, +): QwpNodeClientOptions { + return normalizeQwpNodeClientOptions( + resolveQwpNodeClientConfig(configurationString, extraOptions), + ); +} + /** Creates a lazy Node QWP client with bounded sender and query pools. */ -export function createQwpNodeClient(options: QwpNodeClientOptions): QwpClient { +export function createQwpNodeClient(options: QwpNodeClientOptions): QwpClient; +export function createQwpNodeClient( + configurationString: string, + extraOptions?: QwpNodeClientConfigOptions, +): QwpClient; +export function createQwpNodeClient( + optionsOrConfiguration: QwpNodeClientOptions | string, + extraOptions: QwpNodeClientConfigOptions = {}, +): QwpClient { + const options = resolveNodeClientOptions( + optionsOrConfiguration, + extraOptions, + ); const orphanDrainer = createPooledOrphanDrainer(options); return new QwpClient( { @@ -594,14 +646,69 @@ export function createQwpNodeClient(options: QwpNodeClientOptions): QwpClient { } /** Creates and prewarms a combined Node QWP ingress/egress client. */ -export async function connectQwpNodeClient( +export function connectQwpNodeClient( options: QwpNodeClientOptions, +): Promise; +export async function connectQwpNodeClient( + configurationString: string, + extraOptions?: QwpNodeClientConfigOptions, +): Promise; +export async function connectQwpNodeClient( + optionsOrConfiguration: QwpNodeClientOptions | string, + extraOptions: QwpNodeClientConfigOptions = {}, ): Promise { - const client = createQwpNodeClient(options); + const client = createQwpNodeClient( + resolveNodeClientOptions(optionsOrConfiguration, extraOptions), + ); await client.connect(); return client; } +function resolveNodeClientOptions( + optionsOrConfiguration: QwpNodeClientOptions | string, + extraOptions: QwpNodeClientConfigOptions, +): QwpNodeClientOptions { + return typeof optionsOrConfiguration === "string" + ? parseQwpNodeClientConfig(optionsOrConfiguration, extraOptions) + : normalizeQwpNodeClientOptions(optionsOrConfiguration); +} + +function normalizeQwpNodeClientOptions( + options: QwpNodeClientOptions, +): QwpNodeClientOptions { + if (!options.lazyConnect) return options; + const storeAndForward = options.ingress.storeAndForward; + if (!storeAndForward) { + throw new RangeError( + "conflicting configuration: lazyConnect requires ingress storeAndForward so writes remain available while the server is down", + ); + } + if ( + storeAndForward.initialConnectMode !== undefined && + storeAndForward.initialConnectMode !== QWP_INITIAL_CONNECT_MODE.ASYNC + ) { + throw new RangeError( + `conflicting configuration: lazyConnect requires storeAndForward.initialConnectMode='async', got '${storeAndForward.initialConnectMode}'`, + ); + } + if ((options.pool?.queryPoolMin ?? 0) > 0) { + throw new RangeError( + `conflicting configuration: lazyConnect requires queryPoolMin=0, got ${options.pool?.queryPoolMin}`, + ); + } + return { + ...options, + ingress: { + ...options.ingress, + storeAndForward: { + ...storeAndForward, + initialConnectMode: QWP_INITIAL_CONNECT_MODE.ASYNC, + }, + }, + pool: { ...options.pool, queryPoolMin: 0 }, + }; +} + function pooledNodeClientOptions( options: QwpNodeClientOptions, ): QwpClientPoolOptions | undefined { diff --git a/test/qwp/node-client-config.test.ts b/test/qwp/node-client-config.test.ts new file mode 100644 index 0000000..4e565a6 --- /dev/null +++ b/test/qwp/node-client-config.test.ts @@ -0,0 +1,249 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + connectQwpNodeClient, + createQwpNodeClient, + parseQwpNodeClientConfig, + type QwpNodeClientOptions, + type QwpWebSocketLike, +} from "../../src/qwp/node"; + +class RejectingWebSocket { + binaryType = ""; + readyState = 0; + private readonly listeners = new Map void>>(); + + constructor() { + queueMicrotask(() => this.emit("error", new Error("offline"))); + } + + send(): void {} + + close(): void { + if (this.readyState === 3) return; + this.readyState = 3; + this.emit("close", { code: 1000, reason: "", wasClean: true }); + } + + addEventListener(type: string, listener: (event: unknown) => void): void { + let listeners = this.listeners.get(type); + if (!listeners) this.listeners.set(type, (listeners = new Set())); + listeners.add(listener); + } + + removeEventListener(type: string, listener: (event: unknown) => void): void { + this.listeners.get(type)?.delete(listener); + } + + private emit(type: string, event: unknown): void { + for (const listener of this.listeners.get(type) ?? []) listener(event); + } +} + +describe("QWP unified Node client configuration", () => { + it("uses one ordered cluster and authentication configuration for both sides", () => { + const options = parseQwpNodeClientConfig( + "wss::addr=db-a.example:9443,db-b.example;addr=db-c.example:9555;" + + "username=admin;password=s;;ecret;client_id=typescript-test;" + + "target=replica;zone=eu-west-1a;compression=zstd;compression_level=3;" + + "max_batch_rows=512;initial_credit=8192;buffer_pool_size=2;" + + "sender_pool_min=0;sender_pool_max=2;query_pool_min=1;query_pool_max=8;" + + "acquire_timeout_ms=2500;query_close_timeout_ms=7000;", + ); + + expect(String(options.ingress.url)).toBe( + "wss://db-a.example:9443/write/v4", + ); + expect(options.ingress.failoverUrls?.map(String)).toEqual([ + "wss://db-b.example:9000/write/v4", + "wss://db-c.example:9555/write/v4", + ]); + expect(String(options.egress.url)).toBe("wss://db-a.example:9443/read/v1"); + expect(options.egress.failoverUrls?.map(String)).toEqual([ + "wss://db-b.example:9000/read/v1", + "wss://db-c.example:9555/read/v1", + ]); + const authorization = `Basic ${Buffer.from( + "admin:s;ecret", + "utf8", + ).toString("base64")}`; + expect(options.ingress.authorization).toBe(authorization); + expect(options.egress.authorization).toBe(authorization); + expect(options.ingress.clientId).toBe("typescript-test"); + expect(options.egress.clientId).toBe("typescript-test"); + expect(options.egress).toMatchObject({ + target: "replica", + zone: "eu-west-1a", + compression: "zstd", + compressionLevel: 3, + maxBatchRows: 512, + }); + expect(options.egressSession).toMatchObject({ + initialCredit: 8192, + bufferPoolSize: 2, + cancelDrainTimeoutMs: 7000, + reconnect: {}, + }); + expect(options.pool).toMatchObject({ + senderPoolMin: 0, + senderPoolMax: 2, + queryPoolMin: 1, + queryPoolMax: 8, + acquireTimeoutMs: 2500, + }); + }); + + it("coordinates lazy_connect across persistent ingress and the query pool", () => { + const options = parseQwpNodeClientConfig( + "ws::addr=localhost;sf_dir=/tmp/qwp-unified-test;lazy_connect=on;", + ); + + expect(options.lazyConnect).toBe(true); + expect(options.ingress.storeAndForward).toMatchObject({ + directory: "/tmp/qwp-unified-test", + initialConnectMode: "async", + }); + expect(options.pool?.queryPoolMin).toBe(0); + }); + + it("starts lazy persistent ingress without prewarming egress", async () => { + const directory = await mkdtemp(join(tmpdir(), "qwp-unified-client-")); + const attemptedPaths: string[] = []; + let client: Awaited> | undefined; + try { + client = await connectQwpNodeClient( + `ws::addr=offline.example;sf_dir=${directory};lazy_connect=on;sender_pool_max=1;`, + { + webSocket: { + webSocketFactory: (url, { onConnected }) => { + attemptedPaths.push(new URL(url).pathname); + onConnected(); + return new RejectingWebSocket() as unknown as QwpWebSocketLike; + }, + }, + }, + ); + + expect(attemptedPaths).toEqual(["/write/v4"]); + expect(client.metrics.senders.total).toBe(1); + expect(client.metrics.queries.total).toBe(0); + } finally { + await client?.close(); + await rm(directory, { recursive: true, force: true }); + } + }); + + it("rejects lazy startup conflicts before constructing the client", () => { + expect(() => + parseQwpNodeClientConfig( + "ws::addr=localhost;lazy_connect=on;initial_connect_retry=sync;sf_dir=/tmp/qwp;", + ), + ).toThrow(/lazyConnect requires.*initialConnectMode='async'/); + expect(() => + parseQwpNodeClientConfig( + "ws::addr=localhost;lazy_connect=on;query_pool_min=1;sf_dir=/tmp/qwp;", + ), + ).toThrow(/lazyConnect requires queryPoolMin=0/); + expect(() => + parseQwpNodeClientConfig("ws::addr=localhost;lazy_connect=on;"), + ).toThrow(/lazyConnect requires ingress storeAndForward/); + expect(() => + createQwpNodeClient({ + ingress: { url: "ws://localhost:9000/write/v4" }, + egress: { url: "ws://localhost:9000/read/v1" }, + lazyConnect: true, + }), + ).toThrow(/lazyConnect requires ingress storeAndForward/); + }); + + it("validates ingress, egress, pool, and shared conflicts up front", () => { + expect(() => + parseQwpNodeClientConfig("ws::addr=localhost;auto_flush=perhaps;"), + ).toThrow(/Invalid auto_flush/); + expect(() => + parseQwpNodeClientConfig("ws::addr=localhost;compression_level=23;"), + ).toThrow(/compression_level must be an integer between 1 and 22/); + expect(() => + parseQwpNodeClientConfig( + "ws::addr=localhost;sender_pool_min=3;sender_pool_max=2;", + ), + ).toThrow(/senderPoolMin cannot exceed senderPoolMax/); + expect(() => + parseQwpNodeClientConfig( + "ws::addr=localhost;username=admin;password=secret;token=oidc;", + ), + ).toThrow(/cannot be combined/); + expect(() => + parseQwpNodeClientConfig( + "ws::addr=localhost;failover=off;failover_backoff_initial_ms=1000;failover_backoff_max_ms=10;", + ), + ).toThrow(/maximum backoff/); + expect(() => + parseQwpNodeClientConfig("ws::addr=localhost;tls_verify=unsafe_off;"), + ).toThrow(/only supported by the wss schema/); + }); + + it("validates the string before applying explicit programmatic overrides", () => { + const options = parseQwpNodeClientConfig( + "ws::addr=localhost;target=primary;query_pool_max=2;", + { + egress: { target: "replica" }, + pool: { queryPoolMax: 6 }, + }, + ); + expect(options.egress.target).toBe("replica"); + expect(options.pool?.queryPoolMax).toBe(6); + + expect(() => + parseQwpNodeClientConfig("ws::addr=localhost;compression_level=99;", { + egress: { compressionLevel: 1 }, + }), + ).toThrow(/compression_level/); + }); + + it("keeps the existing object API and accepts a string in the same facade", async () => { + const legacy: QwpNodeClientOptions = { + ingress: { url: "ws://localhost:9000/write/v4" }, + egress: { url: "ws://localhost:9000/read/v1" }, + pool: { senderPoolMin: 0, queryPoolMin: 0 }, + }; + const objectClient = createQwpNodeClient(legacy); + const stringClient = createQwpNodeClient( + "ws::addr=localhost;sender_pool_min=0;query_pool_min=0;", + ); + expect(objectClient.metrics.senders.minimum).toBe(0); + expect(stringClient.metrics.queries.minimum).toBe(0); + await Promise.all([objectClient.close(), stringClient.close()]); + }); + + it("rejects duplicate, unknown, and unsupported active keys", () => { + expect(() => + parseQwpNodeClientConfig( + "ws::addr=db-a;addr=db-b;target=primary;target=replica;", + ), + ).toThrow(/Duplicate.*target/); + expect(() => + parseQwpNodeClientConfig("ws::addr=localhost;made_up=1;"), + ).toThrow(/Unknown.*made_up/); + expect(() => + parseQwpNodeClientConfig("ws::addr=localhost;sf_max_segment_bytes=1m;"), + ).toThrow(/not supported by the TypeScript client/); + }); + + it("validates cluster authorities and supports bracketed IPv6", () => { + const options = parseQwpNodeClientConfig( + "ws::addr=[::1],[2001:db8::2]:9443;sender_pool_min=0;query_pool_min=0;", + ); + expect(String(options.ingress.url)).toBe("ws://[::1]:9000/write/v4"); + expect(options.egress.failoverUrls?.map(String)).toEqual([ + "ws://[2001:db8::2]:9443/read/v1", + ]); + for (const address of ["host:", "host:0", "host:65536", "::1"]) { + expect(() => parseQwpNodeClientConfig(`ws::addr=${address};`)).toThrow( + /Invalid QWP cluster address/, + ); + } + }); +}); diff --git a/test/qwp/public-api-contract.ts b/test/qwp/public-api-contract.ts index e61eb9c..dac9721 100644 --- a/test/qwp/public-api-contract.ts +++ b/test/qwp/public-api-contract.ts @@ -20,11 +20,13 @@ import { connectQwpNodeIngress, connectQwpNodeSender, connectQwpNodeWebSocket, + parseQwpNodeClientConfig, retryQwpNodeOrphanSlot, scanQwpNodeOrphanSlots, } from "../../src/qwp/node"; import type { QwpNodeClientOptions, + QwpNodeClientConfigOptions, QwpNodeEgressOptions, QwpNodeIngressOptions, QwpNodeOrphanDrainEvent, @@ -108,6 +110,16 @@ const nodeClientSignature: ( options: QwpNodeClientOptions, ) => Promise = connectQwpNodeClient; +const nodeClusterClientSignature: ( + configurationString: string, + extraOptions?: QwpNodeClientConfigOptions, +) => Promise = connectQwpNodeClient; + +const nodeClusterParserSignature: ( + configurationString: string, + extraOptions?: QwpNodeClientConfigOptions, +) => QwpNodeClientOptions = parseQwpNodeClientConfig; + const poolOptionsContract: QwpClientPoolOptions = { senderPoolMin: 1, senderPoolMax: 2, From 564cc68c9184c14d721d02ca859be32f2c64f8f3 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 18 Aug 2026 21:19:32 +0100 Subject: [PATCH 059/265] docs(qwp): correct browser compression negotiation --- README.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index a8682dc..6fbe90d 100644 --- a/README.md +++ b/README.md @@ -296,9 +296,12 @@ try { } ``` -Zstd decoding is also included in the browser entry point. Browsers cannot set -the `X-QWP-Accept-Encoding` upgrade header themselves, so a same-origin reverse -proxy must add it when browser clients should opt into compression. +Zstd decoding and negotiation are also included in the browser entry point. +Because browsers cannot set the `X-QWP-Accept-Encoding` upgrade header, the +client sends the same preference through the WebSocket URL's +`qwp_accept_encoding` parameter. No proxy-injected compression header is +required. Older servers ignore the parameter and safely continue with raw +batches. Level `1` is the lowest-CPU default and is usually the right starting point. Higher values trade server CPU for wire size; the client accepts levels 1–22, From 6bb56e9224201836e2c724323078d1cd2416ac65 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 18 Aug 2026 21:41:26 +0100 Subject: [PATCH 060/265] fix(qwp): rotate endpoints after not-writable nack --- src/qwp/internal/failover.ts | 24 +++++-- .../reconnecting-ingress-connection.ts | 8 +++ src/qwp/transport.ts | 6 ++ test/qwp/reconnect.test.ts | 66 +++++++++++++++++++ 4 files changed, 100 insertions(+), 4 deletions(-) diff --git a/src/qwp/internal/failover.ts b/src/qwp/internal/failover.ts index c1c2ed3..33e513b 100644 --- a/src/qwp/internal/failover.ts +++ b/src/qwp/internal/failover.ts @@ -67,13 +67,16 @@ export function createQwpFailoverConnectionFactory( state: HOST_STATE.UNKNOWN, zoneTier: zoneBlind ? ZONE_TIER.SAME : ZONE_TIER.UNKNOWN, })); + let deferredEndpoint: number | undefined; return async (): Promise => { const attempts: QwpFailoverAttempt[] = []; const attempted = new Set(); + const deferredForSweep = deferredEndpoint; + deferredEndpoint = undefined; while (attempted.size < endpoints.length) { - const index = pickNextEndpoint(health, attempted); + const index = pickNextEndpoint(health, attempted, deferredForSweep); attempted.add(index); const endpoint = endpoints[index]; let candidate: QwpBinaryConnection | undefined; @@ -110,7 +113,10 @@ export function createQwpFailoverConnectionFactory( ); } health[index].state = HOST_STATE.HEALTHY; - return observeConnectionHealth(candidate, health[index]); + return observeConnectionHealth(candidate, health[index], () => { + health[index].state = HOST_STATE.TRANSIENT_REJECT; + deferredEndpoint = index; + }); } catch (error) { recordFailure(health[index], configuredZone, zoneBlind, error); attempts.push({ endpoint, error }); @@ -161,15 +167,23 @@ function matchesTarget(role: string | undefined, target: QwpTarget): boolean { function pickNextEndpoint( health: readonly QwpEndpointHealth[], attempted: ReadonlySet, + deferredEndpoint?: number, ): number { let selected = -1; for (let index = 0; index < health.length; index++) { - if (attempted.has(index)) continue; + if (attempted.has(index) || index === deferredEndpoint) continue; if (selected < 0 || compareHealth(health[index], health[selected]) < 0) { selected = index; } } - return selected; + if (selected >= 0) return selected; + if ( + deferredEndpoint !== undefined && + !attempted.has(deferredEndpoint) + ) { + return deferredEndpoint; + } + throw new Error("QWP endpoint sweep has no unattempted endpoint"); } function compareHealth( @@ -217,6 +231,7 @@ function recordFailure( function observeConnectionHealth( connection: QwpBinaryConnection, health: QwpEndpointHealth, + deprioritizeEndpoint: () => void, ): QwpBinaryConnection { const demote = (): void => { if (health.state === HOST_STATE.HEALTHY) { @@ -237,6 +252,7 @@ function observeConnectionHealth( get ingressDeltaSymbolDictionaryEnabled() { return connection.ingressDeltaSymbolDictionaryEnabled; }, + deprioritizeEndpoint, send: async (payload) => { try { await connection.send(payload); diff --git a/src/qwp/internal/reconnecting-ingress-connection.ts b/src/qwp/internal/reconnecting-ingress-connection.ts index 123de44..76e47ac 100644 --- a/src/qwp/internal/reconnecting-ingress-connection.ts +++ b/src/qwp/internal/reconnecting-ingress-connection.ts @@ -1192,6 +1192,14 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { return; } + if ( + cause instanceof RetriableIngressNackError && + cause.status === QWP_STATUS.NOT_WRITABLE + ) { + // NOT_WRITABLE describes this node, not the replayed frame. Preserve the + // frame and make the next factory sweep start at another endpoint. + failedConnection.deprioritizeEndpoint?.(); + } this.connection = undefined; void failedConnection.close().catch(() => undefined); const reconnecting = this.connectLoop( diff --git a/src/qwp/transport.ts b/src/qwp/transport.ts index 3e8098e..2469a68 100644 --- a/src/qwp/transport.ts +++ b/src/qwp/transport.ts @@ -428,6 +428,12 @@ export interface QwpBinaryConnection { /** @internal Physical delivery metrics exposed by replaying transports. */ getIngressMetrics?(): QwpIngressTransportMetrics; + /** + * @internal Marks this endpoint as temporarily unsuitable and asks a stateful + * connection factory to start its next sweep at another configured endpoint. + */ + deprioritizeEndpoint?(): void; + send(payload: Uint8Array): Promise; /** Sends an RFC 6455 PING when the underlying runtime supports it. */ ping?(): Promise; diff --git a/test/qwp/reconnect.test.ts b/test/qwp/reconnect.test.ts index bab5668..9ec157a 100644 --- a/test/qwp/reconnect.test.ts +++ b/test/qwp/reconnect.test.ts @@ -377,6 +377,72 @@ describe("QWP endpoint failover", () => { expect(attempts).toEqual(["primary", "secondary"]); }); + it("rotates away from an endpoint that responds NOT_WRITABLE", async () => { + const attempts: string[] = []; + const connections: FakeConnection[] = []; + const factory = createQwpFailoverConnectionFactory( + "primary", + ["secondary"], + async (endpoint) => { + attempts.push(String(endpoint)); + const connection = new FakeConnection(String(endpoint)); + connections.push(connection); + return connection; + }, + ); + const session = await QwpIngressSession.connect(factory, { + reconnect: { + maxAttempts: 1, + initialBackoffMs: 0, + maxBackoffMs: 0, + }, + }); + + const pending = session.sendFrame(Uint8Array.of(9)); + const primary = connections[0]; + await vi.waitFor(() => expect(primary.sent).toHaveLength(1)); + primary.receive(ingressResponse(QWP_STATUS.NOT_WRITABLE, 0n)); + await vi.waitFor(() => + expect( + connections.find((connection) => connection.endpoint === "secondary") + ?.sent, + ).toHaveLength(1), + ); + const secondary = connections.find( + (connection) => connection.endpoint === "secondary", + )!; + secondary.receive(ingressResponse(QWP_STATUS.OK, 0n)); + + await expect(pending).resolves.toMatchObject({ + status: QWP_STATUS.OK, + sequence: 0n, + }); + expect(attempts).toEqual(["primary", "secondary"]); + await session.close(); + }); + + it("uses a NOT_WRITABLE endpoint only after other endpoints fail", async () => { + const attempts: string[] = []; + let secondaryAvailable = true; + const factory = createQwpFailoverConnectionFactory( + "primary", + ["secondary"], + async (endpoint) => { + attempts.push(String(endpoint)); + if (endpoint === "secondary" && !secondaryAvailable) { + throw new Error("secondary unavailable"); + } + return new FakeConnection(String(endpoint)); + }, + ); + + const primary = await factory(); + primary.deprioritizeEndpoint!(); + secondaryAvailable = false; + await expect(factory()).resolves.toMatchObject({ endpoint: "primary" }); + expect(attempts).toEqual(["primary", "secondary", "primary"]); + }); + it("uses SERVER_INFO for browser-compatible role validation", async () => { const primary = new FakeConnection("primary"); primary.receive(serverInfo("primary", QWP_SERVER_ROLE.PRIMARY, "zone-b")); From 387f6467ee27f0bc6b30eef1e4c04e3c7f33249e Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 18 Aug 2026 21:57:24 +0100 Subject: [PATCH 061/265] fix(qwp): recover persistent replay slots --- QWP.md | 12 ++ src/qwp-node/file-replay-store.ts | 132 +++++++++++++++++- src/qwp-node/orphan-drainer.ts | 13 +- .../reconnecting-ingress-connection.ts | 78 +++++++++-- src/qwp/node.ts | 86 +++++++++++- src/qwp/transport.ts | 11 ++ test/qwp/node-transport.test.ts | 63 ++++++++- test/qwp/orphan-drainer.test.ts | 1 + test/qwp/public-api-contract.ts | 3 + test/qwp/public-api.test.ts | 3 + test/qwp/reconnect.test.ts | 78 ++++++++++- 11 files changed, 450 insertions(+), 30 deletions(-) diff --git a/QWP.md b/QWP.md index a0af570..b79a9b5 100644 --- a/QWP.md +++ b/QWP.md @@ -171,6 +171,18 @@ Locks left by a terminated process on the same host are recovered automatically; locks owned by a live local process, another host, or an unidentifiable owner fail closed. +On startup, a dictionary sidecar truncated at a complete-block boundary is rebuilt +from the ordered symbol deltas embedded in surviving committed frames and healed +before replay. If the frame journal is structurally corrupt, or the surviving deltas +contain a dictionary gap or conflict that cannot be reconstructed, the foreground +slot is renamed to `.unreplayable-N`, marked with `.qwp.failed`, and preserved +for inspection. The sender then starts once with a clean slot at the configured path. +`onRecoveryQuarantine` receives the original and quarantine paths plus the terminal +cause; its callback cannot interrupt recovery. Quarantined paths are never adopted by +the orphan scanner. Operational filesystem errors are not quarantined and still fail +startup, so a temporary permissions or disk problem cannot be mistaken for data +corruption. + For a standalone sender, `drainOrphans: true` scans sibling directories beneath the configured journal directory's parent, excludes the sender's own directory, and adopts record-bearing slots left by failed producers. Adoption is lock-protected and diff --git a/src/qwp-node/file-replay-store.ts b/src/qwp-node/file-replay-store.ts index 7b360b1..b410f88 100644 --- a/src/qwp-node/file-replay-store.ts +++ b/src/qwp-node/file-replay-store.ts @@ -7,10 +7,12 @@ import { rename, rm, rmdir, + stat, unlink, + writeFile, } from "node:fs/promises"; import { hostname } from "node:os"; -import { join } from "node:path"; +import { basename, dirname, join } from "node:path"; import { QWP_MAX_SYMBOL_DICTIONARY_SIZE } from "../qwp/core"; import { QwpIngressReplayRecord, @@ -32,6 +34,9 @@ const LOCK_DIRECTORY = ".qwp.lock"; const LOCK_OWNER_FILE = "owner.json"; const LOCK_RECOVERY_FILE = "recovery.json"; const ABANDONED_LOCK_PREFIX = ".qwp.lock.abandoned-"; +const QUARANTINE_SLOT_INFIX = ".unreplayable-"; +const QUARANTINE_FAILED_SENTINEL = ".qwp.failed"; +const MAX_QUARANTINE_SLOT_ATTEMPTS = 64; // The file-per-frame journal has no fixed segment working set. Preserve two // default-sized QWP batches instead, mirroring Java's active+spare liveness // floor when the current dictionary generation consumes the configured cap. @@ -130,6 +135,29 @@ export class QwpReplayStoreError extends Error { } } +/** Durable journal bytes are structurally corrupt and cannot be replayed. */ +export class QwpReplayStoreCorruptionError extends QwpReplayStoreError { + constructor(message: string, cause?: unknown) { + super(message, cause); + this.name = "QwpReplayStoreCorruptionError"; + } +} + +/** A terminal replay slot was preserved under a quarantine pathname. */ +export class QwpReplayStoreQuarantinedError extends QwpReplayStoreError { + constructor( + readonly directory: string, + readonly quarantineDirectory: string, + cause: unknown, + ) { + super( + `QWP store-and-forward recovery could not replay the existing slot; its data was preserved at ${quarantineDirectory} and the producer continued with a fresh slot at ${directory}`, + cause, + ); + this.name = "QwpReplayStoreQuarantinedError"; + } +} + export class QwpReplayStoreFullError extends QwpReplayStoreError { constructor( readonly maxBytes: number, @@ -326,13 +354,18 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { } const record = decodeRecord(bytes, name); if (record.frameSequence <= previous) { - throw new QwpReplayStoreError( + throw new QwpReplayStoreCorruptionError( `QWP store-and-forward sequence is not strictly increasing [file=${name}]`, ); } + if (previous >= 0n && record.frameSequence !== previous + 1n) { + throw new QwpReplayStoreCorruptionError( + `QWP store-and-forward sequence has a gap [previous=${previous}, received=${record.frameSequence}]`, + ); + } const expectedName = recordFileName(record.frameSequence); if (name !== expectedName) { - throw new QwpReplayStoreError( + throw new QwpReplayStoreCorruptionError( `QWP store-and-forward filename does not match its sequence [file=${name}, expected=${expectedName}]`, ); } @@ -1129,8 +1162,10 @@ function encodeDictionaryBlock( return block; } -function corruptDictionary(reason: string): QwpReplayStoreError { - return new QwpReplayStoreError(`corrupt QWP symbol dictionary: ${reason}`); +function corruptDictionary(reason: string): QwpReplayStoreCorruptionError { + return new QwpReplayStoreCorruptionError( + `corrupt QWP symbol dictionary: ${reason}`, + ); } async function truncateDictionaryTail( @@ -1148,12 +1183,95 @@ async function truncateDictionaryTail( await syncDirectory(directory); } -function corruptRecord(name: string, reason: string): QwpReplayStoreError { - return new QwpReplayStoreError( +function corruptRecord( + name: string, + reason: string, +): QwpReplayStoreCorruptionError { + return new QwpReplayStoreCorruptionError( `corrupt QWP store-and-forward record [file=${name}]: ${reason}`, ); } +/** @internal True for slot names reserved for operator-inspected data loss. */ +export function isQwpNodeReplayQuarantineSlotName(name: string): boolean { + const marker = name.lastIndexOf(QUARANTINE_SLOT_INFIX); + if (marker <= 0) return false; + return /^\d+$/.test(name.slice(marker + QUARANTINE_SLOT_INFIX.length)); +} + +/** + * @internal Preserves a proven-unreplayable slot and frees its stable pathname + * for a fresh producer. The caller must have closed the replay store first. + */ +export async function quarantineQwpNodeReplayStore( + directory: string, + cause: unknown, +): Promise { + const normalized = directory.trim(); + if (!normalized) { + throw new QwpReplayStoreError( + "cannot quarantine an empty QWP store-and-forward directory", + cause, + ); + } + const parent = dirname(normalized); + const slotName = basename(normalized); + let quarantineDirectory: string | undefined; + for (let attempt = 0; attempt < MAX_QUARANTINE_SLOT_ATTEMPTS; attempt++) { + const candidate = join( + parent, + `${slotName}${QUARANTINE_SLOT_INFIX}${attempt}`, + ); + if (await pathExists(candidate)) continue; + try { + await rename(normalized, candidate); + quarantineDirectory = candidate; + break; + } catch (error) { + if ( + nodeErrorCode(error) === "EEXIST" || + nodeErrorCode(error) === "ENOTEMPTY" + ) { + continue; + } + throw new QwpReplayStoreError( + `could not quarantine unreplayable QWP store-and-forward slot [directory=${normalized}, target=${candidate}]`, + error, + ); + } + } + if (!quarantineDirectory) { + throw new QwpReplayStoreError( + `could not quarantine unreplayable QWP store-and-forward slot; ${MAX_QUARANTINE_SLOT_ATTEMPTS} quarantine paths already exist [directory=${normalized}]`, + cause, + ); + } + + const recoveryError = + cause instanceof Error ? cause : new Error(String(cause)); + await writeFile( + join(quarantineDirectory, QUARANTINE_FAILED_SENTINEL), + `${new Date().toISOString()} ${recoveryError.name}: ${recoveryError.message}\n`, + { encoding: "utf8", flag: "wx", mode: 0o600 }, + ).catch(() => undefined); + await syncDirectory(parent); + return new QwpReplayStoreQuarantinedError( + normalized, + quarantineDirectory, + recoveryError, + ); +} + +async function pathExists(path: string): Promise { + try { + await stat(path); + return true; + } catch (error) { + if (nodeErrorCode(error) === "ENOENT") return false; + throw error; + } +} + function validateFrameSequence(frameSequence: bigint): void { if (frameSequence < 0n || frameSequence > MAX_FRAME_SEQUENCE) { throw new QwpReplayStoreError( diff --git a/src/qwp-node/orphan-drainer.ts b/src/qwp-node/orphan-drainer.ts index ab62243..b5d9152 100644 --- a/src/qwp-node/orphan-drainer.ts +++ b/src/qwp-node/orphan-drainer.ts @@ -4,7 +4,10 @@ import type { QwpConnectionCloseInfo, QwpIngressTransportMetrics, } from "../qwp/transport"; -import { QwpReplayStoreLockedError } from "./file-replay-store"; +import { + isQwpNodeReplayQuarantineSlotName, + QwpReplayStoreLockedError, +} from "./file-replay-store"; const RECORD_SUFFIX = ".qwp"; const DEFAULT_MAX_CONCURRENT = 4; @@ -98,7 +101,13 @@ export async function scanQwpNodeOrphanSlots( const candidates: string[] = []; for (const entry of entries) { - if (!entry.isDirectory() || excludeSlot?.(entry.name)) continue; + if ( + !entry.isDirectory() || + isQwpNodeReplayQuarantineSlotName(entry.name) || + excludeSlot?.(entry.name) + ) { + continue; + } const directory = join(rootDirectory, entry.name); let children; try { diff --git a/src/qwp/internal/reconnecting-ingress-connection.ts b/src/qwp/internal/reconnecting-ingress-connection.ts index 76e47ac..83d1b5a 100644 --- a/src/qwp/internal/reconnecting-ingress-connection.ts +++ b/src/qwp/internal/reconnecting-ingress-connection.ts @@ -31,6 +31,7 @@ import { QwpReplayDictionaryPersistenceError, QwpReplayRejectedError, QwpSendClosedError, + QwpUnrecoverableReplayDictionaryError, QwpUpgradeError, } from "../transport"; import { QwpAsyncQueue } from "./async-queue"; @@ -304,17 +305,23 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { ? 1 : 0, ); - const symbolDictionary = store.loadSymbolDictionary + const persistedSymbolDictionary = store.loadSymbolDictionary ? await store.loadSymbolDictionary() : []; - validateRecoveredDictionary(sortedRecords, symbolDictionary, store); + const recoveredDiscardTail = analyzeRecoveredDiscardTail(sortedRecords); + const symbolDictionary = await recoverSymbolDictionary( + sortedRecords, + persistedSymbolDictionary, + recoveredDiscardTail, + store, + ); connection = new QwpReconnectingIngressConnection( factory, reconnectOptions, store, sortedRecords, symbolDictionary, - analyzeRecoveredDiscardTail(sortedRecords), + recoveredDiscardTail, localMaxBatchSizeBytes, backgroundStoreAndForward, orphanStoreAndForward, @@ -1343,36 +1350,81 @@ function isRecoveredCommitBarrier(payload: Uint8Array): boolean { } } -function validateRecoveredDictionary( +async function recoverSymbolDictionary( records: readonly QwpIngressReplayRecord[], - dictionary: readonly string[], + persistedDictionary: readonly string[], + discardTail: RecoveredDiscardTail | undefined, store: QwpIngressReplayStore, -): void { +): Promise { const hasDictionaryPersistence = store.loadSymbolDictionary !== undefined && store.appendSymbolDictionary !== undefined; + const dictionary = [...persistedDictionary]; + const dictionaryIds = new Map(dictionary.map((entry, id) => [entry, id])); + const persistedSize = dictionary.length; for (const record of records) { - const delta = readSymbolDictionaryDelta(record.payload); + // A wholly deferred recovery tail is retired locally and never replayed. + // Its dictionary additions therefore cannot make a committed prefix safe. + if ( + discardTail !== undefined && + record.frameSequence >= discardTail.startSequence + ) { + break; + } + let delta: ReturnType; + try { + delta = readSymbolDictionaryDelta(record.payload); + } catch (error) { + throw new QwpUnrecoverableReplayDictionaryError( + `persisted QWP frame contains an invalid symbol dictionary delta [sequence=${record.frameSequence}]`, + error, + ); + } if (!delta) continue; if (!hasDictionaryPersistence) { - throw new QwpReplayDictionaryError( + throw new QwpUnrecoverableReplayDictionaryError( "persisted QWP delta frames require a replay store with dictionary persistence", ); } - if (delta.startId + delta.entries.length > dictionary.length) { - throw new QwpReplayDictionaryError( - `persisted QWP frame references an incomplete symbol dictionary [startId=${delta.startId}, count=${delta.entries.length}, dictionarySize=${dictionary.length}]`, + if (delta.startId > dictionary.length) { + throw new QwpUnrecoverableReplayDictionaryError( + `persisted QWP frame references a symbol dictionary gap that cannot be reconstructed [startId=${delta.startId}, dictionarySize=${dictionary.length}]`, ); } delta.entries.forEach((entry, index) => { const id = delta.startId + index; - if (dictionary[id] !== entry) { - throw new QwpReplayDictionaryError( + const existing = dictionary[id]; + if (existing !== undefined && existing !== entry) { + throw new QwpUnrecoverableReplayDictionaryError( `persisted QWP frame conflicts with symbol dictionary at ID ${id}`, ); } + if (id === dictionary.length) { + const duplicateId = dictionaryIds.get(entry); + if (duplicateId !== undefined) { + throw new QwpUnrecoverableReplayDictionaryError( + `persisted QWP frame assigns symbol dictionary value ${JSON.stringify(entry)} to both ID ${duplicateId} and ID ${id}`, + ); + } + dictionary.push(entry); + dictionaryIds.set(entry, id); + } }); } + if (dictionary.length > persistedSize) { + try { + await store.appendSymbolDictionary!( + persistedSize, + dictionary.slice(persistedSize), + ); + } catch (error) { + throw new QwpReplayDictionaryError( + "could not heal the recovered QWP symbol dictionary from surviving frame deltas", + error, + ); + } + } + return dictionary; } function dictionaryCatchupFrames( diff --git a/src/qwp/node.ts b/src/qwp/node.ts index 1561eef..2877c7f 100644 --- a/src/qwp/node.ts +++ b/src/qwp/node.ts @@ -5,6 +5,7 @@ import type { Agent } from "node:http"; import type { IncomingHttpHeaders } from "node:http"; import { basename, dirname, join } from "node:path"; import WebSocket from "ws"; +import { log } from "../logging"; import { decodeQwpContentEncoding, encodeQwpAcceptEncoding, @@ -29,6 +30,7 @@ import { QwpEgressRoutingOptions, QwpHandshakeMetadata, QwpInitialConnectMode, + QwpUnrecoverableReplayDictionaryError, QwpUpgradeError, QwpWebSocketConnectOptions, } from "./transport"; @@ -36,7 +38,12 @@ import { QwpEgressSession, QwpEgressSessionOptions } from "./egress-session"; import { QwpIngressSession, QwpIngressSessionOptions } from "./ingress-session"; import { QwpSender, QwpSenderOptions } from "./sender"; import { QwpClient, QwpClientPoolOptions } from "./client"; -import { QwpNodeFileReplayStore } from "../qwp-node/file-replay-store"; +import { + quarantineQwpNodeReplayStore, + QwpNodeFileReplayStore, + QwpReplayStoreCorruptionError, + QwpReplayStoreQuarantinedError, +} from "../qwp-node/file-replay-store"; import type { QwpNodeFileReplayStoreOptions } from "../qwp-node/file-replay-store"; import { QwpNodeOrphanDrainer, @@ -49,9 +56,11 @@ export { QwpNodeFileReplayStore, QwpReplayStoreAppendTimeoutError, QwpReplayStoreCheckpointError, + QwpReplayStoreCorruptionError, QwpReplayStoreError, QwpReplayStoreFullError, QwpReplayStoreLockedError, + QwpReplayStoreQuarantinedError, } from "../qwp-node/file-replay-store"; export type { QwpNodeFileReplayStoreMetrics, @@ -193,6 +202,14 @@ export interface QwpNodeIngressOptions extends QwpNodeWebSocketOptions { storeAndForward?: QwpNodeStoreAndForwardOptions; } +/** Notification that an unreplayable foreground slot was preserved aside. */ +export interface QwpNodeReplayRecoveryEvent { + readonly timestampMs: number; + readonly directory: string; + readonly quarantineDirectory: string; + readonly error: QwpReplayStoreQuarantinedError; +} + /** Node store-and-forward controls layered on the crash-safe replay journal. */ export interface QwpNodeStoreAndForwardOptions extends QwpNodeFileReplayStoreOptions { @@ -219,6 +236,11 @@ export interface QwpNodeStoreAndForwardOptions orphanScanIntervalMs?: number; /** Receives isolated scanner and drainer lifecycle notifications. */ onOrphanDrainEvent?: (event: QwpNodeOrphanDrainEvent) => void; + /** + * Receives a data-loss notification when corrupt foreground replay bytes are + * preserved under an `.unreplayable-N` pathname and a fresh slot is opened. + */ + onRecoveryQuarantine?: (event: QwpNodeReplayRecoveryEvent) => void; } export interface QwpNodeEgressOptions @@ -497,7 +519,7 @@ async function connectQwpNodeIngressInternal( "Node QWP ingress reconnection requires a persistent storeAndForward directory", ); } - const replayStore = options.storeAndForward + let replayStore = options.storeAndForward ? new QwpNodeFileReplayStore(options.storeAndForward) : sessionOptions.replayStore; const reconnect = options.storeAndForward @@ -525,10 +547,32 @@ async function connectQwpNodeIngressInternal( startOrphanDrainer && options.storeAndForward?.drainOrphans === true ? createStandaloneOrphanDrainer(options, sessionOptions) : undefined; - const session = await QwpIngressSession.connect( - createQwpNodeConnectionFactory(options), - effectiveSessionOptions, - ); + const connectionFactory = createQwpNodeConnectionFactory(options); + let session: QwpIngressSession; + try { + session = await QwpIngressSession.connect( + connectionFactory, + effectiveSessionOptions, + ); + } catch (error) { + if ( + !options.storeAndForward || + sessionOptions.orphanStoreAndForward === true || + !isQuarantinableReplayRecoveryError(error) + ) { + throw error; + } + const recoveryError = await quarantineQwpNodeReplayStore( + options.storeAndForward.directory, + error, + ); + emitReplayRecoveryQuarantine(options.storeAndForward, recoveryError); + replayStore = new QwpNodeFileReplayStore(options.storeAndForward); + session = await QwpIngressSession.connect(connectionFactory, { + ...effectiveSessionOptions, + replayStore, + }); + } if (orphanDrainer) { session.registerCloseHook(() => orphanDrainer.close()); orphanDrainer.start(); @@ -536,6 +580,36 @@ async function connectQwpNodeIngressInternal( return session; } +function isQuarantinableReplayRecoveryError(error: unknown): boolean { + return ( + error instanceof QwpReplayStoreCorruptionError || + error instanceof QwpUnrecoverableReplayDictionaryError + ); +} + +function emitReplayRecoveryQuarantine( + options: QwpNodeStoreAndForwardOptions, + error: QwpReplayStoreQuarantinedError, +): void { + const event: QwpNodeReplayRecoveryEvent = { + timestampMs: Date.now(), + directory: error.directory, + quarantineDirectory: error.quarantineDirectory, + error, + }; + if (!options.onRecoveryQuarantine) { + log("error", error); + return; + } + try { + options.onRecoveryQuarantine(event); + } catch { + // Recovery already succeeded. A notification callback must not brick the + // fresh producer slot; fall back to the default logger instead. + log("error", error); + } +} + /** * Creates a fluent Node QWP sender without opening the WebSocket yet. * Call connect(), or let the first flush connect lazily. diff --git a/src/qwp/transport.ts b/src/qwp/transport.ts index 2469a68..a42be37 100644 --- a/src/qwp/transport.ts +++ b/src/qwp/transport.ts @@ -109,6 +109,17 @@ export class QwpReplayDictionaryError extends Error { } } +/** + * Recovered delta frames depend on symbol IDs that neither the durable + * dictionary prefix nor the surviving frames can reconstruct. + */ +export class QwpUnrecoverableReplayDictionaryError extends QwpReplayDictionaryError { + constructor(message: string, cause?: unknown) { + super(message, cause); + this.name = "QwpUnrecoverableReplayDictionaryError"; + } +} + /** * A replay dictionary sidecar rejected an append before its delta frame was * published. The reconnecting transport has permanently switched to full, diff --git a/test/qwp/node-transport.test.ts b/test/qwp/node-transport.test.ts index f46a506..8540ec5 100644 --- a/test/qwp/node-transport.test.ts +++ b/test/qwp/node-transport.test.ts @@ -1,6 +1,6 @@ import type { AddressInfo, Socket } from "node:net"; import { createServer as createTcpServer } from "node:net"; -import { mkdtemp, readdir, rm } from "node:fs/promises"; +import { mkdtemp, readdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { WebSocketServer } from "ws"; @@ -20,6 +20,8 @@ import { QWP_UPGRADE_TIMEOUT_PHASE, QwpByteWriter, QwpNodeFileReplayStore, + QwpReplayStoreCorruptionError, + QwpReplayStoreQuarantinedError, QwpUpgradeError, writeQwpVarint, } from "../../src/qwp/node"; @@ -424,6 +426,65 @@ describe("QWP Node transport", () => { } }); + it("quarantines a corrupt foreground slot and continues with a fresh producer", async () => { + server = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + server.on("headers", (headers) => { + headers.push("X-QWP-Version: 1"); + }); + server.on("connection", (socket) => { + socket.on("message", () => socket.send(okResponse(0n, "trades", 1n))); + }); + await listen(server); + + const rootDirectory = await mkdtemp(join(tmpdir(), "qwp-node-recovery-")); + const directory = join(rootDirectory, "sender-0"); + const seed = new QwpNodeFileReplayStore({ directory }); + await seed.load(); + await seed.append({ frameSequence: 0n, payload: Uint8Array.of(1) }); + await seed.close(); + const [record] = (await readdir(directory)).filter((name) => + name.endsWith(".qwp"), + ); + await writeFile(join(directory, record), Uint8Array.of(0)); + + const events: QwpReplayStoreQuarantinedError[] = []; + const address = server.address() as AddressInfo; + try { + const session = await connectQwpNodeIngress({ + url: `ws://127.0.0.1:${address.port}/write/v4`, + storeAndForward: { + directory, + initialConnectMode: "sync", + onRecoveryQuarantine: (event) => events.push(event.error), + }, + }); + try { + await expect( + session.sendFrame(Uint8Array.of(2)), + ).resolves.toMatchObject({ sequence: 0n }); + } finally { + await session.close(); + } + + const quarantineDirectory = join( + rootDirectory, + "sender-0.unreplayable-0", + ); + expect(events).toHaveLength(1); + expect(events[0]).toBeInstanceOf(QwpReplayStoreQuarantinedError); + expect(events[0].cause).toBeInstanceOf(QwpReplayStoreCorruptionError); + expect(events[0].quarantineDirectory).toBe(quarantineDirectory); + expect(await readdir(quarantineDirectory)).toEqual( + expect.arrayContaining([record, ".qwp.failed"]), + ); + expect( + (await readdir(directory)).filter((name) => name.endsWith(".qwp")), + ).toEqual([]); + } finally { + await rm(rootDirectory, { recursive: true, force: true }); + } + }); + it("fails over and replays an unacknowledged frame through the public Node API", async () => { const primary = new WebSocketServer({ host: "127.0.0.1", port: 0 }); const secondary = new WebSocketServer({ host: "127.0.0.1", port: 0 }); diff --git a/test/qwp/orphan-drainer.test.ts b/test/qwp/orphan-drainer.test.ts index 9721c16..bd9b62c 100644 --- a/test/qwp/orphan-drainer.test.ts +++ b/test/qwp/orphan-drainer.test.ts @@ -90,6 +90,7 @@ describe("QWP Node orphan drainer", () => { await recordSlot(rootDirectory, "live"); const failed = await recordSlot(rootDirectory, "failed"); await writeFile(join(failed, QWP_ORPHAN_FAILED_SENTINEL), "inspect me"); + await recordSlot(rootDirectory, "sender-0.unreplayable-0"); await mkdir(join(rootDirectory, "empty")); await expect( diff --git a/test/qwp/public-api-contract.ts b/test/qwp/public-api-contract.ts index dac9721..ff13ea1 100644 --- a/test/qwp/public-api-contract.ts +++ b/test/qwp/public-api-contract.ts @@ -30,6 +30,7 @@ import type { QwpNodeEgressOptions, QwpNodeIngressOptions, QwpNodeOrphanDrainEvent, + QwpNodeReplayRecoveryEvent, QwpNodeStoreAndForwardOptions, QwpNodeWebSocketOptions, } from "../../src/qwp/node"; @@ -149,6 +150,8 @@ const nodeStoreAndForwardContract: QwpNodeStoreAndForwardOptions = { maxBackgroundDrainers: 2, orphanScanIntervalMs: 30_000, onOrphanDrainEvent: (event: QwpNodeOrphanDrainEvent) => void event.metrics, + onRecoveryQuarantine: (event: QwpNodeReplayRecoveryEvent) => + void event.quarantineDirectory, }; const queryOptionsContract: QwpEgressQueryOptions = { diff --git a/test/qwp/public-api.test.ts b/test/qwp/public-api.test.ts index 17eec51..0a2a04d 100644 --- a/test/qwp/public-api.test.ts +++ b/test/qwp/public-api.test.ts @@ -42,6 +42,7 @@ const sharedRuntimeContract = [ "QwpSendTimeoutError", "QwpSender", "QwpSenderCloseTimeoutError", + "QwpUnrecoverableReplayDictionaryError", "QwpUpgradeError", ] as const; @@ -67,9 +68,11 @@ const nodeRuntimeContract = [ "QwpNodeOrphanDrainer", "QwpReplayStoreAppendTimeoutError", "QwpReplayStoreCheckpointError", + "QwpReplayStoreCorruptionError", "QwpReplayStoreError", "QwpReplayStoreFullError", "QwpReplayStoreLockedError", + "QwpReplayStoreQuarantinedError", "QwpVersionMismatchError", "connectQwpNodeEgress", "connectQwpNodeIngress", diff --git a/test/qwp/reconnect.test.ts b/test/qwp/reconnect.test.ts index 9ec157a..ce044ac 100644 --- a/test/qwp/reconnect.test.ts +++ b/test/qwp/reconnect.test.ts @@ -3,6 +3,8 @@ import { mkdtemp, readdir, rm, + stat, + truncate, unlink, writeFile, } from "node:fs/promises"; @@ -16,6 +18,7 @@ import { QwpNodeFileReplayStore, QwpReplayStoreAppendTimeoutError, QwpReplayStoreCheckpointError, + QwpReplayStoreCorruptionError, QwpReplayStoreError, QwpReplayStoreFullError, QwpReplayStoreLockedError, @@ -45,6 +48,7 @@ import { QwpReconnectExhaustedError, QwpReplayRejectedError, QwpReplayDictionaryPersistenceError, + QwpUnrecoverableReplayDictionaryError, QwpUpgradeError, encodeQwpFrame, encodeQwpDurableAckPollFrame, @@ -1498,6 +1502,76 @@ describe("QWP ingress reconnect and replay", () => { await rm(directory, { recursive: true, force: true }); }); + it("reconstructs and heals a truncated symbol dictionary from surviving deltas", async () => { + const directory = await createTemporaryDirectory(); + const dictionary = new QwpSymbolDictionary(); + encodeQwpIngressFrame([symbolTable("ETH-USD")], { + dictionary, + confirmedMaxSymbolId: -1, + }); + + const seed = new QwpNodeFileReplayStore({ directory }); + await seed.load(); + await seed.appendSymbolDictionary(0, dictionary.entriesFrom(0)); + const persistedPrefixSize = (await stat(join(directory, "symbols.qwpdict"))) + .size; + + const replayFrame = encodeQwpIngressFrame([symbolTable("BTC-USD")], { + dictionary, + confirmedMaxSymbolId: 0, + }); + await seed.appendSymbolDictionary(1, dictionary.entriesFrom(1)); + await seed.append({ frameSequence: 5n, payload: replayFrame }); + await seed.close(); + await truncate(join(directory, "symbols.qwpdict"), persistedPrefixSize); + + const connection = new FakeConnection("primary"); + const session = await QwpIngressSession.connect(async () => connection, { + reconnect: { maxAttempts: 1 }, + replayStore: new QwpNodeFileReplayStore({ directory }), + }); + expect(connection.sent).toHaveLength(2); + expect(decodeQwpIngressSymbolDictionaryDelta(connection.sent[0])).toEqual({ + startId: 0, + entries: ["ETH-USD", "BTC-USD"], + }); + expect(connection.sent[1]).toEqual(replayFrame); + await session.close(); + + const verify = new QwpNodeFileReplayStore({ directory }); + await expect(verify.load()).resolves.toHaveLength(1); + await expect(verify.loadSymbolDictionary()).resolves.toEqual([ + "ETH-USD", + "BTC-USD", + ]); + await verify.close(); + await rm(directory, { recursive: true, force: true }); + }); + + it("rejects a surviving delta with an unreconstructable dictionary gap", async () => { + const directory = await createTemporaryDirectory(); + const dictionary = new QwpSymbolDictionary(); + dictionary.getOrAdd("ETH-USD"); + dictionary.getOrAdd("BTC-USD"); + const replayFrame = encodeQwpIngressFrame([symbolTable("SOL-USD")], { + dictionary, + confirmedMaxSymbolId: 1, + }); + const seed = new QwpNodeFileReplayStore({ directory }); + await seed.load(); + await seed.appendSymbolDictionary(0, ["ETH-USD"]); + await seed.append({ frameSequence: 5n, payload: replayFrame }); + await seed.close(); + + await expect( + QwpIngressSession.connect(async () => new FakeConnection("primary"), { + reconnect: { maxAttempts: 1 }, + replayStore: new QwpNodeFileReplayStore({ directory }), + }), + ).rejects.toBeInstanceOf(QwpUnrecoverableReplayDictionaryError); + await rm(directory, { recursive: true, force: true }); + }); + it("recovers a Node journal before new frames and removes it after ACK", async () => { const directory = await createTemporaryDirectory(); const seed = new QwpNodeFileReplayStore({ directory }); @@ -2520,7 +2594,9 @@ describe("QWP Node file replay store", () => { await writeFile(join(directory, record), Uint8Array.of(0)); const recovered = new QwpNodeFileReplayStore({ directory }); - await expect(recovered.load()).rejects.toBeInstanceOf(QwpReplayStoreError); + await expect(recovered.load()).rejects.toBeInstanceOf( + QwpReplayStoreCorruptionError, + ); await recovered.close(); }); From 5e0b243f6684b8bf2e31463f08b78faea9322971 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 18 Aug 2026 22:10:36 +0100 Subject: [PATCH 062/265] fix(qwp): align egress failover with Java client --- QWP.md | 22 ++- src/qwp-node/client-config.ts | 10 +- src/qwp/egress-session.ts | 175 ++++++++++-------- .../reconnecting-egress-connection.ts | 141 +++++++++----- src/qwp/transport.ts | 6 +- test/qwp/node-client-config.test.ts | 10 +- test/qwp/public-api-contract.ts | 5 + test/qwp/reconnect.test.ts | 149 ++++++++++++++- 8 files changed, 383 insertions(+), 135 deletions(-) diff --git a/QWP.md b/QWP.md index b79a9b5..6a8408d 100644 --- a/QWP.md +++ b/QWP.md @@ -655,11 +655,21 @@ sends `X-QWP-Max-Batch-Rows`; browsers use the `qwp_max_batch_rows` URL paramete which requires a server that supports browser QWP negotiation. Older servers ignore the browser parameter and keep their configured batch size. -Egress reconnect never silently resumes a partially consumed result. Configure -`onReplayReset` to opt into at-least-once query re-execution, discard any rows from -the previous attempt in that callback, and rebuild downstream state. Without that -hook, losing a connection with an operation in flight raises -`QwpEgressReplayRequiredError`. +Egress failover is enabled by default in Node.js and browsers. A transport failure or +invalid protocol response closes and deprioritizes that endpoint, reconnects, resets +connection-scoped decoding state, and re-executes the active query. The default policy +uses eight connection sweeps, full-jitter backoff starting at 50 ms and capped at one +second, and a 30-second outage deadline. `QUERY_ERROR` remains a query result and does +not trigger failover. + +Re-execution is at least once: a statement may have completed before its response was +lost, and a consumer may already have observed a prefix of SELECT rows. Queued but +unconsumed batches are discarded automatically. Configure `onReplayReset` when the +application must clear an accumulated prefix before batches restart at sequence zero; +the callback is an optional notification, not an opt-in. Set `reconnect: false` to use +one fixed connection and surface failures without replay. Supplying a `reconnect` +object tunes the failover bounds and also retains the earlier opt-in behavior of +retrying initial connection establishment. Browser egress uses the same session API: @@ -846,7 +856,7 @@ The public error classes preserve enough context for policy decisions: | `QwpEgressQueryAbandonedError` | Result iteration ended before the server completed the query | | `QwpEgressQueryTimeoutError` | The client deadline expired and cancellation began | | `QwpEgressQueryCancelTimeoutError` | A cancelled query did not produce a terminal server response before the drain deadline | -| `QwpEgressReplayRequiredError` | Re-execution needs an explicit reset callback | +| `QwpEgressReplayRequiredError` | Deprecated compatibility type from the former explicit replay opt-in | Always close senders and sessions in `finally`. Sender publication plus ACK draining is bounded by `closeFlushTimeoutMs`; the subsequent WebSocket closing handshake is bounded diff --git a/src/qwp-node/client-config.ts b/src/qwp-node/client-config.ts index 713863d..9304d66 100644 --- a/src/qwp-node/client-config.ts +++ b/src/qwp-node/client-config.ts @@ -536,7 +536,7 @@ function parseIngressReconnect( function parseEgressReconnect( values: ReadonlyMap, -): QwpReconnectOptions | undefined { +): QwpReconnectOptions | false | undefined { const failover = optionalBoolean(values.get("failover")?.[0], "failover"); const reconnect: QwpReconnectOptions = { maxAttempts: optionalInteger( @@ -561,9 +561,11 @@ function parseEgressReconnect( ), }; validateReconnectBounds(reconnect, "QWP egress failover"); - if (failover === false) return undefined; + if (failover === false) return false; // The Java facade defaults egress failover to on for cluster strings. - return reconnect; + return failover === true || hasDefinedValue(reconnect) + ? reconnect + : undefined; } function parseStoreAndForward( @@ -657,7 +659,7 @@ function validateStoreAndForwardDependencies( } function validateReconnectBounds( - reconnect: QwpReconnectOptions | undefined, + reconnect: QwpReconnectOptions | false | undefined, name: string, ): void { if (!reconnect) return; diff --git a/src/qwp/egress-session.ts b/src/qwp/egress-session.ts index bd79098..6e87be3 100644 --- a/src/qwp/egress-session.ts +++ b/src/qwp/egress-session.ts @@ -39,13 +39,16 @@ export interface QwpEgressSessionOptions { queryTimeoutMs?: number; /** Maximum wait for a terminal response after CANCEL. Defaults to 5 seconds. */ cancelDrainTimeoutMs?: number; - /** Enables bounded reconnects. Active operations replay only with onReplayReset. */ - reconnect?: QwpReconnectOptions; /** - * Explicitly opts into at-least-once re-execution after a disconnect. The - * query's not-yet-consumed batches are discarded before this callback, and - * callers must discard any result prefix they already consumed. The event - * includes the authoritative SERVER_INFO for the replacement endpoint. + * Bounded failover policy. Failover and at-least-once active-query replay + * are enabled by default; set false to keep one fixed connection. + */ + reconnect?: QwpReconnectOptions | false; + /** + * Optional notification immediately before an active query is re-executed. + * Not-yet-consumed batches are discarded automatically; callers that retain + * an already-consumed prefix should discard it here. Omitting this callback + * leaves replay enabled and is appropriate for idempotent consumers. */ onReplayReset?: (event: QwpEgressReplayResetEvent) => void | Promise; } @@ -96,6 +99,12 @@ export const QWP_DEFAULT_EGRESS_INITIAL_CREDIT = 256 * 1024; export const QWP_DEFAULT_EGRESS_BUFFER_POOL_SIZE = 4; const MAX_UINT64 = 0xffffffffffffffffn; +const DEFAULT_EGRESS_RECONNECT_OPTIONS: Readonly = { + maxAttempts: 8, + initialBackoffMs: 50, + maxBackoffMs: 1_000, + maxDurationMs: 30_000, +}; function validateOptionalTimeout( value: number | undefined, @@ -583,10 +592,14 @@ export class QwpEgressSession implements QwpEgressQueryControl { ): Promise { const validated = validateEgressSessionOptions(options); const state: { session?: QwpEgressSession } = {}; - const connection = options.reconnect + const reconnectOptions = + options.reconnect === false + ? undefined + : (options.reconnect ?? DEFAULT_EGRESS_RECONNECT_OPTIONS); + const connection = reconnectOptions ? await QwpReconnectingEgressConnection.connect( factory, - options.reconnect, + reconnectOptions, validated.serverInfoTimeoutMs, (serverInfo) => state.session?.prepareConnectionReset(serverInfo), (serverInfo, requestId) => { @@ -603,6 +616,7 @@ export class QwpEgressSession implements QwpEgressQueryControl { await options.onReplayReset!(event); } : undefined, + options.reconnect !== undefined, ) : await factory(); let session: QwpEgressSession; @@ -871,37 +885,25 @@ export class QwpEgressSession implements QwpEgressQueryControl { private async consumeMessages(): Promise { try { for await (const payload of this.connection.messages) { - const message = decodeQwpEgressMessage(payload); - switch (message.kind) { - case "server-info": - if (this.serverInfo) { - throw new QwpProtocolError("received duplicate QWP SERVER_INFO"); - } - this.serverInfo = message; - clearTimeout(this.serverInfoTimer); - this.resolveServerInfo(message); - break; - case "cache-reset": - this.decoder.applyCacheReset(message.resetMask); - break; - case "result-batch": { - const query = this.requireActive(message.requestId); - if (query.retired) { - const creditBytes = query.lateBatchCredit(payload.byteLength); - if (creditBytes > 0) { - void this.sendWhileActive( - message.requestId, - encodeQwpCredit(message.requestId, creditBytes), - ).catch(() => undefined); + try { + const message = decodeQwpEgressMessage(payload); + switch (message.kind) { + case "server-info": + if (this.serverInfo) { + throw new QwpProtocolError( + "received duplicate QWP SERVER_INFO", + ); } - } else if (query.usesViews) { - await query.pushView( - this.decoder.decodeView(message), - payload.byteLength, - ); - } else { - const reservation = await query.reserveMaterializedBatch(); - if (reservation === "retired") { + this.serverInfo = message; + clearTimeout(this.serverInfoTimer); + this.resolveServerInfo(message); + break; + case "cache-reset": + this.decoder.applyCacheReset(message.resetMask); + break; + case "result-batch": { + const query = this.requireActive(message.requestId); + if (query.retired) { const creditBytes = query.lateBatchCredit(payload.byteLength); if (creditBytes > 0) { void this.sendWhileActive( @@ -909,47 +911,72 @@ export class QwpEgressSession implements QwpEgressQueryControl { encodeQwpCredit(message.requestId, creditBytes), ).catch(() => undefined); } - } else if (reservation === "reserved") { - try { - query.pushReserved( - this.decoder.decode(message), - payload.byteLength, - ); - } catch (error) { - query.releaseMaterializedBatch(); - throw error; + } else if (query.usesViews) { + await query.pushView( + this.decoder.decodeView(message), + payload.byteLength, + ); + } else { + const reservation = await query.reserveMaterializedBatch(); + if (reservation === "retired") { + const creditBytes = query.lateBatchCredit(payload.byteLength); + if (creditBytes > 0) { + void this.sendWhileActive( + message.requestId, + encodeQwpCredit(message.requestId, creditBytes), + ).catch(() => undefined); + } + } else if (reservation === "reserved") { + try { + query.pushReserved( + this.decoder.decode(message), + payload.byteLength, + ); + } catch (error) { + query.releaseMaterializedBatch(); + throw error; + } } } + break; + } + case "result-end": { + const query = this.requireActive(message.requestId); + this.clearActive(query); + this.clearCancelDrain(message.requestId); + query.finish(message); + break; + } + case "exec-done": { + const query = this.requireActive(message.requestId); + this.clearActive(query); + this.clearCancelDrain(message.requestId); + query.finish(message); + break; + } + case "query-error": { + const query = this.requireActive(message.requestId); + this.clearActive(query); + this.clearCancelDrain(message.requestId); + query.fail( + new QwpEgressQueryError( + message.requestId, + message.status, + message.message, + ), + ); + break; } - break; - } - case "result-end": { - const query = this.requireActive(message.requestId); - this.clearActive(query); - this.clearCancelDrain(message.requestId); - query.finish(message); - break; - } - case "exec-done": { - const query = this.requireActive(message.requestId); - this.clearActive(query); - this.clearCancelDrain(message.requestId); - query.finish(message); - break; } - case "query-error": { - const query = this.requireActive(message.requestId); - this.clearActive(query); - this.clearCancelDrain(message.requestId); - query.fail( - new QwpEgressQueryError( - message.requestId, - message.status, - message.message, - ), - ); - break; + } catch (error) { + if ( + error instanceof QwpProtocolError && + this.connection instanceof QwpReconnectingEgressConnection + ) { + await this.connection.recoverProtocolFailure(error); + continue; } + throw error; } } if (!this.closing) { diff --git a/src/qwp/internal/reconnecting-egress-connection.ts b/src/qwp/internal/reconnecting-egress-connection.ts index 175b1ab..dacc0d1 100644 --- a/src/qwp/internal/reconnecting-egress-connection.ts +++ b/src/qwp/internal/reconnecting-egress-connection.ts @@ -10,7 +10,6 @@ import { QwpBinaryConnection, QwpConnectionCloseInfo, QwpConnectionFactory, - QwpEgressReplayRequiredError, QwpEgressReplayResetEvent, QwpFailoverError, QwpHandshakeMetadata, @@ -44,10 +43,20 @@ class ReplayResetCallbackError extends Error { } } +class ReplayStateError extends QwpProtocolError { + readonly cause?: unknown; + + constructor(message: string, cause?: unknown) { + super(message); + this.name = "ReplayStateError"; + this.cause = cause; + } +} + /** - * Reconnects an egress wire and, only with an explicit reset handler, replays - * the in-flight request and its control messages. Statements may therefore be - * executed more than once when their outcome was lost with the connection. + * Reconnects an egress wire and replays the in-flight request and its control + * messages. Statements may therefore be executed more than once when their + * outcome was lost with the connection. */ export class QwpReconnectingEgressConnection implements QwpBinaryConnection { private readonly messagesQueue = new QwpAsyncQueue(); @@ -80,10 +89,11 @@ export class QwpReconnectingEgressConnection implements QwpBinaryConnection { private readonly onConnectionReset: ConnectionResetHandler, private readonly encodeQueryRequest: QueryRequestEncoder, private readonly onReplayReset?: ReplayResetHandler, + private readonly retryInitialConnection = true, ) { - this.maxAttempts = reconnectOptions.maxAttempts ?? 3; - this.initialBackoffMs = reconnectOptions.initialBackoffMs ?? 100; - this.maxBackoffMs = reconnectOptions.maxBackoffMs ?? 5_000; + this.maxAttempts = reconnectOptions.maxAttempts ?? 8; + this.initialBackoffMs = reconnectOptions.initialBackoffMs ?? 50; + this.maxBackoffMs = reconnectOptions.maxBackoffMs ?? 1_000; this.maxDurationMs = reconnectOptions.maxDurationMs ?? 30_000; validateReconnectPolicy( this.maxAttempts, @@ -105,6 +115,7 @@ export class QwpReconnectingEgressConnection implements QwpBinaryConnection { onConnectionReset: ConnectionResetHandler, encodeQueryRequest: QueryRequestEncoder, onReplayReset?: ReplayResetHandler, + retryInitialConnection = true, ): Promise { const reconnecting = new QwpReconnectingEgressConnection( factory, @@ -113,6 +124,7 @@ export class QwpReconnectingEgressConnection implements QwpBinaryConnection { onConnectionReset, encodeQueryRequest, onReplayReset, + retryInitialConnection, ); try { await reconnecting.connectLoop(undefined, false); @@ -186,6 +198,7 @@ export class QwpReconnectingEgressConnection implements QwpBinaryConnection { private async connectLoop( initialCause: unknown, reconnecting: boolean, + skipQueueBarrier = false, ): Promise { const outageStarted = Date.now(); const previousEndpoint = this.lastEndpoint; @@ -238,6 +251,7 @@ export class QwpReconnectingEgressConnection implements QwpBinaryConnection { serverInfo, previousEndpoint, initialCause, + skipQueueBarrier, ); } else { this.initialServerInfo = serverInfo; @@ -281,6 +295,7 @@ export class QwpReconnectingEgressConnection implements QwpBinaryConnection { cause: error, }); if (!isRetryableReconnectError(error)) throw error; + if (!reconnecting && !this.retryInitialConnection) throw error; const attemptsExhausted = this.maxAttempts > 0 && attempt >= this.maxAttempts; const durationExhausted = @@ -323,7 +338,8 @@ export class QwpReconnectingEgressConnection implements QwpBinaryConnection { message.kind === "exec-done" || message.kind === "query-error" ) { - this.outboundReplay = []; + const activeRequestId = replayRequestId(this.outboundReplay); + if (activeRequestId === message.requestId) this.outboundReplay = []; } this.messagesQueue.push(next.value); } @@ -341,16 +357,12 @@ export class QwpReconnectingEgressConnection implements QwpBinaryConnection { ) { return; } - if (error instanceof QwpProtocolError) { - this.failTerminal(error); - await connection - .close(1002, "invalid QWP egress message") - .catch(() => undefined); - return; - } - await this.requestReconnect(error, connection).catch((reconnectError) => { - this.failTerminal(reconnectError); - }); + await this.requestReconnect( + error, + connection, + error instanceof QwpProtocolError ? 1002 : 1000, + error instanceof QwpProtocolError ? "invalid QWP egress message" : "", + ).catch((reconnectError) => this.failTerminal(reconnectError)); } } @@ -383,7 +395,7 @@ export class QwpReconnectingEgressConnection implements QwpBinaryConnection { ): void { const initial = this.initialServerInfo; if (!initial) { - throw new QwpProtocolError( + throw new ReplayStateError( "QWP reconnect started before the initial SERVER_INFO was received", ); } @@ -409,11 +421,13 @@ export class QwpReconnectingEgressConnection implements QwpBinaryConnection { serverInfo: QwpServerInfoMessage, previousEndpoint: string | URL | undefined, cause: unknown, + skipQueueBarrier: boolean, ): Promise { if (this.outboundReplay.length === 0) { // A terminal response may already be queued. Let the bounded session // consume it before resetting connection-scoped decoder state. - await this.messagesQueue.barrier(); + if (skipQueueBarrier) this.messagesQueue.clear(); + else await this.messagesQueue.barrier(); await this.onConnectionReset(serverInfo); return; } @@ -424,25 +438,24 @@ export class QwpReconnectingEgressConnection implements QwpBinaryConnection { await this.onConnectionReset(serverInfo); const requestId = replayRequestId(this.outboundReplay); if (requestId === undefined) { - throw new QwpProtocolError( + throw new ReplayStateError( "QWP egress replay is missing its QUERY_REQUEST", ); } - if (!this.onReplayReset) { - throw new QwpEgressReplayRequiredError(requestId); - } - try { - await this.onReplayReset({ - requestId, - serverInfo, - previousEndpoint, - endpoint: connection.endpoint, - cause, - }); - } catch (error) { - throw new ReplayResetCallbackError(error); + if (this.onReplayReset) { + try { + await this.onReplayReset({ + requestId, + serverInfo, + previousEndpoint, + endpoint: connection.endpoint, + cause, + }); + } catch (error) { + throw new ReplayResetCallbackError(error); + } } - const request = await this.encodeQueryRequest(serverInfo, requestId); + const request = await this.encodeReplayRequest(serverInfo, requestId); validateEncodedRequest(request, requestId); const preparedRequest = request.slice(); this.outboundReplay[0] = preparedRequest; @@ -458,11 +471,44 @@ export class QwpReconnectingEgressConnection implements QwpBinaryConnection { "QWP QUERY_REQUEST cannot be prepared before SERVER_INFO", ); } - const encoded = await this.encodeQueryRequest(serverInfo, requestId); + const encoded = await this.encodeReplayRequest(serverInfo, requestId); validateEncodedRequest(encoded, requestId); return encoded.slice(); } + private async encodeReplayRequest( + serverInfo: QwpServerInfoMessage, + requestId: bigint, + ): Promise { + try { + return await this.encodeQueryRequest(serverInfo, requestId); + } catch (error) { + throw new ReplayStateError( + `QWP egress could not reconstruct active request ID ${requestId}`, + error, + ); + } + } + + /** @internal Replaces a connection whose server response was invalid. */ + async recoverProtocolFailure(error: QwpProtocolError): Promise { + this.throwIfUnavailable(); + const connection = this.connection; + if (!connection) throw new QwpSendClosedError(); + try { + await this.requestReconnect( + error, + connection, + 1002, + "invalid QWP egress message", + true, + ); + } catch (reconnectError) { + this.failTerminal(reconnectError); + throw reconnectError; + } + } + private trackOutbound(payload: Uint8Array): void { switch (payload[0]) { case QWP_EGRESS_MESSAGE.QUERY_REQUEST: @@ -485,6 +531,9 @@ export class QwpReconnectingEgressConnection implements QwpBinaryConnection { private async requestReconnect( cause: unknown, failedConnection: QwpBinaryConnection, + closeCode = 1000, + closeReason = "", + skipQueueBarrier = false, ): Promise { if (this.closing) throw new QwpSendClosedError(); if (this.connection && this.connection !== failedConnection) return; @@ -492,14 +541,21 @@ export class QwpReconnectingEgressConnection implements QwpBinaryConnection { const activeReconnect = this.reconnectTask; await activeReconnect; if (this.connection === failedConnection && !this.closing) { - await this.requestReconnect(cause, failedConnection); + await this.requestReconnect( + cause, + failedConnection, + closeCode, + closeReason, + skipQueueBarrier, + ); } return; } this.connection = undefined; - void failedConnection.close().catch(() => undefined); - const reconnecting = this.connectLoop(cause, true); + if (closeCode !== 1000) failedConnection.deprioritizeEndpoint?.(); + void failedConnection.close(closeCode, closeReason).catch(() => undefined); + const reconnecting = this.connectLoop(cause, true, skipQueueBarrier); this.reconnectTask = reconnecting; try { await reconnecting; @@ -579,7 +635,7 @@ function validateEncodedRequest( ): void { const requestId = replayRequestId([payload]); if (requestId !== expectedRequestId) { - throw new QwpProtocolError( + throw new ReplayStateError( `QWP query encoder returned the wrong request [expected=${expectedRequestId}, actual=${requestId ?? "missing"}]`, ); } @@ -622,8 +678,7 @@ function isRetryableReconnectError(error: unknown): boolean { ); } return !( - error instanceof QwpEgressReplayRequiredError || - error instanceof ReplayResetCallbackError || - error instanceof QwpProtocolError + error instanceof ReplayStateError || + error instanceof ReplayResetCallbackError ); } diff --git a/src/qwp/transport.ts b/src/qwp/transport.ts index a42be37..1e30ac5 100644 --- a/src/qwp/transport.ts +++ b/src/qwp/transport.ts @@ -135,7 +135,11 @@ export class QwpReplayDictionaryPersistenceError extends QwpReplayDictionaryErro } } -/** An active egress operation cannot be safely replayed without an explicit reset hook. */ +/** + * @deprecated Standard egress sessions now reset and replay automatically. + * Retained for source compatibility with clients that classified the former + * explicit-replay opt-in failure. + */ export class QwpEgressReplayRequiredError extends Error { constructor(readonly requestId?: bigint) { super( diff --git a/test/qwp/node-client-config.test.ts b/test/qwp/node-client-config.test.ts index 4e565a6..b77c5ab 100644 --- a/test/qwp/node-client-config.test.ts +++ b/test/qwp/node-client-config.test.ts @@ -84,8 +84,8 @@ describe("QWP unified Node client configuration", () => { initialCredit: 8192, bufferPoolSize: 2, cancelDrainTimeoutMs: 7000, - reconnect: {}, }); + expect(options.egressSession?.reconnect).toBeUndefined(); expect(options.pool).toMatchObject({ senderPoolMin: 0, senderPoolMax: 2, @@ -108,6 +108,14 @@ describe("QWP unified Node client configuration", () => { expect(options.pool?.queryPoolMin).toBe(0); }); + it("preserves failover=off as an explicit programmatic opt-out", () => { + const options = parseQwpNodeClientConfig( + "ws::addr=localhost;failover=off;", + ); + + expect(options.egressSession?.reconnect).toBe(false); + }); + it("starts lazy persistent ingress without prewarming egress", async () => { const directory = await mkdtemp(join(tmpdir(), "qwp-unified-client-")); const attemptedPaths: string[] = []; diff --git a/test/qwp/public-api-contract.ts b/test/qwp/public-api-contract.ts index ff13ea1..b054704 100644 --- a/test/qwp/public-api-contract.ts +++ b/test/qwp/public-api-contract.ts @@ -169,6 +169,10 @@ const egressSessionOptionsContract: QwpEgressSessionOptions = { cancelDrainTimeoutMs: 5_000, }; +const fixedConnectionEgressContract: QwpEgressSessionOptions = { + reconnect: false, +}; + const browserEgressOptionsContract: QwpBrowserEgressOptions = { url: "wss://node-1.example/read/v1", failoverUrls: ["wss://node-2.example/read/v1"], @@ -289,6 +293,7 @@ void nodeOrphanRetrySignature; void nodeStoreAndForwardContract; void queryOptionsContract; void egressSessionOptionsContract; +void fixedConnectionEgressContract; void browserEgressOptionsContract; void nodeEgressOptionsContract; void rootExtraOptionsContract; diff --git a/test/qwp/reconnect.test.ts b/test/qwp/reconnect.test.ts index ce044ac..60c71dd 100644 --- a/test/qwp/reconnect.test.ts +++ b/test/qwp/reconnect.test.ts @@ -35,8 +35,8 @@ import { QwpBinaryConnection, QwpByteWriter, QwpConnectionCloseInfo, - QwpEgressReplayRequiredError, QwpEgressSession, + QwpEgressSessionClosedError, QwpIngressSession, QwpIngressReplayRecord, QwpIngressReplayStore, @@ -1777,6 +1777,19 @@ describe("QWP ingress reconnect and replay", () => { }); describe("QWP egress reconnect and replay", () => { + it("keeps default initial connection establishment fail-fast", async () => { + const failure = new Error("offline"); + let factoryCalls = 0; + + await expect( + QwpEgressSession.connect(async () => { + factoryCalls++; + throw failure; + }), + ).rejects.toBe(failure); + expect(factoryCalls).toBe(1); + }); + it("applies full jitter to egress reconnect backoff", async () => { vi.useFakeTimers(); const random = vi.spyOn(Math, "random").mockReturnValue(0.25); @@ -2119,7 +2132,49 @@ describe("QWP egress reconnect and replay", () => { await session.close(); }); - it("fails rather than silently replaying an active operation without reset", async () => { + it("defaults failover on and replays an active operation without a reset callback", async () => { + const first = new FakeConnection("primary"); + const second = new FakeConnection("secondary"); + const connections = [first, second]; + const session = await QwpEgressSession.connect(async () => { + const connection = connections.shift(); + if (!connection) throw new Error("no connection available"); + queueMicrotask(() => connection.receive(serverInfo(connection.endpoint))); + return connection; + }); + const query = await session.query("update x set n = n + 1"); + first.drop(); + + await vi.waitFor(() => expect(second.sent).toEqual(first.sent)); + second.receive(resultEnd()); + await expect(query.completion).resolves.toMatchObject({ + kind: "result-end", + }); + await session.close(); + }); + + it("allows automatic egress failover to be disabled", async () => { + const first = new FakeConnection("primary"); + let factoryCalls = 0; + const session = await QwpEgressSession.connect( + async () => { + factoryCalls++; + queueMicrotask(() => first.receive(serverInfo("primary"))); + return first; + }, + { reconnect: false }, + ); + const query = await session.query("select 1"); + first.drop(); + + await expect(query.completion).rejects.toBeInstanceOf( + QwpEgressSessionClosedError, + ); + expect(factoryCalls).toBe(1); + await session.close(); + }); + + it("fails over and replays after a result decoder protocol error", async () => { const first = new FakeConnection("primary"); const second = new FakeConnection("secondary"); const connections = [first, second]; @@ -2140,12 +2195,94 @@ describe("QWP egress reconnect and replay", () => { }, }, ); - const query = await session.query("update x set n = n + 1"); - first.drop(); + const query = await session.query("select * from x"); + first.receive(emptyResultBatch(0n, 1)); - await expect(query.completion).rejects.toBeInstanceOf( - QwpEgressReplayRequiredError, + await vi.waitFor(() => expect(second.sent).toEqual(first.sent)); + second.receive(emptyResultBatch()); + second.receive(resultEnd()); + const iterator = query[Symbol.asyncIterator](); + await expect(iterator.next()).resolves.toMatchObject({ done: false }); + await expect(iterator.next()).resolves.toEqual({ + value: undefined, + done: true, + }); + await expect(query.completion).resolves.toMatchObject({ + kind: "result-end", + }); + await session.close(); + }); + + it("rotates endpoints after a malformed egress frame", async () => { + const attempts: string[] = []; + const connections = new Map(); + const factory = createQwpEgressFailoverConnectionFactory( + "primary", + ["secondary"], + async (endpoint) => { + const name = String(endpoint); + attempts.push(name); + const connection = new FakeConnection(name); + connections.set(name, connection); + connection.receive(serverInfo(name)); + return connection; + }, + {}, + 100, + ); + const session = await QwpEgressSession.connect(factory, { + reconnect: { + maxAttempts: 1, + initialBackoffMs: 0, + maxBackoffMs: 0, + }, + }); + const primary = connections.get("primary")!; + const query = await session.query("select 1"); + primary.receive(Uint8Array.of(0xff)); + + await vi.waitFor(() => + expect(connections.get("secondary")?.sent).toEqual(primary.sent), + ); + const secondary = connections.get("secondary")!; + secondary.receive(resultEnd()); + await expect(query.completion).resolves.toMatchObject({ + kind: "result-end", + }); + expect(attempts).toEqual(["primary", "secondary"]); + await session.close(); + }); + + it("recovers an idle session after an invalid terminal response", async () => { + const first = new FakeConnection("primary"); + const second = new FakeConnection("secondary"); + const connections = [first, second]; + const session = await QwpEgressSession.connect( + async () => { + const connection = connections.shift(); + if (!connection) throw new Error("no connection available"); + queueMicrotask(() => + connection.receive(serverInfo(connection.endpoint)), + ); + return connection; + }, + { + reconnect: { + maxAttempts: 1, + initialBackoffMs: 0, + maxBackoffMs: 0, + }, + }, ); + + first.receive(resultEnd()); + await vi.waitFor(() => expect(connections).toHaveLength(0)); + const query = await session.query("select 1"); + expect(second.sent).toHaveLength(1); + second.receive(resultEnd()); + await expect(query.completion).resolves.toMatchObject({ + kind: "result-end", + }); await session.close(); }); }); From 818379fec85b0746bcc85cb74a1fc07de428fede Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 18 Aug 2026 22:27:08 +0100 Subject: [PATCH 063/265] feat(qwp): enable ingress reconnect by default --- QWP.md | 24 +++++--- README.md | 4 +- src/qwp/ingress-session.ts | 47 +++++++++++--- .../reconnecting-ingress-connection.ts | 17 ++++-- src/qwp/node.ts | 13 +--- src/qwp/transport.ts | 5 +- test/qwp/public-api-contract.ts | 5 ++ test/qwp/reconnect.test.ts | 61 ++++++++++++++++++- test/qwp/session.test.ts | 30 +++++++++ 9 files changed, 169 insertions(+), 37 deletions(-) diff --git a/QWP.md b/QWP.md index 6a8408d..7b9e088 100644 --- a/QWP.md +++ b/QWP.md @@ -421,13 +421,20 @@ rejection) and then by zone affinity; configuration order breaks ties. Health ou zone, so a known healthy cross-zone node is preferred to an untried local node. Every connection sweep can still try every endpoint, allowing role and health changes to recover. A non-orderly close demotes the selected endpoint before the next sweep. -`reconnect` controls full-jitter exponential backoff and emits lifecycle events. Each -retry delay is selected between zero and the current exponential ceiling, preventing -clients disconnected together from retrying in lockstep. Its attempt and duration -bounds apply to browser/memory reconnect and Node `"sync"` startup. A Node -foreground store-and-forward replay loop remains unbounded after startup. Node ingress -requires a persistent replay store when reconnect is enabled; browser ingress can only -replay from memory for the lifetime of the page. +Ingress reconnect is enabled by default for factory-created browser and Node sessions. +Unacknowledged frames are retained in memory and replayed at least once after a +transport failure. The default memory policy uses full-jitter backoff from 100 ms to +5 seconds and a five-minute per-outage deadline; the initial connection remains +fail-fast. Set `reconnect: false` for one fixed connection. Supplying a `reconnect` +object tunes the bounds, emits lifecycle events through `onEvent`, and retains the +earlier opt-in behavior of retrying initial connection establishment. + +Each retry delay is selected between zero and the current exponential ceiling, +preventing clients disconnected together from retrying in lockstep. Configured attempt +and duration bounds apply to browser/memory reconnect and Node `"sync"` startup. A +Node foreground store-and-forward replay loop remains unbounded after startup. Without +`storeAndForward`, both Node and browser ingress replay only for the lifetime of the +process or page; configuring a Node directory makes the same replay crash-safe. Ingress also detects a replay head that is repeatedly NACKed or followed by a non-orderly WebSocket close. `maxFrameRejections` controls the strike threshold and @@ -884,7 +891,8 @@ Review these behavioral differences before rollout: - QWP symbol dictionaries are connection-scoped and automatic. - Large batches are split to the negotiated WebSocket payload cap. - QWP transactional auto-flush is per table and must be explicitly committed. -- Node reconnection requires store-and-forward and has at-least-once replay semantics. +- Browser and Node QWP ingress reconnect by default with in-memory, at-least-once + replay. Configure Node store-and-forward when replay must survive process failure. - Existing HTTP, TCP, and TLS options do not automatically apply to QWP; put QWP-only connection and session controls under `extraOptions.qwp`. diff --git a/README.md b/README.md index 6fbe90d..9501c8a 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,9 @@ continue to default to ACK waiting. Browser applications use the browser entry point, which has no Node.js dependencies. Cookies are supplied by the browser during a same-origin -WebSocket upgrade. +WebSocket upgrade. Browser and non-persistent Node ingress reconnect by default and +retain unacknowledged frames in memory; set `reconnect: false` in the session options +for a fixed connection. Only Node store-and-forward survives process failure. ```typescript import { connectQwpBrowserSender } from "@questdb/nodejs-client/qwp/browser"; diff --git a/src/qwp/ingress-session.ts b/src/qwp/ingress-session.ts index 1f2ca99..2524c8f 100644 --- a/src/qwp/ingress-session.ts +++ b/src/qwp/ingress-session.ts @@ -11,6 +11,7 @@ import { QwpTableBuffer, } from "./core"; import { + QWP_INITIAL_CONNECT_MODE, QwpBinaryConnection, QwpConnectionCloseInfo, QwpConnectionFactory, @@ -145,15 +146,15 @@ function mergeIngressResponses( export interface QwpIngressSessionOptions { ackTimeoutMs?: number; /** - * Enables bounded reconnection and at-least-once replay of unacknowledged - * frames. Browser replay is memory-only. Node connectors require a - * persistent store-and-forward directory when this is enabled. + * Bounded reconnection and at-least-once replay policy. Reconnection is + * enabled by default for factory-created sessions; set false to keep one + * fixed connection. Browser and non-persistent Node replay is memory-only. * * An ACK lost during disconnect can cause a frame to be replayed after the * server accepted it; configure server-side deduplication when duplicates * are not acceptable. */ - reconnect?: QwpReconnectOptions; + reconnect?: QwpReconnectOptions | false; /** @internal Node adapter hook for persistent store-and-forward. */ replayStore?: QwpIngressReplayStore; /** @internal Starts the Node persistent drainer without waiting for a server. */ @@ -193,6 +194,13 @@ export const QWP_INGRESS_PROGRESS_KIND = { DURABLE_ACKNOWLEDGED: "durable-acknowledged", } as const; +const DEFAULT_INGRESS_RECONNECT_OPTIONS: Readonly = { + maxAttempts: 0, + initialBackoffMs: 100, + maxBackoffMs: 5_000, + maxDurationMs: 300_000, +}; + export type QwpIngressProgressKind = (typeof QWP_INGRESS_PROGRESS_KIND)[keyof typeof QWP_INGRESS_PROGRESS_KIND]; @@ -423,24 +431,45 @@ export class QwpIngressSession { options: QwpIngressSessionOptions = {}, ): Promise { validateIngressSessionOptions(options); - if (options.replayStore && !options.reconnect) { - throw new RangeError("a QWP replayStore requires reconnect options"); + if (options.replayStore && options.reconnect === false) { + throw new RangeError("a QWP replayStore requires ingress reconnect"); } if (options.backgroundStoreAndForward && !options.replayStore) { throw new RangeError( "background QWP store-and-forward requires a replayStore", ); } - const connection = options.reconnect + const reconnectOptions = + options.reconnect === false + ? undefined + : (options.reconnect ?? DEFAULT_INGRESS_RECONNECT_OPTIONS); + const initialConnectMode = + options.initialConnectMode ?? + (options.reconnect === undefined && !options.backgroundStoreAndForward + ? QWP_INITIAL_CONNECT_MODE.OFF + : undefined); + // Preserve the connector contract that the first browser/Node transport + // is constructed synchronously. The in-memory replay store initializes + // asynchronously, but real and test WebSockets may open immediately after + // their factory returns. + const initialConnection = + reconnectOptions && + options.reconnect === undefined && + !options.replayStore && + !options.backgroundStoreAndForward + ? factory() + : undefined; + const connection = reconnectOptions ? await QwpReconnectingIngressConnection.connect( factory, - options.reconnect, + reconnectOptions, options.replayStore, options.maxBatchSizeBytes, options.backgroundStoreAndForward, - options.initialConnectMode, + initialConnectMode, options.orphanStoreAndForward, options.catchUpCapGapMinEscalationWindowMs, + initialConnection, ) : await factory(); try { diff --git a/src/qwp/internal/reconnecting-ingress-connection.ts b/src/qwp/internal/reconnecting-ingress-connection.ts index 83d1b5a..ab3442c 100644 --- a/src/qwp/internal/reconnecting-ingress-connection.ts +++ b/src/qwp/internal/reconnecting-ingress-connection.ts @@ -293,6 +293,7 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { : QWP_INITIAL_CONNECT_MODE.SYNC, orphanStoreAndForward = false, catchUpCapGapMinEscalationWindowMs = DEFAULT_CATCH_UP_CAP_GAP_MIN_ESCALATION_WINDOW_MS, + initialConnection?: Promise, ): Promise { const store = replayStore ?? new QwpMemoryReplayStore(); let connection: QwpReconnectingIngressConnection | undefined; @@ -338,10 +339,10 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { await connection.connectLoop( undefined, false, - backgroundStoreAndForward && - initialConnectMode === QWP_INITIAL_CONNECT_MODE.OFF + initialConnectMode === QWP_INITIAL_CONNECT_MODE.OFF ? "single" : "configured", + initialConnection, ); } catch (error) { if ( @@ -362,7 +363,11 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { return connection; } catch (error) { await connection?.close().catch(() => undefined); - if (!connection) await store.close().catch(() => undefined); + if (!connection) { + const opened = await initialConnection?.catch(() => undefined); + await opened?.close().catch(() => undefined); + await store.close().catch(() => undefined); + } throw error; } } @@ -510,6 +515,7 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { attemptPolicy: ConnectAttemptPolicy = this.backgroundStoreAndForward ? "unbounded" : "configured", + initialConnection?: Promise, ): Promise { const outageStarted = Date.now(); const previousEndpoint = this.lastEndpoint; @@ -543,7 +549,10 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { if (reconnecting) this.totalReconnectAttempts++; let candidate: QwpBinaryConnection | undefined; try { - candidate = await this.factory(); + candidate = + attempt === 1 && initialConnection + ? await initialConnection + : await this.factory(); this.hasEverConnected = true; this.connectingCandidate = candidate; if (this.closing) { diff --git a/src/qwp/node.ts b/src/qwp/node.ts index 2877c7f..128f92a 100644 --- a/src/qwp/node.ts +++ b/src/qwp/node.ts @@ -196,8 +196,8 @@ export interface QwpNodeWebSocketOptions extends QwpWebSocketConnectOptions { export interface QwpNodeIngressOptions extends QwpNodeWebSocketOptions { /** - * Enables persistent Node store-and-forward and ingress reconnection. Use a - * directory owned exclusively by this ingress session. + * Upgrades the default in-memory ingress replay to persistent Node + * store-and-forward. Use a directory owned exclusively by this session. */ storeAndForward?: QwpNodeStoreAndForwardOptions; } @@ -510,15 +510,6 @@ async function connectQwpNodeIngressInternal( "storeAndForward and a custom replayStore cannot both be configured", ); } - if ( - sessionOptions.reconnect && - !options.storeAndForward && - !sessionOptions.replayStore - ) { - throw new RangeError( - "Node QWP ingress reconnection requires a persistent storeAndForward directory", - ); - } let replayStore = options.storeAndForward ? new QwpNodeFileReplayStore(options.storeAndForward) : sessionOptions.replayStore; diff --git a/src/qwp/transport.ts b/src/qwp/transport.ts index 1e30ac5..2f1e727 100644 --- a/src/qwp/transport.ts +++ b/src/qwp/transport.ts @@ -213,8 +213,9 @@ export interface QwpReconnectEvent { } /** - * Initial connection policy for a persistent store-and-forward ingress - * session. Browser and memory-only reconnect transports do not use it. + * Initial connection policy for an ingress reconnect session. Public browser + * and memory-only helpers resolve their default internally; Node persistent + * store-and-forward exposes all three modes. */ export const QWP_INITIAL_CONNECT_MODE = { /** Try once on the caller and fail immediately. */ diff --git a/test/qwp/public-api-contract.ts b/test/qwp/public-api-contract.ts index b054704..08d01c0 100644 --- a/test/qwp/public-api-contract.ts +++ b/test/qwp/public-api-contract.ts @@ -169,6 +169,10 @@ const egressSessionOptionsContract: QwpEgressSessionOptions = { cancelDrainTimeoutMs: 5_000, }; +const fixedConnectionIngressContract: QwpIngressSessionOptions = { + reconnect: false, +}; + const fixedConnectionEgressContract: QwpEgressSessionOptions = { reconnect: false, }; @@ -293,6 +297,7 @@ void nodeOrphanRetrySignature; void nodeStoreAndForwardContract; void queryOptionsContract; void egressSessionOptionsContract; +void fixedConnectionIngressContract; void fixedConnectionEgressContract; void browserEgressOptionsContract; void nodeEgressOptionsContract; diff --git a/test/qwp/reconnect.test.ts b/test/qwp/reconnect.test.ts index 60c71dd..2efe3b2 100644 --- a/test/qwp/reconnect.test.ts +++ b/test/qwp/reconnect.test.ts @@ -38,6 +38,7 @@ import { QwpEgressSession, QwpEgressSessionClosedError, QwpIngressSession, + QwpIngressSessionClosedError, QwpIngressReplayRecord, QwpIngressReplayStore, QwpHandshakeMetadata, @@ -493,6 +494,62 @@ describe("QWP endpoint failover", () => { }); describe("QWP ingress reconnect and replay", () => { + it("keeps default ingress initial connection establishment fail-fast", async () => { + const failure = new Error("offline"); + let factoryCalls = 0; + + await expect( + QwpIngressSession.connect(async () => { + factoryCalls++; + throw failure; + }), + ).rejects.toBe(failure); + expect(factoryCalls).toBe(1); + }); + + it("defaults memory-mode ingress reconnect on and replays an unacknowledged frame", async () => { + const first = new FakeConnection("primary"); + const second = new FakeConnection("secondary"); + const connections = [first, second]; + const session = await QwpIngressSession.connect(async () => { + const connection = connections.shift(); + if (!connection) throw new Error("no connection available"); + return connection; + }); + + const pending = session.sendFrame(Uint8Array.of(9)); + await vi.waitFor(() => expect(first.sent).toHaveLength(1)); + first.drop(); + + await vi.waitFor(() => expect(second.sent).toEqual(first.sent)); + second.receive(ingressResponse(QWP_STATUS.OK, 0n)); + await expect(pending).resolves.toMatchObject({ + status: QWP_STATUS.OK, + sequence: 0n, + }); + expect(session.metrics.totalFramesReplayed).toBe(1); + await session.close(); + }); + + it("allows automatic ingress reconnect to be disabled", async () => { + const connection = new FakeConnection("primary"); + let factoryCalls = 0; + const session = await QwpIngressSession.connect( + async () => { + factoryCalls++; + return connection; + }, + { reconnect: false }, + ); + const pending = session.sendFrame(Uint8Array.of(9)); + await vi.waitFor(() => expect(connection.sent).toHaveLength(1)); + connection.drop(); + + await expect(pending).rejects.toBeInstanceOf(QwpIngressSessionClosedError); + expect(factoryCalls).toBe(1); + await session.close(); + }); + it("applies full jitter to ingress reconnect backoff", async () => { vi.useFakeTimers(); const random = vi.spyOn(Math, "random").mockReturnValue(0.25); @@ -2737,13 +2794,13 @@ describe("QWP Node file replay store", () => { await recovered.close(); }); - it("requires persistence when Node ingress reconnection is enabled", async () => { + it("accepts tuned in-memory reconnect for Node ingress", async () => { await expect( connectQwpNodeIngress( { url: "ws://127.0.0.1:1/write/v4" }, { reconnect: { maxAttempts: 1 } }, ), - ).rejects.toThrow(/persistent storeAndForward directory/); + ).rejects.toBeInstanceOf(QwpReconnectExhaustedError); }); }); diff --git a/test/qwp/session.test.ts b/test/qwp/session.test.ts index c1a0fd6..c637a1f 100644 --- a/test/qwp/session.test.ts +++ b/test/qwp/session.test.ts @@ -529,6 +529,36 @@ describe("QWP WebSocket adapters", () => { await session.close(); }); + it("reconnects browser ingress by default and replays from memory", async () => { + const sockets: FakeWebSocket[] = []; + const session = await connectQwpBrowserIngress({ + url: "ws://localhost:9000/write/v4", + webSocketFactory: () => { + const socket = new FakeWebSocket(); + sockets.push(socket); + queueMicrotask(() => { + socket.open(); + socket.message(ingressServerInfo(128)); + }); + return asQwpSocket(socket); + }, + }); + + const pending = session.sendFrame(Uint8Array.of(1)); + await vi.waitFor(() => expect(sockets[0].sent).toHaveLength(1)); + sockets[0].close(1006, "connection lost"); + + await vi.waitFor(() => expect(sockets).toHaveLength(2)); + await vi.waitFor(() => expect(sockets[1].sent).toEqual(sockets[0].sent)); + sockets[1].message(ingressResponse(QWP_STATUS.OK, 0n)); + await expect(pending).resolves.toMatchObject({ + status: QWP_STATUS.OK, + sequence: 0n, + }); + expect(session.metrics.totalFramesReplayed).toBe(1); + await session.close(); + }); + it("splits fluent browser rows under the negotiated server cap", async () => { const socket = new FakeWebSocket(); const sender = createQwpBrowserSender( From 49f7a9ede5dfadbb66bcf8647cf9b989b9d6a742 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 18 Aug 2026 22:47:28 +0100 Subject: [PATCH 064/265] fix(qwp): align unified config with Java client --- QWP.md | 28 +++-- README.md | 8 +- src/qwp-node/client-config.ts | 163 +++++++++++++++++--------- src/qwp-node/file-replay-store.ts | 39 +++++++ src/qwp/core/table.ts | 22 ++-- src/qwp/ingress-session.ts | 34 ++++-- src/qwp/node.ts | 172 ++++++++++++++++++++------- src/qwp/sender.ts | 23 +++- src/sender.ts | 3 + test/qwp/node-client-config.test.ts | 173 +++++++++++++++++++++++++--- test/qwp/node-transport.test.ts | 2 +- test/qwp/public-api-contract.ts | 2 + test/qwp/reconnect.test.ts | 17 +++ test/qwp/sender.test.ts | 26 +++++ 14 files changed, 573 insertions(+), 139 deletions(-) diff --git a/QWP.md b/QWP.md index 7b9e088..a62bf75 100644 --- a/QWP.md +++ b/QWP.md @@ -109,8 +109,9 @@ to cover the complete WebSocket opening lifecycle and they do not expose `authTimeoutMs`. Give each active sender its own store-and-forward directory. The Node.js journal -persists frames and their symbol dictionary before sending. Persistent senders can -start while every endpoint is offline and reconnect indefinitely by default. Unless +persists frames and their symbol dictionary before sending. Set +`initialConnectMode: "async"` when a persistent sender must start while every +endpoint is offline. Unless `awaitServerAck: true` or `awaitDurableAck: true` is selected, `flush()` resolves once the complete logical flush reaches the configured local journal boundary; a background drainer then sends it in order. The default `"append"` boundary is locally durable, @@ -119,12 +120,15 @@ Applications can therefore keep publishing during an outage until the configured `maxBytes` applies backpressure. A failed journal publication leaves the high-level rows staged so the caller can retry. -`initialConnectMode` selects persistent startup behavior: `"off"` makes one +`initialConnectMode` selects persistent startup behavior: `"off"` (the default) +makes one fail-fast attempt, `"sync"` retries on the caller within the configured reconnect -budget, and `"async"` (the backwards-compatible default) returns immediately while +budget, and `"async"` returns immediately while the background replay loop connects. `Sender.fromConfig()` also accepts `initial_connect_retry=off|sync|async` when `qwp.webSocket.storeAndForward` is supplied. Initial authentication, upgrade, and capability failures remain terminal. +When no mode is explicit, configuring any reconnect duration/backoff key promotes +the initial connection to `"sync"`, so that budget also governs startup. After a foreground persistent sender has connected successfully at least once, the same failures are retried indefinitely so credential rotation and rolling capability changes cannot strand its journal. The configured reconnect attempt/duration budget @@ -283,7 +287,7 @@ Rows are staged until an auto-flush boundary or an explicit `flush()`. A `null` `undefined` column value omits that column from the row. `atNow()` asks QuestDB to assign the designated timestamp; `at(value, unit)` sends an explicit `ns`, `us`, or `ms` timestamp. `close()` publishes completed rows and waits for the committed-frame -ACK watermark for up to `closeFlushTimeoutMs` (5 seconds by default). Set it to `0` +ACK watermark for up to `closeFlushTimeoutMs` (60 seconds by default). Set it to `0` to publish without the ACK drain. An unfinished row is still discarded with a warning. The configuration-string equivalent is `close_flush_timeout_millis`. @@ -730,19 +734,25 @@ sizes may be passed as the second argument. The whole string is still validated before overrides are applied, matching the Java builder's fail-fast behavior. Set `lazy_connect=on` to tolerate an unavailable cluster during startup. In the -TypeScript client this requires `sf_dir`: ingress uses persistent -store-and-forward with `initial_connect_retry=async`, while egress uses +TypeScript client ingress uses memory replay by default, or persistent replay when +`sf_dir` is present, with `initial_connect_retry=async`; egress uses `query_pool_min=0` and connects on the first query. Explicit `initial_connect_retry=off|sync` or a positive `query_pool_min` conflicts with `lazy_connect` and is rejected before the client is created: ```typescript const db = await connectQwpNodeClient( - "wss::addr=node-a.example,node-b.example;" + - "sf_dir=/var/lib/my-app/qwp;lazy_connect=on;", + "wss::addr=node-a.example,node-b.example;" + "lazy_connect=on;", ); ``` +For unified strings with `sf_dir`, Java-compatible defaults apply: memory +durability, a 10 GiB total journal cap, 4 MiB frame/segment batches, a 30-second +capacity wait, a 60-second close drain, and fail-fast initial connection. Set +`sender_id` to name the disk slot base; pooled senders use `-`. +The parser also supports `max_name_len`, password-protected `tls_roots`, and the +Java listener/error inbox capacity keys. + The object form remains available for cases where constructing the two sides separately is useful: diff --git a/README.md b/README.md index 9501c8a..f021b34 100644 --- a/README.md +++ b/README.md @@ -92,8 +92,10 @@ can start and accept flushes while QuestDB is offline. `flush()` then resolves after local durable journal publication and a background drainer reconnects and sends in order. Set `qwp.sender.awaitServerAck: true` to wait for the QuestDB ACK instead, or `awaitDurableAck: true` to wait through durable upload. -Set `initialConnectMode` to `"off"`, `"sync"`, or `"async"` (the default) to -choose fail-fast, bounded blocking, or background startup. The configuration-string +Set `initialConnectMode` to `"off"` (the default), `"sync"`, or `"async"` to +choose fail-fast, bounded blocking, or background startup. Supplying reconnect +budget settings without an explicit mode promotes initial startup to `"sync"`, +matching the Java client. The configuration-string equivalent is `initial_connect_retry`, used together with the store-and-forward options in `extraOptions.qwp`. Set `drainOrphans: true` when sibling journal directories share a dedicated parent: @@ -148,7 +150,7 @@ await sender.commit(); await sender.close(); ``` -QWP `close()` publishes completed rows and waits up to 5 seconds for their +QWP `close()` publishes completed rows and waits up to 60 seconds for their committed-frame ACK watermark. Configure `closeFlushTimeoutMs` (or `close_flush_timeout_millis` in a `ws::` string); `0` publishes without waiting. An unfinished row is not completed implicitly. diff --git a/src/qwp-node/client-config.ts b/src/qwp-node/client-config.ts index 9304d66..3fdc1cc 100644 --- a/src/qwp-node/client-config.ts +++ b/src/qwp-node/client-config.ts @@ -15,6 +15,10 @@ import type { QwpReconnectOptions, QwpTarget } from "../qwp/transport"; const DEFAULT_QWP_PORT = 9000; const MAX_BATCH_ROWS = 1_048_576; +const DEFAULT_CLOSE_FLUSH_TIMEOUT_MS = 60_000; +const DEFAULT_SF_MAX_SEGMENT_BYTES = 4 * 1024 * 1024; +const DEFAULT_SF_MAX_TOTAL_BYTES = 10 * 1024 * 1024 * 1024; +const DEFAULT_SF_APPEND_DEADLINE_MS = 30_000; const SUPPORTED_KEYS = new Set([ "addr", @@ -25,6 +29,7 @@ const SUPPORTED_KEYS = new Set([ "token", "tls_verify", "tls_roots", + "tls_roots_password", "auth_timeout_ms", "connect_timeout", "auto_flush", @@ -72,6 +77,11 @@ const SUPPORTED_KEYS = new Set([ "max_lifetime_ms", "housekeeper_interval_ms", "lazy_connect", + "connection_listener_inbox_capacity", + "error_inbox_capacity", + "max_name_len", + "sender_id", + "sf_max_segment_bytes", // Reserved by the shared QWP configuration vocabulary. They are accepted // as intentional no-ops until the TypeScript client exposes these policies. "on_internal_error", @@ -82,15 +92,6 @@ const SUPPORTED_KEYS = new Set([ "on_write_error", ]); -const UNSUPPORTED_KEYS = new Set([ - "tls_roots_password", - "connection_listener_inbox_capacity", - "error_inbox_capacity", - "max_name_len", - "sender_id", - "sf_max_segment_bytes", -]); - interface ParsedConfig { readonly schema: "ws" | "wss"; readonly values: ReadonlyMap; @@ -105,6 +106,12 @@ export function resolveQwpNodeClientConfig( const value = (key: string): string | undefined => parsed.values.get(key)?.[0]; const endpoints = parseEndpoints(parsed); + const lazyConnect = + optionalBoolean(value("lazy_connect"), "lazy_connect") ?? false; + const initialConnectMode = resolveInitialConnectMode( + parsed.values, + lazyConnect, + ); validateAuthentication(parsed.values); validateTls(parsed); @@ -130,6 +137,7 @@ export function resolveQwpNodeClientConfig( const configuredStoreAndForward = parseStoreAndForward( parsed.values, extraOptions.storeAndForward?.directory, + initialConnectMode, ); const storeAndForward = extraOptions.storeAndForward ? { ...configuredStoreAndForward, ...extraOptions.storeAndForward } @@ -154,17 +162,36 @@ export function resolveQwpNodeClientConfig( "auto_flush_interval", 0, ), - closeFlushTimeoutMs: optionalInteger( - value("close_flush_timeout_millis"), - "close_flush_timeout_millis", - 0, - ), + closeFlushTimeoutMs: + optionalInteger( + value("close_flush_timeout_millis"), + "close_flush_timeout_millis", + 0, + ) ?? DEFAULT_CLOSE_FLUSH_TIMEOUT_MS, + maxNameLength: + optionalInteger(value("max_name_len"), "max_name_len", 16) ?? 127, transactional: optionalBoolean(value("transaction"), "transaction"), ...extraOptions.sender, }; const ingressSession: QwpIngressSessionOptions = { reconnect: ingressReconnect, + initialConnectMode, + maxBatchSizeBytes: optionalSize( + value("sf_max_segment_bytes"), + "sf_max_segment_bytes", + 1, + ), + connectionListenerInboxCapacity: optionalInteger( + value("connection_listener_inbox_capacity"), + "connection_listener_inbox_capacity", + 1, + ), + errorInboxCapacity: optionalInteger( + value("error_inbox_capacity"), + "error_inbox_capacity", + 16, + ), durableAckKeepaliveMs: optionalInteger( value("durable_ack_keepalive_interval_millis"), "durable_ack_keepalive_interval_millis", @@ -239,6 +266,7 @@ export function resolveQwpNodeClientConfig( extraOptions.webSocket?.requestDurableAck ?? optionalBoolean(value("request_durable_ack"), "request_durable_ack"), storeAndForward, + senderId: validateSenderId(value("sender_id") ?? "default"), }; const egress: QwpNodeEgressOptions = { ...common, @@ -279,8 +307,7 @@ export function resolveQwpNodeClientConfig( ingressSession, egressSession, pool, - lazyConnect: - optionalBoolean(value("lazy_connect"), "lazy_connect") ?? false, + lazyConnect, }; } @@ -308,11 +335,6 @@ function parseConfigurationString(configurationString: string): ParsedConfig { const rawKey = setting.slice(0, equals); const rawValue = setting.slice(equals + 1); validateConfigText(rawKey, rawValue); - if (UNSUPPORTED_KEYS.has(rawKey)) { - throw new Error( - `QWP cluster configuration key '${rawKey}' is not supported by the TypeScript client`, - ); - } if (!SUPPORTED_KEYS.has(rawKey)) { throw new Error(`Unknown QWP cluster configuration key: '${rawKey}'`); } @@ -478,15 +500,27 @@ function createAuthorization( function validateTls(parsed: ParsedConfig): void { const tlsVerify = parsed.values.get("tls_verify")?.[0]; + const tlsRoots = parsed.values.get("tls_roots")?.[0]; + const tlsRootsPassword = parsed.values.get("tls_roots_password")?.[0]; if (tlsVerify !== undefined) { optionalEnum(tlsVerify, "tls_verify", ["on", "unsafe_off"] as const); } if ( parsed.schema === "ws" && - (tlsVerify !== undefined || parsed.values.has("tls_roots")) + (tlsVerify !== undefined || + tlsRoots !== undefined || + tlsRootsPassword !== undefined) ) { throw new Error( - "tls_verify and tls_roots are only supported by the wss schema", + "tls_verify, tls_roots, and tls_roots_password are only supported by the wss schema", + ); + } + if (tlsRootsPassword !== undefined && tlsRoots === undefined) { + throw new Error("tls_roots_password requires tls_roots"); + } + if (tlsRoots !== undefined && tlsVerify === "unsafe_off") { + throw new Error( + "tls_roots cannot be combined with tls_verify=unsafe_off; remove tls_verify to use custom roots, or remove tls_roots to disable certificate validation", ); } } @@ -494,9 +528,13 @@ function validateTls(parsed: ParsedConfig): void { function createTlsAgent(parsed: ParsedConfig): HttpsAgent | undefined { const tlsVerify = parsed.values.get("tls_verify")?.[0]; const tlsRoots = parsed.values.get("tls_roots")?.[0]; + const tlsRootsPassword = parsed.values.get("tls_roots_password")?.[0]; if (tlsVerify === undefined && tlsRoots === undefined) return undefined; + const roots = tlsRoots ? readFileSync(tlsRoots) : undefined; return new HttpsAgent({ - ca: tlsRoots ? readFileSync(tlsRoots) : undefined, + ca: tlsRootsPassword === undefined ? roots : undefined, + pfx: tlsRootsPassword === undefined ? undefined : roots, + passphrase: tlsRootsPassword, rejectUnauthorized: tlsVerify !== "unsafe_off", }); } @@ -505,20 +543,17 @@ function parseIngressReconnect( values: ReadonlyMap, ): QwpReconnectOptions | undefined { const reconnect: QwpReconnectOptions = { - initialBackoffMs: optionalInteger( + initialBackoffMs: optionalPositiveInteger( values.get("reconnect_initial_backoff_millis")?.[0], "reconnect_initial_backoff_millis", - 0, ), - maxBackoffMs: optionalInteger( + maxBackoffMs: optionalPositiveInteger( values.get("reconnect_max_backoff_millis")?.[0], "reconnect_max_backoff_millis", - 0, ), - maxDurationMs: optionalInteger( + maxDurationMs: optionalPositiveInteger( values.get("reconnect_max_duration_millis")?.[0], "reconnect_max_duration_millis", - 0, ), maxFrameRejections: optionalInteger( values.get("max_frame_rejections")?.[0], @@ -571,6 +606,7 @@ function parseEgressReconnect( function parseStoreAndForward( values: ReadonlyMap, fallbackDirectory?: string, + initialConnectMode?: "off" | "sync" | "async", ): QwpNodeStoreAndForwardOptions | undefined { const directory = values.get("sf_dir")?.[0] ?? fallbackDirectory; if (!directory) return undefined; @@ -581,27 +617,31 @@ function parseStoreAndForward( ); return { directory, - maxBytes: optionalSize( - values.get("sf_max_total_bytes")?.[0], - "sf_max_total_bytes", - 1, - ), - durability, + maxBytes: + optionalSize( + values.get("sf_max_total_bytes")?.[0], + "sf_max_total_bytes", + 1, + ) ?? DEFAULT_SF_MAX_TOTAL_BYTES, + maxSegmentBytes: + optionalSize( + values.get("sf_max_segment_bytes")?.[0], + "sf_max_segment_bytes", + 1, + ) ?? DEFAULT_SF_MAX_SEGMENT_BYTES, + durability: durability ?? "memory", checkpointIntervalMs: optionalInteger( values.get("sf_sync_interval_millis")?.[0], "sf_sync_interval_millis", 0, ), - backpressurePolicy: values.has("sf_append_deadline_millis") - ? "wait" - : undefined, - appendDeadlineMs: optionalPositiveInteger( - values.get("sf_append_deadline_millis")?.[0], - "sf_append_deadline_millis", - ), - initialConnectMode: optionalInitialConnectMode( - values.get("initial_connect_retry")?.[0], - ), + backpressurePolicy: "wait", + appendDeadlineMs: + optionalPositiveInteger( + values.get("sf_append_deadline_millis")?.[0], + "sf_append_deadline_millis", + ) ?? DEFAULT_SF_APPEND_DEADLINE_MS, + initialConnectMode, catchUpCapGapMinEscalationWindowMs: optionalInteger( values.get("catch_up_cap_gap_min_escalation_window_millis")?.[0], "catch_up_cap_gap_min_escalation_window_millis", @@ -625,12 +665,6 @@ function validateStoreAndForwardDependencies( storeAndForward: QwpNodeStoreAndForwardOptions | undefined, ): void { const sfOnlyKeys = [ - "initial_connect_retry", - "reconnect_initial_backoff_millis", - "reconnect_max_backoff_millis", - "reconnect_max_duration_millis", - "max_frame_rejections", - "poison_min_escalation_window_millis", "catch_up_cap_gap_min_escalation_window_millis", "drain_orphans", "max_background_drainers", @@ -658,6 +692,31 @@ function validateStoreAndForwardDependencies( validateReconnectBounds(parseEgressReconnect(values), "QWP egress failover"); } +function resolveInitialConnectMode( + values: ReadonlyMap, + lazyConnect: boolean, +): "off" | "sync" | "async" { + const explicit = optionalInitialConnectMode( + values.get("initial_connect_retry")?.[0], + ); + if (explicit !== undefined) return explicit; + if (lazyConnect) return "async"; + return values.has("reconnect_initial_backoff_millis") || + values.has("reconnect_max_backoff_millis") || + values.has("reconnect_max_duration_millis") + ? "sync" + : "off"; +} + +function validateSenderId(value: string): string { + if (!/^[A-Za-z0-9_-]+$/.test(value)) { + throw new Error( + "sender_id must contain only letters, digits, underscores, and hyphens", + ); + } + return value; +} + function validateReconnectBounds( reconnect: QwpReconnectOptions | false | undefined, name: string, diff --git a/src/qwp-node/file-replay-store.ts b/src/qwp-node/file-replay-store.ts index b410f88..a43b541 100644 --- a/src/qwp-node/file-replay-store.ts +++ b/src/qwp-node/file-replay-store.ts @@ -41,6 +41,7 @@ const MAX_QUARANTINE_SLOT_ATTEMPTS = 64; // default-sized QWP batches instead, mirroring Java's active+spare liveness // floor when the current dictionary generation consumes the configured cap. const DEFAULT_LIVE_FRAME_BYTES = 2 * 16 * 1024 * 1024; +const DEFAULT_MAX_SEGMENT_BYTES = 4 * 1024 * 1024; const DEFAULT_CHECKPOINT_INTERVAL_MS = 5_000; const DEFAULT_APPEND_DEADLINE_MS = 30_000; const MAX_TIMER_DELAY_MS = 0x7fffffff; @@ -92,6 +93,12 @@ export interface QwpNodeFileReplayStoreOptions { * retires that dictionary generation. */ maxBytes?: number; + /** + * Maximum QWP frame payload stored in one journal record. The TypeScript + * journal is file-per-frame rather than segmented, but this preserves the + * Java `sf_max_segment_bytes` batching boundary. Defaults to 4 MiB. + */ + maxSegmentBytes?: number; /** * Local persistence barrier. `append` preserves the existing fsync-per-frame * behavior, `periodic` checkpoints dirty files in the background, and @@ -170,6 +177,18 @@ export class QwpReplayStoreFullError extends QwpReplayStoreError { } } +export class QwpReplayStoreSegmentTooLargeError extends QwpReplayStoreError { + constructor( + readonly maxSegmentBytes: number, + readonly payloadBytes: number, + ) { + super( + `QWP store-and-forward frame exceeds sf_max_segment_bytes [maxSegmentBytes=${maxSegmentBytes}, payloadBytes=${payloadBytes}]`, + ); + this.name = "QwpReplayStoreSegmentTooLargeError"; + } +} + export class QwpReplayStoreAppendTimeoutError extends QwpReplayStoreError { constructor( readonly maxBytes: number, @@ -225,6 +244,7 @@ export class QwpReplayStoreLockedError extends QwpReplayStoreError { export class QwpNodeFileReplayStore implements QwpIngressReplayStore { private readonly directory: string; private readonly maxBytes: number; + private readonly maxSegmentBytes: number; private readonly liveFrameBytes: number; private readonly durability: QwpSfDurability; private readonly checkpointIntervalMs: number; @@ -266,6 +286,10 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { } this.directory = directory; this.maxBytes = maxBytes; + this.maxSegmentBytes = validatePositiveSafeInteger( + options.maxSegmentBytes ?? DEFAULT_MAX_SEGMENT_BYTES, + "store-and-forward maxSegmentBytes", + ); this.liveFrameBytes = Math.min(maxBytes, DEFAULT_LIVE_FRAME_BYTES); this.durability = validateDurability( options.durability ?? QWP_SF_DURABILITY.APPEND, @@ -393,6 +417,14 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { append(record: QwpIngressReplayRecord): Promise { if (this.closing || this.closed) return Promise.reject(this.closedError()); + if (record.payload.byteLength > this.maxSegmentBytes) { + return Promise.reject( + new QwpReplayStoreSegmentTooLargeError( + this.maxSegmentBytes, + record.payload.byteLength, + ), + ); + } const bytes = encodeRecord(record); if (bytes.byteLength > this.maxBytes) { return Promise.reject( @@ -1344,6 +1376,13 @@ function validateTimerDelay(value: number, name: string): number { return value; } +function validatePositiveSafeInteger(value: number, name: string): number { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new RangeError(`${name} must be a positive safe integer`); + } + return value; +} + async function ignoreMissing(operation: Promise): Promise { try { await operation; diff --git a/src/qwp/core/table.ts b/src/qwp/core/table.ts index d1a4530..f97c7da 100644 --- a/src/qwp/core/table.ts +++ b/src/qwp/core/table.ts @@ -1,7 +1,6 @@ import { QWP_COLUMN_TYPE, QWP_MAX_COLUMNS_PER_TABLE, - QWP_MAX_COLUMN_NAME_LENGTH, QWP_MAX_TABLE_NAME_LENGTH, QwpColumnType, } from "./constants"; @@ -33,18 +32,21 @@ export interface QwpColumnBuffer { /** Mutable columnar staging area for one QWP ingress table. */ export class QwpTableBuffer { readonly name: string; + private readonly maxNameLength: number; private readonly columnList: QwpColumnBuffer[] = []; private readonly columnsByName = new Map(); private rows = 0; - constructor(name: string) { + constructor(name: string, maxNameLength = QWP_MAX_TABLE_NAME_LENGTH) { + if (!Number.isSafeInteger(maxNameLength) || maxNameLength < 1) { + throw new RangeError("maxNameLength must be a positive safe integer"); + } if (!name) throw new Error("table name cannot be empty"); - if (utf8Length(name) > QWP_MAX_TABLE_NAME_LENGTH) { - throw new Error( - `table name too long [maxLength=${QWP_MAX_TABLE_NAME_LENGTH}]`, - ); + if (utf8Length(name) > maxNameLength) { + throw new Error(`table name too long [maxLength=${maxNameLength}]`); } this.name = name; + this.maxNameLength = maxNameLength; } get rowCount(): number { @@ -81,10 +83,8 @@ export class QwpTableBuffer { return existing; } - if (utf8Length(name) > QWP_MAX_COLUMN_NAME_LENGTH) { - throw new Error( - `column name too long [maxLength=${QWP_MAX_COLUMN_NAME_LENGTH}]`, - ); + if (utf8Length(name) > this.maxNameLength) { + throw new Error(`column name too long [maxLength=${this.maxNameLength}]`); } if (this.columnList.length >= QWP_MAX_COLUMNS_PER_TABLE) { throw new Error( @@ -192,7 +192,7 @@ export class QwpTableBuffer { ); } - const result = new QwpTableBuffer(this.name); + const result = new QwpTableBuffer(this.name, this.maxNameLength); result.rows = end - start; for (const column of this.columnList) { let valueStart = 0; diff --git a/src/qwp/ingress-session.ts b/src/qwp/ingress-session.ts index 2524c8f..0d0e34d 100644 --- a/src/qwp/ingress-session.ts +++ b/src/qwp/ingress-session.ts @@ -157,9 +157,9 @@ export interface QwpIngressSessionOptions { reconnect?: QwpReconnectOptions | false; /** @internal Node adapter hook for persistent store-and-forward. */ replayStore?: QwpIngressReplayStore; - /** @internal Starts the Node persistent drainer without waiting for a server. */ + /** @internal Starts memory or persistent replay without waiting for a server. */ backgroundStoreAndForward?: boolean; - /** @internal Initial connection policy supplied by the Node SF adapter. */ + /** @internal Initial connection policy supplied by the Node adapter. */ initialConnectMode?: QwpInitialConnectMode; /** @internal Orphan sessions may quarantine persistent catch-up cap gaps. */ orphanStoreAndForward?: boolean; @@ -180,6 +180,16 @@ export interface QwpIngressSessionOptions { * but disables automatic polling. */ durableAckKeepaliveMs?: number; + /** + * Validated Java-compatible connection listener inbox capacity. Reserved + * until a connection-listener callback is installed on this session. + */ + connectionListenerInboxCapacity?: number; + /** + * Validated Java-compatible async error inbox capacity. Reserved until the + * callback dispatcher exposes bounded delivery controls. + */ + errorInboxCapacity?: number; onResponse?: (response: QwpIngressResponse) => void; onDurableAck?: (response: QwpIngressResponse) => void; /** Monotonic send/accept/durability notifications. Callback errors are ignored. */ @@ -344,6 +354,21 @@ function validateIngressSessionOptions( "durableAckKeepaliveMs must be a non-negative finite number", ); } + for (const [name, value, minimum] of [ + [ + "connectionListenerInboxCapacity", + options.connectionListenerInboxCapacity, + 1, + ], + ["errorInboxCapacity", options.errorInboxCapacity, 16], + ] as const) { + if ( + value !== undefined && + (!Number.isSafeInteger(value) || value < minimum) + ) { + throw new RangeError(`${name} must be an integer of at least ${minimum}`); + } + } } /** @@ -434,11 +459,6 @@ export class QwpIngressSession { if (options.replayStore && options.reconnect === false) { throw new RangeError("a QWP replayStore requires ingress reconnect"); } - if (options.backgroundStoreAndForward && !options.replayStore) { - throw new RangeError( - "background QWP store-and-forward requires a replayStore", - ); - } const reconnectOptions = options.reconnect === false ? undefined diff --git a/src/qwp/node.ts b/src/qwp/node.ts index 128f92a..cd54e0b 100644 --- a/src/qwp/node.ts +++ b/src/qwp/node.ts @@ -61,6 +61,7 @@ export { QwpReplayStoreFullError, QwpReplayStoreLockedError, QwpReplayStoreQuarantinedError, + QwpReplayStoreSegmentTooLargeError, } from "../qwp-node/file-replay-store"; export type { QwpNodeFileReplayStoreMetrics, @@ -200,6 +201,11 @@ export interface QwpNodeIngressOptions extends QwpNodeWebSocketOptions { * store-and-forward. Use a directory owned exclusively by this session. */ storeAndForward?: QwpNodeStoreAndForwardOptions; + /** + * Slot name below storeAndForward.directory. Unified configurations default + * to `default`; pooled clients derive `-` names. + */ + senderId?: string; } /** Notification that an unreplayable foreground slot was preserved aside. */ @@ -214,8 +220,8 @@ export interface QwpNodeReplayRecoveryEvent { export interface QwpNodeStoreAndForwardOptions extends QwpNodeFileReplayStoreOptions { /** - * Initial server connection policy. Defaults to `async` for compatibility: - * persisted rows may be published before an endpoint is online. + * Initial server connection policy. Defaults to `off`; an explicitly tuned + * reconnect policy promotes it to `sync`, matching the Java client. */ initialConnectMode?: QwpInitialConnectMode; /** @@ -267,10 +273,10 @@ export interface QwpNodeClientOptions { egressSession?: QwpEgressSessionOptions; pool?: QwpClientPoolOptions; /** - * Coordinates a non-blocking startup: persistent ingress connects in the - * background and the egress pool remains cold until the first query. Requires - * ingress store-and-forward and conflicts with a positive queryPoolMin or a - * non-async initialConnectMode. + * Coordinates a non-blocking startup: ingress connects in the background, + * using memory replay when store-and-forward is absent, and the egress pool + * remains cold until the first query. Conflicts with a positive queryPoolMin + * or a non-async initialConnectMode. */ lazyConnect?: boolean; } @@ -505,38 +511,55 @@ async function connectQwpNodeIngressInternal( sessionOptions: QwpIngressSessionOptions, startOrphanDrainer: boolean, ): Promise { - if (options.storeAndForward && sessionOptions.replayStore) { + const storeAndForward = resolveNodeStoreAndForwardOptions(options); + if (storeAndForward && sessionOptions.replayStore) { throw new RangeError( "storeAndForward and a custom replayStore cannot both be configured", ); } - let replayStore = options.storeAndForward - ? new QwpNodeFileReplayStore(options.storeAndForward) + let replayStore = storeAndForward + ? new QwpNodeFileReplayStore(storeAndForward) : sessionOptions.replayStore; - const reconnect = options.storeAndForward + const reconnect = storeAndForward ? (sessionOptions.reconnect ?? {}) : sessionOptions.reconnect; - const initialConnectMode = options.storeAndForward + const initialConnectMode = storeAndForward ? validateInitialConnectMode( - options.storeAndForward.initialConnectMode ?? - QWP_INITIAL_CONNECT_MODE.ASYNC, + storeAndForward.initialConnectMode ?? + (sessionOptions.reconnect === undefined + ? QWP_INITIAL_CONNECT_MODE.OFF + : QWP_INITIAL_CONNECT_MODE.SYNC), ) - : undefined; + : sessionOptions.initialConnectMode; + const backgroundReplay = + storeAndForward !== undefined || + sessionOptions.backgroundStoreAndForward === true || + initialConnectMode === QWP_INITIAL_CONNECT_MODE.ASYNC; + const storeBatchCap = + storeAndForward?.maxSegmentBytes ?? + (storeAndForward ? 4 * 1024 * 1024 : undefined); const effectiveSessionOptions: QwpIngressSessionOptions = { ...sessionOptions, reconnect, replayStore, - backgroundStoreAndForward: options.storeAndForward !== undefined, + backgroundStoreAndForward: backgroundReplay, initialConnectMode, + maxBatchSizeBytes: minimumDefined( + sessionOptions.maxBatchSizeBytes, + storeBatchCap, + ), catchUpCapGapMinEscalationWindowMs: - options.storeAndForward?.catchUpCapGapMinEscalationWindowMs, + storeAndForward?.catchUpCapGapMinEscalationWindowMs, durableAckKeepaliveMs: options.requestDurableAck ? (sessionOptions.durableAckKeepaliveMs ?? 200) : sessionOptions.durableAckKeepaliveMs, }; const orphanDrainer = - startOrphanDrainer && options.storeAndForward?.drainOrphans === true - ? createStandaloneOrphanDrainer(options, sessionOptions) + startOrphanDrainer && storeAndForward?.drainOrphans === true + ? createStandaloneOrphanDrainer( + { ...options, senderId: undefined, storeAndForward }, + sessionOptions, + ) : undefined; const connectionFactory = createQwpNodeConnectionFactory(options); let session: QwpIngressSession; @@ -547,18 +570,18 @@ async function connectQwpNodeIngressInternal( ); } catch (error) { if ( - !options.storeAndForward || + !storeAndForward || sessionOptions.orphanStoreAndForward === true || !isQuarantinableReplayRecoveryError(error) ) { throw error; } const recoveryError = await quarantineQwpNodeReplayStore( - options.storeAndForward.directory, + storeAndForward.directory, error, ); - emitReplayRecoveryQuarantine(options.storeAndForward, recoveryError); - replayStore = new QwpNodeFileReplayStore(options.storeAndForward); + emitReplayRecoveryQuarantine(storeAndForward, recoveryError); + replayStore = new QwpNodeFileReplayStore(storeAndForward); session = await QwpIngressSession.connect(connectionFactory, { ...effectiveSessionOptions, replayStore, @@ -614,7 +637,11 @@ export function createQwpNodeSender( ...senderOptions, awaitServerAck: senderOptions.awaitServerAck ?? - (options.storeAndForward ? senderOptions.awaitDurableAck === true : true), + (options.storeAndForward || + sessionOptions.backgroundStoreAndForward === true || + sessionOptions.initialConnectMode === QWP_INITIAL_CONNECT_MODE.ASYNC + ? senderOptions.awaitDurableAck === true + : true), }; return new QwpSender( () => @@ -741,19 +768,31 @@ function resolveNodeClientOptions( function normalizeQwpNodeClientOptions( options: QwpNodeClientOptions, ): QwpNodeClientOptions { - if (!options.lazyConnect) return options; const storeAndForward = options.ingress.storeAndForward; - if (!storeAndForward) { + const storeInitialConnectMode = storeAndForward?.initialConnectMode; + const sessionInitialConnectMode = options.ingressSession?.initialConnectMode; + if ( + storeInitialConnectMode !== undefined && + sessionInitialConnectMode !== undefined && + storeInitialConnectMode !== sessionInitialConnectMode + ) { throw new RangeError( - "conflicting configuration: lazyConnect requires ingress storeAndForward so writes remain available while the server is down", + `conflicting configuration: storeAndForward.initialConnectMode='${storeInitialConnectMode}' differs from ingressSession.initialConnectMode='${sessionInitialConnectMode}'`, ); } - if ( - storeAndForward.initialConnectMode !== undefined && - storeAndForward.initialConnectMode !== QWP_INITIAL_CONNECT_MODE.ASYNC - ) { + if (!options.lazyConnect) return options; + for (const configuredInitialConnectMode of [ + storeInitialConnectMode, + sessionInitialConnectMode, + ]) { + if ( + configuredInitialConnectMode === undefined || + configuredInitialConnectMode === QWP_INITIAL_CONNECT_MODE.ASYNC + ) { + continue; + } throw new RangeError( - `conflicting configuration: lazyConnect requires storeAndForward.initialConnectMode='async', got '${storeAndForward.initialConnectMode}'`, + `conflicting configuration: lazyConnect requires initialConnectMode='async', got '${configuredInitialConnectMode}'`, ); } if ((options.pool?.queryPoolMin ?? 0) > 0) { @@ -765,10 +804,21 @@ function normalizeQwpNodeClientOptions( ...options, ingress: { ...options.ingress, - storeAndForward: { - ...storeAndForward, - initialConnectMode: QWP_INITIAL_CONNECT_MODE.ASYNC, - }, + storeAndForward: storeAndForward + ? { + ...storeAndForward, + initialConnectMode: QWP_INITIAL_CONNECT_MODE.ASYNC, + } + : undefined, + }, + sender: { + ...options.sender, + awaitServerAck: options.sender?.awaitServerAck ?? false, + }, + ingressSession: { + ...options.ingressSession, + backgroundStoreAndForward: true, + initialConnectMode: QWP_INITIAL_CONNECT_MODE.ASYNC, }, pool: { ...options.pool, queryPoolMin: 0 }, }; @@ -797,9 +847,13 @@ function pooledNodeIngressOptions( } return { ...options, + senderId: undefined, storeAndForward: { ...options.storeAndForward, - directory: join(rootDirectory, `sender-${slot}`), + directory: join( + rootDirectory, + `${validateQwpSenderId(options.senderId ?? "sender")}-${slot}`, + ), // The client-level drainer owns sibling adoption. Per-sender scanners // would contend with other managed pool slots during prewarm/borrows. drainOrphans: false, @@ -831,12 +885,13 @@ function createPooledOrphanDrainer( throw new RangeError("storeAndForward directory must not be empty"); } const managedSlotCount = options.pool?.senderPoolMax ?? 4; + const senderId = validateQwpSenderId(options.ingress.senderId ?? "sender"); return createNodeOrphanDrainer( options.ingress, options.ingressSession ?? {}, rootDirectory, (slotName) => { - const managedIndex = parseCanonicalSenderSlot(slotName); + const managedIndex = parseCanonicalSenderSlot(slotName, senderId); if (managedIndex !== undefined && managedIndex < managedSlotCount) { return true; } @@ -869,6 +924,7 @@ function createNodeOrphanDrainer( connectQwpNodeIngressInternal( { ...options, + senderId: undefined, storeAndForward: { ...storeAndForward, directory, @@ -908,13 +964,51 @@ function orphanIngressSessionOptions( }; } -function parseCanonicalSenderSlot(name: string): number | undefined { - const match = /^sender-(0|[1-9]\d*)$/.exec(name); +function parseCanonicalSenderSlot( + name: string, + senderId = "sender", +): number | undefined { + const escapedSenderId = senderId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const match = new RegExp(`^${escapedSenderId}-(0|[1-9]\\d*)$`).exec(name); if (!match) return undefined; const index = Number(match[1]); return Number.isSafeInteger(index) ? index : undefined; } +function resolveNodeStoreAndForwardOptions( + options: QwpNodeIngressOptions, +): QwpNodeStoreAndForwardOptions | undefined { + const storeAndForward = options.storeAndForward; + if (!storeAndForward || options.senderId === undefined) + return storeAndForward; + const rootDirectory = storeAndForward.directory.trim(); + if (!rootDirectory) { + throw new RangeError("storeAndForward directory must not be empty"); + } + return { + ...storeAndForward, + directory: join(rootDirectory, validateQwpSenderId(options.senderId)), + }; +} + +function validateQwpSenderId(value: string): string { + if (!value || !/^[A-Za-z0-9_-]+$/.test(value)) { + throw new RangeError( + "senderId must contain only letters, digits, underscores, and hyphens", + ); + } + return value; +} + +function minimumDefined( + left: number | undefined, + right: number | undefined, +): number | undefined { + if (left === undefined) return right; + if (right === undefined) return left; + return Math.min(left, right); +} + function validateInitialConnectMode( value: QwpInitialConnectMode, ): QwpInitialConnectMode { diff --git a/src/qwp/sender.ts b/src/qwp/sender.ts index c827dc2..eeffa60 100644 --- a/src/qwp/sender.ts +++ b/src/qwp/sender.ts @@ -37,6 +37,8 @@ export interface QwpSenderOptions { */ autoFlushBytes?: number; autoFlushIntervalMs?: number; + /** Maximum UTF-8 byte length of table and column names. Defaults to 127. */ + maxNameLength?: number; /** * Keep auto-flushed rows in an open server-side transaction. An explicit * flush()/commit() closes the transaction. QWP transactions are atomic per @@ -55,7 +57,7 @@ export interface QwpSenderOptions { durableAckTimeoutMs?: number; /** * Maximum time close() spends publishing queued rows and waiting for the - * server ACK watermark. Zero skips the drain. Defaults to 5 seconds. + * server ACK watermark. Zero skips the drain. Defaults to 60 seconds. */ closeFlushTimeoutMs?: number; /** QWP frame encoding options supported by the high-level sender. */ @@ -169,7 +171,8 @@ interface QwpSenderFlushResult { const DEFAULT_AUTO_FLUSH_ROWS = 1_000; const DEFAULT_AUTO_FLUSH_BYTES = 0; const DEFAULT_AUTO_FLUSH_INTERVAL_MS = 100; -const DEFAULT_CLOSE_FLUSH_TIMEOUT_MS = 5_000; +const DEFAULT_CLOSE_FLUSH_TIMEOUT_MS = 60_000; +const DEFAULT_MAX_NAME_LENGTH = 127; function validateNonNegativeInteger(value: number, name: string): void { if (!Number.isSafeInteger(value) || value < 0) { @@ -422,6 +425,7 @@ export class QwpSender { private readonly transactional: boolean; private readonly awaitServerAck: boolean; private readonly closeFlushTimeoutMs: number; + private readonly maxNameLength: number; private readonly log: QwpSenderLogger; constructor( @@ -437,10 +441,16 @@ export class QwpSender { this.awaitServerAck = options.awaitServerAck ?? true; this.closeFlushTimeoutMs = options.closeFlushTimeoutMs ?? DEFAULT_CLOSE_FLUSH_TIMEOUT_MS; + this.maxNameLength = options.maxNameLength ?? DEFAULT_MAX_NAME_LENGTH; validateNonNegativeInteger(this.autoFlushRows, "autoFlushRows"); validateNonNegativeInteger(this.autoFlushBytes, "autoFlushBytes"); validateNonNegativeInteger(this.autoFlushIntervalMs, "autoFlushIntervalMs"); validateNonNegativeInteger(this.closeFlushTimeoutMs, "closeFlushTimeoutMs"); + if (!Number.isSafeInteger(this.maxNameLength) || this.maxNameLength < 16) { + throw new RangeError( + "maxNameLength must be a safe integer of at least 16", + ); + } if ( options.durableAckTimeoutMs !== undefined && (!Number.isFinite(options.durableAckTimeoutMs) || @@ -496,7 +506,7 @@ export class QwpSender { this.throwIfUnavailable(); if (this.current) throw new Error("Table name has already been set"); // Validate eagerly rather than waiting for flush. - new QwpTableBuffer(name); + new QwpTableBuffer(name, this.maxNameLength); let table = this.tablesByName.get(name); if (!table) { table = { name, rows: [], schema: new Map() }; @@ -1176,6 +1186,11 @@ export class QwpSender { if (typeof name !== "string") { throw new TypeError("column name must be a string"); } + if (name && utf8Length(name) > this.maxNameLength) { + throw new Error( + `column name too long [maxLength=${this.maxNameLength}]`, + ); + } const existingSchema = table.schema.get(name); if ( existingSchema && @@ -1381,7 +1396,7 @@ export class QwpSender { } private buildTable(name: string, rows: readonly StagedRow[]): QwpTableBuffer { - const result = new QwpTableBuffer(name); + const result = new QwpTableBuffer(name, this.maxNameLength); for (const row of rows) { for (const column of row.columns.values()) { const target = result.getOrCreateColumn(column.name, column.type); diff --git a/src/sender.ts b/src/sender.ts index ef6a97b..ee65f51 100644 --- a/src/sender.ts +++ b/src/sender.ts @@ -597,6 +597,9 @@ function createConfiguredQwpSender( closeFlushTimeoutMs: isInteger(options.close_flush_timeout_millis, 0) ? options.close_flush_timeout_millis : configuredSender.closeFlushTimeoutMs, + maxNameLength: isInteger(options.max_name_len, 1) + ? options.max_name_len + : configuredSender.maxNameLength, log: logger, }, options.qwp?.session, diff --git a/test/qwp/node-client-config.test.ts b/test/qwp/node-client-config.test.ts index b77c5ab..9680fee 100644 --- a/test/qwp/node-client-config.test.ts +++ b/test/qwp/node-client-config.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdtemp, readdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; @@ -108,6 +108,40 @@ describe("QWP unified Node client configuration", () => { expect(options.pool?.queryPoolMin).toBe(0); }); + it("uses Java-compatible startup and store-and-forward defaults", () => { + const defaults = parseQwpNodeClientConfig( + "ws::addr=localhost;sf_dir=/tmp/qwp-unified-test;", + ); + + expect(defaults.ingress.storeAndForward).toMatchObject({ + directory: "/tmp/qwp-unified-test", + maxBytes: 10 * 1024 * 1024 * 1024, + maxSegmentBytes: 4 * 1024 * 1024, + durability: "memory", + backpressurePolicy: "wait", + appendDeadlineMs: 30_000, + initialConnectMode: "off", + }); + expect(defaults.ingress.senderId).toBe("default"); + expect(defaults.ingressSession?.initialConnectMode).toBe("off"); + expect(defaults.sender).toMatchObject({ + closeFlushTimeoutMs: 60_000, + maxNameLength: 127, + }); + + const tuned = parseQwpNodeClientConfig( + "ws::addr=localhost;sf_dir=/tmp/qwp-unified-test;reconnect_max_duration_millis=1234;", + ); + expect(tuned.ingress.storeAndForward?.initialConnectMode).toBe("sync"); + expect(tuned.ingressSession?.initialConnectMode).toBe("sync"); + + const tunedMemory = parseQwpNodeClientConfig( + "ws::addr=localhost;reconnect_initial_backoff_millis=25;", + ); + expect(tunedMemory.ingress.storeAndForward).toBeUndefined(); + expect(tunedMemory.ingressSession?.initialConnectMode).toBe("sync"); + }); + it("preserves failover=off as an explicit programmatic opt-out", () => { const options = parseQwpNodeClientConfig( "ws::addr=localhost;failover=off;", @@ -116,13 +150,37 @@ describe("QWP unified Node client configuration", () => { expect(options.egressSession?.reconnect).toBe(false); }); + it("fails fast on the default persistent initial connection", async () => { + const directory = await mkdtemp(join(tmpdir(), "qwp-unified-off-")); + let attempts = 0; + const client = createQwpNodeClient( + `ws::addr=offline.example;sf_dir=${directory};sender_pool_max=1;query_pool_min=0;`, + { + webSocket: { + webSocketFactory: (_url, { onConnected }) => { + attempts++; + onConnected(); + return new RejectingWebSocket() as unknown as QwpWebSocketLike; + }, + }, + }, + ); + try { + await expect(client.connect()).rejects.toThrow(); + expect(attempts).toBe(1); + } finally { + await client.close(); + await rm(directory, { recursive: true, force: true }); + } + }); + it("starts lazy persistent ingress without prewarming egress", async () => { const directory = await mkdtemp(join(tmpdir(), "qwp-unified-client-")); const attemptedPaths: string[] = []; let client: Awaited> | undefined; try { client = await connectQwpNodeClient( - `ws::addr=offline.example;sf_dir=${directory};lazy_connect=on;sender_pool_max=1;`, + `ws::addr=offline.example;sf_dir=${directory};sender_id=producer_1;lazy_connect=on;sender_pool_max=1;`, { webSocket: { webSocketFactory: (url, { onConnected }) => { @@ -137,13 +195,43 @@ describe("QWP unified Node client configuration", () => { expect(attemptedPaths).toEqual(["/write/v4"]); expect(client.metrics.senders.total).toBe(1); expect(client.metrics.queries.total).toBe(0); + expect(await readdir(directory)).toContain("producer_1-0"); } finally { await client?.close(); await rm(directory, { recursive: true, force: true }); } }); - it("rejects lazy startup conflicts before constructing the client", () => { + it("starts lazy memory-buffered ingress without sf_dir", async () => { + const attemptedPaths: string[] = []; + const client = await connectQwpNodeClient( + "ws::addr=offline.example;lazy_connect=on;sender_pool_max=1;", + { + webSocket: { + webSocketFactory: (url, { onConnected }) => { + attemptedPaths.push(new URL(url).pathname); + onConnected(); + return new RejectingWebSocket() as unknown as QwpWebSocketLike; + }, + }, + sender: { closeFlushTimeoutMs: 0 }, + }, + ); + try { + expect(attemptedPaths).toEqual(["/write/v4"]); + expect(client.metrics.senders.total).toBe(1); + expect(client.metrics.queries.total).toBe(0); + const sender = await client.borrowSender(); + await sender.table("events").longColumn("value", 42n).atNow(); + await sender.flush(); + expect(sender.metrics.totalRowsPublished).toBe(1); + await sender.close(); + } finally { + await client.close(); + } + }); + + it("rejects lazy startup conflicts before constructing the client", async () => { expect(() => parseQwpNodeClientConfig( "ws::addr=localhost;lazy_connect=on;initial_connect_retry=sync;sf_dir=/tmp/qwp;", @@ -154,16 +242,35 @@ describe("QWP unified Node client configuration", () => { "ws::addr=localhost;lazy_connect=on;query_pool_min=1;sf_dir=/tmp/qwp;", ), ).toThrow(/lazyConnect requires queryPoolMin=0/); - expect(() => - parseQwpNodeClientConfig("ws::addr=localhost;lazy_connect=on;"), - ).toThrow(/lazyConnect requires ingress storeAndForward/); expect(() => createQwpNodeClient({ - ingress: { url: "ws://localhost:9000/write/v4" }, + ingress: { + url: "ws://localhost:9000/write/v4", + storeAndForward: { + directory: "/tmp/qwp", + initialConnectMode: "off", + }, + }, egress: { url: "ws://localhost:9000/read/v1" }, - lazyConnect: true, + ingressSession: { initialConnectMode: "sync" }, }), - ).toThrow(/lazyConnect requires ingress storeAndForward/); + ).toThrow(/initialConnectMode.*differs/); + const memoryOptions = parseQwpNodeClientConfig( + "ws::addr=localhost;lazy_connect=on;", + ); + expect(memoryOptions.ingress.storeAndForward).toBeUndefined(); + expect(memoryOptions.ingressSession).toMatchObject({ + backgroundStoreAndForward: true, + initialConnectMode: "async", + }); + const client = createQwpNodeClient({ + ingress: { url: "ws://localhost:9000/write/v4" }, + egress: { url: "ws://localhost:9000/read/v1" }, + lazyConnect: true, + pool: { senderPoolMin: 0 }, + }); + expect(client.metrics.senders.minimum).toBe(0); + await client.close(); }); it("validates ingress, egress, pool, and shared conflicts up front", () => { @@ -226,7 +333,7 @@ describe("QWP unified Node client configuration", () => { await Promise.all([objectClient.close(), stringClient.close()]); }); - it("rejects duplicate, unknown, and unsupported active keys", () => { + it("rejects duplicate and unknown active keys", () => { expect(() => parseQwpNodeClientConfig( "ws::addr=db-a;addr=db-b;target=primary;target=replica;", @@ -235,9 +342,49 @@ describe("QWP unified Node client configuration", () => { expect(() => parseQwpNodeClientConfig("ws::addr=localhost;made_up=1;"), ).toThrow(/Unknown.*made_up/); - expect(() => - parseQwpNodeClientConfig("ws::addr=localhost;sf_max_segment_bytes=1m;"), - ).toThrow(/not supported by the TypeScript client/); + }); + + it("accepts and validates the remaining Java QWP configuration keys", async () => { + const directory = await mkdtemp(join(tmpdir(), "qwp-tls-roots-")); + const trustStore = join(directory, "roots.p12"); + await writeFile(trustStore, Uint8Array.of(1, 2, 3)); + try { + const options = parseQwpNodeClientConfig( + `wss::addr=localhost;tls_roots=${trustStore};tls_roots_password=secret;` + + "connection_listener_inbox_capacity=7;error_inbox_capacity=32;" + + "max_name_len=512;sender_id=producer_1;sf_max_segment_bytes=8m;", + ); + expect(options.ingress.agent).toBeDefined(); + expect(options.sender?.maxNameLength).toBe(512); + expect(options.ingress.senderId).toBe("producer_1"); + expect(options.ingressSession).toMatchObject({ + maxBatchSizeBytes: 8 * 1024 * 1024, + connectionListenerInboxCapacity: 7, + errorInboxCapacity: 32, + }); + + expect(() => + parseQwpNodeClientConfig( + "wss::addr=localhost;tls_roots_password=secret;", + ), + ).toThrow(/requires tls_roots/); + expect(() => + parseQwpNodeClientConfig( + `wss::addr=localhost;tls_roots=${trustStore};tls_verify=unsafe_off;`, + ), + ).toThrow(/cannot be combined/); + expect(() => + parseQwpNodeClientConfig("ws::addr=localhost;max_name_len=15;"), + ).toThrow(/max_name_len/); + expect(() => + parseQwpNodeClientConfig("ws::addr=localhost;sender_id=bad.name;"), + ).toThrow(/sender_id/); + expect(() => + parseQwpNodeClientConfig("ws::addr=localhost;error_inbox_capacity=15;"), + ).toThrow(/error_inbox_capacity/); + } finally { + await rm(directory, { recursive: true, force: true }); + } }); it("validates cluster authorities and supports bracketed IPv6", () => { diff --git a/test/qwp/node-transport.test.ts b/test/qwp/node-transport.test.ts index 8540ec5..9dc3e04 100644 --- a/test/qwp/node-transport.test.ts +++ b/test/qwp/node-transport.test.ts @@ -549,7 +549,7 @@ describe("QWP Node transport", () => { { url: `ws://127.0.0.1:${port}/write/v4`, connectTimeoutMs: 100, - storeAndForward: { directory }, + storeAndForward: { directory, initialConnectMode: "async" }, }, { autoFlush: false }, { diff --git a/test/qwp/public-api-contract.ts b/test/qwp/public-api-contract.ts index 08d01c0..5f1a6c5 100644 --- a/test/qwp/public-api-contract.ts +++ b/test/qwp/public-api-contract.ts @@ -142,6 +142,7 @@ const nodeOrphanRetrySignature: (directory: string) => Promise = const nodeStoreAndForwardContract: QwpNodeStoreAndForwardOptions = { directory: "/tmp/qwp-public-api-contract", + maxSegmentBytes: 4 * 1024 * 1024, durability: "periodic", checkpointIntervalMs: 5_000, backpressurePolicy: "wait", @@ -204,6 +205,7 @@ const qwpExtraOptionsContract: QwpExtraOptions = { sender: { transactional: true, autoFlushBytes: 4 * 1024 * 1024, + maxNameLength: 255, closeFlushTimeoutMs: 5_000, awaitDurableAck: true, }, diff --git a/test/qwp/reconnect.test.ts b/test/qwp/reconnect.test.ts index 2efe3b2..103bca0 100644 --- a/test/qwp/reconnect.test.ts +++ b/test/qwp/reconnect.test.ts @@ -22,6 +22,7 @@ import { QwpReplayStoreError, QwpReplayStoreFullError, QwpReplayStoreLockedError, + QwpReplayStoreSegmentTooLargeError, } from "../../src/qwp/node"; import { QWP_RECONNECT_EVENT_KIND, @@ -2392,6 +2393,22 @@ describe("QWP Node file replay store", () => { expect( () => new QwpNodeFileReplayStore({ directory, appendDeadlineMs: 0 }), ).toThrow(/appendDeadlineMs must be a positive safe integer/); + expect( + () => new QwpNodeFileReplayStore({ directory, maxSegmentBytes: 0 }), + ).toThrow(/maxSegmentBytes must be a positive safe integer/); + + const segmented = new QwpNodeFileReplayStore({ + directory, + maxSegmentBytes: 2, + }); + await segmented.load(); + await expect( + segmented.append({ + frameSequence: 0n, + payload: Uint8Array.of(1, 2, 3), + }), + ).rejects.toBeInstanceOf(QwpReplayStoreSegmentTooLargeError); + await segmented.close(); const defaults = new QwpNodeFileReplayStore({ directory }); expect(defaults.metrics).toMatchObject({ diff --git a/test/qwp/sender.test.ts b/test/qwp/sender.test.ts index c33f3a0..e5b8a58 100644 --- a/test/qwp/sender.test.ts +++ b/test/qwp/sender.test.ts @@ -207,6 +207,32 @@ describe("QWP high-level sender", () => { ).toThrow(/closeFlushTimeoutMs must be a non-negative safe integer/); }); + it("applies a configurable UTF-8 table and column name limit", async () => { + const session = new RecordingSession(); + expect( + () => new QwpSender(async () => session, { maxNameLength: 15 }), + ).toThrow(/maxNameLength must be a safe integer of at least 16/); + + const defaultSender = new QwpSender(async () => session); + expect(() => defaultSender.table("t".repeat(128))).toThrow( + /table name too long.*maxLength=127/, + ); + await defaultSender.close(); + + const sender = new QwpSender(async () => session, { + autoFlush: false, + maxNameLength: 256, + }); + await sender + .table("t".repeat(128)) + .longColumn("c".repeat(128), 42n) + .atNow(); + await sender.flush(); + expect(session.sends.at(-1)?.tables[0].name).toHaveLength(128); + expect(session.sends.at(-1)?.tables[0].columns[0].name).toHaveLength(128); + await sender.close(); + }); + it("returns a publication sequence and waits for its ACK independently", async () => { const session = new WatermarkSession(); const sender = new QwpSender(async () => session, { From d5d9d9a5b824cec8832d2bc78f93ea5e70e94a43 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 18 Aug 2026 23:15:29 +0100 Subject: [PATCH 065/265] feat(qwp): isolate observability callbacks --- QWP.md | 49 ++++-- README.md | 20 ++- src/qwp-node/orphan-drainer.ts | 76 +++++++-- src/qwp/index.ts | 1 + src/qwp/ingress-session.ts | 129 +++++++++++++-- src/qwp/internal/notification-dispatcher.ts | 154 ++++++++++++++++++ .../reconnecting-ingress-connection.ts | 105 +++++++++++- src/qwp/node.ts | 35 +++- src/qwp/sender-error.ts | 142 ++++++++++++++++ src/qwp/transport.ts | 9 + test/qwp/node-transport.test.ts | 32 +++- test/qwp/notification-dispatcher.test.ts | 65 ++++++++ test/qwp/orphan-drainer.test.ts | 25 +++ test/qwp/public-api-contract.ts | 11 ++ test/qwp/public-api.test.ts | 2 + test/qwp/reconnect.test.ts | 16 ++ test/qwp/sender-error.test.ts | 86 ++++++++++ test/qwp/session.test.ts | 17 ++ 18 files changed, 912 insertions(+), 62 deletions(-) create mode 100644 src/qwp/internal/notification-dispatcher.ts create mode 100644 src/qwp/sender-error.ts create mode 100644 test/qwp/notification-dispatcher.test.ts create mode 100644 test/qwp/sender-error.test.ts diff --git a/QWP.md b/QWP.md index a62bf75..0c6a0a0 100644 --- a/QWP.md +++ b/QWP.md @@ -182,7 +182,10 @@ contain a dictionary gap or conflict that cannot be reconstructed, the foregroun slot is renamed to `.unreplayable-N`, marked with `.qwp.failed`, and preserved for inspection. The sender then starts once with a clean slot at the configured path. `onRecoveryQuarantine` receives the original and quarantine paths plus the terminal -cause; its callback cannot interrupt recovery. Quarantined paths are never adopted by +cause and a typed `senderError`. The shared `onSenderError` callback receives the same +`data-loss` / `abandoned` verdict and its `quarantinedPath`. This build-time recovery +notification is synchronous because no connected sender dispatcher exists yet; +callback failures cannot interrupt recovery. Quarantined paths are never adopted by the orphan scanner. Operational filesystem errors are not quarantined and still fail startup, so a temporary permissions or disk problem cannot be mistaken for data corruption. @@ -196,8 +199,9 @@ default). The scanner runs immediately and then every 30 seconds; set `.qwp.failed` in the slot so a corrupt or permanently rejected head cannot cause a hot retry loop. After inspection or repair, call `retryQwpNodeOrphanSlot(slotDirectory)` to make it eligible again. `onOrphanDrainEvent` reports discovery, drain, lock -contention, quarantine, and scanner failures without allowing callback exceptions to -interrupt recovery. +contention, quarantine, and scanner failures through a bounded asynchronous inbox. An +abandoned slot also reports a typed `data-loss` sender error. Callback exceptions +cannot interrupt recovery. A foreground sender retries a symbol-dictionary catch-up entry that is too large for the current target forever because a larger-cap node may return. An orphan drainer @@ -476,6 +480,15 @@ const sender = createQwpNodeSender( } }, onError: (event) => console.error("QWP ingress", event.error), + onSenderError: (error) => { + console.error( + "QWP rejection", + error.category, + error.appliedPolicy, + error.fromFsn, + error.toFsn, + ); + }, }, ); @@ -483,10 +496,23 @@ await sender.connect(); console.info(sender.metrics); ``` -Callback failures are contained and cannot fail the session. Keep callbacks short; -Node.js and browsers run them on the JavaScript event loop. The ingress snapshot -separates client-session sequences from persistent replay watermarks and reports -published, sent, replayed, acknowledged, durable, reconnect, and error counters. +Callbacks are placed on bounded asynchronous inboxes and never invoked inside ACK, +reconnect, or orphan-recovery protocol stacks. Connection events default to 64 retained +entries and errors to 256; `connectionListenerInboxCapacity` and +`errorInboxCapacity` (or their snake-case unified-string keys) tune those bounds. +Overflow drops the oldest pending entry and retains the newest state. Inspect +`droppedProgressNotifications`, `droppedConnectionNotifications`, and +`droppedErrorNotifications` in the immutable ingress metrics; non-zero values mean an +observer is not keeping up. Callback failures are contained. Callbacks still execute on +the JavaScript event loop, so CPU-bound synchronous work should be moved to an +application worker. + +`onSenderError` is the Java-parity rejection stream. Its immutable payload includes +`category`, applied policy, raw server status/message, wire message sequence, inclusive +stable `[fromFsn, toFsn]` correlation range, optional single-table attribution, and +`quarantinedPath` for abandoned persistent data. The legacy `onError` callback remains +available for timeouts and general session failures; classified NACK events also expose +the same payload as `event.senderError`. ## Egress @@ -751,7 +777,8 @@ durability, a 10 GiB total journal cap, 4 MiB frame/segment batches, a 30-second capacity wait, a 60-second close drain, and fail-fast initial connection. Set `sender_id` to name the disk slot base; pooled senders use `-`. The parser also supports `max_name_len`, password-protected `tls_roots`, and the -Java listener/error inbox capacity keys. +Java listener/error inbox capacity keys. Those capacities actively bound asynchronous +connection and typed-error delivery and are reflected in ingress drop counters. The object form remains available for cases where constructing the two sides separately is useful: @@ -935,8 +962,10 @@ acknowledgement, and persistent replay—but uses runtime-specific connection fa | Reusable result views | `queryViews()` with column views or `forEachRow()` row views | | Egress row/buffer bounds | `maxBatchRows` and session `bufferPoolSize` | -Do not translate Java threading assumptions directly: callbacks, WebSocket delivery, -and iteration all share the JavaScript event loop. +Unlike Java's dedicated dispatcher threads, TypeScript callback inboxes schedule work on +later JavaScript event-loop turns. This keeps user callbacks out of protocol call stacks, +but CPU-bound callback code still blocks the runtime and belongs in a Worker or +`worker_threads` task. ## Public API policy diff --git a/README.md b/README.md index f021b34..edb60af 100644 --- a/README.md +++ b/README.md @@ -185,6 +185,14 @@ const sender = createQwpBrowserSender( } }, onError: (event) => console.error("QWP ingress", event.error), + onSenderError: (error) => + console.error( + "QWP rejection", + error.category, + error.appliedPolicy, + error.fromFsn, + error.toFsn, + ), }, ); @@ -198,9 +206,15 @@ console.info( Snapshots distinguish the client-session acceptance sequence from persistent replay watermarks. With durable ACKs, `replayAcknowledgedFrameSequence` -advances only after the durable watermark covers a frame. Observer exceptions -are contained so they cannot fail the session, but callbacks should remain -lightweight because browser and Node JavaScript share the event loop. +advances only after the durable watermark covers a frame. Observer callbacks are +dispatched asynchronously through bounded, drop-oldest inboxes, so they do not run +inside ACK or reconnect protocol stacks. The metrics snapshot exposes delivered and +dropped progress, connection, and error notification counters. +`connectionListenerInboxCapacity` and `errorInboxCapacity` tune the Java-compatible +64/256 defaults. `onSenderError` receives typed category/policy, wire status, message +sequence, stable frame-sequence range, and quarantine context. Observer exceptions are +contained, but CPU-bound callbacks should still move work to a Worker because browser +and Node JavaScript share the event loop. When QuestDB authentication is enabled, establish the browser's HttpOnly `qdb_session` cookie over REST before opening a QWP WebSocket. A QuestDB REST diff --git a/src/qwp-node/orphan-drainer.ts b/src/qwp-node/orphan-drainer.ts index b5d9152..0913cc4 100644 --- a/src/qwp-node/orphan-drainer.ts +++ b/src/qwp-node/orphan-drainer.ts @@ -8,11 +8,18 @@ import { isQwpNodeReplayQuarantineSlotName, QwpReplayStoreLockedError, } from "./file-replay-store"; +import { QwpNotificationDispatcher } from "../qwp/internal/notification-dispatcher"; +import { + createQwpDataLossSenderError, + type QwpSenderError, +} from "../qwp/sender-error"; const RECORD_SUFFIX = ".qwp"; const DEFAULT_MAX_CONCURRENT = 4; const DEFAULT_SCAN_INTERVAL_MS = 30_000; const DEFAULT_PROGRESS_POLL_MS = 50; +const DEFAULT_CONNECTION_LISTENER_INBOX_CAPACITY = 64; +const DEFAULT_ERROR_INBOX_CAPACITY = 256; /** A terminal orphan-drain failure marker. Remove it to retry the slot. */ export const QWP_ORPHAN_FAILED_SENTINEL = ".qwp.failed"; @@ -34,6 +41,8 @@ export interface QwpNodeOrphanDrainEvent { readonly timestampMs: number; readonly directory?: string; readonly error?: Error; + /** Present when a failed slot has been abandoned behind its sentinel. */ + readonly senderError?: QwpSenderError; readonly metrics: QwpNodeOrphanDrainerMetrics; } @@ -46,6 +55,10 @@ export interface QwpNodeOrphanDrainerMetrics { readonly locked: number; readonly failed: number; readonly scanFailures: number; + readonly deliveredNotifications: number; + readonly droppedNotifications: number; + readonly deliveredErrorNotifications: number; + readonly droppedErrorNotifications: number; readonly closing: boolean; readonly closed: boolean; } @@ -78,6 +91,12 @@ export interface QwpNodeOrphanDrainerOptions { /** Durable-ACK prompt cadence for adopted sessions. Zero disables it. */ durableAckPollIntervalMs?: number; onEvent?: (event: QwpNodeOrphanDrainEvent) => void; + /** Java-parity data-loss notification for an abandoned orphan slot. */ + onSenderError?: (error: QwpSenderError) => void; + /** Bounded lifecycle-event inbox. Defaults to 64. */ + eventInboxCapacity?: number; + /** Bounded data-loss inbox. Defaults to 256. */ + errorInboxCapacity?: number; } /** @@ -149,7 +168,8 @@ export class QwpNodeOrphanDrainer { private readonly maxConcurrent: number; private readonly scanIntervalMs: number; private readonly durableAckPollIntervalMs: number; - private readonly onEvent?: (event: QwpNodeOrphanDrainEvent) => void; + private readonly eventDispatcher?: QwpNotificationDispatcher; + private readonly errorDispatcher?: QwpNotificationDispatcher; private readonly known = new Set(); private readonly queue: string[] = []; private readonly active = new Map(); @@ -194,13 +214,33 @@ export class QwpNodeOrphanDrainer { "QWP orphan-drain durableAckPollIntervalMs must be a non-negative finite number", ); } + for (const [name, value] of [ + ["eventInboxCapacity", options.eventInboxCapacity], + ["errorInboxCapacity", options.errorInboxCapacity], + ] as const) { + if (value !== undefined && (!Number.isSafeInteger(value) || value < 1)) { + throw new RangeError(`${name} must be a positive safe integer`); + } + } this.rootDirectory = rootDirectory; this.excludeSlot = options.excludeSlot; this.createSession = options.createSession; this.maxConcurrent = maxConcurrent; this.scanIntervalMs = scanIntervalMs; this.durableAckPollIntervalMs = durableAckPollIntervalMs; - this.onEvent = options.onEvent; + if (options.onEvent) { + this.eventDispatcher = new QwpNotificationDispatcher( + options.onEvent, + options.eventInboxCapacity ?? + DEFAULT_CONNECTION_LISTENER_INBOX_CAPACITY, + ); + } + if (options.onSenderError) { + this.errorDispatcher = new QwpNotificationDispatcher( + options.onSenderError, + options.errorInboxCapacity ?? DEFAULT_ERROR_INBOX_CAPACITY, + ); + } } get metrics(): QwpNodeOrphanDrainerMetrics { @@ -213,6 +253,10 @@ export class QwpNodeOrphanDrainer { locked: this.locked, failed: this.failed, scanFailures: this.scanFailures, + deliveredNotifications: this.eventDispatcher?.metrics.delivered ?? 0, + droppedNotifications: this.eventDispatcher?.metrics.dropped ?? 0, + deliveredErrorNotifications: this.errorDispatcher?.metrics.delivered ?? 0, + droppedErrorNotifications: this.errorDispatcher?.metrics.dropped ?? 0, closing: this.closing, closed: this.closed, }); @@ -364,6 +408,10 @@ export class QwpNodeOrphanDrainer { ); await Promise.allSettled(Array.from(this.workers)); this.known.clear(); + await Promise.all([ + this.eventDispatcher?.close(), + this.errorDispatcher?.close(), + ]); this.closed = true; } @@ -372,17 +420,19 @@ export class QwpNodeOrphanDrainer { directory?: string, error?: Error, ): void { - try { - this.onEvent?.({ - kind, - timestampMs: Date.now(), - directory, - error, - metrics: this.metrics, - }); - } catch { - // Observers must not interfere with durable recovery. - } + const senderError = + kind === QWP_ORPHAN_DRAIN_EVENT_KIND.FAILED && directory && error + ? createQwpDataLossSenderError(error.message, directory) + : undefined; + this.eventDispatcher?.offer({ + kind, + timestampMs: Date.now(), + directory, + error, + senderError, + metrics: this.metrics, + }); + if (senderError) this.errorDispatcher?.offer(senderError); } } diff --git a/src/qwp/index.ts b/src/qwp/index.ts index 460f765..a167d1a 100644 --- a/src/qwp/index.ts +++ b/src/qwp/index.ts @@ -11,4 +11,5 @@ export * from "./client"; export * from "./egress-session"; export * from "./ingress-session"; export * from "./sender"; +export * from "./sender-error"; export * from "./transport"; diff --git a/src/qwp/ingress-session.ts b/src/qwp/ingress-session.ts index 0d0e34d..fce6313 100644 --- a/src/qwp/ingress-session.ts +++ b/src/qwp/ingress-session.ts @@ -22,8 +22,17 @@ import { QwpReplayDictionaryPersistenceError, } from "./transport"; import { QwpReconnectingIngressConnection } from "./internal/reconnecting-ingress-connection"; +import { QwpNotificationDispatcher } from "./internal/notification-dispatcher"; +import { + createQwpSenderError, + QWP_SENDER_ERROR_POLICY, + type QwpSenderError, +} from "./sender-error"; const QWP_FLAGS_OFFSET = 5; +const DEFAULT_CONNECTION_LISTENER_INBOX_CAPACITY = 64; +const DEFAULT_ERROR_INBOX_CAPACITY = 256; +const DEFAULT_PROGRESS_INBOX_CAPACITY = 256; interface PlannedIngressFrames { readonly frames: Uint8Array[]; @@ -181,15 +190,17 @@ export interface QwpIngressSessionOptions { */ durableAckKeepaliveMs?: number; /** - * Validated Java-compatible connection listener inbox capacity. Reserved - * until a connection-listener callback is installed on this session. + * Bounded reconnect-listener inbox. Oldest pending events are dropped when + * full. Defaults to 64, matching the Java client. */ connectionListenerInboxCapacity?: number; /** - * Validated Java-compatible async error inbox capacity. Reserved until the - * callback dispatcher exposes bounded delivery controls. + * Bounded typed/legacy error inbox. Oldest pending errors are dropped when + * full. Defaults to 256, matching the Java client. */ errorInboxCapacity?: number; + /** Java-parity typed server-rejection and data-loss notifications. */ + onSenderError?: (error: QwpSenderError) => void; onResponse?: (response: QwpIngressResponse) => void; onDurableAck?: (response: QwpIngressResponse) => void; /** Monotonic send/accept/durability notifications. Callback errors are ignored. */ @@ -238,6 +249,12 @@ export interface QwpIngressMetrics { readonly totalReconnectsSucceeded: number; readonly totalFailovers: number; readonly totalReconnectErrors: number; + readonly deliveredProgressNotifications: number; + readonly droppedProgressNotifications: number; + readonly deliveredConnectionNotifications: number; + readonly droppedConnectionNotifications: number; + readonly deliveredErrorNotifications: number; + readonly droppedErrorNotifications: number; /** Stable store-and-forward watermark; absent without reconnect/replay. */ readonly replayPublishedFrameSequence?: bigint; /** Trim watermark; in durable-ACK mode it advances only after durability. */ @@ -260,6 +277,8 @@ export interface QwpIngressErrorEvent { readonly terminal: boolean; readonly timestampMs: number; readonly response?: QwpIngressResponse; + /** Present for a classified server rejection. */ + readonly senderError?: QwpSenderError; readonly metrics: QwpIngressMetrics; } @@ -285,7 +304,10 @@ interface PendingAcknowledgedSequence { } export class QwpIngressNackError extends Error { - constructor(readonly response: QwpIngressResponse) { + constructor( + readonly response: QwpIngressResponse, + readonly senderError: QwpSenderError = createQwpSenderError(response), + ) { super( response.errorMessage ?? `QuestDB rejected QWP frame [status=0x${response.status.toString(16)}]`, @@ -417,6 +439,8 @@ export class QwpIngressSession { private closePromise?: Promise; private readonly closeHooks: (() => void | Promise)[] = []; private readonly receiveLoop: Promise; + private readonly progressDispatcher?: QwpNotificationDispatcher<() => void>; + private readonly errorDispatcher?: QwpNotificationDispatcher<() => void>; constructor( private readonly connection: QwpBinaryConnection, @@ -443,6 +467,21 @@ export class QwpIngressSession { throw error; } this.localMaxBatchSizeBytes = options.maxBatchSizeBytes; + if (options.onResponse || options.onDurableAck || options.onProgress) { + this.progressDispatcher = new QwpNotificationDispatcher( + (callback) => callback(), + DEFAULT_PROGRESS_INBOX_CAPACITY, + ); + } + if ( + options.onError || + (options.onSenderError && !connection.managesIngressSenderErrors) + ) { + this.errorDispatcher = new QwpNotificationDispatcher( + (callback) => callback(), + options.errorInboxCapacity ?? DEFAULT_ERROR_INBOX_CAPACITY, + ); + } for (const entry of connection.ingressSymbolDictionary ?? []) { this.symbolDictionary.addRecovered(entry); } @@ -490,6 +529,10 @@ export class QwpIngressSession { options.orphanStoreAndForward, options.catchUpCapGapMinEscalationWindowMs, initialConnection, + options.connectionListenerInboxCapacity ?? + DEFAULT_CONNECTION_LISTENER_INBOX_CAPACITY, + options.errorInboxCapacity ?? DEFAULT_ERROR_INBOX_CAPACITY, + options.onSenderError, ) : await factory(); try { @@ -563,6 +606,20 @@ export class QwpIngressSession { totalReconnectsSucceeded: transport?.totalReconnectsSucceeded ?? 0, totalFailovers: transport?.totalFailovers ?? 0, totalReconnectErrors: transport?.totalReconnectErrors ?? 0, + deliveredProgressNotifications: + this.progressDispatcher?.metrics.delivered ?? 0, + droppedProgressNotifications: + this.progressDispatcher?.metrics.dropped ?? 0, + deliveredConnectionNotifications: + transport?.deliveredConnectionNotifications ?? 0, + droppedConnectionNotifications: + transport?.droppedConnectionNotifications ?? 0, + deliveredErrorNotifications: + (transport?.deliveredErrorNotifications ?? 0) + + (this.errorDispatcher?.metrics.delivered ?? 0), + droppedErrorNotifications: + (transport?.droppedErrorNotifications ?? 0) + + (this.errorDispatcher?.metrics.dropped ?? 0), replayPublishedFrameSequence: transport?.publishedFrameSequence, replayAcknowledgedFrameSequence: transport?.acknowledgedFrameSequence, pendingReplayFrames: transport?.pendingReplayFrames ?? 0, @@ -1017,6 +1074,10 @@ export class QwpIngressSession { this.receiveLoop, ...closeHooks, ]); + await Promise.all([ + this.progressDispatcher?.close(), + this.errorDispatcher?.close(), + ]); if (closeResult.status === "rejected") throw closeResult.reason; } @@ -1039,11 +1100,11 @@ export class QwpIngressSession { } private handleResponse(response: QwpIngressResponse): void { - this.invokeCallback(this.options.onResponse, response); + this.dispatchProgressCallback(this.options.onResponse, response); if (response.status === QWP_STATUS.DURABLE_ACK) { this.totalDurableAcks++; const advanced = this.applyDurableAck(response); - this.invokeCallback(this.options.onDurableAck, response); + this.dispatchProgressCallback(this.options.onDurableAck, response); if (advanced) { this.emitProgress( QWP_INGRESS_PROGRESS_KIND.DURABLE_ACKNOWLEDGED, @@ -1080,7 +1141,15 @@ export class QwpIngressSession { this.totalNacks++; const pending = this.pending.get(response.sequence); - const error = new QwpIngressNackError(response); + const fsn = this.connection.getIngressFrameSequence?.(response.sequence); + const senderError = createQwpSenderError(response, { + appliedPolicy: this.connection.managesIngressSenderErrors + ? undefined + : QWP_SENDER_ERROR_POLICY.TERMINAL, + fromFsn: fsn ?? response.sequence, + toFsn: fsn ?? response.sequence, + }); + const error = new QwpIngressNackError(response, senderError); if ( !this.acknowledgementRejection || response.sequence < this.acknowledgementRejection.sequence @@ -1096,7 +1165,7 @@ export class QwpIngressSession { const dictionaryGap = this.deltaSymbolsPublished && response.status === QWP_STATUS.DICTIONARY_GAP; - this.recordError(error, dictionaryGap, response); + this.recordError(error, dictionaryGap, response, senderError); if (dictionaryGap) { // This wire cannot repair a missing prefix without reconnect catch-up. this.fail(error, true); @@ -1104,16 +1173,12 @@ export class QwpIngressSession { } } - private invokeCallback( + private dispatchProgressCallback( callback: ((event: T) => void) | undefined, event: T, ): void { - if (!callback) return; - try { - callback(event); - } catch { - // Observability callbacks must not break protocol progress. - } + if (!callback || !this.progressDispatcher) return; + this.progressDispatcher.offer(() => safelyInvoke(callback, event)); } private emitProgress( @@ -1121,7 +1186,7 @@ export class QwpIngressSession { sequence?: bigint, response?: QwpIngressResponse, ): void { - this.invokeCallback(this.options.onProgress, { + this.dispatchProgressCallback(this.options.onProgress, { kind, timestampMs: Date.now(), sequence, @@ -1134,6 +1199,7 @@ export class QwpIngressSession { error: unknown, terminal: boolean, response?: QwpIngressResponse, + senderError?: QwpSenderError, ): Error { const observed = error instanceof Error @@ -1141,12 +1207,19 @@ export class QwpIngressSession { : new Error(`QWP ingress failed: ${error}`); this.lastError = observed; this.totalErrors++; - this.invokeCallback(this.options.onError, { + const event: QwpIngressErrorEvent = { error: observed, terminal, timestampMs: Date.now(), response, + senderError, metrics: this.metrics, + }; + this.errorDispatcher?.offer(() => { + safelyInvoke(this.options.onError, event); + if (senderError && !this.connection.managesIngressSenderErrors) { + safelyInvoke(this.options.onSenderError, senderError); + } }); return observed; } @@ -1329,3 +1402,23 @@ export class QwpIngressSession { this.acknowledgedSequenceWaiters.clear(); } } + +function safelyInvoke( + callback: ((event: T) => void) | undefined, + event: T, +): void { + if (!callback) return; + try { + const result = (callback as (value: T) => unknown)(event); + if ( + result !== null && + (typeof result === "object" || typeof result === "function") && + "catch" in result && + typeof result.catch === "function" + ) { + void result.catch(() => undefined); + } + } catch { + // Observability callbacks must not break protocol progress. + } +} diff --git a/src/qwp/internal/notification-dispatcher.ts b/src/qwp/internal/notification-dispatcher.ts new file mode 100644 index 0000000..36f9334 --- /dev/null +++ b/src/qwp/internal/notification-dispatcher.ts @@ -0,0 +1,154 @@ +export interface QwpNotificationDispatcherMetrics { + readonly pending: number; + readonly delivered: number; + readonly dropped: number; + readonly closing: boolean; + readonly closed: boolean; +} + +/** + * Browser-safe, bounded callback mailbox. + * + * One notification is delivered per event-loop turn so protocol work already + * queued by the WebSocket is not performed inside user callback stacks. When + * the inbox fills, the oldest pending notification is discarded and the most + * recent state is retained, matching the Java QWP dispatchers. + */ +export class QwpNotificationDispatcher { + private readonly queue: T[] = []; + private timer?: ReturnType; + private closeTimer?: ReturnType; + private closePromise?: Promise; + private resolveClose?: () => void; + private dispatching = false; + private closing = false; + private closed = false; + private delivered = 0; + private dropped = 0; + + constructor( + private readonly handler: (notification: T) => unknown, + private readonly capacity: number, + ) { + if (!Number.isSafeInteger(capacity) || capacity < 1) { + throw new RangeError( + "QWP notification inbox capacity must be a positive safe integer", + ); + } + } + + get metrics(): QwpNotificationDispatcherMetrics { + return Object.freeze({ + pending: this.queue.length, + delivered: this.delivered, + dropped: this.dropped, + closing: this.closing, + closed: this.closed, + }); + } + + /** Non-blocking enqueue with drop-oldest overflow. */ + offer(notification: T): boolean { + if (this.closing || this.closed) return false; + if (this.queue.length >= this.capacity) { + this.queue.shift(); + this.dropped++; + } + this.queue.push(notification); + this.schedule(); + return true; + } + + /** + * Stops accepting new notifications and best-effort drains the retained + * tail. Any entries still pending at the deadline are counted as dropped. + */ + close(drainDeadlineMs = 100): Promise { + if (this.closePromise) return this.closePromise; + if (!Number.isFinite(drainDeadlineMs) || drainDeadlineMs < 0) { + return Promise.reject( + new RangeError( + "QWP notification drain deadline must be non-negative and finite", + ), + ); + } + this.closing = true; + this.closePromise = new Promise((resolve) => { + this.resolveClose = resolve; + }); + if (this.queue.length === 0 && !this.dispatching) { + this.finishClose(); + return this.closePromise; + } + this.schedule(); + this.closeTimer = setTimeout(() => { + this.closeTimer = undefined; + this.dropped += this.queue.length; + this.queue.length = 0; + if (!this.dispatching) this.finishClose(); + }, drainDeadlineMs); + unrefTimer(this.closeTimer); + return this.closePromise; + } + + private schedule(): void { + if (this.timer || this.dispatching || this.closed) return; + this.timer = setTimeout(() => { + this.timer = undefined; + this.dispatchOne(); + }, 0); + unrefTimer(this.timer); + } + + private dispatchOne(): void { + if (this.closed || this.dispatching) return; + const notification = this.queue.shift(); + if (notification === undefined) { + if (this.closing) this.finishClose(); + return; + } + this.dispatching = true; + this.delivered++; + try { + const result = this.handler(notification); + if (isPromiseLike(result)) void result.catch(() => undefined); + } catch { + // Observability callbacks never participate in protocol progress. + } finally { + this.dispatching = false; + } + if (this.queue.length > 0) { + this.schedule(); + } else if (this.closing) { + this.finishClose(); + } + } + + private finishClose(): void { + if (this.closed) return; + this.closed = true; + if (this.timer) clearTimeout(this.timer); + if (this.closeTimer) clearTimeout(this.closeTimer); + this.timer = undefined; + this.closeTimer = undefined; + this.resolveClose?.(); + this.resolveClose = undefined; + } +} + +function isPromiseLike(value: unknown): value is PromiseLike & { + catch(onRejected: (reason: unknown) => unknown): unknown; +} { + return ( + value !== null && + (typeof value === "object" || typeof value === "function") && + "then" in value && + typeof value.then === "function" && + "catch" in value && + typeof value.catch === "function" + ); +} + +function unrefTimer(timer: ReturnType): void { + (timer as ReturnType & { unref?: () => void }).unref?.(); +} diff --git a/src/qwp/internal/reconnecting-ingress-connection.ts b/src/qwp/internal/reconnecting-ingress-connection.ts index ab3442c..8bc3586 100644 --- a/src/qwp/internal/reconnecting-ingress-connection.ts +++ b/src/qwp/internal/reconnecting-ingress-connection.ts @@ -36,6 +36,12 @@ import { } from "../transport"; import { QwpAsyncQueue } from "./async-queue"; import { jitterReconnectDelayMs } from "./reconnect-backoff"; +import { QwpNotificationDispatcher } from "./notification-dispatcher"; +import { + createQwpProtocolViolationSenderError, + createQwpSenderError, + type QwpSenderError, +} from "../sender-error"; const DEFAULT_CATCH_UP_CAP_GAP_MIN_ESCALATION_WINDOW_MS = 300_000; const MAX_CATCH_UP_CAP_GAP_ATTEMPTS = 16; @@ -175,6 +181,8 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { private readonly poisonMinEscalationWindowMs: number; private readonly catchUpCapGapMinEscalationWindowMs: number; private readonly localMaxBatchSizeBytes?: number; + private readonly connectionDispatcher?: QwpNotificationDispatcher; + private readonly errorDispatcher?: QwpNotificationDispatcher; private readonly resolveClosed: (info: QwpConnectionCloseInfo) => void; private connection?: QwpBinaryConnection; private connectingCandidate?: QwpBinaryConnection; @@ -215,6 +223,7 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { private deltaSymbolDictionaryEnabled: boolean; readonly messages: AsyncIterable = this.messagesQueue; readonly closed: Promise; + readonly managesIngressSenderErrors = true; ping?: () => Promise; private constructor( @@ -228,6 +237,9 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { private readonly backgroundStoreAndForward = false, private readonly orphanStoreAndForward = false, catchUpCapGapMinEscalationWindowMs = DEFAULT_CATCH_UP_CAP_GAP_MIN_ESCALATION_WINDOW_MS, + connectionListenerInboxCapacity = 64, + errorInboxCapacity = 256, + onSenderError?: (error: QwpSenderError) => void, ) { this.store = store; this.symbolDictionary = [...symbolDictionary]; @@ -245,6 +257,18 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { reconnectOptions.poisonMinEscalationWindowMs ?? 5_000; this.catchUpCapGapMinEscalationWindowMs = catchUpCapGapMinEscalationWindowMs; + if (reconnectOptions.onEvent) { + this.connectionDispatcher = new QwpNotificationDispatcher( + reconnectOptions.onEvent, + connectionListenerInboxCapacity, + ); + } + if (onSenderError) { + this.errorDispatcher = new QwpNotificationDispatcher( + onSenderError, + errorInboxCapacity, + ); + } validateReconnectPolicy( this.maxAttempts, this.initialBackoffMs, @@ -294,6 +318,9 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { orphanStoreAndForward = false, catchUpCapGapMinEscalationWindowMs = DEFAULT_CATCH_UP_CAP_GAP_MIN_ESCALATION_WINDOW_MS, initialConnection?: Promise, + connectionListenerInboxCapacity = 64, + errorInboxCapacity = 256, + onSenderError?: (error: QwpSenderError) => void, ): Promise { const store = replayStore ?? new QwpMemoryReplayStore(); let connection: QwpReconnectingIngressConnection | undefined; @@ -327,6 +354,9 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { backgroundStoreAndForward, orphanStoreAndForward, catchUpCapGapMinEscalationWindowMs, + connectionListenerInboxCapacity, + errorInboxCapacity, + onSenderError, ); await connection.retireRecoveredDiscardTailIfReady(); if ( @@ -411,9 +441,22 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { totalFailovers: this.totalFailovers, totalReconnectErrors: this.totalReconnectErrors, totalServerNacks: this.totalServerNacks, + deliveredConnectionNotifications: + this.connectionDispatcher?.metrics.delivered ?? 0, + droppedConnectionNotifications: + this.connectionDispatcher?.metrics.dropped ?? 0, + deliveredErrorNotifications: this.errorDispatcher?.metrics.delivered ?? 0, + droppedErrorNotifications: this.errorDispatcher?.metrics.dropped ?? 0, }); } + getIngressFrameSequence(clientSequence: bigint): bigint | undefined { + for (const frame of this.frames.values()) { + if (frame.clientSequence === clientSequence) return frame.frameSequence; + } + return undefined; + } + send(payload: Uint8Array): Promise { if (this.terminalError) return Promise.reject(this.terminalError); if (this.closing) return Promise.reject(new QwpSendClosedError()); @@ -505,6 +548,10 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { try { await this.closeStore(); } finally { + await Promise.all([ + this.connectionDispatcher?.close(), + this.errorDispatcher?.close(), + ]); this.settleClosed(closeInfo); } } @@ -857,6 +904,14 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { if (this.wireFrames.length === 0) { if (response.status === QWP_STATUS.OK) return undefined; this.totalServerNacks++; + const pending = this.pendingFsnRange(); + this.emitSenderError( + createQwpSenderError(response, { + messageSequence: response.sequence ?? undefined, + fromFsn: pending?.from, + toFsn: pending?.to, + }), + ); if (isRetriableIngressStatus(response.status)) { throw new RetriableIngressNackError( -1n, @@ -910,6 +965,16 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { } this.totalServerNacks++; + const pending = frame.dictionaryCatchup + ? this.pendingFsnRange() + : undefined; + this.emitSenderError( + createQwpSenderError(response, { + messageSequence: response.sequence, + fromFsn: pending?.from ?? frame.frameSequence, + toFsn: pending?.to ?? frame.frameSequence, + }), + ); if (isRetriableIngressStatus(response.status)) { const exempt = @@ -923,6 +988,14 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { ); } if (this.recordPoisonStrike(frame.frameSequence)) { + this.emitSenderError( + createQwpProtocolViolationSenderError( + `frame remained rejected after ${this.poisonStrikes} attempts${ + response.errorMessage ? `: ${response.errorMessage}` : "" + }`, + frame.frameSequence, + ), + ); throw new QwpReplayRejectedError( frame.frameSequence, response.status, @@ -1026,9 +1099,15 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { const closeDetail = closeInfo ? `code=${closeInfo.code}, reason=${closeInfo.reason}` : "transport ended without an orderly close"; - return new QwpProtocolError( - `QWP ingress frame repeatedly caused a non-orderly connection loss [frameSequence=${head.frameSequence}, strikes=${this.poisonStrikes}, ${closeDetail}]`, + const message = `QWP ingress frame repeatedly caused a non-orderly connection loss [frameSequence=${head.frameSequence}, strikes=${this.poisonStrikes}, ${closeDetail}]`; + this.emitSenderError( + createQwpProtocolViolationSenderError( + message, + head.frameSequence, + this.nextFrameSequence - 1n, + ), ); + return new QwpProtocolError(message); } return new RetriableIngressConnectionError( cappedExponentialBackoff( @@ -1264,11 +1343,23 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { } private emitEvent(event: Omit): void { - try { - this.reconnectOptions.onEvent?.({ ...event, timestampMs: Date.now() }); - } catch { - // Connection observers must not interfere with replay progress. - } + this.connectionDispatcher?.offer({ + ...event, + timestampMs: Date.now(), + }); + } + + private emitSenderError(error: QwpSenderError): void { + this.errorDispatcher?.offer(error); + } + + private pendingFsnRange(): { from: bigint; to: bigint } | undefined { + const iterator = this.frames.keys(); + const first = iterator.next(); + if (first.done) return undefined; + let to = first.value; + for (const frameSequence of iterator) to = frameSequence; + return { from: first.value, to }; } private throwIfUnavailable(): void { diff --git a/src/qwp/node.ts b/src/qwp/node.ts index cd54e0b..749202b 100644 --- a/src/qwp/node.ts +++ b/src/qwp/node.ts @@ -36,6 +36,10 @@ import { } from "./transport"; import { QwpEgressSession, QwpEgressSessionOptions } from "./egress-session"; import { QwpIngressSession, QwpIngressSessionOptions } from "./ingress-session"; +import { + createQwpDataLossSenderError, + type QwpSenderError, +} from "./sender-error"; import { QwpSender, QwpSenderOptions } from "./sender"; import { QwpClient, QwpClientPoolOptions } from "./client"; import { @@ -214,6 +218,7 @@ export interface QwpNodeReplayRecoveryEvent { readonly directory: string; readonly quarantineDirectory: string; readonly error: QwpReplayStoreQuarantinedError; + readonly senderError: QwpSenderError; } /** Node store-and-forward controls layered on the crash-safe replay journal. */ @@ -580,7 +585,11 @@ async function connectQwpNodeIngressInternal( storeAndForward.directory, error, ); - emitReplayRecoveryQuarantine(storeAndForward, recoveryError); + emitReplayRecoveryQuarantine( + storeAndForward, + recoveryError, + effectiveSessionOptions.onSenderError, + ); replayStore = new QwpNodeFileReplayStore(storeAndForward); session = await QwpIngressSession.connect(connectionFactory, { ...effectiveSessionOptions, @@ -604,21 +613,36 @@ function isQuarantinableReplayRecoveryError(error: unknown): boolean { function emitReplayRecoveryQuarantine( options: QwpNodeStoreAndForwardOptions, error: QwpReplayStoreQuarantinedError, + onSenderError?: (error: QwpSenderError) => void, ): void { + const senderError = createQwpDataLossSenderError( + error.message, + error.quarantineDirectory, + ); const event: QwpNodeReplayRecoveryEvent = { timestampMs: Date.now(), directory: error.directory, quarantineDirectory: error.quarantineDirectory, error, + senderError, }; - if (!options.onRecoveryQuarantine) { + if (!options.onRecoveryQuarantine && !onSenderError) { log("error", error); return; } + let callbackFailed = false; try { - options.onRecoveryQuarantine(event); + options.onRecoveryQuarantine?.(event); } catch { - // Recovery already succeeded. A notification callback must not brick the + callbackFailed = true; + } + try { + onSenderError?.(senderError); + } catch { + callbackFailed = true; + } + if (callbackFailed) { + // Recovery already succeeded. Notification callbacks must not brick the // fresh producer slot; fall back to the default logger instead. log("error", error); } @@ -920,6 +944,9 @@ function createNodeOrphanDrainer( ? (sessionOptions.durableAckKeepaliveMs ?? 200) : 0, onEvent: storeAndForward.onOrphanDrainEvent, + onSenderError: sessionOptions.onSenderError, + eventInboxCapacity: sessionOptions.connectionListenerInboxCapacity, + errorInboxCapacity: sessionOptions.errorInboxCapacity, createSession: (directory) => connectQwpNodeIngressInternal( { diff --git a/src/qwp/sender-error.ts b/src/qwp/sender-error.ts new file mode 100644 index 0000000..e6c234f --- /dev/null +++ b/src/qwp/sender-error.ts @@ -0,0 +1,142 @@ +import { QWP_STATUS, type QwpIngressResponse } from "./core"; + +export const QWP_SENDER_ERROR_CATEGORY = { + SCHEMA_MISMATCH: "schema-mismatch", + PARSE_ERROR: "parse-error", + INTERNAL_ERROR: "internal-error", + SECURITY_ERROR: "security-error", + WRITE_ERROR: "write-error", + NOT_WRITABLE: "not-writable", + DICTIONARY_GAP: "dictionary-gap", + PROTOCOL_VIOLATION: "protocol-violation", + DATA_LOSS: "data-loss", + UNKNOWN: "unknown", +} as const; + +export type QwpSenderErrorCategory = + (typeof QWP_SENDER_ERROR_CATEGORY)[keyof typeof QWP_SENDER_ERROR_CATEGORY]; + +export const QWP_SENDER_ERROR_POLICY = { + RETRIABLE: "retriable", + RETRIABLE_OTHER: "retriable-other", + TERMINAL: "terminal", + ABANDONED: "abandoned", +} as const; + +export type QwpSenderErrorPolicy = + (typeof QWP_SENDER_ERROR_POLICY)[keyof typeof QWP_SENDER_ERROR_POLICY]; + +/** Immutable Java-parity context for an ingress rejection or data loss. */ +export interface QwpSenderError { + readonly category: QwpSenderErrorCategory; + readonly appliedPolicy: QwpSenderErrorPolicy; + readonly serverStatusByte?: number; + readonly serverMessage?: string; + readonly messageSequence?: bigint; + /** Inclusive stable store-and-forward frame-sequence range. */ + readonly fromFsn?: bigint; + readonly toFsn?: bigint; + readonly tableName?: string; + readonly detectedAtMs: number; + /** Preserved on-disk bytes for a data-loss/quarantine notification. */ + readonly quarantinedPath?: string; +} + +export interface QwpSenderErrorResponseContext { + readonly appliedPolicy?: QwpSenderErrorPolicy; + readonly messageSequence?: bigint; + readonly fromFsn?: bigint; + readonly toFsn?: bigint; + readonly tableName?: string; + readonly detectedAtMs?: number; +} + +export function createQwpSenderError( + response: QwpIngressResponse, + context: QwpSenderErrorResponseContext = {}, +): QwpSenderError { + const category = qwpSenderErrorCategory(response.status); + const sequence = response.sequence ?? undefined; + const fromFsn = context.fromFsn ?? sequence; + return Object.freeze({ + category, + appliedPolicy: + context.appliedPolicy ?? qwpDefaultSenderErrorPolicy(category), + serverStatusByte: response.status, + serverMessage: response.errorMessage, + messageSequence: context.messageSequence ?? sequence, + fromFsn, + toFsn: context.toFsn ?? fromFsn, + tableName: + context.tableName ?? + (response.tables.length === 1 ? response.tables[0].name : undefined), + detectedAtMs: context.detectedAtMs ?? Date.now(), + }); +} + +export function createQwpProtocolViolationSenderError( + message: string, + fromFsn?: bigint, + toFsn = fromFsn, +): QwpSenderError { + return Object.freeze({ + category: QWP_SENDER_ERROR_CATEGORY.PROTOCOL_VIOLATION, + appliedPolicy: QWP_SENDER_ERROR_POLICY.TERMINAL, + serverMessage: message, + fromFsn, + toFsn, + detectedAtMs: Date.now(), + }); +} + +export function createQwpDataLossSenderError( + message: string, + quarantinedPath: string, +): QwpSenderError { + return Object.freeze({ + category: QWP_SENDER_ERROR_CATEGORY.DATA_LOSS, + appliedPolicy: QWP_SENDER_ERROR_POLICY.ABANDONED, + serverMessage: message, + detectedAtMs: Date.now(), + quarantinedPath, + }); +} + +export function qwpSenderErrorCategory(status: number): QwpSenderErrorCategory { + switch (status) { + case QWP_STATUS.SCHEMA_MISMATCH: + return QWP_SENDER_ERROR_CATEGORY.SCHEMA_MISMATCH; + case QWP_STATUS.PARSE_ERROR: + return QWP_SENDER_ERROR_CATEGORY.PARSE_ERROR; + case QWP_STATUS.INTERNAL_ERROR: + return QWP_SENDER_ERROR_CATEGORY.INTERNAL_ERROR; + case QWP_STATUS.SECURITY_ERROR: + return QWP_SENDER_ERROR_CATEGORY.SECURITY_ERROR; + case QWP_STATUS.WRITE_ERROR: + return QWP_SENDER_ERROR_CATEGORY.WRITE_ERROR; + case QWP_STATUS.NOT_WRITABLE: + return QWP_SENDER_ERROR_CATEGORY.NOT_WRITABLE; + case QWP_STATUS.DICTIONARY_GAP: + return QWP_SENDER_ERROR_CATEGORY.DICTIONARY_GAP; + default: + return QWP_SENDER_ERROR_CATEGORY.UNKNOWN; + } +} + +export function qwpDefaultSenderErrorPolicy( + category: QwpSenderErrorCategory, +): QwpSenderErrorPolicy { + switch (category) { + case QWP_SENDER_ERROR_CATEGORY.WRITE_ERROR: + case QWP_SENDER_ERROR_CATEGORY.INTERNAL_ERROR: + case QWP_SENDER_ERROR_CATEGORY.DICTIONARY_GAP: + case QWP_SENDER_ERROR_CATEGORY.UNKNOWN: + return QWP_SENDER_ERROR_POLICY.RETRIABLE; + case QWP_SENDER_ERROR_CATEGORY.NOT_WRITABLE: + return QWP_SENDER_ERROR_POLICY.RETRIABLE_OTHER; + case QWP_SENDER_ERROR_CATEGORY.DATA_LOSS: + return QWP_SENDER_ERROR_POLICY.ABANDONED; + default: + return QWP_SENDER_ERROR_POLICY.TERMINAL; + } +} diff --git a/src/qwp/transport.ts b/src/qwp/transport.ts index 2f1e727..08af768 100644 --- a/src/qwp/transport.ts +++ b/src/qwp/transport.ts @@ -189,6 +189,10 @@ export interface QwpIngressTransportMetrics { readonly totalFailovers: number; readonly totalReconnectErrors: number; readonly totalServerNacks: number; + readonly deliveredConnectionNotifications?: number; + readonly droppedConnectionNotifications?: number; + readonly deliveredErrorNotifications?: number; + readonly droppedErrorNotifications?: number; } export const QWP_RECONNECT_EVENT_KIND = { @@ -438,12 +442,17 @@ export interface QwpBinaryConnection { readonly ingressSymbolDictionary?: readonly string[]; /** @internal False after replay dictionary persistence becomes unavailable. */ readonly ingressDeltaSymbolDictionaryEnabled?: boolean; + /** @internal True when the transport dispatches typed sender errors itself. */ + readonly managesIngressSenderErrors?: boolean; /** Endpoint backing this connection, when supplied by its adapter. */ readonly endpoint?: string | URL; /** @internal Physical delivery metrics exposed by replaying transports. */ getIngressMetrics?(): QwpIngressTransportMetrics; + /** @internal Resolves a session sequence to its stable replay FSN. */ + getIngressFrameSequence?(clientSequence: bigint): bigint | undefined; + /** * @internal Marks this endpoint as temporarily unsuitable and asks a stateful * connection factory to start its next sweep at another configured endpoint. diff --git a/test/qwp/node-transport.test.ts b/test/qwp/node-transport.test.ts index 9dc3e04..df103cc 100644 --- a/test/qwp/node-transport.test.ts +++ b/test/qwp/node-transport.test.ts @@ -15,6 +15,8 @@ import { QWP_EGRESS_CAPABILITY, QWP_EGRESS_MESSAGE, QWP_SERVER_ROLE, + QWP_SENDER_ERROR_CATEGORY, + QWP_SENDER_ERROR_POLICY, QWP_STATUS, QWP_UPGRADE_ERROR_KIND, QWP_UPGRADE_TIMEOUT_PHASE, @@ -23,6 +25,7 @@ import { QwpReplayStoreCorruptionError, QwpReplayStoreQuarantinedError, QwpUpgradeError, + type QwpSenderError, writeQwpVarint, } from "../../src/qwp/node"; @@ -448,16 +451,25 @@ describe("QWP Node transport", () => { await writeFile(join(directory, record), Uint8Array.of(0)); const events: QwpReplayStoreQuarantinedError[] = []; + const senderErrors: QwpSenderError[] = []; const address = server.address() as AddressInfo; try { - const session = await connectQwpNodeIngress({ - url: `ws://127.0.0.1:${address.port}/write/v4`, - storeAndForward: { - directory, - initialConnectMode: "sync", - onRecoveryQuarantine: (event) => events.push(event.error), + const session = await connectQwpNodeIngress( + { + url: `ws://127.0.0.1:${address.port}/write/v4`, + storeAndForward: { + directory, + initialConnectMode: "sync", + onRecoveryQuarantine: (event) => { + events.push(event.error); + expect(event.senderError.quarantinedPath).toBe( + event.quarantineDirectory, + ); + }, + }, }, - }); + { onSenderError: (error) => senderErrors.push(error) }, + ); try { await expect( session.sendFrame(Uint8Array.of(2)), @@ -474,6 +486,12 @@ describe("QWP Node transport", () => { expect(events[0]).toBeInstanceOf(QwpReplayStoreQuarantinedError); expect(events[0].cause).toBeInstanceOf(QwpReplayStoreCorruptionError); expect(events[0].quarantineDirectory).toBe(quarantineDirectory); + expect(senderErrors).toHaveLength(1); + expect(senderErrors[0]).toMatchObject({ + category: QWP_SENDER_ERROR_CATEGORY.DATA_LOSS, + appliedPolicy: QWP_SENDER_ERROR_POLICY.ABANDONED, + quarantinedPath: quarantineDirectory, + }); expect(await readdir(quarantineDirectory)).toEqual( expect.arrayContaining([record, ".qwp.failed"]), ); diff --git a/test/qwp/notification-dispatcher.test.ts b/test/qwp/notification-dispatcher.test.ts new file mode 100644 index 0000000..0df868c --- /dev/null +++ b/test/qwp/notification-dispatcher.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it, vi } from "vitest"; +import { QwpNotificationDispatcher } from "../../src/qwp/internal/notification-dispatcher"; + +describe("QwpNotificationDispatcher", () => { + it("delivers outside the protocol call stack in FIFO order", async () => { + const received: number[] = []; + const dispatcher = new QwpNotificationDispatcher( + (value) => received.push(value), + 4, + ); + + dispatcher.offer(1); + dispatcher.offer(2); + expect(received).toEqual([]); + + await vi.waitFor(() => expect(received).toEqual([1, 2])); + expect(dispatcher.metrics).toMatchObject({ delivered: 2, dropped: 0 }); + await dispatcher.close(); + }); + + it("drops the oldest pending item and retains the newest tail", async () => { + const received: number[] = []; + const dispatcher = new QwpNotificationDispatcher( + (value) => received.push(value), + 2, + ); + + dispatcher.offer(1); + dispatcher.offer(2); + dispatcher.offer(3); + + expect(dispatcher.metrics).toMatchObject({ pending: 2, dropped: 1 }); + await vi.waitFor(() => expect(received).toEqual([2, 3])); + await dispatcher.close(); + }); + + it("contains callback failures and continues dispatching", async () => { + const received: number[] = []; + const dispatcher = new QwpNotificationDispatcher((value) => { + received.push(value); + if (value === 1) throw new Error("observer failed"); + }, 4); + + dispatcher.offer(1); + dispatcher.offer(2); + await vi.waitFor(() => expect(received).toEqual([1, 2])); + expect(dispatcher.metrics.delivered).toBe(2); + await dispatcher.close(); + }); + + it("drains retained notifications and rejects post-close offers", async () => { + const received: number[] = []; + const dispatcher = new QwpNotificationDispatcher( + (value) => received.push(value), + 4, + ); + dispatcher.offer(1); + dispatcher.offer(2); + + await dispatcher.close(); + expect(received).toEqual([1, 2]); + expect(dispatcher.offer(3)).toBe(false); + expect(dispatcher.metrics.closed).toBe(true); + }); +}); diff --git a/test/qwp/orphan-drainer.test.ts b/test/qwp/orphan-drainer.test.ts index bd9b62c..01aa14b 100644 --- a/test/qwp/orphan-drainer.test.ts +++ b/test/qwp/orphan-drainer.test.ts @@ -11,6 +11,11 @@ import { scanQwpNodeOrphanSlots, type QwpNodeOrphanDrainSession, } from "../../src/qwp/node"; +import { + QWP_SENDER_ERROR_CATEGORY, + QWP_SENDER_ERROR_POLICY, + type QwpSenderError, +} from "../../src/qwp"; class FakeDrainSession implements QwpNodeOrphanDrainSession { pendingReplayFrames = 1; @@ -185,16 +190,36 @@ describe("QWP Node orphan drainer", () => { const rootDirectory = await root(); const directory = await recordSlot(rootDirectory, "corrupt"); const terminal = new Error("corrupt replay record"); + const senderErrors: QwpSenderError[] = []; + const events: string[] = []; const drainer = new QwpNodeOrphanDrainer({ rootDirectory, scanIntervalMs: 0, createSession: async () => { throw terminal; }, + onEvent: (event) => { + if (event.senderError) events.push(event.senderError.category); + }, + onSenderError: (error) => senderErrors.push(error), }); drainer.start(); await vi.waitFor(() => expect(drainer.metrics.failed).toBe(1)); expect(await readdir(directory)).toContain(QWP_ORPHAN_FAILED_SENTINEL); + await vi.waitFor(() => expect(senderErrors).toHaveLength(1)); + await vi.waitFor(() => expect(events).toEqual(["data-loss"])); + expect(senderErrors[0]).toMatchObject({ + category: QWP_SENDER_ERROR_CATEGORY.DATA_LOSS, + appliedPolicy: QWP_SENDER_ERROR_POLICY.ABANDONED, + quarantinedPath: directory, + serverMessage: terminal.message, + }); + expect(drainer.metrics).toMatchObject({ + deliveredNotifications: expect.any(Number), + droppedNotifications: 0, + deliveredErrorNotifications: 1, + droppedErrorNotifications: 0, + }); await expect(scanQwpNodeOrphanSlots(rootDirectory)).resolves.toEqual([]); await retryQwpNodeOrphanSlot(directory); diff --git a/test/qwp/public-api-contract.ts b/test/qwp/public-api-contract.ts index 5f1a6c5..9b45480 100644 --- a/test/qwp/public-api-contract.ts +++ b/test/qwp/public-api-contract.ts @@ -44,6 +44,7 @@ import type { QwpEgressViewQuery, QwpIngressSession, QwpIngressSessionOptions, + QwpSenderError, QwpQueryLease, QwpResultBatchView, QwpResultBatchViewHandler, @@ -172,6 +173,16 @@ const egressSessionOptionsContract: QwpEgressSessionOptions = { const fixedConnectionIngressContract: QwpIngressSessionOptions = { reconnect: false, + connectionListenerInboxCapacity: 64, + errorInboxCapacity: 256, + onSenderError: (error: QwpSenderError) => + void [ + error.category, + error.appliedPolicy, + error.fromFsn, + error.toFsn, + error.quarantinedPath, + ], }; const fixedConnectionEgressContract: QwpEgressSessionOptions = { diff --git a/test/qwp/public-api.test.ts b/test/qwp/public-api.test.ts index 0a2a04d..a940b0c 100644 --- a/test/qwp/public-api.test.ts +++ b/test/qwp/public-api.test.ts @@ -10,6 +10,8 @@ const sharedRuntimeContract = [ "QWP_DEFAULT_EGRESS_BUFFER_POOL_SIZE", "QWP_MAX_BATCH_ROWS_UPPER_BOUND", "QWP_RECONNECT_EVENT_KIND", + "QWP_SENDER_ERROR_CATEGORY", + "QWP_SENDER_ERROR_POLICY", "QWP_TARGET", "QWP_UPGRADE_ERROR_KIND", "QWP_VERSION", diff --git a/test/qwp/reconnect.test.ts b/test/qwp/reconnect.test.ts index 103bca0..b4fd27c 100644 --- a/test/qwp/reconnect.test.ts +++ b/test/qwp/reconnect.test.ts @@ -32,10 +32,13 @@ import { QWP_QUERY_FLAG_RESET_DICTIONARY, QWP_SERVER_ROLE, QWP_STATUS, + QWP_SENDER_ERROR_CATEGORY, + QWP_SENDER_ERROR_POLICY, QWP_UPGRADE_ERROR_KIND, QwpBinaryConnection, QwpByteWriter, QwpConnectionCloseInfo, + type QwpSenderError, QwpEgressSession, QwpEgressSessionClosedError, QwpIngressSession, @@ -1202,6 +1205,7 @@ describe("QWP ingress reconnect and replay", () => { const first = new FakeConnection("primary"); const second = new FakeConnection("secondary"); const connections = [first, second]; + const senderErrors: QwpSenderError[] = []; const session = await QwpIngressSession.connect( async () => { const connection = connections.shift(); @@ -1209,6 +1213,7 @@ describe("QWP ingress reconnect and replay", () => { return connection; }, { + onSenderError: (error) => senderErrors.push(error), reconnect: { maxAttempts: 1, initialBackoffMs: 0, @@ -1226,12 +1231,23 @@ describe("QWP ingress reconnect and replay", () => { status: QWP_STATUS.OK, sequence: 0n, }); + await vi.waitFor(() => expect(senderErrors).toHaveLength(1)); + expect(senderErrors[0]).toMatchObject({ + category: QWP_SENDER_ERROR_CATEGORY.WRITE_ERROR, + appliedPolicy: QWP_SENDER_ERROR_POLICY.RETRIABLE, + serverStatusByte: QWP_STATUS.WRITE_ERROR, + messageSequence: 0n, + fromFsn: 0n, + toFsn: 0n, + }); expect(session.metrics).toMatchObject({ totalNacks: 1, totalFramesSent: 2, totalFramesReplayed: 1, totalReconnectAttempts: 1, totalReconnectsSucceeded: 1, + deliveredErrorNotifications: 1, + droppedErrorNotifications: 0, }); await session.close(); }); diff --git a/test/qwp/sender-error.test.ts b/test/qwp/sender-error.test.ts new file mode 100644 index 0000000..9fcfe1c --- /dev/null +++ b/test/qwp/sender-error.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; +import { + createQwpDataLossSenderError, + createQwpSenderError, + QWP_SENDER_ERROR_CATEGORY, + QWP_SENDER_ERROR_POLICY, + QWP_STATUS, +} from "../../src/qwp"; + +describe("QWP typed sender errors", () => { + it.each([ + [ + QWP_STATUS.SCHEMA_MISMATCH, + QWP_SENDER_ERROR_CATEGORY.SCHEMA_MISMATCH, + QWP_SENDER_ERROR_POLICY.TERMINAL, + ], + [ + QWP_STATUS.PARSE_ERROR, + QWP_SENDER_ERROR_CATEGORY.PARSE_ERROR, + QWP_SENDER_ERROR_POLICY.TERMINAL, + ], + [ + QWP_STATUS.INTERNAL_ERROR, + QWP_SENDER_ERROR_CATEGORY.INTERNAL_ERROR, + QWP_SENDER_ERROR_POLICY.RETRIABLE, + ], + [ + QWP_STATUS.SECURITY_ERROR, + QWP_SENDER_ERROR_CATEGORY.SECURITY_ERROR, + QWP_SENDER_ERROR_POLICY.TERMINAL, + ], + [ + QWP_STATUS.WRITE_ERROR, + QWP_SENDER_ERROR_CATEGORY.WRITE_ERROR, + QWP_SENDER_ERROR_POLICY.RETRIABLE, + ], + [ + QWP_STATUS.NOT_WRITABLE, + QWP_SENDER_ERROR_CATEGORY.NOT_WRITABLE, + QWP_SENDER_ERROR_POLICY.RETRIABLE_OTHER, + ], + [ + QWP_STATUS.DICTIONARY_GAP, + QWP_SENDER_ERROR_CATEGORY.DICTIONARY_GAP, + QWP_SENDER_ERROR_POLICY.RETRIABLE, + ], + [ + 0xfe, + QWP_SENDER_ERROR_CATEGORY.UNKNOWN, + QWP_SENDER_ERROR_POLICY.RETRIABLE, + ], + ])("maps status 0x%s to %s / %s", (status, category, appliedPolicy) => { + const error = createQwpSenderError( + { + status, + sequence: 7n, + tables: [{ name: "trades", sequenceTransaction: 11n }], + errorMessage: "rejected", + }, + { fromFsn: 41n, toFsn: 43n }, + ); + + expect(error).toMatchObject({ + category, + appliedPolicy, + serverStatusByte: status, + serverMessage: "rejected", + messageSequence: 7n, + fromFsn: 41n, + toFsn: 43n, + tableName: "trades", + }); + expect(Object.isFrozen(error)).toBe(true); + }); + + it("reports abandoned bytes with their quarantine path", () => { + expect( + createQwpDataLossSenderError("corrupt journal", "/qwp/slot.bad"), + ).toMatchObject({ + category: QWP_SENDER_ERROR_CATEGORY.DATA_LOSS, + appliedPolicy: QWP_SENDER_ERROR_POLICY.ABANDONED, + serverMessage: "corrupt journal", + quarantinedPath: "/qwp/slot.bad", + }); + }); +}); diff --git a/test/qwp/session.test.ts b/test/qwp/session.test.ts index c637a1f..2a8ece3 100644 --- a/test/qwp/session.test.ts +++ b/test/qwp/session.test.ts @@ -23,6 +23,8 @@ import { QWP_FLAG_DELTA_SYMBOL_DICTIONARY, QWP_INGRESS_PROGRESS_KIND, QWP_STATUS, + QWP_SENDER_ERROR_CATEGORY, + QWP_SENDER_ERROR_POLICY, QWP_UPGRADE_ERROR_KIND, QWP_UPGRADE_TIMEOUT_PHASE, QwpBatchTooLargeError, @@ -38,6 +40,7 @@ import { QwpIngressNackError, QwpIngressSession, QwpIngressSessionClosedError, + type QwpSenderError, QwpTableBuffer, QwpSendClosedError, QwpSendTimeoutError, @@ -1839,6 +1842,7 @@ describe("QwpIngressSession", () => { socket.open(); const progress: string[] = []; const errors: { terminal: boolean; message: string }[] = []; + const senderErrors: QwpSenderError[] = []; const session = new QwpIngressSession(await connecting, { durableAckKeepaliveMs: 0, onProgress: (event) => progress.push(event.kind), @@ -1849,6 +1853,7 @@ describe("QwpIngressSession", () => { }); throw new Error("observer failure must be contained"); }, + onSenderError: (error) => senderErrors.push(error), }); socket.onSend = () => { const sequence = BigInt(socket.sent.length - 1); @@ -1874,6 +1879,9 @@ describe("QwpIngressSession", () => { name: "QwpIngressNackError", }); + await vi.waitFor(() => expect(progress).toHaveLength(4)); + await vi.waitFor(() => expect(errors).toHaveLength(1)); + await vi.waitFor(() => expect(senderErrors).toHaveLength(1)); expect(progress).toEqual([ QWP_INGRESS_PROGRESS_KIND.PUBLISHED, QWP_INGRESS_PROGRESS_KIND.ACKNOWLEDGED, @@ -1881,6 +1889,15 @@ describe("QwpIngressSession", () => { QWP_INGRESS_PROGRESS_KIND.PUBLISHED, ]); expect(errors).toEqual([{ terminal: false, message: "write failed" }]); + expect(senderErrors[0]).toMatchObject({ + category: QWP_SENDER_ERROR_CATEGORY.WRITE_ERROR, + appliedPolicy: QWP_SENDER_ERROR_POLICY.TERMINAL, + serverStatusByte: QWP_STATUS.WRITE_ERROR, + serverMessage: "write failed", + messageSequence: 1n, + fromFsn: 1n, + toFsn: 1n, + }); expect(session.metrics).toMatchObject({ publishedSequence: 1n, acknowledgedSequence: 0n, From e975044f50c8eb7ae9230ff375d8a5186843bbf5 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 18 Aug 2026 23:43:55 +0100 Subject: [PATCH 066/265] feat(qwp): add segmented replay and UDP ingress --- QWP.md | 58 +++- README.md | 22 ++ src/options.ts | 70 ++++- src/qwp-node/file-replay-store.ts | 444 ++++++++++++++++++++++++++---- src/qwp-node/orphan-drainer.ts | 6 +- src/qwp-node/udp-sender.ts | 343 +++++++++++++++++++++++ src/qwp/node.ts | 64 +++++ src/sender.ts | 63 ++++- test/options.test.ts | 54 +++- test/qwp/node-transport.test.ts | 10 +- test/qwp/orphan-drainer.test.ts | 5 +- test/qwp/public-api-contract.ts | 19 ++ test/qwp/public-api.test.ts | 5 + test/qwp/reconnect.test.ts | 153 +++++++++- test/qwp/udp-sender.test.ts | 194 +++++++++++++ 15 files changed, 1399 insertions(+), 111 deletions(-) create mode 100644 src/qwp-node/udp-sender.ts create mode 100644 test/qwp/udp-sender.test.ts diff --git a/QWP.md b/QWP.md index 0c6a0a0..583c979 100644 --- a/QWP.md +++ b/QWP.md @@ -14,7 +14,7 @@ policy. Imports from internal source paths are never supported. | Entry point | Runtime | Use it for | | ------------------------------------ | ------------------ | ----------------------------------------------------------------------------------------- | -| `@questdb/nodejs-client` | Node.js | Existing `Sender`, including QWP ingress selected with `ws::` or `wss::` | +| `@questdb/nodejs-client` | Node.js | Existing `Sender`, including QWP ingress selected with `ws::`, `wss::`, or `udp::` | | `@questdb/nodejs-client/qwp/browser` | Browser | Browser-safe QWP ingress, egress, authentication bootstrap, sessions, and codecs | | `@questdb/nodejs-client/qwp/node` | Node.js | QWP ingress and egress with upgrade headers, TLS agents, and persistent store-and-forward | | `@questdb/nodejs-client/qwp` | Browser or Node.js | Shared protocol codecs and low-level session abstractions for advanced integrations | @@ -59,6 +59,38 @@ try { `username` plus `password` selects HTTP Basic authentication for the WebSocket upgrade. `token` selects Bearer authentication. Use `wss::` in production. +### Node.js fire-and-forget UDP + +`udp::` selects Node-only QWP v1 over IPv4 UDP while retaining the fluent row API: + +```typescript +import { Sender } from "@questdb/nodejs-client"; + +const sender = await Sender.fromConfig( + "udp::addr=239.1.2.3:9007;max_datagram_size=1400;multicast_ttl=1", +); +await sender.connect(); +await sender + .table("trades") + .symbol("symbol", "ETH-USD") + .floatColumn("price", 2615.54) + .atNow(); +await sender.close(); +``` + +The default port is 9007, maximum datagram size is 1400 bytes, and multicast TTL +is zero. Each datagram is self-contained, contains exactly one table, and uses an +inline schema plus table-local symbol dictionaries. Batches are split at row +boundaries; `QwpUdpDatagramTooLargeError` is raised before transmission when one +row cannot fit. `connectQwpNodeUdpSender()` and `connectQwpNodeUdp()` expose the +same transport from `qwp/node`. + +UDP provides no authentication, TLS, server or durable ACK, transactions, +reconnection, compression, or store-and-forward. Local socket errors are delivered +to `QwpNodeUdpOptions.onError`; like the Java sender, they are observational and do +not retry rows that may already have been handed to the network. UDP is unavailable +from the browser entry point. + Advanced QWP options are accepted in the second argument: ```typescript @@ -141,9 +173,9 @@ The connect-string key `durability` controls the local persistence barrier: -- `"append"` (the backwards-compatible default) fsyncs every frame and its atomic - directory rename before publication resolves. -- `"periodic"` checkpoints frame files, symbol metadata, and directory changes in the +- `"append"` (the backwards-compatible default) fsyncs every segment append and its + atomic segment creation before publication resolves. +- `"periodic"` checkpoints segment files, symbol metadata, and directory changes in the background. The default interval is 5 seconds, and `close()` performs a final checkpoint. A power failure can lose the most recent checkpoint window. - `"memory"` relies on operating-system writeback. It survives an orderly close and @@ -151,11 +183,12 @@ The connect-string key `backpressurePolicy: "error"` preserves the existing immediate `QwpReplayStoreFullError` behavior. Set it to `"wait"` to pause publication until an -ACK deletes record files. `appendDeadlineMs` bounds each such pause (30 seconds by +ACK advances the checksummed cursor and deletes fully drained segments. +`appendDeadlineMs` bounds each such pause (30 seconds by default) and expiry raises `QwpReplayStoreAppendTimeoutError`. Waiting appenders do not hold the journal mutation queue, so ACK cleanup can continue. Direct users of -`QwpNodeFileReplayStore` can inspect `metrics` for pending checkpoint work, -checkpoints, checkpoint failures, active waiters, stalls, and timeouts. +`QwpNodeFileReplayStore` can inspect `metrics` for pending records and segments, +checkpoint work, checkpoint failures, active waiters, stalls, and timeouts. The persisted symbol dictionary is monotonic for one open journal generation and cannot be reclaimed by an ACK alone. It counts toward the `maxBytes` target, but the @@ -163,7 +196,9 @@ journal preserves up to 32 MiB (or the configured target when smaller) for live records if dictionary growth uses all remaining headroom. Dictionary persistence itself is never rejected by the target, so actual disk usage can exceed it by the current dictionary overshoot. Frame growth beyond the liveness allowance remains -backpressured until ACK trimming frees record files. Once every frame is acknowledged, +backpressured until ACK trimming frees complete segments. A partly acknowledged +segment remains charged to the disk budget until its last live record is acknowledged. +Once every frame is acknowledged, `close()` removes the dictionary under the journal lock; the next clean start uses a fresh symbol-ID space. A partially drained close retains the dictionary required by the surviving frames. @@ -175,6 +210,12 @@ Locks left by a terminated process on the same host are recovered automatically; locks owned by a live local process, another host, or an unidentifiable owner fail closed. +New journals coalesce records into bounded `.qwps` segments, targeting +`maxSegmentBytes` (4 MiB by default) plus at most one record header. This bounds inode +growth during long outages. Existing file-per-frame `.qwp` slots remain readable and +can be drained alongside new segmented appends, so the storage upgrade does not +require an offline migration. + On startup, a dictionary sidecar truncated at a complete-block boundary is rebuilt from the ordered symbol deltas embedded in surviving committed frames and healed before replay. If the frame journal is structurally corrupt, or the surviving deltas @@ -957,6 +998,7 @@ acknowledgement, and persistent replay—but uses runtime-specific connection fa | Explicit drain/commit | `flush()` / `commit()` | | Durable delivery | `requestDurableAck` plus `awaitDurableAck` | | Store-and-forward | Node `storeAndForward`; intentionally unavailable in browsers | +| Fire-and-forget UDP ingress | Node `udp::` or `connectQwpNodeUdpSender()` | | Query parameters | `session.query(sql, { binds })` | | Materialized result batches | `for await (const batch of query)` | | Reusable result views | `queryViews()` with column views or `forEachRow()` row views | diff --git a/README.md b/README.md index edb60af..a0666d2 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,25 @@ await sender.flush(); await sender.close(); ``` +Node.js also supports fire-and-forget QWP-over-UDP through the same API: + +```typescript +const sender = await Sender.fromConfig( + "udp::addr=239.1.2.3:9007;max_datagram_size=1400;multicast_ttl=1", +); +await sender.connect(); +await sender + .table("trades") + .symbol("symbol", "ETH-USD") + .floatColumn("price", 2615.54) + .atNow(); +await sender.close(); +``` + +UDP datagrams are self-contained and split at row boundaries. UDP has no +authentication, acknowledgements, transactions, retry, or store-and-forward and is +not available in browsers. See the QWP guide for the lower-level Node UDP API. + When Node QWP is configured with `qwp.webSocket.storeAndForward`, the sender can start and accept flushes while QuestDB is offline. `flush()` then resolves after local durable journal publication and a background drainer reconnects @@ -98,6 +117,9 @@ budget settings without an explicit mode promotes initial startup to `"sync"`, matching the Java client. The configuration-string equivalent is `initial_connect_retry`, used together with the store-and-forward options in `extraOptions.qwp`. +Persistent frames are coalesced into bounded 4 MiB segments by default, with a +checksummed ACK cursor for partially acknowledged segments. Existing file-per-frame +replay directories remain readable and are migrated naturally as new frames arrive. Set `drainOrphans: true` when sibling journal directories share a dedicated parent: the Node client scans and drains slots left by failed producer processes with bounded concurrency. Pooled QWP clients recover out-of-range `sender-N` slots automatically, diff --git a/src/options.ts b/src/options.ts index 1070a03..8e3a7d5 100644 --- a/src/options.ts +++ b/src/options.ts @@ -9,6 +9,7 @@ import { fetchJson, isBoolean, isInteger } from "./utils"; import { DEFAULT_REQUEST_TIMEOUT } from "./transport/http/base"; import type { QwpNodeIngressOptions, + QwpNodeUdpOptions, QwpInitialConnectMode, QwpIngressSessionOptions, QwpSenderOptions, @@ -17,6 +18,7 @@ import type { const HTTP_PORT = 9000; const TCP_PORT = 9009; const QWP_PORT = 9000; +const QWP_UDP_PORT = 9007; const HTTP = "http"; const HTTPS = "https"; @@ -24,6 +26,7 @@ const TCP = "tcp"; const TCPS = "tcps"; const WS = "ws"; const WSS = "wss"; +const UDP = "udp"; const ON = "on"; const OFF = "off"; @@ -43,6 +46,8 @@ type QwpExtraOptions = { session?: QwpIngressSessionOptions; /** High-level buffering and auto-flush options. */ sender?: QwpSenderOptions; + /** Node-only QWP-over-UDP socket overrides. */ + udp?: Omit; }; type ExtraOptions = { @@ -70,8 +75,8 @@ type DeprecatedOptions = { *
    * Connection and protocol options *
      - *
    • protocol: enum, accepted values: http, https, tcp, tcps, ws, wss - The protocol used to communicate with the server.
      - * WS/WSS select QWP ingress. When https, tcps, or wss is used, the connection is secured with TLS encryption. + *
    • protocol: enum, accepted values: http, https, tcp, tcps, ws, wss, udp - The protocol used to communicate with the server.
      + * WS/WSS select acknowledged QWP ingress. UDP selects Node-only fire-and-forget QWP datagrams. When https, tcps, or wss is used, the connection is secured with TLS encryption. *
    • *
    • protocol_version: enum, accepted values: auto, 1, 2 - The protocol version used for data serialization.
      * Version 1 uses text-based serialization for all data types. Version 2 uses binary encoding for doubles and arrays.
      @@ -212,6 +217,8 @@ class SenderOptions { tls_roots_password?: never; // not supported max_name_len?: number; + max_datagram_size?: number; + multicast_ttl?: number; log?: Logger; agent?: Agent | http.Agent | https.Agent; @@ -405,6 +412,7 @@ function parseConfigurationString( parseTlsOptions(options); parseRequestTimeoutOptions(options); parseMaxNameLength(options); + parseUdpOptions(options); parseStdlibTransport(options); } @@ -477,6 +485,8 @@ const ValidConfigKeys = [ "tls_ca", "tls_roots", "tls_roots_password", + "max_datagram_size", + "multicast_ttl", ]; function validateConfigKey(key: string) { @@ -515,21 +525,24 @@ function parseProtocol(options: SenderOptions, configString: string) { case TCPS: case WS: case WSS: + case UDP: break; default: throw new Error( - `Invalid protocol: '${options.protocol}', accepted protocols: 'http', 'https', 'tcp', 'tcps', 'ws', 'wss'`, + `Invalid protocol: '${options.protocol}', accepted protocols: 'http', 'https', 'tcp', 'tcps', 'ws', 'wss', 'udp'`, ); } return index + 2; } function parseProtocolVersion(options: SenderOptions) { - if (options.protocol === WS || options.protocol === WSS) { + if ( + options.protocol === WS || + options.protocol === WSS || + options.protocol === UDP + ) { if (options.protocol_version !== undefined) { - throw new Error( - "'protocol_version' is not used by the QWP ws/wss protocols", - ); + throw new Error("'protocol_version' is not used by QWP transports"); } return; } @@ -578,9 +591,12 @@ function parseAddress(options: SenderOptions) { case WSS: options.port = QWP_PORT; return; + case UDP: + options.port = QWP_UDP_PORT; + return; default: throw new Error( - `Invalid protocol: '${options.protocol}', accepted protocols: 'http', 'https', 'tcp', 'tcps', 'ws', 'wss'`, + `Invalid protocol: '${options.protocol}', accepted protocols: 'http', 'https', 'tcp', 'tcps', 'ws', 'wss', 'udp'`, ); } } @@ -619,11 +635,10 @@ function parseAutoFlushOptions(options: SenderOptions) { if ( options.auto_flush_bytes !== undefined && options.protocol !== WS && - options.protocol !== WSS + options.protocol !== WSS && + options.protocol !== UDP ) { - throw new Error( - "auto_flush_bytes is only supported for QWP ws/wss transport", - ); + throw new Error("auto_flush_bytes is only supported for QWP transports"); } parseInteger(options, "auto_flush_interval", "auto flush interval", 0); } @@ -690,6 +705,13 @@ function parseCatchUpCapGapOptions(options: SenderOptions) { function parseTlsOptions(options: SenderOptions) { parseBoolean(options, "tls_verify", "TLS verify", UNSAFE_OFF); + if ( + options.protocol === UDP && + (options.tls_verify !== undefined || options.tls_ca !== undefined) + ) { + throw new Error("TLS is not supported for QWP UDP transport"); + } + if (options.tls_roots || options.tls_roots_password) { throw new Error( `'tls_roots' and 'tls_roots_password' options are not supported, please, use the 'tls_ca' option or the NODE_EXTRA_CA_CERTS environment variable instead`, @@ -707,6 +729,29 @@ function parseMaxNameLength(options: SenderOptions) { parseInteger(options, "max_name_len", "max name length", 1); } +function parseUdpOptions(options: SenderOptions) { + parseInteger(options, "max_datagram_size", "maximum datagram size", 1); + parseInteger(options, "multicast_ttl", "multicast TTL", 0); + if (options.multicast_ttl !== undefined && options.multicast_ttl > 255) { + throw new Error(`Invalid multicast TTL option: ${options.multicast_ttl}`); + } + if ( + (options.max_datagram_size !== undefined || + options.multicast_ttl !== undefined) && + options.protocol !== UDP + ) { + throw new Error( + "max_datagram_size and multicast_ttl are only supported for QWP UDP transport", + ); + } + if ( + options.protocol === UDP && + (options.username || options.password || options.token) + ) { + throw new Error("authentication is not supported for QWP UDP transport"); + } +} + function parseStdlibTransport(options: SenderOptions) { parseBoolean(options, "stdlib_http", "stdlib http"); } @@ -765,6 +810,7 @@ export { TCPS, WS, WSS, + UDP, PROTOCOL_VERSION_AUTO, PROTOCOL_VERSION_V1, PROTOCOL_VERSION_V2, diff --git a/src/qwp-node/file-replay-store.ts b/src/qwp-node/file-replay-store.ts index a43b541..ef57514 100644 --- a/src/qwp-node/file-replay-store.ts +++ b/src/qwp-node/file-replay-store.ts @@ -25,7 +25,11 @@ const HEADER_SIZE = 52; const SHA256_SIZE = 32; const MAX_FRAME_SEQUENCE = 0xffffffffffffffffn; const RECORD_SUFFIX = ".qwp"; +const SEGMENT_SUFFIX = ".qwps"; const TEMP_MARKER = ".tmp-"; +const ACK_MAGIC = Buffer.from("QWPA"); +const ACK_FILE = "ack.qwpstate"; +const ACK_STATE_SIZE = 48; const DICTIONARY_MAGIC = Buffer.from("QWPD"); const DICTIONARY_FILE = "symbols.qwpdict"; const DICTIONARY_HEADER_SIZE = 8; @@ -37,9 +41,8 @@ const ABANDONED_LOCK_PREFIX = ".qwp.lock.abandoned-"; const QUARANTINE_SLOT_INFIX = ".unreplayable-"; const QUARANTINE_FAILED_SENTINEL = ".qwp.failed"; const MAX_QUARANTINE_SLOT_ATTEMPTS = 64; -// The file-per-frame journal has no fixed segment working set. Preserve two -// default-sized QWP batches instead, mirroring Java's active+spare liveness -// floor when the current dictionary generation consumes the configured cap. +// Preserve two default-sized QWP batches, mirroring Java's active+spare +// liveness floor when the current dictionary generation consumes the cap. const DEFAULT_LIVE_FRAME_BYTES = 2 * 16 * 1024 * 1024; const DEFAULT_MAX_SEGMENT_BYTES = 4 * 1024 * 1024; const DEFAULT_CHECKPOINT_INTERVAL_MS = 5_000; @@ -67,6 +70,19 @@ export type QwpSfBackpressurePolicy = interface StoredRecord { readonly path: string; readonly size: number; + readonly segment?: StoredSegment; +} + +interface StoredSegment { + readonly path: string; + readonly firstSequence: bigint; + size: number; + liveRecords: number; +} + +interface RecoveredStoredRecord { + readonly record: QwpIngressReplayRecord; + readonly stored: StoredRecord; } interface PendingCapacity { @@ -94,9 +110,9 @@ export interface QwpNodeFileReplayStoreOptions { */ maxBytes?: number; /** - * Maximum QWP frame payload stored in one journal record. The TypeScript - * journal is file-per-frame rather than segmented, but this preserves the - * Java `sf_max_segment_bytes` batching boundary. Defaults to 4 MiB. + * Maximum QWP frame payload and target segment data size. Segments may exceed + * this value by one record header so a maximum-sized frame still fits. + * Defaults to 4 MiB. */ maxSegmentBytes?: number; /** @@ -121,6 +137,7 @@ export interface QwpNodeFileReplayStoreMetrics { readonly durability: QwpSfDurability; readonly backpressurePolicy: QwpSfBackpressurePolicy; readonly pendingRecords: number; + readonly pendingSegments: number; readonly totalBytes: number; readonly dirtyRecords: number; readonly checkpointPending: boolean; @@ -251,6 +268,7 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { private readonly backpressurePolicy: QwpSfBackpressurePolicy; private readonly appendDeadlineMs: number; private readonly records = new Map(); + private readonly segments = new Map(); private readonly symbols: string[] = []; private readonly symbolValues = new Set(); private readonly dirtyRecordPaths = new Set(); @@ -258,7 +276,9 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { private operationTail: Promise = Promise.resolve(); private totalBytes = 0; private dictionaryFileSize = 0; + private acknowledgedThrough = -1n; private dictionaryDirty = false; + private acknowledgementDirty = false; private directoryDirty = false; private capacityGeneration = 0; private checkpointTimer?: ReturnType; @@ -272,6 +292,7 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { private loaded = false; private closing = false; private closed = false; + private activeSegment?: StoredSegment; constructor(options: QwpNodeFileReplayStoreOptions) { const directory = options.directory.trim(); @@ -320,11 +341,13 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { durability: this.durability, backpressurePolicy: this.backpressurePolicy, pendingRecords: this.records.size, + pendingSegments: this.segments.size, totalBytes: this.totalBytes, dirtyRecords: this.dirtyRecordPaths.size, checkpointPending: this.dirtyRecordPaths.size > 0 || this.dictionaryDirty || + this.acknowledgementDirty || this.directoryDirty, waitingAppends: this.capacityWaiters.size, totalCheckpoints: this.totalCheckpoints, @@ -350,6 +373,7 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { await this.acquireDirectoryLock(); const entries = await readdir(this.directory, { withFileTypes: true }); const recordNames: string[] = []; + const segmentNames: string[] = []; let removedTemporaryFile = false; for (const entry of entries) { if (!entry.isFile()) continue; @@ -358,35 +382,94 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { removedTemporaryFile = true; } else if (entry.name.endsWith(RECORD_SUFFIX)) { recordNames.push(entry.name); + } else if (entry.name.endsWith(SEGMENT_SUFFIX)) { + segmentNames.push(entry.name); } } if (removedTemporaryFile) await syncDirectory(this.directory); recordNames.sort(); + segmentNames.sort(); - const recovered: QwpIngressReplayRecord[] = []; - let previous = -1n; - for (const name of recordNames) { + const acknowledgedThrough = await this.loadAcknowledgedThrough(); + const recoveredEntries: RecoveredStoredRecord[] = []; + let removedDrainedSegment = false; + for (let index = 0; index < segmentNames.length; index++) { + const name = segmentNames[index]; const path = join(this.directory, name); let bytes: Buffer; try { bytes = await readFile(path); } catch (error) { throw new QwpReplayStoreError( - `could not read QWP store-and-forward record [file=${name}]`, + `could not read QWP store-and-forward segment [file=${name}]`, error, ); } - const record = decodeRecord(bytes, name); - if (record.frameSequence <= previous) { + const decoded = decodeSegment(bytes, name); + if (decoded.tornTail) { + if ( + decoded.records.length === 0 || + index !== segmentNames.length - 1 + ) { + throw corruptRecord( + name, + "segment has an unrecoverable torn record tail", + ); + } + await truncateSegmentTail(path, decoded.validBytes, this.directory); + bytes = bytes.subarray(0, decoded.validBytes); + } + if (decoded.records.length === 0) { + await ignoreMissing(unlink(path)); + removedDrainedSegment = true; + continue; + } + const firstSequence = decoded.records[0].frameSequence; + const expectedName = segmentFileName(firstSequence); + if (name !== expectedName) { throw new QwpReplayStoreCorruptionError( - `QWP store-and-forward sequence is not strictly increasing [file=${name}]`, + `QWP store-and-forward segment filename does not match its first sequence [file=${name}, expected=${expectedName}]`, ); } - if (previous >= 0n && record.frameSequence !== previous + 1n) { - throw new QwpReplayStoreCorruptionError( - `QWP store-and-forward sequence has a gap [previous=${previous}, received=${record.frameSequence}]`, + const liveRecords = decoded.records.filter( + (record) => record.frameSequence > acknowledgedThrough, + ); + if (liveRecords.length === 0) { + await ignoreMissing(unlink(path)); + removedDrainedSegment = true; + continue; + } + const segment: StoredSegment = { + path, + firstSequence, + size: bytes.byteLength, + liveRecords: liveRecords.length, + }; + this.segments.set(path, segment); + this.totalBytes += bytes.byteLength; + for (const record of liveRecords) { + recoveredEntries.push({ + record, + stored: { path, size: 0, segment }, + }); + } + this.activeSegment = segment; + } + if (removedDrainedSegment) await syncDirectory(this.directory); + + let previous = acknowledgedThrough; + for (const name of recordNames) { + const path = join(this.directory, name); + let bytes: Buffer; + try { + bytes = await readFile(path); + } catch (error) { + throw new QwpReplayStoreError( + `could not read QWP store-and-forward record [file=${name}]`, + error, ); } + const record = decodeRecord(bytes, name); const expectedName = recordFileName(record.frameSequence); if (name !== expectedName) { throw new QwpReplayStoreCorruptionError( @@ -394,16 +477,47 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { ); } this.totalBytes += bytes.byteLength; - if (this.totalBytes > this.maxBytes) { - throw new QwpReplayStoreFullError(this.maxBytes, this.totalBytes); + if (record.frameSequence > acknowledgedThrough) { + recoveredEntries.push({ + record, + stored: { path, size: bytes.byteLength }, + }); + } else { + await ignoreMissing(unlink(path)); + this.totalBytes -= bytes.byteLength; + removedDrainedSegment = true; } - this.records.set(record.frameSequence, { - path, - size: bytes.byteLength, - }); + } + if (removedDrainedSegment) await syncDirectory(this.directory); + recoveredEntries.sort((left, right) => + left.record.frameSequence < right.record.frameSequence + ? -1 + : left.record.frameSequence > right.record.frameSequence + ? 1 + : 0, + ); + const recovered: QwpIngressReplayRecord[] = []; + for (const { record, stored } of recoveredEntries) { + if (record.frameSequence <= previous) { + throw new QwpReplayStoreCorruptionError( + `QWP store-and-forward sequence is not strictly increasing [frameSequence=${record.frameSequence}]`, + ); + } + if (previous >= 0n && record.frameSequence !== previous + 1n) { + throw new QwpReplayStoreCorruptionError( + `QWP store-and-forward sequence has a gap [previous=${previous}, received=${record.frameSequence}]`, + ); + } + this.records.set(record.frameSequence, stored); recovered.push(record); previous = record.frameSequence; } + if (recovered.length === 0 && acknowledgedThrough >= 0n) { + await this.removeAcknowledgedThrough(); + } + if (this.totalBytes > this.maxBytes) { + throw new QwpReplayStoreFullError(this.maxBytes, this.totalBytes); + } await this.loadDictionaryFile(); this.loaded = true; loadSucceeded = true; @@ -438,23 +552,52 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { if (this.closing || this.closed) return Promise.reject(this.closedError()); return this.enqueue(async () => { this.assertReady(); - let changed = false; - for (const [sequence, record] of this.records) { - if (sequence > frameSequence) break; + const acknowledged: Array<[bigint, StoredRecord]> = []; + for (const entry of this.records.entries()) { + if (entry[0] > frameSequence) break; + acknowledged.push(entry); + } + if (acknowledged.length === 0) return; + // Persist the logical cursor before mutating files or in-memory state. A + // crash after this point can leave extra bytes, but never resurrects an + // acknowledged prefix from a partially-live segment. + await this.persistAcknowledgedThrough(frameSequence); + const emptiedSegments = new Set(); + for (const [sequence, record] of acknowledged) { + if (record.segment) { + record.segment.liveRecords--; + if (record.segment.liveRecords === 0) { + emptiedSegments.add(record.segment); + } + } else { + try { + await ignoreMissing(unlink(record.path)); + } catch (error) { + throw new QwpReplayStoreError( + `could not acknowledge QWP store-and-forward record [frameSequence=${sequence}]`, + error, + ); + } + this.dirtyRecordPaths.delete(record.path); + this.totalBytes -= record.size; + } + this.records.delete(sequence); + } + for (const segment of emptiedSegments) { try { - await ignoreMissing(unlink(record.path)); + await ignoreMissing(unlink(segment.path)); } catch (error) { throw new QwpReplayStoreError( - `could not acknowledge QWP store-and-forward record [frameSequence=${sequence}]`, + `could not acknowledge QWP store-and-forward segment [firstSequence=${segment.firstSequence}]`, error, ); } - this.records.delete(sequence); - this.dirtyRecordPaths.delete(record.path); - this.totalBytes -= record.size; - changed = true; + this.segments.delete(segment.path); + this.dirtyRecordPaths.delete(segment.path); + this.totalBytes -= segment.size; + if (this.activeSegment === segment) this.activeSegment = undefined; } - if (!changed) return; + if (this.records.size === 0) await this.removeAcknowledgedThrough(); if (this.durability === QWP_SF_DURABILITY.APPEND) { await syncDirectory(this.directory); } else if (this.durability === QWP_SF_DURABILITY.PERIODIC) { @@ -649,39 +792,74 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { throw new QwpReplayStoreFullError(this.maxBytes, requiredBytes); } - const name = recordFileName(record.frameSequence); - const finalPath = join(this.directory, name); - const temporaryPath = join( - this.directory, - `${name}${TEMP_MARKER}${process.pid}-${randomUUID()}`, - ); - try { - const file = await open(temporaryPath, "wx", 0o600); + const segmentLimit = this.maxSegmentBytes + HEADER_SIZE; + let segment = this.activeSegment; + if (!segment || segment.size + bytes.byteLength > segmentLimit) { + const name = segmentFileName(record.frameSequence); + const finalPath = join(this.directory, name); + const temporaryPath = join( + this.directory, + `${name}${TEMP_MARKER}${process.pid}-${randomUUID()}`, + ); try { - await file.writeFile(bytes); + const file = await open(temporaryPath, "wx", 0o600); + try { + await file.writeFile(bytes); + if (this.durability === QWP_SF_DURABILITY.APPEND) await file.sync(); + } finally { + await file.close(); + } + await rename(temporaryPath, finalPath); if (this.durability === QWP_SF_DURABILITY.APPEND) { - await file.sync(); + await syncDirectory(this.directory); + } else if (this.durability === QWP_SF_DURABILITY.PERIODIC) { + this.dirtyRecordPaths.add(finalPath); + this.directoryDirty = true; } - } finally { - await file.close(); + } catch (error) { + await ignoreMissing(unlink(temporaryPath)); + throw new QwpReplayStoreError( + `could not create QWP store-and-forward segment [frameSequence=${record.frameSequence}]`, + error, + ); } - await rename(temporaryPath, finalPath); - if (this.durability === QWP_SF_DURABILITY.APPEND) { - await syncDirectory(this.directory); - } else if (this.durability === QWP_SF_DURABILITY.PERIODIC) { - this.dirtyRecordPaths.add(finalPath); - this.directoryDirty = true; + segment = { + path: finalPath, + firstSequence: record.frameSequence, + size: 0, + liveRecords: 0, + }; + this.segments.set(finalPath, segment); + this.activeSegment = segment; + } else { + const previousSize = segment.size; + try { + const file = await open(segment.path, "a", 0o600); + try { + await file.writeFile(bytes); + if (this.durability === QWP_SF_DURABILITY.APPEND) await file.sync(); + } catch (error) { + await file.truncate(previousSize).catch(() => undefined); + throw error; + } finally { + await file.close(); + } + if (this.durability === QWP_SF_DURABILITY.PERIODIC) { + this.dirtyRecordPaths.add(segment.path); + } + } catch (error) { + throw new QwpReplayStoreError( + `could not append QWP store-and-forward segment [frameSequence=${record.frameSequence}]`, + error, + ); } - } catch (error) { - await ignoreMissing(unlink(temporaryPath)); - throw new QwpReplayStoreError( - `could not persist QWP store-and-forward record [frameSequence=${record.frameSequence}]`, - error, - ); } + segment.size += bytes.byteLength; + segment.liveRecords++; this.records.set(record.frameSequence, { - path: finalPath, - size: bytes.byteLength, + path: segment.path, + size: 0, + segment, }); this.totalBytes = requiredBytes; } @@ -762,6 +940,7 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { if ( this.dirtyRecordPaths.size === 0 && !this.dictionaryDirty && + !this.acknowledgementDirty && !this.directoryDirty ) { return; @@ -771,9 +950,13 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { if (this.dictionaryDirty) { await syncFile(join(this.directory, DICTIONARY_FILE)); } + if (this.acknowledgementDirty) { + await syncFile(join(this.directory, ACK_FILE)); + } if (this.directoryDirty) await syncDirectory(this.directory); this.dirtyRecordPaths.clear(); this.dictionaryDirty = false; + this.acknowledgementDirty = false; this.directoryDirty = false; this.checkpointFailure = undefined; this.totalCheckpoints++; @@ -786,6 +969,66 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { } } + private async loadAcknowledgedThrough(): Promise { + const path = join(this.directory, ACK_FILE); + let bytes: Buffer; + try { + bytes = await readFile(path); + } catch (error) { + if (nodeErrorCode(error) === "ENOENT") return -1n; + throw new QwpReplayStoreError( + "could not read QWP store-and-forward ACK watermark", + error, + ); + } + this.acknowledgedThrough = decodeAcknowledgedThrough(bytes); + return this.acknowledgedThrough; + } + + private async persistAcknowledgedThrough( + frameSequence: bigint, + ): Promise { + if (frameSequence <= this.acknowledgedThrough) return; + const name = `${ACK_FILE}${TEMP_MARKER}${process.pid}-${randomUUID()}`; + const temporaryPath = join(this.directory, name); + const finalPath = join(this.directory, ACK_FILE); + try { + const file = await open(temporaryPath, "wx", 0o600); + try { + await file.writeFile(encodeAcknowledgedThrough(frameSequence)); + if (this.durability === QWP_SF_DURABILITY.APPEND) await file.sync(); + } finally { + await file.close(); + } + await rename(temporaryPath, finalPath); + if (this.durability === QWP_SF_DURABILITY.APPEND) { + await syncDirectory(this.directory); + } else if (this.durability === QWP_SF_DURABILITY.PERIODIC) { + this.acknowledgementDirty = true; + this.directoryDirty = true; + } + this.acknowledgedThrough = frameSequence; + } catch (error) { + await ignoreMissing(unlink(temporaryPath)); + throw new QwpReplayStoreError( + `could not persist QWP store-and-forward ACK watermark [frameSequence=${frameSequence}]`, + error, + ); + } + } + + private async removeAcknowledgedThrough(): Promise { + if (this.acknowledgedThrough < 0n) return; + await ignoreMissing(unlink(join(this.directory, ACK_FILE))); + this.acknowledgedThrough = -1n; + this.acknowledgementDirty = false; + if (this.durability === QWP_SF_DURABILITY.APPEND) { + await syncDirectory(this.directory); + } else if (this.durability === QWP_SF_DURABILITY.PERIODIC) { + this.directoryDirty = true; + } + } + /** * Retires the dictionary generation only after every operation has settled * and no replay frame remains. Doing this in acknowledgeThrough() would be @@ -1132,6 +1375,72 @@ function decodeRecord(bytes: Buffer, name: string): QwpIngressReplayRecord { return { frameSequence, payload: new Uint8Array(payload) }; } +interface DecodedSegment { + readonly records: QwpIngressReplayRecord[]; + readonly validBytes: number; + readonly tornTail: boolean; +} + +function decodeSegment(bytes: Buffer, name: string): DecodedSegment { + const records: QwpIngressReplayRecord[] = []; + let offset = 0; + while (offset < bytes.byteLength) { + const remaining = bytes.byteLength - offset; + if (remaining < HEADER_SIZE) { + return { records, validBytes: offset, tornTail: true }; + } + if (!bytes.subarray(offset, offset + MAGIC.byteLength).equals(MAGIC)) { + throw corruptRecord(name, `invalid record magic at offset ${offset}`); + } + const payloadLength = bytes.readUInt32LE(offset + 16); + const recordEnd = offset + HEADER_SIZE + payloadLength; + if (recordEnd > bytes.byteLength) { + return { records, validBytes: offset, tornTail: true }; + } + records.push( + decodeRecord(bytes.subarray(offset, recordEnd), `${name}@${offset}`), + ); + offset = recordEnd; + } + return { records, validBytes: offset, tornTail: false }; +} + +function encodeAcknowledgedThrough(frameSequence: bigint): Buffer { + validateFrameSequence(frameSequence); + const bytes = Buffer.alloc(ACK_STATE_SIZE); + ACK_MAGIC.copy(bytes, 0); + bytes.writeUInt8(FORMAT_VERSION, 4); + bytes.writeBigUInt64LE(frameSequence, 8); + createHash("sha256").update(bytes.subarray(0, 16)).digest().copy(bytes, 16); + return bytes; +} + +function decodeAcknowledgedThrough(bytes: Buffer): bigint { + if (bytes.byteLength !== ACK_STATE_SIZE) { + throw new QwpReplayStoreCorruptionError( + "corrupt QWP store-and-forward ACK watermark: invalid length", + ); + } + if (!bytes.subarray(0, ACK_MAGIC.byteLength).equals(ACK_MAGIC)) { + throw new QwpReplayStoreCorruptionError( + "corrupt QWP store-and-forward ACK watermark: invalid magic", + ); + } + if (bytes.readUInt8(4) !== FORMAT_VERSION) { + throw new QwpReplayStoreCorruptionError( + `corrupt QWP store-and-forward ACK watermark: unsupported version ${bytes.readUInt8(4)}`, + ); + } + const expected = bytes.subarray(16); + const actual = createHash("sha256").update(bytes.subarray(0, 16)).digest(); + if (!actual.equals(expected)) { + throw new QwpReplayStoreCorruptionError( + "corrupt QWP store-and-forward ACK watermark: checksum mismatch", + ); + } + return bytes.readBigUInt64LE(8); +} + function encodeDictionaryHeader(): Buffer { const header = Buffer.alloc(DICTIONARY_HEADER_SIZE); DICTIONARY_MAGIC.copy(header, 0); @@ -1215,6 +1524,21 @@ async function truncateDictionaryTail( await syncDirectory(directory); } +async function truncateSegmentTail( + path: string, + size: number, + directory: string, +): Promise { + const file = await open(path, "r+"); + try { + await file.truncate(size); + await file.sync(); + } finally { + await file.close(); + } + await syncDirectory(directory); +} + function corruptRecord( name: string, reason: string, @@ -1316,6 +1640,10 @@ function recordFileName(frameSequence: bigint): string { return `${frameSequence.toString().padStart(20, "0")}${RECORD_SUFFIX}`; } +function segmentFileName(frameSequence: bigint): string { + return `${frameSequence.toString().padStart(20, "0")}${SEGMENT_SUFFIX}`; +} + async function syncDirectory(directory: string): Promise { let handle; try { diff --git a/src/qwp-node/orphan-drainer.ts b/src/qwp-node/orphan-drainer.ts index 0913cc4..302e6fc 100644 --- a/src/qwp-node/orphan-drainer.ts +++ b/src/qwp-node/orphan-drainer.ts @@ -15,6 +15,7 @@ import { } from "../qwp/sender-error"; const RECORD_SUFFIX = ".qwp"; +const SEGMENT_SUFFIX = ".qwps"; const DEFAULT_MAX_CONCURRENT = 4; const DEFAULT_SCAN_INTERVAL_MS = 30_000; const DEFAULT_PROGRESS_POLL_MS = 50; @@ -145,7 +146,10 @@ export async function scanQwpNodeOrphanSlots( } if ( children.some( - (child) => child.isFile() && child.name.endsWith(RECORD_SUFFIX), + (child) => + child.isFile() && + (child.name.endsWith(RECORD_SUFFIX) || + child.name.endsWith(SEGMENT_SUFFIX)), ) ) { candidates.push(directory); diff --git a/src/qwp-node/udp-sender.ts b/src/qwp-node/udp-sender.ts new file mode 100644 index 0000000..2c93b8f --- /dev/null +++ b/src/qwp-node/udp-sender.ts @@ -0,0 +1,343 @@ +import { createSocket, type Socket } from "node:dgram"; +import { + encodeQwpIngressFrame, + type QwpIngressEncodeOptions, + type QwpIngressResponse, + type QwpTableBuffer, +} from "../qwp/core"; +import type { QwpSenderSession } from "../qwp/sender"; + +const DEFAULT_QWP_UDP_PORT = 9007; +const DEFAULT_MAX_DATAGRAM_SIZE = 1_400; + +/** Minimal injectable UDP socket surface used by the Node QWP sender. */ +export interface QwpNodeUdpSocketLike { + bind(port: number, address: string, callback: () => void): void; + send( + message: Uint8Array, + port: number, + address: string, + callback: (error: Error | null, bytes: number) => void, + ): void; + close(callback: () => void): void; + on(event: "error", listener: (error: Error) => void): unknown; + setMulticastTTL(ttl: number): number; + setMulticastInterface(multicastInterface: string): void; +} + +export interface QwpNodeUdpOptions { + /** Destination hostname or IPv4 address. */ + host: string; + /** Destination port. Defaults to the Java QWP UDP port, 9007. */ + port?: number; + /** Maximum encoded datagram size. Defaults to 1400 bytes. */ + maxDatagramSize?: number; + /** IPv4 multicast TTL from 0 through 255. Defaults to 0. */ + multicastTtl?: number; + /** Optional local IPv4 interface used for multicast traffic. */ + multicastInterface?: string; + /** Receives isolated local socket errors; UDP has no server acknowledgement. */ + onError?: (error: Error) => void; + /** @internal Test hook. */ + socketFactory?: () => QwpNodeUdpSocketLike; +} + +export interface QwpNodeUdpMetrics { + readonly publishedDatagramSequence: bigint; + readonly totalDatagramsSent: number; + readonly totalBytesSent: number; + readonly totalSendErrors: number; + readonly closed: boolean; +} + +/** A single encoded row cannot fit into the configured UDP datagram. */ +export class QwpUdpDatagramTooLargeError extends Error { + constructor( + readonly maxDatagramSize: number, + readonly datagramSize: number, + readonly tableName: string, + readonly row: number, + ) { + super( + `single QWP row exceeds maximum UDP datagram size [maxDatagramSize=${maxDatagramSize}, datagramSize=${datagramSize}, table=${tableName}, row=${row}]`, + ); + this.name = "QwpUdpDatagramTooLargeError"; + } +} + +/** + * Node-only, fire-and-forget QWP v1 ingress session over IPv4 UDP. + * + * Each datagram is self-contained: it carries one table, an inline schema and + * local symbol dictionaries. There are no ACKs, retries, transactions, + * authentication, compression, or store-and-forward semantics. + */ +export class QwpNodeUdpSession implements QwpSenderSession { + readonly maxBatchSizeBytes: number; + private readonly host: string; + private readonly port: number; + private readonly multicastTtl: number; + private readonly multicastInterface?: string; + private readonly onError?: (error: Error) => void; + private readonly socket: QwpNodeUdpSocketLike; + private bindReject?: (error: Error) => void; + private closePromise?: Promise; + private bound = false; + private closing = false; + private closed = false; + private sequence = -1n; + private totalDatagramsSent = 0; + private totalBytesSent = 0; + private totalSendErrors = 0; + + private constructor(options: QwpNodeUdpOptions) { + this.host = validateHost(options.host); + this.port = validatePort(options.port ?? DEFAULT_QWP_UDP_PORT); + this.maxBatchSizeBytes = validatePositiveInteger( + options.maxDatagramSize ?? DEFAULT_MAX_DATAGRAM_SIZE, + "maxDatagramSize", + ); + this.multicastTtl = validateTtl(options.multicastTtl ?? 0); + this.multicastInterface = options.multicastInterface?.trim() || undefined; + this.onError = options.onError; + this.socket = + options.socketFactory?.() ?? socketAdapter(createSocket("udp4")); + this.socket.on("error", (error) => this.handleSocketError(error)); + } + + static async connect(options: QwpNodeUdpOptions): Promise { + const session = new QwpNodeUdpSession(options); + try { + await session.bind(); + return session; + } catch (error) { + await session.close().catch(() => undefined); + throw error; + } + } + + get publishedFrameSequence(): bigint { + return this.sequence; + } + + get acknowledgedFrameSequence(): bigint { + // UDP has no remote ACK. Treat successful local handoff as the only + // available watermark so the shared high-level sender can close cleanly. + return this.sequence; + } + + get udpMetrics(): QwpNodeUdpMetrics { + return Object.freeze({ + publishedDatagramSequence: this.sequence, + totalDatagramsSent: this.totalDatagramsSent, + totalBytesSent: this.totalBytesSent, + totalSendErrors: this.totalSendErrors, + closed: this.closed, + }); + } + + async sendTables( + tables: readonly QwpTableBuffer[], + options: QwpIngressEncodeOptions = {}, + ): Promise { + this.assertOpen(); + if (options.deferCommit) { + throw new Error( + "QWP UDP does not support transactions or deferred commit", + ); + } + const datagrams = encodeUdpDatagrams(tables, this.maxBatchSizeBytes); + for (const datagram of datagrams) await this.send(datagram); + return { status: 0, sequence: this.sequence, tables: [] }; + } + + async publishTables( + tables: readonly QwpTableBuffer[], + options: QwpIngressEncodeOptions = {}, + ): Promise { + await this.sendTables(tables, options); + } + + waitForAcknowledged(targetSequence: bigint): Promise { + this.assertOpen(); + if (targetSequence > this.sequence) { + return Promise.reject( + new RangeError( + `QWP UDP datagram sequence has not been published [target=${targetSequence}, published=${this.sequence}]`, + ), + ); + } + return Promise.resolve(); + } + + waitForDurable(): Promise { + return Promise.reject( + new Error("QWP UDP does not provide server or durable acknowledgements"), + ); + } + + close(): Promise { + if (this.closePromise) return this.closePromise; + this.closing = true; + this.closePromise = new Promise((resolve) => { + if (!this.bound) { + this.closed = true; + resolve(); + return; + } + this.socket.close(() => { + this.bound = false; + this.closed = true; + resolve(); + }); + }); + return this.closePromise; + } + + private bind(): Promise { + return new Promise((resolve, reject) => { + this.bindReject = reject; + this.socket.bind(0, "0.0.0.0", () => { + this.bindReject = undefined; + this.bound = true; + try { + this.socket.setMulticastTTL(this.multicastTtl); + if (this.multicastInterface) { + this.socket.setMulticastInterface(this.multicastInterface); + } + resolve(); + } catch (error) { + reject(asError(error)); + } + }); + }); + } + + private send(datagram: Uint8Array): Promise { + this.assertOpen(); + return new Promise((resolve) => { + const complete = (error: Error | null, bytes = 0): void => { + this.sequence++; + if (error) { + this.reportError(error); + } else { + this.totalDatagramsSent++; + this.totalBytesSent += bytes; + } + // Match Java's fire-and-forget policy: local UDP send failures are + // observable, but they do not make flush retry already-sent rows. + resolve(); + }; + try { + this.socket.send(datagram, this.port, this.host, complete); + } catch (error) { + complete(asError(error)); + } + }); + } + + private handleSocketError(error: Error): void { + const reject = this.bindReject; + if (reject) { + this.bindReject = undefined; + reject(error); + return; + } + this.reportError(error); + } + + private reportError(error: Error): void { + this.totalSendErrors++; + try { + this.onError?.(error); + } catch { + // UDP error observers cannot participate in sender progress. + } + } + + private assertOpen(): void { + if (this.closing || this.closed) + throw new Error("QWP UDP sender is closed"); + } +} + +function encodeUdpDatagrams( + tables: readonly QwpTableBuffer[], + maxDatagramSize: number, +): Uint8Array[] { + const result: Uint8Array[] = []; + for (const table of tables) { + let start = 0; + while (start < table.rowCount) { + let low = start + 1; + let high = table.rowCount; + let acceptedEnd = start; + let accepted: Uint8Array | undefined; + let smallestRejectedSize = 0; + while (low <= high) { + const end = Math.floor((low + high) / 2); + const encoded = encodeQwpIngressFrame([table.sliceRows(start, end)], { + gorilla: false, + }); + if (encoded.byteLength <= maxDatagramSize) { + acceptedEnd = end; + accepted = encoded; + low = end + 1; + } else { + smallestRejectedSize = encoded.byteLength; + high = end - 1; + } + } + if (!accepted) { + const oneRow = encodeQwpIngressFrame( + [table.sliceRows(start, start + 1)], + { gorilla: false }, + ); + throw new QwpUdpDatagramTooLargeError( + maxDatagramSize, + smallestRejectedSize || oneRow.byteLength, + table.name, + start, + ); + } + result.push(accepted); + start = acceptedEnd; + } + } + return result; +} + +function socketAdapter(socket: Socket): QwpNodeUdpSocketLike { + return socket as unknown as QwpNodeUdpSocketLike; +} + +function validateHost(host: string): string { + const value = host?.trim(); + if (!value) throw new RangeError("QWP UDP host must not be empty"); + return value; +} + +function validatePort(port: number): number { + const value = validatePositiveInteger(port, "port"); + if (value > 65_535) + throw new RangeError("QWP UDP port must not exceed 65535"); + return value; +} + +function validateTtl(ttl: number): number { + if (!Number.isSafeInteger(ttl) || ttl < 0 || ttl > 255) { + throw new RangeError("QWP UDP multicastTtl must be between 0 and 255"); + } + return ttl; +} + +function validatePositiveInteger(value: number, name: string): number { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new RangeError(`QWP UDP ${name} must be a positive safe integer`); + } + return value; +} + +function asError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} diff --git a/src/qwp/node.ts b/src/qwp/node.ts index 749202b..385e694 100644 --- a/src/qwp/node.ts +++ b/src/qwp/node.ts @@ -53,6 +53,10 @@ import { QwpNodeOrphanDrainer, type QwpNodeOrphanDrainEvent, } from "../qwp-node/orphan-drainer"; +import { + QwpNodeUdpSession, + type QwpNodeUdpOptions, +} from "../qwp-node/udp-sender"; export { QWP_SF_BACKPRESSURE_POLICY, @@ -80,6 +84,15 @@ export { retryQwpNodeOrphanSlot, scanQwpNodeOrphanSlots, } from "../qwp-node/orphan-drainer"; +export { + QwpNodeUdpSession, + QwpUdpDatagramTooLargeError, +} from "../qwp-node/udp-sender"; +export type { + QwpNodeUdpMetrics, + QwpNodeUdpOptions, + QwpNodeUdpSocketLike, +} from "../qwp-node/udp-sender"; export type { QwpNodeOrphanDrainEvent, QwpNodeOrphanDrainEventKind, @@ -692,6 +705,57 @@ export async function connectQwpNodeSender( return sender; } +/** Opens a Node IPv4 UDP socket for fire-and-forget QWP ingress. */ +export function connectQwpNodeUdp( + options: QwpNodeUdpOptions, +): Promise { + return QwpNodeUdpSession.connect(options); +} + +/** + * Creates a fluent Node QWP-over-UDP sender without opening its socket yet. + * UDP has no authentication, server ACK, durable ACK, transaction, retry, or + * store-and-forward semantics. + */ +export function createQwpNodeUdpSender( + options: QwpNodeUdpOptions, + senderOptions: QwpSenderOptions = {}, +): QwpSender { + validateUdpSenderOptions(senderOptions); + return new QwpSender(() => connectQwpNodeUdp(options), { + ...senderOptions, + autoFlushBytes: + senderOptions.autoFlushBytes ?? options.maxDatagramSize ?? 1_400, + transactional: false, + awaitServerAck: true, + awaitDurableAck: false, + encode: { + ...senderOptions.encode, + gorilla: false, + symbolDictionary: "full", + }, + }); +} + +/** Opens a Node UDP socket and returns a fluent fire-and-forget QWP sender. */ +export async function connectQwpNodeUdpSender( + options: QwpNodeUdpOptions, + senderOptions: QwpSenderOptions = {}, +): Promise { + const sender = createQwpNodeUdpSender(options, senderOptions); + await sender.connect(); + return sender; +} + +function validateUdpSenderOptions(options: QwpSenderOptions): void { + if (options.transactional) { + throw new RangeError("QWP UDP does not support transactions"); + } + if (options.awaitDurableAck) { + throw new RangeError("QWP UDP does not support durable acknowledgements"); + } +} + /** Opens a Node WebSocket and waits for the egress SERVER_INFO handshake. */ export async function connectQwpNodeEgress( options: QwpNodeEgressOptions, diff --git a/src/sender.ts b/src/sender.ts index ee65f51..fc4d8b6 100644 --- a/src/sender.ts +++ b/src/sender.ts @@ -3,12 +3,16 @@ import { readFileSync } from "node:fs"; import * as http from "node:http"; import * as https from "node:https"; import { log, Logger } from "./logging"; -import { SenderOptions, ExtraOptions, WS, WSS } from "./options"; +import { SenderOptions, ExtraOptions, UDP, WS, WSS } from "./options"; import { SenderTransport, createTransport } from "./transport"; import { SenderBuffer, createBuffer } from "./buffer"; import { isBoolean, isInteger, TimestampUnit } from "./utils"; import { QWP_INGRESS_PATH } from "./qwp/core"; -import { createQwpNodeSender, QwpSender } from "./qwp/node"; +import { + createQwpNodeSender, + createQwpNodeUdpSender, + QwpSender, +} from "./qwp/node"; const DEFAULT_AUTO_FLUSH_INTERVAL = 1000; // 1 sec @@ -25,6 +29,7 @@ const DEFAULT_AUTO_FLUSH_INTERVAL = 1000; // 1 sec *
    • TCP: Direct TCP connection, provides persistent connections. Uses JWK token-based authentication.
    • *
    • TCPS: Secure TCP transport with TLS encryption.
    • *
    • WS/WSS: QWP ingress over WebSocket, including browser-compatible wire encoding and QWP ACKs.
    • + *
    • UDP: Node-only fire-and-forget QWP ingress in self-contained datagrams.
    • *
    *

    *

    @@ -68,6 +73,7 @@ const DEFAULT_AUTO_FLUSH_INTERVAL = 1000; // 1 sec *

  • TCP: Sender.fromConfig("tcp::addr=localhost:9009")
  • *
  • TCPS with authentication: Sender.fromConfig("tcps::addr=localhost:9009;username=user;token=private_key")
  • *
  • QWP: Sender.fromConfig("ws::addr=localhost:9000")
  • + *
  • QWP UDP: Sender.fromConfig("udp::addr=localhost:9007;max_datagram_size=1400")
  • * *

    *

    @@ -113,8 +119,15 @@ class Sender { */ constructor(options: SenderOptions) { this.log = options && typeof options.log === "function" ? options.log : log; - if (options?.protocol === WS || options?.protocol === WSS) { - this.qwpSender = createConfiguredQwpSender(options, this.log); + if ( + options?.protocol === WS || + options?.protocol === WSS || + options?.protocol === UDP + ) { + this.qwpSender = + options.protocol === UDP + ? createConfiguredQwpUdpSender(options, this.log) + : createConfiguredQwpSender(options, this.log); this.autoFlush = false; this.autoFlushRows = 0; this.autoFlushInterval = 0; @@ -606,6 +619,48 @@ function createConfiguredQwpSender( ); } +function createConfiguredQwpUdpSender( + options: SenderOptions, + logger: Logger, +): QwpSender { + if (!options.host || !options.port) { + throw new Error("The 'host' and 'port' options are mandatory for QWP UDP"); + } + const configuredUdp = options.qwp?.udp ?? {}; + const configuredSender = options.qwp?.sender ?? {}; + const maxDatagramSize = + options.max_datagram_size ?? configuredUdp.maxDatagramSize ?? 1_400; + return createQwpNodeUdpSender( + { + ...configuredUdp, + host: options.host, + port: options.port, + maxDatagramSize, + multicastTtl: options.multicast_ttl ?? configuredUdp.multicastTtl, + onError: configuredUdp.onError ?? ((error) => logger("warn", error)), + }, + { + ...configuredSender, + autoFlush: isBoolean(options.auto_flush) + ? options.auto_flush + : configuredSender.autoFlush, + autoFlushRows: isInteger(options.auto_flush_rows, 0) + ? options.auto_flush_rows + : configuredSender.autoFlushRows, + autoFlushBytes: isInteger(options.auto_flush_bytes, 0) + ? options.auto_flush_bytes + : (configuredSender.autoFlushBytes ?? maxDatagramSize), + autoFlushIntervalMs: isInteger(options.auto_flush_interval, 0) + ? options.auto_flush_interval + : configuredSender.autoFlushIntervalMs, + maxNameLength: isInteger(options.max_name_len, 1) + ? options.max_name_len + : configuredSender.maxNameLength, + log: logger, + }, + ); +} + function qwpAuthorization(options: SenderOptions): string | undefined { if (options.token) return `Bearer ${options.token}`; if (options.username !== undefined || options.password !== undefined) { diff --git a/test/options.test.ts b/test/options.test.ts index 2332f38..4ad1923 100644 --- a/test/options.test.ts +++ b/test/options.test.ts @@ -70,36 +70,39 @@ describe("Configuration string parser suite", function () { options = await SenderOptions.fromConfig("wss::addr=host"); expect(options.protocol).toBe("wss"); + options = await SenderOptions.fromConfig("udp::addr=host"); + expect(options.protocol).toBe("udp"); + await expect( async () => await SenderOptions.fromConfig("HTTP::"), ).rejects.toThrow( - "Invalid protocol: 'HTTP', accepted protocols: 'http', 'https', 'tcp', 'tcps', 'ws', 'wss'", + "Invalid protocol: 'HTTP', accepted protocols: 'http', 'https', 'tcp', 'tcps', 'ws', 'wss', 'udp'", ); await expect( async () => await SenderOptions.fromConfig("Http::"), ).rejects.toThrow( - "Invalid protocol: 'Http', accepted protocols: 'http', 'https', 'tcp', 'tcps', 'ws', 'wss'", + "Invalid protocol: 'Http', accepted protocols: 'http', 'https', 'tcp', 'tcps', 'ws', 'wss', 'udp'", ); await expect( async () => await SenderOptions.fromConfig("HtTps::"), ).rejects.toThrow( - "Invalid protocol: 'HtTps', accepted protocols: 'http', 'https', 'tcp', 'tcps', 'ws', 'wss'", + "Invalid protocol: 'HtTps', accepted protocols: 'http', 'https', 'tcp', 'tcps', 'ws', 'wss', 'udp'", ); await expect( async () => await SenderOptions.fromConfig("TCP::"), ).rejects.toThrow( - "Invalid protocol: 'TCP', accepted protocols: 'http', 'https', 'tcp', 'tcps', 'ws', 'wss'", + "Invalid protocol: 'TCP', accepted protocols: 'http', 'https', 'tcp', 'tcps', 'ws', 'wss', 'udp'", ); await expect( async () => await SenderOptions.fromConfig("TcP::"), ).rejects.toThrow( - "Invalid protocol: 'TcP', accepted protocols: 'http', 'https', 'tcp', 'tcps', 'ws', 'wss'", + "Invalid protocol: 'TcP', accepted protocols: 'http', 'https', 'tcp', 'tcps', 'ws', 'wss', 'udp'", ); await expect( async () => await SenderOptions.fromConfig("Tcps::"), ).rejects.toThrow( - "Invalid protocol: 'Tcps', accepted protocols: 'http', 'https', 'tcp', 'tcps', 'ws', 'wss'", + "Invalid protocol: 'Tcps', accepted protocols: 'http', 'https', 'tcp', 'tcps', 'ws', 'wss', 'udp'", ); }); @@ -241,14 +244,17 @@ describe("Configuration string parser suite", function () { expect(options.host).toBe("hostname"); expect(options.port).toBe(9000); expect(options.protocol_version).toBeUndefined(); + + options = await SenderOptions.fromConfig("udp::addr=hostname"); + expect(options.host).toBe("hostname"); + expect(options.port).toBe(9007); + expect(options.protocol_version).toBeUndefined(); }); it("can parse protocol version", async function () { await expect( SenderOptions.fromConfig("ws::addr=hostname;protocol_version=1"), - ).rejects.toThrow( - "'protocol_version' is not used by the QWP ws/wss protocols", - ); + ).rejects.toThrow("'protocol_version' is not used by QWP transports"); // invalid protocol version await expect( @@ -807,7 +813,7 @@ describe("Configuration string parser suite", function () { ).rejects.toThrow("Invalid auto flush rows option, not a number: '1w23'"); }); - it("parses auto_flush_bytes only for QWP WebSocket", async function () { + it("parses auto_flush_bytes only for QWP transports", async function () { let options = await SenderOptions.fromConfig( "ws::addr=host:9000;auto_flush_bytes=123;", ); @@ -818,13 +824,39 @@ describe("Configuration string parser suite", function () { ); expect(options.auto_flush_bytes).toBe(0); + options = await SenderOptions.fromConfig( + "udp::addr=host:9007;auto_flush_bytes=1400;", + ); + expect(options.auto_flush_bytes).toBe(1400); + await expect( SenderOptions.fromConfig("ws::addr=host:9000;auto_flush_bytes=-1;"), ).rejects.toThrow("Invalid auto flush bytes option: -1"); await expect( SenderOptions.fromConfig("http::addr=host:9000;auto_flush_bytes=123;"), + ).rejects.toThrow("auto_flush_bytes is only supported for QWP transports"); + }); + + it("parses and validates QWP UDP options", async function () { + const options = await SenderOptions.fromConfig( + "udp::addr=host;max_datagram_size=1400;multicast_ttl=2;", + ); + expect(options.max_datagram_size).toBe(1400); + expect(options.multicast_ttl).toBe(2); + + await expect( + SenderOptions.fromConfig("udp::addr=host;multicast_ttl=256;"), + ).rejects.toThrow("Invalid multicast TTL option: 256"); + await expect( + SenderOptions.fromConfig("udp::addr=host;username=admin;"), + ).rejects.toThrow("authentication is not supported for QWP UDP transport"); + await expect( + SenderOptions.fromConfig("udp::addr=host;tls_verify=on;"), + ).rejects.toThrow("TLS is not supported for QWP UDP transport"); + await expect( + SenderOptions.fromConfig("ws::addr=host;max_datagram_size=1400;"), ).rejects.toThrow( - "auto_flush_bytes is only supported for QWP ws/wss transport", + "max_datagram_size and multicast_ttl are only supported for QWP UDP transport", ); }); diff --git a/test/qwp/node-transport.test.ts b/test/qwp/node-transport.test.ts index df103cc..3c6b513 100644 --- a/test/qwp/node-transport.test.ts +++ b/test/qwp/node-transport.test.ts @@ -413,7 +413,7 @@ describe("QWP Node transport", () => { async () => { expect( (await readdir(orphanDirectory)).filter((name) => - name.endsWith(".qwp"), + name.endsWith(".qwps"), ), ).toEqual([]); expect(events).toContain("drained"); @@ -446,7 +446,7 @@ describe("QWP Node transport", () => { await seed.append({ frameSequence: 0n, payload: Uint8Array.of(1) }); await seed.close(); const [record] = (await readdir(directory)).filter((name) => - name.endsWith(".qwp"), + name.endsWith(".qwps"), ); await writeFile(join(directory, record), Uint8Array.of(0)); @@ -496,7 +496,7 @@ describe("QWP Node transport", () => { expect.arrayContaining([record, ".qwp.failed"]), ); expect( - (await readdir(directory)).filter((name) => name.endsWith(".qwp")), + (await readdir(directory)).filter((name) => name.endsWith(".qwps")), ).toEqual([]); } finally { await rm(rootDirectory, { recursive: true, force: true }); @@ -583,7 +583,7 @@ describe("QWP Node transport", () => { await sender.table("trades").symbol("symbol", "ETH-USD").atNow(); await expect(sender.flush()).resolves.toBe(true); expect( - (await readdir(directory)).filter((name) => name.endsWith(".qwp")), + (await readdir(directory)).filter((name) => name.endsWith(".qwps")), ).toHaveLength(1); server = new WebSocketServer({ host: "127.0.0.1", port }); @@ -601,7 +601,7 @@ describe("QWP Node transport", () => { await vi.waitFor( async () => expect( - (await readdir(directory)).filter((name) => name.endsWith(".qwp")), + (await readdir(directory)).filter((name) => name.endsWith(".qwps")), ).toEqual([]), { timeout: 2_000 }, ); diff --git a/test/qwp/orphan-drainer.test.ts b/test/qwp/orphan-drainer.test.ts index 01aa14b..f82cc4c 100644 --- a/test/qwp/orphan-drainer.test.ts +++ b/test/qwp/orphan-drainer.test.ts @@ -92,6 +92,9 @@ describe("QWP Node orphan drainer", () => { it("finds record-bearing child slots while excluding live and failed slots", async () => { const rootDirectory = await root(); const orphan = await recordSlot(rootDirectory, "orphan"); + const segmented = join(rootDirectory, "segmented"); + await mkdir(segmented); + await writeFile(join(segmented, "00000000000000000000.qwps"), "segment"); await recordSlot(rootDirectory, "live"); const failed = await recordSlot(rootDirectory, "failed"); await writeFile(join(failed, QWP_ORPHAN_FAILED_SENTINEL), "inspect me"); @@ -100,7 +103,7 @@ describe("QWP Node orphan drainer", () => { await expect( scanQwpNodeOrphanSlots(rootDirectory, (name) => name === "live"), - ).resolves.toEqual([orphan]); + ).resolves.toEqual([orphan, segmented]); await expect( scanQwpNodeOrphanSlots(join(rootDirectory, "missing")), ).resolves.toEqual([]); diff --git a/test/qwp/public-api-contract.ts b/test/qwp/public-api-contract.ts index 9b45480..2f187d9 100644 --- a/test/qwp/public-api-contract.ts +++ b/test/qwp/public-api-contract.ts @@ -19,6 +19,8 @@ import { connectQwpNodeClient, connectQwpNodeIngress, connectQwpNodeSender, + connectQwpNodeUdp, + connectQwpNodeUdpSender, connectQwpNodeWebSocket, parseQwpNodeClientConfig, retryQwpNodeOrphanSlot, @@ -29,6 +31,8 @@ import type { QwpNodeClientConfigOptions, QwpNodeEgressOptions, QwpNodeIngressOptions, + QwpNodeUdpOptions, + QwpNodeUdpSession, QwpNodeOrphanDrainEvent, QwpNodeReplayRecoveryEvent, QwpNodeStoreAndForwardOptions, @@ -88,6 +92,15 @@ const nodeSenderSignature: ( sessionOptions?: QwpIngressSessionOptions, ) => Promise = connectQwpNodeSender; +const nodeUdpSignature: ( + options: QwpNodeUdpOptions, +) => Promise = connectQwpNodeUdp; + +const nodeUdpSenderSignature: ( + options: QwpNodeUdpOptions, + senderOptions?: QwpSenderOptions, +) => Promise = connectQwpNodeUdpSender; + const nodeIngressSignature: ( options: QwpNodeIngressOptions, sessionOptions?: QwpIngressSessionOptions, @@ -223,6 +236,10 @@ const qwpExtraOptionsContract: QwpExtraOptions = { session: { reconnect: { maxAttempts: 3 }, }, + udp: { + maxDatagramSize: 1_400, + multicastTtl: 1, + }, }; function senderSequenceContract( @@ -299,6 +316,8 @@ void browserEgressSignature; void bootstrapSignature; void browserClientSignature; void nodeSenderSignature; +void nodeUdpSignature; +void nodeUdpSenderSignature; void nodeIngressSignature; void nodeEgressSignature; void nodeWebSocketSignature; diff --git a/test/qwp/public-api.test.ts b/test/qwp/public-api.test.ts index a940b0c..84c3b0c 100644 --- a/test/qwp/public-api.test.ts +++ b/test/qwp/public-api.test.ts @@ -68,6 +68,7 @@ const nodeRuntimeContract = [ "QWP_SF_DURABILITY", "QwpNodeFileReplayStore", "QwpNodeOrphanDrainer", + "QwpNodeUdpSession", "QwpReplayStoreAppendTimeoutError", "QwpReplayStoreCheckpointError", "QwpReplayStoreCorruptionError", @@ -75,15 +76,19 @@ const nodeRuntimeContract = [ "QwpReplayStoreFullError", "QwpReplayStoreLockedError", "QwpReplayStoreQuarantinedError", + "QwpUdpDatagramTooLargeError", "QwpVersionMismatchError", "connectQwpNodeEgress", "connectQwpNodeIngress", "connectQwpNodeClient", "connectQwpNodeSender", + "connectQwpNodeUdp", + "connectQwpNodeUdpSender", "connectQwpNodeWebSocket", "createQwpNodeConnectionFactory", "createQwpNodeClient", "createQwpNodeSender", + "createQwpNodeUdpSender", "retryQwpNodeOrphanSlot", "scanQwpNodeOrphanSlots", ] as const; diff --git a/test/qwp/reconnect.test.ts b/test/qwp/reconnect.test.ts index b4fd27c..b811f89 100644 --- a/test/qwp/reconnect.test.ts +++ b/test/qwp/reconnect.test.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { mkdir, mkdtemp, @@ -1551,7 +1552,7 @@ describe("QWP ingress reconnect and replay", () => { connection.receive(ingressResponse(QWP_STATUS.OK, 1n)); await vi.waitFor(async () => expect( - (await readdir(directory)).filter((name) => name.endsWith(".qwp")), + (await readdir(directory)).filter((name) => name.endsWith(".qwps")), ).toEqual([]), ); @@ -1698,7 +1699,7 @@ describe("QWP ingress reconnect and replay", () => { }); expect(connection.sent).toEqual([]); expect( - (await readdir(directory)).filter((name) => name.endsWith(".qwp")), + (await readdir(directory)).filter((name) => name.endsWith(".qwps")), ).toEqual([]); expect(session.metrics).toMatchObject({ replayPublishedFrameSequence: 7n, @@ -1752,7 +1753,7 @@ describe("QWP ingress reconnect and replay", () => { connection.receive(durableResponse([["trades", 42n]])); await vi.waitFor(async () => expect( - (await readdir(directory)).filter((name) => name.endsWith(".qwp")), + (await readdir(directory)).filter((name) => name.endsWith(".qwps")), ).toEqual([]), ); expect(session.metrics).toMatchObject({ @@ -1774,7 +1775,7 @@ describe("QWP ingress reconnect and replay", () => { connection.receive(durableResponse([["trades", 43n]])); await vi.waitFor(async () => expect( - (await readdir(directory)).filter((name) => name.endsWith(".qwp")), + (await readdir(directory)).filter((name) => name.endsWith(".qwps")), ).toEqual([]), ); await vi.waitFor(() => expect(session.acknowledgedFrameSequence).toBe(8n)); @@ -1807,13 +1808,13 @@ describe("QWP ingress reconnect and replay", () => { connection.receive(ingressResponse(QWP_STATUS.OK, 1n, [["trades", 42n]])); await vi.waitFor(async () => expect( - (await readdir(directory)).filter((name) => name.endsWith(".qwp")), - ).toHaveLength(2), + (await readdir(directory)).filter((name) => name.endsWith(".qwps")), + ).toHaveLength(1), ); connection.receive(durableResponse([["trades", 42n]])); await vi.waitFor(async () => expect( - (await readdir(directory)).filter((name) => name.endsWith(".qwp")), + (await readdir(directory)).filter((name) => name.endsWith(".qwps")), ).toEqual([]), ); await session.close(); @@ -1836,13 +1837,13 @@ describe("QWP ingress reconnect and replay", () => { connection.receive(ingressResponse(QWP_STATUS.OK, 0n, [["trades", 42n]])); await expect(pending).resolves.toMatchObject({ sequence: 0n }); expect( - (await readdir(directory)).filter((name) => name.endsWith(".qwp")), + (await readdir(directory)).filter((name) => name.endsWith(".qwps")), ).toHaveLength(1); connection.receive(durableResponse([["trades", 42n]])); await vi.waitFor(async () => expect( - (await readdir(directory)).filter((name) => name.endsWith(".qwp")), + (await readdir(directory)).filter((name) => name.endsWith(".qwps")), ).toEqual([]), ); await session.close(); @@ -2443,6 +2444,9 @@ describe("QWP Node file replay store", () => { await first.append({ frameSequence: 0n, payload: Uint8Array.of(1, 2) }); await first.append({ frameSequence: 1n, payload: Uint8Array.of(3, 4) }); await first.close(); + expect( + (await readdir(directory)).filter((name) => name.endsWith(".qwps")), + ).toHaveLength(1); const second = new QwpNodeFileReplayStore({ directory }); await expect(second.load()).resolves.toEqual([ @@ -2451,6 +2455,9 @@ describe("QWP Node file replay store", () => { ]); await second.acknowledgeThrough(0n); await second.close(); + expect(await readdir(directory)).toEqual( + expect.arrayContaining(["ack.qwpstate"]), + ); const third = new QwpNodeFileReplayStore({ directory }); await expect(third.load()).resolves.toEqual([ @@ -2459,6 +2466,115 @@ describe("QWP Node file replay store", () => { await third.close(); }); + it("detects a replay gap immediately after a persisted ACK watermark", async () => { + const directory = await trackedDirectory(); + const first = new QwpNodeFileReplayStore({ directory }); + await first.load(); + for (let sequence = 0n; sequence < 3n; sequence++) { + await first.append({ + frameSequence: sequence, + payload: Uint8Array.of(Number(sequence)), + }); + } + await first.acknowledgeThrough(0n); + await first.close(); + + const segment = (await readdir(directory)).find((name) => + name.endsWith(".qwps"), + )!; + const path = join(directory, segment); + await truncate(path, 53); + await writeFile(path, encodeLegacyReplayRecord(2n, Uint8Array.of(2)), { + flag: "a", + }); + + const recovered = new QwpNodeFileReplayStore({ directory }); + await expect(recovered.load()).rejects.toThrow( + /sequence has a gap \[previous=0, received=2\]/, + ); + await recovered.close(); + }); + + it("coalesces many replay frames into bounded segment files", async () => { + const directory = await trackedDirectory(); + const store = new QwpNodeFileReplayStore({ + directory, + maxSegmentBytes: 256, + }); + await store.load(); + for (let sequence = 0n; sequence < 25n; sequence++) { + await store.append({ + frameSequence: sequence, + payload: Uint8Array.of(1), + }); + } + const segments = (await readdir(directory)).filter((name) => + name.endsWith(".qwps"), + ); + expect(segments.length).toBeGreaterThan(1); + expect(segments.length).toBeLessThan(25); + expect(store.metrics).toMatchObject({ + pendingRecords: 25, + pendingSegments: segments.length, + }); + for (const segment of segments) { + expect((await stat(join(directory, segment))).size).toBeLessThanOrEqual( + 256 + 52, + ); + } + await store.close(); + }); + + it("repairs a torn append at the tail of the active segment", async () => { + const directory = await trackedDirectory(); + const first = new QwpNodeFileReplayStore({ directory }); + await first.load(); + await first.append({ frameSequence: 0n, payload: Uint8Array.of(1, 2, 3) }); + await first.close(); + const segment = (await readdir(directory)).find((name) => + name.endsWith(".qwps"), + )!; + const validSize = (await stat(join(directory, segment))).size; + await writeFile(join(directory, segment), Uint8Array.of(0x51, 0x57), { + flag: "a", + }); + + const recovered = new QwpNodeFileReplayStore({ directory }); + await expect(recovered.load()).resolves.toEqual([ + { frameSequence: 0n, payload: Uint8Array.of(1, 2, 3) }, + ]); + expect((await stat(join(directory, segment))).size).toBe(validSize); + await recovered.close(); + }); + + it("loads legacy file-per-frame records and writes new segmented appends", async () => { + const directory = await trackedDirectory(); + await writeFile( + join(directory, "00000000000000000005.qwp"), + encodeLegacyReplayRecord(5n, Uint8Array.of(1, 2)), + ); + + const first = new QwpNodeFileReplayStore({ directory }); + await expect(first.load()).resolves.toEqual([ + { frameSequence: 5n, payload: Uint8Array.of(1, 2) }, + ]); + await first.append({ frameSequence: 6n, payload: Uint8Array.of(3, 4) }); + await first.close(); + expect(await readdir(directory)).toEqual( + expect.arrayContaining([ + "00000000000000000005.qwp", + "00000000000000000006.qwps", + ]), + ); + + const recovered = new QwpNodeFileReplayStore({ directory }); + await expect(recovered.load()).resolves.toEqual([ + { frameSequence: 5n, payload: Uint8Array.of(1, 2) }, + { frameSequence: 6n, payload: Uint8Array.of(3, 4) }, + ]); + await recovered.close(); + }); + it.each([ QWP_SF_DURABILITY.APPEND, QWP_SF_DURABILITY.PERIODIC, @@ -2687,7 +2803,7 @@ describe("QWP Node file replay store", () => { await store.append({ frameSequence: 0n, payload: Uint8Array.of(1) }); await store.append({ frameSequence: 1n, payload: Uint8Array.of(2) }); const record = (await readdir(directory)).find((name) => - name.endsWith(".qwp"), + name.endsWith(".qwps"), ); expect(record).toBeDefined(); await unlink(join(directory, record!)); @@ -2714,6 +2830,7 @@ describe("QWP Node file replay store", () => { const store = new QwpNodeFileReplayStore({ directory, maxBytes: 106, + maxSegmentBytes: 1, backpressurePolicy: QWP_SF_BACKPRESSURE_POLICY.WAIT, appendDeadlineMs: 1_000, }); @@ -2816,7 +2933,7 @@ describe("QWP Node file replay store", () => { await first.append({ frameSequence: 0n, payload: Uint8Array.of(1) }); await first.close(); const [record] = (await readdir(directory)).filter((name) => - name.endsWith(".qwp"), + name.endsWith(".qwps"), ); await writeFile(join(directory, record), Uint8Array.of(0)); @@ -2840,3 +2957,17 @@ describe("QWP Node file replay store", () => { async function createTemporaryDirectory(): Promise { return mkdtemp(join(tmpdir(), "qwp-replay-")); } + +function encodeLegacyReplayRecord( + frameSequence: bigint, + payload: Uint8Array, +): Buffer { + const bytes = Buffer.alloc(52 + payload.byteLength); + bytes.write("QWPR", 0, "ascii"); + bytes.writeUInt8(1, 4); + bytes.writeBigUInt64LE(frameSequence, 8); + bytes.writeUInt32LE(payload.byteLength, 16); + createHash("sha256").update(payload).digest().copy(bytes, 20); + Buffer.from(payload).copy(bytes, 52); + return bytes; +} diff --git a/test/qwp/udp-sender.test.ts b/test/qwp/udp-sender.test.ts new file mode 100644 index 0000000..f6b7dd7 --- /dev/null +++ b/test/qwp/udp-sender.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, it } from "vitest"; +import { Sender } from "../../src"; +import { + connectQwpNodeUdp, + connectQwpNodeUdpSender, + createQwpNodeUdpSender, + type QwpNodeUdpSocketLike, +} from "../../src/qwp/node"; +import { decodeQwpFrame, QWP_COLUMN_TYPE, QwpTableBuffer } from "../../src/qwp"; +import { QwpUdpDatagramTooLargeError } from "../../src/qwp/node"; + +class FakeUdpSocket implements QwpNodeUdpSocketLike { + readonly packets: Uint8Array[] = []; + readonly destinations: Array<{ host: string; port: number }> = []; + multicastTtl = -1; + multicastInterface?: string; + sendError?: Error; + closed = false; + private errorListener?: (error: Error) => void; + + bind(_port: number, _address: string, callback: () => void): void { + queueMicrotask(callback); + } + + send( + message: Uint8Array, + port: number, + host: string, + callback: (error: Error | null, bytes: number) => void, + ): void { + this.packets.push(message.slice()); + this.destinations.push({ host, port }); + queueMicrotask(() => callback(this.sendError ?? null, message.byteLength)); + } + + close(callback: () => void): void { + this.closed = true; + queueMicrotask(callback); + } + + on(_event: "error", listener: (error: Error) => void): unknown { + this.errorListener = listener; + return this; + } + + setMulticastTTL(ttl: number): number { + this.multicastTtl = ttl; + return ttl; + } + + setMulticastInterface(multicastInterface: string): void { + this.multicastInterface = multicastInterface; + } + + emitError(error: Error): void { + this.errorListener?.(error); + } +} + +function longTable(rows: number): QwpTableBuffer { + const table = new QwpTableBuffer("trades"); + for (let row = 0; row < rows; row++) { + const column = table.getOrCreateColumn("price", QWP_COLUMN_TYPE.LONG)!; + column.values.push(BigInt(row)); + table.nextRow(); + } + return table; +} + +function stringTable(value: string): QwpTableBuffer { + const table = new QwpTableBuffer("events"); + const column = table.getOrCreateColumn("message", QWP_COLUMN_TYPE.VARCHAR)!; + column.values.push(value); + table.nextRow(); + return table; +} + +describe("QWP Node UDP sender", () => { + it("splits at row boundaries into self-contained one-table datagrams", async () => { + const socket = new FakeUdpSocket(); + const session = await connectQwpNodeUdp({ + host: "239.1.2.3", + port: 9007, + maxDatagramSize: 80, + multicastTtl: 2, + multicastInterface: "127.0.0.1", + socketFactory: () => socket, + }); + + await session.sendTables([longTable(20)]); + + expect(socket.packets.length).toBeGreaterThan(1); + for (const packet of socket.packets) { + expect(packet.byteLength).toBeLessThanOrEqual(80); + expect(decodeQwpFrame(packet)).toMatchObject({ + flags: 0, + tableCount: 1, + }); + } + expect(socket.destinations).toEqual( + socket.packets.map(() => ({ host: "239.1.2.3", port: 9007 })), + ); + expect(socket.multicastTtl).toBe(2); + expect(socket.multicastInterface).toBe("127.0.0.1"); + expect(session.udpMetrics).toMatchObject({ + totalDatagramsSent: socket.packets.length, + totalSendErrors: 0, + }); + await session.close(); + expect(socket.closed).toBe(true); + }); + + it("rejects one oversized row before sending any datagram", async () => { + const socket = new FakeUdpSocket(); + const session = await connectQwpNodeUdp({ + host: "localhost", + maxDatagramSize: 64, + socketFactory: () => socket, + }); + + await expect( + session.sendTables([stringTable("x".repeat(256))]), + ).rejects.toBeInstanceOf(QwpUdpDatagramTooLargeError); + expect(socket.packets).toEqual([]); + await session.close(); + }); + + it("reports local send failures without retrying fire-and-forget rows", async () => { + const socket = new FakeUdpSocket(); + socket.sendError = new Error("network unreachable"); + const errors: Error[] = []; + const session = await connectQwpNodeUdp({ + host: "localhost", + socketFactory: () => socket, + onError: (error) => errors.push(error), + }); + + await expect(session.sendTables([longTable(1)])).resolves.toMatchObject({ + status: 0, + sequence: 0n, + }); + expect(errors.map((error) => error.message)).toEqual([ + "network unreachable", + ]); + expect(session.udpMetrics).toMatchObject({ + publishedDatagramSequence: 0n, + totalDatagramsSent: 0, + totalSendErrors: 1, + }); + await session.close(); + }); + + it("integrates UDP with the fluent sender and top-level config API", async () => { + const directSocket = new FakeUdpSocket(); + const direct = await connectQwpNodeUdpSender( + { + host: "localhost", + socketFactory: () => directSocket, + }, + { autoFlush: false }, + ); + direct.table("trades").longColumn("price", 42n); + await direct.atNow(); + await expect(direct.flush()).resolves.toBe(true); + expect(directSocket.packets).toHaveLength(1); + await direct.close(); + + const configuredSocket = new FakeUdpSocket(); + const configured = await Sender.fromConfig( + "udp::addr=localhost;max_datagram_size=256;multicast_ttl=1;auto_flush=off;", + { qwp: { udp: { socketFactory: () => configuredSocket } } }, + ); + await configured.connect(); + configured.table("trades").intColumn("price", 7); + await configured.atNow(); + await configured.flush(); + expect(configuredSocket.packets).toHaveLength(1); + expect(configuredSocket.multicastTtl).toBe(1); + await configured.close(); + }); + + it("rejects acknowledgement and transaction options that UDP cannot honor", () => { + const options = { + host: "localhost", + socketFactory: () => new FakeUdpSocket(), + }; + expect(() => + createQwpNodeUdpSender(options, { transactional: true }), + ).toThrow(/does not support transactions/); + expect(() => + createQwpNodeUdpSender(options, { awaitDurableAck: true }), + ).toThrow(/does not support durable acknowledgements/); + }); +}); From c302d81ff8fb7f72dfa915eb7657c97a8495ff65 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 19 Aug 2026 00:13:48 +0100 Subject: [PATCH 067/265] fix(qwp): retain rows until local publication --- QWP.md | 6 + src/qwp/ingress-session.ts | 269 ++++++++++++++---- .../reconnecting-ingress-connection.ts | 10 +- src/qwp/sender.ts | 50 +++- src/qwp/transport.ts | 6 + test/qwp/public-api-contract.ts | 10 + test/qwp/reconnect.test.ts | 112 +++++++- 7 files changed, 387 insertions(+), 76 deletions(-) diff --git a/QWP.md b/QWP.md index 583c979..4fb352a 100644 --- a/QWP.md +++ b/QWP.md @@ -356,6 +356,12 @@ Low-level Node sessions expose `publishFrame()`, `publishTables()`, and `publishTablesDelta()` for local-publication semantics. Their `send*()` counterparts continue to return the server ACK. Use the publication methods only with persistent store-and-forward when local durability is the intended completion boundary. +`sendFrameWithPublication()`, `sendTablesWithPublication()`, and +`sendTablesDeltaWithPublication()` expose both boundaries from one operation: await +`publication` before releasing retryable source rows, then await `acknowledgement` +when server acceptance is also required. If a split logical batch cannot be fully +journaled, its unattempted suffix is suppressed and the operation's publication +promise rejects. ### Browser ingress diff --git a/src/qwp/ingress-session.ts b/src/qwp/ingress-session.ts index fce6313..dc31230 100644 --- a/src/qwp/ingress-session.ts +++ b/src/qwp/ingress-session.ts @@ -1,4 +1,5 @@ import { + decodeQwpIngressSymbolDictionaryDelta, decodeQwpIngressResponse, encodeQwpDurableAckPollFrame, encodeQwpIngressFrame, @@ -282,6 +283,21 @@ export interface QwpIngressErrorEvent { readonly metrics: QwpIngressMetrics; } +/** + * One ingress operation with independent local-publication and server-ACK + * completion. Publication resolves after every physical frame belonging to + * the logical batch has been accepted by the connection. For persistent Node + * transports that means the frames are durable in the replay journal. + */ +export interface QwpIngressSendResult { + /** Last client-session sequence allocated to this logical batch. */ + readonly sequence: bigint; + /** Local transport/journal ownership boundary. */ + readonly publication: Promise; + /** Cumulative server response for every frame in the logical batch. */ + readonly acknowledgement: Promise; +} + interface PendingResponse { resolve: (response: QwpIngressResponse) => void; reject: (error: unknown) => void; @@ -632,19 +648,33 @@ export class QwpIngressSession { tables: readonly QwpTableBuffer[], encodeOptions: QwpIngressEncodeOptions = {}, ): Promise { - this.throwIfUnavailable(); - const cap = this.maxBatchSizeBytes; - if (cap === undefined) { - return this.sendFrame(encodeQwpIngressFrame(tables, encodeOptions)); - } - let planned: PlannedIngressFrames; try { - planned = planIngressFrames(tables, encodeOptions, cap); + return this.sendTablesWithPublication(tables, encodeOptions) + .acknowledgement; } catch (error) { if (error instanceof QwpBatchTooLargeError) return Promise.reject(error); throw error; } - return this.sendPlannedFrames(planned.frames); + } + + /** + * Starts an ingress batch and exposes local publication separately from its + * server ACK. High-level senders use this boundary to retain retryable rows + * until a persistent replay journal owns the complete logical batch. + */ + sendTablesWithPublication( + tables: readonly QwpTableBuffer[], + encodeOptions: QwpIngressEncodeOptions = {}, + ): QwpIngressSendResult { + this.throwIfUnavailable(); + const cap = this.maxBatchSizeBytes; + if (cap === undefined) { + return this.sendFrameWithPublication( + encodeQwpIngressFrame(tables, encodeOptions), + ); + } + const planned = planIngressFrames(tables, encodeOptions, cap); + return this.sendPlannedFramesWithPublication(planned.frames); } /** @@ -685,18 +715,46 @@ export class QwpIngressSession { "gorilla" | "deferCommit" > = {}, ): Promise { + try { + return this.sendTablesDeltaWithPublication(tables, encodeOptions) + .acknowledgement; + } catch (error) { + if (error instanceof QwpBatchTooLargeError) return Promise.reject(error); + throw error; + } + } + + /** Delta-dictionary variant of sendTablesWithPublication(). */ + sendTablesDeltaWithPublication( + tables: readonly QwpTableBuffer[], + encodeOptions: Pick< + QwpIngressEncodeOptions, + "gorilla" | "deferCommit" + > = {}, + ): QwpIngressSendResult { this.throwIfUnavailable(); if (this.connection.ingressDeltaSymbolDictionaryEnabled === false) { - return this.sendTables(tables, encodeOptions); + return this.sendTablesWithPublication(tables, encodeOptions); } const previousSize = this.symbolDictionary.size; const previousPublishedMaxSymbolId = this.publishedMaxSymbolId; const previousDeltaSymbolsPublished = this.deltaSymbolsPublished; - const cap = this.maxBatchSizeBytes; - if (cap !== undefined) { - let planned: PlannedIngressFrames; - try { - planned = planIngressFrames( + let successfullyPublishedMaxSymbolId = previousPublishedMaxSymbolId; + let successfullyPublishedDelta = previousDeltaSymbolsPublished; + const recordPublishedDelta = (frame: Uint8Array): void => { + const delta = decodeQwpIngressSymbolDictionaryDelta(frame); + if (!delta) return; + successfullyPublishedDelta = true; + successfullyPublishedMaxSymbolId = Math.max( + successfullyPublishedMaxSymbolId, + delta.startId + delta.entries.length - 1, + ); + }; + let sending: QwpIngressSendResult; + try { + const cap = this.maxBatchSizeBytes; + if (cap !== undefined) { + const planned = planIngressFrames( tables, { ...encodeOptions, @@ -705,44 +763,50 @@ export class QwpIngressSession { }, cap, ); - } catch (error) { - if (error instanceof QwpBatchTooLargeError) - return Promise.reject(error); - throw error; - } - this.publishedMaxSymbolId = this.symbolDictionary.size - 1; - this.deltaSymbolsPublished = true; - try { - return this.sendPlannedFrames(planned.frames); - } catch (error) { - this.symbolDictionary.truncate(previousSize); - this.publishedMaxSymbolId = previousPublishedMaxSymbolId; - this.deltaSymbolsPublished = previousDeltaSymbolsPublished; - throw error; + this.publishedMaxSymbolId = this.symbolDictionary.size - 1; + this.deltaSymbolsPublished = true; + sending = this.sendPlannedFramesWithPublication( + planned.frames, + recordPublishedDelta, + ); + } else { + const frame = encodeQwpIngressFrame(tables, { + ...encodeOptions, + dictionary: this.symbolDictionary, + confirmedMaxSymbolId: this.publishedMaxSymbolId, + }); + this.publishedMaxSymbolId = this.symbolDictionary.size - 1; + this.deltaSymbolsPublished = true; + const rawSending = this.sendFrameWithPublication(frame); + sending = { + ...rawSending, + publication: rawSending.publication.then(() => + recordPublishedDelta(frame), + ), + }; } - } - - let frame: Uint8Array; - try { - frame = encodeQwpIngressFrame(tables, { - ...encodeOptions, - dictionary: this.symbolDictionary, - confirmedMaxSymbolId: this.publishedMaxSymbolId, - }); - } catch (error) { - this.symbolDictionary.truncate(previousSize); - throw error; - } - this.publishedMaxSymbolId = this.symbolDictionary.size - 1; - this.deltaSymbolsPublished = true; - try { - return this.sendFrame(frame); } catch (error) { this.symbolDictionary.truncate(previousSize); this.publishedMaxSymbolId = previousPublishedMaxSymbolId; this.deltaSymbolsPublished = previousDeltaSymbolsPublished; throw error; } + + // The publication promise, rather than a synchronous try/catch around + // sendFrame(), is the authoritative ownership boundary. Restore the + // allocator/watermark before the acknowledgement observes a local journal + // rejection, while retaining dictionary entries that did persist. + const publication = sending.publication.catch((error: unknown) => { + this.restoreDeltaStateAfterPublishFailure(previousSize); + this.publishedMaxSymbolId = successfullyPublishedMaxSymbolId; + this.deltaSymbolsPublished = successfullyPublishedDelta; + throw error; + }); + const acknowledgement = Promise.all([ + publication, + sending.acknowledgement, + ]).then(([, response]) => response); + return { sequence: sending.sequence, publication, acknowledgement }; } /** @@ -764,6 +828,17 @@ export class QwpIngressSession { const previousSize = this.symbolDictionary.size; const previousPublishedMaxSymbolId = this.publishedMaxSymbolId; const previousDeltaSymbolsPublished = this.deltaSymbolsPublished; + let successfullyPublishedMaxSymbolId = previousPublishedMaxSymbolId; + let successfullyPublishedDelta = previousDeltaSymbolsPublished; + const recordPublishedDelta = (frame: Uint8Array): void => { + const delta = decodeQwpIngressSymbolDictionaryDelta(frame); + if (!delta) return; + successfullyPublishedDelta = true; + successfullyPublishedMaxSymbolId = Math.max( + successfullyPublishedMaxSymbolId, + delta.startId + delta.entries.length - 1, + ); + }; try { const cap = this.maxBatchSizeBytes; if (cap !== undefined) { @@ -778,7 +853,7 @@ export class QwpIngressSession { ); this.publishedMaxSymbolId = this.symbolDictionary.size - 1; this.deltaSymbolsPublished = true; - await this.publishPlannedFrames(planned.frames); + await this.publishPlannedFrames(planned.frames, recordPublishedDelta); return; } @@ -790,10 +865,11 @@ export class QwpIngressSession { this.publishedMaxSymbolId = this.symbolDictionary.size - 1; this.deltaSymbolsPublished = true; await this.publishFrame(frame); + recordPublishedDelta(frame); } catch (error) { this.restoreDeltaStateAfterPublishFailure(previousSize); - this.publishedMaxSymbolId = previousPublishedMaxSymbolId; - this.deltaSymbolsPublished = previousDeltaSymbolsPublished; + this.publishedMaxSymbolId = successfullyPublishedMaxSymbolId; + this.deltaSymbolsPublished = successfullyPublishedDelta; throw error; } } @@ -857,6 +933,23 @@ export class QwpIngressSession { } sendFrame(frame: Uint8Array): Promise { + try { + return this.sendFrameWithPublication(frame).acknowledgement; + } catch (error) { + if (error instanceof QwpBatchTooLargeError) return Promise.reject(error); + throw error; + } + } + + /** Starts one pre-encoded frame with independent publication and ACKs. */ + sendFrameWithPublication(frame: Uint8Array): QwpIngressSendResult { + return this.startFrameWithPublication(frame); + } + + private startFrameWithPublication( + frame: Uint8Array, + publicationBarrier: Promise = this.sendTail, + ): QwpIngressSendResult { this.throwIfUnavailable(); const ackDeferredUntilCommit = frame.byteLength > QWP_FLAGS_OFFSET && @@ -865,9 +958,7 @@ export class QwpIngressSession { this.maxBatchSizeBytes !== undefined && frame.byteLength > this.maxBatchSizeBytes ) { - return Promise.reject( - new QwpBatchTooLargeError(frame.byteLength, this.maxBatchSizeBytes), - ); + throw new QwpBatchTooLargeError(frame.byteLength, this.maxBatchSizeBytes); } const sequence = this.nextSequence++; let pending!: PendingResponse; @@ -878,13 +969,31 @@ export class QwpIngressSession { this.totalFramesPublished++; this.totalBytesPublished += frame.byteLength; - const sending = this.sendTail.then(async () => { - this.throwIfUnavailable(); - await this.connection.send(frame); - }); + let sendStarted = false; + const sending = publicationBarrier.then( + async () => { + this.throwIfUnavailable(); + sendStarted = true; + await this.connection.send(frame); + }, + (error: unknown) => { + // This session sequence was already allocated, but the frame must not + // reach a replay transport after an earlier frame in the same logical + // transaction failed publication. Reserve its translation slot so all + // later wire ACKs still map to the correct session sequence. + this.connection.skipIngressClientSequence?.(); + throw error; + }, + ); this.sendTail = sending.catch((error: unknown) => { + if (!sendStarted) return; if (error instanceof QwpReplayDictionaryPersistenceError) { this.recordError(error, false); + } else if (this.connection instanceof QwpReconnectingIngressConnection) { + // Replay transports own their terminal state. A local journal append + // failure is retryable by the caller and must not brick the session; + // terminal transport failures independently close the message stream. + this.recordError(error, false); } else { this.fail(error); } @@ -920,21 +1029,57 @@ export class QwpIngressSession { if (pending.timer) clearTimeout(pending.timer); pending.reject(error); }); - return response; + return { + sequence, + publication: sending, + acknowledgement: response, + }; } - private sendPlannedFrames( + private sendPlannedFramesWithPublication( frames: readonly Uint8Array[], - ): Promise { - const responses = frames.map((frame) => this.sendFrame(frame)); - if (responses.length === 1) return responses[0]; - return Promise.all(responses).then(mergeIngressResponses); + onFramePublished?: (frame: Uint8Array) => void, + ): QwpIngressSendResult { + const sends: QwpIngressSendResult[] = []; + let publicationBarrier = this.sendTail; + for (const frame of frames) { + const sending = this.startFrameWithPublication(frame, publicationBarrier); + const tracked = onFramePublished + ? { + ...sending, + publication: sending.publication.then(() => + onFramePublished(frame), + ), + } + : sending; + sends.push(tracked); + // Within one logical split batch a failed prefix must suppress every + // later frame. In particular, never send the final commit frame after a + // deferred prefix failed to enter the replay journal. + publicationBarrier = tracked.publication; + } + if (sends.length === 1) return sends[0]; + // The final barrier settles only after every suffix has either published + // or been deliberately suppressed and had its sequence slot reserved. + const publication = publicationBarrier; + const acknowledgement = Promise.all( + sends.map((send) => send.acknowledgement), + ).then(mergeIngressResponses); + return { + sequence: sends[sends.length - 1].sequence, + publication, + acknowledgement, + }; } private async publishPlannedFrames( frames: readonly Uint8Array[], + onFramePublished?: (frame: Uint8Array) => void, ): Promise { - for (const frame of frames) await this.publishFrame(frame); + for (const frame of frames) { + await this.publishFrame(frame); + onFramePublished?.(frame); + } } /** diff --git a/src/qwp/internal/reconnecting-ingress-connection.ts b/src/qwp/internal/reconnecting-ingress-connection.ts index 8bc3586..67f9796 100644 --- a/src/qwp/internal/reconnecting-ingress-connection.ts +++ b/src/qwp/internal/reconnecting-ingress-connection.ts @@ -191,6 +191,7 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { private wireFrames: ReplayFrame[] = []; private nextFrameSequence = 0n; private nextClientSequence = 0n; + private publishedFrameSequence = -1n; private acknowledgedFrameSequence = -1n; private highestOkFrameSequence = -1n; private poisonFrameSequence?: bigint; @@ -304,6 +305,7 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { this.acknowledgedFrameSequence = records[0].frameSequence - 1n; } this.nextFrameSequence = previous + 1n; + this.publishedFrameSequence = previous; } static async connect( @@ -428,7 +430,7 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { pendingReplayBytes += frame.payload.byteLength; } return Object.freeze({ - publishedFrameSequence: this.nextFrameSequence - 1n, + publishedFrameSequence: this.publishedFrameSequence, acknowledgedFrameSequence: this.acknowledgedFrameSequence, pendingReplayFrames: this.frames.size, pendingReplayBytes, @@ -457,6 +459,11 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { return undefined; } + skipIngressClientSequence(): void { + this.nextClientSequence++; + this.nextFrameSequence++; + } + send(payload: Uint8Array): Promise { if (this.terminalError) return Promise.reject(this.terminalError); if (this.closing) return Promise.reject(new QwpSendClosedError()); @@ -480,6 +487,7 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { } await this.store.append(frame); this.frames.set(frame.frameSequence, frame); + this.publishedFrameSequence = frame.frameSequence; if (this.backgroundStoreAndForward) { this.enqueueDrain(frame); return; diff --git a/src/qwp/sender.ts b/src/qwp/sender.ts index eeffa60..110cb9e 100644 --- a/src/qwp/sender.ts +++ b/src/qwp/sender.ts @@ -10,6 +10,7 @@ import { } from "./core"; import { QwpIngressAckTimeoutError, + type QwpIngressSendResult, type QwpIngressMetrics, } from "./ingress-session"; @@ -100,6 +101,14 @@ export interface QwpSenderSession { tables: readonly QwpTableBuffer[], options?: Pick, ): Promise; + sendTablesWithPublication?( + tables: readonly QwpTableBuffer[], + options?: QwpIngressEncodeOptions, + ): QwpIngressSendResult; + sendTablesDeltaWithPublication?( + tables: readonly QwpTableBuffer[], + options?: Pick, + ): QwpIngressSendResult; publishTables?( tables: readonly QwpTableBuffer[], options?: QwpIngressEncodeOptions, @@ -1287,15 +1296,32 @@ export class QwpSender { let publishedSequence = -1n; const waitForServerAck = this.awaitServerAck && !publicationOnly; if (waitForServerAck) { - response = useDelta - ? session.sendTablesDelta!(wireTables, { - gorilla: encode?.gorilla, - deferCommit, - }) - : session.sendTables(wireTables, { - gorilla: encode?.gorilla, - deferCommit, - }); + const trackedSender = useDelta + ? session.sendTablesDeltaWithPublication + : session.sendTablesWithPublication; + if (trackedSender) { + const sending = trackedSender.call(session, wireTables, { + gorilla: encode?.gorilla, + deferCommit, + }); + response = sending.acknowledgement; + // Observe ACK rejection while the local-publication boundary is being + // awaited; it is consumed normally below after ownership transfers. + void response.catch(() => undefined); + publication = sending.publication.then(() => { + publishedSequence = sending.sequence; + }); + } else { + response = useDelta + ? session.sendTablesDelta!(wireTables, { + gorilla: encode?.gorilla, + deferCommit, + }) + : session.sendTables(wireTables, { + gorilla: encode?.gorilla, + deferCommit, + }); + } } else { const publisher = useDelta ? session.publishTablesDelta @@ -1322,9 +1348,9 @@ export class QwpSender { sessionPublishedSequence(session), ); this.totalFlushes++; - // Publication-only Node store-and-forward transfers row ownership only - // after every frame is durable locally. A disk-capacity or I/O failure - // therefore leaves the staged rows available for retry. + // Transfer row ownership only after every logical frame is accepted by + // the transport. For Node store-and-forward this is the durable journal + // boundary, independently of whether this flush also waits for an ACK. if (publication) await publication; for (const { table, rows } of snapshots) table.rows.splice(0, rows.length); const sentRows = snapshots.reduce( diff --git a/src/qwp/transport.ts b/src/qwp/transport.ts index 08af768..1fdb27e 100644 --- a/src/qwp/transport.ts +++ b/src/qwp/transport.ts @@ -453,6 +453,12 @@ export interface QwpBinaryConnection { /** @internal Resolves a session sequence to its stable replay FSN. */ getIngressFrameSequence?(clientSequence: bigint): bigint | undefined; + /** + * @internal Reserves a client sequence for a split-batch suffix suppressed + * before send(), keeping replay ACK translation aligned with the session. + */ + skipIngressClientSequence?(): void; + /** * @internal Marks this endpoint as temporarily unsuitable and asks a stateful * connection factory to start its next sweep at another configured endpoint. diff --git a/test/qwp/public-api-contract.ts b/test/qwp/public-api-contract.ts index 2f187d9..74e70a8 100644 --- a/test/qwp/public-api-contract.ts +++ b/test/qwp/public-api-contract.ts @@ -48,6 +48,7 @@ import type { QwpEgressViewQuery, QwpIngressSession, QwpIngressSessionOptions, + QwpIngressSendResult, QwpSenderError, QwpQueryLease, QwpResultBatchView, @@ -253,6 +254,12 @@ function senderSequenceContract( const sessionWait: Promise = session.waitForAcknowledged(0n, 5_000); const sessionPublished: bigint = session.publishedFrameSequence; const sessionAcknowledged: bigint = session.acknowledgedFrameSequence; + const tracked: QwpIngressSendResult = session.sendFrameWithPublication( + new Uint8Array(), + ); + const localPublication: Promise = tracked.publication; + const serverAcknowledgement = tracked.acknowledgement; + const trackedSequence: bigint = tracked.sequence; void published; void senderWait; void senderPublished; @@ -260,6 +267,9 @@ function senderSequenceContract( void sessionWait; void sessionPublished; void sessionAcknowledged; + void localPublication; + void serverAcknowledgement; + void trackedSequence; } function rootSenderSequenceContract(sender: Sender): void { diff --git a/test/qwp/reconnect.test.ts b/test/qwp/reconnect.test.ts index b811f89..030339a 100644 --- a/test/qwp/reconnect.test.ts +++ b/test/qwp/reconnect.test.ts @@ -54,6 +54,7 @@ import { QwpReconnectExhaustedError, QwpReplayRejectedError, QwpReplayDictionaryPersistenceError, + QwpSender, QwpUnrecoverableReplayDictionaryError, QwpUpgradeError, encodeQwpFrame, @@ -154,6 +155,17 @@ function symbolTable(symbol: string): QwpTableBuffer { return table; } +function symbolRows(symbols: readonly string[]): QwpTableBuffer { + const table = new QwpTableBuffer("trades"); + for (const symbol of symbols) { + table + .getOrCreateColumn("symbol", QWP_COLUMN_TYPE.SYMBOL)! + .values.push(symbol); + table.nextRow(); + } + return table; +} + class FakeConnection implements QwpBinaryConnection { readonly messages: AsyncIterable; readonly sent: Uint8Array[] = []; @@ -230,9 +242,15 @@ class FailOnceDictionaryReplayStore extends TrackingReplayStore { readonly symbols: string[] = []; appendAttempts = 0; + constructor(private readonly failOnAppendAttempt = 1) { + super(); + } + override async append(record: QwpIngressReplayRecord): Promise { this.appendAttempts++; - if (this.appendAttempts === 1) throw new Error("journal is full"); + if (this.appendAttempts === this.failOnAppendAttempt) { + throw new Error("journal is full"); + } await super.append(record); } @@ -906,6 +924,98 @@ describe("QWP ingress reconnect and replay", () => { await session.close(); }); + it("retains ACK-waiting high-level rows until journal publication succeeds", async () => { + const connection = new FakeConnection("primary"); + const replayStore = new FailOnceDictionaryReplayStore(); + const session = await QwpIngressSession.connect(async () => connection, { + ackTimeoutMs: 1_000, + reconnect: { maxAttempts: 1 }, + replayStore, + }); + const sender = new QwpSender(async () => session, { + autoFlush: false, + awaitServerAck: true, + }); + await sender.table("trades").symbol("symbol", "ETH-USD").atNow(); + + await expect(sender.flush()).rejects.toThrow("journal is full"); + expect(sender.metrics).toMatchObject({ + pendingRows: 1, + totalRowsPublished: 0, + totalFlushFailures: 1, + }); + expect(sender.publishedSequence).toBe(-1n); + expect(replayStore.symbols).toEqual(["ETH-USD"]); + expect(replayStore.records.size).toBe(0); + + const retried = sender.flush(); + await vi.waitFor(() => expect(connection.sent).toHaveLength(1)); + expect(decodeQwpIngressSymbolDictionaryDelta(connection.sent[0])).toEqual({ + startId: 0, + entries: ["ETH-USD"], + }); + connection.receive(ingressResponse(QWP_STATUS.OK, 0n)); + await expect(retried).resolves.toBe(true); + expect(sender.metrics).toMatchObject({ + pendingRows: 0, + totalRowsPublished: 1, + totalFlushes: 2, + }); + await sender.close(); + }); + + it("stops a split ACK-waiting batch after a failed journal prefix", async () => { + const connection = new FakeConnection("primary"); + const replayStore = new FailOnceDictionaryReplayStore(2); + const symbols = ["symbol-0000", "symbol-1111", "symbol-2222"]; + const sizingDictionary = new QwpSymbolDictionary(); + const cap = encodeQwpIngressFrame([symbolTable(symbols[0])], { + dictionary: sizingDictionary, + confirmedMaxSymbolId: -1, + }).byteLength; + const session = await QwpIngressSession.connect(async () => connection, { + ackTimeoutMs: 1_000, + reconnect: { maxAttempts: 1 }, + replayStore, + maxBatchSizeBytes: cap, + }); + + const failed = session.sendTablesDeltaWithPublication([ + symbolRows(symbols), + ]); + await expect(failed.publication).rejects.toThrow("journal is full"); + await expect(failed.acknowledgement).rejects.toThrow("journal is full"); + expect([...replayStore.records.keys()]).toEqual([0n]); + expect(connection.sent).toHaveLength(1); + expect(decodeQwpIngressSymbolDictionaryDelta(connection.sent[0])).toEqual({ + startId: 0, + entries: [symbols[0]], + }); + // The failed second frame persisted its sidecar entry before its frame + // append failed; the suppressed third frame persisted neither. + expect(replayStore.symbols).toEqual(symbols.slice(0, 2)); + + const retried = session.sendTablesDeltaWithPublication([ + symbolRows(symbols), + ]); + await expect(retried.publication).resolves.toBeUndefined(); + expect(connection.sent).toHaveLength(4); + expect(connection.sent.slice(1).every((frame) => frame.length <= cap)).toBe( + true, + ); + expect(decodeQwpIngressSymbolDictionaryDelta(connection.sent[1])).toEqual({ + startId: 1, + entries: [symbols[1]], + }); + connection.receive( + ingressResponse(QWP_STATUS.OK, BigInt(connection.sent.length - 1)), + ); + await expect(retried.acknowledgement).resolves.toMatchObject({ + sequence: retried.sequence, + }); + await session.close(); + }); + it("falls back to full symbols after dictionary persistence fails", async () => { const connection = new FakeConnection("primary"); const replayStore = new FailingDictionaryPersistenceReplayStore(); From ba9cee6eb92a412e95b83063988317d2b6938c0c Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 19 Aug 2026 00:27:57 +0100 Subject: [PATCH 068/265] fix(qwp): retry durable ack capability gaps --- QWP.md | 19 +- src/qwp-node/orphan-drainer.ts | 52 ++- src/qwp/ingress-session.ts | 12 + .../reconnecting-ingress-connection.ts | 134 ++++++- src/qwp/node.ts | 32 +- src/qwp/transport.ts | 8 + test/qwp/orphan-drainer.test.ts | 69 ++++ test/qwp/reconnect.test.ts | 337 ++++++++++++++++++ 8 files changed, 652 insertions(+), 11 deletions(-) diff --git a/QWP.md b/QWP.md index 4fb352a..790602b 100644 --- a/QWP.md +++ b/QWP.md @@ -240,9 +240,22 @@ default). The scanner runs immediately and then every 30 seconds; set `.qwp.failed` in the slot so a corrupt or permanently rejected head cannot cause a hot retry loop. After inspection or repair, call `retryQwpNodeOrphanSlot(slotDirectory)` to make it eligible again. `onOrphanDrainEvent` reports discovery, drain, lock -contention, quarantine, and scanner failures through a bounded asynchronous inbox. An -abandoned slot also reports a typed `data-loss` sender error. Callback exceptions -cannot interrupt recovery. +contention, quarantine, scanner failures, durable-ACK capability gaps, and transient +all-replica windows through a bounded asynchronous inbox. An abandoned slot also +reports a typed `data-loss` sender error. Callback exceptions cannot interrupt +recovery. + +Blocking (`off` or `sync`) foreground startup fails immediately if every usable +endpoint lacks durable-ACK support. Asynchronous foreground startup and steady-state +store-and-forward reconnects retain their records and retry through rolling upgrades. +An orphan slot retries a consecutive durable-ACK capability-gap episode until either +16 connection sweeps or the configured reconnect `maxDurationMs` is reached, then it +is quarantined behind `.qwp.failed` (`maxDurationMs: 0` disables only the time half of +the budget). A transport outage or an all-replica window resets both halves of this +orphan budget; neither transient condition can itself quarantine persisted data. The +`durable-ack-unavailable`, +`durable-ack-persistent-failure`, and `primary-unavailable` orphan events expose the +distinction to operators. A foreground sender retries a symbol-dictionary catch-up entry that is too large for the current target forever because a larger-cap node may return. An orphan drainer diff --git a/src/qwp-node/orphan-drainer.ts b/src/qwp-node/orphan-drainer.ts index 302e6fc..d4696e6 100644 --- a/src/qwp-node/orphan-drainer.ts +++ b/src/qwp-node/orphan-drainer.ts @@ -1,6 +1,8 @@ import { readdir, unlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; -import type { +import { + QWP_RECONNECT_EVENT_KIND, + type QwpReconnectEvent, QwpConnectionCloseInfo, QwpIngressTransportMetrics, } from "../qwp/transport"; @@ -30,6 +32,9 @@ export const QWP_ORPHAN_DRAIN_EVENT_KIND = { STARTED: "started", DRAINED: "drained", LOCKED: "locked", + DURABLE_ACK_UNAVAILABLE: "durable-ack-unavailable", + DURABLE_ACK_PERSISTENT_FAILURE: "durable-ack-persistent-failure", + PRIMARY_UNAVAILABLE: "primary-unavailable", FAILED: "failed", SCAN_FAILED: "scan-failed", } as const; @@ -42,6 +47,10 @@ export interface QwpNodeOrphanDrainEvent { readonly timestampMs: number; readonly directory?: string; readonly error?: Error; + /** One-based attempt in the current capability/topology episode. */ + readonly attempt?: number; + /** Elapsed time in the current consecutive capability-gap episode. */ + readonly episodeMs?: number; /** Present when a failed slot has been abandoned behind its sentinel. */ readonly senderError?: QwpSenderError; readonly metrics: QwpNodeOrphanDrainerMetrics; @@ -84,7 +93,10 @@ export interface QwpNodeOrphanDrainerOptions { /** Slot names owned by the foreground producer/pool and never adoptable. */ excludeSlot?: (slotName: string) => boolean; /** Creates one independent replay session for an adopted slot. */ - createSession(directory: string): Promise; + createSession( + directory: string, + onReconnectEvent?: (event: QwpReconnectEvent) => void, + ): Promise; /** Maximum slots drained concurrently. Defaults to 4. */ maxConcurrent?: number; /** Rescan cadence; zero performs only the startup scan. Defaults to 30s. */ @@ -168,6 +180,7 @@ export class QwpNodeOrphanDrainer { private readonly excludeSlot?: (slotName: string) => boolean; private readonly createSession: ( directory: string, + onReconnectEvent?: (event: QwpReconnectEvent) => void, ) => Promise; private readonly maxConcurrent: number; private readonly scanIntervalMs: number; @@ -331,7 +344,9 @@ export class QwpNodeOrphanDrainer { private async drainOne(directory: string): Promise { let session: QwpNodeOrphanDrainSession | undefined; try { - session = await this.createSession(directory); + session = await this.createSession(directory, (event) => + this.emitReconnectEvent(directory, event), + ); if (this.closing) { await session.close(1001, "QWP orphan drainer is closing"); return; @@ -423,6 +438,8 @@ export class QwpNodeOrphanDrainer { kind: QwpNodeOrphanDrainEventKind, directory?: string, error?: Error, + attempt?: number, + episodeMs?: number, ): void { const senderError = kind === QWP_ORPHAN_DRAIN_EVENT_KIND.FAILED && directory && error @@ -433,11 +450,40 @@ export class QwpNodeOrphanDrainer { timestampMs: Date.now(), directory, error, + attempt, + episodeMs, senderError, metrics: this.metrics, }); if (senderError) this.errorDispatcher?.offer(senderError); } + + private emitReconnectEvent( + directory: string, + event: QwpReconnectEvent, + ): void { + let kind: QwpNodeOrphanDrainEventKind | undefined; + switch (event.kind) { + case QWP_RECONNECT_EVENT_KIND.DURABLE_ACK_UNAVAILABLE: + kind = QWP_ORPHAN_DRAIN_EVENT_KIND.DURABLE_ACK_UNAVAILABLE; + break; + case QWP_RECONNECT_EVENT_KIND.DURABLE_ACK_PERSISTENT_FAILURE: + kind = QWP_ORPHAN_DRAIN_EVENT_KIND.DURABLE_ACK_PERSISTENT_FAILURE; + break; + case QWP_RECONNECT_EVENT_KIND.PRIMARY_UNAVAILABLE: + kind = QWP_ORPHAN_DRAIN_EVENT_KIND.PRIMARY_UNAVAILABLE; + break; + default: + return; + } + this.emit( + kind, + directory, + event.cause instanceof Error ? event.cause : undefined, + event.attempt, + event.episodeMs, + ); + } } async function markFailed(directory: string, error: Error): Promise { diff --git a/src/qwp/ingress-session.ts b/src/qwp/ingress-session.ts index dc31230..9e97c9c 100644 --- a/src/qwp/ingress-session.ts +++ b/src/qwp/ingress-session.ts @@ -173,6 +173,8 @@ export interface QwpIngressSessionOptions { initialConnectMode?: QwpInitialConnectMode; /** @internal Orphan sessions may quarantine persistent catch-up cap gaps. */ orphanStoreAndForward?: boolean; + /** @internal Consecutive durable-ACK gap budget retained for orphan SF. */ + orphanDurableAckMismatchMaxDurationMs?: number; /** @internal Minimum cap-gap dwell before an orphan can be quarantined. */ catchUpCapGapMinEscalationWindowMs?: number; /** @@ -392,6 +394,15 @@ function validateIngressSessionOptions( "durableAckKeepaliveMs must be a non-negative finite number", ); } + const orphanDurableAckBudget = options.orphanDurableAckMismatchMaxDurationMs; + if ( + orphanDurableAckBudget !== undefined && + (!Number.isFinite(orphanDurableAckBudget) || orphanDurableAckBudget < 0) + ) { + throw new RangeError( + "orphanDurableAckMismatchMaxDurationMs must be a non-negative finite number", + ); + } for (const [name, value, minimum] of [ [ "connectionListenerInboxCapacity", @@ -543,6 +554,7 @@ export class QwpIngressSession { options.backgroundStoreAndForward, initialConnectMode, options.orphanStoreAndForward, + options.orphanDurableAckMismatchMaxDurationMs, options.catchUpCapGapMinEscalationWindowMs, initialConnection, options.connectionListenerInboxCapacity ?? diff --git a/src/qwp/internal/reconnecting-ingress-connection.ts b/src/qwp/internal/reconnecting-ingress-connection.ts index 67f9796..d49abf5 100644 --- a/src/qwp/internal/reconnecting-ingress-connection.ts +++ b/src/qwp/internal/reconnecting-ingress-connection.ts @@ -15,9 +15,11 @@ import { import { QWP_INITIAL_CONNECT_MODE, QWP_RECONNECT_EVENT_KIND, + QWP_UPGRADE_ERROR_KIND, QwpBinaryConnection, QwpConnectionCloseInfo, QwpConnectionFactory, + QwpDurableAckUnavailableError, QwpFailoverError, QwpHandshakeMetadata, QwpIngressReplayRecord, @@ -45,6 +47,8 @@ import { const DEFAULT_CATCH_UP_CAP_GAP_MIN_ESCALATION_WINDOW_MS = 300_000; const MAX_CATCH_UP_CAP_GAP_ATTEMPTS = 16; +const DEFAULT_ORPHAN_DURABLE_ACK_MISMATCH_MAX_DURATION_MS = 300_000; +const MAX_ORPHAN_DURABLE_ACK_MISMATCH_ATTEMPTS = 16; type ConnectAttemptPolicy = "single" | "configured" | "unbounded"; @@ -74,6 +78,19 @@ class QwpCatchUpCapGapError extends RangeError { } } +class QwpDurableAckPersistentFailureError extends Error { + constructor( + readonly attempts: number, + readonly episodeMs: number, + readonly cause: QwpDurableAckUnavailableError, + ) { + super( + `QWP durable ACK remained unavailable for an orphan replay slot [attempts=${attempts}/${MAX_ORPHAN_DURABLE_ACK_MISMATCH_ATTEMPTS}, episodeMs=${episodeMs}]: ${cause.message}`, + ); + this.name = "QwpDurableAckPersistentFailureError"; + } +} + interface ReplayFrame extends QwpIngressReplayRecord { readonly clientSequence?: bigint; ackDelivered: boolean; @@ -180,6 +197,7 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { private readonly maxFrameRejections: number; private readonly poisonMinEscalationWindowMs: number; private readonly catchUpCapGapMinEscalationWindowMs: number; + private readonly orphanDurableAckMismatchMaxDurationMs: number; private readonly localMaxBatchSizeBytes?: number; private readonly connectionDispatcher?: QwpNotificationDispatcher; private readonly errorDispatcher?: QwpNotificationDispatcher; @@ -199,6 +217,8 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { private poisonStrikes = 0; private catchUpCapGapAttempts = 0; private catchUpCapGapFirstMs = 0; + private durableAckMismatchAttempts = 0; + private durableAckMismatchFirstMs = 0; private progressAtLastExemptRecycle = -1n; private zeroProgressRecycles = 0; private recoveredDiscardTail?: RecoveredDiscardTail; @@ -237,6 +257,7 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { localMaxBatchSizeBytes?: number, private readonly backgroundStoreAndForward = false, private readonly orphanStoreAndForward = false, + orphanDurableAckMismatchMaxDurationMs = DEFAULT_ORPHAN_DURABLE_ACK_MISMATCH_MAX_DURATION_MS, catchUpCapGapMinEscalationWindowMs = DEFAULT_CATCH_UP_CAP_GAP_MIN_ESCALATION_WINDOW_MS, connectionListenerInboxCapacity = 64, errorInboxCapacity = 256, @@ -258,6 +279,8 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { reconnectOptions.poisonMinEscalationWindowMs ?? 5_000; this.catchUpCapGapMinEscalationWindowMs = catchUpCapGapMinEscalationWindowMs; + this.orphanDurableAckMismatchMaxDurationMs = + orphanDurableAckMismatchMaxDurationMs; if (reconnectOptions.onEvent) { this.connectionDispatcher = new QwpNotificationDispatcher( reconnectOptions.onEvent, @@ -318,6 +341,7 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { ? QWP_INITIAL_CONNECT_MODE.ASYNC : QWP_INITIAL_CONNECT_MODE.SYNC, orphanStoreAndForward = false, + orphanDurableAckMismatchMaxDurationMs = DEFAULT_ORPHAN_DURABLE_ACK_MISMATCH_MAX_DURATION_MS, catchUpCapGapMinEscalationWindowMs = DEFAULT_CATCH_UP_CAP_GAP_MIN_ESCALATION_WINDOW_MS, initialConnection?: Promise, connectionListenerInboxCapacity = 64, @@ -355,6 +379,7 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { localMaxBatchSizeBytes, backgroundStoreAndForward, orphanStoreAndForward, + orphanDurableAckMismatchMaxDurationMs, catchUpCapGapMinEscalationWindowMs, connectionListenerInboxCapacity, errorInboxCapacity, @@ -577,6 +602,7 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { let attempt = 0; let backoffMs = this.initialBackoffMs; let lastError = initialCause; + let primaryUnavailableAttempts = 0; if (reconnecting) { this.emitEvent({ kind: QWP_RECONNECT_EVENT_KIND.RECONNECTING, @@ -618,6 +644,7 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { if (this.closing) throw new QwpSendClosedError(); this.install(candidate, replayed); this.resetCatchUpCapGapEpisode(); + this.resetDurableAckMismatchEpisode(); this.connectingCandidate = undefined; if (reconnecting) { this.totalReconnectsSucceeded++; @@ -668,7 +695,38 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { ) { throw capGapError.error; } - if (!this.isRetryableReconnectError(error)) throw error; + const durableAckMismatch = durableAckUnavailableCause(error); + if ( + durableAckMismatch && + (!this.backgroundStoreAndForward || attemptPolicy !== "unbounded") + ) { + this.resetDurableAckMismatchEpisode(); + throw durableAckMismatch; + } + const durableAckPolicy = + durableAckMismatch && + this.backgroundStoreAndForward && + attemptPolicy === "unbounded" + ? this.applyDurableAckMismatchPolicy(durableAckMismatch) + : undefined; + if (!durableAckPolicy) this.resetDurableAckMismatchEpisode(); + if (durableAckPolicy?.exhausted) throw durableAckPolicy.error; + if ( + this.orphanStoreAndForward && + attemptPolicy === "unbounded" && + isPrimaryUnavailableError(error) + ) { + primaryUnavailableAttempts++; + this.emitEvent({ + kind: QWP_RECONNECT_EVENT_KIND.PRIMARY_UNAVAILABLE, + attempt: primaryUnavailableAttempts, + previousEndpoint, + cause: error, + }); + } + if (!durableAckPolicy && !this.isRetryableReconnectError(error)) { + throw error; + } const attemptsExhausted = attemptPolicy === "single" || (attemptPolicy === "configured" && @@ -725,6 +783,54 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { this.catchUpCapGapFirstMs = 0; } + private applyDurableAckMismatchPolicy(error: QwpDurableAckUnavailableError): { + exhausted: boolean; + error: QwpDurableAckUnavailableError | QwpDurableAckPersistentFailureError; + } { + const now = monotonicNowMs(); + if (this.durableAckMismatchAttempts === 0) { + this.durableAckMismatchFirstMs = now; + } + this.durableAckMismatchAttempts++; + const episodeMs = Math.max(0, now - this.durableAckMismatchFirstMs); + const durationExhausted = + this.orphanDurableAckMismatchMaxDurationMs > 0 && + episodeMs >= this.orphanDurableAckMismatchMaxDurationMs; + const exhausted = + this.orphanStoreAndForward && + (this.durableAckMismatchAttempts >= + MAX_ORPHAN_DURABLE_ACK_MISMATCH_ATTEMPTS || + durationExhausted); + if (exhausted) { + const persistent = new QwpDurableAckPersistentFailureError( + this.durableAckMismatchAttempts, + episodeMs, + error, + ); + this.emitEvent({ + kind: QWP_RECONNECT_EVENT_KIND.DURABLE_ACK_PERSISTENT_FAILURE, + attempt: this.durableAckMismatchAttempts, + previousEndpoint: this.lastEndpoint, + cause: persistent, + episodeMs, + }); + return { exhausted: true, error: persistent }; + } + this.emitEvent({ + kind: QWP_RECONNECT_EVENT_KIND.DURABLE_ACK_UNAVAILABLE, + attempt: this.durableAckMismatchAttempts, + previousEndpoint: this.lastEndpoint, + cause: error, + episodeMs, + }); + return { exhausted: false, error }; + } + + private resetDurableAckMismatchEpisode(): void { + this.durableAckMismatchAttempts = 0; + this.durableAckMismatchFirstMs = 0; + } + private isRetryableReconnectError(error: unknown): boolean { if ( this.backgroundStoreAndForward && @@ -1657,6 +1763,32 @@ function isEndpointPolicyFailure(error: unknown): boolean { ); } +/** Returns the typed capability gap retained anywhere in a failed endpoint sweep. */ +function durableAckUnavailableCause( + error: unknown, +): QwpDurableAckUnavailableError | undefined { + if (error instanceof QwpDurableAckUnavailableError) return error; + if (!(error instanceof QwpFailoverError) || error.attempts.length === 0) { + return undefined; + } + for (const attempt of error.attempts) { + const cause = durableAckUnavailableCause(attempt.error); + if (cause) return cause; + } + return undefined; +} + +function isPrimaryUnavailableError(error: unknown): boolean { + if (error instanceof QwpUpgradeError) { + return error.kind === QWP_UPGRADE_ERROR_KIND.ROLE_REJECTED; + } + return ( + error instanceof QwpFailoverError && + error.attempts.length > 0 && + error.attempts.every((attempt) => isPrimaryUnavailableError(attempt.error)) + ); +} + function reconnectDelayMs(error: unknown): number { return error instanceof RetriableIngressNackError || error instanceof RetriableIngressConnectionError diff --git a/src/qwp/node.ts b/src/qwp/node.ts index 385e694..6024723 100644 --- a/src/qwp/node.ts +++ b/src/qwp/node.ts @@ -30,6 +30,7 @@ import { QwpEgressRoutingOptions, QwpHandshakeMetadata, QwpInitialConnectMode, + type QwpReconnectEvent, QwpUnrecoverableReplayDictionaryError, QwpUpgradeError, QwpWebSocketConnectOptions, @@ -258,7 +259,10 @@ export interface QwpNodeStoreAndForwardOptions maxBackgroundDrainers?: number; /** Rescan cadence; zero scans only at startup. Defaults to 30 seconds. */ orphanScanIntervalMs?: number; - /** Receives isolated scanner and drainer lifecycle notifications. */ + /** + * Receives isolated scanner, drainer, durable-ACK capability-gap, and + * primary-unavailable lifecycle notifications. + */ onOrphanDrainEvent?: (event: QwpNodeOrphanDrainEvent) => void; /** * Receives a data-loss notification when corrupt foreground replay bytes are @@ -1011,7 +1015,7 @@ function createNodeOrphanDrainer( onSenderError: sessionOptions.onSenderError, eventInboxCapacity: sessionOptions.connectionListenerInboxCapacity, errorInboxCapacity: sessionOptions.errorInboxCapacity, - createSession: (directory) => + createSession: (directory, onReconnectEvent) => connectQwpNodeIngressInternal( { ...options, @@ -1025,7 +1029,7 @@ function createNodeOrphanDrainer( initialConnectMode: QWP_INITIAL_CONNECT_MODE.ASYNC, }, }, - orphanIngressSessionOptions(sessionOptions), + orphanIngressSessionOptions(sessionOptions, onReconnectEvent), false, ), }); @@ -1033,21 +1037,41 @@ function createNodeOrphanDrainer( function orphanIngressSessionOptions( options: QwpIngressSessionOptions, + onReconnectEvent?: (event: QwpReconnectEvent) => void, ): QwpIngressSessionOptions { + const configuredReconnect = + options.reconnect === false ? undefined : options.reconnect; + const configuredOnEvent = configuredReconnect?.onEvent; return { ...options, // No foreground caller remains to retry orphan bytes, so transport // outages stay retryable for the drainer's lifetime. Authentication, // protocol, and poison-frame failures remain terminal and quarantined. reconnect: { - ...options.reconnect, + ...configuredReconnect, maxAttempts: 0, maxDurationMs: 0, + onEvent: (event) => { + try { + configuredOnEvent?.(event); + } catch { + // Reconnect observers cannot interrupt orphan recovery. + } + try { + onReconnectEvent?.(event); + } catch { + // Orphan lifecycle observers use their own bounded dispatcher. + } + }, }, replayStore: undefined, backgroundStoreAndForward: undefined, initialConnectMode: undefined, orphanStoreAndForward: true, + orphanDurableAckMismatchMaxDurationMs: + options.orphanDurableAckMismatchMaxDurationMs ?? + configuredReconnect?.maxDurationMs ?? + 300_000, onResponse: undefined, onDurableAck: undefined, onProgress: undefined, diff --git a/src/qwp/transport.ts b/src/qwp/transport.ts index 1fdb27e..b16ddf0 100644 --- a/src/qwp/transport.ts +++ b/src/qwp/transport.ts @@ -201,6 +201,12 @@ export const QWP_RECONNECT_EVENT_KIND = { ATTEMPT_FAILED: "attempt-failed", RECONNECTED: "reconnected", FAILED_OVER: "failed-over", + /** An unbounded SF loop is waiting for durable-ACK-capable endpoints. */ + DURABLE_ACK_UNAVAILABLE: "durable-ack-unavailable", + /** An orphan exhausted its consecutive durable-ACK mismatch budget. */ + DURABLE_ACK_PERSISTENT_FAILURE: "durable-ack-persistent-failure", + /** Every reachable ingress endpoint is temporarily unable to be primary. */ + PRIMARY_UNAVAILABLE: "primary-unavailable", } as const; export type QwpReconnectEventKind = @@ -214,6 +220,8 @@ export interface QwpReconnectEvent { readonly endpoint?: string | URL; readonly previousEndpoint?: string | URL; readonly cause?: unknown; + /** Elapsed time in the current consecutive capability-gap episode. */ + readonly episodeMs?: number; } /** diff --git a/test/qwp/orphan-drainer.test.ts b/test/qwp/orphan-drainer.test.ts index f82cc4c..976a50f 100644 --- a/test/qwp/orphan-drainer.test.ts +++ b/test/qwp/orphan-drainer.test.ts @@ -12,6 +12,7 @@ import { type QwpNodeOrphanDrainSession, } from "../../src/qwp/node"; import { + QWP_RECONNECT_EVENT_KIND, QWP_SENDER_ERROR_CATEGORY, QWP_SENDER_ERROR_POLICY, type QwpSenderError, @@ -173,6 +174,74 @@ describe("QWP Node orphan drainer", () => { await drainer.close(); }); + it("forwards durable-ACK and primary-unavailable reconnect events", async () => { + const rootDirectory = await root(); + const directory = await recordSlot(rootDirectory, "rolling-upgrade"); + const events: Array<{ + kind: string; + directory?: string; + attempt?: number; + episodeMs?: number; + }> = []; + const drainer = new QwpNodeOrphanDrainer({ + rootDirectory, + scanIntervalMs: 0, + durableAckPollIntervalMs: 1, + createSession: async (_directory, onReconnectEvent) => { + onReconnectEvent?.({ + kind: QWP_RECONNECT_EVENT_KIND.DURABLE_ACK_UNAVAILABLE, + attempt: 3, + timestampMs: Date.now(), + episodeMs: 25, + }); + onReconnectEvent?.({ + kind: QWP_RECONNECT_EVENT_KIND.PRIMARY_UNAVAILABLE, + attempt: 2, + timestampMs: Date.now(), + }); + onReconnectEvent?.({ + kind: QWP_RECONNECT_EVENT_KIND.DURABLE_ACK_PERSISTENT_FAILURE, + attempt: 16, + timestampMs: Date.now(), + episodeMs: 300_000, + cause: new Error("durable ACK remained unavailable"), + }); + return new FakeDrainSession(); + }, + onEvent: (event) => events.push(event), + }); + + drainer.start(); + await vi.waitFor(() => expect(drainer.metrics.drained).toBe(1)); + await vi.waitFor(() => + expect(events).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: QWP_ORPHAN_DRAIN_EVENT_KIND.DURABLE_ACK_UNAVAILABLE, + directory, + attempt: 3, + episodeMs: 25, + }), + expect.objectContaining({ + kind: QWP_ORPHAN_DRAIN_EVENT_KIND.PRIMARY_UNAVAILABLE, + directory, + attempt: 2, + }), + expect.objectContaining({ + kind: QWP_ORPHAN_DRAIN_EVENT_KIND.DURABLE_ACK_PERSISTENT_FAILURE, + directory, + attempt: 16, + episodeMs: 300_000, + error: expect.objectContaining({ + message: "durable ACK remained unavailable", + }), + }), + ]), + ), + ); + await drainer.close(); + }); + it("skips live locked slots without quarantining them", async () => { const rootDirectory = await root(); const directory = await recordSlot(rootDirectory, "live"); diff --git a/test/qwp/reconnect.test.ts b/test/qwp/reconnect.test.ts index 030339a..bd7d02f 100644 --- a/test/qwp/reconnect.test.ts +++ b/test/qwp/reconnect.test.ts @@ -39,6 +39,8 @@ import { QwpBinaryConnection, QwpByteWriter, QwpConnectionCloseInfo, + QwpDurableAckUnavailableError, + QwpFailoverError, type QwpSenderError, QwpEgressSession, QwpEgressSessionClosedError, @@ -769,6 +771,341 @@ describe("QWP ingress reconnect and replay", () => { await session.close(); }); + it("keeps durable-ACK mismatch fail-fast for blocking SF startup", async () => { + for (const initialConnectMode of ["off", "sync"] as const) { + let factoryCalls = 0; + await expect( + QwpIngressSession.connect( + async () => { + factoryCalls++; + throw new QwpDurableAckUnavailableError("ws://primary/write/v4"); + }, + { + backgroundStoreAndForward: true, + initialConnectMode, + reconnect: { + maxAttempts: 5, + initialBackoffMs: 0, + maxBackoffMs: 0, + }, + replayStore: new TrackingReplayStore(), + }, + ), + ).rejects.toBeInstanceOf(QwpDurableAckUnavailableError); + expect(factoryCalls).toBe(1); + } + }); + + it("preserves durable-ACK mismatch priority across a mixed endpoint sweep", async () => { + let factoryCalls = 0; + await expect( + QwpIngressSession.connect( + async () => { + factoryCalls++; + throw new QwpFailoverError([ + { + endpoint: "ws://old-primary/write/v4", + error: new QwpDurableAckUnavailableError( + "ws://old-primary/write/v4", + ), + }, + { + endpoint: "ws://offline/write/v4", + error: new Error("connection refused"), + }, + ]); + }, + { + backgroundStoreAndForward: true, + initialConnectMode: "sync", + reconnect: { + maxAttempts: 5, + initialBackoffMs: 0, + maxBackoffMs: 0, + }, + replayStore: new TrackingReplayStore(), + }, + ), + ).rejects.toBeInstanceOf(QwpDurableAckUnavailableError); + expect(factoryCalls).toBe(1); + }); + + it("retries durable-ACK mismatch during asynchronous foreground startup", async () => { + const connection = new FakeConnection("primary", { + qwpVersion: 1, + durableAckEnabled: true, + }); + const events: QwpReconnectEvent[] = []; + let factoryCalls = 0; + const session = await QwpIngressSession.connect( + async () => { + factoryCalls++; + if (factoryCalls <= 2) { + throw new QwpDurableAckUnavailableError("ws://primary/write/v4"); + } + return connection; + }, + { + backgroundStoreAndForward: true, + initialConnectMode: "async", + reconnect: { + maxAttempts: 1, + initialBackoffMs: 0, + maxBackoffMs: 0, + onEvent: (event) => events.push(event), + }, + replayStore: new TrackingReplayStore(), + }, + ); + + await session.publishFrame(Uint8Array.of(7)); + await vi.waitFor(() => expect(connection.sent).toEqual([Uint8Array.of(7)])); + await vi.waitFor(() => + expect( + events + .filter( + (event) => + event.kind === QWP_RECONNECT_EVENT_KIND.DURABLE_ACK_UNAVAILABLE, + ) + .map((event) => event.attempt), + ).toEqual([1, 2]), + ); + expect( + events.some( + (event) => + event.kind === + QWP_RECONNECT_EVENT_KIND.DURABLE_ACK_PERSISTENT_FAILURE, + ), + ).toBe(false); + await session.close(); + }); + + it("bounds consecutive orphan durable-ACK mismatch episodes", async () => { + const events: QwpReconnectEvent[] = []; + let factoryCalls = 0; + const session = await QwpIngressSession.connect( + async () => { + factoryCalls++; + throw new QwpDurableAckUnavailableError("ws://primary/write/v4"); + }, + { + backgroundStoreAndForward: true, + initialConnectMode: "async", + orphanStoreAndForward: true, + orphanDurableAckMismatchMaxDurationMs: 0, + reconnect: { + initialBackoffMs: 0, + maxBackoffMs: 0, + onEvent: (event) => events.push(event), + }, + replayStore: new TrackingReplayStore(), + }, + ); + + await session.closed; + await vi.waitFor(() => + expect( + events.filter( + (event) => + event.kind === + QWP_RECONNECT_EVENT_KIND.DURABLE_ACK_PERSISTENT_FAILURE, + ), + ).toHaveLength(1), + ); + const unavailable = events.filter( + (event) => + event.kind === QWP_RECONNECT_EVENT_KIND.DURABLE_ACK_UNAVAILABLE, + ); + expect(factoryCalls).toBe(16); + expect(unavailable).toHaveLength(15); + expect(unavailable.map((event) => event.attempt)).toEqual( + Array.from({ length: 15 }, (_, index) => index + 1), + ); + expect(session.metrics.lastError).toMatchObject({ + name: "QwpDurableAckPersistentFailureError", + attempts: 16, + }); + await session.close(); + }); + + it("bounds an orphan durable-ACK mismatch episode by duration", async () => { + const events: QwpReconnectEvent[] = []; + let factoryCalls = 0; + const session = await QwpIngressSession.connect( + async () => { + factoryCalls++; + await new Promise((resolve) => setTimeout(resolve, 5)); + throw new QwpDurableAckUnavailableError("ws://primary/write/v4"); + }, + { + backgroundStoreAndForward: true, + initialConnectMode: "async", + orphanStoreAndForward: true, + orphanDurableAckMismatchMaxDurationMs: 1, + reconnect: { + initialBackoffMs: 0, + maxBackoffMs: 0, + onEvent: (event) => events.push(event), + }, + replayStore: new TrackingReplayStore(), + }, + ); + + await session.closed; + await vi.waitFor(() => + expect( + events.filter( + (event) => + event.kind === + QWP_RECONNECT_EVENT_KIND.DURABLE_ACK_PERSISTENT_FAILURE, + ), + ).toHaveLength(1), + ); + expect(factoryCalls).toBeGreaterThanOrEqual(2); + expect(factoryCalls).toBeLessThan(16); + expect(session.metrics.lastError).toMatchObject({ + name: "QwpDurableAckPersistentFailureError", + attempts: factoryCalls, + }); + await session.close(); + }); + + it("resets an orphan durable-ACK episode after primary unavailability", async () => { + const connection = new FakeConnection("primary", { + qwpVersion: 1, + durableAckEnabled: true, + }); + const events: QwpReconnectEvent[] = []; + let factoryCalls = 0; + const session = await QwpIngressSession.connect( + async () => { + factoryCalls++; + if (factoryCalls <= 15 || (factoryCalls >= 17 && factoryCalls <= 31)) { + throw new QwpDurableAckUnavailableError("ws://primary/write/v4"); + } + if (factoryCalls === 16) { + throw new QwpUpgradeError("all endpoints are replicas", { + kind: QWP_UPGRADE_ERROR_KIND.ROLE_REJECTED, + retryable: true, + tryNextEndpoint: true, + serverRole: "REPLICA", + }); + } + return connection; + }, + { + backgroundStoreAndForward: true, + initialConnectMode: "async", + orphanStoreAndForward: true, + orphanDurableAckMismatchMaxDurationMs: 0, + reconnect: { + initialBackoffMs: 0, + maxBackoffMs: 0, + onEvent: (event) => events.push(event), + }, + replayStore: new TrackingReplayStore(), + }, + ); + + await vi.waitFor(() => expect(factoryCalls).toBe(32)); + await vi.waitFor(() => + expect( + events.filter( + (event) => + event.kind === QWP_RECONNECT_EVENT_KIND.PRIMARY_UNAVAILABLE, + ), + ).toHaveLength(1), + ); + await vi.waitFor(() => + expect( + events.filter( + (event) => + event.kind === QWP_RECONNECT_EVENT_KIND.DURABLE_ACK_UNAVAILABLE, + ), + ).toHaveLength(30), + ); + const unavailableAttempts = events + .filter( + (event) => + event.kind === QWP_RECONNECT_EVENT_KIND.DURABLE_ACK_UNAVAILABLE, + ) + .map((event) => event.attempt); + expect(unavailableAttempts).toEqual([ + ...Array.from({ length: 15 }, (_, index) => index + 1), + ...Array.from({ length: 15 }, (_, index) => index + 1), + ]); + expect( + events.some( + (event) => + event.kind === + QWP_RECONNECT_EVENT_KIND.DURABLE_ACK_PERSISTENT_FAILURE, + ), + ).toBe(false); + await session.close(); + }); + + it("resets an orphan durable-ACK episode after a transport outage", async () => { + const connection = new FakeConnection("primary", { + qwpVersion: 1, + durableAckEnabled: true, + }); + const events: QwpReconnectEvent[] = []; + let factoryCalls = 0; + const session = await QwpIngressSession.connect( + async () => { + factoryCalls++; + if (factoryCalls <= 15 || (factoryCalls >= 17 && factoryCalls <= 31)) { + throw new QwpDurableAckUnavailableError("ws://primary/write/v4"); + } + if (factoryCalls === 16) { + throw new Error("cluster temporarily unreachable"); + } + return connection; + }, + { + backgroundStoreAndForward: true, + initialConnectMode: "async", + orphanStoreAndForward: true, + orphanDurableAckMismatchMaxDurationMs: 0, + reconnect: { + initialBackoffMs: 0, + maxBackoffMs: 0, + onEvent: (event) => events.push(event), + }, + replayStore: new TrackingReplayStore(), + }, + ); + + await vi.waitFor(() => expect(factoryCalls).toBe(32)); + await vi.waitFor(() => + expect( + events.filter( + (event) => + event.kind === QWP_RECONNECT_EVENT_KIND.DURABLE_ACK_UNAVAILABLE, + ), + ).toHaveLength(30), + ); + expect( + events + .filter( + (event) => + event.kind === QWP_RECONNECT_EVENT_KIND.DURABLE_ACK_UNAVAILABLE, + ) + .map((event) => event.attempt), + ).toEqual([ + ...Array.from({ length: 15 }, (_, index) => index + 1), + ...Array.from({ length: 15 }, (_, index) => index + 1), + ]); + expect( + events.some( + (event) => + event.kind === + QWP_RECONNECT_EVENT_KIND.DURABLE_ACK_PERSISTENT_FAILURE, + ), + ).toBe(false); + await session.close(); + }); + it("retries endpoint-policy failures forever after foreground SF connected once", async () => { const first = new FakeConnection("primary"); const replacement = new FakeConnection("primary"); From 2d834ffc0e1fa6c4ab32221638776da33aafcf3c Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 19 Aug 2026 00:36:27 +0100 Subject: [PATCH 069/265] fix(qwp): rebuild recoverable symbol sidecars --- QWP.md | 10 +- src/qwp-node/file-replay-store.ts | 110 +++++++++++++++++- .../reconnecting-ingress-connection.ts | 99 ++++++++++++---- src/qwp/transport.ts | 5 + test/qwp/node-transport.test.ts | 61 ++++++++++ test/qwp/reconnect.test.ts | 77 ++++++++++++ 6 files changed, 337 insertions(+), 25 deletions(-) diff --git a/QWP.md b/QWP.md index 790602b..1e7615a 100644 --- a/QWP.md +++ b/QWP.md @@ -218,10 +218,12 @@ require an offline migration. On startup, a dictionary sidecar truncated at a complete-block boundary is rebuilt from the ordered symbol deltas embedded in surviving committed frames and healed -before replay. If the frame journal is structurally corrupt, or the surviving deltas -contain a dictionary gap or conflict that cannot be reconstructed, the foreground -slot is renamed to `.unreplayable-N`, marked with `.qwp.failed`, and preserved -for inspection. The sender then starts once with a clean slot at the configured path. +before replay. A corrupt or stale dictionary sidecar is replaced when those committed +frames independently reconstruct a complete dense dictionary from ID zero. If the +frame journal is structurally corrupt, or the surviving deltas contain a dictionary +gap or conflict that cannot be reconstructed, the foreground slot is renamed to +`.unreplayable-N`, marked with `.qwp.failed`, and preserved for inspection. The +sender then starts once with a clean slot at the configured path. `onRecoveryQuarantine` receives the original and quarantine paths plus the terminal cause and a typed `senderError`. The shared `onSenderError` callback receives the same `data-loss` / `abandoned` verdict and its `quarantinedPath`. This build-time recovery diff --git a/src/qwp-node/file-replay-store.ts b/src/qwp-node/file-replay-store.ts index ef57514..d91f8d0 100644 --- a/src/qwp-node/file-replay-store.ts +++ b/src/qwp-node/file-replay-store.ts @@ -276,6 +276,7 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { private operationTail: Promise = Promise.resolve(); private totalBytes = 0; private dictionaryFileSize = 0; + private dictionaryLoadError?: unknown; private acknowledgedThrough = -1n; private dictionaryDirty = false; private acknowledgementDirty = false; @@ -518,7 +519,17 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { if (this.totalBytes > this.maxBytes) { throw new QwpReplayStoreFullError(this.maxBytes, this.totalBytes); } - await this.loadDictionaryFile(); + try { + await this.loadDictionaryFile(); + } catch (error) { + // Frame recovery decides whether this sidecar is load-bearing. Keep + // the file untouched until the ordered committed-frame scan either + // reconstructs it completely or rejects the slot as unreplayable. + this.symbols.length = 0; + this.symbolValues.clear(); + this.dictionaryFileSize = 0; + this.dictionaryLoadError = error; + } this.loaded = true; loadSucceeded = true; this.scheduleCheckpoint(); @@ -611,6 +622,7 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { if (this.closing || this.closed) return Promise.reject(this.closedError()); return this.enqueue(async () => { this.assertReady(); + if (this.dictionaryLoadError) throw this.dictionaryLoadError; return this.symbols.slice(); }); } @@ -622,6 +634,7 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { if (this.closing || this.closed) return Promise.reject(this.closedError()); return this.enqueue(async () => { this.assertReady(); + if (this.dictionaryLoadError) throw this.dictionaryLoadError; if (startId !== this.symbols.length) { throw new QwpReplayStoreError( `QWP symbol dictionary is not dense [expected=${this.symbols.length}, received=${startId}]`, @@ -706,6 +719,79 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { }); } + replaceSymbolDictionary(entries: readonly string[]): Promise { + if (this.closing || this.closed) return Promise.reject(this.closedError()); + return this.enqueue(async () => { + this.assertReady(); + validateReplacementDictionary(entries); + const finalPath = join(this.directory, DICTIONARY_FILE); + const previousSize = this.dictionaryFileSize; + if (entries.length === 0) { + try { + await ignoreMissing(unlink(finalPath)); + if (this.durability === QWP_SF_DURABILITY.APPEND) { + await syncDirectory(this.directory); + } else if (this.durability === QWP_SF_DURABILITY.PERIODIC) { + this.directoryDirty = true; + } + } catch (error) { + throw new QwpReplayStoreError( + "could not remove unusable QWP symbol dictionary", + error, + ); + } + this.symbols.length = 0; + this.symbolValues.clear(); + this.totalBytes -= previousSize; + this.dictionaryFileSize = 0; + this.dictionaryLoadError = undefined; + this.dictionaryDirty = false; + return; + } + + const replacement = Buffer.concat([ + encodeDictionaryHeader(), + encodeDictionaryBlock(0, entries), + ]); + const temporaryPath = join( + this.directory, + `${DICTIONARY_FILE}${TEMP_MARKER}${process.pid}-${randomUUID()}`, + ); + try { + const file = await open(temporaryPath, "wx", 0o600); + try { + await file.writeFile(replacement); + if (this.durability === QWP_SF_DURABILITY.APPEND) { + await file.sync(); + } + } finally { + await file.close(); + } + await ignoreMissing(unlink(finalPath)); + await rename(temporaryPath, finalPath); + if (this.durability === QWP_SF_DURABILITY.APPEND) { + await syncDirectory(this.directory); + } else if (this.durability === QWP_SF_DURABILITY.PERIODIC) { + this.dictionaryDirty = true; + this.directoryDirty = true; + } + } catch (error) { + await ignoreMissing(unlink(temporaryPath)); + throw new QwpReplayStoreError( + "could not replace unusable QWP symbol dictionary", + error, + ); + } + this.symbols.length = 0; + this.symbols.push(...entries); + this.symbolValues.clear(); + for (const entry of entries) this.symbolValues.add(entry); + this.totalBytes = this.totalBytes - previousSize + replacement.byteLength; + this.dictionaryFileSize = replacement.byteLength; + this.dictionaryLoadError = undefined; + }); + } + close(): Promise { if (this.closePromise) return this.closePromise; this.closing = true; @@ -1503,6 +1589,28 @@ function encodeDictionaryBlock( return block; } +function validateReplacementDictionary(entries: readonly string[]): void { + if (entries.length > QWP_MAX_SYMBOL_DICTIONARY_SIZE) { + throw new QwpReplayStoreError( + `QWP symbol dictionary exceeds maximum size ${QWP_MAX_SYMBOL_DICTIONARY_SIZE}`, + ); + } + const values = new Set(); + for (const entry of entries) { + if (typeof entry !== "string") { + throw new QwpReplayStoreError( + "QWP symbol dictionary values must be strings", + ); + } + if (values.has(entry)) { + throw new QwpReplayStoreError( + `QWP symbol dictionary contains a duplicate value: '${entry}'`, + ); + } + values.add(entry); + } +} + function corruptDictionary(reason: string): QwpReplayStoreCorruptionError { return new QwpReplayStoreCorruptionError( `corrupt QWP symbol dictionary: ${reason}`, diff --git a/src/qwp/internal/reconnecting-ingress-connection.ts b/src/qwp/internal/reconnecting-ingress-connection.ts index d49abf5..2e20b28 100644 --- a/src/qwp/internal/reconnecting-ingress-connection.ts +++ b/src/qwp/internal/reconnecting-ingress-connection.ts @@ -348,7 +348,8 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { errorInboxCapacity = 256, onSenderError?: (error: QwpSenderError) => void, ): Promise { - const store = replayStore ?? new QwpMemoryReplayStore(); + const store: QwpIngressReplayStore = + replayStore ?? new QwpMemoryReplayStore(); let connection: QwpReconnectingIngressConnection | undefined; try { const records = await store.load(); @@ -359,15 +360,23 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { ? 1 : 0, ); - const persistedSymbolDictionary = store.loadSymbolDictionary - ? await store.loadSymbolDictionary() - : []; + let persistedSymbolDictionary: readonly string[] = []; + let persistedSymbolDictionaryFailure: unknown; + if (store.loadSymbolDictionary) { + try { + persistedSymbolDictionary = await store.loadSymbolDictionary(); + } catch (error) { + if (!store.replaceSymbolDictionary) throw error; + persistedSymbolDictionaryFailure = error; + } + } const recoveredDiscardTail = analyzeRecoveredDiscardTail(sortedRecords); const symbolDictionary = await recoverSymbolDictionary( sortedRecords, persistedSymbolDictionary, recoveredDiscardTail, store, + persistedSymbolDictionaryFailure, ); connection = new QwpReconnectingIngressConnection( factory, @@ -1569,13 +1578,73 @@ async function recoverSymbolDictionary( persistedDictionary: readonly string[], discardTail: RecoveredDiscardTail | undefined, store: QwpIngressReplayStore, + persistedDictionaryFailure?: unknown, ): Promise { const hasDictionaryPersistence = store.loadSymbolDictionary !== undefined && store.appendSymbolDictionary !== undefined; - const dictionary = [...persistedDictionary]; + let recoveredFromPersisted = true; + let dictionary: string[]; + try { + dictionary = reconstructSymbolDictionary( + records, + persistedDictionary, + discardTail, + hasDictionaryPersistence, + persistedDictionaryFailure, + ); + } catch (error) { + if (!store.replaceSymbolDictionary || persistedDictionary.length === 0) { + throw error; + } + // A structurally valid sidecar can still belong to an older dictionary + // generation. Only discard it when the committed frames independently + // reconstruct a complete dense dictionary from ID zero. + dictionary = reconstructSymbolDictionary( + records, + [], + discardTail, + hasDictionaryPersistence, + error, + ); + recoveredFromPersisted = false; + } + const replacePersistedDictionary = + persistedDictionaryFailure !== undefined || !recoveredFromPersisted; + if (replacePersistedDictionary) { + try { + await store.replaceSymbolDictionary!(dictionary); + } catch (error) { + throw new QwpReplayDictionaryError( + "could not replace the unusable QWP symbol dictionary from surviving frame deltas", + error, + ); + } + } else if (dictionary.length > persistedDictionary.length) { + try { + await store.appendSymbolDictionary!( + persistedDictionary.length, + dictionary.slice(persistedDictionary.length), + ); + } catch (error) { + throw new QwpReplayDictionaryError( + "could not heal the recovered QWP symbol dictionary from surviving frame deltas", + error, + ); + } + } + return dictionary; +} + +function reconstructSymbolDictionary( + records: readonly QwpIngressReplayRecord[], + baseline: readonly string[], + discardTail: RecoveredDiscardTail | undefined, + hasDictionaryPersistence: boolean, + recoveryCause?: unknown, +): string[] { + const dictionary = [...baseline]; const dictionaryIds = new Map(dictionary.map((entry, id) => [entry, id])); - const persistedSize = dictionary.length; for (const record of records) { // A wholly deferred recovery tail is retired locally and never replayed. // Its dictionary additions therefore cannot make a committed prefix safe. @@ -1591,7 +1660,7 @@ async function recoverSymbolDictionary( } catch (error) { throw new QwpUnrecoverableReplayDictionaryError( `persisted QWP frame contains an invalid symbol dictionary delta [sequence=${record.frameSequence}]`, - error, + recoveryCause ?? error, ); } if (!delta) continue; @@ -1603,6 +1672,7 @@ async function recoverSymbolDictionary( if (delta.startId > dictionary.length) { throw new QwpUnrecoverableReplayDictionaryError( `persisted QWP frame references a symbol dictionary gap that cannot be reconstructed [startId=${delta.startId}, dictionarySize=${dictionary.length}]`, + recoveryCause, ); } delta.entries.forEach((entry, index) => { @@ -1611,6 +1681,7 @@ async function recoverSymbolDictionary( if (existing !== undefined && existing !== entry) { throw new QwpUnrecoverableReplayDictionaryError( `persisted QWP frame conflicts with symbol dictionary at ID ${id}`, + recoveryCause, ); } if (id === dictionary.length) { @@ -1618,6 +1689,7 @@ async function recoverSymbolDictionary( if (duplicateId !== undefined) { throw new QwpUnrecoverableReplayDictionaryError( `persisted QWP frame assigns symbol dictionary value ${JSON.stringify(entry)} to both ID ${duplicateId} and ID ${id}`, + recoveryCause, ); } dictionary.push(entry); @@ -1625,19 +1697,6 @@ async function recoverSymbolDictionary( } }); } - if (dictionary.length > persistedSize) { - try { - await store.appendSymbolDictionary!( - persistedSize, - dictionary.slice(persistedSize), - ); - } catch (error) { - throw new QwpReplayDictionaryError( - "could not heal the recovered QWP symbol dictionary from surviving frame deltas", - error, - ); - } - } return dictionary; } diff --git a/src/qwp/transport.ts b/src/qwp/transport.ts index b16ddf0..7f4d58e 100644 --- a/src/qwp/transport.ts +++ b/src/qwp/transport.ts @@ -168,6 +168,11 @@ export interface QwpIngressReplayStore { startId: number, entries: readonly string[], ): Promise; + /** + * Atomically replaces an unusable dictionary after surviving committed + * frames prove that its complete ID space can be reconstructed. + */ + replaceSymbolDictionary?(entries: readonly string[]): Promise; close(): Promise; } diff --git a/test/qwp/node-transport.test.ts b/test/qwp/node-transport.test.ts index 3c6b513..ffb948d 100644 --- a/test/qwp/node-transport.test.ts +++ b/test/qwp/node-transport.test.ts @@ -12,6 +12,8 @@ import { connectQwpNodeWebSocket, createQwpNodeSender, encodeQwpFrame, + encodeQwpIngressFrame, + QWP_COLUMN_TYPE, QWP_EGRESS_CAPABILITY, QWP_EGRESS_MESSAGE, QWP_SERVER_ROLE, @@ -24,6 +26,8 @@ import { QwpNodeFileReplayStore, QwpReplayStoreCorruptionError, QwpReplayStoreQuarantinedError, + QwpSymbolDictionary, + QwpTableBuffer, QwpUpgradeError, type QwpSenderError, writeQwpVarint, @@ -503,6 +507,63 @@ describe("QWP Node transport", () => { } }); + it("repairs a corrupt dictionary sidecar instead of quarantining self-contained frames", async () => { + server = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + server.on("headers", (headers) => { + headers.push("X-QWP-Version: 1"); + }); + const received: Uint8Array[] = []; + server.on("connection", (socket) => { + socket.on("message", (payload) => { + received.push(new Uint8Array(payload as Buffer)); + }); + }); + await listen(server); + + const rootDirectory = await mkdtemp(join(tmpdir(), "qwp-node-recovery-")); + const directory = join(rootDirectory, "sender-0"); + const dictionary = new QwpSymbolDictionary(); + const table = new QwpTableBuffer("trades"); + table + .getOrCreateColumn("symbol", QWP_COLUMN_TYPE.SYMBOL)! + .values.push("ETH-USD"); + table.nextRow(); + const replayFrame = encodeQwpIngressFrame([table], { + dictionary, + confirmedMaxSymbolId: -1, + }); + const seed = new QwpNodeFileReplayStore({ directory }); + await seed.load(); + await seed.appendSymbolDictionary(0, dictionary.entriesFrom(0)); + await seed.append({ frameSequence: 0n, payload: replayFrame }); + await seed.close(); + await writeFile(join(directory, "symbols.qwpdict"), Uint8Array.of(0)); + + const quarantined: QwpReplayStoreQuarantinedError[] = []; + const address = server.address() as AddressInfo; + try { + const session = await connectQwpNodeIngress({ + url: `ws://127.0.0.1:${address.port}/write/v4`, + storeAndForward: { + directory, + initialConnectMode: "sync", + onRecoveryQuarantine: (event) => quarantined.push(event.error), + }, + }); + await vi.waitFor(() => expect(received).toHaveLength(2)); + await session.close(); + + expect(quarantined).toEqual([]); + expect(await readdir(rootDirectory)).toEqual(["sender-0"]); + const verify = new QwpNodeFileReplayStore({ directory }); + await expect(verify.load()).resolves.toHaveLength(1); + await expect(verify.loadSymbolDictionary()).resolves.toEqual(["ETH-USD"]); + await verify.close(); + } finally { + await rm(rootDirectory, { recursive: true, force: true }); + } + }); + it("fails over and replays an unacknowledged frame through the public Node API", async () => { const primary = new WebSocketServer({ host: "127.0.0.1", port: 0 }); const secondary = new WebSocketServer({ host: "127.0.0.1", port: 0 }); diff --git a/test/qwp/reconnect.test.ts b/test/qwp/reconnect.test.ts index bd7d02f..80d7075 100644 --- a/test/qwp/reconnect.test.ts +++ b/test/qwp/reconnect.test.ts @@ -2070,6 +2070,83 @@ describe("QWP ingress reconnect and replay", () => { await rm(directory, { recursive: true, force: true }); }); + it.each(["structurally corrupt", "stale but valid"] as const)( + "rebuilds a %s symbol sidecar from self-contained committed frames", + async (failureKind) => { + const directory = await createTemporaryDirectory(); + const dictionary = new QwpSymbolDictionary(); + const replayFrame = encodeQwpIngressFrame([symbolTable("ETH-USD")], { + dictionary, + confirmedMaxSymbolId: -1, + }); + const seed = new QwpNodeFileReplayStore({ directory }); + await seed.load(); + await seed.appendSymbolDictionary( + 0, + failureKind === "stale but valid" + ? ["STALE-SYMBOL"] + : dictionary.entriesFrom(0), + ); + await seed.append({ frameSequence: 5n, payload: replayFrame }); + await seed.close(); + if (failureKind === "structurally corrupt") { + await writeFile(join(directory, "symbols.qwpdict"), Uint8Array.of(0)); + } + + const connection = new FakeConnection("primary"); + const session = await QwpIngressSession.connect(async () => connection, { + reconnect: { maxAttempts: 1 }, + replayStore: new QwpNodeFileReplayStore({ directory }), + }); + expect(connection.sent).toHaveLength(2); + expect(decodeQwpIngressSymbolDictionaryDelta(connection.sent[0])).toEqual( + { + startId: 0, + entries: ["ETH-USD"], + }, + ); + expect(connection.sent[1]).toEqual(replayFrame); + await session.close(); + + const verify = new QwpNodeFileReplayStore({ directory }); + await expect(verify.load()).resolves.toHaveLength(1); + await expect(verify.loadSymbolDictionary()).resolves.toEqual(["ETH-USD"]); + await verify.close(); + await rm(directory, { recursive: true, force: true }); + }, + ); + + it("rejects corrupt sidecar recovery when committed frames are not self-contained", async () => { + const directory = await createTemporaryDirectory(); + const dictionary = new QwpSymbolDictionary(); + dictionary.getOrAdd("ETH-USD"); + const replayFrame = encodeQwpIngressFrame([symbolTable("BTC-USD")], { + dictionary, + confirmedMaxSymbolId: 0, + }); + const seed = new QwpNodeFileReplayStore({ directory }); + await seed.load(); + await seed.appendSymbolDictionary(0, dictionary.entriesFrom(0)); + await seed.append({ frameSequence: 5n, payload: replayFrame }); + await seed.close(); + await writeFile(join(directory, "symbols.qwpdict"), Uint8Array.of(0)); + + await expect( + QwpIngressSession.connect(async () => new FakeConnection("primary"), { + reconnect: { maxAttempts: 1 }, + replayStore: new QwpNodeFileReplayStore({ directory }), + }), + ).rejects.toBeInstanceOf(QwpUnrecoverableReplayDictionaryError); + + const verify = new QwpNodeFileReplayStore({ directory }); + await expect(verify.load()).resolves.toHaveLength(1); + await expect(verify.loadSymbolDictionary()).rejects.toBeInstanceOf( + QwpReplayStoreCorruptionError, + ); + await verify.close(); + await rm(directory, { recursive: true, force: true }); + }); + it("rejects a surviving delta with an unreconstructable dictionary gap", async () => { const directory = await createTemporaryDirectory(); const dictionary = new QwpSymbolDictionary(); From 3b540054e75be9a6c0fd247c0d8fde4816243a75 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 19 Aug 2026 00:47:28 +0100 Subject: [PATCH 070/265] fix(qwp): report asynchronous sender failures --- QWP.md | 8 +++- README.md | 8 ++-- src/qwp-node/orphan-drainer.ts | 11 +++--- src/qwp/ingress-session.ts | 34 ++++++++++++++-- .../reconnecting-ingress-connection.ts | 11 +++--- src/qwp/sender-error.ts | 35 +++++++++++++++++ test/qwp/public-api-contract.ts | 5 +++ test/qwp/public-api.test.ts | 1 + test/qwp/reconnect.test.ts | 3 ++ test/qwp/sender-error.test.ts | 39 ++++++++++++++++++- 10 files changed, 134 insertions(+), 21 deletions(-) diff --git a/QWP.md b/QWP.md index 1e7615a..d473c41 100644 --- a/QWP.md +++ b/QWP.md @@ -574,7 +574,13 @@ application worker. stable `[fromFsn, toFsn]` correlation range, optional single-table attribution, and `quarantinedPath` for abandoned persistent data. The legacy `onError` callback remains available for timeouts and general session failures; classified NACK events also expose -the same payload as `event.senderError`. +the same payload as `event.senderError`. When `onSenderError` is omitted, QWP logs +retriable rejections at `warn` and terminal rejections or abandoned data at `error`. +General asynchronous session failures are likewise logged when `onError` is omitted, +so a background store-and-forward failure is never silent by default. Reconnect and +orphan-drain fallbacks use the same bounded asynchronous error inbox; direct session +fallback logging adds no callback or close-time dependency. Both paths work in browsers +and Node.js. ## Egress diff --git a/README.md b/README.md index a0666d2..6bd0237 100644 --- a/README.md +++ b/README.md @@ -234,9 +234,11 @@ inside ACK or reconnect protocol stacks. The metrics snapshot exposes delivered dropped progress, connection, and error notification counters. `connectionListenerInboxCapacity` and `errorInboxCapacity` tune the Java-compatible 64/256 defaults. `onSenderError` receives typed category/policy, wire status, message -sequence, stable frame-sequence range, and quarantine context. Observer exceptions are -contained, but CPU-bound callbacks should still move work to a Worker because browser -and Node JavaScript share the event loop. +sequence, stable frame-sequence range, and quarantine context. If it is omitted, +retriable rejections are logged at `warn` and terminal rejections or abandoned data at +`error`; general asynchronous ingress failures are also logged when `onError` is +omitted. Observer exceptions are contained, but CPU-bound callbacks should still move +work to a Worker because browser and Node JavaScript share the event loop. When QuestDB authentication is enabled, establish the browser's HttpOnly `qdb_session` cookie over REST before opening a QWP WebSocket. A QuestDB REST diff --git a/src/qwp-node/orphan-drainer.ts b/src/qwp-node/orphan-drainer.ts index d4696e6..c05dd86 100644 --- a/src/qwp-node/orphan-drainer.ts +++ b/src/qwp-node/orphan-drainer.ts @@ -13,6 +13,7 @@ import { import { QwpNotificationDispatcher } from "../qwp/internal/notification-dispatcher"; import { createQwpDataLossSenderError, + defaultQwpSenderErrorHandler, type QwpSenderError, } from "../qwp/sender-error"; @@ -252,12 +253,10 @@ export class QwpNodeOrphanDrainer { DEFAULT_CONNECTION_LISTENER_INBOX_CAPACITY, ); } - if (options.onSenderError) { - this.errorDispatcher = new QwpNotificationDispatcher( - options.onSenderError, - options.errorInboxCapacity ?? DEFAULT_ERROR_INBOX_CAPACITY, - ); - } + this.errorDispatcher = new QwpNotificationDispatcher( + options.onSenderError ?? defaultQwpSenderErrorHandler, + options.errorInboxCapacity ?? DEFAULT_ERROR_INBOX_CAPACITY, + ); } get metrics(): QwpNodeOrphanDrainerMetrics { diff --git a/src/qwp/ingress-session.ts b/src/qwp/ingress-session.ts index 9e97c9c..ab76ed0 100644 --- a/src/qwp/ingress-session.ts +++ b/src/qwp/ingress-session.ts @@ -26,9 +26,11 @@ import { QwpReconnectingIngressConnection } from "./internal/reconnecting-ingres import { QwpNotificationDispatcher } from "./internal/notification-dispatcher"; import { createQwpSenderError, + defaultQwpSenderErrorHandler, QWP_SENDER_ERROR_POLICY, type QwpSenderError, } from "./sender-error"; +import { log } from "../logging"; const QWP_FLAGS_OFFSET = 5; const DEFAULT_CONNECTION_LISTENER_INBOX_CAPACITY = 64; @@ -202,7 +204,11 @@ export interface QwpIngressSessionOptions { * full. Defaults to 256, matching the Java client. */ errorInboxCapacity?: number; - /** Java-parity typed server-rejection and data-loss notifications. */ + /** + * Java-parity typed server-rejection and data-loss notifications. When + * omitted, the default handler logs retriable errors at warn and terminal + * errors or abandoned data at error. + */ onSenderError?: (error: QwpSenderError) => void; onResponse?: (response: QwpIngressResponse) => void; onDurableAck?: (response: QwpIngressResponse) => void; @@ -1372,12 +1378,22 @@ export class QwpIngressSession { senderError, metrics: this.metrics, }; - this.errorDispatcher?.offer(() => { + const notify = (): void => { safelyInvoke(this.options.onError, event); if (senderError && !this.connection.managesIngressSenderErrors) { - safelyInvoke(this.options.onSenderError, senderError); + safelyInvoke( + this.options.onSenderError ?? defaultQwpSenderErrorHandler, + senderError, + ); + } else if (!senderError && !this.options.onError) { + safelyInvoke( + defaultQwpIngressErrorHandler, + Object.freeze({ terminal, error: observed }), + ); } - }); + }; + if (this.errorDispatcher) this.errorDispatcher.offer(notify); + else notify(); return observed; } @@ -1579,3 +1595,13 @@ function safelyInvoke( // Observability callbacks must not break protocol progress. } } + +function defaultQwpIngressErrorHandler(event: { + readonly terminal: boolean; + readonly error: Error; +}): void { + log( + event.terminal ? "error" : "warn", + `QWP ingress ${event.terminal ? "terminated" : "reported an asynchronous failure"} [message=${event.error.message}]`, + ); +} diff --git a/src/qwp/internal/reconnecting-ingress-connection.ts b/src/qwp/internal/reconnecting-ingress-connection.ts index 2e20b28..38db9ec 100644 --- a/src/qwp/internal/reconnecting-ingress-connection.ts +++ b/src/qwp/internal/reconnecting-ingress-connection.ts @@ -42,6 +42,7 @@ import { QwpNotificationDispatcher } from "./notification-dispatcher"; import { createQwpProtocolViolationSenderError, createQwpSenderError, + defaultQwpSenderErrorHandler, type QwpSenderError, } from "../sender-error"; @@ -287,12 +288,10 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { connectionListenerInboxCapacity, ); } - if (onSenderError) { - this.errorDispatcher = new QwpNotificationDispatcher( - onSenderError, - errorInboxCapacity, - ); - } + this.errorDispatcher = new QwpNotificationDispatcher( + onSenderError ?? defaultQwpSenderErrorHandler, + errorInboxCapacity, + ); validateReconnectPolicy( this.maxAttempts, this.initialBackoffMs, diff --git a/src/qwp/sender-error.ts b/src/qwp/sender-error.ts index e6c234f..88ba37d 100644 --- a/src/qwp/sender-error.ts +++ b/src/qwp/sender-error.ts @@ -1,4 +1,5 @@ import { QWP_STATUS, type QwpIngressResponse } from "./core"; +import { log } from "../logging"; export const QWP_SENDER_ERROR_CATEGORY = { SCHEMA_MISMATCH: "schema-mismatch", @@ -51,6 +52,40 @@ export interface QwpSenderErrorResponseContext { readonly detectedAtMs?: number; } +/** + * Browser-safe fallback for asynchronous ingress rejections and abandoned + * persistent data. Applications can replace it with `onSenderError`. + */ +export function defaultQwpSenderErrorHandler(error: QwpSenderError): void { + const level = + error.category === QWP_SENDER_ERROR_CATEGORY.DATA_LOSS || + error.appliedPolicy === QWP_SENDER_ERROR_POLICY.TERMINAL || + error.appliedPolicy === QWP_SENDER_ERROR_POLICY.ABANDONED + ? "error" + : "warn"; + if (error.category === QWP_SENDER_ERROR_CATEGORY.DATA_LOSS) { + log( + level, + `QWP buffered data abandoned [category=${error.category}, policy=${error.appliedPolicy}, quarantined=${error.quarantinedPath ?? "none"}, message=${error.serverMessage ?? "none"}]`, + ); + return; + } + const status = + error.serverStatusByte === undefined + ? "none" + : `0x${error.serverStatusByte.toString(16).padStart(2, "0")}`; + const fsn = + error.fromFsn === undefined + ? "none" + : error.toFsn === undefined || error.toFsn === error.fromFsn + ? error.fromFsn.toString() + : `${error.fromFsn}..${error.toFsn}`; + log( + level, + `QuestDB rejected QWP ingress batch [category=${error.category}, policy=${error.appliedPolicy}, status=${status}, fsn=${fsn}, table=${error.tableName ?? "(multi)"}, sequence=${error.messageSequence?.toString() ?? "none"}, message=${error.serverMessage ?? "none"}]`, + ); +} + export function createQwpSenderError( response: QwpIngressResponse, context: QwpSenderErrorResponseContext = {}, diff --git a/test/qwp/public-api-contract.ts b/test/qwp/public-api-contract.ts index 74e70a8..d2fb0a6 100644 --- a/test/qwp/public-api-contract.ts +++ b/test/qwp/public-api-contract.ts @@ -1,5 +1,6 @@ import { Sender } from "../../src"; import type { ExtraOptions, QwpExtraOptions } from "../../src"; +import { defaultQwpSenderErrorHandler } from "../../src/qwp"; import { bootstrapQwpBrowserSession, connectQwpBrowserClient, @@ -69,6 +70,9 @@ const browserSenderSignature: ( sessionOptions?: QwpIngressSessionOptions, ) => Promise = connectQwpBrowserSender; +const defaultSenderErrorHandlerSignature: (error: QwpSenderError) => void = + defaultQwpSenderErrorHandler; + const browserIngressSignature: ( options: QwpBrowserWebSocketOptions, sessionOptions?: QwpIngressSessionOptions, @@ -321,6 +325,7 @@ const rootExtraOptionsContract: ExtraOptions = { }; void browserSenderSignature; +void defaultSenderErrorHandlerSignature; void browserIngressSignature; void browserEgressSignature; void bootstrapSignature; diff --git a/test/qwp/public-api.test.ts b/test/qwp/public-api.test.ts index 84c3b0c..fb9eae1 100644 --- a/test/qwp/public-api.test.ts +++ b/test/qwp/public-api.test.ts @@ -46,6 +46,7 @@ const sharedRuntimeContract = [ "QwpSenderCloseTimeoutError", "QwpUnrecoverableReplayDictionaryError", "QwpUpgradeError", + "defaultQwpSenderErrorHandler", ] as const; const browserRuntimeContract = [ diff --git a/test/qwp/reconnect.test.ts b/test/qwp/reconnect.test.ts index 80d7075..c0118a2 100644 --- a/test/qwp/reconnect.test.ts +++ b/test/qwp/reconnect.test.ts @@ -1764,6 +1764,9 @@ describe("QWP ingress reconnect and replay", () => { status: QWP_STATUS.OK, sequence: 0n, }); + await vi.waitFor(() => + expect(session.metrics.deliveredErrorNotifications).toBe(2), + ); await session.close(); }); diff --git a/test/qwp/sender-error.test.ts b/test/qwp/sender-error.test.ts index 9fcfe1c..038be51 100644 --- a/test/qwp/sender-error.test.ts +++ b/test/qwp/sender-error.test.ts @@ -1,13 +1,20 @@ -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { createQwpDataLossSenderError, createQwpSenderError, + defaultQwpSenderErrorHandler, QWP_SENDER_ERROR_CATEGORY, QWP_SENDER_ERROR_POLICY, QWP_STATUS, } from "../../src/qwp"; +const logging = vi.hoisted(() => ({ log: vi.fn() })); + +vi.mock("../../src/logging", () => logging); + describe("QWP typed sender errors", () => { + beforeEach(() => logging.log.mockClear()); + it.each([ [ QWP_STATUS.SCHEMA_MISMATCH, @@ -83,4 +90,34 @@ describe("QWP typed sender errors", () => { quarantinedPath: "/qwp/slot.bad", }); }); + + it("warns by default for a retriable server rejection", () => { + defaultQwpSenderErrorHandler( + createQwpSenderError( + { + status: QWP_STATUS.WRITE_ERROR, + sequence: 7n, + tables: [{ name: "trades", sequenceTransaction: 11n }], + errorMessage: "disk busy", + }, + { fromFsn: 41n, toFsn: 43n }, + ), + ); + + expect(logging.log).toHaveBeenCalledWith( + "warn", + "QuestDB rejected QWP ingress batch [category=write-error, policy=retriable, status=0x09, fsn=41..43, table=trades, sequence=7, message=disk busy]", + ); + }); + + it("reports abandoned persistent data as an error by default", () => { + defaultQwpSenderErrorHandler( + createQwpDataLossSenderError("corrupt journal", "/qwp/slot.bad"), + ); + + expect(logging.log).toHaveBeenCalledWith( + "error", + "QWP buffered data abandoned [category=data-loss, policy=abandoned, quarantined=/qwp/slot.bad, message=corrupt journal]", + ); + }); }); From 40b6013c7256bbbed91309163580efbc0298ebde Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 19 Aug 2026 00:53:14 +0100 Subject: [PATCH 071/265] feat(qwp): expose current egress server info --- QWP.md | 10 +++ src/qwp/client.ts | 11 ++- src/qwp/core/egress.ts | 5 +- src/qwp/egress-session.ts | 24 +++++-- test/qwp/client.test.ts | 119 +++++++++++++++++++++++++++++--- test/qwp/core.test.ts | 8 ++- test/qwp/public-api-contract.ts | 6 ++ 7 files changed, 162 insertions(+), 21 deletions(-) diff --git a/QWP.md b/QWP.md index d473c41..0270919 100644 --- a/QWP.md +++ b/QWP.md @@ -767,6 +767,13 @@ uses eight connection sweeps, full-jitter backoff starting at 50 ms and capped a second, and a 30-second outage deadline. `QUERY_ERROR` remains a query result and does not trigger failover. +`session.ready` resolves once with the initial `SERVER_INFO`. Read +`session.serverInfo` for the immutable snapshot from the currently bound endpoint: +role, zone, cluster and node IDs, epoch, capabilities, server clock, and negotiated +compression. Reading the property is non-perturbing and never initiates a failover +walk. If an endpoint dies, it continues to report the previous snapshot until the +transport successfully rebinds, then refreshes to the new endpoint. + Re-execution is at least once: a statement may have completed before its response was lost, and a consumer may already have observed a prefix of SELECT rows. Queued but unconsumed batches are discarded automatically. Configure `onReplayReset` when the @@ -916,6 +923,9 @@ exhaustion raises `QwpPoolAcquireTimeoutError`. Query handles are single-flight, but separate borrowed handles run concurrently. Returning a handle with an active query sends `CANCEL` and waits for the session's bounded cancellation drain; a connection that cannot drain is closed instead of being handed to another borrower. +Each query lease exposes the same refreshed snapshot as `lease.serverInfo`; accessing +it after returning the lease raises `QwpClientClosedError` rather than exposing a +pooled connection now owned by another borrower. The shared housekeeper closes excess connections after `idleTimeoutMs` and recycles connections older than `maxLifetimeMs` once they are idle, while always retaining each configured pool minimum. Set either timeout to zero to disable that policy; diff --git a/src/qwp/client.ts b/src/qwp/client.ts index e02261c..cc4582b 100644 --- a/src/qwp/client.ts +++ b/src/qwp/client.ts @@ -465,7 +465,7 @@ export class QwpQueryLease { private closePromise?: Promise; private released = false; - /** SERVER_INFO for the endpoint selected by this pooled session. */ + /** Initial SERVER_INFO; use serverInfo for the current post-failover snapshot. */ readonly ready: Promise; /** @internal */ @@ -481,6 +481,15 @@ export class QwpQueryLease { return this.session.handshake; } + /** + * Cached immutable SERVER_INFO for this lease's currently bound endpoint. + * Reading it does not drive failover; a successful query replay refreshes it. + */ + get serverInfo(): QwpServerInfoMessage | undefined { + this.throwIfReleased(); + return this.session.serverInfo; + } + get negotiatedCompression(): QwpNegotiatedEgressCompression | undefined { this.throwIfReleased(); return this.session.negotiatedCompression; diff --git a/src/qwp/core/egress.ts b/src/qwp/core/egress.ts index abdd08c..a214c8a 100644 --- a/src/qwp/core/egress.ts +++ b/src/qwp/core/egress.ts @@ -24,6 +24,7 @@ export interface QwpQueryRequest { queryFlags?: number | bigint; } +/** Immutable endpoint metadata from the most recent successful egress bind. */ export interface QwpServerInfoMessage extends QwpFrameHeader { kind: "server-info"; role: number; @@ -197,7 +198,7 @@ export function decodeQwpEgressMessage(bytes: Uint8Array): QwpEgressMessage { ? reader.readUint8("egress compression level") : null; reader.expectEnd("SERVER_INFO"); - return { + return Object.freeze({ ...header, kind: "server-info", role, @@ -209,7 +210,7 @@ export function decodeQwpEgressMessage(bytes: Uint8Array): QwpEgressMessage { zoneId, compressionCodec, compressionLevel, - }; + }); } case QWP_EGRESS_MESSAGE.RESULT_BATCH: { const requestId = reader.readBigUint64("result request ID"); diff --git a/src/qwp/egress-session.ts b/src/qwp/egress-session.ts index 6e87be3..68b6c60 100644 --- a/src/qwp/egress-session.ts +++ b/src/qwp/egress-session.ts @@ -530,12 +530,13 @@ export class QwpEgressSession implements QwpEgressQueryControl { private activeRequest?: QwpReplayableQueryRequest; private nextRequestId = 0n; private sendTail: Promise = Promise.resolve(); - private serverInfo?: QwpServerInfoMessage; + private currentServerInfo?: QwpServerInfoMessage; private failure?: Error; private closing = false; private closePromise?: Promise; private cancelDrainRequestId?: bigint; private cancelDrainTimer?: ReturnType; + /** Initial SERVER_INFO; use serverInfo for the current post-failover snapshot. */ readonly ready: Promise; constructor( @@ -647,9 +648,18 @@ export class QwpEgressSession implements QwpEgressQueryControl { return this.connection.handshake; } + /** + * Cached immutable SERVER_INFO for the currently bound endpoint. Reading it + * never initiates a connection or failover walk. It is undefined before the + * initial bind and refreshes after every successful reconnect. + */ + get serverInfo(): QwpServerInfoMessage | undefined { + return this.currentServerInfo; + } + /** Effective codec and level echoed by the server on the active endpoint. */ get negotiatedCompression(): QwpNegotiatedEgressCompression | undefined { - const serverInfo = this.serverInfo; + const serverInfo = this.currentServerInfo; if ( serverInfo?.compressionCodec === QWP_COMPRESSION_CODEC.ZSTD && serverInfo.compressionLevel !== null @@ -758,7 +768,9 @@ export class QwpEgressSession implements QwpEgressQueryControl { this.active = query; this.activeRequest = request; try { - await this.send(this.encodeQueryRequest(request, this.serverInfo!)); + await this.send( + this.encodeQueryRequest(request, this.currentServerInfo!), + ); } catch (error) { this.clearActive(query); query.fail(error); @@ -889,12 +901,12 @@ export class QwpEgressSession implements QwpEgressQueryControl { const message = decodeQwpEgressMessage(payload); switch (message.kind) { case "server-info": - if (this.serverInfo) { + if (this.currentServerInfo) { throw new QwpProtocolError( "received duplicate QWP SERVER_INFO", ); } - this.serverInfo = message; + this.currentServerInfo = message; clearTimeout(this.serverInfoTimer); this.resolveServerInfo(message); break; @@ -1005,7 +1017,7 @@ export class QwpEgressSession implements QwpEgressQueryControl { private async prepareConnectionReset( serverInfo: QwpServerInfoMessage, ): Promise { - this.serverInfo = serverInfo; + this.currentServerInfo = serverInfo; await this.active?.resetForReplay(); this.decoder.applyCacheReset(QWP_RESET_MASK_DICTIONARY); this.decoder.resetQuerySchema(); diff --git a/test/qwp/client.test.ts b/test/qwp/client.test.ts index 2f39c0f..ead2ba0 100644 --- a/test/qwp/client.test.ts +++ b/test/qwp/client.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import { encodeQwpFrame, + QWP_EGRESS_CAPABILITY, QWP_EGRESS_MESSAGE, QWP_SERVER_ROLE, QWP_STATUS, @@ -25,15 +26,27 @@ function writeString(writer: QwpByteWriter, value: string): void { writer.writeUint16(encoded.length).writeBytes(encoded); } -function serverInfo(nodeId: string): Uint8Array { +function serverInfo( + nodeId: string, + options: { + readonly role?: number; + readonly clusterId?: string; + readonly zoneId?: string; + readonly capabilities?: number; + } = {}, +): Uint8Array { + const capabilities = + (options.capabilities ?? 0) | + (options.zoneId === undefined ? 0 : QWP_EGRESS_CAPABILITY.ZONE); const payload = new QwpByteWriter() .writeUint8(QWP_EGRESS_MESSAGE.SERVER_INFO) - .writeUint8(QWP_SERVER_ROLE.STANDALONE) + .writeUint8(options.role ?? QWP_SERVER_ROLE.STANDALONE) .writeBigUint64(1n) - .writeUint32(0) + .writeUint32(capabilities) .writeBigInt64(123n); - writeString(payload, "cluster"); + writeString(payload, options.clusterId ?? "cluster"); writeString(payload, nodeId); + if (options.zoneId !== undefined) writeString(payload, options.zoneId); return encodeQwpFrame(payload.toUint8Array()); } @@ -83,17 +96,24 @@ class FakeConnection implements QwpBinaryConnection { close(code = 1000, reason = ""): Promise { this.closeCount++; - if (!this.closedSettled) { - this.closedSettled = true; - this.incoming.end(); - this.resolveClosed({ code, reason, wasClean: code === 1000 }); - } + this.finish({ code, reason, wasClean: code === 1000 }); return Promise.resolve(); } receive(payload: Uint8Array): void { this.incoming.push(payload); } + + drop(): void { + this.finish({ code: 1006, reason: "connection lost", wasClean: false }); + } + + private finish(info: QwpConnectionCloseInfo): void { + if (this.closedSettled) return; + this.closedSettled = true; + this.incoming.end(); + this.resolveClosed(info); + } } class FakeSenderSession implements QwpSenderSession { @@ -326,6 +346,87 @@ describe("QWP pooled client", () => { expect(connections).toHaveLength(2); }); + it("exposes immutable server information and refreshes it after failover", async () => { + const first = new FakeConnection("primary"); + const second = new FakeConnection("replica"); + const connections = [first, second]; + const client = new QwpClient( + { + createSender: async () => { + throw new Error("sender factory should not run"); + }, + createQuerySession: async () => + QwpEgressSession.connect( + async () => { + const connection = connections.shift(); + if (!connection) throw new Error("no connection available"); + queueMicrotask(() => + connection.receive( + serverInfo(`node-${connection.endpoint}`, { + role: + connection === first + ? QWP_SERVER_ROLE.PRIMARY + : QWP_SERVER_ROLE.REPLICA, + zoneId: connection === first ? "zone-a" : "zone-b", + capabilities: + connection === first + ? QWP_EGRESS_CAPABILITY.QUERY_FLAGS + : 0, + }), + ), + ); + return connection; + }, + { + reconnect: { + maxAttempts: 1, + initialBackoffMs: 0, + maxBackoffMs: 0, + }, + }, + ), + }, + { + senderPoolMin: 0, + senderPoolMax: 1, + queryPoolMin: 0, + queryPoolMax: 1, + }, + ); + + const lease = await client.borrowQuery(); + const initial = lease.serverInfo; + expect(initial).toMatchObject({ + role: QWP_SERVER_ROLE.PRIMARY, + clusterId: "cluster", + nodeId: "node-primary", + zoneId: "zone-a", + capabilities: + QWP_EGRESS_CAPABILITY.QUERY_FLAGS | QWP_EGRESS_CAPABILITY.ZONE, + }); + expect(await lease.ready).toBe(initial); + expect(Object.isFrozen(initial)).toBe(true); + + const query = await lease.query("select 1"); + first.drop(); + await vi.waitFor(() => expect(second.sent).toHaveLength(1)); + expect(lease.serverInfo).toMatchObject({ + role: QWP_SERVER_ROLE.REPLICA, + clusterId: "cluster", + nodeId: "node-replica", + zoneId: "zone-b", + capabilities: QWP_EGRESS_CAPABILITY.ZONE, + }); + expect(lease.serverInfo).not.toBe(initial); + expect(Object.isFrozen(lease.serverInfo)).toBe(true); + + second.receive(resultEnd(query.requestId)); + await query.completion; + await lease.close(); + expect(() => lease.serverInfo).toThrow(QwpClientClosedError); + await client.close(); + }); + it("reaps idle excess connections without shrinking below pool minimums", async () => { vi.useFakeTimers(); try { diff --git a/test/qwp/core.test.ts b/test/qwp/core.test.ts index 1c85e7e..54ac832 100644 --- a/test/qwp/core.test.ts +++ b/test/qwp/core.test.ts @@ -402,9 +402,10 @@ describe("QWP egress codec", () => { writeU16String(payload, "eu-west-1a"); payload.writeUint8(QWP_COMPRESSION_CODEC.ZSTD).writeUint8(3); - expect( - decodeQwpEgressMessage(encodeQwpFrame(payload.toUint8Array())), - ).toMatchObject({ + const message = decodeQwpEgressMessage( + encodeQwpFrame(payload.toUint8Array()), + ); + expect(message).toMatchObject({ kind: "server-info", role: 1, epoch: 3n, @@ -414,6 +415,7 @@ describe("QWP egress codec", () => { compressionCodec: QWP_COMPRESSION_CODEC.ZSTD, compressionLevel: 3, }); + expect(Object.isFrozen(message)).toBe(true); }); it("decodes RESULT_END and rejects truncated control frames", () => { diff --git a/test/qwp/public-api-contract.ts b/test/qwp/public-api-contract.ts index d2fb0a6..edb35d2 100644 --- a/test/qwp/public-api-contract.ts +++ b/test/qwp/public-api-contract.ts @@ -56,6 +56,7 @@ import type { QwpResultBatchViewHandler, QwpResultRowView, QwpResultRowViewCallback, + QwpServerInfoMessage, QwpSender, QwpSenderOptions, } from "../../src/qwp"; @@ -291,6 +292,9 @@ function queryViewContract( session: QwpEgressSession, lease: QwpQueryLease, ): void { + const sessionServerInfo: QwpServerInfoMessage | undefined = + session.serverInfo; + const leaseServerInfo: QwpServerInfoMessage | undefined = lease.serverInfo; const handler: QwpResultBatchViewHandler = (batch, query) => { const typedBatch: QwpResultBatchView = batch; const requestId: bigint = query.requestId; @@ -318,6 +322,8 @@ function queryViewContract( ); void direct; void pooled; + void sessionServerInfo; + void leaseServerInfo; } const rootExtraOptionsContract: ExtraOptions = { From 745054f1520e0141a5b5fb158cf188945ae492a2 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 19 Aug 2026 01:01:20 +0100 Subject: [PATCH 072/265] feat(qwp): unify browser cluster configuration --- QWP.md | 36 +++++++ src/qwp/browser.ts | 183 ++++++++++++++++++++++++++++++-- test/qwp/public-api-contract.ts | 42 ++++++++ test/qwp/session.test.ts | 106 ++++++++++++++++++ 4 files changed, 358 insertions(+), 9 deletions(-) diff --git a/QWP.md b/QWP.md index 0270919..664d6fd 100644 --- a/QWP.md +++ b/QWP.md @@ -916,6 +916,42 @@ try { } ``` +Browser applications can likewise describe the cluster, REST/OIDC +authentication bootstrap, and failover order once. A cluster URL may be an +origin, a reverse-proxy base path, or an existing `/write/v4` or `/read/v1` +endpoint; the facade derives both protocol routes while preserving query +parameters. Omit `sessionBootstrap.url` to derive the matching `/exec` route +for every failover endpoint: + +```typescript +import { connectQwpBrowserClient } from "@questdb/nodejs-client/qwp/browser"; + +const db = await connectQwpBrowserClient({ + cluster: { + url: "wss://node-a.example/qdb", + failoverUrls: ["wss://node-b.example/qdb"], + sessionBootstrap: { + authentication: { type: "bearer", token: oidcOrRestAccessToken }, + serviceAccount: "analytics", + }, + }, + ingress: { requestDurableAck: true }, + egress: { + target: "replica", + zone: "eu-west-1a", + compression: "zstd", + }, + pool: { senderPoolMax: 2, queryPoolMax: 8 }, +}); +``` + +`url`, `failoverUrls`, and `sessionBootstrap` belong to `cluster` in this +unified form and are rejected if repeated under `ingress` or `egress`. +Side-specific timeouts, WebSocket factories, durable-ACK settings, routing, and +compression remain available as explicit overrides. The original split object +form with complete `ingress` and `egress` trees remains supported for advanced +cases that intentionally connect the two sides differently. + `connectQwpNodeClient()` and `connectQwpBrowserClient()` prewarm each configured pool minimum. Their `createQwp*Client()` counterparts are lazy. Pools grow to their maximum under concurrent borrows and apply one FIFO acquisition deadline; diff --git a/src/qwp/browser.ts b/src/qwp/browser.ts index 7af7d76..058f50f 100644 --- a/src/qwp/browser.ts +++ b/src/qwp/browser.ts @@ -300,16 +300,87 @@ export interface QwpBrowserEgressOptions maxBatchRows?: number; } -/** Browser configuration for a combined pooled QWP ingress/egress client. */ -export interface QwpBrowserClientOptions { - ingress: QwpBrowserWebSocketOptions; - egress: QwpBrowserEgressOptions; +/** Shared browser transport and authentication for one QWP cluster. */ +export interface QwpBrowserClusterOptions extends QwpWebSocketConnectOptions { + /** + * Authenticates before every connection attempt. When `url` is omitted from + * this bootstrap, its REST endpoint follows the active cluster endpoint. + */ + sessionBootstrap?: QwpBrowserSessionBootstrapConfig; + /** Shared test or framework hook; either side may override it. */ + webSocketFactory?: ( + url: string | URL, + protocols?: string | string[], + ) => QwpWebSocketLike; +} + +/** Ingress-only overrides for a unified browser cluster. */ +export type QwpBrowserClientIngressOptions = Partial< + Pick< + QwpBrowserWebSocketOptions, + | "protocols" + | "connectTimeoutMs" + | "sendTimeoutMs" + | "closeTimeoutMs" + | "requestDurableAck" + | "ingressNegotiationTimeoutMs" + | "webSocketFactory" + > +>; + +/** Egress-only overrides for a unified browser cluster. */ +export type QwpBrowserClientEgressOptions = Partial< + Pick< + QwpBrowserEgressOptions, + | "protocols" + | "connectTimeoutMs" + | "sendTimeoutMs" + | "closeTimeoutMs" + | "webSocketFactory" + | "target" + | "zone" + | "compression" + | "compressionLevel" + | "maxBatchRows" + > +>; + +interface QwpBrowserClientBaseOptions { sender?: QwpSenderOptions; ingressSession?: QwpIngressSessionOptions; egressSession?: QwpEgressSessionOptions; pool?: QwpClientPoolOptions; } +/** + * Recommended combined-browser form. One endpoint list and authentication + * bootstrap are shared while side-specific protocol options remain explicit. + */ +export interface QwpBrowserUnifiedClientOptions + extends QwpBrowserClientBaseOptions { + cluster: QwpBrowserClusterOptions; + ingress?: QwpBrowserClientIngressOptions; + egress?: QwpBrowserClientEgressOptions; +} + +/** Backwards-compatible form with completely independent connection trees. */ +export interface QwpBrowserSplitClientOptions + extends QwpBrowserClientBaseOptions { + cluster?: never; + ingress: QwpBrowserWebSocketOptions; + egress: QwpBrowserEgressOptions; +} + +/** Browser configuration for a combined pooled QWP ingress/egress client. */ +export type QwpBrowserClientOptions = + | QwpBrowserUnifiedClientOptions + | QwpBrowserSplitClientOptions; + +interface QwpResolvedBrowserClientOptions extends QwpBrowserClientBaseOptions { + ingress: QwpBrowserWebSocketOptions; + egress: QwpBrowserEgressOptions; +} + /** * Opens a QWP-capable browser WebSocket. * @@ -635,17 +706,111 @@ export async function connectQwpBrowserEgress( ); } +const CLUSTER_OWNED_BROWSER_OPTION_NAMES = [ + "url", + "failoverUrls", + "sessionBootstrap", +] as const; + +function assertNoBrowserClusterOptionConflicts( + side: "ingress" | "egress", + options: object | undefined, +): void { + if (!options) return; + for (const name of CLUSTER_OWNED_BROWSER_OPTION_NAMES) { + if (Object.prototype.hasOwnProperty.call(options, name)) { + throw new TypeError( + `conflicting browser client configuration: ${side}.${name} must be configured once under cluster.${name}`, + ); + } + } +} + +function browserClusterEndpoint( + endpoint: string | URL, + route: "write/v4" | "read/v1", +): URL { + const url = + endpoint instanceof URL + ? new URL(endpoint) + : new URL(endpoint, globalThis.location?.href); + if (url.protocol !== "ws:" && url.protocol !== "wss:") { + throw new TypeError(`QWP browser cluster URL must use WS or WSS: ${url}`); + } + if (url.hash) { + throw new TypeError( + `QWP browser cluster URL cannot contain a fragment: ${url}`, + ); + } + const qwpRoute = /\/(?:write\/v4|read\/v1)\/?$/; + if (qwpRoute.test(url.pathname)) { + url.pathname = url.pathname.replace(qwpRoute, `/${route}`); + } else { + url.pathname = `${url.pathname.replace(/\/+$/, "")}/${route}`; + } + return url; +} + +function resolveQwpBrowserClientOptions( + options: QwpBrowserClientOptions, +): QwpResolvedBrowserClientOptions { + if ("cluster" in options && options.cluster !== undefined) { + assertNoBrowserClusterOptionConflicts("ingress", options.ingress); + assertNoBrowserClusterOptionConflicts("egress", options.egress); + const { url, failoverUrls, ...shared } = options.cluster; + const ingress: QwpBrowserWebSocketOptions = { + ...shared, + ...options.ingress, + url: browserClusterEndpoint(url, "write/v4"), + failoverUrls: failoverUrls?.map((endpoint) => + browserClusterEndpoint(endpoint, "write/v4"), + ), + }; + const egress: QwpBrowserEgressOptions = { + ...shared, + ...options.egress, + url: browserClusterEndpoint(url, "read/v1"), + failoverUrls: failoverUrls?.map((endpoint) => + browserClusterEndpoint(endpoint, "read/v1"), + ), + }; + return { + ingress, + egress, + sender: options.sender, + ingressSession: options.ingressSession, + egressSession: options.egressSession, + pool: options.pool, + }; + } + if (!options.ingress || !options.egress) { + throw new TypeError( + "browser client configuration requires either cluster or both ingress and egress", + ); + } + const split = options as QwpBrowserSplitClientOptions; + return { + ingress: split.ingress, + egress: split.egress, + sender: split.sender, + ingressSession: split.ingressSession, + egressSession: split.egressSession, + pool: split.pool, + }; +} + /** Creates a lazy browser QWP client with bounded sender and query pools. */ export function createQwpBrowserClient( options: QwpBrowserClientOptions, ): QwpClient { + const resolved = resolveQwpBrowserClientOptions(options); return new QwpClient( { createSender: async () => { const sender = createQwpBrowserSender( - options.ingress, - options.sender, - options.ingressSession, + resolved.ingress, + resolved.sender, + resolved.ingressSession, ); try { await sender.connect(); @@ -656,9 +821,9 @@ export function createQwpBrowserClient( } }, createQuerySession: () => - connectQwpBrowserEgress(options.egress, options.egressSession), + connectQwpBrowserEgress(resolved.egress, resolved.egressSession), }, - options.pool, + resolved.pool, ); } diff --git a/test/qwp/public-api-contract.ts b/test/qwp/public-api-contract.ts index edb35d2..bcff39b 100644 --- a/test/qwp/public-api-contract.ts +++ b/test/qwp/public-api-contract.ts @@ -9,10 +9,15 @@ import { connectQwpBrowserSender, } from "../../src/qwp/browser"; import type { + QwpBrowserClusterOptions, + QwpBrowserClientEgressOptions, + QwpBrowserClientIngressOptions, QwpBrowserClientOptions, QwpBrowserSessionBootstrapOptions, QwpBrowserSessionBootstrapResult, QwpBrowserEgressOptions, + QwpBrowserSplitClientOptions, + QwpBrowserUnifiedClientOptions, QwpBrowserWebSocketOptions, } from "../../src/qwp/browser"; import { @@ -216,6 +221,43 @@ const browserEgressOptionsContract: QwpBrowserEgressOptions = { maxBatchRows: 512, }; +const browserClusterOptionsContract: QwpBrowserClusterOptions = { + url: "wss://node-1.example/qdb", + failoverUrls: ["wss://node-2.example/qdb"], + connectTimeoutMs: 5_000, + sessionBootstrap: { + authentication: { type: "bearer", token: "oidc-token" }, + }, +}; + +const browserIngressOverridesContract: QwpBrowserClientIngressOptions = { + requestDurableAck: true, + ingressNegotiationTimeoutMs: 1_000, +}; + +const browserEgressOverridesContract: QwpBrowserClientEgressOptions = { + target: "replica", + zone: "eu-west-1a", + compression: "zstd", + maxBatchRows: 512, +}; + +const browserUnifiedClientContract: QwpBrowserUnifiedClientOptions = { + cluster: browserClusterOptionsContract, + ingress: browserIngressOverridesContract, + egress: browserEgressOverridesContract, +}; + +const browserSplitClientContract: QwpBrowserSplitClientOptions = { + ingress: { url: "wss://node-1.example/write/v4" }, + egress: { url: "wss://node-1.example/read/v1" }, +}; + +const browserClientOptionsContracts: readonly QwpBrowserClientOptions[] = [ + browserUnifiedClientContract, + browserSplitClientContract, +]; + const nodeEgressOptionsContract: QwpNodeEgressOptions = { url: "wss://node-1.example/read/v1", failoverUrls: ["wss://node-2.example/read/v1"], diff --git a/test/qwp/session.test.ts b/test/qwp/session.test.ts index 2a8ece3..4a5dfc3 100644 --- a/test/qwp/session.test.ts +++ b/test/qwp/session.test.ts @@ -1,9 +1,11 @@ import { describe, expect, it, vi } from "vitest"; import { bootstrapQwpBrowserSession, + connectQwpBrowserClient, connectQwpBrowserEgress, connectQwpBrowserIngress, connectQwpBrowserWebSocket, + createQwpBrowserClient, createQwpBrowserSender, QwpBrowserSessionBootstrapError, QwpWebSocketLike, @@ -423,6 +425,110 @@ describe("QWP WebSocket adapters", () => { await connection.close(); }); + it("uses one browser cluster for authenticated ingress, egress, and failover", async () => { + const webSocketUrls: URL[] = []; + const bootstrapUrls: URL[] = []; + const client = await connectQwpBrowserClient({ + cluster: { + url: "wss://node-a.example/qdb?tenant=blue", + failoverUrls: ["wss://node-b.example/qdb?tenant=blue"], + sessionBootstrap: { + authentication: { type: "bearer", token: "access-token" }, + fetch: async (input) => { + bootstrapUrls.push(new URL(input)); + return new Response("{}", { status: 200 }); + }, + }, + webSocketFactory: (url) => { + const requestUrl = new URL(url); + webSocketUrls.push(requestUrl); + if (requestUrl.hostname === "node-a.example") { + throw new QwpUpgradeError("offline", { + kind: QWP_UPGRADE_ERROR_KIND.TRANSPORT, + retryable: true, + tryNextEndpoint: true, + url, + }); + } + const socket = new FakeWebSocket(); + queueMicrotask(() => { + socket.open(); + socket.message( + requestUrl.pathname.endsWith("/read/v1") + ? serverInfoFrame() + : ingressServerInfo(1_048_576), + ); + }); + return asQwpSocket(socket); + }, + }, + ingress: { ingressNegotiationTimeoutMs: 1_000 }, + egress: { target: "any", maxBatchRows: 512 }, + }); + try { + expect( + webSocketUrls.map((url) => `${url.hostname}${url.pathname}`).sort(), + ).toEqual([ + "node-a.example/qdb/read/v1", + "node-a.example/qdb/write/v4", + "node-b.example/qdb/read/v1", + "node-b.example/qdb/write/v4", + ]); + expect( + webSocketUrls.every((url) => url.searchParams.get("tenant") === "blue"), + ).toBe(true); + expect( + webSocketUrls + .find((url) => url.pathname.endsWith("/read/v1")) + ?.searchParams.get("qwp_max_batch_rows"), + ).toBe("512"); + expect( + bootstrapUrls.map((url) => `${url.hostname}${url.pathname}`).sort(), + ).toEqual([ + "node-a.example/qdb/exec", + "node-a.example/qdb/exec", + "node-b.example/qdb/exec", + "node-b.example/qdb/exec", + ]); + } finally { + await client.close(); + } + }); + + it("rejects connection fields duplicated under unified browser overrides", () => { + expect(() => + createQwpBrowserClient({ + cluster: { url: "wss://questdb.example" }, + ingress: { url: "wss://other.example/write/v4" }, + } as never), + ).toThrow("ingress.url must be configured once under cluster.url"); + expect(() => + createQwpBrowserClient({ + cluster: { url: "wss://questdb.example" }, + egress: { + sessionBootstrap: { + authentication: { type: "bearer", token: "other-token" }, + }, + }, + } as never), + ).toThrow( + "egress.sessionBootstrap must be configured once under cluster.sessionBootstrap", + ); + }); + + it("validates unified browser cluster URLs before opening a socket", () => { + expect(() => + createQwpBrowserClient({ + cluster: { url: "https://questdb.example" }, + }), + ).toThrow("QWP browser cluster URL must use WS or WSS"); + expect(() => + createQwpBrowserClient({ + cluster: { url: "wss://questdb.example/#fragment" }, + }), + ).toThrow("QWP browser cluster URL cannot contain a fragment"); + }); + it("buffers browser messages until a consumer is attached", async () => { const socket = new FakeWebSocket(); const connecting = connectQwpBrowserWebSocket({ From f3b7739886ae8eada15bb23d2c0828b842594882 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 19 Aug 2026 01:47:53 +0100 Subject: [PATCH 073/265] feat(qwp): add fixed-segment replay store --- QWP.md | 38 +- README.md | 8 +- src/qwp-node/file-replay-store.ts | 838 +++++++++++++++++++++++------- src/qwp-node/orphan-drainer.ts | 47 +- src/qwp/node.ts | 1 + test/qwp/node-transport.test.ts | 62 ++- test/qwp/orphan-drainer.test.ts | 6 +- test/qwp/public-api.test.ts | 1 + test/qwp/reconnect.test.ts | 199 ++++--- 9 files changed, 895 insertions(+), 305 deletions(-) diff --git a/QWP.md b/QWP.md index 664d6fd..4c1dcd4 100644 --- a/QWP.md +++ b/QWP.md @@ -173,8 +173,8 @@ The connect-string key `durability` controls the local persistence barrier: -- `"append"` (the backwards-compatible default) fsyncs every segment append and its - atomic segment creation before publication resolves. +- `"append"` (the default) fsyncs every positional write to the open active segment; + hot-spare creation and activation are durable before publication resolves. - `"periodic"` checkpoints segment files, symbol metadata, and directory changes in the background. The default interval is 5 seconds, and `close()` performs a final checkpoint. A power failure can lose the most recent checkpoint window. @@ -183,7 +183,8 @@ The connect-string key `backpressurePolicy: "error"` preserves the existing immediate `QwpReplayStoreFullError` behavior. Set it to `"wait"` to pause publication until an -ACK advances the checksummed cursor and deletes fully drained segments. +ACK advances the checksummed cursor, then a bounded background trimmer deletes fully +drained segments. `appendDeadlineMs` bounds each such pause (30 seconds by default) and expiry raises `QwpReplayStoreAppendTimeoutError`. Waiting appenders do not hold the journal mutation queue, so ACK cleanup can continue. Direct users of @@ -191,13 +192,15 @@ not hold the journal mutation queue, so ACK cleanup can continue. Direct users o checkpoint work, checkpoint failures, active waiters, stalls, and timeouts. The persisted symbol dictionary is monotonic for one open journal generation and -cannot be reclaimed by an ACK alone. It counts toward the `maxBytes` target, but the -journal preserves up to 32 MiB (or the configured target when smaller) for live frame -records if dictionary growth uses all remaining headroom. Dictionary persistence -itself is never rejected by the target, so actual disk usage can exceed it by the -current dictionary overshoot. Frame growth beyond the liveness allowance remains -backpressured until ACK trimming frees complete segments. A partly acknowledged -segment remains charged to the disk budget until its last live record is acknowledged. +cannot be reclaimed by an ACK alone. It counts toward the `maxBytes` target together +with each complete fixed-segment reservation, including the hot spare. The journal +preserves up to 32 MiB (or the configured target when smaller) for live frame segments +if dictionary growth uses all remaining headroom. Dictionary persistence itself is +never rejected by the target, so actual disk usage can exceed it by the current +dictionary overshoot and at most one liveness segment. Frame growth beyond that +allowance remains backpressured until background ACK trimming frees complete segments. +A partly acknowledged segment remains charged to the disk budget until its last live +record is acknowledged. Once every frame is acknowledged, `close()` removes the dictionary under the journal lock; the next clean start uses a fresh symbol-ID space. A partially drained close retains the dictionary required by @@ -210,11 +213,16 @@ Locks left by a terminated process on the same host are recovered automatically; locks owned by a live local process, another host, or an unidentifiable owner fail closed. -New journals coalesce records into bounded `.qwps` segments, targeting -`maxSegmentBytes` (4 MiB by default) plus at most one record header. This bounds inode -growth during long outages. Existing file-per-frame `.qwp` slots remain readable and -can be drained alongside new segmented appends, so the storage upgrade does not -require an offline migration. +New journals use fixed-size `.qwpseg` files. Each file reserves a 64-byte segment +header, `maxSegmentBytes` of target data (4 MiB by default), and one record header so +a maximum-sized frame fits. The active segment and one unassigned hot spare keep open +file handles; rotation activates the spare and provisions its replacement away from +the normal append path. ACK trimming runs in bounded background batches. + +This v2 layout intentionally does not read the retired experimental file-per-frame +`.qwp` or variable `.qwps` formats. `load()` raises `QwpReplayStoreFormatError` and +leaves those files untouched. Drain such a slot with the previous client or discard +it explicitly before upgrading. On startup, a dictionary sidecar truncated at a complete-block boundary is rebuilt from the ordered symbol deltas embedded in surviving committed frames and healed diff --git a/README.md b/README.md index 6bd0237..29d3ae3 100644 --- a/README.md +++ b/README.md @@ -117,9 +117,11 @@ budget settings without an explicit mode promotes initial startup to `"sync"`, matching the Java client. The configuration-string equivalent is `initial_connect_retry`, used together with the store-and-forward options in `extraOptions.qwp`. -Persistent frames are coalesced into bounded 4 MiB segments by default, with a -checksummed ACK cursor for partially acknowledged segments. Existing file-per-frame -replay directories remain readable and are migrated naturally as new frames arrive. +Persistent frames are coalesced into fixed-size 4 MiB `.qwpseg` segments by default, +with persistent active/hot-spare handles and a checksummed ACK cursor for partially +acknowledged segments. The retired experimental `.qwp`/`.qwps` disk format is not +readable; drain it with the previous client or explicitly discard the slot before +upgrading. Set `drainOrphans: true` when sibling journal directories share a dedicated parent: the Node client scans and drains slots left by failed producer processes with bounded concurrency. Pooled QWP clients recover out-of-range `sender-N` slots automatically, diff --git a/src/qwp-node/file-replay-store.ts b/src/qwp-node/file-replay-store.ts index d91f8d0..afbf664 100644 --- a/src/qwp-node/file-replay-store.ts +++ b/src/qwp-node/file-replay-store.ts @@ -11,6 +11,7 @@ import { unlink, writeFile, } from "node:fs/promises"; +import type { FileHandle } from "node:fs/promises"; import { hostname } from "node:os"; import { basename, dirname, join } from "node:path"; import { QWP_MAX_SYMBOL_DICTIONARY_SIZE } from "../qwp/core"; @@ -20,12 +21,18 @@ import { } from "../qwp/transport"; const MAGIC = Buffer.from("QWPR"); -const FORMAT_VERSION = 1; +const FORMAT_VERSION = 2; const HEADER_SIZE = 52; const SHA256_SIZE = 32; const MAX_FRAME_SEQUENCE = 0xffffffffffffffffn; -const RECORD_SUFFIX = ".qwp"; -const SEGMENT_SUFFIX = ".qwps"; +const LEGACY_RECORD_SUFFIX = ".qwp"; +const LEGACY_SEGMENT_SUFFIX = ".qwps"; +const SEGMENT_MAGIC = Buffer.from("QWPS"); +const SEGMENT_SUFFIX = ".qwpseg"; +const SEGMENT_HEADER_SIZE = 64; +const SEGMENT_HEADER_PREFIX_SIZE = 32; +const SEGMENT_STATE_SPARE = 0; +const SEGMENT_STATE_ASSIGNED = 1; const TEMP_MARKER = ".tmp-"; const ACK_MAGIC = Buffer.from("QWPA"); const ACK_FILE = "ack.qwpstate"; @@ -47,6 +54,7 @@ const DEFAULT_LIVE_FRAME_BYTES = 2 * 16 * 1024 * 1024; const DEFAULT_MAX_SEGMENT_BYTES = 4 * 1024 * 1024; const DEFAULT_CHECKPOINT_INTERVAL_MS = 5_000; const DEFAULT_APPEND_DEADLINE_MS = 30_000; +const TRIM_BATCH_SIZE = 8; const MAX_TIMER_DELAY_MS = 0x7fffffff; const UTF8_DECODER = new TextDecoder("utf-8", { fatal: true }); @@ -75,9 +83,20 @@ interface StoredRecord { interface StoredSegment { readonly path: string; + readonly generation: bigint; readonly firstSequence: bigint; - size: number; + readonly capacity: number; + readonly size: number; + logicalSize: number; liveRecords: number; + handle?: FileHandle; +} + +interface HotSpareSegment { + readonly path: string; + readonly generation: bigint; + readonly size: number; + readonly handle: FileHandle; } interface RecoveredStoredRecord { @@ -103,16 +122,16 @@ export interface QwpNodeFileReplayStoreOptions { /** Exclusive directory used by one ingress session. */ directory: string; /** - * Target maximum journal size including record headers and symbol metadata. - * Defaults to 1 GiB. The current symbol dictionary may exceed this target so - * it cannot consume the journal's live frame budget before a drained close - * retires that dictionary generation. + * Target maximum journal size including fixed segment reservations and + * symbol metadata. Defaults to 1 GiB. The current symbol dictionary may + * exceed this target so it cannot consume the journal's live frame budget + * before a drained close retires that dictionary generation. */ maxBytes?: number; /** - * Maximum QWP frame payload and target segment data size. Segments may exceed - * this value by one record header so a maximum-sized frame still fits. - * Defaults to 4 MiB. + * Maximum QWP frame payload and target segment data size. Each fixed segment + * reserves this value plus one record header and its 64-byte segment header, + * so a maximum-sized frame still fits. Defaults to 4 MiB. */ maxSegmentBytes?: number; /** @@ -167,6 +186,16 @@ export class QwpReplayStoreCorruptionError extends QwpReplayStoreError { } } +/** An existing journal belongs to the retired experimental disk format. */ +export class QwpReplayStoreFormatError extends QwpReplayStoreError { + constructor(readonly directory: string) { + super( + `QWP store-and-forward journal uses the retired experimental disk format [directory=${directory}]; drain it with the previous client or explicitly discard the slot`, + ); + this.name = "QwpReplayStoreFormatError"; + } +} + /** A terminal replay slot was preserved under a quarantine pathname. */ export class QwpReplayStoreQuarantinedError extends QwpReplayStoreError { constructor( @@ -252,9 +281,10 @@ export class QwpReplayStoreLockedError extends QwpReplayStoreError { /** * Node store-and-forward journal with configurable local durability. * - * `append` fsyncs each frame before its atomic rename, while `periodic` batches - * those barriers and `memory` relies on OS writeback. An ACK removes its - * covered files. A crash between the server ACK and local deletion can cause + * The active fixed-size segment and one hot spare remain open for positional + * writes. `append` fsyncs each frame, `periodic` batches barriers, and `memory` + * relies on OS writeback. An ACK persists its cursor before bounded background + * trimming. A crash between the server ACK and local deletion can cause * at-least-once replay. An exclusive, lifetime lock prevents another process * from recovering or mutating the same directory. */ @@ -262,6 +292,7 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { private readonly directory: string; private readonly maxBytes: number; private readonly maxSegmentBytes: number; + private readonly segmentFileSize: number; private readonly liveFrameBytes: number; private readonly durability: QwpSfDurability; private readonly checkpointIntervalMs: number; @@ -273,6 +304,7 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { private readonly symbolValues = new Set(); private readonly dirtyRecordPaths = new Set(); private readonly capacityWaiters = new Set(); + private readonly pendingTrimSegments: StoredSegment[] = []; private operationTail: Promise = Promise.resolve(); private totalBytes = 0; private dictionaryFileSize = 0; @@ -284,6 +316,7 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { private capacityGeneration = 0; private checkpointTimer?: ReturnType; private checkpointFailure?: QwpReplayStoreCheckpointError; + private maintenanceFailure?: QwpReplayStoreError; private totalCheckpoints = 0; private totalCheckpointFailures = 0; private totalBackpressureStalls = 0; @@ -294,6 +327,9 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { private closing = false; private closed = false; private activeSegment?: StoredSegment; + private hotSpare?: HotSpareSegment; + private nextSegmentGeneration = 0n; + private maintenanceScheduled = false; constructor(options: QwpNodeFileReplayStoreOptions) { const directory = options.directory.trim(); @@ -312,6 +348,18 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { options.maxSegmentBytes ?? DEFAULT_MAX_SEGMENT_BYTES, "store-and-forward maxSegmentBytes", ); + if (this.maxSegmentBytes > 0xffffffff) { + throw new RangeError( + "store-and-forward maxSegmentBytes must fit in uint32", + ); + } + this.segmentFileSize = + SEGMENT_HEADER_SIZE + this.maxSegmentBytes + HEADER_SIZE; + if (!Number.isSafeInteger(this.segmentFileSize)) { + throw new RangeError( + "store-and-forward maxSegmentBytes is too large for a fixed segment", + ); + } this.liveFrameBytes = Math.min(maxBytes, DEFAULT_LIVE_FRAME_BYTES); this.durability = validateDurability( options.durability ?? QWP_SF_DURABILITY.APPEND, @@ -373,29 +421,42 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { try { await this.acquireDirectoryLock(); const entries = await readdir(this.directory, { withFileTypes: true }); - const recordNames: string[] = []; const segmentNames: string[] = []; let removedTemporaryFile = false; + let hasRetiredFormat = false; for (const entry of entries) { if (!entry.isFile()) continue; if (entry.name.includes(TEMP_MARKER)) { await ignoreMissing(unlink(join(this.directory, entry.name))); removedTemporaryFile = true; - } else if (entry.name.endsWith(RECORD_SUFFIX)) { - recordNames.push(entry.name); + } else if ( + entry.name.endsWith(LEGACY_RECORD_SUFFIX) || + entry.name.endsWith(LEGACY_SEGMENT_SUFFIX) + ) { + hasRetiredFormat = true; } else if (entry.name.endsWith(SEGMENT_SUFFIX)) { segmentNames.push(entry.name); } } if (removedTemporaryFile) await syncDirectory(this.directory); - recordNames.sort(); + if (hasRetiredFormat) { + throw new QwpReplayStoreFormatError(this.directory); + } segmentNames.sort(); const acknowledgedThrough = await this.loadAcknowledgedThrough(); const recoveredEntries: RecoveredStoredRecord[] = []; - let removedDrainedSegment = false; - for (let index = 0; index < segmentNames.length; index++) { - const name = segmentNames[index]; + const recoveredSegments: Array<{ + readonly name: string; + readonly path: string; + readonly decoded: DecodedSegment; + }> = []; + const spareSegments: Array<{ + readonly name: string; + readonly path: string; + readonly decoded: DecodedSegment; + }> = []; + for (const name of segmentNames) { const path = join(this.directory, name); let bytes: Buffer; try { @@ -407,29 +468,60 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { ); } const decoded = decodeSegment(bytes, name); - if (decoded.tornTail) { + const expectedName = segmentFileName(decoded.generation); + if (name !== expectedName) { + throw new QwpReplayStoreCorruptionError( + `QWP store-and-forward segment filename does not match its generation [file=${name}, expected=${expectedName}]`, + ); + } + this.nextSegmentGeneration = maxBigInt( + this.nextSegmentGeneration, + decoded.generation + 1n, + ); + const entry = { name, path, decoded }; + if (!decoded.assigned) { if ( - decoded.records.length === 0 || - index !== segmentNames.length - 1 + decoded.records.length !== 0 || + decoded.logicalSize !== 0 || + decoded.tornTail ) { + throw corruptRecord(name, "unassigned spare contains records"); + } + spareSegments.push(entry); + } else { + recoveredSegments.push(entry); + } + } + recoveredSegments.sort((left, right) => + compareBigInt( + left.decoded.firstSequence, + right.decoded.firstSequence, + ), + ); + let changedDirectory = false; + for (let index = 0; index < recoveredSegments.length; index++) { + const { name, path, decoded } = recoveredSegments[index]; + if (decoded.tornTail) { + if (index !== recoveredSegments.length - 1) { throw corruptRecord( name, - "segment has an unrecoverable torn record tail", + "non-active segment has a torn record tail", ); } - await truncateSegmentTail(path, decoded.validBytes, this.directory); - bytes = bytes.subarray(0, decoded.validBytes); - } - if (decoded.records.length === 0) { - await ignoreMissing(unlink(path)); - removedDrainedSegment = true; - continue; + await repairSegmentTail( + path, + SEGMENT_HEADER_SIZE + decoded.logicalSize, + decoded.size, + this.directory, + ); } - const firstSequence = decoded.records[0].frameSequence; - const expectedName = segmentFileName(firstSequence); - if (name !== expectedName) { - throw new QwpReplayStoreCorruptionError( - `QWP store-and-forward segment filename does not match its first sequence [file=${name}, expected=${expectedName}]`, + if ( + decoded.records.length > 0 && + decoded.records[0].frameSequence !== decoded.firstSequence + ) { + throw corruptRecord( + name, + `first record sequence does not match segment base [base=${decoded.firstSequence}, received=${decoded.records[0].frameSequence}]`, ); } const liveRecords = decoded.records.filter( @@ -437,17 +529,20 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { ); if (liveRecords.length === 0) { await ignoreMissing(unlink(path)); - removedDrainedSegment = true; + changedDirectory = true; continue; } const segment: StoredSegment = { path, - firstSequence, - size: bytes.byteLength, + generation: decoded.generation, + firstSequence: decoded.firstSequence, + capacity: decoded.capacity, + size: decoded.size, + logicalSize: decoded.logicalSize, liveRecords: liveRecords.length, }; this.segments.set(path, segment); - this.totalBytes += bytes.byteLength; + this.totalBytes += segment.size; for (const record of liveRecords) { recoveredEntries.push({ record, @@ -456,40 +551,25 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { } this.activeSegment = segment; } - if (removedDrainedSegment) await syncDirectory(this.directory); - - let previous = acknowledgedThrough; - for (const name of recordNames) { - const path = join(this.directory, name); - let bytes: Buffer; - try { - bytes = await readFile(path); - } catch (error) { - throw new QwpReplayStoreError( - `could not read QWP store-and-forward record [file=${name}]`, - error, - ); - } - const record = decodeRecord(bytes, name); - const expectedName = recordFileName(record.frameSequence); - if (name !== expectedName) { - throw new QwpReplayStoreCorruptionError( - `QWP store-and-forward filename does not match its sequence [file=${name}, expected=${expectedName}]`, - ); - } - this.totalBytes += bytes.byteLength; - if (record.frameSequence > acknowledgedThrough) { - recoveredEntries.push({ - record, - stored: { path, size: bytes.byteLength }, - }); + if (this.activeSegment) { + this.activeSegment.handle = await open(this.activeSegment.path, "r+"); + } + for (let index = 0; index < spareSegments.length; index++) { + const { path, decoded } = spareSegments[index]; + if (!this.hotSpare && decoded.capacity === this.maxSegmentBytes) { + this.hotSpare = { + path, + generation: decoded.generation, + size: decoded.size, + handle: await open(path, "r+"), + }; + this.totalBytes += decoded.size; } else { await ignoreMissing(unlink(path)); - this.totalBytes -= bytes.byteLength; - removedDrainedSegment = true; + changedDirectory = true; } } - if (removedDrainedSegment) await syncDirectory(this.directory); + if (changedDirectory) await syncDirectory(this.directory); recoveredEntries.sort((left, right) => left.record.frameSequence < right.record.frameSequence ? -1 @@ -497,6 +577,7 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { ? 1 : 0, ); + let previous = acknowledgedThrough; const recovered: QwpIngressReplayRecord[] = []; for (const { record, stored } of recoveredEntries) { if (record.frameSequence <= previous) { @@ -516,9 +597,6 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { if (recovered.length === 0 && acknowledgedThrough >= 0n) { await this.removeAcknowledgedThrough(); } - if (this.totalBytes > this.maxBytes) { - throw new QwpReplayStoreFullError(this.maxBytes, this.totalBytes); - } try { await this.loadDictionaryFile(); } catch (error) { @@ -531,11 +609,18 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { this.dictionaryLoadError = error; } this.loaded = true; + await this.ensureHotSpare(false); loadSucceeded = true; this.scheduleCheckpoint(); return recovered; } finally { - if (!loadSucceeded) await this.releaseDirectoryLock(); + if (!loadSucceeded) { + try { + await this.closeSegmentHandles(); + } finally { + await this.releaseDirectoryLock(); + } + } } }); } @@ -595,26 +680,10 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { this.records.delete(sequence); } for (const segment of emptiedSegments) { - try { - await ignoreMissing(unlink(segment.path)); - } catch (error) { - throw new QwpReplayStoreError( - `could not acknowledge QWP store-and-forward segment [firstSequence=${segment.firstSequence}]`, - error, - ); - } - this.segments.delete(segment.path); - this.dirtyRecordPaths.delete(segment.path); - this.totalBytes -= segment.size; if (this.activeSegment === segment) this.activeSegment = undefined; + this.pendingTrimSegments.push(segment); } - if (this.records.size === 0) await this.removeAcknowledgedThrough(); - if (this.durability === QWP_SF_DURABILITY.APPEND) { - await syncDirectory(this.directory); - } else if (this.durability === QWP_SF_DURABILITY.PERIODIC) { - this.directoryDirty = true; - } - this.signalCapacity(); + this.scheduleMaintenance(); }); } @@ -801,6 +870,7 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { this.closePromise = this.operationTail.then(async () => { let failure: unknown; try { + await this.drainPendingMaintenance(); if (this.durability === QWP_SF_DURABILITY.PERIODIC) { await this.checkpointDirty(); } @@ -809,6 +879,12 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { } catch (error) { failure = error; } + try { + await this.discardHotSpare(); + await this.closeSegmentHandles(); + } catch (error) { + failure ??= error; + } try { await this.releaseDirectoryLock(); } catch (error) { @@ -868,86 +944,302 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { `QWP store-and-forward sequence already exists [frameSequence=${record.frameSequence}]`, ); } - const requiredBytes = this.totalBytes + bytes.byteLength; + let segment = this.activeSegment; + if ( + !segment || + segment.logicalSize + bytes.byteLength > segment.capacity + HEADER_SIZE + ) { + segment = await this.activateHotSpare(record.frameSequence); + } + const handle = segment.handle; + if (!handle) { + throw new QwpReplayStoreError( + `active QWP store-and-forward segment is not open [file=${segment.path}]`, + ); + } + const writeOffset = SEGMENT_HEADER_SIZE + segment.logicalSize; + try { + await writeFully(handle, bytes, writeOffset); + if (this.durability === QWP_SF_DURABILITY.APPEND) { + await handle.sync(); + } else if (this.durability === QWP_SF_DURABILITY.PERIODIC) { + this.dirtyRecordPaths.add(segment.path); + } + } catch (error) { + // The fixed file cannot be shortened without losing its reservation. + // Clear the attempted range so recovery still observes canonical zero + // padding if the caller retries after a transient write failure. + await zeroRange(handle, writeOffset, bytes.byteLength).catch( + () => undefined, + ); + throw new QwpReplayStoreError( + `could not append QWP store-and-forward segment [frameSequence=${record.frameSequence}]`, + error, + ); + } + segment.logicalSize += bytes.byteLength; + segment.liveRecords++; + this.records.set(record.frameSequence, { + path: segment.path, + size: 0, + segment, + }); + this.scheduleHotSpare(); + } + + private async activateHotSpare( + firstSequence: bigint, + ): Promise { + const previous = this.activeSegment; + if (previous?.handle) { + if (this.durability === QWP_SF_DURABILITY.PERIODIC) { + await previous.handle.sync(); + this.dirtyRecordPaths.delete(previous.path); + } + await previous.handle.close(); + previous.handle = undefined; + } + await this.ensureHotSpare(true); + const spare = this.hotSpare; + if (!spare) { + throw new QwpReplayStoreFullError( + this.maxBytes, + this.totalBytes + this.segmentFileSize, + ); + } + try { + await writeFully( + spare.handle, + encodeSegmentHeader( + spare.generation, + firstSequence, + this.maxSegmentBytes, + true, + ), + 0, + ); + if (this.durability === QWP_SF_DURABILITY.APPEND) { + await spare.handle.sync(); + } else if (this.durability === QWP_SF_DURABILITY.PERIODIC) { + this.dirtyRecordPaths.add(spare.path); + } + } catch (error) { + throw new QwpReplayStoreError( + `could not activate QWP store-and-forward hot spare [frameSequence=${firstSequence}]`, + error, + ); + } + const segment: StoredSegment = { + path: spare.path, + generation: spare.generation, + firstSequence, + capacity: this.maxSegmentBytes, + size: spare.size, + logicalSize: 0, + liveRecords: 0, + handle: spare.handle, + }; + this.hotSpare = undefined; + this.segments.set(segment.path, segment); + this.activeSegment = segment; + if (previous && previous.liveRecords === 0) { + await this.trimSegment(previous); + } + return segment; + } + + private async ensureHotSpare(required: boolean): Promise { + if (this.hotSpare || this.closing || this.closed) return; + const requiredBytes = this.totalBytes + this.segmentFileSize; const frameBytes = this.totalBytes - this.dictionaryFileSize; - const requiredFrameBytes = frameBytes + bytes.byteLength; const preservesLiveness = this.dictionaryFileSize > 0 && - (requiredFrameBytes <= this.liveFrameBytes || frameBytes === 0); + (frameBytes < this.liveFrameBytes || this.segments.size === 0); if (requiredBytes > this.maxBytes && !preservesLiveness) { - throw new QwpReplayStoreFullError(this.maxBytes, requiredBytes); + if (required) { + throw new QwpReplayStoreFullError(this.maxBytes, requiredBytes); + } + return; } - - const segmentLimit = this.maxSegmentBytes + HEADER_SIZE; - let segment = this.activeSegment; - if (!segment || segment.size + bytes.byteLength > segmentLimit) { - const name = segmentFileName(record.frameSequence); - const finalPath = join(this.directory, name); - const temporaryPath = join( - this.directory, - `${name}${TEMP_MARKER}${process.pid}-${randomUUID()}`, + const generation = this.nextSegmentGeneration++; + const name = segmentFileName(generation); + const path = join(this.directory, name); + const temporaryPath = join( + this.directory, + `${name}${TEMP_MARKER}${process.pid}-${randomUUID()}`, + ); + let temporaryHandle: FileHandle | undefined; + let handle: FileHandle | undefined; + try { + temporaryHandle = await open(temporaryPath, "wx+", 0o600); + await temporaryHandle.truncate(this.segmentFileSize); + await writeFully( + temporaryHandle, + encodeSegmentHeader(generation, 0n, this.maxSegmentBytes, false), + 0, ); - try { - const file = await open(temporaryPath, "wx", 0o600); - try { - await file.writeFile(bytes); - if (this.durability === QWP_SF_DURABILITY.APPEND) await file.sync(); - } finally { - await file.close(); - } - await rename(temporaryPath, finalPath); - if (this.durability === QWP_SF_DURABILITY.APPEND) { - await syncDirectory(this.directory); - } else if (this.durability === QWP_SF_DURABILITY.PERIODIC) { - this.dirtyRecordPaths.add(finalPath); - this.directoryDirty = true; - } - } catch (error) { - await ignoreMissing(unlink(temporaryPath)); - throw new QwpReplayStoreError( - `could not create QWP store-and-forward segment [frameSequence=${record.frameSequence}]`, - error, - ); + if (this.durability === QWP_SF_DURABILITY.APPEND) { + await temporaryHandle.sync(); + } + await temporaryHandle.close(); + temporaryHandle = undefined; + await rename(temporaryPath, path); + if (this.durability === QWP_SF_DURABILITY.APPEND) { + await syncDirectory(this.directory); + } else if (this.durability === QWP_SF_DURABILITY.PERIODIC) { + this.directoryDirty = true; } - segment = { - path: finalPath, - firstSequence: record.frameSequence, - size: 0, - liveRecords: 0, + handle = await open(path, "r+"); + this.hotSpare = { + path, + generation, + size: this.segmentFileSize, + handle, }; - this.segments.set(finalPath, segment); - this.activeSegment = segment; - } else { - const previousSize = segment.size; + this.totalBytes = requiredBytes; + } catch (error) { + await temporaryHandle?.close().catch(() => undefined); + await handle?.close().catch(() => undefined); + await ignoreMissing(unlink(temporaryPath)); + await ignoreMissing(unlink(path)); + throw new QwpReplayStoreError( + `could not provision QWP store-and-forward hot spare [generation=${generation}]`, + error, + ); + } + } + + private scheduleHotSpare(): void { + if (this.hotSpare || this.closing || this.closed) return; + queueMicrotask(() => { + if (this.hotSpare || this.closing || this.closed) return; + void this.enqueue(() => this.ensureHotSpare(false)).catch(() => { + // Capacity exhaustion is expected: ACK trimming will make a later + // rotation retry provisioning synchronously. Other failures surface on + // that required path rather than as an unhandled background rejection. + }); + }); + } + + private scheduleMaintenance(): void { + if ( + this.maintenanceScheduled || + this.pendingTrimSegments.length === 0 || + this.closing || + this.closed + ) { + return; + } + this.maintenanceScheduled = true; + queueMicrotask(() => { + if (this.closing || this.closed) { + this.maintenanceScheduled = false; + return; + } + void this.enqueue(() => this.runMaintenanceBatch()).catch((error) => { + this.maintenanceScheduled = false; + this.maintenanceFailure = + error instanceof QwpReplayStoreError + ? error + : new QwpReplayStoreError( + `QWP store-and-forward background maintenance failed [directory=${this.directory}]`, + error, + ); + this.rejectCapacityWaiters(this.maintenanceFailure); + }); + }); + } + + private async runMaintenanceBatch(): Promise { + this.maintenanceScheduled = false; + let trimmed = 0; + while (trimmed < TRIM_BATCH_SIZE && this.pendingTrimSegments.length > 0) { + const segment = this.pendingTrimSegments[0]; + await this.trimSegment(segment); + this.pendingTrimSegments.shift(); + trimmed++; + } + if (trimmed > 0) { + if (this.durability === QWP_SF_DURABILITY.APPEND) { + await syncDirectory(this.directory); + } else if (this.durability === QWP_SF_DURABILITY.PERIODIC) { + this.directoryDirty = true; + } + this.signalCapacity(); + this.scheduleHotSpare(); + } + if (this.records.size === 0 && this.pendingTrimSegments.length === 0) { + await this.removeAcknowledgedThrough(); + } + if (this.pendingTrimSegments.length > 0) this.scheduleMaintenance(); + } + + private async drainPendingMaintenance(): Promise { + this.maintenanceFailure = undefined; + while (this.pendingTrimSegments.length > 0) { + await this.runMaintenanceBatch(); + } + if (this.records.size === 0) await this.removeAcknowledgedThrough(); + } + + private async trimSegment(segment: StoredSegment): Promise { + try { + await segment.handle?.close(); + segment.handle = undefined; + await ignoreMissing(unlink(segment.path)); + } catch (error) { + throw new QwpReplayStoreError( + `could not trim QWP store-and-forward segment [firstSequence=${segment.firstSequence}]`, + error, + ); + } + this.segments.delete(segment.path); + this.dirtyRecordPaths.delete(segment.path); + this.totalBytes -= segment.size; + if (this.activeSegment === segment) this.activeSegment = undefined; + } + + private async closeSegmentHandles(): Promise { + const handles = new Set(); + for (const segment of this.segments.values()) { + if (segment.handle) handles.add(segment.handle); + segment.handle = undefined; + } + if (this.hotSpare) handles.add(this.hotSpare.handle); + this.hotSpare = undefined; + let failure: unknown; + for (const handle of handles) { try { - const file = await open(segment.path, "a", 0o600); - try { - await file.writeFile(bytes); - if (this.durability === QWP_SF_DURABILITY.APPEND) await file.sync(); - } catch (error) { - await file.truncate(previousSize).catch(() => undefined); - throw error; - } finally { - await file.close(); - } - if (this.durability === QWP_SF_DURABILITY.PERIODIC) { - this.dirtyRecordPaths.add(segment.path); - } + await handle.close(); } catch (error) { - throw new QwpReplayStoreError( - `could not append QWP store-and-forward segment [frameSequence=${record.frameSequence}]`, - error, - ); + failure ??= error; } } - segment.size += bytes.byteLength; - segment.liveRecords++; - this.records.set(record.frameSequence, { - path: segment.path, - size: 0, - segment, - }); - this.totalBytes = requiredBytes; + if (failure) { + throw new QwpReplayStoreError( + `could not close QWP store-and-forward segment handles [directory=${this.directory}]`, + failure, + ); + } + } + + private async discardHotSpare(): Promise { + const spare = this.hotSpare; + if (!spare) return; + this.hotSpare = undefined; + try { + await spare.handle.close(); + await ignoreMissing(unlink(spare.path)); + this.totalBytes -= spare.size; + if (this.durability !== QWP_SF_DURABILITY.MEMORY) { + await syncDirectory(this.directory); + } + } catch (error) { + throw new QwpReplayStoreError( + `could not discard QWP store-and-forward hot spare [file=${spare.path}]`, + error, + ); + } } private waitForCapacity( @@ -958,6 +1250,9 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { if (this.checkpointFailure) { return Promise.reject(this.checkpointFailure); } + if (this.maintenanceFailure) { + return Promise.reject(this.maintenanceFailure); + } if (capacityGeneration !== this.capacityGeneration) { return Promise.resolve(); } @@ -1032,7 +1327,13 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { return; } try { - for (const path of this.dirtyRecordPaths) await syncFile(path); + for (const path of this.dirtyRecordPaths) { + if (this.activeSegment?.path === path && this.activeSegment.handle) { + await this.activeSegment.handle.sync(); + } else { + await syncFile(path); + } + } if (this.dictionaryDirty) { await syncFile(join(this.directory, DICTIONARY_FILE)); } @@ -1407,6 +1708,7 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { ); } if (this.checkpointFailure) throw this.checkpointFailure; + if (this.maintenanceFailure) throw this.maintenanceFailure; } private closedError(): QwpReplayStoreError { @@ -1462,33 +1764,171 @@ function decodeRecord(bytes: Buffer, name: string): QwpIngressReplayRecord { } interface DecodedSegment { + readonly generation: bigint; + readonly firstSequence: bigint; + readonly assigned: boolean; + readonly capacity: number; + readonly size: number; readonly records: QwpIngressReplayRecord[]; - readonly validBytes: number; + /** Bytes occupied by encoded records, excluding the fixed segment header. */ + readonly logicalSize: number; readonly tornTail: boolean; } +function encodeSegmentHeader( + generation: bigint, + firstSequence: bigint, + capacity: number, + assigned: boolean, +): Buffer { + const bytes = Buffer.alloc(SEGMENT_HEADER_SIZE); + SEGMENT_MAGIC.copy(bytes, 0); + bytes.writeUInt8(FORMAT_VERSION, 4); + bytes.writeUInt8(assigned ? SEGMENT_STATE_ASSIGNED : SEGMENT_STATE_SPARE, 5); + bytes.writeUInt16LE(SEGMENT_HEADER_SIZE, 6); + bytes.writeBigUInt64LE(generation, 8); + bytes.writeBigUInt64LE(firstSequence, 16); + bytes.writeUInt32LE(capacity, 24); + createHash("sha256") + .update(bytes.subarray(0, SEGMENT_HEADER_PREFIX_SIZE)) + .digest() + .copy(bytes, SEGMENT_HEADER_PREFIX_SIZE); + return bytes; +} + function decodeSegment(bytes: Buffer, name: string): DecodedSegment { + if (bytes.byteLength < SEGMENT_HEADER_SIZE) { + throw corruptRecord(name, "fixed segment is shorter than its header"); + } + if (!bytes.subarray(0, SEGMENT_MAGIC.byteLength).equals(SEGMENT_MAGIC)) { + throw corruptRecord(name, "invalid segment magic"); + } + if (bytes.readUInt8(4) !== FORMAT_VERSION) { + throw corruptRecord( + name, + `unsupported segment version ${bytes.readUInt8(4)}`, + ); + } + const state = bytes.readUInt8(5); + if (state !== SEGMENT_STATE_SPARE && state !== SEGMENT_STATE_ASSIGNED) { + throw corruptRecord(name, `invalid fixed segment state ${state}`); + } + if (bytes.readUInt16LE(6) !== SEGMENT_HEADER_SIZE) { + throw corruptRecord(name, "invalid fixed segment header size"); + } + const capacity = bytes.readUInt32LE(24); + const expectedSize = SEGMENT_HEADER_SIZE + capacity + HEADER_SIZE; + if (capacity === 0 || bytes.byteLength !== expectedSize) { + throw corruptRecord( + name, + `fixed segment has invalid capacity or size [capacity=${capacity}, expectedSize=${expectedSize}, receivedSize=${bytes.byteLength}]`, + ); + } + const expectedDigest = bytes.subarray( + SEGMENT_HEADER_PREFIX_SIZE, + SEGMENT_HEADER_SIZE, + ); + const actualDigest = createHash("sha256") + .update(bytes.subarray(0, SEGMENT_HEADER_PREFIX_SIZE)) + .digest(); + if (!actualDigest.equals(expectedDigest)) { + throw corruptRecord(name, "segment header checksum mismatch"); + } + const generation = bytes.readBigUInt64LE(8); + const firstSequence = bytes.readBigUInt64LE(16); + const assigned = state === SEGMENT_STATE_ASSIGNED; const records: QwpIngressReplayRecord[] = []; - let offset = 0; + let offset = SEGMENT_HEADER_SIZE; while (offset < bytes.byteLength) { + if (bytes[offset] === 0 && isZeroFilled(bytes, offset)) { + return { + generation, + firstSequence, + assigned, + capacity, + size: bytes.byteLength, + records, + logicalSize: offset - SEGMENT_HEADER_SIZE, + tornTail: false, + }; + } const remaining = bytes.byteLength - offset; if (remaining < HEADER_SIZE) { - return { records, validBytes: offset, tornTail: true }; + return { + generation, + firstSequence, + assigned, + capacity, + size: bytes.byteLength, + records, + logicalSize: offset - SEGMENT_HEADER_SIZE, + tornTail: true, + }; } if (!bytes.subarray(offset, offset + MAGIC.byteLength).equals(MAGIC)) { - throw corruptRecord(name, `invalid record magic at offset ${offset}`); + return { + generation, + firstSequence, + assigned, + capacity, + size: bytes.byteLength, + records, + logicalSize: offset - SEGMENT_HEADER_SIZE, + tornTail: true, + }; } const payloadLength = bytes.readUInt32LE(offset + 16); const recordEnd = offset + HEADER_SIZE + payloadLength; if (recordEnd > bytes.byteLength) { - return { records, validBytes: offset, tornTail: true }; + return { + generation, + firstSequence, + assigned, + capacity, + size: bytes.byteLength, + records, + logicalSize: offset - SEGMENT_HEADER_SIZE, + tornTail: true, + }; + } + try { + records.push( + decodeRecord(bytes.subarray(offset, recordEnd), `${name}@${offset}`), + ); + } catch (error) { + if (error instanceof QwpReplayStoreCorruptionError) { + return { + generation, + firstSequence, + assigned, + capacity, + size: bytes.byteLength, + records, + logicalSize: offset - SEGMENT_HEADER_SIZE, + tornTail: true, + }; + } + throw error; } - records.push( - decodeRecord(bytes.subarray(offset, recordEnd), `${name}@${offset}`), - ); offset = recordEnd; } - return { records, validBytes: offset, tornTail: false }; + return { + generation, + firstSequence, + assigned, + capacity, + size: bytes.byteLength, + records, + logicalSize: offset - SEGMENT_HEADER_SIZE, + tornTail: false, + }; +} + +function isZeroFilled(bytes: Buffer, offset: number): boolean { + for (let index = offset; index < bytes.byteLength; index++) { + if (bytes[index] !== 0) return false; + } + return true; } function encodeAcknowledgedThrough(frameSequence: bigint): Buffer { @@ -1632,14 +2072,16 @@ async function truncateDictionaryTail( await syncDirectory(directory); } -async function truncateSegmentTail( +async function repairSegmentTail( path: string, - size: number, + logicalEnd: number, + fixedSize: number, directory: string, ): Promise { const file = await open(path, "r+"); try { - await file.truncate(size); + await file.truncate(logicalEnd); + await file.truncate(fixedSize); await file.sync(); } finally { await file.close(); @@ -1744,12 +2186,52 @@ function validateFrameSequence(frameSequence: bigint): void { } } -function recordFileName(frameSequence: bigint): string { - return `${frameSequence.toString().padStart(20, "0")}${RECORD_SUFFIX}`; +function segmentFileName(generation: bigint): string { + return `${generation.toString().padStart(20, "0")}${SEGMENT_SUFFIX}`; +} + +function compareBigInt(left: bigint, right: bigint): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +function maxBigInt(left: bigint, right: bigint): bigint { + return left > right ? left : right; +} + +async function writeFully( + handle: FileHandle, + bytes: Uint8Array, + position: number, +): Promise { + let offset = 0; + while (offset < bytes.byteLength) { + const { bytesWritten } = await handle.write( + bytes, + offset, + bytes.byteLength - offset, + position + offset, + ); + if (bytesWritten === 0) { + throw new QwpReplayStoreError("fixed segment write made no progress"); + } + offset += bytesWritten; + } } -function segmentFileName(frameSequence: bigint): string { - return `${frameSequence.toString().padStart(20, "0")}${SEGMENT_SUFFIX}`; +async function zeroRange( + handle: FileHandle, + position: number, + length: number, +): Promise { + const zeroes = Buffer.alloc(Math.min(length, 64 * 1024)); + let remaining = length; + let offset = position; + while (remaining > 0) { + const chunk = zeroes.subarray(0, Math.min(remaining, zeroes.byteLength)); + await writeFully(handle, chunk, offset); + offset += chunk.byteLength; + remaining -= chunk.byteLength; + } } async function syncDirectory(directory: string): Promise { diff --git a/src/qwp-node/orphan-drainer.ts b/src/qwp-node/orphan-drainer.ts index c05dd86..816a747 100644 --- a/src/qwp-node/orphan-drainer.ts +++ b/src/qwp-node/orphan-drainer.ts @@ -1,4 +1,4 @@ -import { readdir, unlink, writeFile } from "node:fs/promises"; +import { open, readdir, unlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { QWP_RECONNECT_EVENT_KIND, @@ -17,8 +17,9 @@ import { type QwpSenderError, } from "../qwp/sender-error"; -const RECORD_SUFFIX = ".qwp"; -const SEGMENT_SUFFIX = ".qwps"; +const SEGMENT_SUFFIX = ".qwpseg"; +const SEGMENT_HEADER_PROBE_SIZE = 24; +const SEGMENT_STATE_SPARE = 0; const DEFAULT_MAX_CONCURRENT = 4; const DEFAULT_SCAN_INTERVAL_MS = 30_000; const DEFAULT_PROGRESS_POLL_MS = 50; @@ -157,14 +158,15 @@ export async function scanQwpNodeOrphanSlots( ) { continue; } - if ( - children.some( - (child) => - child.isFile() && - (child.name.endsWith(RECORD_SUFFIX) || - child.name.endsWith(SEGMENT_SUFFIX)), - ) - ) { + let hasAssignedSegment = false; + for (const child of children) { + if (!child.isFile() || !child.name.endsWith(SEGMENT_SUFFIX)) continue; + if (await isAssignedSegmentOrInvalid(join(directory, child.name))) { + hasAssignedSegment = true; + break; + } + } + if (hasAssignedSegment) { candidates.push(directory); } } @@ -172,6 +174,29 @@ export async function scanQwpNodeOrphanSlots( return candidates; } +async function isAssignedSegmentOrInvalid(path: string): Promise { + let handle; + try { + handle = await open(path, "r"); + const header = Buffer.alloc(SEGMENT_HEADER_PROBE_SIZE); + const { bytesRead } = await handle.read(header, 0, header.byteLength, 0); + if ( + bytesRead !== header.byteLength || + header.toString("ascii", 0, 4) !== "QWPS" || + header.readUInt8(4) !== 2 + ) { + return true; + } + return header.readUInt8(5) !== SEGMENT_STATE_SPARE; + } catch (error) { + if (nodeErrorCode(error) === "ENOENT") return false; + // Let adoption report/quarantine an unreadable or malformed segment. + return true; + } finally { + await handle?.close().catch(() => undefined); + } +} + /** * Bounded Node-only scanner and background drainer for replay slots left by * terminated producer processes. Each adopted slot uses its own connection. diff --git a/src/qwp/node.ts b/src/qwp/node.ts index 6024723..6d62dc0 100644 --- a/src/qwp/node.ts +++ b/src/qwp/node.ts @@ -67,6 +67,7 @@ export { QwpReplayStoreCheckpointError, QwpReplayStoreCorruptionError, QwpReplayStoreError, + QwpReplayStoreFormatError, QwpReplayStoreFullError, QwpReplayStoreLockedError, QwpReplayStoreQuarantinedError, diff --git a/test/qwp/node-transport.test.ts b/test/qwp/node-transport.test.ts index ffb948d..5f52733 100644 --- a/test/qwp/node-transport.test.ts +++ b/test/qwp/node-transport.test.ts @@ -1,6 +1,6 @@ import type { AddressInfo, Socket } from "node:net"; import { createServer as createTcpServer } from "node:net"; -import { mkdtemp, readdir, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, open, readdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { WebSocketServer } from "ws"; @@ -415,11 +415,7 @@ describe("QWP Node transport", () => { try { await vi.waitFor( async () => { - expect( - (await readdir(orphanDirectory)).filter((name) => - name.endsWith(".qwps"), - ), - ).toEqual([]); + expect(await assignedReplaySegments(orphanDirectory)).toEqual([]); expect(events).toContain("drained"); }, { timeout: 2_000 }, @@ -449,9 +445,7 @@ describe("QWP Node transport", () => { await seed.load(); await seed.append({ frameSequence: 0n, payload: Uint8Array.of(1) }); await seed.close(); - const [record] = (await readdir(directory)).filter((name) => - name.endsWith(".qwps"), - ); + const [record] = await assignedReplaySegments(directory); await writeFile(join(directory, record), Uint8Array.of(0)); const events: QwpReplayStoreQuarantinedError[] = []; @@ -499,9 +493,7 @@ describe("QWP Node transport", () => { expect(await readdir(quarantineDirectory)).toEqual( expect.arrayContaining([record, ".qwp.failed"]), ); - expect( - (await readdir(directory)).filter((name) => name.endsWith(".qwps")), - ).toEqual([]); + expect(await assignedReplaySegments(directory)).toEqual([]); } finally { await rm(rootDirectory, { recursive: true, force: true }); } @@ -643,9 +635,7 @@ describe("QWP Node transport", () => { await expect(sender.connect()).resolves.toBe(true); await sender.table("trades").symbol("symbol", "ETH-USD").atNow(); await expect(sender.flush()).resolves.toBe(true); - expect( - (await readdir(directory)).filter((name) => name.endsWith(".qwps")), - ).toHaveLength(1); + expect(await assignedReplaySegments(directory)).toHaveLength(1); server = new WebSocketServer({ host: "127.0.0.1", port }); server.on("headers", (headers) => { @@ -660,10 +650,7 @@ describe("QWP Node transport", () => { await listen(server); await vi.waitFor( - async () => - expect( - (await readdir(directory)).filter((name) => name.endsWith(".qwps")), - ).toEqual([]), + async () => expect(await assignedReplaySegments(directory)).toEqual([]), { timeout: 2_000 }, ); } finally { @@ -689,3 +676,40 @@ function closeServer(server: WebSocketServer): Promise { server.close((error) => (error ? reject(error) : resolve())); }); } + +async function assignedReplaySegments(directory: string): Promise { + const names = (await readdir(directory)).filter((name) => + name.endsWith(".qwpseg"), + ); + const assigned: string[] = []; + for (const name of names) { + let file; + try { + file = await open(join(directory, name), "r"); + } catch (error) { + if ( + error && + typeof error === "object" && + "code" in error && + error.code === "ENOENT" + ) { + continue; + } + throw error; + } + try { + const header = Buffer.alloc(24); + const { bytesRead } = await file.read(header, 0, header.byteLength, 0); + if ( + bytesRead !== header.byteLength || + header.toString("ascii", 0, 4) !== "QWPS" || + header.readUInt8(5) !== 0 + ) { + assigned.push(name); + } + } finally { + await file.close(); + } + } + return assigned; +} diff --git a/test/qwp/orphan-drainer.test.ts b/test/qwp/orphan-drainer.test.ts index 976a50f..5c93b49 100644 --- a/test/qwp/orphan-drainer.test.ts +++ b/test/qwp/orphan-drainer.test.ts @@ -86,7 +86,7 @@ describe("QWP Node orphan drainer", () => { ): Promise { const directory = join(rootDirectory, name); await mkdir(directory); - await writeFile(join(directory, "00000000000000000000.qwp"), "frame"); + await writeFile(join(directory, "00000000000000000000.qwpseg"), "frame"); return directory; } @@ -95,7 +95,7 @@ describe("QWP Node orphan drainer", () => { const orphan = await recordSlot(rootDirectory, "orphan"); const segmented = join(rootDirectory, "segmented"); await mkdir(segmented); - await writeFile(join(segmented, "00000000000000000000.qwps"), "segment"); + await writeFile(join(segmented, "00000000000000000000.qwpseg"), "segment"); await recordSlot(rootDirectory, "live"); const failed = await recordSlot(rootDirectory, "failed"); await writeFile(join(failed, QWP_ORPHAN_FAILED_SENTINEL), "inspect me"); @@ -160,7 +160,7 @@ describe("QWP Node orphan drainer", () => { const session = new FakeDrainSession(); session.pollDurableAck = async () => { session.pendingReplayFrames = 0; - await rm(join(directory, "00000000000000000000.qwp")); + await rm(join(directory, "00000000000000000000.qwpseg")); }; return session; }, diff --git a/test/qwp/public-api.test.ts b/test/qwp/public-api.test.ts index fb9eae1..47e517d 100644 --- a/test/qwp/public-api.test.ts +++ b/test/qwp/public-api.test.ts @@ -74,6 +74,7 @@ const nodeRuntimeContract = [ "QwpReplayStoreCheckpointError", "QwpReplayStoreCorruptionError", "QwpReplayStoreError", + "QwpReplayStoreFormatError", "QwpReplayStoreFullError", "QwpReplayStoreLockedError", "QwpReplayStoreQuarantinedError", diff --git a/test/qwp/reconnect.test.ts b/test/qwp/reconnect.test.ts index c0118a2..af7f93f 100644 --- a/test/qwp/reconnect.test.ts +++ b/test/qwp/reconnect.test.ts @@ -2,6 +2,7 @@ import { createHash } from "node:crypto"; import { mkdir, mkdtemp, + open, readdir, rm, stat, @@ -21,6 +22,7 @@ import { QwpReplayStoreCheckpointError, QwpReplayStoreCorruptionError, QwpReplayStoreError, + QwpReplayStoreFormatError, QwpReplayStoreFullError, QwpReplayStoreLockedError, QwpReplayStoreSegmentTooLargeError, @@ -2001,9 +2003,7 @@ describe("QWP ingress reconnect and replay", () => { connection.receive(ingressResponse(QWP_STATUS.OK, 0n)); connection.receive(ingressResponse(QWP_STATUS.OK, 1n)); await vi.waitFor(async () => - expect( - (await readdir(directory)).filter((name) => name.endsWith(".qwps")), - ).toEqual([]), + expect(await assignedReplaySegments(directory)).toEqual([]), ); const current = session.sendTablesDelta([symbolTable("SOL-USD")]); @@ -2225,9 +2225,7 @@ describe("QWP ingress reconnect and replay", () => { replayStore: new QwpNodeFileReplayStore({ directory }), }); expect(connection.sent).toEqual([]); - expect( - (await readdir(directory)).filter((name) => name.endsWith(".qwps")), - ).toEqual([]); + expect(await assignedReplaySegments(directory)).toEqual([]); expect(session.metrics).toMatchObject({ replayPublishedFrameSequence: 7n, replayAcknowledgedFrameSequence: 7n, @@ -2279,9 +2277,7 @@ describe("QWP ingress reconnect and replay", () => { await vi.waitFor(() => expect(session.metrics.pendingReplayFrames).toBe(3)); connection.receive(durableResponse([["trades", 42n]])); await vi.waitFor(async () => - expect( - (await readdir(directory)).filter((name) => name.endsWith(".qwps")), - ).toEqual([]), + expect(await assignedReplaySegments(directory)).toEqual([]), ); expect(session.metrics).toMatchObject({ replayAcknowledgedFrameSequence: 7n, @@ -2301,9 +2297,7 @@ describe("QWP ingress reconnect and replay", () => { await expect(current).resolves.toMatchObject({ sequence: 0n }); connection.receive(durableResponse([["trades", 43n]])); await vi.waitFor(async () => - expect( - (await readdir(directory)).filter((name) => name.endsWith(".qwps")), - ).toEqual([]), + expect(await assignedReplaySegments(directory)).toEqual([]), ); await vi.waitFor(() => expect(session.acknowledgedFrameSequence).toBe(8n)); await session.close(); @@ -2334,15 +2328,11 @@ describe("QWP ingress reconnect and replay", () => { expect(connection.sent).toEqual([deferred, commit]); connection.receive(ingressResponse(QWP_STATUS.OK, 1n, [["trades", 42n]])); await vi.waitFor(async () => - expect( - (await readdir(directory)).filter((name) => name.endsWith(".qwps")), - ).toHaveLength(1), + expect(await assignedReplaySegments(directory)).toHaveLength(1), ); connection.receive(durableResponse([["trades", 42n]])); await vi.waitFor(async () => - expect( - (await readdir(directory)).filter((name) => name.endsWith(".qwps")), - ).toEqual([]), + expect(await assignedReplaySegments(directory)).toEqual([]), ); await session.close(); await rm(directory, { recursive: true, force: true }); @@ -2363,15 +2353,11 @@ describe("QWP ingress reconnect and replay", () => { await vi.waitFor(() => expect(connection.sent).toHaveLength(1)); connection.receive(ingressResponse(QWP_STATUS.OK, 0n, [["trades", 42n]])); await expect(pending).resolves.toMatchObject({ sequence: 0n }); - expect( - (await readdir(directory)).filter((name) => name.endsWith(".qwps")), - ).toHaveLength(1); + expect(await assignedReplaySegments(directory)).toHaveLength(1); connection.receive(durableResponse([["trades", 42n]])); await vi.waitFor(async () => - expect( - (await readdir(directory)).filter((name) => name.endsWith(".qwps")), - ).toEqual([]), + expect(await assignedReplaySegments(directory)).toEqual([]), ); await session.close(); await rm(directory, { recursive: true, force: true }); @@ -2971,9 +2957,7 @@ describe("QWP Node file replay store", () => { await first.append({ frameSequence: 0n, payload: Uint8Array.of(1, 2) }); await first.append({ frameSequence: 1n, payload: Uint8Array.of(3, 4) }); await first.close(); - expect( - (await readdir(directory)).filter((name) => name.endsWith(".qwps")), - ).toHaveLength(1); + expect(await assignedReplaySegments(directory)).toHaveLength(1); const second = new QwpNodeFileReplayStore({ directory }); await expect(second.load()).resolves.toEqual([ @@ -3006,14 +2990,18 @@ describe("QWP Node file replay store", () => { await first.acknowledgeThrough(0n); await first.close(); - const segment = (await readdir(directory)).find((name) => - name.endsWith(".qwps"), - )!; + const [segment] = await assignedReplaySegments(directory); const path = join(directory, segment); - await truncate(path, 53); - await writeFile(path, encodeLegacyReplayRecord(2n, Uint8Array.of(2)), { - flag: "a", - }); + const file = await open(path, "r+"); + try { + const sequence = Buffer.alloc(8); + sequence.writeBigUInt64LE(2n); + // Fixed header + first one-byte record + record sequence field. + await file.write(sequence, 0, sequence.byteLength, 64 + 53 + 8); + await file.sync(); + } finally { + await file.close(); + } const recovered = new QwpNodeFileReplayStore({ directory }); await expect(recovered.load()).rejects.toThrow( @@ -3035,9 +3023,7 @@ describe("QWP Node file replay store", () => { payload: Uint8Array.of(1), }); } - const segments = (await readdir(directory)).filter((name) => - name.endsWith(".qwps"), - ); + const segments = await assignedReplaySegments(directory); expect(segments.length).toBeGreaterThan(1); expect(segments.length).toBeLessThan(25); expect(store.metrics).toMatchObject({ @@ -3045,26 +3031,57 @@ describe("QWP Node file replay store", () => { pendingSegments: segments.length, }); for (const segment of segments) { - expect((await stat(join(directory, segment))).size).toBeLessThanOrEqual( - 256 + 52, - ); + expect((await stat(join(directory, segment))).size).toBe(64 + 256 + 52); } await store.close(); }); + it("recovers v2 segments after maxSegmentBytes changes", async () => { + const directory = await trackedDirectory(); + const first = new QwpNodeFileReplayStore({ + directory, + maxSegmentBytes: 1, + }); + await first.load(); + await first.append({ frameSequence: 0n, payload: Uint8Array.of(1) }); + await first.close(); + + const second = new QwpNodeFileReplayStore({ + directory, + maxSegmentBytes: 256, + }); + await expect(second.load()).resolves.toEqual([ + { frameSequence: 0n, payload: Uint8Array.of(1) }, + ]); + await second.append({ frameSequence: 1n, payload: Uint8Array.of(2) }); + await second.close(); + + const third = new QwpNodeFileReplayStore({ + directory, + maxSegmentBytes: 512, + }); + await expect(third.load()).resolves.toEqual([ + { frameSequence: 0n, payload: Uint8Array.of(1) }, + { frameSequence: 1n, payload: Uint8Array.of(2) }, + ]); + await third.close(); + }); + it("repairs a torn append at the tail of the active segment", async () => { const directory = await trackedDirectory(); const first = new QwpNodeFileReplayStore({ directory }); await first.load(); await first.append({ frameSequence: 0n, payload: Uint8Array.of(1, 2, 3) }); await first.close(); - const segment = (await readdir(directory)).find((name) => - name.endsWith(".qwps"), - )!; + const [segment] = await assignedReplaySegments(directory); const validSize = (await stat(join(directory, segment))).size; - await writeFile(join(directory, segment), Uint8Array.of(0x51, 0x57), { - flag: "a", - }); + const file = await open(join(directory, segment), "r+"); + try { + await file.write(Uint8Array.of(0x51, 0x57), 0, 2, 64 + 55); + await file.sync(); + } finally { + await file.close(); + } const recovered = new QwpNodeFileReplayStore({ directory }); await expect(recovered.load()).resolves.toEqual([ @@ -3074,32 +3091,26 @@ describe("QWP Node file replay store", () => { await recovered.close(); }); - it("loads legacy file-per-frame records and writes new segmented appends", async () => { + it("rejects the retired experimental file-per-frame format", async () => { const directory = await trackedDirectory(); await writeFile( join(directory, "00000000000000000005.qwp"), encodeLegacyReplayRecord(5n, Uint8Array.of(1, 2)), ); + await writeFile( + join(directory, "00000000000000000006.qwps"), + encodeLegacyReplayRecord(6n, Uint8Array.of(3, 4)), + ); const first = new QwpNodeFileReplayStore({ directory }); - await expect(first.load()).resolves.toEqual([ - { frameSequence: 5n, payload: Uint8Array.of(1, 2) }, - ]); - await first.append({ frameSequence: 6n, payload: Uint8Array.of(3, 4) }); - await first.close(); - expect(await readdir(directory)).toEqual( - expect.arrayContaining([ - "00000000000000000005.qwp", - "00000000000000000006.qwps", - ]), + await expect(first.load()).rejects.toBeInstanceOf( + QwpReplayStoreFormatError, ); - - const recovered = new QwpNodeFileReplayStore({ directory }); - await expect(recovered.load()).resolves.toEqual([ - { frameSequence: 5n, payload: Uint8Array.of(1, 2) }, - { frameSequence: 6n, payload: Uint8Array.of(3, 4) }, + expect(await readdir(directory)).toEqual([ + "00000000000000000005.qwp", + "00000000000000000006.qwps", ]); - await recovered.close(); + await first.close(); }); it.each([ @@ -3320,7 +3331,8 @@ describe("QWP Node file replay store", () => { const directory = await trackedDirectory(); const store = new QwpNodeFileReplayStore({ directory, - maxBytes: 106, + maxBytes: 234, + maxSegmentBytes: 1, durability: QWP_SF_DURABILITY.PERIODIC, checkpointIntervalMs: 250, backpressurePolicy: QWP_SF_BACKPRESSURE_POLICY.WAIT, @@ -3329,11 +3341,8 @@ describe("QWP Node file replay store", () => { await store.load(); await store.append({ frameSequence: 0n, payload: Uint8Array.of(1) }); await store.append({ frameSequence: 1n, payload: Uint8Array.of(2) }); - const record = (await readdir(directory)).find((name) => - name.endsWith(".qwps"), - ); - expect(record).toBeDefined(); - await unlink(join(directory, record!)); + await store.appendSymbolDictionary(0, ["BTC-USD"]); + await unlink(join(directory, "symbols.qwpdict")); const blocked = store.append({ frameSequence: 2n, @@ -3356,7 +3365,7 @@ describe("QWP Node file replay store", () => { const directory = await trackedDirectory(); const store = new QwpNodeFileReplayStore({ directory, - maxBytes: 106, + maxBytes: 234, maxSegmentBytes: 1, backpressurePolicy: QWP_SF_BACKPRESSURE_POLICY.WAIT, appendDeadlineMs: 1_000, @@ -3385,7 +3394,8 @@ describe("QWP Node file replay store", () => { const directory = await trackedDirectory(); const store = new QwpNodeFileReplayStore({ directory, - maxBytes: 106, + maxBytes: 234, + maxSegmentBytes: 1, backpressurePolicy: QWP_SF_BACKPRESSURE_POLICY.WAIT, appendDeadlineMs: 100, }); @@ -3399,8 +3409,8 @@ describe("QWP Node file replay store", () => { }); const rejection = expect(blocked).rejects.toMatchObject({ name: "QwpReplayStoreAppendTimeoutError", - maxBytes: 106, - requiredBytes: 159, + maxBytes: 234, + requiredBytes: 351, timeoutMs: 100, } satisfies Partial); await vi.waitFor(() => expect(store.metrics.waitingAppends).toBe(1)); @@ -3418,6 +3428,7 @@ describe("QWP Node file replay store", () => { const first = new QwpNodeFileReplayStore({ directory, maxBytes: 60, + maxSegmentBytes: 1, }); await first.load(); // Header + block metadata + this entry occupy 66 bytes, already above @@ -3439,6 +3450,7 @@ describe("QWP Node file replay store", () => { const recovered = new QwpNodeFileReplayStore({ directory, maxBytes: 60, + maxSegmentBytes: 1, }); await expect(recovered.load()).resolves.toEqual([ { frameSequence: 1n, payload: Uint8Array.of(2) }, @@ -3459,9 +3471,7 @@ describe("QWP Node file replay store", () => { await first.load(); await first.append({ frameSequence: 0n, payload: Uint8Array.of(1) }); await first.close(); - const [record] = (await readdir(directory)).filter((name) => - name.endsWith(".qwps"), - ); + const [record] = await assignedReplaySegments(directory); await writeFile(join(directory, record), Uint8Array.of(0)); const recovered = new QwpNodeFileReplayStore({ directory }); @@ -3485,6 +3495,43 @@ async function createTemporaryDirectory(): Promise { return mkdtemp(join(tmpdir(), "qwp-replay-")); } +async function assignedReplaySegments(directory: string): Promise { + const names = (await readdir(directory)).filter((name) => + name.endsWith(".qwpseg"), + ); + const assigned: string[] = []; + for (const name of names) { + let file; + try { + file = await open(join(directory, name), "r"); + } catch (error) { + if ( + error && + typeof error === "object" && + "code" in error && + error.code === "ENOENT" + ) { + continue; + } + throw error; + } + try { + const header = Buffer.alloc(24); + const { bytesRead } = await file.read(header, 0, header.byteLength, 0); + if ( + bytesRead !== header.byteLength || + header.toString("ascii", 0, 4) !== "QWPS" || + header.readUInt8(5) !== 0 + ) { + assigned.push(name); + } + } finally { + await file.close(); + } + } + return assigned; +} + function encodeLegacyReplayRecord( frameSequence: bigint, payload: Uint8Array, From 514da340b560b5f266c1a8b14f68b04b90b02ead Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 19 Aug 2026 14:25:03 +0100 Subject: [PATCH 074/265] feat(qwp): adopt cross-client SFA persistence format --- QWP.md | 31 +- README.md | 9 +- src/qwp-node/file-replay-store.ts | 1141 ++++++++++++----- src/qwp-node/orphan-drainer.ts | 20 +- src/qwp/node.ts | 3 +- test/qwp/fixtures/sfa/README.md | 15 + .../java-two-chunk-torn-tail.symbol-dict.hex | 2 + .../sfa/java-two-chunk.symbol-dict.hex | 2 + .../sfa/java-two-frame-torn-tail.sfa.hex | 3 + test/qwp/fixtures/sfa/java-two-frame.sfa.hex | 3 + test/qwp/node-transport.test.ts | 39 +- test/qwp/orphan-drainer.test.ts | 22 +- test/qwp/public-api.test.ts | 1 - test/qwp/reconnect.test.ts | 152 +-- test/qwp/sfa-interop.test.ts | 182 +++ 15 files changed, 1095 insertions(+), 530 deletions(-) create mode 100644 test/qwp/fixtures/sfa/README.md create mode 100644 test/qwp/fixtures/sfa/java-two-chunk-torn-tail.symbol-dict.hex create mode 100644 test/qwp/fixtures/sfa/java-two-chunk.symbol-dict.hex create mode 100644 test/qwp/fixtures/sfa/java-two-frame-torn-tail.sfa.hex create mode 100644 test/qwp/fixtures/sfa/java-two-frame.sfa.hex create mode 100644 test/qwp/sfa-interop.test.ts diff --git a/QWP.md b/QWP.md index 4c1dcd4..765d978 100644 --- a/QWP.md +++ b/QWP.md @@ -213,16 +213,23 @@ Locks left by a terminated process on the same host are recovered automatically; locks owned by a live local process, another host, or an unidentifiable owner fail closed. -New journals use fixed-size `.qwpseg` files. Each file reserves a 64-byte segment -header, `maxSegmentBytes` of target data (4 MiB by default), and one record header so -a maximum-sized frame fits. The active segment and one unassigned hot spare keep open -file handles; rotation activates the spare and provisions its replacement away from -the normal append path. ACK trimming runs in bounded background batches. - -This v2 layout intentionally does not read the retired experimental file-per-frame -`.qwp` or variable `.qwps` formats. `load()` raises `QwpReplayStoreFormatError` and -leaves those files untouched. Drain such a slot with the previous client or discard -it explicitly before upgrading. +New journals use the cross-client SFA persistence layout. Fixed-size +`sf-.sfa` files have the Java/Rust 24-byte `SF01` header and +`[crc32c, payloadLength, payload]` frame envelope. `sf-manifest.bin` and +`.ack-watermark` use the shared dual-slot checksummed metadata layout, while +`.symbol-dict` uses the shared chunked `SYD1` representation. TypeScript tests load +Java-produced segment and dictionary fixtures and compare TypeScript output with the +same normalized bytes. + +Each segment reserves `maxSegmentBytes` of target payload data (4 MiB by default) +plus one frame header so a maximum-sized frame fits. The active segment and one +preallocated temporary hot spare keep open file handles; rotation activates the +spare and provisions its replacement away from the normal append path. ACK trimming +advances the durable manifest head before unlinking segments and runs in bounded +background batches. + +Recovery also handles the canonical creation crash window in which a valid SFA +segment becomes durable before its manifest. On startup, a dictionary sidecar truncated at a complete-block boundary is rebuilt from the ordered symbol deltas embedded in surviving committed frames and healed @@ -992,8 +999,8 @@ client prewarms every persistent sender slot (overriding `senderPoolMin`) so jou left by previously busy slots are recovered even when current traffic is lower. A client-level orphan scanner also drains canonical `sender-N` slots outside the current pool range, covering restarts where `senderPoolMax` was reduced. This managed-slot -recovery is automatic; `drainOrphans: true` additionally adopts noncanonical/legacy -sibling slots beneath the pool root. +recovery is automatic; `drainOrphans: true` additionally adopts noncanonical sibling +slots beneath the pool root. ## Error handling and cleanup diff --git a/README.md b/README.md index 29d3ae3..a7c14c4 100644 --- a/README.md +++ b/README.md @@ -117,11 +117,10 @@ budget settings without an explicit mode promotes initial startup to `"sync"`, matching the Java client. The configuration-string equivalent is `initial_connect_retry`, used together with the store-and-forward options in `extraOptions.qwp`. -Persistent frames are coalesced into fixed-size 4 MiB `.qwpseg` segments by default, -with persistent active/hot-spare handles and a checksummed ACK cursor for partially -acknowledged segments. The retired experimental `.qwp`/`.qwps` disk format is not -readable; drain it with the previous client or explicitly discard the slot before -upgrading. +Persistent frames are coalesced into fixed-size 4 MiB `.sfa` segments by default, +using the shared Java/Rust/Python SFA envelope, manifest, ACK watermark, and symbol +dictionary formats. The active segment and a preallocated temporary hot spare keep +open handles. Set `drainOrphans: true` when sibling journal directories share a dedicated parent: the Node client scans and drains slots left by failed producer processes with bounded concurrency. Pooled QWP clients recover out-of-range `sender-N` slots automatically, diff --git a/src/qwp-node/file-replay-store.ts b/src/qwp-node/file-replay-store.ts index afbf664..ca8d7a7 100644 --- a/src/qwp-node/file-replay-store.ts +++ b/src/qwp-node/file-replay-store.ts @@ -1,4 +1,4 @@ -import { createHash, randomUUID } from "node:crypto"; +import { randomUUID } from "node:crypto"; import { mkdir, open, @@ -20,27 +20,26 @@ import { QwpIngressReplayStore, } from "../qwp/transport"; -const MAGIC = Buffer.from("QWPR"); -const FORMAT_VERSION = 2; -const HEADER_SIZE = 52; -const SHA256_SIZE = 32; -const MAX_FRAME_SEQUENCE = 0xffffffffffffffffn; -const LEGACY_RECORD_SUFFIX = ".qwp"; -const LEGACY_SEGMENT_SUFFIX = ".qwps"; -const SEGMENT_MAGIC = Buffer.from("QWPS"); -const SEGMENT_SUFFIX = ".qwpseg"; -const SEGMENT_HEADER_SIZE = 64; -const SEGMENT_HEADER_PREFIX_SIZE = 32; -const SEGMENT_STATE_SPARE = 0; -const SEGMENT_STATE_ASSIGNED = 1; +const FORMAT_VERSION = 1; +const MAX_FRAME_SEQUENCE = 0x7fffffffffffffffn; +const SEGMENT_MAGIC = Buffer.from("SF01"); +const SEGMENT_PREFIX = "sf-"; +const SEGMENT_SUFFIX = ".sfa"; +const SEGMENT_HEADER_SIZE = 24; +const FRAME_HEADER_SIZE = 8; +const MANIFEST_REQUIRED_FLAG = 1; +const MANIFEST_MAGIC = Buffer.from("SFM1"); +const MANIFEST_FILE = "sf-manifest.bin"; const TEMP_MARKER = ".tmp-"; -const ACK_MAGIC = Buffer.from("QWPA"); -const ACK_FILE = "ack.qwpstate"; -const ACK_STATE_SIZE = 48; -const DICTIONARY_MAGIC = Buffer.from("QWPD"); -const DICTIONARY_FILE = "symbols.qwpdict"; +const ACK_MAGIC = Buffer.from("AKW1"); +const ACK_FILE = ".ack-watermark"; +const DICTIONARY_MAGIC = Buffer.from("SYD1"); +const DICTIONARY_FILE = ".symbol-dict"; const DICTIONARY_HEADER_SIZE = 8; -const DICTIONARY_BLOCK_HEADER_SIZE = 44; +const DUAL_SLOT_FILE_SIZE = 8 * 1024; +const RECORD_SLOT_SIZE = 4 * 1024; +const METADATA_RECORD_SIZE = 64; +const METADATA_CRC_OFFSET = 60; const LOCK_DIRECTORY = ".qwp.lock"; const LOCK_OWNER_FILE = "owner.json"; const LOCK_RECOVERY_FILE = "recovery.json"; @@ -83,17 +82,18 @@ interface StoredRecord { interface StoredSegment { readonly path: string; - readonly generation: bigint; readonly firstSequence: bigint; readonly capacity: number; readonly size: number; logicalSize: number; liveRecords: number; + frameCount: number; + manifestFlagPending: boolean; handle?: FileHandle; } interface HotSpareSegment { - readonly path: string; + path: string; readonly generation: bigint; readonly size: number; readonly handle: FileHandle; @@ -130,7 +130,7 @@ export interface QwpNodeFileReplayStoreOptions { maxBytes?: number; /** * Maximum QWP frame payload and target segment data size. Each fixed segment - * reserves this value plus one record header and its 64-byte segment header, + * reserves this value plus one record header and its 24-byte SFA header, * so a maximum-sized frame still fits. Defaults to 4 MiB. */ maxSegmentBytes?: number; @@ -186,16 +186,6 @@ export class QwpReplayStoreCorruptionError extends QwpReplayStoreError { } } -/** An existing journal belongs to the retired experimental disk format. */ -export class QwpReplayStoreFormatError extends QwpReplayStoreError { - constructor(readonly directory: string) { - super( - `QWP store-and-forward journal uses the retired experimental disk format [directory=${directory}]; drain it with the previous client or explicitly discard the slot`, - ); - this.name = "QwpReplayStoreFormatError"; - } -} - /** A terminal replay slot was preserved under a quarantine pathname. */ export class QwpReplayStoreQuarantinedError extends QwpReplayStoreError { constructor( @@ -329,6 +319,11 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { private activeSegment?: StoredSegment; private hotSpare?: HotSpareSegment; private nextSegmentGeneration = 0n; + private manifestGeneration = 0n; + private manifestHeadBase?: bigint; + private manifestActiveBase?: bigint; + private manifestInvalid = false; + private ackGeneration = 0n; private maintenanceScheduled = false; constructor(options: QwpNodeFileReplayStoreOptions) { @@ -337,9 +332,9 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { throw new RangeError("store-and-forward directory must not be empty"); } const maxBytes = options.maxBytes ?? 1024 * 1024 * 1024; - if (!Number.isSafeInteger(maxBytes) || maxBytes <= HEADER_SIZE) { + if (!Number.isSafeInteger(maxBytes) || maxBytes <= SEGMENT_HEADER_SIZE) { throw new RangeError( - `store-and-forward maxBytes must be a safe integer greater than ${HEADER_SIZE}`, + `store-and-forward maxBytes must be a safe integer greater than ${SEGMENT_HEADER_SIZE}`, ); } this.directory = directory; @@ -354,7 +349,7 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { ); } this.segmentFileSize = - SEGMENT_HEADER_SIZE + this.maxSegmentBytes + HEADER_SIZE; + SEGMENT_HEADER_SIZE + FRAME_HEADER_SIZE + this.maxSegmentBytes; if (!Number.isSafeInteger(this.segmentFileSize)) { throw new RangeError( "store-and-forward maxSegmentBytes is too large for a fixed segment", @@ -423,27 +418,19 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { const entries = await readdir(this.directory, { withFileTypes: true }); const segmentNames: string[] = []; let removedTemporaryFile = false; - let hasRetiredFormat = false; for (const entry of entries) { if (!entry.isFile()) continue; if (entry.name.includes(TEMP_MARKER)) { await ignoreMissing(unlink(join(this.directory, entry.name))); removedTemporaryFile = true; - } else if ( - entry.name.endsWith(LEGACY_RECORD_SUFFIX) || - entry.name.endsWith(LEGACY_SEGMENT_SUFFIX) - ) { - hasRetiredFormat = true; } else if (entry.name.endsWith(SEGMENT_SUFFIX)) { segmentNames.push(entry.name); } } if (removedTemporaryFile) await syncDirectory(this.directory); - if (hasRetiredFormat) { - throw new QwpReplayStoreFormatError(this.directory); - } segmentNames.sort(); + await this.loadManifest(); const acknowledgedThrough = await this.loadAcknowledgedThrough(); const recoveredEntries: RecoveredStoredRecord[] = []; const recoveredSegments: Array<{ @@ -451,11 +438,6 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { readonly path: string; readonly decoded: DecodedSegment; }> = []; - const spareSegments: Array<{ - readonly name: string; - readonly path: string; - readonly decoded: DecodedSegment; - }> = []; for (const name of segmentNames) { const path = join(this.directory, name); let bytes: Buffer; @@ -467,30 +449,15 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { error, ); } + const generation = parseSegmentGeneration(name); const decoded = decodeSegment(bytes, name); - const expectedName = segmentFileName(decoded.generation); - if (name !== expectedName) { - throw new QwpReplayStoreCorruptionError( - `QWP store-and-forward segment filename does not match its generation [file=${name}, expected=${expectedName}]`, + if (generation !== undefined) { + this.nextSegmentGeneration = maxBigInt( + this.nextSegmentGeneration, + generation + 1n, ); } - this.nextSegmentGeneration = maxBigInt( - this.nextSegmentGeneration, - decoded.generation + 1n, - ); - const entry = { name, path, decoded }; - if (!decoded.assigned) { - if ( - decoded.records.length !== 0 || - decoded.logicalSize !== 0 || - decoded.tornTail - ) { - throw corruptRecord(name, "unassigned spare contains records"); - } - spareSegments.push(entry); - } else { - recoveredSegments.push(entry); - } + recoveredSegments.push({ name, path, decoded }); } recoveredSegments.sort((left, right) => compareBigInt( @@ -498,11 +465,25 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { right.decoded.firstSequence, ), ); + const selectedActivePath = selectRecoveredActivePath( + recoveredSegments, + this.manifestActiveBase, + ); + const manifestStalePaths = await this.validateRecoveredManifest( + recoveredSegments, + selectedActivePath, + ); let changedDirectory = false; + const removalPaths: string[] = []; for (let index = 0; index < recoveredSegments.length; index++) { const { name, path, decoded } = recoveredSegments[index]; + if (manifestStalePaths.has(path)) { + removalPaths.push(path); + changedDirectory = true; + continue; + } if (decoded.tornTail) { - if (index !== recoveredSegments.length - 1) { + if (path !== selectedActivePath) { throw corruptRecord( name, "non-active segment has a torn record tail", @@ -527,19 +508,22 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { const liveRecords = decoded.records.filter( (record) => record.frameSequence > acknowledgedThrough, ); - if (liveRecords.length === 0) { - await ignoreMissing(unlink(path)); + const retainEmptyActive = + decoded.records.length === 0 && path === selectedActivePath; + if (liveRecords.length === 0 && !retainEmptyActive) { + removalPaths.push(path); changedDirectory = true; continue; } const segment: StoredSegment = { path, - generation: decoded.generation, firstSequence: decoded.firstSequence, capacity: decoded.capacity, size: decoded.size, logicalSize: decoded.logicalSize, liveRecords: liveRecords.length, + frameCount: decoded.records.length, + manifestFlagPending: false, }; this.segments.set(path, segment); this.totalBytes += segment.size; @@ -549,26 +533,30 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { stored: { path, size: 0, segment }, }); } - this.activeSegment = segment; + if (path === selectedActivePath) { + this.activeSegment = segment; + } + } + if (this.segments.size > 0) { + await this.rewriteManifestForCurrentSegments(); + for (const segment of this.segments.values()) { + await markSegmentManifestRequired(segment.path); + } + } else if ( + removalPaths.length > 0 && + this.manifestHeadBase !== undefined + ) { + const collapsed = + acknowledgedThrough >= 0n + ? acknowledgedThrough + 1n + : (this.manifestActiveBase ?? this.manifestHeadBase); + await this.writeManifest(collapsed, collapsed); } + for (const path of removalPaths) await ignoreMissing(unlink(path)); + if (this.segments.size === 0) await this.removeManifest(); if (this.activeSegment) { this.activeSegment.handle = await open(this.activeSegment.path, "r+"); } - for (let index = 0; index < spareSegments.length; index++) { - const { path, decoded } = spareSegments[index]; - if (!this.hotSpare && decoded.capacity === this.maxSegmentBytes) { - this.hotSpare = { - path, - generation: decoded.generation, - size: decoded.size, - handle: await open(path, "r+"), - }; - this.totalBytes += decoded.size; - } else { - await ignoreMissing(unlink(path)); - changedDirectory = true; - } - } if (changedDirectory) await syncDirectory(this.directory); recoveredEntries.sort((left, right) => left.record.frameSequence < right.record.frameSequence @@ -944,19 +932,45 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { `QWP store-and-forward sequence already exists [frameSequence=${record.frameSequence}]`, ); } - let segment = this.activeSegment; + const lastSequence = + lastMapKey(this.records) ?? + (this.acknowledgedThrough >= 0n ? this.acknowledgedThrough : undefined); if ( - !segment || - segment.logicalSize + bytes.byteLength > segment.capacity + HEADER_SIZE + lastSequence !== undefined && + record.frameSequence !== lastSequence + 1n ) { + throw new QwpReplayStoreError( + `QWP store-and-forward sequence must be contiguous [previous=${lastSequence}, received=${record.frameSequence}]`, + ); + } + let segment = this.activeSegment; + if (!segment || segment.logicalSize + bytes.byteLength > segment.capacity) { segment = await this.activateHotSpare(record.frameSequence); } + const expectedSequence = segment.firstSequence + BigInt(segment.frameCount); + if (record.frameSequence !== expectedSequence) { + throw new QwpReplayStoreError( + `QWP store-and-forward segment sequence must be contiguous [expected=${expectedSequence}, received=${record.frameSequence}]`, + ); + } const handle = segment.handle; if (!handle) { throw new QwpReplayStoreError( `active QWP store-and-forward segment is not open [file=${segment.path}]`, ); } + if (segment.manifestFlagPending) { + try { + await writeFully(handle, Uint8Array.of(MANIFEST_REQUIRED_FLAG), 5); + await handle.sync(); + segment.manifestFlagPending = false; + } catch (error) { + throw new QwpReplayStoreError( + `could not stamp the QWP store-and-forward manifest-required flag [file=${segment.path}]`, + error, + ); + } + } const writeOffset = SEGMENT_HEADER_SIZE + segment.logicalSize; try { await writeFully(handle, bytes, writeOffset); @@ -979,6 +993,7 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { } segment.logicalSize += bytes.byteLength; segment.liveRecords++; + segment.frameCount++; this.records.set(record.frameSequence, { path: segment.path, size: 0, @@ -1007,22 +1022,22 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { this.totalBytes + this.segmentFileSize, ); } + const finalPath = join(this.directory, segmentFileName(spare.generation)); try { + // Publish a manifest-optional empty segment first. If the process dies + // before the manifest update, recovery can safely adopt this file. Once + // the durable boundary names it, flip the header flag so future recovery + // must fail closed if the manifest disappears. await writeFully( spare.handle, - encodeSegmentHeader( - spare.generation, - firstSequence, - this.maxSegmentBytes, - true, - ), + encodeSegmentHeader(firstSequence, false), 0, ); - if (this.durability === QWP_SF_DURABILITY.APPEND) { - await spare.handle.sync(); - } else if (this.durability === QWP_SF_DURABILITY.PERIODIC) { - this.dirtyRecordPaths.add(spare.path); - } + await spare.handle.sync(); + await rename(spare.path, finalPath); + spare.path = finalPath; + await syncDirectory(this.directory); + await this.advanceManifestForActivation(firstSequence); } catch (error) { throw new QwpReplayStoreError( `could not activate QWP store-and-forward hot spare [frameSequence=${firstSequence}]`, @@ -1030,18 +1045,31 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { ); } const segment: StoredSegment = { - path: spare.path, - generation: spare.generation, + path: finalPath, firstSequence, - capacity: this.maxSegmentBytes, + capacity: spare.size - SEGMENT_HEADER_SIZE, size: spare.size, logicalSize: 0, liveRecords: 0, + frameCount: 0, + manifestFlagPending: true, handle: spare.handle, }; this.hotSpare = undefined; this.segments.set(segment.path, segment); this.activeSegment = segment; + try { + await writeFully(spare.handle, Uint8Array.of(MANIFEST_REQUIRED_FLAG), 5); + await spare.handle.sync(); + segment.manifestFlagPending = false; + } catch (error) { + // The manifest already durably names this segment, so it must remain in + // the ring. The next append retries only the idempotent flag stamp. + throw new QwpReplayStoreError( + `could not stamp the QWP store-and-forward manifest-required flag [file=${segment.path}]`, + error, + ); + } if (previous && previous.liveRecords === 0) { await this.trimSegment(previous); } @@ -1063,7 +1091,6 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { } const generation = this.nextSegmentGeneration++; const name = segmentFileName(generation); - const path = join(this.directory, name); const temporaryPath = join( this.directory, `${name}${TEMP_MARKER}${process.pid}-${randomUUID()}`, @@ -1073,25 +1100,13 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { try { temporaryHandle = await open(temporaryPath, "wx+", 0o600); await temporaryHandle.truncate(this.segmentFileSize); - await writeFully( - temporaryHandle, - encodeSegmentHeader(generation, 0n, this.maxSegmentBytes, false), - 0, - ); if (this.durability === QWP_SF_DURABILITY.APPEND) { await temporaryHandle.sync(); } - await temporaryHandle.close(); + handle = temporaryHandle; temporaryHandle = undefined; - await rename(temporaryPath, path); - if (this.durability === QWP_SF_DURABILITY.APPEND) { - await syncDirectory(this.directory); - } else if (this.durability === QWP_SF_DURABILITY.PERIODIC) { - this.directoryDirty = true; - } - handle = await open(path, "r+"); this.hotSpare = { - path, + path: temporaryPath, generation, size: this.segmentFileSize, handle, @@ -1101,7 +1116,6 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { await temporaryHandle?.close().catch(() => undefined); await handle?.close().catch(() => undefined); await ignoreMissing(unlink(temporaryPath)); - await ignoreMissing(unlink(path)); throw new QwpReplayStoreError( `could not provision QWP store-and-forward hot spare [generation=${generation}]`, error, @@ -1186,7 +1200,22 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { try { await segment.handle?.close(); segment.handle = undefined; + const remaining = [...this.segments.values()] + .filter((candidate) => candidate !== segment) + .sort((left, right) => + compareBigInt(left.firstSequence, right.firstSequence), + ); + if (remaining.length > 0) { + await this.writeManifest( + remaining[0].firstSequence, + remaining[remaining.length - 1].firstSequence, + ); + } else { + const collapsed = segment.firstSequence + BigInt(segment.frameCount); + await this.writeManifest(collapsed, collapsed); + } await ignoreMissing(unlink(segment.path)); + if (remaining.length === 0) await this.removeManifest(); } catch (error) { throw new QwpReplayStoreError( `could not trim QWP store-and-forward segment [firstSequence=${segment.firstSequence}]`, @@ -1356,6 +1385,220 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { } } + private async loadManifest(): Promise { + const path = join(this.directory, MANIFEST_FILE); + let bytes: Buffer; + try { + bytes = await readFile(path); + } catch (error) { + if (nodeErrorCode(error) === "ENOENT") return; + throw new QwpReplayStoreError( + "could not read QWP store-and-forward manifest", + error, + ); + } + if (bytes.byteLength !== DUAL_SLOT_FILE_SIZE) { + this.manifestInvalid = true; + return; + } + const record = decodeLatestMetadataRecord(bytes, MANIFEST_MAGIC); + if (!record || record.first < 0n || record.second < record.first) { + this.manifestInvalid = true; + return; + } + this.manifestGeneration = record.generation; + this.manifestHeadBase = record.first; + this.manifestActiveBase = record.second; + } + + private async validateRecoveredManifest( + segments: readonly { + readonly name: string; + readonly path: string; + readonly decoded: DecodedSegment; + }[], + selectedActivePath: string | undefined, + ): Promise> { + const stale = new Set(); + const requiresManifest = segments.some( + ({ decoded }) => decoded.manifestRequired, + ); + if ( + this.manifestHeadBase === undefined || + this.manifestActiveBase === undefined + ) { + if (requiresManifest) { + throw new QwpReplayStoreCorruptionError( + `QWP store-and-forward segments require a valid ${MANIFEST_FILE}`, + ); + } + if (this.manifestInvalid) { + await ignoreMissing(unlink(join(this.directory, MANIFEST_FILE))); + await syncDirectory(this.directory); + this.manifestInvalid = false; + } + for (const { decoded, path } of segments) { + if (decoded.records.length > 0 || path === selectedActivePath) continue; + if (decoded.tornTail) { + throw new QwpReplayStoreCorruptionError( + `QWP store-and-forward empty extra segment contains a torn tail [file=${path}]`, + ); + } + stale.add(path); + } + return stale; + } + + const head = this.manifestHeadBase; + const active = this.manifestActiveBase; + if (segments.length === 0) { + if (head !== active) { + throw new QwpReplayStoreCorruptionError( + `QWP store-and-forward manifest references a missing segment chain [headBase=${head}, activeBase=${active}]`, + ); + } + await this.removeManifest(); + return stale; + } + + const committed = segments.filter(({ decoded, path }) => { + if (decoded.records.length === 0 && path !== selectedActivePath) { + if (decoded.tornTail) { + throw new QwpReplayStoreCorruptionError( + `QWP store-and-forward empty extra segment contains a torn tail [file=${path}]`, + ); + } + stale.add(path); + return false; + } + if (decoded.firstSequence < head) { + const end = decoded.firstSequence + BigInt(decoded.records.length); + if (end > head) { + throw new QwpReplayStoreCorruptionError( + `QWP store-and-forward segment overlaps the manifest head boundary [base=${decoded.firstSequence}, end=${end}, headBase=${head}]`, + ); + } + stale.add(path); + return false; + } + if (decoded.firstSequence > active) { + if (decoded.records.length !== 0) { + throw new QwpReplayStoreCorruptionError( + `QWP store-and-forward segment lies beyond the manifest active boundary [file=${decoded.firstSequence}, activeBase=${active}]`, + ); + } + stale.add(path); + return false; + } + return true; + }); + if ( + committed.length === 0 || + committed[0].decoded.firstSequence !== head || + committed[committed.length - 1].decoded.firstSequence !== active + ) { + if (committed.length === 0 && head === active) return stale; + throw new QwpReplayStoreCorruptionError( + `QWP store-and-forward manifest boundaries do not match the segment chain [headBase=${head}, activeBase=${active}]`, + ); + } + for (let index = 1; index < committed.length; index++) { + const previous = committed[index - 1].decoded; + const expected = previous.firstSequence + BigInt(previous.records.length); + if (committed[index].decoded.firstSequence !== expected) { + throw new QwpReplayStoreCorruptionError( + `QWP store-and-forward segment chain has a gap [previousBase=${previous.firstSequence}, expected=${expected}, received=${committed[index].decoded.firstSequence}]`, + ); + } + } + return stale; + } + + private async advanceManifestForActivation( + firstSequence: bigint, + ): Promise { + const head = this.manifestHeadBase ?? firstSequence; + await this.writeManifest(head, firstSequence); + } + + private async rewriteManifestForCurrentSegments(): Promise { + const current = [...this.segments.values()].sort((left, right) => + compareBigInt(left.firstSequence, right.firstSequence), + ); + if (current.length === 0) { + await this.removeManifest(); + return; + } + await this.writeManifest( + current[0].firstSequence, + current[current.length - 1].firstSequence, + ); + } + + private async writeManifest( + headBase: bigint, + activeBase: bigint, + ): Promise { + if (this.manifestGeneration > 0n) { + if ( + this.manifestHeadBase !== undefined && + headBase < this.manifestHeadBase + ) { + headBase = this.manifestHeadBase; + } + if ( + this.manifestActiveBase !== undefined && + activeBase < this.manifestActiveBase + ) { + activeBase = this.manifestActiveBase; + } + if ( + headBase === this.manifestHeadBase && + activeBase === this.manifestActiveBase + ) { + return; + } + } + if (headBase < 0n || activeBase < headBase) { + throw new QwpReplayStoreCorruptionError( + `invalid QWP store-and-forward manifest boundaries [headBase=${headBase}, activeBase=${activeBase}]`, + ); + } + const path = join(this.directory, MANIFEST_FILE); + const nextGeneration = this.manifestGeneration + 1n; + const file = await openMetadataFile(path); + try { + const record = encodeMetadataRecord( + MANIFEST_MAGIC, + nextGeneration, + headBase, + activeBase, + ); + await writeFully( + file, + record, + Number((nextGeneration & 1n) * BigInt(RECORD_SLOT_SIZE)), + ); + await file.sync(); + } finally { + await file.close(); + } + await syncDirectory(this.directory); + this.manifestGeneration = nextGeneration; + this.manifestHeadBase = headBase; + this.manifestActiveBase = activeBase; + this.manifestInvalid = false; + } + + private async removeManifest(): Promise { + await ignoreMissing(unlink(join(this.directory, MANIFEST_FILE))); + await syncDirectory(this.directory); + this.manifestGeneration = 0n; + this.manifestHeadBase = undefined; + this.manifestActiveBase = undefined; + this.manifestInvalid = false; + } + private async loadAcknowledgedThrough(): Promise { const path = join(this.directory, ACK_FILE); let bytes: Buffer; @@ -1368,7 +1611,26 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { error, ); } - this.acknowledgedThrough = decodeAcknowledgedThrough(bytes); + if (bytes.byteLength !== DUAL_SLOT_FILE_SIZE) { + // A wrong-sized file is not valid dual-slot metadata. The watermark is + // only a duplicate-suppression hint, so resetting it is conservative. + await replaceFile( + path, + Buffer.alloc(DUAL_SLOT_FILE_SIZE), + this.directory, + ); + this.ackGeneration = 0n; + this.acknowledgedThrough = -1n; + return this.acknowledgedThrough; + } + const record = decodeLatestMetadataRecord(bytes, ACK_MAGIC); + if (!record || record.first < -1n) { + this.ackGeneration = 0n; + this.acknowledgedThrough = -1n; + return this.acknowledgedThrough; + } + this.ackGeneration = record.generation; + this.acknowledgedThrough = record.first; return this.acknowledgedThrough; } @@ -1376,27 +1638,32 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { frameSequence: bigint, ): Promise { if (frameSequence <= this.acknowledgedThrough) return; - const name = `${ACK_FILE}${TEMP_MARKER}${process.pid}-${randomUUID()}`; - const temporaryPath = join(this.directory, name); const finalPath = join(this.directory, ACK_FILE); + const nextGeneration = this.ackGeneration + 1n; + const record = encodeMetadataRecord( + ACK_MAGIC, + nextGeneration, + frameSequence, + 0n, + ); try { - const file = await open(temporaryPath, "wx", 0o600); + const file = await openMetadataFile(finalPath); try { - await file.writeFile(encodeAcknowledgedThrough(frameSequence)); + await writeFully( + file, + record, + Number((nextGeneration & 1n) * BigInt(RECORD_SLOT_SIZE)), + ); if (this.durability === QWP_SF_DURABILITY.APPEND) await file.sync(); } finally { await file.close(); } - await rename(temporaryPath, finalPath); - if (this.durability === QWP_SF_DURABILITY.APPEND) { - await syncDirectory(this.directory); - } else if (this.durability === QWP_SF_DURABILITY.PERIODIC) { + if (this.durability === QWP_SF_DURABILITY.PERIODIC) { this.acknowledgementDirty = true; - this.directoryDirty = true; } + this.ackGeneration = nextGeneration; this.acknowledgedThrough = frameSequence; } catch (error) { - await ignoreMissing(unlink(temporaryPath)); throw new QwpReplayStoreError( `could not persist QWP store-and-forward ACK watermark [frameSequence=${frameSequence}]`, error, @@ -1408,6 +1675,7 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { if (this.acknowledgedThrough < 0n) return; await ignoreMissing(unlink(join(this.directory, ACK_FILE))); this.acknowledgedThrough = -1n; + this.ackGeneration = 0n; this.acknowledgementDirty = false; if (this.durability === QWP_SF_DURABILITY.APPEND) { await syncDirectory(this.directory); @@ -1620,65 +1888,24 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { if (bytes.readUInt8(4) !== FORMAT_VERSION) { throw corruptDictionary(`unsupported version ${bytes.readUInt8(4)}`); } + if (bytes[5] !== 0 || bytes[6] !== 0 || bytes[7] !== 0) { + throw corruptDictionary("reserved header bytes are not zero"); + } let offset = DICTIONARY_HEADER_SIZE; while (offset < bytes.byteLength) { - if (bytes.byteLength - offset < DICTIONARY_BLOCK_HEADER_SIZE) { - await truncateDictionaryTail(path, offset, this.directory); - break; - } - const startId = bytes.readUInt32LE(offset); - const count = bytes.readUInt32LE(offset + 4); - const payloadLength = bytes.readUInt32LE(offset + 8); - const blockEnd = offset + DICTIONARY_BLOCK_HEADER_SIZE + payloadLength; - if (blockEnd > bytes.byteLength) { + const chunk = decodeDictionaryChunk(bytes, offset); + if (!chunk) { await truncateDictionaryTail(path, offset, this.directory); break; } - if (startId !== this.symbols.length) { - throw corruptDictionary( - `dictionary is not dense [expected=${this.symbols.length}, received=${startId}]`, - ); - } - if (startId + count > QWP_MAX_SYMBOL_DICTIONARY_SIZE) { + const startId = this.symbols.length; + if (startId + chunk.entries.length > QWP_MAX_SYMBOL_DICTIONARY_SIZE) { throw corruptDictionary( `dictionary exceeds maximum size ${QWP_MAX_SYMBOL_DICTIONARY_SIZE}`, ); } - const payload = bytes.subarray( - offset + DICTIONARY_BLOCK_HEADER_SIZE, - blockEnd, - ); - const expectedDigest = bytes.subarray(offset + 12, offset + 44); - const actualDigest = createHash("sha256") - .update(bytes.subarray(offset, offset + 12)) - .update(payload) - .digest(); - if (!actualDigest.equals(expectedDigest)) { - if (blockEnd === bytes.byteLength) { - await truncateDictionaryTail(path, offset, this.directory); - break; - } - throw corruptDictionary(`checksum mismatch at ID ${startId}`); - } - let payloadOffset = 0; - for (let index = 0; index < count; index++) { - if (payloadOffset + 4 > payload.byteLength) { - throw corruptDictionary(`entry ${startId + index} is truncated`); - } - const length = payload.readUInt32LE(payloadOffset); - payloadOffset += 4; - if (payloadOffset + length > payload.byteLength) { - throw corruptDictionary(`entry ${startId + index} is truncated`); - } - let entry: string; - try { - entry = UTF8_DECODER.decode( - payload.subarray(payloadOffset, payloadOffset + length), - ); - } catch { - throw corruptDictionary(`entry ${startId + index} is not UTF-8`); - } - payloadOffset += length; + for (let index = 0; index < chunk.entries.length; index++) { + const entry = chunk.entries[index]; if (this.symbolValues.has(entry)) { throw corruptDictionary( `duplicate value at ID ${startId + index}: '${entry}'`, @@ -1687,10 +1914,7 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { this.symbolValues.add(entry); this.symbols.push(entry); } - if (payloadOffset !== payload.byteLength) { - throw corruptDictionary(`block at ID ${startId} has trailing bytes`); - } - offset = blockEnd; + offset = chunk.end; } this.dictionaryFileSize = offset; this.totalBytes += offset; @@ -1723,50 +1947,22 @@ function encodeRecord(record: QwpIngressReplayRecord): Buffer { `QWP frame is too large for the store-and-forward format [size=${record.payload.byteLength}]`, ); } - const bytes = Buffer.allocUnsafe(HEADER_SIZE + record.payload.byteLength); - MAGIC.copy(bytes, 0); - bytes.writeUInt8(FORMAT_VERSION, 4); - bytes.fill(0, 5, 8); - bytes.writeBigUInt64LE(record.frameSequence, 8); - bytes.writeUInt32LE(record.payload.byteLength, 16); - const digest = createHash("sha256").update(record.payload).digest(); - digest.copy(bytes, 20); + const bytes = Buffer.allocUnsafe( + FRAME_HEADER_SIZE + record.payload.byteLength, + ); + bytes.writeUInt32LE(record.payload.byteLength, 4); Buffer.from( record.payload.buffer, record.payload.byteOffset, record.payload.byteLength, - ).copy(bytes, HEADER_SIZE); + ).copy(bytes, FRAME_HEADER_SIZE); + bytes.writeUInt32LE(crc32c(bytes.subarray(4)), 0); return bytes; } -function decodeRecord(bytes: Buffer, name: string): QwpIngressReplayRecord { - if (bytes.byteLength < HEADER_SIZE) { - throw corruptRecord(name, "record is shorter than its header"); - } - if (!bytes.subarray(0, MAGIC.byteLength).equals(MAGIC)) { - throw corruptRecord(name, "invalid magic"); - } - if (bytes.readUInt8(4) !== FORMAT_VERSION) { - throw corruptRecord(name, `unsupported version ${bytes.readUInt8(4)}`); - } - const frameSequence = bytes.readBigUInt64LE(8); - const payloadLength = bytes.readUInt32LE(16); - if (HEADER_SIZE + payloadLength !== bytes.byteLength) { - throw corruptRecord(name, "payload length does not match file size"); - } - const payload = bytes.subarray(HEADER_SIZE); - const expectedDigest = bytes.subarray(20, 20 + SHA256_SIZE); - const actualDigest = createHash("sha256").update(payload).digest(); - if (!actualDigest.equals(expectedDigest)) { - throw corruptRecord(name, "payload checksum mismatch"); - } - return { frameSequence, payload: new Uint8Array(payload) }; -} - interface DecodedSegment { - readonly generation: bigint; readonly firstSequence: bigint; - readonly assigned: boolean; + readonly manifestRequired: boolean; readonly capacity: number; readonly size: number; readonly records: QwpIngressReplayRecord[]; @@ -1775,24 +1971,49 @@ interface DecodedSegment { readonly tornTail: boolean; } +function selectRecoveredActivePath( + segments: readonly { + readonly name: string; + readonly path: string; + readonly decoded: DecodedSegment; + }[], + manifestActiveBase: bigint | undefined, +): string | undefined { + if (manifestActiveBase !== undefined) { + const candidates = segments.filter( + ({ decoded }) => decoded.firstSequence === manifestActiveBase, + ); + const data = candidates.filter(({ decoded }) => decoded.records.length > 0); + if (data.length > 1) { + throw new QwpReplayStoreCorruptionError( + `multiple QWP store-and-forward data segments claim the manifest active base [activeBase=${manifestActiveBase}]`, + ); + } + if (data.length === 1) return data[0].path; + const empty = candidates.filter(({ decoded }) => !decoded.tornTail); + return (empty.find(({ name }) => name === "sf-initial.sfa") ?? empty[0]) + ?.path; + } + + const data = segments.filter(({ decoded }) => decoded.records.length > 0); + if (data.length > 0) return data[data.length - 1].path; + const empty = segments.filter(({ decoded }) => !decoded.tornTail); + return (empty.find(({ name }) => name === "sf-initial.sfa") ?? empty[0]) + ?.path; +} + function encodeSegmentHeader( - generation: bigint, firstSequence: bigint, - capacity: number, - assigned: boolean, + manifestRequired: boolean, ): Buffer { + validateFrameSequence(firstSequence); const bytes = Buffer.alloc(SEGMENT_HEADER_SIZE); SEGMENT_MAGIC.copy(bytes, 0); bytes.writeUInt8(FORMAT_VERSION, 4); - bytes.writeUInt8(assigned ? SEGMENT_STATE_ASSIGNED : SEGMENT_STATE_SPARE, 5); - bytes.writeUInt16LE(SEGMENT_HEADER_SIZE, 6); - bytes.writeBigUInt64LE(generation, 8); - bytes.writeBigUInt64LE(firstSequence, 16); - bytes.writeUInt32LE(capacity, 24); - createHash("sha256") - .update(bytes.subarray(0, SEGMENT_HEADER_PREFIX_SIZE)) - .digest() - .copy(bytes, SEGMENT_HEADER_PREFIX_SIZE); + bytes.writeUInt8(manifestRequired ? MANIFEST_REQUIRED_FLAG : 0, 5); + bytes.writeUInt16LE(0, 6); + bytes.writeBigUInt64LE(firstSequence, 8); + bytes.writeBigUInt64LE(BigInt(Date.now()) * 1_000n, 16); return bytes; } @@ -1809,42 +2030,23 @@ function decodeSegment(bytes: Buffer, name: string): DecodedSegment { `unsupported segment version ${bytes.readUInt8(4)}`, ); } - const state = bytes.readUInt8(5); - if (state !== SEGMENT_STATE_SPARE && state !== SEGMENT_STATE_ASSIGNED) { - throw corruptRecord(name, `invalid fixed segment state ${state}`); - } - if (bytes.readUInt16LE(6) !== SEGMENT_HEADER_SIZE) { - throw corruptRecord(name, "invalid fixed segment header size"); + const flags = bytes.readUInt8(5); + if ((flags & ~MANIFEST_REQUIRED_FLAG) !== 0) { + throw corruptRecord(name, `unsupported segment flags ${flags}`); } - const capacity = bytes.readUInt32LE(24); - const expectedSize = SEGMENT_HEADER_SIZE + capacity + HEADER_SIZE; - if (capacity === 0 || bytes.byteLength !== expectedSize) { - throw corruptRecord( - name, - `fixed segment has invalid capacity or size [capacity=${capacity}, expectedSize=${expectedSize}, receivedSize=${bytes.byteLength}]`, - ); + if (bytes.readUInt16LE(6) !== 0) { + throw corruptRecord(name, "segment reserved field is not zero"); } - const expectedDigest = bytes.subarray( - SEGMENT_HEADER_PREFIX_SIZE, - SEGMENT_HEADER_SIZE, - ); - const actualDigest = createHash("sha256") - .update(bytes.subarray(0, SEGMENT_HEADER_PREFIX_SIZE)) - .digest(); - if (!actualDigest.equals(expectedDigest)) { - throw corruptRecord(name, "segment header checksum mismatch"); - } - const generation = bytes.readBigUInt64LE(8); - const firstSequence = bytes.readBigUInt64LE(16); - const assigned = state === SEGMENT_STATE_ASSIGNED; + const firstSequence = bytes.readBigUInt64LE(8); + validateFrameSequence(firstSequence); + const capacity = bytes.byteLength - SEGMENT_HEADER_SIZE; const records: QwpIngressReplayRecord[] = []; let offset = SEGMENT_HEADER_SIZE; while (offset < bytes.byteLength) { if (bytes[offset] === 0 && isZeroFilled(bytes, offset)) { return { - generation, firstSequence, - assigned, + manifestRequired: (flags & MANIFEST_REQUIRED_FLAG) !== 0, capacity, size: bytes.byteLength, records, @@ -1853,11 +2055,10 @@ function decodeSegment(bytes: Buffer, name: string): DecodedSegment { }; } const remaining = bytes.byteLength - offset; - if (remaining < HEADER_SIZE) { + if (remaining < FRAME_HEADER_SIZE) { return { - generation, firstSequence, - assigned, + manifestRequired: (flags & MANIFEST_REQUIRED_FLAG) !== 0, capacity, size: bytes.byteLength, records, @@ -1865,11 +2066,12 @@ function decodeSegment(bytes: Buffer, name: string): DecodedSegment { tornTail: true, }; } - if (!bytes.subarray(offset, offset + MAGIC.byteLength).equals(MAGIC)) { + const payloadLength = bytes.readUInt32LE(offset + 4); + const recordEnd = offset + FRAME_HEADER_SIZE + payloadLength; + if (recordEnd > bytes.byteLength) { return { - generation, firstSequence, - assigned, + manifestRequired: (flags & MANIFEST_REQUIRED_FLAG) !== 0, capacity, size: bytes.byteLength, records, @@ -1877,13 +2079,12 @@ function decodeSegment(bytes: Buffer, name: string): DecodedSegment { tornTail: true, }; } - const payloadLength = bytes.readUInt32LE(offset + 16); - const recordEnd = offset + HEADER_SIZE + payloadLength; - if (recordEnd > bytes.byteLength) { + const storedCrc = bytes.readUInt32LE(offset); + const actualCrc = crc32c(bytes.subarray(offset + 4, recordEnd)); + if (storedCrc !== actualCrc) { return { - generation, firstSequence, - assigned, + manifestRequired: (flags & MANIFEST_REQUIRED_FLAG) !== 0, capacity, size: bytes.byteLength, records, @@ -1891,31 +2092,15 @@ function decodeSegment(bytes: Buffer, name: string): DecodedSegment { tornTail: true, }; } - try { - records.push( - decodeRecord(bytes.subarray(offset, recordEnd), `${name}@${offset}`), - ); - } catch (error) { - if (error instanceof QwpReplayStoreCorruptionError) { - return { - generation, - firstSequence, - assigned, - capacity, - size: bytes.byteLength, - records, - logicalSize: offset - SEGMENT_HEADER_SIZE, - tornTail: true, - }; - } - throw error; - } + const frameSequence = firstSequence + BigInt(records.length); + validateFrameSequence(frameSequence); + const payload = bytes.subarray(offset + FRAME_HEADER_SIZE, recordEnd); + records.push({ frameSequence, payload: new Uint8Array(payload) }); offset = recordEnd; } return { - generation, firstSequence, - assigned, + manifestRequired: (flags & MANIFEST_REQUIRED_FLAG) !== 0, capacity, size: bytes.byteLength, records, @@ -1931,42 +2116,6 @@ function isZeroFilled(bytes: Buffer, offset: number): boolean { return true; } -function encodeAcknowledgedThrough(frameSequence: bigint): Buffer { - validateFrameSequence(frameSequence); - const bytes = Buffer.alloc(ACK_STATE_SIZE); - ACK_MAGIC.copy(bytes, 0); - bytes.writeUInt8(FORMAT_VERSION, 4); - bytes.writeBigUInt64LE(frameSequence, 8); - createHash("sha256").update(bytes.subarray(0, 16)).digest().copy(bytes, 16); - return bytes; -} - -function decodeAcknowledgedThrough(bytes: Buffer): bigint { - if (bytes.byteLength !== ACK_STATE_SIZE) { - throw new QwpReplayStoreCorruptionError( - "corrupt QWP store-and-forward ACK watermark: invalid length", - ); - } - if (!bytes.subarray(0, ACK_MAGIC.byteLength).equals(ACK_MAGIC)) { - throw new QwpReplayStoreCorruptionError( - "corrupt QWP store-and-forward ACK watermark: invalid magic", - ); - } - if (bytes.readUInt8(4) !== FORMAT_VERSION) { - throw new QwpReplayStoreCorruptionError( - `corrupt QWP store-and-forward ACK watermark: unsupported version ${bytes.readUInt8(4)}`, - ); - } - const expected = bytes.subarray(16); - const actual = createHash("sha256").update(bytes.subarray(0, 16)).digest(); - if (!actual.equals(expected)) { - throw new QwpReplayStoreCorruptionError( - "corrupt QWP store-and-forward ACK watermark: checksum mismatch", - ); - } - return bytes.readBigUInt64LE(8); -} - function encodeDictionaryHeader(): Buffer { const header = Buffer.alloc(DICTIONARY_HEADER_SIZE); DICTIONARY_MAGIC.copy(header, 0); @@ -1978,7 +2127,7 @@ function encodeDictionaryBlock( startId: number, entries: readonly string[], ): Buffer { - if (!Number.isSafeInteger(startId) || startId < 0 || startId > 0xffffffff) { + if (!Number.isSafeInteger(startId) || startId < 0) { throw new QwpReplayStoreError( `QWP symbol dictionary start ID is outside uint32 range [startId=${startId}]`, ); @@ -1988,9 +2137,6 @@ function encodeDictionaryBlock( `QWP symbol dictionary exceeds maximum size ${QWP_MAX_SYMBOL_DICTIONARY_SIZE}`, ); } - if (entries.length > 0xffffffff) { - throw new QwpReplayStoreError("QWP symbol dictionary block is too large"); - } const encoded = entries.map((entry) => { if (typeof entry !== "string") { throw new QwpReplayStoreError( @@ -1999,36 +2145,156 @@ function encodeDictionaryBlock( } return Buffer.from(entry, "utf8"); }); - let payloadLength = 0; + let entryBytes = 0; for (const entry of encoded) { - payloadLength += 4 + entry.byteLength; - if (payloadLength > 0xffffffff) { + entryBytes += unsignedVarintSize(entry.byteLength) + entry.byteLength; + if (entryBytes > 0xffffffff) { throw new QwpReplayStoreError( "QWP symbol dictionary block payload is too large", ); } } - const block = Buffer.allocUnsafe( - DICTIONARY_BLOCK_HEADER_SIZE + payloadLength, - ); - block.writeUInt32LE(startId, 0); - block.writeUInt32LE(entries.length, 4); - block.writeUInt32LE(payloadLength, 8); - let offset = DICTIONARY_BLOCK_HEADER_SIZE; + const countSize = unsignedVarintSize(entries.length); + const bytesSize = unsignedVarintSize(entryBytes); + const block = Buffer.allocUnsafe(countSize + bytesSize + entryBytes + 4); + let offset = 0; + offset = writeUnsignedVarint(block, offset, entries.length); + offset = writeUnsignedVarint(block, offset, entryBytes); for (const entry of encoded) { - block.writeUInt32LE(entry.byteLength, offset); - offset += 4; + offset = writeUnsignedVarint(block, offset, entry.byteLength); entry.copy(block, offset); offset += entry.byteLength; } - const digest = createHash("sha256") - .update(block.subarray(0, 12)) - .update(block.subarray(DICTIONARY_BLOCK_HEADER_SIZE)) - .digest(); - digest.copy(block, 12); + block.writeUInt32LE(crc32c(block.subarray(0, offset)), offset); return block; } +interface DecodedDictionaryChunk { + readonly entries: readonly string[]; + readonly end: number; +} + +function decodeDictionaryChunk( + bytes: Buffer, + start: number, +): DecodedDictionaryChunk | undefined { + const count = readUnsignedVarint(bytes, start, bytes.byteLength); + if (!count) return undefined; + const entryBytes = readUnsignedVarint(bytes, count.offset, bytes.byteLength); + if (!entryBytes) return undefined; + if (count.value === 0 || entryBytes.value === 0) return undefined; + const entriesEnd = entryBytes.offset + entryBytes.value; + const chunkEnd = entriesEnd + 4; + if (entriesEnd > bytes.byteLength || chunkEnd > bytes.byteLength) { + return undefined; + } + const storedCrc = bytes.readUInt32LE(entriesEnd); + const actualCrc = crc32c(bytes.subarray(start, entriesEnd)); + if (storedCrc !== actualCrc) return undefined; + + const entries: string[] = []; + let offset = entryBytes.offset; + for (let index = 0; index < count.value; index++) { + const length = readUnsignedVarint(bytes, offset, entriesEnd); + if (!length || length.offset + length.value > entriesEnd) { + throw corruptDictionary( + `invalid entry ${index} in chunk at offset ${start}`, + ); + } + try { + entries.push( + UTF8_DECODER.decode( + bytes.subarray(length.offset, length.offset + length.value), + ), + ); + } catch (error) { + throw corruptDictionary( + `entry ${index} in chunk at offset ${start} is not valid UTF-8: ${String(error)}`, + ); + } + offset = length.offset + length.value; + } + if (offset !== entriesEnd) { + throw corruptDictionary( + `chunk at offset ${start} has ${entriesEnd - offset} unclaimed entry bytes`, + ); + } + return { entries, end: chunkEnd }; +} + +interface DecodedVarint { + readonly value: number; + readonly offset: number; +} + +function readUnsignedVarint( + bytes: Buffer, + offset: number, + limit: number, +): DecodedVarint | undefined { + let value = 0; + let multiplier = 1; + for (let index = 0; index < 5; index++) { + if (offset >= limit) return undefined; + const byte = bytes[offset++]; + value += (byte & 0x7f) * multiplier; + if ((byte & 0x80) === 0) { + if (value > 0xffffffff) return undefined; + return { value, offset }; + } + multiplier *= 128; + } + return undefined; +} + +function unsignedVarintSize(value: number): number { + if (!Number.isSafeInteger(value) || value < 0 || value > 0xffffffff) { + throw new QwpReplayStoreError( + `value is outside the SFA uint32 varint range [value=${value}]`, + ); + } + let size = 1; + while (value >= 128) { + value = Math.floor(value / 128); + size++; + } + return size; +} + +function writeUnsignedVarint( + bytes: Buffer, + offset: number, + value: number, +): number { + unsignedVarintSize(value); + while (value >= 128) { + bytes[offset++] = value % 128 | 0x80; + value = Math.floor(value / 128); + } + bytes[offset++] = value; + return offset; +} + +const CRC32C_TABLE = (() => { + const table = new Uint32Array(256); + for (let index = 0; index < table.length; index++) { + let value = index; + for (let bit = 0; bit < 8; bit++) { + value = (value & 1) !== 0 ? 0x82f63b78 ^ (value >>> 1) : value >>> 1; + } + table[index] = value >>> 0; + } + return table; +})(); + +function crc32c(bytes: Uint8Array): number { + let crc = 0xffffffff; + for (const byte of bytes) { + crc = CRC32C_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8); + } + return (crc ^ 0xffffffff) >>> 0; +} + function validateReplacementDictionary(entries: readonly string[]): void { if (entries.length > QWP_MAX_SYMBOL_DICTIONARY_SIZE) { throw new QwpReplayStoreError( @@ -2089,6 +2355,26 @@ async function repairSegmentTail( await syncDirectory(directory); } +async function markSegmentManifestRequired(path: string): Promise { + const file = await open(path, "r+"); + try { + const flag = Buffer.alloc(1); + const { bytesRead } = await file.read(flag, 0, 1, 5); + if (bytesRead !== 1) { + throw new QwpReplayStoreCorruptionError( + `could not read QWP store-and-forward segment flags [file=${path}]`, + ); + } + if ((flag[0] & MANIFEST_REQUIRED_FLAG) === 0) { + flag[0] |= MANIFEST_REQUIRED_FLAG; + await writeFully(file, flag, 5); + await file.sync(); + } + } finally { + await file.close(); + } +} + function corruptRecord( name: string, reason: string, @@ -2187,7 +2473,154 @@ function validateFrameSequence(frameSequence: bigint): void { } function segmentFileName(generation: bigint): string { - return `${generation.toString().padStart(20, "0")}${SEGMENT_SUFFIX}`; + if (generation < 0n || generation > MAX_FRAME_SEQUENCE) { + throw new QwpReplayStoreError( + `QWP store-and-forward segment generation is outside int64 range [generation=${generation}]`, + ); + } + return `${SEGMENT_PREFIX}${generation.toString(16).padStart(16, "0")}${SEGMENT_SUFFIX}`; +} + +function parseSegmentGeneration(name: string): bigint | undefined { + const match = /^sf-([0-9a-fA-F]{16})\.sfa$/.exec(name); + if (!match) return undefined; + const generation = BigInt(`0x${match[1]}`); + if (generation > MAX_FRAME_SEQUENCE) { + throw new QwpReplayStoreCorruptionError( + `QWP store-and-forward segment generation is outside int64 range [file=${name}]`, + ); + } + return generation; +} + +interface MetadataRecord { + readonly generation: bigint; + readonly first: bigint; + readonly second: bigint; +} + +function encodeMetadataRecord( + magic: Buffer, + generation: bigint, + first: bigint, + second: bigint, +): Buffer { + if (magic.byteLength !== 4) { + throw new QwpReplayStoreError("SFA metadata magic must be four bytes"); + } + if (generation <= 0n || generation > MAX_FRAME_SEQUENCE) { + throw new QwpReplayStoreError( + `SFA metadata generation is outside positive int64 range [generation=${generation}]`, + ); + } + if ( + first < -0x8000000000000000n || + first > MAX_FRAME_SEQUENCE || + second < -0x8000000000000000n || + second > MAX_FRAME_SEQUENCE + ) { + throw new QwpReplayStoreError("SFA metadata value is outside int64 range"); + } + const record = Buffer.alloc(METADATA_RECORD_SIZE); + magic.copy(record, 0); + record.writeUInt32LE(FORMAT_VERSION, 4); + record.writeBigInt64LE(generation, 8); + record.writeBigInt64LE(first, 16); + record.writeBigInt64LE(second, 24); + record.writeUInt32LE(crc32c(record.subarray(0, METADATA_CRC_OFFSET)), 60); + return record; +} + +function decodeLatestMetadataRecord( + bytes: Buffer, + magic: Buffer, +): MetadataRecord | undefined { + const first = decodeMetadataRecord(bytes, 0, magic); + const second = decodeMetadataRecord(bytes, RECORD_SLOT_SIZE, magic); + if (!first) return second; + if (!second) return first; + return first.generation >= second.generation ? first : second; +} + +function decodeMetadataRecord( + bytes: Buffer, + offset: number, + magic: Buffer, +): MetadataRecord | undefined { + if (offset + METADATA_RECORD_SIZE > bytes.byteLength) return undefined; + const record = bytes.subarray(offset, offset + METADATA_RECORD_SIZE); + if (!record.subarray(0, 4).equals(magic)) return undefined; + if (record.readUInt32LE(4) !== FORMAT_VERSION) return undefined; + const storedCrc = record.readUInt32LE(METADATA_CRC_OFFSET); + if (storedCrc !== crc32c(record.subarray(0, METADATA_CRC_OFFSET))) { + return undefined; + } + const generation = record.readBigInt64LE(8); + if (generation <= 0n) return undefined; + return { + generation, + first: record.readBigInt64LE(16), + second: record.readBigInt64LE(24), + }; +} + +async function openMetadataFile(path: string): Promise { + let file: FileHandle; + let created = false; + try { + file = await open(path, "r+"); + } catch (error) { + if (nodeErrorCode(error) !== "ENOENT") throw error; + try { + file = await open(path, "wx+", 0o600); + created = true; + } catch (createError) { + if (nodeErrorCode(createError) !== "EEXIST") throw createError; + file = await open(path, "r+"); + } + } + try { + const metadata = await file.stat(); + if (metadata.size !== DUAL_SLOT_FILE_SIZE) { + await file.truncate(0); + await writeFully(file, Buffer.alloc(DUAL_SLOT_FILE_SIZE), 0); + await file.sync(); + created = true; + } + if (created) await syncDirectory(dirname(path)); + return file; + } catch (error) { + await file.close().catch(() => undefined); + throw error; + } +} + +async function replaceFile( + path: string, + bytes: Buffer, + directory: string, +): Promise { + const temporaryPath = `${path}${TEMP_MARKER}${process.pid}-${randomUUID()}`; + let file: FileHandle | undefined; + try { + file = await open(temporaryPath, "wx", 0o600); + await writeFully(file, bytes, 0); + await file.sync(); + await file.close(); + file = undefined; + await rename(temporaryPath, path); + await syncDirectory(directory); + } catch (error) { + await file?.close().catch(() => undefined); + await ignoreMissing(unlink(temporaryPath)); + throw error; + } +} + +function lastMapKey(values: Map): bigint | undefined { + let last: bigint | undefined; + for (const key of values.keys()) last = key; + return last; } function compareBigInt(left: bigint, right: bigint): number { diff --git a/src/qwp-node/orphan-drainer.ts b/src/qwp-node/orphan-drainer.ts index 816a747..f564f0a 100644 --- a/src/qwp-node/orphan-drainer.ts +++ b/src/qwp-node/orphan-drainer.ts @@ -17,9 +17,10 @@ import { type QwpSenderError, } from "../qwp/sender-error"; -const SEGMENT_SUFFIX = ".qwpseg"; -const SEGMENT_HEADER_PROBE_SIZE = 24; -const SEGMENT_STATE_SPARE = 0; +const SEGMENT_SUFFIX = ".sfa"; +const SEGMENT_HEADER_SIZE = 24; +const FRAME_HEADER_SIZE = 8; +const SEGMENT_HEADER_PROBE_SIZE = SEGMENT_HEADER_SIZE + FRAME_HEADER_SIZE; const DEFAULT_MAX_CONCURRENT = 4; const DEFAULT_SCAN_INTERVAL_MS = 30_000; const DEFAULT_PROGRESS_POLL_MS = 50; @@ -181,13 +182,18 @@ async function isAssignedSegmentOrInvalid(path: string): Promise { const header = Buffer.alloc(SEGMENT_HEADER_PROBE_SIZE); const { bytesRead } = await handle.read(header, 0, header.byteLength, 0); if ( - bytesRead !== header.byteLength || - header.toString("ascii", 0, 4) !== "QWPS" || - header.readUInt8(4) !== 2 + bytesRead < SEGMENT_HEADER_SIZE || + header.toString("ascii", 0, 4) !== "SF01" || + header.readUInt8(4) !== 1 || + header.readUInt16LE(6) !== 0 ) { return true; } - return header.readUInt8(5) !== SEGMENT_STATE_SPARE; + if (bytesRead < SEGMENT_HEADER_PROBE_SIZE) return false; + for (let offset = SEGMENT_HEADER_SIZE; offset < bytesRead; offset++) { + if (header[offset] !== 0) return true; + } + return false; } catch (error) { if (nodeErrorCode(error) === "ENOENT") return false; // Let adoption report/quarantine an unreadable or malformed segment. diff --git a/src/qwp/node.ts b/src/qwp/node.ts index 6d62dc0..e096b1a 100644 --- a/src/qwp/node.ts +++ b/src/qwp/node.ts @@ -67,7 +67,6 @@ export { QwpReplayStoreCheckpointError, QwpReplayStoreCorruptionError, QwpReplayStoreError, - QwpReplayStoreFormatError, QwpReplayStoreFullError, QwpReplayStoreLockedError, QwpReplayStoreQuarantinedError, @@ -989,7 +988,7 @@ function createPooledOrphanDrainer( return true; } // Same-base slots outside the new pool range are always recovered. A - // caller must opt in before unrelated/legacy sibling names are adopted. + // caller must opt in before unrelated sibling names are adopted. return ( managedIndex === undefined && storeAndForward.drainOrphans !== true ); diff --git a/test/qwp/fixtures/sfa/README.md b/test/qwp/fixtures/sfa/README.md new file mode 100644 index 0000000..6229f9d --- /dev/null +++ b/test/qwp/fixtures/sfa/README.md @@ -0,0 +1,15 @@ +# QWP/WebSocket SFA interoperability fixtures + +These are byte-for-byte copies of the Java-produced fixtures maintained by +the Rust client under `questdb-rs/src/tests/interop/qwp-ws-sfa`. They exercise +the shared `.sfa` segment envelope and `.symbol-dict` formats without deriving +expected bytes from the TypeScript implementation under test. + +- The segment starts at frame sequence 42 and contains payloads `one` and + `two-two` in a 64-byte zero-padded file. +- The dictionary contains chunks `["one"]` and `["two", "three"]`. +- The torn variants corrupt the second frame/chunk and verify that recovery + retains and durably truncates to the valid prefix. + +The Rust fixture suite can regenerate and validate these bytes bidirectionally +against Java's real `MmapSegment` and `PersistedSymbolDict` implementations. diff --git a/test/qwp/fixtures/sfa/java-two-chunk-torn-tail.symbol-dict.hex b/test/qwp/fixtures/sfa/java-two-chunk-torn-tail.symbol-dict.hex new file mode 100644 index 0000000..f536716 --- /dev/null +++ b/test/qwp/fixtures/sfa/java-two-chunk-torn-tail.symbol-dict.hex @@ -0,0 +1,2 @@ +53594431010000000104036f6e6589318d70020a0375776f +057468726565a2444d7f diff --git a/test/qwp/fixtures/sfa/java-two-chunk.symbol-dict.hex b/test/qwp/fixtures/sfa/java-two-chunk.symbol-dict.hex new file mode 100644 index 0000000..6313089 --- /dev/null +++ b/test/qwp/fixtures/sfa/java-two-chunk.symbol-dict.hex @@ -0,0 +1,2 @@ +53594431010000000104036f6e6589318d70020a0374776f +057468726565a2444d7f diff --git a/test/qwp/fixtures/sfa/java-two-frame-torn-tail.sfa.hex b/test/qwp/fixtures/sfa/java-two-frame-torn-tail.sfa.hex new file mode 100644 index 0000000..61589a1 --- /dev/null +++ b/test/qwp/fixtures/sfa/java-two-frame-torn-tail.sfa.hex @@ -0,0 +1,3 @@ +53463031010000002a00000000000000cb04fb711f010000 +a60ecb49030000006f6e653af600070700000074766f2d74 +776f0000000000000000000000000000 diff --git a/test/qwp/fixtures/sfa/java-two-frame.sfa.hex b/test/qwp/fixtures/sfa/java-two-frame.sfa.hex new file mode 100644 index 0000000..7f22464 --- /dev/null +++ b/test/qwp/fixtures/sfa/java-two-frame.sfa.hex @@ -0,0 +1,3 @@ +53463031010000002a00000000000000cb04fb711f010000 +a60ecb49030000006f6e653af600070700000074776f2d74 +776f0000000000000000000000000000 diff --git a/test/qwp/node-transport.test.ts b/test/qwp/node-transport.test.ts index 5f52733..aba5e75 100644 --- a/test/qwp/node-transport.test.ts +++ b/test/qwp/node-transport.test.ts @@ -1,6 +1,6 @@ import type { AddressInfo, Socket } from "node:net"; import { createServer as createTcpServer } from "node:net"; -import { mkdtemp, open, readdir, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, readdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { WebSocketServer } from "ws"; @@ -529,7 +529,7 @@ describe("QWP Node transport", () => { await seed.appendSymbolDictionary(0, dictionary.entriesFrom(0)); await seed.append({ frameSequence: 0n, payload: replayFrame }); await seed.close(); - await writeFile(join(directory, "symbols.qwpdict"), Uint8Array.of(0)); + await writeFile(join(directory, ".symbol-dict"), Uint8Array.of(0)); const quarantined: QwpReplayStoreQuarantinedError[] = []; const address = server.address() as AddressInfo; @@ -678,38 +678,5 @@ function closeServer(server: WebSocketServer): Promise { } async function assignedReplaySegments(directory: string): Promise { - const names = (await readdir(directory)).filter((name) => - name.endsWith(".qwpseg"), - ); - const assigned: string[] = []; - for (const name of names) { - let file; - try { - file = await open(join(directory, name), "r"); - } catch (error) { - if ( - error && - typeof error === "object" && - "code" in error && - error.code === "ENOENT" - ) { - continue; - } - throw error; - } - try { - const header = Buffer.alloc(24); - const { bytesRead } = await file.read(header, 0, header.byteLength, 0); - if ( - bytesRead !== header.byteLength || - header.toString("ascii", 0, 4) !== "QWPS" || - header.readUInt8(5) !== 0 - ) { - assigned.push(name); - } - } finally { - await file.close(); - } - } - return assigned; + return (await readdir(directory)).filter((name) => name.endsWith(".sfa")); } diff --git a/test/qwp/orphan-drainer.test.ts b/test/qwp/orphan-drainer.test.ts index 5c93b49..aa837bf 100644 --- a/test/qwp/orphan-drainer.test.ts +++ b/test/qwp/orphan-drainer.test.ts @@ -65,6 +65,16 @@ class FakeDrainSession implements QwpNodeOrphanDrainSession { } } +function assignedSfaSegment(): Buffer { + const bytes = Buffer.alloc(24 + 8); + bytes.write("SF01", 0, "ascii"); + bytes.writeUInt8(1, 4); + // A non-zero envelope probe is enough for the read-only orphan scanner; + // adoption performs full CRC and manifest validation under the slot lock. + bytes.writeUInt8(1, 24); + return bytes; +} + describe("QWP Node orphan drainer", () => { const roots: string[] = []; @@ -86,7 +96,10 @@ describe("QWP Node orphan drainer", () => { ): Promise { const directory = join(rootDirectory, name); await mkdir(directory); - await writeFile(join(directory, "00000000000000000000.qwpseg"), "frame"); + await writeFile( + join(directory, "sf-0000000000000000.sfa"), + assignedSfaSegment(), + ); return directory; } @@ -95,7 +108,10 @@ describe("QWP Node orphan drainer", () => { const orphan = await recordSlot(rootDirectory, "orphan"); const segmented = join(rootDirectory, "segmented"); await mkdir(segmented); - await writeFile(join(segmented, "00000000000000000000.qwpseg"), "segment"); + await writeFile( + join(segmented, "sf-0000000000000000.sfa"), + assignedSfaSegment(), + ); await recordSlot(rootDirectory, "live"); const failed = await recordSlot(rootDirectory, "failed"); await writeFile(join(failed, QWP_ORPHAN_FAILED_SENTINEL), "inspect me"); @@ -160,7 +176,7 @@ describe("QWP Node orphan drainer", () => { const session = new FakeDrainSession(); session.pollDurableAck = async () => { session.pendingReplayFrames = 0; - await rm(join(directory, "00000000000000000000.qwpseg")); + await rm(join(directory, "sf-0000000000000000.sfa")); }; return session; }, diff --git a/test/qwp/public-api.test.ts b/test/qwp/public-api.test.ts index 47e517d..fb9eae1 100644 --- a/test/qwp/public-api.test.ts +++ b/test/qwp/public-api.test.ts @@ -74,7 +74,6 @@ const nodeRuntimeContract = [ "QwpReplayStoreCheckpointError", "QwpReplayStoreCorruptionError", "QwpReplayStoreError", - "QwpReplayStoreFormatError", "QwpReplayStoreFullError", "QwpReplayStoreLockedError", "QwpReplayStoreQuarantinedError", diff --git a/test/qwp/reconnect.test.ts b/test/qwp/reconnect.test.ts index af7f93f..a71fe04 100644 --- a/test/qwp/reconnect.test.ts +++ b/test/qwp/reconnect.test.ts @@ -1,4 +1,3 @@ -import { createHash } from "node:crypto"; import { mkdir, mkdtemp, @@ -22,7 +21,6 @@ import { QwpReplayStoreCheckpointError, QwpReplayStoreCorruptionError, QwpReplayStoreError, - QwpReplayStoreFormatError, QwpReplayStoreFullError, QwpReplayStoreLockedError, QwpReplayStoreSegmentTooLargeError, @@ -2038,7 +2036,7 @@ describe("QWP ingress reconnect and replay", () => { const seed = new QwpNodeFileReplayStore({ directory }); await seed.load(); await seed.appendSymbolDictionary(0, dictionary.entriesFrom(0)); - const persistedPrefixSize = (await stat(join(directory, "symbols.qwpdict"))) + const persistedPrefixSize = (await stat(join(directory, ".symbol-dict"))) .size; const replayFrame = encodeQwpIngressFrame([symbolTable("BTC-USD")], { @@ -2048,7 +2046,7 @@ describe("QWP ingress reconnect and replay", () => { await seed.appendSymbolDictionary(1, dictionary.entriesFrom(1)); await seed.append({ frameSequence: 5n, payload: replayFrame }); await seed.close(); - await truncate(join(directory, "symbols.qwpdict"), persistedPrefixSize); + await truncate(join(directory, ".symbol-dict"), persistedPrefixSize); const connection = new FakeConnection("primary"); const session = await QwpIngressSession.connect(async () => connection, { @@ -2093,7 +2091,7 @@ describe("QWP ingress reconnect and replay", () => { await seed.append({ frameSequence: 5n, payload: replayFrame }); await seed.close(); if (failureKind === "structurally corrupt") { - await writeFile(join(directory, "symbols.qwpdict"), Uint8Array.of(0)); + await writeFile(join(directory, ".symbol-dict"), Uint8Array.of(0)); } const connection = new FakeConnection("primary"); @@ -2132,7 +2130,7 @@ describe("QWP ingress reconnect and replay", () => { await seed.appendSymbolDictionary(0, dictionary.entriesFrom(0)); await seed.append({ frameSequence: 5n, payload: replayFrame }); await seed.close(); - await writeFile(join(directory, "symbols.qwpdict"), Uint8Array.of(0)); + await writeFile(join(directory, ".symbol-dict"), Uint8Array.of(0)); await expect( QwpIngressSession.connect(async () => new FakeConnection("primary"), { @@ -2225,7 +2223,9 @@ describe("QWP ingress reconnect and replay", () => { replayStore: new QwpNodeFileReplayStore({ directory }), }); expect(connection.sent).toEqual([]); - expect(await assignedReplaySegments(directory)).toEqual([]); + await vi.waitFor(async () => + expect(await assignedReplaySegments(directory)).toEqual([]), + ); expect(session.metrics).toMatchObject({ replayPublishedFrameSequence: 7n, replayAcknowledgedFrameSequence: 7n, @@ -2967,7 +2967,7 @@ describe("QWP Node file replay store", () => { await second.acknowledgeThrough(0n); await second.close(); expect(await readdir(directory)).toEqual( - expect.arrayContaining(["ack.qwpstate"]), + expect.arrayContaining([".ack-watermark"]), ); const third = new QwpNodeFileReplayStore({ directory }); @@ -2979,7 +2979,10 @@ describe("QWP Node file replay store", () => { it("detects a replay gap immediately after a persisted ACK watermark", async () => { const directory = await trackedDirectory(); - const first = new QwpNodeFileReplayStore({ directory }); + const first = new QwpNodeFileReplayStore({ + directory, + maxSegmentBytes: 1, + }); await first.load(); for (let sequence = 0n; sequence < 3n; sequence++) { await first.append({ @@ -2990,22 +2993,22 @@ describe("QWP Node file replay store", () => { await first.acknowledgeThrough(0n); await first.close(); - const [segment] = await assignedReplaySegments(directory); - const path = join(directory, segment); + const segments = await assignedReplaySegments(directory); + const path = join(directory, segments[segments.length - 1]); const file = await open(path, "r+"); try { const sequence = Buffer.alloc(8); - sequence.writeBigUInt64LE(2n); - // Fixed header + first one-byte record + record sequence field. - await file.write(sequence, 0, sequence.byteLength, 64 + 53 + 8); + sequence.writeBigUInt64LE(3n); + // SFA derives frame sequences from each segment's durable base. + await file.write(sequence, 0, sequence.byteLength, 8); await file.sync(); } finally { await file.close(); } const recovered = new QwpNodeFileReplayStore({ directory }); - await expect(recovered.load()).rejects.toThrow( - /sequence has a gap \[previous=0, received=2\]/, + await expect(recovered.load()).rejects.toBeInstanceOf( + QwpReplayStoreCorruptionError, ); await recovered.close(); }); @@ -3017,7 +3020,7 @@ describe("QWP Node file replay store", () => { maxSegmentBytes: 256, }); await store.load(); - for (let sequence = 0n; sequence < 25n; sequence++) { + for (let sequence = 0n; sequence < 100n; sequence++) { await store.append({ frameSequence: sequence, payload: Uint8Array.of(1), @@ -3025,18 +3028,18 @@ describe("QWP Node file replay store", () => { } const segments = await assignedReplaySegments(directory); expect(segments.length).toBeGreaterThan(1); - expect(segments.length).toBeLessThan(25); + expect(segments.length).toBeLessThan(100); expect(store.metrics).toMatchObject({ - pendingRecords: 25, + pendingRecords: 100, pendingSegments: segments.length, }); for (const segment of segments) { - expect((await stat(join(directory, segment))).size).toBe(64 + 256 + 52); + expect((await stat(join(directory, segment))).size).toBe(24 + 8 + 256); } await store.close(); }); - it("recovers v2 segments after maxSegmentBytes changes", async () => { + it("recovers SFA segments after maxSegmentBytes changes", async () => { const directory = await trackedDirectory(); const first = new QwpNodeFileReplayStore({ directory, @@ -3077,7 +3080,7 @@ describe("QWP Node file replay store", () => { const validSize = (await stat(join(directory, segment))).size; const file = await open(join(directory, segment), "r+"); try { - await file.write(Uint8Array.of(0x51, 0x57), 0, 2, 64 + 55); + await file.write(Uint8Array.of(0x51, 0x57), 0, 2, 24 + 8 + 3); await file.sync(); } finally { await file.close(); @@ -3091,28 +3094,6 @@ describe("QWP Node file replay store", () => { await recovered.close(); }); - it("rejects the retired experimental file-per-frame format", async () => { - const directory = await trackedDirectory(); - await writeFile( - join(directory, "00000000000000000005.qwp"), - encodeLegacyReplayRecord(5n, Uint8Array.of(1, 2)), - ); - await writeFile( - join(directory, "00000000000000000006.qwps"), - encodeLegacyReplayRecord(6n, Uint8Array.of(3, 4)), - ); - - const first = new QwpNodeFileReplayStore({ directory }); - await expect(first.load()).rejects.toBeInstanceOf( - QwpReplayStoreFormatError, - ); - expect(await readdir(directory)).toEqual([ - "00000000000000000005.qwp", - "00000000000000000006.qwps", - ]); - await first.close(); - }); - it.each([ QWP_SF_DURABILITY.APPEND, QWP_SF_DURABILITY.PERIODIC, @@ -3134,7 +3115,7 @@ describe("QWP Node file replay store", () => { "ETH-USD", "BTC-USD", ]); - expect(await readdir(directory)).toContain("symbols.qwpdict"); + expect(await readdir(directory)).toContain(".symbol-dict"); await first.close(); expect(await readdir(directory)).toEqual([]); @@ -3239,11 +3220,9 @@ describe("QWP Node file replay store", () => { await first.appendSymbolDictionary(0, ["ETH-USD", "BTC-USD"]); await first.append({ frameSequence: 0n, payload: Uint8Array.of(1) }); await first.close(); - await writeFile( - join(directory, "symbols.qwpdict"), - Uint8Array.of(1, 2, 3), - { flag: "a" }, - ); + await writeFile(join(directory, ".symbol-dict"), Uint8Array.of(1, 2, 3), { + flag: "a", + }); const recovered = new QwpNodeFileReplayStore({ directory }); await recovered.load(); @@ -3331,7 +3310,7 @@ describe("QWP Node file replay store", () => { const directory = await trackedDirectory(); const store = new QwpNodeFileReplayStore({ directory, - maxBytes: 234, + maxBytes: 66, maxSegmentBytes: 1, durability: QWP_SF_DURABILITY.PERIODIC, checkpointIntervalMs: 250, @@ -3342,7 +3321,7 @@ describe("QWP Node file replay store", () => { await store.append({ frameSequence: 0n, payload: Uint8Array.of(1) }); await store.append({ frameSequence: 1n, payload: Uint8Array.of(2) }); await store.appendSymbolDictionary(0, ["BTC-USD"]); - await unlink(join(directory, "symbols.qwpdict")); + await unlink(join(directory, ".symbol-dict")); const blocked = store.append({ frameSequence: 2n, @@ -3365,7 +3344,7 @@ describe("QWP Node file replay store", () => { const directory = await trackedDirectory(); const store = new QwpNodeFileReplayStore({ directory, - maxBytes: 234, + maxBytes: 66, maxSegmentBytes: 1, backpressurePolicy: QWP_SF_BACKPRESSURE_POLICY.WAIT, appendDeadlineMs: 1_000, @@ -3394,7 +3373,7 @@ describe("QWP Node file replay store", () => { const directory = await trackedDirectory(); const store = new QwpNodeFileReplayStore({ directory, - maxBytes: 234, + maxBytes: 66, maxSegmentBytes: 1, backpressurePolicy: QWP_SF_BACKPRESSURE_POLICY.WAIT, appendDeadlineMs: 100, @@ -3409,8 +3388,8 @@ describe("QWP Node file replay store", () => { }); const rejection = expect(blocked).rejects.toMatchObject({ name: "QwpReplayStoreAppendTimeoutError", - maxBytes: 234, - requiredBytes: 351, + maxBytes: 66, + requiredBytes: 99, timeoutMs: 100, } satisfies Partial); await vi.waitFor(() => expect(store.metrics.waitingAppends).toBe(1)); @@ -3427,13 +3406,13 @@ describe("QWP Node file replay store", () => { const directory = await trackedDirectory(); const first = new QwpNodeFileReplayStore({ directory, - maxBytes: 60, + maxBytes: 32, maxSegmentBytes: 1, }); await first.load(); - // Header + block metadata + this entry occupy 66 bytes, already above - // the configured target. Unlike frame bytes, this prefix never shrinks. - await first.appendSymbolDictionary(0, ["abcdefghij"]); + // Header + block metadata + this entry exceed the configured target. + // Unlike frame bytes, this prefix never shrinks. + await first.appendSymbolDictionary(0, ["abcdefghijklmnopqrstuvwxyz1234"]); await expect( first.append({ frameSequence: 0n, payload: Uint8Array.of(1) }), ).resolves.toBeUndefined(); @@ -3449,14 +3428,14 @@ describe("QWP Node file replay store", () => { const recovered = new QwpNodeFileReplayStore({ directory, - maxBytes: 60, + maxBytes: 32, maxSegmentBytes: 1, }); await expect(recovered.load()).resolves.toEqual([ { frameSequence: 1n, payload: Uint8Array.of(2) }, ]); await expect(recovered.loadSymbolDictionary()).resolves.toEqual([ - "abcdefghij", + "abcdefghijklmnopqrstuvwxyz1234", ]); await recovered.acknowledgeThrough(1n); await expect( @@ -3496,52 +3475,5 @@ async function createTemporaryDirectory(): Promise { } async function assignedReplaySegments(directory: string): Promise { - const names = (await readdir(directory)).filter((name) => - name.endsWith(".qwpseg"), - ); - const assigned: string[] = []; - for (const name of names) { - let file; - try { - file = await open(join(directory, name), "r"); - } catch (error) { - if ( - error && - typeof error === "object" && - "code" in error && - error.code === "ENOENT" - ) { - continue; - } - throw error; - } - try { - const header = Buffer.alloc(24); - const { bytesRead } = await file.read(header, 0, header.byteLength, 0); - if ( - bytesRead !== header.byteLength || - header.toString("ascii", 0, 4) !== "QWPS" || - header.readUInt8(5) !== 0 - ) { - assigned.push(name); - } - } finally { - await file.close(); - } - } - return assigned; -} - -function encodeLegacyReplayRecord( - frameSequence: bigint, - payload: Uint8Array, -): Buffer { - const bytes = Buffer.alloc(52 + payload.byteLength); - bytes.write("QWPR", 0, "ascii"); - bytes.writeUInt8(1, 4); - bytes.writeBigUInt64LE(frameSequence, 8); - bytes.writeUInt32LE(payload.byteLength, 16); - createHash("sha256").update(payload).digest().copy(bytes, 20); - Buffer.from(payload).copy(bytes, 52); - return bytes; + return (await readdir(directory)).filter((name) => name.endsWith(".sfa")); } diff --git a/test/qwp/sfa-interop.test.ts b/test/qwp/sfa-interop.test.ts new file mode 100644 index 0000000..44ba35d --- /dev/null +++ b/test/qwp/sfa-interop.test.ts @@ -0,0 +1,182 @@ +import { + mkdtemp, + readFile, + readdir, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { QwpNodeFileReplayStore } from "../../src/qwp/node"; + +const FIXTURE_DIRECTORY = join(process.cwd(), "test/qwp/fixtures/sfa"); + +describe("QWP SFA cross-client persistence", () => { + const directories: string[] = []; + + afterEach(async () => { + await Promise.all( + directories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })), + ); + }); + + async function directory(): Promise { + const path = await mkdtemp(join(tmpdir(), "qwp-sfa-interop-")); + directories.push(path); + return path; + } + + it("recovers and extends a segment written by the Java client", async () => { + const path = await directory(); + await writeFile( + join(path, "sf-initial.sfa"), + await fixture("java-two-frame.sfa.hex"), + ); + + const store = new QwpNodeFileReplayStore({ directory: path }); + await expect(store.load()).resolves.toEqual([ + { frameSequence: 42n, payload: bytes("one") }, + { frameSequence: 43n, payload: bytes("two-two") }, + ]); + await store.append({ frameSequence: 44n, payload: bytes("!") }); + await store.close(); + + const recovered = new QwpNodeFileReplayStore({ directory: path }); + await expect(recovered.load()).resolves.toEqual([ + { frameSequence: 42n, payload: bytes("one") }, + { frameSequence: 43n, payload: bytes("two-two") }, + { frameSequence: 44n, payload: bytes("!") }, + ]); + await recovered.close(); + }); + + it("repairs the Java segment fixture at its valid CRC prefix", async () => { + const path = await directory(); + const segmentPath = join(path, "sf-initial.sfa"); + await writeFile( + segmentPath, + await fixture("java-two-frame-torn-tail.sfa.hex"), + ); + + const store = new QwpNodeFileReplayStore({ directory: path }); + await expect(store.load()).resolves.toEqual([ + { frameSequence: 42n, payload: bytes("one") }, + ]); + const repaired = await readFile(segmentPath); + expect(repaired.subarray(35).every((value) => value === 0)).toBe(true); + await store.close(); + }); + + it("writes the same normalized segment bytes as Java", async () => { + const path = await directory(); + const store = new QwpNodeFileReplayStore({ + directory: path, + maxSegmentBytes: 32, + }); + await store.load(); + await store.append({ frameSequence: 42n, payload: bytes("one") }); + await store.append({ frameSequence: 43n, payload: bytes("two-two") }); + await store.close(); + + const [segmentName] = (await readdir(path)).filter((name) => + name.endsWith(".sfa"), + ); + const actual = await readFile(join(path, segmentName)); + const expected = await fixture("java-two-frame.sfa.hex"); + // Java fixture timestamps are normalized and predate required manifests. + actual.writeUInt8(0, 5); + expected.subarray(16, 24).copy(actual, 16); + expect(actual).toEqual(expected); + }); + + it("adopts Java's initial segment without retaining its empty hot spare", async () => { + const path = await directory(); + const initial = await fixture("java-two-frame.sfa.hex"); + await writeFile(join(path, "sf-initial.sfa"), initial); + const spare = Buffer.alloc(initial.byteLength); + initial.subarray(0, 24).copy(spare); + spare.writeBigUInt64LE(44n, 8); + await writeFile(join(path, "sf-0000000000000000.sfa"), spare); + + const store = new QwpNodeFileReplayStore({ directory: path }); + await expect(store.load()).resolves.toEqual([ + { frameSequence: 42n, payload: bytes("one") }, + { frameSequence: 43n, payload: bytes("two-two") }, + ]); + expect( + (await readdir(path)).filter((name) => name.endsWith(".sfa")), + ).toEqual(["sf-initial.sfa"]); + await store.close(); + }); + + it("loads and extends Java symbol-dictionary chunks", async () => { + const path = await directory(); + await writeFile( + join(path, ".symbol-dict"), + await fixture("java-two-chunk.symbol-dict.hex"), + ); + + const store = new QwpNodeFileReplayStore({ directory: path }); + await store.load(); + await expect(store.loadSymbolDictionary()).resolves.toEqual([ + "one", + "two", + "three", + ]); + await store.appendSymbolDictionary(3, ["four"]); + await store.append({ frameSequence: 0n, payload: Uint8Array.of(1) }); + await store.close(); + + const recovered = new QwpNodeFileReplayStore({ directory: path }); + await recovered.load(); + await expect(recovered.loadSymbolDictionary()).resolves.toEqual([ + "one", + "two", + "three", + "four", + ]); + await recovered.close(); + }); + + it("writes the same symbol-dictionary bytes as Java", async () => { + const path = await directory(); + const dictionaryPath = join(path, ".symbol-dict"); + const store = new QwpNodeFileReplayStore({ directory: path }); + await store.load(); + await store.appendSymbolDictionary(0, ["one"]); + await store.appendSymbolDictionary(1, ["two", "three"]); + + await expect(readFile(dictionaryPath)).resolves.toEqual( + await fixture("java-two-chunk.symbol-dict.hex"), + ); + await store.close(); + }); + + it("truncates a torn Java dictionary fixture to its valid chunk", async () => { + const path = await directory(); + const dictionaryPath = join(path, ".symbol-dict"); + await writeFile( + dictionaryPath, + await fixture("java-two-chunk-torn-tail.symbol-dict.hex"), + ); + + const store = new QwpNodeFileReplayStore({ directory: path }); + await store.load(); + await expect(store.loadSymbolDictionary()).resolves.toEqual(["one"]); + expect((await stat(dictionaryPath)).size).toBe(18); + await store.close(); + }); +}); + +async function fixture(name: string): Promise { + const text = await readFile(join(FIXTURE_DIRECTORY, name), "utf8"); + return Buffer.from(text.replaceAll(/\s/g, ""), "hex"); +} + +function bytes(value: string): Uint8Array { + return new TextEncoder().encode(value); +} From 3a9b092bdf2d1406a833b21099cb4bf0cb6ac9fe Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 19 Aug 2026 15:42:34 +0100 Subject: [PATCH 075/265] fix(qwp): coordinate SFA ownership across clients --- QWP.md | 17 +- README.md | 2 +- THIRD_PARTY_NOTICES.md | 21 ++ package.json | 1 + pnpm-lock.yaml | 16 ++ src/qwp-node/advisory-lock.ts | 220 ++++++++++++++++++++ src/qwp-node/file-replay-store.ts | 333 +++++++++--------------------- src/qwp-node/orphan-drainer.ts | 3 +- test/qwp/node-transport.test.ts | 7 +- test/qwp/reconnect.test.ts | 116 ++++++++--- 10 files changed, 469 insertions(+), 267 deletions(-) create mode 100644 src/qwp-node/advisory-lock.ts diff --git a/QWP.md b/QWP.md index 765d978..32c267b 100644 --- a/QWP.md +++ b/QWP.md @@ -209,9 +209,14 @@ the surviving frames. The journal takes an exclusive lock when it is loaded and holds it until the sender or session closes. A second live process using the same directory fails with `QwpReplayStoreLockedError` before recovery or cleanup can mutate journal contents. -Locks left by a terminated process on the same host are recovered automatically; -locks owned by a live local process, another host, or an unidentifiable owner fail -closed. +The stable `.lock` file is protected by `flock` on Unix and `LockFileEx` on Windows, +with the holder PID recorded in `.lock.pid` for diagnostics. These are the same files +and native lock primitives used by the Java client, so Java and Node processes cannot +simultaneously own one slot. The kernel releases the lock when a process terminates; +the lock and PID files deliberately remain so their inode is never replaced beneath a +live owner, and the next holder refreshes the PID sidecar. Short-lived locks under the +shared parent directory's `.slot-locks` child also match Java and serialize orphan +adoption with close/rename/recreate quarantine transitions. New journals use the cross-client SFA persistence layout. Fixed-size `sf-.sfa` files have the Java/Rust 24-byte `SF01` header and @@ -237,7 +242,7 @@ before replay. A corrupt or stale dictionary sidecar is replaced when those comm frames independently reconstruct a complete dense dictionary from ID zero. If the frame journal is structurally corrupt, or the surviving deltas contain a dictionary gap or conflict that cannot be reconstructed, the foreground slot is renamed to -`.unreplayable-N`, marked with `.qwp.failed`, and preserved for inspection. The +`.unreplayable-N`, marked with `.failed`, and preserved for inspection. The sender then starts once with a clean slot at the configured path. `onRecoveryQuarantine` receives the original and quarantine paths plus the terminal cause and a typed `senderError`. The shared `onSenderError` callback receives the same @@ -254,7 +259,7 @@ adopts record-bearing slots left by failed producers. Adoption is lock-protected uses an independent QWP connection per slot, bounded by `maxBackgroundDrainers` (4 by default). The scanner runs immediately and then every 30 seconds; set `orphanScanIntervalMs: 0` for a startup-only scan. Terminal recovery failures create -`.qwp.failed` in the slot so a corrupt or permanently rejected head cannot cause a hot +`.failed` in the slot so a corrupt or permanently rejected head cannot cause a hot retry loop. After inspection or repair, call `retryQwpNodeOrphanSlot(slotDirectory)` to make it eligible again. `onOrphanDrainEvent` reports discovery, drain, lock contention, quarantine, scanner failures, durable-ACK capability gaps, and transient @@ -267,7 +272,7 @@ endpoint lacks durable-ACK support. Asynchronous foreground startup and steady-s store-and-forward reconnects retain their records and retry through rolling upgrades. An orphan slot retries a consecutive durable-ACK capability-gap episode until either 16 connection sweeps or the configured reconnect `maxDurationMs` is reached, then it -is quarantined behind `.qwp.failed` (`maxDurationMs: 0` disables only the time half of +is quarantined behind `.failed` (`maxDurationMs: 0` disables only the time half of the budget). A transport outage or an all-replica window resets both halves of this orphan budget; neither transient condition can itself quarantine persisted data. The `durable-ack-unavailable`, diff --git a/README.md b/README.md index a7c14c4..220edee 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,7 @@ Set `drainOrphans: true` when sibling journal directories share a dedicated pare the Node client scans and drains slots left by failed producer processes with bounded concurrency. Pooled QWP clients recover out-of-range `sender-N` slots automatically, including leftovers after `senderPoolMax` is reduced. Terminally bad slots are marked -`.qwp.failed` for inspection and can be re-enabled with +`.failed` for inspection and can be re-enabled with `retryQwpNodeOrphanSlot()`. This persistent mode is Node-only; browser senders continue to default to ACK waiting. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 9d10e92..306ba8a 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -21,3 +21,24 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +This product also bundles `fs-ext-extra-prebuilt` 2.2.11, which is available +under the MIT License: + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/package.json b/package.json index 137e3f1..b973947 100644 --- a/package.json +++ b/package.json @@ -92,6 +92,7 @@ "vitest": "^3.1.3" }, "dependencies": { + "fs-ext-extra-prebuilt": "2.2.11", "undici": "^7.8.0", "ws": "^8.21.3" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e026261..67c13cc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + fs-ext-extra-prebuilt: + specifier: 2.2.11 + version: 2.2.11 undici: specifier: ^7.8.0 version: 7.8.0 @@ -1412,6 +1415,10 @@ packages: fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + fs-ext-extra-prebuilt@2.2.11: + resolution: {integrity: sha512-uCD7z+RlNFvyYQ0rNK5FdhJVScuYrtNsY5PPFtpF1XcEAfHy06eYOu9vDNR4G1r/gq0BX9VyNU/2nhxy2tdQaA==} + engines: {node: '>= 8.0.0'} + fsevents@2.3.2: resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1766,6 +1773,9 @@ packages: nan@2.22.0: resolution: {integrity: sha512-nbajikzWTMwsW+eSsNm3QwlOs7het9gGJU5dDZzRTQGk03vyBOauxgI4VakDzE0PtsGTmXPsXTbbjVhRwR5mpw==} + nan@2.28.0: + resolution: {integrity: sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==} + nanoid@3.3.8: resolution: {integrity: sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -3811,6 +3821,10 @@ snapshots: fs-constants@1.0.0: {} + fs-ext-extra-prebuilt@2.2.11: + dependencies: + nan: 2.28.0 + fsevents@2.3.2: optional: true @@ -4101,6 +4115,8 @@ snapshots: nan@2.22.0: optional: true + nan@2.28.0: {} + nanoid@3.3.8: {} natural-compare@1.4.0: {} diff --git a/src/qwp-node/advisory-lock.ts b/src/qwp-node/advisory-lock.ts new file mode 100644 index 0000000..9e0b736 --- /dev/null +++ b/src/qwp-node/advisory-lock.ts @@ -0,0 +1,220 @@ +import { mkdir, open, readFile, unlink, writeFile } from "node:fs/promises"; +import type { FileHandle } from "node:fs/promises"; +import { basename, dirname, join, resolve } from "node:path"; +import { flock } from "fs-ext-extra-prebuilt"; + +const SLOT_LOCK_FILE = ".lock"; +const SLOT_LOCK_PID_FILE = ".lock.pid"; +const LOGICAL_LOCK_DIRECTORY = ".slot-locks"; + +// An explicit unlock can fail without proving that the kernel released the +// lock. Keep such descriptors reachable and retry them before acquiring any +// later lock, matching Java SlotLock's fail-closed release retry list. +const pendingReleases = new Set(); + +type FlockOperation = "exnb" | "un"; + +/** @internal Native advisory-lock contention with Java-compatible diagnostics. */ +export class QwpNodeAdvisoryLockBusyError extends Error { + constructor( + readonly lockPath: string, + readonly holderPid?: number, + cause?: unknown, + ) { + super(`QWP advisory lock is already held [file=${lockPath}]`); + this.name = "QwpNodeAdvisoryLockBusyError"; + this.cause = cause; + } +} + +/** @internal Native advisory-lock setup or release failure. */ +export class QwpNodeAdvisoryLockError extends Error { + constructor( + message: string, + readonly lockPath: string, + cause?: unknown, + ) { + super(`${message} [file=${lockPath}]`); + this.name = "QwpNodeAdvisoryLockError"; + this.cause = cause; + } +} + +/** + * Lifetime owner of Java-compatible `.lock` / `.lock.pid` slot metadata. + * The files deliberately remain after release: unlinking a lock pathname can + * create a second inode while another process still holds the first one. + * + * @internal + */ +export class QwpNodeAdvisoryLock { + private released = false; + + private constructor( + readonly lockPath: string, + readonly pidPath: string, + private readonly handle: FileHandle, + ) {} + + static async acquire(directory: string): Promise { + return QwpNodeAdvisoryLock.acquireAt( + join(directory, SLOT_LOCK_FILE), + join(directory, SLOT_LOCK_PID_FILE), + ); + } + + /** Acquires Java's parent-anchored guard for a logical slot pathname. */ + static async acquireLogical( + slotDirectory: string, + ): Promise { + const { lockDirectory, lockPath, pidPath } = + logicalLockPaths(slotDirectory); + await mkdir(lockDirectory, { recursive: true }); + return QwpNodeAdvisoryLock.acquireAt(lockPath, pidPath); + } + + /** Best-effort Java-compatible cleanup for a permanently drained slot. */ + static async removeOrphanLogical(slotDirectory: string): Promise { + const { lockPath, pidPath } = logicalLockPaths(slotDirectory); + let guard: QwpNodeAdvisoryLock; + try { + // Only unlink while owning this inode. A live transition holder makes + // cleanup safely leave the files for a later drained close. + guard = await QwpNodeAdvisoryLock.acquireAt(lockPath, pidPath); + } catch { + return; + } + try { + // Sidecar first: after the lock pathname is gone, a racing acquirer may + // create a new inode and its own PID sidecar, which we must not remove. + await unlink(pidPath).catch(() => undefined); + await unlink(lockPath).catch(() => undefined); + } finally { + await guard.release().catch(() => undefined); + } + } + + private static async acquireAt( + lockPath: string, + pidPath: string, + ): Promise { + await retryPendingReleases(); + let handle: FileHandle; + try { + // a+ maps to a read/write handle on Windows, which LockFileEx requires. + // Java likewise opens/creates this stable inode read/write before locking. + handle = await open(lockPath, "a+", 0o600); + } catch (error) { + throw new QwpNodeAdvisoryLockError( + "could not open QWP advisory lock", + lockPath, + error, + ); + } + + try { + await flockAsync(handle.fd, "exnb"); + } catch (error) { + const holderPid = isLockContention(error) + ? await readHolderPid(pidPath) + : undefined; + await handle.close().catch(() => undefined); + if (isLockContention(error)) { + throw new QwpNodeAdvisoryLockBusyError(lockPath, holderPid, error); + } + throw new QwpNodeAdvisoryLockError( + "could not acquire QWP advisory lock", + lockPath, + error, + ); + } + + // Diagnostic-only, matching Java SlotLock: failure to refresh the sidecar + // must not discard an already-acquired kernel lock. + await writeFile(pidPath, `${process.pid}\n`, { + encoding: "utf8", + flag: "w", + mode: 0o600, + }).catch(() => undefined); + return new QwpNodeAdvisoryLock(lockPath, pidPath, handle); + } + + async release(): Promise { + if (this.released) return; + try { + await flockAsync(this.handle.fd, "un"); + } catch (error) { + // Keep the descriptor alive when unlock is unconfirmed. Closing it would + // usually release the lock, but would lose Java's explicit-release safety + // contract and make retry/diagnostics impossible. + pendingReleases.add(this); + throw new QwpNodeAdvisoryLockError( + "could not release QWP advisory lock", + this.lockPath, + error, + ); + } + this.released = true; + pendingReleases.delete(this); + // The kernel unlock is the ownership boundary. Match Java by making the + // subsequent descriptor close best-effort and never unlinking either file. + await this.handle.close().catch(() => undefined); + } +} + +async function retryPendingReleases(): Promise { + for (const lock of [...pendingReleases]) { + await lock.release().catch(() => undefined); + } +} + +function flockAsync(fd: number, operation: FlockOperation): Promise { + return new Promise((resolve, reject) => { + flock(fd, operation, (error) => { + if (error) reject(error); + else resolve(); + }); + }); +} + +function isLockContention(error: unknown): boolean { + const code = nodeErrorCode(error); + return ( + code === "EACCES" || + code === "EAGAIN" || + code === "EBUSY" || + code === "EWOULDBLOCK" + ); +} + +async function readHolderPid(path: string): Promise { + let text: string; + try { + text = await readFile(path, "utf8"); + } catch { + return undefined; + } + const value = Number(text.trim().slice(0, 64)); + return Number.isSafeInteger(value) && value > 0 ? value : undefined; +} + +function nodeErrorCode(error: unknown): string | undefined { + return error && typeof error === "object" && "code" in error + ? String(error.code) + : undefined; +} + +function logicalLockPaths(slotDirectory: string): { + readonly lockDirectory: string; + readonly lockPath: string; + readonly pidPath: string; +} { + const absoluteSlot = resolve(slotDirectory); + const lockDirectory = join(dirname(absoluteSlot), LOGICAL_LOCK_DIRECTORY); + const slotName = basename(absoluteSlot); + return { + lockDirectory, + lockPath: join(lockDirectory, `${slotName}${SLOT_LOCK_FILE}`), + pidPath: join(lockDirectory, `${slotName}${SLOT_LOCK_PID_FILE}`), + }; +} diff --git a/src/qwp-node/file-replay-store.ts b/src/qwp-node/file-replay-store.ts index ca8d7a7..f939af2 100644 --- a/src/qwp-node/file-replay-store.ts +++ b/src/qwp-node/file-replay-store.ts @@ -5,20 +5,21 @@ import { readdir, readFile, rename, - rm, - rmdir, stat, unlink, writeFile, } from "node:fs/promises"; import type { FileHandle } from "node:fs/promises"; -import { hostname } from "node:os"; import { basename, dirname, join } from "node:path"; import { QWP_MAX_SYMBOL_DICTIONARY_SIZE } from "../qwp/core"; import { QwpIngressReplayRecord, QwpIngressReplayStore, } from "../qwp/transport"; +import { + QwpNodeAdvisoryLock, + QwpNodeAdvisoryLockBusyError, +} from "./advisory-lock"; const FORMAT_VERSION = 1; const MAX_FRAME_SEQUENCE = 0x7fffffffffffffffn; @@ -40,12 +41,8 @@ const DUAL_SLOT_FILE_SIZE = 8 * 1024; const RECORD_SLOT_SIZE = 4 * 1024; const METADATA_RECORD_SIZE = 64; const METADATA_CRC_OFFSET = 60; -const LOCK_DIRECTORY = ".qwp.lock"; -const LOCK_OWNER_FILE = "owner.json"; -const LOCK_RECOVERY_FILE = "recovery.json"; -const ABANDONED_LOCK_PREFIX = ".qwp.lock.abandoned-"; const QUARANTINE_SLOT_INFIX = ".unreplayable-"; -const QUARANTINE_FAILED_SENTINEL = ".qwp.failed"; +const QUARANTINE_FAILED_SENTINEL = ".failed"; const MAX_QUARANTINE_SLOT_ATTEMPTS = 64; // Preserve two default-sized QWP batches, mirroring Java's active+spare // liveness floor when the current dictionary generation consumes the cap. @@ -110,14 +107,6 @@ interface PendingCapacity { timer?: ReturnType; } -interface ReplayStoreLockOwner { - readonly version: 1; - readonly token: string; - readonly pid: number; - readonly hostname: string; - readonly createdAtMs: number; -} - export interface QwpNodeFileReplayStoreOptions { /** Exclusive directory used by one ingress session. */ directory: string; @@ -255,12 +244,8 @@ export class QwpReplayStoreLockedError extends QwpReplayStoreError { constructor( readonly directory: string, readonly holderPid?: number, - readonly holderHostname?: string, ) { - const holder = - holderPid === undefined - ? "unknown" - : `${holderPid}${holderHostname ? `@${holderHostname}` : ""}`; + const holder = holderPid === undefined ? "unknown" : String(holderPid); super( `QWP store-and-forward directory is already in use [directory=${directory}, holder=${holder}]`, ); @@ -311,7 +296,7 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { private totalCheckpointFailures = 0; private totalBackpressureStalls = 0; private totalAppendTimeouts = 0; - private lockOwner?: ReplayStoreLockOwner; + private slotLock?: QwpNodeAdvisoryLock; private closePromise?: Promise; private loaded = false; private closing = false; @@ -873,6 +858,12 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { } catch (error) { failure ??= error; } + if (!failure && this.loaded && this.records.size === 0) { + // Java retires the parent-anchored pair once the slot is permanently + // drained. Keep the local slot lock held throughout this best-effort + // cleanup so a racing drainer cannot adopt the old directory. + await QwpNodeAdvisoryLock.removeOrphanLogical(this.directory); + } try { await this.releaseDirectoryLock(); } catch (error) { @@ -1731,134 +1722,45 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { } private async acquireDirectoryLock(): Promise { - const lockPath = join(this.directory, LOCK_DIRECTORY); - const ownerPath = join(lockPath, LOCK_OWNER_FILE); - const owner: ReplayStoreLockOwner = { - version: 1, - token: randomUUID(), - pid: process.pid, - hostname: hostname(), - createdAtMs: Date.now(), - }; - - for (;;) { - try { - await mkdir(lockPath, { mode: 0o700 }); - } catch (error) { - if (nodeErrorCode(error) !== "EEXIST") { - throw new QwpReplayStoreError( - `could not acquire QWP store-and-forward directory lock [directory=${this.directory}]`, - error, - ); - } - const holder = await readLockOwner(ownerPath); - if (!holder) { - throw new QwpReplayStoreLockedError(this.directory); - } - if (!isDefinitelyDeadLockOwner(holder)) { - throw new QwpReplayStoreLockedError( - this.directory, - holder.pid, - holder.hostname, - ); - } - - // Claim recovery inside the directory before renaming it. This keeps - // simultaneous starters that observed the same dead PID from both - // adopting the stale pathname. Re-read the owner after the claim so a - // process that arrived after another recovery cannot move the new lock. - const recoveryPath = join(lockPath, LOCK_RECOVERY_FILE); - try { - await writeLockOwner(recoveryPath, owner); - } catch (claimError) { - const code = nodeErrorCode(claimError); - if (code === "ENOENT") continue; - if (code === "EEXIST") { - throw new QwpReplayStoreLockedError( - this.directory, - holder.pid, - holder.hostname, - ); - } - throw new QwpReplayStoreError( - `could not claim abandoned QWP store-and-forward directory lock [directory=${this.directory}]`, - claimError, - ); - } - const claimedHolder = await readLockOwner(ownerPath); - if (!claimedHolder || claimedHolder.token !== holder.token) { - await ignoreMissing(unlink(recoveryPath)); - if (!claimedHolder) continue; - throw new QwpReplayStoreLockedError( - this.directory, - claimedHolder.pid, - claimedHolder.hostname, - ); - } - - const abandonedPath = join( + let logicalLock: QwpNodeAdvisoryLock | undefined; + let failure: unknown; + try { + // Match Java's lock order. The parent-anchored guard closes the race + // between orphan adoption and a close -> rename -> recreate transition. + logicalLock = await QwpNodeAdvisoryLock.acquireLogical(this.directory); + this.slotLock = await QwpNodeAdvisoryLock.acquire(this.directory); + } catch (error) { + if (error instanceof QwpNodeAdvisoryLockBusyError) { + failure = new QwpReplayStoreLockedError( this.directory, - `${ABANDONED_LOCK_PREFIX}${randomUUID()}`, + error.holderPid, + ); + } else { + failure = new QwpReplayStoreError( + `could not acquire QWP store-and-forward directory lock [directory=${this.directory}]`, + error, ); - try { - await rename(lockPath, abandonedPath); - } catch (renameError) { - if (nodeErrorCode(renameError) === "ENOENT") { - await ignoreMissing(unlink(recoveryPath)); - continue; - } - await ignoreMissing(unlink(recoveryPath)); - throw new QwpReplayStoreError( - `could not recover abandoned QWP store-and-forward directory lock [directory=${this.directory}]`, - renameError, - ); - } - try { - await rm(abandonedPath, { recursive: true, force: true }); - await syncDirectory(this.directory); - } catch (cleanupError) { - throw new QwpReplayStoreError( - `could not remove abandoned QWP store-and-forward directory lock [directory=${this.directory}]`, - cleanupError, - ); - } - continue; } - + } + if (logicalLock) { try { - await writeLockOwner(ownerPath, owner); - await syncDirectory(lockPath); - await syncDirectory(this.directory); - this.lockOwner = owner; - return; + await logicalLock.release(); } catch (error) { - await rm(lockPath, { recursive: true, force: true }).catch( - () => undefined, - ); - throw new QwpReplayStoreError( - `could not initialize QWP store-and-forward directory lock [directory=${this.directory}]`, + failure ??= new QwpReplayStoreError( + `could not release QWP store-and-forward logical lock [directory=${this.directory}]`, error, ); } } + if (failure) throw failure; } private async releaseDirectoryLock(): Promise { - const owner = this.lockOwner; - if (!owner) return; - const lockPath = join(this.directory, LOCK_DIRECTORY); - const ownerPath = join(lockPath, LOCK_OWNER_FILE); - const persistedOwner = await readLockOwner(ownerPath); - if (!persistedOwner || persistedOwner.token !== owner.token) { - throw new QwpReplayStoreError( - `refusing to release a QWP store-and-forward directory lock owned by another process [directory=${this.directory}]`, - ); - } + const slotLock = this.slotLock; + if (!slotLock) return; try { - await unlink(ownerPath); - await rmdir(lockPath); - await syncDirectory(this.directory); - this.lockOwner = undefined; + await slotLock.release(); + this.slotLock = undefined; } catch (error) { throw new QwpReplayStoreError( `could not release QWP store-and-forward directory lock [directory=${this.directory}]`, @@ -2408,50 +2310,78 @@ export async function quarantineQwpNodeReplayStore( } const parent = dirname(normalized); const slotName = basename(normalized); - let quarantineDirectory: string | undefined; - for (let attempt = 0; attempt < MAX_QUARANTINE_SLOT_ATTEMPTS; attempt++) { - const candidate = join( - parent, - `${slotName}${QUARANTINE_SLOT_INFIX}${attempt}`, + let logicalLock: QwpNodeAdvisoryLock; + try { + logicalLock = await QwpNodeAdvisoryLock.acquireLogical(normalized); + } catch (error) { + if (error instanceof QwpNodeAdvisoryLockBusyError) { + throw new QwpReplayStoreLockedError(normalized, error.holderPid); + } + throw new QwpReplayStoreError( + `could not acquire QWP store-and-forward logical lock for quarantine [directory=${normalized}]`, + error, ); - if (await pathExists(candidate)) continue; - try { - await rename(normalized, candidate); - quarantineDirectory = candidate; - break; - } catch (error) { - if ( - nodeErrorCode(error) === "EEXIST" || - nodeErrorCode(error) === "ENOTEMPTY" - ) { - continue; + } + let result: QwpReplayStoreQuarantinedError | undefined; + let failure: unknown; + try { + let quarantineDirectory: string | undefined; + for (let attempt = 0; attempt < MAX_QUARANTINE_SLOT_ATTEMPTS; attempt++) { + const candidate = join( + parent, + `${slotName}${QUARANTINE_SLOT_INFIX}${attempt}`, + ); + if (await pathExists(candidate)) continue; + try { + await rename(normalized, candidate); + quarantineDirectory = candidate; + break; + } catch (error) { + if ( + nodeErrorCode(error) === "EEXIST" || + nodeErrorCode(error) === "ENOTEMPTY" + ) { + continue; + } + throw new QwpReplayStoreError( + `could not quarantine unreplayable QWP store-and-forward slot [directory=${normalized}, target=${candidate}]`, + error, + ); } + } + if (!quarantineDirectory) { throw new QwpReplayStoreError( - `could not quarantine unreplayable QWP store-and-forward slot [directory=${normalized}, target=${candidate}]`, - error, + `could not quarantine unreplayable QWP store-and-forward slot; ${MAX_QUARANTINE_SLOT_ATTEMPTS} quarantine paths already exist [directory=${normalized}]`, + cause, ); } + + const recoveryError = + cause instanceof Error ? cause : new Error(String(cause)); + await writeFile( + join(quarantineDirectory, QUARANTINE_FAILED_SENTINEL), + `${new Date().toISOString()} ${recoveryError.name}: ${recoveryError.message}\n`, + { encoding: "utf8", flag: "wx", mode: 0o600 }, + ).catch(() => undefined); + await syncDirectory(parent); + result = new QwpReplayStoreQuarantinedError( + normalized, + quarantineDirectory, + recoveryError, + ); + } catch (error) { + failure = error; } - if (!quarantineDirectory) { - throw new QwpReplayStoreError( - `could not quarantine unreplayable QWP store-and-forward slot; ${MAX_QUARANTINE_SLOT_ATTEMPTS} quarantine paths already exist [directory=${normalized}]`, - cause, + try { + await logicalLock.release(); + } catch (error) { + failure ??= new QwpReplayStoreError( + `could not release QWP store-and-forward logical lock after quarantine [directory=${normalized}]`, + error, ); } - - const recoveryError = - cause instanceof Error ? cause : new Error(String(cause)); - await writeFile( - join(quarantineDirectory, QUARANTINE_FAILED_SENTINEL), - `${new Date().toISOString()} ${recoveryError.name}: ${recoveryError.message}\n`, - { encoding: "utf8", flag: "wx", mode: 0o600 }, - ).catch(() => undefined); - await syncDirectory(parent); - return new QwpReplayStoreQuarantinedError( - normalized, - quarantineDirectory, - recoveryError, - ); + if (failure) throw failure; + return result!; } async function pathExists(path: string): Promise { @@ -2747,60 +2677,3 @@ function nodeErrorCode(error: unknown): string | undefined { ? String(error.code) : undefined; } - -async function readLockOwner( - ownerPath: string, -): Promise { - let parsed: unknown; - try { - parsed = JSON.parse(await readFile(ownerPath, "utf8")); - } catch (error) { - const code = nodeErrorCode(error); - if (code === "ENOENT" || error instanceof SyntaxError) return undefined; - throw new QwpReplayStoreError( - `could not read QWP store-and-forward directory lock [file=${ownerPath}]`, - error, - ); - } - if (!parsed || typeof parsed !== "object") return undefined; - const owner = parsed as Partial; - if ( - owner.version !== 1 || - typeof owner.token !== "string" || - owner.token.length === 0 || - !Number.isSafeInteger(owner.pid) || - (owner.pid ?? 0) <= 0 || - typeof owner.hostname !== "string" || - owner.hostname.length === 0 || - !Number.isSafeInteger(owner.createdAtMs) || - (owner.createdAtMs ?? 0) < 0 - ) { - return undefined; - } - return owner as ReplayStoreLockOwner; -} - -async function writeLockOwner( - path: string, - owner: ReplayStoreLockOwner, -): Promise { - const file = await open(path, "wx", 0o600); - try { - await file.writeFile(`${JSON.stringify(owner)}\n`, "utf8"); - await file.sync(); - } finally { - await file.close(); - } -} - -function isDefinitelyDeadLockOwner(owner: ReplayStoreLockOwner): boolean { - if (owner.hostname !== hostname() || owner.pid === process.pid) { - return false; - } - try { - process.kill(owner.pid, 0); - return false; - } catch (error) { - return nodeErrorCode(error) === "ESRCH"; - } -} diff --git a/src/qwp-node/orphan-drainer.ts b/src/qwp-node/orphan-drainer.ts index f564f0a..567dce2 100644 --- a/src/qwp-node/orphan-drainer.ts +++ b/src/qwp-node/orphan-drainer.ts @@ -28,7 +28,8 @@ const DEFAULT_CONNECTION_LISTENER_INBOX_CAPACITY = 64; const DEFAULT_ERROR_INBOX_CAPACITY = 256; /** A terminal orphan-drain failure marker. Remove it to retry the slot. */ -export const QWP_ORPHAN_FAILED_SENTINEL = ".qwp.failed"; +/** Java-compatible marker that excludes a failed slot from automatic drain. */ +export const QWP_ORPHAN_FAILED_SENTINEL = ".failed"; export const QWP_ORPHAN_DRAIN_EVENT_KIND = { DISCOVERED: "discovered", diff --git a/test/qwp/node-transport.test.ts b/test/qwp/node-transport.test.ts index aba5e75..0aa5a5b 100644 --- a/test/qwp/node-transport.test.ts +++ b/test/qwp/node-transport.test.ts @@ -491,7 +491,7 @@ describe("QWP Node transport", () => { quarantinedPath: quarantineDirectory, }); expect(await readdir(quarantineDirectory)).toEqual( - expect.arrayContaining([record, ".qwp.failed"]), + expect.arrayContaining([record, ".failed"]), ); expect(await assignedReplaySegments(directory)).toEqual([]); } finally { @@ -546,7 +546,10 @@ describe("QWP Node transport", () => { await session.close(); expect(quarantined).toEqual([]); - expect(await readdir(rootDirectory)).toEqual(["sender-0"]); + expect((await readdir(rootDirectory)).sort()).toEqual([ + ".slot-locks", + "sender-0", + ]); const verify = new QwpNodeFileReplayStore({ directory }); await expect(verify.load()).resolves.toHaveLength(1); await expect(verify.loadSymbolDictionary()).resolves.toEqual(["ETH-USD"]); diff --git a/test/qwp/reconnect.test.ts b/test/qwp/reconnect.test.ts index a71fe04..b0d5973 100644 --- a/test/qwp/reconnect.test.ts +++ b/test/qwp/reconnect.test.ts @@ -3,14 +3,16 @@ import { mkdtemp, open, readdir, + readFile, rm, stat, truncate, unlink, writeFile, } from "node:fs/promises"; -import { hostname, tmpdir } from "node:os"; +import { tmpdir } from "node:os"; import { join } from "node:path"; +import { flock } from "fs-ext-extra-prebuilt"; import { afterEach, describe, expect, it, vi } from "vitest"; import { connectQwpNodeIngress, @@ -70,6 +72,21 @@ import { QwpAsyncQueue } from "../../src/qwp/internal/async-queue"; import { createQwpEgressFailoverConnectionFactory } from "../../src/qwp/internal/egress-routing"; import { createQwpFailoverConnectionFactory } from "../../src/qwp/internal/failover"; +function nativeFlock(fd: number, operation: "exnb" | "un"): Promise { + return new Promise((resolve, reject) => { + flock(fd, operation, (error) => { + if (error) reject(error); + else resolve(); + }); + }); +} + +async function expectOnlyJavaSlotLockMetadata( + directory: string, +): Promise { + expect((await readdir(directory)).sort()).toEqual([".lock", ".lock.pid"]); +} + function ingressResponse( status: number, sequence: bigint, @@ -2021,7 +2038,7 @@ describe("QWP ingress reconnect and replay", () => { verify.appendSymbolDictionary(0, ["BTC-USD"]), ).resolves.toBeUndefined(); await verify.close(); - expect(await readdir(directory)).toEqual([]); + await expectOnlyJavaSlotLockMetadata(directory); await rm(directory, { recursive: true, force: true }); }); @@ -3117,7 +3134,7 @@ describe("QWP Node file replay store", () => { ]); expect(await readdir(directory)).toContain(".symbol-dict"); await first.close(); - expect(await readdir(directory)).toEqual([]); + await expectOnlyJavaSlotLockMetadata(directory); const second = new QwpNodeFileReplayStore({ directory, durability }); await expect(second.load()).resolves.toEqual([]); @@ -3126,7 +3143,7 @@ describe("QWP Node file replay store", () => { second.appendSymbolDictionary(0, ["BTC-USD"]), ).resolves.toBeUndefined(); await second.close(); - expect(await readdir(directory)).toEqual([]); + await expectOnlyJavaSlotLockMetadata(directory); }, ); @@ -3147,7 +3164,7 @@ describe("QWP Node file replay store", () => { await expect(second.loadSymbolDictionary()).resolves.toEqual(["ETH-USD"]); await second.acknowledgeThrough(1n); await second.close(); - expect(await readdir(directory)).toEqual([]); + await expectOnlyJavaSlotLockMetadata(directory); }); it("holds an exclusive directory lock for the store lifetime", async () => { @@ -3160,7 +3177,6 @@ describe("QWP Node file replay store", () => { name: "QwpReplayStoreLockedError", directory, holderPid: process.pid, - holderHostname: hostname(), } satisfies Partial); await first.append({ frameSequence: 0n, payload: Uint8Array.of(7) }); @@ -3171,20 +3187,10 @@ describe("QWP Node file replay store", () => { await second.close(); }); - it("recovers a lock left by a terminated local process", async () => { + it("arbitrates acquisition over stale Java lock metadata", async () => { const directory = await trackedDirectory(); - const lockDirectory = join(directory, ".qwp.lock"); - await mkdir(lockDirectory); - await writeFile( - join(lockDirectory, "owner.json"), - JSON.stringify({ - version: 1, - token: "abandoned", - pid: 2_147_483_647, - hostname: hostname(), - createdAtMs: 0, - }), - ); + await writeFile(join(directory, ".lock"), ""); + await writeFile(join(directory, ".lock.pid"), "2147483647\n"); const stores = [ new QwpNodeFileReplayStore({ directory }), @@ -3202,15 +3208,68 @@ describe("QWP Node file replay store", () => { status: "rejected", reason: { name: "QwpReplayStoreLockedError" }, }); - expect( - (await readdir(directory)).filter((name) => - name.startsWith(".qwp.lock.abandoned-"), - ), - ).toEqual([]); await stores[winner].close(); await expect(stores[loser].load()).resolves.toEqual([]); await stores[loser].close(); - expect(await readdir(directory)).toEqual([]); + await expectOnlyJavaSlotLockMetadata(directory); + }); + + it("contends with a Java-compatible native advisory lock", async () => { + const directory = await trackedDirectory(); + const lockPath = join(directory, ".lock"); + const lockHandle = await open(lockPath, "a+"); + await nativeFlock(lockHandle.fd, "exnb"); + await writeFile(join(directory, ".lock.pid"), "4242\n"); + + const store = new QwpNodeFileReplayStore({ directory }); + await expect(store.load()).rejects.toMatchObject({ + name: "QwpReplayStoreLockedError", + directory, + holderPid: 4242, + } satisfies Partial); + + await nativeFlock(lockHandle.fd, "un"); + await lockHandle.close(); + await expect(store.load()).resolves.toEqual([]); + expect(await readFile(join(directory, ".lock.pid"), "utf8")).toBe( + `${process.pid}\n`, + ); + await store.close(); + }); + + it("contends with Java's parent-anchored logical slot lock", async () => { + const rootDirectory = await trackedDirectory(); + const directory = join(rootDirectory, "sender-0"); + const logicalLockDirectory = join(rootDirectory, ".slot-locks"); + await mkdir(directory); + await mkdir(logicalLockDirectory); + const lockPath = join(logicalLockDirectory, "sender-0.lock"); + const lockHandle = await open(lockPath, "a+"); + await nativeFlock(lockHandle.fd, "exnb"); + await writeFile(join(logicalLockDirectory, "sender-0.lock.pid"), "9090\n"); + + const store = new QwpNodeFileReplayStore({ directory }); + await expect(store.load()).rejects.toMatchObject({ + name: "QwpReplayStoreLockedError", + directory, + holderPid: 9090, + } satisfies Partial); + + await nativeFlock(lockHandle.fd, "un"); + await lockHandle.close(); + await expect(store.load()).resolves.toEqual([]); + await store.close(); + }); + + it("retires logical lock files after a slot is fully drained", async () => { + const rootDirectory = await trackedDirectory(); + const directory = join(rootDirectory, "sender-0"); + const store = new QwpNodeFileReplayStore({ directory }); + await store.load(); + await store.close(); + + expect(await readdir(join(rootDirectory, ".slot-locks"))).toEqual([]); + await expectOnlyJavaSlotLockMetadata(directory); }); it("recovers a persisted dictionary and truncates a torn append tail", async () => { @@ -3254,7 +3313,7 @@ describe("QWP Node file replay store", () => { await expect( store.append({ frameSequence: 0n, payload: Uint8Array.of(1, 2, 3) }), ).rejects.toBeInstanceOf(QwpReplayStoreFullError); - expect(await readdir(directory)).toEqual([".qwp.lock"]); + await expectOnlyJavaSlotLockMetadata(directory); await store.close(); }); @@ -3337,7 +3396,10 @@ describe("QWP Node file replay store", () => { await expect(store.close()).rejects.toBeInstanceOf( QwpReplayStoreCheckpointError, ); - expect(await readdir(directory)).not.toContain(".qwp.lock"); + const lockHandle = await open(join(directory, ".lock"), "r+"); + await nativeFlock(lockHandle.fd, "exnb"); + await nativeFlock(lockHandle.fd, "un"); + await lockHandle.close(); }); it("waits for ACK trimming without blocking the acknowledgement queue", async () => { From ef07a221e3ca3c456e73d7c7107a2adb3a58f77f Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 19 Aug 2026 15:53:36 +0100 Subject: [PATCH 076/265] fix(qwp): align identifier handling with Java --- QWP.md | 2 + src/qwp/core/identifiers.ts | 87 +++++++++++++++++++++++++++++++++++++ src/qwp/core/table.ts | 24 +++++----- src/qwp/sender.ts | 29 ++++++++----- test/qwp/core.test.ts | 62 ++++++++++++++++++++++++++ test/qwp/sender.test.ts | 35 ++++++++++++++- 6 files changed, 215 insertions(+), 24 deletions(-) create mode 100644 src/qwp/core/identifiers.ts diff --git a/QWP.md b/QWP.md index 32c267b..44c964c 100644 --- a/QWP.md +++ b/QWP.md @@ -1060,6 +1060,8 @@ Review these behavioral differences before rollout: store-and-forward it defaults to local durable publication; set `awaitServerAck` to restore ACK waiting, or `awaitDurableAck` to wait through durable upload. - QWP symbol dictionaries are connection-scoped and automatic. +- Table and column identifiers are rejected locally using the Java client's rules; + column identity is case-insensitive and preserves the spelling first declared. - Large batches are split to the negotiated WebSocket payload cap. - QWP transactional auto-flush is per table and must be explicitly committed. - Browser and Node QWP ingress reconnect by default with in-memory, at-least-once diff --git a/src/qwp/core/identifiers.ts b/src/qwp/core/identifiers.ts new file mode 100644 index 0000000..9d3fb08 --- /dev/null +++ b/src/qwp/core/identifiers.ts @@ -0,0 +1,87 @@ +function isIllegalCommonIdentifierCharacter( + character: string, + codeUnit: number, +): boolean { + if (codeUnit <= 0x0f || codeUnit === 0x7f || codeUnit === 0xfeff) { + return true; + } + switch (character) { + case "?": + case ",": + case "'": + case '"': + case "\\": + case "/": + case ":": + case ")": + case "(": + case "+": + case "*": + case "%": + case "~": + return true; + default: + return false; + } +} + +/** @internal Applies Java TableUtils table-name rules and UTF-16 length. */ +export function validateQwpTableName( + name: string, + maxNameLength: number, +): void { + if (name.length === 0) throw new Error("table name cannot be empty"); + if (name.length > maxNameLength) { + throw new Error(`table name too long [maxLength=${maxNameLength}]`); + } + if (name.charAt(0) === " " || name.charAt(name.length - 1) === " ") { + throw new Error(`table name contains illegal characters: ${name}`); + } + for (let index = 0; index < name.length; index++) { + const character = name.charAt(index); + if ( + (character === "." && + (index === 0 || + index === name.length - 1 || + name.charAt(index - 1) === ".")) || + isIllegalCommonIdentifierCharacter(character, name.charCodeAt(index)) + ) { + throw new Error(`table name contains illegal characters: ${name}`); + } + } +} + +/** @internal Applies Java TableUtils column-name rules and UTF-16 length. */ +export function validateQwpColumnName( + name: string, + maxNameLength: number, +): void { + if (name.length === 0) throw new Error("column name cannot be empty"); + if (name.length > maxNameLength) { + throw new Error(`column name too long [maxLength=${maxNameLength}]`); + } + for (let index = 0; index < name.length; index++) { + const character = name.charAt(index); + if ( + character === "." || + character === "-" || + isIllegalCommonIdentifierCharacter(character, name.charCodeAt(index)) + ) { + throw new Error(`column name contains illegal characters: ${name}`); + } + } +} + +/** + * @internal Java's LowerCaseCharSequenceIntHashMap lowercases each UTF-16 code + * unit independently. Taking the first code unit avoids JavaScript's one + * expanding lowercase mapping (U+0130) and gives the same simple mapping. + */ +export function qwpColumnNameKey(name: string): string { + let key = ""; + for (let index = 0; index < name.length; index++) { + const character = name.charAt(index); + key += character.toLowerCase().charAt(0); + } + return key; +} diff --git a/src/qwp/core/table.ts b/src/qwp/core/table.ts index f97c7da..cd8d5cf 100644 --- a/src/qwp/core/table.ts +++ b/src/qwp/core/table.ts @@ -4,7 +4,11 @@ import { QWP_MAX_TABLE_NAME_LENGTH, QwpColumnType, } from "./constants"; -import { utf8Length } from "./bytes"; +import { + qwpColumnNameKey, + validateQwpColumnName, + validateQwpTableName, +} from "./identifiers"; export interface QwpSymbolValue { id: number; @@ -41,10 +45,7 @@ export class QwpTableBuffer { if (!Number.isSafeInteger(maxNameLength) || maxNameLength < 1) { throw new RangeError("maxNameLength must be a positive safe integer"); } - if (!name) throw new Error("table name cannot be empty"); - if (utf8Length(name) > maxNameLength) { - throw new Error(`table name too long [maxLength=${maxNameLength}]`); - } + validateQwpTableName(name, maxNameLength); this.name = name; this.maxNameLength = maxNameLength; } @@ -70,7 +71,8 @@ export class QwpTableBuffer { throw new Error("column name cannot be empty"); } - const existing = this.columnsByName.get(name); + const nameKey = qwpColumnNameKey(name); + const existing = this.columnsByName.get(nameKey); if (existing) { if (existing.type !== type) { throw new Error( @@ -83,9 +85,7 @@ export class QwpTableBuffer { return existing; } - if (utf8Length(name) > this.maxNameLength) { - throw new Error(`column name too long [maxLength=${this.maxNameLength}]`); - } + if (!designatedTimestamp) validateQwpColumnName(name, this.maxNameLength); if (this.columnList.length >= QWP_MAX_COLUMNS_PER_TABLE) { throw new Error( `column count exceeds maximum ${QWP_MAX_COLUMNS_PER_TABLE}`, @@ -102,7 +102,7 @@ export class QwpTableBuffer { column.nulls.push(false); column.size++; this.columnList.push(column); - this.columnsByName.set(name, column); + this.columnsByName.set(nameKey, column); return column; } @@ -168,7 +168,7 @@ export class QwpTableBuffer { for (let index = this.columnList.length - 1; index >= 0; index--) { const column = this.columnList[index]; if (this.rows === 0 && column.size === 0) { - this.columnsByName.delete(column.name); + this.columnsByName.delete(qwpColumnNameKey(column.name)); this.columnList.splice(index, 1); } } @@ -213,7 +213,7 @@ export class QwpTableBuffer { decimalScale: column.decimalScale, }; result.columnList.push(sliced); - result.columnsByName.set(sliced.name, sliced); + result.columnsByName.set(qwpColumnNameKey(sliced.name), sliced); } return result; } diff --git a/src/qwp/sender.ts b/src/qwp/sender.ts index 110cb9e..7c3a1e0 100644 --- a/src/qwp/sender.ts +++ b/src/qwp/sender.ts @@ -13,6 +13,7 @@ import { type QwpIngressSendResult, type QwpIngressMetrics, } from "./ingress-session"; +import { qwpColumnNameKey, validateQwpColumnName } from "./core/identifiers"; export type QwpTimestampUnit = "ns" | "us" | "ms"; @@ -38,7 +39,7 @@ export interface QwpSenderOptions { */ autoFlushBytes?: number; autoFlushIntervalMs?: number; - /** Maximum UTF-8 byte length of table and column names. Defaults to 127. */ + /** Maximum UTF-16 length of table and column names. Defaults to 127. */ maxNameLength?: number; /** * Keep auto-flushed rows in an open server-side transaction. An explicit @@ -163,7 +164,7 @@ interface StagedTable { rows: StagedRow[]; schema: Map< string, - Pick + Pick >; } @@ -894,7 +895,7 @@ export class QwpSender { ): Promise { try { const timestamp = timestampValue(value, unit); - this.addColumn("", timestamp.type, timestamp.value); + this.addColumn("", timestamp.type, timestamp.value, {}, true); this.finishRow(); } catch (error) { this.failRow(error); @@ -1188,6 +1189,7 @@ export class QwpSender { type: QwpColumnType, value: unknown, metadata: Pick = {}, + designatedTimestamp = false, ): QwpSender { try { this.throwIfUnavailable(); @@ -1195,12 +1197,11 @@ export class QwpSender { if (typeof name !== "string") { throw new TypeError("column name must be a string"); } - if (name && utf8Length(name) > this.maxNameLength) { - throw new Error( - `column name too long [maxLength=${this.maxNameLength}]`, - ); + if (!designatedTimestamp) { + validateQwpColumnName(name, this.maxNameLength); } - const existingSchema = table.schema.get(name); + const nameKey = qwpColumnNameKey(name); + const existingSchema = table.schema.get(nameKey); if ( existingSchema && (existingSchema.type !== type || @@ -1209,9 +1210,15 @@ export class QwpSender { ) { throw new Error(`column type mismatch for '${name}'`); } - if (this.currentRow.has(name)) return this; - table.schema.set(name, { type, ...metadata }); - this.currentRow.set(name, { name, type, value, ...metadata }); + if (this.currentRow.has(nameKey)) return this; + const canonicalName = existingSchema?.name ?? name; + table.schema.set(nameKey, { name: canonicalName, type, ...metadata }); + this.currentRow.set(nameKey, { + name: canonicalName, + type, + value, + ...metadata, + }); return this; } catch (error) { return this.failRow(error); diff --git a/test/qwp/core.test.ts b/test/qwp/core.test.ts index 54ac832..6998b4e 100644 --- a/test/qwp/core.test.ts +++ b/test/qwp/core.test.ts @@ -197,6 +197,68 @@ describe("QWP frame envelope", () => { }); describe("QWP ingress codec", () => { + it("applies Java-compatible table and column identifier rules", () => { + for (const name of [ + "", + " leading", + "trailing ", + ".hidden", + "trailing.", + "double..dot", + "bad/name", + "bad\nname", + "bad\ufeffname", + ]) { + expect(() => new QwpTableBuffer(name)).toThrow( + /table name (cannot be empty|contains illegal characters)/, + ); + } + for (const name of [ + "bad.column", + "bad-column", + "bad/name", + "bad\tname", + "bad\u007fname", + ]) { + const table = new QwpTableBuffer("valid table.csv"); + expect(() => table.getOrCreateColumn(name, QWP_COLUMN_TYPE.LONG)).toThrow( + /column name contains illegal characters/, + ); + } + + expect(() => new QwpTableBuffer("😀", 2)).not.toThrow(); + expect(() => new QwpTableBuffer("😀", 1)).toThrow( + /table name too long.*maxLength=1/, + ); + const unicode = new QwpTableBuffer("t", 2); + expect(() => + unicode.getOrCreateColumn("😀", QWP_COLUMN_TYPE.LONG), + ).not.toThrow(); + }); + + it("tracks columns case-insensitively and preserves first spelling", () => { + const table = new QwpTableBuffer("events"); + const first = table.getOrCreateColumn("Value", QWP_COLUMN_TYPE.LONG)!; + first.values.push(1n); + expect(table.getOrCreateColumn("VALUE", QWP_COLUMN_TYPE.LONG)).toBeNull(); + table.nextRow(); + + const second = table.getOrCreateColumn("value", QWP_COLUMN_TYPE.LONG)!; + expect(second).toBe(first); + second.values.push(2n); + table.nextRow(); + + expect(table.columns).toHaveLength(1); + expect(table.columns[0]).toMatchObject({ + name: "Value", + values: [1n, 2n], + nulls: [false, false], + }); + expect(() => + table.getOrCreateColumn("vAlUe", QWP_COLUMN_TYPE.DOUBLE), + ).toThrow(/column type mismatch/); + }); + it("slices compacted table rows without losing null positions", () => { const table = new QwpTableBuffer("events"); table.getOrCreateColumn("value", QWP_COLUMN_TYPE.LONG)!.values.push(10n); diff --git a/test/qwp/sender.test.ts b/test/qwp/sender.test.ts index e5b8a58..d0d36bb 100644 --- a/test/qwp/sender.test.ts +++ b/test/qwp/sender.test.ts @@ -207,7 +207,7 @@ describe("QWP high-level sender", () => { ).toThrow(/closeFlushTimeoutMs must be a non-negative safe integer/); }); - it("applies a configurable UTF-8 table and column name limit", async () => { + it("applies a configurable Java-compatible identifier length", async () => { const session = new RecordingSession(); expect( () => new QwpSender(async () => session, { maxNameLength: 15 }), @@ -233,6 +233,39 @@ describe("QWP high-level sender", () => { await sender.close(); }); + it("uses case-insensitive column identity in the fluent sender", async () => { + const session = new RecordingSession(); + const sender = new QwpSender(async () => session, { autoFlush: false }); + + await sender + .table("events") + .longColumn("Value", 1n) + .longColumn("VALUE", 99n) + .atNow(); + await sender.table("events").longColumn("value", 2n).atNow(); + await sender.flush(); + + const table = session.sends[0].tables[0]; + expect(table.columns).toHaveLength(1); + expect(column(table, "Value").values).toEqual([1n, 2n]); + expect(() => column(table, "VALUE")).toThrow(/missing column/); + await sender.close(); + }); + + it("rejects illegal identifiers before publishing", async () => { + const session = new RecordingSession(); + const sender = new QwpSender(async () => session, { autoFlush: false }); + + expect(() => sender.table("bad/table")).toThrow( + /table name contains illegal characters/, + ); + expect(() => sender.table("events").longColumn("bad-column", 1n)).toThrow( + /column name contains illegal characters/, + ); + expect(session.sends).toHaveLength(0); + await sender.close(); + }); + it("returns a publication sequence and waits for its ACK independently", async () => { const session = new WatermarkSession(); const sender = new QwpSender(async () => session, { From 948f1c15b69de4b9e9f0c0f08eebcc414703c774 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 19 Aug 2026 16:57:28 +0100 Subject: [PATCH 077/265] fix(qwp): bound in-memory replay --- QWP.md | 23 ++- README.md | 4 +- src/qwp-node/client-config.ts | 11 +- src/qwp/ingress-session.ts | 63 +++++++ .../reconnecting-ingress-connection.ts | 156 +++++++++++++++++- src/qwp/transport.ts | 36 ++++ test/qwp/node-client-config.test.ts | 5 +- test/qwp/public-api-contract.ts | 6 + test/qwp/public-api.test.ts | 2 + test/qwp/reconnect.test.ts | 125 ++++++++++++++ 10 files changed, 417 insertions(+), 14 deletions(-) diff --git a/QWP.md b/QWP.md index 44c964c..87c7026 100644 --- a/QWP.md +++ b/QWP.md @@ -513,11 +513,19 @@ connection sweep can still try every endpoint, allowing role and health changes recover. A non-orderly close demotes the selected endpoint before the next sweep. Ingress reconnect is enabled by default for factory-created browser and Node sessions. Unacknowledged frames are retained in memory and replayed at least once after a -transport failure. The default memory policy uses full-jitter backoff from 100 ms to -5 seconds and a five-minute per-outage deadline; the initial connection remains -fail-fast. Set `reconnect: false` for one fixed connection. Supplying a `reconnect` -object tunes the bounds, emits lifecycle events through `onEvent`, and retains the -earlier opt-in behavior of retrying initial connection establishment. +transport failure. The built-in memory replay queue is capped at 128 MiB. When the +cap is full, publication waits for ACK-driven trimming for at most 30 seconds, then +rejects with `QwpMemoryReplayAppendTimeoutError`; a single frame that can never fit +is rejected immediately with `QwpMemoryReplayFrameTooLargeError`. Set +`memoryReplayMaxBytes` and `memoryReplayAppendDeadlineMs` on ingress session options +to tune these bounds. The accounting includes a fixed per-frame allowance so many +small frames cannot bypass the byte cap. + +The default memory policy uses full-jitter backoff from 100 ms to 5 seconds and a +five-minute per-outage deadline; the initial connection remains fail-fast. Set +`reconnect: false` for one fixed connection. Supplying a `reconnect` object tunes the +bounds, emits lifecycle events through `onEvent`, and retains the earlier opt-in +behavior of retrying initial connection establishment. Each retry delay is selected between zero and the current exponential ceiling, preventing clients disconnected together from retrying in lockstep. Configured attempt @@ -871,6 +879,8 @@ For unified strings with `sf_dir`, Java-compatible defaults apply: memory durability, a 10 GiB total journal cap, 4 MiB frame/segment batches, a 30-second capacity wait, a 60-second close drain, and fail-fast initial connection. Set `sender_id` to name the disk slot base; pooled senders use `-`. +Without `sf_dir`, `sf_max_total_bytes` and `sf_append_deadline_millis` tune the +built-in memory replay queue instead. The parser also supports `max_name_len`, password-protected `tls_roots`, and the Java listener/error inbox capacity keys. Those capacities actively bound asynchronous connection and typed-error delivery and are reflected in ingress drop counters. @@ -1065,7 +1075,8 @@ Review these behavioral differences before rollout: - Large batches are split to the negotiated WebSocket payload cap. - QWP transactional auto-flush is per table and must be explicitly committed. - Browser and Node QWP ingress reconnect by default with in-memory, at-least-once - replay. Configure Node store-and-forward when replay must survive process failure. + replay. That queue has a 128 MiB cap and a bounded 30-second capacity wait by + default. Configure Node store-and-forward when replay must survive process failure. - Existing HTTP, TCP, and TLS options do not automatically apply to QWP; put QWP-only connection and session controls under `extraOptions.qwp`. diff --git a/README.md b/README.md index 220edee..8f1537f 100644 --- a/README.md +++ b/README.md @@ -305,7 +305,9 @@ const sender = await connectQwpBrowserSender( Browser durable ACKs are an in-memory delivery confirmation only. Persistent store-and-forward remains available exclusively through the Node.js entry -point. +point. In-memory ingress replay is capped at 128 MiB and waits at most 30 seconds +for ACK-driven trimming by default; tune `memoryReplayMaxBytes` and +`memoryReplayAppendDeadlineMs` in the ingress session options when needed. ### Zstd-compressed QWP egress diff --git a/src/qwp-node/client-config.ts b/src/qwp-node/client-config.ts index 3fdc1cc..fd1fccb 100644 --- a/src/qwp-node/client-config.ts +++ b/src/qwp-node/client-config.ts @@ -177,6 +177,15 @@ export function resolveQwpNodeClientConfig( const ingressSession: QwpIngressSessionOptions = { reconnect: ingressReconnect, initialConnectMode, + memoryReplayMaxBytes: storeAndForward + ? undefined + : optionalSize(value("sf_max_total_bytes"), "sf_max_total_bytes", 1), + memoryReplayAppendDeadlineMs: storeAndForward + ? undefined + : optionalPositiveInteger( + value("sf_append_deadline_millis"), + "sf_append_deadline_millis", + ), maxBatchSizeBytes: optionalSize( value("sf_max_segment_bytes"), "sf_max_segment_bytes", @@ -668,9 +677,7 @@ function validateStoreAndForwardDependencies( "catch_up_cap_gap_min_escalation_window_millis", "drain_orphans", "max_background_drainers", - "sf_append_deadline_millis", "sf_durability", - "sf_max_total_bytes", "sf_sync_interval_millis", ]; const configured = sfOnlyKeys.find((key) => values.has(key)); diff --git a/src/qwp/ingress-session.ts b/src/qwp/ingress-session.ts index ab76ed0..eb1cfb0 100644 --- a/src/qwp/ingress-session.ts +++ b/src/qwp/ingress-session.ts @@ -167,6 +167,17 @@ export interface QwpIngressSessionOptions { * are not acceptable. */ reconnect?: QwpReconnectOptions | false; + /** + * Hard cap for the built-in memory-only replay queue, including estimated + * per-frame bookkeeping. Defaults to 128 MiB. This applies in browsers and + * non-persistent Node sessions; custom replay stores enforce their own cap. + */ + memoryReplayMaxBytes?: number; + /** + * Maximum time a memory replay append waits for ACK-driven trimming after + * reaching memoryReplayMaxBytes. Defaults to 30 seconds. + */ + memoryReplayAppendDeadlineMs?: number; /** @internal Node adapter hook for persistent store-and-forward. */ replayStore?: QwpIngressReplayStore; /** @internal Starts memory or persistent replay without waiting for a server. */ @@ -270,6 +281,11 @@ export interface QwpIngressMetrics { readonly replayAcknowledgedFrameSequence?: bigint; readonly pendingReplayFrames: number; readonly pendingReplayBytes: number; + readonly memoryReplayMaxBytes?: number; + readonly memoryReplayUsedBytes?: number; + readonly waitingMemoryReplayAppends: number; + readonly totalMemoryReplayBackpressureStalls: number; + readonly totalMemoryReplayAppendTimeouts: number; readonly lastError?: Error; } @@ -391,6 +407,35 @@ function validateIngressSessionOptions( ) { throw new RangeError("maxBatchSizeBytes must be a positive safe integer"); } + const memoryReplayMaxBytes = options.memoryReplayMaxBytes; + if ( + memoryReplayMaxBytes !== undefined && + (!Number.isSafeInteger(memoryReplayMaxBytes) || memoryReplayMaxBytes <= 0) + ) { + throw new RangeError( + "memoryReplayMaxBytes must be a positive safe integer", + ); + } + const memoryReplayAppendDeadlineMs = options.memoryReplayAppendDeadlineMs; + if ( + memoryReplayAppendDeadlineMs !== undefined && + (!Number.isSafeInteger(memoryReplayAppendDeadlineMs) || + memoryReplayAppendDeadlineMs <= 0 || + memoryReplayAppendDeadlineMs > 2_147_483_647) + ) { + throw new RangeError( + "memoryReplayAppendDeadlineMs must be a positive safe integer no greater than 2147483647", + ); + } + if ( + options.replayStore && + (memoryReplayMaxBytes !== undefined || + memoryReplayAppendDeadlineMs !== undefined) + ) { + throw new RangeError( + "memory replay capacity options cannot be combined with a custom replayStore", + ); + } const keepalive = options.durableAckKeepaliveMs; if ( keepalive !== undefined && @@ -488,6 +533,15 @@ export class QwpIngressSession { "ingress reconnect options require QwpIngressSession.connect(factory, options)", ); } + if ( + (options.memoryReplayMaxBytes !== undefined || + options.memoryReplayAppendDeadlineMs !== undefined) && + !(connection instanceof QwpReconnectingIngressConnection) + ) { + throw new Error( + "memory replay capacity options require ingress reconnect", + ); + } validateIngressSessionOptions(options); } catch (error) { try { @@ -557,6 +611,8 @@ export class QwpIngressSession { reconnectOptions, options.replayStore, options.maxBatchSizeBytes, + options.memoryReplayMaxBytes, + options.memoryReplayAppendDeadlineMs, options.backgroundStoreAndForward, initialConnectMode, options.orphanStoreAndForward, @@ -658,6 +714,13 @@ export class QwpIngressSession { replayAcknowledgedFrameSequence: transport?.acknowledgedFrameSequence, pendingReplayFrames: transport?.pendingReplayFrames ?? 0, pendingReplayBytes: transport?.pendingReplayBytes ?? 0, + memoryReplayMaxBytes: transport?.memoryReplayMaxBytes, + memoryReplayUsedBytes: transport?.memoryReplayUsedBytes, + waitingMemoryReplayAppends: transport?.waitingMemoryReplayAppends ?? 0, + totalMemoryReplayBackpressureStalls: + transport?.totalMemoryReplayBackpressureStalls ?? 0, + totalMemoryReplayAppendTimeouts: + transport?.totalMemoryReplayAppendTimeouts ?? 0, lastError: this.lastError, }); } diff --git a/src/qwp/internal/reconnecting-ingress-connection.ts b/src/qwp/internal/reconnecting-ingress-connection.ts index 38db9ec..db7edc5 100644 --- a/src/qwp/internal/reconnecting-ingress-connection.ts +++ b/src/qwp/internal/reconnecting-ingress-connection.ts @@ -26,6 +26,8 @@ import { QwpIngressReplayStore, QwpIngressTransportMetrics, QwpInitialConnectMode, + QwpMemoryReplayAppendTimeoutError, + QwpMemoryReplayFrameTooLargeError, QwpReconnectEvent, QwpReconnectExhaustedError, QwpReconnectOptions, @@ -50,6 +52,12 @@ const DEFAULT_CATCH_UP_CAP_GAP_MIN_ESCALATION_WINDOW_MS = 300_000; const MAX_CATCH_UP_CAP_GAP_ATTEMPTS = 16; const DEFAULT_ORPHAN_DURABLE_ACK_MISMATCH_MAX_DURATION_MS = 300_000; const MAX_ORPHAN_DURABLE_ACK_MISMATCH_ATTEMPTS = 16; +const DEFAULT_MEMORY_REPLAY_MAX_BYTES = 128 * 1024 * 1024; +const DEFAULT_MEMORY_REPLAY_APPEND_DEADLINE_MS = 30_000; +// Charge a conservative fixed amount so even empty/very small opaque frames +// cannot grow the replay Map without bound. Payload arrays are not copied by +// the store, so the configured budget primarily tracks live frame storage. +const MEMORY_REPLAY_RECORD_OVERHEAD_BYTES = 64; type ConnectAttemptPolicy = "single" | "configured" | "unbounded"; @@ -142,6 +150,30 @@ class RetriableIngressConnectionError extends Error { class QwpMemoryReplayStore implements QwpIngressReplayStore { private readonly records = new Map(); private readonly symbols: string[] = []; + private readonly capacityWaiters = new Set<{ + resolve: () => void; + reject: (error: Error) => void; + timer: ReturnType; + }>(); + private usedBytes = 0; + private closing = false; + private totalBackpressureStalls = 0; + private totalAppendTimeouts = 0; + + constructor( + readonly maxBytes = DEFAULT_MEMORY_REPLAY_MAX_BYTES, + private readonly appendDeadlineMs = DEFAULT_MEMORY_REPLAY_APPEND_DEADLINE_MS, + ) {} + + get metrics() { + return { + maxBytes: this.maxBytes, + usedBytes: this.usedBytes, + waitingAppends: this.capacityWaiters.size, + totalBackpressureStalls: this.totalBackpressureStalls, + totalAppendTimeouts: this.totalAppendTimeouts, + } as const; + } async load(): Promise { return Array.from(this.records, ([frameSequence, payload]) => ({ @@ -151,14 +183,54 @@ class QwpMemoryReplayStore implements QwpIngressReplayStore { } async append(record: QwpIngressReplayRecord): Promise { - this.records.set(record.frameSequence, record.payload.slice()); + if (this.closing) throw new QwpSendClosedError(); + if (this.records.has(record.frameSequence)) { + throw new Error( + `QWP memory replay sequence already exists [frameSequence=${record.frameSequence}]`, + ); + } + const requiredBytes = + record.payload.byteLength + MEMORY_REPLAY_RECORD_OVERHEAD_BYTES; + if (requiredBytes > this.maxBytes) { + throw new QwpMemoryReplayFrameTooLargeError( + this.maxBytes, + record.payload.byteLength, + requiredBytes, + ); + } + if (this.usedBytes + requiredBytes > this.maxBytes) { + this.totalBackpressureStalls++; + const deadline = Date.now() + this.appendDeadlineMs; + while (this.usedBytes + requiredBytes > this.maxBytes) { + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) { + this.totalAppendTimeouts++; + throw new QwpMemoryReplayAppendTimeoutError( + this.maxBytes, + this.usedBytes, + requiredBytes, + this.appendDeadlineMs, + ); + } + await this.waitForCapacity(remainingMs, requiredBytes); + if (this.closing) throw new QwpSendClosedError(); + } + } + // send() already made the replay-owned payload copy. Sharing it between + // the connection and this accounting store avoids doubling the backlog. + this.records.set(record.frameSequence, record.payload); + this.usedBytes += requiredBytes; } async acknowledgeThrough(frameSequence: bigint): Promise { for (const sequence of this.records.keys()) { if (sequence > frameSequence) break; + const payload = this.records.get(sequence)!; + this.usedBytes -= + payload.byteLength + MEMORY_REPLAY_RECORD_OVERHEAD_BYTES; this.records.delete(sequence); } + this.releaseCapacityWaiters(); } async loadSymbolDictionary(): Promise { @@ -177,7 +249,56 @@ class QwpMemoryReplayStore implements QwpIngressReplayStore { this.symbols.push(...entries); } - async close(): Promise {} + async close(): Promise { + if (this.closing) return; + this.closing = true; + const error = new QwpSendClosedError(); + for (const waiter of this.capacityWaiters) { + clearTimeout(waiter.timer); + waiter.reject(error); + } + this.capacityWaiters.clear(); + this.records.clear(); + this.symbols.length = 0; + this.usedBytes = 0; + } + + private waitForCapacity( + timeoutMs: number, + requiredBytes: number, + ): Promise { + return new Promise((resolve, reject) => { + const waiter = { + resolve: () => { + clearTimeout(waiter.timer); + this.capacityWaiters.delete(waiter); + resolve(); + }, + reject: (error: Error) => { + clearTimeout(waiter.timer); + this.capacityWaiters.delete(waiter); + reject(error); + }, + timer: undefined as unknown as ReturnType, + }; + waiter.timer = setTimeout(() => { + this.totalAppendTimeouts++; + waiter.reject( + new QwpMemoryReplayAppendTimeoutError( + this.maxBytes, + this.usedBytes, + requiredBytes, + this.appendDeadlineMs, + ), + ); + }, timeoutMs); + this.capacityWaiters.add(waiter); + }); + } + + private releaseCapacityWaiters(): void { + for (const waiter of [...this.capacityWaiters]) waiter.resolve(); + } } /** @@ -335,6 +456,8 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { reconnectOptions: QwpReconnectOptions, replayStore?: QwpIngressReplayStore, localMaxBatchSizeBytes?: number, + memoryReplayMaxBytes = DEFAULT_MEMORY_REPLAY_MAX_BYTES, + memoryReplayAppendDeadlineMs = DEFAULT_MEMORY_REPLAY_APPEND_DEADLINE_MS, backgroundStoreAndForward = false, initialConnectMode: QwpInitialConnectMode = backgroundStoreAndForward ? QWP_INITIAL_CONNECT_MODE.ASYNC @@ -348,7 +471,11 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { onSenderError?: (error: QwpSenderError) => void, ): Promise { const store: QwpIngressReplayStore = - replayStore ?? new QwpMemoryReplayStore(); + replayStore ?? + new QwpMemoryReplayStore( + memoryReplayMaxBytes, + memoryReplayAppendDeadlineMs, + ); let connection: QwpReconnectingIngressConnection | undefined; try { const records = await store.load(); @@ -462,11 +589,21 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { for (const frame of this.frames.values()) { pendingReplayBytes += frame.payload.byteLength; } + const memoryMetrics = + this.store instanceof QwpMemoryReplayStore + ? this.store.metrics + : undefined; return Object.freeze({ publishedFrameSequence: this.publishedFrameSequence, acknowledgedFrameSequence: this.acknowledgedFrameSequence, pendingReplayFrames: this.frames.size, pendingReplayBytes, + memoryReplayMaxBytes: memoryMetrics?.maxBytes, + memoryReplayUsedBytes: memoryMetrics?.usedBytes, + waitingMemoryReplayAppends: memoryMetrics?.waitingAppends ?? 0, + totalMemoryReplayBackpressureStalls: + memoryMetrics?.totalBackpressureStalls ?? 0, + totalMemoryReplayAppendTimeouts: memoryMetrics?.totalAppendTimeouts ?? 0, totalFramesSent: this.totalFramesSent, totalBytesSent: this.totalBytesSent, totalFramesReplayed: this.totalFramesReplayed, @@ -589,6 +726,7 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { try { await this.closeStore(); } finally { + this.releaseMemoryReplayReferences(); await Promise.all([ this.connectionDispatcher?.close(), this.errorDispatcher?.close(), @@ -1502,7 +1640,9 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { reason: this.terminalError.message, wasClean: false, }); - void this.closeStore().catch(() => undefined); + void this.closeStore() + .catch(() => undefined) + .finally(() => this.releaseMemoryReplayReferences()); void this.connection ?.close(1011, "QWP reconnect failed") .catch(() => undefined); @@ -1520,6 +1660,14 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { } return this.storeClosePromise; } + + private releaseMemoryReplayReferences(): void { + if (!(this.store instanceof QwpMemoryReplayStore)) return; + this.frames.clear(); + this.wireFrames = []; + this.symbolDictionary.length = 0; + this.durableWatermarks.clear(); + } } function readSymbolDictionaryDelta(payload: Uint8Array) { diff --git a/src/qwp/transport.ts b/src/qwp/transport.ts index 7f4d58e..c9a112a 100644 --- a/src/qwp/transport.ts +++ b/src/qwp/transport.ts @@ -47,6 +47,35 @@ export class QwpSendClosedError extends QwpSendError { } } +/** One frame can never fit in the configured in-memory replay budget. */ +export class QwpMemoryReplayFrameTooLargeError extends RangeError { + constructor( + readonly maxBytes: number, + readonly payloadBytes: number, + readonly requiredBytes: number, + ) { + super( + `QWP frame exceeds the in-memory replay budget [maxBytes=${maxBytes}, payloadBytes=${payloadBytes}, requiredBytes=${requiredBytes}]`, + ); + this.name = "QwpMemoryReplayFrameTooLargeError"; + } +} + +/** ACK-driven trimming did not free in-memory replay capacity in time. */ +export class QwpMemoryReplayAppendTimeoutError extends Error { + constructor( + readonly maxBytes: number, + readonly usedBytes: number, + readonly requiredBytes: number, + readonly timeoutMs: number, + ) { + super( + `QWP in-memory replay append remained backpressured for ${timeoutMs} ms [maxBytes=${maxBytes}, usedBytes=${usedBytes}, requiredBytes=${requiredBytes}]`, + ); + this.name = "QwpMemoryReplayAppendTimeoutError"; + } +} + export interface QwpFailoverAttempt { readonly endpoint: string | URL; readonly error: unknown; @@ -184,6 +213,13 @@ export interface QwpIngressTransportMetrics { readonly acknowledgedFrameSequence: bigint; readonly pendingReplayFrames: number; readonly pendingReplayBytes: number; + /** Configured cap for the built-in memory replay store. */ + readonly memoryReplayMaxBytes?: number; + /** Estimated payload and record-bookkeeping bytes charged to that cap. */ + readonly memoryReplayUsedBytes?: number; + readonly waitingMemoryReplayAppends: number; + readonly totalMemoryReplayBackpressureStalls: number; + readonly totalMemoryReplayAppendTimeouts: number; /** Physical WebSocket sends, including replay and dictionary catch-up. */ readonly totalFramesSent: number; readonly totalBytesSent: number; diff --git a/test/qwp/node-client-config.test.ts b/test/qwp/node-client-config.test.ts index 9680fee..f28b718 100644 --- a/test/qwp/node-client-config.test.ts +++ b/test/qwp/node-client-config.test.ts @@ -352,13 +352,16 @@ describe("QWP unified Node client configuration", () => { const options = parseQwpNodeClientConfig( `wss::addr=localhost;tls_roots=${trustStore};tls_roots_password=secret;` + "connection_listener_inbox_capacity=7;error_inbox_capacity=32;" + - "max_name_len=512;sender_id=producer_1;sf_max_segment_bytes=8m;", + "max_name_len=512;sender_id=producer_1;sf_max_segment_bytes=8m;" + + "sf_max_total_bytes=64m;sf_append_deadline_millis=1234;", ); expect(options.ingress.agent).toBeDefined(); expect(options.sender?.maxNameLength).toBe(512); expect(options.ingress.senderId).toBe("producer_1"); expect(options.ingressSession).toMatchObject({ maxBatchSizeBytes: 8 * 1024 * 1024, + memoryReplayMaxBytes: 64 * 1024 * 1024, + memoryReplayAppendDeadlineMs: 1234, connectionListenerInboxCapacity: 7, errorInboxCapacity: 32, }); diff --git a/test/qwp/public-api-contract.ts b/test/qwp/public-api-contract.ts index bcff39b..47c3d13 100644 --- a/test/qwp/public-api-contract.ts +++ b/test/qwp/public-api-contract.ts @@ -209,6 +209,11 @@ const fixedConnectionIngressContract: QwpIngressSessionOptions = { ], }; +const memoryReplayIngressContract: QwpIngressSessionOptions = { + memoryReplayMaxBytes: 128 * 1024 * 1024, + memoryReplayAppendDeadlineMs: 30_000, +}; + const fixedConnectionEgressContract: QwpEgressSessionOptions = { reconnect: false, }; @@ -375,6 +380,7 @@ const rootExtraOptionsContract: ExtraOptions = { void browserSenderSignature; void defaultSenderErrorHandlerSignature; void browserIngressSignature; +void memoryReplayIngressContract; void browserEgressSignature; void bootstrapSignature; void browserClientSignature; diff --git a/test/qwp/public-api.test.ts b/test/qwp/public-api.test.ts index fb9eae1..ac01e02 100644 --- a/test/qwp/public-api.test.ts +++ b/test/qwp/public-api.test.ts @@ -30,6 +30,8 @@ const sharedRuntimeContract = [ "QwpIngressNackError", "QwpIngressAckTimeoutError", "QwpIngressSession", + "QwpMemoryReplayAppendTimeoutError", + "QwpMemoryReplayFrameTooLargeError", "QwpProtocolError", "QwpPoolAcquireTimeoutError", "QwpPoolResourceError", diff --git a/test/qwp/reconnect.test.ts b/test/qwp/reconnect.test.ts index b0d5973..1cdc9d3 100644 --- a/test/qwp/reconnect.test.ts +++ b/test/qwp/reconnect.test.ts @@ -51,6 +51,8 @@ import { QwpIngressReplayRecord, QwpIngressReplayStore, QwpHandshakeMetadata, + QwpMemoryReplayAppendTimeoutError, + QwpMemoryReplayFrameTooLargeError, QwpProtocolError, QwpSymbolDictionary, QwpTableBuffer, @@ -536,6 +538,124 @@ describe("QWP endpoint failover", () => { }); describe("QWP ingress reconnect and replay", () => { + it("bounds memory replay and resumes publication after ACK trimming", async () => { + const connection = new FakeConnection("primary"); + const session = await QwpIngressSession.connect(async () => connection, { + memoryReplayMaxBytes: 130, + memoryReplayAppendDeadlineMs: 1_000, + }); + + await session.publishFrame(Uint8Array.of(1)); + await session.publishFrame(Uint8Array.of(2)); + const blocked = session.publishFrame(Uint8Array.of(3)); + + await vi.waitFor(() => + expect(session.metrics).toMatchObject({ + memoryReplayMaxBytes: 130, + memoryReplayUsedBytes: 130, + waitingMemoryReplayAppends: 1, + totalMemoryReplayBackpressureStalls: 1, + totalMemoryReplayAppendTimeouts: 0, + }), + ); + expect(connection.sent).toEqual([Uint8Array.of(1), Uint8Array.of(2)]); + + connection.receive(ingressResponse(QWP_STATUS.OK, 0n)); + await expect(blocked).resolves.toBeUndefined(); + expect(connection.sent).toEqual([ + Uint8Array.of(1), + Uint8Array.of(2), + Uint8Array.of(3), + ]); + expect(session.metrics).toMatchObject({ + memoryReplayUsedBytes: 130, + waitingMemoryReplayAppends: 0, + totalMemoryReplayBackpressureStalls: 1, + totalMemoryReplayAppendTimeouts: 0, + }); + await session.close(); + }); + + it("bounds memory replay waits with typed capacity errors", async () => { + const connection = new FakeConnection("primary"); + const session = await QwpIngressSession.connect(async () => connection, { + memoryReplayMaxBytes: 65, + memoryReplayAppendDeadlineMs: 50, + }); + + await session.publishFrame(Uint8Array.of(1)); + await expect(session.publishFrame(Uint8Array.of(2))).rejects.toMatchObject({ + name: "QwpMemoryReplayAppendTimeoutError", + maxBytes: 65, + usedBytes: 65, + requiredBytes: 65, + timeoutMs: 50, + } satisfies Partial); + expect(session.metrics).toMatchObject({ + pendingReplayFrames: 1, + pendingReplayBytes: 1, + waitingMemoryReplayAppends: 0, + totalMemoryReplayBackpressureStalls: 1, + totalMemoryReplayAppendTimeouts: 1, + }); + + await expect( + QwpIngressSession.connect(async () => new FakeConnection("other"), { + memoryReplayMaxBytes: 64, + }).then((tooSmall) => + tooSmall.publishFrame(Uint8Array.of(1)).finally(() => tooSmall.close()), + ), + ).rejects.toBeInstanceOf(QwpMemoryReplayFrameTooLargeError); + await session.close(); + }); + + it("interrupts a memory replay capacity wait on close", async () => { + const connection = new FakeConnection("primary"); + const session = await QwpIngressSession.connect(async () => connection, { + memoryReplayMaxBytes: 65, + memoryReplayAppendDeadlineMs: 60_000, + }); + + await session.publishFrame(Uint8Array.of(1)); + const blocked = session.publishFrame(Uint8Array.of(2)); + const rejected = expect(blocked).rejects.toMatchObject({ + name: "QwpSendClosedError", + }); + await vi.waitFor(() => + expect(session.metrics.waitingMemoryReplayAppends).toBe(1), + ); + + await session.close(); + await rejected; + expect(session.metrics).toMatchObject({ + pendingReplayFrames: 0, + pendingReplayBytes: 0, + memoryReplayUsedBytes: 0, + waitingMemoryReplayAppends: 0, + }); + }); + + it("validates memory replay capacity controls", async () => { + await expect( + QwpIngressSession.connect(async () => new FakeConnection("primary"), { + memoryReplayMaxBytes: 0, + }), + ).rejects.toThrow(/memoryReplayMaxBytes must be a positive safe integer/); + await expect( + QwpIngressSession.connect(async () => new FakeConnection("primary"), { + memoryReplayAppendDeadlineMs: 0, + }), + ).rejects.toThrow( + /memoryReplayAppendDeadlineMs must be a positive safe integer/, + ); + await expect( + QwpIngressSession.connect(async () => new FakeConnection("primary"), { + memoryReplayMaxBytes: 1024, + replayStore: new TrackingReplayStore(), + }), + ).rejects.toThrow(/cannot be combined with a custom replayStore/); + }); + it("keeps default ingress initial connection establishment fail-fast", async () => { const failure = new Error("offline"); let factoryCalls = 0; @@ -1495,6 +1615,11 @@ describe("QWP ingress reconnect and replay", () => { replayAcknowledgedFrameSequence: 1n, pendingReplayFrames: 0, pendingReplayBytes: 0, + memoryReplayMaxBytes: 128 * 1024 * 1024, + memoryReplayUsedBytes: 0, + waitingMemoryReplayAppends: 0, + totalMemoryReplayBackpressureStalls: 0, + totalMemoryReplayAppendTimeouts: 0, }); await session.close(); }); From 1d881d8fdb5f941a65465956d2ca34f2e609c41b Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 19 Aug 2026 17:12:09 +0100 Subject: [PATCH 078/265] fix(qwp): align flush publication semantics --- QWP.md | 24 ++++++++---- README.md | 31 +++++++++------ src/qwp/node.ts | 18 +-------- src/qwp/sender.ts | 21 +++++++--- test/qwp/client.test.ts | 12 +++++- test/qwp/sender-node-integration.test.ts | 3 +- test/qwp/sender.test.ts | 50 ++++++++++++++++++++++++ test/qwp/session.test.ts | 22 +++++++++++ 8 files changed, 136 insertions(+), 45 deletions(-) diff --git a/QWP.md b/QWP.md index 87c7026..507e513 100644 --- a/QWP.md +++ b/QWP.md @@ -296,8 +296,8 @@ smallest target node's cap when offline startup is required. Set `awaitServerAck: true` when a particular flush must observe QuestDB's protocol ACK before returning. `awaitDurableAck: true` implies server-ACK waiting and additionally -waits for replicated/durable progress. Browser senders continue to default to their -existing ACK-waiting behavior and do not offer persistent publication. +waits for replicated/durable progress. Browser senders use the in-memory replay +publication boundary by default and do not offer persistent disk publication. A crash after the server accepts a frame but before local acknowledgement cleanup can replay that frame, so delivery is at least once. Applications that require exactly-once @@ -339,6 +339,11 @@ try { } ``` +Like the Java QWP sender, `flush()` and `commit()` resolve after the complete +logical flush reaches the local ingress/replay publication boundary. They do +not wait for a server ACK by default. Set `awaitServerAck: true` for an +implicit ACK barrier, or use the explicit sequence API below. + For producer-controlled acknowledgement barriers, publish first and wait for the cumulative ACK watermark separately: @@ -462,8 +467,9 @@ const sender = await connectQwpBrowserSender({ ### Transactions and durable acknowledgement Transactional auto-flush keeps automatically emitted frames in an open server-side -transaction. `commit()` (an alias for `flush()`) closes the group and waits for its -cumulative acknowledgement: +transaction. `commit()` (an alias for `flush()`) publishes the group-closing frame. +The example also waits for its cumulative durable acknowledgement because it enables +`awaitDurableAck`: ```typescript const sender = await connectQwpBrowserSender( @@ -1066,9 +1072,10 @@ For the common fluent API, migration is primarily a transport change: Review these behavioral differences before rollout: -- QWP `flush()` waits for a protocol ACK by default. With Node persistent - store-and-forward it defaults to local durable publication; set `awaitServerAck` to - restore ACK waiting, or `awaitDurableAck` to wait through durable upload. +- QWP `flush()` uses the Java-compatible local-publication boundary by default in + browsers and Node.js. Set `awaitServerAck` for a protocol ACK barrier, or + `awaitDurableAck` to wait through durable upload. With Node persistent + store-and-forward, local publication means durable journal append. - QWP symbol dictionaries are connection-scoped and automatic. - Table and column identifiers are rejected locally using the Java client's rules; column identity is case-insensitive and preserves the spelling first declared. @@ -1101,7 +1108,8 @@ acknowledgement, and persistent replay—but uses runtime-specific connection fa | ---------------------------- | ------------------------------------------------------------- | | Sender/builder configuration | `Sender.fromConfig()` in Node.js, or `connectQwp*Sender()` | | Fluent table row | `table()`, typed column methods, `at()` / `atNow()` | -| Explicit drain/commit | `flush()` / `commit()` | +| Local publish/commit | `flush()` / `commit()` | +| Explicit ACK barrier | `flushAndGetSequence()` plus `waitForAcknowledged()` | | Durable delivery | `requestDurableAck` plus `awaitDurableAck` | | Store-and-forward | Node `storeAndForward`; intentionally unavailable in browsers | | Fire-and-forget UDP ingress | Node `udp::` or `connectQwpNodeUdpSender()` | diff --git a/README.md b/README.md index 8f1537f..8c07883 100644 --- a/README.md +++ b/README.md @@ -106,11 +106,13 @@ UDP datagrams are self-contained and split at row boundaries. UDP has no authentication, acknowledgements, transactions, retry, or store-and-forward and is not available in browsers. See the QWP guide for the lower-level Node UDP API. -When Node QWP is configured with `qwp.webSocket.storeAndForward`, the sender -can start and accept flushes while QuestDB is offline. `flush()` then resolves -after local durable journal publication and a background drainer reconnects -and sends in order. Set `qwp.sender.awaitServerAck: true` to wait for the -QuestDB ACK instead, or `awaitDurableAck: true` to wait through durable upload. +QWP `flush()` resolves at the local publication boundary by default in both +Node.js and browsers, matching the Java QWP sender. Set +`qwp.sender.awaitServerAck: true` to wait for QuestDB's protocol ACK instead, +or `awaitDurableAck: true` to wait through durable upload. When Node QWP is +configured with `qwp.webSocket.storeAndForward`, the publication boundary is +the local durable journal, so the sender can accept flushes while QuestDB is +offline and a background drainer reconnects and sends them in order. Set `initialConnectMode` to `"off"` (the default), `"sync"`, or `"async"` to choose fail-fast, bounded blocking, or background startup. Supplying reconnect budget settings without an explicit mode promotes initial startup to `"sync"`, @@ -127,7 +129,7 @@ concurrency. Pooled QWP clients recover out-of-range `sender-N` slots automatica including leftovers after `senderPoolMax` is reduced. Terminally bad slots are marked `.failed` for inspection and can be re-enabled with `retryQwpNodeOrphanSlot()`. This persistent mode is Node-only; browser senders -continue to default to ACK waiting. +use the in-memory replay boundary. Browser applications use the browser entry point, which has no Node.js dependencies. Cookies are supplied by the browser during a same-origin @@ -148,9 +150,11 @@ await sender.close(); For batches larger than the automatic flush threshold, transactional mode keeps each auto-flushed frame in an open server-side transaction. An explicit -`flush()` (or its `commit()` alias) sends the group-closing frame and waits for -the cumulative ACK. QuestDB guarantees this atomicity per table; a flush that -contains multiple tables is not one cross-table transaction. +`flush()` (or its `commit()` alias) publishes the group-closing frame. Set +`awaitServerAck: true`, or wait on the sequence returned by +`flushAndGetSequence()`, when the call must also observe the cumulative ACK. +QuestDB guarantees this atomicity per table; a flush that contains multiple +tables is not one cross-table transaction. ```typescript const sender = await connectQwpBrowserSender( @@ -180,10 +184,11 @@ An unfinished row is not completed implicitly. The server intentionally withholds ACKs for deferred frames until commit. The sender pipelines transactional auto-flushes without waiting for those ACKs, -then waits for all of them at `flush()`/`commit()`. If durable ACK waiting is -enabled, it starts only after the transaction commits. Closing without an -explicit commit abandons the open transaction and logs a warning; QuestDB -rolls it back when the WebSocket disconnects. +then publishes the group-closing frame at `flush()`/`commit()`. With +`awaitServerAck` or `awaitDurableAck`, that call also waits for all covered +ACKs; durable waiting starts only after the transaction commits. Closing +without an explicit commit abandons the open transaction and logs a warning; +QuestDB rolls it back when the WebSocket disconnects. Ingress sessions expose browser-safe progress/error callbacks and immutable metrics snapshots. Reconnect events remain on `reconnect.onEvent`, keeping diff --git a/src/qwp/node.ts b/src/qwp/node.ts index e096b1a..3c1296b 100644 --- a/src/qwp/node.ts +++ b/src/qwp/node.ts @@ -674,27 +674,17 @@ export function createQwpNodeSender( senderOptions: QwpSenderOptions = {}, sessionOptions: QwpIngressSessionOptions = {}, ): QwpSender { - const effectiveSenderOptions: QwpSenderOptions = { - ...senderOptions, - awaitServerAck: - senderOptions.awaitServerAck ?? - (options.storeAndForward || - sessionOptions.backgroundStoreAndForward === true || - sessionOptions.initialConnectMode === QWP_INITIAL_CONNECT_MODE.ASYNC - ? senderOptions.awaitDurableAck === true - : true), - }; return new QwpSender( () => connectQwpNodeIngress( { ...options, requestDurableAck: - options.requestDurableAck ?? effectiveSenderOptions.awaitDurableAck, + options.requestDurableAck ?? senderOptions.awaitDurableAck, }, sessionOptions, ), - effectiveSenderOptions, + senderOptions, ); } @@ -903,10 +893,6 @@ function normalizeQwpNodeClientOptions( } : undefined, }, - sender: { - ...options.sender, - awaitServerAck: options.sender?.awaitServerAck ?? false, - }, ingressSession: { ...options.ingressSession, backgroundStoreAndForward: true, diff --git a/src/qwp/sender.ts b/src/qwp/sender.ts index 7c3a1e0..601bb7a 100644 --- a/src/qwp/sender.ts +++ b/src/qwp/sender.ts @@ -49,12 +49,15 @@ export interface QwpSenderOptions { transactional?: boolean; /** * Wait for the server's protocol ACK before flush()/commit() resolves. - * Defaults to true. Node persistent store-and-forward defaults this to false - * so a flush resolves after local durable publication and drains in the - * background. + * Defaults to false, matching the Java QWP sender's local-publication + * boundary. Set this to true for an acknowledgement barrier, or use + * flushAndGetSequence() followed by waitForAcknowledged(). */ awaitServerAck?: boolean; - /** Wait for durable upload after every successful ingress ACK. */ + /** + * Wait for durable upload after every successful ingress ACK. When true, + * this implies awaitServerAck unless awaitServerAck is explicitly false. + */ awaitDurableAck?: boolean; durableAckTimeoutMs?: number; /** @@ -448,7 +451,8 @@ export class QwpSender { this.autoFlushIntervalMs = options.autoFlushIntervalMs ?? DEFAULT_AUTO_FLUSH_INTERVAL_MS; this.transactional = options.transactional ?? false; - this.awaitServerAck = options.awaitServerAck ?? true; + this.awaitServerAck = + options.awaitServerAck ?? options.awaitDurableAck === true; this.closeFlushTimeoutMs = options.closeFlushTimeoutMs ?? DEFAULT_CLOSE_FLUSH_TIMEOUT_MS; this.maxNameLength = options.maxNameLength ?? DEFAULT_MAX_NAME_LENGTH; @@ -910,6 +914,10 @@ export class QwpSender { await this.tryFlush(); } + /** + * Publishes completed rows to the local ingress/replay boundary. This does + * not wait for a server ACK unless awaitServerAck or awaitDurableAck is set. + */ flush(): Promise { return this.enqueueFlush(false); } @@ -1391,7 +1399,8 @@ export class QwpSender { this.deferredAcks.push(response); // The server intentionally withholds this ACK until a later commit. // Observe rejection now so abandoning an open transaction during close - // never creates an unhandled rejection; flush()/commit() still awaits it. + // never creates an unhandled rejection; an ACK-waiting flush/commit + // still awaits it. void response.catch(() => undefined); } return { flushed: true, sequence: publishedSequence }; diff --git a/test/qwp/client.test.ts b/test/qwp/client.test.ts index ead2ba0..78a3a1f 100644 --- a/test/qwp/client.test.ts +++ b/test/qwp/client.test.ts @@ -119,16 +119,26 @@ class FakeConnection implements QwpBinaryConnection { class FakeSenderSession implements QwpSenderSession { flushes = 0; closes = 0; + publishedFrameSequence = -1n; + acknowledgedFrameSequence = -1n; sendTables(): Promise { this.flushes++; + const sequence = ++this.publishedFrameSequence; + this.acknowledgedFrameSequence = sequence; return Promise.resolve({ status: QWP_STATUS.OK, - sequence: BigInt(this.flushes - 1), + sequence, tables: [], }); } + publishTables(): Promise { + this.flushes++; + this.acknowledgedFrameSequence = ++this.publishedFrameSequence; + return Promise.resolve(); + } + waitForDurable(): Promise { return Promise.resolve(); } diff --git a/test/qwp/sender-node-integration.test.ts b/test/qwp/sender-node-integration.test.ts index 139adcd..c21ffc1 100644 --- a/test/qwp/sender-node-integration.test.ts +++ b/test/qwp/sender-node-integration.test.ts @@ -164,7 +164,8 @@ describe("Sender QWP integration", () => { await sender.connect(); await sender.table("events").intColumn("value", 42).atNow(); - expect(frames).toHaveLength(1); + expect(sender.publishedSequence).toBe(0n); + await vi.waitFor(() => expect(frames).toHaveLength(1)); await expect(sender.flush()).resolves.toBe(false); } finally { await sender.close(); diff --git a/test/qwp/sender.test.ts b/test/qwp/sender.test.ts index d0d36bb..f5fb49d 100644 --- a/test/qwp/sender.test.ts +++ b/test/qwp/sender.test.ts @@ -18,6 +18,7 @@ class RecordingSession implements QwpSenderSession { }[] = []; readonly durable: QwpIngressResponse[] = []; deltaSendCount = 0; + publicationCount = 0; closeCount = 0; publishedFrameSequence = -1n; acknowledgedFrameSequence = -1n; @@ -47,6 +48,24 @@ class RecordingSession implements QwpSenderSession { return this.sendTables(tables, options); } + async publishTables( + tables: readonly QwpTableBuffer[], + options?: QwpIngressEncodeOptions, + ): Promise { + this.publicationCount++; + this.sends.push({ tables, options }); + const sequence = ++this.publishedFrameSequence; + if (!options?.deferCommit) this.acknowledgedFrameSequence = sequence; + } + + async publishTablesDelta( + tables: readonly QwpTableBuffer[], + options?: Pick, + ): Promise { + this.deltaSendCount++; + await this.publishTables(tables, options); + } + async waitForDurable(response: QwpIngressResponse): Promise { this.durable.push(response); } @@ -175,6 +194,36 @@ function column(table: QwpTableBuffer, name: string) { } describe("QWP high-level sender", () => { + it("uses the Java-compatible local-publication flush boundary by default", async () => { + const session = new PublishingSession(); + const sender = new QwpSender(async () => session, { autoFlush: false }); + await sender.table("events").longColumn("value", 42n).atNow(); + + await expect(sender.flush()).resolves.toBe(true); + expect(session.publicationAttempts).toBe(1); + expect(session.acknowledgedFrameSequence).toBe(-1n); + expect(sender.publishedSequence).toBe(0n); + expect(sender.acknowledgedSequence).toBe(-1n); + + session.acknowledgedFrameSequence = 0n; + await sender.close(); + }); + + it("retains explicit server-ACK flush behavior", async () => { + const session = new RecordingSession(); + const sender = new QwpSender(async () => session, { + autoFlush: false, + awaitServerAck: true, + }); + await sender.table("events").longColumn("value", 42n).atNow(); + + await expect(sender.flush()).resolves.toBe(true); + expect(session.deltaSendCount).toBe(1); + expect(session.publicationCount).toBe(0); + expect(sender.acknowledgedSequence).toBe(0n); + await sender.close(); + }); + it("validates the byte auto-flush threshold", () => { const session = new RecordingSession(); expect( @@ -369,6 +418,7 @@ describe("QWP high-level sender", () => { const session = new ClosingUnblocksSession(); const sender = new QwpSender(async () => session, { autoFlush: false, + awaitServerAck: true, closeFlushTimeoutMs: 10, }); await sender.table("events").longColumn("value", 42n).atNow(); diff --git a/test/qwp/session.test.ts b/test/qwp/session.test.ts index 4a5dfc3..550b0da 100644 --- a/test/qwp/session.test.ts +++ b/test/qwp/session.test.ts @@ -668,6 +668,28 @@ describe("QWP WebSocket adapters", () => { await session.close(); }); + it("uses the local-publication flush boundary in browsers by default", async () => { + const socket = new FakeWebSocket(); + const sender = createQwpBrowserSender( + { + url: "ws://localhost:9000/write/v4", + webSocketFactory: () => asQwpSocket(socket), + }, + { autoFlush: false, closeFlushTimeoutMs: 0 }, + ); + const connecting = sender.connect(); + socket.open(); + socket.message(ingressServerInfo(1_024)); + await connecting; + + await sender.table("events").longColumn("value", 42n).atNow(); + await expect(sender.flush()).resolves.toBe(true); + expect(socket.sent).toHaveLength(1); + expect(sender.publishedSequence).toBe(0n); + expect(sender.acknowledgedSequence).toBe(-1n); + await sender.close(); + }); + it("splits fluent browser rows under the negotiated server cap", async () => { const socket = new FakeWebSocket(); const sender = createQwpBrowserSender( From 8b737d83f031492a1bd76349e2a32a58da52e906 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 19 Aug 2026 17:21:21 +0100 Subject: [PATCH 079/265] fix(qwp): recover idle pooled SFA slots --- QWP.md | 15 ++-- README.md | 7 +- src/qwp-node/orphan-drainer.ts | 77 +++++++++++++++--- src/qwp/client.ts | 59 ++++++++++++-- src/qwp/node.ts | 137 ++++++++++++++++++++++++++------ test/qwp/client.test.ts | 61 ++++++++++++++ test/qwp/node-transport.test.ts | 71 +++++++++++++++++ 7 files changed, 373 insertions(+), 54 deletions(-) diff --git a/QWP.md b/QWP.md index 507e513..7a5ba60 100644 --- a/QWP.md +++ b/QWP.md @@ -1015,13 +1015,14 @@ while a sender that outlives the bounded wait owns its eventual teardown. Pooled sender `close()` flushes completed rows, discards an unfinished row with a warning, and resets staging before reuse. With Node store-and-forward enabled, the configured directory is treated as a pool root and each stable sender slot owns a -`sender-N` child directory, avoiding journal lock conflicts. A connected pooled -client prewarms every persistent sender slot (overriding `senderPoolMin`) so journals -left by previously busy slots are recovered even when current traffic is lower. A -client-level orphan scanner also drains canonical `sender-N` slots outside the current -pool range, covering restarts where `senderPoolMax` was reduced. This managed-slot -recovery is automatic; `drainOrphans: true` additionally adopts noncanonical sibling -slots beneath the pool root. +`sender-N` child directory, avoiding journal lock conflicts. The configured +`senderPoolMin` remains authoritative. A client-level recovery scanner reserves and +drains inactive canonical slots independently of foreground pool connections, both +inside the current range and outside it after `senderPoolMax` is reduced. Foreground +creation and recovery share an atomic slot coordinator, so neither can acquire a +managed journal while the other owns it. This managed-slot recovery is automatic; +`drainOrphans: true` additionally adopts noncanonical sibling slots beneath the pool +root. ## Error handling and cleanup diff --git a/README.md b/README.md index 8c07883..854e3fe 100644 --- a/README.md +++ b/README.md @@ -125,9 +125,10 @@ dictionary formats. The active segment and a preallocated temporary hot spare ke open handles. Set `drainOrphans: true` when sibling journal directories share a dedicated parent: the Node client scans and drains slots left by failed producer processes with bounded -concurrency. Pooled QWP clients recover out-of-range `sender-N` slots automatically, -including leftovers after `senderPoolMax` is reduced. Terminally bad slots are marked -`.failed` for inspection and can be re-enabled with +concurrency. Pooled QWP clients recover idle in-range and out-of-range `sender-N` +slots automatically without raising `senderPoolMin`, including leftovers after +`senderPoolMax` is reduced. Terminally bad slots are marked `.failed` for inspection +and can be re-enabled with `retryQwpNodeOrphanSlot()`. This persistent mode is Node-only; browser senders use the in-memory replay boundary. diff --git a/src/qwp-node/orphan-drainer.ts b/src/qwp-node/orphan-drainer.ts index 567dce2..b860fa9 100644 --- a/src/qwp-node/orphan-drainer.ts +++ b/src/qwp-node/orphan-drainer.ts @@ -101,9 +101,16 @@ export interface QwpNodeOrphanDrainerOptions { directory: string, onReconnectEvent?: (event: QwpReconnectEvent) => void, ): Promise; + /** Atomically reserves a candidate against a foreground pool owner. */ + tryReserveSlot?: (directory: string) => boolean; + /** Releases a reservation previously granted by tryReserveSlot. */ + releaseSlot?: (directory: string) => void; /** Maximum slots drained concurrently. Defaults to 4. */ maxConcurrent?: number; - /** Rescan cadence; zero performs only the startup scan. Defaults to 30s. */ + /** + * Periodic rescan cadence; zero disables the timer. Explicit scanNow() + * requests remain available. Defaults to 30s. + */ scanIntervalMs?: number; /** Durable-ACK prompt cadence for adopted sessions. Zero disables it. */ durableAckPollIntervalMs?: number; @@ -215,6 +222,8 @@ export class QwpNodeOrphanDrainer { directory: string, onReconnectEvent?: (event: QwpReconnectEvent) => void, ) => Promise; + private readonly tryReserveSlot?: (directory: string) => boolean; + private readonly releaseSlot?: (directory: string) => void; private readonly maxConcurrent: number; private readonly scanIntervalMs: number; private readonly durableAckPollIntervalMs: number; @@ -226,6 +235,7 @@ export class QwpNodeOrphanDrainer { private readonly workers = new Set>(); private scanTimer?: ReturnType; private scanPromise?: Promise; + private scanRequested = false; private closePromise?: Promise; private started = false; private closing = false; @@ -275,6 +285,16 @@ export class QwpNodeOrphanDrainer { this.rootDirectory = rootDirectory; this.excludeSlot = options.excludeSlot; this.createSession = options.createSession; + if ( + (options.tryReserveSlot === undefined) !== + (options.releaseSlot === undefined) + ) { + throw new RangeError( + "QWP orphan-drain slot reservation requires both tryReserveSlot and releaseSlot", + ); + } + this.tryReserveSlot = options.tryReserveSlot; + this.releaseSlot = options.releaseSlot; this.maxConcurrent = maxConcurrent; this.scanIntervalMs = scanIntervalMs; this.durableAckPollIntervalMs = durableAckPollIntervalMs; @@ -314,7 +334,13 @@ export class QwpNodeOrphanDrainer { start(): void { if (this.started || this.closing || this.closed) return; this.started = true; - this.scanPromise = this.scanOnce(); + this.requestScan(); + } + + /** Requests an immediate scan, coalescing with one already in progress. */ + scanNow(): void { + if (!this.started || this.closing || this.closed) return; + this.requestScan(); } close(): Promise { @@ -345,14 +371,40 @@ export class QwpNodeOrphanDrainer { undefined, toError(error, "QWP orphan-slot scan failed"), ); - } finally { - if (!this.closing && this.scanIntervalMs > 0) { - this.scanTimer = setTimeout(() => { - this.scanTimer = undefined; - this.scanPromise = this.scanOnce(); - }, this.scanIntervalMs); - this.scanTimer.unref?.(); - } + } + } + + private requestScan(): void { + if (this.closing || this.closed) return; + if (this.scanPromise) { + this.scanRequested = true; + return; + } + if (this.scanTimer) clearTimeout(this.scanTimer); + this.scanTimer = undefined; + const scanning = this.scanOnce(); + this.scanPromise = scanning; + void scanning.then( + () => this.finishScan(scanning), + () => this.finishScan(scanning), + ); + } + + private finishScan(scanning: Promise): void { + if (this.scanPromise !== scanning) return; + this.scanPromise = undefined; + if (this.closing || this.closed) return; + if (this.scanRequested) { + this.scanRequested = false; + this.requestScan(); + return; + } + if (this.scanIntervalMs > 0) { + this.scanTimer = setTimeout(() => { + this.scanTimer = undefined; + this.requestScan(); + }, this.scanIntervalMs); + this.scanTimer.unref?.(); } } @@ -374,7 +426,10 @@ export class QwpNodeOrphanDrainer { private async drainOne(directory: string): Promise { let session: QwpNodeOrphanDrainSession | undefined; + let reserved = false; try { + if (this.tryReserveSlot && !this.tryReserveSlot(directory)) return; + reserved = this.tryReserveSlot !== undefined; session = await this.createSession(directory, (event) => this.emitReconnectEvent(directory, event), ); @@ -406,6 +461,7 @@ export class QwpNodeOrphanDrainer { .close(1000, "QWP orphan slot drained") .catch(() => undefined); } + if (reserved) this.releaseSlot?.(directory); } } @@ -447,6 +503,7 @@ export class QwpNodeOrphanDrainer { this.closing = true; if (this.scanTimer) clearTimeout(this.scanTimer); this.scanTimer = undefined; + this.scanRequested = false; this.queue.length = 0; await this.scanPromise?.catch(() => undefined); await Promise.all( diff --git a/src/qwp/client.ts b/src/qwp/client.ts index cc4582b..0f7c77a 100644 --- a/src/qwp/client.ts +++ b/src/qwp/client.ts @@ -47,12 +47,21 @@ export interface QwpClientPoolOptions { export interface QwpClientFactories { createSender(slot: number): Promise; createQuerySession(slot: number): Promise; + /** @internal Coordinates stable persistent sender slots with recovery. */ + senderSlotReservation?: QwpPoolSlotReservation; /** @internal Starts runtime-specific background services on first use. */ start?(): void | Promise; /** @internal Stops runtime-specific background services during close. */ close?(): void | Promise; } +/** @internal Cross-owner reservation for stable pooled sender slot indexes. */ +export interface QwpPoolSlotReservation { + tryReserve(slot: number): boolean; + release(slot: number): void; + onAvailable(listener: () => void): () => void; +} + export interface QwpResourcePoolMetrics { readonly minimum: number; readonly maximum: number; @@ -148,6 +157,8 @@ class QwpResourcePool { private readonly creationOperations = new Set>(); private readonly waiters = new Set(); private readonly closeWaiters = new Set(); + private readonly reservedSlots = new Set(); + private readonly unsubscribeSlotAvailability?: () => void; private closePromise?: Promise; private pendingLeaseTeardowns = 0; private closed = false; @@ -162,7 +173,12 @@ class QwpResourcePool { private readonly createResource: (slot: number) => Promise, private readonly destroyResource: (resource: T) => Promise, private readonly closeLeasedOnShutdown = false, - ) {} + private readonly slotReservation?: QwpPoolSlotReservation, + ) { + this.unsubscribeSlotAvailability = slotReservation?.onAvailable(() => + this.wakeWaiters(), + ); + } get metrics(): QwpResourcePoolMetrics { return Object.freeze({ @@ -273,6 +289,7 @@ class QwpResourcePool { private async closeNow(): Promise { if (this.closed) return; this.closed = true; + this.unsubscribeSlotAvailability?.(); for (const waiter of this.waiters) { if (waiter.timer) clearTimeout(waiter.timer); waiter.reject(new QwpClientClosedError()); @@ -338,6 +355,10 @@ class QwpResourcePool { !this.creatingSlots.has(slot) && !this.destroyingSlots.has(slot) ) { + if (this.slotReservation && !this.slotReservation.tryReserve(slot)) { + continue; + } + if (this.slotReservation) this.reservedSlots.add(slot); this.creatingSlots.add(slot); return slot; } @@ -351,6 +372,7 @@ class QwpResourcePool { finishCreation = resolve; }); this.creationOperations.add(operation); + let retained = false; try { let value: T; try { @@ -371,9 +393,11 @@ class QwpResourcePool { leased: true, }; this.all.set(slot, entry); + retained = true; return entry; } finally { this.creatingSlots.delete(slot); + if (!retained) this.releaseSlotReservation(slot); finishCreation(); this.creationOperations.delete(operation); this.wakeWaiters(); @@ -437,13 +461,18 @@ class QwpResourcePool { private destroy(entry: PoolEntry): Promise { if (!entry.destroyPromise) { - entry.destroyPromise = this.destroyResource(entry.value).catch( - () => undefined, - ); + entry.destroyPromise = this.destroyResource(entry.value) + .catch(() => undefined) + .finally(() => this.releaseSlotReservation(entry.slot)); } return entry.destroyPromise; } + private releaseSlotReservation(slot: number): void { + if (!this.reservedSlots.delete(slot)) return; + this.slotReservation?.release(slot); + } + private async destroyRetired(entry: PoolEntry): Promise { this.destroyingSlots.add(entry.slot); try { @@ -572,6 +601,8 @@ export class QwpClient { validated.maxLifetimeMs, factories.createSender, (sender) => sender.close(), + false, + factories.senderSlotReservation, ); this.queryPool = new QwpResourcePool( "query", @@ -656,10 +687,22 @@ export class QwpClient { await this.startPromise?.catch(() => undefined); await this.housekeepingTask; try { - await Promise.resolve() - .then(() => this.closeFactories?.()) - .catch(() => undefined); - await Promise.all([this.queryPool.close(), this.senderPool.close()]); + let runtimeClose: Promise; + try { + runtimeClose = Promise.resolve(this.closeFactories?.()).catch( + () => undefined, + ); + } catch { + runtimeClose = Promise.resolve(); + } + // Stop runtime scanners and reject pool waiters in the same phase. This + // prevents a recovery-slot release during shutdown from waking an older + // borrow into a newly created foreground connection. + await Promise.all([ + runtimeClose, + this.queryPool.close(), + this.senderPool.close(), + ]); } finally { this.closed = true; } diff --git a/src/qwp/node.ts b/src/qwp/node.ts index 3c1296b..3e28305 100644 --- a/src/qwp/node.ts +++ b/src/qwp/node.ts @@ -42,7 +42,11 @@ import { type QwpSenderError, } from "./sender-error"; import { QwpSender, QwpSenderOptions } from "./sender"; -import { QwpClient, QwpClientPoolOptions } from "./client"; +import { + QwpClient, + QwpClientPoolOptions, + type QwpPoolSlotReservation, +} from "./client"; import { quarantineQwpNodeReplayStore, QwpNodeFileReplayStore, @@ -252,12 +256,15 @@ export interface QwpNodeStoreAndForwardOptions /** * Adopts sibling replay slots left by terminated producers. Standalone * senders default this to false; pooled clients always recover their own - * out-of-range `sender-N` slots after a pool-size reduction. + * idle in-range and out-of-range `sender-N` slots. */ drainOrphans?: boolean; /** Maximum sibling slots drained concurrently. Defaults to 4. */ maxBackgroundDrainers?: number; - /** Rescan cadence; zero scans only at startup. Defaults to 30 seconds. */ + /** + * Periodic rescan cadence; zero disables the timer. Pooled ownership + * changes can still trigger a scan. Defaults to 30 seconds. + */ orphanScanIntervalMs?: number; /** * Receives isolated scanner, drainer, durable-ACK capability-gap, and @@ -792,7 +799,9 @@ export function createQwpNodeClient( optionsOrConfiguration, extraOptions, ); - const orphanDrainer = createPooledOrphanDrainer(options); + const slotCoordinator = createPooledSlotCoordinator(options); + const orphanDrainer = createPooledOrphanDrainer(options, slotCoordinator); + let unsubscribeRecoveryScan: (() => void) | undefined; return new QwpClient( { createSender: async (slot) => { @@ -812,10 +821,22 @@ export function createQwpNodeClient( }, createQuerySession: () => connectQwpNodeEgress(options.egress, options.egressSession), - start: () => orphanDrainer?.start(), - close: () => orphanDrainer?.close(), + senderSlotReservation: slotCoordinator, + start: () => { + if (orphanDrainer && slotCoordinator) { + unsubscribeRecoveryScan = slotCoordinator.onAvailable(() => + orphanDrainer.scanNow(), + ); + } + orphanDrainer?.start(); + }, + close: async () => { + unsubscribeRecoveryScan?.(); + unsubscribeRecoveryScan = undefined; + await orphanDrainer?.close(); + }, }, - pooledNodeClientOptions(options), + options.pool, ); } @@ -902,18 +923,6 @@ function normalizeQwpNodeClientOptions( }; } -function pooledNodeClientOptions( - options: QwpNodeClientOptions, -): QwpClientPoolOptions | undefined { - if (!options.ingress.storeAndForward) return options.pool; - const senderPoolMax = options.pool?.senderPoolMax ?? 4; - return { - ...options.pool, - senderPoolMin: senderPoolMax, - senderPoolMax, - }; -} - function pooledNodeIngressOptions( options: QwpNodeIngressOptions, slot: number, @@ -955,6 +964,7 @@ function createStandaloneOrphanDrainer( function createPooledOrphanDrainer( options: QwpNodeClientOptions, + slotCoordinator?: QwpPooledSfaSlotCoordinator, ): QwpNodeOrphanDrainer | undefined { const storeAndForward = options.ingress.storeAndForward; if (!storeAndForward) return undefined; @@ -970,15 +980,17 @@ function createPooledOrphanDrainer( rootDirectory, (slotName) => { const managedIndex = parseCanonicalSenderSlot(slotName, senderId); - if (managedIndex !== undefined && managedIndex < managedSlotCount) { - return true; + if (managedIndex !== undefined) { + return ( + managedIndex < managedSlotCount && + slotCoordinator?.isForegroundReserved(managedIndex) === true + ); } - // Same-base slots outside the new pool range are always recovered. A - // caller must opt in before unrelated sibling names are adopted. - return ( - managedIndex === undefined && storeAndForward.drainOrphans !== true - ); + // Same-base slots in and outside the current pool range are always + // recovered. A caller must opt in before unrelated siblings are adopted. + return storeAndForward.drainOrphans !== true; }, + slotCoordinator, ); } @@ -987,11 +999,18 @@ function createNodeOrphanDrainer( sessionOptions: QwpIngressSessionOptions, rootDirectory: string, excludeSlot: (slotName: string) => boolean, + slotCoordinator?: QwpPooledSfaSlotCoordinator, ): QwpNodeOrphanDrainer { const storeAndForward = options.storeAndForward!; return new QwpNodeOrphanDrainer({ rootDirectory, excludeSlot, + tryReserveSlot: slotCoordinator + ? (directory) => slotCoordinator.tryReserveRecovery(directory) + : undefined, + releaseSlot: slotCoordinator + ? (directory) => slotCoordinator.releaseRecovery(directory) + : undefined, maxConcurrent: storeAndForward.maxBackgroundDrainers, scanIntervalMs: storeAndForward.orphanScanIntervalMs, durableAckPollIntervalMs: options.requestDurableAck @@ -1021,6 +1040,72 @@ function createNodeOrphanDrainer( }); } +function createPooledSlotCoordinator( + options: QwpNodeClientOptions, +): QwpPooledSfaSlotCoordinator | undefined { + if (!options.ingress.storeAndForward) return undefined; + return new QwpPooledSfaSlotCoordinator( + validateQwpSenderId(options.ingress.senderId ?? "sender"), + options.pool?.senderPoolMax ?? 4, + ); +} + +/** Serializes foreground pool creation with recovery of its stable SFA slots. */ +class QwpPooledSfaSlotCoordinator implements QwpPoolSlotReservation { + private readonly foreground = new Set(); + private readonly recovering = new Set(); + private readonly listeners = new Set<() => void>(); + + constructor( + private readonly senderId: string, + private readonly managedSlotCount: number, + ) {} + + tryReserve(slot: number): boolean { + if (this.foreground.has(slot) || this.recovering.has(slot)) return false; + this.foreground.add(slot); + return true; + } + + release(slot: number): void { + if (!this.foreground.delete(slot)) return; + this.notifyAvailable(); + } + + onAvailable(listener: () => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + isForegroundReserved(slot: number): boolean { + return this.foreground.has(slot); + } + + tryReserveRecovery(directory: string): boolean { + const slot = parseCanonicalSenderSlot(basename(directory), this.senderId); + if (slot === undefined || slot >= this.managedSlotCount) return true; + if (this.foreground.has(slot) || this.recovering.has(slot)) return false; + this.recovering.add(slot); + return true; + } + + releaseRecovery(directory: string): void { + const slot = parseCanonicalSenderSlot(basename(directory), this.senderId); + if ( + slot === undefined || + slot >= this.managedSlotCount || + !this.recovering.delete(slot) + ) { + return; + } + this.notifyAvailable(); + } + + private notifyAvailable(): void { + for (const listener of this.listeners) listener(); + } +} + function orphanIngressSessionOptions( options: QwpIngressSessionOptions, onReconnectEvent?: (event: QwpReconnectEvent) => void, diff --git a/test/qwp/client.test.ts b/test/qwp/client.test.ts index 78a3a1f..848a7e8 100644 --- a/test/qwp/client.test.ts +++ b/test/qwp/client.test.ts @@ -16,6 +16,7 @@ import { QwpHandshakeMetadata, QwpIngressResponse, QwpPoolAcquireTimeoutError, + type QwpPoolSlotReservation, QwpSender, QwpSenderSession, } from "../../src/qwp"; @@ -163,6 +164,66 @@ async function createQuerySession( } describe("QWP pooled client", () => { + it("coordinates a pooled sender slot with background recovery", async () => { + const listeners = new Set<() => void>(); + let recovering = true; + let reserved = false; + let creations = 0; + let releases = 0; + const reservation: QwpPoolSlotReservation = { + tryReserve: () => { + if (recovering || reserved) return false; + reserved = true; + return true; + }, + release: () => { + reserved = false; + releases++; + }, + onAvailable: (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }; + const client = new QwpClient( + { + senderSlotReservation: reservation, + createSender: async () => { + creations++; + const session = new FakeSenderSession(); + const sender = new QwpSender(async () => session, { + autoFlush: false, + }); + await sender.connect(); + return sender; + }, + createQuerySession: async () => { + throw new Error("query factory should not run"); + }, + }, + { + senderPoolMin: 0, + senderPoolMax: 1, + queryPoolMin: 0, + queryPoolMax: 1, + acquireTimeoutMs: 500, + }, + ); + + const borrowing = client.borrowSender(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(creations).toBe(0); + + recovering = false; + for (const listener of listeners) listener(); + const sender = await borrowing; + expect(creations).toBe(1); + await sender.close(); + expect(releases).toBe(0); + await client.close(); + expect(releases).toBe(1); + }); + it("validates idle, lifetime, and housekeeping options", () => { const factories = { createSender: async () => { diff --git a/test/qwp/node-transport.test.ts b/test/qwp/node-transport.test.ts index 0aa5a5b..330561a 100644 --- a/test/qwp/node-transport.test.ts +++ b/test/qwp/node-transport.test.ts @@ -429,6 +429,77 @@ describe("QWP Node transport", () => { } }); + it("recovers an idle in-range SFA slot without prewarming to pool maximum", async () => { + const endpoint = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + endpoint.on("headers", (headers) => { + headers.push("X-QWP-Version: 1"); + }); + const received: Uint8Array[] = []; + endpoint.on("connection", (socket) => { + let sequence = 0n; + socket.on("message", (payload) => { + received.push(new Uint8Array(payload as Buffer)); + socket.send(okResponse(sequence++, "trades", 1n)); + }); + }); + await listen(endpoint); + const address = endpoint.address() as AddressInfo; + const rootDirectory = await mkdtemp(join(tmpdir(), "qwp-node-pool-in-")); + const idleManagedDirectory = join(rootDirectory, "sender-1"); + const idleManaged = new QwpNodeFileReplayStore({ + directory: idleManagedDirectory, + }); + await idleManaged.load(); + await idleManaged.append({ + frameSequence: 0n, + payload: Uint8Array.of(7, 8, 9), + }); + await idleManaged.close(); + + const events: string[] = []; + const client = await connectQwpNodeClient({ + ingress: { + url: `ws://127.0.0.1:${address.port}/write/v4`, + storeAndForward: { + directory: rootDirectory, + orphanScanIntervalMs: 0, + onOrphanDrainEvent: (event) => events.push(event.kind), + }, + }, + egress: { + url: `ws://127.0.0.1:${address.port}/read/v1`, + }, + pool: { + senderPoolMin: 1, + senderPoolMax: 2, + queryPoolMin: 0, + queryPoolMax: 1, + }, + }); + try { + expect(client.metrics.senders).toMatchObject({ + minimum: 1, + maximum: 2, + total: 1, + }); + await vi.waitFor( + async () => { + expect(await assignedReplaySegments(idleManagedDirectory)).toEqual( + [], + ); + expect(events).toContain("drained"); + }, + { timeout: 2_000 }, + ); + expect(received).toContainEqual(Uint8Array.of(7, 8, 9)); + expect(client.metrics.senders.total).toBe(1); + } finally { + await client.close(); + await closeServer(endpoint); + await rm(rootDirectory, { recursive: true, force: true }); + } + }); + it("quarantines a corrupt foreground slot and continues with a fresh producer", async () => { server = new WebSocketServer({ host: "127.0.0.1", port: 0 }); server.on("headers", (headers) => { From 42722cccb41debeafcdc38842a58c504516c1f2f Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 19 Aug 2026 17:34:57 +0100 Subject: [PATCH 080/265] fix(qwp): share failover health across drainers --- QWP.md | 6 + src/qwp/internal/egress-routing.ts | 4 +- src/qwp/internal/failover.ts | 293 ++++++++++++++++++++++------- src/qwp/node.ts | 35 +++- test/qwp/reconnect.test.ts | 89 ++++++++- 5 files changed, 351 insertions(+), 76 deletions(-) diff --git a/QWP.md b/QWP.md index 7a5ba60..aa86908 100644 --- a/QWP.md +++ b/QWP.md @@ -517,6 +517,12 @@ rejection) and then by zone affinity; configuration order breaks ties. Health ou zone, so a known healthy cross-zone node is preferred to an untried local node. Every connection sweep can still try every endpoint, allowing role and health changes to recover. A non-orderly close demotes the selected endpoint before the next sweep. +Each standalone Node sender/drainer family, and each pooled orphan scanner, shares one +live health ledger among its walkers while keeping independent sweep cursors, so +concurrent drainers cannot consume one another's endpoint attempts. After a foreground +round is exhausted, stale classifications are reset while learned zone tiers persist; +the most recent successful same-zone endpoint remains sticky. Background orphan +drainers publish health observations but never reset foreground classifications. Ingress reconnect is enabled by default for factory-created browser and Node sessions. Unacknowledged frames are retained in memory and replayed at least once after a transport failure. The built-in memory replay queue is capped at 128 MiB. When the diff --git a/src/qwp/internal/egress-routing.ts b/src/qwp/internal/egress-routing.ts index 69b8116..4280970 100644 --- a/src/qwp/internal/egress-routing.ts +++ b/src/qwp/internal/egress-routing.ts @@ -6,11 +6,11 @@ import { import { QwpBinaryConnection, QwpConnectionFactory, - QwpEgressRoutingOptions, QwpSendClosedError, } from "../transport"; import { createQwpFailoverConnectionFactory, + QwpFailoverSelectionOptions, QwpValidatedConnection, } from "./failover"; @@ -23,7 +23,7 @@ export function createQwpEgressFailoverConnectionFactory( preferredUrl: string | URL, failoverUrls: readonly (string | URL)[] | undefined, connect: (endpoint: string | URL) => Promise, - routing: QwpEgressRoutingOptions, + routing: QwpFailoverSelectionOptions, serverInfoTimeoutMs: number, ): QwpConnectionFactory { return createQwpFailoverConnectionFactory( diff --git a/src/qwp/internal/failover.ts b/src/qwp/internal/failover.ts index 33e513b..e40dbf9 100644 --- a/src/qwp/internal/failover.ts +++ b/src/qwp/internal/failover.ts @@ -32,6 +32,152 @@ type ZoneTier = (typeof ZONE_TIER)[keyof typeof ZONE_TIER]; interface QwpEndpointHealth { state: HostState; zoneTier: ZoneTier; + lastSuccessEpoch: number; +} + +/** + * Shared endpoint classifications used by independent connection walkers. + * Each factory keeps its own sweep cursor while publishing observations here, + * so concurrent pooled sessions and orphan drainers cannot steal attempts from + * one another but immediately benefit from one another's health discoveries. + */ +export class QwpFailoverHealthTracker { + private readonly endpointKeys: readonly string[]; + private readonly health: QwpEndpointHealth[]; + private successEpoch = 0; + + constructor( + preferredUrl: string | URL, + failoverUrls: readonly (string | URL)[] | undefined, + private readonly target: QwpTarget, + private readonly configuredZone: string | undefined, + ) { + this.endpointKeys = endpointKeys(preferredUrl, failoverUrls); + const zoneBlind = this.zoneBlind; + this.health = this.endpointKeys.map(() => ({ + state: HOST_STATE.UNKNOWN, + zoneTier: zoneBlind ? ZONE_TIER.SAME : ZONE_TIER.UNKNOWN, + lastSuccessEpoch: 0, + })); + } + + assertCompatible( + preferredUrl: string | URL, + failoverUrls: readonly (string | URL)[] | undefined, + target: QwpTarget, + configuredZone: string | undefined, + ): void { + const keys = endpointKeys(preferredUrl, failoverUrls); + if ( + target !== this.target || + configuredZone !== this.configuredZone || + keys.length !== this.endpointKeys.length || + keys.some((key, index) => key !== this.endpointKeys[index]) + ) { + throw new RangeError( + "QWP failover health tracker does not match the endpoint routing configuration", + ); + } + } + + newRoundCursor(deferredEndpoint?: number): QwpFailoverRoundCursor { + return new QwpFailoverRoundCursor(this.health, deferredEndpoint); + } + + /** + * Starts a recovery round with stale classifications forgotten. The newest + * successful same-zone endpoint stays healthy, matching the Java client's + * locality-aware stickiness; learned zone tiers persist across rounds. + */ + forgetClassifications(): void { + let stickyIndex = -1; + let newestSuccess = -1; + for (let index = 0; index < this.health.length; index++) { + const health = this.health[index]; + if ( + health.state === HOST_STATE.HEALTHY && + health.zoneTier === ZONE_TIER.SAME && + health.lastSuccessEpoch > newestSuccess + ) { + stickyIndex = index; + newestSuccess = health.lastSuccessEpoch; + } + } + for (let index = 0; index < this.health.length; index++) { + if (index !== stickyIndex) this.health[index].state = HOST_STATE.UNKNOWN; + } + } + + recordFailure(index: number, error: unknown): void { + const health = this.health[index]; + if (error instanceof QwpUpgradeError) { + this.recordZone(index, error.serverZone); + if (error.kind === QWP_UPGRADE_ERROR_KIND.ROLE_REJECTED) { + health.state = + normalizeRole(error.serverRole) === "PRIMARY_CATCHUP" + ? HOST_STATE.TRANSIENT_REJECT + : HOST_STATE.TOPOLOGY_REJECT; + return; + } + } + health.state = HOST_STATE.TRANSPORT_ERROR; + } + + recordSuccess(index: number): void { + const health = this.health[index]; + health.state = HOST_STATE.HEALTHY; + health.lastSuccessEpoch = ++this.successEpoch; + } + + recordZone(index: number, serverZone: string | undefined): void { + const normalized = normalizeZone(serverZone); + if (!normalized) return; + this.health[index].zoneTier = + this.zoneBlind || normalized === this.configuredZone + ? ZONE_TIER.SAME + : ZONE_TIER.OTHER; + } + + recordMidStreamFailure(index: number): void { + const health = this.health[index]; + if (health.state === HOST_STATE.HEALTHY) { + health.state = HOST_STATE.TRANSPORT_ERROR; + } + } + + recordTransientReject(index: number): void { + this.health[index].state = HOST_STATE.TRANSIENT_REJECT; + } + + private get zoneBlind(): boolean { + return ( + this.configuredZone === undefined || this.target === QWP_TARGET.PRIMARY + ); + } +} + +class QwpFailoverRoundCursor { + private readonly attempted = new Set(); + + constructor( + private readonly health: readonly QwpEndpointHealth[], + private readonly deferredEndpoint?: number, + ) {} + + next(): number | undefined { + const selected = pickNextEndpoint( + this.health, + this.attempted, + this.deferredEndpoint, + ); + if (selected === undefined) return undefined; + this.attempted.add(selected); + return selected; + } + + get exhausted(): boolean { + return this.attempted.size === this.health.length; + } } export interface QwpValidatedConnection { @@ -45,6 +191,24 @@ export interface QwpFailoverSelectionOptions extends QwpEgressRoutingOptions { validateConnection?: ( connection: QwpBinaryConnection, ) => Promise; + /** @internal Shares classifications without sharing a walker's cursor. */ + healthTracker?: QwpFailoverHealthTracker; + /** @internal Background walkers must not reset shared classifications. */ + resetClassificationsAfterExhaustion?: boolean; +} + +/** Creates a health ledger that can be shared by independent walkers. */ +export function createQwpFailoverHealthTracker( + preferredUrl: string | URL, + failoverUrls: readonly (string | URL)[] | undefined, + options: QwpEgressRoutingOptions = {}, +): QwpFailoverHealthTracker { + return new QwpFailoverHealthTracker( + preferredUrl, + failoverUrls, + normalizeTarget(options.target), + normalizeZone(options.zone), + ); } /** @@ -61,23 +225,41 @@ export function createQwpFailoverConnectionFactory( const endpoints = [preferredUrl, ...(failoverUrls ?? [])]; const target = normalizeTarget(options.target); const configuredZone = normalizeZone(options.zone); - const zoneBlind = - configuredZone === undefined || target === QWP_TARGET.PRIMARY; - const health: QwpEndpointHealth[] = endpoints.map(() => ({ - state: HOST_STATE.UNKNOWN, - zoneTier: zoneBlind ? ZONE_TIER.SAME : ZONE_TIER.UNKNOWN, - })); + const healthTracker = + options.healthTracker ?? + new QwpFailoverHealthTracker( + preferredUrl, + failoverUrls, + target, + configuredZone, + ); + healthTracker.assertCompatible( + preferredUrl, + failoverUrls, + target, + configuredZone, + ); + const resetClassificationsAfterExhaustion = + options.resetClassificationsAfterExhaustion !== false; let deferredEndpoint: number | undefined; + let resetClassificationsBeforeSweep = false; return async (): Promise => { + if ( + resetClassificationsBeforeSweep && + resetClassificationsAfterExhaustion + ) { + healthTracker.forgetClassifications(); + } + resetClassificationsBeforeSweep = false; const attempts: QwpFailoverAttempt[] = []; - const attempted = new Set(); const deferredForSweep = deferredEndpoint; deferredEndpoint = undefined; + const cursor = healthTracker.newRoundCursor(deferredForSweep); - while (attempted.size < endpoints.length) { - const index = pickNextEndpoint(health, attempted, deferredForSweep); - attempted.add(index); + while (true) { + const index = cursor.next(); + if (index === undefined) break; const endpoint = endpoints[index]; let candidate: QwpBinaryConnection | undefined; try { @@ -98,12 +280,7 @@ export function createQwpFailoverConnectionFactory( }; } candidate = validated.connection; - recordZone( - health[index], - configuredZone, - zoneBlind, - validated.serverZone, - ); + healthTracker.recordZone(index, validated.serverZone); if (!matchesTarget(validated.serverRole, target)) { throw new QwpRoleMismatchError( target, @@ -112,13 +289,20 @@ export function createQwpFailoverConnectionFactory( validated.serverZone, ); } - health[index].state = HOST_STATE.HEALTHY; - return observeConnectionHealth(candidate, health[index], () => { - health[index].state = HOST_STATE.TRANSIENT_REJECT; - deferredEndpoint = index; - }); + healthTracker.recordSuccess(index); + resetClassificationsBeforeSweep = cursor.exhausted; + return observeConnectionHealth( + candidate, + () => { + healthTracker.recordMidStreamFailure(index); + }, + () => { + healthTracker.recordTransientReject(index); + deferredEndpoint = index; + }, + ); } catch (error) { - recordFailure(health[index], configuredZone, zoneBlind, error); + healthTracker.recordFailure(index, error); attempts.push({ endpoint, error }); if (candidate) await candidate.close().catch(() => undefined); if (error instanceof QwpUpgradeError && !error.tryNextEndpoint) { @@ -126,6 +310,7 @@ export function createQwpFailoverConnectionFactory( } } } + resetClassificationsBeforeSweep = true; if (attempts.length === 1) throw attempts[0].error; throw new QwpFailoverError(attempts); }; @@ -168,7 +353,7 @@ function pickNextEndpoint( health: readonly QwpEndpointHealth[], attempted: ReadonlySet, deferredEndpoint?: number, -): number { +): number | undefined { let selected = -1; for (let index = 0; index < health.length; index++) { if (attempted.has(index) || index === deferredEndpoint) continue; @@ -177,13 +362,10 @@ function pickNextEndpoint( } } if (selected >= 0) return selected; - if ( - deferredEndpoint !== undefined && - !attempted.has(deferredEndpoint) - ) { + if (deferredEndpoint !== undefined && !attempted.has(deferredEndpoint)) { return deferredEndpoint; } - throw new Error("QWP endpoint sweep has no unattempted endpoint"); + return undefined; } function compareHealth( @@ -195,52 +377,14 @@ function compareHealth( return 0; } -function recordZone( - health: QwpEndpointHealth, - configuredZone: string | undefined, - zoneBlind: boolean, - serverZone: string | undefined, -): void { - const normalized = normalizeZone(serverZone); - if (!normalized) return; - health.zoneTier = - zoneBlind || normalized === configuredZone - ? ZONE_TIER.SAME - : ZONE_TIER.OTHER; -} - -function recordFailure( - health: QwpEndpointHealth, - configuredZone: string | undefined, - zoneBlind: boolean, - error: unknown, -): void { - if (error instanceof QwpUpgradeError) { - recordZone(health, configuredZone, zoneBlind, error.serverZone); - if (error.kind === QWP_UPGRADE_ERROR_KIND.ROLE_REJECTED) { - health.state = - normalizeRole(error.serverRole) === "PRIMARY_CATCHUP" - ? HOST_STATE.TRANSIENT_REJECT - : HOST_STATE.TOPOLOGY_REJECT; - return; - } - } - health.state = HOST_STATE.TRANSPORT_ERROR; -} - function observeConnectionHealth( connection: QwpBinaryConnection, - health: QwpEndpointHealth, + demoteEndpoint: () => void, deprioritizeEndpoint: () => void, ): QwpBinaryConnection { - const demote = (): void => { - if (health.state === HOST_STATE.HEALTHY) { - health.state = HOST_STATE.TRANSPORT_ERROR; - } - }; void connection.closed.then((info) => { - if (!info.wasClean) demote(); - }, demote); + if (!info.wasClean) demoteEndpoint(); + }, demoteEndpoint); const observed: QwpBinaryConnection = { messages: connection.messages, closed: connection.closed, @@ -257,7 +401,7 @@ function observeConnectionHealth( try { await connection.send(payload); } catch (error) { - demote(); + demoteEndpoint(); throw error; } }, @@ -268,7 +412,7 @@ function observeConnectionHealth( try { await connection.ping!(); } catch (error) { - demote(); + demoteEndpoint(); throw error; } }; @@ -278,3 +422,10 @@ function observeConnectionHealth( } return observed; } + +function endpointKeys( + preferredUrl: string | URL, + failoverUrls: readonly (string | URL)[] | undefined, +): readonly string[] { + return [preferredUrl, ...(failoverUrls ?? [])].map(String); +} diff --git a/src/qwp/node.ts b/src/qwp/node.ts index 3e28305..7acda9a 100644 --- a/src/qwp/node.ts +++ b/src/qwp/node.ts @@ -17,7 +17,11 @@ import { QwpWebSocketLike, validateQwpWebSocketTimeouts, } from "./internal/websocket-connection"; -import { createQwpFailoverConnectionFactory } from "./internal/failover"; +import { + createQwpFailoverConnectionFactory, + createQwpFailoverHealthTracker, + QwpFailoverHealthTracker, +} from "./internal/failover"; import { createQwpEgressFailoverConnectionFactory } from "./internal/egress-routing"; import { validateQwpMaxBatchRows } from "./internal/egress-limits"; import { resolveQwpNodeClientConfig } from "../qwp-node/client-config"; @@ -377,11 +381,20 @@ export function connectQwpNodeWebSocket( /** Creates a stateful Node endpoint walker suitable for session reconnects. */ export function createQwpNodeConnectionFactory( options: QwpNodeWebSocketOptions, +): QwpConnectionFactory { + return createQwpNodeConnectionFactoryInternal(options); +} + +function createQwpNodeConnectionFactoryInternal( + options: QwpNodeWebSocketOptions, + healthTracker?: QwpFailoverHealthTracker, + resetClassificationsAfterExhaustion = true, ): QwpConnectionFactory { return createQwpFailoverConnectionFactory( options.url, options.failoverUrls, (endpoint) => connectQwpNodeEndpoint(options, endpoint), + { healthTracker, resetClassificationsAfterExhaustion }, ); } @@ -539,7 +552,11 @@ async function connectQwpNodeIngressInternal( options: QwpNodeIngressOptions, sessionOptions: QwpIngressSessionOptions, startOrphanDrainer: boolean, + sharedHealthTracker?: QwpFailoverHealthTracker, ): Promise { + const healthTracker = + sharedHealthTracker ?? + createQwpFailoverHealthTracker(options.url, options.failoverUrls); const storeAndForward = resolveNodeStoreAndForwardOptions(options); if (storeAndForward && sessionOptions.replayStore) { throw new RangeError( @@ -588,9 +605,14 @@ async function connectQwpNodeIngressInternal( ? createStandaloneOrphanDrainer( { ...options, senderId: undefined, storeAndForward }, sessionOptions, + healthTracker, ) : undefined; - const connectionFactory = createQwpNodeConnectionFactory(options); + const connectionFactory = createQwpNodeConnectionFactoryInternal( + options, + healthTracker, + startOrphanDrainer, + ); let session: QwpIngressSession; try { session = await QwpIngressSession.connect( @@ -951,6 +973,7 @@ function pooledNodeIngressOptions( function createStandaloneOrphanDrainer( options: QwpNodeIngressOptions, sessionOptions: QwpIngressSessionOptions, + healthTracker: QwpFailoverHealthTracker, ): QwpNodeOrphanDrainer { const storeAndForward = options.storeAndForward!; const ownDirectory = storeAndForward.directory.trim(); @@ -959,6 +982,7 @@ function createStandaloneOrphanDrainer( sessionOptions, dirname(ownDirectory), (slotName) => slotName === basename(ownDirectory), + healthTracker, ); } @@ -974,6 +998,10 @@ function createPooledOrphanDrainer( } const managedSlotCount = options.pool?.senderPoolMax ?? 4; const senderId = validateQwpSenderId(options.ingress.senderId ?? "sender"); + const healthTracker = createQwpFailoverHealthTracker( + options.ingress.url, + options.ingress.failoverUrls, + ); return createNodeOrphanDrainer( options.ingress, options.ingressSession ?? {}, @@ -990,6 +1018,7 @@ function createPooledOrphanDrainer( // recovered. A caller must opt in before unrelated siblings are adopted. return storeAndForward.drainOrphans !== true; }, + healthTracker, slotCoordinator, ); } @@ -999,6 +1028,7 @@ function createNodeOrphanDrainer( sessionOptions: QwpIngressSessionOptions, rootDirectory: string, excludeSlot: (slotName: string) => boolean, + healthTracker: QwpFailoverHealthTracker, slotCoordinator?: QwpPooledSfaSlotCoordinator, ): QwpNodeOrphanDrainer { const storeAndForward = options.storeAndForward!; @@ -1036,6 +1066,7 @@ function createNodeOrphanDrainer( }, orphanIngressSessionOptions(sessionOptions, onReconnectEvent), false, + healthTracker, ), }); } diff --git a/test/qwp/reconnect.test.ts b/test/qwp/reconnect.test.ts index 1cdc9d3..89e4a35 100644 --- a/test/qwp/reconnect.test.ts +++ b/test/qwp/reconnect.test.ts @@ -72,7 +72,10 @@ import { } from "../../src/qwp"; import { QwpAsyncQueue } from "../../src/qwp/internal/async-queue"; import { createQwpEgressFailoverConnectionFactory } from "../../src/qwp/internal/egress-routing"; -import { createQwpFailoverConnectionFactory } from "../../src/qwp/internal/failover"; +import { + createQwpFailoverConnectionFactory, + createQwpFailoverHealthTracker, +} from "../../src/qwp/internal/failover"; function nativeFlock(fd: number, operation: "exnb" | "un"): Promise { return new Promise((resolve, reject) => { @@ -302,6 +305,90 @@ class FailingDictionaryPersistenceReplayStore extends TrackingReplayStore { } describe("QWP endpoint failover", () => { + it("shares live health without sharing concurrent sweep cursors", async () => { + const tracker = createQwpFailoverHealthTracker("primary", ["secondary"]); + const attempts: string[] = []; + const createFactory = (walker: string) => + createQwpFailoverConnectionFactory( + "primary", + ["secondary"], + async (endpoint) => { + attempts.push(`${walker}:${endpoint}`); + return new FakeConnection(String(endpoint)); + }, + { healthTracker: tracker }, + ); + const first = createFactory("first"); + const second = createFactory("second"); + + await Promise.all([first(), second()]); + expect(attempts).toEqual(["first:primary", "second:primary"]); + + const primary = await first(); + primary.deprioritizeEndpoint!(); + const sharedObservation = await second(); + expect(sharedObservation.endpoint).toBe("secondary"); + }); + + it("keeps only the newest same-zone success sticky across resets", () => { + const tracker = createQwpFailoverHealthTracker( + "older-local", + ["newer-local", "remote"], + { target: "replica", zone: "zone-a" }, + ); + tracker.recordZone(0, "zone-a"); + tracker.recordSuccess(0); + tracker.recordZone(1, "zone-a"); + tracker.recordSuccess(1); + tracker.recordZone(2, "zone-b"); + tracker.recordSuccess(2); + + tracker.forgetClassifications(); + const cursor = tracker.newRoundCursor(); + expect([ + cursor.next(), + cursor.next(), + cursor.next(), + cursor.next(), + ]).toEqual([1, 0, 2, undefined]); + }); + + it("lets background walkers retain shared classifications across sweeps", async () => { + const run = async (resetClassificationsAfterExhaustion: boolean) => { + const attempts: string[] = []; + const factory = createQwpFailoverConnectionFactory( + "topology-reject", + ["transport-error"], + async (endpoint) => { + attempts.push(String(endpoint)); + if (endpoint === "topology-reject") { + throw new QwpUpgradeError("wrong role", { + kind: QWP_UPGRADE_ERROR_KIND.ROLE_REJECTED, + retryable: true, + tryNextEndpoint: true, + serverRole: "REPLICA", + }); + } + throw new Error("unreachable"); + }, + { resetClassificationsAfterExhaustion }, + ); + await expect(factory()).rejects.toBeDefined(); + attempts.length = 0; + await expect(factory()).rejects.toBeDefined(); + return attempts; + }; + + await expect(run(true)).resolves.toEqual([ + "topology-reject", + "transport-error", + ]); + await expect(run(false)).resolves.toEqual([ + "transport-error", + "topology-reject", + ]); + }); + it("keeps a healthy endpoint sticky until a mid-stream failure", async () => { const attempts: string[] = []; let primaryAvailable = false; From 43debcf7b685189c60def3126b93bb48fd80abd3 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 19 Aug 2026 17:44:47 +0100 Subject: [PATCH 081/265] feat(qwp): unify Sender WebSocket configuration --- QWP.md | 11 ++- README.md | 5 ++ src/sender.ts | 85 +++++++++++++++++++++++- test/qwp/sender-node-integration.test.ts | 44 +++++++++++- 4 files changed, 138 insertions(+), 7 deletions(-) diff --git a/QWP.md b/QWP.md index aa86908..3ff8351 100644 --- a/QWP.md +++ b/QWP.md @@ -59,6 +59,12 @@ try { `username` plus `password` selects HTTP Basic authentication for the WebSocket upgrade. `token` selects Bearer authentication. Use `wss::` in production. +`Sender.fromConfig()` uses the same Java-compatible `ws::`/`wss::` vocabulary +as `connectQwpNodeClient()`. Comma-separated or repeated `addr` values configure +ordered failover endpoints, and ingress, egress, pool, and reserved policy keys +are validated from one schema. The standalone sender applies ingress-owned keys; +keys owned only by egress or the pooled facade are accepted as intentional no-ops. + ### Node.js fire-and-forget UDP `udp::` selects Node-only QWP v1 over IPv4 UDP while retaining the fluent row API: @@ -1091,8 +1097,9 @@ Review these behavioral differences before rollout: - Browser and Node QWP ingress reconnect by default with in-memory, at-least-once replay. That queue has a 128 MiB cap and a bounded 30-second capacity wait by default. Configure Node store-and-forward when replay must survive process failure. -- Existing HTTP, TCP, and TLS options do not automatically apply to QWP; put QWP-only - connection and session controls under `extraOptions.qwp`. +- HTTP/TCP-only keys do not carry over to `ws::`; use the unified QWP connect-string + vocabulary. Programmatic callbacks, custom agents, and other non-string hooks remain + available under `extraOptions.qwp`. Roll out `ws::` per sender instance so the existing protocols can remain in service during migration. diff --git a/README.md b/README.md index 854e3fe..cfd708d 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,11 @@ await sender.flush(); await sender.close(); ``` +The regular `Sender` accepts the same unified QWP configuration vocabulary as +the pooled Node client. Use comma-separated or repeated `addr` values for +failover; standalone ingress validates but otherwise ignores egress- and +pool-only keys. + Node.js also supports fire-and-forget QWP-over-UDP through the same API: ```typescript diff --git a/src/sender.ts b/src/sender.ts index fc4d8b6..fcfdb19 100644 --- a/src/sender.ts +++ b/src/sender.ts @@ -2,6 +2,7 @@ import { readFileSync } from "node:fs"; import * as http from "node:http"; import * as https from "node:https"; +import { Agent as UndiciAgent } from "undici"; import { log, Logger } from "./logging"; import { SenderOptions, ExtraOptions, UDP, WS, WSS } from "./options"; import { SenderTransport, createTransport } from "./transport"; @@ -13,8 +14,14 @@ import { createQwpNodeUdpSender, QwpSender, } from "./qwp/node"; +import { resolveQwpNodeClientConfig } from "./qwp-node/client-config"; const DEFAULT_AUTO_FLUSH_INTERVAL = 1000; // 1 sec +const RESOLVED_QWP_SENDER = Symbol("resolvedQwpSender"); + +type ResolvedQwpSenderOptions = SenderOptions & { + readonly [RESOLVED_QWP_SENDER]: QwpSender; +}; /** * The QuestDB client's API provides methods to connect to the database, ingest data, and close the connection.
    @@ -119,6 +126,17 @@ class Sender { */ constructor(options: SenderOptions) { this.log = options && typeof options.log === "function" ? options.log : log; + const resolvedQwpSender = options + ? (options as Partial)[RESOLVED_QWP_SENDER] + : undefined; + if (resolvedQwpSender) { + this.qwpSender = resolvedQwpSender; + this.autoFlush = false; + this.autoFlushRows = 0; + this.autoFlushInterval = 0; + this.resetAutoFlush(); + return; + } if ( options?.protocol === WS || options?.protocol === WSS || @@ -164,6 +182,9 @@ class Sender { configurationString: string, extraOptions?: ExtraOptions, ): Promise { + if (isQwpWebSocketConfiguration(configurationString)) { + return createConfiguredQwpSenderFacade(configurationString, extraOptions); + } return new Sender( await SenderOptions.fromConfig(configurationString, extraOptions), ); @@ -181,9 +202,7 @@ class Sender { * @return {Sender} A Sender object initialized from the QDB_CLIENT_CONF environment variable. */ static async fromEnv(extraOptions?: ExtraOptions): Promise { - return new Sender( - await SenderOptions.fromConfig(process.env.QDB_CLIENT_CONF, extraOptions), - ); + return Sender.fromConfig(process.env.QDB_CLIENT_CONF, extraOptions); } /** @@ -545,6 +564,66 @@ class Sender { } } +function isQwpWebSocketConfiguration(configurationString: string): boolean { + const separator = configurationString?.indexOf("::") ?? -1; + if (separator < 0) return false; + const schema = configurationString.slice(0, separator); + return schema === WS || schema === WSS; +} + +function createConfiguredQwpSenderFacade( + configurationString: string, + extraOptions: ExtraOptions | undefined, +): Sender { + validateQwpExtraOptions(extraOptions); + const logger = extraOptions?.log ?? log; + const configuredWebSocket = extraOptions?.qwp?.webSocket; + const { storeAndForward, senderId, failoverUrls, ...webSocketOverrides } = + configuredWebSocket ?? {}; + let agent = webSocketOverrides.agent; + if (!agent && extraOptions?.agent instanceof http.Agent) { + agent = extraOptions.agent; + } + const resolved = resolveQwpNodeClientConfig(configurationString, { + webSocket: { ...webSocketOverrides, agent }, + storeAndForward, + sender: { ...extraOptions?.qwp?.sender, log: logger }, + ingressSession: extraOptions?.qwp?.session, + }); + const qwpSender = createQwpNodeSender( + { + ...resolved.ingress, + failoverUrls: failoverUrls ?? resolved.ingress.failoverUrls, + senderId: senderId ?? resolved.ingress.senderId, + }, + resolved.sender, + resolved.ingressSession, + ); + const separator = configurationString.indexOf("::"); + const options = { + protocol: configurationString.slice(0, separator), + log: logger, + [RESOLVED_QWP_SENDER]: qwpSender, + } as ResolvedQwpSenderOptions; + return new Sender(options); +} + +function validateQwpExtraOptions(extraOptions: ExtraOptions | undefined): void { + if (extraOptions?.log && typeof extraOptions.log !== "function") { + throw new Error("Invalid logging function"); + } + const agent = extraOptions?.agent; + if ( + agent && + !(agent instanceof UndiciAgent) && + !(agent instanceof http.Agent) && + // @ts-expect-error TypeScript narrows the Agent union too aggressively. + !(agent instanceof https.Agent) + ) { + throw new Error("Invalid HTTP agent"); + } +} + function createConfiguredQwpSender( options: SenderOptions, logger: Logger, diff --git a/test/qwp/sender-node-integration.test.ts b/test/qwp/sender-node-integration.test.ts index c21ffc1..3019329 100644 --- a/test/qwp/sender-node-integration.test.ts +++ b/test/qwp/sender-node-integration.test.ts @@ -48,12 +48,11 @@ describe("Sender QWP integration", () => { ); const directory = await mkdtemp(join(tmpdir(), "qwp-sender-startup-")); const sender = await Sender.fromConfig( - `ws::addr=127.0.0.1:${port};initial_connect_retry=off`, + `ws::addr=127.0.0.1:${port};sf_dir=${directory};initial_connect_retry=off`, { qwp: { webSocket: { connectTimeoutMs: 100, - storeAndForward: { directory }, }, session: { reconnect: { @@ -138,6 +137,47 @@ describe("Sender QWP integration", () => { ).toBe(QWP_MAGIC); }); + it("uses the unified cluster vocabulary and fails over between addr entries", async () => { + let authorization: string | undefined; + let clientId: string | undefined; + let requestPath: string | undefined; + server = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + server.on("headers", (headers) => { + headers.push("X-QWP-Version: 1"); + headers.push("X-QWP-Max-Batch-Size: 1048576"); + }); + server.on("connection", (_socket, request) => { + authorization = request.headers.authorization; + clientId = request.headers["x-qwp-client-id"] as string | undefined; + requestPath = request.url; + }); + await new Promise((resolve, reject) => { + server!.once("listening", resolve); + server!.once("error", reject); + }); + const { port } = server.address() as AddressInfo; + + const sender = await Sender.fromConfig( + "ws::" + + `addr=127.0.0.1:1,127.0.0.1:${port};` + + "user=admin;pass=secret;client_id=sender-config-test;" + + "connect_timeout=250;reconnect_initial_backoff_millis=1;" + + "reconnect_max_backoff_millis=2;reconnect_max_duration_millis=1000;" + + "request_durable_ack=off;target=replica;compression=raw;" + + "sender_pool_min=0;query_pool_min=0;auto_flush=off;", + ); + try { + await sender.connect(); + expect(requestPath).toBe("/write/v4"); + expect(clientId).toBe("sender-config-test"); + expect(authorization).toBe( + `Basic ${Buffer.from("admin:secret", "utf8").toString("base64")}`, + ); + } finally { + await sender.close(); + } + }); + it("honors auto_flush_bytes from the ws:: configuration string", async () => { const frames: Uint8Array[] = []; server = new WebSocketServer({ host: "127.0.0.1", port: 0 }); From 1267de2fb6e1bda6850853964044f18a3af3c533 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 19 Aug 2026 17:56:31 +0100 Subject: [PATCH 082/265] perf(qwp): decode result views ahead --- QWP.md | 19 ++-- README.md | 6 +- src/qwp/core/result-batch.ts | 46 ++++++--- src/qwp/egress-session.ts | 177 ++++++++++++++++++++++++++--------- test/qwp/egress.test.ts | 96 +++++++++++++++++++ 5 files changed, 272 insertions(+), 72 deletions(-) diff --git a/QWP.md b/QWP.md index 3ff8351..bc8dc6f 100644 --- a/QWP.md +++ b/QWP.md @@ -767,15 +767,16 @@ server read-ahead in Node.js and browsers. Set a session-level `initialCredit` t the default, override it per query, or explicitly set zero for legacy unbounded streaming. Set `autoCredit: false` and call `query.grantCredit()` for manual control. -Materialized `query()` results also use a client-side decoded-batch pool with four -slots by default. Set the session-level `bufferPoolSize` to tune this bound. Once the -pool fills, decoding pauses until iteration requests another batch; callers must -consume a multi-batch SELECT before awaiting its terminal `completion`. This bound is -independent of QWP credit, so `initialCredit: 0` no longer permits an unbounded queue -of materialized JavaScript value arrays. Protocol credit remains the stronger -end-to-end bound, particularly in browsers where the WebSocket implementation may -buffer raw frames before JavaScript reads them. `queryViews()` already has a single -reusable decoded batch and does not consume materialized-pool slots. +Both materialized `query()` results and zero-copy `queryViews()` use a client-side +decoded-batch pool with four slots by default. Set the session-level +`bufferPoolSize` to tune this bound. Materialized decoding pauses when all slots are +queued until iteration requests another batch. For `queryViews()`, callbacks remain +serial and callback-scoped, while the receive loop continues decoding into the other +reusable slots; a slow callback stalls decoding only after the pool fills. This bound +is independent of QWP credit, so `initialCredit: 0` no longer permits an unbounded +queue of decoded batches. Protocol credit remains the stronger end-to-end bound, +particularly in browsers where the WebSocket implementation may buffer raw frames +before JavaScript reads them. A session `queryTimeoutMs` supplies the default deadline; per-query `timeoutMs` overrides it, and zero disables it. Expiry rejects iteration and `completion` with diff --git a/README.md b/README.md index cfd708d..3a9321e 100644 --- a/README.md +++ b/README.md @@ -379,8 +379,10 @@ For allocation-sensitive consumers, `session.queryViews(sql, onBatch)` supplies bounded, reusable column views instead of materializing every value into JavaScript arrays. Typed accessors read fixed-width values directly from QWP bytes, and raw byte views are available for vectorized processing. The callback is awaited before -credit is replenished. Views are invalid when it returns; copy a byte view with -`.slice()` or call `batch.materialize()` inside the callback to retain data. +credit is replenished, while the receive loop decodes ahead through the bounded +reusable buffer pool. Views are invalid when their callback returns; copy a byte +view with `.slice()` or call `batch.materialize()` inside the callback to retain +data. Tune the default four-slot pool with the session's `bufferPoolSize`. `queryTimeoutMs` sets the session's default query deadline; a per-query `timeoutMs` overrides it, and zero disables the deadline. When a deadline diff --git a/src/qwp/core/result-batch.ts b/src/qwp/core/result-batch.ts index 5133f69..157652d 100644 --- a/src/qwp/core/result-batch.ts +++ b/src/qwp/core/result-batch.ts @@ -1261,14 +1261,14 @@ interface PreparedResultBatch { /** Stateful decoder for connection-scoped QWP result batches. */ export class QwpResultBatchDecoder { private readonly symbolDictionary: string[] = []; - private readonly viewBatch = new QwpResultBatchView(); - private readonly viewLayouts: QwpResultColumnViewLayout[] = []; - private readonly viewLayoutPool: QwpResultColumnViewLayout[] = []; + private readonly viewBatches: QwpResultBatchView[] = []; + private readonly viewLayouts: QwpResultColumnViewLayout[][] = []; + private readonly viewLayoutPools: QwpResultColumnViewLayout[][] = []; private schema?: QwpResultColumnSchema[]; private expectedBatchSequence = 0n; resetQuerySchema(): void { - this.viewBatch.release(); + for (const batch of this.viewBatches) batch.release(); this.schema = undefined; this.expectedBatchSequence = 0n; } @@ -1279,6 +1279,12 @@ export class QwpResultBatchDecoder { } } + /** @internal Drops frame-backed references after a failed slot decode. */ + releaseView(slot: number): void { + this.viewBatches[slot]?.release(); + for (const layout of this.viewLayoutPools[slot] ?? []) layout.release(); + } + decode(message: QwpResultBatchMessage): QwpResultBatch { const { reader, tableName, rowCount, deltaMode } = this.prepare(message); @@ -1297,31 +1303,41 @@ export class QwpResultBatchDecoder { } /** - * Decodes into one reusable batch/column-view set without materializing a - * JavaScript value array. A subsequent decode invalidates the prior view. + * Decodes into one slot from a reusable batch/column-view pool without + * materializing a JavaScript value array. Reusing the same slot invalidates + * its prior view; callers must not reuse a slot until its consumer releases + * the preceding batch. */ - decodeView(message: QwpResultBatchMessage): QwpResultBatchView { - this.viewBatch.release(); + decodeView(message: QwpResultBatchMessage, slot = 0): QwpResultBatchView { + if (!Number.isSafeInteger(slot) || slot < 0) { + throw new RangeError( + "QWP result view slot must be a non-negative integer", + ); + } + const viewBatch = (this.viewBatches[slot] ??= new QwpResultBatchView()); + const viewLayouts = (this.viewLayouts[slot] ??= []); + const viewLayoutPool = (this.viewLayoutPools[slot] ??= []); + viewBatch.release(); const { reader, tableName, rowCount, deltaMode } = this.prepare(message); const schema = this.schema!; - while (this.viewLayoutPool.length < schema.length) { - this.viewLayoutPool.push(new QwpResultColumnViewLayout()); + while (viewLayoutPool.length < schema.length) { + viewLayoutPool.push(new QwpResultColumnViewLayout()); } - this.viewLayouts.length = schema.length; + viewLayouts.length = schema.length; for (let index = 0; index < schema.length; index++) { - const layout = this.viewLayoutPool[index]; - this.viewLayouts[index] = layout; + const layout = viewLayoutPool[index]; + viewLayouts[index] = layout; layout.reset(schema[index], rowCount); this.readColumnView(reader, layout, deltaMode, message.flags); } reader.expectEnd("RESULT_BATCH"); this.expectedBatchSequence++; - return this.viewBatch.reset( + return viewBatch.reset( message.requestId, message.batchSequence, tableName, rowCount, - this.viewLayouts, + viewLayouts, ); } diff --git a/src/qwp/egress-session.ts b/src/qwp/egress-session.ts index 68b6c60..356b17b 100644 --- a/src/qwp/egress-session.ts +++ b/src/qwp/egress-session.ts @@ -33,7 +33,7 @@ export interface QwpEgressSessionOptions { serverInfoTimeoutMs?: number; /** Default per-query send-ahead credit. Defaults to 256 KiB; zero is unbounded. */ initialCredit?: number | bigint; - /** Maximum decoded materialized batches waiting for a consumer. Defaults to 4. */ + /** Maximum decoded batches waiting for a consumer. Defaults to 4. */ bufferPoolSize?: number; /** Default per-query deadline. Zero or undefined disables query deadlines. */ queryTimeoutMs?: number; @@ -95,7 +95,7 @@ interface QwpReplayableQueryRequest { /** Default bounded send-ahead window used by high-level egress queries. */ export const QWP_DEFAULT_EGRESS_INITIAL_CREDIT = 256 * 1024; -/** Default decoded materialized-result queue depth, matching the Java client. */ +/** Default decoded result-buffer pool depth, matching the Java client. */ export const QWP_DEFAULT_EGRESS_BUFFER_POOL_SIZE = 4; const MAX_UINT64 = 0xffffffffffffffffn; @@ -250,6 +250,10 @@ interface QwpQueuedResultBatch { type QwpBatchReservation = "reserved" | "retired" | "reset"; +type QwpViewBatchReservation = + | { readonly status: "reserved"; readonly slot: number } + | { readonly status: "retired" | "reset" }; + /** Control handle returned by queryViews(). */ export interface QwpEgressViewQuery { readonly requestId: bigint; @@ -276,8 +280,9 @@ export class QwpEgressQuery implements AsyncIterable { private bufferedBatchCount = 0; private bufferGeneration = 0; private readonly bufferWaiters = new Set<() => void>(); - private viewInProgress = false; - private readonly viewReleaseWaiters = new Set<() => void>(); + private readonly availableViewSlots: number[]; + private viewTail: Promise = Promise.resolve(); + private wireComplete = false; private terminal = false; private timeoutTimer?: ReturnType; readonly completion: Promise; @@ -301,6 +306,9 @@ export class QwpEgressQuery implements AsyncIterable { void this.completion.catch(() => undefined); this.resolveCompletion = resolve; this.rejectCompletion = reject; + this.availableViewSlots = viewHandler + ? Array.from({ length: bufferPoolSize }, (_, slot) => slot) + : []; } [Symbol.asyncIterator](): AsyncIterator { @@ -374,37 +382,76 @@ export class QwpEgressQuery implements AsyncIterable { this.releaseBufferedBatches(1); } - /** @internal */ - async pushView( + /** @internal Waits for one reusable zero-copy view slot. */ + async reserveViewBatch(): Promise { + const generation = this.bufferGeneration; + while ( + !this.terminal && + generation === this.bufferGeneration && + this.availableViewSlots.length === 0 + ) { + await new Promise((resolve) => this.bufferWaiters.add(resolve)); + } + if (this.terminal) return { status: "retired" }; + if (generation !== this.bufferGeneration) return { status: "reset" }; + return { status: "reserved", slot: this.availableViewSlots.shift()! }; + } + + /** @internal Queues a decoded view after reserveViewBatch(). */ + pushReservedView( batch: QwpResultBatchView, creditBytes: number, - ): Promise { + slot: number, + ): void { if (this.terminal) { batch.release(); + this.releaseViewSlot(slot); return; } const generation = this.bufferGeneration; - this.viewInProgress = true; - let handlerError: Error | undefined; - try { - await this.viewHandler!(batch, this); - } catch (error) { - handlerError = error instanceof Error ? error : new Error(String(error)); - } finally { - batch.release(); - this.viewInProgress = false; - for (const resolve of this.viewReleaseWaiters) resolve(); - this.viewReleaseWaiters.clear(); - } - if (generation !== this.bufferGeneration) return; - if (handlerError) { - await this.control - .rejectView(this.requestId, handlerError) + this.viewTail = this.viewTail.then(async () => { + if (this.terminal || generation !== this.bufferGeneration) { + batch.release(); + this.releaseViewSlot(slot); + return; + } + let handlerError: Error | undefined; + try { + await this.viewHandler!(batch, this); + } catch (error) { + handlerError = + error instanceof Error ? error : new Error(String(error)); + } finally { + batch.release(); + this.releaseViewSlot(slot); + } + if (generation !== this.bufferGeneration) return; + if (handlerError) { + void this.control + .rejectView(this.requestId, handlerError) + .catch(() => undefined); + return; + } + if ( + !this.autoCredit || + this.terminal || + this.wireComplete || + creditBytes === 0 + ) { + return; + } + // Credit and cancellation sends must not hold a view slot or its drain + // barrier: reconnect resets wait on that barrier before transport sends + // resume. The session send tail preserves wire order and owns failures. + void this.control + .grantCredit(this.requestId, creditBytes) .catch(() => undefined); - return; - } - if (!this.autoCredit || this.terminal || creditBytes === 0) return; - await this.control.grantCredit(this.requestId, creditBytes); + }); + } + + /** @internal Releases a reservation when zero-copy decoding fails. */ + releaseViewBatch(slot: number): void { + this.releaseViewSlot(slot); } /** @internal */ @@ -413,7 +460,9 @@ export class QwpEgressQuery implements AsyncIterable { } /** @internal */ - finish(completion: QwpQueryCompletion): void { + async finish(completion: QwpQueryCompletion): Promise { + this.wireComplete = true; + if (this.viewHandler) await this.viewTail; if (this.terminal) return; this.terminal = true; this.wakeBufferWaiters(); @@ -423,6 +472,14 @@ export class QwpEgressQuery implements AsyncIterable { this.resolveCompletion(completion); } + /** @internal Preserves batch/callback order before a wire query error. */ + async finishError(error: Error): Promise { + this.wireComplete = true; + if (this.viewHandler) await this.viewTail; + if (this.terminal) return; + this.fail(error); + } + /** @internal */ fail(error: unknown): void { if (this.terminal) return; @@ -456,13 +513,15 @@ export class QwpEgressQuery implements AsyncIterable { async resetForReplay(): Promise { this.deliveredCreditBytes = 0; this.bufferGeneration++; + this.wireComplete = false; this.releaseBufferedBatches(this.batches.clear().length); this.wakeBufferWaiters(); - if (this.viewInProgress) { - await new Promise((resolve) => - this.viewReleaseWaiters.add(resolve), - ); - } + await this.viewTail; + } + + /** @internal Waits until all callback-scoped views have been released. */ + waitForViewDrain(): Promise { + return this.viewTail; } private clearTimeout(): void { @@ -494,6 +553,11 @@ export class QwpEgressQuery implements AsyncIterable { this.bufferWaiters.clear(); } + private releaseViewSlot(slot: number): void { + this.availableViewSlots.push(slot); + this.wakeBufferWaiters(); + } + private async releaseDeliveredCredit(): Promise { const creditBytes = this.deliveredCreditBytes; this.deliveredCreditBytes = 0; @@ -694,8 +758,9 @@ export class QwpEgressSession implements QwpEgressQueryControl { /** * Executes a query through a bounded, reusable, zero-copy batch callback. - * The callback is awaited before its batch is invalidated and flow-control - * credit is replenished. + * Callbacks run serially and are awaited before their batch is invalidated + * and flow-control credit is replenished. The receive loop decodes ahead + * into the remaining reusable slots, up to bufferPoolSize. */ async queryViews( sql: string, @@ -878,7 +943,8 @@ export class QwpEgressSession implements QwpEgressQueryControl { this.clearCancelDrain(); const error = new QwpEgressSessionClosedError(); this.rejectServerInfo(error); - this.active?.fail(error); + const active = this.active; + active?.fail(error); this.clearActive(); let transportClose: Promise; try { @@ -890,6 +956,7 @@ export class QwpEgressSession implements QwpEgressQueryControl { this.sendTail, transportClose, this.receiveLoop, + active?.waitForViewDrain() ?? Promise.resolve(), ]); if (closeResult.status === "rejected") throw closeResult.reason; } @@ -924,10 +991,28 @@ export class QwpEgressSession implements QwpEgressQueryControl { ).catch(() => undefined); } } else if (query.usesViews) { - await query.pushView( - this.decoder.decodeView(message), - payload.byteLength, - ); + const reservation = await query.reserveViewBatch(); + if (reservation.status === "retired") { + const creditBytes = query.lateBatchCredit(payload.byteLength); + if (creditBytes > 0) { + void this.sendWhileActive( + message.requestId, + encodeQwpCredit(message.requestId, creditBytes), + ).catch(() => undefined); + } + } else if (reservation.status === "reserved") { + try { + query.pushReservedView( + this.decoder.decodeView(message, reservation.slot), + payload.byteLength, + reservation.slot, + ); + } catch (error) { + this.decoder.releaseView(reservation.slot); + query.releaseViewBatch(reservation.slot); + throw error; + } + } } else { const reservation = await query.reserveMaterializedBatch(); if (reservation === "retired") { @@ -954,29 +1039,29 @@ export class QwpEgressSession implements QwpEgressQueryControl { } case "result-end": { const query = this.requireActive(message.requestId); - this.clearActive(query); this.clearCancelDrain(message.requestId); - query.finish(message); + await query.finish(message); + this.clearActive(query); break; } case "exec-done": { const query = this.requireActive(message.requestId); - this.clearActive(query); this.clearCancelDrain(message.requestId); - query.finish(message); + await query.finish(message); + this.clearActive(query); break; } case "query-error": { const query = this.requireActive(message.requestId); - this.clearActive(query); this.clearCancelDrain(message.requestId); - query.fail( + await query.finishError( new QwpEgressQueryError( message.requestId, message.status, message.message, ), ); + this.clearActive(query); break; } } diff --git a/test/qwp/egress.test.ts b/test/qwp/egress.test.ts index fdf484c..82b2513 100644 --- a/test/qwp/egress.test.ts +++ b/test/qwp/egress.test.ts @@ -864,6 +864,59 @@ describe("QwpEgressSession", () => { await session.close(); }); + it("decodes reusable views ahead through a bounded slot pool", async () => { + const decodeView = vi.spyOn(QwpResultBatchDecoder.prototype, "decodeView"); + const connection = new FakeConnection(); + const session = new QwpEgressSession(connection, { + initialCredit: 0, + bufferPoolSize: 2, + }); + connection.receive(serverInfo()); + + const entered: number[] = []; + const releases: Array<() => void> = []; + const delivered: QwpResultBatchView[] = []; + try { + const query = await session.queryViews( + "select * from x", + async (batch) => { + const sequence = Number(batch.batchSequence); + delivered.push(batch); + entered.push(sequence); + await new Promise((resolve) => { + releases[sequence] = resolve; + }); + }, + ); + + connection.receive(emptyResultBatch(query.requestId, 0)); + connection.receive(emptyResultBatch(query.requestId, 1)); + connection.receive(emptyResultBatch(query.requestId, 2)); + connection.receive(resultEnd(query.requestId, 0n)); + + await vi.waitFor(() => expect(decodeView).toHaveBeenCalledTimes(2)); + expect(entered).toEqual([0]); + await Promise.resolve(); + expect(decodeView).toHaveBeenCalledTimes(2); + + releases[0](); + await vi.waitFor(() => { + expect(decodeView).toHaveBeenCalledTimes(3); + expect(entered).toEqual([0, 1]); + }); + + releases[1](); + await vi.waitFor(() => expect(entered).toEqual([0, 1, 2])); + expect(new Set(delivered).size).toBe(2); + + releases[2](); + await expect(query.completion).resolves.toMatchObject({ totalRows: 0n }); + } finally { + decodeView.mockRestore(); + await session.close(); + } + }); + it("cancels and drains when a result-view callback fails", async () => { const connection = new FakeConnection(); const session = new QwpEgressSession(connection); @@ -894,6 +947,49 @@ describe("QwpEgressSession", () => { await session.close(); }); + it("keeps a query error ordered after an active result-view callback", async () => { + const connection = new FakeConnection(); + const session = new QwpEgressSession(connection); + connection.receive(serverInfo()); + + let releaseHandler!: () => void; + const handlerReleased = new Promise((resolve) => { + releaseHandler = resolve; + }); + let delivered: QwpResultBatchView | undefined; + let enterHandler!: () => void; + const handlerEntered = new Promise((resolve) => { + enterHandler = resolve; + }); + const query = await session.queryViews("select * from x", async (batch) => { + delivered = batch; + enterHandler(); + await handlerReleased; + expect(batch.valid).toBe(true); + }); + + connection.receive(firstResultBatch(query.requestId)); + connection.receive(queryError(query.requestId, "query failed")); + await handlerEntered; + + await expect(session.query("select 2")).rejects.toThrow( + "a QWP query is already active", + ); + expect(delivered!.valid).toBe(true); + + releaseHandler(); + await expect(query.completion).rejects.toMatchObject({ + name: "QwpEgressQueryError", + message: "query failed", + }); + expect(delivered!.valid).toBe(false); + + const next = await session.query("select 2"); + connection.receive(resultEnd(next.requestId, 0n)); + await next.completion; + await session.close(); + }); + it("uses bounded credit by default and allows a session-level override", async () => { const connection = new FakeConnection(); const session = new QwpEgressSession(connection); From a39865d7a032380facdbd3de0a9352632fca309a Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 19 Aug 2026 18:06:09 +0100 Subject: [PATCH 083/265] fix(qwp): align egress flow control defaults --- QWP.md | 18 ++++-- README.md | 15 +++-- src/qwp/browser.ts | 9 ++- src/qwp/egress-session.ts | 61 +++++++++++++++++-- src/qwp/node.ts | 9 ++- test/qwp/egress.test.ts | 103 +++++++++++++++++++++++++------- test/qwp/public-api-contract.ts | 4 ++ test/qwp/public-api.test.ts | 1 + 8 files changed, 180 insertions(+), 40 deletions(-) diff --git a/QWP.md b/QWP.md index bc8dc6f..bb0a780 100644 --- a/QWP.md +++ b/QWP.md @@ -760,12 +760,11 @@ flag only when `SERVER_INFO` advertises `QUERY_FLAGS`; older servers receive the same flag-free request as the default path, so this option remains safe during a rolling upgrade. -The high-level client defaults `initialCredit` to 256 KiB, bounding unread wire data -to roughly that window plus at most one server batch. The exact wire size of each -batch is replenished when iteration advances beyond it, so a slow consumer limits -server read-ahead in Node.js and browsers. Set a session-level `initialCredit` to tune -the default, override it per query, or explicitly set zero for legacy unbounded -streaming. Set `autoCredit: false` and call `query.grantCredit()` for manual control. +Matching Java, the high-level client defaults `initialCredit` to zero, allowing +unbounded server send-ahead. Set a positive session-level or per-query value to bound +wire buffering, particularly in browsers. With positive credit, the exact wire size +of each consumed batch is replenished automatically. Set `autoCredit: false` and call +`query.grantCredit()` for manual control. Both materialized `query()` results and zero-copy `queryViews()` use a client-side decoded-batch pool with four slots by default. Set the session-level @@ -786,6 +785,13 @@ discards buffered batches, restores their flow-control credit, sends `CANCEL`, a rejects `completion` with `QwpEgressQueryAbandonedError`. Call `query.cancel()` for explicit cancellation. +`await query.awaitCompletion(timeoutMs)` bounds only the caller's wait and returns +`false` without cancelling when the timeout expires, matching Java +`Completion.await(timeout, unit)`. `query.isDone()` reports terminal state. Use the +query deadline options only when timeout should actively cancel the server query. +The initial and reconnect `SERVER_INFO` timeout defaults to five seconds, matching +Java, and remains configurable through `serverInfoTimeoutMs`. + Cancellation draining is bounded by `cancelDrainTimeoutMs` (5 seconds by default). Late batches are decoded and credited while the terminal response is pending. If the server does not terminate the query within the bound, the client fails with diff --git a/README.md b/README.md index 3a9321e..b910b27 100644 --- a/README.md +++ b/README.md @@ -368,12 +368,11 @@ Both `"zstd"` and `"auto"` advertise Zstd followed by raw fallback, and the server still sends an individual batch raw when compression would make it larger. -Egress queries use a bounded 256 KiB `initialCredit` window by default. The client -automatically replenishes the exact wire size of each result batch after the async -iterator advances past it, so a slow Node.js or browser consumer limits how far the -server can stream ahead. Tune `initialCredit` on the session or individual query; -set it to zero only to opt into legacy unbounded streaming. Set `autoCredit: false` -to manage credit explicitly through `query.grantCredit()`. +Matching the Java client, egress queries default `initialCredit` to zero, meaning +unbounded server send-ahead. Set a positive session or per-query value to bound wire +buffering—particularly in browsers. With positive credit, the client automatically +replenishes the exact wire size of each result batch after consumption. Set +`autoCredit: false` to manage credit explicitly through `query.grantCredit()`. For allocation-sensitive consumers, `session.queryViews(sql, onBatch)` supplies bounded, reusable column views instead of materializing every value into JavaScript @@ -392,6 +391,10 @@ server response before accepting another query on that connection. Breaking out of `for await` early cancels the query too. `cancelDrainTimeoutMs` bounds that wait (5 seconds by default); an unresponsive cancellation closes the connection with `QwpEgressQueryCancelTimeoutError` instead of wedging the session. +To bound only the caller's wait without cancelling, use +`await query.awaitCompletion(timeoutMs)`. It returns `false` on timeout and leaves +the query active, matching Java `Completion.await(timeout, unit)`. The SERVER_INFO +handshake timeout defaults to five seconds on both clients. ### Authentication and secure connection diff --git a/src/qwp/browser.ts b/src/qwp/browser.ts index 058f50f..72abd36 100644 --- a/src/qwp/browser.ts +++ b/src/qwp/browser.ts @@ -26,7 +26,11 @@ import { QwpUpgradeError, QwpWebSocketConnectOptions, } from "./transport"; -import { QwpEgressSession, QwpEgressSessionOptions } from "./egress-session"; +import { + QWP_DEFAULT_EGRESS_SERVER_INFO_TIMEOUT_MS, + QwpEgressSession, + QwpEgressSessionOptions, +} from "./egress-session"; import { QwpIngressSession, QwpIngressSessionOptions } from "./ingress-session"; import { QwpSender, QwpSenderOptions } from "./sender"; import { QwpClient, QwpClientPoolOptions } from "./client"; @@ -700,7 +704,8 @@ export async function connectQwpBrowserEgress( options.failoverUrls, (endpoint) => connectQwpBrowserEgressEndpoint(options, endpoint), { target: options.target, zone: options.zone }, - sessionOptions.serverInfoTimeoutMs ?? 15_000, + sessionOptions.serverInfoTimeoutMs ?? + QWP_DEFAULT_EGRESS_SERVER_INFO_TIMEOUT_MS, ), sessionOptions, ); diff --git a/src/qwp/egress-session.ts b/src/qwp/egress-session.ts index 356b17b..228525b 100644 --- a/src/qwp/egress-session.ts +++ b/src/qwp/egress-session.ts @@ -30,8 +30,9 @@ import { } from "./transport"; export interface QwpEgressSessionOptions { + /** SERVER_INFO handshake deadline. Defaults to 5 seconds. */ serverInfoTimeoutMs?: number; - /** Default per-query send-ahead credit. Defaults to 256 KiB; zero is unbounded. */ + /** Default per-query send-ahead credit. Defaults to zero (unbounded). */ initialCredit?: number | bigint; /** Maximum decoded batches waiting for a consumer. Defaults to 4. */ bufferPoolSize?: number; @@ -93,8 +94,10 @@ interface QwpReplayableQueryRequest { readonly resetDictionary: boolean; } -/** Default bounded send-ahead window used by high-level egress queries. */ -export const QWP_DEFAULT_EGRESS_INITIAL_CREDIT = 256 * 1024; +/** Default send-ahead credit used by Java and TypeScript: zero is unbounded. */ +export const QWP_DEFAULT_EGRESS_INITIAL_CREDIT = 0; +/** Default wait for the initial or reconnected SERVER_INFO frame. */ +export const QWP_DEFAULT_EGRESS_SERVER_INFO_TIMEOUT_MS = 5_000; /** Default decoded result-buffer pool depth, matching the Java client. */ export const QWP_DEFAULT_EGRESS_BUFFER_POOL_SIZE = 4; @@ -120,7 +123,8 @@ function validateOptionalTimeout( function validateEgressSessionOptions( options: QwpEgressSessionOptions, ): QwpValidatedEgressSessionOptions { - const serverInfoTimeoutMs = options.serverInfoTimeoutMs ?? 15_000; + const serverInfoTimeoutMs = + options.serverInfoTimeoutMs ?? QWP_DEFAULT_EGRESS_SERVER_INFO_TIMEOUT_MS; if (!Number.isFinite(serverInfoTimeoutMs) || serverInfoTimeoutMs <= 0) { throw new RangeError( "serverInfoTimeoutMs must be a positive finite number", @@ -254,12 +258,20 @@ type QwpViewBatchReservation = | { readonly status: "reserved"; readonly slot: number } | { readonly status: "retired" | "reset" }; +interface QwpCompletionWaiter { + readonly resolve: () => void; + readonly reject: (error: unknown) => void; +} + /** Control handle returned by queryViews(). */ export interface QwpEgressViewQuery { readonly requestId: bigint; readonly completion: Promise; + /** Waits without cancelling; false means only this wait timed out. */ + awaitCompletion(timeoutMs: number): Promise; cancel(): Promise; grantCredit(additionalBytes: number | bigint): Promise; + isDone(): boolean; } /** @@ -276,6 +288,7 @@ export class QwpEgressQuery implements AsyncIterable { private readonly batches = new QwpAsyncQueue(); private readonly resolveCompletion: (value: QwpQueryCompletion) => void; private readonly rejectCompletion: (error: unknown) => void; + private readonly completionWaiters = new Set(); private deliveredCreditBytes = 0; private bufferedBatchCount = 0; private bufferGeneration = 0; @@ -343,6 +356,42 @@ export class QwpEgressQuery implements AsyncIterable { return this.control.grantCredit(this.requestId, additionalBytes); } + /** + * Waits for completion without changing the query lifecycle. A finite wait + * returns false on expiry; the query remains active until it completes, is + * cancelled explicitly, or its configured query deadline expires. + */ + async awaitCompletion(timeoutMs: number): Promise { + const timeout = validateOptionalTimeout(timeoutMs, "completion timeoutMs"); + if (this.terminal) { + await this.completion; + return true; + } + if (timeout === 0) return false; + return new Promise((resolve, reject) => { + const waiter: QwpCompletionWaiter = { + resolve: () => { + clearTimeout(timer); + resolve(true); + }, + reject: (error) => { + clearTimeout(timer); + reject(error); + }, + }; + const timer = setTimeout(() => { + this.completionWaiters.delete(waiter); + resolve(false); + }, timeout); + this.completionWaiters.add(waiter); + }); + } + + /** Whether the query has reached any terminal outcome. */ + isDone(): boolean { + return this.terminal; + } + /** @internal Starts the deadline after QUERY_REQUEST reaches the transport. */ armTimeout(timeoutMs: number): void { if (timeoutMs === 0 || this.terminal) return; @@ -470,6 +519,8 @@ export class QwpEgressQuery implements AsyncIterable { this.deliveredCreditBytes = 0; this.batches.end(); this.resolveCompletion(completion); + for (const waiter of this.completionWaiters) waiter.resolve(); + this.completionWaiters.clear(); } /** @internal Preserves batch/callback order before a wire query error. */ @@ -489,6 +540,8 @@ export class QwpEgressQuery implements AsyncIterable { this.deliveredCreditBytes = 0; this.batches.fail(error); this.rejectCompletion(error); + for (const waiter of this.completionWaiters) waiter.reject(error); + this.completionWaiters.clear(); } /** @internal Discards queued results and retires the consumer immediately. */ diff --git a/src/qwp/node.ts b/src/qwp/node.ts index 7acda9a..15454f3 100644 --- a/src/qwp/node.ts +++ b/src/qwp/node.ts @@ -39,7 +39,11 @@ import { QwpUpgradeError, QwpWebSocketConnectOptions, } from "./transport"; -import { QwpEgressSession, QwpEgressSessionOptions } from "./egress-session"; +import { + QWP_DEFAULT_EGRESS_SERVER_INFO_TIMEOUT_MS, + QwpEgressSession, + QwpEgressSessionOptions, +} from "./egress-session"; import { QwpIngressSession, QwpIngressSessionOptions } from "./ingress-session"; import { createQwpDataLossSenderError, @@ -791,7 +795,8 @@ export async function connectQwpNodeEgress( transport.failoverUrls, (endpoint) => connectQwpNodeEndpoint(transport, endpoint), { target: options.target, zone: options.zone }, - sessionOptions.serverInfoTimeoutMs ?? 15_000, + sessionOptions.serverInfoTimeoutMs ?? + QWP_DEFAULT_EGRESS_SERVER_INFO_TIMEOUT_MS, ), sessionOptions, ); diff --git a/test/qwp/egress.test.ts b/test/qwp/egress.test.ts index 82b2513..1aa692e 100644 --- a/test/qwp/egress.test.ts +++ b/test/qwp/egress.test.ts @@ -10,6 +10,7 @@ import { QWP_FLAG_GORILLA, QWP_FLAG_ZSTD, QWP_DEFAULT_EGRESS_INITIAL_CREDIT, + QWP_DEFAULT_EGRESS_SERVER_INFO_TIMEOUT_MS, QWP_MAX_ZSTD_DECOMPRESSED_SIZE, QWP_QUERY_FLAG_RESET_DICTIONARY, QWP_STATUS, @@ -703,6 +704,28 @@ describe("QwpEgressSession", () => { } }); + it("uses the Java-compatible SERVER_INFO timeout by default", async () => { + vi.useFakeTimers(); + try { + const connection = new FakeConnection(); + const session = new QwpEgressSession(connection); + const ready = session.ready.catch((error: unknown) => error); + + expect(QWP_DEFAULT_EGRESS_SERVER_INFO_TIMEOUT_MS).toBe(5_000); + await vi.advanceTimersByTimeAsync(4_999); + expect(connection.closeCalls).toEqual([]); + await vi.advanceTimersByTimeAsync(1); + await expect(ready).resolves.toMatchObject({ + message: "timed out waiting for QWP SERVER_INFO", + }); + expect(connection.closeCalls).toEqual([ + { code: 1002, reason: "missing QWP SERVER_INFO" }, + ]); + } finally { + vi.useRealTimers(); + } + }); + it("close interrupts an egress request whose send has not settled", async () => { const connection = new FakeConnection(); const session = new QwpEgressSession(connection); @@ -990,7 +1013,7 @@ describe("QwpEgressSession", () => { await session.close(); }); - it("uses bounded credit by default and allows a session-level override", async () => { + it("defaults to Java-compatible unbounded credit and allows a bounded override", async () => { const connection = new FakeConnection(); const session = new QwpEgressSession(connection); connection.receive(serverInfo()); @@ -1003,37 +1026,35 @@ describe("QwpEgressSession", () => { expect(readQwpVarint(request)).toBe( BigInt(QWP_DEFAULT_EGRESS_INITIAL_CREDIT), ); + expect(QWP_DEFAULT_EGRESS_INITIAL_CREDIT).toBe(0); const resultFrame = firstResultBatch(query.requestId); connection.receive(resultFrame); const iterator = query[Symbol.asyncIterator](); await iterator.next(); const next = iterator.next(); - await vi.waitFor(() => expect(connection.sent).toHaveLength(2)); - const credit = new QwpByteReader(connection.sent[1]); - expect(credit.readUint8()).toBe(QWP_EGRESS_MESSAGE.CREDIT); - expect(credit.readBigUint64()).toBe(query.requestId); - expect(readQwpVarint(credit)).toBe(BigInt(resultFrame.byteLength)); + await Promise.resolve(); + expect(connection.sent).toHaveLength(1); connection.receive(resultEnd(query.requestId)); await next; await query.completion; await session.close(); - const unboundedConnection = new FakeConnection(); - const unbounded = new QwpEgressSession(unboundedConnection, { - initialCredit: 0, + const boundedConnection = new FakeConnection(); + const bounded = new QwpEgressSession(boundedConnection, { + initialCredit: 64, }); - unboundedConnection.receive(serverInfo()); - const unboundedQuery = await unbounded.query("select 1"); - const unboundedRequest = new QwpByteReader(unboundedConnection.sent[0]); - unboundedRequest.readUint8(); - unboundedRequest.readBigUint64(); - const unboundedSqlLength = Number(readQwpVarint(unboundedRequest)); - unboundedRequest.readBytes(unboundedSqlLength); - expect(readQwpVarint(unboundedRequest)).toBe(0n); - unboundedConnection.receive(resultEnd(unboundedQuery.requestId)); - await unboundedQuery.completion; - await unbounded.close(); + boundedConnection.receive(serverInfo()); + const boundedQuery = await bounded.query("select 1"); + const boundedRequest = new QwpByteReader(boundedConnection.sent[0]); + boundedRequest.readUint8(); + boundedRequest.readBigUint64(); + const boundedSqlLength = Number(readQwpVarint(boundedRequest)); + boundedRequest.readBytes(boundedSqlLength); + expect(readQwpVarint(boundedRequest)).toBe(64n); + boundedConnection.receive(resultEnd(boundedQuery.requestId)); + await boundedQuery.completion; + await bounded.close(); }); it("bounds decoded materialized batches when wire credit is unbounded", async () => { @@ -1178,6 +1199,48 @@ describe("QwpEgressSession", () => { await session.close(); }); + it("bounds completion waiting without cancelling the query", async () => { + vi.useFakeTimers(); + try { + const connection = new FakeConnection(); + const session = new QwpEgressSession(connection); + connection.receive(serverInfo()); + const query = await session.query("select * from slow_table"); + + expect(query.isDone()).toBe(false); + await expect(query.awaitCompletion(0)).resolves.toBe(false); + const waiting = query.awaitCompletion(25); + await vi.advanceTimersByTimeAsync(25); + await expect(waiting).resolves.toBe(false); + expect(query.isDone()).toBe(false); + expect(connection.sent).toHaveLength(1); + await expect(session.query("select 2")).rejects.toThrow( + "a QWP query is already active", + ); + + connection.receive(resultEnd(query.requestId, 0n)); + await query.completion; + expect(query.isDone()).toBe(true); + await expect(query.awaitCompletion(0)).resolves.toBe(true); + expect(connection.sent).toHaveLength(1); + await expect(query.awaitCompletion(-1)).rejects.toThrow( + "completion timeoutMs must be a non-negative finite number", + ); + + const failed = await session.query("broken sql"); + const failureWait = failed.awaitCompletion(25); + connection.receive(queryError(failed.requestId, "bad syntax")); + await expect(failureWait).rejects.toMatchObject({ + name: "QwpEgressQueryError", + message: "bad syntax", + }); + expect(failed.isDone()).toBe(true); + await session.close(); + } finally { + vi.useRealTimers(); + } + }); + it("times out a query, sends CANCEL, and drains the terminal response", async () => { vi.useFakeTimers(); try { diff --git a/test/qwp/public-api-contract.ts b/test/qwp/public-api-contract.ts index 47c3d13..0c9d96e 100644 --- a/test/qwp/public-api-contract.ts +++ b/test/qwp/public-api-contract.ts @@ -345,6 +345,8 @@ function queryViewContract( const handler: QwpResultBatchViewHandler = (batch, query) => { const typedBatch: QwpResultBatchView = batch; const requestId: bigint = query.requestId; + const completionWait: Promise = query.awaitCompletion(1_000); + const done: boolean = query.isDone(); const rawValues: Uint8Array | undefined = batch.column(0).valuesBytes(); const directRow: QwpResultRowView = batch.row(0); const rowCallback: QwpResultRowViewCallback = (row) => { @@ -356,6 +358,8 @@ function queryViewContract( batch.forEachRow(rowCallback); void typedBatch; void requestId; + void completionWait; + void done; void rawValues; void directRow; }; diff --git a/test/qwp/public-api.test.ts b/test/qwp/public-api.test.ts index ac01e02..d4f8fbb 100644 --- a/test/qwp/public-api.test.ts +++ b/test/qwp/public-api.test.ts @@ -8,6 +8,7 @@ const sharedRuntimeContract = [ "QWP_INGRESS_PROGRESS_KIND", "QWP_DEFAULT_EGRESS_INITIAL_CREDIT", "QWP_DEFAULT_EGRESS_BUFFER_POOL_SIZE", + "QWP_DEFAULT_EGRESS_SERVER_INFO_TIMEOUT_MS", "QWP_MAX_BATCH_ROWS_UPPER_BOUND", "QWP_RECONNECT_EVENT_KIND", "QWP_SENDER_ERROR_CATEGORY", From 61cb47f1c00fe79e049a9cff78e414507f14c172 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 19 Aug 2026 21:53:34 +0100 Subject: [PATCH 084/265] perf(qwp): streamline store-and-forward persistence --- QWP.md | 20 +- README.md | 6 +- src/qwp-node/file-replay-store.ts | 458 +++++++++++++----- src/qwp-node/segment-maintenance-worker.ts | 204 ++++++++ .../reconnecting-ingress-connection.ts | 128 ++++- src/qwp/transport.ts | 13 + test/qwp/reconnect.test.ts | 93 ++++ 7 files changed, 770 insertions(+), 152 deletions(-) create mode 100644 src/qwp-node/segment-maintenance-worker.ts diff --git a/QWP.md b/QWP.md index bb0a780..fc62282 100644 --- a/QWP.md +++ b/QWP.md @@ -179,7 +179,8 @@ The connect-string key `durability` controls the local persistence barrier: -- `"append"` (the default) fsyncs every positional write to the open active segment; +- `"append"` (the default) issues a data-only durability barrier after every vectored + positional frame write; manifest and directory metadata retain full barriers; hot-spare creation and activation are durable before publication resolves. - `"periodic"` checkpoints segment files, symbol metadata, and directory changes in the background. The default interval is 5 seconds, and `close()` performs a final @@ -234,10 +235,19 @@ same normalized bytes. Each segment reserves `maxSegmentBytes` of target payload data (4 MiB by default) plus one frame header so a maximum-sized frame fits. The active segment and one -preallocated temporary hot spare keep open file handles; rotation activates the -spare and provisions its replacement away from the normal append path. ACK trimming -advances the durable manifest head before unlinking segments and runs in bounded -background batches. +pre-sized temporary hot spare keep open file handles; rotation activates the spare. +A process-wide, unreferenced worker provisions replacements, checkpoints dirty paths, +and performs ACK-driven unlink and directory barriers. ACK trimming advances the +durable manifest head before handing removal to that worker and runs in bounded +background batches. Frame append uses a vectored header-plus-payload write, avoiding +an additional payload-sized journal buffer. + +Recovery validates segment CRCs with a reusable 64 KiB scanner and indexes only frame +sequence, file offset, and payload length. The reconnect loop reads one payload from +its retained segment handle when it is ready to send it; it does not materialize the +complete persisted backlog. Fresh background store-and-forward frames likewise drop +their resident payload after journal publication and are read back on demand. Memory +therefore scales with the active encoding/send window rather than total disk backlog. Recovery also handles the canonical creation crash window in which a valid SFA segment becomes durable before its manifest. diff --git a/README.md b/README.md index b910b27..308cc9c 100644 --- a/README.md +++ b/README.md @@ -126,8 +126,10 @@ equivalent is `initial_connect_retry`, used together with the store-and-forward options in `extraOptions.qwp`. Persistent frames are coalesced into fixed-size 4 MiB `.sfa` segments by default, using the shared Java/Rust/Python SFA envelope, manifest, ACK watermark, and symbol -dictionary formats. The active segment and a preallocated temporary hot spare keep -open handles. +dictionary formats. The active segment and a pre-sized temporary hot spare keep open +handles. A shared worker provisions spares, checkpoints files, and trims acknowledged +segments. Recovery keeps only frame offsets in memory and reads payloads from disk as +they are sent, so a large persisted backlog is not duplicated on the JavaScript heap. Set `drainOrphans: true` when sibling journal directories share a dedicated parent: the Node client scans and drains slots left by failed producer processes with bounded concurrency. Pooled QWP clients recover idle in-range and out-of-range `sender-N` diff --git a/src/qwp-node/file-replay-store.ts b/src/qwp-node/file-replay-store.ts index f939af2..698fe99 100644 --- a/src/qwp-node/file-replay-store.ts +++ b/src/qwp-node/file-replay-store.ts @@ -14,12 +14,14 @@ import { basename, dirname, join } from "node:path"; import { QWP_MAX_SYMBOL_DICTIONARY_SIZE } from "../qwp/core"; import { QwpIngressReplayRecord, + QwpIngressReplayReference, QwpIngressReplayStore, } from "../qwp/transport"; import { QwpNodeAdvisoryLock, QwpNodeAdvisoryLockBusyError, } from "./advisory-lock"; +import { qwpSegmentMaintenanceWorker } from "./segment-maintenance-worker"; const FORMAT_VERSION = 1; const MAX_FRAME_SEQUENCE = 0x7fffffffffffffffn; @@ -74,6 +76,8 @@ export type QwpSfBackpressurePolicy = interface StoredRecord { readonly path: string; readonly size: number; + readonly payloadOffset?: number; + readonly payloadLength?: number; readonly segment?: StoredSegment; } @@ -96,11 +100,27 @@ interface HotSpareSegment { readonly handle: FileHandle; } +interface ScannedRecord extends QwpIngressReplayReference { + readonly payloadOffset: number; +} + interface RecoveredStoredRecord { - readonly record: QwpIngressReplayRecord; + readonly record: ScannedRecord; readonly stored: StoredRecord; } +interface EncodedRecord { + readonly header: Buffer; + readonly payload: Uint8Array; + readonly byteLength: number; +} + +interface SegmentScanScratch { + readonly segmentHeader: Buffer; + readonly frameHeader: Buffer; + readonly data: Buffer; +} + interface PendingCapacity { resolve: () => void; reject: (error: Error) => void; @@ -275,6 +295,7 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { private readonly appendDeadlineMs: number; private readonly records = new Map(); private readonly segments = new Map(); + private readonly segmentOrder: StoredSegment[] = []; private readonly symbols: string[] = []; private readonly symbolValues = new Set(); private readonly dirtyRecordPaths = new Set(); @@ -303,6 +324,7 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { private closed = false; private activeSegment?: StoredSegment; private hotSpare?: HotSpareSegment; + private hotSpareTask?: Promise; private nextSegmentGeneration = 0n; private manifestGeneration = 0n; private manifestHeadBase?: bigint; @@ -387,7 +409,19 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { }); } - load(): Promise { + async load(): Promise { + const references = await this.loadReferences(); + const records: QwpIngressReplayRecord[] = []; + for (const reference of references) { + records.push({ + frameSequence: reference.frameSequence, + payload: await this.readPayload(reference.frameSequence), + }); + } + return records; + } + + loadReferences(): Promise { if (this.closing || this.closed) return Promise.reject(this.closedError()); return this.enqueue(async () => { this.assertOpen(); @@ -398,6 +432,7 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { } await mkdir(this.directory, { recursive: true }); let loadSucceeded = false; + const recoveryHandles = new Set(); try { await this.acquireDirectoryLock(); const entries = await readdir(this.directory, { withFileTypes: true }); @@ -422,27 +457,37 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { readonly name: string; readonly path: string; readonly decoded: DecodedSegment; + readonly handle: FileHandle; }> = []; + const scanScratch: SegmentScanScratch = { + segmentHeader: Buffer.allocUnsafe(SEGMENT_HEADER_SIZE), + frameHeader: Buffer.allocUnsafe(FRAME_HEADER_SIZE), + data: Buffer.allocUnsafe(64 * 1024), + }; for (const name of segmentNames) { const path = join(this.directory, name); - let bytes: Buffer; + let handle: FileHandle | undefined; try { - bytes = await readFile(path); + handle = await open(path, "r+"); + const decoded = await scanSegment(handle, name, scanScratch); + const generation = parseSegmentGeneration(name); + if (generation !== undefined) { + this.nextSegmentGeneration = maxBigInt( + this.nextSegmentGeneration, + generation + 1n, + ); + } + recoveredSegments.push({ name, path, decoded, handle }); + recoveryHandles.add(handle); + handle = undefined; } catch (error) { + await handle?.close().catch(() => undefined); + if (error instanceof QwpReplayStoreError) throw error; throw new QwpReplayStoreError( - `could not read QWP store-and-forward segment [file=${name}]`, + `could not scan QWP store-and-forward segment [file=${name}]`, error, ); } - const generation = parseSegmentGeneration(name); - const decoded = decodeSegment(bytes, name); - if (generation !== undefined) { - this.nextSegmentGeneration = maxBigInt( - this.nextSegmentGeneration, - generation + 1n, - ); - } - recoveredSegments.push({ name, path, decoded }); } recoveredSegments.sort((left, right) => compareBigInt( @@ -461,8 +506,10 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { let changedDirectory = false; const removalPaths: string[] = []; for (let index = 0; index < recoveredSegments.length; index++) { - const { name, path, decoded } = recoveredSegments[index]; + const { name, path, decoded, handle } = recoveredSegments[index]; if (manifestStalePaths.has(path)) { + await handle.close(); + recoveryHandles.delete(handle); removalPaths.push(path); changedDirectory = true; continue; @@ -496,6 +543,8 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { const retainEmptyActive = decoded.records.length === 0 && path === selectedActivePath; if (liveRecords.length === 0 && !retainEmptyActive) { + await handle.close(); + recoveryHandles.delete(handle); removalPaths.push(path); changedDirectory = true; continue; @@ -509,13 +558,22 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { liveRecords: liveRecords.length, frameCount: decoded.records.length, manifestFlagPending: false, + handle, }; this.segments.set(path, segment); + this.segmentOrder.push(segment); + recoveryHandles.delete(handle); this.totalBytes += segment.size; for (const record of liveRecords) { recoveredEntries.push({ record, - stored: { path, size: 0, segment }, + stored: { + path, + size: 0, + payloadOffset: record.payloadOffset, + payloadLength: record.payloadLength, + segment, + }, }); } if (path === selectedActivePath) { @@ -539,9 +597,6 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { } for (const path of removalPaths) await ignoreMissing(unlink(path)); if (this.segments.size === 0) await this.removeManifest(); - if (this.activeSegment) { - this.activeSegment.handle = await open(this.activeSegment.path, "r+"); - } if (changedDirectory) await syncDirectory(this.directory); recoveredEntries.sort((left, right) => left.record.frameSequence < right.record.frameSequence @@ -551,7 +606,7 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { : 0, ); let previous = acknowledgedThrough; - const recovered: QwpIngressReplayRecord[] = []; + const recovered: QwpIngressReplayReference[] = []; for (const { record, stored } of recoveredEntries) { if (record.frameSequence <= previous) { throw new QwpReplayStoreCorruptionError( @@ -564,7 +619,10 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { ); } this.records.set(record.frameSequence, stored); - recovered.push(record); + recovered.push({ + frameSequence: record.frameSequence, + payloadLength: record.payloadLength, + }); previous = record.frameSequence; } if (recovered.length === 0 && acknowledgedThrough >= 0n) { @@ -589,7 +647,12 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { } finally { if (!loadSucceeded) { try { - await this.closeSegmentHandles(); + await Promise.all([ + this.closeSegmentHandles(), + ...[...recoveryHandles].map((handle) => + handle.close().catch(() => undefined), + ), + ]); } finally { await this.releaseDirectoryLock(); } @@ -598,6 +661,31 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { }); } + readPayload(frameSequence: bigint): Promise { + if (this.closing || this.closed) return Promise.reject(this.closedError()); + return this.enqueue(async () => { + this.assertReady(); + const stored = this.records.get(frameSequence); + if ( + !stored?.segment || + stored.payloadOffset === undefined || + stored.payloadLength === undefined + ) { + throw new QwpReplayStoreError( + `QWP store-and-forward frame is not available [frameSequence=${frameSequence}]`, + ); + } + let handle = stored.segment.handle; + if (!handle) { + handle = await open(stored.segment.path, "r+"); + stored.segment.handle = handle; + } + const payload = new Uint8Array(stored.payloadLength); + await readFully(handle, payload, stored.payloadOffset); + return payload; + }); + } + append(record: QwpIngressReplayRecord): Promise { if (this.closing || this.closed) return Promise.reject(this.closedError()); if (record.payload.byteLength > this.maxSegmentBytes) { @@ -853,6 +941,9 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { failure = error; } try { + await this.hotSpareTask?.catch((error) => { + failure ??= error; + }); await this.discardHotSpare(); await this.closeSegmentHandles(); } catch (error) { @@ -878,7 +969,7 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { private async appendWithBackpressure( record: QwpIngressReplayRecord, - bytes: Buffer, + bytes: EncodedRecord, ): Promise { let deadline = 0; let stalled = false; @@ -914,7 +1005,7 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { private async appendOnce( record: QwpIngressReplayRecord, - bytes: Buffer, + bytes: EncodedRecord, ): Promise { this.assertReady(); validateFrameSequence(record.frameSequence); @@ -964,9 +1055,9 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { } const writeOffset = SEGMENT_HEADER_SIZE + segment.logicalSize; try { - await writeFully(handle, bytes, writeOffset); + await writevFully(handle, [bytes.header, bytes.payload], writeOffset); if (this.durability === QWP_SF_DURABILITY.APPEND) { - await handle.sync(); + await handle.datasync(); } else if (this.durability === QWP_SF_DURABILITY.PERIODIC) { this.dirtyRecordPaths.add(segment.path); } @@ -988,6 +1079,8 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { this.records.set(record.frameSequence, { path: segment.path, size: 0, + payloadOffset: writeOffset + FRAME_HEADER_SIZE, + payloadLength: record.payload.byteLength, segment, }); this.scheduleHotSpare(); @@ -999,11 +1092,9 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { const previous = this.activeSegment; if (previous?.handle) { if (this.durability === QWP_SF_DURABILITY.PERIODIC) { - await previous.handle.sync(); + await previous.handle.datasync(); this.dirtyRecordPaths.delete(previous.path); } - await previous.handle.close(); - previous.handle = undefined; } await this.ensureHotSpare(true); const spare = this.hotSpare; @@ -1048,6 +1139,7 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { }; this.hotSpare = undefined; this.segments.set(segment.path, segment); + this.segmentOrder.push(segment); this.activeSegment = segment; try { await writeFully(spare.handle, Uint8Array.of(MANIFEST_REQUIRED_FLAG), 5); @@ -1068,7 +1160,22 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { } private async ensureHotSpare(required: boolean): Promise { - if (this.hotSpare || this.closing || this.closed) return; + if (this.hotSpare) return; + if (this.hotSpareTask) { + await this.hotSpareTask; + return; + } + if (this.closing || this.closed) return; + const provisioning = this.provisionHotSpare(required); + this.hotSpareTask = provisioning; + try { + await provisioning; + } finally { + if (this.hotSpareTask === provisioning) this.hotSpareTask = undefined; + } + } + + private async provisionHotSpare(required: boolean): Promise { const requiredBytes = this.totalBytes + this.segmentFileSize; const frameBytes = this.totalBytes - this.dictionaryFileSize; const preservesLiveness = @@ -1086,27 +1193,35 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { this.directory, `${name}${TEMP_MARKER}${process.pid}-${randomUUID()}`, ); - let temporaryHandle: FileHandle | undefined; let handle: FileHandle | undefined; + this.totalBytes = requiredBytes; try { - temporaryHandle = await open(temporaryPath, "wx+", 0o600); - await temporaryHandle.truncate(this.segmentFileSize); - if (this.durability === QWP_SF_DURABILITY.APPEND) { - await temporaryHandle.sync(); + await qwpSegmentMaintenanceWorker.provision( + temporaryPath, + this.segmentFileSize, + this.durability === QWP_SF_DURABILITY.APPEND, + ); + handle = await open(temporaryPath, "r+"); + if (this.closing || this.closed) { + await handle.close(); + handle = undefined; + await qwpSegmentMaintenanceWorker.unlink(temporaryPath); + this.totalBytes -= this.segmentFileSize; + return; } - handle = temporaryHandle; - temporaryHandle = undefined; this.hotSpare = { path: temporaryPath, generation, size: this.segmentFileSize, handle, }; - this.totalBytes = requiredBytes; + handle = undefined; } catch (error) { - await temporaryHandle?.close().catch(() => undefined); await handle?.close().catch(() => undefined); - await ignoreMissing(unlink(temporaryPath)); + await qwpSegmentMaintenanceWorker + .unlink(temporaryPath) + .catch(() => undefined); + this.totalBytes -= this.segmentFileSize; throw new QwpReplayStoreError( `could not provision QWP store-and-forward hot spare [generation=${generation}]`, error, @@ -1118,7 +1233,7 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { if (this.hotSpare || this.closing || this.closed) return; queueMicrotask(() => { if (this.hotSpare || this.closing || this.closed) return; - void this.enqueue(() => this.ensureHotSpare(false)).catch(() => { + void this.ensureHotSpare(false).catch(() => { // Capacity exhaustion is expected: ACK trimming will make a later // rotation retry provisioning synchronously. Other failures surface on // that required path rather than as an unhandled background rejection. @@ -1166,7 +1281,7 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { } if (trimmed > 0) { if (this.durability === QWP_SF_DURABILITY.APPEND) { - await syncDirectory(this.directory); + await qwpSegmentMaintenanceWorker.syncDirectory(this.directory); } else if (this.durability === QWP_SF_DURABILITY.PERIODIC) { this.directoryDirty = true; } @@ -1191,22 +1306,26 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { try { await segment.handle?.close(); segment.handle = undefined; - const remaining = [...this.segments.values()] - .filter((candidate) => candidate !== segment) - .sort((left, right) => - compareBigInt(left.firstSequence, right.firstSequence), - ); - if (remaining.length > 0) { - await this.writeManifest( - remaining[0].firstSequence, - remaining[remaining.length - 1].firstSequence, + const segmentIndex = this.segmentOrder.indexOf(segment); + if (segmentIndex < 0) { + throw new QwpReplayStoreError( + `QWP store-and-forward segment is absent from the ordered ring [firstSequence=${segment.firstSequence}]`, ); + } + if (this.segmentOrder.length > 1) { + const head = + segmentIndex === 0 ? this.segmentOrder[1] : this.segmentOrder[0]; + const active = + segmentIndex === this.segmentOrder.length - 1 + ? this.segmentOrder[this.segmentOrder.length - 2] + : this.segmentOrder[this.segmentOrder.length - 1]; + await this.writeManifest(head.firstSequence, active.firstSequence); } else { const collapsed = segment.firstSequence + BigInt(segment.frameCount); await this.writeManifest(collapsed, collapsed); } - await ignoreMissing(unlink(segment.path)); - if (remaining.length === 0) await this.removeManifest(); + await qwpSegmentMaintenanceWorker.unlink(segment.path); + if (this.segmentOrder.length === 1) await this.removeManifest(); } catch (error) { throw new QwpReplayStoreError( `could not trim QWP store-and-forward segment [firstSequence=${segment.firstSequence}]`, @@ -1214,6 +1333,7 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { ); } this.segments.delete(segment.path); + this.segmentOrder.splice(this.segmentOrder.indexOf(segment), 1); this.dirtyRecordPaths.delete(segment.path); this.totalBytes -= segment.size; if (this.activeSegment === segment) this.activeSegment = undefined; @@ -1249,10 +1369,10 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { this.hotSpare = undefined; try { await spare.handle.close(); - await ignoreMissing(unlink(spare.path)); + await qwpSegmentMaintenanceWorker.unlink(spare.path); this.totalBytes -= spare.size; if (this.durability !== QWP_SF_DURABILITY.MEMORY) { - await syncDirectory(this.directory); + await qwpSegmentMaintenanceWorker.syncDirectory(this.directory); } } catch (error) { throw new QwpReplayStoreError( @@ -1347,20 +1467,17 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { return; } try { - for (const path of this.dirtyRecordPaths) { - if (this.activeSegment?.path === path && this.activeSegment.handle) { - await this.activeSegment.handle.sync(); - } else { - await syncFile(path); - } - } + const paths = [...this.dirtyRecordPaths]; if (this.dictionaryDirty) { - await syncFile(join(this.directory, DICTIONARY_FILE)); + paths.push(join(this.directory, DICTIONARY_FILE)); } if (this.acknowledgementDirty) { - await syncFile(join(this.directory, ACK_FILE)); + paths.push(join(this.directory, ACK_FILE)); } - if (this.directoryDirty) await syncDirectory(this.directory); + await qwpSegmentMaintenanceWorker.checkpoint( + paths, + this.directoryDirty ? this.directory : undefined, + ); this.dirtyRecordPaths.clear(); this.dictionaryDirty = false; this.acknowledgementDirty = false; @@ -1513,16 +1630,13 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { } private async rewriteManifestForCurrentSegments(): Promise { - const current = [...this.segments.values()].sort((left, right) => - compareBigInt(left.firstSequence, right.firstSequence), - ); - if (current.length === 0) { + if (this.segmentOrder.length === 0) { await this.removeManifest(); return; } await this.writeManifest( - current[0].firstSequence, - current[current.length - 1].firstSequence, + this.segmentOrder[0].firstSequence, + this.segmentOrder[this.segmentOrder.length - 1].firstSequence, ); } @@ -1842,24 +1956,21 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { } } -function encodeRecord(record: QwpIngressReplayRecord): Buffer { +function encodeRecord(record: QwpIngressReplayRecord): EncodedRecord { validateFrameSequence(record.frameSequence); if (record.payload.byteLength > 0xffffffff) { throw new QwpReplayStoreError( `QWP frame is too large for the store-and-forward format [size=${record.payload.byteLength}]`, ); } - const bytes = Buffer.allocUnsafe( - FRAME_HEADER_SIZE + record.payload.byteLength, - ); - bytes.writeUInt32LE(record.payload.byteLength, 4); - Buffer.from( - record.payload.buffer, - record.payload.byteOffset, - record.payload.byteLength, - ).copy(bytes, FRAME_HEADER_SIZE); - bytes.writeUInt32LE(crc32c(bytes.subarray(4)), 0); - return bytes; + const header = Buffer.allocUnsafe(FRAME_HEADER_SIZE); + header.writeUInt32LE(record.payload.byteLength, 4); + header.writeUInt32LE(crc32cParts([header.subarray(4), record.payload]), 0); + return { + header, + payload: record.payload, + byteLength: FRAME_HEADER_SIZE + record.payload.byteLength, + }; } interface DecodedSegment { @@ -1867,7 +1978,7 @@ interface DecodedSegment { readonly manifestRequired: boolean; readonly capacity: number; readonly size: number; - readonly records: QwpIngressReplayRecord[]; + readonly records: ScannedRecord[]; /** Bytes occupied by encoded records, excluding the fixed segment header. */ readonly logicalSize: number; readonly tornTail: boolean; @@ -1919,76 +2030,109 @@ function encodeSegmentHeader( return bytes; } -function decodeSegment(bytes: Buffer, name: string): DecodedSegment { - if (bytes.byteLength < SEGMENT_HEADER_SIZE) { +async function scanSegment( + handle: FileHandle, + name: string, + scratch: SegmentScanScratch, +): Promise { + const fileSize = (await handle.stat()).size; + if (fileSize < SEGMENT_HEADER_SIZE) { throw corruptRecord(name, "fixed segment is shorter than its header"); } - if (!bytes.subarray(0, SEGMENT_MAGIC.byteLength).equals(SEGMENT_MAGIC)) { + const segmentHeader = scratch.segmentHeader; + await readFully(handle, segmentHeader, 0); + if ( + !segmentHeader.subarray(0, SEGMENT_MAGIC.byteLength).equals(SEGMENT_MAGIC) + ) { throw corruptRecord(name, "invalid segment magic"); } - if (bytes.readUInt8(4) !== FORMAT_VERSION) { + if (segmentHeader.readUInt8(4) !== FORMAT_VERSION) { throw corruptRecord( name, - `unsupported segment version ${bytes.readUInt8(4)}`, + `unsupported segment version ${segmentHeader.readUInt8(4)}`, ); } - const flags = bytes.readUInt8(5); + const flags = segmentHeader.readUInt8(5); if ((flags & ~MANIFEST_REQUIRED_FLAG) !== 0) { throw corruptRecord(name, `unsupported segment flags ${flags}`); } - if (bytes.readUInt16LE(6) !== 0) { + if (segmentHeader.readUInt16LE(6) !== 0) { throw corruptRecord(name, "segment reserved field is not zero"); } - const firstSequence = bytes.readBigUInt64LE(8); + const firstSequence = segmentHeader.readBigUInt64LE(8); validateFrameSequence(firstSequence); - const capacity = bytes.byteLength - SEGMENT_HEADER_SIZE; - const records: QwpIngressReplayRecord[] = []; + const capacity = fileSize - SEGMENT_HEADER_SIZE; + const records: ScannedRecord[] = []; + const frameHeader = scratch.frameHeader; + const scanBuffer = scratch.data; let offset = SEGMENT_HEADER_SIZE; - while (offset < bytes.byteLength) { - if (bytes[offset] === 0 && isZeroFilled(bytes, offset)) { + while (offset < fileSize) { + const remaining = fileSize - offset; + const headerBytes = Math.min(remaining, FRAME_HEADER_SIZE); + await readFully(handle, frameHeader.subarray(0, headerBytes), offset); + if ( + frameHeader[0] === 0 && + isZeroFilled(frameHeader, 0, headerBytes) && + (await isZeroFilledFile( + handle, + offset + headerBytes, + fileSize, + scanBuffer, + )) + ) { return { firstSequence, manifestRequired: (flags & MANIFEST_REQUIRED_FLAG) !== 0, capacity, - size: bytes.byteLength, + size: fileSize, records, logicalSize: offset - SEGMENT_HEADER_SIZE, tornTail: false, }; } - const remaining = bytes.byteLength - offset; if (remaining < FRAME_HEADER_SIZE) { return { firstSequence, manifestRequired: (flags & MANIFEST_REQUIRED_FLAG) !== 0, capacity, - size: bytes.byteLength, + size: fileSize, records, logicalSize: offset - SEGMENT_HEADER_SIZE, tornTail: true, }; } - const payloadLength = bytes.readUInt32LE(offset + 4); + const payloadLength = frameHeader.readUInt32LE(4); const recordEnd = offset + FRAME_HEADER_SIZE + payloadLength; - if (recordEnd > bytes.byteLength) { + if (recordEnd > fileSize) { return { firstSequence, manifestRequired: (flags & MANIFEST_REQUIRED_FLAG) !== 0, capacity, - size: bytes.byteLength, + size: fileSize, records, logicalSize: offset - SEGMENT_HEADER_SIZE, tornTail: true, }; } - const storedCrc = bytes.readUInt32LE(offset); - const actualCrc = crc32c(bytes.subarray(offset + 4, recordEnd)); + const storedCrc = frameHeader.readUInt32LE(0); + let crc = crc32cUpdate(0xffffffff, frameHeader.subarray(4)); + let payloadOffset = offset + FRAME_HEADER_SIZE; + let payloadRemaining = payloadLength; + while (payloadRemaining > 0) { + const chunkLength = Math.min(payloadRemaining, scanBuffer.byteLength); + const chunk = scanBuffer.subarray(0, chunkLength); + await readFully(handle, chunk, payloadOffset); + crc = crc32cUpdate(crc, chunk); + payloadOffset += chunkLength; + payloadRemaining -= chunkLength; + } + const actualCrc = (crc ^ 0xffffffff) >>> 0; if (storedCrc !== actualCrc) { return { firstSequence, manifestRequired: (flags & MANIFEST_REQUIRED_FLAG) !== 0, capacity, - size: bytes.byteLength, + size: fileSize, records, logicalSize: offset - SEGMENT_HEADER_SIZE, tornTail: true, @@ -1996,28 +2140,52 @@ function decodeSegment(bytes: Buffer, name: string): DecodedSegment { } const frameSequence = firstSequence + BigInt(records.length); validateFrameSequence(frameSequence); - const payload = bytes.subarray(offset + FRAME_HEADER_SIZE, recordEnd); - records.push({ frameSequence, payload: new Uint8Array(payload) }); + records.push({ + frameSequence, + payloadLength, + payloadOffset: offset + FRAME_HEADER_SIZE, + }); offset = recordEnd; } return { firstSequence, manifestRequired: (flags & MANIFEST_REQUIRED_FLAG) !== 0, capacity, - size: bytes.byteLength, + size: fileSize, records, logicalSize: offset - SEGMENT_HEADER_SIZE, tornTail: false, }; } -function isZeroFilled(bytes: Buffer, offset: number): boolean { - for (let index = offset; index < bytes.byteLength; index++) { +function isZeroFilled( + bytes: Buffer, + offset: number, + end = bytes.byteLength, +): boolean { + for (let index = offset; index < end; index++) { if (bytes[index] !== 0) return false; } return true; } +async function isZeroFilledFile( + handle: FileHandle, + start: number, + end: number, + scratch: Buffer, +): Promise { + let offset = start; + while (offset < end) { + const length = Math.min(end - offset, scratch.byteLength); + const chunk = scratch.subarray(0, length); + await readFully(handle, chunk, offset); + if (!isZeroFilled(chunk, 0, length)) return false; + offset += length; + } + return true; +} + function encodeDictionaryHeader(): Buffer { const header = Buffer.alloc(DICTIONARY_HEADER_SIZE); DICTIONARY_MAGIC.copy(header, 0); @@ -2190,11 +2358,21 @@ const CRC32C_TABLE = (() => { })(); function crc32c(bytes: Uint8Array): number { + return (crc32cUpdate(0xffffffff, bytes) ^ 0xffffffff) >>> 0; +} + +function crc32cParts(parts: readonly Uint8Array[]): number { let crc = 0xffffffff; + for (const part of parts) crc = crc32cUpdate(crc, part); + return (crc ^ 0xffffffff) >>> 0; +} + +function crc32cUpdate(initial: number, bytes: Uint8Array): number { + let crc = initial; for (const byte of bytes) { crc = CRC32C_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8); } - return (crc ^ 0xffffffff) >>> 0; + return crc; } function validateReplacementDictionary(entries: readonly string[]): void { @@ -2581,6 +2759,55 @@ async function writeFully( } } +async function writevFully( + handle: FileHandle, + buffers: readonly Uint8Array[], + position: number, +): Promise { + let pending = buffers.map((buffer) => + Buffer.from(buffer.buffer, buffer.byteOffset, buffer.byteLength), + ); + let writePosition = position; + while (pending.length > 0) { + const { bytesWritten } = await handle.writev(pending, writePosition); + if (bytesWritten === 0) { + throw new QwpReplayStoreError("fixed segment write made no progress"); + } + writePosition += bytesWritten; + let consumed = bytesWritten; + let firstPending = 0; + while ( + firstPending < pending.length && + consumed >= pending[firstPending].byteLength + ) { + consumed -= pending[firstPending].byteLength; + firstPending++; + } + pending = pending.slice(firstPending); + if (consumed > 0) pending[0] = pending[0].subarray(consumed); + } +} + +async function readFully( + handle: FileHandle, + bytes: Uint8Array, + position: number, +): Promise { + let offset = 0; + while (offset < bytes.byteLength) { + const { bytesRead } = await handle.read( + bytes, + offset, + bytes.byteLength - offset, + position + offset, + ); + if (bytesRead === 0) { + throw new QwpReplayStoreError("fixed segment read ended unexpectedly"); + } + offset += bytesRead; + } +} + async function zeroRange( handle: FileHandle, position: number, @@ -2612,15 +2839,6 @@ async function syncDirectory(directory: string): Promise { } } -async function syncFile(path: string): Promise { - const handle = await open(path, "r"); - try { - await handle.sync(); - } finally { - await handle.close(); - } -} - function validateDurability(value: string): QwpSfDurability { if ( value === QWP_SF_DURABILITY.MEMORY || diff --git a/src/qwp-node/segment-maintenance-worker.ts b/src/qwp-node/segment-maintenance-worker.ts new file mode 100644 index 0000000..973a16e --- /dev/null +++ b/src/qwp-node/segment-maintenance-worker.ts @@ -0,0 +1,204 @@ +import { Worker } from "node:worker_threads"; + +interface WorkerFailure { + readonly name?: string; + readonly message: string; + readonly stack?: string; + readonly code?: string; +} + +interface WorkerReply { + readonly id: number; + readonly error?: WorkerFailure; +} + +type MaintenanceRequest = + | { + readonly operation: "provision"; + readonly path: string; + readonly size: number; + readonly durable: boolean; + } + | { + readonly operation: "unlink"; + readonly path: string; + } + | { + readonly operation: "sync-directory"; + readonly directory: string; + } + | { + readonly operation: "checkpoint"; + readonly paths: readonly string[]; + readonly directory?: string; + }; + +interface PendingRequest { + readonly resolve: () => void; + readonly reject: (error: Error) => void; +} + +const WORKER_SOURCE = String.raw` +const { parentPort } = require("node:worker_threads"); +const { open, unlink } = require("node:fs/promises"); + +async function syncDirectory(directory) { + let handle; + try { + handle = await open(directory, "r"); + await handle.sync(); + } catch (error) { + if (process.platform !== "win32") throw error; + } finally { + await handle?.close().catch(() => undefined); + } +} + +async function datasyncFile(path) { + const handle = await open(path, "r"); + try { + await handle.datasync(); + } finally { + await handle.close(); + } +} + +async function run(request) { + switch (request.operation) { + case "provision": { + let handle; + try { + handle = await open(request.path, "wx+", 0o600); + await handle.truncate(request.size); + if (request.durable) await handle.sync(); + } finally { + await handle?.close().catch(() => undefined); + } + return; + } + case "unlink": + try { + await unlink(request.path); + } catch (error) { + if (error?.code !== "ENOENT") throw error; + } + return; + case "sync-directory": + await syncDirectory(request.directory); + return; + case "checkpoint": + for (const path of request.paths) await datasyncFile(path); + if (request.directory) await syncDirectory(request.directory); + return; + default: + throw new Error("unknown QWP segment-maintenance operation"); + } +} + +let operationTail = Promise.resolve(); +parentPort.on("message", ({ id, request }) => { + const operation = operationTail.then(() => run(request)); + operationTail = operation.catch(() => undefined); + void operation.then( + () => parentPort.postMessage({ id }), + (cause) => parentPort.postMessage({ + id, + error: { + name: cause?.name, + message: cause instanceof Error ? cause.message : String(cause), + stack: cause?.stack, + code: cause?.code, + }, + }), + ); +}); +`; + +/** One unreferenced maintenance worker shared by every SF journal in a process. */ +class QwpSegmentMaintenanceWorker { + private readonly pending = new Map(); + private worker?: Worker; + private nextRequestId = 1; + + provision(path: string, size: number, durable: boolean): Promise { + return this.request({ operation: "provision", path, size, durable }); + } + + unlink(path: string): Promise { + return this.request({ operation: "unlink", path }); + } + + syncDirectory(directory: string): Promise { + return this.request({ operation: "sync-directory", directory }); + } + + checkpoint(paths: readonly string[], directory?: string): Promise { + if (paths.length === 0 && directory === undefined) return Promise.resolve(); + return this.request({ operation: "checkpoint", paths, directory }); + } + + private request(request: MaintenanceRequest): Promise { + const worker = this.ensureWorker(); + const id = this.nextRequestId++; + worker.ref(); + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }); + try { + worker.postMessage({ id, request }); + } catch (error) { + this.pending.delete(id); + if (this.pending.size === 0) worker.unref(); + reject(error); + } + }); + } + + private ensureWorker(): Worker { + if (this.worker) return this.worker; + const worker = new Worker(WORKER_SOURCE, { + eval: true, + name: "questdb-qwp-segment-maintenance", + }); + worker.on("message", (reply: WorkerReply) => this.onReply(reply)); + worker.on("error", (error) => this.onWorkerFailure(worker, error)); + worker.on("exit", (code) => { + if (this.worker === worker) { + this.onWorkerFailure( + worker, + new Error(`QWP segment-maintenance worker exited with code ${code}`), + ); + } + }); + worker.unref(); + this.worker = worker; + return worker; + } + + private onReply(reply: WorkerReply): void { + const pending = this.pending.get(reply.id); + if (!pending) return; + this.pending.delete(reply.id); + if (reply.error) pending.reject(workerError(reply.error)); + else pending.resolve(); + if (this.pending.size === 0) this.worker?.unref(); + } + + private onWorkerFailure(worker: Worker, error: Error): void { + if (this.worker !== worker) return; + this.worker = undefined; + for (const pending of this.pending.values()) pending.reject(error); + this.pending.clear(); + } +} + +function workerError(failure: WorkerFailure): Error { + const error = new Error(failure.message); + error.name = failure.name ?? "Error"; + if (failure.stack) error.stack = failure.stack; + if (failure.code) { + (error as Error & { code?: string }).code = failure.code; + } + return error; +} + +export const qwpSegmentMaintenanceWorker = new QwpSegmentMaintenanceWorker(); diff --git a/src/qwp/internal/reconnecting-ingress-connection.ts b/src/qwp/internal/reconnecting-ingress-connection.ts index db7edc5..7f6e0eb 100644 --- a/src/qwp/internal/reconnecting-ingress-connection.ts +++ b/src/qwp/internal/reconnecting-ingress-connection.ts @@ -23,6 +23,7 @@ import { QwpFailoverError, QwpHandshakeMetadata, QwpIngressReplayRecord, + QwpIngressReplayReference, QwpIngressReplayStore, QwpIngressTransportMetrics, QwpInitialConnectMode, @@ -100,7 +101,8 @@ class QwpDurableAckPersistentFailureError extends Error { } } -interface ReplayFrame extends QwpIngressReplayRecord { +interface ReplayFrame extends QwpIngressReplayReference { + payload?: Uint8Array; readonly clientSequence?: bigint; ackDelivered: boolean; transmitted: boolean; @@ -108,6 +110,13 @@ interface ReplayFrame extends QwpIngressReplayRecord { dictionaryCatchup?: boolean; } +type LoadedReplayRecord = QwpIngressReplayReference & { + readonly payload?: Uint8Array; +}; + +type LazyReplayStore = QwpIngressReplayStore & + Required>; + interface RecoveredDiscardTail { readonly startSequence: bigint; readonly tipSequence: bigint; @@ -312,6 +321,7 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { private readonly durableWatermarks = new Map(); private readonly symbolDictionary: string[]; private readonly store: QwpIngressReplayStore; + private readonly lazyReplayStore?: LazyReplayStore; private readonly maxAttempts: number; private readonly initialBackoffMs: number; private readonly maxBackoffMs: number; @@ -373,7 +383,7 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { private readonly factory: QwpConnectionFactory, private readonly reconnectOptions: QwpReconnectOptions, store: QwpIngressReplayStore, - records: readonly QwpIngressReplayRecord[], + records: readonly LoadedReplayRecord[], symbolDictionary: readonly string[], recoveredDiscardTail: RecoveredDiscardTail | undefined, localMaxBatchSizeBytes?: number, @@ -386,6 +396,7 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { onSenderError?: (error: QwpSenderError) => void, ) { this.store = store; + this.lazyReplayStore = isLazyReplayStore(store) ? store : undefined; this.symbolDictionary = [...symbolDictionary]; this.deltaSymbolDictionaryEnabled = store.loadSymbolDictionary !== undefined && @@ -435,9 +446,20 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { "QWP replay store records must have strictly increasing non-negative sequences", ); } + if ( + !Number.isSafeInteger(record.payloadLength) || + record.payloadLength < 0 || + (record.payload !== undefined && + record.payload.byteLength !== record.payloadLength) + ) { + throw new Error( + `QWP replay store returned an invalid payload length [frameSequence=${record.frameSequence}, payloadLength=${record.payloadLength}]`, + ); + } const frame: ReplayFrame = { frameSequence: record.frameSequence, - payload: record.payload.slice(), + payloadLength: record.payloadLength, + payload: record.payload?.slice(), ackDelivered: true, transmitted: true, }; @@ -478,7 +500,13 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { ); let connection: QwpReconnectingIngressConnection | undefined; try { - const records = await store.load(); + const lazyStore = isLazyReplayStore(store) ? store : undefined; + const records: readonly LoadedReplayRecord[] = lazyStore + ? await lazyStore.loadReferences() + : (await store.load()).map((record) => ({ + ...record, + payloadLength: record.payload.byteLength, + })); const sortedRecords = [...records].sort((a, b) => a.frameSequence < b.frameSequence ? -1 @@ -496,9 +524,17 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { persistedSymbolDictionaryFailure = error; } } - const recoveredDiscardTail = analyzeRecoveredDiscardTail(sortedRecords); + const loadPayload = (record: LoadedReplayRecord) => + record.payload + ? Promise.resolve(record.payload) + : lazyStore!.readPayload(record.frameSequence); + const recoveredDiscardTail = await analyzeRecoveredDiscardTail( + sortedRecords, + loadPayload, + ); const symbolDictionary = await recoverSymbolDictionary( sortedRecords, + loadPayload, persistedSymbolDictionary, recoveredDiscardTail, store, @@ -587,7 +623,7 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { getIngressMetrics(): QwpIngressTransportMetrics { let pendingReplayBytes = 0; for (const frame of this.frames.values()) { - pendingReplayBytes += frame.payload.byteLength; + pendingReplayBytes += frame.payloadLength; } const memoryMetrics = this.store instanceof QwpMemoryReplayStore @@ -641,12 +677,13 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { frameSequence: this.nextFrameSequence++, clientSequence: this.nextClientSequence++, payload: payload.slice(), + payloadLength: payload.byteLength, ackDelivered: false, transmitted: false, }; const publishing = this.sendTail.then(async () => { this.throwIfUnavailable(); - const delta = readSymbolDictionaryDelta(frame.payload); + const delta = readSymbolDictionaryDelta(frame.payload!); if (delta) { if (!this.deltaSymbolDictionaryEnabled) { throw new QwpReplayDictionaryError( @@ -655,10 +692,14 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { } await this.persistSymbolDictionaryDelta(delta); } - await this.store.append(frame); + await this.store.append({ + frameSequence: frame.frameSequence, + payload: frame.payload!, + }); this.frames.set(frame.frameSequence, frame); this.publishedFrameSequence = frame.frameSequence; if (this.backgroundStoreAndForward) { + if (this.lazyReplayStore) frame.payload = undefined; this.enqueueDrain(frame); return; } @@ -1002,6 +1043,7 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { const frame: ReplayFrame = { frameSequence: -1n, payload, + payloadLength: payload.byteLength, ackDelivered: true, transmitted: true, dictionaryCatchup: true, @@ -1013,13 +1055,14 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { if (!frame.transmitted) continue; if (this.isRecoveredDiscardFrame(frame.frameSequence)) continue; frame.durableTargets = undefined; - if (cap !== undefined && frame.payload.byteLength > cap) { + if (cap !== undefined && frame.payloadLength > cap) { throw new RangeError( - `persisted QWP frame exceeds reconnect target batch cap [size=${frame.payload.byteLength}, max=${cap}]`, + `persisted QWP frame exceeds reconnect target batch cap [size=${frame.payloadLength}, max=${cap}]`, ); } + const payload = await this.readFramePayload(frame); replayed.push(frame); - await this.sendPhysical(connection, frame.payload, true); + await this.sendPhysical(connection, payload, true); } return replayed; } @@ -1497,18 +1540,39 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { connection.handshake.maxBatchSizeBytes, this.localMaxBatchSizeBytes, ); - if (cap !== undefined && frame.payload.byteLength > cap) { + if (cap !== undefined && frame.payloadLength > cap) { throw new RangeError( - `QWP frame exceeds reconnect target batch cap [size=${frame.payload.byteLength}, max=${cap}]`, + `QWP frame exceeds reconnect target batch cap [size=${frame.payloadLength}, max=${cap}]`, ); } + const payload = await this.readFramePayload(frame); frame.transmitted = true; this.wireFrames.push(frame); try { - await this.sendPhysical(connection, frame.payload, false); + await this.sendPhysical(connection, payload, false); + if (this.lazyReplayStore) frame.payload = undefined; } catch (error) { await this.requestReconnect(error, connection); + if (this.lazyReplayStore) frame.payload = undefined; + } + } + + private async readFramePayload(frame: ReplayFrame): Promise { + let payload = frame.payload; + if (!payload) { + if (!this.lazyReplayStore) { + throw new QwpProtocolError( + `QWP replay payload is unavailable [frameSequence=${frame.frameSequence}]`, + ); + } + payload = await this.lazyReplayStore.readPayload(frame.frameSequence); + } + if (payload.byteLength !== frame.payloadLength) { + throw new QwpProtocolError( + `persisted QWP frame length changed [frameSequence=${frame.frameSequence}, expected=${frame.payloadLength}, received=${payload.byteLength}]`, + ); } + return payload; } private async sendPhysical( @@ -1670,6 +1734,15 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { } } +function isLazyReplayStore( + store: QwpIngressReplayStore, +): store is LazyReplayStore { + return ( + typeof store.loadReferences === "function" && + typeof store.readPayload === "function" + ); +} + function readSymbolDictionaryDelta(payload: Uint8Array) { // Preserve support for opaque/custom payloads used with the low-level API. if ( @@ -1681,12 +1754,13 @@ function readSymbolDictionaryDelta(payload: Uint8Array) { return decodeQwpIngressSymbolDictionaryDelta(payload); } -function analyzeRecoveredDiscardTail( - records: readonly QwpIngressReplayRecord[], -): RecoveredDiscardTail | undefined { +async function analyzeRecoveredDiscardTail( + records: readonly LoadedReplayRecord[], + loadPayload: (record: LoadedReplayRecord) => Promise, +): Promise { let boundaryIndex = -1; for (let index = 0; index < records.length; index++) { - if (isRecoveredCommitBarrier(records[index].payload)) { + if (isRecoveredCommitBarrier(await loadPayload(records[index]))) { boundaryIndex = index; } } @@ -1721,7 +1795,8 @@ function isRecoveredCommitBarrier(payload: Uint8Array): boolean { } async function recoverSymbolDictionary( - records: readonly QwpIngressReplayRecord[], + records: readonly LoadedReplayRecord[], + loadPayload: (record: LoadedReplayRecord) => Promise, persistedDictionary: readonly string[], discardTail: RecoveredDiscardTail | undefined, store: QwpIngressReplayStore, @@ -1733,8 +1808,9 @@ async function recoverSymbolDictionary( let recoveredFromPersisted = true; let dictionary: string[]; try { - dictionary = reconstructSymbolDictionary( + dictionary = await reconstructSymbolDictionary( records, + loadPayload, persistedDictionary, discardTail, hasDictionaryPersistence, @@ -1747,8 +1823,9 @@ async function recoverSymbolDictionary( // A structurally valid sidecar can still belong to an older dictionary // generation. Only discard it when the committed frames independently // reconstruct a complete dense dictionary from ID zero. - dictionary = reconstructSymbolDictionary( + dictionary = await reconstructSymbolDictionary( records, + loadPayload, [], discardTail, hasDictionaryPersistence, @@ -1783,13 +1860,14 @@ async function recoverSymbolDictionary( return dictionary; } -function reconstructSymbolDictionary( - records: readonly QwpIngressReplayRecord[], +async function reconstructSymbolDictionary( + records: readonly LoadedReplayRecord[], + loadPayload: (record: LoadedReplayRecord) => Promise, baseline: readonly string[], discardTail: RecoveredDiscardTail | undefined, hasDictionaryPersistence: boolean, recoveryCause?: unknown, -): string[] { +): Promise { const dictionary = [...baseline]; const dictionaryIds = new Map(dictionary.map((entry, id) => [entry, id])); for (const record of records) { @@ -1803,7 +1881,7 @@ function reconstructSymbolDictionary( } let delta: ReturnType; try { - delta = readSymbolDictionaryDelta(record.payload); + delta = readSymbolDictionaryDelta(await loadPayload(record)); } catch (error) { throw new QwpUnrecoverableReplayDictionaryError( `persisted QWP frame contains an invalid symbol dictionary delta [sequence=${record.frameSequence}]`, diff --git a/src/qwp/transport.ts b/src/qwp/transport.ts index c9a112a..e908831 100644 --- a/src/qwp/transport.ts +++ b/src/qwp/transport.ts @@ -185,9 +185,22 @@ export interface QwpIngressReplayRecord { readonly payload: Uint8Array; } +/** Lightweight durable-frame descriptor used by disk-backed replay stores. */ +export interface QwpIngressReplayReference { + readonly frameSequence: bigint; + readonly payloadLength: number; +} + /** Browser-safe abstraction; Node supplies a persistent filesystem implementation. */ export interface QwpIngressReplayStore { load(): Promise; + /** + * Opens and validates the journal without materializing every payload. + * Implementations that provide this must also provide `readPayload`. + */ + loadReferences?(): Promise; + /** Reads one previously loaded durable payload on demand. */ + readPayload?(frameSequence: bigint): Promise; append(record: QwpIngressReplayRecord): Promise; acknowledgeThrough(frameSequence: bigint): Promise; /** Loads the durable, dense symbol prefix used by persisted delta frames. */ diff --git a/test/qwp/reconnect.test.ts b/test/qwp/reconnect.test.ts index 89e4a35..8c7ec60 100644 --- a/test/qwp/reconnect.test.ts +++ b/test/qwp/reconnect.test.ts @@ -49,6 +49,7 @@ import { QwpIngressSession, QwpIngressSessionClosedError, QwpIngressReplayRecord, + QwpIngressReplayReference, QwpIngressReplayStore, QwpHandshakeMetadata, QwpMemoryReplayAppendTimeoutError, @@ -262,6 +263,30 @@ class TrackingReplayStore implements QwpIngressReplayStore { } } +class LazyTrackingReplayStore extends TrackingReplayStore { + readonly reads: bigint[] = []; + loadCalls = 0; + + override async load(): Promise { + this.loadCalls++; + throw new Error("eager replay load must not be used"); + } + + async loadReferences(): Promise { + return Array.from(this.records, ([frameSequence, payload]) => ({ + frameSequence, + payloadLength: payload.byteLength, + })); + } + + async readPayload(frameSequence: bigint): Promise { + this.reads.push(frameSequence); + const payload = this.records.get(frameSequence); + if (!payload) throw new Error(`missing replay frame ${frameSequence}`); + return payload.slice(); + } +} + class FailOnceDictionaryReplayStore extends TrackingReplayStore { readonly symbols: string[] = []; appendAttempts = 0; @@ -963,6 +988,51 @@ describe("QWP ingress reconnect and replay", () => { await session.close(); }); + it("drops background payloads after persistence and reads them lazily for drain", async () => { + const connection = new FakeConnection("primary"); + const replayStore = new LazyTrackingReplayStore(); + let releaseOnline!: () => void; + const online = new Promise((resolve) => { + releaseOnline = resolve; + }); + let factoryCalls = 0; + const session = await QwpIngressSession.connect( + async () => { + if (factoryCalls++ === 0) { + throw new QwpUpgradeError("offline", { + kind: QWP_UPGRADE_ERROR_KIND.TRANSPORT, + retryable: true, + tryNextEndpoint: true, + }); + } + await online; + return connection; + }, + { + backgroundStoreAndForward: true, + reconnect: { + maxAttempts: 0, + maxDurationMs: 0, + initialBackoffMs: 0, + maxBackoffMs: 0, + }, + replayStore, + }, + ); + + await session.publishFrame(Uint8Array.of(1)); + await session.publishFrame(Uint8Array.of(2)); + expect(replayStore.loadCalls).toBe(0); + expect(replayStore.reads).toEqual([]); + + releaseOnline(); + await vi.waitFor(() => + expect(connection.sent).toEqual([Uint8Array.of(1), Uint8Array.of(2)]), + ); + expect(replayStore.reads).toEqual([0n, 1n]); + await session.close(); + }); + it("keeps an asynchronous initial authentication rejection terminal", async () => { const replayStore = new TrackingReplayStore(); let factoryCalls = 0; @@ -3206,6 +3276,29 @@ describe("QWP Node file replay store", () => { await third.close(); }); + it("indexes recovered frames without materializing their payloads", async () => { + const directory = await trackedDirectory(); + const seed = new QwpNodeFileReplayStore({ directory }); + await seed.load(); + await seed.append({ + frameSequence: 0n, + payload: Uint8Array.of(1, 2, 3), + }); + await seed.append({ frameSequence: 1n, payload: Uint8Array.of(4) }); + await seed.close(); + + const recovered = new QwpNodeFileReplayStore({ directory }); + await expect(recovered.loadReferences()).resolves.toEqual([ + { frameSequence: 0n, payloadLength: 3 }, + { frameSequence: 1n, payloadLength: 1 }, + ]); + await expect(recovered.readPayload(1n)).resolves.toEqual(Uint8Array.of(4)); + await expect(recovered.readPayload(2n)).rejects.toThrow( + /frame is not available/, + ); + await recovered.close(); + }); + it("detects a replay gap immediately after a persisted ACK watermark", async () => { const directory = await trackedDirectory(); const first = new QwpNodeFileReplayStore({ From 5579c5ee387e882ad66b6fb05cff07933c6caa6a Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 19 Aug 2026 22:38:03 +0100 Subject: [PATCH 085/265] test(qwp): add performance benchmark suite --- QWP.md | 8 ++ benchmarks/README.md | 86 +++++++++++++++ benchmarks/e2e.ts | 179 ++++++++++++++++++++++++++++++++ benchmarks/egress.bench.ts | 144 +++++++++++++++++++++++++ benchmarks/encoder.bench.ts | 90 ++++++++++++++++ benchmarks/floors.test.ts | 22 ++++ benchmarks/floors.ts | 38 +++++++ benchmarks/persistence.bench.ts | 169 ++++++++++++++++++++++++++++++ benchmarks/sender.bench.ts | 162 +++++++++++++++++++++++++++++ benchmarks/tables.ts | 29 ++++++ benchmarks/validate.test.ts | 92 ++++++++++++++++ benchmarks/workloads.test.ts | 47 +++++++++ benchmarks/workloads.ts | 132 +++++++++++++++++++++++ package.json | 5 + tsconfig.bench.json | 7 ++ vitest.bench-e2e.config.ts | 9 ++ 16 files changed, 1219 insertions(+) create mode 100644 benchmarks/README.md create mode 100644 benchmarks/e2e.ts create mode 100644 benchmarks/egress.bench.ts create mode 100644 benchmarks/encoder.bench.ts create mode 100644 benchmarks/floors.test.ts create mode 100644 benchmarks/floors.ts create mode 100644 benchmarks/persistence.bench.ts create mode 100644 benchmarks/sender.bench.ts create mode 100644 benchmarks/tables.ts create mode 100644 benchmarks/validate.test.ts create mode 100644 benchmarks/workloads.test.ts create mode 100644 benchmarks/workloads.ts create mode 100644 tsconfig.bench.json create mode 100644 vitest.bench-e2e.config.ts diff --git a/QWP.md b/QWP.md index fc62282..01bcdcc 100644 --- a/QWP.md +++ b/QWP.md @@ -1154,6 +1154,14 @@ later JavaScript event-loop turns. This keeps user callbacks out of protocol cal but CPU-bound callback code still blocks the runtime and belongs in a Worker or `worker_threads` task. +## Development benchmarks + +The repository includes diagnostic QWP benchmarks for ingress encoding, fluent sender +construction, symbol dictionaries, egress materialization and reusable views, Zstd, +store-and-forward persistence/recovery, and live completion-boundary latency. See +[`benchmarks/README.md`](benchmarks/README.md) for commands and result interpretation. +They are intentionally not CI performance gates. + ## Public API policy Only the four package entry points listed at the top are public. In particular, diff --git a/benchmarks/README.md b/benchmarks/README.md new file mode 100644 index 0000000..865e90c --- /dev/null +++ b/benchmarks/README.md @@ -0,0 +1,86 @@ +# QWP benchmarks + +These benchmarks are diagnostic tools, not CI performance gates. Run them on a +quiet, pinned machine and compare commits on the same host. + +```bash +# Encoder, high-level sender, egress, and store-and-forward benchmarks. +pnpm bench + +# Live QuestDB on localhost:9000. +pnpm bench:e2e + +# Override the live server and sample sizes. +QDB_ADDR=host:9000 BENCH_ROWS=10000 pnpm bench:e2e + +# Use real storage for persistence measurements. +QWP_BENCH_DIR=/var/tmp/qwp-bench pnpm bench + +pnpm typecheck:bench +pnpm lint:bench +pnpm format:bench +pnpm vitest run benchmarks/*.test.ts +``` + +Set `QWP_BENCH_DURABLE_ACK=1` for an additional live durable-ACK arm. The +server must advertise durable acknowledgements. The default E2E run measures +local WebSocket publication, protocol ACK, and local store-and-forward append +as separate completion contracts. + +## Workloads + +- `trades`: one low-cardinality symbol, two doubles, and a designated timestamp. +- `wide`: 50 data columns across symbol, long, double, and varchar families. +- `sparse`: eight potential long columns with deterministic 30% nulls. +- `highCardinalitySymbols`: one distinct symbol per row up to 100,000 values. + +All workloads use a deterministic xorshift generator. Encoder floors perform +only the minimum int64, UTF-8, or `Map` work, without QWP schema, null, or frame +overhead. They are comparison baselines, not performance targets. + +## Reading Vitest output + +Vitest reports benchmark callbacks per second (`hz`): + +- encoder and sender callbacks process 10,000 rows; +- materialized/view egress callbacks process 10,000 rows; +- the compressed egress callback processes 100 rows; +- persistence append callbacks write 100 4 KiB frames. + +Multiply `hz` by the corresponding unit count before reporting rows or appends +per second. Check `rme` before treating small differences as meaningful. + +The persistence suite prints a 256 MiB write-and-fsync baseline. It benchmarks +all three file-store policies: + +- `memory`: file writes relying on operating-system page-cache writeback; +- `periodic`: file writes plus background checkpoints; +- `append`: a persistence barrier after every frame. + +It also measures full recovery of 1,000 frame references and lazy 4 KiB payload +reads. Segment rolls, hot-spare provisioning, checkpoint timers, and background +trimming can make the distribution bimodal. Report percentiles or the complete +distribution rather than only its mean. + +Each append callback also acknowledges its preceding prefix, retaining one live +record so the journal remains in steady state without exhausting its configured +capacity. The reported number therefore includes normal ACK bookkeeping and trim +scheduling. + +Confirm that `QWP_BENCH_DIR` is not tmpfs before describing any result as disk +performance. The directory and the baseline must live on the same filesystem. + +## E2E completion boundaries + +The live benchmark flushes every measured row so each sample contains a real +completion boundary. Its arms are deliberately not interchangeable: + +- local publication means the WebSocket accepted the frame; +- protocol ACK means QuestDB accepted the frame; +- local SF append means the frame crossed the configured local persistence + boundary; +- optional durable ACK means the server reported durable upload. + +Each repetition uses a disjoint timestamp range. Store-and-forward repetitions +also use distinct sender IDs so recovered dictionaries and replay slots do not +turn later repetitions into warm-recovery measurements. diff --git a/benchmarks/e2e.ts b/benchmarks/e2e.ts new file mode 100644 index 0000000..82dcb76 --- /dev/null +++ b/benchmarks/e2e.ts @@ -0,0 +1,179 @@ +import { it } from "vitest"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Sender } from "../src"; +import { BENCHMARK_WORKLOADS } from "./workloads"; + +const ADDRESS = process.env.QDB_ADDR ?? "localhost:9000"; +const ROWS = Number(process.env.BENCH_ROWS ?? 5000); +const WARMUP_ROWS = Number(process.env.BENCH_WARMUP_ROWS ?? 500); +const REPEATS = Number(process.env.BENCH_REPEATS ?? 3); +const TIMESTAMP_STRIDE = 1_000_000_000n; +const SF_ROOT = process.env.QWP_BENCH_DIR ?? tmpdir(); + +type SenderExtraOptions = NonNullable[1]>; + +interface ArmOptions { + label: string; + table: string; + configuration: (repeat: number, sfDirectory: string) => string; + extraOptions?: SenderExtraOptions; +} + +function positiveInteger(value: number, name: string): number { + if (!Number.isSafeInteger(value) || value < 1) { + throw new RangeError(`${name} must be a positive safe integer`); + } + return value; +} + +function nonNegativeInteger(value: number, name: string): number { + if (!Number.isSafeInteger(value) || value < 0) { + throw new RangeError(`${name} must be a non-negative safe integer`); + } + return value; +} + +function percentile( + sorted: readonly number[], + percentileValue: number, +): number { + if (sorted.length === 0) return Number.NaN; + const rank = Math.ceil((percentileValue / 100) * sorted.length) - 1; + return sorted[Math.max(0, Math.min(sorted.length - 1, rank))]; +} + +async function measureArm( + options: ArmOptions, + repeat: number, + sfDirectory: string, +): Promise { + const sender = await Sender.fromConfig( + options.configuration(repeat, sfDirectory), + options.extraOptions, + ); + const samples: number[] = []; + const timestampOffset = BigInt(repeat) * TIMESTAMP_STRIDE; + try { + await sender.connect(); + const allRows = BENCHMARK_WORKLOADS.trades.rows(WARMUP_ROWS + ROWS); + for (const row of allRows.slice(0, WARMUP_ROWS)) { + sender + .table(options.table) + .symbol("symbol", row.symbols[0][1]) + .floatColumn("price", row.doubles[0][1]) + .floatColumn("amount", row.doubles[1][1]); + await sender.at(row.timestamp + timestampOffset); + } + await sender.flush(); + + for (const row of allRows.slice(WARMUP_ROWS)) { + sender + .table(options.table) + .symbol("symbol", row.symbols[0][1]) + .floatColumn("price", row.doubles[0][1]) + .floatColumn("amount", row.doubles[1][1]); + await sender.at(row.timestamp + timestampOffset); + const started = process.hrtime.bigint(); + await sender.flush(); + samples.push(Number(process.hrtime.bigint() - started) / 1000); + } + } finally { + await sender.close(); + } + return samples.sort((left, right) => left - right); +} + +function report(label: string, runs: readonly number[][]): void { + console.log(`\n${label}`); + for (const value of [50, 90, 99, 99.9]) { + const samples = runs.map((run) => percentile(run, value)); + const minimum = Math.min(...samples).toFixed(1); + const maximum = Math.max(...samples).toFixed(1); + console.log( + ` p${value}\t${minimum} - ${maximum} us (${runs.length} repeats)`, + ); + } +} + +it("measures QWP ingress completion boundaries", async () => { + positiveInteger(ROWS, "BENCH_ROWS"); + nonNegativeInteger(WARMUP_ROWS, "BENCH_WARMUP_ROWS"); + positiveInteger(REPEATS, "BENCH_REPEATS"); + + await mkdir(SF_ROOT, { recursive: true }); + const sfDirectory = await mkdtemp(join(SF_ROOT, "qwp-bench-e2e-")); + console.log( + `QWP E2E latency: ${ADDRESS}, ${ROWS} rows, ${REPEATS} repeats per arm`, + ); + console.log(`SF directory: ${sfDirectory}`); + console.log( + `Verify real storage before quoting SF results: df -T ${SF_ROOT}`, + ); + + const arms: ArmOptions[] = [ + { + label: "flush() = local WebSocket publication", + table: "bench_e2e_publication", + configuration: () => `ws::addr=${ADDRESS};auto_flush=off`, + extraOptions: { + qwp: { sender: { autoFlush: false, closeFlushTimeoutMs: 0 } }, + }, + }, + { + label: "flush() = server protocol ACK", + table: "bench_e2e_ack", + configuration: () => `ws::addr=${ADDRESS};auto_flush=off`, + extraOptions: { + qwp: { + sender: { + autoFlush: false, + awaitServerAck: true, + closeFlushTimeoutMs: 0, + }, + }, + }, + }, + { + label: "flush() = local SF append durability", + table: "bench_e2e_sf", + configuration: (repeat, directory) => + `ws::addr=${ADDRESS};auto_flush=off;sf_dir=${directory};` + + `sender_id=bench-${repeat};sf_durability=append`, + extraOptions: { + qwp: { sender: { autoFlush: false, closeFlushTimeoutMs: 0 } }, + }, + }, + ]; + + if (process.env.QWP_BENCH_DURABLE_ACK === "1") { + arms.push({ + label: "flush() = server durable ACK", + table: "bench_e2e_durable_ack", + configuration: () => + `ws::addr=${ADDRESS};auto_flush=off;request_durable_ack=on`, + extraOptions: { + qwp: { + sender: { + autoFlush: false, + awaitDurableAck: true, + closeFlushTimeoutMs: 0, + }, + }, + }, + }); + } + + try { + for (const arm of arms) { + const runs: number[][] = []; + for (let repeat = 0; repeat < REPEATS; repeat++) { + runs.push(await measureArm(arm, repeat, sfDirectory)); + } + report(arm.label, runs); + } + } finally { + await rm(sfDirectory, { recursive: true, force: true }); + } +}); diff --git a/benchmarks/egress.bench.ts b/benchmarks/egress.bench.ts new file mode 100644 index 0000000..a899115 --- /dev/null +++ b/benchmarks/egress.bench.ts @@ -0,0 +1,144 @@ +import { bench, describe } from "vitest"; +import { + decodeQwpEgressMessage, + encodeQwpFrame, + encodeQwpGorilla, + QWP_COLUMN_TYPE, + QWP_EGRESS_MESSAGE, + QWP_FLAG_DELTA_SYMBOL_DICTIONARY, + QWP_FLAG_GORILLA, + QWP_FLAG_ZSTD, + QwpByteWriter, + QwpResultBatchDecoder, + writeQwpVarint, +} from "../src/qwp/core"; + +const ROWS = 10_000; +let sink = 0; + +function writeString(writer: QwpByteWriter, value: string): void { + const bytes = new TextEncoder().encode(value); + writeQwpVarint(writer, bytes.byteLength); + writer.writeBytes(bytes); +} + +function resultFrame(rowCount: number): Uint8Array { + const payload = new QwpByteWriter(); + payload.writeUint8(QWP_EGRESS_MESSAGE.RESULT_BATCH).writeBigUint64(1n); + writeQwpVarint(payload, 0); // batch sequence + writeString(payload, "bench_result"); + writeQwpVarint(payload, rowCount); + writeQwpVarint(payload, 4); + for (const [name, type] of [ + ["id", QWP_COLUMN_TYPE.INT], + ["price", QWP_COLUMN_TYPE.DOUBLE], + ["name", QWP_COLUMN_TYPE.VARCHAR], + ["timestamp", QWP_COLUMN_TYPE.TIMESTAMP], + ] as const) { + writeString(payload, name); + payload.writeUint8(type); + } + + payload.writeUint8(0); // no INT nulls + for (let row = 0; row < rowCount; row++) payload.writeInt32(row); + + payload.writeUint8(0); // no DOUBLE nulls + for (let row = 0; row < rowCount; row++) { + payload.writeFloat64(1000 + (row % 1000) / 10); + } + + payload.writeUint8(0); // no VARCHAR nulls + const text = Array.from( + { length: rowCount }, + (_, row) => `value-${row % 100}`, + ); + let textOffset = 0; + payload.writeUint32(0); + for (const value of text) { + textOffset += new TextEncoder().encode(value).byteLength; + payload.writeUint32(textOffset); + } + for (const value of text) payload.writeUtf8(value); + + payload.writeUint8(0).writeUint8(1); // no nulls, Gorilla encoded + payload.writeBytes( + encodeQwpGorilla( + Array.from( + { length: rowCount }, + (_, row) => 1_700_000_000_000_000n + BigInt(row) * 1000n, + ), + ), + ); + return encodeQwpFrame(payload.toUint8Array(), QWP_FLAG_GORILLA, 1); +} + +// A standard Zstd frame containing a 100-row QWP INT result body. +const COMPRESSED_INT_RESULT_BODY = Uint8Array.from([ + 40, 181, 47, 253, 96, 153, 0, 157, 0, 0, 96, 0, 0, 0, 100, 1, 1, 120, 4, 0, + 42, 0, 0, 1, 0, 138, 171, 46, 9, +]); + +function compressedResultFrame(): Uint8Array { + const payload = new QwpByteWriter(); + payload.writeUint8(QWP_EGRESS_MESSAGE.RESULT_BATCH).writeBigUint64(1n); + writeQwpVarint(payload, 0); + payload.writeBytes(COMPRESSED_INT_RESULT_BODY); + return encodeQwpFrame( + payload.toUint8Array(), + QWP_FLAG_DELTA_SYMBOL_DICTIONARY | QWP_FLAG_ZSTD, + 1, + ); +} + +const decoded = decodeQwpEgressMessage(resultFrame(ROWS)); +if (decoded.kind !== "result-batch") { + throw new Error("benchmark frame is not a result batch"); +} +const compressed = decodeQwpEgressMessage(compressedResultFrame()); +if (compressed.kind !== "result-batch") { + throw new Error("compressed benchmark frame is not a result batch"); +} + +describe("QWP egress batch decoding", () => { + bench(`materialized / ${ROWS} rows`, () => { + const batch = new QwpResultBatchDecoder().decode(decoded); + sink += batch.rowCount + batch.columns.length; + }); + + const viewDecoder = new QwpResultBatchDecoder(); + bench(`reusable column views / ${ROWS} rows`, () => { + viewDecoder.resetQuerySchema(); + const batch = viewDecoder.decodeView(decoded); + sink += batch.rowCount + batch.columnCount; + }); + + const columnDecoder = new QwpResultBatchDecoder(); + bench(`column-view traversal / ${ROWS} rows`, () => { + columnDecoder.resetQuerySchema(); + const batch = columnDecoder.decodeView(decoded); + const ids = batch.column(0); + const prices = batch.column(1); + const names = batch.column(2); + for (let row = 0; row < batch.rowCount; row++) { + sink += ids.getInt(row) + prices.getDouble(row); + sink += names.getString(row)?.length ?? 0; + } + }); + + const rowDecoder = new QwpResultBatchDecoder(); + bench(`row-view traversal / ${ROWS} rows`, () => { + rowDecoder.resetQuerySchema(); + const batch = rowDecoder.decodeView(decoded); + batch.forEachRow((row) => { + sink += row.getInt(0) + row.getDouble(1); + sink += row.getString(2)?.length ?? 0; + }); + }); + + bench("Zstd decompress + materialize / 100 INT rows", () => { + const batch = new QwpResultBatchDecoder().decode(compressed); + sink += batch.rowCount + Number(batch.columns[0].values[0]); + }); +}); + +export const egressBenchmarkSink = (): number => sink; diff --git a/benchmarks/encoder.bench.ts b/benchmarks/encoder.bench.ts new file mode 100644 index 0000000..327fec0 --- /dev/null +++ b/benchmarks/encoder.bench.ts @@ -0,0 +1,90 @@ +import { bench, describe } from "vitest"; +import { + encodeQwpIngressFrame, + QWP_COLUMN_TYPE, + QwpSymbolDictionary, + QwpTableBuffer, +} from "../src/qwp/core"; +import { + floorInternSymbols, + floorWriteLongs, + floorWriteStrings, +} from "./floors"; +import { buildBenchmarkTable } from "./tables"; +import { BENCHMARK_WORKLOADS } from "./workloads"; + +const ROWS = 10_000; +let sink = 0; + +describe("QWP ingress frame encoder", () => { + for (const name of ["trades", "wide", "sparse"] as const) { + const table = buildBenchmarkTable(BENCHMARK_WORKLOADS[name].rows(ROWS)); + bench(`${name} / Gorilla off`, () => { + sink += encodeQwpIngressFrame([table], { gorilla: false }).byteLength; + }); + bench(`${name} / Gorilla on`, () => { + sink += encodeQwpIngressFrame([table], { gorilla: true }).byteLength; + }); + } +}); + +describe("encoder floors", () => { + const longs = BENCHMARK_WORKLOADS.sparse + .rows(ROWS) + .flatMap((row) => row.longs.map(([, value]) => value)) + .slice(0, ROWS); + const longTable = new QwpTableBuffer("floor_long"); + for (const value of longs) { + longTable + .getOrCreateColumn("value", QWP_COLUMN_TYPE.LONG) + ?.values.push(value); + longTable.nextRow(); + } + + bench("floor / writeBigInt64LE", () => { + sink += floorWriteLongs(longs).byteLength; + }); + bench("QWP / single long column", () => { + sink += encodeQwpIngressFrame([longTable], { + gorilla: false, + }).byteLength; + }); + + const strings = BENCHMARK_WORKLOADS.wide + .rows(ROWS) + .flatMap((row) => row.strings.map(([, value]) => value)) + .slice(0, ROWS); + const stringTable = new QwpTableBuffer("floor_varchar"); + for (const value of strings) { + stringTable + .getOrCreateColumn("value", QWP_COLUMN_TYPE.VARCHAR) + ?.values.push(value); + stringTable.nextRow(); + } + + bench("floor / UTF-8 write", () => { + sink += floorWriteStrings(strings).byteLength; + }); + bench("QWP / single varchar column", () => { + sink += encodeQwpIngressFrame([stringTable], { + gorilla: false, + }).byteLength; + }); +}); + +describe("symbol interning", () => { + const symbols = BENCHMARK_WORKLOADS.highCardinalitySymbols + .rows(ROWS) + .map((row) => row.symbols[0][1]); + + bench("floor / Map", () => { + sink += floorInternSymbols(symbols).length; + }); + bench("QwpSymbolDictionary.getOrAdd", () => { + const dictionary = new QwpSymbolDictionary(); + for (const symbol of symbols) dictionary.getOrAdd(symbol); + sink += dictionary.size; + }); +}); + +export const benchmarkSink = (): number => sink; diff --git a/benchmarks/floors.test.ts b/benchmarks/floors.test.ts new file mode 100644 index 0000000..6c39bf0 --- /dev/null +++ b/benchmarks/floors.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { + floorInternSymbols, + floorWriteLongs, + floorWriteStrings, +} from "./floors"; + +describe("benchmark floors", () => { + it("writes eight bytes per long", () => { + const bytes = floorWriteLongs([1n, 2n, 3n]); + expect(bytes).toHaveLength(24); + expect(new DataView(bytes.buffer).getBigInt64(8, true)).toBe(2n); + }); + + it("writes UTF-8 values back to back", () => { + expect(floorWriteStrings(["ab", "cd"]).toString("utf8")).toBe("abcd"); + }); + + it("interns symbols to dense IDs", () => { + expect(floorInternSymbols(["a", "b", "a"])).toEqual([0, 1, 0]); + }); +}); diff --git a/benchmarks/floors.ts b/benchmarks/floors.ts new file mode 100644 index 0000000..39c3f50 --- /dev/null +++ b/benchmarks/floors.ts @@ -0,0 +1,38 @@ +import { Buffer } from "node:buffer"; + +/** Minimum byte movement for a flat int64 column, without QWP framing. */ +export function floorWriteLongs(values: readonly bigint[]): Uint8Array { + const bytes = new Uint8Array(values.length * 8); + const view = new DataView(bytes.buffer); + let offset = 0; + for (const value of values) { + view.setBigInt64(offset, BigInt.asIntN(64, value), true); + offset += 8; + } + return bytes; +} + +/** Minimum UTF-8 copying work, without offsets, nulls, or QWP framing. */ +export function floorWriteStrings(values: readonly string[]): Buffer { + let length = 0; + for (const value of values) length += Buffer.byteLength(value, "utf8"); + const bytes = Buffer.allocUnsafe(length); + let offset = 0; + for (const value of values) offset += bytes.write(value, offset, "utf8"); + return bytes; +} + +/** Naive per-row symbol interning baseline. */ +export function floorInternSymbols(values: readonly string[]): number[] { + const ids = new Map(); + const result: number[] = []; + for (const value of values) { + let id = ids.get(value); + if (id === undefined) { + id = ids.size; + ids.set(value, id); + } + result.push(id); + } + return result; +} diff --git a/benchmarks/persistence.bench.ts b/benchmarks/persistence.bench.ts new file mode 100644 index 0000000..23b13f1 --- /dev/null +++ b/benchmarks/persistence.bench.ts @@ -0,0 +1,169 @@ +import { beforeAll, bench, describe } from "vitest"; +import { mkdtemp, mkdir, open, rm, unlink } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + QWP_SF_DURABILITY, + QwpNodeFileReplayStore, + type QwpSfDurability, +} from "../src/qwp-node/file-replay-store"; + +const FRAME = new Uint8Array(4096).fill(0x41); +const APPENDS = 100; +const RECOVERY_FRAMES = 1000; +const MAX_BYTES = 64 * 1024 * 1024; +const MAX_SEGMENT_BYTES = 4 * 1024 * 1024; + +interface StoreState { + store: QwpNodeFileReplayStore; + nextSequence: bigint; +} + +let root: string; +let pageCache: StoreState; +let periodic: StoreState; +let durable: StoreState; +let recoveryDirectory: string; +let lazyReader: QwpNodeFileReplayStore; +let lazySequences: bigint[]; +let lazyCursor = 0; +let sink = 0; + +async function diskBaseline(directory: string): Promise { + const path = join(directory, "disk-baseline.tmp"); + const file = await open(path, "wx", 0o600); + const block = new Uint8Array(4 * 1024 * 1024); + const blocks = 64; + const started = process.hrtime.bigint(); + try { + for (let index = 0; index < blocks; index++) await file.write(block); + await file.sync(); + } finally { + await file.close(); + await unlink(path).catch(() => undefined); + } + const seconds = Number(process.hrtime.bigint() - started) / 1e9; + const mebibytes = (block.byteLength * blocks) / (1024 * 1024); + console.log( + `[disk baseline] ${mebibytes} MiB write + fsync: ${(mebibytes / seconds).toFixed(1)} MiB/s`, + ); +} + +async function createStore( + name: string, + durability: QwpSfDurability, +): Promise { + const directory = join(root, name); + const store = new QwpNodeFileReplayStore({ + directory, + durability, + checkpointIntervalMs: + durability === QWP_SF_DURABILITY.PERIODIC ? 1000 : undefined, + maxBytes: MAX_BYTES, + maxSegmentBytes: MAX_SEGMENT_BYTES, + }); + await store.loadReferences(); + return { store, nextSequence: 0n }; +} + +async function appendBatch(state: StoreState): Promise { + for (let index = 0; index < APPENDS; index++) { + await state.store.append({ + frameSequence: state.nextSequence++, + payload: FRAME, + }); + } + // Keep one record live so repeated iterations exercise steady-state segment + // use instead of retiring the active segment after every benchmark body. + await state.store.acknowledgeThrough(state.nextSequence - 2n); +} + +async function seedBacklog(directory: string): Promise { + const store = new QwpNodeFileReplayStore({ + directory, + durability: QWP_SF_DURABILITY.MEMORY, + maxBytes: MAX_BYTES, + maxSegmentBytes: MAX_SEGMENT_BYTES, + }); + await store.loadReferences(); + for (let index = 0; index < RECOVERY_FRAMES; index++) { + await store.append({ frameSequence: BigInt(index), payload: FRAME }); + } + await store.close(); +} + +beforeAll(async () => { + const configuredRoot = process.env.QWP_BENCH_DIR ?? tmpdir(); + await mkdir(configuredRoot, { recursive: true }); + root = await mkdtemp(join(configuredRoot, "qwp-bench-")); + console.log(`[store-and-forward] benchmark directory: ${root}`); + console.log( + `[store-and-forward] verify real storage when quoting results: df -T ${configuredRoot}`, + ); + await diskBaseline(root); + + [pageCache, periodic, durable] = await Promise.all([ + createStore("page-cache", QWP_SF_DURABILITY.MEMORY), + createStore("periodic", QWP_SF_DURABILITY.PERIODIC), + createStore("append", QWP_SF_DURABILITY.APPEND), + ]); + + recoveryDirectory = join(root, "recovery"); + const lazyDirectory = join(root, "lazy-read"); + await seedBacklog(recoveryDirectory); + await seedBacklog(lazyDirectory); + lazyReader = new QwpNodeFileReplayStore({ + directory: lazyDirectory, + durability: QWP_SF_DURABILITY.MEMORY, + maxBytes: MAX_BYTES, + maxSegmentBytes: MAX_SEGMENT_BYTES, + }); + lazySequences = (await lazyReader.loadReferences()).map( + (reference) => reference.frameSequence, + ); + + return async () => { + await Promise.all([ + pageCache.store.close(), + periodic.store.close(), + durable.store.close(), + lazyReader.close(), + ]); + await rm(root, { recursive: true, force: true }); + }; +}); + +describe(`QwpNodeFileReplayStore / ${APPENDS} appends`, () => { + bench("durability=memory (page-cache write)", async () => { + await appendBatch(pageCache); + }); + + bench("durability=periodic", async () => { + await appendBatch(periodic); + }); + + bench("durability=append (fsync per frame)", async () => { + await appendBatch(durable); + }); +}); + +describe("store-and-forward recovery", () => { + bench(`recover ${RECOVERY_FRAMES} frame references`, async () => { + const store = new QwpNodeFileReplayStore({ + directory: recoveryDirectory, + durability: QWP_SF_DURABILITY.MEMORY, + maxBytes: MAX_BYTES, + maxSegmentBytes: MAX_SEGMENT_BYTES, + }); + const references = await store.loadReferences(); + sink += references.length; + await store.close(); + }); + + bench("lazy read / 4 KiB payload", async () => { + const sequence = lazySequences[lazyCursor++ % lazySequences.length]; + sink += (await lazyReader.readPayload(sequence)).byteLength; + }); +}); + +export const persistenceBenchmarkSink = (): number => sink; diff --git a/benchmarks/sender.bench.ts b/benchmarks/sender.bench.ts new file mode 100644 index 0000000..09d29b2 --- /dev/null +++ b/benchmarks/sender.bench.ts @@ -0,0 +1,162 @@ +import { beforeAll, bench, describe } from "vitest"; +import { + encodeQwpIngressFrame, + QWP_STATUS, + QwpSymbolDictionary, + type QwpIngressEncodeOptions, + type QwpIngressResponse, + type QwpTableBuffer, +} from "../src/qwp/core"; +import { QwpSender, type QwpSenderSession } from "../src/qwp/sender"; +import { BENCHMARK_WORKLOADS, type BenchmarkRow } from "./workloads"; + +const ROWS = 10_000; +let sink = 0; + +class EncodingSession implements QwpSenderSession { + private readonly dictionary = new QwpSymbolDictionary(); + private confirmedMaxSymbolId = -1; + private publishedSequence = -1n; + + get publishedFrameSequence(): bigint { + return this.publishedSequence; + } + + get acknowledgedFrameSequence(): bigint { + return this.publishedSequence; + } + + async sendTables( + tables: readonly QwpTableBuffer[], + options: QwpIngressEncodeOptions = {}, + ): Promise { + this.encode(tables, options); + return this.response(); + } + + async sendTablesDelta( + tables: readonly QwpTableBuffer[], + options: Pick = {}, + ): Promise { + this.encodeDelta(tables, options); + return this.response(); + } + + async publishTables( + tables: readonly QwpTableBuffer[], + options: QwpIngressEncodeOptions = {}, + ): Promise { + this.encode(tables, options); + } + + async publishTablesDelta( + tables: readonly QwpTableBuffer[], + options: Pick = {}, + ): Promise { + this.encodeDelta(tables, options); + } + + async waitForDurable(): Promise {} + + async close(): Promise {} + + private encode( + tables: readonly QwpTableBuffer[], + options: QwpIngressEncodeOptions, + ): void { + sink += encodeQwpIngressFrame(tables, options).byteLength; + this.publishedSequence++; + } + + private encodeDelta( + tables: readonly QwpTableBuffer[], + options: Pick, + ): void { + sink += encodeQwpIngressFrame(tables, { + ...options, + dictionary: this.dictionary, + confirmedMaxSymbolId: this.confirmedMaxSymbolId, + }).byteLength; + this.confirmedMaxSymbolId = this.dictionary.size - 1; + this.publishedSequence++; + } + + private response(): QwpIngressResponse { + return { + status: QWP_STATUS.OK, + sequence: this.publishedSequence, + tables: [], + }; + } +} + +async function fillSender( + sender: QwpSender, + rows: readonly BenchmarkRow[], +): Promise { + for (const row of rows) { + sender.table(row.table); + for (const [name, value] of row.symbols) sender.symbol(name, value); + for (const [name, value] of row.longs) sender.longColumn(name, value); + for (const [name, value] of row.doubles) { + sender.doubleColumn(name, value); + } + for (const [name, value] of row.strings) { + sender.stringColumn(name, value); + } + await sender.at(row.timestamp); + } +} + +function senderFor( + session: EncodingSession, + symbolDictionary: "delta" | "full", +): QwpSender { + return new QwpSender(async () => session, { + autoFlush: false, + closeFlushTimeoutMs: 0, + encode: { symbolDictionary }, + }); +} + +describe("high-level QwpSender build and encode", () => { + for (const name of ["trades", "wide", "sparse"] as const) { + const rows = BENCHMARK_WORKLOADS[name].rows(ROWS); + bench(name, async () => { + const sender = senderFor(new EncodingSession(), "full"); + await fillSender(sender, rows); + await sender.flush(); + }); + } +}); + +describe("high-level symbol dictionary modes", () => { + const rows = BENCHMARK_WORKLOADS.highCardinalitySymbols.rows(ROWS); + const steadySession = new EncodingSession(); + + beforeAll(async () => { + const sender = senderFor(steadySession, "delta"); + await fillSender(sender, rows); + await sender.flush(); + }); + + bench("full dictionary", async () => { + const sender = senderFor(new EncodingSession(), "full"); + await fillSender(sender, rows); + await sender.flush(); + }); + + bench("delta dictionary / cold", async () => { + const sender = senderFor(new EncodingSession(), "delta"); + await fillSender(sender, rows); + await sender.flush(); + }); + + bench("delta dictionary / confirmed steady state", async () => { + const sender = senderFor(steadySession, "delta"); + await fillSender(sender, rows); + await sender.flush(); + }); +}); + +export const senderBenchmarkSink = (): number => sink; diff --git a/benchmarks/tables.ts b/benchmarks/tables.ts new file mode 100644 index 0000000..2a62391 --- /dev/null +++ b/benchmarks/tables.ts @@ -0,0 +1,29 @@ +import { QWP_COLUMN_TYPE, QwpTableBuffer } from "../src/qwp/core"; +import type { BenchmarkRow } from "./workloads"; + +export function buildBenchmarkTable( + rows: readonly BenchmarkRow[], +): QwpTableBuffer { + const table = new QwpTableBuffer(rows[0].table); + for (const row of rows) { + for (const [name, value] of row.symbols) { + table.getOrCreateColumn(name, QWP_COLUMN_TYPE.SYMBOL)?.values.push(value); + } + for (const [name, value] of row.longs) { + table.getOrCreateColumn(name, QWP_COLUMN_TYPE.LONG)?.values.push(value); + } + for (const [name, value] of row.doubles) { + table.getOrCreateColumn(name, QWP_COLUMN_TYPE.DOUBLE)?.values.push(value); + } + for (const [name, value] of row.strings) { + table + .getOrCreateColumn(name, QWP_COLUMN_TYPE.VARCHAR) + ?.values.push(value); + } + table + .getOrCreateColumn("", QWP_COLUMN_TYPE.TIMESTAMP) + ?.values.push(row.timestamp); + table.nextRow(); + } + return table; +} diff --git a/benchmarks/validate.test.ts b/benchmarks/validate.test.ts new file mode 100644 index 0000000..8bfe8cf --- /dev/null +++ b/benchmarks/validate.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; +import { + encodeQwpIngressFrame, + QWP_COLUMN_TYPE, + QwpSymbolDictionary, + QwpTableBuffer, +} from "../src/qwp/core"; +import { buildBenchmarkTable } from "./tables"; +import { BENCHMARK_WORKLOADS, type BenchmarkRow } from "./workloads"; + +const BASE_TIMESTAMP = 1_700_000_000_000_000n; + +function encode( + rows: readonly BenchmarkRow[], + dictionary?: QwpSymbolDictionary, + confirmedMaxSymbolId?: number, +): Uint8Array { + return encodeQwpIngressFrame([buildBenchmarkTable(rows)], { + dictionary, + confirmedMaxSymbolId, + }); +} + +describe("benchmark wire-format invariants", () => { + it("encodes trades to a plausible number of bytes per row", () => { + const rows = BENCHMARK_WORKLOADS.trades.rows(10_000); + const bytesPerRow = encode(rows).byteLength / rows.length; + expect(bytesPerRow).toBeGreaterThan(14); + expect(bytesPerRow).toBeLessThan(24); + }); + + it("compacts null values instead of writing placeholders", () => { + const sparse = BENCHMARK_WORKLOADS.sparse.rows(2000); + const sparseBytes = encode(sparse).byteLength; + const dense = sparse.map((row) => ({ + ...row, + nulls: [], + longs: ["a", "b", "c", "d", "e", "f", "g", "h"].map( + (name) => [name, 1n] as [string, bigint], + ), + })); + expect(sparseBytes).toBeLessThan(encode(dense).byteLength * 0.9); + }); + + it("emits fewer bytes after a symbol-dictionary baseline is confirmed", () => { + const rows = BENCHMARK_WORKLOADS.highCardinalitySymbols.rows(5000); + const fullBytes = encode(rows).byteLength; + const dictionary = new QwpSymbolDictionary(); + encode(rows, dictionary, -1); + const deltaBytes = encode(rows, dictionary, dictionary.size - 1).byteLength; + expect(deltaBytes).toBeLessThan(fullBytes); + }); + + it("does not treat a populated but unconfirmed dictionary as steady state", () => { + const rows = BENCHMARK_WORKLOADS.highCardinalitySymbols.rows(5000); + const fullBytes = encode(rows).byteLength; + const dictionary = new QwpSymbolDictionary(); + encode(rows, dictionary, -1); + const coldBytes = encode(rows, dictionary, -1).byteLength; + expect(coldBytes).toBeGreaterThan(fullBytes * 0.9); + }); + + it("does not apply Gorilla encoding to long columns", () => { + const table = new QwpTableBuffer("gorilla_long"); + for (let index = 0; index < 5000; index++) { + table + .getOrCreateColumn("value", QWP_COLUMN_TYPE.LONG) + ?.values.push(BigInt(index)); + table.nextRow(); + } + expect(encodeQwpIngressFrame([table], { gorilla: true }).byteLength).toBe( + encodeQwpIngressFrame([table], { gorilla: false }).byteLength, + ); + }); + + it("compresses regularly spaced timestamps with Gorilla encoding", () => { + const table = new QwpTableBuffer("gorilla_timestamp"); + for (let index = 0; index < 5000; index++) { + table + .getOrCreateColumn("timestamp", QWP_COLUMN_TYPE.TIMESTAMP) + ?.values.push(BASE_TIMESTAMP + BigInt(index) * 1000n); + table.nextRow(); + } + const uncompressed = encodeQwpIngressFrame([table], { + gorilla: false, + }).byteLength; + const compressed = encodeQwpIngressFrame([table], { + gorilla: true, + }).byteLength; + expect(compressed).toBeLessThan(uncompressed / 2); + }); +}); diff --git a/benchmarks/workloads.test.ts b/benchmarks/workloads.test.ts new file mode 100644 index 0000000..cfee948 --- /dev/null +++ b/benchmarks/workloads.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { BENCHMARK_WORKLOADS } from "./workloads"; + +function stringify(value: unknown): string { + return JSON.stringify(value, (_key, item) => + typeof item === "bigint" ? item.toString() : item, + ); +} + +describe("benchmark workloads", () => { + it("are deterministic across calls", () => { + expect(stringify(BENCHMARK_WORKLOADS.trades.rows(100))).toBe( + stringify(BENCHMARK_WORKLOADS.trades.rows(100)), + ); + }); + + it("builds the advertised trade shape", () => { + const row = BENCHMARK_WORKLOADS.trades.rows(1)[0]; + expect(row.symbols).toHaveLength(1); + expect(row.doubles).toHaveLength(2); + }); + + it("builds 50 wide data columns", () => { + const row = BENCHMARK_WORKLOADS.wide.rows(1)[0]; + expect( + row.symbols.length + + row.longs.length + + row.doubles.length + + row.strings.length, + ).toBe(50); + }); + + it("builds high-cardinality symbols", () => { + const rows = BENCHMARK_WORKLOADS.highCardinalitySymbols.rows(5000); + expect(new Set(rows.map((row) => row.symbols[0][1])).size).toBeGreaterThan( + 4000, + ); + }); + + it("makes roughly 30 percent of sparse values null", () => { + const rows = BENCHMARK_WORKLOADS.sparse.rows(1000); + const nulls = rows.reduce((total, row) => total + row.nulls.length, 0); + const ratio = nulls / (rows.length * 8); + expect(ratio).toBeGreaterThan(0.2); + expect(ratio).toBeLessThan(0.4); + }); +}); diff --git a/benchmarks/workloads.ts b/benchmarks/workloads.ts new file mode 100644 index 0000000..63586a8 --- /dev/null +++ b/benchmarks/workloads.ts @@ -0,0 +1,132 @@ +export interface BenchmarkRow { + table: string; + symbols: [string, string][]; + longs: [string, bigint][]; + doubles: [string, number][]; + strings: [string, string][]; + /** Column names deliberately left unset for this row. */ + nulls: string[]; + timestamp: bigint; +} + +export interface BenchmarkWorkload { + name: string; + columns: number; + rows(count: number): BenchmarkRow[]; +} + +/** Deterministic, dependency-free xorshift32 generator. */ +function random(seed: number): () => number { + let state = seed || 0x9e3779b9; + const next = (): number => { + state ^= state << 13; + state ^= state >>> 17; + state ^= state << 5; + return (state >>> 0) / 0x100000000; + }; + // Small xorshift seeds start near zero. Discard the poorly diffused prefix. + for (let index = 0; index < 16; index++) next(); + return next; +} + +const BASE_TIMESTAMP = 1_700_000_000_000_000n; + +function trades(count: number): BenchmarkRow[] { + const next = random(1); + const symbols = ["ETH-USD", "BTC-USD", "SOL-USD", "ADA-USD"]; + return Array.from({ length: count }, (_, index) => ({ + table: "bench_trades", + symbols: [["symbol", symbols[index % symbols.length]]], + longs: [], + doubles: [ + ["price", 1000 + next() * 5000], + ["amount", next()], + ], + strings: [], + nulls: [], + timestamp: BASE_TIMESTAMP + BigInt(index) * 1000n, + })); +} + +function wide(count: number): BenchmarkRow[] { + const next = random(2); + const rows: BenchmarkRow[] = []; + for (let index = 0; index < count; index++) { + const longs: [string, bigint][] = []; + const doubles: [string, number][] = []; + const strings: [string, string][] = []; + for (let column = 0; column < 20; column++) { + longs.push([`l${column}`, BigInt(Math.floor(next() * 1e6))]); + doubles.push([`d${column}`, next() * 1000]); + } + for (let column = 0; column < 9; column++) { + strings.push([`s${column}`, `v${Math.floor(next() * 100)}`]); + } + rows.push({ + table: "bench_wide", + symbols: [["sym", `s${index % 16}`]], + longs, + doubles, + strings, + nulls: [], + timestamp: BASE_TIMESTAMP + BigInt(index) * 1000n, + }); + } + return rows; +} + +function highCardinalitySymbols(count: number): BenchmarkRow[] { + return Array.from({ length: count }, (_, index) => ({ + table: "bench_highcard", + symbols: [["sym", `sym-${index % 100_000}`]], + longs: [["v", BigInt(index)]], + doubles: [], + strings: [], + nulls: [], + timestamp: BASE_TIMESTAMP + BigInt(index) * 1000n, + })); +} + +function sparse(count: number): BenchmarkRow[] { + const next = random(4); + const names = ["a", "b", "c", "d", "e", "f", "g", "h"]; + const rows: BenchmarkRow[] = []; + for (let index = 0; index < count; index++) { + const longs: [string, bigint][] = []; + const nulls: string[] = []; + for (const name of names) { + if (next() < 0.3) nulls.push(name); + else longs.push([name, BigInt(Math.floor(next() * 1e6))]); + } + rows.push({ + table: "bench_sparse", + symbols: [], + longs, + doubles: [], + strings: [], + nulls, + timestamp: BASE_TIMESTAMP + BigInt(index) * 1000n, + }); + } + return rows; +} + +export type BenchmarkWorkloadName = + | "trades" + | "wide" + | "highCardinalitySymbols" + | "sparse"; + +export const BENCHMARK_WORKLOADS: Record< + BenchmarkWorkloadName, + BenchmarkWorkload +> = { + trades: { name: "trades", columns: 4, rows: trades }, + wide: { name: "wide", columns: 50, rows: wide }, + highCardinalitySymbols: { + name: "highCardinalitySymbols", + columns: 3, + rows: highCardinalitySymbols, + }, + sparse: { name: "sparse", columns: 8, rows: sparse }, +}; diff --git a/package.json b/package.json index b973947..bb290ed 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,11 @@ "eslint": "eslint src/**", "typecheck": "tsc --noEmit", "typecheck:qwp-browser": "tsc --noEmit -p tsconfig.qwp-browser.json", + "bench": "vitest bench --run benchmarks", + "bench:e2e": "vitest run --config vitest.bench-e2e.config.ts", + "typecheck:bench": "tsc --noEmit -p tsconfig.bench.json", + "lint:bench": "eslint 'benchmarks/**/*.ts' vitest.bench-e2e.config.ts", + "format:bench": "prettier --write 'benchmarks/**/*.{ts,md}' tsconfig.bench.json vitest.bench-e2e.config.ts", "format": "prettier --write '{src,test}/**/*.{ts,js,json}'", "docs": "typedoc --out docs src/index.ts", "preview:docs": "serve docs" diff --git a/tsconfig.bench.json b/tsconfig.bench.json new file mode 100644 index 0000000..8b99661 --- /dev/null +++ b/tsconfig.bench.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "skipLibCheck": true + }, + "include": ["src", "benchmarks", "vitest.bench-e2e.config.ts"] +} diff --git a/vitest.bench-e2e.config.ts b/vitest.bench-e2e.config.ts new file mode 100644 index 0000000..bebc1e9 --- /dev/null +++ b/vitest.bench-e2e.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["benchmarks/e2e.ts"], + testTimeout: 30 * 60 * 1000, + hookTimeout: 30 * 60 * 1000, + }, +}); From 9d751bda1832a850e1de0778b73e6dc41a98f44c Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 20 Aug 2026 01:02:13 +0100 Subject: [PATCH 086/265] feat(qwp): add compiled object-row table writers Co-Authored-By: Claude Opus 5 (1M context) --- QWP.md | 57 +++++ README.md | 25 ++ src/qwp/client.ts | 36 +++ src/qwp/index.ts | 23 ++ src/qwp/sender.ts | 390 +++++++++++++++++++++++++++++++- src/qwp/writer.ts | 192 ++++++++++++++++ src/sender.ts | 18 ++ test/qwp/client.test.ts | 73 ++++++ test/qwp/public-api-contract.ts | 40 +++- test/qwp/sender.test.ts | 250 ++++++++++++++++++++ 10 files changed, 1102 insertions(+), 2 deletions(-) create mode 100644 src/qwp/writer.ts diff --git a/QWP.md b/QWP.md index 01bcdcc..d98f984 100644 --- a/QWP.md +++ b/QWP.md @@ -327,6 +327,7 @@ Use `QwpSender` directly when QWP-only column types or detailed session controls needed: ```typescript +import * as qwp from "@questdb/nodejs-client/qwp"; import { connectQwpNodeSender } from "@questdb/nodejs-client/qwp/node"; const sender = await connectQwpNodeSender( @@ -355,6 +356,62 @@ try { } ``` +### Compiled object-row writers + +For repeated rows with one table schema, compile a table-bound writer instead of +sharing the fluent row-builder state: + +```typescript +const trades = sender.writer("trades", { + symbol: qwp.symbol(), + side: qwp.symbol(), + price: qwp.double(), + quantity: qwp.long(), + timestamp: qwp.designatedTimestamp("ns"), +}); + +await trades.row({ + symbol: "ETH-USD", + side: "sell", + price: 2615.54, + quantity: 42n, + timestamp: 1_723_000_000_000_000_000n, +}); + +await trades.rows([ + { + symbol: "BTC-USD", + side: "buy", + price: 39_269.98, + quantity: 7n, + timestamp: 1_723_000_001_000_000_000n, + }, +]); +``` + +`rows()` accepts `Iterable` and `AsyncIterable` sources and applies the sender's +normal auto-flush, batch-cap, backpressure, transaction, symbol-dictionary, and ACK +settings. The schema is validated once. `symbol()`, `varchar()`, `bool()`, `byte()`, +`short()`, `int32()`, `int64()`, `float32()`, `float64()`, `timestamp(unit)`, and +`designatedTimestamp(unit)` define the currently supported object fields; `long()` and +`double()` are aliases of `int64()` and `float64()`. LONG and nanosecond timestamp +inputs are `bigint` so they cannot silently lose precision. + +Widths are spelled out deliberately. The fluent row API predates these names and its +`floatColumn()` and `intColumn()` are 64-bit despite reading as 32-bit, with +`float32Column()` and `int32Column()` as the narrow forms. Compiled writers avoid the +ambiguity: `float32()`/`float64()` and `int32()`/`int64()` mean exactly what they say. + +Regular fields may be absent, `null`, or `undefined`, which writes a NULL. A schema +may contain at most one designated timestamp and, when present, that field is required +in every row. Unknown object keys and type mismatches raise `QwpWriterRowError`; bulk +errors include the zero-based row index. A failing row is never partly staged. Rows +successfully completed before a later iterable row fails remain available to flush. + +Compiled writers are also available through the regular Node `Sender` when it uses a +QWP transport. Calling `writer()` for an HTTP or TCP ILP sender raises an error. A +writer obtained from a pooled sender lease cannot be used after the lease is closed. + Like the Java QWP sender, `flush()` and `commit()` resolve after the complete logical flush reaches the local ingress/replay publication boundary. They do not wait for a server ACK by default. Set `awaitServerAck: true` for an diff --git a/README.md b/README.md index 308cc9c..8ae32e9 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,31 @@ await sender.flush(); await sender.close(); ``` +For repeated object rows, compile the table schema once. The resulting writer +validates each complete row before changing sender state and accepts both individual +rows and synchronous or asynchronous iterables: + +```typescript +import * as qwp from "@questdb/nodejs-client/qwp"; + +const trades = sender.writer("trades", { + symbol: qwp.symbol(), + side: qwp.symbol(), + price: qwp.double(), + quantity: qwp.long(), + timestamp: qwp.designatedTimestamp("ns"), +}); + +await trades.row({ + symbol: "ETH-USD", + side: "sell", + price: 2615.54, + quantity: 42n, + timestamp: 1_723_000_000_000_000_000n, +}); +await trades.rows(moreTrades); +``` + The regular `Sender` accepts the same unified QWP configuration vocabulary as the pooled Node client. Use comma-separated or repeated `addr` values for failover; standalone ingress validates but otherwise ignores egress- and diff --git a/src/qwp/client.ts b/src/qwp/client.ts index 0f7c77a..8d1632e 100644 --- a/src/qwp/client.ts +++ b/src/qwp/client.ts @@ -756,6 +756,39 @@ function createSenderLease( let closePromise: Promise | undefined; const methods = new Map unknown>(); + const guardTableWriter = (writer: T): T => { + // Memoized per writer, matching the sender proxy below: appends re-enter + // this trap per row, so a fresh closure per access would allocate on the + // hot path and hand out unstable method identities. + const writerMethods = new Map< + PropertyKey, + (...args: unknown[]) => unknown + >(); + const guarded: T = new Proxy(writer, { + get(target, property) { + if (released) { + throw new QwpClientClosedError("QWP sender lease is closed"); + } + const value = Reflect.get(target, property, target); + if (typeof value !== "function") return value; + let wrapped = writerMethods.get(property); + if (!wrapped) { + wrapped = (...args: unknown[]) => { + if (released) { + throw new QwpClientClosedError("QWP sender lease is closed"); + } + // Re-enter through the proxy so multi-row helpers such as rows() + // re-check the lease between appends instead of only on entry. + return Reflect.apply(value, guarded, args); + }; + writerMethods.set(property, wrapped); + } + return wrapped; + }, + }); + return guarded; + }; + const release = (): Promise => { if (closePromise) return closePromise; released = true; @@ -790,6 +823,9 @@ function createSenderLease( throw new QwpClientClosedError("QWP sender lease is closed"); } const result = Reflect.apply(value, target, args); + if (property === "writer" && typeof result === "object" && result) { + return guardTableWriter(result); + } return result === target ? proxy : result; }; methods.set(property, wrapped); diff --git a/src/qwp/index.ts b/src/qwp/index.ts index a167d1a..7ee9d09 100644 --- a/src/qwp/index.ts +++ b/src/qwp/index.ts @@ -13,3 +13,26 @@ export * from "./ingress-session"; export * from "./sender"; export * from "./sender-error"; export * from "./transport"; +export { + bool, + byte, + designatedTimestamp, + double, + float32, + float64, + int32, + int64, + long, + short, + symbol, + timestamp, + varchar, + QwpWriterRowError, +} from "./writer"; +export type { + QwpTimestampUnit, + QwpWriterColumn, + QwpWriterColumnKind, + QwpWriterRow, + QwpWriterSchema, +} from "./writer"; diff --git a/src/qwp/sender.ts b/src/qwp/sender.ts index 601bb7a..abb7b38 100644 --- a/src/qwp/sender.ts +++ b/src/qwp/sender.ts @@ -14,8 +14,16 @@ import { type QwpIngressMetrics, } from "./ingress-session"; import { qwpColumnNameKey, validateQwpColumnName } from "./core/identifiers"; +import { + isQwpWriterColumn, + QwpWriterRowError, + type QwpTimestampUnit, + type QwpWriterColumn, + type QwpWriterRow, + type QwpWriterSchema, +} from "./writer"; -export type QwpTimestampUnit = "ns" | "us" | "ms"; +export type { QwpTimestampUnit } from "./writer"; export type QwpSenderLogger = ( level: "error" | "warn" | "info" | "debug", @@ -176,6 +184,20 @@ interface StagedRow { readonly estimatedBytes: number; } +interface CompiledQwpWriterColumn { + readonly inputName: string; + readonly wireName: string; + readonly nameKey: string; + readonly type: QwpColumnType; + readonly descriptor: QwpWriterColumn; +} + +interface CompiledQwpWriterSchema { + readonly tableName: string; + readonly columns: readonly CompiledQwpWriterColumn[]; + readonly inputNames: ReadonlySet; +} + interface QwpSenderFlushResult { readonly flushed: boolean; readonly sequence: bigint; @@ -400,6 +422,135 @@ function parseIpv4(value: string | number): number { return packed; } +function qwpWriterColumnType( + descriptor: QwpWriterColumn, +): QwpColumnType { + switch (descriptor.kind) { + case "symbol": + return QWP_COLUMN_TYPE.SYMBOL; + case "varchar": + return QWP_COLUMN_TYPE.VARCHAR; + case "bool": + return QWP_COLUMN_TYPE.BOOLEAN; + case "byte": + return QWP_COLUMN_TYPE.BYTE; + case "short": + return QWP_COLUMN_TYPE.SHORT; + case "int32": + return QWP_COLUMN_TYPE.INT; + case "int64": + return QWP_COLUMN_TYPE.LONG; + case "float32": + return QWP_COLUMN_TYPE.FLOAT; + case "float64": + return QWP_COLUMN_TYPE.DOUBLE; + case "timestamp": + return descriptor.unit === "ns" + ? QWP_COLUMN_TYPE.TIMESTAMP_NANOS + : QWP_COLUMN_TYPE.TIMESTAMP; + } +} + +function encodeQwpWriterValue( + column: CompiledQwpWriterColumn, + value: unknown, +): unknown { + switch (column.descriptor.kind) { + case "symbol": + case "varchar": + if (typeof value !== "string") { + throw new TypeError(`${column.descriptor.kind} accepts only strings`); + } + return value; + case "bool": + if (typeof value !== "boolean") { + throw new TypeError("bool accepts only booleans"); + } + return value; + case "byte": + if (typeof value !== "number") { + throw new TypeError("byte accepts only numbers"); + } + return checkedRange(value, -128, 127, "byte value"); + case "short": + if (typeof value !== "number") { + throw new TypeError("short accepts only numbers"); + } + return checkedRange(value, -32_768, 32_767, "short value"); + case "int32": + if (typeof value !== "number") { + throw new TypeError("int32 accepts only numbers"); + } + return checkedRange(value, -2_147_483_648, 2_147_483_647, "int32 value"); + case "int64": + if (typeof value !== "bigint") { + throw new TypeError("int64 accepts only bigint values"); + } + return checkedInt64(value, "int64 value", true); + case "float32": + case "float64": + if (typeof value !== "number") { + throw new TypeError(`${column.descriptor.kind} accepts only numbers`); + } + return value; + case "timestamp": { + if (typeof value !== "number" && typeof value !== "bigint") { + throw new TypeError("timestamp accepts only number or bigint values"); + } + return timestampValue(value, column.descriptor.unit ?? "us").value; + } + } +} + +const QWP_TABLE_WRITER_CONSTRUCTOR = Symbol("QWP table writer constructor"); + +/** A reusable table-bound writer compiled from a QWP schema. */ +export class QwpTableWriter { + /** @internal Construct table writers with QwpSender.writer(). */ + constructor( + token: typeof QWP_TABLE_WRITER_CONSTRUCTOR, + readonly tableName: string, + private readonly appendRow: ( + row: unknown, + rowIndex?: number, + ) => Promise, + ) { + if (token !== QWP_TABLE_WRITER_CONSTRUCTOR) { + throw new TypeError("QWP table writers must be created by QwpSender"); + } + } + + /** Validates and atomically appends one complete object row. */ + row(row: QwpWriterRow): Promise { + return this.appendRow(row); + } + + /** Appends a synchronous or asynchronous stream of complete object rows. */ + async rows( + rows: Iterable> | AsyncIterable>, + ): Promise { + const source = rows as + | Partial< + Iterable> & AsyncIterable> + > + | null + | undefined; + if ( + source === null || + source === undefined || + (typeof source[Symbol.iterator] !== "function" && + typeof source[Symbol.asyncIterator] !== "function") + ) { + throw new TypeError("QWP table writer rows must be iterable"); + } + + let rowIndex = 0; + for await (const row of rows) { + await this.appendRow(row, rowIndex++); + } + } +} + /** * Browser-safe high-level QWP ingress API. * @@ -516,6 +667,23 @@ export class QwpSender { return this; } + /** + * Compiles an immutable table schema into an atomic object-row writer. + * The returned writer remains usable after this sender is reset. + */ + writer( + tableName: string, + schema: Schema, + ): QwpTableWriter { + this.throwIfUnavailable(); + const compiled = this.compileWriterSchema(tableName, schema); + return new QwpTableWriter( + QWP_TABLE_WRITER_CONSTRUCTOR, + tableName, + (row, rowIndex) => this.appendCompiledWriterRow(compiled, row, rowIndex), + ); + } + table(name: string): QwpSender { this.throwIfUnavailable(); if (this.current) throw new Error("Table name has already been set"); @@ -1168,6 +1336,226 @@ export class QwpSender { } } + private compileWriterSchema( + tableName: string, + schema: Schema, + ): CompiledQwpWriterSchema { + // Reuse the wire buffer's table validation so both sender APIs accept the + // exact same identifiers. + new QwpTableBuffer(tableName, this.maxNameLength); + if ( + typeof schema !== "object" || + schema === null || + Array.isArray(schema) + ) { + throw new TypeError("QWP writer schema must be an object"); + } + + const entries = Object.entries(schema); + if (entries.length === 0) { + throw new TypeError("QWP writer schema must contain at least one column"); + } + + const columns: CompiledQwpWriterColumn[] = []; + const inputNames = new Set(); + const nameKeys = new Set(); + let designatedTimestampCount = 0; + for (const [inputName, candidate] of entries) { + validateQwpColumnName(inputName, this.maxNameLength); + if (!isQwpWriterColumn(candidate)) { + throw new TypeError( + `invalid QWP writer descriptor for column '${inputName}'`, + ); + } + if (candidate.designatedTimestamp) designatedTimestampCount++; + if (designatedTimestampCount > 1) { + throw new TypeError( + "QWP writer schema cannot contain more than one designated timestamp", + ); + } + const wireName = candidate.designatedTimestamp ? "" : inputName; + const nameKey = qwpColumnNameKey(wireName); + if (nameKeys.has(nameKey)) { + throw new TypeError( + `duplicate case-insensitive QWP writer column '${inputName}'`, + ); + } + nameKeys.add(nameKey); + inputNames.add(inputName); + columns.push( + Object.freeze({ + inputName, + wireName, + nameKey, + type: qwpWriterColumnType(candidate), + descriptor: candidate, + }), + ); + } + + return Object.freeze({ + tableName, + columns: Object.freeze(columns), + inputNames, + }); + } + + private encodeCompiledWriterRow( + schema: CompiledQwpWriterSchema, + input: unknown, + rowIndex: number | undefined, + ): StagedRow { + if (typeof input !== "object" || input === null || Array.isArray(input)) { + throw new QwpWriterRowError( + schema.tableName, + undefined, + rowIndex, + new TypeError("row must be an object"), + ); + } + + let inputKeys: string[]; + try { + inputKeys = Object.keys(input); + } catch (error) { + throw new QwpWriterRowError(schema.tableName, undefined, rowIndex, error); + } + const unknownName = inputKeys.find( + (inputName) => !schema.inputNames.has(inputName), + ); + if (unknownName !== undefined) { + throw new QwpWriterRowError( + schema.tableName, + unknownName, + rowIndex, + new TypeError("column is not present in the compiled schema"), + ); + } + + const values = input as Record; + const columns = new Map(); + for (const column of schema.columns) { + let value: unknown; + try { + value = Object.prototype.hasOwnProperty.call(input, column.inputName) + ? values[column.inputName] + : undefined; + } catch (error) { + throw new QwpWriterRowError( + schema.tableName, + column.inputName, + rowIndex, + error, + ); + } + if (value === null || value === undefined) { + if (column.descriptor.designatedTimestamp) { + throw new QwpWriterRowError( + schema.tableName, + column.inputName, + rowIndex, + new TypeError("designated timestamp is required"), + ); + } + continue; + } + try { + columns.set(column.nameKey, { + name: column.wireName, + type: column.type, + value: encodeQwpWriterValue(column, value), + }); + } catch (error) { + throw new QwpWriterRowError( + schema.tableName, + column.inputName, + rowIndex, + error, + ); + } + } + + if (columns.size === 0) { + throw new QwpWriterRowError( + schema.tableName, + undefined, + rowIndex, + new TypeError("row must contain at least one non-null value"), + ); + } + return { columns, estimatedBytes: stagedRowBytes(columns) }; + } + + private async appendCompiledWriterRow( + schema: CompiledQwpWriterSchema, + input: unknown, + rowIndex: number | undefined, + ): Promise { + this.throwIfUnavailable(); + // Report the conflicting fluent row before validating this one: it is the + // actionable error, and row contents cannot be staged either way. + if (this.current) { + throw new QwpWriterRowError( + schema.tableName, + undefined, + rowIndex, + new Error("a fluent row is already in progress"), + ); + } + const row = this.encodeCompiledWriterRow(schema, input, rowIndex); + + const existingTable = this.tablesByName.get(schema.tableName); + if (existingTable) { + for (const [nameKey, column] of row.columns) { + const existing = existingTable.schema.get(nameKey); + if ( + existing && + (existing.type !== column.type || + existing.geohashPrecision !== column.geohashPrecision || + existing.decimalScale !== column.decimalScale) + ) { + const inputName = schema.columns.find( + (candidate) => candidate.nameKey === nameKey, + )?.inputName; + throw new QwpWriterRowError( + schema.tableName, + inputName, + rowIndex, + new Error("column type conflicts with the sender's staged schema"), + ); + } + } + } + + let table = existingTable; + if (!table) { + table = { name: schema.tableName, rows: [], schema: new Map() }; + this.tablesByName.set(schema.tableName, table); + this.tables.push(table); + } + for (const [nameKey, column] of row.columns) { + const existing = table.schema.get(nameKey); + if (existing) column.name = existing.name; + else { + table.schema.set(nameKey, { + name: column.name, + type: column.type, + geohashPrecision: column.geohashPrecision, + decimalScale: column.decimalScale, + }); + } + } + table.rows.push(row); + this.pendingRowCount++; + this.pendingByteCount += row.estimatedBytes; + this.totalRowsStaged++; + this.log( + "debug", + `Pending QWP rows: ${this.pendingRowCount}, estimated bytes: ${this.pendingByteCount}`, + ); + await this.tryFlush(); + } + private fixedDecimalColumn( name: string, unscaled: bigint | null | undefined, diff --git a/src/qwp/writer.ts b/src/qwp/writer.ts new file mode 100644 index 0000000..6879c57 --- /dev/null +++ b/src/qwp/writer.ts @@ -0,0 +1,192 @@ +export type QwpTimestampUnit = "ns" | "us" | "ms"; + +export type QwpWriterColumnKind = + | "symbol" + | "varchar" + | "bool" + | "byte" + | "short" + | "int32" + | "int64" + | "float32" + | "float64" + | "timestamp"; + +const QWP_WRITER_COLUMN = Symbol("QWP writer column"); +const QWP_WRITER_INPUT: unique symbol = Symbol("QWP writer input"); + +/** A reusable, immutable column definition for a compiled QWP table writer. */ +export interface QwpWriterColumn< + T, + DesignatedTimestamp extends boolean = false, +> { + readonly kind: QwpWriterColumnKind; + readonly designatedTimestamp: DesignatedTimestamp; + readonly unit?: QwpTimestampUnit; + /** @internal Carries the input type without adding a runtime value. */ + readonly [QWP_WRITER_INPUT]?: T; +} + +interface BrandedQwpWriterColumn + extends QwpWriterColumn { + readonly [QWP_WRITER_COLUMN]: true; +} + +export type QwpWriterSchema = Readonly< + Record> +>; + +type QwpWriterColumnInput = + Column extends QwpWriterColumn ? Input : never; + +type QwpDesignatedTimestampKey = { + [Key in keyof Schema]: Schema[Key] extends QwpWriterColumn + ? Key + : never; +}[keyof Schema]; + +type QwpRegularColumnKey = Exclude< + keyof Schema, + QwpDesignatedTimestampKey +>; + +/** The object accepted by a table writer compiled from `Schema`. */ +export type QwpWriterRow = { + [Key in QwpDesignatedTimestampKey]-?: QwpWriterColumnInput< + Schema[Key] + >; +} & { + [Key in QwpRegularColumnKey]?: + | QwpWriterColumnInput + | null + | undefined; +}; + +type TimestampInput = Unit extends "ns" + ? bigint + : number | bigint; + +function column( + kind: QwpWriterColumnKind, + designatedTimestamp: DesignatedTimestamp, + unit?: QwpTimestampUnit, +): QwpWriterColumn { + return Object.freeze({ + kind, + designatedTimestamp, + unit, + [QWP_WRITER_COLUMN]: true, + }) as BrandedQwpWriterColumn; +} + +function validateTimestampUnit(unit: QwpTimestampUnit): void { + if (unit !== "ns" && unit !== "us" && unit !== "ms") { + throw new TypeError(`unsupported timestamp unit '${String(unit)}'`); + } +} + +/** Defines a string-valued QuestDB SYMBOL column. */ +export function symbol(): QwpWriterColumn { + return column("symbol", false); +} + +/** Defines a string-valued QuestDB VARCHAR column. */ +export function varchar(): QwpWriterColumn { + return column("varchar", false); +} + +/** Defines a QuestDB BOOLEAN column. */ +export function bool(): QwpWriterColumn { + return column("bool", false); +} + +/** Defines a signed 8-bit QuestDB BYTE column. */ +export function byte(): QwpWriterColumn { + return column("byte", false); +} + +/** Defines a signed 16-bit QuestDB SHORT column. */ +export function short(): QwpWriterColumn { + return column("short", false); +} + +/** Defines a signed 32-bit QuestDB INT column. */ +export function int32(): QwpWriterColumn { + return column("int32", false); +} + +/** Defines a signed 64-bit QuestDB LONG column. Inputs must be bigint. */ +export function int64(): QwpWriterColumn { + return column("int64", false); +} + +/** Defines a signed 64-bit QuestDB LONG column. Alias of {@link int64}. */ +export function long(): QwpWriterColumn { + return int64(); +} + +/** Defines a 32-bit QuestDB FLOAT column. */ +export function float32(): QwpWriterColumn { + return column("float32", false); +} + +/** Defines a 64-bit QuestDB DOUBLE column. */ +export function float64(): QwpWriterColumn { + return column("float64", false); +} + +/** Defines a 64-bit QuestDB DOUBLE column. Alias of {@link float64}. */ +export function double(): QwpWriterColumn { + return float64(); +} + +/** Defines a regular timestamp column with an explicit input unit. */ +export function timestamp( + unit: Unit = "us" as Unit, +): QwpWriterColumn> { + validateTimestampUnit(unit); + return column("timestamp", false, unit); +} + +/** Defines the writer's required designated timestamp field. */ +export function designatedTimestamp( + unit: Unit = "us" as Unit, +): QwpWriterColumn, true> { + validateTimestampUnit(unit); + return column("timestamp", true, unit); +} + +/** A complete object row failed compiled-writer validation. */ +export class QwpWriterRowError extends Error { + readonly cause: unknown; + + constructor( + readonly tableName: string, + readonly columnName: string | undefined, + readonly rowIndex: number | undefined, + cause: unknown, + ) { + const detail = cause instanceof Error ? cause.message : String(cause); + const row = rowIndex === undefined ? "" : ` at index ${rowIndex}`; + const columnNameSuffix = + columnName === undefined ? "" : `, column '${columnName}'`; + super( + `invalid QWP row for table '${tableName}'${row}${columnNameSuffix}: ${detail}`, + ); + this.name = "QwpWriterRowError"; + this.cause = cause; + } +} + +/** @internal */ +export function isQwpWriterColumn( + value: unknown, +): value is QwpWriterColumn { + return ( + typeof value === "object" && + value !== null && + (value as Partial>)[ + QWP_WRITER_COLUMN + ] === true + ); +} diff --git a/src/sender.ts b/src/sender.ts index fcfdb19..05a1406 100644 --- a/src/sender.ts +++ b/src/sender.ts @@ -14,6 +14,8 @@ import { createQwpNodeUdpSender, QwpSender, } from "./qwp/node"; +import type { QwpTableWriter } from "./qwp/sender"; +import type { QwpWriterSchema } from "./qwp/writer"; import { resolveQwpNodeClientConfig } from "./qwp-node/client-config"; const DEFAULT_AUTO_FLUSH_INTERVAL = 1000; // 1 sec @@ -221,6 +223,22 @@ class Sender { return this; } + /** + * Compiles a table-bound object-row writer for QWP transports. + * Legacy ILP transports continue to use the fluent row API. + */ + writer( + tableName: string, + schema: Schema, + ): QwpTableWriter { + if (!this.qwpSender) { + throw new Error( + "compiled table writers are available only with QWP transports", + ); + } + return this.qwpSender.writer(tableName, schema); + } + /** * Creates a TCP connection to the database. * diff --git a/test/qwp/client.test.ts b/test/qwp/client.test.ts index 848a7e8..715eacd 100644 --- a/test/qwp/client.test.ts +++ b/test/qwp/client.test.ts @@ -19,6 +19,9 @@ import { type QwpPoolSlotReservation, QwpSender, QwpSenderSession, + designatedTimestamp, + long, + symbol as qwpSymbol, } from "../../src/qwp"; import { QwpAsyncQueue } from "../../src/qwp/internal/async-queue"; @@ -306,10 +309,15 @@ describe("QWP pooled client", () => { const first = await client.borrowSender(); await first.table("trades").symbol("symbol", "ETH-USD").atNow(); + const objects = first.writer("objects", { symbol: qwpSymbol() }); + // The lease guard memoizes its wrappers, so method identity is stable. + expect(objects.row).toBe(objects.row); + await objects.row({ symbol: "BTC-USD" }); await first.close(); expect(senderSessions[0].flushes).toBe(1); expect(senderSessions[0].closes).toBe(0); expect(() => first.table("late")).toThrow(QwpClientClosedError); + expect(() => objects.row({ symbol: "late" })).toThrow(QwpClientClosedError); const second = await client.borrowSender(); expect(senderCreations).toBe(1); @@ -324,6 +332,71 @@ describe("QWP pooled client", () => { expect(senderSessions[0].closes).toBe(1); }); + it("stops an in-flight writer stream when its lease is released", async () => { + const senderSessions: FakeSenderSession[] = []; + let senderCreations = 0; + const client = new QwpClient( + { + createSender: async () => { + senderCreations++; + const session = new FakeSenderSession(); + senderSessions.push(session); + const sender = new QwpSender(async () => session, { + autoFlush: false, + }); + await sender.connect(); + return sender; + }, + createQuerySession: async () => { + throw new Error("query factory should not run"); + }, + }, + { + senderPoolMin: 1, + senderPoolMax: 1, + queryPoolMin: 0, + queryPoolMax: 1, + }, + ); + await client.connect(); + + const first = await client.borrowSender(); + const events = first.writer("events", { + value: long(), + timestamp: designatedTimestamp("ns"), + }); + + let resumeSource!: () => void; + const suspended = new Promise((resolve) => { + resumeSource = resolve; + }); + async function* source() { + yield { value: 1n, timestamp: 10n }; + await suspended; + yield { value: 2n, timestamp: 20n }; + yield { value: 3n, timestamp: 30n }; + } + + const inFlight = events.rows(source()); + await new Promise((resolve) => setImmediate(resolve)); + await first.close(); + expect(senderSessions[0].flushes).toBe(1); + + // The pool hands the very same sender to the next borrower. + const second = await client.borrowSender(); + expect(senderCreations).toBe(1); + + resumeSource(); + await expect(inFlight).rejects.toBeInstanceOf(QwpClientClosedError); + + // Rows yielded after the release must not reach the new lease. + await second.flush(); + expect(senderSessions[0].flushes).toBe(1); + + await second.close(); + await client.close(); + }); + it("waits for a borrowed sender without closing it underneath its owner", async () => { const senderSessions: FakeSenderSession[] = []; const client = new QwpClient( diff --git a/test/qwp/public-api-contract.ts b/test/qwp/public-api-contract.ts index 0c9d96e..c2533ac 100644 --- a/test/qwp/public-api-contract.ts +++ b/test/qwp/public-api-contract.ts @@ -1,6 +1,12 @@ import { Sender } from "../../src"; import type { ExtraOptions, QwpExtraOptions } from "../../src"; -import { defaultQwpSenderErrorHandler } from "../../src/qwp"; +import { + defaultQwpSenderErrorHandler, + designatedTimestamp, + double, + long, + symbol as qwpSymbol, +} from "../../src/qwp"; import { bootstrapQwpBrowserSession, connectQwpBrowserClient, @@ -64,6 +70,8 @@ import type { QwpServerInfoMessage, QwpSender, QwpSenderOptions, + QwpTableWriter, + QwpWriterRow, } from "../../src/qwp"; // This file is part of the repository typecheck. Assignments deliberately @@ -335,6 +343,35 @@ function rootSenderSequenceContract(sender: Sender): void { void acknowledgedWatermark; } +function compiledWriterContract(sender: QwpSender, rootSender: Sender): void { + const schema = { + symbol: qwpSymbol(), + price: double(), + quantity: long(), + timestamp: designatedTimestamp("ns"), + } as const; + const writer: QwpTableWriter = sender.writer("trades", schema); + const row: QwpWriterRow = { + symbol: "ETH-USD", + price: 2_615.54, + quantity: 42n, + timestamp: 1_723_000_000_000_000_000n, + }; + const single: Promise = writer.row(row); + const batch: Promise = writer.rows([row]); + const rootWriter: QwpTableWriter = rootSender.writer( + "trades", + schema, + ); + // @ts-expect-error The designated timestamp is required. + void writer.row({ price: 1 }); + // @ts-expect-error LONG values are bigint, not number. + void writer.row({ quantity: 42, timestamp: 1n }); + void single; + void batch; + void rootWriter; +} + function queryViewContract( session: QwpEgressSession, lease: QwpQueryLease, @@ -409,5 +446,6 @@ void nodeEgressOptionsContract; void rootExtraOptionsContract; void senderSequenceContract; void rootSenderSequenceContract; +void compiledWriterContract; void queryViewContract; void Sender; diff --git a/test/qwp/sender.test.ts b/test/qwp/sender.test.ts index f5fb49d..48ace29 100644 --- a/test/qwp/sender.test.ts +++ b/test/qwp/sender.test.ts @@ -8,7 +8,21 @@ import { QwpSenderCloseTimeoutError, QwpSenderSession, QwpTableBuffer, + QwpWriterRowError, + bool, + byte, + designatedTimestamp, + double, encodeQwpIngressFrame, + float32, + float64, + int32, + int64, + long, + short, + symbol as qwpSymbol, + timestamp, + varchar, } from "../../src/qwp"; class RecordingSession implements QwpSenderSession { @@ -617,6 +631,242 @@ describe("QWP high-level sender", () => { expect(table.columns.map((item) => item.name)).toEqual(["kept"]); }); + it("compiles a typed table writer and appends object rows", async () => { + const session = new RecordingSession(); + const sender = new QwpSender(async () => session, { autoFlush: false }); + const trades = sender.writer("trades", { + symbol: qwpSymbol(), + side: qwpSymbol(), + venue: varchar(), + active: bool(), + flags: byte(), + partition: short(), + sequence: int32(), + quantity: int64(), + spread: float32(), + price: float64(), + received: timestamp("ms"), + timestamp: designatedTimestamp("ns"), + }); + + await trades.row({ + symbol: "ETH-USD", + side: "sell", + venue: "LDN", + active: true, + flags: 1, + partition: 2, + sequence: 3, + quantity: 42n, + spread: 0.25, + price: 2_615.54, + received: 1_723_000_000_000, + timestamp: 1_723_000_000_000_000_000n, + }); + await trades.rows([ + { + symbol: "BTC-USD", + price: 39_269.98, + timestamp: 1_723_000_001_000_000_000n, + }, + ]); + async function* moreRows() { + yield { + symbol: "SOL-USD", + quantity: 7n, + timestamp: 1_723_000_002_000_000_000n, + }; + } + await trades.rows(moreRows()); + + expect(sender.metrics).toMatchObject({ + totalRowsStaged: 3, + pendingRows: 3, + }); + await sender.flush(); + + const table = session.sends[0].tables[0]; + expect(table.name).toBe("trades"); + expect(table.rowCount).toBe(3); + expect(column(table, "symbol")).toMatchObject({ + type: QWP_COLUMN_TYPE.SYMBOL, + values: ["ETH-USD", "BTC-USD", "SOL-USD"], + nulls: [false, false, false], + }); + expect(column(table, "side")).toMatchObject({ + values: ["sell"], + nulls: [false, true, true], + }); + expect(column(table, "quantity")).toMatchObject({ + type: QWP_COLUMN_TYPE.LONG, + values: [42n, 7n], + nulls: [false, true, false], + }); + // Widths are pinned deliberately: the fluent API's floatColumn() and + // intColumn() are 64-bit, so the writer's names must not drift. + expect(column(table, "spread")).toMatchObject({ + type: QWP_COLUMN_TYPE.FLOAT, + values: [0.25], + }); + expect(column(table, "price")).toMatchObject({ + type: QWP_COLUMN_TYPE.DOUBLE, + values: [2_615.54, 39_269.98], + }); + expect(column(table, "sequence")).toMatchObject({ + type: QWP_COLUMN_TYPE.INT, + values: [3], + }); + expect(column(table, "received")).toMatchObject({ + type: QWP_COLUMN_TYPE.TIMESTAMP, + values: [1_723_000_000_000_000n], + }); + expect(column(table, "")).toMatchObject({ + type: QWP_COLUMN_TYPE.TIMESTAMP_NANOS, + values: [ + 1_723_000_000_000_000_000n, + 1_723_000_001_000_000_000n, + 1_723_000_002_000_000_000n, + ], + }); + }); + + it("maps width aliases onto the same column types", () => { + expect(double()).toEqual(float64()); + expect(long()).toEqual(int64()); + expect(float32()).not.toEqual(float64()); + expect(int32()).not.toEqual(int64()); + }); + + it("reports an open fluent row ahead of object-row validation", async () => { + const sender = new QwpSender(async () => new RecordingSession(), { + autoFlush: false, + }); + const trades = sender.writer("trades", { + price: double(), + timestamp: designatedTimestamp("ns"), + }); + + sender.table("trades").symbol("side", "buy"); + // Both faults apply; the conflicting fluent row is the actionable one. + await expect( + trades.row({ price: "nope", timestamp: 1n } as never), + ).rejects.toMatchObject({ + name: "QwpWriterRowError", + columnName: undefined, + }); + await expect(trades.row({ price: 1, timestamp: 1n })).rejects.toThrow( + /a fluent row is already in progress/, + ); + + // Closing the fluent row hands the table back to the writer. + await sender.at(5n, "ns"); + await trades.row({ price: 1, timestamp: 1n }); + expect(sender.metrics.pendingRows).toBe(2); + }); + + it("rejects invalid object rows without poisoning writer state", async () => { + const session = new RecordingSession(); + const sender = new QwpSender(async () => session, { autoFlush: false }); + const trades = sender.writer("trades", { + symbol: qwpSymbol(), + price: double(), + quantity: long(), + timestamp: designatedTimestamp("ns"), + }); + + await expect( + trades.row({ + symbol: "bad", + price: "not-a-number", + timestamp: 1n, + } as never), + ).rejects.toMatchObject({ + name: "QwpWriterRowError", + tableName: "trades", + columnName: "price", + rowIndex: undefined, + }); + expect(sender.metrics.pendingRows).toBe(0); + + await expect( + trades.rows([ + { symbol: "ETH-USD", price: 2_615.54, timestamp: 2n }, + { + symbol: "BTC-USD", + price: 39_269.98, + timestamp: undefined, + } as never, + ]), + ).rejects.toMatchObject({ + name: "QwpWriterRowError", + columnName: "timestamp", + rowIndex: 1, + }); + expect(sender.metrics.pendingRows).toBe(1); + + await trades.row({ + symbol: "SOL-USD", + quantity: 7n, + timestamp: 3n, + }); + await sender.flush(); + const table = session.sends[0].tables[0]; + expect(table.rowCount).toBe(2); + expect(column(table, "symbol").values).toEqual(["ETH-USD", "SOL-USD"]); + }); + + it("rejects unknown keys and invalid compiled schemas", async () => { + const sender = new QwpSender(async () => new RecordingSession(), { + autoFlush: false, + }); + const trades = sender.writer("trades", { + price: double(), + timestamp: designatedTimestamp("ns"), + }); + + await expect( + trades.row({ price: 1, timestamp: 1n, prise: 2 } as never), + ).rejects.toMatchObject({ + columnName: "prise", + rowIndex: undefined, + }); + expect(() => + sender.writer("trades", { + timestamp: designatedTimestamp("ns"), + received: designatedTimestamp("us"), + }), + ).toThrow(/more than one designated timestamp/); + expect(() => + sender.writer("trades", { + Price: double(), + price: double(), + }), + ).toThrow(/duplicate case-insensitive/); + expect(() => sender.writer("trades", { price: {} as never })).toThrow( + /invalid QWP writer descriptor/, + ); + }); + + it("keeps compiled rows atomic across concurrent calls and sender reset", async () => { + const session = new RecordingSession(); + const sender = new QwpSender(async () => session, { autoFlush: false }); + const events = sender.writer("events", { + value: long(), + timestamp: designatedTimestamp("ns"), + }); + + await Promise.all([ + events.row({ value: 1n, timestamp: 10n }), + events.row({ value: 2n, timestamp: 20n }), + ]); + sender.reset(); + await events.row({ value: 3n, timestamp: 30n }); + await sender.flush(); + + expect(session.sends[0].tables[0].rowCount).toBe(1); + expect(column(session.sends[0].tables[0], "value").values).toEqual([3n]); + }); + it("can await durable ACKs and auto-flush by row count", async () => { const session = new RecordingSession(); const sender = new QwpSender(async () => session, { From b8a57b34f03a3d716538aebc20159dcbd7266864 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 20 Aug 2026 02:40:57 +0100 Subject: [PATCH 087/265] feat(qwp): complete compiled writer column types The compiled object-row writer covered ten scalar kinds, so rows touching UUID, LONG256, IPV4, GEOHASH, DECIMAL, DATE, CHAR, BINARY, or array columns had to fall back to the fluent row builder. Add the remaining factories so the schema vocabulary maps onto every QWP column type. Geohash precision and decimal scale belong to the column rather than the value, so they are fixed when the schema is compiled, mirrored onto every staged column, and reconciled against the sender's staged schema on append. Each field also accepts the shape its egress result view emits, letting a query result be written back without conversion. Decimal text and scaled records rescale to the column's scale only when exact, and are rejected when that would round. Co-Authored-By: Claude Opus 5 (1M context) --- QWP.md | 46 ++++- README.md | 6 + src/qwp/index.ts | 23 +++ src/qwp/sender.ts | 327 +++++++++++++++++++++++++++++++- src/qwp/writer.ts | 206 +++++++++++++++++++- test/qwp/public-api-contract.ts | 64 +++++++ test/qwp/sender.test.ts | 259 +++++++++++++++++++++++++ 7 files changed, 919 insertions(+), 12 deletions(-) diff --git a/QWP.md b/QWP.md index d98f984..de56363 100644 --- a/QWP.md +++ b/QWP.md @@ -391,17 +391,53 @@ await trades.rows([ `rows()` accepts `Iterable` and `AsyncIterable` sources and applies the sender's normal auto-flush, batch-cap, backpressure, transaction, symbol-dictionary, and ACK -settings. The schema is validated once. `symbol()`, `varchar()`, `bool()`, `byte()`, -`short()`, `int32()`, `int64()`, `float32()`, `float64()`, `timestamp(unit)`, and -`designatedTimestamp(unit)` define the currently supported object fields; `long()` and -`double()` are aliases of `int64()` and `float64()`. LONG and nanosecond timestamp -inputs are `bigint` so they cannot silently lose precision. +settings. The schema is validated once. + +The schema vocabulary covers every column type the fluent row API can write: + +| Field | QuestDB type | Accepted row values | +| --------------------------- | -------------------- | -------------------------------------------------------------------------------------- | +| `symbol()` | SYMBOL | `string` | +| `varchar()` | VARCHAR | `string` | +| `char()` | CHAR | `string` of one UTF-16 code unit | +| `bool()` | BOOLEAN | `boolean` | +| `byte()` | BYTE | `number` | +| `short()` | SHORT | `number` | +| `int32()` | INT | `number` | +| `int64()`, `long()` | LONG | `bigint` | +| `float32()` | FLOAT | `number` | +| `float64()`, `double()` | DOUBLE | `number` | +| `timestamp(unit)` | TIMESTAMP | `number` or `bigint`; `"ns"` requires `bigint` | +| `designatedTimestamp(unit)` | designated TIMESTAMP | as above, required in every row | +| `date()` | DATE | `number` or `bigint` milliseconds since the epoch | +| `binary()` | BINARY | `Uint8Array`, copied on append | +| `uuid()` | UUID | canonical UUID text, 16 bytes, or `{ low, high }` | +| `long256()` | LONG256 | unsigned 256-bit `bigint`, `0x` hex text, four little-endian words, or `{ words }` | +| `ipv4()` | IPV4 | dotted-quad text or the packed address; `0.0.0.0` is the NULL sentinel | +| `geohash(precisionBits)` | GEOHASH | raw bits, base-32 text of `precisionBits / 5` characters, or `{ bits, precisionBits }` | +| `decimal64(scale)` | DECIMAL64 | unscaled `bigint`, decimal text, `number`, or `{ unscaled, scale }` | +| `decimal128(scale)` | DECIMAL128 | as above, scale up to 38 | +| `decimal256(scale)` | DECIMAL256 | as above, scale up to 76 | +| `doubleArray()` | DOUBLE[] | nested `number` arrays of uniform shape, or `{ dimensions, values }` | +| `longArray()` | LONG[] | nested `bigint`/`number` arrays of uniform shape, or `{ dimensions, values }` | + +LONG, LONG256, and nanosecond timestamp inputs are `bigint` so they cannot silently +lose precision. The record forms are exactly what the egress result views hand back, +so a query result value can be written straight into a row without conversion. Widths are spelled out deliberately. The fluent row API predates these names and its `floatColumn()` and `intColumn()` are 64-bit despite reading as 32-bit, with `float32Column()` and `int32Column()` as the narrow forms. Compiled writers avoid the ambiguity: `float32()`/`float64()` and `int32()`/`int64()` mean exactly what they say. +Geohash precision and decimal scale belong to the column, not the value, so they are +fixed when the schema is compiled and validated against the sender's staged schema on +every append. Decimal text and `{ unscaled, scale }` values are rescaled to the +column's scale when that is exact, and rejected when it would round: at +`decimal64(2)`, `"1.50"` stages as `150n` and `"1.005"` raises `QwpWriterRowError`. +Base-32 geohash text carries five bits per character, so `geohash(20)` accepts +`"u33d"` and rejects `"u33"`. + Regular fields may be absent, `null`, or `undefined`, which writes a NULL. A schema may contain at most one designated timestamp and, when present, that field is required in every row. Unknown object keys and type mismatches raise `QwpWriterRowError`; bulk diff --git a/README.md b/README.md index 8ae32e9..db82a75 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,12 @@ await trades.row({ await trades.rows(moreTrades); ``` +The schema vocabulary covers every QuestDB column type the fluent row API can write, +including `date()`, `char()`, `binary()`, `uuid()`, `long256()`, `ipv4()`, +`geohash(precisionBits)`, `decimal64/128/256(scale)`, `doubleArray()`, and +`longArray()`. See [QWP.md](QWP.md#compiled-object-row-writers) for the accepted value +forms of each field. + The regular `Sender` accepts the same unified QWP configuration vocabulary as the pooled Node client. Use comma-separated or repeated `addr` values for failover; standalone ingress validates but otherwise ignores egress- and diff --git a/src/qwp/index.ts b/src/qwp/index.ts index 7ee9d09..7a8034c 100644 --- a/src/qwp/index.ts +++ b/src/qwp/index.ts @@ -14,23 +14,46 @@ export * from "./sender"; export * from "./sender-error"; export * from "./transport"; export { + binary, bool, byte, + char, + date, + decimal64, + decimal128, + decimal256, designatedTimestamp, double, + doubleArray, float32, float64, + geohash, int32, int64, + ipv4, long, + long256, + longArray, short, symbol, timestamp, + uuid, varchar, + QWP_DECIMAL_MAX_SCALE, QwpWriterRowError, } from "./writer"; export type { + QwpDecimalInput, + QwpDoubleArrayInput, + QwpGeohashInput, + QwpIpv4Input, + QwpLong256Input, + QwpLong256Words, + QwpLongArrayInput, + QwpNestedLongArray, + QwpNestedNumberArray, QwpTimestampUnit, + QwpUuidInput, QwpWriterColumn, QwpWriterColumnKind, QwpWriterRow, diff --git a/src/qwp/sender.ts b/src/qwp/sender.ts index abb7b38..e97f060 100644 --- a/src/qwp/sender.ts +++ b/src/qwp/sender.ts @@ -17,6 +17,8 @@ import { qwpColumnNameKey, validateQwpColumnName } from "./core/identifiers"; import { isQwpWriterColumn, QwpWriterRowError, + validateDecimalScale, + validateGeohashPrecision, type QwpTimestampUnit, type QwpWriterColumn, type QwpWriterRow, @@ -190,6 +192,10 @@ interface CompiledQwpWriterColumn { readonly nameKey: string; readonly type: QwpColumnType; readonly descriptor: QwpWriterColumn; + /** Fixed GEOHASH precision, mirrored onto every staged column. */ + readonly geohashPrecision?: number; + /** Fixed DECIMAL scale, mirrored onto every staged column. */ + readonly decimalScale?: number; } interface CompiledQwpWriterSchema { @@ -422,6 +428,230 @@ function parseIpv4(value: string | number): number { return packed; } +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function fitsUnsigned(value: bigint, bits: number): boolean { + return BigInt.asUintN(bits, value) === value; +} + +/** Accepts either signed or unsigned 64-bit limbs, as the egress views emit. */ +function checkedLimb64(value: unknown, name: string): bigint { + if (typeof value !== "bigint") + throw new TypeError(`${name} must be a bigint`); + if (!fitsSigned(value, 64) && !fitsUnsigned(value, 64)) { + throw new RangeError(`${name} does not fit in 64 bits`); + } + return BigInt.asUintN(64, value); +} + +function uuidLimbBytes(low: bigint, high: bigint): Uint8Array { + const bytes = new Uint8Array(16); + const view = new DataView(bytes.buffer); + view.setBigUint64(0, low, true); + view.setBigUint64(8, high, true); + return bytes; +} + +function writerUuidBytes(value: unknown): Uint8Array { + if (typeof value === "string" || value instanceof Uint8Array) { + return uuidBytes(value); + } + if (isRecord(value) && "low" in value && "high" in value) { + return uuidLimbBytes( + checkedLimb64(value.low, "UUID low limb"), + checkedLimb64(value.high, "UUID high limb"), + ); + } + throw new TypeError( + "uuid accepts canonical UUID text, 16 bytes, or {low, high} limbs", + ); +} + +function long256WordBytes(words: readonly unknown[]): Uint8Array { + if (words.length !== 4) { + throw new TypeError("long256 accepts exactly four 64-bit words"); + } + return littleEndianWords( + words.map((word, index) => + BigInt.asIntN(64, checkedLimb64(word, `LONG256 word ${index}`)), + ), + ); +} + +function long256MagnitudeBytes(value: bigint): Uint8Array { + if (!fitsUnsigned(value, 256)) { + throw new RangeError("long256 value must be an unsigned 256-bit integer"); + } + const words: bigint[] = []; + for (let index = 0; index < 4; index++) { + words.push( + BigInt.asIntN(64, (value >> BigInt(index * 64)) & 0xffffffffffffffffn), + ); + } + return littleEndianWords(words); +} + +function writerLong256Bytes(value: unknown): Uint8Array { + if (typeof value === "bigint") return long256MagnitudeBytes(value); + if (typeof value === "string") { + if (!/^0x[0-9a-f]{1,64}$/i.test(value)) { + throw new TypeError( + "long256 text must be a 0x-prefixed hex value of up to 64 digits", + ); + } + return long256MagnitudeBytes(BigInt(value)); + } + if (Array.isArray(value)) return long256WordBytes(value); + if (isRecord(value) && Array.isArray(value.words)) { + return long256WordBytes(value.words); + } + throw new TypeError( + "long256 accepts a bigint, 0x hex text, four words, or {words}", + ); +} + +/** QuestDB's base-32 geohash alphabet; five bits per character. */ +const GEOHASH_ALPHABET = "0123456789bcdefghjkmnpqrstuvwxyz"; + +function geohashTextBits(text: string, precisionBits: number): bigint { + if (text.length * 5 !== precisionBits) { + throw new RangeError( + `geohash text of ${text.length} character(s) carries ${text.length * 5} bits, but the column is ${precisionBits} bits`, + ); + } + let bits = 0n; + for (const character of text.toLowerCase()) { + const index = GEOHASH_ALPHABET.indexOf(character); + if (index < 0) { + throw new TypeError(`invalid geohash character '${character}'`); + } + bits = (bits << 5n) | BigInt(index); + } + return bits; +} + +function writerGeohashBits(value: unknown, precisionBits: number): bigint { + let bits: bigint; + if (typeof value === "string") { + bits = geohashTextBits(value, precisionBits); + } else if (typeof value === "bigint" || typeof value === "number") { + bits = checkedBigInt(value, "geohash value"); + } else if (isRecord(value) && "bits" in value) { + if ( + value.precisionBits !== undefined && + value.precisionBits !== precisionBits + ) { + throw new RangeError( + `geohash precision mismatch [column=${precisionBits}, received=${String(value.precisionBits)}]`, + ); + } + bits = checkedBigInt(value.bits as number | bigint, "geohash value"); + } else { + throw new TypeError( + "geohash accepts raw bits, base-32 text, or {bits, precisionBits}", + ); + } + if (bits < 0n || bits >= 1n << BigInt(precisionBits)) { + throw new RangeError("geohash value does not fit the column precision"); + } + return bits; +} + +function rescaleDecimal( + unscaled: bigint, + fromScale: number, + toScale: number, +): bigint { + if (fromScale === toScale) return unscaled; + if (fromScale < toScale) { + return unscaled * 10n ** BigInt(toScale - fromScale); + } + const divisor = 10n ** BigInt(fromScale - toScale); + if (unscaled % divisor !== 0n) { + throw new RangeError( + `decimal value is not exactly representable at scale ${toScale}`, + ); + } + return unscaled / divisor; +} + +function writerDecimalUnscaled( + value: unknown, + scale: number, + bits: number, +): bigint { + let unscaled: bigint; + if (typeof value === "bigint") { + unscaled = value; + } else if (typeof value === "string" || typeof value === "number") { + const parsed = parseDecimal(value); + unscaled = rescaleDecimal(parsed.unscaled, parsed.scale, scale); + } else if (isRecord(value) && "unscaled" in value) { + if (typeof value.unscaled !== "bigint") { + throw new TypeError("decimal unscaled value must be a bigint"); + } + if (!Number.isSafeInteger(value.scale) || (value.scale as number) < 0) { + throw new TypeError("decimal scale must be a non-negative safe integer"); + } + unscaled = rescaleDecimal(value.unscaled, value.scale as number, scale); + } else { + throw new TypeError( + "decimal accepts a bigint, decimal text, a number, or {unscaled, scale}", + ); + } + if (!fitsSigned(unscaled, bits)) { + throw new RangeError(`decimal value exceeds signed int${bits}`); + } + return unscaled; +} + +function writerArrayValue(value: unknown, elements: "double" | "long") { + let array: QwpArrayValue; + if (Array.isArray(value)) { + array = flattenQwpArray(value); + } else if ( + isRecord(value) && + Array.isArray(value.dimensions) && + Array.isArray(value.values) + ) { + const dimensions = value.dimensions.map((dimension, index) => + checkedRange( + dimension as number, + 0, + Number.MAX_SAFE_INTEGER, + `array dimension ${index}`, + ), + ); + if (dimensions.length === 0 || dimensions.length > 255) { + throw new RangeError("QWP array must have between 1 and 255 dimensions"); + } + const expected = dimensions.reduce( + (total, dimension) => total * dimension, + 1, + ); + if (expected !== value.values.length) { + throw new RangeError( + `array shape ${dimensions.join("x")} needs ${expected} value(s), received ${value.values.length}`, + ); + } + array = { dimensions, values: [...value.values] as (number | bigint)[] }; + } else { + throw new TypeError( + `${elements}Array accepts nested arrays or {dimensions, values}`, + ); + } + if (elements === "long") { + array.values = array.values.map((item) => + checkedInt64(item, "long array value"), + ); + } else if (array.values.some((item) => typeof item !== "number")) { + throw new TypeError("doubleArray accepts only number values"); + } + return array; +} + function qwpWriterColumnType( descriptor: QwpWriterColumn, ): QwpColumnType { @@ -448,6 +678,55 @@ function qwpWriterColumnType( return descriptor.unit === "ns" ? QWP_COLUMN_TYPE.TIMESTAMP_NANOS : QWP_COLUMN_TYPE.TIMESTAMP; + case "date": + return QWP_COLUMN_TYPE.DATE; + case "char": + return QWP_COLUMN_TYPE.CHAR; + case "binary": + return QWP_COLUMN_TYPE.BINARY; + case "uuid": + return QWP_COLUMN_TYPE.UUID; + case "long256": + return QWP_COLUMN_TYPE.LONG256; + case "ipv4": + return QWP_COLUMN_TYPE.IPV4; + case "geohash": + return QWP_COLUMN_TYPE.GEOHASH; + case "decimal64": + return QWP_COLUMN_TYPE.DECIMAL64; + case "decimal128": + return QWP_COLUMN_TYPE.DECIMAL128; + case "decimal256": + return QWP_COLUMN_TYPE.DECIMAL256; + case "doubleArray": + return QWP_COLUMN_TYPE.DOUBLE_ARRAY; + case "longArray": + return QWP_COLUMN_TYPE.LONG_ARRAY; + } +} + +/** Lifts the descriptor's fixed geohash precision or decimal scale, if any. */ +function qwpWriterColumnMetadata( + descriptor: QwpWriterColumn, +): Pick { + switch (descriptor.kind) { + case "geohash": + return { + geohashPrecision: validateGeohashPrecision( + descriptor.precisionBits as number, + ), + }; + case "decimal64": + case "decimal128": + case "decimal256": + return { + decimalScale: validateDecimalScale( + descriptor.scale as number, + descriptor.kind, + ), + }; + default: + return {}; } } @@ -499,6 +778,42 @@ function encodeQwpWriterValue( } return timestampValue(value, column.descriptor.unit ?? "us").value; } + case "date": + if (typeof value !== "number" && typeof value !== "bigint") { + throw new TypeError("date accepts only number or bigint values"); + } + return checkedInt64(value, "date value"); + case "char": + if (typeof value !== "string" || value.length !== 1) { + throw new TypeError("char accepts one UTF-16 code unit"); + } + return value; + case "binary": + if (!(value instanceof Uint8Array)) { + throw new TypeError("binary accepts only Uint8Array values"); + } + return new Uint8Array(value); + case "uuid": + return writerUuidBytes(value); + case "long256": + return writerLong256Bytes(value); + case "ipv4": + if (typeof value !== "string" && typeof value !== "number") { + throw new TypeError("ipv4 accepts dotted-quad text or a packed number"); + } + return parseIpv4(value); + case "geohash": + return writerGeohashBits(value, column.geohashPrecision as number); + case "decimal64": + return writerDecimalUnscaled(value, column.decimalScale as number, 64); + case "decimal128": + return writerDecimalUnscaled(value, column.decimalScale as number, 128); + case "decimal256": + return writerDecimalUnscaled(value, column.decimalScale as number, 256); + case "doubleArray": + return writerArrayValue(value, "double"); + case "longArray": + return writerArrayValue(value, "long"); } } @@ -1389,6 +1704,7 @@ export class QwpSender { nameKey, type: qwpWriterColumnType(candidate), descriptor: candidate, + ...qwpWriterColumnMetadata(candidate), }), ); } @@ -1460,11 +1776,18 @@ export class QwpSender { continue; } try { - columns.set(column.nameKey, { + const staged: StagedColumn = { name: column.wireName, type: column.type, value: encodeQwpWriterValue(column, value), - }); + }; + if (column.geohashPrecision !== undefined) { + staged.geohashPrecision = column.geohashPrecision; + } + if (column.decimalScale !== undefined) { + staged.decimalScale = column.decimalScale; + } + columns.set(column.nameKey, staged); } catch (error) { throw new QwpWriterRowError( schema.tableName, diff --git a/src/qwp/writer.ts b/src/qwp/writer.ts index 6879c57..e737ec6 100644 --- a/src/qwp/writer.ts +++ b/src/qwp/writer.ts @@ -10,11 +10,30 @@ export type QwpWriterColumnKind = | "int64" | "float32" | "float64" - | "timestamp"; + | "timestamp" + | "date" + | "char" + | "binary" + | "uuid" + | "long256" + | "ipv4" + | "geohash" + | "decimal64" + | "decimal128" + | "decimal256" + | "doubleArray" + | "longArray"; const QWP_WRITER_COLUMN = Symbol("QWP writer column"); const QWP_WRITER_INPUT: unique symbol = Symbol("QWP writer input"); +/** Maximum DECIMAL scale of each fixed-width decimal column type. */ +export const QWP_DECIMAL_MAX_SCALE = { + decimal64: 18, + decimal128: 38, + decimal256: 76, +} as const; + /** A reusable, immutable column definition for a compiled QWP table writer. */ export interface QwpWriterColumn< T, @@ -23,6 +42,10 @@ export interface QwpWriterColumn< readonly kind: QwpWriterColumnKind; readonly designatedTimestamp: DesignatedTimestamp; readonly unit?: QwpTimestampUnit; + /** GEOHASH precision in bits, fixed for the whole column. */ + readonly precisionBits?: number; + /** DECIMAL scale, fixed for the whole column. */ + readonly scale?: number; /** @internal Carries the input type without adding a runtime value. */ readonly [QWP_WRITER_INPUT]?: T; } @@ -66,15 +89,89 @@ type TimestampInput = Unit extends "ns" ? bigint : number | bigint; +/** UUID input: canonical text, 16 bytes, or the egress limb pair. */ +export type QwpUuidInput = + | string + | Uint8Array + | { readonly low: bigint; readonly high: bigint }; + +/** LONG256 little-endian words; word 0 is least significant. */ +export type QwpLong256Words = readonly [bigint, bigint, bigint, bigint]; + +/** + * LONG256 input: an unsigned 256-bit `bigint`, a `0x`-prefixed hex string of + * up to 64 digits, four little-endian words, or the egress word record. + */ +export type QwpLong256Input = + | bigint + | string + | QwpLong256Words + | { readonly words: QwpLong256Words }; + +/** IPV4 input: dotted-quad text or the packed 32-bit address. */ +export type QwpIpv4Input = string | number; + +/** + * GEOHASH input: the raw bits, base-32 geohash text whose length matches the + * column precision, or the egress bit record. + */ +export type QwpGeohashInput = + | bigint + | number + | string + | { readonly bits: bigint; readonly precisionBits: number }; + +/** + * DECIMAL input: the unscaled `bigint` at the column's scale, decimal text (or + * a number) that is exactly representable at that scale, or the egress record. + */ +export type QwpDecimalInput = + | bigint + | number + | string + | { readonly unscaled: bigint; readonly scale: number }; + +/** Nested DOUBLE array of uniform shape. */ +export type QwpNestedNumberArray = readonly (number | QwpNestedNumberArray)[]; + +/** Nested LONG array of uniform shape. */ +export type QwpNestedLongArray = readonly ( + | number + | bigint + | QwpNestedLongArray +)[]; + +/** DOUBLE array input: nested arrays or a flat shape-and-values record. */ +export type QwpDoubleArrayInput = + | QwpNestedNumberArray + | { + readonly dimensions: readonly number[]; + readonly values: readonly number[]; + }; + +/** LONG array input: nested arrays or a flat shape-and-values record. */ +export type QwpLongArrayInput = + | QwpNestedLongArray + | { + readonly dimensions: readonly number[]; + readonly values: readonly (number | bigint)[]; + }; + +interface QwpWriterColumnMetadata { + unit?: QwpTimestampUnit; + precisionBits?: number; + scale?: number; +} + function column( kind: QwpWriterColumnKind, designatedTimestamp: DesignatedTimestamp, - unit?: QwpTimestampUnit, + metadata: QwpWriterColumnMetadata = {}, ): QwpWriterColumn { return Object.freeze({ kind, designatedTimestamp, - unit, + ...metadata, [QWP_WRITER_COLUMN]: true, }) as BrandedQwpWriterColumn; } @@ -85,6 +182,30 @@ function validateTimestampUnit(unit: QwpTimestampUnit): void { } } +/** @internal Shared by the writer factories and the schema compiler. */ +export function validateGeohashPrecision(precisionBits: number): number { + if ( + !Number.isSafeInteger(precisionBits) || + precisionBits < 1 || + precisionBits > 60 + ) { + throw new RangeError("geohash precision must be between 1 and 60 bits"); + } + return precisionBits; +} + +/** @internal Shared by the writer factories and the schema compiler. */ +export function validateDecimalScale( + scale: number, + kind: keyof typeof QWP_DECIMAL_MAX_SCALE, +): number { + const maximumScale = QWP_DECIMAL_MAX_SCALE[kind]; + if (!Number.isSafeInteger(scale) || scale < 0 || scale > maximumScale) { + throw new RangeError(`${kind} scale must be between 0 and ${maximumScale}`); + } + return scale; +} + /** Defines a string-valued QuestDB SYMBOL column. */ export function symbol(): QwpWriterColumn { return column("symbol", false); @@ -145,7 +266,7 @@ export function timestamp( unit: Unit = "us" as Unit, ): QwpWriterColumn> { validateTimestampUnit(unit); - return column("timestamp", false, unit); + return column("timestamp", false, { unit }); } /** Defines the writer's required designated timestamp field. */ @@ -153,7 +274,82 @@ export function designatedTimestamp( unit: Unit = "us" as Unit, ): QwpWriterColumn, true> { validateTimestampUnit(unit); - return column("timestamp", true, unit); + return column("timestamp", true, { unit }); +} + +/** Defines a QuestDB DATE column. Inputs are milliseconds since the epoch. */ +export function date(): QwpWriterColumn { + return column("date", false); +} + +/** Defines a QuestDB CHAR column. Inputs are one UTF-16 code unit. */ +export function char(): QwpWriterColumn { + return column("char", false); +} + +/** Defines a QuestDB BINARY column. Inputs are copied on append. */ +export function binary(): QwpWriterColumn { + return column("binary", false); +} + +/** Defines a QuestDB UUID column. */ +export function uuid(): QwpWriterColumn { + return column("uuid", false); +} + +/** Defines a QuestDB LONG256 column. */ +export function long256(): QwpWriterColumn { + return column("long256", false); +} + +/** Defines a QuestDB IPV4 column. `0.0.0.0` is the NULL sentinel. */ +export function ipv4(): QwpWriterColumn { + return column("ipv4", false); +} + +/** + * Defines a QuestDB GEOHASH column of fixed precision. + * + * @param precisionBits - Precision in bits, 1 through 60. Base-32 text inputs + * carry five bits per character, so `geohash(20)` accepts four characters. + */ +export function geohash( + precisionBits: number, +): QwpWriterColumn { + return column("geohash", false, { + precisionBits: validateGeohashPrecision(precisionBits), + }); +} + +/** Defines a QuestDB DECIMAL64 column of fixed scale, up to 18. */ +export function decimal64(scale: number): QwpWriterColumn { + return column("decimal64", false, { + scale: validateDecimalScale(scale, "decimal64"), + }); +} + +/** Defines a QuestDB DECIMAL128 column of fixed scale, up to 38. */ +export function decimal128(scale: number): QwpWriterColumn { + return column("decimal128", false, { + scale: validateDecimalScale(scale, "decimal128"), + }); +} + +/** Defines a QuestDB DECIMAL256 column of fixed scale, up to 76. */ +export function decimal256(scale: number): QwpWriterColumn { + return column("decimal256", false, { + scale: validateDecimalScale(scale, "decimal256"), + }); +} + +/** Defines a QuestDB DOUBLE[] column of any uniform shape. */ +export function doubleArray(): QwpWriterColumn { + return column("doubleArray", false); +} + +/** Defines a QuestDB LONG[] column of any uniform shape. */ +export function longArray(): QwpWriterColumn { + return column("longArray", false); } /** A complete object row failed compiled-writer validation. */ diff --git a/test/qwp/public-api-contract.ts b/test/qwp/public-api-contract.ts index c2533ac..a9eb832 100644 --- a/test/qwp/public-api-contract.ts +++ b/test/qwp/public-api-contract.ts @@ -1,11 +1,23 @@ import { Sender } from "../../src"; import type { ExtraOptions, QwpExtraOptions } from "../../src"; import { + binary, + char, + date, + decimal64, + decimal128, + decimal256, defaultQwpSenderErrorHandler, designatedTimestamp, double, + doubleArray, + geohash, + ipv4, long, + long256, + longArray, symbol as qwpSymbol, + uuid, } from "../../src/qwp"; import { bootstrapQwpBrowserSession, @@ -372,6 +384,57 @@ function compiledWriterContract(sender: QwpSender, rootSender: Sender): void { void rootWriter; } +function compiledWriterTypeContract(sender: QwpSender): void { + const schema = { + created_date: date(), + letter: char(), + payload: binary(), + id: uuid(), + hash: long256(), + ip: ipv4(), + location: geohash(20), + price: decimal64(4), + wide_price: decimal128(2), + widest_price: decimal256(0), + samples: doubleArray(), + counters: longArray(), + timestamp: designatedTimestamp("ns"), + } as const; + const writer: QwpTableWriter = sender.writer("typed", schema); + const row: QwpWriterRow = { + created_date: 1_700_000_000_000n, + letter: "Q", + payload: Uint8Array.of(1, 2, 3), + id: "123e4567-e89b-12d3-a456-426614174000", + hash: "0x0102", + ip: "192.168.0.1", + location: "u33d", + price: "123.4500", + wide_price: 1_234n, + widest_price: { unscaled: 42n, scale: 0 }, + samples: [ + [1.5, 2.5], + [3.5, 4.5], + ], + counters: [1n, 2n, 3n], + timestamp: 1_723_000_000_000_000_000n, + }; + // Egress-shaped values are accepted without casts. + const egressShaped: QwpWriterRow = { + id: { low: 1n, high: 2n }, + hash: { words: [1n, 2n, 3n, 4n] }, + location: { bits: 7n, precisionBits: 20 }, + price: { unscaled: 1_234_500n, scale: 4 }, + samples: { dimensions: [2, 2], values: [1, 2, 3, 4] }, + timestamp: 1_723_000_001_000_000_000n, + }; + // @ts-expect-error BINARY values are bytes, not number arrays. + void writer.row({ payload: [1, 2, 3], timestamp: 1n }); + // @ts-expect-error CHAR values are strings. + void writer.row({ letter: 7, timestamp: 1n }); + void writer.rows([row, egressShaped]); +} + function queryViewContract( session: QwpEgressSession, lease: QwpQueryLease, @@ -447,5 +510,6 @@ void rootExtraOptionsContract; void senderSequenceContract; void rootSenderSequenceContract; void compiledWriterContract; +void compiledWriterTypeContract; void queryViewContract; void Sender; diff --git a/test/qwp/sender.test.ts b/test/qwp/sender.test.ts index 48ace29..66a3d61 100644 --- a/test/qwp/sender.test.ts +++ b/test/qwp/sender.test.ts @@ -9,19 +9,31 @@ import { QwpSenderSession, QwpTableBuffer, QwpWriterRowError, + binary, bool, byte, + char, + date, + decimal64, + decimal128, + decimal256, designatedTimestamp, double, + doubleArray, encodeQwpIngressFrame, float32, float64, + geohash, int32, int64, + ipv4, long, + long256, + longArray, short, symbol as qwpSymbol, timestamp, + uuid, varchar, } from "../../src/qwp"; @@ -730,6 +742,253 @@ describe("QWP high-level sender", () => { }); }); + it("compiles the remaining QuestDB column types into object rows", async () => { + const session = new RecordingSession(); + const sender = new QwpSender(async () => session, { autoFlush: false }); + const typed = sender.writer("typed", { + created_date: date(), + letter: char(), + payload: binary(), + id: uuid(), + hash: long256(), + ip: ipv4(), + location: geohash(20), + price: decimal64(4), + wide_price: decimal128(2), + widest_price: decimal256(0), + samples: doubleArray(), + counters: longArray(), + timestamp: designatedTimestamp("ns"), + }); + + await typed.row({ + created_date: 1_700_000_000_000n, + letter: "Q", + payload: Uint8Array.of(1, 2, 3), + id: "123e4567-e89b-12d3-a456-426614174000", + hash: "0x0102", + ip: "192.168.0.1", + // Base-32 geohash text carries five bits per character. + location: "u33d", + price: "123.4500", + wide_price: 1_234n, + widest_price: { unscaled: 42n, scale: 0 }, + samples: [ + [1.5, 2.5], + [3.5, 4.5], + ], + counters: [1n, 2n, 3n], + timestamp: 1_723_000_000_000_000_000n, + }); + // The shapes the egress views hand back are valid ingress inputs. + await typed.row({ + id: { low: 0x1122334455667788n, high: 0x99aabbccddeeff00n }, + hash: { words: [1n, 2n, 3n, 4n] }, + location: { bits: 7n, precisionBits: 20 }, + price: { unscaled: 1_234_500n, scale: 4 }, + samples: { dimensions: [2, 2], values: [1, 2, 3, 4] }, + ip: 0xc0a80002, + timestamp: 1_723_000_001_000_000_000n, + }); + await sender.flush(); + + const table = session.sends[0].tables[0]; + expect(table.rowCount).toBe(2); + expect(column(table, "created_date")).toMatchObject({ + type: QWP_COLUMN_TYPE.DATE, + values: [1_700_000_000_000n], + nulls: [false, true], + }); + expect(column(table, "letter")).toMatchObject({ + type: QWP_COLUMN_TYPE.CHAR, + values: ["Q"], + }); + expect(column(table, "payload")).toMatchObject({ + type: QWP_COLUMN_TYPE.BINARY, + values: [Uint8Array.of(1, 2, 3)], + }); + expect(column(table, "id")).toMatchObject({ + type: QWP_COLUMN_TYPE.UUID, + values: [ + // Canonical text and {low, high} limbs both encode little-endian. + Uint8Array.of( + 0x00, + 0x40, + 0x17, + 0x14, + 0x66, + 0x42, + 0x56, + 0xa4, + 0xd3, + 0x12, + 0x9b, + 0xe8, + 0x67, + 0x45, + 0x3e, + 0x12, + ), + Uint8Array.of( + 0x88, + 0x77, + 0x66, + 0x55, + 0x44, + 0x33, + 0x22, + 0x11, + 0x00, + 0xff, + 0xee, + 0xdd, + 0xcc, + 0xbb, + 0xaa, + 0x99, + ), + ], + }); + const hashes = column(table, "hash"); + expect(hashes.type).toBe(QWP_COLUMN_TYPE.LONG256); + expect(hashes.values[0]).toEqual( + Uint8Array.of(0x02, 0x01, ...new Uint8Array(30)), + ); + expect( + new DataView((hashes.values[1] as Uint8Array).buffer).getBigInt64( + 24, + true, + ), + ).toBe(4n); + expect(column(table, "ip")).toMatchObject({ + type: QWP_COLUMN_TYPE.IPV4, + values: [0xc0a80001, 0xc0a80002], + }); + expect(column(table, "location")).toMatchObject({ + type: QWP_COLUMN_TYPE.GEOHASH, + geohashPrecision: 20, + values: [855_148n, 7n], + }); + expect(column(table, "price")).toMatchObject({ + type: QWP_COLUMN_TYPE.DECIMAL64, + decimalScale: 4, + values: [1_234_500n, 1_234_500n], + }); + expect(column(table, "wide_price")).toMatchObject({ + type: QWP_COLUMN_TYPE.DECIMAL128, + decimalScale: 2, + values: [1_234n], + }); + expect(column(table, "widest_price")).toMatchObject({ + type: QWP_COLUMN_TYPE.DECIMAL256, + decimalScale: 0, + values: [42n], + }); + expect(column(table, "samples")).toMatchObject({ + type: QWP_COLUMN_TYPE.DOUBLE_ARRAY, + values: [ + { dimensions: [2, 2], values: [1.5, 2.5, 3.5, 4.5] }, + { dimensions: [2, 2], values: [1, 2, 3, 4] }, + ], + }); + expect(column(table, "counters")).toMatchObject({ + type: QWP_COLUMN_TYPE.LONG_ARRAY, + values: [{ dimensions: [3], values: [1n, 2n, 3n] }], + }); + expect(() => encodeQwpIngressFrame([table])).not.toThrow(); + }); + + it("validates fixed precision and scale when compiling the schema", () => { + const sender = new QwpSender(async () => new RecordingSession(), { + autoFlush: false, + }); + expect(() => geohash(0)).toThrow(/between 1 and 60 bits/); + expect(() => geohash(61)).toThrow(/between 1 and 60 bits/); + expect(() => decimal64(19)).toThrow( + /decimal64 scale must be between 0 and 18/, + ); + expect(() => decimal128(39)).toThrow(/between 0 and 38/); + expect(() => decimal256(-1)).toThrow(/between 0 and 76/); + expect(() => + sender.writer("typed", { location: geohash(5) }), + ).not.toThrow(); + }); + + it("rejects values that do not fit the compiled column type", async () => { + const session = new RecordingSession(); + const sender = new QwpSender(async () => session, { autoFlush: false }); + const typed = sender.writer("typed", { + letter: char(), + payload: binary(), + id: uuid(), + hash: long256(), + ip: ipv4(), + location: geohash(20), + price: decimal64(2), + samples: doubleArray(), + counters: longArray(), + timestamp: designatedTimestamp("ns"), + }); + const rejects = async ( + row: object, + message: RegExp, + columnName: string, + ) => { + await expect( + typed.row({ timestamp: 1n, ...row } as never), + ).rejects.toMatchObject({ name: "QwpWriterRowError", columnName }); + await expect( + typed.row({ timestamp: 1n, ...row } as never), + ).rejects.toThrow(message); + }; + + await rejects({ letter: "QQ" }, /one UTF-16 code unit/, "letter"); + await rejects({ payload: [1, 2, 3] }, /only Uint8Array values/, "payload"); + await rejects({ id: "not-a-uuid" }, /canonical UUID/, "id"); + await rejects({ hash: "0102" }, /0x-prefixed hex/, "hash"); + await rejects({ hash: [1n, 2n] }, /exactly four 64-bit words/, "hash"); + await rejects({ ip: "0.0.0.0" }, /NULL sentinel/, "ip"); + await rejects({ location: "u33" }, /column is 20 bits/, "location"); + await rejects({ location: 1n << 21n }, /does not fit/, "location"); + await rejects( + { location: { bits: 1n, precisionBits: 25 } }, + /precision mismatch/, + "location", + ); + await rejects( + { price: "1.005" }, + /not exactly representable at scale 2/, + "price", + ); + await rejects({ price: 1n << 70n }, /exceeds signed int64/, "price"); + await rejects({ samples: [1n, 2n] }, /only number values/, "samples"); + await rejects({ counters: [[1n], [2n, 3n]] }, /irregular/, "counters"); + await rejects( + { samples: { dimensions: [2, 2], values: [1, 2, 3] } }, + /needs 4 value\(s\), received 3/, + "samples", + ); + expect(sender.metrics.pendingRows).toBe(0); + + // Trailing zeros rescale exactly, so the same column still accepts text. + await typed.row({ price: "1.50", timestamp: 2n }); + await sender.flush(); + expect(column(session.sends[0].tables[0], "price").values).toEqual([150n]); + }); + + it("reconciles compiled precision and scale with the fluent row API", async () => { + const sender = new QwpSender(async () => new RecordingSession(), { + autoFlush: false, + }); + const typed = sender.writer("typed", { location: geohash(20) }); + + await sender.table("typed").geohashColumn("location", 3n, 25).atNow(); + await expect(typed.row({ location: 7n })).rejects.toThrow( + /conflicts with the sender's staged schema/, + ); + expect(sender.metrics.pendingRows).toBe(1); + }); + it("maps width aliases onto the same column types", () => { expect(double()).toEqual(float64()); expect(long()).toEqual(int64()); From b90d56e7e4b055ae514973e352c4abfe1ac5d0c3 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 20 Aug 2026 02:41:23 +0100 Subject: [PATCH 088/265] fix(qwp): release the table selection on failed rows A staged fluent row is its columns plus the table chosen by table(), but failRow() cleared only the columns. A rejected value therefore left the sender inside a row with no columns: table() raised "Table name has already been set", while further setters silently accumulated into a fresh row on the old table. The only recovery was reset(), which also drops every row staged since the last flush. Discard both halves so a failed row is a discarded row. cancelRow() had the same one-sided clear and now shares the rollback. Setters called after a failure raise "table name must be set before adding columns" instead of quietly opening a new row, so the rollback test re-selects the table, matching what callers must now do. Co-Authored-By: Claude Opus 5 (1M context) --- QWP.md | 25 +++++++++++++ src/qwp/sender.ts | 18 ++++++++-- test/qwp/sender.test.ts | 80 ++++++++++++++++++++++++++++++++++++++++- 3 files changed, 120 insertions(+), 3 deletions(-) diff --git a/QWP.md b/QWP.md index de56363..5d33172 100644 --- a/QWP.md +++ b/QWP.md @@ -356,6 +356,31 @@ try { } ``` +A row in progress is the columns staged so far plus the table selected by +`table()`. When a setter or `at()` rejects a value, the sender discards both, so a +half-built row can never reach QuestDB and the next row starts from `table()` again: + +```typescript +for (const reading of readings) { + try { + await sender + .table("telemetry") + .symbol("device", reading.device) + .floatColumn("value", reading.value) + .at(reading.timestamp, "ms"); + } catch (error) { + // Only this row is gone. Rows staged earlier stay pending. + log.warn(error); + } +} +await sender.flush(); +``` + +Setters called after a failure raise `table name must be set before adding columns` +rather than quietly joining a fresh row. `cancelRow()` discards a row in progress the +same way without an error, and `reset()` remains the heavier option that also drops +every row staged since the last flush. + ### Compiled object-row writers For repeated rows with one table schema, compile a table-bound writer instead of diff --git a/src/qwp/sender.ts b/src/qwp/sender.ts index e97f060..00ffe45 100644 --- a/src/qwp/sender.ts +++ b/src/qwp/sender.ts @@ -1370,9 +1370,13 @@ export class QwpSender { }); } + /** + * Discards the row in progress, including its table selection, so the next + * row starts from table() again. Rows already completed stay staged. + */ cancelRow(): QwpSender { this.throwIfUnavailable(); - this.currentRow.clear(); + this.discardRow(); return this; } @@ -1966,8 +1970,18 @@ export class QwpSender { return this.current; } - private failRow(error: unknown): never { + /** + * Drops the row in progress. A staged row is both its columns and its table + * selection, so releasing only the columns would leave the sender inside a + * row that table() then refuses to reopen. + */ + private discardRow(): void { this.currentRow.clear(); + this.current = undefined; + } + + private failRow(error: unknown): never { + this.discardRow(); throw error; } diff --git a/test/qwp/sender.test.ts b/test/qwp/sender.test.ts index 66a3d61..ae0f50a 100644 --- a/test/qwp/sender.test.ts +++ b/test/qwp/sender.test.ts @@ -636,13 +636,91 @@ describe("QWP high-level sender", () => { expect(() => sender.stringColumn("bad", 42 as unknown as string)).toThrow( /only strings/, ); - await sender.longColumn("kept", 7n).atNow(); + // The failed row released its table, so the next row starts from table(). + await sender.table("events").longColumn("kept", 7n).atNow(); await sender.flush(); const table = session.sends[0].tables[0]; expect(table.columns.map((item) => item.name)).toEqual(["kept"]); }); + it("keeps the sender usable after a failed row, without losing staged rows", async () => { + const session = new RecordingSession(); + const sender = new QwpSender(async () => session, { autoFlush: false }); + + await sender.table("events").longColumn("value", 1n).atNow(); + sender.table("events").symbol("kind", "start"); + expect(() => sender.stringColumn("label", 42 as unknown as string)).toThrow( + /only strings/, + ); + + // Recovery no longer needs reset(), which would drop the completed row too. + expect(() => sender.table("events")).not.toThrow(); + await sender.longColumn("value", 2n).atNow(); + await sender.flush(); + + const table = session.sends[0].tables[0]; + expect(table.rowCount).toBe(2); + expect(table.columns.map((item) => item.name)).toEqual(["value"]); + expect(column(table, "value").values).toEqual([1n, 2n]); + }); + + it("refuses to continue a failed row implicitly", async () => { + const sender = new QwpSender(async () => new RecordingSession(), { + autoFlush: false, + }); + + sender.table("events").longColumn("value", 1n); + expect(() => sender.stringColumn("label", 42 as unknown as string)).toThrow( + /only strings/, + ); + // Setters after the failure must not silently open a new row. + expect(() => sender.longColumn("value", 2n)).toThrow( + /table name must be set before adding columns/, + ); + await expect(sender.atNow()).rejects.toThrow( + /table name must be set before adding columns/, + ); + expect(sender.metrics.pendingRows).toBe(0); + }); + + it("releases the row when the designated timestamp is rejected", async () => { + const session = new RecordingSession(); + const sender = new QwpSender(async () => session, { autoFlush: false }); + + sender.table("events").longColumn("value", 1n); + await expect(sender.at(1.5, "us")).rejects.toThrow(/safe integer/); + + await sender.table("events").longColumn("value", 2n).atNow(); + await sender.flush(); + expect(column(session.sends[0].tables[0], "value").values).toEqual([2n]); + }); + + it("cancelRow() discards the row in progress and its table selection", async () => { + const session = new RecordingSession(); + const sender = new QwpSender(async () => session, { autoFlush: false }); + + await sender.table("events").longColumn("value", 1n).atNow(); + sender.table("events").longColumn("value", 99n).cancelRow(); + + expect(sender.metrics.pendingRows).toBe(1); + await sender.table("other").longColumn("value", 2n).atNow(); + await sender.flush(); + + const tables = session.sends[0].tables; + expect(tables.map((table) => table.name)).toEqual(["events", "other"]); + expect(column(tables[0], "value").values).toEqual([1n]); + expect(column(tables[1], "value").values).toEqual([2n]); + }); + + it("cancelRow() leaves a closed sender alone", async () => { + const sender = new QwpSender(async () => new RecordingSession(), { + autoFlush: false, + }); + await sender.close(); + expect(() => sender.cancelRow()).toThrow(/closed/); + }); + it("compiles a typed table writer and appends object rows", async () => { const session = new RecordingSession(); const sender = new QwpSender(async () => session, { autoFlush: false }); From 55afb31683e6be5050fedcdc031ef688c9c34c8b Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 20 Aug 2026 11:49:07 +0100 Subject: [PATCH 089/265] chore(qwp): extend the review skill to QWP The review-pr skill only described the ILP client, so reviews of the QWP WebSocket, UDP, browser, and store-and-forward code had no checklist, no agent role, and no verification burden to work from. Add Agent 14 for QWP wire format and session semantics and Agent 15 for store-and-forward, replay, and failover, plus checklists for both. The store-and-forward checklist states the durability contract as blocking: the steady-state replay loop never surfaces transport errors to the producer, foreground replay stays unbounded after startup, an unknown NACK status fails open, and the ack watermark never passes an unacknowledged frame. Extend the existing sections with the QWP entry points, config parser, public API contract, dependencies, routes, and cross-context fanouts, and record the QWP failure modes in the mindset and severity rules. Co-Authored-By: Claude Opus 5 (1M context) --- .claude/skills/review-pr/SKILL.md | 209 +++++++++++++++++++++++++----- 1 file changed, 180 insertions(+), 29 deletions(-) diff --git a/.claude/skills/review-pr/SKILL.md b/.claude/skills/review-pr/SKILL.md index 10059c9..07effda 100644 --- a/.claude/skills/review-pr/SKILL.md +++ b/.claude/skills/review-pr/SKILL.md @@ -1,6 +1,6 @@ --- name: review-pr -description: Review a GitHub pull request or local Git range against @questdb/nodejs-client TypeScript ILP client coding standards +description: Review a GitHub pull request or local Git range against @questdb/nodejs-client TypeScript ILP/QWP client coding standards argument-hint: "[PR number or URL | --range=..] [--level=0..3]" allowed-tools: Bash, Read, Grep, Glob, Agent --- @@ -26,9 +26,11 @@ to verify a regression test against reverted production hunks; remove it afterwa You are a senior QuestDB engineer performing a blocking code review. `@questdb/nodejs-client` is mission-critical software: it serializes rows into the -QuestDB InfluxDB Line Protocol (ILP) and sends them over HTTP/HTTPS or TCP/TCPS. -A bug can silently corrupt bytes, drop or duplicate rows, leak credentials, exhaust -resources, or break supported Node.js consumers. +QuestDB InfluxDB Line Protocol (ILP) over HTTP/HTTPS or TCP/TCPS, and into the QuestDB +Wire Protocol (QWP) over WebSocket or fire-and-forget UDP, with a browser build, an +egress query path, and a crash-safe Node store-and-forward journal. A bug can silently +corrupt bytes, drop or duplicate rows, abandon persisted data, leak credentials, +exhaust resources, or break supported Node.js and browser consumers. **A review that blocks on everything blocks on nothing.** Every finding costs an author and CI round-trip. Reserve blocking severity for defects with a real user @@ -52,7 +54,14 @@ when the gates pass. Zero findings is a successful outcome. - **Think adversarially.** Exercise `null`/`undefined`, empty strings and arrays, `NaN`/`Infinity`, imprecise `number` integers, `bigint`, multi-byte UTF-8, all ILP delimiters, maximum buffer sizes, retries after uncertain sends, connection drops, - TLS/auth failures, and every negotiated protocol version. + TLS/auth failures, and every negotiated protocol version. For QWP also exercise + mid-frame socket loss, replay after a restart, a NACK of an already replayed frame, + a full or externally locked journal directory, a role-rejected or capability-gapped + endpoint, and a truncated or hostile server frame. +- **Store-and-forward promises no data loss.** Once rows enter the journal, only a + rejection that is deterministic under byte-identical replay may abandon them, and a + transient outage must never end the replay loop or surface to the producer. Treat a + breach of the store-and-forward checklist as Critical. - **Demand efficient hot paths.** Per-row and per-cell work scales to millions of rows. Avoid allocations, repeated scans, redundant conversions, extra buffer copies, and suboptimal algorithms there. Bounded setup/configuration work is less severe. @@ -77,14 +86,17 @@ to `gh`. | Level | What runs | |-------|-----------| | **0 (default)** | Steps 1, 2, 2.4, 2.5f, 2.6, and 4. Review inline without agent fanout. Build a compact coverage map and apply the Step 3b admission gate inline from a blank evidence form. | -| **1** | Add Steps 2.5a and 2.5e when tests change. Run Agent 1 plus at most two applicable roles from Agents 2-7 and 9-13. Independently falsify each surviving atomic candidate. | +| **1** | Add Steps 2.5a and 2.5e when tests change. Run Agent 1 plus at most two applicable roles from Agents 2-7, 9-13, and 14-15. Independently falsify each surviving atomic candidate. | | **2** | Run all of Step 2.5, restricting 2.5b to exported/public/protected symbols, transport interfaces, shared helpers, and configuration options. Run Agent 1 plus at most four change-relevant roles. Independently falsify each surviving candidate. | -| **3** | Run the full workflow. Select at most six applicable discovery roles: Agent 1 always; Agent 8 when changed symbols have out-of-diff callers; Agents 2-7 when their domains are touched; Agents 9-13 for changed tests or a fix claim; Agent 10 only when a distinct adversarial pass is warranted. Depth comes from evidence, not agent count. | +| **3** | Run the full workflow. Select at most six applicable discovery roles: Agent 1 always; Agent 8 when changed symbols have out-of-diff callers; Agents 2-7 and 14-15 when their domains are touched; Agents 9-13 for changed tests or a fix claim; Agent 10 only when a distinct adversarial pass is warranted. Depth comes from evidence, not agent count. | State the selected level at the start of the review. If defaulted, mention that level -3 exists for a full mission-critical pass. Changes to `src/buffer/**`, transport/auth/ -TLS, protocol negotiation, flush semantics, or `src/index.ts` are high risk; recommend -level 3, but honor an explicit lower level and state the limitation. +3 exists for a full mission-critical pass. Changes to `src/buffer/**`, `src/qwp/**`, +`src/qwp-node/**`, transport/auth/TLS, protocol negotiation, flush semantics, or any +public entry point (`src/index.ts`, `src/qwp/index.ts`, `src/qwp/node.ts`, +`src/qwp/browser.ts`) are high risk; recommend level 3, but honor an explicit lower +level and state the limitation. Replay-journal, ack-watermark, drainer, and failover +changes stay high risk regardless of how small the diff is. ## Spawning review agents @@ -147,6 +159,9 @@ Check the repository conventions in `CONTRIBUTING.md` and recent accepted PRs: - README/TSDoc updates accompany user-visible behavior where needed. - New or renamed options document their defaults and deprecation path through `SenderOptions.resolveDeprecated`. +- New or renamed QWP keys are wired through `src/qwp-node/client-config.ts`, validated + against the transports that support them, and documented in `QWP.md`. +- A changed public QWP surface updates `test/qwp/public-api-contract.ts`. ## Step 2.4: Submodule boundaries (mandatory at every level) @@ -196,13 +211,22 @@ and exports. Group results by file and include overrides and implementations. At minimum check: -- `src/index.ts` and emitted public type implications. +- All four public entry points — `src/index.ts`, `src/qwp/index.ts`, `src/qwp/node.ts`, + `src/qwp/browser.ts` — and emitted public type implications. - `SenderBufferBase` plus `SenderBufferV1`/`V2`/`V3` overrides and `createBuffer`. - `SenderTransport` plus Undici, stdlib HTTP, and TCP implementations. - `SenderOptions.resolveAuto`, `resolveDeprecated`, config parsing, `fromConfig`, and - `fromEnv` for option changes. -- Unit/integration tests and test helpers. -- `README.md` and examples for public symbols/options. + `fromEnv` for option changes, plus `src/qwp-node/client-config.ts` for QWP keys. +- Changed `src/qwp/core/**` constants and codecs against both the ingress encoder and + the egress decoder; one cap or type byte is normally read by both sides. +- `QwpSender` and the writer helpers, `QwpIngressSession`, `QwpEgressSession`, + `QwpClient`, the reconnecting connections in `src/qwp/internal/**`, and the UDP + sender. +- `QwpNodeFileReplayStore`, `QwpNodeOrphanDrainer`, the advisory lock, and the segment + maintenance worker for any store-and-forward change. +- Unit/integration tests and test helpers, including `test/qwp/**` and its fixtures. +- `test/qwp/public-api-contract.ts` for any exported QWP symbol, type, or option. +- `README.md`, `QWP.md`, and examples for public symbols/options. A changed shared symbol with no recorded `rg` command is a skill violation. Never assert “only used here” without the search trace. @@ -230,14 +254,22 @@ For each changed symbol, record before versus after for every applicable contrac List places where the change is visible but the diff does not touch, grouped by: - Per-row/per-cell buffer-build hot path. -- Protocol-version fanout (v1/v2/v3). -- Transport fanout (Undici, stdlib HTTP, TCP/TCPS). -- Flush, retry, and lazy auto-flush paths. -- Protocol negotiation and configuration parsing. +- Protocol-version fanout (ILP v1/v2/v3, and the QWP frame version with its negotiated + caps and capabilities). +- Transport fanout (Undici, stdlib HTTP, TCP/TCPS, QWP WebSocket ingress, QWP egress, + Node UDP). +- Runtime fanout (Node entry points versus the browser build, which must stay free of + Node built-ins, `ws`, and `qwp-node` imports). +- Flush, commit, retry, replay, and lazy auto-flush paths. +- Reconnect, failover, role/capability rejection, and poison-frame escalation. +- Store-and-forward journal, orphan drainer, advisory locking, and the maintenance + worker thread. +- Protocol negotiation, durable-ACK capability negotiation, and configuration parsing. - Auth/TLS and resource lifecycle. -- Worker-thread use (one mutable `Sender` per worker). -- Public ESM/CJS/type surface. -- Tests, helpers, README, and examples. +- Worker-thread use (one mutable `Sender` per worker) and multi-process use of a single + store-and-forward directory. +- Public ESM/CJS/type surface across all four entry points. +- Tests, helpers, README, `QWP.md`, and examples. Every listed context must be checked in Step 3. @@ -259,9 +291,18 @@ Record current facts with file/line citations; do not rely on this list becoming - TypeScript flags from `tsconfig.json`, especially `strictNullChecks`, `noImplicitAny`, and `noUncheckedIndexedAccess`. - Node.js version floor and `@types/node` version. -- `undici` major and the `stdlib_http` alternative. -- Dual ESM/CJS build and `package.json` exports. -- Protocol default/negotiation and TCP's explicit-version requirement. +- Runtime dependencies and what each covers: `undici` (HTTP), `ws` (Node QWP + WebSocket), and the native `fs-ext-extra-prebuilt` advisory locks used by + store-and-forward. `fzstd` is a devDependency that the bundler inlines; making it an + external import would break installs. +- Dual ESM/CJS build and every `package.json` exports subpath (`.`, `./qwp`, + `./qwp/browser`, `./qwp/node`), plus which sources each subpath is allowed to import. +- ILP protocol default/negotiation and TCP's explicit-version requirement. +- QWP `QWP_VERSION`, the `/write/v4` ingress and `/read/v1` egress routes, the caps in + `src/qwp/core/constants.ts`, and the capabilities negotiated per connection. +- `worker_threads` use by the segment maintenance worker, and the `Date.now()` / + `Math.random()` dependencies in backoff, episode, and timeout accounting that + deterministic tests must be able to control. - `Buffer.write` versus `writeInt*` boundary semantics. A short `Buffer.write` can silently truncate, while numeric writes throw out of bounds; `writeInt8` requires `-128..127` and marker bytes above 127 must be sign-folded. @@ -284,8 +325,9 @@ path, build an internal row containing: assertion and observation seam. - **Effort/fragility evidence:** concrete setup, nondeterminism, platform, or production seam costs; “hard to test” alone is not evidence. -- **Dimensions:** applicable protocol, transport, happy/error, NULL, boundary, - concurrency, retry, and resource-cleanup dimensions. +- **Dimensions:** applicable protocol, transport, runtime (Node/browser), happy/error, + NULL, boundary, concurrency, retry, reconnect/replay, crash-recovery, and + resource-cleanup dimensions. - **Disposition:** `COVERED`, `CRITICAL GAP`, `MODERATE GAP`, `ACCEPTED GAP`, or `EXEMPT`. Mark rows with no effective assertion `UNTESTED` before classification. Missing tests @@ -396,6 +438,23 @@ assertions, and unnecessary casts. Name a real reusable alternative for each com hunk each test depends on. A candidate survives only if the test passes at head and fails when the production fix is reverted in an isolated scratch worktree. +**Agent 14 — QWP wire format and protocol sessions:** Reconstruct frame headers, +LEB128 varints, column encodings, Gorilla bit packing, zstd framing, symbol-dictionary +IDs with their delta/reset flags, decimal scale, geohash bits, array shape, and NULL +bitmaps against the caps in `src/qwp/core/constants.ts`. Check the ingress encoder and +the egress decoder together because both read the same constants. Check status-byte to +category to policy mapping, per-table transaction grouping, durable-ACK negotiation, +ingress cap splitting, and that a truncated, oversized, or hostile server frame is +rejected before it is allocated, copied, or trusted. + +**Agent 15 — Store-and-forward, replay, and failover:** Verify the durability contract +in the checklist below. Trace the cumulative ack watermark, replay from +`ackedFsn + 1`, segment format and checkpoints, append backpressure and deadlines, +cross-process advisory locking, orphan-slot quarantine, poison-frame strike accounting, +capability-gap episodes, reconnect budgets, and endpoint health/zone ranking. Any path +that abandons accepted rows, advances the watermark past an unacknowledged frame, or +ends the steady-state replay loop on a transient failure is a data-loss candidate. + Combine outputs into a private candidate ledger. Split compound narratives into atomic propositions, deduplicate by proposition plus evidence, and record dependencies. Do not draft severity, fixes, or report prose yet. @@ -474,7 +533,18 @@ Then independently verify Node-client specifics: README example, ESM/CJS output implication, and supported Node version. 10. For test efficacy, prove the assertion reaches the change and would fail under the claimed regression. Recompute expected hex/bytes rather than trusting fixtures. -11. Derive a fix only after admission, then verify it compiles and closes all admitted +11. For QWP wire claims, reconstruct the frame bytes for encode and decode, and check + every length, cap, and flag against `src/qwp/core/constants.ts` rather than against + an assumed peer behavior. +12. For replay, ack, reconnect, or failover claims, trace the cumulative ack watermark + and prove which frames a restart, NACK, or non-orderly close resends or drops. + Classify the failure through `qwpDefaultSenderErrorPolicy` before calling anything + terminal. +13. For store-and-forward claims, execute against a real directory: fill it, hold its + lock from a second process, truncate or corrupt a segment, and kill the process + between append and checkpoint. Durability and crash-recovery claims need journal + artifacts, never source reading alone. +14. Derive a fix only after admission, then verify it compiles and closes all admitted paths without creating a compatibility, ownership, or retry defect. ### Net user impact and ledger classification @@ -535,6 +605,72 @@ enumerated instance independently rather than sampling and generalizing. - Undici and stdlib HTTP agree on auth, TLS, timeout, retry, and response handling. - Basic/Bearer/JWK credentials are correct and never logged or included in errors. - Verification is disabled only explicitly; custom CA/roots are applied. +- QWP endpoint selection honors the health and zone ranking; a background drainer + publishes health observations but never resets foreground classifications. +- Upgrade failures are classified into a `QwpUpgradeError` kind, and a browser's opaque + upgrade error is never reported as a specific cause. +- WebSocket close codes carry no policy meaning; classify by status byte and upgrade + kind instead. + +### QWP wire format and sessions + +- Frame header magic, version, flags, table count, and payload length agree between + encoder and decoder, and every cap in `src/qwp/core/constants.ts` is enforced on both + sides. +- Varints stay inside uint64; row, column, name-length, array-element, and dictionary + limits are checked on encode and on decode. +- Symbol dictionary IDs stay dense and connection-scoped; delta and reset flags match + what the peer reconstructs, and a `DICTIONARY_GAP` rejection triggers catch-up rather + than a terminal failure. +- Gorilla, zstd, and raw encodings round-trip; decompression respects + `QWP_MAX_ZSTD_DECOMPRESSED_SIZE`, and every server-supplied length is validated before + it is allocated or copied. +- Decimal scale, geohash bits, long256 words, UUID, IPv4, binary, and array shape + validation match the documented bounds for each column and bind type. +- Server-supplied text decodes as fatal UTF-8 into a `QwpProtocolError`, never into a + silently mangled value. +- Transactions are atomic per table, not across a flush; closing publishes staged rows + without committing them. +- Durable ACK is requested through the Node upgrade header or the browser subprotocol, + and an unconfirmed capability fails with `QwpDurableAckUnavailableError`. +- Ingress splitting respects the negotiated cap, and a single row above the cap fails + with `QwpBatchTooLargeError` instead of being dropped. +- The browser entry point stays free of Node built-ins, `ws`, and `qwp-node` symbols. + +### Store-and-forward and durability + +A breach here is Critical: the contract is that a running producer neither loses data +nor hard-fails on a transient outage. + +- The steady-state replay loop does not surface transport or server errors to the + producer. Journal exhaustion and its append deadline are the errors a caller may see. +- Node foreground replay is unbounded after startup. Attempt and duration budgets apply + to `"sync"` startup and to the browser/memory policy only; a budget that latches a + running sender terminal during a long outage is a data-loss defect. +- Backoff is exponential with full jitter and a capped per-attempt delay, while the + store-and-forward retry loop itself stays uncapped. +- NACK policy follows `qwpDefaultSenderErrorPolicy`: `WRITE_ERROR`, `INTERNAL_ERROR`, + `DICTIONARY_GAP`, and an unknown status retry from `ackedFsn + 1`; `NOT_WRITABLE` + retries elsewhere; only rejections that are deterministic under byte-identical replay + go terminal. An unrecognized status byte fails open to retry, never closed. +- The ack watermark never advances past a NACKed or unacknowledged frame, and abandoned + bytes are quarantined and reported through `QwpSenderError` rather than dropped. +- Repeated rejection escalates through the poison-frame detector, honoring + `maxFrameRejections` and `poisonMinEscalationWindowMs`. Normal and going-away closes, + `NOT_WRITABLE`, and dictionary catch-up must not consume strikes, and a transient + class must not consume a capability-gap episode budget. +- Orphan-drainer terminals are the ones that are terminal by design — authentication, + protocol, poison frame, and an exhausted capability-gap episode — and they quarantine + the slot behind its `.failed` sentinel for an operator. Any other terminal is a + finding. +- Segment magic, format version, and checkpoint invariants hold; a torn, truncated, or + foreign-version segment is quarantined instead of replayed. +- Advisory locking is fail-closed: a directory owned by another process yields + `QwpReplayStoreLockedError`, a release that cannot be proved stays on the retry list, + and the maintenance worker is stopped on every exit path. +- UDP ingress is fire-and-forget by contract — no acknowledgement, no replay, no + durability claim. Review it for datagram sizing and socket cleanup, not against the + guarantees above. ### Async, concurrency, and resources @@ -543,6 +679,9 @@ enumerated instance independently rather than sampling and generalizing. - Do not invite concurrent mutation or share a Sender across workers. - Close owned sockets/pools/agents/timers/listeners on every path; preserve user-owned agents; do not retain stale buffer views. +- Close QWP sockets, keepalive and ACK timers, reconnect timers, the maintenance worker + thread, and advisory locks on every path, including a failed upgrade, an aborted + replay, and a quarantined slot. ### Performance @@ -557,9 +696,14 @@ enumerated instance independently rather than sampling and generalizing. - Export new public symbols; treat removals/renames/signature/default changes as compatibility changes. +- Only the four documented entry points are public. Paths containing `internal`, + `qwp-node`, or `src` are implementation details even when a bundler resolves them. +- A changed exported QWP symbol, option, constant, or error updates + `test/qwp/public-api-contract.ts` and `QWP.md`. - Keep TSDoc/types accurate and avoid casts that hide runtime null/type problems. - Wire renamed options through parsing, validation, `resolveDeprecated`, `resolveAuto`, - `fromConfig`, and `fromEnv` as applicable. + `fromConfig`, and `fromEnv` as applicable, and through the QWP config parser for QWP + keys. - Update README/examples for user-visible behavior. - Keep ESLint/Prettier clean; remove dead code/imports; follow local naming/order. @@ -570,6 +714,9 @@ enumerated instance independently rather than sampling and generalizing. - Use byte-level assertions for serializer changes and transport-level assertions for network/auth changes. - Recompute expected hex/bytes and ensure assertions can fail and reach production code. +- QWP changes need frame-level assertions, and behavior that depends on it needs a + reconnect, replay, restart, or lock-contention test. Reuse the fake sockets, fixtures, + and interop helpers already in `test/qwp/`. - A bug fix needs a regression test that fails without the fix unless the Step 2.6 proportionality analysis admits a non-Critical gap. - Prefer existing helpers and deterministic synchronization; avoid brittle timing, @@ -606,8 +753,12 @@ Severity is determined by reachable user consequence, not checklist category. **Critical** requires a supported trigger and one of: -- Wrong/missing/duplicated/corrupted data or ILP wire bytes. +- Wrong/missing/duplicated/corrupted data or ILP/QWP wire bytes. +- Abandoned, silently dropped, or unreplayable store-and-forward data, or an ack + watermark advanced past an unacknowledged frame. - Crash, hang, outage, unbounded loop, OOM, or unbounded socket/timer/listener leak. +- A steady-state replay loop that ends, or surfaces a transient transport failure to the + producer, instead of retrying. - Credential exposure, auth/TLS bypass, or another security failure. - Silent/misleading failure that makes ingestion appear successful or undiagnosable. - Public API, config, runtime, module-system, protocol, or rolling-version compatibility From b963ab9e55ee0a364345243c7afa633fa3cda1ef Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 20 Aug 2026 17:09:14 +0100 Subject: [PATCH 090/265] feat(qwp): make the native locking module optional and lazily loaded `fs-ext-extra-prebuilt` supplies the flock/LockFileEx primitives behind the store-and-forward slot lock, but it was a required dependency imported at module scope. Since dist/cjs/index.js reaches qwp/node, every consumer loaded the native addon on import, ILP-only senders that never open a journal included, and an install on a platform with no matching prebuilt binary failed outright unless a C++ toolchain was present. Resolve the binding through a cached dynamic import on the first lock, before any lock file is created, and move the package to optionalDependencies. A failed resolution is not cached, because the module throws from its own module scope on an ABI mismatch and a later Node upgrade can make the same import succeed. bunchee derives externals from dependencies and peerDependencies only, so the move alone made rollup inline the package: it vendored the JS wrapper, rewrote the native binary require into a stub that always throws, and pointed the binaries lookup at dist/. That artifact would have failed store-and-forward on every platform, so the build script now passes --external fs-ext-extra-prebuilt. Store-and-forward never falls back to lock-free operation, since the lock is what keeps a second process, Node or Java, off the same slot. A missing or unloadable module therefore surfaces as the new public QwpReplayStoreUnavailableError, mapped from the internal advisory-lock error the same way lock contention maps to QwpReplayStoreLockedError. Co-Authored-By: Claude Opus 5 (1M context) --- .claude/skills/review-pr/SKILL.md | 7 ++++- QWP.md | 11 ++++++++ package.json | 6 +++-- pnpm-lock.yaml | 13 ++++++--- src/qwp-node/advisory-lock.ts | 44 +++++++++++++++++++++++++++++-- src/qwp-node/file-replay-store.ts | 26 ++++++++++++++++++ src/qwp/node.ts | 1 + test/qwp/public-api.test.ts | 1 + test/qwp/reconnect.test.ts | 28 ++++++++++++++++++++ 9 files changed, 128 insertions(+), 9 deletions(-) diff --git a/.claude/skills/review-pr/SKILL.md b/.claude/skills/review-pr/SKILL.md index 07effda..e7f0b40 100644 --- a/.claude/skills/review-pr/SKILL.md +++ b/.claude/skills/review-pr/SKILL.md @@ -293,7 +293,12 @@ Record current facts with file/line citations; do not rely on this list becoming - Node.js version floor and `@types/node` version. - Runtime dependencies and what each covers: `undici` (HTTP), `ws` (Node QWP WebSocket), and the native `fs-ext-extra-prebuilt` advisory locks used by - store-and-forward. `fzstd` is a devDependency that the bundler inlines; making it an + store-and-forward. That last one is an `optionalDependency` that `advisory-lock.ts` + reaches through a lazy `import()`; a static top-level import would restore an eager + native load for every ILP consumer. bunchee externalizes only `dependencies` and + `peerDependencies`, so `--external fs-ext-extra-prebuilt` in the `build` script is + load-bearing — without it the module is inlined and its native binary lookup + breaks at runtime. `fzstd` is a devDependency that the bundler inlines; making it an external import would break installs. - Dual ESM/CJS build and every `package.json` exports subpath (`.`, `./qwp`, `./qwp/browser`, `./qwp/node`), plus which sources each subpath is allowed to import. diff --git a/QWP.md b/QWP.md index 5d33172..e2b7cbb 100644 --- a/QWP.md +++ b/QWP.md @@ -225,6 +225,16 @@ live owner, and the next holder refreshes the PID sidecar. Short-lived locks und shared parent directory's `.slot-locks` child also match Java and serialize orphan adoption with close/rename/recreate quarantine transitions. +Those native primitives come from `fs-ext-extra-prebuilt`, an optional dependency that +ships prebuilt binaries for macOS, Linux, and Windows on x64 and arm64. It is +installed by default and imported on the first lock, so ILP-only senders and QWP +sessions without store-and-forward never load it. An install that skipped it, or a +platform with no matching prebuilt binary and no build toolchain, therefore leaves the +rest of the client fully usable and fails only when a store-and-forward journal is +loaded, raising `QwpReplayStoreUnavailableError`. Store-and-forward never falls back +to lock-free operation, because the lock is what keeps a second process, Java or Node, +off the same slot. + New journals use the cross-client SFA persistence layout. Fixed-size `sf-.sfa` files have the Java/Rust 24-byte `SF01` header and `[crc32c, payloadLength, payload]` frame envelope. `sf-manifest.bin` and @@ -1194,6 +1204,7 @@ The public error classes preserve enough context for policy decisions: | `QwpReplayStoreAppendTimeoutError` | The Node.js replay journal did not regain capacity before the configured append deadline | | `QwpReplayStoreCheckpointError` | A periodic Node.js replay-journal checkpoint failed; operations fail closed until a retry succeeds | | `QwpReplayStoreLockedError` | Another process owns the configured Node.js replay directory | +| `QwpReplayStoreUnavailableError` | Store-and-forward's optional native locking module is missing or unusable here | | `QwpEgressQueryError` | QuestDB returned a terminal query error | | `QwpEgressQueryAbandonedError` | Result iteration ended before the server completed the query | | `QwpEgressQueryTimeoutError` | The client deadline expired and cancellation began | diff --git a/package.json b/package.json index bb290ed..7327b4a 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "scripts": { "test": "vitest", "test:qwp-browser-e2e": "pnpm build && vitest run --config vitest.qwp-browser.config.ts", - "build": "bunchee", + "build": "bunchee --external fs-ext-extra-prebuilt", "eslint": "eslint src/**", "typecheck": "tsc --noEmit", "typecheck:qwp-browser": "tsc --noEmit -p tsconfig.qwp-browser.json", @@ -97,8 +97,10 @@ "vitest": "^3.1.3" }, "dependencies": { - "fs-ext-extra-prebuilt": "2.2.11", "undici": "^7.8.0", "ws": "^8.21.3" + }, + "optionalDependencies": { + "fs-ext-extra-prebuilt": "2.2.11" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 67c13cc..93425d6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,9 +8,6 @@ importers: .: dependencies: - fs-ext-extra-prebuilt: - specifier: 2.2.11 - version: 2.2.11 undici: specifier: ^7.8.0 version: 7.8.0 @@ -63,6 +60,10 @@ importers: vitest: specifier: ^3.1.3 version: 3.1.3(@types/node@22.15.17) + optionalDependencies: + fs-ext-extra-prebuilt: + specifier: 2.2.11 + version: 2.2.11 packages: @@ -1472,6 +1473,7 @@ packages: glob@10.4.5: resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true globals@14.0.0: @@ -2351,6 +2353,7 @@ packages: uuid@10.0.0: resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true vary@1.1.2: @@ -3824,6 +3827,7 @@ snapshots: fs-ext-extra-prebuilt@2.2.11: dependencies: nan: 2.28.0 + optional: true fsevents@2.3.2: optional: true @@ -4115,7 +4119,8 @@ snapshots: nan@2.22.0: optional: true - nan@2.28.0: {} + nan@2.28.0: + optional: true nanoid@3.3.8: {} diff --git a/src/qwp-node/advisory-lock.ts b/src/qwp-node/advisory-lock.ts index 9e0b736..b16920e 100644 --- a/src/qwp-node/advisory-lock.ts +++ b/src/qwp-node/advisory-lock.ts @@ -1,7 +1,6 @@ import { mkdir, open, readFile, unlink, writeFile } from "node:fs/promises"; import type { FileHandle } from "node:fs/promises"; import { basename, dirname, join, resolve } from "node:path"; -import { flock } from "fs-ext-extra-prebuilt"; const SLOT_LOCK_FILE = ".lock"; const SLOT_LOCK_PID_FILE = ".lock.pid"; @@ -13,6 +12,13 @@ const LOGICAL_LOCK_DIRECTORY = ".slot-locks"; const pendingReleases = new Set(); type FlockOperation = "exnb" | "un"; +type FlockFn = (typeof import("fs-ext-extra-prebuilt"))["flock"]; + +// `fs-ext-extra-prebuilt` is an optional native dependency. It is resolved on +// the first lock rather than at module scope, so ILP-only and +// store-and-forward-free QWP users neither load the addon nor depend on a +// prebuilt binary existing for their platform. +let flockPromise: Promise | undefined; /** @internal Native advisory-lock contention with Java-compatible diagnostics. */ export class QwpNodeAdvisoryLockBusyError extends Error { @@ -40,6 +46,19 @@ export class QwpNodeAdvisoryLockError extends Error { } } +/** @internal The optional native locking module is absent or unloadable. */ +export class QwpNodeAdvisoryLockUnavailableError extends Error { + constructor(cause?: unknown) { + super( + "QWP store-and-forward requires the optional native module " + + "'fs-ext-extra-prebuilt', which could not be loaded " + + `[platform=${process.platform}-${process.arch}, node=${process.versions.node}]`, + ); + this.name = "QwpNodeAdvisoryLockUnavailableError"; + this.cause = cause; + } +} + /** * Lifetime owner of Java-compatible `.lock` / `.lock.pid` slot metadata. * The files deliberately remain after release: unlinking a lock pathname can @@ -98,6 +117,9 @@ export class QwpNodeAdvisoryLock { lockPath: string, pidPath: string, ): Promise { + // Resolve the native binding before creating anything: an unsupported + // platform must fail without leaving slot metadata behind. + await loadFlock(); await retryPendingReleases(); let handle: FileHandle; try { @@ -168,7 +190,25 @@ async function retryPendingReleases(): Promise { } } -function flockAsync(fd: number, operation: FlockOperation): Promise { +function loadFlock(): Promise { + // A failed attempt is not cached. The module throws from its own module scope + // when no binding matches this platform/ABI, and a later Node upgrade or + // reinstall can make the very same import succeed. + flockPromise ??= import("fs-ext-extra-prebuilt").then( + (module) => module.flock, + (error) => { + flockPromise = undefined; + throw new QwpNodeAdvisoryLockUnavailableError(error); + }, + ); + return flockPromise; +} + +async function flockAsync( + fd: number, + operation: FlockOperation, +): Promise { + const flock = await loadFlock(); return new Promise((resolve, reject) => { flock(fd, operation, (error) => { if (error) reject(error); diff --git a/src/qwp-node/file-replay-store.ts b/src/qwp-node/file-replay-store.ts index 698fe99..a61df96 100644 --- a/src/qwp-node/file-replay-store.ts +++ b/src/qwp-node/file-replay-store.ts @@ -20,6 +20,7 @@ import { import { QwpNodeAdvisoryLock, QwpNodeAdvisoryLockBusyError, + QwpNodeAdvisoryLockUnavailableError, } from "./advisory-lock"; import { qwpSegmentMaintenanceWorker } from "./segment-maintenance-worker"; @@ -273,6 +274,26 @@ export class QwpReplayStoreLockedError extends QwpReplayStoreError { } } +/** + * Store-and-forward cannot run because its optional native locking module is + * missing or has no binding for this platform. There is no lock-free fallback: + * the lock is what keeps a second process, Node or Java, off the same slot. + */ +export class QwpReplayStoreUnavailableError extends QwpReplayStoreError { + constructor( + readonly directory: string, + cause: unknown, + ) { + super( + "QWP store-and-forward requires the optional native module " + + `'fs-ext-extra-prebuilt', which could not be loaded [directory=${directory}, ` + + `platform=${process.platform}-${process.arch}]`, + cause, + ); + this.name = "QwpReplayStoreUnavailableError"; + } +} + /** * Node store-and-forward journal with configurable local durability. * @@ -1849,6 +1870,8 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { this.directory, error.holderPid, ); + } else if (error instanceof QwpNodeAdvisoryLockUnavailableError) { + failure = new QwpReplayStoreUnavailableError(this.directory, error); } else { failure = new QwpReplayStoreError( `could not acquire QWP store-and-forward directory lock [directory=${this.directory}]`, @@ -2495,6 +2518,9 @@ export async function quarantineQwpNodeReplayStore( if (error instanceof QwpNodeAdvisoryLockBusyError) { throw new QwpReplayStoreLockedError(normalized, error.holderPid); } + if (error instanceof QwpNodeAdvisoryLockUnavailableError) { + throw new QwpReplayStoreUnavailableError(normalized, error); + } throw new QwpReplayStoreError( `could not acquire QWP store-and-forward logical lock for quarantine [directory=${normalized}]`, error, diff --git a/src/qwp/node.ts b/src/qwp/node.ts index 15454f3..c802295 100644 --- a/src/qwp/node.ts +++ b/src/qwp/node.ts @@ -83,6 +83,7 @@ export { QwpReplayStoreLockedError, QwpReplayStoreQuarantinedError, QwpReplayStoreSegmentTooLargeError, + QwpReplayStoreUnavailableError, } from "../qwp-node/file-replay-store"; export type { QwpNodeFileReplayStoreMetrics, diff --git a/test/qwp/public-api.test.ts b/test/qwp/public-api.test.ts index d4f8fbb..5ff3b77 100644 --- a/test/qwp/public-api.test.ts +++ b/test/qwp/public-api.test.ts @@ -80,6 +80,7 @@ const nodeRuntimeContract = [ "QwpReplayStoreFullError", "QwpReplayStoreLockedError", "QwpReplayStoreQuarantinedError", + "QwpReplayStoreUnavailableError", "QwpUdpDatagramTooLargeError", "QwpVersionMismatchError", "connectQwpNodeEgress", diff --git a/test/qwp/reconnect.test.ts b/test/qwp/reconnect.test.ts index 8c7ec60..ae49629 100644 --- a/test/qwp/reconnect.test.ts +++ b/test/qwp/reconnect.test.ts @@ -26,6 +26,7 @@ import { QwpReplayStoreFullError, QwpReplayStoreLockedError, QwpReplayStoreSegmentTooLargeError, + QwpReplayStoreUnavailableError, } from "../../src/qwp/node"; import { QWP_RECONNECT_EVENT_KIND, @@ -3492,6 +3493,33 @@ describe("QWP Node file replay store", () => { await second.close(); }); + it("fails closed when the native locking module cannot be loaded", async () => { + const directory = await trackedDirectory(); + vi.resetModules(); + // Reproduces the module-scope throw the optional dependency raises when no + // prebuilt binding matches the platform, and MODULE_NOT_FOUND when an + // install omitted it. + vi.doMock("fs-ext-extra-prebuilt", () => { + throw new Error("Failed to load fs-ext native module."); + }); + try { + const { QwpNodeFileReplayStore: UnavailableStore } = await import( + "../../src/qwp-node/file-replay-store" + ); + const store = new UnavailableStore({ directory }); + await expect(store.load()).rejects.toMatchObject({ + name: "QwpReplayStoreUnavailableError", + directory, + } satisfies Partial); + // The binding resolves before the lock file is opened, so a slot that + // cannot be owned is never given Java-visible lock metadata. + await expect(readdir(directory)).resolves.toEqual([]); + } finally { + vi.doUnmock("fs-ext-extra-prebuilt"); + vi.resetModules(); + } + }); + it("arbitrates acquisition over stale Java lock metadata", async () => { const directory = await trackedDirectory(); await writeFile(join(directory, ".lock"), ""); From 9b31c0648d19c4a1466f8cff5b6bd3527d83a9bf Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 20 Aug 2026 18:31:41 +0100 Subject: [PATCH 091/265] fix(qwp): share the writer column brand across bundles The column factories brand every descriptor with a module-private Symbol, and writer() validates that brand. bunchee emits one self-contained bundle per entry point, so writer.ts was compiled into ./qwp, ./qwp/node and ./qwp/browser separately and each evaluated its own Symbol() call. The factories are exported only from ./qwp while writer() lives on senders built from the other entries, so every published call path compared brands minted by different bundles and sender.writer() raised "invalid QWP writer descriptor" for every consumer of the package. Every suite imports from src/, where all four entries share one module instance, so none of them could observe it. Register the brand with Symbol.for so each bundle resolves the same symbol; the key carries a version because the global registry is shared, and a future incompatible descriptor shape must not silently interop with this one. Add a suite that loads the built bundles through package.json exports the way a consumer does, in both module formats, and run it in CI. It also pins the optional native locking module: the package root must still load where no prebuilt binding matches, and --external must keep the addon out of the bundle, since inlining it breaks its own binary lookup at runtime. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/build.yml | 7 ++ package.json | 1 + src/qwp/writer.ts | 12 +- test/qwp/dist.e2e.ts | 217 ++++++++++++++++++++++++++++++++++++ vitest.dist.config.ts | 11 ++ 5 files changed, 247 insertions(+), 1 deletion(-) create mode 100644 test/qwp/dist.e2e.ts create mode 100644 vitest.dist.config.ts diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d4e3f71..b2476da 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -40,6 +40,13 @@ jobs: - name: Tests run: pnpm test + # Loads the built bundles through package.json `exports`, the way a + # consumer does. Every other suite imports from `src/`, where all four + # entry points share one module instance and cross-bundle defects are + # invisible. + - name: Built package tests + run: pnpm test:dist + qwp-browser-e2e: name: QWP browser E2E runs-on: ubuntu-latest diff --git a/package.json b/package.json index 7327b4a..4ac246e 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "scripts": { "test": "vitest", "test:qwp-browser-e2e": "pnpm build && vitest run --config vitest.qwp-browser.config.ts", + "test:dist": "pnpm build && vitest run --config vitest.dist.config.ts", "build": "bunchee --external fs-ext-extra-prebuilt", "eslint": "eslint src/**", "typecheck": "tsc --noEmit", diff --git a/src/qwp/writer.ts b/src/qwp/writer.ts index e737ec6..24f25f8 100644 --- a/src/qwp/writer.ts +++ b/src/qwp/writer.ts @@ -24,7 +24,17 @@ export type QwpWriterColumnKind = | "doubleArray" | "longArray"; -const QWP_WRITER_COLUMN = Symbol("QWP writer column"); +// Registered in the global symbol registry rather than created per module. +// The published package emits one bundle per entry point ('.', './qwp', +// './qwp/browser', './qwp/node'), so a module-private brand would differ +// between the bundle that stamps a column and the bundle that validates it: +// a schema built with the factories from './qwp' would be rejected by the +// writer() of a sender imported from './qwp/node'. The key carries a version +// so a future incompatible descriptor shape cannot interop with this one. +const QWP_WRITER_COLUMN: unique symbol = Symbol.for( + "questdb.qwp.writer.column.v1", +); +// Type-level only: never stamped at runtime, so it needs no shared identity. const QWP_WRITER_INPUT: unique symbol = Symbol("QWP writer input"); /** Maximum DECIMAL scale of each fixed-width decimal column type. */ diff --git a/test/qwp/dist.e2e.ts b/test/qwp/dist.e2e.ts new file mode 100644 index 0000000..0913cec --- /dev/null +++ b/test/qwp/dist.e2e.ts @@ -0,0 +1,217 @@ +import { execFile } from "node:child_process"; +import { existsSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { beforeAll, describe, expect, it } from "vitest"; + +/** + * Consumer-facing checks that run against the built package instead of `src/`. + * + * Every other suite imports from `src/`, where all four entry points resolve to + * one module instance. The published package emits one bundle per entry point, + * so module-private state is duplicated per bundle and cross-entry-point usage + * can break in ways `src/`-level tests structurally cannot observe. The + * compiled writer regression these tests cover is exactly that: the column + * factories live only in `./qwp`, while `writer()` lives on senders built from + * `./qwp/node`, `./qwp/browser`, and the package root. + * + * Requires a build. Run with `pnpm test:dist`. + */ + +const ROOT = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../..", +); +const require_ = createRequire(import.meta.url); + +type Subpath = "." | "./qwp" | "./qwp/browser" | "./qwp/node"; +type Format = "import" | "require"; + +/** Resolves a subpath through package.json `exports`, as a consumer would. */ +let resolveExport: (subpath: Subpath, format: Format) => string; + +beforeAll(async () => { + const manifest = JSON.parse( + await readFile(path.join(ROOT, "package.json"), "utf8"), + ) as { exports: Record> }; + + resolveExport = (subpath, format) => { + const target = manifest.exports[subpath]?.[format]?.default; + if (!target) { + throw new Error( + `package.json exports has no '${format}' target for '${subpath}'`, + ); + } + return path.join(ROOT, target); + }; + + for (const subpath of [ + ".", + "./qwp", + "./qwp/browser", + "./qwp/node", + ] as const) { + for (const format of ["import", "require"] as const) { + const target = resolveExport(subpath, format); + if (!existsSync(target)) { + throw new Error( + `${target} is missing - run 'pnpm build' before this suite`, + ); + } + } + } +}); + +const load = (subpath: Subpath, format: Format) => + format === "require" + ? Promise.resolve(require_(resolveExport(subpath, format))) + : import(pathToFileURL(resolveExport(subpath, format)).href); + +/* eslint-disable @typescript-eslint/no-explicit-any */ +const schemaFrom = (factories: any) => ({ + symbol: factories.symbol(), + price: factories.double(), + timestamp: factories.designatedTimestamp("ns"), +}); + +const stageTwoRows = async (writer: any) => { + await writer.row({ symbol: "ETH-USD", price: 2615.54, timestamp: 1n }); + await writer.rows([{ symbol: "BTC-USD", price: 39_269.98, timestamp: 2n }]); +}; + +const URL_ = "ws://127.0.0.1:9/write/v4"; + +describe.each(["import", "require"] as const)( + "built package (%s)", + (format) => { + // The factories are exported only from './qwp', so every real use of a + // compiled writer crosses at least one entry-point boundary. + it.each(["./qwp/browser", "./qwp/node"] as const)( + "compiles a writer on a %s sender from './qwp' column factories", + async (senderSubpath) => { + const qwp: any = await load("./qwp", format); + const entry: any = await load(senderSubpath, format); + const create = + senderSubpath === "./qwp/node" + ? entry.createQwpNodeSender + : entry.createQwpBrowserSender; + + const sender = create({ url: URL_, autoFlush: false }); + const trades = sender.writer("trades", schemaFrom(qwp)); + await stageTwoRows(trades); + + expect(sender.metrics.pendingRows).toBe(2); + }, + ); + + it("compiles a writer on the package-root Sender", async () => { + const root: any = await load(".", format); + const qwp: any = await load("./qwp", format); + + const sender = await root.Sender.fromConfig( + "ws::addr=127.0.0.1:9;auto_flush=off;", + { log: () => {} }, + ); + const trades = sender.writer("trades", schemaFrom(qwp)); + await stageTwoRows(trades); + + expect(sender.publishedSequence).toBe(-1n); + }); + + it("re-exported factories keep the identity of their defining bundle", async () => { + const qwp: any = await load("./qwp", format); + const node: any = await load("./qwp/node", format); + const browser: any = await load("./qwp/browser", format); + + // './qwp/node' and './qwp/browser' re-export the factories with + // `export * from "./index"`, so they must be the very same functions. + expect(node.symbol).toBe(qwp.symbol); + expect(browser.symbol).toBe(qwp.symbol); + + // ...and a descriptor built through any of them must be accepted by a + // writer compiled in any other bundle. This is the assertion that fails + // when the column brand is a module-private Symbol rather than a shared + // one: the factory and the validator end up in different bundles. + const sender = node.createQwpNodeSender({ url: URL_, autoFlush: false }); + for (const factories of [qwp, node, browser]) { + expect(() => + sender.writer("trades", schemaFrom(factories)), + ).not.toThrow(); + } + }); + }, +); + +describe("optional native locking module", () => { + // `fs-ext-extra-prebuilt` ships prebuilt bindings only for + // darwin/linux/win32 x arm64/x64 on a bounded range of Node majors, and + // throws from its own module scope when none matches - so on musl (Alpine), + // a future Node major, or an exotic arch it is unloadable. Spoofing + // `process.platform` is what its loader keys off, so it reproduces exactly + // that state. Only store-and-forward needs the addon; ILP-only consumers of + // the package root must never pay for it. + const unloadable = (body: string) => + `Object.defineProperty(process,'platform',{value:'sunos'});${body}`; + + const runNode = (script: string) => + new Promise<{ code: number | null; stdout: string; stderr: string }>( + (resolve) => { + const child = execFile( + process.execPath, + ["-e", script], + (error, stdout, stderr) => + resolve({ + code: error ? ((error as { code?: number }).code ?? 1) : 0, + stdout, + stderr, + }), + ); + child.on("error", () => + resolve({ code: 1, stdout: "", stderr: "spawn failed" }), + ); + }, + ); + + it.each(["import", "require"] as const)( + "the package root loads (%s) when the addon cannot be loaded", + async (format) => { + const target = resolveExport(".", format); + const load_ = + format === "require" + ? `console.log(typeof require(${JSON.stringify(target)}).Sender)` + : `import(${JSON.stringify(pathToFileURL(target).href)}).then(m => console.log(typeof m.Sender))`; + + const { code, stdout, stderr } = await runNode(unloadable(load_)); + + // A static top-level import of the addon anywhere on the root entry's + // module graph makes this throw for every HTTP/TCP user on such a + // platform - the addon must stay behind a lazy import(). + expect(stderr).not.toMatch(/fs-ext/); + expect(code).toBe(0); + expect(stdout.trim()).toBe("function"); + }, + ); + + it("keeps the addon an external specifier rather than inlining it", async () => { + // bunchee externalizes `dependencies` and `peerDependencies` only. The + // addon is an optionalDependency, so `--external fs-ext-extra-prebuilt` in + // the build script is load-bearing: without it the module is inlined and + // its __dirname-relative binary lookup resolves into dist/ and breaks at + // runtime, which the import test above cannot observe. + for (const format of ["import", "require"] as const) { + const bundle = await readFile( + resolveExport("./qwp/node", format), + "utf8", + ); + + // The bare specifier survives, and it is reached through a dynamic + // import() rather than a top-level one. + expect(bundle).toMatch(/\bimport\(['"]fs-ext-extra-prebuilt['"]\)/); + // `findPrebuiltBinary` is the addon's own loader; seeing it here would + // mean the module was inlined into our bundle. + expect(bundle).not.toMatch(/findPrebuiltBinary/); + } + }); +}); diff --git a/vitest.dist.config.ts b/vitest.dist.config.ts new file mode 100644 index 0000000..f4efe46 --- /dev/null +++ b/vitest.dist.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["test/qwp/dist.e2e.ts"], + // The suite loads the built bundles directly; Vite must not pre-bundle or + // otherwise rewrite them, or the per-entry-point module identity this + // suite exists to check would be lost. + server: { deps: { external: [/dist[\\/]/] } }, + }, +}); From 2b871aa4e97997426a9de322d5cbafd750d70cdb Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 20 Aug 2026 18:31:48 +0100 Subject: [PATCH 092/265] fix(qwp): discard the row when a symbol value cannot be converted symbol() accepts an unknown value and stringifies it in addColumn's argument list. An argument expression is evaluated before the callee runs, so a conversion that throws -- a null-prototype object, a non-callable or throwing toString, a throwing Proxy trap -- escaped addColumn's rollback and left the sender inside a half-built row. The next at()/atNow() published that row, and a producer that instead started a fresh row had table() refuse it and its columns accumulate into the abandoned one, emitting a single row that merged two logical rows. That is exactly what the fluent row rollback prevents for every other setter. Convert inside the guard so the failure routes through failRow(). symbol() was the only method passing a conversion into addColumn's arguments without a guard of its own; the rest either guard themselves or delegate to a method that does. Co-Authored-By: Claude Opus 5 (1M context) --- src/qwp/sender.ts | 12 ++++++++++- test/qwp/sender.test.ts | 45 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/src/qwp/sender.ts b/src/qwp/sender.ts index 00ffe45..0bdc0cf 100644 --- a/src/qwp/sender.ts +++ b/src/qwp/sender.ts @@ -1016,7 +1016,17 @@ export class QwpSender { symbol(name: string, value: unknown): QwpSender { if (value === null || value === undefined) return this; - return this.addColumn(name, QWP_COLUMN_TYPE.SYMBOL, String(value)); + // String() runs inside the guard, not in addColumn's argument list: the + // value is `unknown`, so its conversion can throw (a null-prototype + // object, a throwing or non-callable toString, a throwing Proxy trap). + // Outside the guard that throw escapes before failRow() can discard the + // row, leaving the sender inside a half-built row that the next + // at()/atNow() would publish. + try { + return this.addColumn(name, QWP_COLUMN_TYPE.SYMBOL, String(value)); + } catch (error) { + return this.failRow(error); + } } stringColumn(name: string, value: string | null | undefined): QwpSender { diff --git a/test/qwp/sender.test.ts b/test/qwp/sender.test.ts index ae0f50a..c7edaf0 100644 --- a/test/qwp/sender.test.ts +++ b/test/qwp/sender.test.ts @@ -644,6 +644,51 @@ describe("QWP high-level sender", () => { expect(table.columns.map((item) => item.name)).toEqual(["kept"]); }); + it("rolls back the row when a symbol value cannot be converted", async () => { + const session = new RecordingSession(); + const sender = new QwpSender(async () => session, { autoFlush: false }); + + // symbol() takes `unknown` and stringifies it, so the conversion itself can + // throw. A null-prototype object has no toString; querystring.parse() and + // several JSON parsers hand these back, so it is ordinary user data. + sender.table("events").longColumn("value", 1n); + expect(() => + sender.symbol("tag", Object.create(null) as unknown), + ).toThrow(); + + // The rejected row must not survive to be published by the next close. + expect(() => sender.table("events")).not.toThrow(); + await sender.longColumn("value", 2n).atNow(); + await sender.flush(); + + const table = session.sends[0].tables[0]; + expect(table.rowCount).toBe(1); + expect(column(table, "value").values).toEqual([2n]); + }); + + it("does not merge a later row into one abandoned by a symbol failure", async () => { + const session = new RecordingSession(); + const sender = new QwpSender(async () => session, { autoFlush: false }); + + sender.table("events").longColumn("value", 1n); + expect(() => sender.symbol("tag", { toString: null } as unknown)).toThrow(); + + // Without the rollback the table stays selected, this symbol lands in the + // abandoned row, the duplicate `value` is dropped by the dedup guard, and + // one row carrying both rows' data is emitted. + await sender + .table("events") + .symbol("tag", "second") + .longColumn("value", 2n) + .atNow(); + await sender.flush(); + + const table = session.sends[0].tables[0]; + expect(table.rowCount).toBe(1); + expect(column(table, "value").values).toEqual([2n]); + expect(column(table, "tag").values).toEqual(["second"]); + }); + it("keeps the sender usable after a failed row, without losing staged rows", async () => { const session = new RecordingSession(); const sender = new QwpSender(async () => session, { autoFlush: false }); From bf933677cf147b56dd79023957bf8caf1317e383 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 20 Aug 2026 18:36:53 +0100 Subject: [PATCH 093/265] fix(qwp): allocate frame sequences only for journalled frames send() took its frame sequence when the frame object was built, before the serialized tail persisted anything. A rejected append -- an exhausted journal, a missed append deadline -- therefore consumed a sequence no store record ever followed, and the store enforces contiguity, so every later append failed with "sequence must be contiguous" until the journal drained completely. Under the "wait" backpressure policy the follow-on failures were not QwpReplayStoreFullError, so appendWithBackpressure rethrew them immediately and the configured wait degraded into instant failure for the rest of the outage. Journal exhaustion is the one error a producer is meant to see, and it has to be survivable. Allocate the sequence inside the tail, immediately before the append, and advance the counter only once the store has accepted the record. Sends are already serialized on sendTail, so allocation order still matches append order. skipIngressClientSequence() reserved a frame sequence too, which left the same hole whenever a split batch skipped a frame; it now reserves only the client sequence, which is all the ACK translation slot its name and callsite describe. Co-Authored-By: Claude Opus 5 (1M context) --- .../reconnecting-ingress-connection.ts | 23 +++++- test/qwp/reconnect.test.ts | 75 ++++++++++++++++++- 2 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/qwp/internal/reconnecting-ingress-connection.ts b/src/qwp/internal/reconnecting-ingress-connection.ts index 7f6e0eb..a216e54 100644 --- a/src/qwp/internal/reconnecting-ingress-connection.ts +++ b/src/qwp/internal/reconnecting-ingress-connection.ts @@ -101,7 +101,10 @@ class QwpDurableAckPersistentFailureError extends Error { } } -interface ReplayFrame extends QwpIngressReplayReference { +interface ReplayFrame extends Omit { + // Assigned inside send()'s serialized tail, immediately before the journal + // append, so a frame that never reaches the store consumes no sequence. + frameSequence: bigint; payload?: Uint8Array; readonly clientSequence?: bigint; ackDelivered: boolean; @@ -666,15 +669,19 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { } skipIngressClientSequence(): void { + // Only the client sequence is reserved. The skipped frame never reaches + // the journal, so consuming a frame sequence here would leave a hole that + // makes every later append non-contiguous. this.nextClientSequence++; - this.nextFrameSequence++; } send(payload: Uint8Array): Promise { if (this.terminalError) return Promise.reject(this.terminalError); if (this.closing) return Promise.reject(new QwpSendClosedError()); const frame: ReplayFrame = { - frameSequence: this.nextFrameSequence++, + // Placeholder; the real sequence is allocated in the tail below, once + // the journal has accepted the frame. + frameSequence: -1n, clientSequence: this.nextClientSequence++, payload: payload.slice(), payloadLength: payload.byteLength, @@ -692,10 +699,18 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { } await this.persistSymbolDictionaryDelta(delta); } + // Sends are serialized on sendTail, so allocating here rather than at + // call time keeps frame sequences dense and in append order. A rejected + // append -- an exhausted journal, a missed append deadline -- must not + // consume one: the store enforces contiguity, so a hole would make every + // later append fail until the journal drained completely. + const frameSequence = this.nextFrameSequence; await this.store.append({ - frameSequence: frame.frameSequence, + frameSequence, payload: frame.payload!, }); + this.nextFrameSequence = frameSequence + 1n; + frame.frameSequence = frameSequence; this.frames.set(frame.frameSequence, frame); this.publishedFrameSequence = frame.frameSequence; if (this.backgroundStoreAndForward) { diff --git a/test/qwp/reconnect.test.ts b/test/qwp/reconnect.test.ts index ae49629..b3e9388 100644 --- a/test/qwp/reconnect.test.ts +++ b/test/qwp/reconnect.test.ts @@ -317,6 +317,33 @@ class FailOnceDictionaryReplayStore extends TrackingReplayStore { } } +/** Rejects sequence holes the way QwpNodeFileReplayStore does. */ +class ContiguousReplayStore extends TrackingReplayStore { + appendAttempts = 0; + private lastSequence?: bigint; + + constructor(private readonly failOnAppendAttempt = 1) { + super(); + } + + override async append(record: QwpIngressReplayRecord): Promise { + this.appendAttempts++; + if (this.appendAttempts === this.failOnAppendAttempt) { + throw new Error("journal is full"); + } + const expected = + this.lastSequence === undefined ? 0n : this.lastSequence + 1n; + if (record.frameSequence !== expected) { + throw new Error( + "QWP store-and-forward sequence must be contiguous " + + `[previous=${this.lastSequence ?? -1n}, received=${record.frameSequence}]`, + ); + } + this.lastSequence = record.frameSequence; + await super.append(record); + } +} + class FailingDictionaryPersistenceReplayStore extends TrackingReplayStore { appendSymbolDictionaryCalls = 0; @@ -1550,12 +1577,58 @@ describe("QWP ingress reconnect and replay", () => { ).resolves.toBeUndefined(); expect(replayStore.appendAttempts).toBe(2); expect(replayStore.symbols).toEqual(["ETH-USD", "BTC-USD"]); + // The rejected append consumed no frame sequence, so the surviving record + // is the journal's first. A hole here would make the store reject every + // later append as non-contiguous. + expect([...replayStore.records.keys()]).toEqual([0n]); expect( - decodeQwpIngressSymbolDictionaryDelta(replayStore.records.get(1n)!), + decodeQwpIngressSymbolDictionaryDelta(replayStore.records.get(0n)!), ).toEqual({ startId: 0, entries: ["ETH-USD", "BTC-USD"] }); await session.close(); }); + it("keeps journal appends contiguous after a rejected append", async () => { + const replayStore = new ContiguousReplayStore(); + const session = await QwpIngressSession.connect( + async () => { + throw new QwpUpgradeError("offline", { + kind: QWP_UPGRADE_ERROR_KIND.TRANSPORT, + retryable: true, + tryNextEndpoint: true, + }); + }, + { + backgroundStoreAndForward: true, + reconnect: { + maxAttempts: 0, + maxDurationMs: 0, + initialBackoffMs: 10_000, + maxBackoffMs: 10_000, + }, + replayStore, + }, + ); + + await expect(session.publishFrame(Uint8Array.of(1))).rejects.toThrow( + "journal is full", + ); + + // Journal exhaustion is the one error a producer may see, and it must be + // survivable: once there is room again every later frame has to be + // accepted. Consuming a sequence for the rejected append would leave a + // hole and make the store reject everything that followed until the + // journal drained completely. + await expect( + session.publishFrame(Uint8Array.of(2)), + ).resolves.toBeUndefined(); + await expect( + session.publishFrame(Uint8Array.of(3)), + ).resolves.toBeUndefined(); + expect([...replayStore.records.keys()]).toEqual([0n, 1n]); + + await session.close(); + }); + it("retains ACK-waiting high-level rows until journal publication succeeds", async () => { const connection = new FakeConnection("primary"); const replayStore = new FailOnceDictionaryReplayStore(); From a42fec5ab79eedc8a1056c70d790a0ed4cfa1841 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 20 Aug 2026 18:41:50 +0100 Subject: [PATCH 094/265] fix(qwp): retry background segment maintenance instead of latching A failed maintenance batch stored its error in maintenanceFailure, which assertReady() and waitForCapacity() both raise, so append, acknowledgeThrough, readPayload and loadSymbolDictionary all failed from that point on. Nothing cleared the field except drainPendingMaintenance(), reachable only from close(), and nothing rescheduled maintenance either, because every entry point now threw. One transient trim failure -- a briefly full or read-only filesystem, a restarted maintenance worker -- therefore bricked a running store-and-forward producer for the rest of the process lifetime. The sibling checkpointFailure does not behave this way: its periodic timer keeps firing, so a later success clears it. Give maintenance the same shape. A failed batch schedules a retry, a completed batch clears the recorded failure, and the retry timer is unref'd and torn down with the checkpoint timer on close. Neither runMaintenanceBatch nor trimSegment calls assertReady, so the retry is not blocked by the failure it is meant to clear. A condition that genuinely persists still surfaces: the journal fills and append reports QwpReplayStoreFullError, which is an error the durability contract lets a producer see. Co-Authored-By: Claude Opus 5 (1M context) --- src/qwp-node/file-replay-store.ts | 28 +++++++++++++++++++ test/qwp/reconnect.test.ts | 45 +++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/src/qwp-node/file-replay-store.ts b/src/qwp-node/file-replay-store.ts index a61df96..e6554e3 100644 --- a/src/qwp-node/file-replay-store.ts +++ b/src/qwp-node/file-replay-store.ts @@ -54,6 +54,10 @@ const DEFAULT_MAX_SEGMENT_BYTES = 4 * 1024 * 1024; const DEFAULT_CHECKPOINT_INTERVAL_MS = 5_000; const DEFAULT_APPEND_DEADLINE_MS = 30_000; const TRIM_BATCH_SIZE = 8; +// Background segment trimming retries on this cadence. A trim failure is +// normally transient -- a briefly full or read-only filesystem, a maintenance +// worker restart -- so it must not become permanent. +const MAINTENANCE_RETRY_DELAY_MS = 1_000; const MAX_TIMER_DELAY_MS = 0x7fffffff; const UTF8_DECODER = new TextDecoder("utf-8", { fatal: true }); @@ -334,6 +338,7 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { private checkpointTimer?: ReturnType; private checkpointFailure?: QwpReplayStoreCheckpointError; private maintenanceFailure?: QwpReplayStoreError; + private maintenanceRetryTimer?: ReturnType; private totalCheckpoints = 0; private totalCheckpointFailures = 0; private totalBackpressureStalls = 0; @@ -948,6 +953,8 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { this.closing = true; if (this.checkpointTimer) clearTimeout(this.checkpointTimer); this.checkpointTimer = undefined; + if (this.maintenanceRetryTimer) clearTimeout(this.maintenanceRetryTimer); + this.maintenanceRetryTimer = undefined; this.rejectCapacityWaiters(this.closedError()); this.closePromise = this.operationTail.then(async () => { let failure: unknown; @@ -1287,6 +1294,7 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { error, ); this.rejectCapacityWaiters(this.maintenanceFailure); + this.scheduleMaintenanceRetry(); }); }); } @@ -1313,6 +1321,26 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { await this.removeAcknowledgedThrough(); } if (this.pendingTrimSegments.length > 0) this.scheduleMaintenance(); + // The batch completed, so whatever made the previous one fail is gone. + // Mirrors checkpointDirty(), which clears checkpointFailure on success. + this.maintenanceFailure = undefined; + } + + private scheduleMaintenanceRetry(): void { + if ( + this.maintenanceRetryTimer || + this.closing || + this.closed || + this.pendingTrimSegments.length === 0 + ) { + return; + } + this.maintenanceRetryTimer = setTimeout(() => { + this.maintenanceRetryTimer = undefined; + if (this.closing || this.closed) return; + this.scheduleMaintenance(); + }, MAINTENANCE_RETRY_DELAY_MS); + this.maintenanceRetryTimer.unref?.(); } private async drainPendingMaintenance(): Promise { diff --git a/test/qwp/reconnect.test.ts b/test/qwp/reconnect.test.ts index b3e9388..7933600 100644 --- a/test/qwp/reconnect.test.ts +++ b/test/qwp/reconnect.test.ts @@ -73,6 +73,7 @@ import { writeQwpVarint, } from "../../src/qwp"; import { QwpAsyncQueue } from "../../src/qwp/internal/async-queue"; +import { qwpSegmentMaintenanceWorker } from "../../src/qwp-node/segment-maintenance-worker"; import { createQwpEgressFailoverConnectionFactory } from "../../src/qwp/internal/egress-routing"; import { createQwpFailoverConnectionFactory, @@ -3373,6 +3374,50 @@ describe("QWP Node file replay store", () => { await recovered.close(); }); + it("recovers from a transient background maintenance failure", async () => { + const directory = await trackedDirectory(); + const store = new QwpNodeFileReplayStore({ directory, maxSegmentBytes: 1 }); + await store.load(); + for (let sequence = 0n; sequence < 3n; sequence++) { + await store.append({ + frameSequence: sequence, + payload: Uint8Array.of(Number(sequence)), + }); + } + + // Trimming an emptied segment is background work. Fail it once, the way a + // briefly read-only or full filesystem, or a restarted maintenance worker, + // would. The spy falls back to the real implementation afterwards, so the + // condition is genuinely transient. + const unlink = vi + .spyOn(qwpSegmentMaintenanceWorker, "unlink") + .mockRejectedValueOnce( + Object.assign(new Error("EACCES: permission denied"), { + code: "EACCES", + }), + ); + + await store.acknowledgeThrough(0n); + await vi.waitFor(() => expect(unlink).toHaveBeenCalled()); + + // The failure must not latch. Before the fix it was cleared only by + // close(), so every later append, acknowledgeThrough and readPayload threw + // the trim error for the rest of the process lifetime. + // waitFor surfaces the store's own error if it never recovers, so a + // regression reports the latched trim failure rather than a bare timeout. + await vi.waitFor(() => store.loadSymbolDictionary(), { + timeout: 4_000, + interval: 100, + }); + await expect( + store.append({ frameSequence: 3n, payload: Uint8Array.of(3) }), + ).resolves.toBeUndefined(); + await expect(store.acknowledgeThrough(1n)).resolves.toBeUndefined(); + + unlink.mockRestore(); + await store.close(); + }, 15_000); + it("detects a replay gap immediately after a persisted ACK watermark", async () => { const directory = await trackedDirectory(); const first = new QwpNodeFileReplayStore({ From e42368a6a8a01524ea8cfd7174cef3a2a16db02c Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 20 Aug 2026 18:45:37 +0100 Subject: [PATCH 095/265] fix(qwp): retry orphan slots that fail to drain transiently The drainer treated QwpReplayStoreLockedError as the single retryable failure and quarantined everything else behind a .failed sentinel, reporting the journal as abandoned data. An unreachable server, an ACK poll deadline, ENOSPC or an EMFILE while loadReferences opens one descriptor per segment therefore destroyed a perfectly replayable slot -- and the server being down is the normal reason the producer that left the slot behind died in the first place. Recovery then needed a manual retryQwpNodeOrphanSlot(). Invert the classification so only failures that are terminal by design quarantine: a rejected authentication, a protocol violation (also how poison-frame escalation surfaces), an exhausted durable-ACK capability-gap episode, and a corrupt journal. Anything else emits the new "retrying" event, counts in metrics.retrying, and leaves the slot for a later scan without a sentinel or a data-loss report. The existing quarantine test raised a bare Error as a stand-in for a terminal failure and now raises the QwpReplayStoreCorruptionError its name describes. Co-Authored-By: Claude Opus 5 (1M context) --- src/qwp-node/orphan-drainer.ts | 38 +++++++++++++++++++ .../reconnecting-ingress-connection.ts | 2 +- test/qwp/orphan-drainer.test.ts | 37 +++++++++++++++++- 3 files changed, 75 insertions(+), 2 deletions(-) diff --git a/src/qwp-node/orphan-drainer.ts b/src/qwp-node/orphan-drainer.ts index b860fa9..6db0266 100644 --- a/src/qwp-node/orphan-drainer.ts +++ b/src/qwp-node/orphan-drainer.ts @@ -2,14 +2,19 @@ import { open, readdir, unlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { QWP_RECONNECT_EVENT_KIND, + QWP_UPGRADE_ERROR_KIND, type QwpReconnectEvent, QwpConnectionCloseInfo, QwpIngressTransportMetrics, + QwpUpgradeError, } from "../qwp/transport"; import { isQwpNodeReplayQuarantineSlotName, + QwpReplayStoreCorruptionError, QwpReplayStoreLockedError, } from "./file-replay-store"; +import { QwpProtocolError } from "../qwp/core/errors"; +import { QwpDurableAckPersistentFailureError } from "../qwp/internal/reconnecting-ingress-connection"; import { QwpNotificationDispatcher } from "../qwp/internal/notification-dispatcher"; import { createQwpDataLossSenderError, @@ -36,6 +41,8 @@ export const QWP_ORPHAN_DRAIN_EVENT_KIND = { STARTED: "started", DRAINED: "drained", LOCKED: "locked", + /** The attempt failed transiently; the slot is left for a later scan. */ + RETRYING: "retrying", DURABLE_ACK_UNAVAILABLE: "durable-ack-unavailable", DURABLE_ACK_PERSISTENT_FAILURE: "durable-ack-persistent-failure", PRIMARY_UNAVAILABLE: "primary-unavailable", @@ -67,6 +74,8 @@ export interface QwpNodeOrphanDrainerMetrics { readonly active: number; readonly drained: number; readonly locked: number; + /** Attempts that failed transiently and left the slot in place. */ + readonly retrying: number; readonly failed: number; readonly scanFailures: number; readonly deliveredNotifications: number; @@ -244,6 +253,7 @@ export class QwpNodeOrphanDrainer { private discovered = 0; private drained = 0; private locked = 0; + private retrying = 0; private failed = 0; private scanFailures = 0; @@ -319,6 +329,7 @@ export class QwpNodeOrphanDrainer { active: this.active.size, drained: this.drained, locked: this.locked, + retrying: this.retrying, failed: this.failed, scanFailures: this.scanFailures, deliveredNotifications: this.eventDispatcher?.metrics.delivered ?? 0, @@ -451,6 +462,15 @@ export class QwpNodeOrphanDrainer { return; } const failure = toError(error, "QWP orphan drain failed"); + if (!isTerminalDrainFailure(failure)) { + // Transient: the journal is intact and a later scan can still drain + // it. Quarantining here would abandon accepted rows -- and report + // data loss -- because the process briefly ran out of descriptors or + // the server was unreachable. + this.retrying++; + this.emit(QWP_ORPHAN_DRAIN_EVENT_KIND.RETRYING, directory, failure); + return; + } this.failed++; await markFailed(directory, failure).catch(() => undefined); this.emit(QWP_ORPHAN_DRAIN_EVENT_KIND.FAILED, directory, failure); @@ -595,6 +615,24 @@ function delay(milliseconds: number): Promise { return new Promise((resolve) => setTimeout(resolve, milliseconds)); } +/** + * Only failures that are terminal by design quarantine a slot behind its + * `.failed` sentinel and report the abandoned bytes as data loss: a rejected + * authentication, a protocol violation (which is also how poison-frame + * escalation surfaces), an exhausted durable-ACK capability-gap episode, and a + * corrupt journal. Everything else -- an unreachable server, an ACK timeout, + * EMFILE, ENOSPC -- is transient, and the slot is left intact for a later scan. + */ +function isTerminalDrainFailure(error: Error): boolean { + if (error instanceof QwpReplayStoreCorruptionError) return true; + if (error instanceof QwpProtocolError) return true; + if (error instanceof QwpDurableAckPersistentFailureError) return true; + if (error instanceof QwpUpgradeError) { + return error.kind === QWP_UPGRADE_ERROR_KIND.AUTHENTICATION; + } + return false; +} + function toError(error: unknown, fallback: string): Error { return error instanceof Error ? error : new Error(fallback, { cause: error }); } diff --git a/src/qwp/internal/reconnecting-ingress-connection.ts b/src/qwp/internal/reconnecting-ingress-connection.ts index a216e54..2424088 100644 --- a/src/qwp/internal/reconnecting-ingress-connection.ts +++ b/src/qwp/internal/reconnecting-ingress-connection.ts @@ -88,7 +88,7 @@ class QwpCatchUpCapGapError extends RangeError { } } -class QwpDurableAckPersistentFailureError extends Error { +export class QwpDurableAckPersistentFailureError extends Error { constructor( readonly attempts: number, readonly episodeMs: number, diff --git a/test/qwp/orphan-drainer.test.ts b/test/qwp/orphan-drainer.test.ts index aa837bf..390d459 100644 --- a/test/qwp/orphan-drainer.test.ts +++ b/test/qwp/orphan-drainer.test.ts @@ -6,6 +6,7 @@ import { QWP_ORPHAN_DRAIN_EVENT_KIND, QWP_ORPHAN_FAILED_SENTINEL, QwpNodeOrphanDrainer, + QwpReplayStoreCorruptionError, QwpReplayStoreLockedError, retryQwpNodeOrphanSlot, scanQwpNodeOrphanSlots, @@ -274,10 +275,44 @@ describe("QWP Node orphan drainer", () => { await drainer.close(); }); + it("leaves a slot intact when the drain attempt fails transiently", async () => { + const rootDirectory = await root(); + const directory = await recordSlot(rootDirectory, "transient"); + // EMFILE while opening one descriptor per segment, an unreachable server, + // an ACK poll timeout: the journal is intact and a later scan can drain it. + const transient = Object.assign(new Error("EMFILE: too many open files"), { + code: "EMFILE", + }); + const senderErrors: QwpSenderError[] = []; + const drainer = new QwpNodeOrphanDrainer({ + rootDirectory, + scanIntervalMs: 0, + createSession: async () => { + throw transient; + }, + onSenderError: (error) => senderErrors.push(error), + }); + drainer.start(); + + await vi.waitFor(() => expect(drainer.metrics.retrying).toBe(1)); + expect(drainer.metrics.failed).toBe(0); + // No sentinel, no abandoned-data report, and the slot is still offered to + // the next scan. + expect(await readdir(directory)).not.toContain(QWP_ORPHAN_FAILED_SENTINEL); + expect(senderErrors).toEqual([]); + await expect(scanQwpNodeOrphanSlots(rootDirectory)).resolves.toEqual([ + directory, + ]); + + await drainer.close(); + }); + it("quarantines terminal failures until an operator explicitly retries", async () => { const rootDirectory = await root(); const directory = await recordSlot(rootDirectory, "corrupt"); - const terminal = new Error("corrupt replay record"); + // Terminal by design: a corrupt journal cannot be replayed, so the slot is + // quarantined rather than retried. + const terminal = new QwpReplayStoreCorruptionError("corrupt replay record"); const senderErrors: QwpSenderError[] = []; const events: string[] = []; const drainer = new QwpNodeOrphanDrainer({ From 436d8412d9518d5920f0db5dae2d9220c98b8dff Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 20 Aug 2026 18:50:11 +0100 Subject: [PATCH 096/265] fix(qwp): release rows that cannot fit the negotiated batch cap Staging is deliberately retained when a flush fails, so a transient transport error costs no rows. A QwpBatchTooLargeError is not transient: the splitter bisects a batch down to single rows, and a single row above the cap re-encodes to the same oversized frame every time. Retaining it wedged the sender -- flush(), auto-flush and close() all raised the identical error, nothing ever reached the wire, pendingRows and pendingByteCount grew without bound, and close() finally discarded every good row staged before and after the offending one. The default store-and-forward segment size makes the cap 4 MiB, so one large varchar or binary value is enough. Release the flush's staged rows when the cap rejects them, at both points the rejection can surface: the synchronous plan inside sendTablesWithPublication, and the awaited publication. The caller still receives the error, which names the offending size and the cap, and the discarded count is logged. Splicing and the pending-counter arithmetic move into releaseStagedRows() so the success and abandon paths cannot drift apart. Co-Authored-By: Claude Opus 5 (1M context) --- src/qwp/sender.ts | 169 +++++++++++++++++++++++++-------------- test/qwp/session.test.ts | 36 +++++++++ 2 files changed, 144 insertions(+), 61 deletions(-) diff --git a/src/qwp/sender.ts b/src/qwp/sender.ts index 0bdc0cf..57ee1a3 100644 --- a/src/qwp/sender.ts +++ b/src/qwp/sender.ts @@ -9,6 +9,7 @@ import { type QwpArrayValue, } from "./core"; import { + QwpBatchTooLargeError, QwpIngressAckTimeoutError, type QwpIngressSendResult, type QwpIngressMetrics, @@ -1995,6 +1996,52 @@ export class QwpSender { throw error; } + /** Removes a flush's staged rows from the pending buffers. */ + private releaseStagedRows( + snapshots: readonly { table: StagedTable; rows: readonly StagedRow[] }[], + ): number { + for (const { table, rows } of snapshots) table.rows.splice(0, rows.length); + const rowCount = snapshots.reduce( + (count, item) => count + item.rows.length, + 0, + ); + const byteCount = snapshots.reduce( + (total, item) => + total + + item.rows.reduce( + (tableTotal, row) => tableTotal + row.estimatedBytes, + 0, + ), + 0, + ); + this.pendingRowCount -= rowCount; + this.pendingByteCount -= byteCount; + return rowCount; + } + + /** + * Staging is normally retained when a flush fails, so a transient transport + * error costs no rows. A batch-cap rejection is not transient: re-encoding + * the same rows always exceeds the same cap, so retaining them wedges the + * sender -- every later flush, auto-flush and close() raises the identical + * error, pendingRows grows without bound, and close() finally discards the + * lot. Release them instead; the caller still sees the error, which names + * the offending size and the cap. + */ + private releaseUnsendableRows( + error: unknown, + snapshots: readonly { table: StagedTable; rows: readonly StagedRow[] }[], + ): void { + if (!(error instanceof QwpBatchTooLargeError)) return; + const abandoned = this.releaseStagedRows(snapshots); + this.log( + "error", + `Discarded ${abandoned} QWP row(s) that cannot fit the negotiated batch cap: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + private async tryFlush(): Promise { const byteThreshold = this.effectiveAutoFlushByteThreshold(); if ( @@ -2045,53 +2092,60 @@ export class QwpSender { let publication: Promise | undefined; let publishedSequence = -1n; const waitForServerAck = this.awaitServerAck && !publicationOnly; - if (waitForServerAck) { - const trackedSender = useDelta - ? session.sendTablesDeltaWithPublication - : session.sendTablesWithPublication; - if (trackedSender) { - const sending = trackedSender.call(session, wireTables, { - gorilla: encode?.gorilla, - deferCommit, - }); - response = sending.acknowledgement; - // Observe ACK rejection while the local-publication boundary is being - // awaited; it is consumed normally below after ownership transfers. - void response.catch(() => undefined); - publication = sending.publication.then(() => { - publishedSequence = sending.sequence; - }); + // planIngressFrames runs synchronously here, so an unfittable row throws + // before anything reaches the transport. + try { + if (waitForServerAck) { + const trackedSender = useDelta + ? session.sendTablesDeltaWithPublication + : session.sendTablesWithPublication; + if (trackedSender) { + const sending = trackedSender.call(session, wireTables, { + gorilla: encode?.gorilla, + deferCommit, + }); + response = sending.acknowledgement; + // Observe ACK rejection while the local-publication boundary is being + // awaited; it is consumed normally below after ownership transfers. + void response.catch(() => undefined); + publication = sending.publication.then(() => { + publishedSequence = sending.sequence; + }); + } else { + response = useDelta + ? session.sendTablesDelta!(wireTables, { + gorilla: encode?.gorilla, + deferCommit, + }) + : session.sendTables(wireTables, { + gorilla: encode?.gorilla, + deferCommit, + }); + } } else { - response = useDelta - ? session.sendTablesDelta!(wireTables, { - gorilla: encode?.gorilla, - deferCommit, - }) - : session.sendTables(wireTables, { - gorilla: encode?.gorilla, - deferCommit, - }); - } - } else { - const publisher = useDelta - ? session.publishTablesDelta - : session.publishTables; - if (!publisher) { - throw new Error( - "this QWP ingress session does not support publication-only flushes", - ); - } - publication = publisher - .call(session, wireTables, { - gorilla: encode?.gorilla, - deferCommit, - }) - .then(() => { - publishedSequence = advancedSequence( - beforeSequence, - sessionPublishedSequence(session), + const publisher = useDelta + ? session.publishTablesDelta + : session.publishTables; + if (!publisher) { + throw new Error( + "this QWP ingress session does not support publication-only flushes", ); - }); + } + publication = publisher + .call(session, wireTables, { + gorilla: encode?.gorilla, + deferCommit, + }) + .then(() => { + publishedSequence = advancedSequence( + beforeSequence, + sessionPublishedSequence(session), + ); + }); + } + } catch (error) { + this.releaseUnsendableRows(error, snapshots); + throw error; } publishedSequence = advancedSequence( beforeSequence, @@ -2101,22 +2155,15 @@ export class QwpSender { // Transfer row ownership only after every logical frame is accepted by // the transport. For Node store-and-forward this is the durable journal // boundary, independently of whether this flush also waits for an ACK. - if (publication) await publication; - for (const { table, rows } of snapshots) table.rows.splice(0, rows.length); - const sentRows = snapshots.reduce( - (count, item) => count + item.rows.length, - 0, - ); - const sentBytes = snapshots.reduce( - (total, item) => - total + - item.rows.reduce((tableTotal, row) => { - return tableTotal + row.estimatedBytes; - }, 0), - 0, - ); - this.pendingRowCount -= sentRows; - this.pendingByteCount -= sentBytes; + if (publication) { + try { + await publication; + } catch (error) { + this.releaseUnsendableRows(error, snapshots); + throw error; + } + } + const sentRows = this.releaseStagedRows(snapshots); this.totalRowsPublished += sentRows; this.lastFlushTime = Date.now(); this.log( diff --git a/test/qwp/session.test.ts b/test/qwp/session.test.ts index 550b0da..f944dd3 100644 --- a/test/qwp/session.test.ts +++ b/test/qwp/session.test.ts @@ -787,6 +787,42 @@ describe("QWP WebSocket adapters", () => { await sender.close(); }); + it("stays usable after a row that cannot fit the negotiated batch cap", async () => { + const socket = new FakeWebSocket(); + const cap = 200; + const sender = createQwpBrowserSender( + { + url: "ws://localhost:9000/write/v4", + webSocketFactory: () => asQwpSocket(socket), + }, + { autoFlush: false, encode: { gorilla: false } }, + { maxBatchSizeBytes: cap }, + ); + const connecting = sender.connect(); + socket.open(); + await connecting; + socket.onSend = () => { + const sequence = BigInt(socket.sent.length - 1); + socket.message(ingressResponse(QWP_STATUS.OK, sequence)); + }; + + // The splitter bisects a batch down to single rows; one row above the cap + // is unsplittable and always re-encodes to the same oversized frame. + await sender.table("events").stringColumn("v", "x".repeat(500)).atNow(); + await expect(sender.flush()).rejects.toBeInstanceOf(QwpBatchTooLargeError); + + // Retaining those rows would wedge the sender: the same error on every + // later flush, pendingRows growing without bound, and close() discarding + // everything staged after it. + expect(sender.metrics.pendingRows).toBe(0); + await sender.table("events").stringColumn("v", "ok").atNow(); + await expect(sender.flush()).resolves.toBe(true); + expect(socket.sent).toHaveLength(1); + expect(socket.sent[0].byteLength).toBeLessThanOrEqual(cap); + + await sender.close(); + }); + it("pipelines transactional browser auto-flush until an explicit commit ACK", async () => { const socket = new FakeWebSocket(); const sender = createQwpBrowserSender( From b3f13a47c22f61f2cb803567cb9e7fd65ab45710 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 20 Aug 2026 18:52:49 +0100 Subject: [PATCH 097/265] fix(qwp): encode DATE columns as timestamps on ingress columnPayloadSize() and writeColumn() grouped DATE with LONG and emitted a bare int64 run, while the result decoder routes DATE through the same timestamp reader as TIMESTAMP and so consumes a per-column encoding byte whenever QWP_FLAG_GORILLA is set -- which is the default. The two halves of this client therefore disagreed about the layout of a DATE column: feeding the encoder's own output back to QwpResultBatchDecoder makes it read the low byte of the first value as the encoding byte and misparse the rest of the batch. Nothing caught it because DATE is never round-tripped encoder to decoder. Treat DATE as the timestamp column it is, which also makes it Gorilla-eligible rather than always raw. The decoder is the side to match: it models what the server sends, and the hand-written server fixture in test/qwp/egress.test.ts writes a null flag plus an encoding byte for DATE, against a single flag byte for the LONG beside it. Worth confirming against the server implementation, since no QWP server was available to arbitrate directly. Co-Authored-By: Claude Opus 5 (1M context) --- src/qwp/core/ingress.ts | 10 ++++++++-- test/qwp/sender.test.ts | 28 ++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src/qwp/core/ingress.ts b/src/qwp/core/ingress.ts index 3052580..6b933ce 100644 --- a/src/qwp/core/ingress.ts +++ b/src/qwp/core/ingress.ts @@ -159,7 +159,11 @@ function columnPayloadSize( return size + Math.ceil(valueCount / 8); } + // DATE carries milliseconds since the epoch and the result decoder reads it + // through the same timestamp path as TIMESTAMP, so it takes the same + // per-column encoding byte and is Gorilla-eligible. if ( + column.type === QWP_COLUMN_TYPE.DATE || column.type === QWP_COLUMN_TYPE.TIMESTAMP || column.type === QWP_COLUMN_TYPE.TIMESTAMP_NANOS ) { @@ -307,14 +311,16 @@ function writeColumn( for (const value of column.values) writer.writeFloat32(Number(value)); return; case QWP_COLUMN_TYPE.LONG: - case QWP_COLUMN_TYPE.DATE: for (const value of column.values) { writer.writeBigInt64(BigInt(value as number | bigint)); } return; + case QWP_COLUMN_TYPE.DATE: case QWP_COLUMN_TYPE.TIMESTAMP: case QWP_COLUMN_TYPE.TIMESTAMP_NANOS: { - const timestamps = column.values.map((value) => BigInt(value as bigint)); + const timestamps = column.values.map((value) => + BigInt(value as number | bigint), + ); if (!options.gorilla) { for (const timestamp of timestamps) writer.writeBigInt64(timestamp); return; diff --git a/test/qwp/sender.test.ts b/test/qwp/sender.test.ts index c7edaf0..cb4d4fc 100644 --- a/test/qwp/sender.test.ts +++ b/test/qwp/sender.test.ts @@ -644,6 +644,34 @@ describe("QWP high-level sender", () => { expect(table.columns.map((item) => item.name)).toEqual(["kept"]); }); + it("gives DATE the same per-column encoding byte as TIMESTAMP", async () => { + const frameFor = async ( + write: (sender: QwpSender) => QwpSender, + ): Promise => { + const session = new RecordingSession(); + const sender = new QwpSender(async () => session, { autoFlush: false }); + await write(sender.table("t")).atNow(); + await sender.flush(); + return encodeQwpIngressFrame( + session.sends[0].tables, + session.sends[0].options, + ).byteLength; + }; + + const date = await frameFor((s) => s.dateColumn("c", 1_700_000_000_000)); + const timestamp = await frameFor((s) => + s.timestampColumn("c", 1_700_000_000_000_000n), + ); + const long = await frameFor((s) => s.longColumn("c", 1_700_000_000_000n)); + + // The result decoder routes DATE through its timestamp reader, which + // consumes a per-column encoding byte whenever QWP_FLAG_GORILLA is set. + // Encoding DATE as a raw LONG left the decoder a byte short, so it + // consumed the low byte of the first value as the encoding byte. + expect(date).toBe(timestamp); + expect(date).toBe(long + 1); + }); + it("rolls back the row when a symbol value cannot be converted", async () => { const session = new RecordingSession(); const sender = new QwpSender(async () => session, { autoFlush: false }); From 91c9603a9dbac4e55de2a43b562602641e90eaa6 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 20 Aug 2026 18:56:28 +0100 Subject: [PATCH 098/265] fix(qwp): trim the ingress wire log as cumulative ACKs arrive wireFrames was append-only for the life of a connection: it was replaced only on reconnect and cleared only on close. Every OK ACK then sliced the whole acknowledged prefix and scanned it three times, so ACK handling was quadratic in frames sent, and because frame.payload is released only for a lazy replay store, the default in-memory store pinned every payload ever sent. A healthy long-lived connection -- the good case -- was the one that grew. Measured over 4 KiB frames: at 16k frames the log held 16k entries and 62.5 MB of payload while metrics.memoryReplayUsedBytes reported 4160 bytes, so the growth was invisible to the documented observability surface. Index the log by wire sequence minus a base offset and drop the covered prefix once an ACK has consumed it. ACKs are cumulative, so nothing reads that prefix again; trimDurablePrefix walks this.frames rather than the wire log, and the frame objects stay reachable there until acknowledgeThrough retires them. A sequence whose frame has already been trimmed now takes the same path as one that was never sent: a duplicate OK is dropped, a NACK is still reported. The same measurement afterwards holds one entry and no retained payload at every size, and 16k frames publish 7.6x faster. Co-Authored-By: Claude Opus 5 (1M context) --- .../reconnecting-ingress-connection.ts | 34 ++++++++++++++----- test/qwp/reconnect.test.ts | 33 ++++++++++++++++++ 2 files changed, 58 insertions(+), 9 deletions(-) diff --git a/src/qwp/internal/reconnecting-ingress-connection.ts b/src/qwp/internal/reconnecting-ingress-connection.ts index 2424088..741dc69 100644 --- a/src/qwp/internal/reconnecting-ingress-connection.ts +++ b/src/qwp/internal/reconnecting-ingress-connection.ts @@ -341,7 +341,12 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { private connectingCandidate?: QwpBinaryConnection; private lastHandshake?: QwpHandshakeMetadata; private lastEndpoint?: string | URL; + // Wire log for the current connection, indexed by wire sequence minus + // wireFramesBase. Acknowledged frames are dropped and the base advances, so + // the log stays proportional to what is still unacknowledged rather than to + // everything ever sent on the connection. private wireFrames: ReplayFrame[] = []; + private wireFramesBase = 0; private nextFrameSequence = 0n; private nextClientSequence = 0n; private publishedFrameSequence = -1n; @@ -1091,6 +1096,7 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { this.lastHandshake = connection.handshake; this.lastEndpoint = connection.endpoint; this.wireFrames = wireFrames; + this.wireFramesBase = 0; if (connection.ping && !this.ping) { // Assigned only when the initial transport supports PING so browser // connections keep the optional capability genuinely absent. @@ -1219,7 +1225,18 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { `QWP response sequence is negative: ${response.sequence}`, ); } - if (this.wireFrames.length === 0) { + const highestWireIndex = this.wireFramesBase + this.wireFrames.length - 1; + const wireIndex = Number( + response.sequence > BigInt(highestWireIndex) + ? BigInt(highestWireIndex) + : response.sequence, + ); + const localIndex = wireIndex - this.wireFramesBase; + const frame = localIndex >= 0 ? this.wireFrames[localIndex] : undefined; + if (!frame) { + // Either nothing has been sent on this connection yet, or this sequence + // was covered by an earlier cumulative ACK and trimmed. A duplicate OK + // has already been delivered; a NACK still has to be reported. if (response.status === QWP_STATUS.OK) return undefined; this.totalServerNacks++; const pending = this.pendingFsnRange(); @@ -1244,17 +1261,10 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { }`, ); } - const highestWireIndex = this.wireFrames.length - 1; - const wireIndex = Number( - response.sequence > BigInt(highestWireIndex) - ? BigInt(highestWireIndex) - : response.sequence, - ); - const frame = this.wireFrames[wireIndex]; if (response.status === QWP_STATUS.OK) { if (frame.dictionaryCatchup) return undefined; - const covered = this.wireFrames.slice(0, wireIndex + 1); + const covered = this.wireFrames.slice(0, localIndex + 1); const clientTarget = findLastClientFrame(covered); const shouldDeliver = covered.some( (candidate) => @@ -1276,6 +1286,11 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { } else { await this.acknowledgeThrough(frame.frameSequence); } + // ACKs are cumulative, so nothing reads the covered prefix again. + // Dropping it keeps both the log and the payloads it pins bounded, and + // keeps each ACK proportional to the frames it actually covers. + this.wireFrames.splice(0, localIndex + 1); + this.wireFramesBase += localIndex + 1; if (!shouldDeliver || clientTarget?.clientSequence === undefined) { return undefined; } @@ -1744,6 +1759,7 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { if (!(this.store instanceof QwpMemoryReplayStore)) return; this.frames.clear(); this.wireFrames = []; + this.wireFramesBase = 0; this.symbolDictionary.length = 0; this.durableWatermarks.clear(); } diff --git a/test/qwp/reconnect.test.ts b/test/qwp/reconnect.test.ts index 7933600..990a619 100644 --- a/test/qwp/reconnect.test.ts +++ b/test/qwp/reconnect.test.ts @@ -1588,6 +1588,39 @@ describe("QWP ingress reconnect and replay", () => { await session.close(); }); + it("trims the wire log as cumulative ACKs arrive", async () => { + const connection = new FakeConnection("primary"); + const session = await QwpIngressSession.connect(async () => connection); + // The wire log is indexed by wire sequence and is not part of the public + // surface, but the invariant it has to hold is: it stays proportional to + // what is unacknowledged, never to everything ever sent on the connection. + const wireLog = () => + ( + session as unknown as { + connection: { wireFrames: readonly { payload?: Uint8Array }[] }; + } + ).connection.wireFrames; + + const payload = new Uint8Array(1024).fill(7); + for (let index = 0; index < 200; index++) { + const publishing = session.publishFrame(payload); + connection.receive(ingressResponse(QWP_STATUS.OK, BigInt(index))); + await publishing; + } + + // Retaining the acknowledged prefix pinned every payload for the life of + // the connection and made each ACK scan it three times over. + expect(wireLog().length).toBeLessThanOrEqual(2); + expect( + wireLog().reduce( + (total, frame) => total + (frame.payload?.byteLength ?? 0), + 0, + ), + ).toBeLessThanOrEqual(payload.byteLength * 2); + + await session.close(); + }); + it("keeps journal appends contiguous after a rejected append", async () => { const replayStore = new ContiguousReplayStore(); const session = await QwpIngressSession.connect( From 47ade10466449d780717bbaa78a516392a9c10a1 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 20 Aug 2026 21:30:08 +0100 Subject: [PATCH 099/265] Revert "fix(qwp): encode DATE columns as timestamps on ingress" This reverts commit b3f13a4. The change was wrong: QWP is deliberately asymmetric for DATE, and the encoder was already correct. The server parses ingress DATE as a plain fixed-width int64 -- QwpTableBlockCursor dispatches TYPE_DATE to QwpFixedWidthColumnCursor alongside LONG, IPV4 and UUID, and only TYPE_TIMESTAMP / TYPE_TIMESTAMP_NANOS reach QwpTimestampColumnCursor, which is the cursor that reads an encoding byte. Egress is the other way round: QwpResultBatchBuffer routes TYPE_DATE through emitTimestampSlice and writes the per-column encoding discriminator, which is what this package's result decoder and the egress test fixture both model. So the two directions genuinely differ, the decoder and the encoder were each right for their own side, and emitting an encoding byte on ingress would have made the server misparse every frame carrying a DATE column. Keep a test and comments pinning the asymmetry, since the apparent inconsistency between the two halves is what prompted the bad change. Co-Authored-By: Claude Opus 5 (1M context) --- src/qwp/core/ingress.ts | 20 ++++++++++++-------- test/qwp/sender.test.ts | 16 +++++++++------- 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/src/qwp/core/ingress.ts b/src/qwp/core/ingress.ts index 6b933ce..976f284 100644 --- a/src/qwp/core/ingress.ts +++ b/src/qwp/core/ingress.ts @@ -159,11 +159,15 @@ function columnPayloadSize( return size + Math.ceil(valueCount / 8); } - // DATE carries milliseconds since the epoch and the result decoder reads it - // through the same timestamp path as TIMESTAMP, so it takes the same - // per-column encoding byte and is Gorilla-eligible. + // DATE is deliberately absent here. The protocol is asymmetric for it: on + // ingress the server parses DATE as a plain fixed-width int64 + // (QwpTableBlockCursor dispatches TYPE_DATE to QwpFixedWidthColumnCursor, + // alongside LONG and UUID), while on egress it emits DATE through + // emitTimestampSlice with a per-column encoding byte. The result decoder in + // this package matches the egress side, so the two directions genuinely + // differ. Adding DATE to this branch makes every ingress frame carrying a + // DATE column misparse server-side. if ( - column.type === QWP_COLUMN_TYPE.DATE || column.type === QWP_COLUMN_TYPE.TIMESTAMP || column.type === QWP_COLUMN_TYPE.TIMESTAMP_NANOS ) { @@ -310,17 +314,17 @@ function writeColumn( case QWP_COLUMN_TYPE.FLOAT: for (const value of column.values) writer.writeFloat32(Number(value)); return; + // DATE joins LONG here: raw int64s, no per-column encoding byte. + // See columnPayloadSize() for why it is not a timestamp on ingress. case QWP_COLUMN_TYPE.LONG: + case QWP_COLUMN_TYPE.DATE: for (const value of column.values) { writer.writeBigInt64(BigInt(value as number | bigint)); } return; - case QWP_COLUMN_TYPE.DATE: case QWP_COLUMN_TYPE.TIMESTAMP: case QWP_COLUMN_TYPE.TIMESTAMP_NANOS: { - const timestamps = column.values.map((value) => - BigInt(value as number | bigint), - ); + const timestamps = column.values.map((value) => BigInt(value as bigint)); if (!options.gorilla) { for (const timestamp of timestamps) writer.writeBigInt64(timestamp); return; diff --git a/test/qwp/sender.test.ts b/test/qwp/sender.test.ts index cb4d4fc..ad255e3 100644 --- a/test/qwp/sender.test.ts +++ b/test/qwp/sender.test.ts @@ -644,7 +644,7 @@ describe("QWP high-level sender", () => { expect(table.columns.map((item) => item.name)).toEqual(["kept"]); }); - it("gives DATE the same per-column encoding byte as TIMESTAMP", async () => { + it("encodes DATE on ingress as a raw int64, unlike TIMESTAMP", async () => { const frameFor = async ( write: (sender: QwpSender) => QwpSender, ): Promise => { @@ -664,12 +664,14 @@ describe("QWP high-level sender", () => { ); const long = await frameFor((s) => s.longColumn("c", 1_700_000_000_000n)); - // The result decoder routes DATE through its timestamp reader, which - // consumes a per-column encoding byte whenever QWP_FLAG_GORILLA is set. - // Encoding DATE as a raw LONG left the decoder a byte short, so it - // consumed the low byte of the first value as the encoding byte. - expect(date).toBe(timestamp); - expect(date).toBe(long + 1); + // QWP is asymmetric for DATE and this pins the ingress half. The server + // parses it as a plain fixed-width int64 (QwpTableBlockCursor sends + // TYPE_DATE to QwpFixedWidthColumnCursor), so it carries no per-column + // encoding byte -- even though the egress result batch gives DATE that + // byte and this package's decoder reads it. Making the two directions + // "consistent" breaks ingest. + expect(date).toBe(long); + expect(date).toBe(timestamp - 1); }); it("rolls back the row when a symbol value cannot be converted", async () => { From 4540b846833e2a2af4d7e6ddcb2e3011fc3ba375 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 20 Aug 2026 21:37:37 +0100 Subject: [PATCH 100/265] fix(qwp): roll back the schema a discarded row introduced addColumn() writes into table.schema before staging the column, and discardRow() cleared only currentRow and the table selection. A rejected or cancelled row therefore left its column types pinned on the table: reusing one of those names with a different type raised "column type mismatch" for a column that never reached QuestDB, recoverable only through reset(), which drops every row staged since the last flush. table() also registers a StagedTable before any column exists, so a loop that keeps rejecting rows on fresh table names accumulated one empty table per name for the sender's lifetime. Track the schema keys each row introduces and delete them when the row is discarded, and drop a table that the discarded row created if nothing else has staged or learned from it. Only keys this row added are rolled back, and finishRow() releases them, so a committed row still pins its column types and per-table consistency is unchanged -- both directions are covered by tests. Co-Authored-By: Claude Opus 5 (1M context) --- src/qwp/sender.ts | 20 +++++++++++ test/qwp/sender.test.ts | 75 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+) diff --git a/src/qwp/sender.ts b/src/qwp/sender.ts index 57ee1a3..e091d18 100644 --- a/src/qwp/sender.ts +++ b/src/qwp/sender.ts @@ -879,6 +879,10 @@ export class QwpSender { private readonly tablesByName = new Map(); private current?: StagedTable; private currentRow = new Map(); + // Schema keys the row in progress introduced. A row that is discarded must + // not leave its column types behind: nothing was published, so nothing was + // learned about the table. + private currentRowSchemaKeys: string[] = []; private pendingRowCount = 0; private pendingByteCount = 0; private lastFlushTime = Date.now(); @@ -978,6 +982,7 @@ export class QwpSender { this.tables.length = 0; this.tablesByName.clear(); this.current = undefined; + this.currentRowSchemaKeys.length = 0; this.currentRow.clear(); this.resetAutoFlush(); return this; @@ -1946,6 +1951,7 @@ export class QwpSender { } if (this.currentRow.has(nameKey)) return this; const canonicalName = existingSchema?.name ?? name; + if (!existingSchema) this.currentRowSchemaKeys.push(nameKey); table.schema.set(nameKey, { name: canonicalName, type, ...metadata }); this.currentRow.set(nameKey, { name: canonicalName, @@ -1963,6 +1969,7 @@ export class QwpSender { const table = this.requireTable(); const estimatedBytes = stagedRowBytes(this.currentRow); table.rows.push({ columns: this.currentRow, estimatedBytes }); + this.currentRowSchemaKeys.length = 0; this.currentRow = new Map(); this.current = undefined; this.pendingRowCount++; @@ -1987,6 +1994,19 @@ export class QwpSender { * row that table() then refuses to reopen. */ private discardRow(): void { + const table = this.current; + if (table) { + for (const key of this.currentRowSchemaKeys) table.schema.delete(key); + // A table this row brought into being, and that nothing else has staged + // or learned from, goes with it. Otherwise a loop that keeps rejecting + // rows on fresh table names accumulates empty StagedTables forever. + if (table.rows.length === 0 && table.schema.size === 0) { + this.tablesByName.delete(table.name); + const index = this.tables.indexOf(table); + if (index >= 0) this.tables.splice(index, 1); + } + } + this.currentRowSchemaKeys.length = 0; this.currentRow.clear(); this.current = undefined; } diff --git a/test/qwp/sender.test.ts b/test/qwp/sender.test.ts index ad255e3..0d2956f 100644 --- a/test/qwp/sender.test.ts +++ b/test/qwp/sender.test.ts @@ -719,6 +719,81 @@ describe("QWP high-level sender", () => { expect(column(table, "tag").values).toEqual(["second"]); }); + it("does not let a discarded row pin the table schema", async () => { + const session = new RecordingSession(); + const sender = new QwpSender(async () => session, { autoFlush: false }); + + // 'a' only ever appeared in a row that was thrown away, so nothing about + // it reached QuestDB and it must not constrain the column's type. + sender.table("events").longColumn("a", 1n); + expect(() => sender.stringColumn("b", 42 as unknown as string)).toThrow(); + await sender.table("events").stringColumn("a", "x").atNow(); + await sender.flush(); + + const table = session.sends[0].tables[0]; + expect(column(table, "a").type).toBe(QWP_COLUMN_TYPE.VARCHAR); + }); + + it("does not let a cancelled row pin the table schema", async () => { + const session = new RecordingSession(); + const sender = new QwpSender(async () => session, { autoFlush: false }); + + sender.table("events").longColumn("a", 1n).cancelRow(); + await sender.table("events").stringColumn("a", "x").atNow(); + await sender.flush(); + + expect(column(session.sends[0].tables[0], "a").type).toBe( + QWP_COLUMN_TYPE.VARCHAR, + ); + }); + + it("still pins the schema learned from a row that was published", () => { + const session = new RecordingSession(); + const sender = new QwpSender(async () => session, { autoFlush: false }); + + // The rollback must not weaken per-table type consistency: this row was + // completed, so its column types are real. + sender.table("events").longColumn("a", 1n).atNow(); + expect(() => sender.table("events").stringColumn("a", "x")).toThrow( + /column type mismatch/, + ); + }); + + it("keeps an earlier row's schema when a later row is discarded", async () => { + const session = new RecordingSession(); + const sender = new QwpSender(async () => session, { autoFlush: false }); + + await sender.table("events").longColumn("a", 1n).atNow(); + // Discarding this row may only roll back what this row introduced ('b'), + // never what the committed row above learned ('a'). + sender.table("events").longColumn("b", 2n); + expect(() => sender.stringColumn("bad", 42 as unknown as string)).toThrow(); + + expect(() => sender.table("events").stringColumn("a", "x")).toThrow( + /column type mismatch/, + ); + await sender.table("events").longColumn("b", 3n).atNow(); + await sender.flush(); + expect(session.sends[0].tables[0].rowCount).toBe(2); + }); + + it("does not accumulate tables created by rows that were discarded", async () => { + const session = new RecordingSession(); + const sender = new QwpSender(async () => session, { autoFlush: false }); + + await sender.table("kept").longColumn("value", 1n).atNow(); + for (let index = 0; index < 100; index++) { + sender.table(`transient-${index}`).longColumn("value", 1n); + expect(() => + sender.stringColumn("bad", 42 as unknown as string), + ).toThrow(); + } + + const staged = (sender as unknown as { tables: readonly unknown[] }).tables; + expect(staged).toHaveLength(1); + expect(sender.metrics.pendingRows).toBe(1); + }); + it("keeps the sender usable after a failed row, without losing staged rows", async () => { const session = new RecordingSession(); const sender = new QwpSender(async () => session, { autoFlush: false }); From 70776f4d9030df50c406da50625ec1beefbeba39 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 20 Aug 2026 22:10:10 +0100 Subject: [PATCH 101/265] fix(qwp): parse ws/wss connect strings with one schema A ws/wss connect string was parsed by two different parsers depending on how the sender was built. Sender.fromConfig() short-circuited to resolveQwpNodeClientConfig(), the Java-aligned QWP schema, while SenderOptions.fromConfig() + new Sender() ran the string through the legacy ILP parser, which had ws/wss support bolted on with a different vocabulary. The two disagreed on nearly every key and inverted the TLS pair exactly: tls_ca was accepted by one and rejected by the other, tls_roots the other way round, and init_buf_size, max_buf_size, request_timeout and friends were accepted and then silently ignored on the SenderOptions path. Every QuestDB client has to accept the same connect-string keys, so ws/wss now resolve only through the QWP schema. The legacy parser recognises the scheme, hands the string to resolveQwpNodeClientConfig() and stops; SenderOptions carries the resolved configuration, and Sender builds from it, so Sender.fromConfig() no longer needs a special case at all. Unknown keys now report like the Java client -- "unknown configuration key: " plus its relocation hint for a legacy key used on ws/wss, so init_buf_size explains where it applies and retry_timeout points at reconnect_max_duration_millis. Node's QWP key set already matched io.questdb.client.impl.ConfigSchema exactly, so no key changed name or meaning. close_flush_timeout_millis, initial_connect_retry and catch_up_cap_gap_min_escalation_window_millis leave the legacy vocabulary because they are QWP-only; auto_flush_bytes stays for udp, which is a legacy transport in the Java client too. Programmatic construction from a discrete options object is unchanged, and a new test pins the two entry points to identical results. Co-Authored-By: Claude Opus 5 (1M context) --- src/options.ts | 159 ++++++++++----------------- src/qwp-node/client-config.ts | 27 ++++- src/sender.ts | 120 +++++---------------- test/options.test.ts | 161 ++++++++++++---------------- test/qwp/node-client-config.test.ts | 2 +- 5 files changed, 180 insertions(+), 289 deletions(-) diff --git a/src/options.ts b/src/options.ts index 8e3a7d5..15479dc 100644 --- a/src/options.ts +++ b/src/options.ts @@ -7,17 +7,48 @@ import * as https from "https"; import { Logger } from "./logging"; import { fetchJson, isBoolean, isInteger } from "./utils"; import { DEFAULT_REQUEST_TIMEOUT } from "./transport/http/base"; +import { resolveQwpNodeClientConfig } from "./qwp-node/client-config"; +import type { QwpNodeClientOptions } from "./qwp/node"; import type { QwpNodeIngressOptions, QwpNodeUdpOptions, - QwpInitialConnectMode, QwpIngressSessionOptions, QwpSenderOptions, } from "./qwp/node"; +/** + * @ignore + * Connect strings for ws/wss, kept aside for the QWP schema to parse. A + * WeakMap rather than a field so SenderOptions keeps its legacy ILP shape. + */ +const qwpConnectStrings = new WeakMap(); + +/** @ignore Configuration resolved from a ws/wss connect string. */ +const qwpConfigs = new WeakMap(); + +/** @ignore Returns the QWP configuration these options resolved to. */ +function qwpConfig(options: SenderOptions): QwpNodeClientOptions | undefined { + return qwpConfigs.get(options); +} + +function resolveQwpConfig( + options: SenderOptions, + configString: string, +): QwpNodeClientOptions { + const configuredWebSocket = options.qwp?.webSocket; + const { storeAndForward, ...webSocketOverrides } = configuredWebSocket ?? {}; + let agent = webSocketOverrides.agent; + if (!agent && options.agent instanceof http.Agent) agent = options.agent; + return resolveQwpNodeClientConfig(configString, { + webSocket: { ...webSocketOverrides, agent }, + storeAndForward, + sender: { ...options.qwp?.sender, log: options.log ?? undefined }, + ingressSession: options.qwp?.session, + }); +} + const HTTP_PORT = 9000; const TCP_PORT = 9009; -const QWP_PORT = 9000; const QWP_UDP_PORT = 9007; const HTTP = "http"; @@ -76,7 +107,9 @@ type DeprecatedOptions = { * Connection and protocol options *

      *
    • protocol: enum, accepted values: http, https, tcp, tcps, ws, wss, udp - The protocol used to communicate with the server.
      - * WS/WSS select acknowledged QWP ingress. UDP selects Node-only fire-and-forget QWP datagrams. When https, tcps, or wss is used, the connection is secured with TLS encryption. + * WS/WSS select acknowledged QWP ingress; their connect strings use the QWP configuration schema, + * shared with the other QuestDB clients, and are documented in QWP.md rather than in this list. + * UDP selects Node-only fire-and-forget QWP datagrams and uses the options below. When https, tcps, or wss is used, the connection is secured with TLS encryption. *
    • *
    • protocol_version: enum, accepted values: auto, 1, 2 - The protocol version used for data serialization.
      * Version 1 uses text-based serialization for all data types. Version 2 uses binary encoding for doubles and arrays.
      @@ -128,23 +161,14 @@ type DeprecatedOptions = { *
    • auto_flush_rows: integer - The number of rows that will trigger a flush. When set to 0, row-based flushing is disabled.
      * The Sender will default this parameter to 75000 rows when HTTP protocol is used, and to 600 in case of TCP protocol. *
    • - *
    • auto_flush_bytes: integer or off - QWP WebSocket buffered-byte threshold. Defaults to off.
      - * Reaching the threshold flushes after the completed row. This option is supported by ws/wss only. + *
    • auto_flush_bytes: integer or off - Buffered-byte threshold. Defaults to off.
      + * Reaching the threshold flushes after the completed row. This option is supported by udp only; + * on ws/wss it belongs to the QWP configuration schema. *
    • *
    • auto_flush_interval: integer - The number of milliseconds that will trigger a flush, default value is 1000. * When set to 0, interval-based flushing is disabled.
      * Note that the setting is checked only when a new row is added to the buffer. There is no timer registered to flush the buffer automatically. *
    • - *
    • close_flush_timeout_millis: integer - Maximum time QWP close waits for committed rows to be acknowledged. - * Defaults to 5000; 0 publishes pending rows but skips the ACK drain. This option is supported by ws/wss only. - *
    • - *
    • initial_connect_retry: enum, accepted values: off, sync, async - QWP persistent - * store-and-forward startup policy. Requires qwp.webSocket.storeAndForward. - *
    • - *
    • catch_up_cap_gap_min_escalation_window_millis: integer - Minimum dwell - * before an orphan symbol-dictionary cap gap can be quarantined. Defaults to 300000. - * Requires qwp.webSocket.storeAndForward. - *
    • *
    *
    * Buffer sizing options @@ -199,9 +223,6 @@ class SenderOptions { auto_flush_rows?: number; auto_flush_bytes?: number; auto_flush_interval?: number; - close_flush_timeout_millis?: number; - initial_connect_retry?: QwpInitialConnectMode; - catch_up_cap_gap_min_escalation_window_millis?: number; request_min_throughput?: number; request_timeout?: number; @@ -266,6 +287,13 @@ class SenderOptions { this.agent = extraOptions.agent; this.qwp = extraOptions.qwp; } + + const connectString = qwpConnectStrings.get(this); + if (connectString !== undefined) { + // Resolve now rather than at Sender construction so a bad key still + // fails here, where every other connect-string error is raised. + qwpConfigs.set(this, resolveQwpConfig(this, connectString)); + } } /** @@ -401,14 +429,19 @@ function parseConfigurationString( } const position = parseProtocol(options, configString); + if (options.protocol === WS || options.protocol === WSS) { + // QWP connect strings have their own Java-aligned key vocabulary and are + // parsed only by resolveQwpNodeClientConfig(). Parsing them here as well + // would be a second, divergent parser for the same string; the Sender + // resolves the stashed string through the QWP schema instead. + qwpConnectStrings.set(options, configString); + return; + } parseSettings(options, configString, position); parseProtocolVersion(options); parseAddress(options); parseBufferSizes(options); parseAutoFlushOptions(options); - parseCloseFlushOptions(options); - parseInitialConnectOptions(options); - parseCatchUpCapGapOptions(options); parseTlsOptions(options); parseRequestTimeoutOptions(options); parseMaxNameLength(options); @@ -471,9 +504,6 @@ const ValidConfigKeys = [ "auto_flush_rows", "auto_flush_bytes", "auto_flush_interval", - "close_flush_timeout_millis", - "initial_connect_retry", - "catch_up_cap_gap_min_escalation_window_millis", "request_min_throughput", "request_timeout", "retry_timeout", @@ -536,13 +566,9 @@ function parseProtocol(options: SenderOptions, configString: string) { } function parseProtocolVersion(options: SenderOptions) { - if ( - options.protocol === WS || - options.protocol === WSS || - options.protocol === UDP - ) { + if (options.protocol === UDP) { if (options.protocol_version !== undefined) { - throw new Error("'protocol_version' is not used by QWP transports"); + throw new Error("'protocol_version' is not used by the udp transport"); } return; } @@ -587,10 +613,6 @@ function parseAddress(options: SenderOptions) { case TCPS: options.port = TCP_PORT; return; - case WS: - case WSS: - options.port = QWP_PORT; - return; case UDP: options.port = QWP_UDP_PORT; return; @@ -632,76 +654,12 @@ function parseAutoFlushOptions(options: SenderOptions) { } else { parseInteger(options, "auto_flush_bytes", "auto flush bytes", 0); } - if ( - options.auto_flush_bytes !== undefined && - options.protocol !== WS && - options.protocol !== WSS && - options.protocol !== UDP - ) { - throw new Error("auto_flush_bytes is only supported for QWP transports"); + if (options.auto_flush_bytes !== undefined && options.protocol !== UDP) { + throw new Error("auto_flush_bytes is only supported for the udp transport"); } parseInteger(options, "auto_flush_interval", "auto flush interval", 0); } -function parseCloseFlushOptions(options: SenderOptions) { - parseInteger(options, "close_flush_timeout_millis", "close flush timeout", 0); - if ( - options.close_flush_timeout_millis !== undefined && - options.protocol !== WS && - options.protocol !== WSS - ) { - throw new Error( - "close_flush_timeout_millis is only supported for QWP ws/wss transport", - ); - } -} - -function parseInitialConnectOptions(options: SenderOptions) { - const value = options.initial_connect_retry as unknown; - if (value === undefined) return; - if (options.protocol !== WS && options.protocol !== WSS) { - throw new Error( - "initial_connect_retry is only supported for QWP ws/wss transport", - ); - } - switch (value) { - case "on": - case "true": - case "sync": - options.initial_connect_retry = "sync"; - return; - case "off": - case "false": - options.initial_connect_retry = "off"; - return; - case "async": - options.initial_connect_retry = "async"; - return; - default: - throw new Error( - `Invalid initial_connect_retry: '${String(value)}', accepted values: 'off', 'sync', 'async'`, - ); - } -} - -function parseCatchUpCapGapOptions(options: SenderOptions) { - parseInteger( - options, - "catch_up_cap_gap_min_escalation_window_millis", - "catch-up cap-gap minimum escalation window", - 0, - ); - if ( - options.catch_up_cap_gap_min_escalation_window_millis !== undefined && - options.protocol !== WS && - options.protocol !== WSS - ) { - throw new Error( - "catch_up_cap_gap_min_escalation_window_millis is only supported for QWP ws/wss transport", - ); - } -} - function parseTlsOptions(options: SenderOptions) { parseBoolean(options, "tls_verify", "TLS verify", UNSAFE_OFF); @@ -802,6 +760,7 @@ function parseInteger( export { SenderOptions, + qwpConfig, ExtraOptions, QwpExtraOptions, HTTP, diff --git a/src/qwp-node/client-config.ts b/src/qwp-node/client-config.ts index fd1fccb..49f76fa 100644 --- a/src/qwp-node/client-config.ts +++ b/src/qwp-node/client-config.ts @@ -20,6 +20,28 @@ const DEFAULT_SF_MAX_SEGMENT_BYTES = 4 * 1024 * 1024; const DEFAULT_SF_MAX_TOTAL_BYTES = 10 * 1024 * 1024 * 1024; const DEFAULT_SF_APPEND_DEADLINE_MS = 30_000; +/** + * Legacy ILP keys that are not part of the QWP vocabulary. They are rejected + * like any other unknown key, with the same relocation hint the Java client + * gives, so a connect string behaves identically across QuestDB clients. + */ +const RELOCATED_HINTS = new Map([ + ["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)"], +]); + const SUPPORTED_KEYS = new Set([ "addr", "username", @@ -345,7 +367,10 @@ function parseConfigurationString(configurationString: string): ParsedConfig { const rawValue = setting.slice(equals + 1); validateConfigText(rawKey, rawValue); if (!SUPPORTED_KEYS.has(rawKey)) { - throw new Error(`Unknown QWP cluster configuration key: '${rawKey}'`); + const hint = RELOCATED_HINTS.get(rawKey); + throw new Error( + `unknown configuration key: ${rawKey}${hint ? ` ${hint}` : ""}`, + ); } const key = rawKey === "user" ? "username" : rawKey === "pass" ? "password" : rawKey; diff --git a/src/sender.ts b/src/sender.ts index 05a1406..561bd53 100644 --- a/src/sender.ts +++ b/src/sender.ts @@ -2,9 +2,15 @@ import { readFileSync } from "node:fs"; import * as http from "node:http"; import * as https from "node:https"; -import { Agent as UndiciAgent } from "undici"; import { log, Logger } from "./logging"; -import { SenderOptions, ExtraOptions, UDP, WS, WSS } from "./options"; +import { + SenderOptions, + ExtraOptions, + qwpConfig, + UDP, + WS, + WSS, +} from "./options"; import { SenderTransport, createTransport } from "./transport"; import { SenderBuffer, createBuffer } from "./buffer"; import { isBoolean, isInteger, TimestampUnit } from "./utils"; @@ -16,7 +22,6 @@ import { } from "./qwp/node"; import type { QwpTableWriter } from "./qwp/sender"; import type { QwpWriterSchema } from "./qwp/writer"; -import { resolveQwpNodeClientConfig } from "./qwp-node/client-config"; const DEFAULT_AUTO_FLUSH_INTERVAL = 1000; // 1 sec const RESOLVED_QWP_SENDER = Symbol("resolvedQwpSender"); @@ -144,8 +149,17 @@ class Sender { options?.protocol === WSS || options?.protocol === UDP ) { - this.qwpSender = - options.protocol === UDP + const resolved = qwpConfig(options); + this.qwpSender = resolved + ? // SenderOptions already parsed the ws/wss connect string with the + // QWP schema, so there is one vocabulary and one parser however the + // sender was constructed. + createQwpNodeSender( + resolved.ingress, + resolved.sender, + resolved.ingressSession, + ) + : options.protocol === UDP ? createConfiguredQwpUdpSender(options, this.log) : createConfiguredQwpSender(options, this.log); this.autoFlush = false; @@ -184,9 +198,6 @@ class Sender { configurationString: string, extraOptions?: ExtraOptions, ): Promise { - if (isQwpWebSocketConfiguration(configurationString)) { - return createConfiguredQwpSenderFacade(configurationString, extraOptions); - } return new Sender( await SenderOptions.fromConfig(configurationString, extraOptions), ); @@ -582,66 +593,6 @@ class Sender { } } -function isQwpWebSocketConfiguration(configurationString: string): boolean { - const separator = configurationString?.indexOf("::") ?? -1; - if (separator < 0) return false; - const schema = configurationString.slice(0, separator); - return schema === WS || schema === WSS; -} - -function createConfiguredQwpSenderFacade( - configurationString: string, - extraOptions: ExtraOptions | undefined, -): Sender { - validateQwpExtraOptions(extraOptions); - const logger = extraOptions?.log ?? log; - const configuredWebSocket = extraOptions?.qwp?.webSocket; - const { storeAndForward, senderId, failoverUrls, ...webSocketOverrides } = - configuredWebSocket ?? {}; - let agent = webSocketOverrides.agent; - if (!agent && extraOptions?.agent instanceof http.Agent) { - agent = extraOptions.agent; - } - const resolved = resolveQwpNodeClientConfig(configurationString, { - webSocket: { ...webSocketOverrides, agent }, - storeAndForward, - sender: { ...extraOptions?.qwp?.sender, log: logger }, - ingressSession: extraOptions?.qwp?.session, - }); - const qwpSender = createQwpNodeSender( - { - ...resolved.ingress, - failoverUrls: failoverUrls ?? resolved.ingress.failoverUrls, - senderId: senderId ?? resolved.ingress.senderId, - }, - resolved.sender, - resolved.ingressSession, - ); - const separator = configurationString.indexOf("::"); - const options = { - protocol: configurationString.slice(0, separator), - log: logger, - [RESOLVED_QWP_SENDER]: qwpSender, - } as ResolvedQwpSenderOptions; - return new Sender(options); -} - -function validateQwpExtraOptions(extraOptions: ExtraOptions | undefined): void { - if (extraOptions?.log && typeof extraOptions.log !== "function") { - throw new Error("Invalid logging function"); - } - const agent = extraOptions?.agent; - if ( - agent && - !(agent instanceof UndiciAgent) && - !(agent instanceof http.Agent) && - // @ts-expect-error TypeScript narrows the Agent union too aggressively. - !(agent instanceof https.Agent) - ) { - throw new Error("Invalid HTTP agent"); - } -} - function createConfiguredQwpSender( options: SenderOptions, logger: Logger, @@ -661,27 +612,10 @@ function createConfiguredQwpSender( } const authorization = configuredWebSocket.authorization ?? qwpAuthorization(options); - if ( - (options.initial_connect_retry !== undefined || - options.catch_up_cap_gap_min_escalation_window_millis !== undefined) && - !configuredWebSocket.storeAndForward - ) { - throw new Error( - "initial_connect_retry and catch_up_cap_gap_min_escalation_window_millis require qwp.webSocket.storeAndForward", - ); - } - const storeAndForward = configuredWebSocket.storeAndForward - ? { - ...configuredWebSocket.storeAndForward, - initialConnectMode: - options.initial_connect_retry ?? - configuredWebSocket.storeAndForward.initialConnectMode, - catchUpCapGapMinEscalationWindowMs: - options.catch_up_cap_gap_min_escalation_window_millis ?? - configuredWebSocket.storeAndForward - .catchUpCapGapMinEscalationWindowMs, - } - : undefined; + // ws/wss connect-string keys are the QWP schema's, parsed only by + // resolveQwpNodeClientConfig(). This path builds a sender from a + // programmatic options object, so it reads options.qwp.* directly. + const storeAndForward = configuredWebSocket.storeAndForward; return createQwpNodeSender( { ...configuredWebSocket, @@ -698,15 +632,11 @@ function createConfiguredQwpSender( autoFlushRows: isInteger(options.auto_flush_rows, 0) ? options.auto_flush_rows : configuredSender.autoFlushRows, - autoFlushBytes: isInteger(options.auto_flush_bytes, 0) - ? options.auto_flush_bytes - : configuredSender.autoFlushBytes, + autoFlushBytes: configuredSender.autoFlushBytes, autoFlushIntervalMs: isInteger(options.auto_flush_interval, 0) ? options.auto_flush_interval : configuredSender.autoFlushIntervalMs, - closeFlushTimeoutMs: isInteger(options.close_flush_timeout_millis, 0) - ? options.close_flush_timeout_millis - : configuredSender.closeFlushTimeoutMs, + closeFlushTimeoutMs: configuredSender.closeFlushTimeoutMs, maxNameLength: isInteger(options.max_name_len, 1) ? options.max_name_len : configuredSender.maxNameLength, diff --git a/test/options.test.ts b/test/options.test.ts index 4ad1923..4a504ff 100644 --- a/test/options.test.ts +++ b/test/options.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, beforeAll, afterAll } from "vitest"; import { Agent } from "undici"; +import { Sender } from "../src/sender"; import { SenderOptions } from "../src"; import { MockHttp } from "./util/mockhttp"; import { readFileSync } from "fs"; @@ -240,11 +241,7 @@ describe("Configuration string parser suite", function () { expect(options.username).toBe("user1"); expect(options.token).toBe("jwkprivkey123"); - options = await SenderOptions.fromConfig("ws::addr=hostname"); - expect(options.host).toBe("hostname"); - expect(options.port).toBe(9000); - expect(options.protocol_version).toBeUndefined(); - + // ws/wss endpoints belong to the QWP schema, not the legacy ILP fields. options = await SenderOptions.fromConfig("udp::addr=hostname"); expect(options.host).toBe("hostname"); expect(options.port).toBe(9007); @@ -252,9 +249,12 @@ describe("Configuration string parser suite", function () { }); it("can parse protocol version", async function () { + // Rejected by the QWP schema, with the Java client's relocation hint. await expect( SenderOptions.fromConfig("ws::addr=hostname;protocol_version=1"), - ).rejects.toThrow("'protocol_version' is not used by QWP transports"); + ).rejects.toThrow( + "unknown configuration key: protocol_version (QWP negotiates the protocol version during the WebSocket upgrade)", + ); // invalid protocol version await expect( @@ -813,28 +813,22 @@ describe("Configuration string parser suite", function () { ).rejects.toThrow("Invalid auto flush rows option, not a number: '1w23'"); }); - it("parses auto_flush_bytes only for QWP transports", async function () { - let options = await SenderOptions.fromConfig( - "ws::addr=host:9000;auto_flush_bytes=123;", - ); - expect(options.auto_flush_bytes).toBe(123); - - options = await SenderOptions.fromConfig( - "wss::addr=host:9000;auto_flush_bytes=off;", - ); - expect(options.auto_flush_bytes).toBe(0); - - options = await SenderOptions.fromConfig( + it("parses auto_flush_bytes only for the udp transport", async function () { + // udp is a legacy transport in the Java client's vocabulary, so its keys + // stay on this parser; ws/wss carry auto_flush_bytes through the QWP one. + const options = await SenderOptions.fromConfig( "udp::addr=host:9007;auto_flush_bytes=1400;", ); expect(options.auto_flush_bytes).toBe(1400); await expect( - SenderOptions.fromConfig("ws::addr=host:9000;auto_flush_bytes=-1;"), + SenderOptions.fromConfig("udp::addr=host:9007;auto_flush_bytes=-1;"), ).rejects.toThrow("Invalid auto flush bytes option: -1"); await expect( SenderOptions.fromConfig("http::addr=host:9000;auto_flush_bytes=123;"), - ).rejects.toThrow("auto_flush_bytes is only supported for QWP transports"); + ).rejects.toThrow( + "auto_flush_bytes is only supported for the udp transport", + ); }); it("parses and validates QWP UDP options", async function () { @@ -853,89 +847,72 @@ describe("Configuration string parser suite", function () { await expect( SenderOptions.fromConfig("udp::addr=host;tls_verify=on;"), ).rejects.toThrow("TLS is not supported for QWP UDP transport"); + // On ws/wss these are legacy keys, rejected by the QWP schema with the + // Java client's relocation hint. await expect( SenderOptions.fromConfig("ws::addr=host;max_datagram_size=1400;"), ).rejects.toThrow( - "max_datagram_size and multicast_ttl are only supported for QWP UDP transport", - ); - }); - - it("parses close_flush_timeout_millis only for QWP WebSocket", async function () { - let options = await SenderOptions.fromConfig( - "ws::addr=host:9000;close_flush_timeout_millis=123;", - ); - expect(options.close_flush_timeout_millis).toBe(123); - - options = await SenderOptions.fromConfig( - "wss::addr=host:9000;close_flush_timeout_millis=0;", - ); - expect(options.close_flush_timeout_millis).toBe(0); - - await expect( - SenderOptions.fromConfig( - "ws::addr=host:9000;close_flush_timeout_millis=-1;", - ), - ).rejects.toThrow("Invalid close flush timeout option: -1"); - await expect( - SenderOptions.fromConfig( - "http::addr=host:9000;close_flush_timeout_millis=123;", - ), - ).rejects.toThrow( - "close_flush_timeout_millis is only supported for QWP ws/wss transport", + "unknown configuration key: max_datagram_size (applies to legacy http/tcp/udp transports only)", ); }); - it("parses initial_connect_retry only for QWP WebSocket", async function () { - await expect( - SenderOptions.fromConfig("ws::addr=host:9000;initial_connect_retry=off;"), - ).resolves.toMatchObject({ initial_connect_retry: "off" }); - await expect( - SenderOptions.fromConfig( - "wss::addr=host:9000;initial_connect_retry=sync;", - ), - ).resolves.toMatchObject({ initial_connect_retry: "sync" }); - await expect( - SenderOptions.fromConfig( - "ws::addr=host:9000;initial_connect_retry=async;", - ), - ).resolves.toMatchObject({ initial_connect_retry: "async" }); - await expect( - SenderOptions.fromConfig("ws::addr=host:9000;initial_connect_retry=on;"), - ).resolves.toMatchObject({ initial_connect_retry: "sync" }); - await expect( - SenderOptions.fromConfig( - "http::addr=host:9000;initial_connect_retry=sync;", - ), - ).rejects.toThrow( - "initial_connect_retry is only supported for QWP ws/wss transport", - ); - await expect( - SenderOptions.fromConfig( - "ws::addr=host:9000;initial_connect_retry=eventually;", - ), - ).rejects.toThrow("Invalid initial_connect_retry"); + it("parses a ws connect string with one schema, whichever entry point is used", async function () { + // There must be a single QWP parser: Sender.fromConfig() and + // SenderOptions.fromConfig() + new Sender() previously disagreed, and + // tls_ca/tls_roots were exactly inverted between them. + const cases = [ + ["sf_dir=/tmp/qwp-parity", true], + ["transaction=on", true], + ["tls_ca=/tmp/nope.pem", false], + ["init_buf_size=1024", false], + ["max_buf_size=99999", false], + ["retry_timeout=1000", false], + ["protocol_version=2", false], + ["bogus_key=1", false], + ] as const; + + for (const [setting, accepted] of cases) { + const config = `ws::addr=127.0.0.1:9000;${setting};`; + const viaOptions = await SenderOptions.fromConfig(config, { + log: () => {}, + }).then( + () => "ok", + (error: Error) => error.message, + ); + const viaSender = await Sender.fromConfig(config, { log: () => {} }).then( + async (sender) => { + await sender.close().catch(() => undefined); + return "ok"; + }, + (error: Error) => error.message, + ); + expect(viaOptions).toBe(viaSender); + expect(viaOptions === "ok").toBe(accepted); + } }); - it("parses orphan catch-up cap-gap dwell only for QWP WebSocket", async function () { - await expect( - SenderOptions.fromConfig( - "ws::addr=host:9000;catch_up_cap_gap_min_escalation_window_millis=300000;", - ), - ).resolves.toMatchObject({ - catch_up_cap_gap_min_escalation_window_millis: 300_000, - }); + it("leaves QWP-only keys to the QWP schema", async function () { + // close_flush_timeout_millis, initial_connect_retry and + // catch_up_cap_gap_min_escalation_window_millis are QWP vocabulary. This + // parser never sees them: on ws/wss the QWP schema takes the whole connect + // string, and on a legacy transport they are simply unknown. await expect( SenderOptions.fromConfig( - "ws::addr=host:9000;catch_up_cap_gap_min_escalation_window_millis=-1;", + "ws::addr=host:9000;close_flush_timeout_millis=123;initial_connect_retry=off;", ), - ).rejects.toThrow("Invalid catch-up cap-gap minimum escalation window"); - await expect( - SenderOptions.fromConfig( - "http::addr=host:9000;catch_up_cap_gap_min_escalation_window_millis=1;", - ), - ).rejects.toThrow( - "catch_up_cap_gap_min_escalation_window_millis is only supported for QWP ws/wss transport", - ); + ).resolves.toMatchObject({ protocol: "ws" }); + + for (const key of [ + "close_flush_timeout_millis=123", + "initial_connect_retry=sync", + "catch_up_cap_gap_min_escalation_window_millis=1", + ]) { + await expect( + SenderOptions.fromConfig(`http::addr=host:9000;${key};`), + ).rejects.toThrow( + `Unknown configuration key: '${key.slice(0, key.indexOf("="))}'`, + ); + } }); it("can parse auto_flush_interval config", async function () { diff --git a/test/qwp/node-client-config.test.ts b/test/qwp/node-client-config.test.ts index f28b718..3e30f82 100644 --- a/test/qwp/node-client-config.test.ts +++ b/test/qwp/node-client-config.test.ts @@ -341,7 +341,7 @@ describe("QWP unified Node client configuration", () => { ).toThrow(/Duplicate.*target/); expect(() => parseQwpNodeClientConfig("ws::addr=localhost;made_up=1;"), - ).toThrow(/Unknown.*made_up/); + ).toThrow(/unknown configuration key: made_up/); }); it("accepts and validates the remaining Java QWP configuration keys", async () => { From 7629fbdddbb970baccf6d23a53926848a717c69a Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 20 Aug 2026 22:28:20 +0100 Subject: [PATCH 102/265] fix(qwp): match the Java close-flush timeout and always bound close() Two defects around close_flush_timeout_millis, both settled against the Java client rather than against the surrounding prose. The default was 60_000 in both the sender and the connect-string parser, while QwpWebSocketSender declares closeFlushTimeoutMillis = 5_000L. A caller sizing a shutdown budget from the documentation, or moving between clients, was out by 12x. Use 5 seconds, and correct QWP.md and README.md, which claimed 60. The timeout also bounded nothing when it was set to 0. Java treats "0 or -1" as a fast close that skips the ACK drain, and Node did skip it -- but it derived the publication deadline from the same value, so withCloseDeadline() received undefined and awaited the publication unwrapped. The value chosen to make close() cheapest was the only one under which close() could block forever on an unreachable server. Java has no such hole because its publish is a local hand-off into the send ring; a publication here can be a socket write that never settles. Split the two deadlines: the drain keeps the opt-out, and the publication is bounded by the default when the caller opted out, so close() always returns. Co-Authored-By: Claude Opus 5 (1M context) --- QWP.md | 6 ++++-- README.md | 2 +- src/qwp-node/client-config.ts | 2 +- src/qwp/sender.ts | 25 ++++++++++++++++------ test/qwp/node-client-config.test.ts | 2 +- test/qwp/sender.test.ts | 32 +++++++++++++++++++++++++++++ 6 files changed, 58 insertions(+), 11 deletions(-) diff --git a/QWP.md b/QWP.md index e2b7cbb..d950848 100644 --- a/QWP.md +++ b/QWP.md @@ -516,8 +516,10 @@ Rows are staged until an auto-flush boundary or an explicit `flush()`. A `null` `undefined` column value omits that column from the row. `atNow()` asks QuestDB to assign the designated timestamp; `at(value, unit)` sends an explicit `ns`, `us`, or `ms` timestamp. `close()` publishes completed rows and waits for the committed-frame -ACK watermark for up to `closeFlushTimeoutMs` (60 seconds by default). Set it to `0` -to publish without the ACK drain. An unfinished row is still discarded with a warning. +ACK watermark for up to `closeFlushTimeoutMs` (5 seconds by default, matching the +Java client). Set it to `0` or a negative value for a fast close, which publishes +without the ACK drain; publication itself stays bounded, so `close()` always +returns. An unfinished row is still discarded with a warning. The configuration-string equivalent is `close_flush_timeout_millis`. `autoFlushBytes` is a soft threshold over estimated raw column-buffer storage and is diff --git a/README.md b/README.md index db82a75..74282d6 100644 --- a/README.md +++ b/README.md @@ -216,7 +216,7 @@ await sender.commit(); await sender.close(); ``` -QWP `close()` publishes completed rows and waits up to 60 seconds for their +QWP `close()` publishes completed rows and waits up to 5 seconds for their committed-frame ACK watermark. Configure `closeFlushTimeoutMs` (or `close_flush_timeout_millis` in a `ws::` string); `0` publishes without waiting. An unfinished row is not completed implicitly. diff --git a/src/qwp-node/client-config.ts b/src/qwp-node/client-config.ts index 49f76fa..a0a35dd 100644 --- a/src/qwp-node/client-config.ts +++ b/src/qwp-node/client-config.ts @@ -15,7 +15,7 @@ import type { QwpReconnectOptions, QwpTarget } from "../qwp/transport"; const DEFAULT_QWP_PORT = 9000; const MAX_BATCH_ROWS = 1_048_576; -const DEFAULT_CLOSE_FLUSH_TIMEOUT_MS = 60_000; +const DEFAULT_CLOSE_FLUSH_TIMEOUT_MS = 5_000; const DEFAULT_SF_MAX_SEGMENT_BYTES = 4 * 1024 * 1024; const DEFAULT_SF_MAX_TOTAL_BYTES = 10 * 1024 * 1024 * 1024; const DEFAULT_SF_APPEND_DEADLINE_MS = 30_000; diff --git a/src/qwp/sender.ts b/src/qwp/sender.ts index e091d18..6a7a93a 100644 --- a/src/qwp/sender.ts +++ b/src/qwp/sender.ts @@ -213,7 +213,9 @@ interface QwpSenderFlushResult { const DEFAULT_AUTO_FLUSH_ROWS = 1_000; const DEFAULT_AUTO_FLUSH_BYTES = 0; const DEFAULT_AUTO_FLUSH_INTERVAL_MS = 100; -const DEFAULT_CLOSE_FLUSH_TIMEOUT_MS = 60_000; +// Matches the Java client's close_flush_timeout default so close() costs the +// same everywhere. +const DEFAULT_CLOSE_FLUSH_TIMEOUT_MS = 5_000; const DEFAULT_MAX_NAME_LENGTH = 127; function validateNonNegativeInteger(value: number, name: string): void { @@ -1544,10 +1546,21 @@ export class QwpSender { private async closeNow(): Promise { if (this.closed) return; this.closing = true; - const deadline = + // The timeout bounds the ACK drain, and <= 0 opts out of it entirely + // ("fast close"), matching the Java client. Publication still has to be + // bounded: unlike Java's local hand-off into the send ring, a publication + // here can be a socket write that never settles, and leaving it unbounded + // made 0 -- the value chosen to make close() cheapest -- the only value + // that could hang forever. + const drainDeadline = this.closeFlushTimeoutMs > 0 ? Date.now() + this.closeFlushTimeoutMs : undefined; + const publishDeadline = + Date.now() + + (this.closeFlushTimeoutMs > 0 + ? this.closeFlushTimeoutMs + : DEFAULT_CLOSE_FLUSH_TIMEOUT_MS); let terminalError: unknown; try { @@ -1568,12 +1581,12 @@ export class QwpSender { throw error; } }); - await this.withCloseDeadline(closeFlush, deadline); + await this.withCloseDeadline(closeFlush, publishDeadline); const session = this.activeSession; const target = this.lastCommitBoundarySequence; if ( - deadline !== undefined && + drainDeadline !== undefined && session && target >= 0n && sessionAcknowledgedSequence(session) < target @@ -1583,12 +1596,12 @@ export class QwpSender { "this QWP ingress session does not expose an ACK watermark", ); } - const remaining = deadline - Date.now(); + const remaining = drainDeadline - Date.now(); if (remaining <= 0) throw this.closeTimeoutError(); try { await this.withCloseDeadline( session.waitForAcknowledged(target, remaining), - deadline, + drainDeadline, ); } catch (error) { if (error instanceof QwpIngressAckTimeoutError) { diff --git a/test/qwp/node-client-config.test.ts b/test/qwp/node-client-config.test.ts index 3e30f82..fef9a78 100644 --- a/test/qwp/node-client-config.test.ts +++ b/test/qwp/node-client-config.test.ts @@ -125,7 +125,7 @@ describe("QWP unified Node client configuration", () => { expect(defaults.ingress.senderId).toBe("default"); expect(defaults.ingressSession?.initialConnectMode).toBe("off"); expect(defaults.sender).toMatchObject({ - closeFlushTimeoutMs: 60_000, + closeFlushTimeoutMs: 5_000, maxNameLength: 127, }); diff --git a/test/qwp/sender.test.ts b/test/qwp/sender.test.ts index 0d2956f..eb7aa6e 100644 --- a/test/qwp/sender.test.ts +++ b/test/qwp/sender.test.ts @@ -674,6 +674,38 @@ describe("QWP high-level sender", () => { expect(date).toBe(timestamp - 1); }); + it("bounds close() even when the ACK drain is opted out", async () => { + // close_flush_timeout_millis <= 0 is "fast close": it skips the ACK drain, + // as the Java client does. It must not also remove the bound on the + // publication -- that made 0, the value chosen to make close() cheapest, + // the only value that could block forever on an unreachable server. + class StallingSession extends RecordingSession { + override publishTables(): Promise { + return new Promise(() => undefined); + } + override publishTablesDelta(): Promise { + return this.publishTables(); + } + } + + const sender = new QwpSender(async () => new StallingSession(), { + autoFlush: false, + closeFlushTimeoutMs: 0, + }); + await sender.table("events").longColumn("value", 1n).atNow(); + + const settled = await Promise.race([ + sender.close().then( + () => "resolved", + (error: Error) => error.constructor.name, + ), + new Promise((resolve) => + setTimeout(() => resolve("still pending"), 8_000), + ), + ]); + expect(settled).toBe("QwpSenderCloseTimeoutError"); + }, 20_000); + it("rolls back the row when a symbol value cannot be converted", async () => { const session = new RecordingSession(); const sender = new QwpSender(async () => session, { autoFlush: false }); From a644688f9a21207a5bbec2c99ef89026ef8fb5bc Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 20 Aug 2026 22:34:30 +0100 Subject: [PATCH 103/265] fix(qwp): retry an over-cap frame instead of failing the sender transmit() and replayInto() applied the same check -- does this frame fit the reconnect target's batch cap -- and classified it oppositely. replayInto()'s RangeError is retryable, so the connect loop moves on to another node; transmit() threw outside its try, so enqueueDrain()'s catch called failTerminal() and a failover to a node with a smaller advertised cap killed a running store-and-forward sender outright. A frame journalled while offline was never transmitted, so replayInto() skips it and the drain loop reaches transmit(): that is the path a store-and-forward producer takes on its first connect after an outage. The Java client is explicit that this must not be terminal. Its cap-gap policy notes that data "already shipped inside a data frame is never reclassified as unsendable", that tightening the check "would invent a new terminal for data the producer sent successfully", and that on a cap gap "a foreground sender retries forever" -- only an orphan drainer may latch, and only after MAX_CATCHUP_CAP_GAP_ATTEMPTS consecutive gaps with no progress in between. Treat the over-cap frame as a connection-level failure and request a reconnect, matching replayInto()'s classification of the identical condition, so the loop keeps looking for a node that can take it. The frame is marked transmitted so replayInto() includes it in the resend set, and deliberately not pushed onto the wire log, which is indexed by wire sequence and must not gain an entry that never reached the wire. Co-Authored-By: Claude Opus 5 (1M context) --- .../reconnecting-ingress-connection.ts | 17 ++++++- test/qwp/reconnect.test.ts | 45 +++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/src/qwp/internal/reconnecting-ingress-connection.ts b/src/qwp/internal/reconnecting-ingress-connection.ts index 741dc69..073f419 100644 --- a/src/qwp/internal/reconnecting-ingress-connection.ts +++ b/src/qwp/internal/reconnecting-ingress-connection.ts @@ -1571,9 +1571,22 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { this.localMaxBatchSizeBytes, ); if (cap !== undefined && frame.payloadLength > cap) { - throw new RangeError( - `QWP frame exceeds reconnect target batch cap [size=${frame.payloadLength}, max=${cap}]`, + // Data the producer already handed over is never reclassified as + // unsendable because a failover landed on a smaller-cap node: that would + // invent a terminal for a frame an earlier node would have taken. Treat + // it as a connection-level failure, exactly as replayInto() does with the + // identical check, so the reconnect loop keeps looking for a node that + // can take it. Marking it transmitted is what puts it in replayInto()'s + // resend set; it is deliberately not pushed onto the wire log, because + // nothing reached the wire and the log is indexed by wire sequence. + frame.transmitted = true; + await this.requestReconnect( + new RangeError( + `QWP frame exceeds reconnect target batch cap [size=${frame.payloadLength}, max=${cap}]`, + ), + connection, ); + return; } const payload = await this.readFramePayload(frame); frame.transmitted = true; diff --git a/test/qwp/reconnect.test.ts b/test/qwp/reconnect.test.ts index 990a619..7f44910 100644 --- a/test/qwp/reconnect.test.ts +++ b/test/qwp/reconnect.test.ts @@ -1939,6 +1939,51 @@ describe("QWP ingress reconnect and replay", () => { await session.close(); }); + it("waits for a larger-cap node instead of failing a journalled frame", async () => { + // A frame journalled while offline was never transmitted, so replayInto() + // skips it and the drain loop calls transmit() -- the path that used to + // treat a smaller-cap node as terminal. The Java client retries a + // foreground sender forever rather than reclassifying data the producer + // already handed over as unsendable. + const tooSmall = new FakeConnection("small-cap", { + qwpVersion: 1, + maxBatchSizeBytes: 4, + }); + const large = new FakeConnection("large-cap"); + const attempts: unknown[] = []; + const session = await QwpIngressSession.connect( + async () => { + attempts.push(1); + if (attempts.length === 1) { + throw new QwpUpgradeError("offline", { + kind: QWP_UPGRADE_ERROR_KIND.TRANSPORT, + retryable: true, + tryNextEndpoint: true, + }); + } + return attempts.length === 2 ? tooSmall : large; + }, + { + backgroundStoreAndForward: true, + ackTimeoutMs: 1_000, + reconnect: { maxAttempts: 0, initialBackoffMs: 0, maxBackoffMs: 0 }, + }, + ); + + const payload = Uint8Array.of(1, 2, 3, 4, 5, 6, 7, 8); + await expect(session.publishFrame(payload)).resolves.toBeUndefined(); + + // The small-cap node cannot take the journalled frame; the session must + // roll on to one that can rather than going terminal. + await vi.waitFor(() => expect(large.sent).toHaveLength(1), { + timeout: 5_000, + }); + expect(large.sent[0]).toEqual(payload); + large.receive(ingressResponse(QWP_STATUS.OK, 0n)); + + await session.close(); + }, 20_000); + it("chunks reconnect dictionary catch-up under the negotiated batch cap", async () => { const first = new FakeConnection("primary"); const second = new FakeConnection("secondary", { From 4717b60674da0bb9fa7bd2a35c3601184ff64a3a Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 20 Aug 2026 22:50:00 +0100 Subject: [PATCH 104/265] fix(qwp): discard an over-cap batch on close, not on flush 436d841 released the staged rows as soon as a batch cap rejected them, which fixed the wedge but split from the Java client. Java keeps the two moments apart: the split throw "RETAINS the batch by design, and its message invites the caller to retry", while close() catches BatchTooLargeForCapException, calls resetTableBuffersAfterFlush() to discard it, records the error and lets the rest of shutdown run -- "unlike the over-cap case, this failure is not a verdict on the batch's contents" is the comment on the sibling catch that does not discard. Move the discard to the same boundary. A failed flush leaves staging alone, so the caller still owns the batch and can retry or trim it; close() drops what the cap will never accept, logs the abandoned count, and still tears the session down before rethrowing. The outer catch in closeNow() already let the rest of close() run, so only the discard was missing. The flush path loses the try/catch 436d841 added, restoring the original "do not compact staging if encoding throws" shape. Co-Authored-By: Claude Opus 5 (1M context) --- src/qwp/sender.ts | 145 +++++++++++++++++++-------------------- test/qwp/session.test.ts | 22 +++--- 2 files changed, 81 insertions(+), 86 deletions(-) diff --git a/src/qwp/sender.ts b/src/qwp/sender.ts index 6a7a93a..571fad9 100644 --- a/src/qwp/sender.ts +++ b/src/qwp/sender.ts @@ -1612,6 +1612,19 @@ export class QwpSender { } } catch (error) { terminalError = error; + if (error instanceof QwpBatchTooLargeError) { + // A cap rejection is a verdict on the batch's contents: no later flush + // can make it fit, so close() discards it and finishes shutdown rather + // than leaving it staged for a sender that is about to go away. Any + // other failure is not a verdict on the batch and leaves staging alone. + // This mirrors the Java client's close(), which calls + // resetTableBuffersAfterFlush() for exactly this exception. + const abandoned = this.discardStagedRows(); + this.log( + "error", + `Discarded ${abandoned} QWP row(s) on close: ${error.message}`, + ); + } } let closeError: unknown; @@ -2053,26 +2066,14 @@ export class QwpSender { } /** - * Staging is normally retained when a flush fails, so a transient transport - * error costs no rows. A batch-cap rejection is not transient: re-encoding - * the same rows always exceeds the same cap, so retaining them wedges the - * sender -- every later flush, auto-flush and close() raises the identical - * error, pendingRows grows without bound, and close() finally discards the - * lot. Release them instead; the caller still sees the error, which names - * the offending size and the cap. + * Discards every staged row, the way the Java client's close() does with + * resetTableBuffersAfterFlush() when the batch cap rejects the batch. */ - private releaseUnsendableRows( - error: unknown, - snapshots: readonly { table: StagedTable; rows: readonly StagedRow[] }[], - ): void { - if (!(error instanceof QwpBatchTooLargeError)) return; - const abandoned = this.releaseStagedRows(snapshots); - this.log( - "error", - `Discarded ${abandoned} QWP row(s) that cannot fit the negotiated batch cap: ${ - error instanceof Error ? error.message : String(error) - }`, - ); + private discardStagedRows(): number { + const snapshots = this.tables + .filter((table) => table.rows.length > 0) + .map((table) => ({ table, rows: table.rows.slice() })); + return this.releaseStagedRows(snapshots); } private async tryFlush(): Promise { @@ -2126,59 +2127,56 @@ export class QwpSender { let publishedSequence = -1n; const waitForServerAck = this.awaitServerAck && !publicationOnly; // planIngressFrames runs synchronously here, so an unfittable row throws - // before anything reaches the transport. - try { - if (waitForServerAck) { - const trackedSender = useDelta - ? session.sendTablesDeltaWithPublication - : session.sendTablesWithPublication; - if (trackedSender) { - const sending = trackedSender.call(session, wireTables, { - gorilla: encode?.gorilla, - deferCommit, - }); - response = sending.acknowledgement; - // Observe ACK rejection while the local-publication boundary is being - // awaited; it is consumed normally below after ownership transfers. - void response.catch(() => undefined); - publication = sending.publication.then(() => { - publishedSequence = sending.sequence; - }); - } else { - response = useDelta - ? session.sendTablesDelta!(wireTables, { - gorilla: encode?.gorilla, - deferCommit, - }) - : session.sendTables(wireTables, { - gorilla: encode?.gorilla, - deferCommit, - }); - } + // before anything reaches the transport and staging is retained: the + // caller keeps the batch and can retry it. close() is where an over-cap + // batch is finally discarded. + if (waitForServerAck) { + const trackedSender = useDelta + ? session.sendTablesDeltaWithPublication + : session.sendTablesWithPublication; + if (trackedSender) { + const sending = trackedSender.call(session, wireTables, { + gorilla: encode?.gorilla, + deferCommit, + }); + response = sending.acknowledgement; + // Observe ACK rejection while the local-publication boundary is being + // awaited; it is consumed normally below after ownership transfers. + void response.catch(() => undefined); + publication = sending.publication.then(() => { + publishedSequence = sending.sequence; + }); } else { - const publisher = useDelta - ? session.publishTablesDelta - : session.publishTables; - if (!publisher) { - throw new Error( - "this QWP ingress session does not support publication-only flushes", - ); - } - publication = publisher - .call(session, wireTables, { - gorilla: encode?.gorilla, - deferCommit, - }) - .then(() => { - publishedSequence = advancedSequence( - beforeSequence, - sessionPublishedSequence(session), - ); - }); + response = useDelta + ? session.sendTablesDelta!(wireTables, { + gorilla: encode?.gorilla, + deferCommit, + }) + : session.sendTables(wireTables, { + gorilla: encode?.gorilla, + deferCommit, + }); } - } catch (error) { - this.releaseUnsendableRows(error, snapshots); - throw error; + } else { + const publisher = useDelta + ? session.publishTablesDelta + : session.publishTables; + if (!publisher) { + throw new Error( + "this QWP ingress session does not support publication-only flushes", + ); + } + publication = publisher + .call(session, wireTables, { + gorilla: encode?.gorilla, + deferCommit, + }) + .then(() => { + publishedSequence = advancedSequence( + beforeSequence, + sessionPublishedSequence(session), + ); + }); } publishedSequence = advancedSequence( beforeSequence, @@ -2189,12 +2187,7 @@ export class QwpSender { // the transport. For Node store-and-forward this is the durable journal // boundary, independently of whether this flush also waits for an ACK. if (publication) { - try { - await publication; - } catch (error) { - this.releaseUnsendableRows(error, snapshots); - throw error; - } + await publication; } const sentRows = this.releaseStagedRows(snapshots); this.totalRowsPublished += sentRows; diff --git a/test/qwp/session.test.ts b/test/qwp/session.test.ts index f944dd3..7b73e74 100644 --- a/test/qwp/session.test.ts +++ b/test/qwp/session.test.ts @@ -787,7 +787,7 @@ describe("QWP WebSocket adapters", () => { await sender.close(); }); - it("stays usable after a row that cannot fit the negotiated batch cap", async () => { + it("retains an over-cap batch at flush and discards it on close", async () => { const socket = new FakeWebSocket(); const cap = 200; const sender = createQwpBrowserSender( @@ -809,18 +809,20 @@ describe("QWP WebSocket adapters", () => { // The splitter bisects a batch down to single rows; one row above the cap // is unsplittable and always re-encodes to the same oversized frame. await sender.table("events").stringColumn("v", "x".repeat(500)).atNow(); + + // A cap rejection retains the batch and invites a retry, matching the Java + // client, whose split throw "RETAINS the batch by design". + await expect(sender.flush()).rejects.toBeInstanceOf(QwpBatchTooLargeError); + expect(sender.metrics.pendingRows).toBe(1); await expect(sender.flush()).rejects.toBeInstanceOf(QwpBatchTooLargeError); + expect(sender.metrics.pendingRows).toBe(1); - // Retaining those rows would wedge the sender: the same error on every - // later flush, pendingRows growing without bound, and close() discarding - // everything staged after it. + // close() is the way out: it discards the batch the cap will never accept, + // surfaces the error, and still completes shutdown. Java does the same via + // resetTableBuffersAfterFlush(). + await expect(sender.close()).rejects.toBeInstanceOf(QwpBatchTooLargeError); expect(sender.metrics.pendingRows).toBe(0); - await sender.table("events").stringColumn("v", "ok").atNow(); - await expect(sender.flush()).resolves.toBe(true); - expect(socket.sent).toHaveLength(1); - expect(socket.sent[0].byteLength).toBeLessThanOrEqual(cap); - - await sender.close(); + expect(socket.sent).toHaveLength(0); }); it("pipelines transactional browser auto-flush until an explicit commit ACK", async () => { From f05f9aeb35b7fc9d5b409d8c92b28885a6d79c08 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 20 Aug 2026 23:06:10 +0100 Subject: [PATCH 105/265] docs: describe null and undefined column values The nullish-omission change altered behaviour on the existing HTTP and TCP senders -- most nullish values used to throw, and protocol v2 encoded arrayColumn(name, null) as an explicit NULL array marker -- but README.md did not mention null or undefined anywhere, and the repository has no changelog, so the only record was the JSDoc on each method. Document the semantics next to the basic usage example: a nullish value omits the column, which QuestDB records as NULL. That is the model the clients share, stated in the Java Sender as "to mark the value NULL, omit the column from the row"; the Node client just performs the omission for the caller. The section also covers the two consequences that surprise people -- an omitted column is not created on a table that lacks it, and an all-nullish row is rejected on ILP, which cannot encode a row with no fields, but accepted on QWP, which is columnar -- and calls out the behaviour change for anyone who relied on the throw. The example and the wire output shown beside it were executed, not written from memory. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/README.md b/README.md index 74282d6..d9ed030 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,45 @@ async function run() { run().then(console.log).catch(console.error); ``` +### Null and undefined values + +Passing `null` or `undefined` as a column or symbol value omits that column from +the row, and QuestDB records the omission as NULL. This is the model the QuestDB +clients share — the Java client puts it as "to mark the value NULL, omit the +column from the row" — with the Node client doing the omission for you, so a +record with optional fields needs no branching: + +```typescript +const trade: { side?: string; amount?: number } = { amount: 0.011 }; + +await sender + .table("trades") + .symbol("symbol", "BTC-USD") + .symbol("side", trade.side) // undefined -> column omitted -> NULL + .floatColumn("price", 39269.98) + .floatColumn("amount", trade.amount) + .at(Date.now(), "ms"); +// wire: trades,symbol=BTC-USD price=39269.98,amount=0.011 +``` + +This applies to every column method on both the ILP (`http`/`https`/`tcp`/`tcps`) +and QWP (`ws`/`wss`/`udp`) senders, and to the compiled QWP writers. + +Two consequences are worth knowing: + +- An omitted column is not created on a table that does not already have it. The + omission carries no type, so schema-on-write has nothing to infer from. +- A row in which _every_ value is nullish behaves differently per protocol. ILP + has no way to encode a row with no fields, so `at()`/`atNow()` rejects it with + "The row must have a symbol or column set before it is closed". QWP is + columnar and can express it, so the row is sent with no columns — carrying + only its designated timestamp. + +**Changed in this release.** Earlier versions threw a type error for most nullish +values, and protocol v2 encoded `arrayColumn(name, null)` as an explicit NULL +array marker. Both now omit the column instead. If your code relied on the throw +as a data-quality guard, validate before calling the sender. + ### QWP ingress from Node.js or a browser See the [complete QWP guide](./QWP.md) for ingress and egress APIs, the combined From 2e8e514012d87d3e6d98142eb9d4775938e7dd23 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 20 Aug 2026 23:17:34 +0100 Subject: [PATCH 106/265] docs(qwp): add a configuration-string key reference QWP.md documented the programmatic camelCase options well but named only 16 of the 67 keys a ws/wss connect string accepts. tls_verify, sf_durability, request_durable_ack, drain_orphans, max_frame_rejections, the reconnect_* and failover_* families and the reserved on_*_error keys appeared nowhere in the repository, so the portable spelling -- the one shared with the other QuestDB clients -- was undiscoverable. Add a reference grouped the way io.questdb.client.impl.ConfigSchema groups the same vocabulary: connection, ingress, reconnect and failover, store-and-forward, egress, pool, reserved. Values and defaults were read off the parser rather than written from memory, which corrected four of them: sf_durability defaults to memory rather than append, max_name_len to 127, sender_id to "default", and drain_orphans is opt-in -- qwp/node.ts requires drainOrphans === true, so an absent key leaves orphan draining off. Two tests keep the reference honest: every key the parser accepts must be named in QWP.md, and every key the reference lists must be one the parser accepts. Both were confirmed to fail on drift. Co-Authored-By: Claude Opus 5 (1M context) --- QWP.md | 118 ++++++++++++++++++++++++++++++++++ src/qwp-node/client-config.ts | 8 ++- test/qwp/config-docs.test.ts | 48 ++++++++++++++ 3 files changed, 172 insertions(+), 2 deletions(-) create mode 100644 test/qwp/config-docs.test.ts diff --git a/QWP.md b/QWP.md index d950848..8ad8c4f 100644 --- a/QWP.md +++ b/QWP.md @@ -65,6 +65,124 @@ ordered failover endpoints, and ingress, egress, pool, and reserved policy keys are validated from one schema. The standalone sender applies ingress-owned keys; keys owned only by egress or the pooled facade are accepted as intentional no-ops. +## Configuration-string keys + +Every `ws::`/`wss::` connect string is parsed by one schema, shared with the +other QuestDB clients, whichever entry point builds the client — +`Sender.fromConfig()`, `SenderOptions.fromConfig()`, `connectQwpNodeClient()`, +or `connectQwpNodeQuery()`. An unrecognised key is rejected with +`unknown configuration key: `; a legacy ILP key adds a hint pointing at +where it applies instead. + +Keys are grouped by the component that applies them. A client applies the keys +its own side owns and accepts the rest as intentional no-ops, so one connect +string can configure a sender, a query client, or the pooled facade. Every key +also has a programmatic equivalent on the corresponding options object; the +connect string is the portable spelling. + +### Connection + +| Key | Value | Default | Meaning | +| -------------------- | ------------------ | --------- | ------------------------------------------------------------------ | +| `addr` | `host[:port]` | port 9000 | Endpoint. Repeat the key, or comma-separate, for ordered failover. | +| `username`, `user` | string | — | HTTP Basic user for the WebSocket upgrade. | +| `password`, `pass` | string | — | HTTP Basic password. | +| `token` | string | — | Bearer token; alternative to Basic. | +| `tls_verify` | `on`, `unsafe_off` | on | Certificate verification. `unsafe_off` disables it. | +| `tls_roots` | path | — | PEM or PKCS#12 trust store for a private CA. | +| `tls_roots_password` | string | — | Password for `tls_roots`. | +| `auth_timeout_ms` | integer ms | — | Deadline for the authentication exchange. | +| `connect_timeout` | integer ms | — | Deadline for establishing one connection. | + +### Ingress + +| Key | Value | Default | Meaning | +| ----------------------------------------------- | ---------------- | --------- | ------------------------------------------------------------------------ | +| `auto_flush` | `on`, `off` | on | Master switch for all auto-flush triggers. | +| `auto_flush_rows` | integer | — | Flush after this many staged rows. | +| `auto_flush_bytes` | integer or `off` | off | Flush once staged rows reach this estimated size. | +| `auto_flush_interval` | integer ms | — | Flush when this long has passed. Checked as rows are added. | +| `close_flush_timeout_millis` | integer ms | `5000` | Bound on `close()`'s ACK drain. `0` or negative is a fast close. | +| `transaction` | `on`, `off` | off | Group each flush into a per-table transaction. | +| `request_durable_ack` | `on`, `off` | off | Require durable ACKs; fails if the server cannot confirm them. | +| `durable_ack_keepalive_interval_millis` | integer ms | — | Poll interval for durable-ACK progress. | +| `max_name_len` | integer | `127` | Maximum table and column name length. | +| `sender_id` | string | `default` | Identifies this producer to the server and in the journal. | +| `max_frame_rejections` | integer | — | Rejections of one frame before the poison-frame detector escalates. | +| `poison_min_escalation_window_millis` | integer ms | — | Minimum dwell before a poison frame may escalate. | +| `catch_up_cap_gap_min_escalation_window_millis` | integer ms | `300000` | Minimum dwell before an orphan symbol-dictionary cap gap is quarantined. | +| `connection_listener_inbox_capacity` | integer | — | Bound on the connection-event inbox before events are dropped. | +| `error_inbox_capacity` | integer | — | Bound on the `onSenderError` inbox before events are dropped. | + +### Reconnect and failover + +| Key | Value | Default | Meaning | +| ---------------------------------- | --------------------------- | ------- | -------------------------------------------------------------------------------------- | +| `reconnect_initial_backoff_millis` | integer ms | — | First reconnect delay; grows exponentially with jitter. | +| `reconnect_max_backoff_millis` | integer ms | — | Ceiling for one reconnect delay. | +| `reconnect_max_duration_millis` | integer ms | — | Budget for a reconnect episode. This is the QWP replacement for ILP's `retry_timeout`. | +| `failover` | `on`, `off` | — | Enables endpoint failover for egress. | +| `failover_max_attempts` | integer ≥ 1 | — | Failover attempts before giving up. | +| `failover_backoff_initial_ms` | integer ms | — | First failover delay. | +| `failover_backoff_max_ms` | integer ms | — | Ceiling for one failover delay. | +| `failover_max_duration_ms` | integer ms | — | Budget for a failover episode. | +| `target` | `any`, `primary`, `replica` | — | Server role this client will accept. | +| `zone` | string | — | Preferred topology zone when ranking endpoints. | + +### Store-and-forward (Node only) + +Setting `sf_dir` turns on the persistent journal; the rest tune it. A default +shown as a dash is applied downstream of the connect string, by the sender or +session that consumes it. + +| Key | Value | Default | Meaning | +| --------------------------- | ------------------------------ | ------------- | ----------------------------------------------------------------- | +| `sf_dir` | path | — | Journal directory. Enables store-and-forward. | +| `sf_durability` | `memory`, `periodic`, `append` | `memory` | Local durability barrier after each vectored append. | +| `sf_max_total_bytes` | integer bytes | `10737418240` | Journal ceiling. Reaching it is the one error a producer sees. | +| `sf_max_segment_bytes` | integer bytes | `4194304` | Size of one segment file. | +| `sf_sync_interval_millis` | integer ms | — | Checkpoint interval when `sf_durability=periodic`. | +| `sf_append_deadline_millis` | integer ms | `30000` | How long an append waits for space before failing. | +| `initial_connect_retry` | `off`, `sync`, `async` | `off` | Startup policy when the server is unreachable. Requires `sf_dir`. | +| `drain_orphans` | `on`, `off` | off | Adopt and drain journals left by crashed producers. | +| `max_background_drainers` | integer | — | Concurrent orphan drainers. | + +### Egress + +| Key | Value | Default | Meaning | +| ------------------- | --------------------- | ---------- | -------------------------------------------------- | +| `max_batch_rows` | integer, 1..1048576 | — | Rows the server puts in one result batch. | +| `initial_credit` | integer ≥ 0 | — | Starting flow-control credit for a query. | +| `buffer_pool_size` | integer ≥ 1 | — | Reusable result buffers held per session. | +| `compression` | `raw`, `zstd`, `auto` | negotiated | Result compression to negotiate. | +| `compression_level` | integer, 1..22 | — | zstd level requested from the server. | +| `client_id` | string | — | Identifies this client in server-side diagnostics. | + +### Pool + +Applied by the pooled facade; a standalone sender or query client ignores them. + +| Key | Value | Default | Meaning | +| ------------------------- | ----------- | ------- | --------------------------------------------- | +| `sender_pool_min` | integer | — | Senders kept warm. | +| `sender_pool_max` | integer | — | Sender ceiling. | +| `query_pool_min` | integer | — | Query sessions kept warm. | +| `query_pool_max` | integer | — | Query-session ceiling. | +| `acquire_timeout_ms` | integer ms | — | How long `acquire()` waits for a free entry. | +| `query_close_timeout_ms` | integer ms | — | Bound on closing a borrowed query session. | +| `idle_timeout_ms` | integer ms | — | Idle time before a pooled entry is reaped. | +| `max_lifetime_ms` | integer ms | — | Absolute lifetime of a pooled entry. | +| `housekeeper_interval_ms` | integer ms | — | How often the pool reaps aged entries. | +| `lazy_connect` | `on`, `off` | off | Start without blocking on a first connection. | + +### Reserved + +`on_write_error`, `on_server_error`, `on_internal_error`, `on_parse_error`, +`on_schema_error` and `on_security_error` are part of the shared vocabulary and +are accepted, but this client does not yet apply them: server-error policy comes +from `qwpDefaultSenderErrorPolicy` and the `onSenderError` stream. They are +listed so a connect string written for another QuestDB client is not rejected. + ### Node.js fire-and-forget UDP `udp::` selects Node-only QWP v1 over IPv4 UDP while retaining the fluent row API: diff --git a/src/qwp-node/client-config.ts b/src/qwp-node/client-config.ts index a0a35dd..87f3271 100644 --- a/src/qwp-node/client-config.ts +++ b/src/qwp-node/client-config.ts @@ -42,7 +42,11 @@ const RELOCATED_HINTS = new Map([ ["multicast_ttl", "(applies to legacy http/tcp/udp transports only)"], ]); -const SUPPORTED_KEYS = new Set([ +/** + * @internal Every key a ws/wss connect string may carry, shared with the other + * QuestDB clients. Exported so the QWP.md reference can be tested against it. + */ +export const QWP_SUPPORTED_CONFIG_KEYS: ReadonlySet = new Set([ "addr", "username", "password", @@ -366,7 +370,7 @@ function parseConfigurationString(configurationString: string): ParsedConfig { const rawKey = setting.slice(0, equals); const rawValue = setting.slice(equals + 1); validateConfigText(rawKey, rawValue); - if (!SUPPORTED_KEYS.has(rawKey)) { + if (!QWP_SUPPORTED_CONFIG_KEYS.has(rawKey)) { const hint = RELOCATED_HINTS.get(rawKey); throw new Error( `unknown configuration key: ${rawKey}${hint ? ` ${hint}` : ""}`, diff --git a/test/qwp/config-docs.test.ts b/test/qwp/config-docs.test.ts new file mode 100644 index 0000000..9bddfc1 --- /dev/null +++ b/test/qwp/config-docs.test.ts @@ -0,0 +1,48 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { QWP_SUPPORTED_CONFIG_KEYS } from "../../src/qwp-node/client-config"; + +const ROOT = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../..", +); + +describe("QWP configuration-string reference", () => { + it("documents every key the parser accepts", async () => { + // A key the parser takes but QWP.md never names is undiscoverable: the + // connect string is the portable spelling shared with the other QuestDB + // clients, so the reference has to track the schema. + const doc = await readFile(path.join(ROOT, "QWP.md"), "utf8"); + const undocumented = [...QWP_SUPPORTED_CONFIG_KEYS] + .filter((key) => !doc.includes(`\`${key}\``)) + .sort(); + + expect(undocumented).toEqual([]); + }); + + it("does not document keys the parser rejects", async () => { + // The reference tables are the only place these back-ticked snake_case + // names appear, so anything listed there must really be accepted. + const doc = await readFile(path.join(ROOT, "QWP.md"), "utf8"); + const start = doc.indexOf("## Configuration-string keys"); + const section = doc.slice( + start, + doc.indexOf("\n### Node.js fire-and-forget UDP", start), + ); + const listed = new Set( + [ + ...section.matchAll(/^\| `([a-z0-9_]+)`(?:, `([a-z0-9_]+)`)?/gm), + ].flatMap((match) => [match[1], match[2]].filter(Boolean) as string[]), + ); + + const unknown = [...listed] + .filter((key) => !QWP_SUPPORTED_CONFIG_KEYS.has(key)) + .sort(); + + expect(unknown).toEqual([]); + // Guard against the extraction silently matching nothing. + expect(listed.size).toBeGreaterThan(50); + }); +}); From de9fac440275f1f19652ee34ac6c728393711b09 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 20 Aug 2026 23:27:13 +0100 Subject: [PATCH 107/265] ci: enforce the browser, benchmark, and export-map gates tsconfig.qwp-browser.json is where the browser contract lives -- strict, no @types/node, DOM lib, over src/qwp/** minus node.ts -- and `pnpm typecheck` does not cover it, so nothing in CI stopped a Node built-in or a strict-null violation reaching the browser entry point. This PR adds 81 lines to build.yml without invoking the script that checks it. Run typecheck:qwp-browser in both workflows; adding a `node:fs` import to src/qwp/core/frame.ts now fails with "Cannot find module 'node:fs'" instead of shipping. typecheck:bench and lint:bench were absent for the same reason and are listed as validation the PR performed, so they run too. The publish artifact check tested only dist/cjs/index.js and dist/es/index.mjs. The package now declares four export subpaths, and a publish that omitted ./qwp, ./qwp/browser or ./qwp/node would resolve to nothing for consumers while still passing. Drive the check off the exports map instead, covering the `types` targets as well, so it cannot drift as subpaths are added. Removing one emitted bundle makes it exit 1 naming the subpath. Every added step was run locally and passes; both workflows were parsed to confirm the embedded script survives YAML quoting. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/build.yml | 13 +++++++++++++ .github/workflows/publish.yml | 28 ++++++++++++++++++++++++++-- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b2476da..22810ba 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -37,6 +37,19 @@ jobs: - name: Type-checking run: pnpm typecheck + # tsconfig.qwp-browser.json is where the browser contract lives: strict, + # no @types/node, DOM lib, over src/qwp/** minus node.ts. `pnpm typecheck` + # does not cover it, so without this step nothing stops a Node built-in or + # a strict-null violation reaching the browser entry point. + - name: Type-checking (browser) + run: pnpm typecheck:qwp-browser + + - name: Type-checking (benchmarks) + run: pnpm typecheck:bench + + - name: Linting (benchmarks) + run: pnpm lint:bench + - name: Tests run: pnpm test diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 2e30f98..0d4d718 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -29,16 +29,40 @@ jobs: - name: Type-checking run: pnpm typecheck + # tsconfig.qwp-browser.json is where the browser contract lives: strict, + # no @types/node, DOM lib, over src/qwp/** minus node.ts. `pnpm typecheck` + # does not cover it, so without this step nothing stops a Node built-in or + # a strict-null violation reaching the browser entry point. + - name: Type-checking (browser) + run: pnpm typecheck:qwp-browser + - name: Tests run: pnpm test - name: Build run: pnpm build + # Every subpath in `exports`, not just the root: a publish that omits + # ./qwp, ./qwp/browser or ./qwp/node resolves to nothing for consumers. - name: Check for build artifacts run: | - [ -f dist/cjs/index.js ] || (echo "CJS build missing" && exit 1) - [ -f dist/es/index.mjs ] || (echo "ESM build missing" && exit 1) + node -e ' + const fs = require("node:fs"); + const { exports: map } = require("./package.json"); + const missing = []; + for (const [subpath, conditions] of Object.entries(map)) { + for (const target of Object.values(conditions)) { + for (const file of Object.values(target)) { + if (!fs.existsSync(file)) missing.push(subpath + " -> " + file); + } + } + } + if (missing.length > 0) { + console.error("missing build artifacts:\n " + missing.join("\n ")); + process.exit(1); + } + console.log("all " + Object.keys(map).length + " export subpaths present"); + ' - name: Publish uses: JS-DevTools/npm-publish@v3 From 7997f0b2b2e6706d17da7d96311a1acd7996272a Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 20 Aug 2026 23:41:43 +0100 Subject: [PATCH 108/265] test(qwp): close the mutation-proven coverage gaps Four guards could be deleted, or their boundary flipped, with the whole suite green. isRetriableIngressStatus had coverage only for the retriable direction, so `return true` survived and the terminal branch was untested. That branch is a cross-client contract: the Java client's policy maps SCHEMA_MISMATCH, PARSE_ERROR and SECURITY_ERROR to TERMINAL ("deterministic: same bytes, same mismatch") and everything else, including status bytes it does not recognise, to a retriable category. Node's set already matched; now both directions are pinned, including an unrecognised byte failing open. floatColumn is the one scalar setter overridden per protocol version, and the issue-28 guard was tested only on v1 -- not on v2, which HTTP negotiates by default. Cover all three versions. The store-and-forward metadata CRC32C had no test at all. Tearing the winning ack-watermark slot and leaving its checksum stale now proves the checksum rejects it and recovery fails closed; without the check the torn watermark wins on generation and load() resolves with an empty replay set, silently dropping every unacknowledged frame on the crash-recovery path the feature exists for. The six protocol caps are pinned at their boundaries. A million rows or symbols would dominate the suite, so those two use a stub whose count the guard reads rather than materialising the data. Every mutation listed above was re-run and now fails the suite. Co-Authored-By: Claude Opus 5 (1M context) --- test/qwp/core.test.ts | 80 +++++++++++++++++++++++++++++ test/qwp/reconnect.test.ts | 101 +++++++++++++++++++++++++++++++++++++ test/sender.buffer.test.ts | 25 +++++++++ 3 files changed, 206 insertions(+) diff --git a/test/qwp/core.test.ts b/test/qwp/core.test.ts index 6998b4e..9f6b23b 100644 --- a/test/qwp/core.test.ts +++ b/test/qwp/core.test.ts @@ -18,6 +18,11 @@ import { encodeQwpQueryRequest, encodeQwpVarint, QWP_COLUMN_TYPE, + QWP_MAX_COLUMNS_PER_TABLE, + QWP_MAX_ERROR_MESSAGE_LENGTH, + QWP_MAX_ROWS_PER_TABLE, + QWP_MAX_SYMBOL_DICTIONARY_SIZE, + decodeQwpIngressResponse, QWP_COMPRESSION_CODEC, QWP_DURABLE_ACK_WEBSOCKET_PROTOCOL, QWP_EGRESS_CAPABILITY, @@ -501,3 +506,78 @@ describe("QWP egress codec", () => { ).toThrow(/truncated/i); }); }); + +describe("protocol caps", () => { + // Every one of these guards could be deleted, or its boundary flipped, with + // the whole suite green. They are the client's own defence against building + // a frame the server will reject, so each needs its boundary pinned. + + it("accepts the last column and rejects the next", () => { + const table = new QwpTableBuffer("t"); + for (let index = 0; index < QWP_MAX_COLUMNS_PER_TABLE; index++) { + expect( + table.getOrCreateColumn(`c${index}`, QWP_COLUMN_TYPE.LONG), + ).not.toBeNull(); + } + expect(() => + table.getOrCreateColumn("one_too_many", QWP_COLUMN_TYPE.LONG), + ).toThrow(`column count exceeds maximum ${QWP_MAX_COLUMNS_PER_TABLE}`); + }); + + it("accepts the last dictionary entry and rejects the next", () => { + for (const add of ["getOrAdd", "addRecovered"] as const) { + const dictionary = new QwpSymbolDictionary(); + // Fill the backing array without materialising a million strings; the + // guard reads its length. + (dictionary as unknown as { values: string[] }).values.length = + QWP_MAX_SYMBOL_DICTIONARY_SIZE - 1; + expect(() => dictionary[add]("last")).not.toThrow(); + expect(() => dictionary[add]("one too many")).toThrow( + `symbol dictionary exceeds maximum size ${QWP_MAX_SYMBOL_DICTIONARY_SIZE}`, + ); + } + }); + + it("rejects a frame with more than 65535 tables", () => { + const table = new QwpTableBuffer("t"); + table.getOrCreateColumn("c", QWP_COLUMN_TYPE.LONG); + // The guard runs before any encoding, so the same buffer can stand in for + // every entry. + expect(() => encodeQwpIngressFrame(new Array(65_536).fill(table))).toThrow( + "more than 65535 tables", + ); + expect(() => + encodeQwpIngressFrame(new Array(65_535).fill(table)), + ).not.toThrow("more than 65535 tables"); + }); + + it("rejects a table above the row cap", () => { + // A million real rows would dominate the suite; the guard reads rowCount, + // and a column-less table clears the consistency check that precedes it. + const oversized = { + name: "t", + rowCount: QWP_MAX_ROWS_PER_TABLE + 1, + columns: [], + } as unknown as QwpTableBuffer; + expect(() => encodeQwpIngressFrame([oversized])).toThrow( + `maximum is ${QWP_MAX_ROWS_PER_TABLE}`, + ); + }); + + it("rejects a NACK whose declared message length is above the cap", () => { + const nack = (length: number) => + new QwpByteWriter() + .writeUint8(QWP_STATUS.WRITE_ERROR) + .writeBigUint64(0n) + .writeUint16(length) + .writeBytes(new Uint8Array(length)) + .toUint8Array(); + + expect(() => + decodeQwpIngressResponse(nack(QWP_MAX_ERROR_MESSAGE_LENGTH + 1)), + ).toThrow(`exceeds ${QWP_MAX_ERROR_MESSAGE_LENGTH} bytes`); + expect(() => + decodeQwpIngressResponse(nack(QWP_MAX_ERROR_MESSAGE_LENGTH)), + ).not.toThrow(); + }); +}); diff --git a/test/qwp/reconnect.test.ts b/test/qwp/reconnect.test.ts index 7f44910..3bf3ff6 100644 --- a/test/qwp/reconnect.test.ts +++ b/test/qwp/reconnect.test.ts @@ -2101,6 +2101,65 @@ describe("QWP ingress reconnect and replay", () => { expect(replayStore.closeCount).toBe(1); }); + // The terminal set is a cross-client contract: the Java client's policy maps + // SCHEMA_MISMATCH, PARSE_ERROR and SECURITY_ERROR to TERMINAL ("deterministic: + // same bytes, same mismatch") and everything else -- including status bytes it + // does not recognise -- to a retriable category, failing open on a newer + // server. Only the retriable direction had coverage, so the whole terminal + // branch could be deleted with a green suite. + it.each([ + ["SCHEMA_MISMATCH", QWP_STATUS.SCHEMA_MISMATCH], + ["PARSE_ERROR", QWP_STATUS.PARSE_ERROR], + ["SECURITY_ERROR", QWP_STATUS.SECURITY_ERROR], + ])( + "fails the connection on a %s NACK without replaying", + async (_name, status) => { + const first = new FakeConnection("primary"); + const second = new FakeConnection("secondary"); + const connections = [first, second]; + const session = await QwpIngressSession.connect( + async () => connections.shift() ?? new FakeConnection("extra"), + { reconnect: { maxAttempts: 1, initialBackoffMs: 0, maxBackoffMs: 0 } }, + ); + + const pending = session.sendFrame(Uint8Array.of(9)); + await vi.waitFor(() => expect(first.sent).toHaveLength(1)); + first.receive(ingressResponse(status, 0n)); + + await expect(pending).rejects.toMatchObject({ + name: "QwpIngressNackError", + response: { status }, + }); + // A deterministic rejection must not be replayed: the same bytes would be + // rejected again on every node in turn. + expect(second.sent).toEqual([]); + await session.close().catch(() => undefined); + }, + ); + + it.each([ + ["INTERNAL_ERROR", QWP_STATUS.INTERNAL_ERROR], + ["DICTIONARY_GAP", QWP_STATUS.DICTIONARY_GAP], + ["an unrecognised status", 0x7f], + ])("replays after a %s NACK", async (_name, status) => { + const first = new FakeConnection("primary"); + const second = new FakeConnection("secondary"); + const connections = [first, second]; + const session = await QwpIngressSession.connect( + async () => connections.shift() ?? new FakeConnection("extra"), + { reconnect: { maxAttempts: 1, initialBackoffMs: 0, maxBackoffMs: 0 } }, + ); + + const pending = session.sendFrame(Uint8Array.of(9)); + await vi.waitFor(() => expect(first.sent).toHaveLength(1)); + first.receive(ingressResponse(status, 0n)); + + await vi.waitFor(() => expect(second.sent).toEqual([Uint8Array.of(9)])); + second.receive(ingressResponse(QWP_STATUS.OK, 0n)); + await expect(pending).resolves.toMatchObject({ status: QWP_STATUS.OK }); + await session.close(); + }); + it("reconnects and replays a transient ingress NACK without advancing", async () => { const first = new FakeConnection("primary"); const second = new FakeConnection("secondary"); @@ -3452,6 +3511,48 @@ describe("QWP Node file replay store", () => { await recovered.close(); }); + it("ignores an ack-watermark slot whose checksum does not match", async () => { + const directory = await trackedDirectory(); + const store = new QwpNodeFileReplayStore({ directory, maxSegmentBytes: 1 }); + await store.load(); + for (let sequence = 0n; sequence < 4n; sequence++) { + await store.append({ + frameSequence: sequence, + payload: Uint8Array.of(Number(sequence)), + }); + } + // Two acknowledgements fill both alternating slots, the second carrying the + // higher generation and the live watermark. + await store.acknowledgeThrough(0n); + await store.acknowledgeThrough(1n); + await store.close(); + + // Tear the winning slot the way a crash between write and fsync would: + // move the watermark past every retained frame and leave its CRC32C stale. + // Without the checksum this record still wins on generation, and its + // watermark retires frames the server never acknowledged -- silent data + // loss on exactly the crash-recovery path store-and-forward exists for. + const ackPath = join(directory, ".ack-watermark"); + const bytes = await readFile(ackPath); + const slotSize = 4 * 1024; + const winner = + bytes.readBigInt64LE(8) >= bytes.readBigInt64LE(slotSize + 8) + ? 0 + : slotSize; + bytes.writeBigInt64LE(9n, winner + 16); + await writeFile(ackPath, bytes); + + // The checksum rejects it, so recovery falls back to the intact slot, whose + // watermark is older than the segments on disk. That mismatch is caught and + // the journal is quarantined -- fail closed. Accepting the torn record + // instead would have resolved, silently dropping frames 2 and 3. + const recovered = new QwpNodeFileReplayStore({ directory }); + await expect(recovered.load()).rejects.toBeInstanceOf( + QwpReplayStoreCorruptionError, + ); + await recovered.close(); + }); + it("recovers from a transient background maintenance failure", async () => { const directory = await trackedDirectory(); const store = new QwpNodeFileReplayStore({ directory, maxSegmentBytes: 1 }); diff --git a/test/sender.buffer.test.ts b/test/sender.buffer.test.ts index ac0e820..fddb9af 100644 --- a/test/sender.buffer.test.ts +++ b/test/sender.buffer.test.ts @@ -509,6 +509,31 @@ describe("Sender message builder test suite (anything not covered in client inte await sender.close(); }); + it("omits float columns with null or undefined value on every version", async function () { + // floatColumn is the one scalar setter overridden per protocol version + // (bufferv1 and bufferv2, the latter inherited by v3). Every other setter + // lives once in SenderBufferBase, so the v1 test above covers them all -- + // but the v2/v3 override had no coverage, and v2 is what HTTP negotiates by + // default. + for (const version of ["1", "2", "3"] as const) { + const sender = new Sender({ + protocol: "tcp", + protocol_version: version, + host: "host", + auto_flush: false, + init_buf_size: 1024, + }); + await sender + .table("tableName") + .floatColumn("skipped", null) + .floatColumn("alsoSkipped", undefined) + .intColumn("kept", 1) + .atNow(); + expect(bufferContent(sender)).toBe("tableName kept=1i\n"); + await sender.close(); + } + }); + it("omits decimal columns with null or undefined value", async function () { const sender = new Sender({ protocol: "tcp", From ca78e94ce3fe6c714d6ac7cde90f83489490289a Mon Sep 17 00:00:00 2001 From: glasstiger Date: Fri, 21 Aug 2026 08:21:01 +0100 Subject: [PATCH 109/265] fix(qwp): store a UUID passed as bytes in canonical order uuidBytes() returned a 16-byte Uint8Array verbatim while the text and {low, high} forms encode the two limbs little-endian, low limb first. The byte form is therefore the exact reverse of the other two, so the same UUID lands as two different values depending on which form the caller uses. The little-endian layout is the correct one: binds.ts and the egress result decoder both read low at offset 0 and high at offset 8, so a UUID written as bytes never round-trips. It is silent -- 16 bytes is a valid UUID whichever way round it is, so QuestDB accepts it and only a query by canonical text reveals that the stored value is wrong. Read the incoming bytes as canonical RFC 4122 big-endian -- what uuid.parse() and java.util.UUID produce -- and re-emit them through uuidLimbBytes(), the same path the other two forms take. Documented on QwpUuidInput and in QWP.md, since neither said which byte order the byte form meant. Co-Authored-By: Claude Opus 5 (1M context) --- QWP.md | 2 +- src/qwp/sender.ts | 15 ++++++++++++++- src/qwp/writer.ts | 6 +++++- test/qwp/sender.test.ts | 38 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 58 insertions(+), 3 deletions(-) diff --git a/QWP.md b/QWP.md index 8ad8c4f..af46982 100644 --- a/QWP.md +++ b/QWP.md @@ -564,7 +564,7 @@ The schema vocabulary covers every column type the fluent row API can write: | `designatedTimestamp(unit)` | designated TIMESTAMP | as above, required in every row | | `date()` | DATE | `number` or `bigint` milliseconds since the epoch | | `binary()` | BINARY | `Uint8Array`, copied on append | -| `uuid()` | UUID | canonical UUID text, 16 bytes, or `{ low, high }` | +| `uuid()` | UUID | canonical UUID text, 16 canonical big-endian bytes, or `{ low, high }` | | `long256()` | LONG256 | unsigned 256-bit `bigint`, `0x` hex text, four little-endian words, or `{ words }` | | `ipv4()` | IPV4 | dotted-quad text or the packed address; `0.0.0.0` is the NULL sentinel | | `geohash(precisionBits)` | GEOHASH | raw bits, base-32 text of `precisionBits / 5` characters, or `{ bits, precisionBits }` | diff --git a/src/qwp/sender.ts b/src/qwp/sender.ts index 571fad9..e706e55 100644 --- a/src/qwp/sender.ts +++ b/src/qwp/sender.ts @@ -392,7 +392,20 @@ function uuidBytes(value: string | Uint8Array): Uint8Array { if (value.length !== 16) { throw new RangeError("UUID byte value must contain exactly 16 bytes"); } - return new Uint8Array(value); + // The 16 bytes are canonical (RFC 4122) big-endian order, the form + // uuid.parse() and java.util.UUID produce: bytes 0-7 are the high limb, + // bytes 8-15 the low limb. QWP carries the two limbs little-endian, low + // first, so read each limb big-endian and re-emit it through the same + // path the text and {low, high} forms use. + const source = new DataView( + value.buffer, + value.byteOffset, + value.byteLength, + ); + return uuidLimbBytes( + source.getBigUint64(8, false), + source.getBigUint64(0, false), + ); } const match = /^([0-9a-f]{8})-([0-9a-f]{4})-([0-9a-f]{4})-([0-9a-f]{4})-([0-9a-f]{12})$/i.exec( diff --git a/src/qwp/writer.ts b/src/qwp/writer.ts index 24f25f8..f6396de 100644 --- a/src/qwp/writer.ts +++ b/src/qwp/writer.ts @@ -99,7 +99,11 @@ type TimestampInput = Unit extends "ns" ? bigint : number | bigint; -/** UUID input: canonical text, 16 bytes, or the egress limb pair. */ +/** + * UUID input: canonical text, 16 canonical (RFC 4122) big-endian bytes, or the + * egress limb pair. All three forms describe the same UUID; the byte form is + * what `uuid.parse()` and `java.util.UUID` produce, not pre-encoded wire bytes. + */ export type QwpUuidInput = | string | Uint8Array diff --git a/test/qwp/sender.test.ts b/test/qwp/sender.test.ts index eb7aa6e..661f2cb 100644 --- a/test/qwp/sender.test.ts +++ b/test/qwp/sender.test.ts @@ -1158,6 +1158,44 @@ describe("QWP high-level sender", () => { expect(() => encodeQwpIngressFrame([table])).not.toThrow(); }); + it("encodes a UUID identically from text, canonical bytes, and limbs", async () => { + // The 16-byte form is canonical RFC 4122 order -- what uuid.parse() and + // java.util.UUID hand back. Passing those bytes through verbatim would + // store the UUID byte-reversed, silently, because 16 bytes is a valid + // UUID whichever way round it is. + const text = "123e4567-e89b-12d3-a456-426614174000"; + const canonical = Uint8Array.from([ + 0x12, 0x3e, 0x45, 0x67, 0xe8, 0x9b, 0x12, 0xd3, 0xa4, 0x56, 0x42, 0x66, + 0x14, 0x17, 0x40, 0x00, + ]); + const session = new RecordingSession(); + const sender = new QwpSender(async () => session, { autoFlush: false }); + + await sender.table("t").uuidColumn("id", text).atNow(); + await sender.table("t").uuidColumn("id", canonical).atNow(); + const rows = sender.writer("t", { id: uuid() }); + await rows.row({ id: text }); + await rows.row({ id: canonical }); + await rows.row({ + id: { low: 0xa456426614174000n, high: 0x123e4567e89b12d3n }, + }); + await sender.flush(); + + const values = column(session.sends[0].tables[0], "id").values; + expect(values).toHaveLength(5); + for (const encoded of values) { + expect(encoded).toEqual(values[0]); + } + // Little-endian low limb first, matching the egress decoder. + expect(values[0]).toEqual( + Uint8Array.from([ + 0x00, 0x40, 0x17, 0x14, 0x66, 0x42, 0x56, 0xa4, 0xd3, 0x12, 0x9b, 0xe8, + 0x67, 0x45, 0x3e, 0x12, + ]), + ); + await sender.close(); + }); + it("validates fixed precision and scale when compiling the schema", () => { const sender = new QwpSender(async () => new RecordingSession(), { autoFlush: false, From bd65a052f3ca00e68b4a4490722ff0015175faf9 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Fri, 21 Aug 2026 08:22:34 +0100 Subject: [PATCH 110/265] fix(qwp): walk failover endpoints on an opaque upgrade error The failover sweep short-circuited on `!error.tryNextEndpoint`, so an undefined flag stopped it. Browsers cannot see the HTTP response, so openQwpWebSocket classifies every browser upgrade failure as `opaque` with tryNextEndpoint left undefined -- which covers connection refused, connection reset, DNS and TLS failures, and every non-101 status. A browser configured with failoverUrls therefore never tried its secondary on an initial connect, while the byte-identical Node configuration walked the whole list. tryNextEndpoint is a tri-state, and its sibling `retryable` is already read as `!== false` by isRetryableReconnectError. Read this one the same way so only an explicit false short-circuits. The two producers that set it -- a 401/403 upgrade in node.ts and an authentication bootstrap failure in browser.ts -- set it explicitly, so fail-fast on authentication is unchanged. The existing failover coverage could not catch this: it injects a factory throw carrying tryNextEndpoint: true, a shape a real browser WebSocket never produces. The new test drives a bare `error` event instead, and a second test pins the authentication short-circuit so the fix cannot regress the other way. Co-Authored-By: Claude Opus 5 (1M context) --- src/qwp/internal/failover.ts | 11 +++++++- test/qwp/session.test.ts | 52 ++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/src/qwp/internal/failover.ts b/src/qwp/internal/failover.ts index e40dbf9..825f1d1 100644 --- a/src/qwp/internal/failover.ts +++ b/src/qwp/internal/failover.ts @@ -305,7 +305,16 @@ export function createQwpFailoverConnectionFactory( healthTracker.recordFailure(index, error); attempts.push({ endpoint, error }); if (candidate) await candidate.close().catch(() => undefined); - if (error instanceof QwpUpgradeError && !error.tryNextEndpoint) { + // tryNextEndpoint is a tri-state: only an explicit false short-circuits + // the sweep. A browser cannot see the HTTP response, so every refused, + // reset, or non-101 upgrade it reports is `undefined`; treating that as + // "stop" would make failoverUrls unreachable in browsers. This matches + // isRetryableReconnectError(), which reads the sibling `retryable` flag + // of the same tri-state as `!== false`. + if ( + error instanceof QwpUpgradeError && + error.tryNextEndpoint === false + ) { throw error; } } diff --git a/test/qwp/session.test.ts b/test/qwp/session.test.ts index 7b73e74..0d2c7c8 100644 --- a/test/qwp/session.test.ts +++ b/test/qwp/session.test.ts @@ -616,6 +616,58 @@ describe("QWP WebSocket adapters", () => { await sender.close(); }); + it("walks browser failover endpoints when the upgrade error is opaque", async () => { + // A browser never learns the HTTP response, so every refused, reset, or + // non-101 upgrade arrives as a bare `error` event and is classified + // `opaque` with tryNextEndpoint left undefined. The existing failover + // coverage injects a factory throw carrying tryNextEndpoint: true, a shape + // a real browser WebSocket cannot produce, so it cannot observe this. + const attempted: string[] = []; + const session = await connectQwpBrowserIngress({ + url: "ws://node-a.example/write/v4", + failoverUrls: ["ws://node-b.example/write/v4"], + webSocketFactory: (url) => { + const requestUrl = new URL(url); + attempted.push(requestUrl.hostname); + const socket = new FakeWebSocket(); + queueMicrotask(() => { + if (requestUrl.hostname === "node-a.example") { + socket.error(); + socket.close(1006, ""); + return; + } + socket.open(); + socket.message(ingressServerInfo(128)); + }); + return asQwpSocket(socket); + }, + }); + + expect(attempted).toEqual(["node-a.example", "node-b.example"]); + await session.close(); + }); + + it("stops the browser failover sweep on an authentication rejection", async () => { + // Only an explicit tryNextEndpoint: false short-circuits, so a 401 must + // still fail fast instead of walking the rest of the cluster. + const attempted: string[] = []; + await expect( + connectQwpBrowserIngress({ + url: "ws://node-a.example/write/v4", + failoverUrls: ["ws://node-b.example/write/v4"], + sessionBootstrap: { + authentication: { type: "bearer", token: "token" }, + fetch: async () => new Response("nope", { status: 401 }), + }, + webSocketFactory: (url) => { + attempted.push(new URL(url).hostname); + return asQwpSocket(new FakeWebSocket()); + }, + }), + ).rejects.toBeInstanceOf(QwpBrowserSessionBootstrapError); + expect(attempted).toEqual([]); + }); + it("uses the browser-selected ingress batch cap automatically", async () => { const socket = new FakeWebSocket(); let capturedUrl: string | URL | undefined; From e13f250f96f5d36a3d24d6e80b7c8f4d55303891 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Fri, 21 Aug 2026 08:24:34 +0100 Subject: [PATCH 111/265] fix(qwp): keep UDP rows staged when a datagram cannot be encoded QwpNodeUdpSession.sendTables was async, so QwpUdpDatagramTooLargeError from encodeUdpDatagrams surfaced as a rejected promise rather than a synchronous throw. QwpSender only awaits a publication handle before transferring row ownership, and the UDP session has no sendTablesWithPublication, so flushNow reached releaseStagedRows and totalRowsPublished before the rejection was observed 27 lines later. One oversized row therefore discarded the whole batch -- including rows well within max_datagram_size -- with nothing on the wire, reported them as published while publishedSequence stayed -1n, and left a retry with nothing to retry. It is reachable on the plainest string, `udp::addr=host:9007`, and auto_flush_bytes does not guard it: that threshold is a pre-encode estimate checked after the offending row is already staged. This is not the fire-and-forget carve-out. That covers rows which "may already have been handed to the network"; encoding fails before socket.send is ever called, totalSendErrors stays 0, and onError is never invoked. The WebSocket path retains an over-cap batch for exactly this reason, and close() discards one only for QwpBatchTooLargeError, which this error does not extend. Encode synchronously and defer only the sends, matching what planIngressFrames gives the WebSocket path. flushAndGetSequence already retained the batch because it awaits publication first, so this also removes that split. Co-Authored-By: Claude Opus 5 (1M context) --- src/qwp-node/udp-sender.ts | 23 +++++++++++++---- test/qwp/udp-sender.test.ts | 49 ++++++++++++++++++++++++++++++++++--- 2 files changed, 64 insertions(+), 8 deletions(-) diff --git a/src/qwp-node/udp-sender.ts b/src/qwp-node/udp-sender.ts index 2c93b8f..833cc24 100644 --- a/src/qwp-node/udp-sender.ts +++ b/src/qwp-node/udp-sender.ts @@ -136,7 +136,14 @@ export class QwpNodeUdpSession implements QwpSenderSession { }); } - async sendTables( + // Not `async`: validation and encoding must run synchronously, before the + // returned promise exists. The high-level sender transfers row ownership as + // soon as a flush reaches the transport, so a batch that cannot be encoded + // has to fail the flush before that transfer -- the same contract + // planIngressFrames gives the WebSocket path by throwing out of + // sendTablesWithPublication. Only the sends themselves are deferred, and a + // failed send is fire-and-forget by design. + sendTables( tables: readonly QwpTableBuffer[], options: QwpIngressEncodeOptions = {}, ): Promise { @@ -147,15 +154,21 @@ export class QwpNodeUdpSession implements QwpSenderSession { ); } const datagrams = encodeUdpDatagrams(tables, this.maxBatchSizeBytes); - for (const datagram of datagrams) await this.send(datagram); - return { status: 0, sequence: this.sequence, tables: [] }; + return this.sendDatagrams(datagrams); } - async publishTables( + publishTables( tables: readonly QwpTableBuffer[], options: QwpIngressEncodeOptions = {}, ): Promise { - await this.sendTables(tables, options); + return this.sendTables(tables, options).then(() => undefined); + } + + private async sendDatagrams( + datagrams: readonly Uint8Array[], + ): Promise { + for (const datagram of datagrams) await this.send(datagram); + return { status: 0, sequence: this.sequence, tables: [] }; } waitForAcknowledged(targetSequence: bigint): Promise { diff --git a/test/qwp/udp-sender.test.ts b/test/qwp/udp-sender.test.ts index f6b7dd7..ce2ee30 100644 --- a/test/qwp/udp-sender.test.ts +++ b/test/qwp/udp-sender.test.ts @@ -118,13 +118,56 @@ describe("QWP Node UDP sender", () => { socketFactory: () => socket, }); - await expect( - session.sendTables([stringTable("x".repeat(256))]), - ).rejects.toBeInstanceOf(QwpUdpDatagramTooLargeError); + // Synchronously, before the returned promise exists -- QwpSender relies on + // that to keep the batch staged when encoding fails. + expect(() => session.sendTables([stringTable("x".repeat(256))])).toThrow( + QwpUdpDatagramTooLargeError, + ); + expect(() => session.publishTables([stringTable("x".repeat(256))])).toThrow( + QwpUdpDatagramTooLargeError, + ); expect(socket.packets).toEqual([]); await session.close(); }); + it("retains a batch whose oversized row cannot be encoded", async () => { + // The session-level test above never reaches QwpSender, which is where row + // ownership transfers. An encode failure happens before any datagram is + // handed to the socket, so it is not the "already on the network" case the + // fire-and-forget contract covers: the rows that do fit must survive for + // the caller to retry, and none of them may be counted as published. + const socket = new FakeUdpSocket(); + const sender = await connectQwpNodeUdpSender( + { host: "localhost", maxDatagramSize: 256, socketFactory: () => socket }, + { autoFlush: false }, + ); + for (const message of ["abc", "abc", "abc"]) { + await sender.table("events").stringColumn("message", message).atNow(); + } + await sender + .table("events") + .stringColumn("message", "x".repeat(2000)) + .atNow(); + + await expect(sender.flush()).rejects.toBeInstanceOf( + QwpUdpDatagramTooLargeError, + ); + expect(socket.packets).toEqual([]); + expect(sender.metrics).toMatchObject({ + pendingRows: 4, + totalRowsPublished: 0, + }); + + // Dropping the offending row lets the retry deliver the three that fit. + sender.reset(); + for (const message of ["abc", "abc", "abc"]) { + await sender.table("events").stringColumn("message", message).atNow(); + } + await expect(sender.flush()).resolves.toBe(true); + expect(socket.packets).toHaveLength(1); + await sender.close(); + }); + it("reports local send failures without retrying fire-and-forget rows", async () => { const socket = new FakeUdpSocket(); socket.sendError = new Error("network unreachable"); From 21d6145f9059a65cee07d681a264e064418b057a Mon Sep 17 00:00:00 2001 From: glasstiger Date: Fri, 21 Aug 2026 11:04:26 +0100 Subject: [PATCH 112/265] fix(qwp): lock a decimal column's scale and keep its width stable A QWP column carries one type and one decimal scale for a whole frame, but the fluent setters derived both from each value: decimalColumnText took the scale from the literal's fraction, decimalColumn took the width from the magnitude, and timestampColumn took the type from the unit. addColumn pinned all three on the first value and then threw `column type mismatch` for every later row that differed, discarding it, with an error naming neither the scale nor the values. Follow the Java client, whose QwpTableBuffer.ColumnBuffer locks decimalScale on the first value and rescales later values onto it, reporting only the two cases where that is impossible: a rescale that would drop a digit, and one that overflows the column's width. Both messages are reproduced here. decimalColumn takes the width from the value's magnitude, which the Java client never does -- there the width comes from the Decimal64/128/256 overload, so a larger second value cannot change the column type. The untyped setter now pins DECIMAL256, matching Java's untyped CharSequence overload; decimal64Column, decimal128Column and decimal256Column remain the narrower equivalents. Mixed timestamp units stay a mismatch, as in Java, where TIMESTAMP and TIMESTAMP_NANOS are distinct types that assertColumnType rejects. Co-Authored-By: Claude Opus 5 (1M context) --- src/qwp/sender.ts | 82 ++++++++++++++++++++++++++++++++++++----- test/qwp/sender.test.ts | 74 +++++++++++++++++++++++++++++++++++++ 2 files changed, 146 insertions(+), 10 deletions(-) diff --git a/src/qwp/sender.ts b/src/qwp/sender.ts index e706e55..add8cb5 100644 --- a/src/qwp/sender.ts +++ b/src/qwp/sender.ts @@ -358,11 +358,45 @@ function stagedRowBytes(columns: ReadonlyMap): number { return bytes; } -function decimalType(value: bigint, scale: number): QwpColumnType { - if (scale <= 18 && fitsSigned(value, 64)) return QWP_COLUMN_TYPE.DECIMAL64; - if (scale <= 38 && fitsSigned(value, 128)) return QWP_COLUMN_TYPE.DECIMAL128; - if (scale <= 76 && fitsSigned(value, 256)) return QWP_COLUMN_TYPE.DECIMAL256; - throw new RangeError("decimal value or scale exceeds DECIMAL256 capacity"); +const DECIMAL_WIDTH = new Map([ + [QWP_COLUMN_TYPE.DECIMAL64, 64], + [QWP_COLUMN_TYPE.DECIMAL128, 128], + [QWP_COLUMN_TYPE.DECIMAL256, 256], +]); + +function isDecimalType(type: QwpColumnType): boolean { + return DECIMAL_WIDTH.has(type); +} + +/** + * Rescales a decimal onto the scale its column locked on its first value, + * matching the Java client's QwpTableBuffer.ColumnBuffer.addDecimal* path. A + * QWP column carries one scale for the whole frame, so the alternative to + * rescaling is rejecting the row; both clients rescale where it is exact and + * report the two cases where it is not. + */ +function rescaleToColumnScale( + name: string, + value: bigint, + fromScale: number, + toScale: number, + type: QwpColumnType, +): bigint { + let rescaled: bigint; + try { + rescaled = rescaleDecimal(value, fromScale, toScale); + } catch { + throw new RangeError( + `column '${name}' cannot rescale decimal from scale ${fromScale} to ${toScale} without precision loss`, + ); + } + const bits = DECIMAL_WIDTH.get(type); + if (bits !== undefined && !fitsSigned(rescaled, bits)) { + throw new RangeError( + `Decimal${bits} overflow: rescaling from scale ${fromScale} to ${toScale} exceeds ${bits}-bit capacity`, + ); + } + return rescaled; } function parseDecimal(value: string | number): { @@ -1327,7 +1361,15 @@ export class QwpSender { typeof unscaled === "bigint" ? unscaled : signedBigEndianToBigInt(unscaled); - return this.addColumn(name, decimalType(value, scale), value, { + if (!fitsSigned(value, 256)) { + throw new RangeError("decimal value exceeds DECIMAL256 capacity"); + } + // Widest type, not one derived from this value's magnitude: the column + // carries one type per frame, so deriving it per value would reject the + // next row whose magnitude needs a different width. The Java client takes + // the width from the overload for the same reason; decimal64Column, + // decimal128Column and decimal256Column are the narrower equivalents. + return this.addColumn(name, QWP_COLUMN_TYPE.DECIMAL256, value, { decimalScale: scale, }); } catch (error) { @@ -1983,10 +2025,28 @@ export class QwpSender { if ( existingSchema && (existingSchema.type !== type || - existingSchema.geohashPrecision !== metadata.geohashPrecision || - existingSchema.decimalScale !== metadata.decimalScale) + existingSchema.geohashPrecision !== metadata.geohashPrecision) ) { - throw new Error(`column type mismatch for '${name}'`); + throw new Error( + `column type mismatch for '${name}' [existing=${existingSchema.type}, received=${type}]`, + ); + } + if ( + existingSchema && + existingSchema.decimalScale !== metadata.decimalScale && + isDecimalType(type) + ) { + // The column locked its scale on its first value, as the Java client's + // ColumnBuffer does; later values are rescaled onto it rather than + // changing a scale the frame can only carry once. + value = rescaleToColumnScale( + name, + value as bigint, + metadata.decimalScale ?? 0, + existingSchema.decimalScale ?? 0, + type, + ); + metadata = { ...metadata, decimalScale: existingSchema.decimalScale }; } if (this.currentRow.has(nameKey)) return this; const canonicalName = existingSchema?.name ?? name; @@ -2059,7 +2119,9 @@ export class QwpSender { private releaseStagedRows( snapshots: readonly { table: StagedTable; rows: readonly StagedRow[] }[], ): number { - for (const { table, rows } of snapshots) table.rows.splice(0, rows.length); + for (const { table, rows } of snapshots) { + table.rows.splice(0, rows.length); + } const rowCount = snapshots.reduce( (count, item) => count + item.rows.length, 0, diff --git a/test/qwp/sender.test.ts b/test/qwp/sender.test.ts index 661f2cb..c25efbb 100644 --- a/test/qwp/sender.test.ts +++ b/test/qwp/sender.test.ts @@ -1196,6 +1196,80 @@ describe("QWP high-level sender", () => { await sender.close(); }); + it("locks a decimal column's scale on its first value and rescales onto it", async () => { + // A QWP column carries one scale per frame. The Java client's ColumnBuffer + // locks it on the first value and rescales later ones onto it, so do the + // same rather than rejecting every row after the first. + const session = new RecordingSession(); + const sender = new QwpSender(async () => session, { autoFlush: false }); + await sender.table("fx").decimalColumnText("mid", "1.500").atNow(); + await sender.table("fx").decimalColumnText("mid", "2.25").atNow(); + await sender.table("fx").decimalColumnText("mid", "3").atNow(); + await sender.flush(); + + const mid = column(session.sends[0].tables[0], "mid"); + expect(session.sends[0].tables[0].rowCount).toBe(3); + expect(mid.decimalScale).toBe(3); + expect(mid.values).toEqual([1_500n, 2_250n, 3_000n]); + await sender.close(); + }); + + it("rejects a decimal the column's locked scale cannot represent", async () => { + const session = new RecordingSession(); + const sender = new QwpSender(async () => session, { autoFlush: false }); + await sender.table("fx").decimalColumnText("mid", "1.5").atNow(); + // Scale 1 cannot carry 2.25 without dropping a digit, which is the one + // case the Java client reports instead of rescaling. + expect(() => sender.table("fx").decimalColumnText("mid", "2.25")).toThrow( + /column 'mid' cannot rescale decimal from scale 2 to 1 without precision loss/, + ); + await sender.flush(); + expect(column(session.sends[0].tables[0], "mid").values).toEqual([15n]); + await sender.close(); + }); + + it("keeps a decimal column's width stable across magnitudes", async () => { + // The width came from each value's magnitude, so a larger second value + // changed the column type and the row was discarded. Java takes the width + // from the overload; the untyped setter therefore pins the widest. + const session = new RecordingSession(); + const sender = new QwpSender(async () => session, { autoFlush: false }); + await sender.table("fx").decimalColumn("mid", 12_345n, 2).atNow(); + await sender + .table("fx") + .decimalColumn("mid", 10n ** 25n, 2) + .atNow(); + await sender.flush(); + + const mid = column(session.sends[0].tables[0], "mid"); + expect(mid.type).toBe(QWP_COLUMN_TYPE.DECIMAL256); + expect(mid.decimalScale).toBe(2); + expect(mid.values).toEqual([12_345n, 10n ** 25n]); + await sender.close(); + }); + + it("rejects mixed timestamp units within one column", async () => { + // TIMESTAMP and TIMESTAMP_NANOS are distinct column types; the Java client + // rejects the second unit rather than promoting the column. + const session = new RecordingSession(); + const sender = new QwpSender(async () => session, { autoFlush: false }); + await sender.table("t").timestampColumn("seen", 5n, "us").atNow(); + expect(() => + sender.table("t").timestampColumn("seen", 7_000n, "ns"), + ).toThrow(/column type mismatch for 'seen'/); + await sender.close(); + }); + + it("still rejects a genuine column family change", async () => { + const session = new RecordingSession(); + const sender = new QwpSender(async () => session, { autoFlush: false }); + await sender.table("t").longColumn("v", 1n).atNow(); + expect(() => sender.table("t").stringColumn("v", "two")).toThrow( + /column type mismatch for 'v'/, + ); + await sender.close(); + }); + it("validates fixed precision and scale when compiling the schema", () => { const sender = new QwpSender(async () => new RecordingSession(), { autoFlush: false, From 00dae5386491a8a81c3b25e55483aac2f4792b8d Mon Sep 17 00:00:00 2001 From: glasstiger Date: Fri, 21 Aug 2026 11:13:19 +0100 Subject: [PATCH 113/265] fix(qwp): bound egress recovery from an undecodable response A QwpProtocolError raised while handling a message -- an unknown column type, a truncated body, a non-monotonic offset table, any of the ~30 throw sites in result-batch.ts -- routes to recoverProtocolFailure, which reconnects and replays the stored QUERY_REQUEST. The replacement server returns the same response, so the failure reproduces immediately. Nothing bounded that loop. connectLoop declares outageStarted, attempt and backoffMs as function-locals and re-initialises them per call, and its exhaustion check only runs in the catch of a failed attempt -- but here every attempt succeeds. maxAttempts and maxDurationMs were therefore never consumed. Measured on defaults: 38 connections/second indefinitely, the query never settling, and with a failover factory the close code 1002 deprioritises each endpoint in turn so the storm rotates the whole cluster. A pooled QwpClient lease is held for the duration, so other borrowers hit acquireTimeoutMs. Charge these recoveries to the reconnect budget that already exists. The Java client bounds the same case this way: QwpQueryClient.executeImpl retries around the whole query and counts every re-submission against failover_max_attempts (default 8) and failover_max_duration, whichever expires first, then reports the failure to the handler. Its decode path comments that "a server that consistently emits garbage will surface as round-exhaustion eventually". The budget is scoped per query, as Java scopes it per execute(): a new application QUERY_REQUEST resets it, and replay does not go through trackOutbound, so a request that keeps poisoning still exhausts it. Exhaustion raises QwpReconnectExhaustedError. maxAttempts counts reconnects here, as it does in connectLoop, so maxAttempts=1 still permits one recovery and 0 remains unlimited. Co-Authored-By: Claude Opus 5 (1M context) --- .../reconnecting-egress-connection.ts | 32 +++++++++ test/qwp/reconnect.test.ts | 66 +++++++++++++++++++ 2 files changed, 98 insertions(+) diff --git a/src/qwp/internal/reconnecting-egress-connection.ts b/src/qwp/internal/reconnecting-egress-connection.ts index dacc0d1..078d9a9 100644 --- a/src/qwp/internal/reconnecting-egress-connection.ts +++ b/src/qwp/internal/reconnecting-egress-connection.ts @@ -72,6 +72,8 @@ export class QwpReconnectingEgressConnection implements QwpBinaryConnection { private initialServerInfo?: QwpServerInfoMessage; private currentServerInfo?: QwpServerInfoMessage; private outboundReplay: Uint8Array[] = []; + private protocolRecoveries = 0; + private protocolRecoveryStartedAt = 0; private generation = 0; private sendTail: Promise = Promise.resolve(); private reconnectTask?: Promise; @@ -495,6 +497,32 @@ export class QwpReconnectingEgressConnection implements QwpBinaryConnection { this.throwIfUnavailable(); const connection = this.connection; if (!connection) throw new QwpSendClosedError(); + // Reconnecting replays the same QUERY_REQUEST, so a response this client + // cannot decode reproduces on the replacement connection. Each connect + // SUCCEEDS, so connectLoop's own budget is never consumed and the retry + // would otherwise run forever, rotating the whole cluster. Charge these + // recoveries to the same maxAttempts/maxDurationMs budget instead, the way + // the Java client counts every re-submission of one execute() against + // failover_max_attempts and failover_max_duration. + if (this.protocolRecoveries === 0) { + this.protocolRecoveryStartedAt = Date.now(); + } + this.protocolRecoveries++; + // `>` not `>=`: maxAttempts counts reconnects here, as it does in + // connectLoop, so maxAttempts=1 still permits one recovery. + const attemptsExhausted = + this.maxAttempts > 0 && this.protocolRecoveries > this.maxAttempts; + const durationExhausted = + this.maxDurationMs > 0 && + Date.now() - this.protocolRecoveryStartedAt >= this.maxDurationMs; + if (attemptsExhausted || durationExhausted) { + const exhausted = new QwpReconnectExhaustedError( + this.protocolRecoveries, + error, + ); + this.failTerminal(exhausted); + throw exhausted; + } try { await this.requestReconnect( error, @@ -513,6 +541,10 @@ export class QwpReconnectingEgressConnection implements QwpBinaryConnection { switch (payload[0]) { case QWP_EGRESS_MESSAGE.QUERY_REQUEST: this.outboundReplay = [payload]; + // A new application query is fresh progress, matching the Java + // client's per-execute() scoping. Replay does not come through here, + // so a request that keeps poisoning still exhausts its budget. + this.protocolRecoveries = 0; break; case QWP_EGRESS_MESSAGE.CREDIT: case QWP_EGRESS_MESSAGE.CANCEL: diff --git a/test/qwp/reconnect.test.ts b/test/qwp/reconnect.test.ts index 3bf3ff6..7189ec4 100644 --- a/test/qwp/reconnect.test.ts +++ b/test/qwp/reconnect.test.ts @@ -164,6 +164,22 @@ function emptyResultBatch(requestId = 0n, batchSequence = 0): Uint8Array { return encodeQwpFrame(payload.toUint8Array(), 0, 1); } +/** A batch declaring a column type no QWP client build knows how to decode. */ +function undecodableResultBatch(requestId = 0n): Uint8Array { + const payload = new QwpByteWriter() + .writeUint8(QWP_EGRESS_MESSAGE.RESULT_BATCH) + .writeBigUint64(requestId); + writeQwpVarint(payload, 0); // batch sequence + writeQwpVarint(payload, 0); // table name + writeQwpVarint(payload, 1); // row count + writeQwpVarint(payload, 1); // column count + writeQwpVarint(payload, 1); + payload.writeUint8(0x63); // column name "c" + payload.writeUint8(0xfe); // column type + payload.writeUint8(0x00); // encoding + return encodeQwpFrame(payload.toUint8Array(), 0, 1); +} + function resultEnd(requestId = 0n): Uint8Array { const payload = new QwpByteWriter() .writeUint8(QWP_EGRESS_MESSAGE.RESULT_END) @@ -2933,6 +2949,56 @@ describe("QWP egress reconnect and replay", () => { } }); + it("stops replaying a query whose response cannot be decoded", async () => { + // Reconnecting replays the same QUERY_REQUEST, so an undecodable response + // reproduces on every replacement connection. Each connect SUCCEEDS, so + // connectLoop's own budget is never consumed: without charging these + // recoveries to the failover budget the loop runs forever and the query + // never settles. + const connections: FakeConnection[] = []; + const session = await QwpEgressSession.connect( + async () => { + const connection = new FakeConnection(`node-${connections.length}`); + connections.push(connection); + queueMicrotask(() => + connection.receive(serverInfo(connection.endpoint)), + ); + return connection; + }, + { + reconnect: { + maxAttempts: 3, + maxDurationMs: 0, + initialBackoffMs: 0, + maxBackoffMs: 0, + }, + }, + ); + + const query = await session.query("select 1"); + for (const connection of connections) { + connection.receive(undecodableResultBatch()); + } + const drain = (async () => { + for await (const _batch of query) void _batch; + })(); + await vi.waitFor(() => expect(connections.length).toBeGreaterThan(1)); + // Every replacement gets the same undecodable batch. + const feed = setInterval(() => { + for (const connection of connections) { + connection.receive(undecodableResultBatch()); + } + }, 1); + try { + await expect(drain).rejects.toBeInstanceOf(QwpReconnectExhaustedError); + } finally { + clearInterval(feed); + } + // Bounded by the failover budget rather than looping without limit. + expect(connections.length).toBeLessThanOrEqual(6); + await session.close().catch(() => undefined); + }); + it("retries the initial connection until one provides SERVER_INFO", async () => { const first = new FakeConnection("primary"); const second = new FakeConnection("secondary"); From 0a236d8fb59fdbe26942300888f8b9863ba4db05 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Fri, 21 Aug 2026 11:28:26 +0100 Subject: [PATCH 114/265] fix(qwp): report journal bytes abandoned by torn-tail recovery scanSegment reported tornTail for any record that failed its CRC or whose header was zeroed, and recovery truncated the active segment there. That is right for an interrupted append, where nothing follows. It is wrong to do silently when a lost block or bit rot leaves intact records behind the damage: every one of them was physically zeroed and its sequence reused, with no warning, no event and no error, and the store then reported a clean, contiguous journal. Nothing downstream noticed either -- the manifest stores no record counts, the contiguity check only detects a gap at the front, and the ACK watermark sits below the damage. Detect the case, but keep abandoning the residue, matching the Java client: MmapSegment zeroes an active segment's torn tail by policy because "past a mid-file tear it can still hold valid-CRC frames of real unacked payloads, but they are unreachable by replay (the FSN sequence breaks at the tear)". That reasoning holds here -- loadReferences requires strictly contiguous sequences, so preserving those frames would fail recovery with a gap error rather than replay them. Halting a producer for bytes that can never be sent trades availability for nothing. What Java does and this did not is say so: it emits a WARN and exposes the residue through tornTailBytes(). Recovery now reports the abandoned byte count through onRecoveryDataLoss, wired by default to the onSenderError stream as a DATA_LOSS/ABANDONED QwpSenderError, and falling back to the logger when no handler is configured. Reporting cannot throw, because recovery has already succeeded by then. A torn record in a sealed segment stays fail-closed, as it already was, which is the Java sealed-suffix contract: it is zeroed only on proof that frame accounting is complete, and a tear that cost frames preserves every byte on disk for operator extraction. Co-Authored-By: Claude Opus 5 (1M context) --- src/qwp-node/file-replay-store.ts | 127 ++++++++++++++++++++++++++++-- src/qwp/node.ts | 47 ++++++++++- src/qwp/sender-error.ts | 3 +- test/qwp/reconnect.test.ts | 103 ++++++++++++++++++++++++ 4 files changed, 269 insertions(+), 11 deletions(-) diff --git a/src/qwp-node/file-replay-store.ts b/src/qwp-node/file-replay-store.ts index e6554e3..d73c55c 100644 --- a/src/qwp-node/file-replay-store.ts +++ b/src/qwp-node/file-replay-store.ts @@ -23,6 +23,7 @@ import { QwpNodeAdvisoryLockUnavailableError, } from "./advisory-lock"; import { qwpSegmentMaintenanceWorker } from "./segment-maintenance-worker"; +import { log } from "../logging"; const FORMAT_VERSION = 1; const MAX_FRAME_SEQUENCE = 0x7fffffffffffffffn; @@ -132,6 +133,20 @@ interface PendingCapacity { timer?: ReturnType; } +/** + * Frames discarded while recovering a damaged journal. Emitted instead of + * failing recovery when the damage sits in the active segment, matching the + * Java client, which zeroes an active torn tail by policy and reports the + * residue through a WARN plus MmapSegment.tornTailBytes(). + */ +export interface QwpNodeReplayDataLossReport { + readonly directory: string; + readonly segmentFile: string; + /** Bytes after the damaged record that recovery could not reach. */ + readonly discardedBytes: number; + readonly reason: string; +} + export interface QwpNodeFileReplayStoreOptions { /** Exclusive directory used by one ingress session. */ directory: string; @@ -164,6 +179,11 @@ export interface QwpNodeFileReplayStoreOptions { backpressurePolicy?: QwpSfBackpressurePolicy; /** Per-append disk-capacity wait deadline. Defaults to 30 seconds. */ appendDeadlineMs?: number; + /** + * Reports journal bytes abandoned during recovery. Defaults to logging at + * error level; recovery still succeeds, so this must never be silent. + */ + onRecoveryDataLoss?: (report: QwpNodeReplayDataLossReport) => void; } export interface QwpNodeFileReplayStoreMetrics { @@ -318,6 +338,9 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { private readonly checkpointIntervalMs: number; private readonly backpressurePolicy: QwpSfBackpressurePolicy; private readonly appendDeadlineMs: number; + private readonly onRecoveryDataLoss?: ( + report: QwpNodeReplayDataLossReport, + ) => void; private readonly records = new Map(); private readonly segments = new Map(); private readonly segmentOrder: StoredSegment[] = []; @@ -407,6 +430,7 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { "store-and-forward checkpointIntervalMs requires durability='periodic'", ); } + this.onRecoveryDataLoss = options.onRecoveryDataLoss; this.appendDeadlineMs = validateTimerDelay( options.appendDeadlineMs ?? DEFAULT_APPEND_DEADLINE_MS, "store-and-forward appendDeadlineMs", @@ -547,6 +571,24 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { "non-active segment has a torn record tail", ); } + if (decoded.interiorDamage) { + // The active segment's residue is abandoned by policy, matching + // the Java client: past a mid-file tear the frames behind it are + // unreachable anyway, because replay requires a contiguous + // sequence and the tear breaks it. Recovery therefore proceeds on + // the valid prefix, but the loss is always reported -- discarding + // it silently is what made this dangerous. + this.reportRecoveryDataLoss({ + directory: this.directory, + segmentFile: name, + discardedBytes: Math.max( + 0, + decoded.size - SEGMENT_HEADER_SIZE - decoded.logicalSize, + ), + reason: + "a damaged record is followed by intact records that replay can no longer reach", + }); + } await repairSegmentTail( path, SEGMENT_HEADER_SIZE + decoded.logicalSize, @@ -1825,6 +1867,26 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { } } + /** + * Recovery succeeded, so this must not throw: a reporting failure cannot be + * allowed to brick a slot that is otherwise ready to replay. Without a + * handler it logs, so abandoned journal bytes are never silent. + */ + private reportRecoveryDataLoss(report: QwpNodeReplayDataLossReport): void { + const message = + `QWP store-and-forward discarded ${report.discardedBytes} journal byte(s) during recovery ` + + `[directory=${report.directory}, segment=${report.segmentFile}]: ${report.reason}`; + if (!this.onRecoveryDataLoss) { + log("error", message); + return; + } + try { + this.onRecoveryDataLoss(report); + } catch { + log("error", message); + } + } + private async removeAcknowledgedThrough(): Promise { if (this.acknowledgedThrough < 0n) return; await ignoreMissing(unlink(join(this.directory, ACK_FILE))); @@ -2033,6 +2095,12 @@ interface DecodedSegment { /** Bytes occupied by encoded records, excluding the fixed segment header. */ readonly logicalSize: number; readonly tornTail: boolean; + /** + * Set when structurally intact data still follows the damaged record, which + * makes this a hole rather than an unwritten tail. Repairing it would delete + * records that are still on disk, so recovery quarantines instead. + */ + readonly interiorDamage?: boolean; } function selectRecoveredActivePath( @@ -2121,16 +2189,15 @@ async function scanSegment( const remaining = fileSize - offset; const headerBytes = Math.min(remaining, FRAME_HEADER_SIZE); await readFully(handle, frameHeader.subarray(0, headerBytes), offset); - if ( - frameHeader[0] === 0 && - isZeroFilled(frameHeader, 0, headerBytes) && - (await isZeroFilledFile( + const zeroedHeader = + frameHeader[0] === 0 && isZeroFilled(frameHeader, 0, headerBytes); + if (zeroedHeader) { + const paddingToEnd = await isZeroFilledFile( handle, offset + headerBytes, fileSize, scanBuffer, - )) - ) { + ); return { firstSequence, manifestRequired: (flags & MANIFEST_REQUIRED_FLAG) !== 0, @@ -2138,7 +2205,12 @@ async function scanSegment( size: fileSize, records, logicalSize: offset - SEGMENT_HEADER_SIZE, - tornTail: false, + // Padding to EOF is the ordinary unwritten tail. A zeroed record with + // live bytes behind it is a lost block -- the shape an unordered + // page-cache writeback leaves after a host crash -- so the records + // after it are still intact and must not be truncated away. + tornTail: !paddingToEnd, + interiorDamage: !paddingToEnd, }; } if (remaining < FRAME_HEADER_SIZE) { @@ -2187,6 +2259,14 @@ async function scanSegment( records, logicalSize: offset - SEGMENT_HEADER_SIZE, tornTail: true, + // A record that still verifies where this one ends means the damage is + // bit rot in the middle of the journal, not an interrupted append. + interiorDamage: await hasValidRecordAt( + handle, + recordEnd, + fileSize, + scratch, + ), }; } const frameSequence = firstSequence + BigInt(records.length); @@ -2220,6 +2300,39 @@ function isZeroFilled( return true; } +/** + * Reports whether a complete, CRC-verified record starts at `offset`. Records + * are contiguous, so this is the only place the next one can begin: finding it + * proves the preceding damage has intact data behind it. + */ +async function hasValidRecordAt( + handle: FileHandle, + offset: number, + fileSize: number, + scratch: SegmentScanScratch, +): Promise { + if (offset + FRAME_HEADER_SIZE > fileSize) return false; + const frameHeader = scratch.frameHeader; + await readFully(handle, frameHeader, offset); + if (frameHeader[0] === 0 && isZeroFilled(frameHeader, 0, FRAME_HEADER_SIZE)) { + return false; + } + const payloadLength = frameHeader.readUInt32LE(4); + if (offset + FRAME_HEADER_SIZE + payloadLength > fileSize) return false; + let crc = crc32cUpdate(0xffffffff, frameHeader.subarray(4)); + let payloadOffset = offset + FRAME_HEADER_SIZE; + let payloadRemaining = payloadLength; + while (payloadRemaining > 0) { + const chunkLength = Math.min(payloadRemaining, scratch.data.byteLength); + const chunk = scratch.data.subarray(0, chunkLength); + await readFully(handle, chunk, payloadOffset); + crc = crc32cUpdate(crc, chunk); + payloadOffset += chunkLength; + payloadRemaining -= chunkLength; + } + return frameHeader.readUInt32LE(0) === (crc ^ 0xffffffff) >>> 0; +} + async function isZeroFilledFile( handle: FileHandle, start: number, diff --git a/src/qwp/node.ts b/src/qwp/node.ts index c802295..63ec73d 100644 --- a/src/qwp/node.ts +++ b/src/qwp/node.ts @@ -47,6 +47,7 @@ import { import { QwpIngressSession, QwpIngressSessionOptions } from "./ingress-session"; import { createQwpDataLossSenderError, + defaultQwpSenderErrorHandler, type QwpSenderError, } from "./sender-error"; import { QwpSender, QwpSenderOptions } from "./sender"; @@ -61,7 +62,10 @@ import { QwpReplayStoreCorruptionError, QwpReplayStoreQuarantinedError, } from "../qwp-node/file-replay-store"; -import type { QwpNodeFileReplayStoreOptions } from "../qwp-node/file-replay-store"; +import type { + QwpNodeFileReplayStoreOptions, + QwpNodeReplayDataLossReport, +} from "../qwp-node/file-replay-store"; import { QwpNodeOrphanDrainer, type QwpNodeOrphanDrainEvent, @@ -88,6 +92,7 @@ export { export type { QwpNodeFileReplayStoreMetrics, QwpNodeFileReplayStoreOptions, + QwpNodeReplayDataLossReport, QwpSfBackpressurePolicy, QwpSfDurability, } from "../qwp-node/file-replay-store"; @@ -569,7 +574,12 @@ async function connectQwpNodeIngressInternal( ); } let replayStore = storeAndForward - ? new QwpNodeFileReplayStore(storeAndForward) + ? new QwpNodeFileReplayStore( + withRecoveryDataLossReporter( + storeAndForward, + sessionOptions.onSenderError, + ), + ) : sessionOptions.replayStore; const reconnect = storeAndForward ? (sessionOptions.reconnect ?? {}) @@ -641,7 +651,12 @@ async function connectQwpNodeIngressInternal( recoveryError, effectiveSessionOptions.onSenderError, ); - replayStore = new QwpNodeFileReplayStore(storeAndForward); + replayStore = new QwpNodeFileReplayStore( + withRecoveryDataLossReporter( + storeAndForward, + effectiveSessionOptions.onSenderError, + ), + ); session = await QwpIngressSession.connect(connectionFactory, { ...effectiveSessionOptions, replayStore, @@ -654,6 +669,32 @@ async function connectQwpNodeIngressInternal( return session; } +/** + * Routes abandoned journal bytes into the onSenderError stream. Recovery has + * already succeeded by the time this runs, so it only reports; the caller's + * own onRecoveryDataLoss wins when supplied. + */ +function withRecoveryDataLossReporter( + options: QwpNodeStoreAndForwardOptions, + onSenderError?: (error: QwpSenderError) => void, +): QwpNodeStoreAndForwardOptions { + if (options.onRecoveryDataLoss || !onSenderError) return options; + return { + ...options, + onRecoveryDataLoss: (report: QwpNodeReplayDataLossReport) => { + const senderError = createQwpDataLossSenderError( + `QWP store-and-forward discarded ${report.discardedBytes} journal byte(s) during recovery ` + + `[directory=${report.directory}, segment=${report.segmentFile}]: ${report.reason}`, + ); + try { + onSenderError(senderError); + } catch { + defaultQwpSenderErrorHandler(senderError); + } + }, + }; +} + function isQuarantinableReplayRecoveryError(error: unknown): boolean { return ( error instanceof QwpReplayStoreCorruptionError || diff --git a/src/qwp/sender-error.ts b/src/qwp/sender-error.ts index 88ba37d..38f255d 100644 --- a/src/qwp/sender-error.ts +++ b/src/qwp/sender-error.ts @@ -126,7 +126,8 @@ export function createQwpProtocolViolationSenderError( export function createQwpDataLossSenderError( message: string, - quarantinedPath: string, + /** Omitted when the bytes were abandoned rather than preserved on disk. */ + quarantinedPath?: string, ): QwpSenderError { return Object.freeze({ category: QWP_SENDER_ERROR_CATEGORY.DATA_LOSS, diff --git a/test/qwp/reconnect.test.ts b/test/qwp/reconnect.test.ts index 7189ec4..d6e78d5 100644 --- a/test/qwp/reconnect.test.ts +++ b/test/qwp/reconnect.test.ts @@ -27,6 +27,7 @@ import { QwpReplayStoreLockedError, QwpReplayStoreSegmentTooLargeError, QwpReplayStoreUnavailableError, + type QwpNodeReplayDataLossReport, } from "../../src/qwp/node"; import { QWP_RECONNECT_EVENT_KIND, @@ -3780,6 +3781,108 @@ describe("QWP Node file replay store", () => { await recovered.close(); }); + it.each([ + ["a zeroed record", "hole"], + ["a flipped payload byte", "bitrot"], + ] as const)( + "reports the records %s strands behind it instead of dropping them silently", + async (_label, shape) => { + // Truncating here is only correct for an unwritten tail. A lost block -- + // what an unordered page-cache writeback leaves after a host crash under + // the connect-string default durability -- or bit rot strands the records + // behind it. Replay needs a contiguous sequence, so the tear makes them + // unreachable whatever recovery does; the Java client abandons the + // active segment's residue by policy for exactly that reason. What it + // must never do is abandon them without saying so. + const directory = await trackedDirectory(); + const first = new QwpNodeFileReplayStore({ directory }); + await first.load(); + for (let sequence = 0; sequence < 5; sequence++) { + await first.append({ + frameSequence: BigInt(sequence), + payload: Uint8Array.of(sequence, sequence, sequence), + }); + } + await first.close(); + + const [segment] = await assignedReplaySegments(directory); + const recordSize = 8 + 3; + const secondRecord = 24 + recordSize * 2; + const file = await open(join(directory, segment), "r+"); + try { + await file.write( + shape === "hole" ? new Uint8Array(recordSize) : Uint8Array.of(0xff), + 0, + shape === "hole" ? recordSize : 1, + shape === "hole" ? secondRecord : secondRecord + 8, + ); + await file.sync(); + } finally { + await file.close(); + } + + const reports: QwpNodeReplayDataLossReport[] = []; + const recovered = new QwpNodeFileReplayStore({ + directory, + onRecoveryDataLoss: (report) => reports.push(report), + }); + // Recovery still succeeds on the valid prefix, so the producer keeps + // running rather than being blocked behind an operator. + await expect(recovered.load()).resolves.toEqual([ + { frameSequence: 0n, payload: Uint8Array.of(0, 0, 0) }, + { frameSequence: 1n, payload: Uint8Array.of(1, 1, 1) }, + ]); + expect(reports).toHaveLength(1); + expect(reports[0]).toMatchObject({ + directory, + segmentFile: segment, + reason: expect.stringContaining("replay can no longer reach"), + }); + expect(reports[0].discardedBytes).toBeGreaterThan(0); + await recovered.close(); + }, + ); + + it("still fails closed when a sealed segment has a torn record", async () => { + // Java zeroes a sealed suffix only on proof that its frame accounting is + // complete; a tear that cost frames fails recovery before any mutation so + // every byte stays on disk for extraction. + const directory = await trackedDirectory(); + const first = new QwpNodeFileReplayStore({ + directory, + maxSegmentBytes: 32, + }); + await first.load(); + for (let sequence = 0; sequence < 6; sequence++) { + await first.append({ + frameSequence: BigInt(sequence), + payload: Uint8Array.of(sequence, sequence, sequence), + }); + } + await first.close(); + + const segments = await assignedReplaySegments(directory); + expect(segments.length).toBeGreaterThan(1); + const sealed = await open(join(directory, segments[0]), "r+"); + try { + await sealed.write(Uint8Array.of(0xff), 0, 1, 24 + 8); + await sealed.sync(); + } finally { + await sealed.close(); + } + + const reports: QwpNodeReplayDataLossReport[] = []; + const recovered = new QwpNodeFileReplayStore({ + directory, + onRecoveryDataLoss: (report) => reports.push(report), + }); + await expect(recovered.load()).rejects.toBeInstanceOf( + QwpReplayStoreCorruptionError, + ); + expect(reports).toEqual([]); + await recovered.close().catch(() => undefined); + }); + it.each([ QWP_SF_DURABILITY.APPEND, QWP_SF_DURABILITY.PERIODIC, From c7be5bb14ac7ca705932e7410e551eeff113b647 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Fri, 21 Aug 2026 08:40:19 +0100 Subject: [PATCH 115/265] fix(qwp): retry a transient journal read instead of latching the sender transmit() awaited readFramePayload outside its try block, and enqueueDrain's only handler is an unconditional failTerminal. Any store read failure therefore ended the producer permanently: terminalError is never cleared and QwpSender.getSession caches its session forever, so every later flush rejected, the ingress session closed, no reconnect was attempted, and the journalled frames stayed on disk unreachable in-process. The standalone orphan drainer excludes the foreground's own slot, so only discarding the sender recovered. The trigger is transient by the store's own design. A failed segment trim parks maintenanceFailure, which assertReady raises from readPayload, and a retry clears it about a second later -- a briefly full or read-only filesystem, or a restarted maintenance worker. Commit a42fec5 made the store self-heal precisely so one such failure could not "brick a running store-and-forward producer for the rest of the process lifetime"; the connection layer still did. replayInto makes the identical read and its failures reach connectLoop, where isRetryableReconnectError treats QwpReplayStoreError as retriable and background store-and-forward retries unbounded. Route the drain-path read the same way: mark the frame transmitted so replayInto resends it, keep it off the wire log because nothing reached the wire, and request a reconnect. This is the technique the batch-cap branch directly above already uses. Deterministic corruption -- QwpProtocolError, QwpReplayRejectedError -- still escapes and stays terminal. Co-Authored-By: Claude Opus 5 (1M context) --- .../reconnecting-ingress-connection.ts | 21 ++++++- test/qwp/reconnect.test.ts | 59 +++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/src/qwp/internal/reconnecting-ingress-connection.ts b/src/qwp/internal/reconnecting-ingress-connection.ts index 073f419..7db27df 100644 --- a/src/qwp/internal/reconnecting-ingress-connection.ts +++ b/src/qwp/internal/reconnecting-ingress-connection.ts @@ -1588,7 +1588,26 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { ); return; } - const payload = await this.readFramePayload(frame); + let payload: Uint8Array; + try { + payload = await this.readFramePayload(frame); + } catch (error) { + // A journal read can fail transiently: a briefly full or read-only + // filesystem parks maintenanceFailure for about a second, and the store + // clears it on the next successful batch. enqueueDrain's only handler is + // failTerminal, so letting this escape would brick a running producer for + // the rest of the process lifetime -- the very outcome the store-level + // retry was added to prevent. replayInto() makes the identical read and + // connectLoop retries its failures, so route this one the same way. + // Deterministic corruption still escapes and stays terminal. + if (!isRetryableReconnectError(error)) throw error; + // Nothing reached the wire, so the frame is deliberately kept off the + // wire log; marking it transmitted is what puts it in replayInto()'s + // resend set, exactly as the batch-cap branch above does. + frame.transmitted = true; + await this.requestReconnect(error, connection); + return; + } frame.transmitted = true; this.wireFrames.push(frame); try { diff --git a/test/qwp/reconnect.test.ts b/test/qwp/reconnect.test.ts index d6e78d5..e6dd5c7 100644 --- a/test/qwp/reconnect.test.ts +++ b/test/qwp/reconnect.test.ts @@ -1079,6 +1079,65 @@ describe("QWP ingress reconnect and replay", () => { await session.close(); }); + it("retries a transient journal read instead of latching the sender", async () => { + // A store read can fail transiently -- a briefly full or read-only + // filesystem parks the trim failure for about a second and the store + // clears it on the next successful batch. enqueueDrain's only handler is + // failTerminal, so before the fix that transient condition ended the + // producer for the rest of the process lifetime with its frames stranded + // on disk, which is exactly what the store-level retry exists to prevent. + class FlakyReadStore extends LazyTrackingReplayStore { + failNextRead = true; + + override async readPayload(frameSequence: bigint): Promise { + if (this.failNextRead) { + this.failNextRead = false; + throw new QwpReplayStoreError( + "could not trim QWP store-and-forward segment [firstSequence=0]", + ); + } + return super.readPayload(frameSequence); + } + } + + const connections: FakeConnection[] = []; + const replayStore = new FlakyReadStore(); + const session = await QwpIngressSession.connect( + async () => { + const connection = new FakeConnection(`node-${connections.length}`); + connections.push(connection); + return connection; + }, + { + backgroundStoreAndForward: true, + reconnect: { + maxAttempts: 0, + maxDurationMs: 0, + initialBackoffMs: 0, + maxBackoffMs: 0, + }, + replayStore, + }, + ); + + await session.publishFrame(Uint8Array.of(1)); + + // The frame is still journalled, so a reconnect replays it once the store + // recovers rather than the sender going terminal. + await vi.waitFor(() => + expect( + connections.some((connection) => + connection.sent.some((payload) => payload[payload.length - 1] === 1), + ), + ).toBe(true), + ); + // The producer never sees the transient failure. + await expect( + session.publishFrame(Uint8Array.of(2)), + ).resolves.toBeUndefined(); + await session.close(); + }); + it("keeps an asynchronous initial authentication rejection terminal", async () => { const replayStore = new TrackingReplayStore(); let factoryCalls = 0; From a5f261a1431c8077d23834f2bef5a346f1ce9878 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Fri, 21 Aug 2026 08:42:17 +0100 Subject: [PATCH 116/265] fix(qwp): drop a frame's wire slot when a reconnect lands during its read transmit() captured the connection, awaited the journal read, then pushed the frame onto this.wireFrames and sent it on the captured connection. install() replaces wireFrames wholesale and resets wireFramesBase, and replayInto() skips a frame that is not yet transmitted, so a reconnect completing inside that await left the frame written to the dead socket while occupying wire slot n of the replacement -- the slot its next real frame would take. translateResponse clamps an ACK to the wire log and indexes it with no sequence-identity check, so the replacement's first cumulative ACK retired a frame no server had received: acknowledgedFrameSequence advanced past it, its journal record was deleted, and QwpSender had already released the staged rows. No QwpSenderError, no reconnect event, no NACK -- totalFramesSent counted a frame that never reached a server. The window is real in the shipped sf_dir configuration: send() drops frame.payload for a lazy store so every drain reads from disk, and that read queues behind fsyncing appends on the store's shared FIFO -- measured at 5 to 162ms -- while the default reconnect delay is full jitter over [0,100)ms. The producing event is an endpoint that accepts the upgrade and then dies with nothing outstanding: a node restarting, a load balancer recycling sockets. Re-check the connection and the install generation after the read, and retry the frame against the current connection when either moved. The existing coverage only exercised the opposite ordering, where the reconnect starts before requireConnection() returns. Co-Authored-By: Claude Opus 5 (1M context) --- .../reconnecting-ingress-connection.ts | 26 +++++++- test/qwp/reconnect.test.ts | 64 +++++++++++++++++++ 2 files changed, 88 insertions(+), 2 deletions(-) diff --git a/src/qwp/internal/reconnecting-ingress-connection.ts b/src/qwp/internal/reconnecting-ingress-connection.ts index 7db27df..86b1d09 100644 --- a/src/qwp/internal/reconnecting-ingress-connection.ts +++ b/src/qwp/internal/reconnecting-ingress-connection.ts @@ -1565,7 +1565,17 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { } private async transmit(frame: ReplayFrame): Promise { + // Loops when a reconnect completes while this frame's payload is being + // read; see the currency check below. + for (;;) { + if (await this.transmitOnce(frame)) return; + } + } + + /** Returns false when a reconnect invalidated the captured connection. */ + private async transmitOnce(frame: ReplayFrame): Promise { const connection = await this.requireConnection(); + const generation = this.generation; const cap = minimumDefined( connection.handshake.maxBatchSizeBytes, this.localMaxBatchSizeBytes, @@ -1586,7 +1596,7 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { ), connection, ); - return; + return true; } let payload: Uint8Array; try { @@ -1606,7 +1616,18 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { // resend set, exactly as the batch-cap branch above does. frame.transmitted = true; await this.requestReconnect(error, connection); - return; + return true; + } + // The journal read above yields, and with a lazy store it can park behind + // an fsyncing append for longer than a jittered reconnect takes. install() + // swaps this.wireFrames wholesale and resets wireFramesBase, while + // replayInto() skipped this frame because it was not transmitted yet. + // Pushing it now would log it against the replacement connection's wire + // sequence while sending it on the dead one, so the replacement's next + // cumulative ACK would retire a frame no server ever received and delete + // its journal record. Retry against the current connection instead. + if (this.connection !== connection || this.generation !== generation) { + return false; } frame.transmitted = true; this.wireFrames.push(frame); @@ -1617,6 +1638,7 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { await this.requestReconnect(error, connection); if (this.lazyReplayStore) frame.payload = undefined; } + return true; } private async readFramePayload(frame: ReplayFrame): Promise { diff --git a/test/qwp/reconnect.test.ts b/test/qwp/reconnect.test.ts index e6dd5c7..47b7a1a 100644 --- a/test/qwp/reconnect.test.ts +++ b/test/qwp/reconnect.test.ts @@ -1079,6 +1079,70 @@ describe("QWP ingress reconnect and replay", () => { await session.close(); }); + it("does not log a frame against a connection installed during its journal read", async () => { + // With a lazy store the drain always reads from disk, and that read can + // park behind an fsyncing append for longer than a jittered reconnect + // takes. install() swaps the wire log wholesale, so a frame pushed after + // the swap occupies the replacement's wire slot while being written to the + // dead socket: the replacement's next cumulative ACK then retires a frame + // no server ever received, and its journal record is deleted. + let releaseRead!: () => void; + const parked = new Promise((resolve) => { + releaseRead = resolve; + }); + class ParkingReadStore extends LazyTrackingReplayStore { + parkNextRead = false; + + override async readPayload(frameSequence: bigint): Promise { + if (this.parkNextRead) { + this.parkNextRead = false; + await parked; + } + return super.readPayload(frameSequence); + } + } + + const connections: FakeConnection[] = []; + const replayStore = new ParkingReadStore(); + const session = await QwpIngressSession.connect( + async () => { + const connection = new FakeConnection(`node-${connections.length}`); + connections.push(connection); + return connection; + }, + { + backgroundStoreAndForward: true, + reconnect: { + maxAttempts: 0, + maxDurationMs: 0, + initialBackoffMs: 0, + maxBackoffMs: 0, + }, + replayStore, + }, + ); + + await session.publishFrame(Uint8Array.of(1)); + await vi.waitFor(() => expect(connections[0].sent).toHaveLength(1)); + + // Park the next drain read, then drop the connection underneath it. + replayStore.parkNextRead = true; + await session.publishFrame(Uint8Array.of(2)); + connections[0].drop(); + await vi.waitFor(() => expect(connections.length).toBe(2)); + releaseRead(); + + // Frame 2 must reach the live connection, not the dropped one. + await vi.waitFor(() => + expect( + connections[1].sent.some( + (payload) => payload[payload.length - 1] === 2, + ), + ).toBe(true), + ); + await session.close(); + }); + it("retries a transient journal read instead of latching the sender", async () => { // A store read can fail transiently -- a briefly full or read-only // filesystem parks the trim failure for about a second and the store From 3b5a74633c50d50a8f9e1418311ec43613ebe0a6 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Fri, 21 Aug 2026 11:49:19 +0100 Subject: [PATCH 117/265] fix(qwp): reject instead of throwing when flushing a closed sender flush(), flushAndGetSequence() and commit() all funnel into enqueueFlushResult, which is not async and calls throwIfUnavailable() as its first statement. A sender that is closed, or closing for the duration of close()'s bounded publish and ACK drain, therefore threw synchronously out of methods whose signature promises a Promise. `sender.flush().catch(handler)` does not catch that, so the common shape -- a periodic flush racing shutdown from a timer or event handler -- turns into an uncaught exception rather than a handled rejection. The sibling members at(), atNow(), connect(), waitForAcknowledged() and close() are all async and reject normally, so the surface was inconsistent with itself. The legacy Sender facade wraps flush() in its own async method, which is why only the qwp entry points were exposed. Declare the three methods async. Their bodies contain no await before the enqueue, so enqueueFlushResult still runs synchronously on the call and flush ordering is unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- src/qwp/sender.ts | 11 ++++++++--- test/qwp/sender.test.ts | 44 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/src/qwp/sender.ts b/src/qwp/sender.ts index add8cb5..006f7b9 100644 --- a/src/qwp/sender.ts +++ b/src/qwp/sender.ts @@ -1478,7 +1478,12 @@ export class QwpSender { * Publishes completed rows to the local ingress/replay boundary. This does * not wait for a server ACK unless awaitServerAck or awaitDurableAck is set. */ - flush(): Promise { + // `async` so a closed or closing sender rejects rather than throwing out of + // a method the signature says returns a Promise: a caller written as + // `sender.flush().catch(...)` would not catch a synchronous throw, and from a + // timer or event handler it becomes an uncaught exception. The enqueue itself + // still runs synchronously, so flush ordering is unchanged. + async flush(): Promise { return this.enqueueFlush(false); } @@ -1488,7 +1493,7 @@ export class QwpSender { * Pass the result to waitForAcknowledged() when an explicit delivery * barrier is needed. */ - flushAndGetSequence(): Promise { + async flushAndGetSequence(): Promise { return this.enqueueSequenceFlush(false); } @@ -1541,7 +1546,7 @@ export class QwpSender { * ergonomic alias for flush(); pending local rows are included in the same * group-closing frame. */ - commit(): Promise { + async commit(): Promise { return this.flush(); } diff --git a/test/qwp/sender.test.ts b/test/qwp/sender.test.ts index c25efbb..1c9a945 100644 --- a/test/qwp/sender.test.ts +++ b/test/qwp/sender.test.ts @@ -1270,6 +1270,50 @@ describe("QWP high-level sender", () => { await sender.close(); }); + it("rejects rather than throwing when flushed after close", async () => { + // The signature promises a Promise, so `sender.flush().catch(handler)` has + // to catch this. A synchronous throw escapes that handler entirely and + // becomes an uncaught exception when the caller is a timer or an event + // handler -- the shape a periodic flush racing shutdown actually has. + const session = new RecordingSession(); + const sender = new QwpSender(async () => session, { autoFlush: false }); + await sender.table("t").intColumn("a", 1).atNow(); + await sender.close(); + + for (const call of [ + () => sender.flush(), + () => sender.flushAndGetSequence(), + () => sender.commit(), + ]) { + let caught: unknown; + // Deliberately not inside try/catch: a synchronous throw would escape. + const settled = call().catch((error: unknown) => { + caught = error; + }); + await settled; + expect(String(caught)).toContain("QWP sender is closed"); + } + }); + + it("loses no rows across back-to-back flushes", async () => { + // The enqueue still has to run synchronously on the call, so two flushes + // issued without awaiting cannot drop or duplicate staged rows. + const session = new RecordingSession(); + const sender = new QwpSender(async () => session, { autoFlush: false }); + await sender.table("t").intColumn("a", 1).atNow(); + const first = sender.flush(); + await sender.table("t").intColumn("a", 2).atNow(); + const second = sender.flush(); + await Promise.all([first, second]); + const delivered = session.sends.reduce( + (total, send) => total + send.tables[0].rowCount, + 0, + ); + expect(delivered).toBe(2); + expect(sender.metrics.pendingRows).toBe(0); + await sender.close(); + }); + it("validates fixed precision and scale when compiling the schema", () => { const sender = new QwpSender(async () => new RecordingSession(), { autoFlush: false, From bd4a2245a8396054662a68db4db9543e75caae7c Mon Sep 17 00:00:00 2001 From: glasstiger Date: Fri, 21 Aug 2026 11:55:02 +0100 Subject: [PATCH 118/265] fix(qwp): cancel a connect still negotiating when close() is called connectingCandidate is assigned only after the connection factory resolves, so close() issued while a connect was still in flight found nothing to cancel and returned immediately. The socket -- already accepted by the peer but not yet answered at the HTTP upgrade -- and its opening deadline stayed alive until that deadline fired: measured at 15s on the defaults, with getActiveResourcesInfo() still reporting a TCPSocketWrap and a Timeout eight seconds after close() had resolved, and the process exiting exactly when authTimeoutMs elapsed. Give each connect attempt an AbortController, abort it from close(), and have openQwpWebSocket tear the pending socket down on that signal. Both the ingress and egress reconnect loops carry the same defect and are fixed together. The listener is removed once the upgrade settles either way, so a long-lived signal cannot accumulate listeners across reconnects. QwpConnectionFactory gains an optional AbortSignal parameter. A factory that declares no parameters stays assignable, so existing implementations and the webSocketFactory test hooks are unaffected. Co-Authored-By: Claude Opus 5 (1M context) --- src/qwp/browser.ts | 17 ++++++++-- src/qwp/internal/egress-routing.ts | 5 ++- src/qwp/internal/failover.ts | 9 +++-- .../reconnecting-egress-connection.ts | 13 ++++++- .../reconnecting-ingress-connection.ts | 20 ++++++++--- src/qwp/internal/websocket-connection.ts | 24 +++++++++++++ src/qwp/node.ts | 6 ++-- src/qwp/transport.ts | 10 +++++- test/qwp/session.test.ts | 34 +++++++++++++++++++ 9 files changed, 123 insertions(+), 15 deletions(-) diff --git a/src/qwp/browser.ts b/src/qwp/browser.ts index 72abd36..a6410c8 100644 --- a/src/qwp/browser.ts +++ b/src/qwp/browser.ts @@ -400,7 +400,8 @@ export function connectQwpBrowserWebSocket( return createQwpFailoverConnectionFactory( options.url, options.failoverUrls, - (endpoint) => connectQwpBrowserRawEndpoint(options, endpoint), + (endpoint, signal) => + connectQwpBrowserRawEndpoint(options, endpoint, signal), )(); } @@ -411,7 +412,8 @@ export function createQwpBrowserConnectionFactory( return createQwpFailoverConnectionFactory( options.url, options.failoverUrls, - (endpoint) => connectQwpBrowserIngressEndpoint(options, endpoint), + (endpoint, signal) => + connectQwpBrowserIngressEndpoint(options, endpoint, signal), ); } @@ -420,6 +422,7 @@ async function connectQwpBrowserEndpoint( endpoint: string | URL, requestEndpoint: string | URL, protocols: string | string[] | undefined, + signal: AbortSignal | undefined, completeHandshake: ( selectedProtocol: string | undefined, ) => QwpBinaryConnection["handshake"], @@ -449,6 +452,7 @@ async function connectQwpBrowserEndpoint( }); const socket = factory(requestEndpoint, protocols); return openQwpWebSocket(socket, { + signal, url: endpoint, connectTimeoutMs: options.connectTimeoutMs, sendTimeoutMs: options.sendTimeoutMs, @@ -474,6 +478,7 @@ function browserNegotiationUrl( function connectQwpBrowserRawEndpoint( options: QwpBrowserWebSocketOptions, endpoint: string | URL, + signal?: AbortSignal, ): Promise { const protocols = options.requestDurableAck ? addQwpDurableAckWebSocketProtocol(options.protocols) @@ -483,6 +488,7 @@ function connectQwpBrowserRawEndpoint( endpoint, endpoint, protocols, + signal, (selectedProtocol) => { const durableAckEnabled = isQwpDurableAckWebSocketProtocol(selectedProtocol); @@ -570,6 +576,7 @@ async function applyQwpBrowserIngressHandshake( async function connectQwpBrowserIngressEndpoint( options: QwpBrowserWebSocketOptions, endpoint: string | URL, + signal?: AbortSignal, ): Promise { const timeoutMs = options.ingressNegotiationTimeoutMs ?? 250; if (!Number.isFinite(timeoutMs) || timeoutMs < 0) { @@ -584,6 +591,7 @@ async function connectQwpBrowserIngressEndpoint( options.requestDurableAck ? addQwpDurableAckWebSocketProtocol(options.protocols) : options.protocols, + signal, (selectedProtocol) => { const durableAckEnabled = isQwpDurableAckWebSocketProtocol(selectedProtocol); @@ -608,6 +616,7 @@ async function connectQwpBrowserIngressEndpoint( function connectQwpBrowserEgressEndpoint( options: QwpBrowserEgressOptions, endpoint: string | URL, + signal?: AbortSignal, ): Promise { const compression = options.compression ?? "raw"; const acceptEncoding = encodeQwpAcceptEncoding( @@ -635,6 +644,7 @@ function connectQwpBrowserEgressEndpoint( endpoint, requestEndpoint, options.protocols, + signal, () => ({ qwpVersion: QWP_VERSION, negotiatedCompression: { codec: "raw", level: 0 }, @@ -702,7 +712,8 @@ export async function connectQwpBrowserEgress( createQwpEgressFailoverConnectionFactory( options.url, options.failoverUrls, - (endpoint) => connectQwpBrowserEgressEndpoint(options, endpoint), + (endpoint, signal) => + connectQwpBrowserEgressEndpoint(options, endpoint, signal), { target: options.target, zone: options.zone }, sessionOptions.serverInfoTimeoutMs ?? QWP_DEFAULT_EGRESS_SERVER_INFO_TIMEOUT_MS, diff --git a/src/qwp/internal/egress-routing.ts b/src/qwp/internal/egress-routing.ts index 4280970..7490714 100644 --- a/src/qwp/internal/egress-routing.ts +++ b/src/qwp/internal/egress-routing.ts @@ -22,7 +22,10 @@ import { export function createQwpEgressFailoverConnectionFactory( preferredUrl: string | URL, failoverUrls: readonly (string | URL)[] | undefined, - connect: (endpoint: string | URL) => Promise, + connect: ( + endpoint: string | URL, + signal?: AbortSignal, + ) => Promise, routing: QwpFailoverSelectionOptions, serverInfoTimeoutMs: number, ): QwpConnectionFactory { diff --git a/src/qwp/internal/failover.ts b/src/qwp/internal/failover.ts index 825f1d1..23d251d 100644 --- a/src/qwp/internal/failover.ts +++ b/src/qwp/internal/failover.ts @@ -219,7 +219,10 @@ export function createQwpFailoverHealthTracker( export function createQwpFailoverConnectionFactory( preferredUrl: string | URL, failoverUrls: readonly (string | URL)[] | undefined, - connect: (endpoint: string | URL) => Promise, + connect: ( + endpoint: string | URL, + signal?: AbortSignal, + ) => Promise, options: QwpFailoverSelectionOptions = {}, ): QwpConnectionFactory { const endpoints = [preferredUrl, ...(failoverUrls ?? [])]; @@ -244,7 +247,7 @@ export function createQwpFailoverConnectionFactory( let deferredEndpoint: number | undefined; let resetClassificationsBeforeSweep = false; - return async (): Promise => { + return async (signal?: AbortSignal): Promise => { if ( resetClassificationsBeforeSweep && resetClassificationsAfterExhaustion @@ -263,7 +266,7 @@ export function createQwpFailoverConnectionFactory( const endpoint = endpoints[index]; let candidate: QwpBinaryConnection | undefined; try { - candidate = await connect(endpoint); + candidate = await connect(endpoint, signal); let validated: QwpValidatedConnection = { connection: candidate, serverRole: candidate.handshake.serverRole, diff --git a/src/qwp/internal/reconnecting-egress-connection.ts b/src/qwp/internal/reconnecting-egress-connection.ts index 078d9a9..c7ecd72 100644 --- a/src/qwp/internal/reconnecting-egress-connection.ts +++ b/src/qwp/internal/reconnecting-egress-connection.ts @@ -67,6 +67,7 @@ export class QwpReconnectingEgressConnection implements QwpBinaryConnection { private readonly resolveClosed: (info: QwpConnectionCloseInfo) => void; private connection?: QwpBinaryConnection; private connectingCandidate?: QwpBinaryConnection; + private connectAbort?: AbortController; private lastHandshake?: QwpHandshakeMetadata; private lastEndpoint?: string | URL; private initialServerInfo?: QwpServerInfoMessage; @@ -175,6 +176,10 @@ export class QwpReconnectingEgressConnection implements QwpBinaryConnection { this.cancelBackoff?.(); this.messagesQueue.end(); const connection = this.connection; + // Tears down a connect that is still negotiating. Without this the socket + // and its deadline outlive close(), keeping the event loop open for up to + // connectTimeoutMs/authTimeoutMs after close() has already resolved. + this.connectAbort?.abort(); const connectingCandidate = this.connectingCandidate; this.connection = undefined; this.connectingCandidate = undefined; @@ -229,7 +234,13 @@ export class QwpReconnectingEgressConnection implements QwpBinaryConnection { attempt++; let candidate: QwpBinaryConnection | undefined; try { - candidate = await this.factory(); + const abort = new AbortController(); + this.connectAbort = abort; + try { + candidate = await this.factory(abort.signal); + } finally { + if (this.connectAbort === abort) this.connectAbort = undefined; + } this.connectingCandidate = candidate; if (this.closing) { await candidate.close().catch(() => undefined); diff --git a/src/qwp/internal/reconnecting-ingress-connection.ts b/src/qwp/internal/reconnecting-ingress-connection.ts index 86b1d09..e0342b8 100644 --- a/src/qwp/internal/reconnecting-ingress-connection.ts +++ b/src/qwp/internal/reconnecting-ingress-connection.ts @@ -339,6 +339,7 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { private readonly resolveClosed: (info: QwpConnectionCloseInfo) => void; private connection?: QwpBinaryConnection; private connectingCandidate?: QwpBinaryConnection; + private connectAbort?: AbortController; private lastHandshake?: QwpHandshakeMetadata; private lastEndpoint?: string | URL; // Wire log for the current connection, indexed by wire sequence minus @@ -765,6 +766,10 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { this.cancelBackoff?.(); this.messagesQueue.end(); const connection = this.connection; + // Tears down a connect that is still negotiating. Without this the socket + // and its deadline outlive close(), keeping the event loop open for up to + // connectTimeoutMs/authTimeoutMs after close() has already resolved. + this.connectAbort?.abort(); const connectingCandidate = this.connectingCandidate; this.connection = undefined; this.connectingCandidate = undefined; @@ -837,10 +842,17 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { if (reconnecting) this.totalReconnectAttempts++; let candidate: QwpBinaryConnection | undefined; try { - candidate = - attempt === 1 && initialConnection - ? await initialConnection - : await this.factory(); + if (attempt === 1 && initialConnection) { + candidate = await initialConnection; + } else { + const abort = new AbortController(); + this.connectAbort = abort; + try { + candidate = await this.factory(abort.signal); + } finally { + if (this.connectAbort === abort) this.connectAbort = undefined; + } + } this.hasEverConnected = true; this.connectingCandidate = candidate; if (this.closing) { diff --git a/src/qwp/internal/websocket-connection.ts b/src/qwp/internal/websocket-connection.ts index 1171705..655313c 100644 --- a/src/qwp/internal/websocket-connection.ts +++ b/src/qwp/internal/websocket-connection.ts @@ -83,6 +83,13 @@ export interface QwpWebSocketOpenOptions { openingFailure?: Promise; /** Browsers hide the HTTP response behind a generic WebSocket error event. */ opaqueErrors?: boolean; + /** + * Tears the pending upgrade down immediately. Without it a close() issued + * while the peer has accepted the TCP connection but not answered the + * upgrade leaves the socket and its deadline alive until that deadline + * fires, which keeps the Node event loop open long after close() resolved. + */ + signal?: AbortSignal; } const WEBSOCKET_OPEN = 1; @@ -382,10 +389,26 @@ export function openQwpWebSocket( if (openingSettled) return; openingSettled = true; if (timeout) clearTimeout(timeout); + options.signal?.removeEventListener("abort", abortOpening); void closeSocket(closeCode, closeReason); reject(error); }; + const abortOpening = (): void => { + failOpening( + new QwpSendClosedError(), + 1000, + "QWP connection closed while connecting", + ); + }; + if (options.signal) { + if (options.signal.aborted) { + abortOpening(); + return; + } + options.signal.addEventListener("abort", abortOpening, { once: true }); + } + armOpeningTimeout( connectTimeoutMs, options.transportConnected @@ -434,6 +457,7 @@ export function openQwpWebSocket( openingSettled = true; opened = true; if (timeout) clearTimeout(timeout); + options.signal?.removeEventListener("abort", abortOpening); const connection: QwpBinaryConnection = { messages, closed, diff --git a/src/qwp/node.ts b/src/qwp/node.ts index 63ec73d..429a335 100644 --- a/src/qwp/node.ts +++ b/src/qwp/node.ts @@ -403,7 +403,7 @@ function createQwpNodeConnectionFactoryInternal( return createQwpFailoverConnectionFactory( options.url, options.failoverUrls, - (endpoint) => connectQwpNodeEndpoint(options, endpoint), + (endpoint, signal) => connectQwpNodeEndpoint(options, endpoint, signal), { healthTracker, resetClassificationsAfterExhaustion }, ); } @@ -411,6 +411,7 @@ function createQwpNodeConnectionFactoryInternal( function connectQwpNodeEndpoint( options: QwpNodeWebSocketOptions, endpoint: string | URL, + signal?: AbortSignal, ): Promise { validateQwpWebSocketTimeouts(options); const clientMaxVersion = options.maxVersion ?? QWP_VERSION; @@ -511,6 +512,7 @@ function connectQwpNodeEndpoint( }); return openQwpWebSocket(socket, { url: endpoint, + signal, connectTimeoutMs: options.connectTimeoutMs, authTimeoutMs: options.authTimeoutMs, transportConnected, @@ -835,7 +837,7 @@ export async function connectQwpNodeEgress( createQwpEgressFailoverConnectionFactory( transport.url, transport.failoverUrls, - (endpoint) => connectQwpNodeEndpoint(transport, endpoint), + (endpoint, signal) => connectQwpNodeEndpoint(transport, endpoint, signal), { target: options.target, zone: options.zone }, sessionOptions.serverInfoTimeoutMs ?? QWP_DEFAULT_EGRESS_SERVER_INFO_TIMEOUT_MS, diff --git a/src/qwp/transport.ts b/src/qwp/transport.ts index e908831..4990f8f 100644 --- a/src/qwp/transport.ts +++ b/src/qwp/transport.ts @@ -549,4 +549,12 @@ export interface QwpWebSocketConnectOptions { closeTimeoutMs?: number; } -export type QwpConnectionFactory = () => Promise; +/** + * Opens one connection. The optional signal is aborted when the owning session + * closes, so a factory that is still negotiating can tear its socket down + * instead of leaving it alive until its own deadline expires. Factories that + * ignore the parameter remain assignable. + */ +export type QwpConnectionFactory = ( + signal?: AbortSignal, +) => Promise; diff --git a/test/qwp/session.test.ts b/test/qwp/session.test.ts index 0d2c7c8..2540e5a 100644 --- a/test/qwp/session.test.ts +++ b/test/qwp/session.test.ts @@ -668,6 +668,40 @@ describe("QWP WebSocket adapters", () => { expect(attempted).toEqual([]); }); + it("tears down a reconnect still negotiating when close() is called", async () => { + // connectingCandidate is assigned only after the factory resolves, so a + // close() issued while the peer has accepted the socket but not answered + // the upgrade used to find nothing to cancel: the socket and its deadline + // stayed alive for up to connectTimeoutMs after close() had resolved. + const sockets: FakeWebSocket[] = []; + const session = await connectQwpBrowserIngress({ + url: "ws://stalls.example/write/v4", + connectTimeoutMs: 30_000, + reconnect: { initialBackoffMs: 0, maxBackoffMs: 0 }, + webSocketFactory: () => { + const socket = new FakeWebSocket(); + sockets.push(socket); + if (sockets.length === 1) { + queueMicrotask(() => { + socket.open(); + socket.message(ingressServerInfo(128)); + }); + } + // Every replacement is left hanging mid-upgrade. + return asQwpSocket(socket); + }, + }); + + sockets[0].close(1006, "dropped"); + await vi.waitFor(() => expect(sockets.length).toBeGreaterThan(1)); + const pending = sockets[sockets.length - 1]; + expect(pending.closeCalls).toEqual([]); + + await session.close(); + // Closed by close(), not left to the 30s connect deadline. + expect(pending.closeCalls.length).toBeGreaterThan(0); + }); + it("uses the browser-selected ingress batch cap automatically", async () => { const socket = new FakeWebSocket(); let capturedUrl: string | URL | undefined; From df1d95228c28b95f27d870419f89b6e90af1196f Mon Sep 17 00:00:00 2001 From: glasstiger Date: Fri, 21 Aug 2026 11:55:59 +0100 Subject: [PATCH 119/265] fix(qwp): close the UDP socket when bind fails QwpNodeUdpSession.close() short-circuited on `!this.bound`, and `bound` is set only inside bind()'s success callback. A bind failure -- EMFILE under fd exhaustion, EACCES in a restricted sandbox, EADDRNOTAVAIL -- therefore reached connect()'s cleanup with `bound` still false and skipped socket.close() entirely. node:dgram does not close the handle itself after a bind error (verified: the handle is still present 300ms later), so every failed connect() leaked one descriptor for the process lifetime, and a reconnect loop retrying after EMFILE compounded the exhaustion it was retrying from. Close unconditionally and treat an already-closed socket as done, which is what the Java client's QwpUdpSender.close() does -- it calls channel.close() without consulting bind state. The other constructor-time failure path was already safe: setMulticastTTL/setMulticastInterface throw after `bound` is true. Co-Authored-By: Claude Opus 5 (1M context) --- src/qwp-node/udp-sender.ts | 20 +++++++++++++------- test/qwp/udp-sender.test.ts | 29 ++++++++++++++++++++++++++++- 2 files changed, 41 insertions(+), 8 deletions(-) diff --git a/src/qwp-node/udp-sender.ts b/src/qwp-node/udp-sender.ts index 833cc24..78547f0 100644 --- a/src/qwp-node/udp-sender.ts +++ b/src/qwp-node/udp-sender.ts @@ -193,16 +193,22 @@ export class QwpNodeUdpSession implements QwpSenderSession { if (this.closePromise) return this.closePromise; this.closing = true; this.closePromise = new Promise((resolve) => { - if (!this.bound) { - this.closed = true; - resolve(); - return; - } - this.socket.close(() => { + // `bound` is set only by a successful bind, but node:dgram keeps the + // handle open after a bind error, so skipping close() here leaked one + // descriptor per failed connect(). Close unconditionally and treat an + // already-closed socket as done, the way the Java client's close() + // closes its channel without consulting bind state. + try { + this.socket.close(() => { + this.bound = false; + this.closed = true; + resolve(); + }); + } catch { this.bound = false; this.closed = true; resolve(); - }); + } }); return this.closePromise; } diff --git a/test/qwp/udp-sender.test.ts b/test/qwp/udp-sender.test.ts index ce2ee30..e64a892 100644 --- a/test/qwp/udp-sender.test.ts +++ b/test/qwp/udp-sender.test.ts @@ -18,8 +18,16 @@ class FakeUdpSocket implements QwpNodeUdpSocketLike { closed = false; private errorListener?: (error: Error) => void; + bindError?: Error; + bind(_port: number, _address: string, callback: () => void): void { - queueMicrotask(callback); + queueMicrotask(() => { + if (this.bindError) { + this.errorListener?.(this.bindError); + return; + } + callback(); + }); } send( @@ -168,6 +176,25 @@ describe("QWP Node UDP sender", () => { await sender.close(); }); + it("closes the socket when bind fails", async () => { + // node:dgram keeps the handle open after a bind error, so a connect() that + // fails on EACCES/EMFILE/EADDRNOTAVAIL used to leak one descriptor -- and a + // reconnect loop retrying after EMFILE compounds the exhaustion it is + // retrying from. + const socket = new FakeUdpSocket(); + socket.bindError = Object.assign(new Error("EACCES: permission denied"), { + code: "EACCES", + }); + + await expect( + connectQwpNodeUdp({ + host: "localhost", + socketFactory: () => socket, + }), + ).rejects.toThrow(/EACCES/); + expect(socket.closed).toBe(true); + }); + it("reports local send failures without retrying fire-and-forget rows", async () => { const socket = new FakeUdpSocket(); socket.sendError = new Error("network unreachable"); From 0092879a82464c28ed0fafa667d1e1123e37153a Mon Sep 17 00:00:00 2001 From: glasstiger Date: Fri, 21 Aug 2026 11:57:39 +0100 Subject: [PATCH 120/265] docs(qwp): state the auto-flush defaults and correct auto_flush_bytes QWP.md's ingress table showed a dash for auto_flush_rows and auto_flush_interval while giving concrete numbers for every sibling key, so a reader had no way to learn that ws:: applies 1000 rows and 100 ms where http:: applies 75000 and 1000 ms. That is 75x smaller batches and 10x more frequent time-triggered flushes for a workload migrated on the one-line change the migration guide recommends, which the behavioral-differences checklist did not mention either. The values themselves match the Java client, which keeps separate DEFAULT_WS_AUTO_FLUSH_ROWS and DEFAULT_WS_AUTO_FLUSH_INTERVAL constants for exactly this reason, so only the documentation was wrong. Both numbers are now pinned by a test that reads them out of QWP.md and asserts the sender flushes on those thresholds, so the table cannot drift from the code again. Separately, SenderOptions' TSDoc claimed auto_flush_bytes "Defaults to off", but udp -- the only transport that accepts the key -- defaults it to max_datagram_size so datagrams flush before outgrowing the limit. Co-Authored-By: Claude Opus 5 (1M context) --- QWP.md | 10 ++++-- src/options.ts | 6 ++-- test/qwp/config-docs.test.ts | 62 +++++++++++++++++++++++++++++++++++- 3 files changed, 73 insertions(+), 5 deletions(-) diff --git a/QWP.md b/QWP.md index af46982..7566679 100644 --- a/QWP.md +++ b/QWP.md @@ -99,9 +99,9 @@ connect string is the portable spelling. | Key | Value | Default | Meaning | | ----------------------------------------------- | ---------------- | --------- | ------------------------------------------------------------------------ | | `auto_flush` | `on`, `off` | on | Master switch for all auto-flush triggers. | -| `auto_flush_rows` | integer | — | Flush after this many staged rows. | +| `auto_flush_rows` | integer | `1000` | Flush after this many staged rows. | | `auto_flush_bytes` | integer or `off` | off | Flush once staged rows reach this estimated size. | -| `auto_flush_interval` | integer ms | — | Flush when this long has passed. Checked as rows are added. | +| `auto_flush_interval` | integer ms | `100` | Flush when this long has passed. Checked as rows are added. | | `close_flush_timeout_millis` | integer ms | `5000` | Bound on `close()`'s ACK drain. `0` or negative is a fast close. | | `transaction` | `on`, `off` | off | Group each flush into a per-table transaction. | | `request_durable_ack` | `on`, `off` | off | Require durable ACKs; fails if the server cannot confirm them. | @@ -1366,6 +1366,12 @@ Review these behavioral differences before rollout: - HTTP/TCP-only keys do not carry over to `ws::`; use the unified QWP connect-string vocabulary. Programmatic callbacks, custom agents, and other non-string hooks remain available under `extraOptions.qwp`. +- Auto-flush defaults differ from the ILP transports, matching the Java client's + separate WebSocket defaults: `auto_flush_rows` is `1000` where `http::` uses + `75000` and `tcp::` uses `600`, and `auto_flush_interval` is `100` ms where both + use `1000` ms. A workload migrated on the one-line change above therefore sends + smaller batches far more often; set both keys explicitly to keep its previous + batching. Roll out `ws::` per sender instance so the existing protocols can remain in service during migration. diff --git a/src/options.ts b/src/options.ts index 15479dc..3795162 100644 --- a/src/options.ts +++ b/src/options.ts @@ -161,9 +161,11 @@ type DeprecatedOptions = { *
  • auto_flush_rows: integer - The number of rows that will trigger a flush. When set to 0, row-based flushing is disabled.
    * The Sender will default this parameter to 75000 rows when HTTP protocol is used, and to 600 in case of TCP protocol. *
  • - *
  • auto_flush_bytes: integer or off - Buffered-byte threshold. Defaults to off.
    + *
  • auto_flush_bytes: integer or off - Buffered-byte threshold.
    * Reaching the threshold flushes after the completed row. This option is supported by udp only; - * on ws/wss it belongs to the QWP configuration schema. + * on ws/wss it belongs to the QWP configuration schema.
    + * Defaults to max_datagram_size, so datagrams are flushed before they outgrow the + * configured limit. Set it to off to disable the byte trigger. *
  • *
  • auto_flush_interval: integer - The number of milliseconds that will trigger a flush, default value is 1000. * When set to 0, interval-based flushing is disabled.
    diff --git a/test/qwp/config-docs.test.ts b/test/qwp/config-docs.test.ts index 9bddfc1..20194fa 100644 --- a/test/qwp/config-docs.test.ts +++ b/test/qwp/config-docs.test.ts @@ -1,7 +1,8 @@ import { readFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; +import { QwpSender, type QwpSenderSession } from "../../src/qwp"; import { QWP_SUPPORTED_CONFIG_KEYS } from "../../src/qwp-node/client-config"; const ROOT = path.resolve( @@ -45,4 +46,63 @@ describe("QWP configuration-string reference", () => { // Guard against the extraction silently matching nothing. expect(listed.size).toBeGreaterThan(50); }); + + it("documents the auto-flush defaults the sender actually applies", async () => { + // These two rows read "—" while every sibling gave a number, so a reader + // had no way to learn that ws:: batches 75x smaller and flushes 10x more + // often than http::. Pin the documented values to real behavior. + const doc = await readFile(path.join(ROOT, "QWP.md"), "utf8"); + const documented = (key: string): number => { + const row = new RegExp( + `^\\| \`${key}\`\\s*\\|[^|]*\\|\\s*\`?(\\d+)\`?\\s*\\|`, + "m", + ).exec(doc); + if (!row) throw new Error(`no numeric default documented for ${key}`); + return Number(row[1]); + }; + const rows = documented("auto_flush_rows"); + const intervalMs = documented("auto_flush_interval"); + + const sends: number[] = []; + const session = { + publishedFrameSequence: -1n, + acknowledgedFrameSequence: -1n, + async publishTables(tables: readonly { rowCount: number }[]) { + sends.push(tables[0].rowCount); + }, + async publishTablesDelta(tables: readonly { rowCount: number }[]) { + sends.push(tables[0].rowCount); + }, + async sendTables() { + return { status: 0, sequence: 0n, tables: [] }; + }, + async waitForDurable() {}, + async close() {}, + } as unknown as QwpSenderSession; + + const byRows = new QwpSender(async () => session); + for (let row = 0; row < rows - 1; row++) { + await byRows.table("t").intColumn("a", row).atNow(); + } + expect(sends).toEqual([]); + await byRows.table("t").intColumn("a", rows).atNow(); + expect(sends).toEqual([rows]); + await byRows.close(); + + sends.length = 0; + vi.useFakeTimers(); + try { + const byInterval = new QwpSender(async () => session); + await byInterval.table("t").intColumn("a", 1).atNow(); + vi.advanceTimersByTime(intervalMs - 1); + await byInterval.table("t").intColumn("a", 2).atNow(); + expect(sends).toEqual([]); + vi.advanceTimersByTime(1); + await byInterval.table("t").intColumn("a", 3).atNow(); + expect(sends).toEqual([3]); + await byInterval.close(); + } finally { + vi.useRealTimers(); + } + }); }); From da1132b25428622ebeb7a8c2c505cc6ca633acf3 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Fri, 21 Aug 2026 12:16:54 +0100 Subject: [PATCH 121/265] refactor(qwp): drop the unreachable resolved-sender constructor branch RESOLVED_QWP_SENDER was a module-private Symbol() that nothing ever assigned: its only three references are the declaration, the type that keys on it, and the constructor branch that reads it. Because it is Symbol() rather than Symbol.for() and is not exported, no caller inside or outside the package can produce a SenderOptions carrying that key, so the branch was unreachable. Remove the symbol, the ResolvedQwpSenderOptions type, and the branch. The remaining ws/wss/udp path is unchanged and is the only way a Sender acquires a QwpSender. Co-Authored-By: Claude Opus 5 (1M context) --- src/sender.ts | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/src/sender.ts b/src/sender.ts index 561bd53..513e6ce 100644 --- a/src/sender.ts +++ b/src/sender.ts @@ -24,11 +24,6 @@ import type { QwpTableWriter } from "./qwp/sender"; import type { QwpWriterSchema } from "./qwp/writer"; const DEFAULT_AUTO_FLUSH_INTERVAL = 1000; // 1 sec -const RESOLVED_QWP_SENDER = Symbol("resolvedQwpSender"); - -type ResolvedQwpSenderOptions = SenderOptions & { - readonly [RESOLVED_QWP_SENDER]: QwpSender; -}; /** * The QuestDB client's API provides methods to connect to the database, ingest data, and close the connection.
    @@ -133,17 +128,6 @@ class Sender { */ constructor(options: SenderOptions) { this.log = options && typeof options.log === "function" ? options.log : log; - const resolvedQwpSender = options - ? (options as Partial)[RESOLVED_QWP_SENDER] - : undefined; - if (resolvedQwpSender) { - this.qwpSender = resolvedQwpSender; - this.autoFlush = false; - this.autoFlushRows = 0; - this.autoFlushInterval = 0; - this.resetAutoFlush(); - return; - } if ( options?.protocol === WS || options?.protocol === WSS || From 2ac690797290aa3ebcb289a7d7b165aee311b402 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Fri, 21 Aug 2026 12:23:09 +0100 Subject: [PATCH 122/265] fix(qwp): keep compiled-writer row typing across published bundles QwpWriterColumn carried its input type on a `unique symbol` key. bunchee emits one self-contained bundle per entry point, so each emitted .d.ts re-declared that symbol, and four nominally distinct keys resulted. The phantom property is optional, so a column built by './qwp' still satisfied './qwp/node''s QwpWriterColumn -- it simply never matched its key, leaving no inference site, so QwpWriterColumnInput fell back to `unknown` and every row field silently accepted anything. That is invisible in this repository: importing from `src/` gives all four entry points one module instance and one symbol, so the in-repo suites and the public API contract typecheck correctly. Only a consumer resolving through package.json `exports` sees the separate declaration files, which is every consumer of the published package -- including the pattern README.md and QWP.md teach. Wrong-typed rows still threw QwpWriterRowError at runtime, so this cost compile-time checking rather than data integrity. Symbol.for() does not help: `declare const x: unique symbol` is nominal per declaration however the value is obtained. Carry the type on a shared property name instead, which resolves structurally across bundles. Co-Authored-By: Claude Opus 5 (1M context) --- src/qwp/writer.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/qwp/writer.ts b/src/qwp/writer.ts index f6396de..50f1cf4 100644 --- a/src/qwp/writer.ts +++ b/src/qwp/writer.ts @@ -34,8 +34,6 @@ export type QwpWriterColumnKind = const QWP_WRITER_COLUMN: unique symbol = Symbol.for( "questdb.qwp.writer.column.v1", ); -// Type-level only: never stamped at runtime, so it needs no shared identity. -const QWP_WRITER_INPUT: unique symbol = Symbol("QWP writer input"); /** Maximum DECIMAL scale of each fixed-width decimal column type. */ export const QWP_DECIMAL_MAX_SCALE = { @@ -56,8 +54,18 @@ export interface QwpWriterColumn< readonly precisionBits?: number; /** DECIMAL scale, fixed for the whole column. */ readonly scale?: number; - /** @internal Carries the input type without adding a runtime value. */ - readonly [QWP_WRITER_INPUT]?: T; + /** + * @internal Carries the input type without adding a runtime value. Never + * assigned, and deliberately a plain property rather than a `unique symbol`: + * each emitted bundle would declare its own symbol, making the key nominally + * distinct per entry point. A column built by './qwp' would then satisfy + * './qwp/node''s QwpWriterColumn without ever matching its phantom key, so + * QwpWriterColumnInput would infer `unknown` and every row field would + * silently accept anything. A shared property name resolves structurally + * across bundles, which is what keeps row typing alive for consumers of the + * published package. + */ + readonly __qwpWriterInput?: T; } interface BrandedQwpWriterColumn From 822da584f9b5d21a2bb3f7f8c4347ec49d3d462c Mon Sep 17 00:00:00 2001 From: glasstiger Date: Fri, 21 Aug 2026 12:23:20 +0100 Subject: [PATCH 123/265] ci: typecheck a consumer against the built declaration files test:dist loads the built bundles but asserts runtime behaviour only, with `any` throughout its helpers, and no step ran tsc over consumer code at all. The compiled writers promise per-column row typing that lives entirely in the emitted .d.ts files, so nothing in CI could observe it: importing from `src/` gives all four entry points one module instance, which is why `pnpm typecheck` and the public API contract pass even when the published types are inert. Add tsconfig.dist-types(.cjs).json, which resolve @questdb/nodejs-client and its three subpaths to the emitted declarations for both the ESM and CJS emits, over a consumer fixture that exercises a compiled writer built from every entry point. Each check is a `@ts-expect-error`, so the gate fails in both directions: a check that stops firing is reported as an unused directive, which is exactly what a collapse of the row input type looks like. Verified against the previous commit's parent: the four value checks report TS2578 there and pass after it. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/build.yml | 7 ++++ .github/workflows/publish.yml | 6 ++++ package.json | 1 + test/dist-types/writer-rows.ts | 65 ++++++++++++++++++++++++++++++++++ tsconfig.dist-types.cjs.json | 11 ++++++ tsconfig.dist-types.json | 18 ++++++++++ 6 files changed, 108 insertions(+) create mode 100644 test/dist-types/writer-rows.ts create mode 100644 tsconfig.dist-types.cjs.json create mode 100644 tsconfig.dist-types.json diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 22810ba..05f271b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -60,6 +60,13 @@ jobs: - name: Built package tests run: pnpm test:dist + # test:dist asserts runtime behaviour only. The compiled writers promise + # per-column row typing, which lives entirely in the emitted .d.ts files + # and is therefore invisible to every suite that imports from `src/`, + # where all four entry points share one module instance. + - name: Type-checking (built package consumer) + run: pnpm typecheck:dist + qwp-browser-e2e: name: QWP browser E2E runs-on: ubuntu-latest diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 0d4d718..171ea1d 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -42,6 +42,12 @@ jobs: - name: Build run: pnpm build + # Guards the emitted .d.ts row typing, which no runtime suite can see. + - name: Type-checking (built package consumer) + run: | + pnpm exec tsc --noEmit -p tsconfig.dist-types.json + pnpm exec tsc --noEmit -p tsconfig.dist-types.cjs.json + # Every subpath in `exports`, not just the root: a publish that omits # ./qwp, ./qwp/browser or ./qwp/node resolves to nothing for consumers. - name: Check for build artifacts diff --git a/package.json b/package.json index 4ac246e..9108343 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "test": "vitest", "test:qwp-browser-e2e": "pnpm build && vitest run --config vitest.qwp-browser.config.ts", "test:dist": "pnpm build && vitest run --config vitest.dist.config.ts", + "typecheck:dist": "pnpm build && tsc --noEmit -p tsconfig.dist-types.json && tsc --noEmit -p tsconfig.dist-types.cjs.json", "build": "bunchee --external fs-ext-extra-prebuilt", "eslint": "eslint src/**", "typecheck": "tsc --noEmit", diff --git a/test/dist-types/writer-rows.ts b/test/dist-types/writer-rows.ts new file mode 100644 index 0000000..18d79a3 --- /dev/null +++ b/test/dist-types/writer-rows.ts @@ -0,0 +1,65 @@ +// Typechecked against the BUILT bundles, not src/. In src/ all four entry +// points share one module instance, so a type identity that only holds within +// a bundle still looks correct there; only a consumer resolving through +// package.json `exports` sees the emitted .d.ts files separately. +// +// Every check below is a `@ts-expect-error`, so this file fails loudly in both +// directions: if a check stops firing, tsc reports the directive as unused +// (TS2578), which is exactly what a collapse of the row input type looks like. +import { Sender } from "@questdb/nodejs-client"; +import { + designatedTimestamp, + double, + long, + symbol, + type QwpWriterRow, +} from "@questdb/nodejs-client/qwp"; +import { createQwpNodeSender } from "@questdb/nodejs-client/qwp/node"; +import { createQwpBrowserSender } from "@questdb/nodejs-client/qwp/browser"; + +const schema = { + ticker: symbol(), + price: double(), + quantity: long(), + timestamp: designatedTimestamp("ns"), +} as const; + +declare const rootSender: Sender; +declare const nodeSender: ReturnType; +declare const browserSender: ReturnType; + +const fromRoot = rootSender.writer("trades", schema); +const fromNode = nodeSender.writer("trades", schema); +const fromBrowser = browserSender.writer("trades", schema); + +for (const trades of [fromRoot, fromNode, fromBrowser]) { + // A correct row must still compile. + void trades.row({ + ticker: "ETH-USD", + price: 2615.54, + quantity: 42n, + timestamp: 1_723_000_000_000_000_000n, + }); + + // @ts-expect-error symbol() accepts only strings. + void trades.row({ ticker: 1, price: 1, quantity: 1n, timestamp: 1n }); + // @ts-expect-error double() does not accept bigint. + void trades.row({ ticker: "a", price: 1n, quantity: 1n, timestamp: 1n }); + // @ts-expect-error long() requires bigint, not number. + void trades.row({ ticker: "a", price: 1, quantity: 1, timestamp: 1n }); + // @ts-expect-error a nanosecond designated timestamp requires bigint. + void trades.row({ ticker: "a", price: 1, quantity: 1n, timestamp: 1 }); + // @ts-expect-error the designated timestamp is required. + void trades.row({ ticker: "a", price: 1, quantity: 1n }); + // @ts-expect-error unknown columns are rejected. + void trades.row({ ticker: "a", price: 1, quantity: 1n, timestamp: 1n, x: 1 }); +} + +// The row type must also be nameable and enforced on its own. +const row: QwpWriterRow = { + ticker: "ETH-USD", + price: 1, + quantity: 1n, + timestamp: 1n, +}; +void row; diff --git a/tsconfig.dist-types.cjs.json b/tsconfig.dist-types.cjs.json new file mode 100644 index 0000000..ac50f5e --- /dev/null +++ b/tsconfig.dist-types.cjs.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.dist-types.json", + "compilerOptions": { + "paths": { + "@questdb/nodejs-client": ["./dist/cjs/index.d.ts"], + "@questdb/nodejs-client/qwp": ["./dist/cjs/qwp/index.d.ts"], + "@questdb/nodejs-client/qwp/node": ["./dist/cjs/qwp/node.d.ts"], + "@questdb/nodejs-client/qwp/browser": ["./dist/cjs/qwp/browser.d.ts"] + } + } +} diff --git a/tsconfig.dist-types.json b/tsconfig.dist-types.json new file mode 100644 index 0000000..9840c76 --- /dev/null +++ b/tsconfig.dist-types.json @@ -0,0 +1,18 @@ +{ + "include": ["test/dist-types"], + "compilerOptions": { + "moduleResolution": "bundler", + "module": "ESNext", + "target": "ES2022", + "lib": ["ES2022", "DOM"], + "types": [], + "strict": true, + "noEmit": true, + "paths": { + "@questdb/nodejs-client": ["./dist/es/index.d.mts"], + "@questdb/nodejs-client/qwp": ["./dist/es/qwp/index.d.mts"], + "@questdb/nodejs-client/qwp/node": ["./dist/es/qwp/node.d.mts"], + "@questdb/nodejs-client/qwp/browser": ["./dist/es/qwp/browser.d.mts"] + } + } +} From 4756867f5deaaa3e6dd3706945cdfd09e8e96e63 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Fri, 21 Aug 2026 13:03:10 +0100 Subject: [PATCH 124/265] test(qwp): pin which published classes can be named in a type position Each entry point emits a self-contained bundle, so a class implemented in src/qwp/** is declared once per bundle. 14 of the 16 classes duplicated between the qwp and qwp/node bundles carry private members, which makes them nominal and their declarations mutually incompatible. qwp/node.d.ts compounds it by re-exporting index's QwpSender wholesale while createQwpNodeSender returns its own local, unexported one, so the importable type and the returned type differ whichever subpath a consumer imports from. Inference is unaffected, which is why nothing caught this: every documented example writes `const sender = await connectQwpNodeSender(...)`. Only explicit annotation breaks -- class fields, parameter and return types -- and no workaround exists, because the correctly-typed declaration is not exported. Record the defect with @ts-expect-error rather than leaving it latent, and pin the two shapes that do work so they cannot regress: inference, and the structural classes with no private members. Collapsing src/qwp/** into one shared chunk gives each class a single declaration, at which point these annotations compile and tsc reports the directives as unused. Co-Authored-By: Claude Opus 5 (1M context) --- test/dist-types/class-identity.ts | 65 +++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 test/dist-types/class-identity.ts diff --git a/test/dist-types/class-identity.ts b/test/dist-types/class-identity.ts new file mode 100644 index 0000000..fd89c23 --- /dev/null +++ b/test/dist-types/class-identity.ts @@ -0,0 +1,65 @@ +// Pins whether the classes a factory returns can be named in a type position +// by a consumer of the published package. +// +// Each entry point emits a self-contained bundle, so a class implemented in +// src/qwp/** is declared once per bundle. A class carrying private members is +// nominal, so those declarations are mutually incompatible -- and qwp/node.d.ts +// re-exports index's QwpSender wholesale (`export * from './index'`) while +// createQwpNodeSender returns its own local, unexported one. The importable +// type and the returned type are therefore different declarations no matter +// which subpath the consumer imports from. +// +// The @ts-expect-error directives below record that defect. When the build +// emits src/qwp/** as one shared chunk, each class collapses to a single +// declaration, these annotations start compiling, and tsc reports the +// directives as unused (TS2578) -- which is the signal to delete them. +import { + connectQwpNodeClient, + createQwpNodeSender, +} from "@questdb/nodejs-client/qwp/node"; +import type { + QwpClient, + QwpSender, + QwpTableWriter, +} from "@questdb/nodejs-client/qwp"; +import { + designatedTimestamp, + QwpUpgradeError, + symbol, +} from "@questdb/nodejs-client/qwp"; + +declare const senderOptions: Parameters[0]; +declare const clientOptions: Parameters[0]; + +// Inference works today and must keep working: this is the documented shape. +const inferred = createQwpNodeSender(senderOptions); +void inferred.flush(); + +// @ts-expect-error known gap: node's QwpSender is a separate declaration. +const annotated: QwpSender = createQwpNodeSender(senderOptions); +void annotated; + +async function annotatedClient(): Promise { + // @ts-expect-error known gap: node's QwpClient is a separate declaration. + const client: QwpClient = await connectQwpNodeClient(clientOptions); + void client; +} +void annotatedClient; + +const schema = { ticker: symbol(), ts: designatedTimestamp("ns") } as const; + +// @ts-expect-error known gap: QwpTableWriter is nominal via its private +// appendRow, so the writer a sender returns cannot be annotated either. +const writer: QwpTableWriter = inferred.writer("trades", schema); +void writer; + +// Inference remains the working shape for writers too. +const inferredWriter = inferred.writer("trades", schema); +void inferredWriter.row({ ticker: "ETH-USD", ts: 1n }); + +// QwpUpgradeError has no private members, so it is structural and annotates +// cleanly today. It must stay that way once the bundles are collapsed. +const upgradeFailure: QwpUpgradeError = new QwpUpgradeError("nope", { + kind: "opaque", +}); +void upgradeFailure; From aabde6e370bd244d94a09b36fbd7441b542bd913 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Fri, 21 Aug 2026 13:11:22 +0100 Subject: [PATCH 125/265] build(qwp): emit src/qwp/** as shared chunks instead of per-entry copies bunchee bundles each entry point self-contained, so the QWP implementation was inlined into ./qwp, ./qwp/node and ./qwp/browser separately. Anything whose identity depends on its declaration site therefore existed three times, and 14 of the 16 classes duplicated between the bundles carry private members, which makes them nominal. qwp/node.d.ts compounded it: it re-exports index's QwpSender wholesale while createQwpNodeSender returns its own local, unexported one, so `const s: QwpSender = createQwpNodeSender(opts)` failed from every subpath with no workaround, because the correctly-typed declaration was not exported anywhere. The same duplication produced the writer-brand defect fixed earlier; this removes the cause rather than another symptom. Move the implementation under underscore-prefixed paths, which is bunchee's shared-module convention, keeping the four entry files where the exports map expects them. The prefix has to be applied at every level -- _core and _internal as well as _qwp -- because a directory without it is inlined into each shared module that imports it, which leaves the duplication in place. Consumers get smaller graphs, since an entry no longer carries code only its siblings need: ./qwp/browser 942 -> 510 kB, ./qwp/node 1112 -> 682 kB, the root ILP entry 1256 -> 826 kB, ./qwp 452 -> 460 kB, and the published runtime total 1747 -> 845 kB. Two things the layout newly requires. dist/_qwp is outside dist/es and dist/cjs, so `files` must ship it -- npm pack omitted 132 files without that, publishing a package whose every entry imports something missing. And the publish artifact check now follows relative imports out of the exports targets, since no exports entry names a chunk. Browser purity is unchanged and verified through the whole chunk graph: 35 modules, none importing a node: builtin or ws. Co-Authored-By: Claude Opus 5 (1M context) --- .claude/skills/review-pr/SKILL.md | 14 +++++----- .github/workflows/publish.yml | 17 +++++++++++- benchmarks/egress.bench.ts | 2 +- benchmarks/encoder.bench.ts | 2 +- benchmarks/sender.bench.ts | 4 +-- benchmarks/tables.ts | 2 +- benchmarks/validate.test.ts | 2 +- package.json | 1 + src/{qwp/core => _qwp/_core}/binds.ts | 0 src/{qwp/core => _qwp/_core}/bytes.ts | 0 src/{qwp/core => _qwp/_core}/compression.ts | 0 src/{qwp/core => _qwp/_core}/constants.ts | 0 src/{qwp/core => _qwp/_core}/durable-ack.ts | 0 src/{qwp/core => _qwp/_core}/egress.ts | 0 src/{qwp/core => _qwp/_core}/errors.ts | 0 src/{qwp/core => _qwp/_core}/frame.ts | 0 src/{qwp/core => _qwp/_core}/gorilla.ts | 0 src/{qwp/core => _qwp/_core}/identifiers.ts | 0 src/{qwp/core => _qwp/_core}/index.ts | 0 src/{qwp/core => _qwp/_core}/ingress.ts | 0 src/{qwp/core => _qwp/_core}/result-batch.ts | 0 .../core => _qwp/_core}/symbol-dictionary.ts | 0 src/{qwp/core => _qwp/_core}/table.ts | 0 src/{qwp/core => _qwp/_core}/varint.ts | 0 src/{qwp/core => _qwp/_core}/zstd.ts | 0 .../_internal}/async-queue.ts | 0 .../_internal}/egress-limits.ts | 2 +- .../_internal}/egress-routing.ts | 2 +- .../internal => _qwp/_internal}/failover.ts | 0 .../_internal}/notification-dispatcher.ts | 0 .../_internal}/reconnect-backoff.ts | 0 .../reconnecting-egress-connection.ts | 2 +- .../reconnecting-ingress-connection.ts | 2 +- .../_internal}/websocket-connection.ts | 2 +- src/{qwp => _qwp}/client.ts | 2 +- src/{qwp => _qwp}/egress-session.ts | 6 ++--- src/{qwp => _qwp}/ingress-session.ts | 6 ++--- src/{qwp => _qwp}/sender-error.ts | 2 +- src/{qwp => _qwp}/sender.ts | 4 +-- src/{qwp => _qwp}/transport.ts | 4 +-- src/{qwp => _qwp}/writer.ts | 0 src/qwp-node/client-config.ts | 10 +++---- src/qwp-node/file-replay-store.ts | 4 +-- src/qwp-node/orphan-drainer.ts | 10 +++---- src/qwp-node/udp-sender.ts | 4 +-- src/qwp/browser.ts | 25 +++++++++-------- src/qwp/index.ts | 18 ++++++------- src/qwp/node.ts | 27 ++++++++++--------- src/sender.ts | 6 ++--- test/dist-types/class-identity.ts | 27 +++++++------------ test/qwp/client.test.ts | 2 +- test/qwp/egress.test.ts | 2 +- test/qwp/notification-dispatcher.test.ts | 2 +- test/qwp/reconnect.test.ts | 6 ++--- tsconfig.qwp-browser.json | 2 +- 55 files changed, 118 insertions(+), 105 deletions(-) rename src/{qwp/core => _qwp/_core}/binds.ts (100%) rename src/{qwp/core => _qwp/_core}/bytes.ts (100%) rename src/{qwp/core => _qwp/_core}/compression.ts (100%) rename src/{qwp/core => _qwp/_core}/constants.ts (100%) rename src/{qwp/core => _qwp/_core}/durable-ack.ts (100%) rename src/{qwp/core => _qwp/_core}/egress.ts (100%) rename src/{qwp/core => _qwp/_core}/errors.ts (100%) rename src/{qwp/core => _qwp/_core}/frame.ts (100%) rename src/{qwp/core => _qwp/_core}/gorilla.ts (100%) rename src/{qwp/core => _qwp/_core}/identifiers.ts (100%) rename src/{qwp/core => _qwp/_core}/index.ts (100%) rename src/{qwp/core => _qwp/_core}/ingress.ts (100%) rename src/{qwp/core => _qwp/_core}/result-batch.ts (100%) rename src/{qwp/core => _qwp/_core}/symbol-dictionary.ts (100%) rename src/{qwp/core => _qwp/_core}/table.ts (100%) rename src/{qwp/core => _qwp/_core}/varint.ts (100%) rename src/{qwp/core => _qwp/_core}/zstd.ts (100%) rename src/{qwp/internal => _qwp/_internal}/async-queue.ts (100%) rename src/{qwp/internal => _qwp/_internal}/egress-limits.ts (86%) rename src/{qwp/internal => _qwp/_internal}/egress-routing.ts (99%) rename src/{qwp/internal => _qwp/_internal}/failover.ts (100%) rename src/{qwp/internal => _qwp/_internal}/notification-dispatcher.ts (100%) rename src/{qwp/internal => _qwp/_internal}/reconnect-backoff.ts (100%) rename src/{qwp/internal => _qwp/_internal}/reconnecting-egress-connection.ts (99%) rename src/{qwp/internal => _qwp/_internal}/reconnecting-ingress-connection.ts (99%) rename src/{qwp/internal => _qwp/_internal}/websocket-connection.ts (99%) rename src/{qwp => _qwp}/client.ts (99%) rename src/{qwp => _qwp}/egress-session.ts (99%) rename src/{qwp => _qwp}/ingress-session.ts (99%) rename src/{qwp => _qwp}/sender-error.ts (98%) rename src/{qwp => _qwp}/sender.ts (99%) rename src/{qwp => _qwp}/transport.ts (99%) rename src/{qwp => _qwp}/writer.ts (100%) diff --git a/.claude/skills/review-pr/SKILL.md b/.claude/skills/review-pr/SKILL.md index e7f0b40..7715fa5 100644 --- a/.claude/skills/review-pr/SKILL.md +++ b/.claude/skills/review-pr/SKILL.md @@ -91,7 +91,7 @@ to `gh`. | **3** | Run the full workflow. Select at most six applicable discovery roles: Agent 1 always; Agent 8 when changed symbols have out-of-diff callers; Agents 2-7 and 14-15 when their domains are touched; Agents 9-13 for changed tests or a fix claim; Agent 10 only when a distinct adversarial pass is warranted. Depth comes from evidence, not agent count. | State the selected level at the start of the review. If defaulted, mention that level -3 exists for a full mission-critical pass. Changes to `src/buffer/**`, `src/qwp/**`, +3 exists for a full mission-critical pass. Changes to `src/buffer/**`, `src/_qwp/**`, `src/qwp-node/**`, transport/auth/TLS, protocol negotiation, flush semantics, or any public entry point (`src/index.ts`, `src/qwp/index.ts`, `src/qwp/node.ts`, `src/qwp/browser.ts`) are high risk; recommend level 3, but honor an explicit lower @@ -217,10 +217,10 @@ At minimum check: - `SenderTransport` plus Undici, stdlib HTTP, and TCP implementations. - `SenderOptions.resolveAuto`, `resolveDeprecated`, config parsing, `fromConfig`, and `fromEnv` for option changes, plus `src/qwp-node/client-config.ts` for QWP keys. -- Changed `src/qwp/core/**` constants and codecs against both the ingress encoder and +- Changed `src/_qwp/_core/**` constants and codecs against both the ingress encoder and the egress decoder; one cap or type byte is normally read by both sides. - `QwpSender` and the writer helpers, `QwpIngressSession`, `QwpEgressSession`, - `QwpClient`, the reconnecting connections in `src/qwp/internal/**`, and the UDP + `QwpClient`, the reconnecting connections in `src/_qwp/_internal/**`, and the UDP sender. - `QwpNodeFileReplayStore`, `QwpNodeOrphanDrainer`, the advisory lock, and the segment maintenance worker for any store-and-forward change. @@ -304,7 +304,7 @@ Record current facts with file/line citations; do not rely on this list becoming `./qwp/browser`, `./qwp/node`), plus which sources each subpath is allowed to import. - ILP protocol default/negotiation and TCP's explicit-version requirement. - QWP `QWP_VERSION`, the `/write/v4` ingress and `/read/v1` egress routes, the caps in - `src/qwp/core/constants.ts`, and the capabilities negotiated per connection. + `src/_qwp/_core/constants.ts`, and the capabilities negotiated per connection. - `worker_threads` use by the segment maintenance worker, and the `Date.now()` / `Math.random()` dependencies in backoff, episode, and timeout accounting that deterministic tests must be able to control. @@ -446,7 +446,7 @@ fails when the production fix is reverted in an isolated scratch worktree. **Agent 14 — QWP wire format and protocol sessions:** Reconstruct frame headers, LEB128 varints, column encodings, Gorilla bit packing, zstd framing, symbol-dictionary IDs with their delta/reset flags, decimal scale, geohash bits, array shape, and NULL -bitmaps against the caps in `src/qwp/core/constants.ts`. Check the ingress encoder and +bitmaps against the caps in `src/_qwp/_core/constants.ts`. Check the ingress encoder and the egress decoder together because both read the same constants. Check status-byte to category to policy mapping, per-table transaction grouping, durable-ACK negotiation, ingress cap splitting, and that a truncated, oversized, or hostile server frame is @@ -539,7 +539,7 @@ Then independently verify Node-client specifics: 10. For test efficacy, prove the assertion reaches the change and would fail under the claimed regression. Recompute expected hex/bytes rather than trusting fixtures. 11. For QWP wire claims, reconstruct the frame bytes for encode and decode, and check - every length, cap, and flag against `src/qwp/core/constants.ts` rather than against + every length, cap, and flag against `src/_qwp/_core/constants.ts` rather than against an assumed peer behavior. 12. For replay, ack, reconnect, or failover claims, trace the cumulative ack watermark and prove which frames a restart, NACK, or non-orderly close resends or drops. @@ -620,7 +620,7 @@ enumerated instance independently rather than sampling and generalizing. ### QWP wire format and sessions - Frame header magic, version, flags, table count, and payload length agree between - encoder and decoder, and every cap in `src/qwp/core/constants.ts` is enforced on both + encoder and decoder, and every cap in `src/_qwp/_core/constants.ts` is enforced on both sides. - Varints stay inside uint64; row, column, name-length, array-element, and dictionary limits are checked on encode and on decode. diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 171ea1d..7039c73 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -50,16 +50,31 @@ jobs: # Every subpath in `exports`, not just the root: a publish that omits # ./qwp, ./qwp/browser or ./qwp/node resolves to nothing for consumers. + # Entry bundles also import shared chunks that no `exports` entry names, + # so the walk follows relative imports: a chunk left out of `files` + # publishes a package whose every entry resolves to a missing file. - name: Check for build artifacts run: | node -e ' const fs = require("node:fs"); const { exports: map } = require("./package.json"); const missing = []; + const path = require("node:path"); + const seen = new Set(); + const walk = (file, from) => { + if (!fs.existsSync(file)) { missing.push(from + " -> " + file); return; } + const key = path.resolve(file); + if (seen.has(key)) return; + seen.add(key); + const source = fs.readFileSync(file, "utf8"); + for (const [, spec] of source.matchAll(/(?:from|require\()\s*['\"](\.[^'\"]+)['\"]/g)) { + walk(path.join(path.dirname(file), spec), file); + } + }; for (const [subpath, conditions] of Object.entries(map)) { for (const target of Object.values(conditions)) { for (const file of Object.values(target)) { - if (!fs.existsSync(file)) missing.push(subpath + " -> " + file); + walk(file, subpath); } } } diff --git a/benchmarks/egress.bench.ts b/benchmarks/egress.bench.ts index a899115..0e43148 100644 --- a/benchmarks/egress.bench.ts +++ b/benchmarks/egress.bench.ts @@ -11,7 +11,7 @@ import { QwpByteWriter, QwpResultBatchDecoder, writeQwpVarint, -} from "../src/qwp/core"; +} from "../src/_qwp/_core"; const ROWS = 10_000; let sink = 0; diff --git a/benchmarks/encoder.bench.ts b/benchmarks/encoder.bench.ts index 327fec0..5267855 100644 --- a/benchmarks/encoder.bench.ts +++ b/benchmarks/encoder.bench.ts @@ -4,7 +4,7 @@ import { QWP_COLUMN_TYPE, QwpSymbolDictionary, QwpTableBuffer, -} from "../src/qwp/core"; +} from "../src/_qwp/_core"; import { floorInternSymbols, floorWriteLongs, diff --git a/benchmarks/sender.bench.ts b/benchmarks/sender.bench.ts index 09d29b2..36a89d5 100644 --- a/benchmarks/sender.bench.ts +++ b/benchmarks/sender.bench.ts @@ -6,8 +6,8 @@ import { type QwpIngressEncodeOptions, type QwpIngressResponse, type QwpTableBuffer, -} from "../src/qwp/core"; -import { QwpSender, type QwpSenderSession } from "../src/qwp/sender"; +} from "../src/_qwp/_core"; +import { QwpSender, type QwpSenderSession } from "../src/_qwp/sender"; import { BENCHMARK_WORKLOADS, type BenchmarkRow } from "./workloads"; const ROWS = 10_000; diff --git a/benchmarks/tables.ts b/benchmarks/tables.ts index 2a62391..e71a08c 100644 --- a/benchmarks/tables.ts +++ b/benchmarks/tables.ts @@ -1,4 +1,4 @@ -import { QWP_COLUMN_TYPE, QwpTableBuffer } from "../src/qwp/core"; +import { QWP_COLUMN_TYPE, QwpTableBuffer } from "../src/_qwp/_core"; import type { BenchmarkRow } from "./workloads"; export function buildBenchmarkTable( diff --git a/benchmarks/validate.test.ts b/benchmarks/validate.test.ts index 8bfe8cf..335034c 100644 --- a/benchmarks/validate.test.ts +++ b/benchmarks/validate.test.ts @@ -4,7 +4,7 @@ import { QWP_COLUMN_TYPE, QwpSymbolDictionary, QwpTableBuffer, -} from "../src/qwp/core"; +} from "../src/_qwp/_core"; import { buildBenchmarkTable } from "./tables"; import { BENCHMARK_WORKLOADS, type BenchmarkRow } from "./workloads"; diff --git a/package.json b/package.json index 9108343..c1070cc 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "files": [ "QWP.md", "THIRD_PARTY_NOTICES.md", + "dist/_qwp", "dist/cjs", "dist/es" ], diff --git a/src/qwp/core/binds.ts b/src/_qwp/_core/binds.ts similarity index 100% rename from src/qwp/core/binds.ts rename to src/_qwp/_core/binds.ts diff --git a/src/qwp/core/bytes.ts b/src/_qwp/_core/bytes.ts similarity index 100% rename from src/qwp/core/bytes.ts rename to src/_qwp/_core/bytes.ts diff --git a/src/qwp/core/compression.ts b/src/_qwp/_core/compression.ts similarity index 100% rename from src/qwp/core/compression.ts rename to src/_qwp/_core/compression.ts diff --git a/src/qwp/core/constants.ts b/src/_qwp/_core/constants.ts similarity index 100% rename from src/qwp/core/constants.ts rename to src/_qwp/_core/constants.ts diff --git a/src/qwp/core/durable-ack.ts b/src/_qwp/_core/durable-ack.ts similarity index 100% rename from src/qwp/core/durable-ack.ts rename to src/_qwp/_core/durable-ack.ts diff --git a/src/qwp/core/egress.ts b/src/_qwp/_core/egress.ts similarity index 100% rename from src/qwp/core/egress.ts rename to src/_qwp/_core/egress.ts diff --git a/src/qwp/core/errors.ts b/src/_qwp/_core/errors.ts similarity index 100% rename from src/qwp/core/errors.ts rename to src/_qwp/_core/errors.ts diff --git a/src/qwp/core/frame.ts b/src/_qwp/_core/frame.ts similarity index 100% rename from src/qwp/core/frame.ts rename to src/_qwp/_core/frame.ts diff --git a/src/qwp/core/gorilla.ts b/src/_qwp/_core/gorilla.ts similarity index 100% rename from src/qwp/core/gorilla.ts rename to src/_qwp/_core/gorilla.ts diff --git a/src/qwp/core/identifiers.ts b/src/_qwp/_core/identifiers.ts similarity index 100% rename from src/qwp/core/identifiers.ts rename to src/_qwp/_core/identifiers.ts diff --git a/src/qwp/core/index.ts b/src/_qwp/_core/index.ts similarity index 100% rename from src/qwp/core/index.ts rename to src/_qwp/_core/index.ts diff --git a/src/qwp/core/ingress.ts b/src/_qwp/_core/ingress.ts similarity index 100% rename from src/qwp/core/ingress.ts rename to src/_qwp/_core/ingress.ts diff --git a/src/qwp/core/result-batch.ts b/src/_qwp/_core/result-batch.ts similarity index 100% rename from src/qwp/core/result-batch.ts rename to src/_qwp/_core/result-batch.ts diff --git a/src/qwp/core/symbol-dictionary.ts b/src/_qwp/_core/symbol-dictionary.ts similarity index 100% rename from src/qwp/core/symbol-dictionary.ts rename to src/_qwp/_core/symbol-dictionary.ts diff --git a/src/qwp/core/table.ts b/src/_qwp/_core/table.ts similarity index 100% rename from src/qwp/core/table.ts rename to src/_qwp/_core/table.ts diff --git a/src/qwp/core/varint.ts b/src/_qwp/_core/varint.ts similarity index 100% rename from src/qwp/core/varint.ts rename to src/_qwp/_core/varint.ts diff --git a/src/qwp/core/zstd.ts b/src/_qwp/_core/zstd.ts similarity index 100% rename from src/qwp/core/zstd.ts rename to src/_qwp/_core/zstd.ts diff --git a/src/qwp/internal/async-queue.ts b/src/_qwp/_internal/async-queue.ts similarity index 100% rename from src/qwp/internal/async-queue.ts rename to src/_qwp/_internal/async-queue.ts diff --git a/src/qwp/internal/egress-limits.ts b/src/_qwp/_internal/egress-limits.ts similarity index 86% rename from src/qwp/internal/egress-limits.ts rename to src/_qwp/_internal/egress-limits.ts index f58c070..1afc651 100644 --- a/src/qwp/internal/egress-limits.ts +++ b/src/_qwp/_internal/egress-limits.ts @@ -1,4 +1,4 @@ -import { QWP_MAX_BATCH_ROWS_UPPER_BOUND } from "../core"; +import { QWP_MAX_BATCH_ROWS_UPPER_BOUND } from "../_core"; export function validateQwpMaxBatchRows( value: number | undefined, diff --git a/src/qwp/internal/egress-routing.ts b/src/_qwp/_internal/egress-routing.ts similarity index 99% rename from src/qwp/internal/egress-routing.ts rename to src/_qwp/_internal/egress-routing.ts index 7490714..5d15aec 100644 --- a/src/qwp/internal/egress-routing.ts +++ b/src/_qwp/_internal/egress-routing.ts @@ -2,7 +2,7 @@ import { decodeQwpEgressMessage, QWP_SERVER_ROLE, QwpProtocolError, -} from "../core"; +} from "../_core"; import { QwpBinaryConnection, QwpConnectionFactory, diff --git a/src/qwp/internal/failover.ts b/src/_qwp/_internal/failover.ts similarity index 100% rename from src/qwp/internal/failover.ts rename to src/_qwp/_internal/failover.ts diff --git a/src/qwp/internal/notification-dispatcher.ts b/src/_qwp/_internal/notification-dispatcher.ts similarity index 100% rename from src/qwp/internal/notification-dispatcher.ts rename to src/_qwp/_internal/notification-dispatcher.ts diff --git a/src/qwp/internal/reconnect-backoff.ts b/src/_qwp/_internal/reconnect-backoff.ts similarity index 100% rename from src/qwp/internal/reconnect-backoff.ts rename to src/_qwp/_internal/reconnect-backoff.ts diff --git a/src/qwp/internal/reconnecting-egress-connection.ts b/src/_qwp/_internal/reconnecting-egress-connection.ts similarity index 99% rename from src/qwp/internal/reconnecting-egress-connection.ts rename to src/_qwp/_internal/reconnecting-egress-connection.ts index c7ecd72..b123121 100644 --- a/src/qwp/internal/reconnecting-egress-connection.ts +++ b/src/_qwp/_internal/reconnecting-egress-connection.ts @@ -3,7 +3,7 @@ import { QWP_EGRESS_MESSAGE, QwpProtocolError, QwpServerInfoMessage, -} from "../core"; +} from "../_core"; import { QWP_RECONNECT_EVENT_KIND, QWP_UPGRADE_ERROR_KIND, diff --git a/src/qwp/internal/reconnecting-ingress-connection.ts b/src/_qwp/_internal/reconnecting-ingress-connection.ts similarity index 99% rename from src/qwp/internal/reconnecting-ingress-connection.ts rename to src/_qwp/_internal/reconnecting-ingress-connection.ts index e0342b8..8212aea 100644 --- a/src/qwp/internal/reconnecting-ingress-connection.ts +++ b/src/_qwp/_internal/reconnecting-ingress-connection.ts @@ -11,7 +11,7 @@ import { QwpProtocolError, qwpVarintSize, utf8Length, -} from "../core"; +} from "../_core"; import { QWP_INITIAL_CONNECT_MODE, QWP_RECONNECT_EVENT_KIND, diff --git a/src/qwp/internal/websocket-connection.ts b/src/_qwp/_internal/websocket-connection.ts similarity index 99% rename from src/qwp/internal/websocket-connection.ts rename to src/_qwp/_internal/websocket-connection.ts index 655313c..468f9ae 100644 --- a/src/qwp/internal/websocket-connection.ts +++ b/src/_qwp/_internal/websocket-connection.ts @@ -1,4 +1,4 @@ -import { QwpProtocolError } from "../core"; +import { QwpProtocolError } from "../_core"; import { QWP_UPGRADE_ERROR_KIND, QWP_UPGRADE_TIMEOUT_PHASE, diff --git a/src/qwp/client.ts b/src/_qwp/client.ts similarity index 99% rename from src/qwp/client.ts rename to src/_qwp/client.ts index 8d1632e..fa220e3 100644 --- a/src/qwp/client.ts +++ b/src/_qwp/client.ts @@ -10,7 +10,7 @@ import { QwpHandshakeMetadata } from "./transport"; import type { QwpNegotiatedEgressCompression, QwpServerInfoMessage, -} from "./core"; +} from "./_core"; const DEFAULT_POOL_MIN = 1; const DEFAULT_POOL_MAX = 4; diff --git a/src/qwp/egress-session.ts b/src/_qwp/egress-session.ts similarity index 99% rename from src/qwp/egress-session.ts rename to src/_qwp/egress-session.ts index 228525b..c97cef2 100644 --- a/src/qwp/egress-session.ts +++ b/src/_qwp/egress-session.ts @@ -17,9 +17,9 @@ import { QwpResultBatchView, QwpResultEndMessage, QwpServerInfoMessage, -} from "./core"; -import { QwpAsyncQueue } from "./internal/async-queue"; -import { QwpReconnectingEgressConnection } from "./internal/reconnecting-egress-connection"; +} from "./_core"; +import { QwpAsyncQueue } from "./_internal/async-queue"; +import { QwpReconnectingEgressConnection } from "./_internal/reconnecting-egress-connection"; import { QwpBinaryConnection, QwpConnectionCloseInfo, diff --git a/src/qwp/ingress-session.ts b/src/_qwp/ingress-session.ts similarity index 99% rename from src/qwp/ingress-session.ts rename to src/_qwp/ingress-session.ts index eb1cfb0..d5d2f00 100644 --- a/src/qwp/ingress-session.ts +++ b/src/_qwp/ingress-session.ts @@ -10,7 +10,7 @@ import { QwpProtocolError, QwpSymbolDictionary, QwpTableBuffer, -} from "./core"; +} from "./_core"; import { QWP_INITIAL_CONNECT_MODE, QwpBinaryConnection, @@ -22,8 +22,8 @@ import { QwpReconnectOptions, QwpReplayDictionaryPersistenceError, } from "./transport"; -import { QwpReconnectingIngressConnection } from "./internal/reconnecting-ingress-connection"; -import { QwpNotificationDispatcher } from "./internal/notification-dispatcher"; +import { QwpReconnectingIngressConnection } from "./_internal/reconnecting-ingress-connection"; +import { QwpNotificationDispatcher } from "./_internal/notification-dispatcher"; import { createQwpSenderError, defaultQwpSenderErrorHandler, diff --git a/src/qwp/sender-error.ts b/src/_qwp/sender-error.ts similarity index 98% rename from src/qwp/sender-error.ts rename to src/_qwp/sender-error.ts index 38f255d..5f34f81 100644 --- a/src/qwp/sender-error.ts +++ b/src/_qwp/sender-error.ts @@ -1,4 +1,4 @@ -import { QWP_STATUS, type QwpIngressResponse } from "./core"; +import { QWP_STATUS, type QwpIngressResponse } from "./_core"; import { log } from "../logging"; export const QWP_SENDER_ERROR_CATEGORY = { diff --git a/src/qwp/sender.ts b/src/_qwp/sender.ts similarity index 99% rename from src/qwp/sender.ts rename to src/_qwp/sender.ts index 006f7b9..759ad45 100644 --- a/src/qwp/sender.ts +++ b/src/_qwp/sender.ts @@ -7,14 +7,14 @@ import { flattenQwpArray, utf8Length, type QwpArrayValue, -} from "./core"; +} from "./_core"; import { QwpBatchTooLargeError, QwpIngressAckTimeoutError, type QwpIngressSendResult, type QwpIngressMetrics, } from "./ingress-session"; -import { qwpColumnNameKey, validateQwpColumnName } from "./core/identifiers"; +import { qwpColumnNameKey, validateQwpColumnName } from "./_core/identifiers"; import { isQwpWriterColumn, QwpWriterRowError, diff --git a/src/qwp/transport.ts b/src/_qwp/transport.ts similarity index 99% rename from src/qwp/transport.ts rename to src/_qwp/transport.ts index 4990f8f..7caee01 100644 --- a/src/qwp/transport.ts +++ b/src/_qwp/transport.ts @@ -1,5 +1,5 @@ -import type { QwpNegotiatedEgressCompression } from "./core/compression"; -import type { QwpServerInfoMessage } from "./core/egress"; +import type { QwpNegotiatedEgressCompression } from "./_core/compression"; +import type { QwpServerInfoMessage } from "./_core/egress"; export interface QwpConnectionCloseInfo { code: number; diff --git a/src/qwp/writer.ts b/src/_qwp/writer.ts similarity index 100% rename from src/qwp/writer.ts rename to src/_qwp/writer.ts diff --git a/src/qwp-node/client-config.ts b/src/qwp-node/client-config.ts index 87f3271..1680c7b 100644 --- a/src/qwp-node/client-config.ts +++ b/src/qwp-node/client-config.ts @@ -7,11 +7,11 @@ import type { QwpNodeIngressOptions, QwpNodeStoreAndForwardOptions, } from "../qwp/node"; -import type { QwpClientPoolOptions } from "../qwp/client"; -import type { QwpEgressSessionOptions } from "../qwp/egress-session"; -import type { QwpIngressSessionOptions } from "../qwp/ingress-session"; -import type { QwpSenderOptions } from "../qwp/sender"; -import type { QwpReconnectOptions, QwpTarget } from "../qwp/transport"; +import type { QwpClientPoolOptions } from "../_qwp/client"; +import type { QwpEgressSessionOptions } from "../_qwp/egress-session"; +import type { QwpIngressSessionOptions } from "../_qwp/ingress-session"; +import type { QwpSenderOptions } from "../_qwp/sender"; +import type { QwpReconnectOptions, QwpTarget } from "../_qwp/transport"; const DEFAULT_QWP_PORT = 9000; const MAX_BATCH_ROWS = 1_048_576; diff --git a/src/qwp-node/file-replay-store.ts b/src/qwp-node/file-replay-store.ts index d73c55c..b08cac0 100644 --- a/src/qwp-node/file-replay-store.ts +++ b/src/qwp-node/file-replay-store.ts @@ -11,12 +11,12 @@ import { } from "node:fs/promises"; import type { FileHandle } from "node:fs/promises"; import { basename, dirname, join } from "node:path"; -import { QWP_MAX_SYMBOL_DICTIONARY_SIZE } from "../qwp/core"; +import { QWP_MAX_SYMBOL_DICTIONARY_SIZE } from "../_qwp/_core"; import { QwpIngressReplayRecord, QwpIngressReplayReference, QwpIngressReplayStore, -} from "../qwp/transport"; +} from "../_qwp/transport"; import { QwpNodeAdvisoryLock, QwpNodeAdvisoryLockBusyError, diff --git a/src/qwp-node/orphan-drainer.ts b/src/qwp-node/orphan-drainer.ts index 6db0266..b1fc4c6 100644 --- a/src/qwp-node/orphan-drainer.ts +++ b/src/qwp-node/orphan-drainer.ts @@ -7,20 +7,20 @@ import { QwpConnectionCloseInfo, QwpIngressTransportMetrics, QwpUpgradeError, -} from "../qwp/transport"; +} from "../_qwp/transport"; import { isQwpNodeReplayQuarantineSlotName, QwpReplayStoreCorruptionError, QwpReplayStoreLockedError, } from "./file-replay-store"; -import { QwpProtocolError } from "../qwp/core/errors"; -import { QwpDurableAckPersistentFailureError } from "../qwp/internal/reconnecting-ingress-connection"; -import { QwpNotificationDispatcher } from "../qwp/internal/notification-dispatcher"; +import { QwpProtocolError } from "../_qwp/_core/errors"; +import { QwpDurableAckPersistentFailureError } from "../_qwp/_internal/reconnecting-ingress-connection"; +import { QwpNotificationDispatcher } from "../_qwp/_internal/notification-dispatcher"; import { createQwpDataLossSenderError, defaultQwpSenderErrorHandler, type QwpSenderError, -} from "../qwp/sender-error"; +} from "../_qwp/sender-error"; const SEGMENT_SUFFIX = ".sfa"; const SEGMENT_HEADER_SIZE = 24; diff --git a/src/qwp-node/udp-sender.ts b/src/qwp-node/udp-sender.ts index 78547f0..981a11e 100644 --- a/src/qwp-node/udp-sender.ts +++ b/src/qwp-node/udp-sender.ts @@ -4,8 +4,8 @@ import { type QwpIngressEncodeOptions, type QwpIngressResponse, type QwpTableBuffer, -} from "../qwp/core"; -import type { QwpSenderSession } from "../qwp/sender"; +} from "../_qwp/_core"; +import type { QwpSenderSession } from "../_qwp/sender"; const DEFAULT_QWP_UDP_PORT = 9007; const DEFAULT_MAX_DATAGRAM_SIZE = 1_400; diff --git a/src/qwp/browser.ts b/src/qwp/browser.ts index a6410c8..2561619 100644 --- a/src/qwp/browser.ts +++ b/src/qwp/browser.ts @@ -5,10 +5,10 @@ import { openQwpWebSocket, QwpWebSocketLike, validateQwpWebSocketTimeouts, -} from "./internal/websocket-connection"; -import { createQwpFailoverConnectionFactory } from "./internal/failover"; -import { createQwpEgressFailoverConnectionFactory } from "./internal/egress-routing"; -import { validateQwpMaxBatchRows } from "./internal/egress-limits"; +} from "../_qwp/_internal/websocket-connection"; +import { createQwpFailoverConnectionFactory } from "../_qwp/_internal/failover"; +import { createQwpEgressFailoverConnectionFactory } from "../_qwp/_internal/egress-routing"; +import { validateQwpMaxBatchRows } from "../_qwp/_internal/egress-limits"; import { addQwpDurableAckWebSocketProtocol, decodeQwpIngressServerInfo, @@ -16,7 +16,7 @@ import { isQwpDurableAckWebSocketProtocol, QwpEgressCompression, QWP_VERSION, -} from "./core"; +} from "../_qwp/_core"; import { QwpBinaryConnection, QwpConnectionFactory, @@ -25,17 +25,20 @@ import { QWP_UPGRADE_ERROR_KIND, QwpUpgradeError, QwpWebSocketConnectOptions, -} from "./transport"; +} from "../_qwp/transport"; import { QWP_DEFAULT_EGRESS_SERVER_INFO_TIMEOUT_MS, QwpEgressSession, QwpEgressSessionOptions, -} from "./egress-session"; -import { QwpIngressSession, QwpIngressSessionOptions } from "./ingress-session"; -import { QwpSender, QwpSenderOptions } from "./sender"; -import { QwpClient, QwpClientPoolOptions } from "./client"; +} from "../_qwp/egress-session"; +import { + QwpIngressSession, + QwpIngressSessionOptions, +} from "../_qwp/ingress-session"; +import { QwpSender, QwpSenderOptions } from "../_qwp/sender"; +import { QwpClient, QwpClientPoolOptions } from "../_qwp/client"; -export type { QwpWebSocketLike } from "./internal/websocket-connection"; +export type { QwpWebSocketLike } from "../_qwp/_internal/websocket-connection"; export type QwpBrowserSessionAuthentication = | { diff --git a/src/qwp/index.ts b/src/qwp/index.ts index 7a8034c..0073d50 100644 --- a/src/qwp/index.ts +++ b/src/qwp/index.ts @@ -6,13 +6,13 @@ * * @packageDocumentation */ -export * from "./core"; -export * from "./client"; -export * from "./egress-session"; -export * from "./ingress-session"; -export * from "./sender"; -export * from "./sender-error"; -export * from "./transport"; +export * from "../_qwp/_core"; +export * from "../_qwp/client"; +export * from "../_qwp/egress-session"; +export * from "../_qwp/ingress-session"; +export * from "../_qwp/sender"; +export * from "../_qwp/sender-error"; +export * from "../_qwp/transport"; export { binary, bool, @@ -41,7 +41,7 @@ export { varchar, QWP_DECIMAL_MAX_SCALE, QwpWriterRowError, -} from "./writer"; +} from "../_qwp/writer"; export type { QwpDecimalInput, QwpDoubleArrayInput, @@ -58,4 +58,4 @@ export type { QwpWriterColumnKind, QwpWriterRow, QwpWriterSchema, -} from "./writer"; +} from "../_qwp/writer"; diff --git a/src/qwp/node.ts b/src/qwp/node.ts index 429a335..fb8dd2a 100644 --- a/src/qwp/node.ts +++ b/src/qwp/node.ts @@ -11,19 +11,19 @@ import { encodeQwpAcceptEncoding, QWP_VERSION, type QwpEgressCompression, -} from "./core"; +} from "../_qwp/_core"; import { openQwpWebSocket, QwpWebSocketLike, validateQwpWebSocketTimeouts, -} from "./internal/websocket-connection"; +} from "../_qwp/_internal/websocket-connection"; import { createQwpFailoverConnectionFactory, createQwpFailoverHealthTracker, QwpFailoverHealthTracker, -} from "./internal/failover"; -import { createQwpEgressFailoverConnectionFactory } from "./internal/egress-routing"; -import { validateQwpMaxBatchRows } from "./internal/egress-limits"; +} from "../_qwp/_internal/failover"; +import { createQwpEgressFailoverConnectionFactory } from "../_qwp/_internal/egress-routing"; +import { validateQwpMaxBatchRows } from "../_qwp/_internal/egress-limits"; import { resolveQwpNodeClientConfig } from "../qwp-node/client-config"; import { QWP_INITIAL_CONNECT_MODE, @@ -38,24 +38,27 @@ import { QwpUnrecoverableReplayDictionaryError, QwpUpgradeError, QwpWebSocketConnectOptions, -} from "./transport"; +} from "../_qwp/transport"; import { QWP_DEFAULT_EGRESS_SERVER_INFO_TIMEOUT_MS, QwpEgressSession, QwpEgressSessionOptions, -} from "./egress-session"; -import { QwpIngressSession, QwpIngressSessionOptions } from "./ingress-session"; +} from "../_qwp/egress-session"; +import { + QwpIngressSession, + QwpIngressSessionOptions, +} from "../_qwp/ingress-session"; import { createQwpDataLossSenderError, defaultQwpSenderErrorHandler, type QwpSenderError, -} from "./sender-error"; -import { QwpSender, QwpSenderOptions } from "./sender"; +} from "../_qwp/sender-error"; +import { QwpSender, QwpSenderOptions } from "../_qwp/sender"; import { QwpClient, QwpClientPoolOptions, type QwpPoolSlotReservation, -} from "./client"; +} from "../_qwp/client"; import { quarantineQwpNodeReplayStore, QwpNodeFileReplayStore, @@ -119,7 +122,7 @@ export type { QwpNodeOrphanDrainerOptions, } from "../qwp-node/orphan-drainer"; -export type { QwpWebSocketLike } from "./internal/websocket-connection"; +export type { QwpWebSocketLike } from "../_qwp/_internal/websocket-connection"; export class QwpVersionMismatchError extends QwpUpgradeError { constructor( diff --git a/src/sender.ts b/src/sender.ts index 513e6ce..59aa5ab 100644 --- a/src/sender.ts +++ b/src/sender.ts @@ -14,14 +14,14 @@ import { import { SenderTransport, createTransport } from "./transport"; import { SenderBuffer, createBuffer } from "./buffer"; import { isBoolean, isInteger, TimestampUnit } from "./utils"; -import { QWP_INGRESS_PATH } from "./qwp/core"; +import { QWP_INGRESS_PATH } from "./_qwp/_core"; import { createQwpNodeSender, createQwpNodeUdpSender, QwpSender, } from "./qwp/node"; -import type { QwpTableWriter } from "./qwp/sender"; -import type { QwpWriterSchema } from "./qwp/writer"; +import type { QwpTableWriter } from "./_qwp/sender"; +import type { QwpWriterSchema } from "./_qwp/writer"; const DEFAULT_AUTO_FLUSH_INTERVAL = 1000; // 1 sec diff --git a/test/dist-types/class-identity.ts b/test/dist-types/class-identity.ts index fd89c23..13915ec 100644 --- a/test/dist-types/class-identity.ts +++ b/test/dist-types/class-identity.ts @@ -1,18 +1,11 @@ -// Pins whether the classes a factory returns can be named in a type position -// by a consumer of the published package. +// Pins that the classes a factory returns can be named in a type position by a +// consumer of the published package. // -// Each entry point emits a self-contained bundle, so a class implemented in -// src/qwp/** is declared once per bundle. A class carrying private members is -// nominal, so those declarations are mutually incompatible -- and qwp/node.d.ts -// re-exports index's QwpSender wholesale (`export * from './index'`) while -// createQwpNodeSender returns its own local, unexported one. The importable -// type and the returned type are therefore different declarations no matter -// which subpath the consumer imports from. -// -// The @ts-expect-error directives below record that defect. When the build -// emits src/qwp/** as one shared chunk, each class collapses to a single -// declaration, these annotations start compiling, and tsc reports the -// directives as unused (TS2578) -- which is the signal to delete them. +// src/_qwp/** is emitted as shared chunks rather than inlined per entry, so +// each class is declared exactly once across the four bundles. Were an entry to +// start inlining them again, its declaration would be a second, nominally +// distinct one -- every class here carries private members -- and these +// annotations would stop compiling. import { connectQwpNodeClient, createQwpNodeSender, @@ -35,12 +28,10 @@ declare const clientOptions: Parameters[0]; const inferred = createQwpNodeSender(senderOptions); void inferred.flush(); -// @ts-expect-error known gap: node's QwpSender is a separate declaration. const annotated: QwpSender = createQwpNodeSender(senderOptions); void annotated; async function annotatedClient(): Promise { - // @ts-expect-error known gap: node's QwpClient is a separate declaration. const client: QwpClient = await connectQwpNodeClient(clientOptions); void client; } @@ -48,8 +39,8 @@ void annotatedClient; const schema = { ticker: symbol(), ts: designatedTimestamp("ns") } as const; -// @ts-expect-error known gap: QwpTableWriter is nominal via its private -// appendRow, so the writer a sender returns cannot be annotated either. +// QwpTableWriter is nominal via its private appendRow, so this only compiles +// while the writer a sender returns comes from the same declaration. const writer: QwpTableWriter = inferred.writer("trades", schema); void writer; diff --git a/test/qwp/client.test.ts b/test/qwp/client.test.ts index 715eacd..f3b2b7c 100644 --- a/test/qwp/client.test.ts +++ b/test/qwp/client.test.ts @@ -23,7 +23,7 @@ import { long, symbol as qwpSymbol, } from "../../src/qwp"; -import { QwpAsyncQueue } from "../../src/qwp/internal/async-queue"; +import { QwpAsyncQueue } from "../../src/_qwp/_internal/async-queue"; function writeString(writer: QwpByteWriter, value: string): void { const encoded = new TextEncoder().encode(value); diff --git a/test/qwp/egress.test.ts b/test/qwp/egress.test.ts index 1aa692e..7e3ad29 100644 --- a/test/qwp/egress.test.ts +++ b/test/qwp/egress.test.ts @@ -29,7 +29,7 @@ import { readQwpVarint, writeQwpVarint, } from "../../src/qwp"; -import { QwpAsyncQueue } from "../../src/qwp/internal/async-queue"; +import { QwpAsyncQueue } from "../../src/_qwp/_internal/async-queue"; const RESULT_FLAGS = QWP_FLAG_DELTA_SYMBOL_DICTIONARY | QWP_FLAG_GORILLA; diff --git a/test/qwp/notification-dispatcher.test.ts b/test/qwp/notification-dispatcher.test.ts index 0df868c..0917101 100644 --- a/test/qwp/notification-dispatcher.test.ts +++ b/test/qwp/notification-dispatcher.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { QwpNotificationDispatcher } from "../../src/qwp/internal/notification-dispatcher"; +import { QwpNotificationDispatcher } from "../../src/_qwp/_internal/notification-dispatcher"; describe("QwpNotificationDispatcher", () => { it("delivers outside the protocol call stack in FIFO order", async () => { diff --git a/test/qwp/reconnect.test.ts b/test/qwp/reconnect.test.ts index 47b7a1a..661bb8c 100644 --- a/test/qwp/reconnect.test.ts +++ b/test/qwp/reconnect.test.ts @@ -73,13 +73,13 @@ import { decodeQwpIngressSymbolDictionaryDelta, writeQwpVarint, } from "../../src/qwp"; -import { QwpAsyncQueue } from "../../src/qwp/internal/async-queue"; +import { QwpAsyncQueue } from "../../src/_qwp/_internal/async-queue"; import { qwpSegmentMaintenanceWorker } from "../../src/qwp-node/segment-maintenance-worker"; -import { createQwpEgressFailoverConnectionFactory } from "../../src/qwp/internal/egress-routing"; +import { createQwpEgressFailoverConnectionFactory } from "../../src/_qwp/_internal/egress-routing"; import { createQwpFailoverConnectionFactory, createQwpFailoverHealthTracker, -} from "../../src/qwp/internal/failover"; +} from "../../src/_qwp/_internal/failover"; function nativeFlock(fd: number, operation: "exnb" | "un"): Promise { return new Promise((resolve, reject) => { diff --git a/tsconfig.qwp-browser.json b/tsconfig.qwp-browser.json index f796f63..23469db 100644 --- a/tsconfig.qwp-browser.json +++ b/tsconfig.qwp-browser.json @@ -1,5 +1,5 @@ { - "include": ["src/qwp/**/*.ts"], + "include": ["src/_qwp/**/*.ts", "src/qwp/**/*.ts"], "exclude": ["src/qwp/node.ts"], "compilerOptions": { "moduleResolution": "bundler", From 23994269d4898be27622c4d7afc0a399992c7a8a Mon Sep 17 00:00:00 2001 From: glasstiger Date: Fri, 21 Aug 2026 13:25:41 +0100 Subject: [PATCH 126/265] test(qwp): cover failover in a real browser The C8 defect existed because the only failover coverage injected a factory throw carrying tryNextEndpoint: true -- a shape a real browser WebSocket cannot produce, since a browser never sees the HTTP response. The unit test added with the fix drives a bare error event through a fake socket, which is closer but still an approximation. Drive real Chromium at a genuinely refused port instead. A probe confirms the browser's own classification is `kind: "opaque"` with retryable and tryNextEndpoint both absent, which is exactly the tri-state the sweep reads, so the endpoint list is walked under the real conditions rather than a modelled one. Verified load-bearing: reverting the failover guard fails this test. The asset server is re-rooted at dist/ because the browser bundle now imports shared chunks from dist/_qwp; serving only dist/es/qwp 403s every entry import, which broke all eleven browser tests until this was fixed. Co-Authored-By: Claude Opus 5 (1M context) --- test/qwp/browser.e2e.ts | 65 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 63 insertions(+), 2 deletions(-) diff --git a/test/qwp/browser.e2e.ts b/test/qwp/browser.e2e.ts index 3e75214..716b33a 100644 --- a/test/qwp/browser.e2e.ts +++ b/test/qwp/browser.e2e.ts @@ -63,8 +63,10 @@ function closeWebSocketServer(server: WebSocketServer): Promise { }); } +// Rooted at dist/, not dist/es/qwp: the browser bundle imports shared chunks +// from dist/_qwp, so serving only its own directory 403s every entry import. function createModuleServer(): Server { - const moduleRoot = path.resolve(process.cwd(), "dist/es/qwp"); + const moduleRoot = path.resolve(process.cwd(), "dist"); return createServer(async (request, response) => { try { const requestUrl = new URL(request.url ?? "/", "http://127.0.0.1"); @@ -171,7 +173,7 @@ describe("QWP in a real browser", () => { assetServer = createModuleServer(); await listen(assetServer); const address = assetServer.address() as AddressInfo; - assetUrl = `http://127.0.0.1:${address.port}/browser.mjs`; + assetUrl = `http://127.0.0.1:${address.port}/es/qwp/browser.mjs`; browser = await chromium.launch({ channel: process.env.QWP_BROWSER_CHANNEL, @@ -305,6 +307,65 @@ describe("QWP in a real browser", () => { } }); + it("walks failoverUrls when the preferred endpoint refuses, in a real browser", async () => { + // A browser never sees the HTTP response, so a refused connection surfaces + // as a bare error event that openQwpWebSocket classifies `opaque` with + // tryNextEndpoint left undefined. The fake-socket coverage in + // session.test.ts can only approximate that shape; this drives real + // Chromium at a genuinely refused port so the classification is the + // browser's own, which is what the previous failover coverage could not do + // -- it injected a factory throw carrying tryNextEndpoint: true, which no + // real browser WebSocket produces. + const healthy = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + let healthyConnections = 0; + healthy.on("connection", (socket) => { + healthyConnections++; + socket.send(browserIngressServerInfo(1_048_576)); + }); + await waitForWebSocketServer(healthy); + const healthyPort = (healthy.address() as AddressInfo).port; + + // Bind and release a port so the preferred endpoint reliably refuses. + const vacated = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + await waitForWebSocketServer(vacated); + const refusedPort = (vacated.address() as AddressInfo).port; + await closeWebSocketServer(vacated); + + const page = await browser.newPage(); + try { + await page.goto(assetUrl); + const handshake = await page.evaluate( + async ({ moduleUrl, url, failoverUrls }) => { + const importModule = new Function("url", "return import(url)") as ( + url: string, + ) => Promise>; + const qwp = await importModule(moduleUrl); + const connection = await qwp.connectQwpBrowserIngress({ + url, + failoverUrls, + }); + try { + return connection.handshake; + } finally { + await connection.close(); + } + }, + { + moduleUrl: assetUrl, + url: `ws://127.0.0.1:${refusedPort}/write/v4`, + failoverUrls: [`ws://127.0.0.1:${healthyPort}/write/v4`], + }, + ); + + // The sweep reached the secondary rather than stopping at the refusal. + expect(healthyConnections).toBe(1); + expect(handshake).toMatchObject({ qwpVersion: 1 }); + } finally { + await page.close(); + await closeWebSocketServer(healthy); + } + }); + it("falls back to raw when an older egress server ignores compression", async () => { const server = new WebSocketServer({ host: "127.0.0.1", port: 0 }); server.on("connection", (socket) => socket.send(browserServerInfo())); From a543a4f3f2cb4a17100c322aa2254d21ce51c9b9 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Fri, 21 Aug 2026 15:05:55 +0100 Subject: [PATCH 127/265] ci: allow the build workflow to be dispatched manually GitHub disabled this workflow for repository inactivity, which also stopped it firing on pull requests, so the PR gates were silently not running. Manual dispatch is the recovery path that does not require pushing a commit to a branch. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/build.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 05f271b..61aea99 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -7,6 +7,10 @@ on: pull_request: schedule: - cron: "15 2,10,18 * * *" + # GitHub disables a scheduled workflow after 60 days of repository + # inactivity, which stops it running on pull requests too. Dispatch is + # how to re-run it after that without pushing to a branch. + workflow_dispatch: jobs: test: From 5225cdd2636b75bca7afeef47220aae7ba2c81ec Mon Sep 17 00:00:00 2001 From: glasstiger Date: Fri, 21 Aug 2026 18:11:30 +0100 Subject: [PATCH 128/265] refactor(qwp): replace the native slot lock with a pure-JS directory lock fs-ext-extra-prebuilt is a NAN addon, so it needs a fresh binary for every Node major and ships none past Node 25. Store-and-forward therefore stopped working entirely on Node 26 rather than degrading. Ownership now comes from a `.lock.owner` directory: mkdir is the only exclusive-by-construction filesystem operation available on every supported platform without a native addon. A kernel lock vanished when its holder died, so a heartbeat replaces that: the holder refreshes the directory mtime every 5s and a contender reclaims a slot idle for 15s, or immediately when the recorded PID is gone from the same host. Stale directories are renamed aside before removal so two contenders cannot both win one slot. This drops the guarantee that a Java and a Node client exclude each other on one directory, because Java uses flock/LockFileEx and nothing pure-JS can participate in those. The persistence format stays cross-client for sequential handoff; only concurrent cross-runtime access is now unsupported, and QWP.md states that explicitly. `.lock` and `.lock.pid` are still written so a slot keeps the on-disk shape Java expects. QwpReplayStoreUnavailableError is removed: with no optional native module, nothing can be unavailable. The two tests asserting contention with a real Java flock are removed rather than inverted, since simulating a flock holder required the dependency being dropped; stale-reclaim tests replace them. Co-Authored-By: Claude Opus 5 (1M context) --- .claude/skills/review-pr/SKILL.md | 17 +- QWP.md | 44 ++-- THIRD_PARTY_NOTICES.md | 21 -- package.json | 5 +- pnpm-lock.yaml | 19 -- src/qwp-node/advisory-lock.ts | 329 +++++++++++++++++++++--------- src/qwp-node/file-replay-store.ts | 26 --- src/qwp/node.ts | 1 - test/qwp/dist.e2e.ts | 64 +++--- test/qwp/public-api.test.ts | 1 - test/qwp/reconnect.test.ts | 130 ++++++------ 11 files changed, 360 insertions(+), 297 deletions(-) diff --git a/.claude/skills/review-pr/SKILL.md b/.claude/skills/review-pr/SKILL.md index 7715fa5..7f5bb93 100644 --- a/.claude/skills/review-pr/SKILL.md +++ b/.claude/skills/review-pr/SKILL.md @@ -291,15 +291,14 @@ Record current facts with file/line citations; do not rely on this list becoming - TypeScript flags from `tsconfig.json`, especially `strictNullChecks`, `noImplicitAny`, and `noUncheckedIndexedAccess`. - Node.js version floor and `@types/node` version. -- Runtime dependencies and what each covers: `undici` (HTTP), `ws` (Node QWP - WebSocket), and the native `fs-ext-extra-prebuilt` advisory locks used by - store-and-forward. That last one is an `optionalDependency` that `advisory-lock.ts` - reaches through a lazy `import()`; a static top-level import would restore an eager - native load for every ILP consumer. bunchee externalizes only `dependencies` and - `peerDependencies`, so `--external fs-ext-extra-prebuilt` in the `build` script is - load-bearing — without it the module is inlined and its native binary lookup - breaks at runtime. `fzstd` is a devDependency that the bundler inlines; making it an - external import would break installs. +- Runtime dependencies and what each covers: `undici` (HTTP) and `ws` (Node QWP + WebSocket). There is no native dependency: store-and-forward locking is pure + JavaScript in `advisory-lock.ts`, using a `.lock.owner` directory as the mutex with + an mtime heartbeat for stale recovery. Reintroducing a native addon would break + every consumer on a platform or Node major it has no binary for, so treat a new + `optionalDependencies` entry or a compiled binary in the bundle as a finding. + `fzstd` is a devDependency that the bundler inlines; making it an external import + would break installs. - Dual ESM/CJS build and every `package.json` exports subpath (`.`, `./qwp`, `./qwp/browser`, `./qwp/node`), plus which sources each subpath is allowed to import. - ILP protocol default/negotiation and TCP's explicit-version requirement. diff --git a/QWP.md b/QWP.md index 7566679..93df938 100644 --- a/QWP.md +++ b/QWP.md @@ -332,26 +332,35 @@ fresh symbol-ID space. A partially drained close retains the dictionary required the surviving frames. The journal takes an exclusive lock when it is loaded and holds it until the sender -or session closes. A second live process using the same directory fails with +or session closes. A second live Node.js process using the same directory fails with `QwpReplayStoreLockedError` before recovery or cleanup can mutate journal contents. -The stable `.lock` file is protected by `flock` on Unix and `LockFileEx` on Windows, -with the holder PID recorded in `.lock.pid` for diagnostics. These are the same files -and native lock primitives used by the Java client, so Java and Node processes cannot -simultaneously own one slot. The kernel releases the lock when a process terminates; -the lock and PID files deliberately remain so their inode is never replaced beneath a -live owner, and the next holder refreshes the PID sidecar. Short-lived locks under the -shared parent directory's `.slot-locks` child also match Java and serialize orphan +Ownership is held by a `.lock.owner` directory created next to the slot: `mkdir` is +the only exclusive-by-construction filesystem operation available on every supported +platform without a native addon, so exactly one process can create it. The holder PID +is recorded in `.lock.pid` for diagnostics, and the stable `.lock` file is created and +left in place so a slot keeps the on-disk shape a Java client expects. Short-lived +locks under the shared parent directory's `.slot-locks` child serialize orphan adoption with close/rename/recreate quarantine transitions. -Those native primitives come from `fs-ext-extra-prebuilt`, an optional dependency that -ships prebuilt binaries for macOS, Linux, and Windows on x64 and arm64. It is -installed by default and imported on the first lock, so ILP-only senders and QWP -sessions without store-and-forward never load it. An install that skipped it, or a -platform with no matching prebuilt binary and no build toolchain, therefore leaves the -rest of the client fully usable and fails only when a store-and-forward journal is -loaded, raising `QwpReplayStoreUnavailableError`. Store-and-forward never falls back -to lock-free operation, because the lock is what keeps a second process, Java or Node, -off the same slot. +**A Node.js client and a Java client must not use one persistence directory at the +same time.** The Java client locks `.lock` with `flock` on Unix and `LockFileEx` on +Windows. The Node.js client does not participate in those kernel locks, so the two +runtimes will not see each other's lock and can both open the same slot, corrupting +the journal. The persistence format itself remains cross-client: a directory written +by one runtime can be handed to the other once the first has closed it. Only +concurrent access is unsupported, and only between runtimes — two Node.js processes +still exclude each other correctly. + +A kernel lock disappears the instant its holder dies; a directory does not. The holder +therefore refreshes the owner directory's mtime every 5 seconds, and a contender +reclaims a slot whose mtime has not advanced for 15 seconds. A contender also reclaims +immediately when the owner record names a process that no longer exists on the same +host, which is the common case after a crash. A stale owner directory is renamed aside +before removal, so two contenders racing to reclaim one slot cannot both win it. If a +holder is paused long enough for its heartbeat to lapse — `SIGSTOP`, a suspended VM, +or a stalled filesystem — its lock can be reclaimed while it still believes it holds +it; the original holder detects the reclaim at its next heartbeat and stops refreshing +so that only the new owner advances the mtime. New journals use the cross-client SFA persistence layout. Fixed-size `sf-.sfa` files have the Java/Rust 24-byte `SF01` header and @@ -1324,7 +1333,6 @@ The public error classes preserve enough context for policy decisions: | `QwpReplayStoreAppendTimeoutError` | The Node.js replay journal did not regain capacity before the configured append deadline | | `QwpReplayStoreCheckpointError` | A periodic Node.js replay-journal checkpoint failed; operations fail closed until a retry succeeds | | `QwpReplayStoreLockedError` | Another process owns the configured Node.js replay directory | -| `QwpReplayStoreUnavailableError` | Store-and-forward's optional native locking module is missing or unusable here | | `QwpEgressQueryError` | QuestDB returned a terminal query error | | `QwpEgressQueryAbandonedError` | Result iteration ended before the server completed the query | | `QwpEgressQueryTimeoutError` | The client deadline expired and cancellation began | diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 306ba8a..9d10e92 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -21,24 +21,3 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -This product also bundles `fs-ext-extra-prebuilt` 2.2.11, which is available -under the MIT License: - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/package.json b/package.json index c1070cc..e811b5c 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "test:qwp-browser-e2e": "pnpm build && vitest run --config vitest.qwp-browser.config.ts", "test:dist": "pnpm build && vitest run --config vitest.dist.config.ts", "typecheck:dist": "pnpm build && tsc --noEmit -p tsconfig.dist-types.json && tsc --noEmit -p tsconfig.dist-types.cjs.json", - "build": "bunchee --external fs-ext-extra-prebuilt", + "build": "bunchee", "eslint": "eslint src/**", "typecheck": "tsc --noEmit", "typecheck:qwp-browser": "tsc --noEmit -p tsconfig.qwp-browser.json", @@ -102,8 +102,5 @@ "dependencies": { "undici": "^7.8.0", "ws": "^8.21.3" - }, - "optionalDependencies": { - "fs-ext-extra-prebuilt": "2.2.11" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 93425d6..9040014 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -60,10 +60,6 @@ importers: vitest: specifier: ^3.1.3 version: 3.1.3(@types/node@22.15.17) - optionalDependencies: - fs-ext-extra-prebuilt: - specifier: 2.2.11 - version: 2.2.11 packages: @@ -1416,10 +1412,6 @@ packages: fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} - fs-ext-extra-prebuilt@2.2.11: - resolution: {integrity: sha512-uCD7z+RlNFvyYQ0rNK5FdhJVScuYrtNsY5PPFtpF1XcEAfHy06eYOu9vDNR4G1r/gq0BX9VyNU/2nhxy2tdQaA==} - engines: {node: '>= 8.0.0'} - fsevents@2.3.2: resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1775,9 +1767,6 @@ packages: nan@2.22.0: resolution: {integrity: sha512-nbajikzWTMwsW+eSsNm3QwlOs7het9gGJU5dDZzRTQGk03vyBOauxgI4VakDzE0PtsGTmXPsXTbbjVhRwR5mpw==} - nan@2.28.0: - resolution: {integrity: sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==} - nanoid@3.3.8: resolution: {integrity: sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -3824,11 +3813,6 @@ snapshots: fs-constants@1.0.0: {} - fs-ext-extra-prebuilt@2.2.11: - dependencies: - nan: 2.28.0 - optional: true - fsevents@2.3.2: optional: true @@ -4119,9 +4103,6 @@ snapshots: nan@2.22.0: optional: true - nan@2.28.0: - optional: true - nanoid@3.3.8: {} natural-compare@1.4.0: {} diff --git a/src/qwp-node/advisory-lock.ts b/src/qwp-node/advisory-lock.ts index b16920e..e9d213d 100644 --- a/src/qwp-node/advisory-lock.ts +++ b/src/qwp-node/advisory-lock.ts @@ -1,26 +1,50 @@ -import { mkdir, open, readFile, unlink, writeFile } from "node:fs/promises"; -import type { FileHandle } from "node:fs/promises"; +import { + mkdir, + readFile, + rename, + rm, + rmdir, + stat, + unlink, + utimes, + writeFile, +} from "node:fs/promises"; +import { hostname } from "node:os"; import { basename, dirname, join, resolve } from "node:path"; const SLOT_LOCK_FILE = ".lock"; const SLOT_LOCK_PID_FILE = ".lock.pid"; const LOGICAL_LOCK_DIRECTORY = ".slot-locks"; -// An explicit unlock can fail without proving that the kernel released the -// lock. Keep such descriptors reachable and retry them before acquiring any -// later lock, matching Java SlotLock's fail-closed release retry list. +// The owner directory is the mutex. `mkdir` is the only filesystem primitive +// that is atomically exclusive on both POSIX and Windows without a native +// binding, so ownership is "created this directory" rather than a kernel lock. +const OWNER_DIRECTORY_SUFFIX = ".owner"; +const OWNER_FILE = "owner"; + +// The kernel released a `flock` the instant a holder died. A directory outlives +// its creator, so ownership is instead proven by a liveness heartbeat: the +// holder refreshes the owner directory's mtime, and a contender may reclaim a +// lock whose mtime has stopped advancing. +const HEARTBEAT_INTERVAL_MS = 5_000; +const STALE_AFTER_MS = 15_000; + +// An explicit release can fail without proving that the owner directory is +// gone. Keep such locks reachable and retry them before acquiring any later +// lock, matching Java SlotLock's fail-closed release retry list. const pendingReleases = new Set(); -type FlockOperation = "exnb" | "un"; -type FlockFn = (typeof import("fs-ext-extra-prebuilt"))["flock"]; +// Distinguishes concurrent steal attempts within one process. A stale owner +// directory is renamed aside before removal so that exactly one contender can +// claim the right to clear it. +let stealCounter = 0; -// `fs-ext-extra-prebuilt` is an optional native dependency. It is resolved on -// the first lock rather than at module scope, so ILP-only and -// store-and-forward-free QWP users neither load the addon nor depend on a -// prebuilt binary existing for their platform. -let flockPromise: Promise | undefined; +interface OwnerRecord { + readonly pid: number; + readonly host: string; +} -/** @internal Native advisory-lock contention with Java-compatible diagnostics. */ +/** @internal Advisory-lock contention with Java-compatible diagnostics. */ export class QwpNodeAdvisoryLockBusyError extends Error { constructor( readonly lockPath: string, @@ -33,7 +57,7 @@ export class QwpNodeAdvisoryLockBusyError extends Error { } } -/** @internal Native advisory-lock setup or release failure. */ +/** @internal Advisory-lock setup or release failure. */ export class QwpNodeAdvisoryLockError extends Error { constructor( message: string, @@ -46,34 +70,31 @@ export class QwpNodeAdvisoryLockError extends Error { } } -/** @internal The optional native locking module is absent or unloadable. */ -export class QwpNodeAdvisoryLockUnavailableError extends Error { - constructor(cause?: unknown) { - super( - "QWP store-and-forward requires the optional native module " + - "'fs-ext-extra-prebuilt', which could not be loaded " + - `[platform=${process.platform}-${process.arch}, node=${process.versions.node}]`, - ); - this.name = "QwpNodeAdvisoryLockUnavailableError"; - this.cause = cause; - } -} - /** - * Lifetime owner of Java-compatible `.lock` / `.lock.pid` slot metadata. - * The files deliberately remain after release: unlinking a lock pathname can - * create a second inode while another process still holds the first one. + * Lifetime owner of Java-compatible `.lock` / `.lock.pid` slot metadata plus + * the `.lock.owner` directory that provides mutual exclusion. The metadata + * files deliberately remain after release so a slot keeps the on-disk shape a + * Java client expects to find; only the owner directory is transient. + * + * Exclusion covers Node processes only. A Java client locks `.lock` with + * `flock`/`LockFileEx`, which this implementation does not participate in, so + * the two runtimes must not use one directory at the same time. * * @internal */ export class QwpNodeAdvisoryLock { private released = false; + private compromised = false; + private heartbeat?: NodeJS.Timeout; private constructor( readonly lockPath: string, readonly pidPath: string, - private readonly handle: FileHandle, - ) {} + private readonly ownerPath: string, + private ownerMtimeMs: number, + ) { + this.startHeartbeat(); + } static async acquire(directory: string): Promise { return QwpNodeAdvisoryLock.acquireAt( @@ -97,18 +118,19 @@ export class QwpNodeAdvisoryLock { const { lockPath, pidPath } = logicalLockPaths(slotDirectory); let guard: QwpNodeAdvisoryLock; try { - // Only unlink while owning this inode. A live transition holder makes - // cleanup safely leave the files for a later drained close. + // Only unlink while holding the lock. A live holder makes cleanup safely + // leave the files for a later drained close. guard = await QwpNodeAdvisoryLock.acquireAt(lockPath, pidPath); } catch { return; } try { // Sidecar first: after the lock pathname is gone, a racing acquirer may - // create a new inode and its own PID sidecar, which we must not remove. + // create its own PID sidecar, which we must not remove. await unlink(pidPath).catch(() => undefined); await unlink(lockPath).catch(() => undefined); } finally { + // Releasing removes the owner directory, leaving the parent empty. await guard.release().catch(() => undefined); } } @@ -117,58 +139,77 @@ export class QwpNodeAdvisoryLock { lockPath: string, pidPath: string, ): Promise { - // Resolve the native binding before creating anything: an unsupported - // platform must fail without leaving slot metadata behind. - await loadFlock(); await retryPendingReleases(); - let handle: FileHandle; - try { - // a+ maps to a read/write handle on Windows, which LockFileEx requires. - // Java likewise opens/creates this stable inode read/write before locking. - handle = await open(lockPath, "a+", 0o600); - } catch (error) { - throw new QwpNodeAdvisoryLockError( - "could not open QWP advisory lock", - lockPath, - error, - ); + const ownerPath = `${lockPath}${OWNER_DIRECTORY_SUFFIX}`; + + // Claim the mutex before creating anything else, so losing contention + // leaves no metadata behind for a slot this process does not own. + let claimed = await claimOwnerDirectory(ownerPath); + if (!claimed) { + if (await reclaimIfStale(ownerPath, pidPath)) { + claimed = await claimOwnerDirectory(ownerPath); + } + if (!claimed) { + throw new QwpNodeAdvisoryLockBusyError( + lockPath, + await readHolderPid(pidPath), + ); + } } + let ownerMtimeMs: number; try { - await flockAsync(handle.fd, "exnb"); + await writeFile( + join(ownerPath, OWNER_FILE), + JSON.stringify({ pid: process.pid, host: hostname() }), + { encoding: "utf8", mode: 0o600 }, + ); + ownerMtimeMs = await touchOwnerDirectory(ownerPath); + // Keep the Java-visible slot metadata present and current. Java creates + // these itself when absent, so they exist for format parity and for the + // holder PID a contender reports. + await writeFile(lockPath, "", { + encoding: "utf8", + flag: "a", + mode: 0o600, + }); } catch (error) { - const holderPid = isLockContention(error) - ? await readHolderPid(pidPath) - : undefined; - await handle.close().catch(() => undefined); - if (isLockContention(error)) { - throw new QwpNodeAdvisoryLockBusyError(lockPath, holderPid, error); - } + await removeOwnerDirectory(ownerPath).catch(() => undefined); throw new QwpNodeAdvisoryLockError( - "could not acquire QWP advisory lock", + "could not establish QWP advisory lock", lockPath, error, ); } // Diagnostic-only, matching Java SlotLock: failure to refresh the sidecar - // must not discard an already-acquired kernel lock. + // must not discard an already-acquired lock. await writeFile(pidPath, `${process.pid}\n`, { encoding: "utf8", flag: "w", mode: 0o600, }).catch(() => undefined); - return new QwpNodeAdvisoryLock(lockPath, pidPath, handle); + return new QwpNodeAdvisoryLock(lockPath, pidPath, ownerPath, ownerMtimeMs); } async release(): Promise { if (this.released) return; + this.stopHeartbeat(); + if (this.compromised) { + // The owner directory was reclaimed by another process while we held it. + // Removing it now would strip a lock this process no longer owns. + this.released = true; + pendingReleases.delete(this); + throw new QwpNodeAdvisoryLockError( + "QWP advisory lock was reclaimed by another process before release", + this.lockPath, + ); + } try { - await flockAsync(this.handle.fd, "un"); + await removeOwnerDirectory(this.ownerPath); } catch (error) { - // Keep the descriptor alive when unlock is unconfirmed. Closing it would - // usually release the lock, but would lose Java's explicit-release safety - // contract and make retry/diagnostics impossible. + // Keep the lock reachable when removal is unconfirmed, so a later + // acquisition retries it rather than assuming the mutex is free. pendingReleases.add(this); throw new QwpNodeAdvisoryLockError( "could not release QWP advisory lock", @@ -178,9 +219,37 @@ export class QwpNodeAdvisoryLock { } this.released = true; pendingReleases.delete(this); - // The kernel unlock is the ownership boundary. Match Java by making the - // subsequent descriptor close best-effort and never unlinking either file. - await this.handle.close().catch(() => undefined); + } + + private startHeartbeat(): void { + this.heartbeat = setInterval(() => { + void this.beat(); + }, HEARTBEAT_INTERVAL_MS); + // Never hold the event loop open for a lock refresh. + this.heartbeat.unref?.(); + } + + private stopHeartbeat(): void { + if (this.heartbeat) clearInterval(this.heartbeat); + this.heartbeat = undefined; + } + + private async beat(): Promise { + if (this.released || this.compromised) return; + try { + const current = await stat(this.ownerPath); + if (Math.trunc(current.mtimeMs) !== Math.trunc(this.ownerMtimeMs)) { + // Someone judged this lock stale and took it. Stop refreshing so the + // new owner's heartbeat is the only one advancing the mtime. + this.compromised = true; + this.stopHeartbeat(); + return; + } + this.ownerMtimeMs = await touchOwnerDirectory(this.ownerPath); + } catch { + // A transient stat/utimes failure is not proof of loss. The next beat + // retries; a genuinely removed directory surfaces as a drifted mtime. + } } } @@ -190,43 +259,111 @@ async function retryPendingReleases(): Promise { } } -function loadFlock(): Promise { - // A failed attempt is not cached. The module throws from its own module scope - // when no binding matches this platform/ABI, and a later Node upgrade or - // reinstall can make the very same import succeed. - flockPromise ??= import("fs-ext-extra-prebuilt").then( - (module) => module.flock, - (error) => { - flockPromise = undefined; - throw new QwpNodeAdvisoryLockUnavailableError(error); - }, - ); - return flockPromise; +/** Returns true when this call created the owner directory. */ +async function claimOwnerDirectory(ownerPath: string): Promise { + try { + await mkdir(ownerPath); + return true; + } catch (error) { + if (nodeErrorCode(error) === "EEXIST") return false; + throw new QwpNodeAdvisoryLockError( + "could not create QWP advisory lock owner directory", + ownerPath, + error, + ); + } +} + +async function removeOwnerDirectory(ownerPath: string): Promise { + await unlink(join(ownerPath, OWNER_FILE)).catch(() => undefined); + await rmdir(ownerPath); +} + +/** Refreshes the heartbeat and returns the mtime that now proves ownership. */ +async function touchOwnerDirectory(ownerPath: string): Promise { + const now = new Date(); + await utimes(ownerPath, now, now); + return Math.trunc((await stat(ownerPath)).mtimeMs); } -async function flockAsync( - fd: number, - operation: FlockOperation, -): Promise { - const flock = await loadFlock(); - return new Promise((resolve, reject) => { - flock(fd, operation, (error) => { - if (error) reject(error); - else resolve(); - }); - }); +/** + * Clears an owner directory whose holder is gone. The directory is renamed + * aside first: `rename` lets exactly one contender win, so a lock can never be + * removed twice and handed to two acquirers. + */ +async function reclaimIfStale( + ownerPath: string, + pidPath: string, +): Promise { + let mtimeMs: number; + try { + mtimeMs = (await stat(ownerPath)).mtimeMs; + } catch { + // Already gone; the caller's next mkdir decides the winner. + return true; + } + if (!(await isStale(ownerPath, pidPath, mtimeMs))) return false; + + const abandoned = `${ownerPath}.stale-${process.pid}-${stealCounter++}`; + try { + await rename(ownerPath, abandoned); + } catch { + // Lost the race to another contender, or the holder released normally. + return true; + } + await rm(abandoned, { recursive: true, force: true }).catch(() => undefined); + return true; } -function isLockContention(error: unknown): boolean { - const code = nodeErrorCode(error); +async function isStale( + ownerPath: string, + pidPath: string, + mtimeMs: number, +): Promise { + if (Date.now() - mtimeMs > STALE_AFTER_MS) return true; + // Fast path for a crash on this host: a heartbeat that can never resume is + // stale immediately. A PID is meaningless on another host, so this is only + // consulted when the recorded host matches. + const owner = await readOwnerRecord(ownerPath, pidPath); return ( - code === "EACCES" || - code === "EAGAIN" || - code === "EBUSY" || - code === "EWOULDBLOCK" + owner !== undefined && owner.host === hostname() && !isPidAlive(owner.pid) ); } +async function readOwnerRecord( + ownerPath: string, + pidPath: string, +): Promise { + try { + const parsed: unknown = JSON.parse( + await readFile(join(ownerPath, OWNER_FILE), "utf8"), + ); + if (parsed && typeof parsed === "object") { + const { pid, host } = parsed as Partial; + if (typeof pid === "number" && typeof host === "string") { + return { pid, host }; + } + } + } catch { + // Fall through: a lock written by an older client, or a torn write. + } + // A slot locked before the owner record existed still has the PID sidecar, + // but nothing proves which host wrote it, so it can only expire by mtime. + const pid = await readHolderPid(pidPath); + return pid === undefined ? undefined : { pid, host: hostname() }; +} + +function isPidAlive(pid: number): boolean { + try { + // Signal 0 performs the permission and existence check without delivering. + process.kill(pid, 0); + return true; + } catch (error) { + // EPERM means the process exists but belongs to another user. + return nodeErrorCode(error) === "EPERM"; + } +} + async function readHolderPid(path: string): Promise { let text: string; try { diff --git a/src/qwp-node/file-replay-store.ts b/src/qwp-node/file-replay-store.ts index b08cac0..c0d58d0 100644 --- a/src/qwp-node/file-replay-store.ts +++ b/src/qwp-node/file-replay-store.ts @@ -20,7 +20,6 @@ import { import { QwpNodeAdvisoryLock, QwpNodeAdvisoryLockBusyError, - QwpNodeAdvisoryLockUnavailableError, } from "./advisory-lock"; import { qwpSegmentMaintenanceWorker } from "./segment-maintenance-worker"; import { log } from "../logging"; @@ -298,26 +297,6 @@ export class QwpReplayStoreLockedError extends QwpReplayStoreError { } } -/** - * Store-and-forward cannot run because its optional native locking module is - * missing or has no binding for this platform. There is no lock-free fallback: - * the lock is what keeps a second process, Node or Java, off the same slot. - */ -export class QwpReplayStoreUnavailableError extends QwpReplayStoreError { - constructor( - readonly directory: string, - cause: unknown, - ) { - super( - "QWP store-and-forward requires the optional native module " + - `'fs-ext-extra-prebuilt', which could not be loaded [directory=${directory}, ` + - `platform=${process.platform}-${process.arch}]`, - cause, - ); - this.name = "QwpReplayStoreUnavailableError"; - } -} - /** * Node store-and-forward journal with configurable local durability. * @@ -1960,8 +1939,6 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { this.directory, error.holderPid, ); - } else if (error instanceof QwpNodeAdvisoryLockUnavailableError) { - failure = new QwpReplayStoreUnavailableError(this.directory, error); } else { failure = new QwpReplayStoreError( `could not acquire QWP store-and-forward directory lock [directory=${this.directory}]`, @@ -2659,9 +2636,6 @@ export async function quarantineQwpNodeReplayStore( if (error instanceof QwpNodeAdvisoryLockBusyError) { throw new QwpReplayStoreLockedError(normalized, error.holderPid); } - if (error instanceof QwpNodeAdvisoryLockUnavailableError) { - throw new QwpReplayStoreUnavailableError(normalized, error); - } throw new QwpReplayStoreError( `could not acquire QWP store-and-forward logical lock for quarantine [directory=${normalized}]`, error, diff --git a/src/qwp/node.ts b/src/qwp/node.ts index fb8dd2a..df96782 100644 --- a/src/qwp/node.ts +++ b/src/qwp/node.ts @@ -90,7 +90,6 @@ export { QwpReplayStoreLockedError, QwpReplayStoreQuarantinedError, QwpReplayStoreSegmentTooLargeError, - QwpReplayStoreUnavailableError, } from "../qwp-node/file-replay-store"; export type { QwpNodeFileReplayStoreMetrics, diff --git a/test/qwp/dist.e2e.ts b/test/qwp/dist.e2e.ts index 0913cec..6589b0e 100644 --- a/test/qwp/dist.e2e.ts +++ b/test/qwp/dist.e2e.ts @@ -144,17 +144,7 @@ describe.each(["import", "require"] as const)( }, ); -describe("optional native locking module", () => { - // `fs-ext-extra-prebuilt` ships prebuilt bindings only for - // darwin/linux/win32 x arm64/x64 on a bounded range of Node majors, and - // throws from its own module scope when none matches - so on musl (Alpine), - // a future Node major, or an exotic arch it is unloadable. Spoofing - // `process.platform` is what its loader keys off, so it reproduces exactly - // that state. Only store-and-forward needs the addon; ILP-only consumers of - // the package root must never pay for it. - const unloadable = (body: string) => - `Object.defineProperty(process,'platform',{value:'sunos'});${body}`; - +describe("store-and-forward locking", () => { const runNode = (script: string) => new Promise<{ code: number | null; stdout: string; stderr: string }>( (resolve) => { @@ -175,7 +165,7 @@ describe("optional native locking module", () => { ); it.each(["import", "require"] as const)( - "the package root loads (%s) when the addon cannot be loaded", + "the package root loads (%s) on a platform no addon would support", async (format) => { const target = resolveExport(".", format); const load_ = @@ -183,35 +173,51 @@ describe("optional native locking module", () => { ? `console.log(typeof require(${JSON.stringify(target)}).Sender)` : `import(${JSON.stringify(pathToFileURL(target).href)}).then(m => console.log(typeof m.Sender))`; - const { code, stdout, stderr } = await runNode(unloadable(load_)); + // Spoofing an exotic platform is what a native addon's loader keys off. + // The slot lock is pure JavaScript now, so this must stay boring - it + // guards against a native dependency creeping back onto the root entry's + // module graph, where it would break every HTTP/TCP user on musl, a + // future Node major, or an unusual architecture. + const { code, stdout, stderr } = await runNode( + `Object.defineProperty(process,'platform',{value:'sunos'});${load_}`, + ); - // A static top-level import of the addon anywhere on the root entry's - // module graph makes this throw for every HTTP/TCP user on such a - // platform - the addon must stay behind a lazy import(). - expect(stderr).not.toMatch(/fs-ext/); + expect(stderr).toBe(""); expect(code).toBe(0); expect(stdout.trim()).toBe("function"); }, ); - it("keeps the addon an external specifier rather than inlining it", async () => { - // bunchee externalizes `dependencies` and `peerDependencies` only. The - // addon is an optionalDependency, so `--external fs-ext-extra-prebuilt` in - // the build script is load-bearing: without it the module is inlined and - // its __dirname-relative binary lookup resolves into dist/ and breaks at - // runtime, which the import test above cannot observe. + it("ships the slot lock in the bundle with no native addon", async () => { for (const format of ["import", "require"] as const) { const bundle = await readFile( resolveExport("./qwp/node", format), "utf8", ); - // The bare specifier survives, and it is reached through a dynamic - // import() rather than a top-level one. - expect(bundle).toMatch(/\bimport\(['"]fs-ext-extra-prebuilt['"]\)/); - // `findPrebuiltBinary` is the addon's own loader; seeing it here would - // mean the module was inlined into our bundle. - expect(bundle).not.toMatch(/findPrebuiltBinary/); + // The `.lock.owner` mutex is the whole locking implementation, so it must + // be inlined rather than reached through any external specifier. + expect(bundle).toContain('".owner"'); + expect(bundle).toContain('".slot-locks"'); + // Nothing may pull in a compiled binary: a prebuilt addon is exactly the + // per-Node-major breakage this lock exists to avoid. + expect(bundle).not.toMatch(/fs-ext/); + expect(bundle).not.toMatch(/['"][^'"]*\.node['"]\s*\)/); } }); + + it("declares no optional or native dependencies", async () => { + const manifest: { + dependencies?: Record; + optionalDependencies?: Record; + } = JSON.parse( + await readFile(new URL("../../package.json", import.meta.url), "utf8"), + ); + + expect(manifest.optionalDependencies).toBeUndefined(); + expect(Object.keys(manifest.dependencies ?? {}).sort()).toEqual([ + "undici", + "ws", + ]); + }); }); diff --git a/test/qwp/public-api.test.ts b/test/qwp/public-api.test.ts index 5ff3b77..d4f8fbb 100644 --- a/test/qwp/public-api.test.ts +++ b/test/qwp/public-api.test.ts @@ -80,7 +80,6 @@ const nodeRuntimeContract = [ "QwpReplayStoreFullError", "QwpReplayStoreLockedError", "QwpReplayStoreQuarantinedError", - "QwpReplayStoreUnavailableError", "QwpUdpDatagramTooLargeError", "QwpVersionMismatchError", "connectQwpNodeEgress", diff --git a/test/qwp/reconnect.test.ts b/test/qwp/reconnect.test.ts index 661bb8c..db7bd60 100644 --- a/test/qwp/reconnect.test.ts +++ b/test/qwp/reconnect.test.ts @@ -8,11 +8,11 @@ import { stat, truncate, unlink, + utimes, writeFile, } from "node:fs/promises"; -import { tmpdir } from "node:os"; +import { hostname, tmpdir } from "node:os"; import { join } from "node:path"; -import { flock } from "fs-ext-extra-prebuilt"; import { afterEach, describe, expect, it, vi } from "vitest"; import { connectQwpNodeIngress, @@ -26,7 +26,6 @@ import { QwpReplayStoreFullError, QwpReplayStoreLockedError, QwpReplayStoreSegmentTooLargeError, - QwpReplayStoreUnavailableError, type QwpNodeReplayDataLossReport, } from "../../src/qwp/node"; import { @@ -81,15 +80,6 @@ import { createQwpFailoverHealthTracker, } from "../../src/_qwp/_internal/failover"; -function nativeFlock(fd: number, operation: "exnb" | "un"): Promise { - return new Promise((resolve, reject) => { - flock(fd, operation, (error) => { - if (error) reject(error); - else resolve(); - }); - }); -} - async function expectOnlyJavaSlotLockMetadata( directory: string, ): Promise { @@ -4082,33 +4072,6 @@ describe("QWP Node file replay store", () => { await second.close(); }); - it("fails closed when the native locking module cannot be loaded", async () => { - const directory = await trackedDirectory(); - vi.resetModules(); - // Reproduces the module-scope throw the optional dependency raises when no - // prebuilt binding matches the platform, and MODULE_NOT_FOUND when an - // install omitted it. - vi.doMock("fs-ext-extra-prebuilt", () => { - throw new Error("Failed to load fs-ext native module."); - }); - try { - const { QwpNodeFileReplayStore: UnavailableStore } = await import( - "../../src/qwp-node/file-replay-store" - ); - const store = new UnavailableStore({ directory }); - await expect(store.load()).rejects.toMatchObject({ - name: "QwpReplayStoreUnavailableError", - directory, - } satisfies Partial); - // The binding resolves before the lock file is opened, so a slot that - // cannot be owned is never given Java-visible lock metadata. - await expect(readdir(directory)).resolves.toEqual([]); - } finally { - vi.doUnmock("fs-ext-extra-prebuilt"); - vi.resetModules(); - } - }); - it("arbitrates acquisition over stale Java lock metadata", async () => { const directory = await trackedDirectory(); await writeFile(join(directory, ".lock"), ""); @@ -4136,51 +4099,63 @@ describe("QWP Node file replay store", () => { await expectOnlyJavaSlotLockMetadata(directory); }); - it("contends with a Java-compatible native advisory lock", async () => { + it("reclaims a slot whose owner heartbeat stopped", async () => { const directory = await trackedDirectory(); - const lockPath = join(directory, ".lock"); - const lockHandle = await open(lockPath, "a+"); - await nativeFlock(lockHandle.fd, "exnb"); - await writeFile(join(directory, ".lock.pid"), "4242\n"); + const ownerPath = join(directory, ".lock.owner"); + await mkdir(ownerPath); + // A live PID with an mtime far beyond the staleness window: only the + // stopped heartbeat marks this owner as gone. + await writeFile( + join(ownerPath, "owner"), + JSON.stringify({ pid: process.pid, host: hostname() }), + ); + const longAgo = new Date(Date.now() - 60_000); + await utimes(ownerPath, longAgo, longAgo); const store = new QwpNodeFileReplayStore({ directory }); - await expect(store.load()).rejects.toMatchObject({ - name: "QwpReplayStoreLockedError", - directory, - holderPid: 4242, - } satisfies Partial); - - await nativeFlock(lockHandle.fd, "un"); - await lockHandle.close(); await expect(store.load()).resolves.toEqual([]); expect(await readFile(join(directory, ".lock.pid"), "utf8")).toBe( `${process.pid}\n`, ); await store.close(); + await expectOnlyJavaSlotLockMetadata(directory); }); - it("contends with Java's parent-anchored logical slot lock", async () => { - const rootDirectory = await trackedDirectory(); - const directory = join(rootDirectory, "sender-0"); - const logicalLockDirectory = join(rootDirectory, ".slot-locks"); - await mkdir(directory); - await mkdir(logicalLockDirectory); - const lockPath = join(logicalLockDirectory, "sender-0.lock"); - const lockHandle = await open(lockPath, "a+"); - await nativeFlock(lockHandle.fd, "exnb"); - await writeFile(join(logicalLockDirectory, "sender-0.lock.pid"), "9090\n"); + it("reclaims a slot whose owner process is gone from this host", async () => { + const directory = await trackedDirectory(); + const ownerPath = join(directory, ".lock.owner"); + await mkdir(ownerPath); + // Fresh mtime, so only the dead PID can justify reclaiming the slot. The + // kernel used to do this for us by releasing the flock on process exit. + await writeFile( + join(ownerPath, "owner"), + JSON.stringify({ pid: 2147483647, host: hostname() }), + ); + + const store = new QwpNodeFileReplayStore({ directory }); + await expect(store.load()).resolves.toEqual([]); + await store.close(); + await expectOnlyJavaSlotLockMetadata(directory); + }); + + it("leaves a slot owned by a live heartbeat alone", async () => { + const directory = await trackedDirectory(); + const ownerPath = join(directory, ".lock.owner"); + await mkdir(ownerPath); + // A PID on another host can never be probed for liveness, so a fresh + // heartbeat is the only thing keeping this slot held. + await writeFile( + join(ownerPath, "owner"), + JSON.stringify({ pid: 4242, host: `${hostname()}-elsewhere` }), + ); + await writeFile(join(directory, ".lock.pid"), "4242\n"); const store = new QwpNodeFileReplayStore({ directory }); await expect(store.load()).rejects.toMatchObject({ name: "QwpReplayStoreLockedError", directory, - holderPid: 9090, + holderPid: 4242, } satisfies Partial); - - await nativeFlock(lockHandle.fd, "un"); - await lockHandle.close(); - await expect(store.load()).resolves.toEqual([]); - await store.close(); }); it("retires logical lock files after a slot is fully drained", async () => { @@ -4235,8 +4210,15 @@ describe("QWP Node file replay store", () => { await expect( store.append({ frameSequence: 0n, payload: Uint8Array.of(1, 2, 3) }), ).rejects.toBeInstanceOf(QwpReplayStoreFullError); - await expectOnlyJavaSlotLockMetadata(directory); + // Asserted while the store still holds the slot, so the owner directory is + // expected here; nothing journal-shaped may exist alongside it. + expect((await readdir(directory)).sort()).toEqual([ + ".lock", + ".lock.owner", + ".lock.pid", + ]); await store.close(); + await expectOnlyJavaSlotLockMetadata(directory); }); it("checkpoints periodic frame and dictionary writes", async () => { @@ -4318,10 +4300,12 @@ describe("QWP Node file replay store", () => { await expect(store.close()).rejects.toBeInstanceOf( QwpReplayStoreCheckpointError, ); - const lockHandle = await open(join(directory, ".lock"), "r+"); - await nativeFlock(lockHandle.fd, "exnb"); - await nativeFlock(lockHandle.fd, "un"); - await lockHandle.close(); + // The slot lock is released even though close() rejected: no owner + // directory remains, so another store can take the slot. + await expect(readdir(directory)).resolves.not.toContain(".lock.owner"); + const reopened = new QwpNodeFileReplayStore({ directory }); + await reopened.load(); + await reopened.close(); }); it("waits for ACK trimming without blocking the acknowledgement queue", async () => { From 68be1cc4a75cdd852ea50c950ec4d8e02b1ad64a Mon Sep 17 00:00:00 2001 From: glasstiger Date: Fri, 21 Aug 2026 18:41:21 +0100 Subject: [PATCH 129/265] fix(tcp): derive the JWK public point from the configured private key `auth: {keyId, token}` supplies only the private scalar, so the JWK was completed with a hardcoded x/y that bears no relation to it. Node accepted that inconsistent pair without checking up to v24; v26 validates it and raises ERR_CRYPTO_INVALID_JWK, which breaks ILP TCP authentication outright for anyone on that runtime. Derive the point with ECDH instead. Callers passing a complete `jwk` were never affected, and a derived pair is byte-identical to a correct one, so authentication behaviour is unchanged on every Node version. The existing auth tests cannot catch this: they pass with the placeholder on any Node below v26. The added test compares the point against the private key directly. Co-Authored-By: Claude Opus 5 (1M context) --- src/transport/tcp.ts | 30 +++++++++++++++++----- test/sender.transport.test.ts | 48 +++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 7 deletions(-) diff --git a/src/transport/tcp.ts b/src/transport/tcp.ts index efc76fa..3f33a2f 100644 --- a/src/transport/tcp.ts +++ b/src/transport/tcp.ts @@ -13,12 +13,28 @@ import { isBoolean } from "../utils"; // Default number of rows that trigger auto-flush for TCP transport. const DEFAULT_TCP_AUTO_FLUSH_ROWS = 600; -// Arbitrary public key, used to construct valid JWK tokens. -// These are not used for actual authentication, only required for crypto API compatibility. -const PUBLIC_KEY = { - x: "aultdA0PjhD_cWViqKKyL5chm6H1n-BiZBo_48T-uqc", - y: "__ptaol41JWSpTTL525yVEfzmY8A6Vi_QrW1FjKcHMg", -}; +// A JWK is not a valid EC key without its public point, but QuestDB's TCP auth +// config carries only the private scalar. Deriving the point keeps the pair +// mathematically consistent; a fixed placeholder used to stand in here, which +// Node accepted without validation up to v24 and rejects from v26 with +// ERR_CRYPTO_INVALID_JWK. +function derivePublicKey(privateKey: string): { x: string; y: string } { + let point: Buffer; + try { + const ecdh = crypto.createECDH("prime256v1"); + ecdh.setPrivateKey(Buffer.from(privateKey, "base64url")); + point = ecdh.getPublicKey(); + } catch (err) { + throw new Error( + `Invalid private key, the 'token' property of the 'auth' config option must be a base64url-encoded P-256 private key: ${err instanceof Error ? err.message : String(err)}`, + ); + } + // Uncompressed SEC1 point: an 0x04 tag followed by the 32-byte X and Y. + return { + x: point.subarray(1, 33).toString("base64url"), + y: point.subarray(33, 65).toString("base64url"), + }; +} // New Line character const NEWLINE = 10; @@ -303,7 +319,7 @@ function constructJwk(options: SenderOptions): Record { return { kid: options.auth.keyId, d: options.auth.token, - ...PUBLIC_KEY, + ...derivePublicKey(options.auth.token), kty: "EC", crv: "P-256", }; diff --git a/test/sender.transport.test.ts b/test/sender.transport.test.ts index 56759d1..08e6b2e 100644 --- a/test/sender.transport.test.ts +++ b/test/sender.transport.test.ts @@ -4,6 +4,8 @@ import { readFileSync } from "fs"; import { Agent } from "undici"; import http from "http"; +import crypto from "node:crypto"; + import { Sender, SenderOptions, UndiciTransport, HttpTransport } from "../src"; import { MockProxy } from "./util/mockproxy"; import { MockHttp } from "./util/mockhttp"; @@ -356,6 +358,52 @@ describe("Sender TCP suite", function () { ); } + const tcpJwk = (auth: SenderOptions["auth"]) => { + const sender = new Sender({ + protocol: "tcp", + protocol_version: "1", + port: PROXY_PORT, + host: PROXY_HOST, + auth, + }); + return (sender as unknown as { transport: { jwk: Record } }) + .transport.jwk; + }; + + it("derives a public point that matches the configured private key", () => { + // The JWK built from `auth` used to carry a fixed placeholder x/y unrelated + // to the private scalar. Node accepted that up to v24 without checking, so + // the authentication tests below pass either way; only comparing the point + // against the key catches a regression before Node v26 rejects it outright. + for (const auth of [ + AUTH, + { keyId: "user1", token: "zhPiK3BkYMYJvRf5sqyrWNJwjDKHOWHnRbmQggUll6A" }, + ]) { + const jwk = tcpJwk(auth); + const ecdh = crypto.createECDH("prime256v1"); + ecdh.setPrivateKey(Buffer.from(auth!.token, "base64url")); + const point = ecdh.getPublicKey(); + + expect(jwk.x).toBe(point.subarray(1, 33).toString("base64url")); + expect(jwk.y).toBe(point.subarray(33, 65).toString("base64url")); + // Node v26 raises ERR_CRYPTO_INVALID_JWK here for an inconsistent pair. + expect(() => + crypto.createPrivateKey({ key: jwk, format: "jwk" }), + ).not.toThrow(); + } + }); + + it("rejects a private key outside the P-256 scalar range", () => { + // 32 zero bytes. Short tokens are left-padded into a valid scalar, so an + // out-of-range one is what actually reaches the wrapped error path. + expect(() => + tcpJwk({ + keyId: "user1", + token: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + }), + ).toThrow(/must be a base64url-encoded P-256 private key/); + }); + it("can authenticate", async function () { const proxy = await createProxy(true); const sender = await createSender(AUTH); From 3360f6780c875e31be2df71f2f138c30fda150ce Mon Sep 17 00:00:00 2001 From: glasstiger Date: Fri, 21 Aug 2026 19:29:57 +0100 Subject: [PATCH 130/265] test(qwp): drop browser tests that need a live QuestDB The three `against QuestDB` cases duplicated server-side coverage that already exists, and each server-side version is broader: session cookie auth -> QwpBrowserSessionAuthTest .testSessionCookieAuthenticatesIngressAndEgress, plus missing-session rejection, cookie rotation and the service-account hook, plus the Enterprise REST/OIDC login suites durable ACK opt-in -> QwpIngressUpgradeProcessorOnHeadersReadyTest .testOnHeadersReadyDoesNotSelectBrowserSubprotocol- WhenRegistryDisabled and its three siblings version + batch cap -> testBrowserHandshakeAppendsIngressServerInfo, testOnHeadersReadyAdvertisesEffectiveBatchSize, QwpEgressMaxBatchRowsTest Tests needing a live database belong in the repositories that own the topology and authentication fixtures. What is left here is the eight cases that drive the built browser bundle in real Chromium against a local mock server - the client-side half of the same negotiation paths, and the part no Java suite can cover because it does not run JavaScript. The job no longer pulls a container: it drops from ~16s to ~2s, stops depending on questdb/questdb:nightly, and is renamed since it is no longer an end-to-end suite. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/build.yml | 15 +- package.json | 2 +- test/qwp/browser.e2e.ts | 348 ----------------------------------- vitest.qwp-browser.config.ts | 5 +- 4 files changed, 12 insertions(+), 358 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 61aea99..9073182 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -71,12 +71,13 @@ jobs: - name: Type-checking (built package consumer) run: pnpm typecheck:dist - qwp-browser-e2e: - name: QWP browser E2E + # Drives the built browser bundle in real Chromium against a local mock + # server. Tests that need a live QuestDB belong in the server repositories, + # where the topology and authentication fixtures already exist. + qwp-browser: + name: QWP browser bundle runs-on: ubuntu-latest - timeout-minutes: 15 - env: - QWP_BROWSER_E2E_IMAGE: ${{ vars.QWP_BROWSER_E2E_IMAGE || 'questdb/questdb:nightly' }} + timeout-minutes: 10 steps: - name: Checkout repository uses: actions/checkout@v4 @@ -94,8 +95,8 @@ jobs: - name: Install Chromium run: pnpm exec playwright install --with-deps chromium - - name: Authenticated ingress and egress - run: pnpm test:qwp-browser-e2e + - name: Browser bundle tests + run: pnpm test:qwp-browser enterprise-qwp-e2e: name: Dispatch Enterprise QWP E2E diff --git a/package.json b/package.json index e811b5c..532d6a8 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "QuestDB Node.js Client", "scripts": { "test": "vitest", - "test:qwp-browser-e2e": "pnpm build && vitest run --config vitest.qwp-browser.config.ts", + "test:qwp-browser": "pnpm build && vitest run --config vitest.qwp-browser.config.ts", "test:dist": "pnpm build && vitest run --config vitest.dist.config.ts", "typecheck:dist": "pnpm build && tsc --noEmit -p tsconfig.dist-types.json && tsc --noEmit -p tsconfig.dist-types.cjs.json", "build": "bunchee", diff --git a/test/qwp/browser.e2e.ts b/test/qwp/browser.e2e.ts index 716b33a..95b91a4 100644 --- a/test/qwp/browser.e2e.ts +++ b/test/qwp/browser.e2e.ts @@ -3,14 +3,10 @@ import { readFile } from "node:fs/promises"; import { AddressInfo } from "node:net"; import path from "node:path"; import { Browser, chromium } from "playwright"; -import { GenericContainer, StartedTestContainer } from "testcontainers"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { WebSocketServer } from "ws"; import { - connectQwpNodeIngress, - connectQwpNodeWebSocket, encodeQwpFrame, - QWP_COLUMN_TYPE, QWP_COMPRESSION_CODEC, QWP_DURABLE_ACK_WEBSOCKET_PROTOCOL, QWP_DEFAULT_EGRESS_INITIAL_CREDIT, @@ -19,17 +15,10 @@ import { QWP_STATUS, QwpByteReader, QwpByteWriter, - QwpDurableAckUnavailableError, - QwpTableBuffer, readQwpVarint, writeQwpVarint, } from "../../src/qwp/node"; -const USER = process.env.QWP_BROWSER_E2E_USER ?? "admin"; -const PASSWORD = process.env.QWP_BROWSER_E2E_PASSWORD ?? "quest"; -const QUESTDB_HTTP_PORT = 9000; -const WRITE_BATCH_SIZE = 8; - function listen(server: Server): Promise { return new Promise((resolve, reject) => { server.once("error", reject); @@ -87,20 +76,6 @@ function createModuleServer(): Server { }); } -function websocketUrl(httpUrl: string, pathname: string): string { - const url = new URL(pathname, httpUrl); - url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; - return url.toString(); -} - -async function executeSql(questdbUrl: string, sql: string): Promise { - return fetch(new URL(`/exec?query=${encodeURIComponent(sql)}`, questdbUrl), { - headers: { - Authorization: `Basic ${Buffer.from(`${USER}:${PASSWORD}`).toString("base64")}`, - }, - }); -} - function writeU16String(writer: QwpByteWriter, value: string): void { const bytes = new TextEncoder().encode(value); writer.writeUint16(bytes.length).writeBytes(bytes); @@ -166,8 +141,6 @@ describe("QWP in a real browser", () => { let assetServer: Server; let assetUrl: string; let browser: Browser; - let container: StartedTestContainer | undefined; - let questdbUrl: string; beforeAll(async () => { assetServer = createModuleServer(); @@ -679,325 +652,4 @@ describe("QWP in a real browser", () => { await closeWebSocketServer(server); } }); - - describe("against QuestDB", () => { - beforeAll(async () => { - const configuredUrl = process.env.QWP_BROWSER_E2E_URL; - if (configuredUrl) { - questdbUrl = new URL(configuredUrl).toString(); - } else { - container = await new GenericContainer( - process.env.QWP_BROWSER_E2E_IMAGE ?? "questdb/questdb:nightly", - ) - .withEnvironment({ - QDB_HTTP_USER: USER, - QDB_HTTP_PASSWORD: PASSWORD, - }) - .withExposedPorts(QUESTDB_HTTP_PORT) - .start(); - questdbUrl = new URL( - `http://${container.getHost()}:${container.getMappedPort(QUESTDB_HTTP_PORT)}`, - ).toString(); - } - }); - - afterAll(async () => { - await container?.stop(); - }); - - it("authenticates ingress and egress with the browser session cookie", async () => { - const context = await browser.newContext({ bypassCSP: true }); - const page = await context.newPage(); - const tableName = `qwp_browser_e2e_${Date.now()}`; - const ingressUrl = websocketUrl(questdbUrl, "/write/v4"); - const egressUrl = websocketUrl(questdbUrl, "/read/v1"); - - try { - await page.goto(questdbUrl, { waitUntil: "domcontentloaded" }); - - const anonymousUpgrades = await page.evaluate( - async ({ moduleUrl, ingress, egress }) => { - const importModule = new Function("url", "return import(url)") as ( - url: string, - ) => Promise>; - const qwp = await importModule(moduleUrl); - const tryConnect = async ( - connect: (options: { - url: string; - }) => Promise<{ close(): Promise }>, - url: string, - ) => { - try { - const session = await connect({ url }); - await session.close(); - return { connected: true }; - } catch (error) { - const failure = error as { - name?: string; - kind?: string; - retryable?: boolean; - statusCode?: number; - }; - return { - connected: false, - name: failure.name, - kind: failure.kind, - retryable: failure.retryable ?? null, - statusCode: failure.statusCode ?? null, - }; - } - }; - return { - ingress: await tryConnect(qwp.connectQwpBrowserIngress, ingress), - egress: await tryConnect(qwp.connectQwpBrowserEgress, egress), - }; - }, - { moduleUrl: assetUrl, ingress: ingressUrl, egress: egressUrl }, - ); - expect(anonymousUpgrades).toEqual({ - ingress: { - connected: false, - name: "QwpUpgradeError", - kind: "opaque", - retryable: null, - statusCode: null, - }, - egress: { - connected: false, - name: "QwpUpgradeError", - kind: "opaque", - retryable: null, - statusCode: null, - }, - }); - - const login = await page.evaluate( - async ({ moduleUrl, username, password, table }) => { - const importModule = new Function("url", "return import(url)") as ( - url: string, - ) => Promise>; - const qwp = await importModule(moduleUrl); - const bootstrap = await qwp.bootstrapQwpBrowserSession({ - url: new URL("/exec", location.href), - authentication: { type: "basic", username, password }, - }); - const query = - `create table ${table} (value long, ts timestamp) ` + - "timestamp(ts) partition by day wal"; - const response = await fetch( - `/exec?query=${encodeURIComponent(query)}`, - { credentials: "include" }, - ); - return { - bootstrapStatus: bootstrap.status, - status: response.status, - body: await response.text(), - }; - }, - { - moduleUrl: assetUrl, - username: USER, - password: PASSWORD, - table: tableName, - }, - ); - expect(login.bootstrapStatus).toBe(200); - expect(login.status, login.body).toBe(200); - - const cookies = await context.cookies(questdbUrl); - expect(cookies).toContainEqual( - expect.objectContaining({ name: "qdb_session", httpOnly: true }), - ); - - const ingressResult = await page.evaluate( - async ({ moduleUrl, url, table, batchSize }) => { - const importModule = new Function("url", "return import(url)") as ( - url: string, - ) => Promise>; - const qwp = await importModule(moduleUrl); - const sender = await qwp.connectQwpBrowserSender( - { url }, - { autoFlush: false }, - ); - try { - for (let index = 0; index < batchSize; index++) { - await sender - .table(table) - .longColumn("value", 42n) - .at(BigInt(Date.now()) * 1_000n); - } - return { flushed: await sender.flush() }; - } finally { - await sender.close(); - } - }, - { - moduleUrl: assetUrl, - url: ingressUrl, - table: tableName, - batchSize: WRITE_BATCH_SIZE, - }, - ); - expect(ingressResult).toEqual({ flushed: true }); - - await expect - .poll( - () => - page.evaluate(async (table) => { - const response = await fetch( - `/exec?query=${encodeURIComponent(`select count() from ${table}`)}`, - { credentials: "include" }, - ); - if (!response.ok) return -1; - const result = await response.json(); - return result.dataset[0][0] as number; - }, tableName), - { timeout: 30_000, interval: 250 }, - ) - .toBe(WRITE_BATCH_SIZE); - - const egressResult = await page.evaluate( - async ({ moduleUrl, url, table }) => { - const importModule = new Function("url", "return import(url)") as ( - url: string, - ) => Promise>; - const qwp = await importModule(moduleUrl); - const session = await qwp.connectQwpBrowserEgress({ url }); - try { - const query = await session.query( - `select value from ${table} where value = $1 and ts >= $2 order by ts`, - { - binds: (binds: any) => - binds.setLong(0, 42n).setTimestampMicros(1, 0n), - }, - ); - const values: string[] = []; - for await (const batch of query) { - for (const row of batch.rows()) values.push(String(row[0])); - } - const completion = await query.completion; - - const typedQuery = await session.query( - "select " + - "$1::boolean, $2::byte, $3::short, $4::char, " + - "$5::int, $6::long, $7::float, $8::double, " + - "$9::date, $10::timestamp, $11::timestamp_ns, " + - "$12::varchar, $13::uuid, $14::long256, " + - "cast($15 as geohash(60b)), $16::decimal(18, 4), " + - "$17::decimal(38, 6), $18::decimal(76, 10) " + - "from long_sequence(1)", - { - binds: (binds: any) => - binds - .setBoolean(0, true) - .setByte(1, 42) - .setShort(2, 1234) - .setChar(3, "Q") - .setInt(4, 2_000_000) - .setLong(5, 9_000_000_000n) - .setFloat(6, 3.25) - .setDouble(7, 2.5) - .setDate(8, 1_700_000_000_000n) - .setTimestampMicros(9, 1_700_000_000_000_000n) - .setTimestampNanos(10, 1_700_000_000_123_456_789n) - .setVarchar(11, "café") - .setUuid(12, "123e4567-e89b-12d3-a456-426614174000") - .setLong256(13, 1n, 2n, 3n, 4n) - .setGeohash(14, 60, 0x0fffffffffffffffn) - .setDecimal64(15, 4, 123_456_789n) - .setDecimal128(16, 6, 123_456_789_123_456n, 0n) - .setDecimal256(17, 10, 420_000_000_000n, 0n, 0n, 0n), - }, - ); - let typedRow: any[] | undefined; - for await (const batch of typedQuery) { - typedRow = [...batch.rows()][0]; - } - await typedQuery.completion; - const normalize = (value: any): any => { - if (typeof value === "bigint") return value.toString(); - if (Array.isArray(value)) return value.map(normalize); - if (value && typeof value === "object") { - return Object.fromEntries( - Object.entries(value).map(([key, nested]) => [ - key, - normalize(nested), - ]), - ); - } - return value; - }; - return { - values, - completion: completion.kind, - typedRow: typedRow?.map(normalize), - }; - } finally { - await session.close(); - } - }, - { moduleUrl: assetUrl, url: egressUrl, table: tableName }, - ); - expect(egressResult).toEqual({ - values: Array.from({ length: WRITE_BATCH_SIZE }, () => "42"), - completion: "result-end", - typedRow: [ - true, - 42, - 1234, - "Q", - 2_000_000, - "9000000000", - 3.25, - 2.5, - "1700000000000", - "1700000000000000", - "1700000000123456789", - "café", - { low: "11841725276408463360", high: "1314564453825188563" }, - { words: ["1", "2", "3", "4"] }, - { bits: "1152921504606846975", precisionBits: 60 }, - { unscaled: "123456789", scale: 4 }, - { unscaled: "123456789123456", scale: 6 }, - { unscaled: "420000000000", scale: 10 }, - ], - }); - } finally { - await page - .evaluate(async (table) => { - await fetch( - `/exec?query=${encodeURIComponent(`drop table ${table}`)}`, - { - credentials: "include", - }, - ); - }, tableName) - .catch(() => undefined); - await context.close(); - } - }); - - it("rejects durable ACK opt-in when the server does not advertise it", async () => { - await expect( - connectQwpNodeIngress({ - url: websocketUrl(questdbUrl, "/write/v4"), - authorization: `Basic ${Buffer.from(`${USER}:${PASSWORD}`).toString("base64")}`, - requestDurableAck: true, - }), - ).rejects.toBeInstanceOf(QwpDurableAckUnavailableError); - }); - - it("negotiates the server QWP version and ingress batch cap", async () => { - const connection = await connectQwpNodeWebSocket({ - url: websocketUrl(questdbUrl, "/write/v4"), - authorization: `Basic ${Buffer.from(`${USER}:${PASSWORD}`).toString("base64")}`, - }); - try { - expect(connection.handshake.qwpVersion).toBe(1); - expect(connection.handshake.maxBatchSizeBytes).toBeGreaterThan(12); - } finally { - await connection.close(); - } - }); - }); }); diff --git a/vitest.qwp-browser.config.ts b/vitest.qwp-browser.config.ts index 0b392e6..179afd2 100644 --- a/vitest.qwp-browser.config.ts +++ b/vitest.qwp-browser.config.ts @@ -3,7 +3,8 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { include: ["test/qwp/browser.e2e.ts"], - hookTimeout: 300_000, - testTimeout: 120_000, + // Only a Chromium launch happens in beforeAll now, not a container pull. + hookTimeout: 120_000, + testTimeout: 30_000, }, }); From 0a115379e71fc8d7f80fadb4f7e0c42a1fcc5d11 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Fri, 21 Aug 2026 19:45:59 +0100 Subject: [PATCH 131/265] ci: type-check test/** and fix what that surfaces `pnpm typecheck` included only `src` and one contract file, so nothing type-checked test/** at all. vitest strips types with esbuild rather than checking them, so a test could reference a deleted export and still pass - which is exactly how a dangling import of a removed error class survived every gate in this branch. `tsconfig.test.json` follows the tsconfig.bench.json pattern rather than widening the base config, which bunchee also reads. Turning it on surfaced 22 errors. Two were real: QwpNodeOrphanDrainSession was not exported from src/qwp/node, but the public QwpNodeOrphanDrainerOptions.createSession returns it, so nobody outside this package could implement that interface. Now exported and pinned in the type-position contract. session.test.ts passed `reconnect` in connectQwpBrowserIngress's first argument, where it is not a valid key. It was silently dropped and the test ran on the default backoff rather than the zero backoff it asked for. The rest were test-local: helper parameters whose types were inferred as narrow literals from their default values, header lookups typed `string` where node returns `string | string[]`, a listener returning Array.push's number, node's Blob requiring its sources argument, a duplicated named import, a type argument on toMatchObject, and two `satisfies Partial` that did not account for toMatchObject matching nested objects partially. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/build.yml | 6 ++++ package.json | 1 + src/qwp/node.ts | 1 + test/qwp/core.test.ts | 1 - test/qwp/egress.test.ts | 4 +-- test/qwp/node-transport.test.ts | 6 ++-- test/qwp/public-api-contract.ts | 10 +++++++ test/qwp/reconnect.test.ts | 8 +++--- test/qwp/sender.test.ts | 4 +-- test/qwp/session.test.ts | 51 +++++++++++++++++++++------------ tsconfig.test.json | 8 ++++++ 11 files changed, 69 insertions(+), 31 deletions(-) create mode 100644 tsconfig.test.json diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9073182..b6ce024 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -48,6 +48,12 @@ jobs: - name: Type-checking (browser) run: pnpm typecheck:qwp-browser + # `pnpm typecheck` covers src plus a single contract file, so nothing + # type-checked test/** at all: a test could reference a deleted export + # and still pass, because vitest strips types without checking them. + - name: Type-checking (tests) + run: pnpm typecheck:test + - name: Type-checking (benchmarks) run: pnpm typecheck:bench diff --git a/package.json b/package.json index 532d6a8..02a9827 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "eslint": "eslint src/**", "typecheck": "tsc --noEmit", "typecheck:qwp-browser": "tsc --noEmit -p tsconfig.qwp-browser.json", + "typecheck:test": "tsc --noEmit -p tsconfig.test.json", "bench": "vitest bench --run benchmarks", "bench:e2e": "vitest run --config vitest.bench-e2e.config.ts", "typecheck:bench": "tsc --noEmit -p tsconfig.bench.json", diff --git a/src/qwp/node.ts b/src/qwp/node.ts index df96782..28aab75 100644 --- a/src/qwp/node.ts +++ b/src/qwp/node.ts @@ -119,6 +119,7 @@ export type { QwpNodeOrphanDrainEventKind, QwpNodeOrphanDrainerMetrics, QwpNodeOrphanDrainerOptions, + QwpNodeOrphanDrainSession, } from "../qwp-node/orphan-drainer"; export type { QwpWebSocketLike } from "../_qwp/_internal/websocket-connection"; diff --git a/test/qwp/core.test.ts b/test/qwp/core.test.ts index 9f6b23b..6cd4b57 100644 --- a/test/qwp/core.test.ts +++ b/test/qwp/core.test.ts @@ -22,7 +22,6 @@ import { QWP_MAX_ERROR_MESSAGE_LENGTH, QWP_MAX_ROWS_PER_TABLE, QWP_MAX_SYMBOL_DICTIONARY_SIZE, - decodeQwpIngressResponse, QWP_COMPRESSION_CODEC, QWP_DURABLE_ACK_WEBSOCKET_PROTOCOL, QWP_EGRESS_CAPABILITY, diff --git a/test/qwp/egress.test.ts b/test/qwp/egress.test.ts index 7e3ad29..be8af54 100644 --- a/test/qwp/egress.test.ts +++ b/test/qwp/egress.test.ts @@ -52,7 +52,7 @@ function writeU16String(writer: QwpByteWriter, value: string): void { } function serverInfo( - capabilities = QWP_EGRESS_CAPABILITY.QUERY_FLAGS, + capabilities: number = QWP_EGRESS_CAPABILITY.QUERY_FLAGS, ): Uint8Array { const payload = new QwpByteWriter(); payload @@ -233,7 +233,7 @@ function scalarResultBatch(): Uint8Array { function queryError( requestId: bigint, message: string, - status = QWP_STATUS.PARSE_ERROR, + status: number = QWP_STATUS.PARSE_ERROR, ): Uint8Array { const bytes = new TextEncoder().encode(message); const payload = new QwpByteWriter(); diff --git a/test/qwp/node-transport.test.ts b/test/qwp/node-transport.test.ts index 330561a..8afc8ea 100644 --- a/test/qwp/node-transport.test.ts +++ b/test/qwp/node-transport.test.ts @@ -34,7 +34,7 @@ import { } from "../../src/qwp/node"; function serverInfo( - role = QWP_SERVER_ROLE.STANDALONE, + role: number = QWP_SERVER_ROLE.STANDALONE, zone?: string, ): Uint8Array { const capabilities = zone === undefined ? 0 : QWP_EGRESS_CAPABILITY.ZONE; @@ -148,7 +148,7 @@ describe("QWP Node transport", () => { it("negotiates durable ACK and polls progress with a WebSocket PING", async () => { const table = "trades"; const sequenceTransaction = 7n; - let requestedDurableAck: string | undefined; + let requestedDurableAck: string | string[] | undefined; let pingCount = 0; server = new WebSocketServer({ host: "127.0.0.1", port: 0 }); @@ -201,7 +201,7 @@ describe("QWP Node transport", () => { }); it("surfaces the server-clamped Zstd level from a real upgrade", async () => { - let acceptEncoding: string | undefined; + let acceptEncoding: string | string[] | undefined; server = new WebSocketServer({ host: "127.0.0.1", port: 0 }); server.on("headers", (headers) => { headers.push("X-QWP-Version: 1"); diff --git a/test/qwp/public-api-contract.ts b/test/qwp/public-api-contract.ts index a9eb832..4e4f1de 100644 --- a/test/qwp/public-api-contract.ts +++ b/test/qwp/public-api-contract.ts @@ -58,6 +58,7 @@ import type { QwpNodeUdpOptions, QwpNodeUdpSession, QwpNodeOrphanDrainEvent, + QwpNodeOrphanDrainSession, QwpNodeReplayRecoveryEvent, QwpNodeStoreAndForwardOptions, QwpNodeWebSocketOptions, @@ -185,6 +186,14 @@ const nodeOrphanScanSignature: ( const nodeOrphanRetrySignature: (directory: string) => Promise = retryQwpNodeOrphanSlot; +// QwpNodeOrphanDrainerOptions.createSession returns this, so anyone +// implementing that interface must be able to name it. +const nodeOrphanDrainSessionContract: ( + session: QwpNodeOrphanDrainSession, +) => Promise = async (session) => { + await session.closed; +}; + const nodeStoreAndForwardContract: QwpNodeStoreAndForwardOptions = { directory: "/tmp/qwp-public-api-contract", maxSegmentBytes: 4 * 1024 * 1024, @@ -499,6 +508,7 @@ void nodeClientSignature; void poolOptionsContract; void nodeOrphanScanSignature; void nodeOrphanRetrySignature; +void nodeOrphanDrainSessionContract; void nodeStoreAndForwardContract; void queryOptionsContract; void egressSessionOptionsContract; diff --git a/test/qwp/reconnect.test.ts b/test/qwp/reconnect.test.ts index db7bd60..aff416b 100644 --- a/test/qwp/reconnect.test.ts +++ b/test/qwp/reconnect.test.ts @@ -126,9 +126,9 @@ function writeUint16String(writer: QwpByteWriter, value: string): void { function serverInfo( node: string, - role = QWP_SERVER_ROLE.STANDALONE, + role: number = QWP_SERVER_ROLE.STANDALONE, zone?: string, - capabilities = QWP_EGRESS_CAPABILITY.QUERY_FLAGS, + capabilities: number = QWP_EGRESS_CAPABILITY.QUERY_FLAGS, ): Uint8Array { const advertisedCapabilities = capabilities | (zone === undefined ? 0 : QWP_EGRESS_CAPABILITY.ZONE); @@ -3321,7 +3321,7 @@ describe("QWP egress reconnect and replay", () => { maxBackoffMs: 0, }, bufferPoolSize: 1, - onReplayReset: (event) => resets.push(event.requestId), + onReplayReset: (event) => void resets.push(event.requestId), }, ); const query = await session.query("select * from x"); @@ -3383,7 +3383,7 @@ describe("QWP egress reconnect and replay", () => { initialBackoffMs: 0, maxBackoffMs: 0, }, - onReplayReset: (event) => resets.push(event.requestId), + onReplayReset: (event) => void resets.push(event.requestId), }, ); const query = await session.queryViews("select * from x", async () => { diff --git a/test/qwp/sender.test.ts b/test/qwp/sender.test.ts index 1c9a945..2034041 100644 --- a/test/qwp/sender.test.ts +++ b/test/qwp/sender.test.ts @@ -1501,10 +1501,10 @@ describe("QWP high-level sender", () => { await expect( trades.row({ price: 1, timestamp: 1n, prise: 2 } as never), - ).rejects.toMatchObject({ + ).rejects.toMatchObject({ columnName: "prise", rowIndex: undefined, - }); + } satisfies Partial); expect(() => sender.writer("trades", { timestamp: designatedTimestamp("ns"), diff --git a/test/qwp/session.test.ts b/test/qwp/session.test.ts index 2540e5a..9127215 100644 --- a/test/qwp/session.test.ts +++ b/test/qwp/session.test.ts @@ -40,6 +40,7 @@ import { QWP_DURABLE_ACK_WEBSOCKET_PROTOCOL, QwpIngressAckTimeoutError, QwpIngressNackError, + QwpIngressResponse, QwpIngressSession, QwpIngressSessionClosedError, type QwpSenderError, @@ -274,6 +275,14 @@ function ingressServerInfo(maxBatchSizeBytes: number): Uint8Array { .toUint8Array(); } +/** + * `toMatchObject` matches nested objects partially, but `Partial` only + * relaxes the top level, so the nested response needs relaxing too. + */ +type QwpIngressNackMatch = Partial> & { + response: Partial; +}; + describe("QWP WebSocket adapters", () => { it.each(["browser", "node"] as const)( "validates %s timeouts before creating a WebSocket", @@ -674,23 +683,27 @@ describe("QWP WebSocket adapters", () => { // the upgrade used to find nothing to cancel: the socket and its deadline // stayed alive for up to connectTimeoutMs after close() had resolved. const sockets: FakeWebSocket[] = []; - const session = await connectQwpBrowserIngress({ - url: "ws://stalls.example/write/v4", - connectTimeoutMs: 30_000, - reconnect: { initialBackoffMs: 0, maxBackoffMs: 0 }, - webSocketFactory: () => { - const socket = new FakeWebSocket(); - sockets.push(socket); - if (sockets.length === 1) { - queueMicrotask(() => { - socket.open(); - socket.message(ingressServerInfo(128)); - }); - } - // Every replacement is left hanging mid-upgrade. - return asQwpSocket(socket); + const session = await connectQwpBrowserIngress( + { + url: "ws://stalls.example/write/v4", + connectTimeoutMs: 30_000, + webSocketFactory: () => { + const socket = new FakeWebSocket(); + sockets.push(socket); + if (sockets.length === 1) { + queueMicrotask(() => { + socket.open(); + socket.message(ingressServerInfo(128)); + }); + } + // Every replacement is left hanging mid-upgrade. + return asQwpSocket(socket); + }, }, - }); + // Reconnection is a session policy, not a socket option; passing it in + // the first argument silently dropped it and left the default backoff. + { reconnect: { initialBackoffMs: 0, maxBackoffMs: 0 } }, + ); sockets[0].close(1006, "dropped"); await vi.waitFor(() => expect(sockets.length).toBeGreaterThan(1)); @@ -1461,7 +1474,7 @@ describe("QWP WebSocket adapters", () => { socket.open(); const connection = await connecting; const next = connection.messages[Symbol.asyncIterator]().next(); - socket.message(new NeverSettlingBlob()); + socket.message(new NeverSettlingBlob([])); await vi.advanceTimersByTimeAsync(0); const closing = connection.close(); @@ -1831,7 +1844,7 @@ describe("QwpIngressSession", () => { await expect(session.waitForAcknowledged(1n, 1_000)).rejects.toMatchObject({ name: "QwpIngressNackError", response: { sequence: 0n, errorMessage: "write failed" }, - } satisfies Partial); + } satisfies QwpIngressNackMatch); await expect(session.waitForAcknowledged(-1n)).resolves.toBeUndefined(); await session.close(); }); @@ -2341,7 +2354,7 @@ describe("QwpIngressSession", () => { await expect(session.sendFrame(Uint8Array.of(1))).rejects.toMatchObject({ name: "QwpIngressNackError", response: { sequence: 0n, errorMessage: "write failed" }, - } satisfies Partial); + } satisfies QwpIngressNackMatch); await expect(session.sendFrame(Uint8Array.of(2))).resolves.toMatchObject({ sequence: 1n, status: QWP_STATUS.OK, diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 0000000..9017ad8 --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "skipLibCheck": true + }, + "include": ["src", "test"] +} From a11f2dd1c7c5f86fce7de910f85e3ff7d9a2145b Mon Sep 17 00:00:00 2001 From: glasstiger Date: Fri, 21 Aug 2026 19:51:48 +0100 Subject: [PATCH 132/265] fix(ci): keep test/dist-types out of the test type-check test/dist-types imports the package by its published name, which resolves only against a built dist/. tsconfig.test.json included all of test/, so those files were type-checked before any build ran and failed with TS2307. It passed locally only because a dist/ from an earlier build was still present; verified now by removing dist/ first, which reproduces CI. Those files already belong to tsconfig.dist-types*.json, which typecheck:dist runs after pnpm build, so excluding them moves no coverage. Co-Authored-By: Claude Opus 5 (1M context) --- tsconfig.test.json | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tsconfig.test.json b/tsconfig.test.json index 9017ad8..e38df0d 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -4,5 +4,9 @@ "noEmit": true, "skipLibCheck": true }, - "include": ["src", "test"] + "include": ["src", "test"], + // test/dist-types imports the package by its published name, which only + // resolves against a built dist/. Those files belong to + // tsconfig.dist-types*.json, which typecheck:dist runs after pnpm build. + "exclude": ["test/dist-types"] } From 3b494f5f0b1b55c27215b1a0f99c2084ca5f6ef5 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Fri, 21 Aug 2026 22:14:03 +0100 Subject: [PATCH 133/265] fix(ci): make the build-artifact gate runnable The `Check for build artifacts` step could never run. Its body was a single-quoted `node -e '...'` containing a regex character class `['\"]`, and a YAML block scalar performs no escape processing, so three literal single quotes reached the shell. That closes the `node -e` argument at a bare `(`: bash, sh, dash and zsh all fail to parse it, exit 2, and the `Publish` step that follows never runs. Behind that sat a second failure. The walk matched `from "./x"` anywhere in the raw text of an emitted file, including inside a comment the bundler preserved. src/_qwp/writer.ts explains the writer column brand with the sentence "a schema built with the factories from './qwp' would be rejected by the writer() of a sender imported from './qwp/node'", which the pattern read as two imports and reported as four missing artifacts, exiting 1 against a clean build. Move the script to scripts/check-build-artifacts.mjs, where it needs no shell quoting, and anchor the pattern to specifiers that name an emitted file so prose cannot look like an import. Also run it in build.yml: the gate existing only in the release workflow is why neither failure was visible on a pull request. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/build.yml | 6 ++++ .github/workflows/publish.yml | 31 +----------------- scripts/check-build-artifacts.mjs | 53 +++++++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 30 deletions(-) create mode 100644 scripts/check-build-artifacts.mjs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b6ce024..b704544 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -77,6 +77,12 @@ jobs: - name: Type-checking (built package consumer) run: pnpm typecheck:dist + # The same gate publish.yml runs. Keeping it here too means a chunk that + # escapes `files`, or a regression in the check itself, fails a pull + # request instead of first being discovered during a release. + - name: Check for build artifacts + run: node scripts/check-build-artifacts.mjs + # Drives the built browser bundle in real Chromium against a local mock # server. Tests that need a live QuestDB belong in the server repositories, # where the topology and authentication fixtures already exist. diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 7039c73..1c91ae3 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -54,36 +54,7 @@ jobs: # so the walk follows relative imports: a chunk left out of `files` # publishes a package whose every entry resolves to a missing file. - name: Check for build artifacts - run: | - node -e ' - const fs = require("node:fs"); - const { exports: map } = require("./package.json"); - const missing = []; - const path = require("node:path"); - const seen = new Set(); - const walk = (file, from) => { - if (!fs.existsSync(file)) { missing.push(from + " -> " + file); return; } - const key = path.resolve(file); - if (seen.has(key)) return; - seen.add(key); - const source = fs.readFileSync(file, "utf8"); - for (const [, spec] of source.matchAll(/(?:from|require\()\s*['\"](\.[^'\"]+)['\"]/g)) { - walk(path.join(path.dirname(file), spec), file); - } - }; - for (const [subpath, conditions] of Object.entries(map)) { - for (const target of Object.values(conditions)) { - for (const file of Object.values(target)) { - walk(file, subpath); - } - } - } - if (missing.length > 0) { - console.error("missing build artifacts:\n " + missing.join("\n ")); - process.exit(1); - } - console.log("all " + Object.keys(map).length + " export subpaths present"); - ' + run: node scripts/check-build-artifacts.mjs - name: Publish uses: JS-DevTools/npm-publish@v3 diff --git a/scripts/check-build-artifacts.mjs b/scripts/check-build-artifacts.mjs new file mode 100644 index 0000000..f8f7c84 --- /dev/null +++ b/scripts/check-build-artifacts.mjs @@ -0,0 +1,53 @@ +// Verifies that every package `exports` target exists, and that the shared +// chunks those entries import were published too. Entry bundles import chunks +// that no `exports` entry names, so a chunk left out of `files` would publish a +// package whose every entry resolves to a missing file. +// +// This lives in a file rather than inline in the workflow because the pattern +// below needs both quote characters, which cannot survive a single-quoted +// `node -e` argument in a YAML block scalar. +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; + +// Only specifiers that name an emitted file. Matching every `from "./x"` in the +// raw text would also match prose inside a comment the bundler preserved -- a +// sentence such as "the factories from './qwp'" is not an import, and treating +// it as one reports a build artifact that was never meant to exist. +const SPECIFIER = + /(?:\bfrom|\brequire\(|\bimport\()\s*["'](\.[^"']*\.(?:d\.)?[mc]?[jt]s)["']/g; + +const { exports: map } = JSON.parse(readFileSync("package.json", "utf8")); + +const missing = []; +const seen = new Set(); + +const walk = (file, from) => { + if (!existsSync(file)) { + missing.push(`${from} -> ${file}`); + return; + } + const key = resolve(file); + if (seen.has(key)) return; + seen.add(key); + const source = readFileSync(file, "utf8"); + for (const [, specifier] of source.matchAll(SPECIFIER)) { + walk(join(dirname(file), specifier), file); + } +}; + +for (const [subpath, conditions] of Object.entries(map)) { + for (const target of Object.values(conditions)) { + for (const file of Object.values(target)) { + walk(file, subpath); + } + } +} + +if (missing.length > 0) { + console.error(`missing build artifacts:\n ${missing.join("\n ")}`); + process.exit(1); +} + +console.log( + `all ${Object.keys(map).length} export subpaths present, ${seen.size} files walked`, +); From 93cbad4db023adcd9fe38febe1a4cdac6e5537ff Mon Sep 17 00:00:00 2001 From: glasstiger Date: Fri, 21 Aug 2026 22:28:01 +0100 Subject: [PATCH 134/265] fix(qwp): attach socket listeners before an aborted signal closes them A multi-address Node client closed while it was reconnecting terminated the host process with an uncaught exception and exit code 1. close() aborts the shared connect AbortSignal. The endpoint being negotiated rejects with QwpSendClosedError, which is not a QwpUpgradeError, so the failover sweep in createQwpFailoverConnectionFactory does not stop: it moves to the next endpoint carrying the same, now aborted signal. openQwpWebSocket saw signal.aborted, closed the socket and returned early -- but its open/message/error/close listeners are attached at the very end of the executor, so that socket had none. `ws` answers close() on a CONNECTING socket by emitting `error` on a later tick, and an EventEmitter with no `error` listener rethrows into the process. A catch around close() cannot stop it: the throw arrives after close() has already resolved. Record the pre-aborted signal instead and apply it after the listeners are attached, so the close it triggers has a subscriber and the promise rejects the way every other failure does. Also attach a throwaway error listener before the timeout-validation teardown, which tears down a CONNECTING socket that nothing has subscribed to yet for the same reason. Reproduced 10/10 before the fix and 0/25 after, driving the built ESM bundle from a fresh child process and reading the raw exit code. The regression test asserts the ordering directly rather than the crash, because `ws` defers the emit and a synchronous throw would only be swallowed by closeSocket()'s own try/catch. Co-Authored-By: Claude Opus 5 (1M context) --- src/_qwp/_internal/websocket-connection.ts | 25 +++++++-- test/qwp/session.test.ts | 59 +++++++++++++++++++++- 2 files changed, 79 insertions(+), 5 deletions(-) diff --git a/src/_qwp/_internal/websocket-connection.ts b/src/_qwp/_internal/websocket-connection.ts index 468f9ae..d98d1b5 100644 --- a/src/_qwp/_internal/websocket-connection.ts +++ b/src/_qwp/_internal/websocket-connection.ts @@ -131,6 +131,9 @@ async function normalizeBinaryMessage(data: unknown): Promise { } /** Wraps a WHATWG-style WebSocket and resolves once its opening handshake succeeds. */ +/** Absorbs a socket `error` raised before the real listeners are attached. */ +const ignoreSocketError = (): void => undefined; + export function openQwpWebSocket( socket: QwpWebSocketLike, options: QwpWebSocketOpenOptions, @@ -139,6 +142,10 @@ export function openQwpWebSocket( validateQwpWebSocketTimeouts(options); } catch (error) { try { + // Tearing down a CONNECTING socket makes `ws` emit `error`, and nothing + // has subscribed to this one yet. Absorb it rather than let an + // EventEmitter with no listener rethrow it into the process. + socket.addEventListener("error", ignoreSocketError); if (socket.terminate) socket.terminate(); else if (socket.readyState !== WEBSOCKET_CLOSED) socket.close(); } catch { @@ -401,12 +408,20 @@ export function openQwpWebSocket( "QWP connection closed while connecting", ); }; + // Aborting closes the socket, and closing a CONNECTING `ws` socket makes it + // emit `error` on the next tick. This executor attaches the socket's + // listeners last, so acting on an already-aborted signal here would leave + // that event unhandled and terminate the process. A failover sweep hands + // the same signal to every remaining endpoint after close() aborts it, so + // this is the ordinary shape for a multi-address client, not a rare race. + // Record the abort and apply it once the listeners are in place. + let abortedBeforeListening = false; if (options.signal) { if (options.signal.aborted) { - abortOpening(); - return; + abortedBeforeListening = true; + } else { + options.signal.addEventListener("abort", abortOpening, { once: true }); } - options.signal.addEventListener("abort", abortOpening, { once: true }); } armOpeningTimeout( @@ -591,5 +606,9 @@ export function openQwpWebSocket( : new Error("failed to configure QWP WebSocket listeners"), ); } + // Safe now: `onError` is attached, so the close this triggers has a + // subscriber. A failed attachment above already settled the opening, and + // failOpening() is idempotent, so this is a no-op in that case. + if (abortedBeforeListening) abortOpening(); }); } diff --git a/test/qwp/session.test.ts b/test/qwp/session.test.ts index 9127215..d8ff8c2 100644 --- a/test/qwp/session.test.ts +++ b/test/qwp/session.test.ts @@ -51,6 +51,7 @@ import { QwpSymbolDictionary, readQwpVarintNumber, } from "../../src/qwp"; +import { openQwpWebSocket } from "../../src/_qwp/_internal/websocket-connection"; type Listener = (event: unknown) => void; @@ -62,7 +63,7 @@ class FakeWebSocket { readonly sent: Uint8Array[] = []; readonly closeCalls: { code?: number; reason?: string }[] = []; onSend?: (payload: Uint8Array) => void; - private readonly listeners = new Map(); + protected readonly listeners = new Map(); addEventListener(type: string, listener: Listener): void { const listeners = this.listeners.get(type) ?? []; @@ -113,9 +114,35 @@ class FakeWebSocket { this.emit("error", {}); } - private emit(type: string, event: unknown): void { + protected emit(type: string, event: unknown): void { for (const listener of this.listeners.get(type) ?? []) listener(event); } + + protected listenerCountFor(type: string): number { + return (this.listeners.get(type) ?? []).length; + } +} + +/** + * Records whether an `error` listener existed each time close() was called. + * + * Closing a socket that is still CONNECTING makes `ws` emit `error`, and it + * does so on a later tick, so a synchronous throw here would only be swallowed + * by closeSocket()'s own try/catch and prove nothing. `ws` is an EventEmitter + * rather than an EventTarget, so that deferred `error` is rethrown into the + * process when nothing is subscribed. The observable invariant this client has + * to hold is therefore the ordering itself: never close a connecting socket + * before its error listener is attached. + */ +class FakeNodeWebSocket extends FakeWebSocket { + readonly errorListenerAtClose: boolean[] = []; + + close(code?: number, reason?: string): void { + if (this.readyState !== 3) { + this.errorListenerAtClose.push(this.listenerCountFor("error") > 0); + } + super.close(code, reason); + } } class FakeStuckCloseWebSocket extends FakeWebSocket { @@ -715,6 +742,34 @@ describe("QWP WebSocket adapters", () => { expect(pending.closeCalls.length).toBeGreaterThan(0); }); + it("attaches the socket error listener before an aborted signal closes it", async () => { + // A failover sweep hands one AbortSignal to every endpoint in turn, so + // after close() aborts it the next endpoint enters openQwpWebSocket with + // the signal already aborted. Acting on it before the listeners were + // attached closed a CONNECTING socket nothing was subscribed to, and `ws` + // rethrew the resulting 'error' out of the process instead of rejecting. + const socket = new FakeNodeWebSocket(); + const controller = new AbortController(); + controller.abort(); + + await expect( + openQwpWebSocket(asQwpSocket(socket), { + url: "ws://aborted.example/write/v4", + signal: controller.signal, + // Never reached: the abort settles the opening before any upgrade. + completeHandshake: () => ({ qwpVersion: 1, maxBatchSizeBytes: 128 }), + connectTimeoutMs: 50, + authTimeoutMs: 50, + sendTimeoutMs: 50, + closeTimeoutMs: 50, + }), + ).rejects.toBeInstanceOf(QwpSendClosedError); + + // The socket is still closed; only the ordering changed. + expect(socket.closeCalls.length).toBeGreaterThan(0); + expect(socket.errorListenerAtClose).not.toContain(false); + }); + it("uses the browser-selected ingress batch cap automatically", async () => { const socket = new FakeWebSocket(); let capturedUrl: string | URL | undefined; From 5f305bf7cdf35ef0c0ab852713b3eca3cdc9ec4e Mon Sep 17 00:00:00 2001 From: glasstiger Date: Fri, 21 Aug 2026 22:41:59 +0100 Subject: [PATCH 135/265] fix(qwp): prove advisory-lock ownership with an acquisition token The slot lock identified an acquisition by pathname and mtime, both of which are reused the instant a lock changes hands. Two consequences, each reproduced through QwpNodeFileReplayStore against real directories. A release removed the owner directory by path. Because a failed release is parked on a module-global list and retried before the next acquisition of any lock in the process, a stalled holder that later opened an unrelated journal deleted the owner directory of whichever process held that first pathname by then. A fourth process could then open a journal a third was already appending to. Observed: owner inode 1414684522 -> null, then "T opened slotA while R holds it". Staleness fell back to the `.lock.pid` sidecar when the owner record could not be read, and stamped it with the local hostname. The sidecar deliberately outlives its holder for Java parity, so it always names a process that has exited, and the fabricated hostname satisfied the same-host guard that was supposed to make a foreign PID meaningless. Every acquisition is briefly recordless, between its mkdir and its record write, so a contender arriving in that window judged a directory that had just been created stale and renamed it away from its live owner. The comment on that fallback already said a sidecar "can only expire by mtime"; the code did not. Write a per-acquisition token into the owner record, verify it before removing anything, and let a recordless directory expire by mtime alone as intended. Deterministic before/after on the mid-acquisition state: "ACQUIRED (stole it), ownerDir replaced" becomes "refused: QwpReplayStoreLockedError, ownerDir intact", with an owner record naming a live PID still refused in both. This does not change the documented mtime reclaim of a genuinely stale slot, which is covered separately. Co-Authored-By: Claude Opus 5 (1M context) --- src/qwp-node/advisory-lock.ts | 91 ++++++++++++++++++++++++++--------- test/qwp/reconnect.test.ts | 48 ++++++++++++++++++ 2 files changed, 115 insertions(+), 24 deletions(-) diff --git a/src/qwp-node/advisory-lock.ts b/src/qwp-node/advisory-lock.ts index e9d213d..accd245 100644 --- a/src/qwp-node/advisory-lock.ts +++ b/src/qwp-node/advisory-lock.ts @@ -9,6 +9,7 @@ import { utimes, writeFile, } from "node:fs/promises"; +import { randomUUID } from "node:crypto"; import { hostname } from "node:os"; import { basename, dirname, join, resolve } from "node:path"; @@ -42,6 +43,13 @@ let stealCounter = 0; interface OwnerRecord { readonly pid: number; readonly host: string; + /** + * Identifies one acquisition, not one pathname. Ownership is otherwise a + * path plus an mtime, and both are reused the moment a lock changes hands, + * so a holder that removed a directory by path alone could remove whichever + * acquisition happens to occupy that path now. + */ + readonly token?: string; } /** @internal Advisory-lock contention with Java-compatible diagnostics. */ @@ -92,6 +100,7 @@ export class QwpNodeAdvisoryLock { readonly pidPath: string, private readonly ownerPath: string, private ownerMtimeMs: number, + private readonly token: string, ) { this.startHeartbeat(); } @@ -146,7 +155,7 @@ export class QwpNodeAdvisoryLock { // leaves no metadata behind for a slot this process does not own. let claimed = await claimOwnerDirectory(ownerPath); if (!claimed) { - if (await reclaimIfStale(ownerPath, pidPath)) { + if (await reclaimIfStale(ownerPath)) { claimed = await claimOwnerDirectory(ownerPath); } if (!claimed) { @@ -157,11 +166,12 @@ export class QwpNodeAdvisoryLock { } } + const token = newOwnerToken(); let ownerMtimeMs: number; try { await writeFile( join(ownerPath, OWNER_FILE), - JSON.stringify({ pid: process.pid, host: hostname() }), + JSON.stringify({ pid: process.pid, host: hostname(), token }), { encoding: "utf8", mode: 0o600 }, ); ownerMtimeMs = await touchOwnerDirectory(ownerPath); @@ -189,7 +199,13 @@ export class QwpNodeAdvisoryLock { flag: "w", mode: 0o600, }).catch(() => undefined); - return new QwpNodeAdvisoryLock(lockPath, pidPath, ownerPath, ownerMtimeMs); + return new QwpNodeAdvisoryLock( + lockPath, + pidPath, + ownerPath, + ownerMtimeMs, + token, + ); } async release(): Promise { @@ -205,6 +221,14 @@ export class QwpNodeAdvisoryLock { this.lockPath, ); } + // A release can be retried long after the fact, by which time the pathname + // may hold somebody else's acquisition. Removing it then would strip a + // live lock, so prove the directory is still the one this object created. + if (!(await this.ownsOwnerDirectory())) { + this.released = true; + pendingReleases.delete(this); + return; + } try { await removeOwnerDirectory(this.ownerPath); } catch (error) { @@ -221,6 +245,16 @@ export class QwpNodeAdvisoryLock { pendingReleases.delete(this); } + /** + * Whether the owner directory still carries this acquisition's token. A + * missing directory, an unreadable record, or a different token all mean + * this object no longer owns the pathname. + */ + private async ownsOwnerDirectory(): Promise { + const owner = await readOwnerFile(this.ownerPath); + return owner?.token !== undefined && owner.token === this.token; + } + private startHeartbeat(): void { this.heartbeat = setInterval(() => { void this.beat(); @@ -291,10 +325,7 @@ async function touchOwnerDirectory(ownerPath: string): Promise { * aside first: `rename` lets exactly one contender win, so a lock can never be * removed twice and handed to two acquirers. */ -async function reclaimIfStale( - ownerPath: string, - pidPath: string, -): Promise { +async function reclaimIfStale(ownerPath: string): Promise { let mtimeMs: number; try { mtimeMs = (await stat(ownerPath)).mtimeMs; @@ -302,7 +333,7 @@ async function reclaimIfStale( // Already gone; the caller's next mkdir decides the winner. return true; } - if (!(await isStale(ownerPath, pidPath, mtimeMs))) return false; + if (!(await isStale(ownerPath, mtimeMs))) return false; const abandoned = `${ownerPath}.stale-${process.pid}-${stealCounter++}`; try { @@ -315,42 +346,54 @@ async function reclaimIfStale( return true; } -async function isStale( - ownerPath: string, - pidPath: string, - mtimeMs: number, -): Promise { +async function isStale(ownerPath: string, mtimeMs: number): Promise { if (Date.now() - mtimeMs > STALE_AFTER_MS) return true; // Fast path for a crash on this host: a heartbeat that can never resume is // stale immediately. A PID is meaningless on another host, so this is only - // consulted when the recorded host matches. - const owner = await readOwnerRecord(ownerPath, pidPath); + // consulted when the record itself names this host. + // + // A directory with no readable record expires by mtime alone. It used to + // fall back to the `.lock.pid` sidecar, which deliberately outlives its + // holder for Java parity and therefore always names a process that has + // already exited -- and the fallback stamped that dead PID with the local + // hostname, so the host check below could not reject it. Every acquisition + // is briefly recordless, between its mkdir and its record write, so a + // contender arriving in that window declared a directory that had just been + // created stale and took it away from its live owner. + const owner = await readOwnerFile(ownerPath); return ( owner !== undefined && owner.host === hostname() && !isPidAlive(owner.pid) ); } -async function readOwnerRecord( +async function readOwnerFile( ownerPath: string, - pidPath: string, ): Promise { try { const parsed: unknown = JSON.parse( await readFile(join(ownerPath, OWNER_FILE), "utf8"), ); if (parsed && typeof parsed === "object") { - const { pid, host } = parsed as Partial; + const { pid, host, token } = parsed as Partial; if (typeof pid === "number" && typeof host === "string") { - return { pid, host }; + return { + pid, + host, + token: typeof token === "string" ? token : undefined, + }; } } } catch { - // Fall through: a lock written by an older client, or a torn write. + // A record written by an older client, a torn write, or an acquisition + // that has not written its record yet. None of them prove a holder is + // gone, so the caller falls back to the mtime heartbeat. } - // A slot locked before the owner record existed still has the PID sidecar, - // but nothing proves which host wrote it, so it can only expire by mtime. - const pid = await readHolderPid(pidPath); - return pid === undefined ? undefined : { pid, host: hostname() }; + return undefined; +} + +/** Identifies one acquisition, so a release can prove what it is removing. */ +function newOwnerToken(): string { + return `${process.pid}-${randomUUID()}`; } function isPidAlive(pid: number): boolean { diff --git a/test/qwp/reconnect.test.ts b/test/qwp/reconnect.test.ts index aff416b..5bf6dc9 100644 --- a/test/qwp/reconnect.test.ts +++ b/test/qwp/reconnect.test.ts @@ -72,6 +72,7 @@ import { decodeQwpIngressSymbolDictionaryDelta, writeQwpVarint, } from "../../src/qwp"; +import { QwpNodeAdvisoryLock } from "../../src/qwp-node/advisory-lock"; import { QwpAsyncQueue } from "../../src/_qwp/_internal/async-queue"; import { qwpSegmentMaintenanceWorker } from "../../src/qwp-node/segment-maintenance-worker"; import { createQwpEgressFailoverConnectionFactory } from "../../src/_qwp/_internal/egress-routing"; @@ -4099,6 +4100,53 @@ describe("QWP Node file replay store", () => { await expectOnlyJavaSlotLockMetadata(directory); }); + it("treats an owner directory with no record yet as held", async () => { + // The state every acquisition passes through between its mkdir and its + // owner-record write. Staleness used to fall back to the `.lock.pid` + // sidecar, which outlives its holder for Java parity and so always names a + // process that has exited -- and it stamped that dead PID with the local + // hostname, so the same-host guard could not reject it. A contender + // arriving in that window declared a just-created directory stale and + // renamed it away from its live owner. + const directory = await trackedDirectory(); + await mkdir(join(directory, ".lock.owner")); + await writeFile(join(directory, ".lock"), ""); + await writeFile(join(directory, ".lock.pid"), "2147483647\n"); + const ownerInode = (await stat(join(directory, ".lock.owner"))).ino; + + const store = new QwpNodeFileReplayStore({ directory }); + await expect(store.load()).rejects.toMatchObject({ + name: "QwpReplayStoreLockedError", + }); + expect((await stat(join(directory, ".lock.owner"))).ino).toBe(ownerInode); + }); + + it("does not remove an owner directory a later acquisition owns", async () => { + // A release can be retried long after the fact, and the pathname it holds + // is reused the moment the lock changes hands. Removing by path alone + // stripped whichever acquisition occupied the path at that point. + const directory = await trackedDirectory(); + const lock = await QwpNodeAdvisoryLock.acquire(directory); + const ownerFile = join(directory, ".lock.owner", "owner"); + + // Stand in for the pathname having been handed to another acquisition. + await writeFile( + ownerFile, + JSON.stringify({ + pid: process.pid, + host: hostname(), + token: "someone-else", + }), + ); + + await lock.release(); + await expect(stat(join(directory, ".lock.owner"))).resolves.toBeDefined(); + expect(JSON.parse(await readFile(ownerFile, "utf8")).token).toBe( + "someone-else", + ); + await rm(join(directory, ".lock.owner"), { recursive: true, force: true }); + }); + it("reclaims a slot whose owner heartbeat stopped", async () => { const directory = await trackedDirectory(); const ownerPath = join(directory, ".lock.owner"); From 8870a2823c2e0ff172dee31b8d867ab7c38233d3 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Fri, 21 Aug 2026 22:49:07 +0100 Subject: [PATCH 136/265] fix(qwp): stop writing to a journal whose slot lock was reclaimed A store-and-forward holder that stopped heartbeating kept appending to its journal after another process had taken the slot over, destroying frames the new owner had already durably appended. Nothing on the write path consulted the lock. `compromised` was read in exactly two places, both inside advisory-lock.ts, so the reclaim was visible only at close() -- long after the damage. appendOnce() writes at SEGMENT_HEADER_SIZE + logicalSize through an already-open handle, so the resumed holder wrote at offsets the new owner had moved past, and a frame's sequence is derived from its position in the segment: a same-width overwrite leaves a journal that reopens with contiguous sequences, valid CRCs, no torn tail, and no data-loss report. Three changes make the loss impossible: - assertReady() -- the chokepoint every mutating path already routes through -- fails with the new QwpReplayStoreLockLostError once the slot lock can no longer be vouched for. - The heartbeat treats ENOENT as proof of loss. It previously waited for a drifted mtime, which a removed directory can never produce, so a lock whose directory was simply deleted was never noticed at all. It also compares the acquisition token, because an mtime cannot separate our directory from a replacement made inside the same clock tick, and some filesystems only report whole seconds. - Ownership expires on elapsed time, not only on the heartbeat firing. The heartbeat is a timer, so the very block that loses the lock also stops the timer that would notice; the first write after resuming landed before it could run. This is conservative by design: the holder gives up as soon as a contender could have taken the slot. Two processes, real directories, a 20s main-thread block. Before: the resumed holder's five appends all resolved, close() was clean, no callback fired, and the byte census read A=1280 B=0 -- every one of the new owner's durable frames gone. After: all five appends fail with QwpReplayStoreLockLostError and the census reads A=640 B=640. QWP.md documented the reclaim but not what the reclaimed holder then did with its open handle, and claimed a second process cannot mutate journal contents. Both paragraphs now describe the actual contract. Co-Authored-By: Claude Opus 5 (1M context) --- QWP.md | 26 ++++++++++++++----- src/qwp-node/advisory-lock.ts | 42 +++++++++++++++++++++++++++---- src/qwp-node/file-replay-store.ts | 27 ++++++++++++++++++++ src/qwp/node.ts | 1 + test/qwp/reconnect.test.ts | 27 ++++++++++++++++++++ 5 files changed, 112 insertions(+), 11 deletions(-) diff --git a/QWP.md b/QWP.md index 93df938..00b5d3c 100644 --- a/QWP.md +++ b/QWP.md @@ -333,7 +333,9 @@ the surviving frames. The journal takes an exclusive lock when it is loaded and holds it until the sender or session closes. A second live Node.js process using the same directory fails with -`QwpReplayStoreLockedError` before recovery or cleanup can mutate journal contents. +`QwpReplayStoreLockedError` before recovery or cleanup can mutate journal contents, +unless the first has stopped heartbeating long enough to be reclaimed — in which case +it is the first that stops writing, as described under the heartbeat below. Ownership is held by a `.lock.owner` directory created next to the slot: `mkdir` is the only exclusive-by-construction filesystem operation available on every supported platform without a native addon, so exactly one process can create it. The holder PID @@ -356,11 +358,23 @@ therefore refreshes the owner directory's mtime every 5 seconds, and a contender reclaims a slot whose mtime has not advanced for 15 seconds. A contender also reclaims immediately when the owner record names a process that no longer exists on the same host, which is the common case after a crash. A stale owner directory is renamed aside -before removal, so two contenders racing to reclaim one slot cannot both win it. If a -holder is paused long enough for its heartbeat to lapse — `SIGSTOP`, a suspended VM, -or a stalled filesystem — its lock can be reclaimed while it still believes it holds -it; the original holder detects the reclaim at its next heartbeat and stops refreshing -so that only the new owner advances the mtime. +before removal, so two contenders racing to reclaim one slot cannot both win it. Each +acquisition also writes a token into the owner record and checks it before removing +anything, so a release can never take away a directory that has since been handed to +somebody else. + +If a holder is paused long enough for its heartbeat to lapse — `SIGSTOP`, a suspended +VM, a stalled filesystem, or any synchronous section that blocks the event loop for +more than 15 seconds — its lock can be reclaimed while it still believes it holds it. +Such a holder stops writing: once it can no longer vouch for its own lock, every +append, checkpoint and acknowledgement on that journal fails with +`QwpReplayStoreLockLostError`, and the sender falls back to whatever its durability +policy does when the journal is unavailable. This is deliberately conservative — the +holder fails as soon as a contender *could* have taken the slot, not only once one +demonstrably has — because the alternative is writing at offsets the new owner now +owns. A frame's sequence is derived from its position in the segment, so a same-width +overwrite would otherwise reopen as a complete journal with the new owner's +acknowledged frames missing and nothing reported. New journals use the cross-client SFA persistence layout. Fixed-size `sf-.sfa` files have the Java/Rust 24-byte `SF01` header and diff --git a/src/qwp-node/advisory-lock.ts b/src/qwp-node/advisory-lock.ts index accd245..5431dab 100644 --- a/src/qwp-node/advisory-lock.ts +++ b/src/qwp-node/advisory-lock.ts @@ -93,6 +93,8 @@ export class QwpNodeAdvisoryLockError extends Error { export class QwpNodeAdvisoryLock { private released = false; private compromised = false; + /** When this object last proved it still owned the directory. */ + private provenAtMs = Date.now(); private heartbeat?: NodeJS.Timeout; private constructor( @@ -275,16 +277,46 @@ export class QwpNodeAdvisoryLock { if (Math.trunc(current.mtimeMs) !== Math.trunc(this.ownerMtimeMs)) { // Someone judged this lock stale and took it. Stop refreshing so the // new owner's heartbeat is the only one advancing the mtime. - this.compromised = true; - this.stopHeartbeat(); + this.markCompromised(); + return; + } + // The mtime alone cannot separate our directory from a replacement that + // landed inside the same clock tick, and some filesystems report whole + // seconds. The token settles it. + if (!(await this.ownsOwnerDirectory())) { + this.markCompromised(); return; } this.ownerMtimeMs = await touchOwnerDirectory(this.ownerPath); - } catch { - // A transient stat/utimes failure is not proof of loss. The next beat - // retries; a genuinely removed directory surfaces as a drifted mtime. + this.provenAtMs = Date.now(); + } catch (error) { + // A directory that is gone is proof of loss: it cannot later reappear + // with a drifted mtime, so waiting for one means never noticing at all. + // Any other failure may be transient, and the next beat retries. + if (nodeErrorCode(error) === "ENOENT") this.markCompromised(); } } + + /** + * Whether this lock is known to have been taken over. Callers that mutate + * the resource it guards must stop when it is true: the pathname now belongs + * to another acquisition, and writing on is what turns a lost lock into lost + * data. + */ + get lost(): boolean { + if (this.compromised) return true; + // The heartbeat is a timer, so a section that blocks the event loop past + // the staleness window resumes with the flag still unset -- yet by then + // any contender was already entitled to reclaim the slot, and the first + // write after resuming lands before the timer can run. Ownership this + // object cannot still vouch for counts as lost. + return Date.now() - this.provenAtMs > STALE_AFTER_MS; + } + + private markCompromised(): void { + this.compromised = true; + this.stopHeartbeat(); + } } async function retryPendingReleases(): Promise { diff --git a/src/qwp-node/file-replay-store.ts b/src/qwp-node/file-replay-store.ts index c0d58d0..694026a 100644 --- a/src/qwp-node/file-replay-store.ts +++ b/src/qwp-node/file-replay-store.ts @@ -234,6 +234,27 @@ export class QwpReplayStoreQuarantinedError extends QwpReplayStoreError { } } +/** + * The advisory lock guarding this journal was taken over by another process + * while it was open, so this store may no longer write to it. + * + * A holder whose heartbeat lapses -- a long synchronous section, a paused + * process, a stalled filesystem -- can have its slot reclaimed while it still + * believes it holds it. Whatever this store does next must not be an append: + * the new owner appends at offsets this store still believes are free, and + * because a frame's sequence is derived from its position, an overwrite of the + * same width leaves a journal that reopens as intact with the new owner's + * frames gone. Failing the append is what keeps that loss impossible. + */ +export class QwpReplayStoreLockLostError extends QwpReplayStoreError { + constructor(readonly directory: string) { + super( + `QWP store-and-forward journal lock was taken over by another process while it was open; this journal is no longer writable [directory=${directory}]`, + ); + this.name = "QwpReplayStoreLockLostError"; + } +} + export class QwpReplayStoreFullError extends QwpReplayStoreError { constructor( readonly maxBytes: number, @@ -2037,6 +2058,12 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { "QWP store-and-forward journal must be loaded before use", ); } + // Every mutating path routes through here, so this is the one place that + // has to notice the slot was taken over. Writing on would corrupt the new + // owner's journal rather than this store's own. + if (this.slotLock?.lost) { + throw new QwpReplayStoreLockLostError(this.directory); + } if (this.checkpointFailure) throw this.checkpointFailure; if (this.maintenanceFailure) throw this.maintenanceFailure; } diff --git a/src/qwp/node.ts b/src/qwp/node.ts index 28aab75..23233d0 100644 --- a/src/qwp/node.ts +++ b/src/qwp/node.ts @@ -88,6 +88,7 @@ export { QwpReplayStoreError, QwpReplayStoreFullError, QwpReplayStoreLockedError, + QwpReplayStoreLockLostError, QwpReplayStoreQuarantinedError, QwpReplayStoreSegmentTooLargeError, } from "../qwp-node/file-replay-store"; diff --git a/test/qwp/reconnect.test.ts b/test/qwp/reconnect.test.ts index 5bf6dc9..ae4c582 100644 --- a/test/qwp/reconnect.test.ts +++ b/test/qwp/reconnect.test.ts @@ -4100,6 +4100,33 @@ describe("QWP Node file replay store", () => { await expectOnlyJavaSlotLockMetadata(directory); }); + it("refuses to append once its slot lock can no longer be vouched for", async () => { + // A holder paused past the staleness window -- a long synchronous section, + // a suspended VM, a stalled filesystem -- can have its slot reclaimed while + // it still believes it holds it. It used to keep appending: the writes + // resolved, and because a frame's sequence comes from its position in the + // segment, an overwrite of the same width reopened as a complete journal + // with the new owner's frames silently gone. + // + // Only Date is faked here: the heartbeat is what must *not* get a chance to + // run, which is exactly the window the first write after resuming lands in. + const directory = await trackedDirectory(); + const store = new QwpNodeFileReplayStore({ directory }); + await store.load(); + await store.append({ frameSequence: 0n, payload: Uint8Array.of(1) }); + + vi.useFakeTimers({ toFake: ["Date"] }); + try { + vi.setSystemTime(Date.now() + 20_000); + await expect( + store.append({ frameSequence: 1n, payload: Uint8Array.of(2) }), + ).rejects.toMatchObject({ name: "QwpReplayStoreLockLostError" }); + } finally { + vi.useRealTimers(); + } + await store.close().catch(() => undefined); + }); + it("treats an owner directory with no record yet as held", async () => { // The state every acquisition passes through between its mkdir and its // owner-record write. Staleness used to fall back to the `.lock.pid` From 7aaf8dfcaf5fcc8b1e20b22db22e1df778be18fe Mon Sep 17 00:00:00 2001 From: glasstiger Date: Sat, 22 Aug 2026 00:34:05 +0100 Subject: [PATCH 137/265] fix: validate a column call even when its value is nullish Omitting a nullish column took the rest of the call's validation with it. The guard added for issue #28 is the first statement of all 13 ILP setters, so a null or undefined value returned before the column name, the row state, or the decimal scale had been looked at. The result was a diagnosis that depended on the data rather than on the code: the same call site raised on rows that carried a value and stayed silent on rows that did not. A misspelled name, a name over max_name_len, a symbol placed after a column, or an out-of-range decimal scale could therefore first surface in production, on whichever row happened to be populated. On a sender with max_name_len=5 this whole sequence threw nothing and flushed "t i=1i\n": .decimalColumn(12345, null, 999) .arrayColumn("bad?name", null) .stringColumn("wayTooLongForMaxNameLen5", undefined) .symbol(123, null) .intColumn("i", 1) Move the value-independent checks into validateColumnCall() and validateSymbolCall(), run them ahead of the nullish guard, and drop them from writeColumn(), whose callers are exactly these setters -- so the name is still scanned once per cell, not twice. The reported error is now the real defect rather than the incidental one. Where this sequence previously reported "Column value must be of type string, received undefined", it reports "Column name is too long, max length is 5". Nullish values still omit the column, and a value the negotiated protocol version cannot represent is still skipped rather than rejected, which is the behaviour the suite already pins. Co-Authored-By: Claude Opus 5 (1M context) --- src/buffer/base.ts | 67 +++++++++++++++++++++++++++++--------- src/buffer/bufferv1.ts | 2 ++ src/buffer/bufferv2.ts | 2 ++ src/buffer/bufferv3.ts | 11 +++++-- test/sender.buffer.test.ts | 55 +++++++++++++++++++++++++++++++ 5 files changed, 118 insertions(+), 19 deletions(-) diff --git a/src/buffer/base.ts b/src/buffer/base.ts index 46a86ec..bcfb1a9 100644 --- a/src/buffer/base.ts +++ b/src/buffer/base.ts @@ -156,22 +156,14 @@ abstract class SenderBufferBase implements SenderBuffer { * @return {SenderBuffer} Returns with a reference to this buffer. */ symbol(name: string, value: unknown): SenderBuffer { + this.validateSymbolCall(name); // A null or undefined value omits the symbol entirely (see issue #28). if (this.isNullOrUndefined(value)) { return this; } - if (typeof name !== "string") { - throw new Error(`Symbol name must be a string, received ${typeof name}`); - } - if (!this.hasTable || this.hasColumns) { - throw new Error( - "Symbol can be added only after table name is set and before any column added", - ); - } const valueStr = value.toString(); this.checkCapacity([name, valueStr], 2 + name.length + valueStr.length); this.write(","); - validateColumnName(name, this.maxNameLength); this.writeEscaped(name); this.write("="); this.writeEscaped(valueStr); @@ -188,6 +180,7 @@ abstract class SenderBufferBase implements SenderBuffer { * @return {SenderBuffer} Returns with a reference to this buffer. */ stringColumn(name: string, value: string | null | undefined): SenderBuffer { + this.validateColumnCall(name); // A null or undefined value omits the column entirely (see issue #28). if (this.isNullOrUndefined(value)) { return this; @@ -215,6 +208,7 @@ abstract class SenderBufferBase implements SenderBuffer { * @return {SenderBuffer} Returns with a reference to this buffer. */ booleanColumn(name: string, value: boolean | null | undefined): SenderBuffer { + this.validateColumnCall(name); // A null or undefined value omits the column entirely (see issue #28). if (this.isNullOrUndefined(value)) { return this; @@ -270,6 +264,7 @@ abstract class SenderBufferBase implements SenderBuffer { * @throws Error if the value is not an integer */ intColumn(name: string, value: number | null | undefined): SenderBuffer { + this.validateColumnCall(name); // A null or undefined value omits the column entirely (see issue #28). if (this.isNullOrUndefined(value)) { return this; @@ -322,6 +317,7 @@ abstract class SenderBufferBase implements SenderBuffer { value: number | bigint | null | undefined, unit: TimestampUnit = "us", ): SenderBuffer { + this.validateColumnCall(name); // A null or undefined value omits the column entirely (see issue #28). if (this.isNullOrUndefined(value)) { return this; @@ -456,6 +452,47 @@ abstract class SenderBufferBase implements SenderBuffer { return value === null || value === undefined; } + /** + * @ignore + * Validates everything about a column call that does not depend on its + * value. Every setter runs this before testing the value for nullish, so a + * malformed name or a misplaced call is reported whether or not this + * particular row happens to carry a value for that column -- otherwise the + * same call site raises on some rows and stays silent on others, and a + * misspelled or over-long name first surfaces in production, on the row that + * happens to be populated. + * + * @param name - The column name to validate. + */ + protected validateColumnCall(name: string): void { + if (typeof name !== "string") { + throw new Error(`Column name must be a string, received ${typeof name}`); + } + if (!this.hasTable) { + throw new Error("Column can be set only after table name is set"); + } + validateColumnName(name, this.maxNameLength); + } + + /** + * @ignore + * The symbol equivalent of {@link validateColumnCall}. Symbols carry an + * extra ordering rule: they must precede every column on the row. + * + * @param name - The symbol name to validate. + */ + protected validateSymbolCall(name: string): void { + if (typeof name !== "string") { + throw new Error(`Symbol name must be a string, received ${typeof name}`); + } + if (!this.hasTable || this.hasColumns) { + throw new Error( + "Symbol can be added only after table name is set and before any column added", + ); + } + validateColumnName(name, this.maxNameLength); + } + /** * @ignore * Common logic for writing column data to the buffer. @@ -470,20 +507,16 @@ abstract class SenderBufferBase implements SenderBuffer { writeValue: () => void, valueType?: string, ) { - if (typeof name !== "string") { - throw new Error(`Column name must be a string, received ${typeof name}`); - } + // The name and row-state checks ran in validateColumnCall(), which every + // setter calls before deciding whether the value is nullish. Repeating + // validateColumnName() here would rescan the name on every cell. if (valueType && typeof value !== valueType) { throw new Error( `Column value must be of type ${valueType}, received ${typeof value}`, ); } - if (!this.hasTable) { - throw new Error("Column can be set only after table name is set"); - } this.checkCapacity([name], 2 + name.length); this.write(this.hasColumns ? "," : " "); - validateColumnName(name, this.maxNameLength); this.writeEscaped(name); this.write("="); writeValue(); @@ -582,6 +615,7 @@ abstract class SenderBufferBase implements SenderBuffer { name: string, value: string | number | null | undefined, ): SenderBuffer { + this.validateColumnCall(name); // A null or undefined value omits the column entirely (see issue #28). if (this.isNullOrUndefined(value)) { return this; @@ -613,6 +647,7 @@ abstract class SenderBufferBase implements SenderBuffer { unscaled: bigint | Int8Array | null | undefined, scale: number, ): SenderBuffer { + this.validateColumnCall(name); // A null or undefined value omits the column entirely (see issue #28). if (this.isNullOrUndefined(unscaled)) { return this; diff --git a/src/buffer/bufferv1.ts b/src/buffer/bufferv1.ts index 34335a7..03001d5 100644 --- a/src/buffer/bufferv1.ts +++ b/src/buffer/bufferv1.ts @@ -28,6 +28,7 @@ class SenderBufferV1 extends SenderBufferBase { * @return {Sender} Returns with a reference to this sender. */ floatColumn(name: string, value: number | null | undefined): SenderBuffer { + this.validateColumnCall(name); // A null or undefined value omits the column entirely (see issue #28). if (this.isNullOrUndefined(value)) { return this; @@ -74,6 +75,7 @@ class SenderBufferV1 extends SenderBufferBase { * @throws Error indicating arrays are not supported in v1 */ arrayColumn(name: string, value: unknown[] | null | undefined): SenderBuffer { + this.validateColumnCall(name); // A null or undefined value omits the column entirely (see issue #28). if (this.isNullOrUndefined(value)) { return this; diff --git a/src/buffer/bufferv2.ts b/src/buffer/bufferv2.ts index ec488c2..d2166f5 100644 --- a/src/buffer/bufferv2.ts +++ b/src/buffer/bufferv2.ts @@ -44,6 +44,7 @@ class SenderBufferV2 extends SenderBufferBase { * @returns {Sender} Returns with a reference to this buffer. */ floatColumn(name: string, value: number | null | undefined): SenderBuffer { + this.validateColumnCall(name); // A null or undefined value omits the column entirely (see issue #28). if (this.isNullOrUndefined(value)) { return this; @@ -96,6 +97,7 @@ class SenderBufferV2 extends SenderBufferBase { * - or the array is not homogeneous: its elements are not all the same type */ arrayColumn(name: string, value: unknown[] | null | undefined): SenderBuffer { + this.validateColumnCall(name); // A null or undefined value omits the column entirely (see issue #28). if (this.isNullOrUndefined(value)) { return this; diff --git a/src/buffer/bufferv3.ts b/src/buffer/bufferv3.ts index d84b23a..d22c3c1 100644 --- a/src/buffer/bufferv3.ts +++ b/src/buffer/bufferv3.ts @@ -46,6 +46,7 @@ class SenderBufferV3 extends SenderBufferV2 { name: string, value: string | number | null | undefined, ): SenderBuffer { + this.validateColumnCall(name); // A null or undefined value omits the column entirely (see issue #28). if (this.isNullOrUndefined(value)) { return this; @@ -91,13 +92,17 @@ class SenderBufferV3 extends SenderBufferV2 { unscaled: bigint | Int8Array | null | undefined, scale: number, ): SenderBuffer { + this.validateColumnCall(name); + // The scale describes the column, not this row's value, so it is checked + // before the value is: otherwise a bad constant is reported only on rows + // that happen to carry a value. + if (scale < 0 || scale > 76) { + throw new RangeError("Scale must be between 0 and 76"); + } // A null or undefined value omits the column entirely (see issue #28). if (this.isNullOrUndefined(unscaled)) { return this; } - if (scale < 0 || scale > 76) { - throw new RangeError("Scale must be between 0 and 76"); - } let arr: number[]; if (typeof unscaled === "bigint") { arr = bigintToTwosComplementBytes(unscaled); diff --git a/test/sender.buffer.test.ts b/test/sender.buffer.test.ts index fddb9af..7465f31 100644 --- a/test/sender.buffer.test.ts +++ b/test/sender.buffer.test.ts @@ -534,6 +534,61 @@ describe("Sender message builder test suite (anything not covered in client inte } }); + it("validates the column call even when the value is nullish", async function () { + // Omitting the column must not take the rest of the call's validation with + // it. A nullish value used to return before the name, the row state and + // the decimal scale were ever looked at, so the same call site raised on + // rows that carried a value and stayed silent on rows that did not -- a + // misspelled or over-long name first surfaced in production. + const build = () => + new Sender({ + protocol: "tcp", + protocol_version: "3", + host: "host", + auto_flush: false, + max_name_len: 5, + init_buf_size: 1024, + }).table("t"); + + for (const value of [null, undefined] as const) { + expect(() => build().stringColumn("tooLongForFive", value)).toThrow( + "Column name is too long, max length is 5", + ); + expect(() => build().intColumn("", value)).toThrow( + "Empty string is not allowed as column name", + ); + expect(() => build().floatColumn("a.b", value)).toThrow( + "Invalid character in column name: .", + ); + expect(() => + build().booleanColumn(123 as unknown as string, value), + ).toThrow("Column name must be a string, received number"); + expect(() => build().symbol(123 as unknown as string, value)).toThrow( + "Symbol name must be a string, received number", + ); + // Symbols must still precede every column on the row. + expect(() => build().intColumn("i", 1).symbol("s", value)).toThrow( + "Symbol can be added only after table name is set and before any column added", + ); + // The scale describes the column, not this row's value. + expect(() => build().decimalColumn("d", value, 999)).toThrow( + "Scale must be between 0 and 76", + ); + } + + // A column set before any table is still rejected. + const noTable = new Sender({ + protocol: "tcp", + protocol_version: "3", + host: "host", + auto_flush: false, + init_buf_size: 1024, + }); + expect(() => noTable.stringColumn("c", null)).toThrow( + "Column can be set only after table name is set", + ); + }); + it("omits decimal columns with null or undefined value", async function () { const sender = new Sender({ protocol: "tcp", From be17047627ec09311499950ee1c4eaf6280a4bd8 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Sat, 22 Aug 2026 00:36:49 +0100 Subject: [PATCH 138/265] fix(qwp): accept nullish words in long256Column README states that passing null or undefined omits the column "on both the ILP and QWP senders, and to the compiled QWP writers". That held for 21 of the 22 QWP column methods. long256Column was the exception: its four value parameters were plain bigint, so BigInt.asIntN() raised "Cannot convert null to a BigInt", failRow() discarded the row, and a plain-JavaScript caller mapping an optional field onto it got a raw TypeError where every sibling method omits the column. TypeScript callers were spared, which is why no suite noticed. Accept nullish words. A LONG256 is one value spread over four arguments, so "no value" means all four are absent; that omits the column. A partial set is a caller mistake rather than a NULL and now says so, instead of failing inside BigInt conversion. Also correct the one other place the shared documentation overstated the rule. Sender.decimalColumn's TSDoc said "An empty array represents the NULL value" for both backends, but the ILP buffers write an explicit NULL decimal field for an empty Int8Array while the QWP sender omits the column, as it does for null. Both land as NULL for a column that already exists -- verified against QuestDB 9.4.3 and 10.0.1-nightly -- but the encodings differ and the TSDoc now says so. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 5 ++++- src/_qwp/sender.ts | 32 ++++++++++++++++++++++++++------ src/sender.ts | 5 ++++- test/qwp/sender.test.ts | 26 ++++++++++++++++++++++++++ 4 files changed, 60 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index d9ed030..3cf789a 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,10 @@ await sender ``` This applies to every column method on both the ILP (`http`/`https`/`tcp`/`tcps`) -and QWP (`ws`/`wss`/`udp`) senders, and to the compiled QWP writers. +and QWP (`ws`/`wss`/`udp`) senders, and to the compiled QWP writers. The one +method that spreads a single value over several arguments, `long256Column`, +omits its column when _all four_ words are nullish; a partial set is rejected +rather than treated as NULL. Two consequences are worth knowing: diff --git a/src/_qwp/sender.ts b/src/_qwp/sender.ts index 759ad45..1457c71 100644 --- a/src/_qwp/sender.ts +++ b/src/_qwp/sender.ts @@ -1286,17 +1286,37 @@ export class QwpSender { long256Column( name: string, - word0: bigint, - word1: bigint, - word2: bigint, - word3: bigint, + word0: bigint | null | undefined, + word1: bigint | null | undefined, + word2: bigint | null | undefined, + word3: bigint | null | undefined, ): QwpSender { + const given = [word0, word1, word2, word3]; + const absent = given.filter( + (word) => word === null || word === undefined, + ).length; + // A LONG256 is one value spread over four words, so "no value" means all + // four are absent -- that omits the column, like every other setter. A + // partial set is a caller mistake rather than a NULL, and saying so beats + // letting BigInt.asIntN() raise "Cannot convert null to a BigInt". + if (absent === given.length) return this; + if (absent > 0) { + return this.failRow( + new TypeError( + "long256Column needs all four words, or none of them for a NULL value", + ), + ); + } try { - const words = [word0, word1, word2, word3]; - for (const [index, word] of words.entries()) { + const words: bigint[] = []; + for (const [index, word] of given.entries()) { + if (typeof word !== "bigint") { + throw new TypeError(`LONG256 word ${index} must be a bigint`); + } if (BigInt.asIntN(64, word) !== word) { throw new RangeError(`LONG256 word ${index} exceeds signed int64`); } + words.push(word); } return this.addColumn( name, diff --git a/src/sender.ts b/src/sender.ts index 59aa5ab..5ad95ed 100644 --- a/src/sender.ts +++ b/src/sender.ts @@ -494,8 +494,11 @@ class Sender { * @param {string} name - Column name. * @param {Int8Array | bigint | null | undefined} unscaled - The unscaled value of the decimal in two's * complement representation and big-endian byte order. - * An empty array represents the NULL value. * A null or undefined value omits the column entirely (stored as NULL). + * An empty array also represents NULL, but the two are not encoded alike: + * on the ILP transports an empty array writes an explicit NULL decimal + * field, while the QWP transports omit the column exactly as they do for + * null. QuestDB records NULL either way for a column that already exists. * @param {number} scale - The scale of the decimal value. * @returns {Sender} Returns with a reference to this buffer. * @throws Error if decimals are not supported by the buffer implementation, or decimal validation fails: diff --git a/test/qwp/sender.test.ts b/test/qwp/sender.test.ts index 2034041..7d5d3b9 100644 --- a/test/qwp/sender.test.ts +++ b/test/qwp/sender.test.ts @@ -628,6 +628,32 @@ describe("QWP high-level sender", () => { expect(() => encodeQwpIngressFrame([table])).not.toThrow(); }); + it("omits a long256 column when all four words are nullish", async () => { + // long256Column was the only column method whose value parameters did not + // accept null or undefined, so the nullish rule README states for "every + // column method" did not hold for it: a plain-JavaScript caller mapping an + // optional field onto it got "Cannot convert null to a BigInt" and a + // silently discarded row. + const session = new RecordingSession(); + const sender = new QwpSender(async () => session, { autoFlush: false }); + + await sender + .table("hashes") + .long256Column("absent", null, null, null, null) + .long256Column("alsoAbsent", undefined, undefined, undefined, undefined) + .longColumn("kept", 7n) + .atNow(); + await sender.flush(); + + const table = session.sends[0].tables[0]; + expect(table.columns.map((c) => c.name)).toEqual(["kept"]); + + // A partial set is a caller mistake, not a NULL, and says so. + expect(() => + sender.table("hashes").long256Column("partial", 1n, null, 3n, 4n), + ).toThrow(/all four words, or none of them/); + }); + it("rolls back the whole current row when a setter fails", async () => { const session = new RecordingSession(); const sender = new QwpSender(async () => session, { autoFlush: false }); From 1981bf2dc6d11d9e35e1c0fd5c08ef4a50a44651 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Sat, 22 Aug 2026 00:39:19 +0100 Subject: [PATCH 139/265] fix(qwp): reject symbol IDs and encode options that cannot be honoured Two ways a SYMBOL column could reach QuestDB as empty strings, with the frame acknowledged OK. encodeQwpIngressFrame() accepts a bare numeric dictionary ID for a SYMBOL value: symbolId() has an explicit branch for it. Its non-delta twin, symbolText(), had none. It read `.text` off the number, got undefined, and TextEncoder encodes undefined as zero bytes -- so every distinct symbol in the frame collapsed into a single empty-string inline dictionary entry with all rows indexing it. The non-delta encoder builds its dictionary out of the texts, so there is genuinely nothing to resolve an ID against; say so rather than emitting a frame that looks valid. The delta path, which is handed the dictionary that gives IDs meaning, is unchanged. QwpNodeUdpSession.sendTables() takes QwpIngressEncodeOptions but encodeUdpDatagrams() dropped the argument and hardcoded `{ gorilla: false }`. A caller who correctly supplied a delta dictionary had it silently ignored and fell into exactly the case above. A datagram has to decode on its own, so a connection-scoped dictionary cannot apply to one: reject `dictionary` and `confirmedMaxSymbolId` instead of accepting and discarding them, and pass `gorilla` through rather than ignoring that too. The high-level QwpSender coerces symbol values with String(), so it was never affected; this is the low-level `./qwp` surface that QWP.md documents for advanced integrations. Co-Authored-By: Claude Opus 5 (1M context) --- src/_qwp/_core/ingress.ts | 22 +++++++++++++++++++++- src/qwp-node/udp-sender.ts | 25 ++++++++++++++++++++++--- test/qwp/core.test.ts | 28 ++++++++++++++++++++++++++++ test/qwp/udp-sender.test.ts | 25 +++++++++++++++++++++++++ 4 files changed, 96 insertions(+), 4 deletions(-) diff --git a/src/_qwp/_core/ingress.ts b/src/_qwp/_core/ingress.ts index 976f284..305d8e4 100644 --- a/src/_qwp/_core/ingress.ts +++ b/src/_qwp/_core/ingress.ts @@ -73,7 +73,27 @@ export function decodeQwpIngressServerInfo( } function symbolText(value: unknown): string { - return typeof value === "string" ? value : (value as QwpSymbolValue).text; + if (typeof value === "string") return value; + // A bare dictionary ID carries no text, and this encoder builds its inline + // dictionary out of the texts, so there is nothing to resolve it against. + // Reading `.text` off a number yields undefined, which TextEncoder happily + // encodes as zero bytes -- every symbol in the frame would collapse into one + // empty-string entry and be acknowledged as if it were correct. Say so + // instead. symbolId() accepts the numeric form because the delta encoder is + // given the dictionary that gives it meaning. + if (typeof value === "number") { + throw new Error( + `QWP symbol ID ${value} needs a symbol dictionary; pass one to encode a delta frame, or supply the symbol as a string or {id, text}`, + ); + } + const text = (value as QwpSymbolValue)?.text; + if (typeof text !== "string") { + throw new Error( + "QWP symbol value must be a string or a {id, text} pair, received " + + (value === null ? "null" : typeof value), + ); + } + return text; } function symbolId(value: unknown, dictionary: QwpSymbolDictionary): number { diff --git a/src/qwp-node/udp-sender.ts b/src/qwp-node/udp-sender.ts index 981a11e..c0d30be 100644 --- a/src/qwp-node/udp-sender.ts +++ b/src/qwp-node/udp-sender.ts @@ -153,7 +153,25 @@ export class QwpNodeUdpSession implements QwpSenderSession { "QWP UDP does not support transactions or deferred commit", ); } - const datagrams = encodeUdpDatagrams(tables, this.maxBatchSizeBytes); + // Every datagram has to decode on its own, so there is no connection over + // which a delta dictionary could be reconstructed. Accepting one and + // encoding without it used to write every symbol in the frame as the empty + // string, acknowledged as though it were correct. + if (options.dictionary !== undefined) { + throw new Error( + "QWP UDP datagrams are self-contained and cannot use a delta symbol dictionary; supply symbol values as strings", + ); + } + if (options.confirmedMaxSymbolId !== undefined) { + throw new Error( + "QWP UDP has no connection to track confirmed symbol IDs against", + ); + } + const datagrams = encodeUdpDatagrams( + tables, + this.maxBatchSizeBytes, + options.gorilla ?? false, + ); return this.sendDatagrams(datagrams); } @@ -283,6 +301,7 @@ export class QwpNodeUdpSession implements QwpSenderSession { function encodeUdpDatagrams( tables: readonly QwpTableBuffer[], maxDatagramSize: number, + gorilla = false, ): Uint8Array[] { const result: Uint8Array[] = []; for (const table of tables) { @@ -296,7 +315,7 @@ function encodeUdpDatagrams( while (low <= high) { const end = Math.floor((low + high) / 2); const encoded = encodeQwpIngressFrame([table.sliceRows(start, end)], { - gorilla: false, + gorilla, }); if (encoded.byteLength <= maxDatagramSize) { acceptedEnd = end; @@ -310,7 +329,7 @@ function encodeUdpDatagrams( if (!accepted) { const oneRow = encodeQwpIngressFrame( [table.sliceRows(start, start + 1)], - { gorilla: false }, + { gorilla }, ); throw new QwpUdpDatagramTooLargeError( maxDatagramSize, diff --git a/test/qwp/core.test.ts b/test/qwp/core.test.ts index 6cd4b57..6f94a15 100644 --- a/test/qwp/core.test.ts +++ b/test/qwp/core.test.ts @@ -580,3 +580,31 @@ describe("protocol caps", () => { ).not.toThrow(); }); }); + +describe("QWP ingress symbol encoding", () => { + it("rejects a bare symbol ID when no dictionary gives it meaning", () => { + // The delta encoder resolves a numeric SYMBOL value against the dictionary + // it is handed. The non-delta encoder builds its inline dictionary out of + // the values' text, and reading `.text` off a number gives undefined -- + // which TextEncoder encodes as zero bytes. Two distinct symbols used to + // collapse into a single empty-string entry and be acknowledged OK. + const dictionary = new QwpSymbolDictionary(); + const eth = dictionary.getOrAdd("ETH-USD"); + const btc = dictionary.getOrAdd("BTC-USD"); + + const table = new QwpTableBuffer("trades"); + for (const id of [eth, btc, eth]) { + table + .getOrCreateColumn("symbol", QWP_COLUMN_TYPE.SYMBOL)! + .values.push(id); + table.nextRow(); + } + + expect(() => + encodeQwpIngressFrame([table], { dictionary, confirmedMaxSymbolId: -1 }), + ).not.toThrow(); + expect(() => encodeQwpIngressFrame([table])).toThrow( + /needs a symbol dictionary/, + ); + }); +}); diff --git a/test/qwp/udp-sender.test.ts b/test/qwp/udp-sender.test.ts index e64a892..076cc0b 100644 --- a/test/qwp/udp-sender.test.ts +++ b/test/qwp/udp-sender.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { Sender } from "../../src"; import { + QwpSymbolDictionary, connectQwpNodeUdp, connectQwpNodeUdpSender, createQwpNodeUdpSender, @@ -84,6 +85,30 @@ function stringTable(value: string): QwpTableBuffer { } describe("QWP Node UDP sender", () => { + it("rejects encode options a self-contained datagram cannot honour", async () => { + // sendTables() accepts QwpIngressEncodeOptions but encodeUdpDatagrams + // discarded them, so a caller who correctly passed a delta dictionary got + // it silently ignored -- and the non-delta encoder then wrote every symbol + // in the frame as the empty string. + const socket = new FakeUdpSocket(); + const session = await connectQwpNodeUdp({ + host: "127.0.0.1", + socketFactory: () => socket, + }); + + expect(() => + session.sendTables([longTable(1)], { + dictionary: new QwpSymbolDictionary(), + }), + ).toThrow(/cannot use a delta symbol dictionary/); + expect(() => + session.sendTables([longTable(1)], { confirmedMaxSymbolId: 0 }), + ).toThrow(/no connection to track confirmed symbol IDs/); + expect(socket.packets).toHaveLength(0); + + await session.close(); + }); + it("splits at row boundaries into self-contained one-table datagrams", async () => { const socket = new FakeUdpSocket(); const session = await connectQwpNodeUdp({ From 597991cffc82f7ffa20635f5761639fbd5e648e9 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Sat, 22 Aug 2026 00:43:24 +0100 Subject: [PATCH 140/265] fix(qwp): bound decoded identifiers in bytes, not code units The egress RESULT_BATCH decoder bounded the table- and column-name wire fields with QWP_MAX_TABLE_NAME_LENGTH and QWP_MAX_COLUMN_NAME_LENGTH. Those limits are UTF-16 code-unit counts -- the unit Java's TableUtils measures in, which identifiers.ts mirrors on the ingress side -- but the wire field they were compared against is a UTF-8 byte count. So this client could encode identifiers it was then unable to read back. No configuration was needed: at the default limit of 127, a name of 64 accented characters is 64 code units and 128 bytes. Ingress accepted it, and a later query carrying that name failed with "column name length out of range: 128". That QwpProtocolError is routed to recoverProtocolFailure, which replays the same query on a replacement connection, so it reproduced on every endpoint and ended in QwpReconnectExhaustedError with the whole failover set deprioritized. Bound the decode by QWP_MAX_IDENTIFIER_BYTES instead: the same limit expressed as the widest UTF-8 encoding of a maximum-length identifier, three bytes per code unit. The allocation stays bounded, which is what the cap is for, and every identifier the encoder can legally produce now decodes. Also record the unit in the QWP.md `max_name_len` row, since a limit whose unit is unstated is what allowed the two sides to drift apart. Note that `max_name_len` still has no upper bound, for servers configured with a larger cairo.max.file.name.length; setting it above the protocol identifier limit remains the operator's business to match to their server. Co-Authored-By: Claude Opus 5 (1M context) --- QWP.md | 2 +- src/_qwp/_core/constants.ts | 15 ++++++++++++++ src/_qwp/_core/result-batch.ts | 7 +++---- test/qwp/egress.test.ts | 37 ++++++++++++++++++++++++++++++++++ 4 files changed, 56 insertions(+), 5 deletions(-) diff --git a/QWP.md b/QWP.md index 00b5d3c..94379f5 100644 --- a/QWP.md +++ b/QWP.md @@ -106,7 +106,7 @@ connect string is the portable spelling. | `transaction` | `on`, `off` | off | Group each flush into a per-table transaction. | | `request_durable_ack` | `on`, `off` | off | Require durable ACKs; fails if the server cannot confirm them. | | `durable_ack_keepalive_interval_millis` | integer ms | — | Poll interval for durable-ACK progress. | -| `max_name_len` | integer | `127` | Maximum table and column name length. | +| `max_name_len` | integer | `127` | Maximum table and column name length, in UTF-16 code units. | | `sender_id` | string | `default` | Identifies this producer to the server and in the journal. | | `max_frame_rejections` | integer | — | Rejections of one frame before the poison-frame detector escalates. | | `poison_min_escalation_window_millis` | integer ms | — | Minimum dwell before a poison frame may escalate. | diff --git a/src/_qwp/_core/constants.ts b/src/_qwp/_core/constants.ts index 619493d..7a97806 100644 --- a/src/_qwp/_core/constants.ts +++ b/src/_qwp/_core/constants.ts @@ -91,8 +91,23 @@ export const QWP_SERVER_ROLE = { } as const; export const QWP_MAX_COLUMNS_PER_TABLE = 2048; +/** + * Identifier length limits, in UTF-16 code units -- the unit Java's + * `TableUtils` measures in, which `identifiers.ts` mirrors on the ingress side. + */ export const QWP_MAX_COLUMN_NAME_LENGTH = 127; export const QWP_MAX_TABLE_NAME_LENGTH = 127; +/** + * The same limits as a wire byte count, for bounding a decode allocation. + * + * A UTF-16 code unit takes at most three UTF-8 bytes (a surrogate pair is two + * units and four bytes, so two bytes per unit). Bounding the wire length by the + * code-unit limit directly would reject identifiers this client itself encodes: + * 64 accented characters are 64 code units but 128 bytes, so at the default + * limit a name that passed ingress validation could not be read back out of a + * result set. + */ +export const QWP_MAX_IDENTIFIER_BYTES = QWP_MAX_TABLE_NAME_LENGTH * 3; export const QWP_MAX_ROWS_PER_TABLE = 1_000_000; export const QWP_MAX_SYMBOL_DICTIONARY_SIZE = 1_000_000; export const QWP_MAX_ERROR_MESSAGE_LENGTH = 1024; diff --git a/src/_qwp/_core/result-batch.ts b/src/_qwp/_core/result-batch.ts index 157652d..1eb7b46 100644 --- a/src/_qwp/_core/result-batch.ts +++ b/src/_qwp/_core/result-batch.ts @@ -4,9 +4,8 @@ import { QWP_FLAG_DELTA_SYMBOL_DICTIONARY, QWP_FLAG_GORILLA, QWP_FLAG_ZSTD, - QWP_MAX_COLUMN_NAME_LENGTH, QWP_MAX_COLUMNS_PER_TABLE, - QWP_MAX_TABLE_NAME_LENGTH, + QWP_MAX_IDENTIFIER_BYTES, QWP_RESET_MASK_DICTIONARY, QwpColumnType, } from "./constants"; @@ -1363,7 +1362,7 @@ export class QwpResultBatchDecoder { const tableNameLength = readCount( reader, - QWP_MAX_TABLE_NAME_LENGTH, + QWP_MAX_IDENTIFIER_BYTES, "table name length", ); const tableName = reader.readUtf8(tableNameLength, "table name"); @@ -1378,7 +1377,7 @@ export class QwpResultBatchDecoder { this.schema = Array.from({ length: columnCount }, () => { const nameLength = readCount( reader, - QWP_MAX_COLUMN_NAME_LENGTH, + QWP_MAX_IDENTIFIER_BYTES, "column name length", ); const name = reader.readUtf8(nameLength, "column name"); diff --git a/test/qwp/egress.test.ts b/test/qwp/egress.test.ts index be8af54..1ded655 100644 --- a/test/qwp/egress.test.ts +++ b/test/qwp/egress.test.ts @@ -24,6 +24,7 @@ import { QwpEgressQueryTimeoutError, QwpEgressSession, QwpResultBatchDecoder, + QwpTableBuffer, QwpResultBatchView, QwpResultRowView, readQwpVarint, @@ -308,6 +309,42 @@ describe("QWP result batch decoder", () => { ]); }); + it("decodes identifiers this client is allowed to ingest", () => { + // Ingress validates identifiers in UTF-16 code units, mirroring Java's + // TableUtils. The decoder bounded the wire field with the same number, but + // that field is a UTF-8 byte count, so a name that passed ingress could not + // be read back: 64 accented characters are 64 code units and 128 bytes. + for (const name of ["a".repeat(127), "é".repeat(127), "あ".repeat(127)]) { + // Ingress accepts it at the default limit. + expect(() => new QwpTableBuffer(name)).not.toThrow(); + + const payload = new QwpByteWriter(); + payload.writeUint8(QWP_EGRESS_MESSAGE.RESULT_BATCH).writeBigUint64(0n); + writeQwpVarint(payload, 0); // batch sequence + writeQwpVarint(payload, 0); // empty dictionary delta start + writeQwpVarint(payload, 0); // empty dictionary delta count + writeString(payload, name); // table name + writeQwpVarint(payload, 0); // rows + writeQwpVarint(payload, 1); // columns + writeString(payload, name); // column name + payload.writeUint8(QWP_COLUMN_TYPE.INT); + payload.writeUint8(0); // null flag, still present for a zero-row column + + const message = decodeQwpEgressMessage( + encodeQwpFrame( + payload.toUint8Array(), + QWP_FLAG_DELTA_SYMBOL_DICTIONARY, + 1, + ), + ); + if (message.kind !== "result-batch") + throw new Error("unexpected message"); + const batch = new QwpResultBatchDecoder().decode(message); + expect(batch.tableName).toBe(name); + expect(batch.columns.map((column) => column.name)).toEqual([name]); + } + }); + it("exposes bounded zero-copy column views without value arrays", () => { const message = decodeQwpEgressMessage(firstResultBatch()); if (message.kind !== "result-batch") throw new Error("unexpected message"); From c754bfb16fbd68421f762acfeab6c8552510da17 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Sat, 22 Aug 2026 00:48:16 +0100 Subject: [PATCH 141/265] fix: make the QWP entry points reachable for docs and node10 consumers Two ways the three new subpaths were published without being usable. typedoc.json listed only ./src/index.ts, which exports none of the QWP surface, so the generated API reference -- the site GitHub Pages serves from docs/ on main, and the package homepage -- documented 1 of the 324 symbols the QWP entry points export, and that one was a Sender.symbol name collision. Typedoc had been reporting it all along as "referenced by ... but not included in the documentation". Adding the three entry points takes the output from 17 pages to 349, with a module page each for qwp, qwp/browser and qwp/node. TypeScript's node10 resolution ignores `exports`, and `module: "commonjs"` implies node10 unless moduleResolution is set explicitly -- which is what `tsc --init` still emits. Such a consumer got TS2307 for all three documented imports while the same imports worked at runtime. Declaring them in `typesVersions` as well fixes it: verified against a real `npm pack` install, three TS2307 errors before and none after, across node10, node16, nodenext and bundler. The build-artifact check now walks the typesVersions targets too, since a missing one breaks compilation in a way no runtime suite can see. Note that this proves the files exist, not that they resolve -- both tsconfig.dist-types*.json use explicit `paths`, which bypasses module resolution entirely, and that is why neither caught this. A real guard needs a packed install. docs/ itself is not regenerated here; it is refreshed at release, and is already a version behind. Co-Authored-By: Claude Opus 5 (1M context) --- QWP.md | 9 ++++++++- package.json | 13 +++++++++++++ scripts/check-build-artifacts.mjs | 14 +++++++++++++- typedoc.json | 7 ++++++- 4 files changed, 40 insertions(+), 3 deletions(-) diff --git a/QWP.md b/QWP.md index 94379f5..e72f1be 100644 --- a/QWP.md +++ b/QWP.md @@ -19,6 +19,13 @@ policy. Imports from internal source paths are never supported. | `@questdb/nodejs-client/qwp/node` | Node.js | QWP ingress and egress with upgrade headers, TLS agents, and persistent store-and-forward | | `@questdb/nodejs-client/qwp` | Browser or Node.js | Shared protocol codecs and low-level session abstractions for advanced integrations | +The three QWP subpaths are declared both in `exports` and in `typesVersions`, so +they resolve under every TypeScript `moduleResolution` setting, including the +legacy `node10` that `module: "commonjs"` still implies by default. Keep the two +declarations in step: `exports` alone leaves a `node10` consumer with +`TS2307: Cannot find module '@questdb/nodejs-client/qwp/node'` at compile time +while the same import works perfectly at runtime. + Do not import the package root from browser code. It retains the existing Node.js transports and dependencies for backward compatibility. The browser entry point has no Node.js imports. Node-only features remain in `qwp/node`, so supporting browsers @@ -370,7 +377,7 @@ Such a holder stops writing: once it can no longer vouch for its own lock, every append, checkpoint and acknowledgement on that journal fails with `QwpReplayStoreLockLostError`, and the sender falls back to whatever its durability policy does when the journal is unavailable. This is deliberately conservative — the -holder fails as soon as a contender *could* have taken the slot, not only once one +holder fails as soon as a contender _could_ have taken the slot, not only once one demonstrably has — because the alternative is writing at offsets the new owner now owns. A frame's sequence is derived from its position in the segment, so a same-width overwrite would otherwise reopen as a complete journal with the new owner's diff --git a/package.json b/package.json index 02a9827..4310997 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,19 @@ "main": "dist/cjs/index.js", "module": "dist/es/index.mjs", "types": "dist/cjs/index.d.ts", + "typesVersions": { + "*": { + "qwp": [ + "./dist/cjs/qwp/index.d.ts" + ], + "qwp/browser": [ + "./dist/cjs/qwp/browser.d.ts" + ], + "qwp/node": [ + "./dist/cjs/qwp/node.d.ts" + ] + } + }, "exports": { ".": { "import": { diff --git a/scripts/check-build-artifacts.mjs b/scripts/check-build-artifacts.mjs index f8f7c84..c995587 100644 --- a/scripts/check-build-artifacts.mjs +++ b/scripts/check-build-artifacts.mjs @@ -16,7 +16,9 @@ import { dirname, join, resolve } from "node:path"; const SPECIFIER = /(?:\bfrom|\brequire\(|\bimport\()\s*["'](\.[^"']*\.(?:d\.)?[mc]?[jt]s)["']/g; -const { exports: map } = JSON.parse(readFileSync("package.json", "utf8")); +const { exports: map, typesVersions } = JSON.parse( + readFileSync("package.json", "utf8"), +); const missing = []; const seen = new Set(); @@ -43,6 +45,16 @@ for (const [subpath, conditions] of Object.entries(map)) { } } +// typesVersions is what TypeScript's legacy node10 resolution reads instead of +// `exports`, so a target missing here breaks those consumers with a TS2307 that +// no runtime test can see. +for (const [subpath, targets] of Object.entries(typesVersions?.["*"] ?? {})) { + for (const target of targets) { + if (!existsSync(target)) + missing.push(`typesVersions ${subpath} -> ${target}`); + } +} + if (missing.length > 0) { console.error(`missing build artifacts:\n ${missing.join("\n ")}`); process.exit(1); diff --git a/typedoc.json b/typedoc.json index d76ef0d..682b45d 100644 --- a/typedoc.json +++ b/typedoc.json @@ -1,6 +1,11 @@ { "$schema": "https://typedoc.org/schema.json", - "entryPoints": ["./src/index.ts"], + "entryPoints": [ + "./src/index.ts", + "./src/qwp/index.ts", + "./src/qwp/browser.ts", + "./src/qwp/node.ts" + ], "out": "docs", "name": "QuestDB Node.js Client", "readme": "./README.md", From 06064378affe693d511ba7834eb22d102236df4f Mon Sep 17 00:00:00 2001 From: glasstiger Date: Sat, 22 Aug 2026 00:58:38 +0100 Subject: [PATCH 142/265] fix(qwp): stop close() from leaving connects and writes behind Two halves of one lifecycle gap. close() could not cancel a first connect. The reconnect loop owns an AbortController, but the initial attempt bypasses it -- it is either handed to QwpReconnectingIngressConnection as `initialConnection` or awaited directly -- and both were built by calling the factory with no signal. closeNow() could therefore only attach `.then(c => c.close())` to the pending promise, so the socket and its opening deadline outlived close() by the full connect/auth timeout. A CLI, serverless or test process that closed a sender and expected to exit hung for up to that long. Measured against a peer that accepts TCP and never answers the upgrade: close() returned at ~305ms and the process exited at 20008ms. QwpSenderSessionFactory now takes an optional AbortSignal, mirroring QwpConnectionFactory (whose doc comment already notes that factories ignoring the parameter stay assignable), and the sender aborts it when a close finds a connect still in flight. Same probe: exit at 309ms. close() also bounds its own flush with a deadline but cannot cancel it, so an abandoned close flush stayed runnable. getSession() clears sessionPromise when a connect fails, so that leftover flush reached the cleared field, dialled the database again and wrote rows after close() had already returned -- an application closing a sender to stop writing kept writing. Through a proxy that swallows the first connection, the server received an ingress frame 366ms after close() rejected with QwpSenderCloseTimeoutError; it now receives none. The new guard is keyed on `closed`, not `closing`: close() is documented to publish completed rows, and doing that legitimately needs a session even when none was opened yet. Co-Authored-By: Claude Opus 5 (1M context) --- src/_qwp/ingress-session.ts | 12 +++++-- src/_qwp/sender.ts | 36 ++++++++++++++++++-- src/qwp/browser.ts | 6 +++- src/qwp/node.ts | 24 ++++++++++---- test/qwp/sender.test.ts | 66 +++++++++++++++++++++++++++++++++++++ 5 files changed, 132 insertions(+), 12 deletions(-) diff --git a/src/_qwp/ingress-session.ts b/src/_qwp/ingress-session.ts index d5d2f00..e831f85 100644 --- a/src/_qwp/ingress-session.ts +++ b/src/_qwp/ingress-session.ts @@ -580,6 +580,14 @@ export class QwpIngressSession { static async connect( factory: QwpConnectionFactory, options: QwpIngressSessionOptions = {}, + /** + * Cancels a first connect that is still negotiating. The reconnect loop + * owns its own controller, but the initial attempt bypasses it -- it is + * either handed in as `initialConnection` or awaited directly below -- so + * without this a close() during the first connect left the socket and its + * deadline alive for the full connect/auth timeout. + */ + signal?: AbortSignal, ): Promise { validateIngressSessionOptions(options); if (options.replayStore && options.reconnect === false) { @@ -603,7 +611,7 @@ export class QwpIngressSession { options.reconnect === undefined && !options.replayStore && !options.backgroundStoreAndForward - ? factory() + ? factory(signal) : undefined; const connection = reconnectOptions ? await QwpReconnectingIngressConnection.connect( @@ -624,7 +632,7 @@ export class QwpIngressSession { options.errorInboxCapacity ?? DEFAULT_ERROR_INBOX_CAPACITY, options.onSenderError, ) - : await factory(); + : await factory(signal); try { return new QwpIngressSession(connection, options); } catch (error) { diff --git a/src/_qwp/sender.ts b/src/_qwp/sender.ts index 1457c71..0ec628a 100644 --- a/src/_qwp/sender.ts +++ b/src/_qwp/sender.ts @@ -143,7 +143,15 @@ export interface QwpSenderSession { close(code?: number, reason?: string): Promise; } -export type QwpSenderSessionFactory = () => Promise; +/** + * Opens the sender's session. The signal is aborted by close(), so a connect + * still negotiating can be torn down instead of outliving the sender by up to + * its connect/auth deadline. Factories that ignore the parameter remain + * assignable, matching QwpConnectionFactory. + */ +export type QwpSenderSessionFactory = ( + signal?: AbortSignal, +) => Promise; /** Immutable high-level sender counters plus the active ingress snapshot. */ export interface QwpSenderMetrics { @@ -961,6 +969,8 @@ export class QwpSender { private readonly maxNameLength: number; private readonly log: QwpSenderLogger; + private readonly connectAbort = new AbortController(); + constructor( private readonly sessionFactory: QwpSenderSessionFactory, private readonly options: QwpSenderOptions = {}, @@ -1717,7 +1727,10 @@ export class QwpSender { } } else if (this.sessionPromise) { // A close deadline can expire while the connection factory is still in - // flight. Attach cleanup so a late connection cannot leak its socket. + // flight. Abort it so the socket and its deadline go away now rather + // than keeping the event loop alive until the connect timeout fires, + // and still attach cleanup in case it had already connected. + this.connectAbort.abort(); void this.sessionPromise .then((connected) => connected.close()) .catch(() => undefined); @@ -2366,7 +2379,18 @@ export class QwpSender { private getSession(): Promise { if (!this.sessionPromise) { - const connecting = this.sessionFactory(); + // close() bounds its own flush with a deadline but cannot cancel it, so + // an abandoned close flush stays runnable. Without this it could reach a + // cleared sessionPromise, dial the database again, and write rows after + // close() had already returned to the caller. + // + // Keyed on `closed`, not `closing`: close() is documented to publish + // completed rows, and doing that legitimately needs a session even when + // none was opened yet. + if (this.closed) { + return Promise.reject(this.unavailableError()); + } + const connecting = this.sessionFactory(this.connectAbort.signal); const tracked = connecting .then((session) => { this.activeSession = session; @@ -2397,6 +2421,12 @@ export class QwpSender { return Math.min(this.autoFlushBytes, safeServerBudget); } + private unavailableError(): Error { + return new Error( + this.closed ? "QWP sender is closed" : "QWP sender is closing", + ); + } + private throwIfClosed(): void { if (this.closed) throw new Error("QWP sender is closed"); } diff --git a/src/qwp/browser.ts b/src/qwp/browser.ts index 2561619..7a0dbeb 100644 --- a/src/qwp/browser.ts +++ b/src/qwp/browser.ts @@ -659,6 +659,8 @@ function connectQwpBrowserEgressEndpoint( export async function connectQwpBrowserIngress( options: QwpBrowserWebSocketOptions, sessionOptions: QwpIngressSessionOptions = {}, + /** Cancels a first connect still negotiating; see QwpIngressSession.connect. */ + signal?: AbortSignal, ): Promise { const effectiveSessionOptions: QwpIngressSessionOptions = { ...sessionOptions, @@ -669,6 +671,7 @@ export async function connectQwpBrowserIngress( return QwpIngressSession.connect( createQwpBrowserConnectionFactory(options), effectiveSessionOptions, + signal, ); } @@ -682,7 +685,7 @@ export function createQwpBrowserSender( sessionOptions: QwpIngressSessionOptions = {}, ): QwpSender { return new QwpSender( - () => + (signal) => connectQwpBrowserIngress( { ...options, @@ -690,6 +693,7 @@ export function createQwpBrowserSender( options.requestDurableAck ?? senderOptions.awaitDurableAck, }, sessionOptions, + signal, ), senderOptions, ); diff --git a/src/qwp/node.ts b/src/qwp/node.ts index 23233d0..1d076f5 100644 --- a/src/qwp/node.ts +++ b/src/qwp/node.ts @@ -560,8 +560,16 @@ function connectQwpNodeEndpoint( export async function connectQwpNodeIngress( options: QwpNodeIngressOptions, sessionOptions: QwpIngressSessionOptions = {}, + /** Cancels a first connect still negotiating; see QwpIngressSession.connect. */ + signal?: AbortSignal, ): Promise { - return connectQwpNodeIngressInternal(options, sessionOptions, true); + return connectQwpNodeIngressInternal( + options, + sessionOptions, + true, + undefined, + signal, + ); } async function connectQwpNodeIngressInternal( @@ -569,6 +577,7 @@ async function connectQwpNodeIngressInternal( sessionOptions: QwpIngressSessionOptions, startOrphanDrainer: boolean, sharedHealthTracker?: QwpFailoverHealthTracker, + signal?: AbortSignal, ): Promise { const healthTracker = sharedHealthTracker ?? @@ -639,6 +648,7 @@ async function connectQwpNodeIngressInternal( session = await QwpIngressSession.connect( connectionFactory, effectiveSessionOptions, + signal, ); } catch (error) { if ( @@ -663,10 +673,11 @@ async function connectQwpNodeIngressInternal( effectiveSessionOptions.onSenderError, ), ); - session = await QwpIngressSession.connect(connectionFactory, { - ...effectiveSessionOptions, - replayStore, - }); + session = await QwpIngressSession.connect( + connectionFactory, + { ...effectiveSessionOptions, replayStore }, + signal, + ); } if (orphanDrainer) { session.registerCloseHook(() => orphanDrainer.close()); @@ -756,7 +767,7 @@ export function createQwpNodeSender( sessionOptions: QwpIngressSessionOptions = {}, ): QwpSender { return new QwpSender( - () => + (signal) => connectQwpNodeIngress( { ...options, @@ -764,6 +775,7 @@ export function createQwpNodeSender( options.requestDurableAck ?? senderOptions.awaitDurableAck, }, sessionOptions, + signal, ), senderOptions, ); diff --git a/test/qwp/sender.test.ts b/test/qwp/sender.test.ts index 7d5d3b9..aae7489 100644 --- a/test/qwp/sender.test.ts +++ b/test/qwp/sender.test.ts @@ -1321,6 +1321,72 @@ describe("QWP high-level sender", () => { } }); + it("aborts a first connect still negotiating when close() is called", async () => { + // The reconnect loop owns an AbortController, but the first connect + // bypasses it, so close() could only attach cleanup to the pending promise. + // The socket and its deadline then outlived close() by the whole + // connect/auth timeout, and a CLI or serverless process that closed and + // expected to exit hung for that long. + let received: AbortSignal | undefined; + let settleConnect!: (session: QwpSenderSession) => void; + const sender = new QwpSender( + (signal) => { + received = signal; + return new Promise((resolve) => { + settleConnect = resolve; + }); + }, + { autoFlush: false, closeFlushTimeoutMs: 20 }, + ); + + sender.connect().catch(() => undefined); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(received).toBeDefined(); + expect(received!.aborted).toBe(false); + + await sender.close().catch(() => undefined); + expect(received!.aborted).toBe(true); + + // Let the abandoned connect settle so it cannot leak into another test. + settleConnect(new RecordingSession()); + }); + + it("does not open a new session once close() has returned", async () => { + // close() bounds its flush with a deadline but cannot cancel it, so an + // abandoned close flush stays runnable. getSession() clears sessionPromise + // when a connect fails, so that leftover flush could dial the database + // again and write rows after close() had already returned to the caller -- + // an application that closed a sender to stop writing kept writing. + let sessions = 0; + let failFirstConnect!: (error: Error) => void; + // The first connect must still be pending when close() gives up, and fail + // only afterwards: that is what clears sessionPromise while an abandoned + // close flush is still runnable. + const firstConnect = new Promise((_, reject) => { + failFirstConnect = reject; + }); + const sender = new QwpSender( + async () => { + sessions++; + return sessions === 1 ? firstConnect : new RecordingSession(); + }, + { autoFlush: false, closeFlushTimeoutMs: 20 }, + ); + + await sender.table("t").intColumn("a", 1).atNow(); + sender.flush().catch(() => undefined); + await sender.close().catch(() => undefined); + expect(sessions).toBe(1); + + failFirstConnect(new Error("first connect failed")); + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(sessions).toBe(1); + + // And a fresh acquisition is refused outright rather than dialling. + await expect(sender.flush()).rejects.toThrow("QWP sender is closed"); + expect(sessions).toBe(1); + }); + it("loses no rows across back-to-back flushes", async () => { // The enqueue still has to run synchronously on the call, so two flushes // issued without awaiting cannot drop or duplicate staged rows. From 390c1bcfee260e9d86dc004c53de772a6daaaaef Mon Sep 17 00:00:00 2001 From: glasstiger Date: Sat, 22 Aug 2026 01:18:50 +0100 Subject: [PATCH 143/265] test(qwp): cover store-and-forward exclusion across real processes The journal's exclusion, reclaim and release rules are entirely about what separate processes observe of each other, and nothing tested them that way. Every existing lock test runs two stores in one process, where they share a module-global pending-release list, one event loop and every advisory-lock object -- so the mechanisms could be observed in isolation but never the contract. The closest existing test, "arbitrates acquisition over stale Java lock metadata", also builds its directory with mkdtemp, so the parent has no .slot-locks/ and no .lock.pid from an earlier producer: the state a contender actually meets in production is structurally unreachable there. Adds a suite that forks real producers against the built package, since that is what a deployed process runs. It covers exclusion under contention from a used parent, a live heartbeating holder refusing a contender, adoption of a SIGKILLed producer's slot with its frames recovered, a reclaimed holder refusing to write, and a stalled holder's release leaving a live lock alone. Two of the five fail against the code before the lock fixes; the other three passed already and are labelled in the file as contract tests rather than regression tests. Staleness is produced by backdating the owner directory's mtime rather than by waiting out the 15s window, which is the same on-disk state a paused holder leaves and keeps the suite to about 21 seconds. It runs under `pnpm test:dist`, which build.yml already executes. Co-Authored-By: Claude Opus 5 (1M context) --- test/qwp/sfa-multiprocess-child.mjs | 67 +++++++ test/qwp/sfa-multiprocess.e2e.ts | 300 ++++++++++++++++++++++++++++ vitest.dist.config.ts | 2 +- 3 files changed, 368 insertions(+), 1 deletion(-) create mode 100644 test/qwp/sfa-multiprocess-child.mjs create mode 100644 test/qwp/sfa-multiprocess.e2e.ts diff --git a/test/qwp/sfa-multiprocess-child.mjs b/test/qwp/sfa-multiprocess-child.mjs new file mode 100644 index 0000000..cbd7a10 --- /dev/null +++ b/test/qwp/sfa-multiprocess-child.mjs @@ -0,0 +1,67 @@ +// One real OS process driving a store-and-forward journal, steered over IPC. +// +// The journal's exclusion, reclaim and release rules are all about what +// *separate processes* observe of each other, and none of that is reachable +// from a single-process test: two stores in one process share a module-global +// pending-release list, an event loop, and every advisory lock object. This +// child exists so the suite can put real processes on both sides. +// +// It imports the built package rather than `src/`, because that is what a +// deployed producer runs, and because a forked child has no TypeScript loader. +import { pathToFileURL } from "node:url"; + +const [, , distDir, directory] = process.argv; +const { QwpNodeFileReplayStore } = await import( + pathToFileURL(`${distDir}/es/qwp/node.mjs`).href +); + +const payload = (marker) => new Uint8Array(64).fill(marker.charCodeAt(0)); +const named = (error) => ({ + name: error?.name ?? "Error", + message: String(error?.message ?? error).slice(0, 200), + causeName: error?.cause?.name, +}); + +let store; +const handlers = { + async open() { + store = new QwpNodeFileReplayStore({ directory, durability: "append" }); + const records = await store.loadReferences(); + return { recovered: records.length }; + }, + async append({ sequence, marker }) { + await store.append({ + frameSequence: BigInt(sequence), + payload: payload(marker), + }); + return {}; + }, + async close() { + await store.close(); + return {}; + }, + // Acquiring any other lock is what drains this process's pending-release + // list, which is the step that used to remove somebody else's directory. + async openOther({ otherDirectory }) { + const other = new QwpNodeFileReplayStore({ + directory: otherDirectory, + durability: "append", + }); + await other.loadReferences(); + await other.close(); + return {}; + }, +}; + +process.on("message", (message) => { + const { id, command, args } = message; + void (async () => { + try { + process.send({ id, ok: true, ...(await handlers[command](args ?? {})) }); + } catch (error) { + process.send({ id, ok: false, error: named(error) }); + } + })(); +}); + +process.send({ ready: true }); diff --git a/test/qwp/sfa-multiprocess.e2e.ts b/test/qwp/sfa-multiprocess.e2e.ts new file mode 100644 index 0000000..a2d557a --- /dev/null +++ b/test/qwp/sfa-multiprocess.e2e.ts @@ -0,0 +1,300 @@ +import { fork, type ChildProcess } from "node:child_process"; +import { + mkdir, + mkdtemp, + readdir, + readFile, + stat, + utimes, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, describe, expect, it } from "vitest"; + +/** + * The store-and-forward exclusion, reclaim and release contract, exercised + * with real OS processes against the built package. + * + * Everything here is cross-process by nature and cannot be reached from a + * single-process suite. Two stores in one process share a module-global + * pending-release list, one event loop, and every advisory-lock object, so + * in-process tests can only observe the mechanisms in isolation -- never the + * contract itself. The in-process lock tests in reconnect.test.ts also always + * run against a fresh `mkdtemp` parent, which has no `.slot-locks/` directory + * and no `.lock.pid` left by an earlier producer, so the state a contender + * actually meets in production is structurally unreachable there. + * + * Requires a build. Run with `pnpm test:dist`. + */ + +const ROOT = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../..", +); +const DIST = path.join(ROOT, "dist"); +const CHILD = path.join(ROOT, "test/qwp/sfa-multiprocess-child.mjs"); + +// One heartbeat interval plus slack: how long a holder needs before it can +// notice that its lock was taken. Both live in src/qwp-node/advisory-lock.ts. +const HEARTBEAT_INTERVAL_MS = 5_000; +const BEAT_SETTLE_MS = HEARTBEAT_INTERVAL_MS + 1_500; +const STALE_AFTER_MS = 15_000; + +interface Reply { + ok: boolean; + recovered?: number; + error?: { name: string; message: string; causeName?: string }; +} + +/** A forked producer, driven one request at a time. */ +class Producer { + private nextId = 0; + private constructor(private readonly child: ChildProcess) {} + + static async start(directory: string): Promise { + const child = fork(CHILD, [DIST, directory], { + stdio: ["ignore", "ignore", "pipe", "ipc"], + }); + await new Promise((resolve, reject) => { + child.once("message", () => resolve()); + child.once("exit", (code) => + reject(new Error(`child exited early with ${code}`)), + ); + }); + return new Producer(child); + } + + send(command: string, args: Record = {}): Promise { + const id = this.nextId++; + return new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error(`child timed out on ${command}`)), + 30_000, + ); + const onMessage = (message: Reply & { id: number }) => { + if (message.id !== id) return; + clearTimeout(timer); + this.child.off("message", onMessage); + resolve(message); + }; + this.child.on("message", onMessage); + this.child.send({ id, command, args }); + }); + } + + /** SIGKILL, the way a crashed producer leaves a slot behind. */ + kill(signal: NodeJS.Signals = "SIGKILL"): Promise { + return new Promise((resolve) => { + this.child.once("exit", () => resolve()); + this.child.kill(signal); + }); + } + + get alive(): boolean { + return this.child.exitCode === null && !this.child.killed; + } +} + +const running: Producer[] = []; +const track = async (directory: string): Promise => { + const producer = await Producer.start(directory); + running.push(producer); + return producer; +}; + +afterEach(async () => { + await Promise.all(running.splice(0).map((p) => (p.alive ? p.kill() : null))); +}); + +async function slot(): Promise { + const root = await mkdtemp(path.join(tmpdir(), "qwp-sfa-mp-")); + const directory = path.join(root, "slot"); + await mkdir(directory, { recursive: true }); + return directory; +} + +/** + * Leaves the slot in the shape a real one is in: opened and closed by an + * earlier producer that has since exited, so `.lock` and `.lock.pid` persist + * and the PID they name is dead. This is exactly the precondition an + * `mkdtemp` parent cannot have. + */ +async function withPriorProducer(directory: string): Promise { + const seed = await Producer.start(directory); + expect((await seed.send("open")).ok).toBe(true); + expect((await seed.send("close")).ok).toBe(true); + await seed.kill("SIGTERM"); + expect((await readdir(directory)).sort()).toEqual( + expect.arrayContaining([".lock", ".lock.pid"]), + ); +} + +/** + * Backdates the owner directory so it looks like a holder whose heartbeat + * lapsed, without spending the staleness window in real time. This is the + * on-disk state a paused process, a suspended VM or a stalled filesystem + * produces; the holder is still very much alive. + */ +async function simulateLapsedHeartbeat(directory: string): Promise { + const owner = path.join(directory, ".lock.owner"); + const when = new Date(Date.now() - STALE_AFTER_MS - 5_000); + await utimes(owner, when, when); +} + +async function markerCounts( + directory: string, +): Promise> { + const counts: Record = {}; + for (const file of await readdir(directory)) { + if (!file.endsWith(".sfa")) continue; + for (const byte of await readFile(path.join(directory, file))) { + if (byte < 0x41 || byte > 0x5a) continue; + const marker = String.fromCharCode(byte); + counts[marker] = (counts[marker] ?? 0) + 1; + } + } + return counts; +} + +describe("QWP store-and-forward across processes", () => { + it("hands one slot to exactly one of several contending processes", async () => { + // A contract test, not a regression test: this passes against the code + // before the acquisition-token fix too. It is here because nothing + // previously asserted the exclusion contract across processes at all, and + // because it is the only place the used-parent precondition exists -- the + // in-process tests always start from an empty `mkdtemp` directory. + const directory = await slot(); + await withPriorProducer(directory); + + const contenders = await Promise.all([ + track(directory), + track(directory), + track(directory), + track(directory), + ]); + const replies = await Promise.all(contenders.map((p) => p.send("open"))); + + const winners = replies.filter((reply) => reply.ok); + expect(winners).toHaveLength(1); + for (const loser of replies.filter((reply) => !reply.ok)) { + // The designed error, not whatever an incidental filesystem race + // produced on the way there. + expect(loser.error?.name).toBe("QwpReplayStoreLockedError"); + } + }, 60_000); + + it("refuses a contender while the holder keeps heartbeating", async () => { + // Also a contract test: it guards the other direction of the reclaim rule, + // that a holder still refreshing its mtime is never aged out. + const directory = await slot(); + const holder = await track(directory); + expect((await holder.send("open")).ok).toBe(true); + expect((await holder.send("append", { sequence: 0, marker: "A" })).ok).toBe( + true, + ); + + // Long enough for several heartbeats: a live holder must never age out. + await new Promise((resolve) => setTimeout(resolve, BEAT_SETTLE_MS)); + + const contender = await track(directory); + const refused = await contender.send("open"); + expect(refused.ok).toBe(false); + expect(refused.error?.name).toBe("QwpReplayStoreLockedError"); + }, 60_000); + + it("adopts a crashed producer's slot and recovers its frames", async () => { + // Contract test: crash recovery worked before the lock changes, and has to + // keep working now that a record-less directory no longer expires on the + // dead PID in the sidecar. + const directory = await slot(); + const crashing = await track(directory); + expect((await crashing.send("open")).ok).toBe(true); + for (let sequence = 0; sequence < 5; sequence++) { + expect( + (await crashing.send("append", { sequence, marker: "A" })).ok, + ).toBe(true); + } + await crashing.kill(); + + // No staleness wait: the owner record names a dead PID on this host, which + // is the crash fast path. + const successor = await track(directory); + const opened = await successor.send("open"); + expect(opened.ok).toBe(true); + expect(opened.recovered).toBe(5); + }, 60_000); + + it("stops a reclaimed holder from overwriting the new owner's frames", async () => { + const directory = await slot(); + const stalled = await track(directory); + expect((await stalled.send("open")).ok).toBe(true); + for (let sequence = 0; sequence < 5; sequence++) { + expect((await stalled.send("append", { sequence, marker: "A" })).ok).toBe( + true, + ); + } + + await simulateLapsedHeartbeat(directory); + const successor = await track(directory); + expect((await successor.send("open")).ok).toBe(true); + for (let sequence = 5; sequence < 10; sequence++) { + expect( + (await successor.send("append", { sequence, marker: "B" })).ok, + ).toBe(true); + } + + // The stalled holder needs one heartbeat to see that its directory moved. + await new Promise((resolve) => setTimeout(resolve, BEAT_SETTLE_MS)); + + const rejected = await stalled.send("append", { + sequence: 5, + marker: "A", + }); + expect(rejected.ok).toBe(false); + expect(rejected.error?.name).toBe("QwpReplayStoreLockLostError"); + + // The decisive assertion: the successor's durable bytes are still there. + // A frame's sequence comes from its position, so a same-width overwrite + // would leave a journal that reopens as complete with these bytes gone. + const counts = await markerCounts(directory); + expect(counts.B).toBe(5 * 64); + expect(counts.A).toBe(5 * 64); + }, 60_000); + + it("does not let a stalled holder's release strip a live lock", async () => { + const directory = await slot(); + const other = path.join(path.dirname(directory), "other-slot"); + await mkdir(other, { recursive: true }); + + const stalled = await track(directory); + expect((await stalled.send("open")).ok).toBe(true); + + // Reclaim the slot out from under it, then hand it back, so the stalled + // holder's own release finds a directory that is no longer its own. + await simulateLapsedHeartbeat(directory); + const interloper = await track(directory); + expect((await interloper.send("open")).ok).toBe(true); + expect((await interloper.send("close")).ok).toBe(true); + await new Promise((resolve) => setTimeout(resolve, BEAT_SETTLE_MS)); + await stalled.send("close"); + + const owner = await track(directory); + expect((await owner.send("open")).ok).toBe(true); + const ownerInode = (await stat(path.join(directory, ".lock.owner"))).ino; + + // Acquiring any other lock drains this process's pending-release list. + expect( + (await stalled.send("openOther", { otherDirectory: other })).ok, + ).toBe(true); + await new Promise((resolve) => setTimeout(resolve, 250)); + + expect((await stat(path.join(directory, ".lock.owner"))).ino).toBe( + ownerInode, + ); + const gatecrasher = await track(directory); + const refused = await gatecrasher.send("open"); + expect(refused.ok).toBe(false); + expect(refused.error?.name).toBe("QwpReplayStoreLockedError"); + }, 60_000); +}); diff --git a/vitest.dist.config.ts b/vitest.dist.config.ts index f4efe46..16b663e 100644 --- a/vitest.dist.config.ts +++ b/vitest.dist.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { - include: ["test/qwp/dist.e2e.ts"], + include: ["test/qwp/dist.e2e.ts", "test/qwp/sfa-multiprocess.e2e.ts"], // The suite loads the built bundles directly; Vite must not pre-bundle or // otherwise rewrite them, or the per-entry-point module identity this // suite exists to check would be lost. From ce3ae470e469cc2744066564197d00b980083ac6 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Sun, 23 Aug 2026 22:42:56 +0100 Subject: [PATCH 144/265] fix(qwp): reject geohash and decimal values that cannot be encoded geohashColumn guards only with `value < 0n || value >= 1n << BigInt(precision)`. A relational comparison between a non-numeric string and a BigInt is undefined, so both branches are false; a numeric string, a boolean, an array or a non-integer number compares numerically and passes. Nothing looks at the value again until the encoder reaches `BigInt(value as bigint)`. Two outcomes, both silent at the call site. A value BigInt() can convert stores a different number than the compiled writer stores for the same input. Base-32 geohash text is a documented input form and the writer decodes it, so the same string means two things: fluent geohashColumn("g","12",10) -> ...0e 000a 0c 00 = 12 writer geohash(10).row({g:"12"}) -> ...0e 000a 22 00 = 34 A value it cannot convert is worse. The row is already staged, and staging is deliberately retained on an encode failure so the caller can retry, while closeNow() discards staged rows only for QwpBatchTooLargeError. The batch is therefore retained but never retryable: every flush and close throws "Cannot convert u33d to a BigInt", no frame is ever sent, and healthy rows staged before it -- including rows for unrelated tables -- are never delivered. Only reset() recovers, and it discards everything staged. decimalColumn has the same gap with a quieter outcome. A value that is neither a bigint nor an Int8Array falls through to signedBigEndianToBigInt, which iterates its argument, and a string is iterable: "12345" coerces character by character into 0x0102030405 and "x" stores 0, both with no error anywhere. The declared bigint type is not containment: the package ships JS, and `fromJson.bits as bigint` compiles clean. Decisively, every other setter on the class runtime-checks its value despite having an equally narrow declared type -- stringColumn, booleanColumn, charColumn, binaryColumn, uuidColumn, ipv4Column, long256Column, the fixed-width decimals, the timestamps and both array setters all reject. These two were the exceptions. Both guards route through failRow, so the partial row and its table selection are discarded like every sibling. decimal64Column, decimal128Column and decimal256Column already failed safe: fitsSigned calls BigInt.asIntN, which throws inside their try. Co-Authored-By: Claude Opus 5 (1M context) --- src/_qwp/sender.ts | 23 +++++++++++++++++ test/qwp/sender.test.ts | 56 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/src/_qwp/sender.ts b/src/_qwp/sender.ts index 0ec628a..30b73ba 100644 --- a/src/_qwp/sender.ts +++ b/src/_qwp/sender.ts @@ -1383,6 +1383,15 @@ export class QwpSender { if (!Number.isSafeInteger(scale) || scale < 0 || scale > 76) { throw new RangeError("decimal scale must be between 0 and 76"); } + if (typeof unscaled !== "bigint" && !(unscaled instanceof Int8Array)) { + // signedBigEndianToBigInt() iterates its argument, and a string is + // iterable: "12345" would coerce character by character into + // 0x0102030405 and store silently, while "x" would store 0. Every + // other setter rejects a wrong-typed value at the call site. + throw new TypeError( + "decimalColumn accepts only bigint or Int8Array values", + ); + } if (unscaled instanceof Int8Array && unscaled.length === 0) return this; if (unscaled instanceof Int8Array && unscaled.length > 32) { throw new RangeError("decimal unscaled value cannot exceed 32 bytes"); @@ -1458,6 +1467,20 @@ export class QwpSender { precision: number, ): QwpSender { if (value === null || value === undefined) return this; + if (typeof value !== "bigint") { + // The range check below compares against BigInts, and neither branch of + // it rejects a wrong-typed value: a non-numeric string makes both + // comparisons undefined, while a numeric string, a boolean or an array + // makes them numeric. Such a value would reach BigInt() in the frame + // encoder instead, where it either stores a different number than the + // compiled writer stores for the same input or throws long after the + // row was staged. + return this.failRow( + new TypeError( + "geohashColumn accepts only bigint raw bits; base-32 text is accepted by a compiled writer's geohash() column", + ), + ); + } if (!Number.isSafeInteger(precision) || precision < 1 || precision > 60) { return this.failRow( new RangeError("geohash precision must be between 1 and 60"), diff --git a/test/qwp/sender.test.ts b/test/qwp/sender.test.ts index aae7489..8ae7f67 100644 --- a/test/qwp/sender.test.ts +++ b/test/qwp/sender.test.ts @@ -1497,6 +1497,62 @@ describe("QWP high-level sender", () => { expect(sender.metrics.pendingRows).toBe(1); }); + it("rejects a wrong-typed geohash or decimal value at the call site", async () => { + const session = new RecordingSession(); + const sender = new QwpSender(async () => session, { autoFlush: false }); + + // None of these was rejected by the BigInt range guard: a non-numeric + // string makes both comparisons undefined, and everything else compares + // numerically. They reached BigInt() inside the frame encoder instead, + // where they either stored a different number than a compiled writer + // stores for the same input -- "12" is 34 as base-32 geohash text, not 12 + // -- or threw long after the row had been staged, leaving a sender that + // could never flush or close. + for (const value of [ + "12", + "u33d", + "", + true, + 1.5, + Number.NaN, + [3], + { bits: 3n }, + ]) { + expect(() => + sender.table("geo").geohashColumn("g", value as unknown as bigint, 20), + ).toThrow(/geohashColumn accepts only bigint raw bits/); + // The rejected row takes its table selection with it. + expect(sender.metrics.pendingRows).toBe(0); + } + + // signedBigEndianToBigInt() iterates its argument and a string is + // iterable, so "12345" coerced character by character into 0x0102030405 + // and "x" stored 0 -- both silently, with no error anywhere. + for (const value of ["12345", "x", 12_345, true]) { + expect(() => + sender.table("fx").decimalColumn("d", value as unknown as bigint, 2), + ).toThrow(/decimalColumn accepts only bigint or Int8Array values/); + expect(sender.metrics.pendingRows).toBe(0); + } + + // The rejections leave the sender usable and the accepted forms alone. + await sender + .table("geo") + .geohashColumn("g", 34n, 20) + .decimalColumn("d", 12_345n, 2) + .decimalColumn("absent", new Int8Array(0), 2) + .atNow(); + await sender.flush(); + + const [table] = session.sends[0].tables; + expect(table.columns.map((candidate) => candidate.name)).toEqual([ + "g", + "d", + ]); + expect(column(table, "g").values).toEqual([34n]); + expect(column(table, "d").values).toEqual([12_345n]); + }); + it("maps width aliases onto the same column types", () => { expect(double()).toEqual(float64()); expect(long()).toEqual(int64()); From d7c90dc3b6b3c76c9ed48d915ab4c09faa702d25 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Sun, 23 Aug 2026 22:43:12 +0100 Subject: [PATCH 145/265] fix(qwp): retry a transient journal fault raised while applying an ACK c7be5bb fixed this on the transmit path. The acknowledgement path still latched. The message pump's response-processing catch rethrows only RetriableIngressNackError, QwpProtocolError and QwpReplayRejectedError, and sends everything else to failTerminal. Applying a cumulative ACK reaches store.acknowledgeThrough, and QwpNodeFileReplayStore.assertReady raises a parked maintenanceFailure or checkpointFailure from there. Those are transient by the store's own design -- a42fec5 made it clear them on the next successful batch -- but failTerminal is permanent, so a filesystem hiccup of about a second ended a healthy producer for the rest of the process lifetime. Measured against a real chmod 0500 on the journal directory for 400ms: the connection settled 1011 "could not persist QWP store-and-forward ACK watermark" and a publish three seconds after the volume had recovered still threw. A parked trim failure reaches the same place through assertReady on the next ACK. The transmit path's comment already states why it must not latch and routes the identical class through isRetryableReconnectError to requestReconnect. This path now agrees. Retrying cannot duplicate anything the old behaviour avoided. acknowledgeThrough persists its cursor before it mutates files or memory, so a failure there leaves exactly the state a crash at that instant would leave and replay resumes from the persisted watermark -- which is also what an operator got after restarting the process the terminal latch forced. isRetryableReconnectError alone is too broad here: it treats every error that is not a server rejection as retriable, and two store failures are verdicts on the journal rather than faults. Corrupt bytes read the same way on every attempt, and a slot whose lock another process took over must never be replayed out of, because that races the new owner's appends -- the loss QwpReplayStoreLockLostError exists to prevent. Both now carry retryable: false, which the browser-safe connection reads structurally since it cannot reference the Node-only store classes. Co-Authored-By: Claude Opus 5 (1M context) --- QWP.md | 11 ++ .../reconnecting-ingress-connection.ts | 32 ++++ src/qwp-node/file-replay-store.ts | 21 +++ test/qwp/reconnect.test.ts | 159 ++++++++++++++++++ 4 files changed, 223 insertions(+) diff --git a/QWP.md b/QWP.md index e72f1be..d253fd6 100644 --- a/QWP.md +++ b/QWP.md @@ -400,6 +400,17 @@ durable manifest head before handing removal to that worker and runs in bounded background batches. Frame append uses a vectored header-plus-payload write, avoiding an additional payload-sized journal buffer. +A background provisioning, checkpoint or trim failure is parked on the store and +raised from the next journal call, then cleared by the next successful batch. Because +such a fault is transient — a briefly full, read-only or descriptor-starved volume — +reaching one while applying a server acknowledgement reconnects and replays rather +than ending the sender: a filesystem hiccup must not cost a running producer. Failures +that are verdicts on the journal itself carry `retryable: false` and stay terminal; +today those are `QwpReplayStoreCorruptionError` and `QwpReplayStoreLockLostError`. The +store persists its acknowledgement cursor before it mutates anything, so a fault at +that moment leaves exactly the state a crash at that moment would leave, and replay +resumes from the persisted watermark. + Recovery validates segment CRCs with a reusable 64 KiB scanner and indexes only frame sequence, file offset, and payload length. The reconnect loop reads one payload from its retained segment handle when it is ready to send it; it does not materialize the diff --git a/src/_qwp/_internal/reconnecting-ingress-connection.ts b/src/_qwp/_internal/reconnecting-ingress-connection.ts index 8212aea..b6064f6 100644 --- a/src/_qwp/_internal/reconnecting-ingress-connection.ts +++ b/src/_qwp/_internal/reconnecting-ingress-connection.ts @@ -1145,6 +1145,23 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { // The wire payload decoded successfully. Failures from this point // are local replay-store/bookkeeping failures, not evidence that // the server rejected the head frame. + if (isRetryableResponseFailure(error)) { + // A journal fault here is usually transient: a briefly full or + // read-only filesystem parks maintenanceFailure for about a + // second and the store clears it on the next successful batch. + // failTerminal() is permanent, so latching would brick a running + // producer for the rest of the process lifetime -- the outcome + // the store-level retry exists to prevent. transmitOnce() routes + // the identical class to requestReconnect() for that reason and + // this path has to agree. acknowledgeThrough() persists its + // cursor before it mutates anything, so a failure here leaves + // exactly the state a crash at this instant would leave, and + // replay resumes from the persisted watermark. + await this.requestReconnect(error, connection).catch( + (reconnectError) => this.failTerminal(reconnectError), + ); + return; + } this.failTerminal(error); await connection .close(1011, "QWP ingress response processing failed") @@ -2136,6 +2153,21 @@ function isRetryableReconnectError(error: unknown): boolean { ); } +/** + * Whether a failure raised while applying a server response should be retried + * through a reconnect rather than latching the connection terminal. + * + * Replay-store errors are declared in the Node-only layer, so the journal's own + * verdict -- structural corruption, or a slot lock another process took over -- + * is read structurally through the `retryable` flag those classes carry. + */ +function isRetryableResponseFailure(error: unknown): boolean { + if (!isRetryableReconnectError(error)) return false; + return ( + (error as { retryable?: unknown } | null | undefined)?.retryable !== false + ); +} + function isEndpointPolicyFailure(error: unknown): boolean { if (error instanceof QwpUpgradeError) return true; return ( diff --git a/src/qwp-node/file-replay-store.ts b/src/qwp-node/file-replay-store.ts index 694026a..81ecea3 100644 --- a/src/qwp-node/file-replay-store.ts +++ b/src/qwp-node/file-replay-store.ts @@ -204,6 +204,18 @@ export interface QwpNodeFileReplayStoreMetrics { export class QwpReplayStoreError extends Error { readonly cause?: unknown; + /** + * Whether reconnecting and replaying can plausibly clear this failure. + * + * Background maintenance and checkpoint faults are parked and cleared on the + * next successful batch, so a briefly full, read-only or descriptor-starved + * filesystem is retryable. Structural corruption and a slot lock taken over + * by another process are verdicts on the journal itself and are not. The + * ingress connection lives in the browser-safe layer and cannot reference + * these classes, so it reads this flag structurally. + */ + readonly retryable: boolean = true; + constructor(message: string, cause?: unknown) { super(message); this.name = "QwpReplayStoreError"; @@ -213,6 +225,9 @@ export class QwpReplayStoreError extends Error { /** Durable journal bytes are structurally corrupt and cannot be replayed. */ export class QwpReplayStoreCorruptionError extends QwpReplayStoreError { + /** Corrupt bytes read the same way on every attempt. */ + override readonly retryable = false; + constructor(message: string, cause?: unknown) { super(message, cause); this.name = "QwpReplayStoreCorruptionError"; @@ -247,6 +262,12 @@ export class QwpReplayStoreQuarantinedError extends QwpReplayStoreError { * frames gone. Failing the append is what keeps that loss impossible. */ export class QwpReplayStoreLockLostError extends QwpReplayStoreError { + /** + * Retrying is precisely what must not happen: the slot belongs to another + * process now, so replaying out of it would race that owner's appends. + */ + override readonly retryable = false; + constructor(readonly directory: string) { super( `QWP store-and-forward journal lock was taken over by another process while it was open; this journal is no longer writable [directory=${directory}]`, diff --git a/test/qwp/reconnect.test.ts b/test/qwp/reconnect.test.ts index ae4c582..0b5dd9c 100644 --- a/test/qwp/reconnect.test.ts +++ b/test/qwp/reconnect.test.ts @@ -960,6 +960,109 @@ describe("QWP ingress reconnect and replay", () => { await session.close(); }); + it("reconnects instead of latching when an ACK meets a transient journal fault", async () => { + // A parked maintenance or checkpoint failure surfaces out of the store on + // the next call and clears itself on the next successful batch. Reaching + // it while applying a server ACK used to run failTerminal(), which is + // permanent -- so a filesystem hiccup of about a second ended a healthy + // producer for the rest of the process lifetime. transmitOnce() already + // routed the identical class to a reconnect for that reason. + class AckFaultStore extends TrackingReplayStore { + failNextAck = false; + ackFailures = 0; + + override async acknowledgeThrough(frameSequence: bigint): Promise { + if (this.failNextAck) { + this.failNextAck = false; + this.ackFailures++; + throw new QwpReplayStoreError( + "could not trim QWP store-and-forward segment [firstSequence=0]", + ); + } + return super.acknowledgeThrough(frameSequence); + } + } + + const connections = [ + new FakeConnection("primary"), + new FakeConnection("replacement"), + ]; + let factoryCalls = 0; + const replayStore = new AckFaultStore(); + const session = await QwpIngressSession.connect( + async () => connections[Math.min(factoryCalls++, connections.length - 1)], + { + replayStore, + reconnect: { + maxAttempts: 0, + maxDurationMs: 0, + initialBackoffMs: 0, + maxBackoffMs: 0, + }, + }, + ); + + await session.publishFrame(Uint8Array.of(1)); + expect(connections[0].sent).toEqual([Uint8Array.of(1)]); + + replayStore.failNextAck = true; + connections[0].receive(ingressResponse(QWP_STATUS.OK, 0n)); + + await vi.waitFor(() => expect(factoryCalls).toBe(2)); + expect(replayStore.ackFailures).toBe(1); + // acknowledgeThrough() threw before it could retire the frame, so the + // journal still holds it and the replacement connection replays it. The + // real store persists its cursor before mutating anything, so this is the + // same state a crash at this instant would leave. + expect(Array.from(replayStore.records.keys())).toEqual([0n]); + await vi.waitFor(() => + expect(connections[1].sent).toEqual([Uint8Array.of(1)]), + ); + + // The producer survives. Before the fix every later publish rejected with + // the journal error for the lifetime of the process. + connections[1].receive(ingressResponse(QWP_STATUS.OK, 0n)); + await expect( + session.publishFrame(Uint8Array.of(2)), + ).resolves.toBeUndefined(); + await session.close(); + }); + + it("stays terminal when an ACK meets a journal verdict rather than a fault", async () => { + // Corrupt bytes read the same way on every attempt, so reconnecting would + // spin. The store marks such failures non-retryable and this path honours + // that rather than retrying everything that is not a server rejection. + class CorruptOnAckStore extends TrackingReplayStore { + override async acknowledgeThrough(): Promise { + throw new QwpReplayStoreCorruptionError( + "QWP store-and-forward segment is corrupt", + ); + } + } + + const connection = new FakeConnection("primary"); + let factoryCalls = 0; + const session = await QwpIngressSession.connect( + async () => { + factoryCalls++; + return connection; + }, + { replayStore: new CorruptOnAckStore() }, + ); + + await session.publishFrame(Uint8Array.of(1)); + connection.receive(ingressResponse(QWP_STATUS.OK, 0n)); + await expect(session.closed).resolves.toMatchObject({ code: 1011 }); + + // A failed session rejects synchronously, so go through a thunk. + await expect(async () => + session.publishFrame(Uint8Array.of(2)), + ).rejects.toThrow(/corrupt/); + // No replacement was sought: retrying corrupt bytes only spins. + expect(factoryCalls).toBe(1); + await session.close().catch(() => undefined); + }); + it("publishes while initially offline and drains after a background connection", async () => { const connection = new FakeConnection("primary"); const replayStore = new TrackingReplayStore(); @@ -3778,6 +3881,62 @@ describe("QWP Node file replay store", () => { await store.close(); }, 15_000); + it("keeps the producer alive when that failure surfaces while applying an ACK", async () => { + // The test above proves the store self-heals. Nothing connected that to + // the connection, which reached the parked failure through + // assertReady() on the next ACK and ran failTerminal() -- permanent, so a + // filesystem hiccup of about a second ended a healthy producer for the + // rest of the process lifetime. + const directory = await trackedDirectory(); + const store = new QwpNodeFileReplayStore({ directory, maxSegmentBytes: 1 }); + const connections: FakeConnection[] = []; + const session = await QwpIngressSession.connect( + async () => { + // A fresh connection per attempt; handing back a closed one makes the + // transport look like it keeps dying and trips poison escalation. + const next = new FakeConnection(`endpoint-${connections.length}`); + connections.push(next); + return next; + }, + { replayStore: store, reconnect: { maxAttempts: 0, maxDurationMs: 0 } }, + ); + + for (let sequence = 0; sequence < 3; sequence++) { + await session.publishFrame(Uint8Array.of(sequence)); + } + + const unlink = vi + .spyOn(qwpSegmentMaintenanceWorker, "unlink") + .mockRejectedValueOnce( + Object.assign(new Error("EACCES: permission denied"), { + code: "EACCES", + }), + ); + + // The first ACK schedules the trim that fails; the parked failure then + // surfaces out of the store on the next one. + connections[0].receive(ingressResponse(QWP_STATUS.OK, 0n)); + await vi.waitFor(() => expect(unlink).toHaveBeenCalled()); + connections[0].receive(ingressResponse(QWP_STATUS.OK, 1n)); + + // A reconnect, not a terminal latch. Default backoff bounds the attempts + // to the second or so the store needs to clear the failure. + await vi.waitFor(() => expect(connections.length).toBeGreaterThan(1), { + timeout: 5_000, + }); + unlink.mockRestore(); + await vi.waitFor(() => store.loadSymbolDictionary(), { + timeout: 5_000, + interval: 100, + }); + + await expect( + session.publishFrame(Uint8Array.of(9)), + ).resolves.toBeUndefined(); + await session.close().catch(() => undefined); + await store.close().catch(() => undefined); + }, 20_000); + it("detects a replay gap immediately after a persisted ACK watermark", async () => { const directory = await trackedDirectory(); const first = new QwpNodeFileReplayStore({ From 6e8951bcac7bde8768205acba3a2d656343921e3 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Sun, 23 Aug 2026 22:53:51 +0100 Subject: [PATCH 146/265] fix(qwp): quarantine an orphan slot whose head the server rejects QWP.md promises `.failed` "so a corrupt or permanently rejected head cannot cause a hot retry loop", and node.ts repeats that authentication, protocol and poison-frame failures stay terminal and quarantined. A permanently rejected head did neither. isTerminalDrainFailure tested four types. Both ways the connection gives up on a head frame -- a deterministically terminal status, and a retriable status repeated until the poison detector escalated it -- raise QwpReplayRejectedError, which extends Error and matched none of them, so the slot was classified RETRYING. Nothing bounds that: pump()'s finally drops the directory from `known`, finishScan re-arms at a fixed interval, and `retrying` is a process-wide counter, not a per-slot budget. Measured against a real WebSocket server answering SCHEMA_MISMATCH, with orphanScanIntervalMs=100: 29 adoptions and 29 re-sends of the same frame in three seconds, failed=0, no sentinel, the journal segment untouched. PARSE_ERROR and SECURITY_ERROR are identical. Growth is linear with no plateau -- 59 adoptions in twelve seconds at the 200ms interval -- and each adoption resets the poison strike count, so the detector's decision is discarded every scan. The two escalation routes disagreed, which is what makes this unintended. classifyConnectionLoss returns QwpProtocolError, so poison escalation driven by repeated connection loss already quarantines correctly: one adoption, sentinel written, failed=1. Only the NACK route looped. The comment claiming a protocol violation "is also how poison-frame escalation surfaces" was true of the connection-loss route alone. Bytes were never at risk -- the segment stays intact and a TERMINAL QwpSenderError is emitted on every attempt, which the default handler logs at error level. What was missing is the stop, and the operator signal that says which slot needs looking at. Quarantining restores both; retryQwpNodeOrphanSlot() makes the slot eligible again after inspection. Standalone senders need drainOrphans: true, but createPooledOrphanDrainer builds a drainer whenever ingress.storeAndForward is set, so pooled clients were exposed by default. Co-Authored-By: Claude Opus 5 (1M context) --- src/qwp-node/orphan-drainer.ts | 18 ++++++++++--- test/qwp/orphan-drainer.test.ts | 45 +++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 4 deletions(-) diff --git a/src/qwp-node/orphan-drainer.ts b/src/qwp-node/orphan-drainer.ts index b1fc4c6..78fffaa 100644 --- a/src/qwp-node/orphan-drainer.ts +++ b/src/qwp-node/orphan-drainer.ts @@ -6,6 +6,7 @@ import { type QwpReconnectEvent, QwpConnectionCloseInfo, QwpIngressTransportMetrics, + QwpReplayRejectedError, QwpUpgradeError, } from "../_qwp/transport"; import { @@ -618,14 +619,23 @@ function delay(milliseconds: number): Promise { /** * Only failures that are terminal by design quarantine a slot behind its * `.failed` sentinel and report the abandoned bytes as data loss: a rejected - * authentication, a protocol violation (which is also how poison-frame - * escalation surfaces), an exhausted durable-ACK capability-gap episode, and a - * corrupt journal. Everything else -- an unreachable server, an ACK timeout, - * EMFILE, ENOSPC -- is transient, and the slot is left intact for a later scan. + * authentication, a protocol violation, a head the server will not accept, an + * exhausted durable-ACK capability-gap episode, and a corrupt journal. + * Everything else -- an unreachable server, an ACK timeout, EMFILE, ENOSPC -- + * is transient, and the slot is left intact for a later scan. + * + * QwpReplayRejectedError covers both ways the connection gives up on a head + * frame: a deterministically terminal status, and a retriable status repeated + * until the poison detector escalated it. Re-adopting either restarts the same + * frame against the same server with the strike count reset, which is the hot + * retry loop the `.failed` sentinel exists to prevent. Poison escalation + * driven by connection loss rather than a NACK arrives as a QwpProtocolError + * and is already covered above. */ function isTerminalDrainFailure(error: Error): boolean { if (error instanceof QwpReplayStoreCorruptionError) return true; if (error instanceof QwpProtocolError) return true; + if (error instanceof QwpReplayRejectedError) return true; if (error instanceof QwpDurableAckPersistentFailureError) return true; if (error instanceof QwpUpgradeError) { return error.kind === QWP_UPGRADE_ERROR_KIND.AUTHENTICATION; diff --git a/test/qwp/orphan-drainer.test.ts b/test/qwp/orphan-drainer.test.ts index 390d459..32568ab 100644 --- a/test/qwp/orphan-drainer.test.ts +++ b/test/qwp/orphan-drainer.test.ts @@ -16,6 +16,8 @@ import { QWP_RECONNECT_EVENT_KIND, QWP_SENDER_ERROR_CATEGORY, QWP_SENDER_ERROR_POLICY, + QWP_STATUS, + QwpReplayRejectedError, type QwpSenderError, } from "../../src/qwp"; @@ -307,6 +309,49 @@ describe("QWP Node orphan drainer", () => { await drainer.close(); }); + it("quarantines a head the server will not accept", async () => { + // QWP.md promises `.failed` "so a corrupt or permanently rejected head + // cannot cause a hot retry loop". A rejected head arrives as + // QwpReplayRejectedError, which the classifier did not recognise, so the + // slot was re-adopted on every scan and the same frame re-sent forever + // with the poison strike count reset each time. + const rootDirectory = await root(); + const directory = await recordSlot(rootDirectory, "rejected"); + const rejected = new QwpReplayRejectedError( + 0n, + QWP_STATUS.SCHEMA_MISMATCH, + "column type mismatch", + ); + const senderErrors: QwpSenderError[] = []; + const drainer = new QwpNodeOrphanDrainer({ + rootDirectory, + scanIntervalMs: 0, + createSession: async () => { + const session = new FakeDrainSession(); + // The realistic route: the connection gives up on the head frame and + // fails the session while its replay frames are still pending. + queueMicrotask(() => session.fail(rejected)); + return session; + }, + onSenderError: (error) => senderErrors.push(error), + }); + drainer.start(); + + await vi.waitFor(() => expect(drainer.metrics.failed).toBe(1)); + expect(drainer.metrics.retrying).toBe(0); + expect(await readdir(directory)).toContain(QWP_ORPHAN_FAILED_SENTINEL); + await vi.waitFor(() => expect(senderErrors).toHaveLength(1)); + expect(senderErrors[0]).toMatchObject({ + category: QWP_SENDER_ERROR_CATEGORY.DATA_LOSS, + appliedPolicy: QWP_SENDER_ERROR_POLICY.ABANDONED, + quarantinedPath: directory, + }); + // The sentinel takes the slot out of the scan, so nothing re-sends it. + await expect(scanQwpNodeOrphanSlots(rootDirectory)).resolves.toEqual([]); + + await drainer.close(); + }); + it("quarantines terminal failures until an operator explicitly retries", async () => { const rootDirectory = await root(); const directory = await recordSlot(rootDirectory, "corrupt"); From a9a19484e24dabd1097dfe73c3f464b56603d268 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Sun, 23 Aug 2026 22:54:08 +0100 Subject: [PATCH 147/265] fix(qwp): bound the grid a RESULT_BATCH declares, not only each dimension prepare() checked the row count against MAX_ROWS_PER_BATCH and the column count against QWP_MAX_COLUMNS_PER_TABLE, each against its own constant and neither against the bytes received. Their product does not have to be reachable: 1,048,576 rows of 2,048 columns is 2.1 billion cells. decode() then allocates two rowCount-length arrays per column -- the null layout and the expanded values -- measured at 16 bytes per cell. QWP_MAX_ZSTD_DECOMPRESSED_SIZE bounds decompressed bytes only, and bytes are the wrong unit here. An all-NULL column costs one bit per cell before Zstd, and RLE encodes a whole bitmap run in a single byte, so a compressed body detaches the declared grid from the wire entirely. 64 MiB of all-NULL bitmaps -- inside the Zstd cap -- describes 511 columns of 1,048,576 rows, about 1.07 billion array slots. Measured against the built bundle: 140 bytes allocated 83 MB (593,763x), 1,727 bytes exhausted a 1 GB heap, and 6,655 bytes declaring 62,917,817 decompressed bytes aborted the process with exit 134. The frames are well formed -- the decoder accepted them and returned every column all-null -- so any compromised or buggy server can emit one as the first RESULT_BATCH of an ordinary query(). Nothing capped it earlier: reserveMaterializedBatch gates on batch count rather than size, credit is accounted in compressed wire bytes, and the ws socket carries no maxPayload. The zero-copy queryViews() path allocates one pooled Int32Array per column instead, which amplifies less but still reaches roughly 2 GB at 511 columns. Cap the product. 32Mi cells is about 512 MB decoded at the measured 16 bytes per cell: far above any plausible result -- the widest supported table at 16k rows, or a full 1,048,576-row batch at 32 columns -- and far below what the two independent caps permitted. The check runs in prepare(), before a column is read, because reading one is what allocates; it covers decode() and decodeView() alike, and continuation batches against their established schema. Co-Authored-By: Claude Opus 5 (1M context) --- QWP.md | 9 +++ src/_qwp/_core/constants.ts | 17 ++++++ src/_qwp/_core/result-batch.ts | 10 ++++ test/qwp/egress.test.ts | 101 +++++++++++++++++++++++++++++++++ 4 files changed, 137 insertions(+) diff --git a/QWP.md b/QWP.md index d253fd6..01a8b32 100644 --- a/QWP.md +++ b/QWP.md @@ -1112,6 +1112,15 @@ sends `X-QWP-Max-Batch-Rows`; browsers use the `qwp_max_batch_rows` URL paramete which requires a server that supports browser QWP negotiation. Older servers ignore the browser parameter and keep their configured batch size. +A single `RESULT_BATCH` may declare at most `QWP_MAX_CELLS_PER_BATCH` cells -- +32Mi, its rows multiplied by its columns. The row and column caps bound each +dimension on its own, and a compressed body detaches the grid they describe from +the bytes on the wire: an all-NULL column is one bit per cell before Zstd, so +without this bound a few kilobytes of RLE-compressed bitmap declares a result no +heap can hold. The bound is checked before any column is read, and 32Mi cells sits +far above any plausible result -- the widest supported table at 16k rows, or a full +1,048,576-row batch at 32 columns. Lower `maxBatchRows` for genuinely wide tables. + Egress failover is enabled by default in Node.js and browsers. A transport failure or invalid protocol response closes and deprioritizes that endpoint, reconnects, resets connection-scoped decoding state, and re-executes the active query. The default policy diff --git a/src/_qwp/_core/constants.ts b/src/_qwp/_core/constants.ts index 7a97806..ef03970 100644 --- a/src/_qwp/_core/constants.ts +++ b/src/_qwp/_core/constants.ts @@ -113,6 +113,23 @@ export const QWP_MAX_SYMBOL_DICTIONARY_SIZE = 1_000_000; export const QWP_MAX_ERROR_MESSAGE_LENGTH = 1024; /** Largest client-requested egress RESULT_BATCH row cap. */ export const QWP_MAX_BATCH_ROWS_UPPER_BOUND = 1_048_576; +/** + * Largest `rowCount * columnCount` a single RESULT_BATCH may declare. + * + * The row and column caps above bound each dimension on its own, and their + * product does not have to be reachable: 1,048,576 rows of 2,048 columns is + * 2.1 billion cells. Decoding materializes two `rowCount`-length arrays per + * column, measured at 16 bytes per cell, so the product is what decides how + * much memory a response can cost. It is also the dimension a compressed body + * detaches from the wire: an all-NULL column is one bit per cell before zstd, + * so without this bound a few kilobytes of RLE-compressed bitmap declares a + * grid no heap can hold. + * + * 32Mi cells is roughly 512 MB decoded. That is far above any plausible + * result -- the widest supported table at 16k rows, or a full 1,048,576-row + * batch at 32 columns -- and far below what the caps alone would permit. + */ +export const QWP_MAX_CELLS_PER_BATCH = 33_554_432; export const QWP_INGRESS_PATH = "/write/v4"; export const QWP_EGRESS_PATH = "/read/v1"; diff --git a/src/_qwp/_core/result-batch.ts b/src/_qwp/_core/result-batch.ts index 1eb7b46..d1026b9 100644 --- a/src/_qwp/_core/result-batch.ts +++ b/src/_qwp/_core/result-batch.ts @@ -4,6 +4,7 @@ import { QWP_FLAG_DELTA_SYMBOL_DICTIONARY, QWP_FLAG_GORILLA, QWP_FLAG_ZSTD, + QWP_MAX_CELLS_PER_BATCH, QWP_MAX_COLUMNS_PER_TABLE, QWP_MAX_IDENTIFIER_BYTES, QWP_RESET_MASK_DICTIONARY, @@ -1394,6 +1395,15 @@ export class QwpResultBatchDecoder { "continuation RESULT_BATCH arrived before its schema-bearing batch", ); } + // Each dimension passed its own cap; the grid they describe still has to + // be one this client will allocate. Checked before any column is read, + // because reading one is what allocates. + const cells = rowCount * this.schema.length; + if (cells > QWP_MAX_CELLS_PER_BATCH) { + throw new QwpProtocolError( + `RESULT_BATCH declares ${cells} cells, above the client cap ${QWP_MAX_CELLS_PER_BATCH} [rows=${rowCount}, columns=${this.schema.length}]`, + ); + } return { reader, tableName, rowCount, deltaMode }; } diff --git a/test/qwp/egress.test.ts b/test/qwp/egress.test.ts index 1ded655..6afdfd9 100644 --- a/test/qwp/egress.test.ts +++ b/test/qwp/egress.test.ts @@ -11,6 +11,8 @@ import { QWP_FLAG_ZSTD, QWP_DEFAULT_EGRESS_INITIAL_CREDIT, QWP_DEFAULT_EGRESS_SERVER_INFO_TIMEOUT_MS, + QWP_MAX_CELLS_PER_BATCH, + QWP_MAX_COLUMNS_PER_TABLE, QWP_MAX_ZSTD_DECOMPRESSED_SIZE, QWP_QUERY_FLAG_RESET_DICTIONARY, QWP_STATUS, @@ -148,6 +150,66 @@ function compressedIntResultBatch(requestId = 0n): Uint8Array { ); } +/** + * A Zstd frame of RAW and RLE blocks. RLE is what detaches a declared grid + * from the bytes on the wire: one byte encodes a whole run, so an all-NULL + * bitmap of any size compresses to almost nothing. + */ +function rleZstdFrame( + blocks: readonly ( + | { raw: number[] } + | { rle: [byte: number, size: number] } + )[], + contentSize: number, +): Uint8Array { + // Magic, then a single-segment descriptor with an 8-byte content size. + const out = [0x28, 0xb5, 0x2f, 0xfd, 0xe0]; + let size = BigInt(contentSize); + for (let index = 0; index < 8; index++) { + out.push(Number(size & 0xffn)); + size >>= 8n; + } + blocks.forEach((block, index) => { + const last = index === blocks.length - 1 ? 1 : 0; + const [kind, length] = + "raw" in block ? [0, block.raw.length] : [1, block.rle[1]]; + const header = last | (kind << 1) | (length << 3); + out.push(header & 0xff, (header >>> 8) & 0xff, (header >>> 16) & 0xff); + out.push(...("raw" in block ? block.raw : [block.rle[0]])); + }); + return Uint8Array.from(out); +} + +/** A compressed RESULT_BATCH declaring an all-NULL grid of the given shape. */ +function compressedAllNullBatch(rows: number, columns: number): Uint8Array { + const schema = new QwpByteWriter(); + writeQwpVarint(schema, 0); // table name + writeQwpVarint(schema, rows); + writeQwpVarint(schema, columns); + for (let index = 0; index < columns; index++) { + writeString(schema, `c${index}`); + schema.writeUint8(QWP_COLUMN_TYPE.BOOLEAN); + } + const schemaBytes = Array.from(schema.toUint8Array()); + const bitmapBytes = Math.ceil(rows / 8); + const blocks: ({ raw: number[] } | { rle: [number, number] })[] = [ + { raw: schemaBytes }, + ]; + for (let index = 0; index < columns; index++) { + blocks.push({ raw: [1] }); // null flag + blocks.push({ rle: [0xff, bitmapBytes] }); // every row NULL + } + const body = rleZstdFrame( + blocks, + schemaBytes.length + columns * (1 + bitmapBytes), + ); + const payload = new QwpByteWriter(); + payload.writeUint8(QWP_EGRESS_MESSAGE.RESULT_BATCH).writeBigUint64(1n); + writeQwpVarint(payload, 0); + payload.writeBytes(body); + return encodeQwpFrame(payload.toUint8Array(), QWP_FLAG_ZSTD, 1); +} + function scalarResultBatch(): Uint8Array { const payload = new QwpByteWriter(); payload.writeUint8(QWP_EGRESS_MESSAGE.RESULT_BATCH).writeBigUint64(0n); @@ -599,6 +661,45 @@ describe("QWP result batch decoder", () => { expect(batch.column(0).getInt(99)).toBe(42); }); + it("bounds the grid a RESULT_BATCH declares, not only each dimension", () => { + // The row and column caps are independent, so their product -- 1,048,576 + // rows of 2,048 columns -- is 2.1 billion cells. Decoding materializes + // two rowCount-length arrays per column, and an all-NULL column is one + // bit per cell before Zstd, so a few kilobytes of RLE-compressed bitmap + // used to declare a grid no heap could hold: 1,727 wire bytes exhausted a + // 1 GB heap and 6,655 aborted the process outright. + for (const columns of [128, 480, 511]) { + const wire = compressedAllNullBatch(1_048_576, columns); + expect(wire.byteLength).toBeLessThan(8_000); + const message = decodeQwpEgressMessage(wire); + if (message.kind !== "result-batch") + throw new Error("unexpected message"); + + const before = process.memoryUsage().heapUsed; + expect(() => new QwpResultBatchDecoder().decode(message)).toThrow( + /above the client cap/, + ); + // Rejected in prepare(), before a column is read -- reading one is what + // allocates. + expect(process.memoryUsage().heapUsed - before).toBeLessThan(50e6); + } + }); + + it("still decodes a legitimate batch at the grid cap", () => { + // The widest supported table, at the row count that exactly reaches the + // cap: this must keep working. + const rows = QWP_MAX_CELLS_PER_BATCH / QWP_MAX_COLUMNS_PER_TABLE; + const message = decodeQwpEgressMessage( + compressedAllNullBatch(rows, QWP_MAX_COLUMNS_PER_TABLE), + ); + if (message.kind !== "result-batch") throw new Error("unexpected message"); + + const batch = new QwpResultBatchDecoder().decode(message); + expect(batch.rowCount).toBe(rows); + expect(batch.columns).toHaveLength(QWP_MAX_COLUMNS_PER_TABLE); + expect(batch.get(0, 0)).toBeNull(); + }); + it("requires a bounded, single Zstd frame", () => { const decodeBody = (body: Uint8Array) => { const bytes = compressedIntResultBatch(); From 89f0ee62693a0f20b8b552032cc998d0c62f12f7 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Sun, 23 Aug 2026 23:09:01 +0100 Subject: [PATCH 148/265] fix(qwp): close segment handles when the hot spare cannot be discarded discardHotSpare() wraps and rethrows anything but ENOENT from the spare's unlink or the directory fsync, and it shared a try block with closeSegmentHandles(). A read-only, full or descriptor-starved volume therefore skipped the second call and stranded one FileHandle per live segment. Nothing reopens them: close() memoizes closePromise and the finally below sets `closed` regardless, so a long-lived process that opens and closes store-and-forward senders against a degraded volume leaks descriptors until it exits. load()'s failure path already separates the two for this reason, closing the segment handles and the recovery handles in a finally before releasing the lock. Give close() the same shape. The hot-spare failure is still reported -- it is still the first `failure` and close() still rejects with it. Co-Authored-By: Claude Opus 5 (1M context) --- src/qwp-node/file-replay-store.ts | 10 +++++++ test/qwp/reconnect.test.ts | 46 +++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/src/qwp-node/file-replay-store.ts b/src/qwp-node/file-replay-store.ts index 81ecea3..f520fd7 100644 --- a/src/qwp-node/file-replay-store.ts +++ b/src/qwp-node/file-replay-store.ts @@ -1036,6 +1036,16 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { failure ??= error; }); await this.discardHotSpare(); + } catch (error) { + failure ??= error; + } + // Its own try: discardHotSpare() rethrows anything but ENOENT from the + // spare's unlink or the directory fsync, and sharing one block let a + // read-only or full volume skip this and strand one descriptor per live + // segment. close() memoizes closePromise and sets `closed` below, so + // nothing would reopen them. load()'s failure path already separates + // the two for the same reason. + try { await this.closeSegmentHandles(); } catch (error) { failure ??= error; diff --git a/test/qwp/reconnect.test.ts b/test/qwp/reconnect.test.ts index 0b5dd9c..61931a5 100644 --- a/test/qwp/reconnect.test.ts +++ b/test/qwp/reconnect.test.ts @@ -3937,6 +3937,52 @@ describe("QWP Node file replay store", () => { await store.close().catch(() => undefined); }, 20_000); + it("closes segment handles even when the hot spare cannot be discarded", async () => { + // discardHotSpare() rethrows anything but ENOENT from the spare's unlink + // or the directory fsync. It shared a try with closeSegmentHandles(), so a + // read-only or full volume skipped the second and stranded one descriptor + // per live segment -- unreachable afterwards, because close() memoizes + // closePromise and marks the store closed regardless. + const directory = await trackedDirectory(); + const store = new QwpNodeFileReplayStore({ directory }); + await store.load(); + await store.append({ frameSequence: 0n, payload: Uint8Array.of(1) }); + + const internals = store as unknown as { + segments: Map; + hotSpare?: { path: string }; + }; + const openHandles = () => + [...internals.segments.values()].filter( + (segment) => segment.handle !== undefined, + ).length; + // The spare is provisioned in the background after the first append. + await vi.waitFor(() => expect(internals.hotSpare).toBeDefined()); + const sparePath = internals.hotSpare!.path; + expect(openHandles()).toBeGreaterThan(0); + + // Only the spare's own unlink fails; every other maintenance path is + // left alone so the failure is unambiguously discardHotSpare()'s. + const realUnlink = qwpSegmentMaintenanceWorker.unlink.bind( + qwpSegmentMaintenanceWorker, + ); + const unlink = vi + .spyOn(qwpSegmentMaintenanceWorker, "unlink") + .mockImplementation(async (path: string) => { + if (path !== sparePath) return realUnlink(path); + throw Object.assign(new Error("EACCES: permission denied"), { + code: "EACCES", + }); + }); + + // The failure is still reported rather than swallowed... + await expect(store.close()).rejects.toThrow(/could not discard/); + // ...and the segment handles are released anyway. + expect(openHandles()).toBe(0); + + unlink.mockRestore(); + }); + it("detects a replay gap immediately after a persisted ACK watermark", async () => { const directory = await trackedDirectory(); const first = new QwpNodeFileReplayStore({ From 18b28770503c7baf3737fefa7137721d5cbae4cd Mon Sep 17 00:00:00 2001 From: glasstiger Date: Sun, 23 Aug 2026 23:09:01 +0100 Subject: [PATCH 149/265] docs: generate the QWP entry points typedoc.json already declares c754bfb added ./src/qwp/index.ts, browser.ts and node.ts to typedoc.json to make the QWP entry points reachable for docs, but the npm script still passed src/index.ts positionally, and TypeDoc treats positional arguments as entry points that override the config file. `pnpm docs` therefore emitted the root entry alone, with warnings that QwpTableWriter, QwpWriterColumn and the four QwpExtraOptions members were "referenced but not included in the documentation"; one output file mentioned connectQwpNodeSender, the copy of QWP.md placed in media/. Dropping the argument emits modules/qwp.html, qwp_browser.html and qwp_node.html, and 43 files reference QwpSender. Four entry points move the README to project level, where {@link Sender} and {@link SenderOptions} no longer resolve, so both now name their module. That removes the last two warnings the switch introduced. docs/ is regenerated at release time -- its history is v4.1.0, v4.2.0 -- so it is left alone here and the next release picks up the QWP pages. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 8 ++++++-- package.json | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 3cf789a..13191e0 100644 --- a/README.md +++ b/README.md @@ -28,12 +28,12 @@ Use the stdlib_http option to switch to the standard HTTP/HTTPS modules. ## Configuration options Detailed description of the client's configuration options can be found in -the {@link SenderOptions} documentation. +the {@link index.SenderOptions | SenderOptions} documentation. ## Examples The examples below demonstrate how to use the client.
    -For more details, please, check the {@link Sender}'s documentation. +For more details, please, check the {@link index.Sender | Sender}'s documentation. ### Basic API usage @@ -101,6 +101,10 @@ Two consequences are worth knowing: "The row must have a symbol or column set before it is closed". QWP is columnar and can express it, so the row is sent with no columns — carrying only its designated timestamp. +- A rejected `at()`/`atNow()` on ILP discards the row it could not close, + including its table name, and leaves rows already in the buffer alone. Catch + the error and start the next row from `table()`; there is no need to `reset()` + and nothing already buffered is lost. **Changed in this release.** Earlier versions threw a type error for most nullish values, and protocol v2 encoded `arrayColumn(name, null)` as an explicit NULL diff --git a/package.json b/package.json index 4310997..981115a 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "lint:bench": "eslint 'benchmarks/**/*.ts' vitest.bench-e2e.config.ts", "format:bench": "prettier --write 'benchmarks/**/*.{ts,md}' tsconfig.bench.json vitest.bench-e2e.config.ts", "format": "prettier --write '{src,test}/**/*.{ts,js,json}'", - "docs": "typedoc --out docs src/index.ts", + "docs": "typedoc", "preview:docs": "serve docs" }, "files": [ From 81bb83795b2d0bb24847a4aa141cd13a8e002a8a Mon Sep 17 00:00:00 2001 From: glasstiger Date: Sun, 23 Aug 2026 23:09:25 +0100 Subject: [PATCH 150/265] fix: discard a row that cannot be closed instead of wedging the sender at() and atNow() threw before startNewRow(), leaving hasTable set and position past endOfLastRow. Every later table() then raised "Table name has already been set" -- including after a successful flush(), because compact() moves bytes without touching the row flags -- so one rejected row ended the sender for good unless the caller knew to call reset(), which discards whatever was staged. Two ways in. A row whose every value is nullish cannot be encoded, and this release turned that from a per-value type error into the documented outcome of the omission contract, so it is now reachable from data rather than from a programming mistake. And the timestamp unit is only validated inside writeTimestamp, which runs after at() has written the separator: an unknown unit left a trailing space in an open row, and retrying at() with a good unit appended a second separator and corrupted the line. Roll the row back on either. at()/atNow() now leave the buffer exactly as it was before table(), which is the contract the QWP sender's cancelRow() already offers, and rows already in the buffer are untouched. A caller catches the error and starts the next row from table(). Note this narrows what a caught error leaves behind: code that caught the empty-row error and then added a column to the same open row no longer sees it. That row could never be closed through table() again, so the pattern could not survive a loop. Co-Authored-By: Claude Opus 5 (1M context) --- src/buffer/base.ts | 81 ++++++++++++++++++++++++++------------ test/sender.buffer.test.ts | 54 +++++++++++++++++++++++++ 2 files changed, 109 insertions(+), 26 deletions(-) diff --git a/src/buffer/base.ts b/src/buffer/base.ts index bcfb1a9..5d2957f 100644 --- a/src/buffer/base.ts +++ b/src/buffer/base.ts @@ -96,6 +96,25 @@ abstract class SenderBufferBase implements SenderBuffer { return this; } + /** + * @ignore + * Drops the row being built, so a row that cannot be closed leaves the + * buffer exactly as it was before table() -- the same contract the QWP + * sender's cancelRow() offers. + * + * Without this, a rejected close left `hasTable` set and `position` past + * `endOfLastRow`: every later table() raised "Table name has already been + * set", including after a successful flush(), because compact() moves bytes + * without touching the row flags. reset() was the only way out and it + * discards whatever was already staged. A throw from writeTimestamp() also + * left the separator it had already written, so retrying at() produced a + * second one and corrupted the line. + */ + private discardIncompleteRow() { + this.position = this.endOfLastRow; + this.startNewRow(); + } + private startNewRow() { this.endOfLastRow = this.position; this.hasTable = false; @@ -361,26 +380,31 @@ abstract class SenderBufferBase implements SenderBuffer { * @throws {Error} If `unit` is `'ns'` but `value` is not a `BigInt`. */ at(timestamp: number | bigint, unit: TimestampUnit = "us") { - if (!this.hasSymbols && !this.hasColumns) { - throw new Error( - "The row must have a symbol or column set before it is closed", - ); - } - if (typeof timestamp !== "bigint" && !Number.isInteger(timestamp)) { - throw new Error( - `Designated timestamp must be an integer or BigInt, received ${timestamp}`, - ); - } - if (unit == "ns" && typeof timestamp !== "bigint") { - throw new Error( - `Designated timestamp must be a BigInt if it is set in nanoseconds`, - ); + try { + if (!this.hasSymbols && !this.hasColumns) { + throw new Error( + "The row must have a symbol or column set before it is closed", + ); + } + if (typeof timestamp !== "bigint" && !Number.isInteger(timestamp)) { + throw new Error( + `Designated timestamp must be an integer or BigInt, received ${timestamp}`, + ); + } + if (unit == "ns" && typeof timestamp !== "bigint") { + throw new Error( + `Designated timestamp must be a BigInt if it is set in nanoseconds`, + ); + } + this.checkCapacity([], 1); + this.write(" "); + this.writeTimestamp(timestamp, unit, true); + this.write("\n"); + this.startNewRow(); + } catch (error) { + this.discardIncompleteRow(); + throw error; } - this.checkCapacity([], 1); - this.write(" "); - this.writeTimestamp(timestamp, unit, true); - this.write("\n"); - this.startNewRow(); } /** @@ -388,14 +412,19 @@ abstract class SenderBufferBase implements SenderBuffer { * Designated timestamp will be populated by the server on this record. */ atNow() { - if (!this.hasSymbols && !this.hasColumns) { - throw new Error( - "The row must have a symbol or column set before it is closed", - ); + try { + if (!this.hasSymbols && !this.hasColumns) { + throw new Error( + "The row must have a symbol or column set before it is closed", + ); + } + this.checkCapacity([], 1); + this.write("\n"); + this.startNewRow(); + } catch (error) { + this.discardIncompleteRow(); + throw error; } - this.checkCapacity([], 1); - this.write("\n"); - this.startNewRow(); } /** diff --git a/test/sender.buffer.test.ts b/test/sender.buffer.test.ts index 7465f31..032d9c0 100644 --- a/test/sender.buffer.test.ts +++ b/test/sender.buffer.test.ts @@ -589,6 +589,60 @@ describe("Sender message builder test suite (anything not covered in client inte ); }); + it("discards a row that cannot be closed instead of wedging the sender", async function () { + // A rejected close used to leave hasTable set and position past + // endOfLastRow, so every later table() raised "Table name has already been + // set" -- including after a successful flush(), because compact() moves + // bytes without touching the row flags. Only reset() recovered, and it + // discards whatever was already staged. + const sender = new Sender({ + protocol: "http", + protocol_version: "2", + host: "host", + auto_flush: false, + init_buf_size: 1024, + }); + + await sender.table("t").stringColumn("kept", "first").atNow(); + + // Every value nullish: nothing to encode, so the row cannot be closed. + await expect( + async () => await sender.table("t").arrayColumn("a", null).atNow(), + ).rejects.toThrow( + "The row must have a symbol or column set before it is closed", + ); + + // The sender carries on, and the good row is untouched. + await sender.table("t").stringColumn("kept", "second").atNow(); + expect(bufferContent(sender)).toBe('t kept="first"\nt kept="second"\n'); + await sender.close(); + }); + + it("discards a row whose designated timestamp is rejected", async function () { + // The unit is only checked inside writeTimestamp, which runs after the + // separator has been written, so retrying at() used to append a second + // separator and corrupt the line. + const sender = new Sender({ + protocol: "http", + protocol_version: "2", + host: "host", + auto_flush: false, + init_buf_size: 1024, + }); + + await expect( + async () => + await sender + .table("t") + .stringColumn("c", "x") + .at(1000, "weeks" as "us"), + ).rejects.toThrow("Unknown timestamp unit: weeks"); + + await sender.table("t").stringColumn("c", "y").at(1000, "us"); + expect(bufferContent(sender)).toBe('t c="y" 1000t\n'); + await sender.close(); + }); + it("omits decimal columns with null or undefined value", async function () { const sender = new Sender({ protocol: "tcp", From 0a1fccf7e6d1dfb35e10eef76d2b4a2514e0fb56 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Sun, 23 Aug 2026 23:09:25 +0100 Subject: [PATCH 151/265] fix(qwp): keep auto-flush accurate when reset() lands during a flush reset() zeroes pendingRowCount and pendingByteCount synchronously, while a flush already in flight subtracts its own snapshot in releaseStagedRows() after its await. Both ran against the same counters, so the rows were retired twice. Executed with the interleaving forced: two staged rows leave pendingRows at -2 and pendingBytes at -32, permanently, and three further rows under autoFlushRows: 3 then produce no frame at all -- every row- and byte-triggered auto-flush stays late by that offset for the rest of the sender's life, and metrics.pendingRows reads negative to anything polling it. Both entry points are public and documented and neither claims they exclude each other; Sender.reset() delegates straight through. The ILP sender is immune because flush() snapshots and calls resetAutoFlush() before its first await. Version the staging instead. reset() bumps a generation, flushNow() captures it alongside its snapshots, and releaseStagedRows() retires nothing when they disagree -- the tables those snapshots hold are already detached from `tables`, so there is nothing left to splice either. No rows are lost or duplicated either way; this is the counters only. Co-Authored-By: Claude Opus 5 (1M context) --- src/_qwp/sender.ts | 23 ++++++++++++-- test/qwp/sender.test.ts | 66 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 2 deletions(-) diff --git a/src/_qwp/sender.ts b/src/_qwp/sender.ts index 30b73ba..18ee1de 100644 --- a/src/_qwp/sender.ts +++ b/src/_qwp/sender.ts @@ -942,6 +942,11 @@ export class QwpSender { private currentRowSchemaKeys: string[] = []; private pendingRowCount = 0; private pendingByteCount = 0; + /** + * Bumped by reset(), so a flush that snapshotted the previous staging can + * tell its rows are already gone rather than retiring them a second time. + */ + private stagingGeneration = 0; private lastFlushTime = Date.now(); private sessionPromise?: Promise; private activeSession?: QwpSenderSession; @@ -1043,6 +1048,10 @@ export class QwpSender { this.current = undefined; this.currentRowSchemaKeys.length = 0; this.currentRow.clear(); + // A flush already in flight holds snapshots of the tables just dropped. + // Retiring them against the counters this call zeroes would subtract the + // same rows twice, so mark the staging they belong to as gone. + this.stagingGeneration++; this.resetAutoFlush(); return this; } @@ -2179,7 +2188,16 @@ export class QwpSender { /** Removes a flush's staged rows from the pending buffers. */ private releaseStagedRows( snapshots: readonly { table: StagedTable; rows: readonly StagedRow[] }[], + generation: number, ): number { + if (generation !== this.stagingGeneration) { + // reset() dropped this staging and already zeroed the counters. The + // tables these snapshots hold are detached from `tables`, so there is + // nothing left to retire and subtracting would drive pendingRows + // negative -- permanently, which delays every later row- and + // byte-triggered auto-flush by that offset. + return 0; + } for (const { table, rows } of snapshots) { table.rows.splice(0, rows.length); } @@ -2209,7 +2227,7 @@ export class QwpSender { const snapshots = this.tables .filter((table) => table.rows.length > 0) .map((table) => ({ table, rows: table.rows.slice() })); - return this.releaseStagedRows(snapshots); + return this.releaseStagedRows(snapshots, this.stagingGeneration); } private async tryFlush(): Promise { @@ -2240,6 +2258,7 @@ export class QwpSender { return { flushed: false, sequence: -1n }; } const session = await this.getSession(); + const generation = this.stagingGeneration; const snapshots = this.tables .filter((table) => table.rows.length > 0) .map((table) => ({ table, rows: table.rows.slice() })); @@ -2325,7 +2344,7 @@ export class QwpSender { if (publication) { await publication; } - const sentRows = this.releaseStagedRows(snapshots); + const sentRows = this.releaseStagedRows(snapshots, generation); this.totalRowsPublished += sentRows; this.lastFlushTime = Date.now(); this.log( diff --git a/test/qwp/sender.test.ts b/test/qwp/sender.test.ts index 8ae7f67..b239f7b 100644 --- a/test/qwp/sender.test.ts +++ b/test/qwp/sender.test.ts @@ -1553,6 +1553,72 @@ describe("QWP high-level sender", () => { expect(column(table, "d").values).toEqual([12_345n]); }); + it("keeps auto-flush accurate when reset() lands during a flush", async () => { + // reset() zeroes the pending counters synchronously, while a flush already + // in flight subtracts its own snapshot after its await. Both ran against + // the same counters, so the rows were retired twice: pendingRows went + // negative and stayed there, delaying every later row- and byte-triggered + // auto-flush by that offset for the sender's life. + let releaseSend!: () => void; + const parked = new Promise((resolve) => { + releaseSend = resolve; + }); + let entered!: () => void; + const inSend = new Promise((resolve) => { + entered = resolve; + }); + let armed = true; + let frames = 0; + + // flush() publishes locally by default, so park that rather than sendTables. + class ParkingSession extends RecordingSession { + override async publishTables( + tables: readonly QwpTableBuffer[], + options?: QwpIngressEncodeOptions, + ): Promise { + if (armed) { + armed = false; + entered(); + await parked; + } + frames++; + return super.publishTables(tables, options); + } + } + + const sender = new QwpSender(async () => new ParkingSession(), { + autoFlush: true, + autoFlushRows: 3, + closeFlushTimeoutMs: 0, + }); + + for (const value of [1n, 2n]) { + sender.table("t").longColumn("v", value); + await sender.at(1_000n); + } + expect(sender.metrics.pendingRows).toBe(2); + + const flushing = sender.flush(); + await inSend; + sender.reset(); + expect(sender.metrics.pendingRows).toBe(0); + + releaseSend(); + await flushing; + // The parked flush must not retire rows the reset already dropped. + expect(sender.metrics.pendingRows).toBe(0); + expect(sender.metrics.pendingBytes).toBe(0); + + // Row-triggered auto-flush still fires on the row it was configured for. + const before = frames; + for (const value of [3n, 4n, 5n]) { + sender.table("t").longColumn("v", value); + await sender.at(2_000n); + } + expect(frames - before).toBe(1); + await sender.close(); + }); + it("maps width aliases onto the same column types", () => { expect(double()).toEqual(float64()); expect(long()).toEqual(int64()); From 5f2f692daadb1cb36120457775defff2a64e1971 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Sun, 23 Aug 2026 23:09:38 +0100 Subject: [PATCH 152/265] fix(qwp): let an explicit connect_timeout bound the WebSocket upgrade Opening a connection runs under two deadlines: connectTimeoutMs covers the TCP/TLS transport, and authTimeoutMs takes over for the upgrade and authentication exchange as soon as transportConnected resolves. Both defaulted to 15 seconds independently, and QWP.md documented the default for both as an em dash, so a caller who narrowed connect_timeout got no part of what the key's own description promises -- "deadline for establishing one connection". Measured against a peer that accepts TCP and never answers the upgrade, which is what a stalled proxy or load balancer looks like: ws::...;connect_timeout=200 held the first atNow() for 15,018ms. Adding auth_timeout_ms=300 brought it to 305ms, but nothing pointed at that key, and its default was undocumented. authTimeoutMs now falls back to connectTimeoutMs before the default. This only ever tightens a bound the caller set explicitly -- it changes nothing when both are set or when neither is -- and passing auth_timeout_ms still buys the slower phase an independent budget, which is the right thing when an upgrade legitimately outlasts the transport. QWP.md now states both defaults and which phase each covers. Co-Authored-By: Claude Opus 5 (1M context) --- QWP.md | 31 +++++++++++------ src/_qwp/_internal/websocket-connection.ts | 11 +++++- test/qwp/node-transport.test.ts | 40 ++++++++++++++++++++++ 3 files changed, 70 insertions(+), 12 deletions(-) diff --git a/QWP.md b/QWP.md index 01a8b32..3bd6b62 100644 --- a/QWP.md +++ b/QWP.md @@ -89,17 +89,17 @@ connect string is the portable spelling. ### Connection -| Key | Value | Default | Meaning | -| -------------------- | ------------------ | --------- | ------------------------------------------------------------------ | -| `addr` | `host[:port]` | port 9000 | Endpoint. Repeat the key, or comma-separate, for ordered failover. | -| `username`, `user` | string | — | HTTP Basic user for the WebSocket upgrade. | -| `password`, `pass` | string | — | HTTP Basic password. | -| `token` | string | — | Bearer token; alternative to Basic. | -| `tls_verify` | `on`, `unsafe_off` | on | Certificate verification. `unsafe_off` disables it. | -| `tls_roots` | path | — | PEM or PKCS#12 trust store for a private CA. | -| `tls_roots_password` | string | — | Password for `tls_roots`. | -| `auth_timeout_ms` | integer ms | — | Deadline for the authentication exchange. | -| `connect_timeout` | integer ms | — | Deadline for establishing one connection. | +| Key | Value | Default | Meaning | +| -------------------- | ------------------ | --------- | ---------------------------------------------------------------------------------------- | +| `addr` | `host[:port]` | port 9000 | Endpoint. Repeat the key, or comma-separate, for ordered failover. | +| `username`, `user` | string | — | HTTP Basic user for the WebSocket upgrade. | +| `password`, `pass` | string | — | HTTP Basic password. | +| `token` | string | — | Bearer token; alternative to Basic. | +| `tls_verify` | `on`, `unsafe_off` | on | Certificate verification. `unsafe_off` disables it. | +| `tls_roots` | path | — | PEM or PKCS#12 trust store for a private CA. | +| `tls_roots_password` | string | — | Password for `tls_roots`. | +| `auth_timeout_ms` | integer ms | `15000` | Deadline for the upgrade and authentication exchange. | +| `connect_timeout` | integer ms | `15000` | Deadline for the TCP/TLS transport, and for the upgrade unless `auth_timeout_ms` is set. | ### Ingress @@ -1121,6 +1121,15 @@ heap can hold. The bound is checked before any column is read, and 32Mi cells si far above any plausible result -- the widest supported table at 16k rows, or a full 1,048,576-row batch at 32 columns. Lower `maxBatchRows` for genuinely wide tables. +Opening a connection runs under two deadlines. `connect_timeout` covers the TCP/TLS +transport, and `auth_timeout_ms` takes over for the WebSocket upgrade and the +authentication exchange as soon as the transport connects; both default to 15 +seconds. Setting only `connect_timeout` bounds both phases with that value, so an +endpoint that accepts TCP and never answers the upgrade -- a stalled proxy or load +balancer -- fails inside the budget you asked for rather than 15 seconds later. Set +`auth_timeout_ms` as well when the upgrade legitimately needs longer than the +transport. + Egress failover is enabled by default in Node.js and browsers. A transport failure or invalid protocol response closes and deprioritizes that endpoint, reconnects, resets connection-scoped decoding state, and re-executes the active query. The default policy diff --git a/src/_qwp/_internal/websocket-connection.ts b/src/_qwp/_internal/websocket-connection.ts index d98d1b5..206b6f8 100644 --- a/src/_qwp/_internal/websocket-connection.ts +++ b/src/_qwp/_internal/websocket-connection.ts @@ -154,7 +154,16 @@ export function openQwpWebSocket( return Promise.reject(error); } const connectTimeoutMs = options.connectTimeoutMs ?? DEFAULT_TIMEOUT_MS; - const authTimeoutMs = options.authTimeoutMs ?? DEFAULT_TIMEOUT_MS; + // Opening a connection is two deadlines: connectTimeoutMs covers the TCP/TLS + // transport, and authTimeoutMs takes over for the upgrade and authentication + // exchange the moment transportConnected resolves. A caller who narrows only + // the first is bounding how long establishing one connection may take, and + // the upgrade is part of that -- inheriting keeps an explicit 200 ms from + // being exceeded 75x by a default nobody chose, which is what a peer that + // accepts TCP and never answers the upgrade used to cost. Setting + // authTimeoutMs restores an independent budget for the slower phase. + const authTimeoutMs = + options.authTimeoutMs ?? options.connectTimeoutMs ?? DEFAULT_TIMEOUT_MS; const sendTimeoutMs = options.sendTimeoutMs ?? DEFAULT_TIMEOUT_MS; const closeTimeoutMs = options.closeTimeoutMs ?? DEFAULT_TIMEOUT_MS; diff --git a/test/qwp/node-transport.test.ts b/test/qwp/node-transport.test.ts index 8afc8ea..b8304c4 100644 --- a/test/qwp/node-transport.test.ts +++ b/test/qwp/node-transport.test.ts @@ -145,6 +145,46 @@ describe("QWP Node transport", () => { } }); + it("lets an explicit connect timeout bound the upgrade too", async () => { + // Opening a connection is two deadlines, and the upgrade runs under the + // second one. A caller who set only connectTimeoutMs was therefore held + // for the undocumented 15s authTimeoutMs default -- 75x the bound they + // asked for -- whenever a peer accepted TCP and never answered. + const sockets = new Set(); + const tcpServer = createTcpServer((socket) => { + sockets.add(socket); + socket.once("close", () => sockets.delete(socket)); + socket.resume(); + }); + await new Promise((resolve, reject) => { + tcpServer.once("error", reject); + tcpServer.listen(0, "127.0.0.1", resolve); + }); + + try { + const address = tcpServer.address() as AddressInfo; + const started = Date.now(); + await expect( + connectQwpNodeWebSocket({ + url: `ws://127.0.0.1:${address.port}/write/v4`, + connectTimeoutMs: 40, + closeTimeoutMs: 25, + }), + ).rejects.toMatchObject({ + name: "QwpUpgradeError", + kind: QWP_UPGRADE_ERROR_KIND.TIMEOUT, + timeoutPhase: QWP_UPGRADE_TIMEOUT_PHASE.AUTHENTICATION, + message: "QWP authentication/WebSocket upgrade timed out after 40ms", + } satisfies Partial); + expect(Date.now() - started).toBeLessThan(5_000); + } finally { + for (const socket of sockets) socket.destroy(); + await new Promise((resolve, reject) => { + tcpServer.close((error) => (error ? reject(error) : resolve())); + }); + } + }); + it("negotiates durable ACK and polls progress with a WebSocket PING", async () => { const table = "trades"; const sequenceTransaction = 7n; From 4ea0f387a0947773217027d899d01fe1ac545718 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Sun, 23 Aug 2026 23:55:21 +0100 Subject: [PATCH 153/265] perf(qwp): decompress a RESULT_BATCH in place, not through a shifting window decompressQwpZstdFrame drove fzstd's streaming Decompress, which keeps a window buffer the size of the declared window and memmoves the whole thing down after every block. Reframing single-segment made that window the entire content, so the cost was blocks x contentSize: 862 ms for a legitimate 64 MiB batch that decodes in 6 ms, and 1.5 s for the 4 KB all-NULL frame the grid-cap test builds -- which is how "bounds the grid a RESULT_BATCH declares" came to exceed the 5 s test timeout on every CI runner while taking 3.2 s locally. The shift is an amplification vector in its own right: a few kilobytes of RLE blocks buy seconds of memcpy, and nothing about that needs the frame to be malformed. fzstd's one-shot decompress() decodes straight into the output buffer whenever the window spans it, resolving matches against the output instead of a copy, so the reframe now targets that path. What that path does not give is the byte accounting: it never reports how far it got, it zero-pads a frame that stops short, and it truncates one that runs long. So the reframe appends an eight-byte RLE block after the frame's own blocks -- clearing the last-block flag on the block that carried it -- and declares the content size that marker needs. The marker lands exactly where the frame's output ends, so finding it at the declared content size is the guarantee `written === contentSize` used to give, and the bytes it occupies are the headroom that lets an over-long frame write past the declared size instead of being silently truncated into it. Both mismatch messages are unchanged, and a short frame still reports its real size: the marker is the last thing it writes, and everything past that is the untouched zero tail of the buffer. Handing fzstd a buffer of our own would have been the obvious way to keep exact accounting, and it is a trap: it compares that argument against a sentinel with `!=`, which coerces the whole Uint8Array to a string. 840 ms for a decode that takes 8 ms. Measured on 1,048,576-row all-NULL frames, decode falls from 104 ms to 3.8 ms at 128 columns, from 1,365 ms to 11 ms at 480, and from 1,544 ms to 16 ms at 511. The test that timed out now runs in 28 ms. Frames carrying a content checksum now reach fzstd without those four bytes, since the marker has to be the last block; nothing verified them before either. A test covers that shape, because none did. Co-Authored-By: Claude Opus 5 (1M context) --- src/_qwp/_core/zstd.ts | 143 ++++++++++++++++++++++++++++++---------- test/qwp/egress.test.ts | 21 ++++++ 2 files changed, 128 insertions(+), 36 deletions(-) diff --git a/src/_qwp/_core/zstd.ts b/src/_qwp/_core/zstd.ts index 018b63d..1cfbd1f 100644 --- a/src/_qwp/_core/zstd.ts +++ b/src/_qwp/_core/zstd.ts @@ -1,4 +1,4 @@ -import { Decompress } from "fzstd"; +import { decompress } from "fzstd"; import { QwpProtocolError } from "./errors"; /** Matches the Java client's per-connection decompression safety cap. */ @@ -7,12 +7,29 @@ export const QWP_MAX_ZSTD_DECOMPRESSED_SIZE = 64 * 1024 * 1024; const ZSTD_MAGIC = 0xfd2fb528; const ZSTD_MAX_BLOCK_SIZE = 128 * 1024; +/** + * Bytes of marker appended after the declared content, and the value they + * carry. fzstd decodes into the output buffer without reporting how far it + * got, so the marker is how the decoded length is observed: a run this long + * cannot be faked by a frame that stops early, because everything past what + * the frame wrote is the untouched zero tail of the buffer. + */ +const ZSTD_SIZE_MARKER_BYTES = 8; +const ZSTD_SIZE_MARKER = 0xa5; + interface ZstdFrameInfo { readonly contentSize: number; readonly dataOffset: number; readonly checksum: boolean; } +interface ZstdBlockLayout { + /** Offset of the header of the block flagged last. */ + readonly lastBlockOffset: number; + /** First byte after the last block, so before any content checksum. */ + readonly blocksEnd: number; +} + function requireAvailable( bytes: Uint8Array, offset: number, @@ -100,13 +117,18 @@ function inspectZstdFrame(frame: Uint8Array): ZstdFrameInfo { return { contentSize: Number(contentSize), dataOffset: offset, checksum }; } -function validateSingleZstdFrame(frame: Uint8Array, info: ZstdFrameInfo): void { +function validateSingleZstdFrame( + frame: Uint8Array, + info: ZstdFrameInfo, +): ZstdBlockLayout { let offset = info.dataOffset; + let lastBlockOffset = info.dataOffset; let lastBlock = false; while (!lastBlock) { requireAvailable(frame, offset, 3, "block header"); const header = frame[offset] | (frame[offset + 1] << 8) | (frame[offset + 2] << 16); + lastBlockOffset = offset; offset += 3; lastBlock = (header & 1) !== 0; const blockType = (header >>> 1) & 0x03; @@ -123,6 +145,7 @@ function validateSingleZstdFrame(frame: Uint8Array, info: ZstdFrameInfo): void { requireAvailable(frame, offset, encodedSize, "block body"); offset += encodedSize; } + const blocksEnd = offset; if (info.checksum) { requireAvailable(frame, offset, 4, "content checksum"); offset += 4; @@ -132,58 +155,106 @@ function validateSingleZstdFrame(frame: Uint8Array, info: ZstdFrameInfo): void { `zstd body must contain exactly one frame [frameBytes=${offset}, actual=${frame.byteLength}]`, ); } + return { lastBlockOffset, blocksEnd }; } -function frameWithProbeContentSize( +/** + * Reframes the blocks with a single-segment header, an eight-byte size marker + * appended as a final RLE block, and the content size that marker needs. + * + * fzstd sizes its output from the declared content size and never reports how + * far it actually got, so the marker is what makes the decoded length + * observable: it lands wherever the frame's own output ends, which is the + * declared content size and nowhere else for a frame that means what its + * header says. Those bytes are also the headroom that lets an over-long frame + * write past the declared size instead of being silently truncated into it. + * + * A single-segment window of the output's size is sufficient for all valid + * frames because no match can refer before the decoded content, and it is what + * makes fzstd decode in place: given a window that spans the whole output, it + * resolves matches against the output itself instead of shifting a separate + * window buffer down after every block, which is quadratic in the content + * size. That shift cost a 4 KB frame declaring 64 MiB about 1.5 seconds. + */ +function frameWithSizeMarker( frame: Uint8Array, info: ZstdFrameInfo, + layout: ZstdBlockLayout, ): Uint8Array { - // fzstd uses the frame content size as its output allocation and otherwise - // truncates a corrupt frame whose real output is larger. Reframe the same - // blocks with one extra byte of capacity so our callback can detect that - // overflow. A single-segment window of expected size + 1 is sufficient for - // all valid frames because no match can refer before the decoded content. + // Magic, then a single-segment descriptor with an 8-byte content size. The + // checksum flag is dropped along with the trailing checksum bytes: nothing + // verifies them, and the marker has to be the frame's last block. const headerSize = 4 + 1 + 8; - const blocks = frame.subarray(info.dataOffset); - const probe = new Uint8Array(headerSize + blocks.byteLength); - probe.set(frame.subarray(0, 4)); - probe[4] = 0xe0 | (info.checksum ? 0x04 : 0); - let size = BigInt(info.contentSize + 1); + const markerSize = 3 + 1; + const blocks = frame.subarray(info.dataOffset, layout.blocksEnd); + const reframed = new Uint8Array(headerSize + blocks.byteLength + markerSize); + reframed.set(frame.subarray(0, 4)); + reframed[4] = 0xe0; + let size = BigInt(info.contentSize + ZSTD_SIZE_MARKER_BYTES); for (let index = 0; index < 8; index++) { - probe[5 + index] = Number(size & 0xffn); + reframed[5 + index] = Number(size & 0xffn); size >>= 8n; } - probe.set(blocks, headerSize); - return probe; + reframed.set(blocks, headerSize); + // The marker block is the last one now, so the block that was carries the + // flag no longer. + reframed[headerSize + (layout.lastBlockOffset - info.dataOffset)] &= ~1; + const marker = headerSize + blocks.byteLength; + const header = 1 | (1 << 1) | (ZSTD_SIZE_MARKER_BYTES << 3); + reframed[marker] = header & 0xff; + reframed[marker + 1] = (header >>> 8) & 0xff; + reframed[marker + 2] = (header >>> 16) & 0xff; + reframed[marker + 3] = ZSTD_SIZE_MARKER; + return reframed; +} + +function hasSizeMarkerAt(output: Uint8Array, offset: number): boolean { + if (offset < 0 || offset + ZSTD_SIZE_MARKER_BYTES > output.byteLength) { + return false; + } + for (let index = 0; index < ZSTD_SIZE_MARKER_BYTES; index++) { + if (output[offset + index] !== ZSTD_SIZE_MARKER) return false; + } + return true; +} + +/** Rejects a frame whose output did not end where its header said it would. */ +function requireDeclaredSize(output: Uint8Array, contentSize: number): void { + if (hasSizeMarkerAt(output, contentSize)) return; + // The marker is the last thing a short frame writes and the tail beyond it + // was never touched, so its offset -- eight bytes before the last non-zero + // byte -- is that frame's real output size. A frame that ran long instead + // pushed the marker past the buffer or overwrote it with its own bytes. + let end = output.byteLength; + while (end > 0 && output[end - 1] === 0) end--; + const decoded = end - ZSTD_SIZE_MARKER_BYTES; + if (decoded < contentSize && hasSizeMarkerAt(output, decoded)) { + throw new QwpProtocolError( + `zstd decompressed size ${decoded} does not match frame content size ${contentSize}`, + ); + } + throw new QwpProtocolError( + `zstd output exceeds declared content size ${contentSize}`, + ); } /** Decompresses the single bounded Zstd frame carried by a RESULT_BATCH. */ export function decompressQwpZstdFrame(frame: Uint8Array): Uint8Array { const info = inspectZstdFrame(frame); - validateSingleZstdFrame(frame, info); - const probeFrame = frameWithProbeContentSize(frame, info); - const output = new Uint8Array(info.contentSize); - let written = 0; + const layout = validateSingleZstdFrame(frame, info); + let output: Uint8Array; try { - const decoder = new Decompress((chunk) => { - if (written + chunk.byteLength > output.byteLength) { - throw new QwpProtocolError( - `zstd output exceeds declared content size ${info.contentSize}`, - ); - } - output.set(chunk, written); - written += chunk.byteLength; - }); - decoder.push(probeFrame, true); + // fzstd allocates the output itself, from the declared content size the + // marker is accounted for in. Handing it a buffer of our own instead costs + // more than the decompression does: it compares that argument against a + // sentinel with `!=`, and coercing a 64 MiB Uint8Array to a string for + // that comparison took 840 ms where the whole decode takes 8 ms. + output = decompress(frameWithSizeMarker(frame, info, layout)); } catch (error) { if (error instanceof QwpProtocolError) throw error; const detail = error instanceof Error ? `: ${error.message}` : ""; throw new QwpProtocolError(`zstd decompression failed${detail}`); } - if (written !== info.contentSize) { - throw new QwpProtocolError( - `zstd decompressed size ${written} does not match frame content size ${info.contentSize}`, - ); - } - return output; + requireDeclaredSize(output, info.contentSize); + return output.subarray(0, info.contentSize); } diff --git a/test/qwp/egress.test.ts b/test/qwp/egress.test.ts index 6afdfd9..930d711 100644 --- a/test/qwp/egress.test.ts +++ b/test/qwp/egress.test.ts @@ -650,6 +650,27 @@ describe("QWP result batch decoder", () => { expect(batch.get(99, 0)).toBe(42); }); + it("decodes a Zstd frame that carries a content checksum", () => { + // Nothing verifies the four trailing bytes -- neither the frame walk nor + // fzstd looks at them -- but they sit past the last block, so a decoder + // that mistakes them for one more block, or appends after them, gets a + // frame that no longer decodes. + const size = COMPRESSED_INT_RESULT_BODY.byteLength; + const checksummed = new Uint8Array(size + 4); + checksummed.set(COMPRESSED_INT_RESULT_BODY); + checksummed[4] |= 0x04; // content checksum flag + checksummed.set(Uint8Array.of(9, 9, 9, 9), size); + + const message = decodeQwpEgressMessage(compressedIntResultBatch()); + if (message.kind !== "result-batch") throw new Error("unexpected message"); + const batch = new QwpResultBatchDecoder().decode({ + ...message, + body: checksummed, + }); + expect(batch.rowCount).toBe(100); + expect(batch.get(99, 0)).toBe(42); + }); + it("exposes a reusable view over a Zstd RESULT_BATCH", () => { const message = decodeQwpEgressMessage(compressedIntResultBatch(7n)); if (message.kind !== "result-batch") throw new Error("unexpected message"); From 6417967a85571a9c912da5daefae4e7fb26dc816 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Mon, 24 Aug 2026 11:49:52 +0100 Subject: [PATCH 154/265] fix(qwp): retry a transient upgrade rejection instead of failing terminal classifyUpgradeRejection marked every non-101 status except 421 as retryable: false. connectLoop rethrows a non-retryable error before it reaches the attempt or duration budget, and pump()'s catch then latches the connection terminal, so a single blip ended the sender for the life of the process. Measured against a ws server that accepts the first upgrade and answers the reconnect with a status for 250ms before recovering, on the documented default connect string ws::addr=host:port with no tuning: 503, 502, 500, 504 and 429 each produced exactly one reconnect attempt and never recovered. 421 made five and honoured the budget. Raising the budget to maxAttempts: 200 and maxDurationMs: 60000 still produced one attempt, because the throw precedes the exhaustion check. Afterwards every flush rejects with the original upgrade error even once the server is healthy, rows staged at that moment are never delivered, close() throws rather than draining them, and failTerminal discards the unacked frames held in the in-memory replay buffer. connect() still resolves true and metrics.connected still reads true, so a health check sees a healthy sender. A rolling restart behind nginx or an ALB is exactly this shape, and the rest of the library already says so: the browser bootstrap uses statusCode >= 500, and the ILP HTTP transport's RETRIABLE_STATUS_CODES lists 500, 503, 504 and more as "server errors and gateway timeouts that may be transient". 5xx and 429 now join 421. 401 and 403 stay terminal, and a 4xx other than 429 is a client-side mistake that byte-identical replay cannot fix, so it stays non-retryable too. Co-Authored-By: Claude Opus 5 (1M context) --- src/qwp/node.ts | 10 +++++++++- test/qwp/session.test.ts | 28 ++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/qwp/node.ts b/src/qwp/node.ts index 1d076f5..a382b97 100644 --- a/src/qwp/node.ts +++ b/src/qwp/node.ts @@ -168,7 +168,15 @@ function classifyUpgradeRejection( `QWP WebSocket upgrade rejected with HTTP ${statusCode}${suffix}`, { kind, - retryable: statusCode === 421, + // A 5xx or a 429 is what a proxy, a load balancer, or a rolling restart + // answers with while a backend is coming back, so it must not end the + // reconnect loop: connectLoop rethrows a non-retryable error before it + // ever reaches the attempt/duration budget, which latches the sender + // terminal on the first blip. This matches the browser bootstrap + // (`statusCode >= 500`) and the ILP HTTP transport's retriable set. + // 401/403 stay terminal, and a 4xx other than 429 is a client-side + // mistake that byte-identical replay cannot fix. + retryable: statusCode === 421 || statusCode === 429 || statusCode >= 500, tryNextEndpoint: statusCode !== 401 && statusCode !== 403, url, statusCode, diff --git a/test/qwp/session.test.ts b/test/qwp/session.test.ts index d8ff8c2..fff2bb5 100644 --- a/test/qwp/session.test.ts +++ b/test/qwp/session.test.ts @@ -1298,10 +1298,38 @@ describe("QWP WebSocket adapters", () => { tryNextEndpoint: true, }, { + // A rolling restart behind a proxy answers 503 for a few seconds. The + // reconnect loop must keep sweeping instead of latching terminal. statusCode: 503, statusMessage: "Service Unavailable", headers: {}, kind: QWP_UPGRADE_ERROR_KIND.HTTP_REJECTED, + retryable: true, + tryNextEndpoint: true, + }, + { + statusCode: 502, + statusMessage: "Bad Gateway", + headers: {}, + kind: QWP_UPGRADE_ERROR_KIND.HTTP_REJECTED, + retryable: true, + tryNextEndpoint: true, + }, + { + statusCode: 429, + statusMessage: "Too Many Requests", + headers: {}, + kind: QWP_UPGRADE_ERROR_KIND.HTTP_REJECTED, + retryable: true, + tryNextEndpoint: true, + }, + { + // A 4xx that is not 401/403/421/429 is a client-side mistake, so + // byte-identical replay cannot fix it and the sweep must not retry it. + statusCode: 404, + statusMessage: "Not Found", + headers: {}, + kind: QWP_UPGRADE_ERROR_KIND.HTTP_REJECTED, retryable: false, tryNextEndpoint: true, }, From 12f32f133fa30845dc5d9078bc73c0c71bff7565 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Mon, 24 Aug 2026 11:50:32 +0100 Subject: [PATCH 155/265] fix(qwp): stop a transient owner-record read from latching the slot lock readOwnerFile collapsed every failure into undefined, ownsOwnerDirectory read undefined as "not mine", and beat() read that as a takeover and called markCompromised() -- which also stops the heartbeat, so nothing could ever clear it. One failed read permanently ended a journal nobody had touched. The read is the only step of the heartbeat that needs a file descriptor: stat() and utimes() do not. Process-wide descriptor pressure, from anywhere in the host application, therefore hits precisely this call while the rest of the beat still succeeds; EIO and NFS ESTALE land the same way. Reproduced with a real kernel fault and no mocking -- ulimit -n 120, then exhausting descriptors with openSync across one beat -- and the surviving stat() proved the mtime was unchanged, which is the evidence of ownership the code then threw away. The latch also bought nothing. Injecting the same EIO on stat() instead leaves the beat transient: lost stays false until provenAtMs goes stale at +21.9s, the fault clears, the next beat refreshes, and release() succeeds. Injecting it on the record read latched at the first beat, while provenAtMs was 5.9s old, and never recovered. Both are equally safe against the double-write the design fears; only one is recoverable. Collateral from one injected EMFILE: append rejects QwpReplayStoreLockLostError with retryable false so nothing retries it, close() throws, the .lock.owner directory leaks because release() skips removal when compromised, and a same-process reopen is refused for a full staleness window. The message -- "taken over by another process while it was open" -- was false; the record still held our own pid and token. readOwnerFile now reports absent, present or unreadable. Only a record that is genuinely gone or genuinely someone else's marks the lock lost; a failed read skips the beat and lets the next one retry, exactly as the catch below it already does for stat(), with provenAtMs staleness supplying the fail-closed guarantee. release() keeps a lock it cannot vouch for on the retry list instead of reporting a release that never happened. Co-Authored-By: Claude Opus 5 (1M context) --- src/qwp-node/advisory-lock.ts | 99 +++++++++++++++++++++++++++-------- test/qwp/reconnect.test.ts | 42 +++++++++++++++ 2 files changed, 120 insertions(+), 21 deletions(-) diff --git a/src/qwp-node/advisory-lock.ts b/src/qwp-node/advisory-lock.ts index 5431dab..7932f15 100644 --- a/src/qwp-node/advisory-lock.ts +++ b/src/qwp-node/advisory-lock.ts @@ -226,11 +226,22 @@ export class QwpNodeAdvisoryLock { // A release can be retried long after the fact, by which time the pathname // may hold somebody else's acquisition. Removing it then would strip a // live lock, so prove the directory is still the one this object created. - if (!(await this.ownsOwnerDirectory())) { + const ownership = await this.ownershipState(); + if (ownership === "foreign") { this.released = true; pendingReleases.delete(this); return; } + if (ownership === "unknown") { + // Neither "ours to remove" nor "somebody else's to leave alone". Keep it + // on the retry list so a later acquisition settles it, rather than + // reporting a release that never happened and stranding the directory. + pendingReleases.add(this); + throw new QwpNodeAdvisoryLockError( + "could not confirm QWP advisory lock ownership before release", + this.lockPath, + ); + } try { await removeOwnerDirectory(this.ownerPath); } catch (error) { @@ -248,13 +259,20 @@ export class QwpNodeAdvisoryLock { } /** - * Whether the owner directory still carries this acquisition's token. A - * missing directory, an unreadable record, or a different token all mean - * this object no longer owns the pathname. + * Whether the owner directory still carries this acquisition's token. + * + * `"unknown"` is deliberately distinct from `"foreign"`: a read that failed + * says nothing about who owns the pathname, and callers that latch on it + * turn a transient descriptor shortage into a permanently dead journal. + * Staleness of {@link provenAtMs} is what keeps `"unknown"` fail-closed. */ - private async ownsOwnerDirectory(): Promise { + private async ownershipState(): Promise<"owned" | "foreign" | "unknown"> { const owner = await readOwnerFile(this.ownerPath); - return owner?.token !== undefined && owner.token === this.token; + if (owner.state === "unreadable") return "unknown"; + if (owner.state === "absent") return "foreign"; + return owner.record.token !== undefined && owner.record.token === this.token + ? "owned" + : "foreign"; } private startHeartbeat(): void { @@ -283,10 +301,19 @@ export class QwpNodeAdvisoryLock { // The mtime alone cannot separate our directory from a replacement that // landed inside the same clock tick, and some filesystems report whole // seconds. The token settles it. - if (!(await this.ownsOwnerDirectory())) { + const ownership = await this.ownershipState(); + if (ownership === "foreign") { this.markCompromised(); return; } + if (ownership === "unknown") { + // Refreshing an mtime we cannot vouch for would extend a lock that may + // no longer be ours, so skip the beat entirely and let the next one + // retry -- the same treatment the catch below gives a failed stat(). + // If the fault persists, `provenAtMs` goes stale and `lost` fails + // closed on its own, which is recoverable; latching here is not. + return; + } this.ownerMtimeMs = await touchOwnerDirectory(this.ownerPath); this.provenAtMs = Date.now(); } catch (error) { @@ -394,33 +421,63 @@ async function isStale(ownerPath: string, mtimeMs: number): Promise { // created stale and took it away from its live owner. const owner = await readOwnerFile(ownerPath); return ( - owner !== undefined && owner.host === hostname() && !isPidAlive(owner.pid) + owner.state === "present" && + owner.record.host === hostname() && + !isPidAlive(owner.record.pid) ); } -async function readOwnerFile( - ownerPath: string, -): Promise { +/** + * Outcome of reading an owner record. + * + * `unreadable` carries no information about ownership and must never be read + * as one. `stat()` and `utimes()` need no file descriptor while this read must + * `open(2)`, so process-wide descriptor pressure -- from anywhere in the host + * application -- fails precisely this call while every other step of the + * heartbeat still succeeds. `EIO` and NFS `ESTALE` land the same way. Treating + * that as a takeover latches a lock nobody took, which is unrecoverable + * because the latch also stops the heartbeat. + */ +type OwnerRead = + | { readonly state: "absent" } + | { readonly state: "present"; readonly record: OwnerRecord } + | { readonly state: "unreadable" }; + +async function readOwnerFile(ownerPath: string): Promise { + let contents: string; try { - const parsed: unknown = JSON.parse( - await readFile(join(ownerPath, OWNER_FILE), "utf8"), - ); + contents = await readFile(join(ownerPath, OWNER_FILE), "utf8"); + } catch (error) { + // A record that is gone is positive evidence: this acquisition wrote one + // and it is no longer there. Every other failure is a fault in the read + // itself and proves nothing. + return nodeErrorCode(error) === "ENOENT" + ? { state: "absent" } + : { state: "unreadable" }; + } + try { + const parsed: unknown = JSON.parse(contents); if (parsed && typeof parsed === "object") { const { pid, host, token } = parsed as Partial; if (typeof pid === "number" && typeof host === "string") { return { - pid, - host, - token: typeof token === "string" ? token : undefined, + state: "present", + record: { + pid, + host, + token: typeof token === "string" ? token : undefined, + }, }; } } + // A record written by an older client: it parsed, and it carries no token + // of ours, so it is somebody else's acquisition. + return { state: "absent" }; } catch { - // A record written by an older client, a torn write, or an acquisition - // that has not written its record yet. None of them prove a holder is - // gone, so the caller falls back to the mtime heartbeat. + // A torn write, caught mid-`writeFile` by a contender that is still + // establishing itself. Not proof that this acquisition lost anything. + return { state: "unreadable" }; } - return undefined; } /** Identifies one acquisition, so a release can prove what it is removing. */ diff --git a/test/qwp/reconnect.test.ts b/test/qwp/reconnect.test.ts index 61931a5..6f6fadb 100644 --- a/test/qwp/reconnect.test.ts +++ b/test/qwp/reconnect.test.ts @@ -4379,6 +4379,48 @@ describe("QWP Node file replay store", () => { await rm(join(directory, ".lock.owner"), { recursive: true, force: true }); }); + it("survives a transient failure to read its own owner record", async () => { + // Reading the record needs a descriptor; stat() and utimes() do not. So + // process-wide descriptor pressure -- from anywhere in the host app -- and + // EIO or NFS ESTALE fail precisely this one call while the rest of the + // heartbeat still succeeds. Treating that as a takeover latched the lock + // permanently, because the same step also stops the heartbeat that would + // clear it: every later append then failed with "taken over by another + // process" for a slot nobody took, and release() threw. Staleness of + // provenAtMs is what keeps an unprovable beat fail-closed, and unlike a + // latch it recovers. + const directory = await trackedDirectory(); + const lock = await QwpNodeAdvisoryLock.acquire(directory); + const beat = () => + (lock as unknown as { beat(): Promise }).beat.call(lock); + const ownerPath = join(directory, ".lock.owner"); + const recordPath = join(ownerPath, "owner"); + const record = await readFile(recordPath, "utf8"); + const untouched = await stat(ownerPath); + + // A directory where the record belongs yields EISDIR for every user, root + // included, so this stands in for a transient fault without a mock. + await unlink(recordPath); + await mkdir(recordPath); + // Adding and removing an entry moves the parent's mtime. Put it back, so + // the beat's staleness check sees exactly the value it last wrote and the + // read is the only thing that fails. + await utimes(ownerPath, untouched.atime, untouched.mtime); + + await beat(); + expect(lock.lost).toBe(false); + + // The fault clears, and the lock is still usable rather than latched. + await rm(recordPath, { recursive: true }); + await writeFile(recordPath, record); + await utimes(ownerPath, untouched.atime, untouched.mtime); + + await beat(); + expect(lock.lost).toBe(false); + await expect(lock.release()).resolves.toBeUndefined(); + await expect(stat(ownerPath)).rejects.toMatchObject({ code: "ENOENT" }); + }); + it("reclaims a slot whose owner heartbeat stopped", async () => { const directory = await trackedDirectory(); const ownerPath = join(directory, ".lock.owner"); From 6c3de06b64f9b15bfe08567426eb8da475557d13 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Mon, 24 Aug 2026 11:51:33 +0100 Subject: [PATCH 156/265] fix(qwp): make the ACK watermark durable before the manifest that trims it persistAcknowledgedThrough fsyncs the watermark only under "append" durability, while writeManifest fsyncs its record and the directory whatever the mode. A trim runs straight after the ACK that emptied the segment, so the head could reach disk while the watermark justifying it was still in the page cache. A syscall trace of the unmodified program, with only the fs import redirected to a recording shim, shows the order: write .ack-watermark with no sync, then write sf-manifest.bin, sync it, sync the directory. Under "append" the same trace syncs the watermark, and the control run recovers cleanly from the same crash -- the single missing fsync is the whole difference. Recovery rejects the resulting pair with "sequence has a gap" and quarantines the slot behind a .failed sentinel; the fresh slot starts empty and the orphan drainer skips *.unreplayable-N, so every live frame is abandoned rather than a bounded suffix. QWP.md promises "periodic" can lose the most recent checkpoint window, and this loses the journal. Worth being precise about reachability: a real SIGKILL does not produce it. The un-fsynced write reaches the page cache and survives a process crash, an OOM kill and a container stop -- verified by forking a producer, killing it, and reopening. Only a kernel panic, a power cut or a hard VM loss drops that page, which is exactly the failure "periodic" exists to bound. writeManifest now flushes a pending watermark first. The flag driving it is tracked separately from acknowledgementDirty, which only schedules the periodic checkpoint, so the ordering holds under "memory" too. Co-Authored-By: Claude Opus 5 (1M context) --- src/qwp-node/file-replay-store.ts | 42 +++++++++++++++++++++++++++++- test/qwp/reconnect.test.ts | 43 +++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/src/qwp-node/file-replay-store.ts b/src/qwp-node/file-replay-store.ts index f520fd7..0835d18 100644 --- a/src/qwp-node/file-replay-store.ts +++ b/src/qwp-node/file-replay-store.ts @@ -377,6 +377,18 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { private acknowledgedThrough = -1n; private dictionaryDirty = false; private acknowledgementDirty = false; + /** + * Set whenever the ACK watermark has been written but not yet fsynced, in + * every durability mode -- unlike {@link acknowledgementDirty}, which only + * schedules the periodic checkpoint. + * + * `writeManifest` fsyncs the manifest and the directory unconditionally, and + * a trim writes the manifest right after an ACK advances the watermark. Left + * unsynced, a power loss can make the manifest head durable while the + * watermark that justifies it is not, and recovery rejects that pair for the + * whole journal rather than losing the checkpoint window `periodic` promises. + */ + private acknowledgementUnsynced = false; private directoryDirty = false; private capacityGeneration = 0; private checkpointTimer?: ReturnType; @@ -1603,6 +1615,7 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { this.dirtyRecordPaths.clear(); this.dictionaryDirty = false; this.acknowledgementDirty = false; + this.acknowledgementUnsynced = false; this.directoryDirty = false; this.checkpointFailure = undefined; this.totalCheckpoints++; @@ -1791,6 +1804,10 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { `invalid QWP store-and-forward manifest boundaries [headBase=${headBase}, activeBase=${activeBase}]`, ); } + // The manifest below is fsynced unconditionally, so a watermark still + // sitting in the page cache would be overtaken by the head that trimming it + // justified. Make the watermark durable first: recovery reads the pair. + await this.syncAcknowledgement(); const path = join(this.directory, MANIFEST_FILE); const nextGeneration = this.manifestGeneration + 1n; const file = await openMetadataFile(path); @@ -1881,7 +1898,12 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { record, Number((nextGeneration & 1n) * BigInt(RECORD_SLOT_SIZE)), ); - if (this.durability === QWP_SF_DURABILITY.APPEND) await file.sync(); + if (this.durability === QWP_SF_DURABILITY.APPEND) { + await file.sync(); + this.acknowledgementUnsynced = false; + } else { + this.acknowledgementUnsynced = true; + } } finally { await file.close(); } @@ -1918,12 +1940,30 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { } } + /** + * Makes a written-but-unsynced ACK watermark durable. Called before any + * manifest write, which is fsynced unconditionally, so the two records can + * never reach disk out of order. + */ + private async syncAcknowledgement(): Promise { + if (!this.acknowledgementUnsynced) return; + const file = await openMetadataFile(join(this.directory, ACK_FILE)); + try { + await file.sync(); + } finally { + await file.close(); + } + this.acknowledgementUnsynced = false; + this.acknowledgementDirty = false; + } + private async removeAcknowledgedThrough(): Promise { if (this.acknowledgedThrough < 0n) return; await ignoreMissing(unlink(join(this.directory, ACK_FILE))); this.acknowledgedThrough = -1n; this.ackGeneration = 0n; this.acknowledgementDirty = false; + this.acknowledgementUnsynced = false; if (this.durability === QWP_SF_DURABILITY.APPEND) { await syncDirectory(this.directory); } else if (this.durability === QWP_SF_DURABILITY.PERIODIC) { diff --git a/test/qwp/reconnect.test.ts b/test/qwp/reconnect.test.ts index 6f6fadb..82f9475 100644 --- a/test/qwp/reconnect.test.ts +++ b/test/qwp/reconnect.test.ts @@ -4379,6 +4379,49 @@ describe("QWP Node file replay store", () => { await rm(join(directory, ".lock.owner"), { recursive: true, force: true }); }); + it("makes the ACK watermark durable before the manifest that trimming advanced", async () => { + // writeManifest() fsyncs the manifest and the directory whatever the + // durability mode, while the watermark write skips its fsync outside + // "append". A trim runs straight after the ACK that emptied the segment, + // so a power loss could leave a durable head above a watermark still in + // the page cache -- and recovery rejects that pair for the whole journal + // rather than losing the checkpoint window "periodic" promises. + // + // The ordering is not observable from outside without a real power cut, so + // assert the flag that drives it: after a trim nothing may be left + // unsynced. Dropping the syncAcknowledgement() call from writeManifest() + // leaves it true. + const directory = await trackedDirectory(); + const store = new QwpNodeFileReplayStore({ + directory, + maxSegmentBytes: 8192, + durability: "periodic", + checkpointIntervalMs: 3_600_000, + }); + const internals = store as unknown as { acknowledgementUnsynced: boolean }; + await store.load(); + for (let sequence = 0n; sequence < 10n; sequence++) { + await store.append({ + frameSequence: sequence, + payload: new Uint8Array(2048), + }); + } + + // An ACK that empties no segment leaves the watermark for the checkpoint, + // which is an hour away here -- so the flag is meaningful. + await store.acknowledgeThrough(0n); + expect(internals.acknowledgementUnsynced).toBe(true); + + // This one trims, so the manifest advances and the watermark must overtake + // it on disk first. + await store.acknowledgeThrough(5n); + await vi.waitFor(async () => + expect(await assignedReplaySegments(directory)).not.toHaveLength(4), + ); + expect(internals.acknowledgementUnsynced).toBe(false); + await store.close(); + }); + it("survives a transient failure to read its own owner record", async () => { // Reading the record needs a descriptor; stat() and utimes() do not. So // process-wide descriptor pressure -- from anywhere in the host app -- and From 6d020853e9d72618ef841b9982bc83be9bcfa0d4 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Mon, 24 Aug 2026 11:54:28 +0100 Subject: [PATCH 157/265] fix(qwp): keep a reclaimed journal from deleting the new owner's files assertReady() is the only reader of slotLock.lost, and it guards the six public mutators. Background maintenance and every teardown step ran outside it -- and close() is reached by exactly the terminal path a lost lock triggers, so losing the slot was what set the deletions going rather than what stopped them. failTerminal calls closeStore(), which calls store.close(), which is itself one of the unfenced paths. What that destroyed, executed across real processes with control arms: - close() alone, with no fault injection anywhere, deleted the successor's live .symbol-dict; its restart then failed with "corrupt QWP symbol dictionary: invalid magic". - A trim deleted three of the current owner's segment files, six frames of payload, and sf-manifest.bin; the restart failed with "segment chain has a gap" and the slot was quarantined. - The manifest is written into one of two slots chosen by generation parity, so an ex-owner's generation 7 record physically overwrote the successor's generation 9, and recovery then read "segment lies beyond the manifest active boundary". - Dropping .ack-watermark resurrected an already acknowledged frame for re-send. Control runs where the evicted holder stayed suspended recovered every frame, so the deletions are the cause rather than the symptom. Every mutating maintenance and teardown step now checks ownership first and releases in-memory state only. discardHotSpare still closes its descriptor, which is ours either way, but no longer unlinks a name the successor may have re-created. drainPendingMaintenance drops its queue instead of retrying against somebody else's files, so close() still finishes. Co-Authored-By: Claude Opus 5 (1M context) --- src/qwp-node/file-replay-store.ts | 44 +++++++++++++++++++++-- test/qwp/reconnect.test.ts | 60 +++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 2 deletions(-) diff --git a/src/qwp-node/file-replay-store.ts b/src/qwp-node/file-replay-store.ts index 0835d18..64f13c0 100644 --- a/src/qwp-node/file-replay-store.ts +++ b/src/qwp-node/file-replay-store.ts @@ -1062,7 +1062,12 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { } catch (error) { failure ??= error; } - if (!failure && this.loaded && this.records.size === 0) { + if ( + !failure && + this.loaded && + this.records.size === 0 && + this.ownsDirectory + ) { // Java retires the parent-anchored pair once the slot is permanently // drained. Keep the local slot lock held throughout this best-effort // cleanup so a racing drainer cannot adopt the old directory. @@ -1386,6 +1391,10 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { private async runMaintenanceBatch(): Promise { this.maintenanceScheduled = false; + if (!this.ownsDirectory) { + this.pendingTrimSegments.length = 0; + return; + } let trimmed = 0; while (trimmed < TRIM_BATCH_SIZE && this.pendingTrimSegments.length > 0) { const segment = this.pendingTrimSegments[0]; @@ -1430,6 +1439,12 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { private async drainPendingMaintenance(): Promise { this.maintenanceFailure = undefined; + if (!this.ownsDirectory) { + // Nothing here is ours to trim any more. Drop the queue so close() can + // finish instead of retrying against the new owner's files. + this.pendingTrimSegments.length = 0; + return; + } while (this.pendingTrimSegments.length > 0) { await this.runMaintenanceBatch(); } @@ -1503,6 +1518,9 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { this.hotSpare = undefined; try { await spare.handle.close(); + // The descriptor is ours either way, but the file is not once the slot + // has been reclaimed: the successor may have re-created that name. + if (!this.ownsDirectory) return; await qwpSegmentMaintenanceWorker.unlink(spare.path); this.totalBytes -= spare.size; if (this.durability !== QWP_SF_DURABILITY.MEMORY) { @@ -1804,6 +1822,7 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { `invalid QWP store-and-forward manifest boundaries [headBase=${headBase}, activeBase=${activeBase}]`, ); } + if (!this.ownsDirectory) return; // The manifest below is fsynced unconditionally, so a watermark still // sitting in the page cache would be overtaken by the head that trimming it // justified. Make the watermark durable first: recovery reads the pair. @@ -1835,6 +1854,7 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { } private async removeManifest(): Promise { + if (!this.ownsDirectory) return; await ignoreMissing(unlink(join(this.directory, MANIFEST_FILE))); await syncDirectory(this.directory); this.manifestGeneration = 0n; @@ -1959,6 +1979,7 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { private async removeAcknowledgedThrough(): Promise { if (this.acknowledgedThrough < 0n) return; + if (!this.ownsDirectory) return; await ignoreMissing(unlink(join(this.directory, ACK_FILE))); this.acknowledgedThrough = -1n; this.ackGeneration = 0n; @@ -1981,7 +2002,8 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { if ( !this.loaded || this.records.size !== 0 || - this.dictionaryFileSize === 0 + this.dictionaryFileSize === 0 || + !this.ownsDirectory ) { return; } @@ -2122,6 +2144,24 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { // safe; frame appends retain the bounded liveness floor until then. } + /** + * Whether this store may still mutate its own directory. + * + * {@link assertReady} fences the public mutators by throwing, but background + * maintenance and every teardown step run outside it -- and `close()` is + * reached precisely by the terminal path a lost lock triggers. Once the slot + * has been reclaimed the pathname belongs to another acquisition, so an + * unlink or a manifest rewrite there destroys the live owner's journal + * rather than this store's: its segments, its `sf-manifest.bin` (the + * dual-slot record can even be overwritten by a lower generation of the same + * parity), its `.ack-watermark` -- which resurrects acknowledged frames for + * re-send -- or its `.symbol-dict`. These paths therefore skip the directory + * and release in-memory state only. + */ + private get ownsDirectory(): boolean { + return !this.slotLock?.lost; + } + private assertReady(): void { this.assertOpen(); if (!this.loaded) { diff --git a/test/qwp/reconnect.test.ts b/test/qwp/reconnect.test.ts index 82f9475..f700549 100644 --- a/test/qwp/reconnect.test.ts +++ b/test/qwp/reconnect.test.ts @@ -4422,6 +4422,66 @@ describe("QWP Node file replay store", () => { await store.close(); }); + it("leaves the directory alone once its slot lock was reclaimed", async () => { + // assertReady() fences the public mutators, but background maintenance and + // every teardown step ran outside it -- and close() is reached by exactly + // the terminal path a lost lock triggers, so losing the slot was what set + // the deletions going. They unlinked the successor's segments, its + // sf-manifest.bin and its .symbol-dict, and dropped its .ack-watermark, + // which resurrects acknowledged frames for re-send. + const directory = await trackedDirectory(); + const evicted = new QwpNodeFileReplayStore({ directory }); + await evicted.load(); + await evicted.appendSymbolDictionary(0, ["evicted"]); + await evicted.append({ frameSequence: 0n, payload: Uint8Array.of(1) }); + // Fully drained, so close() takes the teardown paths that delete: the + // watermark, the dictionary, and the parent-anchored orphan pair. + await evicted.acknowledgeThrough(0n); + + // Stand in for a holder paused past the staleness window: the slot is + // reclaimed while this store still has it open. + const longAgo = new Date(Date.now() - 60_000); + await utimes(join(directory, ".lock.owner"), longAgo, longAgo); + const successor = new QwpNodeFileReplayStore({ directory }); + await expect(successor.load()).resolves.toBeDefined(); + const inherited = await successor.loadSymbolDictionary(); + await successor.appendSymbolDictionary(inherited.length, ["successor"]); + await successor.append({ frameSequence: 1n, payload: Uint8Array.of(9) }); + await successor.acknowledgeThrough(1n); + await successor.append({ frameSequence: 2n, payload: Uint8Array.of(10) }); + // A hot spare is provisioned in the background under a .tmp- name, so it + // can appear between the two listings. It is scratch space, not journal + // state, and it is not what this test is about. + const durableEntries = async () => + (await readdir(directory)) + .filter((name) => !name.includes(".tmp-")) + .sort(); + const before = await durableEntries(); + const successorDictionary = await readFile(join(directory, ".symbol-dict")); + + // The evicted store notices on its next mutating call, then shuts down -- + // which is the moment it used to start deleting. Only Date is faked, so + // the heartbeat cannot run: this is the window a paused holder resumes in. + vi.useFakeTimers({ toFake: ["Date"] }); + try { + vi.setSystemTime(Date.now() + 20_000); + await expect( + evicted.append({ frameSequence: 1n, payload: Uint8Array.of(2) }), + ).rejects.toMatchObject({ name: "QwpReplayStoreLockLostError" }); + await evicted.close().catch(() => undefined); + } finally { + vi.useRealTimers(); + } + + expect(await durableEntries()).toEqual(before); + expect(await readFile(join(directory, ".symbol-dict"))).toEqual( + successorDictionary, + ); + // The successor is still healthy, and still owns the lock it took. + await successor.append({ frameSequence: 3n, payload: Uint8Array.of(11) }); + await successor.close(); + }); + it("survives a transient failure to read its own owner record", async () => { // Reading the record needs a descriptor; stat() and utimes() do not. So // process-wide descriptor pressure -- from anywhere in the host app -- and From ff3212387aaf9061009d6c5451af20414696f6b1 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Mon, 24 Aug 2026 11:54:56 +0100 Subject: [PATCH 158/265] fix(qwp): retry a recoverable slot before quarantining it A journal that fails recovery was quarantined on the first attempt, moved aside behind a .failed sentinel and reported as data loss. Yet the failed load's own close() runs drainPendingMaintenance(), which drops a watermark stranded by a torn checkpoint -- so the condition that rejected the journal is usually gone by the time the caller is told the data is unreadable. Executed against the stale-watermark state a power loss leaves: the first load throws "sequence has a gap [previous=1, received=18]", and a second attempt on the same directory resolves with all six frames. Copying the quarantined directory out and loading it also resolves with six frames -- the bytes were intact and replayable the whole time, and only the decision to stop after one try lost them. connectQwpNodeIngress now retries the directory once before giving up on it. Only a second recovery failure quarantines; a transport fault or an aborted connect during the retry says nothing about the journal, so it propagates and leaves the directory alone. This is independent of durability mode: any recovery failure the store repairs on close was being abandoned, not only the one the missing ACK fsync produced. Co-Authored-By: Claude Opus 5 (1M context) --- src/qwp/node.ts | 55 ++++++++++++++++------ test/qwp/node-transport.test.ts | 83 ++++++++++++++++++++++++++++++++- 2 files changed, 122 insertions(+), 16 deletions(-) diff --git a/src/qwp/node.ts b/src/qwp/node.ts index a382b97..517ac49 100644 --- a/src/qwp/node.ts +++ b/src/qwp/node.ts @@ -666,26 +666,51 @@ async function connectQwpNodeIngressInternal( ) { throw error; } - const recoveryError = await quarantineQwpNodeReplayStore( - storeAndForward.directory, - error, - ); - emitReplayRecoveryQuarantine( - storeAndForward, - recoveryError, - effectiveSessionOptions.onSenderError, - ); - replayStore = new QwpNodeFileReplayStore( + // Retry the same directory once before giving up on it. A failed load + // closes its store, and that close drains pending maintenance and drops a + // watermark left stranded by a torn checkpoint -- so the very condition + // that rejected the journal is usually repaired by the time we get here, + // and the frames are intact. Quarantining on the first failure abandons + // recoverable data. + const retryStore = new QwpNodeFileReplayStore( withRecoveryDataLossReporter( storeAndForward, effectiveSessionOptions.onSenderError, ), ); - session = await QwpIngressSession.connect( - connectionFactory, - { ...effectiveSessionOptions, replayStore }, - signal, - ); + try { + replayStore = retryStore; + session = await QwpIngressSession.connect( + connectionFactory, + { ...effectiveSessionOptions, replayStore: retryStore }, + signal, + ); + } catch (retryError) { + // Only a second recovery failure proves the journal is unreadable. + // Anything else -- a transport fault, an aborted connect -- says nothing + // about it, so leave the directory alone and report it as-is. + if (!isQuarantinableReplayRecoveryError(retryError)) throw retryError; + const recoveryError = await quarantineQwpNodeReplayStore( + storeAndForward.directory, + retryError, + ); + emitReplayRecoveryQuarantine( + storeAndForward, + recoveryError, + effectiveSessionOptions.onSenderError, + ); + replayStore = new QwpNodeFileReplayStore( + withRecoveryDataLossReporter( + storeAndForward, + effectiveSessionOptions.onSenderError, + ), + ); + session = await QwpIngressSession.connect( + connectionFactory, + { ...effectiveSessionOptions, replayStore }, + signal, + ); + } } if (orphanDrainer) { session.registerCloseHook(() => orphanDrainer.close()); diff --git a/test/qwp/node-transport.test.ts b/test/qwp/node-transport.test.ts index b8304c4..ef23960 100644 --- a/test/qwp/node-transport.test.ts +++ b/test/qwp/node-transport.test.ts @@ -1,6 +1,6 @@ import type { AddressInfo, Socket } from "node:net"; import { createServer as createTcpServer } from "node:net"; -import { mkdtemp, readdir, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { WebSocketServer } from "ws"; @@ -610,6 +610,87 @@ describe("QWP Node transport", () => { } }); + it("retries a recoverable slot instead of quarantining it on the first failure", async () => { + // A power loss between an ACK and the checkpoint that trims the segment it + // emptied can leave a durable manifest head above the durable watermark, + // which recovery rejects. Quarantining on the first failure abandoned the + // whole journal -- yet the failed load's own close() drops the stranded + // watermark, so a second attempt recovers every frame. The bytes were + // never lost; only the decision to stop after one try lost them. + server = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + server.on("headers", (headers) => { + headers.push("X-QWP-Version: 1"); + }); + const delivered: number[] = []; + server.on("connection", (socket) => { + socket.on("message", (data: Buffer) => { + delivered.push(data.byteLength); + socket.send(okResponse(BigInt(delivered.length - 1), "trades", 1n)); + }); + }); + await listen(server); + + const rootDirectory = await mkdtemp(join(tmpdir(), "qwp-node-retry-")); + const directory = join(rootDirectory, "sender-0"); + const payload = (value: number) => new Uint8Array(2048).fill(value & 0xff); + const seed = new QwpNodeFileReplayStore({ + directory, + maxSegmentBytes: 8192, + }); + await seed.load(); + for (let sequence = 0n; sequence < 10n; sequence++) { + await seed.append({ frameSequence: sequence, payload: payload(0) }); + } + await seed.acknowledgeThrough(1n); + await vi.waitFor(async () => + expect(await readFile(join(directory, ".ack-watermark"))).toBeDefined(), + ); + // The watermark as it stood before the trim below advanced the manifest. + const stranded = await readFile(join(directory, ".ack-watermark")); + for (let sequence = 10n; sequence < 24n; sequence++) { + await seed.append({ frameSequence: sequence, payload: payload(1) }); + } + await seed.acknowledgeThrough(17n); + await vi.waitFor(async () => + expect(await assignedReplaySegments(directory)).toHaveLength(2), + ); + await seed.close(); + // Model the lost page: the manifest and the unlinks reached disk, the + // watermark that justified them did not. + await writeFile(join(directory, ".ack-watermark"), stranded); + + const quarantined: QwpReplayStoreQuarantinedError[] = []; + const senderErrors: QwpSenderError[] = []; + const address = server.address() as AddressInfo; + try { + const session = await connectQwpNodeIngress( + { + url: `ws://127.0.0.1:${address.port}/write/v4`, + storeAndForward: { + directory, + initialConnectMode: "sync", + onRecoveryQuarantine: (event) => quarantined.push(event.error), + }, + }, + { onSenderError: (error) => senderErrors.push(error) }, + ); + await session.close(); + + expect(quarantined).toEqual([]); + expect(senderErrors).toEqual([]); + // The slot keeps its name: nothing was moved aside for an operator. + const siblings = await readdir(rootDirectory); + expect(siblings).toContain("sender-0"); + expect(siblings.filter((name) => name.startsWith("sender-0."))).toEqual( + [], + ); + // And the six frames the journal still held were replayed, not dropped. + expect(delivered.length).toBeGreaterThanOrEqual(6); + } finally { + await rm(rootDirectory, { recursive: true, force: true }); + } + }); + it("repairs a corrupt dictionary sidecar instead of quarantining self-contained frames", async () => { server = new WebSocketServer({ host: "127.0.0.1", port: 0 }); server.on("headers", (headers) => { From 45a380a70bd589ad0672fa1dd0740e032da75ee0 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Mon, 24 Aug 2026 11:55:25 +0100 Subject: [PATCH 159/265] perf(qwp): split UDP datagrams without re-encoding the batch each time encodeUdpDatagrams searched for each datagram's last row between start+1 and table.rowCount, so the first probe of every datagram encoded half the rows still left. sliceRows then made it worse: values holds non-null entries only, so it walked every row before the slice to turn a row index into a value index. Two independent quadratics over the same batch. Measured through Sender.fromConfig("udp::addr=host:9007;auto_flush=off;"), one documented option, then staging rows and calling flush(): rows before after 4000 67ms 29ms 16000 535ms 45ms 32000 2110ms 88ms 64000 10913ms 182ms 128000 - 353ms encodeUdpDatagrams is a plain synchronous loop, so this is a hard event-loop stall: a 1ms heartbeat recorded zero ticks inside a 2291ms encode span while firing 45 outside it. No timers, sockets or health checks run in that window, and throughput fell as the batch grew. The search now gallops its upper bound outward from the last accepted run, and sliceRows skips the null scan for a column that has none -- the common case, and an exact shortcut rather than an approximation. The split is unchanged: the datagram boundaries are identical, and the row-encode count for 4000 rows drops from 395,826 to 31,508, now scaling 2.0x per doubling instead of 3.85x. The default UDP configuration was never affected, because its byte trigger keeps a flush near one datagram. sliceRows is also what the ingress batch-cap bisector walks with, so that path gets the same relief. Co-Authored-By: Claude Opus 5 (1M context) --- src/_qwp/_core/table.ts | 26 +++++++++++++------ src/qwp-node/udp-sender.ts | 20 ++++++++++++++- test/qwp/core.test.ts | 31 +++++++++++++++++++++++ test/qwp/udp-sender.test.ts | 50 +++++++++++++++++++++++++++++++++++++ 4 files changed, 119 insertions(+), 8 deletions(-) diff --git a/src/_qwp/_core/table.ts b/src/_qwp/_core/table.ts index cd8d5cf..622b5ae 100644 --- a/src/_qwp/_core/table.ts +++ b/src/_qwp/_core/table.ts @@ -195,13 +195,25 @@ export class QwpTableBuffer { const result = new QwpTableBuffer(this.name, this.maxNameLength); result.rows = end - start; for (const column of this.columnList) { - let valueStart = 0; - for (let row = 0; row < start; row++) { - if (!column.nulls[row]) valueStart++; - } - let valueEnd = valueStart; - for (let row = start; row < end; row++) { - if (!column.nulls[row]) valueEnd++; + // `values` holds non-null entries only, so a row index becomes a value + // index by skipping the nulls before it. A column with no nulls at all + // needs no scan, and that is the common case -- without this shortcut + // every slice costs O(start) per column, which makes a caller that walks + // a table in ascending slices quadratic in its row count all over again. + let valueStart: number; + let valueEnd: number; + if (column.values.length === column.size) { + valueStart = start; + valueEnd = end; + } else { + valueStart = 0; + for (let row = 0; row < start; row++) { + if (!column.nulls[row]) valueStart++; + } + valueEnd = valueStart; + for (let row = start; row < end; row++) { + if (!column.nulls[row]) valueEnd++; + } } const sliced: QwpColumnBuffer = { name: column.name, diff --git a/src/qwp-node/udp-sender.ts b/src/qwp-node/udp-sender.ts index c0d30be..ddb549a 100644 --- a/src/qwp-node/udp-sender.ts +++ b/src/qwp-node/udp-sender.ts @@ -306,9 +306,26 @@ function encodeUdpDatagrams( const result: Uint8Array[] = []; for (const table of tables) { let start = 0; + // Rows accepted by the previous datagram, used to seed the next search + // window. Datagrams of one table hold a similar number of rows, so the + // previous run is a good guess at the next one. + let window = 0; while (start < table.rowCount) { let low = start + 1; - let high = table.rowCount; + // Gallop the upper bound outward from `start` rather than searching to + // `table.rowCount`. Bounding by the whole batch makes the first probe of + // every datagram encode half the remaining rows, so a flush costs + // O(rows^2 / rowsPerDatagram) row-encodes and a large batch stalls the + // event loop for seconds. Doubling from the last accepted run keeps the + // probes proportional to one datagram and yields the same split. + let high = Math.min(table.rowCount, start + Math.max(2 * window, 2)); + while (high < table.rowCount) { + const probe = encodeQwpIngressFrame([table.sliceRows(start, high)], { + gorilla, + }); + if (probe.byteLength > maxDatagramSize) break; + high = Math.min(table.rowCount, start + (high - start) * 2); + } let acceptedEnd = start; let accepted: Uint8Array | undefined; let smallestRejectedSize = 0; @@ -339,6 +356,7 @@ function encodeUdpDatagrams( ); } result.push(accepted); + window = acceptedEnd - start; start = acceptedEnd; } } diff --git a/test/qwp/core.test.ts b/test/qwp/core.test.ts index 6f94a15..d3e68d4 100644 --- a/test/qwp/core.test.ts +++ b/test/qwp/core.test.ts @@ -283,6 +283,37 @@ describe("QWP ingress codec", () => { expect(() => table.sliceRows(-1, 2)).toThrow(/invalid.*row range/i); }); + it("slices a null-free column without walking the rows before it", () => { + // `values` holds non-null entries only, so a row index becomes a value + // index by counting the nulls before it. Doing that by scanning from row + // zero costs O(start) per column on every slice, which makes any caller + // that walks a table in ascending slices -- the UDP datagram splitter, the + // ingress batch-cap bisector -- quadratic in the row count. A column with + // no nulls needs no scan at all, and that is the common case. + const table = new QwpTableBuffer("events"); + const rows = 5_000; + for (let row = 0; row < rows; row++) { + table.getOrCreateColumn("value", QWP_COLUMN_TYPE.LONG)!.values.push(1n); + table.nextRow(); + } + const column = table.columns[0]; + let indexReads = 0; + column.nulls = new Proxy(column.nulls, { + get(target, key, receiver) { + if (typeof key === "string" && /^\d+$/.test(key)) indexReads++; + return Reflect.get(target, key, receiver); + }, + }); + + const sliced = table.sliceRows(rows - 10, rows); + + expect(sliced.rowCount).toBe(10); + expect(sliced.columns[0].values).toHaveLength(10); + // Scanning would touch every row before the slice; the shortcut touches + // none of them. + expect(indexReads).toBeLessThan(rows / 10); + }); + it("encodes a compacted LONG column with an LSB-first null bitmap", () => { const table = new QwpTableBuffer("t"); table.getOrCreateColumn("a", QWP_COLUMN_TYPE.LONG)!.values.push(1n); diff --git a/test/qwp/udp-sender.test.ts b/test/qwp/udp-sender.test.ts index 076cc0b..91dc6a5 100644 --- a/test/qwp/udp-sender.test.ts +++ b/test/qwp/udp-sender.test.ts @@ -143,6 +143,56 @@ describe("QWP Node UDP sender", () => { expect(socket.closed).toBe(true); }); + it("splits a large batch without re-encoding it once per datagram", async () => { + // The search for each datagram's last row used to run to table.rowCount, + // so the first probe of every datagram encoded half the rows still left. + // That is O(rows^2 / rowsPerDatagram) row-encodes: a 40k-row flush blocked + // the event loop for seconds and the cost quadrupled every time the batch + // doubled. What matters is rows encoded, not probes -- the probe count was + // always logarithmic; each one just encoded half of everything left. Every + // probe slices exactly once, so summing the slice widths measures the work + // exactly, and unlike wall-clock it cannot flake on a loaded machine. + const slicedRows: number[] = []; + for (const rows of [2000, 4000]) { + const socket = new FakeUdpSocket(); + const session = await connectQwpNodeUdp({ + host: "localhost", + port: 9007, + maxDatagramSize: 200, + socketFactory: () => socket, + }); + const table = longTable(rows); + const sliceRows = table.sliceRows.bind(table); + let encoded = 0; + table.sliceRows = (from: number, to: number) => { + encoded += to - from; + return sliceRows(from, to); + }; + + await session.sendTables([table]); + + slicedRows.push(encoded); + // The split still has to hold: many self-contained frames, none over cap. + expect(socket.packets.length).toBeGreaterThan(1); + for (const packet of socket.packets) { + expect(packet.byteLength).toBeLessThanOrEqual(200); + expect(decodeQwpFrame(packet)).toMatchObject({ + flags: 0, + tableCount: 1, + }); + } + await session.close(); + } + + // Doubling the rows must roughly double the work. The quadratic version + // quadrupled it. + const [small, large] = slicedRows; + expect(large).toBeLessThan(small * 3); + // And the work stays a small multiple of the batch, not a multiple of its + // square: the quadratic version sliced hundreds of thousands of rows here. + expect(large).toBeLessThan(4000 * 12); + }); + it("rejects one oversized row before sending any datagram", async () => { const socket = new FakeUdpSocket(); const session = await connectQwpNodeUdp({ From 68b06b4d3843cb8fe1438cb1cd64c1a7096662ef Mon Sep 17 00:00:00 2001 From: glasstiger Date: Mon, 24 Aug 2026 12:06:30 +0100 Subject: [PATCH 160/265] fix(qwp): keep a QWP logger supplied without a top-level one resolveQwpConfig() built the sender options as `{ ...options.qwp?.sender, log: options.log ?? undefined }`, and resolveQwpNodeClientConfig() spreads that object last over its own defaults. So a caller who passed only `qwp.sender.log` had it overwritten by an explicit undefined, and QwpSender fell back to `() => undefined`. The result is total silence from the sender, including the warn that completed rows are being discarded at close and the error reporting how many were lost -- the messages a logger is configured to catch. Only adding a second, top-level `log` produced any output, and that one then won anyway. Sibling fields of the same documented object -- awaitDurableAck, autoFlushRows -- always took effect, and the neighbouring webSocket merge already resolves per field with `??`, so this was a slip rather than a precedence rule. The top-level logger still wins; it just falls back to the QWP one instead of to undefined. Co-Authored-By: Claude Opus 5 (1M context) --- src/options.ts | 9 ++++++++- test/options.test.ts | 23 +++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/options.ts b/src/options.ts index 3795162..74f3726 100644 --- a/src/options.ts +++ b/src/options.ts @@ -42,7 +42,14 @@ function resolveQwpConfig( return resolveQwpNodeClientConfig(configString, { webSocket: { ...webSocketOverrides, agent }, storeAndForward, - sender: { ...options.qwp?.sender, log: options.log ?? undefined }, + // The top-level logger still wins, but falling back to the QWP one rather + // than to undefined matters: resolveQwpNodeClientConfig() spreads this + // object last, so an explicit undefined overwrote a logger the caller had + // configured and left the sender with a no-op sink. + sender: { + ...options.qwp?.sender, + log: options.log ?? options.qwp?.sender?.log, + }, ingressSession: options.qwp?.session, }); } diff --git a/test/options.test.ts b/test/options.test.ts index 4a504ff..708c5d7 100644 --- a/test/options.test.ts +++ b/test/options.test.ts @@ -4,6 +4,7 @@ import { Agent } from "undici"; import { Sender } from "../src/sender"; import { SenderOptions } from "../src"; +import { qwpConfig } from "../src/options"; import { MockHttp } from "./util/mockhttp"; import { readFileSync } from "fs"; @@ -1268,6 +1269,28 @@ describe("Configuration string parser suite", function () { ).rejects.toThrow("Invalid logging function"); }); + it("keeps a QWP logger supplied without a top-level one", async function () { + // resolveQwpConfig() set `log` after spreading qwp.sender, and the QWP + // config resolver spreads that object last, so an explicit undefined beat + // the caller's logger and QwpSender fell back to its no-op sink. Every + // sender-level message was lost, including the warn that completed rows + // are being discarded at close. Sibling fields of the same documented + // object always took effect, which is what made this a slip rather than a + // precedence rule. + const senderLog = () => undefined; + const qwpOnly = await SenderOptions.fromConfig("ws::addr=host:9000;", { + qwp: { sender: { log: senderLog } }, + }); + expect(qwpConfig(qwpOnly)?.sender?.log).toBe(senderLog); + + // The top-level logger still wins when both are given. + const both = await SenderOptions.fromConfig("ws::addr=host:9000;", { + log: console.log, + qwp: { sender: { log: senderLog } }, + }); + expect(qwpConfig(both)?.sender?.log).toBe(console.log); + }); + it("can take a custom agent", async function () { const agent = new Agent({ connect: { keepAlive: true } }); From de38f5ae2360978550d1f6d256ece9cf125729c1 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Mon, 24 Aug 2026 12:12:14 +0100 Subject: [PATCH 161/265] fix(qwp): make the zstd over-run marker impossible to spell by accident MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard that rejects a Zstd frame whose output runs past its declared content size worked by appending a run of one byte and looking for that run at the declared size. A run cannot say where it starts. A frame that overshot by k bytes pushed the marker to contentSize + k and left its own k bytes in front of it, so the check still matched whenever those bytes were the marker byte -- only min(k, 8) of them had to. Overshooting by one therefore needed a single byte, 1 in 256, and 0xa5 is a legal UTF-8 continuation byte (0xc2 0xa5 is "¥"), so a VARCHAR ending in one collided without anyone trying. An RLE final block made it worse: fzstd's fill clamps, so any overshoot at all landed, and a 1,613-byte frame declaring 8 bytes with 52MB of nominal output was accepted. It is not a harmless missed rejection. The output is truncated to the declared size, and that truncation also swallows the "unexpected trailing byte(s)" the same bytes raise when declared honestly -- so the client reports a complete, successful result for a frame it is supposed to reject. The marker is now eight distinct bytes written as a raw block, since an RLE block can only repeat one. No proper prefix of the pattern equals a proper suffix, so no shift can reproduce it and testing the declared offset is now the whole test. Eight bytes of slack past the marker keep a small overshoot inside the buffer so it can still be reported with the size it really decoded; anything further cannot place the marker and fzstd rejects it first. Locating the marker for that message no longer scans back over zeros: fzstd stages a block's literals in the unwritten tail of the output buffer, so the tail is not reliably zero. It searches for the pattern instead, bounded, because a hostile frame chooses how far off its output ends. Co-Authored-By: Claude Opus 5 (1M context) --- src/_qwp/_core/zstd.ts | 86 ++++++++++++++++++++++++++++++++++------- test/qwp/egress.test.ts | 59 ++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+), 15 deletions(-) diff --git a/src/_qwp/_core/zstd.ts b/src/_qwp/_core/zstd.ts index 1cfbd1f..3afa49f 100644 --- a/src/_qwp/_core/zstd.ts +++ b/src/_qwp/_core/zstd.ts @@ -15,7 +15,40 @@ const ZSTD_MAX_BLOCK_SIZE = 128 * 1024; * the frame wrote is the untouched zero tail of the buffer. */ const ZSTD_SIZE_MARKER_BYTES = 8; -const ZSTD_SIZE_MARKER = 0xa5; +/** + * The marker written past a frame's declared content size, as an eight-byte + * raw block rather than a repeated byte. + * + * A run of one byte cannot say where it starts. A frame that ran long by k + * bytes pushes the marker to contentSize + k, leaving its own k bytes in front + * of it -- and a repeated-byte marker still matched at contentSize whenever + * those k bytes happened to be that byte. Only min(k, 8) of them had to, + * so overshooting by one needed a single byte with probability 1/256, and + * 0xa5 is a legal UTF-8 continuation byte, so a VARCHAR ending in one collided + * by accident. + * + * These eight bytes are distinct, so no proper prefix of the pattern equals a + * proper suffix of it and no shift can reproduce it. The last byte is non-zero + * so the scan for a short frame's marker still stops at the marker. + */ +const ZSTD_SIZE_MARKER = Uint8Array.of( + 0xa5, + 0x5a, + 0xc3, + 0x3c, + 0x69, + 0x96, + 0x0f, + 0xf0, +); +/** + * Room past the marker, so a frame that overshoots by up to this much still + * lands its marker inside the buffer and gets a report of the size it really + * decoded rather than a bare decompression failure. + */ +const ZSTD_SIZE_SLACK_BYTES = 8; +/** How far back the marker is looked for when reporting a size mismatch. */ +const ZSTD_SIZE_SEARCH_BYTES = 64 * 1024; interface ZstdFrameInfo { readonly contentSize: number; @@ -185,12 +218,14 @@ function frameWithSizeMarker( // checksum flag is dropped along with the trailing checksum bytes: nothing // verifies them, and the marker has to be the frame's last block. const headerSize = 4 + 1 + 8; - const markerSize = 3 + 1; + const markerSize = 3 + ZSTD_SIZE_MARKER_BYTES; const blocks = frame.subarray(info.dataOffset, layout.blocksEnd); const reframed = new Uint8Array(headerSize + blocks.byteLength + markerSize); reframed.set(frame.subarray(0, 4)); reframed[4] = 0xe0; - let size = BigInt(info.contentSize + ZSTD_SIZE_MARKER_BYTES); + let size = BigInt( + info.contentSize + ZSTD_SIZE_MARKER_BYTES + ZSTD_SIZE_SLACK_BYTES, + ); for (let index = 0; index < 8; index++) { reframed[5 + index] = Number(size & 0xffn); size >>= 8n; @@ -200,11 +235,13 @@ function frameWithSizeMarker( // flag no longer. reframed[headerSize + (layout.lastBlockOffset - info.dataOffset)] &= ~1; const marker = headerSize + blocks.byteLength; - const header = 1 | (1 << 1) | (ZSTD_SIZE_MARKER_BYTES << 3); + // Raw block, not RLE: the marker has to be eight chosen bytes, and an RLE + // block can only repeat one. + const header = 1 | (0 << 1) | (ZSTD_SIZE_MARKER_BYTES << 3); reframed[marker] = header & 0xff; reframed[marker + 1] = (header >>> 8) & 0xff; reframed[marker + 2] = (header >>> 16) & 0xff; - reframed[marker + 3] = ZSTD_SIZE_MARKER; + reframed.set(ZSTD_SIZE_MARKER, marker + 3); return reframed; } @@ -213,24 +250,43 @@ function hasSizeMarkerAt(output: Uint8Array, offset: number): boolean { return false; } for (let index = 0; index < ZSTD_SIZE_MARKER_BYTES; index++) { - if (output[offset + index] !== ZSTD_SIZE_MARKER) return false; + if (output[offset + index] !== ZSTD_SIZE_MARKER[index]) return false; } return true; } +/** + * Where the marker landed, searched downwards from `from`, or -1. + * + * Only a diagnostic: whether the frame is well formed at all was already + * settled by testing the declared offset. fzstd stages a block's literals in + * the unwritten tail of the output buffer, so that tail is not reliably zero + * and the marker cannot be found by scanning back over zeros. The search is + * bounded because a hostile frame chooses how far off its output ends. + */ +function findSizeMarker(output: Uint8Array, from: number): number { + const start = Math.min(from, output.byteLength - ZSTD_SIZE_MARKER_BYTES); + const floor = Math.max(0, start - ZSTD_SIZE_SEARCH_BYTES); + for (let offset = start; offset >= floor; offset--) { + if (hasSizeMarkerAt(output, offset)) return offset; + } + return -1; +} + /** Rejects a frame whose output did not end where its header said it would. */ function requireDeclaredSize(output: Uint8Array, contentSize: number): void { + // The marker lands exactly where the frame's own output ended, and no shift + // of it can spell itself, so this is the whole test: it holds for a frame + // that means what its header says and for no other. A frame that overshot by + // more than the slack could not land its marker inside the buffer at all, + // and fzstd has already rejected it by the time we get here. if (hasSizeMarkerAt(output, contentSize)) return; - // The marker is the last thing a short frame writes and the tail beyond it - // was never touched, so its offset -- eight bytes before the last non-zero - // byte -- is that frame's real output size. A frame that ran long instead - // pushed the marker past the buffer or overwrote it with its own bytes. - let end = output.byteLength; - while (end > 0 && output[end - 1] === 0) end--; - const decoded = end - ZSTD_SIZE_MARKER_BYTES; - if (decoded < contentSize && hasSizeMarkerAt(output, decoded)) { + const decoded = findSizeMarker(output, contentSize + ZSTD_SIZE_SLACK_BYTES); + if (decoded >= 0 && decoded !== contentSize) { throw new QwpProtocolError( - `zstd decompressed size ${decoded} does not match frame content size ${contentSize}`, + decoded < contentSize + ? `zstd decompressed size ${decoded} does not match frame content size ${contentSize}` + : `zstd output exceeds declared content size ${contentSize} by ${decoded - contentSize}`, ); } throw new QwpProtocolError( diff --git a/test/qwp/egress.test.ts b/test/qwp/egress.test.ts index 930d711..fec95eb 100644 --- a/test/qwp/egress.test.ts +++ b/test/qwp/egress.test.ts @@ -32,6 +32,7 @@ import { readQwpVarint, writeQwpVarint, } from "../../src/qwp"; +import { decompressQwpZstdFrame } from "../../src/_qwp/_core/zstd"; import { QwpAsyncQueue } from "../../src/_qwp/_internal/async-queue"; const RESULT_FLAGS = QWP_FLAG_DELTA_SYMBOL_DICTIONARY | QWP_FLAG_GORILLA; @@ -777,6 +778,64 @@ describe("QWP result batch decoder", () => { reservedBlock[7] = (reservedBlock[7] & ~0x06) | 0x06; expect(() => decodeBody(reservedBlock)).toThrow(/reserved block type/i); }); + + it("rejects an over-long Zstd frame whatever its output ends with", () => { + // The over-run guard used to look for a run of one byte at the declared + // size. A frame that ran long pushed that marker further out and left its + // own bytes in front of it, so the run still matched whenever those bytes + // happened to be the marker byte -- only min(overshoot, 8) of them had to, + // making a one-byte overshoot a 1-in-256 bypass, and 0xa5 is a legal UTF-8 + // continuation byte, so a VARCHAR ending in one collided by accident. The + // test above only passed because its fixture happens to decode to a 0x00. + // + // The bypass is not neutral: truncating the output to the declared size + // also hides the "unexpected trailing byte(s)" the same bytes would raise + // if they were declared honestly, so the client reports a complete, + // successful result for a frame it is supposed to reject. + const singleSegmentFrame = ( + declared: number, + rleByte: number, + emit: number, + ) => + Uint8Array.from([ + 0x28, + 0xb5, + 0x2f, + 0xfd, // magic + 0xe0, // single segment, 8-byte content size, no checksum + declared, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + // One RLE block, flagged last, emitting `emit` copies of `rleByte`. + 1 | (1 << 1) | (emit << 3), + ((1 | (1 << 1) | (emit << 3)) >>> 8) & 0xff, + ((1 | (1 << 1) | (emit << 3)) >>> 16) & 0xff, + rleByte, + ]); + + // Exactly the shape that used to be accepted: declares 8, emits 16, and + // the eight bytes past the declared size are the old marker byte. + expect(() => + decompressQwpZstdFrame(singleSegmentFrame(8, 0xa5, 16)), + ).toThrow(/exceeds declared content size/i); + // Overshooting by one needed a single lucky byte. + expect(() => + decompressQwpZstdFrame(singleSegmentFrame(8, 0xa5, 9)), + ).toThrow(/exceeds declared content size/i); + // Any other filler was always caught, and still is. + expect(() => + decompressQwpZstdFrame(singleSegmentFrame(8, 0x5a, 16)), + ).toThrow(/exceeds declared content size/i); + // A frame that means what it says still round-trips. + expect(decompressQwpZstdFrame(singleSegmentFrame(8, 0x42, 8))).toEqual( + new Uint8Array(8).fill(0x42), + ); + }); }); describe("QwpEgressSession", () => { From 266438f0dec76edd7955de1e40b58ef1c6810824 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Mon, 24 Aug 2026 12:15:52 +0100 Subject: [PATCH 162/265] fix(qwp): validate a column call even when its value is nullish Every fluent setter returned on a nullish value before addColumn had looked at anything, so the sender's availability, the row state and the column name were never checked for a row that omitted the column. All 26 setters accepted an illegal name, a non-string name, a call made before table(), and a call on a closed sender, as long as the value happened to be null. The consequence is that a call site is accepted or rejected by which rows happen to carry a value. A typo'd column name -- "user-id", say -- is silently fine on every row where the field is absent, then throws on the first row that has one, and failRow() discards that whole row. Rows already staged reach the server missing a column the caller believed they were writing, and the field that is usually null is exactly the field a caller reaches for the nullish rule with, so the typo survives testing. The ILP senders had this bug and fixed it in validateColumnCall(), with a regression test naming this failure mode. README.md documents the nullish rule as shared by the ILP and QWP senders, so they have to agree; only the compiled writers were already safe, because they validate every schema key at compile time whatever the values. The constants that describe a column rather than a row's value -- a decimal's scale, a geohash's precision -- are now checked before the value too, matching SenderBufferV3. Co-Authored-By: Claude Opus 5 (1M context) --- src/_qwp/sender.ts | 94 ++++++++++++++++++++++++++++++----------- test/qwp/sender.test.ts | 57 +++++++++++++++++++++++++ 2 files changed, 127 insertions(+), 24 deletions(-) diff --git a/src/_qwp/sender.ts b/src/_qwp/sender.ts index 18ee1de..4596aa9 100644 --- a/src/_qwp/sender.ts +++ b/src/_qwp/sender.ts @@ -1088,8 +1088,39 @@ export class QwpSender { return this; } + /** + * Whether a nullish value omits this column -- and, when it does, that the + * call was still a valid one. + * + * Omitting a column must not take the rest of the call's validation with it. + * The sender's availability, the row state and the column name describe the + * call site, not this row's value, so a call site that is wrong is wrong on + * every row. Returning early on nullish meant a misspelled or over-long name + * raised only on the rows that happened to carry a value, and stayed silent + * on the rest -- which is how a typo reaches production. The ILP senders had + * the same bug and fix it in validateColumnCall(); README.md documents the + * nullish rule as shared by both, so these must agree. + */ + private omitsNullish( + name: string, + value: unknown, + ): value is null | undefined { + if (value !== null && value !== undefined) return false; + try { + this.throwIfUnavailable(); + this.requireTable(); + if (typeof name !== "string") { + throw new TypeError("column name must be a string"); + } + validateQwpColumnName(name, this.maxNameLength); + } catch (error) { + this.failRow(error); + } + return true; + } + symbol(name: string, value: unknown): QwpSender { - if (value === null || value === undefined) return this; + if (this.omitsNullish(name, value)) return this; // String() runs inside the guard, not in addColumn's argument list: the // value is `unknown`, so its conversion can throw (a null-prototype // object, a throwing or non-callable toString, a throwing Proxy trap). @@ -1104,7 +1135,7 @@ export class QwpSender { } stringColumn(name: string, value: string | null | undefined): QwpSender { - if (value === null || value === undefined) return this; + if (this.omitsNullish(name, value)) return this; if (typeof value !== "string") { return this.failRow(new TypeError("stringColumn accepts only strings")); } @@ -1112,7 +1143,7 @@ export class QwpSender { } booleanColumn(name: string, value: boolean | null | undefined): QwpSender { - if (value === null || value === undefined) return this; + if (this.omitsNullish(name, value)) return this; if (typeof value !== "boolean") { return this.failRow(new TypeError("booleanColumn accepts only booleans")); } @@ -1120,7 +1151,7 @@ export class QwpSender { } floatColumn(name: string, value: number | null | undefined): QwpSender { - if (value === null || value === undefined) return this; + if (this.omitsNullish(name, value)) return this; if (typeof value !== "number") { return this.failRow(new TypeError("floatColumn accepts only numbers")); } @@ -1132,7 +1163,7 @@ export class QwpSender { } float32Column(name: string, value: number | null | undefined): QwpSender { - if (value === null || value === undefined) return this; + if (this.omitsNullish(name, value)) return this; if (typeof value !== "number") { return this.failRow(new TypeError("float32Column accepts only numbers")); } @@ -1140,7 +1171,7 @@ export class QwpSender { } byteColumn(name: string, value: number | null | undefined): QwpSender { - if (value === null || value === undefined) return this; + if (this.omitsNullish(name, value)) return this; try { return this.addColumn( name, @@ -1153,7 +1184,7 @@ export class QwpSender { } shortColumn(name: string, value: number | null | undefined): QwpSender { - if (value === null || value === undefined) return this; + if (this.omitsNullish(name, value)) return this; try { return this.addColumn( name, @@ -1166,7 +1197,7 @@ export class QwpSender { } int32Column(name: string, value: number | null | undefined): QwpSender { - if (value === null || value === undefined) return this; + if (this.omitsNullish(name, value)) return this; try { return this.addColumn( name, @@ -1179,7 +1210,7 @@ export class QwpSender { } intColumn(name: string, value: number | null | undefined): QwpSender { - if (value === null || value === undefined) return this; + if (this.omitsNullish(name, value)) return this; try { return this.addColumn( name, @@ -1195,7 +1226,7 @@ export class QwpSender { name: string, value: number | bigint | null | undefined, ): QwpSender { - if (value === null || value === undefined) return this; + if (this.omitsNullish(name, value)) return this; try { return this.addColumn( name, @@ -1208,7 +1239,7 @@ export class QwpSender { } arrayColumn(name: string, value: unknown[] | null | undefined): QwpSender { - if (value === null || value === undefined) return this; + if (this.omitsNullish(name, value)) return this; try { const array = flattenQwpArray(value); if (array.values.some((item) => typeof item !== "number")) { @@ -1224,7 +1255,7 @@ export class QwpSender { name: string, value: unknown[] | null | undefined, ): QwpSender { - if (value === null || value === undefined) return this; + if (this.omitsNullish(name, value)) return this; try { const array = flattenQwpArray(value); array.values = array.values.map((item) => @@ -1241,7 +1272,7 @@ export class QwpSender { value: number | bigint | null | undefined, unit: QwpTimestampUnit = "us", ): QwpSender { - if (value === null || value === undefined) return this; + if (this.omitsNullish(name, value)) return this; try { const timestamp = timestampValue(value, unit); return this.addColumn(name, timestamp.type, timestamp.value); @@ -1272,7 +1303,7 @@ export class QwpSender { } binaryColumn(name: string, value: Uint8Array | null | undefined): QwpSender { - if (value === null || value === undefined) return this; + if (this.omitsNullish(name, value)) return this; if (!(value instanceof Uint8Array)) { return this.failRow( new TypeError("binaryColumn accepts only Uint8Array values"), @@ -1282,7 +1313,7 @@ export class QwpSender { } charColumn(name: string, value: string | null | undefined): QwpSender { - if (value === null || value === undefined) return this; + if (this.omitsNullish(name, value)) return this; if (typeof value !== "string" || value.length !== 1) { return this.failRow( new TypeError("charColumn accepts one UTF-16 code unit"), @@ -1295,7 +1326,7 @@ export class QwpSender { name: string, value: string | Uint8Array | null | undefined, ): QwpSender { - if (value === null || value === undefined) return this; + if (this.omitsNullish(name, value)) return this; try { return this.addColumn(name, QWP_COLUMN_TYPE.UUID, uuidBytes(value)); } catch (error) { @@ -1318,7 +1349,11 @@ export class QwpSender { // four are absent -- that omits the column, like every other setter. A // partial set is a caller mistake rather than a NULL, and saying so beats // letting BigInt.asIntN() raise "Cannot convert null to a BigInt". - if (absent === given.length) return this; + if (absent === given.length) { + // Still a column call, so it is still checked like one. + this.omitsNullish(name, null); + return this; + } if (absent > 0) { return this.failRow( new TypeError( @@ -1351,7 +1386,7 @@ export class QwpSender { name: string, value: string | number | null | undefined, ): QwpSender { - if (value === null || value === undefined) return this; + if (this.omitsNullish(name, value)) return this; try { return this.addColumn(name, QWP_COLUMN_TYPE.IPV4, parseIpv4(value)); } catch (error) { @@ -1363,7 +1398,7 @@ export class QwpSender { name: string, value: string | number | null | undefined, ): QwpSender { - if (value === null || value === undefined) return this; + if (this.omitsNullish(name, value)) return this; try { const decimal = parseDecimal(value); if (decimal.scale > 76 || !fitsSigned(decimal.unscaled, 256)) { @@ -1387,11 +1422,15 @@ export class QwpSender { unscaled: Int8Array | bigint | null | undefined, scale: number, ): QwpSender { - if (unscaled === null || unscaled === undefined) return this; + // The scale describes the column, not this row's value, so a bad constant + // is reported whether or not this row happens to carry a decimal. + if (!Number.isSafeInteger(scale) || scale < 0 || scale > 76) { + return this.failRow( + new RangeError("decimal scale must be between 0 and 76"), + ); + } + if (this.omitsNullish(name, unscaled)) return this; try { - if (!Number.isSafeInteger(scale) || scale < 0 || scale > 76) { - throw new RangeError("decimal scale must be between 0 and 76"); - } if (typeof unscaled !== "bigint" && !(unscaled instanceof Int8Array)) { // signedBigEndianToBigInt() iterates its argument, and a string is // iterable: "12345" would coerce character by character into @@ -1475,7 +1514,14 @@ export class QwpSender { value: bigint | null | undefined, precision: number, ): QwpSender { - if (value === null || value === undefined) return this; + // The precision describes the column, not this row's value, so a bad + // constant is reported whether or not this row happens to carry a geohash. + if (!Number.isSafeInteger(precision) || precision < 1 || precision > 60) { + return this.failRow( + new RangeError("geohash precision must be between 1 and 60"), + ); + } + if (this.omitsNullish(name, value)) return this; if (typeof value !== "bigint") { // The range check below compares against BigInts, and neither branch of // it rejects a wrong-typed value: a non-numeric string makes both diff --git a/test/qwp/sender.test.ts b/test/qwp/sender.test.ts index b239f7b..e08939a 100644 --- a/test/qwp/sender.test.ts +++ b/test/qwp/sender.test.ts @@ -220,6 +220,63 @@ function column(table: QwpTableBuffer, name: string) { } describe("QWP high-level sender", () => { + it("validates a column call even when its value is nullish", async () => { + // Omitting the column must not take the rest of the call's validation with + // it. A nullish value used to return before the sender state, the row + // state and the name were ever looked at, so the same call site raised on + // rows that carried a value and stayed silent on rows that did not -- a + // misspelled or over-long name first surfaced in production, on the row + // that happened to be populated. The ILP senders fix this in + // validateColumnCall(), and README.md documents the nullish rule as shared + // by both, so the two must agree. + const build = () => + new QwpSender(async () => new PublishingSession(), { + autoFlush: false, + maxNameLength: 16, + }); + + for (const value of [null, undefined] as const) { + // No table yet. + expect(() => build().stringColumn("c", value)).toThrow( + /table name must be set/i, + ); + const table = () => build().table("t"); + expect(() => table().stringColumn("a".repeat(20), value)).toThrow( + /too long/i, + ); + expect(() => table().longColumn("bad.name", value)).toThrow( + /illegal characters/i, + ); + expect(() => table().symbol("bad-name", value)).toThrow( + /illegal characters/i, + ); + expect(() => + table().booleanColumn(123 as unknown as string, value), + ).toThrow(/must be a string/i); + // A constant that describes the column, not this row's value. + expect(() => table().decimalColumn("d", value, 999)).toThrow( + /decimal scale/i, + ); + expect(() => table().geohashColumn("g", value, 0)).toThrow( + /geohash precision/i, + ); + // All four words absent is the LONG256 way of spelling a NULL. + expect(() => + table().long256Column("bad.name", value, value, value, value), + ).toThrow(/illegal characters/i); + } + + // A valid nullish call is still simply omitted. + const sender = build(); + await sender + .table("t") + .stringColumn("skipped", null) + .longColumn("kept", 1n) + .atNow(); + expect(sender.metrics.pendingRows).toBe(1); + await sender.close(); + }); + it("uses the Java-compatible local-publication flush boundary by default", async () => { const session = new PublishingSession(); const sender = new QwpSender(async () => session, { autoFlush: false }); From 0843b0ece5d52dcd2f5e999569baab2b8ebd0db3 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Mon, 24 Aug 2026 12:21:53 +0100 Subject: [PATCH 163/265] fix(qwp): apply target and zone to ingress, not only egress Both keys were parsed from the ws/wss cluster string, validated, and then applied to the egress connection factory alone. On the ingress side target degenerated to "accept any role" and the health tracker ran zone-blind, so every endpoint ranked as same-zone and configuration order alone decided where writes landed -- including on a replica that answered the upgrade. Through Sender.fromConfig it was total, because that path uses only options.ingress: a bogus target still threw "Invalid target", while a valid one did nothing whatsoever. QWP.md lists both under "Reconnect and failover", and annotates the neighbouring failover key as egress-only but not these two, and the ingress section promises endpoints are "ranked by observed health ... and then by zone affinity". Both now reach the ingress factory and its health tracker, and QWP.md says plainly that they cover both directions. Role matching also had to become fail-open for an endpoint that declares no role. Egress always learns one from SERVER_INFO, but ingress reads it from an upgrade response header that an older server may not send and a proxy may strip, and the egress rule applied unchanged rejected such an endpoint outright -- an existing failover test using target=replica against a header-less mock server went from connecting to exhausting its reconnect budget. A server that does know its role still rejects a misdirected write itself, with the 421 this client classifies as ROLE_REJECTED, so nothing is lost by trusting it. Co-Authored-By: Claude Opus 5 (1M context) --- QWP.md | 6 +-- src/_qwp/_internal/failover.ts | 8 ++++ src/_qwp/transport.ts | 5 ++ src/qwp-node/client-config.ts | 20 +++++--- src/qwp/node.ts | 20 ++++++-- test/qwp/node-client-config.test.ts | 21 +++++++++ test/qwp/node-transport.test.ts | 71 +++++++++++++++++++++++++++++ 7 files changed, 139 insertions(+), 12 deletions(-) diff --git a/QWP.md b/QWP.md index 3bd6b62..2aaf1a7 100644 --- a/QWP.md +++ b/QWP.md @@ -133,8 +133,8 @@ connect string is the portable spelling. | `failover_backoff_initial_ms` | integer ms | — | First failover delay. | | `failover_backoff_max_ms` | integer ms | — | Ceiling for one failover delay. | | `failover_max_duration_ms` | integer ms | — | Budget for a failover episode. | -| `target` | `any`, `primary`, `replica` | — | Server role this client will accept. | -| `zone` | string | — | Preferred topology zone when ranking endpoints. | +| `target` | `any`, `primary`, `replica` | — | Server role this client will accept, on both ingress and egress. | +| `zone` | string | — | Preferred topology zone when ranking endpoints, on both ingress and egress. | ### Store-and-forward (Node only) @@ -1041,7 +1041,7 @@ credit window bounds server read-ahead while application work is in progress. `target` accepts `any` (the default), `primary`, or `replica`. Primary routing also accepts standalone servers and a primary completing catch-up, matching the Java -client. `zone` is an opaque, case-insensitive preference for `any` and `replica`; +client. Both keys apply to ingress and egress alike. `zone` is an opaque, case-insensitive preference for `any` and `replica`; cross-zone endpoints remain eligible. It is ignored for `primary`, which must be followed across zones. The client validates the authoritative role and zone from the first QWP `SERVER_INFO` frame before accepting an endpoint, so the same guarantees diff --git a/src/_qwp/_internal/failover.ts b/src/_qwp/_internal/failover.ts index 23d251d..c6d0ee9 100644 --- a/src/_qwp/_internal/failover.ts +++ b/src/_qwp/_internal/failover.ts @@ -353,6 +353,14 @@ function normalizeRole(role: string | undefined): string | undefined { function matchesTarget(role: string | undefined, target: QwpTarget): boolean { if (target === QWP_TARGET.ANY) return true; const normalized = normalizeRole(role); + // An endpoint that declares no role is accepted whatever the target. Egress + // always learns one from SERVER_INFO, but ingress reads it from an upgrade + // response header that an older server may not send and a proxy may strip, + // and refusing to write to a node purely because it stayed silent would take + // a working deployment offline. A server that does know its role still + // rejects a misdirected write itself, with the 421 this client classifies as + // ROLE_REJECTED. + if (normalized === undefined) return true; if (target === QWP_TARGET.REPLICA) return normalized === "REPLICA"; return ( normalized === "PRIMARY" || diff --git a/src/_qwp/transport.ts b/src/_qwp/transport.ts index 7caee01..7907b9b 100644 --- a/src/_qwp/transport.ts +++ b/src/_qwp/transport.ts @@ -378,6 +378,11 @@ export const QWP_TARGET = { export type QwpTarget = (typeof QWP_TARGET)[keyof typeof QWP_TARGET]; /** Browser-safe endpoint-routing controls used by QWP egress clients. */ +/** + * Endpoint routing preferences. Named for egress, where they landed first, but + * ingress ranks and validates its endpoints with the same machinery and honours + * the same two keys. + */ export interface QwpEgressRoutingOptions { /** Selects any readable node, a primary/standalone node, or a replica. */ target?: QwpTarget; diff --git a/src/qwp-node/client-config.ts b/src/qwp-node/client-config.ts index 1680c7b..3f0667e 100644 --- a/src/qwp-node/client-config.ts +++ b/src/qwp-node/client-config.ts @@ -291,12 +291,24 @@ export function resolveQwpNodeClientConfig( }; validatePool(pool); + const target = optionalEnum(value("target"), "target", [ + "any", + "primary", + "replica", + ] as const) as QwpTarget | undefined; + const zone = value("zone"); const ingress: QwpNodeIngressOptions = { ...common, url: withPath(endpoints[0], "/write/v4"), failoverUrls: endpoints .slice(1) .map((endpoint) => withPath(endpoint, "/write/v4")), + // `target` and `zone` are one cluster-routing pair, and QWP.md documents + // them under "Reconnect and failover" and promises the ingress endpoint + // ranking uses zone affinity. Reaching only the egress factory left both + // silently inert for writes. + target, + zone, requestDurableAck: extraOptions.webSocket?.requestDurableAck ?? optionalBoolean(value("request_durable_ack"), "request_durable_ack"), @@ -309,12 +321,8 @@ export function resolveQwpNodeClientConfig( failoverUrls: endpoints .slice(1) .map((endpoint) => withPath(endpoint, "/read/v1")), - target: optionalEnum(value("target"), "target", [ - "any", - "primary", - "replica", - ] as const) as QwpTarget | undefined, - zone: value("zone"), + target, + zone, compression: optionalEnum(value("compression"), "compression", [ "raw", "zstd", diff --git a/src/qwp/node.ts b/src/qwp/node.ts index 517ac49..8c26fcb 100644 --- a/src/qwp/node.ts +++ b/src/qwp/node.ts @@ -243,7 +243,9 @@ export interface QwpNodeWebSocketOptions extends QwpWebSocketConnectOptions { ) => QwpWebSocketLike; } -export interface QwpNodeIngressOptions extends QwpNodeWebSocketOptions { +export interface QwpNodeIngressOptions + extends QwpNodeWebSocketOptions, + QwpEgressRoutingOptions { /** * Upgrades the default in-memory ingress replay to persistent Node * store-and-forward. Use a directory owned exclusively by this session. @@ -412,11 +414,20 @@ function createQwpNodeConnectionFactoryInternal( healthTracker?: QwpFailoverHealthTracker, resetClassificationsAfterExhaustion = true, ): QwpConnectionFactory { + const routing = options as QwpEgressRoutingOptions; return createQwpFailoverConnectionFactory( options.url, options.failoverUrls, (endpoint, signal) => connectQwpNodeEndpoint(options, endpoint, signal), - { healthTracker, resetClassificationsAfterExhaustion }, + { + // Ingress used to drop these, so `target` degenerated to "accept any + // role" and every endpoint ranked as same-zone however the caller had + // configured the cluster. + target: routing.target, + zone: routing.zone, + healthTracker, + resetClassificationsAfterExhaustion, + }, ); } @@ -589,7 +600,10 @@ async function connectQwpNodeIngressInternal( ): Promise { const healthTracker = sharedHealthTracker ?? - createQwpFailoverHealthTracker(options.url, options.failoverUrls); + createQwpFailoverHealthTracker(options.url, options.failoverUrls, { + target: options.target, + zone: options.zone, + }); const storeAndForward = resolveNodeStoreAndForwardOptions(options); if (storeAndForward && sessionOptions.replayStore) { throw new RangeError( diff --git a/test/qwp/node-client-config.test.ts b/test/qwp/node-client-config.test.ts index fef9a78..930eab6 100644 --- a/test/qwp/node-client-config.test.ts +++ b/test/qwp/node-client-config.test.ts @@ -390,6 +390,27 @@ describe("QWP unified Node client configuration", () => { } }); + it("routes ingress by target and zone, not only egress", () => { + // Both keys were parsed, validated and then applied to the egress factory + // alone. On the ingress side target degenerated to "accept any role" and + // the health tracker ran zone-blind, so every endpoint ranked as same-zone + // and configuration order alone decided where writes went. Through + // Sender.fromConfig it was total: that path uses only options.ingress, so + // a bogus target still threw while a valid one did nothing at all. + const options = parseQwpNodeClientConfig( + "ws::addr=db-a.example:9000,db-b.example:9000;target=primary;zone=eu-west-1a;", + ); + + expect(options.ingress).toMatchObject({ + target: "primary", + zone: "eu-west-1a", + }); + expect(options.egress).toMatchObject({ + target: "primary", + zone: "eu-west-1a", + }); + }); + it("validates cluster authorities and supports bracketed IPv6", () => { const options = parseQwpNodeClientConfig( "ws::addr=[::1],[2001:db8::2]:9443;sender_pool_min=0;query_pool_min=0;", diff --git a/test/qwp/node-transport.test.ts b/test/qwp/node-transport.test.ts index ef23960..ec3c031 100644 --- a/test/qwp/node-transport.test.ts +++ b/test/qwp/node-transport.test.ts @@ -610,6 +610,77 @@ describe("QWP Node transport", () => { } }); + it("skips an ingress endpoint whose role the target excludes", async () => { + // target and zone reached the egress connection factory only, so ingress + // matched every role and ranked every endpoint as same-zone: writes landed + // on whichever endpoint came first in the configuration, replica included. + const roleServer = async (role: string) => { + const instance = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + instance.on("headers", (headers) => { + headers.push("X-QWP-Version: 1"); + headers.push(`X-QuestDB-Role: ${role}`); + }); + instance.on("connection", (socket) => { + socket.on("message", () => socket.send(okResponse(0n, "trades", 1n))); + }); + await listen(instance); + return instance; + }; + const replica = await roleServer("REPLICA"); + server = await roleServer("PRIMARY"); + const replicaPort = (replica.address() as AddressInfo).port; + const primaryPort = (server.address() as AddressInfo).port; + + try { + // The replica is preferred by configuration order, so only the role + // check can move the write off it. + const session = await connectQwpNodeIngress({ + url: `ws://127.0.0.1:${replicaPort}/write/v4`, + failoverUrls: [`ws://127.0.0.1:${primaryPort}/write/v4`], + target: "primary", + }); + try { + expect(session.handshake.serverRole?.toUpperCase()).toBe("PRIMARY"); + await expect( + session.sendFrame(Uint8Array.of(1)), + ).resolves.toMatchObject({ sequence: 0n }); + } finally { + await session.close(); + } + } finally { + await new Promise((resolve) => replica.close(() => resolve())); + } + }); + + it("accepts an ingress endpoint that declares no role at all", async () => { + // Ingress reads the role from an upgrade response header, which an older + // server may not send and a proxy may strip. Egress always learns one from + // SERVER_INFO, so applying the egress rule unchanged would refuse to write + // to a node purely for staying silent. A server that does know its role + // still rejects a misdirected write itself, with a 421. + server = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + server.on("headers", (headers) => { + headers.push("X-QWP-Version: 1"); + }); + server.on("connection", (socket) => { + socket.on("message", () => socket.send(okResponse(0n, "trades", 1n))); + }); + await listen(server); + + const address = server.address() as AddressInfo; + const session = await connectQwpNodeIngress({ + url: `ws://127.0.0.1:${address.port}/write/v4`, + target: "primary", + }); + try { + await expect(session.sendFrame(Uint8Array.of(1))).resolves.toMatchObject({ + sequence: 0n, + }); + } finally { + await session.close(); + } + }); + it("retries a recoverable slot instead of quarantining it on the first failure", async () => { // A power loss between an ACK and the checkpoint that trims the segment it // emptied can leave a durable manifest head above the durable watermark, From 26a190da7056b34844c13543709ba0299bbd09c2 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Mon, 24 Aug 2026 12:28:05 +0100 Subject: [PATCH 164/265] fix(qwp): let close() abort a connect that holds a store-and-forward lock QwpIngressSession.connect() receives an AbortSignal and passed it to exactly two places: the eager initial connection, and the non-reconnecting branch. The eager connection is skipped whenever a replay store or background store-and-forward is configured -- precisely the configurations that take a slot lock -- and QwpReconnectingIngressConnection.connect() had no signal parameter at all. So close() aborted nothing on the one path where a connect owns a lock. The result: close() resolved, with no error and no warning, while the abandoned connect went on holding the slot for the rest of its connect budget. Against a peer that accepts TCP and never answers the upgrade -- a stalled proxy or load balancer -- that is the full connect_timeout, and 30s with a reconnect budget; a second sender on the same directory failed with QwpReplayStoreLockedError naming its own process. The abandoned session also kept doing real work after shutdown, re-sending a journaled frame and, in one shape, renaming a slot to .unreplayable-N. QWP.md says the journal "takes an exclusive lock when it is loaded and holds it until the sender or session closes", and the signal's own documentation says a connect still negotiating "can be torn down instead of outliving the sender by up to its connect/auth deadline". Both described the intent, not the behaviour; connectAbort itself is new in this branch, so this is incompletely-wired new work rather than legacy. The signal now reaches that connect. It is checked before the store is loaded, again once the lock is held, and an abort during the connect closes the connection -- which closes the store and releases the lock. A lock can still outlive close() by an in-flight load, which is bounded by the load rather than by the connect budget. Co-Authored-By: Claude Opus 5 (1M context) --- .../reconnecting-ingress-connection.ts | 92 +++++++++++++------ src/_qwp/ingress-session.ts | 5 + test/qwp/sender-node-integration.test.ts | 73 ++++++++++++++- 3 files changed, 140 insertions(+), 30 deletions(-) diff --git a/src/_qwp/_internal/reconnecting-ingress-connection.ts b/src/_qwp/_internal/reconnecting-ingress-connection.ts index b6064f6..73d03e8 100644 --- a/src/_qwp/_internal/reconnecting-ingress-connection.ts +++ b/src/_qwp/_internal/reconnecting-ingress-connection.ts @@ -500,6 +500,7 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { connectionListenerInboxCapacity = 64, errorInboxCapacity = 256, onSenderError?: (error: QwpSenderError) => void, + signal?: AbortSignal, ): Promise { const store: QwpIngressReplayStore = replayStore ?? @@ -508,6 +509,14 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { memoryReplayAppendDeadlineMs, ); let connection: QwpReconnectingIngressConnection | undefined; + // close() aborts this while a connect is still negotiating. Without it the + // caller returns from close() and this keeps going: a persistent store + // takes its slot lock after the sender is gone and holds it for the rest + // of the connect budget, and the abandoned session goes on to send frames + // and even quarantine directories. + const abortError = () => + signal?.reason ?? new Error("QWP connect was aborted"); + if (signal?.aborted) throw abortError(); try { const lazyStore = isLazyReplayStore(store) ? store : undefined; const records: readonly LoadedReplayRecord[] = lazyStore @@ -565,38 +574,32 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { errorInboxCapacity, onSenderError, ); - await connection.retireRecoveredDiscardTailIfReady(); - if ( - backgroundStoreAndForward && - initialConnectMode === QWP_INITIAL_CONNECT_MODE.ASYNC - ) { - connection.startBackgroundConnect(); - } else { - try { - await connection.connectLoop( - undefined, - false, - initialConnectMode === QWP_INITIAL_CONNECT_MODE.OFF - ? "single" - : "configured", + // The store's lock is held from here on, so an abort has something to + // release and must reach the connect that is about to run. + if (signal?.aborted) throw abortError(); + const onAbort = () => { + void connection?.close().catch(() => undefined); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + try { + await connection.retireRecoveredDiscardTailIfReady(); + if ( + backgroundStoreAndForward && + initialConnectMode === QWP_INITIAL_CONNECT_MODE.ASYNC + ) { + connection.startBackgroundConnect(); + } else { + await connection.connectLoopOrCatchUp( + initialConnectMode, initialConnection, + backgroundStoreAndForward, + orphanStoreAndForward, ); - } catch (error) { - if ( - backgroundStoreAndForward && - !orphanStoreAndForward && - error instanceof QwpCatchUpCapGapError - ) { - // Java returns the foreground sender once the wire has connected, - // then moves recovered-dictionary catch-up to its unbounded I/O - // loop. Do the same instead of making OFF/SYNC construction wait - // forever for a larger-cap node. - connection.startBackgroundConnect(); - } else { - throw error; - } } + } finally { + signal?.removeEventListener("abort", onAbort); } + if (signal?.aborted) throw abortError(); return connection; } catch (error) { await connection?.close().catch(() => undefined); @@ -609,6 +612,39 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { } } + /** The foreground connect, with Java's catch-up fallback around it. */ + private async connectLoopOrCatchUp( + initialConnectMode: QwpInitialConnectMode, + initialConnection: Promise | undefined, + backgroundStoreAndForward: boolean, + orphanStoreAndForward: boolean, + ): Promise { + try { + await this.connectLoop( + undefined, + false, + initialConnectMode === QWP_INITIAL_CONNECT_MODE.OFF + ? "single" + : "configured", + initialConnection, + ); + } catch (error) { + if ( + backgroundStoreAndForward && + !orphanStoreAndForward && + error instanceof QwpCatchUpCapGapError + ) { + // Java returns the foreground sender once the wire has connected, + // then moves recovered-dictionary catch-up to its unbounded I/O + // loop. Do the same instead of making OFF/SYNC construction wait + // forever for a larger-cap node. + this.startBackgroundConnect(); + } else { + throw error; + } + } + } + get handshake(): QwpHandshakeMetadata { if (!this.lastHandshake) { if (this.backgroundStoreAndForward) return { qwpVersion: 1 }; diff --git a/src/_qwp/ingress-session.ts b/src/_qwp/ingress-session.ts index e831f85..7b93057 100644 --- a/src/_qwp/ingress-session.ts +++ b/src/_qwp/ingress-session.ts @@ -631,6 +631,11 @@ export class QwpIngressSession { DEFAULT_CONNECTION_LISTENER_INBOX_CAPACITY, options.errorInboxCapacity ?? DEFAULT_ERROR_INBOX_CAPACITY, options.onSenderError, + // Without this the signal reached only the eager initialConnection + // above, which is skipped for exactly the configurations that own a + // replay store -- so close() could not tear down the one connect + // that holds a lock. + signal, ) : await factory(signal); try { diff --git a/test/qwp/sender-node-integration.test.ts b/test/qwp/sender-node-integration.test.ts index 3019329..7aaedb3 100644 --- a/test/qwp/sender-node-integration.test.ts +++ b/test/qwp/sender-node-integration.test.ts @@ -1,5 +1,6 @@ -import { mkdtemp, rm } from "node:fs/promises"; -import type { AddressInfo } from "node:net"; +import { mkdtemp, readdir, rm } from "node:fs/promises"; +import type { AddressInfo, Socket } from "node:net"; +import { createServer as createTcpServer } from "node:net"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { WebSocketServer } from "ws"; @@ -10,6 +11,7 @@ import { QWP_MAGIC, QWP_STATUS, QwpByteWriter, + QwpNodeFileReplayStore, decodeQwpIngressSymbolDictionaryDelta, } from "../../src/qwp/node"; @@ -247,6 +249,73 @@ describe("Sender QWP integration", () => { expect(sender.acknowledgedSequence).toBe(0n); }); + it("releases the store-and-forward slot before close() returns", async () => { + // close() aborts a connect that is still negotiating, but the signal only + // ever reached the eager initial connection -- which is skipped for + // precisely the configurations that own a replay store. So close() + // returned and resolved while the abandoned connect went on holding the + // slot lock for the rest of its connect budget: a second sender on the + // same directory failed with QwpReplayStoreLockedError naming its own + // process, and the session kept doing real work after shutdown. + // + // The peer accepts TCP and never answers the upgrade -- a stalled proxy or + // load balancer -- so the attempt hangs for the whole connect timeout + // rather than failing fast the way a refused port would. + const sockets = new Set(); + const stalled = createTcpServer((socket) => { + sockets.add(socket); + socket.once("close", () => sockets.delete(socket)); + socket.resume(); + }); + await new Promise((resolve, reject) => { + stalled.once("error", reject); + stalled.listen(0, "127.0.0.1", resolve); + }); + const port = (stalled.address() as AddressInfo).port; + const directory = await mkdtemp(join(tmpdir(), "qwp-close-lock-")); + let connecting: Promise = Promise.resolve(); + try { + const sender = await Sender.fromConfig( + `ws::addr=127.0.0.1:${port};` + + `sf_dir=${directory};auto_flush=off;` + + "connect_timeout=30000;reconnect_max_duration_millis=30000;", + ); + connecting = sender.connect().catch(() => undefined); + // Let the connect reach the upgrade, so the store is loaded and its lock + // taken before close() runs. + await vi.waitFor(() => expect(sockets.size).toBe(1)); + await sender.close(); + + // The lock may outlive close() by an in-flight load, but not by the + // connect budget -- three seconds is an order of magnitude under the 30s + // configured here and far above a load. + const deadline = Date.now() + 3_000; + let reopened = false; + let lastError: unknown; + while (!reopened && Date.now() < deadline) { + const probe = new QwpNodeFileReplayStore({ + directory: join(directory, "default"), + }); + try { + await probe.load(); + await probe.close(); + reopened = true; + } catch (error) { + lastError = error; + await new Promise((resolve) => setTimeout(resolve, 25)); + } + } + expect(reopened, `slot still locked: ${lastError}`).toBe(true); + } finally { + for (const socket of sockets) socket.destroy(); + await new Promise((resolve) => stalled.close(() => resolve())); + await connecting; + await rm(directory, { recursive: true, force: true }).catch( + () => undefined, + ); + } + }, 40_000); + it("closes the socket and reports a bounded close-drain timeout", async () => { const frames: Uint8Array[] = []; server = new WebSocketServer({ host: "127.0.0.1", port: 0 }); From a645bf3f8d68dafec21370715d7502c81fa8f4cd Mon Sep 17 00:00:00 2001 From: glasstiger Date: Mon, 24 Aug 2026 13:15:22 +0100 Subject: [PATCH 165/265] test(qwp): stop the auto-flush row trigger racing the interval trigger The row-count half of "documents the auto-flush defaults the sender actually applies" staged 999 rows against a live 100ms auto-flush interval. The work is about 2ms, but it is 999 awaits, and on a contended two-core CI runner the event loop can take longer than the interval to get through them -- so the interval trigger fired mid-loop and the assertion saw a partial row count: "expected [ 773 ] to deeply equal []". The race is inherent to the test rather than new, but this branch's added suites raised the parallel load enough to lose it. Reproduced deterministically by injecting a 150ms stall into the loop, which fails identically with "expected [ 302 ] to deeply equal []" and passes with the clock frozen. Only Date is faked, which is all the interval check reads, so the flush machinery's own timers keep working. The interval half already drives a faked clock explicitly and is unaffected. Co-Authored-By: Claude Opus 5 (1M context) --- test/qwp/config-docs.test.ts | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/test/qwp/config-docs.test.ts b/test/qwp/config-docs.test.ts index 20194fa..dc30aa6 100644 --- a/test/qwp/config-docs.test.ts +++ b/test/qwp/config-docs.test.ts @@ -80,14 +80,25 @@ describe("QWP configuration-string reference", () => { async close() {}, } as unknown as QwpSenderSession; - const byRows = new QwpSender(async () => session); - for (let row = 0; row < rows - 1; row++) { - await byRows.table("t").intColumn("a", row).atNow(); + // Freeze the clock while the row trigger is under test, so the interval + // trigger cannot fire instead. Staging 999 rows is ~2ms of work but 999 + // awaits, and on a loaded CI runner the event loop can take longer than + // the 100ms interval to get through them -- which flushed mid-loop and + // failed this assertion with a partial row count. Only Date is faked, so + // the flush machinery's own timers keep working. + vi.useFakeTimers({ toFake: ["Date"] }); + try { + const byRows = new QwpSender(async () => session); + for (let row = 0; row < rows - 1; row++) { + await byRows.table("t").intColumn("a", row).atNow(); + } + expect(sends).toEqual([]); + await byRows.table("t").intColumn("a", rows).atNow(); + expect(sends).toEqual([rows]); + await byRows.close(); + } finally { + vi.useRealTimers(); } - expect(sends).toEqual([]); - await byRows.table("t").intColumn("a", rows).atNow(); - expect(sends).toEqual([rows]); - await byRows.close(); sends.length = 0; vi.useFakeTimers(); From 8f5a475808c76700b27d21652914342f1e6845a3 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Mon, 24 Aug 2026 18:02:26 +0100 Subject: [PATCH 166/265] docs: name the client after JavaScript, not Node.js The client now ships a browser build alongside the Node.js one, so naming it after a single runtime no longer describes who can use it. The sibling clients are named for the language their consumers write, and consumers here write JavaScript or TypeScript -- both served by the same JavaScript artifact and its type declarations, so neither is excluded by the broader name. This changes the human-facing name only: the package description, the typedoc title, the package documentation header, README prose, the examples manifest, and CLAUDE.md. The published package name, every import specifier, the tsconfig path mappings, and the repository URLs are untouched, so nothing an existing consumer resolves against moves. Two comments that had settled on "TypeScript client" are folded in here too, so the release that raises the question does not leave a third name behind. References that genuinely mean the Node.js runtime keep it: the store-and-forward locking section reasons about runtimes rather than products, and the pooled Node client and the orphan drainer are Node-only APIs. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CmYejbzDF32mYojAwbTqM4 --- CLAUDE.md | 2 +- QWP.md | 2 +- README.md | 2 +- examples.manifest.yaml | 8 ++++---- package.json | 2 +- src/index.ts | 4 +++- src/qwp-node/client-config.ts | 2 +- typedoc.json | 2 +- 8 files changed, 13 insertions(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8260426..c18ca40 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -This is the QuestDB Node.js client library (@questdb/nodejs-client) that provides data ingestion capabilities to QuestDB databases. The client supports multiple transport protocols (HTTP/HTTPS, TCP/TCPS) and authentication methods. +This is the QuestDB JavaScript client library (@questdb/nodejs-client) that provides data ingestion capabilities to QuestDB databases. The client supports multiple transport protocols (HTTP/HTTPS, TCP/TCPS) and authentication methods. ## Development Commands diff --git a/QWP.md b/QWP.md index 2aaf1a7..f8b8152 100644 --- a/QWP.md +++ b/QWP.md @@ -1205,7 +1205,7 @@ sizes may be passed as the second argument. The whole string is still validated before overrides are applied, matching the Java builder's fail-fast behavior. Set `lazy_connect=on` to tolerate an unavailable cluster during startup. In the -TypeScript client ingress uses memory replay by default, or persistent replay when +JavaScript client, ingress uses memory replay by default, or persistent replay when `sf_dir` is present, with `initial_connect_retry=async`; egress uses `query_pool_min=0` and connects on the first query. Explicit `initial_connect_retry=off|sync` or a positive `query_pool_min` conflicts with diff --git a/README.md b/README.md index 13191e0..ca7c56f 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ run().then(console.log).catch(console.error); Passing `null` or `undefined` as a column or symbol value omits that column from the row, and QuestDB records the omission as NULL. This is the model the QuestDB clients share — the Java client puts it as "to mark the value NULL, omit the -column from the row" — with the Node client doing the omission for you, so a +column from the row" — with the JavaScript client doing the omission for you, so a record with optional fields needs no branching: ```typescript diff --git a/examples.manifest.yaml b/examples.manifest.yaml index 428a4dd..bfdcaa9 100644 --- a/examples.manifest.yaml +++ b/examples.manifest.yaml @@ -2,12 +2,12 @@ lang: javascript path: examples/basic.js header: |- - NodeJS client library [repo](https://github.com/questdb/nodejs-questdb-client). + JavaScript client library [repo](https://github.com/questdb/nodejs-questdb-client). - name: ilp-auth lang: javascript path: examples/auth.js header: |- - NodeJS client library [repo](https://github.com/questdb/nodejs-questdb-client). + JavaScript client library [repo](https://github.com/questdb/nodejs-questdb-client). auth: kid: testapp d: 9b9x5WhJywDEuo1KGQWSPNxtX-6X6R2BRCKhYMMY6n8 @@ -20,7 +20,7 @@ lang: javascript path: examples/auth_tls.js header: |- - NodeJS client library [repo](https://github.com/questdb/nodejs-questdb-client). + JavaScript client library [repo](https://github.com/questdb/nodejs-questdb-client). auth: kid: testapp d: 9b9x5WhJywDEuo1KGQWSPNxtX-6X6R2BRCKhYMMY6n8 @@ -33,5 +33,5 @@ lang: javascript path: examples/basic.js header: |- - NodeJS client library [repo](https://github.com/questdb/nodejs-questdb-client). + JavaScript client library [repo](https://github.com/questdb/nodejs-questdb-client). conf: http::addr=localhost:9000 diff --git a/package.json b/package.json index 981115a..0cc7620 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@questdb/nodejs-client", "version": "4.2.0", - "description": "QuestDB Node.js Client", + "description": "QuestDB JavaScript Client", "scripts": { "test": "vitest", "test:qwp-browser": "pnpm build && vitest run --config vitest.qwp-browser.config.ts", diff --git a/src/index.ts b/src/index.ts index 9e1d3ea..116558f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,7 @@ /** - * A Node.js client for QuestDB. + * The QuestDB JavaScript client. + * + * This entry point targets Node.js. See `./qwp/browser` for the browser build. * @packageDocumentation */ diff --git a/src/qwp-node/client-config.ts b/src/qwp-node/client-config.ts index 3f0667e..d4b6311 100644 --- a/src/qwp-node/client-config.ts +++ b/src/qwp-node/client-config.ts @@ -109,7 +109,7 @@ export const QWP_SUPPORTED_CONFIG_KEYS: ReadonlySet = new Set([ "sender_id", "sf_max_segment_bytes", // Reserved by the shared QWP configuration vocabulary. They are accepted - // as intentional no-ops until the TypeScript client exposes these policies. + // as intentional no-ops until the JavaScript client exposes these policies. "on_internal_error", "on_parse_error", "on_schema_error", diff --git a/typedoc.json b/typedoc.json index 682b45d..543693b 100644 --- a/typedoc.json +++ b/typedoc.json @@ -7,7 +7,7 @@ "./src/qwp/node.ts" ], "out": "docs", - "name": "QuestDB Node.js Client", + "name": "QuestDB JavaScript Client", "readme": "./README.md", "tsconfig": "./tsconfig.json", "exclude": ["**/test/**/*", "**/examples/**/*", "**/node_modules/**/*"], From d9e08934398f5cfadcb0b45a8b1e07cef48b670c Mon Sep 17 00:00:00 2001 From: glasstiger Date: Mon, 24 Aug 2026 18:41:40 +0100 Subject: [PATCH 167/265] ci: point the Enterprise dispatch at the renamed e2e pipeline The Enterprise lane is now build-and-test-e2e-javascript-client and takes javascriptClientCommit / javascriptClientPrNumber, following this repository's rename away from Node.js. The dispatch resolves the pipeline by name and sends those parameters, so both sides have to agree or the lookup fails. The job stays gated behind ENTERPRISE_E2E_ENABLED, so nothing runs until the pipeline is renamed in Azure DevOps to match. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CmYejbzDF32mYojAwbTqM4 --- .github/workflows/build.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b704544..4d67c3c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -136,7 +136,7 @@ jobs: ORG_URL="https://dev.azure.com/questdb/" PROJECT="questdb-enterprise" - PIPELINE_NAME="build-and-test-e2e-typescript-client" + PIPELINE_NAME="build-and-test-e2e-javascript-client" PIPELINES=$(curl -fsS -u ":${ENT_DISPATCH_PAT}" \ "${ORG_URL}${PROJECT}/_apis/pipelines?api-version=7.0") PIPELINE_ID=$(echo "$PIPELINES" | jq -r --arg name "$PIPELINE_NAME" \ @@ -152,8 +152,8 @@ jobs: --arg branch "$CLIENT_BRANCH" \ '{ templateParameters: { - typescriptClientCommit: $commit, - typescriptClientPrNumber: $pr, + javascriptClientCommit: $commit, + javascriptClientPrNumber: $pr, clientBranch: $branch } }') From 88311dd36814dfe59febbfa8ef753ab39da1b01a Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 25 Aug 2026 00:03:54 +0100 Subject: [PATCH 168/265] fix(qwp): keep an async observability callback from crashing the host Seven observability callbacks were invoked inside a synchronous-only try/catch. That guards a thrown error, but an async callback returns a promise: when it rejects, the rejection escapes the try/catch and Node >= 15 turns an unhandled rejection into process termination by default. A single async onEvent, onError, onSenderError, onRecoveryQuarantine or onRecoveryDataLoss -- all reachable from the plain public API -- took the whole process down. The orphan-drain path was the decisive one. QWP.md says callbacks are "placed on bounded asynchronous inboxes and never invoked inside ACK, reconnect, or orphan-recovery protocol stacks ... Callback failures are contained", and the drainer does feed reconnect events through QwpNotificationDispatcher, whose dispatchOne already contained a returned promise. But the wrapper installed as the dispatcher's handler swallowed the sync throw and returned undefined, so that check had nothing to attach to and the user's promise orphaned through the very inbox meant to hold it. The containment already existed twice -- QwpNotificationDispatcher and a private safelyInvoke in ingress-session -- so this consolidates both onto one helper, src/_qwp/_internal/safe-callback.ts. safelyInvoke() contains a synchronous throw and a rejected promise alike, routing either to a guarded onFailure that can never re-escape, and uses the portable then(undefined, ...) rather than catch() so a bare thenable is handled too. All seven sites now go through it, each keeping its own failure behaviour: swallow, log once, or fall back to the default handler. Verified as a real process rather than under Vitest, which masks the crash by handling unhandled rejections itself: on Node 20.11.0 the old sync-only pattern exits 1 and safelyInvoke exits 0 with onFailure seeing the rejection. Added a safe-callback suite and an async-rejection case to the dispatcher suite, both asserting no unhandledRejection fires. Co-Authored-By: Claude Opus 4.8 --- src/_qwp/_internal/notification-dispatcher.ts | 17 +- .../reconnecting-egress-connection.ts | 12 +- src/_qwp/_internal/safe-callback.ts | 60 +++++++ src/_qwp/ingress-session.ts | 21 +-- src/qwp-node/file-replay-store.ts | 9 +- src/qwp-node/udp-sender.ts | 9 +- src/qwp/node.ts | 50 +++--- test/qwp/notification-dispatcher.test.ts | 27 ++++ test/qwp/safe-callback.test.ts | 150 ++++++++++++++++++ 9 files changed, 277 insertions(+), 78 deletions(-) create mode 100644 src/_qwp/_internal/safe-callback.ts create mode 100644 test/qwp/safe-callback.test.ts diff --git a/src/_qwp/_internal/notification-dispatcher.ts b/src/_qwp/_internal/notification-dispatcher.ts index 36f9334..3eb1d7b 100644 --- a/src/_qwp/_internal/notification-dispatcher.ts +++ b/src/_qwp/_internal/notification-dispatcher.ts @@ -1,3 +1,5 @@ +import { isPromiseLike } from "./safe-callback"; + export interface QwpNotificationDispatcherMetrics { readonly pending: number; readonly delivered: number; @@ -111,7 +113,7 @@ export class QwpNotificationDispatcher { this.delivered++; try { const result = this.handler(notification); - if (isPromiseLike(result)) void result.catch(() => undefined); + if (isPromiseLike(result)) void result.then(undefined, () => undefined); } catch { // Observability callbacks never participate in protocol progress. } finally { @@ -136,19 +138,6 @@ export class QwpNotificationDispatcher { } } -function isPromiseLike(value: unknown): value is PromiseLike & { - catch(onRejected: (reason: unknown) => unknown): unknown; -} { - return ( - value !== null && - (typeof value === "object" || typeof value === "function") && - "then" in value && - typeof value.then === "function" && - "catch" in value && - typeof value.catch === "function" - ); -} - function unrefTimer(timer: ReturnType): void { (timer as ReturnType & { unref?: () => void }).unref?.(); } diff --git a/src/_qwp/_internal/reconnecting-egress-connection.ts b/src/_qwp/_internal/reconnecting-egress-connection.ts index b123121..0131eac 100644 --- a/src/_qwp/_internal/reconnecting-egress-connection.ts +++ b/src/_qwp/_internal/reconnecting-egress-connection.ts @@ -21,6 +21,7 @@ import { } from "../transport"; import { QwpAsyncQueue } from "./async-queue"; import { jitterReconnectDelayMs } from "./reconnect-backoff"; +import { safelyInvoke } from "./safe-callback"; type ReplayResetHandler = ( event: QwpEgressReplayResetEvent, @@ -623,11 +624,12 @@ export class QwpReconnectingEgressConnection implements QwpBinaryConnection { } private emitEvent(event: Omit): void { - try { - this.reconnectOptions.onEvent?.({ ...event, timestampMs: Date.now() }); - } catch { - // Connection observers must not interfere with replay progress. - } + // Contain synchronous throws and rejected promises alike: a failing + // observer, sync or async, must never interfere with replay progress. + safelyInvoke(this.reconnectOptions.onEvent, { + ...event, + timestampMs: Date.now(), + }); } private throwIfUnavailable(): void { diff --git a/src/_qwp/_internal/safe-callback.ts b/src/_qwp/_internal/safe-callback.ts new file mode 100644 index 0000000..69f4eda --- /dev/null +++ b/src/_qwp/_internal/safe-callback.ts @@ -0,0 +1,60 @@ +/** + * Containment for user-supplied observability callbacks. + * + * Notification callbacks (reconnect events, sender errors, recovery reports) + * run purely for their side effects and must never interfere with protocol + * progress. A synchronous throw is easy to contain with try/catch, but an + * `async` callback returns a promise: if it rejects, the rejection escapes the + * surrounding try/catch and Node treats it as an unhandled rejection, which + * terminates the host process by default (Node >= 15). This helper contains + * both failure modes so a broken callback can never crash the client's host or + * stall protocol work. + */ + +/** + * Invokes an observability callback without letting a synchronous throw or a + * rejected promise (from an `async` callback) escape. On either failure the + * optional {@link onFailure} handler runs; it is itself guarded so it can never + * re-escape the containment it backs. + */ +export function safelyInvoke( + callback: ((event: T) => unknown) | undefined, + event: T, + onFailure?: (error: unknown) => void, +): void { + if (!callback) return; + try { + const result = callback(event); + if (isPromiseLike(result)) { + void result.then(undefined, (error) => reportFailure(onFailure, error)); + } + } catch (error) { + reportFailure(onFailure, error); + } +} + +function reportFailure( + onFailure: ((error: unknown) => void) | undefined, + error: unknown, +): void { + if (!onFailure) return; + try { + onFailure(error); + } catch { + // A failing fallback must not re-escape the containment it backs. + } +} + +/** + * Minimal Promises/A+ thenable test. A genuine thenable only guarantees a + * `then` method, so `then(undefined, onRejected)` — not `catch` — is the + * portable way to attach a rejection handler. + */ +export function isPromiseLike(value: unknown): value is PromiseLike { + return ( + value !== null && + (typeof value === "object" || typeof value === "function") && + "then" in value && + typeof value.then === "function" + ); +} diff --git a/src/_qwp/ingress-session.ts b/src/_qwp/ingress-session.ts index 7b93057..6d393b4 100644 --- a/src/_qwp/ingress-session.ts +++ b/src/_qwp/ingress-session.ts @@ -24,6 +24,7 @@ import { } from "./transport"; import { QwpReconnectingIngressConnection } from "./_internal/reconnecting-ingress-connection"; import { QwpNotificationDispatcher } from "./_internal/notification-dispatcher"; +import { safelyInvoke } from "./_internal/safe-callback"; import { createQwpSenderError, defaultQwpSenderErrorHandler, @@ -1652,26 +1653,6 @@ export class QwpIngressSession { } } -function safelyInvoke( - callback: ((event: T) => void) | undefined, - event: T, -): void { - if (!callback) return; - try { - const result = (callback as (value: T) => unknown)(event); - if ( - result !== null && - (typeof result === "object" || typeof result === "function") && - "catch" in result && - typeof result.catch === "function" - ) { - void result.catch(() => undefined); - } - } catch { - // Observability callbacks must not break protocol progress. - } -} - function defaultQwpIngressErrorHandler(event: { readonly terminal: boolean; readonly error: Error; diff --git a/src/qwp-node/file-replay-store.ts b/src/qwp-node/file-replay-store.ts index 64f13c0..1807b80 100644 --- a/src/qwp-node/file-replay-store.ts +++ b/src/qwp-node/file-replay-store.ts @@ -23,6 +23,7 @@ import { } from "./advisory-lock"; import { qwpSegmentMaintenanceWorker } from "./segment-maintenance-worker"; import { log } from "../logging"; +import { safelyInvoke } from "../_qwp/_internal/safe-callback"; const FORMAT_VERSION = 1; const MAX_FRAME_SEQUENCE = 0x7fffffffffffffffn; @@ -1953,11 +1954,9 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { log("error", message); return; } - try { - this.onRecoveryDataLoss(report); - } catch { - log("error", message); - } + // A rejected promise from an async handler must log the abandoned bytes, + // exactly as a synchronous throw does; neither may escape. + safelyInvoke(this.onRecoveryDataLoss, report, () => log("error", message)); } /** diff --git a/src/qwp-node/udp-sender.ts b/src/qwp-node/udp-sender.ts index ddb549a..d55cb9c 100644 --- a/src/qwp-node/udp-sender.ts +++ b/src/qwp-node/udp-sender.ts @@ -6,6 +6,7 @@ import { type QwpTableBuffer, } from "../_qwp/_core"; import type { QwpSenderSession } from "../_qwp/sender"; +import { safelyInvoke } from "../_qwp/_internal/safe-callback"; const DEFAULT_QWP_UDP_PORT = 9007; const DEFAULT_MAX_DATAGRAM_SIZE = 1_400; @@ -285,11 +286,9 @@ export class QwpNodeUdpSession implements QwpSenderSession { private reportError(error: Error): void { this.totalSendErrors++; - try { - this.onError?.(error); - } catch { - // UDP error observers cannot participate in sender progress. - } + // Contain synchronous throws and rejected promises alike: a UDP error + // observer must never participate in sender progress or crash the host. + safelyInvoke(this.onError, error); } private assertOpen(): void { diff --git a/src/qwp/node.ts b/src/qwp/node.ts index 8c26fcb..ce2520e 100644 --- a/src/qwp/node.ts +++ b/src/qwp/node.ts @@ -24,6 +24,7 @@ import { } from "../_qwp/_internal/failover"; import { createQwpEgressFailoverConnectionFactory } from "../_qwp/_internal/egress-routing"; import { validateQwpMaxBatchRows } from "../_qwp/_internal/egress-limits"; +import { safelyInvoke } from "../_qwp/_internal/safe-callback"; import { resolveQwpNodeClientConfig } from "../qwp-node/client-config"; import { QWP_INITIAL_CONNECT_MODE, @@ -750,11 +751,11 @@ function withRecoveryDataLossReporter( `QWP store-and-forward discarded ${report.discardedBytes} journal byte(s) during recovery ` + `[directory=${report.directory}, segment=${report.segmentFile}]: ${report.reason}`, ); - try { - onSenderError(senderError); - } catch { - defaultQwpSenderErrorHandler(senderError); - } + // A rejected promise from an async onSenderError must fall back to the + // default handler, exactly as a synchronous throw does. + safelyInvoke(onSenderError, senderError, () => + defaultQwpSenderErrorHandler(senderError), + ); }, }; } @@ -786,22 +787,17 @@ function emitReplayRecoveryQuarantine( log("error", error); return; } - let callbackFailed = false; - try { - options.onRecoveryQuarantine?.(event); - } catch { - callbackFailed = true; - } - try { - onSenderError?.(senderError); - } catch { - callbackFailed = true; - } - if (callbackFailed) { + let loggedFallback = false; + const reportCallbackFailure = (): void => { + if (loggedFallback) return; + loggedFallback = true; // Recovery already succeeded. Notification callbacks must not brick the - // fresh producer slot; fall back to the default logger instead. + // fresh producer slot; fall back to the default logger instead. A failure + // may surface asynchronously (a rejected promise), so log at most once. log("error", error); - } + }; + safelyInvoke(options.onRecoveryQuarantine, event, reportCallbackFailure); + safelyInvoke(onSenderError, senderError, reportCallbackFailure); } /** @@ -1266,16 +1262,12 @@ function orphanIngressSessionOptions( maxAttempts: 0, maxDurationMs: 0, onEvent: (event) => { - try { - configuredOnEvent?.(event); - } catch { - // Reconnect observers cannot interrupt orphan recovery. - } - try { - onReconnectEvent?.(event); - } catch { - // Orphan lifecycle observers use their own bounded dispatcher. - } + // This wrapper is the dispatcher's handler, so a rejected promise it + // returned would orphan through the very inbox meant to contain it. + // Contain both observers here: a reconnect observer cannot interrupt + // orphan recovery, and the orphan lifecycle observer stays bounded. + safelyInvoke(configuredOnEvent, event); + safelyInvoke(onReconnectEvent, event); }, }, replayStore: undefined, diff --git a/test/qwp/notification-dispatcher.test.ts b/test/qwp/notification-dispatcher.test.ts index 0917101..0b2c195 100644 --- a/test/qwp/notification-dispatcher.test.ts +++ b/test/qwp/notification-dispatcher.test.ts @@ -48,6 +48,33 @@ describe("QwpNotificationDispatcher", () => { await dispatcher.close(); }); + it("contains a rejected promise from an async handler", async () => { + const rejections: unknown[] = []; + const listener = (reason: unknown): void => { + rejections.push(reason); + }; + process.on("unhandledRejection", listener); + try { + const delivered: number[] = []; + const dispatcher = new QwpNotificationDispatcher((value) => { + delivered.push(value); + // An async observer that rejects must not escape the inbox as an + // unhandled rejection and terminate the host process. + return Promise.reject(new Error(`observer ${value} rejected`)); + }, 4); + + dispatcher.offer(1); + dispatcher.offer(2); + await vi.waitFor(() => expect(delivered).toEqual([1, 2])); + await new Promise((resolve) => setTimeout(resolve, 25)); + expect(rejections).toEqual([]); + expect(dispatcher.metrics.delivered).toBe(2); + await dispatcher.close(); + } finally { + process.off("unhandledRejection", listener); + } + }); + it("drains retained notifications and rejects post-close offers", async () => { const received: number[] = []; const dispatcher = new QwpNotificationDispatcher( diff --git a/test/qwp/safe-callback.test.ts b/test/qwp/safe-callback.test.ts new file mode 100644 index 0000000..f9b8d64 --- /dev/null +++ b/test/qwp/safe-callback.test.ts @@ -0,0 +1,150 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + isPromiseLike, + safelyInvoke, +} from "../../src/_qwp/_internal/safe-callback"; + +/** + * Runs `body`, then waits long enough for Node to surface any orphaned + * rejection, and returns the reasons of every `unhandledRejection` seen in the + * window. An empty array proves a rejection handler was attached synchronously + * -- the exact thing that keeps an async observability callback from + * terminating the host process (Node >= 15 exits on unhandled rejection). + */ +async function unhandledRejectionsDuring( + body: () => void, + settleMs = 25, +): Promise { + const reasons: unknown[] = []; + const listener = (reason: unknown): void => { + reasons.push(reason); + }; + process.on("unhandledRejection", listener); + try { + body(); + await new Promise((resolve) => setTimeout(resolve, settleMs)); + } finally { + process.off("unhandledRejection", listener); + } + return reasons; +} + +describe("safelyInvoke", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("is a no-op for an absent callback", async () => { + const onFailure = vi.fn(); + const seen = await unhandledRejectionsDuring(() => { + safelyInvoke(undefined, "event", onFailure); + }); + expect(seen).toEqual([]); + expect(onFailure).not.toHaveBeenCalled(); + }); + + it("delivers the event to a well-behaved callback", () => { + const received: string[] = []; + safelyInvoke((event: string) => received.push(event), "payload"); + expect(received).toEqual(["payload"]); + }); + + it("contains a synchronous throw and reports it", () => { + const boom = new Error("sync observer failed"); + const onFailure = vi.fn(); + expect(() => + safelyInvoke( + () => { + throw boom; + }, + undefined, + onFailure, + ), + ).not.toThrow(); + expect(onFailure).toHaveBeenCalledWith(boom); + }); + + it("contains a rejected promise from an async callback without crashing", async () => { + const boom = new Error("async observer rejected"); + const onFailure = vi.fn(); + const seen = await unhandledRejectionsDuring(() => { + safelyInvoke(() => Promise.reject(boom), undefined, onFailure); + }); + expect(seen).toEqual([]); + expect(onFailure).toHaveBeenCalledTimes(1); + expect(onFailure).toHaveBeenCalledWith(boom); + }); + + it("leaves a resolving async callback alone", async () => { + const onFailure = vi.fn(); + const seen = await unhandledRejectionsDuring(() => { + safelyInvoke(() => Promise.resolve("done"), undefined, onFailure); + }); + expect(seen).toEqual([]); + expect(onFailure).not.toHaveBeenCalled(); + }); + + it("swallows a throwing failure handler on the synchronous path", () => { + expect(() => + safelyInvoke( + () => { + throw new Error("callback"); + }, + undefined, + () => { + throw new Error("fallback also failed"); + }, + ), + ).not.toThrow(); + }); + + it("swallows a throwing failure handler on the async path", async () => { + const seen = await unhandledRejectionsDuring(() => { + safelyInvoke( + () => Promise.reject(new Error("callback rejected")), + undefined, + () => { + throw new Error("fallback also failed"); + }, + ); + }); + expect(seen).toEqual([]); + }); + + it("ignores a non-thenable return value", () => { + const onFailure = vi.fn(); + expect(() => safelyInvoke(() => 42, undefined, onFailure)).not.toThrow(); + expect(onFailure).not.toHaveBeenCalled(); + }); + + it("contains a foreign thenable that rejects", async () => { + const boom = new Error("thenable rejected"); + const onFailure = vi.fn(); + const seen = await unhandledRejectionsDuring(() => { + // A bare thenable that exposes only `then`, not `catch`. + const thenable = { + then(_onFulfilled: unknown, onRejected: (reason: unknown) => void) { + onRejected(boom); + }, + }; + safelyInvoke(() => thenable, undefined, onFailure); + }); + expect(seen).toEqual([]); + expect(onFailure).toHaveBeenCalledWith(boom); + }); +}); + +describe("isPromiseLike", () => { + it("accepts native promises and bare thenables", () => { + expect(isPromiseLike(Promise.resolve().catch(() => undefined))).toBe(true); + expect(isPromiseLike({ then: () => undefined })).toBe(true); + }); + + it("rejects non-thenables", () => { + expect(isPromiseLike(null)).toBe(false); + expect(isPromiseLike(undefined)).toBe(false); + expect(isPromiseLike(42)).toBe(false); + expect(isPromiseLike({})).toBe(false); + expect(isPromiseLike({ then: 1 })).toBe(false); + }); +}); From e3f7475999ef35006d8c4acd35b6b6da653e063d Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 25 Aug 2026 00:17:25 +0100 Subject: [PATCH 169/265] test(qwp): assert wss:// verifies certificates and preserves credentials Nothing asserted that a wss:// producer verifies the server certificate or that its Authorization header carries the operator's credentials unchanged. Both fail silently -- a disabled check still connects, transposed Basic credentials still form a header -- so mutating either the TLS agent or the authorization encoding left the whole suite green. The nearest assertion only checked that ingress.agent exists, never its rejectUnauthorized, ca, pfx or passphrase. This is a departure from the client's own standard rather than a general gap: sender.transport.test.ts exercises ILP TLS for real against test/certs, so the fixtures a QWP test needs already exist. The new suite covers both wss construction paths. The documented `wss::` connect string, resolved by parseQwpNodeClientConfig(), is asserted for rejectUnauthorized, a custom ca, and a pfx trust store with its passphrase across tls_verify on/unsafe_off and tls_roots, plus the unconfigured case that must build no agent at all so node's verifying default applies. The programmatic `new Sender({ protocol: "wss", ... })` object, handled by sender.ts, is asserted by capturing the ingress options handed to createQwpNodeSender: the agent's ca and rejectUnauthorized, the Basic header's username:password order, and the Bearer prefix. Each admitted mutation was re-applied and now turns the suite red: disabling verification in createTlsAgent fails three connect-string tests, the same in the sender.ts agent fails two, and swapping the Basic order or dropping the Bearer prefix fails two. The unsafe_off and tls_verify=false cases stay green under the TLS mutations, so the tests assert the intent rather than a constant. Co-Authored-By: Claude Opus 4.8 --- test/qwp/wss-tls-security.test.ts | 151 ++++++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 test/qwp/wss-tls-security.test.ts diff --git a/test/qwp/wss-tls-security.test.ts b/test/qwp/wss-tls-security.test.ts new file mode 100644 index 0000000..c783fc6 --- /dev/null +++ b/test/qwp/wss-tls-security.test.ts @@ -0,0 +1,151 @@ +import { readFileSync } from "node:fs"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import * as qwpNode from "../../src/qwp/node"; +import { Sender } from "../../src/sender"; + +/** + * A wss:// producer must verify the server certificate, and its authorization + * header must carry the operator's credentials unchanged. Both are silent when + * wrong -- a disabled check still connects, transposed Basic credentials still + * form a header -- so nothing but an explicit assertion on the constructed TLS + * agent and authorization catches a regression. The reused ILP fixture already + * ships a real CA at test/certs/ca/ca.crt. + * + * There are two construction paths and both are asserted here: the documented + * `wss::` connect string resolved by parseQwpNodeClientConfig(), and the + * programmatic `new Sender({ protocol: "wss", ... })` object handled by + * sender.ts. + */ + +const CA_PATH = "test/certs/ca/ca.crt"; + +interface AgentTlsOptions { + rejectUnauthorized?: boolean; + ca?: Buffer | string; + pfx?: Buffer | string; + passphrase?: string; +} + +/** node's http(s).Agent stores its constructor options on `.options`. */ +function agentTlsOptions(agent: unknown): AgentTlsOptions { + expect(agent, "expected a TLS agent to be constructed").toBeDefined(); + return (agent as { options: AgentTlsOptions }).options; +} + +describe("QWP wss:: connect-string verifies the server certificate", () => { + it("keeps verification on for tls_verify=on", () => { + const options = qwpNode.parseQwpNodeClientConfig( + "wss::addr=localhost;tls_verify=on;", + ); + expect(agentTlsOptions(options.ingress.agent).rejectUnauthorized).toBe( + true, + ); + }); + + it("applies a custom root CA and keeps verification on", () => { + const options = qwpNode.parseQwpNodeClientConfig( + `wss::addr=localhost;tls_roots=${CA_PATH};`, + ); + const tls = agentTlsOptions(options.ingress.agent); + expect(tls.rejectUnauthorized).toBe(true); + expect(tls.ca).toEqual(readFileSync(CA_PATH)); + expect(tls.pfx).toBeUndefined(); + }); + + it("loads a PFX trust store with its passphrase", async () => { + const dir = await mkdtemp(join(tmpdir(), "qwp-pfx-roots-")); + const store = join(dir, "roots.p12"); + const bytes = Uint8Array.of(1, 2, 3, 4); + await writeFile(store, bytes); + try { + const options = qwpNode.parseQwpNodeClientConfig( + `wss::addr=localhost;tls_roots=${store};tls_roots_password=secret;`, + ); + const tls = agentTlsOptions(options.ingress.agent); + expect(tls.rejectUnauthorized).toBe(true); + expect(tls.pfx).toEqual(Buffer.from(bytes)); + expect(tls.passphrase).toBe("secret"); + expect(tls.ca).toBeUndefined(); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it("disables verification only when tls_verify=unsafe_off is explicit", () => { + const options = qwpNode.parseQwpNodeClientConfig( + "wss::addr=localhost;tls_verify=unsafe_off;", + ); + expect(agentTlsOptions(options.ingress.agent).rejectUnauthorized).toBe( + false, + ); + }); + + it("leaves TLS to node's verifying default when unconfigured", () => { + // No explicit agent means the WebSocket upgrade uses node's default, which + // verifies -- not an agent that silently turns verification off. + const options = qwpNode.parseQwpNodeClientConfig("wss::addr=localhost;"); + expect(options.ingress.agent).toBeUndefined(); + }); +}); + +describe("QWP programmatic wss sender applies TLS and authorization", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + /** + * Constructs a Sender for a programmatic options object and returns the + * ingress options handed to createQwpNodeSender, without opening a socket. + */ + function ingressFor( + options: Record, + ): qwpNode.QwpNodeIngressOptions { + const spy = vi + .spyOn(qwpNode, "createQwpNodeSender") + .mockReturnValue({ reset() {} } as unknown as qwpNode.QwpSender); + new Sender({ + protocol: "wss", + host: "localhost", + port: 9000, + ...options, + } as never); + expect(spy).toHaveBeenCalledTimes(1); + return spy.mock.calls[0][0]; + } + + it("builds a verifying https agent with the configured root CA", () => { + const tls = agentTlsOptions(ingressFor({ tls_ca: CA_PATH }).agent); + expect(tls.rejectUnauthorized).toBe(true); + expect(tls.ca).toEqual(readFileSync(CA_PATH)); + }); + + it("verifies by default when neither tls_ca nor tls_verify is set", () => { + expect(agentTlsOptions(ingressFor({}).agent).rejectUnauthorized).toBe(true); + }); + + it("disables verification only for tls_verify=false", () => { + expect( + agentTlsOptions(ingressFor({ tls_verify: false }).agent) + .rejectUnauthorized, + ).toBe(false); + }); + + it("encodes Basic credentials as username:password, in that order", () => { + const authorization = ingressFor({ + username: "alice", + password: "s3cret", + }).authorization; + expect(authorization).toBe( + `Basic ${Buffer.from("alice:s3cret", "utf8").toString("base64")}`, + ); + }); + + it("prefixes a bearer token", () => { + expect(ingressFor({ token: "tok-123" }).authorization).toBe( + "Bearer tok-123", + ); + }); +}); From c4fc77d96eba2d023fb47b2631ed2dcee739826b Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 25 Aug 2026 00:37:53 +0100 Subject: [PATCH 170/265] fix(qwp): validate nullish dateColumn and fixed-width decimal calls dateColumn and fixedDecimalColumn -- the shared body of decimal64Column, decimal128Column and decimal256Column -- returned on a nullish value through a raw === null || === undefined check, bypassing omitsNullish. So on a nullish row they skipped the sender availability, row state and column name checks every other setter runs, and the decimals also skipped the scale check. A misspelled or over-long name, or a bad scale constant, then surfaced only on the rows that happened to carry a value and stayed silent on the rest -- which is how a typo reaches production. An inventory of all 26 setters confirms these four were the only ones left: the rest already route a nullish value through omitsNullish, and doubleColumn is safe because it delegates to floatColumn. This is the same class commit 266438f fixed for the setters it covered, and README.md documents the nullish rule as shared across QuestDB clients, so they must agree. dateColumn now goes through omitsNullish like its sibling timestampColumn. fixedDecimalColumn hoists the scale check above the gate -- the scale describes the column, not this row's value -- then routes the name through omitsNullish, exactly as decimalColumn already does. The regression test written for this bug covered seven setters and omitted all four; it now asserts dateColumn's name, each fixed-width decimal's scale constant, and decimal64Column's over-long name all raise on a nullish value, and that a valid nullish dateColumn/decimal64Column call is still omitted. Co-Authored-By: Claude Opus 4.8 --- src/_qwp/sender.ts | 21 +++++++++------------ test/qwp/sender.test.ts | 22 ++++++++++++++++++++++ 2 files changed, 31 insertions(+), 12 deletions(-) diff --git a/src/_qwp/sender.ts b/src/_qwp/sender.ts index 4596aa9..d19101a 100644 --- a/src/_qwp/sender.ts +++ b/src/_qwp/sender.ts @@ -1285,12 +1285,7 @@ export class QwpSender { name: string, millisecondsSinceEpoch: number | bigint | null | undefined, ): QwpSender { - if ( - millisecondsSinceEpoch === null || - millisecondsSinceEpoch === undefined - ) { - return this; - } + if (this.omitsNullish(name, millisecondsSinceEpoch)) return this; try { return this.addColumn( name, @@ -2104,13 +2099,15 @@ export class QwpSender { bits: number, maximumScale: number, ): QwpSender { - if (unscaled === null || unscaled === undefined) return this; + // The scale describes the column, not this row's value, so a bad constant + // is reported whether or not this row happens to carry a decimal. + if (!Number.isSafeInteger(scale) || scale < 0 || scale > maximumScale) { + return this.failRow( + new RangeError(`decimal scale must be between 0 and ${maximumScale}`), + ); + } + if (this.omitsNullish(name, unscaled)) return this; try { - if (!Number.isSafeInteger(scale) || scale < 0 || scale > maximumScale) { - throw new RangeError( - `decimal scale must be between 0 and ${maximumScale}`, - ); - } if (!fitsSigned(unscaled, bits)) { throw new RangeError(`decimal value exceeds signed int${bits}`); } diff --git a/test/qwp/sender.test.ts b/test/qwp/sender.test.ts index e08939a..b29b73e 100644 --- a/test/qwp/sender.test.ts +++ b/test/qwp/sender.test.ts @@ -264,6 +264,26 @@ describe("QWP high-level sender", () => { expect(() => table().long256Column("bad.name", value, value, value, value), ).toThrow(/illegal characters/i); + // dateColumn and the three fixed-width decimal setters returned on a + // nullish value before validating anything -- commit 266438f fixed this + // class and missed exactly these four. + expect(() => table().dateColumn("bad.name", value)).toThrow( + /illegal characters/i, + ); + expect(() => table().decimal64Column("a".repeat(20), value, 2)).toThrow( + /too long/i, + ); + // The scale constant describes the column, not this row's value, so it is + // checked whether or not the value is present. + expect(() => table().decimal64Column("d", value, 999)).toThrow( + /decimal scale/i, + ); + expect(() => table().decimal128Column("d", value, 999)).toThrow( + /decimal scale/i, + ); + expect(() => table().decimal256Column("d", value, 999)).toThrow( + /decimal scale/i, + ); } // A valid nullish call is still simply omitted. @@ -271,6 +291,8 @@ describe("QWP high-level sender", () => { await sender .table("t") .stringColumn("skipped", null) + .dateColumn("dateval", null) + .decimal64Column("decval", null, 2) .longColumn("kept", 1n) .atNow(); expect(sender.metrics.pendingRows).toBe(1); From 57455811941261ac2cbce73dccc031a39ec03d8e Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 25 Aug 2026 01:22:12 +0100 Subject: [PATCH 171/265] perf(qwp): drop the per-cell column-key rebuild on the ingest path qwpColumnNameKey builds its result one code unit at a time, and it ran twice per cell: once when the cell is staged, then again at flush inside getOrCreateColumn -- even though buildTable iterates row.columns, a Map already keyed by exactly that value. Two changes remove the redundant work. buildTable now iterates the map entries and passes the key it already holds into getOrCreateColumn, which takes it as an optional third argument defaulting to qwpColumnNameKey(name), so every other caller is unchanged. That key is provably the one getOrCreateColumn would have computed: each entry's stored canonical name is one whose key is the map key it lives under. Separately, qwpColumnNameKey gains an all-lower-case-ASCII fast path -- a name of only lower-case-stable code units is returned unchanged, and the first upper-case ASCII or non-ASCII code unit resumes the per-code-unit mapping from the stable prefix -- so the common name skips the rebuild entirely. A local before/after run of the shipped benchmarks (benchmarks/sender.bench.ts, build and encode) measured roughly +33% on trades, +12% on wide and +8% on sparse; trades is the primary ingest path. The surface is new in this branch, so this is measurable headroom rather than a regression from an earlier release. A new identifiers suite asserts the fast path is byte-identical to the per-code-unit reference across upper-case, non-ASCII, surrogate-pair and U+0130 inputs, so two spellings of one column still collide on the same key. Co-Authored-By: Claude Opus 4.8 --- src/_qwp/_core/identifiers.ts | 17 ++++++++-- src/_qwp/_core/table.ts | 10 ++++-- src/_qwp/sender.ts | 10 ++++-- test/qwp/identifiers.test.ts | 60 +++++++++++++++++++++++++++++++++++ 4 files changed, 91 insertions(+), 6 deletions(-) create mode 100644 test/qwp/identifiers.test.ts diff --git a/src/_qwp/_core/identifiers.ts b/src/_qwp/_core/identifiers.ts index 9d3fb08..982a699 100644 --- a/src/_qwp/_core/identifiers.ts +++ b/src/_qwp/_core/identifiers.ts @@ -78,8 +78,21 @@ export function validateQwpColumnName( * expanding lowercase mapping (U+0130) and gives the same simple mapping. */ export function qwpColumnNameKey(name: string): string { - let key = ""; - for (let index = 0; index < name.length; index++) { + // Fast path: a name of only lower-case-stable code units -- ASCII other than + // A-Z -- already equals its key, so it is returned without rebuilding. The + // first upper-case ASCII letter or non-ASCII code unit (which may lower-case + // or expand) drops to the per-code-unit mapping below, resuming from the + // stable prefix. This runs once per cell on the ingest path, so the common + // all-lower-case name skips the character-by-character rebuild entirely. + let index = 0; + for (; index < name.length; index++) { + const code = name.charCodeAt(index); + if (code >= 0x80 || (code >= 0x41 && code <= 0x5a)) break; + } + if (index === name.length) return name; + + let key = name.slice(0, index); + for (; index < name.length; index++) { const character = name.charAt(index); key += character.toLowerCase().charAt(0); } diff --git a/src/_qwp/_core/table.ts b/src/_qwp/_core/table.ts index 622b5ae..3b2ce54 100644 --- a/src/_qwp/_core/table.ts +++ b/src/_qwp/_core/table.ts @@ -62,7 +62,14 @@ export class QwpTableBuffer { * Returns null when the current row already contains this column. The first * value wins, matching the existing Sender API. */ - getOrCreateColumn(name: string, type: QwpColumnType): QwpColumnBuffer | null { + getOrCreateColumn( + name: string, + type: QwpColumnType, + // The caller may pass the key it already holds -- the flush path iterates a + // Map already keyed by it -- to skip a per-cell rebuild. It must equal + // qwpColumnNameKey(name); it defaults to it when omitted. + nameKey: string = qwpColumnNameKey(name), + ): QwpColumnBuffer | null { const designatedTimestamp = name.length === 0 && (type === QWP_COLUMN_TYPE.TIMESTAMP || @@ -71,7 +78,6 @@ export class QwpTableBuffer { throw new Error("column name cannot be empty"); } - const nameKey = qwpColumnNameKey(name); const existing = this.columnsByName.get(nameKey); if (existing) { if (existing.type !== type) { diff --git a/src/_qwp/sender.ts b/src/_qwp/sender.ts index d19101a..604c85a 100644 --- a/src/_qwp/sender.ts +++ b/src/_qwp/sender.ts @@ -2446,8 +2446,14 @@ export class QwpSender { private buildTable(name: string, rows: readonly StagedRow[]): QwpTableBuffer { const result = new QwpTableBuffer(name, this.maxNameLength); for (const row of rows) { - for (const column of row.columns.values()) { - const target = result.getOrCreateColumn(column.name, column.type); + // row.columns is already keyed by qwpColumnNameKey(column.name); passing + // that key through avoids getOrCreateColumn recomputing it per cell. + for (const [nameKey, column] of row.columns) { + const target = result.getOrCreateColumn( + column.name, + column.type, + nameKey, + ); if (!target) continue; if (column.geohashPrecision !== undefined) { result.setGeohashPrecision(target, column.geohashPrecision); diff --git a/test/qwp/identifiers.test.ts b/test/qwp/identifiers.test.ts new file mode 100644 index 0000000..6eef887 --- /dev/null +++ b/test/qwp/identifiers.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { qwpColumnNameKey } from "../../src/_qwp/_core/identifiers"; + +/** + * The pre-optimization reference: lower-case each UTF-16 code unit + * independently and keep only its first code unit. The all-lower-case-ASCII + * fast path must produce byte-identical keys to this for every input, or two + * spellings of one column would stop colliding on the same case-insensitive + * key. + */ +function referenceKey(name: string): string { + let key = ""; + for (let index = 0; index < name.length; index++) { + key += name.charAt(index).toLowerCase().charAt(0); + } + return key; +} + +describe("qwpColumnNameKey", () => { + it("returns a lower-case-stable ASCII name unchanged", () => { + for (const name of ["value", "a", "col_1", "trade99", ""]) { + expect(qwpColumnNameKey(name)).toBe(name); + } + }); + + it("matches the per-code-unit reference across mixed inputs", () => { + const cases = [ + "value", + "Value", + "VALUE", + "vAlUe", + "abcDef", // resumes mapping only at the first upper-case letter + "MixedCase123", + "UPPER_lower", + "Ünïcøde", // non-ASCII letters, already lower-case + "Æß", // non-ASCII upper-case that lower-cases + "SMILE😀SMILE", // a surrogate pair mid-string + " Spaced Name ", + "0123456789", + "!@#$%^&*()", + "", + ]; + for (const name of cases) { + expect(qwpColumnNameKey(name), name).toBe(referenceKey(name)); + } + }); + + it("keeps a case-insensitive key stable across spellings", () => { + const key = qwpColumnNameKey("Value"); + expect(qwpColumnNameKey("value")).toBe(key); + expect(qwpColumnNameKey("VALUE")).toBe(key); + expect(qwpColumnNameKey("vAlUe")).toBe(key); + }); + + it("takes only the first code unit of an expanding lower-case (U+0130)", () => { + // JS lower-cases 'İ' to 'i' + combining dot above; the key keeps just 'i'. + expect(qwpColumnNameKey("İ")).toBe("i"); + expect(qwpColumnNameKey("İ")).toHaveLength(1); + }); +}); From 8d2cf79b28ee3f2d151be6c33c0952b4aa11d7eb Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 25 Aug 2026 01:38:23 +0100 Subject: [PATCH 172/265] fix(qwp): send an all-nullish compiled-writer row instead of rejecting it README says the nullish rule applies to the compiled QWP writers and that a QWP row whose every value is nullish is sent with no columns; QWP.md says regular fields may all be null and a designated timestamp is required only "when present". But for a schema without a designated timestamp, encodeCompiledWriterRow threw QwpWriterRowError: row must contain at least one non-null value -- a string that appears nowhere in README.md, QWP.md, any test or any example. The exact fluent analogue, table("t").symbol("side", null) .atNow(), is accepted and encodes a real frame, so the two APIs disagreed on documented behaviour. Dropping the columns.size === 0 guard makes the writer send the all-nullish row with no columns, exactly as the fluent path does. The guard only ever bit a timestamp-less schema: a designated timestamp is required earlier in the same function -- it throws when nullish and stages a column when present -- so a schema that declares one always reaches this point with at least one column. A column-less row encodes cleanly; the encoder's non-null-row check is a per-column count consistency check that a row with no columns has nothing to fail. Two tests cover it: a timestamp-less writer now stages and flushes an all-null row as a zero-column, real-encoding frame, and a schema that declares a designated timestamp still rejects a nullish one -- the requirement the dropped guard sat next to. Co-Authored-By: Claude Opus 4.8 --- src/_qwp/sender.ts | 12 ++++------- test/qwp/sender.test.ts | 47 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 8 deletions(-) diff --git a/src/_qwp/sender.ts b/src/_qwp/sender.ts index 604c85a..073137e 100644 --- a/src/_qwp/sender.ts +++ b/src/_qwp/sender.ts @@ -2010,14 +2010,10 @@ export class QwpSender { } } - if (columns.size === 0) { - throw new QwpWriterRowError( - schema.tableName, - undefined, - rowIndex, - new TypeError("row must contain at least one non-null value"), - ); - } + // An all-nullish row is legal: QWP is columnar, so it is sent with no + // columns, exactly as the fluent table().atNow() analogue and as README and + // QWP.md document. A designated timestamp, when the schema has one, is + // required above, so an empty row reaches here only for a schema with none. return { columns, estimatedBytes: stagedRowBytes(columns) }; } diff --git a/test/qwp/sender.test.ts b/test/qwp/sender.test.ts index b29b73e..e6a01c8 100644 --- a/test/qwp/sender.test.ts +++ b/test/qwp/sender.test.ts @@ -1563,6 +1563,53 @@ describe("QWP high-level sender", () => { expect(column(session.sends[0].tables[0], "price").values).toEqual([150n]); }); + it("sends an all-nullish writer row for a schema without a designated timestamp", async () => { + // README and QWP.md say a QWP row whose every value is nullish is sent with + // no columns, and the fluent table().atNow() analogue does exactly that. The + // compiled writer used to reject it with "row must contain at least one + // non-null value" -- an error documented nowhere; the two APIs must agree. + const session = new RecordingSession(); + const sender = new QwpSender(async () => session, { autoFlush: false }); + const events = sender.writer("events", { + side: qwpSymbol(), + price: float64(), + }); + + await expect( + events.row({ side: null, price: undefined }), + ).resolves.toBeUndefined(); + await expect(events.row({})).resolves.toBeUndefined(); // absent keys, too + expect(sender.metrics.pendingRows).toBe(2); + + await sender.flush(); + const table = session.sends[0].tables[0]; + expect(table.name).toBe("events"); + expect(table.columns).toHaveLength(0); + expect(table.rowCount).toBe(2); + // The columnar frame really encodes -- the point of sending it at all. + expect(encodeQwpIngressFrame([table]).byteLength).toBeGreaterThan(0); + + await sender.close(); + }); + + it("still requires a designated timestamp in every writer row", async () => { + // Dropping the all-nullish guard must not weaken the one field QWP.md says + // is required in every row when the schema declares it. + const sender = new QwpSender(async () => new RecordingSession(), { + autoFlush: false, + }); + const trades = sender.writer("trades", { + price: float64(), + timestamp: designatedTimestamp("ns"), + }); + + await expect(trades.row({ price: null, timestamp: null })).rejects.toThrow( + /designated timestamp is required/, + ); + expect(sender.metrics.pendingRows).toBe(0); + await sender.close(); + }); + it("reconciles compiled precision and scale with the fluent row API", async () => { const sender = new QwpSender(async () => new RecordingSession(), { autoFlush: false, From fcebb9c4cfd2403a8aec941e5fdd22bea534809e Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 25 Aug 2026 01:48:11 +0100 Subject: [PATCH 173/265] fix(qwp): point the udp-only relocation hint at udp, not http/tcp/udp A ws/wss connect string that carries max_datagram_size or multicast_ttl was rejected with the hint "(applies to legacy http/tcp/udp transports only)". But those two keys are UDP-only: http and tcp reject them as well, with "max_datagram_size and multicast_ttl are only supported for QWP UDP transport". So the hint sent the user to two more protocols that also refuse the key. The same string is correct for the four sibling keys it is shared with, which really do span all three transports, and options.ts already had the right wording; only these two entries were wrong. The test pinned the wrong string. The hint now reads "(applies to the legacy udp transport only)" for both keys, and the test asserts it for max_datagram_size and multicast_ttl alike. Neither key was documented anywhere a reader would look, even though the auto_flush_bytes entry cites max_datagram_size as its own default. A "UDP specific options" block in the SenderOptions reference -- the configuration reference README points to -- now documents both, with defaults (1400 and 0), the 0-255 TTL range, and that only udp accepts them; and the QWP.md UDP prose now spells the key names instead of only describing the values. Co-Authored-By: Claude Opus 4.8 --- QWP.md | 13 +++++++------ src/options.ts | 11 +++++++++++ src/qwp-node/client-config.ts | 4 ++-- test/options.test.ts | 12 +++++++++--- 4 files changed, 29 insertions(+), 11 deletions(-) diff --git a/QWP.md b/QWP.md index f8b8152..48bf523 100644 --- a/QWP.md +++ b/QWP.md @@ -209,12 +209,13 @@ await sender await sender.close(); ``` -The default port is 9007, maximum datagram size is 1400 bytes, and multicast TTL -is zero. Each datagram is self-contained, contains exactly one table, and uses an -inline schema plus table-local symbol dictionaries. Batches are split at row -boundaries; `QwpUdpDatagramTooLargeError` is raised before transmission when one -row cannot fit. `connectQwpNodeUdpSender()` and `connectQwpNodeUdp()` expose the -same transport from `qwp/node`. +The default port is 9007, the maximum datagram size (`max_datagram_size`) is 1400 +bytes, and the multicast TTL (`multicast_ttl`) is zero. Each datagram is +self-contained, contains exactly one table, and uses an inline schema plus +table-local symbol dictionaries. Batches are split at row boundaries; +`QwpUdpDatagramTooLargeError` is raised before transmission when one row cannot +fit. `connectQwpNodeUdpSender()` and `connectQwpNodeUdp()` expose the same +transport from `qwp/node`. UDP provides no authentication, TLS, server or durable ACK, transactions, reconnection, compression, or store-and-forward. Local socket errors are delivered diff --git a/src/options.ts b/src/options.ts index 74f3726..2c6e966 100644 --- a/src/options.ts +++ b/src/options.ts @@ -212,6 +212,17 @@ type DeprecatedOptions = { * Recommended to use the same setting as the server, which also uses 127 by default. *
  • * + *
    + * UDP specific options + *
      + *
    • max_datagram_size: integer - Maximum encoded datagram size in bytes, defaults to 1400.
      + * A row that cannot fit a single datagram is rejected before transmission. It is also the default for + * auto_flush_bytes. Supported by the udp transport only; http, tcp and ws/wss reject it. + *
    • + *
    • multicast_ttl: integer - Multicast time-to-live for outgoing datagrams, from 0 to 255, defaults to 0.
      + * Supported by the udp transport only; http, tcp and ws/wss reject it. + *
    • + *
    */ class SenderOptions { protocol: string; diff --git a/src/qwp-node/client-config.ts b/src/qwp-node/client-config.ts index d4b6311..4e1519d 100644 --- a/src/qwp-node/client-config.ts +++ b/src/qwp-node/client-config.ts @@ -38,8 +38,8 @@ const RELOCATED_HINTS = new Map([ "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)"], + ["max_datagram_size", "(applies to the legacy udp transport only)"], + ["multicast_ttl", "(applies to the legacy udp transport only)"], ]); /** diff --git a/test/options.test.ts b/test/options.test.ts index 708c5d7..a0e5196 100644 --- a/test/options.test.ts +++ b/test/options.test.ts @@ -848,12 +848,18 @@ describe("Configuration string parser suite", function () { await expect( SenderOptions.fromConfig("udp::addr=host;tls_verify=on;"), ).rejects.toThrow("TLS is not supported for QWP UDP transport"); - // On ws/wss these are legacy keys, rejected by the QWP schema with the - // Java client's relocation hint. + // On ws/wss these are legacy keys, rejected by the QWP schema with a + // relocation hint. max_datagram_size and multicast_ttl are UDP-only -- http + // and tcp reject them too -- so the hint must name only udp, not all three. await expect( SenderOptions.fromConfig("ws::addr=host;max_datagram_size=1400;"), ).rejects.toThrow( - "unknown configuration key: max_datagram_size (applies to legacy http/tcp/udp transports only)", + "unknown configuration key: max_datagram_size (applies to the legacy udp transport only)", + ); + await expect( + SenderOptions.fromConfig("ws::addr=host;multicast_ttl=2;"), + ).rejects.toThrow( + "unknown configuration key: multicast_ttl (applies to the legacy udp transport only)", ); }); From e0b361148c856817719e6d903f2325c23dbb038c Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 25 Aug 2026 02:01:25 +0100 Subject: [PATCH 174/265] fix(qwp): count rows a reset-interrupted flush already published releaseStagedRows() returns 0 on a staging-generation mismatch, which is correct for retiring pending rows -- reset() has already zeroed those counters, so subtracting again would drive pendingRows negative (the bug 0a1fccf fixed). But that one return value also fed totalRowsPublished += sentRows, so a reset() landing while a flush awaited its publication boundary lost the count for rows whose frames had already entered the ingress session -- exactly what the field documents. The counter then skewed permanently low: a five-row flush interrupted this way put all five rows on the wire and reported totalRowsPublished 0. The published count and the retired count are different questions. The flush now takes the published count from its own snapshots -- the rows it sent, regardless of a concurrent generation bump -- and releaseStagedRows() keeps doing only the pending retirement. The same retired-count value was wrong for three siblings that shared it, all now reading the published count: the flush debug log, the deferred-transaction row tally (the open transaction still holds those rows), and the > 0 guard that counts a committed transaction. The no-reset path is unchanged, since there the two counts are equal. A test holds a flush at its publication boundary, drops a reset() between "frame entered the session" and "rows retired", and asserts all five rows reached the wire with totalRowsPublished at five and pendingRows at zero rather than negative. Co-Authored-By: Claude Opus 4.8 --- src/_qwp/sender.ts | 23 +++++++++--- test/qwp/sender.test.ts | 79 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 5 deletions(-) diff --git a/src/_qwp/sender.ts b/src/_qwp/sender.ts index 073137e..8265dee 100644 --- a/src/_qwp/sender.ts +++ b/src/_qwp/sender.ts @@ -2383,12 +2383,22 @@ export class QwpSender { if (publication) { await publication; } - const sentRows = this.releaseStagedRows(snapshots, generation); - this.totalRowsPublished += sentRows; + // These snapshot rows are exactly the ones whose frames entered the ingress + // session, so they count as published even when a concurrent reset() has + // since bumped the staging generation. releaseStagedRows() retires them from + // the pending counters, returning early across that reset so pendingRows is + // not driven negative -- how many rows were retired is a separate question + // from how many were sent, and only the latter feeds the published metrics. + const publishedRows = snapshots.reduce( + (count, snapshot) => count + snapshot.rows.length, + 0, + ); + this.releaseStagedRows(snapshots, generation); + this.totalRowsPublished += publishedRows; this.lastFlushTime = Date.now(); this.log( "debug", - `${deferCommit ? "Auto-flushing" : "Flushing"} ${sentRows} QWP row(s)${deferCommit ? " with commit deferred" : ""}`, + `${deferCommit ? "Auto-flushing" : "Flushing"} ${publishedRows} QWP row(s)${deferCommit ? " with commit deferred" : ""}`, ); if (!deferCommit && publishedSequence >= 0n) { this.lastCommitBoundarySequence = publishedSequence; @@ -2396,7 +2406,7 @@ export class QwpSender { if (deferCommit) { this.hasDeferredMessages = true; - this.deferredRowCount += sentRows; + this.deferredRowCount += publishedRows; if (response) { this.deferredAcks.push(response); // The server intentionally withholds this ACK until a later commit. @@ -2430,7 +2440,10 @@ export class QwpSender { if (!publicationOnly && deferredAcks.length > 0) { await Promise.all(deferredAcks); } - if (this.transactional && (closesDeferredTransaction || sentRows > 0)) { + if ( + this.transactional && + (closesDeferredTransaction || publishedRows > 0) + ) { this.totalTransactionsCommitted++; } if (this.options.awaitDurableAck && ack) { diff --git a/test/qwp/sender.test.ts b/test/qwp/sender.test.ts index e6a01c8..6641344 100644 --- a/test/qwp/sender.test.ts +++ b/test/qwp/sender.test.ts @@ -213,6 +213,59 @@ class DeferredWatermarkSession extends PublishingSession { } } +/** + * Holds a flush at its publication boundary: the frame is recorded and counted + * as sent, then the awaited promise stays pending until unblock(). This lets a + * test drop a reset() between "frame entered the session" and "rows retired". + */ +class HeldPublicationSession extends RecordingSession { + publishedRowCount = 0; + readonly publishCalled: Promise; + private signalPublishCalled!: () => void; + private release?: () => void; + + constructor() { + super(); + this.publishCalled = new Promise((resolve) => { + this.signalPublishCalled = resolve; + }); + } + + private hold( + tables: readonly QwpTableBuffer[], + options?: QwpIngressEncodeOptions, + ): Promise { + this.sends.push({ tables, options }); + for (const table of tables) this.publishedRowCount += table.rowCount; + const sequence = ++this.publishedFrameSequence; + if (!options?.deferCommit) this.acknowledgedFrameSequence = sequence; + this.signalPublishCalled(); + return new Promise((resolve) => { + this.release = resolve; + }); + } + + override publishTables( + tables: readonly QwpTableBuffer[], + options?: QwpIngressEncodeOptions, + ): Promise { + return this.hold(tables, options); + } + + override publishTablesDelta( + tables: readonly QwpTableBuffer[], + options?: Pick, + ): Promise { + return this.hold(tables, options); + } + + /** Lets the awaited publication boundary resolve. */ + unblock(): void { + this.release?.(); + this.release = undefined; + } +} + function column(table: QwpTableBuffer, name: string) { const result = table.columns.find((candidate) => candidate.name === name); if (!result) throw new Error(`missing column '${name}'`); @@ -329,6 +382,32 @@ describe("QWP high-level sender", () => { await sender.close(); }); + it("counts rows a reset-interrupted flush already published", async () => { + // reset() bumps the staging generation so a flush in flight will not retire + // its rows from the pending counters twice. That same early return also fed + // totalRowsPublished, so rows whose frames had already entered the session + // went uncounted forever -- the counter skewed permanently low. + const session = new HeldPublicationSession(); + const sender = new QwpSender(async () => session, { autoFlush: false }); + for (let value = 0; value < 5; value++) { + await sender.table("t").intColumn("v", value).atNow(); + } + expect(sender.metrics.totalRowsStaged).toBe(5); + + const flushing = sender.flush(); + await session.publishCalled; // the frame has entered the session + sender.reset(); // lands while the flush awaits its publication boundary + session.unblock(); + await flushing; + + expect(session.publishedRowCount).toBe(5); // all five reached the wire + expect(sender.metrics.totalRowsPublished).toBe(5); + expect(sender.metrics.totalRowsStaged).toBe(5); + // reset() zeroed the pending counter; the flush must not re-subtract it. + expect(sender.metrics.pendingRows).toBe(0); + await sender.close(); + }); + it("validates the byte auto-flush threshold", () => { const session = new RecordingSession(); expect( From 687913b88f1666bbff7373d987e61f8b6cdb1128 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 25 Aug 2026 02:16:54 +0100 Subject: [PATCH 175/265] fix(qwp): keep a parked append waiting through a self-healing trim fault scheduleMaintenance() rejected every parked appender with the maintenance failure and then, on the very next line, scheduled the retry that makes the rejection unnecessary. A background segment trim that briefly fails -- a read-only or full filesystem, a restarted maintenance worker -- therefore rejected a parked store-and-forward append with a retryable "could not trim QWP store-and-forward segment" error a few milliseconds into its append deadline, even though the retry self-heals about a second later and the identical append then succeeds. totalAppendTimeouts stays zero, so it is not the deadline error a caller watches for, and it contradicts the sf_dir wait contract QWP.md states: the journal ceiling is the one error a producer sees. The retried batch already releases parked appenders through signalCapacity() on success, and each appender keeps its own append deadline, so a permanent failure still ends in the typed append timeout rather than hanging. Dropping the reject leaves them parked for that retry. A released appender re-runs appendOnce() through enqueue(), which is serialized behind the maintenance batch, so it runs only after the batch has cleared the failure -- it never observes the stale one at assertReady(). The checkpoint sibling still rejects its waiters, correctly: that class has no retry outside durability "periodic", which is not the connect-string default. A test parks an append at capacity, fails the trim once, and asserts the append resolves when the retry frees space rather than being rejected, with totalAppendTimeouts still zero. Co-Authored-By: Claude Opus 4.8 --- src/qwp-node/file-replay-store.ts | 9 +++++- test/qwp/reconnect.test.ts | 49 +++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/src/qwp-node/file-replay-store.ts b/src/qwp-node/file-replay-store.ts index 1807b80..371190e 100644 --- a/src/qwp-node/file-replay-store.ts +++ b/src/qwp-node/file-replay-store.ts @@ -1384,7 +1384,14 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { `QWP store-and-forward background maintenance failed [directory=${this.directory}]`, error, ); - this.rejectCapacityWaiters(this.maintenanceFailure); + // Leave parked appenders waiting: maintenance self-heals on the retry + // scheduled below, whose signalCapacity() releases them, and each keeps + // its own append deadline. Rejecting here surfaced a retryable trim + // fault as the flush error even though the identical append succeeds a + // moment later -- the one error an sf_dir producer should see is the + // journal ceiling, i.e. its append deadline elapsing. A released + // appender re-runs appendOnce() through enqueue(), serialized behind + // this batch, so it never observes the not-yet-cleared failure. this.scheduleMaintenanceRetry(); }); }); diff --git a/test/qwp/reconnect.test.ts b/test/qwp/reconnect.test.ts index f700549..f9de5cb 100644 --- a/test/qwp/reconnect.test.ts +++ b/test/qwp/reconnect.test.ts @@ -4733,6 +4733,55 @@ describe("QWP Node file replay store", () => { await reopened.close(); }); + it("keeps a parked append waiting across a transient trim fault", async () => { + // The sibling checkpoint failure above rejects waiting appends because that + // class has no retry. Maintenance does retry, so a parked append must stay + // parked and be released when the retry frees capacity -- never rejected + // with the retryable trim error, which is not the deadline error a producer + // watches for. + const directory = await trackedDirectory(); + const store = new QwpNodeFileReplayStore({ + directory, + maxBytes: 66, + maxSegmentBytes: 1, + backpressurePolicy: QWP_SF_BACKPRESSURE_POLICY.WAIT, + appendDeadlineMs: 5_000, + }); + await store.load(); + await store.append({ frameSequence: 0n, payload: Uint8Array.of(1) }); + await store.append({ frameSequence: 1n, payload: Uint8Array.of(2) }); + + const blocked = store.append({ + frameSequence: 2n, + payload: Uint8Array.of(3), + }); + await vi.waitFor(() => expect(store.metrics.waitingAppends).toBe(1)); + + // Fail the trim that frees capacity once; the retry a second later uses the + // real implementation, so the fault is genuinely transient. + const unlink = vi + .spyOn(qwpSegmentMaintenanceWorker, "unlink") + .mockRejectedValueOnce( + Object.assign(new Error("EACCES: permission denied"), { + code: "EACCES", + }), + ); + + await store.acknowledgeThrough(0n); + + // The parked append survives the fault: the retry releases it rather than + // the failure rejecting it, and it never reaches its append deadline. + await expect(blocked).resolves.toBeUndefined(); + expect(unlink).toHaveBeenCalled(); + expect(store.metrics).toMatchObject({ + waitingAppends: 0, + totalAppendTimeouts: 0, + }); + + unlink.mockRestore(); + await store.close(); + }, 15_000); + it("waits for ACK trimming without blocking the acknowledgement queue", async () => { const directory = await trackedDirectory(); const store = new QwpNodeFileReplayStore({ From c61f26ee9e2d0843e6f8f08e0660d09ebd155eaa Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 25 Aug 2026 02:37:49 +0100 Subject: [PATCH 176/265] test(qwp): count only payload bytes in the sfa-multiprocess marker check markerCounts scanned the whole .sfa file for bytes in the A-Z range, so it also tallied framing bytes: the segment header ends in a microsecond wall-clock timestamp and each frame header carries a CRC32C. Whenever one of those bytes happened to equal 'A' (0x41) or 'B' (0x42) on a given run -- about 1.5% of the time per segment -- the durability assertion saw 321 instead of 320 and failed. The append path already rejects the reclaimed holder before it writes anything (assertReady throws QwpReplayStoreLockLostError ahead of the segment write), which the +1 rather than +64 discrepancy confirms, so no payload byte ever leaked; the flake was purely in the test helper. Walk the frame framing instead and count only payload bytes, which are pure marker fill by construction, making the check exact and deterministic. Co-Authored-By: Claude Opus 4.8 --- test/qwp/sfa-multiprocess.e2e.ts | 39 ++++++++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/test/qwp/sfa-multiprocess.e2e.ts b/test/qwp/sfa-multiprocess.e2e.ts index a2d557a..f5617c3 100644 --- a/test/qwp/sfa-multiprocess.e2e.ts +++ b/test/qwp/sfa-multiprocess.e2e.ts @@ -142,16 +142,47 @@ async function simulateLapsedHeartbeat(directory: string): Promise { await utimes(owner, when, when); } +// The store's on-disk segment layout, mirrored from +// src/qwp-node/file-replay-store.ts. A fixed 24-byte segment header precedes a +// run of frames, each an 8-byte header -- a CRC32C followed by a uint32 +// little-endian payload length -- then the payload. The rest of the fixed-size +// file is zero padding, so a frame whose header reads back as all zeroes marks +// the end of the written frames. +const SEGMENT_HEADER_SIZE = 24; +const FRAME_HEADER_SIZE = 8; + +/** + * Tallies the marker bytes across every frame *payload* in the slot's segments. + * + * It walks the frame framing rather than scanning the raw file, because the + * markers are only meaningful inside payloads: the segment header ends in a + * microsecond wall-clock timestamp and every frame header carries a CRC, and a + * whole-file byte scan would also count whichever of those framing bytes happen + * to land on a marker's ASCII code on a given run -- about a 1.5% chance per + * segment for 'A'/'B' -- turning this durability assertion flaky. Payload bytes + * are pure marker fill by construction, so counting only them is exact. + */ async function markerCounts( directory: string, ): Promise> { const counts: Record = {}; for (const file of await readdir(directory)) { if (!file.endsWith(".sfa")) continue; - for (const byte of await readFile(path.join(directory, file))) { - if (byte < 0x41 || byte > 0x5a) continue; - const marker = String.fromCharCode(byte); - counts[marker] = (counts[marker] ?? 0) + 1; + const bytes = await readFile(path.join(directory, file)); + let offset = SEGMENT_HEADER_SIZE; + while (offset + FRAME_HEADER_SIZE <= bytes.length) { + const payloadLength = bytes.readUInt32LE(offset + 4); + if (payloadLength === 0) break; // zero-filled tail: no more frames + const start = offset + FRAME_HEADER_SIZE; + const end = start + payloadLength; + if (end > bytes.length) break; + for (let index = start; index < end; index++) { + const byte = bytes[index]; + if (byte < 0x41 || byte > 0x5a) continue; + const marker = String.fromCharCode(byte); + counts[marker] = (counts[marker] ?? 0) + 1; + } + offset = end; } } return counts; From 0dfee20174dacc613507395a3b6668ee5acbfc75 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 25 Aug 2026 11:48:03 +0100 Subject: [PATCH 177/265] fix(qwp): reject an over-range ingress ACK instead of clamping it An over-range wire sequence was clamped to the newest in-flight frame, so an OK(seq) beyond the last frame sent retired every in-flight frame, advanced the watermark, and trimmed journal records the server never confirmed -- silently deleting unacknowledged data. The NACK path shared the clamp and charged the poison strike to the tail frame instead of the head. Reject an over-range sequence as QwpProtocolError, matching the null and negative guards above it and sitting before the OK/NACK branch so both paths are covered. A frame is logged before it is sent, so a conforming server can only acknowledge a sequence it has received; anything beyond the last frame sent is a protocol violation. The trims-wire-log test delivered each ACK before its frame was sent -- impossible in production and only survivable via the clamp -- so it now awaits the send first. Co-Authored-By: Claude Opus 4.8 --- .../reconnecting-ingress-connection.ts | 18 +++++--- test/qwp/reconnect.test.ts | 42 +++++++++++++++---- 2 files changed, 46 insertions(+), 14 deletions(-) diff --git a/src/_qwp/_internal/reconnecting-ingress-connection.ts b/src/_qwp/_internal/reconnecting-ingress-connection.ts index 73d03e8..49ff9d0 100644 --- a/src/_qwp/_internal/reconnecting-ingress-connection.ts +++ b/src/_qwp/_internal/reconnecting-ingress-connection.ts @@ -1291,11 +1291,19 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { ); } const highestWireIndex = this.wireFramesBase + this.wireFrames.length - 1; - const wireIndex = Number( - response.sequence > BigInt(highestWireIndex) - ? BigInt(highestWireIndex) - : response.sequence, - ); + if (response.sequence > BigInt(highestWireIndex)) { + // Reject an over-range sequence rather than clamping it, matching the null + // and negative guards above. A frame is logged here before it is sent, so + // a conforming server can only acknowledge a sequence it has received, + // never one beyond the last frame sent. Clamping a bogus over-range value + // onto the newest in-flight frame would retire every unacknowledged frame + // below it and delete journal records the server never confirmed -- the + // watermark must never advance past an unacknowledged frame. + throw new QwpProtocolError( + `QWP response sequence is beyond the last frame sent: ${response.sequence} > ${highestWireIndex}`, + ); + } + const wireIndex = Number(response.sequence); const localIndex = wireIndex - this.wireFramesBase; const frame = localIndex >= 0 ? this.wireFrames[localIndex] : undefined; if (!frame) { diff --git a/test/qwp/reconnect.test.ts b/test/qwp/reconnect.test.ts index f9de5cb..bbc9ab8 100644 --- a/test/qwp/reconnect.test.ts +++ b/test/qwp/reconnect.test.ts @@ -1837,14 +1837,16 @@ describe("QWP ingress reconnect and replay", () => { const payload = new Uint8Array(1024).fill(7); for (let index = 0; index < 200; index++) { - const publishing = session.publishFrame(payload); + // Await the send before delivering its ACK: a frame is logged before it + // is sent, so a real server never acknowledges a sequence beyond the last + // frame sent, and an over-range ACK is now rejected rather than clamped. + await session.publishFrame(payload); connection.receive(ingressResponse(QWP_STATUS.OK, BigInt(index))); - await publishing; } // Retaining the acknowledged prefix pinned every payload for the life of // the connection and made each ACK scan it three times over. - expect(wireLog().length).toBeLessThanOrEqual(2); + await vi.waitFor(() => expect(wireLog().length).toBeLessThanOrEqual(2)); expect( wireLog().reduce( (total, frame) => total + (frame.payload?.byteLength ?? 0), @@ -2641,19 +2643,41 @@ describe("QWP ingress reconnect and replay", () => { await session.close(); }); - it("clamps an ingress ACK to the highest wire sequence sent", async () => { + it("rejects an over-range ingress ACK instead of clamping it onto in-flight frames", async () => { const connection = new FakeConnection("primary"); const session = await QwpIngressSession.connect(async () => connection, { reconnect: { maxAttempts: 1 }, }); - const pending = session.sendFrame(Uint8Array.of(9)); - await vi.waitFor(() => expect(connection.sent).toHaveLength(1)); + const first = session.sendFrame(Uint8Array.of(9)); + const second = session.sendFrame(Uint8Array.of(8)); + await vi.waitFor(() => expect(connection.sent).toHaveLength(2)); + // Only wire sequences 0 and 1 were sent. Clamping 999 onto the newest + // in-flight frame would retire both frames and delete journal records the + // server never acknowledged, so an over-range ACK must be rejected. connection.receive(ingressResponse(QWP_STATUS.OK, 999n)); - await expect(pending).resolves.toMatchObject({ - status: QWP_STATUS.OK, - sequence: 0n, + await expect(first).rejects.toBeInstanceOf(QwpProtocolError); + await expect(second).rejects.toBeInstanceOf(QwpProtocolError); + expect(session.acknowledgedFrameSequence).toBe(-1n); + await session.close(); + }); + + it("rejects an over-range ingress NACK instead of charging the wrong frame", async () => { + const connection = new FakeConnection("primary"); + const session = await QwpIngressSession.connect(async () => connection, { + reconnect: { maxAttempts: 1 }, }); + const first = session.sendFrame(Uint8Array.of(9)); + const second = session.sendFrame(Uint8Array.of(8)); + await vi.waitFor(() => expect(connection.sent).toHaveLength(2)); + // Clamping this WRITE_ERROR onto the newest in-flight frame would charge the + // poison strike to the tail frame instead of the head. An over-range NACK is + // a protocol violation, so it must terminate rather than drive a retry. + connection.receive(ingressResponse(QWP_STATUS.WRITE_ERROR, 999n)); + + await expect(first).rejects.toBeInstanceOf(QwpProtocolError); + await expect(second).rejects.toBeInstanceOf(QwpProtocolError); + expect(session.metrics.totalNacks).toBe(0); await session.close(); }); From 2bf90ffde2a46ced0f84f1f5a6305b39ed239577 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 25 Aug 2026 11:55:48 +0100 Subject: [PATCH 178/265] fix(qwp): stop a stale advisory-lock holder from re-proving itself beat() guarded only on released and compromised, so a holder whose event loop stalled past STALE_AFTER_MS -- a frozen container, a stalled NFS mount, a long GC pause -- would resume and re-prove a lock it had already lost. The owner-record read and the mtime touch inside a beat are separate syscalls, and a contender's reclaim landing between them let the beat stamp the new owner's directory and reset provenAtMs, clearing the staleness fence so lost went back to false. One heartbeat later the rightful owner saw a drifted mtime and fenced itself off its own slot; meanwhile the un-fenced holder resumed appending, corrupting a journal two processes now shared. A contender reclaims a slot only once its mtime is stale, which is the same instant the holder's own lost rule fires. So guard the top of beat() on this.lost as well: a holder that has gone stale must not beat, and the mtime it declines to refresh keeps lost latched. Co-Authored-By: Claude Opus 4.8 --- src/qwp-node/advisory-lock.ts | 11 ++++++++++- test/qwp/reconnect.test.ts | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/src/qwp-node/advisory-lock.ts b/src/qwp-node/advisory-lock.ts index 7932f15..9c037b5 100644 --- a/src/qwp-node/advisory-lock.ts +++ b/src/qwp-node/advisory-lock.ts @@ -289,7 +289,16 @@ export class QwpNodeAdvisoryLock { } private async beat(): Promise { - if (this.released || this.compromised) return; + // A holder that has already gone stale must not re-prove itself. A + // contender reclaims a slot only once its mtime is stale, which is the same + // instant this object's own `lost` rule fires (both use STALE_AFTER_MS, and + // provenAtMs is stamped with the mtime). So a beat that resumes past the + // window may be racing a reclaim: the owner-record read and the mtime touch + // below are separate syscalls, and a reclaim landing between them would let + // this stamp the new owner's directory and reset the fence -- un-fencing a + // lock this process has already lost. Staying out once `lost` keeps that + // window closed; the mtime it declined to refresh keeps `lost` latched. + if (this.released || this.compromised || this.lost) return; try { const current = await stat(this.ownerPath); if (Math.trunc(current.mtimeMs) !== Math.trunc(this.ownerMtimeMs)) { diff --git a/test/qwp/reconnect.test.ts b/test/qwp/reconnect.test.ts index bbc9ab8..2c5933d 100644 --- a/test/qwp/reconnect.test.ts +++ b/test/qwp/reconnect.test.ts @@ -4548,6 +4548,40 @@ describe("QWP Node file replay store", () => { await expect(stat(ownerPath)).rejects.toMatchObject({ code: "ENOENT" }); }); + it("does not re-prove a slot lock that has already gone stale", async () => { + // A holder paused past the staleness window is already `lost` by its own + // rule, and a contender is entitled to reclaim its slot the moment the + // mtime is that old. The owner-record read and the mtime touch inside a + // beat are separate syscalls, so a reclaim landing between them let a + // resuming beat stamp the new owner's directory and reset provenAtMs -- + // clearing the fence and un-fencing a lock this process had already lost. + // One beat later the rightful owner saw a drifted mtime and fenced itself + // off its own slot. A stale holder must not beat at all. + const directory = await trackedDirectory(); + const lock = await QwpNodeAdvisoryLock.acquire(directory); + const beat = () => + (lock as unknown as { beat(): Promise }).beat.call(lock); + const ownerPath = join(directory, ".lock.owner"); + const stampedMtimeMs = (await stat(ownerPath)).mtimeMs; + + vi.useFakeTimers({ toFake: ["Date"] }); + try { + vi.setSystemTime(Date.now() + 20_000); + expect(lock.lost).toBe(true); + + await beat(); + + // The beat must not have re-proven ownership: the fence stays raised and + // the directory mtime is untouched, so it cannot have stamped a + // successor's directory either. + expect(lock.lost).toBe(true); + expect((await stat(ownerPath)).mtimeMs).toBe(stampedMtimeMs); + } finally { + vi.useRealTimers(); + } + await lock.release().catch(() => undefined); + }); + it("reclaims a slot whose owner heartbeat stopped", async () => { const directory = await trackedDirectory(); const ownerPath = join(directory, ".lock.owner"); From ea1bfb04f02565e05dc26347aff83114fc6b46b6 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 25 Aug 2026 12:12:20 +0100 Subject: [PATCH 179/265] fix(qwp): stop a caller agent on wss from silently disabling tls_verify Both QWP agent-selection sites picked a caller-supplied agent with `instanceof http.Agent` and then skipped building the TLS agent entirely, so a wss producer that passed any agent lost certificate verification without a warning: an https.Agent({rejectUnauthorized:false}), or a plain https.Agent under NODE_TLS_REJECT_UNAUTHORIZED=0, connected to an untrusted certificate even with tls_verify=on. The agent is the WebSocket upgrade's sole TLS channel, so dropping tls_verify/tls_ca dropped verification. https.Agent extends http.Agent, so the same check also admitted a plain http.Agent onto a wss socket; it was accepted at construction and failed at the first flush with ERR_INVALID_PROTOCOL, after rows were already taken. Select the caller agent scheme-aware, matching the ILP stdlib transport: an https.Agent for wss, a plain http.Agent for ws, otherwise fall back to the scheme's verifying default. And reject a caller agent combined with tls_verify/tls_ca/tls_roots at construction rather than silently dropping the verification those keys asked for -- TLS belongs on the agent itself. Co-Authored-By: Claude Opus 4.8 --- QWP.md | 7 +++ src/options.ts | 26 ++++++++- src/qwp-node/client-config.ts | 12 +++- src/sender.ts | 22 ++++++-- test/qwp/wss-tls-security.test.ts | 94 +++++++++++++++++++++++++++++++ 5 files changed, 154 insertions(+), 7 deletions(-) diff --git a/QWP.md b/QWP.md index 48bf523..e5e76cd 100644 --- a/QWP.md +++ b/QWP.md @@ -1205,6 +1205,13 @@ callbacks, custom agents, store-and-forward, sender/session settings, and pool sizes may be passed as the second argument. The whole string is still validated before overrides are applied, matching the Java builder's fail-fast behavior. +A custom `wss://` agent is the WebSocket upgrade's sole TLS channel, so it +carries its own certificate verification and cannot be combined with +`tls_verify`, `tls_roots`, or `tls_roots_password` — that combination is +rejected rather than silently dropping either. Configure verification on the +agent instead, and pass an `https.Agent` for `wss` (a plain `http.Agent` is for +`ws`). + Set `lazy_connect=on` to tolerate an unavailable cluster during startup. In the JavaScript client, ingress uses memory replay by default, or persistent replay when `sf_dir` is present, with `initial_connect_retry=async`; egress uses diff --git a/src/options.ts b/src/options.ts index 2c6e966..cb4ef83 100644 --- a/src/options.ts +++ b/src/options.ts @@ -31,14 +31,36 @@ function qwpConfig(options: SenderOptions): QwpNodeClientOptions | undefined { return qwpConfigs.get(options); } +/** + * @ignore + * Selects a caller-supplied ILP-style agent for a QWP WebSocket upgrade only + * when it matches the scheme: an https.Agent for wss, a plain http.Agent (not + * an https.Agent, which extends it) for ws. A bare http.Agent would fail a wss + * upgrade with ERR_INVALID_PROTOCOL and an https.Agent would attempt TLS on a + * plain ws socket, so a mismatch -- or a non-http agent such as an undici Agent + * -- yields undefined and the caller falls back to the scheme's default. + */ +export function selectQwpSchemeAgent( + agent: unknown, + secure: boolean, +): http.Agent | undefined { + if (secure) { + return agent instanceof https.Agent ? agent : undefined; + } + return agent instanceof http.Agent && !(agent instanceof https.Agent) + ? agent + : undefined; +} + function resolveQwpConfig( options: SenderOptions, configString: string, ): QwpNodeClientOptions { const configuredWebSocket = options.qwp?.webSocket; const { storeAndForward, ...webSocketOverrides } = configuredWebSocket ?? {}; - let agent = webSocketOverrides.agent; - if (!agent && options.agent instanceof http.Agent) agent = options.agent; + const agent = + webSocketOverrides.agent ?? + selectQwpSchemeAgent(options.agent, options.protocol === WSS); return resolveQwpNodeClientConfig(configString, { webSocket: { ...webSocketOverrides, agent }, storeAndForward, diff --git a/src/qwp-node/client-config.ts b/src/qwp-node/client-config.ts index 4e1519d..4957c51 100644 --- a/src/qwp-node/client-config.ts +++ b/src/qwp-node/client-config.ts @@ -144,6 +144,16 @@ export function resolveQwpNodeClientConfig( const authorization = createAuthorization(parsed.values); const configuredAgent = createTlsAgent(parsed); + const callerAgent = extraOptions.webSocket?.agent; + if (callerAgent && configuredAgent) { + // configuredAgent is built only when tls_verify/tls_roots were set, and a + // caller agent is the WebSocket upgrade's sole TLS channel. Preferring the + // caller agent here silently dropped the verification those keys asked for. + // Reject the ambiguous combination rather than quietly discarding either. + throw new Error( + "a custom QWP WebSocket agent cannot be combined with tls_verify, tls_roots, or tls_roots_password; configure TLS on the agent itself", + ); + } const common = { ...extraOptions.webSocket, @@ -155,7 +165,7 @@ export function resolveQwpNodeClientConfig( optionalPositiveInteger(value("auth_timeout_ms"), "auth_timeout_ms"), clientId: extraOptions.webSocket?.clientId ?? value("client_id"), authorization: extraOptions.webSocket?.authorization ?? authorization, - agent: extraOptions.webSocket?.agent ?? configuredAgent, + agent: callerAgent ?? configuredAgent, }; const ingressReconnect = parseIngressReconnect(parsed.values); diff --git a/src/sender.ts b/src/sender.ts index 5ad95ed..3a1ab43 100644 --- a/src/sender.ts +++ b/src/sender.ts @@ -1,12 +1,12 @@ // @ts-check import { readFileSync } from "node:fs"; -import * as http from "node:http"; import * as https from "node:https"; import { log, Logger } from "./logging"; import { SenderOptions, ExtraOptions, qwpConfig, + selectQwpSchemeAgent, UDP, WS, WSS, @@ -589,9 +589,23 @@ function createConfiguredQwpSender( } const configuredWebSocket = options.qwp?.webSocket ?? {}; const configuredSender = options.qwp?.sender ?? {}; - let agent = configuredWebSocket.agent; - if (!agent && options.agent instanceof http.Agent) agent = options.agent; - if (!agent && options.protocol === WSS) { + const secure = options.protocol === WSS; + let agent = + configuredWebSocket.agent ?? selectQwpSchemeAgent(options.agent, secure); + if (agent) { + // A caller-supplied agent is the WebSocket upgrade's sole TLS channel. + // Applying tls_verify/tls_ca would silently override the agent the caller + // built; dropping them silently discards the verification they asked for. + // Reject the ambiguous combination rather than doing either quietly. + if ( + secure && + (options.tls_ca !== undefined || options.tls_verify !== undefined) + ) { + throw new Error( + "a custom QWP WebSocket agent cannot be combined with tls_verify or tls_ca; configure TLS on the agent itself", + ); + } + } else if (secure) { agent = new https.Agent({ ca: options.tls_ca ? readFileSync(options.tls_ca) : undefined, rejectUnauthorized: options.tls_verify ?? true, diff --git a/test/qwp/wss-tls-security.test.ts b/test/qwp/wss-tls-security.test.ts index c783fc6..d6ea8b6 100644 --- a/test/qwp/wss-tls-security.test.ts +++ b/test/qwp/wss-tls-security.test.ts @@ -1,10 +1,13 @@ import { readFileSync } from "node:fs"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import * as http from "node:http"; +import * as https from "node:https"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import * as qwpNode from "../../src/qwp/node"; import { Sender } from "../../src/sender"; +import { SenderOptions, qwpConfig } from "../../src/options"; /** * A wss:// producer must verify the server certificate, and its authorization @@ -89,6 +92,53 @@ describe("QWP wss:: connect-string verifies the server certificate", () => { const options = qwpNode.parseQwpNodeClientConfig("wss::addr=localhost;"); expect(options.ingress.agent).toBeUndefined(); }); + + it("rejects a caller agent combined with tls_verify", () => { + // The agent is the upgrade's sole TLS channel, so preferring it silently + // dropped the verification tls_verify asked for. Reject, don't drop. + expect(() => + qwpNode.parseQwpNodeClientConfig("wss::addr=localhost;tls_verify=on;", { + webSocket: { agent: new https.Agent() }, + }), + ).toThrow(/custom QWP WebSocket agent cannot be combined/); + }); + + it("rejects a caller agent combined with tls_roots", () => { + expect(() => + qwpNode.parseQwpNodeClientConfig( + `wss::addr=localhost;tls_roots=${CA_PATH};`, + { webSocket: { agent: new https.Agent() } }, + ), + ).toThrow(/custom QWP WebSocket agent cannot be combined/); + }); + + it("keeps a caller agent when no TLS keys are set", () => { + // Without tls_verify/tls_roots the caller owns TLS through their agent, so + // it passes through unchanged rather than being rejected. + const agent = new https.Agent(); + const options = qwpNode.parseQwpNodeClientConfig("wss::addr=localhost;", { + webSocket: { agent }, + }); + expect(options.ingress.agent).toBe(agent); + }); + + it("promotes a top-level https agent onto the wss connect string", async () => { + const agent = new https.Agent(); + const options = await SenderOptions.fromConfig("wss::addr=localhost;", { + agent, + }); + expect(qwpConfig(options)?.ingress.agent).toBe(agent); + }); + + it("does not promote a plain http agent onto wss", async () => { + // https.Agent extends http.Agent, so the old instanceof http.Agent test + // admitted a bare http.Agent that fails a wss upgrade with + // ERR_INVALID_PROTOCOL. It is ignored now, leaving node's verifying default. + const options = await SenderOptions.fromConfig("wss::addr=localhost;", { + agent: new http.Agent(), + }); + expect(qwpConfig(options)?.ingress.agent).toBeUndefined(); + }); }); describe("QWP programmatic wss sender applies TLS and authorization", () => { @@ -116,6 +166,22 @@ describe("QWP programmatic wss sender applies TLS and authorization", () => { return spy.mock.calls[0][0]; } + /** + * Constructs a wss Sender, stubbing createQwpNodeSender so a construction + * that fails to reject does not open a real socket. For the throwing cases. + */ + function constructWss(options: Record): void { + vi.spyOn(qwpNode, "createQwpNodeSender").mockReturnValue({ + reset() {}, + } as unknown as qwpNode.QwpSender); + new Sender({ + protocol: "wss", + host: "localhost", + port: 9000, + ...options, + } as never); + } + it("builds a verifying https agent with the configured root CA", () => { const tls = agentTlsOptions(ingressFor({ tls_ca: CA_PATH }).agent); expect(tls.rejectUnauthorized).toBe(true); @@ -133,6 +199,34 @@ describe("QWP programmatic wss sender applies TLS and authorization", () => { ).toBe(false); }); + it("keeps a caller https agent for the wss upgrade", () => { + const agent = new https.Agent(); + expect(ingressFor({ agent }).agent).toBe(agent); + }); + + it("does not admit a plain http agent to a wss upgrade", () => { + // A bare http.Agent would fail the wss upgrade with ERR_INVALID_PROTOCOL + // after at()/atNow() already accepted rows. It is ignored, leaving the + // verifying default agent in place instead. + const ingress = ingressFor({ agent: new http.Agent() }); + expect(ingress.agent).toBeInstanceOf(https.Agent); + expect(agentTlsOptions(ingress.agent).rejectUnauthorized).toBe(true); + }); + + it("rejects a caller agent combined with tls_verify", () => { + // Passing an agent alongside tls_verify used to silently drop tls_verify, + // letting an insecure agent connect with verification requested on. + expect(() => + constructWss({ agent: new https.Agent(), tls_verify: false }), + ).toThrow(/custom QWP WebSocket agent cannot be combined/); + }); + + it("rejects a caller agent combined with tls_ca", () => { + expect(() => + constructWss({ agent: new https.Agent(), tls_ca: CA_PATH }), + ).toThrow(/custom QWP WebSocket agent cannot be combined/); + }); + it("encodes Basic credentials as username:password, in that order", () => { const authorization = ingressFor({ username: "alice", From 3a0b28605a9feba7d0834a6002d7efdb1ad5d9d2 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 25 Aug 2026 13:19:42 +0100 Subject: [PATCH 180/265] fix(qwp): bound the egress delta dictionary a RESULT_BATCH declares readDeltaDictionary allocated up to MAX_CONNECTION_SYMBOLS (8,388,608) strings, bounded by that entry cap alone and never by the wire bytes that declared them. A zero-length entry costs one decompressed byte, so ~300 Zstd-compressed bytes decompressed to 8.4M entries and allocated 8.4M empty strings -- ~140 MB of heap and ~0.9 s of blocked event loop -- before any column was read, halting all ingestion in the process and OOMing a heap-capped container from a single frame. CACHE_RESET empties the array, so the cost recurs per frame indefinitely. The delta dictionary precedes the grid in the frame body, so its read cannot be physically relocated after the grid cell cap. Instead bound the declared entry count to the frame's payload length before the entry loop allocates: each entry occupies at least one wire byte, so a count above the payload was manufactured by Zstd, not transmitted. This makes the work proportional to the wire, as commit a9a1948's cell cap did for the grid and as the local symbol dictionary already is against its row count. Co-Authored-By: Claude Opus 4.8 --- src/_qwp/_core/result-batch.ts | 20 +++++++++- test/qwp/egress.test.ts | 73 ++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 2 deletions(-) diff --git a/src/_qwp/_core/result-batch.ts b/src/_qwp/_core/result-batch.ts index d1026b9..e762091 100644 --- a/src/_qwp/_core/result-batch.ts +++ b/src/_qwp/_core/result-batch.ts @@ -1359,7 +1359,7 @@ export class QwpResultBatchDecoder { : message.body; const reader = new QwpByteReader(body); const deltaMode = (message.flags & QWP_FLAG_DELTA_SYMBOL_DICTIONARY) !== 0; - if (deltaMode) this.readDeltaDictionary(reader); + if (deltaMode) this.readDeltaDictionary(reader, message.body.length); const tableNameLength = readCount( reader, @@ -1859,7 +1859,10 @@ export class QwpResultBatchDecoder { }; } - private readDeltaDictionary(reader: QwpByteReader): void { + private readDeltaDictionary( + reader: QwpByteReader, + framePayloadBytes: number, + ): void { const start = readCount( reader, MAX_CONNECTION_SYMBOLS, @@ -1880,6 +1883,19 @@ export class QwpResultBatchDecoder { `symbol dictionary exceeds ${MAX_CONNECTION_SYMBOLS} entries`, ); } + // Each declared entry occupies at least one byte in the frame that carried + // it, so a count above the frame's payload length was manufactured by Zstd + // decompression rather than transmitted: a few hundred wire bytes could + // otherwise declare millions of zero-length entries and allocate them all + // here, before any column is read. Bound the entry count to the wire, as + // the grid cell cap does, and as the local dictionary is bounded by its row + // count. Checked before the loop, because reading an entry is what + // allocates. + if (count > framePayloadBytes) { + throw new QwpProtocolError( + `delta symbol dictionary declares ${count} entries, above the ${framePayloadBytes}-byte frame payload`, + ); + } for (let index = 0; index < count; index++) { const length = readCount(reader, reader.remaining, "symbol length"); this.symbolDictionary.push(reader.readUtf8(length, "symbol")); diff --git a/test/qwp/egress.test.ts b/test/qwp/egress.test.ts index fec95eb..252c071 100644 --- a/test/qwp/egress.test.ts +++ b/test/qwp/egress.test.ts @@ -211,6 +211,50 @@ function compressedAllNullBatch(rows: number, columns: number): Uint8Array { return encodeQwpFrame(payload.toUint8Array(), QWP_FLAG_ZSTD, 1); } +/** + * A compressed RESULT_BATCH declaring `count` zero-length delta dictionary + * entries. Each costs one decompressed byte, so Zstd RLE packs millions of + * them into a few hundred wire bytes -- the delta-dictionary analogue of the + * all-NULL grid flood above. + */ +function deltaDictionaryFloodBatch(count: number): Uint8Array { + const header = new QwpByteWriter(); + writeQwpVarint(header, 0); // delta dictionary start + writeQwpVarint(header, count); // delta dictionary count + const headerBytes = Array.from(header.toUint8Array()); + + const grid = new QwpByteWriter(); + writeQwpVarint(grid, 0); // table name + writeQwpVarint(grid, 0); // rows + writeQwpVarint(grid, 0); // columns -- an empty, in-cap grid + const gridBytes = Array.from(grid.toUint8Array()); + + const ZSTD_BLOCK_MAX = 131072; + const blocks: ({ raw: number[] } | { rle: [number, number] })[] = [ + { raw: headerBytes }, + ]; + for (let remaining = count; remaining > 0; ) { + const run = Math.min(remaining, ZSTD_BLOCK_MAX); + blocks.push({ rle: [0x00, run] }); // `run` zero-length symbol entries + remaining -= run; + } + blocks.push({ raw: gridBytes }); + + const body = rleZstdFrame( + blocks, + headerBytes.length + count + gridBytes.length, + ); + const payload = new QwpByteWriter(); + payload.writeUint8(QWP_EGRESS_MESSAGE.RESULT_BATCH).writeBigUint64(1n); + writeQwpVarint(payload, 0); // batch sequence + payload.writeBytes(body); + return encodeQwpFrame( + payload.toUint8Array(), + QWP_FLAG_DELTA_SYMBOL_DICTIONARY | QWP_FLAG_ZSTD, + 1, + ); +} + function scalarResultBatch(): Uint8Array { const payload = new QwpByteWriter(); payload.writeUint8(QWP_EGRESS_MESSAGE.RESULT_BATCH).writeBigUint64(0n); @@ -722,6 +766,35 @@ describe("QWP result batch decoder", () => { expect(batch.get(0, 0)).toBeNull(); }); + it("bounds the delta symbol dictionary a RESULT_BATCH declares", () => { + // readDeltaDictionary ran before the grid cell cap and was bounded only by + // MAX_CONNECTION_SYMBOLS, never by the wire. A zero-length entry costs one + // decompressed byte, so a few hundred Zstd-compressed bytes declared 8.4M + // of them and allocated 8.4M empty strings -- ~140 MB and ~0.9 s of blocked + // event loop -- before any column was read. + const wire = deltaDictionaryFloodBatch(8_388_608); + expect(wire.byteLength).toBeLessThan(2_000); + const message = decodeQwpEgressMessage(wire); + if (message.kind !== "result-batch") throw new Error("unexpected message"); + + const before = process.memoryUsage().heapUsed; + expect(() => new QwpResultBatchDecoder().decode(message)).toThrow( + /above the \d+-byte frame payload/, + ); + // Rejected before the entry loop -- reading one is what allocates. + expect(process.memoryUsage().heapUsed - before).toBeLessThan(50e6); + }); + + it("still decodes a delta dictionary that fits its frame", () => { + // A real delta carries actual symbols, so its entry count never exceeds the + // bytes that transmitted it: the bound only rejects counts Zstd manufactured. + const message = decodeQwpEgressMessage(firstResultBatch()); + if (message.kind !== "result-batch") throw new Error("unexpected message"); + const batch = new QwpResultBatchDecoder().decode(message); + expect(batch.get(0, 2)).toBe("alpha"); + expect(batch.get(1, 2)).toBe("beta"); + }); + it("requires a bounded, single Zstd frame", () => { const decodeBody = (body: Uint8Array) => { const bytes = compressedIntResultBatch(); From f2764bd6e9d2a3a6eef66ceec60d18d433ec9270 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 25 Aug 2026 13:37:25 +0100 Subject: [PATCH 181/265] perf(qwp): drop two quadratic/redundant costs from the encoder hot path (a) The non-delta ("full", encode.symbolDictionary="full") symbol column resolved each row against its inline dictionary with Array.prototype.indexOf, in both columnPayloadSize and writeColumn -- O(rows x distinct), measured quadratic: 4k/8k/16k/32k high-cardinality rows took 31/127/399/1652 ms versus 3.6/6.8/12.9/24.4 ms in delta mode (67x at 32k). Build the inline dictionary once with a Map keyed by symbol text, the same O(1) lookup QwpSymbolDictionary.getOrAdd already uses for delta mode, and reuse the resolved row IDs. The wire output is unchanged: entries stay in first-seen order and IDs index into them. Full-mode encoding is now linear, ~92x faster at 32k rows and on par with delta mode. (b) utf8Length() was encodeUtf8(v).length, allocating and discarding a Uint8Array per call -- and the encoder sizes every VARCHAR cell before writing it, so each was UTF-8 encoded twice. Count the bytes instead: Node's native Buffer.byteLength (measured 22.7 ns vs 221 ns), reached through globalThis so the browser build still compiles, with an allocation-free scan as the runtime-neutral fallback. Both match encodeUtf8() byte-for-byte, including the 3-byte replacement for an unpaired surrogate, so measured sizes never disagree with the bytes written. Co-Authored-By: Claude Opus 4.8 --- src/_qwp/_core/bytes.ts | 38 +++++++++++++++++++++++++- src/_qwp/_core/ingress.ts | 57 ++++++++++++++++++++++++++++----------- test/qwp/core.test.ts | 52 +++++++++++++++++++++++++++++++++++ 3 files changed, 130 insertions(+), 17 deletions(-) diff --git a/src/_qwp/_core/bytes.ts b/src/_qwp/_core/bytes.ts index eb67ae3..b18eebc 100644 --- a/src/_qwp/_core/bytes.ts +++ b/src/_qwp/_core/bytes.ts @@ -7,8 +7,44 @@ export function encodeUtf8(value: string): Uint8Array { return UTF8_ENCODER.encode(value); } +// Node's Buffer.byteLength counts UTF-8 bytes natively, ~10x faster than +// encoding into a Uint8Array only to read .length and discard it (measured +// 22.7 ns vs 221 ns; the encoder UTF-8-encodes every VARCHAR cell twice -- +// once to size, once to write). Reached through globalThis so the browser +// build, which has no Node types, still compiles and falls back to the +// allocation-free scan below. Both count exactly what encodeUtf8() writes, +// including the 3-byte replacement for an unpaired surrogate, so measured sizes +// never disagree with the bytes emitted. +const nodeByteLength = ( + globalThis as { + Buffer?: { byteLength(value: string, encoding: "utf8"): number }; + } +).Buffer?.byteLength; + export function utf8Length(value: string): number { - return encodeUtf8(value).length; + if (nodeByteLength) return nodeByteLength(value, "utf8"); + let bytes = 0; + for (let index = 0; index < value.length; index++) { + const code = value.charCodeAt(index); + if (code < 0x80) { + bytes += 1; + } else if (code < 0x800) { + bytes += 2; + } else if (code >= 0xd800 && code <= 0xdbff) { + // A high surrogate paired with a low surrogate is one 4-byte code point; + // an unpaired one becomes the 3-byte replacement character. + const next = value.charCodeAt(index + 1); + if (next >= 0xdc00 && next <= 0xdfff) { + bytes += 4; + index++; + } else { + bytes += 3; + } + } else { + bytes += 3; + } + } + return bytes; } export function decodeUtf8(value: Uint8Array): string { diff --git a/src/_qwp/_core/ingress.ts b/src/_qwp/_core/ingress.ts index 305d8e4..5aa4331 100644 --- a/src/_qwp/_core/ingress.ts +++ b/src/_qwp/_core/ingress.ts @@ -113,6 +113,39 @@ function symbolId(value: unknown, dictionary: QwpSymbolDictionary): number { return id; } +interface InlineSymbolDictionary { + /** Distinct symbol texts in first-seen order, matching Set iteration. */ + readonly entries: readonly string[]; + /** The dictionary index of each row's value, in row order. */ + readonly rowIds: readonly number[]; +} + +// A non-delta ("full") symbol column carries its own inline dictionary. +// Resolving each row against it with Array.prototype.indexOf is O(rows x +// distinct) -- measured quadratic, 67x slower than delta mode at 32k rows. A +// Map keyed by text makes each lookup O(1), the same fix +// QwpSymbolDictionary.getOrAdd already applies in delta mode. symbolText() runs +// once per value here, so measureColumn and writeColumn no longer resolve each +// value twice. +function inlineSymbolDictionary( + values: readonly unknown[], +): InlineSymbolDictionary { + const entries: string[] = []; + const indexByText = new Map(); + const rowIds = new Array(values.length); + for (let row = 0; row < values.length; row++) { + const text = symbolText(values[row]); + let id = indexByText.get(text); + if (id === undefined) { + id = entries.length; + indexByText.set(text, id); + entries.push(text); + } + rowIds[row] = id; + } + return { entries, rowIds }; +} + function nullCount(column: QwpColumnBuffer): number { let count = 0; for (const value of column.nulls) if (value) count++; @@ -207,14 +240,10 @@ function columnPayloadSize( } return size; } - const dictionary = [ - ...new Set(column.values.map((value) => symbolText(value))), - ]; - size += qwpVarintSize(dictionary.length); - for (const value of dictionary) size += qwpStringSize(value); - for (const value of column.values) { - size += qwpVarintSize(dictionary.indexOf(symbolText(value))); - } + const { entries, rowIds } = inlineSymbolDictionary(column.values); + size += qwpVarintSize(entries.length); + for (const entry of entries) size += qwpStringSize(entry); + for (const id of rowIds) size += qwpVarintSize(id); return size; } @@ -380,14 +409,10 @@ function writeColumn( } return; } - const dictionary = [ - ...new Set(column.values.map((value) => symbolText(value))), - ]; - writeQwpVarint(writer, dictionary.length); - for (const value of dictionary) writeQwpString(writer, value); - for (const value of column.values) { - writeQwpVarint(writer, dictionary.indexOf(symbolText(value))); - } + const { entries, rowIds } = inlineSymbolDictionary(column.values); + writeQwpVarint(writer, entries.length); + for (const entry of entries) writeQwpString(writer, entry); + for (const id of rowIds) writeQwpVarint(writer, id); return; } case QWP_COLUMN_TYPE.VARCHAR: diff --git a/test/qwp/core.test.ts b/test/qwp/core.test.ts index d3e68d4..170fb11 100644 --- a/test/qwp/core.test.ts +++ b/test/qwp/core.test.ts @@ -40,6 +40,7 @@ import { readQwpVarint, writeQwpVarint, } from "../../src/qwp"; +import { encodeUtf8, utf8Length } from "../../src/_qwp/_core/bytes"; function dataView(bytes: Uint8Array): DataView { return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); @@ -87,6 +88,25 @@ describe("QWP browser-safe byte core", () => { ), ).toThrow(/uint64/i); }); + + it("measures UTF-8 byte length identically to encoding it", () => { + // utf8Length feeds frame sizing, so it must count exactly what encodeUtf8() + // writes -- including the 3-byte replacement for an unpaired surrogate -- + // rather than diverge and mis-size a VARCHAR column. + for (const value of [ + "", + "order_12345", + "héllo", + "€uro", + "smile 😀 mix", + "\uD800", // lone high surrogate + "\uDC00", // lone low surrogate + "a\uD800b", // high surrogate not followed by a low one + "😀", // a valid surrogate pair + ]) { + expect(utf8Length(value)).toBe(encodeUtf8(value).length); + } + }); }); describe("QWP browser durable-ACK negotiation", () => { @@ -395,6 +415,38 @@ describe("QWP ingress codec", () => { ]); }); + it("encodes a full inline symbol dictionary with dense first-seen IDs", () => { + // Without a connection dictionary the encoder emits a per-column dictionary + // and one ID per row. Resolving each row used to be O(rows x distinct) via + // Array.indexOf; a Map keyed by text makes it linear without changing the + // bytes -- the dictionary stays in first-seen order and IDs index into it. + const table = new QwpTableBuffer("t"); + for (const symbol of ["a", "b", "a", "c", "b"]) { + table.getOrCreateColumn("s", QWP_COLUMN_TYPE.SYMBOL)!.values.push(symbol); + table.nextRow(); + } + const frame = decodeQwpFrame(encodeQwpIngressFrame([table])); + const reader = new QwpByteReader(frame.payload); + expect(reader.readUtf8(Number(readQwpVarint(reader)))).toBe("t"); + expect(readQwpVarint(reader)).toBe(5n); // rows + expect(readQwpVarint(reader)).toBe(1n); // columns + expect(reader.readUtf8(Number(readQwpVarint(reader)))).toBe("s"); + expect(reader.readUint8()).toBe(QWP_COLUMN_TYPE.SYMBOL); + expect(reader.readUint8()).toBe(0); // no nulls + + const entries: string[] = []; + const dictSize = Number(readQwpVarint(reader)); + for (let index = 0; index < dictSize; index++) { + entries.push(reader.readUtf8(Number(readQwpVarint(reader)))); + } + expect(entries).toEqual(["a", "b", "c"]); + + const ids: number[] = []; + for (let row = 0; row < 5; row++) ids.push(Number(readQwpVarint(reader))); + expect(ids).toEqual([0, 1, 0, 2, 1]); + reader.expectEnd(); + }); + it("rolls back tentative symbols when frame encoding fails", () => { const dictionary = new QwpSymbolDictionary(); const table = new QwpTableBuffer("broken"); From d7b2fdcfdc48ab6e7379aab89bd0abe89f82dd0d Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 25 Aug 2026 14:14:28 +0100 Subject: [PATCH 182/265] fix: validate the timestamp unit even when the value is nullish timestampColumn's nullish early-return (issue #28) sat above the unit check. The unit is only validated inside writeTimestamp, which runs only for a value that is written, so timestampColumn("c", 1000, "s") threw "Unknown timestamp unit: s" while timestampColumn("c", null, "s") returned silently on every protocol version -- a bad constant reported only on the rows that happened to carry a value, the exact hazard the scale check in SenderBufferV3.decimalColumn was hoisted to avoid. Hoist the unit validity check above the nullish return, matching that principle. The ns/BigInt rule stays below it: it constrains the value's type, and a null value omits the column, so null with unit "ns" is omitted rather than rejected -- consistent with issue #28. The base-class decimalColumnText/decimalColumn stubs skip a nullish value too (consistent with the documented v1 arrayColumn), but their @throws tags still described the v3 validation the stubs never run. Correct them to state what the stubs actually do: reject any real value as unsupported, omit a nullish one. Co-Authored-By: Claude Opus 4.8 --- src/buffer/base.ts | 48 ++++++++++++++++++++++++-------------- test/sender.buffer.test.ts | 40 +++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 17 deletions(-) diff --git a/src/buffer/base.ts b/src/buffer/base.ts index 5d2957f..f16e777 100644 --- a/src/buffer/base.ts +++ b/src/buffer/base.ts @@ -328,6 +328,8 @@ abstract class SenderBufferBase implements SenderBuffer { * * @returns {SenderBuffer} Returns with a reference to this buffer. * + * @throws {Error} If `unit` is not one of `'ns'`, `'us'`, or `'ms'` (checked + * even when `value` is null or undefined). * @throws {Error} If `value` is not an integer or `BigInt`. * @throws {Error} If `unit` is `'ns'` but `value` is not a `BigInt`. */ @@ -337,6 +339,14 @@ abstract class SenderBufferBase implements SenderBuffer { unit: TimestampUnit = "us", ): SenderBuffer { this.validateColumnCall(name); + // The unit describes how to read the timestamp, not this row's value, so a + // bad unit is rejected before the value is: otherwise it is only reported + // on rows that carry a value and stays silent on the ones that omit it. + // (Same principle as the scale check in SenderBufferV3.decimalColumn; the + // ns/BigInt rule below stays value-dependent, as null omits the column.) + if (unit !== "ns" && unit !== "us" && unit !== "ms") { + throw new Error(`Unknown timestamp unit: ${unit}`); + } // A null or undefined value omits the column entirely (see issue #28). if (this.isNullOrUndefined(value)) { return this; @@ -631,14 +641,18 @@ abstract class SenderBufferBase implements SenderBuffer { * * Use it to insert into DECIMAL database columns. * + * Decimals are not supported by protocol v1/v2, so this base implementation + * rejects any actual value. A null or undefined value omits the column + * entirely (stored as NULL), consistent with the other column methods. + * Protocol v3 overrides this with a validating implementation. + * * @param {string} name - Column name. - * @param {string | number} value - The decimal value to write. - * - Accepts either a `number` or a `string` containing a valid decimal representation. - * - String values should follow standard decimal notation (e.g., `"123.45"` or `"-0.001"`). - * @returns {Sender} Returns with a reference to this buffer. - * @throws Error If decimals are not supported by the buffer implementation, or validation fails. - * Possible validation errors: - * - The provided string is not a valid decimal representation. + * @param {string | number | null | undefined} value - The decimal value to + * write. Only null or undefined is accepted here (which skips the column); + * any actual value throws. + * @returns {SenderBuffer} Returns with a reference to this buffer. + * @throws {Error} Indicating decimals are not supported in protocol v1/v2, + * unless the value is null or undefined. */ decimalColumnText( name: string, @@ -657,19 +671,19 @@ abstract class SenderBufferBase implements SenderBuffer { * * Use it to insert into DECIMAL database columns. * + * Decimals are not supported by protocol v1/v2, so this base implementation + * rejects any actual value. A null or undefined value omits the column + * entirely (stored as NULL), consistent with the other column methods. + * Protocol v3 overrides this with a validating implementation. + * * @param {string} name - Column name. - * @param {bigint | Int8Array} unscaled - The unscaled integer portion of the decimal value. - * - If a `bigint` is provided, it will be converted automatically. - * - If an `Int8Array` is provided, it must contain the two’s complement representation - * of the unscaled value in **big-endian** byte order. - * - An empty `Int8Array` represents a `NULL` value. + * @param {bigint | Int8Array | null | undefined} unscaled - The unscaled + * integer portion of the decimal value. Only null or undefined is accepted + * here (which skips the column); any actual value throws. * @param {number} scale - The number of fractional digits (the scale) of the decimal value. * @returns {SenderBuffer} Returns with a reference to this buffer. - * @throws {Error} If decimals are not supported by the buffer implementation, or validation fails. - * Possible validation errors: - * - `unscaled` length is not between 0 and 32 bytes. - * - `scale` is not between 0 and 76. - * - `unscaled` contains invalid bytes. + * @throws {Error} Indicating decimals are not supported in protocol v1/v2, + * unless the value is null or undefined. */ decimalColumn( name: string, diff --git a/test/sender.buffer.test.ts b/test/sender.buffer.test.ts index 032d9c0..9527ba7 100644 --- a/test/sender.buffer.test.ts +++ b/test/sender.buffer.test.ts @@ -574,6 +574,11 @@ describe("Sender message builder test suite (anything not covered in client inte expect(() => build().decimalColumn("d", value, 999)).toThrow( "Scale must be between 0 and 76", ); + // Nor does the timestamp unit: a bad unit is reported even when the + // value is omitted, rather than only on rows that carry one. + expect(() => build().timestampColumn("ts", value, "s" as "us")).toThrow( + "Unknown timestamp unit: s", + ); } // A column set before any table is still rejected. @@ -708,6 +713,41 @@ describe("Sender message builder test suite (anything not covered in client inte await sender.close(); }); + it("rejects a bad timestamp unit even when the value is nullish, on every version", async function () { + for (const version of ["1", "2", "3"] as const) { + const build = () => + new Sender({ + protocol: "tcp", + protocol_version: version, + host: "host", + auto_flush: false, + init_buf_size: 1024, + }); + + // A bad unit used to be reported only inside writeTimestamp, which never + // runs for an omitted value, so it stayed silent on nullish rows. + for (const value of [null, undefined] as const) { + expect(() => + build() + .table("t") + .timestampColumn("ts", value, "weeks" as "us"), + ).toThrow("Unknown timestamp unit: weeks"); + } + + // A valid unit still omits a null value (issue #28); `ns` with a null + // value is likewise omitted, not rejected for not being a BigInt. + const sender = build(); + await sender + .table("t") + .timestampColumn("skippedNs", null, "ns") + .timestampColumn("skippedMs", undefined, "ms") + .intColumn("kept", 1) + .atNow(); + expect(bufferContent(sender)).toBe("t kept=1i\n"); + await sender.close(); + } + }); + it("supports timestamp field as number for 'us' and 'ms' units with protocol v1", async function () { const sender = new Sender({ protocol: "tcp", From afc24fdd9fff39b8ce2d1f0fff41f189a2f3d4f4 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 25 Aug 2026 14:42:39 +0100 Subject: [PATCH 183/265] fix(qwp): install the default logger for ws::/wss:: connect strings resolveQwpConfig() resolved the QWP sender's log to `options.log ?? options.qwp?.sender?.log` and set it as an own key, so a bare ws::/wss:: connect string with no extraOptions produced `log: undefined`. resolveQwpNodeClientConfig() spreads that object last and QwpSender falls back to a no-op sink for an undefined log, so a connect string silenced every sender-level message -- the discarded-rows and uncommitted-transaction warnings QWP.md and README document, plus two errors -- while programmatic ws, udp::, http:: and tcp:: all emit through the default console logger. Fall back to that same default logger last, mirroring the Sender's own `this.log = options.log ?? log`, so the resolved config always carries a real logger. Co-Authored-By: Claude Opus 4.8 --- src/options.ts | 14 ++++++++------ test/options.test.ts | 9 +++++++++ 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/src/options.ts b/src/options.ts index cb4ef83..a19ab98 100644 --- a/src/options.ts +++ b/src/options.ts @@ -4,7 +4,7 @@ import { Agent } from "undici"; import * as http from "http"; import * as https from "https"; -import { Logger } from "./logging"; +import { log, Logger } from "./logging"; import { fetchJson, isBoolean, isInteger } from "./utils"; import { DEFAULT_REQUEST_TIMEOUT } from "./transport/http/base"; import { resolveQwpNodeClientConfig } from "./qwp-node/client-config"; @@ -64,13 +64,15 @@ function resolveQwpConfig( return resolveQwpNodeClientConfig(configString, { webSocket: { ...webSocketOverrides, agent }, storeAndForward, - // The top-level logger still wins, but falling back to the QWP one rather - // than to undefined matters: resolveQwpNodeClientConfig() spreads this - // object last, so an explicit undefined overwrote a logger the caller had - // configured and left the sender with a no-op sink. + // The top-level logger wins, then the QWP-specific one, then the default + // console logger -- never undefined. resolveQwpNodeClientConfig() spreads + // this object last, so an explicit `log: undefined` overwrote a configured + // logger, and with no logger anywhere the QWP sender falls back to a no-op + // sink: a ws::/wss:: connect string then silenced every warning and error + // the other transports emit. sender: { ...options.qwp?.sender, - log: options.log ?? options.qwp?.sender?.log, + log: options.log ?? options.qwp?.sender?.log ?? log, }, ingressSession: options.qwp?.session, }); diff --git a/test/options.test.ts b/test/options.test.ts index a0e5196..6768e72 100644 --- a/test/options.test.ts +++ b/test/options.test.ts @@ -5,6 +5,7 @@ import { Agent } from "undici"; import { Sender } from "../src/sender"; import { SenderOptions } from "../src"; import { qwpConfig } from "../src/options"; +import { log } from "../src/logging"; import { MockHttp } from "./util/mockhttp"; import { readFileSync } from "fs"; @@ -1295,6 +1296,14 @@ describe("Configuration string parser suite", function () { qwp: { sender: { log: senderLog } }, }); expect(qwpConfig(both)?.sender?.log).toBe(console.log); + + // With no logger anywhere -- a bare ws::/wss:: connect string and no + // extraOptions -- the default console logger is installed, not the no-op + // sink, so it emits the same warnings and errors the other transports do. + const neither = await SenderOptions.fromConfig("ws::addr=host:9000;"); + expect(qwpConfig(neither)?.sender?.log).toBe(log); + const secure = await SenderOptions.fromConfig("wss::addr=host:9000;"); + expect(qwpConfig(secure)?.sender?.log).toBe(log); }); it("can take a custom agent", async function () { From e7a03e2556b8e533edefeb6f741ca5efd57fc6ce Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 25 Aug 2026 14:42:49 +0100 Subject: [PATCH 184/265] fix(qwp): wait out a self-healing trim fault met by a fresh append 687913b keeps an already-parked store-and-forward append waiting through a transient background trim fault, but a fresh append arriving while the fault is parked meets it at assertReady() instead, and appendWithBackpressure re-threw it because it is not a QwpReplayStoreFullError. So under backpressurePolicy "wait" a flush rejected with the retryable "could not trim..." error a few milliseconds into a 30 s append deadline, even though the maintenance retry self-heals ~1 s later and the identical append then succeeds -- contradicting the sf_dir contract that the journal ceiling is the one error a producer sees. A newly-arriving capacity waiter hit the same fault through waitForCapacity's reject. Treat the parked maintenance failure like the journal ceiling on the append path: wait it out within the same deadline, released by the retry's signalCapacity(), and bounded by the typed append timeout if it never heals. checkpointFailure still propagates -- its self-heal does not signal capacity, so waiting would hang rather than resolve. Co-Authored-By: Claude Opus 4.8 --- src/qwp-node/file-replay-store.ts | 34 ++++++++++++++++------ test/qwp/reconnect.test.ts | 48 +++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 8 deletions(-) diff --git a/src/qwp-node/file-replay-store.ts b/src/qwp-node/file-replay-store.ts index 371190e..10ee08f 100644 --- a/src/qwp-node/file-replay-store.ts +++ b/src/qwp-node/file-replay-store.ts @@ -1099,10 +1099,23 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { await this.enqueue(() => this.appendOnce(record, bytes)); return; } catch (error) { - if (!(error instanceof QwpReplayStoreFullError)) throw error; + const full = error instanceof QwpReplayStoreFullError; + // A background segment trim that transiently failed self-heals on its + // scheduled retry, whose signalCapacity() releases parked appenders. A + // fresh append hits that parked failure at assertReady() -- but it must + // not surface as the flush error either, so wait it out within the same + // append deadline as the journal ceiling. A permanent fault still ends + // in the typed append timeout. (checkpointFailure is not released by + // signalCapacity, so it still propagates; see scheduleMaintenance.) + const healingTrim = error === this.maintenanceFailure; + if (!full && !healingTrim) throw error; if (this.backpressurePolicy === QWP_SF_BACKPRESSURE_POLICY.ERROR) { throw error; } + const requiredBytes = + error instanceof QwpReplayStoreFullError + ? error.requiredBytes + : bytes.byteLength; if (!stalled) { stalled = true; deadline = Date.now() + this.appendDeadlineMs; @@ -1113,11 +1126,15 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { this.totalAppendTimeouts++; throw new QwpReplayStoreAppendTimeoutError( this.maxBytes, - error.requiredBytes, + requiredBytes, this.appendDeadlineMs, ); } - await this.waitForCapacity(capacityGeneration, remainingMs, error); + await this.waitForCapacity( + capacityGeneration, + remainingMs, + requiredBytes, + ); } } } @@ -1545,14 +1562,15 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { private waitForCapacity( capacityGeneration: number, timeoutMs: number, - full: QwpReplayStoreFullError, + requiredBytes: number, ): Promise { if (this.checkpointFailure) { return Promise.reject(this.checkpointFailure); } - if (this.maintenanceFailure) { - return Promise.reject(this.maintenanceFailure); - } + // maintenanceFailure is deliberately not rejected here: it self-heals on + // its scheduled retry, whose signalCapacity() releases this waiter, exactly + // as scheduleMaintenance() leaves the already-parked appender waiting. A + // permanent fault is bounded by the append deadline below. if (capacityGeneration !== this.capacityGeneration) { return Promise.resolve(); } @@ -1564,7 +1582,7 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { reject( new QwpReplayStoreAppendTimeoutError( this.maxBytes, - full.requiredBytes, + requiredBytes, this.appendDeadlineMs, ), ); diff --git a/test/qwp/reconnect.test.ts b/test/qwp/reconnect.test.ts index 2c5933d..67252ab 100644 --- a/test/qwp/reconnect.test.ts +++ b/test/qwp/reconnect.test.ts @@ -4840,6 +4840,54 @@ describe("QWP Node file replay store", () => { await store.close(); }, 15_000); + it("waits out a self-healing trim fault met by a fresh append, not only a parked one", async () => { + // 687913b keeps an already-parked append waiting through a transient trim + // fault. An append that arrives while the fault is parked meets it at + // assertReady() instead of in the capacity wait, and used to reject the + // flush with the retryable trim error there -- it must wait it out too. + const directory = await trackedDirectory(); + const store = new QwpNodeFileReplayStore({ + directory, + maxBytes: 66, + maxSegmentBytes: 1, + backpressurePolicy: QWP_SF_BACKPRESSURE_POLICY.WAIT, + appendDeadlineMs: 5_000, + }); + await store.load(); + await store.append({ frameSequence: 0n, payload: Uint8Array.of(1) }); + await store.append({ frameSequence: 1n, payload: Uint8Array.of(2) }); + + // Fail the next trim once, then acknowledge to drive it: the maintenance + // failure is parked and a retry is scheduled ~1 s later with the real + // unlink. No append is waiting yet, so nothing is parked in the capacity + // queue. + const unlink = vi + .spyOn(qwpSegmentMaintenanceWorker, "unlink") + .mockRejectedValueOnce( + Object.assign(new Error("EACCES: permission denied"), { + code: "EACCES", + }), + ); + await store.acknowledgeThrough(0n); + await vi.waitFor(() => expect(unlink).toHaveBeenCalled()); + + // Issued only now, the append meets the parked failure at assertReady(). + // It must still resolve when the retry frees space, never reaching its + // deadline nor surfacing the retryable trim error. + const fresh = store.append({ + frameSequence: 2n, + payload: Uint8Array.of(3), + }); + await expect(fresh).resolves.toBeUndefined(); + expect(store.metrics).toMatchObject({ + waitingAppends: 0, + totalAppendTimeouts: 0, + }); + + unlink.mockRestore(); + await store.close(); + }, 15_000); + it("waits for ACK trimming without blocking the acknowledgement queue", async () => { const directory = await trackedDirectory(); const store = new QwpNodeFileReplayStore({ From 3854ca635e7601352974509ba0ea7a5606693d08 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 25 Aug 2026 14:42:59 +0100 Subject: [PATCH 185/265] perf(qwp): slice a sparse table in ascending order without rescanning sliceRows() turns a row index into a value index by counting the non-null entries before it, and 45a380a made that O(1) only for a column with no nulls. A column with a single null took the else arm, which recounted from row zero on every call -- O(start) per column. encodeUdpDatagrams and the ingress batch-cap bisector both walk a table in ascending slices, so that made a flush quadratic again: measured through the public udp:: Sender API, one column null on 30% of rows took 198/572/1956 ms at 32k/64k/128k rows, versus 96/170/349 ms dense, with zero 1 ms heartbeat ticks during the 1956 ms stall. Memoize each column's non-null offset before `start` and advance it only across the newly covered rows when `start` moves forward, so an ascending walk is linear; a backward or post-mutation slice falls back to a from-zero recount. The slices are byte-identical, and dense columns keep their O(1) shortcut. The sparse arm now scales like the dense one. Co-Authored-By: Claude Opus 4.8 --- src/_qwp/_core/table.ts | 66 +++++++++++++++++++++++++++++++++-------- test/qwp/core.test.ts | 65 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+), 12 deletions(-) diff --git a/src/_qwp/_core/table.ts b/src/_qwp/_core/table.ts index 3b2ce54..b1a793c 100644 --- a/src/_qwp/_core/table.ts +++ b/src/_qwp/_core/table.ts @@ -40,6 +40,13 @@ export class QwpTableBuffer { private readonly columnList: QwpColumnBuffer[] = []; private readonly columnsByName = new Map(); private rows = 0; + // Memoizes the non-null value offset each column's slice starts from, reused + // while a caller walks the table in ascending `start` slices. See sliceRows(). + private sliceValueOffsets?: { + rows: number; + start: number; + offsets: number[]; + }; constructor(name: string, maxNameLength = QWP_MAX_TABLE_NAME_LENGTH) { if (!Number.isSafeInteger(maxNameLength) || maxNameLength < 1) { @@ -200,22 +207,21 @@ export class QwpTableBuffer { const result = new QwpTableBuffer(this.name, this.maxNameLength); result.rows = end - start; - for (const column of this.columnList) { - // `values` holds non-null entries only, so a row index becomes a value - // index by skipping the nulls before it. A column with no nulls at all - // needs no scan, and that is the common case -- without this shortcut - // every slice costs O(start) per column, which makes a caller that walks - // a table in ascending slices quadratic in its row count all over again. - let valueStart: number; + // `values` holds non-null entries only, so a row index becomes a value + // index by skipping the nulls before it. A column with no nulls at all + // needs no scan (the common case), and for a sparse one the offset before + // `start` is memoized and advanced across slices rather than recounted from + // row 0 -- otherwise a caller walking the table in ascending slices + // (encodeUdpDatagrams, the ingress batch-cap search) is quadratic in its + // row count all over again. + const valueStarts = this.nonNullValueOffsets(start); + for (let index = 0; index < this.columnList.length; index++) { + const column = this.columnList[index]; + const valueStart = valueStarts[index]; let valueEnd: number; if (column.values.length === column.size) { - valueStart = start; valueEnd = end; } else { - valueStart = 0; - for (let row = 0; row < start; row++) { - if (!column.nulls[row]) valueStart++; - } valueEnd = valueStart; for (let row = start; row < end; row++) { if (!column.nulls[row]) valueEnd++; @@ -236,10 +242,46 @@ export class QwpTableBuffer { return result; } + /** + * The non-null value count in rows `[0, start)` for each column -- the value + * index at which a slice starting at `start` begins. Recomputing this from + * row 0 on every call makes sliceRows() O(start), so the previous result is + * reused and advanced only over the newly covered rows when `start` moves + * forward, keeping an ascending walk linear. A dense column needs no scan; + * its value index equals the row index. + */ + private nonNullValueOffsets(start: number): number[] { + const columns = this.columnList; + const cache = this.sliceValueOffsets; + const reuse = + cache !== undefined && + cache.rows === this.rows && + cache.offsets.length === columns.length && + cache.start <= start; + const from = reuse ? cache.start : 0; + const offsets = reuse ? cache.offsets : new Array(columns.length); + for (let index = 0; index < columns.length; index++) { + const column = columns[index]; + if (column.values.length === column.size) { + offsets[index] = start; + continue; + } + const nulls = column.nulls; + let offset = reuse ? offsets[index] : 0; + for (let row = from; row < start; row++) { + if (!nulls[row]) offset++; + } + offsets[index] = offset; + } + this.sliceValueOffsets = { rows: this.rows, start, offsets }; + return offsets; + } + reset(): void { this.columnList.length = 0; this.columnsByName.clear(); this.rows = 0; + this.sliceValueOffsets = undefined; } } diff --git a/test/qwp/core.test.ts b/test/qwp/core.test.ts index 170fb11..7736c96 100644 --- a/test/qwp/core.test.ts +++ b/test/qwp/core.test.ts @@ -334,6 +334,71 @@ describe("QWP ingress codec", () => { expect(indexReads).toBeLessThan(rows / 10); }); + it("slices a sparse column incrementally across an ascending walk", () => { + // A column with nulls cannot use the dense shortcut, so its value offset + // was recounted from row zero on every slice -- O(start) per call, and + // O(rows^2) across a bisector that walks the table in ascending slices. The + // offset is now memoized and advanced only over newly covered rows, so each + // null flag is read a bounded number of times over the whole walk. + const table = new QwpTableBuffer("events"); + const rows = 4_000; + for (let row = 0; row < rows; row++) { + const column = table.getOrCreateColumn("value", QWP_COLUMN_TYPE.LONG)!; + if (row % 3 === 0) column.nulls[row] = true; + else column.values.push(BigInt(row)); + table.nextRow(); + } + const column = table.columns[0]; + let indexReads = 0; + column.nulls = new Proxy(column.nulls, { + get(target, key, receiver) { + if (typeof key === "string" && /^\d+$/.test(key)) indexReads++; + return Reflect.get(target, key, receiver); + }, + }); + + const step = 50; + for (let start = 0; start < rows; start += step) { + table.sliceRows(start, Math.min(rows, start + step)); + } + + // Amortized O(1) reads per row (advance the offset, count the slice, copy + // the bitmap), so the walk is linear. The from-zero rescan was ~rows^2/step + // -- about 160k reads here -- so this bound only holds with the memo. + expect(indexReads).toBeLessThan(rows * 4); + }); + + it("slices identically whether or not the offset memo is warm", () => { + // The memo must never change what a slice returns: an ascending walk warms + // it, a later out-of-order slice falls back to a from-zero recount, and + // both must match a fresh table's slice byte for byte. + const build = () => { + const table = new QwpTableBuffer("events"); + for (let row = 0; row < 40; row++) { + const column = table.getOrCreateColumn("v", QWP_COLUMN_TYPE.LONG)!; + if (row % 4 === 0) column.nulls[row] = true; + else column.values.push(BigInt(row)); + table.nextRow(); + } + return table; + }; + const warmed = build(); + for (let start = 0; start < 40; start += 10) + warmed.sliceRows(start, start + 10); + + for (const [start, end] of [ + [12, 27], + [0, 40], + [5, 6], + [30, 40], + ] as const) { + const fromWarm = warmed.sliceRows(start, end).columns[0]; + const fromFresh = build().sliceRows(start, end).columns[0]; + expect(fromWarm.values).toEqual(fromFresh.values); + expect(fromWarm.nulls).toEqual(fromFresh.nulls); + } + }); + it("encodes a compacted LONG column with an LSB-first null bitmap", () => { const table = new QwpTableBuffer("t"); table.getOrCreateColumn("a", QWP_COLUMN_TYPE.LONG)!.values.push(1n); From 92bda6f264ae94f0f02d2136638cdba426679f30 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 25 Aug 2026 15:01:37 +0100 Subject: [PATCH 186/265] fix(qwp): validate the server decimal scale and drain views before a cache reset Two server-supplied egress values were trusted. (a) The DECIMAL scale is a single wire byte, read unbounded on both the materialized and zero-copy view paths while the adjacent GEOHASH precision is validated, the encoder bounds it (QWP_DECIMAL_MAX_SCALE: 18/38/76), and a byte of 255 decodes to a value off by up to 10^237. Bound it and raise QwpProtocolError like GEOHASH does. (b) A server-initiated CACHE_RESET cleared the connection symbol dictionary in place immediately, while delta-mode result views alias that array and resolve their SYMBOL cells lazily inside the view callback -- so a reset arriving mid-callback turned live cells into undefined, with no error. The client-initiated reset already drains in-flight views through resetForReplay(); do the same on the server route before clearing. CACHE_RESET was handled but wholly untested; both paths now have coverage. Co-Authored-By: Claude Opus 4.8 --- src/_qwp/_core/result-batch.ts | 31 +++++++++- src/_qwp/egress-session.ts | 9 +++ test/qwp/egress.test.ts | 105 +++++++++++++++++++++++++++++++++ 3 files changed, 143 insertions(+), 2 deletions(-) diff --git a/src/_qwp/_core/result-batch.ts b/src/_qwp/_core/result-batch.ts index e762091..753ddd5 100644 --- a/src/_qwp/_core/result-batch.ts +++ b/src/_qwp/_core/result-batch.ts @@ -991,6 +991,33 @@ function fixedTypeWidth(type: QwpColumnType): number { } } +// The scale the server sends is a single byte, so it must be bounded like the +// encoder bounds it (QwpTableBuffer.setDecimalScale) and QWP_DECIMAL_MAX_SCALE +// exports it. An unchecked 255 decodes to a value off by up to 10^237. +function decimalMaxScale(type: QwpColumnType): number { + switch (type) { + case QWP_COLUMN_TYPE.DECIMAL64: + return 18; + case QWP_COLUMN_TYPE.DECIMAL128: + return 38; + case QWP_COLUMN_TYPE.DECIMAL256: + return 76; + default: + throw new TypeError(`QWP type 0x${type.toString(16)} is not decimal`); + } +} + +function readDecimalScale(reader: QwpByteReader, type: QwpColumnType): number { + const scale = reader.readUint8("decimal scale"); + const maximum = decimalMaxScale(type); + if (scale > maximum) { + throw new QwpProtocolError( + `decimal scale out of range: ${scale} (max ${maximum})`, + ); + } + return scale; +} + function unsignedLittleEndianValue( bytes: Uint8Array, offset = 0, @@ -1459,7 +1486,7 @@ export class QwpResultBatchDecoder { case QWP_COLUMN_TYPE.DECIMAL64: case QWP_COLUMN_TYPE.DECIMAL128: case QWP_COLUMN_TYPE.DECIMAL256: { - layout.scale = reader.readUint8("decimal scale"); + layout.scale = readDecimalScale(reader, type); this.readFixedView( reader, layout, @@ -1808,7 +1835,7 @@ export class QwpResultBatchDecoder { case QWP_COLUMN_TYPE.DECIMAL64: case QWP_COLUMN_TYPE.DECIMAL128: case QWP_COLUMN_TYPE.DECIMAL256: { - scale = reader.readUint8("decimal scale"); + scale = readDecimalScale(reader, schema.type); const bytes = schema.type === QWP_COLUMN_TYPE.DECIMAL64 ? 8 diff --git a/src/_qwp/egress-session.ts b/src/_qwp/egress-session.ts index c97cef2..5e7e766 100644 --- a/src/_qwp/egress-session.ts +++ b/src/_qwp/egress-session.ts @@ -1031,6 +1031,15 @@ export class QwpEgressSession implements QwpEgressQueryControl { this.resolveServerInfo(message); break; case "cache-reset": + // Delta-mode views alias the decoder's symbol dictionary and + // resolve their cells lazily inside the view callback, so + // clearing it in place mid-callback turns live SYMBOL cells into + // undefined. Drain in-flight views first -- delivering them + // against the dictionary they were decoded with -- exactly as the + // client-initiated reset does through resetForReplay(). + if (this.active?.usesViews) { + await this.active.waitForViewDrain(); + } this.decoder.applyCacheReset(message.resetMask); break; case "result-batch": { diff --git a/test/qwp/egress.test.ts b/test/qwp/egress.test.ts index 252c071..c52e3ba 100644 --- a/test/qwp/egress.test.ts +++ b/test/qwp/egress.test.ts @@ -15,6 +15,7 @@ import { QWP_MAX_COLUMNS_PER_TABLE, QWP_MAX_ZSTD_DECOMPRESSED_SIZE, QWP_QUERY_FLAG_RESET_DICTIONARY, + QWP_RESET_MASK_DICTIONARY, QWP_STATUS, QwpBinaryConnection, QwpByteReader, @@ -139,6 +140,34 @@ function resultEnd(requestId = 0n, totalRows = 3n): Uint8Array { return encodeQwpFrame(payload.toUint8Array()); } +function cacheReset(mask: number): Uint8Array { + const payload = new QwpByteWriter(); + payload.writeUint8(QWP_EGRESS_MESSAGE.CACHE_RESET).writeUint8(mask); + return encodeQwpFrame(payload.toUint8Array()); +} + +/** A one-row RESULT_BATCH of a single DECIMAL column carrying `scale`. */ +function decimalBatch(type: number, scale: number, words: number): Uint8Array { + const payload = new QwpByteWriter(); + payload.writeUint8(QWP_EGRESS_MESSAGE.RESULT_BATCH).writeBigUint64(0n); + writeQwpVarint(payload, 0); // batch sequence + writeQwpVarint(payload, 0); // empty delta dictionary start + writeQwpVarint(payload, 0); // empty delta dictionary count + writeQwpVarint(payload, 0); // table name + writeQwpVarint(payload, 1); // rows + writeQwpVarint(payload, 1); // columns + writeString(payload, "d"); + payload.writeUint8(type); + payload.writeUint8(0); // no nulls + payload.writeUint8(scale); // scale byte, unvalidated on the wire + for (let word = 0; word < words; word++) payload.writeBigInt64(0n); + return encodeQwpFrame( + payload.toUint8Array(), + QWP_FLAG_DELTA_SYMBOL_DICTIONARY, + 1, + ); +} + function compressedIntResultBatch(requestId = 0n): Uint8Array { const payload = new QwpByteWriter(); payload.writeUint8(QWP_EGRESS_MESSAGE.RESULT_BATCH).writeBigUint64(requestId); @@ -795,6 +824,36 @@ describe("QWP result batch decoder", () => { expect(batch.get(1, 2)).toBe("beta"); }); + it("rejects a decimal scale byte the encoder would never send", () => { + // The scale is a single wire byte; unchecked, a 255 decodes to a value off + // by up to 10^237. Bound it like the adjacent GEOHASH precision and the + // encoder (QWP_DECIMAL_MAX_SCALE: 18/38/76), on both decode paths. + for (const [type, words, max] of [ + [QWP_COLUMN_TYPE.DECIMAL64, 1, 18], + [QWP_COLUMN_TYPE.DECIMAL128, 2, 38], + [QWP_COLUMN_TYPE.DECIMAL256, 4, 76], + ] as const) { + const decodeAt = (scale: number) => { + const message = decodeQwpEgressMessage( + decimalBatch(type, scale, words), + ); + if (message.kind !== "result-batch") throw new Error("unexpected"); + return message; + }; + expect(() => + new QwpResultBatchDecoder().decode(decodeAt(max + 1)), + ).toThrow(/decimal scale out of range/); + // The zero-copy view path reads the same byte. + expect(() => + new QwpResultBatchDecoder().decodeView(decodeAt(255)), + ).toThrow(/decimal scale out of range/); + // The maximum the encoder allows still decodes. + expect(() => + new QwpResultBatchDecoder().decode(decodeAt(max)), + ).not.toThrow(); + } + }); + it("requires a bounded, single Zstd frame", () => { const decodeBody = (body: Uint8Array) => { const bytes = compressedIntResultBatch(); @@ -1304,6 +1363,52 @@ describe("QwpEgressSession", () => { await session.close(); }); + it("does not clear the delta symbol dictionary under a live view callback", async () => { + // A server-initiated CACHE_RESET cleared the connection symbol dictionary + // in place immediately. Delta-mode views alias that array and resolve their + // cells lazily, so a reset arriving mid-callback turned live SYMBOL cells to + // undefined. The reset must drain in-flight views first, as its + // client-initiated sibling does. + const connection = new FakeConnection(); + const session = new QwpEgressSession(connection); + connection.receive(serverInfo()); + + let enterHandler!: () => void; + const handlerEntered = new Promise((resolve) => { + enterHandler = resolve; + }); + let releaseHandler!: () => void; + const handlerReleased = new Promise((resolve) => { + releaseHandler = resolve; + }); + const before: unknown[] = []; + const after: unknown[] = []; + const query = await session.queryViews("select * from x", async (batch) => { + // Column 2 is the delta SYMBOL column [alpha, beta] with ids [0, 1, 0]. + for (let row = 0; row < 3; row++) + before.push(batch.row(row).getSymbol(2)); + enterHandler(); + await handlerReleased; + for (let row = 0; row < 3; row++) after.push(batch.row(row).getSymbol(2)); + }); + + connection.receive(firstResultBatch(query.requestId)); + await handlerEntered; + + // Inject the reset while the callback is parked reading the aliased dict. + connection.receive(cacheReset(QWP_RESET_MASK_DICTIONARY)); + connection.receive(resultEnd(query.requestId, 3n)); + await Promise.resolve(); + await Promise.resolve(); + + releaseHandler(); + await query.completion; + + expect(before).toEqual(["alpha", "beta", "alpha"]); + expect(after).toEqual(["alpha", "beta", "alpha"]); + await session.close(); + }); + it("defaults to Java-compatible unbounded credit and allows a bounded override", async () => { const connection = new FakeConnection(); const session = new QwpEgressSession(connection); From de2c6f69e7b6ecc6f82cc19739c70a53adef5f5d Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 25 Aug 2026 16:05:41 +0100 Subject: [PATCH 187/265] perf(qwp): lazy-load the QWP Node subsystem from the root entry sender.ts value-imported ./qwp/node, which statically pulls in ws, node:dgram and node:os, and referenced it from the Sender constructor so it could not be tree-shaken. require("@questdb/nodejs-client") therefore eagerly loaded the whole QWP Node subsystem -- measured 105 kB -> 920 kB of package bytes and +36% cold load -- for every http/tcp consumer, and made the root entry throw MODULE_NOT_FOUND wherever ws was unresolvable. Require ./qwp/node lazily, only when a ws/wss/udp Sender is built, through the package's own subpath so both the ESM and CJS builds resolve their matching artifact via the "exports" require condition. createRequire keeps it off the root's static graph; a synchronous constructor still gets the module. Confirmed against the built package: require(root) loads neither ws, dgram nor os, and a ws/wss/udp sender lazily loads and works, both ESM and CJS. A dist e2e guard pins this in both formats -- the root must load with the QWP Node subsystem absent, then load it on the first ws/wss/udp sender -- so the graph cannot silently regrow. The synchronous require resolves the built artifact, so the few source suites that build a QWP sender through the root Sender warm the cache first with the exported preloadQwpNode(), sharing the live source module their spies target. Co-Authored-By: Claude Opus 4.8 --- src/sender.ts | 42 +++++++++++++++++++----- test/options.test.ts | 7 +++- test/qwp/dist.e2e.ts | 33 +++++++++++++++++++ test/qwp/sender-node-integration.test.ts | 8 ++++- test/qwp/udp-sender.test.ts | 8 ++++- test/qwp/wss-tls-security.test.ts | 10 ++++-- 6 files changed, 95 insertions(+), 13 deletions(-) diff --git a/src/sender.ts b/src/sender.ts index 3a1ab43..d407c0c 100644 --- a/src/sender.ts +++ b/src/sender.ts @@ -14,15 +14,41 @@ import { import { SenderTransport, createTransport } from "./transport"; import { SenderBuffer, createBuffer } from "./buffer"; import { isBoolean, isInteger, TimestampUnit } from "./utils"; +import { createRequire } from "node:module"; import { QWP_INGRESS_PATH } from "./_qwp/_core"; -import { - createQwpNodeSender, - createQwpNodeUdpSender, - QwpSender, -} from "./qwp/node"; +import type { QwpSender } from "./qwp/node"; import type { QwpTableWriter } from "./_qwp/sender"; import type { QwpWriterSchema } from "./_qwp/writer"; +// The QWP Node subsystem statically pulls in ws, node:dgram and node:os. Load +// it lazily -- only when a ws/wss/udp Sender is actually built -- so the http +// and tcp consumers that make up the bulk of the root entry's callers keep a +// lean module graph and do not hard-depend on ws being resolvable. The +// package's own subpath is used so both the ESM and CJS builds resolve the +// matching qwp/node artifact through the "exports" condition. +type QwpNodeModule = typeof import("./qwp/node"); +let qwpNodeModule: QwpNodeModule | undefined; +function loadQwpNode(): QwpNodeModule { + if (!qwpNodeModule) { + qwpNodeModule = createRequire(import.meta.url)( + "@questdb/nodejs-client/qwp/node", + ) as QwpNodeModule; + } + return qwpNodeModule; +} + +/** + * @internal Warms the cache through a dynamic import rather than the + * synchronous require above. The require resolves the published qwp/node + * artifact through the package `exports`; a suite running against `src/` has no + * such artifact for its live source, so it preloads first to share the very + * module instance it imported -- keeping spies and source edits effective. The + * built entry never calls this: the synchronous fallback covers it. + */ +export async function preloadQwpNode(): Promise { + qwpNodeModule ??= await import("./qwp/node"); +} + const DEFAULT_AUTO_FLUSH_INTERVAL = 1000; // 1 sec /** @@ -138,7 +164,7 @@ class Sender { ? // SenderOptions already parsed the ws/wss connect string with the // QWP schema, so there is one vocabulary and one parser however the // sender was constructed. - createQwpNodeSender( + loadQwpNode().createQwpNodeSender( resolved.ingress, resolved.sender, resolved.ingressSession, @@ -617,7 +643,7 @@ function createConfiguredQwpSender( // resolveQwpNodeClientConfig(). This path builds a sender from a // programmatic options object, so it reads options.qwp.* directly. const storeAndForward = configuredWebSocket.storeAndForward; - return createQwpNodeSender( + return loadQwpNode().createQwpNodeSender( { ...configuredWebSocket, storeAndForward, @@ -658,7 +684,7 @@ function createConfiguredQwpUdpSender( const configuredSender = options.qwp?.sender ?? {}; const maxDatagramSize = options.max_datagram_size ?? configuredUdp.maxDatagramSize ?? 1_400; - return createQwpNodeUdpSender( + return loadQwpNode().createQwpNodeUdpSender( { ...configuredUdp, host: options.host, diff --git a/test/options.test.ts b/test/options.test.ts index 6768e72..17793fb 100644 --- a/test/options.test.ts +++ b/test/options.test.ts @@ -2,10 +2,15 @@ import { describe, it, expect, beforeAll, afterAll } from "vitest"; import { Agent } from "undici"; -import { Sender } from "../src/sender"; +import { Sender, preloadQwpNode } from "../src/sender"; import { SenderOptions } from "../src"; import { qwpConfig } from "../src/options"; import { log } from "../src/logging"; + +// The root Sender lazy-loads the QWP Node subsystem through the package's own +// subpath (the built artifact); against source, warm its cache with the source +// module so ws/wss/udp senders built here run the code under test. +beforeAll(preloadQwpNode); import { MockHttp } from "./util/mockhttp"; import { readFileSync } from "fs"; diff --git a/test/qwp/dist.e2e.ts b/test/qwp/dist.e2e.ts index 6589b0e..9ec43ba 100644 --- a/test/qwp/dist.e2e.ts +++ b/test/qwp/dist.e2e.ts @@ -188,6 +188,39 @@ describe("store-and-forward locking", () => { }, ); + it.each(["import", "require"] as const)( + "loads the QWP Node subsystem only when a ws/wss/udp sender is built (%s)", + async (format) => { + // The root entry must not eagerly pull ws, node:dgram and node:os onto + // the module graph: http/tcp consumers -- the bulk of callers -- would + // pay for the whole QWP Node subsystem and hard-depend on ws resolving. + // It must still load lazily and work on the first ws/wss/udp sender. + const target = resolveExport(".", format); + const probe = + '({ ws: !!require.cache[require.resolve("ws")],' + + " dgram: process.moduleLoadList.some((m) => /dgram/.test(m)) })"; + const body = + `const before = ${probe};` + + ' const sender = new Sender({ protocol: "udp", host: "127.0.0.1", port: 9007 });' + + ` const after = ${probe};` + + " console.log(JSON.stringify({ before, after, table: typeof sender.table }));"; + const load_ = + format === "require" + ? `const { Sender } = require(${JSON.stringify(target)}); ${body}` + : `import(${JSON.stringify(pathToFileURL(target).href)}).then(({ Sender }) => { ${body} });`; + + const { code, stdout, stderr } = await runNode(load_); + expect(stderr).toBe(""); + expect(code).toBe(0); + const result = JSON.parse(stdout.trim()); + // Root load alone leaves the QWP Node subsystem off the graph. + expect(result.before).toEqual({ ws: false, dgram: false }); + // The first ws/wss/udp sender lazily loads it, and is functional. + expect(result.after).toEqual({ ws: true, dgram: true }); + expect(result.table).toBe("function"); + }, + ); + it("ships the slot lock in the bundle with no native addon", async () => { for (const format of ["import", "require"] as const) { const bundle = await readFile( diff --git a/test/qwp/sender-node-integration.test.ts b/test/qwp/sender-node-integration.test.ts index 7aaedb3..3eb836d 100644 --- a/test/qwp/sender-node-integration.test.ts +++ b/test/qwp/sender-node-integration.test.ts @@ -4,8 +4,14 @@ import { createServer as createTcpServer } from "node:net"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { WebSocketServer } from "ws"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { Sender } from "../../src"; +import { preloadQwpNode } from "../../src/sender"; + +// The root Sender lazy-loads the QWP Node subsystem through the package's own +// subpath (the built artifact); against source, warm its cache with the source +// module so the ws:: senders built here run the code under test. +beforeAll(preloadQwpNode); import { QWP_FLAG_DELTA_SYMBOL_DICTIONARY, QWP_MAGIC, diff --git a/test/qwp/udp-sender.test.ts b/test/qwp/udp-sender.test.ts index 91dc6a5..b99dd8d 100644 --- a/test/qwp/udp-sender.test.ts +++ b/test/qwp/udp-sender.test.ts @@ -1,5 +1,11 @@ -import { describe, expect, it } from "vitest"; +import { beforeAll, describe, expect, it } from "vitest"; import { Sender } from "../../src"; +import { preloadQwpNode } from "../../src/sender"; + +// The root Sender lazy-loads the QWP Node subsystem through the package's own +// subpath (the built artifact); against source, warm its cache with the source +// module so the Sender.fromConfig("udp::...") below runs the code under test. +beforeAll(preloadQwpNode); import { QwpSymbolDictionary, connectQwpNodeUdp, diff --git a/test/qwp/wss-tls-security.test.ts b/test/qwp/wss-tls-security.test.ts index d6ea8b6..9aa687c 100644 --- a/test/qwp/wss-tls-security.test.ts +++ b/test/qwp/wss-tls-security.test.ts @@ -4,11 +4,17 @@ import * as http from "node:http"; import * as https from "node:https"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import * as qwpNode from "../../src/qwp/node"; -import { Sender } from "../../src/sender"; +import { Sender, preloadQwpNode } from "../../src/sender"; import { SenderOptions, qwpConfig } from "../../src/options"; +// The root Sender lazy-loads the QWP Node subsystem through the package's own +// subpath, which resolves to the built artifact. Running against source, warm +// its cache with the source module first so the createQwpNodeSender spies below +// apply to the same instance the Sender calls. +beforeAll(preloadQwpNode); + /** * A wss:// producer must verify the server certificate, and its authorization * header must carry the operator's credentials unchanged. Both are silent when From 73ed15c1e20a77a7f2afc696651838dc18becd77 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 25 Aug 2026 16:49:12 +0100 Subject: [PATCH 188/265] fix(qwp): validate compressed delta bounds after decompression --- src/_qwp/_core/result-batch.ts | 24 ++++++++++----------- test/qwp/egress.test.ts | 39 ++++++++++++++++++++++++++++------ 2 files changed, 43 insertions(+), 20 deletions(-) diff --git a/src/_qwp/_core/result-batch.ts b/src/_qwp/_core/result-batch.ts index 753ddd5..7c4a309 100644 --- a/src/_qwp/_core/result-batch.ts +++ b/src/_qwp/_core/result-batch.ts @@ -17,7 +17,8 @@ import { decompressQwpZstdFrame } from "./zstd"; const MAX_ARRAY_DIMENSION_LENGTH = (1 << 28) - 1; const MAX_ARRAY_ELEMENTS = 268_435_327; -const MAX_CONNECTION_SYMBOLS = 8_388_608; +// Matches QuestDB's connection-scoped symbol dictionary limit. +const MAX_CONNECTION_SYMBOLS = 2_000_000; const MAX_ROWS_PER_BATCH = 1_048_576; export interface QwpDecimalValue { @@ -1386,7 +1387,7 @@ export class QwpResultBatchDecoder { : message.body; const reader = new QwpByteReader(body); const deltaMode = (message.flags & QWP_FLAG_DELTA_SYMBOL_DICTIONARY) !== 0; - if (deltaMode) this.readDeltaDictionary(reader, message.body.length); + if (deltaMode) this.readDeltaDictionary(reader, body.length); const tableNameLength = readCount( reader, @@ -1888,7 +1889,7 @@ export class QwpResultBatchDecoder { private readDeltaDictionary( reader: QwpByteReader, - framePayloadBytes: number, + decompressedPayloadBytes: number, ): void { const start = readCount( reader, @@ -1910,17 +1911,14 @@ export class QwpResultBatchDecoder { `symbol dictionary exceeds ${MAX_CONNECTION_SYMBOLS} entries`, ); } - // Each declared entry occupies at least one byte in the frame that carried - // it, so a count above the frame's payload length was manufactured by Zstd - // decompression rather than transmitted: a few hundred wire bytes could - // otherwise declare millions of zero-length entries and allocate them all - // here, before any column is read. Bound the entry count to the wire, as - // the grid cell cap does, and as the local dictionary is bounded by its row - // count. Checked before the loop, because reading an entry is what - // allocates. - if (count > framePayloadBytes) { + // Each declared entry occupies at least one length byte in the decompressed + // body. Check that structural lower bound before the loop, because reading + // an entry is what allocates. The compressed length is not a valid bound: + // a legitimate dictionary with repetitive symbols may compress below its + // entry count. + if (count > decompressedPayloadBytes) { throw new QwpProtocolError( - `delta symbol dictionary declares ${count} entries, above the ${framePayloadBytes}-byte frame payload`, + `delta symbol dictionary declares ${count} entries, above the ${decompressedPayloadBytes}-byte decompressed payload`, ); } for (let index = 0; index < count; index++) { diff --git a/test/qwp/egress.test.ts b/test/qwp/egress.test.ts index c52e3ba..98933eb 100644 --- a/test/qwp/egress.test.ts +++ b/test/qwp/egress.test.ts @@ -45,6 +45,14 @@ const COMPRESSED_INT_RESULT_BODY = Uint8Array.from([ 42, 0, 0, 1, 0, 138, 171, 46, 9, ]); +// A 37,006-byte RESULT_BATCH body containing 1,000 distinct, repetitive +// symbols, compressed by Zstd to 659 bytes. Its dictionary count legitimately +// exceeds the compressed payload length. +const COMPRESSED_LARGE_DELTA_RESULT_BODY = Buffer.from( + "KLUv/WSOjzUUAMY/dBewpZAODMMwDENOQ5WTlDKllE4PJAcqEmsAYABzANu2bdu2bdu2bdu2bdu2bZIkSZIkSZIkSZIkSZIkSZIkSZIkSZIkSZIkSZIkSZIkSZIkSZL0kXA5hX8kXE5hPhIupyAfCZdTiI+Eyyn4I+FyCv1IuJwCPxIup7CPhMsp6CPhcgoDAAQCAgQBbdu2bdu2bdu2bdu2bdu2bdu2bdu2bdu2bdu2bdu2bdu2bduWJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJLdt27Zt27Zt27Zt27Zt27Zt27Zt27YtIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIhERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERtm3btm3btm3btm3btm3btm3btm3btm3btm3btm3btm3btm3btt22DUEQ/P////////////////////////////////////////////////8/MzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMyMiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiISg+moIvBbA/LX2A0SQBD4/xUERvAH////8/////7//+v//3/9///f//9/+///t/////f//3f////u/////f//3v//3/v//7///9/3//99////7////f7/v9/////7/7+////39///f///9+//f//+///f/3///f/vv/////f/73////7v/////v/93//f//3/Ee/8/3f39aLfF31f1Pui74u+XeT7ou+LfvctogsAoJ2KWwFDNyrb", + "base64", +); + function writeString(writer: QwpByteWriter, value: string): void { const bytes = new TextEncoder().encode(value); writeQwpVarint(writer, bytes.length); @@ -180,6 +188,18 @@ function compressedIntResultBatch(requestId = 0n): Uint8Array { ); } +function compressedLargeDeltaResultBatch(requestId = 0n): Uint8Array { + const payload = new QwpByteWriter(); + payload.writeUint8(QWP_EGRESS_MESSAGE.RESULT_BATCH).writeBigUint64(requestId); + writeQwpVarint(payload, 0); + payload.writeBytes(COMPRESSED_LARGE_DELTA_RESULT_BODY); + return encodeQwpFrame( + payload.toUint8Array(), + QWP_FLAG_DELTA_SYMBOL_DICTIONARY | QWP_FLAG_ZSTD, + 1, + ); +} + /** * A Zstd frame of RAW and RLE blocks. RLE is what detaches a declared grid * from the bytes on the wire: one byte encodes a whole run, so an all-NULL @@ -796,19 +816,17 @@ describe("QWP result batch decoder", () => { }); it("bounds the delta symbol dictionary a RESULT_BATCH declares", () => { - // readDeltaDictionary ran before the grid cell cap and was bounded only by - // MAX_CONNECTION_SYMBOLS, never by the wire. A zero-length entry costs one - // decompressed byte, so a few hundred Zstd-compressed bytes declared 8.4M - // of them and allocated 8.4M empty strings -- ~140 MB and ~0.9 s of blocked - // event loop -- before any column was read. - const wire = deltaDictionaryFloodBatch(8_388_608); + // A zero-length entry costs one decompressed byte, so a few hundred + // Zstd-compressed bytes can declare millions of them. Reject beyond the + // server's connection dictionary cap before entering the allocation loop. + const wire = deltaDictionaryFloodBatch(2_000_001); expect(wire.byteLength).toBeLessThan(2_000); const message = decodeQwpEgressMessage(wire); if (message.kind !== "result-batch") throw new Error("unexpected message"); const before = process.memoryUsage().heapUsed; expect(() => new QwpResultBatchDecoder().decode(message)).toThrow( - /above the \d+-byte frame payload/, + /delta dictionary count out of range: 2000001/, ); // Rejected before the entry loop -- reading one is what allocates. expect(process.memoryUsage().heapUsed - before).toBeLessThan(50e6); @@ -824,6 +842,13 @@ describe("QWP result batch decoder", () => { expect(batch.get(1, 2)).toBe("beta"); }); + it("decodes a compressed delta larger than its wire payload", () => { + const message = decodeQwpEgressMessage(compressedLargeDeltaResultBatch()); + if (message.kind !== "result-batch") throw new Error("unexpected message"); + expect(message.body.byteLength).toBeLessThan(1_000); + expect(() => new QwpResultBatchDecoder().decode(message)).not.toThrow(); + }); + it("rejects a decimal scale byte the encoder would never send", () => { // The scale is a single wire byte; unchecked, a 255 decodes to a value off // by up to 10^237. Bound it like the adjacent GEOHASH precision and the From 28e499bec343b2b98aca02328a96b056b5a38f10 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 25 Aug 2026 16:51:03 +0100 Subject: [PATCH 189/265] fix(qwp): validate identifier limits in UTF-8 bytes --- QWP.md | 2 +- src/_qwp/_core/constants.ts | 17 ++++++----------- src/_qwp/_core/identifiers.ts | 10 ++++++---- src/_qwp/sender.ts | 2 +- test/qwp/core.test.ts | 20 +++++++++++++++----- test/qwp/egress.test.ts | 12 ++++-------- test/qwp/sender.test.ts | 8 +++++++- 7 files changed, 40 insertions(+), 31 deletions(-) diff --git a/QWP.md b/QWP.md index e5e76cd..32b6fb2 100644 --- a/QWP.md +++ b/QWP.md @@ -113,7 +113,7 @@ connect string is the portable spelling. | `transaction` | `on`, `off` | off | Group each flush into a per-table transaction. | | `request_durable_ack` | `on`, `off` | off | Require durable ACKs; fails if the server cannot confirm them. | | `durable_ack_keepalive_interval_millis` | integer ms | — | Poll interval for durable-ACK progress. | -| `max_name_len` | integer | `127` | Maximum table and column name length, in UTF-16 code units. | +| `max_name_len` | integer | `127` | Maximum table and column name length, in UTF-8 bytes. | | `sender_id` | string | `default` | Identifies this producer to the server and in the journal. | | `max_frame_rejections` | integer | — | Rejections of one frame before the poison-frame detector escalates. | | `poison_min_escalation_window_millis` | integer ms | — | Minimum dwell before a poison frame may escalate. | diff --git a/src/_qwp/_core/constants.ts b/src/_qwp/_core/constants.ts index ef03970..ffd2fc2 100644 --- a/src/_qwp/_core/constants.ts +++ b/src/_qwp/_core/constants.ts @@ -91,21 +91,16 @@ export const QWP_SERVER_ROLE = { } as const; export const QWP_MAX_COLUMNS_PER_TABLE = 2048; -/** - * Identifier length limits, in UTF-16 code units -- the unit Java's - * `TableUtils` measures in, which `identifiers.ts` mirrors on the ingress side. - */ +/** Default QWP ingress identifier limits, in UTF-8 wire bytes. */ export const QWP_MAX_COLUMN_NAME_LENGTH = 127; export const QWP_MAX_TABLE_NAME_LENGTH = 127; /** - * The same limits as a wire byte count, for bounding a decode allocation. + * Defensive byte bound for identifiers decoded from query results. * - * A UTF-16 code unit takes at most three UTF-8 bytes (a surrogate pair is two - * units and four bytes, so two bytes per unit). Bounding the wire length by the - * code-unit limit directly would reject identifiers this client itself encodes: - * 64 accented characters are 64 code units but 128 bytes, so at the default - * limit a name that passed ingress validation could not be read back out of a - * result set. + * Existing tables may have names created through APIs that apply Java's + * 127-UTF-16-code-unit metadata limit. One code unit takes at most three UTF-8 + * bytes, so query decoding accepts that larger representation even though QWP + * ingress enforces its 127-byte protocol limit. */ export const QWP_MAX_IDENTIFIER_BYTES = QWP_MAX_TABLE_NAME_LENGTH * 3; export const QWP_MAX_ROWS_PER_TABLE = 1_000_000; diff --git a/src/_qwp/_core/identifiers.ts b/src/_qwp/_core/identifiers.ts index 982a699..017592e 100644 --- a/src/_qwp/_core/identifiers.ts +++ b/src/_qwp/_core/identifiers.ts @@ -1,3 +1,5 @@ +import { utf8Length } from "./bytes"; + function isIllegalCommonIdentifierCharacter( character: string, codeUnit: number, @@ -25,13 +27,13 @@ function isIllegalCommonIdentifierCharacter( } } -/** @internal Applies Java TableUtils table-name rules and UTF-16 length. */ +/** @internal Applies Java TableUtils rules and the QWP UTF-8 byte limit. */ export function validateQwpTableName( name: string, maxNameLength: number, ): void { if (name.length === 0) throw new Error("table name cannot be empty"); - if (name.length > maxNameLength) { + if (utf8Length(name) > maxNameLength) { throw new Error(`table name too long [maxLength=${maxNameLength}]`); } if (name.charAt(0) === " " || name.charAt(name.length - 1) === " ") { @@ -51,13 +53,13 @@ export function validateQwpTableName( } } -/** @internal Applies Java TableUtils column-name rules and UTF-16 length. */ +/** @internal Applies Java TableUtils rules and the QWP UTF-8 byte limit. */ export function validateQwpColumnName( name: string, maxNameLength: number, ): void { if (name.length === 0) throw new Error("column name cannot be empty"); - if (name.length > maxNameLength) { + if (utf8Length(name) > maxNameLength) { throw new Error(`column name too long [maxLength=${maxNameLength}]`); } for (let index = 0; index < name.length; index++) { diff --git a/src/_qwp/sender.ts b/src/_qwp/sender.ts index 8265dee..9e94305 100644 --- a/src/_qwp/sender.ts +++ b/src/_qwp/sender.ts @@ -50,7 +50,7 @@ export interface QwpSenderOptions { */ autoFlushBytes?: number; autoFlushIntervalMs?: number; - /** Maximum UTF-16 length of table and column names. Defaults to 127. */ + /** Maximum UTF-8 byte length of table and column names. Defaults to 127. */ maxNameLength?: number; /** * Keep auto-flushed rows in an open server-side transaction. An explicit diff --git a/test/qwp/core.test.ts b/test/qwp/core.test.ts index 7736c96..1e4676d 100644 --- a/test/qwp/core.test.ts +++ b/test/qwp/core.test.ts @@ -250,14 +250,24 @@ describe("QWP ingress codec", () => { ); } - expect(() => new QwpTableBuffer("😀", 2)).not.toThrow(); - expect(() => new QwpTableBuffer("😀", 1)).toThrow( - /table name too long.*maxLength=1/, + const atByteLimit = `${"é".repeat(63)}a`; + const overByteLimit = "é".repeat(64); + expect(() => new QwpTableBuffer(atByteLimit)).not.toThrow(); + expect(() => new QwpTableBuffer(overByteLimit)).toThrow( + /table name too long.*maxLength=127/, ); - const unicode = new QwpTableBuffer("t", 2); + const unicode = new QwpTableBuffer("t"); expect(() => - unicode.getOrCreateColumn("😀", QWP_COLUMN_TYPE.LONG), + unicode.getOrCreateColumn(atByteLimit, QWP_COLUMN_TYPE.LONG), ).not.toThrow(); + expect(() => + unicode.getOrCreateColumn(overByteLimit, QWP_COLUMN_TYPE.LONG), + ).toThrow(/column name too long.*maxLength=127/); + + expect(() => new QwpTableBuffer("😀", 4)).not.toThrow(); + expect(() => new QwpTableBuffer("😀", 3)).toThrow( + /table name too long.*maxLength=3/, + ); }); it("tracks columns case-insensitively and preserves first spelling", () => { diff --git a/test/qwp/egress.test.ts b/test/qwp/egress.test.ts index 98933eb..2620cf8 100644 --- a/test/qwp/egress.test.ts +++ b/test/qwp/egress.test.ts @@ -465,15 +465,11 @@ describe("QWP result batch decoder", () => { ]); }); - it("decodes identifiers this client is allowed to ingest", () => { - // Ingress validates identifiers in UTF-16 code units, mirroring Java's - // TableUtils. The decoder bounded the wire field with the same number, but - // that field is a UTF-8 byte count, so a name that passed ingress could not - // be read back: 64 accented characters are 64 code units and 128 bytes. + it("decodes identifiers at the defensive egress byte bound", () => { + // Query results may expose existing Java metadata created through another + // protocol. Keep accepting up to 127 UTF-16 code units on egress, while + // QWP ingress separately applies its 127-byte wire limit. for (const name of ["a".repeat(127), "é".repeat(127), "あ".repeat(127)]) { - // Ingress accepts it at the default limit. - expect(() => new QwpTableBuffer(name)).not.toThrow(); - const payload = new QwpByteWriter(); payload.writeUint8(QWP_EGRESS_MESSAGE.RESULT_BATCH).writeBigUint64(0n); writeQwpVarint(payload, 0); // batch sequence diff --git a/test/qwp/sender.test.ts b/test/qwp/sender.test.ts index 6641344..a2c81f2 100644 --- a/test/qwp/sender.test.ts +++ b/test/qwp/sender.test.ts @@ -440,7 +440,7 @@ describe("QWP high-level sender", () => { ).toThrow(/closeFlushTimeoutMs must be a non-negative safe integer/); }); - it("applies a configurable Java-compatible identifier length", async () => { + it("applies a configurable UTF-8 identifier byte length", async () => { const session = new RecordingSession(); expect( () => new QwpSender(async () => session, { maxNameLength: 15 }), @@ -450,6 +450,12 @@ describe("QWP high-level sender", () => { expect(() => defaultSender.table("t".repeat(128))).toThrow( /table name too long.*maxLength=127/, ); + expect(() => defaultSender.table("é".repeat(64))).toThrow( + /table name too long.*maxLength=127/, + ); + expect(() => + defaultSender.table("events").longColumn("é".repeat(64), 42n), + ).toThrow(/column name too long.*maxLength=127/); await defaultSender.close(); const sender = new QwpSender(async () => session, { From 6cec1376d1b22282cb1ae6bf06c5650fa9ab42cb Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 25 Aug 2026 16:51:49 +0100 Subject: [PATCH 190/265] fix(qwp): preserve routing for pooled orphan recovery --- src/qwp/node.ts | 4 ++++ test/qwp/node-transport.test.ts | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/src/qwp/node.ts b/src/qwp/node.ts index ce2520e..4be5a1e 100644 --- a/src/qwp/node.ts +++ b/src/qwp/node.ts @@ -1109,6 +1109,10 @@ function createPooledOrphanDrainer( const healthTracker = createQwpFailoverHealthTracker( options.ingress.url, options.ingress.failoverUrls, + { + target: options.ingress.target, + zone: options.ingress.zone, + }, ); return createNodeOrphanDrainer( options.ingress, diff --git a/test/qwp/node-transport.test.ts b/test/qwp/node-transport.test.ts index ec3c031..a12a815 100644 --- a/test/qwp/node-transport.test.ts +++ b/test/qwp/node-transport.test.ts @@ -402,6 +402,8 @@ describe("QWP Node transport", () => { endpoint.on("headers", (headers) => { headers.push("X-QWP-Version: 1"); headers.push("X-QWP-Durable-Ack: enabled"); + headers.push("X-QuestDB-Role: PRIMARY"); + headers.push("X-QuestDB-Zone: eu-west-1"); }); const received: Uint8Array[] = []; let pingCount = 0; @@ -434,6 +436,8 @@ describe("QWP Node transport", () => { const client = await connectQwpNodeClient({ ingress: { url: `ws://127.0.0.1:${address.port}/write/v4`, + target: "primary", + zone: "eu-west-1", requestDurableAck: true, storeAndForward: { directory: rootDirectory, From 76e111ec1fd7524d27b98b11881faaf7dbfca873 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 25 Aug 2026 16:59:16 +0100 Subject: [PATCH 191/265] fix(qwp): retry rate-limited browser bootstrap --- src/qwp/browser.ts | 3 ++- test/qwp/session.test.ts | 52 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/src/qwp/browser.ts b/src/qwp/browser.ts index 7a0dbeb..ae7952a 100644 --- a/src/qwp/browser.ts +++ b/src/qwp/browser.ts @@ -101,7 +101,8 @@ export class QwpBrowserSessionBootstrapError extends QwpUpgradeError { kind: authenticationFailure ? QWP_UPGRADE_ERROR_KIND.AUTHENTICATION : QWP_UPGRADE_ERROR_KIND.HTTP_REJECTED, - retryable: !authenticationFailure && statusCode >= 500, + retryable: + !authenticationFailure && (statusCode === 429 || statusCode >= 500), tryNextEndpoint: !authenticationFailure, url, statusCode, diff --git a/test/qwp/session.test.ts b/test/qwp/session.test.ts index fff2bb5..45f0f99 100644 --- a/test/qwp/session.test.ts +++ b/test/qwp/session.test.ts @@ -822,6 +822,58 @@ describe("QWP WebSocket adapters", () => { await session.close(); }); + it("retries a rate-limited browser bootstrap during reconnect", async () => { + const sockets: FakeWebSocket[] = []; + const bootstrapStatuses: number[] = []; + const session = await connectQwpBrowserIngress( + { + url: "ws://localhost:9000/write/v4", + sessionBootstrap: { + authentication: { type: "bearer", token: "access-token" }, + fetch: async () => { + const status = bootstrapStatuses.length === 1 ? 429 : 200; + bootstrapStatuses.push(status); + return new Response(status === 429 ? "rate limited" : "{}", { + status, + statusText: status === 429 ? "Too Many Requests" : "OK", + }); + }, + }, + webSocketFactory: () => { + const socket = new FakeWebSocket(); + sockets.push(socket); + queueMicrotask(() => { + socket.open(); + socket.message(ingressServerInfo(128)); + }); + return asQwpSocket(socket); + }, + }, + { + reconnect: { + initialBackoffMs: 0, + maxBackoffMs: 0, + maxAttempts: 3, + }, + }, + ); + + const pending = session.sendFrame(Uint8Array.of(1)); + await vi.waitFor(() => expect(sockets[0].sent).toHaveLength(1)); + sockets[0].close(1006, "connection lost"); + + await vi.waitFor(() => expect(bootstrapStatuses).toEqual([200, 429, 200])); + await vi.waitFor(() => expect(sockets).toHaveLength(2)); + await vi.waitFor(() => expect(sockets[1].sent).toEqual(sockets[0].sent)); + sockets[1].message(ingressResponse(QWP_STATUS.OK, 0n)); + await expect(pending).resolves.toMatchObject({ + status: QWP_STATUS.OK, + sequence: 0n, + }); + expect(session.metrics.totalFramesReplayed).toBe(1); + await session.close(); + }); + it("uses the local-publication flush boundary in browsers by default", async () => { const socket = new FakeWebSocket(); const sender = createQwpBrowserSender( From d5a16e4155329ba46ea1074e3d0aa414cf0f2947 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 25 Aug 2026 17:24:01 +0100 Subject: [PATCH 192/265] test(qwp): settle trimming before lock reclamation --- test/qwp/reconnect.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/test/qwp/reconnect.test.ts b/test/qwp/reconnect.test.ts index 67252ab..f2777d1 100644 --- a/test/qwp/reconnect.test.ts +++ b/test/qwp/reconnect.test.ts @@ -4461,6 +4461,17 @@ describe("QWP Node file replay store", () => { // Fully drained, so close() takes the teardown paths that delete: the // watermark, the dictionary, and the parent-anchored orphan pair. await evicted.acknowledgeThrough(0n); + // acknowledgeThrough() schedules segment trimming in the background. Let + // that work settle before manufacturing a stale lease: otherwise the test + // can make the successor scan a segment that this still-live store is + // concurrently removing, which is not the paused-holder scenario below. + await vi.waitFor( + async () => { + expect(evicted.metrics.pendingSegments).toBe(0); + expect(await readdir(directory)).not.toContain(".ack-watermark"); + }, + { timeout: 5_000 }, + ); // Stand in for a holder paused past the staleness window: the slot is // reclaimed while this store still has it open. From afc4057dd37b5d5a3e1fad3f35ece270be68b14e Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 25 Aug 2026 19:32:03 +0100 Subject: [PATCH 193/265] fix(qwp): preserve root failover endpoints --- src/options.ts | 13 +++++- test/qwp/sender-node-integration.test.ts | 58 ++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/src/options.ts b/src/options.ts index a19ab98..9bf46f1 100644 --- a/src/options.ts +++ b/src/options.ts @@ -57,11 +57,12 @@ function resolveQwpConfig( configString: string, ): QwpNodeClientOptions { const configuredWebSocket = options.qwp?.webSocket; - const { storeAndForward, ...webSocketOverrides } = configuredWebSocket ?? {}; + const { storeAndForward, failoverUrls, ...webSocketOverrides } = + configuredWebSocket ?? {}; const agent = webSocketOverrides.agent ?? selectQwpSchemeAgent(options.agent, options.protocol === WSS); - return resolveQwpNodeClientConfig(configString, { + const resolved = resolveQwpNodeClientConfig(configString, { webSocket: { ...webSocketOverrides, agent }, storeAndForward, // The top-level logger wins, then the QWP-specific one, then the default @@ -76,6 +77,14 @@ function resolveQwpConfig( }, ingressSession: options.qwp?.session, }); + if (failoverUrls === undefined) return resolved; + return { + ...resolved, + ingress: { + ...resolved.ingress, + failoverUrls, + }, + }; } const HTTP_PORT = 9000; diff --git a/test/qwp/sender-node-integration.test.ts b/test/qwp/sender-node-integration.test.ts index 3eb836d..504aea8 100644 --- a/test/qwp/sender-node-integration.test.ts +++ b/test/qwp/sender-node-integration.test.ts @@ -186,6 +186,64 @@ describe("Sender QWP integration", () => { } }); + it("uses typed root failoverUrls after the primary upgrade is rejected", async () => { + let primaryAttempts = 0; + let secondaryAttempts = 0; + let requestPath: string | undefined; + const primary = new WebSocketServer({ + host: "127.0.0.1", + port: 0, + verifyClient: (_info, accept) => { + primaryAttempts++; + accept(false, 503, "Unavailable"); + }, + }); + server = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + server.on("headers", (headers) => { + headers.push("X-QWP-Version: 1"); + headers.push("X-QWP-Max-Batch-Size: 1048576"); + }); + server.on("connection", (_socket, request) => { + secondaryAttempts++; + requestPath = request.url; + }); + await Promise.all([ + new Promise((resolve, reject) => { + primary.once("listening", resolve); + primary.once("error", reject); + }), + new Promise((resolve, reject) => { + server!.once("listening", resolve); + server!.once("error", reject); + }), + ]); + const primaryPort = (primary.address() as AddressInfo).port; + const secondaryPort = (server.address() as AddressInfo).port; + + let sender: Sender | undefined; + try { + sender = await Sender.fromConfig( + `ws::addr=127.0.0.1:${primaryPort};connect_timeout=250;auto_flush=off;`, + { + qwp: { + webSocket: { + failoverUrls: [`ws://127.0.0.1:${secondaryPort}/write/v4`], + }, + }, + }, + ); + await sender.connect(); + expect(primaryAttempts).toBe(1); + expect(secondaryAttempts).toBe(1); + expect(requestPath).toBe("/write/v4"); + } finally { + await sender?.close().catch(() => undefined); + await new Promise((resolve, reject) => + primary.close((error) => (error ? reject(error) : resolve())), + ); + } + }); + it("honors auto_flush_bytes from the ws:: configuration string", async () => { const frames: Uint8Array[] = []; server = new WebSocketServer({ host: "127.0.0.1", port: 0 }); From ca8d23dd38bf1cefe60c4234788d2f96bbb936e2 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Tue, 25 Aug 2026 19:32:08 +0100 Subject: [PATCH 194/265] fix(qwp): accept signed packed IPv4 values --- QWP.md | 2 +- src/_qwp/sender.ts | 12 +++++- src/_qwp/writer.ts | 2 +- test/qwp/sender.test.ts | 81 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 94 insertions(+), 3 deletions(-) diff --git a/QWP.md b/QWP.md index 32b6fb2..fc739eb 100644 --- a/QWP.md +++ b/QWP.md @@ -608,7 +608,7 @@ The schema vocabulary covers every column type the fluent row API can write: | `binary()` | BINARY | `Uint8Array`, copied on append | | `uuid()` | UUID | canonical UUID text, 16 canonical big-endian bytes, or `{ low, high }` | | `long256()` | LONG256 | unsigned 256-bit `bigint`, `0x` hex text, four little-endian words, or `{ words }` | -| `ipv4()` | IPV4 | dotted-quad text or the packed address; `0.0.0.0` is the NULL sentinel | +| `ipv4()` | IPV4 | dotted-quad text or signed/unsigned packed address; `0.0.0.0` is the NULL sentinel | | `geohash(precisionBits)` | GEOHASH | raw bits, base-32 text of `precisionBits / 5` characters, or `{ bits, precisionBits }` | | `decimal64(scale)` | DECIMAL64 | unscaled `bigint`, decimal text, `number`, or `{ unscaled, scale }` | | `decimal128(scale)` | DECIMAL128 | as above, scale up to 38 | diff --git a/src/_qwp/sender.ts b/src/_qwp/sender.ts index 9e94305..bef9eab 100644 --- a/src/_qwp/sender.ts +++ b/src/_qwp/sender.ts @@ -466,7 +466,17 @@ function uuidBytes(value: string | Uint8Array): Uint8Array { function parseIpv4(value: string | number): number { if (typeof value === "number") { - return checkedRange(value, 1, 0xffffffff, "IPv4 value"); + if (!Number.isInteger(value) || value < -0x80000000 || value > 0xffffffff) { + throw new RangeError( + "IPv4 value must be a signed int32 or unsigned uint32", + ); + } + if (value === 0) { + throw new RangeError("0.0.0.0 is QuestDB's IPv4 NULL sentinel"); + } + // Java and QuestDB expose packed IPv4 values as signed int32s, while + // JavaScript callers often use uint32s. Both forms carry the same bits. + return value >>> 0; } const parts = value.split("."); if (parts.length !== 4) diff --git a/src/_qwp/writer.ts b/src/_qwp/writer.ts index 50f1cf4..7b6a715 100644 --- a/src/_qwp/writer.ts +++ b/src/_qwp/writer.ts @@ -130,7 +130,7 @@ export type QwpLong256Input = | QwpLong256Words | { readonly words: QwpLong256Words }; -/** IPV4 input: dotted-quad text or the packed 32-bit address. */ +/** IPV4 input: dotted-quad text or a signed/unsigned packed 32-bit address. */ export type QwpIpv4Input = string | number; /** diff --git a/test/qwp/sender.test.ts b/test/qwp/sender.test.ts index a2c81f2..7e8ed53 100644 --- a/test/qwp/sender.test.ts +++ b/test/qwp/sender.test.ts @@ -1,9 +1,13 @@ import { describe, expect, it } from "vitest"; import { QWP_COLUMN_TYPE, + QWP_EGRESS_MESSAGE, + QWP_FLAG_DELTA_SYMBOL_DICTIONARY, QWP_STATUS, QwpIngressEncodeOptions, QwpIngressResponse, + QwpByteWriter, + QwpResultBatchDecoder, QwpSender, QwpSenderCloseTimeoutError, QwpSenderSession, @@ -14,12 +18,14 @@ import { byte, char, date, + decodeQwpEgressMessage, decimal64, decimal128, decimal256, designatedTimestamp, double, doubleArray, + encodeQwpFrame, encodeQwpIngressFrame, float32, float64, @@ -35,6 +41,7 @@ import { timestamp, uuid, varchar, + writeQwpVarint, } from "../../src/qwp"; class RecordingSession implements QwpSenderSession { @@ -272,6 +279,25 @@ function column(table: QwpTableBuffer, name: string) { return result; } +function ipv4ResultBatch(value: number): Uint8Array { + const payload = new QwpByteWriter(); + payload.writeUint8(QWP_EGRESS_MESSAGE.RESULT_BATCH).writeBigUint64(0n); + writeQwpVarint(payload, 0); // batch sequence + writeQwpVarint(payload, 0); // empty dictionary delta start + writeQwpVarint(payload, 0); // empty dictionary delta count + writeQwpVarint(payload, 0); // table name + writeQwpVarint(payload, 1); // rows + writeQwpVarint(payload, 1); // columns + writeQwpVarint(payload, 2); + payload.writeUtf8("ip").writeUint8(QWP_COLUMN_TYPE.IPV4); + payload.writeUint8(0).writeInt32(value); + return encodeQwpFrame( + payload.toUint8Array(), + QWP_FLAG_DELTA_SYMBOL_DICTIONARY, + 1, + ); +} + describe("QWP high-level sender", () => { it("validates a column call even when its value is nullish", async () => { // Omitting the column must not take the rest of the call's validation with @@ -752,6 +778,7 @@ describe("QWP high-level sender", () => { .uuidColumn("id", "123e4567-e89b-12d3-a456-426614174000") .long256Column("hash", 1n, 2n, 3n, 4n) .ipv4Column("ip", "192.168.0.1") + .ipv4Column("signed_ip", -1_062_731_775) .atNow(); await sender.flush(); @@ -789,9 +816,59 @@ describe("QWP high-level sender", () => { type: QWP_COLUMN_TYPE.IPV4, values: [0xc0a80001], }); + expect(column(table, "signed_ip")).toMatchObject({ + type: QWP_COLUMN_TYPE.IPV4, + values: [0xc0a80001], + }); expect(() => encodeQwpIngressFrame([table])).not.toThrow(); }); + it("round-trips signed packed IPv4 values from egress", async () => { + const message = decodeQwpEgressMessage(ipv4ResultBatch(-1_062_731_775)); + if (message.kind !== "result-batch") throw new Error("unexpected message"); + const materialized = new QwpResultBatchDecoder().decode(message).get(0, 0); + const viewBatch = new QwpResultBatchDecoder().decodeView(message); + const viewed = viewBatch.column(0).get(0); + expect(materialized).toBe(-1_062_731_775); + expect(viewed).toBe(-1_062_731_775); + if (typeof materialized !== "number" || typeof viewed !== "number") { + throw new Error("expected packed IPv4 numbers"); + } + + const session = new RecordingSession(); + const sender = new QwpSender(async () => session, { autoFlush: false }); + await sender.writer("compiled", { ip: ipv4() }).row({ ip: materialized }); + await sender.table("fluent").ipv4Column("ip", viewed).atNow(); + await sender.flush(); + + const tables = session.sends[0].tables; + const compiled = tables.find((table) => table.name === "compiled"); + const fluent = tables.find((table) => table.name === "fluent"); + if (!compiled || !fluent) throw new Error("missing round-trip table"); + expect(column(compiled, "ip").values).toEqual([0xc0a80001]); + expect(column(fluent, "ip").values).toEqual([0xc0a80001]); + viewBatch.release(); + }); + + it("accepts signed and unsigned packed IPv4 boundaries", async () => { + const session = new RecordingSession(); + const sender = new QwpSender(async () => session, { autoFlush: false }); + await sender + .table("bounds") + .ipv4Column("signed_min", -0x80000000) + .ipv4Column("signed_max", -1) + .ipv4Column("unsigned_min", 0x80000000) + .ipv4Column("unsigned_max", 0xffffffff) + .atNow(); + await sender.flush(); + + const table = session.sends[0].tables[0]; + expect(column(table, "signed_min").values).toEqual([0x80000000]); + expect(column(table, "signed_max").values).toEqual([0xffffffff]); + expect(column(table, "unsigned_min").values).toEqual([0x80000000]); + expect(column(table, "unsigned_max").values).toEqual([0xffffffff]); + }); + it("omits a long256 column when all four words are nullish", async () => { // long256Column was the only column method whose value parameters did not // accept null or undefined, so the nullish rule README states for "every @@ -1620,6 +1697,10 @@ describe("QWP high-level sender", () => { await rejects({ hash: "0102" }, /0x-prefixed hex/, "hash"); await rejects({ hash: [1n, 2n] }, /exactly four 64-bit words/, "hash"); await rejects({ ip: "0.0.0.0" }, /NULL sentinel/, "ip"); + await rejects({ ip: 0 }, /NULL sentinel/, "ip"); + await rejects({ ip: -0x80000001 }, /signed int32 or unsigned uint32/, "ip"); + await rejects({ ip: 0x100000000 }, /signed int32 or unsigned uint32/, "ip"); + await rejects({ ip: 1.5 }, /signed int32 or unsigned uint32/, "ip"); await rejects({ location: "u33" }, /column is 20 bits/, "location"); await rejects({ location: 1n << 21n }, /does not fit/, "location"); await rejects( From 0bb1adbc5b1e741f758343107b9f783b9b030a8f Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 26 Aug 2026 00:30:03 +0100 Subject: [PATCH 195/265] fix(qwp): require PEM TLS roots --- QWP.md | 16 ++++-- src/qwp-node/client-config.ts | 37 ++++++++++--- test/qwp/node-client-config.test.ts | 82 +++++++++++++---------------- test/qwp/wss-tls-security.test.ts | 35 +++++------- 4 files changed, 93 insertions(+), 77 deletions(-) diff --git a/QWP.md b/QWP.md index fc739eb..d7f314e 100644 --- a/QWP.md +++ b/QWP.md @@ -96,8 +96,8 @@ connect string is the portable spelling. | `password`, `pass` | string | — | HTTP Basic password. | | `token` | string | — | Bearer token; alternative to Basic. | | `tls_verify` | `on`, `unsafe_off` | on | Certificate verification. `unsafe_off` disables it. | -| `tls_roots` | path | — | PEM or PKCS#12 trust store for a private CA. | -| `tls_roots_password` | string | — | Password for `tls_roots`. | +| `tls_roots` | path | — | PEM file containing trusted private-CA certificates. PKCS#12 is not supported. | +| `tls_roots_password` | string | — | Unsupported by Node; convert PKCS#12 roots to PEM and omit this key. | | `auth_timeout_ms` | integer ms | `15000` | Deadline for the upgrade and authentication exchange. | | `connect_timeout` | integer ms | `15000` | Deadline for the TCP/TLS transport, and for the upgrade unless `auth_timeout_ms` is set. | @@ -1212,6 +1212,12 @@ rejected rather than silently dropping either. Configure verification on the agent instead, and pass an `https.Agent` for `wss` (a plain `http.Agent` is for `ws`). +The Node client accepts `tls_roots` only as valid PEM-encoded CA certificates. +Password-protected PKCS#12 trust stores and `tls_roots_password` are rejected: +Node's `pfx` option represents client private-key/certificate identity, not +additional trusted roots. Export the CA certificates to PEM and omit the +password key. + Set `lazy_connect=on` to tolerate an unavailable cluster during startup. In the JavaScript client, ingress uses memory replay by default, or persistent replay when `sf_dir` is present, with `initial_connect_retry=async`; egress uses @@ -1231,9 +1237,9 @@ capacity wait, a 60-second close drain, and fail-fast initial connection. Set `sender_id` to name the disk slot base; pooled senders use `-`. Without `sf_dir`, `sf_max_total_bytes` and `sf_append_deadline_millis` tune the built-in memory replay queue instead. -The parser also supports `max_name_len`, password-protected `tls_roots`, and the -Java listener/error inbox capacity keys. Those capacities actively bound asynchronous -connection and typed-error delivery and are reflected in ingress drop counters. +The parser also supports `max_name_len` and the Java listener/error inbox +capacity keys. Those capacities actively bound asynchronous connection and +typed-error delivery and are reflected in ingress drop counters. The object form remains available for cases where constructing the two sides separately is useful: diff --git a/src/qwp-node/client-config.ts b/src/qwp-node/client-config.ts index 4957c51..d3b5ef0 100644 --- a/src/qwp-node/client-config.ts +++ b/src/qwp-node/client-config.ts @@ -1,5 +1,6 @@ import { readFileSync } from "node:fs"; import { Agent as HttpsAgent } from "node:https"; +import { createSecureContext } from "node:tls"; import type { QwpNodeClientConfigOptions, QwpNodeClientOptions, @@ -571,8 +572,10 @@ function validateTls(parsed: ParsedConfig): void { "tls_verify, tls_roots, and tls_roots_password are only supported by the wss schema", ); } - if (tlsRootsPassword !== undefined && tlsRoots === undefined) { - throw new Error("tls_roots_password requires tls_roots"); + if (tlsRootsPassword !== undefined) { + throw new Error( + "tls_roots_password is not supported by the Node.js QWP client; tls_roots must contain PEM-encoded CA certificates, not a password-protected PKCS#12 trust store", + ); } if (tlsRoots !== undefined && tlsVerify === "unsafe_off") { throw new Error( @@ -584,17 +587,37 @@ function validateTls(parsed: ParsedConfig): void { function createTlsAgent(parsed: ParsedConfig): HttpsAgent | undefined { const tlsVerify = parsed.values.get("tls_verify")?.[0]; const tlsRoots = parsed.values.get("tls_roots")?.[0]; - const tlsRootsPassword = parsed.values.get("tls_roots_password")?.[0]; if (tlsVerify === undefined && tlsRoots === undefined) return undefined; - const roots = tlsRoots ? readFileSync(tlsRoots) : undefined; + const roots = tlsRoots ? readPemTlsRoots(tlsRoots) : undefined; return new HttpsAgent({ - ca: tlsRootsPassword === undefined ? roots : undefined, - pfx: tlsRootsPassword === undefined ? undefined : roots, - passphrase: tlsRootsPassword, + ca: roots, rejectUnauthorized: tlsVerify !== "unsafe_off", }); } +function readPemTlsRoots(path: string): Buffer { + const roots = readFileSync(path); + if ( + !roots.includes("-----BEGIN CERTIFICATE-----") || + !roots.includes("-----END CERTIFICATE-----") + ) { + throw new Error( + "tls_roots must contain valid PEM-encoded CA certificates; PKCS#12 trust stores are not supported by the Node.js QWP client", + ); + } + try { + // Parse the configured roots now so PKCS#12 or malformed files fail while + // resolving the connect string, before a sender accepts rows or connects. + createSecureContext({ ca: roots }); + } catch (cause) { + throw new Error( + "tls_roots must contain valid PEM-encoded CA certificates; PKCS#12 trust stores are not supported by the Node.js QWP client", + { cause }, + ); + } + return roots; +} + function parseIngressReconnect( values: ReadonlyMap, ): QwpReconnectOptions | undefined { diff --git a/test/qwp/node-client-config.test.ts b/test/qwp/node-client-config.test.ts index 930eab6..2b0db07 100644 --- a/test/qwp/node-client-config.test.ts +++ b/test/qwp/node-client-config.test.ts @@ -344,50 +344,44 @@ describe("QWP unified Node client configuration", () => { ).toThrow(/unknown configuration key: made_up/); }); - it("accepts and validates the remaining Java QWP configuration keys", async () => { - const directory = await mkdtemp(join(tmpdir(), "qwp-tls-roots-")); - const trustStore = join(directory, "roots.p12"); - await writeFile(trustStore, Uint8Array.of(1, 2, 3)); - try { - const options = parseQwpNodeClientConfig( - `wss::addr=localhost;tls_roots=${trustStore};tls_roots_password=secret;` + - "connection_listener_inbox_capacity=7;error_inbox_capacity=32;" + - "max_name_len=512;sender_id=producer_1;sf_max_segment_bytes=8m;" + - "sf_max_total_bytes=64m;sf_append_deadline_millis=1234;", - ); - expect(options.ingress.agent).toBeDefined(); - expect(options.sender?.maxNameLength).toBe(512); - expect(options.ingress.senderId).toBe("producer_1"); - expect(options.ingressSession).toMatchObject({ - maxBatchSizeBytes: 8 * 1024 * 1024, - memoryReplayMaxBytes: 64 * 1024 * 1024, - memoryReplayAppendDeadlineMs: 1234, - connectionListenerInboxCapacity: 7, - errorInboxCapacity: 32, - }); - - expect(() => - parseQwpNodeClientConfig( - "wss::addr=localhost;tls_roots_password=secret;", - ), - ).toThrow(/requires tls_roots/); - expect(() => - parseQwpNodeClientConfig( - `wss::addr=localhost;tls_roots=${trustStore};tls_verify=unsafe_off;`, - ), - ).toThrow(/cannot be combined/); - expect(() => - parseQwpNodeClientConfig("ws::addr=localhost;max_name_len=15;"), - ).toThrow(/max_name_len/); - expect(() => - parseQwpNodeClientConfig("ws::addr=localhost;sender_id=bad.name;"), - ).toThrow(/sender_id/); - expect(() => - parseQwpNodeClientConfig("ws::addr=localhost;error_inbox_capacity=15;"), - ).toThrow(/error_inbox_capacity/); - } finally { - await rm(directory, { recursive: true, force: true }); - } + it("accepts and validates the remaining Java QWP configuration keys", () => { + const trustStore = "test/certs/ca/ca.crt"; + const options = parseQwpNodeClientConfig( + `wss::addr=localhost;tls_roots=${trustStore};` + + "connection_listener_inbox_capacity=7;error_inbox_capacity=32;" + + "max_name_len=512;sender_id=producer_1;sf_max_segment_bytes=8m;" + + "sf_max_total_bytes=64m;sf_append_deadline_millis=1234;", + ); + expect(options.ingress.agent).toBeDefined(); + expect(options.sender?.maxNameLength).toBe(512); + expect(options.ingress.senderId).toBe("producer_1"); + expect(options.ingressSession).toMatchObject({ + maxBatchSizeBytes: 8 * 1024 * 1024, + memoryReplayMaxBytes: 64 * 1024 * 1024, + memoryReplayAppendDeadlineMs: 1234, + connectionListenerInboxCapacity: 7, + errorInboxCapacity: 32, + }); + + expect(() => + parseQwpNodeClientConfig( + `wss::addr=localhost;tls_roots=${trustStore};tls_roots_password=secret;`, + ), + ).toThrow(/tls_roots_password.*PEM-encoded CA certificates/); + expect(() => + parseQwpNodeClientConfig( + `wss::addr=localhost;tls_roots=${trustStore};tls_verify=unsafe_off;`, + ), + ).toThrow(/cannot be combined/); + expect(() => + parseQwpNodeClientConfig("ws::addr=localhost;max_name_len=15;"), + ).toThrow(/max_name_len/); + expect(() => + parseQwpNodeClientConfig("ws::addr=localhost;sender_id=bad.name;"), + ).toThrow(/sender_id/); + expect(() => + parseQwpNodeClientConfig("ws::addr=localhost;error_inbox_capacity=15;"), + ).toThrow(/error_inbox_capacity/); }); it("routes ingress by target and zone, not only egress", () => { diff --git a/test/qwp/wss-tls-security.test.ts b/test/qwp/wss-tls-security.test.ts index 9aa687c..3c5c8c3 100644 --- a/test/qwp/wss-tls-security.test.ts +++ b/test/qwp/wss-tls-security.test.ts @@ -1,9 +1,6 @@ import { readFileSync } from "node:fs"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; import * as http from "node:http"; import * as https from "node:https"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import * as qwpNode from "../../src/qwp/node"; import { Sender, preloadQwpNode } from "../../src/sender"; @@ -35,7 +32,6 @@ interface AgentTlsOptions { rejectUnauthorized?: boolean; ca?: Buffer | string; pfx?: Buffer | string; - passphrase?: string; } /** node's http(s).Agent stores its constructor options on `.options`. */ @@ -64,23 +60,20 @@ describe("QWP wss:: connect-string verifies the server certificate", () => { expect(tls.pfx).toBeUndefined(); }); - it("loads a PFX trust store with its passphrase", async () => { - const dir = await mkdtemp(join(tmpdir(), "qwp-pfx-roots-")); - const store = join(dir, "roots.p12"); - const bytes = Uint8Array.of(1, 2, 3, 4); - await writeFile(store, bytes); - try { - const options = qwpNode.parseQwpNodeClientConfig( - `wss::addr=localhost;tls_roots=${store};tls_roots_password=secret;`, - ); - const tls = agentTlsOptions(options.ingress.agent); - expect(tls.rejectUnauthorized).toBe(true); - expect(tls.pfx).toEqual(Buffer.from(bytes)); - expect(tls.passphrase).toBe("secret"); - expect(tls.ca).toBeUndefined(); - } finally { - await rm(dir, { recursive: true, force: true }); - } + it("rejects password-protected PKCS#12 trust stores with PEM guidance", () => { + expect(() => + qwpNode.parseQwpNodeClientConfig( + "wss::addr=localhost;tls_roots=roots.p12;tls_roots_password=secret;", + ), + ).toThrow(/tls_roots_password.*PEM-encoded CA certificates.*PKCS#12/); + }); + + it("rejects non-PEM tls_roots before opening a connection", () => { + expect(() => + qwpNode.parseQwpNodeClientConfig( + "wss::addr=localhost;tls_roots=package.json;", + ), + ).toThrow(/valid PEM-encoded CA certificates.*PKCS#12/); }); it("disables verification only when tls_verify=unsafe_off is explicit", () => { From 8c47a5eaf3d07b11400a99a297cf3078bc0bcac2 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 26 Aug 2026 11:32:33 +0100 Subject: [PATCH 196/265] fix(qwp): preserve ESM runtime identity --- src/sender.ts | 41 ++++++++++---------------------- test/qwp/dist.e2e.ts | 56 +++++++++++++++++--------------------------- 2 files changed, 35 insertions(+), 62 deletions(-) diff --git a/src/sender.ts b/src/sender.ts index d407c0c..8799811 100644 --- a/src/sender.ts +++ b/src/sender.ts @@ -14,39 +14,24 @@ import { import { SenderTransport, createTransport } from "./transport"; import { SenderBuffer, createBuffer } from "./buffer"; import { isBoolean, isInteger, TimestampUnit } from "./utils"; -import { createRequire } from "node:module"; import { QWP_INGRESS_PATH } from "./_qwp/_core"; +import * as qwpNodeModule from "./qwp/node"; import type { QwpSender } from "./qwp/node"; import type { QwpTableWriter } from "./_qwp/sender"; import type { QwpWriterSchema } from "./_qwp/writer"; -// The QWP Node subsystem statically pulls in ws, node:dgram and node:os. Load -// it lazily -- only when a ws/wss/udp Sender is actually built -- so the http -// and tcp consumers that make up the bulk of the root entry's callers keep a -// lean module graph and do not hard-depend on ws being resolvable. The -// package's own subpath is used so both the ESM and CJS builds resolve the -// matching qwp/node artifact through the "exports" condition. -type QwpNodeModule = typeof import("./qwp/node"); -let qwpNodeModule: QwpNodeModule | undefined; -function loadQwpNode(): QwpNodeModule { - if (!qwpNodeModule) { - qwpNodeModule = createRequire(import.meta.url)( - "@questdb/nodejs-client/qwp/node", - ) as QwpNodeModule; - } - return qwpNodeModule; -} +// Import the package's QWP Node entry so each root build stays in its own +// module universe: Bunchee rewrites this entry import to qwp/node.mjs for ESM +// and qwp/node.js for CommonJS. Loading the CommonJS condition from the ESM +// root would duplicate every QWP class and break instanceof across the +// documented root and /qwp/node entry points. /** - * @internal Warms the cache through a dynamic import rather than the - * synchronous require above. The require resolves the published qwp/node - * artifact through the package `exports`; a suite running against `src/` has no - * such artifact for its live source, so it preloads first to share the very - * module instance it imported -- keeping spies and source edits effective. The - * built entry never calls this: the synchronous fallback covers it. + * @internal Retained for source-level suites that preload QWP before installing + * spies. The production root already imports the matching-format entry. */ -export async function preloadQwpNode(): Promise { - qwpNodeModule ??= await import("./qwp/node"); +export function preloadQwpNode(): Promise { + return Promise.resolve(); } const DEFAULT_AUTO_FLUSH_INTERVAL = 1000; // 1 sec @@ -164,7 +149,7 @@ class Sender { ? // SenderOptions already parsed the ws/wss connect string with the // QWP schema, so there is one vocabulary and one parser however the // sender was constructed. - loadQwpNode().createQwpNodeSender( + qwpNodeModule.createQwpNodeSender( resolved.ingress, resolved.sender, resolved.ingressSession, @@ -643,7 +628,7 @@ function createConfiguredQwpSender( // resolveQwpNodeClientConfig(). This path builds a sender from a // programmatic options object, so it reads options.qwp.* directly. const storeAndForward = configuredWebSocket.storeAndForward; - return loadQwpNode().createQwpNodeSender( + return qwpNodeModule.createQwpNodeSender( { ...configuredWebSocket, storeAndForward, @@ -684,7 +669,7 @@ function createConfiguredQwpUdpSender( const configuredSender = options.qwp?.sender ?? {}; const maxDatagramSize = options.max_datagram_size ?? configuredUdp.maxDatagramSize ?? 1_400; - return loadQwpNode().createQwpNodeUdpSender( + return qwpNodeModule.createQwpNodeUdpSender( { ...configuredUdp, host: options.host, diff --git a/test/qwp/dist.e2e.ts b/test/qwp/dist.e2e.ts index 9ec43ba..45c6e3f 100644 --- a/test/qwp/dist.e2e.ts +++ b/test/qwp/dist.e2e.ts @@ -106,9 +106,10 @@ describe.each(["import", "require"] as const)( }, ); - it("compiles a writer on the package-root Sender", async () => { + it("keeps package-root writer and error identity across QWP entries", async () => { const root: any = await load(".", format); const qwp: any = await load("./qwp", format); + const node: any = await load("./qwp/node", format); const sender = await root.Sender.fromConfig( "ws::addr=127.0.0.1:9;auto_flush=off;", @@ -118,6 +119,26 @@ describe.each(["import", "require"] as const)( await stageTwoRows(trades); expect(sender.publishedSequence).toBe(-1n); + expect(trades).toBeInstanceOf(qwp.QwpTableWriter); + expect(trades).toBeInstanceOf(node.QwpTableWriter); + + let rowError: unknown; + try { + await trades.row({ + symbol: "SOL-USD", + price: "not-a-number", + timestamp: 3n, + }); + } catch (error) { + rowError = error; + } + expect(rowError).toBeInstanceOf(qwp.QwpWriterRowError); + expect(rowError).toBeInstanceOf(node.QwpWriterRowError); + + const otherFormat = format === "import" ? "require" : "import"; + const otherNode: any = await load("./qwp/node", otherFormat); + expect(trades).not.toBeInstanceOf(otherNode.QwpTableWriter); + expect(rowError).not.toBeInstanceOf(otherNode.QwpWriterRowError); }); it("re-exported factories keep the identity of their defining bundle", async () => { @@ -188,39 +209,6 @@ describe("store-and-forward locking", () => { }, ); - it.each(["import", "require"] as const)( - "loads the QWP Node subsystem only when a ws/wss/udp sender is built (%s)", - async (format) => { - // The root entry must not eagerly pull ws, node:dgram and node:os onto - // the module graph: http/tcp consumers -- the bulk of callers -- would - // pay for the whole QWP Node subsystem and hard-depend on ws resolving. - // It must still load lazily and work on the first ws/wss/udp sender. - const target = resolveExport(".", format); - const probe = - '({ ws: !!require.cache[require.resolve("ws")],' + - " dgram: process.moduleLoadList.some((m) => /dgram/.test(m)) })"; - const body = - `const before = ${probe};` + - ' const sender = new Sender({ protocol: "udp", host: "127.0.0.1", port: 9007 });' + - ` const after = ${probe};` + - " console.log(JSON.stringify({ before, after, table: typeof sender.table }));"; - const load_ = - format === "require" - ? `const { Sender } = require(${JSON.stringify(target)}); ${body}` - : `import(${JSON.stringify(pathToFileURL(target).href)}).then(({ Sender }) => { ${body} });`; - - const { code, stdout, stderr } = await runNode(load_); - expect(stderr).toBe(""); - expect(code).toBe(0); - const result = JSON.parse(stdout.trim()); - // Root load alone leaves the QWP Node subsystem off the graph. - expect(result.before).toEqual({ ws: false, dgram: false }); - // The first ws/wss/udp sender lazily loads it, and is functional. - expect(result.after).toEqual({ ws: true, dgram: true }); - expect(result.table).toBe("function"); - }, - ); - it("ships the slot lock in the bundle with no native addon", async () => { for (const format of ["import", "require"] as const) { const bundle = await readFile( From 303a6bbecae0ca61d908d0310c2d4f9dd6e43dd5 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 26 Aug 2026 11:38:35 +0100 Subject: [PATCH 197/265] fix(qwp): accept exponent decimal numbers --- src/_qwp/sender.ts | 16 ++++++-- test/qwp/sender.test.ts | 82 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 3 deletions(-) diff --git a/src/_qwp/sender.ts b/src/_qwp/sender.ts index bef9eab..5ca9e95 100644 --- a/src/_qwp/sender.ts +++ b/src/_qwp/sender.ts @@ -412,13 +412,23 @@ function parseDecimal(value: string | number): { scale: number; } { const text = String(value); - const match = /^([+-]?)(\d+)(?:\.(\d+))?$/.exec(text); + const match = + typeof value === "number" + ? /^([+-]?)(\d+)(?:\.(\d+))?(?:[eE]([+-]?\d+))?$/.exec(text) + : /^([+-]?)(\d+)(?:\.(\d+))?$/.exec(text); if (!match) throw new TypeError(`invalid decimal value '${text}'`); const fraction = match[3] ?? ""; - const magnitude = BigInt(`${match[2]}${fraction}`); + const exponent = match[4] === undefined ? 0 : Number(match[4]); + let digits = `${match[2]}${fraction}`; + let scale = fraction.length - exponent; + if (scale < 0) { + digits += "0".repeat(-scale); + scale = 0; + } + const magnitude = BigInt(digits); return { unscaled: match[1] === "-" ? -magnitude : magnitude, - scale: fraction.length, + scale, }; } diff --git a/test/qwp/sender.test.ts b/test/qwp/sender.test.ts index 7e8ed53..83dedcc 100644 --- a/test/qwp/sender.test.ts +++ b/test/qwp/sender.test.ts @@ -1425,6 +1425,88 @@ describe("QWP high-level sender", () => { expect(() => encodeQwpIngressFrame([table])).not.toThrow(); }); + it("accepts exact number decimals rendered in exponent notation", async () => { + const session = new RecordingSession(); + const sender = new QwpSender(async () => session, { autoFlush: false }); + const typed = sender.writer("typed_decimals", { + fraction: decimal128(20), + whole128: decimal128(0), + whole256: decimal256(0), + timestamp: designatedTimestamp("ns"), + }); + + // Both values stringify with an exponent even though they are exactly + // representable at their declared decimal scales. + await typed.row({ + fraction: 2 ** -20, + whole128: 1e21, + whole256: 1e21, + timestamp: 1n, + }); + await sender + .table("fluent_decimals") + .decimalColumnText("fraction", 2 ** -20) + .decimalColumnText("whole", 1e21) + .atNow(); + await sender.flush(); + + const typedTable = session.sends[0].tables.find( + (table) => table.name === "typed_decimals", + )!; + expect(column(typedTable, "fraction")).toMatchObject({ + type: QWP_COLUMN_TYPE.DECIMAL128, + decimalScale: 20, + values: [95_367_431_640_625n], + }); + expect(column(typedTable, "whole128")).toMatchObject({ + type: QWP_COLUMN_TYPE.DECIMAL128, + decimalScale: 0, + values: [1_000_000_000_000_000_000_000n], + }); + expect(column(typedTable, "whole256")).toMatchObject({ + type: QWP_COLUMN_TYPE.DECIMAL256, + decimalScale: 0, + values: [1_000_000_000_000_000_000_000n], + }); + + const fluentTable = session.sends[0].tables.find( + (table) => table.name === "fluent_decimals", + )!; + expect(column(fluentTable, "fraction")).toMatchObject({ + type: QWP_COLUMN_TYPE.DECIMAL256, + decimalScale: 20, + values: [95_367_431_640_625n], + }); + expect(column(fluentTable, "whole")).toMatchObject({ + type: QWP_COLUMN_TYPE.DECIMAL256, + decimalScale: 0, + values: [1_000_000_000_000_000_000_000n], + }); + await sender.close(); + }); + + it("still enforces decimal scale and width after expanding exponents", async () => { + const sender = new QwpSender(async () => new RecordingSession(), { + autoFlush: false, + }); + const typed = sender.writer("typed_decimals", { + fraction: decimal128(19), + whole128: decimal128(0), + whole256: decimal256(0), + }); + + await expect(typed.row({ fraction: 2 ** -20 })).rejects.toThrow( + /not exactly representable at scale 19/, + ); + await expect(typed.row({ whole128: 2e38 })).rejects.toThrow( + /exceeds signed int128/, + ); + await expect(typed.row({ whole256: 6e76 })).rejects.toThrow( + /exceeds signed int256/, + ); + await sender.close(); + }); + it("encodes a UUID identically from text, canonical bytes, and limbs", async () => { // The 16-byte form is canonical RFC 4122 order -- what uuid.parse() and // java.util.UUID hand back. Passing those bytes through verbatim would From 818eb9671ac5d16b130edb3a449f084c8ffd7c97 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 26 Aug 2026 12:55:56 +0100 Subject: [PATCH 198/265] test(qwp): verify custom PEM root handshake --- test/qwp/wss-tls-security.test.ts | 43 +++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/test/qwp/wss-tls-security.test.ts b/test/qwp/wss-tls-security.test.ts index 3c5c8c3..ca65fc3 100644 --- a/test/qwp/wss-tls-security.test.ts +++ b/test/qwp/wss-tls-security.test.ts @@ -1,6 +1,7 @@ import { readFileSync } from "node:fs"; import * as http from "node:http"; import * as https from "node:https"; +import type { AddressInfo } from "node:net"; import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import * as qwpNode from "../../src/qwp/node"; import { Sender, preloadQwpNode } from "../../src/sender"; @@ -60,6 +61,48 @@ describe("QWP wss:: connect-string verifies the server certificate", () => { expect(tls.pfx).toBeUndefined(); }); + it("trusts a server signed by the configured PEM root", async () => { + const server = https.createServer( + { + key: readFileSync("test/certs/server/server.key"), + cert: readFileSync("test/certs/server/server.crt"), + }, + (_request, response) => { + response.end("ok"); + }, + ); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + + try { + const port = (server.address() as AddressInfo).port; + const options = qwpNode.parseQwpNodeClientConfig( + `wss::addr=127.0.0.1:${port};tls_roots=${CA_PATH};`, + ); + await new Promise((resolve, reject) => { + const request = https.get( + { + hostname: "127.0.0.1", + port, + agent: options.ingress.agent as https.Agent, + }, + (response) => { + response.resume(); + response.once("end", resolve); + response.once("error", reject); + }, + ); + request.once("error", reject); + }); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } + }); + it("rejects password-protected PKCS#12 trust stores with PEM guidance", () => { expect(() => qwpNode.parseQwpNodeClientConfig( From 5284c8f3f53eb77e789f85da6850b5f69780b6ca Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 26 Aug 2026 14:44:35 +0100 Subject: [PATCH 199/265] fix(qwp): preserve typed ingress overrides --- QWP.md | 10 ++++++++++ src/options.ts | 27 ++++++++++++++++++++++----- test/options.test.ts | 43 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 5 deletions(-) diff --git a/QWP.md b/QWP.md index d7f314e..08af0a5 100644 --- a/QWP.md +++ b/QWP.md @@ -87,6 +87,13 @@ string can configure a sender, a query client, or the pooled facade. Every key also has a programmatic equivalent on the corresponding options object; the connect string is the portable spelling. +The complete connect string is parsed and validated before typed overrides are +applied. When both forms set the same option, the typed value wins. In +particular, `Sender.fromConfig()` applies `qwp.webSocket.failoverUrls`, +`target`, `zone`, and `senderId` after URL parsing. The primary ingress URL +continues to come from `addr`, because the typed object intentionally omits +`url`. + ### Connection | Key | Value | Default | Meaning | @@ -235,6 +242,9 @@ const sender = await Sender.fromConfig( connectTimeoutMs: 5_000, authTimeoutMs: 15_000, failoverUrls: ["wss://questdb-dr.example:9000/write/v4"], + target: "any", + zone: "eu-west-1a", + senderId: "producer-a", storeAndForward: { directory: "/var/lib/my-service/qwp-replay/producer-a", maxBytes: 512 * 1024 * 1024, diff --git a/src/options.ts b/src/options.ts index 9bf46f1..bc60b36 100644 --- a/src/options.ts +++ b/src/options.ts @@ -57,8 +57,14 @@ function resolveQwpConfig( configString: string, ): QwpNodeClientOptions { const configuredWebSocket = options.qwp?.webSocket; - const { storeAndForward, failoverUrls, ...webSocketOverrides } = - configuredWebSocket ?? {}; + const { + storeAndForward, + failoverUrls, + target, + zone, + senderId, + ...webSocketOverrides + } = configuredWebSocket ?? {}; const agent = webSocketOverrides.agent ?? selectQwpSchemeAgent(options.agent, options.protocol === WSS); @@ -77,12 +83,19 @@ function resolveQwpConfig( }, ingressSession: options.qwp?.session, }); - if (failoverUrls === undefined) return resolved; + + // The primary URL remains derived from `addr`, which the typed options do + // not expose. Fields available in both forms are applied only after the + // complete connect string has been parsed and validated, so typed ingress + // routing and producer identity cannot be overwritten by URL defaults. return { ...resolved, ingress: { ...resolved.ingress, - failoverUrls, + ...(failoverUrls === undefined ? {} : { failoverUrls }), + ...(target === undefined ? {} : { target }), + ...(zone === undefined ? {} : { zone }), + ...(senderId === undefined ? {} : { senderId }), }, }; } @@ -111,7 +124,11 @@ const PROTOCOL_VERSION_V3 = "3"; const LINE_PROTO_SUPPORT_VERSION = "line.proto.support.versions"; type QwpExtraOptions = { - /** Node WebSocket and persistent store-and-forward options. */ + /** + * Node ingress overrides. Values are applied after the connect string has + * been fully parsed and validated; typed values win when both forms set the + * same option. + */ webSocket?: Omit; /** Ingress ACK, durable-ACK, and reconnect options. */ session?: QwpIngressSessionOptions; diff --git a/test/options.test.ts b/test/options.test.ts index 17793fb..9366bb7 100644 --- a/test/options.test.ts +++ b/test/options.test.ts @@ -904,6 +904,49 @@ describe("Configuration string parser suite", function () { } }); + it("applies typed QWP ingress overrides after URL parsing", async function () { + const options = await SenderOptions.fromConfig( + "ws::addr=url-primary:9000,url-secondary:9001;" + + "target=primary;zone=url-zone;sender_id=url-sender;", + { + qwp: { + webSocket: { + failoverUrls: ["ws://typed-secondary:9100/custom-write"], + target: "replica", + zone: "typed-zone", + senderId: "typed-sender", + }, + }, + }, + ); + const resolved = qwpConfig(options); + + expect(String(resolved?.ingress.url)).toBe( + "ws://url-primary:9000/write/v4", + ); + expect(resolved?.ingress.failoverUrls?.map(String)).toEqual([ + "ws://typed-secondary:9100/custom-write", + ]); + expect(resolved?.ingress).toMatchObject({ + target: "replica", + zone: "typed-zone", + senderId: "typed-sender", + }); + expect(resolved?.egress.failoverUrls?.map(String)).toEqual([ + "ws://url-secondary:9001/read/v1", + ]); + expect(resolved?.egress).toMatchObject({ + target: "primary", + zone: "url-zone", + }); + + await expect( + SenderOptions.fromConfig("ws::addr=url-primary:9000;target=not-a-role;", { + qwp: { webSocket: { target: "replica" } }, + }), + ).rejects.toThrow(/target/); + }); + it("leaves QWP-only keys to the QWP schema", async function () { // close_flush_timeout_millis, initial_connect_retry and // catch_up_cap_gap_min_escalation_window_millis are QWP vocabulary. This From de73067c296d7bd50d7eb06fbc8abdd3e7e073a6 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 26 Aug 2026 14:44:39 +0100 Subject: [PATCH 200/265] fix(qwp): avoid ACK deadlines for browser polls --- README.md | 4 +- src/_qwp/ingress-session.ts | 34 ++++++++++-- test/qwp/session.test.ts | 107 ++++++++++++++++++++++++++++++++++++ 3 files changed, 139 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index ca7c56f..678d7c3 100644 --- a/README.md +++ b/README.md @@ -384,7 +384,9 @@ Browsers can request durable ingress acknowledgements without custom HTTP headers. The client offers a QWP WebSocket subprotocol and verifies that the server selected it before sending data. Browser keepalives use side-effect-free, table-less QWP poll frames because the WebSocket API does not expose -protocol-level PING frames. +protocol-level PING frames. A poll completes once published: durable progress +arrives independently, and an open deferred transaction may intentionally +prevent the server from sending a cumulative OK for that poll. ```typescript const sender = await connectQwpBrowserSender( diff --git a/src/_qwp/ingress-session.ts b/src/_qwp/ingress-session.ts index 6d393b4..fa2ef52 100644 --- a/src/_qwp/ingress-session.ts +++ b/src/_qwp/ingress-session.ts @@ -202,7 +202,7 @@ export interface QwpIngressSessionOptions { /** * Enables durable-ACK tracking. While committed table transactions await * durable upload, Node transports send WebSocket PING frames and browser - * transports send table-less QWP commit frames. Zero keeps tracking enabled + * transports send table-less QWP poll frames. Zero keeps tracking enabled * but disables automatic polling. */ durableAckKeepaliveMs?: number; @@ -1044,6 +1044,7 @@ export class QwpIngressSession { private startFrameWithPublication( frame: Uint8Array, publicationBarrier: Promise = this.sendTail, + ackTimeoutEnabled = true, ): QwpIngressSendResult { this.throwIfUnavailable(); const ackDeferredUntilCommit = @@ -1105,7 +1106,7 @@ export class QwpIngressSession { // group-closing frame has its own deadline and cumulatively resolves // this waiter, so starting a per-frame timer here would make valid // transactions fail merely because they stayed open for ackTimeoutMs. - if (ackDeferredUntilCommit) return; + if (ackDeferredUntilCommit || !ackTimeoutEnabled) return; pending.timer = setTimeout(() => { if (!this.pending.delete(sequence)) return; const error = new Error( @@ -1271,13 +1272,36 @@ export class QwpIngressSession { /** * Prompts the server to publish its latest durable-ingress watermarks. * Node transports use a WebSocket PING; browsers send the protocol-level - * table-less durable-ACK poll frame. + * table-less durable-ACK poll frame. Browser completion means the control + * frame was published; durable progress arrives independently because the + * server may withhold its cumulative OK while a transaction remains open. */ pollDurableAck(): Promise { this.throwIfUnavailable(); return this.connection.ping ? this.connection.ping() - : this.sendFrame(encodeQwpDurableAckPollFrame()).then(() => undefined); + : this.publishBrowserDurableAckPoll(); + } + + /** + * Publishes a browser control poll without an ordinary ACK deadline. + * + * QuestDB can answer this frame with durable progress but deliberately defer + * its cumulative OK while an earlier transaction is still open. Retaining an + * untimed internal waiter preserves NACK handling and lets a later cumulative + * OK retire the poll sequence; callers only wait for local publication. + */ + private publishBrowserDurableAckPoll(): Promise { + const poll = this.startFrameWithPublication( + encodeQwpDurableAckPollFrame(), + this.sendTail, + false, + ); + void poll.acknowledgement.catch((error: unknown) => { + if (this.closing || this.failure) return; + this.fail(error); + }); + return poll.publication; } /** @internal Registers runtime-specific cleanup owned by this session. */ @@ -1605,7 +1629,7 @@ export class QwpIngressSession { } const poll = this.connection.ping ? this.connection.ping() - : this.sendFrame(encodeQwpDurableAckPollFrame()).then(() => undefined); + : this.publishBrowserDurableAckPoll(); void poll .then(() => this.scheduleDurablePoll()) .catch((error: unknown) => this.fail(error)); diff --git a/test/qwp/session.test.ts b/test/qwp/session.test.ts index 45f0f99..73a2bb6 100644 --- a/test/qwp/session.test.ts +++ b/test/qwp/session.test.ts @@ -2466,6 +2466,113 @@ describe("QwpIngressSession", () => { } }); + it("does not ACK-timeout a browser durable poll behind a deferred frame", async () => { + vi.useFakeTimers(); + try { + const socket = new FakeWebSocket(); + socket.protocol = QWP_DURABLE_ACK_WEBSOCKET_PROTOCOL; + const connecting = connectQwpBrowserIngress( + { + url: "ws://localhost:9000/write/v4", + requestDurableAck: true, + ingressNegotiationTimeoutMs: 0, + webSocketFactory: () => asQwpSocket(socket), + }, + { + ackTimeoutMs: 20, + durableAckKeepaliveMs: 5, + }, + ); + socket.open(); + const session = await connecting; + socket.onSend = () => { + if (socket.sent.length === 1) { + socket.message( + ingressResponse(QWP_STATUS.OK, 0n, undefined, [["trades", 42n]]), + ); + } else if (socket.sent.length === 3) { + // The tandem server reports durable progress for the poll but does + // not cumulatively OK it while sequence 1 remains deferred. + socket.message(durableResponse([["trades", 42n]])); + } else if (socket.sent.length === 4) { + socket.message( + ingressResponse(QWP_STATUS.OK, 3n, undefined, [["trades", 43n]]), + ); + } + }; + + const committed = await session.sendFrame( + encodeQwpIngressFrame([longTable("trades", [1n])]), + ); + const durable = session.waitForDurable(committed); + const deferred = session.sendFrameWithPublication( + encodeQwpIngressFrame([longTable("trades", [2n])], { + deferCommit: true, + }), + ); + await deferred.publication; + let deferredState: "pending" | "resolved" | "rejected" = "pending"; + void deferred.acknowledgement.then( + () => { + deferredState = "resolved"; + }, + () => { + deferredState = "rejected"; + }, + ); + + await vi.advanceTimersByTimeAsync(5); + await expect(durable).resolves.toBeUndefined(); + expect(socket.sent[2]).toEqual(encodeQwpDurableAckPollFrame()); + + await vi.advanceTimersByTimeAsync(40); + expect(deferredState).toBe("pending"); + expect(session.metrics.lastError).toBeUndefined(); + + const commit = session.sendFrame(encodeQwpIngressFrame([])); + await expect(commit).resolves.toMatchObject({ sequence: 3n }); + await expect(deferred.acknowledgement).resolves.toMatchObject({ + sequence: 3n, + }); + expect(session.metrics.pendingResponses).toBe(0); + await session.close(); + } finally { + vi.useRealTimers(); + } + }); + + it("still terminates a browser session when a durable poll is NACKed", async () => { + const socket = new FakeWebSocket(); + socket.protocol = QWP_DURABLE_ACK_WEBSOCKET_PROTOCOL; + const connecting = connectQwpBrowserIngress( + { + url: "ws://localhost:9000/write/v4", + requestDurableAck: true, + ingressNegotiationTimeoutMs: 0, + webSocketFactory: () => asQwpSocket(socket), + }, + { + onError: () => undefined, + onSenderError: () => undefined, + }, + ); + socket.open(); + const session = await connecting; + socket.onSend = () => { + socket.message( + ingressResponse(QWP_STATUS.PARSE_ERROR, 0n, "invalid durable poll"), + ); + }; + + await expect(session.pollDurableAck()).resolves.toBeUndefined(); + await vi.waitFor(() => { + expect(() => session.publishFrame(Uint8Array.of(1))).toThrow( + "invalid durable poll", + ); + }); + await session.close(); + }); + it("rejects the matching frame on NACK without breaking later ACKs", async () => { const socket = new FakeWebSocket(); const connecting = connectQwpBrowserWebSocket({ From f2aca2dc278ef082c98d51139d29cbbdd1f9a31f Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 26 Aug 2026 14:55:17 +0100 Subject: [PATCH 201/265] fix(qwp): require durable ACK negotiation for polling --- README.md | 5 +++- src/_qwp/ingress-session.ts | 21 +++++++++++++-- src/qwp/browser.ts | 8 ++++++ test/qwp/session.test.ts | 54 +++++++++++++++++++++++++++++++++++++ 4 files changed, 85 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 678d7c3..bf43535 100644 --- a/README.md +++ b/README.md @@ -386,7 +386,10 @@ server selected it before sending data. Browser keepalives use side-effect-free, table-less QWP poll frames because the WebSocket API does not expose protocol-level PING frames. A poll completes once published: durable progress arrives independently, and an open deferred transaction may intentionally -prevent the server from sending a cumulative OK for that poll. +prevent the server from sending a cumulative OK for that poll. Supplying +`durableAckKeepaliveMs` requires durable negotiation (`requestDurableAck: true`, +either explicit or implied by `awaitDurableAck`); manual polls and durable waits +reject locally when the capability was not negotiated. ```typescript const sender = await connectQwpBrowserSender( diff --git a/src/_qwp/ingress-session.ts b/src/_qwp/ingress-session.ts index fa2ef52..f3154cd 100644 --- a/src/_qwp/ingress-session.ts +++ b/src/_qwp/ingress-session.ts @@ -203,7 +203,8 @@ export interface QwpIngressSessionOptions { * Enables durable-ACK tracking. While committed table transactions await * durable upload, Node transports send WebSocket PING frames and browser * transports send table-less QWP poll frames. Zero keeps tracking enabled - * but disables automatic polling. + * but disables automatic polling. Factory-created browser sessions require + * requestDurableAck=true when this option is supplied. */ durableAckKeepaliveMs?: number; /** @@ -1242,6 +1243,11 @@ export class QwpIngressSession { new Error("durable ACK tracking is not enabled for this session"), ); } + if (!this.connection.handshake.durableAckEnabled) { + return Promise.reject( + new Error("durable ACK was not negotiated for this session"), + ); + } if (response.status !== QWP_STATUS.OK) { return Promise.reject( new Error("only a successful QWP ACK can be awaited for durability"), @@ -1278,6 +1284,11 @@ export class QwpIngressSession { */ pollDurableAck(): Promise { this.throwIfUnavailable(); + if (!this.connection.handshake.durableAckEnabled) { + return Promise.reject( + new Error("durable ACK was not negotiated for this session"), + ); + } return this.connection.ping ? this.connection.ping() : this.publishBrowserDurableAckPoll(); @@ -1499,7 +1510,12 @@ export class QwpIngressSession { } private trackDurableTargets(response: QwpIngressResponse): void { - if (this.options.durableAckKeepaliveMs === undefined) return; + if ( + this.options.durableAckKeepaliveMs === undefined || + !this.connection.handshake.durableAckEnabled + ) { + return; + } for (const table of response.tables) { const durable = this.durableWatermarks.get(table.name); if (durable !== undefined && durable >= table.sequenceTransaction) { @@ -1613,6 +1629,7 @@ export class QwpIngressSession { if ( interval === undefined || interval === 0 || + !this.connection.handshake.durableAckEnabled || this.pendingDurableTargets.size === 0 || this.durablePollTimer ) { diff --git a/src/qwp/browser.ts b/src/qwp/browser.ts index ae7952a..f027f2e 100644 --- a/src/qwp/browser.ts +++ b/src/qwp/browser.ts @@ -663,6 +663,14 @@ export async function connectQwpBrowserIngress( /** Cancels a first connect still negotiating; see QwpIngressSession.connect. */ signal?: AbortSignal, ): Promise { + if ( + sessionOptions.durableAckKeepaliveMs !== undefined && + options.requestDurableAck !== true + ) { + throw new RangeError( + "durableAckKeepaliveMs requires requestDurableAck=true for browser ingress", + ); + } const effectiveSessionOptions: QwpIngressSessionOptions = { ...sessionOptions, durableAckKeepaliveMs: options.requestDurableAck diff --git a/test/qwp/session.test.ts b/test/qwp/session.test.ts index 73a2bb6..8dba3e6 100644 --- a/test/qwp/session.test.ts +++ b/test/qwp/session.test.ts @@ -1998,6 +1998,23 @@ describe("QwpIngressSession", () => { expect(factoryCalls).toBe(0); }); + it("rejects browser durable keepalives without requesting negotiation", async () => { + let factoryCalls = 0; + await expect( + connectQwpBrowserIngress( + { + url: "ws://localhost:9000/write/v4", + webSocketFactory: () => { + factoryCalls++; + return asQwpSocket(new FakeWebSocket()); + }, + }, + { durableAckKeepaliveMs: 5 }, + ), + ).rejects.toThrow("durableAckKeepaliveMs requires requestDurableAck=true"); + expect(factoryCalls).toBe(0); + }); + it("close aborts a send blocked by browser backpressure", async () => { vi.useFakeTimers(); try { @@ -2385,8 +2402,10 @@ describe("QwpIngressSession", () => { vi.useFakeTimers(); try { const socket = new FakePingWebSocket(); + socket.protocol = QWP_DURABLE_ACK_WEBSOCKET_PROTOCOL; const connecting = connectQwpBrowserWebSocket({ url: "ws://localhost:9000/write/v4", + requestDurableAck: true, webSocketFactory: () => asQwpSocket(socket), }); socket.open(); @@ -2421,6 +2440,41 @@ describe("QwpIngressSession", () => { } }); + it("does not poll when durable ACK was not negotiated", async () => { + vi.useFakeTimers(); + try { + const socket = new FakeWebSocket(); + const connecting = connectQwpBrowserWebSocket({ + url: "ws://localhost:9000/write/v4", + webSocketFactory: () => asQwpSocket(socket), + }); + socket.open(); + const session = new QwpIngressSession(await connecting, { + durableAckKeepaliveMs: 5, + }); + socket.onSend = () => { + socket.message( + ingressResponse(QWP_STATUS.OK, 0n, undefined, [["trades", 42n]]), + ); + }; + + const ack = await session.sendFrame(Uint8Array.of(1)); + await vi.advanceTimersByTimeAsync(20); + expect(socket.sent).toHaveLength(1); + expect(session.metrics.pendingDurableTables).toBe(0); + await expect(session.waitForDurable(ack)).rejects.toThrow( + "durable ACK was not negotiated", + ); + await expect(session.pollDurableAck()).rejects.toThrow( + "durable ACK was not negotiated", + ); + expect(socket.sent).toHaveLength(1); + await session.close(); + } finally { + vi.useRealTimers(); + } + }); + it("polls durable progress with table-less QWP frames in browsers", async () => { vi.useFakeTimers(); try { From 002b81debd7c31a9a0dd8a6f6d4b8cb0e5197c86 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 26 Aug 2026 21:53:09 +0100 Subject: [PATCH 202/265] fix(qwp): stop terminal replay retry loops --- .../reconnecting-ingress-connection.ts | 29 ++-- src/qwp-node/orphan-drainer.ts | 9 +- test/qwp/public-api.test.ts | 1 + test/qwp/reconnect.test.ts | 124 ++++++++++++++---- 4 files changed, 116 insertions(+), 47 deletions(-) diff --git a/src/_qwp/_internal/reconnecting-ingress-connection.ts b/src/_qwp/_internal/reconnecting-ingress-connection.ts index 49ff9d0..2693807 100644 --- a/src/_qwp/_internal/reconnecting-ingress-connection.ts +++ b/src/_qwp/_internal/reconnecting-ingress-connection.ts @@ -62,7 +62,7 @@ const MEMORY_REPLAY_RECORD_OVERHEAD_BYTES = 64; type ConnectAttemptPolicy = "single" | "configured" | "unbounded"; -class QwpCatchUpCapGapError extends RangeError { +export class QwpCatchUpCapGapError extends RangeError { constructor( readonly symbolId: number, readonly frameLength: number, @@ -1181,7 +1181,7 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { // The wire payload decoded successfully. Failures from this point // are local replay-store/bookkeeping failures, not evidence that // the server rejected the head frame. - if (isRetryableResponseFailure(error)) { + if (isRetryableReconnectError(error)) { // A journal fault here is usually transient: a briefly full or // read-only filesystem parks maintenanceFailure for about a // second and the store clears it on the next successful batch. @@ -2192,21 +2192,16 @@ function isRetryableReconnectError(error: unknown): boolean { isRetryableReconnectError(attempt.error), ); } - return !( - error instanceof QwpReplayRejectedError || error instanceof QwpProtocolError - ); -} - -/** - * Whether a failure raised while applying a server response should be retried - * through a reconnect rather than latching the connection terminal. - * - * Replay-store errors are declared in the Node-only layer, so the journal's own - * verdict -- structural corruption, or a slot lock another process took over -- - * is read structurally through the `retryable` flag those classes carry. - */ -function isRetryableResponseFailure(error: unknown): boolean { - if (!isRetryableReconnectError(error)) return false; + if ( + error instanceof QwpReplayRejectedError || + error instanceof QwpProtocolError + ) { + return false; + } + // Replay-store errors are declared in the Node-only layer, so the journal's + // own verdict -- structural corruption, or a slot lock another process took + // over -- is read structurally through the `retryable` flag those classes + // carry. Every reconnect/replay path must honour the same verdict. return ( (error as { retryable?: unknown } | null | undefined)?.retryable !== false ); diff --git a/src/qwp-node/orphan-drainer.ts b/src/qwp-node/orphan-drainer.ts index 78fffaa..e8ce7a4 100644 --- a/src/qwp-node/orphan-drainer.ts +++ b/src/qwp-node/orphan-drainer.ts @@ -15,7 +15,10 @@ import { QwpReplayStoreLockedError, } from "./file-replay-store"; import { QwpProtocolError } from "../_qwp/_core/errors"; -import { QwpDurableAckPersistentFailureError } from "../_qwp/_internal/reconnecting-ingress-connection"; +import { + QwpCatchUpCapGapError, + QwpDurableAckPersistentFailureError, +} from "../_qwp/_internal/reconnecting-ingress-connection"; import { QwpNotificationDispatcher } from "../_qwp/_internal/notification-dispatcher"; import { createQwpDataLossSenderError, @@ -620,7 +623,8 @@ function delay(milliseconds: number): Promise { * Only failures that are terminal by design quarantine a slot behind its * `.failed` sentinel and report the abandoned bytes as data loss: a rejected * authentication, a protocol violation, a head the server will not accept, an - * exhausted durable-ACK capability-gap episode, and a corrupt journal. + * exhausted durable-ACK or symbol catch-up capability-gap episode, and a + * corrupt journal. * Everything else -- an unreachable server, an ACK timeout, EMFILE, ENOSPC -- * is transient, and the slot is left intact for a later scan. * @@ -636,6 +640,7 @@ function isTerminalDrainFailure(error: Error): boolean { if (error instanceof QwpReplayStoreCorruptionError) return true; if (error instanceof QwpProtocolError) return true; if (error instanceof QwpReplayRejectedError) return true; + if (error instanceof QwpCatchUpCapGapError) return true; if (error instanceof QwpDurableAckPersistentFailureError) return true; if (error instanceof QwpUpgradeError) { return error.kind === QWP_UPGRADE_ERROR_KIND.AUTHENTICATION; diff --git a/test/qwp/public-api.test.ts b/test/qwp/public-api.test.ts index d4f8fbb..e109419 100644 --- a/test/qwp/public-api.test.ts +++ b/test/qwp/public-api.test.ts @@ -79,6 +79,7 @@ const nodeRuntimeContract = [ "QwpReplayStoreError", "QwpReplayStoreFullError", "QwpReplayStoreLockedError", + "QwpReplayStoreLockLostError", "QwpReplayStoreQuarantinedError", "QwpUdpDatagramTooLargeError", "QwpVersionMismatchError", diff --git a/test/qwp/reconnect.test.ts b/test/qwp/reconnect.test.ts index f2777d1..ac7b7d0 100644 --- a/test/qwp/reconnect.test.ts +++ b/test/qwp/reconnect.test.ts @@ -16,15 +16,18 @@ import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { connectQwpNodeIngress, + QWP_ORPHAN_FAILED_SENTINEL, QWP_SF_BACKPRESSURE_POLICY, QWP_SF_DURABILITY, QwpNodeFileReplayStore, + QwpNodeOrphanDrainer, QwpReplayStoreAppendTimeoutError, QwpReplayStoreCheckpointError, QwpReplayStoreCorruptionError, QwpReplayStoreError, QwpReplayStoreFullError, QwpReplayStoreLockedError, + QwpReplayStoreLockLostError, QwpReplayStoreSegmentTooLargeError, type QwpNodeReplayDataLossReport, } from "../../src/qwp/node"; @@ -1296,6 +1299,41 @@ describe("QWP ingress reconnect and replay", () => { await session.close(); }); + it("stays terminal when a replay read reports that the journal lock was lost", async () => { + const lockLost = new QwpReplayStoreLockLostError("/qwp/sender-0"); + class LockLostReadStore extends LazyTrackingReplayStore { + override async readPayload(): Promise { + throw lockLost; + } + } + + const replayStore = new LockLostReadStore(); + let factoryCalls = 0; + const session = await QwpIngressSession.connect( + async () => { + factoryCalls++; + return new FakeConnection(`node-${factoryCalls}`); + }, + { + backgroundStoreAndForward: true, + reconnect: { + maxAttempts: 0, + maxDurationMs: 0, + initialBackoffMs: 0, + maxBackoffMs: 0, + }, + replayStore, + }, + ); + + await session.publishFrame(Uint8Array.of(1)); + await expect(session.closed).resolves.toMatchObject({ code: 1011 }); + expect(session.metrics.lastError).toBe(lockLost); + expect(factoryCalls).toBe(1); + await vi.waitFor(() => expect(replayStore.closeCount).toBe(1)); + await session.close().catch(() => undefined); + }); + it("keeps an asynchronous initial authentication rejection terminal", async () => { const replayStore = new TrackingReplayStore(); let factoryCalls = 0; @@ -1745,38 +1783,68 @@ describe("QWP ingress reconnect and replay", () => { expect(foreground.metrics.lastError).toBeUndefined(); await foreground.close(); + const rootDirectory = await createTemporaryDirectory(); + const orphanDirectory = join(rootDirectory, "orphan"); + await mkdir(orphanDirectory); + const segment = Buffer.alloc(32); + segment.write("SF01", 0, "ascii"); + segment.writeUInt8(1, 4); + segment.writeUInt8(1, 24); + await writeFile(join(orphanDirectory, "sf-0000000000000000.sfa"), segment); + const orphanStore = new FailOnceDictionaryReplayStore(); orphanStore.symbols.push("x".repeat(64)); orphanStore.records.set(0n, Uint8Array.of(1)); + const senderErrors: QwpSenderError[] = []; let orphanCalls = 0; - const orphan = await QwpIngressSession.connect( - async () => { - orphanCalls++; - return new FakeConnection("primary", { - qwpVersion: 1, - maxBatchSizeBytes: 16, - }); - }, - { - backgroundStoreAndForward: true, - initialConnectMode: "async", - orphanStoreAndForward: true, - catchUpCapGapMinEscalationWindowMs: 0, - reconnect: { - initialBackoffMs: 0, - maxBackoffMs: 0, - }, - replayStore: orphanStore, - }, - ); - await orphan.closed; - await vi.waitFor(() => - expect(orphan.metrics.lastError?.message).toMatch( - /attempt=16\/16.*data must be resent/, - ), - ); - expect(orphanCalls).toBe(16); - await orphan.close(); + const drainer = new QwpNodeOrphanDrainer({ + rootDirectory, + scanIntervalMs: 0, + durableAckPollIntervalMs: 0, + createSession: async () => + QwpIngressSession.connect( + async () => { + orphanCalls++; + return new FakeConnection("primary", { + qwpVersion: 1, + maxBatchSizeBytes: 16, + }); + }, + { + backgroundStoreAndForward: true, + initialConnectMode: "async", + orphanStoreAndForward: true, + catchUpCapGapMinEscalationWindowMs: 0, + reconnect: { + initialBackoffMs: 0, + maxBackoffMs: 0, + }, + replayStore: orphanStore, + }, + ), + onSenderError: (error) => senderErrors.push(error), + }); + try { + drainer.start(); + await vi.waitFor(() => expect(drainer.metrics.failed).toBe(1)); + expect(drainer.metrics.retrying).toBe(0); + expect(orphanCalls).toBe(16); + expect(await readdir(orphanDirectory)).toContain( + QWP_ORPHAN_FAILED_SENTINEL, + ); + await vi.waitFor(() => expect(senderErrors).toHaveLength(1)); + expect(senderErrors[0]).toMatchObject({ + category: QWP_SENDER_ERROR_CATEGORY.DATA_LOSS, + appliedPolicy: QWP_SENDER_ERROR_POLICY.ABANDONED, + quarantinedPath: orphanDirectory, + serverMessage: expect.stringMatching( + /attempt=16\/16.*data must be resent/, + ), + }); + } finally { + await drainer.close(); + await rm(rootDirectory, { recursive: true, force: true }); + } }); it("preserves durable dictionary IDs after frame journal backpressure", async () => { From b19302ebc2e36da48dbd3ad270a54ff35f7c95dc Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 26 Aug 2026 23:25:53 +0100 Subject: [PATCH 203/265] perf(qwp): lazy-load root QWP module --- src/options.ts | 17 +++++++++++-- src/qwp-node/module-registry.ts | 42 +++++++++++++++++++++++++++++++++ src/sender.ts | 27 ++++++++++----------- test/qwp/dist.e2e.ts | 39 +++++++++++++++++++++++++++++- 4 files changed, 107 insertions(+), 18 deletions(-) create mode 100644 src/qwp-node/module-registry.ts diff --git a/src/options.ts b/src/options.ts index bc60b36..f0aa24c 100644 --- a/src/options.ts +++ b/src/options.ts @@ -7,7 +7,10 @@ import * as https from "https"; import { log, Logger } from "./logging"; import { fetchJson, isBoolean, isInteger } from "./utils"; import { DEFAULT_REQUEST_TIMEOUT } from "./transport/http/base"; -import { resolveQwpNodeClientConfig } from "./qwp-node/client-config"; +import { + getQwpNodeModule, + preloadQwpNodeModule, +} from "./qwp-node/module-registry"; import type { QwpNodeClientOptions } from "./qwp/node"; import type { QwpNodeIngressOptions, @@ -68,7 +71,7 @@ function resolveQwpConfig( const agent = webSocketOverrides.agent ?? selectQwpSchemeAgent(options.agent, options.protocol === WSS); - const resolved = resolveQwpNodeClientConfig(configString, { + const resolved = getQwpNodeModule().parseQwpNodeClientConfig(configString, { webSocket: { ...webSocketOverrides, agent }, storeAndForward, // The top-level logger wins, then the QWP-specific one, then the default @@ -466,6 +469,9 @@ class SenderOptions { configurationString: string, extraOptions?: ExtraOptions, ): Promise { + if (isQwpConfigurationString(configurationString)) { + await preloadQwpNodeModule(); + } const options = new SenderOptions(configurationString, extraOptions); await SenderOptions.resolveAuto(options); return options; @@ -490,6 +496,13 @@ class SenderOptions { } } +function isQwpConfigurationString(configurationString: string): boolean { + const separator = configurationString?.indexOf("::") ?? -1; + if (separator < 0) return false; + const protocol = configurationString.slice(0, separator); + return protocol === WS || protocol === WSS || protocol === UDP; +} + function parseConfigurationString( options: SenderOptions, configString: string, diff --git a/src/qwp-node/module-registry.ts b/src/qwp-node/module-registry.ts new file mode 100644 index 0000000..3ccd5bb --- /dev/null +++ b/src/qwp-node/module-registry.ts @@ -0,0 +1,42 @@ +import { createRequire } from "node:module"; + +type QwpNodeModule = typeof import("../qwp/node"); + +let qwpNodeModule: QwpNodeModule | undefined; +let qwpNodeModulePromise: Promise | undefined; + +/** + * Loads the QWP Node entry through the current bundle's module format and + * retains that exact namespace for every root-entry QWP call site. + * + * Bunchee rewrites the relative import to `qwp/node.mjs` in the ESM root and + * `qwp/node.js` in the CommonJS root. Awaiting this before a QWP SenderOptions + * or Sender is constructed therefore preserves constructor identity with the + * documented same-format `qwp/node` entry without putting it on the eager root + * module graph. + */ +export async function preloadQwpNodeModule(): Promise { + if (qwpNodeModule) return; + const loading = + qwpNodeModulePromise ?? + (qwpNodeModulePromise = import("../qwp/node") as Promise); + try { + qwpNodeModule ??= await loading; + } catch (error) { + if (qwpNodeModulePromise === loading) qwpNodeModulePromise = undefined; + throw error; + } +} + +/** Returns the one QWP Node namespace selected for this root module. */ +export function getQwpNodeModule(): QwpNodeModule { + if (!qwpNodeModule) { + // Sender's public constructor is synchronous. Async factories preload the + // matching-format entry above; this fallback retains direct-constructor + // compatibility and selects the package's CommonJS condition. + qwpNodeModule = createRequire(import.meta.url)( + "@questdb/nodejs-client/qwp/node", + ) as QwpNodeModule; + } + return qwpNodeModule; +} diff --git a/src/sender.ts b/src/sender.ts index 8799811..f7ab76e 100644 --- a/src/sender.ts +++ b/src/sender.ts @@ -14,25 +14,22 @@ import { import { SenderTransport, createTransport } from "./transport"; import { SenderBuffer, createBuffer } from "./buffer"; import { isBoolean, isInteger, TimestampUnit } from "./utils"; -import { QWP_INGRESS_PATH } from "./_qwp/_core"; -import * as qwpNodeModule from "./qwp/node"; +import { + getQwpNodeModule, + preloadQwpNodeModule, +} from "./qwp-node/module-registry"; import type { QwpSender } from "./qwp/node"; import type { QwpTableWriter } from "./_qwp/sender"; import type { QwpWriterSchema } from "./_qwp/writer"; -// Import the package's QWP Node entry so each root build stays in its own -// module universe: Bunchee rewrites this entry import to qwp/node.mjs for ESM -// and qwp/node.js for CommonJS. Loading the CommonJS condition from the ESM -// root would duplicate every QWP class and break instanceof across the -// documented root and /qwp/node entry points. +const QWP_INGRESS_PATH = "/write/v4"; /** - * @internal Retained for source-level suites that preload QWP before installing - * spies. The production root already imports the matching-format entry. + * @internal Preloads the matching-format QWP Node entry into the root module's + * registry. The async configuration factories call this automatically; it is + * exposed for source-level suites and synchronous programmatic construction. */ -export function preloadQwpNode(): Promise { - return Promise.resolve(); -} +export const preloadQwpNode = preloadQwpNodeModule; const DEFAULT_AUTO_FLUSH_INTERVAL = 1000; // 1 sec @@ -149,7 +146,7 @@ class Sender { ? // SenderOptions already parsed the ws/wss connect string with the // QWP schema, so there is one vocabulary and one parser however the // sender was constructed. - qwpNodeModule.createQwpNodeSender( + getQwpNodeModule().createQwpNodeSender( resolved.ingress, resolved.sender, resolved.ingressSession, @@ -628,7 +625,7 @@ function createConfiguredQwpSender( // resolveQwpNodeClientConfig(). This path builds a sender from a // programmatic options object, so it reads options.qwp.* directly. const storeAndForward = configuredWebSocket.storeAndForward; - return qwpNodeModule.createQwpNodeSender( + return getQwpNodeModule().createQwpNodeSender( { ...configuredWebSocket, storeAndForward, @@ -669,7 +666,7 @@ function createConfiguredQwpUdpSender( const configuredSender = options.qwp?.sender ?? {}; const maxDatagramSize = options.max_datagram_size ?? configuredUdp.maxDatagramSize ?? 1_400; - return qwpNodeModule.createQwpNodeUdpSender( + return getQwpNodeModule().createQwpNodeUdpSender( { ...configuredUdp, host: options.host, diff --git a/test/qwp/dist.e2e.ts b/test/qwp/dist.e2e.ts index 45c6e3f..b9d49d2 100644 --- a/test/qwp/dist.e2e.ts +++ b/test/qwp/dist.e2e.ts @@ -109,12 +109,14 @@ describe.each(["import", "require"] as const)( it("keeps package-root writer and error identity across QWP entries", async () => { const root: any = await load(".", format); const qwp: any = await load("./qwp", format); - const node: any = await load("./qwp/node", format); const sender = await root.Sender.fromConfig( "ws::addr=127.0.0.1:9;auto_flush=off;", { log: () => {} }, ); + // Loading the public Node entry after the root has lazily initialized + // QWP proves the registry selected this same-format module instance. + const node: any = await load("./qwp/node", format); const trades = sender.writer("trades", schemaFrom(qwp)); await stageTwoRows(trades); @@ -185,6 +187,41 @@ describe("store-and-forward locking", () => { }, ); + it.each(["import", "require"] as const)( + "loads QWP only when a ws/wss/udp root sender is built (%s)", + async (format) => { + const target = resolveExport(".", format); + const probe = + '({ ws: !!require.cache[require.resolve("ws")],' + + " dgram: process.moduleLoadList.some((m) => /dgram/.test(m)) })"; + const body = + `const before = ${probe};` + + ' const http = await Sender.fromConfig("http::addr=127.0.0.1:9000;protocol_version=1;");' + + ` const afterHttp = ${probe};` + + " await http.close();" + + ' const sender = await Sender.fromConfig("udp::addr=127.0.0.1:9007;");' + + ` const afterQwp = ${probe};` + + " await sender.close();" + + " console.log(JSON.stringify({ before, afterHttp, afterQwp, table: typeof sender.table }));"; + const load_ = + format === "require" + ? `(async () => { const { Sender } = require(${JSON.stringify(target)}); ${body} })();` + : `import(${JSON.stringify(pathToFileURL(target).href)}).then(async ({ Sender }) => { ${body} });`; + + const { code, stdout, stderr } = await runNode(load_); + expect(stderr).toBe(""); + expect(code).toBe(0); + expect(JSON.parse(stdout.trim())).toEqual({ + before: { ws: false, dgram: false }, + afterHttp: { ws: false, dgram: false }, + // ESM-loaded CommonJS dependencies are not exposed through + // require.cache; dgram is the format-independent QWP graph probe. + afterQwp: { ws: format === "require", dgram: true }, + table: "function", + }); + }, + ); + it.each(["import", "require"] as const)( "the package root loads (%s) on a platform no addon would support", async (format) => { From d565bec8f160089736ffd94b8fcef8a2d15c77e1 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 26 Aug 2026 23:37:45 +0100 Subject: [PATCH 204/265] fix(qwp): warn when ignoring incompatible agents --- src/options.ts | 37 ++++++++++++++++++---- src/sender.ts | 5 ++- test/qwp/wss-tls-security.test.ts | 51 ++++++++++++++++++++++++++++++- 3 files changed, 85 insertions(+), 8 deletions(-) diff --git a/src/options.ts b/src/options.ts index f0aa24c..108fc91 100644 --- a/src/options.ts +++ b/src/options.ts @@ -46,13 +46,33 @@ function qwpConfig(options: SenderOptions): QwpNodeClientOptions | undefined { export function selectQwpSchemeAgent( agent: unknown, secure: boolean, + logger?: Logger, ): http.Agent | undefined { if (secure) { - return agent instanceof https.Agent ? agent : undefined; + if (agent instanceof https.Agent) return agent; + } else if (agent instanceof http.Agent && !(agent instanceof https.Agent)) { + return agent; + } + if (agent !== undefined) { + const scheme = secure ? WSS : WS; + const expected = secure + ? "a Node.js https.Agent" + : "a plain Node.js http.Agent"; + const received = + agent instanceof Agent + ? "undici.Agent" + : agent instanceof https.Agent + ? "Node.js https.Agent" + : agent instanceof http.Agent + ? "Node.js http.Agent" + : ((agent as { constructor?: { name?: string } } | null) + ?.constructor?.name ?? typeof agent); + logger?.( + "warn", + `Ignoring ${received} supplied through 'agent' for QWP ${scheme}: the ws WebSocket transport requires ${expected}; configure a compatible agent through 'qwp.webSocket.agent'`, + ); } - return agent instanceof http.Agent && !(agent instanceof https.Agent) - ? agent - : undefined; + return undefined; } function resolveQwpConfig( @@ -68,9 +88,10 @@ function resolveQwpConfig( senderId, ...webSocketOverrides } = configuredWebSocket ?? {}; + const logger = options.log ?? options.qwp?.sender?.log ?? log; const agent = webSocketOverrides.agent ?? - selectQwpSchemeAgent(options.agent, options.protocol === WSS); + selectQwpSchemeAgent(options.agent, options.protocol === WSS, logger); const resolved = getQwpNodeModule().parseQwpNodeClientConfig(configString, { webSocket: { ...webSocketOverrides, agent }, storeAndForward, @@ -82,7 +103,7 @@ function resolveQwpConfig( // the other transports emit. sender: { ...options.qwp?.sender, - log: options.log ?? options.qwp?.sender?.log ?? log, + log: logger, }, ingressSession: options.qwp?.session, }); @@ -143,6 +164,10 @@ type QwpExtraOptions = { type ExtraOptions = { log?: Logger; + /** + * Transport-specific connection agent. Undici agents apply to the default + * HTTP(S) transport; QWP ws/wss requires a Node http/https agent. + */ agent?: Agent | http.Agent | https.Agent; qwp?: QwpExtraOptions; }; diff --git a/src/sender.ts b/src/sender.ts index f7ab76e..0e5b275 100644 --- a/src/sender.ts +++ b/src/sender.ts @@ -109,6 +109,8 @@ const DEFAULT_AUTO_FLUSH_INTERVAL = 1000; // 1 sec * HTTP(S) connections. A popular setting would be disabling persistent connections, in this case an agent can be * passed to the Sender with keepAlive set to false.
    * For example: Sender.fromConfig(`http::addr=host:port`, { agent: new undici.Agent({ connect: { keepAlive: false } })})
    + * An undici.Agent applies only to the default HTTP(S) transport. QWP WS/WSS uses the ws package and requires + * a Node.js http.Agent/https.Agent; an incompatible top-level agent is ignored with a warning.
    * If no custom agent is configured, the Sender will use its own agent which overrides some default values * of undici.Agent. The Sender's own agent uses persistent connections with 1 minute idle timeout, pipelines requests default to 1. *

    @@ -599,7 +601,8 @@ function createConfiguredQwpSender( const configuredSender = options.qwp?.sender ?? {}; const secure = options.protocol === WSS; let agent = - configuredWebSocket.agent ?? selectQwpSchemeAgent(options.agent, secure); + configuredWebSocket.agent ?? + selectQwpSchemeAgent(options.agent, secure, logger); if (agent) { // A caller-supplied agent is the WebSocket upgrade's sole TLS channel. // Applying tls_verify/tls_ca would silently override the agent the caller diff --git a/test/qwp/wss-tls-security.test.ts b/test/qwp/wss-tls-security.test.ts index ca65fc3..5e312e3 100644 --- a/test/qwp/wss-tls-security.test.ts +++ b/test/qwp/wss-tls-security.test.ts @@ -2,6 +2,7 @@ import { readFileSync } from "node:fs"; import * as http from "node:http"; import * as https from "node:https"; import type { AddressInfo } from "node:net"; +import { Agent as UndiciAgent } from "undici"; import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import * as qwpNode from "../../src/qwp/node"; import { Sender, preloadQwpNode } from "../../src/sender"; @@ -176,10 +177,36 @@ describe("QWP wss:: connect-string verifies the server certificate", () => { // https.Agent extends http.Agent, so the old instanceof http.Agent test // admitted a bare http.Agent that fails a wss upgrade with // ERR_INVALID_PROTOCOL. It is ignored now, leaving node's verifying default. + const logger = vi.fn(); const options = await SenderOptions.fromConfig("wss::addr=localhost;", { agent: new http.Agent(), + log: logger, }); expect(qwpConfig(options)?.ingress.agent).toBeUndefined(); + expect(logger).toHaveBeenCalledWith( + "warn", + expect.stringMatching( + /Ignoring Node\.js http\.Agent.*QWP wss.*https\.Agent/, + ), + ); + }); + + it("warns when an undici agent cannot be promoted onto wss", async () => { + const agent = new UndiciAgent(); + const logger = vi.fn(); + try { + const options = await SenderOptions.fromConfig("wss::addr=localhost;", { + agent, + log: logger, + }); + expect(qwpConfig(options)?.ingress.agent).toBeUndefined(); + expect(logger).toHaveBeenCalledWith( + "warn", + expect.stringMatching(/Ignoring undici\.Agent.*QWP wss.*https\.Agent/), + ); + } finally { + await agent.close(); + } }); }); @@ -250,9 +277,31 @@ describe("QWP programmatic wss sender applies TLS and authorization", () => { // A bare http.Agent would fail the wss upgrade with ERR_INVALID_PROTOCOL // after at()/atNow() already accepted rows. It is ignored, leaving the // verifying default agent in place instead. - const ingress = ingressFor({ agent: new http.Agent() }); + const logger = vi.fn(); + const ingress = ingressFor({ agent: new http.Agent(), log: logger }); expect(ingress.agent).toBeInstanceOf(https.Agent); expect(agentTlsOptions(ingress.agent).rejectUnauthorized).toBe(true); + expect(logger).toHaveBeenCalledWith( + "warn", + expect.stringMatching( + /Ignoring Node\.js http\.Agent.*QWP wss.*https\.Agent/, + ), + ); + }); + + it("warns before replacing an undici agent with the wss default", async () => { + const agent = new UndiciAgent(); + const logger = vi.fn(); + try { + const ingress = ingressFor({ agent, log: logger }); + expect(ingress.agent).toBeInstanceOf(https.Agent); + expect(logger).toHaveBeenCalledWith( + "warn", + expect.stringMatching(/Ignoring undici\.Agent.*QWP wss.*https\.Agent/), + ); + } finally { + await agent.close(); + } }); it("rejects a caller agent combined with tls_verify", () => { From 7c29d3ab12dc22d6937be3e2f5446ef647d614ca Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 26 Aug 2026 23:43:15 +0100 Subject: [PATCH 205/265] fix(qwp): accept trusted certificate PEM roots --- src/qwp-node/client-config.ts | 23 +++++++--------------- test/certs/ca/ca-trusted.crt | 32 +++++++++++++++++++++++++++++++ test/qwp/wss-tls-security.test.ts | 10 +++++++--- 3 files changed, 46 insertions(+), 19 deletions(-) create mode 100644 test/certs/ca/ca-trusted.crt diff --git a/src/qwp-node/client-config.ts b/src/qwp-node/client-config.ts index d3b5ef0..d9ccaaa 100644 --- a/src/qwp-node/client-config.ts +++ b/src/qwp-node/client-config.ts @@ -1,6 +1,5 @@ import { readFileSync } from "node:fs"; import { Agent as HttpsAgent } from "node:https"; -import { createSecureContext } from "node:tls"; import type { QwpNodeClientConfigOptions, QwpNodeClientOptions, @@ -597,22 +596,14 @@ function createTlsAgent(parsed: ParsedConfig): HttpsAgent | undefined { function readPemTlsRoots(path: string): Buffer { const roots = readFileSync(path); - if ( - !roots.includes("-----BEGIN CERTIFICATE-----") || - !roots.includes("-----END CERTIFICATE-----") - ) { - throw new Error( - "tls_roots must contain valid PEM-encoded CA certificates; PKCS#12 trust stores are not supported by the Node.js QWP client", - ); - } - try { - // Parse the configured roots now so PKCS#12 or malformed files fail while - // resolving the connect string, before a sender accepts rows or connects. - createSecureContext({ ca: roots }); - } catch (cause) { + const containsCertificate = + (roots.includes("-----BEGIN CERTIFICATE-----") && + roots.includes("-----END CERTIFICATE-----")) || + (roots.includes("-----BEGIN TRUSTED CERTIFICATE-----") && + roots.includes("-----END TRUSTED CERTIFICATE-----")); + if (!containsCertificate) { throw new Error( - "tls_roots must contain valid PEM-encoded CA certificates; PKCS#12 trust stores are not supported by the Node.js QWP client", - { cause }, + "tls_roots must contain PEM-encoded CA certificates (CERTIFICATE or TRUSTED CERTIFICATE); PKCS#12 trust stores are not supported by the Node.js QWP client", ); } return roots; diff --git a/test/certs/ca/ca-trusted.crt b/test/certs/ca/ca-trusted.crt new file mode 100644 index 0000000..39c83b9 --- /dev/null +++ b/test/certs/ca/ca-trusted.crt @@ -0,0 +1,32 @@ +-----BEGIN TRUSTED CERTIFICATE----- +MIIFdTCCA12gAwIBAgIUSNH1u5rgN7g+hl3fP0PMXN/kRQgwDQYJKoZIhvcNAQEL +BQAwSTELMAkGA1UEBhMCR0IxCzAJBgNVBAgMAkVOMQowCAYDVQQHDAEuMREwDwYD +VQQKDAhRVUVTVCBDQTEOMAwGA1UEAwwFUVVFU1QwIBcNMjMxMDA5MDY1MjE2WhgP +MjA1MTAyMjQwNjUyMTZaMEkxCzAJBgNVBAYTAkdCMQswCQYDVQQIDAJFTjEKMAgG +A1UEBwwBLjERMA8GA1UECgwIUVVFU1QgQ0ExDjAMBgNVBAMMBVFVRVNUMIICIjAN +BgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAnTPld+/J40FP7vsgGvbQi0QFMXYP +ywwRFzOjc0fZCVwE9g+qBjOHBX4zSsD+vw8Hi8mc5ZKJRZIXiGIydnJ5jUgZroS4 +XxGb2iUdbQ4oNgxwI9BB+AG/xSqlDTQFdC9Tf38HgsxaZPf8ZlakqfW48d5qoIfj +XiJRDH+2oTH9NObbLOLqD3nhpjlcQyZVMzmDg0m5NqOS1hJa0dCy7RJ6kUdKt/s4 +DIJQc2Nm0W+wEBaEcUU9fl4ohKmz8LW0hAgmCVdv2Jm4zZqEaNsQVGBHkuEelBBJ +SaY9uHy4tVnUqJ/t7so1xLLFgV1Nq+6Uj0RfM/VsrKpIp10zgzWJYSodpCPO7ARN +JRJwBQeQ3WAkZDkFY7+SC4hx8y75dYXzkjoigknVCMzwFuJ8DtGzgaEGKrgJ1hi9 +66BRHEpnxcGG6gprQjZ3AUlRUkZq13F46RjbDpfyXxRkYf4/EpW467VAQ2OD0Jqa +A6qiKeO5Eb6VAq00EjRGZ/3yUeOK1iVdb289g04GEtVFASwUdwIX/UQxMYe1l/Cp +t2v5kujhJitzhhhp+tN4lrvCx6o7Zxh3SLlQZNNZmZ7tm9WE/4EMnl6RAkF5FXGK +Giq4jlZd3yzzddriDvtFcBorqinKD71nVCy0KAWfChKRHTe7AxMtshaH8z5BLNni +9GJNXrQozDSDgl0CAwEAAaNTMFEwHQYDVR0OBBYEFAaR1TD+YvTvCmgoGfapO31z +ljnyMB8GA1UdIwQYMBaAFAaR1TD+YvTvCmgoGfapO31zljnyMA8GA1UdEwEB/wQF +MAMBAf8wDQYJKoZIhvcNAQELBQADggIBAJGluSDawzkdBM8cigLjUmkFFfPPku7Q +zK1tBEqlPk/zQCXT2AMusf5N9jbP1CAHmq8D+89ArKSlZpw2B7IhcJrqHBVU3JaA +8TA7rOCcPwoBWO/ipTrEwOZvCLFxoRn3ZmDGpsca2me7uvNHDk3b0PkLEIUMvQEU +NnCsozZbpGZHCdNWCk0ONsGWgamPal/Yi9b8bsADzJE87QSgSMK7QHjkV5PfV9Cg +gVSiS+b4JAqXbc9Mb4bEH/kexSimPCXYATmcAPNy2RUHOs8LGcSs+nIX4xvRTr4w +iji+dSwDFkahgPfmC+x2K1MsQQNEP7F16yg/8hJWvbDMyEKC3xCYVe7c83bEAMIc +xmZVb99Q/W7KV1u3fDxJP1kp3fiDaDt87nxdCQDZ8SAvS1kJ1WTfld8rCej7H8zP +Dcip4MgqDgmNDpG+hD3aluZHBaSfDp2BnFKamob6Ri/tq0MzeV9a3XJIThvU3iz5 +GZnWrP1MnXf/kr+KzU1tJNWGn25kcscVCcZ5d4JAYDVAc5Qe4sPna5dD+ZA3zE2C +6WH9qZh+s1UEyAkftPEosdHyNl3xlHHCNA65mgnf72O68C5eDvWClWtbVxf5H4RM +EdJjm9jP/HM/tJvj1KS8p0941lJ9ApqaPKUGx1pSnDjg+jVJEtB6JOyeWMSUI8g8 +z2hyreerpV/AMAwwCgYIKwYBBQUHAwE= +-----END TRUSTED CERTIFICATE----- diff --git a/test/qwp/wss-tls-security.test.ts b/test/qwp/wss-tls-security.test.ts index 5e312e3..fba50f8 100644 --- a/test/qwp/wss-tls-security.test.ts +++ b/test/qwp/wss-tls-security.test.ts @@ -29,6 +29,7 @@ beforeAll(preloadQwpNode); */ const CA_PATH = "test/certs/ca/ca.crt"; +const TRUSTED_CA_PATH = "test/certs/ca/ca-trusted.crt"; interface AgentTlsOptions { rejectUnauthorized?: boolean; @@ -62,7 +63,10 @@ describe("QWP wss:: connect-string verifies the server certificate", () => { expect(tls.pfx).toBeUndefined(); }); - it("trusts a server signed by the configured PEM root", async () => { + it.each([ + ["CERTIFICATE", CA_PATH], + ["TRUSTED CERTIFICATE", TRUSTED_CA_PATH], + ])("trusts a server signed by a %s PEM root", async (_label, rootsPath) => { const server = https.createServer( { key: readFileSync("test/certs/server/server.key"), @@ -80,7 +84,7 @@ describe("QWP wss:: connect-string verifies the server certificate", () => { try { const port = (server.address() as AddressInfo).port; const options = qwpNode.parseQwpNodeClientConfig( - `wss::addr=127.0.0.1:${port};tls_roots=${CA_PATH};`, + `wss::addr=127.0.0.1:${port};tls_roots=${rootsPath};`, ); await new Promise((resolve, reject) => { const request = https.get( @@ -117,7 +121,7 @@ describe("QWP wss:: connect-string verifies the server certificate", () => { qwpNode.parseQwpNodeClientConfig( "wss::addr=localhost;tls_roots=package.json;", ), - ).toThrow(/valid PEM-encoded CA certificates.*PKCS#12/); + ).toThrow(/PEM-encoded CA certificates.*PKCS#12/); }); it("disables verification only when tls_verify=unsafe_off is explicit", () => { From 6f71c998a6651491919f9597a9a6ee11e13edf97 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 26 Aug 2026 23:50:11 +0100 Subject: [PATCH 206/265] fix(qwp): validate programmatic security options --- src/options.ts | 21 +++++++++++++-------- src/sender.ts | 26 ++++++++++++++++++++++---- test/qwp/udp-sender.test.ts | 22 ++++++++++++++++++++++ test/qwp/wss-tls-security.test.ts | 21 +++++++++++++++++++++ 4 files changed, 78 insertions(+), 12 deletions(-) diff --git a/src/options.ts b/src/options.ts index 108fc91..1f157ed 100644 --- a/src/options.ts +++ b/src/options.ts @@ -771,12 +771,7 @@ function parseAutoFlushOptions(options: SenderOptions) { function parseTlsOptions(options: SenderOptions) { parseBoolean(options, "tls_verify", "TLS verify", UNSAFE_OFF); - if ( - options.protocol === UDP && - (options.tls_verify !== undefined || options.tls_ca !== undefined) - ) { - throw new Error("TLS is not supported for QWP UDP transport"); - } + validateUdpSecurityOptions(options); if (options.tls_roots || options.tls_roots_password) { throw new Error( @@ -810,9 +805,18 @@ function parseUdpOptions(options: SenderOptions) { "max_datagram_size and multicast_ttl are only supported for QWP UDP transport", ); } +} + +/** @ignore Rejects security options that the fire-and-forget UDP wire cannot honor. */ +function validateUdpSecurityOptions(options: SenderOptions): void { + if (options.protocol !== UDP) return; + if (options.tls_verify !== undefined || options.tls_ca !== undefined) { + throw new Error("TLS is not supported for QWP UDP transport"); + } if ( - options.protocol === UDP && - (options.username || options.password || options.token) + options.username !== undefined || + options.password !== undefined || + options.token !== undefined ) { throw new Error("authentication is not supported for QWP UDP transport"); } @@ -878,6 +882,7 @@ export { WS, WSS, UDP, + validateUdpSecurityOptions, PROTOCOL_VERSION_AUTO, PROTOCOL_VERSION_V1, PROTOCOL_VERSION_V2, diff --git a/src/sender.ts b/src/sender.ts index 0e5b275..8da185e 100644 --- a/src/sender.ts +++ b/src/sender.ts @@ -8,6 +8,7 @@ import { qwpConfig, selectQwpSchemeAgent, UDP, + validateUdpSecurityOptions, WS, WSS, } from "./options"; @@ -622,8 +623,9 @@ function createConfiguredQwpSender( rejectUnauthorized: options.tls_verify ?? true, }); } + const configuredAuthorization = qwpAuthorization(options); const authorization = - configuredWebSocket.authorization ?? qwpAuthorization(options); + configuredWebSocket.authorization ?? configuredAuthorization; // ws/wss connect-string keys are the QWP schema's, parsed only by // resolveQwpNodeClientConfig(). This path builds a sender from a // programmatic options object, so it reads options.qwp.* directly. @@ -662,6 +664,7 @@ function createConfiguredQwpUdpSender( options: SenderOptions, logger: Logger, ): QwpSender { + validateUdpSecurityOptions(options); if (!options.host || !options.port) { throw new Error("The 'host' and 'port' options are mandatory for QWP UDP"); } @@ -701,13 +704,28 @@ function createConfiguredQwpUdpSender( } function qwpAuthorization(options: SenderOptions): string | undefined { - if (options.token) return `Bearer ${options.token}`; - if (options.username !== undefined || options.password !== undefined) { - if (!options.username || options.password === undefined) { + const hasUsername = options.username !== undefined; + const hasPassword = options.password !== undefined; + const hasToken = options.token !== undefined; + if (hasUsername !== hasPassword || !options.username || !options.password) { + if (hasUsername || hasPassword) { throw new Error( "QWP Basic authentication requires both 'username' and 'password'", ); } + } + if (hasToken && hasUsername) { + throw new Error( + "QWP 'token' authentication cannot be combined with 'username'/'password'", + ); + } + if (hasToken) { + if (!options.token) { + throw new Error("QWP Bearer authentication requires a non-empty 'token'"); + } + return `Bearer ${options.token}`; + } + if (hasUsername) { return `Basic ${Buffer.from(`${options.username}:${options.password}`, "utf8").toString("base64")}`; } return undefined; diff --git a/test/qwp/udp-sender.test.ts b/test/qwp/udp-sender.test.ts index b99dd8d..4e47440 100644 --- a/test/qwp/udp-sender.test.ts +++ b/test/qwp/udp-sender.test.ts @@ -330,6 +330,28 @@ describe("QWP Node UDP sender", () => { await configured.close(); }); + it("rejects security options supplied through programmatic UDP options", () => { + const options = { protocol: "udp", host: "localhost", port: 9007 }; + for (const credentials of [ + { username: "admin" }, + { password: "secret" }, + { token: "bearer" }, + ]) { + expect(() => new Sender({ ...options, ...credentials } as never)).toThrow( + "authentication is not supported for QWP UDP transport", + ); + } + for (const tls of [ + { tls_verify: true }, + { tls_verify: false }, + { tls_ca: "test/certs/ca/ca.crt" }, + ]) { + expect(() => new Sender({ ...options, ...tls } as never)).toThrow( + "TLS is not supported for QWP UDP transport", + ); + } + }); + it("rejects acknowledgement and transaction options that UDP cannot honor", () => { const options = { host: "localhost", diff --git a/test/qwp/wss-tls-security.test.ts b/test/qwp/wss-tls-security.test.ts index fba50f8..ff0b6a7 100644 --- a/test/qwp/wss-tls-security.test.ts +++ b/test/qwp/wss-tls-security.test.ts @@ -337,4 +337,25 @@ describe("QWP programmatic wss sender applies TLS and authorization", () => { "Bearer tok-123", ); }); + + it("rejects Bearer authentication combined with Basic credentials", () => { + expect(() => + constructWss({ + username: "alice", + password: "s3cret", + token: "tok-123", + }), + ).toThrow( + "QWP 'token' authentication cannot be combined with 'username'/'password'", + ); + }); + + it("rejects empty programmatic authentication secrets", () => { + expect(() => constructWss({ username: "alice", password: "" })).toThrow( + "QWP Basic authentication requires both 'username' and 'password'", + ); + expect(() => constructWss({ token: "" })).toThrow( + "QWP Bearer authentication requires a non-empty 'token'", + ); + }); }); From 3eb46108de876758667c98726176879f6b1f64c5 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 27 Aug 2026 00:00:30 +0100 Subject: [PATCH 207/265] build: verify packed artifacts --- scripts/check-build-artifacts.mjs | 52 ++++++++++++++++++++++++++----- 1 file changed, 44 insertions(+), 8 deletions(-) diff --git a/scripts/check-build-artifacts.mjs b/scripts/check-build-artifacts.mjs index c995587..eaa2808 100644 --- a/scripts/check-build-artifacts.mjs +++ b/scripts/check-build-artifacts.mjs @@ -1,13 +1,14 @@ -// Verifies that every package `exports` target exists, and that the shared -// chunks those entries import were published too. Entry bundles import chunks -// that no `exports` entry names, so a chunk left out of `files` would publish a -// package whose every entry resolves to a missing file. +// Verifies that every package `exports` target exists and is included by +// `npm pack`, along with every shared chunk those entries import. Entry bundles +// import chunks that no `exports` entry names, so checking only the untarred +// tree would miss a chunk left out of `files` and publish broken entry points. // // This lives in a file rather than inline in the workflow because the pattern // below needs both quote characters, which cannot survive a single-quoted // `node -e` argument in a YAML block scalar. import { existsSync, readFileSync } from "node:fs"; -import { dirname, join, resolve } from "node:path"; +import { execFileSync } from "node:child_process"; +import { dirname, join, relative, resolve, sep } from "node:path"; // Only specifiers that name an emitted file. Matching every `from "./x"` in the // raw text would also match prose inside a comment the bundler preserved -- a @@ -50,8 +51,43 @@ for (const [subpath, conditions] of Object.entries(map)) { // no runtime test can see. for (const [subpath, targets] of Object.entries(typesVersions?.["*"] ?? {})) { for (const target of targets) { - if (!existsSync(target)) - missing.push(`typesVersions ${subpath} -> ${target}`); + walk(target, `typesVersions ${subpath}`); + } +} + +if (missing.length > 0) { + console.error(`missing build artifacts:\n ${missing.join("\n ")}`); + process.exit(1); +} + +let pack; +try { + [pack] = JSON.parse( + execFileSync( + process.platform === "win32" ? "npm.cmd" : "npm", + ["pack", "--dry-run", "--json"], + { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }, + ), + ); +} catch (error) { + const stderr = error?.stderr?.toString().trim(); + console.error(`npm pack --dry-run failed${stderr ? `:\n${stderr}` : ""}`); + process.exit(1); +} + +if (!Array.isArray(pack?.files)) { + console.error("npm pack --dry-run returned no package file manifest"); + process.exit(1); +} + +const packedFiles = new Set(pack.files.map(({ path }) => path)); +for (const file of seen) { + const packagePath = relative(process.cwd(), file).split(sep).join("/"); + if (!packedFiles.has(packagePath)) { + missing.push(`npm pack omits ${packagePath}`); } } @@ -61,5 +97,5 @@ if (missing.length > 0) { } console.log( - `all ${Object.keys(map).length} export subpaths present, ${seen.size} files walked`, + `all ${Object.keys(map).length} export subpaths present, ${seen.size} files walked and packed`, ); From 6634ca416b233c0e11bf3018cdd8c726098b8909 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 27 Aug 2026 00:08:39 +0100 Subject: [PATCH 208/265] fix(qwp): report CRC tail data loss --- src/qwp-node/file-replay-store.ts | 24 +++++++++------- test/qwp/reconnect.test.ts | 46 +++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 10 deletions(-) diff --git a/src/qwp-node/file-replay-store.ts b/src/qwp-node/file-replay-store.ts index 10ee08f..25a5973 100644 --- a/src/qwp-node/file-replay-store.ts +++ b/src/qwp-node/file-replay-store.ts @@ -142,7 +142,7 @@ interface PendingCapacity { export interface QwpNodeReplayDataLossReport { readonly directory: string; readonly segmentFile: string; - /** Bytes after the damaged record that recovery could not reach. */ + /** Bytes at and after the damaged record that recovery could not retain. */ readonly discardedBytes: number; readonly reason: string; } @@ -605,13 +605,13 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { "non-active segment has a torn record tail", ); } - if (decoded.interiorDamage) { - // The active segment's residue is abandoned by policy, matching - // the Java client: past a mid-file tear the frames behind it are - // unreachable anyway, because replay requires a contiguous - // sequence and the tear breaks it. Recovery therefore proceeds on - // the valid prefix, but the loss is always reported -- discarding - // it silently is what made this dangerous. + if (decoded.interiorDamage || decoded.crcMismatch) { + // The active segment's damaged suffix is abandoned by policy, + // matching the Java client. An interior tear strands the frames + // behind it because replay requires a contiguous sequence; a + // tail CRC mismatch proves the complete final record itself was + // lost. Recovery proceeds on the valid prefix, but provable loss + // is always reported -- discarding it silently is dangerous. this.reportRecoveryDataLoss({ directory: this.directory, segmentFile: name, @@ -619,8 +619,9 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { 0, decoded.size - SEGMENT_HEADER_SIZE - decoded.logicalSize, ), - reason: - "a damaged record is followed by intact records that replay can no longer reach", + reason: decoded.interiorDamage + ? "a damaged record is followed by intact records that replay can no longer reach" + : "the active segment tail contains a complete record whose CRC32C does not match", }); } await repairSegmentTail( @@ -2234,6 +2235,8 @@ interface DecodedSegment { /** Bytes occupied by encoded records, excluding the fixed segment header. */ readonly logicalSize: number; readonly tornTail: boolean; + /** A structurally complete record was present, but its CRC32C did not match. */ + readonly crcMismatch?: boolean; /** * Set when structurally intact data still follows the damaged record, which * makes this a hole rather than an unwritten tail. Repairing it would delete @@ -2398,6 +2401,7 @@ async function scanSegment( records, logicalSize: offset - SEGMENT_HEADER_SIZE, tornTail: true, + crcMismatch: true, // A record that still verifies where this one ends means the damage is // bit rot in the middle of the journal, not an interrupted append. interiorDamage: await hasValidRecordAt( diff --git a/test/qwp/reconnect.test.ts b/test/qwp/reconnect.test.ts index ac7b7d0..578aaf2 100644 --- a/test/qwp/reconnect.test.ts +++ b/test/qwp/reconnect.test.ts @@ -4192,6 +4192,52 @@ describe("QWP Node file replay store", () => { await recovered.close(); }); + it("reports a CRC-failing record at the active segment tail", async () => { + // A zero-filled active tail may be an append that never completed, but a + // complete record whose payload no longer matches its CRC proves that + // journal bytes were abandoned. This is especially important for memory + // durability, where page-cache writeback can persist those pieces out of + // order after append already returned to the producer. + const directory = await trackedDirectory(); + const first = new QwpNodeFileReplayStore({ + directory, + durability: QWP_SF_DURABILITY.MEMORY, + }); + await first.load(); + await first.append({ frameSequence: 0n, payload: Uint8Array.of(1, 1, 1) }); + await first.append({ frameSequence: 1n, payload: Uint8Array.of(2, 2, 2) }); + await first.close(); + + const [segment] = await assignedReplaySegments(directory); + const recordSize = 8 + 3; + const secondPayload = 24 + recordSize + 8; + const file = await open(join(directory, segment), "r+"); + try { + await file.write(Uint8Array.of(0xff), 0, 1, secondPayload); + await file.sync(); + } finally { + await file.close(); + } + + const reports: QwpNodeReplayDataLossReport[] = []; + const recovered = new QwpNodeFileReplayStore({ + directory, + durability: QWP_SF_DURABILITY.MEMORY, + onRecoveryDataLoss: (report) => reports.push(report), + }); + await expect(recovered.loadReferences()).resolves.toEqual([ + { frameSequence: 0n, payloadLength: 3 }, + ]); + expect(reports).toHaveLength(1); + expect(reports[0]).toMatchObject({ + directory, + segmentFile: segment, + reason: expect.stringContaining("CRC32C"), + }); + expect(reports[0].discardedBytes).toBeGreaterThanOrEqual(recordSize); + await recovered.close(); + }); + it.each([ ["a zeroed record", "hole"], ["a flipped payload byte", "bitrot"], From ac91f90317d01fa04af3e19fc7764bc52dec8654 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 27 Aug 2026 00:43:36 +0100 Subject: [PATCH 209/265] docs: update Sender transport semantics --- src/sender.ts | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/sender.ts b/src/sender.ts index 8da185e..2558679 100644 --- a/src/sender.ts +++ b/src/sender.ts @@ -246,7 +246,9 @@ class Sender { } /** - * Creates a TCP connection to the database. + * Establishes the transport connection for TCP, TCPS, WS, WSS, and UDP. + * HTTP and HTTPS connect per request and reject this call because no explicit + * connection step is required. * * @return {Promise} Resolves to true if the client is connected. */ @@ -529,6 +531,10 @@ class Sender { /** * Closes the row after writing the designated timestamp into the buffer of the sender. + * If validation or encoding rejects the row before it is completed, the + * incomplete row and its table selection are discarded; rows completed + * earlier remain staged. Start the next row with {@link table} again. A later + * auto-flush failure does not discard the row that was successfully closed. * * **Precision rules**: * - **Protocol v2 and higher:** @@ -544,10 +550,10 @@ class Sender { * - `'us'` — microseconds *(default)* * - `'ms'` — milliseconds * - * @returns {SenderBuffer} Returns with a reference to this buffer. + * @returns {Promise} Resolves after the row is closed and any triggered auto-flush completes. * - * @throws {Error} If `value` is not an integer or `BigInt`. - * @throws {Error} If `unit` is `'ns'` but `value` is not a `BigInt`. + * @throws {Error} If `timestamp` is not an integer or `BigInt`. + * @throws {Error} If `unit` is `'ns'` but `timestamp` is not a `BigInt`. */ async at( timestamp: number | bigint, @@ -563,6 +569,12 @@ class Sender { /** * Closes the row without writing designated timestamp into the buffer of the sender.
    * Designated timestamp will be populated by the server on this record. + * If validation or encoding rejects the row before it is completed, the + * incomplete row and its table selection are discarded; rows completed + * earlier remain staged. Start the next row with {@link table} again. A later + * auto-flush failure does not discard the row that was successfully closed. + * + * @returns {Promise} Resolves after the row is closed and any triggered auto-flush completes. */ async atNow(): Promise { if (this.qwpSender) return this.qwpSender.atNow(); From a7f9818fa4a750a6b16cbbd1ef4a3f140b239954 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 27 Aug 2026 13:38:21 +0100 Subject: [PATCH 210/265] fix: statically load QWP node module --- src/options.ts | 19 +--- src/qwp-node/module-registry.ts | 42 --------- src/sender.ts | 18 +--- test/options.test.ts | 6 +- test/qwp/dist.e2e.ts | 115 ++++++++++++++++++----- test/qwp/sender-node-integration.test.ts | 8 +- test/qwp/udp-sender.test.ts | 8 +- test/qwp/wss-tls-security.test.ts | 10 +- 8 files changed, 104 insertions(+), 122 deletions(-) delete mode 100644 src/qwp-node/module-registry.ts diff --git a/src/options.ts b/src/options.ts index 1f157ed..1fab518 100644 --- a/src/options.ts +++ b/src/options.ts @@ -7,12 +7,9 @@ import * as https from "https"; import { log, Logger } from "./logging"; import { fetchJson, isBoolean, isInteger } from "./utils"; import { DEFAULT_REQUEST_TIMEOUT } from "./transport/http/base"; -import { - getQwpNodeModule, - preloadQwpNodeModule, -} from "./qwp-node/module-registry"; -import type { QwpNodeClientOptions } from "./qwp/node"; +import * as qwpNode from "./qwp/node"; import type { + QwpNodeClientOptions, QwpNodeIngressOptions, QwpNodeUdpOptions, QwpIngressSessionOptions, @@ -92,7 +89,7 @@ function resolveQwpConfig( const agent = webSocketOverrides.agent ?? selectQwpSchemeAgent(options.agent, options.protocol === WSS, logger); - const resolved = getQwpNodeModule().parseQwpNodeClientConfig(configString, { + const resolved = qwpNode.parseQwpNodeClientConfig(configString, { webSocket: { ...webSocketOverrides, agent }, storeAndForward, // The top-level logger wins, then the QWP-specific one, then the default @@ -494,9 +491,6 @@ class SenderOptions { configurationString: string, extraOptions?: ExtraOptions, ): Promise { - if (isQwpConfigurationString(configurationString)) { - await preloadQwpNodeModule(); - } const options = new SenderOptions(configurationString, extraOptions); await SenderOptions.resolveAuto(options); return options; @@ -521,13 +515,6 @@ class SenderOptions { } } -function isQwpConfigurationString(configurationString: string): boolean { - const separator = configurationString?.indexOf("::") ?? -1; - if (separator < 0) return false; - const protocol = configurationString.slice(0, separator); - return protocol === WS || protocol === WSS || protocol === UDP; -} - function parseConfigurationString( options: SenderOptions, configString: string, diff --git a/src/qwp-node/module-registry.ts b/src/qwp-node/module-registry.ts deleted file mode 100644 index 3ccd5bb..0000000 --- a/src/qwp-node/module-registry.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { createRequire } from "node:module"; - -type QwpNodeModule = typeof import("../qwp/node"); - -let qwpNodeModule: QwpNodeModule | undefined; -let qwpNodeModulePromise: Promise | undefined; - -/** - * Loads the QWP Node entry through the current bundle's module format and - * retains that exact namespace for every root-entry QWP call site. - * - * Bunchee rewrites the relative import to `qwp/node.mjs` in the ESM root and - * `qwp/node.js` in the CommonJS root. Awaiting this before a QWP SenderOptions - * or Sender is constructed therefore preserves constructor identity with the - * documented same-format `qwp/node` entry without putting it on the eager root - * module graph. - */ -export async function preloadQwpNodeModule(): Promise { - if (qwpNodeModule) return; - const loading = - qwpNodeModulePromise ?? - (qwpNodeModulePromise = import("../qwp/node") as Promise); - try { - qwpNodeModule ??= await loading; - } catch (error) { - if (qwpNodeModulePromise === loading) qwpNodeModulePromise = undefined; - throw error; - } -} - -/** Returns the one QWP Node namespace selected for this root module. */ -export function getQwpNodeModule(): QwpNodeModule { - if (!qwpNodeModule) { - // Sender's public constructor is synchronous. Async factories preload the - // matching-format entry above; this fallback retains direct-constructor - // compatibility and selects the package's CommonJS condition. - qwpNodeModule = createRequire(import.meta.url)( - "@questdb/nodejs-client/qwp/node", - ) as QwpNodeModule; - } - return qwpNodeModule; -} diff --git a/src/sender.ts b/src/sender.ts index 2558679..07beca5 100644 --- a/src/sender.ts +++ b/src/sender.ts @@ -15,23 +15,13 @@ import { import { SenderTransport, createTransport } from "./transport"; import { SenderBuffer, createBuffer } from "./buffer"; import { isBoolean, isInteger, TimestampUnit } from "./utils"; -import { - getQwpNodeModule, - preloadQwpNodeModule, -} from "./qwp-node/module-registry"; +import * as qwpNode from "./qwp/node"; import type { QwpSender } from "./qwp/node"; import type { QwpTableWriter } from "./_qwp/sender"; import type { QwpWriterSchema } from "./_qwp/writer"; const QWP_INGRESS_PATH = "/write/v4"; -/** - * @internal Preloads the matching-format QWP Node entry into the root module's - * registry. The async configuration factories call this automatically; it is - * exposed for source-level suites and synchronous programmatic construction. - */ -export const preloadQwpNode = preloadQwpNodeModule; - const DEFAULT_AUTO_FLUSH_INTERVAL = 1000; // 1 sec /** @@ -149,7 +139,7 @@ class Sender { ? // SenderOptions already parsed the ws/wss connect string with the // QWP schema, so there is one vocabulary and one parser however the // sender was constructed. - getQwpNodeModule().createQwpNodeSender( + qwpNode.createQwpNodeSender( resolved.ingress, resolved.sender, resolved.ingressSession, @@ -642,7 +632,7 @@ function createConfiguredQwpSender( // resolveQwpNodeClientConfig(). This path builds a sender from a // programmatic options object, so it reads options.qwp.* directly. const storeAndForward = configuredWebSocket.storeAndForward; - return getQwpNodeModule().createQwpNodeSender( + return qwpNode.createQwpNodeSender( { ...configuredWebSocket, storeAndForward, @@ -684,7 +674,7 @@ function createConfiguredQwpUdpSender( const configuredSender = options.qwp?.sender ?? {}; const maxDatagramSize = options.max_datagram_size ?? configuredUdp.maxDatagramSize ?? 1_400; - return getQwpNodeModule().createQwpNodeUdpSender( + return qwpNode.createQwpNodeUdpSender( { ...configuredUdp, host: options.host, diff --git a/test/options.test.ts b/test/options.test.ts index 9366bb7..36461c4 100644 --- a/test/options.test.ts +++ b/test/options.test.ts @@ -2,15 +2,11 @@ import { describe, it, expect, beforeAll, afterAll } from "vitest"; import { Agent } from "undici"; -import { Sender, preloadQwpNode } from "../src/sender"; +import { Sender } from "../src/sender"; import { SenderOptions } from "../src"; import { qwpConfig } from "../src/options"; import { log } from "../src/logging"; -// The root Sender lazy-loads the QWP Node subsystem through the package's own -// subpath (the built artifact); against source, warm its cache with the source -// module so ws/wss/udp senders built here run the code under test. -beforeAll(preloadQwpNode); import { MockHttp } from "./util/mockhttp"; import { readFileSync } from "fs"; diff --git a/test/qwp/dist.e2e.ts b/test/qwp/dist.e2e.ts index b9d49d2..6b4c0a9 100644 --- a/test/qwp/dist.e2e.ts +++ b/test/qwp/dist.e2e.ts @@ -69,6 +69,25 @@ const load = (subpath: Subpath, format: Format) => ? Promise.resolve(require_(resolveExport(subpath, format))) : import(pathToFileURL(resolveExport(subpath, format)).href); +const runNode = (script: string) => + new Promise<{ code: number | null; stdout: string; stderr: string }>( + (resolve) => { + const child = execFile( + process.execPath, + ["-e", script], + (error, stdout, stderr) => + resolve({ + code: error ? ((error as { code?: number }).code ?? 1) : 0, + stdout, + stderr, + }), + ); + child.on("error", () => + resolve({ code: 1, stdout: "", stderr: "spawn failed" }), + ); + }, + ); + /* eslint-disable @typescript-eslint/no-explicit-any */ const schemaFrom = (factories: any) => ({ symbol: factories.symbol(), @@ -114,8 +133,8 @@ describe.each(["import", "require"] as const)( "ws::addr=127.0.0.1:9;auto_flush=off;", { log: () => {} }, ); - // Loading the public Node entry after the root has lazily initialized - // QWP proves the registry selected this same-format module instance. + // Loading the public Node entry after the root proves its static import + // selected this same-format module instance. const node: any = await load("./qwp/node", format); const trades = sender.writer("trades", schemaFrom(qwp)); await stageTwoRows(trades); @@ -143,6 +162,31 @@ describe.each(["import", "require"] as const)( expect(rowError).not.toBeInstanceOf(otherNode.QwpWriterRowError); }); + it("keeps synchronous package-root identity", async () => { + const root: any = await load(".", format); + const sender = new root.Sender( + new root.SenderOptions("ws::addr=127.0.0.1:9;auto_flush=off;", { + log: () => {}, + }), + ); + const node: any = await load("./qwp/node", format); + const trades = sender.writer("trades", schemaFrom(node)); + + expect(trades).toBeInstanceOf(node.QwpTableWriter); + + let rowError: unknown; + try { + await trades.row({ + symbol: "SOL-USD", + price: "not-a-number", + timestamp: 3n, + }); + } catch (error) { + rowError = error; + } + expect(rowError).toBeInstanceOf(node.QwpWriterRowError); + }); + it("re-exported factories keep the identity of their defining bundle", async () => { const qwp: any = await load("./qwp", format); const node: any = await load("./qwp/node", format); @@ -167,28 +211,53 @@ describe.each(["import", "require"] as const)( }, ); -describe("store-and-forward locking", () => { - const runNode = (script: string) => - new Promise<{ code: number | null; stdout: string; stderr: string }>( - (resolve) => { - const child = execFile( - process.execPath, - ["-e", script], - (error, stdout, stderr) => - resolve({ - code: error ? ((error as { code?: number }).code ?? 1) : 0, - stdout, - stderr, - }), +describe("package-root static QWP import", () => { + it("uses the ESM QWP entry for synchronous ESM construction", async () => { + const rootUrl = pathToFileURL(resolveExport(".", "import")).href; + const nodeUrl = pathToFileURL(resolveExport("./qwp/node", "import")).href; + const commonJsNode = resolveExport("./qwp/node", "require"); + const configuration = "ws::addr=127.0.0.1:9;auto_flush=off;"; + const script = ` + (async () => { + const root = await import(${JSON.stringify(rootUrl)}); + const commonJsLoaded = Boolean(require.cache[require.resolve(${JSON.stringify(commonJsNode)})]); + const node = await import(${JSON.stringify(nodeUrl)}); + const sender = new root.Sender( + new root.SenderOptions(${JSON.stringify(configuration)}, { log: () => {} }), ); - child.on("error", () => - resolve({ code: 1, stdout: "", stderr: "spawn failed" }), - ); - }, - ); + const writer = sender.writer("trades", (${schemaFrom.toString()})(node)); + let rowError; + try { + await writer.row({ symbol: "SOL-USD", price: "not-a-number", timestamp: 3n }); + } catch (error) { + rowError = error; + } + + console.log(JSON.stringify({ + commonJsLoaded, + writerIdentity: writer instanceof node.QwpTableWriter, + errorIdentity: rowError instanceof node.QwpWriterRowError, + })); + })().catch((error) => { + console.error(error); + process.exitCode = 1; + }); + `; + + const { code, stdout, stderr } = await runNode(script); + expect(stderr).toBe(""); + expect(code).toBe(0); + expect(JSON.parse(stdout.trim())).toEqual({ + commonJsLoaded: false, + writerIdentity: true, + errorIdentity: true, + }); + }); +}); +describe("store-and-forward locking", () => { it.each(["import", "require"] as const)( - "loads QWP only when a ws/wss/udp root sender is built (%s)", + "loads QWP with the package root (%s)", async (format) => { const target = resolveExport(".", format); const probe = @@ -212,8 +281,8 @@ describe("store-and-forward locking", () => { expect(stderr).toBe(""); expect(code).toBe(0); expect(JSON.parse(stdout.trim())).toEqual({ - before: { ws: false, dgram: false }, - afterHttp: { ws: false, dgram: false }, + before: { ws: format === "require", dgram: true }, + afterHttp: { ws: format === "require", dgram: true }, // ESM-loaded CommonJS dependencies are not exposed through // require.cache; dgram is the format-independent QWP graph probe. afterQwp: { ws: format === "require", dgram: true }, diff --git a/test/qwp/sender-node-integration.test.ts b/test/qwp/sender-node-integration.test.ts index 504aea8..112eb6e 100644 --- a/test/qwp/sender-node-integration.test.ts +++ b/test/qwp/sender-node-integration.test.ts @@ -4,14 +4,8 @@ import { createServer as createTcpServer } from "node:net"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { WebSocketServer } from "ws"; -import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { Sender } from "../../src"; -import { preloadQwpNode } from "../../src/sender"; - -// The root Sender lazy-loads the QWP Node subsystem through the package's own -// subpath (the built artifact); against source, warm its cache with the source -// module so the ws:: senders built here run the code under test. -beforeAll(preloadQwpNode); import { QWP_FLAG_DELTA_SYMBOL_DICTIONARY, QWP_MAGIC, diff --git a/test/qwp/udp-sender.test.ts b/test/qwp/udp-sender.test.ts index 4e47440..5e3fd5b 100644 --- a/test/qwp/udp-sender.test.ts +++ b/test/qwp/udp-sender.test.ts @@ -1,11 +1,5 @@ -import { beforeAll, describe, expect, it } from "vitest"; +import { describe, expect, it } from "vitest"; import { Sender } from "../../src"; -import { preloadQwpNode } from "../../src/sender"; - -// The root Sender lazy-loads the QWP Node subsystem through the package's own -// subpath (the built artifact); against source, warm its cache with the source -// module so the Sender.fromConfig("udp::...") below runs the code under test. -beforeAll(preloadQwpNode); import { QwpSymbolDictionary, connectQwpNodeUdp, diff --git a/test/qwp/wss-tls-security.test.ts b/test/qwp/wss-tls-security.test.ts index ff0b6a7..7c15f6b 100644 --- a/test/qwp/wss-tls-security.test.ts +++ b/test/qwp/wss-tls-security.test.ts @@ -3,17 +3,11 @@ import * as http from "node:http"; import * as https from "node:https"; import type { AddressInfo } from "node:net"; import { Agent as UndiciAgent } from "undici"; -import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import * as qwpNode from "../../src/qwp/node"; -import { Sender, preloadQwpNode } from "../../src/sender"; +import { Sender } from "../../src/sender"; import { SenderOptions, qwpConfig } from "../../src/options"; -// The root Sender lazy-loads the QWP Node subsystem through the package's own -// subpath, which resolves to the built artifact. Running against source, warm -// its cache with the source module first so the createQwpNodeSender spies below -// apply to the same instance the Sender calls. -beforeAll(preloadQwpNode); - /** * A wss:// producer must verify the server certificate, and its authorization * header must carry the operator's credentials unchanged. Both are silent when From 0b66d49ba4347cc262f7d09a09b772912b12e330 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 27 Aug 2026 13:51:34 +0100 Subject: [PATCH 211/265] fix: reset poison strikes during outages --- QWP.md | 19 +- .../reconnecting-ingress-connection.ts | 20 ++- test/qwp/reconnect.test.ts | 162 +++++++++++++----- 3 files changed, 147 insertions(+), 54 deletions(-) diff --git a/QWP.md b/QWP.md index 08af0a5..b39b56d 100644 --- a/QWP.md +++ b/QWP.md @@ -122,8 +122,8 @@ continues to come from `addr`, because the typed object intentionally omits | `durable_ack_keepalive_interval_millis` | integer ms | — | Poll interval for durable-ACK progress. | | `max_name_len` | integer | `127` | Maximum table and column name length, in UTF-8 bytes. | | `sender_id` | string | `default` | Identifies this producer to the server and in the journal. | -| `max_frame_rejections` | integer | — | Rejections of one frame before the poison-frame detector escalates. | -| `poison_min_escalation_window_millis` | integer ms | — | Minimum dwell before a poison frame may escalate. | +| `max_frame_rejections` | integer | `4` | Consecutive suspect outcomes for one frame before terminal escalation. | +| `poison_min_escalation_window_millis` | integer ms | `5000` | Minimum dwell before a poison frame may escalate. | | `catch_up_cap_gap_min_escalation_window_millis` | integer ms | `300000` | Minimum dwell before an orphan symbol-dictionary cap gap is quarantined. | | `connection_listener_inbox_capacity` | integer | — | Bound on the connection-event inbox before events are dropped. | | `error_inbox_capacity` | integer | — | Bound on the `onSenderError` inbox before events are dropped. | @@ -863,11 +863,16 @@ Node foreground store-and-forward replay loop remains unbounded after startup. W process or page; configuring a Node directory makes the same replay crash-safe. Ingress also detects a replay head that is repeatedly NACKed or followed by a -non-orderly WebSocket close. `maxFrameRejections` controls the strike threshold and -`poisonMinEscalationWindowMs` (5 seconds by default) prevents a brief outage from -being mistaken for a deterministic poison frame. Normal and going-away closes, -`NOT_WRITABLE`, and retriable symbol-dictionary catch-up rejections are retried with -pacing but do not count as poison strikes. +non-orderly WebSocket close. `maxFrameRejections` defaults to 4 consecutive strikes, +and `poisonMinEscalationWindowMs` defaults to 5 seconds. Both conditions must be met +before escalation. Normal (1000), going-away (1001), service-restart (1012), and +try-again-later (1013) closes, `NOT_WRITABLE`, retriable symbol-dictionary catch-up +rejections, and intervening connection-establishment failures reset the strike +episode. Abnormal closes (1006), internal-error closes (1011), and transport errors +without close information may count when an unacknowledged replay head exists. +Escalation is terminal for that producer; store-and-forward retains and quarantines +the affected rows for explicit `retryQwpNodeOrphanSlot()` recovery rather than +silently discarding them. Node.js sees the rejected upgrade status and `X-QuestDB-Role`, so a read-only replica or catching-up primary can be classified and skipped. Browsers deliberately expose diff --git a/src/_qwp/_internal/reconnecting-ingress-connection.ts b/src/_qwp/_internal/reconnecting-ingress-connection.ts index 2693807..1d5d88f 100644 --- a/src/_qwp/_internal/reconnecting-ingress-connection.ts +++ b/src/_qwp/_internal/reconnecting-ingress-connection.ts @@ -924,6 +924,12 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { } return; } catch (error) { + // A poison frame is meant to identify a connection that repeatedly + // accepts the same replay head and then rejects it or disappears. A + // failed connection/replay attempt breaks that sequence: the server + // is unavailable independently of the frame, so old strikes must not + // survive while the outage supplies the escalation dwell time. + this.resetPoisonEpisode(); if (reconnecting) this.totalReconnectErrors++; lastError = error; if (this.connectingCandidate === candidate) { @@ -1386,6 +1392,7 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { const exempt = frame.dictionaryCatchup || response.status === QWP_STATUS.NOT_WRITABLE; if (exempt) { + this.resetPoisonEpisode(); throw new RetriableIngressNackError( frame.frameSequence, response.status, @@ -1469,6 +1476,10 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { ) { return; } + this.resetPoisonEpisode(); + } + + private resetPoisonEpisode(): void { this.poisonFrameSequence = undefined; this.poisonFirstStrikeMs = 0; this.poisonStrikes = 0; @@ -1493,8 +1504,13 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { cause: unknown, closeInfo?: QwpConnectionCloseInfo, ): Error { - const orderly = closeInfo?.code === 1000 || closeInfo?.code === 1001; - const head = orderly ? undefined : this.currentPoisonHead(); + const exempt = + closeInfo?.code === 1000 || + closeInfo?.code === 1001 || + closeInfo?.code === 1012 || + closeInfo?.code === 1013; + if (exempt) this.resetPoisonEpisode(); + const head = exempt ? undefined : this.currentPoisonHead(); if (!head) { return new RetriableIngressConnectionError( this.nextExemptRecycleDelay(), diff --git a/test/qwp/reconnect.test.ts b/test/qwp/reconnect.test.ts index 578aaf2..526b849 100644 --- a/test/qwp/reconnect.test.ts +++ b/test/qwp/reconnect.test.ts @@ -238,8 +238,12 @@ class FakeConnection implements QwpBinaryConnection { this.incoming.push(payload); } - drop(): void { - this.finish({ code: 1006, reason: "connection lost", wasClean: false }); + drop(code = 1006, reason = "connection lost"): void { + this.finish({ code, reason, wasClean: false }); + } + + transportError(): void { + this.incoming.fail(new Error("WebSocket transport error")); } private finish(info: QwpConnectionCloseInfo): void { @@ -2618,55 +2622,120 @@ describe("QWP ingress reconnect and replay", () => { await session.close(); }); - it("stops replaying a head frame that repeatedly causes non-orderly closes", async () => { - const first = new FakeConnection("primary"); - const second = new FakeConnection("secondary"); - const connections = [first, second]; - const replayStore = new TrackingReplayStore(); - const session = await QwpIngressSession.connect( - async () => { - const connection = connections.shift(); - if (!connection) throw new Error("no connection available"); - return connection; - }, - { - replayStore, - reconnect: { - maxAttempts: 1, - maxFrameRejections: 2, - poisonMinEscalationWindowMs: 0, - initialBackoffMs: 0, - maxBackoffMs: 0, + it.each([ + ["close code 1006", (connection: FakeConnection) => connection.drop()], + [ + "close code 1011", + (connection: FakeConnection) => + connection.drop(1011, "internal server error"), + ], + [ + "no close information", + (connection: FakeConnection) => connection.transportError(), + ], + ] as const)( + "stops replaying a head frame that repeatedly causes %s", + async (_failure, fail) => { + const first = new FakeConnection("primary"); + const second = new FakeConnection("secondary"); + const connections = [first, second]; + const replayStore = new TrackingReplayStore(); + const session = await QwpIngressSession.connect( + async () => { + const connection = connections.shift(); + if (!connection) throw new Error("no connection available"); + return connection; }, - }, - ); - const pending = session.sendFrame(Uint8Array.of(9)); - await vi.waitFor(() => expect(first.sent).toHaveLength(1)); - first.drop(); - await vi.waitFor(() => expect(second.sent).toHaveLength(1)); - second.drop(); + { + replayStore, + reconnect: { + maxAttempts: 1, + maxFrameRejections: 2, + poisonMinEscalationWindowMs: 0, + initialBackoffMs: 0, + maxBackoffMs: 0, + }, + }, + ); + const pending = session.sendFrame(Uint8Array.of(9)); + await vi.waitFor(() => expect(first.sent).toHaveLength(1)); + fail(first); + await vi.waitFor(() => expect(second.sent).toHaveLength(1)); + fail(second); + + await expect(pending).rejects.toThrow(/frameSequence=0, strikes=2/); + await expect(pending).rejects.toBeInstanceOf(QwpProtocolError); + expect(connections).toHaveLength(0); + expect(Array.from(replayStore.records.keys())).toEqual([0n]); + await session.close(); + }, + ); - await expect(pending).rejects.toThrow(/frameSequence=0, strikes=2/); - await expect(pending).rejects.toBeInstanceOf(QwpProtocolError); - expect(connections).toHaveLength(0); - expect(Array.from(replayStore.records.keys())).toEqual([0n]); - await session.close(); - }); + it.each([ + [1000, "normal closure"], + [1001, "going away"], + [1012, "service restart"], + [1013, "try again later"], + ] as const)( + "close code %i breaks a poison-frame strike episode", + async (code, reason) => { + const firstSuspect = new FakeConnection("suspect-1"); + const exempt = new FakeConnection("restarting"); + const secondSuspect = new FakeConnection("suspect-2"); + const healthy = new FakeConnection("healthy"); + const connections = [firstSuspect, exempt, secondSuspect, healthy]; + const session = await QwpIngressSession.connect( + async () => { + const connection = connections.shift(); + if (!connection) throw new Error("no connection available"); + return connection; + }, + { + reconnect: { + maxAttempts: 1, + maxFrameRejections: 2, + poisonMinEscalationWindowMs: 0, + initialBackoffMs: 0, + maxBackoffMs: 0, + }, + }, + ); + const pending = session.sendFrame(Uint8Array.of(9)); + await vi.waitFor(() => expect(firstSuspect.sent).toHaveLength(1)); + firstSuspect.drop(); + await vi.waitFor(() => expect(exempt.sent).toHaveLength(1)); + exempt.drop(code, reason); + await vi.waitFor(() => expect(secondSuspect.sent).toHaveLength(1)); + secondSuspect.drop(); + await vi.waitFor(() => expect(healthy.sent).toHaveLength(1)); + healthy.receive(ingressResponse(QWP_STATUS.OK, 0n)); + + await expect(pending).resolves.toMatchObject({ + status: QWP_STATUS.OK, + sequence: 0n, + }); + await session.close(); + }, + ); - it("does not count orderly ingress closes as poison-frame strikes", async () => { - const first = new FakeConnection("primary"); - const second = new FakeConnection("secondary"); - const connections = [first, second]; + it("resets poison strikes when connection establishment fails", async () => { + const first = new FakeConnection("terminating-1"); + const second = new FakeConnection("terminating-2"); + const healthy = new FakeConnection("healthy"); + let factoryCalls = 0; const session = await QwpIngressSession.connect( async () => { - const connection = connections.shift(); - if (!connection) throw new Error("no connection available"); - return connection; + factoryCalls++; + if (factoryCalls === 1) return first; + if (factoryCalls === 2) throw new Error("connection refused"); + if (factoryCalls === 3) return second; + if (factoryCalls === 4) return healthy; + throw new Error("no connection available"); }, { reconnect: { - maxAttempts: 1, - maxFrameRejections: 1, + maxAttempts: 2, + maxFrameRejections: 2, poisonMinEscalationWindowMs: 0, initialBackoffMs: 0, maxBackoffMs: 0, @@ -2675,14 +2744,17 @@ describe("QWP ingress reconnect and replay", () => { ); const pending = session.sendFrame(Uint8Array.of(9)); await vi.waitFor(() => expect(first.sent).toHaveLength(1)); - await first.close(1001, "rolling restart"); + first.drop(); await vi.waitFor(() => expect(second.sent).toHaveLength(1)); - second.receive(ingressResponse(QWP_STATUS.OK, 0n)); + second.drop(); + await vi.waitFor(() => expect(healthy.sent).toHaveLength(1)); + healthy.receive(ingressResponse(QWP_STATUS.OK, 0n)); await expect(pending).resolves.toMatchObject({ status: QWP_STATUS.OK, sequence: 0n, }); + expect(factoryCalls).toBe(4); await session.close(); }); From fb4a8b317cd6fb0061344193cc6e8c7d3ed46553 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 27 Aug 2026 15:18:10 +0100 Subject: [PATCH 212/265] docs: clarify ILP auto-flush failure semantics --- README.md | 6 +++++- src/sender.ts | 18 ++++++++++++------ 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index bf43535..a476ab0 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,11 @@ Two consequences are worth knowing: - A rejected `at()`/`atNow()` on ILP discards the row it could not close, including its table name, and leaves rows already in the buffer alone. Catch the error and start the next row from `table()`; there is no need to `reset()` - and nothing already buffered is lost. + and nothing already buffered is lost. This applies to validation and encoding + failures before the row closes. If an ILP auto-flush send fails, the completed + batch has already been removed from the sender buffer; applications that need + to retry must retain and resubmit those rows. QWP keeps successfully closed + rows for its retry and replay path. **Changed in this release.** Earlier versions threw a type error for most nullish values, and protocol v2 encoded `arrayColumn(name, null)` as an explicit NULL diff --git a/src/sender.ts b/src/sender.ts index 07beca5..10edd52 100644 --- a/src/sender.ts +++ b/src/sender.ts @@ -520,11 +520,14 @@ class Sender { } /** - * Closes the row after writing the designated timestamp into the buffer of the sender. + * Closes the row after writing the designated timestamp. * If validation or encoding rejects the row before it is completed, the * incomplete row and its table selection are discarded; rows completed - * earlier remain staged. Start the next row with {@link table} again. A later - * auto-flush failure does not discard the row that was successfully closed. + * earlier remain staged. Start the next row with {@link table} again. If this + * call triggers an auto-flush that fails, ILP transports have already removed + * the entire staged batch from the sender buffer. Applications that need to + * retry ILP rows must retain and resubmit them. QWP retains successfully + * closed rows for its retry and replay path. * * **Precision rules**: * - **Protocol v2 and higher:** @@ -557,12 +560,15 @@ class Sender { } /** - * Closes the row without writing designated timestamp into the buffer of the sender.
    + * Closes the row without writing a designated timestamp. * Designated timestamp will be populated by the server on this record. * If validation or encoding rejects the row before it is completed, the * incomplete row and its table selection are discarded; rows completed - * earlier remain staged. Start the next row with {@link table} again. A later - * auto-flush failure does not discard the row that was successfully closed. + * earlier remain staged. Start the next row with {@link table} again. If this + * call triggers an auto-flush that fails, ILP transports have already removed + * the entire staged batch from the sender buffer. Applications that need to + * retry ILP rows must retain and resubmit them. QWP retains successfully + * closed rows for its retry and replay path. * * @returns {Promise} Resolves after the row is closed and any triggered auto-flush completes. */ From 425e9362bf47fea799351aeb3bf2bd872790e6b1 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 27 Aug 2026 17:04:32 +0100 Subject: [PATCH 213/265] fix: validate unsupported ILP columns consistently --- README.md | 34 +++++++------ src/buffer/base.ts | 63 ++++++++++++------------ src/buffer/bufferv1.ts | 12 ++--- src/buffer/index.ts | 16 +++++-- src/sender.ts | 24 ++++++---- test/sender.buffer.test.ts | 98 ++++++++++++++++++++++++-------------- 6 files changed, 142 insertions(+), 105 deletions(-) diff --git a/README.md b/README.md index a476ab0..81d03d1 100644 --- a/README.md +++ b/README.md @@ -86,11 +86,13 @@ await sender // wire: trades,symbol=BTC-USD price=39269.98,amount=0.011 ``` -This applies to every column method on both the ILP (`http`/`https`/`tcp`/`tcps`) -and QWP (`ws`/`wss`/`udp`) senders, and to the compiled QWP writers. The one -method that spreads a single value over several arguments, `long256Column`, -omits its column when _all four_ words are nullish; a partial set is rejected -rather than treated as NULL. +This applies to every column method supported by the selected protocol on both +the ILP (`http`/`https`/`tcp`/`tcps`) and QWP (`ws`/`wss`/`udp`) senders, and to +the compiled QWP writers. Capability checks still run for nullish values: ILP +v1 always rejects `arrayColumn`, and ILP v1/v2 always reject the decimal column +methods. The one method that spreads a single value over several arguments, +`long256Column`, omits its column when _all four_ words are nullish; a partial +set is rejected rather than treated as NULL. Two consequences are worth knowing: @@ -104,16 +106,18 @@ Two consequences are worth knowing: - A rejected `at()`/`atNow()` on ILP discards the row it could not close, including its table name, and leaves rows already in the buffer alone. Catch the error and start the next row from `table()`; there is no need to `reset()` - and nothing already buffered is lost. This applies to validation and encoding - failures before the row closes. If an ILP auto-flush send fails, the completed - batch has already been removed from the sender buffer; applications that need - to retry must retain and resubmit those rows. QWP keeps successfully closed - rows for its retry and replay path. - -**Changed in this release.** Earlier versions threw a type error for most nullish -values, and protocol v2 encoded `arrayColumn(name, null)` as an explicit NULL -array marker. Both now omit the column instead. If your code relied on the throw -as a data-quality guard, validate before calling the sender. + and nothing already buffered is lost. The exception is an invalid designated + timestamp unit: it is rejected before closing begins, leaving the row open so + `at()` can be retried with a valid unit. If an ILP auto-flush send fails, the + completed batch has already been removed from the sender buffer; applications + that need to retry must retain and resubmit those rows. QWP keeps successfully + closed rows for its retry and replay path. + +**Changed in this release.** Earlier versions threw a type error for most +nullish values, and protocol v2 encoded `arrayColumn(name, null)` as an explicit +NULL array marker. Supported column methods now omit the column instead. If your +code relied on the throw as a data-quality guard, validate before calling the +sender. ### QWP ingress from Node.js or a browser diff --git a/src/buffer/base.ts b/src/buffer/base.ts index f16e777..8e87b1a 100644 --- a/src/buffer/base.ts +++ b/src/buffer/base.ts @@ -106,15 +106,21 @@ abstract class SenderBufferBase implements SenderBuffer { * `endOfLastRow`: every later table() raised "Table name has already been * set", including after a successful flush(), because compact() moves bytes * without touching the row flags. reset() was the only way out and it - * discards whatever was already staged. A throw from writeTimestamp() also - * left the separator it had already written, so retrying at() produced a - * second one and corrupted the line. + * discards whatever was already staged. An invalid timestamp unit also used + * to reach writeTimestamp() after the separator was written, so retrying at() + * produced a second one and corrupted the line. */ private discardIncompleteRow() { this.position = this.endOfLastRow; this.startNewRow(); } + private validateTimestampUnit(unit: TimestampUnit): void { + if (unit !== "ns" && unit !== "us" && unit !== "ms") { + throw new Error(`Unknown timestamp unit: ${unit}`); + } + } + private startNewRow() { this.endOfLastRow = this.position; this.hasTable = false; @@ -261,7 +267,7 @@ abstract class SenderBufferBase implements SenderBuffer { * Writes an array column with its values into the buffer. * * @param {string} name - Column name - * @param {unknown[] | null | undefined} value - Array values to write (currently supports double arrays). A null or undefined value omits the column entirely, storing NULL. + * @param {unknown[] | null | undefined} value - Array values to write (currently supports double arrays). A null or undefined value omits the column entirely when arrays are supported; protocol v1 rejects the call for every value. * @returns {SenderBuffer} Returns with a reference to this buffer. * @throws Error if arrays are not supported by the buffer implementation, or array validation fails: * - value is not an array @@ -344,9 +350,7 @@ abstract class SenderBufferBase implements SenderBuffer { // on rows that carry a value and stays silent on the ones that omit it. // (Same principle as the scale check in SenderBufferV3.decimalColumn; the // ns/BigInt rule below stays value-dependent, as null omits the column.) - if (unit !== "ns" && unit !== "us" && unit !== "ms") { - throw new Error(`Unknown timestamp unit: ${unit}`); - } + this.validateTimestampUnit(unit); // A null or undefined value omits the column entirely (see issue #28). if (this.isNullOrUndefined(value)) { return this; @@ -386,10 +390,16 @@ abstract class SenderBufferBase implements SenderBuffer { * * @returns {SenderBuffer} Returns with a reference to this buffer. * - * @throws {Error} If `value` is not an integer or `BigInt`. - * @throws {Error} If `unit` is `'ns'` but `value` is not a `BigInt`. + * @throws {Error} If `timestamp` is not an integer or `BigInt`. + * @throws {Error} If `unit` is `'ns'` but `timestamp` is not a `BigInt`. + * @throws {Error} If `unit` is not one of `'ns'`, `'us'`, or `'ms'`. This + * validation leaves the open row unchanged so the call can be retried. */ at(timestamp: number | bigint, unit: TimestampUnit = "us") { + // The unit is a call-site parameter, so reject it before attempting to + // close (and potentially discard) the row. This also avoids writing the + // timestamp separator before discovering that the unit is invalid. + this.validateTimestampUnit(unit); try { if (!this.hasSymbols && !this.hasColumns) { throw new Error( @@ -642,27 +652,20 @@ abstract class SenderBufferBase implements SenderBuffer { * Use it to insert into DECIMAL database columns. * * Decimals are not supported by protocol v1/v2, so this base implementation - * rejects any actual value. A null or undefined value omits the column - * entirely (stored as NULL), consistent with the other column methods. - * Protocol v3 overrides this with a validating implementation. + * rejects the call even when the value is null or undefined. Protocol v3 + * overrides this with a validating implementation. * * @param {string} name - Column name. * @param {string | number | null | undefined} value - The decimal value to - * write. Only null or undefined is accepted here (which skips the column); - * any actual value throws. + * write. * @returns {SenderBuffer} Returns with a reference to this buffer. - * @throws {Error} Indicating decimals are not supported in protocol v1/v2, - * unless the value is null or undefined. + * @throws {Error} Indicating decimals are not supported in protocol v1/v2. */ decimalColumnText( name: string, value: string | number | null | undefined, ): SenderBuffer { this.validateColumnCall(name); - // A null or undefined value omits the column entirely (see issue #28). - if (this.isNullOrUndefined(value)) { - return this; - } throw new Error("Decimals are not supported in protocol v1/v2"); } @@ -672,18 +675,17 @@ abstract class SenderBufferBase implements SenderBuffer { * Use it to insert into DECIMAL database columns. * * Decimals are not supported by protocol v1/v2, so this base implementation - * rejects any actual value. A null or undefined value omits the column - * entirely (stored as NULL), consistent with the other column methods. - * Protocol v3 overrides this with a validating implementation. + * rejects the call even when the value is null or undefined. Protocol v3 + * overrides this with a validating implementation. * * @param {string} name - Column name. * @param {bigint | Int8Array | null | undefined} unscaled - The unscaled - * integer portion of the decimal value. Only null or undefined is accepted - * here (which skips the column); any actual value throws. + * integer portion of the decimal value. * @param {number} scale - The number of fractional digits (the scale) of the decimal value. * @returns {SenderBuffer} Returns with a reference to this buffer. - * @throws {Error} Indicating decimals are not supported in protocol v1/v2, - * unless the value is null or undefined. + * @throws {RangeError} If `scale` is not between 0 and 76. Scale validation + * runs even when `unscaled` is null or undefined. + * @throws {Error} Indicating decimals are not supported in protocol v1/v2. */ decimalColumn( name: string, @@ -691,9 +693,10 @@ abstract class SenderBufferBase implements SenderBuffer { scale: number, ): SenderBuffer { this.validateColumnCall(name); - // A null or undefined value omits the column entirely (see issue #28). - if (this.isNullOrUndefined(unscaled)) { - return this; + // The scale describes the column, not this row's value. Keep its validation + // consistent with protocol v3 even though v1/v2 reject the decimal API. + if (scale < 0 || scale > 76) { + throw new RangeError("Scale must be between 0 and 76"); } throw new Error("Decimals are not supported in protocol v1/v2"); } diff --git a/src/buffer/bufferv1.ts b/src/buffer/bufferv1.ts index 03001d5..17ab531 100644 --- a/src/buffer/bufferv1.ts +++ b/src/buffer/bufferv1.ts @@ -63,25 +63,21 @@ class SenderBufferV1 extends SenderBufferBase { } } + /* eslint-disable @typescript-eslint/no-unused-vars */ /** * Array columns are not supported in protocol v1.
    - * A null or undefined value omits the column entirely (stored as NULL), - * consistent with the other column methods; any actual array throws. + * The capability check applies even when the value is null or undefined. * * @param {string} name - Column name. - * @param {unknown[] | null | undefined} value - Array values. Only null or - * undefined is accepted in v1 (which skips the column). + * @param {unknown[] | null | undefined} value - Array values. * @returns {SenderBuffer} Returns with a reference to this buffer. * @throws Error indicating arrays are not supported in v1 */ arrayColumn(name: string, value: unknown[] | null | undefined): SenderBuffer { this.validateColumnCall(name); - // A null or undefined value omits the column entirely (see issue #28). - if (this.isNullOrUndefined(value)) { - return this; - } throw new Error("Arrays are not supported in protocol v1"); } + /* eslint-enable @typescript-eslint/no-unused-vars */ } export { SenderBufferV1 }; diff --git a/src/buffer/index.ts b/src/buffer/index.ts index 34aa6bb..bed4d2f 100644 --- a/src/buffer/index.ts +++ b/src/buffer/index.ts @@ -124,7 +124,7 @@ interface SenderBuffer { /** * Writes an array column with its values into the buffer. * @param name - Column name - * @param value - Array values to write (currently supports double arrays). A null or undefined value omits the column entirely, storing NULL. + * @param value - Array values to write (currently supports double arrays). A null or undefined value omits the column entirely when arrays are supported; protocol v1 rejects the call for every value. * @returns Returns with a reference to this buffer. * @throws Error if arrays are not supported by the buffer implementation, or array validation fails: * - value is not an array @@ -167,6 +167,8 @@ interface SenderBuffer { * * @throws {Error} If `value` is not an integer or `BigInt`. * @throws {Error} If `unit` is `'ns'` but `value` is not a `BigInt`. + * @throws {Error} If `unit` is not one of `'ns'`, `'us'`, or `'ms'`. This + * validation runs even when `value` is null or undefined. */ timestampColumn( name: string, @@ -183,7 +185,8 @@ interface SenderBuffer { * @param {string | number | null | undefined} value - The decimal value to write. * - Accepts either a `number` or a `string` containing a valid decimal representation. * - String values should follow standard decimal notation (e.g., `"123.45"` or `"-0.001"`). - * - A null or undefined value omits the column entirely (stored as NULL). + * - A null or undefined value omits the column entirely (stored as NULL) + * when decimals are supported; protocol v1/v2 reject the call for every value. * @returns {Sender} Returns with a reference to this buffer. * @throws Error If decimals are not supported by the buffer implementation, or validation fails. * Possible validation errors: @@ -205,7 +208,8 @@ interface SenderBuffer { * - If an `Int8Array` is provided, it must contain the two’s complement representation * of the unscaled value in **big-endian** byte order. * - An empty `Int8Array` represents a `NULL` value. - * - A null or undefined value omits the column entirely (stored as NULL). + * - A null or undefined value omits the column entirely (stored as NULL) + * when decimals are supported; protocol v1/v2 reject the call for every value. * @param {number} scale - The number of fractional digits (the scale) of the decimal value. * @returns {SenderBuffer} Returns with a reference to this buffer. * @throws {Error} If decimals are not supported by the buffer implementation, or validation fails. @@ -239,8 +243,10 @@ interface SenderBuffer { * * @returns {SenderBuffer} Returns with a reference to this buffer. * - * @throws {Error} If `value` is not an integer or `BigInt`. - * @throws {Error} If `unit` is `'ns'` but `value` is not a `BigInt`. + * @throws {Error} If `timestamp` is not an integer or `BigInt`. + * @throws {Error} If `unit` is `'ns'` but `timestamp` is not a `BigInt`. + * @throws {Error} If `unit` is not one of `'ns'`, `'us'`, or `'ms'`. This + * validation leaves the open row unchanged so the call can be retried. */ at(timestamp: number | bigint, unit: TimestampUnit): void; diff --git a/src/sender.ts b/src/sender.ts index 10edd52..b0338b8 100644 --- a/src/sender.ts +++ b/src/sender.ts @@ -406,7 +406,7 @@ class Sender { * Writes an array column with its values into the buffer of the sender. * * @param {string} name - Column name - * @param {unknown[] | null | undefined} value - Array values to write (currently supports double arrays). A null or undefined value omits the column entirely, storing NULL. + * @param {unknown[] | null | undefined} value - Array values to write (currently supports double arrays). A null or undefined value omits the column entirely when arrays are supported; protocol v1 rejects the call for every value. * @returns {Sender} Returns with a reference to this sender. * @throws Error if arrays are not supported by the buffer implementation, or array validation fails: * - value is not an array @@ -475,7 +475,7 @@ class Sender { * Use it to insert into DECIMAL database columns. * * @param {string} name - Column name. - * @param {string | number | null | undefined} value - Column value, accepts only number/string values. A null or undefined value omits the column entirely (stored as NULL). + * @param {string | number | null | undefined} value - Column value, accepts only number/string values. A null or undefined value omits the column entirely when decimals are supported; ILP protocol v1/v2 reject the call for every value. * @returns {Sender} Returns with a reference to this buffer. * @throws Error if decimals are not supported by the buffer implementation, or decimal validation fails: * - string value is not a valid decimal representation @@ -497,7 +497,8 @@ class Sender { * @param {string} name - Column name. * @param {Int8Array | bigint | null | undefined} unscaled - The unscaled value of the decimal in two's * complement representation and big-endian byte order. - * A null or undefined value omits the column entirely (stored as NULL). + * A null or undefined value omits the column entirely when decimals are + * supported; ILP protocol v1/v2 reject the call for every value. * An empty array also represents NULL, but the two are not encoded alike: * on the ILP transports an empty array writes an explicit NULL decimal * field, while the QWP transports omit the column exactly as they do for @@ -521,13 +522,15 @@ class Sender { /** * Closes the row after writing the designated timestamp. - * If validation or encoding rejects the row before it is completed, the - * incomplete row and its table selection are discarded; rows completed - * earlier remain staged. Start the next row with {@link table} again. If this - * call triggers an auto-flush that fails, ILP transports have already removed - * the entire staged batch from the sender buffer. Applications that need to - * retry ILP rows must retain and resubmit them. QWP retains successfully - * closed rows for its retry and replay path. + * On ILP, an invalid timestamp unit is rejected before closing begins and + * leaves the row open so this method can be retried. If other validation or + * encoding rejects the row before it is completed, the incomplete row and its + * table selection are discarded; rows completed earlier remain staged. Start + * the next row with {@link table} again. If this call triggers an auto-flush + * that fails, ILP transports have already removed the entire staged batch from + * the sender buffer. Applications that need to retry ILP rows must retain and + * resubmit them. QWP retains successfully closed rows for its retry and replay + * path. * * **Precision rules**: * - **Protocol v2 and higher:** @@ -547,6 +550,7 @@ class Sender { * * @throws {Error} If `timestamp` is not an integer or `BigInt`. * @throws {Error} If `unit` is `'ns'` but `timestamp` is not a `BigInt`. + * @throws {Error} If `unit` is not one of `'ns'`, `'us'`, or `'ms'`. */ async at( timestamp: number | bigint, diff --git a/test/sender.buffer.test.ts b/test/sender.buffer.test.ts index 9527ba7..77c28cc 100644 --- a/test/sender.buffer.test.ts +++ b/test/sender.buffer.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest"; import { readFileSync } from "fs"; -import { Sender, SenderOptions } from "../src"; +import { createBuffer, Sender, SenderOptions } from "../src"; import { PROTOCOL_VERSION_V3 } from "../src/options"; type Column = { name: string } & ( @@ -181,17 +181,28 @@ describe("Sender message builder test suite (anything not covered in client inte await sender.close(); }); - it("does not support arrays with protocol v1", async function () { - const sender = new Sender({ + it("rejects arrays with protocol v1 regardless of value", async function () { + const options = { protocol: "tcp", protocol_version: "1", host: "host", + auto_flush: false, init_buf_size: 1024, - }); - expect(() => - sender.table("tableName").arrayColumn("arrayCol", [12.3, 23.4]), - ).toThrow("Arrays are not supported in protocol v1"); - await sender.close(); + }; + + // Cover both the public buffer factory and the Sender delegation path. + for (const target of [createBuffer(options), new Sender(options)]) { + for (const value of [[12.3, 23.4], null, undefined]) { + target.reset(); + expect(() => + target.table("tableName").arrayColumn("arrayCol", value), + ).toThrow("Arrays are not supported in protocol v1"); + } + if (target instanceof Sender) { + target.reset(); + await target.close(); + } + } }); it("supports arrays with protocol v2", async function () { @@ -623,10 +634,9 @@ describe("Sender message builder test suite (anything not covered in client inte await sender.close(); }); - it("discards a row whose designated timestamp is rejected", async function () { - // The unit is only checked inside writeTimestamp, which runs after the - // separator has been written, so retrying at() used to append a second - // separator and corrupt the line. + it("keeps a row open when its designated timestamp unit is rejected", async function () { + // Unit validation happens before the close attempt mutates the row, so the + // caller can correct a bad constant and retry at() directly. const sender = new Sender({ protocol: "http", protocol_version: "2", @@ -643,8 +653,8 @@ describe("Sender message builder test suite (anything not covered in client inte .at(1000, "weeks" as "us"), ).rejects.toThrow("Unknown timestamp unit: weeks"); - await sender.table("t").stringColumn("c", "y").at(1000, "us"); - expect(bufferContent(sender)).toBe('t c="y" 1000t\n'); + await sender.at(1000, "us"); + expect(bufferContent(sender)).toBe('t c="x" 1000t\n'); await sender.close(); }); @@ -666,30 +676,44 @@ describe("Sender message builder test suite (anything not covered in client inte await sender.close(); }); - it("skips null/undefined array columns regardless of protocol version", async function () { - // v1 does not support arrays, but a null or undefined value is a no-op skip - // (consistent with every other column type) rather than an error. - const sender = new Sender({ - protocol: "tcp", - protocol_version: "1", - host: "host", - auto_flush: false, - init_buf_size: 1024, - }); - await sender - .table("tableName") - .arrayColumn("skippedArr1", null) - .arrayColumn("skippedArr2", undefined) - .intColumn("keptInt", 1) - .atNow(); - expect(bufferContent(sender)).toBe("tableName keptInt=1i\n"); + it("rejects decimals with protocol v1/v2 regardless of value", async function () { + for (const version of ["1", "2"] as const) { + const options = { + protocol: "tcp", + protocol_version: version, + host: "host", + auto_flush: false, + init_buf_size: 1024, + }; + + // Cover both the public buffer factory and the Sender delegation path. + for (const target of [createBuffer(options), new Sender(options)]) { + for (const value of ["1.5", null, undefined] as const) { + target.reset(); + expect(() => target.table("t").decimalColumnText("d", value)).toThrow( + "Decimals are not supported in protocol v1/v2", + ); + } - // An actual array value still throws on v1. - sender.reset(); - expect(() => - sender.table("tableName").arrayColumn("arr", [1, 2, 3]), - ).toThrow("Arrays are not supported in protocol v1"); - await sender.close(); + for (const value of [15n, null, undefined] as const) { + target.reset(); + expect(() => target.table("t").decimalColumn("d", value, 2)).toThrow( + "Decimals are not supported in protocol v1/v2", + ); + + for (const scale of [-1, 77]) { + target.reset(); + expect(() => + target.table("t").decimalColumn("d", value, scale), + ).toThrow("Scale must be between 0 and 76"); + } + } + if (target instanceof Sender) { + target.reset(); + await target.close(); + } + } + } }); it("throws on invalid timestamp unit", async function () { From f9961feaa42d39b0e38c42d082b0e6c21f05d5f2 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 27 Aug 2026 17:32:46 +0100 Subject: [PATCH 214/265] fix: retry transient QWP journal faults --- QWP.md | 15 +++-- README.md | 20 +++--- src/qwp-node/file-replay-store.ts | 69 ++++++++++--------- test/qwp/reconnect.test.ts | 106 ++++++++++++++++++++++++++---- 4 files changed, 153 insertions(+), 57 deletions(-) diff --git a/QWP.md b/QWP.md index b39b56d..e5e26b4 100644 --- a/QWP.md +++ b/QWP.md @@ -156,7 +156,7 @@ session that consumes it. | `sf_max_total_bytes` | integer bytes | `10737418240` | Journal ceiling. Reaching it is the one error a producer sees. | | `sf_max_segment_bytes` | integer bytes | `4194304` | Size of one segment file. | | `sf_sync_interval_millis` | integer ms | — | Checkpoint interval when `sf_durability=periodic`. | -| `sf_append_deadline_millis` | integer ms | `30000` | How long an append waits for space before failing. | +| `sf_append_deadline_millis` | integer ms | `30000` | How long an append waits for space or a retryable journal fault. | | `initial_connect_retry` | `off`, `sync`, `async` | `off` | Startup policy when the server is unreachable. Requires `sf_dir`. | | `drain_orphans` | `on`, `off` | off | Adopt and drain journals left by crashed producers. | | `max_background_drainers` | integer | — | Concurrent orphan drainers. | @@ -328,11 +328,14 @@ The connect-string key `QwpReplayStoreFullError` behavior. Set it to `"wait"` to pause publication until an ACK advances the checksummed cursor, then a bounded background trimmer deletes fully drained segments. -`appendDeadlineMs` bounds each such pause (30 seconds by -default) and expiry raises `QwpReplayStoreAppendTimeoutError`. Waiting appenders do -not hold the journal mutation queue, so ACK cleanup can continue. Direct users of -`QwpNodeFileReplayStore` can inspect `metrics` for pending records and segments, -checkpoint work, checkpoint failures, active waiters, stalls, and timeouts. +`appendDeadlineMs` bounds each such pause and retries of transient journal faults +such as a briefly read-only, full, or descriptor-starved filesystem (30 seconds +by default). Expiry raises `QwpReplayStoreAppendTimeoutError`. Waiting appenders +do not hold the journal mutation queue, so ACK cleanup and checkpoint recovery +can continue. Corruption and loss of the journal lock remain immediate failures. +Direct users of `QwpNodeFileReplayStore` can inspect `metrics` for pending records +and segments, checkpoint work, checkpoint failures, active waiters, stalls, and +timeouts. The persisted symbol dictionary is monotonic for one open journal generation and cannot be reclaimed by an ACK alone. It counts toward the `maxBytes` target together diff --git a/README.md b/README.md index 81d03d1..86b4863 100644 --- a/README.md +++ b/README.md @@ -86,15 +86,17 @@ await sender // wire: trades,symbol=BTC-USD price=39269.98,amount=0.011 ``` -This applies to every column method supported by the selected protocol on both -the ILP (`http`/`https`/`tcp`/`tcps`) and QWP (`ws`/`wss`/`udp`) senders, and to -the compiled QWP writers. Capability checks still run for nullish values: ILP -v1 always rejects `arrayColumn`, and ILP v1/v2 always reject the decimal column -methods. The one method that spreads a single value over several arguments, -`long256Column`, omits its column when _all four_ words are nullish; a partial -set is rejected rather than treated as NULL. - -Two consequences are worth knowing: +The eight column methods on `Sender` follow this rule for both ILP +(`http`/`https`/`tcp`/`tcps`) and QWP (`ws`/`wss`/`udp`) transports, subject to +protocol support. The broader direct `QwpSender` API and compiled QWP writers +follow the same omission rule for their additional column types. Capability +checks still run for nullish values: ILP v1 always rejects `arrayColumn`, and ILP +v1/v2 always reject the decimal column methods. The QWP-only +`QwpSender.long256Column` method spreads one value over four arguments; it omits +the column when _all four_ words are nullish and rejects a partial set rather +than treating it as NULL. + +Three consequences are worth knowing: - An omitted column is not created on a table that does not already have it. The omission carries no type, so schema-on-write has nothing to infer from. diff --git a/src/qwp-node/file-replay-store.ts b/src/qwp-node/file-replay-store.ts index 25a5973..f161924 100644 --- a/src/qwp-node/file-replay-store.ts +++ b/src/qwp-node/file-replay-store.ts @@ -55,10 +55,10 @@ const DEFAULT_MAX_SEGMENT_BYTES = 4 * 1024 * 1024; const DEFAULT_CHECKPOINT_INTERVAL_MS = 5_000; const DEFAULT_APPEND_DEADLINE_MS = 30_000; const TRIM_BATCH_SIZE = 8; -// Background segment trimming retries on this cadence. A trim failure is -// normally transient -- a briefly full or read-only filesystem, a maintenance -// worker restart -- so it must not become permanent. -const MAINTENANCE_RETRY_DELAY_MS = 1_000; +// Retry transient store faults on this cadence. Filesystem recovery does not +// emit a capacity signal, so foreground appends poll at the same deliberately +// slow rate as background segment maintenance. +const TRANSIENT_STORE_RETRY_DELAY_MS = 1_000; const MAX_TIMER_DELAY_MS = 0x7fffffff; const UTF8_DECODER = new TextDecoder("utf-8", { fatal: true }); @@ -177,7 +177,7 @@ export interface QwpNodeFileReplayStoreOptions { * Defaults to `error` for backwards compatibility. */ backpressurePolicy?: QwpSfBackpressurePolicy; - /** Per-append disk-capacity wait deadline. Defaults to 30 seconds. */ + /** Per-append capacity or retryable store-fault deadline. Defaults to 30 seconds. */ appendDeadlineMs?: number; /** * Reports journal bytes abandoned during recovery. Defaults to logging at @@ -224,6 +224,11 @@ export class QwpReplayStoreError extends Error { } } +/** An in-memory journal invariant cannot become true by retrying the same call. */ +class QwpReplayStoreInvariantError extends QwpReplayStoreError { + override readonly retryable = false; +} + /** Durable journal bytes are structurally corrupt and cannot be replayed. */ export class QwpReplayStoreCorruptionError extends QwpReplayStoreError { /** Corrupt bytes read the same way on every attempt. */ @@ -1100,16 +1105,10 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { await this.enqueue(() => this.appendOnce(record, bytes)); return; } catch (error) { - const full = error instanceof QwpReplayStoreFullError; - // A background segment trim that transiently failed self-heals on its - // scheduled retry, whose signalCapacity() releases parked appenders. A - // fresh append hits that parked failure at assertReady() -- but it must - // not surface as the flush error either, so wait it out within the same - // append deadline as the journal ceiling. A permanent fault still ends - // in the typed append timeout. (checkpointFailure is not released by - // signalCapacity, so it still propagates; see scheduleMaintenance.) - const healingTrim = error === this.maintenanceFailure; - if (!full && !healingTrim) throw error; + if (this.closing || this.closed) throw this.closedError(); + if (!(error instanceof QwpReplayStoreError) || !error.retryable) { + throw error; + } if (this.backpressurePolicy === QWP_SF_BACKPRESSURE_POLICY.ERROR) { throw error; } @@ -1135,6 +1134,13 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { capacityGeneration, remainingMs, requiredBytes, + // Capacity exhaustion has an explicit ACK/trim wake-up. A generic + // retryable store fault (for example EACCES while activating a hot + // spare) has no event when the filesystem heals, so retry it on a + // bounded cadence until the same append deadline expires. + error instanceof QwpReplayStoreFullError + ? undefined + : TRANSIENT_STORE_RETRY_DELAY_MS, ); } } @@ -1147,7 +1153,7 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { this.assertReady(); validateFrameSequence(record.frameSequence); if (this.records.has(record.frameSequence)) { - throw new QwpReplayStoreError( + throw new QwpReplayStoreInvariantError( `QWP store-and-forward sequence already exists [frameSequence=${record.frameSequence}]`, ); } @@ -1158,7 +1164,7 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { lastSequence !== undefined && record.frameSequence !== lastSequence + 1n ) { - throw new QwpReplayStoreError( + throw new QwpReplayStoreInvariantError( `QWP store-and-forward sequence must be contiguous [previous=${lastSequence}, received=${record.frameSequence}]`, ); } @@ -1168,13 +1174,13 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { } const expectedSequence = segment.firstSequence + BigInt(segment.frameCount); if (record.frameSequence !== expectedSequence) { - throw new QwpReplayStoreError( + throw new QwpReplayStoreInvariantError( `QWP store-and-forward segment sequence must be contiguous [expected=${expectedSequence}, received=${record.frameSequence}]`, ); } const handle = segment.handle; if (!handle) { - throw new QwpReplayStoreError( + throw new QwpReplayStoreInvariantError( `active QWP store-and-forward segment is not open [file=${segment.path}]`, ); } @@ -1459,7 +1465,7 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { this.maintenanceRetryTimer = undefined; if (this.closing || this.closed) return; this.scheduleMaintenance(); - }, MAINTENANCE_RETRY_DELAY_MS); + }, TRANSIENT_STORE_RETRY_DELAY_MS); this.maintenanceRetryTimer.unref?.(); } @@ -1564,21 +1570,23 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { capacityGeneration: number, timeoutMs: number, requiredBytes: number, + retryIntervalMs?: number, ): Promise { - if (this.checkpointFailure) { - return Promise.reject(this.checkpointFailure); - } - // maintenanceFailure is deliberately not rejected here: it self-heals on - // its scheduled retry, whose signalCapacity() releases this waiter, exactly - // as scheduleMaintenance() leaves the already-parked appender waiting. A - // permanent fault is bounded by the append deadline below. if (capacityGeneration !== this.capacityGeneration) { return Promise.resolve(); } return new Promise((resolve, reject) => { const pending: PendingCapacity = { resolve, reject }; + const timerMs = + retryIntervalMs === undefined + ? timeoutMs + : Math.min(timeoutMs, retryIntervalMs); pending.timer = setTimeout(() => { if (!this.capacityWaiters.delete(pending)) return; + if (retryIntervalMs !== undefined) { + resolve(); + return; + } this.totalAppendTimeouts++; reject( new QwpReplayStoreAppendTimeoutError( @@ -1587,7 +1595,7 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { this.appendDeadlineMs, ), ); - }, timeoutMs); + }, timerMs); this.capacityWaiters.add(pending); if (capacityGeneration !== this.capacityGeneration) { this.capacityWaiters.delete(pending); @@ -1646,6 +1654,7 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { return; } try { + const recovering = this.checkpointFailure !== undefined; const paths = [...this.dirtyRecordPaths]; if (this.dictionaryDirty) { paths.push(join(this.directory, DICTIONARY_FILE)); @@ -1664,11 +1673,11 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { this.directoryDirty = false; this.checkpointFailure = undefined; this.totalCheckpoints++; + if (recovering) this.signalCapacity(); } catch (cause) { const error = new QwpReplayStoreCheckpointError(this.directory, cause); this.checkpointFailure = error; this.totalCheckpointFailures++; - this.rejectCapacityWaiters(error); throw error; } } @@ -2190,7 +2199,7 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { private assertReady(): void { this.assertOpen(); if (!this.loaded) { - throw new QwpReplayStoreError( + throw new QwpReplayStoreInvariantError( "QWP store-and-forward journal must be loaded before use", ); } diff --git a/test/qwp/reconnect.test.ts b/test/qwp/reconnect.test.ts index 526b849..f7a67a4 100644 --- a/test/qwp/reconnect.test.ts +++ b/test/qwp/reconnect.test.ts @@ -4526,7 +4526,11 @@ describe("QWP Node file replay store", () => { // Only Date is faked here: the heartbeat is what must *not* get a chance to // run, which is exactly the window the first write after resuming lands in. const directory = await trackedDirectory(); - const store = new QwpNodeFileReplayStore({ directory }); + const store = new QwpNodeFileReplayStore({ + directory, + backpressurePolicy: QWP_SF_BACKPRESSURE_POLICY.WAIT, + appendDeadlineMs: 100, + }); await store.load(); await store.append({ frameSequence: 0n, payload: Uint8Array.of(1) }); @@ -4901,6 +4905,32 @@ describe("QWP Node file replay store", () => { await expectOnlyJavaSlotLockMetadata(directory); }); + it("does not wait on a non-retryable append invariant", async () => { + const directory = await trackedDirectory(); + const store = new QwpNodeFileReplayStore({ + directory, + backpressurePolicy: QWP_SF_BACKPRESSURE_POLICY.WAIT, + appendDeadlineMs: 1_000, + }); + await store.load(); + await store.append({ frameSequence: 0n, payload: Uint8Array.of(1) }); + + await expect( + store.append({ frameSequence: 0n, payload: Uint8Array.of(1) }), + ).rejects.toMatchObject({ + name: "QwpReplayStoreError", + retryable: false, + message: + "QWP store-and-forward sequence already exists [frameSequence=0]", + }); + expect(store.metrics).toMatchObject({ + waitingAppends: 0, + totalBackpressureStalls: 0, + totalAppendTimeouts: 0, + }); + await store.close(); + }); + it("checkpoints periodic frame and dictionary writes", async () => { const directory = await trackedDirectory(); const store = new QwpNodeFileReplayStore({ @@ -4949,16 +4979,16 @@ describe("QWP Node file replay store", () => { await store.close(); }); - it("fails waiting appends closed when a periodic checkpoint fails", async () => { + it("bounds waiting appends when a periodic checkpoint cannot recover", async () => { const directory = await trackedDirectory(); const store = new QwpNodeFileReplayStore({ directory, maxBytes: 66, maxSegmentBytes: 1, durability: QWP_SF_DURABILITY.PERIODIC, - checkpointIntervalMs: 250, + checkpointIntervalMs: 100, backpressurePolicy: QWP_SF_BACKPRESSURE_POLICY.WAIT, - appendDeadlineMs: 2_000, + appendDeadlineMs: 500, }); await store.load(); await store.append({ frameSequence: 0n, payload: Uint8Array.of(1) }); @@ -4970,12 +5000,17 @@ describe("QWP Node file replay store", () => { frameSequence: 2n, payload: Uint8Array.of(3), }); + const rejection = expect(blocked).rejects.toBeInstanceOf( + QwpReplayStoreAppendTimeoutError, + ); await vi.waitFor(() => expect(store.metrics.waitingAppends).toBe(1)); - await expect(blocked).rejects.toBeInstanceOf(QwpReplayStoreCheckpointError); + await vi.waitFor(() => + expect(store.metrics.totalCheckpointFailures).toBeGreaterThan(0), + ); + await rejection; expect(store.metrics).toMatchObject({ waitingAppends: 0, - totalCheckpointFailures: 1, - totalAppendTimeouts: 0, + totalAppendTimeouts: 1, }); await expect(store.close()).rejects.toBeInstanceOf( QwpReplayStoreCheckpointError, @@ -4988,12 +5023,59 @@ describe("QWP Node file replay store", () => { await reopened.close(); }); + it("waits out a transient hot-spare write fault", async () => { + const directory = await trackedDirectory(); + const store = new QwpNodeFileReplayStore({ + directory, + maxSegmentBytes: 1, + backpressurePolicy: QWP_SF_BACKPRESSURE_POLICY.WAIT, + appendDeadlineMs: 3_000, + }); + await store.load(); + await store.append({ frameSequence: 0n, payload: Uint8Array.of(1) }); + + // Let the first append replenish its hot spare before injecting faults. The + // first failure then hits background replenishment after frame 1; the second + // hits frame 2's required provisioning path and reaches appendWithBackpressure + // as a plain, retryable QwpReplayStoreError. The next retry uses the real + // worker and succeeds. + const internals = store as unknown as { hotSpare?: unknown }; + await vi.waitFor(() => expect(internals.hotSpare).toBeDefined()); + const transient = Object.assign(new Error("EACCES: permission denied"), { + code: "EACCES", + }); + const provision = vi + .spyOn(qwpSegmentMaintenanceWorker, "provision") + .mockRejectedValueOnce(transient) + .mockRejectedValueOnce(transient); + + await store.append({ frameSequence: 1n, payload: Uint8Array.of(2) }); + await vi.waitFor(() => expect(provision).toHaveBeenCalledTimes(1)); + const recovering = store.append({ + frameSequence: 2n, + payload: Uint8Array.of(3), + }); + await vi.waitFor(() => + expect(store.metrics.totalBackpressureStalls).toBe(1), + ); + await expect(recovering).resolves.toBeUndefined(); + expect(provision.mock.calls.length).toBeGreaterThanOrEqual(3); + expect(store.metrics).toMatchObject({ + pendingRecords: 3, + waitingAppends: 0, + totalBackpressureStalls: 1, + totalAppendTimeouts: 0, + }); + + provision.mockRestore(); + await store.close(); + }, 10_000); + it("keeps a parked append waiting across a transient trim fault", async () => { - // The sibling checkpoint failure above rejects waiting appends because that - // class has no retry. Maintenance does retry, so a parked append must stay - // parked and be released when the retry frees capacity -- never rejected - // with the retryable trim error, which is not the deadline error a producer - // watches for. + // The permanent checkpoint failure above reaches the append deadline. + // Maintenance retries and self-heals, so a parked append must instead be + // released when that retry frees capacity -- never rejected with the + // retryable trim error, which is not the deadline error a producer watches. const directory = await trackedDirectory(); const store = new QwpNodeFileReplayStore({ directory, From c044adcf8d4ff9874aed25cd33cbc88ecc391769 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 27 Aug 2026 22:59:37 +0100 Subject: [PATCH 215/265] docs(qwp): document numeric null sentinels --- QWP.md | 13 ++++++++++--- src/_qwp/sender.ts | 14 ++++++++++++++ src/_qwp/writer.ts | 18 +++++++++++++++--- 3 files changed, 39 insertions(+), 6 deletions(-) diff --git a/QWP.md b/QWP.md index e5e26b4..1b70e14 100644 --- a/QWP.md +++ b/QWP.md @@ -611,13 +611,13 @@ The schema vocabulary covers every column type the fluent row API can write: | `bool()` | BOOLEAN | `boolean` | | `byte()` | BYTE | `number` | | `short()` | SHORT | `number` | -| `int32()` | INT | `number` | -| `int64()`, `long()` | LONG | `bigint` | +| `int32()` | INT | `number`; `-2_147_483_648` is the NULL sentinel | +| `int64()`, `long()` | LONG | `bigint`; `-9_223_372_036_854_775_808n` is the NULL sentinel | | `float32()` | FLOAT | `number` | | `float64()`, `double()` | DOUBLE | `number` | | `timestamp(unit)` | TIMESTAMP | `number` or `bigint`; `"ns"` requires `bigint` | | `designatedTimestamp(unit)` | designated TIMESTAMP | as above, required in every row | -| `date()` | DATE | `number` or `bigint` milliseconds since the epoch | +| `date()` | DATE | epoch milliseconds; `-9_223_372_036_854_775_808n` is the NULL sentinel | | `binary()` | BINARY | `Uint8Array`, copied on append | | `uuid()` | UUID | canonical UUID text, 16 canonical big-endian bytes, or `{ low, high }` | | `long256()` | LONG256 | unsigned 256-bit `bigint`, `0x` hex text, four little-endian words, or `{ words }` | @@ -633,6 +633,13 @@ LONG, LONG256, and nanosecond timestamp inputs are `bigint` so they cannot silen lose precision. The record forms are exactly what the egress result views hand back, so a query result value can be written straight into a row without conversion. +QuestDB reserves the minimum signed value as NULL for INT, LONG, and DATE. Both the +fluent setters (`int32Column()`, `longColumn()`, and `dateColumn()`) and the compiled +writer fields above follow the Java QWP client and server convention: passing +`-2_147_483_648` to INT, or `-9_223_372_036_854_775_808n` to LONG or DATE, stores +NULL. These sentinel values cannot be stored as ordinary numeric values. Passing +`null` or `undefined`, or omitting a compiled-writer field, also writes NULL. + Widths are spelled out deliberately. The fluent row API predates these names and its `floatColumn()` and `intColumn()` are 64-bit despite reading as 32-bit, with `float32Column()` and `int32Column()` as the narrow forms. Compiled writers avoid the diff --git a/src/_qwp/sender.ts b/src/_qwp/sender.ts index 5ca9e95..a92d6ea 100644 --- a/src/_qwp/sender.ts +++ b/src/_qwp/sender.ts @@ -1216,6 +1216,10 @@ export class QwpSender { } } + /** + * Adds a QuestDB INT column value. `-2_147_483_648` is QuestDB's INT NULL + * sentinel: it is stored as NULL and cannot be stored as an ordinary value. + */ int32Column(name: string, value: number | null | undefined): QwpSender { if (this.omitsNullish(name, value)) return this; try { @@ -1242,6 +1246,11 @@ export class QwpSender { } } + /** + * Adds a QuestDB LONG column value. `-9_223_372_036_854_775_808n` is + * QuestDB's LONG NULL sentinel: it is stored as NULL and cannot be stored as + * an ordinary value. + */ longColumn( name: string, value: number | bigint | null | undefined, @@ -1301,6 +1310,11 @@ export class QwpSender { } } + /** + * Adds a QuestDB DATE column value in milliseconds since the epoch. + * `-9_223_372_036_854_775_808n` is QuestDB's DATE NULL sentinel: it is + * stored as NULL and cannot be stored as an ordinary value. + */ dateColumn( name: string, millisecondsSinceEpoch: number | bigint | null | undefined, diff --git a/src/_qwp/writer.ts b/src/_qwp/writer.ts index 7b6a715..5d2ceb4 100644 --- a/src/_qwp/writer.ts +++ b/src/_qwp/writer.ts @@ -253,12 +253,20 @@ export function short(): QwpWriterColumn { return column("short", false); } -/** Defines a signed 32-bit QuestDB INT column. */ +/** + * Defines a signed 32-bit QuestDB INT column. + * `-2_147_483_648` is QuestDB's INT NULL sentinel: it is stored as NULL and + * cannot be stored as an ordinary INT value. + */ export function int32(): QwpWriterColumn { return column("int32", false); } -/** Defines a signed 64-bit QuestDB LONG column. Inputs must be bigint. */ +/** + * Defines a signed 64-bit QuestDB LONG column. Inputs must be bigint. + * `-9_223_372_036_854_775_808n` is QuestDB's LONG NULL sentinel: it is stored + * as NULL and cannot be stored as an ordinary LONG value. + */ export function int64(): QwpWriterColumn { return column("int64", false); } @@ -299,7 +307,11 @@ export function designatedTimestamp( return column("timestamp", true, { unit }); } -/** Defines a QuestDB DATE column. Inputs are milliseconds since the epoch. */ +/** + * Defines a QuestDB DATE column. Inputs are milliseconds since the epoch. + * `-9_223_372_036_854_775_808n` is QuestDB's DATE NULL sentinel: it is stored + * as NULL and cannot be stored as an ordinary DATE value. + */ export function date(): QwpWriterColumn { return column("date", false); } From c8576dd1bf091821b0f05fa54099c750f24ef1e1 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 27 Aug 2026 23:05:55 +0100 Subject: [PATCH 216/265] fix(qwp): reject UDP authentication aliases --- src/options.ts | 4 +++- test/qwp/udp-sender.test.ts | 19 ++++++++++++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/options.ts b/src/options.ts index 1fab518..aff8e76 100644 --- a/src/options.ts +++ b/src/options.ts @@ -803,7 +803,9 @@ function validateUdpSecurityOptions(options: SenderOptions): void { if ( options.username !== undefined || options.password !== undefined || - options.token !== undefined + options.token !== undefined || + options.auth !== undefined || + options.jwk !== undefined ) { throw new Error("authentication is not supported for QWP UDP transport"); } diff --git a/test/qwp/udp-sender.test.ts b/test/qwp/udp-sender.test.ts index 5e3fd5b..65215a7 100644 --- a/test/qwp/udp-sender.test.ts +++ b/test/qwp/udp-sender.test.ts @@ -325,11 +325,27 @@ describe("QWP Node UDP sender", () => { }); it("rejects security options supplied through programmatic UDP options", () => { - const options = { protocol: "udp", host: "localhost", port: 9007 }; + let socketCreations = 0; + const options = { + protocol: "udp", + host: "localhost", + port: 9007, + qwp: { + udp: { + socketFactory: () => { + socketCreations++; + return new FakeUdpSocket(); + }, + }, + }, + }; for (const credentials of [ { username: "admin" }, { password: "secret" }, { token: "bearer" }, + { auth: { username: "admin", password: "secret" } }, + { auth: { keyId: "admin", token: "private-key" } }, + { jwk: { kty: "EC", crv: "P-256", d: "private-key" } }, ]) { expect(() => new Sender({ ...options, ...credentials } as never)).toThrow( "authentication is not supported for QWP UDP transport", @@ -344,6 +360,7 @@ describe("QWP Node UDP sender", () => { "TLS is not supported for QWP UDP transport", ); } + expect(socketCreations).toBe(0); }); it("rejects acknowledgement and transaction options that UDP cannot honor", () => { From 38175c6b3037e8965213ca236fef1fcee9372b05 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 27 Aug 2026 23:09:57 +0100 Subject: [PATCH 217/265] docs(qwp): document long-array ingestion limit --- QWP.md | 8 +++++++- src/_qwp/sender.ts | 7 +++++++ src/_qwp/writer.ts | 8 +++++++- 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/QWP.md b/QWP.md index 1b70e14..0f4f4d6 100644 --- a/QWP.md +++ b/QWP.md @@ -627,12 +627,18 @@ The schema vocabulary covers every column type the fluent row API can write: | `decimal128(scale)` | DECIMAL128 | as above, scale up to 38 | | `decimal256(scale)` | DECIMAL256 | as above, scale up to 76 | | `doubleArray()` | DOUBLE[] | nested `number` arrays of uniform shape, or `{ dimensions, values }` | -| `longArray()` | LONG[] | nested `bigint`/`number` arrays of uniform shape, or `{ dimensions, values }` | +| `longArray()` | LONG[] | encodes the protocol type, but current QuestDB servers reject ingestion | LONG, LONG256, and nanosecond timestamp inputs are `bigint` so they cannot silently lose precision. The record forms are exactly what the egress result views hand back, so a query result value can be written straight into a row without conversion. +Current QuestDB servers accept only DOUBLE arrays for ingestion. `longArrayColumn()` +and `longArray()` remain available for Java-client and protocol parity and encode the +QWP LONG_ARRAY type, but flushing one is rejected by the server with `long arrays are +not supported, only double arrays`. Decoding LONG_ARRAY values in query results remains +supported. + QuestDB reserves the minimum signed value as NULL for INT, LONG, and DATE. Both the fluent setters (`int32Column()`, `longColumn()`, and `dateColumn()`) and the compiled writer fields above follow the Java QWP client and server convention: passing diff --git a/src/_qwp/sender.ts b/src/_qwp/sender.ts index a92d6ea..69d6aa4 100644 --- a/src/_qwp/sender.ts +++ b/src/_qwp/sender.ts @@ -1280,6 +1280,13 @@ export class QwpSender { } } + /** + * Adds a protocol LONG[] column value. + * + * Current QuestDB servers reject LONG-array ingestion with `long arrays are + * not supported, only double arrays`. This method remains available for + * Java-client and protocol parity. + */ longArrayColumn( name: string, value: unknown[] | null | undefined, diff --git a/src/_qwp/writer.ts b/src/_qwp/writer.ts index 5d2ceb4..81a5253 100644 --- a/src/_qwp/writer.ts +++ b/src/_qwp/writer.ts @@ -381,7 +381,13 @@ export function doubleArray(): QwpWriterColumn { return column("doubleArray", false); } -/** Defines a QuestDB LONG[] column of any uniform shape. */ +/** + * Defines a protocol LONG[] column of any uniform shape. + * + * Current QuestDB servers reject LONG-array ingestion with `long arrays are + * not supported, only double arrays`. This descriptor remains available for + * Java-client and protocol parity. + */ export function longArray(): QwpWriterColumn { return column("longArray", false); } From 5c891bfb2c7cd670bf3ae5f5363415ce410e71c4 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 27 Aug 2026 23:15:06 +0100 Subject: [PATCH 218/265] fix(qwp): cap array dimensions at server limit --- QWP.md | 5 ++-- src/_qwp/_core/constants.ts | 2 ++ src/_qwp/_core/ingress.ts | 9 ++++++ src/_qwp/_core/table.ts | 7 +++-- src/_qwp/sender.ts | 13 ++++++-- src/_qwp/writer.ts | 4 +-- test/qwp/sender.test.ts | 59 +++++++++++++++++++++++++++++++++++++ 7 files changed, 90 insertions(+), 9 deletions(-) diff --git a/QWP.md b/QWP.md index 0f4f4d6..b16db5a 100644 --- a/QWP.md +++ b/QWP.md @@ -626,7 +626,7 @@ The schema vocabulary covers every column type the fluent row API can write: | `decimal64(scale)` | DECIMAL64 | unscaled `bigint`, decimal text, `number`, or `{ unscaled, scale }` | | `decimal128(scale)` | DECIMAL128 | as above, scale up to 38 | | `decimal256(scale)` | DECIMAL256 | as above, scale up to 76 | -| `doubleArray()` | DOUBLE[] | nested `number` arrays of uniform shape, or `{ dimensions, values }` | +| `doubleArray()` | DOUBLE[] | uniform nested arrays or `{ dimensions, values }`; 1 to 32 dimensions | | `longArray()` | LONG[] | encodes the protocol type, but current QuestDB servers reject ingestion | LONG, LONG256, and nanosecond timestamp inputs are `bigint` so they cannot silently @@ -637,7 +637,8 @@ Current QuestDB servers accept only DOUBLE arrays for ingestion. `longArrayColum and `longArray()` remain available for Java-client and protocol parity and encode the QWP LONG_ARRAY type, but flushing one is rejected by the server with `long arrays are not supported, only double arrays`. Decoding LONG_ARRAY values in query results remains -supported. +supported. QWP arrays may have between 1 and 32 dimensions; the client rejects a larger +rank before encoding a frame. QuestDB reserves the minimum signed value as NULL for INT, LONG, and DATE. Both the fluent setters (`int32Column()`, `longColumn()`, and `dateColumn()`) and the compiled diff --git a/src/_qwp/_core/constants.ts b/src/_qwp/_core/constants.ts index ffd2fc2..6105eaa 100644 --- a/src/_qwp/_core/constants.ts +++ b/src/_qwp/_core/constants.ts @@ -91,6 +91,8 @@ export const QWP_SERVER_ROLE = { } as const; export const QWP_MAX_COLUMNS_PER_TABLE = 2048; +/** Maximum array rank accepted by QuestDB's QWP ingress decoder. */ +export const QWP_MAX_ARRAY_DIMENSIONS = 32; /** Default QWP ingress identifier limits, in UTF-8 wire bytes. */ export const QWP_MAX_COLUMN_NAME_LENGTH = 127; export const QWP_MAX_TABLE_NAME_LENGTH = 127; diff --git a/src/_qwp/_core/ingress.ts b/src/_qwp/_core/ingress.ts index 5aa4331..e5a6c80 100644 --- a/src/_qwp/_core/ingress.ts +++ b/src/_qwp/_core/ingress.ts @@ -8,6 +8,7 @@ import { QWP_FLAG_DURABLE_ACK_POLL, QWP_FLAG_GORILLA, QWP_HEADER_SIZE, + QWP_MAX_ARRAY_DIMENSIONS, QWP_MAX_ERROR_MESSAGE_LENGTH, QWP_MAX_ROWS_PER_TABLE, QWP_MAX_SYMBOL_DICTIONARY_SIZE, @@ -267,6 +268,14 @@ function columnPayloadSize( ) { for (const value of column.values) { const array = value as QwpArrayValue; + if ( + array.dimensions.length === 0 || + array.dimensions.length > QWP_MAX_ARRAY_DIMENSIONS + ) { + throw new RangeError( + `QWP array must have between 1 and ${QWP_MAX_ARRAY_DIMENSIONS} dimensions`, + ); + } size += 1 + array.dimensions.length * 4 + array.values.length * 8; } return size; diff --git a/src/_qwp/_core/table.ts b/src/_qwp/_core/table.ts index b1a793c..44eeb66 100644 --- a/src/_qwp/_core/table.ts +++ b/src/_qwp/_core/table.ts @@ -1,5 +1,6 @@ import { QWP_COLUMN_TYPE, + QWP_MAX_ARRAY_DIMENSIONS, QWP_MAX_COLUMNS_PER_TABLE, QWP_MAX_TABLE_NAME_LENGTH, QwpColumnType, @@ -292,8 +293,10 @@ export function flattenQwpArray(value: unknown[]): QwpArrayValue { dimensions.push(level.length); level = level[0]; } - if (dimensions.length === 0 || dimensions.length > 255) { - throw new Error("QWP array must have between 1 and 255 dimensions"); + if (dimensions.length === 0 || dimensions.length > QWP_MAX_ARRAY_DIMENSIONS) { + throw new Error( + `QWP array must have between 1 and ${QWP_MAX_ARRAY_DIMENSIONS} dimensions`, + ); } const values: (number | bigint)[] = []; diff --git a/src/_qwp/sender.ts b/src/_qwp/sender.ts index 69d6aa4..be2a9bf 100644 --- a/src/_qwp/sender.ts +++ b/src/_qwp/sender.ts @@ -1,5 +1,6 @@ import { QWP_COLUMN_TYPE, + QWP_MAX_ARRAY_DIMENSIONS, QwpColumnType, QwpIngressEncodeOptions, QwpIngressResponse, @@ -702,8 +703,13 @@ function writerArrayValue(value: unknown, elements: "double" | "long") { `array dimension ${index}`, ), ); - if (dimensions.length === 0 || dimensions.length > 255) { - throw new RangeError("QWP array must have between 1 and 255 dimensions"); + if ( + dimensions.length === 0 || + dimensions.length > QWP_MAX_ARRAY_DIMENSIONS + ) { + throw new RangeError( + `QWP array must have between 1 and ${QWP_MAX_ARRAY_DIMENSIONS} dimensions`, + ); } const expected = dimensions.reduce( (total, dimension) => total * dimension, @@ -1267,6 +1273,7 @@ export class QwpSender { } } + /** Adds a QuestDB DOUBLE[] value with between 1 and 32 dimensions. */ arrayColumn(name: string, value: unknown[] | null | undefined): QwpSender { if (this.omitsNullish(name, value)) return this; try { @@ -1281,7 +1288,7 @@ export class QwpSender { } /** - * Adds a protocol LONG[] column value. + * Adds a protocol LONG[] column value with between 1 and 32 dimensions. * * Current QuestDB servers reject LONG-array ingestion with `long arrays are * not supported, only double arrays`. This method remains available for diff --git a/src/_qwp/writer.ts b/src/_qwp/writer.ts index 81a5253..62d7f66 100644 --- a/src/_qwp/writer.ts +++ b/src/_qwp/writer.ts @@ -376,13 +376,13 @@ export function decimal256(scale: number): QwpWriterColumn { }); } -/** Defines a QuestDB DOUBLE[] column of any uniform shape. */ +/** Defines a QuestDB DOUBLE[] column with between 1 and 32 dimensions. */ export function doubleArray(): QwpWriterColumn { return column("doubleArray", false); } /** - * Defines a protocol LONG[] column of any uniform shape. + * Defines a protocol LONG[] column with between 1 and 32 dimensions. * * Current QuestDB servers reject LONG-array ingestion with `long arrays are * not supported, only double arrays`. This descriptor remains available for diff --git a/test/qwp/sender.test.ts b/test/qwp/sender.test.ts index 83dedcc..8c68852 100644 --- a/test/qwp/sender.test.ts +++ b/test/qwp/sender.test.ts @@ -3,6 +3,7 @@ import { QWP_COLUMN_TYPE, QWP_EGRESS_MESSAGE, QWP_FLAG_DELTA_SYMBOL_DICTIONARY, + QWP_MAX_ARRAY_DIMENSIONS, QWP_STATUS, QwpIngressEncodeOptions, QwpIngressResponse, @@ -1811,6 +1812,64 @@ describe("QWP high-level sender", () => { expect(column(session.sends[0].tables[0], "price").values).toEqual([150n]); }); + it("caps array dimensionality at the server's 32-dimension limit", async () => { + const nestedArray = (rank: number): unknown[] => { + let value: unknown = 1; + for (let dimension = 0; dimension < rank; dimension++) value = [value]; + return value as unknown[]; + }; + const session = new RecordingSession(); + const sender = new QwpSender(async () => session, { autoFlush: false }); + const rank32 = new Array(QWP_MAX_ARRAY_DIMENSIONS).fill(1); + const rank33 = new Array(QWP_MAX_ARRAY_DIMENSIONS + 1).fill(1); + + await sender + .table("fluent") + .arrayColumn("samples", nestedArray(QWP_MAX_ARRAY_DIMENSIONS)) + .atNow(); + expect(() => + sender + .table("too_deep") + .arrayColumn("samples", nestedArray(QWP_MAX_ARRAY_DIMENSIONS + 1)), + ).toThrow(/between 1 and 32 dimensions/); + + const typed = sender.writer("typed", { samples: doubleArray() }); + await typed.row({ samples: { dimensions: rank32, values: [1] } }); + await expect( + typed.row({ samples: { dimensions: rank33, values: [1] } }), + ).rejects.toThrow(/between 1 and 32 dimensions/); + expect(sender.metrics.pendingRows).toBe(2); + + await sender.flush(); + const fluent = session.sends[0].tables.find( + (table) => table.name === "fluent", + ); + const compiled = session.sends[0].tables.find( + (table) => table.name === "typed", + ); + expect( + (column(fluent!, "samples").values[0] as { dimensions: number[] }) + .dimensions, + ).toHaveLength(QWP_MAX_ARRAY_DIMENSIONS); + expect( + (column(compiled!, "samples").values[0] as { dimensions: number[] }) + .dimensions, + ).toHaveLength(QWP_MAX_ARRAY_DIMENSIONS); + + // Keep the low-level encoder fail-closed even when a caller constructs a + // QwpTableBuffer directly and bypasses both high-level validators. + const raw = new QwpTableBuffer("raw"); + const rawColumn = raw.getOrCreateColumn( + "samples", + QWP_COLUMN_TYPE.DOUBLE_ARRAY, + )!; + rawColumn.values.push({ dimensions: rank33, values: [1] }); + raw.nextRow(); + expect(() => encodeQwpIngressFrame([raw])).toThrow( + /between 1 and 32 dimensions/, + ); + }); + it("sends an all-nullish writer row for a schema without a designated timestamp", async () => { // README and QWP.md say a QWP row whose every value is nullish is sent with // no columns, and the fluent table().atNow() analogue does exactly that. The From a6df999684e177590769bbbf975825cfdfcd0d95 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 27 Aug 2026 23:18:34 +0100 Subject: [PATCH 219/265] fix(qwp): keep mixed failover sweeps retryable --- .../reconnecting-ingress-connection.ts | 10 ++-- test/qwp/reconnect.test.ts | 59 ++++++++++++++++++- 2 files changed, 62 insertions(+), 7 deletions(-) diff --git a/src/_qwp/_internal/reconnecting-ingress-connection.ts b/src/_qwp/_internal/reconnecting-ingress-connection.ts index 1d5d88f..175b35b 100644 --- a/src/_qwp/_internal/reconnecting-ingress-connection.ts +++ b/src/_qwp/_internal/reconnecting-ingress-connection.ts @@ -2231,7 +2231,7 @@ function isEndpointPolicyFailure(error: unknown): boolean { ); } -/** Returns the typed capability gap retained anywhere in a failed endpoint sweep. */ +/** Returns a durable-ACK gap only when it accounts for the whole failure. */ function durableAckUnavailableCause( error: unknown, ): QwpDurableAckUnavailableError | undefined { @@ -2239,11 +2239,13 @@ function durableAckUnavailableCause( if (!(error instanceof QwpFailoverError) || error.attempts.length === 0) { return undefined; } + let cause: QwpDurableAckUnavailableError | undefined; for (const attempt of error.attempts) { - const cause = durableAckUnavailableCause(attempt.error); - if (cause) return cause; + const attemptCause = durableAckUnavailableCause(attempt.error); + if (!attemptCause) return undefined; + cause ??= attemptCause; } - return undefined; + return cause; } function isPrimaryUnavailableError(error: unknown): boolean { diff --git a/test/qwp/reconnect.test.ts b/test/qwp/reconnect.test.ts index f7a67a4..697ff84 100644 --- a/test/qwp/reconnect.test.ts +++ b/test/qwp/reconnect.test.ts @@ -1395,7 +1395,7 @@ describe("QWP ingress reconnect and replay", () => { } }); - it("preserves durable-ACK mismatch priority across a mixed endpoint sweep", async () => { + it("keeps a mixed durable-ACK and transport failure sweep retryable", async () => { let factoryCalls = 0; await expect( QwpIngressSession.connect( @@ -1425,8 +1425,8 @@ describe("QWP ingress reconnect and replay", () => { replayStore: new TrackingReplayStore(), }, ), - ).rejects.toBeInstanceOf(QwpDurableAckUnavailableError); - expect(factoryCalls).toBe(1); + ).rejects.toBeInstanceOf(QwpReconnectExhaustedError); + expect(factoryCalls).toBe(5); }); it("retries durable-ACK mismatch during asynchronous foreground startup", async () => { @@ -1527,6 +1527,59 @@ describe("QWP ingress reconnect and replay", () => { await session.close(); }); + it("does not count mixed endpoint sweeps as orphan durable-ACK mismatches", async () => { + const connection = new FakeConnection("primary", { + qwpVersion: 1, + durableAckEnabled: true, + }); + const events: QwpReconnectEvent[] = []; + let factoryCalls = 0; + const session = await QwpIngressSession.connect( + async () => { + factoryCalls++; + if (factoryCalls <= 16) { + throw new QwpFailoverError([ + { + endpoint: "ws://old-primary/write/v4", + error: new QwpDurableAckUnavailableError( + "ws://old-primary/write/v4", + ), + }, + { + endpoint: "ws://offline/write/v4", + error: new Error("connection refused"), + }, + ]); + } + return connection; + }, + { + backgroundStoreAndForward: true, + initialConnectMode: "async", + orphanStoreAndForward: true, + orphanDurableAckMismatchMaxDurationMs: 0, + reconnect: { + initialBackoffMs: 0, + maxBackoffMs: 0, + onEvent: (event) => events.push(event), + }, + replayStore: new TrackingReplayStore(), + }, + ); + + await vi.waitFor(() => expect(factoryCalls).toBe(17)); + expect( + events.some( + (event) => + event.kind === QWP_RECONNECT_EVENT_KIND.DURABLE_ACK_UNAVAILABLE || + event.kind === + QWP_RECONNECT_EVENT_KIND.DURABLE_ACK_PERSISTENT_FAILURE, + ), + ).toBe(false); + expect(session.metrics.lastError).toBeUndefined(); + await session.close(); + }); + it("bounds an orphan durable-ACK mismatch episode by duration", async () => { const events: QwpReconnectEvent[] = []; let factoryCalls = 0; From c5d56f9802d41260d07a5905dc42b23a89ca3498 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 27 Aug 2026 23:23:09 +0100 Subject: [PATCH 220/265] fix(qwp): bound browser session bootstrap --- src/qwp/browser.ts | 174 ++++++++++++++++++++++++++++++--------- test/qwp/session.test.ts | 119 ++++++++++++++++++++++++++ 2 files changed, 255 insertions(+), 38 deletions(-) diff --git a/src/qwp/browser.ts b/src/qwp/browser.ts index f027f2e..7f6f5d8 100644 --- a/src/qwp/browser.ts +++ b/src/qwp/browser.ts @@ -22,6 +22,7 @@ import { QwpConnectionFactory, QwpDurableAckUnavailableError, QwpEgressRoutingOptions, + QwpSendClosedError, QWP_UPGRADE_ERROR_KIND, QwpUpgradeError, QwpWebSocketConnectOptions, @@ -389,6 +390,33 @@ interface QwpResolvedBrowserClientOptions extends QwpBrowserClientBaseOptions { egress: QwpBrowserEgressOptions; } +const DEFAULT_BROWSER_CONNECT_TIMEOUT_MS = 15_000; + +function composeBrowserAbortSignals( + signals: readonly (AbortSignal | undefined)[], +): { signal: AbortSignal; dispose: () => void } { + const controller = new AbortController(); + const listeners: { signal: AbortSignal; listener: () => void }[] = []; + for (const signal of signals) { + if (!signal) continue; + if (signal.aborted) { + controller.abort(); + break; + } + const listener = (): void => controller.abort(); + signal.addEventListener("abort", listener, { once: true }); + listeners.push({ signal, listener }); + } + return { + signal: controller.signal, + dispose: () => { + for (const entry of listeners) { + entry.signal.removeEventListener("abort", entry.listener); + } + }, + }; +} + /** * Opens a QWP-capable browser WebSocket. * @@ -430,40 +458,108 @@ async function connectQwpBrowserEndpoint( completeHandshake: ( selectedProtocol: string | undefined, ) => QwpBinaryConnection["handshake"], + finishOpening: ( + connection: QwpBinaryConnection, + ) => Promise = async (connection) => connection, ): Promise { validateQwpWebSocketTimeouts(options); - if (options.sessionBootstrap) { - await bootstrapQwpBrowserSession({ - ...options.sessionBootstrap, - url: options.sessionBootstrap.url ?? defaultBootstrapUrl(endpoint), - }); + const connectTimeoutMs = + options.connectTimeoutMs ?? DEFAULT_BROWSER_CONNECT_TIMEOUT_MS; + const openingAbort = new AbortController(); + let openedConnection: QwpBinaryConnection | undefined; + let deadlineTimer: ReturnType | undefined; + let rejectBoundary!: (error: Error) => void; + let boundarySettled = false; + const failBoundary = (error: Error, reason: string): void => { + if (boundarySettled) return; + boundarySettled = true; + rejectBoundary(error); + openingAbort.abort(); + void openedConnection?.close(1000, reason).catch(() => undefined); + }; + const boundary = new Promise((_resolve, reject) => { + rejectBoundary = reject; + }); + const abortOpening = (): void => { + failBoundary( + new QwpSendClosedError(), + "QWP connection closed while connecting", + ); + }; + if (signal?.aborted) abortOpening(); + else signal?.addEventListener("abort", abortOpening, { once: true }); + if (!boundarySettled) { + deadlineTimer = setTimeout(() => { + failBoundary( + new QwpUpgradeError( + `QWP WebSocket connection timed out after ${connectTimeoutMs}ms`, + { + kind: QWP_UPGRADE_ERROR_KIND.TIMEOUT, + retryable: true, + tryNextEndpoint: true, + url: endpoint, + }, + ), + "QWP connection timeout", + ); + }, connectTimeoutMs); } - const factory = - options.webSocketFactory ?? - ((url: string | URL, protocols?: string | string[]) => { - const WebSocketConstructor = ( - globalThis as unknown as { - WebSocket?: new ( - url: string | URL, - protocols?: string | string[], - ) => QwpWebSocketLike; - } - ).WebSocket; - if (!WebSocketConstructor) { - throw new Error("WebSocket is not available in this browser runtime"); + + const opening = (async (): Promise => { + if (options.sessionBootstrap) { + const bootstrapAbort = composeBrowserAbortSignals([ + openingAbort.signal, + options.sessionBootstrap.signal, + ]); + try { + await bootstrapQwpBrowserSession({ + ...options.sessionBootstrap, + url: options.sessionBootstrap.url ?? defaultBootstrapUrl(endpoint), + signal: bootstrapAbort.signal, + }); + } finally { + bootstrapAbort.dispose(); } - return new WebSocketConstructor(url, protocols); + } + if (openingAbort.signal.aborted) throw new QwpSendClosedError(); + const factory = + options.webSocketFactory ?? + ((url: string | URL, protocols?: string | string[]) => { + const WebSocketConstructor = ( + globalThis as unknown as { + WebSocket?: new ( + url: string | URL, + protocols?: string | string[], + ) => QwpWebSocketLike; + } + ).WebSocket; + if (!WebSocketConstructor) { + throw new Error("WebSocket is not available in this browser runtime"); + } + return new WebSocketConstructor(url, protocols); + }); + const socket = factory(requestEndpoint, protocols); + openedConnection = await openQwpWebSocket(socket, { + signal: openingAbort.signal, + url: endpoint, + connectTimeoutMs, + sendTimeoutMs: options.sendTimeoutMs, + closeTimeoutMs: options.closeTimeoutMs, + completeHandshake: () => completeHandshake(socket.protocol), + opaqueErrors: true, }); - const socket = factory(requestEndpoint, protocols); - return openQwpWebSocket(socket, { - signal, - url: endpoint, - connectTimeoutMs: options.connectTimeoutMs, - sendTimeoutMs: options.sendTimeoutMs, - closeTimeoutMs: options.closeTimeoutMs, - completeHandshake: () => completeHandshake(socket.protocol), - opaqueErrors: true, - }); + return finishOpening(openedConnection); + })(); + + try { + const connection = await Promise.race([opening, boundary]); + boundarySettled = true; + return connection; + } finally { + boundarySettled = true; + if (deadlineTimer) clearTimeout(deadlineTimer); + signal?.removeEventListener("abort", abortOpening); + } } function browserNegotiationUrl( @@ -588,7 +684,7 @@ async function connectQwpBrowserIngressEndpoint( "ingressNegotiationTimeoutMs must be a non-negative finite number", ); } - const connection = await connectQwpBrowserEndpoint( + return connectQwpBrowserEndpoint( options, endpoint, browserNegotiationUrl(endpoint, "qwp_browser_handshake", "v1"), @@ -606,15 +702,17 @@ async function connectQwpBrowserIngressEndpoint( ? { qwpVersion: QWP_VERSION, durableAckEnabled: true } : { qwpVersion: QWP_VERSION }; }, + async (connection) => { + try { + return await applyQwpBrowserIngressHandshake(connection, timeoutMs); + } catch (error) { + await connection + .close(1002, "invalid QWP ingress SERVER_INFO") + .catch(() => undefined); + throw error; + } + }, ); - try { - return await applyQwpBrowserIngressHandshake(connection, timeoutMs); - } catch (error) { - await connection - .close(1002, "invalid QWP ingress SERVER_INFO") - .catch(() => undefined); - throw error; - } } function connectQwpBrowserEgressEndpoint( diff --git a/test/qwp/session.test.ts b/test/qwp/session.test.ts index 8dba3e6..74c419c 100644 --- a/test/qwp/session.test.ts +++ b/test/qwp/session.test.ts @@ -461,6 +461,79 @@ describe("QWP WebSocket adapters", () => { await connection.close(); }); + it("includes a stalled browser bootstrap in the connection deadline", async () => { + vi.useFakeTimers(); + try { + let bootstrapSignal: AbortSignal | null | undefined; + let webSocketFactoryCalls = 0; + const connecting = connectQwpBrowserWebSocket({ + url: "wss://questdb.example/write/v4", + connectTimeoutMs: 25, + sessionBootstrap: { + authentication: { type: "bearer", token: "rest-token" }, + fetch: async (_input, init) => { + bootstrapSignal = init?.signal; + return new Promise((_resolve, reject) => { + bootstrapSignal?.addEventListener( + "abort", + () => reject(new Error("bootstrap aborted")), + { once: true }, + ); + }); + }, + }, + webSocketFactory: () => { + webSocketFactoryCalls++; + return asQwpSocket(new FakeWebSocket()); + }, + }); + const rejected = expect(connecting).rejects.toMatchObject({ + name: "QwpUpgradeError", + kind: QWP_UPGRADE_ERROR_KIND.TIMEOUT, + retryable: true, + tryNextEndpoint: true, + message: "QWP WebSocket connection timed out after 25ms", + } satisfies Partial); + + await vi.advanceTimersByTimeAsync(25); + await rejected; + expect(bootstrapSignal?.aborted).toBe(true); + expect(webSocketFactoryCalls).toBe(0); + } finally { + vi.useRealTimers(); + } + }); + + it("includes browser ingress negotiation in the connection deadline", async () => { + vi.useFakeTimers(); + try { + const socket = new FakeWebSocket(); + const connecting = connectQwpBrowserIngress( + { + url: "wss://questdb.example/write/v4", + connectTimeoutMs: 25, + ingressNegotiationTimeoutMs: 1_000, + webSocketFactory: () => { + queueMicrotask(() => socket.open()); + return asQwpSocket(socket); + }, + }, + { reconnect: false }, + ); + const rejected = expect(connecting).rejects.toMatchObject({ + name: "QwpUpgradeError", + kind: QWP_UPGRADE_ERROR_KIND.TIMEOUT, + message: "QWP WebSocket connection timed out after 25ms", + } satisfies Partial); + + await vi.advanceTimersByTimeAsync(25); + await rejected; + expect(socket.closeCalls).toHaveLength(1); + } finally { + vi.useRealTimers(); + } + }); + it("uses one browser cluster for authenticated ingress, egress, and failover", async () => { const webSocketUrls: URL[] = []; const bootstrapUrls: URL[] = []; @@ -742,6 +815,52 @@ describe("QWP WebSocket adapters", () => { expect(pending.closeCalls.length).toBeGreaterThan(0); }); + it("aborts a browser bootstrap still pending when its session closes", async () => { + const socket = new FakeWebSocket(); + let bootstrapCalls = 0; + let reconnectBootstrapSignal: AbortSignal | null | undefined; + let rejectReconnectBootstrap: ((error: Error) => void) | undefined; + const session = await connectQwpBrowserIngress( + { + url: "ws://stalls.example/write/v4", + connectTimeoutMs: 30_000, + sessionBootstrap: { + authentication: { type: "bearer", token: "rest-token" }, + fetch: async (_input, init) => { + bootstrapCalls++; + if (bootstrapCalls === 1) { + return new Response("{}", { status: 200 }); + } + reconnectBootstrapSignal = init?.signal; + return new Promise((_resolve, reject) => { + rejectReconnectBootstrap = reject; + reconnectBootstrapSignal?.addEventListener( + "abort", + () => reject(new Error("bootstrap aborted")), + { once: true }, + ); + }); + }, + }, + webSocketFactory: () => { + queueMicrotask(() => { + socket.open(); + socket.message(ingressServerInfo(128)); + }); + return asQwpSocket(socket); + }, + }, + { reconnect: { initialBackoffMs: 0, maxBackoffMs: 0 } }, + ); + + socket.close(1006, "dropped"); + await vi.waitFor(() => expect(bootstrapCalls).toBe(2)); + await session.close(); + + expect(reconnectBootstrapSignal?.aborted).toBe(true); + expect(rejectReconnectBootstrap).toBeDefined(); + }); + it("attaches the socket error listener before an aborted signal closes it", async () => { // A failover sweep hands one AbortSignal to every endpoint in turn, so // after close() aborts it the next endpoint enters openQwpWebSocket with From e2d30941ffc43abb04432e6c757505d7d10a7366 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 27 Aug 2026 23:25:52 +0100 Subject: [PATCH 221/265] fix(qwp): close direct sessions after NACK --- QWP.md | 6 ++++ src/_qwp/ingress-session.ts | 23 ++++++++++++--- test/qwp/session.test.ts | 57 ++++++++++++++++++++++++------------- 3 files changed, 63 insertions(+), 23 deletions(-) diff --git a/QWP.md b/QWP.md index b16db5a..90d210f 100644 --- a/QWP.md +++ b/QWP.md @@ -872,6 +872,12 @@ five-minute per-outage deadline; the initial connection remains fail-fast. Set bounds, emits lifecycle events through `onEvent`, and retains the earlier opt-in behavior of retrying initial connection establishment. +QuestDB stops processing a connection's later frames after any ingress NACK so a +cumulative ACK cannot advance across the rejected sequence. Reconnecting sessions +recycle that connection and replay from their last ACK. A fixed `reconnect: false` +session instead becomes terminal and closes immediately after reporting the NACK; +create a new session before sending more rows. + Each retry delay is selected between zero and the current exponential ceiling, preventing clients disconnected together from retrying in lockstep. Configured attempt and duration bounds apply to browser/memory reconnect and Node `"sync"` startup. A diff --git a/src/_qwp/ingress-session.ts b/src/_qwp/ingress-session.ts index f3154cd..34e00ce 100644 --- a/src/_qwp/ingress-session.ts +++ b/src/_qwp/ingress-session.ts @@ -1440,11 +1440,26 @@ export class QwpIngressSession { const dictionaryGap = this.deltaSymbolsPublished && response.status === QWP_STATUS.DICTIONARY_GAP; - this.recordError(error, dictionaryGap, response, senderError); - if (dictionaryGap) { - // This wire cannot repair a missing prefix without reconnect catch-up. + const directPipelineBroken = + this.connection.managesIngressSenderErrors !== true; + this.recordError( + error, + dictionaryGap || directPipelineBroken, + response, + senderError, + ); + if (dictionaryGap || directPipelineBroken) { + // QuestDB stops processing later frames on a connection after any NACK + // so its cumulative ACK cannot skip the rejected sequence. Reconnecting + // transports recycle and replay below their last ACK; a fixed/direct + // session has no such recovery path and must fail closed immediately. this.fail(error, true); - void this.connection.close(1002, "QWP symbol dictionary gap"); + void this.connection.close( + 1002, + dictionaryGap + ? "QWP symbol dictionary gap" + : "QWP ingress pipeline rejected", + ); } } diff --git a/test/qwp/session.test.ts b/test/qwp/session.test.ts index 74c419c..a600e22 100644 --- a/test/qwp/session.test.ts +++ b/test/qwp/session.test.ts @@ -2082,7 +2082,7 @@ describe("QwpIngressSession", () => { await session.close(); }); - it("latches publication-only NACKs for later ACK watermark waits", async () => { + it("fails a fixed session and its ACK waiters after a publication-only NACK", async () => { const socket = new FakeWebSocket(); const connecting = connectQwpBrowserWebSocket({ url: "ws://localhost:9000/write/v4", @@ -2092,14 +2092,20 @@ describe("QwpIngressSession", () => { const session = new QwpIngressSession(await connecting); await session.publishFrame(Uint8Array.of(1)); await session.publishFrame(Uint8Array.of(2)); + const acknowledged = session.waitForAcknowledged(1n, 1_000); socket.message(ingressResponse(QWP_STATUS.WRITE_ERROR, 0n, "write failed")); - await vi.waitFor(() => expect(session.metrics.totalNacks).toBe(1)); - await expect(session.waitForAcknowledged(1n, 1_000)).rejects.toMatchObject({ + await expect(acknowledged).rejects.toMatchObject({ name: "QwpIngressNackError", response: { sequence: 0n, errorMessage: "write failed" }, } satisfies QwpIngressNackMatch); - await expect(session.waitForAcknowledged(-1n)).resolves.toBeUndefined(); + expect(() => session.publishFrame(Uint8Array.of(3))).toThrow( + "write failed", + ); + expect(socket.closeCalls).toContainEqual({ + code: 1002, + reason: "QWP ingress pipeline rejected", + }); await session.close(); }); @@ -2424,7 +2430,7 @@ describe("QwpIngressSession", () => { QWP_INGRESS_PROGRESS_KIND.DURABLE_ACKNOWLEDGED, QWP_INGRESS_PROGRESS_KIND.PUBLISHED, ]); - expect(errors).toEqual([{ terminal: false, message: "write failed" }]); + expect(errors).toEqual([{ terminal: true, message: "write failed" }]); expect(senderErrors[0]).toMatchObject({ category: QWP_SENDER_ERROR_CATEGORY.WRITE_ERROR, appliedPolicy: QWP_SENDER_ERROR_POLICY.TERMINAL, @@ -2452,6 +2458,13 @@ describe("QwpIngressSession", () => { lastError: expect.objectContaining({ name: "QwpIngressNackError" }), }); expect(Object.isFrozen(session.metrics)).toBe(true); + expect(() => session.publishFrame(Uint8Array.of(3))).toThrow( + "write failed", + ); + expect(socket.closeCalls).toContainEqual({ + code: 1002, + reason: "QWP ingress pipeline rejected", + }); await session.close(); }); @@ -2746,7 +2759,7 @@ describe("QwpIngressSession", () => { await session.close(); }); - it("rejects the matching frame on NACK without breaking later ACKs", async () => { + it("fails all pipelined frames and closes a fixed session after a NACK", async () => { const socket = new FakeWebSocket(); const connecting = connectQwpBrowserWebSocket({ url: "ws://localhost:9000/write/v4", @@ -2754,25 +2767,31 @@ describe("QwpIngressSession", () => { }); socket.open(); const session = new QwpIngressSession(await connecting); - let sequence = 0n; + let sent = 0; socket.onSend = () => { - const current = sequence++; - socket.message( - ingressResponse( - current === 0n ? QWP_STATUS.WRITE_ERROR : QWP_STATUS.OK, - current, - "write failed", - ), - ); + sent++; + if (sent === 2) { + socket.message( + ingressResponse(QWP_STATUS.WRITE_ERROR, 0n, "write failed"), + ); + } }; - await expect(session.sendFrame(Uint8Array.of(1))).rejects.toMatchObject({ + const first = session.sendFrame(Uint8Array.of(1)); + const second = session.sendFrame(Uint8Array.of(2)); + await expect(first).rejects.toMatchObject({ name: "QwpIngressNackError", response: { sequence: 0n, errorMessage: "write failed" }, } satisfies QwpIngressNackMatch); - await expect(session.sendFrame(Uint8Array.of(2))).resolves.toMatchObject({ - sequence: 1n, - status: QWP_STATUS.OK, + await expect(second).rejects.toMatchObject({ + name: "QwpIngressNackError", + response: { sequence: 0n, errorMessage: "write failed" }, + } satisfies QwpIngressNackMatch); + expect(socket.sent).toEqual([Uint8Array.of(1), Uint8Array.of(2)]); + expect(() => session.sendFrame(Uint8Array.of(3))).toThrow("write failed"); + expect(socket.closeCalls).toContainEqual({ + code: 1002, + reason: "QWP ingress pipeline rejected", }); await session.close(); }); From 94bbecdc58f09c5c9b628a709ec5d90f06462da1 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Fri, 28 Aug 2026 13:19:02 +0100 Subject: [PATCH 222/265] fix(qwp): retry unknown ingress statuses indefinitely --- .../reconnecting-ingress-connection.ts | 7 ++- test/qwp/reconnect.test.ts | 43 +++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/_qwp/_internal/reconnecting-ingress-connection.ts b/src/_qwp/_internal/reconnecting-ingress-connection.ts index 175b35b..3346d7c 100644 --- a/src/_qwp/_internal/reconnecting-ingress-connection.ts +++ b/src/_qwp/_internal/reconnecting-ingress-connection.ts @@ -46,6 +46,8 @@ import { createQwpProtocolViolationSenderError, createQwpSenderError, defaultQwpSenderErrorHandler, + qwpSenderErrorCategory, + QWP_SENDER_ERROR_CATEGORY, type QwpSenderError, } from "../sender-error"; @@ -1390,7 +1392,10 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { if (isRetriableIngressStatus(response.status)) { const exempt = - frame.dictionaryCatchup || response.status === QWP_STATUS.NOT_WRITABLE; + frame.dictionaryCatchup || + response.status === QWP_STATUS.NOT_WRITABLE || + qwpSenderErrorCategory(response.status) === + QWP_SENDER_ERROR_CATEGORY.UNKNOWN; if (exempt) { this.resetPoisonEpisode(); throw new RetriableIngressNackError( diff --git a/test/qwp/reconnect.test.ts b/test/qwp/reconnect.test.ts index 697ff84..31a12f1 100644 --- a/test/qwp/reconnect.test.ts +++ b/test/qwp/reconnect.test.ts @@ -2521,6 +2521,49 @@ describe("QWP ingress reconnect and replay", () => { await session.close(); }); + it("keeps retrying repeated unrecognised statuses", async () => { + const first = new FakeConnection("primary"); + const second = new FakeConnection("secondary"); + const third = new FakeConnection("primary"); + const connections = [first, second, third]; + const session = await QwpIngressSession.connect( + async () => { + const connection = connections.shift(); + if (!connection) throw new Error("no connection available"); + return connection; + }, + { + reconnect: { + maxAttempts: 1, + maxFrameRejections: 2, + poisonMinEscalationWindowMs: 0, + initialBackoffMs: 0, + maxBackoffMs: 0, + }, + }, + ); + + const pending = session.sendFrame(Uint8Array.of(9)); + await vi.waitFor(() => expect(first.sent).toHaveLength(1)); + first.receive(ingressResponse(0x7f, 0n)); + await vi.waitFor(() => expect(second.sent).toHaveLength(1)); + second.receive(ingressResponse(0x7f, 0n)); + await vi.waitFor(() => expect(third.sent).toHaveLength(1)); + third.receive(ingressResponse(QWP_STATUS.OK, 0n)); + + await expect(pending).resolves.toMatchObject({ + status: QWP_STATUS.OK, + sequence: 0n, + }); + expect(session.metrics).toMatchObject({ + totalNacks: 2, + totalFramesSent: 3, + totalFramesReplayed: 2, + totalReconnectsSucceeded: 2, + }); + await session.close(); + }); + it("reconnects and replays a transient ingress NACK without advancing", async () => { const first = new FakeConnection("primary"); const second = new FakeConnection("secondary"); From 913f8fb4aee1f631452d7f529f6b5dabea4b848f Mon Sep 17 00:00:00 2001 From: glasstiger Date: Fri, 28 Aug 2026 13:20:14 +0100 Subject: [PATCH 223/265] fix(qwp): validate array dimensions before encoding --- src/_qwp/_core/constants.ts | 2 ++ src/_qwp/_core/ingress.ts | 12 +++++++++++ src/_qwp/sender.ts | 3 ++- test/qwp/sender.test.ts | 42 +++++++++++++++++++++++++++++++++++++ 4 files changed, 58 insertions(+), 1 deletion(-) diff --git a/src/_qwp/_core/constants.ts b/src/_qwp/_core/constants.ts index 6105eaa..083a2f7 100644 --- a/src/_qwp/_core/constants.ts +++ b/src/_qwp/_core/constants.ts @@ -93,6 +93,8 @@ export const QWP_SERVER_ROLE = { export const QWP_MAX_COLUMNS_PER_TABLE = 2048; /** Maximum array rank accepted by QuestDB's QWP ingress decoder. */ export const QWP_MAX_ARRAY_DIMENSIONS = 32; +/** Maximum signed int32 array-axis length accepted by QWP ingress. */ +export const QWP_MAX_ARRAY_DIMENSION_LENGTH = 2_147_483_647; /** Default QWP ingress identifier limits, in UTF-8 wire bytes. */ export const QWP_MAX_COLUMN_NAME_LENGTH = 127; export const QWP_MAX_TABLE_NAME_LENGTH = 127; diff --git a/src/_qwp/_core/ingress.ts b/src/_qwp/_core/ingress.ts index e5a6c80..d2a84cf 100644 --- a/src/_qwp/_core/ingress.ts +++ b/src/_qwp/_core/ingress.ts @@ -8,6 +8,7 @@ import { QWP_FLAG_DURABLE_ACK_POLL, QWP_FLAG_GORILLA, QWP_HEADER_SIZE, + QWP_MAX_ARRAY_DIMENSION_LENGTH, QWP_MAX_ARRAY_DIMENSIONS, QWP_MAX_ERROR_MESSAGE_LENGTH, QWP_MAX_ROWS_PER_TABLE, @@ -276,6 +277,17 @@ function columnPayloadSize( `QWP array must have between 1 and ${QWP_MAX_ARRAY_DIMENSIONS} dimensions`, ); } + for (const [index, dimension] of array.dimensions.entries()) { + if ( + !Number.isSafeInteger(dimension) || + dimension < 0 || + dimension > QWP_MAX_ARRAY_DIMENSION_LENGTH + ) { + throw new RangeError( + `array dimension ${index} must be between 0 and ${QWP_MAX_ARRAY_DIMENSION_LENGTH}`, + ); + } + } size += 1 + array.dimensions.length * 4 + array.values.length * 8; } return size; diff --git a/src/_qwp/sender.ts b/src/_qwp/sender.ts index be2a9bf..34d9bf5 100644 --- a/src/_qwp/sender.ts +++ b/src/_qwp/sender.ts @@ -1,5 +1,6 @@ import { QWP_COLUMN_TYPE, + QWP_MAX_ARRAY_DIMENSION_LENGTH, QWP_MAX_ARRAY_DIMENSIONS, QwpColumnType, QwpIngressEncodeOptions, @@ -699,7 +700,7 @@ function writerArrayValue(value: unknown, elements: "double" | "long") { checkedRange( dimension as number, 0, - Number.MAX_SAFE_INTEGER, + QWP_MAX_ARRAY_DIMENSION_LENGTH, `array dimension ${index}`, ), ); diff --git a/test/qwp/sender.test.ts b/test/qwp/sender.test.ts index 8c68852..80b4436 100644 --- a/test/qwp/sender.test.ts +++ b/test/qwp/sender.test.ts @@ -3,6 +3,7 @@ import { QWP_COLUMN_TYPE, QWP_EGRESS_MESSAGE, QWP_FLAG_DELTA_SYMBOL_DICTIONARY, + QWP_MAX_ARRAY_DIMENSION_LENGTH, QWP_MAX_ARRAY_DIMENSIONS, QWP_STATUS, QwpIngressEncodeOptions, @@ -1870,6 +1871,47 @@ describe("QWP high-level sender", () => { ); }); + it("caps each compiled array dimension at the server's int32 limit", async () => { + const session = new RecordingSession(); + const sender = new QwpSender(async () => session, { autoFlush: false }); + const typed = sender.writer("typed", { samples: doubleArray() }); + + await typed.row({ + samples: { + dimensions: [0, QWP_MAX_ARRAY_DIMENSION_LENGTH], + values: [], + }, + }); + for (const dimension of [ + QWP_MAX_ARRAY_DIMENSION_LENGTH + 1, + 2 ** 32, + ]) { + await expect( + typed.row({ + samples: { dimensions: [0, dimension], values: [] }, + }), + ).rejects.toThrow(/array dimension 1 must be between 0 and 2147483647/); + } + + await sender.flush(); + expect( + (column(session.sends[0].tables[0], "samples").values[0] as { + dimensions: number[]; + }).dimensions, + ).toEqual([0, QWP_MAX_ARRAY_DIMENSION_LENGTH]); + + const raw = new QwpTableBuffer("raw"); + const rawColumn = raw.getOrCreateColumn( + "samples", + QWP_COLUMN_TYPE.DOUBLE_ARRAY, + )!; + rawColumn.values.push({ dimensions: [0, 2 ** 32], values: [] }); + raw.nextRow(); + expect(() => encodeQwpIngressFrame([raw])).toThrow( + /array dimension 1 must be between 0 and 2147483647/, + ); + }); + it("sends an all-nullish writer row for a schema without a designated timestamp", async () => { // README and QWP.md say a QWP row whose every value is nullish is sent with // no columns, and the fluent table().atNow() analogue does exactly that. The From 405daf69b328dbea69020cd98123a48f2203b2c3 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Fri, 28 Aug 2026 13:21:26 +0100 Subject: [PATCH 224/265] fix(qwp): validate timestamp units on null columns --- src/_qwp/sender.ts | 10 +++++++++- test/qwp/sender.test.ts | 3 +++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/_qwp/sender.ts b/src/_qwp/sender.ts index 34d9bf5..ab43cde 100644 --- a/src/_qwp/sender.ts +++ b/src/_qwp/sender.ts @@ -310,6 +310,12 @@ function timestampValue( } } +function validateTimestampUnit(unit: QwpTimestampUnit): void { + if (unit !== "ns" && unit !== "us" && unit !== "ms") { + throw new TypeError(`unsupported timestamp unit '${String(unit)}'`); + } +} + function signedBigEndianToBigInt(bytes: Int8Array): bigint { if (bytes.length === 0) return 0n; let result = 0n; @@ -1316,8 +1322,10 @@ export class QwpSender { value: number | bigint | null | undefined, unit: QwpTimestampUnit = "us", ): QwpSender { - if (this.omitsNullish(name, value)) return this; + const omitted = this.omitsNullish(name, value); try { + validateTimestampUnit(unit); + if (omitted) return this; const timestamp = timestampValue(value, unit); return this.addColumn(name, timestamp.type, timestamp.value); } catch (error) { diff --git a/test/qwp/sender.test.ts b/test/qwp/sender.test.ts index 80b4436..1e927b4 100644 --- a/test/qwp/sender.test.ts +++ b/test/qwp/sender.test.ts @@ -341,6 +341,9 @@ describe("QWP high-level sender", () => { expect(() => table().geohashColumn("g", value, 0)).toThrow( /geohash precision/i, ); + expect(() => + table().timestampColumn("ts", value, "fortnights" as "us"), + ).toThrow(/unsupported timestamp unit 'fortnights'/i); // All four words absent is the LONG256 way of spelling a NULL. expect(() => table().long256Column("bad.name", value, value, value, value), From d347267f83d5c58c73dc44cab65c1f6f0354744c Mon Sep 17 00:00:00 2001 From: glasstiger Date: Fri, 28 Aug 2026 13:27:04 +0100 Subject: [PATCH 225/265] fix(qwp): cancel pool connections during shutdown --- .../reconnecting-egress-connection.ts | 9 +++ src/_qwp/client.ts | 19 +++-- src/_qwp/egress-session.ts | 24 ++++++- src/qwp/browser.ts | 21 +++++- src/qwp/node.ts | 18 ++++- test/qwp/node-client-config.test.ts | 72 +++++++++++++++++++ 6 files changed, 151 insertions(+), 12 deletions(-) diff --git a/src/_qwp/_internal/reconnecting-egress-connection.ts b/src/_qwp/_internal/reconnecting-egress-connection.ts index 0131eac..ec33fc1 100644 --- a/src/_qwp/_internal/reconnecting-egress-connection.ts +++ b/src/_qwp/_internal/reconnecting-egress-connection.ts @@ -120,6 +120,7 @@ export class QwpReconnectingEgressConnection implements QwpBinaryConnection { encodeQueryRequest: QueryRequestEncoder, onReplayReset?: ReplayResetHandler, retryInitialConnection = true, + signal?: AbortSignal, ): Promise { const reconnecting = new QwpReconnectingEgressConnection( factory, @@ -130,12 +131,20 @@ export class QwpReconnectingEgressConnection implements QwpBinaryConnection { onReplayReset, retryInitialConnection, ); + const abortOpening = (): void => { + void reconnecting.close().catch(() => undefined); + }; try { + if (signal?.aborted) throw new QwpSendClosedError(); + signal?.addEventListener("abort", abortOpening, { once: true }); await reconnecting.connectLoop(undefined, false); + if (signal?.aborted) throw new QwpSendClosedError(); return reconnecting; } catch (error) { await reconnecting.close().catch(() => undefined); throw error; + } finally { + signal?.removeEventListener("abort", abortOpening); } } diff --git a/src/_qwp/client.ts b/src/_qwp/client.ts index fa220e3..29be3ba 100644 --- a/src/_qwp/client.ts +++ b/src/_qwp/client.ts @@ -45,8 +45,11 @@ export interface QwpClientPoolOptions { } export interface QwpClientFactories { - createSender(slot: number): Promise; - createQuerySession(slot: number): Promise; + createSender(slot: number, signal?: AbortSignal): Promise; + createQuerySession( + slot: number, + signal?: AbortSignal, + ): Promise; /** @internal Coordinates stable persistent sender slots with recovery. */ senderSlotReservation?: QwpPoolSlotReservation; /** @internal Starts runtime-specific background services on first use. */ @@ -155,6 +158,7 @@ class QwpResourcePool { private readonly creatingSlots = new Set(); private readonly destroyingSlots = new Set(); private readonly creationOperations = new Set>(); + private readonly creationAbortControllers = new Set(); private readonly waiters = new Set(); private readonly closeWaiters = new Set(); private readonly reservedSlots = new Set(); @@ -170,7 +174,10 @@ class QwpResourcePool { private readonly acquireTimeoutMs: number, private readonly idleTimeoutMs: number, private readonly maxLifetimeMs: number, - private readonly createResource: (slot: number) => Promise, + private readonly createResource: ( + slot: number, + signal: AbortSignal, + ) => Promise, private readonly destroyResource: (resource: T) => Promise, private readonly closeLeasedOnShutdown = false, private readonly slotReservation?: QwpPoolSlotReservation, @@ -295,6 +302,7 @@ class QwpResourcePool { waiter.reject(new QwpClientClosedError()); } this.waiters.clear(); + for (const controller of this.creationAbortControllers) controller.abort(); // Idle entries always belong to the closing thread. Borrowed senders remain // owner-managed, while borrowed query sessions are retired with them below. const entries = this.available.splice(0); @@ -372,11 +380,13 @@ class QwpResourcePool { finishCreation = resolve; }); this.creationOperations.add(operation); + const controller = new AbortController(); + this.creationAbortControllers.add(controller); let retained = false; try { let value: T; try { - value = await this.createResource(slot); + value = await this.createResource(slot, controller.signal); } catch (error) { throw new QwpPoolResourceError(this.resource, error); } @@ -396,6 +406,7 @@ class QwpResourcePool { retained = true; return entry; } finally { + this.creationAbortControllers.delete(controller); this.creatingSlots.delete(slot); if (!retained) this.releaseSlotReservation(slot); finishCreation(); diff --git a/src/_qwp/egress-session.ts b/src/_qwp/egress-session.ts index 5e7e766..33e5677 100644 --- a/src/_qwp/egress-session.ts +++ b/src/_qwp/egress-session.ts @@ -27,6 +27,7 @@ import { QwpEgressReplayResetEvent, QwpHandshakeMetadata, QwpReconnectOptions, + QwpSendClosedError, } from "./transport"; export interface QwpEgressSessionOptions { @@ -707,6 +708,8 @@ export class QwpEgressSession implements QwpEgressQueryControl { static async connect( factory: QwpConnectionFactory, options: QwpEgressSessionOptions = {}, + /** Cancels a connection or SERVER_INFO handshake still in progress. */ + signal?: AbortSignal, ): Promise { const validated = validateEgressSessionOptions(options); const state: { session?: QwpEgressSession } = {}; @@ -735,13 +738,28 @@ export class QwpEgressSession implements QwpEgressQueryControl { } : undefined, options.reconnect !== undefined, + signal, ) - : await factory(); - let session: QwpEgressSession; + : await factory(signal); + let session: QwpEgressSession | undefined; + const abortOpening = (): void => { + if (session) { + void session + .close(1000, "QWP client closed while connecting") + .catch(() => undefined); + } else { + void connection + .close(1000, "QWP client closed while connecting") + .catch(() => undefined); + } + }; try { + if (signal?.aborted) throw new QwpSendClosedError(); session = new QwpEgressSession(connection, options); state.session = session; + signal?.addEventListener("abort", abortOpening, { once: true }); await session.ready; + if (signal?.aborted) throw new QwpSendClosedError(); return session; } catch (error) { if (state.session) { @@ -754,6 +772,8 @@ export class QwpEgressSession implements QwpEgressQueryControl { .catch(() => undefined); } throw error; + } finally { + signal?.removeEventListener("abort", abortOpening); } } diff --git a/src/qwp/browser.ts b/src/qwp/browser.ts index 7f6f5d8..f453618 100644 --- a/src/qwp/browser.ts +++ b/src/qwp/browser.ts @@ -821,6 +821,8 @@ export async function connectQwpBrowserSender( export async function connectQwpBrowserEgress( options: QwpBrowserEgressOptions, sessionOptions: QwpEgressSessionOptions = {}, + /** Cancels an opening connection during pooled-client shutdown. */ + signal?: AbortSignal, ): Promise { return QwpEgressSession.connect( createQwpEgressFailoverConnectionFactory( @@ -833,6 +835,7 @@ export async function connectQwpBrowserEgress( QWP_DEFAULT_EGRESS_SERVER_INFO_TIMEOUT_MS, ), sessionOptions, + signal, ); } @@ -936,22 +939,34 @@ export function createQwpBrowserClient( const resolved = resolveQwpBrowserClientOptions(options); return new QwpClient( { - createSender: async () => { + createSender: async (_slot, signal) => { const sender = createQwpBrowserSender( resolved.ingress, resolved.sender, resolved.ingressSession, ); + const abortOpening = (): void => { + void sender.close().catch(() => undefined); + }; try { + if (signal?.aborted) throw new QwpSendClosedError(); + signal?.addEventListener("abort", abortOpening, { once: true }); await sender.connect(); + if (signal?.aborted) throw new QwpSendClosedError(); return sender; } catch (error) { await sender.close().catch(() => undefined); throw error; + } finally { + signal?.removeEventListener("abort", abortOpening); } }, - createQuerySession: () => - connectQwpBrowserEgress(resolved.egress, resolved.egressSession), + createQuerySession: (_slot, signal) => + connectQwpBrowserEgress( + resolved.egress, + resolved.egressSession, + signal, + ), }, resolved.pool, ); diff --git a/src/qwp/node.ts b/src/qwp/node.ts index 4be5a1e..c158234 100644 --- a/src/qwp/node.ts +++ b/src/qwp/node.ts @@ -36,6 +36,7 @@ import { QwpHandshakeMetadata, QwpInitialConnectMode, type QwpReconnectEvent, + QwpSendClosedError, QwpUnrecoverableReplayDictionaryError, QwpUpgradeError, QwpWebSocketConnectOptions, @@ -890,6 +891,8 @@ function validateUdpSenderOptions(options: QwpSenderOptions): void { export async function connectQwpNodeEgress( options: QwpNodeEgressOptions, sessionOptions: QwpEgressSessionOptions = {}, + /** Cancels an opening connection during pooled-client shutdown. */ + signal?: AbortSignal, ): Promise { const transport = egressTransportOptions(options); return QwpEgressSession.connect( @@ -902,6 +905,7 @@ export async function connectQwpNodeEgress( QWP_DEFAULT_EGRESS_SERVER_INFO_TIMEOUT_MS, ), sessionOptions, + signal, ); } @@ -934,23 +938,31 @@ export function createQwpNodeClient( let unsubscribeRecoveryScan: (() => void) | undefined; return new QwpClient( { - createSender: async (slot) => { + createSender: async (slot, signal) => { const ingress = pooledNodeIngressOptions(options.ingress, slot); const sender = createQwpNodeSender( ingress, options.sender, options.ingressSession, ); + const abortOpening = (): void => { + void sender.close().catch(() => undefined); + }; try { + if (signal?.aborted) throw new QwpSendClosedError(); + signal?.addEventListener("abort", abortOpening, { once: true }); await sender.connect(); + if (signal?.aborted) throw new QwpSendClosedError(); return sender; } catch (error) { await sender.close().catch(() => undefined); throw error; + } finally { + signal?.removeEventListener("abort", abortOpening); } }, - createQuerySession: () => - connectQwpNodeEgress(options.egress, options.egressSession), + createQuerySession: (_slot, signal) => + connectQwpNodeEgress(options.egress, options.egressSession, signal), senderSlotReservation: slotCoordinator, start: () => { if (orphanDrainer && slotCoordinator) { diff --git a/test/qwp/node-client-config.test.ts b/test/qwp/node-client-config.test.ts index 2b0db07..23e981d 100644 --- a/test/qwp/node-client-config.test.ts +++ b/test/qwp/node-client-config.test.ts @@ -42,6 +42,36 @@ class RejectingWebSocket { } } +class PendingWebSocket { + binaryType = ""; + readyState = 0; + closeCount = 0; + private readonly listeners = new Map void>>(); + + send(): void {} + + close(code = 1000, reason = ""): void { + if (this.readyState === 3) return; + this.closeCount++; + this.readyState = 3; + this.emit("close", { code, reason, wasClean: code === 1000 }); + } + + addEventListener(type: string, listener: (event: unknown) => void): void { + let listeners = this.listeners.get(type); + if (!listeners) this.listeners.set(type, (listeners = new Set())); + listeners.add(listener); + } + + removeEventListener(type: string, listener: (event: unknown) => void): void { + this.listeners.get(type)?.delete(listener); + } + + private emit(type: string, event: unknown): void { + for (const listener of this.listeners.get(type) ?? []) listener(event); + } +} + describe("QWP unified Node client configuration", () => { it("uses one ordered cluster and authentication configuration for both sides", () => { const options = parseQwpNodeClientConfig( @@ -174,6 +204,48 @@ describe("QWP unified Node client configuration", () => { } }); + it("cancels an in-flight query prewarm connection during close", async () => { + let resolveSocket!: (socket: PendingWebSocket) => void; + const socketCreated = new Promise((resolve) => { + resolveSocket = resolve; + }); + const client = createQwpNodeClient({ + ingress: { url: "ws://localhost:9000/write/v4" }, + egress: { + url: "ws://localhost:9000/read/v1", + authTimeoutMs: 30_000, + webSocketFactory: (_url, { onConnected }) => { + const socket = new PendingWebSocket(); + resolveSocket(socket); + onConnected(); + return socket as unknown as QwpWebSocketLike; + }, + }, + pool: { + senderPoolMin: 0, + senderPoolMax: 1, + queryPoolMin: 1, + queryPoolMax: 1, + acquireTimeoutMs: 1_000, + }, + }); + + const connecting = client.connect(); + const socket = await socketCreated; + expect(client.metrics.queries.creating).toBe(1); + + await client.close(); + + expect(socket.closeCount).toBe(1); + expect(socket.readyState).toBe(3); + expect(client.metrics).toMatchObject({ + closing: true, + closed: true, + queries: { total: 0, creating: 0 }, + }); + await expect(connecting).rejects.toThrow(); + }); + it("starts lazy persistent ingress without prewarming egress", async () => { const directory = await mkdtemp(join(tmpdir(), "qwp-unified-client-")); const attemptedPaths: string[] = []; From be03ff0cc948fe3d1fa6aed6f8bf82dfc0753c83 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Fri, 28 Aug 2026 13:28:54 +0100 Subject: [PATCH 226/265] fix(qwp): accept negative close flush timeouts --- src/_qwp/sender.ts | 7 +++++-- src/qwp-node/client-config.ts | 4 ++-- test/options.test.ts | 1 + test/qwp/node-client-config.test.ts | 5 +++++ test/qwp/sender.test.ts | 4 ++-- 5 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/_qwp/sender.ts b/src/_qwp/sender.ts index ab43cde..3bbc569 100644 --- a/src/_qwp/sender.ts +++ b/src/_qwp/sender.ts @@ -75,7 +75,8 @@ export interface QwpSenderOptions { durableAckTimeoutMs?: number; /** * Maximum time close() spends publishing queued rows and waiting for the - * server ACK watermark. Zero skips the drain. Defaults to 60 seconds. + * server ACK watermark. Zero or a negative value skips the drain. Defaults + * to 5 seconds. */ closeFlushTimeoutMs?: number; /** QWP frame encoding options supported by the high-level sender. */ @@ -1027,7 +1028,9 @@ export class QwpSender { validateNonNegativeInteger(this.autoFlushRows, "autoFlushRows"); validateNonNegativeInteger(this.autoFlushBytes, "autoFlushBytes"); validateNonNegativeInteger(this.autoFlushIntervalMs, "autoFlushIntervalMs"); - validateNonNegativeInteger(this.closeFlushTimeoutMs, "closeFlushTimeoutMs"); + if (!Number.isSafeInteger(this.closeFlushTimeoutMs)) { + throw new RangeError("closeFlushTimeoutMs must be a safe integer"); + } if (!Number.isSafeInteger(this.maxNameLength) || this.maxNameLength < 16) { throw new RangeError( "maxNameLength must be a safe integer of at least 16", diff --git a/src/qwp-node/client-config.ts b/src/qwp-node/client-config.ts index d9ccaaa..b354df9 100644 --- a/src/qwp-node/client-config.ts +++ b/src/qwp-node/client-config.ts @@ -202,7 +202,7 @@ export function resolveQwpNodeClientConfig( optionalInteger( value("close_flush_timeout_millis"), "close_flush_timeout_millis", - 0, + Number.MIN_SAFE_INTEGER, ) ?? DEFAULT_CLOSE_FLUSH_TIMEOUT_MS, maxNameLength: optionalInteger(value("max_name_len"), "max_name_len", 16) ?? 127, @@ -883,7 +883,7 @@ function optionalInteger( maximum = Number.MAX_SAFE_INTEGER, ): number | undefined { if (value === undefined) return undefined; - if (!/^\d+$/.test(value)) throw new Error(`Invalid ${key}: '${value}'`); + if (!/^-?\d+$/.test(value)) throw new Error(`Invalid ${key}: '${value}'`); const parsed = Number(value); if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) { throw new RangeError( diff --git a/test/options.test.ts b/test/options.test.ts index 36461c4..8637a55 100644 --- a/test/options.test.ts +++ b/test/options.test.ts @@ -872,6 +872,7 @@ describe("Configuration string parser suite", function () { const cases = [ ["sf_dir=/tmp/qwp-parity", true], ["transaction=on", true], + ["close_flush_timeout_millis=-1", true], ["tls_ca=/tmp/nope.pem", false], ["init_buf_size=1024", false], ["max_buf_size=99999", false], diff --git a/test/qwp/node-client-config.test.ts b/test/qwp/node-client-config.test.ts index 23e981d..2614d7e 100644 --- a/test/qwp/node-client-config.test.ts +++ b/test/qwp/node-client-config.test.ts @@ -158,6 +158,11 @@ describe("QWP unified Node client configuration", () => { closeFlushTimeoutMs: 5_000, maxNameLength: 127, }); + expect( + parseQwpNodeClientConfig( + "ws::addr=localhost;close_flush_timeout_millis=-1;", + ).sender?.closeFlushTimeoutMs, + ).toBe(-1); const tuned = parseQwpNodeClientConfig( "ws::addr=localhost;sf_dir=/tmp/qwp-unified-test;reconnect_max_duration_millis=1234;", diff --git a/test/qwp/sender.test.ts b/test/qwp/sender.test.ts index 1e927b4..6a087d1 100644 --- a/test/qwp/sender.test.ts +++ b/test/qwp/sender.test.ts @@ -462,13 +462,13 @@ describe("QWP high-level sender", () => { new QwpSender(async () => session, { closeFlushTimeoutMs: -1, }), - ).toThrow(/closeFlushTimeoutMs must be a non-negative safe integer/); + ).not.toThrow(); expect( () => new QwpSender(async () => session, { closeFlushTimeoutMs: 1.5, }), - ).toThrow(/closeFlushTimeoutMs must be a non-negative safe integer/); + ).toThrow(/closeFlushTimeoutMs must be a safe integer/); }); it("applies a configurable UTF-8 identifier byte length", async () => { From 5a670165b923880ae2ed005250bfe3a06ade15ad Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 2 Sep 2026 01:10:47 +0100 Subject: [PATCH 227/265] refactor: split Node.js and browser clients --- .github/workflows/build.yml | 18 +- .github/workflows/publish.yml | 30 +- CLAUDE.md | 34 +- CONTRIBUTING.md | 25 +- QWP.md | 58 +- README.md | 27 +- benchmarks/e2e.ts | 2 +- benchmarks/egress.bench.ts | 2 +- benchmarks/encoder.bench.ts | 2 +- benchmarks/persistence.bench.ts | 2 +- benchmarks/sender.bench.ts | 4 +- benchmarks/tables.ts | 2 +- benchmarks/validate.test.ts | 2 +- docs/assets/hierarchy.js | 2 +- docs/assets/highlight.css | 8 +- docs/assets/navigation.js | 2 +- docs/assets/search.js | 2 +- docs/classes/HttpTransport.html | 35 - docs/classes/Sender.html | 231 --- docs/classes/SenderBufferV1.html | 150 -- docs/classes/SenderBufferV2.html | 158 -- docs/classes/SenderOptions.html | 167 -- docs/classes/TcpTransport.html | 21 - docs/classes/UndiciTransport.html | 34 - ..._browser-client.QwpBatchTooLargeError.html | 7 + ..._questdb_browser-client.QwpBindValues.html | 34 + ...lient.QwpBrowserSessionBootstrapError.html | 23 + ..._questdb_browser-client.QwpByteReader.html | 19 + ..._questdb_browser-client.QwpByteWriter.html | 21 + .../_questdb_browser-client.QwpClient.html | 15 + ...b_browser-client.QwpClientClosedError.html | 6 + ...-client.QwpDurableAckUnavailableError.html | 22 + ...questdb_browser-client.QwpEgressQuery.html | 42 + ...r-client.QwpEgressQueryAbandonedError.html | 7 + ...ient.QwpEgressQueryCancelTimeoutError.html | 8 + ...db_browser-client.QwpEgressQueryError.html | 7 + ...ser-client.QwpEgressQueryTimeoutError.html | 8 + ...r-client.QwpEgressReplayRequiredError.html | 9 + ...estdb_browser-client.QwpEgressSession.html | 39 + ...er-client.QwpEgressSessionClosedError.html | 6 + ...estdb_browser-client.QwpFailoverError.html | 8 + ...wser-client.QwpIngressAckTimeoutError.html | 9 + ...db_browser-client.QwpIngressNackError.html | 7 + ...stdb_browser-client.QwpIngressSession.html | 64 + ...r-client.QwpIngressSessionClosedError.html | 6 + ...ent.QwpMemoryReplayAppendTimeoutError.html | 10 + ...ent.QwpMemoryReplayFrameTooLargeError.html | 9 + ...ser-client.QwpPoolAcquireTimeoutError.html | 8 + ...b_browser-client.QwpPoolResourceError.html | 8 + ...estdb_browser-client.QwpProtocolError.html | 6 + ..._questdb_browser-client.QwpQueryLease.html | 14 + ...ser-client.QwpReconnectExhaustedError.html | 8 + ...owser-client.QwpReplayDictionaryError.html | 7 + ...t.QwpReplayDictionaryPersistenceError.html | 9 + ...browser-client.QwpReplayRejectedError.html | 8 + ...questdb_browser-client.QwpResultBatch.html | 9 + ..._browser-client.QwpResultBatchDecoder.html | 13 + ...tdb_browser-client.QwpResultBatchView.html | 25 + ...db_browser-client.QwpResultColumnView.html | 56 + ...estdb_browser-client.QwpResultRowView.html | 38 + ...b_browser-client.QwpRoleMismatchError.html | 23 + ...tdb_browser-client.QwpSendClosedError.html | 8 + .../_questdb_browser-client.QwpSendError.html | 7 + ...db_browser-client.QwpSendTimeoutError.html | 9 + .../_questdb_browser-client.QwpSender.html | 78 + ...ser-client.QwpSenderCloseTimeoutError.html | 9 + ...db_browser-client.QwpSymbolDictionary.html | 12 + ...questdb_browser-client.QwpTableBuffer.html | 20 + ...questdb_browser-client.QwpTableWriter.html | 9 + ...QwpUnrecoverableReplayDictionaryError.html | 8 + ...uestdb_browser-client.QwpUpgradeError.html | 22 + ...stdb_browser-client.QwpWriterRowError.html | 10 + .../_questdb_nodejs-client.HttpTransport.html | 35 + ...b_nodejs-client.QwpBatchTooLargeError.html | 35 + .../_questdb_nodejs-client.QwpBindValues.html | 34 + .../_questdb_nodejs-client.QwpByteReader.html | 19 + .../_questdb_nodejs-client.QwpByteWriter.html | 21 + .../_questdb_nodejs-client.QwpClient.html | 15 + ...db_nodejs-client.QwpClientClosedError.html | 34 + ...-client.QwpDurableAckUnavailableError.html | 49 + ..._questdb_nodejs-client.QwpEgressQuery.html | 42 + ...s-client.QwpEgressQueryAbandonedError.html | 35 + ...ient.QwpEgressQueryCancelTimeoutError.html | 36 + ...tdb_nodejs-client.QwpEgressQueryError.html | 35 + ...ejs-client.QwpEgressQueryTimeoutError.html | 36 + ...s-client.QwpEgressReplayRequiredError.html | 37 + ...uestdb_nodejs-client.QwpEgressSession.html | 39 + ...js-client.QwpEgressSessionClosedError.html | 34 + ...uestdb_nodejs-client.QwpFailoverError.html | 35 + ...dejs-client.QwpIngressAckTimeoutError.html | 37 + ...tdb_nodejs-client.QwpIngressNackError.html | 35 + ...estdb_nodejs-client.QwpIngressSession.html | 64 + ...s-client.QwpIngressSessionClosedError.html | 34 + ...ent.QwpMemoryReplayAppendTimeoutError.html | 38 + ...ent.QwpMemoryReplayFrameTooLargeError.html | 37 + ..._nodejs-client.QwpNodeFileReplayStore.html | 26 + ...db_nodejs-client.QwpNodeOrphanDrainer.html | 10 + ...estdb_nodejs-client.QwpNodeUdpSession.html | 15 + ...ejs-client.QwpPoolAcquireTimeoutError.html | 36 + ...db_nodejs-client.QwpPoolResourceError.html | 35 + ...uestdb_nodejs-client.QwpProtocolError.html | 34 + .../_questdb_nodejs-client.QwpQueryLease.html | 14 + ...ejs-client.QwpReconnectExhaustedError.html | 35 + ...odejs-client.QwpReplayDictionaryError.html | 34 + ...t.QwpReplayDictionaryPersistenceError.html | 36 + ..._nodejs-client.QwpReplayRejectedError.html | 36 + ...ient.QwpReplayStoreAppendTimeoutError.html | 44 + ...-client.QwpReplayStoreCheckpointError.html | 42 + ...-client.QwpReplayStoreCorruptionError.html | 36 + ...tdb_nodejs-client.QwpReplayStoreError.html | 41 + ...nodejs-client.QwpReplayStoreFullError.html | 43 + ...js-client.QwpReplayStoreLockLostError.html | 46 + ...dejs-client.QwpReplayStoreLockedError.html | 43 + ...client.QwpReplayStoreQuarantinedError.html | 44 + ...nt.QwpReplayStoreSegmentTooLargeError.html | 43 + ..._questdb_nodejs-client.QwpResultBatch.html | 9 + ...b_nodejs-client.QwpResultBatchDecoder.html | 13 + ...stdb_nodejs-client.QwpResultBatchView.html | 25 + ...tdb_nodejs-client.QwpResultColumnView.html | 56 + ...uestdb_nodejs-client.QwpResultRowView.html | 38 + ...db_nodejs-client.QwpRoleMismatchError.html | 50 + ...stdb_nodejs-client.QwpSendClosedError.html | 35 + .../_questdb_nodejs-client.QwpSendError.html | 34 + ...tdb_nodejs-client.QwpSendTimeoutError.html | 36 + .../_questdb_nodejs-client.QwpSender.html | 78 + ...ejs-client.QwpSenderCloseTimeoutError.html | 37 + ...tdb_nodejs-client.QwpSymbolDictionary.html | 12 + ..._questdb_nodejs-client.QwpTableBuffer.html | 20 + ..._questdb_nodejs-client.QwpTableWriter.html | 9 + ...js-client.QwpUdpDatagramTooLargeError.html | 38 + ...QwpUnrecoverableReplayDictionaryError.html | 35 + ...questdb_nodejs-client.QwpUpgradeError.html | 49 + ...nodejs-client.QwpVersionMismatchError.html | 51 + ...estdb_nodejs-client.QwpWriterRowError.html | 37 + .../_questdb_nodejs-client.Sender.html | 276 +++ ..._questdb_nodejs-client.SenderBufferV1.html | 148 ++ ..._questdb_nodejs-client.SenderBufferV2.html | 152 ++ ..._questdb_nodejs-client.SenderBufferV3.html | 162 ++ .../_questdb_nodejs-client.SenderOptions.html | 190 ++ .../_questdb_nodejs-client.TcpTransport.html | 21 + ...questdb_nodejs-client.UndiciTransport.html | 34 + ...ent.addQwpDurableAckWebSocketProtocol.html | 2 + .../_questdb_browser-client.binary.html | 2 + .../_questdb_browser-client.bool.html | 2 + ...ser-client.bootstrapQwpBrowserSession.html | 5 + .../_questdb_browser-client.byte.html | 2 + .../_questdb_browser-client.char.html | 2 + .../_questdb_browser-client.concatBytes.html | 1 + ...rowser-client.connectQwpBrowserClient.html | 2 + ...rowser-client.connectQwpBrowserEgress.html | 3 + ...owser-client.connectQwpBrowserIngress.html | 3 + ...rowser-client.connectQwpBrowserSender.html | 2 + ...ser-client.connectQwpBrowserWebSocket.html | 7 + ...browser-client.createQwpBrowserClient.html | 2 + ...ent.createQwpBrowserConnectionFactory.html | 2 + ...browser-client.createQwpBrowserSender.html | 3 + ...r-client.createQwpDataLossSenderError.html | 2 + ...createQwpProtocolViolationSenderError.html | 1 + ...b_browser-client.createQwpSenderError.html | 1 + .../_questdb_browser-client.date.html | 4 + .../_questdb_browser-client.decimal128.html | 2 + .../_questdb_browser-client.decimal256.html | 2 + .../_questdb_browser-client.decimal64.html | 2 + ...owser-client.decodeQwpContentEncoding.html | 4 + ...browser-client.decodeQwpEgressMessage.html | 2 + ...questdb_browser-client.decodeQwpFrame.html | 1 + ...owser-client.decodeQwpIngressResponse.html | 2 + ...ser-client.decodeQwpIngressServerInfo.html | 2 + ...decodeQwpIngressSymbolDictionaryDelta.html | 2 + ...uestdb_browser-client.decodeQwpVarint.html | 1 + .../_questdb_browser-client.decodeUtf8.html | 1 + ...browser-client.decompressQwpZstdFrame.html | 2 + ...r-client.defaultQwpSenderErrorHandler.html | 3 + ...db_browser-client.designatedTimestamp.html | 2 + .../_questdb_browser-client.double.html | 2 + .../_questdb_browser-client.doubleArray.html | 2 + ...rowser-client.encodeQwpAcceptEncoding.html | 2 + ...questdb_browser-client.encodeQwpBinds.html | 2 + ...uestdb_browser-client.encodeQwpCancel.html | 2 + ...uestdb_browser-client.encodeQwpCredit.html | 2 + ...r-client.encodeQwpDurableAckPollFrame.html | 2 + ...questdb_browser-client.encodeQwpFrame.html | 1 + ...estdb_browser-client.encodeQwpGorilla.html | 2 + ...er-client.encodeQwpIngressCommitFrame.html | 1 + ..._browser-client.encodeQwpIngressFrame.html | 2 + ...encodeQwpIngressSymbolDictionaryFrame.html | 2 + ..._browser-client.encodeQwpQueryRequest.html | 2 + ...uestdb_browser-client.encodeQwpVarint.html | 1 + .../_questdb_browser-client.encodeUtf8.html | 1 + ...uestdb_browser-client.flattenQwpArray.html | 1 + .../_questdb_browser-client.float32.html | 2 + .../_questdb_browser-client.float64.html | 2 + .../_questdb_browser-client.geohash.html | 4 + .../_questdb_browser-client.int32.html | 4 + .../_questdb_browser-client.int64.html | 4 + .../_questdb_browser-client.ipv4.html | 2 + ...ient.isQwpDurableAckWebSocketProtocol.html | 2 + .../_questdb_browser-client.long.html | 2 + .../_questdb_browser-client.long256.html | 2 + .../_questdb_browser-client.longArray.html | 4 + ...er-client.qwpDefaultSenderErrorPolicy.html | 1 + ...questdb_browser-client.qwpGorillaSize.html | 2 + ...browser-client.qwpSenderErrorCategory.html | 1 + ..._questdb_browser-client.qwpVarintSize.html | 2 + ..._questdb_browser-client.readQwpVarint.html | 2 + ...db_browser-client.readQwpVarintNumber.html | 1 + .../_questdb_browser-client.short.html | 2 + .../_questdb_browser-client.symbol.html | 2 + .../_questdb_browser-client.timestamp.html | 2 + .../_questdb_browser-client.utf8Length.html | 1 + .../_questdb_browser-client.uuid.html | 2 + .../_questdb_browser-client.varchar.html | 2 + ...db_browser-client.writeQwpFrameHeader.html | 1 + ...questdb_browser-client.writeQwpVarint.html | 2 + ...ent.addQwpDurableAckWebSocketProtocol.html | 2 + ...js-client.bigintToTwosComplementBytes.html | 5 + .../_questdb_nodejs-client.binary.html | 2 + .../_questdb_nodejs-client.bool.html | 2 + .../_questdb_nodejs-client.byte.html | 2 + .../_questdb_nodejs-client.char.html | 2 + .../_questdb_nodejs-client.concatBytes.html | 1 + ...db_nodejs-client.connectQwpNodeClient.html | 3 + ...db_nodejs-client.connectQwpNodeEgress.html | 3 + ...b_nodejs-client.connectQwpNodeIngress.html | 3 + ...db_nodejs-client.connectQwpNodeSender.html | 2 + ...estdb_nodejs-client.connectQwpNodeUdp.html | 2 + ...nodejs-client.connectQwpNodeUdpSender.html | 2 + ...nodejs-client.connectQwpNodeWebSocket.html | 2 + .../_questdb_nodejs-client.createBuffer.html | 6 + ...s-client.createQwpDataLossSenderError.html | 2 + ...tdb_nodejs-client.createQwpNodeClient.html | 3 + ...client.createQwpNodeConnectionFactory.html | 2 + ...tdb_nodejs-client.createQwpNodeSender.html | 3 + ..._nodejs-client.createQwpNodeUdpSender.html | 4 + ...createQwpProtocolViolationSenderError.html | 1 + ...db_nodejs-client.createQwpSenderError.html | 1 + ...questdb_nodejs-client.createTransport.html | 5 + .../_questdb_nodejs-client.date.html | 4 + .../_questdb_nodejs-client.decimal128.html | 2 + .../_questdb_nodejs-client.decimal256.html | 2 + .../_questdb_nodejs-client.decimal64.html | 2 + ...odejs-client.decodeQwpContentEncoding.html | 4 + ..._nodejs-client.decodeQwpEgressMessage.html | 2 + ..._questdb_nodejs-client.decodeQwpFrame.html | 1 + ...odejs-client.decodeQwpIngressResponse.html | 2 + ...ejs-client.decodeQwpIngressServerInfo.html | 2 + ...decodeQwpIngressSymbolDictionaryDelta.html | 2 + ...questdb_nodejs-client.decodeQwpVarint.html | 1 + .../_questdb_nodejs-client.decodeUtf8.html | 1 + ..._nodejs-client.decompressQwpZstdFrame.html | 2 + ...s-client.defaultQwpSenderErrorHandler.html | 3 + ...tdb_nodejs-client.designatedTimestamp.html | 2 + .../_questdb_nodejs-client.double.html | 2 + .../_questdb_nodejs-client.doubleArray.html | 2 + ...nodejs-client.encodeQwpAcceptEncoding.html | 2 + ..._questdb_nodejs-client.encodeQwpBinds.html | 2 + ...questdb_nodejs-client.encodeQwpCancel.html | 2 + ...questdb_nodejs-client.encodeQwpCredit.html | 2 + ...s-client.encodeQwpDurableAckPollFrame.html | 2 + ..._questdb_nodejs-client.encodeQwpFrame.html | 1 + ...uestdb_nodejs-client.encodeQwpGorilla.html | 2 + ...js-client.encodeQwpIngressCommitFrame.html | 1 + ...b_nodejs-client.encodeQwpIngressFrame.html | 2 + ...encodeQwpIngressSymbolDictionaryFrame.html | 2 + ...b_nodejs-client.encodeQwpQueryRequest.html | 2 + ...questdb_nodejs-client.encodeQwpVarint.html | 1 + .../_questdb_nodejs-client.encodeUtf8.html | 1 + ...questdb_nodejs-client.flattenQwpArray.html | 1 + .../_questdb_nodejs-client.float32.html | 2 + .../_questdb_nodejs-client.float64.html | 2 + .../_questdb_nodejs-client.geohash.html | 4 + .../_questdb_nodejs-client.int32.html | 4 + .../_questdb_nodejs-client.int64.html | 4 + .../_questdb_nodejs-client.ipv4.html | 2 + ...ient.isQwpDurableAckWebSocketProtocol.html | 2 + .../_questdb_nodejs-client.long.html | 2 + .../_questdb_nodejs-client.long256.html | 2 + .../_questdb_nodejs-client.longArray.html | 4 + ...odejs-client.parseQwpNodeClientConfig.html | 2 + ...js-client.qwpDefaultSenderErrorPolicy.html | 1 + ..._questdb_nodejs-client.qwpGorillaSize.html | 2 + ..._nodejs-client.qwpSenderErrorCategory.html | 1 + .../_questdb_nodejs-client.qwpVarintSize.html | 2 + .../_questdb_nodejs-client.readQwpVarint.html | 2 + ...tdb_nodejs-client.readQwpVarintNumber.html | 1 + ..._nodejs-client.retryQwpNodeOrphanSlot.html | 2 + ..._nodejs-client.scanQwpNodeOrphanSlots.html | 5 + .../_questdb_nodejs-client.short.html | 2 + .../_questdb_nodejs-client.symbol.html | 2 + .../_questdb_nodejs-client.timestamp.html | 2 + .../_questdb_nodejs-client.utf8Length.html | 1 + .../_questdb_nodejs-client.uuid.html | 2 + .../_questdb_nodejs-client.varchar.html | 2 + ...tdb_nodejs-client.writeQwpFrameHeader.html | 1 + ..._questdb_nodejs-client.writeQwpVarint.html | 2 + .../bigintToTwosComplementBytes.html | 5 - docs/functions/createBuffer.html | 6 - docs/functions/createTransport.html | 5 - docs/hierarchy.html | 2 +- docs/index.html | 253 ++- docs/interfaces/SenderBuffer.html | 151 -- docs/interfaces/SenderTransport.html | 18 - ..._questdb_browser-client.QwpArrayValue.html | 3 + ...db_browser-client.QwpBinaryConnection.html | 29 + ...owser-client.QwpBrowserClusterOptions.html | 18 + ...rowser-client.QwpBrowserEgressOptions.html | 35 + ...ent.QwpBrowserSessionBootstrapOptions.html | 10 + ...ient.QwpBrowserSessionBootstrapResult.html | 4 + ...r-client.QwpBrowserSplitClientOptions.html | 9 + ...client.QwpBrowserUnifiedClientOptions.html | 10 + ...ser-client.QwpBrowserWebSocketOptions.html | 23 + ...b_browser-client.QwpCacheResetMessage.html | 7 + ...tdb_browser-client.QwpClientFactories.html | 9 + ...estdb_browser-client.QwpClientMetrics.html | 5 + ...b_browser-client.QwpClientPoolOptions.html | 18 + ...uestdb_browser-client.QwpColumnBuffer.html | 11 + ...browser-client.QwpConnectionCloseInfo.html | 4 + ...uestdb_browser-client.QwpDecimalValue.html | 3 + ..._browser-client.QwpEgressQueryOptions.html | 17 + ...wser-client.QwpEgressReplayResetEvent.html | 8 + ...rowser-client.QwpEgressRoutingOptions.html | 8 + ...rowser-client.QwpEgressSessionOptions.html | 19 + ...tdb_browser-client.QwpEgressViewQuery.html | 9 + ...uestdb_browser-client.QwpEncodedBinds.html | 3 + ...tdb_browser-client.QwpExecDoneMessage.html | 9 + ...tdb_browser-client.QwpFailoverAttempt.html | 3 + .../_questdb_browser-client.QwpFrame.html | 6 + ...questdb_browser-client.QwpFrameHeader.html | 5 + ...uestdb_browser-client.QwpGeohashValue.html | 3 + ...b_browser-client.QwpHandshakeMetadata.html | 16 + ...rowser-client.QwpIngressEncodeOptions.html | 7 + ...b_browser-client.QwpIngressErrorEvent.html | 8 + ...stdb_browser-client.QwpIngressMetrics.html | 42 + ...rowser-client.QwpIngressProgressEvent.html | 6 + ...browser-client.QwpIngressReplayRecord.html | 3 + ...wser-client.QwpIngressReplayReference.html | 4 + ..._browser-client.QwpIngressReplayStore.html | 18 + ...tdb_browser-client.QwpIngressResponse.html | 5 + ...b_browser-client.QwpIngressSendResult.html | 11 + ...owser-client.QwpIngressSessionOptions.html | 56 + ...lient.QwpIngressSymbolDictionaryDelta.html | 3 + ..._browser-client.QwpIngressTableResult.html | 3 + ...ser-client.QwpIngressTransportMetrics.html | 29 + ...uestdb_browser-client.QwpLong256Value.html | 3 + ...browser-client.QwpPoolSlotReservation.html | 5 + ...b_browser-client.QwpQueryErrorMessage.html | 9 + ...uestdb_browser-client.QwpQueryRequest.html | 13 + ...stdb_browser-client.QwpReconnectEvent.html | 10 + ...db_browser-client.QwpReconnectOptions.html | 17 + ...browser-client.QwpResourcePoolMetrics.html | 8 + ...db_browser-client.QwpResultArrayValue.html | 3 + ..._browser-client.QwpResultBatchMessage.html | 11 + ...uestdb_browser-client.QwpResultColumn.html | 6 + ..._browser-client.QwpResultColumnSchema.html | 3 + ...db_browser-client.QwpResultEndMessage.html | 9 + ...browser-client.QwpSenderEncodeOptions.html | 4 + ...questdb_browser-client.QwpSenderError.html | 14 + ...-client.QwpSenderErrorResponseContext.html | 7 + ...estdb_browser-client.QwpSenderMetrics.html | 18 + ...estdb_browser-client.QwpSenderOptions.html | 31 + ...estdb_browser-client.QwpSenderSession.html | 15 + ...b_browser-client.QwpServerInfoMessage.html | 16 + ...questdb_browser-client.QwpSymbolValue.html | 3 + ...browser-client.QwpUpgradeErrorDetails.html | 14 + .../_questdb_browser-client.QwpUuidValue.html | 3 + ...ser-client.QwpWebSocketConnectOptions.html | 12 + ...estdb_browser-client.QwpWebSocketLike.html | 18 + ...uestdb_browser-client.QwpWriterColumn.html | 19 + .../_questdb_nodejs-client.QwpArrayValue.html | 3 + ...tdb_nodejs-client.QwpBinaryConnection.html | 29 + ...db_nodejs-client.QwpCacheResetMessage.html | 7 + ...stdb_nodejs-client.QwpClientFactories.html | 9 + ...uestdb_nodejs-client.QwpClientMetrics.html | 5 + ...db_nodejs-client.QwpClientPoolOptions.html | 18 + ...questdb_nodejs-client.QwpColumnBuffer.html | 11 + ..._nodejs-client.QwpConnectionCloseInfo.html | 4 + ...questdb_nodejs-client.QwpDecimalValue.html | 3 + ...b_nodejs-client.QwpEgressQueryOptions.html | 17 + ...dejs-client.QwpEgressReplayResetEvent.html | 8 + ...nodejs-client.QwpEgressRoutingOptions.html | 8 + ...nodejs-client.QwpEgressSessionOptions.html | 19 + ...stdb_nodejs-client.QwpEgressViewQuery.html | 9 + ...questdb_nodejs-client.QwpEncodedBinds.html | 3 + ...stdb_nodejs-client.QwpExecDoneMessage.html | 9 + ...stdb_nodejs-client.QwpFailoverAttempt.html | 3 + .../_questdb_nodejs-client.QwpFrame.html | 6 + ..._questdb_nodejs-client.QwpFrameHeader.html | 5 + ...questdb_nodejs-client.QwpGeohashValue.html | 3 + ...db_nodejs-client.QwpHandshakeMetadata.html | 16 + ...nodejs-client.QwpIngressEncodeOptions.html | 7 + ...db_nodejs-client.QwpIngressErrorEvent.html | 8 + ...estdb_nodejs-client.QwpIngressMetrics.html | 42 + ...nodejs-client.QwpIngressProgressEvent.html | 6 + ..._nodejs-client.QwpIngressReplayRecord.html | 3 + ...dejs-client.QwpIngressReplayReference.html | 4 + ...b_nodejs-client.QwpIngressReplayStore.html | 18 + ...stdb_nodejs-client.QwpIngressResponse.html | 5 + ...db_nodejs-client.QwpIngressSendResult.html | 11 + ...odejs-client.QwpIngressSessionOptions.html | 56 + ...lient.QwpIngressSymbolDictionaryDelta.html | 3 + ...b_nodejs-client.QwpIngressTableResult.html | 3 + ...ejs-client.QwpIngressTransportMetrics.html | 29 + ...questdb_nodejs-client.QwpLong256Value.html | 3 + ...ejs-client.QwpNodeClientConfigOptions.html | 13 + ...db_nodejs-client.QwpNodeClientOptions.html | 13 + ...db_nodejs-client.QwpNodeEgressOptions.html | 39 + ...-client.QwpNodeFileReplayStoreMetrics.html | 14 + ...-client.QwpNodeFileReplayStoreOptions.html | 27 + ...b_nodejs-client.QwpNodeIngressOptions.html | 37 + ...nodejs-client.QwpNodeOrphanDrainEvent.html | 12 + ...dejs-client.QwpNodeOrphanDrainSession.html | 7 + ...js-client.QwpNodeOrphanDrainerMetrics.html | 17 + ...js-client.QwpNodeOrphanDrainerOptions.html | 25 + ...js-client.QwpNodeReplayDataLossReport.html | 10 + ...ejs-client.QwpNodeReplayRecoveryEvent.html | 7 + ...-client.QwpNodeStoreAndForwardOptions.html | 50 + ...estdb_nodejs-client.QwpNodeUdpMetrics.html | 6 + ...estdb_nodejs-client.QwpNodeUdpOptions.html | 15 + ...db_nodejs-client.QwpNodeUdpSocketLike.html | 8 + ...nodejs-client.QwpNodeUpgradeRejection.html | 4 + ...nodejs-client.QwpNodeWebSocketOptions.html | 24 + ..._nodejs-client.QwpPoolSlotReservation.html | 5 + ...db_nodejs-client.QwpQueryErrorMessage.html | 9 + ...questdb_nodejs-client.QwpQueryRequest.html | 13 + ...estdb_nodejs-client.QwpReconnectEvent.html | 10 + ...tdb_nodejs-client.QwpReconnectOptions.html | 17 + ..._nodejs-client.QwpResourcePoolMetrics.html | 8 + ...tdb_nodejs-client.QwpResultArrayValue.html | 3 + ...b_nodejs-client.QwpResultBatchMessage.html | 11 + ...questdb_nodejs-client.QwpResultColumn.html | 6 + ...b_nodejs-client.QwpResultColumnSchema.html | 3 + ...tdb_nodejs-client.QwpResultEndMessage.html | 9 + ..._nodejs-client.QwpSenderEncodeOptions.html | 4 + ..._questdb_nodejs-client.QwpSenderError.html | 14 + ...-client.QwpSenderErrorResponseContext.html | 7 + ...uestdb_nodejs-client.QwpSenderMetrics.html | 18 + ...uestdb_nodejs-client.QwpSenderOptions.html | 31 + ...uestdb_nodejs-client.QwpSenderSession.html | 15 + ...db_nodejs-client.QwpServerInfoMessage.html | 16 + ..._questdb_nodejs-client.QwpSymbolValue.html | 3 + ..._nodejs-client.QwpUpgradeErrorDetails.html | 14 + .../_questdb_nodejs-client.QwpUuidValue.html | 3 + ...ejs-client.QwpWebSocketConnectOptions.html | 12 + ...uestdb_nodejs-client.QwpWebSocketLike.html | 18 + ...questdb_nodejs-client.QwpWriterColumn.html | 19 + .../_questdb_nodejs-client.SenderBuffer.html | 159 ++ ...questdb_nodejs-client.SenderTransport.html | 18 + docs/media/QWP.md | 1531 +++++++++++++++++ docs/modules.html | 3 +- docs/modules/_questdb_browser-client.html | 109 ++ docs/modules/_questdb_nodejs-client.html | 136 ++ docs/types/ExtraOptions.html | 3 - docs/types/Logger.html | 4 - docs/types/TimestampUnit.html | 2 - ..._questdb_browser-client.QwpBindSetter.html | 1 + .../_questdb_browser-client.QwpBindType.html | 2 + ...-client.QwpBrowserClientEgressOptions.html | 2 + ...client.QwpBrowserClientIngressOptions.html | 2 + ...rowser-client.QwpBrowserClientOptions.html | 2 + ...uestdb_browser-client.QwpBrowserFetch.html | 1 + ...lient.QwpBrowserSessionAuthentication.html | 3 + ...ient.QwpBrowserSessionBootstrapConfig.html | 2 + ..._questdb_browser-client.QwpColumnType.html | 1 + ...b_browser-client.QwpConnectionFactory.html | 5 + ...uestdb_browser-client.QwpDecimalInput.html | 3 + ...db_browser-client.QwpDoubleArrayInput.html | 2 + ...b_browser-client.QwpEgressCompression.html | 1 + ...estdb_browser-client.QwpEgressMessage.html | 1 + ...uestdb_browser-client.QwpGeohashInput.html | 3 + ...browser-client.QwpIngressProgressKind.html | 1 + ..._browser-client.QwpInitialConnectMode.html | 1 + .../_questdb_browser-client.QwpInt64.html | 1 + .../_questdb_browser-client.QwpIpv4Input.html | 2 + ...uestdb_browser-client.QwpLong256Input.html | 3 + ...uestdb_browser-client.QwpLong256Words.html | 2 + ...stdb_browser-client.QwpLongArrayInput.html | 2 + ...client.QwpNegotiatedEgressCompression.html | 1 + ...tdb_browser-client.QwpNestedLongArray.html | 2 + ...b_browser-client.QwpNestedNumberArray.html | 2 + ...tdb_browser-client.QwpQueryCompletion.html | 1 + ..._browser-client.QwpReconnectEventKind.html | 1 + ...wser-client.QwpResultBatchViewHandler.html | 3 + ...owser-client.QwpResultRowViewCallback.html | 2 + ...questdb_browser-client.QwpResultValue.html | 1 + ...browser-client.QwpSenderErrorCategory.html | 1 + ...b_browser-client.QwpSenderErrorPolicy.html | 1 + ...uestdb_browser-client.QwpSenderLogger.html | 1 + ...rowser-client.QwpSenderSessionFactory.html | 5 + .../_questdb_browser-client.QwpTarget.html | 2 + ...estdb_browser-client.QwpTimestampUnit.html | 1 + ...db_browser-client.QwpUpgradeErrorKind.html | 1 + ...browser-client.QwpUpgradeTimeoutPhase.html | 2 + .../_questdb_browser-client.QwpUuidInput.html | 4 + ...db_browser-client.QwpWriterColumnKind.html | 1 + .../_questdb_browser-client.QwpWriterRow.html | 2 + ...uestdb_browser-client.QwpWriterSchema.html | 1 + .../_questdb_nodejs-client.ExtraOptions.html | 6 + docs/types/_questdb_nodejs-client.Logger.html | 4 + .../_questdb_nodejs-client.QwpBindSetter.html | 1 + .../_questdb_nodejs-client.QwpBindType.html | 2 + .../_questdb_nodejs-client.QwpColumnType.html | 1 + ...db_nodejs-client.QwpConnectionFactory.html | 5 + ...questdb_nodejs-client.QwpDecimalInput.html | 3 + ...tdb_nodejs-client.QwpDoubleArrayInput.html | 2 + ...db_nodejs-client.QwpEgressCompression.html | 1 + ...uestdb_nodejs-client.QwpEgressMessage.html | 1 + ...questdb_nodejs-client.QwpExtraOptions.html | 11 + ...questdb_nodejs-client.QwpGeohashInput.html | 3 + ..._nodejs-client.QwpIngressProgressKind.html | 1 + ...b_nodejs-client.QwpInitialConnectMode.html | 1 + .../_questdb_nodejs-client.QwpInt64.html | 1 + .../_questdb_nodejs-client.QwpIpv4Input.html | 2 + ...questdb_nodejs-client.QwpLong256Input.html | 3 + ...questdb_nodejs-client.QwpLong256Words.html | 2 + ...estdb_nodejs-client.QwpLongArrayInput.html | 2 + ...client.QwpNegotiatedEgressCompression.html | 1 + ...stdb_nodejs-client.QwpNestedLongArray.html | 2 + ...db_nodejs-client.QwpNestedNumberArray.html | 2 + ...js-client.QwpNodeOrphanDrainEventKind.html | 1 + ...stdb_nodejs-client.QwpQueryCompletion.html | 1 + ...b_nodejs-client.QwpReconnectEventKind.html | 1 + ...dejs-client.QwpResultBatchViewHandler.html | 3 + ...odejs-client.QwpResultRowViewCallback.html | 2 + ..._questdb_nodejs-client.QwpResultValue.html | 1 + ..._nodejs-client.QwpSenderErrorCategory.html | 1 + ...db_nodejs-client.QwpSenderErrorPolicy.html | 1 + ...questdb_nodejs-client.QwpSenderLogger.html | 1 + ...nodejs-client.QwpSenderSessionFactory.html | 5 + ...nodejs-client.QwpSfBackpressurePolicy.html | 1 + ...questdb_nodejs-client.QwpSfDurability.html | 1 + .../_questdb_nodejs-client.QwpTarget.html | 2 + ...uestdb_nodejs-client.QwpTimestampUnit.html | 1 + ...tdb_nodejs-client.QwpUpgradeErrorKind.html | 1 + ..._nodejs-client.QwpUpgradeTimeoutPhase.html | 2 + .../_questdb_nodejs-client.QwpUuidInput.html | 4 + ...tdb_nodejs-client.QwpWriterColumnKind.html | 1 + .../_questdb_nodejs-client.QwpWriterRow.html | 2 + ...questdb_nodejs-client.QwpWriterSchema.html | 1 + .../_questdb_nodejs-client.TimestampUnit.html | 2 + ...uestdb_browser-client.QWP_COLUMN_TYPE.html | 1 + ..._browser-client.QWP_COMPRESSION_CODEC.html | 1 + ..._browser-client.QWP_DECIMAL_MAX_SCALE.html | 2 + ...t.QWP_DEFAULT_EGRESS_BUFFER_POOL_SIZE.html | 2 + ...ent.QWP_DEFAULT_EGRESS_INITIAL_CREDIT.html | 2 + ...DEFAULT_EGRESS_SERVER_INFO_TIMEOUT_MS.html | 2 + ...nt.QWP_DURABLE_ACK_WEBSOCKET_PROTOCOL.html | 3 + ..._browser-client.QWP_EGRESS_CAPABILITY.html | 1 + ...tdb_browser-client.QWP_EGRESS_MESSAGE.html | 1 + ...uestdb_browser-client.QWP_EGRESS_PATH.html | 1 + ...b_browser-client.QWP_ENCODING_GORILLA.html | 1 + ...wser-client.QWP_ENCODING_UNCOMPRESSED.html | 1 + ..._browser-client.QWP_FLAG_DEFER_COMMIT.html | 1 + ...ient.QWP_FLAG_DELTA_SYMBOL_DICTIONARY.html | 1 + ...wser-client.QWP_FLAG_DURABLE_ACK_POLL.html | 2 + ...estdb_browser-client.QWP_FLAG_GORILLA.html | 1 + ..._questdb_browser-client.QWP_FLAG_ZSTD.html | 1 + ...uestdb_browser-client.QWP_HEADER_SIZE.html | 1 + ...estdb_browser-client.QWP_INGRESS_PATH.html | 1 + ...wser-client.QWP_INGRESS_PROGRESS_KIND.html | 1 + ...owser-client.QWP_INITIAL_CONNECT_MODE.html | 7 + .../_questdb_browser-client.QWP_MAGIC.html | 2 + ...owser-client.QWP_MAX_ARRAY_DIMENSIONS.html | 2 + ...client.QWP_MAX_ARRAY_DIMENSION_LENGTH.html | 2 + ...client.QWP_MAX_BATCH_ROWS_UPPER_BOUND.html | 2 + ...rowser-client.QWP_MAX_CELLS_PER_BATCH.html | 13 + ...wser-client.QWP_MAX_COLUMNS_PER_TABLE.html | 1 + ...ser-client.QWP_MAX_COLUMN_NAME_LENGTH.html | 2 + ...r-client.QWP_MAX_ERROR_MESSAGE_LENGTH.html | 1 + ...owser-client.QWP_MAX_IDENTIFIER_BYTES.html | 6 + ...browser-client.QWP_MAX_ROWS_PER_TABLE.html | 1 + ...client.QWP_MAX_SYMBOL_DICTIONARY_SIZE.html | 1 + ...wser-client.QWP_MAX_TABLE_NAME_LENGTH.html | 1 + ...client.QWP_MAX_ZSTD_DECOMPRESSED_SIZE.html | 2 + ...lient.QWP_QUERY_FLAG_RESET_DICTIONARY.html | 1 + ...owser-client.QWP_RECONNECT_EVENT_KIND.html | 4 + ...wser-client.QWP_RESET_MASK_DICTIONARY.html | 1 + ...wser-client.QWP_SENDER_ERROR_CATEGORY.html | 1 + ...rowser-client.QWP_SENDER_ERROR_POLICY.html | 1 + ...uestdb_browser-client.QWP_SERVER_ROLE.html | 1 + .../_questdb_browser-client.QWP_STATUS.html | 1 + .../_questdb_browser-client.QWP_TARGET.html | 1 + ...browser-client.QWP_UPGRADE_ERROR_KIND.html | 2 + ...wser-client.QWP_UPGRADE_TIMEOUT_PHASE.html | 1 + .../_questdb_browser-client.QWP_VERSION.html | 1 + ...client.QWP_ZSTD_MAX_COMPRESSION_LEVEL.html | 1 + ...client.QWP_ZSTD_MIN_COMPRESSION_LEVEL.html | 1 + ...questdb_nodejs-client.QWP_COLUMN_TYPE.html | 1 + ...b_nodejs-client.QWP_COMPRESSION_CODEC.html | 1 + ...b_nodejs-client.QWP_DECIMAL_MAX_SCALE.html | 2 + ...t.QWP_DEFAULT_EGRESS_BUFFER_POOL_SIZE.html | 2 + ...ent.QWP_DEFAULT_EGRESS_INITIAL_CREDIT.html | 2 + ...DEFAULT_EGRESS_SERVER_INFO_TIMEOUT_MS.html | 2 + ...nt.QWP_DURABLE_ACK_WEBSOCKET_PROTOCOL.html | 3 + ...b_nodejs-client.QWP_EGRESS_CAPABILITY.html | 1 + ...stdb_nodejs-client.QWP_EGRESS_MESSAGE.html | 1 + ...questdb_nodejs-client.QWP_EGRESS_PATH.html | 1 + ...db_nodejs-client.QWP_ENCODING_GORILLA.html | 1 + ...dejs-client.QWP_ENCODING_UNCOMPRESSED.html | 1 + ...b_nodejs-client.QWP_FLAG_DEFER_COMMIT.html | 1 + ...ient.QWP_FLAG_DELTA_SYMBOL_DICTIONARY.html | 1 + ...dejs-client.QWP_FLAG_DURABLE_ACK_POLL.html | 2 + ...uestdb_nodejs-client.QWP_FLAG_GORILLA.html | 1 + .../_questdb_nodejs-client.QWP_FLAG_ZSTD.html | 1 + ...questdb_nodejs-client.QWP_HEADER_SIZE.html | 1 + ...uestdb_nodejs-client.QWP_INGRESS_PATH.html | 1 + ...dejs-client.QWP_INGRESS_PROGRESS_KIND.html | 1 + ...odejs-client.QWP_INITIAL_CONNECT_MODE.html | 7 + .../_questdb_nodejs-client.QWP_MAGIC.html | 2 + ...odejs-client.QWP_MAX_ARRAY_DIMENSIONS.html | 2 + ...client.QWP_MAX_ARRAY_DIMENSION_LENGTH.html | 2 + ...client.QWP_MAX_BATCH_ROWS_UPPER_BOUND.html | 2 + ...nodejs-client.QWP_MAX_CELLS_PER_BATCH.html | 13 + ...dejs-client.QWP_MAX_COLUMNS_PER_TABLE.html | 1 + ...ejs-client.QWP_MAX_COLUMN_NAME_LENGTH.html | 2 + ...s-client.QWP_MAX_ERROR_MESSAGE_LENGTH.html | 1 + ...odejs-client.QWP_MAX_IDENTIFIER_BYTES.html | 6 + ..._nodejs-client.QWP_MAX_ROWS_PER_TABLE.html | 1 + ...client.QWP_MAX_SYMBOL_DICTIONARY_SIZE.html | 1 + ...dejs-client.QWP_MAX_TABLE_NAME_LENGTH.html | 1 + ...client.QWP_MAX_ZSTD_DECOMPRESSED_SIZE.html | 2 + ...js-client.QWP_ORPHAN_DRAIN_EVENT_KIND.html | 2 + ...ejs-client.QWP_ORPHAN_FAILED_SENTINEL.html | 2 + ...lient.QWP_QUERY_FLAG_RESET_DICTIONARY.html | 1 + ...odejs-client.QWP_RECONNECT_EVENT_KIND.html | 4 + ...dejs-client.QWP_RESET_MASK_DICTIONARY.html | 1 + ...dejs-client.QWP_SENDER_ERROR_CATEGORY.html | 1 + ...nodejs-client.QWP_SENDER_ERROR_POLICY.html | 1 + ...questdb_nodejs-client.QWP_SERVER_ROLE.html | 1 + ...ejs-client.QWP_SF_BACKPRESSURE_POLICY.html | 1 + ...estdb_nodejs-client.QWP_SF_DURABILITY.html | 1 + .../_questdb_nodejs-client.QWP_STATUS.html | 1 + .../_questdb_nodejs-client.QWP_TARGET.html | 1 + ..._nodejs-client.QWP_UPGRADE_ERROR_KIND.html | 2 + ...dejs-client.QWP_UPGRADE_TIMEOUT_PHASE.html | 1 + .../_questdb_nodejs-client.QWP_VERSION.html | 1 + ...client.QWP_ZSTD_MAX_COMPRESSION_LEVEL.html | 1 + ...client.QWP_ZSTD_MIN_COMPRESSION_LEVEL.html | 1 + examples/qwp-browser.ts | 2 +- package.json | 96 +- packages/browser-client/README.md | 323 ++++ .../browser-client/THIRD_PARTY_NOTICES.md | 23 + packages/browser-client/package.json | 48 + .../browser-client/src/index.ts | 32 +- .../browser-client/tsconfig.json | 5 +- packages/browser-client/typedoc.json | 5 + packages/client-core/package.json | 9 + .../client-core/src}/_qwp/_core/binds.ts | 0 .../client-core/src}/_qwp/_core/bytes.ts | 0 .../src}/_qwp/_core/compression.ts | 0 .../client-core/src}/_qwp/_core/constants.ts | 0 .../src}/_qwp/_core/durable-ack.ts | 0 .../client-core/src}/_qwp/_core/egress.ts | 0 .../client-core/src}/_qwp/_core/errors.ts | 0 .../client-core/src}/_qwp/_core/frame.ts | 0 .../client-core/src}/_qwp/_core/gorilla.ts | 0 .../src}/_qwp/_core/identifiers.ts | 0 .../client-core/src}/_qwp/_core/index.ts | 0 .../client-core/src}/_qwp/_core/ingress.ts | 0 .../src}/_qwp/_core/result-batch.ts | 0 .../src}/_qwp/_core/symbol-dictionary.ts | 0 .../client-core/src}/_qwp/_core/table.ts | 0 .../client-core/src}/_qwp/_core/varint.ts | 0 .../client-core/src}/_qwp/_core/zstd.ts | 0 .../src}/_qwp/_internal/async-queue.ts | 0 .../src}/_qwp/_internal/egress-limits.ts | 0 .../src}/_qwp/_internal/egress-routing.ts | 0 .../src}/_qwp/_internal/failover.ts | 0 .../_qwp/_internal/notification-dispatcher.ts | 0 .../src}/_qwp/_internal/reconnect-backoff.ts | 0 .../reconnecting-egress-connection.ts | 0 .../reconnecting-ingress-connection.ts | 0 .../src}/_qwp/_internal/safe-callback.ts | 0 .../_qwp/_internal/websocket-connection.ts | 0 .../client-core/src}/_qwp/client.ts | 0 .../client-core/src}/_qwp/egress-session.ts | 0 .../client-core/src}/_qwp/ingress-session.ts | 0 .../client-core/src}/_qwp/sender-error.ts | 0 .../client-core/src}/_qwp/sender.ts | 0 .../client-core/src}/_qwp/transport.ts | 0 .../client-core/src}/_qwp/writer.ts | 12 +- {src => packages/client-core/src}/logging.ts | 0 .../client-core/src}/qwp/index.ts | 4 +- packages/client-core/tsconfig.json | 12 + packages/nodejs-client/README.md | 307 ++++ packages/nodejs-client/THIRD_PARTY_NOTICES.md | 23 + packages/nodejs-client/package.json | 49 + .../nodejs-client/src}/buffer/base.ts | 0 .../nodejs-client/src}/buffer/bufferv1.ts | 0 .../nodejs-client/src}/buffer/bufferv2.ts | 0 .../nodejs-client/src}/buffer/bufferv3.ts | 0 .../nodejs-client/src}/buffer/index.ts | 0 {src => packages/nodejs-client/src}/index.ts | 3 +- packages/nodejs-client/src/logging.ts | 1 + .../nodejs-client/src}/options.ts | 4 +- .../src}/qwp-node/advisory-lock.ts | 0 .../src}/qwp-node/client-config.ts | 15 +- .../src}/qwp-node/file-replay-store.ts | 6 +- .../src}/qwp-node/orphan-drainer.ts | 10 +- .../qwp-node/segment-maintenance-worker.ts | 0 .../nodejs-client/src}/qwp-node/udp-sender.ts | 6 +- .../nodejs-client/src/qwp.ts | 52 +- {src => packages/nodejs-client/src}/sender.ts | 8 +- .../nodejs-client/src}/transport/http/base.ts | 0 .../src}/transport/http/stdlib.ts | 0 .../src}/transport/http/undici.ts | 0 .../nodejs-client/src}/transport/index.ts | 0 .../nodejs-client/src}/transport/tcp.ts | 0 {src => packages/nodejs-client/src}/utils.ts | 0 .../nodejs-client/src}/validation.ts | 0 packages/nodejs-client/tsconfig.json | 4 + packages/nodejs-client/typedoc.json | 8 + pnpm-lock.yaml | 216 +-- pnpm-workspace.yaml | 2 + scripts/check-build-artifacts.mjs | 150 +- scripts/clean-package-dist.mjs | 15 + scripts/generateDocs.sh | 4 +- test/dist-types/class-identity.ts | 14 +- test/dist-types/writer-rows.ts | 66 +- test/logging.test.ts | 4 +- test/options.test.ts | 8 +- test/package-boundaries.e2e.ts | 286 +++ test/qwp/binds.test.ts | 2 +- test/qwp/browser.e2e.ts | 13 +- test/qwp/client.test.ts | 4 +- test/qwp/config-docs.test.ts | 7 +- test/qwp/core.test.ts | 7 +- test/qwp/dist.e2e.ts | 179 +- test/qwp/egress.test.ts | 6 +- test/qwp/identifiers.test.ts | 2 +- test/qwp/node-client-config.test.ts | 2 +- test/qwp/node-transport.test.ts | 2 +- test/qwp/notification-dispatcher.test.ts | 2 +- test/qwp/orphan-drainer.test.ts | 4 +- test/qwp/public-api-contract.ts | 19 +- test/qwp/public-api.test.ts | 6 +- test/qwp/reconnect.test.ts | 14 +- test/qwp/safe-callback.test.ts | 2 +- test/qwp/sender-error.test.ts | 4 +- test/qwp/sender-node-integration.test.ts | 4 +- test/qwp/sender.test.ts | 15 +- test/qwp/session.test.ts | 8 +- test/qwp/sfa-interop.test.ts | 2 +- test/qwp/sfa-multiprocess-child.mjs | 2 +- test/qwp/sfa-multiprocess.e2e.ts | 6 +- test/qwp/udp-sender.test.ts | 12 +- test/qwp/wss-tls-security.test.ts | 10 +- test/sender.buffer.test.ts | 8 +- test/sender.config.test.ts | 9 +- test/sender.integration.test.ts | 2 +- test/sender.transport.test.ts | 7 +- test/testapp.ts | 2 +- test/utils.decimal.test.ts | 2 +- tsconfig.bench.json | 2 +- tsconfig.dist-types.cjs.json | 10 +- tsconfig.dist-types.json | 10 +- tsconfig.json | 7 +- tsconfig.test.json | 2 +- typedoc.json | 11 +- vitest.dist.config.ts | 6 +- 759 files changed, 10341 insertions(+), 1732 deletions(-) delete mode 100644 docs/classes/HttpTransport.html delete mode 100644 docs/classes/Sender.html delete mode 100644 docs/classes/SenderBufferV1.html delete mode 100644 docs/classes/SenderBufferV2.html delete mode 100644 docs/classes/SenderOptions.html delete mode 100644 docs/classes/TcpTransport.html delete mode 100644 docs/classes/UndiciTransport.html create mode 100644 docs/classes/_questdb_browser-client.QwpBatchTooLargeError.html create mode 100644 docs/classes/_questdb_browser-client.QwpBindValues.html create mode 100644 docs/classes/_questdb_browser-client.QwpBrowserSessionBootstrapError.html create mode 100644 docs/classes/_questdb_browser-client.QwpByteReader.html create mode 100644 docs/classes/_questdb_browser-client.QwpByteWriter.html create mode 100644 docs/classes/_questdb_browser-client.QwpClient.html create mode 100644 docs/classes/_questdb_browser-client.QwpClientClosedError.html create mode 100644 docs/classes/_questdb_browser-client.QwpDurableAckUnavailableError.html create mode 100644 docs/classes/_questdb_browser-client.QwpEgressQuery.html create mode 100644 docs/classes/_questdb_browser-client.QwpEgressQueryAbandonedError.html create mode 100644 docs/classes/_questdb_browser-client.QwpEgressQueryCancelTimeoutError.html create mode 100644 docs/classes/_questdb_browser-client.QwpEgressQueryError.html create mode 100644 docs/classes/_questdb_browser-client.QwpEgressQueryTimeoutError.html create mode 100644 docs/classes/_questdb_browser-client.QwpEgressReplayRequiredError.html create mode 100644 docs/classes/_questdb_browser-client.QwpEgressSession.html create mode 100644 docs/classes/_questdb_browser-client.QwpEgressSessionClosedError.html create mode 100644 docs/classes/_questdb_browser-client.QwpFailoverError.html create mode 100644 docs/classes/_questdb_browser-client.QwpIngressAckTimeoutError.html create mode 100644 docs/classes/_questdb_browser-client.QwpIngressNackError.html create mode 100644 docs/classes/_questdb_browser-client.QwpIngressSession.html create mode 100644 docs/classes/_questdb_browser-client.QwpIngressSessionClosedError.html create mode 100644 docs/classes/_questdb_browser-client.QwpMemoryReplayAppendTimeoutError.html create mode 100644 docs/classes/_questdb_browser-client.QwpMemoryReplayFrameTooLargeError.html create mode 100644 docs/classes/_questdb_browser-client.QwpPoolAcquireTimeoutError.html create mode 100644 docs/classes/_questdb_browser-client.QwpPoolResourceError.html create mode 100644 docs/classes/_questdb_browser-client.QwpProtocolError.html create mode 100644 docs/classes/_questdb_browser-client.QwpQueryLease.html create mode 100644 docs/classes/_questdb_browser-client.QwpReconnectExhaustedError.html create mode 100644 docs/classes/_questdb_browser-client.QwpReplayDictionaryError.html create mode 100644 docs/classes/_questdb_browser-client.QwpReplayDictionaryPersistenceError.html create mode 100644 docs/classes/_questdb_browser-client.QwpReplayRejectedError.html create mode 100644 docs/classes/_questdb_browser-client.QwpResultBatch.html create mode 100644 docs/classes/_questdb_browser-client.QwpResultBatchDecoder.html create mode 100644 docs/classes/_questdb_browser-client.QwpResultBatchView.html create mode 100644 docs/classes/_questdb_browser-client.QwpResultColumnView.html create mode 100644 docs/classes/_questdb_browser-client.QwpResultRowView.html create mode 100644 docs/classes/_questdb_browser-client.QwpRoleMismatchError.html create mode 100644 docs/classes/_questdb_browser-client.QwpSendClosedError.html create mode 100644 docs/classes/_questdb_browser-client.QwpSendError.html create mode 100644 docs/classes/_questdb_browser-client.QwpSendTimeoutError.html create mode 100644 docs/classes/_questdb_browser-client.QwpSender.html create mode 100644 docs/classes/_questdb_browser-client.QwpSenderCloseTimeoutError.html create mode 100644 docs/classes/_questdb_browser-client.QwpSymbolDictionary.html create mode 100644 docs/classes/_questdb_browser-client.QwpTableBuffer.html create mode 100644 docs/classes/_questdb_browser-client.QwpTableWriter.html create mode 100644 docs/classes/_questdb_browser-client.QwpUnrecoverableReplayDictionaryError.html create mode 100644 docs/classes/_questdb_browser-client.QwpUpgradeError.html create mode 100644 docs/classes/_questdb_browser-client.QwpWriterRowError.html create mode 100644 docs/classes/_questdb_nodejs-client.HttpTransport.html create mode 100644 docs/classes/_questdb_nodejs-client.QwpBatchTooLargeError.html create mode 100644 docs/classes/_questdb_nodejs-client.QwpBindValues.html create mode 100644 docs/classes/_questdb_nodejs-client.QwpByteReader.html create mode 100644 docs/classes/_questdb_nodejs-client.QwpByteWriter.html create mode 100644 docs/classes/_questdb_nodejs-client.QwpClient.html create mode 100644 docs/classes/_questdb_nodejs-client.QwpClientClosedError.html create mode 100644 docs/classes/_questdb_nodejs-client.QwpDurableAckUnavailableError.html create mode 100644 docs/classes/_questdb_nodejs-client.QwpEgressQuery.html create mode 100644 docs/classes/_questdb_nodejs-client.QwpEgressQueryAbandonedError.html create mode 100644 docs/classes/_questdb_nodejs-client.QwpEgressQueryCancelTimeoutError.html create mode 100644 docs/classes/_questdb_nodejs-client.QwpEgressQueryError.html create mode 100644 docs/classes/_questdb_nodejs-client.QwpEgressQueryTimeoutError.html create mode 100644 docs/classes/_questdb_nodejs-client.QwpEgressReplayRequiredError.html create mode 100644 docs/classes/_questdb_nodejs-client.QwpEgressSession.html create mode 100644 docs/classes/_questdb_nodejs-client.QwpEgressSessionClosedError.html create mode 100644 docs/classes/_questdb_nodejs-client.QwpFailoverError.html create mode 100644 docs/classes/_questdb_nodejs-client.QwpIngressAckTimeoutError.html create mode 100644 docs/classes/_questdb_nodejs-client.QwpIngressNackError.html create mode 100644 docs/classes/_questdb_nodejs-client.QwpIngressSession.html create mode 100644 docs/classes/_questdb_nodejs-client.QwpIngressSessionClosedError.html create mode 100644 docs/classes/_questdb_nodejs-client.QwpMemoryReplayAppendTimeoutError.html create mode 100644 docs/classes/_questdb_nodejs-client.QwpMemoryReplayFrameTooLargeError.html create mode 100644 docs/classes/_questdb_nodejs-client.QwpNodeFileReplayStore.html create mode 100644 docs/classes/_questdb_nodejs-client.QwpNodeOrphanDrainer.html create mode 100644 docs/classes/_questdb_nodejs-client.QwpNodeUdpSession.html create mode 100644 docs/classes/_questdb_nodejs-client.QwpPoolAcquireTimeoutError.html create mode 100644 docs/classes/_questdb_nodejs-client.QwpPoolResourceError.html create mode 100644 docs/classes/_questdb_nodejs-client.QwpProtocolError.html create mode 100644 docs/classes/_questdb_nodejs-client.QwpQueryLease.html create mode 100644 docs/classes/_questdb_nodejs-client.QwpReconnectExhaustedError.html create mode 100644 docs/classes/_questdb_nodejs-client.QwpReplayDictionaryError.html create mode 100644 docs/classes/_questdb_nodejs-client.QwpReplayDictionaryPersistenceError.html create mode 100644 docs/classes/_questdb_nodejs-client.QwpReplayRejectedError.html create mode 100644 docs/classes/_questdb_nodejs-client.QwpReplayStoreAppendTimeoutError.html create mode 100644 docs/classes/_questdb_nodejs-client.QwpReplayStoreCheckpointError.html create mode 100644 docs/classes/_questdb_nodejs-client.QwpReplayStoreCorruptionError.html create mode 100644 docs/classes/_questdb_nodejs-client.QwpReplayStoreError.html create mode 100644 docs/classes/_questdb_nodejs-client.QwpReplayStoreFullError.html create mode 100644 docs/classes/_questdb_nodejs-client.QwpReplayStoreLockLostError.html create mode 100644 docs/classes/_questdb_nodejs-client.QwpReplayStoreLockedError.html create mode 100644 docs/classes/_questdb_nodejs-client.QwpReplayStoreQuarantinedError.html create mode 100644 docs/classes/_questdb_nodejs-client.QwpReplayStoreSegmentTooLargeError.html create mode 100644 docs/classes/_questdb_nodejs-client.QwpResultBatch.html create mode 100644 docs/classes/_questdb_nodejs-client.QwpResultBatchDecoder.html create mode 100644 docs/classes/_questdb_nodejs-client.QwpResultBatchView.html create mode 100644 docs/classes/_questdb_nodejs-client.QwpResultColumnView.html create mode 100644 docs/classes/_questdb_nodejs-client.QwpResultRowView.html create mode 100644 docs/classes/_questdb_nodejs-client.QwpRoleMismatchError.html create mode 100644 docs/classes/_questdb_nodejs-client.QwpSendClosedError.html create mode 100644 docs/classes/_questdb_nodejs-client.QwpSendError.html create mode 100644 docs/classes/_questdb_nodejs-client.QwpSendTimeoutError.html create mode 100644 docs/classes/_questdb_nodejs-client.QwpSender.html create mode 100644 docs/classes/_questdb_nodejs-client.QwpSenderCloseTimeoutError.html create mode 100644 docs/classes/_questdb_nodejs-client.QwpSymbolDictionary.html create mode 100644 docs/classes/_questdb_nodejs-client.QwpTableBuffer.html create mode 100644 docs/classes/_questdb_nodejs-client.QwpTableWriter.html create mode 100644 docs/classes/_questdb_nodejs-client.QwpUdpDatagramTooLargeError.html create mode 100644 docs/classes/_questdb_nodejs-client.QwpUnrecoverableReplayDictionaryError.html create mode 100644 docs/classes/_questdb_nodejs-client.QwpUpgradeError.html create mode 100644 docs/classes/_questdb_nodejs-client.QwpVersionMismatchError.html create mode 100644 docs/classes/_questdb_nodejs-client.QwpWriterRowError.html create mode 100644 docs/classes/_questdb_nodejs-client.Sender.html create mode 100644 docs/classes/_questdb_nodejs-client.SenderBufferV1.html create mode 100644 docs/classes/_questdb_nodejs-client.SenderBufferV2.html create mode 100644 docs/classes/_questdb_nodejs-client.SenderBufferV3.html create mode 100644 docs/classes/_questdb_nodejs-client.SenderOptions.html create mode 100644 docs/classes/_questdb_nodejs-client.TcpTransport.html create mode 100644 docs/classes/_questdb_nodejs-client.UndiciTransport.html create mode 100644 docs/functions/_questdb_browser-client.addQwpDurableAckWebSocketProtocol.html create mode 100644 docs/functions/_questdb_browser-client.binary.html create mode 100644 docs/functions/_questdb_browser-client.bool.html create mode 100644 docs/functions/_questdb_browser-client.bootstrapQwpBrowserSession.html create mode 100644 docs/functions/_questdb_browser-client.byte.html create mode 100644 docs/functions/_questdb_browser-client.char.html create mode 100644 docs/functions/_questdb_browser-client.concatBytes.html create mode 100644 docs/functions/_questdb_browser-client.connectQwpBrowserClient.html create mode 100644 docs/functions/_questdb_browser-client.connectQwpBrowserEgress.html create mode 100644 docs/functions/_questdb_browser-client.connectQwpBrowserIngress.html create mode 100644 docs/functions/_questdb_browser-client.connectQwpBrowserSender.html create mode 100644 docs/functions/_questdb_browser-client.connectQwpBrowserWebSocket.html create mode 100644 docs/functions/_questdb_browser-client.createQwpBrowserClient.html create mode 100644 docs/functions/_questdb_browser-client.createQwpBrowserConnectionFactory.html create mode 100644 docs/functions/_questdb_browser-client.createQwpBrowserSender.html create mode 100644 docs/functions/_questdb_browser-client.createQwpDataLossSenderError.html create mode 100644 docs/functions/_questdb_browser-client.createQwpProtocolViolationSenderError.html create mode 100644 docs/functions/_questdb_browser-client.createQwpSenderError.html create mode 100644 docs/functions/_questdb_browser-client.date.html create mode 100644 docs/functions/_questdb_browser-client.decimal128.html create mode 100644 docs/functions/_questdb_browser-client.decimal256.html create mode 100644 docs/functions/_questdb_browser-client.decimal64.html create mode 100644 docs/functions/_questdb_browser-client.decodeQwpContentEncoding.html create mode 100644 docs/functions/_questdb_browser-client.decodeQwpEgressMessage.html create mode 100644 docs/functions/_questdb_browser-client.decodeQwpFrame.html create mode 100644 docs/functions/_questdb_browser-client.decodeQwpIngressResponse.html create mode 100644 docs/functions/_questdb_browser-client.decodeQwpIngressServerInfo.html create mode 100644 docs/functions/_questdb_browser-client.decodeQwpIngressSymbolDictionaryDelta.html create mode 100644 docs/functions/_questdb_browser-client.decodeQwpVarint.html create mode 100644 docs/functions/_questdb_browser-client.decodeUtf8.html create mode 100644 docs/functions/_questdb_browser-client.decompressQwpZstdFrame.html create mode 100644 docs/functions/_questdb_browser-client.defaultQwpSenderErrorHandler.html create mode 100644 docs/functions/_questdb_browser-client.designatedTimestamp.html create mode 100644 docs/functions/_questdb_browser-client.double.html create mode 100644 docs/functions/_questdb_browser-client.doubleArray.html create mode 100644 docs/functions/_questdb_browser-client.encodeQwpAcceptEncoding.html create mode 100644 docs/functions/_questdb_browser-client.encodeQwpBinds.html create mode 100644 docs/functions/_questdb_browser-client.encodeQwpCancel.html create mode 100644 docs/functions/_questdb_browser-client.encodeQwpCredit.html create mode 100644 docs/functions/_questdb_browser-client.encodeQwpDurableAckPollFrame.html create mode 100644 docs/functions/_questdb_browser-client.encodeQwpFrame.html create mode 100644 docs/functions/_questdb_browser-client.encodeQwpGorilla.html create mode 100644 docs/functions/_questdb_browser-client.encodeQwpIngressCommitFrame.html create mode 100644 docs/functions/_questdb_browser-client.encodeQwpIngressFrame.html create mode 100644 docs/functions/_questdb_browser-client.encodeQwpIngressSymbolDictionaryFrame.html create mode 100644 docs/functions/_questdb_browser-client.encodeQwpQueryRequest.html create mode 100644 docs/functions/_questdb_browser-client.encodeQwpVarint.html create mode 100644 docs/functions/_questdb_browser-client.encodeUtf8.html create mode 100644 docs/functions/_questdb_browser-client.flattenQwpArray.html create mode 100644 docs/functions/_questdb_browser-client.float32.html create mode 100644 docs/functions/_questdb_browser-client.float64.html create mode 100644 docs/functions/_questdb_browser-client.geohash.html create mode 100644 docs/functions/_questdb_browser-client.int32.html create mode 100644 docs/functions/_questdb_browser-client.int64.html create mode 100644 docs/functions/_questdb_browser-client.ipv4.html create mode 100644 docs/functions/_questdb_browser-client.isQwpDurableAckWebSocketProtocol.html create mode 100644 docs/functions/_questdb_browser-client.long.html create mode 100644 docs/functions/_questdb_browser-client.long256.html create mode 100644 docs/functions/_questdb_browser-client.longArray.html create mode 100644 docs/functions/_questdb_browser-client.qwpDefaultSenderErrorPolicy.html create mode 100644 docs/functions/_questdb_browser-client.qwpGorillaSize.html create mode 100644 docs/functions/_questdb_browser-client.qwpSenderErrorCategory.html create mode 100644 docs/functions/_questdb_browser-client.qwpVarintSize.html create mode 100644 docs/functions/_questdb_browser-client.readQwpVarint.html create mode 100644 docs/functions/_questdb_browser-client.readQwpVarintNumber.html create mode 100644 docs/functions/_questdb_browser-client.short.html create mode 100644 docs/functions/_questdb_browser-client.symbol.html create mode 100644 docs/functions/_questdb_browser-client.timestamp.html create mode 100644 docs/functions/_questdb_browser-client.utf8Length.html create mode 100644 docs/functions/_questdb_browser-client.uuid.html create mode 100644 docs/functions/_questdb_browser-client.varchar.html create mode 100644 docs/functions/_questdb_browser-client.writeQwpFrameHeader.html create mode 100644 docs/functions/_questdb_browser-client.writeQwpVarint.html create mode 100644 docs/functions/_questdb_nodejs-client.addQwpDurableAckWebSocketProtocol.html create mode 100644 docs/functions/_questdb_nodejs-client.bigintToTwosComplementBytes.html create mode 100644 docs/functions/_questdb_nodejs-client.binary.html create mode 100644 docs/functions/_questdb_nodejs-client.bool.html create mode 100644 docs/functions/_questdb_nodejs-client.byte.html create mode 100644 docs/functions/_questdb_nodejs-client.char.html create mode 100644 docs/functions/_questdb_nodejs-client.concatBytes.html create mode 100644 docs/functions/_questdb_nodejs-client.connectQwpNodeClient.html create mode 100644 docs/functions/_questdb_nodejs-client.connectQwpNodeEgress.html create mode 100644 docs/functions/_questdb_nodejs-client.connectQwpNodeIngress.html create mode 100644 docs/functions/_questdb_nodejs-client.connectQwpNodeSender.html create mode 100644 docs/functions/_questdb_nodejs-client.connectQwpNodeUdp.html create mode 100644 docs/functions/_questdb_nodejs-client.connectQwpNodeUdpSender.html create mode 100644 docs/functions/_questdb_nodejs-client.connectQwpNodeWebSocket.html create mode 100644 docs/functions/_questdb_nodejs-client.createBuffer.html create mode 100644 docs/functions/_questdb_nodejs-client.createQwpDataLossSenderError.html create mode 100644 docs/functions/_questdb_nodejs-client.createQwpNodeClient.html create mode 100644 docs/functions/_questdb_nodejs-client.createQwpNodeConnectionFactory.html create mode 100644 docs/functions/_questdb_nodejs-client.createQwpNodeSender.html create mode 100644 docs/functions/_questdb_nodejs-client.createQwpNodeUdpSender.html create mode 100644 docs/functions/_questdb_nodejs-client.createQwpProtocolViolationSenderError.html create mode 100644 docs/functions/_questdb_nodejs-client.createQwpSenderError.html create mode 100644 docs/functions/_questdb_nodejs-client.createTransport.html create mode 100644 docs/functions/_questdb_nodejs-client.date.html create mode 100644 docs/functions/_questdb_nodejs-client.decimal128.html create mode 100644 docs/functions/_questdb_nodejs-client.decimal256.html create mode 100644 docs/functions/_questdb_nodejs-client.decimal64.html create mode 100644 docs/functions/_questdb_nodejs-client.decodeQwpContentEncoding.html create mode 100644 docs/functions/_questdb_nodejs-client.decodeQwpEgressMessage.html create mode 100644 docs/functions/_questdb_nodejs-client.decodeQwpFrame.html create mode 100644 docs/functions/_questdb_nodejs-client.decodeQwpIngressResponse.html create mode 100644 docs/functions/_questdb_nodejs-client.decodeQwpIngressServerInfo.html create mode 100644 docs/functions/_questdb_nodejs-client.decodeQwpIngressSymbolDictionaryDelta.html create mode 100644 docs/functions/_questdb_nodejs-client.decodeQwpVarint.html create mode 100644 docs/functions/_questdb_nodejs-client.decodeUtf8.html create mode 100644 docs/functions/_questdb_nodejs-client.decompressQwpZstdFrame.html create mode 100644 docs/functions/_questdb_nodejs-client.defaultQwpSenderErrorHandler.html create mode 100644 docs/functions/_questdb_nodejs-client.designatedTimestamp.html create mode 100644 docs/functions/_questdb_nodejs-client.double.html create mode 100644 docs/functions/_questdb_nodejs-client.doubleArray.html create mode 100644 docs/functions/_questdb_nodejs-client.encodeQwpAcceptEncoding.html create mode 100644 docs/functions/_questdb_nodejs-client.encodeQwpBinds.html create mode 100644 docs/functions/_questdb_nodejs-client.encodeQwpCancel.html create mode 100644 docs/functions/_questdb_nodejs-client.encodeQwpCredit.html create mode 100644 docs/functions/_questdb_nodejs-client.encodeQwpDurableAckPollFrame.html create mode 100644 docs/functions/_questdb_nodejs-client.encodeQwpFrame.html create mode 100644 docs/functions/_questdb_nodejs-client.encodeQwpGorilla.html create mode 100644 docs/functions/_questdb_nodejs-client.encodeQwpIngressCommitFrame.html create mode 100644 docs/functions/_questdb_nodejs-client.encodeQwpIngressFrame.html create mode 100644 docs/functions/_questdb_nodejs-client.encodeQwpIngressSymbolDictionaryFrame.html create mode 100644 docs/functions/_questdb_nodejs-client.encodeQwpQueryRequest.html create mode 100644 docs/functions/_questdb_nodejs-client.encodeQwpVarint.html create mode 100644 docs/functions/_questdb_nodejs-client.encodeUtf8.html create mode 100644 docs/functions/_questdb_nodejs-client.flattenQwpArray.html create mode 100644 docs/functions/_questdb_nodejs-client.float32.html create mode 100644 docs/functions/_questdb_nodejs-client.float64.html create mode 100644 docs/functions/_questdb_nodejs-client.geohash.html create mode 100644 docs/functions/_questdb_nodejs-client.int32.html create mode 100644 docs/functions/_questdb_nodejs-client.int64.html create mode 100644 docs/functions/_questdb_nodejs-client.ipv4.html create mode 100644 docs/functions/_questdb_nodejs-client.isQwpDurableAckWebSocketProtocol.html create mode 100644 docs/functions/_questdb_nodejs-client.long.html create mode 100644 docs/functions/_questdb_nodejs-client.long256.html create mode 100644 docs/functions/_questdb_nodejs-client.longArray.html create mode 100644 docs/functions/_questdb_nodejs-client.parseQwpNodeClientConfig.html create mode 100644 docs/functions/_questdb_nodejs-client.qwpDefaultSenderErrorPolicy.html create mode 100644 docs/functions/_questdb_nodejs-client.qwpGorillaSize.html create mode 100644 docs/functions/_questdb_nodejs-client.qwpSenderErrorCategory.html create mode 100644 docs/functions/_questdb_nodejs-client.qwpVarintSize.html create mode 100644 docs/functions/_questdb_nodejs-client.readQwpVarint.html create mode 100644 docs/functions/_questdb_nodejs-client.readQwpVarintNumber.html create mode 100644 docs/functions/_questdb_nodejs-client.retryQwpNodeOrphanSlot.html create mode 100644 docs/functions/_questdb_nodejs-client.scanQwpNodeOrphanSlots.html create mode 100644 docs/functions/_questdb_nodejs-client.short.html create mode 100644 docs/functions/_questdb_nodejs-client.symbol.html create mode 100644 docs/functions/_questdb_nodejs-client.timestamp.html create mode 100644 docs/functions/_questdb_nodejs-client.utf8Length.html create mode 100644 docs/functions/_questdb_nodejs-client.uuid.html create mode 100644 docs/functions/_questdb_nodejs-client.varchar.html create mode 100644 docs/functions/_questdb_nodejs-client.writeQwpFrameHeader.html create mode 100644 docs/functions/_questdb_nodejs-client.writeQwpVarint.html delete mode 100644 docs/functions/bigintToTwosComplementBytes.html delete mode 100644 docs/functions/createBuffer.html delete mode 100644 docs/functions/createTransport.html delete mode 100644 docs/interfaces/SenderBuffer.html delete mode 100644 docs/interfaces/SenderTransport.html create mode 100644 docs/interfaces/_questdb_browser-client.QwpArrayValue.html create mode 100644 docs/interfaces/_questdb_browser-client.QwpBinaryConnection.html create mode 100644 docs/interfaces/_questdb_browser-client.QwpBrowserClusterOptions.html create mode 100644 docs/interfaces/_questdb_browser-client.QwpBrowserEgressOptions.html create mode 100644 docs/interfaces/_questdb_browser-client.QwpBrowserSessionBootstrapOptions.html create mode 100644 docs/interfaces/_questdb_browser-client.QwpBrowserSessionBootstrapResult.html create mode 100644 docs/interfaces/_questdb_browser-client.QwpBrowserSplitClientOptions.html create mode 100644 docs/interfaces/_questdb_browser-client.QwpBrowserUnifiedClientOptions.html create mode 100644 docs/interfaces/_questdb_browser-client.QwpBrowserWebSocketOptions.html create mode 100644 docs/interfaces/_questdb_browser-client.QwpCacheResetMessage.html create mode 100644 docs/interfaces/_questdb_browser-client.QwpClientFactories.html create mode 100644 docs/interfaces/_questdb_browser-client.QwpClientMetrics.html create mode 100644 docs/interfaces/_questdb_browser-client.QwpClientPoolOptions.html create mode 100644 docs/interfaces/_questdb_browser-client.QwpColumnBuffer.html create mode 100644 docs/interfaces/_questdb_browser-client.QwpConnectionCloseInfo.html create mode 100644 docs/interfaces/_questdb_browser-client.QwpDecimalValue.html create mode 100644 docs/interfaces/_questdb_browser-client.QwpEgressQueryOptions.html create mode 100644 docs/interfaces/_questdb_browser-client.QwpEgressReplayResetEvent.html create mode 100644 docs/interfaces/_questdb_browser-client.QwpEgressRoutingOptions.html create mode 100644 docs/interfaces/_questdb_browser-client.QwpEgressSessionOptions.html create mode 100644 docs/interfaces/_questdb_browser-client.QwpEgressViewQuery.html create mode 100644 docs/interfaces/_questdb_browser-client.QwpEncodedBinds.html create mode 100644 docs/interfaces/_questdb_browser-client.QwpExecDoneMessage.html create mode 100644 docs/interfaces/_questdb_browser-client.QwpFailoverAttempt.html create mode 100644 docs/interfaces/_questdb_browser-client.QwpFrame.html create mode 100644 docs/interfaces/_questdb_browser-client.QwpFrameHeader.html create mode 100644 docs/interfaces/_questdb_browser-client.QwpGeohashValue.html create mode 100644 docs/interfaces/_questdb_browser-client.QwpHandshakeMetadata.html create mode 100644 docs/interfaces/_questdb_browser-client.QwpIngressEncodeOptions.html create mode 100644 docs/interfaces/_questdb_browser-client.QwpIngressErrorEvent.html create mode 100644 docs/interfaces/_questdb_browser-client.QwpIngressMetrics.html create mode 100644 docs/interfaces/_questdb_browser-client.QwpIngressProgressEvent.html create mode 100644 docs/interfaces/_questdb_browser-client.QwpIngressReplayRecord.html create mode 100644 docs/interfaces/_questdb_browser-client.QwpIngressReplayReference.html create mode 100644 docs/interfaces/_questdb_browser-client.QwpIngressReplayStore.html create mode 100644 docs/interfaces/_questdb_browser-client.QwpIngressResponse.html create mode 100644 docs/interfaces/_questdb_browser-client.QwpIngressSendResult.html create mode 100644 docs/interfaces/_questdb_browser-client.QwpIngressSessionOptions.html create mode 100644 docs/interfaces/_questdb_browser-client.QwpIngressSymbolDictionaryDelta.html create mode 100644 docs/interfaces/_questdb_browser-client.QwpIngressTableResult.html create mode 100644 docs/interfaces/_questdb_browser-client.QwpIngressTransportMetrics.html create mode 100644 docs/interfaces/_questdb_browser-client.QwpLong256Value.html create mode 100644 docs/interfaces/_questdb_browser-client.QwpPoolSlotReservation.html create mode 100644 docs/interfaces/_questdb_browser-client.QwpQueryErrorMessage.html create mode 100644 docs/interfaces/_questdb_browser-client.QwpQueryRequest.html create mode 100644 docs/interfaces/_questdb_browser-client.QwpReconnectEvent.html create mode 100644 docs/interfaces/_questdb_browser-client.QwpReconnectOptions.html create mode 100644 docs/interfaces/_questdb_browser-client.QwpResourcePoolMetrics.html create mode 100644 docs/interfaces/_questdb_browser-client.QwpResultArrayValue.html create mode 100644 docs/interfaces/_questdb_browser-client.QwpResultBatchMessage.html create mode 100644 docs/interfaces/_questdb_browser-client.QwpResultColumn.html create mode 100644 docs/interfaces/_questdb_browser-client.QwpResultColumnSchema.html create mode 100644 docs/interfaces/_questdb_browser-client.QwpResultEndMessage.html create mode 100644 docs/interfaces/_questdb_browser-client.QwpSenderEncodeOptions.html create mode 100644 docs/interfaces/_questdb_browser-client.QwpSenderError.html create mode 100644 docs/interfaces/_questdb_browser-client.QwpSenderErrorResponseContext.html create mode 100644 docs/interfaces/_questdb_browser-client.QwpSenderMetrics.html create mode 100644 docs/interfaces/_questdb_browser-client.QwpSenderOptions.html create mode 100644 docs/interfaces/_questdb_browser-client.QwpSenderSession.html create mode 100644 docs/interfaces/_questdb_browser-client.QwpServerInfoMessage.html create mode 100644 docs/interfaces/_questdb_browser-client.QwpSymbolValue.html create mode 100644 docs/interfaces/_questdb_browser-client.QwpUpgradeErrorDetails.html create mode 100644 docs/interfaces/_questdb_browser-client.QwpUuidValue.html create mode 100644 docs/interfaces/_questdb_browser-client.QwpWebSocketConnectOptions.html create mode 100644 docs/interfaces/_questdb_browser-client.QwpWebSocketLike.html create mode 100644 docs/interfaces/_questdb_browser-client.QwpWriterColumn.html create mode 100644 docs/interfaces/_questdb_nodejs-client.QwpArrayValue.html create mode 100644 docs/interfaces/_questdb_nodejs-client.QwpBinaryConnection.html create mode 100644 docs/interfaces/_questdb_nodejs-client.QwpCacheResetMessage.html create mode 100644 docs/interfaces/_questdb_nodejs-client.QwpClientFactories.html create mode 100644 docs/interfaces/_questdb_nodejs-client.QwpClientMetrics.html create mode 100644 docs/interfaces/_questdb_nodejs-client.QwpClientPoolOptions.html create mode 100644 docs/interfaces/_questdb_nodejs-client.QwpColumnBuffer.html create mode 100644 docs/interfaces/_questdb_nodejs-client.QwpConnectionCloseInfo.html create mode 100644 docs/interfaces/_questdb_nodejs-client.QwpDecimalValue.html create mode 100644 docs/interfaces/_questdb_nodejs-client.QwpEgressQueryOptions.html create mode 100644 docs/interfaces/_questdb_nodejs-client.QwpEgressReplayResetEvent.html create mode 100644 docs/interfaces/_questdb_nodejs-client.QwpEgressRoutingOptions.html create mode 100644 docs/interfaces/_questdb_nodejs-client.QwpEgressSessionOptions.html create mode 100644 docs/interfaces/_questdb_nodejs-client.QwpEgressViewQuery.html create mode 100644 docs/interfaces/_questdb_nodejs-client.QwpEncodedBinds.html create mode 100644 docs/interfaces/_questdb_nodejs-client.QwpExecDoneMessage.html create mode 100644 docs/interfaces/_questdb_nodejs-client.QwpFailoverAttempt.html create mode 100644 docs/interfaces/_questdb_nodejs-client.QwpFrame.html create mode 100644 docs/interfaces/_questdb_nodejs-client.QwpFrameHeader.html create mode 100644 docs/interfaces/_questdb_nodejs-client.QwpGeohashValue.html create mode 100644 docs/interfaces/_questdb_nodejs-client.QwpHandshakeMetadata.html create mode 100644 docs/interfaces/_questdb_nodejs-client.QwpIngressEncodeOptions.html create mode 100644 docs/interfaces/_questdb_nodejs-client.QwpIngressErrorEvent.html create mode 100644 docs/interfaces/_questdb_nodejs-client.QwpIngressMetrics.html create mode 100644 docs/interfaces/_questdb_nodejs-client.QwpIngressProgressEvent.html create mode 100644 docs/interfaces/_questdb_nodejs-client.QwpIngressReplayRecord.html create mode 100644 docs/interfaces/_questdb_nodejs-client.QwpIngressReplayReference.html create mode 100644 docs/interfaces/_questdb_nodejs-client.QwpIngressReplayStore.html create mode 100644 docs/interfaces/_questdb_nodejs-client.QwpIngressResponse.html create mode 100644 docs/interfaces/_questdb_nodejs-client.QwpIngressSendResult.html create mode 100644 docs/interfaces/_questdb_nodejs-client.QwpIngressSessionOptions.html create mode 100644 docs/interfaces/_questdb_nodejs-client.QwpIngressSymbolDictionaryDelta.html create mode 100644 docs/interfaces/_questdb_nodejs-client.QwpIngressTableResult.html create mode 100644 docs/interfaces/_questdb_nodejs-client.QwpIngressTransportMetrics.html create mode 100644 docs/interfaces/_questdb_nodejs-client.QwpLong256Value.html create mode 100644 docs/interfaces/_questdb_nodejs-client.QwpNodeClientConfigOptions.html create mode 100644 docs/interfaces/_questdb_nodejs-client.QwpNodeClientOptions.html create mode 100644 docs/interfaces/_questdb_nodejs-client.QwpNodeEgressOptions.html create mode 100644 docs/interfaces/_questdb_nodejs-client.QwpNodeFileReplayStoreMetrics.html create mode 100644 docs/interfaces/_questdb_nodejs-client.QwpNodeFileReplayStoreOptions.html create mode 100644 docs/interfaces/_questdb_nodejs-client.QwpNodeIngressOptions.html create mode 100644 docs/interfaces/_questdb_nodejs-client.QwpNodeOrphanDrainEvent.html create mode 100644 docs/interfaces/_questdb_nodejs-client.QwpNodeOrphanDrainSession.html create mode 100644 docs/interfaces/_questdb_nodejs-client.QwpNodeOrphanDrainerMetrics.html create mode 100644 docs/interfaces/_questdb_nodejs-client.QwpNodeOrphanDrainerOptions.html create mode 100644 docs/interfaces/_questdb_nodejs-client.QwpNodeReplayDataLossReport.html create mode 100644 docs/interfaces/_questdb_nodejs-client.QwpNodeReplayRecoveryEvent.html create mode 100644 docs/interfaces/_questdb_nodejs-client.QwpNodeStoreAndForwardOptions.html create mode 100644 docs/interfaces/_questdb_nodejs-client.QwpNodeUdpMetrics.html create mode 100644 docs/interfaces/_questdb_nodejs-client.QwpNodeUdpOptions.html create mode 100644 docs/interfaces/_questdb_nodejs-client.QwpNodeUdpSocketLike.html create mode 100644 docs/interfaces/_questdb_nodejs-client.QwpNodeUpgradeRejection.html create mode 100644 docs/interfaces/_questdb_nodejs-client.QwpNodeWebSocketOptions.html create mode 100644 docs/interfaces/_questdb_nodejs-client.QwpPoolSlotReservation.html create mode 100644 docs/interfaces/_questdb_nodejs-client.QwpQueryErrorMessage.html create mode 100644 docs/interfaces/_questdb_nodejs-client.QwpQueryRequest.html create mode 100644 docs/interfaces/_questdb_nodejs-client.QwpReconnectEvent.html create mode 100644 docs/interfaces/_questdb_nodejs-client.QwpReconnectOptions.html create mode 100644 docs/interfaces/_questdb_nodejs-client.QwpResourcePoolMetrics.html create mode 100644 docs/interfaces/_questdb_nodejs-client.QwpResultArrayValue.html create mode 100644 docs/interfaces/_questdb_nodejs-client.QwpResultBatchMessage.html create mode 100644 docs/interfaces/_questdb_nodejs-client.QwpResultColumn.html create mode 100644 docs/interfaces/_questdb_nodejs-client.QwpResultColumnSchema.html create mode 100644 docs/interfaces/_questdb_nodejs-client.QwpResultEndMessage.html create mode 100644 docs/interfaces/_questdb_nodejs-client.QwpSenderEncodeOptions.html create mode 100644 docs/interfaces/_questdb_nodejs-client.QwpSenderError.html create mode 100644 docs/interfaces/_questdb_nodejs-client.QwpSenderErrorResponseContext.html create mode 100644 docs/interfaces/_questdb_nodejs-client.QwpSenderMetrics.html create mode 100644 docs/interfaces/_questdb_nodejs-client.QwpSenderOptions.html create mode 100644 docs/interfaces/_questdb_nodejs-client.QwpSenderSession.html create mode 100644 docs/interfaces/_questdb_nodejs-client.QwpServerInfoMessage.html create mode 100644 docs/interfaces/_questdb_nodejs-client.QwpSymbolValue.html create mode 100644 docs/interfaces/_questdb_nodejs-client.QwpUpgradeErrorDetails.html create mode 100644 docs/interfaces/_questdb_nodejs-client.QwpUuidValue.html create mode 100644 docs/interfaces/_questdb_nodejs-client.QwpWebSocketConnectOptions.html create mode 100644 docs/interfaces/_questdb_nodejs-client.QwpWebSocketLike.html create mode 100644 docs/interfaces/_questdb_nodejs-client.QwpWriterColumn.html create mode 100644 docs/interfaces/_questdb_nodejs-client.SenderBuffer.html create mode 100644 docs/interfaces/_questdb_nodejs-client.SenderTransport.html create mode 100644 docs/media/QWP.md create mode 100644 docs/modules/_questdb_browser-client.html create mode 100644 docs/modules/_questdb_nodejs-client.html delete mode 100644 docs/types/ExtraOptions.html delete mode 100644 docs/types/Logger.html delete mode 100644 docs/types/TimestampUnit.html create mode 100644 docs/types/_questdb_browser-client.QwpBindSetter.html create mode 100644 docs/types/_questdb_browser-client.QwpBindType.html create mode 100644 docs/types/_questdb_browser-client.QwpBrowserClientEgressOptions.html create mode 100644 docs/types/_questdb_browser-client.QwpBrowserClientIngressOptions.html create mode 100644 docs/types/_questdb_browser-client.QwpBrowserClientOptions.html create mode 100644 docs/types/_questdb_browser-client.QwpBrowserFetch.html create mode 100644 docs/types/_questdb_browser-client.QwpBrowserSessionAuthentication.html create mode 100644 docs/types/_questdb_browser-client.QwpBrowserSessionBootstrapConfig.html create mode 100644 docs/types/_questdb_browser-client.QwpColumnType.html create mode 100644 docs/types/_questdb_browser-client.QwpConnectionFactory.html create mode 100644 docs/types/_questdb_browser-client.QwpDecimalInput.html create mode 100644 docs/types/_questdb_browser-client.QwpDoubleArrayInput.html create mode 100644 docs/types/_questdb_browser-client.QwpEgressCompression.html create mode 100644 docs/types/_questdb_browser-client.QwpEgressMessage.html create mode 100644 docs/types/_questdb_browser-client.QwpGeohashInput.html create mode 100644 docs/types/_questdb_browser-client.QwpIngressProgressKind.html create mode 100644 docs/types/_questdb_browser-client.QwpInitialConnectMode.html create mode 100644 docs/types/_questdb_browser-client.QwpInt64.html create mode 100644 docs/types/_questdb_browser-client.QwpIpv4Input.html create mode 100644 docs/types/_questdb_browser-client.QwpLong256Input.html create mode 100644 docs/types/_questdb_browser-client.QwpLong256Words.html create mode 100644 docs/types/_questdb_browser-client.QwpLongArrayInput.html create mode 100644 docs/types/_questdb_browser-client.QwpNegotiatedEgressCompression.html create mode 100644 docs/types/_questdb_browser-client.QwpNestedLongArray.html create mode 100644 docs/types/_questdb_browser-client.QwpNestedNumberArray.html create mode 100644 docs/types/_questdb_browser-client.QwpQueryCompletion.html create mode 100644 docs/types/_questdb_browser-client.QwpReconnectEventKind.html create mode 100644 docs/types/_questdb_browser-client.QwpResultBatchViewHandler.html create mode 100644 docs/types/_questdb_browser-client.QwpResultRowViewCallback.html create mode 100644 docs/types/_questdb_browser-client.QwpResultValue.html create mode 100644 docs/types/_questdb_browser-client.QwpSenderErrorCategory.html create mode 100644 docs/types/_questdb_browser-client.QwpSenderErrorPolicy.html create mode 100644 docs/types/_questdb_browser-client.QwpSenderLogger.html create mode 100644 docs/types/_questdb_browser-client.QwpSenderSessionFactory.html create mode 100644 docs/types/_questdb_browser-client.QwpTarget.html create mode 100644 docs/types/_questdb_browser-client.QwpTimestampUnit.html create mode 100644 docs/types/_questdb_browser-client.QwpUpgradeErrorKind.html create mode 100644 docs/types/_questdb_browser-client.QwpUpgradeTimeoutPhase.html create mode 100644 docs/types/_questdb_browser-client.QwpUuidInput.html create mode 100644 docs/types/_questdb_browser-client.QwpWriterColumnKind.html create mode 100644 docs/types/_questdb_browser-client.QwpWriterRow.html create mode 100644 docs/types/_questdb_browser-client.QwpWriterSchema.html create mode 100644 docs/types/_questdb_nodejs-client.ExtraOptions.html create mode 100644 docs/types/_questdb_nodejs-client.Logger.html create mode 100644 docs/types/_questdb_nodejs-client.QwpBindSetter.html create mode 100644 docs/types/_questdb_nodejs-client.QwpBindType.html create mode 100644 docs/types/_questdb_nodejs-client.QwpColumnType.html create mode 100644 docs/types/_questdb_nodejs-client.QwpConnectionFactory.html create mode 100644 docs/types/_questdb_nodejs-client.QwpDecimalInput.html create mode 100644 docs/types/_questdb_nodejs-client.QwpDoubleArrayInput.html create mode 100644 docs/types/_questdb_nodejs-client.QwpEgressCompression.html create mode 100644 docs/types/_questdb_nodejs-client.QwpEgressMessage.html create mode 100644 docs/types/_questdb_nodejs-client.QwpExtraOptions.html create mode 100644 docs/types/_questdb_nodejs-client.QwpGeohashInput.html create mode 100644 docs/types/_questdb_nodejs-client.QwpIngressProgressKind.html create mode 100644 docs/types/_questdb_nodejs-client.QwpInitialConnectMode.html create mode 100644 docs/types/_questdb_nodejs-client.QwpInt64.html create mode 100644 docs/types/_questdb_nodejs-client.QwpIpv4Input.html create mode 100644 docs/types/_questdb_nodejs-client.QwpLong256Input.html create mode 100644 docs/types/_questdb_nodejs-client.QwpLong256Words.html create mode 100644 docs/types/_questdb_nodejs-client.QwpLongArrayInput.html create mode 100644 docs/types/_questdb_nodejs-client.QwpNegotiatedEgressCompression.html create mode 100644 docs/types/_questdb_nodejs-client.QwpNestedLongArray.html create mode 100644 docs/types/_questdb_nodejs-client.QwpNestedNumberArray.html create mode 100644 docs/types/_questdb_nodejs-client.QwpNodeOrphanDrainEventKind.html create mode 100644 docs/types/_questdb_nodejs-client.QwpQueryCompletion.html create mode 100644 docs/types/_questdb_nodejs-client.QwpReconnectEventKind.html create mode 100644 docs/types/_questdb_nodejs-client.QwpResultBatchViewHandler.html create mode 100644 docs/types/_questdb_nodejs-client.QwpResultRowViewCallback.html create mode 100644 docs/types/_questdb_nodejs-client.QwpResultValue.html create mode 100644 docs/types/_questdb_nodejs-client.QwpSenderErrorCategory.html create mode 100644 docs/types/_questdb_nodejs-client.QwpSenderErrorPolicy.html create mode 100644 docs/types/_questdb_nodejs-client.QwpSenderLogger.html create mode 100644 docs/types/_questdb_nodejs-client.QwpSenderSessionFactory.html create mode 100644 docs/types/_questdb_nodejs-client.QwpSfBackpressurePolicy.html create mode 100644 docs/types/_questdb_nodejs-client.QwpSfDurability.html create mode 100644 docs/types/_questdb_nodejs-client.QwpTarget.html create mode 100644 docs/types/_questdb_nodejs-client.QwpTimestampUnit.html create mode 100644 docs/types/_questdb_nodejs-client.QwpUpgradeErrorKind.html create mode 100644 docs/types/_questdb_nodejs-client.QwpUpgradeTimeoutPhase.html create mode 100644 docs/types/_questdb_nodejs-client.QwpUuidInput.html create mode 100644 docs/types/_questdb_nodejs-client.QwpWriterColumnKind.html create mode 100644 docs/types/_questdb_nodejs-client.QwpWriterRow.html create mode 100644 docs/types/_questdb_nodejs-client.QwpWriterSchema.html create mode 100644 docs/types/_questdb_nodejs-client.TimestampUnit.html create mode 100644 docs/variables/_questdb_browser-client.QWP_COLUMN_TYPE.html create mode 100644 docs/variables/_questdb_browser-client.QWP_COMPRESSION_CODEC.html create mode 100644 docs/variables/_questdb_browser-client.QWP_DECIMAL_MAX_SCALE.html create mode 100644 docs/variables/_questdb_browser-client.QWP_DEFAULT_EGRESS_BUFFER_POOL_SIZE.html create mode 100644 docs/variables/_questdb_browser-client.QWP_DEFAULT_EGRESS_INITIAL_CREDIT.html create mode 100644 docs/variables/_questdb_browser-client.QWP_DEFAULT_EGRESS_SERVER_INFO_TIMEOUT_MS.html create mode 100644 docs/variables/_questdb_browser-client.QWP_DURABLE_ACK_WEBSOCKET_PROTOCOL.html create mode 100644 docs/variables/_questdb_browser-client.QWP_EGRESS_CAPABILITY.html create mode 100644 docs/variables/_questdb_browser-client.QWP_EGRESS_MESSAGE.html create mode 100644 docs/variables/_questdb_browser-client.QWP_EGRESS_PATH.html create mode 100644 docs/variables/_questdb_browser-client.QWP_ENCODING_GORILLA.html create mode 100644 docs/variables/_questdb_browser-client.QWP_ENCODING_UNCOMPRESSED.html create mode 100644 docs/variables/_questdb_browser-client.QWP_FLAG_DEFER_COMMIT.html create mode 100644 docs/variables/_questdb_browser-client.QWP_FLAG_DELTA_SYMBOL_DICTIONARY.html create mode 100644 docs/variables/_questdb_browser-client.QWP_FLAG_DURABLE_ACK_POLL.html create mode 100644 docs/variables/_questdb_browser-client.QWP_FLAG_GORILLA.html create mode 100644 docs/variables/_questdb_browser-client.QWP_FLAG_ZSTD.html create mode 100644 docs/variables/_questdb_browser-client.QWP_HEADER_SIZE.html create mode 100644 docs/variables/_questdb_browser-client.QWP_INGRESS_PATH.html create mode 100644 docs/variables/_questdb_browser-client.QWP_INGRESS_PROGRESS_KIND.html create mode 100644 docs/variables/_questdb_browser-client.QWP_INITIAL_CONNECT_MODE.html create mode 100644 docs/variables/_questdb_browser-client.QWP_MAGIC.html create mode 100644 docs/variables/_questdb_browser-client.QWP_MAX_ARRAY_DIMENSIONS.html create mode 100644 docs/variables/_questdb_browser-client.QWP_MAX_ARRAY_DIMENSION_LENGTH.html create mode 100644 docs/variables/_questdb_browser-client.QWP_MAX_BATCH_ROWS_UPPER_BOUND.html create mode 100644 docs/variables/_questdb_browser-client.QWP_MAX_CELLS_PER_BATCH.html create mode 100644 docs/variables/_questdb_browser-client.QWP_MAX_COLUMNS_PER_TABLE.html create mode 100644 docs/variables/_questdb_browser-client.QWP_MAX_COLUMN_NAME_LENGTH.html create mode 100644 docs/variables/_questdb_browser-client.QWP_MAX_ERROR_MESSAGE_LENGTH.html create mode 100644 docs/variables/_questdb_browser-client.QWP_MAX_IDENTIFIER_BYTES.html create mode 100644 docs/variables/_questdb_browser-client.QWP_MAX_ROWS_PER_TABLE.html create mode 100644 docs/variables/_questdb_browser-client.QWP_MAX_SYMBOL_DICTIONARY_SIZE.html create mode 100644 docs/variables/_questdb_browser-client.QWP_MAX_TABLE_NAME_LENGTH.html create mode 100644 docs/variables/_questdb_browser-client.QWP_MAX_ZSTD_DECOMPRESSED_SIZE.html create mode 100644 docs/variables/_questdb_browser-client.QWP_QUERY_FLAG_RESET_DICTIONARY.html create mode 100644 docs/variables/_questdb_browser-client.QWP_RECONNECT_EVENT_KIND.html create mode 100644 docs/variables/_questdb_browser-client.QWP_RESET_MASK_DICTIONARY.html create mode 100644 docs/variables/_questdb_browser-client.QWP_SENDER_ERROR_CATEGORY.html create mode 100644 docs/variables/_questdb_browser-client.QWP_SENDER_ERROR_POLICY.html create mode 100644 docs/variables/_questdb_browser-client.QWP_SERVER_ROLE.html create mode 100644 docs/variables/_questdb_browser-client.QWP_STATUS.html create mode 100644 docs/variables/_questdb_browser-client.QWP_TARGET.html create mode 100644 docs/variables/_questdb_browser-client.QWP_UPGRADE_ERROR_KIND.html create mode 100644 docs/variables/_questdb_browser-client.QWP_UPGRADE_TIMEOUT_PHASE.html create mode 100644 docs/variables/_questdb_browser-client.QWP_VERSION.html create mode 100644 docs/variables/_questdb_browser-client.QWP_ZSTD_MAX_COMPRESSION_LEVEL.html create mode 100644 docs/variables/_questdb_browser-client.QWP_ZSTD_MIN_COMPRESSION_LEVEL.html create mode 100644 docs/variables/_questdb_nodejs-client.QWP_COLUMN_TYPE.html create mode 100644 docs/variables/_questdb_nodejs-client.QWP_COMPRESSION_CODEC.html create mode 100644 docs/variables/_questdb_nodejs-client.QWP_DECIMAL_MAX_SCALE.html create mode 100644 docs/variables/_questdb_nodejs-client.QWP_DEFAULT_EGRESS_BUFFER_POOL_SIZE.html create mode 100644 docs/variables/_questdb_nodejs-client.QWP_DEFAULT_EGRESS_INITIAL_CREDIT.html create mode 100644 docs/variables/_questdb_nodejs-client.QWP_DEFAULT_EGRESS_SERVER_INFO_TIMEOUT_MS.html create mode 100644 docs/variables/_questdb_nodejs-client.QWP_DURABLE_ACK_WEBSOCKET_PROTOCOL.html create mode 100644 docs/variables/_questdb_nodejs-client.QWP_EGRESS_CAPABILITY.html create mode 100644 docs/variables/_questdb_nodejs-client.QWP_EGRESS_MESSAGE.html create mode 100644 docs/variables/_questdb_nodejs-client.QWP_EGRESS_PATH.html create mode 100644 docs/variables/_questdb_nodejs-client.QWP_ENCODING_GORILLA.html create mode 100644 docs/variables/_questdb_nodejs-client.QWP_ENCODING_UNCOMPRESSED.html create mode 100644 docs/variables/_questdb_nodejs-client.QWP_FLAG_DEFER_COMMIT.html create mode 100644 docs/variables/_questdb_nodejs-client.QWP_FLAG_DELTA_SYMBOL_DICTIONARY.html create mode 100644 docs/variables/_questdb_nodejs-client.QWP_FLAG_DURABLE_ACK_POLL.html create mode 100644 docs/variables/_questdb_nodejs-client.QWP_FLAG_GORILLA.html create mode 100644 docs/variables/_questdb_nodejs-client.QWP_FLAG_ZSTD.html create mode 100644 docs/variables/_questdb_nodejs-client.QWP_HEADER_SIZE.html create mode 100644 docs/variables/_questdb_nodejs-client.QWP_INGRESS_PATH.html create mode 100644 docs/variables/_questdb_nodejs-client.QWP_INGRESS_PROGRESS_KIND.html create mode 100644 docs/variables/_questdb_nodejs-client.QWP_INITIAL_CONNECT_MODE.html create mode 100644 docs/variables/_questdb_nodejs-client.QWP_MAGIC.html create mode 100644 docs/variables/_questdb_nodejs-client.QWP_MAX_ARRAY_DIMENSIONS.html create mode 100644 docs/variables/_questdb_nodejs-client.QWP_MAX_ARRAY_DIMENSION_LENGTH.html create mode 100644 docs/variables/_questdb_nodejs-client.QWP_MAX_BATCH_ROWS_UPPER_BOUND.html create mode 100644 docs/variables/_questdb_nodejs-client.QWP_MAX_CELLS_PER_BATCH.html create mode 100644 docs/variables/_questdb_nodejs-client.QWP_MAX_COLUMNS_PER_TABLE.html create mode 100644 docs/variables/_questdb_nodejs-client.QWP_MAX_COLUMN_NAME_LENGTH.html create mode 100644 docs/variables/_questdb_nodejs-client.QWP_MAX_ERROR_MESSAGE_LENGTH.html create mode 100644 docs/variables/_questdb_nodejs-client.QWP_MAX_IDENTIFIER_BYTES.html create mode 100644 docs/variables/_questdb_nodejs-client.QWP_MAX_ROWS_PER_TABLE.html create mode 100644 docs/variables/_questdb_nodejs-client.QWP_MAX_SYMBOL_DICTIONARY_SIZE.html create mode 100644 docs/variables/_questdb_nodejs-client.QWP_MAX_TABLE_NAME_LENGTH.html create mode 100644 docs/variables/_questdb_nodejs-client.QWP_MAX_ZSTD_DECOMPRESSED_SIZE.html create mode 100644 docs/variables/_questdb_nodejs-client.QWP_ORPHAN_DRAIN_EVENT_KIND.html create mode 100644 docs/variables/_questdb_nodejs-client.QWP_ORPHAN_FAILED_SENTINEL.html create mode 100644 docs/variables/_questdb_nodejs-client.QWP_QUERY_FLAG_RESET_DICTIONARY.html create mode 100644 docs/variables/_questdb_nodejs-client.QWP_RECONNECT_EVENT_KIND.html create mode 100644 docs/variables/_questdb_nodejs-client.QWP_RESET_MASK_DICTIONARY.html create mode 100644 docs/variables/_questdb_nodejs-client.QWP_SENDER_ERROR_CATEGORY.html create mode 100644 docs/variables/_questdb_nodejs-client.QWP_SENDER_ERROR_POLICY.html create mode 100644 docs/variables/_questdb_nodejs-client.QWP_SERVER_ROLE.html create mode 100644 docs/variables/_questdb_nodejs-client.QWP_SF_BACKPRESSURE_POLICY.html create mode 100644 docs/variables/_questdb_nodejs-client.QWP_SF_DURABILITY.html create mode 100644 docs/variables/_questdb_nodejs-client.QWP_STATUS.html create mode 100644 docs/variables/_questdb_nodejs-client.QWP_TARGET.html create mode 100644 docs/variables/_questdb_nodejs-client.QWP_UPGRADE_ERROR_KIND.html create mode 100644 docs/variables/_questdb_nodejs-client.QWP_UPGRADE_TIMEOUT_PHASE.html create mode 100644 docs/variables/_questdb_nodejs-client.QWP_VERSION.html create mode 100644 docs/variables/_questdb_nodejs-client.QWP_ZSTD_MAX_COMPRESSION_LEVEL.html create mode 100644 docs/variables/_questdb_nodejs-client.QWP_ZSTD_MIN_COMPRESSION_LEVEL.html create mode 100644 packages/browser-client/README.md create mode 100644 packages/browser-client/THIRD_PARTY_NOTICES.md create mode 100644 packages/browser-client/package.json rename src/qwp/browser.ts => packages/browser-client/src/index.ts (96%) rename tsconfig.qwp-browser.json => packages/browser-client/tsconfig.json (62%) create mode 100644 packages/browser-client/typedoc.json create mode 100644 packages/client-core/package.json rename {src => packages/client-core/src}/_qwp/_core/binds.ts (100%) rename {src => packages/client-core/src}/_qwp/_core/bytes.ts (100%) rename {src => packages/client-core/src}/_qwp/_core/compression.ts (100%) rename {src => packages/client-core/src}/_qwp/_core/constants.ts (100%) rename {src => packages/client-core/src}/_qwp/_core/durable-ack.ts (100%) rename {src => packages/client-core/src}/_qwp/_core/egress.ts (100%) rename {src => packages/client-core/src}/_qwp/_core/errors.ts (100%) rename {src => packages/client-core/src}/_qwp/_core/frame.ts (100%) rename {src => packages/client-core/src}/_qwp/_core/gorilla.ts (100%) rename {src => packages/client-core/src}/_qwp/_core/identifiers.ts (100%) rename {src => packages/client-core/src}/_qwp/_core/index.ts (100%) rename {src => packages/client-core/src}/_qwp/_core/ingress.ts (100%) rename {src => packages/client-core/src}/_qwp/_core/result-batch.ts (100%) rename {src => packages/client-core/src}/_qwp/_core/symbol-dictionary.ts (100%) rename {src => packages/client-core/src}/_qwp/_core/table.ts (100%) rename {src => packages/client-core/src}/_qwp/_core/varint.ts (100%) rename {src => packages/client-core/src}/_qwp/_core/zstd.ts (100%) rename {src => packages/client-core/src}/_qwp/_internal/async-queue.ts (100%) rename {src => packages/client-core/src}/_qwp/_internal/egress-limits.ts (100%) rename {src => packages/client-core/src}/_qwp/_internal/egress-routing.ts (100%) rename {src => packages/client-core/src}/_qwp/_internal/failover.ts (100%) rename {src => packages/client-core/src}/_qwp/_internal/notification-dispatcher.ts (100%) rename {src => packages/client-core/src}/_qwp/_internal/reconnect-backoff.ts (100%) rename {src => packages/client-core/src}/_qwp/_internal/reconnecting-egress-connection.ts (100%) rename {src => packages/client-core/src}/_qwp/_internal/reconnecting-ingress-connection.ts (100%) rename {src => packages/client-core/src}/_qwp/_internal/safe-callback.ts (100%) rename {src => packages/client-core/src}/_qwp/_internal/websocket-connection.ts (100%) rename {src => packages/client-core/src}/_qwp/client.ts (100%) rename {src => packages/client-core/src}/_qwp/egress-session.ts (100%) rename {src => packages/client-core/src}/_qwp/ingress-session.ts (100%) rename {src => packages/client-core/src}/_qwp/sender-error.ts (100%) rename {src => packages/client-core/src}/_qwp/sender.ts (100%) rename {src => packages/client-core/src}/_qwp/transport.ts (100%) rename {src => packages/client-core/src}/_qwp/writer.ts (96%) rename {src => packages/client-core/src}/logging.ts (100%) rename {src => packages/client-core/src}/qwp/index.ts (87%) create mode 100644 packages/client-core/tsconfig.json create mode 100644 packages/nodejs-client/README.md create mode 100644 packages/nodejs-client/THIRD_PARTY_NOTICES.md create mode 100644 packages/nodejs-client/package.json rename {src => packages/nodejs-client/src}/buffer/base.ts (100%) rename {src => packages/nodejs-client/src}/buffer/bufferv1.ts (100%) rename {src => packages/nodejs-client/src}/buffer/bufferv2.ts (100%) rename {src => packages/nodejs-client/src}/buffer/bufferv3.ts (100%) rename {src => packages/nodejs-client/src}/buffer/index.ts (100%) rename {src => packages/nodejs-client/src}/index.ts (88%) create mode 100644 packages/nodejs-client/src/logging.ts rename {src => packages/nodejs-client/src}/options.ts (99%) rename {src => packages/nodejs-client/src}/qwp-node/advisory-lock.ts (100%) rename {src => packages/nodejs-client/src}/qwp-node/client-config.ts (98%) rename {src => packages/nodejs-client/src}/qwp-node/file-replay-store.ts (99%) rename {src => packages/nodejs-client/src}/qwp-node/orphan-drainer.ts (98%) rename {src => packages/nodejs-client/src}/qwp-node/segment-maintenance-worker.ts (100%) rename {src => packages/nodejs-client/src}/qwp-node/udp-sender.ts (98%) rename src/qwp/node.ts => packages/nodejs-client/src/qwp.ts (96%) rename {src => packages/nodejs-client/src}/sender.ts (99%) rename {src => packages/nodejs-client/src}/transport/http/base.ts (100%) rename {src => packages/nodejs-client/src}/transport/http/stdlib.ts (100%) rename {src => packages/nodejs-client/src}/transport/http/undici.ts (100%) rename {src => packages/nodejs-client/src}/transport/index.ts (100%) rename {src => packages/nodejs-client/src}/transport/tcp.ts (100%) rename {src => packages/nodejs-client/src}/utils.ts (100%) rename {src => packages/nodejs-client/src}/validation.ts (100%) create mode 100644 packages/nodejs-client/tsconfig.json create mode 100644 packages/nodejs-client/typedoc.json create mode 100644 pnpm-workspace.yaml create mode 100644 scripts/clean-package-dist.mjs create mode 100644 test/package-boundaries.e2e.ts diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4d67c3c..2bf71e5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -32,7 +32,7 @@ jobs: - uses: pnpm/action-setup@v4 with: - version: 9 + version: 10.12.4 run_install: true - name: Linting @@ -41,10 +41,8 @@ jobs: - name: Type-checking run: pnpm typecheck - # tsconfig.qwp-browser.json is where the browser contract lives: strict, - # no @types/node, DOM lib, over src/qwp/** minus node.ts. `pnpm typecheck` - # does not cover it, so without this step nothing stops a Node built-in or - # a strict-null violation reaching the browser entry point. + # This contract uses DOM libraries with no @types/node and covers only + # the source graph shipped in @questdb/browser-client. - name: Type-checking (browser) run: pnpm typecheck:qwp-browser @@ -64,16 +62,14 @@ jobs: run: pnpm test # Loads the built bundles through package.json `exports`, the way a - # consumer does. Every other suite imports from `src/`, where all four - # entry points share one module instance and cross-bundle defects are - # invisible. + # consumer does, and verifies the two npm package boundaries. - name: Built package tests run: pnpm test:dist # test:dist asserts runtime behaviour only. The compiled writers promise # per-column row typing, which lives entirely in the emitted .d.ts files - # and is therefore invisible to every suite that imports from `src/`, - # where all four entry points share one module instance. + # and is therefore invisible to source-level tests, where the public + # entries share one private-core module instance. - name: Type-checking (built package consumer) run: pnpm typecheck:dist @@ -101,7 +97,7 @@ jobs: - uses: pnpm/action-setup@v4 with: - version: 9 + version: 10.12.4 run_install: true - name: Install Chromium diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 1c91ae3..3a1fcd2 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -20,7 +20,7 @@ jobs: - uses: pnpm/action-setup@v4 with: - version: 9 + version: 10.12.4 run_install: true - name: Linting @@ -29,10 +29,8 @@ jobs: - name: Type-checking run: pnpm typecheck - # tsconfig.qwp-browser.json is where the browser contract lives: strict, - # no @types/node, DOM lib, over src/qwp/** minus node.ts. `pnpm typecheck` - # does not cover it, so without this step nothing stops a Node built-in or - # a strict-null violation reaching the browser entry point. + # This contract uses DOM libraries with no @types/node and covers only + # the source graph shipped in @questdb/browser-client. - name: Type-checking (browser) run: pnpm typecheck:qwp-browser @@ -42,24 +40,32 @@ jobs: - name: Build run: pnpm build + - name: Built package tests + run: pnpm exec vitest run --config vitest.dist.config.ts + # Guards the emitted .d.ts row typing, which no runtime suite can see. - name: Type-checking (built package consumer) run: | pnpm exec tsc --noEmit -p tsconfig.dist-types.json pnpm exec tsc --noEmit -p tsconfig.dist-types.cjs.json - # Every subpath in `exports`, not just the root: a publish that omits - # ./qwp, ./qwp/browser or ./qwp/node resolves to nothing for consumers. - # Entry bundles also import shared chunks that no `exports` entry names, - # so the walk follows relative imports: a chunk left out of `files` - # publishes a package whose every entry resolves to a missing file. + # Checks both npm tarballs and follows every exported declaration and + # runtime module through its relative dependency graph. - name: Check for build artifacts run: node scripts/check-build-artifacts.mjs - - name: Publish + - name: Publish @questdb/nodejs-client + uses: JS-DevTools/npm-publish@v3 + with: + token: ${{ secrets.CI_TOKEN }} + access: public + strategy: all + package: packages/nodejs-client/package.json + + - name: Publish @questdb/browser-client uses: JS-DevTools/npm-publish@v3 with: token: ${{ secrets.CI_TOKEN }} access: public strategy: all - package: package.json + package: packages/browser-client/package.json diff --git a/CLAUDE.md b/CLAUDE.md index c18ca40..827911d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,21 +4,26 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -This is the QuestDB JavaScript client library (@questdb/nodejs-client) that provides data ingestion capabilities to QuestDB databases. The client supports multiple transport protocols (HTTP/HTTPS, TCP/TCPS) and authentication methods. +This repository builds the QuestDB JavaScript clients: `@questdb/nodejs-client` +for Node.js and `@questdb/browser-client` for browsers. A private +`@questdb/client-core` workspace package owns their shared implementation. ## Development Commands ### Build + ```bash -pnpm build # Build the library using bunchee (produces both ESM and CJS outputs) +pnpm build # Build both public ESM/CJS packages from client-core ``` ### Testing + ```bash pnpm test # Run all tests using Vitest ``` ### Code Quality + ```bash pnpm eslint # Run ESLint on all source files pnpm typecheck # Run TypeScript type checking without emitting files @@ -26,8 +31,9 @@ pnpm format # Format code using Prettier ``` ### Documentation + ```bash -pnpm docs # Build JSDoc documentation +pnpm run docs # Build TypeDoc documentation pnpm preview:docs # Preview generated documentation locally ``` @@ -35,20 +41,30 @@ pnpm preview:docs # Preview generated documentation locally ### Core Components -1. **Sender** (`src/sender.ts`): Main API class that orchestrates data ingestion. Handles auto-flushing, connection management, and provides the builder pattern API for constructing rows. +Runtime-neutral QWP implementation lives under `packages/client-core/src`. +Each public package owns its runtime-specific source, package metadata, +documentation, and single root build. + +1. **Sender** (`packages/nodejs-client/src/sender.ts`): Main Node.js API class that orchestrates data ingestion. Handles auto-flushing, connection management, and provides the builder pattern API for constructing rows. + +2. **Transport Layer** (`packages/nodejs-client/src/transport/`): -2. **Transport Layer** (`src/transport/`): - `http/undici.ts`: Default HTTP transport using Undici library for high performance - `http/stdlib.ts`: Alternative HTTP transport using Node.js built-in modules - `tcp.ts`: TCP/TCPS transport for persistent connections with JWK authentication - Protocol negotiation and retry logic for HTTP transports -3. **Buffer System** (`src/buffer/`): +3. **Buffer System** (`packages/nodejs-client/src/buffer/`): + - `bufferv1.ts`: Text-based protocol (version 1) for backward compatibility - `bufferv2.ts`: Binary protocol (version 2) with double encoding and array support - Dynamic buffer resizing and row-level transaction support -4. **Configuration** (`src/options.ts`): Comprehensive options parsing from connection strings with validation and deprecation handling. +4. **Configuration** (`packages/nodejs-client/src/options.ts`): Comprehensive options parsing from connection strings with validation and deprecation handling. + +5. **QWP core** (`packages/client-core/src/qwp/` and `packages/client-core/src/_qwp/`): Shared browser-safe protocol/session code. + +6. **Runtime adapters**: `packages/nodejs-client/src/qwp.ts` owns Node WebSocket, UDP, TLS, and persistence support; `packages/browser-client/src/index.ts` owns the browser WebSocket and authentication adapter. The browser package must remain free of Node built-ins, Node typings, `undici`, and `ws`. ### Protocol Versions @@ -66,6 +82,7 @@ pnpm preview:docs # Preview generated documentation locally ## Testing Strategy Tests are organized by component: + - `sender.config.test.ts`: Configuration parsing and validation - `sender.buffer.test.ts`: Buffer operations and protocol serialization - `sender.transport.test.ts`: Transport layer functionality @@ -80,4 +97,5 @@ Integration tests use TestContainers to spin up QuestDB instances for realistic - Buffer automatically resizes up to `max_buf_size` (default 100MB) - Auto-flush triggers based on row count or time interval - Each worker thread needs its own Sender instance (buffers cannot be shared) -- Protocol version 2 is recommended for new implementations with array column support \ No newline at end of file +- Protocol version 2 is recommended for new implementations with array column support +- Run `pnpm test:dist` after package-boundary changes; it checks both npm tarballs, ESM/CJS loading, browser bundling, and the absence of Node modules from the browser artifact. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 563e1b1..a31bca5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,26 +1,29 @@ -# Contributing to nodejs-questdb-client +# Contributing to the QuestDB JavaScript Client -Thank you for your interest in contributing to nodejs-questdb-client! This document provides guidelines and instructions for contributing to the project. +Thank you for your interest in contributing to the QuestDB JavaScript Client! +This repository contains both the Node.js and browser npm packages. ## Development Setup 1. Fork and clone the repository: + ```bash git clone https://github.com/YOUR_USERNAME/nodejs-questdb-client.git cd nodejs-questdb-client ``` 2. Install dependencies: + ```bash pnpm install ``` - ## Running Tests The project uses Vitest for testing. Tests are located in the `test` directory. 1. Run tests in watch mode during development: + ```bash pnpm run test ``` @@ -30,8 +33,7 @@ pnpm run test - Some tests use mock servers and certificates located in the `test/certs` directory > You can generate the certificates by running the `generateCerts.sh` script in the `scripts` directory. The script requires two arguments: the output directory and the password for the certificates. -`./scripts/generateCerts.sh . questdbPwd123` - +> `./scripts/generateCerts.sh . questdbPwd123` ## Code Style and Quality @@ -40,23 +42,27 @@ pnpm run test 2. Format your code using Prettier 3. Lint your code: + ```bash -pnpm run lint +pnpm eslint ``` 4. Fix linting issues: + ```bash -pnpm run lint --fix +pnpm eslint --fix ``` ## Making Changes 1. Create a new branch for your changes: + ```bash git checkout -b feature/your-feature-name ``` 2. Make your changes and commit them with clear, descriptive commit messages: + ```bash git add . git commit -m "feat: add new feature" @@ -65,6 +71,7 @@ git commit -m "feat: add new feature" We follow the [Conventional Commits](https://www.conventionalcommits.org/) specification for commit messages. 3. Push your changes to your fork: + ```bash git push origin feature/your-feature-name ``` @@ -88,10 +95,10 @@ git push origin feature/your-feature-name ## Need Help? If you have questions or need help, you can: + - Open an issue with your question - Join our community discussions (if available) ## License -By contributing to nodejs-questdb-client, you agree that your contributions will be licensed under the project's license. - +By contributing to the QuestDB JavaScript Client, you agree that your contributions will be licensed under the project's license. diff --git a/QWP.md b/QWP.md index 90d210f..927af9e 100644 --- a/QWP.md +++ b/QWP.md @@ -5,31 +5,23 @@ It describes the supported public entry points, delivery semantics, authenticati failure handling, and migration from the existing Node.js sender and the low-level QWP API. -QWP support is currently a preview. The documented exports are the compatibility -baseline for the first QWP release, but may still change before that release. Once -released, changes to this documented surface follow the package's semantic-versioning -policy. Imports from internal source paths are never supported. +The documented exports are the public compatibility baseline. Changes to this +surface follow the package's semantic-versioning policy. Imports from internal +source paths are never supported. ## Choose an entry point -| Entry point | Runtime | Use it for | -| ------------------------------------ | ------------------ | ----------------------------------------------------------------------------------------- | -| `@questdb/nodejs-client` | Node.js | Existing `Sender`, including QWP ingress selected with `ws::`, `wss::`, or `udp::` | -| `@questdb/nodejs-client/qwp/browser` | Browser | Browser-safe QWP ingress, egress, authentication bootstrap, sessions, and codecs | -| `@questdb/nodejs-client/qwp/node` | Node.js | QWP ingress and egress with upgrade headers, TLS agents, and persistent store-and-forward | -| `@questdb/nodejs-client/qwp` | Browser or Node.js | Shared protocol codecs and low-level session abstractions for advanced integrations | - -The three QWP subpaths are declared both in `exports` and in `typesVersions`, so -they resolve under every TypeScript `moduleResolution` setting, including the -legacy `node10` that `module: "commonjs"` still implies by default. Keep the two -declarations in step: `exports` alone leaves a `node10` consumer with -`TS2307: Cannot find module '@questdb/nodejs-client/qwp/node'` at compile time -while the same import works perfectly at runtime. - -Do not import the package root from browser code. It retains the existing Node.js -transports and dependencies for backward compatibility. The browser entry point has -no Node.js imports. Node-only features remain in `qwp/node`, so supporting browsers -does not require redesigning or breaking the existing client. +| Package | Runtime | Use it for | +| ------------------------- | ------- | --------------------------------------------------------------------------------------------------- | +| `@questdb/nodejs-client` | Node.js | Existing `Sender`, QWP codecs, WebSocket/UDP ingress, egress, TLS, and persistent store-and-forward | +| `@questdb/browser-client` | Browser | Browser-safe QWP ingress, egress, authentication bootstrap, sessions, and codecs | + +Each distribution exposes its complete API directly from its package root. + +Browser applications should install and import `@questdb/browser-client`. Its +published module graph has no Node.js imports, Node.js typings, Node engine +requirement, `undici`, or `ws`. Node-only transports and persistence remain in +`@questdb/nodejs-client`. QWP uses `/write/v4` for ingress and `/read/v1` for egress. A server must expose these WebSocket routes; optional features are enabled only when negotiation confirms @@ -222,7 +214,7 @@ self-contained, contains exactly one table, and uses an inline schema plus table-local symbol dictionaries. Batches are split at row boundaries; `QwpUdpDatagramTooLargeError` is raised before transmission when one row cannot fit. `connectQwpNodeUdpSender()` and `connectQwpNodeUdp()` expose the same -transport from `qwp/node`. +transport from `@questdb/nodejs-client`. UDP provides no authentication, TLS, server or durable ACK, transactions, reconnection, compression, or store-and-forward. Local socket errors are delivered @@ -510,8 +502,8 @@ Use `QwpSender` directly when QWP-only column types or detailed session controls needed: ```typescript -import * as qwp from "@questdb/nodejs-client/qwp"; -import { connectQwpNodeSender } from "@questdb/nodejs-client/qwp/node"; +import * as qwp from "@questdb/nodejs-client"; +import { connectQwpNodeSender } from "@questdb/nodejs-client"; const sender = await connectQwpNodeSender( { @@ -742,7 +734,7 @@ Browser applications must use the browser entry point and a same-origin WebSocke route (directly or through a reverse proxy): ```typescript -import { connectQwpBrowserSender } from "@questdb/nodejs-client/qwp/browser"; +import { connectQwpBrowserSender } from "@questdb/browser-client"; const url = new URL("/write/v4", location.href); url.protocol = location.protocol === "https:" ? "wss:" : "ws:"; @@ -765,7 +757,7 @@ cookies over REST before opening the WebSocket: import { bootstrapQwpBrowserSession, connectQwpBrowserSender, -} from "@questdb/nodejs-client/qwp/browser"; +} from "@questdb/browser-client"; await bootstrapQwpBrowserSession({ url: new URL("/exec", location.href), @@ -911,7 +903,7 @@ Use immutable metrics snapshots for polling and callbacks for event-driven telem import { QWP_INGRESS_PROGRESS_KIND, createQwpNodeSender, -} from "@questdb/nodejs-client/qwp/node"; +} from "@questdb/nodejs-client"; const sender = createQwpNodeSender( { url: "ws://localhost:9000/write/v4" }, @@ -972,7 +964,7 @@ QWP egress streams typed result batches. One connection executes one active quer a time. ```typescript -import { connectQwpNodeEgress } from "@questdb/nodejs-client/qwp/node"; +import { connectQwpNodeEgress } from "@questdb/nodejs-client"; const session = await connectQwpNodeEgress( { @@ -1195,7 +1187,7 @@ retrying initial connection establishment. Browser egress uses the same session API: ```typescript -import { connectQwpBrowserEgress } from "@questdb/nodejs-client/qwp/browser"; +import { connectQwpBrowserEgress } from "@questdb/browser-client"; const readUrl = new URL("/read/v1", location.href); readUrl.protocol = location.protocol === "https:" ? "wss:" : "ws:"; @@ -1227,7 +1219,7 @@ authentication and TLS configuration to both sides, and validates ingress, egress, and pool settings before opening a socket: ```typescript -import { connectQwpNodeClient } from "@questdb/nodejs-client/qwp/node"; +import { connectQwpNodeClient } from "@questdb/nodejs-client"; const db = await connectQwpNodeClient( "wss::" + @@ -1283,7 +1275,7 @@ The object form remains available for cases where constructing the two sides separately is useful: ```typescript -import { connectQwpNodeClient } from "@questdb/nodejs-client/qwp/node"; +import { connectQwpNodeClient } from "@questdb/nodejs-client"; const db = await connectQwpNodeClient({ ingress: { @@ -1348,7 +1340,7 @@ parameters. Omit `sessionBootstrap.url` to derive the matching `/exec` route for every failover endpoint: ```typescript -import { connectQwpBrowserClient } from "@questdb/nodejs-client/qwp/browser"; +import { connectQwpBrowserClient } from "@questdb/browser-client"; const db = await connectQwpBrowserClient({ cluster: { diff --git a/README.md b/README.md index 86b4863..559f824 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,10 @@ +# QuestDB JavaScript Client + +This repository builds two runtime-specific npm packages from a shared private +core: `@questdb/nodejs-client` for Node.js and `@questdb/browser-client` for +browsers. The browser package exposes its complete API from its package root and +does not include Node.js transports or dependencies. + ## Installation ```shell @@ -11,6 +18,12 @@ yarn add @questdb/nodejs-client pnpm add @questdb/nodejs-client ``` +For browser applications: + +```shell +npm install @questdb/browser-client +``` + ## Compatibility table | QuestDB client version | Supported Node.js versions | Default HTTP Agent | @@ -28,12 +41,12 @@ Use the stdlib_http option to switch to the standard HTTP/HTTPS modules. ## Configuration options Detailed description of the client's configuration options can be found in -the {@link index.SenderOptions | SenderOptions} documentation. +the [SenderOptions documentation](https://questdb.github.io/nodejs-questdb-client/classes/_questdb_nodejs-client.SenderOptions.html). ## Examples The examples below demonstrate how to use the client.
    -For more details, please, check the {@link index.Sender | Sender}'s documentation. +For more details, see the [Sender documentation](https://questdb.github.io/nodejs-questdb-client/classes/_questdb_nodejs-client.Sender.html). ### Basic API usage @@ -148,7 +161,7 @@ validates each complete row before changing sender state and accepts both indivi rows and synchronous or asynchronous iterables: ```typescript -import * as qwp from "@questdb/nodejs-client/qwp"; +import * as qwp from "@questdb/nodejs-client"; const trades = sender.writer("trades", { symbol: qwp.symbol(), @@ -233,7 +246,7 @@ retain unacknowledged frames in memory; set `reconnect: false` in the session op for a fixed connection. Only Node store-and-forward survives process failure. ```typescript -import { connectQwpBrowserSender } from "@questdb/nodejs-client/qwp/browser"; +import { connectQwpBrowserSender } from "@questdb/browser-client"; const url = new URL("/write/v4", location.href); url.protocol = location.protocol === "https:" ? "wss:" : "ws:"; @@ -293,7 +306,7 @@ connection topology separate from batch acceptance and durable progress. import { QWP_INGRESS_PROGRESS_KIND, createQwpBrowserSender, -} from "@questdb/nodejs-client/qwp/browser"; +} from "@questdb/browser-client"; const sender = createQwpBrowserSender( { url }, @@ -351,7 +364,7 @@ does not run an interactive OIDC authorization flow. import { bootstrapQwpBrowserSession, connectQwpBrowserSender, -} from "@questdb/nodejs-client/qwp/browser"; +} from "@questdb/browser-client"; await bootstrapQwpBrowserSession({ url: new URL("/exec", location.href), @@ -420,7 +433,7 @@ Node.js egress clients can opt into compressed result batches during the WebSocket upgrade. Raw batches remain the default for compatibility. ```typescript -import { connectQwpNodeEgress } from "@questdb/nodejs-client/qwp/node"; +import { connectQwpNodeEgress } from "@questdb/nodejs-client"; const session = await connectQwpNodeEgress( { diff --git a/benchmarks/e2e.ts b/benchmarks/e2e.ts index 82dcb76..598a79b 100644 --- a/benchmarks/e2e.ts +++ b/benchmarks/e2e.ts @@ -2,7 +2,7 @@ import { it } from "vitest"; import { mkdir, mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { Sender } from "../src"; +import { Sender } from "../packages/nodejs-client/src"; import { BENCHMARK_WORKLOADS } from "./workloads"; const ADDRESS = process.env.QDB_ADDR ?? "localhost:9000"; diff --git a/benchmarks/egress.bench.ts b/benchmarks/egress.bench.ts index 0e43148..ce55afe 100644 --- a/benchmarks/egress.bench.ts +++ b/benchmarks/egress.bench.ts @@ -11,7 +11,7 @@ import { QwpByteWriter, QwpResultBatchDecoder, writeQwpVarint, -} from "../src/_qwp/_core"; +} from "../packages/client-core/src/_qwp/_core"; const ROWS = 10_000; let sink = 0; diff --git a/benchmarks/encoder.bench.ts b/benchmarks/encoder.bench.ts index 5267855..1b02d35 100644 --- a/benchmarks/encoder.bench.ts +++ b/benchmarks/encoder.bench.ts @@ -4,7 +4,7 @@ import { QWP_COLUMN_TYPE, QwpSymbolDictionary, QwpTableBuffer, -} from "../src/_qwp/_core"; +} from "../packages/client-core/src/_qwp/_core"; import { floorInternSymbols, floorWriteLongs, diff --git a/benchmarks/persistence.bench.ts b/benchmarks/persistence.bench.ts index 23b13f1..a145d55 100644 --- a/benchmarks/persistence.bench.ts +++ b/benchmarks/persistence.bench.ts @@ -6,7 +6,7 @@ import { QWP_SF_DURABILITY, QwpNodeFileReplayStore, type QwpSfDurability, -} from "../src/qwp-node/file-replay-store"; +} from "../packages/nodejs-client/src/qwp-node/file-replay-store"; const FRAME = new Uint8Array(4096).fill(0x41); const APPENDS = 100; diff --git a/benchmarks/sender.bench.ts b/benchmarks/sender.bench.ts index 36a89d5..531343a 100644 --- a/benchmarks/sender.bench.ts +++ b/benchmarks/sender.bench.ts @@ -6,8 +6,8 @@ import { type QwpIngressEncodeOptions, type QwpIngressResponse, type QwpTableBuffer, -} from "../src/_qwp/_core"; -import { QwpSender, type QwpSenderSession } from "../src/_qwp/sender"; +} from "../packages/client-core/src/_qwp/_core"; +import { QwpSender, type QwpSenderSession } from "../packages/client-core/src/_qwp/sender"; import { BENCHMARK_WORKLOADS, type BenchmarkRow } from "./workloads"; const ROWS = 10_000; diff --git a/benchmarks/tables.ts b/benchmarks/tables.ts index e71a08c..bade37b 100644 --- a/benchmarks/tables.ts +++ b/benchmarks/tables.ts @@ -1,4 +1,4 @@ -import { QWP_COLUMN_TYPE, QwpTableBuffer } from "../src/_qwp/_core"; +import { QWP_COLUMN_TYPE, QwpTableBuffer } from "../packages/client-core/src/_qwp/_core"; import type { BenchmarkRow } from "./workloads"; export function buildBenchmarkTable( diff --git a/benchmarks/validate.test.ts b/benchmarks/validate.test.ts index 335034c..fc22278 100644 --- a/benchmarks/validate.test.ts +++ b/benchmarks/validate.test.ts @@ -4,7 +4,7 @@ import { QWP_COLUMN_TYPE, QwpSymbolDictionary, QwpTableBuffer, -} from "../src/_qwp/_core"; +} from "../packages/client-core/src/_qwp/_core"; import { buildBenchmarkTable } from "./tables"; import { BENCHMARK_WORKLOADS, type BenchmarkRow } from "./workloads"; diff --git a/docs/assets/hierarchy.js b/docs/assets/hierarchy.js index 62e498f..a0476a8 100644 --- a/docs/assets/hierarchy.js +++ b/docs/assets/hierarchy.js @@ -1 +1 @@ -window.hierarchyData = "eJx1jkEOgjAURO8y6ypIkUivITvComk/obG0pK0r0rsbNBqicfWT+TPzZkXwPkWInrd8YAg0WlLJeBchVvCWb8fJmSBwJacpdEG6uPiQwHAzTkNU54bhHiwEjEsURqkoFl/u45RmCwZlZYwQSFEftvjhE9mek7E6kIPo67IZMkNdNrsFnVp+8afq8sY/yykWe99f8EvIOT8AighX2g==" \ No newline at end of file +window.hierarchyData = "eJy1ml1znDYUhv8L10qqDxBo7+KvaWactsna6UVmJ4NBtqlZaSsgaSbj/96RcG2vBLsC1CuvPd6Xh3OOPs4r/YyUlG0Trb4gSJIYIEIQBQjGEOvPNNaf0wwgSOMUZFmWAoQThAGDMAEIslT/H0UUMIoTgAiEDCAECQEsTRlABCEMEEIwBYikCQIIQkwAQpiSDYgUv6150VZSNNHqZ5ThmOmfIt/yaBV9/L4761R+U/N3xcO1yL/lVa1/O1dKqghED5UooxXCGYg6VUerqKjzpuHNL1//7njTljdfhSz5X82boq64aN8e1Ht7327rCPQa0Spqm/KNfsCb/g+PIMpSRPfpfpMlv6hq/onv6vzHupWKz8MaEPLhSTKX57rcrXnTVFLMR3nR8KDIsnSfon+Js8rkNVc/FqRrUOoIE4iK+6ouFRfR6gtLKQIZg8lGozKYHEb9g6umalouiiVFdkzVI6gsJkOkpjLe7XZclFfVlsuuXYw5IunDSOko4+k9Lx52shKBAC09H7rsAJ1Uqtvp7ASi29c7Tqenz1G6MEyTh4quOaCTCnTsAIM4BgzGCWCQ6l+zBDAE043Bx/Eo/kVX12Fe4VnJJ6DxeEAvZfHAyzBMr7R8qA4MEa10KZtAA2RPzYcsG4/Xxy5XuWgrESpotqAHH4KDq4qRW/O7LRftlZSXuboLNF6GRD04CcMWp6z5h6rZ5m1xv4TMlvFgibEVszUX5Wktm0V5tEQ8OChOXI6FBNNXfhoToENiJixqr6frIEuoreIRnJSifZJroXghv3GzMQ24dzqu60ObWhvy692dysslY+61wqSEZggBPd6AbhNMVjNkxfKz3mlJEWD8DSkdj5fpbl4h6frg6qS7veXqM55Bsi8wJVwIQmZ2vebDGBNZykR8goL3gnJV7K5ULpqdVO2Mx7/+utfDGdwvktO8uOefeMPbD7xp8ruXng0n9D+ISrRc3ebFwSJxlEZ5nuV6JpJY+6fzO8Wb5pPs2krc/b7rO+K5WENiHmRW+dCEAQQTFvdFRDIb+R9enEnBlwbR0vEOIbO2VhdKf5xLYb7t++wY4oFn/8rzkqtlBL3G5GQhmGqrhiWpMWWMNYMgMFkDZggAE7E+lXFq+QbvRV8xAzbG1Ldwpaa+jLZZes6Exa6/0Rf30iHiKPmmnsbpUQ8oBNyw5PRRnKZZH0yaMBf8KVshgPelfMOZ2qWopXpLQpQXUn3PVRmCbljSlzKj2KX8k9+sdTPYhuCzxRZP1wxZq97Hjj9t+5ZO2I6SbxhZ4vR0TVe3J3qDtRTKlfKmcnt0LXUq624rFvL0It4kqd1LvhJZF/d8mwfh6aWmlxijTxMzSx3zQKufizJMGl+EPEOnTxzc/o4r2wmfirOnMn0hS/q5V6/ONp36xtV7cSuXBsxR8o2YY7A8T0GnUgheLJ7WRvSml11GcR9GTN3eyu0kJnFaItPhMCY9HLOPrk6U/N48F8+JPmdrVb7z70lveoFXIT0k6dEKYQSt1Xbh8ZpLuOR8zZwshjpKctEWnyUhgiDR558YbXpcjA7jzjpOOk4+/TwJ4Qzajv0sn3IAbrpRiXBGoDtdT3UqXZbJVqU5sF7iVQ4zzCgtiDJg4rLpsVDmYk32K4fpphmWpur/F8fSpQthWZorBws9ywGymaalWReAGX7AzL99epHd9jzN7Kd117RcTVx6RxeKfTnPnQFBDA3Czeq8R9nmdN8Ek3QQbW4vNkq3uB/TUexzjROrHGf7jy7tXANSX/MJZ0C6XEEcyJcQktimnec9DoDOMh8JsW/eTDIfXYwp7qO+lrXYfRxBmGk/klSvXoRCZG6D6XtfSX+bLKbAjABgYtZnM7HN79nWhPsWc70JzR7KmxjawM00Jwi1bZw55sQY0CR3Qt/yC+VOHAaaaU/oWG2eSFkYe2KMc7I/oQdIIAdgaE83zwLQ9zGDWgAuWigPQC/8wGybNo+Pj/8C8+mtXg==" \ No newline at end of file diff --git a/docs/assets/highlight.css b/docs/assets/highlight.css index dfbb2d4..7146b74 100644 --- a/docs/assets/highlight.css +++ b/docs/assets/highlight.css @@ -17,10 +17,10 @@ --dark-hl-7: #4FC1FF; --light-hl-8: #098658; --dark-hl-8: #B5CEA8; - --light-hl-9: #000000FF; - --dark-hl-9: #D4D4D4; - --light-hl-10: #267F99; - --dark-hl-10: #4EC9B0; + --light-hl-9: #267F99; + --dark-hl-9: #4EC9B0; + --light-hl-10: #000000FF; + --dark-hl-10: #D4D4D4; --light-code-background: #FFFFFF; --dark-code-background: #1E1E1E; } diff --git a/docs/assets/navigation.js b/docs/assets/navigation.js index da4eb4d..bf06890 100644 --- a/docs/assets/navigation.js +++ b/docs/assets/navigation.js @@ -1 +1 @@ -window.navigationData = "eJyF0s1OwzAMAOB3ybmiNGIDehxC4oDEgYwL4hBSt4tofpR4YhPi3ZHoYUmaptfY/mTHfv8hCCckLXkF3YHbHfse3FtDKmI5HkhLxMi9B1/H8asDqpFU5EvqjrQNvfut8hJdkWhJEg44wpR5cfqjFiiN9nUYj53tzUJDF0ZqBNdzkXQUO3SzDaBnMwwhgWcLvp5ek7rr+9tmQ4PaxxM6/mL/G0+FMLbmTL3OoPhzs1ZuS0tAqfIJ0TLHtbfG4RyIwiVnrzspZEFKEtanyVizLS9w8aKnw8pw6e0taNH5MVH6rjBampBJBR65snstMT2gKLh2QZ9ykBqZYd/GPxhlR1CgcXdG8LlBC+mzoT/+AEllex0=" \ No newline at end of file +window.navigationData = "eJzFnW1zo7iWx79LXs8+TGamd3ZeXYJJQrVt3Binp+fWLRcBJeEGgwfkZHK39rtvCbCNHpDOEaa3+k13tf//3wGEJPR0/v4/V5T8Ra9+u/rbnwdS0/Tx6oer5CXL04oUV7/9/fTfj1X5XpPq35I8IwW9+uFqH9OXq9+udmV6yEn9H9tOvuV/+O8vdJdf/XD1mhXp1W/Xau8v7/ubmCYvUVnO4+qZeFVVVmdEksd1rUEo5Tz4x+tf//cHjpcV6UOcH0iN45xkJv9WuiZ1nZXFTVnSmlbx3uLKNEamGD4oCUmcEiTxJAP4f60yauHfygz+rlDUAN6uotAN+Lp5WZMU/0AktYE2O1TxY06c5HVTxG9xlrN/4bHDNga+91yRuv5yINUHCtjTwQnOY1ykZWFzXwdd4HQ3LhKSR9mOlAc6KgLZCR7FKDCSdYlrRV9lSPZ5/BGSPw9ZZf+kFS4S/YfW7uq3q5TsK5LElKRXqpC66tEijE4JuvDut7Y1x5CJgX0bZ3n5Rio8kFMaKH7RROckr9ZlSm0B4y7j5NWaeBLDWDaFhZeiOLbFZdDFQF+QXVl9tK+Xs9+TIrV+onorRBy3Vbwj9t07vZUhjlVZ5k7SVDPWN2LAA0AOSV0eqsTioiW1iVaVtEzK3ILUVxooTZMxJ3FNUIizzOAfkqQsCpJQ76+X+FBTmzdnwMNIZsVrliU0K4vYph1XOiCpK1LVWU1JYVNkTGagWELyT5JY3ndJbyTWh5w2X29I0kkHJ8xIUmI/hmQ5nPeQkXdbGNOCSG6ZH3aFJeosBrHC8t0S1ClNlDIni6zesRtgUfpEtYG2JkVq2zYLWgAJWe5aCci3icO6ZRvwAJDtWFD3UReEuZKP3WOZn6tMHEsQG1gR+2a/OTw9IQtDTwchWIzJ9HQGwqaoSMI+KphifJNptjPFs3+u4tSirewLDYz2xoTlO57CSw0cp6rij2Z48czICkqqpzjRY85KYcD1l0/SuGdcfbht94j7CIKBRL0J1xq4OeuFVcGeSWo0U2UCA7ef3OO4nAcMK47WjgtgwM0ulLY9vlAkrRkwkH2e0XbwdOTtkIxgAWyK7Ckj6SVCUFnBgvhKHtdl8kpGBiDaGOBunLyQkNSELkhdx8/o+kUyMAEb5W2c0LLKCPoqBTkItiC0yhJLVCcGgdh3ueXTkwxMwKZ/LvYYgKye1og5VudNP9Avnko8TbIwQGckyXZxbtXc9bUGTG+g2/KhyQ4g5PFjuCbUe+PmsTBY0QWGLg80K55HXS/vAcJ2rcMoLO8BwrIPS2F6C0M8yU2wgn3+p2wGGH9tPa0J8xdJZmVBLCtqQW6AHScIHErJbo8uooLcBGMDt2gEE0GM74WJboT9vWKyW4LckfIlrl+sqqu+1oC5j4u0folfyYLQOI1pjGVJBgZgN8vQllDLV1flAcSyryKr6lEygAEtewm8GoZaVWUb4ojL4zxg2GOLkZRVakntW+CgT6Rig80juZ0LBr2mZTUO2zhAkfW+LGp7XiuHwdh4lt1Hm2QABY5pxpUmQLAwmjYjOb7+03nBwojaoagRd7znAERWcVHvy8r2I2bAxgCfl8Xz9S+frJq0vtaAYZ8767ykrBNbvcU2o04KCwP0vJjGsiMlGUCAbDULqdHFpq81YM5TmzatCq+GoixrAlFvxLVT3exZW74GCgsz9JBT+3FXUQ/CNRN8lsVSdgAh28EAO1irRWDWyQvZoStu2QGE9Ip01K086w24dqJsVD9ZYQGD8tMOKJg87aCDHLsmblk0/2/PFJxAIVi+9pwYBBr1/DBPTlpqhgEpF5spQNUbqdiAm+V7IBmYgE3Xyqq67EkNkP7s3IzQOMvRT0thYYIestTquk5CA+A0bO+OalkHbKDwefaKvkJObAI1c552TU5fa57KTNeEclPd9GNv3sDRigT3//zv//rxl2uZEH3sCdKfSSDux2lNph2YqDTzBk2wEXTfECND4F2wMYyEo6m3hFt4BqU1Mjilq8edA30hBc0S4RMISlXaoKM4zd26ZfGUPVuHIfgA4mhfauT7dBaBCMdJqHbq8AMFErQAXjcN5Rf7A0Wg+jIIpTywjT/sGwNNEqQAWluHuOVuX4l9FyNO0oJ5UtcFyFL3WFScbgweewf7MgBFGLb9zH4JhynUIGZGszjvCvCiTDG3URaDiPTTzygI/fQzxHf/9jP2AZ00AP9u0AqL6MvglK9llWJasr4MSLGqE3ghgLQkzyXN2NarMXWDxgUUA1vFfwodxeWUYNbysHsklR2tpwXw2g2H5W6fE2THQFACWPwAILJ6ksUgIremnU1P5qgeu9oATO5Wnrtxnj/GySsaLOjBXOE7EkhTfUSqGL2BFjem5BnX11GoccxVmWeJJbHVgnnz8vkZVV76MjCl69Tiu40qOYAasc1rmFq7FUCcsx2pabzbb4oMBejrAJz+6AqyHhGlcFq3rn/1wm0/gwL7agjzkKXYxvWkAfj3hzqQd1CUgmlh+Y7GhOU72F8c+gcilOP9MuXrausG881iuY2+rbwz5S2uMjblqiHxUp70kwxZrEJvvfaD5dYNZp6LRwkGBuDMc/2FM98unN+3a9eZo69NMjACb53NPNp6dyzM7c3m9tYLt6sgmG/X/h8WeK0dLhh/6Ue+M9+6oTfzo5Gh8Ga4QNZe+OCFW395G2wjf+EFm2i7WI8MSG1qCmwTOjdzb+u4n7dfvZt14H72ou0qDKLADeboiLRuhlC6y3CdlXPjz/3oG5YuGcCAC2+9du7QJZNXw1ArJ7q35DCpCbJ0g5m/vNveBaE/nztokqCH4jbLY63kzayZfRMD+Hbu3LHC74WsOlzg32TJAAacR852/W1xE8y3M9+N/GDphOhCqvMChdF7xVbBHP2KKk0gYMtC1ddCMH+sI3QhOgkNgHvPmXmhVTvUkxog/tL+Xe9roZgwaP/y2V+ib5zSxAju2r1gufTcaLsIZui7qfIwYBfOnY/uLTUio/HvWycMnW/bmb/wlk3Pau4t7/BPb9gJHwK6M6DyAGBvnMi934bB1/V2s1p54fYm2ODL0bATIATXm8/X20bBLGzYggUE2nbZl87CG/G0ZRcwug03YtWwPblnAgB7YRiExw7LiKtW+QDw/sxbRv6tz57St8izKuGiBwDblMhR95p3ACClht2qyRl2AoTQhDq2eEsmADBrhtlH46kzZ33taidDCF82Xvit7RCE3tqLRvTUNFaGIELv2LB5D94ysmqaVR5GLIty4aw/j7hqpYkBvPaWrHPU1guuE3l3AR6sNMGAV8Hcd8dhWwsjtPnODgN8bdKTmiCRE23QVWSrMlhHTnjnob+ZWpXBerO6C52Z191Nm2IvOwCRxxGP1b2zRj8XpYkB/OCFrJOFRXUyg3lT97Xt/Hm8b+49eOjvvWEnUAj+8lIhqJw0IcRpyp1Ne1omeDxS7hzF06FoltcMR2E04wP59HMvkMeMP7zHTGsVOssSFz77vd6uXTAlLaVCQgZcdOgPSlCQD0o0dslLXGHs2O91dmWRxJSdCl2jXM8yvTmbsRZXCiJBKgsMtF3yMAraWmCg3WqiUdTOA4MVDzuzoKoOP9NDT1XFKO7JRYeuSEzJqPKkdMAgh5c5WtD16x61gVg8aqUDBDmLaTwv292s0o4YBFjhA8Efm6CHrMybhbdj49AZQgIay4fh0hjXcrDf6+zala8/Xv+KMj2pzNbXv3yysL7+5ZPZur/kEewsrnsUjcuUtIuPKVu/z3aGZcUzkqP0gGAH1t4ioJpVuGqkcAgIAqU4CUSNGNykj4Bpd+prsefdVGPAZxcMWr9/3iIK8yZ6dUAPcZXhGkVBaoRs6BO2DulUBut2VeqX9/0fNU2tSqvsoEU+xYec8hWytEgSAh720eLr7Llgq3JP679wVEmugzW7EVD+jcJoKazShfoqFuhy5qToyqSTJGRvVTsPWECgwkFTCJbimCk1os1yYsVopSBIRdIMVRcIUgjkPGiwKvMc/drqfCB4eyAYcVdWWZ6jqnNRC8F0Nb9b7nYZtb8s2QYBH43FAsV2bnQASkNIQOpTRBABDB8logbiW2pBaoRgW+qzSmP9lMeUkuJ48jHGX5BqIWVMf7rGmTcSkynuU6KTaEyf211qGNNOojHNCuTFNwK9Ie7CM3nfGG+4f8P57d+0dvXlxrJNXpow8hLXx2C/N9ghv4o7icEU/ebl6k1YnPGfbHts05XVbDcxozQ2enjXVK6zf6EaAF6pR2j37oBQxv07IrKtqi0u6izUACoSp1ZNCSeEAtrNddaYVq6B1S9lhbqKRqAzbDoDKMdGobGkNh9rFPCJdqBPv85J8UxRbclZpbM+ZCnK9JClGru3uMLOQ3USjek7240ydKKsGaCQA2D494ZXSoh/9CBFmZJ/1p3wzJCy9nK/EzbiqJP23lO6Px04eHaWcmLwzpyK58j5ZTE5gXnMZCmBZQw4IzAkG69sf9FkvEr7y+Tilawvnop3gDBxJl6JOnEiXok3bR5eHe67pOHVBTBlFl4dd5okvDrixDl4B9DfOQXvQBSTZeDV8y6fgFfiTZp/V6JNnX53CHjx7LtDoOmS7xqI3yH3rhTB/0/qXW0Y6My7yzIlt9kxr5twRLuZrZADgEG1f4mLWRVnBa4vJIkBsE26tyjPvNKAwacvlnjTZy9WIqdIXiyDLp27WCJMmLpYYn2fzMUK7HdNXGzkT5q3eICOS1t8qpHG1fJaI3gQ7gtJXvdlVoyKQHBB4MuqOjRnb47C8y5w/BgoEnV7yPFVlEoPR87ZvIFtoRYdcNh5WY8qUJwHHP3lEFdxQTObz1eNDTyANXnekYJad5oMVsZAIDnRFdBJUqLrOJfOiK5jXTYh+gDp4vnQBziXT4cugybLhi6hLpwMXek/QS70Ac4UqdCVqAtnQlcypkmELqOmyIMuUS6dBl0NgGVBT5uNBM9VvLNuJYY8TOgxCdjlIL5T/nUZDE+//sC+BsrCuipU6Q1IcMZ3CQZP+I6sCM21YPuL9gV5+BFpfJSBAdd2gGsw4Cc7wE9mgJRmAOSvzC7A20eJxZRoX6Qz3xRplmR4f0FnKPnQXFhSwQcnwbppNt6e950hOaLcQEOmPpcnGafMfD4wpTlB4vMB0iR5zwdYU6Q9l1ETZj1XwKZLei7PO0+X81w3MzldynPDpORkGc+HuNMmPNdPRk6Q73wAePl05zJoomznMmi6ZOeDc7kXy3UuEy6Z6lztfuFM5xJkukTnEmr6POdDk8ITpjkfRE6R5XwINlWS8yHepDnOh6ATpzg3YafJcK6nXjzB+TBuivzmw2s0pktvblgXMnV280H8d0huPsSeOLf5IHaq1OYSEJHZnK3j6BahNknw7MrUgAuYPZYK5w0kqQTzdPkpVTxh8Y9dARo2wkdgf+lqI0AEQ2k5wWRtRk4VkVsDZX/PVTZYuv1Vq2xwdJsejcoCRwUljDZxYYmjmagb8e/ORAoJP7AIZqtswPSwnYD4sL3hChcAu11zU6S3ZfUeV6l9SVMbASLYpHv7t+sshpHsr+8shpGgOaYHV0VC00w3v29nkdq1W3bvjWgBoJ5289rfVdHCQGVjp+u8pGxYrHqLLS5V4WBgnrdC2I3OSHoITzr7AI5SnnogUfgkhkgOL4aS7EqJKDfS2sW27DnbVSsKBzPzkFPrGSNRDqI1i4HsCqRsACJCEtlr1xMhKGL+NDRLmUlNTfSKdMx9PMsNtG63+oiBN4UDjMlP3mNY8uS9jnEc8WjO4PsLW6sMG4EisHvbOS2IM+bZYZ6aXReY0xo5x9P17F4ASW/iNeM1NlVkT2lg9NfTzAiNsxz7pBQOJuYhS22u6qQz+J86Se6YhnTABcq26MlyWhOnl8oTi+lJdZT+8hgMoq8z+ysWpkARA2tTeIr3F61iqQQICUZ5975EsJaSixryDPPG4OzCbA50TSgFO3MaICD62A9nw1XaMwXAvC1aOPuzBgQYPMnazNGeW63EdUso9El9hxZeQNP6zs5nS2JBghIA847H7DVnjuqyxw8sB+hJwTip0YSh1G2lEoN/1QUVANLNVSOfUV8FgAhTnNo0z6b5UWCiZ7/IaBbn3fuxKFPEg5K1ICB3rJuZIR3qprbdv/2MfDgnCcC+m+ZBEvoqOORrWaWIgtxXASE21Q2vA4CW5LmkGTsAYkS1ozEBhcD2xM7l0+fMWE4IRrXHhVnBelIITjF1gKsuhhwA8Pa0lXK3zwlFPU1BCEDxQ2i4K5S1ICC3h0w6W9sMVenB4G6rlxvn+WOcvGK5ghyMFT7LYDDVN5kKoT3B0IQyHl5oQoqnQSKAqhMgNTjUN4GgAkO6IQt0B1ilhkCfbuLktalyDxVB30uFGgRtTs3K8oyiYGcVABKxHVSI9q/9PcT4eHDipsgw/n0ZANMff8HVi6ISDuv2B65euDMxgLy+GII8ZCmyi3KSAOz7oyG42ycqwbCwfMdSwvIdbC9OBsAIyhkACWJTpnEF+uvqmHg5+rbS53oUroRX8iApAWI/W6EbzDx99nEVSdAbeDPP9RfOvM376zqG7KIyT9IbebfOZh5tvTb1+83m9tZjKVCDuTllr4qudcPFcsoPH3ozX5+q1BgJ74WLo8vX6i9vg1Nu0IU+K6sxHrWnKa5N2ORidtzP26/ezTpwP3vRdhUGUeAG+oScioC0ZoZIuqtwnZVz48/9SJ9wV4ZLehivyzZuB+vEMNLKMSTLHsQwpYmxdIOZv7zb3gWhP587WJAgh9I2y3MWbVtk38PAbXJmzzxWAbjBYoF+gyU9jDePHDlhuh1aaQWKovdurYI59tVUekC4duWpL4VQWCZfGwTTGfzvPYfl4bZoeXpKA8NfWr/ifSmUEgbtX4x5sDW4voeR2zV0Xd74RTDD3kqVhYG6cO58bM+o0Rh9f986Yeh82878hbfsckYv79BPbtgIHwG26VdZAKg3TuTeb8Pg63q7Wa28cHsTbNBFaNgIEIHrzefrbaNgFhZowQHCbHvmS2fh2T9p2QRMbqONWNVrDe55ALhtkvuud2J/zSobAN2fecvIv/XZI/oWeTZlW7QAUJvCOOZG8wYAotSO2zQyw0aACJpIRxZsyQPAZc0u+yw89dtsr1xtZIggCFf3znI7Cx1/ufUevGVk0xIOuMDYt44/Z8GyUrr0sJ0xtYmB/GXjhd/aXk/orb3IviuqcTLEEHrHBtz6tqssjFQW5MJZf7a/ZqWHgbv2lqz/19aCrhN5dwGaq/TAcFfB3HdHUVsHI7MZPAgDdNXZU5oYt9sbx/3cvOmb0LO8NKWJmdx8+9iMK3BaEydyog22vWtFBufICe887OduKzI4b1Z3oTPzusJi8UbLBkDicZBqde+ssaVO6WHgPngh6ygjSZ3K4N00ZG137Tw4O/ce0G3DsBEoAn95oQhURpoI4jQdkYmQD8LoxcfBZah6zJ4zdnZt9F7W7SQ/O8uW5QzSJybmI9C4aNn8IZQAjHzyJO9Yom4c+7nO7YPqk+kJbh9Ul0LPmMqMdzOkMUvKIonRz6mn0nuzFRjcDnIcRJKDae2KIWtaKwfTuuV31rhOD+aJ50EicarzITW0TapPHqhDbVJdDkHptyMv7OQAZp7qOGvmyUHHrEhMpWNszaCezOjOqu5uV7VySxOQpraB0O1eclkNZg2vCEdi9evDhyPAl1ZZDWVZvRxKAwjx2OI/ZGXebAAeWaR0fpB4RuIxNMXmFCBoYG8Kx0hjVF+A/Vzn1u43+PFanzZd8DyJzM6mbNBKZ30+6O5HhvTeSmNthu+UdMnmmw2RBW32gGaG/NgSRmkBoQ7sd4AzNTsf1ETh4EA4SXF6oJoweMQXnKU950tLPe+cHME9m2DI+uO38EGYz+BSxwNI9ztANubJbn+5oU/ImqMTGZzbdfpf3vd/1DS1KaeygZbYpG/n63lp8TaAO2yjpdfZc8F2KUSgVNsiVFLrWM22L4x9IzA6CpsWgLaK/QqcNym60ugkCdnbVMgDDhCmcBotHKU4i1ZNaFPO2iBaJYhRkTTD1ACCEsI4D/isyjzHvq06Gwjdmgcm3JVVlueYClyUQihdXe+Wu11GrS9KdkGwx1KxPLFdG8tX+kHiUR8HBOcPnwmk5qHbZUFpZCDb5bNI4/yUx5SS4ph+AWEvKLWMMqY/XaO8G4XJE/W10Ck0ns/t1lyEZ6fQeGYF7sqb3+v9UFedyZtleb/9G8pu/6Z1qy827WCy0kSRl6juBPu5wQ33wdspDJ7Y9y1Xbz3lfPdxVfPDZu2JsAjMkIWG+ic74qDpJWs22RnBGhc9u2uO19m/MM0ML9QTtNsVISTjlkWR2LYI+Es66zT+FYlTm/aK00H9203MtpRWrWXR6oPbu8zOKEThVAYaYp3EhfR7zDeF2kBHfMGNOza/1/k1PSqMYSPQOFKLb1wK+LI90Kdf56R4pphG+SzSOR+yFON5yFKN21tcISdgO4XG851txhvK2GH0V6gBLHSVwAslwj/Yn/8DPCbHmg==" \ No newline at end of file diff --git a/docs/assets/search.js b/docs/assets/search.js index 3155176..7e58730 100644 --- a/docs/assets/search.js +++ b/docs/assets/search.js @@ -1 +1 @@ -window.searchData = "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"; \ No newline at end of file +window.searchData = "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"; \ No newline at end of file diff --git a/docs/classes/HttpTransport.html b/docs/classes/HttpTransport.html deleted file mode 100644 index 8234713..0000000 --- a/docs/classes/HttpTransport.html +++ /dev/null @@ -1,35 +0,0 @@ -HttpTransport | QuestDB Node.js Client - v4.2.0

    Class HttpTransport

    HTTP transport implementation using Node.js built-in http/https modules.
    -Supports both HTTP and HTTPS protocols with configurable authentication.

    -

    Hierarchy

    • HttpTransportBase
      • HttpTransport
    Index

    Constructors

    Properties

    secure: boolean
    host: string
    port: number
    username: string
    password: string
    token: string
    tlsVerify: boolean
    tlsCA: Buffer
    requestMinThroughput: number
    requestTimeout: number
    retryTimeout: number
    log: Logger

    Methods

    • HTTP transport does not require explicit connection establishment.

      -

      Returns Promise<boolean>

      Error indicating connect is not required for HTTP transport

      -
    • HTTP transport does not require explicit connection closure.

      -

      Returns Promise<void>

      Promise that resolves immediately

      -
    • Gets the default auto-flush row count for HTTP transport.

      -

      Returns number

      Default number of rows that trigger auto-flush

      -
    • Sends data to QuestDB using HTTP POST.

      -

      Parameters

      • data: Buffer

        Buffer containing the data to send

        -
      • retryBegin: number = -1

        Internal parameter for tracking retry start time

        -
      • retryInterval: number = -1

        Internal parameter for tracking retry intervals

        -

      Returns Promise<boolean>

      Promise resolving to true if data was sent successfully

      -

      Error if request fails after all retries or times out

      -
    diff --git a/docs/classes/Sender.html b/docs/classes/Sender.html deleted file mode 100644 index 1e84991..0000000 --- a/docs/classes/Sender.html +++ /dev/null @@ -1,231 +0,0 @@ -Sender | QuestDB Node.js Client - v4.2.0

    Class Sender

    The QuestDB client's API provides methods to connect to the database, ingest data, and close the connection.
    -The client supports multiple transport protocols.

    -

    -Transport Options: -

      -
    • HTTP: Uses standard HTTP requests for data ingestion. Provides immediate feedback via HTTP response codes. -Recommended for most use cases due to superior error handling and debugging capabilities. Uses Undici library by default for high performance.
    • -
    • HTTPS: Secure HTTP transport with TLS encryption. Same benefits as HTTP but with encrypted communication. -Supports certificate validation and custom CA certificates.
    • -
    • TCP: Direct TCP connection, provides persistent connections. Uses JWK token-based authentication.
    • -
    • TCPS: Secure TCP transport with TLS encryption.
    • -
    -

    -

    -The client supports authentication.
    -Authentication details can be passed to the Sender in its configuration options.
    -The client supports Basic username/password and Bearer token authentication methods when used with HTTP protocol, -and JWK token authentication when ingesting data via TCP.
    -Please, note that authentication is enabled by default in QuestDB Enterprise only.
    -Details on how to configure authentication in the open source version of -QuestDB: https://questdb.io/docs/reference/api/ilp/authenticate -

    -

    -The client also supports TLS encryption for both, HTTP and TCP transports to provide a secure connection.
    -Please, note that the open source version of QuestDB does not support TLS, and requires an external reverse-proxy, -such as Nginx to enable encryption. -

    -

    -The client supports multiple protocol versions for data serialization. Protocol version 1 uses text-based -serialization, while version 2 uses binary encoding for doubles and supports array columns for improved -performance. The client can automatically negotiate the protocol version with the server when using HTTP/HTTPS -by setting the protocol_version to 'auto' (default behavior). -

    -

    -The client uses a buffer to store data. It automatically flushes the buffer by sending its content to the server. -Auto flushing can be disabled via configuration options to gain control over transactions. Initial and maximum -buffer sizes can also be set. -

    -

    -It is recommended that the Sender is created by using one of the static factory methods, -Sender.fromConfig(configString, extraOptions) or Sender.fromEnv(extraOptions). -If the Sender is created via its constructor, at least the SenderOptions configuration object should be -initialized from a configuration string to make sure that the parameters are validated.
    -Detailed description of the Sender's configuration options can be found in -the SenderOptions documentation. -

    -

    -Transport Configuration Examples: -

      -
    • HTTP: Sender.fromConfig("http::addr=localhost:9000")
    • -
    • HTTPS with authentication: Sender.fromConfig("https::addr=localhost:9000;username=admin;password=secret")
    • -
    • TCP: Sender.fromConfig("tcp::addr=localhost:9009")
    • -
    • TCPS with authentication: Sender.fromConfig("tcps::addr=localhost:9009;username=user;token=private_key")
    • -
    -

    -

    -HTTP Transport Implementation:
    -By default, HTTP/HTTPS transport uses the high-performance Undici library for connection management and request handling. -For compatibility or specific requirements, you can enable the standard HTTP transport using Node.js built-in modules -by setting stdlib_http=on in the configuration string. The standard HTTP transport provides the same functionality -but uses Node.js http/https modules instead of Undici. -

    -

    -Extra options can be provided to the Sender in the extraOptions configuration object.
    -A custom logging function and a custom HTTP(S) agent can be passed to the Sender in this object.
    -The logger implementation provides the option to direct log messages to the same place where the host application's -log is saved. The default logger writes to the console.
    -The custom HTTP(S) agent option becomes handy if there is a need to modify the default options set for the -HTTP(S) connections. A popular setting would be disabling persistent connections, in this case an agent can be -passed to the Sender with keepAlive set to false.
    -For example: Sender.fromConfig(`http::addr=host:port`, { agent: new undici.Agent({ connect: { keepAlive: false } })})
    -If no custom agent is configured, the Sender will use its own agent which overrides some default values -of undici.Agent. The Sender's own agent uses persistent connections with 1 minute idle timeout, pipelines requests default to 1. -

    Index

    Constructors

    • Creates an instance of Sender.

      -

      Parameters

      • options: SenderOptions

        Sender configuration object.
        -See SenderOptions documentation for detailed description of configuration options.

        -

      Returns Sender

    Methods

    • Creates a Sender object by parsing the provided configuration string.

      -

      Parameters

      • configurationString: string

        Configuration string.

        -
      • OptionalextraOptions: ExtraOptions

        Optional extra configuration.

        -
          -
        • 'log' is a logging function used by the Sender. -Prototype: (level: 'error'|'warn'|'info'|'debug', message: string) => void.
        • -
        • 'agent' is a custom http/https agent used by the Sender when http/https transport is used. -Depends on which transport implementation and protocol used, one of the followings expected: undici.Agent, http.Agent or https.Agent.
        • -
        -

      Returns Promise<Sender>

      A Sender object initialized from the provided configuration string.

      -
    • Creates a Sender object by parsing the configuration string set in the QDB_CLIENT_CONF environment variable.

      -

      Parameters

      • OptionalextraOptions: ExtraOptions

        Optional extra configuration.

        -
          -
        • 'log' is a logging function used by the Sender. -Prototype: (level: 'error'|'warn'|'info'|'debug', message: string) => void.
        • -
        • 'agent' is a custom http/https agent used by the Sender when http/https transport is used. -Depends on which transport implementation and protocol used, one of the followings expected: undici.Agent, http.Agent or https.Agent.
        • -
        -

      Returns Promise<Sender>

      A Sender object initialized from the QDB_CLIENT_CONF environment variable.

      -
    • Resets the sender's buffer, data sitting in the buffer will be lost.
      -In other words it clears the buffer, and sets the writing position to the beginning of the buffer.

      -

      Returns Sender

      Returns with a reference to this sender.

      -
    • Creates a TCP connection to the database.

      -

      Returns Promise<boolean>

      Resolves to true if the client is connected.

      -
    • Sends the content of the sender's buffer to the database and compacts the buffer. -If the last row is not finished it stays in the sender's buffer.

      -

      Returns Promise<boolean>

      Resolves to true when there was data in the buffer to send, and it was sent successfully.

      -
    • Closes the connection to the database.
      -Data sitting in the Sender's buffer will be lost unless flush() is called before close().

      -

      Returns Promise<void>

    • Writes the table name into the buffer of the sender of the sender.

      -

      Parameters

      • table: string

        Table name.

        -

      Returns Sender

      Returns with a reference to this sender.

      -
    • Writes a symbol name and value into the buffer of the sender.
      -Use it to insert into SYMBOL columns.

      -

      Parameters

      • name: string

        Symbol name.

        -
      • value: unknown

        Symbol value, toString() is called to extract the actual symbol value from the parameter.

        -

      Returns Sender

      Returns with a reference to this sender.

      -
    • Writes a string column with its value into the buffer of the sender.
      -Use it to insert into VARCHAR and STRING columns.

      -

      Parameters

      • name: string

        Column name.

        -
      • value: string

        Column value, accepts only string values.

        -

      Returns Sender

      Returns with a reference to this sender.

      -
    • Writes a boolean column with its value into the buffer of the sender.
      -Use it to insert into BOOLEAN columns.

      -

      Parameters

      • name: string

        Column name.

        -
      • value: boolean

        Column value, accepts only boolean values.

        -

      Returns Sender

      Returns with a reference to this sender.

      -
    • Writes a 64-bit floating point value into the buffer of the sender.
      -Use it to insert into DOUBLE or FLOAT database columns.

      -

      Parameters

      • name: string

        Column name.

        -
      • value: number

        Column value, accepts only number values.

        -

      Returns Sender

      Returns with a reference to this sender.

      -
    • Writes an array column with its values into the buffer of the sender.

      -

      Parameters

      • name: string

        Column name

        -
      • value: unknown[]

        Array values to write (currently supports double arrays)

        -

      Returns Sender

      Returns with a reference to this sender.

      -

      Error if arrays are not supported by the buffer implementation, or array validation fails:

      -
        -
      • value is not an array
      • -
      • or the shape of the array is irregular: the length of sub-arrays are different
      • -
      • or the array is not homogeneous: its elements are not all the same type
      • -
      -
    • Writes a 64-bit signed integer into the buffer of the sender.
      -Use it to insert into LONG, INT, SHORT and BYTE columns.

      -

      Parameters

      • name: string

        Column name.

        -
      • value: number

        Column value, accepts only number values.

        -

      Returns Sender

      Returns with a reference to this sender.

      -

      Error if the value is not an integer

      -
    • Writes a timestamp column and its value into the buffer of the sender.

      -

      Use this method to insert data into TIMESTAMP or TIMESTAMP_NS columns.

      -

      Precision rules:

      -
        -
      • Protocol v2 and higher: -Timestamps passed with unit 'ns' (nanoseconds) are sent with full nanosecond precision. -All other timestamps are sent with microsecond precision.
      • -
      • Protocol v1: -Always uses microsecond precision, even if the timestamp is specified in nanoseconds.
      • -
      -

      Parameters

      • name: string

        The column name.

        -
      • value: number | bigint

        The epoch timestamp. Must be an integer or a BigInt.

        -
      • Optionalunit: TimestampUnit = "us"

        The time unit of the timestamp. -Supported values:

        -
          -
        • 'ns' — nanoseconds (requires BigInt)
        • -
        • 'us' — microseconds (default)
        • -
        • 'ms' — milliseconds
        • -
        -

      Returns Sender

      Returns with a reference to this buffer.

      -

      If value is not an integer or BigInt.

      -

      If unit is 'ns' but value is not a BigInt.

      -
    • Writes a decimal value into the buffer using the text format.

      -

      Use it to insert into DECIMAL database columns.

      -

      Parameters

      • name: string

        Column name.

        -
      • value: string | number

        Column value, accepts only number/string values.

        -

      Returns Sender

      Returns with a reference to this buffer.

      -

      Error if decimals are not supported by the buffer implementation, or decimal validation fails:

      -
        -
      • string value is not a valid decimal representation
      • -
      -
    • Writes a decimal value into the buffer using the binary format.

      -

      Use it to insert into DECIMAL database columns.

      -

      Parameters

      • name: string

        Column name.

        -
      • unscaled: bigint | Int8Array<ArrayBufferLike>

        The unscaled value of the decimal in two's -complement representation and big-endian byte order. -An empty array represents the NULL value.

        -
      • scale: number

        The scale of the decimal value.

        -

      Returns Sender

      Returns with a reference to this buffer.

      -

      Error if decimals are not supported by the buffer implementation, or decimal validation fails:

      -
        -
      • unscaled value length is not between 0 and 32 bytes
      • -
      • scale is not between 0 and 76
      • -
      • unscaled value contains invalid bytes
      • -
      -
    • Closes the row after writing the designated timestamp into the buffer of the sender.

      -

      Precision rules:

      -
        -
      • Protocol v2 and higher: -Timestamps passed with unit 'ns' (nanoseconds) are sent with full nanosecond precision. -All other timestamps are sent with microsecond precision.
      • -
      • Protocol v1: -Always uses microsecond precision, even if the timestamp is specified in nanoseconds.
      • -
      -

      Parameters

      • timestamp: number | bigint

        Designated epoch timestamp. Must be an integer or a BigInt.

        -
      • Optionalunit: TimestampUnit = "us"

        The time unit of the timestamp. -Supported values:

        -
          -
        • 'ns' — nanoseconds (requires BigInt)
        • -
        • 'us' — microseconds (default)
        • -
        • 'ms' — milliseconds
        • -
        -

      Returns Promise<void>

      Returns with a reference to this buffer.

      -

      If value is not an integer or BigInt.

      -

      If unit is 'ns' but value is not a BigInt.

      -
    • Closes the row without writing designated timestamp into the buffer of the sender.
      -Designated timestamp will be populated by the server on this record.

      -

      Returns Promise<void>

    diff --git a/docs/classes/SenderBufferV1.html b/docs/classes/SenderBufferV1.html deleted file mode 100644 index 6a5dc16..0000000 --- a/docs/classes/SenderBufferV1.html +++ /dev/null @@ -1,150 +0,0 @@ -SenderBufferV1 | QuestDB Node.js Client - v4.2.0

    Class SenderBufferV1

    Buffer implementation for protocol version 1.
    -Sends floating point numbers in their text form.

    -

    Hierarchy

    • SenderBufferBase
      • SenderBufferV1
    Index

    Constructors

    Properties

    buffer: Buffer
    position: number
    log: Logger

    Methods

    • Resets the buffer, data sitting in the buffer will be lost.
      -In other words it clears the buffer, and sets the writing position to the beginning of the buffer.

      -

      Returns SenderBuffer

      Returns with a reference to this buffer.

      -
    • Parameters

      • pos: number = ...

      Returns Buffer

      Returns a cropped buffer, or null if there is nothing to send.
      -The returned buffer is backed by this buffer instance, meaning the view can change as the buffer is mutated. -Used only in tests to assert the buffer's content.

      -
    • Parameters

      • pos: number = ...

      Returns Buffer

      Returns a cropped buffer ready to send to the server, or null if there is nothing to send.
      -The returned buffer is a copy of this buffer. -It also compacts the buffer.

      -
    • Writes the table name into the buffer.

      -

      Parameters

      • table: string

        Table name.

        -

      Returns SenderBuffer

      Returns with a reference to this buffer.

      -
    • Writes a symbol name and value into the buffer.
      -Use it to insert into SYMBOL columns.

      -

      Parameters

      • name: string

        Symbol name.

        -
      • value: unknown

        Symbol value, toString() is called to extract the actual symbol value from the parameter.

        -

      Returns SenderBuffer

      Returns with a reference to this buffer.

      -
    • Writes a string column with its value into the buffer.
      -Use it to insert into VARCHAR and STRING columns.

      -

      Parameters

      • name: string

        Column name.

        -
      • value: string

        Column value, accepts only string values.

        -

      Returns SenderBuffer

      Returns with a reference to this buffer.

      -
    • Writes a boolean column with its value into the buffer.
      -Use it to insert into BOOLEAN columns.

      -

      Parameters

      • name: string

        Column name.

        -
      • value: boolean

        Column value, accepts only boolean values.

        -

      Returns SenderBuffer

      Returns with a reference to this buffer.

      -
    • Writes a 64-bit signed integer into the buffer.
      -Use it to insert into LONG, INT, SHORT and BYTE columns.

      -

      Parameters

      • name: string

        Column name.

        -
      • value: number

        Column value, accepts only number values.

        -

      Returns SenderBuffer

      Returns with a reference to this buffer.

      -

      Error if the value is not an integer

      -
    • Writes a timestamp column and its value into the buffer.

      -

      Use this method to insert data into TIMESTAMP or TIMESTAMP_NS columns.

      -

      Precision rules:

      -
        -
      • Protocol v2 and higher: -Timestamps passed with unit 'ns' (nanoseconds) are sent with full nanosecond precision. -All other timestamps are sent with microsecond precision.
      • -
      • Protocol v1: -Always uses microsecond precision, even if the timestamp is specified in nanoseconds.
      • -
      -

      Parameters

      • name: string

        The column name.

        -
      • value: number | bigint

        The epoch timestamp. Must be an integer or a BigInt.

        -
      • Optionalunit: TimestampUnit = "us"

        The time unit of the timestamp. -Supported values:

        -
          -
        • 'ns' — nanoseconds (requires BigInt)
        • -
        • 'us' — microseconds (default)
        • -
        • 'ms' — milliseconds
        • -
        -

      Returns SenderBuffer

      Returns with a reference to this buffer.

      -

      If value is not an integer or BigInt.

      -

      If unit is 'ns' but value is not a BigInt.

      -
    • Closes the row after writing the designated timestamp into the buffer.

      -

      Precision rules:

      -
        -
      • Protocol v2 and higher: -Timestamps passed with unit 'ns' (nanoseconds) are sent with full nanosecond precision. -All other timestamps are sent with microsecond precision.
      • -
      • Protocol v1: -Always uses microsecond precision, even if the timestamp is specified in nanoseconds.
      • -
      -

      Parameters

      • timestamp: number | bigint

        Designated epoch timestamp. Must be an integer or a BigInt.

        -
      • Optionalunit: TimestampUnit = "us"

        The time unit of the timestamp. -Supported values:

        -
          -
        • 'ns' — nanoseconds (requires BigInt)
        • -
        • 'us' — microseconds (default)
        • -
        • 'ms' — milliseconds
        • -
        -

      Returns void

      Returns with a reference to this buffer.

      -

      If value is not an integer or BigInt.

      -

      If unit is 'ns' but value is not a BigInt.

      -
    • Closes the row without writing designated timestamp into the buffer.
      -Designated timestamp will be populated by the server on this record.

      -

      Returns void

    • Returns the current position of the buffer.
      -New data will be written into the buffer starting from this position.

      -

      Returns number

    • Checks if the buffer has sufficient capacity for additional data and resizes if needed.

      -

      Parameters

      • data: string[]

        Array of strings to calculate the required capacity for

        -
      • base: number = 0

        Base number of bytes to add to the calculation

        -

      Returns void

    • Writes a decimal value into the buffer using its text format.

      -

      Use it to insert into DECIMAL database columns.

      -

      Parameters

      • name: string

        Column name.

        -
      • value: string | number

        The decimal value to write.

        -
          -
        • Accepts either a number or a string containing a valid decimal representation.
        • -
        • String values should follow standard decimal notation (e.g., "123.45" or "-0.001").
        • -
        -

      Returns SenderBuffer

      Returns with a reference to this buffer.

      -

      Error If decimals are not supported by the buffer implementation, or validation fails. -Possible validation errors:

      -
        -
      • The provided string is not a valid decimal representation.
      • -
      -
    • Writes a decimal value into the buffer using its binary format.

      -

      Use it to insert into DECIMAL database columns.

      -

      Parameters

      • name: string

        Column name.

        -
      • unscaled: bigint | Int8Array<ArrayBufferLike>

        The unscaled integer portion of the decimal value.

        -
          -
        • If a bigint is provided, it will be converted automatically.
        • -
        • If an Int8Array is provided, it must contain the two’s complement representation -of the unscaled value in big-endian byte order.
        • -
        • An empty Int8Array represents a NULL value.
        • -
        -
      • scale: number

        The number of fractional digits (the scale) of the decimal value.

        -

      Returns SenderBuffer

      Returns with a reference to this buffer.

      -

      If decimals are not supported by the buffer implementation, or validation fails. -Possible validation errors:

      -
        -
      • unscaled length is not between 0 and 32 bytes.
      • -
      • scale is not between 0 and 76.
      • -
      • unscaled contains invalid bytes.
      • -
      -
    • Writes a 64-bit floating point value into the buffer using v1 serialization (text format).
      -Use it to insert into DOUBLE or FLOAT database columns.

      -

      Parameters

      • name: string

        Column name.

        -
      • value: number

        Column value, accepts only number values.

        -

      Returns SenderBuffer

      Returns with a reference to this sender.

      -
    • Parameters

      • timestamp: number | bigint
      • unit: TimestampUnit = "us"
      • designated: boolean

      Returns void

    diff --git a/docs/classes/SenderBufferV2.html b/docs/classes/SenderBufferV2.html deleted file mode 100644 index aa5c03d..0000000 --- a/docs/classes/SenderBufferV2.html +++ /dev/null @@ -1,158 +0,0 @@ -SenderBufferV2 | QuestDB Node.js Client - v4.2.0

    Class SenderBufferV2

    Buffer implementation for protocol version 2.
    -Sends floating point numbers in binary form, and provides support for arrays.

    -

    Hierarchy

    • SenderBufferBase
      • SenderBufferV2
    Index

    Constructors

    Properties

    buffer: Buffer
    position: number
    log: Logger

    Methods

    • Resets the buffer, data sitting in the buffer will be lost.
      -In other words it clears the buffer, and sets the writing position to the beginning of the buffer.

      -

      Returns SenderBuffer

      Returns with a reference to this buffer.

      -
    • Parameters

      • pos: number = ...

      Returns Buffer

      Returns a cropped buffer, or null if there is nothing to send.
      -The returned buffer is backed by this buffer instance, meaning the view can change as the buffer is mutated. -Used only in tests to assert the buffer's content.

      -
    • Parameters

      • pos: number = ...

      Returns Buffer

      Returns a cropped buffer ready to send to the server, or null if there is nothing to send.
      -The returned buffer is a copy of this buffer. -It also compacts the buffer.

      -
    • Writes the table name into the buffer.

      -

      Parameters

      • table: string

        Table name.

        -

      Returns SenderBuffer

      Returns with a reference to this buffer.

      -
    • Writes a symbol name and value into the buffer.
      -Use it to insert into SYMBOL columns.

      -

      Parameters

      • name: string

        Symbol name.

        -
      • value: unknown

        Symbol value, toString() is called to extract the actual symbol value from the parameter.

        -

      Returns SenderBuffer

      Returns with a reference to this buffer.

      -
    • Writes a string column with its value into the buffer.
      -Use it to insert into VARCHAR and STRING columns.

      -

      Parameters

      • name: string

        Column name.

        -
      • value: string

        Column value, accepts only string values.

        -

      Returns SenderBuffer

      Returns with a reference to this buffer.

      -
    • Writes a boolean column with its value into the buffer.
      -Use it to insert into BOOLEAN columns.

      -

      Parameters

      • name: string

        Column name.

        -
      • value: boolean

        Column value, accepts only boolean values.

        -

      Returns SenderBuffer

      Returns with a reference to this buffer.

      -
    • Writes a 64-bit signed integer into the buffer.
      -Use it to insert into LONG, INT, SHORT and BYTE columns.

      -

      Parameters

      • name: string

        Column name.

        -
      • value: number

        Column value, accepts only number values.

        -

      Returns SenderBuffer

      Returns with a reference to this buffer.

      -

      Error if the value is not an integer

      -
    • Writes a timestamp column and its value into the buffer.

      -

      Use this method to insert data into TIMESTAMP or TIMESTAMP_NS columns.

      -

      Precision rules:

      -
        -
      • Protocol v2 and higher: -Timestamps passed with unit 'ns' (nanoseconds) are sent with full nanosecond precision. -All other timestamps are sent with microsecond precision.
      • -
      • Protocol v1: -Always uses microsecond precision, even if the timestamp is specified in nanoseconds.
      • -
      -

      Parameters

      • name: string

        The column name.

        -
      • value: number | bigint

        The epoch timestamp. Must be an integer or a BigInt.

        -
      • Optionalunit: TimestampUnit = "us"

        The time unit of the timestamp. -Supported values:

        -
          -
        • 'ns' — nanoseconds (requires BigInt)
        • -
        • 'us' — microseconds (default)
        • -
        • 'ms' — milliseconds
        • -
        -

      Returns SenderBuffer

      Returns with a reference to this buffer.

      -

      If value is not an integer or BigInt.

      -

      If unit is 'ns' but value is not a BigInt.

      -
    • Closes the row after writing the designated timestamp into the buffer.

      -

      Precision rules:

      -
        -
      • Protocol v2 and higher: -Timestamps passed with unit 'ns' (nanoseconds) are sent with full nanosecond precision. -All other timestamps are sent with microsecond precision.
      • -
      • Protocol v1: -Always uses microsecond precision, even if the timestamp is specified in nanoseconds.
      • -
      -

      Parameters

      • timestamp: number | bigint

        Designated epoch timestamp. Must be an integer or a BigInt.

        -
      • Optionalunit: TimestampUnit = "us"

        The time unit of the timestamp. -Supported values:

        -
          -
        • 'ns' — nanoseconds (requires BigInt)
        • -
        • 'us' — microseconds (default)
        • -
        • 'ms' — milliseconds
        • -
        -

      Returns void

      Returns with a reference to this buffer.

      -

      If value is not an integer or BigInt.

      -

      If unit is 'ns' but value is not a BigInt.

      -
    • Closes the row without writing designated timestamp into the buffer.
      -Designated timestamp will be populated by the server on this record.

      -

      Returns void

    • Returns the current position of the buffer.
      -New data will be written into the buffer starting from this position.

      -

      Returns number

    • Checks if the buffer has sufficient capacity for additional data and resizes if needed.

      -

      Parameters

      • data: string[]

        Array of strings to calculate the required capacity for

        -
      • base: number = 0

        Base number of bytes to add to the calculation

        -

      Returns void

    • Writes a decimal value into the buffer using its text format.

      -

      Use it to insert into DECIMAL database columns.

      -

      Parameters

      • name: string

        Column name.

        -
      • value: string | number

        The decimal value to write.

        -
          -
        • Accepts either a number or a string containing a valid decimal representation.
        • -
        • String values should follow standard decimal notation (e.g., "123.45" or "-0.001").
        • -
        -

      Returns SenderBuffer

      Returns with a reference to this buffer.

      -

      Error If decimals are not supported by the buffer implementation, or validation fails. -Possible validation errors:

      -
        -
      • The provided string is not a valid decimal representation.
      • -
      -
    • Writes a decimal value into the buffer using its binary format.

      -

      Use it to insert into DECIMAL database columns.

      -

      Parameters

      • name: string

        Column name.

        -
      • unscaled: bigint | Int8Array<ArrayBufferLike>

        The unscaled integer portion of the decimal value.

        -
          -
        • If a bigint is provided, it will be converted automatically.
        • -
        • If an Int8Array is provided, it must contain the two’s complement representation -of the unscaled value in big-endian byte order.
        • -
        • An empty Int8Array represents a NULL value.
        • -
        -
      • scale: number

        The number of fractional digits (the scale) of the decimal value.

        -

      Returns SenderBuffer

      Returns with a reference to this buffer.

      -

      If decimals are not supported by the buffer implementation, or validation fails. -Possible validation errors:

      -
        -
      • unscaled length is not between 0 and 32 bytes.
      • -
      • scale is not between 0 and 76.
      • -
      • unscaled contains invalid bytes.
      • -
      -
    • Writes a 64-bit floating point value into the buffer using v2 serialization (binary format).
      -Use it to insert into DOUBLE or FLOAT database columns.

      -

      Parameters

      • name: string

        Column name.

        -
      • value: number

        Column value, accepts only number values.

        -

      Returns SenderBuffer

      Returns with a reference to this buffer.

      -
    • Write an array column with its values into the buffer using v2 format.

      -

      Parameters

      • name: string

        Column name

        -
      • value: unknown[]

        Array values to write (currently supports double arrays)

        -

      Returns SenderBuffer

      Returns with a reference to this buffer.

      -

      Error if array validation fails:

      -
        -
      • value is not an array
      • -
      • or the shape of the array is irregular: the length of sub-arrays are different
      • -
      • or the array is not homogeneous: its elements are not all the same type
      • -
      -
    diff --git a/docs/classes/SenderOptions.html b/docs/classes/SenderOptions.html deleted file mode 100644 index d962a12..0000000 --- a/docs/classes/SenderOptions.html +++ /dev/null @@ -1,167 +0,0 @@ -SenderOptions | QuestDB Node.js Client - v4.2.0

    Class SenderOptions

    Sender configuration options. -
    -Properties of the object are initialized through a configuration string.
    -The configuration string has the following format: protocol::key=value;key=value...
    -The keys are case-sensitive, the trailing semicolon is optional.
    -The values are validated and an error is thrown if the format is invalid.
    -
    -Connection and protocol options

    -
      -
    • protocol: enum, accepted values: http, https, tcp, tcps - The protocol used to communicate with the server.
      -When https or tcps used, the connection is secured with TLS encryption. -
    • -
    • protocol_version: enum, accepted values: auto, 1, 2 - The protocol version used for data serialization.
      -Version 1 uses text-based serialization for all data types. Version 2 uses binary encoding for doubles and arrays.
      -When set to 'auto' (default for HTTP/HTTPS), the client automatically negotiates the highest supported version with the server.
      -TCP/TCPS connections default to version 1. -
    • -
    • addr: string - Hostname and port, separated by colon. This key is mandatory, but the port part is optional.
      -If no port is specified, a default will be used.
      -When the protocol is HTTP/HTTPS, the port defaults to 9000. When the protocol is TCP/TCPS, the port defaults to 9009.
      -
      -Examples: http::addr=localhost:9000, https::addr=localhost:9000, http::addr=localhost, tcp::addr=localhost:9009 -
    • -
    -
    -Authentication options -
      -
    • username: string - Used for authentication.
      -For HTTP, Basic Authentication requires the password option.
      -For TCP with JWK token authentication, token option is required. -
    • -
    • password: string - Password for HTTP Basic authentication, should be accompanied by the username option. -
    • -
    • token: string - For HTTP with Bearer authentication, this is the bearer token.
      -For TCP with JWK token authentication, this is the private key part of the JWK token, -and must be accompanied by the username option. -
    • -
    -
    -TLS options -
      -
    • tls_verify: enum, accepted values: on, unsafe_off - When the HTTPS or TCPS protocols are selected, TLS encryption is used.
      -By default, the Sender will verify the server's certificate, but this check can be disabled by setting this option to unsafe_off.
      -This is useful in non-production environments where self-signed certificates might be used, but should be avoided in production if possible. -
    • -
    • tls_ca: string - Path to a file containing the root CA's certificate in PEM format.
      -Can be useful when self-signed certificates are used, otherwise should not be set. -
    • -
    -
    -Auto flush options -
      -
    • auto_flush: enum, accepted values: on, off - The Sender automatically flushes the buffer by default. This can be switched off -by setting this option to off.
      -When disabled, the flush() method of the Sender has to be called explicitly to make sure data is sent to the server.
      -Manual buffer flushing can be useful, especially when we want to control transaction boundaries.
      -When the HTTP protocol is used, each flush results in a single HTTP request, which becomes a single transaction on the server side.
      -The transaction either succeeds, and all rows sent in the request are inserted; or it fails, and none of the rows make it into the database. -
    • -
    • auto_flush_rows: integer - The number of rows that will trigger a flush. When set to 0, row-based flushing is disabled.
      -The Sender will default this parameter to 75000 rows when HTTP protocol is used, and to 600 in case of TCP protocol. -
    • -
    • auto_flush_interval: integer - The number of milliseconds that will trigger a flush, default value is 1000. -When set to 0, interval-based flushing is disabled.
      -Note that the setting is checked only when a new row is added to the buffer. There is no timer registered to flush the buffer automatically. -
    • -
    -
    -Buffer sizing options -
      -
    • init_buf_size: integer - Initial buffer size, defaults to 64 KiB in the Sender. -
    • -
    • max_buf_size: integer - Maximum buffer size, defaults to 100 MiB in the Sender.
      -If the buffer would need to be extended beyond the maximum size, an error is thrown. -
    • -
    -
    -HTTP request specific options -
      -
    • request_timeout: integer - The time in milliseconds to wait for a response from the server, set to 10 seconds by default.
      -This is in addition to the calculation derived from the request_min_throughput parameter. -
    • -
    • request_min_throughput: integer - Minimum expected throughput in bytes per second for HTTP requests, set to 100 KiB/s seconds by default.
      -If the throughput is lower than this value, the connection will time out. This is used to calculate an additional -timeout on top of request_timeout. This is useful for large requests. You can set this value to 0 to disable this logic. -
    • -
    • retry_timeout: integer - The time in milliseconds to continue retrying after a failed HTTP request, set to 10 seconds by default.
      -The interval between retries is an exponential backoff starting at 10ms and doubling after each failed attempt up to a maximum of 1 second. -
    • -
    -
    -Other options -
      -
    • stdlib_http: enum, accepted values: on, off - With HTTP protocol the Undici library is used by default. By setting this option -to on the client switches to node's core http and https modules. -
    • -
    • max_name_len: integer - The maximum length of a table or column name, the Sender defaults this parameter to 127.
      -Recommended to use the same setting as the server, which also uses 127 by default. -
    • -
    Index

    Constructors

    • Creates a Sender options object by parsing the provided configuration string.

      -

      Parameters

      • configurationString: string

        Configuration string.

        -
      • OptionalextraOptions: ExtraOptions

        Optional extra configuration.

        -
          -
        • 'log' is a logging function used by the Sender. -Prototype: (level: 'error'|'warn'|'info'|'debug', message: string) => void.
        • -
        • 'agent' is a custom http/https agent used by the Sender when http/https transport is used. -Depends on which transport implementation and protocol used, one of the followings expected: undici.Agent, http.Agent or https.Agent.
        • -
        -

      Returns SenderOptions

    Properties

    protocol: string
    protocol_version?: string
    addr?: string
    host?: string
    port?: number
    username?: string
    password?: string
    token?: string
    token_x?: string
    token_y?: string
    auto_flush?: boolean
    auto_flush_rows?: number
    auto_flush_interval?: number
    request_min_throughput?: number
    request_timeout?: number
    retry_timeout?: number
    init_buf_size?: number
    max_buf_size?: number
    tls_verify?: boolean
    tls_ca?: PathOrFileDescriptor
    tls_roots?: never
    tls_roots_password?: never
    max_name_len?: number
    log?: Logger
    agent?: Agent | Agent | Agent
    stdlib_http?: boolean
    auth?: { username?: string; keyId?: string; password?: string; token?: string }
    jwk?: Record<string, string>

    Methods

    • Resolves the protocol version, if it is set to 'auto'.
      -If TCP transport is used, the protocol version will default to 1. -In case of HTTP transport the /settings endpoint of the database is used to find the protocol versions -supported by the server, and the highest will be selected. -When calling the /settings endpoint the timeout and TLS options are used from the options object.

      -

      Parameters

      • options: SenderOptions

        SenderOptions instance needs resolving protocol version

        -

      Returns Promise<SenderOptions>

    • Creates a Sender options object by parsing the provided configuration string.

      -

      Parameters

      • configurationString: string

        Configuration string.

        -
      • OptionalextraOptions: ExtraOptions

        Optional extra configuration.

        -
          -
        • 'log' is a logging function used by the Sender. -Prototype: (level: 'error'|'warn'|'info'|'debug', message: string) => void.
        • -
        • 'agent' is a custom http/https agent used by the Sender when http/https transport is used. -Depends on which transport implementation and protocol used, one of the followings expected: undici.Agent, http.Agent or https.Agent.
        • -
        -

      Returns Promise<SenderOptions>

      A Sender configuration object initialized from the provided configuration string.

      -
    • Creates a Sender options object by parsing the configuration string set in the QDB_CLIENT_CONF environment variable.

      -

      Parameters

      • OptionalextraOptions: ExtraOptions

        Optional extra configuration.

        -
          -
        • 'log' is a logging function used by the Sender. -Prototype: (level: 'error'|'warn'|'info'|'debug', message: string) => void.
        • -
        • 'agent' is a custom http/https agent used by the Sender when http/https transport is used. -Depends on which transport implementation and protocol used, one of the followings expected: undici.Agent, http.Agent or https.Agent.
        • -
        -

      Returns Promise<SenderOptions>

      A Sender configuration object initialized from the QDB_CLIENT_CONF environment variable.

      -
    diff --git a/docs/classes/TcpTransport.html b/docs/classes/TcpTransport.html deleted file mode 100644 index ea482e4..0000000 --- a/docs/classes/TcpTransport.html +++ /dev/null @@ -1,21 +0,0 @@ -TcpTransport | QuestDB Node.js Client - v4.2.0

    Class TcpTransport

    TCP transport implementation.
    -Supports both plain TCP or secure TLS-encrypted connections with configurable JWK token authentication.

    -

    Implements

    Index

    Constructors

    Methods

    • Creates a TCP connection to the database.

      -

      Returns Promise<boolean>

      Promise resolving to true if the connection is established successfully

      -

      Error if connection fails or authentication is rejected

      -
    • Sends data over the established TCP connection.

      -

      Parameters

      • data: Buffer

        Buffer containing the data to send

        -

      Returns Promise<boolean>

      Promise resolving to true if data was sent successfully

      -

      Error if the data could not be written to the socket

      -
    diff --git a/docs/classes/UndiciTransport.html b/docs/classes/UndiciTransport.html deleted file mode 100644 index c1c1516..0000000 --- a/docs/classes/UndiciTransport.html +++ /dev/null @@ -1,34 +0,0 @@ -UndiciTransport | QuestDB Node.js Client - v4.2.0

    Class UndiciTransport

    HTTP transport implementation using the Undici library.
    -Provides high-performance HTTP requests with connection pooling and retry logic.
    -Supports both HTTP and HTTPS protocols with configurable authentication.

    -

    Hierarchy

    • HttpTransportBase
      • UndiciTransport
    Index

    Constructors

    Properties

    secure: boolean
    host: string
    port: number
    username: string
    password: string
    token: string
    tlsVerify: boolean
    tlsCA: Buffer
    requestMinThroughput: number
    requestTimeout: number
    retryTimeout: number
    log: Logger

    Methods

    • HTTP transport does not require explicit connection establishment.

      -

      Returns Promise<boolean>

      Error indicating connect is not required for HTTP transport

      -
    • HTTP transport does not require explicit connection closure.

      -

      Returns Promise<void>

      Promise that resolves immediately

      -
    • Gets the default auto-flush row count for HTTP transport.

      -

      Returns number

      Default number of rows that trigger auto-flush

      -
    • Sends data to QuestDB using HTTP POST.

      -

      Parameters

      • data: Buffer

        Buffer containing the data to send

        -

      Returns Promise<boolean>

      Promise resolving to true if data was sent successfully

      -

      Error if request fails after all retries or times out

      -
    diff --git a/docs/classes/_questdb_browser-client.QwpBatchTooLargeError.html b/docs/classes/_questdb_browser-client.QwpBatchTooLargeError.html new file mode 100644 index 0000000..b7b5373 --- /dev/null +++ b/docs/classes/_questdb_browser-client.QwpBatchTooLargeError.html @@ -0,0 +1,7 @@ +QwpBatchTooLargeError | QuestDB JavaScript Client - v4.2.0

    Hierarchy

    • RangeError
      • QwpBatchTooLargeError
    Index

    Constructors

    Properties

    batchSizeBytes: number
    maxBatchSizeBytes: number
    message: string
    name: string
    stack?: string
    diff --git a/docs/classes/_questdb_browser-client.QwpBindValues.html b/docs/classes/_questdb_browser-client.QwpBindValues.html new file mode 100644 index 0000000..dbeec65 --- /dev/null +++ b/docs/classes/_questdb_browser-client.QwpBindValues.html @@ -0,0 +1,34 @@ +QwpBindValues | QuestDB JavaScript Client - v4.2.0

    Browser-safe typed positional bind encoder.

    +

    Setters must be called in ascending zero-based index order. SQL placeholders +are one-based, so index 0 binds $1, index 1 binds $2, and so on.

    +
    Index

    Constructors

    Accessors

    Methods

    diff --git a/docs/classes/_questdb_browser-client.QwpBrowserSessionBootstrapError.html b/docs/classes/_questdb_browser-client.QwpBrowserSessionBootstrapError.html new file mode 100644 index 0000000..a4686c2 --- /dev/null +++ b/docs/classes/_questdb_browser-client.QwpBrowserSessionBootstrapError.html @@ -0,0 +1,23 @@ +QwpBrowserSessionBootstrapError | QuestDB JavaScript Client - v4.2.0

    Class QwpBrowserSessionBootstrapError

    An HTTP rejection while creating a browser qdb_session cookie.

    +

    Hierarchy (View Summary)

    Index

    Constructors

    Properties

    cause?: unknown
    closeCode?: number
    message: string
    name: string
    responseBody: string
    retryable?: boolean
    serverRole?: string
    serverZone?: string
    stack?: string
    statusCode?: number
    statusMessage?: string
    timeoutPhase?: QwpUpgradeTimeoutPhase

    Node opening phase that exceeded its deadline.

    +
    tryNextEndpoint?: boolean
    url?: string | URL

    Accessors

    diff --git a/docs/classes/_questdb_browser-client.QwpByteReader.html b/docs/classes/_questdb_browser-client.QwpByteReader.html new file mode 100644 index 0000000..46ceaf1 --- /dev/null +++ b/docs/classes/_questdb_browser-client.QwpByteReader.html @@ -0,0 +1,19 @@ +QwpByteReader | QuestDB JavaScript Client - v4.2.0

    A bounds-checked, runtime-neutral little-endian byte reader.

    +
    Index

    Constructors

    Properties

    bytes: Uint8Array

    Accessors

    Methods

    diff --git a/docs/classes/_questdb_browser-client.QwpByteWriter.html b/docs/classes/_questdb_browser-client.QwpByteWriter.html new file mode 100644 index 0000000..07642eb --- /dev/null +++ b/docs/classes/_questdb_browser-client.QwpByteWriter.html @@ -0,0 +1,21 @@ +QwpByteWriter | QuestDB JavaScript Client - v4.2.0

    A growable, runtime-neutral little-endian byte writer.

    +
    Index

    Constructors

    Accessors

    Methods

    diff --git a/docs/classes/_questdb_browser-client.QwpClient.html b/docs/classes/_questdb_browser-client.QwpClient.html new file mode 100644 index 0000000..b11d8d8 --- /dev/null +++ b/docs/classes/_questdb_browser-client.QwpClient.html @@ -0,0 +1,15 @@ +QwpClient | QuestDB JavaScript Client - v4.2.0

    Browser-safe facade owning bounded ingress and egress connection pools. +Borrowed handles are exclusive; separate query leases execute concurrently.

    +
    Index

    Constructors

    Accessors

    Methods

    Constructors

    Accessors

    Methods

    • Rejects new borrows and closes idle resources. Borrowed query sessions are +cancelled and closed; borrowed senders retain ownership during a bounded +drain and own their teardown if they outlive it.

      +

      Returns Promise<void>

    diff --git a/docs/classes/_questdb_browser-client.QwpClientClosedError.html b/docs/classes/_questdb_browser-client.QwpClientClosedError.html new file mode 100644 index 0000000..71cbf68 --- /dev/null +++ b/docs/classes/_questdb_browser-client.QwpClientClosedError.html @@ -0,0 +1,6 @@ +QwpClientClosedError | QuestDB JavaScript Client - v4.2.0

    The owning QWP client, or one of its returned lease handles, is closed.

    +

    Hierarchy

    • Error
      • QwpClientClosedError
    Index

    Constructors

    Properties

    Constructors

    Properties

    message: string
    name: string
    stack?: string
    diff --git a/docs/classes/_questdb_browser-client.QwpDurableAckUnavailableError.html b/docs/classes/_questdb_browser-client.QwpDurableAckUnavailableError.html new file mode 100644 index 0000000..e6d06aa --- /dev/null +++ b/docs/classes/_questdb_browser-client.QwpDurableAckUnavailableError.html @@ -0,0 +1,22 @@ +QwpDurableAckUnavailableError | QuestDB JavaScript Client - v4.2.0

    Class QwpDurableAckUnavailableError

    A requested durable-ACK capability was not confirmed by the server.

    +

    Hierarchy (View Summary)

    Index

    Constructors

    Properties

    cause?: unknown
    closeCode?: number
    message: string
    name: string
    retryable?: boolean
    serverRole?: string
    serverZone?: string
    stack?: string
    statusCode?: number
    statusMessage?: string
    timeoutPhase?: QwpUpgradeTimeoutPhase

    Node opening phase that exceeded its deadline.

    +
    tryNextEndpoint?: boolean
    url: string | URL

    Accessors

    diff --git a/docs/classes/_questdb_browser-client.QwpEgressQuery.html b/docs/classes/_questdb_browser-client.QwpEgressQuery.html new file mode 100644 index 0000000..c693b0c --- /dev/null +++ b/docs/classes/_questdb_browser-client.QwpEgressQuery.html @@ -0,0 +1,42 @@ +QwpEgressQuery | QuestDB JavaScript Client - v4.2.0

    One QWP query/statement and its stream of materialized result batches.

    +

    Implements

    Index

    Constructors

    Properties

    completion: Promise<QwpQueryCompletion>
    requestId: bigint

    Accessors

    Methods

    • Waits for completion without changing the query lifecycle. A finite wait +returns false on expiry; the query remains active until it completes, is +cancelled explicitly, or its configured query deadline expires.

      +

      Parameters

      • timeoutMs: number

      Returns Promise<boolean>

    diff --git a/docs/classes/_questdb_browser-client.QwpEgressQueryAbandonedError.html b/docs/classes/_questdb_browser-client.QwpEgressQueryAbandonedError.html new file mode 100644 index 0000000..537aa0c --- /dev/null +++ b/docs/classes/_questdb_browser-client.QwpEgressQueryAbandonedError.html @@ -0,0 +1,7 @@ +QwpEgressQueryAbandonedError | QuestDB JavaScript Client - v4.2.0

    Class QwpEgressQueryAbandonedError

    Result iteration ended before the server completed the query.

    +

    Hierarchy

    • Error
      • QwpEgressQueryAbandonedError
    Index

    Constructors

    Properties

    Constructors

    Properties

    message: string
    name: string
    requestId: bigint
    stack?: string
    diff --git a/docs/classes/_questdb_browser-client.QwpEgressQueryCancelTimeoutError.html b/docs/classes/_questdb_browser-client.QwpEgressQueryCancelTimeoutError.html new file mode 100644 index 0000000..72cca1b --- /dev/null +++ b/docs/classes/_questdb_browser-client.QwpEgressQueryCancelTimeoutError.html @@ -0,0 +1,8 @@ +QwpEgressQueryCancelTimeoutError | QuestDB JavaScript Client - v4.2.0

    Class QwpEgressQueryCancelTimeoutError

    The server did not terminate a cancelled query within the drain deadline.

    +

    Hierarchy

    • Error
      • QwpEgressQueryCancelTimeoutError
    Index

    Constructors

    Properties

    Constructors

    Properties

    message: string
    name: string
    requestId: bigint
    stack?: string
    timeoutMs: number
    diff --git a/docs/classes/_questdb_browser-client.QwpEgressQueryError.html b/docs/classes/_questdb_browser-client.QwpEgressQueryError.html new file mode 100644 index 0000000..20b3aeb --- /dev/null +++ b/docs/classes/_questdb_browser-client.QwpEgressQueryError.html @@ -0,0 +1,7 @@ +QwpEgressQueryError | QuestDB JavaScript Client - v4.2.0

    Hierarchy

    • Error
      • QwpEgressQueryError
    Index

    Constructors

    Properties

    Constructors

    Properties

    message: string
    name: string
    requestId: bigint
    stack?: string
    status: number
    diff --git a/docs/classes/_questdb_browser-client.QwpEgressQueryTimeoutError.html b/docs/classes/_questdb_browser-client.QwpEgressQueryTimeoutError.html new file mode 100644 index 0000000..1d354ee --- /dev/null +++ b/docs/classes/_questdb_browser-client.QwpEgressQueryTimeoutError.html @@ -0,0 +1,8 @@ +QwpEgressQueryTimeoutError | QuestDB JavaScript Client - v4.2.0

    A client-side query deadline expired and a QWP CANCEL was sent.

    +

    Hierarchy

    • Error
      • QwpEgressQueryTimeoutError
    Index

    Constructors

    Properties

    Constructors

    Properties

    message: string
    name: string
    requestId: bigint
    stack?: string
    timeoutMs: number
    diff --git a/docs/classes/_questdb_browser-client.QwpEgressReplayRequiredError.html b/docs/classes/_questdb_browser-client.QwpEgressReplayRequiredError.html new file mode 100644 index 0000000..b6dab7b --- /dev/null +++ b/docs/classes/_questdb_browser-client.QwpEgressReplayRequiredError.html @@ -0,0 +1,9 @@ +QwpEgressReplayRequiredError | QuestDB JavaScript Client - v4.2.0

    Class QwpEgressReplayRequiredError

    Standard egress sessions now reset and replay automatically. +Retained for source compatibility with clients that classified the former +explicit-replay opt-in failure.

    +

    Hierarchy

    • Error
      • QwpEgressReplayRequiredError
    Index

    Constructors

    Properties

    Constructors

    Properties

    message: string
    name: string
    requestId?: bigint
    stack?: string
    diff --git a/docs/classes/_questdb_browser-client.QwpEgressSession.html b/docs/classes/_questdb_browser-client.QwpEgressSession.html new file mode 100644 index 0000000..eb3fa25 --- /dev/null +++ b/docs/classes/_questdb_browser-client.QwpEgressSession.html @@ -0,0 +1,39 @@ +QwpEgressSession | QuestDB JavaScript Client - v4.2.0

    Browser-safe QWP egress session.

    +

    The server currently executes one query at a time per connection, so this +session deliberately rejects overlapping query calls. A completed query's +materialized batches may still be consumed while the next query runs.

    +

    Implements

    • QwpEgressQueryControl
    Index

    Constructors

    Properties

    ready: Promise<QwpServerInfoMessage>

    Initial SERVER_INFO; use serverInfo for the current post-failover snapshot.

    +

    Accessors

    Methods

    • Parameters

      • requestId: bigint
      • additionalBytes: number | bigint

      Returns Promise<void>

    • Internal

      Cancels and drains an active operation before a pooled lease is returned. +False means the physical session is no longer safe to reuse.

      +

      Returns Promise<boolean>

    • Internal

      Best-effort cancellation followed by physical connection teardown for +facade shutdown. Unlike pooled lease return, this does not wait for the +server to finish draining the cancelled query.

      +

      Returns Promise<void>

    diff --git a/docs/classes/_questdb_browser-client.QwpEgressSessionClosedError.html b/docs/classes/_questdb_browser-client.QwpEgressSessionClosedError.html new file mode 100644 index 0000000..4c66862 --- /dev/null +++ b/docs/classes/_questdb_browser-client.QwpEgressSessionClosedError.html @@ -0,0 +1,6 @@ +QwpEgressSessionClosedError | QuestDB JavaScript Client - v4.2.0

    Hierarchy

    • Error
      • QwpEgressSessionClosedError
    Index

    Constructors

    Properties

    Constructors

    Properties

    message: string
    name: string
    stack?: string
    diff --git a/docs/classes/_questdb_browser-client.QwpFailoverError.html b/docs/classes/_questdb_browser-client.QwpFailoverError.html new file mode 100644 index 0000000..0dc2a1d --- /dev/null +++ b/docs/classes/_questdb_browser-client.QwpFailoverError.html @@ -0,0 +1,8 @@ +QwpFailoverError | QuestDB JavaScript Client - v4.2.0

    Every eligible QWP endpoint in one connection sweep failed.

    +

    Hierarchy

    • Error
      • QwpFailoverError
    Index

    Constructors

    Properties

    Constructors

    Properties

    attempts: readonly QwpFailoverAttempt[]
    cause?: unknown
    message: string
    name: string
    stack?: string
    diff --git a/docs/classes/_questdb_browser-client.QwpIngressAckTimeoutError.html b/docs/classes/_questdb_browser-client.QwpIngressAckTimeoutError.html new file mode 100644 index 0000000..8baed70 --- /dev/null +++ b/docs/classes/_questdb_browser-client.QwpIngressAckTimeoutError.html @@ -0,0 +1,9 @@ +QwpIngressAckTimeoutError | QuestDB JavaScript Client - v4.2.0

    The ingress ACK watermark did not reach the requested frame in time.

    +

    Hierarchy

    • Error
      • QwpIngressAckTimeoutError
    Index

    Constructors

    Properties

    acknowledgedSequence: bigint
    message: string
    name: string
    stack?: string
    targetSequence: bigint
    timeoutMs: number
    diff --git a/docs/classes/_questdb_browser-client.QwpIngressNackError.html b/docs/classes/_questdb_browser-client.QwpIngressNackError.html new file mode 100644 index 0000000..62fcf79 --- /dev/null +++ b/docs/classes/_questdb_browser-client.QwpIngressNackError.html @@ -0,0 +1,7 @@ +QwpIngressNackError | QuestDB JavaScript Client - v4.2.0

    Hierarchy

    • Error
      • QwpIngressNackError
    Index

    Constructors

    Properties

    Constructors

    Properties

    message: string
    name: string
    senderError: QwpSenderError = ...
    stack?: string
    diff --git a/docs/classes/_questdb_browser-client.QwpIngressSession.html b/docs/classes/_questdb_browser-client.QwpIngressSession.html new file mode 100644 index 0000000..b020144 --- /dev/null +++ b/docs/classes/_questdb_browser-client.QwpIngressSession.html @@ -0,0 +1,64 @@ +QwpIngressSession | QuestDB JavaScript Client - v4.2.0

    Connection-scoped ingress sequencer.

    +

    One promise is registered before each WebSocket send, preventing a fast ACK +from racing its waiter. Calls are serialized to preserve the server's +zero-based wire sequence. Successful ACKs are cumulative, so an ACK for +sequence N resolves every outstanding send through N.

    +
    Index

    Constructors

    Accessors

    • get acknowledgedFrameSequence(): bigint

      Highest cumulative ACK watermark. When durable ACK was negotiated this +advances only after durability; otherwise it follows ordinary OK ACKs.

      +

      Returns bigint

    Methods

    • Prompts the server to publish its latest durable-ingress watermarks. +Node transports use a WebSocket PING; browsers send the protocol-level +table-less durable-ACK poll frame. Browser completion means the control +frame was published; durable progress arrives independently because the +server may withhold its cumulative OK while a transaction remains open.

      +

      Returns Promise<void>

    • Publishes one pre-encoded frame without allocating an ACK waiter. +Applications can observe later acceptance through progress callbacks.

      +

      Parameters

      • frame: Uint8Array

      Returns Promise<void>

    • Waits independently for the cumulative frame ACK watermark. A negative +target is already satisfied, but still surfaces a latched session error.

      +

      Parameters

      • targetSequence: bigint
      • timeoutMs: number = ...

      Returns Promise<void>

    diff --git a/docs/classes/_questdb_browser-client.QwpIngressSessionClosedError.html b/docs/classes/_questdb_browser-client.QwpIngressSessionClosedError.html new file mode 100644 index 0000000..bf8ee61 --- /dev/null +++ b/docs/classes/_questdb_browser-client.QwpIngressSessionClosedError.html @@ -0,0 +1,6 @@ +QwpIngressSessionClosedError | QuestDB JavaScript Client - v4.2.0

    Class QwpIngressSessionClosedError

    Hierarchy

    • Error
      • QwpIngressSessionClosedError
    Index

    Constructors

    Properties

    Constructors

    Properties

    message: string
    name: string
    stack?: string
    diff --git a/docs/classes/_questdb_browser-client.QwpMemoryReplayAppendTimeoutError.html b/docs/classes/_questdb_browser-client.QwpMemoryReplayAppendTimeoutError.html new file mode 100644 index 0000000..2a11cff --- /dev/null +++ b/docs/classes/_questdb_browser-client.QwpMemoryReplayAppendTimeoutError.html @@ -0,0 +1,10 @@ +QwpMemoryReplayAppendTimeoutError | QuestDB JavaScript Client - v4.2.0

    Class QwpMemoryReplayAppendTimeoutError

    ACK-driven trimming did not free in-memory replay capacity in time.

    +

    Hierarchy

    • Error
      • QwpMemoryReplayAppendTimeoutError
    Index

    Constructors

    Properties

    maxBytes: number
    message: string
    name: string
    requiredBytes: number
    stack?: string
    timeoutMs: number
    usedBytes: number
    diff --git a/docs/classes/_questdb_browser-client.QwpMemoryReplayFrameTooLargeError.html b/docs/classes/_questdb_browser-client.QwpMemoryReplayFrameTooLargeError.html new file mode 100644 index 0000000..8d4bf4a --- /dev/null +++ b/docs/classes/_questdb_browser-client.QwpMemoryReplayFrameTooLargeError.html @@ -0,0 +1,9 @@ +QwpMemoryReplayFrameTooLargeError | QuestDB JavaScript Client - v4.2.0

    Class QwpMemoryReplayFrameTooLargeError

    One frame can never fit in the configured in-memory replay budget.

    +

    Hierarchy

    • RangeError
      • QwpMemoryReplayFrameTooLargeError
    Index

    Constructors

    Properties

    maxBytes: number
    message: string
    name: string
    payloadBytes: number
    requiredBytes: number
    stack?: string
    diff --git a/docs/classes/_questdb_browser-client.QwpPoolAcquireTimeoutError.html b/docs/classes/_questdb_browser-client.QwpPoolAcquireTimeoutError.html new file mode 100644 index 0000000..dc1c193 --- /dev/null +++ b/docs/classes/_questdb_browser-client.QwpPoolAcquireTimeoutError.html @@ -0,0 +1,8 @@ +QwpPoolAcquireTimeoutError | QuestDB JavaScript Client - v4.2.0

    A bounded QWP pool could not provide a connection before its deadline.

    +

    Hierarchy

    • Error
      • QwpPoolAcquireTimeoutError
    Index

    Constructors

    Properties

    Constructors

    Properties

    message: string
    name: string
    resource: "sender" | "query"
    stack?: string
    timeoutMs: number
    diff --git a/docs/classes/_questdb_browser-client.QwpPoolResourceError.html b/docs/classes/_questdb_browser-client.QwpPoolResourceError.html new file mode 100644 index 0000000..859cf43 --- /dev/null +++ b/docs/classes/_questdb_browser-client.QwpPoolResourceError.html @@ -0,0 +1,8 @@ +QwpPoolResourceError | QuestDB JavaScript Client - v4.2.0

    A pooled resource failed while a new slot was being connected.

    +

    Hierarchy

    • Error
      • QwpPoolResourceError
    Index

    Constructors

    Properties

    Constructors

    Properties

    cause: unknown
    message: string
    name: string
    resource: "sender" | "query"
    stack?: string
    diff --git a/docs/classes/_questdb_browser-client.QwpProtocolError.html b/docs/classes/_questdb_browser-client.QwpProtocolError.html new file mode 100644 index 0000000..aefc4e4 --- /dev/null +++ b/docs/classes/_questdb_browser-client.QwpProtocolError.html @@ -0,0 +1,6 @@ +QwpProtocolError | QuestDB JavaScript Client - v4.2.0

    Raised when a QWP payload is malformed, truncated, or unsupported.

    +

    Hierarchy

    • Error
      • QwpProtocolError
    Index

    Constructors

    Properties

    Constructors

    Properties

    message: string
    name: string
    stack?: string
    diff --git a/docs/classes/_questdb_browser-client.QwpQueryLease.html b/docs/classes/_questdb_browser-client.QwpQueryLease.html new file mode 100644 index 0000000..38a60be --- /dev/null +++ b/docs/classes/_questdb_browser-client.QwpQueryLease.html @@ -0,0 +1,14 @@ +QwpQueryLease | QuestDB JavaScript Client - v4.2.0

    One exclusively borrowed egress session from a QwpClient query pool.

    +
    Index

    Constructors

    Properties

    ready: Promise<QwpServerInfoMessage>

    Initial SERVER_INFO; use serverInfo for the current post-failover snapshot.

    +

    Accessors

    Methods

    diff --git a/docs/classes/_questdb_browser-client.QwpReconnectExhaustedError.html b/docs/classes/_questdb_browser-client.QwpReconnectExhaustedError.html new file mode 100644 index 0000000..829b31e --- /dev/null +++ b/docs/classes/_questdb_browser-client.QwpReconnectExhaustedError.html @@ -0,0 +1,8 @@ +QwpReconnectExhaustedError | QuestDB JavaScript Client - v4.2.0

    A configured QWP reconnect policy exhausted its retry boundary.

    +

    Hierarchy

    • Error
      • QwpReconnectExhaustedError
    Index

    Constructors

    Properties

    Constructors

    Properties

    attempts: number
    cause: unknown
    message: string
    name: string
    stack?: string
    diff --git a/docs/classes/_questdb_browser-client.QwpReplayDictionaryError.html b/docs/classes/_questdb_browser-client.QwpReplayDictionaryError.html new file mode 100644 index 0000000..354e008 --- /dev/null +++ b/docs/classes/_questdb_browser-client.QwpReplayDictionaryError.html @@ -0,0 +1,7 @@ +QwpReplayDictionaryError | QuestDB JavaScript Client - v4.2.0

    A replay store cannot preserve the dictionary required by delta frames.

    +

    Hierarchy (View Summary)

    Index

    Constructors

    Properties

    Constructors

    Properties

    cause?: unknown
    message: string
    name: string
    stack?: string
    diff --git a/docs/classes/_questdb_browser-client.QwpReplayDictionaryPersistenceError.html b/docs/classes/_questdb_browser-client.QwpReplayDictionaryPersistenceError.html new file mode 100644 index 0000000..e851edc --- /dev/null +++ b/docs/classes/_questdb_browser-client.QwpReplayDictionaryPersistenceError.html @@ -0,0 +1,9 @@ +QwpReplayDictionaryPersistenceError | QuestDB JavaScript Client - v4.2.0

    Class QwpReplayDictionaryPersistenceError

    A replay dictionary sidecar rejected an append before its delta frame was +published. The reconnecting transport has permanently switched to full, +self-contained symbol encoding; retrying the logical batch is safe.

    +

    Hierarchy (View Summary)

    Index

    Constructors

    Properties

    Constructors

    Properties

    cause?: unknown
    message: string
    name: string
    stack?: string
    diff --git a/docs/classes/_questdb_browser-client.QwpReplayRejectedError.html b/docs/classes/_questdb_browser-client.QwpReplayRejectedError.html new file mode 100644 index 0000000..bb9e617 --- /dev/null +++ b/docs/classes/_questdb_browser-client.QwpReplayRejectedError.html @@ -0,0 +1,8 @@ +QwpReplayRejectedError | QuestDB JavaScript Client - v4.2.0

    A replayed ingress frame was rejected and remains in persistent storage.

    +

    Hierarchy

    • Error
      • QwpReplayRejectedError
    Index

    Constructors

    Properties

    Constructors

    Properties

    frameSequence: bigint
    message: string
    name: string
    stack?: string
    status: number
    diff --git a/docs/classes/_questdb_browser-client.QwpResultBatch.html b/docs/classes/_questdb_browser-client.QwpResultBatch.html new file mode 100644 index 0000000..f510743 --- /dev/null +++ b/docs/classes/_questdb_browser-client.QwpResultBatch.html @@ -0,0 +1,9 @@ +QwpResultBatch | QuestDB JavaScript Client - v4.2.0
    Index

    Constructors

    Properties

    Methods

    Constructors

    Properties

    batchSequence: bigint
    columns: readonly QwpResultColumn[]
    requestId: bigint
    rowCount: number
    tableName: string

    Methods

    diff --git a/docs/classes/_questdb_browser-client.QwpResultBatchDecoder.html b/docs/classes/_questdb_browser-client.QwpResultBatchDecoder.html new file mode 100644 index 0000000..60a9aa1 --- /dev/null +++ b/docs/classes/_questdb_browser-client.QwpResultBatchDecoder.html @@ -0,0 +1,13 @@ +QwpResultBatchDecoder | QuestDB JavaScript Client - v4.2.0

    Stateful decoder for connection-scoped QWP result batches.

    +
    Index

    Constructors

    Methods

    diff --git a/docs/classes/_questdb_browser-client.QwpResultBatchView.html b/docs/classes/_questdb_browser-client.QwpResultBatchView.html new file mode 100644 index 0000000..5d74992 --- /dev/null +++ b/docs/classes/_questdb_browser-client.QwpResultBatchView.html @@ -0,0 +1,25 @@ +QwpResultBatchView | QuestDB JavaScript Client - v4.2.0

    Batch-owned reusable view delivered by QwpEgressSession.queryViews(). +Access is invalid after the callback returns. materialize() creates an +independently owned QwpResultBatch when retention is required.

    +
    Index

    Constructors

    Accessors

    Methods

    • Internal

      Parameters

      • requestId: bigint
      • batchSequence: bigint
      • tableName: string
      • rowCount: number
      • layouts: QwpResultColumnViewLayout[]

      Returns this

    diff --git a/docs/classes/_questdb_browser-client.QwpResultColumnView.html b/docs/classes/_questdb_browser-client.QwpResultColumnView.html new file mode 100644 index 0000000..72a5aa1 --- /dev/null +++ b/docs/classes/_questdb_browser-client.QwpResultColumnView.html @@ -0,0 +1,56 @@ +QwpResultColumnView | QuestDB JavaScript Client - v4.2.0

    Reusable, zero-copy view over one QWP result column.

    +

    The view and every byte slice returned from it are valid only while the +surrounding queryViews() callback is running. Copy data that must outlive +the callback.

    +
    Index

    Constructors

    Properties

    columnIndex: number

    Accessors

    Methods

    • Raw packed non-null values. Fixed-width values use QWP little-endian +layout; booleans are bit-packed and variable-width columns contain their +uint32 offset table. SYMBOL returns undefined because IDs are varints.

      +

      Returns undefined | Uint8Array<ArrayBufferLike>

    diff --git a/docs/classes/_questdb_browser-client.QwpResultRowView.html b/docs/classes/_questdb_browser-client.QwpResultRowView.html new file mode 100644 index 0000000..f68de4e --- /dev/null +++ b/docs/classes/_questdb_browser-client.QwpResultRowView.html @@ -0,0 +1,38 @@ +QwpResultRowView | QuestDB JavaScript Client - v4.2.0

    Reusable row-pinned facade over a QwpResultBatchView.

    +

    The batch owns one instance and re-points it in place. It is valid only +while the surrounding queryViews() callback is running, and must not be +retained across forEachRow() iterations. Byte and array views returned by +its accessors remain zero-copy and have the same lifetime.

    +
    Index

    Constructors

    Accessors

    Methods

    diff --git a/docs/classes/_questdb_browser-client.QwpRoleMismatchError.html b/docs/classes/_questdb_browser-client.QwpRoleMismatchError.html new file mode 100644 index 0000000..42ba81e --- /dev/null +++ b/docs/classes/_questdb_browser-client.QwpRoleMismatchError.html @@ -0,0 +1,23 @@ +QwpRoleMismatchError | QuestDB JavaScript Client - v4.2.0

    A connected endpoint advertised a role that does not satisfy target.

    +

    Hierarchy (View Summary)

    Index

    Constructors

    Properties

    cause?: unknown
    closeCode?: number
    message: string
    name: string
    retryable?: boolean
    serverRole?: string
    serverZone?: string
    stack?: string
    statusCode?: number
    statusMessage?: string
    target: QwpTarget
    timeoutPhase?: QwpUpgradeTimeoutPhase

    Node opening phase that exceeded its deadline.

    +
    tryNextEndpoint?: boolean
    url?: string | URL

    Accessors

    diff --git a/docs/classes/_questdb_browser-client.QwpSendClosedError.html b/docs/classes/_questdb_browser-client.QwpSendClosedError.html new file mode 100644 index 0000000..500e8ad --- /dev/null +++ b/docs/classes/_questdb_browser-client.QwpSendClosedError.html @@ -0,0 +1,8 @@ +QwpSendClosedError | QuestDB JavaScript Client - v4.2.0

    A QWP send was rejected because its WebSocket closed.

    +

    Hierarchy (View Summary)

    Index

    Constructors

    Properties

    Constructors

    Properties

    cause?: unknown
    message: string
    name: string
    stack?: string
    diff --git a/docs/classes/_questdb_browser-client.QwpSendError.html b/docs/classes/_questdb_browser-client.QwpSendError.html new file mode 100644 index 0000000..862abe0 --- /dev/null +++ b/docs/classes/_questdb_browser-client.QwpSendError.html @@ -0,0 +1,7 @@ +QwpSendError | QuestDB JavaScript Client - v4.2.0

    A failure while handing a QWP frame to the WebSocket transport.

    +

    Hierarchy (View Summary)

    Index

    Constructors

    Properties

    Constructors

    Properties

    cause?: unknown
    message: string
    name: string
    stack?: string
    diff --git a/docs/classes/_questdb_browser-client.QwpSendTimeoutError.html b/docs/classes/_questdb_browser-client.QwpSendTimeoutError.html new file mode 100644 index 0000000..92d178f --- /dev/null +++ b/docs/classes/_questdb_browser-client.QwpSendTimeoutError.html @@ -0,0 +1,9 @@ +QwpSendTimeoutError | QuestDB JavaScript Client - v4.2.0

    The WebSocket did not drain a QWP frame before its send deadline.

    +

    Hierarchy (View Summary)

    Index

    Constructors

    Properties

    bufferedAmountBytes?: number
    cause?: unknown
    message: string
    name: string
    stack?: string
    timeoutMs: number
    diff --git a/docs/classes/_questdb_browser-client.QwpSender.html b/docs/classes/_questdb_browser-client.QwpSender.html new file mode 100644 index 0000000..66dcba6 --- /dev/null +++ b/docs/classes/_questdb_browser-client.QwpSender.html @@ -0,0 +1,78 @@ +QwpSender | QuestDB JavaScript Client - v4.2.0

    Browser-safe high-level QWP ingress API.

    +

    Applications normally obtain this class through create/connectQwpNodeSender +or create/connectQwpBrowserSender, rather than constructing sessions and +QwpTableBuffer instances themselves.

    +
    Index

    Constructors

    Accessors

    Methods

    • Commits rows previously sent by transactional auto-flush. This is an +ergonomic alias for flush(); pending local rows are included in the same +group-closing frame.

      +

      Returns Promise<boolean>

    • Adds a QuestDB DATE column value in milliseconds since the epoch. +-9_223_372_036_854_775_808n is QuestDB's DATE NULL sentinel: it is +stored as NULL and cannot be stored as an ordinary value.

      +

      Parameters

      • name: string
      • millisecondsSinceEpoch: undefined | null | number | bigint

      Returns QwpSender

    • Parameters

      • name: string
      • unscaled: undefined | null | bigint | Int8Array<ArrayBufferLike>
      • scale: number

      Returns QwpSender

    • Publishes completed rows to the local ingress/replay boundary. This does +not wait for a server ACK unless awaitServerAck or awaitDurableAck is set.

      +

      Returns Promise<boolean>

    • Publishes pending rows without waiting for their server ACK and returns +the highest frame sequence produced by this call, or -1n when empty. +Pass the result to waitForAcknowledged() when an explicit delivery +barrier is needed.

      +

      Returns Promise<bigint>

    • Adds a QuestDB INT column value. -2_147_483_648 is QuestDB's INT NULL +sentinel: it is stored as NULL and cannot be stored as an ordinary value.

      +

      Parameters

      • name: string
      • value: undefined | null | number

      Returns QwpSender

    • Parameters

      • name: string
      • word0: undefined | null | bigint
      • word1: undefined | null | bigint
      • word2: undefined | null | bigint
      • word3: undefined | null | bigint

      Returns QwpSender

    • Adds a protocol LONG[] column value with between 1 and 32 dimensions.

      +

      Current QuestDB servers reject LONG-array ingestion with long arrays are not supported, only double arrays. This method remains available for +Java-client and protocol parity.

      +

      Parameters

      • name: string
      • value: undefined | null | unknown[]

      Returns QwpSender

    • Adds a QuestDB LONG column value. -9_223_372_036_854_775_808n is +QuestDB's LONG NULL sentinel: it is stored as NULL and cannot be stored as +an ordinary value.

      +

      Parameters

      • name: string
      • value: undefined | null | number | bigint

      Returns QwpSender

    • Internal

      Flushes completed rows and resets borrower-local staging without closing +the physical session. Used by the pooled QWP client when a lease returns.

      +

      Returns Promise<void>

    • Independently waits until the cumulative ACK watermark covers a frame.

      +

      Parameters

      • targetSequence: bigint
      • OptionaltimeoutMs: number

      Returns Promise<void>

    diff --git a/docs/classes/_questdb_browser-client.QwpSenderCloseTimeoutError.html b/docs/classes/_questdb_browser-client.QwpSenderCloseTimeoutError.html new file mode 100644 index 0000000..0c63cbf --- /dev/null +++ b/docs/classes/_questdb_browser-client.QwpSenderCloseTimeoutError.html @@ -0,0 +1,9 @@ +QwpSenderCloseTimeoutError | QuestDB JavaScript Client - v4.2.0

    close() could not publish and acknowledge all committed ingress frames.

    +

    Hierarchy

    • Error
      • QwpSenderCloseTimeoutError
    Index

    Constructors

    Properties

    acknowledgedSequence: bigint
    message: string
    name: string
    stack?: string
    targetSequence: bigint
    timeoutMs: number
    diff --git a/docs/classes/_questdb_browser-client.QwpSymbolDictionary.html b/docs/classes/_questdb_browser-client.QwpSymbolDictionary.html new file mode 100644 index 0000000..c356be5 --- /dev/null +++ b/docs/classes/_questdb_browser-client.QwpSymbolDictionary.html @@ -0,0 +1,12 @@ +QwpSymbolDictionary | QuestDB JavaScript Client - v4.2.0

    Connection-scoped QWP symbol dictionary. IDs are dense from zero.

    +
    Index

    Constructors

    Accessors

    Methods

    Constructors

    Accessors

    Methods

    diff --git a/docs/classes/_questdb_browser-client.QwpTableBuffer.html b/docs/classes/_questdb_browser-client.QwpTableBuffer.html new file mode 100644 index 0000000..e183607 --- /dev/null +++ b/docs/classes/_questdb_browser-client.QwpTableBuffer.html @@ -0,0 +1,20 @@ +QwpTableBuffer | QuestDB JavaScript Client - v4.2.0

    Mutable columnar staging area for one QWP ingress table.

    +
    Index

    Constructors

    Properties

    name: string

    Accessors

    Methods

    diff --git a/docs/classes/_questdb_browser-client.QwpTableWriter.html b/docs/classes/_questdb_browser-client.QwpTableWriter.html new file mode 100644 index 0000000..52f396a --- /dev/null +++ b/docs/classes/_questdb_browser-client.QwpTableWriter.html @@ -0,0 +1,9 @@ +QwpTableWriter | QuestDB JavaScript Client - v4.2.0

    Class QwpTableWriter<Schema>

    A reusable table-bound writer compiled from a QWP schema.

    +

    Type Parameters

    Index

    Constructors

    Properties

    Methods

    Constructors

    • Internal

      Construct table writers with QwpSender.writer().

      +

      Type Parameters

      Parameters

      • token: typeof QWP_TABLE_WRITER_CONSTRUCTOR
      • tableName: string
      • appendRow: (row: unknown, rowIndex?: number) => Promise<void>

      Returns QwpTableWriter<Schema>

    Properties

    tableName: string

    Methods

    diff --git a/docs/classes/_questdb_browser-client.QwpUnrecoverableReplayDictionaryError.html b/docs/classes/_questdb_browser-client.QwpUnrecoverableReplayDictionaryError.html new file mode 100644 index 0000000..b99dcde --- /dev/null +++ b/docs/classes/_questdb_browser-client.QwpUnrecoverableReplayDictionaryError.html @@ -0,0 +1,8 @@ +QwpUnrecoverableReplayDictionaryError | QuestDB JavaScript Client - v4.2.0

    Class QwpUnrecoverableReplayDictionaryError

    Recovered delta frames depend on symbol IDs that neither the durable +dictionary prefix nor the surviving frames can reconstruct.

    +

    Hierarchy (View Summary)

    Index

    Constructors

    Properties

    Constructors

    Properties

    cause?: unknown
    message: string
    name: string
    stack?: string
    diff --git a/docs/classes/_questdb_browser-client.QwpUpgradeError.html b/docs/classes/_questdb_browser-client.QwpUpgradeError.html new file mode 100644 index 0000000..9d563f8 --- /dev/null +++ b/docs/classes/_questdb_browser-client.QwpUpgradeError.html @@ -0,0 +1,22 @@ +QwpUpgradeError | QuestDB JavaScript Client - v4.2.0

    A failure while establishing or validating a QWP WebSocket upgrade.

    +

    Hierarchy (View Summary)

    Index

    Constructors

    Properties

    cause?: unknown
    closeCode?: number
    message: string
    name: string
    retryable?: boolean
    serverRole?: string
    serverZone?: string
    stack?: string
    statusCode?: number
    statusMessage?: string
    timeoutPhase?: QwpUpgradeTimeoutPhase

    Node opening phase that exceeded its deadline.

    +
    tryNextEndpoint?: boolean
    url?: string | URL

    Accessors

    diff --git a/docs/classes/_questdb_browser-client.QwpWriterRowError.html b/docs/classes/_questdb_browser-client.QwpWriterRowError.html new file mode 100644 index 0000000..3a30702 --- /dev/null +++ b/docs/classes/_questdb_browser-client.QwpWriterRowError.html @@ -0,0 +1,10 @@ +QwpWriterRowError | QuestDB JavaScript Client - v4.2.0

    A complete object row failed compiled-writer validation.

    +

    Hierarchy

    • Error
      • QwpWriterRowError
    Index

    Constructors

    Properties

    cause: unknown
    columnName: undefined | string
    message: string
    name: string
    rowIndex: undefined | number
    stack?: string
    tableName: string
    diff --git a/docs/classes/_questdb_nodejs-client.HttpTransport.html b/docs/classes/_questdb_nodejs-client.HttpTransport.html new file mode 100644 index 0000000..baf5b62 --- /dev/null +++ b/docs/classes/_questdb_nodejs-client.HttpTransport.html @@ -0,0 +1,35 @@ +HttpTransport | QuestDB JavaScript Client - v4.2.0

    HTTP transport implementation using Node.js built-in http/https modules.
    +Supports both HTTP and HTTPS protocols with configurable authentication.

    +

    Hierarchy

    • HttpTransportBase
      • HttpTransport
    Index

    Constructors

    Properties

    host: string
    log: Logger
    password: string
    port: number
    requestMinThroughput: number
    requestTimeout: number
    retryTimeout: number
    secure: boolean
    tlsCA: Buffer
    tlsVerify: boolean
    token: string
    username: string

    Methods

    • HTTP transport does not require explicit connection establishment.

      +

      Returns Promise<boolean>

      Error indicating connect is not required for HTTP transport

      +
    • Gets the default auto-flush row count for HTTP transport.

      +

      Returns number

      Default number of rows that trigger auto-flush

      +
    • Sends data to QuestDB using HTTP POST.

      +

      Parameters

      • data: Buffer

        Buffer containing the data to send

        +
      • retryBegin: number = -1

        Internal parameter for tracking retry start time

        +
      • retryInterval: number = -1

        Internal parameter for tracking retry intervals

        +

      Returns Promise<boolean>

      Promise resolving to true if data was sent successfully

      +

      Error if request fails after all retries or times out

      +
    diff --git a/docs/classes/_questdb_nodejs-client.QwpBatchTooLargeError.html b/docs/classes/_questdb_nodejs-client.QwpBatchTooLargeError.html new file mode 100644 index 0000000..816c4a3 --- /dev/null +++ b/docs/classes/_questdb_nodejs-client.QwpBatchTooLargeError.html @@ -0,0 +1,35 @@ +QwpBatchTooLargeError | QuestDB JavaScript Client - v4.2.0

    Hierarchy

    • RangeError
      • QwpBatchTooLargeError
    Index

    Constructors

    Properties

    batchSizeBytes: number
    cause?: unknown
    maxBatchSizeBytes: number
    message: string
    name: string
    stack?: string
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames +collected by a stack trace (whether generated by new Error().stack or +Error.captureStackTrace(obj)).

    +

    The default value is 10 but may be set to any valid JavaScript number. Changes +will affect any stack trace captured after the value has been changed.

    +

    If set to a non-number value, or set to a negative number, stack traces will +not capture any frames.

    +

    Methods

    • Creates a .stack property on targetObject, which when accessed returns +a string representing the location in the code at which +Error.captureStackTrace() was called.

      +
      const myObject = {};
      Error.captureStackTrace(myObject);
      myObject.stack; // Similar to `new Error().stack` +
      + +

      The first line of the trace will be prefixed with +${myObject.name}: ${myObject.message}.

      +

      The optional constructorOpt argument accepts a function. If given, all frames +above constructorOpt, including constructorOpt, will be omitted from the +generated stack trace.

      +

      The constructorOpt argument is useful for hiding implementation +details of error generation from the user. For instance:

      +
      function a() {
      b();
      }

      function b() {
      c();
      }

      function c() {
      // Create an error without stack trace to avoid calculating the stack trace twice.
      const { stackTraceLimit } = Error;
      Error.stackTraceLimit = 0;
      const error = new Error();
      Error.stackTraceLimit = stackTraceLimit;

      // Capture the stack trace above function b
      Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace
      throw error;
      }

      a(); +
      + +

      Parameters

      • targetObject: object
      • OptionalconstructorOpt: Function

      Returns void

    • Parameters

      • err: Error
      • stackTraces: CallSite[]

      Returns any

    diff --git a/docs/classes/_questdb_nodejs-client.QwpBindValues.html b/docs/classes/_questdb_nodejs-client.QwpBindValues.html new file mode 100644 index 0000000..409ed58 --- /dev/null +++ b/docs/classes/_questdb_nodejs-client.QwpBindValues.html @@ -0,0 +1,34 @@ +QwpBindValues | QuestDB JavaScript Client - v4.2.0

    Browser-safe typed positional bind encoder.

    +

    Setters must be called in ascending zero-based index order. SQL placeholders +are one-based, so index 0 binds $1, index 1 binds $2, and so on.

    +
    Index

    Constructors

    Accessors

    Methods

    diff --git a/docs/classes/_questdb_nodejs-client.QwpByteReader.html b/docs/classes/_questdb_nodejs-client.QwpByteReader.html new file mode 100644 index 0000000..c3479c3 --- /dev/null +++ b/docs/classes/_questdb_nodejs-client.QwpByteReader.html @@ -0,0 +1,19 @@ +QwpByteReader | QuestDB JavaScript Client - v4.2.0

    A bounds-checked, runtime-neutral little-endian byte reader.

    +
    Index

    Constructors

    Properties

    bytes: Uint8Array

    Accessors

    Methods

    diff --git a/docs/classes/_questdb_nodejs-client.QwpByteWriter.html b/docs/classes/_questdb_nodejs-client.QwpByteWriter.html new file mode 100644 index 0000000..e393334 --- /dev/null +++ b/docs/classes/_questdb_nodejs-client.QwpByteWriter.html @@ -0,0 +1,21 @@ +QwpByteWriter | QuestDB JavaScript Client - v4.2.0

    A growable, runtime-neutral little-endian byte writer.

    +
    Index

    Constructors

    Accessors

    Methods

    diff --git a/docs/classes/_questdb_nodejs-client.QwpClient.html b/docs/classes/_questdb_nodejs-client.QwpClient.html new file mode 100644 index 0000000..8cc3f50 --- /dev/null +++ b/docs/classes/_questdb_nodejs-client.QwpClient.html @@ -0,0 +1,15 @@ +QwpClient | QuestDB JavaScript Client - v4.2.0

    Browser-safe facade owning bounded ingress and egress connection pools. +Borrowed handles are exclusive; separate query leases execute concurrently.

    +
    Index

    Constructors

    Accessors

    Methods

    Constructors

    Accessors

    Methods

    • Rejects new borrows and closes idle resources. Borrowed query sessions are +cancelled and closed; borrowed senders retain ownership during a bounded +drain and own their teardown if they outlive it.

      +

      Returns Promise<void>

    diff --git a/docs/classes/_questdb_nodejs-client.QwpClientClosedError.html b/docs/classes/_questdb_nodejs-client.QwpClientClosedError.html new file mode 100644 index 0000000..6d362c6 --- /dev/null +++ b/docs/classes/_questdb_nodejs-client.QwpClientClosedError.html @@ -0,0 +1,34 @@ +QwpClientClosedError | QuestDB JavaScript Client - v4.2.0

    The owning QWP client, or one of its returned lease handles, is closed.

    +

    Hierarchy

    • Error
      • QwpClientClosedError
    Index

    Constructors

    Properties

    cause?: unknown
    message: string
    name: string
    stack?: string
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames +collected by a stack trace (whether generated by new Error().stack or +Error.captureStackTrace(obj)).

    +

    The default value is 10 but may be set to any valid JavaScript number. Changes +will affect any stack trace captured after the value has been changed.

    +

    If set to a non-number value, or set to a negative number, stack traces will +not capture any frames.

    +

    Methods

    • Creates a .stack property on targetObject, which when accessed returns +a string representing the location in the code at which +Error.captureStackTrace() was called.

      +
      const myObject = {};
      Error.captureStackTrace(myObject);
      myObject.stack; // Similar to `new Error().stack` +
      + +

      The first line of the trace will be prefixed with +${myObject.name}: ${myObject.message}.

      +

      The optional constructorOpt argument accepts a function. If given, all frames +above constructorOpt, including constructorOpt, will be omitted from the +generated stack trace.

      +

      The constructorOpt argument is useful for hiding implementation +details of error generation from the user. For instance:

      +
      function a() {
      b();
      }

      function b() {
      c();
      }

      function c() {
      // Create an error without stack trace to avoid calculating the stack trace twice.
      const { stackTraceLimit } = Error;
      Error.stackTraceLimit = 0;
      const error = new Error();
      Error.stackTraceLimit = stackTraceLimit;

      // Capture the stack trace above function b
      Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace
      throw error;
      }

      a(); +
      + +

      Parameters

      • targetObject: object
      • OptionalconstructorOpt: Function

      Returns void

    • Parameters

      • err: Error
      • stackTraces: CallSite[]

      Returns any

    diff --git a/docs/classes/_questdb_nodejs-client.QwpDurableAckUnavailableError.html b/docs/classes/_questdb_nodejs-client.QwpDurableAckUnavailableError.html new file mode 100644 index 0000000..a5691f1 --- /dev/null +++ b/docs/classes/_questdb_nodejs-client.QwpDurableAckUnavailableError.html @@ -0,0 +1,49 @@ +QwpDurableAckUnavailableError | QuestDB JavaScript Client - v4.2.0

    Class QwpDurableAckUnavailableError

    A requested durable-ACK capability was not confirmed by the server.

    +

    Hierarchy (View Summary)

    Index

    Constructors

    Properties

    cause?: unknown
    closeCode?: number
    message: string
    name: string
    retryable?: boolean
    serverRole?: string
    serverZone?: string
    stack?: string
    statusCode?: number
    statusMessage?: string
    timeoutPhase?: QwpUpgradeTimeoutPhase

    Node opening phase that exceeded its deadline.

    +
    tryNextEndpoint?: boolean
    url: string | URL
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames +collected by a stack trace (whether generated by new Error().stack or +Error.captureStackTrace(obj)).

    +

    The default value is 10 but may be set to any valid JavaScript number. Changes +will affect any stack trace captured after the value has been changed.

    +

    If set to a non-number value, or set to a negative number, stack traces will +not capture any frames.

    +

    Accessors

    Methods

    • Creates a .stack property on targetObject, which when accessed returns +a string representing the location in the code at which +Error.captureStackTrace() was called.

      +
      const myObject = {};
      Error.captureStackTrace(myObject);
      myObject.stack; // Similar to `new Error().stack` +
      + +

      The first line of the trace will be prefixed with +${myObject.name}: ${myObject.message}.

      +

      The optional constructorOpt argument accepts a function. If given, all frames +above constructorOpt, including constructorOpt, will be omitted from the +generated stack trace.

      +

      The constructorOpt argument is useful for hiding implementation +details of error generation from the user. For instance:

      +
      function a() {
      b();
      }

      function b() {
      c();
      }

      function c() {
      // Create an error without stack trace to avoid calculating the stack trace twice.
      const { stackTraceLimit } = Error;
      Error.stackTraceLimit = 0;
      const error = new Error();
      Error.stackTraceLimit = stackTraceLimit;

      // Capture the stack trace above function b
      Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace
      throw error;
      }

      a(); +
      + +

      Parameters

      • targetObject: object
      • OptionalconstructorOpt: Function

      Returns void

    diff --git a/docs/classes/_questdb_nodejs-client.QwpEgressQuery.html b/docs/classes/_questdb_nodejs-client.QwpEgressQuery.html new file mode 100644 index 0000000..5ec8090 --- /dev/null +++ b/docs/classes/_questdb_nodejs-client.QwpEgressQuery.html @@ -0,0 +1,42 @@ +QwpEgressQuery | QuestDB JavaScript Client - v4.2.0

    One QWP query/statement and its stream of materialized result batches.

    +

    Implements

    Index

    Constructors

    Properties

    completion: Promise<QwpQueryCompletion>
    requestId: bigint

    Accessors

    Methods

    • Waits for completion without changing the query lifecycle. A finite wait +returns false on expiry; the query remains active until it completes, is +cancelled explicitly, or its configured query deadline expires.

      +

      Parameters

      • timeoutMs: number

      Returns Promise<boolean>

    diff --git a/docs/classes/_questdb_nodejs-client.QwpEgressQueryAbandonedError.html b/docs/classes/_questdb_nodejs-client.QwpEgressQueryAbandonedError.html new file mode 100644 index 0000000..d56a90b --- /dev/null +++ b/docs/classes/_questdb_nodejs-client.QwpEgressQueryAbandonedError.html @@ -0,0 +1,35 @@ +QwpEgressQueryAbandonedError | QuestDB JavaScript Client - v4.2.0

    Class QwpEgressQueryAbandonedError

    Result iteration ended before the server completed the query.

    +

    Hierarchy

    • Error
      • QwpEgressQueryAbandonedError
    Index

    Constructors

    Properties

    cause?: unknown
    message: string
    name: string
    requestId: bigint
    stack?: string
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames +collected by a stack trace (whether generated by new Error().stack or +Error.captureStackTrace(obj)).

    +

    The default value is 10 but may be set to any valid JavaScript number. Changes +will affect any stack trace captured after the value has been changed.

    +

    If set to a non-number value, or set to a negative number, stack traces will +not capture any frames.

    +

    Methods

    • Creates a .stack property on targetObject, which when accessed returns +a string representing the location in the code at which +Error.captureStackTrace() was called.

      +
      const myObject = {};
      Error.captureStackTrace(myObject);
      myObject.stack; // Similar to `new Error().stack` +
      + +

      The first line of the trace will be prefixed with +${myObject.name}: ${myObject.message}.

      +

      The optional constructorOpt argument accepts a function. If given, all frames +above constructorOpt, including constructorOpt, will be omitted from the +generated stack trace.

      +

      The constructorOpt argument is useful for hiding implementation +details of error generation from the user. For instance:

      +
      function a() {
      b();
      }

      function b() {
      c();
      }

      function c() {
      // Create an error without stack trace to avoid calculating the stack trace twice.
      const { stackTraceLimit } = Error;
      Error.stackTraceLimit = 0;
      const error = new Error();
      Error.stackTraceLimit = stackTraceLimit;

      // Capture the stack trace above function b
      Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace
      throw error;
      }

      a(); +
      + +

      Parameters

      • targetObject: object
      • OptionalconstructorOpt: Function

      Returns void

    • Parameters

      • err: Error
      • stackTraces: CallSite[]

      Returns any

    diff --git a/docs/classes/_questdb_nodejs-client.QwpEgressQueryCancelTimeoutError.html b/docs/classes/_questdb_nodejs-client.QwpEgressQueryCancelTimeoutError.html new file mode 100644 index 0000000..97c763d --- /dev/null +++ b/docs/classes/_questdb_nodejs-client.QwpEgressQueryCancelTimeoutError.html @@ -0,0 +1,36 @@ +QwpEgressQueryCancelTimeoutError | QuestDB JavaScript Client - v4.2.0

    Class QwpEgressQueryCancelTimeoutError

    The server did not terminate a cancelled query within the drain deadline.

    +

    Hierarchy

    • Error
      • QwpEgressQueryCancelTimeoutError
    Index

    Constructors

    Properties

    cause?: unknown
    message: string
    name: string
    requestId: bigint
    stack?: string
    timeoutMs: number
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames +collected by a stack trace (whether generated by new Error().stack or +Error.captureStackTrace(obj)).

    +

    The default value is 10 but may be set to any valid JavaScript number. Changes +will affect any stack trace captured after the value has been changed.

    +

    If set to a non-number value, or set to a negative number, stack traces will +not capture any frames.

    +

    Methods

    • Creates a .stack property on targetObject, which when accessed returns +a string representing the location in the code at which +Error.captureStackTrace() was called.

      +
      const myObject = {};
      Error.captureStackTrace(myObject);
      myObject.stack; // Similar to `new Error().stack` +
      + +

      The first line of the trace will be prefixed with +${myObject.name}: ${myObject.message}.

      +

      The optional constructorOpt argument accepts a function. If given, all frames +above constructorOpt, including constructorOpt, will be omitted from the +generated stack trace.

      +

      The constructorOpt argument is useful for hiding implementation +details of error generation from the user. For instance:

      +
      function a() {
      b();
      }

      function b() {
      c();
      }

      function c() {
      // Create an error without stack trace to avoid calculating the stack trace twice.
      const { stackTraceLimit } = Error;
      Error.stackTraceLimit = 0;
      const error = new Error();
      Error.stackTraceLimit = stackTraceLimit;

      // Capture the stack trace above function b
      Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace
      throw error;
      }

      a(); +
      + +

      Parameters

      • targetObject: object
      • OptionalconstructorOpt: Function

      Returns void

    • Parameters

      • err: Error
      • stackTraces: CallSite[]

      Returns any

    diff --git a/docs/classes/_questdb_nodejs-client.QwpEgressQueryError.html b/docs/classes/_questdb_nodejs-client.QwpEgressQueryError.html new file mode 100644 index 0000000..a4370e7 --- /dev/null +++ b/docs/classes/_questdb_nodejs-client.QwpEgressQueryError.html @@ -0,0 +1,35 @@ +QwpEgressQueryError | QuestDB JavaScript Client - v4.2.0

    Hierarchy

    • Error
      • QwpEgressQueryError
    Index

    Constructors

    Properties

    cause?: unknown
    message: string
    name: string
    requestId: bigint
    stack?: string
    status: number
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames +collected by a stack trace (whether generated by new Error().stack or +Error.captureStackTrace(obj)).

    +

    The default value is 10 but may be set to any valid JavaScript number. Changes +will affect any stack trace captured after the value has been changed.

    +

    If set to a non-number value, or set to a negative number, stack traces will +not capture any frames.

    +

    Methods

    • Creates a .stack property on targetObject, which when accessed returns +a string representing the location in the code at which +Error.captureStackTrace() was called.

      +
      const myObject = {};
      Error.captureStackTrace(myObject);
      myObject.stack; // Similar to `new Error().stack` +
      + +

      The first line of the trace will be prefixed with +${myObject.name}: ${myObject.message}.

      +

      The optional constructorOpt argument accepts a function. If given, all frames +above constructorOpt, including constructorOpt, will be omitted from the +generated stack trace.

      +

      The constructorOpt argument is useful for hiding implementation +details of error generation from the user. For instance:

      +
      function a() {
      b();
      }

      function b() {
      c();
      }

      function c() {
      // Create an error without stack trace to avoid calculating the stack trace twice.
      const { stackTraceLimit } = Error;
      Error.stackTraceLimit = 0;
      const error = new Error();
      Error.stackTraceLimit = stackTraceLimit;

      // Capture the stack trace above function b
      Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace
      throw error;
      }

      a(); +
      + +

      Parameters

      • targetObject: object
      • OptionalconstructorOpt: Function

      Returns void

    • Parameters

      • err: Error
      • stackTraces: CallSite[]

      Returns any

    diff --git a/docs/classes/_questdb_nodejs-client.QwpEgressQueryTimeoutError.html b/docs/classes/_questdb_nodejs-client.QwpEgressQueryTimeoutError.html new file mode 100644 index 0000000..dfb9106 --- /dev/null +++ b/docs/classes/_questdb_nodejs-client.QwpEgressQueryTimeoutError.html @@ -0,0 +1,36 @@ +QwpEgressQueryTimeoutError | QuestDB JavaScript Client - v4.2.0

    Class QwpEgressQueryTimeoutError

    A client-side query deadline expired and a QWP CANCEL was sent.

    +

    Hierarchy

    • Error
      • QwpEgressQueryTimeoutError
    Index

    Constructors

    Properties

    cause?: unknown
    message: string
    name: string
    requestId: bigint
    stack?: string
    timeoutMs: number
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames +collected by a stack trace (whether generated by new Error().stack or +Error.captureStackTrace(obj)).

    +

    The default value is 10 but may be set to any valid JavaScript number. Changes +will affect any stack trace captured after the value has been changed.

    +

    If set to a non-number value, or set to a negative number, stack traces will +not capture any frames.

    +

    Methods

    • Creates a .stack property on targetObject, which when accessed returns +a string representing the location in the code at which +Error.captureStackTrace() was called.

      +
      const myObject = {};
      Error.captureStackTrace(myObject);
      myObject.stack; // Similar to `new Error().stack` +
      + +

      The first line of the trace will be prefixed with +${myObject.name}: ${myObject.message}.

      +

      The optional constructorOpt argument accepts a function. If given, all frames +above constructorOpt, including constructorOpt, will be omitted from the +generated stack trace.

      +

      The constructorOpt argument is useful for hiding implementation +details of error generation from the user. For instance:

      +
      function a() {
      b();
      }

      function b() {
      c();
      }

      function c() {
      // Create an error without stack trace to avoid calculating the stack trace twice.
      const { stackTraceLimit } = Error;
      Error.stackTraceLimit = 0;
      const error = new Error();
      Error.stackTraceLimit = stackTraceLimit;

      // Capture the stack trace above function b
      Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace
      throw error;
      }

      a(); +
      + +

      Parameters

      • targetObject: object
      • OptionalconstructorOpt: Function

      Returns void

    • Parameters

      • err: Error
      • stackTraces: CallSite[]

      Returns any

    diff --git a/docs/classes/_questdb_nodejs-client.QwpEgressReplayRequiredError.html b/docs/classes/_questdb_nodejs-client.QwpEgressReplayRequiredError.html new file mode 100644 index 0000000..056a5ee --- /dev/null +++ b/docs/classes/_questdb_nodejs-client.QwpEgressReplayRequiredError.html @@ -0,0 +1,37 @@ +QwpEgressReplayRequiredError | QuestDB JavaScript Client - v4.2.0

    Class QwpEgressReplayRequiredError

    Standard egress sessions now reset and replay automatically. +Retained for source compatibility with clients that classified the former +explicit-replay opt-in failure.

    +

    Hierarchy

    • Error
      • QwpEgressReplayRequiredError
    Index

    Constructors

    Properties

    cause?: unknown
    message: string
    name: string
    requestId?: bigint
    stack?: string
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames +collected by a stack trace (whether generated by new Error().stack or +Error.captureStackTrace(obj)).

    +

    The default value is 10 but may be set to any valid JavaScript number. Changes +will affect any stack trace captured after the value has been changed.

    +

    If set to a non-number value, or set to a negative number, stack traces will +not capture any frames.

    +

    Methods

    • Creates a .stack property on targetObject, which when accessed returns +a string representing the location in the code at which +Error.captureStackTrace() was called.

      +
      const myObject = {};
      Error.captureStackTrace(myObject);
      myObject.stack; // Similar to `new Error().stack` +
      + +

      The first line of the trace will be prefixed with +${myObject.name}: ${myObject.message}.

      +

      The optional constructorOpt argument accepts a function. If given, all frames +above constructorOpt, including constructorOpt, will be omitted from the +generated stack trace.

      +

      The constructorOpt argument is useful for hiding implementation +details of error generation from the user. For instance:

      +
      function a() {
      b();
      }

      function b() {
      c();
      }

      function c() {
      // Create an error without stack trace to avoid calculating the stack trace twice.
      const { stackTraceLimit } = Error;
      Error.stackTraceLimit = 0;
      const error = new Error();
      Error.stackTraceLimit = stackTraceLimit;

      // Capture the stack trace above function b
      Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace
      throw error;
      }

      a(); +
      + +

      Parameters

      • targetObject: object
      • OptionalconstructorOpt: Function

      Returns void

    • Parameters

      • err: Error
      • stackTraces: CallSite[]

      Returns any

    diff --git a/docs/classes/_questdb_nodejs-client.QwpEgressSession.html b/docs/classes/_questdb_nodejs-client.QwpEgressSession.html new file mode 100644 index 0000000..8864ebe --- /dev/null +++ b/docs/classes/_questdb_nodejs-client.QwpEgressSession.html @@ -0,0 +1,39 @@ +QwpEgressSession | QuestDB JavaScript Client - v4.2.0

    Browser-safe QWP egress session.

    +

    The server currently executes one query at a time per connection, so this +session deliberately rejects overlapping query calls. A completed query's +materialized batches may still be consumed while the next query runs.

    +

    Implements

    • QwpEgressQueryControl
    Index

    Constructors

    Properties

    ready: Promise<QwpServerInfoMessage>

    Initial SERVER_INFO; use serverInfo for the current post-failover snapshot.

    +

    Accessors

    Methods

    • Parameters

      • requestId: bigint
      • additionalBytes: number | bigint

      Returns Promise<void>

    • Internal

      Cancels and drains an active operation before a pooled lease is returned. +False means the physical session is no longer safe to reuse.

      +

      Returns Promise<boolean>

    • Internal

      Best-effort cancellation followed by physical connection teardown for +facade shutdown. Unlike pooled lease return, this does not wait for the +server to finish draining the cancelled query.

      +

      Returns Promise<void>

    diff --git a/docs/classes/_questdb_nodejs-client.QwpEgressSessionClosedError.html b/docs/classes/_questdb_nodejs-client.QwpEgressSessionClosedError.html new file mode 100644 index 0000000..223f6c6 --- /dev/null +++ b/docs/classes/_questdb_nodejs-client.QwpEgressSessionClosedError.html @@ -0,0 +1,34 @@ +QwpEgressSessionClosedError | QuestDB JavaScript Client - v4.2.0

    Class QwpEgressSessionClosedError

    Hierarchy

    • Error
      • QwpEgressSessionClosedError
    Index

    Constructors

    Properties

    cause?: unknown
    message: string
    name: string
    stack?: string
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames +collected by a stack trace (whether generated by new Error().stack or +Error.captureStackTrace(obj)).

    +

    The default value is 10 but may be set to any valid JavaScript number. Changes +will affect any stack trace captured after the value has been changed.

    +

    If set to a non-number value, or set to a negative number, stack traces will +not capture any frames.

    +

    Methods

    • Creates a .stack property on targetObject, which when accessed returns +a string representing the location in the code at which +Error.captureStackTrace() was called.

      +
      const myObject = {};
      Error.captureStackTrace(myObject);
      myObject.stack; // Similar to `new Error().stack` +
      + +

      The first line of the trace will be prefixed with +${myObject.name}: ${myObject.message}.

      +

      The optional constructorOpt argument accepts a function. If given, all frames +above constructorOpt, including constructorOpt, will be omitted from the +generated stack trace.

      +

      The constructorOpt argument is useful for hiding implementation +details of error generation from the user. For instance:

      +
      function a() {
      b();
      }

      function b() {
      c();
      }

      function c() {
      // Create an error without stack trace to avoid calculating the stack trace twice.
      const { stackTraceLimit } = Error;
      Error.stackTraceLimit = 0;
      const error = new Error();
      Error.stackTraceLimit = stackTraceLimit;

      // Capture the stack trace above function b
      Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace
      throw error;
      }

      a(); +
      + +

      Parameters

      • targetObject: object
      • OptionalconstructorOpt: Function

      Returns void

    • Parameters

      • err: Error
      • stackTraces: CallSite[]

      Returns any

    diff --git a/docs/classes/_questdb_nodejs-client.QwpFailoverError.html b/docs/classes/_questdb_nodejs-client.QwpFailoverError.html new file mode 100644 index 0000000..ba7ddd2 --- /dev/null +++ b/docs/classes/_questdb_nodejs-client.QwpFailoverError.html @@ -0,0 +1,35 @@ +QwpFailoverError | QuestDB JavaScript Client - v4.2.0

    Every eligible QWP endpoint in one connection sweep failed.

    +

    Hierarchy

    • Error
      • QwpFailoverError
    Index

    Constructors

    Properties

    attempts: readonly QwpFailoverAttempt[]
    cause?: unknown
    message: string
    name: string
    stack?: string
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames +collected by a stack trace (whether generated by new Error().stack or +Error.captureStackTrace(obj)).

    +

    The default value is 10 but may be set to any valid JavaScript number. Changes +will affect any stack trace captured after the value has been changed.

    +

    If set to a non-number value, or set to a negative number, stack traces will +not capture any frames.

    +

    Methods

    • Creates a .stack property on targetObject, which when accessed returns +a string representing the location in the code at which +Error.captureStackTrace() was called.

      +
      const myObject = {};
      Error.captureStackTrace(myObject);
      myObject.stack; // Similar to `new Error().stack` +
      + +

      The first line of the trace will be prefixed with +${myObject.name}: ${myObject.message}.

      +

      The optional constructorOpt argument accepts a function. If given, all frames +above constructorOpt, including constructorOpt, will be omitted from the +generated stack trace.

      +

      The constructorOpt argument is useful for hiding implementation +details of error generation from the user. For instance:

      +
      function a() {
      b();
      }

      function b() {
      c();
      }

      function c() {
      // Create an error without stack trace to avoid calculating the stack trace twice.
      const { stackTraceLimit } = Error;
      Error.stackTraceLimit = 0;
      const error = new Error();
      Error.stackTraceLimit = stackTraceLimit;

      // Capture the stack trace above function b
      Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace
      throw error;
      }

      a(); +
      + +

      Parameters

      • targetObject: object
      • OptionalconstructorOpt: Function

      Returns void

    • Parameters

      • err: Error
      • stackTraces: CallSite[]

      Returns any

    diff --git a/docs/classes/_questdb_nodejs-client.QwpIngressAckTimeoutError.html b/docs/classes/_questdb_nodejs-client.QwpIngressAckTimeoutError.html new file mode 100644 index 0000000..38fc50e --- /dev/null +++ b/docs/classes/_questdb_nodejs-client.QwpIngressAckTimeoutError.html @@ -0,0 +1,37 @@ +QwpIngressAckTimeoutError | QuestDB JavaScript Client - v4.2.0

    The ingress ACK watermark did not reach the requested frame in time.

    +

    Hierarchy

    • Error
      • QwpIngressAckTimeoutError
    Index

    Constructors

    Properties

    acknowledgedSequence: bigint
    cause?: unknown
    message: string
    name: string
    stack?: string
    targetSequence: bigint
    timeoutMs: number
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames +collected by a stack trace (whether generated by new Error().stack or +Error.captureStackTrace(obj)).

    +

    The default value is 10 but may be set to any valid JavaScript number. Changes +will affect any stack trace captured after the value has been changed.

    +

    If set to a non-number value, or set to a negative number, stack traces will +not capture any frames.

    +

    Methods

    • Creates a .stack property on targetObject, which when accessed returns +a string representing the location in the code at which +Error.captureStackTrace() was called.

      +
      const myObject = {};
      Error.captureStackTrace(myObject);
      myObject.stack; // Similar to `new Error().stack` +
      + +

      The first line of the trace will be prefixed with +${myObject.name}: ${myObject.message}.

      +

      The optional constructorOpt argument accepts a function. If given, all frames +above constructorOpt, including constructorOpt, will be omitted from the +generated stack trace.

      +

      The constructorOpt argument is useful for hiding implementation +details of error generation from the user. For instance:

      +
      function a() {
      b();
      }

      function b() {
      c();
      }

      function c() {
      // Create an error without stack trace to avoid calculating the stack trace twice.
      const { stackTraceLimit } = Error;
      Error.stackTraceLimit = 0;
      const error = new Error();
      Error.stackTraceLimit = stackTraceLimit;

      // Capture the stack trace above function b
      Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace
      throw error;
      }

      a(); +
      + +

      Parameters

      • targetObject: object
      • OptionalconstructorOpt: Function

      Returns void

    • Parameters

      • err: Error
      • stackTraces: CallSite[]

      Returns any

    diff --git a/docs/classes/_questdb_nodejs-client.QwpIngressNackError.html b/docs/classes/_questdb_nodejs-client.QwpIngressNackError.html new file mode 100644 index 0000000..3515c78 --- /dev/null +++ b/docs/classes/_questdb_nodejs-client.QwpIngressNackError.html @@ -0,0 +1,35 @@ +QwpIngressNackError | QuestDB JavaScript Client - v4.2.0

    Hierarchy

    • Error
      • QwpIngressNackError
    Index

    Constructors

    Properties

    cause?: unknown
    message: string
    name: string
    senderError: QwpSenderError = ...
    stack?: string
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames +collected by a stack trace (whether generated by new Error().stack or +Error.captureStackTrace(obj)).

    +

    The default value is 10 but may be set to any valid JavaScript number. Changes +will affect any stack trace captured after the value has been changed.

    +

    If set to a non-number value, or set to a negative number, stack traces will +not capture any frames.

    +

    Methods

    • Creates a .stack property on targetObject, which when accessed returns +a string representing the location in the code at which +Error.captureStackTrace() was called.

      +
      const myObject = {};
      Error.captureStackTrace(myObject);
      myObject.stack; // Similar to `new Error().stack` +
      + +

      The first line of the trace will be prefixed with +${myObject.name}: ${myObject.message}.

      +

      The optional constructorOpt argument accepts a function. If given, all frames +above constructorOpt, including constructorOpt, will be omitted from the +generated stack trace.

      +

      The constructorOpt argument is useful for hiding implementation +details of error generation from the user. For instance:

      +
      function a() {
      b();
      }

      function b() {
      c();
      }

      function c() {
      // Create an error without stack trace to avoid calculating the stack trace twice.
      const { stackTraceLimit } = Error;
      Error.stackTraceLimit = 0;
      const error = new Error();
      Error.stackTraceLimit = stackTraceLimit;

      // Capture the stack trace above function b
      Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace
      throw error;
      }

      a(); +
      + +

      Parameters

      • targetObject: object
      • OptionalconstructorOpt: Function

      Returns void

    • Parameters

      • err: Error
      • stackTraces: CallSite[]

      Returns any

    diff --git a/docs/classes/_questdb_nodejs-client.QwpIngressSession.html b/docs/classes/_questdb_nodejs-client.QwpIngressSession.html new file mode 100644 index 0000000..79a69ec --- /dev/null +++ b/docs/classes/_questdb_nodejs-client.QwpIngressSession.html @@ -0,0 +1,64 @@ +QwpIngressSession | QuestDB JavaScript Client - v4.2.0

    Connection-scoped ingress sequencer.

    +

    One promise is registered before each WebSocket send, preventing a fast ACK +from racing its waiter. Calls are serialized to preserve the server's +zero-based wire sequence. Successful ACKs are cumulative, so an ACK for +sequence N resolves every outstanding send through N.

    +
    Index

    Constructors

    Accessors

    • get acknowledgedFrameSequence(): bigint

      Highest cumulative ACK watermark. When durable ACK was negotiated this +advances only after durability; otherwise it follows ordinary OK ACKs.

      +

      Returns bigint

    Methods

    • Prompts the server to publish its latest durable-ingress watermarks. +Node transports use a WebSocket PING; browsers send the protocol-level +table-less durable-ACK poll frame. Browser completion means the control +frame was published; durable progress arrives independently because the +server may withhold its cumulative OK while a transaction remains open.

      +

      Returns Promise<void>

    • Publishes one pre-encoded frame without allocating an ACK waiter. +Applications can observe later acceptance through progress callbacks.

      +

      Parameters

      • frame: Uint8Array

      Returns Promise<void>

    • Waits independently for the cumulative frame ACK watermark. A negative +target is already satisfied, but still surfaces a latched session error.

      +

      Parameters

      • targetSequence: bigint
      • timeoutMs: number = ...

      Returns Promise<void>

    diff --git a/docs/classes/_questdb_nodejs-client.QwpIngressSessionClosedError.html b/docs/classes/_questdb_nodejs-client.QwpIngressSessionClosedError.html new file mode 100644 index 0000000..68fd4e2 --- /dev/null +++ b/docs/classes/_questdb_nodejs-client.QwpIngressSessionClosedError.html @@ -0,0 +1,34 @@ +QwpIngressSessionClosedError | QuestDB JavaScript Client - v4.2.0

    Class QwpIngressSessionClosedError

    Hierarchy

    • Error
      • QwpIngressSessionClosedError
    Index

    Constructors

    Properties

    cause?: unknown
    message: string
    name: string
    stack?: string
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames +collected by a stack trace (whether generated by new Error().stack or +Error.captureStackTrace(obj)).

    +

    The default value is 10 but may be set to any valid JavaScript number. Changes +will affect any stack trace captured after the value has been changed.

    +

    If set to a non-number value, or set to a negative number, stack traces will +not capture any frames.

    +

    Methods

    • Creates a .stack property on targetObject, which when accessed returns +a string representing the location in the code at which +Error.captureStackTrace() was called.

      +
      const myObject = {};
      Error.captureStackTrace(myObject);
      myObject.stack; // Similar to `new Error().stack` +
      + +

      The first line of the trace will be prefixed with +${myObject.name}: ${myObject.message}.

      +

      The optional constructorOpt argument accepts a function. If given, all frames +above constructorOpt, including constructorOpt, will be omitted from the +generated stack trace.

      +

      The constructorOpt argument is useful for hiding implementation +details of error generation from the user. For instance:

      +
      function a() {
      b();
      }

      function b() {
      c();
      }

      function c() {
      // Create an error without stack trace to avoid calculating the stack trace twice.
      const { stackTraceLimit } = Error;
      Error.stackTraceLimit = 0;
      const error = new Error();
      Error.stackTraceLimit = stackTraceLimit;

      // Capture the stack trace above function b
      Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace
      throw error;
      }

      a(); +
      + +

      Parameters

      • targetObject: object
      • OptionalconstructorOpt: Function

      Returns void

    • Parameters

      • err: Error
      • stackTraces: CallSite[]

      Returns any

    diff --git a/docs/classes/_questdb_nodejs-client.QwpMemoryReplayAppendTimeoutError.html b/docs/classes/_questdb_nodejs-client.QwpMemoryReplayAppendTimeoutError.html new file mode 100644 index 0000000..3b63f9d --- /dev/null +++ b/docs/classes/_questdb_nodejs-client.QwpMemoryReplayAppendTimeoutError.html @@ -0,0 +1,38 @@ +QwpMemoryReplayAppendTimeoutError | QuestDB JavaScript Client - v4.2.0

    Class QwpMemoryReplayAppendTimeoutError

    ACK-driven trimming did not free in-memory replay capacity in time.

    +

    Hierarchy

    • Error
      • QwpMemoryReplayAppendTimeoutError
    Index

    Constructors

    Properties

    cause?: unknown
    maxBytes: number
    message: string
    name: string
    requiredBytes: number
    stack?: string
    timeoutMs: number
    usedBytes: number
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames +collected by a stack trace (whether generated by new Error().stack or +Error.captureStackTrace(obj)).

    +

    The default value is 10 but may be set to any valid JavaScript number. Changes +will affect any stack trace captured after the value has been changed.

    +

    If set to a non-number value, or set to a negative number, stack traces will +not capture any frames.

    +

    Methods

    • Creates a .stack property on targetObject, which when accessed returns +a string representing the location in the code at which +Error.captureStackTrace() was called.

      +
      const myObject = {};
      Error.captureStackTrace(myObject);
      myObject.stack; // Similar to `new Error().stack` +
      + +

      The first line of the trace will be prefixed with +${myObject.name}: ${myObject.message}.

      +

      The optional constructorOpt argument accepts a function. If given, all frames +above constructorOpt, including constructorOpt, will be omitted from the +generated stack trace.

      +

      The constructorOpt argument is useful for hiding implementation +details of error generation from the user. For instance:

      +
      function a() {
      b();
      }

      function b() {
      c();
      }

      function c() {
      // Create an error without stack trace to avoid calculating the stack trace twice.
      const { stackTraceLimit } = Error;
      Error.stackTraceLimit = 0;
      const error = new Error();
      Error.stackTraceLimit = stackTraceLimit;

      // Capture the stack trace above function b
      Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace
      throw error;
      }

      a(); +
      + +

      Parameters

      • targetObject: object
      • OptionalconstructorOpt: Function

      Returns void

    • Parameters

      • err: Error
      • stackTraces: CallSite[]

      Returns any

    diff --git a/docs/classes/_questdb_nodejs-client.QwpMemoryReplayFrameTooLargeError.html b/docs/classes/_questdb_nodejs-client.QwpMemoryReplayFrameTooLargeError.html new file mode 100644 index 0000000..ff9d32d --- /dev/null +++ b/docs/classes/_questdb_nodejs-client.QwpMemoryReplayFrameTooLargeError.html @@ -0,0 +1,37 @@ +QwpMemoryReplayFrameTooLargeError | QuestDB JavaScript Client - v4.2.0

    Class QwpMemoryReplayFrameTooLargeError

    One frame can never fit in the configured in-memory replay budget.

    +

    Hierarchy

    • RangeError
      • QwpMemoryReplayFrameTooLargeError
    Index

    Constructors

    Properties

    cause?: unknown
    maxBytes: number
    message: string
    name: string
    payloadBytes: number
    requiredBytes: number
    stack?: string
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames +collected by a stack trace (whether generated by new Error().stack or +Error.captureStackTrace(obj)).

    +

    The default value is 10 but may be set to any valid JavaScript number. Changes +will affect any stack trace captured after the value has been changed.

    +

    If set to a non-number value, or set to a negative number, stack traces will +not capture any frames.

    +

    Methods

    • Creates a .stack property on targetObject, which when accessed returns +a string representing the location in the code at which +Error.captureStackTrace() was called.

      +
      const myObject = {};
      Error.captureStackTrace(myObject);
      myObject.stack; // Similar to `new Error().stack` +
      + +

      The first line of the trace will be prefixed with +${myObject.name}: ${myObject.message}.

      +

      The optional constructorOpt argument accepts a function. If given, all frames +above constructorOpt, including constructorOpt, will be omitted from the +generated stack trace.

      +

      The constructorOpt argument is useful for hiding implementation +details of error generation from the user. For instance:

      +
      function a() {
      b();
      }

      function b() {
      c();
      }

      function c() {
      // Create an error without stack trace to avoid calculating the stack trace twice.
      const { stackTraceLimit } = Error;
      Error.stackTraceLimit = 0;
      const error = new Error();
      Error.stackTraceLimit = stackTraceLimit;

      // Capture the stack trace above function b
      Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace
      throw error;
      }

      a(); +
      + +

      Parameters

      • targetObject: object
      • OptionalconstructorOpt: Function

      Returns void

    • Parameters

      • err: Error
      • stackTraces: CallSite[]

      Returns any

    diff --git a/docs/classes/_questdb_nodejs-client.QwpNodeFileReplayStore.html b/docs/classes/_questdb_nodejs-client.QwpNodeFileReplayStore.html new file mode 100644 index 0000000..099621a --- /dev/null +++ b/docs/classes/_questdb_nodejs-client.QwpNodeFileReplayStore.html @@ -0,0 +1,26 @@ +QwpNodeFileReplayStore | QuestDB JavaScript Client - v4.2.0

    Node store-and-forward journal with configurable local durability.

    +

    The active fixed-size segment and one hot spare remain open for positional +writes. append fsyncs each frame, periodic batches barriers, and memory +relies on OS writeback. An ACK persists its cursor before bounded background +trimming. A crash between the server ACK and local deletion can cause +at-least-once replay. An exclusive, lifetime lock prevents another process +from recovering or mutating the same directory.

    +

    Implements

    Index

    Constructors

    Accessors

    Methods

    diff --git a/docs/classes/_questdb_nodejs-client.QwpNodeOrphanDrainer.html b/docs/classes/_questdb_nodejs-client.QwpNodeOrphanDrainer.html new file mode 100644 index 0000000..ba69c92 --- /dev/null +++ b/docs/classes/_questdb_nodejs-client.QwpNodeOrphanDrainer.html @@ -0,0 +1,10 @@ +QwpNodeOrphanDrainer | QuestDB JavaScript Client - v4.2.0

    Bounded Node-only scanner and background drainer for replay slots left by +terminated producer processes. Each adopted slot uses its own connection.

    +
    Index

    Constructors

    Accessors

    Methods

    Constructors

    Accessors

    Methods

    diff --git a/docs/classes/_questdb_nodejs-client.QwpNodeUdpSession.html b/docs/classes/_questdb_nodejs-client.QwpNodeUdpSession.html new file mode 100644 index 0000000..8b42a25 --- /dev/null +++ b/docs/classes/_questdb_nodejs-client.QwpNodeUdpSession.html @@ -0,0 +1,15 @@ +QwpNodeUdpSession | QuestDB JavaScript Client - v4.2.0

    Node-only, fire-and-forget QWP v1 ingress session over IPv4 UDP.

    +

    Each datagram is self-contained: it carries one table, an inline schema and +local symbol dictionaries. There are no ACKs, retries, transactions, +authentication, compression, or store-and-forward semantics.

    +

    Implements

    Index

    Properties

    maxBatchSizeBytes: number

    Accessors

    Methods

    diff --git a/docs/classes/_questdb_nodejs-client.QwpPoolAcquireTimeoutError.html b/docs/classes/_questdb_nodejs-client.QwpPoolAcquireTimeoutError.html new file mode 100644 index 0000000..fb56555 --- /dev/null +++ b/docs/classes/_questdb_nodejs-client.QwpPoolAcquireTimeoutError.html @@ -0,0 +1,36 @@ +QwpPoolAcquireTimeoutError | QuestDB JavaScript Client - v4.2.0

    Class QwpPoolAcquireTimeoutError

    A bounded QWP pool could not provide a connection before its deadline.

    +

    Hierarchy

    • Error
      • QwpPoolAcquireTimeoutError
    Index

    Constructors

    Properties

    cause?: unknown
    message: string
    name: string
    resource: "query" | "sender"
    stack?: string
    timeoutMs: number
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames +collected by a stack trace (whether generated by new Error().stack or +Error.captureStackTrace(obj)).

    +

    The default value is 10 but may be set to any valid JavaScript number. Changes +will affect any stack trace captured after the value has been changed.

    +

    If set to a non-number value, or set to a negative number, stack traces will +not capture any frames.

    +

    Methods

    • Creates a .stack property on targetObject, which when accessed returns +a string representing the location in the code at which +Error.captureStackTrace() was called.

      +
      const myObject = {};
      Error.captureStackTrace(myObject);
      myObject.stack; // Similar to `new Error().stack` +
      + +

      The first line of the trace will be prefixed with +${myObject.name}: ${myObject.message}.

      +

      The optional constructorOpt argument accepts a function. If given, all frames +above constructorOpt, including constructorOpt, will be omitted from the +generated stack trace.

      +

      The constructorOpt argument is useful for hiding implementation +details of error generation from the user. For instance:

      +
      function a() {
      b();
      }

      function b() {
      c();
      }

      function c() {
      // Create an error without stack trace to avoid calculating the stack trace twice.
      const { stackTraceLimit } = Error;
      Error.stackTraceLimit = 0;
      const error = new Error();
      Error.stackTraceLimit = stackTraceLimit;

      // Capture the stack trace above function b
      Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace
      throw error;
      }

      a(); +
      + +

      Parameters

      • targetObject: object
      • OptionalconstructorOpt: Function

      Returns void

    • Parameters

      • err: Error
      • stackTraces: CallSite[]

      Returns any

    diff --git a/docs/classes/_questdb_nodejs-client.QwpPoolResourceError.html b/docs/classes/_questdb_nodejs-client.QwpPoolResourceError.html new file mode 100644 index 0000000..e3f3d64 --- /dev/null +++ b/docs/classes/_questdb_nodejs-client.QwpPoolResourceError.html @@ -0,0 +1,35 @@ +QwpPoolResourceError | QuestDB JavaScript Client - v4.2.0

    A pooled resource failed while a new slot was being connected.

    +

    Hierarchy

    • Error
      • QwpPoolResourceError
    Index

    Constructors

    Properties

    cause: unknown
    message: string
    name: string
    resource: "query" | "sender"
    stack?: string
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames +collected by a stack trace (whether generated by new Error().stack or +Error.captureStackTrace(obj)).

    +

    The default value is 10 but may be set to any valid JavaScript number. Changes +will affect any stack trace captured after the value has been changed.

    +

    If set to a non-number value, or set to a negative number, stack traces will +not capture any frames.

    +

    Methods

    • Creates a .stack property on targetObject, which when accessed returns +a string representing the location in the code at which +Error.captureStackTrace() was called.

      +
      const myObject = {};
      Error.captureStackTrace(myObject);
      myObject.stack; // Similar to `new Error().stack` +
      + +

      The first line of the trace will be prefixed with +${myObject.name}: ${myObject.message}.

      +

      The optional constructorOpt argument accepts a function. If given, all frames +above constructorOpt, including constructorOpt, will be omitted from the +generated stack trace.

      +

      The constructorOpt argument is useful for hiding implementation +details of error generation from the user. For instance:

      +
      function a() {
      b();
      }

      function b() {
      c();
      }

      function c() {
      // Create an error without stack trace to avoid calculating the stack trace twice.
      const { stackTraceLimit } = Error;
      Error.stackTraceLimit = 0;
      const error = new Error();
      Error.stackTraceLimit = stackTraceLimit;

      // Capture the stack trace above function b
      Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace
      throw error;
      }

      a(); +
      + +

      Parameters

      • targetObject: object
      • OptionalconstructorOpt: Function

      Returns void

    • Parameters

      • err: Error
      • stackTraces: CallSite[]

      Returns any

    diff --git a/docs/classes/_questdb_nodejs-client.QwpProtocolError.html b/docs/classes/_questdb_nodejs-client.QwpProtocolError.html new file mode 100644 index 0000000..0a2adab --- /dev/null +++ b/docs/classes/_questdb_nodejs-client.QwpProtocolError.html @@ -0,0 +1,34 @@ +QwpProtocolError | QuestDB JavaScript Client - v4.2.0

    Raised when a QWP payload is malformed, truncated, or unsupported.

    +

    Hierarchy

    • Error
      • QwpProtocolError
    Index

    Constructors

    Properties

    cause?: unknown
    message: string
    name: string
    stack?: string
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames +collected by a stack trace (whether generated by new Error().stack or +Error.captureStackTrace(obj)).

    +

    The default value is 10 but may be set to any valid JavaScript number. Changes +will affect any stack trace captured after the value has been changed.

    +

    If set to a non-number value, or set to a negative number, stack traces will +not capture any frames.

    +

    Methods

    • Creates a .stack property on targetObject, which when accessed returns +a string representing the location in the code at which +Error.captureStackTrace() was called.

      +
      const myObject = {};
      Error.captureStackTrace(myObject);
      myObject.stack; // Similar to `new Error().stack` +
      + +

      The first line of the trace will be prefixed with +${myObject.name}: ${myObject.message}.

      +

      The optional constructorOpt argument accepts a function. If given, all frames +above constructorOpt, including constructorOpt, will be omitted from the +generated stack trace.

      +

      The constructorOpt argument is useful for hiding implementation +details of error generation from the user. For instance:

      +
      function a() {
      b();
      }

      function b() {
      c();
      }

      function c() {
      // Create an error without stack trace to avoid calculating the stack trace twice.
      const { stackTraceLimit } = Error;
      Error.stackTraceLimit = 0;
      const error = new Error();
      Error.stackTraceLimit = stackTraceLimit;

      // Capture the stack trace above function b
      Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace
      throw error;
      }

      a(); +
      + +

      Parameters

      • targetObject: object
      • OptionalconstructorOpt: Function

      Returns void

    • Parameters

      • err: Error
      • stackTraces: CallSite[]

      Returns any

    diff --git a/docs/classes/_questdb_nodejs-client.QwpQueryLease.html b/docs/classes/_questdb_nodejs-client.QwpQueryLease.html new file mode 100644 index 0000000..bfcea15 --- /dev/null +++ b/docs/classes/_questdb_nodejs-client.QwpQueryLease.html @@ -0,0 +1,14 @@ +QwpQueryLease | QuestDB JavaScript Client - v4.2.0

    One exclusively borrowed egress session from a QwpClient query pool.

    +
    Index

    Constructors

    Properties

    ready: Promise<QwpServerInfoMessage>

    Initial SERVER_INFO; use serverInfo for the current post-failover snapshot.

    +

    Accessors

    Methods

    diff --git a/docs/classes/_questdb_nodejs-client.QwpReconnectExhaustedError.html b/docs/classes/_questdb_nodejs-client.QwpReconnectExhaustedError.html new file mode 100644 index 0000000..45bf905 --- /dev/null +++ b/docs/classes/_questdb_nodejs-client.QwpReconnectExhaustedError.html @@ -0,0 +1,35 @@ +QwpReconnectExhaustedError | QuestDB JavaScript Client - v4.2.0

    Class QwpReconnectExhaustedError

    A configured QWP reconnect policy exhausted its retry boundary.

    +

    Hierarchy

    • Error
      • QwpReconnectExhaustedError
    Index

    Constructors

    Properties

    attempts: number
    cause: unknown
    message: string
    name: string
    stack?: string
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames +collected by a stack trace (whether generated by new Error().stack or +Error.captureStackTrace(obj)).

    +

    The default value is 10 but may be set to any valid JavaScript number. Changes +will affect any stack trace captured after the value has been changed.

    +

    If set to a non-number value, or set to a negative number, stack traces will +not capture any frames.

    +

    Methods

    • Creates a .stack property on targetObject, which when accessed returns +a string representing the location in the code at which +Error.captureStackTrace() was called.

      +
      const myObject = {};
      Error.captureStackTrace(myObject);
      myObject.stack; // Similar to `new Error().stack` +
      + +

      The first line of the trace will be prefixed with +${myObject.name}: ${myObject.message}.

      +

      The optional constructorOpt argument accepts a function. If given, all frames +above constructorOpt, including constructorOpt, will be omitted from the +generated stack trace.

      +

      The constructorOpt argument is useful for hiding implementation +details of error generation from the user. For instance:

      +
      function a() {
      b();
      }

      function b() {
      c();
      }

      function c() {
      // Create an error without stack trace to avoid calculating the stack trace twice.
      const { stackTraceLimit } = Error;
      Error.stackTraceLimit = 0;
      const error = new Error();
      Error.stackTraceLimit = stackTraceLimit;

      // Capture the stack trace above function b
      Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace
      throw error;
      }

      a(); +
      + +

      Parameters

      • targetObject: object
      • OptionalconstructorOpt: Function

      Returns void

    • Parameters

      • err: Error
      • stackTraces: CallSite[]

      Returns any

    diff --git a/docs/classes/_questdb_nodejs-client.QwpReplayDictionaryError.html b/docs/classes/_questdb_nodejs-client.QwpReplayDictionaryError.html new file mode 100644 index 0000000..3640fae --- /dev/null +++ b/docs/classes/_questdb_nodejs-client.QwpReplayDictionaryError.html @@ -0,0 +1,34 @@ +QwpReplayDictionaryError | QuestDB JavaScript Client - v4.2.0

    A replay store cannot preserve the dictionary required by delta frames.

    +

    Hierarchy (View Summary)

    Index

    Constructors

    Properties

    cause?: unknown
    message: string
    name: string
    stack?: string
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames +collected by a stack trace (whether generated by new Error().stack or +Error.captureStackTrace(obj)).

    +

    The default value is 10 but may be set to any valid JavaScript number. Changes +will affect any stack trace captured after the value has been changed.

    +

    If set to a non-number value, or set to a negative number, stack traces will +not capture any frames.

    +

    Methods

    • Creates a .stack property on targetObject, which when accessed returns +a string representing the location in the code at which +Error.captureStackTrace() was called.

      +
      const myObject = {};
      Error.captureStackTrace(myObject);
      myObject.stack; // Similar to `new Error().stack` +
      + +

      The first line of the trace will be prefixed with +${myObject.name}: ${myObject.message}.

      +

      The optional constructorOpt argument accepts a function. If given, all frames +above constructorOpt, including constructorOpt, will be omitted from the +generated stack trace.

      +

      The constructorOpt argument is useful for hiding implementation +details of error generation from the user. For instance:

      +
      function a() {
      b();
      }

      function b() {
      c();
      }

      function c() {
      // Create an error without stack trace to avoid calculating the stack trace twice.
      const { stackTraceLimit } = Error;
      Error.stackTraceLimit = 0;
      const error = new Error();
      Error.stackTraceLimit = stackTraceLimit;

      // Capture the stack trace above function b
      Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace
      throw error;
      }

      a(); +
      + +

      Parameters

      • targetObject: object
      • OptionalconstructorOpt: Function

      Returns void

    • Parameters

      • err: Error
      • stackTraces: CallSite[]

      Returns any

    diff --git a/docs/classes/_questdb_nodejs-client.QwpReplayDictionaryPersistenceError.html b/docs/classes/_questdb_nodejs-client.QwpReplayDictionaryPersistenceError.html new file mode 100644 index 0000000..98a19cc --- /dev/null +++ b/docs/classes/_questdb_nodejs-client.QwpReplayDictionaryPersistenceError.html @@ -0,0 +1,36 @@ +QwpReplayDictionaryPersistenceError | QuestDB JavaScript Client - v4.2.0

    Class QwpReplayDictionaryPersistenceError

    A replay dictionary sidecar rejected an append before its delta frame was +published. The reconnecting transport has permanently switched to full, +self-contained symbol encoding; retrying the logical batch is safe.

    +

    Hierarchy (View Summary)

    Index

    Constructors

    Properties

    cause?: unknown
    message: string
    name: string
    stack?: string
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames +collected by a stack trace (whether generated by new Error().stack or +Error.captureStackTrace(obj)).

    +

    The default value is 10 but may be set to any valid JavaScript number. Changes +will affect any stack trace captured after the value has been changed.

    +

    If set to a non-number value, or set to a negative number, stack traces will +not capture any frames.

    +

    Methods

    • Creates a .stack property on targetObject, which when accessed returns +a string representing the location in the code at which +Error.captureStackTrace() was called.

      +
      const myObject = {};
      Error.captureStackTrace(myObject);
      myObject.stack; // Similar to `new Error().stack` +
      + +

      The first line of the trace will be prefixed with +${myObject.name}: ${myObject.message}.

      +

      The optional constructorOpt argument accepts a function. If given, all frames +above constructorOpt, including constructorOpt, will be omitted from the +generated stack trace.

      +

      The constructorOpt argument is useful for hiding implementation +details of error generation from the user. For instance:

      +
      function a() {
      b();
      }

      function b() {
      c();
      }

      function c() {
      // Create an error without stack trace to avoid calculating the stack trace twice.
      const { stackTraceLimit } = Error;
      Error.stackTraceLimit = 0;
      const error = new Error();
      Error.stackTraceLimit = stackTraceLimit;

      // Capture the stack trace above function b
      Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace
      throw error;
      }

      a(); +
      + +

      Parameters

      • targetObject: object
      • OptionalconstructorOpt: Function

      Returns void

    diff --git a/docs/classes/_questdb_nodejs-client.QwpReplayRejectedError.html b/docs/classes/_questdb_nodejs-client.QwpReplayRejectedError.html new file mode 100644 index 0000000..e084ded --- /dev/null +++ b/docs/classes/_questdb_nodejs-client.QwpReplayRejectedError.html @@ -0,0 +1,36 @@ +QwpReplayRejectedError | QuestDB JavaScript Client - v4.2.0

    A replayed ingress frame was rejected and remains in persistent storage.

    +

    Hierarchy

    • Error
      • QwpReplayRejectedError
    Index

    Constructors

    Properties

    cause?: unknown
    frameSequence: bigint
    message: string
    name: string
    stack?: string
    status: number
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames +collected by a stack trace (whether generated by new Error().stack or +Error.captureStackTrace(obj)).

    +

    The default value is 10 but may be set to any valid JavaScript number. Changes +will affect any stack trace captured after the value has been changed.

    +

    If set to a non-number value, or set to a negative number, stack traces will +not capture any frames.

    +

    Methods

    • Creates a .stack property on targetObject, which when accessed returns +a string representing the location in the code at which +Error.captureStackTrace() was called.

      +
      const myObject = {};
      Error.captureStackTrace(myObject);
      myObject.stack; // Similar to `new Error().stack` +
      + +

      The first line of the trace will be prefixed with +${myObject.name}: ${myObject.message}.

      +

      The optional constructorOpt argument accepts a function. If given, all frames +above constructorOpt, including constructorOpt, will be omitted from the +generated stack trace.

      +

      The constructorOpt argument is useful for hiding implementation +details of error generation from the user. For instance:

      +
      function a() {
      b();
      }

      function b() {
      c();
      }

      function c() {
      // Create an error without stack trace to avoid calculating the stack trace twice.
      const { stackTraceLimit } = Error;
      Error.stackTraceLimit = 0;
      const error = new Error();
      Error.stackTraceLimit = stackTraceLimit;

      // Capture the stack trace above function b
      Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace
      throw error;
      }

      a(); +
      + +

      Parameters

      • targetObject: object
      • OptionalconstructorOpt: Function

      Returns void

    • Parameters

      • err: Error
      • stackTraces: CallSite[]

      Returns any

    diff --git a/docs/classes/_questdb_nodejs-client.QwpReplayStoreAppendTimeoutError.html b/docs/classes/_questdb_nodejs-client.QwpReplayStoreAppendTimeoutError.html new file mode 100644 index 0000000..83f6048 --- /dev/null +++ b/docs/classes/_questdb_nodejs-client.QwpReplayStoreAppendTimeoutError.html @@ -0,0 +1,44 @@ +QwpReplayStoreAppendTimeoutError | QuestDB JavaScript Client - v4.2.0

    Class QwpReplayStoreAppendTimeoutError

    Hierarchy (View Summary)

    Index

    Constructors

    Properties

    cause?: unknown
    maxBytes: number
    message: string
    name: string
    requiredBytes: number
    retryable: boolean = true

    Whether reconnecting and replaying can plausibly clear this failure.

    +

    Background maintenance and checkpoint faults are parked and cleared on the +next successful batch, so a briefly full, read-only or descriptor-starved +filesystem is retryable. Structural corruption and a slot lock taken over +by another process are verdicts on the journal itself and are not. The +ingress connection lives in the browser-safe layer and cannot reference +these classes, so it reads this flag structurally.

    +
    stack?: string
    timeoutMs: number
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames +collected by a stack trace (whether generated by new Error().stack or +Error.captureStackTrace(obj)).

    +

    The default value is 10 but may be set to any valid JavaScript number. Changes +will affect any stack trace captured after the value has been changed.

    +

    If set to a non-number value, or set to a negative number, stack traces will +not capture any frames.

    +

    Methods

    • Creates a .stack property on targetObject, which when accessed returns +a string representing the location in the code at which +Error.captureStackTrace() was called.

      +
      const myObject = {};
      Error.captureStackTrace(myObject);
      myObject.stack; // Similar to `new Error().stack` +
      + +

      The first line of the trace will be prefixed with +${myObject.name}: ${myObject.message}.

      +

      The optional constructorOpt argument accepts a function. If given, all frames +above constructorOpt, including constructorOpt, will be omitted from the +generated stack trace.

      +

      The constructorOpt argument is useful for hiding implementation +details of error generation from the user. For instance:

      +
      function a() {
      b();
      }

      function b() {
      c();
      }

      function c() {
      // Create an error without stack trace to avoid calculating the stack trace twice.
      const { stackTraceLimit } = Error;
      Error.stackTraceLimit = 0;
      const error = new Error();
      Error.stackTraceLimit = stackTraceLimit;

      // Capture the stack trace above function b
      Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace
      throw error;
      }

      a(); +
      + +

      Parameters

      • targetObject: object
      • OptionalconstructorOpt: Function

      Returns void

    diff --git a/docs/classes/_questdb_nodejs-client.QwpReplayStoreCheckpointError.html b/docs/classes/_questdb_nodejs-client.QwpReplayStoreCheckpointError.html new file mode 100644 index 0000000..33ef3c9 --- /dev/null +++ b/docs/classes/_questdb_nodejs-client.QwpReplayStoreCheckpointError.html @@ -0,0 +1,42 @@ +QwpReplayStoreCheckpointError | QuestDB JavaScript Client - v4.2.0

    Class QwpReplayStoreCheckpointError

    Hierarchy (View Summary)

    Index

    Constructors

    Properties

    cause?: unknown
    directory: string
    message: string
    name: string
    retryable: boolean = true

    Whether reconnecting and replaying can plausibly clear this failure.

    +

    Background maintenance and checkpoint faults are parked and cleared on the +next successful batch, so a briefly full, read-only or descriptor-starved +filesystem is retryable. Structural corruption and a slot lock taken over +by another process are verdicts on the journal itself and are not. The +ingress connection lives in the browser-safe layer and cannot reference +these classes, so it reads this flag structurally.

    +
    stack?: string
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames +collected by a stack trace (whether generated by new Error().stack or +Error.captureStackTrace(obj)).

    +

    The default value is 10 but may be set to any valid JavaScript number. Changes +will affect any stack trace captured after the value has been changed.

    +

    If set to a non-number value, or set to a negative number, stack traces will +not capture any frames.

    +

    Methods

    • Creates a .stack property on targetObject, which when accessed returns +a string representing the location in the code at which +Error.captureStackTrace() was called.

      +
      const myObject = {};
      Error.captureStackTrace(myObject);
      myObject.stack; // Similar to `new Error().stack` +
      + +

      The first line of the trace will be prefixed with +${myObject.name}: ${myObject.message}.

      +

      The optional constructorOpt argument accepts a function. If given, all frames +above constructorOpt, including constructorOpt, will be omitted from the +generated stack trace.

      +

      The constructorOpt argument is useful for hiding implementation +details of error generation from the user. For instance:

      +
      function a() {
      b();
      }

      function b() {
      c();
      }

      function c() {
      // Create an error without stack trace to avoid calculating the stack trace twice.
      const { stackTraceLimit } = Error;
      Error.stackTraceLimit = 0;
      const error = new Error();
      Error.stackTraceLimit = stackTraceLimit;

      // Capture the stack trace above function b
      Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace
      throw error;
      }

      a(); +
      + +

      Parameters

      • targetObject: object
      • OptionalconstructorOpt: Function

      Returns void

    diff --git a/docs/classes/_questdb_nodejs-client.QwpReplayStoreCorruptionError.html b/docs/classes/_questdb_nodejs-client.QwpReplayStoreCorruptionError.html new file mode 100644 index 0000000..0a739fa --- /dev/null +++ b/docs/classes/_questdb_nodejs-client.QwpReplayStoreCorruptionError.html @@ -0,0 +1,36 @@ +QwpReplayStoreCorruptionError | QuestDB JavaScript Client - v4.2.0

    Class QwpReplayStoreCorruptionError

    Durable journal bytes are structurally corrupt and cannot be replayed.

    +

    Hierarchy (View Summary)

    Index

    Constructors

    Properties

    cause?: unknown
    message: string
    name: string
    retryable: false

    Corrupt bytes read the same way on every attempt.

    +
    stack?: string
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames +collected by a stack trace (whether generated by new Error().stack or +Error.captureStackTrace(obj)).

    +

    The default value is 10 but may be set to any valid JavaScript number. Changes +will affect any stack trace captured after the value has been changed.

    +

    If set to a non-number value, or set to a negative number, stack traces will +not capture any frames.

    +

    Methods

    • Creates a .stack property on targetObject, which when accessed returns +a string representing the location in the code at which +Error.captureStackTrace() was called.

      +
      const myObject = {};
      Error.captureStackTrace(myObject);
      myObject.stack; // Similar to `new Error().stack` +
      + +

      The first line of the trace will be prefixed with +${myObject.name}: ${myObject.message}.

      +

      The optional constructorOpt argument accepts a function. If given, all frames +above constructorOpt, including constructorOpt, will be omitted from the +generated stack trace.

      +

      The constructorOpt argument is useful for hiding implementation +details of error generation from the user. For instance:

      +
      function a() {
      b();
      }

      function b() {
      c();
      }

      function c() {
      // Create an error without stack trace to avoid calculating the stack trace twice.
      const { stackTraceLimit } = Error;
      Error.stackTraceLimit = 0;
      const error = new Error();
      Error.stackTraceLimit = stackTraceLimit;

      // Capture the stack trace above function b
      Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace
      throw error;
      }

      a(); +
      + +

      Parameters

      • targetObject: object
      • OptionalconstructorOpt: Function

      Returns void

    diff --git a/docs/classes/_questdb_nodejs-client.QwpReplayStoreError.html b/docs/classes/_questdb_nodejs-client.QwpReplayStoreError.html new file mode 100644 index 0000000..7584c49 --- /dev/null +++ b/docs/classes/_questdb_nodejs-client.QwpReplayStoreError.html @@ -0,0 +1,41 @@ +QwpReplayStoreError | QuestDB JavaScript Client - v4.2.0

    Hierarchy (View Summary)

    Index

    Constructors

    Properties

    cause?: unknown
    message: string
    name: string
    retryable: boolean = true

    Whether reconnecting and replaying can plausibly clear this failure.

    +

    Background maintenance and checkpoint faults are parked and cleared on the +next successful batch, so a briefly full, read-only or descriptor-starved +filesystem is retryable. Structural corruption and a slot lock taken over +by another process are verdicts on the journal itself and are not. The +ingress connection lives in the browser-safe layer and cannot reference +these classes, so it reads this flag structurally.

    +
    stack?: string
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames +collected by a stack trace (whether generated by new Error().stack or +Error.captureStackTrace(obj)).

    +

    The default value is 10 but may be set to any valid JavaScript number. Changes +will affect any stack trace captured after the value has been changed.

    +

    If set to a non-number value, or set to a negative number, stack traces will +not capture any frames.

    +

    Methods

    • Creates a .stack property on targetObject, which when accessed returns +a string representing the location in the code at which +Error.captureStackTrace() was called.

      +
      const myObject = {};
      Error.captureStackTrace(myObject);
      myObject.stack; // Similar to `new Error().stack` +
      + +

      The first line of the trace will be prefixed with +${myObject.name}: ${myObject.message}.

      +

      The optional constructorOpt argument accepts a function. If given, all frames +above constructorOpt, including constructorOpt, will be omitted from the +generated stack trace.

      +

      The constructorOpt argument is useful for hiding implementation +details of error generation from the user. For instance:

      +
      function a() {
      b();
      }

      function b() {
      c();
      }

      function c() {
      // Create an error without stack trace to avoid calculating the stack trace twice.
      const { stackTraceLimit } = Error;
      Error.stackTraceLimit = 0;
      const error = new Error();
      Error.stackTraceLimit = stackTraceLimit;

      // Capture the stack trace above function b
      Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace
      throw error;
      }

      a(); +
      + +

      Parameters

      • targetObject: object
      • OptionalconstructorOpt: Function

      Returns void

    • Parameters

      • err: Error
      • stackTraces: CallSite[]

      Returns any

    diff --git a/docs/classes/_questdb_nodejs-client.QwpReplayStoreFullError.html b/docs/classes/_questdb_nodejs-client.QwpReplayStoreFullError.html new file mode 100644 index 0000000..45a257b --- /dev/null +++ b/docs/classes/_questdb_nodejs-client.QwpReplayStoreFullError.html @@ -0,0 +1,43 @@ +QwpReplayStoreFullError | QuestDB JavaScript Client - v4.2.0

    Hierarchy (View Summary)

    Index

    Constructors

    Properties

    cause?: unknown
    maxBytes: number
    message: string
    name: string
    requiredBytes: number
    retryable: boolean = true

    Whether reconnecting and replaying can plausibly clear this failure.

    +

    Background maintenance and checkpoint faults are parked and cleared on the +next successful batch, so a briefly full, read-only or descriptor-starved +filesystem is retryable. Structural corruption and a slot lock taken over +by another process are verdicts on the journal itself and are not. The +ingress connection lives in the browser-safe layer and cannot reference +these classes, so it reads this flag structurally.

    +
    stack?: string
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames +collected by a stack trace (whether generated by new Error().stack or +Error.captureStackTrace(obj)).

    +

    The default value is 10 but may be set to any valid JavaScript number. Changes +will affect any stack trace captured after the value has been changed.

    +

    If set to a non-number value, or set to a negative number, stack traces will +not capture any frames.

    +

    Methods

    • Creates a .stack property on targetObject, which when accessed returns +a string representing the location in the code at which +Error.captureStackTrace() was called.

      +
      const myObject = {};
      Error.captureStackTrace(myObject);
      myObject.stack; // Similar to `new Error().stack` +
      + +

      The first line of the trace will be prefixed with +${myObject.name}: ${myObject.message}.

      +

      The optional constructorOpt argument accepts a function. If given, all frames +above constructorOpt, including constructorOpt, will be omitted from the +generated stack trace.

      +

      The constructorOpt argument is useful for hiding implementation +details of error generation from the user. For instance:

      +
      function a() {
      b();
      }

      function b() {
      c();
      }

      function c() {
      // Create an error without stack trace to avoid calculating the stack trace twice.
      const { stackTraceLimit } = Error;
      Error.stackTraceLimit = 0;
      const error = new Error();
      Error.stackTraceLimit = stackTraceLimit;

      // Capture the stack trace above function b
      Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace
      throw error;
      }

      a(); +
      + +

      Parameters

      • targetObject: object
      • OptionalconstructorOpt: Function

      Returns void

    diff --git a/docs/classes/_questdb_nodejs-client.QwpReplayStoreLockLostError.html b/docs/classes/_questdb_nodejs-client.QwpReplayStoreLockLostError.html new file mode 100644 index 0000000..3c395c0 --- /dev/null +++ b/docs/classes/_questdb_nodejs-client.QwpReplayStoreLockLostError.html @@ -0,0 +1,46 @@ +QwpReplayStoreLockLostError | QuestDB JavaScript Client - v4.2.0

    Class QwpReplayStoreLockLostError

    The advisory lock guarding this journal was taken over by another process +while it was open, so this store may no longer write to it.

    +

    A holder whose heartbeat lapses -- a long synchronous section, a paused +process, a stalled filesystem -- can have its slot reclaimed while it still +believes it holds it. Whatever this store does next must not be an append: +the new owner appends at offsets this store still believes are free, and +because a frame's sequence is derived from its position, an overwrite of the +same width leaves a journal that reopens as intact with the new owner's +frames gone. Failing the append is what keeps that loss impossible.

    +

    Hierarchy (View Summary)

    Index

    Constructors

    Properties

    cause?: unknown
    directory: string
    message: string
    name: string
    retryable: false

    Retrying is precisely what must not happen: the slot belongs to another +process now, so replaying out of it would race that owner's appends.

    +
    stack?: string
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames +collected by a stack trace (whether generated by new Error().stack or +Error.captureStackTrace(obj)).

    +

    The default value is 10 but may be set to any valid JavaScript number. Changes +will affect any stack trace captured after the value has been changed.

    +

    If set to a non-number value, or set to a negative number, stack traces will +not capture any frames.

    +

    Methods

    • Creates a .stack property on targetObject, which when accessed returns +a string representing the location in the code at which +Error.captureStackTrace() was called.

      +
      const myObject = {};
      Error.captureStackTrace(myObject);
      myObject.stack; // Similar to `new Error().stack` +
      + +

      The first line of the trace will be prefixed with +${myObject.name}: ${myObject.message}.

      +

      The optional constructorOpt argument accepts a function. If given, all frames +above constructorOpt, including constructorOpt, will be omitted from the +generated stack trace.

      +

      The constructorOpt argument is useful for hiding implementation +details of error generation from the user. For instance:

      +
      function a() {
      b();
      }

      function b() {
      c();
      }

      function c() {
      // Create an error without stack trace to avoid calculating the stack trace twice.
      const { stackTraceLimit } = Error;
      Error.stackTraceLimit = 0;
      const error = new Error();
      Error.stackTraceLimit = stackTraceLimit;

      // Capture the stack trace above function b
      Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace
      throw error;
      }

      a(); +
      + +

      Parameters

      • targetObject: object
      • OptionalconstructorOpt: Function

      Returns void

    diff --git a/docs/classes/_questdb_nodejs-client.QwpReplayStoreLockedError.html b/docs/classes/_questdb_nodejs-client.QwpReplayStoreLockedError.html new file mode 100644 index 0000000..58d4f24 --- /dev/null +++ b/docs/classes/_questdb_nodejs-client.QwpReplayStoreLockedError.html @@ -0,0 +1,43 @@ +QwpReplayStoreLockedError | QuestDB JavaScript Client - v4.2.0

    Hierarchy (View Summary)

    Index

    Constructors

    Properties

    cause?: unknown
    directory: string
    holderPid?: number
    message: string
    name: string
    retryable: boolean = true

    Whether reconnecting and replaying can plausibly clear this failure.

    +

    Background maintenance and checkpoint faults are parked and cleared on the +next successful batch, so a briefly full, read-only or descriptor-starved +filesystem is retryable. Structural corruption and a slot lock taken over +by another process are verdicts on the journal itself and are not. The +ingress connection lives in the browser-safe layer and cannot reference +these classes, so it reads this flag structurally.

    +
    stack?: string
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames +collected by a stack trace (whether generated by new Error().stack or +Error.captureStackTrace(obj)).

    +

    The default value is 10 but may be set to any valid JavaScript number. Changes +will affect any stack trace captured after the value has been changed.

    +

    If set to a non-number value, or set to a negative number, stack traces will +not capture any frames.

    +

    Methods

    • Creates a .stack property on targetObject, which when accessed returns +a string representing the location in the code at which +Error.captureStackTrace() was called.

      +
      const myObject = {};
      Error.captureStackTrace(myObject);
      myObject.stack; // Similar to `new Error().stack` +
      + +

      The first line of the trace will be prefixed with +${myObject.name}: ${myObject.message}.

      +

      The optional constructorOpt argument accepts a function. If given, all frames +above constructorOpt, including constructorOpt, will be omitted from the +generated stack trace.

      +

      The constructorOpt argument is useful for hiding implementation +details of error generation from the user. For instance:

      +
      function a() {
      b();
      }

      function b() {
      c();
      }

      function c() {
      // Create an error without stack trace to avoid calculating the stack trace twice.
      const { stackTraceLimit } = Error;
      Error.stackTraceLimit = 0;
      const error = new Error();
      Error.stackTraceLimit = stackTraceLimit;

      // Capture the stack trace above function b
      Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace
      throw error;
      }

      a(); +
      + +

      Parameters

      • targetObject: object
      • OptionalconstructorOpt: Function

      Returns void

    diff --git a/docs/classes/_questdb_nodejs-client.QwpReplayStoreQuarantinedError.html b/docs/classes/_questdb_nodejs-client.QwpReplayStoreQuarantinedError.html new file mode 100644 index 0000000..becff61 --- /dev/null +++ b/docs/classes/_questdb_nodejs-client.QwpReplayStoreQuarantinedError.html @@ -0,0 +1,44 @@ +QwpReplayStoreQuarantinedError | QuestDB JavaScript Client - v4.2.0

    Class QwpReplayStoreQuarantinedError

    A terminal replay slot was preserved under a quarantine pathname.

    +

    Hierarchy (View Summary)

    Index

    Constructors

    Properties

    cause?: unknown
    directory: string
    message: string
    name: string
    quarantineDirectory: string
    retryable: boolean = true

    Whether reconnecting and replaying can plausibly clear this failure.

    +

    Background maintenance and checkpoint faults are parked and cleared on the +next successful batch, so a briefly full, read-only or descriptor-starved +filesystem is retryable. Structural corruption and a slot lock taken over +by another process are verdicts on the journal itself and are not. The +ingress connection lives in the browser-safe layer and cannot reference +these classes, so it reads this flag structurally.

    +
    stack?: string
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames +collected by a stack trace (whether generated by new Error().stack or +Error.captureStackTrace(obj)).

    +

    The default value is 10 but may be set to any valid JavaScript number. Changes +will affect any stack trace captured after the value has been changed.

    +

    If set to a non-number value, or set to a negative number, stack traces will +not capture any frames.

    +

    Methods

    • Creates a .stack property on targetObject, which when accessed returns +a string representing the location in the code at which +Error.captureStackTrace() was called.

      +
      const myObject = {};
      Error.captureStackTrace(myObject);
      myObject.stack; // Similar to `new Error().stack` +
      + +

      The first line of the trace will be prefixed with +${myObject.name}: ${myObject.message}.

      +

      The optional constructorOpt argument accepts a function. If given, all frames +above constructorOpt, including constructorOpt, will be omitted from the +generated stack trace.

      +

      The constructorOpt argument is useful for hiding implementation +details of error generation from the user. For instance:

      +
      function a() {
      b();
      }

      function b() {
      c();
      }

      function c() {
      // Create an error without stack trace to avoid calculating the stack trace twice.
      const { stackTraceLimit } = Error;
      Error.stackTraceLimit = 0;
      const error = new Error();
      Error.stackTraceLimit = stackTraceLimit;

      // Capture the stack trace above function b
      Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace
      throw error;
      }

      a(); +
      + +

      Parameters

      • targetObject: object
      • OptionalconstructorOpt: Function

      Returns void

    diff --git a/docs/classes/_questdb_nodejs-client.QwpReplayStoreSegmentTooLargeError.html b/docs/classes/_questdb_nodejs-client.QwpReplayStoreSegmentTooLargeError.html new file mode 100644 index 0000000..e7afbf1 --- /dev/null +++ b/docs/classes/_questdb_nodejs-client.QwpReplayStoreSegmentTooLargeError.html @@ -0,0 +1,43 @@ +QwpReplayStoreSegmentTooLargeError | QuestDB JavaScript Client - v4.2.0

    Class QwpReplayStoreSegmentTooLargeError

    Hierarchy (View Summary)

    Index

    Constructors

    Properties

    cause?: unknown
    maxSegmentBytes: number
    message: string
    name: string
    payloadBytes: number
    retryable: boolean = true

    Whether reconnecting and replaying can plausibly clear this failure.

    +

    Background maintenance and checkpoint faults are parked and cleared on the +next successful batch, so a briefly full, read-only or descriptor-starved +filesystem is retryable. Structural corruption and a slot lock taken over +by another process are verdicts on the journal itself and are not. The +ingress connection lives in the browser-safe layer and cannot reference +these classes, so it reads this flag structurally.

    +
    stack?: string
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames +collected by a stack trace (whether generated by new Error().stack or +Error.captureStackTrace(obj)).

    +

    The default value is 10 but may be set to any valid JavaScript number. Changes +will affect any stack trace captured after the value has been changed.

    +

    If set to a non-number value, or set to a negative number, stack traces will +not capture any frames.

    +

    Methods

    • Creates a .stack property on targetObject, which when accessed returns +a string representing the location in the code at which +Error.captureStackTrace() was called.

      +
      const myObject = {};
      Error.captureStackTrace(myObject);
      myObject.stack; // Similar to `new Error().stack` +
      + +

      The first line of the trace will be prefixed with +${myObject.name}: ${myObject.message}.

      +

      The optional constructorOpt argument accepts a function. If given, all frames +above constructorOpt, including constructorOpt, will be omitted from the +generated stack trace.

      +

      The constructorOpt argument is useful for hiding implementation +details of error generation from the user. For instance:

      +
      function a() {
      b();
      }

      function b() {
      c();
      }

      function c() {
      // Create an error without stack trace to avoid calculating the stack trace twice.
      const { stackTraceLimit } = Error;
      Error.stackTraceLimit = 0;
      const error = new Error();
      Error.stackTraceLimit = stackTraceLimit;

      // Capture the stack trace above function b
      Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace
      throw error;
      }

      a(); +
      + +

      Parameters

      • targetObject: object
      • OptionalconstructorOpt: Function

      Returns void

    diff --git a/docs/classes/_questdb_nodejs-client.QwpResultBatch.html b/docs/classes/_questdb_nodejs-client.QwpResultBatch.html new file mode 100644 index 0000000..d20c72d --- /dev/null +++ b/docs/classes/_questdb_nodejs-client.QwpResultBatch.html @@ -0,0 +1,9 @@ +QwpResultBatch | QuestDB JavaScript Client - v4.2.0
    Index

    Constructors

    Properties

    Methods

    Constructors

    Properties

    batchSequence: bigint
    columns: readonly QwpResultColumn[]
    requestId: bigint
    rowCount: number
    tableName: string

    Methods

    diff --git a/docs/classes/_questdb_nodejs-client.QwpResultBatchDecoder.html b/docs/classes/_questdb_nodejs-client.QwpResultBatchDecoder.html new file mode 100644 index 0000000..27fcd8b --- /dev/null +++ b/docs/classes/_questdb_nodejs-client.QwpResultBatchDecoder.html @@ -0,0 +1,13 @@ +QwpResultBatchDecoder | QuestDB JavaScript Client - v4.2.0

    Stateful decoder for connection-scoped QWP result batches.

    +
    Index

    Constructors

    Methods

    diff --git a/docs/classes/_questdb_nodejs-client.QwpResultBatchView.html b/docs/classes/_questdb_nodejs-client.QwpResultBatchView.html new file mode 100644 index 0000000..e6783c3 --- /dev/null +++ b/docs/classes/_questdb_nodejs-client.QwpResultBatchView.html @@ -0,0 +1,25 @@ +QwpResultBatchView | QuestDB JavaScript Client - v4.2.0

    Batch-owned reusable view delivered by QwpEgressSession.queryViews(). +Access is invalid after the callback returns. materialize() creates an +independently owned QwpResultBatch when retention is required.

    +
    Index

    Constructors

    Accessors

    Methods

    • Internal

      Parameters

      • requestId: bigint
      • batchSequence: bigint
      • tableName: string
      • rowCount: number
      • layouts: QwpResultColumnViewLayout[]

      Returns this

    diff --git a/docs/classes/_questdb_nodejs-client.QwpResultColumnView.html b/docs/classes/_questdb_nodejs-client.QwpResultColumnView.html new file mode 100644 index 0000000..93453b1 --- /dev/null +++ b/docs/classes/_questdb_nodejs-client.QwpResultColumnView.html @@ -0,0 +1,56 @@ +QwpResultColumnView | QuestDB JavaScript Client - v4.2.0

    Reusable, zero-copy view over one QWP result column.

    +

    The view and every byte slice returned from it are valid only while the +surrounding queryViews() callback is running. Copy data that must outlive +the callback.

    +
    Index

    Constructors

    Properties

    columnIndex: number

    Accessors

    Methods

    • Raw packed non-null values. Fixed-width values use QWP little-endian +layout; booleans are bit-packed and variable-width columns contain their +uint32 offset table. SYMBOL returns undefined because IDs are varints.

      +

      Returns Uint8Array<ArrayBufferLike>

    diff --git a/docs/classes/_questdb_nodejs-client.QwpResultRowView.html b/docs/classes/_questdb_nodejs-client.QwpResultRowView.html new file mode 100644 index 0000000..b5363f9 --- /dev/null +++ b/docs/classes/_questdb_nodejs-client.QwpResultRowView.html @@ -0,0 +1,38 @@ +QwpResultRowView | QuestDB JavaScript Client - v4.2.0

    Reusable row-pinned facade over a QwpResultBatchView.

    +

    The batch owns one instance and re-points it in place. It is valid only +while the surrounding queryViews() callback is running, and must not be +retained across forEachRow() iterations. Byte and array views returned by +its accessors remain zero-copy and have the same lifetime.

    +
    Index

    Constructors

    Accessors

    Methods

    diff --git a/docs/classes/_questdb_nodejs-client.QwpRoleMismatchError.html b/docs/classes/_questdb_nodejs-client.QwpRoleMismatchError.html new file mode 100644 index 0000000..7128c73 --- /dev/null +++ b/docs/classes/_questdb_nodejs-client.QwpRoleMismatchError.html @@ -0,0 +1,50 @@ +QwpRoleMismatchError | QuestDB JavaScript Client - v4.2.0

    A connected endpoint advertised a role that does not satisfy target.

    +

    Hierarchy (View Summary)

    Index

    Constructors

    Properties

    cause?: unknown
    closeCode?: number
    message: string
    name: string
    retryable?: boolean
    serverRole?: string
    serverZone?: string
    stack?: string
    statusCode?: number
    statusMessage?: string
    target: QwpTarget
    timeoutPhase?: QwpUpgradeTimeoutPhase

    Node opening phase that exceeded its deadline.

    +
    tryNextEndpoint?: boolean
    url?: string | URL
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames +collected by a stack trace (whether generated by new Error().stack or +Error.captureStackTrace(obj)).

    +

    The default value is 10 but may be set to any valid JavaScript number. Changes +will affect any stack trace captured after the value has been changed.

    +

    If set to a non-number value, or set to a negative number, stack traces will +not capture any frames.

    +

    Accessors

    Methods

    • Creates a .stack property on targetObject, which when accessed returns +a string representing the location in the code at which +Error.captureStackTrace() was called.

      +
      const myObject = {};
      Error.captureStackTrace(myObject);
      myObject.stack; // Similar to `new Error().stack` +
      + +

      The first line of the trace will be prefixed with +${myObject.name}: ${myObject.message}.

      +

      The optional constructorOpt argument accepts a function. If given, all frames +above constructorOpt, including constructorOpt, will be omitted from the +generated stack trace.

      +

      The constructorOpt argument is useful for hiding implementation +details of error generation from the user. For instance:

      +
      function a() {
      b();
      }

      function b() {
      c();
      }

      function c() {
      // Create an error without stack trace to avoid calculating the stack trace twice.
      const { stackTraceLimit } = Error;
      Error.stackTraceLimit = 0;
      const error = new Error();
      Error.stackTraceLimit = stackTraceLimit;

      // Capture the stack trace above function b
      Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace
      throw error;
      }

      a(); +
      + +

      Parameters

      • targetObject: object
      • OptionalconstructorOpt: Function

      Returns void

    diff --git a/docs/classes/_questdb_nodejs-client.QwpSendClosedError.html b/docs/classes/_questdb_nodejs-client.QwpSendClosedError.html new file mode 100644 index 0000000..9ab2b2c --- /dev/null +++ b/docs/classes/_questdb_nodejs-client.QwpSendClosedError.html @@ -0,0 +1,35 @@ +QwpSendClosedError | QuestDB JavaScript Client - v4.2.0

    A QWP send was rejected because its WebSocket closed.

    +

    Hierarchy (View Summary)

    Index

    Constructors

    Properties

    cause?: unknown
    message: string
    name: string
    stack?: string
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames +collected by a stack trace (whether generated by new Error().stack or +Error.captureStackTrace(obj)).

    +

    The default value is 10 but may be set to any valid JavaScript number. Changes +will affect any stack trace captured after the value has been changed.

    +

    If set to a non-number value, or set to a negative number, stack traces will +not capture any frames.

    +

    Methods

    • Creates a .stack property on targetObject, which when accessed returns +a string representing the location in the code at which +Error.captureStackTrace() was called.

      +
      const myObject = {};
      Error.captureStackTrace(myObject);
      myObject.stack; // Similar to `new Error().stack` +
      + +

      The first line of the trace will be prefixed with +${myObject.name}: ${myObject.message}.

      +

      The optional constructorOpt argument accepts a function. If given, all frames +above constructorOpt, including constructorOpt, will be omitted from the +generated stack trace.

      +

      The constructorOpt argument is useful for hiding implementation +details of error generation from the user. For instance:

      +
      function a() {
      b();
      }

      function b() {
      c();
      }

      function c() {
      // Create an error without stack trace to avoid calculating the stack trace twice.
      const { stackTraceLimit } = Error;
      Error.stackTraceLimit = 0;
      const error = new Error();
      Error.stackTraceLimit = stackTraceLimit;

      // Capture the stack trace above function b
      Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace
      throw error;
      }

      a(); +
      + +

      Parameters

      • targetObject: object
      • OptionalconstructorOpt: Function

      Returns void

    diff --git a/docs/classes/_questdb_nodejs-client.QwpSendError.html b/docs/classes/_questdb_nodejs-client.QwpSendError.html new file mode 100644 index 0000000..82ee29a --- /dev/null +++ b/docs/classes/_questdb_nodejs-client.QwpSendError.html @@ -0,0 +1,34 @@ +QwpSendError | QuestDB JavaScript Client - v4.2.0

    A failure while handing a QWP frame to the WebSocket transport.

    +

    Hierarchy (View Summary)

    Index

    Constructors

    Properties

    cause?: unknown
    message: string
    name: string
    stack?: string
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames +collected by a stack trace (whether generated by new Error().stack or +Error.captureStackTrace(obj)).

    +

    The default value is 10 but may be set to any valid JavaScript number. Changes +will affect any stack trace captured after the value has been changed.

    +

    If set to a non-number value, or set to a negative number, stack traces will +not capture any frames.

    +

    Methods

    • Creates a .stack property on targetObject, which when accessed returns +a string representing the location in the code at which +Error.captureStackTrace() was called.

      +
      const myObject = {};
      Error.captureStackTrace(myObject);
      myObject.stack; // Similar to `new Error().stack` +
      + +

      The first line of the trace will be prefixed with +${myObject.name}: ${myObject.message}.

      +

      The optional constructorOpt argument accepts a function. If given, all frames +above constructorOpt, including constructorOpt, will be omitted from the +generated stack trace.

      +

      The constructorOpt argument is useful for hiding implementation +details of error generation from the user. For instance:

      +
      function a() {
      b();
      }

      function b() {
      c();
      }

      function c() {
      // Create an error without stack trace to avoid calculating the stack trace twice.
      const { stackTraceLimit } = Error;
      Error.stackTraceLimit = 0;
      const error = new Error();
      Error.stackTraceLimit = stackTraceLimit;

      // Capture the stack trace above function b
      Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace
      throw error;
      }

      a(); +
      + +

      Parameters

      • targetObject: object
      • OptionalconstructorOpt: Function

      Returns void

    • Parameters

      • err: Error
      • stackTraces: CallSite[]

      Returns any

    diff --git a/docs/classes/_questdb_nodejs-client.QwpSendTimeoutError.html b/docs/classes/_questdb_nodejs-client.QwpSendTimeoutError.html new file mode 100644 index 0000000..c751880 --- /dev/null +++ b/docs/classes/_questdb_nodejs-client.QwpSendTimeoutError.html @@ -0,0 +1,36 @@ +QwpSendTimeoutError | QuestDB JavaScript Client - v4.2.0

    The WebSocket did not drain a QWP frame before its send deadline.

    +

    Hierarchy (View Summary)

    Index

    Constructors

    Properties

    bufferedAmountBytes?: number
    cause?: unknown
    message: string
    name: string
    stack?: string
    timeoutMs: number
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames +collected by a stack trace (whether generated by new Error().stack or +Error.captureStackTrace(obj)).

    +

    The default value is 10 but may be set to any valid JavaScript number. Changes +will affect any stack trace captured after the value has been changed.

    +

    If set to a non-number value, or set to a negative number, stack traces will +not capture any frames.

    +

    Methods

    • Creates a .stack property on targetObject, which when accessed returns +a string representing the location in the code at which +Error.captureStackTrace() was called.

      +
      const myObject = {};
      Error.captureStackTrace(myObject);
      myObject.stack; // Similar to `new Error().stack` +
      + +

      The first line of the trace will be prefixed with +${myObject.name}: ${myObject.message}.

      +

      The optional constructorOpt argument accepts a function. If given, all frames +above constructorOpt, including constructorOpt, will be omitted from the +generated stack trace.

      +

      The constructorOpt argument is useful for hiding implementation +details of error generation from the user. For instance:

      +
      function a() {
      b();
      }

      function b() {
      c();
      }

      function c() {
      // Create an error without stack trace to avoid calculating the stack trace twice.
      const { stackTraceLimit } = Error;
      Error.stackTraceLimit = 0;
      const error = new Error();
      Error.stackTraceLimit = stackTraceLimit;

      // Capture the stack trace above function b
      Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace
      throw error;
      }

      a(); +
      + +

      Parameters

      • targetObject: object
      • OptionalconstructorOpt: Function

      Returns void

    diff --git a/docs/classes/_questdb_nodejs-client.QwpSender.html b/docs/classes/_questdb_nodejs-client.QwpSender.html new file mode 100644 index 0000000..7b34820 --- /dev/null +++ b/docs/classes/_questdb_nodejs-client.QwpSender.html @@ -0,0 +1,78 @@ +QwpSender | QuestDB JavaScript Client - v4.2.0

    Browser-safe high-level QWP ingress API.

    +

    Applications normally obtain this class through create/connectQwpNodeSender +or create/connectQwpBrowserSender, rather than constructing sessions and +QwpTableBuffer instances themselves.

    +
    Index

    Constructors

    Accessors

    Methods

    • Commits rows previously sent by transactional auto-flush. This is an +ergonomic alias for flush(); pending local rows are included in the same +group-closing frame.

      +

      Returns Promise<boolean>

    • Adds a QuestDB DATE column value in milliseconds since the epoch. +-9_223_372_036_854_775_808n is QuestDB's DATE NULL sentinel: it is +stored as NULL and cannot be stored as an ordinary value.

      +

      Parameters

      • name: string
      • millisecondsSinceEpoch: number | bigint

      Returns QwpSender

    • Publishes completed rows to the local ingress/replay boundary. This does +not wait for a server ACK unless awaitServerAck or awaitDurableAck is set.

      +

      Returns Promise<boolean>

    • Publishes pending rows without waiting for their server ACK and returns +the highest frame sequence produced by this call, or -1n when empty. +Pass the result to waitForAcknowledged() when an explicit delivery +barrier is needed.

      +

      Returns Promise<bigint>

    • Adds a protocol LONG[] column value with between 1 and 32 dimensions.

      +

      Current QuestDB servers reject LONG-array ingestion with long arrays are not supported, only double arrays. This method remains available for +Java-client and protocol parity.

      +

      Parameters

      • name: string
      • value: unknown[]

      Returns QwpSender

    • Adds a QuestDB LONG column value. -9_223_372_036_854_775_808n is +QuestDB's LONG NULL sentinel: it is stored as NULL and cannot be stored as +an ordinary value.

      +

      Parameters

      • name: string
      • value: number | bigint

      Returns QwpSender

    • Internal

      Flushes completed rows and resets borrower-local staging without closing +the physical session. Used by the pooled QWP client when a lease returns.

      +

      Returns Promise<void>

    • Independently waits until the cumulative ACK watermark covers a frame.

      +

      Parameters

      • targetSequence: bigint
      • OptionaltimeoutMs: number

      Returns Promise<void>

    diff --git a/docs/classes/_questdb_nodejs-client.QwpSenderCloseTimeoutError.html b/docs/classes/_questdb_nodejs-client.QwpSenderCloseTimeoutError.html new file mode 100644 index 0000000..3ce17eb --- /dev/null +++ b/docs/classes/_questdb_nodejs-client.QwpSenderCloseTimeoutError.html @@ -0,0 +1,37 @@ +QwpSenderCloseTimeoutError | QuestDB JavaScript Client - v4.2.0

    Class QwpSenderCloseTimeoutError

    close() could not publish and acknowledge all committed ingress frames.

    +

    Hierarchy

    • Error
      • QwpSenderCloseTimeoutError
    Index

    Constructors

    Properties

    acknowledgedSequence: bigint
    cause?: unknown
    message: string
    name: string
    stack?: string
    targetSequence: bigint
    timeoutMs: number
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames +collected by a stack trace (whether generated by new Error().stack or +Error.captureStackTrace(obj)).

    +

    The default value is 10 but may be set to any valid JavaScript number. Changes +will affect any stack trace captured after the value has been changed.

    +

    If set to a non-number value, or set to a negative number, stack traces will +not capture any frames.

    +

    Methods

    • Creates a .stack property on targetObject, which when accessed returns +a string representing the location in the code at which +Error.captureStackTrace() was called.

      +
      const myObject = {};
      Error.captureStackTrace(myObject);
      myObject.stack; // Similar to `new Error().stack` +
      + +

      The first line of the trace will be prefixed with +${myObject.name}: ${myObject.message}.

      +

      The optional constructorOpt argument accepts a function. If given, all frames +above constructorOpt, including constructorOpt, will be omitted from the +generated stack trace.

      +

      The constructorOpt argument is useful for hiding implementation +details of error generation from the user. For instance:

      +
      function a() {
      b();
      }

      function b() {
      c();
      }

      function c() {
      // Create an error without stack trace to avoid calculating the stack trace twice.
      const { stackTraceLimit } = Error;
      Error.stackTraceLimit = 0;
      const error = new Error();
      Error.stackTraceLimit = stackTraceLimit;

      // Capture the stack trace above function b
      Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace
      throw error;
      }

      a(); +
      + +

      Parameters

      • targetObject: object
      • OptionalconstructorOpt: Function

      Returns void

    • Parameters

      • err: Error
      • stackTraces: CallSite[]

      Returns any

    diff --git a/docs/classes/_questdb_nodejs-client.QwpSymbolDictionary.html b/docs/classes/_questdb_nodejs-client.QwpSymbolDictionary.html new file mode 100644 index 0000000..9d92eda --- /dev/null +++ b/docs/classes/_questdb_nodejs-client.QwpSymbolDictionary.html @@ -0,0 +1,12 @@ +QwpSymbolDictionary | QuestDB JavaScript Client - v4.2.0

    Connection-scoped QWP symbol dictionary. IDs are dense from zero.

    +
    Index

    Constructors

    Accessors

    Methods

    Constructors

    Accessors

    Methods

    diff --git a/docs/classes/_questdb_nodejs-client.QwpTableBuffer.html b/docs/classes/_questdb_nodejs-client.QwpTableBuffer.html new file mode 100644 index 0000000..e33ab40 --- /dev/null +++ b/docs/classes/_questdb_nodejs-client.QwpTableBuffer.html @@ -0,0 +1,20 @@ +QwpTableBuffer | QuestDB JavaScript Client - v4.2.0

    Mutable columnar staging area for one QWP ingress table.

    +
    Index

    Constructors

    Properties

    name: string

    Accessors

    Methods

    diff --git a/docs/classes/_questdb_nodejs-client.QwpTableWriter.html b/docs/classes/_questdb_nodejs-client.QwpTableWriter.html new file mode 100644 index 0000000..fe19c85 --- /dev/null +++ b/docs/classes/_questdb_nodejs-client.QwpTableWriter.html @@ -0,0 +1,9 @@ +QwpTableWriter | QuestDB JavaScript Client - v4.2.0

    Class QwpTableWriter<Schema>

    A reusable table-bound writer compiled from a QWP schema.

    +

    Type Parameters

    Index

    Constructors

    Properties

    Methods

    Constructors

    • Internal

      Construct table writers with QwpSender.writer().

      +

      Type Parameters

      Parameters

      • token: typeof QWP_TABLE_WRITER_CONSTRUCTOR
      • tableName: string
      • appendRow: (row: unknown, rowIndex?: number) => Promise<void>

      Returns QwpTableWriter<Schema>

    Properties

    tableName: string

    Methods

    diff --git a/docs/classes/_questdb_nodejs-client.QwpUdpDatagramTooLargeError.html b/docs/classes/_questdb_nodejs-client.QwpUdpDatagramTooLargeError.html new file mode 100644 index 0000000..2dc0e53 --- /dev/null +++ b/docs/classes/_questdb_nodejs-client.QwpUdpDatagramTooLargeError.html @@ -0,0 +1,38 @@ +QwpUdpDatagramTooLargeError | QuestDB JavaScript Client - v4.2.0

    Class QwpUdpDatagramTooLargeError

    A single encoded row cannot fit into the configured UDP datagram.

    +

    Hierarchy

    • Error
      • QwpUdpDatagramTooLargeError
    Index

    Constructors

    Properties

    cause?: unknown
    datagramSize: number
    maxDatagramSize: number
    message: string
    name: string
    row: number
    stack?: string
    tableName: string
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames +collected by a stack trace (whether generated by new Error().stack or +Error.captureStackTrace(obj)).

    +

    The default value is 10 but may be set to any valid JavaScript number. Changes +will affect any stack trace captured after the value has been changed.

    +

    If set to a non-number value, or set to a negative number, stack traces will +not capture any frames.

    +

    Methods

    • Creates a .stack property on targetObject, which when accessed returns +a string representing the location in the code at which +Error.captureStackTrace() was called.

      +
      const myObject = {};
      Error.captureStackTrace(myObject);
      myObject.stack; // Similar to `new Error().stack` +
      + +

      The first line of the trace will be prefixed with +${myObject.name}: ${myObject.message}.

      +

      The optional constructorOpt argument accepts a function. If given, all frames +above constructorOpt, including constructorOpt, will be omitted from the +generated stack trace.

      +

      The constructorOpt argument is useful for hiding implementation +details of error generation from the user. For instance:

      +
      function a() {
      b();
      }

      function b() {
      c();
      }

      function c() {
      // Create an error without stack trace to avoid calculating the stack trace twice.
      const { stackTraceLimit } = Error;
      Error.stackTraceLimit = 0;
      const error = new Error();
      Error.stackTraceLimit = stackTraceLimit;

      // Capture the stack trace above function b
      Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace
      throw error;
      }

      a(); +
      + +

      Parameters

      • targetObject: object
      • OptionalconstructorOpt: Function

      Returns void

    • Parameters

      • err: Error
      • stackTraces: CallSite[]

      Returns any

    diff --git a/docs/classes/_questdb_nodejs-client.QwpUnrecoverableReplayDictionaryError.html b/docs/classes/_questdb_nodejs-client.QwpUnrecoverableReplayDictionaryError.html new file mode 100644 index 0000000..fd27d1a --- /dev/null +++ b/docs/classes/_questdb_nodejs-client.QwpUnrecoverableReplayDictionaryError.html @@ -0,0 +1,35 @@ +QwpUnrecoverableReplayDictionaryError | QuestDB JavaScript Client - v4.2.0

    Class QwpUnrecoverableReplayDictionaryError

    Recovered delta frames depend on symbol IDs that neither the durable +dictionary prefix nor the surviving frames can reconstruct.

    +

    Hierarchy (View Summary)

    Index

    Constructors

    Properties

    cause?: unknown
    message: string
    name: string
    stack?: string
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames +collected by a stack trace (whether generated by new Error().stack or +Error.captureStackTrace(obj)).

    +

    The default value is 10 but may be set to any valid JavaScript number. Changes +will affect any stack trace captured after the value has been changed.

    +

    If set to a non-number value, or set to a negative number, stack traces will +not capture any frames.

    +

    Methods

    • Creates a .stack property on targetObject, which when accessed returns +a string representing the location in the code at which +Error.captureStackTrace() was called.

      +
      const myObject = {};
      Error.captureStackTrace(myObject);
      myObject.stack; // Similar to `new Error().stack` +
      + +

      The first line of the trace will be prefixed with +${myObject.name}: ${myObject.message}.

      +

      The optional constructorOpt argument accepts a function. If given, all frames +above constructorOpt, including constructorOpt, will be omitted from the +generated stack trace.

      +

      The constructorOpt argument is useful for hiding implementation +details of error generation from the user. For instance:

      +
      function a() {
      b();
      }

      function b() {
      c();
      }

      function c() {
      // Create an error without stack trace to avoid calculating the stack trace twice.
      const { stackTraceLimit } = Error;
      Error.stackTraceLimit = 0;
      const error = new Error();
      Error.stackTraceLimit = stackTraceLimit;

      // Capture the stack trace above function b
      Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace
      throw error;
      }

      a(); +
      + +

      Parameters

      • targetObject: object
      • OptionalconstructorOpt: Function

      Returns void

    diff --git a/docs/classes/_questdb_nodejs-client.QwpUpgradeError.html b/docs/classes/_questdb_nodejs-client.QwpUpgradeError.html new file mode 100644 index 0000000..89f20ea --- /dev/null +++ b/docs/classes/_questdb_nodejs-client.QwpUpgradeError.html @@ -0,0 +1,49 @@ +QwpUpgradeError | QuestDB JavaScript Client - v4.2.0

    A failure while establishing or validating a QWP WebSocket upgrade.

    +

    Hierarchy (View Summary)

    Index

    Constructors

    Properties

    cause?: unknown
    closeCode?: number
    message: string
    name: string
    retryable?: boolean
    serverRole?: string
    serverZone?: string
    stack?: string
    statusCode?: number
    statusMessage?: string
    timeoutPhase?: QwpUpgradeTimeoutPhase

    Node opening phase that exceeded its deadline.

    +
    tryNextEndpoint?: boolean
    url?: string | URL
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames +collected by a stack trace (whether generated by new Error().stack or +Error.captureStackTrace(obj)).

    +

    The default value is 10 but may be set to any valid JavaScript number. Changes +will affect any stack trace captured after the value has been changed.

    +

    If set to a non-number value, or set to a negative number, stack traces will +not capture any frames.

    +

    Accessors

    Methods

    • Creates a .stack property on targetObject, which when accessed returns +a string representing the location in the code at which +Error.captureStackTrace() was called.

      +
      const myObject = {};
      Error.captureStackTrace(myObject);
      myObject.stack; // Similar to `new Error().stack` +
      + +

      The first line of the trace will be prefixed with +${myObject.name}: ${myObject.message}.

      +

      The optional constructorOpt argument accepts a function. If given, all frames +above constructorOpt, including constructorOpt, will be omitted from the +generated stack trace.

      +

      The constructorOpt argument is useful for hiding implementation +details of error generation from the user. For instance:

      +
      function a() {
      b();
      }

      function b() {
      c();
      }

      function c() {
      // Create an error without stack trace to avoid calculating the stack trace twice.
      const { stackTraceLimit } = Error;
      Error.stackTraceLimit = 0;
      const error = new Error();
      Error.stackTraceLimit = stackTraceLimit;

      // Capture the stack trace above function b
      Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace
      throw error;
      }

      a(); +
      + +

      Parameters

      • targetObject: object
      • OptionalconstructorOpt: Function

      Returns void

    • Parameters

      • err: Error
      • stackTraces: CallSite[]

      Returns any

    diff --git a/docs/classes/_questdb_nodejs-client.QwpVersionMismatchError.html b/docs/classes/_questdb_nodejs-client.QwpVersionMismatchError.html new file mode 100644 index 0000000..8ba5393 --- /dev/null +++ b/docs/classes/_questdb_nodejs-client.QwpVersionMismatchError.html @@ -0,0 +1,51 @@ +QwpVersionMismatchError | QuestDB JavaScript Client - v4.2.0

    A failure while establishing or validating a QWP WebSocket upgrade.

    +

    Hierarchy (View Summary)

    Index

    Constructors

    Properties

    cause?: unknown
    clientMaxVersion: number
    closeCode?: number
    message: string
    name: string
    retryable?: boolean
    serverRole?: string
    serverVersion: number
    serverZone?: string
    stack?: string
    statusCode?: number
    statusMessage?: string
    timeoutPhase?: QwpUpgradeTimeoutPhase

    Node opening phase that exceeded its deadline.

    +
    tryNextEndpoint?: boolean
    url?: string | URL
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames +collected by a stack trace (whether generated by new Error().stack or +Error.captureStackTrace(obj)).

    +

    The default value is 10 but may be set to any valid JavaScript number. Changes +will affect any stack trace captured after the value has been changed.

    +

    If set to a non-number value, or set to a negative number, stack traces will +not capture any frames.

    +

    Accessors

    Methods

    • Creates a .stack property on targetObject, which when accessed returns +a string representing the location in the code at which +Error.captureStackTrace() was called.

      +
      const myObject = {};
      Error.captureStackTrace(myObject);
      myObject.stack; // Similar to `new Error().stack` +
      + +

      The first line of the trace will be prefixed with +${myObject.name}: ${myObject.message}.

      +

      The optional constructorOpt argument accepts a function. If given, all frames +above constructorOpt, including constructorOpt, will be omitted from the +generated stack trace.

      +

      The constructorOpt argument is useful for hiding implementation +details of error generation from the user. For instance:

      +
      function a() {
      b();
      }

      function b() {
      c();
      }

      function c() {
      // Create an error without stack trace to avoid calculating the stack trace twice.
      const { stackTraceLimit } = Error;
      Error.stackTraceLimit = 0;
      const error = new Error();
      Error.stackTraceLimit = stackTraceLimit;

      // Capture the stack trace above function b
      Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace
      throw error;
      }

      a(); +
      + +

      Parameters

      • targetObject: object
      • OptionalconstructorOpt: Function

      Returns void

    diff --git a/docs/classes/_questdb_nodejs-client.QwpWriterRowError.html b/docs/classes/_questdb_nodejs-client.QwpWriterRowError.html new file mode 100644 index 0000000..8be6a31 --- /dev/null +++ b/docs/classes/_questdb_nodejs-client.QwpWriterRowError.html @@ -0,0 +1,37 @@ +QwpWriterRowError | QuestDB JavaScript Client - v4.2.0

    A complete object row failed compiled-writer validation.

    +

    Hierarchy

    • Error
      • QwpWriterRowError
    Index

    Constructors

    Properties

    cause: unknown
    columnName: string
    message: string
    name: string
    rowIndex: number
    stack?: string
    tableName: string
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames +collected by a stack trace (whether generated by new Error().stack or +Error.captureStackTrace(obj)).

    +

    The default value is 10 but may be set to any valid JavaScript number. Changes +will affect any stack trace captured after the value has been changed.

    +

    If set to a non-number value, or set to a negative number, stack traces will +not capture any frames.

    +

    Methods

    • Creates a .stack property on targetObject, which when accessed returns +a string representing the location in the code at which +Error.captureStackTrace() was called.

      +
      const myObject = {};
      Error.captureStackTrace(myObject);
      myObject.stack; // Similar to `new Error().stack` +
      + +

      The first line of the trace will be prefixed with +${myObject.name}: ${myObject.message}.

      +

      The optional constructorOpt argument accepts a function. If given, all frames +above constructorOpt, including constructorOpt, will be omitted from the +generated stack trace.

      +

      The constructorOpt argument is useful for hiding implementation +details of error generation from the user. For instance:

      +
      function a() {
      b();
      }

      function b() {
      c();
      }

      function c() {
      // Create an error without stack trace to avoid calculating the stack trace twice.
      const { stackTraceLimit } = Error;
      Error.stackTraceLimit = 0;
      const error = new Error();
      Error.stackTraceLimit = stackTraceLimit;

      // Capture the stack trace above function b
      Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace
      throw error;
      }

      a(); +
      + +

      Parameters

      • targetObject: object
      • OptionalconstructorOpt: Function

      Returns void

    • Parameters

      • err: Error
      • stackTraces: CallSite[]

      Returns any

    diff --git a/docs/classes/_questdb_nodejs-client.Sender.html b/docs/classes/_questdb_nodejs-client.Sender.html new file mode 100644 index 0000000..a45c6e4 --- /dev/null +++ b/docs/classes/_questdb_nodejs-client.Sender.html @@ -0,0 +1,276 @@ +Sender | QuestDB JavaScript Client - v4.2.0

    The QuestDB client's API provides methods to connect to the database, ingest data, and close the connection.
    +The client supports multiple transport protocols.

    +

    +Transport Options: +

      +
    • HTTP: Uses standard HTTP requests for data ingestion. Provides immediate feedback via HTTP response codes. +Recommended for most use cases due to superior error handling and debugging capabilities. Uses Undici library by default for high performance.
    • +
    • HTTPS: Secure HTTP transport with TLS encryption. Same benefits as HTTP but with encrypted communication. +Supports certificate validation and custom CA certificates.
    • +
    • TCP: Direct TCP connection, provides persistent connections. Uses JWK token-based authentication.
    • +
    • TCPS: Secure TCP transport with TLS encryption.
    • +
    • WS/WSS: QWP ingress over WebSocket, including browser-compatible wire encoding and QWP ACKs.
    • +
    • UDP: Node-only fire-and-forget QWP ingress in self-contained datagrams.
    • +
    +

    +

    +The client supports authentication.
    +Authentication details can be passed to the Sender in its configuration options.
    +The client supports Basic username/password and Bearer token authentication methods when used with HTTP protocol, +and JWK token authentication when ingesting data via TCP.
    +Please, note that authentication is enabled by default in QuestDB Enterprise only.
    +Details on how to configure authentication in the open source version of +QuestDB: https://questdb.io/docs/reference/api/ilp/authenticate +

    +

    +The client also supports TLS encryption for both, HTTP and TCP transports to provide a secure connection.
    +Please, note that the open source version of QuestDB does not support TLS, and requires an external reverse-proxy, +such as Nginx to enable encryption. +

    +

    +The client supports multiple protocol versions for data serialization. Protocol version 1 uses text-based +serialization, while version 2 uses binary encoding for doubles and supports array columns for improved +performance. The client can automatically negotiate the protocol version with the server when using HTTP/HTTPS +by setting the protocol_version to 'auto' (default behavior). +

    +

    +The client uses a buffer to store data. It automatically flushes the buffer by sending its content to the server. +Auto flushing can be disabled via configuration options to gain control over transactions. Initial and maximum +buffer sizes can also be set. +

    +

    +It is recommended that the Sender is created by using one of the static factory methods, +Sender.fromConfig(configString, extraOptions) or Sender.fromEnv(extraOptions). +If the Sender is created via its constructor, at least the SenderOptions configuration object should be +initialized from a configuration string to make sure that the parameters are validated.
    +Detailed description of the Sender's configuration options can be found in +the SenderOptions documentation. +

    +

    +Transport Configuration Examples: +

      +
    • HTTP: Sender.fromConfig("http::addr=localhost:9000")
    • +
    • HTTPS with authentication: Sender.fromConfig("https::addr=localhost:9000;username=admin;password=secret")
    • +
    • TCP: Sender.fromConfig("tcp::addr=localhost:9009")
    • +
    • TCPS with authentication: Sender.fromConfig("tcps::addr=localhost:9009;username=user;token=private_key")
    • +
    • QWP: Sender.fromConfig("ws::addr=localhost:9000")
    • +
    • QWP UDP: Sender.fromConfig("udp::addr=localhost:9007;max_datagram_size=1400")
    • +
    +

    +

    +HTTP Transport Implementation:
    +By default, HTTP/HTTPS transport uses the high-performance Undici library for connection management and request handling. +For compatibility or specific requirements, you can enable the standard HTTP transport using Node.js built-in modules +by setting stdlib_http=on in the configuration string. The standard HTTP transport provides the same functionality +but uses Node.js http/https modules instead of Undici. +

    +

    +Extra options can be provided to the Sender in the extraOptions configuration object.
    +A custom logging function and a custom HTTP(S) agent can be passed to the Sender in this object.
    +The logger implementation provides the option to direct log messages to the same place where the host application's +log is saved. The default logger writes to the console.
    +The custom HTTP(S) agent option becomes handy if there is a need to modify the default options set for the +HTTP(S) connections. A popular setting would be disabling persistent connections, in this case an agent can be +passed to the Sender with keepAlive set to false.
    +For example: Sender.fromConfig(`http::addr=host:port`, { agent: new undici.Agent({ connect: { keepAlive: false } })})
    +An undici.Agent applies only to the default HTTP(S) transport. QWP WS/WSS uses the ws package and requires +a Node.js http.Agent/https.Agent; an incompatible top-level agent is ignored with a warning.
    +If no custom agent is configured, the Sender will use its own agent which overrides some default values +of undici.Agent. The Sender's own agent uses persistent connections with 1 minute idle timeout, pipelines requests default to 1. +

    Index

    Constructors

    Accessors

    Methods

    • Writes an array column with its values into the buffer of the sender.

      +

      Parameters

      • name: string

        Column name

        +
      • value: unknown[]

        Array values to write (currently supports double arrays). A null or undefined value omits the column entirely when arrays are supported; protocol v1 rejects the call for every value.

        +

      Returns Sender

      Returns with a reference to this sender.

      +

      Error if arrays are not supported by the buffer implementation, or array validation fails:

      +
        +
      • value is not an array
      • +
      • or the shape of the array is irregular: the length of sub-arrays are different
      • +
      • or the array is not homogeneous: its elements are not all the same type
      • +
      +
    • Closes the row after writing the designated timestamp. +On ILP, an invalid timestamp unit is rejected before closing begins and +leaves the row open so this method can be retried. If other validation or +encoding rejects the row before it is completed, the incomplete row and its +table selection are discarded; rows completed earlier remain staged. Start +the next row with table again. If this call triggers an auto-flush +that fails, ILP transports have already removed the entire staged batch from +the sender buffer. Applications that need to retry ILP rows must retain and +resubmit them. QWP retains successfully closed rows for its retry and replay +path.

      +

      Precision rules:

      +
        +
      • Protocol v2 and higher: +Timestamps passed with unit 'ns' (nanoseconds) are sent with full nanosecond precision. +All other timestamps are sent with microsecond precision.
      • +
      • Protocol v1: +Always uses microsecond precision, even if the timestamp is specified in nanoseconds.
      • +
      +

      Parameters

      • timestamp: number | bigint

        Designated epoch timestamp. Must be an integer or a BigInt.

        +
      • Optionalunit: TimestampUnit = "us"

        The time unit of the timestamp. +Supported values:

        +
          +
        • 'ns' — nanoseconds (requires BigInt)
        • +
        • 'us' — microseconds (default)
        • +
        • 'ms' — milliseconds
        • +
        +

      Returns Promise<void>

      Resolves after the row is closed and any triggered auto-flush completes.

      +

      If timestamp is not an integer or BigInt.

      +

      If unit is 'ns' but timestamp is not a BigInt.

      +

      If unit is not one of 'ns', 'us', or 'ms'.

      +
    • Closes the row without writing a designated timestamp. +Designated timestamp will be populated by the server on this record. +If validation or encoding rejects the row before it is completed, the +incomplete row and its table selection are discarded; rows completed +earlier remain staged. Start the next row with table again. If this +call triggers an auto-flush that fails, ILP transports have already removed +the entire staged batch from the sender buffer. Applications that need to +retry ILP rows must retain and resubmit them. QWP retains successfully +closed rows for its retry and replay path.

      +

      Returns Promise<void>

      Resolves after the row is closed and any triggered auto-flush completes.

      +
    • Writes a boolean column with its value into the buffer of the sender.
      +Use it to insert into BOOLEAN columns.

      +

      Parameters

      • name: string

        Column name.

        +
      • value: boolean

        Column value, accepts only boolean values. A null or undefined value omits the column entirely (stored as NULL).

        +

      Returns Sender

      Returns with a reference to this sender.

      +
    • Closes the connection to the database. QWP publishes completed rows and +performs a bounded acknowledgement drain first. Other transports retain +their legacy behavior and require an explicit flush().

      +

      Returns Promise<void>

    • Establishes the transport connection for TCP, TCPS, WS, WSS, and UDP. +HTTP and HTTPS connect per request and reject this call because no explicit +connection step is required.

      +

      Returns Promise<boolean>

      Resolves to true if the client is connected.

      +
    • Writes a decimal value into the buffer using the binary format.

      +

      Use it to insert into DECIMAL database columns.

      +

      Parameters

      • name: string

        Column name.

        +
      • unscaled: bigint | Int8Array<ArrayBufferLike>

        The unscaled value of the decimal in two's +complement representation and big-endian byte order. +A null or undefined value omits the column entirely when decimals are +supported; ILP protocol v1/v2 reject the call for every value. +An empty array also represents NULL, but the two are not encoded alike: +on the ILP transports an empty array writes an explicit NULL decimal +field, while the QWP transports omit the column exactly as they do for +null. QuestDB records NULL either way for a column that already exists.

        +
      • scale: number

        The scale of the decimal value.

        +

      Returns Sender

      Returns with a reference to this buffer.

      +

      Error if decimals are not supported by the buffer implementation, or decimal validation fails:

      +
        +
      • unscaled value length is not between 0 and 32 bytes
      • +
      • scale is not between 0 and 76
      • +
      • unscaled value contains invalid bytes
      • +
      +
    • Writes a decimal value into the buffer using the text format.

      +

      Use it to insert into DECIMAL database columns.

      +

      Parameters

      • name: string

        Column name.

        +
      • value: string | number

        Column value, accepts only number/string values. A null or undefined value omits the column entirely when decimals are supported; ILP protocol v1/v2 reject the call for every value.

        +

      Returns Sender

      Returns with a reference to this buffer.

      +

      Error if decimals are not supported by the buffer implementation, or decimal validation fails:

      +
        +
      • string value is not a valid decimal representation
      • +
      +
    • Writes a 64-bit floating point value into the buffer of the sender.
      +Use it to insert into DOUBLE or FLOAT database columns.

      +

      Parameters

      • name: string

        Column name.

        +
      • value: number

        Column value, accepts only number values. A null or undefined value omits the column entirely (stored as NULL).

        +

      Returns Sender

      Returns with a reference to this sender.

      +
    • Sends the content of the sender's buffer to the database and compacts the buffer. +If the last row is not finished it stays in the sender's buffer.

      +

      Returns Promise<boolean>

      Resolves to true when there was data in the buffer to send, and it was sent successfully.

      +
    • Flushes pending rows and returns the highest QWP frame sequence published +by this call. Non-QWP transports flush normally and return -1n because +they do not expose frame sequences.

      +

      Returns Promise<bigint>

    • Writes a 64-bit signed integer into the buffer of the sender.
      +Use it to insert into LONG, INT, SHORT and BYTE columns.

      +

      Parameters

      • name: string

        Column name.

        +
      • value: number

        Column value, accepts only number values. A null or undefined value omits the column entirely (stored as NULL).

        +

      Returns Sender

      Returns with a reference to this sender.

      +

      Error if the value is not an integer

      +
    • Resets the sender's buffer, data sitting in the buffer will be lost.
      +In other words it clears the buffer, and sets the writing position to the beginning of the buffer.

      +

      Returns Sender

      Returns with a reference to this sender.

      +
    • Writes a string column with its value into the buffer of the sender.
      +Use it to insert into VARCHAR and STRING columns.

      +

      Parameters

      • name: string

        Column name.

        +
      • value: string

        Column value, accepts only string values. A null or undefined value omits the column entirely (stored as NULL).

        +

      Returns Sender

      Returns with a reference to this sender.

      +
    • Writes a symbol name and value into the buffer of the sender.
      +Use it to insert into SYMBOL columns.

      +

      Parameters

      • name: string

        Symbol name.

        +
      • value: unknown

        Symbol value, toString() is called to extract the actual symbol value from the parameter. A null or undefined value omits the symbol entirely (stored as NULL).

        +

      Returns Sender

      Returns with a reference to this sender.

      +
    • Writes a timestamp column and its value into the buffer of the sender.

      +

      Use this method to insert data into TIMESTAMP or TIMESTAMP_NS columns.

      +

      Precision rules:

      +
        +
      • Protocol v2 and higher: +Timestamps passed with unit 'ns' (nanoseconds) are sent with full nanosecond precision. +All other timestamps are sent with microsecond precision.
      • +
      • Protocol v1: +Always uses microsecond precision, even if the timestamp is specified in nanoseconds.
      • +
      +

      Parameters

      • name: string

        The column name.

        +
      • value: number | bigint

        The epoch timestamp. Must be an integer or a BigInt. A null or undefined value omits the column entirely (stored as NULL).

        +
      • Optionalunit: TimestampUnit = "us"

        The time unit of the timestamp. +Supported values:

        +
          +
        • 'ns' — nanoseconds (requires BigInt)
        • +
        • 'us' — microseconds (default)
        • +
        • 'ms' — milliseconds
        • +
        +

      Returns Sender

      Returns with a reference to this buffer.

      +

      If value is not an integer or BigInt.

      +

      If unit is 'ns' but value is not a BigInt.

      +
    • Waits independently for a cumulative QWP ACK watermark.

      +

      Parameters

      • targetSequence: bigint
      • OptionaltimeoutMs: number

      Returns Promise<void>

    • Creates a Sender object by parsing the provided configuration string.

      +

      Parameters

      • configurationString: string

        Configuration string.

        +
      • OptionalextraOptions: ExtraOptions

        Optional extra configuration.

        +
          +
        • 'log' is a logging function used by the Sender. +Prototype: (level: 'error'|'warn'|'info'|'debug', message: string) => void.
        • +
        • 'agent' is a custom http/https agent used by the Sender when http/https transport is used. +Depends on which transport implementation and protocol used, one of the followings expected: undici.Agent, http.Agent or https.Agent.
        • +
        +

      Returns Promise<Sender>

      A Sender object initialized from the provided configuration string.

      +
    • Creates a Sender object by parsing the configuration string set in the QDB_CLIENT_CONF environment variable.

      +

      Parameters

      • OptionalextraOptions: ExtraOptions

        Optional extra configuration.

        +
          +
        • 'log' is a logging function used by the Sender. +Prototype: (level: 'error'|'warn'|'info'|'debug', message: string) => void.
        • +
        • 'agent' is a custom http/https agent used by the Sender when http/https transport is used. +Depends on which transport implementation and protocol used, one of the followings expected: undici.Agent, http.Agent or https.Agent.
        • +
        +

      Returns Promise<Sender>

      A Sender object initialized from the QDB_CLIENT_CONF environment variable.

      +
    diff --git a/docs/classes/_questdb_nodejs-client.SenderBufferV1.html b/docs/classes/_questdb_nodejs-client.SenderBufferV1.html new file mode 100644 index 0000000..0e945e3 --- /dev/null +++ b/docs/classes/_questdb_nodejs-client.SenderBufferV1.html @@ -0,0 +1,148 @@ +SenderBufferV1 | QuestDB JavaScript Client - v4.2.0

    Buffer implementation for protocol version 1.
    +Sends floating point numbers in their text form.

    +

    Hierarchy

    • SenderBufferBase
      • SenderBufferV1
    Index

    Constructors

    Properties

    buffer: Buffer
    log: Logger
    position: number

    Methods

    • Array columns are not supported in protocol v1.
      +The capability check applies even when the value is null or undefined.

      +

      Parameters

      • name: string

        Column name.

        +
      • value: unknown[]

        Array values.

        +

      Returns SenderBuffer

      Returns with a reference to this buffer.

      +

      Error indicating arrays are not supported in v1

      +
    • Closes the row after writing the designated timestamp into the buffer.

      +

      Precision rules:

      +
        +
      • Protocol v2 and higher: +Timestamps passed with unit 'ns' (nanoseconds) are sent with full nanosecond precision. +All other timestamps are sent with microsecond precision.
      • +
      • Protocol v1: +Always uses microsecond precision, even if the timestamp is specified in nanoseconds.
      • +
      +

      Parameters

      • timestamp: number | bigint

        Designated epoch timestamp. Must be an integer or a BigInt.

        +
      • Optionalunit: TimestampUnit = "us"

        The time unit of the timestamp. +Supported values:

        +
          +
        • 'ns' — nanoseconds (requires BigInt)
        • +
        • 'us' — microseconds (default)
        • +
        • 'ms' — milliseconds
        • +
        +

      Returns void

      Returns with a reference to this buffer.

      +

      If timestamp is not an integer or BigInt.

      +

      If unit is 'ns' but timestamp is not a BigInt.

      +

      If unit is not one of 'ns', 'us', or 'ms'. This +validation leaves the open row unchanged so the call can be retried.

      +
    • Closes the row without writing designated timestamp into the buffer.
      +Designated timestamp will be populated by the server on this record.

      +

      Returns void

    • Writes a boolean column with its value into the buffer.
      +Use it to insert into BOOLEAN columns.

      +

      Parameters

      • name: string

        Column name.

        +
      • value: boolean

        Column value, accepts only boolean values. A null or undefined value omits the column entirely (stored as NULL).

        +

      Returns SenderBuffer

      Returns with a reference to this buffer.

      +
    • Checks if the buffer has sufficient capacity for additional data and resizes if needed.

      +

      Parameters

      • data: string[]

        Array of strings to calculate the required capacity for

        +
      • base: number = 0

        Base number of bytes to add to the calculation

        +

      Returns void

    • Returns the current position of the buffer.
      +New data will be written into the buffer starting from this position.

      +

      Returns number

    • Writes a decimal value into the buffer using its binary format.

      +

      Use it to insert into DECIMAL database columns.

      +

      Decimals are not supported by protocol v1/v2, so this base implementation +rejects the call even when the value is null or undefined. Protocol v3 +overrides this with a validating implementation.

      +

      Parameters

      • name: string

        Column name.

        +
      • unscaled: bigint | Int8Array<ArrayBufferLike>

        The unscaled +integer portion of the decimal value.

        +
      • scale: number

        The number of fractional digits (the scale) of the decimal value.

        +

      Returns SenderBuffer

      Returns with a reference to this buffer.

      +

      If scale is not between 0 and 76. Scale validation +runs even when unscaled is null or undefined.

      +

      Indicating decimals are not supported in protocol v1/v2.

      +
    • Writes a decimal value into the buffer using its text format.

      +

      Use it to insert into DECIMAL database columns.

      +

      Decimals are not supported by protocol v1/v2, so this base implementation +rejects the call even when the value is null or undefined. Protocol v3 +overrides this with a validating implementation.

      +

      Parameters

      • name: string

        Column name.

        +
      • value: string | number

        The decimal value to +write.

        +

      Returns SenderBuffer

      Returns with a reference to this buffer.

      +

      Indicating decimals are not supported in protocol v1/v2.

      +
    • Writes a 64-bit floating point value into the buffer using v1 serialization (text format).
      +Use it to insert into DOUBLE or FLOAT database columns.

      +

      Parameters

      • name: string

        Column name.

        +
      • value: number

        Column value, accepts only number values. A null or undefined value omits the column entirely (stored as NULL).

        +

      Returns SenderBuffer

      Returns with a reference to this sender.

      +
    • Writes a 64-bit signed integer into the buffer.
      +Use it to insert into LONG, INT, SHORT and BYTE columns.

      +

      Parameters

      • name: string

        Column name.

        +
      • value: number

        Column value, accepts only number values. A null or undefined value omits the column entirely (stored as NULL).

        +

      Returns SenderBuffer

      Returns with a reference to this buffer.

      +

      Error if the value is not an integer

      +
    • Writes a string column with its value into the buffer.
      +Use it to insert into VARCHAR and STRING columns.

      +

      Parameters

      • name: string

        Column name.

        +
      • value: string

        Column value, accepts only string values. A null or undefined value omits the column entirely (stored as NULL).

        +

      Returns SenderBuffer

      Returns with a reference to this buffer.

      +
    • Writes a symbol name and value into the buffer.
      +Use it to insert into SYMBOL columns.

      +

      Parameters

      • name: string

        Symbol name.

        +
      • value: unknown

        Symbol value, toString() is called to extract the actual symbol value from the parameter. A null or undefined value omits the symbol entirely (stored as NULL).

        +

      Returns SenderBuffer

      Returns with a reference to this buffer.

      +
    • Writes a timestamp column and its value into the buffer.

      +

      Use this method to insert data into TIMESTAMP or TIMESTAMP_NS columns.

      +

      Precision rules:

      +
        +
      • Protocol v2 and higher: +Timestamps passed with unit 'ns' (nanoseconds) are sent with full nanosecond precision. +All other timestamps are sent with microsecond precision.
      • +
      • Protocol v1: +Always uses microsecond precision, even if the timestamp is specified in nanoseconds.
      • +
      +

      Parameters

      • name: string

        The column name.

        +
      • value: number | bigint

        The epoch timestamp. Must be an integer or a BigInt. A null or undefined value omits the column entirely (stored as NULL).

        +
      • Optionalunit: TimestampUnit = "us"

        The time unit of the timestamp. +Supported values:

        +
          +
        • 'ns' — nanoseconds (requires BigInt)
        • +
        • 'us' — microseconds (default)
        • +
        • 'ms' — milliseconds
        • +
        +

      Returns SenderBuffer

      Returns with a reference to this buffer.

      +

      If unit is not one of 'ns', 'us', or 'ms' (checked +even when value is null or undefined).

      +

      If value is not an integer or BigInt.

      +

      If unit is 'ns' but value is not a BigInt.

      +
    • Parameters

      • pos: number = ...

      Returns Buffer

      Returns a cropped buffer ready to send to the server, or null if there is nothing to send.
      +The returned buffer is a copy of this buffer. +It also compacts the buffer.

      +
    • Parameters

      • pos: number = ...

      Returns Buffer

      Returns a cropped buffer, or null if there is nothing to send.
      +The returned buffer is backed by this buffer instance, meaning the view can change as the buffer is mutated. +Used only in tests to assert the buffer's content.

      +
    diff --git a/docs/classes/_questdb_nodejs-client.SenderBufferV2.html b/docs/classes/_questdb_nodejs-client.SenderBufferV2.html new file mode 100644 index 0000000..7b74a3e --- /dev/null +++ b/docs/classes/_questdb_nodejs-client.SenderBufferV2.html @@ -0,0 +1,152 @@ +SenderBufferV2 | QuestDB JavaScript Client - v4.2.0

    Buffer implementation for protocol version 2.
    +Sends floating point numbers in binary form, and provides support for arrays.

    +

    Hierarchy (View Summary)

    Index

    Constructors

    Properties

    buffer: Buffer
    log: Logger
    position: number

    Methods

    • Write an array column with its values into the buffer using v2 format.

      +

      Parameters

      • name: string

        Column name

        +
      • value: unknown[]

        Array values to write (currently supports double arrays). A null or undefined value omits the column entirely, storing NULL.

        +

      Returns SenderBuffer

      Returns with a reference to this buffer.

      +

      Error if array validation fails:

      +
        +
      • value is not an array
      • +
      • or the shape of the array is irregular: the length of sub-arrays are different
      • +
      • or the array is not homogeneous: its elements are not all the same type
      • +
      +
    • Closes the row after writing the designated timestamp into the buffer.

      +

      Precision rules:

      +
        +
      • Protocol v2 and higher: +Timestamps passed with unit 'ns' (nanoseconds) are sent with full nanosecond precision. +All other timestamps are sent with microsecond precision.
      • +
      • Protocol v1: +Always uses microsecond precision, even if the timestamp is specified in nanoseconds.
      • +
      +

      Parameters

      • timestamp: number | bigint

        Designated epoch timestamp. Must be an integer or a BigInt.

        +
      • Optionalunit: TimestampUnit = "us"

        The time unit of the timestamp. +Supported values:

        +
          +
        • 'ns' — nanoseconds (requires BigInt)
        • +
        • 'us' — microseconds (default)
        • +
        • 'ms' — milliseconds
        • +
        +

      Returns void

      Returns with a reference to this buffer.

      +

      If timestamp is not an integer or BigInt.

      +

      If unit is 'ns' but timestamp is not a BigInt.

      +

      If unit is not one of 'ns', 'us', or 'ms'. This +validation leaves the open row unchanged so the call can be retried.

      +
    • Closes the row without writing designated timestamp into the buffer.
      +Designated timestamp will be populated by the server on this record.

      +

      Returns void

    • Writes a boolean column with its value into the buffer.
      +Use it to insert into BOOLEAN columns.

      +

      Parameters

      • name: string

        Column name.

        +
      • value: boolean

        Column value, accepts only boolean values. A null or undefined value omits the column entirely (stored as NULL).

        +

      Returns SenderBuffer

      Returns with a reference to this buffer.

      +
    • Checks if the buffer has sufficient capacity for additional data and resizes if needed.

      +

      Parameters

      • data: string[]

        Array of strings to calculate the required capacity for

        +
      • base: number = 0

        Base number of bytes to add to the calculation

        +

      Returns void

    • Returns the current position of the buffer.
      +New data will be written into the buffer starting from this position.

      +

      Returns number

    • Writes a decimal value into the buffer using its binary format.

      +

      Use it to insert into DECIMAL database columns.

      +

      Decimals are not supported by protocol v1/v2, so this base implementation +rejects the call even when the value is null or undefined. Protocol v3 +overrides this with a validating implementation.

      +

      Parameters

      • name: string

        Column name.

        +
      • unscaled: bigint | Int8Array<ArrayBufferLike>

        The unscaled +integer portion of the decimal value.

        +
      • scale: number

        The number of fractional digits (the scale) of the decimal value.

        +

      Returns SenderBuffer

      Returns with a reference to this buffer.

      +

      If scale is not between 0 and 76. Scale validation +runs even when unscaled is null or undefined.

      +

      Indicating decimals are not supported in protocol v1/v2.

      +
    • Writes a decimal value into the buffer using its text format.

      +

      Use it to insert into DECIMAL database columns.

      +

      Decimals are not supported by protocol v1/v2, so this base implementation +rejects the call even when the value is null or undefined. Protocol v3 +overrides this with a validating implementation.

      +

      Parameters

      • name: string

        Column name.

        +
      • value: string | number

        The decimal value to +write.

        +

      Returns SenderBuffer

      Returns with a reference to this buffer.

      +

      Indicating decimals are not supported in protocol v1/v2.

      +
    • Writes a 64-bit floating point value into the buffer using v2 serialization (binary format).
      +Use it to insert into DOUBLE or FLOAT database columns.

      +

      Parameters

      • name: string

        Column name.

        +
      • value: number

        Column value, accepts only number values. A null or undefined value omits the column entirely (stored as NULL).

        +

      Returns SenderBuffer

      Returns with a reference to this buffer.

      +
    • Writes a 64-bit signed integer into the buffer.
      +Use it to insert into LONG, INT, SHORT and BYTE columns.

      +

      Parameters

      • name: string

        Column name.

        +
      • value: number

        Column value, accepts only number values. A null or undefined value omits the column entirely (stored as NULL).

        +

      Returns SenderBuffer

      Returns with a reference to this buffer.

      +

      Error if the value is not an integer

      +
    • Writes a string column with its value into the buffer.
      +Use it to insert into VARCHAR and STRING columns.

      +

      Parameters

      • name: string

        Column name.

        +
      • value: string

        Column value, accepts only string values. A null or undefined value omits the column entirely (stored as NULL).

        +

      Returns SenderBuffer

      Returns with a reference to this buffer.

      +
    • Writes a symbol name and value into the buffer.
      +Use it to insert into SYMBOL columns.

      +

      Parameters

      • name: string

        Symbol name.

        +
      • value: unknown

        Symbol value, toString() is called to extract the actual symbol value from the parameter. A null or undefined value omits the symbol entirely (stored as NULL).

        +

      Returns SenderBuffer

      Returns with a reference to this buffer.

      +
    • Writes a timestamp column and its value into the buffer.

      +

      Use this method to insert data into TIMESTAMP or TIMESTAMP_NS columns.

      +

      Precision rules:

      +
        +
      • Protocol v2 and higher: +Timestamps passed with unit 'ns' (nanoseconds) are sent with full nanosecond precision. +All other timestamps are sent with microsecond precision.
      • +
      • Protocol v1: +Always uses microsecond precision, even if the timestamp is specified in nanoseconds.
      • +
      +

      Parameters

      • name: string

        The column name.

        +
      • value: number | bigint

        The epoch timestamp. Must be an integer or a BigInt. A null or undefined value omits the column entirely (stored as NULL).

        +
      • Optionalunit: TimestampUnit = "us"

        The time unit of the timestamp. +Supported values:

        +
          +
        • 'ns' — nanoseconds (requires BigInt)
        • +
        • 'us' — microseconds (default)
        • +
        • 'ms' — milliseconds
        • +
        +

      Returns SenderBuffer

      Returns with a reference to this buffer.

      +

      If unit is not one of 'ns', 'us', or 'ms' (checked +even when value is null or undefined).

      +

      If value is not an integer or BigInt.

      +

      If unit is 'ns' but value is not a BigInt.

      +
    • Parameters

      • pos: number = ...

      Returns Buffer

      Returns a cropped buffer ready to send to the server, or null if there is nothing to send.
      +The returned buffer is a copy of this buffer. +It also compacts the buffer.

      +
    • Parameters

      • pos: number = ...

      Returns Buffer

      Returns a cropped buffer, or null if there is nothing to send.
      +The returned buffer is backed by this buffer instance, meaning the view can change as the buffer is mutated. +Used only in tests to assert the buffer's content.

      +
    diff --git a/docs/classes/_questdb_nodejs-client.SenderBufferV3.html b/docs/classes/_questdb_nodejs-client.SenderBufferV3.html new file mode 100644 index 0000000..71419d6 --- /dev/null +++ b/docs/classes/_questdb_nodejs-client.SenderBufferV3.html @@ -0,0 +1,162 @@ +SenderBufferV3 | QuestDB JavaScript Client - v4.2.0

    Buffer implementation for protocol version 3.

    +

    Provides support for decimals.

    +

    Hierarchy (View Summary)

    Index

    Constructors

    Properties

    buffer: Buffer
    log: Logger
    position: number

    Methods

    • Write an array column with its values into the buffer using v2 format.

      +

      Parameters

      • name: string

        Column name

        +
      • value: unknown[]

        Array values to write (currently supports double arrays). A null or undefined value omits the column entirely, storing NULL.

        +

      Returns SenderBuffer

      Returns with a reference to this buffer.

      +

      Error if array validation fails:

      +
        +
      • value is not an array
      • +
      • or the shape of the array is irregular: the length of sub-arrays are different
      • +
      • or the array is not homogeneous: its elements are not all the same type
      • +
      +
    • Closes the row after writing the designated timestamp into the buffer.

      +

      Precision rules:

      +
        +
      • Protocol v2 and higher: +Timestamps passed with unit 'ns' (nanoseconds) are sent with full nanosecond precision. +All other timestamps are sent with microsecond precision.
      • +
      • Protocol v1: +Always uses microsecond precision, even if the timestamp is specified in nanoseconds.
      • +
      +

      Parameters

      • timestamp: number | bigint

        Designated epoch timestamp. Must be an integer or a BigInt.

        +
      • Optionalunit: TimestampUnit = "us"

        The time unit of the timestamp. +Supported values:

        +
          +
        • 'ns' — nanoseconds (requires BigInt)
        • +
        • 'us' — microseconds (default)
        • +
        • 'ms' — milliseconds
        • +
        +

      Returns void

      Returns with a reference to this buffer.

      +

      If timestamp is not an integer or BigInt.

      +

      If unit is 'ns' but timestamp is not a BigInt.

      +

      If unit is not one of 'ns', 'us', or 'ms'. This +validation leaves the open row unchanged so the call can be retried.

      +
    • Checks if the buffer has sufficient capacity for additional data and resizes if needed.

      +

      Parameters

      • data: string[]

        Array of strings to calculate the required capacity for

        +
      • base: number = 0

        Base number of bytes to add to the calculation

        +

      Returns void

    • Writes a decimal value into the buffer using its binary format.

      +

      Use it to insert into DECIMAL database columns.

      +

      Parameters

      • name: string

        Column name.

        +
      • unscaled: bigint | Int8Array<ArrayBufferLike>

        The unscaled integer portion of the decimal value.

        +
          +
        • If a bigint is provided, it will be converted automatically.
        • +
        • If an Int8Array is provided, it must contain the two’s complement representation +of the unscaled value in big-endian byte order.
        • +
        • An empty Int8Array represents a NULL value.
        • +
        +
      • scale: number

        The number of fractional digits (the scale) of the decimal value.

        +

      Returns SenderBuffer

      Returns with a reference to this buffer.

      +

      If decimals are not supported by the buffer implementation, or validation fails. +Possible validation errors:

      +
        +
      • unscaled length is not between 0 and 32 bytes.
      • +
      • scale is not between 0 and 76.
      • +
      • unscaled contains invalid bytes.
      • +
      +
    • Writes a decimal value into the buffer using its text format.

      +

      Use it to insert into DECIMAL database columns.

      +

      Parameters

      • name: string

        Column name.

        +
      • value: string | number

        The decimal value to write.

        +
          +
        • Accepts either a number or a string containing a valid decimal representation.
        • +
        • String values should follow standard decimal notation (e.g., "123.45" or "-0.001").
        • +
        +

      Returns SenderBuffer

      Returns with a reference to this buffer.

      +

      Error If decimals are not supported by the buffer implementation, or validation fails. +Possible validation errors:

      +
        +
      • The provided string is not a valid decimal representation.
      • +
      +
    • Writes a 64-bit floating point value into the buffer using v2 serialization (binary format).
      +Use it to insert into DOUBLE or FLOAT database columns.

      +

      Parameters

      • name: string

        Column name.

        +
      • value: number

        Column value, accepts only number values. A null or undefined value omits the column entirely (stored as NULL).

        +

      Returns SenderBuffer

      Returns with a reference to this buffer.

      +
    • Writes a 64-bit signed integer into the buffer.
      +Use it to insert into LONG, INT, SHORT and BYTE columns.

      +

      Parameters

      • name: string

        Column name.

        +
      • value: number

        Column value, accepts only number values. A null or undefined value omits the column entirely (stored as NULL).

        +

      Returns SenderBuffer

      Returns with a reference to this buffer.

      +

      Error if the value is not an integer

      +
    • Writes a string column with its value into the buffer.
      +Use it to insert into VARCHAR and STRING columns.

      +

      Parameters

      • name: string

        Column name.

        +
      • value: string

        Column value, accepts only string values. A null or undefined value omits the column entirely (stored as NULL).

        +

      Returns SenderBuffer

      Returns with a reference to this buffer.

      +
    • Writes a symbol name and value into the buffer.
      +Use it to insert into SYMBOL columns.

      +

      Parameters

      • name: string

        Symbol name.

        +
      • value: unknown

        Symbol value, toString() is called to extract the actual symbol value from the parameter. A null or undefined value omits the symbol entirely (stored as NULL).

        +

      Returns SenderBuffer

      Returns with a reference to this buffer.

      +
    • Writes a timestamp column and its value into the buffer.

      +

      Use this method to insert data into TIMESTAMP or TIMESTAMP_NS columns.

      +

      Precision rules:

      +
        +
      • Protocol v2 and higher: +Timestamps passed with unit 'ns' (nanoseconds) are sent with full nanosecond precision. +All other timestamps are sent with microsecond precision.
      • +
      • Protocol v1: +Always uses microsecond precision, even if the timestamp is specified in nanoseconds.
      • +
      +

      Parameters

      • name: string

        The column name.

        +
      • value: number | bigint

        The epoch timestamp. Must be an integer or a BigInt. A null or undefined value omits the column entirely (stored as NULL).

        +
      • Optionalunit: TimestampUnit = "us"

        The time unit of the timestamp. +Supported values:

        +
          +
        • 'ns' — nanoseconds (requires BigInt)
        • +
        • 'us' — microseconds (default)
        • +
        • 'ms' — milliseconds
        • +
        +

      Returns SenderBuffer

      Returns with a reference to this buffer.

      +

      If unit is not one of 'ns', 'us', or 'ms' (checked +even when value is null or undefined).

      +

      If value is not an integer or BigInt.

      +

      If unit is 'ns' but value is not a BigInt.

      +
    • Parameters

      • pos: number = ...

      Returns Buffer

      Returns a cropped buffer, or null if there is nothing to send.
      +The returned buffer is backed by this buffer instance, meaning the view can change as the buffer is mutated. +Used only in tests to assert the buffer's content.

      +
    diff --git a/docs/classes/_questdb_nodejs-client.SenderOptions.html b/docs/classes/_questdb_nodejs-client.SenderOptions.html new file mode 100644 index 0000000..82ce714 --- /dev/null +++ b/docs/classes/_questdb_nodejs-client.SenderOptions.html @@ -0,0 +1,190 @@ +SenderOptions | QuestDB JavaScript Client - v4.2.0

    Sender configuration options. +
    +Properties of the object are initialized through a configuration string.
    +The configuration string has the following format: protocol::key=value;key=value...
    +The keys are case-sensitive, the trailing semicolon is optional.
    +The values are validated and an error is thrown if the format is invalid.
    +
    +Connection and protocol options

    +
      +
    • protocol: enum, accepted values: http, https, tcp, tcps, ws, wss, udp - The protocol used to communicate with the server.
      +WS/WSS select acknowledged QWP ingress; their connect strings use the QWP configuration schema, +shared with the other QuestDB clients, and are documented in QWP.md rather than in this list. +UDP selects Node-only fire-and-forget QWP datagrams and uses the options below. When https, tcps, or wss is used, the connection is secured with TLS encryption. +
    • +
    • protocol_version: enum, accepted values: auto, 1, 2 - The protocol version used for data serialization.
      +Version 1 uses text-based serialization for all data types. Version 2 uses binary encoding for doubles and arrays.
      +When set to 'auto' (default for HTTP/HTTPS), the client automatically negotiates the highest supported version with the server.
      +TCP/TCPS connections default to version 1. +
    • +
    • addr: string - Hostname and port, separated by colon. This key is mandatory, but the port part is optional.
      +If no port is specified, a default will be used.
      +When the protocol is HTTP/HTTPS, the port defaults to 9000. When the protocol is TCP/TCPS, the port defaults to 9009.
      +
      +Examples: http::addr=localhost:9000, https::addr=localhost:9000, http::addr=localhost, tcp::addr=localhost:9009 +
    • +
    +
    +Authentication options +
      +
    • username: string - Used for authentication.
      +For HTTP, Basic Authentication requires the password option.
      +For TCP with JWK token authentication, token option is required. +
    • +
    • password: string - Password for HTTP Basic authentication, should be accompanied by the username option. +
    • +
    • token: string - For HTTP with Bearer authentication, this is the bearer token.
      +For TCP with JWK token authentication, this is the private key part of the JWK token, +and must be accompanied by the username option. +
    • +
    +
    +TLS options +
      +
    • tls_verify: enum, accepted values: on, unsafe_off - When the HTTPS or TCPS protocols are selected, TLS encryption is used.
      +By default, the Sender will verify the server's certificate, but this check can be disabled by setting this option to unsafe_off.
      +This is useful in non-production environments where self-signed certificates might be used, but should be avoided in production if possible. +
    • +
    • tls_ca: string - Path to a file containing the root CA's certificate in PEM format.
      +Can be useful when self-signed certificates are used, otherwise should not be set. +
    • +
    +
    +Auto flush options +
      +
    • auto_flush: enum, accepted values: on, off - The Sender automatically flushes the buffer by default. This can be switched off +by setting this option to off.
      +When disabled, the flush() method of the Sender has to be called explicitly to make sure data is sent to the server.
      +Manual buffer flushing can be useful, especially when we want to control transaction boundaries.
      +When the HTTP protocol is used, each flush results in a single HTTP request, which becomes a single transaction on the server side.
      +The transaction either succeeds, and all rows sent in the request are inserted; or it fails, and none of the rows make it into the database. +
    • +
    • auto_flush_rows: integer - The number of rows that will trigger a flush. When set to 0, row-based flushing is disabled.
      +The Sender will default this parameter to 75000 rows when HTTP protocol is used, and to 600 in case of TCP protocol. +
    • +
    • auto_flush_bytes: integer or off - Buffered-byte threshold.
      +Reaching the threshold flushes after the completed row. This option is supported by udp only; +on ws/wss it belongs to the QWP configuration schema.
      +Defaults to max_datagram_size, so datagrams are flushed before they outgrow the +configured limit. Set it to off to disable the byte trigger. +
    • +
    • auto_flush_interval: integer - The number of milliseconds that will trigger a flush, default value is 1000. +When set to 0, interval-based flushing is disabled.
      +Note that the setting is checked only when a new row is added to the buffer. There is no timer registered to flush the buffer automatically. +
    • +
    +
    +Buffer sizing options +
      +
    • init_buf_size: integer - Initial buffer size, defaults to 64 KiB in the Sender. +
    • +
    • max_buf_size: integer - Maximum buffer size, defaults to 100 MiB in the Sender.
      +If the buffer would need to be extended beyond the maximum size, an error is thrown. +
    • +
    +
    +HTTP request specific options +
      +
    • request_timeout: integer - The time in milliseconds to wait for a response from the server, set to 10 seconds by default.
      +This is in addition to the calculation derived from the request_min_throughput parameter. +
    • +
    • request_min_throughput: integer - Minimum expected throughput in bytes per second for HTTP requests, set to 100 KiB/s seconds by default.
      +If the throughput is lower than this value, the connection will time out. This is used to calculate an additional +timeout on top of request_timeout. This is useful for large requests. You can set this value to 0 to disable this logic. +
    • +
    • retry_timeout: integer - The time in milliseconds to continue retrying after a failed HTTP request, set to 10 seconds by default.
      +The interval between retries is an exponential backoff starting at 10ms and doubling after each failed attempt up to a maximum of 1 second. +
    • +
    +
    +Other options +
      +
    • stdlib_http: enum, accepted values: on, off - With HTTP protocol the Undici library is used by default. By setting this option +to on the client switches to node's core http and https modules. +
    • +
    • max_name_len: integer - The maximum length of a table or column name, the Sender defaults this parameter to 127.
      +Recommended to use the same setting as the server, which also uses 127 by default. +
    • +
    +
    +UDP specific options +
      +
    • max_datagram_size: integer - Maximum encoded datagram size in bytes, defaults to 1400.
      +A row that cannot fit a single datagram is rejected before transmission. It is also the default for +auto_flush_bytes. Supported by the udp transport only; http, tcp and ws/wss reject it. +
    • +
    • multicast_ttl: integer - Multicast time-to-live for outgoing datagrams, from 0 to 255, defaults to 0.
      +Supported by the udp transport only; http, tcp and ws/wss reject it. +
    • +
    Index

    Constructors

    • Creates a Sender options object by parsing the provided configuration string.

      +

      Parameters

      • configurationString: string

        Configuration string.

        +
      • OptionalextraOptions: ExtraOptions

        Optional extra configuration.

        +
          +
        • 'log' is a logging function used by the Sender. +Prototype: (level: 'error'|'warn'|'info'|'debug', message: string) => void.
        • +
        • 'agent' is a custom http/https agent used by the Sender when http/https transport is used. +Depends on which transport implementation and protocol used, one of the followings expected: undici.Agent, http.Agent or https.Agent.
        • +
        +

      Returns SenderOptions

    Properties

    addr?: string
    agent?: Agent | Agent | Agent
    auth?: { keyId?: string; password?: string; token?: string; username?: string }
    auto_flush?: boolean
    auto_flush_bytes?: number
    auto_flush_interval?: number
    auto_flush_rows?: number
    host?: string
    init_buf_size?: number
    jwk?: Record<string, string>
    log?: Logger
    max_buf_size?: number
    max_datagram_size?: number
    max_name_len?: number
    multicast_ttl?: number
    password?: string
    port?: number
    protocol: string
    protocol_version?: string
    request_min_throughput?: number
    request_timeout?: number
    retry_timeout?: number
    stdlib_http?: boolean
    tls_ca?: PathOrFileDescriptor
    tls_roots?: never
    tls_roots_password?: never
    tls_verify?: boolean
    token?: string
    token_x?: string
    token_y?: string
    username?: string

    Methods

    • Creates a Sender options object by parsing the provided configuration string.

      +

      Parameters

      • configurationString: string

        Configuration string.

        +
      • OptionalextraOptions: ExtraOptions

        Optional extra configuration.

        +
          +
        • 'log' is a logging function used by the Sender. +Prototype: (level: 'error'|'warn'|'info'|'debug', message: string) => void.
        • +
        • 'agent' is a custom http/https agent used by the Sender when http/https transport is used. +Depends on which transport implementation and protocol used, one of the followings expected: undici.Agent, http.Agent or https.Agent.
        • +
        +

      Returns Promise<SenderOptions>

      A Sender configuration object initialized from the provided configuration string.

      +
    • Creates a Sender options object by parsing the configuration string set in the QDB_CLIENT_CONF environment variable.

      +

      Parameters

      • OptionalextraOptions: ExtraOptions

        Optional extra configuration.

        +
          +
        • 'log' is a logging function used by the Sender. +Prototype: (level: 'error'|'warn'|'info'|'debug', message: string) => void.
        • +
        • 'agent' is a custom http/https agent used by the Sender when http/https transport is used. +Depends on which transport implementation and protocol used, one of the followings expected: undici.Agent, http.Agent or https.Agent.
        • +
        +

      Returns Promise<SenderOptions>

      A Sender configuration object initialized from the QDB_CLIENT_CONF environment variable.

      +
    • Resolves the protocol version, if it is set to 'auto'.
      +If TCP transport is used, the protocol version will default to 1. +In case of HTTP transport the /settings endpoint of the database is used to find the protocol versions +supported by the server, and the highest will be selected. +When calling the /settings endpoint the timeout and TLS options are used from the options object.

      +

      Parameters

      • options: SenderOptions

        SenderOptions instance needs resolving protocol version

        +

      Returns Promise<SenderOptions>

    diff --git a/docs/classes/_questdb_nodejs-client.TcpTransport.html b/docs/classes/_questdb_nodejs-client.TcpTransport.html new file mode 100644 index 0000000..a8c9079 --- /dev/null +++ b/docs/classes/_questdb_nodejs-client.TcpTransport.html @@ -0,0 +1,21 @@ +TcpTransport | QuestDB JavaScript Client - v4.2.0

    TCP transport implementation.
    +Supports both plain TCP or secure TLS-encrypted connections with configurable JWK token authentication.

    +

    Implements

    Index

    Constructors

    Methods

    • Sends data over the established TCP connection.

      +

      Parameters

      • data: Buffer

        Buffer containing the data to send

        +

      Returns Promise<boolean>

      Promise resolving to true if data was sent successfully

      +

      Error if the data could not be written to the socket

      +
    diff --git a/docs/classes/_questdb_nodejs-client.UndiciTransport.html b/docs/classes/_questdb_nodejs-client.UndiciTransport.html new file mode 100644 index 0000000..5c79cd2 --- /dev/null +++ b/docs/classes/_questdb_nodejs-client.UndiciTransport.html @@ -0,0 +1,34 @@ +UndiciTransport | QuestDB JavaScript Client - v4.2.0

    HTTP transport implementation using the Undici library.
    +Provides high-performance HTTP requests with connection pooling and retry logic.
    +Supports both HTTP and HTTPS protocols with configurable authentication.

    +

    Hierarchy

    • HttpTransportBase
      • UndiciTransport
    Index

    Constructors

    Properties

    host: string
    log: Logger
    password: string
    port: number
    requestMinThroughput: number
    requestTimeout: number
    retryTimeout: number
    secure: boolean
    tlsCA: Buffer
    tlsVerify: boolean
    token: string
    username: string

    Methods

    • HTTP transport does not require explicit connection establishment.

      +

      Returns Promise<boolean>

      Error indicating connect is not required for HTTP transport

      +
    • Gets the default auto-flush row count for HTTP transport.

      +

      Returns number

      Default number of rows that trigger auto-flush

      +
    • Sends data to QuestDB using HTTP POST.

      +

      Parameters

      • data: Buffer

        Buffer containing the data to send

        +

      Returns Promise<boolean>

      Promise resolving to true if data was sent successfully

      +

      Error if request fails after all retries or times out

      +
    diff --git a/docs/functions/_questdb_browser-client.addQwpDurableAckWebSocketProtocol.html b/docs/functions/_questdb_browser-client.addQwpDurableAckWebSocketProtocol.html new file mode 100644 index 0000000..e548d37 --- /dev/null +++ b/docs/functions/_questdb_browser-client.addQwpDurableAckWebSocketProtocol.html @@ -0,0 +1,2 @@ +addQwpDurableAckWebSocketProtocol | QuestDB JavaScript Client - v4.2.0

    Function addQwpDurableAckWebSocketProtocol

    • Adds the durable-ACK capability token without mutating user options.

      +

      Parameters

      • protocols: undefined | string | readonly string[]

      Returns string | string[]

    diff --git a/docs/functions/_questdb_browser-client.binary.html b/docs/functions/_questdb_browser-client.binary.html new file mode 100644 index 0000000..19e428e --- /dev/null +++ b/docs/functions/_questdb_browser-client.binary.html @@ -0,0 +1,2 @@ +binary | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_browser-client.bool.html b/docs/functions/_questdb_browser-client.bool.html new file mode 100644 index 0000000..d55c52a --- /dev/null +++ b/docs/functions/_questdb_browser-client.bool.html @@ -0,0 +1,2 @@ +bool | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_browser-client.bootstrapQwpBrowserSession.html b/docs/functions/_questdb_browser-client.bootstrapQwpBrowserSession.html new file mode 100644 index 0000000..c9fa902 --- /dev/null +++ b/docs/functions/_questdb_browser-client.bootstrapQwpBrowserSession.html @@ -0,0 +1,5 @@ +bootstrapQwpBrowserSession | QuestDB JavaScript Client - v4.2.0

    Function bootstrapQwpBrowserSession

    diff --git a/docs/functions/_questdb_browser-client.byte.html b/docs/functions/_questdb_browser-client.byte.html new file mode 100644 index 0000000..f5abd09 --- /dev/null +++ b/docs/functions/_questdb_browser-client.byte.html @@ -0,0 +1,2 @@ +byte | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_browser-client.char.html b/docs/functions/_questdb_browser-client.char.html new file mode 100644 index 0000000..1887d85 --- /dev/null +++ b/docs/functions/_questdb_browser-client.char.html @@ -0,0 +1,2 @@ +char | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_browser-client.concatBytes.html b/docs/functions/_questdb_browser-client.concatBytes.html new file mode 100644 index 0000000..a97bc90 --- /dev/null +++ b/docs/functions/_questdb_browser-client.concatBytes.html @@ -0,0 +1 @@ +concatBytes | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_browser-client.connectQwpBrowserClient.html b/docs/functions/_questdb_browser-client.connectQwpBrowserClient.html new file mode 100644 index 0000000..37def69 --- /dev/null +++ b/docs/functions/_questdb_browser-client.connectQwpBrowserClient.html @@ -0,0 +1,2 @@ +connectQwpBrowserClient | QuestDB JavaScript Client - v4.2.0

    Function connectQwpBrowserClient

    diff --git a/docs/functions/_questdb_browser-client.connectQwpBrowserEgress.html b/docs/functions/_questdb_browser-client.connectQwpBrowserEgress.html new file mode 100644 index 0000000..ba0a301 --- /dev/null +++ b/docs/functions/_questdb_browser-client.connectQwpBrowserEgress.html @@ -0,0 +1,3 @@ +connectQwpBrowserEgress | QuestDB JavaScript Client - v4.2.0

    Function connectQwpBrowserEgress

    diff --git a/docs/functions/_questdb_browser-client.connectQwpBrowserIngress.html b/docs/functions/_questdb_browser-client.connectQwpBrowserIngress.html new file mode 100644 index 0000000..faef3c0 --- /dev/null +++ b/docs/functions/_questdb_browser-client.connectQwpBrowserIngress.html @@ -0,0 +1,3 @@ +connectQwpBrowserIngress | QuestDB JavaScript Client - v4.2.0

    Function connectQwpBrowserIngress

    diff --git a/docs/functions/_questdb_browser-client.connectQwpBrowserSender.html b/docs/functions/_questdb_browser-client.connectQwpBrowserSender.html new file mode 100644 index 0000000..cfbf33c --- /dev/null +++ b/docs/functions/_questdb_browser-client.connectQwpBrowserSender.html @@ -0,0 +1,2 @@ +connectQwpBrowserSender | QuestDB JavaScript Client - v4.2.0

    Function connectQwpBrowserSender

    diff --git a/docs/functions/_questdb_browser-client.connectQwpBrowserWebSocket.html b/docs/functions/_questdb_browser-client.connectQwpBrowserWebSocket.html new file mode 100644 index 0000000..275baf9 --- /dev/null +++ b/docs/functions/_questdb_browser-client.connectQwpBrowserWebSocket.html @@ -0,0 +1,7 @@ +connectQwpBrowserWebSocket | QuestDB JavaScript Client - v4.2.0

    Function connectQwpBrowserWebSocket

    diff --git a/docs/functions/_questdb_browser-client.createQwpBrowserClient.html b/docs/functions/_questdb_browser-client.createQwpBrowserClient.html new file mode 100644 index 0000000..950e83d --- /dev/null +++ b/docs/functions/_questdb_browser-client.createQwpBrowserClient.html @@ -0,0 +1,2 @@ +createQwpBrowserClient | QuestDB JavaScript Client - v4.2.0

    Function createQwpBrowserClient

    diff --git a/docs/functions/_questdb_browser-client.createQwpBrowserConnectionFactory.html b/docs/functions/_questdb_browser-client.createQwpBrowserConnectionFactory.html new file mode 100644 index 0000000..7bee4c6 --- /dev/null +++ b/docs/functions/_questdb_browser-client.createQwpBrowserConnectionFactory.html @@ -0,0 +1,2 @@ +createQwpBrowserConnectionFactory | QuestDB JavaScript Client - v4.2.0

    Function createQwpBrowserConnectionFactory

    diff --git a/docs/functions/_questdb_browser-client.createQwpBrowserSender.html b/docs/functions/_questdb_browser-client.createQwpBrowserSender.html new file mode 100644 index 0000000..85cb9bb --- /dev/null +++ b/docs/functions/_questdb_browser-client.createQwpBrowserSender.html @@ -0,0 +1,3 @@ +createQwpBrowserSender | QuestDB JavaScript Client - v4.2.0

    Function createQwpBrowserSender

    diff --git a/docs/functions/_questdb_browser-client.createQwpDataLossSenderError.html b/docs/functions/_questdb_browser-client.createQwpDataLossSenderError.html new file mode 100644 index 0000000..3d4cb72 --- /dev/null +++ b/docs/functions/_questdb_browser-client.createQwpDataLossSenderError.html @@ -0,0 +1,2 @@ +createQwpDataLossSenderError | QuestDB JavaScript Client - v4.2.0

    Function createQwpDataLossSenderError

    diff --git a/docs/functions/_questdb_browser-client.createQwpProtocolViolationSenderError.html b/docs/functions/_questdb_browser-client.createQwpProtocolViolationSenderError.html new file mode 100644 index 0000000..0946f58 --- /dev/null +++ b/docs/functions/_questdb_browser-client.createQwpProtocolViolationSenderError.html @@ -0,0 +1 @@ +createQwpProtocolViolationSenderError | QuestDB JavaScript Client - v4.2.0

    Function createQwpProtocolViolationSenderError

    diff --git a/docs/functions/_questdb_browser-client.createQwpSenderError.html b/docs/functions/_questdb_browser-client.createQwpSenderError.html new file mode 100644 index 0000000..ddd8ad2 --- /dev/null +++ b/docs/functions/_questdb_browser-client.createQwpSenderError.html @@ -0,0 +1 @@ +createQwpSenderError | QuestDB JavaScript Client - v4.2.0

    Function createQwpSenderError

    diff --git a/docs/functions/_questdb_browser-client.date.html b/docs/functions/_questdb_browser-client.date.html new file mode 100644 index 0000000..3e30fad --- /dev/null +++ b/docs/functions/_questdb_browser-client.date.html @@ -0,0 +1,4 @@ +date | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_browser-client.decimal128.html b/docs/functions/_questdb_browser-client.decimal128.html new file mode 100644 index 0000000..78ca685 --- /dev/null +++ b/docs/functions/_questdb_browser-client.decimal128.html @@ -0,0 +1,2 @@ +decimal128 | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_browser-client.decimal256.html b/docs/functions/_questdb_browser-client.decimal256.html new file mode 100644 index 0000000..adfff70 --- /dev/null +++ b/docs/functions/_questdb_browser-client.decimal256.html @@ -0,0 +1,2 @@ +decimal256 | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_browser-client.decimal64.html b/docs/functions/_questdb_browser-client.decimal64.html new file mode 100644 index 0000000..04808b2 --- /dev/null +++ b/docs/functions/_questdb_browser-client.decimal64.html @@ -0,0 +1,2 @@ +decimal64 | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_browser-client.decodeQwpContentEncoding.html b/docs/functions/_questdb_browser-client.decodeQwpContentEncoding.html new file mode 100644 index 0000000..0d6b936 --- /dev/null +++ b/docs/functions/_questdb_browser-client.decodeQwpContentEncoding.html @@ -0,0 +1,4 @@ +decodeQwpContentEncoding | QuestDB JavaScript Client - v4.2.0

    Function decodeQwpContentEncoding

    diff --git a/docs/functions/_questdb_browser-client.decodeQwpEgressMessage.html b/docs/functions/_questdb_browser-client.decodeQwpEgressMessage.html new file mode 100644 index 0000000..b3d11fd --- /dev/null +++ b/docs/functions/_questdb_browser-client.decodeQwpEgressMessage.html @@ -0,0 +1,2 @@ +decodeQwpEgressMessage | QuestDB JavaScript Client - v4.2.0

    Function decodeQwpEgressMessage

    diff --git a/docs/functions/_questdb_browser-client.decodeQwpFrame.html b/docs/functions/_questdb_browser-client.decodeQwpFrame.html new file mode 100644 index 0000000..3b1ff1b --- /dev/null +++ b/docs/functions/_questdb_browser-client.decodeQwpFrame.html @@ -0,0 +1 @@ +decodeQwpFrame | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_browser-client.decodeQwpIngressResponse.html b/docs/functions/_questdb_browser-client.decodeQwpIngressResponse.html new file mode 100644 index 0000000..b8c5426 --- /dev/null +++ b/docs/functions/_questdb_browser-client.decodeQwpIngressResponse.html @@ -0,0 +1,2 @@ +decodeQwpIngressResponse | QuestDB JavaScript Client - v4.2.0

    Function decodeQwpIngressResponse

    diff --git a/docs/functions/_questdb_browser-client.decodeQwpIngressServerInfo.html b/docs/functions/_questdb_browser-client.decodeQwpIngressServerInfo.html new file mode 100644 index 0000000..edc0ac2 --- /dev/null +++ b/docs/functions/_questdb_browser-client.decodeQwpIngressServerInfo.html @@ -0,0 +1,2 @@ +decodeQwpIngressServerInfo | QuestDB JavaScript Client - v4.2.0

    Function decodeQwpIngressServerInfo

    • Decodes the browser-requested ingress SERVER_INFO payload when present.

      +

      Parameters

      • payload: Uint8Array

      Returns undefined | number

    diff --git a/docs/functions/_questdb_browser-client.decodeQwpIngressSymbolDictionaryDelta.html b/docs/functions/_questdb_browser-client.decodeQwpIngressSymbolDictionaryDelta.html new file mode 100644 index 0000000..b226018 --- /dev/null +++ b/docs/functions/_questdb_browser-client.decodeQwpIngressSymbolDictionaryDelta.html @@ -0,0 +1,2 @@ +decodeQwpIngressSymbolDictionaryDelta | QuestDB JavaScript Client - v4.2.0

    Function decodeQwpIngressSymbolDictionaryDelta

    diff --git a/docs/functions/_questdb_browser-client.decodeQwpVarint.html b/docs/functions/_questdb_browser-client.decodeQwpVarint.html new file mode 100644 index 0000000..7a3e306 --- /dev/null +++ b/docs/functions/_questdb_browser-client.decodeQwpVarint.html @@ -0,0 +1 @@ +decodeQwpVarint | QuestDB JavaScript Client - v4.2.0
    • Parameters

      • bytes: Uint8Array
      • offset: number = 0

      Returns { offset: number; value: bigint }

    diff --git a/docs/functions/_questdb_browser-client.decodeUtf8.html b/docs/functions/_questdb_browser-client.decodeUtf8.html new file mode 100644 index 0000000..7bb9086 --- /dev/null +++ b/docs/functions/_questdb_browser-client.decodeUtf8.html @@ -0,0 +1 @@ +decodeUtf8 | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_browser-client.decompressQwpZstdFrame.html b/docs/functions/_questdb_browser-client.decompressQwpZstdFrame.html new file mode 100644 index 0000000..78d7d0f --- /dev/null +++ b/docs/functions/_questdb_browser-client.decompressQwpZstdFrame.html @@ -0,0 +1,2 @@ +decompressQwpZstdFrame | QuestDB JavaScript Client - v4.2.0

    Function decompressQwpZstdFrame

    diff --git a/docs/functions/_questdb_browser-client.defaultQwpSenderErrorHandler.html b/docs/functions/_questdb_browser-client.defaultQwpSenderErrorHandler.html new file mode 100644 index 0000000..f1ad22d --- /dev/null +++ b/docs/functions/_questdb_browser-client.defaultQwpSenderErrorHandler.html @@ -0,0 +1,3 @@ +defaultQwpSenderErrorHandler | QuestDB JavaScript Client - v4.2.0

    Function defaultQwpSenderErrorHandler

    diff --git a/docs/functions/_questdb_browser-client.designatedTimestamp.html b/docs/functions/_questdb_browser-client.designatedTimestamp.html new file mode 100644 index 0000000..1102ff3 --- /dev/null +++ b/docs/functions/_questdb_browser-client.designatedTimestamp.html @@ -0,0 +1,2 @@ +designatedTimestamp | QuestDB JavaScript Client - v4.2.0

    Function designatedTimestamp

    diff --git a/docs/functions/_questdb_browser-client.double.html b/docs/functions/_questdb_browser-client.double.html new file mode 100644 index 0000000..c9bba22 --- /dev/null +++ b/docs/functions/_questdb_browser-client.double.html @@ -0,0 +1,2 @@ +double | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_browser-client.doubleArray.html b/docs/functions/_questdb_browser-client.doubleArray.html new file mode 100644 index 0000000..cd65116 --- /dev/null +++ b/docs/functions/_questdb_browser-client.doubleArray.html @@ -0,0 +1,2 @@ +doubleArray | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_browser-client.encodeQwpAcceptEncoding.html b/docs/functions/_questdb_browser-client.encodeQwpAcceptEncoding.html new file mode 100644 index 0000000..7833031 --- /dev/null +++ b/docs/functions/_questdb_browser-client.encodeQwpAcceptEncoding.html @@ -0,0 +1,2 @@ +encodeQwpAcceptEncoding | QuestDB JavaScript Client - v4.2.0

    Function encodeQwpAcceptEncoding

    diff --git a/docs/functions/_questdb_browser-client.encodeQwpBinds.html b/docs/functions/_questdb_browser-client.encodeQwpBinds.html new file mode 100644 index 0000000..8463be5 --- /dev/null +++ b/docs/functions/_questdb_browser-client.encodeQwpBinds.html @@ -0,0 +1,2 @@ +encodeQwpBinds | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_browser-client.encodeQwpCancel.html b/docs/functions/_questdb_browser-client.encodeQwpCancel.html new file mode 100644 index 0000000..c51bd15 --- /dev/null +++ b/docs/functions/_questdb_browser-client.encodeQwpCancel.html @@ -0,0 +1,2 @@ +encodeQwpCancel | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_browser-client.encodeQwpCredit.html b/docs/functions/_questdb_browser-client.encodeQwpCredit.html new file mode 100644 index 0000000..429bc9d --- /dev/null +++ b/docs/functions/_questdb_browser-client.encodeQwpCredit.html @@ -0,0 +1,2 @@ +encodeQwpCredit | QuestDB JavaScript Client - v4.2.0
    • Encodes the unframed client-to-server CREDIT payload.

      +

      Parameters

      • request: number | bigint
      • additionalBytes: number | bigint

      Returns Uint8Array

    diff --git a/docs/functions/_questdb_browser-client.encodeQwpDurableAckPollFrame.html b/docs/functions/_questdb_browser-client.encodeQwpDurableAckPollFrame.html new file mode 100644 index 0000000..ab410e7 --- /dev/null +++ b/docs/functions/_questdb_browser-client.encodeQwpDurableAckPollFrame.html @@ -0,0 +1,2 @@ +encodeQwpDurableAckPollFrame | QuestDB JavaScript Client - v4.2.0

    Function encodeQwpDurableAckPollFrame

    diff --git a/docs/functions/_questdb_browser-client.encodeQwpFrame.html b/docs/functions/_questdb_browser-client.encodeQwpFrame.html new file mode 100644 index 0000000..6e58bd2 --- /dev/null +++ b/docs/functions/_questdb_browser-client.encodeQwpFrame.html @@ -0,0 +1 @@ +encodeQwpFrame | QuestDB JavaScript Client - v4.2.0
    • Parameters

      • payload: Uint8Array
      • flags: number = 0
      • tableCount: number = 0

      Returns Uint8Array

    diff --git a/docs/functions/_questdb_browser-client.encodeQwpGorilla.html b/docs/functions/_questdb_browser-client.encodeQwpGorilla.html new file mode 100644 index 0000000..eda4bf7 --- /dev/null +++ b/docs/functions/_questdb_browser-client.encodeQwpGorilla.html @@ -0,0 +1,2 @@ +encodeQwpGorilla | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_browser-client.encodeQwpIngressCommitFrame.html b/docs/functions/_questdb_browser-client.encodeQwpIngressCommitFrame.html new file mode 100644 index 0000000..36d134d --- /dev/null +++ b/docs/functions/_questdb_browser-client.encodeQwpIngressCommitFrame.html @@ -0,0 +1 @@ +encodeQwpIngressCommitFrame | QuestDB JavaScript Client - v4.2.0

    Function encodeQwpIngressCommitFrame

    diff --git a/docs/functions/_questdb_browser-client.encodeQwpIngressFrame.html b/docs/functions/_questdb_browser-client.encodeQwpIngressFrame.html new file mode 100644 index 0000000..efce9ec --- /dev/null +++ b/docs/functions/_questdb_browser-client.encodeQwpIngressFrame.html @@ -0,0 +1,2 @@ +encodeQwpIngressFrame | QuestDB JavaScript Client - v4.2.0

    Function encodeQwpIngressFrame

    diff --git a/docs/functions/_questdb_browser-client.encodeQwpIngressSymbolDictionaryFrame.html b/docs/functions/_questdb_browser-client.encodeQwpIngressSymbolDictionaryFrame.html new file mode 100644 index 0000000..3ea7954 --- /dev/null +++ b/docs/functions/_questdb_browser-client.encodeQwpIngressSymbolDictionaryFrame.html @@ -0,0 +1,2 @@ +encodeQwpIngressSymbolDictionaryFrame | QuestDB JavaScript Client - v4.2.0

    Function encodeQwpIngressSymbolDictionaryFrame

    • Encodes a table-less committed dictionary catch-up frame.

      +

      Parameters

      • startId: number
      • entries: readonly string[]

      Returns Uint8Array

    diff --git a/docs/functions/_questdb_browser-client.encodeQwpQueryRequest.html b/docs/functions/_questdb_browser-client.encodeQwpQueryRequest.html new file mode 100644 index 0000000..0badf5c --- /dev/null +++ b/docs/functions/_questdb_browser-client.encodeQwpQueryRequest.html @@ -0,0 +1,2 @@ +encodeQwpQueryRequest | QuestDB JavaScript Client - v4.2.0

    Function encodeQwpQueryRequest

    diff --git a/docs/functions/_questdb_browser-client.encodeQwpVarint.html b/docs/functions/_questdb_browser-client.encodeQwpVarint.html new file mode 100644 index 0000000..598e67c --- /dev/null +++ b/docs/functions/_questdb_browser-client.encodeQwpVarint.html @@ -0,0 +1 @@ +encodeQwpVarint | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_browser-client.encodeUtf8.html b/docs/functions/_questdb_browser-client.encodeUtf8.html new file mode 100644 index 0000000..c9e0dda --- /dev/null +++ b/docs/functions/_questdb_browser-client.encodeUtf8.html @@ -0,0 +1 @@ +encodeUtf8 | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_browser-client.flattenQwpArray.html b/docs/functions/_questdb_browser-client.flattenQwpArray.html new file mode 100644 index 0000000..f8b0448 --- /dev/null +++ b/docs/functions/_questdb_browser-client.flattenQwpArray.html @@ -0,0 +1 @@ +flattenQwpArray | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_browser-client.float32.html b/docs/functions/_questdb_browser-client.float32.html new file mode 100644 index 0000000..6d5b57c --- /dev/null +++ b/docs/functions/_questdb_browser-client.float32.html @@ -0,0 +1,2 @@ +float32 | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_browser-client.float64.html b/docs/functions/_questdb_browser-client.float64.html new file mode 100644 index 0000000..2734fc7 --- /dev/null +++ b/docs/functions/_questdb_browser-client.float64.html @@ -0,0 +1,2 @@ +float64 | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_browser-client.geohash.html b/docs/functions/_questdb_browser-client.geohash.html new file mode 100644 index 0000000..eb50d12 --- /dev/null +++ b/docs/functions/_questdb_browser-client.geohash.html @@ -0,0 +1,4 @@ +geohash | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_browser-client.int32.html b/docs/functions/_questdb_browser-client.int32.html new file mode 100644 index 0000000..1f5cb49 --- /dev/null +++ b/docs/functions/_questdb_browser-client.int32.html @@ -0,0 +1,4 @@ +int32 | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_browser-client.int64.html b/docs/functions/_questdb_browser-client.int64.html new file mode 100644 index 0000000..cd9eb93 --- /dev/null +++ b/docs/functions/_questdb_browser-client.int64.html @@ -0,0 +1,4 @@ +int64 | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_browser-client.ipv4.html b/docs/functions/_questdb_browser-client.ipv4.html new file mode 100644 index 0000000..f32e37d --- /dev/null +++ b/docs/functions/_questdb_browser-client.ipv4.html @@ -0,0 +1,2 @@ +ipv4 | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_browser-client.isQwpDurableAckWebSocketProtocol.html b/docs/functions/_questdb_browser-client.isQwpDurableAckWebSocketProtocol.html new file mode 100644 index 0000000..b08eb94 --- /dev/null +++ b/docs/functions/_questdb_browser-client.isQwpDurableAckWebSocketProtocol.html @@ -0,0 +1,2 @@ +isQwpDurableAckWebSocketProtocol | QuestDB JavaScript Client - v4.2.0

    Function isQwpDurableAckWebSocketProtocol

    diff --git a/docs/functions/_questdb_browser-client.long.html b/docs/functions/_questdb_browser-client.long.html new file mode 100644 index 0000000..672d415 --- /dev/null +++ b/docs/functions/_questdb_browser-client.long.html @@ -0,0 +1,2 @@ +long | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_browser-client.long256.html b/docs/functions/_questdb_browser-client.long256.html new file mode 100644 index 0000000..d358c92 --- /dev/null +++ b/docs/functions/_questdb_browser-client.long256.html @@ -0,0 +1,2 @@ +long256 | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_browser-client.longArray.html b/docs/functions/_questdb_browser-client.longArray.html new file mode 100644 index 0000000..761b8e6 --- /dev/null +++ b/docs/functions/_questdb_browser-client.longArray.html @@ -0,0 +1,4 @@ +longArray | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_browser-client.qwpDefaultSenderErrorPolicy.html b/docs/functions/_questdb_browser-client.qwpDefaultSenderErrorPolicy.html new file mode 100644 index 0000000..3229ca7 --- /dev/null +++ b/docs/functions/_questdb_browser-client.qwpDefaultSenderErrorPolicy.html @@ -0,0 +1 @@ +qwpDefaultSenderErrorPolicy | QuestDB JavaScript Client - v4.2.0

    Function qwpDefaultSenderErrorPolicy

    diff --git a/docs/functions/_questdb_browser-client.qwpGorillaSize.html b/docs/functions/_questdb_browser-client.qwpGorillaSize.html new file mode 100644 index 0000000..fc07a38 --- /dev/null +++ b/docs/functions/_questdb_browser-client.qwpGorillaSize.html @@ -0,0 +1,2 @@ +qwpGorillaSize | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_browser-client.qwpSenderErrorCategory.html b/docs/functions/_questdb_browser-client.qwpSenderErrorCategory.html new file mode 100644 index 0000000..08b692a --- /dev/null +++ b/docs/functions/_questdb_browser-client.qwpSenderErrorCategory.html @@ -0,0 +1 @@ +qwpSenderErrorCategory | QuestDB JavaScript Client - v4.2.0

    Function qwpSenderErrorCategory

    diff --git a/docs/functions/_questdb_browser-client.qwpVarintSize.html b/docs/functions/_questdb_browser-client.qwpVarintSize.html new file mode 100644 index 0000000..cc0b79f --- /dev/null +++ b/docs/functions/_questdb_browser-client.qwpVarintSize.html @@ -0,0 +1,2 @@ +qwpVarintSize | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_browser-client.readQwpVarint.html b/docs/functions/_questdb_browser-client.readQwpVarint.html new file mode 100644 index 0000000..1bce398 --- /dev/null +++ b/docs/functions/_questdb_browser-client.readQwpVarint.html @@ -0,0 +1,2 @@ +readQwpVarint | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_browser-client.readQwpVarintNumber.html b/docs/functions/_questdb_browser-client.readQwpVarintNumber.html new file mode 100644 index 0000000..ed05629 --- /dev/null +++ b/docs/functions/_questdb_browser-client.readQwpVarintNumber.html @@ -0,0 +1 @@ +readQwpVarintNumber | QuestDB JavaScript Client - v4.2.0

    Function readQwpVarintNumber

    diff --git a/docs/functions/_questdb_browser-client.short.html b/docs/functions/_questdb_browser-client.short.html new file mode 100644 index 0000000..1d9f759 --- /dev/null +++ b/docs/functions/_questdb_browser-client.short.html @@ -0,0 +1,2 @@ +short | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_browser-client.symbol.html b/docs/functions/_questdb_browser-client.symbol.html new file mode 100644 index 0000000..489c8e9 --- /dev/null +++ b/docs/functions/_questdb_browser-client.symbol.html @@ -0,0 +1,2 @@ +symbol | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_browser-client.timestamp.html b/docs/functions/_questdb_browser-client.timestamp.html new file mode 100644 index 0000000..1bf0e93 --- /dev/null +++ b/docs/functions/_questdb_browser-client.timestamp.html @@ -0,0 +1,2 @@ +timestamp | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_browser-client.utf8Length.html b/docs/functions/_questdb_browser-client.utf8Length.html new file mode 100644 index 0000000..caf2844 --- /dev/null +++ b/docs/functions/_questdb_browser-client.utf8Length.html @@ -0,0 +1 @@ +utf8Length | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_browser-client.uuid.html b/docs/functions/_questdb_browser-client.uuid.html new file mode 100644 index 0000000..712fa18 --- /dev/null +++ b/docs/functions/_questdb_browser-client.uuid.html @@ -0,0 +1,2 @@ +uuid | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_browser-client.varchar.html b/docs/functions/_questdb_browser-client.varchar.html new file mode 100644 index 0000000..29c533e --- /dev/null +++ b/docs/functions/_questdb_browser-client.varchar.html @@ -0,0 +1,2 @@ +varchar | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_browser-client.writeQwpFrameHeader.html b/docs/functions/_questdb_browser-client.writeQwpFrameHeader.html new file mode 100644 index 0000000..7ed1f40 --- /dev/null +++ b/docs/functions/_questdb_browser-client.writeQwpFrameHeader.html @@ -0,0 +1 @@ +writeQwpFrameHeader | QuestDB JavaScript Client - v4.2.0

    Function writeQwpFrameHeader

    diff --git a/docs/functions/_questdb_browser-client.writeQwpVarint.html b/docs/functions/_questdb_browser-client.writeQwpVarint.html new file mode 100644 index 0000000..3939ebe --- /dev/null +++ b/docs/functions/_questdb_browser-client.writeQwpVarint.html @@ -0,0 +1,2 @@ +writeQwpVarint | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_nodejs-client.addQwpDurableAckWebSocketProtocol.html b/docs/functions/_questdb_nodejs-client.addQwpDurableAckWebSocketProtocol.html new file mode 100644 index 0000000..5b54859 --- /dev/null +++ b/docs/functions/_questdb_nodejs-client.addQwpDurableAckWebSocketProtocol.html @@ -0,0 +1,2 @@ +addQwpDurableAckWebSocketProtocol | QuestDB JavaScript Client - v4.2.0

    Function addQwpDurableAckWebSocketProtocol

    • Adds the durable-ACK capability token without mutating user options.

      +

      Parameters

      • protocols: string | readonly string[]

      Returns string | string[]

    diff --git a/docs/functions/_questdb_nodejs-client.bigintToTwosComplementBytes.html b/docs/functions/_questdb_nodejs-client.bigintToTwosComplementBytes.html new file mode 100644 index 0000000..63cb98c --- /dev/null +++ b/docs/functions/_questdb_nodejs-client.bigintToTwosComplementBytes.html @@ -0,0 +1,5 @@ +bigintToTwosComplementBytes | QuestDB JavaScript Client - v4.2.0

    Function bigintToTwosComplementBytes

    • Converts a bigint into a two's complement big-endian byte array. +Produces the minimal-width representation that preserves the sign.

      +

      Parameters

      • value: bigint

        The value to serialise

        +

      Returns number[]

      Byte array in big-endian order

      +
    diff --git a/docs/functions/_questdb_nodejs-client.binary.html b/docs/functions/_questdb_nodejs-client.binary.html new file mode 100644 index 0000000..fbba93e --- /dev/null +++ b/docs/functions/_questdb_nodejs-client.binary.html @@ -0,0 +1,2 @@ +binary | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_nodejs-client.bool.html b/docs/functions/_questdb_nodejs-client.bool.html new file mode 100644 index 0000000..996247e --- /dev/null +++ b/docs/functions/_questdb_nodejs-client.bool.html @@ -0,0 +1,2 @@ +bool | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_nodejs-client.byte.html b/docs/functions/_questdb_nodejs-client.byte.html new file mode 100644 index 0000000..f169d23 --- /dev/null +++ b/docs/functions/_questdb_nodejs-client.byte.html @@ -0,0 +1,2 @@ +byte | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_nodejs-client.char.html b/docs/functions/_questdb_nodejs-client.char.html new file mode 100644 index 0000000..5155582 --- /dev/null +++ b/docs/functions/_questdb_nodejs-client.char.html @@ -0,0 +1,2 @@ +char | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_nodejs-client.concatBytes.html b/docs/functions/_questdb_nodejs-client.concatBytes.html new file mode 100644 index 0000000..a312472 --- /dev/null +++ b/docs/functions/_questdb_nodejs-client.concatBytes.html @@ -0,0 +1 @@ +concatBytes | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_nodejs-client.connectQwpNodeClient.html b/docs/functions/_questdb_nodejs-client.connectQwpNodeClient.html new file mode 100644 index 0000000..3ee340b --- /dev/null +++ b/docs/functions/_questdb_nodejs-client.connectQwpNodeClient.html @@ -0,0 +1,3 @@ +connectQwpNodeClient | QuestDB JavaScript Client - v4.2.0

    Function connectQwpNodeClient

    diff --git a/docs/functions/_questdb_nodejs-client.connectQwpNodeEgress.html b/docs/functions/_questdb_nodejs-client.connectQwpNodeEgress.html new file mode 100644 index 0000000..a7a30a9 --- /dev/null +++ b/docs/functions/_questdb_nodejs-client.connectQwpNodeEgress.html @@ -0,0 +1,3 @@ +connectQwpNodeEgress | QuestDB JavaScript Client - v4.2.0

    Function connectQwpNodeEgress

    diff --git a/docs/functions/_questdb_nodejs-client.connectQwpNodeIngress.html b/docs/functions/_questdb_nodejs-client.connectQwpNodeIngress.html new file mode 100644 index 0000000..7ee8516 --- /dev/null +++ b/docs/functions/_questdb_nodejs-client.connectQwpNodeIngress.html @@ -0,0 +1,3 @@ +connectQwpNodeIngress | QuestDB JavaScript Client - v4.2.0

    Function connectQwpNodeIngress

    diff --git a/docs/functions/_questdb_nodejs-client.connectQwpNodeSender.html b/docs/functions/_questdb_nodejs-client.connectQwpNodeSender.html new file mode 100644 index 0000000..083819e --- /dev/null +++ b/docs/functions/_questdb_nodejs-client.connectQwpNodeSender.html @@ -0,0 +1,2 @@ +connectQwpNodeSender | QuestDB JavaScript Client - v4.2.0

    Function connectQwpNodeSender

    diff --git a/docs/functions/_questdb_nodejs-client.connectQwpNodeUdp.html b/docs/functions/_questdb_nodejs-client.connectQwpNodeUdp.html new file mode 100644 index 0000000..e301e89 --- /dev/null +++ b/docs/functions/_questdb_nodejs-client.connectQwpNodeUdp.html @@ -0,0 +1,2 @@ +connectQwpNodeUdp | QuestDB JavaScript Client - v4.2.0

    Function connectQwpNodeUdp

    diff --git a/docs/functions/_questdb_nodejs-client.connectQwpNodeUdpSender.html b/docs/functions/_questdb_nodejs-client.connectQwpNodeUdpSender.html new file mode 100644 index 0000000..d6aa9fe --- /dev/null +++ b/docs/functions/_questdb_nodejs-client.connectQwpNodeUdpSender.html @@ -0,0 +1,2 @@ +connectQwpNodeUdpSender | QuestDB JavaScript Client - v4.2.0

    Function connectQwpNodeUdpSender

    diff --git a/docs/functions/_questdb_nodejs-client.connectQwpNodeWebSocket.html b/docs/functions/_questdb_nodejs-client.connectQwpNodeWebSocket.html new file mode 100644 index 0000000..c05d47c --- /dev/null +++ b/docs/functions/_questdb_nodejs-client.connectQwpNodeWebSocket.html @@ -0,0 +1,2 @@ +connectQwpNodeWebSocket | QuestDB JavaScript Client - v4.2.0

    Function connectQwpNodeWebSocket

    diff --git a/docs/functions/_questdb_nodejs-client.createBuffer.html b/docs/functions/_questdb_nodejs-client.createBuffer.html new file mode 100644 index 0000000..9c77d5b --- /dev/null +++ b/docs/functions/_questdb_nodejs-client.createBuffer.html @@ -0,0 +1,6 @@ +createBuffer | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_nodejs-client.createQwpDataLossSenderError.html b/docs/functions/_questdb_nodejs-client.createQwpDataLossSenderError.html new file mode 100644 index 0000000..96b7741 --- /dev/null +++ b/docs/functions/_questdb_nodejs-client.createQwpDataLossSenderError.html @@ -0,0 +1,2 @@ +createQwpDataLossSenderError | QuestDB JavaScript Client - v4.2.0

    Function createQwpDataLossSenderError

    diff --git a/docs/functions/_questdb_nodejs-client.createQwpNodeClient.html b/docs/functions/_questdb_nodejs-client.createQwpNodeClient.html new file mode 100644 index 0000000..dfdc88d --- /dev/null +++ b/docs/functions/_questdb_nodejs-client.createQwpNodeClient.html @@ -0,0 +1,3 @@ +createQwpNodeClient | QuestDB JavaScript Client - v4.2.0

    Function createQwpNodeClient

    diff --git a/docs/functions/_questdb_nodejs-client.createQwpNodeConnectionFactory.html b/docs/functions/_questdb_nodejs-client.createQwpNodeConnectionFactory.html new file mode 100644 index 0000000..c7e2645 --- /dev/null +++ b/docs/functions/_questdb_nodejs-client.createQwpNodeConnectionFactory.html @@ -0,0 +1,2 @@ +createQwpNodeConnectionFactory | QuestDB JavaScript Client - v4.2.0

    Function createQwpNodeConnectionFactory

    diff --git a/docs/functions/_questdb_nodejs-client.createQwpNodeSender.html b/docs/functions/_questdb_nodejs-client.createQwpNodeSender.html new file mode 100644 index 0000000..8987491 --- /dev/null +++ b/docs/functions/_questdb_nodejs-client.createQwpNodeSender.html @@ -0,0 +1,3 @@ +createQwpNodeSender | QuestDB JavaScript Client - v4.2.0

    Function createQwpNodeSender

    diff --git a/docs/functions/_questdb_nodejs-client.createQwpNodeUdpSender.html b/docs/functions/_questdb_nodejs-client.createQwpNodeUdpSender.html new file mode 100644 index 0000000..71ee519 --- /dev/null +++ b/docs/functions/_questdb_nodejs-client.createQwpNodeUdpSender.html @@ -0,0 +1,4 @@ +createQwpNodeUdpSender | QuestDB JavaScript Client - v4.2.0

    Function createQwpNodeUdpSender

    diff --git a/docs/functions/_questdb_nodejs-client.createQwpProtocolViolationSenderError.html b/docs/functions/_questdb_nodejs-client.createQwpProtocolViolationSenderError.html new file mode 100644 index 0000000..dbf1dbc --- /dev/null +++ b/docs/functions/_questdb_nodejs-client.createQwpProtocolViolationSenderError.html @@ -0,0 +1 @@ +createQwpProtocolViolationSenderError | QuestDB JavaScript Client - v4.2.0

    Function createQwpProtocolViolationSenderError

    diff --git a/docs/functions/_questdb_nodejs-client.createQwpSenderError.html b/docs/functions/_questdb_nodejs-client.createQwpSenderError.html new file mode 100644 index 0000000..f2179e8 --- /dev/null +++ b/docs/functions/_questdb_nodejs-client.createQwpSenderError.html @@ -0,0 +1 @@ +createQwpSenderError | QuestDB JavaScript Client - v4.2.0

    Function createQwpSenderError

    diff --git a/docs/functions/_questdb_nodejs-client.createTransport.html b/docs/functions/_questdb_nodejs-client.createTransport.html new file mode 100644 index 0000000..43da34d --- /dev/null +++ b/docs/functions/_questdb_nodejs-client.createTransport.html @@ -0,0 +1,5 @@ +createTransport | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_nodejs-client.date.html b/docs/functions/_questdb_nodejs-client.date.html new file mode 100644 index 0000000..7e525d6 --- /dev/null +++ b/docs/functions/_questdb_nodejs-client.date.html @@ -0,0 +1,4 @@ +date | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_nodejs-client.decimal128.html b/docs/functions/_questdb_nodejs-client.decimal128.html new file mode 100644 index 0000000..2779439 --- /dev/null +++ b/docs/functions/_questdb_nodejs-client.decimal128.html @@ -0,0 +1,2 @@ +decimal128 | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_nodejs-client.decimal256.html b/docs/functions/_questdb_nodejs-client.decimal256.html new file mode 100644 index 0000000..0ba4586 --- /dev/null +++ b/docs/functions/_questdb_nodejs-client.decimal256.html @@ -0,0 +1,2 @@ +decimal256 | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_nodejs-client.decimal64.html b/docs/functions/_questdb_nodejs-client.decimal64.html new file mode 100644 index 0000000..1c272b1 --- /dev/null +++ b/docs/functions/_questdb_nodejs-client.decimal64.html @@ -0,0 +1,2 @@ +decimal64 | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_nodejs-client.decodeQwpContentEncoding.html b/docs/functions/_questdb_nodejs-client.decodeQwpContentEncoding.html new file mode 100644 index 0000000..4317676 --- /dev/null +++ b/docs/functions/_questdb_nodejs-client.decodeQwpContentEncoding.html @@ -0,0 +1,4 @@ +decodeQwpContentEncoding | QuestDB JavaScript Client - v4.2.0

    Function decodeQwpContentEncoding

    diff --git a/docs/functions/_questdb_nodejs-client.decodeQwpEgressMessage.html b/docs/functions/_questdb_nodejs-client.decodeQwpEgressMessage.html new file mode 100644 index 0000000..59b9b0c --- /dev/null +++ b/docs/functions/_questdb_nodejs-client.decodeQwpEgressMessage.html @@ -0,0 +1,2 @@ +decodeQwpEgressMessage | QuestDB JavaScript Client - v4.2.0

    Function decodeQwpEgressMessage

    diff --git a/docs/functions/_questdb_nodejs-client.decodeQwpFrame.html b/docs/functions/_questdb_nodejs-client.decodeQwpFrame.html new file mode 100644 index 0000000..284e684 --- /dev/null +++ b/docs/functions/_questdb_nodejs-client.decodeQwpFrame.html @@ -0,0 +1 @@ +decodeQwpFrame | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_nodejs-client.decodeQwpIngressResponse.html b/docs/functions/_questdb_nodejs-client.decodeQwpIngressResponse.html new file mode 100644 index 0000000..e374999 --- /dev/null +++ b/docs/functions/_questdb_nodejs-client.decodeQwpIngressResponse.html @@ -0,0 +1,2 @@ +decodeQwpIngressResponse | QuestDB JavaScript Client - v4.2.0

    Function decodeQwpIngressResponse

    diff --git a/docs/functions/_questdb_nodejs-client.decodeQwpIngressServerInfo.html b/docs/functions/_questdb_nodejs-client.decodeQwpIngressServerInfo.html new file mode 100644 index 0000000..2c072cd --- /dev/null +++ b/docs/functions/_questdb_nodejs-client.decodeQwpIngressServerInfo.html @@ -0,0 +1,2 @@ +decodeQwpIngressServerInfo | QuestDB JavaScript Client - v4.2.0

    Function decodeQwpIngressServerInfo

    diff --git a/docs/functions/_questdb_nodejs-client.decodeQwpIngressSymbolDictionaryDelta.html b/docs/functions/_questdb_nodejs-client.decodeQwpIngressSymbolDictionaryDelta.html new file mode 100644 index 0000000..322a031 --- /dev/null +++ b/docs/functions/_questdb_nodejs-client.decodeQwpIngressSymbolDictionaryDelta.html @@ -0,0 +1,2 @@ +decodeQwpIngressSymbolDictionaryDelta | QuestDB JavaScript Client - v4.2.0

    Function decodeQwpIngressSymbolDictionaryDelta

    diff --git a/docs/functions/_questdb_nodejs-client.decodeQwpVarint.html b/docs/functions/_questdb_nodejs-client.decodeQwpVarint.html new file mode 100644 index 0000000..a8a4ae1 --- /dev/null +++ b/docs/functions/_questdb_nodejs-client.decodeQwpVarint.html @@ -0,0 +1 @@ +decodeQwpVarint | QuestDB JavaScript Client - v4.2.0
    • Parameters

      • bytes: Uint8Array
      • offset: number = 0

      Returns { offset: number; value: bigint }

    diff --git a/docs/functions/_questdb_nodejs-client.decodeUtf8.html b/docs/functions/_questdb_nodejs-client.decodeUtf8.html new file mode 100644 index 0000000..70c9bcb --- /dev/null +++ b/docs/functions/_questdb_nodejs-client.decodeUtf8.html @@ -0,0 +1 @@ +decodeUtf8 | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_nodejs-client.decompressQwpZstdFrame.html b/docs/functions/_questdb_nodejs-client.decompressQwpZstdFrame.html new file mode 100644 index 0000000..87a7b3d --- /dev/null +++ b/docs/functions/_questdb_nodejs-client.decompressQwpZstdFrame.html @@ -0,0 +1,2 @@ +decompressQwpZstdFrame | QuestDB JavaScript Client - v4.2.0

    Function decompressQwpZstdFrame

    diff --git a/docs/functions/_questdb_nodejs-client.defaultQwpSenderErrorHandler.html b/docs/functions/_questdb_nodejs-client.defaultQwpSenderErrorHandler.html new file mode 100644 index 0000000..820c64f --- /dev/null +++ b/docs/functions/_questdb_nodejs-client.defaultQwpSenderErrorHandler.html @@ -0,0 +1,3 @@ +defaultQwpSenderErrorHandler | QuestDB JavaScript Client - v4.2.0

    Function defaultQwpSenderErrorHandler

    diff --git a/docs/functions/_questdb_nodejs-client.designatedTimestamp.html b/docs/functions/_questdb_nodejs-client.designatedTimestamp.html new file mode 100644 index 0000000..532df8a --- /dev/null +++ b/docs/functions/_questdb_nodejs-client.designatedTimestamp.html @@ -0,0 +1,2 @@ +designatedTimestamp | QuestDB JavaScript Client - v4.2.0

    Function designatedTimestamp

    diff --git a/docs/functions/_questdb_nodejs-client.double.html b/docs/functions/_questdb_nodejs-client.double.html new file mode 100644 index 0000000..d4ba984 --- /dev/null +++ b/docs/functions/_questdb_nodejs-client.double.html @@ -0,0 +1,2 @@ +double | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_nodejs-client.doubleArray.html b/docs/functions/_questdb_nodejs-client.doubleArray.html new file mode 100644 index 0000000..5fd7e5b --- /dev/null +++ b/docs/functions/_questdb_nodejs-client.doubleArray.html @@ -0,0 +1,2 @@ +doubleArray | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_nodejs-client.encodeQwpAcceptEncoding.html b/docs/functions/_questdb_nodejs-client.encodeQwpAcceptEncoding.html new file mode 100644 index 0000000..d975902 --- /dev/null +++ b/docs/functions/_questdb_nodejs-client.encodeQwpAcceptEncoding.html @@ -0,0 +1,2 @@ +encodeQwpAcceptEncoding | QuestDB JavaScript Client - v4.2.0

    Function encodeQwpAcceptEncoding

    diff --git a/docs/functions/_questdb_nodejs-client.encodeQwpBinds.html b/docs/functions/_questdb_nodejs-client.encodeQwpBinds.html new file mode 100644 index 0000000..5f769f1 --- /dev/null +++ b/docs/functions/_questdb_nodejs-client.encodeQwpBinds.html @@ -0,0 +1,2 @@ +encodeQwpBinds | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_nodejs-client.encodeQwpCancel.html b/docs/functions/_questdb_nodejs-client.encodeQwpCancel.html new file mode 100644 index 0000000..0569d49 --- /dev/null +++ b/docs/functions/_questdb_nodejs-client.encodeQwpCancel.html @@ -0,0 +1,2 @@ +encodeQwpCancel | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_nodejs-client.encodeQwpCredit.html b/docs/functions/_questdb_nodejs-client.encodeQwpCredit.html new file mode 100644 index 0000000..964b1b6 --- /dev/null +++ b/docs/functions/_questdb_nodejs-client.encodeQwpCredit.html @@ -0,0 +1,2 @@ +encodeQwpCredit | QuestDB JavaScript Client - v4.2.0
    • Encodes the unframed client-to-server CREDIT payload.

      +

      Parameters

      • request: number | bigint
      • additionalBytes: number | bigint

      Returns Uint8Array

    diff --git a/docs/functions/_questdb_nodejs-client.encodeQwpDurableAckPollFrame.html b/docs/functions/_questdb_nodejs-client.encodeQwpDurableAckPollFrame.html new file mode 100644 index 0000000..b68b79b --- /dev/null +++ b/docs/functions/_questdb_nodejs-client.encodeQwpDurableAckPollFrame.html @@ -0,0 +1,2 @@ +encodeQwpDurableAckPollFrame | QuestDB JavaScript Client - v4.2.0

    Function encodeQwpDurableAckPollFrame

    diff --git a/docs/functions/_questdb_nodejs-client.encodeQwpFrame.html b/docs/functions/_questdb_nodejs-client.encodeQwpFrame.html new file mode 100644 index 0000000..2a245d6 --- /dev/null +++ b/docs/functions/_questdb_nodejs-client.encodeQwpFrame.html @@ -0,0 +1 @@ +encodeQwpFrame | QuestDB JavaScript Client - v4.2.0
    • Parameters

      • payload: Uint8Array
      • flags: number = 0
      • tableCount: number = 0

      Returns Uint8Array

    diff --git a/docs/functions/_questdb_nodejs-client.encodeQwpGorilla.html b/docs/functions/_questdb_nodejs-client.encodeQwpGorilla.html new file mode 100644 index 0000000..30fa0e7 --- /dev/null +++ b/docs/functions/_questdb_nodejs-client.encodeQwpGorilla.html @@ -0,0 +1,2 @@ +encodeQwpGorilla | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_nodejs-client.encodeQwpIngressCommitFrame.html b/docs/functions/_questdb_nodejs-client.encodeQwpIngressCommitFrame.html new file mode 100644 index 0000000..3d1cfe7 --- /dev/null +++ b/docs/functions/_questdb_nodejs-client.encodeQwpIngressCommitFrame.html @@ -0,0 +1 @@ +encodeQwpIngressCommitFrame | QuestDB JavaScript Client - v4.2.0

    Function encodeQwpIngressCommitFrame

    diff --git a/docs/functions/_questdb_nodejs-client.encodeQwpIngressFrame.html b/docs/functions/_questdb_nodejs-client.encodeQwpIngressFrame.html new file mode 100644 index 0000000..591676a --- /dev/null +++ b/docs/functions/_questdb_nodejs-client.encodeQwpIngressFrame.html @@ -0,0 +1,2 @@ +encodeQwpIngressFrame | QuestDB JavaScript Client - v4.2.0

    Function encodeQwpIngressFrame

    diff --git a/docs/functions/_questdb_nodejs-client.encodeQwpIngressSymbolDictionaryFrame.html b/docs/functions/_questdb_nodejs-client.encodeQwpIngressSymbolDictionaryFrame.html new file mode 100644 index 0000000..3fe04a1 --- /dev/null +++ b/docs/functions/_questdb_nodejs-client.encodeQwpIngressSymbolDictionaryFrame.html @@ -0,0 +1,2 @@ +encodeQwpIngressSymbolDictionaryFrame | QuestDB JavaScript Client - v4.2.0

    Function encodeQwpIngressSymbolDictionaryFrame

    • Encodes a table-less committed dictionary catch-up frame.

      +

      Parameters

      • startId: number
      • entries: readonly string[]

      Returns Uint8Array

    diff --git a/docs/functions/_questdb_nodejs-client.encodeQwpQueryRequest.html b/docs/functions/_questdb_nodejs-client.encodeQwpQueryRequest.html new file mode 100644 index 0000000..e92e1dc --- /dev/null +++ b/docs/functions/_questdb_nodejs-client.encodeQwpQueryRequest.html @@ -0,0 +1,2 @@ +encodeQwpQueryRequest | QuestDB JavaScript Client - v4.2.0

    Function encodeQwpQueryRequest

    diff --git a/docs/functions/_questdb_nodejs-client.encodeQwpVarint.html b/docs/functions/_questdb_nodejs-client.encodeQwpVarint.html new file mode 100644 index 0000000..8cc6c04 --- /dev/null +++ b/docs/functions/_questdb_nodejs-client.encodeQwpVarint.html @@ -0,0 +1 @@ +encodeQwpVarint | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_nodejs-client.encodeUtf8.html b/docs/functions/_questdb_nodejs-client.encodeUtf8.html new file mode 100644 index 0000000..bb58e7f --- /dev/null +++ b/docs/functions/_questdb_nodejs-client.encodeUtf8.html @@ -0,0 +1 @@ +encodeUtf8 | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_nodejs-client.flattenQwpArray.html b/docs/functions/_questdb_nodejs-client.flattenQwpArray.html new file mode 100644 index 0000000..c0a4d96 --- /dev/null +++ b/docs/functions/_questdb_nodejs-client.flattenQwpArray.html @@ -0,0 +1 @@ +flattenQwpArray | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_nodejs-client.float32.html b/docs/functions/_questdb_nodejs-client.float32.html new file mode 100644 index 0000000..2ba6a61 --- /dev/null +++ b/docs/functions/_questdb_nodejs-client.float32.html @@ -0,0 +1,2 @@ +float32 | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_nodejs-client.float64.html b/docs/functions/_questdb_nodejs-client.float64.html new file mode 100644 index 0000000..96f3cde --- /dev/null +++ b/docs/functions/_questdb_nodejs-client.float64.html @@ -0,0 +1,2 @@ +float64 | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_nodejs-client.geohash.html b/docs/functions/_questdb_nodejs-client.geohash.html new file mode 100644 index 0000000..62dbbad --- /dev/null +++ b/docs/functions/_questdb_nodejs-client.geohash.html @@ -0,0 +1,4 @@ +geohash | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_nodejs-client.int32.html b/docs/functions/_questdb_nodejs-client.int32.html new file mode 100644 index 0000000..bedd1ce --- /dev/null +++ b/docs/functions/_questdb_nodejs-client.int32.html @@ -0,0 +1,4 @@ +int32 | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_nodejs-client.int64.html b/docs/functions/_questdb_nodejs-client.int64.html new file mode 100644 index 0000000..48d1b1c --- /dev/null +++ b/docs/functions/_questdb_nodejs-client.int64.html @@ -0,0 +1,4 @@ +int64 | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_nodejs-client.ipv4.html b/docs/functions/_questdb_nodejs-client.ipv4.html new file mode 100644 index 0000000..02cc613 --- /dev/null +++ b/docs/functions/_questdb_nodejs-client.ipv4.html @@ -0,0 +1,2 @@ +ipv4 | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_nodejs-client.isQwpDurableAckWebSocketProtocol.html b/docs/functions/_questdb_nodejs-client.isQwpDurableAckWebSocketProtocol.html new file mode 100644 index 0000000..cc1b0a9 --- /dev/null +++ b/docs/functions/_questdb_nodejs-client.isQwpDurableAckWebSocketProtocol.html @@ -0,0 +1,2 @@ +isQwpDurableAckWebSocketProtocol | QuestDB JavaScript Client - v4.2.0

    Function isQwpDurableAckWebSocketProtocol

    diff --git a/docs/functions/_questdb_nodejs-client.long.html b/docs/functions/_questdb_nodejs-client.long.html new file mode 100644 index 0000000..86a342d --- /dev/null +++ b/docs/functions/_questdb_nodejs-client.long.html @@ -0,0 +1,2 @@ +long | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_nodejs-client.long256.html b/docs/functions/_questdb_nodejs-client.long256.html new file mode 100644 index 0000000..c23d925 --- /dev/null +++ b/docs/functions/_questdb_nodejs-client.long256.html @@ -0,0 +1,2 @@ +long256 | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_nodejs-client.longArray.html b/docs/functions/_questdb_nodejs-client.longArray.html new file mode 100644 index 0000000..4844a01 --- /dev/null +++ b/docs/functions/_questdb_nodejs-client.longArray.html @@ -0,0 +1,4 @@ +longArray | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_nodejs-client.parseQwpNodeClientConfig.html b/docs/functions/_questdb_nodejs-client.parseQwpNodeClientConfig.html new file mode 100644 index 0000000..6ca28ed --- /dev/null +++ b/docs/functions/_questdb_nodejs-client.parseQwpNodeClientConfig.html @@ -0,0 +1,2 @@ +parseQwpNodeClientConfig | QuestDB JavaScript Client - v4.2.0

    Function parseQwpNodeClientConfig

    diff --git a/docs/functions/_questdb_nodejs-client.qwpDefaultSenderErrorPolicy.html b/docs/functions/_questdb_nodejs-client.qwpDefaultSenderErrorPolicy.html new file mode 100644 index 0000000..cc64f21 --- /dev/null +++ b/docs/functions/_questdb_nodejs-client.qwpDefaultSenderErrorPolicy.html @@ -0,0 +1 @@ +qwpDefaultSenderErrorPolicy | QuestDB JavaScript Client - v4.2.0

    Function qwpDefaultSenderErrorPolicy

    diff --git a/docs/functions/_questdb_nodejs-client.qwpGorillaSize.html b/docs/functions/_questdb_nodejs-client.qwpGorillaSize.html new file mode 100644 index 0000000..5407357 --- /dev/null +++ b/docs/functions/_questdb_nodejs-client.qwpGorillaSize.html @@ -0,0 +1,2 @@ +qwpGorillaSize | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_nodejs-client.qwpSenderErrorCategory.html b/docs/functions/_questdb_nodejs-client.qwpSenderErrorCategory.html new file mode 100644 index 0000000..ce781c9 --- /dev/null +++ b/docs/functions/_questdb_nodejs-client.qwpSenderErrorCategory.html @@ -0,0 +1 @@ +qwpSenderErrorCategory | QuestDB JavaScript Client - v4.2.0

    Function qwpSenderErrorCategory

    diff --git a/docs/functions/_questdb_nodejs-client.qwpVarintSize.html b/docs/functions/_questdb_nodejs-client.qwpVarintSize.html new file mode 100644 index 0000000..578e5f7 --- /dev/null +++ b/docs/functions/_questdb_nodejs-client.qwpVarintSize.html @@ -0,0 +1,2 @@ +qwpVarintSize | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_nodejs-client.readQwpVarint.html b/docs/functions/_questdb_nodejs-client.readQwpVarint.html new file mode 100644 index 0000000..e0f4b7b --- /dev/null +++ b/docs/functions/_questdb_nodejs-client.readQwpVarint.html @@ -0,0 +1,2 @@ +readQwpVarint | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_nodejs-client.readQwpVarintNumber.html b/docs/functions/_questdb_nodejs-client.readQwpVarintNumber.html new file mode 100644 index 0000000..7faa300 --- /dev/null +++ b/docs/functions/_questdb_nodejs-client.readQwpVarintNumber.html @@ -0,0 +1 @@ +readQwpVarintNumber | QuestDB JavaScript Client - v4.2.0

    Function readQwpVarintNumber

    diff --git a/docs/functions/_questdb_nodejs-client.retryQwpNodeOrphanSlot.html b/docs/functions/_questdb_nodejs-client.retryQwpNodeOrphanSlot.html new file mode 100644 index 0000000..c401945 --- /dev/null +++ b/docs/functions/_questdb_nodejs-client.retryQwpNodeOrphanSlot.html @@ -0,0 +1,2 @@ +retryQwpNodeOrphanSlot | QuestDB JavaScript Client - v4.2.0

    Function retryQwpNodeOrphanSlot

    diff --git a/docs/functions/_questdb_nodejs-client.scanQwpNodeOrphanSlots.html b/docs/functions/_questdb_nodejs-client.scanQwpNodeOrphanSlots.html new file mode 100644 index 0000000..6148ccf --- /dev/null +++ b/docs/functions/_questdb_nodejs-client.scanQwpNodeOrphanSlots.html @@ -0,0 +1,5 @@ +scanQwpNodeOrphanSlots | QuestDB JavaScript Client - v4.2.0

    Function scanQwpNodeOrphanSlots

    • Returns child replay slots containing unacknowledged records.

      +

      The scan is deliberately read-only and does not inspect lock ownership. +Adoption obtains the replay store's exclusive lock, closing the race with a +live foreground producer or another drainer.

      +

      Parameters

      • rootDirectory: string
      • OptionalexcludeSlot: (slotName: string) => boolean

      Returns Promise<readonly string[]>

    diff --git a/docs/functions/_questdb_nodejs-client.short.html b/docs/functions/_questdb_nodejs-client.short.html new file mode 100644 index 0000000..f671d9c --- /dev/null +++ b/docs/functions/_questdb_nodejs-client.short.html @@ -0,0 +1,2 @@ +short | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_nodejs-client.symbol.html b/docs/functions/_questdb_nodejs-client.symbol.html new file mode 100644 index 0000000..dd5fb97 --- /dev/null +++ b/docs/functions/_questdb_nodejs-client.symbol.html @@ -0,0 +1,2 @@ +symbol | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_nodejs-client.timestamp.html b/docs/functions/_questdb_nodejs-client.timestamp.html new file mode 100644 index 0000000..ba9620d --- /dev/null +++ b/docs/functions/_questdb_nodejs-client.timestamp.html @@ -0,0 +1,2 @@ +timestamp | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_nodejs-client.utf8Length.html b/docs/functions/_questdb_nodejs-client.utf8Length.html new file mode 100644 index 0000000..1bca6b3 --- /dev/null +++ b/docs/functions/_questdb_nodejs-client.utf8Length.html @@ -0,0 +1 @@ +utf8Length | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_nodejs-client.uuid.html b/docs/functions/_questdb_nodejs-client.uuid.html new file mode 100644 index 0000000..5add3be --- /dev/null +++ b/docs/functions/_questdb_nodejs-client.uuid.html @@ -0,0 +1,2 @@ +uuid | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_nodejs-client.varchar.html b/docs/functions/_questdb_nodejs-client.varchar.html new file mode 100644 index 0000000..0644c23 --- /dev/null +++ b/docs/functions/_questdb_nodejs-client.varchar.html @@ -0,0 +1,2 @@ +varchar | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_nodejs-client.writeQwpFrameHeader.html b/docs/functions/_questdb_nodejs-client.writeQwpFrameHeader.html new file mode 100644 index 0000000..fa7688e --- /dev/null +++ b/docs/functions/_questdb_nodejs-client.writeQwpFrameHeader.html @@ -0,0 +1 @@ +writeQwpFrameHeader | QuestDB JavaScript Client - v4.2.0

    Function writeQwpFrameHeader

    diff --git a/docs/functions/_questdb_nodejs-client.writeQwpVarint.html b/docs/functions/_questdb_nodejs-client.writeQwpVarint.html new file mode 100644 index 0000000..6d60996 --- /dev/null +++ b/docs/functions/_questdb_nodejs-client.writeQwpVarint.html @@ -0,0 +1,2 @@ +writeQwpVarint | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/bigintToTwosComplementBytes.html b/docs/functions/bigintToTwosComplementBytes.html deleted file mode 100644 index 1d6d357..0000000 --- a/docs/functions/bigintToTwosComplementBytes.html +++ /dev/null @@ -1,5 +0,0 @@ -bigintToTwosComplementBytes | QuestDB Node.js Client - v4.2.0

    Function bigintToTwosComplementBytes

    • Converts a bigint into a two's complement big-endian byte array. -Produces the minimal-width representation that preserves the sign.

      -

      Parameters

      • value: bigint

        The value to serialise

        -

      Returns number[]

      Byte array in big-endian order

      -
    diff --git a/docs/functions/createBuffer.html b/docs/functions/createBuffer.html deleted file mode 100644 index 750c9d9..0000000 --- a/docs/functions/createBuffer.html +++ /dev/null @@ -1,6 +0,0 @@ -createBuffer | QuestDB Node.js Client - v4.2.0

    Function createBuffer

    • Factory function to create a SenderBuffer instance based on the protocol version.

      -

      Parameters

      • options: SenderOptions

        Sender configuration object. -See SenderOptions documentation for detailed description of configuration options.

        -

      Returns SenderBuffer

      A SenderBuffer instance appropriate for the specified protocol version

      -

      Error if protocol version is not specified or is unsupported

      -
    diff --git a/docs/functions/createTransport.html b/docs/functions/createTransport.html deleted file mode 100644 index 2ed49e7..0000000 --- a/docs/functions/createTransport.html +++ /dev/null @@ -1,5 +0,0 @@ -createTransport | QuestDB Node.js Client - v4.2.0

    Function createTransport

    • Factory function to create appropriate transport instance based on configuration.

      -

      Parameters

      • options: SenderOptions

        Sender configuration options including protocol and connection details

        -

      Returns SenderTransport

      Transport instance appropriate for the specified protocol

      -

      Error if protocol or host options are missing or invalid

      -
    diff --git a/docs/hierarchy.html b/docs/hierarchy.html index a00c719..4369450 100644 --- a/docs/hierarchy.html +++ b/docs/hierarchy.html @@ -1 +1 @@ -QuestDB Node.js Client - v4.2.0

    QuestDB Node.js Client - v4.2.0

    Hierarchy Summary

    +QuestDB JavaScript Client - v4.2.0

    QuestDB JavaScript Client - v4.2.0

    Hierarchy Summary

    diff --git a/docs/index.html b/docs/index.html index 95d10bf..337c54d 100644 --- a/docs/index.html +++ b/docs/index.html @@ -1,4 +1,12 @@ -QuestDB Node.js Client - v4.2.0

    QuestDB Node.js Client - v4.2.0

    # With npm
    npm i -s @questdb/nodejs-client

    # With yarn
    yarn add @questdb/nodejs-client

    # With pnpm
    pnpm add @questdb/nodejs-client +QuestDB JavaScript Client - v4.2.0

    QuestDB JavaScript Client - v4.2.0

    QuestDB JavaScript Client

    This repository builds two runtime-specific npm packages from a shared private +core: @questdb/nodejs-client for Node.js and @questdb/browser-client for +browsers. The browser package exposes its complete API from its package root and +does not include Node.js transports or dependencies.

    +
    # With npm
    npm i -s @questdb/nodejs-client

    # With yarn
    yarn add @questdb/nodejs-client

    # With pnpm
    pnpm add @questdb/nodejs-client +
    + +

    For browser applications:

    +
    npm install @questdb/browser-client
     
    @@ -28,25 +36,252 @@

    The examples below demonstrate how to use the client.
    -For more details, please, check the Sender's documentation.

    +For more details, see the Sender documentation.

    import { Sender } from "@questdb/nodejs-client";

    async function run() {
    // create a sender using HTTP protocol
    const sender = await Sender.fromConfig("http::addr=127.0.0.1:9000");

    // add rows to the buffer of the sender
    await sender
    .table("trades")
    .symbol("symbol", "BTC-USD")
    .symbol("side", "sell")
    .floatColumn("price", 39269.98)
    .floatColumn("amount", 0.011)
    .at(Date.now(), "ms");

    // flush the buffer of the sender, sending the data to QuestDB
    // the buffer is cleared after the data is sent, and the sender is ready to accept new data
    await sender.flush();

    // close the connection after all rows ingested
    // unflushed data will be lost
    await sender.close();
    }

    run().then(console.log).catch(console.error);
    -
    import { Sender } from "@questdb/nodejs-client";

    async function run() {
    // authentication details
    const USER = "admin";
    const PWD = "quest";

    // pass the authentication details to the sender
    // for secure connection use 'https' protocol instead of 'http'
    const sender = await Sender.fromConfig(
    `http::addr=127.0.0.1:9000;username=${USER};password=${PWD}`
    );

    // add rows to the buffer of the sender
    await sender
    .table("trades")
    .symbol("symbol", "ETH-USD")
    .symbol("side", "sell")
    .floatColumn("price", 2615.54)
    .floatColumn("amount", 0.00044)
    .at(Date.now(), "ms");

    // flush the buffer of the sender, sending the data to QuestDB
    await sender.flush();

    // close the connection after all rows ingested
    await sender.close();
    }

    run().catch(console.error); +

    Passing null or undefined as a column or symbol value omits that column from +the row, and QuestDB records the omission as NULL. This is the model the QuestDB +clients share — the Java client puts it as "to mark the value NULL, omit the +column from the row" — with the JavaScript client doing the omission for you, so a +record with optional fields needs no branching:

    +
    const trade: { side?: string; amount?: number } = { amount: 0.011 };

    await sender
    .table("trades")
    .symbol("symbol", "BTC-USD")
    .symbol("side", trade.side) // undefined -> column omitted -> NULL
    .floatColumn("price", 39269.98)
    .floatColumn("amount", trade.amount)
    .at(Date.now(), "ms");
    // wire: trades,symbol=BTC-USD price=39269.98,amount=0.011 <timestamp> +
    + +

    The eight column methods on Sender follow this rule for both ILP +(http/https/tcp/tcps) and QWP (ws/wss/udp) transports, subject to +protocol support. The broader direct QwpSender API and compiled QWP writers +follow the same omission rule for their additional column types. Capability +checks still run for nullish values: ILP v1 always rejects arrayColumn, and ILP +v1/v2 always reject the decimal column methods. The QWP-only +QwpSender.long256Column method spreads one value over four arguments; it omits +the column when all four words are nullish and rejects a partial set rather +than treating it as NULL.

    +

    Three consequences are worth knowing:

    +
      +
    • An omitted column is not created on a table that does not already have it. The +omission carries no type, so schema-on-write has nothing to infer from.
    • +
    • A row in which every value is nullish behaves differently per protocol. ILP +has no way to encode a row with no fields, so at()/atNow() rejects it with +"The row must have a symbol or column set before it is closed". QWP is +columnar and can express it, so the row is sent with no columns — carrying +only its designated timestamp.
    • +
    • A rejected at()/atNow() on ILP discards the row it could not close, +including its table name, and leaves rows already in the buffer alone. Catch +the error and start the next row from table(); there is no need to reset() +and nothing already buffered is lost. The exception is an invalid designated +timestamp unit: it is rejected before closing begins, leaving the row open so +at() can be retried with a valid unit. If an ILP auto-flush send fails, the +completed batch has already been removed from the sender buffer; applications +that need to retry must retain and resubmit those rows. QWP keeps successfully +closed rows for its retry and replay path.
    • +
    +

    Changed in this release. Earlier versions threw a type error for most +nullish values, and protocol v2 encoded arrayColumn(name, null) as an explicit +NULL array marker. Supported column methods now omit the column instead. If your +code relied on the throw as a data-quality guard, validate before calling the +sender.

    +

    See the complete QWP guide for ingress and egress APIs, the combined +pooled client, browser authentication, delivery semantics, migration guidance, and +the public API policy.

    +

    Node.js applications can select QWP through the regular Sender API:

    +
    import { Sender } from "@questdb/nodejs-client";

    const sender = await Sender.fromConfig("ws::addr=127.0.0.1:9000");
    await sender.connect();
    await sender
    .table("trades")
    .symbol("symbol", "ETH-USD")
    .floatColumn("price", 2615.54)
    .at(Date.now(), "ms");
    await sender.flush();
    await sender.close(); +
    + +

    For repeated object rows, compile the table schema once. The resulting writer +validates each complete row before changing sender state and accepts both individual +rows and synchronous or asynchronous iterables:

    +
    import * as qwp from "@questdb/nodejs-client";

    const trades = sender.writer("trades", {
    symbol: qwp.symbol(),
    side: qwp.symbol(),
    price: qwp.double(),
    quantity: qwp.long(),
    timestamp: qwp.designatedTimestamp("ns"),
    });

    await trades.row({
    symbol: "ETH-USD",
    side: "sell",
    price: 2615.54,
    quantity: 42n,
    timestamp: 1_723_000_000_000_000_000n,
    });
    await trades.rows(moreTrades); +
    + +

    The schema vocabulary covers every QuestDB column type the fluent row API can write, +including date(), char(), binary(), uuid(), long256(), ipv4(), +geohash(precisionBits), decimal64/128/256(scale), doubleArray(), and +longArray(). See QWP.md for the accepted value +forms of each field.

    +

    The regular Sender accepts the same unified QWP configuration vocabulary as +the pooled Node client. Use comma-separated or repeated addr values for +failover; standalone ingress validates but otherwise ignores egress- and +pool-only keys.

    +

    Node.js also supports fire-and-forget QWP-over-UDP through the same API:

    +
    const sender = await Sender.fromConfig(
    "udp::addr=239.1.2.3:9007;max_datagram_size=1400;multicast_ttl=1",
    );
    await sender.connect();
    await sender
    .table("trades")
    .symbol("symbol", "ETH-USD")
    .floatColumn("price", 2615.54)
    .atNow();
    await sender.close(); +
    + +

    UDP datagrams are self-contained and split at row boundaries. UDP has no +authentication, acknowledgements, transactions, retry, or store-and-forward and is +not available in browsers. See the QWP guide for the lower-level Node UDP API.

    +

    QWP flush() resolves at the local publication boundary by default in both +Node.js and browsers, matching the Java QWP sender. Set +qwp.sender.awaitServerAck: true to wait for QuestDB's protocol ACK instead, +or awaitDurableAck: true to wait through durable upload. When Node QWP is +configured with qwp.webSocket.storeAndForward, the publication boundary is +the local durable journal, so the sender can accept flushes while QuestDB is +offline and a background drainer reconnects and sends them in order. +Set initialConnectMode to "off" (the default), "sync", or "async" to +choose fail-fast, bounded blocking, or background startup. Supplying reconnect +budget settings without an explicit mode promotes initial startup to "sync", +matching the Java client. The configuration-string +equivalent is initial_connect_retry, used together with the store-and-forward +options in extraOptions.qwp. +Persistent frames are coalesced into fixed-size 4 MiB .sfa segments by default, +using the shared Java/Rust/Python SFA envelope, manifest, ACK watermark, and symbol +dictionary formats. The active segment and a pre-sized temporary hot spare keep open +handles. A shared worker provisions spares, checkpoints files, and trims acknowledged +segments. Recovery keeps only frame offsets in memory and reads payloads from disk as +they are sent, so a large persisted backlog is not duplicated on the JavaScript heap. +Set drainOrphans: true when sibling journal directories share a dedicated parent: +the Node client scans and drains slots left by failed producer processes with bounded +concurrency. Pooled QWP clients recover idle in-range and out-of-range sender-N +slots automatically without raising senderPoolMin, including leftovers after +senderPoolMax is reduced. Terminally bad slots are marked .failed for inspection +and can be re-enabled with +retryQwpNodeOrphanSlot(). This persistent mode is Node-only; browser senders +use the in-memory replay boundary.

    +

    Browser applications use the browser entry point, which has no Node.js +dependencies. Cookies are supplied by the browser during a same-origin +WebSocket upgrade. Browser and non-persistent Node ingress reconnect by default and +retain unacknowledged frames in memory; set reconnect: false in the session options +for a fixed connection. Only Node store-and-forward survives process failure.

    +
    import { connectQwpBrowserSender } from "@questdb/browser-client";

    const url = new URL("/write/v4", location.href);
    url.protocol = location.protocol === "https:" ? "wss:" : "ws:";
    const sender = await connectQwpBrowserSender({ url }, { autoFlush: false });
    await sender.table("events").longColumn("value", 42n).atNow();
    await sender.flush();
    await sender.close(); +
    + +

    For batches larger than the automatic flush threshold, transactional mode +keeps each auto-flushed frame in an open server-side transaction. An explicit +flush() (or its commit() alias) publishes the group-closing frame. Set +awaitServerAck: true, or wait on the sequence returned by +flushAndGetSequence(), when the call must also observe the cumulative ACK. +QuestDB guarantees this atomicity per table; a flush that contains multiple +tables is not one cross-table transaction.

    +
    const sender = await connectQwpBrowserSender(
    { url },
    {
    autoFlushRows: 10_000,
    autoFlushBytes: 4 * 1024 * 1024,
    transactional: true,
    },
    );

    for (const event of events) {
    await sender
    .table("events")
    .symbol("source", event.source)
    .longColumn("value", event.value)
    .at(event.timestamp, "ms");
    }
    await sender.commit();
    await sender.close(); +
    + +

    QWP close() publishes completed rows and waits up to 5 seconds for their +committed-frame ACK watermark. Configure closeFlushTimeoutMs (or +close_flush_timeout_millis in a ws:: string); 0 publishes without waiting. +An unfinished row is not completed implicitly.

    +

    The server intentionally withholds ACKs for deferred frames until commit. The +sender pipelines transactional auto-flushes without waiting for those ACKs, +then publishes the group-closing frame at flush()/commit(). With +awaitServerAck or awaitDurableAck, that call also waits for all covered +ACKs; durable waiting starts only after the transaction commits. Closing +without an explicit commit abandons the open transaction and logs a warning; +QuestDB rolls it back when the WebSocket disconnects.

    +

    Ingress sessions expose browser-safe progress/error callbacks and immutable +metrics snapshots. Reconnect events remain on reconnect.onEvent, keeping +connection topology separate from batch acceptance and durable progress.

    +
    import {
    QWP_INGRESS_PROGRESS_KIND,
    createQwpBrowserSender,
    } from "@questdb/browser-client";

    const sender = createQwpBrowserSender(
    { url },
    { autoFlush: false },
    {
    reconnect: {
    onEvent: (event) => console.info("QWP connection", event),
    },
    onProgress: (event) => {
    if (event.kind === QWP_INGRESS_PROGRESS_KIND.ACKNOWLEDGED) {
    console.info("accepted through", event.sequence);
    }
    },
    onError: (event) => console.error("QWP ingress", event.error),
    onSenderError: (error) =>
    console.error(
    "QWP rejection",
    error.category,
    error.appliedPolicy,
    error.fromFsn,
    error.toFsn,
    ),
    },
    );

    await sender.connect();
    const snapshot = sender.metrics;
    console.info(
    snapshot.totalRowsPublished,
    snapshot.ingress?.totalFramesReplayed,
    ); +
    + +

    Snapshots distinguish the client-session acceptance sequence from persistent +replay watermarks. With durable ACKs, replayAcknowledgedFrameSequence +advances only after the durable watermark covers a frame. Observer callbacks are +dispatched asynchronously through bounded, drop-oldest inboxes, so they do not run +inside ACK or reconnect protocol stacks. The metrics snapshot exposes delivered and +dropped progress, connection, and error notification counters. +connectionListenerInboxCapacity and errorInboxCapacity tune the Java-compatible +64/256 defaults. onSenderError receives typed category/policy, wire status, message +sequence, stable frame-sequence range, and quarantine context. If it is omitted, +retriable rejections are logged at warn and terminal rejections or abandoned data at +error; general asynchronous ingress failures are also logged when onError is +omitted. Observer exceptions are contained, but CPU-bound callbacks should still move +work to a Worker because browser and Node JavaScript share the event loop.

    +

    When QuestDB authentication is enabled, establish the browser's HttpOnly +qdb_session cookie over REST before opening a QWP WebSocket. A QuestDB REST +token and an OIDC access token both use the bearer form. The application is +responsible for obtaining an OIDC token from its identity provider; the client +does not run an interactive OIDC authorization flow.

    +
    import {
    bootstrapQwpBrowserSession,
    connectQwpBrowserSender,
    } from "@questdb/browser-client";

    await bootstrapQwpBrowserSession({
    url: new URL("/exec", location.href),
    authentication: { type: "bearer", token: oidcOrRestAccessToken },
    // QuestDB Enterprise only; omit to use the authenticated principal.
    serviceAccount: "market_data_writer",
    });

    const sender = await connectQwpBrowserSender({ url }, { autoFlush: false }); +
    + +

    The bootstrap can also be attached to the connection options. It then runs +before each initial, reconnect, or failover WebSocket attempt:

    +
    const sender = await connectQwpBrowserSender(
    {
    url,
    sessionBootstrap: {
    authentication: {
    type: "basic",
    username: "admin",
    password: "quest",
    },
    },
    },
    { autoFlush: false },
    ); +
    + +

    The REST request uses credentials: "include". The default bootstrap URL is +/exec beside /write/v4 or /read/v1; set sessionBootstrap.url explicitly +when a reverse proxy exposes a different REST path. The REST and WebSocket +routes should be served from the same browser origin (or configured with +credentialed CORS), otherwise the browser may decline to store or send the +HttpOnly cookies. JavaScript deliberately never reads qdb_session or the +Enterprise qdbServiceAccount cookie.

    +

    Browsers can request durable ingress acknowledgements without custom HTTP +headers. The client offers a QWP WebSocket subprotocol and verifies that the +server selected it before sending data. Browser keepalives use side-effect-free, +table-less QWP poll frames because the WebSocket API does not expose +protocol-level PING frames. A poll completes once published: durable progress +arrives independently, and an open deferred transaction may intentionally +prevent the server from sending a cumulative OK for that poll. Supplying +durableAckKeepaliveMs requires durable negotiation (requestDurableAck: true, +either explicit or implied by awaitDurableAck); manual polls and durable waits +reject locally when the capability was not negotiated.

    +
    const sender = await connectQwpBrowserSender(
    { url, requestDurableAck: true },
    { autoFlush: false, awaitDurableAck: true },
    ); +
    + +

    Browser durable ACKs are an in-memory delivery confirmation only. Persistent +store-and-forward remains available exclusively through the Node.js entry +point. In-memory ingress replay is capped at 128 MiB and waits at most 30 seconds +for ACK-driven trimming by default; tune memoryReplayMaxBytes and +memoryReplayAppendDeadlineMs in the ingress session options when needed.

    +

    Node.js egress clients can opt into compressed result batches during the +WebSocket upgrade. Raw batches remain the default for compatibility.

    +
    import { connectQwpNodeEgress } from "@questdb/nodejs-client";

    const session = await connectQwpNodeEgress(
    {
    url: "ws://127.0.0.1:9000/read/v1",
    compression: "zstd",
    compressionLevel: 3,
    },
    {
    queryTimeoutMs: 30_000,
    },
    );
    try {
    const query = await session.query("select * from trades", {
    initialCredit: 1024 * 1024,
    });
    console.log("effective Zstd level", session.negotiatedZstdLevel);
    for await (const batch of query) {
    for (const row of batch.rows()) console.log(row);
    }
    await query.completion;
    } finally {
    await session.close();
    } +
    + +

    Zstd decoding and negotiation are also included in the browser entry point. +Because browsers cannot set the X-QWP-Accept-Encoding upgrade header, the +client sends the same preference through the WebSocket URL's +qwp_accept_encoding parameter. No proxy-injected compression header is +required. Older servers ignore the parameter and safely continue with raw +batches.

    +

    Level 1 is the lowest-CPU default and is usually the right starting point. +Higher values trade server CPU for wire size; the client accepts levels 1–22, +while the server may clamp the request or apply an operator-configured level. +session.negotiatedCompression and session.negotiatedZstdLevel report what +the active server actually selected and refresh after reconnection or failover. +Both "zstd" and "auto" advertise Zstd followed by raw fallback, and the +server still sends an individual batch raw when compression would make it +larger.

    +

    Matching the Java client, egress queries default initialCredit to zero, meaning +unbounded server send-ahead. Set a positive session or per-query value to bound wire +buffering—particularly in browsers. With positive credit, the client automatically +replenishes the exact wire size of each result batch after consumption. Set +autoCredit: false to manage credit explicitly through query.grantCredit().

    +

    For allocation-sensitive consumers, session.queryViews(sql, onBatch) supplies +bounded, reusable column views instead of materializing every value into JavaScript +arrays. Typed accessors read fixed-width values directly from QWP bytes, and raw +byte views are available for vectorized processing. The callback is awaited before +credit is replenished, while the receive loop decodes ahead through the bounded +reusable buffer pool. Views are invalid when their callback returns; copy a byte +view with .slice() or call batch.materialize() inside the callback to retain +data. Tune the default four-slot pool with the session's bufferPoolSize.

    +

    queryTimeoutMs sets the session's default query deadline; a per-query +timeoutMs overrides it, and zero disables the deadline. When a deadline +expires, the client rejects iteration and query.completion with +QwpEgressQueryTimeoutError, sends QWP CANCEL, and waits for the terminal +server response before accepting another query on that connection. Breaking out +of for await early cancels the query too. cancelDrainTimeoutMs bounds that +wait (5 seconds by default); an unresponsive cancellation closes the connection +with QwpEgressQueryCancelTimeoutError instead of wedging the session. +To bound only the caller's wait without cancelling, use +await query.awaitCompletion(timeoutMs). It returns false on timeout and leaves +the query active, matching Java Completion.await(timeout, unit). The SERVER_INFO +handshake timeout defaults to five seconds on both clients.

    +
    import { Sender } from "@questdb/nodejs-client";

    async function run() {
    // authentication details
    const USER = "admin";
    const PWD = "quest";

    // pass the authentication details to the sender
    // for secure connection use 'https' protocol instead of 'http'
    const sender = await Sender.fromConfig(
    `http::addr=127.0.0.1:9000;username=${USER};password=${PWD}`,
    );

    // add rows to the buffer of the sender
    await sender
    .table("trades")
    .symbol("symbol", "ETH-USD")
    .symbol("side", "sell")
    .floatColumn("price", 2615.54)
    .floatColumn("amount", 0.00044)
    .at(Date.now(), "ms");

    // flush the buffer of the sender, sending the data to QuestDB
    await sender.flush();

    // close the connection after all rows ingested
    await sender.close();
    }

    run().catch(console.error);
    -
    import { Sender } from "@questdb/nodejs-client";

    async function run() {
    // authentication details
    const TOKEN = "Xyvd3er6GF87ysaHk";

    // pass the authentication details to the sender
    // for secure connection use 'https' protocol instead of 'http'
    const sender = await Sender.fromConfig(
    `http::addr=127.0.0.1:9000;token=${TOKEN}`
    );

    // add rows to the buffer of the sender
    await sender
    .table("trades")
    .symbol("symbol", "ETH-USD")
    .symbol("side", "sell")
    .floatColumn("price", 2615.54)
    .floatColumn("amount", 0.00044)
    .at(Date.now(), "ms");

    // flush the buffer of the sender, sending the data to QuestDB
    await sender.flush();

    // close the connection after all rows ingested
    await sender.close();
    }

    run().catch(console.error); +
    import { Sender } from "@questdb/nodejs-client";

    async function run() {
    // authentication details
    const TOKEN = "Xyvd3er6GF87ysaHk";

    // pass the authentication details to the sender
    // for secure connection use 'https' protocol instead of 'http'
    const sender = await Sender.fromConfig(
    `http::addr=127.0.0.1:9000;token=${TOKEN}`,
    );

    // add rows to the buffer of the sender
    await sender
    .table("trades")
    .symbol("symbol", "ETH-USD")
    .symbol("side", "sell")
    .floatColumn("price", 2615.54)
    .floatColumn("amount", 0.00044)
    .at(Date.now(), "ms");

    // flush the buffer of the sender, sending the data to QuestDB
    await sender.flush();

    // close the connection after all rows ingested
    await sender.close();
    }

    run().catch(console.error);
    -
    import { Sender } from "@questdb/nodejs-client";

    async function run() {
    // authentication details
    const CLIENT_ID = "admin";
    const PRIVATE_KEY = "ZRxmCOQBpZoj2fZ-lEtqzVDkCre_ouF3ePpaQNDwoQk";

    // pass the authentication details to the sender
    const sender = await Sender.fromConfig(
    `tcp::addr=127.0.0.1:9009;username=${CLIENT_ID};token=${PRIVATE_KEY}`
    );
    await sender.connect();

    // add rows to the buffer of the sender
    await sender
    .table("trades")
    .symbol("symbol", "BTC-USD")
    .symbol("side", "sell")
    .floatColumn("price", 39269.98)
    .floatColumn("amount", 0.001)
    .at(Date.now(), "ms");

    // flush the buffer of the sender, sending the data to QuestDB
    await sender.flush();

    // close the connection after all rows ingested
    await sender.close();
    }

    run().catch(console.error); +
    import { Sender } from "@questdb/nodejs-client";

    async function run() {
    // authentication details
    const CLIENT_ID = "admin";
    const PRIVATE_KEY = "ZRxmCOQBpZoj2fZ-lEtqzVDkCre_ouF3ePpaQNDwoQk";

    // pass the authentication details to the sender
    const sender = await Sender.fromConfig(
    `tcp::addr=127.0.0.1:9009;username=${CLIENT_ID};token=${PRIVATE_KEY}`,
    );
    await sender.connect();

    // add rows to the buffer of the sender
    await sender
    .table("trades")
    .symbol("symbol", "BTC-USD")
    .symbol("side", "sell")
    .floatColumn("price", 39269.98)
    .floatColumn("amount", 0.001)
    .at(Date.now(), "ms");

    // flush the buffer of the sender, sending the data to QuestDB
    await sender.flush();

    // close the connection after all rows ingested
    await sender.close();
    }

    run().catch(console.error);
    -
    import { Sender } from "@questdb/nodejs-client";

    async function run() {
    // create a sender
    const sender = await Sender.fromConfig('http::addr=localhost:9000');

    // order book snapshots to ingest
    const orderBooks = [
    {
    symbol: 'BTC-USD',
    exchange: 'Coinbase',
    timestamp: Date.now(),
    bidPrices: [50100.25, 50100.20, 50100.15, 50100.10, 50100.05],
    bidSizes: [0.5, 1.2, 2.1, 0.8, 3.5],
    askPrices: [50100.30, 50100.35, 50100.40, 50100.45, 50100.50],
    askSizes: [0.6, 1.5, 1.8, 2.2, 4.0]
    },
    {
    symbol: 'ETH-USD',
    exchange: 'Coinbase',
    timestamp: Date.now(),
    bidPrices: [2850.50, 2850.45, 2850.40, 2850.35, 2850.30],
    bidSizes: [5.0, 8.2, 12.5, 6.8, 15.0],
    askPrices: [2850.55, 2850.60, 2850.65, 2850.70, 2850.75],
    askSizes: [4.5, 7.8, 10.2, 8.5, 20.0]
    }
    ];

    try {
    // add rows to the buffer of the sender
    for (const orderBook of orderBooks) {
    await sender
    .table('order_book_l2')
    .symbol('symbol', orderBook.symbol)
    .symbol('exchange', orderBook.exchange)
    .arrayColumn('bid_prices', orderBook.bidPrices)
    .arrayColumn('bid_sizes', orderBook.bidSizes)
    .arrayColumn('ask_prices', orderBook.askPrices)
    .arrayColumn('ask_sizes', orderBook.askSizes)
    .at(orderBook.timestamp, 'ms');
    }

    // flush the buffer of the sender, sending the data to QuestDB
    // the buffer is cleared after the data is sent, and the sender is ready to accept new data
    await sender.flush();
    } finally {
    // close the connection after all rows ingested
    await sender.close();
    }
    }

    run().then(console.log).catch(console.error); +
    import { Sender } from "@questdb/nodejs-client";

    async function run() {
    // create a sender
    const sender = await Sender.fromConfig("http::addr=localhost:9000");

    // order book snapshots to ingest
    const orderBooks = [
    {
    symbol: "BTC-USD",
    exchange: "Coinbase",
    timestamp: Date.now(),
    bidPrices: [50100.25, 50100.2, 50100.15, 50100.1, 50100.05],
    bidSizes: [0.5, 1.2, 2.1, 0.8, 3.5],
    askPrices: [50100.3, 50100.35, 50100.4, 50100.45, 50100.5],
    askSizes: [0.6, 1.5, 1.8, 2.2, 4.0],
    },
    {
    symbol: "ETH-USD",
    exchange: "Coinbase",
    timestamp: Date.now(),
    bidPrices: [2850.5, 2850.45, 2850.4, 2850.35, 2850.3],
    bidSizes: [5.0, 8.2, 12.5, 6.8, 15.0],
    askPrices: [2850.55, 2850.6, 2850.65, 2850.7, 2850.75],
    askSizes: [4.5, 7.8, 10.2, 8.5, 20.0],
    },
    ];

    try {
    // add rows to the buffer of the sender
    for (const orderBook of orderBooks) {
    await sender
    .table("order_book_l2")
    .symbol("symbol", orderBook.symbol)
    .symbol("exchange", orderBook.exchange)
    .arrayColumn("bid_prices", orderBook.bidPrices)
    .arrayColumn("bid_sizes", orderBook.bidSizes)
    .arrayColumn("ask_prices", orderBook.askPrices)
    .arrayColumn("ask_sizes", orderBook.askSizes)
    .at(orderBook.timestamp, "ms");
    }

    // flush the buffer of the sender, sending the data to QuestDB
    // the buffer is cleared after the data is sent, and the sender is ready to accept new data
    await sender.flush();
    } finally {
    // close the connection after all rows ingested
    await sender.close();
    }
    }

    run().then(console.log).catch(console.error);
    -
    import { Sender } from "@questdb/nodejs-client";
    import { Worker, isMainThread, parentPort, workerData } from "worker_threads";

    // fake venue
    // generates random prices and amounts for a ticker for max 5 seconds, then the feed closes
    function* venue(ticker) {
    let end = false;
    setTimeout(() => {
    end = true;
    }, rndInt(5000));
    while (!end) {
    yield { ticker, price: Math.random(), amount: Math.random() };
    }
    }

    // market data feed simulator
    // uses the fake venue to deliver price and amount updates to the feed handler (onTick() callback)
    async function subscribe(ticker, onTick) {
    const feed = venue(workerData.ticker);
    let tick;
    while ((tick = feed.next().value)) {
    await onTick(tick);
    await sleep(rndInt(30));
    }
    }

    async function run() {
    if (isMainThread) {
    const tickers = ["ETH-USD", "BTC-USD", "SOL-USD", "DOGE-USD"];
    // main thread to start a worker thread for each ticker
    for (let ticker of tickers) {
    new Worker(__filename, { workerData: { ticker: ticker } })
    .on("error", (err) => {
    throw err;
    })
    .on("exit", () => {
    console.log(`${ticker} thread exiting...`);
    })
    .on("message", (msg) => {
    console.log(`Ingested ${msg.count} prices for ticker ${msg.ticker}`);
    });
    }
    } else {
    // it is important that each worker has a dedicated sender object
    // threads cannot share the sender because they would write into the same buffer
    const sender = await Sender.fromConfig("http::addr=127.0.0.1:9000");

    // subscribe for the market data of the ticker assigned to the worker
    // ingest each price update into the database using the sender
    let count = 0;
    await subscribe(workerData.ticker, async (tick) => {
    await sender
    .table("trades")
    .symbol("symbol", tick.ticker)
    .symbol("side", "sell")
    .floatColumn("price", tick.price)
    .floatColumn("amount", tick.amount)
    .at(Date.now(), "ms");
    await sender.flush();
    count++;
    });

    // let the main thread know how many prices were ingested
    parentPort.postMessage({ ticker: workerData.ticker, count });

    // close the connection to the database
    await sender.close();
    }
    }

    function sleep(ms: number) {
    return new Promise((resolve) => setTimeout(resolve, ms));
    }

    function rndInt(limit: number) {
    return Math.floor(Math.random() * limit + 1);
    }

    run().then(console.log).catch(console.error); +
    import { Sender } from "@questdb/nodejs-client";
    import { Worker, isMainThread, parentPort, workerData } from "worker_threads";

    // fake venue
    // generates random prices and amounts for a ticker for max 5 seconds, then the feed closes
    function* venue(ticker) {
    let end = false;
    setTimeout(() => {
    end = true;
    }, rndInt(5000));
    while (!end) {
    yield { ticker, price: Math.random(), amount: Math.random() };
    }
    }

    // market data feed simulator
    // uses the fake venue to deliver price and amount updates to the feed handler (onTick() callback)
    async function subscribe(ticker, onTick) {
    const feed = venue(workerData.ticker);
    let tick;
    while ((tick = feed.next().value)) {
    await onTick(tick);
    await sleep(rndInt(30));
    }
    }

    async function run() {
    if (isMainThread) {
    const tickers = ["ETH-USD", "BTC-USD", "SOL-USD", "DOGE-USD"];
    // main thread to start a worker thread for each ticker
    for (let ticker of tickers) {
    new Worker(__filename, { workerData: { ticker: ticker } })
    .on("error", (err) => {
    throw err;
    })
    .on("exit", () => {
    console.log(`${ticker} thread exiting...`);
    })
    .on("message", (msg) => {
    console.log(`Ingested ${msg.count} prices for ticker ${msg.ticker}`);
    });
    }
    } else {
    // it is important that each worker has a dedicated sender object
    // threads cannot share the sender because they would write into the same buffer
    const sender = await Sender.fromConfig("http::addr=127.0.0.1:9000");

    // subscribe for the market data of the ticker assigned to the worker
    // ingest each price update into the database using the sender
    let count = 0;
    await subscribe(workerData.ticker, async (tick) => {
    await sender
    .table("trades")
    .symbol("symbol", tick.ticker)
    .symbol("side", "sell")
    .floatColumn("price", tick.price)
    .floatColumn("amount", tick.amount)
    .at(Date.now(), "ms");
    await sender.flush();
    count++;
    });

    // let the main thread know how many prices were ingested
    parentPort.postMessage({ ticker: workerData.ticker, count });

    // close the connection to the database
    await sender.close();
    }
    }

    function sleep(ms: number) {
    return new Promise((resolve) => setTimeout(resolve, ms));
    }

    function rndInt(limit: number) {
    return Math.floor(Math.random() * limit + 1);
    }

    run().then(console.log).catch(console.error);

    Since v9.2.0, QuestDB supports the DECIMAL data type. @@ -62,4 +297,4 @@

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Configuration prefixProtocolTypical use
    http::, https::ILPRecommended general-purpose ingestion
    tcp::, tcps::ILPLong-lived ILP connection
    ws::, wss::QWPAcknowledged ingestion, failover, and store-and-forward
    udp::QWPFire-and-forget datagrams on trusted networks
    +

    Use encrypted transports and certificate verification outside trusted local +development environments.

    +

    Avoid flushing after every row when the application can send a larger batch. +The sender also supports automatic flushing through its configuration options.

    +
    import { Sender } from "@questdb/nodejs-client";

    const sender = await Sender.fromConfig("http::addr=localhost:9000");

    try {
    for (const trade of [
    { symbol: "ETH-USD", price: 2_615.54, amount: 0.25 },
    { symbol: "BTC-USD", price: 59_750.1, amount: 0.01 },
    ]) {
    await sender
    .table("trades")
    .symbol("symbol", trade.symbol)
    .floatColumn("price", trade.price)
    .floatColumn("amount", trade.amount)
    .atNow();
    }

    await sender.flush();
    } finally {
    await sender.close();
    } +
    + +

    Passing null or undefined to a supported symbol or column method omits that +column from the row, which records a SQL NULL in QuestDB.

    +

    Configuration strings use the form +protocol::key=value;key=value. HTTP Basic authentication uses username and +password; REST and OIDC access tokens use token.

    +
    import { Sender } from "@questdb/nodejs-client";

    const sender = await Sender.fromConfig(
    `https::addr=questdb.example:9000;token=${process.env.QUESTDB_TOKEN};tls_verify=on`,
    );

    try {
    await sender.table("service_health").booleanColumn("healthy", true).atNow();
    await sender.flush();
    } finally {
    await sender.close();
    } +
    + +

    The same configuration can be provided through QDB_CLIENT_CONF:

    +
    import { Sender } from "@questdb/nodejs-client";

    // QDB_CLIENT_CONF=http::addr=localhost:9000
    const sender = await Sender.fromEnv(); +
    + +

    Changing the configuration prefix to ws:: or wss:: selects QWP while +keeping the familiar Sender row API.

    +
    import { Sender } from "@questdb/nodejs-client";

    const sender = await Sender.fromConfig(
    `wss::addr=questdb.example:9000;token=${process.env.QUESTDB_TOKEN};auto_flush=off`,
    );
    await sender.connect();

    try {
    await sender
    .table("trades")
    .symbol("symbol", "ETH-USD")
    .floatColumn("price", 2_615.54)
    .timestampColumn("received_at", Date.now(), "ms")
    .atNow();

    await sender.flush();
    } finally {
    await sender.close();
    } +
    + +

    QWP senders support server acknowledgements, durable acknowledgements, +transactions, reconnect, failover, compiled row writers, and metrics. See the +QWP guide +for the delivery semantics of each option.

    +

    For repeated object-shaped rows, compile a table schema once. TypeScript then +checks each row against that schema.

    +
    import {
    Sender,
    designatedTimestamp,
    double,
    long,
    symbol,
    } from "@questdb/nodejs-client";

    const sender = await Sender.fromConfig("ws::addr=localhost:9000");
    await sender.connect();

    try {
    const trades = sender.writer("trades", {
    symbol: symbol(),
    side: symbol(),
    price: double(),
    quantity: long(),
    timestamp: designatedTimestamp("ns"),
    });

    await trades.rows([
    {
    symbol: "ETH-USD",
    side: "buy",
    price: 2_615.54,
    quantity: 42n,
    timestamp: 1_723_000_000_000_000_000n,
    },
    {
    symbol: "BTC-USD",
    side: "sell",
    price: 59_750.1,
    quantity: 1n,
    timestamp: 1_723_000_001_000_000_000n,
    },
    ]);

    await sender.flush();
    } finally {
    await sender.close();
    } +
    + +

    Compiled writers are available with QWP transports only.

    +

    QWP egress streams typed result batches. One egress session executes one active +query at a time.

    +
    import { connectQwpNodeEgress } from "@questdb/nodejs-client";

    const session = await connectQwpNodeEgress(
    {
    url: "wss://questdb.example:9000/read/v1",
    authorization: `Bearer ${process.env.QUESTDB_TOKEN}`,
    compression: "zstd",
    },
    { queryTimeoutMs: 30_000 },
    );

    try {
    const query = await session.query(
    "select timestamp, symbol, price from trades where symbol = $1",
    {
    // Bind index 0 corresponds to SQL placeholder $1.
    binds: (binds) => binds.setVarchar(0, "ETH-USD"),
    initialCredit: 1024 * 1024,
    },
    );

    for await (const batch of query) {
    for (const row of batch.rows()) {
    console.log(row);
    }
    }

    await query.completion;
    } finally {
    await session.close();
    } +
    + +

    Use queryViews() instead of query() for reusable, allocation-conscious +column and row views.

    +

    Node.js can journal QWP frames to disk before sending them. The producer can +continue accepting rows during a QuestDB outage and replay them in order after +reconnection.

    +
    import { Sender } from "@questdb/nodejs-client";

    const sender = await Sender.fromConfig(
    "wss::" +
    "addr=questdb-a.example:9000,questdb-b.example:9000;" +
    "sf_dir=/var/lib/my-service/questdb-replay;" +
    "initial_connect_retry=async;",
    );

    await sender.connect(); +
    + +

    Give every active producer its own journal directory. Durability, +backpressure, capacity, orphan recovery, and shutdown behavior are covered in +the store-and-forward section of the QWP guide.

    +
      +
    • Always call close() in a finally block.
    • +
    • Call flush() before closing an ILP sender; otherwise buffered rows are lost.
    • +
    • A QWP sender publishes completed rows during close, but an unfinished row is +never completed implicitly.
    • +
    • Do not write concurrently through one Sender. Give each worker or producer +its own sender.
    • +
    • Treat authentication and protocol errors as configuration failures rather +than retrying the same request indefinitely.
    • +
    + +

    Classes

    HttpTransport
    QwpBatchTooLargeError
    QwpBindValues
    QwpByteReader
    QwpByteWriter
    QwpClient
    QwpClientClosedError
    QwpDurableAckUnavailableError
    QwpEgressQuery
    QwpEgressQueryAbandonedError
    QwpEgressQueryCancelTimeoutError
    QwpEgressQueryError
    QwpEgressQueryTimeoutError
    QwpEgressReplayRequiredError
    QwpEgressSession
    QwpEgressSessionClosedError
    QwpFailoverError
    QwpIngressAckTimeoutError
    QwpIngressNackError
    QwpIngressSession
    QwpIngressSessionClosedError
    QwpMemoryReplayAppendTimeoutError
    QwpMemoryReplayFrameTooLargeError
    QwpNodeFileReplayStore
    QwpNodeOrphanDrainer
    QwpNodeUdpSession
    QwpPoolAcquireTimeoutError
    QwpPoolResourceError
    QwpProtocolError
    QwpQueryLease
    QwpReconnectExhaustedError
    QwpReplayDictionaryError
    QwpReplayDictionaryPersistenceError
    QwpReplayRejectedError
    QwpReplayStoreAppendTimeoutError
    QwpReplayStoreCheckpointError
    QwpReplayStoreCorruptionError
    QwpReplayStoreError
    QwpReplayStoreFullError
    QwpReplayStoreLockedError
    QwpReplayStoreLockLostError
    QwpReplayStoreQuarantinedError
    QwpReplayStoreSegmentTooLargeError
    QwpResultBatch
    QwpResultBatchDecoder
    QwpResultBatchView
    QwpResultColumnView
    QwpResultRowView
    QwpRoleMismatchError
    QwpSendClosedError
    QwpSender
    QwpSenderCloseTimeoutError
    QwpSendError
    QwpSendTimeoutError
    QwpSymbolDictionary
    QwpTableBuffer
    QwpTableWriter
    QwpUdpDatagramTooLargeError
    QwpUnrecoverableReplayDictionaryError
    QwpUpgradeError
    QwpVersionMismatchError
    QwpWriterRowError
    Sender
    SenderBufferV1
    SenderBufferV2
    SenderBufferV3
    SenderOptions
    TcpTransport
    UndiciTransport

    Interfaces

    QwpArrayValue
    QwpBinaryConnection
    QwpCacheResetMessage
    QwpClientFactories
    QwpClientMetrics
    QwpClientPoolOptions
    QwpColumnBuffer
    QwpConnectionCloseInfo
    QwpDecimalValue
    QwpEgressQueryOptions
    QwpEgressReplayResetEvent
    QwpEgressRoutingOptions
    QwpEgressSessionOptions
    QwpEgressViewQuery
    QwpEncodedBinds
    QwpExecDoneMessage
    QwpFailoverAttempt
    QwpFrame
    QwpFrameHeader
    QwpGeohashValue
    QwpHandshakeMetadata
    QwpIngressEncodeOptions
    QwpIngressErrorEvent
    QwpIngressMetrics
    QwpIngressProgressEvent
    QwpIngressReplayRecord
    QwpIngressReplayReference
    QwpIngressReplayStore
    QwpIngressResponse
    QwpIngressSendResult
    QwpIngressSessionOptions
    QwpIngressSymbolDictionaryDelta
    QwpIngressTableResult
    QwpIngressTransportMetrics
    QwpLong256Value
    QwpNodeClientConfigOptions
    QwpNodeClientOptions
    QwpNodeEgressOptions
    QwpNodeFileReplayStoreMetrics
    QwpNodeFileReplayStoreOptions
    QwpNodeIngressOptions
    QwpNodeOrphanDrainerMetrics
    QwpNodeOrphanDrainerOptions
    QwpNodeOrphanDrainEvent
    QwpNodeOrphanDrainSession
    QwpNodeReplayDataLossReport
    QwpNodeReplayRecoveryEvent
    QwpNodeStoreAndForwardOptions
    QwpNodeUdpMetrics
    QwpNodeUdpOptions
    QwpNodeUdpSocketLike
    QwpNodeUpgradeRejection
    QwpNodeWebSocketOptions
    QwpPoolSlotReservation
    QwpQueryErrorMessage
    QwpQueryRequest
    QwpReconnectEvent
    QwpReconnectOptions
    QwpResourcePoolMetrics
    QwpResultArrayValue
    QwpResultBatchMessage
    QwpResultColumn
    QwpResultColumnSchema
    QwpResultEndMessage
    QwpSenderEncodeOptions
    QwpSenderError
    QwpSenderErrorResponseContext
    QwpSenderMetrics
    QwpSenderOptions
    QwpSenderSession
    QwpServerInfoMessage
    QwpSymbolValue
    QwpUpgradeErrorDetails
    QwpUuidValue
    QwpWebSocketConnectOptions
    QwpWebSocketLike
    QwpWriterColumn
    SenderBuffer
    SenderTransport

    Type Aliases

    ExtraOptions
    Logger
    QwpBindSetter
    QwpBindType
    QwpColumnType
    QwpConnectionFactory
    QwpDecimalInput
    QwpDoubleArrayInput
    QwpEgressCompression
    QwpEgressMessage
    QwpExtraOptions
    QwpGeohashInput
    QwpIngressProgressKind
    QwpInitialConnectMode
    QwpInt64
    QwpIpv4Input
    QwpLong256Input
    QwpLong256Words
    QwpLongArrayInput
    QwpNegotiatedEgressCompression
    QwpNestedLongArray
    QwpNestedNumberArray
    QwpNodeOrphanDrainEventKind
    QwpQueryCompletion
    QwpReconnectEventKind
    QwpResultBatchViewHandler
    QwpResultRowViewCallback
    QwpResultValue
    QwpSenderErrorCategory
    QwpSenderErrorPolicy
    QwpSenderLogger
    QwpSenderSessionFactory
    QwpSfBackpressurePolicy
    QwpSfDurability
    QwpTarget
    QwpTimestampUnit
    QwpUpgradeErrorKind
    QwpUpgradeTimeoutPhase
    QwpUuidInput
    QwpWriterColumnKind
    QwpWriterRow
    QwpWriterSchema
    TimestampUnit

    Variables

    QWP_COLUMN_TYPE
    QWP_COMPRESSION_CODEC
    QWP_DECIMAL_MAX_SCALE
    QWP_DEFAULT_EGRESS_BUFFER_POOL_SIZE
    QWP_DEFAULT_EGRESS_INITIAL_CREDIT
    QWP_DEFAULT_EGRESS_SERVER_INFO_TIMEOUT_MS
    QWP_DURABLE_ACK_WEBSOCKET_PROTOCOL
    QWP_EGRESS_CAPABILITY
    QWP_EGRESS_MESSAGE
    QWP_EGRESS_PATH
    QWP_ENCODING_GORILLA
    QWP_ENCODING_UNCOMPRESSED
    QWP_FLAG_DEFER_COMMIT
    QWP_FLAG_DELTA_SYMBOL_DICTIONARY
    QWP_FLAG_DURABLE_ACK_POLL
    QWP_FLAG_GORILLA
    QWP_FLAG_ZSTD
    QWP_HEADER_SIZE
    QWP_INGRESS_PATH
    QWP_INGRESS_PROGRESS_KIND
    QWP_INITIAL_CONNECT_MODE
    QWP_MAGIC
    QWP_MAX_ARRAY_DIMENSION_LENGTH
    QWP_MAX_ARRAY_DIMENSIONS
    QWP_MAX_BATCH_ROWS_UPPER_BOUND
    QWP_MAX_CELLS_PER_BATCH
    QWP_MAX_COLUMN_NAME_LENGTH
    QWP_MAX_COLUMNS_PER_TABLE
    QWP_MAX_ERROR_MESSAGE_LENGTH
    QWP_MAX_IDENTIFIER_BYTES
    QWP_MAX_ROWS_PER_TABLE
    QWP_MAX_SYMBOL_DICTIONARY_SIZE
    QWP_MAX_TABLE_NAME_LENGTH
    QWP_MAX_ZSTD_DECOMPRESSED_SIZE
    QWP_ORPHAN_DRAIN_EVENT_KIND
    QWP_ORPHAN_FAILED_SENTINEL
    QWP_QUERY_FLAG_RESET_DICTIONARY
    QWP_RECONNECT_EVENT_KIND
    QWP_RESET_MASK_DICTIONARY
    QWP_SENDER_ERROR_CATEGORY
    QWP_SENDER_ERROR_POLICY
    QWP_SERVER_ROLE
    QWP_SF_BACKPRESSURE_POLICY
    QWP_SF_DURABILITY
    QWP_STATUS
    QWP_TARGET
    QWP_UPGRADE_ERROR_KIND
    QWP_UPGRADE_TIMEOUT_PHASE
    QWP_VERSION
    QWP_ZSTD_MAX_COMPRESSION_LEVEL
    QWP_ZSTD_MIN_COMPRESSION_LEVEL

    Functions

    addQwpDurableAckWebSocketProtocol
    bigintToTwosComplementBytes
    binary
    bool
    byte
    char
    concatBytes
    connectQwpNodeClient
    connectQwpNodeEgress
    connectQwpNodeIngress
    connectQwpNodeSender
    connectQwpNodeUdp
    connectQwpNodeUdpSender
    connectQwpNodeWebSocket
    createBuffer
    createQwpDataLossSenderError
    createQwpNodeClient
    createQwpNodeConnectionFactory
    createQwpNodeSender
    createQwpNodeUdpSender
    createQwpProtocolViolationSenderError
    createQwpSenderError
    createTransport
    date
    decimal128
    decimal256
    decimal64
    decodeQwpContentEncoding
    decodeQwpEgressMessage
    decodeQwpFrame
    decodeQwpIngressResponse
    decodeQwpIngressServerInfo
    decodeQwpIngressSymbolDictionaryDelta
    decodeQwpVarint
    decodeUtf8
    decompressQwpZstdFrame
    defaultQwpSenderErrorHandler
    designatedTimestamp
    double
    doubleArray
    encodeQwpAcceptEncoding
    encodeQwpBinds
    encodeQwpCancel
    encodeQwpCredit
    encodeQwpDurableAckPollFrame
    encodeQwpFrame
    encodeQwpGorilla
    encodeQwpIngressCommitFrame
    encodeQwpIngressFrame
    encodeQwpIngressSymbolDictionaryFrame
    encodeQwpQueryRequest
    encodeQwpVarint
    encodeUtf8
    flattenQwpArray
    float32
    float64
    geohash
    int32
    int64
    ipv4
    isQwpDurableAckWebSocketProtocol
    long
    long256
    longArray
    parseQwpNodeClientConfig
    qwpDefaultSenderErrorPolicy
    qwpGorillaSize
    qwpSenderErrorCategory
    qwpVarintSize
    readQwpVarint
    readQwpVarintNumber
    retryQwpNodeOrphanSlot
    scanQwpNodeOrphanSlots
    short
    symbol
    timestamp
    utf8Length
    uuid
    varchar
    writeQwpFrameHeader
    writeQwpVarint
    diff --git a/docs/types/ExtraOptions.html b/docs/types/ExtraOptions.html deleted file mode 100644 index ce1cb2b..0000000 --- a/docs/types/ExtraOptions.html +++ /dev/null @@ -1,3 +0,0 @@ -ExtraOptions | QuestDB Node.js Client - v4.2.0

    Type Alias ExtraOptions

    type ExtraOptions = {
        log?: Logger;
        agent?: Agent | http.Agent | https.Agent;
    }
    Index

    Properties

    Properties

    log?: Logger
    agent?: Agent | http.Agent | https.Agent
    diff --git a/docs/types/Logger.html b/docs/types/Logger.html deleted file mode 100644 index fd702cb..0000000 --- a/docs/types/Logger.html +++ /dev/null @@ -1,4 +0,0 @@ -Logger | QuestDB Node.js Client - v4.2.0

    Type Alias Logger

    Logger: (
        level: "error" | "warn" | "info" | "debug",
        message: string | Error,
    ) => void

    Logger function type definition.

    -

    Type declaration

      • (level: "error" | "warn" | "info" | "debug", message: string | Error): void
      • Parameters

        • level: "error" | "warn" | "info" | "debug"

          The log level for the message

          -
        • message: string | Error

          The message to log, either a string or Error object

          -

        Returns void

    diff --git a/docs/types/TimestampUnit.html b/docs/types/TimestampUnit.html deleted file mode 100644 index c79c21d..0000000 --- a/docs/types/TimestampUnit.html +++ /dev/null @@ -1,2 +0,0 @@ -TimestampUnit | QuestDB Node.js Client - v4.2.0

    Type Alias TimestampUnit

    TimestampUnit: "ns" | "us" | "ms"

    Supported timestamp units for QuestDB operations.

    -
    diff --git a/docs/types/_questdb_browser-client.QwpBindSetter.html b/docs/types/_questdb_browser-client.QwpBindSetter.html new file mode 100644 index 0000000..39845ed --- /dev/null +++ b/docs/types/_questdb_browser-client.QwpBindSetter.html @@ -0,0 +1 @@ +QwpBindSetter | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpBindSetter

    QwpBindSetter: (binds: QwpBindValues) => void

    Type declaration

    diff --git a/docs/types/_questdb_browser-client.QwpBindType.html b/docs/types/_questdb_browser-client.QwpBindType.html new file mode 100644 index 0000000..83389fe --- /dev/null +++ b/docs/types/_questdb_browser-client.QwpBindType.html @@ -0,0 +1,2 @@ +QwpBindType | QuestDB JavaScript Client - v4.2.0
    QwpBindType:
        | typeof BOOLEAN
        | typeof BYTE
        | typeof SHORT
        | typeof INT
        | typeof LONG
        | typeof FLOAT
        | typeof DOUBLE
        | typeof TIMESTAMP
        | typeof DATE
        | typeof UUID
        | typeof LONG256
        | typeof GEOHASH
        | typeof VARCHAR
        | typeof TIMESTAMP_NANOS
        | typeof DECIMAL64
        | typeof DECIMAL128
        | typeof DECIMAL256
        | typeof CHAR

    Phase-1 scalar bind types exposed by the Java reference client.

    +
    diff --git a/docs/types/_questdb_browser-client.QwpBrowserClientEgressOptions.html b/docs/types/_questdb_browser-client.QwpBrowserClientEgressOptions.html new file mode 100644 index 0000000..701f53f --- /dev/null +++ b/docs/types/_questdb_browser-client.QwpBrowserClientEgressOptions.html @@ -0,0 +1,2 @@ +QwpBrowserClientEgressOptions | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpBrowserClientEgressOptions

    QwpBrowserClientEgressOptions: Partial<
        Pick<
            QwpBrowserEgressOptions,
            | "protocols"
            | "connectTimeoutMs"
            | "sendTimeoutMs"
            | "closeTimeoutMs"
            | "webSocketFactory"
            | "target"
            | "zone"
            | "compression"
            | "compressionLevel"
            | "maxBatchRows",
        >,
    >

    Egress-only overrides for a unified browser cluster.

    +
    diff --git a/docs/types/_questdb_browser-client.QwpBrowserClientIngressOptions.html b/docs/types/_questdb_browser-client.QwpBrowserClientIngressOptions.html new file mode 100644 index 0000000..990650b --- /dev/null +++ b/docs/types/_questdb_browser-client.QwpBrowserClientIngressOptions.html @@ -0,0 +1,2 @@ +QwpBrowserClientIngressOptions | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpBrowserClientIngressOptions

    QwpBrowserClientIngressOptions: Partial<
        Pick<
            QwpBrowserWebSocketOptions,
            | "protocols"
            | "connectTimeoutMs"
            | "sendTimeoutMs"
            | "closeTimeoutMs"
            | "requestDurableAck"
            | "ingressNegotiationTimeoutMs"
            | "webSocketFactory",
        >,
    >

    Ingress-only overrides for a unified browser cluster.

    +
    diff --git a/docs/types/_questdb_browser-client.QwpBrowserClientOptions.html b/docs/types/_questdb_browser-client.QwpBrowserClientOptions.html new file mode 100644 index 0000000..9afafd7 --- /dev/null +++ b/docs/types/_questdb_browser-client.QwpBrowserClientOptions.html @@ -0,0 +1,2 @@ +QwpBrowserClientOptions | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpBrowserClientOptions

    QwpBrowserClientOptions:
        | QwpBrowserUnifiedClientOptions
        | QwpBrowserSplitClientOptions

    Browser configuration for a combined pooled QWP ingress/egress client.

    +
    diff --git a/docs/types/_questdb_browser-client.QwpBrowserFetch.html b/docs/types/_questdb_browser-client.QwpBrowserFetch.html new file mode 100644 index 0000000..2a7107b --- /dev/null +++ b/docs/types/_questdb_browser-client.QwpBrowserFetch.html @@ -0,0 +1 @@ +QwpBrowserFetch | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpBrowserFetch

    QwpBrowserFetch: (input: string | URL, init?: RequestInit) => Promise<Response>

    Type declaration

      • (input: string | URL, init?: RequestInit): Promise<Response>
      • Parameters

        • input: string | URL
        • Optionalinit: RequestInit

        Returns Promise<Response>

    diff --git a/docs/types/_questdb_browser-client.QwpBrowserSessionAuthentication.html b/docs/types/_questdb_browser-client.QwpBrowserSessionAuthentication.html new file mode 100644 index 0000000..422fc85 --- /dev/null +++ b/docs/types/_questdb_browser-client.QwpBrowserSessionAuthentication.html @@ -0,0 +1,3 @@ +QwpBrowserSessionAuthentication | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpBrowserSessionAuthentication

    QwpBrowserSessionAuthentication:
        | { password: string; type: "basic"; username: string }
        | { token: string; type: "bearer" }

    Type declaration

    • { password: string; type: "basic"; username: string }
      • password: string
      • type: "basic"

        HTTP Basic authentication.

        +
      • username: string
    • { token: string; type: "bearer" }
      • token: string
      • type: "bearer"

        QuestDB REST token or OIDC access token.

        +
    diff --git a/docs/types/_questdb_browser-client.QwpBrowserSessionBootstrapConfig.html b/docs/types/_questdb_browser-client.QwpBrowserSessionBootstrapConfig.html new file mode 100644 index 0000000..1f5011e --- /dev/null +++ b/docs/types/_questdb_browser-client.QwpBrowserSessionBootstrapConfig.html @@ -0,0 +1,2 @@ +QwpBrowserSessionBootstrapConfig | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpBrowserSessionBootstrapConfig

    QwpBrowserSessionBootstrapConfig: Omit<QwpBrowserSessionBootstrapOptions, "url"> & {
        url?: string | URL;
    }

    Type declaration

    • Optionalurl?: string | URL

      Defaults to /exec on the current QWP endpoint's HTTP origin.

      +
    diff --git a/docs/types/_questdb_browser-client.QwpColumnType.html b/docs/types/_questdb_browser-client.QwpColumnType.html new file mode 100644 index 0000000..b492e97 --- /dev/null +++ b/docs/types/_questdb_browser-client.QwpColumnType.html @@ -0,0 +1 @@ +QwpColumnType | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/types/_questdb_browser-client.QwpConnectionFactory.html b/docs/types/_questdb_browser-client.QwpConnectionFactory.html new file mode 100644 index 0000000..372f995 --- /dev/null +++ b/docs/types/_questdb_browser-client.QwpConnectionFactory.html @@ -0,0 +1,5 @@ +QwpConnectionFactory | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpConnectionFactory

    QwpConnectionFactory: (signal?: AbortSignal) => Promise<QwpBinaryConnection>

    Opens one connection. The optional signal is aborted when the owning session +closes, so a factory that is still negotiating can tear its socket down +instead of leaving it alive until its own deadline expires. Factories that +ignore the parameter remain assignable.

    +

    Type declaration

    diff --git a/docs/types/_questdb_browser-client.QwpDecimalInput.html b/docs/types/_questdb_browser-client.QwpDecimalInput.html new file mode 100644 index 0000000..ca4a6b2 --- /dev/null +++ b/docs/types/_questdb_browser-client.QwpDecimalInput.html @@ -0,0 +1,3 @@ +QwpDecimalInput | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpDecimalInput

    QwpDecimalInput: bigint | number | string | { scale: number; unscaled: bigint }

    DECIMAL input: the unscaled bigint at the column's scale, decimal text (or +a number) that is exactly representable at that scale, or the egress record.

    +
    diff --git a/docs/types/_questdb_browser-client.QwpDoubleArrayInput.html b/docs/types/_questdb_browser-client.QwpDoubleArrayInput.html new file mode 100644 index 0000000..79ebd7e --- /dev/null +++ b/docs/types/_questdb_browser-client.QwpDoubleArrayInput.html @@ -0,0 +1,2 @@ +QwpDoubleArrayInput | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpDoubleArrayInput

    QwpDoubleArrayInput:
        | QwpNestedNumberArray
        | { dimensions: readonly number[]; values: readonly number[] }

    DOUBLE array input: nested arrays or a flat shape-and-values record.

    +
    diff --git a/docs/types/_questdb_browser-client.QwpEgressCompression.html b/docs/types/_questdb_browser-client.QwpEgressCompression.html new file mode 100644 index 0000000..ac32e2b --- /dev/null +++ b/docs/types/_questdb_browser-client.QwpEgressCompression.html @@ -0,0 +1 @@ +QwpEgressCompression | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpEgressCompression

    QwpEgressCompression: "raw" | "zstd" | "auto"
    diff --git a/docs/types/_questdb_browser-client.QwpEgressMessage.html b/docs/types/_questdb_browser-client.QwpEgressMessage.html new file mode 100644 index 0000000..0ef16b4 --- /dev/null +++ b/docs/types/_questdb_browser-client.QwpEgressMessage.html @@ -0,0 +1 @@ +QwpEgressMessage | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/types/_questdb_browser-client.QwpGeohashInput.html b/docs/types/_questdb_browser-client.QwpGeohashInput.html new file mode 100644 index 0000000..4b66240 --- /dev/null +++ b/docs/types/_questdb_browser-client.QwpGeohashInput.html @@ -0,0 +1,3 @@ +QwpGeohashInput | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpGeohashInput

    QwpGeohashInput:
        | bigint
        | number
        | string
        | { bits: bigint; precisionBits: number }

    GEOHASH input: the raw bits, base-32 geohash text whose length matches the +column precision, or the egress bit record.

    +
    diff --git a/docs/types/_questdb_browser-client.QwpIngressProgressKind.html b/docs/types/_questdb_browser-client.QwpIngressProgressKind.html new file mode 100644 index 0000000..82a6bed --- /dev/null +++ b/docs/types/_questdb_browser-client.QwpIngressProgressKind.html @@ -0,0 +1 @@ +QwpIngressProgressKind | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/types/_questdb_browser-client.QwpInitialConnectMode.html b/docs/types/_questdb_browser-client.QwpInitialConnectMode.html new file mode 100644 index 0000000..b73f852 --- /dev/null +++ b/docs/types/_questdb_browser-client.QwpInitialConnectMode.html @@ -0,0 +1 @@ +QwpInitialConnectMode | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/types/_questdb_browser-client.QwpInt64.html b/docs/types/_questdb_browser-client.QwpInt64.html new file mode 100644 index 0000000..a708a62 --- /dev/null +++ b/docs/types/_questdb_browser-client.QwpInt64.html @@ -0,0 +1 @@ +QwpInt64 | QuestDB JavaScript Client - v4.2.0
    QwpInt64: number | bigint
    diff --git a/docs/types/_questdb_browser-client.QwpIpv4Input.html b/docs/types/_questdb_browser-client.QwpIpv4Input.html new file mode 100644 index 0000000..c34bc28 --- /dev/null +++ b/docs/types/_questdb_browser-client.QwpIpv4Input.html @@ -0,0 +1,2 @@ +QwpIpv4Input | QuestDB JavaScript Client - v4.2.0
    QwpIpv4Input: string | number

    IPV4 input: dotted-quad text or a signed/unsigned packed 32-bit address.

    +
    diff --git a/docs/types/_questdb_browser-client.QwpLong256Input.html b/docs/types/_questdb_browser-client.QwpLong256Input.html new file mode 100644 index 0000000..2ee9527 --- /dev/null +++ b/docs/types/_questdb_browser-client.QwpLong256Input.html @@ -0,0 +1,3 @@ +QwpLong256Input | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpLong256Input

    QwpLong256Input: bigint | string | QwpLong256Words | { words: QwpLong256Words }

    LONG256 input: an unsigned 256-bit bigint, a 0x-prefixed hex string of +up to 64 digits, four little-endian words, or the egress word record.

    +
    diff --git a/docs/types/_questdb_browser-client.QwpLong256Words.html b/docs/types/_questdb_browser-client.QwpLong256Words.html new file mode 100644 index 0000000..e9f4dc7 --- /dev/null +++ b/docs/types/_questdb_browser-client.QwpLong256Words.html @@ -0,0 +1,2 @@ +QwpLong256Words | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpLong256Words

    QwpLong256Words: readonly [bigint, bigint, bigint, bigint]

    LONG256 little-endian words; word 0 is least significant.

    +
    diff --git a/docs/types/_questdb_browser-client.QwpLongArrayInput.html b/docs/types/_questdb_browser-client.QwpLongArrayInput.html new file mode 100644 index 0000000..6ebcfbd --- /dev/null +++ b/docs/types/_questdb_browser-client.QwpLongArrayInput.html @@ -0,0 +1,2 @@ +QwpLongArrayInput | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpLongArrayInput

    QwpLongArrayInput:
        | QwpNestedLongArray
        | { dimensions: readonly number[]; values: readonly (number | bigint)[] }

    LONG array input: nested arrays or a flat shape-and-values record.

    +
    diff --git a/docs/types/_questdb_browser-client.QwpNegotiatedEgressCompression.html b/docs/types/_questdb_browser-client.QwpNegotiatedEgressCompression.html new file mode 100644 index 0000000..7f6523f --- /dev/null +++ b/docs/types/_questdb_browser-client.QwpNegotiatedEgressCompression.html @@ -0,0 +1 @@ +QwpNegotiatedEgressCompression | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpNegotiatedEgressCompression

    QwpNegotiatedEgressCompression:
        | { codec: "raw"; level: 0 }
        | { codec: "zstd"; level: number }
        | { codec: "unknown"; contentEncoding: string; level: 0 }
    diff --git a/docs/types/_questdb_browser-client.QwpNestedLongArray.html b/docs/types/_questdb_browser-client.QwpNestedLongArray.html new file mode 100644 index 0000000..347181f --- /dev/null +++ b/docs/types/_questdb_browser-client.QwpNestedLongArray.html @@ -0,0 +1,2 @@ +QwpNestedLongArray | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpNestedLongArray

    QwpNestedLongArray: readonly (number | bigint | QwpNestedLongArray)[]

    Nested LONG array of uniform shape.

    +
    diff --git a/docs/types/_questdb_browser-client.QwpNestedNumberArray.html b/docs/types/_questdb_browser-client.QwpNestedNumberArray.html new file mode 100644 index 0000000..9d9067e --- /dev/null +++ b/docs/types/_questdb_browser-client.QwpNestedNumberArray.html @@ -0,0 +1,2 @@ +QwpNestedNumberArray | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpNestedNumberArray

    QwpNestedNumberArray: readonly (number | QwpNestedNumberArray)[]

    Nested DOUBLE array of uniform shape.

    +
    diff --git a/docs/types/_questdb_browser-client.QwpQueryCompletion.html b/docs/types/_questdb_browser-client.QwpQueryCompletion.html new file mode 100644 index 0000000..6c3fb0e --- /dev/null +++ b/docs/types/_questdb_browser-client.QwpQueryCompletion.html @@ -0,0 +1 @@ +QwpQueryCompletion | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/types/_questdb_browser-client.QwpReconnectEventKind.html b/docs/types/_questdb_browser-client.QwpReconnectEventKind.html new file mode 100644 index 0000000..1215ba9 --- /dev/null +++ b/docs/types/_questdb_browser-client.QwpReconnectEventKind.html @@ -0,0 +1 @@ +QwpReconnectEventKind | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/types/_questdb_browser-client.QwpResultBatchViewHandler.html b/docs/types/_questdb_browser-client.QwpResultBatchViewHandler.html new file mode 100644 index 0000000..289e431 --- /dev/null +++ b/docs/types/_questdb_browser-client.QwpResultBatchViewHandler.html @@ -0,0 +1,3 @@ +QwpResultBatchViewHandler | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpResultBatchViewHandler

    QwpResultBatchViewHandler: (
        batch: QwpResultBatchView,
        query: QwpEgressViewQuery,
    ) => void | Promise<void>

    Runs while one reusable batch view is valid. Do not retain the batch, +columns, or raw byte slices after the callback settles.

    +

    Type declaration

    diff --git a/docs/types/_questdb_browser-client.QwpResultRowViewCallback.html b/docs/types/_questdb_browser-client.QwpResultRowViewCallback.html new file mode 100644 index 0000000..1c20f3b --- /dev/null +++ b/docs/types/_questdb_browser-client.QwpResultRowViewCallback.html @@ -0,0 +1,2 @@ +QwpResultRowViewCallback | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpResultRowViewCallback

    QwpResultRowViewCallback: (row: QwpResultRowView) => void

    Callback invoked by QwpResultBatchView.forEachRow().

    +

    Type declaration

    diff --git a/docs/types/_questdb_browser-client.QwpResultValue.html b/docs/types/_questdb_browser-client.QwpResultValue.html new file mode 100644 index 0000000..9032cf4 --- /dev/null +++ b/docs/types/_questdb_browser-client.QwpResultValue.html @@ -0,0 +1 @@ +QwpResultValue | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpResultValue

    QwpResultValue:
        | boolean
        | number
        | bigint
        | string
        | Uint8Array
        | QwpDecimalValue
        | QwpUuidValue
        | QwpLong256Value
        | QwpGeohashValue
        | QwpResultArrayValue
        | null
    diff --git a/docs/types/_questdb_browser-client.QwpSenderErrorCategory.html b/docs/types/_questdb_browser-client.QwpSenderErrorCategory.html new file mode 100644 index 0000000..8719050 --- /dev/null +++ b/docs/types/_questdb_browser-client.QwpSenderErrorCategory.html @@ -0,0 +1 @@ +QwpSenderErrorCategory | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/types/_questdb_browser-client.QwpSenderErrorPolicy.html b/docs/types/_questdb_browser-client.QwpSenderErrorPolicy.html new file mode 100644 index 0000000..5cf6204 --- /dev/null +++ b/docs/types/_questdb_browser-client.QwpSenderErrorPolicy.html @@ -0,0 +1 @@ +QwpSenderErrorPolicy | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/types/_questdb_browser-client.QwpSenderLogger.html b/docs/types/_questdb_browser-client.QwpSenderLogger.html new file mode 100644 index 0000000..7b75334 --- /dev/null +++ b/docs/types/_questdb_browser-client.QwpSenderLogger.html @@ -0,0 +1 @@ +QwpSenderLogger | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpSenderLogger

    QwpSenderLogger: (
        level: "error" | "warn" | "info" | "debug",
        message: string | Error,
    ) => void

    Type declaration

      • (level: "error" | "warn" | "info" | "debug", message: string | Error): void
      • Parameters

        • level: "error" | "warn" | "info" | "debug"
        • message: string | Error

        Returns void

    diff --git a/docs/types/_questdb_browser-client.QwpSenderSessionFactory.html b/docs/types/_questdb_browser-client.QwpSenderSessionFactory.html new file mode 100644 index 0000000..07d6fe7 --- /dev/null +++ b/docs/types/_questdb_browser-client.QwpSenderSessionFactory.html @@ -0,0 +1,5 @@ +QwpSenderSessionFactory | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpSenderSessionFactory

    QwpSenderSessionFactory: (signal?: AbortSignal) => Promise<QwpSenderSession>

    Opens the sender's session. The signal is aborted by close(), so a connect +still negotiating can be torn down instead of outliving the sender by up to +its connect/auth deadline. Factories that ignore the parameter remain +assignable, matching QwpConnectionFactory.

    +

    Type declaration

    diff --git a/docs/types/_questdb_browser-client.QwpTarget.html b/docs/types/_questdb_browser-client.QwpTarget.html new file mode 100644 index 0000000..2dd7265 --- /dev/null +++ b/docs/types/_questdb_browser-client.QwpTarget.html @@ -0,0 +1,2 @@ +QwpTarget | QuestDB JavaScript Client - v4.2.0
    QwpTarget: typeof QWP_TARGET[keyof typeof QWP_TARGET]

    Server role accepted by an egress connection. Defaults to any.

    +
    diff --git a/docs/types/_questdb_browser-client.QwpTimestampUnit.html b/docs/types/_questdb_browser-client.QwpTimestampUnit.html new file mode 100644 index 0000000..792ce1d --- /dev/null +++ b/docs/types/_questdb_browser-client.QwpTimestampUnit.html @@ -0,0 +1 @@ +QwpTimestampUnit | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpTimestampUnit

    QwpTimestampUnit: "ns" | "us" | "ms"
    diff --git a/docs/types/_questdb_browser-client.QwpUpgradeErrorKind.html b/docs/types/_questdb_browser-client.QwpUpgradeErrorKind.html new file mode 100644 index 0000000..8158e08 --- /dev/null +++ b/docs/types/_questdb_browser-client.QwpUpgradeErrorKind.html @@ -0,0 +1 @@ +QwpUpgradeErrorKind | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/types/_questdb_browser-client.QwpUpgradeTimeoutPhase.html b/docs/types/_questdb_browser-client.QwpUpgradeTimeoutPhase.html new file mode 100644 index 0000000..acd680b --- /dev/null +++ b/docs/types/_questdb_browser-client.QwpUpgradeTimeoutPhase.html @@ -0,0 +1,2 @@ +QwpUpgradeTimeoutPhase | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpUpgradeTimeoutPhase

    QwpUpgradeTimeoutPhase: typeof QWP_UPGRADE_TIMEOUT_PHASE[keyof typeof QWP_UPGRADE_TIMEOUT_PHASE]

    Opening phase whose Node QWP deadline expired.

    +
    diff --git a/docs/types/_questdb_browser-client.QwpUuidInput.html b/docs/types/_questdb_browser-client.QwpUuidInput.html new file mode 100644 index 0000000..777ecfd --- /dev/null +++ b/docs/types/_questdb_browser-client.QwpUuidInput.html @@ -0,0 +1,4 @@ +QwpUuidInput | QuestDB JavaScript Client - v4.2.0
    QwpUuidInput: string | Uint8Array | { high: bigint; low: bigint }

    UUID input: canonical text, 16 canonical (RFC 4122) big-endian bytes, or the +egress limb pair. All three forms describe the same UUID; the byte form is +what uuid.parse() and java.util.UUID produce, not pre-encoded wire bytes.

    +
    diff --git a/docs/types/_questdb_browser-client.QwpWriterColumnKind.html b/docs/types/_questdb_browser-client.QwpWriterColumnKind.html new file mode 100644 index 0000000..d5c3693 --- /dev/null +++ b/docs/types/_questdb_browser-client.QwpWriterColumnKind.html @@ -0,0 +1 @@ +QwpWriterColumnKind | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpWriterColumnKind

    QwpWriterColumnKind:
        | "symbol"
        | "varchar"
        | "bool"
        | "byte"
        | "short"
        | "int32"
        | "int64"
        | "float32"
        | "float64"
        | "timestamp"
        | "date"
        | "char"
        | "binary"
        | "uuid"
        | "long256"
        | "ipv4"
        | "geohash"
        | "decimal64"
        | "decimal128"
        | "decimal256"
        | "doubleArray"
        | "longArray"
    diff --git a/docs/types/_questdb_browser-client.QwpWriterRow.html b/docs/types/_questdb_browser-client.QwpWriterRow.html new file mode 100644 index 0000000..bc85e00 --- /dev/null +++ b/docs/types/_questdb_browser-client.QwpWriterRow.html @@ -0,0 +1,2 @@ +QwpWriterRow | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpWriterRow<Schema>

    QwpWriterRow: {
        [Key in QwpDesignatedTimestampKey<Schema>]-?: QwpWriterColumnInput<
            Schema[Key],
        >
    } & {
        [Key in QwpRegularColumnKey<Schema>]?:
            | QwpWriterColumnInput<Schema[Key]>
            | null
    }

    The object accepted by a table writer compiled from Schema.

    +

    Type Parameters

    diff --git a/docs/types/_questdb_browser-client.QwpWriterSchema.html b/docs/types/_questdb_browser-client.QwpWriterSchema.html new file mode 100644 index 0000000..bcbd2a1 --- /dev/null +++ b/docs/types/_questdb_browser-client.QwpWriterSchema.html @@ -0,0 +1 @@ +QwpWriterSchema | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpWriterSchema

    QwpWriterSchema: Readonly<Record<string, QwpWriterColumn<unknown, boolean>>>
    diff --git a/docs/types/_questdb_nodejs-client.ExtraOptions.html b/docs/types/_questdb_nodejs-client.ExtraOptions.html new file mode 100644 index 0000000..6c1ac35 --- /dev/null +++ b/docs/types/_questdb_nodejs-client.ExtraOptions.html @@ -0,0 +1,6 @@ +ExtraOptions | QuestDB JavaScript Client - v4.2.0

    Type Alias ExtraOptions

    type ExtraOptions = {
        agent?: Agent | http.Agent | https.Agent;
        log?: Logger;
        qwp?: QwpExtraOptions;
    }
    Index

    Properties

    Properties

    agent?: Agent | http.Agent | https.Agent

    Transport-specific connection agent. Undici agents apply to the default +HTTP(S) transport; QWP ws/wss requires a Node http/https agent.

    +
    log?: Logger
    diff --git a/docs/types/_questdb_nodejs-client.Logger.html b/docs/types/_questdb_nodejs-client.Logger.html new file mode 100644 index 0000000..bd21e85 --- /dev/null +++ b/docs/types/_questdb_nodejs-client.Logger.html @@ -0,0 +1,4 @@ +Logger | QuestDB JavaScript Client - v4.2.0
    Logger: (
        level: "error" | "warn" | "info" | "debug",
        message: string | Error,
    ) => void

    Logger function type definition.

    +

    Type declaration

      • (level: "error" | "warn" | "info" | "debug", message: string | Error): void
      • Parameters

        • level: "error" | "warn" | "info" | "debug"

          The log level for the message

          +
        • message: string | Error

          The message to log, either a string or Error object

          +

        Returns void

    diff --git a/docs/types/_questdb_nodejs-client.QwpBindSetter.html b/docs/types/_questdb_nodejs-client.QwpBindSetter.html new file mode 100644 index 0000000..c47142e --- /dev/null +++ b/docs/types/_questdb_nodejs-client.QwpBindSetter.html @@ -0,0 +1 @@ +QwpBindSetter | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpBindSetter

    QwpBindSetter: (binds: QwpBindValues) => void

    Type declaration

    diff --git a/docs/types/_questdb_nodejs-client.QwpBindType.html b/docs/types/_questdb_nodejs-client.QwpBindType.html new file mode 100644 index 0000000..e420a35 --- /dev/null +++ b/docs/types/_questdb_nodejs-client.QwpBindType.html @@ -0,0 +1,2 @@ +QwpBindType | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpBindType

    QwpBindType:
        | typeof BOOLEAN
        | typeof BYTE
        | typeof SHORT
        | typeof INT
        | typeof LONG
        | typeof FLOAT
        | typeof DOUBLE
        | typeof TIMESTAMP
        | typeof DATE
        | typeof UUID
        | typeof LONG256
        | typeof GEOHASH
        | typeof VARCHAR
        | typeof TIMESTAMP_NANOS
        | typeof DECIMAL64
        | typeof DECIMAL128
        | typeof DECIMAL256
        | typeof CHAR

    Phase-1 scalar bind types exposed by the Java reference client.

    +
    diff --git a/docs/types/_questdb_nodejs-client.QwpColumnType.html b/docs/types/_questdb_nodejs-client.QwpColumnType.html new file mode 100644 index 0000000..f173eda --- /dev/null +++ b/docs/types/_questdb_nodejs-client.QwpColumnType.html @@ -0,0 +1 @@ +QwpColumnType | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/types/_questdb_nodejs-client.QwpConnectionFactory.html b/docs/types/_questdb_nodejs-client.QwpConnectionFactory.html new file mode 100644 index 0000000..3d83bbb --- /dev/null +++ b/docs/types/_questdb_nodejs-client.QwpConnectionFactory.html @@ -0,0 +1,5 @@ +QwpConnectionFactory | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpConnectionFactory

    QwpConnectionFactory: (signal?: AbortSignal) => Promise<QwpBinaryConnection>

    Opens one connection. The optional signal is aborted when the owning session +closes, so a factory that is still negotiating can tear its socket down +instead of leaving it alive until its own deadline expires. Factories that +ignore the parameter remain assignable.

    +

    Type declaration

    diff --git a/docs/types/_questdb_nodejs-client.QwpDecimalInput.html b/docs/types/_questdb_nodejs-client.QwpDecimalInput.html new file mode 100644 index 0000000..6bb2b5f --- /dev/null +++ b/docs/types/_questdb_nodejs-client.QwpDecimalInput.html @@ -0,0 +1,3 @@ +QwpDecimalInput | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpDecimalInput

    QwpDecimalInput: bigint | number | string | { scale: number; unscaled: bigint }

    DECIMAL input: the unscaled bigint at the column's scale, decimal text (or +a number) that is exactly representable at that scale, or the egress record.

    +
    diff --git a/docs/types/_questdb_nodejs-client.QwpDoubleArrayInput.html b/docs/types/_questdb_nodejs-client.QwpDoubleArrayInput.html new file mode 100644 index 0000000..a8b88e0 --- /dev/null +++ b/docs/types/_questdb_nodejs-client.QwpDoubleArrayInput.html @@ -0,0 +1,2 @@ +QwpDoubleArrayInput | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpDoubleArrayInput

    QwpDoubleArrayInput:
        | QwpNestedNumberArray
        | { dimensions: readonly number[]; values: readonly number[] }

    DOUBLE array input: nested arrays or a flat shape-and-values record.

    +
    diff --git a/docs/types/_questdb_nodejs-client.QwpEgressCompression.html b/docs/types/_questdb_nodejs-client.QwpEgressCompression.html new file mode 100644 index 0000000..7bc063c --- /dev/null +++ b/docs/types/_questdb_nodejs-client.QwpEgressCompression.html @@ -0,0 +1 @@ +QwpEgressCompression | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpEgressCompression

    QwpEgressCompression: "raw" | "zstd" | "auto"
    diff --git a/docs/types/_questdb_nodejs-client.QwpEgressMessage.html b/docs/types/_questdb_nodejs-client.QwpEgressMessage.html new file mode 100644 index 0000000..45c3ea0 --- /dev/null +++ b/docs/types/_questdb_nodejs-client.QwpEgressMessage.html @@ -0,0 +1 @@ +QwpEgressMessage | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/types/_questdb_nodejs-client.QwpExtraOptions.html b/docs/types/_questdb_nodejs-client.QwpExtraOptions.html new file mode 100644 index 0000000..ff0fe2a --- /dev/null +++ b/docs/types/_questdb_nodejs-client.QwpExtraOptions.html @@ -0,0 +1,11 @@ +QwpExtraOptions | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpExtraOptions

    type QwpExtraOptions = {
        sender?: QwpSenderOptions;
        session?: QwpIngressSessionOptions;
        udp?: Omit<QwpNodeUdpOptions, "host" | "port">;
        webSocket?: Omit<QwpNodeIngressOptions, "url">;
    }
    Index

    Properties

    High-level buffering and auto-flush options.

    +

    Ingress ACK, durable-ACK, and reconnect options.

    +
    udp?: Omit<QwpNodeUdpOptions, "host" | "port">

    Node-only QWP-over-UDP socket overrides.

    +
    webSocket?: Omit<QwpNodeIngressOptions, "url">

    Node ingress overrides. Values are applied after the connect string has +been fully parsed and validated; typed values win when both forms set the +same option.

    +
    diff --git a/docs/types/_questdb_nodejs-client.QwpGeohashInput.html b/docs/types/_questdb_nodejs-client.QwpGeohashInput.html new file mode 100644 index 0000000..93c701d --- /dev/null +++ b/docs/types/_questdb_nodejs-client.QwpGeohashInput.html @@ -0,0 +1,3 @@ +QwpGeohashInput | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpGeohashInput

    QwpGeohashInput:
        | bigint
        | number
        | string
        | { bits: bigint; precisionBits: number }

    GEOHASH input: the raw bits, base-32 geohash text whose length matches the +column precision, or the egress bit record.

    +
    diff --git a/docs/types/_questdb_nodejs-client.QwpIngressProgressKind.html b/docs/types/_questdb_nodejs-client.QwpIngressProgressKind.html new file mode 100644 index 0000000..40b466a --- /dev/null +++ b/docs/types/_questdb_nodejs-client.QwpIngressProgressKind.html @@ -0,0 +1 @@ +QwpIngressProgressKind | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/types/_questdb_nodejs-client.QwpInitialConnectMode.html b/docs/types/_questdb_nodejs-client.QwpInitialConnectMode.html new file mode 100644 index 0000000..d88b35b --- /dev/null +++ b/docs/types/_questdb_nodejs-client.QwpInitialConnectMode.html @@ -0,0 +1 @@ +QwpInitialConnectMode | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/types/_questdb_nodejs-client.QwpInt64.html b/docs/types/_questdb_nodejs-client.QwpInt64.html new file mode 100644 index 0000000..cb1738d --- /dev/null +++ b/docs/types/_questdb_nodejs-client.QwpInt64.html @@ -0,0 +1 @@ +QwpInt64 | QuestDB JavaScript Client - v4.2.0
    QwpInt64: number | bigint
    diff --git a/docs/types/_questdb_nodejs-client.QwpIpv4Input.html b/docs/types/_questdb_nodejs-client.QwpIpv4Input.html new file mode 100644 index 0000000..ff6d189 --- /dev/null +++ b/docs/types/_questdb_nodejs-client.QwpIpv4Input.html @@ -0,0 +1,2 @@ +QwpIpv4Input | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpIpv4Input

    QwpIpv4Input: string | number

    IPV4 input: dotted-quad text or a signed/unsigned packed 32-bit address.

    +
    diff --git a/docs/types/_questdb_nodejs-client.QwpLong256Input.html b/docs/types/_questdb_nodejs-client.QwpLong256Input.html new file mode 100644 index 0000000..9f30d46 --- /dev/null +++ b/docs/types/_questdb_nodejs-client.QwpLong256Input.html @@ -0,0 +1,3 @@ +QwpLong256Input | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpLong256Input

    QwpLong256Input: bigint | string | QwpLong256Words | { words: QwpLong256Words }

    LONG256 input: an unsigned 256-bit bigint, a 0x-prefixed hex string of +up to 64 digits, four little-endian words, or the egress word record.

    +
    diff --git a/docs/types/_questdb_nodejs-client.QwpLong256Words.html b/docs/types/_questdb_nodejs-client.QwpLong256Words.html new file mode 100644 index 0000000..4132858 --- /dev/null +++ b/docs/types/_questdb_nodejs-client.QwpLong256Words.html @@ -0,0 +1,2 @@ +QwpLong256Words | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpLong256Words

    QwpLong256Words: readonly [bigint, bigint, bigint, bigint]

    LONG256 little-endian words; word 0 is least significant.

    +
    diff --git a/docs/types/_questdb_nodejs-client.QwpLongArrayInput.html b/docs/types/_questdb_nodejs-client.QwpLongArrayInput.html new file mode 100644 index 0000000..7452c24 --- /dev/null +++ b/docs/types/_questdb_nodejs-client.QwpLongArrayInput.html @@ -0,0 +1,2 @@ +QwpLongArrayInput | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpLongArrayInput

    QwpLongArrayInput:
        | QwpNestedLongArray
        | { dimensions: readonly number[]; values: readonly (number | bigint)[] }

    LONG array input: nested arrays or a flat shape-and-values record.

    +
    diff --git a/docs/types/_questdb_nodejs-client.QwpNegotiatedEgressCompression.html b/docs/types/_questdb_nodejs-client.QwpNegotiatedEgressCompression.html new file mode 100644 index 0000000..f1a25cd --- /dev/null +++ b/docs/types/_questdb_nodejs-client.QwpNegotiatedEgressCompression.html @@ -0,0 +1 @@ +QwpNegotiatedEgressCompression | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpNegotiatedEgressCompression

    QwpNegotiatedEgressCompression:
        | { codec: "raw"; level: 0 }
        | { codec: "zstd"; level: number }
        | { codec: "unknown"; contentEncoding: string; level: 0 }
    diff --git a/docs/types/_questdb_nodejs-client.QwpNestedLongArray.html b/docs/types/_questdb_nodejs-client.QwpNestedLongArray.html new file mode 100644 index 0000000..cdea330 --- /dev/null +++ b/docs/types/_questdb_nodejs-client.QwpNestedLongArray.html @@ -0,0 +1,2 @@ +QwpNestedLongArray | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpNestedLongArray

    QwpNestedLongArray: readonly (number | bigint | QwpNestedLongArray)[]

    Nested LONG array of uniform shape.

    +
    diff --git a/docs/types/_questdb_nodejs-client.QwpNestedNumberArray.html b/docs/types/_questdb_nodejs-client.QwpNestedNumberArray.html new file mode 100644 index 0000000..d7dfb75 --- /dev/null +++ b/docs/types/_questdb_nodejs-client.QwpNestedNumberArray.html @@ -0,0 +1,2 @@ +QwpNestedNumberArray | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpNestedNumberArray

    QwpNestedNumberArray: readonly (number | QwpNestedNumberArray)[]

    Nested DOUBLE array of uniform shape.

    +
    diff --git a/docs/types/_questdb_nodejs-client.QwpNodeOrphanDrainEventKind.html b/docs/types/_questdb_nodejs-client.QwpNodeOrphanDrainEventKind.html new file mode 100644 index 0000000..ed1b51a --- /dev/null +++ b/docs/types/_questdb_nodejs-client.QwpNodeOrphanDrainEventKind.html @@ -0,0 +1 @@ +QwpNodeOrphanDrainEventKind | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/types/_questdb_nodejs-client.QwpQueryCompletion.html b/docs/types/_questdb_nodejs-client.QwpQueryCompletion.html new file mode 100644 index 0000000..e963919 --- /dev/null +++ b/docs/types/_questdb_nodejs-client.QwpQueryCompletion.html @@ -0,0 +1 @@ +QwpQueryCompletion | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/types/_questdb_nodejs-client.QwpReconnectEventKind.html b/docs/types/_questdb_nodejs-client.QwpReconnectEventKind.html new file mode 100644 index 0000000..f0f9bcd --- /dev/null +++ b/docs/types/_questdb_nodejs-client.QwpReconnectEventKind.html @@ -0,0 +1 @@ +QwpReconnectEventKind | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/types/_questdb_nodejs-client.QwpResultBatchViewHandler.html b/docs/types/_questdb_nodejs-client.QwpResultBatchViewHandler.html new file mode 100644 index 0000000..0dea2d1 --- /dev/null +++ b/docs/types/_questdb_nodejs-client.QwpResultBatchViewHandler.html @@ -0,0 +1,3 @@ +QwpResultBatchViewHandler | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpResultBatchViewHandler

    QwpResultBatchViewHandler: (
        batch: QwpResultBatchView,
        query: QwpEgressViewQuery,
    ) => void | Promise<void>

    Runs while one reusable batch view is valid. Do not retain the batch, +columns, or raw byte slices after the callback settles.

    +

    Type declaration

    diff --git a/docs/types/_questdb_nodejs-client.QwpResultRowViewCallback.html b/docs/types/_questdb_nodejs-client.QwpResultRowViewCallback.html new file mode 100644 index 0000000..536e1d8 --- /dev/null +++ b/docs/types/_questdb_nodejs-client.QwpResultRowViewCallback.html @@ -0,0 +1,2 @@ +QwpResultRowViewCallback | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpResultRowViewCallback

    QwpResultRowViewCallback: (row: QwpResultRowView) => void

    Callback invoked by QwpResultBatchView.forEachRow().

    +

    Type declaration

    diff --git a/docs/types/_questdb_nodejs-client.QwpResultValue.html b/docs/types/_questdb_nodejs-client.QwpResultValue.html new file mode 100644 index 0000000..6fd57e8 --- /dev/null +++ b/docs/types/_questdb_nodejs-client.QwpResultValue.html @@ -0,0 +1 @@ +QwpResultValue | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpResultValue

    QwpResultValue:
        | boolean
        | number
        | bigint
        | string
        | Uint8Array
        | QwpDecimalValue
        | QwpUuidValue
        | QwpLong256Value
        | QwpGeohashValue
        | QwpResultArrayValue
        | null
    diff --git a/docs/types/_questdb_nodejs-client.QwpSenderErrorCategory.html b/docs/types/_questdb_nodejs-client.QwpSenderErrorCategory.html new file mode 100644 index 0000000..006618b --- /dev/null +++ b/docs/types/_questdb_nodejs-client.QwpSenderErrorCategory.html @@ -0,0 +1 @@ +QwpSenderErrorCategory | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/types/_questdb_nodejs-client.QwpSenderErrorPolicy.html b/docs/types/_questdb_nodejs-client.QwpSenderErrorPolicy.html new file mode 100644 index 0000000..5399dc4 --- /dev/null +++ b/docs/types/_questdb_nodejs-client.QwpSenderErrorPolicy.html @@ -0,0 +1 @@ +QwpSenderErrorPolicy | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/types/_questdb_nodejs-client.QwpSenderLogger.html b/docs/types/_questdb_nodejs-client.QwpSenderLogger.html new file mode 100644 index 0000000..df340a3 --- /dev/null +++ b/docs/types/_questdb_nodejs-client.QwpSenderLogger.html @@ -0,0 +1 @@ +QwpSenderLogger | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpSenderLogger

    QwpSenderLogger: (
        level: "error" | "warn" | "info" | "debug",
        message: string | Error,
    ) => void

    Type declaration

      • (level: "error" | "warn" | "info" | "debug", message: string | Error): void
      • Parameters

        • level: "error" | "warn" | "info" | "debug"
        • message: string | Error

        Returns void

    diff --git a/docs/types/_questdb_nodejs-client.QwpSenderSessionFactory.html b/docs/types/_questdb_nodejs-client.QwpSenderSessionFactory.html new file mode 100644 index 0000000..1df3f38 --- /dev/null +++ b/docs/types/_questdb_nodejs-client.QwpSenderSessionFactory.html @@ -0,0 +1,5 @@ +QwpSenderSessionFactory | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpSenderSessionFactory

    QwpSenderSessionFactory: (signal?: AbortSignal) => Promise<QwpSenderSession>

    Opens the sender's session. The signal is aborted by close(), so a connect +still negotiating can be torn down instead of outliving the sender by up to +its connect/auth deadline. Factories that ignore the parameter remain +assignable, matching QwpConnectionFactory.

    +

    Type declaration

    diff --git a/docs/types/_questdb_nodejs-client.QwpSfBackpressurePolicy.html b/docs/types/_questdb_nodejs-client.QwpSfBackpressurePolicy.html new file mode 100644 index 0000000..86900db --- /dev/null +++ b/docs/types/_questdb_nodejs-client.QwpSfBackpressurePolicy.html @@ -0,0 +1 @@ +QwpSfBackpressurePolicy | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/types/_questdb_nodejs-client.QwpSfDurability.html b/docs/types/_questdb_nodejs-client.QwpSfDurability.html new file mode 100644 index 0000000..4e25840 --- /dev/null +++ b/docs/types/_questdb_nodejs-client.QwpSfDurability.html @@ -0,0 +1 @@ +QwpSfDurability | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/types/_questdb_nodejs-client.QwpTarget.html b/docs/types/_questdb_nodejs-client.QwpTarget.html new file mode 100644 index 0000000..9c2981a --- /dev/null +++ b/docs/types/_questdb_nodejs-client.QwpTarget.html @@ -0,0 +1,2 @@ +QwpTarget | QuestDB JavaScript Client - v4.2.0
    QwpTarget: typeof QWP_TARGET[keyof typeof QWP_TARGET]

    Server role accepted by an egress connection. Defaults to any.

    +
    diff --git a/docs/types/_questdb_nodejs-client.QwpTimestampUnit.html b/docs/types/_questdb_nodejs-client.QwpTimestampUnit.html new file mode 100644 index 0000000..984a91e --- /dev/null +++ b/docs/types/_questdb_nodejs-client.QwpTimestampUnit.html @@ -0,0 +1 @@ +QwpTimestampUnit | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpTimestampUnit

    QwpTimestampUnit: "ns" | "us" | "ms"
    diff --git a/docs/types/_questdb_nodejs-client.QwpUpgradeErrorKind.html b/docs/types/_questdb_nodejs-client.QwpUpgradeErrorKind.html new file mode 100644 index 0000000..8eb4d33 --- /dev/null +++ b/docs/types/_questdb_nodejs-client.QwpUpgradeErrorKind.html @@ -0,0 +1 @@ +QwpUpgradeErrorKind | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/types/_questdb_nodejs-client.QwpUpgradeTimeoutPhase.html b/docs/types/_questdb_nodejs-client.QwpUpgradeTimeoutPhase.html new file mode 100644 index 0000000..b7cfe84 --- /dev/null +++ b/docs/types/_questdb_nodejs-client.QwpUpgradeTimeoutPhase.html @@ -0,0 +1,2 @@ +QwpUpgradeTimeoutPhase | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpUpgradeTimeoutPhase

    QwpUpgradeTimeoutPhase: typeof QWP_UPGRADE_TIMEOUT_PHASE[keyof typeof QWP_UPGRADE_TIMEOUT_PHASE]

    Opening phase whose Node QWP deadline expired.

    +
    diff --git a/docs/types/_questdb_nodejs-client.QwpUuidInput.html b/docs/types/_questdb_nodejs-client.QwpUuidInput.html new file mode 100644 index 0000000..d3997a9 --- /dev/null +++ b/docs/types/_questdb_nodejs-client.QwpUuidInput.html @@ -0,0 +1,4 @@ +QwpUuidInput | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpUuidInput

    QwpUuidInput: string | Uint8Array | { high: bigint; low: bigint }

    UUID input: canonical text, 16 canonical (RFC 4122) big-endian bytes, or the +egress limb pair. All three forms describe the same UUID; the byte form is +what uuid.parse() and java.util.UUID produce, not pre-encoded wire bytes.

    +
    diff --git a/docs/types/_questdb_nodejs-client.QwpWriterColumnKind.html b/docs/types/_questdb_nodejs-client.QwpWriterColumnKind.html new file mode 100644 index 0000000..c59847b --- /dev/null +++ b/docs/types/_questdb_nodejs-client.QwpWriterColumnKind.html @@ -0,0 +1 @@ +QwpWriterColumnKind | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpWriterColumnKind

    QwpWriterColumnKind:
        | "symbol"
        | "varchar"
        | "bool"
        | "byte"
        | "short"
        | "int32"
        | "int64"
        | "float32"
        | "float64"
        | "timestamp"
        | "date"
        | "char"
        | "binary"
        | "uuid"
        | "long256"
        | "ipv4"
        | "geohash"
        | "decimal64"
        | "decimal128"
        | "decimal256"
        | "doubleArray"
        | "longArray"
    diff --git a/docs/types/_questdb_nodejs-client.QwpWriterRow.html b/docs/types/_questdb_nodejs-client.QwpWriterRow.html new file mode 100644 index 0000000..c08701a --- /dev/null +++ b/docs/types/_questdb_nodejs-client.QwpWriterRow.html @@ -0,0 +1,2 @@ +QwpWriterRow | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpWriterRow<Schema>

    QwpWriterRow: {
        [Key in QwpDesignatedTimestampKey<Schema>]-?: QwpWriterColumnInput<
            Schema[Key],
        >
    } & {
        [Key in QwpRegularColumnKey<Schema>]?:
            | QwpWriterColumnInput<Schema[Key]>
            | null
    }

    The object accepted by a table writer compiled from Schema.

    +

    Type Parameters

    diff --git a/docs/types/_questdb_nodejs-client.QwpWriterSchema.html b/docs/types/_questdb_nodejs-client.QwpWriterSchema.html new file mode 100644 index 0000000..912c770 --- /dev/null +++ b/docs/types/_questdb_nodejs-client.QwpWriterSchema.html @@ -0,0 +1 @@ +QwpWriterSchema | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpWriterSchema

    QwpWriterSchema: Readonly<Record<string, QwpWriterColumn<unknown, boolean>>>
    diff --git a/docs/types/_questdb_nodejs-client.TimestampUnit.html b/docs/types/_questdb_nodejs-client.TimestampUnit.html new file mode 100644 index 0000000..9321730 --- /dev/null +++ b/docs/types/_questdb_nodejs-client.TimestampUnit.html @@ -0,0 +1,2 @@ +TimestampUnit | QuestDB JavaScript Client - v4.2.0

    Type Alias TimestampUnit

    TimestampUnit: "ns" | "us" | "ms"

    Supported timestamp units for QuestDB operations.

    +
    diff --git a/docs/variables/_questdb_browser-client.QWP_COLUMN_TYPE.html b/docs/variables/_questdb_browser-client.QWP_COLUMN_TYPE.html new file mode 100644 index 0000000..a0cae32 --- /dev/null +++ b/docs/variables/_questdb_browser-client.QWP_COLUMN_TYPE.html @@ -0,0 +1 @@ +QWP_COLUMN_TYPE | QuestDB JavaScript Client - v4.2.0

    Variable QWP_COLUMN_TYPEConst

    QWP_COLUMN_TYPE: {
        BINARY: 23;
        BOOLEAN: 1;
        BYTE: 2;
        CHAR: 22;
        DATE: 11;
        DECIMAL128: 20;
        DECIMAL256: 21;
        DECIMAL64: 19;
        DOUBLE: 7;
        DOUBLE_ARRAY: 17;
        FLOAT: 6;
        GEOHASH: 14;
        INT: 4;
        IPV4: 24;
        LONG: 5;
        LONG_ARRAY: 18;
        LONG256: 13;
        SHORT: 3;
        SYMBOL: 9;
        TIMESTAMP: 10;
        TIMESTAMP_NANOS: 16;
        UUID: 12;
        VARCHAR: 15;
    } = ...

    Type declaration

    • ReadonlyBINARY: 23
    • ReadonlyBOOLEAN: 1
    • ReadonlyBYTE: 2
    • ReadonlyCHAR: 22
    • ReadonlyDATE: 11
    • ReadonlyDECIMAL128: 20
    • ReadonlyDECIMAL256: 21
    • ReadonlyDECIMAL64: 19
    • ReadonlyDOUBLE: 7
    • ReadonlyDOUBLE_ARRAY: 17
    • ReadonlyFLOAT: 6
    • ReadonlyGEOHASH: 14
    • ReadonlyINT: 4
    • ReadonlyIPV4: 24
    • ReadonlyLONG: 5
    • ReadonlyLONG_ARRAY: 18
    • ReadonlyLONG256: 13
    • ReadonlySHORT: 3
    • ReadonlySYMBOL: 9
    • ReadonlyTIMESTAMP: 10
    • ReadonlyTIMESTAMP_NANOS: 16
    • ReadonlyUUID: 12
    • ReadonlyVARCHAR: 15
    diff --git a/docs/variables/_questdb_browser-client.QWP_COMPRESSION_CODEC.html b/docs/variables/_questdb_browser-client.QWP_COMPRESSION_CODEC.html new file mode 100644 index 0000000..9f58c9a --- /dev/null +++ b/docs/variables/_questdb_browser-client.QWP_COMPRESSION_CODEC.html @@ -0,0 +1 @@ +QWP_COMPRESSION_CODEC | QuestDB JavaScript Client - v4.2.0

    Variable QWP_COMPRESSION_CODECConst

    QWP_COMPRESSION_CODEC: { RAW: 0; ZSTD: 1 } = ...

    Type declaration

    • ReadonlyRAW: 0
    • ReadonlyZSTD: 1
    diff --git a/docs/variables/_questdb_browser-client.QWP_DECIMAL_MAX_SCALE.html b/docs/variables/_questdb_browser-client.QWP_DECIMAL_MAX_SCALE.html new file mode 100644 index 0000000..f00138c --- /dev/null +++ b/docs/variables/_questdb_browser-client.QWP_DECIMAL_MAX_SCALE.html @@ -0,0 +1,2 @@ +QWP_DECIMAL_MAX_SCALE | QuestDB JavaScript Client - v4.2.0

    Variable QWP_DECIMAL_MAX_SCALEConst

    QWP_DECIMAL_MAX_SCALE: { decimal128: 38; decimal256: 76; decimal64: 18 } = ...

    Maximum DECIMAL scale of each fixed-width decimal column type.

    +

    Type declaration

    • Readonlydecimal128: 38
    • Readonlydecimal256: 76
    • Readonlydecimal64: 18
    diff --git a/docs/variables/_questdb_browser-client.QWP_DEFAULT_EGRESS_BUFFER_POOL_SIZE.html b/docs/variables/_questdb_browser-client.QWP_DEFAULT_EGRESS_BUFFER_POOL_SIZE.html new file mode 100644 index 0000000..a4169b5 --- /dev/null +++ b/docs/variables/_questdb_browser-client.QWP_DEFAULT_EGRESS_BUFFER_POOL_SIZE.html @@ -0,0 +1,2 @@ +QWP_DEFAULT_EGRESS_BUFFER_POOL_SIZE | QuestDB JavaScript Client - v4.2.0

    Variable QWP_DEFAULT_EGRESS_BUFFER_POOL_SIZEConst

    QWP_DEFAULT_EGRESS_BUFFER_POOL_SIZE: 4

    Default decoded result-buffer pool depth, matching the Java client.

    +
    diff --git a/docs/variables/_questdb_browser-client.QWP_DEFAULT_EGRESS_INITIAL_CREDIT.html b/docs/variables/_questdb_browser-client.QWP_DEFAULT_EGRESS_INITIAL_CREDIT.html new file mode 100644 index 0000000..ae7ed55 --- /dev/null +++ b/docs/variables/_questdb_browser-client.QWP_DEFAULT_EGRESS_INITIAL_CREDIT.html @@ -0,0 +1,2 @@ +QWP_DEFAULT_EGRESS_INITIAL_CREDIT | QuestDB JavaScript Client - v4.2.0

    Variable QWP_DEFAULT_EGRESS_INITIAL_CREDITConst

    QWP_DEFAULT_EGRESS_INITIAL_CREDIT: 0

    Default send-ahead credit used by Java and TypeScript: zero is unbounded.

    +
    diff --git a/docs/variables/_questdb_browser-client.QWP_DEFAULT_EGRESS_SERVER_INFO_TIMEOUT_MS.html b/docs/variables/_questdb_browser-client.QWP_DEFAULT_EGRESS_SERVER_INFO_TIMEOUT_MS.html new file mode 100644 index 0000000..c7c41a9 --- /dev/null +++ b/docs/variables/_questdb_browser-client.QWP_DEFAULT_EGRESS_SERVER_INFO_TIMEOUT_MS.html @@ -0,0 +1,2 @@ +QWP_DEFAULT_EGRESS_SERVER_INFO_TIMEOUT_MS | QuestDB JavaScript Client - v4.2.0

    Variable QWP_DEFAULT_EGRESS_SERVER_INFO_TIMEOUT_MSConst

    QWP_DEFAULT_EGRESS_SERVER_INFO_TIMEOUT_MS: 5000 = 5_000

    Default wait for the initial or reconnected SERVER_INFO frame.

    +
    diff --git a/docs/variables/_questdb_browser-client.QWP_DURABLE_ACK_WEBSOCKET_PROTOCOL.html b/docs/variables/_questdb_browser-client.QWP_DURABLE_ACK_WEBSOCKET_PROTOCOL.html new file mode 100644 index 0000000..761f495 --- /dev/null +++ b/docs/variables/_questdb_browser-client.QWP_DURABLE_ACK_WEBSOCKET_PROTOCOL.html @@ -0,0 +1,3 @@ +QWP_DURABLE_ACK_WEBSOCKET_PROTOCOL | QuestDB JavaScript Client - v4.2.0

    Variable QWP_DURABLE_ACK_WEBSOCKET_PROTOCOLConst

    QWP_DURABLE_ACK_WEBSOCKET_PROTOCOL: "questdb.qwp.durable-ack.v1"

    Browser-visible WebSocket subprotocol used to request and confirm durable +ingress acknowledgements. Browsers cannot set or inspect X-QWP-* headers.

    +
    diff --git a/docs/variables/_questdb_browser-client.QWP_EGRESS_CAPABILITY.html b/docs/variables/_questdb_browser-client.QWP_EGRESS_CAPABILITY.html new file mode 100644 index 0000000..415fe29 --- /dev/null +++ b/docs/variables/_questdb_browser-client.QWP_EGRESS_CAPABILITY.html @@ -0,0 +1 @@ +QWP_EGRESS_CAPABILITY | QuestDB JavaScript Client - v4.2.0

    Variable QWP_EGRESS_CAPABILITYConst

    QWP_EGRESS_CAPABILITY: { COMPRESSION: 4; QUERY_FLAGS: 2; ZONE: 1 } = ...

    Type declaration

    • ReadonlyCOMPRESSION: 4
    • ReadonlyQUERY_FLAGS: 2
    • ReadonlyZONE: 1
    diff --git a/docs/variables/_questdb_browser-client.QWP_EGRESS_MESSAGE.html b/docs/variables/_questdb_browser-client.QWP_EGRESS_MESSAGE.html new file mode 100644 index 0000000..26cc165 --- /dev/null +++ b/docs/variables/_questdb_browser-client.QWP_EGRESS_MESSAGE.html @@ -0,0 +1 @@ +QWP_EGRESS_MESSAGE | QuestDB JavaScript Client - v4.2.0

    Variable QWP_EGRESS_MESSAGEConst

    QWP_EGRESS_MESSAGE: {
        CACHE_RESET: 23;
        CANCEL: 20;
        CREDIT: 21;
        EXEC_DONE: 22;
        QUERY_ERROR: 19;
        QUERY_REQUEST: 16;
        RESULT_BATCH: 17;
        RESULT_END: 18;
        SERVER_INFO: 24;
    } = ...

    Type declaration

    • ReadonlyCACHE_RESET: 23
    • ReadonlyCANCEL: 20
    • ReadonlyCREDIT: 21
    • ReadonlyEXEC_DONE: 22
    • ReadonlyQUERY_ERROR: 19
    • ReadonlyQUERY_REQUEST: 16
    • ReadonlyRESULT_BATCH: 17
    • ReadonlyRESULT_END: 18
    • ReadonlySERVER_INFO: 24
    diff --git a/docs/variables/_questdb_browser-client.QWP_EGRESS_PATH.html b/docs/variables/_questdb_browser-client.QWP_EGRESS_PATH.html new file mode 100644 index 0000000..0575b49 --- /dev/null +++ b/docs/variables/_questdb_browser-client.QWP_EGRESS_PATH.html @@ -0,0 +1 @@ +QWP_EGRESS_PATH | QuestDB JavaScript Client - v4.2.0

    Variable QWP_EGRESS_PATHConst

    QWP_EGRESS_PATH: "/read/v1"
    diff --git a/docs/variables/_questdb_browser-client.QWP_ENCODING_GORILLA.html b/docs/variables/_questdb_browser-client.QWP_ENCODING_GORILLA.html new file mode 100644 index 0000000..fdc2689 --- /dev/null +++ b/docs/variables/_questdb_browser-client.QWP_ENCODING_GORILLA.html @@ -0,0 +1 @@ +QWP_ENCODING_GORILLA | QuestDB JavaScript Client - v4.2.0

    Variable QWP_ENCODING_GORILLAConst

    QWP_ENCODING_GORILLA: 1 = 0x01
    diff --git a/docs/variables/_questdb_browser-client.QWP_ENCODING_UNCOMPRESSED.html b/docs/variables/_questdb_browser-client.QWP_ENCODING_UNCOMPRESSED.html new file mode 100644 index 0000000..7ef3f21 --- /dev/null +++ b/docs/variables/_questdb_browser-client.QWP_ENCODING_UNCOMPRESSED.html @@ -0,0 +1 @@ +QWP_ENCODING_UNCOMPRESSED | QuestDB JavaScript Client - v4.2.0

    Variable QWP_ENCODING_UNCOMPRESSEDConst

    QWP_ENCODING_UNCOMPRESSED: 0 = 0x00
    diff --git a/docs/variables/_questdb_browser-client.QWP_FLAG_DEFER_COMMIT.html b/docs/variables/_questdb_browser-client.QWP_FLAG_DEFER_COMMIT.html new file mode 100644 index 0000000..2836d2e --- /dev/null +++ b/docs/variables/_questdb_browser-client.QWP_FLAG_DEFER_COMMIT.html @@ -0,0 +1 @@ +QWP_FLAG_DEFER_COMMIT | QuestDB JavaScript Client - v4.2.0

    Variable QWP_FLAG_DEFER_COMMITConst

    QWP_FLAG_DEFER_COMMIT: 1 = 0x01
    diff --git a/docs/variables/_questdb_browser-client.QWP_FLAG_DELTA_SYMBOL_DICTIONARY.html b/docs/variables/_questdb_browser-client.QWP_FLAG_DELTA_SYMBOL_DICTIONARY.html new file mode 100644 index 0000000..67bebae --- /dev/null +++ b/docs/variables/_questdb_browser-client.QWP_FLAG_DELTA_SYMBOL_DICTIONARY.html @@ -0,0 +1 @@ +QWP_FLAG_DELTA_SYMBOL_DICTIONARY | QuestDB JavaScript Client - v4.2.0

    Variable QWP_FLAG_DELTA_SYMBOL_DICTIONARYConst

    QWP_FLAG_DELTA_SYMBOL_DICTIONARY: 8 = 0x08
    diff --git a/docs/variables/_questdb_browser-client.QWP_FLAG_DURABLE_ACK_POLL.html b/docs/variables/_questdb_browser-client.QWP_FLAG_DURABLE_ACK_POLL.html new file mode 100644 index 0000000..27edeb7 --- /dev/null +++ b/docs/variables/_questdb_browser-client.QWP_FLAG_DURABLE_ACK_POLL.html @@ -0,0 +1,2 @@ +QWP_FLAG_DURABLE_ACK_POLL | QuestDB JavaScript Client - v4.2.0

    Variable QWP_FLAG_DURABLE_ACK_POLLConst

    QWP_FLAG_DURABLE_ACK_POLL: 2 = 0x02

    Table-less ingress control frame that polls negotiated durable-ACK progress.

    +
    diff --git a/docs/variables/_questdb_browser-client.QWP_FLAG_GORILLA.html b/docs/variables/_questdb_browser-client.QWP_FLAG_GORILLA.html new file mode 100644 index 0000000..51c4aa5 --- /dev/null +++ b/docs/variables/_questdb_browser-client.QWP_FLAG_GORILLA.html @@ -0,0 +1 @@ +QWP_FLAG_GORILLA | QuestDB JavaScript Client - v4.2.0

    Variable QWP_FLAG_GORILLAConst

    QWP_FLAG_GORILLA: 4 = 0x04
    diff --git a/docs/variables/_questdb_browser-client.QWP_FLAG_ZSTD.html b/docs/variables/_questdb_browser-client.QWP_FLAG_ZSTD.html new file mode 100644 index 0000000..f93040c --- /dev/null +++ b/docs/variables/_questdb_browser-client.QWP_FLAG_ZSTD.html @@ -0,0 +1 @@ +QWP_FLAG_ZSTD | QuestDB JavaScript Client - v4.2.0

    Variable QWP_FLAG_ZSTDConst

    QWP_FLAG_ZSTD: 16 = 0x10
    diff --git a/docs/variables/_questdb_browser-client.QWP_HEADER_SIZE.html b/docs/variables/_questdb_browser-client.QWP_HEADER_SIZE.html new file mode 100644 index 0000000..2179ab6 --- /dev/null +++ b/docs/variables/_questdb_browser-client.QWP_HEADER_SIZE.html @@ -0,0 +1 @@ +QWP_HEADER_SIZE | QuestDB JavaScript Client - v4.2.0

    Variable QWP_HEADER_SIZEConst

    QWP_HEADER_SIZE: 12
    diff --git a/docs/variables/_questdb_browser-client.QWP_INGRESS_PATH.html b/docs/variables/_questdb_browser-client.QWP_INGRESS_PATH.html new file mode 100644 index 0000000..fd42a3a --- /dev/null +++ b/docs/variables/_questdb_browser-client.QWP_INGRESS_PATH.html @@ -0,0 +1 @@ +QWP_INGRESS_PATH | QuestDB JavaScript Client - v4.2.0

    Variable QWP_INGRESS_PATHConst

    QWP_INGRESS_PATH: "/write/v4"
    diff --git a/docs/variables/_questdb_browser-client.QWP_INGRESS_PROGRESS_KIND.html b/docs/variables/_questdb_browser-client.QWP_INGRESS_PROGRESS_KIND.html new file mode 100644 index 0000000..f556099 --- /dev/null +++ b/docs/variables/_questdb_browser-client.QWP_INGRESS_PROGRESS_KIND.html @@ -0,0 +1 @@ +QWP_INGRESS_PROGRESS_KIND | QuestDB JavaScript Client - v4.2.0

    Variable QWP_INGRESS_PROGRESS_KINDConst

    QWP_INGRESS_PROGRESS_KIND: {
        ACKNOWLEDGED: "acknowledged";
        DURABLE_ACKNOWLEDGED: "durable-acknowledged";
        PUBLISHED: "published";
    } = ...

    Type declaration

    • ReadonlyACKNOWLEDGED: "acknowledged"
    • ReadonlyDURABLE_ACKNOWLEDGED: "durable-acknowledged"
    • ReadonlyPUBLISHED: "published"
    diff --git a/docs/variables/_questdb_browser-client.QWP_INITIAL_CONNECT_MODE.html b/docs/variables/_questdb_browser-client.QWP_INITIAL_CONNECT_MODE.html new file mode 100644 index 0000000..c3bab54 --- /dev/null +++ b/docs/variables/_questdb_browser-client.QWP_INITIAL_CONNECT_MODE.html @@ -0,0 +1,7 @@ +QWP_INITIAL_CONNECT_MODE | QuestDB JavaScript Client - v4.2.0

    Variable QWP_INITIAL_CONNECT_MODEConst

    QWP_INITIAL_CONNECT_MODE: { ASYNC: "async"; OFF: "off"; SYNC: "sync" } = ...

    Initial connection policy for an ingress reconnect session. Public browser +and memory-only helpers resolve their default internally; Node persistent +store-and-forward exposes all three modes.

    +

    Type declaration

    • ReadonlyASYNC: "async"

      Return immediately and connect on the background replay loop.

      +
    • ReadonlyOFF: "off"

      Try once on the caller and fail immediately.

      +
    • ReadonlySYNC: "sync"

      Retry on the caller within the configured reconnect budget.

      +
    diff --git a/docs/variables/_questdb_browser-client.QWP_MAGIC.html b/docs/variables/_questdb_browser-client.QWP_MAGIC.html new file mode 100644 index 0000000..6d9e27b --- /dev/null +++ b/docs/variables/_questdb_browser-client.QWP_MAGIC.html @@ -0,0 +1,2 @@ +QWP_MAGIC | QuestDB JavaScript Client - v4.2.0

    Variable QWP_MAGICConst

    QWP_MAGIC: 827348817 = 0x31505751

    ASCII QWP1, represented as its little-endian uint32 value.

    +
    diff --git a/docs/variables/_questdb_browser-client.QWP_MAX_ARRAY_DIMENSIONS.html b/docs/variables/_questdb_browser-client.QWP_MAX_ARRAY_DIMENSIONS.html new file mode 100644 index 0000000..2987a81 --- /dev/null +++ b/docs/variables/_questdb_browser-client.QWP_MAX_ARRAY_DIMENSIONS.html @@ -0,0 +1,2 @@ +QWP_MAX_ARRAY_DIMENSIONS | QuestDB JavaScript Client - v4.2.0

    Variable QWP_MAX_ARRAY_DIMENSIONSConst

    QWP_MAX_ARRAY_DIMENSIONS: 32

    Maximum array rank accepted by QuestDB's QWP ingress decoder.

    +
    diff --git a/docs/variables/_questdb_browser-client.QWP_MAX_ARRAY_DIMENSION_LENGTH.html b/docs/variables/_questdb_browser-client.QWP_MAX_ARRAY_DIMENSION_LENGTH.html new file mode 100644 index 0000000..1ddc669 --- /dev/null +++ b/docs/variables/_questdb_browser-client.QWP_MAX_ARRAY_DIMENSION_LENGTH.html @@ -0,0 +1,2 @@ +QWP_MAX_ARRAY_DIMENSION_LENGTH | QuestDB JavaScript Client - v4.2.0

    Variable QWP_MAX_ARRAY_DIMENSION_LENGTHConst

    QWP_MAX_ARRAY_DIMENSION_LENGTH: 2147483647 = 2_147_483_647

    Maximum signed int32 array-axis length accepted by QWP ingress.

    +
    diff --git a/docs/variables/_questdb_browser-client.QWP_MAX_BATCH_ROWS_UPPER_BOUND.html b/docs/variables/_questdb_browser-client.QWP_MAX_BATCH_ROWS_UPPER_BOUND.html new file mode 100644 index 0000000..7937161 --- /dev/null +++ b/docs/variables/_questdb_browser-client.QWP_MAX_BATCH_ROWS_UPPER_BOUND.html @@ -0,0 +1,2 @@ +QWP_MAX_BATCH_ROWS_UPPER_BOUND | QuestDB JavaScript Client - v4.2.0

    Variable QWP_MAX_BATCH_ROWS_UPPER_BOUNDConst

    QWP_MAX_BATCH_ROWS_UPPER_BOUND: 1048576 = 1_048_576

    Largest client-requested egress RESULT_BATCH row cap.

    +
    diff --git a/docs/variables/_questdb_browser-client.QWP_MAX_CELLS_PER_BATCH.html b/docs/variables/_questdb_browser-client.QWP_MAX_CELLS_PER_BATCH.html new file mode 100644 index 0000000..75450b2 --- /dev/null +++ b/docs/variables/_questdb_browser-client.QWP_MAX_CELLS_PER_BATCH.html @@ -0,0 +1,13 @@ +QWP_MAX_CELLS_PER_BATCH | QuestDB JavaScript Client - v4.2.0

    Variable QWP_MAX_CELLS_PER_BATCHConst

    QWP_MAX_CELLS_PER_BATCH: 33554432 = 33_554_432

    Largest rowCount * columnCount a single RESULT_BATCH may declare.

    +

    The row and column caps above bound each dimension on its own, and their +product does not have to be reachable: 1,048,576 rows of 2,048 columns is +2.1 billion cells. Decoding materializes two rowCount-length arrays per +column, measured at 16 bytes per cell, so the product is what decides how +much memory a response can cost. It is also the dimension a compressed body +detaches from the wire: an all-NULL column is one bit per cell before zstd, +so without this bound a few kilobytes of RLE-compressed bitmap declares a +grid no heap can hold.

    +

    32Mi cells is roughly 512 MB decoded. That is far above any plausible +result -- the widest supported table at 16k rows, or a full 1,048,576-row +batch at 32 columns -- and far below what the caps alone would permit.

    +
    diff --git a/docs/variables/_questdb_browser-client.QWP_MAX_COLUMNS_PER_TABLE.html b/docs/variables/_questdb_browser-client.QWP_MAX_COLUMNS_PER_TABLE.html new file mode 100644 index 0000000..2b93c41 --- /dev/null +++ b/docs/variables/_questdb_browser-client.QWP_MAX_COLUMNS_PER_TABLE.html @@ -0,0 +1 @@ +QWP_MAX_COLUMNS_PER_TABLE | QuestDB JavaScript Client - v4.2.0

    Variable QWP_MAX_COLUMNS_PER_TABLEConst

    QWP_MAX_COLUMNS_PER_TABLE: 2048
    diff --git a/docs/variables/_questdb_browser-client.QWP_MAX_COLUMN_NAME_LENGTH.html b/docs/variables/_questdb_browser-client.QWP_MAX_COLUMN_NAME_LENGTH.html new file mode 100644 index 0000000..98d1d51 --- /dev/null +++ b/docs/variables/_questdb_browser-client.QWP_MAX_COLUMN_NAME_LENGTH.html @@ -0,0 +1,2 @@ +QWP_MAX_COLUMN_NAME_LENGTH | QuestDB JavaScript Client - v4.2.0

    Variable QWP_MAX_COLUMN_NAME_LENGTHConst

    QWP_MAX_COLUMN_NAME_LENGTH: 127

    Default QWP ingress identifier limits, in UTF-8 wire bytes.

    +
    diff --git a/docs/variables/_questdb_browser-client.QWP_MAX_ERROR_MESSAGE_LENGTH.html b/docs/variables/_questdb_browser-client.QWP_MAX_ERROR_MESSAGE_LENGTH.html new file mode 100644 index 0000000..dd8c9d2 --- /dev/null +++ b/docs/variables/_questdb_browser-client.QWP_MAX_ERROR_MESSAGE_LENGTH.html @@ -0,0 +1 @@ +QWP_MAX_ERROR_MESSAGE_LENGTH | QuestDB JavaScript Client - v4.2.0

    Variable QWP_MAX_ERROR_MESSAGE_LENGTHConst

    QWP_MAX_ERROR_MESSAGE_LENGTH: 1024
    diff --git a/docs/variables/_questdb_browser-client.QWP_MAX_IDENTIFIER_BYTES.html b/docs/variables/_questdb_browser-client.QWP_MAX_IDENTIFIER_BYTES.html new file mode 100644 index 0000000..36ccdba --- /dev/null +++ b/docs/variables/_questdb_browser-client.QWP_MAX_IDENTIFIER_BYTES.html @@ -0,0 +1,6 @@ +QWP_MAX_IDENTIFIER_BYTES | QuestDB JavaScript Client - v4.2.0

    Variable QWP_MAX_IDENTIFIER_BYTESConst

    QWP_MAX_IDENTIFIER_BYTES: number = ...

    Defensive byte bound for identifiers decoded from query results.

    +

    Existing tables may have names created through APIs that apply Java's +127-UTF-16-code-unit metadata limit. One code unit takes at most three UTF-8 +bytes, so query decoding accepts that larger representation even though QWP +ingress enforces its 127-byte protocol limit.

    +
    diff --git a/docs/variables/_questdb_browser-client.QWP_MAX_ROWS_PER_TABLE.html b/docs/variables/_questdb_browser-client.QWP_MAX_ROWS_PER_TABLE.html new file mode 100644 index 0000000..674ecab --- /dev/null +++ b/docs/variables/_questdb_browser-client.QWP_MAX_ROWS_PER_TABLE.html @@ -0,0 +1 @@ +QWP_MAX_ROWS_PER_TABLE | QuestDB JavaScript Client - v4.2.0

    Variable QWP_MAX_ROWS_PER_TABLEConst

    QWP_MAX_ROWS_PER_TABLE: 1000000 = 1_000_000
    diff --git a/docs/variables/_questdb_browser-client.QWP_MAX_SYMBOL_DICTIONARY_SIZE.html b/docs/variables/_questdb_browser-client.QWP_MAX_SYMBOL_DICTIONARY_SIZE.html new file mode 100644 index 0000000..3386ba5 --- /dev/null +++ b/docs/variables/_questdb_browser-client.QWP_MAX_SYMBOL_DICTIONARY_SIZE.html @@ -0,0 +1 @@ +QWP_MAX_SYMBOL_DICTIONARY_SIZE | QuestDB JavaScript Client - v4.2.0

    Variable QWP_MAX_SYMBOL_DICTIONARY_SIZEConst

    QWP_MAX_SYMBOL_DICTIONARY_SIZE: 1000000 = 1_000_000
    diff --git a/docs/variables/_questdb_browser-client.QWP_MAX_TABLE_NAME_LENGTH.html b/docs/variables/_questdb_browser-client.QWP_MAX_TABLE_NAME_LENGTH.html new file mode 100644 index 0000000..f57ced0 --- /dev/null +++ b/docs/variables/_questdb_browser-client.QWP_MAX_TABLE_NAME_LENGTH.html @@ -0,0 +1 @@ +QWP_MAX_TABLE_NAME_LENGTH | QuestDB JavaScript Client - v4.2.0

    Variable QWP_MAX_TABLE_NAME_LENGTHConst

    QWP_MAX_TABLE_NAME_LENGTH: 127
    diff --git a/docs/variables/_questdb_browser-client.QWP_MAX_ZSTD_DECOMPRESSED_SIZE.html b/docs/variables/_questdb_browser-client.QWP_MAX_ZSTD_DECOMPRESSED_SIZE.html new file mode 100644 index 0000000..1e5daa5 --- /dev/null +++ b/docs/variables/_questdb_browser-client.QWP_MAX_ZSTD_DECOMPRESSED_SIZE.html @@ -0,0 +1,2 @@ +QWP_MAX_ZSTD_DECOMPRESSED_SIZE | QuestDB JavaScript Client - v4.2.0

    Variable QWP_MAX_ZSTD_DECOMPRESSED_SIZEConst

    QWP_MAX_ZSTD_DECOMPRESSED_SIZE: number = ...

    Matches the Java client's per-connection decompression safety cap.

    +
    diff --git a/docs/variables/_questdb_browser-client.QWP_QUERY_FLAG_RESET_DICTIONARY.html b/docs/variables/_questdb_browser-client.QWP_QUERY_FLAG_RESET_DICTIONARY.html new file mode 100644 index 0000000..3aa1500 --- /dev/null +++ b/docs/variables/_questdb_browser-client.QWP_QUERY_FLAG_RESET_DICTIONARY.html @@ -0,0 +1 @@ +QWP_QUERY_FLAG_RESET_DICTIONARY | QuestDB JavaScript Client - v4.2.0

    Variable QWP_QUERY_FLAG_RESET_DICTIONARYConst

    QWP_QUERY_FLAG_RESET_DICTIONARY: 1 = 0x01
    diff --git a/docs/variables/_questdb_browser-client.QWP_RECONNECT_EVENT_KIND.html b/docs/variables/_questdb_browser-client.QWP_RECONNECT_EVENT_KIND.html new file mode 100644 index 0000000..105883c --- /dev/null +++ b/docs/variables/_questdb_browser-client.QWP_RECONNECT_EVENT_KIND.html @@ -0,0 +1,4 @@ +QWP_RECONNECT_EVENT_KIND | QuestDB JavaScript Client - v4.2.0

    Variable QWP_RECONNECT_EVENT_KINDConst

    QWP_RECONNECT_EVENT_KIND: {
        ATTEMPT_FAILED: "attempt-failed";
        CONNECTED: "connected";
        DURABLE_ACK_PERSISTENT_FAILURE: "durable-ack-persistent-failure";
        DURABLE_ACK_UNAVAILABLE: "durable-ack-unavailable";
        FAILED_OVER: "failed-over";
        PRIMARY_UNAVAILABLE: "primary-unavailable";
        RECONNECTED: "reconnected";
        RECONNECTING: "reconnecting";
    } = ...

    Type declaration

    • ReadonlyATTEMPT_FAILED: "attempt-failed"
    • ReadonlyCONNECTED: "connected"
    • ReadonlyDURABLE_ACK_PERSISTENT_FAILURE: "durable-ack-persistent-failure"

      An orphan exhausted its consecutive durable-ACK mismatch budget.

      +
    • ReadonlyDURABLE_ACK_UNAVAILABLE: "durable-ack-unavailable"

      An unbounded SF loop is waiting for durable-ACK-capable endpoints.

      +
    • ReadonlyFAILED_OVER: "failed-over"
    • ReadonlyPRIMARY_UNAVAILABLE: "primary-unavailable"

      Every reachable ingress endpoint is temporarily unable to be primary.

      +
    • ReadonlyRECONNECTED: "reconnected"
    • ReadonlyRECONNECTING: "reconnecting"
    diff --git a/docs/variables/_questdb_browser-client.QWP_RESET_MASK_DICTIONARY.html b/docs/variables/_questdb_browser-client.QWP_RESET_MASK_DICTIONARY.html new file mode 100644 index 0000000..ff948d7 --- /dev/null +++ b/docs/variables/_questdb_browser-client.QWP_RESET_MASK_DICTIONARY.html @@ -0,0 +1 @@ +QWP_RESET_MASK_DICTIONARY | QuestDB JavaScript Client - v4.2.0

    Variable QWP_RESET_MASK_DICTIONARYConst

    QWP_RESET_MASK_DICTIONARY: 1 = 0x01
    diff --git a/docs/variables/_questdb_browser-client.QWP_SENDER_ERROR_CATEGORY.html b/docs/variables/_questdb_browser-client.QWP_SENDER_ERROR_CATEGORY.html new file mode 100644 index 0000000..de4a2e1 --- /dev/null +++ b/docs/variables/_questdb_browser-client.QWP_SENDER_ERROR_CATEGORY.html @@ -0,0 +1 @@ +QWP_SENDER_ERROR_CATEGORY | QuestDB JavaScript Client - v4.2.0

    Variable QWP_SENDER_ERROR_CATEGORYConst

    QWP_SENDER_ERROR_CATEGORY: {
        DATA_LOSS: "data-loss";
        DICTIONARY_GAP: "dictionary-gap";
        INTERNAL_ERROR: "internal-error";
        NOT_WRITABLE: "not-writable";
        PARSE_ERROR: "parse-error";
        PROTOCOL_VIOLATION: "protocol-violation";
        SCHEMA_MISMATCH: "schema-mismatch";
        SECURITY_ERROR: "security-error";
        UNKNOWN: "unknown";
        WRITE_ERROR: "write-error";
    } = ...

    Type declaration

    • ReadonlyDATA_LOSS: "data-loss"
    • ReadonlyDICTIONARY_GAP: "dictionary-gap"
    • ReadonlyINTERNAL_ERROR: "internal-error"
    • ReadonlyNOT_WRITABLE: "not-writable"
    • ReadonlyPARSE_ERROR: "parse-error"
    • ReadonlyPROTOCOL_VIOLATION: "protocol-violation"
    • ReadonlySCHEMA_MISMATCH: "schema-mismatch"
    • ReadonlySECURITY_ERROR: "security-error"
    • ReadonlyUNKNOWN: "unknown"
    • ReadonlyWRITE_ERROR: "write-error"
    diff --git a/docs/variables/_questdb_browser-client.QWP_SENDER_ERROR_POLICY.html b/docs/variables/_questdb_browser-client.QWP_SENDER_ERROR_POLICY.html new file mode 100644 index 0000000..2e7aa88 --- /dev/null +++ b/docs/variables/_questdb_browser-client.QWP_SENDER_ERROR_POLICY.html @@ -0,0 +1 @@ +QWP_SENDER_ERROR_POLICY | QuestDB JavaScript Client - v4.2.0

    Variable QWP_SENDER_ERROR_POLICYConst

    QWP_SENDER_ERROR_POLICY: {
        ABANDONED: "abandoned";
        RETRIABLE: "retriable";
        RETRIABLE_OTHER: "retriable-other";
        TERMINAL: "terminal";
    } = ...

    Type declaration

    • ReadonlyABANDONED: "abandoned"
    • ReadonlyRETRIABLE: "retriable"
    • ReadonlyRETRIABLE_OTHER: "retriable-other"
    • ReadonlyTERMINAL: "terminal"
    diff --git a/docs/variables/_questdb_browser-client.QWP_SERVER_ROLE.html b/docs/variables/_questdb_browser-client.QWP_SERVER_ROLE.html new file mode 100644 index 0000000..46c21bf --- /dev/null +++ b/docs/variables/_questdb_browser-client.QWP_SERVER_ROLE.html @@ -0,0 +1 @@ +QWP_SERVER_ROLE | QuestDB JavaScript Client - v4.2.0

    Variable QWP_SERVER_ROLEConst

    QWP_SERVER_ROLE: { PRIMARY: 1; PRIMARY_CATCHUP: 3; REPLICA: 2; STANDALONE: 0 } = ...

    Type declaration

    • ReadonlyPRIMARY: 1
    • ReadonlyPRIMARY_CATCHUP: 3
    • ReadonlyREPLICA: 2
    • ReadonlySTANDALONE: 0
    diff --git a/docs/variables/_questdb_browser-client.QWP_STATUS.html b/docs/variables/_questdb_browser-client.QWP_STATUS.html new file mode 100644 index 0000000..e590ff7 --- /dev/null +++ b/docs/variables/_questdb_browser-client.QWP_STATUS.html @@ -0,0 +1 @@ +QWP_STATUS | QuestDB JavaScript Client - v4.2.0

    Variable QWP_STATUSConst

    QWP_STATUS: {
        CANCELLED: 10;
        DICTIONARY_GAP: 13;
        DURABLE_ACK: 2;
        INTERNAL_ERROR: 6;
        LIMIT_EXCEEDED: 11;
        NOT_WRITABLE: 12;
        OK: 0;
        PARSE_ERROR: 5;
        SCHEMA_MISMATCH: 3;
        SECURITY_ERROR: 8;
        SERVER_INFO: 1;
        WRITE_ERROR: 9;
    } = ...

    Type declaration

    • ReadonlyCANCELLED: 10
    • ReadonlyDICTIONARY_GAP: 13
    • ReadonlyDURABLE_ACK: 2
    • ReadonlyINTERNAL_ERROR: 6
    • ReadonlyLIMIT_EXCEEDED: 11
    • ReadonlyNOT_WRITABLE: 12
    • ReadonlyOK: 0
    • ReadonlyPARSE_ERROR: 5
    • ReadonlySCHEMA_MISMATCH: 3
    • ReadonlySECURITY_ERROR: 8
    • ReadonlySERVER_INFO: 1
    • ReadonlyWRITE_ERROR: 9
    diff --git a/docs/variables/_questdb_browser-client.QWP_TARGET.html b/docs/variables/_questdb_browser-client.QWP_TARGET.html new file mode 100644 index 0000000..49bbef9 --- /dev/null +++ b/docs/variables/_questdb_browser-client.QWP_TARGET.html @@ -0,0 +1 @@ +QWP_TARGET | QuestDB JavaScript Client - v4.2.0

    Variable QWP_TARGETConst

    QWP_TARGET: { ANY: "any"; PRIMARY: "primary"; REPLICA: "replica" } = ...

    Type declaration

    • ReadonlyANY: "any"
    • ReadonlyPRIMARY: "primary"
    • ReadonlyREPLICA: "replica"
    diff --git a/docs/variables/_questdb_browser-client.QWP_UPGRADE_ERROR_KIND.html b/docs/variables/_questdb_browser-client.QWP_UPGRADE_ERROR_KIND.html new file mode 100644 index 0000000..ce04a48 --- /dev/null +++ b/docs/variables/_questdb_browser-client.QWP_UPGRADE_ERROR_KIND.html @@ -0,0 +1,2 @@ +QWP_UPGRADE_ERROR_KIND | QuestDB JavaScript Client - v4.2.0

    Variable QWP_UPGRADE_ERROR_KINDConst

    QWP_UPGRADE_ERROR_KIND: {
        AUTHENTICATION: "authentication";
        CAPABILITY_MISMATCH: "capability-mismatch";
        HTTP_REJECTED: "http-rejected";
        OPAQUE: "opaque";
        ROLE_REJECTED: "role-rejected";
        TIMEOUT: "timeout";
        TRANSPORT: "transport";
        VERSION_MISMATCH: "version-mismatch";
    } = ...

    Type declaration

    • ReadonlyAUTHENTICATION: "authentication"
    • ReadonlyCAPABILITY_MISMATCH: "capability-mismatch"
    • ReadonlyHTTP_REJECTED: "http-rejected"
    • ReadonlyOPAQUE: "opaque"

      Browser WebSocket APIs do not expose the rejected HTTP upgrade.

      +
    • ReadonlyROLE_REJECTED: "role-rejected"
    • ReadonlyTIMEOUT: "timeout"
    • ReadonlyTRANSPORT: "transport"
    • ReadonlyVERSION_MISMATCH: "version-mismatch"
    diff --git a/docs/variables/_questdb_browser-client.QWP_UPGRADE_TIMEOUT_PHASE.html b/docs/variables/_questdb_browser-client.QWP_UPGRADE_TIMEOUT_PHASE.html new file mode 100644 index 0000000..eacaa72 --- /dev/null +++ b/docs/variables/_questdb_browser-client.QWP_UPGRADE_TIMEOUT_PHASE.html @@ -0,0 +1 @@ +QWP_UPGRADE_TIMEOUT_PHASE | QuestDB JavaScript Client - v4.2.0

    Variable QWP_UPGRADE_TIMEOUT_PHASEConst

    QWP_UPGRADE_TIMEOUT_PHASE: {
        AUTHENTICATION: "authentication";
        CONNECT: "connect";
    } = ...

    Type declaration

    • ReadonlyAUTHENTICATION: "authentication"
    • ReadonlyCONNECT: "connect"
    diff --git a/docs/variables/_questdb_browser-client.QWP_VERSION.html b/docs/variables/_questdb_browser-client.QWP_VERSION.html new file mode 100644 index 0000000..d50e948 --- /dev/null +++ b/docs/variables/_questdb_browser-client.QWP_VERSION.html @@ -0,0 +1 @@ +QWP_VERSION | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/variables/_questdb_browser-client.QWP_ZSTD_MAX_COMPRESSION_LEVEL.html b/docs/variables/_questdb_browser-client.QWP_ZSTD_MAX_COMPRESSION_LEVEL.html new file mode 100644 index 0000000..51a4e97 --- /dev/null +++ b/docs/variables/_questdb_browser-client.QWP_ZSTD_MAX_COMPRESSION_LEVEL.html @@ -0,0 +1 @@ +QWP_ZSTD_MAX_COMPRESSION_LEVEL | QuestDB JavaScript Client - v4.2.0

    Variable QWP_ZSTD_MAX_COMPRESSION_LEVELConst

    QWP_ZSTD_MAX_COMPRESSION_LEVEL: 22
    diff --git a/docs/variables/_questdb_browser-client.QWP_ZSTD_MIN_COMPRESSION_LEVEL.html b/docs/variables/_questdb_browser-client.QWP_ZSTD_MIN_COMPRESSION_LEVEL.html new file mode 100644 index 0000000..debbba4 --- /dev/null +++ b/docs/variables/_questdb_browser-client.QWP_ZSTD_MIN_COMPRESSION_LEVEL.html @@ -0,0 +1 @@ +QWP_ZSTD_MIN_COMPRESSION_LEVEL | QuestDB JavaScript Client - v4.2.0

    Variable QWP_ZSTD_MIN_COMPRESSION_LEVELConst

    QWP_ZSTD_MIN_COMPRESSION_LEVEL: 1
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_COLUMN_TYPE.html b/docs/variables/_questdb_nodejs-client.QWP_COLUMN_TYPE.html new file mode 100644 index 0000000..81b18ff --- /dev/null +++ b/docs/variables/_questdb_nodejs-client.QWP_COLUMN_TYPE.html @@ -0,0 +1 @@ +QWP_COLUMN_TYPE | QuestDB JavaScript Client - v4.2.0

    Variable QWP_COLUMN_TYPEConst

    QWP_COLUMN_TYPE: {
        BINARY: 23;
        BOOLEAN: 1;
        BYTE: 2;
        CHAR: 22;
        DATE: 11;
        DECIMAL128: 20;
        DECIMAL256: 21;
        DECIMAL64: 19;
        DOUBLE: 7;
        DOUBLE_ARRAY: 17;
        FLOAT: 6;
        GEOHASH: 14;
        INT: 4;
        IPV4: 24;
        LONG: 5;
        LONG_ARRAY: 18;
        LONG256: 13;
        SHORT: 3;
        SYMBOL: 9;
        TIMESTAMP: 10;
        TIMESTAMP_NANOS: 16;
        UUID: 12;
        VARCHAR: 15;
    } = ...

    Type declaration

    • ReadonlyBINARY: 23
    • ReadonlyBOOLEAN: 1
    • ReadonlyBYTE: 2
    • ReadonlyCHAR: 22
    • ReadonlyDATE: 11
    • ReadonlyDECIMAL128: 20
    • ReadonlyDECIMAL256: 21
    • ReadonlyDECIMAL64: 19
    • ReadonlyDOUBLE: 7
    • ReadonlyDOUBLE_ARRAY: 17
    • ReadonlyFLOAT: 6
    • ReadonlyGEOHASH: 14
    • ReadonlyINT: 4
    • ReadonlyIPV4: 24
    • ReadonlyLONG: 5
    • ReadonlyLONG_ARRAY: 18
    • ReadonlyLONG256: 13
    • ReadonlySHORT: 3
    • ReadonlySYMBOL: 9
    • ReadonlyTIMESTAMP: 10
    • ReadonlyTIMESTAMP_NANOS: 16
    • ReadonlyUUID: 12
    • ReadonlyVARCHAR: 15
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_COMPRESSION_CODEC.html b/docs/variables/_questdb_nodejs-client.QWP_COMPRESSION_CODEC.html new file mode 100644 index 0000000..ed4fc3e --- /dev/null +++ b/docs/variables/_questdb_nodejs-client.QWP_COMPRESSION_CODEC.html @@ -0,0 +1 @@ +QWP_COMPRESSION_CODEC | QuestDB JavaScript Client - v4.2.0

    Variable QWP_COMPRESSION_CODECConst

    QWP_COMPRESSION_CODEC: { RAW: 0; ZSTD: 1 } = ...

    Type declaration

    • ReadonlyRAW: 0
    • ReadonlyZSTD: 1
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_DECIMAL_MAX_SCALE.html b/docs/variables/_questdb_nodejs-client.QWP_DECIMAL_MAX_SCALE.html new file mode 100644 index 0000000..f85a47c --- /dev/null +++ b/docs/variables/_questdb_nodejs-client.QWP_DECIMAL_MAX_SCALE.html @@ -0,0 +1,2 @@ +QWP_DECIMAL_MAX_SCALE | QuestDB JavaScript Client - v4.2.0

    Variable QWP_DECIMAL_MAX_SCALEConst

    QWP_DECIMAL_MAX_SCALE: { decimal128: 38; decimal256: 76; decimal64: 18 } = ...

    Maximum DECIMAL scale of each fixed-width decimal column type.

    +

    Type declaration

    • Readonlydecimal128: 38
    • Readonlydecimal256: 76
    • Readonlydecimal64: 18
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_DEFAULT_EGRESS_BUFFER_POOL_SIZE.html b/docs/variables/_questdb_nodejs-client.QWP_DEFAULT_EGRESS_BUFFER_POOL_SIZE.html new file mode 100644 index 0000000..16bf23e --- /dev/null +++ b/docs/variables/_questdb_nodejs-client.QWP_DEFAULT_EGRESS_BUFFER_POOL_SIZE.html @@ -0,0 +1,2 @@ +QWP_DEFAULT_EGRESS_BUFFER_POOL_SIZE | QuestDB JavaScript Client - v4.2.0

    Variable QWP_DEFAULT_EGRESS_BUFFER_POOL_SIZEConst

    QWP_DEFAULT_EGRESS_BUFFER_POOL_SIZE: 4

    Default decoded result-buffer pool depth, matching the Java client.

    +
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_DEFAULT_EGRESS_INITIAL_CREDIT.html b/docs/variables/_questdb_nodejs-client.QWP_DEFAULT_EGRESS_INITIAL_CREDIT.html new file mode 100644 index 0000000..fa2d6ed --- /dev/null +++ b/docs/variables/_questdb_nodejs-client.QWP_DEFAULT_EGRESS_INITIAL_CREDIT.html @@ -0,0 +1,2 @@ +QWP_DEFAULT_EGRESS_INITIAL_CREDIT | QuestDB JavaScript Client - v4.2.0

    Variable QWP_DEFAULT_EGRESS_INITIAL_CREDITConst

    QWP_DEFAULT_EGRESS_INITIAL_CREDIT: 0

    Default send-ahead credit used by Java and TypeScript: zero is unbounded.

    +
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_DEFAULT_EGRESS_SERVER_INFO_TIMEOUT_MS.html b/docs/variables/_questdb_nodejs-client.QWP_DEFAULT_EGRESS_SERVER_INFO_TIMEOUT_MS.html new file mode 100644 index 0000000..9d0eba7 --- /dev/null +++ b/docs/variables/_questdb_nodejs-client.QWP_DEFAULT_EGRESS_SERVER_INFO_TIMEOUT_MS.html @@ -0,0 +1,2 @@ +QWP_DEFAULT_EGRESS_SERVER_INFO_TIMEOUT_MS | QuestDB JavaScript Client - v4.2.0

    Variable QWP_DEFAULT_EGRESS_SERVER_INFO_TIMEOUT_MSConst

    QWP_DEFAULT_EGRESS_SERVER_INFO_TIMEOUT_MS: 5000 = 5_000

    Default wait for the initial or reconnected SERVER_INFO frame.

    +
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_DURABLE_ACK_WEBSOCKET_PROTOCOL.html b/docs/variables/_questdb_nodejs-client.QWP_DURABLE_ACK_WEBSOCKET_PROTOCOL.html new file mode 100644 index 0000000..784d78d --- /dev/null +++ b/docs/variables/_questdb_nodejs-client.QWP_DURABLE_ACK_WEBSOCKET_PROTOCOL.html @@ -0,0 +1,3 @@ +QWP_DURABLE_ACK_WEBSOCKET_PROTOCOL | QuestDB JavaScript Client - v4.2.0

    Variable QWP_DURABLE_ACK_WEBSOCKET_PROTOCOLConst

    QWP_DURABLE_ACK_WEBSOCKET_PROTOCOL: "questdb.qwp.durable-ack.v1"

    Browser-visible WebSocket subprotocol used to request and confirm durable +ingress acknowledgements. Browsers cannot set or inspect X-QWP-* headers.

    +
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_EGRESS_CAPABILITY.html b/docs/variables/_questdb_nodejs-client.QWP_EGRESS_CAPABILITY.html new file mode 100644 index 0000000..c39f835 --- /dev/null +++ b/docs/variables/_questdb_nodejs-client.QWP_EGRESS_CAPABILITY.html @@ -0,0 +1 @@ +QWP_EGRESS_CAPABILITY | QuestDB JavaScript Client - v4.2.0

    Variable QWP_EGRESS_CAPABILITYConst

    QWP_EGRESS_CAPABILITY: { COMPRESSION: 4; QUERY_FLAGS: 2; ZONE: 1 } = ...

    Type declaration

    • ReadonlyCOMPRESSION: 4
    • ReadonlyQUERY_FLAGS: 2
    • ReadonlyZONE: 1
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_EGRESS_MESSAGE.html b/docs/variables/_questdb_nodejs-client.QWP_EGRESS_MESSAGE.html new file mode 100644 index 0000000..99a48c1 --- /dev/null +++ b/docs/variables/_questdb_nodejs-client.QWP_EGRESS_MESSAGE.html @@ -0,0 +1 @@ +QWP_EGRESS_MESSAGE | QuestDB JavaScript Client - v4.2.0

    Variable QWP_EGRESS_MESSAGEConst

    QWP_EGRESS_MESSAGE: {
        CACHE_RESET: 23;
        CANCEL: 20;
        CREDIT: 21;
        EXEC_DONE: 22;
        QUERY_ERROR: 19;
        QUERY_REQUEST: 16;
        RESULT_BATCH: 17;
        RESULT_END: 18;
        SERVER_INFO: 24;
    } = ...

    Type declaration

    • ReadonlyCACHE_RESET: 23
    • ReadonlyCANCEL: 20
    • ReadonlyCREDIT: 21
    • ReadonlyEXEC_DONE: 22
    • ReadonlyQUERY_ERROR: 19
    • ReadonlyQUERY_REQUEST: 16
    • ReadonlyRESULT_BATCH: 17
    • ReadonlyRESULT_END: 18
    • ReadonlySERVER_INFO: 24
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_EGRESS_PATH.html b/docs/variables/_questdb_nodejs-client.QWP_EGRESS_PATH.html new file mode 100644 index 0000000..64d051e --- /dev/null +++ b/docs/variables/_questdb_nodejs-client.QWP_EGRESS_PATH.html @@ -0,0 +1 @@ +QWP_EGRESS_PATH | QuestDB JavaScript Client - v4.2.0

    Variable QWP_EGRESS_PATHConst

    QWP_EGRESS_PATH: "/read/v1"
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_ENCODING_GORILLA.html b/docs/variables/_questdb_nodejs-client.QWP_ENCODING_GORILLA.html new file mode 100644 index 0000000..2338aa1 --- /dev/null +++ b/docs/variables/_questdb_nodejs-client.QWP_ENCODING_GORILLA.html @@ -0,0 +1 @@ +QWP_ENCODING_GORILLA | QuestDB JavaScript Client - v4.2.0

    Variable QWP_ENCODING_GORILLAConst

    QWP_ENCODING_GORILLA: 1 = 0x01
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_ENCODING_UNCOMPRESSED.html b/docs/variables/_questdb_nodejs-client.QWP_ENCODING_UNCOMPRESSED.html new file mode 100644 index 0000000..ef8e866 --- /dev/null +++ b/docs/variables/_questdb_nodejs-client.QWP_ENCODING_UNCOMPRESSED.html @@ -0,0 +1 @@ +QWP_ENCODING_UNCOMPRESSED | QuestDB JavaScript Client - v4.2.0

    Variable QWP_ENCODING_UNCOMPRESSEDConst

    QWP_ENCODING_UNCOMPRESSED: 0 = 0x00
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_FLAG_DEFER_COMMIT.html b/docs/variables/_questdb_nodejs-client.QWP_FLAG_DEFER_COMMIT.html new file mode 100644 index 0000000..628fe5a --- /dev/null +++ b/docs/variables/_questdb_nodejs-client.QWP_FLAG_DEFER_COMMIT.html @@ -0,0 +1 @@ +QWP_FLAG_DEFER_COMMIT | QuestDB JavaScript Client - v4.2.0

    Variable QWP_FLAG_DEFER_COMMITConst

    QWP_FLAG_DEFER_COMMIT: 1 = 0x01
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_FLAG_DELTA_SYMBOL_DICTIONARY.html b/docs/variables/_questdb_nodejs-client.QWP_FLAG_DELTA_SYMBOL_DICTIONARY.html new file mode 100644 index 0000000..7598fb4 --- /dev/null +++ b/docs/variables/_questdb_nodejs-client.QWP_FLAG_DELTA_SYMBOL_DICTIONARY.html @@ -0,0 +1 @@ +QWP_FLAG_DELTA_SYMBOL_DICTIONARY | QuestDB JavaScript Client - v4.2.0

    Variable QWP_FLAG_DELTA_SYMBOL_DICTIONARYConst

    QWP_FLAG_DELTA_SYMBOL_DICTIONARY: 8 = 0x08
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_FLAG_DURABLE_ACK_POLL.html b/docs/variables/_questdb_nodejs-client.QWP_FLAG_DURABLE_ACK_POLL.html new file mode 100644 index 0000000..388bda7 --- /dev/null +++ b/docs/variables/_questdb_nodejs-client.QWP_FLAG_DURABLE_ACK_POLL.html @@ -0,0 +1,2 @@ +QWP_FLAG_DURABLE_ACK_POLL | QuestDB JavaScript Client - v4.2.0

    Variable QWP_FLAG_DURABLE_ACK_POLLConst

    QWP_FLAG_DURABLE_ACK_POLL: 2 = 0x02

    Table-less ingress control frame that polls negotiated durable-ACK progress.

    +
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_FLAG_GORILLA.html b/docs/variables/_questdb_nodejs-client.QWP_FLAG_GORILLA.html new file mode 100644 index 0000000..8e4794c --- /dev/null +++ b/docs/variables/_questdb_nodejs-client.QWP_FLAG_GORILLA.html @@ -0,0 +1 @@ +QWP_FLAG_GORILLA | QuestDB JavaScript Client - v4.2.0

    Variable QWP_FLAG_GORILLAConst

    QWP_FLAG_GORILLA: 4 = 0x04
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_FLAG_ZSTD.html b/docs/variables/_questdb_nodejs-client.QWP_FLAG_ZSTD.html new file mode 100644 index 0000000..c824852 --- /dev/null +++ b/docs/variables/_questdb_nodejs-client.QWP_FLAG_ZSTD.html @@ -0,0 +1 @@ +QWP_FLAG_ZSTD | QuestDB JavaScript Client - v4.2.0

    Variable QWP_FLAG_ZSTDConst

    QWP_FLAG_ZSTD: 16 = 0x10
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_HEADER_SIZE.html b/docs/variables/_questdb_nodejs-client.QWP_HEADER_SIZE.html new file mode 100644 index 0000000..e4d90ef --- /dev/null +++ b/docs/variables/_questdb_nodejs-client.QWP_HEADER_SIZE.html @@ -0,0 +1 @@ +QWP_HEADER_SIZE | QuestDB JavaScript Client - v4.2.0

    Variable QWP_HEADER_SIZEConst

    QWP_HEADER_SIZE: 12
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_INGRESS_PATH.html b/docs/variables/_questdb_nodejs-client.QWP_INGRESS_PATH.html new file mode 100644 index 0000000..59c8f04 --- /dev/null +++ b/docs/variables/_questdb_nodejs-client.QWP_INGRESS_PATH.html @@ -0,0 +1 @@ +QWP_INGRESS_PATH | QuestDB JavaScript Client - v4.2.0

    Variable QWP_INGRESS_PATHConst

    QWP_INGRESS_PATH: "/write/v4"
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_INGRESS_PROGRESS_KIND.html b/docs/variables/_questdb_nodejs-client.QWP_INGRESS_PROGRESS_KIND.html new file mode 100644 index 0000000..d178da3 --- /dev/null +++ b/docs/variables/_questdb_nodejs-client.QWP_INGRESS_PROGRESS_KIND.html @@ -0,0 +1 @@ +QWP_INGRESS_PROGRESS_KIND | QuestDB JavaScript Client - v4.2.0

    Variable QWP_INGRESS_PROGRESS_KINDConst

    QWP_INGRESS_PROGRESS_KIND: {
        ACKNOWLEDGED: "acknowledged";
        DURABLE_ACKNOWLEDGED: "durable-acknowledged";
        PUBLISHED: "published";
    } = ...

    Type declaration

    • ReadonlyACKNOWLEDGED: "acknowledged"
    • ReadonlyDURABLE_ACKNOWLEDGED: "durable-acknowledged"
    • ReadonlyPUBLISHED: "published"
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_INITIAL_CONNECT_MODE.html b/docs/variables/_questdb_nodejs-client.QWP_INITIAL_CONNECT_MODE.html new file mode 100644 index 0000000..8d21653 --- /dev/null +++ b/docs/variables/_questdb_nodejs-client.QWP_INITIAL_CONNECT_MODE.html @@ -0,0 +1,7 @@ +QWP_INITIAL_CONNECT_MODE | QuestDB JavaScript Client - v4.2.0

    Variable QWP_INITIAL_CONNECT_MODEConst

    QWP_INITIAL_CONNECT_MODE: { ASYNC: "async"; OFF: "off"; SYNC: "sync" } = ...

    Initial connection policy for an ingress reconnect session. Public browser +and memory-only helpers resolve their default internally; Node persistent +store-and-forward exposes all three modes.

    +

    Type declaration

    • ReadonlyASYNC: "async"

      Return immediately and connect on the background replay loop.

      +
    • ReadonlyOFF: "off"

      Try once on the caller and fail immediately.

      +
    • ReadonlySYNC: "sync"

      Retry on the caller within the configured reconnect budget.

      +
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_MAGIC.html b/docs/variables/_questdb_nodejs-client.QWP_MAGIC.html new file mode 100644 index 0000000..c8c9ff6 --- /dev/null +++ b/docs/variables/_questdb_nodejs-client.QWP_MAGIC.html @@ -0,0 +1,2 @@ +QWP_MAGIC | QuestDB JavaScript Client - v4.2.0

    Variable QWP_MAGICConst

    QWP_MAGIC: 827348817 = 0x31505751

    ASCII QWP1, represented as its little-endian uint32 value.

    +
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_MAX_ARRAY_DIMENSIONS.html b/docs/variables/_questdb_nodejs-client.QWP_MAX_ARRAY_DIMENSIONS.html new file mode 100644 index 0000000..2f08df0 --- /dev/null +++ b/docs/variables/_questdb_nodejs-client.QWP_MAX_ARRAY_DIMENSIONS.html @@ -0,0 +1,2 @@ +QWP_MAX_ARRAY_DIMENSIONS | QuestDB JavaScript Client - v4.2.0

    Variable QWP_MAX_ARRAY_DIMENSIONSConst

    QWP_MAX_ARRAY_DIMENSIONS: 32

    Maximum array rank accepted by QuestDB's QWP ingress decoder.

    +
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_MAX_ARRAY_DIMENSION_LENGTH.html b/docs/variables/_questdb_nodejs-client.QWP_MAX_ARRAY_DIMENSION_LENGTH.html new file mode 100644 index 0000000..4845d27 --- /dev/null +++ b/docs/variables/_questdb_nodejs-client.QWP_MAX_ARRAY_DIMENSION_LENGTH.html @@ -0,0 +1,2 @@ +QWP_MAX_ARRAY_DIMENSION_LENGTH | QuestDB JavaScript Client - v4.2.0

    Variable QWP_MAX_ARRAY_DIMENSION_LENGTHConst

    QWP_MAX_ARRAY_DIMENSION_LENGTH: 2147483647 = 2_147_483_647

    Maximum signed int32 array-axis length accepted by QWP ingress.

    +
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_MAX_BATCH_ROWS_UPPER_BOUND.html b/docs/variables/_questdb_nodejs-client.QWP_MAX_BATCH_ROWS_UPPER_BOUND.html new file mode 100644 index 0000000..282184d --- /dev/null +++ b/docs/variables/_questdb_nodejs-client.QWP_MAX_BATCH_ROWS_UPPER_BOUND.html @@ -0,0 +1,2 @@ +QWP_MAX_BATCH_ROWS_UPPER_BOUND | QuestDB JavaScript Client - v4.2.0

    Variable QWP_MAX_BATCH_ROWS_UPPER_BOUNDConst

    QWP_MAX_BATCH_ROWS_UPPER_BOUND: 1048576 = 1_048_576

    Largest client-requested egress RESULT_BATCH row cap.

    +
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_MAX_CELLS_PER_BATCH.html b/docs/variables/_questdb_nodejs-client.QWP_MAX_CELLS_PER_BATCH.html new file mode 100644 index 0000000..1897215 --- /dev/null +++ b/docs/variables/_questdb_nodejs-client.QWP_MAX_CELLS_PER_BATCH.html @@ -0,0 +1,13 @@ +QWP_MAX_CELLS_PER_BATCH | QuestDB JavaScript Client - v4.2.0

    Variable QWP_MAX_CELLS_PER_BATCHConst

    QWP_MAX_CELLS_PER_BATCH: 33554432 = 33_554_432

    Largest rowCount * columnCount a single RESULT_BATCH may declare.

    +

    The row and column caps above bound each dimension on its own, and their +product does not have to be reachable: 1,048,576 rows of 2,048 columns is +2.1 billion cells. Decoding materializes two rowCount-length arrays per +column, measured at 16 bytes per cell, so the product is what decides how +much memory a response can cost. It is also the dimension a compressed body +detaches from the wire: an all-NULL column is one bit per cell before zstd, +so without this bound a few kilobytes of RLE-compressed bitmap declares a +grid no heap can hold.

    +

    32Mi cells is roughly 512 MB decoded. That is far above any plausible +result -- the widest supported table at 16k rows, or a full 1,048,576-row +batch at 32 columns -- and far below what the caps alone would permit.

    +
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_MAX_COLUMNS_PER_TABLE.html b/docs/variables/_questdb_nodejs-client.QWP_MAX_COLUMNS_PER_TABLE.html new file mode 100644 index 0000000..b95a8c7 --- /dev/null +++ b/docs/variables/_questdb_nodejs-client.QWP_MAX_COLUMNS_PER_TABLE.html @@ -0,0 +1 @@ +QWP_MAX_COLUMNS_PER_TABLE | QuestDB JavaScript Client - v4.2.0

    Variable QWP_MAX_COLUMNS_PER_TABLEConst

    QWP_MAX_COLUMNS_PER_TABLE: 2048
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_MAX_COLUMN_NAME_LENGTH.html b/docs/variables/_questdb_nodejs-client.QWP_MAX_COLUMN_NAME_LENGTH.html new file mode 100644 index 0000000..43c3599 --- /dev/null +++ b/docs/variables/_questdb_nodejs-client.QWP_MAX_COLUMN_NAME_LENGTH.html @@ -0,0 +1,2 @@ +QWP_MAX_COLUMN_NAME_LENGTH | QuestDB JavaScript Client - v4.2.0

    Variable QWP_MAX_COLUMN_NAME_LENGTHConst

    QWP_MAX_COLUMN_NAME_LENGTH: 127

    Default QWP ingress identifier limits, in UTF-8 wire bytes.

    +
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_MAX_ERROR_MESSAGE_LENGTH.html b/docs/variables/_questdb_nodejs-client.QWP_MAX_ERROR_MESSAGE_LENGTH.html new file mode 100644 index 0000000..734901c --- /dev/null +++ b/docs/variables/_questdb_nodejs-client.QWP_MAX_ERROR_MESSAGE_LENGTH.html @@ -0,0 +1 @@ +QWP_MAX_ERROR_MESSAGE_LENGTH | QuestDB JavaScript Client - v4.2.0

    Variable QWP_MAX_ERROR_MESSAGE_LENGTHConst

    QWP_MAX_ERROR_MESSAGE_LENGTH: 1024
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_MAX_IDENTIFIER_BYTES.html b/docs/variables/_questdb_nodejs-client.QWP_MAX_IDENTIFIER_BYTES.html new file mode 100644 index 0000000..1900a5e --- /dev/null +++ b/docs/variables/_questdb_nodejs-client.QWP_MAX_IDENTIFIER_BYTES.html @@ -0,0 +1,6 @@ +QWP_MAX_IDENTIFIER_BYTES | QuestDB JavaScript Client - v4.2.0

    Variable QWP_MAX_IDENTIFIER_BYTESConst

    QWP_MAX_IDENTIFIER_BYTES: number = ...

    Defensive byte bound for identifiers decoded from query results.

    +

    Existing tables may have names created through APIs that apply Java's +127-UTF-16-code-unit metadata limit. One code unit takes at most three UTF-8 +bytes, so query decoding accepts that larger representation even though QWP +ingress enforces its 127-byte protocol limit.

    +
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_MAX_ROWS_PER_TABLE.html b/docs/variables/_questdb_nodejs-client.QWP_MAX_ROWS_PER_TABLE.html new file mode 100644 index 0000000..9a74ed9 --- /dev/null +++ b/docs/variables/_questdb_nodejs-client.QWP_MAX_ROWS_PER_TABLE.html @@ -0,0 +1 @@ +QWP_MAX_ROWS_PER_TABLE | QuestDB JavaScript Client - v4.2.0

    Variable QWP_MAX_ROWS_PER_TABLEConst

    QWP_MAX_ROWS_PER_TABLE: 1000000 = 1_000_000
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_MAX_SYMBOL_DICTIONARY_SIZE.html b/docs/variables/_questdb_nodejs-client.QWP_MAX_SYMBOL_DICTIONARY_SIZE.html new file mode 100644 index 0000000..26435f5 --- /dev/null +++ b/docs/variables/_questdb_nodejs-client.QWP_MAX_SYMBOL_DICTIONARY_SIZE.html @@ -0,0 +1 @@ +QWP_MAX_SYMBOL_DICTIONARY_SIZE | QuestDB JavaScript Client - v4.2.0

    Variable QWP_MAX_SYMBOL_DICTIONARY_SIZEConst

    QWP_MAX_SYMBOL_DICTIONARY_SIZE: 1000000 = 1_000_000
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_MAX_TABLE_NAME_LENGTH.html b/docs/variables/_questdb_nodejs-client.QWP_MAX_TABLE_NAME_LENGTH.html new file mode 100644 index 0000000..5d9cb0a --- /dev/null +++ b/docs/variables/_questdb_nodejs-client.QWP_MAX_TABLE_NAME_LENGTH.html @@ -0,0 +1 @@ +QWP_MAX_TABLE_NAME_LENGTH | QuestDB JavaScript Client - v4.2.0

    Variable QWP_MAX_TABLE_NAME_LENGTHConst

    QWP_MAX_TABLE_NAME_LENGTH: 127
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_MAX_ZSTD_DECOMPRESSED_SIZE.html b/docs/variables/_questdb_nodejs-client.QWP_MAX_ZSTD_DECOMPRESSED_SIZE.html new file mode 100644 index 0000000..5898dfd --- /dev/null +++ b/docs/variables/_questdb_nodejs-client.QWP_MAX_ZSTD_DECOMPRESSED_SIZE.html @@ -0,0 +1,2 @@ +QWP_MAX_ZSTD_DECOMPRESSED_SIZE | QuestDB JavaScript Client - v4.2.0

    Variable QWP_MAX_ZSTD_DECOMPRESSED_SIZEConst

    QWP_MAX_ZSTD_DECOMPRESSED_SIZE: number = ...

    Matches the Java client's per-connection decompression safety cap.

    +
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_ORPHAN_DRAIN_EVENT_KIND.html b/docs/variables/_questdb_nodejs-client.QWP_ORPHAN_DRAIN_EVENT_KIND.html new file mode 100644 index 0000000..781dd66 --- /dev/null +++ b/docs/variables/_questdb_nodejs-client.QWP_ORPHAN_DRAIN_EVENT_KIND.html @@ -0,0 +1,2 @@ +QWP_ORPHAN_DRAIN_EVENT_KIND | QuestDB JavaScript Client - v4.2.0

    Variable QWP_ORPHAN_DRAIN_EVENT_KINDConst

    QWP_ORPHAN_DRAIN_EVENT_KIND: {
        DISCOVERED: "discovered";
        DRAINED: "drained";
        DURABLE_ACK_PERSISTENT_FAILURE: "durable-ack-persistent-failure";
        DURABLE_ACK_UNAVAILABLE: "durable-ack-unavailable";
        FAILED: "failed";
        LOCKED: "locked";
        PRIMARY_UNAVAILABLE: "primary-unavailable";
        RETRYING: "retrying";
        SCAN_FAILED: "scan-failed";
        STARTED: "started";
    } = ...

    Type declaration

    • ReadonlyDISCOVERED: "discovered"
    • ReadonlyDRAINED: "drained"
    • ReadonlyDURABLE_ACK_PERSISTENT_FAILURE: "durable-ack-persistent-failure"
    • ReadonlyDURABLE_ACK_UNAVAILABLE: "durable-ack-unavailable"
    • ReadonlyFAILED: "failed"
    • ReadonlyLOCKED: "locked"
    • ReadonlyPRIMARY_UNAVAILABLE: "primary-unavailable"
    • ReadonlyRETRYING: "retrying"

      The attempt failed transiently; the slot is left for a later scan.

      +
    • ReadonlySCAN_FAILED: "scan-failed"
    • ReadonlySTARTED: "started"
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_ORPHAN_FAILED_SENTINEL.html b/docs/variables/_questdb_nodejs-client.QWP_ORPHAN_FAILED_SENTINEL.html new file mode 100644 index 0000000..912c028 --- /dev/null +++ b/docs/variables/_questdb_nodejs-client.QWP_ORPHAN_FAILED_SENTINEL.html @@ -0,0 +1,2 @@ +QWP_ORPHAN_FAILED_SENTINEL | QuestDB JavaScript Client - v4.2.0

    Variable QWP_ORPHAN_FAILED_SENTINELConst

    QWP_ORPHAN_FAILED_SENTINEL: ".failed"

    Java-compatible marker that excludes a failed slot from automatic drain.

    +
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_QUERY_FLAG_RESET_DICTIONARY.html b/docs/variables/_questdb_nodejs-client.QWP_QUERY_FLAG_RESET_DICTIONARY.html new file mode 100644 index 0000000..021580b --- /dev/null +++ b/docs/variables/_questdb_nodejs-client.QWP_QUERY_FLAG_RESET_DICTIONARY.html @@ -0,0 +1 @@ +QWP_QUERY_FLAG_RESET_DICTIONARY | QuestDB JavaScript Client - v4.2.0

    Variable QWP_QUERY_FLAG_RESET_DICTIONARYConst

    QWP_QUERY_FLAG_RESET_DICTIONARY: 1 = 0x01
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_RECONNECT_EVENT_KIND.html b/docs/variables/_questdb_nodejs-client.QWP_RECONNECT_EVENT_KIND.html new file mode 100644 index 0000000..deedbc6 --- /dev/null +++ b/docs/variables/_questdb_nodejs-client.QWP_RECONNECT_EVENT_KIND.html @@ -0,0 +1,4 @@ +QWP_RECONNECT_EVENT_KIND | QuestDB JavaScript Client - v4.2.0

    Variable QWP_RECONNECT_EVENT_KINDConst

    QWP_RECONNECT_EVENT_KIND: {
        ATTEMPT_FAILED: "attempt-failed";
        CONNECTED: "connected";
        DURABLE_ACK_PERSISTENT_FAILURE: "durable-ack-persistent-failure";
        DURABLE_ACK_UNAVAILABLE: "durable-ack-unavailable";
        FAILED_OVER: "failed-over";
        PRIMARY_UNAVAILABLE: "primary-unavailable";
        RECONNECTED: "reconnected";
        RECONNECTING: "reconnecting";
    } = ...

    Type declaration

    • ReadonlyATTEMPT_FAILED: "attempt-failed"
    • ReadonlyCONNECTED: "connected"
    • ReadonlyDURABLE_ACK_PERSISTENT_FAILURE: "durable-ack-persistent-failure"

      An orphan exhausted its consecutive durable-ACK mismatch budget.

      +
    • ReadonlyDURABLE_ACK_UNAVAILABLE: "durable-ack-unavailable"

      An unbounded SF loop is waiting for durable-ACK-capable endpoints.

      +
    • ReadonlyFAILED_OVER: "failed-over"
    • ReadonlyPRIMARY_UNAVAILABLE: "primary-unavailable"

      Every reachable ingress endpoint is temporarily unable to be primary.

      +
    • ReadonlyRECONNECTED: "reconnected"
    • ReadonlyRECONNECTING: "reconnecting"
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_RESET_MASK_DICTIONARY.html b/docs/variables/_questdb_nodejs-client.QWP_RESET_MASK_DICTIONARY.html new file mode 100644 index 0000000..742c91a --- /dev/null +++ b/docs/variables/_questdb_nodejs-client.QWP_RESET_MASK_DICTIONARY.html @@ -0,0 +1 @@ +QWP_RESET_MASK_DICTIONARY | QuestDB JavaScript Client - v4.2.0

    Variable QWP_RESET_MASK_DICTIONARYConst

    QWP_RESET_MASK_DICTIONARY: 1 = 0x01
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_SENDER_ERROR_CATEGORY.html b/docs/variables/_questdb_nodejs-client.QWP_SENDER_ERROR_CATEGORY.html new file mode 100644 index 0000000..36a4e1d --- /dev/null +++ b/docs/variables/_questdb_nodejs-client.QWP_SENDER_ERROR_CATEGORY.html @@ -0,0 +1 @@ +QWP_SENDER_ERROR_CATEGORY | QuestDB JavaScript Client - v4.2.0

    Variable QWP_SENDER_ERROR_CATEGORYConst

    QWP_SENDER_ERROR_CATEGORY: {
        DATA_LOSS: "data-loss";
        DICTIONARY_GAP: "dictionary-gap";
        INTERNAL_ERROR: "internal-error";
        NOT_WRITABLE: "not-writable";
        PARSE_ERROR: "parse-error";
        PROTOCOL_VIOLATION: "protocol-violation";
        SCHEMA_MISMATCH: "schema-mismatch";
        SECURITY_ERROR: "security-error";
        UNKNOWN: "unknown";
        WRITE_ERROR: "write-error";
    } = ...

    Type declaration

    • ReadonlyDATA_LOSS: "data-loss"
    • ReadonlyDICTIONARY_GAP: "dictionary-gap"
    • ReadonlyINTERNAL_ERROR: "internal-error"
    • ReadonlyNOT_WRITABLE: "not-writable"
    • ReadonlyPARSE_ERROR: "parse-error"
    • ReadonlyPROTOCOL_VIOLATION: "protocol-violation"
    • ReadonlySCHEMA_MISMATCH: "schema-mismatch"
    • ReadonlySECURITY_ERROR: "security-error"
    • ReadonlyUNKNOWN: "unknown"
    • ReadonlyWRITE_ERROR: "write-error"
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_SENDER_ERROR_POLICY.html b/docs/variables/_questdb_nodejs-client.QWP_SENDER_ERROR_POLICY.html new file mode 100644 index 0000000..5aced61 --- /dev/null +++ b/docs/variables/_questdb_nodejs-client.QWP_SENDER_ERROR_POLICY.html @@ -0,0 +1 @@ +QWP_SENDER_ERROR_POLICY | QuestDB JavaScript Client - v4.2.0

    Variable QWP_SENDER_ERROR_POLICYConst

    QWP_SENDER_ERROR_POLICY: {
        ABANDONED: "abandoned";
        RETRIABLE: "retriable";
        RETRIABLE_OTHER: "retriable-other";
        TERMINAL: "terminal";
    } = ...

    Type declaration

    • ReadonlyABANDONED: "abandoned"
    • ReadonlyRETRIABLE: "retriable"
    • ReadonlyRETRIABLE_OTHER: "retriable-other"
    • ReadonlyTERMINAL: "terminal"
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_SERVER_ROLE.html b/docs/variables/_questdb_nodejs-client.QWP_SERVER_ROLE.html new file mode 100644 index 0000000..d69040c --- /dev/null +++ b/docs/variables/_questdb_nodejs-client.QWP_SERVER_ROLE.html @@ -0,0 +1 @@ +QWP_SERVER_ROLE | QuestDB JavaScript Client - v4.2.0

    Variable QWP_SERVER_ROLEConst

    QWP_SERVER_ROLE: { PRIMARY: 1; PRIMARY_CATCHUP: 3; REPLICA: 2; STANDALONE: 0 } = ...

    Type declaration

    • ReadonlyPRIMARY: 1
    • ReadonlyPRIMARY_CATCHUP: 3
    • ReadonlyREPLICA: 2
    • ReadonlySTANDALONE: 0
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_SF_BACKPRESSURE_POLICY.html b/docs/variables/_questdb_nodejs-client.QWP_SF_BACKPRESSURE_POLICY.html new file mode 100644 index 0000000..21265b8 --- /dev/null +++ b/docs/variables/_questdb_nodejs-client.QWP_SF_BACKPRESSURE_POLICY.html @@ -0,0 +1 @@ +QWP_SF_BACKPRESSURE_POLICY | QuestDB JavaScript Client - v4.2.0

    Variable QWP_SF_BACKPRESSURE_POLICYConst

    QWP_SF_BACKPRESSURE_POLICY: { ERROR: "error"; WAIT: "wait" } = ...

    Type declaration

    • ReadonlyERROR: "error"
    • ReadonlyWAIT: "wait"
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_SF_DURABILITY.html b/docs/variables/_questdb_nodejs-client.QWP_SF_DURABILITY.html new file mode 100644 index 0000000..c991f64 --- /dev/null +++ b/docs/variables/_questdb_nodejs-client.QWP_SF_DURABILITY.html @@ -0,0 +1 @@ +QWP_SF_DURABILITY | QuestDB JavaScript Client - v4.2.0

    Variable QWP_SF_DURABILITYConst

    QWP_SF_DURABILITY: { APPEND: "append"; MEMORY: "memory"; PERIODIC: "periodic" } = ...

    Type declaration

    • ReadonlyAPPEND: "append"
    • ReadonlyMEMORY: "memory"
    • ReadonlyPERIODIC: "periodic"
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_STATUS.html b/docs/variables/_questdb_nodejs-client.QWP_STATUS.html new file mode 100644 index 0000000..aa00259 --- /dev/null +++ b/docs/variables/_questdb_nodejs-client.QWP_STATUS.html @@ -0,0 +1 @@ +QWP_STATUS | QuestDB JavaScript Client - v4.2.0

    Variable QWP_STATUSConst

    QWP_STATUS: {
        CANCELLED: 10;
        DICTIONARY_GAP: 13;
        DURABLE_ACK: 2;
        INTERNAL_ERROR: 6;
        LIMIT_EXCEEDED: 11;
        NOT_WRITABLE: 12;
        OK: 0;
        PARSE_ERROR: 5;
        SCHEMA_MISMATCH: 3;
        SECURITY_ERROR: 8;
        SERVER_INFO: 1;
        WRITE_ERROR: 9;
    } = ...

    Type declaration

    • ReadonlyCANCELLED: 10
    • ReadonlyDICTIONARY_GAP: 13
    • ReadonlyDURABLE_ACK: 2
    • ReadonlyINTERNAL_ERROR: 6
    • ReadonlyLIMIT_EXCEEDED: 11
    • ReadonlyNOT_WRITABLE: 12
    • ReadonlyOK: 0
    • ReadonlyPARSE_ERROR: 5
    • ReadonlySCHEMA_MISMATCH: 3
    • ReadonlySECURITY_ERROR: 8
    • ReadonlySERVER_INFO: 1
    • ReadonlyWRITE_ERROR: 9
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_TARGET.html b/docs/variables/_questdb_nodejs-client.QWP_TARGET.html new file mode 100644 index 0000000..d24f2ee --- /dev/null +++ b/docs/variables/_questdb_nodejs-client.QWP_TARGET.html @@ -0,0 +1 @@ +QWP_TARGET | QuestDB JavaScript Client - v4.2.0

    Variable QWP_TARGETConst

    QWP_TARGET: { ANY: "any"; PRIMARY: "primary"; REPLICA: "replica" } = ...

    Type declaration

    • ReadonlyANY: "any"
    • ReadonlyPRIMARY: "primary"
    • ReadonlyREPLICA: "replica"
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_UPGRADE_ERROR_KIND.html b/docs/variables/_questdb_nodejs-client.QWP_UPGRADE_ERROR_KIND.html new file mode 100644 index 0000000..97c329c --- /dev/null +++ b/docs/variables/_questdb_nodejs-client.QWP_UPGRADE_ERROR_KIND.html @@ -0,0 +1,2 @@ +QWP_UPGRADE_ERROR_KIND | QuestDB JavaScript Client - v4.2.0

    Variable QWP_UPGRADE_ERROR_KINDConst

    QWP_UPGRADE_ERROR_KIND: {
        AUTHENTICATION: "authentication";
        CAPABILITY_MISMATCH: "capability-mismatch";
        HTTP_REJECTED: "http-rejected";
        OPAQUE: "opaque";
        ROLE_REJECTED: "role-rejected";
        TIMEOUT: "timeout";
        TRANSPORT: "transport";
        VERSION_MISMATCH: "version-mismatch";
    } = ...

    Type declaration

    • ReadonlyAUTHENTICATION: "authentication"
    • ReadonlyCAPABILITY_MISMATCH: "capability-mismatch"
    • ReadonlyHTTP_REJECTED: "http-rejected"
    • ReadonlyOPAQUE: "opaque"

      Browser WebSocket APIs do not expose the rejected HTTP upgrade.

      +
    • ReadonlyROLE_REJECTED: "role-rejected"
    • ReadonlyTIMEOUT: "timeout"
    • ReadonlyTRANSPORT: "transport"
    • ReadonlyVERSION_MISMATCH: "version-mismatch"
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_UPGRADE_TIMEOUT_PHASE.html b/docs/variables/_questdb_nodejs-client.QWP_UPGRADE_TIMEOUT_PHASE.html new file mode 100644 index 0000000..c8626e2 --- /dev/null +++ b/docs/variables/_questdb_nodejs-client.QWP_UPGRADE_TIMEOUT_PHASE.html @@ -0,0 +1 @@ +QWP_UPGRADE_TIMEOUT_PHASE | QuestDB JavaScript Client - v4.2.0

    Variable QWP_UPGRADE_TIMEOUT_PHASEConst

    QWP_UPGRADE_TIMEOUT_PHASE: {
        AUTHENTICATION: "authentication";
        CONNECT: "connect";
    } = ...

    Type declaration

    • ReadonlyAUTHENTICATION: "authentication"
    • ReadonlyCONNECT: "connect"
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_VERSION.html b/docs/variables/_questdb_nodejs-client.QWP_VERSION.html new file mode 100644 index 0000000..cc5ec8b --- /dev/null +++ b/docs/variables/_questdb_nodejs-client.QWP_VERSION.html @@ -0,0 +1 @@ +QWP_VERSION | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_ZSTD_MAX_COMPRESSION_LEVEL.html b/docs/variables/_questdb_nodejs-client.QWP_ZSTD_MAX_COMPRESSION_LEVEL.html new file mode 100644 index 0000000..aabd1bb --- /dev/null +++ b/docs/variables/_questdb_nodejs-client.QWP_ZSTD_MAX_COMPRESSION_LEVEL.html @@ -0,0 +1 @@ +QWP_ZSTD_MAX_COMPRESSION_LEVEL | QuestDB JavaScript Client - v4.2.0

    Variable QWP_ZSTD_MAX_COMPRESSION_LEVELConst

    QWP_ZSTD_MAX_COMPRESSION_LEVEL: 22
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_ZSTD_MIN_COMPRESSION_LEVEL.html b/docs/variables/_questdb_nodejs-client.QWP_ZSTD_MIN_COMPRESSION_LEVEL.html new file mode 100644 index 0000000..2a54c37 --- /dev/null +++ b/docs/variables/_questdb_nodejs-client.QWP_ZSTD_MIN_COMPRESSION_LEVEL.html @@ -0,0 +1 @@ +QWP_ZSTD_MIN_COMPRESSION_LEVEL | QuestDB JavaScript Client - v4.2.0

    Variable QWP_ZSTD_MIN_COMPRESSION_LEVELConst

    QWP_ZSTD_MIN_COMPRESSION_LEVEL: 1
    diff --git a/examples/qwp-browser.ts b/examples/qwp-browser.ts index 7203fee..30982c9 100644 --- a/examples/qwp-browser.ts +++ b/examples/qwp-browser.ts @@ -1,4 +1,4 @@ -import { connectQwpBrowserSender } from "@questdb/nodejs-client/qwp/browser"; +import { connectQwpBrowserSender } from "@questdb/browser-client"; async function main(): Promise { const url = new URL("/write/v4", location.href); diff --git a/package.json b/package.json index 0cc7620..0d31261 100644 --- a/package.json +++ b/package.json @@ -1,101 +1,29 @@ { - "name": "@questdb/nodejs-client", + "name": "questdb-javascript-client-workspace", "version": "4.2.0", - "description": "QuestDB JavaScript Client", + "private": true, + "description": "QuestDB JavaScript Client workspace", + "packageManager": "pnpm@10.12.4", "scripts": { "test": "vitest", "test:qwp-browser": "pnpm build && vitest run --config vitest.qwp-browser.config.ts", "test:dist": "pnpm build && vitest run --config vitest.dist.config.ts", "typecheck:dist": "pnpm build && tsc --noEmit -p tsconfig.dist-types.json && tsc --noEmit -p tsconfig.dist-types.cjs.json", - "build": "bunchee", - "eslint": "eslint src/**", + "build": "pnpm --filter @questdb/nodejs-client build && pnpm --filter @questdb/browser-client build", + "eslint": "eslint packages/*/src", "typecheck": "tsc --noEmit", - "typecheck:qwp-browser": "tsc --noEmit -p tsconfig.qwp-browser.json", + "typecheck:qwp-browser": "tsc --noEmit -p packages/browser-client/tsconfig.json", "typecheck:test": "tsc --noEmit -p tsconfig.test.json", "bench": "vitest bench --run benchmarks", "bench:e2e": "vitest run --config vitest.bench-e2e.config.ts", "typecheck:bench": "tsc --noEmit -p tsconfig.bench.json", "lint:bench": "eslint 'benchmarks/**/*.ts' vitest.bench-e2e.config.ts", "format:bench": "prettier --write 'benchmarks/**/*.{ts,md}' tsconfig.bench.json vitest.bench-e2e.config.ts", - "format": "prettier --write '{src,test}/**/*.{ts,js,json}'", + "format": "prettier --write '{packages,test}/**/*.{ts,js,json}'", + "check:packages": "node scripts/check-build-artifacts.mjs", "docs": "typedoc", "preview:docs": "serve docs" }, - "files": [ - "QWP.md", - "THIRD_PARTY_NOTICES.md", - "dist/_qwp", - "dist/cjs", - "dist/es" - ], - "main": "dist/cjs/index.js", - "module": "dist/es/index.mjs", - "types": "dist/cjs/index.d.ts", - "typesVersions": { - "*": { - "qwp": [ - "./dist/cjs/qwp/index.d.ts" - ], - "qwp/browser": [ - "./dist/cjs/qwp/browser.d.ts" - ], - "qwp/node": [ - "./dist/cjs/qwp/node.d.ts" - ] - } - }, - "exports": { - ".": { - "import": { - "types": "./dist/es/index.d.mts", - "default": "./dist/es/index.mjs" - }, - "require": { - "types": "./dist/cjs/index.d.ts", - "default": "./dist/cjs/index.js" - } - }, - "./qwp": { - "import": { - "types": "./dist/es/qwp/index.d.mts", - "default": "./dist/es/qwp/index.mjs" - }, - "require": { - "types": "./dist/cjs/qwp/index.d.ts", - "default": "./dist/cjs/qwp/index.js" - } - }, - "./qwp/browser": { - "import": { - "types": "./dist/es/qwp/browser.d.mts", - "default": "./dist/es/qwp/browser.mjs" - }, - "require": { - "types": "./dist/cjs/qwp/browser.d.ts", - "default": "./dist/cjs/qwp/browser.js" - } - }, - "./qwp/node": { - "import": { - "types": "./dist/es/qwp/node.d.mts", - "default": "./dist/es/qwp/node.mjs" - }, - "require": { - "types": "./dist/cjs/qwp/node.d.ts", - "default": "./dist/cjs/qwp/node.js" - } - } - }, - "repository": { - "type": "git", - "url": "git+ssh://git@github.com/questdb/nodejs-questdb-client.git" - }, - "keywords": [ - "QuestDB" - ], - "author": "QuestDB", - "license": "Apache-2.0", - "homepage": "https://questdb.github.io/nodejs-questdb-client", "devDependencies": { "@eslint/js": "^9.16.0", "@microsoft/tsdoc": "^0.15.1", @@ -103,18 +31,16 @@ "@types/ws": "^8.18.1", "bunchee": "^6.5.1", "eslint": "^9.26.0", - "fzstd": "0.1.1", "playwright": "^1.62.1", "prettier": "^3.5.3", + "rollup": "^4.40.2", "serve": "^14.2.4", "testcontainers": "^10.25.0", "typedoc": "^0.28.9", "typescript": "^5.7.2", "typescript-eslint": "^8.32.0", - "vitest": "^3.1.3" - }, - "dependencies": { "undici": "^7.8.0", + "vitest": "^3.1.3", "ws": "^8.21.3" } } diff --git a/packages/browser-client/README.md b/packages/browser-client/README.md new file mode 100644 index 0000000..27e4f02 --- /dev/null +++ b/packages/browser-client/README.md @@ -0,0 +1,323 @@ +# QuestDB JavaScript Client for browsers + +The official browser-only QuestDB client. It provides QWP ingestion, streaming +queries, failover, typed row writers, and browser session authentication without +Node.js modules or polyfills. + +The complete browser API is exported from `@questdb/browser-client`. There are +no additional public import paths. + +## Features + +- QWP ingestion through the browser's native WebSocket API +- Streaming queries with typed bind variables and result batches +- Automatic batching, reconnect, failover, and acknowledgement tracking +- Transactional ingestion and durable acknowledgement negotiation +- REST, OIDC, and Basic authentication through HttpOnly session cookies +- ESM, CommonJS, and bundled TypeScript declarations +- No Node.js built-ins, Node.js typings, `ws`, or `undici` + +## Requirements + +- A modern browser with `WebSocket`, `fetch`, `URL`, `TextEncoder`, and + `TextDecoder` +- QuestDB QWP routes exposed at `/write/v4` and `/read/v1` +- The `/exec` REST route when authentication bootstrap is needed + +This package does not contain the Node.js ILP transports; use +`@questdb/nodejs-client` for server-side Node.js programs. + +## Installation + +```shell +npm install @questdb/browser-client +``` + +```shell +yarn add @questdb/browser-client +``` + +```shell +pnpm add @questdb/browser-client +``` + +The package works with browser bundlers such as Vite, Rollup, webpack, and +esbuild. Import only from the package root: + +```typescript +import { connectQwpBrowserSender } from "@questdb/browser-client"; +``` + +## Quick start: ingest from a browser + +Serve QuestDB's QWP route from the application's origin, either directly or +through a reverse proxy. The browser will then apply the page's normal cookie, +origin, and TLS rules to the WebSocket connection. + +```typescript +import { connectQwpBrowserSender } from "@questdb/browser-client"; + +const writeUrl = new URL("/write/v4", window.location.href); +writeUrl.protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + +const sender = await connectQwpBrowserSender( + { url: writeUrl }, + { autoFlush: false }, +); + +try { + await sender + .table("page_events") + .symbol("kind", "view") + .stringColumn("path", window.location.pathname) + .timestampColumn("recorded_at", Date.now(), "ms") + .atNow(); + + await sender.flush(); +} finally { + await sender.close(); +} +``` + +Use `wss:` whenever the page is served over HTTPS. Browsers block insecure +WebSockets from secure pages. + +## Batch and commit rows + +Transactional mode keeps automatically emitted frames in one open server-side +transaction. `commit()` publishes the final frame. Transactions are atomic per +table, not across every table in one flush. + +```typescript +import { connectQwpBrowserSender } from "@questdb/browser-client"; + +const sender = await connectQwpBrowserSender( + { url: writeUrl, requestDurableAck: true }, + { + transactional: true, + autoFlushRows: 10_000, + awaitDurableAck: true, + durableAckTimeoutMs: 30_000, + }, +); + +try { + for (const event of [ + { source: "checkout", value: 1n, timestamp: Date.now() }, + { source: "search", value: 3n, timestamp: Date.now() }, + ]) { + await sender + .table("events") + .symbol("source", event.source) + .longColumn("value", event.value) + .at(event.timestamp, "ms"); + } + + await sender.commit(); +} finally { + await sender.close(); +} +``` + +Browser replay is held in memory and survives reconnects only while the page is +alive. Persistent store-and-forward is intentionally available only from the +Node.js package. + +## Type-safe object rows + +Compile a table schema once when application data already has an object shape. +TypeScript checks every row against the schema. + +```typescript +import { + connectQwpBrowserSender, + designatedTimestamp, + double, + symbol, +} from "@questdb/browser-client"; + +const sender = await connectQwpBrowserSender({ url: writeUrl }); + +try { + const measurements = sender.writer("measurements", { + device: symbol(), + temperature: double(), + timestamp: designatedTimestamp("ms"), + }); + + await measurements.rows([ + { device: "sensor-1", temperature: 21.4, timestamp: Date.now() }, + { device: "sensor-2", temperature: 22.1, timestamp: Date.now() }, + ]); + + await sender.flush(); +} finally { + await sender.close(); +} +``` + +The schema vocabulary also covers QuestDB integers, decimals, UUIDs, IPv4 +addresses, geohashes, binary values, and arrays. + +## Authentication + +Browser JavaScript cannot add an `Authorization` header to a WebSocket upgrade. +Authenticate over REST first so QuestDB can set an HttpOnly session cookie. The +browser then sends that cookie during the QWP WebSocket upgrade. + +```typescript +import { + bootstrapQwpBrowserSession, + connectQwpBrowserSender, +} from "@questdb/browser-client"; + +await bootstrapQwpBrowserSession({ + url: new URL("/exec", window.location.href), + authentication: { + type: "bearer", + token: oidcOrRestAccessToken, + }, + // QuestDB Enterprise only; omit to use the authenticated principal. + serviceAccount: "market_data_writer", +}); + +const sender = await connectQwpBrowserSender({ url: writeUrl }); +``` + +Basic authentication is also supported: + +```typescript +const sender = await connectQwpBrowserSender({ + url: writeUrl, + sessionBootstrap: { + authentication: { + type: "basic", + username: "admin", + password: "quest", + }, + }, +}); +``` + +Putting `sessionBootstrap` on the connection options repeats authentication +before initial connection, reconnect, and failover attempts. The package does +not run an interactive OIDC flow; the application obtains access tokens from +its identity provider. + +The bootstrap request uses credentials. Prefer serving `/exec`, `/write/v4`, +and `/read/v1` from the application's origin. Cross-origin deployments require +credentialed CORS and cookie attributes that permit the browser to store and +send the session cookie. JavaScript never reads the HttpOnly cookie. + +## Stream query results + +QWP egress streams typed result batches. A session runs one active query at a +time and automatically reconnects and walks configured failover URLs. + +```typescript +import { connectQwpBrowserEgress } from "@questdb/browser-client"; + +const readUrl = new URL("/read/v1", window.location.href); +readUrl.protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + +const session = await connectQwpBrowserEgress( + { + url: readUrl, + compression: "zstd", + sessionBootstrap: { + authentication: { type: "bearer", token: oidcOrRestAccessToken }, + }, + }, + { queryTimeoutMs: 30_000 }, +); + +try { + const query = await session.query( + "select timestamp, device, temperature " + + "from measurements where device = $1", + { + // Bind index 0 corresponds to SQL placeholder $1. + binds: (binds) => binds.setVarchar(0, "sensor-1"), + // A positive credit window bounds server read-ahead. + initialCredit: 1024 * 1024, + }, + ); + + for await (const batch of query) { + for (const row of batch.rows()) { + console.log(row); + } + } + + await query.completion; +} finally { + await session.close(); +} +``` + +Use `queryViews()` for reusable zero-copy result views in allocation-sensitive +applications. Copy any view that must outlive its batch callback. + +## Combined ingestion and query client + +`connectQwpBrowserClient()` creates bounded sender and query pools for an +application component that needs concurrent ingestion and queries: + +```typescript +import { connectQwpBrowserClient } from "@questdb/browser-client"; + +const clusterUrl = new URL("/", window.location.href); +clusterUrl.protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + +const db = await connectQwpBrowserClient({ + cluster: { + url: clusterUrl, + sessionBootstrap: { + authentication: { type: "bearer", token: oidcOrRestAccessToken }, + }, + }, + ingress: { requestDurableAck: true }, + egress: { target: "replica", compression: "zstd" }, + pool: { senderPoolMax: 2, queryPoolMax: 4 }, +}); + +try { + const sender = await db.borrowSender(); + try { + await sender.table("events").symbol("kind", "view").atNow(); + } finally { + // Flushes completed rows and returns the sender to the pool. + await sender.close(); + } + + const query = await db.borrowQuery(); + try { + const result = await query.query("select count() from events"); + for await (const batch of result) console.log([...batch.rows()]); + await result.completion; + } finally { + await query.close(); + } +} finally { + await db.close(); +} +``` + +## Error handling and shutdown + +- Always close senders, query sessions, borrowed pool handles, and pooled + clients in `finally` blocks. +- Await asynchronous row completion methods such as `at()`, `atNow()`, and + writer `row()`/`rows()` calls. +- An unfinished row is never completed implicitly during `flush()` or `close()`. +- Do not share one sender between unrelated concurrent producers. +- Re-executed queries are at least once after failover; clear already consumed + results in an `onReplayReset` callback when duplicate prefixes matter. + +## More documentation + +- [Complete repository README](https://github.com/questdb/nodejs-questdb-client#readme) +- [QWP guide](https://github.com/questdb/nodejs-questdb-client/blob/main/QWP.md) +- [Browser API reference](https://questdb.github.io/nodejs-questdb-client/modules/_questdb_browser-client.html) +- [QuestDB documentation](https://questdb.com/docs/) +- [QuestDB Community Forum](https://community.questdb.com/) diff --git a/packages/browser-client/THIRD_PARTY_NOTICES.md b/packages/browser-client/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..9d10e92 --- /dev/null +++ b/packages/browser-client/THIRD_PARTY_NOTICES.md @@ -0,0 +1,23 @@ +# Third-party notices + +This product bundles `fzstd` 0.1.1, which is available under the MIT License: + +Copyright (c) 2020 Arjun Barrett + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/browser-client/package.json b/packages/browser-client/package.json new file mode 100644 index 0000000..04444a6 --- /dev/null +++ b/packages/browser-client/package.json @@ -0,0 +1,48 @@ +{ + "name": "@questdb/browser-client", + "version": "4.2.0", + "description": "QuestDB JavaScript Client for browsers", + "scripts": { + "build": "node ../../scripts/clean-package-dist.mjs browser-client && bunchee" + }, + "files": [ + "dist", + "README.md", + "THIRD_PARTY_NOTICES.md" + ], + "main": "dist/cjs/index.js", + "module": "dist/es/index.mjs", + "browser": "dist/es/index.mjs", + "types": "dist/cjs/index.d.ts", + "exports": { + ".": { + "browser": { + "types": "./dist/es/index.d.mts", + "default": "./dist/es/index.mjs" + }, + "import": { + "types": "./dist/es/index.d.mts", + "default": "./dist/es/index.mjs" + }, + "require": { + "types": "./dist/cjs/index.d.ts", + "default": "./dist/cjs/index.js" + } + } + }, + "repository": { + "type": "git", + "url": "git+https://github.com/questdb/nodejs-questdb-client.git", + "directory": "packages/browser-client" + }, + "homepage": "https://questdb.github.io/nodejs-questdb-client", + "keywords": [ + "QuestDB", + "browser", + "JavaScript", + "TypeScript" + ], + "author": "QuestDB", + "license": "Apache-2.0", + "dependencies": {} +} diff --git a/src/qwp/browser.ts b/packages/browser-client/src/index.ts similarity index 96% rename from src/qwp/browser.ts rename to packages/browser-client/src/index.ts index f453618..4294135 100644 --- a/src/qwp/browser.ts +++ b/packages/browser-client/src/index.ts @@ -1,14 +1,17 @@ -/** Browser WebSocket adapter and browser-safe QWP protocol/session APIs. */ -export * from "./index"; +/** + * Browser WebSocket adapter and browser-safe QWP protocol/session APIs. + * @packageDocumentation + */ +export * from "../../client-core/src/qwp"; import { openQwpWebSocket, QwpWebSocketLike, validateQwpWebSocketTimeouts, -} from "../_qwp/_internal/websocket-connection"; -import { createQwpFailoverConnectionFactory } from "../_qwp/_internal/failover"; -import { createQwpEgressFailoverConnectionFactory } from "../_qwp/_internal/egress-routing"; -import { validateQwpMaxBatchRows } from "../_qwp/_internal/egress-limits"; +} from "../../client-core/src/_qwp/_internal/websocket-connection"; +import { createQwpFailoverConnectionFactory } from "../../client-core/src/_qwp/_internal/failover"; +import { createQwpEgressFailoverConnectionFactory } from "../../client-core/src/_qwp/_internal/egress-routing"; +import { validateQwpMaxBatchRows } from "../../client-core/src/_qwp/_internal/egress-limits"; import { addQwpDurableAckWebSocketProtocol, decodeQwpIngressServerInfo, @@ -16,7 +19,7 @@ import { isQwpDurableAckWebSocketProtocol, QwpEgressCompression, QWP_VERSION, -} from "../_qwp/_core"; +} from "../../client-core/src/_qwp/_core"; import { QwpBinaryConnection, QwpConnectionFactory, @@ -26,20 +29,23 @@ import { QWP_UPGRADE_ERROR_KIND, QwpUpgradeError, QwpWebSocketConnectOptions, -} from "../_qwp/transport"; +} from "../../client-core/src/_qwp/transport"; import { QWP_DEFAULT_EGRESS_SERVER_INFO_TIMEOUT_MS, QwpEgressSession, QwpEgressSessionOptions, -} from "../_qwp/egress-session"; +} from "../../client-core/src/_qwp/egress-session"; import { QwpIngressSession, QwpIngressSessionOptions, -} from "../_qwp/ingress-session"; -import { QwpSender, QwpSenderOptions } from "../_qwp/sender"; -import { QwpClient, QwpClientPoolOptions } from "../_qwp/client"; +} from "../../client-core/src/_qwp/ingress-session"; +import { QwpSender, QwpSenderOptions } from "../../client-core/src/_qwp/sender"; +import { + QwpClient, + QwpClientPoolOptions, +} from "../../client-core/src/_qwp/client"; -export type { QwpWebSocketLike } from "../_qwp/_internal/websocket-connection"; +export type { QwpWebSocketLike } from "../../client-core/src/_qwp/_internal/websocket-connection"; export type QwpBrowserSessionAuthentication = | { diff --git a/tsconfig.qwp-browser.json b/packages/browser-client/tsconfig.json similarity index 62% rename from tsconfig.qwp-browser.json rename to packages/browser-client/tsconfig.json index 23469db..ed3f5f3 100644 --- a/tsconfig.qwp-browser.json +++ b/packages/browser-client/tsconfig.json @@ -1,13 +1,12 @@ { - "include": ["src/_qwp/**/*.ts", "src/qwp/**/*.ts"], - "exclude": ["src/qwp/node.ts"], + "extends": "../../tsconfig.json", + "include": ["src"], "compilerOptions": { "moduleResolution": "bundler", "module": "ESNext", "target": "ES2020", "lib": ["ES2020", "DOM"], "types": [], - "noEmit": true, "strict": true } } diff --git a/packages/browser-client/typedoc.json b/packages/browser-client/typedoc.json new file mode 100644 index 0000000..4e1e6a6 --- /dev/null +++ b/packages/browser-client/typedoc.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://typedoc.org/schema.json", + "entryPoints": ["./src/index.ts"], + "tsconfig": "./tsconfig.json" +} diff --git a/packages/client-core/package.json b/packages/client-core/package.json new file mode 100644 index 0000000..7a97a58 --- /dev/null +++ b/packages/client-core/package.json @@ -0,0 +1,9 @@ +{ + "name": "@questdb/client-core", + "version": "4.2.0", + "private": true, + "description": "Private shared implementation for the QuestDB JavaScript clients", + "dependencies": { + "fzstd": "0.1.1" + } +} diff --git a/src/_qwp/_core/binds.ts b/packages/client-core/src/_qwp/_core/binds.ts similarity index 100% rename from src/_qwp/_core/binds.ts rename to packages/client-core/src/_qwp/_core/binds.ts diff --git a/src/_qwp/_core/bytes.ts b/packages/client-core/src/_qwp/_core/bytes.ts similarity index 100% rename from src/_qwp/_core/bytes.ts rename to packages/client-core/src/_qwp/_core/bytes.ts diff --git a/src/_qwp/_core/compression.ts b/packages/client-core/src/_qwp/_core/compression.ts similarity index 100% rename from src/_qwp/_core/compression.ts rename to packages/client-core/src/_qwp/_core/compression.ts diff --git a/src/_qwp/_core/constants.ts b/packages/client-core/src/_qwp/_core/constants.ts similarity index 100% rename from src/_qwp/_core/constants.ts rename to packages/client-core/src/_qwp/_core/constants.ts diff --git a/src/_qwp/_core/durable-ack.ts b/packages/client-core/src/_qwp/_core/durable-ack.ts similarity index 100% rename from src/_qwp/_core/durable-ack.ts rename to packages/client-core/src/_qwp/_core/durable-ack.ts diff --git a/src/_qwp/_core/egress.ts b/packages/client-core/src/_qwp/_core/egress.ts similarity index 100% rename from src/_qwp/_core/egress.ts rename to packages/client-core/src/_qwp/_core/egress.ts diff --git a/src/_qwp/_core/errors.ts b/packages/client-core/src/_qwp/_core/errors.ts similarity index 100% rename from src/_qwp/_core/errors.ts rename to packages/client-core/src/_qwp/_core/errors.ts diff --git a/src/_qwp/_core/frame.ts b/packages/client-core/src/_qwp/_core/frame.ts similarity index 100% rename from src/_qwp/_core/frame.ts rename to packages/client-core/src/_qwp/_core/frame.ts diff --git a/src/_qwp/_core/gorilla.ts b/packages/client-core/src/_qwp/_core/gorilla.ts similarity index 100% rename from src/_qwp/_core/gorilla.ts rename to packages/client-core/src/_qwp/_core/gorilla.ts diff --git a/src/_qwp/_core/identifiers.ts b/packages/client-core/src/_qwp/_core/identifiers.ts similarity index 100% rename from src/_qwp/_core/identifiers.ts rename to packages/client-core/src/_qwp/_core/identifiers.ts diff --git a/src/_qwp/_core/index.ts b/packages/client-core/src/_qwp/_core/index.ts similarity index 100% rename from src/_qwp/_core/index.ts rename to packages/client-core/src/_qwp/_core/index.ts diff --git a/src/_qwp/_core/ingress.ts b/packages/client-core/src/_qwp/_core/ingress.ts similarity index 100% rename from src/_qwp/_core/ingress.ts rename to packages/client-core/src/_qwp/_core/ingress.ts diff --git a/src/_qwp/_core/result-batch.ts b/packages/client-core/src/_qwp/_core/result-batch.ts similarity index 100% rename from src/_qwp/_core/result-batch.ts rename to packages/client-core/src/_qwp/_core/result-batch.ts diff --git a/src/_qwp/_core/symbol-dictionary.ts b/packages/client-core/src/_qwp/_core/symbol-dictionary.ts similarity index 100% rename from src/_qwp/_core/symbol-dictionary.ts rename to packages/client-core/src/_qwp/_core/symbol-dictionary.ts diff --git a/src/_qwp/_core/table.ts b/packages/client-core/src/_qwp/_core/table.ts similarity index 100% rename from src/_qwp/_core/table.ts rename to packages/client-core/src/_qwp/_core/table.ts diff --git a/src/_qwp/_core/varint.ts b/packages/client-core/src/_qwp/_core/varint.ts similarity index 100% rename from src/_qwp/_core/varint.ts rename to packages/client-core/src/_qwp/_core/varint.ts diff --git a/src/_qwp/_core/zstd.ts b/packages/client-core/src/_qwp/_core/zstd.ts similarity index 100% rename from src/_qwp/_core/zstd.ts rename to packages/client-core/src/_qwp/_core/zstd.ts diff --git a/src/_qwp/_internal/async-queue.ts b/packages/client-core/src/_qwp/_internal/async-queue.ts similarity index 100% rename from src/_qwp/_internal/async-queue.ts rename to packages/client-core/src/_qwp/_internal/async-queue.ts diff --git a/src/_qwp/_internal/egress-limits.ts b/packages/client-core/src/_qwp/_internal/egress-limits.ts similarity index 100% rename from src/_qwp/_internal/egress-limits.ts rename to packages/client-core/src/_qwp/_internal/egress-limits.ts diff --git a/src/_qwp/_internal/egress-routing.ts b/packages/client-core/src/_qwp/_internal/egress-routing.ts similarity index 100% rename from src/_qwp/_internal/egress-routing.ts rename to packages/client-core/src/_qwp/_internal/egress-routing.ts diff --git a/src/_qwp/_internal/failover.ts b/packages/client-core/src/_qwp/_internal/failover.ts similarity index 100% rename from src/_qwp/_internal/failover.ts rename to packages/client-core/src/_qwp/_internal/failover.ts diff --git a/src/_qwp/_internal/notification-dispatcher.ts b/packages/client-core/src/_qwp/_internal/notification-dispatcher.ts similarity index 100% rename from src/_qwp/_internal/notification-dispatcher.ts rename to packages/client-core/src/_qwp/_internal/notification-dispatcher.ts diff --git a/src/_qwp/_internal/reconnect-backoff.ts b/packages/client-core/src/_qwp/_internal/reconnect-backoff.ts similarity index 100% rename from src/_qwp/_internal/reconnect-backoff.ts rename to packages/client-core/src/_qwp/_internal/reconnect-backoff.ts diff --git a/src/_qwp/_internal/reconnecting-egress-connection.ts b/packages/client-core/src/_qwp/_internal/reconnecting-egress-connection.ts similarity index 100% rename from src/_qwp/_internal/reconnecting-egress-connection.ts rename to packages/client-core/src/_qwp/_internal/reconnecting-egress-connection.ts diff --git a/src/_qwp/_internal/reconnecting-ingress-connection.ts b/packages/client-core/src/_qwp/_internal/reconnecting-ingress-connection.ts similarity index 100% rename from src/_qwp/_internal/reconnecting-ingress-connection.ts rename to packages/client-core/src/_qwp/_internal/reconnecting-ingress-connection.ts diff --git a/src/_qwp/_internal/safe-callback.ts b/packages/client-core/src/_qwp/_internal/safe-callback.ts similarity index 100% rename from src/_qwp/_internal/safe-callback.ts rename to packages/client-core/src/_qwp/_internal/safe-callback.ts diff --git a/src/_qwp/_internal/websocket-connection.ts b/packages/client-core/src/_qwp/_internal/websocket-connection.ts similarity index 100% rename from src/_qwp/_internal/websocket-connection.ts rename to packages/client-core/src/_qwp/_internal/websocket-connection.ts diff --git a/src/_qwp/client.ts b/packages/client-core/src/_qwp/client.ts similarity index 100% rename from src/_qwp/client.ts rename to packages/client-core/src/_qwp/client.ts diff --git a/src/_qwp/egress-session.ts b/packages/client-core/src/_qwp/egress-session.ts similarity index 100% rename from src/_qwp/egress-session.ts rename to packages/client-core/src/_qwp/egress-session.ts diff --git a/src/_qwp/ingress-session.ts b/packages/client-core/src/_qwp/ingress-session.ts similarity index 100% rename from src/_qwp/ingress-session.ts rename to packages/client-core/src/_qwp/ingress-session.ts diff --git a/src/_qwp/sender-error.ts b/packages/client-core/src/_qwp/sender-error.ts similarity index 100% rename from src/_qwp/sender-error.ts rename to packages/client-core/src/_qwp/sender-error.ts diff --git a/src/_qwp/sender.ts b/packages/client-core/src/_qwp/sender.ts similarity index 100% rename from src/_qwp/sender.ts rename to packages/client-core/src/_qwp/sender.ts diff --git a/src/_qwp/transport.ts b/packages/client-core/src/_qwp/transport.ts similarity index 100% rename from src/_qwp/transport.ts rename to packages/client-core/src/_qwp/transport.ts diff --git a/src/_qwp/writer.ts b/packages/client-core/src/_qwp/writer.ts similarity index 96% rename from src/_qwp/writer.ts rename to packages/client-core/src/_qwp/writer.ts index 62d7f66..50603ee 100644 --- a/src/_qwp/writer.ts +++ b/packages/client-core/src/_qwp/writer.ts @@ -25,11 +25,11 @@ export type QwpWriterColumnKind = | "longArray"; // Registered in the global symbol registry rather than created per module. -// The published package emits one bundle per entry point ('.', './qwp', -// './qwp/browser', './qwp/node'), so a module-private brand would differ -// between the bundle that stamps a column and the bundle that validates it: -// a schema built with the factories from './qwp' would be rejected by the -// writer() of a sender imported from './qwp/node'. The key carries a version +// The public packages emit several entry bundles, so a module-private brand +// would differ between the bundle that stamps a column and the bundle that +// validates it: +// a schema built with one emitted copy of the factories would be rejected by a +// writer built from another copy. The key carries a version // so a future incompatible descriptor shape cannot interop with this one. const QWP_WRITER_COLUMN: unique symbol = Symbol.for( "questdb.qwp.writer.column.v1", @@ -59,7 +59,7 @@ export interface QwpWriterColumn< * assigned, and deliberately a plain property rather than a `unique symbol`: * each emitted bundle would declare its own symbol, making the key nominally * distinct per entry point. A column built by './qwp' would then satisfy - * './qwp/node''s QwpWriterColumn without ever matching its phantom key, so + * another bundle's QwpWriterColumn without ever matching its phantom key, so * QwpWriterColumnInput would infer `unknown` and every row field would * silently accept anything. A shared property name resolves structurally * across bundles, which is what keeps row typing alive for consumers of the diff --git a/src/logging.ts b/packages/client-core/src/logging.ts similarity index 100% rename from src/logging.ts rename to packages/client-core/src/logging.ts diff --git a/src/qwp/index.ts b/packages/client-core/src/qwp/index.ts similarity index 87% rename from src/qwp/index.ts rename to packages/client-core/src/qwp/index.ts index 0073d50..19de709 100644 --- a/src/qwp/index.ts +++ b/packages/client-core/src/qwp/index.ts @@ -1,8 +1,8 @@ /** * Browser-safe QuestDB Wire Protocol primitives. * - * This entry point intentionally contains no Node.js imports. Higher-level - * browser and Node WebSocket clients will be layered on top of this module. + * This private shared surface intentionally contains no Node.js imports. The + * public browser and Node packages add their runtime-specific adapters. * * @packageDocumentation */ diff --git a/packages/client-core/tsconfig.json b/packages/client-core/tsconfig.json new file mode 100644 index 0000000..ed3f5f3 --- /dev/null +++ b/packages/client-core/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src"], + "compilerOptions": { + "moduleResolution": "bundler", + "module": "ESNext", + "target": "ES2020", + "lib": ["ES2020", "DOM"], + "types": [], + "strict": true + } +} diff --git a/packages/nodejs-client/README.md b/packages/nodejs-client/README.md new file mode 100644 index 0000000..ea7ece0 --- /dev/null +++ b/packages/nodejs-client/README.md @@ -0,0 +1,307 @@ +# QuestDB JavaScript Client for Node.js + +The official QuestDB client for Node.js and TypeScript. Use it to ingest rows +with the InfluxDB Line Protocol (ILP), ingest and query with the QuestDB Wire +Protocol (QWP), and keep publishing through outages with Node-only persistent +store-and-forward. + +The complete Node.js API is exported from `@questdb/nodejs-client`. There are no +additional public import paths. + +## Features + +- ILP ingestion over HTTP, HTTPS, TCP, and TLS-encrypted TCP +- QWP ingestion over WebSocket, secure WebSocket, and UDP +- Streaming QWP queries with typed bind variables and result batches +- Automatic batching, failover, reconnect, and acknowledgement tracking +- Persistent QWP store-and-forward for process and server outages +- ESM, CommonJS, and bundled TypeScript declarations + +## Requirements + +- Node.js 20 or newer +- A running QuestDB instance +- QWP endpoints `/write/v4` and `/read/v1` for QWP ingestion and queries + +## Installation + +```shell +npm install @questdb/nodejs-client +``` + +```shell +yarn add @questdb/nodejs-client +``` + +```shell +pnpm add @questdb/nodejs-client +``` + +## Quick start: ILP over HTTP + +`Sender` buffers rows locally. Add as many complete rows as needed, then call +`flush()` to send the batch. + +```typescript +import { Sender } from "@questdb/nodejs-client"; + +const sender = await Sender.fromConfig("http::addr=localhost:9000"); + +try { + await sender + .table("trades") + .symbol("symbol", "ETH-USD") + .symbol("side", "buy") + .floatColumn("price", 2_615.54) + .floatColumn("amount", 0.25) + .at(Date.now(), "ms"); + + await sender.flush(); +} finally { + await sender.close(); +} +``` + +HTTP and HTTPS connect for each request. TCP, TCPS, WS, WSS, and UDP transports +have an explicit connection, so call `await sender.connect()` before writing. + +## Choosing a transport + +| Configuration prefix | Protocol | Typical use | +| -------------------- | -------- | ------------------------------------------------------- | +| `http::`, `https::` | ILP | Recommended general-purpose ingestion | +| `tcp::`, `tcps::` | ILP | Long-lived ILP connection | +| `ws::`, `wss::` | QWP | Acknowledged ingestion, failover, and store-and-forward | +| `udp::` | QWP | Fire-and-forget datagrams on trusted networks | + +Use encrypted transports and certificate verification outside trusted local +development environments. + +## Batch multiple rows + +Avoid flushing after every row when the application can send a larger batch. +The sender also supports automatic flushing through its configuration options. + +```typescript +import { Sender } from "@questdb/nodejs-client"; + +const sender = await Sender.fromConfig("http::addr=localhost:9000"); + +try { + for (const trade of [ + { symbol: "ETH-USD", price: 2_615.54, amount: 0.25 }, + { symbol: "BTC-USD", price: 59_750.1, amount: 0.01 }, + ]) { + await sender + .table("trades") + .symbol("symbol", trade.symbol) + .floatColumn("price", trade.price) + .floatColumn("amount", trade.amount) + .atNow(); + } + + await sender.flush(); +} finally { + await sender.close(); +} +``` + +Passing `null` or `undefined` to a supported symbol or column method omits that +column from the row, which records a SQL `NULL` in QuestDB. + +## Authentication and TLS + +Configuration strings use the form +`protocol::key=value;key=value`. HTTP Basic authentication uses `username` and +`password`; REST and OIDC access tokens use `token`. + +```typescript +import { Sender } from "@questdb/nodejs-client"; + +const sender = await Sender.fromConfig( + `https::addr=questdb.example:9000;token=${process.env.QUESTDB_TOKEN};tls_verify=on`, +); + +try { + await sender.table("service_health").booleanColumn("healthy", true).atNow(); + await sender.flush(); +} finally { + await sender.close(); +} +``` + +The same configuration can be provided through `QDB_CLIENT_CONF`: + +```typescript +import { Sender } from "@questdb/nodejs-client"; + +// QDB_CLIENT_CONF=http::addr=localhost:9000 +const sender = await Sender.fromEnv(); +``` + +## QWP ingestion + +Changing the configuration prefix to `ws::` or `wss::` selects QWP while +keeping the familiar `Sender` row API. + +```typescript +import { Sender } from "@questdb/nodejs-client"; + +const sender = await Sender.fromConfig( + `wss::addr=questdb.example:9000;token=${process.env.QUESTDB_TOKEN};auto_flush=off`, +); +await sender.connect(); + +try { + await sender + .table("trades") + .symbol("symbol", "ETH-USD") + .floatColumn("price", 2_615.54) + .timestampColumn("received_at", Date.now(), "ms") + .atNow(); + + await sender.flush(); +} finally { + await sender.close(); +} +``` + +QWP senders support server acknowledgements, durable acknowledgements, +transactions, reconnect, failover, compiled row writers, and metrics. See the +[QWP guide](https://github.com/questdb/nodejs-questdb-client/blob/main/QWP.md) +for the delivery semantics of each option. + +### Type-safe object rows + +For repeated object-shaped rows, compile a table schema once. TypeScript then +checks each row against that schema. + +```typescript +import { + Sender, + designatedTimestamp, + double, + long, + symbol, +} from "@questdb/nodejs-client"; + +const sender = await Sender.fromConfig("ws::addr=localhost:9000"); +await sender.connect(); + +try { + const trades = sender.writer("trades", { + symbol: symbol(), + side: symbol(), + price: double(), + quantity: long(), + timestamp: designatedTimestamp("ns"), + }); + + await trades.rows([ + { + symbol: "ETH-USD", + side: "buy", + price: 2_615.54, + quantity: 42n, + timestamp: 1_723_000_000_000_000_000n, + }, + { + symbol: "BTC-USD", + side: "sell", + price: 59_750.1, + quantity: 1n, + timestamp: 1_723_000_001_000_000_000n, + }, + ]); + + await sender.flush(); +} finally { + await sender.close(); +} +``` + +Compiled writers are available with QWP transports only. + +## QWP queries + +QWP egress streams typed result batches. One egress session executes one active +query at a time. + +```typescript +import { connectQwpNodeEgress } from "@questdb/nodejs-client"; + +const session = await connectQwpNodeEgress( + { + url: "wss://questdb.example:9000/read/v1", + authorization: `Bearer ${process.env.QUESTDB_TOKEN}`, + compression: "zstd", + }, + { queryTimeoutMs: 30_000 }, +); + +try { + const query = await session.query( + "select timestamp, symbol, price from trades where symbol = $1", + { + // Bind index 0 corresponds to SQL placeholder $1. + binds: (binds) => binds.setVarchar(0, "ETH-USD"), + initialCredit: 1024 * 1024, + }, + ); + + for await (const batch of query) { + for (const row of batch.rows()) { + console.log(row); + } + } + + await query.completion; +} finally { + await session.close(); +} +``` + +Use `queryViews()` instead of `query()` for reusable, allocation-conscious +column and row views. + +## Persistent store-and-forward + +Node.js can journal QWP frames to disk before sending them. The producer can +continue accepting rows during a QuestDB outage and replay them in order after +reconnection. + +```typescript +import { Sender } from "@questdb/nodejs-client"; + +const sender = await Sender.fromConfig( + "wss::" + + "addr=questdb-a.example:9000,questdb-b.example:9000;" + + "sf_dir=/var/lib/my-service/questdb-replay;" + + "initial_connect_retry=async;", +); + +await sender.connect(); +``` + +Give every active producer its own journal directory. Durability, +backpressure, capacity, orphan recovery, and shutdown behavior are covered in +the [store-and-forward section of the QWP guide](https://github.com/questdb/nodejs-questdb-client/blob/main/QWP.md#store-and-forward-node-only). + +## Error handling and shutdown + +- Always call `close()` in a `finally` block. +- Call `flush()` before closing an ILP sender; otherwise buffered rows are lost. +- A QWP sender publishes completed rows during close, but an unfinished row is + never completed implicitly. +- Do not write concurrently through one `Sender`. Give each worker or producer + its own sender. +- Treat authentication and protocol errors as configuration failures rather + than retrying the same request indefinitely. + +## More documentation + +- [Complete repository README](https://github.com/questdb/nodejs-questdb-client#readme) +- [QWP guide](https://github.com/questdb/nodejs-questdb-client/blob/main/QWP.md) +- [Node.js API reference](https://questdb.github.io/nodejs-questdb-client/modules/_questdb_nodejs-client.html) +- [QuestDB documentation](https://questdb.com/docs/) +- [QuestDB Community Forum](https://community.questdb.com/) diff --git a/packages/nodejs-client/THIRD_PARTY_NOTICES.md b/packages/nodejs-client/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..9d10e92 --- /dev/null +++ b/packages/nodejs-client/THIRD_PARTY_NOTICES.md @@ -0,0 +1,23 @@ +# Third-party notices + +This product bundles `fzstd` 0.1.1, which is available under the MIT License: + +Copyright (c) 2020 Arjun Barrett + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/nodejs-client/package.json b/packages/nodejs-client/package.json new file mode 100644 index 0000000..ebc140a --- /dev/null +++ b/packages/nodejs-client/package.json @@ -0,0 +1,49 @@ +{ + "name": "@questdb/nodejs-client", + "version": "4.2.0", + "description": "QuestDB JavaScript Client for Node.js", + "scripts": { + "build": "node ../../scripts/clean-package-dist.mjs nodejs-client && bunchee" + }, + "files": [ + "dist", + "README.md", + "THIRD_PARTY_NOTICES.md" + ], + "main": "dist/cjs/index.js", + "module": "dist/es/index.mjs", + "types": "dist/cjs/index.d.ts", + "exports": { + ".": { + "import": { + "types": "./dist/es/index.d.mts", + "default": "./dist/es/index.mjs" + }, + "require": { + "types": "./dist/cjs/index.d.ts", + "default": "./dist/cjs/index.js" + } + } + }, + "repository": { + "type": "git", + "url": "git+https://github.com/questdb/nodejs-questdb-client.git", + "directory": "packages/nodejs-client" + }, + "homepage": "https://questdb.github.io/nodejs-questdb-client", + "keywords": [ + "QuestDB", + "Node.js", + "JavaScript", + "TypeScript" + ], + "author": "QuestDB", + "license": "Apache-2.0", + "engines": { + "node": ">=20" + }, + "dependencies": { + "undici": "^7.8.0", + "ws": "^8.21.3" + } +} diff --git a/src/buffer/base.ts b/packages/nodejs-client/src/buffer/base.ts similarity index 100% rename from src/buffer/base.ts rename to packages/nodejs-client/src/buffer/base.ts diff --git a/src/buffer/bufferv1.ts b/packages/nodejs-client/src/buffer/bufferv1.ts similarity index 100% rename from src/buffer/bufferv1.ts rename to packages/nodejs-client/src/buffer/bufferv1.ts diff --git a/src/buffer/bufferv2.ts b/packages/nodejs-client/src/buffer/bufferv2.ts similarity index 100% rename from src/buffer/bufferv2.ts rename to packages/nodejs-client/src/buffer/bufferv2.ts diff --git a/src/buffer/bufferv3.ts b/packages/nodejs-client/src/buffer/bufferv3.ts similarity index 100% rename from src/buffer/bufferv3.ts rename to packages/nodejs-client/src/buffer/bufferv3.ts diff --git a/src/buffer/index.ts b/packages/nodejs-client/src/buffer/index.ts similarity index 100% rename from src/buffer/index.ts rename to packages/nodejs-client/src/buffer/index.ts diff --git a/src/index.ts b/packages/nodejs-client/src/index.ts similarity index 88% rename from src/index.ts rename to packages/nodejs-client/src/index.ts index 116558f..bd1b1b6 100644 --- a/src/index.ts +++ b/packages/nodejs-client/src/index.ts @@ -1,7 +1,7 @@ /** * The QuestDB JavaScript client. * - * This entry point targets Node.js. See `./qwp/browser` for the browser build. + * This entry point targets Node.js. Use `@questdb/browser-client` for the browser build. * @packageDocumentation */ @@ -21,3 +21,4 @@ export { HttpTransport } from "./transport/http/stdlib"; export { UndiciTransport } from "./transport/http/undici"; export type { Logger } from "./logging"; export { bigintToTwosComplementBytes } from "./utils"; +export * from "./qwp"; diff --git a/packages/nodejs-client/src/logging.ts b/packages/nodejs-client/src/logging.ts new file mode 100644 index 0000000..199e526 --- /dev/null +++ b/packages/nodejs-client/src/logging.ts @@ -0,0 +1 @@ +export { log, type Logger } from "../../client-core/src/logging"; diff --git a/src/options.ts b/packages/nodejs-client/src/options.ts similarity index 99% rename from src/options.ts rename to packages/nodejs-client/src/options.ts index aff8e76..1726bd1 100644 --- a/src/options.ts +++ b/packages/nodejs-client/src/options.ts @@ -7,14 +7,14 @@ import * as https from "https"; import { log, Logger } from "./logging"; import { fetchJson, isBoolean, isInteger } from "./utils"; import { DEFAULT_REQUEST_TIMEOUT } from "./transport/http/base"; -import * as qwpNode from "./qwp/node"; +import * as qwpNode from "./qwp"; import type { QwpNodeClientOptions, QwpNodeIngressOptions, QwpNodeUdpOptions, QwpIngressSessionOptions, QwpSenderOptions, -} from "./qwp/node"; +} from "./qwp"; /** * @ignore diff --git a/src/qwp-node/advisory-lock.ts b/packages/nodejs-client/src/qwp-node/advisory-lock.ts similarity index 100% rename from src/qwp-node/advisory-lock.ts rename to packages/nodejs-client/src/qwp-node/advisory-lock.ts diff --git a/src/qwp-node/client-config.ts b/packages/nodejs-client/src/qwp-node/client-config.ts similarity index 98% rename from src/qwp-node/client-config.ts rename to packages/nodejs-client/src/qwp-node/client-config.ts index b354df9..c0b08df 100644 --- a/src/qwp-node/client-config.ts +++ b/packages/nodejs-client/src/qwp-node/client-config.ts @@ -6,12 +6,15 @@ import type { QwpNodeEgressOptions, QwpNodeIngressOptions, QwpNodeStoreAndForwardOptions, -} from "../qwp/node"; -import type { QwpClientPoolOptions } from "../_qwp/client"; -import type { QwpEgressSessionOptions } from "../_qwp/egress-session"; -import type { QwpIngressSessionOptions } from "../_qwp/ingress-session"; -import type { QwpSenderOptions } from "../_qwp/sender"; -import type { QwpReconnectOptions, QwpTarget } from "../_qwp/transport"; +} from "../qwp"; +import type { QwpClientPoolOptions } from "../../../client-core/src/_qwp/client"; +import type { QwpEgressSessionOptions } from "../../../client-core/src/_qwp/egress-session"; +import type { QwpIngressSessionOptions } from "../../../client-core/src/_qwp/ingress-session"; +import type { QwpSenderOptions } from "../../../client-core/src/_qwp/sender"; +import type { + QwpReconnectOptions, + QwpTarget, +} from "../../../client-core/src/_qwp/transport"; const DEFAULT_QWP_PORT = 9000; const MAX_BATCH_ROWS = 1_048_576; diff --git a/src/qwp-node/file-replay-store.ts b/packages/nodejs-client/src/qwp-node/file-replay-store.ts similarity index 99% rename from src/qwp-node/file-replay-store.ts rename to packages/nodejs-client/src/qwp-node/file-replay-store.ts index f161924..57aca47 100644 --- a/src/qwp-node/file-replay-store.ts +++ b/packages/nodejs-client/src/qwp-node/file-replay-store.ts @@ -11,19 +11,19 @@ import { } from "node:fs/promises"; import type { FileHandle } from "node:fs/promises"; import { basename, dirname, join } from "node:path"; -import { QWP_MAX_SYMBOL_DICTIONARY_SIZE } from "../_qwp/_core"; +import { QWP_MAX_SYMBOL_DICTIONARY_SIZE } from "../../../client-core/src/_qwp/_core"; import { QwpIngressReplayRecord, QwpIngressReplayReference, QwpIngressReplayStore, -} from "../_qwp/transport"; +} from "../../../client-core/src/_qwp/transport"; import { QwpNodeAdvisoryLock, QwpNodeAdvisoryLockBusyError, } from "./advisory-lock"; import { qwpSegmentMaintenanceWorker } from "./segment-maintenance-worker"; import { log } from "../logging"; -import { safelyInvoke } from "../_qwp/_internal/safe-callback"; +import { safelyInvoke } from "../../../client-core/src/_qwp/_internal/safe-callback"; const FORMAT_VERSION = 1; const MAX_FRAME_SEQUENCE = 0x7fffffffffffffffn; diff --git a/src/qwp-node/orphan-drainer.ts b/packages/nodejs-client/src/qwp-node/orphan-drainer.ts similarity index 98% rename from src/qwp-node/orphan-drainer.ts rename to packages/nodejs-client/src/qwp-node/orphan-drainer.ts index e8ce7a4..d20b2af 100644 --- a/src/qwp-node/orphan-drainer.ts +++ b/packages/nodejs-client/src/qwp-node/orphan-drainer.ts @@ -8,23 +8,23 @@ import { QwpIngressTransportMetrics, QwpReplayRejectedError, QwpUpgradeError, -} from "../_qwp/transport"; +} from "../../../client-core/src/_qwp/transport"; import { isQwpNodeReplayQuarantineSlotName, QwpReplayStoreCorruptionError, QwpReplayStoreLockedError, } from "./file-replay-store"; -import { QwpProtocolError } from "../_qwp/_core/errors"; +import { QwpProtocolError } from "../../../client-core/src/_qwp/_core/errors"; import { QwpCatchUpCapGapError, QwpDurableAckPersistentFailureError, -} from "../_qwp/_internal/reconnecting-ingress-connection"; -import { QwpNotificationDispatcher } from "../_qwp/_internal/notification-dispatcher"; +} from "../../../client-core/src/_qwp/_internal/reconnecting-ingress-connection"; +import { QwpNotificationDispatcher } from "../../../client-core/src/_qwp/_internal/notification-dispatcher"; import { createQwpDataLossSenderError, defaultQwpSenderErrorHandler, type QwpSenderError, -} from "../_qwp/sender-error"; +} from "../../../client-core/src/_qwp/sender-error"; const SEGMENT_SUFFIX = ".sfa"; const SEGMENT_HEADER_SIZE = 24; diff --git a/src/qwp-node/segment-maintenance-worker.ts b/packages/nodejs-client/src/qwp-node/segment-maintenance-worker.ts similarity index 100% rename from src/qwp-node/segment-maintenance-worker.ts rename to packages/nodejs-client/src/qwp-node/segment-maintenance-worker.ts diff --git a/src/qwp-node/udp-sender.ts b/packages/nodejs-client/src/qwp-node/udp-sender.ts similarity index 98% rename from src/qwp-node/udp-sender.ts rename to packages/nodejs-client/src/qwp-node/udp-sender.ts index d55cb9c..acaa95d 100644 --- a/src/qwp-node/udp-sender.ts +++ b/packages/nodejs-client/src/qwp-node/udp-sender.ts @@ -4,9 +4,9 @@ import { type QwpIngressEncodeOptions, type QwpIngressResponse, type QwpTableBuffer, -} from "../_qwp/_core"; -import type { QwpSenderSession } from "../_qwp/sender"; -import { safelyInvoke } from "../_qwp/_internal/safe-callback"; +} from "../../../client-core/src/_qwp/_core"; +import type { QwpSenderSession } from "../../../client-core/src/_qwp/sender"; +import { safelyInvoke } from "../../../client-core/src/_qwp/_internal/safe-callback"; const DEFAULT_QWP_UDP_PORT = 9007; const DEFAULT_MAX_DATAGRAM_SIZE = 1_400; diff --git a/src/qwp/node.ts b/packages/nodejs-client/src/qwp.ts similarity index 96% rename from src/qwp/node.ts rename to packages/nodejs-client/src/qwp.ts index c158234..6167135 100644 --- a/src/qwp/node.ts +++ b/packages/nodejs-client/src/qwp.ts @@ -1,31 +1,31 @@ /** Node.js WebSocket adapter and shared QWP protocol/session APIs. */ -export * from "./index"; +export * from "../../client-core/src/qwp"; import type { Agent } from "node:http"; import type { IncomingHttpHeaders } from "node:http"; import { basename, dirname, join } from "node:path"; import WebSocket from "ws"; -import { log } from "../logging"; +import { log } from "./logging"; import { decodeQwpContentEncoding, encodeQwpAcceptEncoding, QWP_VERSION, type QwpEgressCompression, -} from "../_qwp/_core"; +} from "../../client-core/src/_qwp/_core"; import { openQwpWebSocket, QwpWebSocketLike, validateQwpWebSocketTimeouts, -} from "../_qwp/_internal/websocket-connection"; +} from "../../client-core/src/_qwp/_internal/websocket-connection"; import { createQwpFailoverConnectionFactory, createQwpFailoverHealthTracker, QwpFailoverHealthTracker, -} from "../_qwp/_internal/failover"; -import { createQwpEgressFailoverConnectionFactory } from "../_qwp/_internal/egress-routing"; -import { validateQwpMaxBatchRows } from "../_qwp/_internal/egress-limits"; -import { safelyInvoke } from "../_qwp/_internal/safe-callback"; -import { resolveQwpNodeClientConfig } from "../qwp-node/client-config"; +} from "../../client-core/src/_qwp/_internal/failover"; +import { createQwpEgressFailoverConnectionFactory } from "../../client-core/src/_qwp/_internal/egress-routing"; +import { validateQwpMaxBatchRows } from "../../client-core/src/_qwp/_internal/egress-limits"; +import { safelyInvoke } from "../../client-core/src/_qwp/_internal/safe-callback"; +import { resolveQwpNodeClientConfig } from "./qwp-node/client-config"; import { QWP_INITIAL_CONNECT_MODE, QWP_UPGRADE_ERROR_KIND, @@ -40,45 +40,45 @@ import { QwpUnrecoverableReplayDictionaryError, QwpUpgradeError, QwpWebSocketConnectOptions, -} from "../_qwp/transport"; +} from "../../client-core/src/_qwp/transport"; import { QWP_DEFAULT_EGRESS_SERVER_INFO_TIMEOUT_MS, QwpEgressSession, QwpEgressSessionOptions, -} from "../_qwp/egress-session"; +} from "../../client-core/src/_qwp/egress-session"; import { QwpIngressSession, QwpIngressSessionOptions, -} from "../_qwp/ingress-session"; +} from "../../client-core/src/_qwp/ingress-session"; import { createQwpDataLossSenderError, defaultQwpSenderErrorHandler, type QwpSenderError, -} from "../_qwp/sender-error"; -import { QwpSender, QwpSenderOptions } from "../_qwp/sender"; +} from "../../client-core/src/_qwp/sender-error"; +import { QwpSender, QwpSenderOptions } from "../../client-core/src/_qwp/sender"; import { QwpClient, QwpClientPoolOptions, type QwpPoolSlotReservation, -} from "../_qwp/client"; +} from "../../client-core/src/_qwp/client"; import { quarantineQwpNodeReplayStore, QwpNodeFileReplayStore, QwpReplayStoreCorruptionError, QwpReplayStoreQuarantinedError, -} from "../qwp-node/file-replay-store"; +} from "./qwp-node/file-replay-store"; import type { QwpNodeFileReplayStoreOptions, QwpNodeReplayDataLossReport, -} from "../qwp-node/file-replay-store"; +} from "./qwp-node/file-replay-store"; import { QwpNodeOrphanDrainer, type QwpNodeOrphanDrainEvent, -} from "../qwp-node/orphan-drainer"; +} from "./qwp-node/orphan-drainer"; import { QwpNodeUdpSession, type QwpNodeUdpOptions, -} from "../qwp-node/udp-sender"; +} from "./qwp-node/udp-sender"; export { QWP_SF_BACKPRESSURE_POLICY, @@ -93,39 +93,39 @@ export { QwpReplayStoreLockLostError, QwpReplayStoreQuarantinedError, QwpReplayStoreSegmentTooLargeError, -} from "../qwp-node/file-replay-store"; +} from "./qwp-node/file-replay-store"; export type { QwpNodeFileReplayStoreMetrics, QwpNodeFileReplayStoreOptions, QwpNodeReplayDataLossReport, QwpSfBackpressurePolicy, QwpSfDurability, -} from "../qwp-node/file-replay-store"; +} from "./qwp-node/file-replay-store"; export { QWP_ORPHAN_DRAIN_EVENT_KIND, QWP_ORPHAN_FAILED_SENTINEL, QwpNodeOrphanDrainer, retryQwpNodeOrphanSlot, scanQwpNodeOrphanSlots, -} from "../qwp-node/orphan-drainer"; +} from "./qwp-node/orphan-drainer"; export { QwpNodeUdpSession, QwpUdpDatagramTooLargeError, -} from "../qwp-node/udp-sender"; +} from "./qwp-node/udp-sender"; export type { QwpNodeUdpMetrics, QwpNodeUdpOptions, QwpNodeUdpSocketLike, -} from "../qwp-node/udp-sender"; +} from "./qwp-node/udp-sender"; export type { QwpNodeOrphanDrainEvent, QwpNodeOrphanDrainEventKind, QwpNodeOrphanDrainerMetrics, QwpNodeOrphanDrainerOptions, QwpNodeOrphanDrainSession, -} from "../qwp-node/orphan-drainer"; +} from "./qwp-node/orphan-drainer"; -export type { QwpWebSocketLike } from "../_qwp/_internal/websocket-connection"; +export type { QwpWebSocketLike } from "../../client-core/src/_qwp/_internal/websocket-connection"; export class QwpVersionMismatchError extends QwpUpgradeError { constructor( diff --git a/src/sender.ts b/packages/nodejs-client/src/sender.ts similarity index 99% rename from src/sender.ts rename to packages/nodejs-client/src/sender.ts index b0338b8..0a4c7da 100644 --- a/src/sender.ts +++ b/packages/nodejs-client/src/sender.ts @@ -15,10 +15,10 @@ import { import { SenderTransport, createTransport } from "./transport"; import { SenderBuffer, createBuffer } from "./buffer"; import { isBoolean, isInteger, TimestampUnit } from "./utils"; -import * as qwpNode from "./qwp/node"; -import type { QwpSender } from "./qwp/node"; -import type { QwpTableWriter } from "./_qwp/sender"; -import type { QwpWriterSchema } from "./_qwp/writer"; +import * as qwpNode from "./qwp"; +import type { QwpSender } from "./qwp"; +import type { QwpTableWriter } from "../../client-core/src/_qwp/sender"; +import type { QwpWriterSchema } from "../../client-core/src/_qwp/writer"; const QWP_INGRESS_PATH = "/write/v4"; diff --git a/src/transport/http/base.ts b/packages/nodejs-client/src/transport/http/base.ts similarity index 100% rename from src/transport/http/base.ts rename to packages/nodejs-client/src/transport/http/base.ts diff --git a/src/transport/http/stdlib.ts b/packages/nodejs-client/src/transport/http/stdlib.ts similarity index 100% rename from src/transport/http/stdlib.ts rename to packages/nodejs-client/src/transport/http/stdlib.ts diff --git a/src/transport/http/undici.ts b/packages/nodejs-client/src/transport/http/undici.ts similarity index 100% rename from src/transport/http/undici.ts rename to packages/nodejs-client/src/transport/http/undici.ts diff --git a/src/transport/index.ts b/packages/nodejs-client/src/transport/index.ts similarity index 100% rename from src/transport/index.ts rename to packages/nodejs-client/src/transport/index.ts diff --git a/src/transport/tcp.ts b/packages/nodejs-client/src/transport/tcp.ts similarity index 100% rename from src/transport/tcp.ts rename to packages/nodejs-client/src/transport/tcp.ts diff --git a/src/utils.ts b/packages/nodejs-client/src/utils.ts similarity index 100% rename from src/utils.ts rename to packages/nodejs-client/src/utils.ts diff --git a/src/validation.ts b/packages/nodejs-client/src/validation.ts similarity index 100% rename from src/validation.ts rename to packages/nodejs-client/src/validation.ts diff --git a/packages/nodejs-client/tsconfig.json b/packages/nodejs-client/tsconfig.json new file mode 100644 index 0000000..596e2cf --- /dev/null +++ b/packages/nodejs-client/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src"] +} diff --git a/packages/nodejs-client/typedoc.json b/packages/nodejs-client/typedoc.json new file mode 100644 index 0000000..d45b7e7 --- /dev/null +++ b/packages/nodejs-client/typedoc.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://typedoc.org/schema.json", + "entryPoints": ["./src/index.ts"], + "tsconfig": "./tsconfig.json", + "validation": { + "notExported": false + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9040014..3451c3c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,13 +7,6 @@ settings: importers: .: - dependencies: - undici: - specifier: ^7.8.0 - version: 7.8.0 - ws: - specifier: ^8.21.3 - version: 8.21.3 devDependencies: '@eslint/js': specifier: ^9.16.0 @@ -33,15 +26,15 @@ importers: eslint: specifier: ^9.26.0 version: 9.26.0 - fzstd: - specifier: 0.1.1 - version: 0.1.1 playwright: specifier: ^1.62.1 version: 1.62.1 prettier: specifier: ^3.5.3 version: 3.5.3 + rollup: + specifier: ^4.40.2 + version: 4.40.2 serve: specifier: ^14.2.4 version: 14.2.4 @@ -57,9 +50,32 @@ importers: typescript-eslint: specifier: ^8.32.0 version: 8.32.0(eslint@9.26.0)(typescript@5.7.2) + undici: + specifier: ^7.8.0 + version: 7.8.0 vitest: specifier: ^3.1.3 version: 3.1.3(@types/node@22.15.17) + ws: + specifier: ^8.21.3 + version: 8.21.3 + + packages/browser-client: {} + + packages/client-core: + dependencies: + fzstd: + specifier: 0.1.1 + version: 0.1.1 + + packages/nodejs-client: + dependencies: + undici: + specifier: ^7.8.0 + version: 7.8.0 + ws: + specifier: ^8.21.3 + version: 8.21.3 packages: @@ -416,101 +432,51 @@ packages: rollup: optional: true - '@rollup/rollup-android-arm-eabi@4.28.0': - resolution: {integrity: sha512-wLJuPLT6grGZsy34g4N1yRfYeouklTgPhH1gWXCYspenKYD0s3cR99ZevOGw5BexMNywkbV3UkjADisozBmpPQ==} - cpu: [arm] - os: [android] - '@rollup/rollup-android-arm-eabi@4.40.2': resolution: {integrity: sha512-JkdNEq+DFxZfUwxvB58tHMHBHVgX23ew41g1OQinthJ+ryhdRk67O31S7sYw8u2lTjHUPFxwar07BBt1KHp/hg==} cpu: [arm] os: [android] - '@rollup/rollup-android-arm64@4.28.0': - resolution: {integrity: sha512-eiNkznlo0dLmVG/6wf+Ifi/v78G4d4QxRhuUl+s8EWZpDewgk7PX3ZyECUXU0Zq/Ca+8nU8cQpNC4Xgn2gFNDA==} - cpu: [arm64] - os: [android] - '@rollup/rollup-android-arm64@4.40.2': resolution: {integrity: sha512-13unNoZ8NzUmnndhPTkWPWbX3vtHodYmy+I9kuLxN+F+l+x3LdVF7UCu8TWVMt1POHLh6oDHhnOA04n8oJZhBw==} cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.28.0': - resolution: {integrity: sha512-lmKx9yHsppblnLQZOGxdO66gT77bvdBtr/0P+TPOseowE7D9AJoBw8ZDULRasXRWf1Z86/gcOdpBrV6VDUY36Q==} - cpu: [arm64] - os: [darwin] - '@rollup/rollup-darwin-arm64@4.40.2': resolution: {integrity: sha512-Gzf1Hn2Aoe8VZzevHostPX23U7N5+4D36WJNHK88NZHCJr7aVMG4fadqkIf72eqVPGjGc0HJHNuUaUcxiR+N/w==} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.28.0': - resolution: {integrity: sha512-8hxgfReVs7k9Js1uAIhS6zq3I+wKQETInnWQtgzt8JfGx51R1N6DRVy3F4o0lQwumbErRz52YqwjfvuwRxGv1w==} - cpu: [x64] - os: [darwin] - '@rollup/rollup-darwin-x64@4.40.2': resolution: {integrity: sha512-47N4hxa01a4x6XnJoskMKTS8XZ0CZMd8YTbINbi+w03A2w4j1RTlnGHOz/P0+Bg1LaVL6ufZyNprSg+fW5nYQQ==} cpu: [x64] os: [darwin] - '@rollup/rollup-freebsd-arm64@4.28.0': - resolution: {integrity: sha512-lA1zZB3bFx5oxu9fYud4+g1mt+lYXCoch0M0V/xhqLoGatbzVse0wlSQ1UYOWKpuSu3gyN4qEc0Dxf/DII1bhQ==} - cpu: [arm64] - os: [freebsd] - '@rollup/rollup-freebsd-arm64@4.40.2': resolution: {integrity: sha512-8t6aL4MD+rXSHHZUR1z19+9OFJ2rl1wGKvckN47XFRVO+QL/dUSpKA2SLRo4vMg7ELA8pzGpC+W9OEd1Z/ZqoQ==} cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.28.0': - resolution: {integrity: sha512-aI2plavbUDjCQB/sRbeUZWX9qp12GfYkYSJOrdYTL/C5D53bsE2/nBPuoiJKoWp5SN78v2Vr8ZPnB+/VbQ2pFA==} - cpu: [x64] - os: [freebsd] - '@rollup/rollup-freebsd-x64@4.40.2': resolution: {integrity: sha512-C+AyHBzfpsOEYRFjztcYUFsH4S7UsE9cDtHCtma5BK8+ydOZYgMmWg1d/4KBytQspJCld8ZIujFMAdKG1xyr4Q==} cpu: [x64] os: [freebsd] - '@rollup/rollup-linux-arm-gnueabihf@4.28.0': - resolution: {integrity: sha512-WXveUPKtfqtaNvpf0iOb0M6xC64GzUX/OowbqfiCSXTdi/jLlOmH0Ba94/OkiY2yTGTwteo4/dsHRfh5bDCZ+w==} - cpu: [arm] - os: [linux] - '@rollup/rollup-linux-arm-gnueabihf@4.40.2': resolution: {integrity: sha512-de6TFZYIvJwRNjmW3+gaXiZ2DaWL5D5yGmSYzkdzjBDS3W+B9JQ48oZEsmMvemqjtAFzE16DIBLqd6IQQRuG9Q==} cpu: [arm] os: [linux] - '@rollup/rollup-linux-arm-musleabihf@4.28.0': - resolution: {integrity: sha512-yLc3O2NtOQR67lI79zsSc7lk31xjwcaocvdD1twL64PK1yNaIqCeWI9L5B4MFPAVGEVjH5k1oWSGuYX1Wutxpg==} - cpu: [arm] - os: [linux] - '@rollup/rollup-linux-arm-musleabihf@4.40.2': resolution: {integrity: sha512-urjaEZubdIkacKc930hUDOfQPysezKla/O9qV+O89enqsqUmQm8Xj8O/vh0gHg4LYfv7Y7UsE3QjzLQzDYN1qg==} cpu: [arm] os: [linux] - '@rollup/rollup-linux-arm64-gnu@4.28.0': - resolution: {integrity: sha512-+P9G9hjEpHucHRXqesY+3X9hD2wh0iNnJXX/QhS/J5vTdG6VhNYMxJ2rJkQOxRUd17u5mbMLHM7yWGZdAASfcg==} - cpu: [arm64] - os: [linux] - '@rollup/rollup-linux-arm64-gnu@4.40.2': resolution: {integrity: sha512-KlE8IC0HFOC33taNt1zR8qNlBYHj31qGT1UqWqtvR/+NuCVhfufAq9fxO8BMFC22Wu0rxOwGVWxtCMvZVLmhQg==} cpu: [arm64] os: [linux] - '@rollup/rollup-linux-arm64-musl@4.28.0': - resolution: {integrity: sha512-1xsm2rCKSTpKzi5/ypT5wfc+4bOGa/9yI/eaOLW0oMs7qpC542APWhl4A37AENGZ6St6GBMWhCCMM6tXgTIplw==} - cpu: [arm64] - os: [linux] - '@rollup/rollup-linux-arm64-musl@4.40.2': resolution: {integrity: sha512-j8CgxvfM0kbnhu4XgjnCWJQyyBOeBI1Zq91Z850aUddUmPeQvuAy6OiMdPS46gNFgy8gN1xkYyLgwLYZG3rBOg==} cpu: [arm64] @@ -521,21 +487,11 @@ packages: cpu: [loong64] os: [linux] - '@rollup/rollup-linux-powerpc64le-gnu@4.28.0': - resolution: {integrity: sha512-zgWxMq8neVQeXL+ouSf6S7DoNeo6EPgi1eeqHXVKQxqPy1B2NvTbaOUWPn/7CfMKL7xvhV0/+fq/Z/J69g1WAQ==} - cpu: [ppc64] - os: [linux] - '@rollup/rollup-linux-powerpc64le-gnu@4.40.2': resolution: {integrity: sha512-3FCIrnrt03CCsZqSYAOW/k9n625pjpuMzVfeI+ZBUSDT3MVIFDSPfSUgIl9FqUftxcUXInvFah79hE1c9abD+Q==} cpu: [ppc64] os: [linux] - '@rollup/rollup-linux-riscv64-gnu@4.28.0': - resolution: {integrity: sha512-VEdVYacLniRxbRJLNtzwGt5vwS0ycYshofI7cWAfj7Vg5asqj+pt+Q6x4n+AONSZW/kVm+5nklde0qs2EUwU2g==} - cpu: [riscv64] - os: [linux] - '@rollup/rollup-linux-riscv64-gnu@4.40.2': resolution: {integrity: sha512-QNU7BFHEvHMp2ESSY3SozIkBPaPBDTsfVNGx3Xhv+TdvWXFGOSH2NJvhD1zKAT6AyuuErJgbdvaJhYVhVqrWTg==} cpu: [riscv64] @@ -546,61 +502,31 @@ packages: cpu: [riscv64] os: [linux] - '@rollup/rollup-linux-s390x-gnu@4.28.0': - resolution: {integrity: sha512-LQlP5t2hcDJh8HV8RELD9/xlYtEzJkm/aWGsauvdO2ulfl3QYRjqrKW+mGAIWP5kdNCBheqqqYIGElSRCaXfpw==} - cpu: [s390x] - os: [linux] - '@rollup/rollup-linux-s390x-gnu@4.40.2': resolution: {integrity: sha512-B7LKIz+0+p348JoAL4X/YxGx9zOx3sR+o6Hj15Y3aaApNfAshK8+mWZEf759DXfRLeL2vg5LYJBB7DdcleYCoQ==} cpu: [s390x] os: [linux] - '@rollup/rollup-linux-x64-gnu@4.28.0': - resolution: {integrity: sha512-Nl4KIzteVEKE9BdAvYoTkW19pa7LR/RBrT6F1dJCV/3pbjwDcaOq+edkP0LXuJ9kflW/xOK414X78r+K84+msw==} - cpu: [x64] - os: [linux] - '@rollup/rollup-linux-x64-gnu@4.40.2': resolution: {integrity: sha512-lG7Xa+BmBNwpjmVUbmyKxdQJ3Q6whHjMjzQplOs5Z+Gj7mxPtWakGHqzMqNER68G67kmCX9qX57aRsW5V0VOng==} cpu: [x64] os: [linux] - '@rollup/rollup-linux-x64-musl@4.28.0': - resolution: {integrity: sha512-eKpJr4vBDOi4goT75MvW+0dXcNUqisK4jvibY9vDdlgLx+yekxSm55StsHbxUsRxSTt3JEQvlr3cGDkzcSP8bw==} - cpu: [x64] - os: [linux] - '@rollup/rollup-linux-x64-musl@4.40.2': resolution: {integrity: sha512-tD46wKHd+KJvsmije4bUskNuvWKFcTOIM9tZ/RrmIvcXnbi0YK/cKS9FzFtAm7Oxi2EhV5N2OpfFB348vSQRXA==} cpu: [x64] os: [linux] - '@rollup/rollup-win32-arm64-msvc@4.28.0': - resolution: {integrity: sha512-Vi+WR62xWGsE/Oj+mD0FNAPY2MEox3cfyG0zLpotZdehPFXwz6lypkGs5y38Jd/NVSbOD02aVad6q6QYF7i8Bg==} - cpu: [arm64] - os: [win32] - '@rollup/rollup-win32-arm64-msvc@4.40.2': resolution: {integrity: sha512-Bjv/HG8RRWLNkXwQQemdsWw4Mg+IJ29LK+bJPW2SCzPKOUaMmPEppQlu/Fqk1d7+DX3V7JbFdbkh/NMmurT6Pg==} cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.28.0': - resolution: {integrity: sha512-kN/Vpip8emMLn/eOza+4JwqDZBL6MPNpkdaEsgUtW1NYN3DZvZqSQrbKzJcTL6hd8YNmFTn7XGWMwccOcJBL0A==} - cpu: [ia32] - os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.40.2': resolution: {integrity: sha512-dt1llVSGEsGKvzeIO76HToiYPNPYPkmjhMHhP00T9S4rDern8P2ZWvWAQUEJ+R1UdMWJ/42i/QqJ2WV765GZcA==} cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.28.0': - resolution: {integrity: sha512-Bvno2/aZT6usSa7lRDL2+hMjVAGjuqaymF1ApZm31JXzniR/hvr14jpU+/z4X6Gt5BPlzosscyJZGUvguXIqeQ==} - cpu: [x64] - os: [win32] - '@rollup/rollup-win32-x64-msvc@4.40.2': resolution: {integrity: sha512-bwspbWB04XJpeElvsp+DCylKfF4trJDa2Y9Go8O6A7YLX2LIKGcNK/CYImJN6ZP4DcuOHB4Utl3iCbnR62DudA==} cpu: [x64] @@ -1272,6 +1198,7 @@ packages: eslint@9.26.0: resolution: {integrity: sha512-Hx0MOjPh6uK9oq9nVsATZKE/Wlbai7KFjfCuw9UHaguDW3x+HF0O5nIi3ud39TWgrTjTO5nHxmL3R1eANinWHQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true peerDependencies: jiti: '*' @@ -2049,11 +1976,6 @@ packages: peerDependencies: rollup: ^2.0.0 || ^3.0.0 || ^4.0.0 - rollup@4.28.0: - resolution: {integrity: sha512-G9GOrmgWHBma4YfCcX8PjH0qhXSdH8B4HDE2o4/jaxj93S4DPCIDoLcXz99eWMji4hB29UFCEd7B2gwGJDR9cQ==} - engines: {node: '>=18.0.0', npm: '>=8.0.0'} - hasBin: true - rollup@4.40.2: resolution: {integrity: sha512-tfUOg6DTP4rhQ3VjOO6B4wyrJnGOX85requAXvqYTHsOgb2TFJdZ3aWpT8W2kPoypSGP7dZUyzxJ9ee4buM5Fg==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -2780,117 +2702,63 @@ snapshots: optionalDependencies: rollup: 4.40.2 - '@rollup/rollup-android-arm-eabi@4.28.0': - optional: true - '@rollup/rollup-android-arm-eabi@4.40.2': optional: true - '@rollup/rollup-android-arm64@4.28.0': - optional: true - '@rollup/rollup-android-arm64@4.40.2': optional: true - '@rollup/rollup-darwin-arm64@4.28.0': - optional: true - '@rollup/rollup-darwin-arm64@4.40.2': optional: true - '@rollup/rollup-darwin-x64@4.28.0': - optional: true - '@rollup/rollup-darwin-x64@4.40.2': optional: true - '@rollup/rollup-freebsd-arm64@4.28.0': - optional: true - '@rollup/rollup-freebsd-arm64@4.40.2': optional: true - '@rollup/rollup-freebsd-x64@4.28.0': - optional: true - '@rollup/rollup-freebsd-x64@4.40.2': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.28.0': - optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.40.2': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.28.0': - optional: true - '@rollup/rollup-linux-arm-musleabihf@4.40.2': optional: true - '@rollup/rollup-linux-arm64-gnu@4.28.0': - optional: true - '@rollup/rollup-linux-arm64-gnu@4.40.2': optional: true - '@rollup/rollup-linux-arm64-musl@4.28.0': - optional: true - '@rollup/rollup-linux-arm64-musl@4.40.2': optional: true '@rollup/rollup-linux-loongarch64-gnu@4.40.2': optional: true - '@rollup/rollup-linux-powerpc64le-gnu@4.28.0': - optional: true - '@rollup/rollup-linux-powerpc64le-gnu@4.40.2': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.28.0': - optional: true - '@rollup/rollup-linux-riscv64-gnu@4.40.2': optional: true '@rollup/rollup-linux-riscv64-musl@4.40.2': optional: true - '@rollup/rollup-linux-s390x-gnu@4.28.0': - optional: true - '@rollup/rollup-linux-s390x-gnu@4.40.2': optional: true - '@rollup/rollup-linux-x64-gnu@4.28.0': - optional: true - '@rollup/rollup-linux-x64-gnu@4.40.2': optional: true - '@rollup/rollup-linux-x64-musl@4.28.0': - optional: true - '@rollup/rollup-linux-x64-musl@4.40.2': optional: true - '@rollup/rollup-win32-arm64-msvc@4.28.0': - optional: true - '@rollup/rollup-win32-arm64-msvc@4.40.2': optional: true - '@rollup/rollup-win32-ia32-msvc@4.28.0': - optional: true - '@rollup/rollup-win32-ia32-msvc@4.40.2': optional: true - '@rollup/rollup-win32-x64-msvc@4.28.0': - optional: true - '@rollup/rollup-win32-x64-msvc@4.40.2': optional: true @@ -3673,7 +3541,7 @@ snapshots: estree-walker@3.0.3: dependencies: - '@types/estree': 1.0.6 + '@types/estree': 1.0.7 esutils@2.0.3: {} @@ -3943,7 +3811,7 @@ snapshots: is-reference@1.2.1: dependencies: - '@types/estree': 1.0.6 + '@types/estree': 1.0.7 is-stream@2.0.1: {} @@ -4376,30 +4244,6 @@ snapshots: magic-string: 0.30.17 rollup: 4.40.2 - rollup@4.28.0: - dependencies: - '@types/estree': 1.0.6 - optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.28.0 - '@rollup/rollup-android-arm64': 4.28.0 - '@rollup/rollup-darwin-arm64': 4.28.0 - '@rollup/rollup-darwin-x64': 4.28.0 - '@rollup/rollup-freebsd-arm64': 4.28.0 - '@rollup/rollup-freebsd-x64': 4.28.0 - '@rollup/rollup-linux-arm-gnueabihf': 4.28.0 - '@rollup/rollup-linux-arm-musleabihf': 4.28.0 - '@rollup/rollup-linux-arm64-gnu': 4.28.0 - '@rollup/rollup-linux-arm64-musl': 4.28.0 - '@rollup/rollup-linux-powerpc64le-gnu': 4.28.0 - '@rollup/rollup-linux-riscv64-gnu': 4.28.0 - '@rollup/rollup-linux-s390x-gnu': 4.28.0 - '@rollup/rollup-linux-x64-gnu': 4.28.0 - '@rollup/rollup-linux-x64-musl': 4.28.0 - '@rollup/rollup-win32-arm64-msvc': 4.28.0 - '@rollup/rollup-win32-ia32-msvc': 4.28.0 - '@rollup/rollup-win32-x64-msvc': 4.28.0 - fsevents: 2.3.3 - rollup@4.40.2: dependencies: '@types/estree': 1.0.7 @@ -4791,7 +4635,7 @@ snapshots: dependencies: esbuild: 0.21.5 postcss: 8.4.49 - rollup: 4.28.0 + rollup: 4.40.2 optionalDependencies: '@types/node': 22.15.17 fsevents: 2.3.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..dee51e9 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - "packages/*" diff --git a/scripts/check-build-artifacts.mjs b/scripts/check-build-artifacts.mjs index eaa2808..f459e61 100644 --- a/scripts/check-build-artifacts.mjs +++ b/scripts/check-build-artifacts.mjs @@ -1,101 +1,91 @@ -// Verifies that every package `exports` target exists and is included by -// `npm pack`, along with every shared chunk those entries import. Entry bundles -// import chunks that no `exports` entry names, so checking only the untarred -// tree would miss a chunk left out of `files` and publish broken entry points. -// -// This lives in a file rather than inline in the workflow because the pattern -// below needs both quote characters, which cannot survive a single-quoted -// `node -e` argument in a YAML block scalar. -import { existsSync, readFileSync } from "node:fs"; +// Verifies every public export, declaration, and relative runtime dependency +// exists in the package directory and is included by `npm pack`. import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; import { dirname, join, relative, resolve, sep } from "node:path"; -// Only specifiers that name an emitted file. Matching every `from "./x"` in the -// raw text would also match prose inside a comment the bundler preserved -- a -// sentence such as "the factories from './qwp'" is not an import, and treating -// it as one reports a build artifact that was never meant to exist. +const PACKAGES = ["packages/nodejs-client", "packages/browser-client"]; const SPECIFIER = /(?:\bfrom|\brequire\(|\bimport\()\s*["'](\.[^"']*\.(?:d\.)?[mc]?[jt]s)["']/g; -const { exports: map, typesVersions } = JSON.parse( - readFileSync("package.json", "utf8"), -); +function exportTargets(value) { + if (typeof value === "string") return [value]; + if (!value || typeof value !== "object") return []; + return Object.values(value).flatMap(exportTargets); +} -const missing = []; -const seen = new Set(); +function checkPackage(packageDirectory) { + const manifest = JSON.parse( + readFileSync(join(packageDirectory, "package.json"), "utf8"), + ); + const missing = []; + const seen = new Set(); + + const walk = (target, from) => { + const file = resolve(packageDirectory, target); + if (!existsSync(file)) { + missing.push(`${from} -> ${relative(packageDirectory, file)}`); + return; + } + if (seen.has(file)) return; + seen.add(file); + const source = readFileSync(file, "utf8"); + for (const [, specifier] of source.matchAll(SPECIFIER)) { + walk(join(dirname(relative(packageDirectory, file)), specifier), target); + } + }; -const walk = (file, from) => { - if (!existsSync(file)) { - missing.push(`${from} -> ${file}`); - return; + for (const [subpath, conditions] of Object.entries(manifest.exports)) { + for (const target of exportTargets(conditions)) walk(target, subpath); } - const key = resolve(file); - if (seen.has(key)) return; - seen.add(key); - const source = readFileSync(file, "utf8"); - for (const [, specifier] of source.matchAll(SPECIFIER)) { - walk(join(dirname(file), specifier), file); + for (const [subpath, targets] of Object.entries( + manifest.typesVersions?.["*"] ?? {}, + )) { + for (const target of targets) walk(target, `typesVersions ${subpath}`); } -}; -for (const [subpath, conditions] of Object.entries(map)) { - for (const target of Object.values(conditions)) { - for (const file of Object.values(target)) { - walk(file, subpath); - } + let pack; + try { + [pack] = JSON.parse( + execFileSync( + process.platform === "win32" ? "npm.cmd" : "npm", + ["pack", "--dry-run", "--json"], + { + cwd: packageDirectory, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }, + ), + ); + } catch (error) { + const stderr = error?.stderr?.toString().trim(); + throw new Error( + `${manifest.name}: npm pack --dry-run failed${stderr ? `:\n${stderr}` : ""}`, + ); } -} -// typesVersions is what TypeScript's legacy node10 resolution reads instead of -// `exports`, so a target missing here breaks those consumers with a TS2307 that -// no runtime test can see. -for (const [subpath, targets] of Object.entries(typesVersions?.["*"] ?? {})) { - for (const target of targets) { - walk(target, `typesVersions ${subpath}`); + if (!Array.isArray(pack?.files)) { + throw new Error(`${manifest.name}: npm pack returned no file manifest`); + } + const packedFiles = new Set(pack.files.map(({ path }) => path)); + for (const file of seen) { + const packagePath = relative(packageDirectory, file).split(sep).join("/"); + if (!packedFiles.has(packagePath)) { + missing.push(`npm pack omits ${packagePath}`); + } } -} -if (missing.length > 0) { - console.error(`missing build artifacts:\n ${missing.join("\n ")}`); - process.exit(1); + if (missing.length > 0) { + throw new Error(`${manifest.name}:\n ${missing.join("\n ")}`); + } + console.log( + `${manifest.name}: ${Object.keys(manifest.exports).length} export subpaths, ${seen.size} linked files present and packed`, + ); } -let pack; try { - [pack] = JSON.parse( - execFileSync( - process.platform === "win32" ? "npm.cmd" : "npm", - ["pack", "--dry-run", "--json"], - { - encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"], - }, - ), - ); + for (const packageDirectory of PACKAGES) checkPackage(packageDirectory); } catch (error) { - const stderr = error?.stderr?.toString().trim(); - console.error(`npm pack --dry-run failed${stderr ? `:\n${stderr}` : ""}`); + console.error(`missing build artifacts:\n${error.message}`); process.exit(1); } - -if (!Array.isArray(pack?.files)) { - console.error("npm pack --dry-run returned no package file manifest"); - process.exit(1); -} - -const packedFiles = new Set(pack.files.map(({ path }) => path)); -for (const file of seen) { - const packagePath = relative(process.cwd(), file).split(sep).join("/"); - if (!packedFiles.has(packagePath)) { - missing.push(`npm pack omits ${packagePath}`); - } -} - -if (missing.length > 0) { - console.error(`missing build artifacts:\n ${missing.join("\n ")}`); - process.exit(1); -} - -console.log( - `all ${Object.keys(map).length} export subpaths present, ${seen.size} files walked and packed`, -); diff --git a/scripts/clean-package-dist.mjs b/scripts/clean-package-dist.mjs new file mode 100644 index 0000000..88bbc9e --- /dev/null +++ b/scripts/clean-package-dist.mjs @@ -0,0 +1,15 @@ +import { rm } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const packageName = process.argv[2]; +const publicPackages = new Set(["nodejs-client", "browser-client"]); +if (!packageName || !publicPackages.has(packageName)) { + throw new Error(`unknown public package: ${packageName ?? ""}`); +} + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +await rm(join(root, "packages", packageName, "dist"), { + recursive: true, + force: true, +}); diff --git a/scripts/generateDocs.sh b/scripts/generateDocs.sh index 186b4bf..e8c2ef6 100755 --- a/scripts/generateDocs.sh +++ b/scripts/generateDocs.sh @@ -1,2 +1,4 @@ #!/bin/bash -jsdoc index.js src/sender.js src/row.js src/timestamp.js README.md -d docs +set -euo pipefail + +pnpm run docs diff --git a/test/dist-types/class-identity.ts b/test/dist-types/class-identity.ts index 13915ec..859026e 100644 --- a/test/dist-types/class-identity.ts +++ b/test/dist-types/class-identity.ts @@ -1,25 +1,23 @@ // Pins that the classes a factory returns can be named in a type position by a // consumer of the published package. // -// src/_qwp/** is emitted as shared chunks rather than inlined per entry, so -// each class is declared exactly once across the four bundles. Were an entry to -// start inlining them again, its declaration would be a second, nominally -// distinct one -- every class here carries private members -- and these -// annotations would stop compiling. +// The Node package has one emitted declaration surface. Every class here +// carries private members, so these annotations ensure its factories and +// public types continue to refer to that same declaration. import { connectQwpNodeClient, createQwpNodeSender, -} from "@questdb/nodejs-client/qwp/node"; +} from "@questdb/nodejs-client"; import type { QwpClient, QwpSender, QwpTableWriter, -} from "@questdb/nodejs-client/qwp"; +} from "@questdb/nodejs-client"; import { designatedTimestamp, QwpUpgradeError, symbol, -} from "@questdb/nodejs-client/qwp"; +} from "@questdb/nodejs-client"; declare const senderOptions: Parameters[0]; declare const clientOptions: Parameters[0]; diff --git a/test/dist-types/writer-rows.ts b/test/dist-types/writer-rows.ts index 18d79a3..9598862 100644 --- a/test/dist-types/writer-rows.ts +++ b/test/dist-types/writer-rows.ts @@ -1,7 +1,6 @@ -// Typechecked against the BUILT bundles, not src/. In src/ all four entry -// points share one module instance, so a type identity that only holds within -// a bundle still looks correct there; only a consumer resolving through -// package.json `exports` sees the emitted .d.ts files separately. +// Typechecked against the BUILT packages, not client-core/src. The source tree +// shares one module instance, so only a consumer resolving each public +// package through `exports` sees the emitted declarations as they are shipped. // // Every check below is a `@ts-expect-error`, so this file fails loudly in both // directions: if a check stops firing, tsc reports the directive as unused @@ -13,9 +12,16 @@ import { long, symbol, type QwpWriterRow, -} from "@questdb/nodejs-client/qwp"; -import { createQwpNodeSender } from "@questdb/nodejs-client/qwp/node"; -import { createQwpBrowserSender } from "@questdb/nodejs-client/qwp/browser"; +} from "@questdb/nodejs-client"; +import { createQwpNodeSender } from "@questdb/nodejs-client"; +import { + createQwpBrowserSender, + designatedTimestamp as browserDesignatedTimestamp, + double as browserDouble, + long as browserLong, + symbol as browserSymbol, + type QwpWriterRow as BrowserQwpWriterRow, +} from "@questdb/browser-client"; const schema = { ticker: symbol(), @@ -24,15 +30,22 @@ const schema = { timestamp: designatedTimestamp("ns"), } as const; +const browserSchema = { + ticker: browserSymbol(), + price: browserDouble(), + quantity: browserLong(), + timestamp: browserDesignatedTimestamp("ns"), +} as const; + declare const rootSender: Sender; declare const nodeSender: ReturnType; declare const browserSender: ReturnType; const fromRoot = rootSender.writer("trades", schema); const fromNode = nodeSender.writer("trades", schema); -const fromBrowser = browserSender.writer("trades", schema); +const fromBrowser = browserSender.writer("trades", browserSchema); -for (const trades of [fromRoot, fromNode, fromBrowser]) { +for (const trades of [fromRoot, fromNode]) { // A correct row must still compile. void trades.row({ ticker: "ETH-USD", @@ -55,6 +68,33 @@ for (const trades of [fromRoot, fromNode, fromBrowser]) { void trades.row({ ticker: "a", price: 1, quantity: 1n, timestamp: 1n, x: 1 }); } +// The independently emitted browser declarations preserve the same schema +// inference at the browser package root. +void fromBrowser.row({ + ticker: "ETH-USD", + price: 2615.54, + quantity: 42n, + timestamp: 1_723_000_000_000_000_000n, +}); +// @ts-expect-error symbol() accepts only strings. +void fromBrowser.row({ ticker: 1, price: 1, quantity: 1n, timestamp: 1n }); +// @ts-expect-error double() does not accept bigint. +void fromBrowser.row({ ticker: "a", price: 1n, quantity: 1n, timestamp: 1n }); +// @ts-expect-error long() requires bigint, not number. +void fromBrowser.row({ ticker: "a", price: 1, quantity: 1, timestamp: 1n }); +// @ts-expect-error a nanosecond designated timestamp requires bigint. +void fromBrowser.row({ ticker: "a", price: 1, quantity: 1n, timestamp: 1 }); +// @ts-expect-error the designated timestamp is required. +void fromBrowser.row({ ticker: "a", price: 1, quantity: 1n }); +void fromBrowser.row({ + ticker: "a", + price: 1, + quantity: 1n, + timestamp: 1n, + // @ts-expect-error unknown columns are rejected. + x: 1, +}); + // The row type must also be nameable and enforced on its own. const row: QwpWriterRow = { ticker: "ETH-USD", @@ -63,3 +103,11 @@ const row: QwpWriterRow = { timestamp: 1n, }; void row; + +const browserRow: BrowserQwpWriterRow = { + ticker: "ETH-USD", + price: 1, + quantity: 1n, + timestamp: 1n, +}; +void browserRow; diff --git a/test/logging.test.ts b/test/logging.test.ts index fecd353..e1ca6f0 100644 --- a/test/logging.test.ts +++ b/test/logging.test.ts @@ -9,7 +9,7 @@ import { vi, } from "vitest"; -import { Logger } from "../src"; +import { Logger } from "../packages/nodejs-client/src"; describe("Default logging suite", function () { const error = vi.spyOn(console, "error").mockImplementation(() => {}); @@ -19,7 +19,7 @@ describe("Default logging suite", function () { let log: Logger; beforeAll(async () => { - log = (await import("../src/logging")).log; + log = (await import("../packages/client-core/src/logging")).log; }); afterAll(() => { diff --git a/test/options.test.ts b/test/options.test.ts index 8637a55..b4905d3 100644 --- a/test/options.test.ts +++ b/test/options.test.ts @@ -2,10 +2,10 @@ import { describe, it, expect, beforeAll, afterAll } from "vitest"; import { Agent } from "undici"; -import { Sender } from "../src/sender"; -import { SenderOptions } from "../src"; -import { qwpConfig } from "../src/options"; -import { log } from "../src/logging"; +import { Sender } from "../packages/nodejs-client/src/sender"; +import { SenderOptions } from "../packages/nodejs-client/src"; +import { qwpConfig } from "../packages/nodejs-client/src/options"; +import { log } from "../packages/client-core/src/logging"; import { MockHttp } from "./util/mockhttp"; import { readFileSync } from "fs"; diff --git a/test/package-boundaries.e2e.ts b/test/package-boundaries.e2e.ts new file mode 100644 index 0000000..a73196e --- /dev/null +++ b/test/package-boundaries.e2e.ts @@ -0,0 +1,286 @@ +import { execFileSync } from "node:child_process"; +import { builtinModules, createRequire } from "node:module"; +import { existsSync } from "node:fs"; +import { + mkdir, + mkdtemp, + readFile, + readdir, + rm, + symlink, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import ts from "typescript"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const NODE_PACKAGE = path.join(ROOT, "packages/nodejs-client"); +const BROWSER_PACKAGE = path.join(ROOT, "packages/browser-client"); +const require_ = createRequire(import.meta.url); +interface PackageManifest { + name: string; + dependencies?: Record; + devDependencies?: Record; + engines?: Record; + files: string[]; + repository: { directory: string }; + exports: Record>; +} + +interface PackedFile { + path: string; +} + +let nodeManifest: PackageManifest; +let browserManifest: PackageManifest; +let nodePackedFiles: Set; +let browserPackedFiles: Set; +let consumerDirectory: string; + +async function manifest(directory: string): Promise { + return JSON.parse( + await readFile(path.join(directory, "package.json"), "utf8"), + ); +} + +function packedFiles(directory: string): Set { + const [pack] = JSON.parse( + execFileSync( + process.platform === "win32" ? "npm.cmd" : "npm", + ["pack", "--dry-run", "--json"], + { cwd: directory, encoding: "utf8" }, + ), + ) as [{ files: PackedFile[] }]; + return new Set(pack.files.map((file) => file.path)); +} + +function exportTarget( + directory: string, + packageManifest: PackageManifest, + subpath: string, + format: "import" | "require", +): string { + const target = packageManifest.exports[subpath]?.[format]?.default; + if (!target) throw new Error(`${packageManifest.name} ${subpath} ${format}`); + return path.join(directory, target); +} + +async function filesBelow(directory: string): Promise { + const result: string[] = []; + for (const entry of await readdir(directory, { withFileTypes: true })) { + const file = path.join(directory, entry.name); + if (entry.isDirectory()) result.push(...(await filesBelow(file))); + else result.push(file); + } + return result; +} + +function moduleSpecifiers(source: string, file: string): string[] { + const sourceFile = ts.createSourceFile( + file, + source, + ts.ScriptTarget.Latest, + false, + ); + const result: string[] = []; + const visit = (node: ts.Node): void => { + if ( + (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && + node.moduleSpecifier && + ts.isStringLiteral(node.moduleSpecifier) + ) { + result.push(node.moduleSpecifier.text); + } else if ( + ts.isCallExpression(node) && + node.arguments.length === 1 && + ts.isStringLiteral(node.arguments[0]) && + (node.expression.kind === ts.SyntaxKind.ImportKeyword || + (ts.isIdentifier(node.expression) && + node.expression.text === "require")) + ) { + result.push(node.arguments[0].text); + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + return result; +} + +beforeAll(async () => { + [nodeManifest, browserManifest] = await Promise.all([ + manifest(NODE_PACKAGE), + manifest(BROWSER_PACKAGE), + ]); + nodePackedFiles = packedFiles(NODE_PACKAGE); + browserPackedFiles = packedFiles(BROWSER_PACKAGE); + + consumerDirectory = await mkdtemp( + path.join(tmpdir(), "questdb-package-consumer-"), + ); + const scope = path.join(consumerDirectory, "node_modules/@questdb"); + await mkdir(scope, { recursive: true }); + await Promise.all([ + symlink(NODE_PACKAGE, path.join(scope, "nodejs-client"), "junction"), + symlink(BROWSER_PACKAGE, path.join(scope, "browser-client"), "junction"), + ]); +}); + +afterAll(async () => { + if (consumerDirectory) { + await rm(consumerDirectory, { recursive: true, force: true }); + } +}); + +describe("public npm package boundaries", () => { + it("packs two self-contained public packages without workspace sources", () => { + for (const files of [nodePackedFiles, browserPackedFiles]) { + expect(files).toContain("package.json"); + expect(files).toContain("README.md"); + expect(files).toContain("THIRD_PARTY_NOTICES.md"); + expect([...files]).toContainEqual(expect.stringMatching(/^dist\//)); + expect([...files]).not.toContainEqual(expect.stringMatching(/^src\//)); + expect([...files]).not.toContainEqual( + expect.stringContaining("client-core"), + ); + expect([...files]).not.toContainEqual( + expect.stringMatching(/node_modules/), + ); + } + }); + + it("keeps runtime metadata on the correct package", () => { + expect(nodeManifest.name).toBe("@questdb/nodejs-client"); + expect(nodeManifest.files).toEqual([ + "dist", + "README.md", + "THIRD_PARTY_NOTICES.md", + ]); + expect(nodeManifest.repository.directory).toBe("packages/nodejs-client"); + expect(Object.keys(nodeManifest.exports)).toEqual(["."]); + expect(nodeManifest.engines?.node).toBe(">=20"); + expect(Object.keys(nodeManifest.dependencies ?? {}).sort()).toEqual([ + "undici", + "ws", + ]); + + expect(browserManifest.name).toBe("@questdb/browser-client"); + expect(browserManifest.files).toEqual([ + "dist", + "README.md", + "THIRD_PARTY_NOTICES.md", + ]); + expect(browserManifest.repository.directory).toBe( + "packages/browser-client", + ); + expect(Object.keys(browserManifest.exports)).toEqual(["."]); + expect(browserManifest.engines?.node).toBeUndefined(); + expect(browserManifest.dependencies).toEqual({}); + expect(browserManifest.devDependencies).toBeUndefined(); + }); + + it.each(["import", "require"] as const)( + "loads every public package entry with %s", + async (format) => { + const load = (target: string) => + format === "require" + ? Promise.resolve(require_(target)) + : import(pathToFileURL(target).href); + + const root = await load( + exportTarget(NODE_PACKAGE, nodeManifest, ".", format), + ); + const browser = await load( + exportTarget(BROWSER_PACKAGE, browserManifest, ".", format), + ); + + expect(root.Sender).toBeTypeOf("function"); + expect(root.QwpSender).toBeTypeOf("function"); + expect(root.connectQwpNodeClient).toBeTypeOf("function"); + expect(browser.connectQwpBrowserClient).toBeTypeOf("function"); + expect(browser.QwpSender).toBeTypeOf("function"); + }, + ); + + it.each(["import", "require"] as const)( + "resolves package-name imports with the %s condition", + (format) => { + const expressions = [ + '"@questdb/nodejs-client"', + '"@questdb/browser-client"', + ]; + const script = + format === "require" + ? `const [node, browser] = [${expressions.map((specifier) => `require(${specifier})`).join(",")}]; console.log([typeof node.Sender, typeof node.connectQwpNodeClient, typeof browser.connectQwpBrowserClient].join(","));` + : `const [node, browser] = await Promise.all([${expressions.map((specifier) => `import(${specifier})`).join(",")}]); console.log([typeof node.Sender, typeof node.connectQwpNodeClient, typeof browser.connectQwpBrowserClient].join(","));`; + const output = execFileSync( + process.execPath, + format === "import" + ? ["--input-type=module", "--eval", script] + : ["--eval", script], + { cwd: consumerDirectory, encoding: "utf8" }, + ); + expect(output.trim()).toBe("function,function,function"); + }, + ); + + it("bundles the browser package root for a browser consumer", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "questdb-browser-")); + const bundle = path.join(directory, "client.mjs"); + try { + execFileSync( + process.platform === "win32" ? "pnpm.cmd" : "pnpm", + [ + "exec", + "rollup", + exportTarget(BROWSER_PACKAGE, browserManifest, ".", "import"), + "--format", + "es", + "--file", + bundle, + "--silent", + ], + { cwd: ROOT, encoding: "utf8" }, + ); + const source = await readFile(bundle, "utf8"); + expect(source).toContain("connectQwpBrowserClient"); + expect(source).not.toMatch(/^\s*(?:import|export).*?from\s/m); + expect(source).not.toMatch(/\brequire\s*\(/); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + it("ships a closed browser module graph with no Node modules or typings", async () => { + const builtins = new Set( + builtinModules.flatMap((name) => [name, `node:${name}`]), + ); + const forbiddenPackages = new Set(["ws", "undici"]); + const files = (await filesBelow(path.join(BROWSER_PACKAGE, "dist"))).filter( + (file) => /\.(?:[mc]?js|d\.(?:ts|mts))$/.test(file), + ); + + for (const file of files) { + const source = await readFile(file, "utf8"); + expect(source, file).not.toMatch(/ specifier === name || specifier.startsWith(`${name}/`), + ), + `${file}: ${specifier}`, + ).toBe(false); + expect(specifier.startsWith("."), `${file}: ${specifier}`).toBe(true); + expect( + existsSync(path.resolve(path.dirname(file), specifier)), + `${file}: unresolved ${specifier}`, + ).toBe(true); + } + } + }); +}); diff --git a/test/qwp/binds.test.ts b/test/qwp/binds.test.ts index 44774c3..fe05571 100644 --- a/test/qwp/binds.test.ts +++ b/test/qwp/binds.test.ts @@ -8,7 +8,7 @@ import { QwpBindValues, QwpByteReader, readQwpVarint, -} from "../../src/qwp"; +} from "../../packages/client-core/src/qwp"; function expectNonNullHeader(reader: QwpByteReader, type: number): void { expect(reader.readUint8()).toBe(type); diff --git a/test/qwp/browser.e2e.ts b/test/qwp/browser.e2e.ts index 95b91a4..1d4e3dc 100644 --- a/test/qwp/browser.e2e.ts +++ b/test/qwp/browser.e2e.ts @@ -17,7 +17,7 @@ import { QwpByteWriter, readQwpVarint, writeQwpVarint, -} from "../../src/qwp/node"; +} from "../../packages/nodejs-client/src"; function listen(server: Server): Promise { return new Promise((resolve, reject) => { @@ -52,10 +52,13 @@ function closeWebSocketServer(server: WebSocketServer): Promise { }); } -// Rooted at dist/, not dist/es/qwp: the browser bundle imports shared chunks -// from dist/_qwp, so serving only its own directory 403s every entry import. +// Rooted at the browser package dist/: the entry imports package-local shared +// chunks, so serving only dist/es would 404 every shared-module request. function createModuleServer(): Server { - const moduleRoot = path.resolve(process.cwd(), "dist"); + const moduleRoot = path.resolve( + process.cwd(), + "packages/browser-client/dist", + ); return createServer(async (request, response) => { try { const requestUrl = new URL(request.url ?? "/", "http://127.0.0.1"); @@ -146,7 +149,7 @@ describe("QWP in a real browser", () => { assetServer = createModuleServer(); await listen(assetServer); const address = assetServer.address() as AddressInfo; - assetUrl = `http://127.0.0.1:${address.port}/es/qwp/browser.mjs`; + assetUrl = `http://127.0.0.1:${address.port}/es/index.mjs`; browser = await chromium.launch({ channel: process.env.QWP_BROWSER_CHANNEL, diff --git a/test/qwp/client.test.ts b/test/qwp/client.test.ts index f3b2b7c..fc6e313 100644 --- a/test/qwp/client.test.ts +++ b/test/qwp/client.test.ts @@ -22,8 +22,8 @@ import { designatedTimestamp, long, symbol as qwpSymbol, -} from "../../src/qwp"; -import { QwpAsyncQueue } from "../../src/_qwp/_internal/async-queue"; +} from "../../packages/client-core/src/qwp"; +import { QwpAsyncQueue } from "../../packages/client-core/src/_qwp/_internal/async-queue"; function writeString(writer: QwpByteWriter, value: string): void { const encoded = new TextEncoder().encode(value); diff --git a/test/qwp/config-docs.test.ts b/test/qwp/config-docs.test.ts index dc30aa6..125f6a4 100644 --- a/test/qwp/config-docs.test.ts +++ b/test/qwp/config-docs.test.ts @@ -2,8 +2,11 @@ import { readFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, it, vi } from "vitest"; -import { QwpSender, type QwpSenderSession } from "../../src/qwp"; -import { QWP_SUPPORTED_CONFIG_KEYS } from "../../src/qwp-node/client-config"; +import { + QwpSender, + type QwpSenderSession, +} from "../../packages/client-core/src/qwp"; +import { QWP_SUPPORTED_CONFIG_KEYS } from "../../packages/nodejs-client/src/qwp-node/client-config"; const ROOT = path.resolve( path.dirname(fileURLToPath(import.meta.url)), diff --git a/test/qwp/core.test.ts b/test/qwp/core.test.ts index 1e4676d..c570a8f 100644 --- a/test/qwp/core.test.ts +++ b/test/qwp/core.test.ts @@ -39,8 +39,11 @@ import { qwpVarintSize, readQwpVarint, writeQwpVarint, -} from "../../src/qwp"; -import { encodeUtf8, utf8Length } from "../../src/_qwp/_core/bytes"; +} from "../../packages/client-core/src/qwp"; +import { + encodeUtf8, + utf8Length, +} from "../../packages/client-core/src/_qwp/_core/bytes"; function dataView(bytes: Uint8Array): DataView { return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); diff --git a/test/qwp/dist.e2e.ts b/test/qwp/dist.e2e.ts index 6b4c0a9..5b446e2 100644 --- a/test/qwp/dist.e2e.ts +++ b/test/qwp/dist.e2e.ts @@ -7,15 +7,12 @@ import { fileURLToPath, pathToFileURL } from "node:url"; import { beforeAll, describe, expect, it } from "vitest"; /** - * Consumer-facing checks that run against the built package instead of `src/`. + * Consumer-facing checks that run against the built packages instead of the + * private core source. * - * Every other suite imports from `src/`, where all four entry points resolve to - * one module instance. The published package emits one bundle per entry point, - * so module-private state is duplicated per bundle and cross-entry-point usage - * can break in ways `src/`-level tests structurally cannot observe. The - * compiled writer regression these tests cover is exactly that: the column - * factories live only in `./qwp`, while `writer()` lives on senders built from - * `./qwp/node`, `./qwp/browser`, and the package root. + * Each public package now emits one root entry. These checks ensure the legacy + * Sender and the complete runtime-specific QWP surface coexist in that entry, + * with one class/type identity per module format. * * Requires a build. Run with `pnpm test:dist`. */ @@ -24,50 +21,70 @@ const ROOT = path.resolve( path.dirname(fileURLToPath(import.meta.url)), "../..", ); +const NODE_PACKAGE = path.join(ROOT, "packages/nodejs-client"); +const BROWSER_PACKAGE = path.join(ROOT, "packages/browser-client"); const require_ = createRequire(import.meta.url); -type Subpath = "." | "./qwp" | "./qwp/browser" | "./qwp/node"; type Format = "import" | "require"; -/** Resolves a subpath through package.json `exports`, as a consumer would. */ -let resolveExport: (subpath: Subpath, format: Format) => string; +/** Resolves the Node package root through `exports`, as a consumer would. */ +let resolveExport: (format: Format) => string; beforeAll(async () => { const manifest = JSON.parse( - await readFile(path.join(ROOT, "package.json"), "utf8"), + await readFile(path.join(NODE_PACKAGE, "package.json"), "utf8"), + ) as { exports: Record> }; + const browserManifest = JSON.parse( + await readFile(path.join(BROWSER_PACKAGE, "package.json"), "utf8"), ) as { exports: Record> }; - resolveExport = (subpath, format) => { - const target = manifest.exports[subpath]?.[format]?.default; + resolveExport = (format) => { + const target = manifest.exports["."]?.[format]?.default; if (!target) { + throw new Error(`package.json exports has no '${format}' root target`); + } + return path.join(NODE_PACKAGE, target); + }; + + for (const format of ["import", "require"] as const) { + const target = resolveExport(format); + if (!existsSync(target)) { throw new Error( - `package.json exports has no '${format}' target for '${subpath}'`, + `${target} is missing - run 'pnpm build' before this suite`, ); } - return path.join(ROOT, target); - }; + } - for (const subpath of [ - ".", - "./qwp", - "./qwp/browser", - "./qwp/node", - ] as const) { - for (const format of ["import", "require"] as const) { - const target = resolveExport(subpath, format); - if (!existsSync(target)) { - throw new Error( - `${target} is missing - run 'pnpm build' before this suite`, - ); - } + resolveBrowserExport = (format) => { + const target = browserManifest.exports["."]?.[format]?.default; + if (!target) { + throw new Error( + `browser package exports has no '${format}' target for '.'`, + ); + } + return path.join(BROWSER_PACKAGE, target); + }; + for (const format of ["import", "require"] as const) { + const target = resolveBrowserExport(format); + if (!existsSync(target)) { + throw new Error( + `${target} is missing - run 'pnpm build' before this suite`, + ); } } }); -const load = (subpath: Subpath, format: Format) => +let resolveBrowserExport: (format: Format) => string; + +const load = (format: Format) => format === "require" - ? Promise.resolve(require_(resolveExport(subpath, format))) - : import(pathToFileURL(resolveExport(subpath, format)).href); + ? Promise.resolve(require_(resolveExport(format))) + : import(pathToFileURL(resolveExport(format)).href); + +const loadBrowser = (format: Format) => + format === "require" + ? Promise.resolve(require_(resolveBrowserExport(format))) + : import(pathToFileURL(resolveBrowserExport(format)).href); const runNode = (script: string) => new Promise<{ code: number | null; stdout: string; stderr: string }>( @@ -105,37 +122,37 @@ const URL_ = "ws://127.0.0.1:9/write/v4"; describe.each(["import", "require"] as const)( "built package (%s)", (format) => { - // The factories are exported only from './qwp', so every real use of a - // compiled writer crosses at least one entry-point boundary. - it.each(["./qwp/browser", "./qwp/node"] as const)( - "compiles a writer on a %s sender from './qwp' column factories", - async (senderSubpath) => { - const qwp: any = await load("./qwp", format); - const entry: any = await load(senderSubpath, format); - const create = - senderSubpath === "./qwp/node" - ? entry.createQwpNodeSender - : entry.createQwpBrowserSender; - - const sender = create({ url: URL_, autoFlush: false }); - const trades = sender.writer("trades", schemaFrom(qwp)); - await stageTwoRows(trades); - - expect(sender.metrics.pendingRows).toBe(2); - }, - ); + it("exposes Sender and the complete Node QWP API at the root", async () => { + const qwp: any = await load(format); + const node: any = await load(format); + const sender = node.createQwpNodeSender({ url: URL_, autoFlush: false }); + const trades = sender.writer("trades", schemaFrom(qwp)); + await stageTwoRows(trades); + + expect(sender.metrics.pendingRows).toBe(2); + }); - it("keeps package-root writer and error identity across QWP entries", async () => { - const root: any = await load(".", format); - const qwp: any = await load("./qwp", format); + it("exposes a self-contained browser writer at the package root", async () => { + const browser: any = await loadBrowser(format); + const sender = browser.createQwpBrowserSender({ + url: URL_, + autoFlush: false, + }); + const trades = sender.writer("trades", schemaFrom(browser)); + await stageTwoRows(trades); + + expect(sender.metrics.pendingRows).toBe(2); + }); + + it("keeps one writer and error identity within the package root", async () => { + const root: any = await load(format); + const qwp: any = await load(format); const sender = await root.Sender.fromConfig( "ws::addr=127.0.0.1:9;auto_flush=off;", { log: () => {} }, ); - // Loading the public Node entry after the root proves its static import - // selected this same-format module instance. - const node: any = await load("./qwp/node", format); + const node: any = await load(format); const trades = sender.writer("trades", schemaFrom(qwp)); await stageTwoRows(trades); @@ -157,19 +174,19 @@ describe.each(["import", "require"] as const)( expect(rowError).toBeInstanceOf(node.QwpWriterRowError); const otherFormat = format === "import" ? "require" : "import"; - const otherNode: any = await load("./qwp/node", otherFormat); + const otherNode: any = await load(otherFormat); expect(trades).not.toBeInstanceOf(otherNode.QwpTableWriter); expect(rowError).not.toBeInstanceOf(otherNode.QwpWriterRowError); }); it("keeps synchronous package-root identity", async () => { - const root: any = await load(".", format); + const root: any = await load(format); const sender = new root.Sender( new root.SenderOptions("ws::addr=127.0.0.1:9;auto_flush=off;", { log: () => {}, }), ); - const node: any = await load("./qwp/node", format); + const node: any = await load(format); const trades = sender.writer("trades", schemaFrom(node)); expect(trades).toBeInstanceOf(node.QwpTableWriter); @@ -187,22 +204,16 @@ describe.each(["import", "require"] as const)( expect(rowError).toBeInstanceOf(node.QwpWriterRowError); }); - it("re-exported factories keep the identity of their defining bundle", async () => { - const qwp: any = await load("./qwp", format); - const node: any = await load("./qwp/node", format); - const browser: any = await load("./qwp/browser", format); + it("re-exported factories keep the root module identity", async () => { + const qwp: any = await load(format); + const node: any = await load(format); - // './qwp/node' and './qwp/browser' re-export the factories with - // `export * from "./index"`, so they must be the very same functions. expect(node.symbol).toBe(qwp.symbol); - expect(browser.symbol).toBe(qwp.symbol); - // ...and a descriptor built through any of them must be accepted by a - // writer compiled in any other bundle. This is the assertion that fails - // when the column brand is a module-private Symbol rather than a shared - // one: the factory and the validator end up in different bundles. + // A descriptor obtained through any root reference must be accepted by + // the writer from that same emitted module. const sender = node.createQwpNodeSender({ url: URL_, autoFlush: false }); - for (const factories of [qwp, node, browser]) { + for (const factories of [qwp, node]) { expect(() => sender.writer("trades", schemaFrom(factories)), ).not.toThrow(); @@ -212,10 +223,10 @@ describe.each(["import", "require"] as const)( ); describe("package-root static QWP import", () => { - it("uses the ESM QWP entry for synchronous ESM construction", async () => { - const rootUrl = pathToFileURL(resolveExport(".", "import")).href; - const nodeUrl = pathToFileURL(resolveExport("./qwp/node", "import")).href; - const commonJsNode = resolveExport("./qwp/node", "require"); + it("uses only the ESM root for synchronous ESM construction", async () => { + const rootUrl = pathToFileURL(resolveExport("import")).href; + const nodeUrl = pathToFileURL(resolveExport("import")).href; + const commonJsNode = resolveExport("require"); const configuration = "ws::addr=127.0.0.1:9;auto_flush=off;"; const script = ` (async () => { @@ -259,7 +270,7 @@ describe("store-and-forward locking", () => { it.each(["import", "require"] as const)( "loads QWP with the package root (%s)", async (format) => { - const target = resolveExport(".", format); + const target = resolveExport(format); const probe = '({ ws: !!require.cache[require.resolve("ws")],' + " dgram: process.moduleLoadList.some((m) => /dgram/.test(m)) })"; @@ -294,7 +305,7 @@ describe("store-and-forward locking", () => { it.each(["import", "require"] as const)( "the package root loads (%s) on a platform no addon would support", async (format) => { - const target = resolveExport(".", format); + const target = resolveExport(format); const load_ = format === "require" ? `console.log(typeof require(${JSON.stringify(target)}).Sender)` @@ -317,10 +328,7 @@ describe("store-and-forward locking", () => { it("ships the slot lock in the bundle with no native addon", async () => { for (const format of ["import", "require"] as const) { - const bundle = await readFile( - resolveExport("./qwp/node", format), - "utf8", - ); + const bundle = await readFile(resolveExport(format), "utf8"); // The `.lock.owner` mutex is the whole locking implementation, so it must // be inlined rather than reached through any external specifier. @@ -338,7 +346,10 @@ describe("store-and-forward locking", () => { dependencies?: Record; optionalDependencies?: Record; } = JSON.parse( - await readFile(new URL("../../package.json", import.meta.url), "utf8"), + await readFile( + new URL("../../packages/nodejs-client/package.json", import.meta.url), + "utf8", + ), ); expect(manifest.optionalDependencies).toBeUndefined(); diff --git a/test/qwp/egress.test.ts b/test/qwp/egress.test.ts index 2620cf8..e794d0f 100644 --- a/test/qwp/egress.test.ts +++ b/test/qwp/egress.test.ts @@ -32,9 +32,9 @@ import { QwpResultRowView, readQwpVarint, writeQwpVarint, -} from "../../src/qwp"; -import { decompressQwpZstdFrame } from "../../src/_qwp/_core/zstd"; -import { QwpAsyncQueue } from "../../src/_qwp/_internal/async-queue"; +} from "../../packages/client-core/src/qwp"; +import { decompressQwpZstdFrame } from "../../packages/client-core/src/_qwp/_core/zstd"; +import { QwpAsyncQueue } from "../../packages/client-core/src/_qwp/_internal/async-queue"; const RESULT_FLAGS = QWP_FLAG_DELTA_SYMBOL_DICTIONARY | QWP_FLAG_GORILLA; diff --git a/test/qwp/identifiers.test.ts b/test/qwp/identifiers.test.ts index 6eef887..ae3a5fa 100644 --- a/test/qwp/identifiers.test.ts +++ b/test/qwp/identifiers.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { qwpColumnNameKey } from "../../src/_qwp/_core/identifiers"; +import { qwpColumnNameKey } from "../../packages/client-core/src/_qwp/_core/identifiers"; /** * The pre-optimization reference: lower-case each UTF-16 code unit diff --git a/test/qwp/node-client-config.test.ts b/test/qwp/node-client-config.test.ts index 2614d7e..78e6a37 100644 --- a/test/qwp/node-client-config.test.ts +++ b/test/qwp/node-client-config.test.ts @@ -8,7 +8,7 @@ import { parseQwpNodeClientConfig, type QwpNodeClientOptions, type QwpWebSocketLike, -} from "../../src/qwp/node"; +} from "../../packages/nodejs-client/src"; class RejectingWebSocket { binaryType = ""; diff --git a/test/qwp/node-transport.test.ts b/test/qwp/node-transport.test.ts index a12a815..33816ff 100644 --- a/test/qwp/node-transport.test.ts +++ b/test/qwp/node-transport.test.ts @@ -31,7 +31,7 @@ import { QwpUpgradeError, type QwpSenderError, writeQwpVarint, -} from "../../src/qwp/node"; +} from "../../packages/nodejs-client/src"; function serverInfo( role: number = QWP_SERVER_ROLE.STANDALONE, diff --git a/test/qwp/notification-dispatcher.test.ts b/test/qwp/notification-dispatcher.test.ts index 0b2c195..cd20f67 100644 --- a/test/qwp/notification-dispatcher.test.ts +++ b/test/qwp/notification-dispatcher.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { QwpNotificationDispatcher } from "../../src/_qwp/_internal/notification-dispatcher"; +import { QwpNotificationDispatcher } from "../../packages/client-core/src/_qwp/_internal/notification-dispatcher"; describe("QwpNotificationDispatcher", () => { it("delivers outside the protocol call stack in FIFO order", async () => { diff --git a/test/qwp/orphan-drainer.test.ts b/test/qwp/orphan-drainer.test.ts index 32568ab..21f4374 100644 --- a/test/qwp/orphan-drainer.test.ts +++ b/test/qwp/orphan-drainer.test.ts @@ -11,7 +11,7 @@ import { retryQwpNodeOrphanSlot, scanQwpNodeOrphanSlots, type QwpNodeOrphanDrainSession, -} from "../../src/qwp/node"; +} from "../../packages/nodejs-client/src"; import { QWP_RECONNECT_EVENT_KIND, QWP_SENDER_ERROR_CATEGORY, @@ -19,7 +19,7 @@ import { QWP_STATUS, QwpReplayRejectedError, type QwpSenderError, -} from "../../src/qwp"; +} from "../../packages/client-core/src/qwp"; class FakeDrainSession implements QwpNodeOrphanDrainSession { pendingReplayFrames = 1; diff --git a/test/qwp/public-api-contract.ts b/test/qwp/public-api-contract.ts index 4e4f1de..528c665 100644 --- a/test/qwp/public-api-contract.ts +++ b/test/qwp/public-api-contract.ts @@ -1,5 +1,8 @@ -import { Sender } from "../../src"; -import type { ExtraOptions, QwpExtraOptions } from "../../src"; +import { Sender } from "../../packages/nodejs-client/src"; +import type { + ExtraOptions, + QwpExtraOptions, +} from "../../packages/nodejs-client/src"; import { binary, char, @@ -18,14 +21,14 @@ import { longArray, symbol as qwpSymbol, uuid, -} from "../../src/qwp"; +} from "../../packages/nodejs-client/src"; import { bootstrapQwpBrowserSession, connectQwpBrowserClient, connectQwpBrowserEgress, connectQwpBrowserIngress, connectQwpBrowserSender, -} from "../../src/qwp/browser"; +} from "../../packages/browser-client/src"; import type { QwpBrowserClusterOptions, QwpBrowserClientEgressOptions, @@ -37,7 +40,7 @@ import type { QwpBrowserSplitClientOptions, QwpBrowserUnifiedClientOptions, QwpBrowserWebSocketOptions, -} from "../../src/qwp/browser"; +} from "../../packages/browser-client/src"; import { connectQwpNodeEgress, connectQwpNodeClient, @@ -49,7 +52,7 @@ import { parseQwpNodeClientConfig, retryQwpNodeOrphanSlot, scanQwpNodeOrphanSlots, -} from "../../src/qwp/node"; +} from "../../packages/nodejs-client/src"; import type { QwpNodeClientOptions, QwpNodeClientConfigOptions, @@ -62,7 +65,7 @@ import type { QwpNodeReplayRecoveryEvent, QwpNodeStoreAndForwardOptions, QwpNodeWebSocketOptions, -} from "../../src/qwp/node"; +} from "../../packages/nodejs-client/src"; import type { QwpBinaryConnection, QwpClient, @@ -85,7 +88,7 @@ import type { QwpSenderOptions, QwpTableWriter, QwpWriterRow, -} from "../../src/qwp"; +} from "../../packages/nodejs-client/src"; // This file is part of the repository typecheck. Assignments deliberately // capture the documented call shapes, so removing or changing a public diff --git a/test/qwp/public-api.test.ts b/test/qwp/public-api.test.ts index e109419..c682b20 100644 --- a/test/qwp/public-api.test.ts +++ b/test/qwp/public-api.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from "vitest"; -import * as browser from "../../src/qwp/browser"; -import * as node from "../../src/qwp/node"; -import * as shared from "../../src/qwp"; +import * as browser from "../../packages/browser-client/src"; +import * as node from "../../packages/nodejs-client/src"; +import * as shared from "../../packages/client-core/src/qwp"; const sharedRuntimeContract = [ "QWP_INGRESS_PROGRESS_KIND", diff --git a/test/qwp/reconnect.test.ts b/test/qwp/reconnect.test.ts index 31a12f1..504be03 100644 --- a/test/qwp/reconnect.test.ts +++ b/test/qwp/reconnect.test.ts @@ -30,7 +30,7 @@ import { QwpReplayStoreLockLostError, QwpReplayStoreSegmentTooLargeError, type QwpNodeReplayDataLossReport, -} from "../../src/qwp/node"; +} from "../../packages/nodejs-client/src"; import { QWP_RECONNECT_EVENT_KIND, QWP_COLUMN_TYPE, @@ -74,15 +74,15 @@ import { encodeQwpQueryRequest, decodeQwpIngressSymbolDictionaryDelta, writeQwpVarint, -} from "../../src/qwp"; -import { QwpNodeAdvisoryLock } from "../../src/qwp-node/advisory-lock"; -import { QwpAsyncQueue } from "../../src/_qwp/_internal/async-queue"; -import { qwpSegmentMaintenanceWorker } from "../../src/qwp-node/segment-maintenance-worker"; -import { createQwpEgressFailoverConnectionFactory } from "../../src/_qwp/_internal/egress-routing"; +} from "../../packages/client-core/src/qwp"; +import { QwpNodeAdvisoryLock } from "../../packages/nodejs-client/src/qwp-node/advisory-lock"; +import { QwpAsyncQueue } from "../../packages/client-core/src/_qwp/_internal/async-queue"; +import { qwpSegmentMaintenanceWorker } from "../../packages/nodejs-client/src/qwp-node/segment-maintenance-worker"; +import { createQwpEgressFailoverConnectionFactory } from "../../packages/client-core/src/_qwp/_internal/egress-routing"; import { createQwpFailoverConnectionFactory, createQwpFailoverHealthTracker, -} from "../../src/_qwp/_internal/failover"; +} from "../../packages/client-core/src/_qwp/_internal/failover"; async function expectOnlyJavaSlotLockMetadata( directory: string, diff --git a/test/qwp/safe-callback.test.ts b/test/qwp/safe-callback.test.ts index f9b8d64..12626ce 100644 --- a/test/qwp/safe-callback.test.ts +++ b/test/qwp/safe-callback.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { isPromiseLike, safelyInvoke, -} from "../../src/_qwp/_internal/safe-callback"; +} from "../../packages/client-core/src/_qwp/_internal/safe-callback"; /** * Runs `body`, then waits long enough for Node to surface any orphaned diff --git a/test/qwp/sender-error.test.ts b/test/qwp/sender-error.test.ts index 038be51..f0c9d63 100644 --- a/test/qwp/sender-error.test.ts +++ b/test/qwp/sender-error.test.ts @@ -6,11 +6,11 @@ import { QWP_SENDER_ERROR_CATEGORY, QWP_SENDER_ERROR_POLICY, QWP_STATUS, -} from "../../src/qwp"; +} from "../../packages/client-core/src/qwp"; const logging = vi.hoisted(() => ({ log: vi.fn() })); -vi.mock("../../src/logging", () => logging); +vi.mock("../../packages/client-core/src/logging", () => logging); describe("QWP typed sender errors", () => { beforeEach(() => logging.log.mockClear()); diff --git a/test/qwp/sender-node-integration.test.ts b/test/qwp/sender-node-integration.test.ts index 112eb6e..807f350 100644 --- a/test/qwp/sender-node-integration.test.ts +++ b/test/qwp/sender-node-integration.test.ts @@ -5,7 +5,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { WebSocketServer } from "ws"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { Sender } from "../../src"; +import { Sender } from "../../packages/nodejs-client/src"; import { QWP_FLAG_DELTA_SYMBOL_DICTIONARY, QWP_MAGIC, @@ -13,7 +13,7 @@ import { QwpByteWriter, QwpNodeFileReplayStore, decodeQwpIngressSymbolDictionaryDelta, -} from "../../src/qwp/node"; +} from "../../packages/nodejs-client/src"; function okResponse(sequence: bigint, table: string): Uint8Array { const encodedTable = new TextEncoder().encode(table); diff --git a/test/qwp/sender.test.ts b/test/qwp/sender.test.ts index 6a087d1..e5811ab 100644 --- a/test/qwp/sender.test.ts +++ b/test/qwp/sender.test.ts @@ -44,7 +44,7 @@ import { uuid, varchar, writeQwpVarint, -} from "../../src/qwp"; +} from "../../packages/client-core/src/qwp"; class RecordingSession implements QwpSenderSession { readonly sends: { @@ -1885,10 +1885,7 @@ describe("QWP high-level sender", () => { values: [], }, }); - for (const dimension of [ - QWP_MAX_ARRAY_DIMENSION_LENGTH + 1, - 2 ** 32, - ]) { + for (const dimension of [QWP_MAX_ARRAY_DIMENSION_LENGTH + 1, 2 ** 32]) { await expect( typed.row({ samples: { dimensions: [0, dimension], values: [] }, @@ -1898,9 +1895,11 @@ describe("QWP high-level sender", () => { await sender.flush(); expect( - (column(session.sends[0].tables[0], "samples").values[0] as { - dimensions: number[]; - }).dimensions, + ( + column(session.sends[0].tables[0], "samples").values[0] as { + dimensions: number[]; + } + ).dimensions, ).toEqual([0, QWP_MAX_ARRAY_DIMENSION_LENGTH]); const raw = new QwpTableBuffer("raw"); diff --git a/test/qwp/session.test.ts b/test/qwp/session.test.ts index a600e22..853c90c 100644 --- a/test/qwp/session.test.ts +++ b/test/qwp/session.test.ts @@ -9,13 +9,13 @@ import { createQwpBrowserSender, QwpBrowserSessionBootstrapError, QwpWebSocketLike, -} from "../../src/qwp/browser"; +} from "../../packages/browser-client/src"; import { connectQwpNodeEgress, connectQwpNodeWebSocket, QwpDurableAckUnavailableError, QwpVersionMismatchError, -} from "../../src/qwp/node"; +} from "../../packages/nodejs-client/src"; import { QWP_COLUMN_TYPE, QWP_COMPRESSION_CODEC, @@ -50,8 +50,8 @@ import { QwpUpgradeError, QwpSymbolDictionary, readQwpVarintNumber, -} from "../../src/qwp"; -import { openQwpWebSocket } from "../../src/_qwp/_internal/websocket-connection"; +} from "../../packages/client-core/src/qwp"; +import { openQwpWebSocket } from "../../packages/client-core/src/_qwp/_internal/websocket-connection"; type Listener = (event: unknown) => void; diff --git a/test/qwp/sfa-interop.test.ts b/test/qwp/sfa-interop.test.ts index 44ba35d..c51f3a8 100644 --- a/test/qwp/sfa-interop.test.ts +++ b/test/qwp/sfa-interop.test.ts @@ -9,7 +9,7 @@ import { import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { QwpNodeFileReplayStore } from "../../src/qwp/node"; +import { QwpNodeFileReplayStore } from "../../packages/nodejs-client/src"; const FIXTURE_DIRECTORY = join(process.cwd(), "test/qwp/fixtures/sfa"); diff --git a/test/qwp/sfa-multiprocess-child.mjs b/test/qwp/sfa-multiprocess-child.mjs index cbd7a10..127c8a5 100644 --- a/test/qwp/sfa-multiprocess-child.mjs +++ b/test/qwp/sfa-multiprocess-child.mjs @@ -12,7 +12,7 @@ import { pathToFileURL } from "node:url"; const [, , distDir, directory] = process.argv; const { QwpNodeFileReplayStore } = await import( - pathToFileURL(`${distDir}/es/qwp/node.mjs`).href + pathToFileURL(`${distDir}/es/index.mjs`).href ); const payload = (marker) => new Uint8Array(64).fill(marker.charCodeAt(0)); diff --git a/test/qwp/sfa-multiprocess.e2e.ts b/test/qwp/sfa-multiprocess.e2e.ts index f5617c3..5e2ab24 100644 --- a/test/qwp/sfa-multiprocess.e2e.ts +++ b/test/qwp/sfa-multiprocess.e2e.ts @@ -32,11 +32,11 @@ const ROOT = path.resolve( path.dirname(fileURLToPath(import.meta.url)), "../..", ); -const DIST = path.join(ROOT, "dist"); +const DIST = path.join(ROOT, "packages/nodejs-client/dist"); const CHILD = path.join(ROOT, "test/qwp/sfa-multiprocess-child.mjs"); // One heartbeat interval plus slack: how long a holder needs before it can -// notice that its lock was taken. Both live in src/qwp-node/advisory-lock.ts. +// notice that its lock was taken. Both live in packages/nodejs-client/src/qwp-node/advisory-lock.ts. const HEARTBEAT_INTERVAL_MS = 5_000; const BEAT_SETTLE_MS = HEARTBEAT_INTERVAL_MS + 1_500; const STALE_AFTER_MS = 15_000; @@ -143,7 +143,7 @@ async function simulateLapsedHeartbeat(directory: string): Promise { } // The store's on-disk segment layout, mirrored from -// src/qwp-node/file-replay-store.ts. A fixed 24-byte segment header precedes a +// packages/nodejs-client/src/qwp-node/file-replay-store.ts. A fixed 24-byte segment header precedes a // run of frames, each an 8-byte header -- a CRC32C followed by a uint32 // little-endian payload length -- then the payload. The rest of the fixed-size // file is zero padding, so a frame whose header reads back as all zeroes marks diff --git a/test/qwp/udp-sender.test.ts b/test/qwp/udp-sender.test.ts index 65215a7..afae36f 100644 --- a/test/qwp/udp-sender.test.ts +++ b/test/qwp/udp-sender.test.ts @@ -1,14 +1,18 @@ import { describe, expect, it } from "vitest"; -import { Sender } from "../../src"; +import { Sender } from "../../packages/nodejs-client/src"; import { QwpSymbolDictionary, connectQwpNodeUdp, connectQwpNodeUdpSender, createQwpNodeUdpSender, type QwpNodeUdpSocketLike, -} from "../../src/qwp/node"; -import { decodeQwpFrame, QWP_COLUMN_TYPE, QwpTableBuffer } from "../../src/qwp"; -import { QwpUdpDatagramTooLargeError } from "../../src/qwp/node"; +} from "../../packages/nodejs-client/src"; +import { + decodeQwpFrame, + QWP_COLUMN_TYPE, + QwpTableBuffer, +} from "../../packages/client-core/src/qwp"; +import { QwpUdpDatagramTooLargeError } from "../../packages/nodejs-client/src"; class FakeUdpSocket implements QwpNodeUdpSocketLike { readonly packets: Uint8Array[] = []; diff --git a/test/qwp/wss-tls-security.test.ts b/test/qwp/wss-tls-security.test.ts index 7c15f6b..2e6324a 100644 --- a/test/qwp/wss-tls-security.test.ts +++ b/test/qwp/wss-tls-security.test.ts @@ -4,9 +4,13 @@ import * as https from "node:https"; import type { AddressInfo } from "node:net"; import { Agent as UndiciAgent } from "undici"; import { afterEach, describe, expect, it, vi } from "vitest"; -import * as qwpNode from "../../src/qwp/node"; -import { Sender } from "../../src/sender"; -import { SenderOptions, qwpConfig } from "../../src/options"; +// Spy on the module Sender imports, rather than the public re-export facade. +import * as qwpNode from "../../packages/nodejs-client/src/qwp"; +import { Sender } from "../../packages/nodejs-client/src/sender"; +import { + SenderOptions, + qwpConfig, +} from "../../packages/nodejs-client/src/options"; /** * A wss:// producer must verify the server certificate, and its authorization diff --git a/test/sender.buffer.test.ts b/test/sender.buffer.test.ts index 77c28cc..e0a0f21 100644 --- a/test/sender.buffer.test.ts +++ b/test/sender.buffer.test.ts @@ -2,8 +2,12 @@ import { describe, it, expect } from "vitest"; import { readFileSync } from "fs"; -import { createBuffer, Sender, SenderOptions } from "../src"; -import { PROTOCOL_VERSION_V3 } from "../src/options"; +import { + createBuffer, + Sender, + SenderOptions, +} from "../packages/nodejs-client/src"; +import { PROTOCOL_VERSION_V3 } from "../packages/nodejs-client/src/options"; type Column = { name: string } & ( | { type: "STRING"; value: string } diff --git a/test/sender.config.test.ts b/test/sender.config.test.ts index ac11c77..558336b 100644 --- a/test/sender.config.test.ts +++ b/test/sender.config.test.ts @@ -1,9 +1,12 @@ // @ts-check import { describe, it, expect } from "vitest"; -import { Sender } from "../src"; -import { DEFAULT_BUFFER_SIZE, DEFAULT_MAX_BUFFER_SIZE } from "../src/buffer"; -import { log } from "../src/logging"; +import { Sender } from "../packages/nodejs-client/src"; +import { + DEFAULT_BUFFER_SIZE, + DEFAULT_MAX_BUFFER_SIZE, +} from "../packages/nodejs-client/src/buffer"; +import { log } from "../packages/client-core/src/logging"; describe("Sender configuration options suite", function () { it("creates a sender from a configuration string", async function () { diff --git a/test/sender.integration.test.ts b/test/sender.integration.test.ts index dc1a5b7..c4ceb54 100644 --- a/test/sender.integration.test.ts +++ b/test/sender.integration.test.ts @@ -3,7 +3,7 @@ import { describe, it, expect, beforeAll, afterAll } from "vitest"; import { GenericContainer, StartedTestContainer } from "testcontainers"; import http from "http"; -import { Sender, SenderOptions } from "../src"; +import { Sender, SenderOptions } from "../packages/nodejs-client/src"; const HTTP_OK = 200; diff --git a/test/sender.transport.test.ts b/test/sender.transport.test.ts index 08e6b2e..f2bcb90 100644 --- a/test/sender.transport.test.ts +++ b/test/sender.transport.test.ts @@ -6,7 +6,12 @@ import http from "http"; import crypto from "node:crypto"; -import { Sender, SenderOptions, UndiciTransport, HttpTransport } from "../src"; +import { + Sender, + SenderOptions, + UndiciTransport, + HttpTransport, +} from "../packages/nodejs-client/src"; import { MockProxy } from "./util/mockproxy"; import { MockHttp } from "./util/mockhttp"; diff --git a/test/testapp.ts b/test/testapp.ts index aed35eb..b8e5cf2 100644 --- a/test/testapp.ts +++ b/test/testapp.ts @@ -1,7 +1,7 @@ import { readFileSync } from "node:fs"; import { Proxy } from "./util/proxy"; -import { Sender, SenderOptions } from "../src"; +import { Sender, SenderOptions } from "../packages/nodejs-client/src"; const PROXY_PORT = 9099; const PORT = 9009; diff --git a/test/utils.decimal.test.ts b/test/utils.decimal.test.ts index bbf8b41..8eb016b 100644 --- a/test/utils.decimal.test.ts +++ b/test/utils.decimal.test.ts @@ -1,6 +1,6 @@ // @ts-check import { describe, it, expect } from "vitest"; -import { bigintToTwosComplementBytes } from "../src/utils"; +import { bigintToTwosComplementBytes } from "../packages/nodejs-client/src/utils"; describe("bigintToTwosComplementBytes", () => { it("encodes zero as a single zero byte", () => { diff --git a/tsconfig.bench.json b/tsconfig.bench.json index 8b99661..6698c6d 100644 --- a/tsconfig.bench.json +++ b/tsconfig.bench.json @@ -3,5 +3,5 @@ "compilerOptions": { "skipLibCheck": true }, - "include": ["src", "benchmarks", "vitest.bench-e2e.config.ts"] + "include": ["packages/*/src", "benchmarks", "vitest.bench-e2e.config.ts"] } diff --git a/tsconfig.dist-types.cjs.json b/tsconfig.dist-types.cjs.json index ac50f5e..ccb5aa1 100644 --- a/tsconfig.dist-types.cjs.json +++ b/tsconfig.dist-types.cjs.json @@ -2,10 +2,12 @@ "extends": "./tsconfig.dist-types.json", "compilerOptions": { "paths": { - "@questdb/nodejs-client": ["./dist/cjs/index.d.ts"], - "@questdb/nodejs-client/qwp": ["./dist/cjs/qwp/index.d.ts"], - "@questdb/nodejs-client/qwp/node": ["./dist/cjs/qwp/node.d.ts"], - "@questdb/nodejs-client/qwp/browser": ["./dist/cjs/qwp/browser.d.ts"] + "@questdb/nodejs-client": [ + "./packages/nodejs-client/dist/cjs/index.d.ts" + ], + "@questdb/browser-client": [ + "./packages/browser-client/dist/cjs/index.d.ts" + ] } } } diff --git a/tsconfig.dist-types.json b/tsconfig.dist-types.json index 9840c76..6363d42 100644 --- a/tsconfig.dist-types.json +++ b/tsconfig.dist-types.json @@ -9,10 +9,12 @@ "strict": true, "noEmit": true, "paths": { - "@questdb/nodejs-client": ["./dist/es/index.d.mts"], - "@questdb/nodejs-client/qwp": ["./dist/es/qwp/index.d.mts"], - "@questdb/nodejs-client/qwp/node": ["./dist/es/qwp/node.d.mts"], - "@questdb/nodejs-client/qwp/browser": ["./dist/es/qwp/browser.d.mts"] + "@questdb/nodejs-client": [ + "./packages/nodejs-client/dist/es/index.d.mts" + ], + "@questdb/browser-client": [ + "./packages/browser-client/dist/es/index.d.mts" + ] } } } diff --git a/tsconfig.json b/tsconfig.json index fd1098f..9727b72 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,14 +1,11 @@ { - "include": ["src", "test/qwp/public-api-contract.ts"], + "include": ["packages/*/src", "test/qwp/public-api-contract.ts"], "compilerOptions": { "moduleResolution": "bundler", "module": "ESNext", "declaration": true, "target": "ESNext", - "lib": [ - "es2020", - "esnext" - ], + "lib": ["es2020", "esnext"], // Types should go into this directory // Go to .js file when using IDE functions like "Go to Definition" in VSCode "declarationMap": true diff --git a/tsconfig.test.json b/tsconfig.test.json index e38df0d..d46ea4e 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -4,7 +4,7 @@ "noEmit": true, "skipLibCheck": true }, - "include": ["src", "test"], + "include": ["packages/*/src", "test"], // test/dist-types imports the package by its published name, which only // resolves against a built dist/. Those files belong to // tsconfig.dist-types*.json, which typecheck:dist runs after pnpm build. diff --git a/typedoc.json b/typedoc.json index 543693b..08ee60a 100644 --- a/typedoc.json +++ b/typedoc.json @@ -1,15 +1,10 @@ { "$schema": "https://typedoc.org/schema.json", - "entryPoints": [ - "./src/index.ts", - "./src/qwp/index.ts", - "./src/qwp/browser.ts", - "./src/qwp/node.ts" - ], + "entryPoints": ["./packages/nodejs-client", "./packages/browser-client"], + "entryPointStrategy": "packages", "out": "docs", "name": "QuestDB JavaScript Client", "readme": "./README.md", - "tsconfig": "./tsconfig.json", "exclude": ["**/test/**/*", "**/examples/**/*", "**/node_modules/**/*"], "excludePrivate": true, "excludeProtected": false, @@ -29,4 +24,4 @@ "inherited": true, "external": false } -} \ No newline at end of file +} diff --git a/vitest.dist.config.ts b/vitest.dist.config.ts index 16b663e..0e03950 100644 --- a/vitest.dist.config.ts +++ b/vitest.dist.config.ts @@ -2,7 +2,11 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { - include: ["test/qwp/dist.e2e.ts", "test/qwp/sfa-multiprocess.e2e.ts"], + include: [ + "test/package-boundaries.e2e.ts", + "test/qwp/dist.e2e.ts", + "test/qwp/sfa-multiprocess.e2e.ts", + ], // The suite loads the built bundles directly; Vite must not pre-bundle or // otherwise rewrite them, or the per-entry-point module identity this // suite exists to check would be lost. From a85e01a3db76f1eab7e225fe1c920f9a18df0dd1 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 2 Sep 2026 12:41:19 +0100 Subject: [PATCH 228/265] fix(qwp): absorb store close failures on protocol-error paths 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) Claude-Session: https://claude.ai/code/session_01B5WuAdKhXeFBSS9mswuZgG --- .../client-core/src/_qwp/egress-session.ts | 6 ++- .../client-core/src/_qwp/ingress-session.ts | 23 ++++++--- test/qwp/reconnect.test.ts | 47 +++++++++++++++++++ 3 files changed, 68 insertions(+), 8 deletions(-) diff --git a/packages/client-core/src/_qwp/egress-session.ts b/packages/client-core/src/_qwp/egress-session.ts index 33e5677..90da2c7 100644 --- a/packages/client-core/src/_qwp/egress-session.ts +++ b/packages/client-core/src/_qwp/egress-session.ts @@ -1166,7 +1166,11 @@ export class QwpEgressSession implements QwpEgressQueryControl { } catch (error) { this.fail(error); if (error instanceof QwpProtocolError) { - void this.connection.close(1002, "invalid QWP egress message"); + // Matches the ingress session: a connection close() that rejects must + // not become an unhandled rejection on an error-handling path. + void this.connection + .close(1002, "invalid QWP egress message") + .catch(() => undefined); } } } diff --git a/packages/client-core/src/_qwp/ingress-session.ts b/packages/client-core/src/_qwp/ingress-session.ts index 34e00ce..88102e8 100644 --- a/packages/client-core/src/_qwp/ingress-session.ts +++ b/packages/client-core/src/_qwp/ingress-session.ts @@ -1369,7 +1369,13 @@ export class QwpIngressSession { } catch (error) { this.fail(error); if (error instanceof QwpProtocolError) { - void this.connection.close(1002, "invalid QWP response"); + // A reconnecting transport's close() awaits the replay store, and a + // persistent store rethrows a checkpoint, segment-handle or lock + // release failure. Discarding that rejection would surface it as an + // unhandled rejection, which terminates the process by default. + void this.connection + .close(1002, "invalid QWP response") + .catch(() => undefined); } } } @@ -1454,12 +1460,15 @@ export class QwpIngressSession { // transports recycle and replay below their last ACK; a fixed/direct // session has no such recovery path and must fail closed immediately. this.fail(error, true); - void this.connection.close( - 1002, - dictionaryGap - ? "QWP symbol dictionary gap" - : "QWP ingress pipeline rejected", - ); + // See consumeMessages(): a rejecting store close must not escape here. + void this.connection + .close( + 1002, + dictionaryGap + ? "QWP symbol dictionary gap" + : "QWP ingress pipeline rejected", + ) + .catch(() => undefined); } } diff --git a/test/qwp/reconnect.test.ts b/test/qwp/reconnect.test.ts index 504be03..91fcfd0 100644 --- a/test/qwp/reconnect.test.ts +++ b/test/qwp/reconnect.test.ts @@ -280,6 +280,16 @@ class TrackingReplayStore implements QwpIngressReplayStore { } } +/** Mirrors QwpNodeFileReplayStore.close() rethrowing a teardown failure. */ +class CloseFaultStore extends TrackingReplayStore { + closeAttempts = 0; + + override async close(): Promise { + this.closeAttempts++; + throw new Error("could not release QWP advisory lock"); + } +} + class LazyTrackingReplayStore extends TrackingReplayStore { readonly reads: bigint[] = []; loadCalls = 0; @@ -2898,6 +2908,43 @@ describe("QWP ingress reconnect and replay", () => { await session.close(); }); + it("does not leak an unhandled rejection when a store close fails on the protocol-error path", async () => { + // The protocol-error branch closes the connection for its side effect. A + // reconnecting transport's close() awaits the replay store, and + // QwpNodeFileReplayStore rethrows a checkpoint, segment-handle or lock + // release failure -- so discarding that promise made a read-only or full + // journal volume terminate the host process with an unhandled rejection. + const unhandled: unknown[] = []; + const onUnhandled = (reason: unknown) => unhandled.push(reason); + process.on("unhandledRejection", onUnhandled); + try { + const connection = new FakeConnection("primary"); + const replayStore = new CloseFaultStore(); + const session = await QwpIngressSession.connect(async () => connection, { + replayStore, + reconnect: { maxAttempts: 1 }, + }); + const pending = session.sendFrame(Uint8Array.of(9)); + await vi.waitFor(() => expect(connection.sent).toHaveLength(1)); + // A one-byte payload cannot carry an ingress response, so decoding it + // raises QwpProtocolError inside consumeMessages(). + connection.receive(Uint8Array.of(QWP_STATUS.OK)); + + await expect(pending).rejects.toBeInstanceOf(QwpProtocolError); + await vi.waitFor(() => expect(replayStore.closeAttempts).toBe(1)); + // Give any escaping rejection a turn of the microtask and macrotask + // queues to reach the process handler. + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(unhandled).toEqual([]); + + // The protocol error itself still reaches the caller through the + // rejected send above; only the secondary teardown failure is absorbed. + await session.close().catch(() => undefined); + } finally { + process.off("unhandledRejection", onUnhandled); + } + }); + it("rejects an over-range ingress NACK instead of charging the wrong frame", async () => { const connection = new FakeConnection("primary"); const session = await QwpIngressSession.connect(async () => connection, { From 3b9157b8a11e4e5cb952f934f9a69ad0fd7ae63f Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 2 Sep 2026 12:48:51 +0100 Subject: [PATCH 229/265] fix(qwp): classify the CANCELLED and LIMIT_EXCEEDED statuses 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) Claude-Session: https://claude.ai/code/session_01B5WuAdKhXeFBSS9mswuZgG --- packages/client-core/src/_qwp/sender-error.ts | 19 +++++++++++ test/qwp/sender-error.test.ts | 32 +++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/packages/client-core/src/_qwp/sender-error.ts b/packages/client-core/src/_qwp/sender-error.ts index 5f34f81..e624f2e 100644 --- a/packages/client-core/src/_qwp/sender-error.ts +++ b/packages/client-core/src/_qwp/sender-error.ts @@ -9,6 +9,8 @@ export const QWP_SENDER_ERROR_CATEGORY = { WRITE_ERROR: "write-error", NOT_WRITABLE: "not-writable", DICTIONARY_GAP: "dictionary-gap", + CANCELLED: "cancelled", + LIMIT_EXCEEDED: "limit-exceeded", PROTOCOL_VIOLATION: "protocol-violation", DATA_LOSS: "data-loss", UNKNOWN: "unknown", @@ -154,7 +156,17 @@ export function qwpSenderErrorCategory(status: number): QwpSenderErrorCategory { return QWP_SENDER_ERROR_CATEGORY.NOT_WRITABLE; case QWP_STATUS.DICTIONARY_GAP: return QWP_SENDER_ERROR_CATEGORY.DICTIONARY_GAP; + case QWP_STATUS.CANCELLED: + return QWP_SENDER_ERROR_CATEGORY.CANCELLED; + case QWP_STATUS.LIMIT_EXCEEDED: + return QWP_SENDER_ERROR_CATEGORY.LIMIT_EXCEEDED; default: + // UNKNOWN is reserved for status bytes this client has no definition + // for, because the replay transport treats that category as + // poison-strike exempt: an unrecognised future status must retry + // forever rather than be blamed on the frame. A status the protocol + // does define must never land here, or a rejection it can never + // satisfy would replay without bound. return QWP_SENDER_ERROR_CATEGORY.UNKNOWN; } } @@ -163,9 +175,16 @@ export function qwpDefaultSenderErrorPolicy( category: QwpSenderErrorCategory, ): QwpSenderErrorPolicy { switch (category) { + // LIMIT_EXCEEDED is deterministic under byte-identical replay, so it can + // never be retried into success. It stays RETRIABLE rather than TERMINAL + // so the poison detector ends it through the quarantine path, which + // preserves the rows for retryQwpNodeOrphanSlot() instead of failing the + // producer outright. case QWP_SENDER_ERROR_CATEGORY.WRITE_ERROR: case QWP_SENDER_ERROR_CATEGORY.INTERNAL_ERROR: case QWP_SENDER_ERROR_CATEGORY.DICTIONARY_GAP: + case QWP_SENDER_ERROR_CATEGORY.CANCELLED: + case QWP_SENDER_ERROR_CATEGORY.LIMIT_EXCEEDED: case QWP_SENDER_ERROR_CATEGORY.UNKNOWN: return QWP_SENDER_ERROR_POLICY.RETRIABLE; case QWP_SENDER_ERROR_CATEGORY.NOT_WRITABLE: diff --git a/test/qwp/sender-error.test.ts b/test/qwp/sender-error.test.ts index f0c9d63..525511d 100644 --- a/test/qwp/sender-error.test.ts +++ b/test/qwp/sender-error.test.ts @@ -6,6 +6,7 @@ import { QWP_SENDER_ERROR_CATEGORY, QWP_SENDER_ERROR_POLICY, QWP_STATUS, + qwpSenderErrorCategory, } from "../../packages/client-core/src/qwp"; const logging = vi.hoisted(() => ({ log: vi.fn() })); @@ -15,6 +16,24 @@ vi.mock("../../packages/client-core/src/logging", () => logging); describe("QWP typed sender errors", () => { beforeEach(() => logging.log.mockClear()); + it("gives every defined QWP_STATUS byte a category other than UNKNOWN", () => { + // The replay transport treats UNKNOWN as poison-strike exempt so an + // unrecognised future status retries forever. A status this client does + // define must be classified, or a rejection it can never satisfy would + // replay without bound. + const unclassified = Object.entries(QWP_STATUS) + .filter( + ([, status]) => + qwpSenderErrorCategory(status) === QWP_SENDER_ERROR_CATEGORY.UNKNOWN, + ) + .map(([name]) => name) + .sort(); + + // OK, SERVER_INFO and DURABLE_ACK are successful responses that never + // reach the error classifier. + expect(unclassified).toEqual(["DURABLE_ACK", "OK", "SERVER_INFO"]); + }); + it.each([ [ QWP_STATUS.SCHEMA_MISMATCH, @@ -51,12 +70,25 @@ describe("QWP typed sender errors", () => { QWP_SENDER_ERROR_CATEGORY.DICTIONARY_GAP, QWP_SENDER_ERROR_POLICY.RETRIABLE, ], + [ + QWP_STATUS.CANCELLED, + QWP_SENDER_ERROR_CATEGORY.CANCELLED, + QWP_SENDER_ERROR_POLICY.RETRIABLE, + ], + [ + QWP_STATUS.LIMIT_EXCEEDED, + QWP_SENDER_ERROR_CATEGORY.LIMIT_EXCEEDED, + QWP_SENDER_ERROR_POLICY.RETRIABLE, + ], [ 0xfe, QWP_SENDER_ERROR_CATEGORY.UNKNOWN, QWP_SENDER_ERROR_POLICY.RETRIABLE, ], ])("maps status 0x%s to %s / %s", (status, category, appliedPolicy) => { + // UNKNOWN is poison-strike exempt in the replay transport, so a status + // the protocol defines must never fall into it: an unsatisfiable + // rejection would otherwise replay without bound. const error = createQwpSenderError( { status, From 901daececb77e032a7feea49220e6c63928d00b2 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 2 Sep 2026 12:49:03 +0100 Subject: [PATCH 230/265] fix(qwp): stop transient server faults from latching the poison detector 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) Claude-Session: https://claude.ai/code/session_01B5WuAdKhXeFBSS9mswuZgG --- QWP.md | 14 +- .../reconnecting-ingress-connection.ts | 60 ++++++++- test/qwp/reconnect.test.ts | 120 ++++++++++++++---- 3 files changed, 157 insertions(+), 37 deletions(-) diff --git a/QWP.md b/QWP.md index 927af9e..e2817b4 100644 --- a/QWP.md +++ b/QWP.md @@ -115,7 +115,7 @@ continues to come from `addr`, because the typed object intentionally omits | `max_name_len` | integer | `127` | Maximum table and column name length, in UTF-8 bytes. | | `sender_id` | string | `default` | Identifies this producer to the server and in the journal. | | `max_frame_rejections` | integer | `4` | Consecutive suspect outcomes for one frame before terminal escalation. | -| `poison_min_escalation_window_millis` | integer ms | `5000` | Minimum dwell before a poison frame may escalate. | +| `poison_min_escalation_window_millis` | integer ms | `300000` | Minimum connected dwell before a poison frame may escalate. | | `catch_up_cap_gap_min_escalation_window_millis` | integer ms | `300000` | Minimum dwell before an orphan symbol-dictionary cap gap is quarantined. | | `connection_listener_inbox_capacity` | integer | — | Bound on the connection-event inbox before events are dropped. | | `error_inbox_capacity` | integer | — | Bound on the `onSenderError` inbox before events are dropped. | @@ -879,11 +879,13 @@ process or page; configuring a Node directory makes the same replay crash-safe. Ingress also detects a replay head that is repeatedly NACKed or followed by a non-orderly WebSocket close. `maxFrameRejections` defaults to 4 consecutive strikes, -and `poisonMinEscalationWindowMs` defaults to 5 seconds. Both conditions must be met -before escalation. Normal (1000), going-away (1001), service-restart (1012), and -try-again-later (1013) closes, `NOT_WRITABLE`, retriable symbol-dictionary catch-up -rejections, and intervening connection-establishment failures reset the strike -episode. Abnormal closes (1006), internal-error closes (1011), and transport errors +and `poisonMinEscalationWindowMs` defaults to 5 minutes. Both conditions must be met +before escalation. The window measures _connected_ dwell only: time spent unable to +reach a server is banked and withheld, so an outage never supplies the dwell, and the +strikes a frame has already earned survive the reconnect it caused. Normal (1000), +going-away (1001), service-restart (1012), and try-again-later (1013) closes, +`NOT_WRITABLE`, `DICTIONARY_GAP`, and retriable symbol-dictionary catch-up rejections +reset the strike episode. Abnormal closes (1006), internal-error closes (1011), and transport errors without close information may count when an unacknowledged replay head exists. Escalation is terminal for that producer; store-and-forward retains and quarantines the affected rows for explicit `retryQwpNodeOrphanSlot()` recovery rather than diff --git a/packages/client-core/src/_qwp/_internal/reconnecting-ingress-connection.ts b/packages/client-core/src/_qwp/_internal/reconnecting-ingress-connection.ts index 3346d7c..822a60d 100644 --- a/packages/client-core/src/_qwp/_internal/reconnecting-ingress-connection.ts +++ b/packages/client-core/src/_qwp/_internal/reconnecting-ingress-connection.ts @@ -358,6 +358,9 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { private poisonFrameSequence?: bigint; private poisonFirstStrikeMs = 0; private poisonStrikes = 0; + /** Elapsed connection-outage time withheld from the escalation window. */ + private poisonOutageMs = 0; + private poisonOutageStartedMs = 0; private catchUpCapGapAttempts = 0; private catchUpCapGapFirstMs = 0; private durableAckMismatchAttempts = 0; @@ -419,8 +422,15 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { this.maxBackoffMs = reconnectOptions.maxBackoffMs ?? 5_000; this.maxDurationMs = reconnectOptions.maxDurationMs ?? 30_000; this.maxFrameRejections = reconnectOptions.maxFrameRejections ?? 4; + // WRITE_ERROR and INTERNAL_ERROR are RETRIABLE by policy, but the only + // thing separating "this frame is poison" from "the server cannot write + // right now" is how long the rejection persists. Five seconds did not + // separate them at all: a concurrent DDL, a checkpoint or a briefly full + // server volume outlives it easily, and with the reconnect backoff capped + // at maxBackoffMs four strikes accumulate well inside that window -- so a + // transient server-side fault permanently killed a running producer. this.poisonMinEscalationWindowMs = - reconnectOptions.poisonMinEscalationWindowMs ?? 5_000; + reconnectOptions.poisonMinEscalationWindowMs ?? 300_000; this.catchUpCapGapMinEscalationWindowMs = catchUpCapGapMinEscalationWindowMs; this.orphanDurableAckMismatchMaxDurationMs = @@ -900,6 +910,8 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { const replayed = await this.replayInto(candidate); if (this.closing) throw new QwpSendClosedError(); this.install(candidate, replayed); + // The server is reachable again, so the escalation window resumes. + this.endPoisonOutage(); this.resetCatchUpCapGapEpisode(); this.resetDurableAckMismatchEpisode(); this.connectingCandidate = undefined; @@ -928,10 +940,13 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { } catch (error) { // A poison frame is meant to identify a connection that repeatedly // accepts the same replay head and then rejects it or disappears. A - // failed connection/replay attempt breaks that sequence: the server - // is unavailable independently of the frame, so old strikes must not - // survive while the outage supplies the escalation dwell time. - this.resetPoisonEpisode(); + // failed connection/replay attempt breaks that sequence, so the + // outage must not supply the escalation dwell time -- but the strikes + // already earned have to survive it. Wiping the episode here made the + // canonical poison case unreachable: a frame that takes the server + // down guarantees the next connect fails, which reset the count + // before it could ever reach maxFrameRejections. + this.beginPoisonOutage(); if (reconnecting) this.totalReconnectErrors++; lastError = error; if (this.connectingCandidate === candidate) { @@ -1394,6 +1409,11 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { const exempt = frame.dictionaryCatchup || response.status === QWP_STATUS.NOT_WRITABLE || + // A DICTIONARY_GAP is the server asking for symbol catch-up, not a + // verdict on this frame. The catch-up it triggers has not been sent + // yet, so charging the frame a strike condemns it before the recovery + // it asked for has been attempted. + response.status === QWP_STATUS.DICTIONARY_GAP || qwpSenderErrorCategory(response.status) === QWP_SENDER_ERROR_CATEGORY.UNKNOWN; if (exempt) { @@ -1488,20 +1508,48 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { this.poisonFrameSequence = undefined; this.poisonFirstStrikeMs = 0; this.poisonStrikes = 0; + this.poisonOutageMs = 0; + this.poisonOutageStartedMs = 0; + } + + /** + * Marks the start of a connection-establishment outage. The strikes a frame + * has already earned survive it -- otherwise a frame that takes the server + * down can never escalate, because the very crash it causes makes the next + * connect fail and wipes the episode. Only the dwell the outage would have + * contributed is withheld, which is what the escalation window is for. + */ + private beginPoisonOutage(): void { + if (this.poisonFrameSequence === undefined) return; + if (this.poisonOutageStartedMs === 0) { + this.poisonOutageStartedMs = Date.now(); + } + } + + /** Banks the elapsed outage so it cannot count toward the escalation window. */ + private endPoisonOutage(): void { + if (this.poisonOutageStartedMs === 0) return; + this.poisonOutageMs += Date.now() - this.poisonOutageStartedMs; + this.poisonOutageStartedMs = 0; } private recordPoisonStrike(frameSequence: bigint): boolean { const now = Date.now(); + this.endPoisonOutage(); if (this.poisonFrameSequence === frameSequence) { this.poisonStrikes++; } else { this.poisonFrameSequence = frameSequence; this.poisonStrikes = 1; this.poisonFirstStrikeMs = now; + this.poisonOutageMs = 0; + this.poisonOutageStartedMs = 0; } + const connectedDwellMs = + now - this.poisonFirstStrikeMs - this.poisonOutageMs; return ( this.poisonStrikes >= this.maxFrameRejections && - now - this.poisonFirstStrikeMs >= this.poisonMinEscalationWindowMs + connectedDwellMs >= this.poisonMinEscalationWindowMs ); } diff --git a/test/qwp/reconnect.test.ts b/test/qwp/reconnect.test.ts index 91fcfd0..1b9bec1 100644 --- a/test/qwp/reconnect.test.ts +++ b/test/qwp/reconnect.test.ts @@ -2531,7 +2531,14 @@ describe("QWP ingress reconnect and replay", () => { await session.close(); }); - it("keeps retrying repeated unrecognised statuses", async () => { + // An unrecognised status must fail open, and a DICTIONARY_GAP is the server + // asking for symbol catch-up rather than a verdict on the frame -- neither + // may consume a poison strike, or a recoverable rejection would escalate to + // a terminal one. + it.each([ + ["an unrecognised status", 0x7f], + ["DICTIONARY_GAP", QWP_STATUS.DICTIONARY_GAP], + ])("keeps retrying repeated %s NACKs", async (_name, status) => { const first = new FakeConnection("primary"); const second = new FakeConnection("secondary"); const third = new FakeConnection("primary"); @@ -2555,9 +2562,9 @@ describe("QWP ingress reconnect and replay", () => { const pending = session.sendFrame(Uint8Array.of(9)); await vi.waitFor(() => expect(first.sent).toHaveLength(1)); - first.receive(ingressResponse(0x7f, 0n)); + first.receive(ingressResponse(status, 0n)); await vi.waitFor(() => expect(second.sent).toHaveLength(1)); - second.receive(ingressResponse(0x7f, 0n)); + second.receive(ingressResponse(status, 0n)); await vi.waitFor(() => expect(third.sent).toHaveLength(1)); third.receive(ingressResponse(QWP_STATUS.OK, 0n)); @@ -2824,23 +2831,29 @@ describe("QWP ingress reconnect and replay", () => { }, ); - it("resets poison strikes when connection establishment fails", async () => { - const first = new FakeConnection("terminating-1"); - const second = new FakeConnection("terminating-2"); - const healthy = new FakeConnection("healthy"); + it("escalates a frame that keeps taking the connection down across reconnect failures", async () => { + // The canonical poison case is a frame that crashes the server, which + // guarantees the following connect attempt fails. Wiping the episode on + // that failure made this case the one the detector could never reach: + // the strike count reset before it ever met maxFrameRejections, and the + // frame replayed without bound. Strikes now survive the outage. + // Every delivery attempt is followed by a refused connect, so the old + // wipe-on-connect-failure rule reset the count after every single strike + // and the frame could never accumulate two. + const handedOut: FakeConnection[] = []; let factoryCalls = 0; const session = await QwpIngressSession.connect( async () => { factoryCalls++; - if (factoryCalls === 1) return first; - if (factoryCalls === 2) throw new Error("connection refused"); - if (factoryCalls === 3) return second; - if (factoryCalls === 4) return healthy; - throw new Error("no connection available"); + if (factoryCalls % 2 === 0) throw new Error("connection refused"); + const connection = new FakeConnection(`terminating-${factoryCalls}`); + handedOut.push(connection); + return connection; }, { reconnect: { - maxAttempts: 2, + maxAttempts: 20, + maxDurationMs: 0, maxFrameRejections: 2, poisonMinEscalationWindowMs: 0, initialBackoffMs: 0, @@ -2849,19 +2862,76 @@ describe("QWP ingress reconnect and replay", () => { }, ); const pending = session.sendFrame(Uint8Array.of(9)); - await vi.waitFor(() => expect(first.sent).toHaveLength(1)); - first.drop(); - await vi.waitFor(() => expect(second.sent).toHaveLength(1)); - second.drop(); - await vi.waitFor(() => expect(healthy.sent).toHaveLength(1)); - healthy.receive(ingressResponse(QWP_STATUS.OK, 0n)); + const dropNext = async (index: number) => { + await vi.waitFor(() => { + expect(handedOut).toHaveLength(index + 1); + expect(handedOut[index].sent).toHaveLength(1); + }); + handedOut[index].drop(); + }; + await dropNext(0); + await dropNext(1); - await expect(pending).resolves.toMatchObject({ - status: QWP_STATUS.OK, - sequence: 0n, - }); - expect(factoryCalls).toBe(4); - await session.close(); + await expect(pending).rejects.toBeInstanceOf(QwpProtocolError); + // Exactly two strikes were needed; no third connection was handed out. + expect(handedOut).toHaveLength(2); + await session.close().catch(() => undefined); + }); + + it("withholds connection-outage time from the poison escalation window", async () => { + // The window exists to prove a rejection persists while the client can + // actually reach a server. Time spent unable to connect must not count + // toward it, or an outage alone would satisfy the dwell. + vi.useFakeTimers({ toFake: ["Date"] }); + try { + const first = new FakeConnection("terminating-1"); + const second = new FakeConnection("terminating-2"); + const healthy = new FakeConnection("healthy"); + let factoryCalls = 0; + const session = await QwpIngressSession.connect( + async () => { + factoryCalls++; + if (factoryCalls === 1) return first; + if (factoryCalls === 2) throw new Error("connection refused"); + if (factoryCalls === 3) { + // Age the clock while the outage is open, so the elapsed time is + // banked as outage rather than counted as connected dwell. + vi.setSystemTime(Date.now() + 30_000); + return second; + } + return healthy; + }, + { + reconnect: { + maxAttempts: 5, + // The simulated outage advances the clock past the default + // 30s reconnect budget, which is not what this test is about. + maxDurationMs: 0, + maxFrameRejections: 2, + poisonMinEscalationWindowMs: 10_000, + initialBackoffMs: 0, + maxBackoffMs: 0, + }, + }, + ); + const pending = session.sendFrame(Uint8Array.of(9)); + await vi.waitFor(() => expect(first.sent).toHaveLength(1)); + first.drop(); + await vi.waitFor(() => expect(second.sent).toHaveLength(1)); + // Second strike: 30s of wall clock has passed, but all of it was the + // outage, so the connected dwell is still under the 10s window. + second.drop(); + await vi.waitFor(() => expect(healthy.sent).toHaveLength(1)); + healthy.receive(ingressResponse(QWP_STATUS.OK, 0n)); + + await expect(pending).resolves.toMatchObject({ + status: QWP_STATUS.OK, + sequence: 0n, + }); + await session.close(); + } finally { + vi.useRealTimers(); + } }); it("does not reconnect after a malformed ingress response", async () => { From 42ab02c412313c3b82c5d8ec9ff03564f5e0b010 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 2 Sep 2026 12:51:08 +0100 Subject: [PATCH 231/265] fix(qwp): reject an over-wide row when it is staged, not at flush 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) Claude-Session: https://claude.ai/code/session_01B5WuAdKhXeFBSS9mswuZgG --- packages/client-core/src/_qwp/sender.ts | 19 +++++++++++ test/qwp/sender.test.ts | 45 +++++++++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/packages/client-core/src/_qwp/sender.ts b/packages/client-core/src/_qwp/sender.ts index 3bbc569..689de7d 100644 --- a/packages/client-core/src/_qwp/sender.ts +++ b/packages/client-core/src/_qwp/sender.ts @@ -2,6 +2,7 @@ import { QWP_COLUMN_TYPE, QWP_MAX_ARRAY_DIMENSION_LENGTH, QWP_MAX_ARRAY_DIMENSIONS, + QWP_MAX_COLUMNS_PER_TABLE, QwpColumnType, QwpIngressEncodeOptions, QwpIngressResponse, @@ -1942,6 +1943,13 @@ export class QwpSender { if (entries.length === 0) { throw new TypeError("QWP writer schema must contain at least one column"); } + // Reject an over-wide schema when it is compiled rather than on the first + // flush that tries to encode a row from it. + if (entries.length > QWP_MAX_COLUMNS_PER_TABLE) { + throw new TypeError( + `QWP writer schema exceeds the maximum of ${QWP_MAX_COLUMNS_PER_TABLE} columns [received=${entries.length}]`, + ); + } const columns: CompiledQwpWriterColumn[] = []; const inputNames = new Set(); @@ -2218,6 +2226,17 @@ export class QwpSender { metadata = { ...metadata, decimalScale: existingSchema.decimalScale }; } if (this.currentRow.has(nameKey)) return this; + if (!existingSchema && table.schema.size >= QWP_MAX_COLUMNS_PER_TABLE) { + // QwpTableBuffer enforces this too, but only once buildTable() runs + // during flush -- and a throw there escapes before releaseStagedRows(), + // so every later flush() and close() hit the same wall and the whole + // staged batch became unreachable. Rejecting the column here discards + // just this row through failRow(), leaving the sender usable and the + // rows staged before it intact. + throw new Error( + `column count exceeds maximum ${QWP_MAX_COLUMNS_PER_TABLE} for table '${table.name}'`, + ); + } const canonicalName = existingSchema?.name ?? name; if (!existingSchema) this.currentRowSchemaKeys.push(nameKey); table.schema.set(nameKey, { name: canonicalName, type, ...metadata }); diff --git a/test/qwp/sender.test.ts b/test/qwp/sender.test.ts index e5811ab..ec1a253 100644 --- a/test/qwp/sender.test.ts +++ b/test/qwp/sender.test.ts @@ -36,6 +36,7 @@ import { int64, ipv4, long, + QWP_MAX_COLUMNS_PER_TABLE, long256, longArray, short, @@ -1098,6 +1099,50 @@ describe("QWP high-level sender", () => { expect(sender.metrics.pendingRows).toBe(1); }); + it("rejects an over-wide row at the column that crosses the cap, not at flush", async () => { + // QwpTableBuffer enforces the 2048-column cap, but only inside + // buildTable() during flush -- and that throw escaped before + // releaseStagedRows(), so every later flush() and close() hit the same + // wall, the sender was permanently unusable, and the rows staged before + // the over-wide one could never be sent. + const session = new RecordingSession(); + const sender = new QwpSender(async () => session, { autoFlush: false }); + + await sender.table("wide").longColumn("keeper", 1n).atNow(); + + sender.table("wide"); + expect(() => { + for (let index = 0; index < QWP_MAX_COLUMNS_PER_TABLE + 8; index++) { + sender.longColumn(`c${index}`, BigInt(index)); + } + }).toThrow(/column count exceeds maximum 2048/); + + // The over-wide row and its table selection are gone; nothing else is. + expect(sender.metrics.pendingRows).toBe(1); + await expect(sender.flush()).resolves.toBe(true); + expect(session.sends).toHaveLength(1); + + // And the sender still works afterwards. + await sender.table("wide").longColumn("keeper", 2n).atNow(); + await expect(sender.flush()).resolves.toBe(true); + expect(session.sends).toHaveLength(2); + await sender.close(); + }); + + it("rejects an over-wide compiled writer schema when it is compiled", async () => { + const session = new RecordingSession(); + const sender = new QwpSender(async () => session, { autoFlush: false }); + const schema: Record> = {}; + for (let index = 0; index < QWP_MAX_COLUMNS_PER_TABLE + 1; index++) { + schema[`c${index}`] = long(); + } + + expect(() => sender.writer("wide", schema)).toThrow( + /exceeds the maximum of 2048 columns/, + ); + await sender.close(); + }); + it("keeps the sender usable after a failed row, without losing staged rows", async () => { const session = new RecordingSession(); const sender = new QwpSender(async () => session, { autoFlush: false }); From 1d4a2280c47cfa43a14ccb84d08d994a097ad8b2 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 2 Sep 2026 12:55:06 +0100 Subject: [PATCH 232/265] perf(qwp): derive each ingress column's encoding once per frame 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) Claude-Session: https://claude.ai/code/session_01B5WuAdKhXeFBSS9mswuZgG --- .../client-core/src/_qwp/_core/ingress.ts | 100 +++++++++++++++--- 1 file changed, 84 insertions(+), 16 deletions(-) diff --git a/packages/client-core/src/_qwp/_core/ingress.ts b/packages/client-core/src/_qwp/_core/ingress.ts index d2a84cf..145dc4a 100644 --- a/packages/client-core/src/_qwp/_core/ingress.ts +++ b/packages/client-core/src/_qwp/_core/ingress.ts @@ -37,10 +37,70 @@ export interface QwpIngressEncodeOptions { deferCommit?: boolean; } +/** + * Per-column work that both encoder passes need, computed once. + * + * Sizing a column and writing it derive the same three things, and deriving + * them twice is not free: the Gorilla path rebuilt the bigint array and ran + * the bit-packing size computation in each pass, then encodeQwpGorilla() ran + * it a third time before encoding. Measured on the repository's own 10k-row + * benchmark workloads that cost 1.8x (trades) to 2.3x (sparse) of total frame + * encode time, for byte-identical output. + */ +interface ColumnPlan { + nullCount: number; + /** Gorilla bytes, or null when the column is written as raw int64s. */ + gorilla?: Uint8Array | null; + /** Inline dictionary, built only when delta symbols are off. */ + inline?: InlineSymbolDictionary; +} + interface ColumnEncodeOptions { gorilla: boolean; deltaSymbols: boolean; dictionary?: QwpSymbolDictionary; + /** + * Scoped to a single encodeQwpIngressFrame() call, so a column mutated + * between calls can never be sized from a stale plan. + */ + plans: Map; +} + +function columnPlan( + column: QwpColumnBuffer, + options: ColumnEncodeOptions, +): ColumnPlan { + let plan = options.plans.get(column); + if (!plan) { + plan = { nullCount: nullCount(column) }; + options.plans.set(column, plan); + } + return plan; +} + +/** Gorilla bytes for a timestamp column, or null when it stays uncompressed. */ +function plannedGorilla( + column: QwpColumnBuffer, + options: ColumnEncodeOptions, +): Uint8Array | null { + const plan = columnPlan(column, options); + if (plan.gorilla === undefined) { + const timestamps = column.values.map((value) => BigInt(value as bigint)); + plan.gorilla = + timestamps.length > 2 && qwpGorillaSize(timestamps) > 0 + ? encodeQwpGorilla(timestamps) + : null; + } + return plan.gorilla; +} + +function plannedInlineSymbols( + column: QwpColumnBuffer, + options: ColumnEncodeOptions, +): InlineSymbolDictionary { + const plan = columnPlan(column, options); + plan.inline ??= inlineSymbolDictionary(column.values); + return plan.inline; } export interface QwpIngressTableResult { @@ -207,7 +267,9 @@ function columnPayloadSize( options: ColumnEncodeOptions, ): number { let size = 1; - if (nullCount(column) > 0) size += Math.ceil(rowCount / 8); + if (columnPlan(column, options).nullCount > 0) { + size += Math.ceil(rowCount / 8); + } const valueCount = column.values.length; if (column.type === QWP_COLUMN_TYPE.BOOLEAN) { @@ -227,9 +289,8 @@ function columnPayloadSize( column.type === QWP_COLUMN_TYPE.TIMESTAMP_NANOS ) { if (!options.gorilla) return size + valueCount * 8; - const timestamps = column.values.map((value) => BigInt(value as bigint)); - const gorillaSize = timestamps.length > 2 ? qwpGorillaSize(timestamps) : -1; - return size + 1 + (gorillaSize > 0 ? gorillaSize : valueCount * 8); + const gorilla = plannedGorilla(column, options); + return size + 1 + (gorilla ? gorilla.byteLength : valueCount * 8); } const width = fixedWidth(column.type); @@ -242,7 +303,7 @@ function columnPayloadSize( } return size; } - const { entries, rowIds } = inlineSymbolDictionary(column.values); + const { entries, rowIds } = plannedInlineSymbols(column, options); size += qwpVarintSize(entries.length); for (const entry of entries) size += qwpStringSize(entry); for (const id of rowIds) size += qwpVarintSize(id); @@ -317,8 +378,9 @@ function writeNullHeader( writer: QwpByteWriter, column: QwpColumnBuffer, rowCount: number, + options: ColumnEncodeOptions, ): void { - if (nullCount(column) === 0) { + if (columnPlan(column, options).nullCount === 0) { writer.writeUint8(0); return; } @@ -348,7 +410,7 @@ function writeColumn( rowCount: number, options: ColumnEncodeOptions, ): void { - writeNullHeader(writer, column, rowCount); + writeNullHeader(writer, column, rowCount, options); switch (column.type) { case QWP_COLUMN_TYPE.BOOLEAN: { @@ -394,19 +456,21 @@ function writeColumn( return; case QWP_COLUMN_TYPE.TIMESTAMP: case QWP_COLUMN_TYPE.TIMESTAMP_NANOS: { - const timestamps = column.values.map((value) => BigInt(value as bigint)); if (!options.gorilla) { - for (const timestamp of timestamps) writer.writeBigInt64(timestamp); + for (const value of column.values) { + writer.writeBigInt64(BigInt(value as bigint)); + } return; } - const gorillaSize = - timestamps.length > 2 ? qwpGorillaSize(timestamps) : -1; - if (gorillaSize > 0) { + const gorilla = plannedGorilla(column, options); + if (gorilla) { writer.writeUint8(QWP_ENCODING_GORILLA); - writer.writeBytes(encodeQwpGorilla(timestamps)); + writer.writeBytes(gorilla); } else { writer.writeUint8(QWP_ENCODING_UNCOMPRESSED); - for (const timestamp of timestamps) writer.writeBigInt64(timestamp); + for (const value of column.values) { + writer.writeBigInt64(BigInt(value as bigint)); + } } return; } @@ -430,7 +494,7 @@ function writeColumn( } return; } - const { entries, rowIds } = inlineSymbolDictionary(column.values); + const { entries, rowIds } = plannedInlineSymbols(column, options); writeQwpVarint(writer, entries.length); for (const entry of entries) writeQwpString(writer, entry); for (const id of rowIds) writeQwpVarint(writer, id); @@ -597,10 +661,14 @@ function encodeQwpIngressFrameInternal( const dictionaryEntries = deltaSymbols ? options.dictionary!.entriesFrom(deltaStart) : []; - const columnOptions = { + const columnOptions: ColumnEncodeOptions = { gorilla, deltaSymbols, dictionary: options.dictionary, + // Shared by the sizing pass below and the write pass further down, so + // each column derives its null count, Gorilla bytes and inline symbol + // dictionary exactly once per frame. + plans: new Map(), }; let flags = 0; From 8c2fefd877530b019bd897539c17223a0fda7b0c Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 2 Sep 2026 14:36:16 +0100 Subject: [PATCH 233/265] fix(qwp): close two holes in the row-discard contract 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) Claude-Session: https://claude.ai/code/session_01B5WuAdKhXeFBSS9mswuZgG --- packages/client-core/src/_qwp/sender.ts | 27 +++++++++- test/qwp/sender.test.ts | 69 +++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 2 deletions(-) diff --git a/packages/client-core/src/_qwp/sender.ts b/packages/client-core/src/_qwp/sender.ts index 689de7d..456c955 100644 --- a/packages/client-core/src/_qwp/sender.ts +++ b/packages/client-core/src/_qwp/sender.ts @@ -1365,7 +1365,21 @@ export class QwpSender { new TypeError("binaryColumn accepts only Uint8Array values"), ); } - return this.addColumn(name, QWP_COLUMN_TYPE.BINARY, new Uint8Array(value)); + try { + // The defensive copy is the one conversion here that can throw, and as + // an argument expression it ran before addColumn()'s own try. A view + // whose ArrayBuffer has been transferred away -- structuredClone with + // `transfer`, or worker.postMessage -- therefore escaped past failRow() + // and left the sender inside a half-built row that the next + // at()/atNow() published. Copy inside the guard, as uuidColumn() does. + return this.addColumn( + name, + QWP_COLUMN_TYPE.BINARY, + new Uint8Array(value), + ); + } catch (error) { + return this.failRow(error); + } } charColumn(name: string, value: string | null | undefined): QwpSender { @@ -1485,6 +1499,16 @@ export class QwpSender { new RangeError("decimal scale must be between 0 and 76"), ); } + // An empty Int8Array is the documented byte-array spelling of NULL, so it + // has to be checked like the null spelling. Returning from inside the try + // below skipped addColumn(), which is where a non-nullish call gets its + // availability, row-state and column-name checks -- so this one spelling + // silently accepted a misspelled name, a non-string name and a call made + // before table(), exactly the hole omitsNullish() exists to close. + if (unscaled instanceof Int8Array && unscaled.length === 0) { + this.omitsNullish(name, null); + return this; + } if (this.omitsNullish(name, unscaled)) return this; try { if (typeof unscaled !== "bigint" && !(unscaled instanceof Int8Array)) { @@ -1496,7 +1520,6 @@ export class QwpSender { "decimalColumn accepts only bigint or Int8Array values", ); } - if (unscaled instanceof Int8Array && unscaled.length === 0) return this; if (unscaled instanceof Int8Array && unscaled.length > 32) { throw new RangeError("decimal unscaled value cannot exceed 32 bytes"); } diff --git a/test/qwp/sender.test.ts b/test/qwp/sender.test.ts index ec1a253..9b009fd 100644 --- a/test/qwp/sender.test.ts +++ b/test/qwp/sender.test.ts @@ -1099,6 +1099,75 @@ describe("QWP high-level sender", () => { expect(sender.metrics.pendingRows).toBe(1); }); + it("validates the call site for the empty-Int8Array spelling of a NULL decimal", async () => { + // An empty Int8Array is the documented byte-array spelling of NULL, and it + // used to return before addColumn(), where a non-nullish call gets its + // availability, row-state and column-name checks. That made this one + // spelling silently accept call sites every other spelling rejects. + const session = new RecordingSession(); + const sender = new QwpSender(async () => session, { autoFlush: false }); + + // No table selected yet. + expect(() => sender.decimalColumn("d", new Int8Array(0), 2)).toThrow( + /table name must be set before adding columns/, + ); + + sender.table("fx"); + expect(() => sender.decimalColumn("bad.name", new Int8Array(0), 2)).toThrow( + /column name contains illegal characters/, + ); + + sender.table("fx"); + expect(() => + sender.decimalColumn(42 as unknown as string, new Int8Array(0), 2), + ).toThrow(/column name must be a string/); + + // The null spelling has always behaved this way; the two now agree. + sender.table("fx"); + expect(() => sender.decimalColumn("bad.name", null, 2)).toThrow( + /column name contains illegal characters/, + ); + + // A well-formed call still omits the column. + await sender + .table("fx") + .decimalColumn("kept", 12_345n, 2) + .decimalColumn("absent", new Int8Array(0), 2) + .atNow(); + await sender.flush(); + const [table] = session.sends[0].tables; + expect(table.columns.map((candidate) => candidate.name)).toEqual(["kept"]); + await sender.close(); + }); + + it("discards the row when a binary value cannot be copied", async () => { + // new Uint8Array(value) was an argument expression, so it ran before + // addColumn()'s try. A detached buffer therefore threw past failRow() and + // left the sender inside a half-built row that the next atNow() published. + const session = new RecordingSession(); + const sender = new QwpSender(async () => session, { autoFlush: false }); + + await sender.table("events").longColumn("value", 1n).atNow(); + + const detached = new Uint8Array([1, 2, 3]); + structuredClone(detached, { transfer: [detached.buffer] }); + sender.table("events").longColumn("value", 2n); + expect(() => sender.binaryColumn("payload", detached)).toThrow( + /detached ArrayBuffer/, + ); + + // The row in progress and its table selection are gone, so the sender is + // ready for the next row rather than stuck inside the failed one. + expect(() => sender.table("events")).not.toThrow(); + await sender.longColumn("value", 3n).atNow(); + await sender.flush(); + + const [table] = session.sends[0].tables; + expect(table.rowCount).toBe(2); + expect(column(table, "value").values).toEqual([1n, 3n]); + await sender.close(); + }); + it("rejects an over-wide row at the column that crosses the cap, not at flush", async () => { // QwpTableBuffer enforces the 2048-column cap, but only inside // buildTable() during flush -- and that throw escaped before From db181f3d46c1a481ec4dc33f2565bc289d420f80 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 2 Sep 2026 14:38:35 +0100 Subject: [PATCH 234/265] fix(qwp): release a decimal column's scale lock once its rows are published 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) Claude-Session: https://claude.ai/code/session_01B5WuAdKhXeFBSS9mswuZgG --- QWP.md | 5 ++- packages/client-core/src/_qwp/sender.ts | 13 +++++++ test/qwp/sender.test.ts | 49 +++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 1 deletion(-) diff --git a/QWP.md b/QWP.md index e2817b4..7cb9a56 100644 --- a/QWP.md +++ b/QWP.md @@ -646,7 +646,10 @@ ambiguity: `float32()`/`float64()` and `int32()`/`int64()` mean exactly what the Geohash precision and decimal scale belong to the column, not the value, so they are fixed when the schema is compiled and validated against the sender's staged schema on -every append. Decimal text and `{ unscaled, scale }` values are rescaled to the +every append. On the fluent row API there is no schema to fix them, so a decimal +column instead locks its scale on the first value staged for it and holds that lock +for the rest of the frame; the lock is released once those rows are published, so the +next frame's first value sets it afresh. Decimal text and `{ unscaled, scale }` values are rescaled to the column's scale when that is exact, and rejected when it would round: at `decimal64(2)`, `"1.50"` stages as `150n` and `"1.005"` raises `QwpWriterRowError`. Base-32 geohash text carries five bits per character, so `geohash(20)` accepts diff --git a/packages/client-core/src/_qwp/sender.ts b/packages/client-core/src/_qwp/sender.ts index 456c955..b0e3183 100644 --- a/packages/client-core/src/_qwp/sender.ts +++ b/packages/client-core/src/_qwp/sender.ts @@ -2341,6 +2341,19 @@ export class QwpSender { } for (const { table, rows } of snapshots) { table.rows.splice(0, rows.length); + // A QWP column carries one decimal scale per frame, which is why the + // first value locks it. Once every row that locked it has been + // published there is nothing left for a later row to be uniform with, + // so the lock has to go too -- it is frame state, like the Java + // client's ColumnBuffer, not table state. Keeping it for the sender's + // lifetime meant a stream whose first decimal happened to be integral + // rejected every more precise value it ever saw afterwards, while the + // same rows over ILP v3 were accepted. + if (table.rows.length === 0) { + for (const [key, column] of table.schema) { + if (isDecimalType(column.type)) table.schema.delete(key); + } + } } const rowCount = snapshots.reduce( (count, item) => count + item.rows.length, diff --git a/test/qwp/sender.test.ts b/test/qwp/sender.test.ts index 9b009fd..2657885 100644 --- a/test/qwp/sender.test.ts +++ b/test/qwp/sender.test.ts @@ -1099,6 +1099,55 @@ describe("QWP high-level sender", () => { expect(sender.metrics.pendingRows).toBe(1); }); + it("relocks a decimal column's scale once its rows have been published", async () => { + // A QWP column carries one decimal scale per frame, so the first value + // locks it -- but the lock lived in table.schema, which releaseStagedRows() + // never cleared. A stream whose first decimal happened to be integral + // therefore rejected every more precise value for the sender's lifetime, + // while ILP v3 accepted the same sequence. + const session = new RecordingSession(); + const sender = new QwpSender(async () => session, { autoFlush: false }); + + await sender.table("fx").decimalColumnText("price", 1).atNow(); + await sender.flush(); + + await sender.table("fx").decimalColumnText("price", 1.5).atNow(); + await sender.flush(); + + expect(session.sends).toHaveLength(2); + expect(column(session.sends[0].tables[0], "price").values).toEqual([1n]); + const second = column(session.sends[1].tables[0], "price"); + expect(second.values).toEqual([15n]); + expect(second.decimalScale).toBe(1); + await sender.close(); + }); + + it("keeps one decimal scale across rows that share a frame", async () => { + // Within a frame the scale still locks on the first value and later rows + // are rescaled onto it, because the wire format can only carry one. + const session = new RecordingSession(); + const sender = new QwpSender(async () => session, { autoFlush: false }); + + await sender.table("fx").decimalColumnText("price", 1.25).atNow(); + await sender.table("fx").decimalColumnText("price", 2).atNow(); + await sender.flush(); + + const staged = column(session.sends[0].tables[0], "price"); + expect(staged.decimalScale).toBe(2); + expect(staged.values).toEqual([125n, 200n]); + + // And within the next frame a value that cannot be rescaled without loss + // still fails its row rather than silently changing the column's scale. + sender.table("fx").decimalColumnText("price", 1); + expect(() => sender.decimalColumnText("other", 1)).not.toThrow(); + await sender.atNow(); + sender.table("fx"); + expect(() => sender.decimalColumnText("price", 1.5)).toThrow( + /cannot rescale decimal/, + ); + await sender.close(); + }); + it("validates the call site for the empty-Int8Array spelling of a NULL decimal", async () => { // An empty Int8Array is the documented byte-array spelling of NULL, and it // used to return before addColumn(), where a non-nullish call gets its From e662f86ebd157af3f0608658dddaed85ab8731db Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 2 Sep 2026 14:40:47 +0100 Subject: [PATCH 235/265] fix(qwp): await the aborted connection's teardown in close() 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) Claude-Session: https://claude.ai/code/session_01B5WuAdKhXeFBSS9mswuZgG --- packages/client-core/src/_qwp/sender.ts | 21 +++++++++++++++--- test/qwp/sender-node-integration.test.ts | 27 ++++++------------------ 2 files changed, 25 insertions(+), 23 deletions(-) diff --git a/packages/client-core/src/_qwp/sender.ts b/packages/client-core/src/_qwp/sender.ts index b0e3183..6fb2a83 100644 --- a/packages/client-core/src/_qwp/sender.ts +++ b/packages/client-core/src/_qwp/sender.ts @@ -1888,9 +1888,24 @@ export class QwpSender { // than keeping the event loop alive until the connect timeout fires, // and still attach cleanup in case it had already connected. this.connectAbort.abort(); - void this.sessionPromise - .then((connected) => connected.close()) - .catch(() => undefined); + // Await that teardown. Firing it and returning meant close() resolved + // while the connection it had just aborted was still releasing the + // store-and-forward advisory lock, its journal handles and its socket, + // so reopening the same sf_dir immediately after `await close()` failed + // with QwpReplayStoreLockedError naming this very process. The abort + // makes the unwind quick, but it is still bounded here so a factory + // that ignores the signal cannot make close() wait out its connect + // timeout. + try { + await this.withCloseDeadline( + this.sessionPromise + .then((connected) => connected.close()) + .catch(() => undefined), + publishDeadline, + ); + } catch (error) { + closeError ??= error; + } } if (this.pendingRowCount > 0 || this.currentRow.size > 0) { diff --git a/test/qwp/sender-node-integration.test.ts b/test/qwp/sender-node-integration.test.ts index 807f350..a90ce20 100644 --- a/test/qwp/sender-node-integration.test.ts +++ b/test/qwp/sender-node-integration.test.ts @@ -344,26 +344,13 @@ describe("Sender QWP integration", () => { await vi.waitFor(() => expect(sockets.size).toBe(1)); await sender.close(); - // The lock may outlive close() by an in-flight load, but not by the - // connect budget -- three seconds is an order of magnitude under the 30s - // configured here and far above a load. - const deadline = Date.now() + 3_000; - let reopened = false; - let lastError: unknown; - while (!reopened && Date.now() < deadline) { - const probe = new QwpNodeFileReplayStore({ - directory: join(directory, "default"), - }); - try { - await probe.load(); - await probe.close(); - reopened = true; - } catch (error) { - lastError = error; - await new Promise((resolve) => setTimeout(resolve, 25)); - } - } - expect(reopened, `slot still locked: ${lastError}`).toBe(true); + // close() awaits the aborted connection's own teardown, so the slot is + // free the moment it returns -- no polling window. + const probe = new QwpNodeFileReplayStore({ + directory: join(directory, "default"), + }); + await probe.load(); + await probe.close(); } finally { for (const socket of sockets) socket.destroy(); await new Promise((resolve) => stalled.close(() => resolve())); From 2f3d6a7e5a052810e7b8ccad7212c3effff9cedd Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 2 Sep 2026 14:43:01 +0100 Subject: [PATCH 236/265] fix(qwp): keep a pooled client usable after a failed connect 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) Claude-Session: https://claude.ai/code/session_01B5WuAdKhXeFBSS9mswuZgG --- QWP.md | 5 ++- packages/client-core/src/_qwp/client.ts | 33 +++++++++++------ test/qwp/client.test.ts | 48 +++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 11 deletions(-) diff --git a/QWP.md b/QWP.md index 7cb9a56..684323a 100644 --- a/QWP.md +++ b/QWP.md @@ -1374,7 +1374,10 @@ form with complete `ingress` and `egress` trees remains supported for advanced cases that intentionally connect the two sides differently. `connectQwpNodeClient()` and `connectQwpBrowserClient()` prewarm each configured -pool minimum. Their `createQwp*Client()` counterparts are lazy. Pools grow to +pool minimum. Their `createQwp*Client()` counterparts are lazy. A prewarm that +fails rejects but does not close the client: connections it did establish stay +pooled, and calling `connect()` again makes a fresh attempt, so a transient +outage at start-up can be retried rather than requiring a new client. Pools grow to their maximum under concurrent borrows and apply one FIFO acquisition deadline; exhaustion raises `QwpPoolAcquireTimeoutError`. Query handles are single-flight, but separate borrowed handles run concurrently. Returning a handle with an active diff --git a/packages/client-core/src/_qwp/client.ts b/packages/client-core/src/_qwp/client.ts index 29be3ba..a27e592 100644 --- a/packages/client-core/src/_qwp/client.ts +++ b/packages/client-core/src/_qwp/client.ts @@ -633,7 +633,18 @@ export class QwpClient { /** Pre-connects the configured minimum sender and query pool sizes. */ connect(): Promise { - if (!this.connectPromise) this.connectPromise = this.connectNow(); + if (!this.connectPromise) { + const attempt = this.connectNow(); + this.connectPromise = attempt; + // A failed prewarm is not a terminal state -- the endpoint may simply + // have been unavailable for a moment during a rolling restart or an LB + // warm-up. Forget the attempt so a retry starts a new one; memoizing it + // meant every later connect() replayed the first rejection without ever + // reaching the server again. The caller still sees this rejection. + attempt.catch(() => { + if (this.connectPromise === attempt) this.connectPromise = undefined; + }); + } return this.connectPromise; } @@ -680,15 +691,17 @@ export class QwpClient { private async connectNow(): Promise { this.throwIfUnavailable(); - try { - await this.ensureStarted(); - this.throwIfUnavailable(); - await Promise.all([this.senderPool.prewarm(), this.queryPool.prewarm()]); - return this; - } catch (error) { - await this.close(); - throw error; - } + await this.ensureStarted(); + this.throwIfUnavailable(); + // prewarm() releases every entry it did manage to acquire back into the + // pool before it rethrows, so a partial prewarm leaves healthy warm + // entries rather than half-built ones. Closing the client here to tidy + // them up therefore destroyed a recoverable client over a transient + // outage: close() latches closing/closed irreversibly, so every later + // borrowSender()/borrowQuery() threw QwpClientClosedError and the object + // had to be rebuilt. Let the rejection reach the caller instead. + await Promise.all([this.senderPool.prewarm(), this.queryPool.prewarm()]); + return this; } private async closeNow(): Promise { diff --git a/test/qwp/client.test.ts b/test/qwp/client.test.ts index fc6e313..faf9c08 100644 --- a/test/qwp/client.test.ts +++ b/test/qwp/client.test.ts @@ -227,6 +227,54 @@ describe("QWP pooled client", () => { expect(releases).toBe(1); }); + it("stays usable after a failed connect and retries on the next one", async () => { + // connectNow() closed the whole client when prewarm rejected, and + // connect() memoized the rejected promise. Between them, a single + // transient outage during prewarm -- a rolling restart, an LB warm-up, a + // 502 from a proxy -- destroyed the client: the retry replayed the old + // rejection without reaching the server, and every borrow afterwards + // threw QwpClientClosedError. + let reachable = false; + let senderCreations = 0; + const client = new QwpClient( + { + createSender: async () => { + senderCreations++; + if (!reachable) throw new Error("connection refused"); + const session = new FakeSenderSession(); + const sender = new QwpSender(async () => session, { + autoFlush: false, + }); + await sender.connect(); + return sender; + }, + createQuerySession: async () => { + throw new Error("query factory should not run"); + }, + }, + { + senderPoolMin: 1, + senderPoolMax: 1, + queryPoolMin: 0, + queryPoolMax: 1, + acquireTimeoutMs: 500, + }, + ); + + await expect(client.connect()).rejects.toThrow(/connection refused/); + expect(client.metrics).toMatchObject({ closing: false, closed: false }); + const attemptsAfterFailure = senderCreations; + + // The endpoint comes back; the retry actually reaches it. + reachable = true; + await expect(client.connect()).resolves.toBe(client); + expect(senderCreations).toBeGreaterThan(attemptsAfterFailure); + + const sender = await client.borrowSender(); + await sender.close(); + await client.close(); + }); + it("validates idle, lifetime, and housekeeping options", () => { const factories = { createSender: async () => { From cdd8e8918fd1b302eb487279f06ae23fb2ac6e14 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 2 Sep 2026 14:45:00 +0100 Subject: [PATCH 237/265] fix(qwp): keep requestDurableAck off the egress upgrade 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) Claude-Session: https://claude.ai/code/session_01B5WuAdKhXeFBSS9mswuZgG --- .../src/qwp-node/client-config.ts | 8 +++++- packages/nodejs-client/src/qwp.ts | 9 +++++++ test/qwp/node-client-config.test.ts | 27 +++++++++++++++++++ 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/packages/nodejs-client/src/qwp-node/client-config.ts b/packages/nodejs-client/src/qwp-node/client-config.ts index c0b08df..a7a7dc9 100644 --- a/packages/nodejs-client/src/qwp-node/client-config.ts +++ b/packages/nodejs-client/src/qwp-node/client-config.ts @@ -158,8 +158,14 @@ export function resolveQwpNodeClientConfig( ); } + // requestDurableAck is ingress-only and the ingress block below sets it + // explicitly. Leaving it in the shared block spread it into `egress` too, + // so a typed `webSocket: { requestDurableAck: true }` override made every + // pooled query session fail its /read/v1 capability check. + const sharedWebSocket = { ...extraOptions.webSocket }; + delete sharedWebSocket.requestDurableAck; const common = { - ...extraOptions.webSocket, + ...sharedWebSocket, connectTimeoutMs: extraOptions.webSocket?.connectTimeoutMs ?? optionalPositiveInteger(value("connect_timeout"), "connect_timeout"), diff --git a/packages/nodejs-client/src/qwp.ts b/packages/nodejs-client/src/qwp.ts index 6167135..cbfdbcd 100644 --- a/packages/nodejs-client/src/qwp.ts +++ b/packages/nodejs-client/src/qwp.ts @@ -229,6 +229,11 @@ export interface QwpNodeWebSocketOptions extends QwpWebSocketConnectOptions { authorization?: string; clientId?: string; maxVersion?: number; + /** + * Ingress-only. Durable ACK is negotiated on `/write/v4`; egress ignores it + * and egressTransportOptions() strips it, because sending the header on + * `/read/v1` makes every query session fail the capability check. + */ requestDurableAck?: boolean; /** Test hook; defaults to the Node-only `ws` implementation. */ webSocketFactory?: ( @@ -374,6 +379,10 @@ function egressTransportOptions( delete transport.maxBatchRows; delete transport.target; delete transport.zone; + // Ingress-only: /read/v1 never answers with x-qwp-durable-ack, so leaving it + // set would make connectQwpNodeEndpoint() reject every query session with + // QwpDurableAckUnavailableError. + delete transport.requestDurableAck; const preference = compression ?? "raw"; const acceptEncoding = encodeQwpAcceptEncoding(preference, compressionLevel); diff --git a/test/qwp/node-client-config.test.ts b/test/qwp/node-client-config.test.ts index 78e6a37..3feb5ba 100644 --- a/test/qwp/node-client-config.test.ts +++ b/test/qwp/node-client-config.test.ts @@ -482,6 +482,33 @@ describe("QWP unified Node client configuration", () => { }); }); + it("keeps requestDurableAck on ingress when it is set as a shared override", () => { + // Durable ACK is negotiated on /write/v4 only. The typed webSocket block + // is spread into both sides, so this override also reached egress, whose + // upgrade then demanded an x-qwp-durable-ack response header that + // /read/v1 never sends -- every pooled query session failed to connect + // with QwpDurableAckUnavailableError while ingress worked fine. + const overridden = parseQwpNodeClientConfig("ws::addr=localhost:9000;", { + webSocket: { requestDurableAck: true }, + }); + expect(overridden.ingress.requestDurableAck).toBe(true); + expect(overridden.egress.requestDurableAck).toBeUndefined(); + + // The connect-string key has always been ingress-only; the two agree now. + const fromString = parseQwpNodeClientConfig( + "ws::addr=localhost:9000;request_durable_ack=on;", + ); + expect(fromString.ingress.requestDurableAck).toBe(true); + expect(fromString.egress.requestDurableAck).toBeUndefined(); + + // Other shared webSocket overrides still reach both sides. + const shared = parseQwpNodeClientConfig("ws::addr=localhost:9000;", { + webSocket: { requestDurableAck: true, clientId: "probe" }, + }); + expect(shared.ingress.clientId).toBe("probe"); + expect(shared.egress.clientId).toBe("probe"); + }); + it("validates cluster authorities and supports bracketed IPv6", () => { const options = parseQwpNodeClientConfig( "ws::addr=[::1],[2001:db8::2]:9443;sender_pool_min=0;query_pool_min=0;", From e1018a1a535fbcd2fd2efe45c324bba8f79b682e Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 2 Sep 2026 14:47:02 +0100 Subject: [PATCH 238/265] docs(qwp): correct seven claims that did not match the code 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-`. 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) Claude-Session: https://claude.ai/code/session_01B5WuAdKhXeFBSS9mswuZgG --- QWP.md | 67 +++++++++++++++++-------------- packages/nodejs-client/src/qwp.ts | 8 +++- test/qwp/config-docs.test.ts | 25 ++++++++++++ 3 files changed, 68 insertions(+), 32 deletions(-) diff --git a/QWP.md b/QWP.md index 684323a..6bf86a6 100644 --- a/QWP.md +++ b/QWP.md @@ -69,7 +69,7 @@ keys owned only by egress or the pooled facade are accepted as intentional no-op Every `ws::`/`wss::` connect string is parsed by one schema, shared with the other QuestDB clients, whichever entry point builds the client — `Sender.fromConfig()`, `SenderOptions.fromConfig()`, `connectQwpNodeClient()`, -or `connectQwpNodeQuery()`. An unrecognised key is rejected with +or `connectQwpNodeEgress()`. An unrecognised key is rejected with `unknown configuration key: `; a legacy ILP key adds a hint pointing at where it applies instead. @@ -141,17 +141,17 @@ Setting `sf_dir` turns on the persistent journal; the rest tune it. A default shown as a dash is applied downstream of the connect string, by the sender or session that consumes it. -| Key | Value | Default | Meaning | -| --------------------------- | ------------------------------ | ------------- | ----------------------------------------------------------------- | -| `sf_dir` | path | — | Journal directory. Enables store-and-forward. | -| `sf_durability` | `memory`, `periodic`, `append` | `memory` | Local durability barrier after each vectored append. | -| `sf_max_total_bytes` | integer bytes | `10737418240` | Journal ceiling. Reaching it is the one error a producer sees. | -| `sf_max_segment_bytes` | integer bytes | `4194304` | Size of one segment file. | -| `sf_sync_interval_millis` | integer ms | — | Checkpoint interval when `sf_durability=periodic`. | -| `sf_append_deadline_millis` | integer ms | `30000` | How long an append waits for space or a retryable journal fault. | -| `initial_connect_retry` | `off`, `sync`, `async` | `off` | Startup policy when the server is unreachable. Requires `sf_dir`. | -| `drain_orphans` | `on`, `off` | off | Adopt and drain journals left by crashed producers. | -| `max_background_drainers` | integer | — | Concurrent orphan drainers. | +| Key | Value | Default | Meaning | +| --------------------------- | ------------------------------ | ------------- | -------------------------------------------------------------------------------------------------------------------- | +| `sf_dir` | path | — | Journal directory. Enables store-and-forward. | +| `sf_durability` | `memory`, `periodic`, `append` | `memory` | Local durability barrier after each vectored append. | +| `sf_max_total_bytes` | integer bytes | `10737418240` | Journal ceiling. Reaching it is the one error a producer sees. | +| `sf_max_segment_bytes` | integer bytes | `4194304` | Size of one segment file. Also caps one ingress frame, including without `sf_dir`, since a frame must fit a segment. | +| `sf_sync_interval_millis` | integer ms | — | Checkpoint interval when `sf_durability=periodic`. | +| `sf_append_deadline_millis` | integer ms | `30000` | How long an append waits for space or a retryable journal fault. | +| `initial_connect_retry` | `off`, `sync`, `async` | `off` | Startup policy when the server is unreachable. Requires `sf_dir`. | +| `drain_orphans` | `on`, `off` | off | Adopt and drain journals left by crashed producers. | +| `max_background_drainers` | integer | — | Concurrent orphan drainers. | ### Egress @@ -166,20 +166,21 @@ session that consumes it. ### Pool -Applied by the pooled facade; a standalone sender or query client ignores them. - -| Key | Value | Default | Meaning | -| ------------------------- | ----------- | ------- | --------------------------------------------- | -| `sender_pool_min` | integer | — | Senders kept warm. | -| `sender_pool_max` | integer | — | Sender ceiling. | -| `query_pool_min` | integer | — | Query sessions kept warm. | -| `query_pool_max` | integer | — | Query-session ceiling. | -| `acquire_timeout_ms` | integer ms | — | How long `acquire()` waits for a free entry. | -| `query_close_timeout_ms` | integer ms | — | Bound on closing a borrowed query session. | -| `idle_timeout_ms` | integer ms | — | Idle time before a pooled entry is reaped. | -| `max_lifetime_ms` | integer ms | — | Absolute lifetime of a pooled entry. | -| `housekeeper_interval_ms` | integer ms | — | How often the pool reaps aged entries. | -| `lazy_connect` | `on`, `off` | off | Start without blocking on a first connection. | +Applied by the pooled facade. A standalone sender or query client ignores +them, with one exception noted in the table. + +| Key | Value | Default | Meaning | +| ------------------------- | ----------- | ------- | ------------------------------------------------------------------------------------------------------------------------------- | +| `sender_pool_min` | integer | — | Senders kept warm. | +| `sender_pool_max` | integer | — | Sender ceiling. | +| `query_pool_min` | integer | — | Query sessions kept warm. | +| `query_pool_max` | integer | — | Query-session ceiling. | +| `acquire_timeout_ms` | integer ms | — | How long `acquire()` waits for a free entry. | +| `query_close_timeout_ms` | integer ms | — | Bound on the CANCEL drain when a query session closes. Also honoured by a standalone egress session built from `egressSession`. | +| `idle_timeout_ms` | integer ms | — | Idle time before a pooled entry is reaped. | +| `max_lifetime_ms` | integer ms | — | Absolute lifetime of a pooled entry. | +| `housekeeper_interval_ms` | integer ms | — | How often the pool reaps aged entries. | +| `lazy_connect` | `on`, `off` | off | Start without blocking on a first connection. | ### Reserved @@ -1077,7 +1078,10 @@ credit window bounds server read-ahead while application work is in progress. `target` accepts `any` (the default), `primary`, or `replica`. Primary routing also accepts standalone servers and a primary completing catch-up, matching the Java -client. Both keys apply to ingress and egress alike. `zone` is an opaque, case-insensitive preference for `any` and `replica`; +client. Both keys apply to ingress and egress on Node.js. In browsers they apply +to egress only: ingress cannot learn a server's role or zone there, because the +WebSocket API hides the upgrade response that carries them. `zone` is an opaque, +case-insensitive preference for `any` and `replica`; cross-zone endpoints remain eligible. It is ignored for `primary`, which must be followed across zones. The client validates the authoritative role and zone from the first QWP `SERVER_INFO` frame before accepting an endpoint, so the same guarantees @@ -1267,11 +1271,14 @@ const db = await connectQwpNodeClient( ``` For unified strings with `sf_dir`, Java-compatible defaults apply: memory -durability, a 10 GiB total journal cap, 4 MiB frame/segment batches, a 30-second -capacity wait, a 60-second close drain, and fail-fast initial connection. Set +durability, a 10 GiB total journal cap, 4 MiB journal segments, a 30-second +capacity wait, a 5-second close drain, and fail-fast initial connection. Set `sender_id` to name the disk slot base; pooled senders use `-`. +The 4 MiB default sizes journal segments only; it does not install an ingress +frame cap. Set `sf_max_segment_bytes` explicitly, or `qwp.session.maxBatchSizeBytes`, +to bound frames before the first publication tells the client the server's cap. Without `sf_dir`, `sf_max_total_bytes` and `sf_append_deadline_millis` tune the -built-in memory replay queue instead. +built-in memory replay queue instead, and `sf_max_segment_bytes` still caps a frame. The parser also supports `max_name_len` and the Java listener/error inbox capacity keys. Those capacities actively bound asynchronous connection and typed-error delivery and are reflected in ingress drop counters. diff --git a/packages/nodejs-client/src/qwp.ts b/packages/nodejs-client/src/qwp.ts index cbfdbcd..4540576 100644 --- a/packages/nodejs-client/src/qwp.ts +++ b/packages/nodejs-client/src/qwp.ts @@ -259,8 +259,12 @@ export interface QwpNodeIngressOptions */ storeAndForward?: QwpNodeStoreAndForwardOptions; /** - * Slot name below storeAndForward.directory. Unified configurations default - * to `default`; pooled clients derive `-` names. + * Slot name below storeAndForward.directory. + * + * A connect string defaults it to `default`. Through the typed API it has no + * default: a standalone sender writes straight into `directory`, and a + * pooled client derives `sender-` names, or `-` when + * this is set. */ senderId?: string; } diff --git a/test/qwp/config-docs.test.ts b/test/qwp/config-docs.test.ts index 125f6a4..3b9d8d9 100644 --- a/test/qwp/config-docs.test.ts +++ b/test/qwp/config-docs.test.ts @@ -7,6 +7,8 @@ import { type QwpSenderSession, } from "../../packages/client-core/src/qwp"; import { QWP_SUPPORTED_CONFIG_KEYS } from "../../packages/nodejs-client/src/qwp-node/client-config"; +import * as nodeClient from "../../packages/nodejs-client/src"; +import * as browserClient from "../../packages/browser-client/src"; const ROOT = path.resolve( path.dirname(fileURLToPath(import.meta.url)), @@ -14,6 +16,29 @@ const ROOT = path.resolve( ); describe("QWP configuration-string reference", () => { + it("only names entry points the package actually exports", async () => { + // config-docs checked key names but never function names, so QWP.md could + // -- and did -- point readers at a connectQwpNodeQuery() that has never + // existed. Every backticked connect/create entry point it names has to + // resolve against the Node package's public surface. + const doc = await readFile(path.join(ROOT, "QWP.md"), "utf8"); + const named = new Set( + [...doc.matchAll(/`((?:connect|create)Qwp[A-Za-z0-9]*)\(\)`/g)].map( + (match) => match[1], + ), + ); + expect(named.size).toBeGreaterThan(3); + + // QWP.md documents both distributions, so an entry point may live in + // either package root. + const exported = new Set([ + ...Object.keys(nodeClient), + ...Object.keys(browserClient), + ]); + const missing = [...named].filter((name) => !exported.has(name)).sort(); + expect(missing).toEqual([]); + }); + it("documents every key the parser accepts", async () => { // A key the parser takes but QWP.md never names is undiscoverable: the // connect string is the portable spelling shared with the other QuestDB From 760d0e0fb36c7011c6e96a0913240a60f0487e89 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 2 Sep 2026 16:38:36 +0100 Subject: [PATCH 239/265] test(qwp): accept Node 26 detached buffer error --- test/qwp/sender.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/qwp/sender.test.ts b/test/qwp/sender.test.ts index 2657885..735f137 100644 --- a/test/qwp/sender.test.ts +++ b/test/qwp/sender.test.ts @@ -1202,7 +1202,7 @@ describe("QWP high-level sender", () => { structuredClone(detached, { transfer: [detached.buffer] }); sender.table("events").longColumn("value", 2n); expect(() => sender.binaryColumn("payload", detached)).toThrow( - /detached ArrayBuffer/, + /detached(?: or out-of-bounds)? ArrayBuffer/, ); // The row in progress and its table selection are gone, so the sender is From 302c544890f18349d3cadc218c45fe012e43d49e Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 2 Sep 2026 18:25:37 +0100 Subject: [PATCH 240/265] fix(ilp): enforce symbol ordering independently of column values validateSymbolCall() gated on hasColumns, which writeColumn() sets only after it writes. A column whose value was nullish returned before that, so the symbol-before-columns rule lapsed for the rest of the row: table("t").stringColumn("c", null).symbol("s", "v") accepted table("t").stringColumn("c", "x").symbol("s", "v") rejected Emitted bytes were correct either way, so nothing was corrupted, but the same call site raised or stayed silent according to that row's data -- the data-dependent validation validateColumnCall() was introduced to avoid. Before the nullish change every column setter threw on null, so the sequence was unreachable and the gap is new. hasColumns cannot simply be set on the omit path: at()/atNow() read it to decide whether a row can be closed, and a row of entirely nullish values would then close into a bare table name with no fields. Track the call separately instead. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VnqPKyf9FYsuPoA22V7qxj --- packages/nodejs-client/src/buffer/base.ts | 19 ++++++++++++++++++- test/sender.buffer.test.ts | 6 ++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/nodejs-client/src/buffer/base.ts b/packages/nodejs-client/src/buffer/base.ts index 8e87b1a..a3bc742 100644 --- a/packages/nodejs-client/src/buffer/base.ts +++ b/packages/nodejs-client/src/buffer/base.ts @@ -28,6 +28,15 @@ abstract class SenderBufferBase implements SenderBuffer { private hasTable: boolean; private hasSymbols: boolean; private hasColumns: boolean; + /** + * Whether a column setter has been called on the row being built, whether or + * not it wrote anything. Distinct from {@link hasColumns}, which tracks bytes + * in the buffer and therefore decides the field separator and whether the row + * can be closed. Symbol ordering is a property of the call sequence, so it + * must be judged on calls: a column whose value was nullish emits nothing but + * still means the caller has moved past the symbol section. + */ + private hasColumnCall: boolean; private readonly maxNameLength: number; @@ -126,6 +135,7 @@ abstract class SenderBufferBase implements SenderBuffer { this.hasTable = false; this.hasSymbols = false; this.hasColumns = false; + this.hasColumnCall = false; } /** @@ -521,6 +531,7 @@ abstract class SenderBufferBase implements SenderBuffer { throw new Error("Column can be set only after table name is set"); } validateColumnName(name, this.maxNameLength); + this.hasColumnCall = true; } /** @@ -528,13 +539,19 @@ abstract class SenderBufferBase implements SenderBuffer { * The symbol equivalent of {@link validateColumnCall}. Symbols carry an * extra ordering rule: they must precede every column on the row. * + * The rule is enforced against {@link hasColumnCall}, not {@link hasColumns}, + * so it does not depend on this row's data. Testing the written-bytes flag + * let the same call site pass whenever the preceding column happened to be + * nullish and throw whenever it carried a value -- the exact data-dependent + * validation {@link validateColumnCall} exists to avoid. + * * @param name - The symbol name to validate. */ protected validateSymbolCall(name: string): void { if (typeof name !== "string") { throw new Error(`Symbol name must be a string, received ${typeof name}`); } - if (!this.hasTable || this.hasColumns) { + if (!this.hasTable || this.hasColumnCall) { throw new Error( "Symbol can be added only after table name is set and before any column added", ); diff --git a/test/sender.buffer.test.ts b/test/sender.buffer.test.ts index e0a0f21..5dea82d 100644 --- a/test/sender.buffer.test.ts +++ b/test/sender.buffer.test.ts @@ -585,6 +585,12 @@ describe("Sender message builder test suite (anything not covered in client inte expect(() => build().intColumn("i", 1).symbol("s", value)).toThrow( "Symbol can be added only after table name is set and before any column added", ); + // And the rule holds when the preceding column was itself omitted. It is + // a property of the call sequence, so judging it by the bytes written + // made the same call site pass or throw according to this row's data. + expect(() => build().intColumn("i", value).symbol("s", "v")).toThrow( + "Symbol can be added only after table name is set and before any column added", + ); // The scale describes the column, not this row's value. expect(() => build().decimalColumn("d", value, 999)).toThrow( "Scale must be between 0 and 76", From ea4b141aab97f2cacd0db5b95058dc74ce8423a6 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 2 Sep 2026 18:25:47 +0100 Subject: [PATCH 241/265] fix(qwp): keep the close ACK wait observed past its deadline closeNow() evaluates session.waitForAcknowledged() -- registering an ACK waiter and starting the promise -- and only then enters withCloseDeadline(), which re-reads the clock. When the close-time publication has consumed the whole budget, that second read throws before Promise.race() can subscribe, so nothing is listening when session.close() rejects the waiter a moment later. Node reports the unhandled rejection and exits by default, after close() has already rejected with the QwpSenderCloseTimeoutError the caller did handle. Measured at close_flush_timeout_millis=1: 8 unhandled rejections across 1500 closes, none at 2ms or above. The window is the few microseconds between the two clock reads, so any budget can hit it if the publication lands on the deadline. Guard the early return; the Promise.race below already subscribes both operands, so that path was never affected. The fix covers all three withCloseDeadline() call sites rather than only the drain. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VnqPKyf9FYsuPoA22V7qxj --- packages/client-core/src/_qwp/sender.ts | 12 +++++- test/qwp/sender.test.ts | 57 ++++++++++++++++++++++++- 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/packages/client-core/src/_qwp/sender.ts b/packages/client-core/src/_qwp/sender.ts index 6fb2a83..a1a46c7 100644 --- a/packages/client-core/src/_qwp/sender.ts +++ b/packages/client-core/src/_qwp/sender.ts @@ -1948,7 +1948,17 @@ export class QwpSender { ): Promise { if (deadline === undefined) return operation; const remaining = deadline - Date.now(); - if (remaining <= 0) throw this.closeTimeoutError(); + if (remaining <= 0) { + // `operation` is already running: the caller evaluated it to pass it in, + // and an ACK waiter is already registered against the session. Throwing + // without racing it would leave nothing subscribed, so the rejection + // session.close() delivers moments later arrives unhandled -- which Node + // turns into a process exit by default, after close() has already + // reported the timeout the caller did handle. The Promise.race below + // subscribes both operands, so only this early return needs the guard. + void operation.catch(() => undefined); + throw this.closeTimeoutError(); + } let timer: ReturnType | undefined; try { return await Promise.race([ diff --git a/test/qwp/sender.test.ts b/test/qwp/sender.test.ts index 735f137..6c756b3 100644 --- a/test/qwp/sender.test.ts +++ b/test/qwp/sender.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { QWP_COLUMN_TYPE, QWP_EGRESS_MESSAGE, @@ -704,6 +704,61 @@ describe("QWP high-level sender", () => { }); }); + it("keeps the close ACK wait observed when the drain deadline has elapsed", async () => { + // closeNow() builds the wait promise -- registering a session waiter -- + // and only then enters withCloseDeadline, which re-reads the clock. When + // the publication has consumed the whole budget that re-read throws before + // Promise.race can subscribe, so the rejection session.close() delivers a + // moment later has no handler. Node turns that into a process exit, after + // close() has already reported the timeout the caller did handle. + let skewMs = 0; + class ElapsingSession extends WatermarkSession { + private readonly rejecters = new Set<(error: Error) => void>(); + + override waitForAcknowledged(): Promise { + // Real code reaches this when the publication lands on the deadline, + // a window of microseconds. Forcing the skew here makes it certain. + skewMs = 10_000; + return new Promise((_resolve, reject) => { + this.rejecters.add(reject); + }); + } + + override async close(): Promise { + for (const reject of this.rejecters) { + reject(new Error("QWP ingress session is closed")); + } + this.rejecters.clear(); + await super.close(); + } + } + + const unhandled: unknown[] = []; + const onUnhandled = (reason: unknown) => unhandled.push(reason); + process.on("unhandledRejection", onUnhandled); + const realNow = Date.now; + vi.spyOn(Date, "now").mockImplementation(() => realNow() + skewMs); + try { + const session = new ElapsingSession(); + const sender = new QwpSender(async () => session, { + autoFlush: false, + closeFlushTimeoutMs: 1_000, + }); + await sender.table("events").longColumn("value", 42n).atNow(); + + await expect(sender.close()).rejects.toBeInstanceOf( + QwpSenderCloseTimeoutError, + ); + expect(session.closeCount).toBe(1); + // Let the orphaned rejection reach the unhandled-rejection check. + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(unhandled).toEqual([]); + } finally { + process.off("unhandledRejection", onUnhandled); + vi.restoreAllMocks(); + } + }); + it("publishes on close without draining when the timeout is zero", async () => { const session = new WatermarkSession(); const sender = new QwpSender(async () => session, { From 3fd79058567309539c9d5467de9e2b9472a42877 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 2 Sep 2026 18:25:56 +0100 Subject: [PATCH 242/265] fix(qwp): serve CommonJS to the browser export condition The browser package's "browser" export condition resolved straight to dist/es/index.mjs. Conditions match in declaration order, so a resolver that adds "browser" while still emitting require() -- most commonly jest-environment-jsdom, which sets customExportConditions: ["browser"], and node --conditions=browser -- was handed an ES module: Error [ERR_REQUIRE_ESM]: require() of ES Module .../dist/es/index.mjs Reproduced on Node 20.11.0; Node 22.12 and later absorb it through require(esm), so two of the three CI matrix entries hide it. Split the condition on import/require the way the top-level map already does. The legacy top-level "browser" field had the same shape and is read by webpack 4, browserify and parcel 1, which are CommonJS-oriented; point it at the CJS build to match "main". Nothing loses the ESM build over this: every resolver that understands "exports" reaches it through the browser/import branch, and "module" still covers the rest. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VnqPKyf9FYsuPoA22V7qxj --- packages/browser-client/package.json | 12 +++++++++--- test/package-boundaries.e2e.ts | 24 ++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/packages/browser-client/package.json b/packages/browser-client/package.json index 04444a6..d139e85 100644 --- a/packages/browser-client/package.json +++ b/packages/browser-client/package.json @@ -12,13 +12,19 @@ ], "main": "dist/cjs/index.js", "module": "dist/es/index.mjs", - "browser": "dist/es/index.mjs", + "browser": "dist/cjs/index.js", "types": "dist/cjs/index.d.ts", "exports": { ".": { "browser": { - "types": "./dist/es/index.d.mts", - "default": "./dist/es/index.mjs" + "import": { + "types": "./dist/es/index.d.mts", + "default": "./dist/es/index.mjs" + }, + "require": { + "types": "./dist/cjs/index.d.ts", + "default": "./dist/cjs/index.js" + } }, "import": { "types": "./dist/es/index.d.mts", diff --git a/test/package-boundaries.e2e.ts b/test/package-boundaries.e2e.ts index a73196e..376ce39 100644 --- a/test/package-boundaries.e2e.ts +++ b/test/package-boundaries.e2e.ts @@ -225,6 +225,30 @@ describe("public npm package boundaries", () => { }, ); + it.each(["import", "require"] as const)( + "resolves the browser package under the browser condition with %s", + (format) => { + // jest-environment-jsdom adds `browser` to the export conditions while + // still emitting require(), and so does `node --conditions=browser`. + // Conditions match in declaration order, so a `browser` key that resolves + // straight to the ESM file hands a CommonJS require an .mjs and Node + // below 22.12 fails with ERR_REQUIRE_ESM. `browser` must therefore split + // on import/require the same way the top-level map does. + const script = + format === "require" + ? 'console.log(typeof require("@questdb/browser-client").connectQwpBrowserClient);' + : 'console.log(typeof (await import("@questdb/browser-client")).connectQwpBrowserClient);'; + const output = execFileSync( + process.execPath, + format === "import" + ? ["--conditions=browser", "--input-type=module", "--eval", script] + : ["--conditions=browser", "--eval", script], + { cwd: consumerDirectory, encoding: "utf8" }, + ); + expect(output.trim()).toBe("function"); + }, + ); + it("bundles the browser package root for a browser consumer", async () => { const directory = await mkdtemp(path.join(tmpdir(), "questdb-browser-")); const bundle = path.join(directory, "client.mjs"); From ec7b286ce4daca6bb5093a30897c6588489c57be Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 2 Sep 2026 18:26:17 +0100 Subject: [PATCH 243/265] fix(qwp): count only written bytes as discarded journal data Recovery measured abandoned data as size - header - logicalSize, where size is the segment file's size. Segments are preallocated to maxSegmentBytes, so that span is mostly unwritten zero padding that was never journalled and cannot have been lost. At the 4 MiB default a single abandoned record was announced to the operator as roughly four million lost bytes -- as the suite's own log line showed: QWP store-and-forward discarded 4194301 journal byte(s) during recovery [...]: the active segment tail contains a complete record whose CRC32C does not match Measure to the last non-zero byte instead. The same case now reports 2, and the CRC test reports exactly the 11-byte record it abandoned. Recovery itself was already correct: the valid prefix is retained and the damage is reported either way. Only the magnitude was wrong, and it is the number an operator sizes a crash by. The Java client computes the same span but consumes it as a predicate -- quarantine or not, and the extent to zero in sanitizeTornTail() -- and never surfaces it as a loss figure, so there is no parity argument for keeping it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VnqPKyf9FYsuPoA22V7qxj --- .../src/qwp-node/file-replay-store.ts | 52 +++++++++++++++++-- test/qwp/reconnect.test.ts | 9 +++- 2 files changed, 56 insertions(+), 5 deletions(-) diff --git a/packages/nodejs-client/src/qwp-node/file-replay-store.ts b/packages/nodejs-client/src/qwp-node/file-replay-store.ts index 57aca47..bdb38a7 100644 --- a/packages/nodejs-client/src/qwp-node/file-replay-store.ts +++ b/packages/nodejs-client/src/qwp-node/file-replay-store.ts @@ -620,10 +620,10 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { this.reportRecoveryDataLoss({ directory: this.directory, segmentFile: name, - discardedBytes: Math.max( - 0, - decoded.size - SEGMENT_HEADER_SIZE - decoded.logicalSize, - ), + // Written bytes only. Segments are preallocated, so the span + // from the valid prefix to EOF is mostly zero padding that was + // never journalled and cannot have been lost. + discardedBytes: Math.max(0, decoded.discardedBytes ?? 0), reason: decoded.interiorDamage ? "a damaged record is followed by intact records that replay can no longer reach" : "the active segment tail contains a complete record whose CRC32C does not match", @@ -2252,6 +2252,11 @@ interface DecodedSegment { * records that are still on disk, so recovery quarantines instead. */ readonly interiorDamage?: boolean; + /** + * Written bytes abandoned beyond the valid prefix, excluding the segment's + * unwritten zero padding. Set only when the segment is damaged. + */ + readonly discardedBytes?: number; } function selectRecoveredActivePath( @@ -2362,6 +2367,10 @@ async function scanSegment( // after it are still intact and must not be truncated away. tornTail: !paddingToEnd, interiorDamage: !paddingToEnd, + discardedBytes: paddingToEnd + ? 0 + : (await findWrittenEnd(handle, offset, fileSize, scanBuffer)) - + offset, }; } if (remaining < FRAME_HEADER_SIZE) { @@ -2419,6 +2428,8 @@ async function scanSegment( fileSize, scratch, ), + discardedBytes: + (await findWrittenEnd(handle, offset, fileSize, scanBuffer)) - offset, }; } const frameSequence = firstSequence + BigInt(records.length); @@ -2485,6 +2496,39 @@ async function hasValidRecordAt( return frameHeader.readUInt32LE(0) === (crc ^ 0xffffffff) >>> 0; } +/** + * Offset one past the last non-zero byte in `[start, end)`, or `start` when the + * range holds no data at all. + * + * Segments are preallocated to their full configured size, so the range between + * the valid prefix and EOF is mostly unwritten zero padding. Measuring loss to + * EOF would put a whole segment's capacity into every recovery report -- at the + * 4 MiB default, a single abandoned record reads as four million lost bytes. + * Only bytes that were actually written can have been lost. + */ +async function findWrittenEnd( + handle: FileHandle, + start: number, + end: number, + scratch: Buffer, +): Promise { + let writtenEnd = start; + let offset = start; + while (offset < end) { + const length = Math.min(end - offset, scratch.byteLength); + const chunk = scratch.subarray(0, length); + await readFully(handle, chunk, offset); + for (let index = length - 1; index >= 0; index--) { + if (chunk[index] !== 0) { + writtenEnd = offset + index + 1; + break; + } + } + offset += length; + } + return writtenEnd; +} + async function isZeroFilledFile( handle: FileHandle, start: number, diff --git a/test/qwp/reconnect.test.ts b/test/qwp/reconnect.test.ts index 1b9bec1..be61591 100644 --- a/test/qwp/reconnect.test.ts +++ b/test/qwp/reconnect.test.ts @@ -4519,7 +4519,10 @@ describe("QWP Node file replay store", () => { segmentFile: segment, reason: expect.stringContaining("CRC32C"), }); - expect(reports[0].discardedBytes).toBeGreaterThanOrEqual(recordSize); + // Exactly the abandoned record, not the segment's preallocated tail. + // Measuring to EOF reported the whole 4 MiB segment for this one lost + // record, which tells an operator nothing about the real loss. + expect(reports[0].discardedBytes).toBe(recordSize); await recovered.close(); }); @@ -4581,6 +4584,10 @@ describe("QWP Node file replay store", () => { reason: expect.stringContaining("replay can no longer reach"), }); expect(reports[0].discardedBytes).toBeGreaterThan(0); + // Written bytes only. These records carry 3-byte payloads, so the loss + // is tens of bytes; the segment is preallocated to 4 MiB and measuring + // to EOF used to report all of it. + expect(reports[0].discardedBytes).toBeLessThan(1024); await recovered.close(); }, ); From 236edddf5a1c1d09d7030a9268f2918267b6dbd3 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 2 Sep 2026 18:26:25 +0100 Subject: [PATCH 244/265] fix(qwp): report a retired deferred recovery tail retireRecoveredDiscardTailIfReady() acknowledges every frame of a recovered tail whose transaction was never committed, deleting the journal records and advancing the ACK watermark, and says nothing on any channel: no onRecoveryDataLoss, no onSenderError, no log. A crashed transactional store-and-forward producer restarts to an emptied journal with nothing for an operator to see. Retiring the tail is correct and stays unchanged -- the server rolled that transaction back on disconnect, so replaying it would rebuild half a transaction. But this is the one path that discards journalled frames with no NACK and no quarantine behind it, so it belongs on the same abandonment channel as every other loss. The Java client logs the frame count and both sequence numbers here; match that. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VnqPKyf9FYsuPoA22V7qxj --- .../_internal/reconnecting-ingress-connection.ts | 16 ++++++++++++++++ test/qwp/reconnect.test.ts | 13 +++++++++++++ 2 files changed, 29 insertions(+) diff --git a/packages/client-core/src/_qwp/_internal/reconnecting-ingress-connection.ts b/packages/client-core/src/_qwp/_internal/reconnecting-ingress-connection.ts index 822a60d..66550c4 100644 --- a/packages/client-core/src/_qwp/_internal/reconnecting-ingress-connection.ts +++ b/packages/client-core/src/_qwp/_internal/reconnecting-ingress-connection.ts @@ -43,6 +43,7 @@ import { QwpAsyncQueue } from "./async-queue"; import { jitterReconnectDelayMs } from "./reconnect-backoff"; import { QwpNotificationDispatcher } from "./notification-dispatcher"; import { + createQwpDataLossSenderError, createQwpProtocolViolationSenderError, createQwpSenderError, defaultQwpSenderErrorHandler, @@ -1658,8 +1659,23 @@ export class QwpReconnectingIngressConnection implements QwpBinaryConnection { ) { return; } + const frameCount = tail.tipSequence - tail.startSequence + 1n; await this.acknowledgeStoredFramesThrough(tail.tipSequence); this.recoveredDiscardTail = undefined; + // Retiring the tail is correct: it belongs to a transaction the producer + // never committed, the server rolled it back on disconnect, and replaying + // it would rebuild half a transaction. Doing it silently is not. This is + // the one path that empties journalled frames with no NACK, no quarantine + // and no recovery report, so it must reach the same channel as every other + // abandonment -- otherwise a crash discards the tail with nothing for an + // operator to see. The Java client logs the same fact on this path. + this.emitSenderError( + createQwpDataLossSenderError( + `recovered store-and-forward journal ends with ${frameCount} deferred frame(s) ` + + `whose transaction was never committed [fsn=${tail.startSequence}..${tail.tipSequence}]; ` + + `the tail was retired without being transmitted`, + ), + ); } private async persistSymbolDictionaryDelta( diff --git a/test/qwp/reconnect.test.ts b/test/qwp/reconnect.test.ts index be61591..5338977 100644 --- a/test/qwp/reconnect.test.ts +++ b/test/qwp/reconnect.test.ts @@ -3340,14 +3340,27 @@ describe("QWP ingress reconnect and replay", () => { await seed.close(); const connection = new FakeConnection("primary"); + const senderErrors: QwpSenderError[] = []; const session = await QwpIngressSession.connect(async () => connection, { reconnect: { maxAttempts: 1 }, replayStore: new QwpNodeFileReplayStore({ directory }), + onSenderError: (error) => senderErrors.push(error), }); expect(connection.sent).toEqual([]); await vi.waitFor(async () => expect(await assignedReplaySegments(directory)).toEqual([]), ); + // Retiring the tail is right, but it empties the journal with no NACK and + // no quarantine, so it has to be announced on the abandonment channel. + await vi.waitFor(() => expect(senderErrors).toHaveLength(1)); + expect(senderErrors[0]).toMatchObject({ + category: QWP_SENDER_ERROR_CATEGORY.DATA_LOSS, + appliedPolicy: QWP_SENDER_ERROR_POLICY.ABANDONED, + }); + expect(senderErrors[0].serverMessage).toContain( + "3 deferred frame(s) whose transaction was never committed", + ); + expect(senderErrors[0].serverMessage).toContain("[fsn=5..7]"); expect(session.metrics).toMatchObject({ replayPublishedFrameSequence: 7n, replayAcknowledgedFrameSequence: 7n, From f41630adf1e4b4219f8721efe8684e5d9c672512 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 2 Sep 2026 18:26:33 +0100 Subject: [PATCH 245/265] docs(qwp): document the cluster-wide terminal auth verdict QWP.md said only that "initial authentication, upgrade, and capability failures remain terminal", which reads as a per-endpoint verdict. It is not: on Node a 401 or 403 short-circuits the endpoint sweep and is terminal for the whole endpoint set, so the endpoints ranked after it are never tried and no reconnect budget applies. Record the rule, the reasoning behind it, the contrast with 404 and every other status that keeps the sweep walking, and the two exemptions -- a Node foreground store-and-forward sender that has connected once, and the browser, whose upgrade error carries no status at all. Also record how it composes with health ranking, which is the part that surprises: a non-orderly close demotes the endpoint it happened on, so a peer answering 401 can rank ahead of the endpoint that just dropped, and that sweep ends without the dropped endpoint being retried at all. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VnqPKyf9FYsuPoA22V7qxj --- QWP.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/QWP.md b/QWP.md index 6bf86a6..d540244 100644 --- a/QWP.md +++ b/QWP.md @@ -901,6 +901,27 @@ an opaque upgrade error because their WebSocket API hides the HTTP response. Avo placing ingress replica endpoints in a browser endpoint list unless the proxy routes writers to a primary. +On Node.js a `401` or `403` on the upgrade is classified as an authentication +failure, and it is the one endpoint verdict that is terminal for the entire endpoint +set rather than for the endpoint that returned it. It short-circuits the sweep: the +endpoints ranked after it are never tried, and the reconnect loop rethrows before the +attempt and duration budgets are consulted, so no reconnect setting extends it. A +credential is cluster-wide, so a node rejecting it reports a configuration error that +walking on to a peer would only mask; the Java client applies the same rule. Every +other rejected status keeps the sweep walking, including `404`, which one node can +return mid-deploy while its peers are healthy, and a sweep mixing such attempts stays +retryable if any one of them was. + +Note how this composes with health ranking. A non-orderly close demotes the endpoint +it happened on, so a peer that answers `401` can rank ahead of the endpoint that just +dropped; that sweep then ends without the dropped endpoint being retried at all, and +the sender stays terminal even after it recovers. Two cases are exempt. A Node +foreground store-and-forward sender that has already connected once retries these +failures indefinitely, so a credential can rotate under a running producer without +losing journaled rows. A browser cannot distinguish them at all, because its upgrade +error carries no status; browser authentication failures surface from the REST +session bootstrap instead. + ### Observability Use immutable metrics snapshots for polling and callbacks for event-driven telemetry: From 198fef1ae3ed51f9417d834a385abfd38272452a Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 2 Sep 2026 18:54:16 +0100 Subject: [PATCH 246/265] refactor(qwp): type the capacity waiter's timer as optional The waiter object is built before its timeout exists, because the timeout callback closes over the waiter. `undefined as unknown as ReturnType` papered over that by declaring a live timer handle where there is none, which is exactly the shape a later strictNullChecks pass would have to unpick. Declare the field optional instead. It genuinely holds no timer between the two statements, and clearTimeout() accepts undefined, so both read sites are already correct. No behaviour change. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VnqPKyf9FYsuPoA22V7qxj --- .../src/_qwp/_internal/reconnecting-ingress-connection.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/client-core/src/_qwp/_internal/reconnecting-ingress-connection.ts b/packages/client-core/src/_qwp/_internal/reconnecting-ingress-connection.ts index 66550c4..0be230b 100644 --- a/packages/client-core/src/_qwp/_internal/reconnecting-ingress-connection.ts +++ b/packages/client-core/src/_qwp/_internal/reconnecting-ingress-connection.ts @@ -168,7 +168,11 @@ class QwpMemoryReplayStore implements QwpIngressReplayStore { private readonly capacityWaiters = new Set<{ resolve: () => void; reject: (error: Error) => void; - timer: ReturnType; + // Assigned immediately after the waiter is built: the timeout callback + // closes over the waiter, so the object has to exist first. Optional + // rather than asserted, because between those two statements it genuinely + // holds no timer, and clearTimeout() accepts undefined. + timer?: ReturnType; }>(); private usedBytes = 0; private closing = false; @@ -294,7 +298,7 @@ class QwpMemoryReplayStore implements QwpIngressReplayStore { this.capacityWaiters.delete(waiter); reject(error); }, - timer: undefined as unknown as ReturnType, + timer: undefined, }; waiter.timer = setTimeout(() => { this.totalAppendTimeouts++; From c5bbb0095041e5760c5d306585998315a95ab958 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 2 Sep 2026 18:54:27 +0100 Subject: [PATCH 247/265] docs: correct the entry-point count and record the workspace gates Three stale documents, all from the split into a pnpm workspace. QWP.md's public API policy claimed "the four package entry points listed at the top", then "the shared, browser, and Node entry points" one sentence later, while the table above it lists two packages and each manifest declares exactly one exports subpath. Say two, name them, and add client-core to the list of paths that are implementation details -- it is the private package a deep import is most likely to reach for. CLAUDE.md's buffer section listed v1 and v2 only, and its protocol section stopped at version 2, so neither mentioned bufferv3.ts, the DECIMAL support it adds, the SenderBufferV1/V2/V3 exports, or that auto-negotiation now prefers v3. Its "version 2 is recommended" note said the same thing a third time. CONTRIBUTING.md still described a single-package repository: install, test, eslint. It now covers the three-package workspace, which package is published and which is inlined, and the ten commands build.yml actually gates a pull request on, plus the Chromium bundle job. Two of those gates exist for reasons that are not obvious from their names -- Vitest strips types without checking them, and the compiled writers' row typing lives only in the emitted declarations -- so both are noted. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VnqPKyf9FYsuPoA22V7qxj --- CLAUDE.md | 7 ++++-- CONTRIBUTING.md | 57 +++++++++++++++++++++++++++++++++++++++++++++++++ QWP.md | 17 +++++++++------ 3 files changed, 72 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 827911d..252d5c0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,6 +58,8 @@ documentation, and single root build. - `bufferv1.ts`: Text-based protocol (version 1) for backward compatibility - `bufferv2.ts`: Binary protocol (version 2) with double encoding and array support + - `bufferv3.ts`: Extends v2 with DECIMAL columns (`decimalColumn`, `decimalColumnText`) + - All three are public exports (`SenderBufferV1`/`V2`/`V3`), selected by `createBuffer` - Dynamic buffer resizing and row-level transaction support 4. **Configuration** (`packages/nodejs-client/src/options.ts`): Comprehensive options parsing from connection strings with validation and deprecation handling. @@ -70,7 +72,8 @@ documentation, and single root build. - **Version 1**: Text-based serialization, compatible with older QuestDB versions - **Version 2**: Binary encoding for doubles, supports array columns, better performance -- **Auto-negotiation**: HTTP transport can automatically detect and use the best protocol version +- **Version 3**: Adds DECIMAL columns on top of version 2 +- **Auto-negotiation**: HTTP transport can automatically detect and use the best protocol version, preferring v3 over v2 ### Key Design Patterns @@ -97,5 +100,5 @@ Integration tests use TestContainers to spin up QuestDB instances for realistic - Buffer automatically resizes up to `max_buf_size` (default 100MB) - Auto-flush triggers based on row count or time interval - Each worker thread needs its own Sender instance (buffers cannot be shared) -- Protocol version 2 is recommended for new implementations with array column support +- Protocol version 2 or higher is recommended for new implementations; v2 adds array columns and v3 adds DECIMAL - Run `pnpm test:dist` after package-boundary changes; it checks both npm tarballs, ESM/CJS loading, browser bundling, and the absence of Node modules from the browser artifact. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a31bca5..56dfe2e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,6 +18,31 @@ cd nodejs-questdb-client pnpm install ``` +## Repository Layout + +The repository is a pnpm workspace of three packages: + +| Package | Published | Contents | +| ------------------------------------ | --------- | --------------------------------------------------------------------- | +| `packages/client-core` | no | Shared runtime-neutral QWP protocol and session code | +| `packages/nodejs-client` | yes | `@questdb/nodejs-client`: ILP transports plus the Node QWP adapter | +| `packages/browser-client` | yes | `@questdb/browser-client`: the browser QWP adapter | + +`client-core` is private and never published; both public packages inline it at +build time. Each published package exposes its whole API from its package root, +so `packages/*/src/index.ts` are the only public entry points. + +Build both packages with: + +```bash +pnpm build +``` + +`packages/browser-client` must stay free of Node built-ins, Node typings, +`undici`, and `ws`. Run `pnpm test:dist` after any change to a package boundary: +it loads both built tarball layouts through their `exports` maps and checks the +browser bundle for Node imports. + ## Running Tests The project uses Vitest for testing. Tests are located in the `test` directory. @@ -53,6 +78,38 @@ pnpm eslint pnpm eslint --fix ``` +## CI Gates + +`.github/workflows/build.yml` runs the following on every pull request, across +Node.js 20, 22, and latest. Run them locally before pushing — `pnpm test` alone +does not cover the type-checking, packaging, or browser-bundle gates. + +| Command | Covers | +| ----------------------------------- | ------------------------------------------------------------- | +| `pnpm eslint` | `packages/*/src` | +| `pnpm typecheck` | Package sources plus the QWP public API contract | +| `pnpm typecheck:qwp-browser` | The browser source graph, with DOM libs and no `@types/node` | +| `pnpm typecheck:test` | `test/**`, which `pnpm typecheck` does not reach | +| `pnpm typecheck:bench` | `benchmarks/**` | +| `pnpm lint:bench` | `benchmarks/**` | +| `pnpm test` | The Vitest suite, including containerized integration tests | +| `pnpm test:dist` | Both built packages loaded through their `exports` maps | +| `pnpm typecheck:dist` | The emitted `.d.ts` files, as a consumer sees them | +| `pnpm check:packages` | That everything `exports` references is present and packed | + +A separate job drives the built browser bundle in real Chromium against a local +mock server: + +```bash +pnpm test:qwp-browser +``` + +Vitest strips types without checking them, so a test can reference a deleted +export and still pass; `pnpm typecheck:test` is what catches that. Likewise the +compiled table writers promise per-column row typing that lives only in the +emitted declarations, which is why `pnpm typecheck:dist` exists alongside +`pnpm test:dist`. + ## Making Changes 1. Create a new branch for your changes: diff --git a/QWP.md b/QWP.md index d540244..449e4b0 100644 --- a/QWP.md +++ b/QWP.md @@ -1557,10 +1557,13 @@ They are intentionally not CI performance gates. ## Public API policy -Only the four package entry points listed at the top are public. In particular, -paths containing `internal`, `qwp-node`, or `src` are implementation details even if -a bundler can resolve them. The compatibility contract checks the documented -high-level constructors, session classes, errors, constants, and option signatures -from the shared, browser, and Node entry points. Additional low-level codec exports -from `qwp` are intended for advanced integrations; prefer high-level APIs when no -custom encoder or transport is required. +Only the two package roots listed at the top are public: `@questdb/nodejs-client` +and `@questdb/browser-client`. Each declares exactly one `exports` subpath, so +those two specifiers are the whole supported surface. In particular, paths +containing `internal`, `qwp-node`, `client-core`, or `src` are implementation +details even if a bundler can resolve them, and `@questdb/client-core` is a private +workspace package that is never published. The compatibility contract checks the +documented high-level constructors, session classes, errors, constants, and option +signatures exported from both package roots. Additional low-level codec exports +share those roots and are intended for advanced integrations; prefer the high-level +APIs when no custom encoder or transport is required. From a03d7710335ab3abb07d871cc498af69ab7e4153 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Wed, 2 Sep 2026 19:07:19 +0100 Subject: [PATCH 248/265] fix(qwp): contextually type the capacity waiter literal 198fef1 made the waiter's timer field optional on the Set's element type, but the local object literal is not contextually typed by that Set, so `timer: undefined` inferred the property as `undefined` and the following `waiter.timer = setTimeout(...)` failed to compile. CI caught it in the browser type-check, where setTimeout() returns number: reconnecting-ingress-connection.ts(303,7): error TS2322: Type 'number' is not assignable to type 'undefined'. Extract the shape as a named CapacityWaiter interface and annotate the literal with it, so the optional field is honoured at the construction site as well as in the Set. No behaviour change. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VnqPKyf9FYsuPoA22V7qxj --- .../reconnecting-ingress-connection.ts | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/packages/client-core/src/_qwp/_internal/reconnecting-ingress-connection.ts b/packages/client-core/src/_qwp/_internal/reconnecting-ingress-connection.ts index 0be230b..764d324 100644 --- a/packages/client-core/src/_qwp/_internal/reconnecting-ingress-connection.ts +++ b/packages/client-core/src/_qwp/_internal/reconnecting-ingress-connection.ts @@ -129,6 +129,16 @@ interface RecoveredDiscardTail { readonly predecessorSequence?: bigint; } +interface CapacityWaiter { + resolve: () => void; + reject: (error: Error) => void; + // Assigned immediately after the waiter is built: the timeout callback + // closes over the waiter, so the object has to exist first. Optional + // rather than asserted, because between those two statements it genuinely + // holds no timer, and clearTimeout() accepts undefined. + timer?: ReturnType; +} + class RetriableIngressNackError extends Error { constructor( readonly frameSequence: bigint, @@ -165,15 +175,7 @@ class RetriableIngressConnectionError extends Error { class QwpMemoryReplayStore implements QwpIngressReplayStore { private readonly records = new Map(); private readonly symbols: string[] = []; - private readonly capacityWaiters = new Set<{ - resolve: () => void; - reject: (error: Error) => void; - // Assigned immediately after the waiter is built: the timeout callback - // closes over the waiter, so the object has to exist first. Optional - // rather than asserted, because between those two statements it genuinely - // holds no timer, and clearTimeout() accepts undefined. - timer?: ReturnType; - }>(); + private readonly capacityWaiters = new Set(); private usedBytes = 0; private closing = false; private totalBackpressureStalls = 0; @@ -287,7 +289,7 @@ class QwpMemoryReplayStore implements QwpIngressReplayStore { requiredBytes: number, ): Promise { return new Promise((resolve, reject) => { - const waiter = { + const waiter: CapacityWaiter = { resolve: () => { clearTimeout(waiter.timer); this.capacityWaiters.delete(waiter); From 782fcedc00226c501d3ac5b441645834c7048f35 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 3 Sep 2026 00:39:07 +0100 Subject: [PATCH 249/265] fix(qwp): report a segment whose record framing overshoots EOF scanSegment reads a record's uint32 payload-length field before the CRC32C that would have covered it, so a damaged length is the one shape that reaches repair with no integrity check firing. That branch returned tornTail with crcMismatch, interiorDamage and discardedBytes all unset, so load()'s report gate skipped and repairSegmentTail then zeroed the intact records behind the damage. Same for a header with fewer than FRAME_HEADER_SIZE non-zero bytes left to read. Segments are preallocated to their full configured size and an append is only started for a record that fits, so an interrupted append always declares a length that still fits the file: a partially written payload fails its CRC32C, and unwritten space reads as zero padding. Framing that overshoots EOF therefore never comes from an interrupted append. It is a damaged length field, or a file truncated below the size it reserved, and both abandon bytes that were journalled. Flag both exits as framingOverrun, measure the abandoned bytes the way the CRC branch does, and report them. Truncation policy is unchanged -- the suffix is still abandoned, matching the Java client -- only the silence is fixed. External truncation was silent for the same reason and is covered by the same change. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VnqPKyf9FYsuPoA22V7qxj --- .../src/qwp-node/file-replay-store.ts | 39 +++++++++- test/qwp/reconnect.test.ts | 72 +++++++++++++++++++ 2 files changed, 108 insertions(+), 3 deletions(-) diff --git a/packages/nodejs-client/src/qwp-node/file-replay-store.ts b/packages/nodejs-client/src/qwp-node/file-replay-store.ts index bdb38a7..1bf1231 100644 --- a/packages/nodejs-client/src/qwp-node/file-replay-store.ts +++ b/packages/nodejs-client/src/qwp-node/file-replay-store.ts @@ -610,12 +610,18 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { "non-active segment has a torn record tail", ); } - if (decoded.interiorDamage || decoded.crcMismatch) { + if ( + decoded.interiorDamage || + decoded.crcMismatch || + decoded.framingOverrun + ) { // The active segment's damaged suffix is abandoned by policy, // matching the Java client. An interior tear strands the frames // behind it because replay requires a contiguous sequence; a // tail CRC mismatch proves the complete final record itself was - // lost. Recovery proceeds on the valid prefix, but provable loss + // lost; framing that overshoots EOF cannot have come from an + // interrupted append at all, so it too abandons journalled + // bytes. Recovery proceeds on the valid prefix, but provable loss // is always reported -- discarding it silently is dangerous. this.reportRecoveryDataLoss({ directory: this.directory, @@ -626,7 +632,9 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { discardedBytes: Math.max(0, decoded.discardedBytes ?? 0), reason: decoded.interiorDamage ? "a damaged record is followed by intact records that replay can no longer reach" - : "the active segment tail contains a complete record whose CRC32C does not match", + : decoded.crcMismatch + ? "the active segment tail contains a complete record whose CRC32C does not match" + : "a record's framing runs past the end of the segment, so its length field is damaged or the file was truncated", }); } await repairSegmentTail( @@ -2252,6 +2260,20 @@ interface DecodedSegment { * records that are still on disk, so recovery quarantines instead. */ readonly interiorDamage?: boolean; + /** + * A record's framing runs past the end of the segment: either its declared + * payload length overshoots EOF, or fewer than {@link FRAME_HEADER_SIZE} + * non-zero bytes remain to hold a header. + * + * Segments are preallocated to their full configured size and an append is + * only started for a record that fits, so an interrupted append always + * declares a length that still fits the file -- a partially written payload + * fails its CRC32C instead, and unwritten space reads as zero padding. + * Framing that overshoots EOF therefore never comes from an interrupted + * append: it is a damaged length field, or a file truncated below the size + * it reserved. Both abandon bytes that were journalled, so both are reported. + */ + readonly framingOverrun?: boolean; /** * Written bytes abandoned beyond the valid prefix, excluding the segment's * unwritten zero padding. Set only when the segment is damaged. @@ -2382,6 +2404,9 @@ async function scanSegment( records, logicalSize: offset - SEGMENT_HEADER_SIZE, tornTail: true, + framingOverrun: true, + discardedBytes: + (await findWrittenEnd(handle, offset, fileSize, scanBuffer)) - offset, }; } const payloadLength = frameHeader.readUInt32LE(4); @@ -2395,6 +2420,14 @@ async function scanSegment( records, logicalSize: offset - SEGMENT_HEADER_SIZE, tornTail: true, + // The length field is read before the CRC32C that would have covered + // it, so a damaged length escapes the integrity check entirely and the + // records behind it are still intact on disk. Repair abandons them + // either way, by the same policy the CRC branch follows; what must not + // happen is abandoning them without saying so. + framingOverrun: true, + discardedBytes: + (await findWrittenEnd(handle, offset, fileSize, scanBuffer)) - offset, }; } const storedCrc = frameHeader.readUInt32LE(0); diff --git a/test/qwp/reconnect.test.ts b/test/qwp/reconnect.test.ts index 5338977..09edb7a 100644 --- a/test/qwp/reconnect.test.ts +++ b/test/qwp/reconnect.test.ts @@ -4605,6 +4605,66 @@ describe("QWP Node file replay store", () => { }, ); + it("reports the records a damaged length field strands behind it", async () => { + // The length field is read before the CRC32C that would have covered it, + // so corrupting it is the one damage shape that reaches repair without any + // integrity check firing. Recovery still abandons the suffix by the same + // policy as a CRC tear, but it used to do it in silence: no report, no + // sentinel, nothing an operator could act on, while the intact records + // behind the damaged one were zeroed off the disk. + const directory = await trackedDirectory(); + const first = new QwpNodeFileReplayStore({ directory }); + await first.load(); + for (let sequence = 0; sequence < 5; sequence++) { + await first.append({ + frameSequence: BigInt(sequence), + payload: Uint8Array.of(sequence, sequence, sequence), + }); + } + await first.close(); + + const [segment] = await assignedReplaySegments(directory); + const recordSize = 8 + 3; + const secondRecord = 24 + recordSize * 2; + const path = join(directory, segment); + const thirdRecordPayload = Uint8Array.of(3, 3, 3); + expect(await payloadOffsetIn(path, thirdRecordPayload)).toBeGreaterThan(0); + + // Overshoot EOF by the declared payload length alone, leaving every other + // header byte -- including the record's own CRC32C -- untouched. + const damagedLength = Buffer.alloc(4); + damagedLength.writeUInt32LE(0xf0000000, 0); + const file = await open(path, "r+"); + try { + await file.write(damagedLength, 0, 4, secondRecord + 4); + await file.sync(); + } finally { + await file.close(); + } + + const reports: QwpNodeReplayDataLossReport[] = []; + const recovered = new QwpNodeFileReplayStore({ + directory, + onRecoveryDataLoss: (report) => reports.push(report), + }); + await expect(recovered.load()).resolves.toEqual([ + { frameSequence: 0n, payload: Uint8Array.of(0, 0, 0) }, + { frameSequence: 1n, payload: Uint8Array.of(1, 1, 1) }, + ]); + expect(reports).toHaveLength(1); + expect(reports[0]).toMatchObject({ + directory, + segmentFile: segment, + reason: expect.stringContaining("runs past the end of the segment"), + }); + // The records behind the tear are gone, so the report has to account for + // them rather than for the preallocated padding. + expect(reports[0].discardedBytes).toBeGreaterThan(0); + expect(reports[0].discardedBytes).toBeLessThan(1024); + expect(await payloadOffsetIn(path, thirdRecordPayload)).toBe(-1); + await recovered.close(); + }); + it("still fails closed when a sealed segment has a torn record", async () => { // Java zeroes a sealed suffix only on proof that its frame accounting is // complete; a tear that cost frames fails recovery before any mutation so @@ -5537,3 +5597,15 @@ async function createTemporaryDirectory(): Promise { async function assignedReplaySegments(directory: string): Promise { return (await readdir(directory)).filter((name) => name.endsWith(".sfa")); } + +/** + * Offset of `payload` inside a segment file, or -1 once repair has zeroed it + * away. Distinguishes records still on disk from records the tail repair + * removed, which the recovered frame list alone cannot show. + */ +async function payloadOffsetIn( + segmentPath: string, + payload: Uint8Array, +): Promise { + return (await readFile(segmentPath)).indexOf(Buffer.from(payload)); +} From c2cf921bdaf15b7f1ed5eb087b3cbf071bac4d94 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 3 Sep 2026 00:44:36 +0100 Subject: [PATCH 250/265] fix(qwp): recover a slot lock from a stall instead of latching it `lost` is a pure function of Date.now() - provenAtMs, and beat() is the only writer of provenAtMs but returned early once `lost`. The flag was therefore permanent by construction: a stall longer than STALE_AFTER_MS -- a suspended VM or container, a debugger pause, a long event-loop block -- fenced a running producer for the rest of the process. Every append then threw QwpReplayStoreLockLostError, which is retryable=false and so routes to failTerminal, with no contender anywhere near the slot and the .lock.owner directory still carrying this acquisition's token. Fencing on staleness is right: past the window the holder genuinely cannot prove it still owns the slot, and a contender is entitled to take it. Latching is what was wrong, and staying out of beat() was only ever a way to avoid one specific hazard -- refreshing the mtime in place, where the owner-record read and the utimes() are separate syscalls and a reclaim landing between them stamps the successor's directory, whose holder then sees a drifted mtime and fences itself off its own slot. Re-enter contention instead. The token decides what is safe: while it still matches, nobody adopted the slot, so no other process replayed or rewrote the journal and the store's in-memory view of it is still accurate. Claiming through reclaimIfStale() plus claimOwnerDirectory() keeps every guarantee that made staying out safe -- reclaimIfStale() declines a directory that is not stale, so a peer that claimed the pathname first is left alone, and its rename lets exactly one contender win. A foreign token still fences for good; an unreadable record still proves nothing and simply retries. The superseded test asserted the mechanism ("must not beat at all"), so it now asserts the property it existed to protect: a stale holder must not disturb a live successor's directory, by mtime or by token. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VnqPKyf9FYsuPoA22V7qxj --- .../src/qwp-node/advisory-lock.ts | 84 ++++++++++++-- test/qwp/reconnect.test.ts | 104 ++++++++++++++++-- 2 files changed, 170 insertions(+), 18 deletions(-) diff --git a/packages/nodejs-client/src/qwp-node/advisory-lock.ts b/packages/nodejs-client/src/qwp-node/advisory-lock.ts index 9c037b5..fa484d1 100644 --- a/packages/nodejs-client/src/qwp-node/advisory-lock.ts +++ b/packages/nodejs-client/src/qwp-node/advisory-lock.ts @@ -102,7 +102,7 @@ export class QwpNodeAdvisoryLock { readonly pidPath: string, private readonly ownerPath: string, private ownerMtimeMs: number, - private readonly token: string, + private token: string, ) { this.startHeartbeat(); } @@ -289,16 +289,22 @@ export class QwpNodeAdvisoryLock { } private async beat(): Promise { - // A holder that has already gone stale must not re-prove itself. A - // contender reclaims a slot only once its mtime is stale, which is the same - // instant this object's own `lost` rule fires (both use STALE_AFTER_MS, and - // provenAtMs is stamped with the mtime). So a beat that resumes past the - // window may be racing a reclaim: the owner-record read and the mtime touch - // below are separate syscalls, and a reclaim landing between them would let - // this stamp the new owner's directory and reset the fence -- un-fencing a - // lock this process has already lost. Staying out once `lost` keeps that - // window closed; the mtime it declined to refresh keeps `lost` latched. - if (this.released || this.compromised || this.lost) return; + if (this.released || this.compromised) return; + // A holder that has already gone stale must not re-prove itself *in place*. + // A contender reclaims a slot only once its mtime is stale, which is the + // same instant this object's own `lost` rule fires (both use + // STALE_AFTER_MS, and provenAtMs is stamped with the mtime). So a beat that + // resumes past the window may be racing a reclaim: the owner-record read + // and the mtime touch below are separate syscalls, and a reclaim landing + // between them would stamp the new owner's directory, whose holder then + // sees a drifted mtime and fences itself off its own slot. + // + // Re-entering contention has no such window, so a stall is recoverable + // rather than terminal. + if (this.lost) { + await this.reacquireAfterStall(); + return; + } try { const current = await stat(this.ownerPath); if (Math.trunc(current.mtimeMs) !== Math.trunc(this.ownerMtimeMs)) { @@ -349,6 +355,62 @@ export class QwpNodeAdvisoryLock { return Date.now() - this.provenAtMs > STALE_AFTER_MS; } + /** + * Re-enters contention for a slot this object has already gone stale on. + * + * A stall longer than STALE_AFTER_MS -- a suspended VM or container, a + * debugger pause, a long event-loop block -- used to fence a producer + * permanently, because `beat()` declined to run and it is the only writer of + * {@link provenAtMs}. Nothing had necessarily taken the slot; the holder + * simply could no longer prove it still owned one. + * + * The token settles that. While it still matches, nobody adopted the slot, + * so no other process has replayed or rewritten the journal and the store's + * in-memory view of it is still accurate. Re-claiming through the same + * primitives a fresh contender uses keeps the guarantee that made staying + * out safe: {@link reclaimIfStale} declines a directory that is not stale, + * and its rename lets exactly one contender win, so a peer that claimed the + * pathname first is never disturbed. + */ + private async reacquireAfterStall(): Promise { + const ownership = await this.ownershipState().catch( + () => "unknown" as const, + ); + // Positive proof somebody adopted the slot. Stay fenced for good. + if (ownership === "foreign") { + this.markCompromised(); + return; + } + // Proves nothing about ownership, so neither reclaim nor latch: retry. + if (ownership === "unknown") return; + + try { + if (!(await reclaimIfStale(this.ownerPath))) return; + if (!(await claimOwnerDirectory(this.ownerPath))) return; + } catch { + return; + } + + const token = newOwnerToken(); + try { + await writeFile( + join(this.ownerPath, OWNER_FILE), + JSON.stringify({ pid: process.pid, host: hostname(), token }), + { encoding: "utf8", mode: 0o600 }, + ); + this.ownerMtimeMs = await touchOwnerDirectory(this.ownerPath); + } catch { + // A directory claimed but never stamped would read as recordless to the + // next contender. Drop it so the pathname is clean either way. + await removeOwnerDirectory(this.ownerPath).catch(() => undefined); + return; + } + // Only now is this a live acquisition again, under a new token: the old one + // must not resurrect a directory this object no longer holds. + this.token = token; + this.provenAtMs = Date.now(); + } + private markCompromised(): void { this.compromised = true; this.stopHeartbeat(); diff --git a/test/qwp/reconnect.test.ts b/test/qwp/reconnect.test.ts index 09edb7a..550e777 100644 --- a/test/qwp/reconnect.test.ts +++ b/test/qwp/reconnect.test.ts @@ -5042,7 +5042,7 @@ describe("QWP Node file replay store", () => { await expect(stat(ownerPath)).rejects.toMatchObject({ code: "ENOENT" }); }); - it("does not re-prove a slot lock that has already gone stale", async () => { + it("never re-proves a slot lock in place once it has gone stale", async () => { // A holder paused past the staleness window is already `lost` by its own // rule, and a contender is entitled to reclaim its slot the moment the // mtime is that old. The owner-record read and the mtime touch inside a @@ -5050,32 +5050,122 @@ describe("QWP Node file replay store", () => { // resuming beat stamp the new owner's directory and reset provenAtMs -- // clearing the fence and un-fencing a lock this process had already lost. // One beat later the rightful owner saw a drifted mtime and fenced itself - // off its own slot. A stale holder must not beat at all. + // off its own slot. A stale holder recovers by re-entering contention, so + // what has to hold is that it never touches the directory in place. const directory = await trackedDirectory(); const lock = await QwpNodeAdvisoryLock.acquire(directory); const beat = () => (lock as unknown as { beat(): Promise }).beat.call(lock); const ownerPath = join(directory, ".lock.owner"); - const stampedMtimeMs = (await stat(ownerPath)).mtimeMs; vi.useFakeTimers({ toFake: ["Date"] }); try { vi.setSystemTime(Date.now() + 20_000); expect(lock.lost).toBe(true); + // A successor adopted the slot while this holder was stalled, and is + // heartbeating: its directory carries its own token and a current mtime. + await rm(ownerPath, { recursive: true, force: true }); + await mkdir(ownerPath); + await writeFile( + join(ownerPath, "owner"), + JSON.stringify({ + pid: process.pid, + host: hostname(), + token: "successor-token", + }), + ); + const live = new Date(Date.now()); + await utimes(ownerPath, live, live); + const successorMtimeMs = (await stat(ownerPath)).mtimeMs; + await beat(); - // The beat must not have re-proven ownership: the fence stays raised and - // the directory mtime is untouched, so it cannot have stamped a - // successor's directory either. + // Fenced for good, and the successor's directory is byte-for-byte as it + // left it -- neither stamped nor reclaimed. expect(lock.lost).toBe(true); - expect((await stat(ownerPath)).mtimeMs).toBe(stampedMtimeMs); + expect((await stat(ownerPath)).mtimeMs).toBe(successorMtimeMs); + expect( + JSON.parse(await readFile(join(ownerPath, "owner"), "utf8")).token, + ).toBe("successor-token"); } finally { vi.useRealTimers(); } await lock.release().catch(() => undefined); }); + it("recovers a slot lock after a stall nobody contended", async () => { + // Fencing on staleness is right -- the holder genuinely cannot prove it + // still owns the slot -- but latching there is not. `beat()` is the only + // writer of provenAtMs and used to decline to run once `lost`, so a + // suspended VM, a debugger pause or a long event-loop block ended a + // producer for the life of the process even with no contender at all. + const directory = await trackedDirectory(); + const lock = await QwpNodeAdvisoryLock.acquire(directory); + const beat = () => + (lock as unknown as { beat(): Promise }).beat.call(lock); + + vi.useFakeTimers({ toFake: ["Date"] }); + try { + vi.setSystemTime(Date.now() + 20_000); + expect(lock.lost).toBe(true); + + await beat(); + + // The token still matched, so nobody adopted the slot and the journal + // behind it was never replayed by anyone else. + expect(lock.lost).toBe(false); + } finally { + vi.useRealTimers(); + } + await lock.release(); + await expectOnlyJavaSlotLockMetadata(directory); + }); + + it("keeps appending after the slot lock recovers from a stall", async () => { + const directory = await trackedDirectory(); + const store = new QwpNodeFileReplayStore({ directory }); + await store.load(); + await store.append({ frameSequence: 0n, payload: Uint8Array.of(1, 2, 3) }); + + const lock = (store as unknown as { slotLock: QwpNodeAdvisoryLock }) + .slotLock; + const beat = () => + (lock as unknown as { beat(): Promise }).beat.call(lock); + + vi.useFakeTimers({ toFake: ["Date"] }); + try { + // The clock stays advanced for the rest of the test: the process resumed + // at a later wall-clock time and keeps running there. Handing it back to + // real time would clear `lost` on its own, because provenAtMs was + // stamped before the jump, and the assertion below would pass with or + // without a recovery path. + vi.setSystemTime(Date.now() + 20_000); + await expect( + store.append({ frameSequence: 1n, payload: Uint8Array.of(4, 5, 6) }), + ).rejects.toBeInstanceOf(QwpReplayStoreLockLostError); + + await beat(); + + // The producer is live again rather than terminal for the rest of the + // process, and the frame staged before the stall is still journalled. + await store.append({ + frameSequence: 1n, + payload: Uint8Array.of(4, 5, 6), + }); + await store.close(); + } finally { + vi.useRealTimers(); + } + + const reopened = new QwpNodeFileReplayStore({ directory }); + await expect(reopened.load()).resolves.toEqual([ + { frameSequence: 0n, payload: Uint8Array.of(1, 2, 3) }, + { frameSequence: 1n, payload: Uint8Array.of(4, 5, 6) }, + ]); + await reopened.close(); + }); + it("reclaims a slot whose owner heartbeat stopped", async () => { const directory = await trackedDirectory(); const ownerPath = join(directory, ".lock.owner"); From 83092074f08ed8d07dfac70172696bab75fc68cd Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 3 Sep 2026 00:49:49 +0100 Subject: [PATCH 251/265] fix(qwp): enforce both table caps where the batch can still be split Two caps were checked below the layer that can act on them, so crossing either wedged the sender: flush() and close() raised the same error for the rest of its life, and the staged rows could be neither sent nor discarded. QWP_MAX_ROWS_PER_TABLE is enforced inside encodeQwpIngressFrame(), which planIngressFrames() calls before its size test and its bisection, so the throw escaped the planner. It was also a plain Error, and close() only discards staging for QwpBatchTooLargeError. A table's row count is knowable without encoding it, so test it first and let an over-cap table bisect exactly like an oversized one -- splitUnitCount() already counts rows, so a table past the cap always leaves more than one unit to split. QWP_MAX_COLUMNS_PER_TABLE is checked per column on the fluent path, for exactly this reason, but the compiled-writer path merged a whole schema into the table without it. Compile time bounds one writer's own schema, so two writers on one table can exceed the cap even though each fits alone, as can a writer appending onto a table the fluent API already filled. Reject the row before the merge, so the table is left as it was and only that row is lost. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VnqPKyf9FYsuPoA22V7qxj --- .../client-core/src/_qwp/ingress-session.ts | 45 +++++++++++------ packages/client-core/src/_qwp/sender.ts | 24 ++++++++++ test/qwp/sender.test.ts | 40 ++++++++++++++++ test/qwp/session.test.ts | 48 +++++++++++++++++++ 4 files changed, 143 insertions(+), 14 deletions(-) diff --git a/packages/client-core/src/_qwp/ingress-session.ts b/packages/client-core/src/_qwp/ingress-session.ts index 88102e8..f4ad0a5 100644 --- a/packages/client-core/src/_qwp/ingress-session.ts +++ b/packages/client-core/src/_qwp/ingress-session.ts @@ -4,6 +4,7 @@ import { encodeQwpDurableAckPollFrame, encodeQwpIngressFrame, QWP_FLAG_DEFER_COMMIT, + QWP_MAX_ROWS_PER_TABLE, QWP_STATUS, QwpIngressEncodeOptions, QwpIngressResponse, @@ -93,24 +94,40 @@ function planIngressFrames( const plan = (candidate: readonly QwpTableBuffer[]): void => { const dictionarySize = dictionary?.size; - const frame = encodeQwpIngressFrame(candidate, { - ...encodeOptions, - deferCommit: false, - dictionary, - confirmedMaxSymbolId: dictionary - ? confirmedMaxSymbolId - : encodeOptions.confirmedMaxSymbolId, - }); - if (frame.byteLength <= maxBatchSizeBytes) { - frames.push(frame); - if (dictionary) confirmedMaxSymbolId = dictionary.size - 1; - return; + // A table over the row cap cannot be encoded at all, and that is knowable + // without encoding it. Testing it here makes it a splittable candidate + // like any oversized one: encodeQwpIngressFrame() discovers the same cap, + // but it runs before the size test and the bisection below, so its throw + // escaped plan() entirely. The batch could then be neither split nor -- + // close() only discards staging for QwpBatchTooLargeError -- abandoned, + // and every later flush() and close() raised it again. + const overRowCap = candidate.some( + (table) => table.rowCount > QWP_MAX_ROWS_PER_TABLE, + ); + let frameByteLength = 0; + if (!overRowCap) { + const frame = encodeQwpIngressFrame(candidate, { + ...encodeOptions, + deferCommit: false, + dictionary, + confirmedMaxSymbolId: dictionary + ? confirmedMaxSymbolId + : encodeOptions.confirmedMaxSymbolId, + }); + if (frame.byteLength <= maxBatchSizeBytes) { + frames.push(frame); + if (dictionary) confirmedMaxSymbolId = dictionary.size - 1; + return; + } + frameByteLength = frame.byteLength; + if (dictionarySize !== undefined) dictionary!.truncate(dictionarySize); } - if (dictionarySize !== undefined) dictionary!.truncate(dictionarySize); const units = splitUnitCount(candidate); if (units <= 1) { - throw new QwpBatchTooLargeError(frame.byteLength, maxBatchSizeBytes); + // Only reachable for an oversized single row: splitUnitCount() counts + // rows, so a table over the row cap always leaves more than one unit. + throw new QwpBatchTooLargeError(frameByteLength, maxBatchSizeBytes); } const [left, right] = splitTablesAtUnit(candidate, Math.ceil(units / 2)); plan(left); diff --git a/packages/client-core/src/_qwp/sender.ts b/packages/client-core/src/_qwp/sender.ts index a1a46c7..e77e8c2 100644 --- a/packages/client-core/src/_qwp/sender.ts +++ b/packages/client-core/src/_qwp/sender.ts @@ -2174,6 +2174,30 @@ export class QwpSender { } } + // Compile time bounds one writer's own schema, but two writers on the same + // table merge into one, so their union can exceed the cap even though both + // fit alone -- as can a writer appending onto a table the fluent API has + // already filled. QwpTableBuffer catches it either way, but only once + // buildTable() runs during flush, and a throw there escapes before + // releaseStagedRows(), so every later flush() and close() hit the same wall + // and the whole staged batch became unreachable. Reject the row before the + // schema is merged, the way the fluent path rejects the column. + const stagedSchema = existingTable?.schema; + let mergedColumnCount = stagedSchema?.size ?? 0; + for (const nameKey of row.columns.keys()) { + if (!stagedSchema?.has(nameKey)) mergedColumnCount++; + } + if (mergedColumnCount > QWP_MAX_COLUMNS_PER_TABLE) { + throw new QwpWriterRowError( + schema.tableName, + undefined, + rowIndex, + new Error( + `column count exceeds maximum ${QWP_MAX_COLUMNS_PER_TABLE} for table '${schema.tableName}'`, + ), + ); + } + let table = existingTable; if (!table) { table = { name: schema.tableName, rows: [], schema: new Map() }; diff --git a/test/qwp/sender.test.ts b/test/qwp/sender.test.ts index 6c756b3..de2554c 100644 --- a/test/qwp/sender.test.ts +++ b/test/qwp/sender.test.ts @@ -1316,6 +1316,46 @@ describe("QWP high-level sender", () => { await sender.close(); }); + it("rejects two compiled writers whose union crosses the cap on one table", async () => { + // Compile time bounds each writer's own schema, so two that fit alone can + // still merge past the cap on a shared table. Only QwpTableBuffer caught + // that, inside buildTable() during flush, and the throw escaped before + // releaseStagedRows(): flush() and close() then raised it forever and the + // staged rows could be neither sent nor discarded. + const session = new RecordingSession(); + const sender = new QwpSender(async () => session, { autoFlush: false }); + + const half = QWP_MAX_COLUMNS_PER_TABLE / 2; + const first: Record> = {}; + const second: Record> = {}; + for (let index = 0; index < half; index++) { + first[`a${index}`] = long(); + second[`b${index}`] = long(); + } + const firstRow = Object.fromEntries( + Object.keys(first).map((name) => [name, 1n]), + ); + const secondRow = Object.fromEntries( + Object.keys(second).map((name) => [name, 2n]), + ); + + await sender.writer("wide", first).row(firstRow); + // Each writer is well under the cap on its own; together they are 2049. + await expect( + sender.writer("wide", { ...second, overflow: long() }).row({ + ...secondRow, + overflow: 3n, + }), + ).rejects.toThrow(/column count exceeds maximum 2048/); + + // The rejected row never reached the schema, so the sender still flushes + // and closes instead of wedging on the cap. + expect(sender.metrics.pendingRows).toBe(1); + await expect(sender.flush()).resolves.toBe(true); + expect(session.sends).toHaveLength(1); + await sender.close(); + }); + it("keeps the sender usable after a failed row, without losing staged rows", async () => { const session = new RecordingSession(); const sender = new QwpSender(async () => session, { autoFlush: false }); diff --git a/test/qwp/session.test.ts b/test/qwp/session.test.ts index 853c90c..2877aaf 100644 --- a/test/qwp/session.test.ts +++ b/test/qwp/session.test.ts @@ -24,6 +24,7 @@ import { QWP_FLAG_DEFER_COMMIT, QWP_FLAG_DELTA_SYMBOL_DICTIONARY, QWP_INGRESS_PROGRESS_KIND, + QWP_MAX_ROWS_PER_TABLE, QWP_STATUS, QWP_SENDER_ERROR_CATEGORY, QWP_SENDER_ERROR_POLICY, @@ -2198,6 +2199,53 @@ describe("QwpIngressSession", () => { await session.close(); }); + it("splits a table over the row cap instead of failing the whole batch", async () => { + // QWP_MAX_ROWS_PER_TABLE is enforced inside encodeQwpIngressFrame(), which + // planIngressFrames() calls before its size test and its bisection -- so + // the throw escaped the planner entirely. The batch could then be neither + // split nor, because close() only discards staging for + // QwpBatchTooLargeError, abandoned: flush() and close() raised the same + // plain Error for the rest of the sender's life. + const socket = new FakeWebSocket(); + const connecting = connectQwpBrowserWebSocket({ + url: "ws://localhost:9000/write/v4", + webSocketFactory: () => asQwpSocket(socket), + }); + socket.open(); + const rows = longTable( + "events", + Array.from({ length: QWP_MAX_ROWS_PER_TABLE + 1 }, (_, index) => + BigInt(index), + ), + ); + const session = new QwpIngressSession(await connecting, { + // Far above either half, so only the row cap can force the split. + maxBatchSizeBytes: 64 * 1024 * 1024, + }); + socket.onSend = () => { + const sequence = BigInt(socket.sent.length - 1); + socket.message( + ingressResponse(QWP_STATUS.OK, sequence, undefined, [ + ["events", sequence + 1n], + ]), + ); + }; + + await expect( + session.sendTables([rows], { gorilla: false }), + ).resolves.toMatchObject({ sequence: 1n }); + + const rowCounts = socket.sent.map(firstIngressTableRowCount); + expect(socket.sent).toHaveLength(2); + expect(rowCounts.every((count) => count <= QWP_MAX_ROWS_PER_TABLE)).toBe( + true, + ); + expect(rowCounts.reduce((total, count) => total + count, 0)).toBe( + QWP_MAX_ROWS_PER_TABLE + 1, + ); + await session.close(); + }, 60_000); + it("splits an oversized ingress flush at row boundaries under the negotiated cap", async () => { const socket = new FakeWebSocket(); const connecting = connectQwpBrowserWebSocket({ From 57684fffd1cb4ce55e2d7b3b7f2afe02f801a371 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 3 Sep 2026 00:51:52 +0100 Subject: [PATCH 252/265] fix(qwp): quarantine an orphan whose dictionary cannot be reconstructed isQuarantinableReplayRecoveryError() treats QwpReplayStoreCorruptionError and QwpUnrecoverableReplayDictionaryError identically on the foreground path: both are journal verdicts, and delta frames referencing symbol IDs no replay can resolve are as final as a torn segment. isTerminalDrainFailure() carried only the first, even though its own doc comment names "a corrupt journal" as terminal by design. An orphan slot in that state was therefore re-adopted on every scan -- by default every 30 seconds, for the life of the process -- taking the slot's advisory lock, re-scanning every segment and re-reading payloads each time, and occupying one of maxConcurrent drain slots on each pass. No `.failed` sentinel was ever written, so the operator got no signal and the documented retryQwpNodeOrphanSlot() hook could never become relevant. Retrying is the safe direction and quarantine here is destructive by report, which is why the omission was easy to miss; but the two classifiers disagreeing about one error is what made the loop endless. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VnqPKyf9FYsuPoA22V7qxj --- .../src/qwp-node/orphan-drainer.ts | 10 +++++ test/qwp/orphan-drainer.test.ts | 39 +++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/packages/nodejs-client/src/qwp-node/orphan-drainer.ts b/packages/nodejs-client/src/qwp-node/orphan-drainer.ts index d20b2af..e348936 100644 --- a/packages/nodejs-client/src/qwp-node/orphan-drainer.ts +++ b/packages/nodejs-client/src/qwp-node/orphan-drainer.ts @@ -7,6 +7,7 @@ import { QwpConnectionCloseInfo, QwpIngressTransportMetrics, QwpReplayRejectedError, + QwpUnrecoverableReplayDictionaryError, QwpUpgradeError, } from "../../../client-core/src/_qwp/transport"; import { @@ -635,9 +636,18 @@ function delay(milliseconds: number): Promise { * retry loop the `.failed` sentinel exists to prevent. Poison escalation * driven by connection loss rather than a NACK arrives as a QwpProtocolError * and is already covered above. + * + * "A corrupt journal" is both journal verdicts, which is why the two are + * classified together by isQuarantinableReplayRecoveryError() on the + * foreground path. A dictionary that cannot be reconstructed is as final as a + * torn segment -- the frames reference symbol IDs no replay can resolve -- so + * omitting it here left the drainer re-adopting the slot every scan for good: + * taking its lock, re-reading every segment, and never writing the sentinel + * that retryQwpNodeOrphanSlot() exists to clear. */ function isTerminalDrainFailure(error: Error): boolean { if (error instanceof QwpReplayStoreCorruptionError) return true; + if (error instanceof QwpUnrecoverableReplayDictionaryError) return true; if (error instanceof QwpProtocolError) return true; if (error instanceof QwpReplayRejectedError) return true; if (error instanceof QwpCatchUpCapGapError) return true; diff --git a/test/qwp/orphan-drainer.test.ts b/test/qwp/orphan-drainer.test.ts index 21f4374..6fb0d8d 100644 --- a/test/qwp/orphan-drainer.test.ts +++ b/test/qwp/orphan-drainer.test.ts @@ -18,6 +18,7 @@ import { QWP_SENDER_ERROR_POLICY, QWP_STATUS, QwpReplayRejectedError, + QwpUnrecoverableReplayDictionaryError, type QwpSenderError, } from "../../packages/client-core/src/qwp"; @@ -397,6 +398,44 @@ describe("QWP Node orphan drainer", () => { await drainer.close(); }); + it("quarantines an orphan whose symbol dictionary cannot be reconstructed", async () => { + // The foreground path classifies this with QwpReplayStoreCorruptionError, + // through isQuarantinableReplayRecoveryError(): both are journal verdicts, + // and frames referencing symbol IDs no replay can resolve are as final as + // a torn segment. isTerminalDrainFailure() omitted it, so the drainer + // re-adopted the slot on every scan for good -- taking its lock and + // re-reading every segment each time -- while the operator got no sentinel + // and no data-loss notification. + const rootDirectory = await root(); + const directory = await recordSlot(rootDirectory, "dictionary"); + const terminal = new QwpUnrecoverableReplayDictionaryError( + "recovered delta frames reference unreconstructable symbol IDs", + ); + const senderErrors: QwpSenderError[] = []; + const drainer = new QwpNodeOrphanDrainer({ + rootDirectory, + scanIntervalMs: 0, + createSession: async () => { + throw terminal; + }, + onSenderError: (error) => senderErrors.push(error), + }); + drainer.start(); + await vi.waitFor(() => expect(drainer.metrics.failed).toBe(1)); + expect(await readdir(directory)).toContain(QWP_ORPHAN_FAILED_SENTINEL); + await vi.waitFor(() => expect(senderErrors).toHaveLength(1)); + expect(senderErrors[0]).toMatchObject({ + category: QWP_SENDER_ERROR_CATEGORY.DATA_LOSS, + appliedPolicy: QWP_SENDER_ERROR_POLICY.ABANDONED, + quarantinedPath: directory, + serverMessage: terminal.message, + }); + // Quarantined, so a later scan leaves it for the operator instead of + // adopting it again. + await expect(scanQwpNodeOrphanSlots(rootDirectory)).resolves.toEqual([]); + await drainer.close(); + }); + it("stops active sessions when the owning client closes", async () => { const rootDirectory = await root(); await recordSlot(rootDirectory, "offline"); From 5740f3362bd801622fc45c61ff3abfa72f84459b Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 3 Sep 2026 00:57:06 +0100 Subject: [PATCH 253/265] fix(qwp): enforce the egress maxBatchRows request on the answer maxBatchRows only ever reached the wire -- an upgrade header on Node, a query parameter in the browser -- and nothing checked the reply against it. The decoder sizes its scratch arrays from the row count the server declares, and release() deliberately keeps those arrays attached to the layout for the next batch, so a peer that ignores the request sets the session's memory floor for its lifetime. The only ceiling was QWP_MAX_CELLS_PER_BATCH multiplied by the buffer-pool size, which that cap's own comment puts at roughly half a gigabyte per slot: four frames of 4.19 MB were measured retaining 1.03 GB of ArrayBuffers, still held after releaseView() on every slot and never shrunk by a later, smaller batch. Give the decoder an optional maxBatchRows and reject a batch above it in prepare(), before any column is read -- reading one is what allocates. QwpEgressSession takes it as a session option, and both connect helpers default it to the value they put on the wire, so the request a client makes is the bound it enforces. Left unset, the cell cap remains the only bound, so nothing changes for a caller that never asked. The scratch reuse itself is deliberate and stays as it is; what was missing was a bound on the input that drives it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VnqPKyf9FYsuPoA22V7qxj --- QWP.md | 8 +++ packages/browser-client/src/index.ts | 8 ++- .../src/_qwp/_core/result-batch.ts | 22 +++++++ .../client-core/src/_qwp/egress-session.ts | 12 ++++ packages/nodejs-client/src/qwp.ts | 8 ++- test/qwp/egress.test.ts | 62 ++++++++++++++++++- test/qwp/public-api-contract.ts | 1 + 7 files changed, 117 insertions(+), 4 deletions(-) diff --git a/QWP.md b/QWP.md index 449e4b0..f6d9d75 100644 --- a/QWP.md +++ b/QWP.md @@ -1173,6 +1173,14 @@ sends `X-QWP-Max-Batch-Rows`; browsers use the `qwp_max_batch_rows` URL paramete which requires a server that supports browser QWP negotiation. Older servers ignore the browser parameter and keep their configured batch size. +The connect helpers also enforce that request on what comes back: a `RESULT_BATCH` +declaring more rows than were asked for is rejected as a `QwpProtocolError` before +any column is read. Decoder scratch is sized from the declared row count and +retained per buffer-pool slot for reuse, so an answer above the request would set +the session's memory floor for its lifetime. Set `maxBatchRows` on the session +options to bound a session built directly from a connection; left unset, the cell +cap below is the only bound. + A single `RESULT_BATCH` may declare at most `QWP_MAX_CELLS_PER_BATCH` cells -- 32Mi, its rows multiplied by its columns. The row and column caps bound each dimension on its own, and a compressed body detaches the grid they describe from diff --git a/packages/browser-client/src/index.ts b/packages/browser-client/src/index.ts index 4294135..8c51b18 100644 --- a/packages/browser-client/src/index.ts +++ b/packages/browser-client/src/index.ts @@ -840,7 +840,13 @@ export async function connectQwpBrowserEgress( sessionOptions.serverInfoTimeoutMs ?? QWP_DEFAULT_EGRESS_SERVER_INFO_TIMEOUT_MS, ), - sessionOptions, + // The request this client puts on the wire is also the bound it enforces + // on the answer; without it a peer's declared row count sizes the decoder + // scratch on its own. + { + ...sessionOptions, + maxBatchRows: sessionOptions.maxBatchRows ?? options.maxBatchRows, + }, signal, ); } diff --git a/packages/client-core/src/_qwp/_core/result-batch.ts b/packages/client-core/src/_qwp/_core/result-batch.ts index 7c4a309..378e5ef 100644 --- a/packages/client-core/src/_qwp/_core/result-batch.ts +++ b/packages/client-core/src/_qwp/_core/result-batch.ts @@ -1288,6 +1288,21 @@ interface PreparedResultBatch { /** Stateful decoder for connection-scoped QWP result batches. */ export class QwpResultBatchDecoder { + /** + * Upper bound on a batch's declared row count, taken from the client's own + * `maxBatchRows` request. + * + * That request only ever reached the wire -- an upgrade header on Node, a + * query parameter in the browser -- and nothing checked the answer against + * it. Scratch arrays are sized from the declared row count and deliberately + * retained per pool slot for reuse, so a peer that ignores the request, or a + * hostile one, sets this session's memory floor for its lifetime: bounded + * only by QWP_MAX_CELLS_PER_BATCH times the pool size, which the cap's own + * comment puts at roughly half a gigabyte per slot. + * + * Left undefined the batch is bounded by the cell cap alone, as before. + */ + maxBatchRows?: number; private readonly symbolDictionary: string[] = []; private readonly viewBatches: QwpResultBatchView[] = []; private readonly viewLayouts: QwpResultColumnViewLayout[][] = []; @@ -1423,6 +1438,13 @@ export class QwpResultBatchDecoder { "continuation RESULT_BATCH arrived before its schema-bearing batch", ); } + // Checked before the grid, because it is the bound this client actually + // asked for and the more actionable error of the two. + if (this.maxBatchRows !== undefined && rowCount > this.maxBatchRows) { + throw new QwpProtocolError( + `RESULT_BATCH declares ${rowCount} rows, above the ${this.maxBatchRows} this client requested`, + ); + } // Each dimension passed its own cap; the grid they describe still has to // be one this client will allocate. Checked before any column is read, // because reading one is what allocates. diff --git a/packages/client-core/src/_qwp/egress-session.ts b/packages/client-core/src/_qwp/egress-session.ts index 90da2c7..dfd071b 100644 --- a/packages/client-core/src/_qwp/egress-session.ts +++ b/packages/client-core/src/_qwp/egress-session.ts @@ -19,6 +19,7 @@ import { QwpServerInfoMessage, } from "./_core"; import { QwpAsyncQueue } from "./_internal/async-queue"; +import { validateQwpMaxBatchRows } from "./_internal/egress-limits"; import { QwpReconnectingEgressConnection } from "./_internal/reconnecting-egress-connection"; import { QwpBinaryConnection, @@ -41,6 +42,14 @@ export interface QwpEgressSessionOptions { queryTimeoutMs?: number; /** Maximum wait for a terminal response after CANCEL. Defaults to 5 seconds. */ cancelDrainTimeoutMs?: number; + /** + * Rejects a RESULT_BATCH declaring more rows than this. The connect helpers + * default it to the `maxBatchRows` they put on the wire, so the request the + * client makes is also the bound it enforces; decoder scratch is sized from + * the declared row count and retained per pool slot, so an answer above the + * request would set this session's memory floor for its lifetime. + */ + maxBatchRows?: number; /** * Bounded failover policy. Failover and at-least-once active-query replay * are enabled by default; set false to keep one fixed connection. @@ -84,6 +93,7 @@ interface QwpValidatedEgressSessionOptions { readonly bufferPoolSize: number; readonly queryTimeoutMs: number; readonly cancelDrainTimeoutMs: number; + readonly maxBatchRows?: number; } interface QwpReplayableQueryRequest { @@ -148,6 +158,7 @@ function validateEgressSessionOptions( options.cancelDrainTimeoutMs ?? 5_000, "cancelDrainTimeoutMs", ), + maxBatchRows: validateQwpMaxBatchRows(options.maxBatchRows), }; } @@ -686,6 +697,7 @@ export class QwpEgressSession implements QwpEgressQueryControl { this.defaultInitialCredit = validated.initialCredit; this.bufferPoolSize = validated.bufferPoolSize; this.cancelDrainTimeoutMs = validated.cancelDrainTimeoutMs; + this.decoder.maxBatchRows = validated.maxBatchRows; let resolve!: (value: QwpServerInfoMessage) => void; let reject!: (error: unknown) => void; this.ready = new Promise((res, rej) => { diff --git a/packages/nodejs-client/src/qwp.ts b/packages/nodejs-client/src/qwp.ts index 4540576..60a7714 100644 --- a/packages/nodejs-client/src/qwp.ts +++ b/packages/nodejs-client/src/qwp.ts @@ -917,7 +917,13 @@ export async function connectQwpNodeEgress( sessionOptions.serverInfoTimeoutMs ?? QWP_DEFAULT_EGRESS_SERVER_INFO_TIMEOUT_MS, ), - sessionOptions, + // The request this client puts on the wire is also the bound it enforces + // on the answer; without it a peer's declared row count sizes the decoder + // scratch on its own. + { + ...sessionOptions, + maxBatchRows: sessionOptions.maxBatchRows ?? options.maxBatchRows, + }, signal, ); } diff --git a/test/qwp/egress.test.ts b/test/qwp/egress.test.ts index e794d0f..25bfd49 100644 --- a/test/qwp/egress.test.ts +++ b/test/qwp/egress.test.ts @@ -231,7 +231,11 @@ function rleZstdFrame( } /** A compressed RESULT_BATCH declaring an all-NULL grid of the given shape. */ -function compressedAllNullBatch(rows: number, columns: number): Uint8Array { +function compressedAllNullBatch( + rows: number, + columns: number, + requestId = 1n, +): Uint8Array { const schema = new QwpByteWriter(); writeQwpVarint(schema, 0); // table name writeQwpVarint(schema, rows); @@ -254,7 +258,7 @@ function compressedAllNullBatch(rows: number, columns: number): Uint8Array { schemaBytes.length + columns * (1 + bitmapBytes), ); const payload = new QwpByteWriter(); - payload.writeUint8(QWP_EGRESS_MESSAGE.RESULT_BATCH).writeBigUint64(1n); + payload.writeUint8(QWP_EGRESS_MESSAGE.RESULT_BATCH).writeBigUint64(requestId); writeQwpVarint(payload, 0); payload.writeBytes(body); return encodeQwpFrame(payload.toUint8Array(), QWP_FLAG_ZSTD, 1); @@ -811,6 +815,29 @@ describe("QWP result batch decoder", () => { expect(batch.get(0, 0)).toBeNull(); }); + it("bounds a RESULT_BATCH by the row count this client asked for", () => { + // maxBatchRows only ever reached the wire -- an upgrade header on Node, a + // query parameter in the browser -- and nothing checked the answer against + // it. Scratch arrays are sized from the declared row count and are + // deliberately retained per pool slot for reuse, so a peer that ignores + // the request, or a hostile one, sets this session's memory floor for its + // lifetime: bounded only by the cell cap times the pool size. + const message = decodeQwpEgressMessage(compressedAllNullBatch(4096, 4)); + if (message.kind !== "result-batch") throw new Error("unexpected message"); + + const decoder = new QwpResultBatchDecoder(); + decoder.maxBatchRows = 1024; + const before = process.memoryUsage().heapUsed; + expect(() => decoder.decode(message)).toThrow( + /declares 4096 rows, above the 1024 this client requested/, + ); + // Rejected in prepare(), before a column is read. + expect(process.memoryUsage().heapUsed - before).toBeLessThan(50e6); + + // Left unset the cell cap stays the only bound, as before. + expect(new QwpResultBatchDecoder().decode(message).rowCount).toBe(4096); + }); + it("bounds the delta symbol dictionary a RESULT_BATCH declares", () => { // A zero-length entry costs one decompressed byte, so a few hundred // Zstd-compressed bytes can declare millions of them. Reject beyond the @@ -1048,6 +1075,37 @@ describe("QwpEgressSession", () => { ), ).rejects.toThrow("cancelDrainTimeoutMs must be a positive finite number"); expect(factoryCalls).toBe(0); + + await expect( + QwpEgressSession.connect( + async () => { + factoryCalls++; + return new FakeConnection(); + }, + { maxBatchRows: 0 }, + ), + ).rejects.toThrow("maxBatchRows must be an integer between 1 and"); + expect(factoryCalls).toBe(0); + }); + + it("enforces its maxBatchRows on the batches a query receives", async () => { + // The session has to hand its own request down to the decoder; otherwise + // the bound exists only as a header on the wire. + const connection = new FakeConnection(); + const session = new QwpEgressSession(connection, { maxBatchRows: 1024 }); + connection.receive(serverInfo()); + await session.ready; + + const query = await session.query("select * from x"); + connection.receive(compressedAllNullBatch(4096, 4, 0n)); + + const consume = async () => { + for await (const batch of query) void batch; + }; + await expect(consume()).rejects.toThrow( + /declares 4096 rows, above the 1024 this client requested/, + ); + await session.close().catch(() => undefined); }); it("closes the transport when SERVER_INFO does not arrive", async () => { diff --git a/test/qwp/public-api-contract.ts b/test/qwp/public-api-contract.ts index 528c665..dcd6341 100644 --- a/test/qwp/public-api-contract.ts +++ b/test/qwp/public-api-contract.ts @@ -225,6 +225,7 @@ const egressSessionOptionsContract: QwpEgressSessionOptions = { bufferPoolSize: 4, queryTimeoutMs: 30_000, cancelDrainTimeoutMs: 5_000, + maxBatchRows: 4096, }; const fixedConnectionIngressContract: QwpIngressSessionOptions = { From d2bc30521bafbe2963981a7687aad4f1ec5fc52f Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 3 Sep 2026 00:59:48 +0100 Subject: [PATCH 254/265] fix(ilp): close the symbol section only on a column call that lands validateColumnCall() set hasColumnCall before the checks that can still reject the call: the value-type test in writeColumn(), the integer test in intColumn(), the array and decimal capability rejections on v1/v2, and the shape and scale validation above them. A caught column error therefore closed the row's symbol section as well, and the next symbol() raised "Symbol can be added only after table name is set and before any column added" -- an unrelated second failure on a path that worked before this branch. A capability probe (try arrayColumn(), fall back to a symbol) is the shape that hits it. The flag's purpose is unchanged and still call-based rather than byte-based: a nullish value writes nothing but does move the caller past the symbols, which is the whole reason it is not judged on hasColumns. What it must not count is a call that contributed nothing at all. Mark it in writeColumn(), past every check that can reject, and in a new omitColumn() helper the nullish branches return through. symbol() keeps returning `this` -- a symbol never closes the section it belongs to. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VnqPKyf9FYsuPoA22V7qxj --- packages/nodejs-client/src/buffer/base.ts | 34 ++++++++++-- packages/nodejs-client/src/buffer/bufferv1.ts | 2 +- packages/nodejs-client/src/buffer/bufferv2.ts | 4 +- packages/nodejs-client/src/buffer/bufferv3.ts | 4 +- test/sender.buffer.test.ts | 54 +++++++++++++++++++ 5 files changed, 89 insertions(+), 9 deletions(-) diff --git a/packages/nodejs-client/src/buffer/base.ts b/packages/nodejs-client/src/buffer/base.ts index a3bc742..64bbc14 100644 --- a/packages/nodejs-client/src/buffer/base.ts +++ b/packages/nodejs-client/src/buffer/base.ts @@ -193,6 +193,8 @@ abstract class SenderBufferBase implements SenderBuffer { symbol(name: string, value: unknown): SenderBuffer { this.validateSymbolCall(name); // A null or undefined value omits the symbol entirely (see issue #28). + // A symbol never closes the symbol section, so this does not mark a + // column call the way the omitting column setters do. if (this.isNullOrUndefined(value)) { return this; } @@ -218,7 +220,7 @@ abstract class SenderBufferBase implements SenderBuffer { this.validateColumnCall(name); // A null or undefined value omits the column entirely (see issue #28). if (this.isNullOrUndefined(value)) { - return this; + return this.omitColumn(); } this.writeColumn( name, @@ -246,7 +248,7 @@ abstract class SenderBufferBase implements SenderBuffer { this.validateColumnCall(name); // A null or undefined value omits the column entirely (see issue #28). if (this.isNullOrUndefined(value)) { - return this; + return this.omitColumn(); } this.writeColumn( name, @@ -302,7 +304,7 @@ abstract class SenderBufferBase implements SenderBuffer { this.validateColumnCall(name); // A null or undefined value omits the column entirely (see issue #28). if (this.isNullOrUndefined(value)) { - return this; + return this.omitColumn(); } if (!Number.isInteger(value)) { throw new Error(`Value must be an integer, received ${value}`); @@ -363,7 +365,7 @@ abstract class SenderBufferBase implements SenderBuffer { this.validateTimestampUnit(unit); // A null or undefined value omits the column entirely (see issue #28). if (this.isNullOrUndefined(value)) { - return this; + return this.omitColumn(); } if (typeof value !== "bigint" && !Number.isInteger(value)) { throw new Error( @@ -521,6 +523,11 @@ abstract class SenderBufferBase implements SenderBuffer { * misspelled or over-long name first surfaces in production, on the row that * happens to be populated. * + * It deliberately does not mark {@link hasColumnCall}: a call that throws + * contributed nothing to the row, so it must not close the symbol section. + * See {@link omitColumn} and {@link writeColumn}, which mark it once the + * call is known to write or to deliberately omit. + * * @param name - The column name to validate. */ protected validateColumnCall(name: string): void { @@ -531,7 +538,24 @@ abstract class SenderBufferBase implements SenderBuffer { throw new Error("Column can be set only after table name is set"); } validateColumnName(name, this.maxNameLength); + } + + /** + * @ignore + * Records a column call that deliberately wrote nothing, and returns the + * buffer so setters can `return this.omitColumn()`. + * + * A nullish value omits the column but still moves the caller past the + * symbol section, so the ordering rule has to count it. A call that *threw* + * did not, which is why the flag is set here rather than in + * {@link validateColumnCall}: marking it up front closed the symbol section + * on rejected calls too, so a caller that caught the error and fell back to + * symbol() -- probing arrayColumn() on protocol v1, say -- got a second, + * unrelated "Symbol can be added only after table name is set" failure. + */ + protected omitColumn(): SenderBuffer { this.hasColumnCall = true; + return this; } /** @@ -581,6 +605,8 @@ abstract class SenderBufferBase implements SenderBuffer { `Column value must be of type ${valueType}, received ${typeof value}`, ); } + // Past every check that can reject the call: this one writes. + this.hasColumnCall = true; this.checkCapacity([name], 2 + name.length); this.write(this.hasColumns ? "," : " "); this.writeEscaped(name); diff --git a/packages/nodejs-client/src/buffer/bufferv1.ts b/packages/nodejs-client/src/buffer/bufferv1.ts index 17ab531..3606d25 100644 --- a/packages/nodejs-client/src/buffer/bufferv1.ts +++ b/packages/nodejs-client/src/buffer/bufferv1.ts @@ -31,7 +31,7 @@ class SenderBufferV1 extends SenderBufferBase { this.validateColumnCall(name); // A null or undefined value omits the column entirely (see issue #28). if (this.isNullOrUndefined(value)) { - return this; + return this.omitColumn(); } this.writeColumn( name, diff --git a/packages/nodejs-client/src/buffer/bufferv2.ts b/packages/nodejs-client/src/buffer/bufferv2.ts index d2166f5..2f68518 100644 --- a/packages/nodejs-client/src/buffer/bufferv2.ts +++ b/packages/nodejs-client/src/buffer/bufferv2.ts @@ -47,7 +47,7 @@ class SenderBufferV2 extends SenderBufferBase { this.validateColumnCall(name); // A null or undefined value omits the column entirely (see issue #28). if (this.isNullOrUndefined(value)) { - return this; + return this.omitColumn(); } this.writeColumn( name, @@ -100,7 +100,7 @@ class SenderBufferV2 extends SenderBufferBase { this.validateColumnCall(name); // A null or undefined value omits the column entirely (see issue #28). if (this.isNullOrUndefined(value)) { - return this; + return this.omitColumn(); } const dimensions = getDimensions(value); diff --git a/packages/nodejs-client/src/buffer/bufferv3.ts b/packages/nodejs-client/src/buffer/bufferv3.ts index d22c3c1..c3b91e6 100644 --- a/packages/nodejs-client/src/buffer/bufferv3.ts +++ b/packages/nodejs-client/src/buffer/bufferv3.ts @@ -49,7 +49,7 @@ class SenderBufferV3 extends SenderBufferV2 { this.validateColumnCall(name); // A null or undefined value omits the column entirely (see issue #28). if (this.isNullOrUndefined(value)) { - return this; + return this.omitColumn(); } let str = ""; if (typeof value === "string") { @@ -101,7 +101,7 @@ class SenderBufferV3 extends SenderBufferV2 { } // A null or undefined value omits the column entirely (see issue #28). if (this.isNullOrUndefined(unscaled)) { - return this; + return this.omitColumn(); } let arr: number[]; if (typeof unscaled === "bigint") { diff --git a/test/sender.buffer.test.ts b/test/sender.buffer.test.ts index 5dea82d..2275db1 100644 --- a/test/sender.buffer.test.ts +++ b/test/sender.buffer.test.ts @@ -615,6 +615,60 @@ describe("Sender message builder test suite (anything not covered in client inte ); }); + it("keeps the symbol section open when a column call is rejected", async function () { + // hasColumnCall was set inside validateColumnCall(), before every check + // that can still reject the call, so a caught column error closed the + // row's symbol section as well. A capability probe -- try arrayColumn(), + // fall back to a symbol -- then hit a second, unrelated "Symbol can be + // added only after table name is set" failure. Only a call that writes, + // or that deliberately omits a nullish value, moves past the symbols. + const build = () => + new Sender({ + protocol: "tcp", + protocol_version: "1", + host: "host", + auto_flush: false, + init_buf_size: 1024, + }).table("t"); + + const rejected: [string, (sender: Sender) => unknown][] = [ + ["arrays on v1", (sender) => sender.arrayColumn("a", [1.0])], + ["decimals on v1", (sender) => sender.decimalColumnText("d", "1.5")], + ["a non-integer int", (sender) => sender.intColumn("i", 1.5)], + [ + "a wrongly typed value", + (sender) => sender.stringColumn("c", 42 as unknown as string), + ], + ]; + + for (const [label, reject] of rejected) { + const sender = build(); + expect(() => reject(sender), label).toThrow(); + // The failed call contributed nothing, so symbols are still legal. + await sender.symbol("s", "v").intColumn("v", 1).atNow(); + expect(bufferContent(sender), label).toBe("t,s=v v=1i\n"); + await sender.close(); + } + + // A column that omitted a nullish value did contribute a call, so it does + // close the section -- the rule the flag exists for. + const omitted = build(); + expect(() => omitted.intColumn("i", null).symbol("s", "v")).toThrow( + "Symbol can be added only after table name is set and before any column added", + ); + await omitted.close(); + + // A nullish symbol is still a symbol, and never closes the section. + const nullishSymbol = build(); + await nullishSymbol + .symbol("a", null) + .symbol("b", "v") + .intColumn("v", 1) + .atNow(); + expect(bufferContent(nullishSymbol)).toBe("t,b=v v=1i\n"); + await nullishSymbol.close(); + }); + it("discards a row that cannot be closed instead of wedging the sender", async function () { // A rejected close used to leave hasTable set and position past // endOfLastRow, so every later table() raised "Table name has already been From 5085e4fd1ea35eb70e5978f32ce39b167edf10cf Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 3 Sep 2026 01:02:20 +0100 Subject: [PATCH 255/265] fix(qwp): reconcile the udp and ws/wss connect-string vocabularies Two halves of the same inconsistency, in opposite directions. `udp::` shares the ILP parser with http/tcp, so init_buf_size, max_buf_size, request_timeout, request_min_throughput, retry_timeout and stdlib_http all parsed on a udp connect string -- and nothing read any of them. The UDP branch of the Sender returns before createBuffer(), and QwpNodeUdpOptions has no field for the rest, so a user capping memory with max_buf_size got no cap and no diagnostic. The parser already rejects an unknown key; a known key that silently does nothing was the outlier. Reject them by name, and say where they do apply. The ws/wss schema's own hints then claimed those keys "apply to legacy http/tcp/udp transports" -- pointing users at the transport that ignores them. They now name http/tcp. In the other direction, `tls_ca` had no relocation hint at all, even though the ILP parser rejects tls_roots by telling users to use tls_ca. Migrating https::addr=h;tls_ca=... to wss:: therefore hit a bare "unknown configuration key: tls_ca" while the client's other parser insisted that was the supported spelling. RELOCATED_HINTS exists for exactly this, so give it the entry: the QWP vocabulary is deliberately Java-aligned on tls_roots, and the way back should say so. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VnqPKyf9FYsuPoA22V7qxj --- packages/nodejs-client/src/options.ts | 32 +++++++++++++ .../src/qwp-node/client-config.ts | 12 ++--- test/options.test.ts | 46 +++++++++++++++++++ 3 files changed, 83 insertions(+), 7 deletions(-) diff --git a/packages/nodejs-client/src/options.ts b/packages/nodejs-client/src/options.ts index 1726bd1..f16201b 100644 --- a/packages/nodejs-client/src/options.ts +++ b/packages/nodejs-client/src/options.ts @@ -792,6 +792,38 @@ function parseUdpOptions(options: SenderOptions) { "max_datagram_size and multicast_ttl are only supported for QWP UDP transport", ); } + validateUdpUnsupportedOptions(options); +} + +/** + * Rejects the ILP-only keys a `udp::` connect string used to accept in + * silence. + * + * UDP shares this parser with http/tcp, so every key they accept parsed here + * too -- but the UDP branch of the Sender returns before createBuffer(), and + * QwpNodeUdpOptions has no field for any of these, so nothing ever read them. + * A user capping memory with max_buf_size got no cap and no diagnostic, while + * the QWP parser's own hint for the same key pointed at udp as a transport + * that supports it. The parser rejects an unknown key, so accepting a known + * one that does nothing is the outlier. + */ +function validateUdpUnsupportedOptions(options: SenderOptions): void { + if (options.protocol !== UDP) return; + const unsupported = [ + "init_buf_size", + "max_buf_size", + "request_timeout", + "request_min_throughput", + "retry_timeout", + "stdlib_http", + ] as const; + for (const key of unsupported) { + if (options[key] !== undefined) { + throw new Error( + `'${key}' option is not supported for QWP UDP transport, it applies to the http/tcp transports only`, + ); + } + } } /** @ignore Rejects security options that the fire-and-forget UDP wire cannot honor. */ diff --git a/packages/nodejs-client/src/qwp-node/client-config.ts b/packages/nodejs-client/src/qwp-node/client-config.ts index a7a7dc9..6d20acd 100644 --- a/packages/nodejs-client/src/qwp-node/client-config.ts +++ b/packages/nodejs-client/src/qwp-node/client-config.ts @@ -34,13 +34,11 @@ const RELOCATED_HINTS = new Map([ "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)", - ], + ["init_buf_size", "(applies to legacy http/tcp transports only)"], + ["max_buf_size", "(applies to legacy http/tcp transports only)"], + ["request_timeout", "(applies to legacy http/tcp transports only)"], + ["request_min_throughput", "(applies to legacy http/tcp transports only)"], + ["tls_ca", "(use tls_roots on ws/wss)"], ["max_datagram_size", "(applies to the legacy udp transport only)"], ["multicast_ttl", "(applies to the legacy udp transport only)"], ]); diff --git a/test/options.test.ts b/test/options.test.ts index b4905d3..018b3ea 100644 --- a/test/options.test.ts +++ b/test/options.test.ts @@ -865,6 +865,52 @@ describe("Configuration string parser suite", function () { ); }); + it("rejects the ILP-only keys a udp connect string cannot act on", async function () { + // udp shares this parser with http/tcp, so their keys parsed here too -- + // but the UDP branch of the Sender returns before createBuffer() and + // QwpNodeUdpOptions has no field for any of them, so nothing read them. A + // user capping memory with max_buf_size got no cap and no diagnostic, + // while the QWP schema's hint for the same key named udp as a transport + // that supports it. An unknown key was already rejected; a known key that + // does nothing was the outlier. + for (const key of [ + "init_buf_size=1024", + "max_buf_size=1048576", + "request_timeout=5000", + "request_min_throughput=1024", + "retry_timeout=1000", + "stdlib_http=on", + ]) { + const name = key.split("=")[0]; + await expect( + SenderOptions.fromConfig(`udp::addr=host;${key};`), + key, + ).rejects.toThrow( + `'${name}' option is not supported for QWP UDP transport, it applies to the http/tcp transports only`, + ); + // http and tcp still take them, and ws/wss still relocate them. + await expect( + SenderOptions.fromConfig(`tcp::addr=host;protocol_version=1;${key};`), + key, + ).resolves.toBeDefined(); + } + + // The ws/wss hint no longer names udp as a transport these apply to. + await expect( + SenderOptions.fromConfig("ws::addr=host;max_buf_size=1048576;"), + ).rejects.toThrow( + "unknown configuration key: max_buf_size (applies to legacy http/tcp transports only)", + ); + + // tls_ca is the ILP spelling of the QWP schema's tls_roots, and the ILP + // parser points users at it by name, so ws/wss owes them the way back. + await expect( + SenderOptions.fromConfig("wss::addr=host;tls_ca=/tmp/ca.pem;"), + ).rejects.toThrow( + "unknown configuration key: tls_ca (use tls_roots on ws/wss)", + ); + }); + it("parses a ws connect string with one schema, whichever entry point is used", async function () { // There must be a single QWP parser: Sender.fromConfig() and // SenderOptions.fromConfig() + new Sender() previously disagreed, and From 85ec77a1a4d5a953657d3571af96b8871b76d462 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 3 Sep 2026 01:04:58 +0100 Subject: [PATCH 256/265] docs: update the review skill for the workspace split The skill was last touched on 2026-08-21; the split into a pnpm workspace landed on 2026-09-02. CLAUDE.md and CONTRIBUTING.md were corrected with it and this file was not, so it directed reviewers at twelve source paths that no longer exist -- src/index.ts, src/qwp/{index,node,browser}.ts, src/_qwp/**, src/qwp-node/**, src/buffer/**, src/_qwp/_core/constants.ts among them. That is worse than ordinary doc drift here, because the paths are what the skill's own procedure runs on. The mandatory Step 2.5b callsite inventory tells a reviewer to sweep the entry points and the QWP core; every one of those globs now matches nothing, silently. The high-risk trigger list keys off the same paths, so a future change to the buffers or the replay journal would not raise the level-3 recommendation it exists to raise. Repoint every path at packages/{client-core,nodejs-client}/src, and fix the three non-path claims that went with them: four public entry points where two ship, four exports subpaths where each package declares only ".", and fzstd described as a devDependency when it is a dependency of the private client-core package that no published package declares -- which is the actual reason the bundler has to inline it. The skill also never named @questdb/browser-client, the second package it is meant to review, so it does now. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VnqPKyf9FYsuPoA22V7qxj --- .claude/skills/review-pr/SKILL.md | 109 +++++++++++++++++------------- 1 file changed, 62 insertions(+), 47 deletions(-) diff --git a/.claude/skills/review-pr/SKILL.md b/.claude/skills/review-pr/SKILL.md index 7f5bb93..3500982 100644 --- a/.claude/skills/review-pr/SKILL.md +++ b/.claude/skills/review-pr/SKILL.md @@ -1,6 +1,6 @@ --- name: review-pr -description: Review a GitHub pull request or local Git range against @questdb/nodejs-client TypeScript ILP/QWP client coding standards +description: Review a GitHub pull request or local Git range against the QuestDB JavaScript client (@questdb/nodejs-client and @questdb/browser-client) TypeScript ILP/QWP coding standards argument-hint: "[PR number or URL | --range=..] [--level=0..3]" allowed-tools: Bash, Read, Grep, Glob, Agent --- @@ -25,10 +25,12 @@ to verify a regression test against reverted production hunks; remove it afterwa ## Review mindset You are a senior QuestDB engineer performing a blocking code review. -`@questdb/nodejs-client` is mission-critical software: it serializes rows into the -QuestDB InfluxDB Line Protocol (ILP) over HTTP/HTTPS or TCP/TCPS, and into the QuestDB -Wire Protocol (QWP) over WebSocket or fire-and-forget UDP, with a browser build, an -egress query path, and a crash-safe Node store-and-forward journal. A bug can silently +`@questdb/nodejs-client` and `@questdb/browser-client` are mission-critical software, +built from a shared private `@questdb/client-core` workspace package: they serialize +rows into the QuestDB InfluxDB Line Protocol (ILP) over HTTP/HTTPS or TCP/TCPS, and +into the QuestDB Wire Protocol (QWP) over WebSocket or fire-and-forget UDP, with a +browser build, an egress query path, and a crash-safe Node store-and-forward journal. +A bug can silently corrupt bytes, drop or duplicate rows, abandon persisted data, leak credentials, exhaust resources, or break supported Node.js and browser consumers. @@ -74,8 +76,9 @@ when the gates pass. Zero findings is a successful outcome. against the actual multiplier, and treat the PR description as a hypothesis. - **Assess reachability before reporting.** Drop theoretical paths that callers, validation, configuration, or buffer bounds make impossible. -- **Never review generated artifacts as source.** `dist/cjs/**`, `dist/es/**`, and - `docs/**` are generated. Review their `src/**/*.ts` or documentation source instead. +- **Never review generated artifacts as source.** `packages/*/dist/**` and `docs/**` + are generated. Review their `packages/*/src/**/*.ts` or documentation source + instead. ## Review level @@ -83,19 +86,20 @@ Parse `$ARGUMENTS` for `--level=N`, `-lN`, or a bare digit `0`-`3`. Default to level 0. Strip the level token and any `--range=` token before passing a PR target to `gh`. -| Level | What runs | -|-------|-----------| -| **0 (default)** | Steps 1, 2, 2.4, 2.5f, 2.6, and 4. Review inline without agent fanout. Build a compact coverage map and apply the Step 3b admission gate inline from a blank evidence form. | -| **1** | Add Steps 2.5a and 2.5e when tests change. Run Agent 1 plus at most two applicable roles from Agents 2-7, 9-13, and 14-15. Independently falsify each surviving atomic candidate. | -| **2** | Run all of Step 2.5, restricting 2.5b to exported/public/protected symbols, transport interfaces, shared helpers, and configuration options. Run Agent 1 plus at most four change-relevant roles. Independently falsify each surviving candidate. | -| **3** | Run the full workflow. Select at most six applicable discovery roles: Agent 1 always; Agent 8 when changed symbols have out-of-diff callers; Agents 2-7 and 14-15 when their domains are touched; Agents 9-13 for changed tests or a fix claim; Agent 10 only when a distinct adversarial pass is warranted. Depth comes from evidence, not agent count. | +| Level | What runs | +| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **0 (default)** | Steps 1, 2, 2.4, 2.5f, 2.6, and 4. Review inline without agent fanout. Build a compact coverage map and apply the Step 3b admission gate inline from a blank evidence form. | +| **1** | Add Steps 2.5a and 2.5e when tests change. Run Agent 1 plus at most two applicable roles from Agents 2-7, 9-13, and 14-15. Independently falsify each surviving atomic candidate. | +| **2** | Run all of Step 2.5, restricting 2.5b to exported/public/protected symbols, transport interfaces, shared helpers, and configuration options. Run Agent 1 plus at most four change-relevant roles. Independently falsify each surviving candidate. | +| **3** | Run the full workflow. Select at most six applicable discovery roles: Agent 1 always; Agent 8 when changed symbols have out-of-diff callers; Agents 2-7 and 14-15 when their domains are touched; Agents 9-13 for changed tests or a fix claim; Agent 10 only when a distinct adversarial pass is warranted. Depth comes from evidence, not agent count. | State the selected level at the start of the review. If defaulted, mention that level -3 exists for a full mission-critical pass. Changes to `src/buffer/**`, `src/_qwp/**`, -`src/qwp-node/**`, transport/auth/TLS, protocol negotiation, flush semantics, or any -public entry point (`src/index.ts`, `src/qwp/index.ts`, `src/qwp/node.ts`, -`src/qwp/browser.ts`) are high risk; recommend level 3, but honor an explicit lower -level and state the limitation. Replay-journal, ack-watermark, drainer, and failover +3 exists for a full mission-critical pass. Changes to +`packages/nodejs-client/src/buffer/**`, `packages/client-core/src/_qwp/**`, +`packages/nodejs-client/src/qwp-node/**`, transport/auth/TLS, protocol negotiation, +flush semantics, or either public entry point (`packages/nodejs-client/src/index.ts`, +`packages/browser-client/src/index.ts`) are high risk; recommend level 3, but honor +an explicit lower level and state the limitation. Replay-journal, ack-watermark, drainer, and failover changes stay high risk regardless of how small the diff is. ## Spawning review agents @@ -159,8 +163,9 @@ Check the repository conventions in `CONTRIBUTING.md` and recent accepted PRs: - README/TSDoc updates accompany user-visible behavior where needed. - New or renamed options document their defaults and deprecation path through `SenderOptions.resolveDeprecated`. -- New or renamed QWP keys are wired through `src/qwp-node/client-config.ts`, validated - against the transports that support them, and documented in `QWP.md`. +- New or renamed QWP keys are wired through + `packages/nodejs-client/src/qwp-node/client-config.ts`, validated against the + transports that support them, and documented in `QWP.md`. - A changed public QWP surface updates `test/qwp/public-api-contract.ts`. ## Step 2.4: Submodule boundaries (mandatory at every level) @@ -211,17 +216,21 @@ and exports. Group results by file and include overrides and implementations. At minimum check: -- All four public entry points — `src/index.ts`, `src/qwp/index.ts`, `src/qwp/node.ts`, - `src/qwp/browser.ts` — and emitted public type implications. +- Both public entry points — `packages/nodejs-client/src/index.ts` and + `packages/browser-client/src/index.ts` — and emitted public type implications. + Each re-exports the shared `packages/client-core/src/qwp` barrel, so a change + there reaches both packages. - `SenderBufferBase` plus `SenderBufferV1`/`V2`/`V3` overrides and `createBuffer`. - `SenderTransport` plus Undici, stdlib HTTP, and TCP implementations. - `SenderOptions.resolveAuto`, `resolveDeprecated`, config parsing, `fromConfig`, and - `fromEnv` for option changes, plus `src/qwp-node/client-config.ts` for QWP keys. -- Changed `src/_qwp/_core/**` constants and codecs against both the ingress encoder and - the egress decoder; one cap or type byte is normally read by both sides. + `fromEnv` for option changes, plus + `packages/nodejs-client/src/qwp-node/client-config.ts` for QWP keys. +- Changed `packages/client-core/src/_qwp/_core/**` constants and codecs against both + the ingress encoder and the egress decoder; one cap or type byte is normally read + by both sides. - `QwpSender` and the writer helpers, `QwpIngressSession`, `QwpEgressSession`, - `QwpClient`, the reconnecting connections in `src/_qwp/_internal/**`, and the UDP - sender. + `QwpClient`, the reconnecting connections in + `packages/client-core/src/_qwp/_internal/**`, and the UDP sender. - `QwpNodeFileReplayStore`, `QwpNodeOrphanDrainer`, the advisory lock, and the segment maintenance worker for any store-and-forward change. - Unit/integration tests and test helpers, including `test/qwp/**` and its fixtures. @@ -297,13 +306,16 @@ Record current facts with file/line citations; do not rely on this list becoming an mtime heartbeat for stale recovery. Reintroducing a native addon would break every consumer on a platform or Node major it has no binary for, so treat a new `optionalDependencies` entry or a compiled binary in the bundle as a finding. - `fzstd` is a devDependency that the bundler inlines; making it an external import - would break installs. -- Dual ESM/CJS build and every `package.json` exports subpath (`.`, `./qwp`, - `./qwp/browser`, `./qwp/node`), plus which sources each subpath is allowed to import. + `fzstd` is a dependency of the private `packages/client-core` package, which no + published package declares, so the bundler must inline it; making it an external + import would break every install. +- Dual ESM/CJS build. Each published package declares exactly one `package.json` + exports subpath, `.`, so those two specifiers are the whole public surface; check + which sources each is allowed to import. - ILP protocol default/negotiation and TCP's explicit-version requirement. - QWP `QWP_VERSION`, the `/write/v4` ingress and `/read/v1` egress routes, the caps in - `src/_qwp/_core/constants.ts`, and the capabilities negotiated per connection. + `packages/client-core/src/_qwp/_core/constants.ts`, and the capabilities negotiated + per connection. - `worker_threads` use by the segment maintenance worker, and the `Date.now()` / `Math.random()` dependencies in backoff, episode, and timeout accounting that deterministic tests must be able to control. @@ -408,10 +420,10 @@ Focus on per-row/per-cell `toString`, string concatenation, repeated `Buffer.byt per-character writes, resize copying, large arrays, and avoidable buffer copies. Every candidate must state its multiplier or fixed bound and whether users wait on the path. -**Agent 7 — Public API, compatibility, and code quality:** Check `src/index.ts`, ESM/ -CJS exports, `.d.ts` implications, TSDoc, option defaults/deprecations, supported Node -APIs, README/examples, unsound casts, dead code/imports, ESLint, Prettier, naming, and -member ordering. Separate compatibility defects from cosmetics. +**Agent 7 — Public API, compatibility, and code quality:** Check both package roots, +ESM/CJS exports, `.d.ts` implications, TSDoc, option defaults/deprecations, supported +Node APIs, README/examples, unsound casts, dead code/imports, ESLint, Prettier, +naming, and member ordering. Separate compatibility defects from cosmetics. **Agent 8 — Cross-context caller impact:** Walk every 2.5b callsite with callers up to two levels. For each, return `SAFE`, `CANDIDATE`, or `INSUFFICIENT_EVIDENCE` and state @@ -445,8 +457,9 @@ fails when the production fix is reverted in an isolated scratch worktree. **Agent 14 — QWP wire format and protocol sessions:** Reconstruct frame headers, LEB128 varints, column encodings, Gorilla bit packing, zstd framing, symbol-dictionary IDs with their delta/reset flags, decimal scale, geohash bits, array shape, and NULL -bitmaps against the caps in `src/_qwp/_core/constants.ts`. Check the ingress encoder and -the egress decoder together because both read the same constants. Check status-byte to +bitmaps against the caps in `packages/client-core/src/_qwp/_core/constants.ts`. Check +the ingress encoder and the egress decoder together because both read the same +constants. Check status-byte to category to policy mapping, per-table transaction grouping, durable-ACK negotiation, ingress cap splitting, and that a truncated, oversized, or hostile server frame is rejected before it is allocated, copied, or trusted. @@ -496,7 +509,7 @@ Admit a behavioral candidate only when every applicable field has cited evidence dispatch, ownership, and cleanup. - **Head observation:** executed trigger and observed result at the reviewed revision. - **Base observation:** identical trigger/result at `$BASE`, or `N/A — genuinely new - surface` with proof. +surface` with proof. - **User symptom:** independently observable consequence. - **Counterevidence search:** strongest disproof and why it does not apply. - **Artifact:** command/test, output, environment/configuration, and revision identity. @@ -519,8 +532,8 @@ Apply these special burdens: Then independently verify Node-client specifics: -1. Read exact source lines in `src/**/*.ts`, not generated output, and trace callers, - interfaces, factories, and v1/v2/v3 overrides. +1. Read exact source lines in `packages/*/src/**/*.ts`, not generated output, and + trace callers, interfaces, factories, and v1/v2/v3 overrides. 2. Count every emitted byte against capacity, including escaped multi-byte UTF-8, separators, suffixes, marker bytes, dimension headers, and decimal payloads. 3. Reconstruct expected wire bytes and compare them with both production output and @@ -538,8 +551,9 @@ Then independently verify Node-client specifics: 10. For test efficacy, prove the assertion reaches the change and would fail under the claimed regression. Recompute expected hex/bytes rather than trusting fixtures. 11. For QWP wire claims, reconstruct the frame bytes for encode and decode, and check - every length, cap, and flag against `src/_qwp/_core/constants.ts` rather than against - an assumed peer behavior. + every length, cap, and flag against + `packages/client-core/src/_qwp/_core/constants.ts` rather than against an assumed + peer behavior. 12. For replay, ack, reconnect, or failover claims, trace the cumulative ack watermark and prove which frames a restart, NACK, or non-orderly close resends or drops. Classify the failure through `qwpDefaultSenderErrorPolicy` before calling anything @@ -619,8 +633,8 @@ enumerated instance independently rather than sampling and generalizing. ### QWP wire format and sessions - Frame header magic, version, flags, table count, and payload length agree between - encoder and decoder, and every cap in `src/_qwp/_core/constants.ts` is enforced on both - sides. + encoder and decoder, and every cap in + `packages/client-core/src/_qwp/_core/constants.ts` is enforced on both sides. - Varints stay inside uint64; row, column, name-length, array-element, and dictionary limits are checked on encode and on decode. - Symbol dictionary IDs stay dense and connection-scoped; delta and reset flags match @@ -700,8 +714,9 @@ nor hard-fails on a transient outage. - Export new public symbols; treat removals/renames/signature/default changes as compatibility changes. -- Only the four documented entry points are public. Paths containing `internal`, - `qwp-node`, or `src` are implementation details even when a bundler resolves them. +- Only the two package roots are public. Paths containing `internal`, `qwp-node`, + `client-core`, or `src` are implementation details even when a bundler resolves + them. - A changed exported QWP symbol, option, constant, or error updates `test/qwp/public-api-contract.ts` and `QWP.md`. - Keep TSDoc/types accurate and avoid casts that hide runtime null/type problems. From 99d659f82ed18568218c1ebf32953b3c75e4ee6d Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 3 Sep 2026 01:13:56 +0100 Subject: [PATCH 257/265] docs: carry the upstream pool-startup contract into the review skill The skill is a hand-adapted fork of questdb/questdb's .claude/skills/review-pr/SKILL.md, taken at b941091. Exactly one upstream commit has landed on that file since: a99a54a, which dropped "sorted alphabetically" from its Java member-ordering standard. That needs no change here -- this adaptation never carried an alphabetical rule, only "member ordering" and "follow local naming/order", which is already what upstream now means. Comparing the two section by section did turn up a gap that predates the fork. Upstream's QWP client-contract checklist states a pool-startup rule this copy never carried: connectivity errors belong to the caller only during initialization, and which mode you start in decides who sees them. It applies here unchanged in substance -- the client has lazy_connect, off by default -- so it is adapted in with the local spelling, verified against the parser rather than the prose: lazy_connect=on resolves with no server present on initial_connect_retry=async and query_pool_min=0, and an explicit initial_connect_retry=off|sync or a positive query_pool_min is rejected before the client is created. It earns its place next to the budget rule above it: both are about the boundary past which a transient outage must never reach the producer, which is exactly what the slot-lock latch fixed in c2cf921 crossed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VnqPKyf9FYsuPoA22V7qxj --- .claude/skills/review-pr/SKILL.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.claude/skills/review-pr/SKILL.md b/.claude/skills/review-pr/SKILL.md index 3500982..44a200f 100644 --- a/.claude/skills/review-pr/SKILL.md +++ b/.claude/skills/review-pr/SKILL.md @@ -665,6 +665,15 @@ nor hard-fails on a transient outage. - Node foreground replay is unbounded after startup. Attempt and duration budgets apply to `"sync"` startup and to the browser/memory policy only; a budget that latches a running sender terminal during a long outage is a data-loss defect. +- Connectivity errors are the caller's problem only during initialization, and + `lazy_connect` decides who sees them. Left off, the default, the initial connect must + surface DNS, connection-refused, TLS, authentication and upgrade-timeout failures to + the caller. Set on, `connectQwpNodeClient()` must resolve with no server present: the + sender buffers immediately, ingress uses `initial_connect_retry=async`, and egress + defers to the first query on `query_pool_min=0`. An explicit + `initial_connect_retry=off|sync`, or a positive `query_pool_min`, conflicts with it + and is rejected before the client is created. Past initialization both modes revert + to the steady-state contract above, whichever one started the client. - Backoff is exponential with full jitter and a capped per-attempt delay, while the store-and-forward retry loop itself stays uncapped. - NACK policy follows `qwpDefaultSenderErrorPolicy`: `WRITE_ERROR`, `INTERNAL_ERROR`, From 6e022efa8c4be79b3a3261fc1579897f8bda35c9 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 3 Sep 2026 12:20:58 +0100 Subject: [PATCH 258/265] fix(qwp): stop an unref'd drain timer from stranding close() QwpNotificationDispatcher.close() settles from exactly two places: the drain finishing, or the drain deadline firing. Both ran on unref'd timers. In a process whose only remaining ref'd handles belong to the QWP client -- a batch ingest, a CLI, a SIGTERM shutdown -- the loop emptied the moment the sockets closed, neither timer fired, and Node exited 0 with the awaited close() never settling. Everything sequenced after it was skipped: the `finally` blocks QWP.md tells users to close in, a final log line, process.exitCode, closing other resources, reopening the same store-and-forward directory. A single pending notification was enough, and no user callback was needed to get there. errorDispatcher is constructed unconditionally in QwpReconnectingIngressConnection, falling back to defaultQwpSenderErrorHandler, and is offered on every sender error, so any client that saw one rejection before closing could hang. It contradicted the documented contract that close() always returns. Ref the two timers close() depends on. closeTimer is bounded by drainDeadlineMs and close() is an explicit caller action, so holding the loop open for that window is the right trade. schedule()'s timer stays unref'd while idle -- an observer that is merely watching must never keep a process alive -- and is ref'd once closing has started, which is the path that carries the drain after closeTimer has fired while a handler was mid-dispatch. The regression test asserts ref state through process.getActiveResourcesInfo() rather than trying to observe the hang: every dispatcher test runs under vitest, whose own handles keep the loop alive and hide this failure entirely. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VnqPKyf9FYsuPoA22V7qxj --- .../_qwp/_internal/notification-dispatcher.ts | 12 ++++++-- test/qwp/notification-dispatcher.test.ts | 29 +++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/packages/client-core/src/_qwp/_internal/notification-dispatcher.ts b/packages/client-core/src/_qwp/_internal/notification-dispatcher.ts index 3eb1d7b..ebfaf9c 100644 --- a/packages/client-core/src/_qwp/_internal/notification-dispatcher.ts +++ b/packages/client-core/src/_qwp/_internal/notification-dispatcher.ts @@ -83,13 +83,19 @@ export class QwpNotificationDispatcher { return this.closePromise; } this.schedule(); + // Deliberately ref'd, unlike the idle timer below. close() resolves only + // from this timer or from the drain finishing, so unref'ing it made the + // returned promise unsettleable whenever the QWP client held the last + // ref'd handle: the loop emptied, neither timer fired, and Node exited + // with everything sequenced after `await close()` skipped. The wait is + // bounded by drainDeadlineMs, and close() is an explicit caller action, so + // holding the loop open for that window is the correct trade. this.closeTimer = setTimeout(() => { this.closeTimer = undefined; this.dropped += this.queue.length; this.queue.length = 0; if (!this.dispatching) this.finishClose(); }, drainDeadlineMs); - unrefTimer(this.closeTimer); return this.closePromise; } @@ -99,7 +105,9 @@ export class QwpNotificationDispatcher { this.timer = undefined; this.dispatchOne(); }, 0); - unrefTimer(this.timer); + // An idle observer must never hold the process open, but once closing has + // started this timer is one of the two things that can settle close(). + if (!this.closing) unrefTimer(this.timer); } private dispatchOne(): void { diff --git a/test/qwp/notification-dispatcher.test.ts b/test/qwp/notification-dispatcher.test.ts index cd20f67..072aa9b 100644 --- a/test/qwp/notification-dispatcher.test.ts +++ b/test/qwp/notification-dispatcher.test.ts @@ -75,6 +75,35 @@ describe("QwpNotificationDispatcher", () => { } }); + it("holds the event loop open until close() settles", async () => { + // close() resolves only from its drain deadline or from the drain + // finishing, and both timers were unref'd. In a process whose only + // remaining handles belonged to the QWP client -- a batch job, or a + // SIGTERM shutdown -- the loop then emptied, neither timer fired, and Node + // exited with the awaited close() never settling: the `finally` blocks, + // the shutdown log and any process.exitCode after it were all skipped. + // A single pending notification was enough. vitest's own handles keep the + // loop alive and so hide the symptom entirely, which is why this asserts + // the ref state rather than trying to observe the hang. + const refdTimeouts = (): number => + process.getActiveResourcesInfo().filter((kind) => kind === "Timeout") + .length; + + const baseline = refdTimeouts(); + const dispatcher = new QwpNotificationDispatcher(() => {}, 8); + dispatcher.offer(1); + dispatcher.offer(2); + // An idle observer must still never keep the process alive by itself. + expect(refdTimeouts()).toBe(baseline); + + const closing = dispatcher.close(); + expect(refdTimeouts()).toBeGreaterThan(baseline); + + await closing; + expect(dispatcher.metrics.closed).toBe(true); + expect(refdTimeouts()).toBe(baseline); + }); + it("drains retained notifications and rejects post-close offers", async () => { const received: number[] = []; const dispatcher = new QwpNotificationDispatcher( From 22acd37759c6a822b97f4aa2a88e74289eeac3b0 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 3 Sep 2026 12:21:14 +0100 Subject: [PATCH 259/265] fix(qwp): stop an unsendable datagram size from reporting delivery max_datagram_size was validated as a positive safe integer and nothing more, in both the connect-string parser and QwpNodeUdpSession. The encoder splits at the configured size, so the configured value is the datagram size: set it to 65507 -- the value a user reaches for as "the IPv4 maximum" -- on a host whose limit is lower, and every datagram is refused by the kernel with EMSGSIZE. macOS defaults net.inet.udp.maxdgram to 9216. It is reachable with stock auto-flush, because autoFlushBytes defaults to maxDatagramSize. The rows then vanished while the client reported success. flush() resolved true, against its own documented "it was sent successfully". Worse, complete() advanced this.sequence before testing the error, and that counter backs both publishedFrameSequence and acknowledgedFrameSequence, so a datagram that never left the host moved the ACK watermark: flushAndGetSequence() returned a sequence covering those rows and waitForAcknowledged() resolved on them. createQwpNodeUdpSender forces awaitServerAck, so that is the documented path. Partial loss was the common case, which made it look healthy. Bound the size at 65507 in both validators, the way multicastTtl is already bounded at 255 and port at 65535 -- an asymmetry, not a decision. Advance the sequence only on a successful handoff. Name the setting in the EMSGSIZE error, since a bare errno says only that the kernel refused the write, not which option to change. flush() still resolves. Failing it would break the deliberate fire-and-forget policy the existing test pins, and on a partially sent batch it would risk duplicate rows on a transport with no dedup or replay. What was actively misleading -- the watermark -- now tells the truth. No static bound can predict a host limit below 65507, which is why the sequence accounting, not the cap, is what covers that residue. The existing send-failure test asserted sequence 0n alongside totalDatagramsSent 0; it had pinned the defect. Its stated intent, reporting the failure without retrying the rows, is unchanged. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VnqPKyf9FYsuPoA22V7qxj --- QWP.md | 8 ++- packages/nodejs-client/src/options.ts | 19 +++++- .../nodejs-client/src/qwp-node/udp-sender.ts | 60 +++++++++++++++++-- test/options.test.ts | 16 +++++ test/qwp/udp-sender.test.ts | 33 +++++++++- 5 files changed, 127 insertions(+), 9 deletions(-) diff --git a/QWP.md b/QWP.md index f6d9d75..104ffee 100644 --- a/QWP.md +++ b/QWP.md @@ -210,7 +210,13 @@ await sender.close(); ``` The default port is 9007, the maximum datagram size (`max_datagram_size`) is 1400 -bytes, and the multicast TTL (`multicast_ttl`) is zero. Each datagram is +bytes, and the multicast TTL (`multicast_ttl`) is zero. `max_datagram_size` accepts +1 through 65507, the IPv4 payload maximum; a larger value is rejected when the sender +is created. Many hosts refuse datagrams well below that ceiling — macOS defaults +`net.inet.udp.maxdgram` to 9216 — so keep the value at or under the path MTU unless +the receiver is known to accept more. A datagram the operating system refuses is +discarded before transmission: it is reported through `onError` and does not advance +`publishedSequence` or `acknowledgedSequence`. Each datagram is self-contained, contains exactly one table, and uses an inline schema plus table-local symbol dictionaries. Batches are split at row boundaries; `QwpUdpDatagramTooLargeError` is raised before transmission when one row cannot diff --git a/packages/nodejs-client/src/options.ts b/packages/nodejs-client/src/options.ts index f16201b..d4d8b1c 100644 --- a/packages/nodejs-client/src/options.ts +++ b/packages/nodejs-client/src/options.ts @@ -290,9 +290,13 @@ type DeprecatedOptions = { *
    * UDP specific options *
      - *
    • max_datagram_size: integer - Maximum encoded datagram size in bytes, defaults to 1400.
      + *
    • max_datagram_size: integer - Maximum encoded datagram size in bytes, from 1 to 65507, + * defaults to 1400.
      * A row that cannot fit a single datagram is rejected before transmission. It is also the default for - * auto_flush_bytes. Supported by the udp transport only; http, tcp and ws/wss reject it. + * auto_flush_bytes. Supported by the udp transport only; http, tcp and ws/wss reject it.
      + * 65507 is the IPv4 maximum, but many hosts refuse well below it, so keep this at or under the path + * MTU unless the receiver is known to accept more. A datagram the operating system refuses is + * discarded before transmission and does not advance the published or acknowledged sequence. *
    • *
    • multicast_ttl: integer - Multicast time-to-live for outgoing datagrams, from 0 to 255, defaults to 0.
      * Supported by the udp transport only; http, tcp and ws/wss reject it. @@ -779,6 +783,17 @@ function parseMaxNameLength(options: SenderOptions) { function parseUdpOptions(options: SenderOptions) { parseInteger(options, "max_datagram_size", "maximum datagram size", 1); + // 65535 minus the 20-byte IP and 8-byte UDP headers. A larger value can never + // be transmitted on IPv4, so it is rejected here rather than silently losing + // every datagram the kernel refuses. Bounded like multicast_ttl below. + if ( + options.max_datagram_size !== undefined && + options.max_datagram_size > 65507 + ) { + throw new Error( + `Invalid maximum datagram size option: ${options.max_datagram_size}, must not exceed 65507`, + ); + } parseInteger(options, "multicast_ttl", "multicast TTL", 0); if (options.multicast_ttl !== undefined && options.multicast_ttl > 255) { throw new Error(`Invalid multicast TTL option: ${options.multicast_ttl}`); diff --git a/packages/nodejs-client/src/qwp-node/udp-sender.ts b/packages/nodejs-client/src/qwp-node/udp-sender.ts index acaa95d..18eb9dd 100644 --- a/packages/nodejs-client/src/qwp-node/udp-sender.ts +++ b/packages/nodejs-client/src/qwp-node/udp-sender.ts @@ -10,6 +10,17 @@ import { safelyInvoke } from "../../../client-core/src/_qwp/_internal/safe-callb const DEFAULT_QWP_UDP_PORT = 9007; const DEFAULT_MAX_DATAGRAM_SIZE = 1_400; +/** + * Largest payload an IPv4 UDP datagram can carry: 65535 total minus the 20-byte + * IP and 8-byte UDP headers. Anything above this is unsendable on every host, + * so it is rejected at configuration time rather than failing per datagram. + * + * A host may refuse well below it -- `net.inet.udp.maxdgram` is 9216 on macOS + * by default -- which no static bound can predict. That residual case is what + * the send path's sequence accounting covers: a datagram the kernel refuses + * never advances the published or acknowledged watermark. + */ +const MAX_IPV4_DATAGRAM_PAYLOAD = 65_507; /** Minimal injectable UDP socket surface used by the Node QWP sender. */ export interface QwpNodeUdpSocketLike { @@ -94,9 +105,8 @@ export class QwpNodeUdpSession implements QwpSenderSession { private constructor(options: QwpNodeUdpOptions) { this.host = validateHost(options.host); this.port = validatePort(options.port ?? DEFAULT_QWP_UDP_PORT); - this.maxBatchSizeBytes = validatePositiveInteger( + this.maxBatchSizeBytes = validateMaxDatagramSize( options.maxDatagramSize ?? DEFAULT_MAX_DATAGRAM_SIZE, - "maxDatagramSize", ); this.multicastTtl = validateTtl(options.multicastTtl ?? 0); this.multicastInterface = options.multicastInterface?.trim() || undefined; @@ -255,10 +265,21 @@ export class QwpNodeUdpSession implements QwpSenderSession { this.assertOpen(); return new Promise((resolve) => { const complete = (error: Error | null, bytes = 0): void => { - this.sequence++; if (error) { - this.reportError(error); + // The watermark deliberately does not advance here. `sequence` backs + // both publishedFrameSequence and acknowledgedFrameSequence, so + // counting a datagram the kernel refused reported rows that never + // left the host as delivered: flushAndGetSequence() returned a + // sequence covering them and waitForAcknowledged() resolved on it. + this.reportError( + describeSendFailure( + error, + datagram.byteLength, + this.maxBatchSizeBytes, + ), + ); } else { + this.sequence++; this.totalDatagramsSent++; this.totalBytesSent += bytes; } @@ -379,6 +400,16 @@ function validatePort(port: number): number { return value; } +function validateMaxDatagramSize(size: number): number { + const value = validatePositiveInteger(size, "maxDatagramSize"); + if (value > MAX_IPV4_DATAGRAM_PAYLOAD) { + throw new RangeError( + `QWP UDP maxDatagramSize must not exceed ${MAX_IPV4_DATAGRAM_PAYLOAD}`, + ); + } + return value; +} + function validateTtl(ttl: number): number { if (!Number.isSafeInteger(ttl) || ttl < 0 || ttl > 255) { throw new RangeError("QWP UDP multicastTtl must be between 0 and 255"); @@ -396,3 +427,24 @@ function validatePositiveInteger(value: number, name: string): number { function asError(error: unknown): Error { return error instanceof Error ? error : new Error(String(error)); } + +/** + * Names the configuration behind a refused datagram. `EMSGSIZE` on its own says + * only that the kernel rejected the write; the actionable part is that + * maxDatagramSize is above what this host accepts, which no static bound can + * predict (macOS defaults `net.inet.udp.maxdgram` to 9216, far below the 65507 + * IPv4 maximum). Reported through onError, so the raw error stays the cause. + */ +function describeSendFailure( + error: Error, + datagramBytes: number, + maxDatagramSize: number, +): Error { + if ((error as NodeJS.ErrnoException).code !== "EMSGSIZE") return error; + const described = new Error( + `QWP UDP datagram of ${datagramBytes} bytes exceeds what this host accepts, so it was discarded before transmission; lower max_datagram_size (currently ${maxDatagramSize})`, + { cause: error }, + ); + described.name = "QwpUdpDatagramRefusedError"; + return described; +} diff --git a/test/options.test.ts b/test/options.test.ts index 018b3ea..5103efd 100644 --- a/test/options.test.ts +++ b/test/options.test.ts @@ -844,6 +844,22 @@ describe("Configuration string parser suite", function () { await expect( SenderOptions.fromConfig("udp::addr=host;multicast_ttl=256;"), ).rejects.toThrow("Invalid multicast TTL option: 256"); + // 65507 is 65535 minus the IP and UDP headers. Above it every datagram is + // refused by the kernel, and the fire-and-forget send path discards each + // one, so the whole batch vanished while flush() still resolved true. + // Bounded here like multicast_ttl above, rather than failing per datagram. + expect( + ( + await SenderOptions.fromConfig( + "udp::addr=host;max_datagram_size=65507;", + ) + ).max_datagram_size, + ).toBe(65507); + await expect( + SenderOptions.fromConfig("udp::addr=host;max_datagram_size=65508;"), + ).rejects.toThrow( + "Invalid maximum datagram size option: 65508, must not exceed 65507", + ); await expect( SenderOptions.fromConfig("udp::addr=host;username=admin;"), ).rejects.toThrow("authentication is not supported for QWP UDP transport"); diff --git a/test/qwp/udp-sender.test.ts b/test/qwp/udp-sender.test.ts index afae36f..c7cc4b2 100644 --- a/test/qwp/udp-sender.test.ts +++ b/test/qwp/udp-sender.test.ts @@ -284,18 +284,47 @@ describe("QWP Node UDP sender", () => { onError: (error) => errors.push(error), }); + // The flush still resolves and the rows are not retried, but the watermark + // must not move: `sequence` backs both publishedFrameSequence and + // acknowledgedFrameSequence, so advancing it over a datagram that never + // left the host reported those rows as delivered -- flushAndGetSequence() + // returned a sequence covering them and waitForAcknowledged() resolved. await expect(session.sendTables([longTable(1)])).resolves.toMatchObject({ status: 0, - sequence: 0n, + sequence: -1n, }); expect(errors.map((error) => error.message)).toEqual([ "network unreachable", ]); expect(session.udpMetrics).toMatchObject({ - publishedDatagramSequence: 0n, + publishedDatagramSequence: -1n, totalDatagramsSent: 0, totalSendErrors: 1, }); + await expect(session.waitForAcknowledged(0n)).rejects.toThrow( + /has not been published/, + ); + await session.close(); + }); + + it("rejects a datagram size no IPv4 host can transmit", async () => { + // 65507 is 65535 minus the IP and UDP headers. Above it every datagram is + // refused by the kernel, which the fire-and-forget send path then discards + // one by one, so the whole batch vanishes with flush() still resolving. + // Reject the configuration instead, the way multicastTtl already is. + await expect( + connectQwpNodeUdp({ + host: "localhost", + maxDatagramSize: 65_508, + socketFactory: () => new FakeUdpSocket(), + }), + ).rejects.toThrow("QWP UDP maxDatagramSize must not exceed 65507"); + + const session = await connectQwpNodeUdp({ + host: "localhost", + maxDatagramSize: 65_507, + socketFactory: () => new FakeUdpSocket(), + }); await session.close(); }); From 8a881ba9b9ad4879f794cb6fb391750bff4ceaa6 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 3 Sep 2026 12:21:29 +0100 Subject: [PATCH 260/265] fix(qwp): report a tail segment whose records never reached disk scanSegment classifies a record region that reads back as zeros all the way to EOF as an ordinary unwritten tail: records [], tornTail false, discardedBytes 0. Recovery then returned the surviving prefix and reported success. That shape is also what an unordered page-cache writeback leaves after a host crash, because activateHotSpare fsyncs the segment header unconditionally while the connect-string default durability, memory, never fsyncs records. A whole segment of accepted frames -- 4 MiB at the default sf_max_segment_bytes -- could be dropped with no callback, no log line and no sentinel, while every adjacent damage shape (torn tail, interior zeroed record, CRC mismatch, damaged length field) was reported. Measured at stock defaults: 6898 of 9000 frames recovered, zero reports. MANIFEST_REQUIRED_FLAG separates the two readings. It is stamped and fsynced as the last step of activateHotSpare, one write syscall before the first record lands, so a flagged segment holding no readable records means records were almost certainly written and lost. The residual ambiguity is a crash inside that one-syscall window, where nothing had been acknowledged to the producer and so nothing was actually lost. That is why this reports an undetermined extent rather than a byte count, and why it does not fail recovery: reporting a loss that may not have happened is recoverable, silently dropping accepted rows is not. discardedBytes is 0 here because a segment whose records are gone leaves nothing to measure. Document that on the public report type as "unknown" rather than "nothing lost", and word the log line so it does not claim a confident "discarded 0 byte(s)". No on-disk format change, so the journal stays cross-client. A definitive fix -- distinguishing the two states outright -- would need a high-water mark in the segment header or manifest, which is a format decision shared with the Java client. The second test is the counterpart: an ordinary reopen must stay silent, or the notification means nothing. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VnqPKyf9FYsuPoA22V7qxj --- .../src/qwp-node/file-replay-store.ts | 51 ++++++++++- test/qwp/reconnect.test.ts | 86 +++++++++++++++++++ 2 files changed, 134 insertions(+), 3 deletions(-) diff --git a/packages/nodejs-client/src/qwp-node/file-replay-store.ts b/packages/nodejs-client/src/qwp-node/file-replay-store.ts index 1bf1231..31029d5 100644 --- a/packages/nodejs-client/src/qwp-node/file-replay-store.ts +++ b/packages/nodejs-client/src/qwp-node/file-replay-store.ts @@ -142,7 +142,13 @@ interface PendingCapacity { export interface QwpNodeReplayDataLossReport { readonly directory: string; readonly segmentFile: string; - /** Bytes at and after the damaged record that recovery could not retain. */ + /** + * Bytes at and after the damaged record that recovery could not retain. + * + * Zero means a loss was detected whose extent the journal cannot measure -- + * a segment whose records are gone leaves nothing to count. Treat it as + * "unknown", not as "nothing lost", and read {@link reason}. + */ readonly discardedBytes: number; readonly reason: string; } @@ -658,6 +664,39 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { ); const retainEmptyActive = decoded.records.length === 0 && path === selectedActivePath; + if ( + retainEmptyActive && + decoded.manifestRequired && + !decoded.tornTail + ) { + // A record region that reads back as zeros all the way to EOF is + // scanned as an unwritten tail: no torn record, no CRC mismatch, + // nothing to count. That is also what an unordered page-cache + // writeback leaves after a host crash, and the default durability + // is `memory`, which never fsyncs records -- so a whole segment of + // accepted frames can vanish while every other damage shape is + // reported. Recovery used to return the surviving prefix and call + // that success. + // + // MANIFEST_REQUIRED_FLAG is what separates the two cases. It is + // stamped and fsynced as the last step of activateHotSpare, one + // write syscall before the first record lands, so a flagged + // segment with no readable records almost always means records + // were written and lost. The residual ambiguity -- a crash inside + // that one-syscall window, where nothing was ever acknowledged to + // the producer -- is why this reports an undetermined extent + // rather than a byte count. Reporting a loss that may not have + // happened is recoverable; silently dropping accepted rows is not. + this.reportRecoveryDataLoss({ + directory: this.directory, + segmentFile: name, + discardedBytes: 0, + reason: + `the active segment holding frame sequences from ${decoded.firstSequence} ` + + `contains no readable records, so any frames journalled into it were lost ` + + `before reaching disk`, + }); + } if (liveRecords.length === 0 && !retainEmptyActive) { await handle.close(); recoveryHandles.delete(handle); @@ -1991,8 +2030,14 @@ export class QwpNodeFileReplayStore implements QwpIngressReplayStore { */ private reportRecoveryDataLoss(report: QwpNodeReplayDataLossReport): void { const message = - `QWP store-and-forward discarded ${report.discardedBytes} journal byte(s) during recovery ` + - `[directory=${report.directory}, segment=${report.segmentFile}]: ${report.reason}`; + report.discardedBytes > 0 + ? `QWP store-and-forward discarded ${report.discardedBytes} journal byte(s) during recovery ` + + `[directory=${report.directory}, segment=${report.segmentFile}]: ${report.reason}` + : // A segment whose records are gone leaves no bytes to count, so the + // extent is unknown rather than zero. Say that instead of reporting + // a confident "discarded 0 byte(s)". + `QWP store-and-forward lost journalled data of undetermined size during recovery ` + + `[directory=${report.directory}, segment=${report.segmentFile}]: ${report.reason}`; if (!this.onRecoveryDataLoss) { log("error", message); return; diff --git a/test/qwp/reconnect.test.ts b/test/qwp/reconnect.test.ts index 550e777..a53bcab 100644 --- a/test/qwp/reconnect.test.ts +++ b/test/qwp/reconnect.test.ts @@ -4605,6 +4605,92 @@ describe("QWP Node file replay store", () => { }, ); + it("reports a tail segment whose records never reached disk", async () => { + // The one damage shape that stayed silent. A record region reading back as + // zeros all the way to EOF scans as an ordinary unwritten tail -- no torn + // record, no CRC mismatch, no bytes to count -- so recovery returned the + // surviving prefix and called it success. It is also exactly what an + // unordered page-cache writeback leaves after a host crash: the header + // survives because activateHotSpare fsyncs it, while the records do not, + // because the connect-string default durability never fsyncs them. A whole + // segment of accepted frames could vanish with no callback and no log. + const directory = await trackedDirectory(); + const first = new QwpNodeFileReplayStore({ + directory, + maxSegmentBytes: 4096, + durability: "memory", + }); + await first.load(); + for (let sequence = 0; sequence < 12; sequence++) { + await first.append({ + frameSequence: BigInt(sequence), + payload: new Uint8Array(600).fill(sequence + 1), + }); + } + await first.close(); + + const segments = await assignedReplaySegments(directory); + expect(segments.length).toBeGreaterThan(1); + const tail = segments[segments.length - 1]; + const path = join(directory, tail); + const size = (await stat(path)).size; + const file = await open(path, "r+"); + try { + // Header intact, every record byte lost. + await file.write(Buffer.alloc(size - 24, 0), 0, size - 24, 24); + await file.sync(); + } finally { + await file.close(); + } + + const reports: QwpNodeReplayDataLossReport[] = []; + const recovered = new QwpNodeFileReplayStore({ + directory, + maxSegmentBytes: 4096, + durability: "memory", + onRecoveryDataLoss: (report) => reports.push(report), + }); + const frames = await recovered.load(); + expect(frames.length).toBeLessThan(12); + expect(reports).toHaveLength(1); + expect(reports[0].segmentFile).toBe(tail); + // No readable record survives, so there is nothing to measure: zero here + // means "extent unknown", which the reason has to spell out. + expect(reports[0].discardedBytes).toBe(0); + expect(reports[0].reason).toMatch(/no readable records/); + await recovered.close(); + }); + + it("stays silent when an undamaged journal is reopened", async () => { + // The counterpart to the test above: an ordinary reopen must not report a + // loss, or the notification means nothing. + const directory = await trackedDirectory(); + const first = new QwpNodeFileReplayStore({ + directory, + maxSegmentBytes: 4096, + durability: "memory", + }); + await first.load(); + for (let sequence = 0; sequence < 12; sequence++) { + await first.append({ + frameSequence: BigInt(sequence), + payload: new Uint8Array(600).fill(sequence + 1), + }); + } + await first.close(); + + const reports: QwpNodeReplayDataLossReport[] = []; + const recovered = new QwpNodeFileReplayStore({ + directory, + maxSegmentBytes: 4096, + durability: "memory", + onRecoveryDataLoss: (report) => reports.push(report), + }); + await expect(recovered.load()).resolves.toHaveLength(12); + expect(reports).toEqual([]); + await recovered.close(); + }); + it("reports the records a damaged length field strands behind it", async () => { // The length field is read before the CRC32C that would have covered it, // so corrupting it is the one damage shape that reaches repair without any From d7595beebc15359d8de379a8989dba13efc531ea Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 3 Sep 2026 12:42:59 +0100 Subject: [PATCH 261/265] fix(qwp): bound a NACK message by its frame, not a fixed ceiling decodeQwpIngressResponse() rejected any ingress NACK whose declared message length exceeded 1024 bytes. No such limit exists. The server truncates ingress error text at (http.send.buffer.size - 100) / 1.5 characters -- about 1.4M at the 2 MB default, and still 2664 at the 4 KB used in its own tests -- so every length the u16 field can express is legal on the wire. Neither QwpConstants.java, server or client, defines an error-message length constant at all. The consequence was not a truncated string. The check threw QwpProtocolError, and QwpReconnectingIngressConnection.translateResponse rethrows that class rather than routing it to requestReconnect, so it landed as PROTOCOL_VIOLATION -> TERMINAL. A WRITE_ERROR or INTERNAL_ERROR NACK is retriable by policy; attaching a verbose explanation to one turned it into a terminal failure that stopped a running producer, on the memory policy discarding the frames it still held. Message length is not a property of frame validity, so it must never decide terminality. The Java client bound-checks the declared length against the frame and nothing else, on both the ingress NACK path (WebSocketResponse.readFrom) and the egress QUERY_ERROR path (QwpEgressIoThread.decodeError, whose comment calls out msgLen=0xFFFF over a tiny payload as the thing it is defending against). Its own MAX_ERROR_MESSAGE_LENGTH = 1024 is used in exactly one place, getErrorMessageUtf8Length(), which sizes a response it is writing. It is a write-side truncation bound and is never applied when decoding. readUtf8() -> readBytes() -> ensureAvailable() already performs the frame-bound check, so removing the ceiling loses no protection: an over-long declared length is still rejected before any copy. The egress decoder was already correct and is untouched. QWP_MAX_ERROR_MESSAGE_LENGTH is removed rather than left unused. It was exported from both package roots, and a "maximum" constant that matches no protocol rule invites someone to enforce it again. Nothing else referenced it; QWP.md and the public API contract never named it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VnqPKyf9FYsuPoA22V7qxj --- .../client-core/src/_qwp/_core/constants.ts | 8 ++++- .../client-core/src/_qwp/_core/ingress.ts | 17 +++++---- test/qwp/core.test.ts | 35 +++++++++++++------ 3 files changed, 42 insertions(+), 18 deletions(-) diff --git a/packages/client-core/src/_qwp/_core/constants.ts b/packages/client-core/src/_qwp/_core/constants.ts index 083a2f7..aa80275 100644 --- a/packages/client-core/src/_qwp/_core/constants.ts +++ b/packages/client-core/src/_qwp/_core/constants.ts @@ -109,7 +109,13 @@ export const QWP_MAX_TABLE_NAME_LENGTH = 127; export const QWP_MAX_IDENTIFIER_BYTES = QWP_MAX_TABLE_NAME_LENGTH * 3; export const QWP_MAX_ROWS_PER_TABLE = 1_000_000; export const QWP_MAX_SYMBOL_DICTIONARY_SIZE = 1_000_000; -export const QWP_MAX_ERROR_MESSAGE_LENGTH = 1024; +// No QWP_MAX_ERROR_MESSAGE_LENGTH. Server-supplied error text is bounded by +// its u16 length field and by the frame that carries it, and by nothing else: +// the server truncates ingress error text at +// (http.send.buffer.size - 100) / 1.5 characters and caps egress QUERY_ERROR +// at whatever its caller passes, so no fixed client-side ceiling matches the +// protocol. The Java client agrees -- its own MAX_ERROR_MESSAGE_LENGTH is a +// write-side truncation bound and is never applied when decoding. /** Largest client-requested egress RESULT_BATCH row cap. */ export const QWP_MAX_BATCH_ROWS_UPPER_BOUND = 1_048_576; /** diff --git a/packages/client-core/src/_qwp/_core/ingress.ts b/packages/client-core/src/_qwp/_core/ingress.ts index 145dc4a..dc27d5b 100644 --- a/packages/client-core/src/_qwp/_core/ingress.ts +++ b/packages/client-core/src/_qwp/_core/ingress.ts @@ -10,7 +10,6 @@ import { QWP_HEADER_SIZE, QWP_MAX_ARRAY_DIMENSION_LENGTH, QWP_MAX_ARRAY_DIMENSIONS, - QWP_MAX_ERROR_MESSAGE_LENGTH, QWP_MAX_ROWS_PER_TABLE, QWP_MAX_SYMBOL_DICTIONARY_SIZE, QWP_STATUS, @@ -835,11 +834,17 @@ export function decodeQwpIngressResponse( } const messageLength = reader.readUint16("NACK message length"); - if (messageLength > QWP_MAX_ERROR_MESSAGE_LENGTH) { - throw new QwpProtocolError( - `QWP error message exceeds ${QWP_MAX_ERROR_MESSAGE_LENGTH} bytes`, - ); - } + // Bounded by the frame, not by a policy cap. The declared u16 length is + // checked against the remaining payload inside readUtf8() -> readBytes() -> + // ensureAvailable(), which is what stops a peer declaring 0xFFFF over a tiny + // payload. A separate 1024-byte ceiling used to sit here, and it rejected + // frames the server is allowed to send: QwpIngressProcessorState truncates + // ingress error text at (http.send.buffer.size - 100) / 1.5 characters -- + // about 1.4M at the 2 MB default -- so anything up to the u16 maximum is + // legal on the wire. Worse, the rejection became a QwpProtocolError, which + // the reconnecting transport rethrows as terminal, so a verbose explanation + // attached to an otherwise retriable WRITE_ERROR killed a running producer. + // The Java client bound-checks against the frame and nothing else. const errorMessage = reader.readUtf8(messageLength, "NACK message"); reader.expectEnd("ingress NACK"); return { status, sequence, tables: [], errorMessage }; diff --git a/test/qwp/core.test.ts b/test/qwp/core.test.ts index c570a8f..a65fa78 100644 --- a/test/qwp/core.test.ts +++ b/test/qwp/core.test.ts @@ -19,7 +19,6 @@ import { encodeQwpVarint, QWP_COLUMN_TYPE, QWP_MAX_COLUMNS_PER_TABLE, - QWP_MAX_ERROR_MESSAGE_LENGTH, QWP_MAX_ROWS_PER_TABLE, QWP_MAX_SYMBOL_DICTIONARY_SIZE, QWP_COMPRESSION_CODEC, @@ -33,6 +32,7 @@ import { QWP_STATUS, QwpByteReader, QwpByteWriter, + QwpProtocolError, QwpSymbolDictionary, QwpTableBuffer, qwpGorillaSize, @@ -724,21 +724,34 @@ describe("protocol caps", () => { ); }); - it("rejects a NACK whose declared message length is above the cap", () => { - const nack = (length: number) => + it("bounds a NACK message by its frame, not by a fixed ceiling", () => { + // A 1024-byte ceiling used to sit on this path and it rejected frames the + // server is allowed to send. QwpIngressProcessorState truncates ingress + // error text at (http.send.buffer.size - 100) / 1.5 characters -- about + // 1.4M at the 2 MB default -- so any length the u16 field can express is + // legal. The rejection surfaced as a QwpProtocolError, which the + // reconnecting transport rethrows as terminal, so a verbose explanation on + // an otherwise retriable WRITE_ERROR killed a running producer. The Java + // client checks the declared length against the frame and nothing else. + const nack = (declared: number, present = declared) => new QwpByteWriter() .writeUint8(QWP_STATUS.WRITE_ERROR) .writeBigUint64(0n) - .writeUint16(length) - .writeBytes(new Uint8Array(length)) + .writeUint16(declared) + .writeBytes(new Uint8Array(present).fill(0x78)) .toUint8Array(); - expect(() => - decodeQwpIngressResponse(nack(QWP_MAX_ERROR_MESSAGE_LENGTH + 1)), - ).toThrow(`exceeds ${QWP_MAX_ERROR_MESSAGE_LENGTH} bytes`); - expect(() => - decodeQwpIngressResponse(nack(QWP_MAX_ERROR_MESSAGE_LENGTH)), - ).not.toThrow(); + for (const length of [0, 1, 1024, 1025, 8192, 65535]) { + const response = decodeQwpIngressResponse(nack(length)); + expect(response.status).toBe(QWP_STATUS.WRITE_ERROR); + expect(response.errorMessage).toHaveLength(length); + } + + // The frame remains the bound: a length the payload cannot satisfy is + // still rejected before anything is copied. + expect(() => decodeQwpIngressResponse(nack(65535, 16))).toThrow( + QwpProtocolError, + ); }); }); From 610c9559e308230d3fe641397cc69a32778f7c79 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 3 Sep 2026 13:09:36 +0100 Subject: [PATCH 262/265] fix(ilp): keep a row closable when the buffer, not the row, is at fault Two places treated a full buffer as a malformed row. at()/atNow() ran their own checkCapacity() inside the try whose catch discards the row. checkCapacity() throws before either method writes a byte, so the row was intact and a flush() that freed space would have closed it -- which is what happened before the discard contract existed. Discarding dropped a fully built row and then reported "The row must have a symbol or column set before it is closed" on the retry, naming the wrong problem: the row was gone, not empty. Hoist the pre-write capacity check out of the try. Everything past the first write stays inside it, because a half-encoded row genuinely cannot be retried. writeColumn() set hasColumnCall before its own checkCapacity(), the last check that can reject before a byte is written. A column call the full buffer refused therefore closed the symbol section having contributed nothing, and the row could then not be closed at all: symbol() raised the ordering error and at()/atNow() raised the empty-row error, neither mentioning the buffer. This is the same reasoning as 302c544/d2bc305 -- the flag marks a call that lands -- applied to the one rejection those commits left on the wrong side of it. Value-type, integer, array and decimal validation all still run earlier and still leave the section open. Both are reachable with an explicit max_buf_size, and on the default 100 MB ceiling once the buffer has doubled past 50 MB. Verified against 7e52548: both sequences now behave exactly as they did before this branch. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VnqPKyf9FYsuPoA22V7qxj --- packages/nodejs-client/src/buffer/base.ts | 33 ++++++++++- test/sender.buffer.test.ts | 69 +++++++++++++++++++++++ 2 files changed, 99 insertions(+), 3 deletions(-) diff --git a/packages/nodejs-client/src/buffer/base.ts b/packages/nodejs-client/src/buffer/base.ts index 64bbc14..7cd6f7b 100644 --- a/packages/nodejs-client/src/buffer/base.ts +++ b/packages/nodejs-client/src/buffer/base.ts @@ -428,12 +428,25 @@ abstract class SenderBufferBase implements SenderBuffer { `Designated timestamp must be a BigInt if it is set in nanoseconds`, ); } - this.checkCapacity([], 1); + } catch (error) { + this.discardIncompleteRow(); + throw error; + } + // A full buffer is not a malformed row. checkCapacity() throws before + // anything is written, so the row is still intact and a flush() that frees + // space lets the same at() succeed -- which is what happened before the + // discard contract existed. Discarding here dropped a fully built row and + // then reported "The row must have a symbol or column set before it is + // closed" on the retry, naming the wrong problem entirely. + this.checkCapacity([], 1); + try { this.write(" "); this.writeTimestamp(timestamp, unit, true); this.write("\n"); this.startNewRow(); } catch (error) { + // Past the first write the row is half encoded, so it cannot be retried + // and has to go. this.discardIncompleteRow(); throw error; } @@ -450,7 +463,14 @@ abstract class SenderBufferBase implements SenderBuffer { "The row must have a symbol or column set before it is closed", ); } - this.checkCapacity([], 1); + } catch (error) { + this.discardIncompleteRow(); + throw error; + } + // See at(): a capacity failure leaves the row intact and retryable after a + // flush, so it must not discard. + this.checkCapacity([], 1); + try { this.write("\n"); this.startNewRow(); } catch (error) { @@ -605,9 +625,16 @@ abstract class SenderBufferBase implements SenderBuffer { `Column value must be of type ${valueType}, received ${typeof value}`, ); } + // checkCapacity() is the last thing that can reject before a byte is + // written -- a full buffer at max_buf_size throws here -- so the flag has + // to be set after it, not before. Setting it first closed the symbol + // section on a call that contributed nothing, and the row then could not + // be closed at all: symbol() raised the ordering error and at()/atNow() + // raised "The row must have a symbol or column set before it is closed", + // neither of which named the full buffer that actually stopped the call. + this.checkCapacity([name], 2 + name.length); // Past every check that can reject the call: this one writes. this.hasColumnCall = true; - this.checkCapacity([name], 2 + name.length); this.write(this.hasColumns ? "," : " "); this.writeEscaped(name); this.write("="); diff --git a/test/sender.buffer.test.ts b/test/sender.buffer.test.ts index 2275db1..1842ef8 100644 --- a/test/sender.buffer.test.ts +++ b/test/sender.buffer.test.ts @@ -698,6 +698,65 @@ describe("Sender message builder test suite (anything not covered in client inte await sender.close(); }); + it("keeps a row open when the buffer is too full to close it", async function () { + // A full buffer is not a malformed row. checkCapacity() throws before + // at()/atNow() writes anything, so the row is intact and a flush() that + // frees space lets the same close succeed -- which is what happened before + // the discard contract existed. Routing it through the discard dropped a + // fully built row and then reported "The row must have a symbol or column + // set before it is closed" on the retry, naming the wrong problem. + const sender = new Sender({ + protocol: "http", + protocol_version: "1", + host: "host", + auto_flush: false, + init_buf_size: 62, + max_buf_size: 62, + }); + + // Eight complete rows of 7 bytes fill 56 of the 62 available bytes. + for (let row = 0; row < 8; row++) { + await sender.table("t").intColumn("a", 5).atNow(); + } + // The ninth row's columns land exactly on the cap, so the newline that + // closes it is the first thing that cannot fit. + sender.table("t").intColumn("a", 5); + await expect(async () => await sender.atNow()).rejects.toThrow( + "Max buffer size is 62 bytes", + ); + + // Freeing space lets the same row close, and nothing was lost. + expect(bufferContent(sender)).toBe("t a=5i\n".repeat(8)); + expect(drainBuffer(sender).toString()).toBe("t a=5i\n".repeat(8)); + await sender.atNow(); + expect(bufferContent(sender)).toBe("t a=5i\n"); + await sender.close(); + }); + + it("keeps the symbol section open when a column call overflows the buffer", async function () { + // hasColumnCall was set before writeColumn()'s own checkCapacity(), the + // last check that can reject before a byte is written. A column call the + // full buffer refused therefore closed the symbol section even though it + // contributed nothing, and the row could then not be closed at all. + const sender = new Sender({ + protocol: "tcp", + protocol_version: "1", + host: "host", + auto_flush: false, + init_buf_size: 32, + max_buf_size: 32, + }); + + sender.table("t"); + expect(() => sender.stringColumn("a".repeat(20), "v")).toThrow( + "Max buffer size is 32 bytes", + ); + // The rejected call wrote nothing, so symbols are still allowed. + await sender.symbol("s", "v").atNow(); + expect(bufferContent(sender)).toBe("t,s=v\n"); + await sender.close(); + }); + it("keeps a row open when its designated timestamp unit is rejected", async function () { // Unit validation happens before the close attempt mutates the row, so the // caller can correct a bad constant and retry at() directly. @@ -1764,6 +1823,16 @@ function bufferSize(sender: Sender) { return sender.buffer.bufferSize; } +/** + * Drains and compacts the buffer exactly as flush() does, without the network + * send flush() would also perform. Lets a test free buffer space against a + * sender whose host does not resolve. + */ +function drainBuffer(sender: Sender) { + // @ts-expect-error - Accessing private field + return sender.buffer.toBufferNew(); +} + function bufferPosition(sender: Sender) { // @ts-expect-error - Accessing private field return sender.buffer.position; From 96fdbf0e4d6262111455c7e3d6955fd7d488d03f Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 3 Sep 2026 13:09:52 +0100 Subject: [PATCH 263/265] fix: declare the Node floor undici actually requires engines.node said ">=20" while every undici 7.x declares ">=20.18.1". Installing on Node 20.0-20.18 warned with EBADENGINE and failed outright under engine-strict, and the error named undici rather than the package that chose the range. The install outcome is unchanged -- the transitive constraint already decided it -- but the package no longer advertises a range it cannot honour, and the failure now names @questdb/nodejs-client and its real floor. The README compatibility table repeated the same claim and says why. Also record that storeAndForward.durability and the sf_durability connect-string key do not share a default: "append" for the object form, "memory" for the connect string. Both were documented correctly and separately, which is exactly how a reader carries one over to the other and assumes a per-append barrier that a journal configured with sf_dir= alone never issues. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VnqPKyf9FYsuPoA22V7qxj --- QWP.md | 14 +++++++++++--- README.md | 5 ++++- packages/nodejs-client/package.json | 2 +- test/package-boundaries.e2e.ts | 6 +++++- 4 files changed, 21 insertions(+), 6 deletions(-) diff --git a/QWP.md b/QWP.md index 104ffee..bea8462 100644 --- a/QWP.md +++ b/QWP.md @@ -314,15 +314,23 @@ The connect-string key `durability` controls the local persistence barrier: -- `"append"` (the default) issues a data-only durability barrier after every vectored - positional frame write; manifest and directory metadata retain full barriers; - hot-spare creation and activation are durable before publication resolves. +- `"append"` (the default for this object form) issues a data-only durability + barrier after every vectored positional frame write; manifest and directory + metadata retain full barriers; hot-spare creation and activation are durable + before publication resolves. - `"periodic"` checkpoints segment files, symbol metadata, and directory changes in the background. The default interval is 5 seconds, and `close()` performs a final checkpoint. A power failure can lose the most recent checkpoint window. - `"memory"` relies on operating-system writeback. It survives an orderly close and normally a process failure, but it makes no power-loss durability promise. +The two surfaces do not share a default. `storeAndForward.durability` above +defaults to `"append"`, while the `sf_durability` connect-string key defaults to +`"memory"` — so a journal configured with `sf_dir=` alone never issues a +per-append barrier, and a host crash can lose whatever writeback had not yet +reached disk. Set `sf_durability=append` explicitly when a connect-string journal +has to survive power loss. + `backpressurePolicy: "error"` preserves the existing immediate `QwpReplayStoreFullError` behavior. Set it to `"wait"` to pause publication until an ACK advances the checksummed cursor, then a bounded background trimmer deletes fully diff --git a/README.md b/README.md index 559f824..34c10e3 100644 --- a/README.md +++ b/README.md @@ -28,9 +28,12 @@ npm install @questdb/browser-client | QuestDB client version | Supported Node.js versions | Default HTTP Agent | | ---------------------- | -------------------------- | ------------------- | -| ^4.0.0 | v20 and above | Undici Http Agent | +| ^4.0.0 | v20.18.1 and above | Undici Http Agent | | ^3.0.0 | v16 and above | Standard Http Agent | +`^4.0.0` depends on `undici`, which declares `node >=20.18.1`; installing on an +earlier v20 warns with `EBADENGINE` and fails outright under `engine-strict`. + The current version of the client requires Node.js v20 or newer version. Versions up to and including 3.0.0 are compatible with Node.js v16 and above. diff --git a/packages/nodejs-client/package.json b/packages/nodejs-client/package.json index ebc140a..6514cd7 100644 --- a/packages/nodejs-client/package.json +++ b/packages/nodejs-client/package.json @@ -40,7 +40,7 @@ "author": "QuestDB", "license": "Apache-2.0", "engines": { - "node": ">=20" + "node": ">=20.18.1" }, "dependencies": { "undici": "^7.8.0", diff --git a/test/package-boundaries.e2e.ts b/test/package-boundaries.e2e.ts index 376ce39..2c1be8c 100644 --- a/test/package-boundaries.e2e.ts +++ b/test/package-boundaries.e2e.ts @@ -159,7 +159,11 @@ describe("public npm package boundaries", () => { ]); expect(nodeManifest.repository.directory).toBe("packages/nodejs-client"); expect(Object.keys(nodeManifest.exports)).toEqual(["."]); - expect(nodeManifest.engines?.node).toBe(">=20"); + // Must not sit below what the runtime dependencies themselves allow, or + // the package advertises a Node range it cannot install on: undici 7.x + // declares >=20.18.1, so a plain ">=20" warned with EBADENGINE and failed + // outright under engine-strict. + expect(nodeManifest.engines?.node).toBe(">=20.18.1"); expect(Object.keys(nodeManifest.dependencies ?? {}).sort()).toEqual([ "undici", "ws", From ed6fae28601febb2f9df92eb7c886c7b538c7407 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 3 Sep 2026 13:22:19 +0100 Subject: [PATCH 264/265] docs(ilp): document the UDP default port in the addr TSDoc The addr entry listed the HTTP/HTTPS (9000) and TCP/TCPS (9009) defaults and stopped there, on the same list whose protocol entry says UDP "uses the options below". parseAddress() defaults a portless udp:: address to QWP_UDP_PORT, 9007, so the one scheme the list claims to cover was the one it left out; `udp::addr=hostname` resolving to 9007 is already asserted in test/options.test.ts. WS/WSS stay out of it deliberately, and now say so: they do not populate the legacy ILP host/port fields at all -- SenderOptions leaves both undefined and the QWP schema resolves the endpoint -- which is why the protocol entry already points at QWP.md for them. This block is published to typedoc as the SenderOptions reference. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VnqPKyf9FYsuPoA22V7qxj --- packages/nodejs-client/src/options.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/nodejs-client/src/options.ts b/packages/nodejs-client/src/options.ts index d4d8b1c..753f734 100644 --- a/packages/nodejs-client/src/options.ts +++ b/packages/nodejs-client/src/options.ts @@ -200,9 +200,11 @@ type DeprecatedOptions = { *
    • *
    • addr: string - Hostname and port, separated by colon. This key is mandatory, but the port part is optional.
      * If no port is specified, a default will be used.
      - * When the protocol is HTTP/HTTPS, the port defaults to 9000. When the protocol is TCP/TCPS, the port defaults to 9009.
      + * When the protocol is HTTP/HTTPS, the port defaults to 9000. When the protocol is TCP/TCPS, the port defaults to 9009. + * When the protocol is UDP, the port defaults to 9007.
      + * WS/WSS resolve their address through the QWP configuration schema instead, documented in QWP.md.
      *
      - * Examples: http::addr=localhost:9000, https::addr=localhost:9000, http::addr=localhost, tcp::addr=localhost:9009 + * Examples: http::addr=localhost:9000, https::addr=localhost:9000, http::addr=localhost, tcp::addr=localhost:9009, udp::addr=localhost:9007 *
    • *
    *
    From 41da86a81e0d2edb009e62e8ff055b88651616e3 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 3 Sep 2026 13:22:37 +0100 Subject: [PATCH 265/265] docs: regenerate the typedoc site The committed output was 30 commits stale, so the published QWP guide contradicted the source in three places: it named a connectQwpNodeQuery() that has never existed, documented poison_min_escalation_window_millis as 5000 against the code's 300000, and still carried a preview disclaimer QWP.md had dropped. docs/index.html links that copy, so those were what a reader following the site actually got. Regenerating also drops the two QWP_MAX_ERROR_MESSAGE_LENGTH variable pages for the constant removed in d7595be, and picks up the TSDoc from the fixes on this branch, including the UDP default port. Most of the diff is not content: typedoc embeds the current commit SHA in every "Defined in" source link, so every regeneration rewrites one line in every page. Reviewing this with `:!docs` and reading the source commits is the intended path. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VnqPKyf9FYsuPoA22V7qxj --- docs/assets/hierarchy.js | 2 +- docs/assets/navigation.js | 2 +- docs/assets/search.js | 2 +- ..._browser-client.QwpBatchTooLargeError.html | 4 +- ..._questdb_browser-client.QwpBindValues.html | 10 +- ...lient.QwpBrowserSessionBootstrapError.html | 10 +- ..._questdb_browser-client.QwpByteReader.html | 4 +- ..._questdb_browser-client.QwpByteWriter.html | 4 +- .../_questdb_browser-client.QwpClient.html | 12 +- ...b_browser-client.QwpClientClosedError.html | 4 +- ...-client.QwpDurableAckUnavailableError.html | 10 +- ...questdb_browser-client.QwpEgressQuery.html | 32 ++-- ...r-client.QwpEgressQueryAbandonedError.html | 4 +- ...ient.QwpEgressQueryCancelTimeoutError.html | 4 +- ...db_browser-client.QwpEgressQueryError.html | 4 +- ...ser-client.QwpEgressQueryTimeoutError.html | 4 +- ...r-client.QwpEgressReplayRequiredError.html | 4 +- ...estdb_browser-client.QwpEgressSession.html | 20 +-- ...er-client.QwpEgressSessionClosedError.html | 4 +- ...estdb_browser-client.QwpFailoverError.html | 4 +- ...wser-client.QwpIngressAckTimeoutError.html | 4 +- ...db_browser-client.QwpIngressNackError.html | 4 +- ...stdb_browser-client.QwpIngressSession.html | 32 ++-- ...r-client.QwpIngressSessionClosedError.html | 4 +- ...ent.QwpMemoryReplayAppendTimeoutError.html | 4 +- ...ent.QwpMemoryReplayFrameTooLargeError.html | 4 +- ...ser-client.QwpPoolAcquireTimeoutError.html | 4 +- ...b_browser-client.QwpPoolResourceError.html | 4 +- ...estdb_browser-client.QwpProtocolError.html | 4 +- ..._questdb_browser-client.QwpQueryLease.html | 8 +- ...ser-client.QwpReconnectExhaustedError.html | 4 +- ...owser-client.QwpReplayDictionaryError.html | 4 +- ...t.QwpReplayDictionaryPersistenceError.html | 4 +- ...browser-client.QwpReplayRejectedError.html | 4 +- ...questdb_browser-client.QwpResultBatch.html | 4 +- ..._browser-client.QwpResultBatchDecoder.html | 19 +- ...tdb_browser-client.QwpResultBatchView.html | 10 +- ...db_browser-client.QwpResultColumnView.html | 24 +-- ...estdb_browser-client.QwpResultRowView.html | 16 +- ...b_browser-client.QwpRoleMismatchError.html | 10 +- ...tdb_browser-client.QwpSendClosedError.html | 4 +- .../_questdb_browser-client.QwpSendError.html | 4 +- ...db_browser-client.QwpSendTimeoutError.html | 4 +- .../_questdb_browser-client.QwpSender.html | 32 ++-- ...ser-client.QwpSenderCloseTimeoutError.html | 4 +- ...db_browser-client.QwpSymbolDictionary.html | 8 +- ...questdb_browser-client.QwpTableBuffer.html | 12 +- ...questdb_browser-client.QwpTableWriter.html | 8 +- ...QwpUnrecoverableReplayDictionaryError.html | 4 +- ...uestdb_browser-client.QwpUpgradeError.html | 10 +- ...stdb_browser-client.QwpWriterRowError.html | 4 +- .../_questdb_nodejs-client.HttpTransport.html | 12 +- ...b_nodejs-client.QwpBatchTooLargeError.html | 4 +- .../_questdb_nodejs-client.QwpBindValues.html | 10 +- .../_questdb_nodejs-client.QwpByteReader.html | 4 +- .../_questdb_nodejs-client.QwpByteWriter.html | 4 +- .../_questdb_nodejs-client.QwpClient.html | 12 +- ...db_nodejs-client.QwpClientClosedError.html | 4 +- ...-client.QwpDurableAckUnavailableError.html | 10 +- ..._questdb_nodejs-client.QwpEgressQuery.html | 32 ++-- ...s-client.QwpEgressQueryAbandonedError.html | 4 +- ...ient.QwpEgressQueryCancelTimeoutError.html | 4 +- ...tdb_nodejs-client.QwpEgressQueryError.html | 4 +- ...ejs-client.QwpEgressQueryTimeoutError.html | 4 +- ...s-client.QwpEgressReplayRequiredError.html | 4 +- ...uestdb_nodejs-client.QwpEgressSession.html | 20 +-- ...js-client.QwpEgressSessionClosedError.html | 4 +- ...uestdb_nodejs-client.QwpFailoverError.html | 4 +- ...dejs-client.QwpIngressAckTimeoutError.html | 4 +- ...tdb_nodejs-client.QwpIngressNackError.html | 4 +- ...estdb_nodejs-client.QwpIngressSession.html | 32 ++-- ...s-client.QwpIngressSessionClosedError.html | 4 +- ...ent.QwpMemoryReplayAppendTimeoutError.html | 4 +- ...ent.QwpMemoryReplayFrameTooLargeError.html | 4 +- ..._nodejs-client.QwpNodeFileReplayStore.html | 14 +- ...db_nodejs-client.QwpNodeOrphanDrainer.html | 8 +- ...estdb_nodejs-client.QwpNodeUdpSession.html | 4 +- ...ejs-client.QwpPoolAcquireTimeoutError.html | 4 +- ...db_nodejs-client.QwpPoolResourceError.html | 4 +- ...uestdb_nodejs-client.QwpProtocolError.html | 4 +- .../_questdb_nodejs-client.QwpQueryLease.html | 8 +- ...ejs-client.QwpReconnectExhaustedError.html | 4 +- ...odejs-client.QwpReplayDictionaryError.html | 4 +- ...t.QwpReplayDictionaryPersistenceError.html | 4 +- ..._nodejs-client.QwpReplayRejectedError.html | 4 +- ...ient.QwpReplayStoreAppendTimeoutError.html | 6 +- ...-client.QwpReplayStoreCheckpointError.html | 6 +- ...-client.QwpReplayStoreCorruptionError.html | 6 +- ...tdb_nodejs-client.QwpReplayStoreError.html | 6 +- ...nodejs-client.QwpReplayStoreFullError.html | 6 +- ...js-client.QwpReplayStoreLockLostError.html | 6 +- ...dejs-client.QwpReplayStoreLockedError.html | 6 +- ...client.QwpReplayStoreQuarantinedError.html | 6 +- ...nt.QwpReplayStoreSegmentTooLargeError.html | 6 +- ..._questdb_nodejs-client.QwpResultBatch.html | 4 +- ...b_nodejs-client.QwpResultBatchDecoder.html | 19 +- ...stdb_nodejs-client.QwpResultBatchView.html | 10 +- ...tdb_nodejs-client.QwpResultColumnView.html | 24 +-- ...uestdb_nodejs-client.QwpResultRowView.html | 16 +- ...db_nodejs-client.QwpRoleMismatchError.html | 10 +- ...stdb_nodejs-client.QwpSendClosedError.html | 4 +- .../_questdb_nodejs-client.QwpSendError.html | 4 +- ...tdb_nodejs-client.QwpSendTimeoutError.html | 4 +- .../_questdb_nodejs-client.QwpSender.html | 32 ++-- ...ejs-client.QwpSenderCloseTimeoutError.html | 4 +- ...tdb_nodejs-client.QwpSymbolDictionary.html | 8 +- ..._questdb_nodejs-client.QwpTableBuffer.html | 12 +- ..._questdb_nodejs-client.QwpTableWriter.html | 8 +- ...js-client.QwpUdpDatagramTooLargeError.html | 4 +- ...QwpUnrecoverableReplayDictionaryError.html | 4 +- ...questdb_nodejs-client.QwpUpgradeError.html | 10 +- ...nodejs-client.QwpVersionMismatchError.html | 10 +- ...estdb_nodejs-client.QwpWriterRowError.html | 4 +- .../_questdb_nodejs-client.Sender.html | 50 +++--- ..._questdb_nodejs-client.SenderBufferV1.html | 38 ++-- ..._questdb_nodejs-client.SenderBufferV2.html | 38 ++-- ..._questdb_nodejs-client.SenderBufferV3.html | 38 ++-- .../_questdb_nodejs-client.SenderOptions.html | 24 ++- .../_questdb_nodejs-client.TcpTransport.html | 12 +- ...questdb_nodejs-client.UndiciTransport.html | 12 +- ...ent.addQwpDurableAckWebSocketProtocol.html | 2 +- .../_questdb_browser-client.binary.html | 2 +- .../_questdb_browser-client.bool.html | 2 +- ...ser-client.bootstrapQwpBrowserSession.html | 2 +- .../_questdb_browser-client.byte.html | 2 +- .../_questdb_browser-client.char.html | 2 +- .../_questdb_browser-client.concatBytes.html | 2 +- ...rowser-client.connectQwpBrowserClient.html | 2 +- ...rowser-client.connectQwpBrowserEgress.html | 2 +- ...owser-client.connectQwpBrowserIngress.html | 2 +- ...rowser-client.connectQwpBrowserSender.html | 2 +- ...ser-client.connectQwpBrowserWebSocket.html | 2 +- ...browser-client.createQwpBrowserClient.html | 2 +- ...ent.createQwpBrowserConnectionFactory.html | 2 +- ...browser-client.createQwpBrowserSender.html | 2 +- ...r-client.createQwpDataLossSenderError.html | 2 +- ...createQwpProtocolViolationSenderError.html | 2 +- ...b_browser-client.createQwpSenderError.html | 2 +- .../_questdb_browser-client.date.html | 2 +- .../_questdb_browser-client.decimal128.html | 2 +- .../_questdb_browser-client.decimal256.html | 2 +- .../_questdb_browser-client.decimal64.html | 2 +- ...owser-client.decodeQwpContentEncoding.html | 2 +- ...browser-client.decodeQwpEgressMessage.html | 2 +- ...questdb_browser-client.decodeQwpFrame.html | 2 +- ...owser-client.decodeQwpIngressResponse.html | 2 +- ...ser-client.decodeQwpIngressServerInfo.html | 2 +- ...decodeQwpIngressSymbolDictionaryDelta.html | 2 +- ...uestdb_browser-client.decodeQwpVarint.html | 2 +- .../_questdb_browser-client.decodeUtf8.html | 2 +- ...browser-client.decompressQwpZstdFrame.html | 2 +- ...r-client.defaultQwpSenderErrorHandler.html | 2 +- ...db_browser-client.designatedTimestamp.html | 2 +- .../_questdb_browser-client.double.html | 2 +- .../_questdb_browser-client.doubleArray.html | 2 +- ...rowser-client.encodeQwpAcceptEncoding.html | 2 +- ...questdb_browser-client.encodeQwpBinds.html | 2 +- ...uestdb_browser-client.encodeQwpCancel.html | 2 +- ...uestdb_browser-client.encodeQwpCredit.html | 2 +- ...r-client.encodeQwpDurableAckPollFrame.html | 2 +- ...questdb_browser-client.encodeQwpFrame.html | 2 +- ...estdb_browser-client.encodeQwpGorilla.html | 2 +- ...er-client.encodeQwpIngressCommitFrame.html | 2 +- ..._browser-client.encodeQwpIngressFrame.html | 2 +- ...encodeQwpIngressSymbolDictionaryFrame.html | 2 +- ..._browser-client.encodeQwpQueryRequest.html | 2 +- ...uestdb_browser-client.encodeQwpVarint.html | 2 +- .../_questdb_browser-client.encodeUtf8.html | 2 +- ...uestdb_browser-client.flattenQwpArray.html | 2 +- .../_questdb_browser-client.float32.html | 2 +- .../_questdb_browser-client.float64.html | 2 +- .../_questdb_browser-client.geohash.html | 2 +- .../_questdb_browser-client.int32.html | 2 +- .../_questdb_browser-client.int64.html | 2 +- .../_questdb_browser-client.ipv4.html | 2 +- ...ient.isQwpDurableAckWebSocketProtocol.html | 2 +- .../_questdb_browser-client.long.html | 2 +- .../_questdb_browser-client.long256.html | 2 +- .../_questdb_browser-client.longArray.html | 2 +- ...er-client.qwpDefaultSenderErrorPolicy.html | 2 +- ...questdb_browser-client.qwpGorillaSize.html | 2 +- ...browser-client.qwpSenderErrorCategory.html | 2 +- ..._questdb_browser-client.qwpVarintSize.html | 2 +- ..._questdb_browser-client.readQwpVarint.html | 2 +- ...db_browser-client.readQwpVarintNumber.html | 2 +- .../_questdb_browser-client.short.html | 2 +- .../_questdb_browser-client.symbol.html | 2 +- .../_questdb_browser-client.timestamp.html | 2 +- .../_questdb_browser-client.utf8Length.html | 2 +- .../_questdb_browser-client.uuid.html | 2 +- .../_questdb_browser-client.varchar.html | 2 +- ...db_browser-client.writeQwpFrameHeader.html | 2 +- ...questdb_browser-client.writeQwpVarint.html | 2 +- ...ent.addQwpDurableAckWebSocketProtocol.html | 2 +- ...js-client.bigintToTwosComplementBytes.html | 2 +- .../_questdb_nodejs-client.binary.html | 2 +- .../_questdb_nodejs-client.bool.html | 2 +- .../_questdb_nodejs-client.byte.html | 2 +- .../_questdb_nodejs-client.char.html | 2 +- .../_questdb_nodejs-client.concatBytes.html | 2 +- ...db_nodejs-client.connectQwpNodeClient.html | 4 +- ...db_nodejs-client.connectQwpNodeEgress.html | 2 +- ...b_nodejs-client.connectQwpNodeIngress.html | 2 +- ...db_nodejs-client.connectQwpNodeSender.html | 2 +- ...estdb_nodejs-client.connectQwpNodeUdp.html | 2 +- ...nodejs-client.connectQwpNodeUdpSender.html | 2 +- ...nodejs-client.connectQwpNodeWebSocket.html | 2 +- .../_questdb_nodejs-client.createBuffer.html | 2 +- ...s-client.createQwpDataLossSenderError.html | 2 +- ...tdb_nodejs-client.createQwpNodeClient.html | 4 +- ...client.createQwpNodeConnectionFactory.html | 2 +- ...tdb_nodejs-client.createQwpNodeSender.html | 2 +- ..._nodejs-client.createQwpNodeUdpSender.html | 2 +- ...createQwpProtocolViolationSenderError.html | 2 +- ...db_nodejs-client.createQwpSenderError.html | 2 +- ...questdb_nodejs-client.createTransport.html | 2 +- .../_questdb_nodejs-client.date.html | 2 +- .../_questdb_nodejs-client.decimal128.html | 2 +- .../_questdb_nodejs-client.decimal256.html | 2 +- .../_questdb_nodejs-client.decimal64.html | 2 +- ...odejs-client.decodeQwpContentEncoding.html | 2 +- ..._nodejs-client.decodeQwpEgressMessage.html | 2 +- ..._questdb_nodejs-client.decodeQwpFrame.html | 2 +- ...odejs-client.decodeQwpIngressResponse.html | 2 +- ...ejs-client.decodeQwpIngressServerInfo.html | 2 +- ...decodeQwpIngressSymbolDictionaryDelta.html | 2 +- ...questdb_nodejs-client.decodeQwpVarint.html | 2 +- .../_questdb_nodejs-client.decodeUtf8.html | 2 +- ..._nodejs-client.decompressQwpZstdFrame.html | 2 +- ...s-client.defaultQwpSenderErrorHandler.html | 2 +- ...tdb_nodejs-client.designatedTimestamp.html | 2 +- .../_questdb_nodejs-client.double.html | 2 +- .../_questdb_nodejs-client.doubleArray.html | 2 +- ...nodejs-client.encodeQwpAcceptEncoding.html | 2 +- ..._questdb_nodejs-client.encodeQwpBinds.html | 2 +- ...questdb_nodejs-client.encodeQwpCancel.html | 2 +- ...questdb_nodejs-client.encodeQwpCredit.html | 2 +- ...s-client.encodeQwpDurableAckPollFrame.html | 2 +- ..._questdb_nodejs-client.encodeQwpFrame.html | 2 +- ...uestdb_nodejs-client.encodeQwpGorilla.html | 2 +- ...js-client.encodeQwpIngressCommitFrame.html | 2 +- ...b_nodejs-client.encodeQwpIngressFrame.html | 2 +- ...encodeQwpIngressSymbolDictionaryFrame.html | 2 +- ...b_nodejs-client.encodeQwpQueryRequest.html | 2 +- ...questdb_nodejs-client.encodeQwpVarint.html | 2 +- .../_questdb_nodejs-client.encodeUtf8.html | 2 +- ...questdb_nodejs-client.flattenQwpArray.html | 2 +- .../_questdb_nodejs-client.float32.html | 2 +- .../_questdb_nodejs-client.float64.html | 2 +- .../_questdb_nodejs-client.geohash.html | 2 +- .../_questdb_nodejs-client.int32.html | 2 +- .../_questdb_nodejs-client.int64.html | 2 +- .../_questdb_nodejs-client.ipv4.html | 2 +- ...ient.isQwpDurableAckWebSocketProtocol.html | 2 +- .../_questdb_nodejs-client.long.html | 2 +- .../_questdb_nodejs-client.long256.html | 2 +- .../_questdb_nodejs-client.longArray.html | 2 +- ...odejs-client.parseQwpNodeClientConfig.html | 2 +- ...js-client.qwpDefaultSenderErrorPolicy.html | 2 +- ..._questdb_nodejs-client.qwpGorillaSize.html | 2 +- ..._nodejs-client.qwpSenderErrorCategory.html | 2 +- .../_questdb_nodejs-client.qwpVarintSize.html | 2 +- .../_questdb_nodejs-client.readQwpVarint.html | 2 +- ...tdb_nodejs-client.readQwpVarintNumber.html | 2 +- ..._nodejs-client.retryQwpNodeOrphanSlot.html | 2 +- ..._nodejs-client.scanQwpNodeOrphanSlots.html | 2 +- .../_questdb_nodejs-client.short.html | 2 +- .../_questdb_nodejs-client.symbol.html | 2 +- .../_questdb_nodejs-client.timestamp.html | 2 +- .../_questdb_nodejs-client.utf8Length.html | 2 +- .../_questdb_nodejs-client.uuid.html | 2 +- .../_questdb_nodejs-client.varchar.html | 2 +- ...tdb_nodejs-client.writeQwpFrameHeader.html | 2 +- ..._questdb_nodejs-client.writeQwpVarint.html | 2 +- docs/hierarchy.html | 2 +- docs/index.html | 4 +- ..._questdb_browser-client.QwpArrayValue.html | 4 +- ...db_browser-client.QwpBinaryConnection.html | 22 +-- ...owser-client.QwpBrowserClusterOptions.html | 14 +- ...rowser-client.QwpBrowserEgressOptions.html | 28 +-- ...ent.QwpBrowserSessionBootstrapOptions.html | 12 +- ...ient.QwpBrowserSessionBootstrapResult.html | 4 +- ...r-client.QwpBrowserSplitClientOptions.html | 4 +- ...client.QwpBrowserUnifiedClientOptions.html | 4 +- ...ser-client.QwpBrowserWebSocketOptions.html | 18 +- ...b_browser-client.QwpCacheResetMessage.html | 4 +- ...tdb_browser-client.QwpClientFactories.html | 8 +- ...estdb_browser-client.QwpClientMetrics.html | 4 +- ...b_browser-client.QwpClientPoolOptions.html | 18 +- ...uestdb_browser-client.QwpColumnBuffer.html | 10 +- ...browser-client.QwpConnectionCloseInfo.html | 4 +- ...uestdb_browser-client.QwpDecimalValue.html | 4 +- ..._browser-client.QwpEgressQueryOptions.html | 16 +- ...wser-client.QwpEgressReplayResetEvent.html | 8 +- ...rowser-client.QwpEgressRoutingOptions.html | 6 +- ...rowser-client.QwpEgressSessionOptions.html | 22 ++- ...tdb_browser-client.QwpEgressViewQuery.html | 6 +- ...uestdb_browser-client.QwpEncodedBinds.html | 4 +- ...tdb_browser-client.QwpExecDoneMessage.html | 4 +- ...tdb_browser-client.QwpFailoverAttempt.html | 4 +- .../_questdb_browser-client.QwpFrame.html | 4 +- ...questdb_browser-client.QwpFrameHeader.html | 4 +- ...uestdb_browser-client.QwpGeohashValue.html | 4 +- ...b_browser-client.QwpHandshakeMetadata.html | 16 +- ...rowser-client.QwpIngressEncodeOptions.html | 6 +- ...b_browser-client.QwpIngressErrorEvent.html | 6 +- ...stdb_browser-client.QwpIngressMetrics.html | 12 +- ...rowser-client.QwpIngressProgressEvent.html | 4 +- ...browser-client.QwpIngressReplayRecord.html | 4 +- ...wser-client.QwpIngressReplayReference.html | 4 +- ..._browser-client.QwpIngressReplayStore.html | 14 +- ...tdb_browser-client.QwpIngressResponse.html | 4 +- ...b_browser-client.QwpIngressSendResult.html | 8 +- ...owser-client.QwpIngressSessionOptions.html | 36 ++-- ...lient.QwpIngressSymbolDictionaryDelta.html | 4 +- ..._browser-client.QwpIngressTableResult.html | 4 +- ...ser-client.QwpIngressTransportMetrics.html | 12 +- ...uestdb_browser-client.QwpLong256Value.html | 4 +- ...browser-client.QwpPoolSlotReservation.html | 4 +- ...b_browser-client.QwpQueryErrorMessage.html | 4 +- ...uestdb_browser-client.QwpQueryRequest.html | 12 +- ...stdb_browser-client.QwpReconnectEvent.html | 6 +- ...db_browser-client.QwpReconnectOptions.html | 14 +- ...browser-client.QwpResourcePoolMetrics.html | 4 +- ...db_browser-client.QwpResultArrayValue.html | 4 +- ..._browser-client.QwpResultBatchMessage.html | 6 +- ...uestdb_browser-client.QwpResultColumn.html | 4 +- ..._browser-client.QwpResultColumnSchema.html | 4 +- ...db_browser-client.QwpResultEndMessage.html | 4 +- ...browser-client.QwpSenderEncodeOptions.html | 6 +- ...questdb_browser-client.QwpSenderError.html | 8 +- ...-client.QwpSenderErrorResponseContext.html | 4 +- ...estdb_browser-client.QwpSenderMetrics.html | 8 +- ...estdb_browser-client.QwpSenderOptions.html | 18 +- ...estdb_browser-client.QwpSenderSession.html | 4 +- ...b_browser-client.QwpServerInfoMessage.html | 4 +- ...questdb_browser-client.QwpSymbolValue.html | 4 +- ...browser-client.QwpUpgradeErrorDetails.html | 8 +- .../_questdb_browser-client.QwpUuidValue.html | 4 +- ...ser-client.QwpWebSocketConnectOptions.html | 10 +- ...estdb_browser-client.QwpWebSocketLike.html | 16 +- ...uestdb_browser-client.QwpWriterColumn.html | 8 +- .../_questdb_nodejs-client.QwpArrayValue.html | 4 +- ...tdb_nodejs-client.QwpBinaryConnection.html | 22 +-- ...db_nodejs-client.QwpCacheResetMessage.html | 4 +- ...stdb_nodejs-client.QwpClientFactories.html | 8 +- ...uestdb_nodejs-client.QwpClientMetrics.html | 4 +- ...db_nodejs-client.QwpClientPoolOptions.html | 18 +- ...questdb_nodejs-client.QwpColumnBuffer.html | 10 +- ..._nodejs-client.QwpConnectionCloseInfo.html | 4 +- ...questdb_nodejs-client.QwpDecimalValue.html | 4 +- ...b_nodejs-client.QwpEgressQueryOptions.html | 16 +- ...dejs-client.QwpEgressReplayResetEvent.html | 8 +- ...nodejs-client.QwpEgressRoutingOptions.html | 6 +- ...nodejs-client.QwpEgressSessionOptions.html | 22 ++- ...stdb_nodejs-client.QwpEgressViewQuery.html | 6 +- ...questdb_nodejs-client.QwpEncodedBinds.html | 4 +- ...stdb_nodejs-client.QwpExecDoneMessage.html | 4 +- ...stdb_nodejs-client.QwpFailoverAttempt.html | 4 +- .../_questdb_nodejs-client.QwpFrame.html | 4 +- ..._questdb_nodejs-client.QwpFrameHeader.html | 4 +- ...questdb_nodejs-client.QwpGeohashValue.html | 4 +- ...db_nodejs-client.QwpHandshakeMetadata.html | 16 +- ...nodejs-client.QwpIngressEncodeOptions.html | 6 +- ...db_nodejs-client.QwpIngressErrorEvent.html | 6 +- ...estdb_nodejs-client.QwpIngressMetrics.html | 12 +- ...nodejs-client.QwpIngressProgressEvent.html | 4 +- ..._nodejs-client.QwpIngressReplayRecord.html | 4 +- ...dejs-client.QwpIngressReplayReference.html | 4 +- ...b_nodejs-client.QwpIngressReplayStore.html | 14 +- ...stdb_nodejs-client.QwpIngressResponse.html | 4 +- ...db_nodejs-client.QwpIngressSendResult.html | 8 +- ...odejs-client.QwpIngressSessionOptions.html | 36 ++-- ...lient.QwpIngressSymbolDictionaryDelta.html | 4 +- ...b_nodejs-client.QwpIngressTableResult.html | 4 +- ...ejs-client.QwpIngressTransportMetrics.html | 12 +- ...questdb_nodejs-client.QwpLong256Value.html | 4 +- ...ejs-client.QwpNodeClientConfigOptions.html | 8 +- ...db_nodejs-client.QwpNodeClientOptions.html | 6 +- ...db_nodejs-client.QwpNodeEgressOptions.html | 29 +-- ...-client.QwpNodeFileReplayStoreMetrics.html | 4 +- ...-client.QwpNodeFileReplayStoreOptions.html | 18 +- ...b_nodejs-client.QwpNodeIngressOptions.html | 32 ++-- ...nodejs-client.QwpNodeOrphanDrainEvent.html | 8 +- ...dejs-client.QwpNodeOrphanDrainSession.html | 6 +- ...js-client.QwpNodeOrphanDrainerMetrics.html | 6 +- ...js-client.QwpNodeOrphanDrainerOptions.html | 24 +-- ...js-client.QwpNodeReplayDataLossReport.html | 9 +- ...ejs-client.QwpNodeReplayRecoveryEvent.html | 4 +- ...-client.QwpNodeStoreAndForwardOptions.html | 32 ++-- ...estdb_nodejs-client.QwpNodeUdpMetrics.html | 4 +- ...estdb_nodejs-client.QwpNodeUdpOptions.html | 16 +- ...db_nodejs-client.QwpNodeUdpSocketLike.html | 4 +- ...nodejs-client.QwpNodeUpgradeRejection.html | 4 +- ...nodejs-client.QwpNodeWebSocketOptions.html | 19 +- ..._nodejs-client.QwpPoolSlotReservation.html | 4 +- ...db_nodejs-client.QwpQueryErrorMessage.html | 4 +- ...questdb_nodejs-client.QwpQueryRequest.html | 12 +- ...estdb_nodejs-client.QwpReconnectEvent.html | 6 +- ...tdb_nodejs-client.QwpReconnectOptions.html | 14 +- ..._nodejs-client.QwpResourcePoolMetrics.html | 4 +- ...tdb_nodejs-client.QwpResultArrayValue.html | 4 +- ...b_nodejs-client.QwpResultBatchMessage.html | 6 +- ...questdb_nodejs-client.QwpResultColumn.html | 4 +- ...b_nodejs-client.QwpResultColumnSchema.html | 4 +- ...tdb_nodejs-client.QwpResultEndMessage.html | 4 +- ..._nodejs-client.QwpSenderEncodeOptions.html | 6 +- ..._questdb_nodejs-client.QwpSenderError.html | 8 +- ...-client.QwpSenderErrorResponseContext.html | 4 +- ...uestdb_nodejs-client.QwpSenderMetrics.html | 8 +- ...uestdb_nodejs-client.QwpSenderOptions.html | 18 +- ...uestdb_nodejs-client.QwpSenderSession.html | 4 +- ...db_nodejs-client.QwpServerInfoMessage.html | 4 +- ..._questdb_nodejs-client.QwpSymbolValue.html | 4 +- ..._nodejs-client.QwpUpgradeErrorDetails.html | 8 +- .../_questdb_nodejs-client.QwpUuidValue.html | 4 +- ...ejs-client.QwpWebSocketConnectOptions.html | 10 +- ...uestdb_nodejs-client.QwpWebSocketLike.html | 16 +- ...questdb_nodejs-client.QwpWriterColumn.html | 8 +- .../_questdb_nodejs-client.SenderBuffer.html | 34 ++-- ...questdb_nodejs-client.SenderTransport.html | 10 +- docs/media/QWP.md | 166 ++++++++++++------ docs/modules/_questdb_browser-client.html | 2 +- docs/modules/_questdb_nodejs-client.html | 2 +- ..._questdb_browser-client.QwpBindSetter.html | 2 +- .../_questdb_browser-client.QwpBindType.html | 2 +- ...-client.QwpBrowserClientEgressOptions.html | 2 +- ...client.QwpBrowserClientIngressOptions.html | 2 +- ...rowser-client.QwpBrowserClientOptions.html | 2 +- ...uestdb_browser-client.QwpBrowserFetch.html | 2 +- ...lient.QwpBrowserSessionAuthentication.html | 2 +- ...ient.QwpBrowserSessionBootstrapConfig.html | 2 +- ..._questdb_browser-client.QwpColumnType.html | 2 +- ...b_browser-client.QwpConnectionFactory.html | 2 +- ...uestdb_browser-client.QwpDecimalInput.html | 2 +- ...db_browser-client.QwpDoubleArrayInput.html | 2 +- ...b_browser-client.QwpEgressCompression.html | 2 +- ...estdb_browser-client.QwpEgressMessage.html | 2 +- ...uestdb_browser-client.QwpGeohashInput.html | 2 +- ...browser-client.QwpIngressProgressKind.html | 2 +- ..._browser-client.QwpInitialConnectMode.html | 2 +- .../_questdb_browser-client.QwpInt64.html | 2 +- .../_questdb_browser-client.QwpIpv4Input.html | 2 +- ...uestdb_browser-client.QwpLong256Input.html | 2 +- ...uestdb_browser-client.QwpLong256Words.html | 2 +- ...stdb_browser-client.QwpLongArrayInput.html | 2 +- ...client.QwpNegotiatedEgressCompression.html | 2 +- ...tdb_browser-client.QwpNestedLongArray.html | 2 +- ...b_browser-client.QwpNestedNumberArray.html | 2 +- ...tdb_browser-client.QwpQueryCompletion.html | 2 +- ..._browser-client.QwpReconnectEventKind.html | 2 +- ...wser-client.QwpResultBatchViewHandler.html | 2 +- ...owser-client.QwpResultRowViewCallback.html | 2 +- ...questdb_browser-client.QwpResultValue.html | 2 +- ...browser-client.QwpSenderErrorCategory.html | 2 +- ...b_browser-client.QwpSenderErrorPolicy.html | 2 +- ...uestdb_browser-client.QwpSenderLogger.html | 2 +- ...rowser-client.QwpSenderSessionFactory.html | 2 +- .../_questdb_browser-client.QwpTarget.html | 2 +- ...estdb_browser-client.QwpTimestampUnit.html | 2 +- ...db_browser-client.QwpUpgradeErrorKind.html | 2 +- ...browser-client.QwpUpgradeTimeoutPhase.html | 2 +- .../_questdb_browser-client.QwpUuidInput.html | 2 +- ...db_browser-client.QwpWriterColumnKind.html | 2 +- .../_questdb_browser-client.QwpWriterRow.html | 2 +- ...uestdb_browser-client.QwpWriterSchema.html | 2 +- .../_questdb_nodejs-client.ExtraOptions.html | 4 +- docs/types/_questdb_nodejs-client.Logger.html | 2 +- .../_questdb_nodejs-client.QwpBindSetter.html | 2 +- .../_questdb_nodejs-client.QwpBindType.html | 2 +- .../_questdb_nodejs-client.QwpColumnType.html | 2 +- ...db_nodejs-client.QwpConnectionFactory.html | 2 +- ...questdb_nodejs-client.QwpDecimalInput.html | 2 +- ...tdb_nodejs-client.QwpDoubleArrayInput.html | 2 +- ...db_nodejs-client.QwpEgressCompression.html | 2 +- ...uestdb_nodejs-client.QwpEgressMessage.html | 2 +- ...questdb_nodejs-client.QwpExtraOptions.html | 10 +- ...questdb_nodejs-client.QwpGeohashInput.html | 2 +- ..._nodejs-client.QwpIngressProgressKind.html | 2 +- ...b_nodejs-client.QwpInitialConnectMode.html | 2 +- .../_questdb_nodejs-client.QwpInt64.html | 2 +- .../_questdb_nodejs-client.QwpIpv4Input.html | 2 +- ...questdb_nodejs-client.QwpLong256Input.html | 2 +- ...questdb_nodejs-client.QwpLong256Words.html | 2 +- ...estdb_nodejs-client.QwpLongArrayInput.html | 2 +- ...client.QwpNegotiatedEgressCompression.html | 2 +- ...stdb_nodejs-client.QwpNestedLongArray.html | 2 +- ...db_nodejs-client.QwpNestedNumberArray.html | 2 +- ...js-client.QwpNodeOrphanDrainEventKind.html | 2 +- ...stdb_nodejs-client.QwpQueryCompletion.html | 2 +- ...b_nodejs-client.QwpReconnectEventKind.html | 2 +- ...dejs-client.QwpResultBatchViewHandler.html | 2 +- ...odejs-client.QwpResultRowViewCallback.html | 2 +- ..._questdb_nodejs-client.QwpResultValue.html | 2 +- ..._nodejs-client.QwpSenderErrorCategory.html | 2 +- ...db_nodejs-client.QwpSenderErrorPolicy.html | 2 +- ...questdb_nodejs-client.QwpSenderLogger.html | 2 +- ...nodejs-client.QwpSenderSessionFactory.html | 2 +- ...nodejs-client.QwpSfBackpressurePolicy.html | 2 +- ...questdb_nodejs-client.QwpSfDurability.html | 2 +- .../_questdb_nodejs-client.QwpTarget.html | 2 +- ...uestdb_nodejs-client.QwpTimestampUnit.html | 2 +- ...tdb_nodejs-client.QwpUpgradeErrorKind.html | 2 +- ..._nodejs-client.QwpUpgradeTimeoutPhase.html | 2 +- .../_questdb_nodejs-client.QwpUuidInput.html | 2 +- ...tdb_nodejs-client.QwpWriterColumnKind.html | 2 +- .../_questdb_nodejs-client.QwpWriterRow.html | 2 +- ...questdb_nodejs-client.QwpWriterSchema.html | 2 +- .../_questdb_nodejs-client.TimestampUnit.html | 2 +- ...uestdb_browser-client.QWP_COLUMN_TYPE.html | 2 +- ..._browser-client.QWP_COMPRESSION_CODEC.html | 2 +- ..._browser-client.QWP_DECIMAL_MAX_SCALE.html | 2 +- ...t.QWP_DEFAULT_EGRESS_BUFFER_POOL_SIZE.html | 2 +- ...ent.QWP_DEFAULT_EGRESS_INITIAL_CREDIT.html | 2 +- ...DEFAULT_EGRESS_SERVER_INFO_TIMEOUT_MS.html | 2 +- ...nt.QWP_DURABLE_ACK_WEBSOCKET_PROTOCOL.html | 2 +- ..._browser-client.QWP_EGRESS_CAPABILITY.html | 2 +- ...tdb_browser-client.QWP_EGRESS_MESSAGE.html | 2 +- ...uestdb_browser-client.QWP_EGRESS_PATH.html | 2 +- ...b_browser-client.QWP_ENCODING_GORILLA.html | 2 +- ...wser-client.QWP_ENCODING_UNCOMPRESSED.html | 2 +- ..._browser-client.QWP_FLAG_DEFER_COMMIT.html | 2 +- ...ient.QWP_FLAG_DELTA_SYMBOL_DICTIONARY.html | 2 +- ...wser-client.QWP_FLAG_DURABLE_ACK_POLL.html | 2 +- ...estdb_browser-client.QWP_FLAG_GORILLA.html | 2 +- ..._questdb_browser-client.QWP_FLAG_ZSTD.html | 2 +- ...uestdb_browser-client.QWP_HEADER_SIZE.html | 2 +- ...estdb_browser-client.QWP_INGRESS_PATH.html | 2 +- ...wser-client.QWP_INGRESS_PROGRESS_KIND.html | 2 +- ...owser-client.QWP_INITIAL_CONNECT_MODE.html | 2 +- .../_questdb_browser-client.QWP_MAGIC.html | 2 +- ...owser-client.QWP_MAX_ARRAY_DIMENSIONS.html | 2 +- ...client.QWP_MAX_ARRAY_DIMENSION_LENGTH.html | 2 +- ...client.QWP_MAX_BATCH_ROWS_UPPER_BOUND.html | 2 +- ...rowser-client.QWP_MAX_CELLS_PER_BATCH.html | 2 +- ...wser-client.QWP_MAX_COLUMNS_PER_TABLE.html | 2 +- ...ser-client.QWP_MAX_COLUMN_NAME_LENGTH.html | 2 +- ...r-client.QWP_MAX_ERROR_MESSAGE_LENGTH.html | 1 - ...owser-client.QWP_MAX_IDENTIFIER_BYTES.html | 2 +- ...browser-client.QWP_MAX_ROWS_PER_TABLE.html | 2 +- ...client.QWP_MAX_SYMBOL_DICTIONARY_SIZE.html | 2 +- ...wser-client.QWP_MAX_TABLE_NAME_LENGTH.html | 2 +- ...client.QWP_MAX_ZSTD_DECOMPRESSED_SIZE.html | 2 +- ...lient.QWP_QUERY_FLAG_RESET_DICTIONARY.html | 2 +- ...owser-client.QWP_RECONNECT_EVENT_KIND.html | 2 +- ...wser-client.QWP_RESET_MASK_DICTIONARY.html | 2 +- ...wser-client.QWP_SENDER_ERROR_CATEGORY.html | 2 +- ...rowser-client.QWP_SENDER_ERROR_POLICY.html | 2 +- ...uestdb_browser-client.QWP_SERVER_ROLE.html | 2 +- .../_questdb_browser-client.QWP_STATUS.html | 2 +- .../_questdb_browser-client.QWP_TARGET.html | 2 +- ...browser-client.QWP_UPGRADE_ERROR_KIND.html | 2 +- ...wser-client.QWP_UPGRADE_TIMEOUT_PHASE.html | 2 +- .../_questdb_browser-client.QWP_VERSION.html | 2 +- ...client.QWP_ZSTD_MAX_COMPRESSION_LEVEL.html | 2 +- ...client.QWP_ZSTD_MIN_COMPRESSION_LEVEL.html | 2 +- ...questdb_nodejs-client.QWP_COLUMN_TYPE.html | 2 +- ...b_nodejs-client.QWP_COMPRESSION_CODEC.html | 2 +- ...b_nodejs-client.QWP_DECIMAL_MAX_SCALE.html | 2 +- ...t.QWP_DEFAULT_EGRESS_BUFFER_POOL_SIZE.html | 2 +- ...ent.QWP_DEFAULT_EGRESS_INITIAL_CREDIT.html | 2 +- ...DEFAULT_EGRESS_SERVER_INFO_TIMEOUT_MS.html | 2 +- ...nt.QWP_DURABLE_ACK_WEBSOCKET_PROTOCOL.html | 2 +- ...b_nodejs-client.QWP_EGRESS_CAPABILITY.html | 2 +- ...stdb_nodejs-client.QWP_EGRESS_MESSAGE.html | 2 +- ...questdb_nodejs-client.QWP_EGRESS_PATH.html | 2 +- ...db_nodejs-client.QWP_ENCODING_GORILLA.html | 2 +- ...dejs-client.QWP_ENCODING_UNCOMPRESSED.html | 2 +- ...b_nodejs-client.QWP_FLAG_DEFER_COMMIT.html | 2 +- ...ient.QWP_FLAG_DELTA_SYMBOL_DICTIONARY.html | 2 +- ...dejs-client.QWP_FLAG_DURABLE_ACK_POLL.html | 2 +- ...uestdb_nodejs-client.QWP_FLAG_GORILLA.html | 2 +- .../_questdb_nodejs-client.QWP_FLAG_ZSTD.html | 2 +- ...questdb_nodejs-client.QWP_HEADER_SIZE.html | 2 +- ...uestdb_nodejs-client.QWP_INGRESS_PATH.html | 2 +- ...dejs-client.QWP_INGRESS_PROGRESS_KIND.html | 2 +- ...odejs-client.QWP_INITIAL_CONNECT_MODE.html | 2 +- .../_questdb_nodejs-client.QWP_MAGIC.html | 2 +- ...odejs-client.QWP_MAX_ARRAY_DIMENSIONS.html | 2 +- ...client.QWP_MAX_ARRAY_DIMENSION_LENGTH.html | 2 +- ...client.QWP_MAX_BATCH_ROWS_UPPER_BOUND.html | 2 +- ...nodejs-client.QWP_MAX_CELLS_PER_BATCH.html | 2 +- ...dejs-client.QWP_MAX_COLUMNS_PER_TABLE.html | 2 +- ...ejs-client.QWP_MAX_COLUMN_NAME_LENGTH.html | 2 +- ...s-client.QWP_MAX_ERROR_MESSAGE_LENGTH.html | 1 - ...odejs-client.QWP_MAX_IDENTIFIER_BYTES.html | 2 +- ..._nodejs-client.QWP_MAX_ROWS_PER_TABLE.html | 2 +- ...client.QWP_MAX_SYMBOL_DICTIONARY_SIZE.html | 2 +- ...dejs-client.QWP_MAX_TABLE_NAME_LENGTH.html | 2 +- ...client.QWP_MAX_ZSTD_DECOMPRESSED_SIZE.html | 2 +- ...js-client.QWP_ORPHAN_DRAIN_EVENT_KIND.html | 2 +- ...ejs-client.QWP_ORPHAN_FAILED_SENTINEL.html | 2 +- ...lient.QWP_QUERY_FLAG_RESET_DICTIONARY.html | 2 +- ...odejs-client.QWP_RECONNECT_EVENT_KIND.html | 2 +- ...dejs-client.QWP_RESET_MASK_DICTIONARY.html | 2 +- ...dejs-client.QWP_SENDER_ERROR_CATEGORY.html | 2 +- ...nodejs-client.QWP_SENDER_ERROR_POLICY.html | 2 +- ...questdb_nodejs-client.QWP_SERVER_ROLE.html | 2 +- ...ejs-client.QWP_SF_BACKPRESSURE_POLICY.html | 2 +- ...estdb_nodejs-client.QWP_SF_DURABILITY.html | 2 +- .../_questdb_nodejs-client.QWP_STATUS.html | 2 +- .../_questdb_nodejs-client.QWP_TARGET.html | 2 +- ..._nodejs-client.QWP_UPGRADE_ERROR_KIND.html | 2 +- ...dejs-client.QWP_UPGRADE_TIMEOUT_PHASE.html | 2 +- .../_questdb_nodejs-client.QWP_VERSION.html | 2 +- ...client.QWP_ZSTD_MAX_COMPRESSION_LEVEL.html | 2 +- ...client.QWP_ZSTD_MIN_COMPRESSION_LEVEL.html | 2 +- 607 files changed, 1695 insertions(+), 1580 deletions(-) delete mode 100644 docs/variables/_questdb_browser-client.QWP_MAX_ERROR_MESSAGE_LENGTH.html delete mode 100644 docs/variables/_questdb_nodejs-client.QWP_MAX_ERROR_MESSAGE_LENGTH.html diff --git a/docs/assets/hierarchy.js b/docs/assets/hierarchy.js index a0476a8..63110f6 100644 --- a/docs/assets/hierarchy.js +++ b/docs/assets/hierarchy.js @@ -1 +1 @@ -window.hierarchyData = "eJy1ml1znDYUhv8L10qqDxBo7+KvaWactsna6UVmJ4NBtqlZaSsgaSbj/96RcG2vBLsC1CuvPd6Xh3OOPs4r/YyUlG0Trb4gSJIYIEIQBQjGEOvPNNaf0wwgSOMUZFmWAoQThAGDMAEIslT/H0UUMIoTgAiEDCAECQEsTRlABCEMEEIwBYikCQIIQkwAQpiSDYgUv6150VZSNNHqZ5ThmOmfIt/yaBV9/L4761R+U/N3xcO1yL/lVa1/O1dKqghED5UooxXCGYg6VUerqKjzpuHNL1//7njTljdfhSz5X82boq64aN8e1Ht7327rCPQa0Spqm/KNfsCb/g+PIMpSRPfpfpMlv6hq/onv6vzHupWKz8MaEPLhSTKX57rcrXnTVFLMR3nR8KDIsnSfon+Js8rkNVc/FqRrUOoIE4iK+6ouFRfR6gtLKQIZg8lGozKYHEb9g6umalouiiVFdkzVI6gsJkOkpjLe7XZclFfVlsuuXYw5IunDSOko4+k9Lx52shKBAC09H7rsAJ1Uqtvp7ASi29c7Tqenz1G6MEyTh4quOaCTCnTsAIM4BgzGCWCQ6l+zBDAE043Bx/Eo/kVX12Fe4VnJJ6DxeEAvZfHAyzBMr7R8qA4MEa10KZtAA2RPzYcsG4/Xxy5XuWgrESpotqAHH4KDq4qRW/O7LRftlZSXuboLNF6GRD04CcMWp6z5h6rZ5m1xv4TMlvFgibEVszUX5Wktm0V5tEQ8OChOXI6FBNNXfhoToENiJixqr6frIEuoreIRnJSifZJroXghv3GzMQ24dzqu60ObWhvy692dysslY+61wqSEZggBPd6AbhNMVjNkxfKz3mlJEWD8DSkdj5fpbl4h6frg6qS7veXqM55Bsi8wJVwIQmZ2vebDGBNZykR8goL3gnJV7K5ULpqdVO2Mx7/+utfDGdwvktO8uOefeMPbD7xp8ruXng0n9D+ISrRc3ebFwSJxlEZ5nuV6JpJY+6fzO8Wb5pPs2krc/b7rO+K5WENiHmRW+dCEAQQTFvdFRDIb+R9enEnBlwbR0vEOIbO2VhdKf5xLYb7t++wY4oFn/8rzkqtlBL3G5GQhmGqrhiWpMWWMNYMgMFkDZggAE7E+lXFq+QbvRV8xAzbG1Ldwpaa+jLZZes6Exa6/0Rf30iHiKPmmnsbpUQ8oBNyw5PRRnKZZH0yaMBf8KVshgPelfMOZ2qWopXpLQpQXUn3PVRmCbljSlzKj2KX8k9+sdTPYhuCzxRZP1wxZq97Hjj9t+5ZO2I6SbxhZ4vR0TVe3J3qDtRTKlfKmcnt0LXUq624rFvL0It4kqd1LvhJZF/d8mwfh6aWmlxijTxMzSx3zQKufizJMGl+EPEOnTxzc/o4r2wmfirOnMn0hS/q5V6/ONp36xtV7cSuXBsxR8o2YY7A8T0GnUgheLJ7WRvSml11GcR9GTN3eyu0kJnFaItPhMCY9HLOPrk6U/N48F8+JPmdrVb7z70lveoFXIT0k6dEKYQSt1Xbh8ZpLuOR8zZwshjpKctEWnyUhgiDR558YbXpcjA7jzjpOOk4+/TwJ4Qzajv0sn3IAbrpRiXBGoDtdT3UqXZbJVqU5sF7iVQ4zzCgtiDJg4rLpsVDmYk32K4fpphmWpur/F8fSpQthWZorBws9ywGymaalWReAGX7AzL99epHd9jzN7Kd117RcTVx6RxeKfTnPnQFBDA3Czeq8R9nmdN8Ek3QQbW4vNkq3uB/TUexzjROrHGf7jy7tXANSX/MJZ0C6XEEcyJcQktimnec9DoDOMh8JsW/eTDIfXYwp7qO+lrXYfRxBmGk/klSvXoRCZG6D6XtfSX+bLKbAjABgYtZnM7HN79nWhPsWc70JzR7KmxjawM00Jwi1bZw55sQY0CR3Qt/yC+VOHAaaaU/oWG2eSFkYe2KMc7I/oQdIIAdgaE83zwLQ9zGDWgAuWigPQC/8wGybNo+Pj/8C8+mtXg==" \ No newline at end of file +window.hierarchyData = "eJy1ml1v2zYUhv+LrtlOJCV++K5JE6xAu61N2l0URqFITKJFJj1KblcU+e8DKTeJScqhPnqTyEH86tE5hx/npX4kWqmuTVafYYoJBRBjjABMM0jMNYPmmpu/EAIBY5wDiHJEAU8hBTDlzPwfwRhwghmAOEU5gDDNKODUfA9DxACEEEEAMSUMwDTFBECIKF2DRIvrRpRdrWSbrH4kDBFofstiI5JV8v7b9vVOF1eNeFXefZTF16JuzKczrZVOQHJXyypZmTskO90kq6RsirYV7W9f/t2JtquuvkhViX/aF2VTC9m9PKr38rbbNAnoNZJV0rXVC3ODF/0f7kHCKGKHdH+oSpzXjfggtk3x/aJTWkzDCgjF8NDU5/lYbS9E29ZKTkd51IigYJwfUvQP8bq2eS309xnpCko9wwSS8rZuKi1ksvrMKc0A45CuDSqH9DjqX0K3ddsJWc4psudUI4LK8zxEaivj1XYrZHVZb4TadbMxByRjGCkbZDy9FeXdVtVyIUBHL4aOH6FTWu+2JjsL0R3qPU9nps9BumWYRg8VU3PAJBWY2AFu5mme5maup+Yjp4BDyNcWH5NB/PNd0yzzCA9KMQHNhwP6VpV3olqG6YlWDNWRIWKU3qp2oQFyoBZDxofj9X5X6EJ2tVwqaK5gBB+EwVXFyl2Im42Q3aVSbwt9s9B4CYlGcGapO1GrRryr203RlbdzyFyZGJbM2Q9cCFmdNqqdlUdHJIKDYOZzzCQYv/KTnAATEjthkZz4SPOXUFclIjiUZockH6UWpfoq7MZ0wb3T87oxtBw5tNsbXVRzxtxThVEJZSgDZrwB0ybYrDLkxPKT2WkpucD4Cyk9Hy/b3TxBMvUh9Mnu+lroT2gCyaHAmHDBFKZsbZlgyoaY8FwmHBMUdBCUy3J7qQvZbpXuJtz+6ddjbo5TfFgkp0V5Kz6IVnTvRNsWN489G8rJT4hadkJfF+XRIvGUBnke5PZMxFl9z260aNsPatfV8ubPbd8RT8UKiUWQOeVDKDZ9/88iwu5++uw/Ub5WUswNoqMTG8IMOovduTaXUynst+PvTQL3/l0UldDzCHqN0cmCKTPWCqfQmjLc/EQZsFkDdghYUyftU5m5k/ob2VdMwMYY+xS+1NiHMTZLz0nSgN/SF/fcIeIpxaaeuA5VwLpZAi4sOX4UU472waTYB99nawngQ6nYcHr7CyPVWxKyOlf6W6GrJejCkrGUjBKf8m9xdWGawW4JPlds9nTN3a3R+53Yb/vmTtieUmwYOYVuT9fumu7EbLDmQvlS0VQsDVGdqma3kTN5epF4Es9VeRS5KG/FpliEp5caX2Kc7RcQzrzm3KifyWqZND4KRYbOnDj4/Z3QrhM+FudAZfxCRvt4mdXZpdNfhX4jr9XcgHlKsRGDyBmMD1PQqZJSlLOntQG98WXHKOnDiCj1+hi/kxjF6YiMh0N4D8eJM+GeaPWtfSieE3PO1uliG9+TXvUCT0J6TDKiFUIQOXuAmcdrPuGc8zV7srjUUZKPNvssCWII7fnnPuXm4hccJz1PPv48CSKG3FVukk8ZgBtvVELEsoAdN9ap9FlGW5X2wHqOVxlmmFBaKc6Ajcu6x8KZjzXarwzTjTMsbdX/EsfSp1vCsrSvHMz0LANkE01Luy4AO/yAnX/79EIeXixOm13bCT1y6R1cKA7lIncGGKU0CDep8x5km9J9Y5TjINrUXmyQbnY/ZqLY5xoRpxwn+48+7VQD0rzms5wB6XMt4kA+hhATJ+0TvccA6CTzEWOaBwzA6Rhj3EfzWtZs93EAYaL9iCnOzXtgkNm3wcw7YbmxIk3igB0BwMasz2bu2gCTrQn/KaZ6E4Z9KW8itIGbaE5g4u+Ix5sTQ0Cj3Anzlt9S7sRxoIn2hInVek8a9FHG2xNDnKP9CTNAFnIAQnu6aRaAeR9zUQvAR1vKAzALP7DbpvX9/f3/7SKt1A==" \ No newline at end of file diff --git a/docs/assets/navigation.js b/docs/assets/navigation.js index bf06890..e95e567 100644 --- a/docs/assets/navigation.js +++ b/docs/assets/navigation.js @@ -1 +1 @@ -window.navigationData = "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" \ No newline at end of file +window.navigationData = "eJzFnW1zo7iWx79LXs8+TGamd3ZeXYJJQrVt3Binp+fWLRcBJeEGgwfkZHK39rtvCbCNHpDOEaa3+k13tf//3wGEJPR0/v4/V5T8Ra9+u/rbnwdS0/Tx6oer5CXL04oUV7/9/fTfj1X5XpPq35I8IwW9+uFqH9OXq9+udmV6yEn9H9tOvuV/+O8vdJdf/XD1mhXp1W/Xau8v7/ubmCYvUVnO4+qZeFVVVmdEksd1rUEo5Tz4x+tf//cHjpcV6UOcH0iN45xkJv9WuiZ1nZXFTVnSmlbx3uLKNEamGD4oCUmcEiTxJAP4f60yauHfygz+rlDUAN6uotAN+Lp5WZMU/0AktYE2O1TxY06c5HVTxG9xlrN/4bHDNga+91yRuv5yINUHCtjTwQnOY1ykZWFzXwdd4HQ3LhKSR9mOlAc6KgLZCR7FKDCSdYlrRV9lSPZ5/BGSPw9ZZf+kFS4S/YfW7uq3q5TsK5LElKRXqpC66tEijE4JuvDut7Y1x5CJgX0bZ3n5Rio8kFMaKH7RROckr9ZlSm0B4y7j5NWaeBLDWDaFhZeiOLbFZdDFQF+QXVl9tK+Xs9+TIrV+onorRBy3Vbwj9t07vZUhjlVZ5k7SVDPWN2LAA0AOSV0eqsTioiW1iVaVtEzK3ILUVxooTZMxJ3FNUIizzOAfkqQsCpJQ76+X+FBTmzdnwMNIZsVrliU0K4vYph1XOiCpK1LVWU1JYVNkTGagWELyT5JY3ndJbyTWh5w2X29I0kkHJ8xIUmI/hmQ5nPeQkXdbGNOCSG6ZH3aFJeosBrHC8t0S1ClNlDIni6zesRtgUfpEtYG2JkVq2zYLWgAJWe5aCci3icO6ZRvwAJDtWFD3UReEuZKP3WOZn6tMHEsQG1gR+2a/OTw9IQtDTwchWIzJ9HQGwqaoSMI+KphifJNptjPFs3+u4tSirewLDYz2xoTlO57CSw0cp6rij2Z48czICkqqpzjRY85KYcD1l0/SuGdcfbht94j7CIKBRL0J1xq4OeuFVcGeSWo0U2UCA7ef3OO4nAcMK47WjgtgwM0ulLY9vlAkrRkwkH2e0XbwdOTtkIxgAWyK7Ckj6SVCUFnBgvhKHtdl8kpGBiDaGOBunLyQkNSELkhdx8/o+kUyMAEb5W2c0LLKCPoqBTkItiC0yhJLVCcGgdh3ueXTkwxMwKZ/LvYYgKye1og5VudNP9Avnko8TbIwQGckyXZxbtXc9bUGTG+g2/KhyQ4g5PFjuCbUe+PmsTBY0QWGLg80K55HXS/vAcJ2rcMoLO8BwrIPS2F6C0M8yU2wgn3+p2wGGH9tPa0J8xdJZmVBLCtqQW6AHScIHErJbo8uooLcBGMDt2gEE0GM74WJboT9vWKyW4LckfIlrl+sqqu+1oC5j4u0folfyYLQOI1pjGVJBgZgN8vQllDLV1flAcSyryKr6lEygAEtewm8GoZaVWUb4ojL4zxg2GOLkZRVakntW+CgT6Rig80juZ0LBr2mZTUO2zhAkfW+LGp7XiuHwdh4lt1Hm2QABY5pxpUmQLAwmjYjOb7+03nBwojaoagRd7znAERWcVHvy8r2I2bAxgCfl8Xz9S+frJq0vtaAYZ8767ykrBNbvcU2o04KCwP0vJjGsiMlGUCAbDULqdHFpq81YM5TmzatCq+GoixrAlFvxLVT3exZW74GCgsz9JBT+3FXUQ/CNRN8lsVSdgAh28EAO1irRWDWyQvZoStu2QGE9Ip01K086w24dqJsVD9ZYQGD8tMOKJg87aCDHLsmblk0/2/PFJxAIVi+9pwYBBr1/DBPTlpqhgEpF5spQNUbqdiAm+V7IBmYgE3Xyqq67EkNkP7s3IzQOMvRT0thYYIestTquk5CA+A0bO+OalkHbKDwefaKvkJObAI1c552TU5fa57KTNeEclPd9GNv3sDRigT3//zv//rxl2uZEH3sCdKfSSDux2lNph2YqDTzBk2wEXTfECND4F2wMYyEo6m3hFt4BqU1Mjilq8edA30hBc0S4RMISlXaoKM4zd26ZfGUPVuHIfgA4mhfauT7dBaBCMdJqHbq8AMFErQAXjcN5Rf7A0Wg+jIIpTywjT/sGwNNEqQAWluHuOVuX4l9FyNO0oJ5UtcFyFL3WFScbgweewf7MgBFGLb9zH4JhynUIGZGszjvCvCiTDG3URaDiPTTzygI/fQzxHf/9jP2AZ00AP9u0AqL6MvglK9llWJasr4MSLGqE3ghgLQkzyXN2NarMXWDxgUUA1vFfwodxeWUYNbysHsklR2tpwXw2g2H5W6fE2THQFACWPwAILJ6ksUgIremnU1P5qgeu9oATO5Wnrtxnj/GySsaLOjBXOE7EkhTfUSqGL2BFjem5BnX11GoccxVmWeJJbHVgnnz8vkZVV76MjCl69Tiu40qOYAasc1rmFq7FUCcsx2pabzbb4oMBejrAJz+6AqyHhGlcFq3rn/1wm0/gwL7agjzkKXYxvWkAfj3hzqQd1CUgmlh+Y7GhOU72F8c+gcilOP9MuXrausG881iuY2+rbwz5S2uMjblqiHxUp70kwxZrEJvvfaD5dYNZp6LRwkGBuDMc/2FM98unN+3a9eZo69NMjACb53NPNp6dyzM7c3m9tYLt6sgmG/X/h8WeK0dLhh/6Ue+M9+6oTfzo5Gh8Ga4QNZe+OCFW395G2wjf+EFm2i7WI8MSG1qCmwTOjdzb+u4n7dfvZt14H72ou0qDKLADeboiLRuhlC6y3CdlXPjz/3oG5YuGcCAC2+9du7QJZNXw1ArJ7q35DCpCbJ0g5m/vNveBaE/nztokqCH4jbLY63kzayZfRMD+Hbu3LHC74WsOlzg32TJAAacR852/W1xE8y3M9+N/GDphOhCqvMChdF7xVbBHP2KKk0gYMtC1ddCMH+sI3QhOgkNgHvPmXmhVTvUkxog/tL+Xe9roZgwaP/y2V+ib5zSxAju2r1gufTcaLsIZui7qfIwYBfOnY/uLTUio/HvWycMnW/bmb/wlk3Pau4t7/BPb9gJHwK6M6DyAGBvnMi934bB1/V2s1p54fYm2ODL0bATIATXm8/X20bBLGzYggUE2nbZl87CG/G0ZRcwug03YtWwPblnAgD7M28Z+bc+u03fIs+qiIkeAGxTJEZdLO8AQEotq1WdP+wECKEJdWz5kkwAYNYOsq+2U2/K+trVToYQvmy88FvbIofe2otGdJU0VoYgQu/YsngP3jKyahtVHkYsi3LhrD+PuGqliQG89pasd+KFYRBuXSfy7gI8WGmCAa+Cue+Ow7YWRmjzoRsG+NqkJzVBIifaoKvIVmWwjpzwzkN/tLQqg/VmdRc6M6+7mzbFXnYAIo9DDqt7Z41+LkoTA/jBC1kvB4vqZAbzpu5rG9rzgNvce/DQH1zDTqAQ/OWlQlA5aUKI05Q7HPa0Tu94pts5iqdD0axvGY7CaMYH8unnXiCPGX96jpnWKnSWJS589nu9XbtiSVrLhIQMuOjQH5SgIB+UaOySl7jC2LHf6+zKIokpO5a5RrmeZXpzNmUsLtVDglQWGGi75mAUtLXAQLvlPKOonQcGK542ZkFVnT6mh56qilHck4sOXZGYklHlSemAQQ6vM7Sg6xceagOxeNRKBwhyFtN4XrbbSaUtKQiwwgeCPzZBD1mZNytfx8ahM4QENJYPw6UxruVgv9fZtUtPf7z+FWV6Upmtr3/5ZGF9/csns3V/zSHYWVx4KBqXKWlX/1K2gJ5tzcqKZyRH6QHBDix+RUA1y2DVSOEUDgRKcRSHGjG4Sx4B026V12LP25nGgM8uGLR+A7tFFOZd7OqAHuIqwzWKgtQI2dAnbB3SqQzW7bLQL+/7P2qaWpVW2UGLfIoPOeUrZGmVIgQ87KPF19lzwZbFnhZg4aiSXAdrtgOg/BuF0VJYJgv1VayQ5cxJ0ZVJJ0nI3qp2HrCAQIWTnhAsxTlPakSbZsSK0UpBkIqkGaouEKQQyHnQYFXmOfq11flA8PZAMOKurLI8R1XnohaC6Wp+t9ztMmp/WbINAj4aiwWK7dzoAJSGkIDUx3ggAhg+y0MNxLfUgtQIwbbUZ5XG+imPKSXF8ehhjL8g1ULKmP50jTNvJCZT3KdEJ9GYPrfbxDCmnURjmhXIi28EekPchWfyxi3ecP+G89u/ae3qy41lm7w0YeQlro/Bfm+wQ34VdxKDKfrNy9W7oDjjP9n+1KYrq9nvYUZpbPTwrqlcZ/9CNQC8Uo/Qbp4BoYwbaERkW1VbXNRZqAFUJE6tmhJOCAW0u9usMa1cA6tfygp1FY1AZ9h0BlCOjUJjSW0+1ijgE+1An36dk+KZotqSs0pnfchSlOkhSzV2b3GFnYfqJBrTd7YdZOhIVzNAIQfA8O8Nr5QQ/+hBijIl/6w74Zkhpc3lfifshFFnzb2ndH868e/sLCWl4J05Fc+RE7xikvLymMly8soYcEpeSDpc2f6i2XCV9pdJhitZXzwX7gBh4lS4EnXiTLgSb9pEuDrcd8mDqwtgyjS4Ou40WXB1xImT4A6gv3MO3IEoJkuBq+ddPgOuxJs0Aa5Emzr/7RDw4ulvh0DTZb81EL9D8lspgv+f3LfaMNCpb5dlSm6zY2I14Yx0M1shBwCDav8SF7MqzgpcX0gSA2CbdG9RnnmlAYPPHyzxpk8frEROkT1YBl06ebBEmDB3sMT6PqmDFdjvmjnYyJ80cfAAHZc3+FQjjavltUbwINwXkrzuy6wYFYHggsCXVXVoDr8chedd4PgxUCTq9pDjqyiVHo6cs3kD20ItOuCw87IeVaA4Dzj6yyGu4oJmNp+vGht4AGvyvCMFte40GayMgUCSkiugk+Qk13EunZJcx7psRvIB0sUTkg9wLp+PXAZNlo5cQl04G7nSf4Jk5AOcKXKRK1EXTkWuZEyTiVxGTZGIXKJcOg+5GgBLQ542Gwmeq3hn3UoMeZjQYzKgy0F8pwToMhie//yBfQ2UhXVVqNIbkOCU6xIMnnEdWRGaa8H2F+0L8vAj0vgoAwOu7QDXYMBPdoCfzADpnH+Qv/J4f94+SiymRPsinfmmSLMkw/sLOkPJhyajkgo+OAvVTbPx9rzvDMkR5QYaMve4PMk4ZerxgSnNCTKPD5AmSTw+wJoi77iMmjDtuAI2XdZxed55uqTjupnJ6XKOGyYlJ0s5PsSdNuO4fjJygoTjA8DL5xuXQROlG5dB02UbH5zLvViycZlwyVzjavcLpxqXINNlGpdQ0ycaH5oUnjDP+CByijTjQ7CpsowP8SZNMj4EnTjHuAk7TYpxPfXiGcaHcVMkGB9eozFdfnHDupCp04sP4r9DdvEh9sTJxQexU+UWl4CI1OJsHUe3CLXJQmdXpgZcwOyxVDhvIEskmKdLEKniCYt/7ArQsBE+AvtLVxsBIhjKiwkma1NiqojcGij7e66ywdLtr1plg6Pb9GhUFjgqKGOziQvL3MxE3Yh/dyZSSPiBRTBbZQOmh+0ExIftDVe4ANjtmpsivS2r97hK7Uua2ggQwSbd279dZzGMZH99ZzGMBE3yPLgqEprnufl9O4vUrt2ye29ECwD1tJvX/q6KFgYqGztd5yVlw2LVW2xxqQoHA/O8FcJudEbSQ3jS2QdwlPLUA4nCZxFEcngxlGRXSkS5kdYutmXP2a5aUTiYmYecWs8YiXIQrVkMZFcgZQMQEZJJXrueCEERE5ihWcpUZmqiV6Rj7uNZbqB1u9VHDLwpHGBMfvIew5In73WM44hHcwbfX9haZdgIFIHd285pQZwxzw7z1Oy6wJzWyDmermf3Akh6E68Zr7GpIntKA6O/nmZGaJzl2CelcDAxD1lqc1UnncH/1ElyxzSkAy5QtkVPltOaOL1cmlhMT6qj9JfHYBB9ndlfsTAFihhYm8JTvL9oFUslQMjwybv3JYK1lN3TkOiXNwan92VzoGtCKdiZ0wAB0cd+OB2t0p4pAOZt0cLZnzUgwOBJ1maO9txqJa5bQqHPqju08AKaV3d2PlsSCxKUAJiUzB5Ok6RgnNRowlDqtlKJwb/qggoA6eaqkc+orwJAhClObZ5l0/woMNOyX2Q0i/Pu/ViUKeJByVoQkDvWzcyQDnVT2+7ffkY+nJMEYN9N8yAJfRUc8rWsUkRB7quAEJvqhtcBQEvyXNKMHQAxotrRmIBCYHti5/Lpc2YsJwSj2uPCrGA9KQSnmDrAVRdDDgB4e9pKudvnhKKepiAEoPghNNwVyloQkNtDJp2tbYaq9GBwt9XLjfP8MU5esVxBDsYKn2UwmOqbTIXQnmBoQhkPLzQhxdMgEUDVCZAaHOqbQFCBId2QBboDrFJDoE83cfLaVLmHiqDvpUINgjanZmV5RlGwswoAidgOKkT71/4eYnw8OHFTZBj/vgyA6Y+/4OpFUQmHdfsDVy/cmRhAXl8MQR6yFNlFOUkA9v3RENztE5VgWFi+Yylh+Q62FycDYATlDIAEsSnTuAL9dXXMfBx9W+lzPQpXwit5kJQAsZ+t0A1mnj79t4ok6A28mef6C2fe5v11HUN2UZkn6Y28W2czj7Zem3v9ZnN767EUqMHcnLJXRde64WI5JWgPvZmvT1VqjIT3wsXR5Wv1l7fBKTfoQp+V1RiP2tMU1yZscjE77uftV+9mHbifvWi7CoMocAN9Qk5FQFozQyTdVbjOyrnx536kT7grwyU9jLfw1mvnDlsoeTGMtHIMybIHMUxpYizdYOYv77Z3QejP5w4WJMihtM3ynEXbFtn3MHCbnNkzj1UAbrBYoN9gSQ/jzSNHTphuh1ZagaLovVurYI59NZUeEK5deepLIRSWydcGwXQG/3vPYXm4LVqentLA8JfWr3hfCqWEQfsXYx5sDa7vYeR2DV2XN34RzLC3UmVhoC6cOx/bM2o0Rt/ft04YOt+2M3/hLbuc0cs79JMbNsJHgG36VRYA6o0TuffbMPi63m5WKy/c3gQbdBEaNgJE4Hrz+XrbKJiFBVpwgDDbnvnSWXj2T1o2AZPbaCNW9VqDex4Arj/zlpF/67Ob9C3ybEqXaAGgNsVhzKXyBgCi1JLaVPPDRoAImkhHFi3JA8BlDR/7MDv1nGyvXG1kiCAIV/fOcjsLHX+59R68ZWTTFg24wNi3jj9nwbJSuvSw3SG1iYH8ZeOF39p+R+itvci+M6hxMsQQescm1Pq2qyyMVBbkwll/tr9mpYeBu/aWrAfmhWEQbl0n8u4CNFfpgeGugrnvjqK2DkZm8/keBuiqs6c0MW63N477uXnTN6FneWlKEzO5+fqw+bLntCZO5EQbbHvXigzOkRPeedgPzlZkcN6s7kJn5nWFxeKNlg2AxOMw0ereWWNLndLDwH3wQtZVRZI6lcG7acjaDtN5eHTuPaDbhmEjUAT+8kIRqIw0EcRpOiIXIB+E0YuPg8sR9Zg9Z+z02Oi9rNtpdnaaLMvao08NzEegcdGy+WMgARj57EfesUTdOPZzndsH1aezE9w+qC6JnTGZGO9mSCSWlEUSo59TT6X3ZmsguD3cOIgkB9PaNTvWtFYOpnUL4KxxnR7ME09kROJUJzRqaJtUn75Ph9qkuix+0m9HXtjJAcw81XHWzJODjlmRmEoHyZpBPZnRnVXd3b5m5aYiIE1tA6HbveSyGswaXpONxOpXaA9HgC+tshrKsno5lAYQ4rHFf8jKvNmCO7JI6fwg8YzEY2iK7SFA0MDuEI6Rxqi+APu5zq1d8f/jtT5xueB5EpmdTfmYlc76jMzdjwwJtpXG2hzbKenSvTdbEgva7MLMDBmqJYzSAkId2HEAZ2r2HqiJwtF9cJLi/D41YfCQLThLe9KWlnreuziCezbBkPUHYOGDMJ+CpY4HkHB3gGzMVN3+ckOfkDVHJzI4tyvlv7zv/6hpalNOZQMtsUmgztfz0vJpAHfYRkuvs+eC7ROIQMmuRaik1rGajVcY+0ZgdBS2DQBtFTsGOG9SdKXRSRKyt6mQBxwgTOE8WDhKcRqsmtAmfbVBtEoQoyJphqkBBCWEcR7wWZV5jn1bdTYQujUPTLgrqyzPMRW4KIVQurreLXe7jFpflOyCYI+lYnliuzaWr/SDxKM+kAfOHz6VR81Dt8uC0shAtstnkcb5KY8pJcUxAQLCXlBqGWVMf7pGeTcKkyfqa6FTaDyf282xCM9OofHMCtyVN7/X+6GuOpO3q/J++zeU3f5N61ZfbNrBZKWJIi9R3Qn2c4Mb7oO3Uxg8se9brt78yfnu46rmh83aM1kRmCELDfVPdshA00vWbHMzgjUuenbXHK+zf2GaGV6oJ2g3DEJIxk2DIrFtEfCXdNZp/CsSpzbtFaeD+rfbiG0prVrLotUHt3uYnRKIwqkMNMQ6iQvp95hvCrWBjviCG3dsfq/za3pUGMNGoHGkFt+4FPBle6BPv85J8UwxjfJZpHM+ZCnG85ClGre3uEJOwHYKjec72w43lDPD6K9QA1joKoEXSoR/sD//B+/hdLs=" \ No newline at end of file diff --git a/docs/assets/search.js b/docs/assets/search.js index 7e58730..179b5a8 100644 --- a/docs/assets/search.js +++ b/docs/assets/search.js @@ -1 +1 @@ -window.searchData = "eJzMvVlz5DiW7/lVrqVeu+Z2KjJj8vbTeLikSE1pSy2RXdl2TQaRcBc76ASTpGuJa/Pdx0CQ7lwAOnAWl790dUUJf/+dQ6wHwMH/+alQr+VP//Ff/+en70kW//Qfx//2UyZW8qf/+On/+Xstyyp++p9P+k9k8Y8oTWRW/fRvP62L9Kf/+Gml4nUqy//52PzdY//v/q/napX+9G8/RakoS1n+9B8//fT//Vv7Kz8f/7b5nT9e8y+iip7vlboQxVKeFoUqNr/SFHf+irW05bf/7adcFBrfbdgW79eft26IVFZWxTqqSKCO+nJBgHbZrlf//fiXDfeT/su75If88l7JkgK9ViyTH/KpUWSkX4m3L+QGrMQbtw2Dep1k8TeRrgPxN6UOoR73YdD1d+uRLebx5+Off/mlQ7rudDMgRiNARPfvv2w/aiFLiWJrBRjYSll9USqVIsMAlrJ62qgwUb5XEotoJHj45s8C1UZKWUVGgofvRKD9FwtG/53IKFmJ9Ofj37CUXSFW1uNfP9OwGiFW1s+/0KDWOkykav2UoutoK8LDeJYqgerKS1ktGg0ewq9SPYvyGcm43KjwUJ7jButSVgnXaF3K6kJlSyReaiT4+PCdT7pR4aG8WqcpEjEzEnx8ZKOOJt3DyNNhxleADjN3PaAahTrIjCORJqbpRzUvd19696wKbG9aNho8hPfJSpaVWOWXSVQozLpWs1at2qpVY6a+EhkddNaI8TA/rJMYSbo2Ejx830QR4RdKLxsVBspKPSRZ9dusKMQ7hrNSa60jGh0a0mGsyJS6k2WZqOyLUlVZFSIPj4ZO6BxCPGkXHjrCNOVHR7QxEusybLWy24pGcstflfE/kvIfSfYsi6SSMac9qSrlXMXUNmnZyMh+iF31P5Ga1Ch+iDWFLHOVlfKLisN6p51WtcpPRnkvbaiQVfEuQlf9HqZsZT/kK5WyeJHFraI2zOgW6qMt+0tlLJb9MLofY1klqnVJ3wEa3Y/sAQ3BpSxLsWQxbrWR/hD79NxaraubZ0E9IDfKeaP8MdYV71fyrTrN4lwlgWGz3QYW75l8q+RW/ENs1BaR2mUE92XLYC8yKe9VrlK1TCKR6kHgVv63jIi/XFJW2x/RI0LR/sjHWV2IrNQijDa3P/EhFg9XX++VvJUilmGLmU2pQ1hZ9WHQ66itR1wnTILPZQwI4WcwbGyDOpyrMqkSFbYXPgDsaLAwFnIlkiwJ3KEYQHZFiCi7MRT5lstID1kYRiMiM1g0aidjIUX8JVmeZ1VgVHrkShE/Jcuk0eEjfUiIUNfcrNg2XnPStvMhY72R++kYS7nYyHBy4r/6YiPDxHmeVT+H7UdZKJNGhI8R/8WTjPV7n2dV2FakHRG2A+lF+JBQfOo187d+SCg+9pr5az8kBJ97zfy9qwUe0WgQzSfHs/A/9WQ/eOJrSh3ILLwDQzELbzzinEWmMltWYbvbA8iNAhFft97l+rw0rKPpItYyuJ7GkzO8q7FywvsaT87glmzFBLflaUrwzuuAE73zupP0Vf+v0MVDF7UWwq4e/FhhywcbLG794EMLWUCMSTErCA9K4BJixIlcQ/iSEnx75CrCgxS0jBhxMvb4LSXBd2fs71tKVHffQjL19rU8foyvZTjH+A0nwSfnHOM3nPiPzjjGG0rAkmIMiVhTeDD+JQtFMAj9aGVoOAdrn7lZjARAmhIHsObpgGDXO/Mh3mCts5JVkURB37JLty1OQNatZ0+qKNTrH2tZBE13u2xG4u9GgoXvTmaBO1xjwLLVICasD5eBa11TmJpJZVngzuegLWTDXU0ol7W3mGuz4+AjoqPCB9OHDJloupOum5wePVkX+pjZLPr+kIkXkaT6vwW71q1yAD7eAYd19oQHqc7d7rSA6tQtwBbImdud9lCeuA23KfS87S5zqE7bhlsCOp26yxzSs6nhNsFOpu4yivZcKtSq0FOpflZRnUkFWAU6kbrTKtLzqFCrAKdR/QwjPIsabhv0JOou06jPoQIsg59C3Wkc/RnUcPsCT6Dusono/KmXHfSnT3dZx3j2FGgx7uSph70850796ml/zXG6LGRZBkcNOsUOYFUxpMEuI7pOcc211SpPZejpTgtoR4aDs5B18fOgSfQIs1GBXeW1U47OoVZJIbGUrQYP47qU5bdEvgbF/kaUWuWlUaHi7EaM/kuU71l0XslCVKr43yjWWipppJh4RbG6NxMSHGqxqjYyLJyvIqnmNM2+1uJo+/3YocgiGTQRGfdPrQQH30IkOLpGgIUtyZKwfB1julaCjy84OOiAlI0OB+myEFk1L2Sc4Bp4rRO1OhykSXkSuOYfQSZlPFzg0/GlopJ17k8Cb2qtOhMpq0fzdfl8K+vIB25o10LFVoibVY/yZLwvRoyDuZCpFKW8FJUsEpEmP2RcVxDkNKoWXXVEnxpRRhu0y8nYtct5metPS+33WnR/fq9/jsrvtdg+/F6dqeJW5mnYqU4rcbVQRdFK8fDqFQnBooaJT09Dz1Sh68BJIRLcpFaLLVSh60DciFGtaJ0xi9mTyPSIG74D7RQ5rHiGhY0wujFwH1MMwWYDVUTBbYGzzszrNU2z5sTUm7HQYdUdBx9h/bG4kqkOuWyhqkf+ljRRhktoFMhlSaO7wsaFpi1xtgpMQzjAus9S3VlrOEOl9tg5xcNudEhJnfWUoN8+3B6bs6/eRy/N1z/vo2fm65NDemOztrmVf6/1ZgawnltEDqamu9ho6rrNffS13WkDQX3fYYGtzjSphsLtaAoeTN3o8tDUh9Y1zjogwlJfWjhbDTLC4WtE9eFhrDNbES7KZ5HF5bP4Dgh39EC7OlysmVyqKhGVjPWGXkHReo62mlFPk9+Gv8oqvpAvkK0+hwU/yipOG0UufnMe8jxbKCS2EUqMEB1tb+/XRBmQoFsVHkrofm+/n0Du+O5iDL1j4+rK2AjlWw6K2fYQNyI8jKg91R4oya7qDtq8kFrrTBU3SqW3ZosGyd1oLlSRK5UWG00eC9orcRhi8LU6f0Lg4aQxJvJ80g5Wc+IRtsc6mHhpIdz+6g7W8nldxeo1O1NF5yYXdsRqRBeqMH/H26MB7hDaZ+PQq4TOmfjEWgZ4sdClcWgrHOprhk7fTd0VQ02+xhbofyCZhnndljwTSapeZBFcRXoFD6BejHmwlaHvGkcNEFUlV3kVNF5YUDsyPJzBNzRt/hxdysQS9uvieVZX31n0HRqetiscQO2cAMNWU4fXXPU1+p6p11TGSxnf6ShbFgVViylDutrlVpvZokq/311R22JU92gFJOo+aQAm6L6b3dpyr0T0HdpmN2UPp7X2kYja6dZHzliqefMHD9xRYqM1WURoPvqREQMfo55ittZXQMC/X/Jw6iplyH/gHWfssdvfnxViJRGdcD/E1xFeaGGCfphhf8DqevAGgS8naIfAhoraIvClXYm3+lzsXfIjPIWkjXol3uqzsWXyA55N0ps+PEuVlRmercqXNF8/pfoKCm1D3KgytkJUXNvZBPkYc5Wm2zvCaA+rNI2NmqjVuKjNl6xrB1GtWDRavMT32jXoFtiIVa3YPphPZFoJUvC4UWSiL+QyKStZ1IGj35VCV+5WsG6Rz0aQiV1PHUkqtxZirtkb1j+T6vlGf+BIhF6GnUR/TarnvKfLaAlNA9VK3K1zS0vSNLfIzO1ywM1QazpmfEDdYTNob7Y0N4BmnXUL1ormHpDoS/LyN9MaIvR4o8Y1WwzfkXOslqFbck7OyXgDcFPOKXJwUQjqbTm3+yj35XbZgNmY87KgX2cu5UoV7+YY6yzPdW8J3BWZVjqA2uMBiK1CO7zpqEc6ehEatPAxRgcxoLELmCVFcxCaxZxWfM82gfZMfOxB7Z3AbFmXTN9GC+/hu7j7rnqlc6/Uhd5RQ/VdY6UD67scgJR9l8WbTH2Xyxiqvsvfkly8pwrwhKmPNY32ni0i6Y1dJpH2xtM29Vu9Ppc6i+ofh05VHBIH0M6nyLAN3OU49w6xWhdh0f1J/I4gNztozJ6ERw3WHvTjOn7buAtUu3uFD6Rej5koanTfTVTn0ly80LNp3qTQVmeBxbY3J++grhaqUpFKw+tpt+Ah1NERD7p+9lzj9GB97fgi9KLFttQB+G4Ag3VcxyNUd0GHhOCLoFY2itMTQ0LUyQkfSvTNyiEx2bXKMHrQnUo3O+pCpQ857DblEBh3ldLOiTolMeoDoCckdrMF3/EasoEveHmyBd/usgKCr3bZ+8/+qHMrmy2J07dnsS4rwF6BQ+IAxqMpMuzg5HIc4d2NSXzMLY5A9uB587TfobNnD+ph3dZRhZMk0huyApAwyypwEPXaxYWv1TaX0dULNze8Vuwgnq4TN7Io9bmhDLDE3qV1gDXFikhdaUY+5ao/Dmuo3vMLsctWy8xDNKARdVT8YOrSmIqm+vSd5agxC+iBYyc7+rBxAH14/j4nNjyF3w7eYT0u12kVnGy6U+wg6m2fBl9ft05xfOn61gCsng5Yzf0DVP3cSRupdL3KAivmyKetBgchKO/biBGV6c2HUr3O1TrscbwxpHqNGhEOxvqw4lXg2eURZK2SAQ8u2yn72XIkzommPAeZLoP9vpStxNlfn9RPAEM7yqb0YfXeXSjCTrz1lCuTWJ6n73MRPUv91gi0XvbYa8lISxaNJCN/3L4FjcaOx69KM9GGZhqaJoamG/Kn7ryIQoHdeROFm7uUVR0mvIue5SromoIbvpRVHbgsW01aC9x9HsL/uuhh9XYbIsKurnaQc0eCYta6paacuU5zm7knZurV9bXWopiB+TBDpxJDXuyMYpoVO/ve0lLNwHfw4ubhHVyaufg0LXZGvsWlmpVP876INMHXhFaFkrN/X0e3C6Lmxci5UMWpiJ5vFXrgOlqoQorouVDYScMUL3xltgXFr86mCFPxHvgyrRVyI8PF2Xk1Dg3b1+IiLsLzwDpGAXDuV29S+Eqty4lfnE1SEjR68tZunV3P624QNr3elj2Y+fUAiWaC3fHRZJz1PIvlGwWz/u9JI0bJPFwV6KP9N7L4JtI1oO0PuWu5XBYvjRwjeQaaVA2BcRMqL06VXa3TFDhnHfGqLFunKXLi6sOdFzJK9OG+L0nowRYb+EbuKYEea/Elh68QhtD4JYIPbxmJsKvydthWhpP0ffWk0u2e+R1oejMCr1XjjWqJmuj42FG95wTcjQopJ3o2PoRETce9GGdFoc9RrGRW30inaXlLWQmtG7e6LO3QZgnJPGjDj4vyelF/SXSjIcN+quX2wa1UKgVgFW+D3mixEr9XBN2GxjVCnKzzZ0Ewd17KKjJCnKwnMkpWIn3I6iEMEH6yYMdGc73VZLVArQPT3bjBWylO3rNUCZpeetEocdJ+lepZlM80E9GlrJZGj2MmOiA/JxoLE/ax70JlSxLU1Ahxsx7/+vlPVdD0FanRezV6nOR3z6qgqRFlo8RKWxUJUa0oWylW3nohQcPbSvHznqkCsvnmhF6oArP5EkBOis3O/FAtfiObJa+rxW97mCM/rJP492QJOJ9rY14n8bMR42a+gAS/HcgpJgi+mzgpdXgOT5uUmdFhI20iiUQh5iaSyBJjdlHTNL8uOXcb1D/0JalWIg/OsOOAX6fpUy0Izqrjy27GXCJuI8bP3AwsNDWlHVm4a0m93VESedqIcXjauvt3q15hzm4KHsy+X5eHZtOvdc30mTokaavBxVioV+CI0cMs1CtyqLCQoqPdPURUqHs3HUmcewhMGuT2tAHf3InC27t5MbHtITBFYNuDGBrVHuFiQ9oerKB49ggUFczeTQmLZA8pcWHs3ZToGPYQmCyA7cEOjF6PkJGh692kwLj1EBQZtN7NiYpYD2lJwtW7mUGx6iErKlC9mxEWpR5C4kLUfpTg+LQNFh2c3s0MjEwPaZFhaQ9OYEx6BIoMSHuQAqPRI1JkKNqXFBLNtbNiQrm7aeFx3CEtPojrQQuO4I5o0eFbP1pQ7NYGiwrc7mCFRm17nNiQ7Q5GtUDy1QJkbMPoj0rlZVKudPghPIfNsPAhRIGsTOhI0MhNZPmOHLxkCY68yXUSx3noNXIHvdYaXSTntqD+Jzx8I7M/7kJWxXvoQ1kO+K7W/iwwGUr1nxOYYMQK9SE2/KUyOht+GLE92lCnjCJqxEZs763Y/OylLEuxpDNjtdHbnyWVfuMibDVlN2EjxDduNa8e3DyHXv5zEBu5/Hl4B5Dd58X7lXyrTrM4V0lgKMNhSvGeybdKbhX3Z41mx1tgVFipB7tNSXmvcpWqZRKJVJczuegIbEnKaqusx4iiVd6zfYXISl2S2rpWd3+2DVYEdzKLgS9KDooewGrARoRdCwwdRLUSsLNSrQN8qSEvXdrJMe9bTtCOa2tYBjRT4kDqJlGms8YLzv6q++ovJPlPF7WrhUn9s4t5JasiiYI2TrqY2+LkZPUT0OUz3pUbIVo/9jLb6f35eXCilN4H1xLwJCk7+IKGzR4WaEK8i+YqLOzZB8pgoc5pJnNgAfcJjQbXN2zOKCARjQgb43slkYDvleSii0QWyTQwQVBv/KgFgIlCdrA9iwLnOa3A5rnQt2t6YNB3a3YwqdUqAfdrm9LkVMGv2w9mKNBX7ae5YoFtmVqBq341529+Pv4NybjRYSY9/vUzCenxr5+ZST//QgL6+RdmThLKvTDeyzdw++5xVkaImrU+GIZ0Z63B5c36RNinYxxiI8LKSEDIx7cug8459MlMYQ6mWRZ/lRV2zVZLiSzWx1zYVm3NWT/cV25EuL5zkqFbSi3ByIemY2PLX5CDn1bgomvOGuIAGxFOxhk+tqFlWOMb+gfwiFx0eSG1yJkqzDPQwbk/e9EsI7ZQRa5UikgAOs0cnPezywhO9znNVJ93xX3mWoLrO5tjrkjAWoONMPiYbI8NfDh2mqoKPZXThapGx3BomJKVLCuxynGfcyPD9UX12U8colbgonsVSXWmillnMwOKqaUWqhB9KWpevdcH3l7alMZTWfff6q26e3PmA7RrbJE4mB06OxnNlt3YcY59WZodPIcptFt63jaZY03k1hjZfdph/uYSsFfpMsH8ywqxfTlJP27DoFZ7SO2UtmXSn+ygeAfZSjb+lpiO+AB7YJau16ddP60XC1nIeLbSF/KD83rY2VtRUYuC83v42gCqqxaPUx5DYu1TGTpTz150kBU7CHxQ9hBang0J3fKGPnKesSkDc4rbcaE5xKc4e+dF4vhWRupFN2g8rYjjoqPGRq1PlyayPCvUCg/diC2MGBvzUlbXxSwm8PJSVqoQMauHw2NUVlR4tMqTsyrWWSTC0n/YUTtKbLR1SqwZgV9rIeD5MTfrYDy41+GfL/VoH0LcKXYAo8CQBjsAdJ3iGPnr/8AgNgJEbPg3HS0+TKHvOXoxQl4+GUFiHj1xUI7683khQYd/RrB1nx7VavCAoQe1vggUeEpvXD3lWwU8qOdBGDzyjD88dNDxoVNp+iSi71gftjp8fiw3uZzuQt/lGdGWmzRO4Md5/IibfEM37SNLWOrmBEHe0WMhT5NI3qpXXK9aqxRGhWpcsozofwYH4jvFDmVE79CQjOiNU5xxXsDrtyNO1Mu3dkrMS40jPkxftJMsvGkM0OCtYvxt+63iIWsWrfqPb2We6vSY7fQ4OAa5W+0A2pAnJLZpeXiWKurnbRFVHDDMtkGNy5eFiGV43eqUO4RaNMRB15euX8hqxpgSun3hxQfJQmNhtCagIeQMzTUzRhylmSGkA2WUGSPak8kQcsLyxoxBHSljyElDs8O4SEeJYShJQTlgLKT29C/kpIBMLy5YW5IXQl5ojpQxrjM9CiUtPBOKBXgiCQohc2C+kzHnMNUJjo0+q8mY2CehCbUVuNwlVht2pC1B1pH+/M+sSG7Va/AMsF/yAOaAFiDsLHDgHap5oJUUOhP0ZKxDuaGBA7tLtRI0eOBHC3lhw8aKeWLDjxQUj7GhokIyTtbjXz93W7t5rELv2G1wk6ySxUJE08TbgtiG3vPe5pmOEsdz1BMKdl/HLQ5Q84IQEnIjQgM4+LZfmtwW9dXybsDaD3ZYnPQ710vImILoaCMV7MSRgxywcjjLw+BiZnm+wPoKZfksvoc2ajtxV40NOcmWhSzLE5lWYngM4DTTXSFNbWl+J9a/Yy62xJvfkZvf4TbTeZyNwLShVZzmrEQmlrI8b6yqz0bXI05oz2i3qJFvDavlZSvPZ5RZ6BKZsBWjBJ5I04LuSzlRY5kXiSqSKvkhR4tnDHlXmKmLHT/mUlfLs0Ks5OieBcaW+o2XWnuhtTGXLcLNuRxkhqMxBJEwzteEvPtmCYY6Bz5a4guq+zES0EaID/R7kje1Yl4XJq3lWr2pHeZPuer5YFZsys7TdVnJ4jqvAIsNqwb9/Ph+dEwfi2c6ePuZfeBeo92fzqBD/YE4DDPKH2faQiSp3nJ9KFJCs1rVtVHdr0l5oSoVKUp7upL7Nabc3jWhrHda9uMqXSlLHeP4opR+bEvklHbVyk8d5fBOOcSU7u4Emn64U7GPb/Eqn+5U9F1WZ0JHl4PXdG5rXuVTWSsvNsrk38I+Sp7WwzNqkOxJHNwYOabjGyL7znSOkKu8MG2PzqKeJrTqhOJfyBcJbNLTNqSNMLMhFDMVqyFsExUvw/DzFItRTNMUL4OaNcSVXKoqEfpP6T9a8xvZ9jc+7PutxNsX/dBE70g31r6VeHvSogXwLGuIAchZpYWeY1LpZUoha52TdX3QchZ9JzOpUY6NsqiV92oawXTZYhbXbNnTJIrJstWqibnyHgwbPIWFNcfyINYejADP/y0W0E7/vfBpZv8WWyYn/3sw7Ef3VCbWGKo3+3YYYF/M3A3aP2pZ4xAjXeCIdfUssyqJBGT7fjfn0egHoAO/y7WuOaisomcGe1rdPZmhjxsnkZxFUe9CMJ09zQ+ICHxZGGhYsswEsDueNqgV3pMh4EFl0grgOVhvE/y6L/MGNY15Rou08+JoHB1MvrbR+HXyWD2DOa3ufswgaxhdG2jbxcgAR7PI06Qy25O4AX2kQxysrAOvpGxHW1Gw08fecx2Cq+datPwbzX3h32Eiq9NWlJYAK252629cE56iNWsryv912mNxHJ9nc3rs476PTtlOa1SjuH9Tyv6ToCTGbDT3Yo59GHnIkkUiY4KBxKZ0OEOJk45gMLH6kGE4cduAHlDCTUD1Wbssoe+0QgxEDStu0/ADC8AIps/ENriEmAgfXtyGEQ8wIeZghhi3QeSDzA6T7MPMn22YGDXEDFUO7liFFZDvZMXIq6yb+g7j+Pb1fc3Db+3bTWPa3fc1i3yD326l7x4/j5HIXXG7SRwb474GEe2N2w2b3h7nMYhgR9xuDNemuL9hFPviLtvIjpH6mgMObdotoN1L9jWCZjvZbhHhcdIJcwbzn7mInuWtzn46TEnjZ9GoPOmcZ5GKZWibthMdtVLoGjP2mE+2LBQ7NGuWN2ou3lMl4guZLavQDWAHcyOZtpL783udy/dSlKEjm8OQWm5l5Pi+QJ1VYg7YknNQ13rjLThu37/IArCcd9iwFWM1YNgj1oVM/54E3zYelCbeutVL5btUVdqS4gVy/MTG16zBy1RVRU84vLoPfEd4MdoKDr4X7QtaZ1f/Yy2Ld1igyk5dq/6tVW0BKg4T7iCxmwl4S9SGFrusRBHcFVrrdiNECWrrMGD3s3tlPz6TyxgHkcal75cJzPAL4g5O4AVxL1DdXKHDQQ90K8QD2nTmeNCtEBmoteXoV7hhYddRedpjntHf66QAh1ztcEeNKiqGNfabK+GQWpfyu5T6Qv+5pn4RKZUhXe2k0WY2J4lT6s+hJffzLVbi7SJZ1D9GBb8Sb2kjyQxfT1b0312KNxr2WlHvZq0EKOsfAD2Bzd4m0BPEzM0D3fTApG43kvvweweeyvEdeHrPD8emOnvm4CkzT/pOUdosjLbHeYBMR9jXeXoOcgAvXW/zQKEpHufxAc+6iUKhsNAkoV6A6zR4T8pC2KiwIPZeM4USQh8z9QGs3nM8YCPCAghKZjpGhKczdUCOuso2vVP9/vp5tlDBzCMF2sVw92EAHNIR9HEAm5ecEWxRAjpLB/BGjRP5VZTzVAoy6FdRRo0eMfag8jYP3kFyLXeL0gZ6AQP8COYIPLL3XOLazc1q+dAA05iyI0QFOvjC5jZqHcSFhRnGAtTXSdW8kHESGu90cOnroypqBYOdanGXA/wpyWLIlpWLW+uBb42FYd+Y3UlC8HyjyIxOVHuPWi1G3CRLqkSkpJW70dxH/a53fsHJoF0G1Kq4NND+JlTAaJkLHhUqm8S2dtrmjbx63/b0RQI7mqEI7cyy96QIGgv+uojDYbSp86fIMdmdw+DzQr4kal0CE1VPGdFK78+Y5sjkOWwsslvRaCaI0cgX3zztBljyTfEb0cSI0htg72rUWifFxswQ+xKk3QwolZCTypZKKMy5fWfRpahxI0NfD9wBbK0LzVkPTF3oS5DWhac6HKOD2HfhETYn3ZGR1cF1aMzN6jznuJlFMj0pRAK+NeA2xYjHWpxgwuJnEH6ua7OEaLbrZ4LKOp0mmQkqK2rVolFlNaHemqOvULXs3qqSfiS6jmCR8XcVWdG30wH6j7CdFXB+CeuA8C2Rr/W6BWTMpjRxTHuV67MLwYFiG9RRTw3o062XWOa6A2KCSa4NuHcKUbyKpJqTOrqW5PJ2/+RnPQqSVI5WiQt1WYisQgydA95aDjtm7oROyhPoBHfAm5QxanZrRR30Y5net4q/QAKInaLEPRggdjyEOYJHjLsumb4Yg4bERIftmMPv+yYjXR1ht7gGpT/8DpeNh+4G19BXdPe3rNzQ21uemCqXhbl9HX6cwcq7EYQebfAEx1w6s4KTXznzNAQ8rbEZgZvWeAKr13K2WMioCt7AtTOr11Js9biwwXfkrNC0N+Q8TYDdj7PyE96Oc8MPRpmzJuPErKrkKg/9DIPSpKMMcEPDhoTayRh6yIVbP/9NwdoIEYIOv3kRfhayLvPhs4gtBd3cwXiDdLbYwURME33AQEPtGI9ugJ2EBnf3HWLaTn4SF9a1d1gJO/QhqK1J/y5F+JXQTsnDaN4dFksjD3FX4w+uFtQFdbcjOmBc6+nSOtoQHSqi5XQ5be0HBTloNV/N3QPISdFuUdq9wKQKbTYjlqNGJNhrPYe4z2qYmxpfKEg3asTIgy/9u8ji8ll8l5eyErGoRCD4qDxx2CyrZFbVcaHwq9N2tqNGVG5Fg3079prrGtMm99hppv+f0AmTw4Rt1jG5keUzon3vTe/Bf3mvgq9pOIxYNS++6T34p0aWz4g2qZ6M5+D3LR2GbKWRz1x6G/P3a/4NNJI4LPj7NUcMKd7YZlvzVgWf+HdgG71Cwc7+B2L/Fb4DMokNPeUzhT3o3M9NPkkTVocd8bFJUHfxi6RYyfhSvN29r55UGhxcdDIebcRX4q2sxWHBRqsjnTdXF7KYq9UqeIPPbUetGbWavPjQQ+AT9KgT4CHwS1UkaRo6h3GTb/WosR0tVYe4IMe/R+VpI46AEJ6dCB7EG3vINVsBZSdy4G7F+IALWeYqCz5a7yDuqPEhm+wEp3TVwgjyV45KFqsk/GE5B3VHjRE5WcmyEqs8+HyZi7oVhB0qmwK392qwfGH9wsTpjr5n6jWV8VLGd3oTMouAra+XRaorW25loR7ekfwqlmnyIgu9nmnvAF+pKlk0T2kSuPto8xPR5ieywU9wG1dXMza76g5n3ybdFMpkk+eyKm9+YF+GFSrPWauh+YH9V0LzuzxV0GjvtQKan2SrfkZ+z5UvFWWFmIf0LNBa2CnIDtyVXKni3dw9uBRvkGiajdzImssHOqwGjaaFG/FQypjDinUpY2Yzcpnp+G/zPMS9/j8UVjSyTYi2amV5jTAfg+pDNKLmS+znKxgD6s0pcgsWrSq3CWblRf4VjOy+voP5NQYDOOHXT2lSPpPO5jea/FN5U01nneVD3RAIjTG/0F2gmEaxJ9NuWl/y2LX5VHsyqlKVSGfRd4pGUmsJo8WJW/dKm+9ABV53SnlHld0EM1AQW1BsRdkNuANHdx3wpYQdVwkA3z6hRVblt1vY3DW/Xh6QcctWjRO5PbFKRr3oCLKC1x0weTdj+vV99TPGCOqOxtiwp57GmEDZ1Rj8PfQ1l53V5SzPt0/ekTWG7jpT1L9QbX9hX6Z9EdH3+qzIupB3lQhPcetl3lPnV8r2VzhNvBKEY0S2h9Hhts0X0NwOIIPfJCIQW+G9GEI73m3M2MvAtzGivFtHkZQxXf+7MaQsO9JMxuib70m2HPdlFN+lER93Y7Rfx76Z14aPEacUehKkG3uAe6xOJvBlVqujOA4rWJjx5xW8sHFHFizcBKcWvMBLVOjBAk4QbPACR58CsLDTHARw4du7jzbtUqQKYEvtKtBezMHHpkZsR1QRqZ7fSO/kOdERN/SmoadrxkIW+A/QiBxq/ejikVeR1oEM17umzEDf9NppxlS9uatUgfootQC2vvQzKm2j7ffPhVovUd7e8nXPGVUbYZy7jftchtSTSyr4Vowd2JwmBye1njbAnCYnOdnsYRDkFVkXP/ghWX9c7CjUoSUZgXbCbnob4OTKil10RZkNYKrrWnq/Nb2QAvhigcsGrUg1ldmBnqcikkyfolHn/xqOQRaz4GtL019KgOUAs2GZmwmrjR7cq42nWFagfWSSSeMO3EpUa3Cv2INtlbhQK8QJrj4q+tCWBdXervRj5beyXKfAUNq2PNcB+RU4zDdgOxqLQt3b8drUgSBz+JQGvi/IB47rHobUBB2EFdlVmRG55K0a1JUamqnZDadrNSpHs913rhwYIvq+LNQ6i+the5bFZ6p4FdBQmjU3/uYnSv0TIosXm5/gNS7SyQAe8rnIv4r8MslO9WtpdYv7M8li9Ur51erfWueRyJciXyWZ3PzWa/1be/iW2+sMF0lZyUznEn9Sb3ORiyipgNNGq62bH0qbH0r0D0XbH+I1dJsD459S5kJfUqH8lNuDRN9b+T18vXq2yPTBau39fqP2SQdTVS7D3xCdMKd92MFor4BvigaZg81YMmHNiiJrSZgxo03kEyniNMlIm9F4NzlufmUPrYnuWswu01b42zG+L4xsD03SmaKybXe3BxMQN6vs9NjrVb7g7aYkJXu+1eTGx4VbXE/ToLfYffHvsOkB7BbQJAnwNKLIn0WnCV8m5Up3+5fiTf+jLkvZ+5qf27btVfNzK/EWNz+3h27YULAtLYz8vpcV0CeGJgxBvTEUCI/c3LTja9GyESU3wBEcGITFT2QanGxwSoo4xXZVJOApiBPvaKsL9rrVje7gbRGeq3+3GbUuKpPWLjPsdai+o4sJl3YESOtL/R8kREeNFNSxXRftCDHeFyIrRQSPkY7YW+Wqp0xriqNm6F/MVVGhcs0MVdiSzhBcg7SyHnHc8Bz5dq8Jaex2MmamCTaXKj/IDktJU4V4G8mQ28VhJ1eSl0BTmb8mQ9oXXwPpgix224jDLBCzkNlIdttFkZbE1zCq1B52q0hzfIBMwiT78LAJnfXD2yjC5AYOu6jzG/iaRnXt3m4W6f37cJPgt2N3mYO8JRtkCvKq+IQpFHfGw0whuHc9ZQ/VBWyAUSx1jeRKdpAx1HezJ4xjuaQNNpbqtranwaTXtoOMJroOPWEn5b1omGmYC9I+hqFvSsPMwl6Z9jGN5O50kHl39WMJiLQCE2aZhxiwWQZ8zaG+E243jOVy+ISJg3jchcqWx79+hrwc1S1KGnl7VUWwj0cwR61KsA97LnE67kap9C5V1a3UtRJyctWiQHqfTWWzF5GkOipLRHakMtGRDPaszWfOyxmpFMEb6k7wrRwndKX7ibqTouKudJ/QKlKj9yv0H2tZvJ/CL2mMytNe5gW8wmgnontwdewxujQXDnZojgtv1BXhtz9C3M7xBsZcj3Zgk78l620M9Ll2hyGoB9u9oUFXjhzE8EtH3rjg10cdyLTv+HqbAXuZ1GED4fu+UwbYRpxbU0chdjRFSceZpySLwZWjC3SklcBP0/ZcM4EKu3prh0VcuvXFBfUSI1B4/7ADsT1VX8g4+DG4MWp7jr5VY0H+W//NGXRy1OOtpcAPVfvAooa3Hit+ZNuBWv4d+ubUGNJoUOENOs9tdAyQ2K1fmPbYjIkS4oGOtkrBLhw4x3mBbR28wrSBtjpMmDKLc5VQfOOjjhQXbJ6UKg6/6WKlNVqwg9R+uICVmY0Uuizzg8wL+ZKodXlKVxFaSf4KAU+EZ+NGpsBzIrs6V9jd6GFx0g62mVboHS61WMC9arvz92REkc7dfdkPuFllZ1+JN8zWVAA2rc/1mas9+Rt8BcZJjrvlEoBe78ffyv82hwzJ+Ou9+KIry2aEylBTtOGNvBfg+QFf3Fwlpcoo7vPbTTD6ZHf4J8wadeqlWheR1FFx2LFziwLt3Bm4X+PiOkLt1tj85ZpJF1LoXUwq7o4eJ3a9JxQ+/3NAb9Q4kVfiLVmtV1TMWzlW6CQjhd7IcULXJw6okFsxTuDmJAEV8laOGHrcLa/TalYU4h1yCmFYnLRDjpOVzErQvMNCddSTg3i17yjXzoD+H2mAN1KUsNbvX6f/gG08jwVodwTqxCSwU+IOtCOTkQRxLtziNBe+ikNT3TipjRQjLGSX30VLts3vjw+KJtnp4RElX1jMxrmLmnzn3N8c6N6CyxTUDoM/Nng32sVNux3tbwhsP9plBeGG9KQJ1rFortL1CmaJKfrRd9VHLONb6ihnNg5yh6ujRH+9Lwkgwjck36g9JdD43m5kHYnAO7lVYUGs3nM8YSOyl2qAmIZ2kbFT0BHkRJO/i57lKjTJyVjgkJp/hwicqsLiIp5K2oUdVVUKWOu3P81izMJjW5z2wGuS6csUiGXHAOyoVsQvOjruIp/Fj5Bp5/C70cEz+CE5bv6+GxQ/ex8SM83dd5uCm7kPzSCYt+9GRs7ah8wcc3YPI+rbaOqVpqmae2dGjc3vmEXGEJh8iWHFH4w4TabFLFKxhJ0tsCiQjjtLVSRpGjoJclEdbeXQXrb5zjWfx73p4rSG4jWXaTPs9QWQmbNTknaPMs/TRMY3Kk0ioF83TEeNWN6KQZ1Z+8edpl4uFbQObFk7OhyYsaz0VeR4Fv7awQi11RLANw48cBeFWp2VoT3xiFTLLEpQ1jkPyOb+EXAWPYJt5DBTaA/ov9eiEFmVZDK+EcFzuxF0Ry4XsIcePaDNzWzYMmqEbMQQl8e8ge/q60Y6uwwNs7m+9GT0OLDrmeJVeEhgxFsLQQMCPqCKoHeoFGnf4B5b2/zdc5VV8i10Uu8WOsSR14ZIOhAPnck64FmtIRz/PI1BD4dWO2hGR08TyAZLqym0Y6enSQS9pdUYqs7T1wxcX2o3gaBrdeNbe1rYmcteWdr+dF2ps3RdPkNyNY6xjrTeQuuBszP2/eRaz+j3gENjZRbcjQ4fZvhpNAcn8ByaH2iTBIrCpR0pHthYv1FcyBgQtrPwtmrQsJ0Xslws9CH7Fzkjbm8b4T01vCSDPFFjAd8K8YA2aVWJ3Nyo8fq2TTRLUq3btLKctdrkitSVjsLHJj3kRo0bWecg1ZkKKcEXW01GfF1BbtrsukT4+g/zjiYz/l0llqTsZSvICN55e6Kcq9UqqUgGzFq78/pEGXW0ycyxzkYxuy8sr922oyOaaDsRhTtxx/4KwdTZTowcZXyxzzXoi0iBkQc7e9KIYmIOvgaAB0o7Om6o3AWtb5KAH1e0YWtB3LuK/uAmnykdtwkUc2LXC7u6lkCfz7aw16J1XUG9nu1lwPYBYkIDtvWF3wBZb+7imTc6PJipgi3Pe4xGhAdwJd509A50yMmCuhJvWthywokSujOdCb4/aIEeypFBW2dFzSOBIOymLO2siOgRrjEh7dNbfe9NZZBAvHJtMWJF8bq1HzwiettH3gjxgJI872LhpnvUxWVGL5dwPeDiqVsZHsjGJ/XLfwRVo5GrWrk9QEOeNd1FHjeaPPg6ZxOVw7UWs7e3uESu3jLvy881+J9J9VxHniJIrvfddrwm1XPe0+c2i9GifRmjF1RnSq/ONkM53gwtulCF6IuyGtCsjsnY440eHfZwuqiXsefZQkEPfQ3Kk04bI5GLpyRNqvBHoe1gRwNFgFuH/nKu3tdlJYvg2xAubiMHuw7hD61W9fNHicrmKpYREftWNWpU92LChXyR4Su3XSakjSqfCTJXUfgy2crdSvHBQi6JOWDJbol5wwOuiTnYoffEvFEzFUuqzkRrMfckmHttDmryi23exhQKMKZbbWiU+PxuwtJ/ijS9EpkqdVK94GztDnYj/SrSNOtJ8xkDvp7nsID2fp63GbDbbg4bCK+7eRvwQ2VkfY/Wou97hrPa+n4XJA1ZpyRttt9g9w1AjoBO63jC1coAJ+WHcI0GEd7gaz7ky0LEsj6reiIrkQS/AmpRIF6nhGegdzHB09Db/DS1tzgP39xyQ6f1iADb5QoAB8zYnMzQOVsAbiGr4h0QEnAydwU5wc1gfxs+8XGSG0XoBCgY/S+VEaP/MIqs6PW9M8p2aRT30DDND8GCSDvoETcIAwxojhPcPIc/V+nkbzTzZ+CblSH4xfuVfKuAb0u4LSjeM/lWYd6XCDBCIxOBGyli2OHEZJ3EkEnmphzpJOQ5WYautPsgR41CuNM2fnCeUHnFkRkBErDBN/xTPt2p6Lus5piXQhwqtJNMPcWCnqSaAjSTN9RxKpcXp+/q8Bhj/m2f5ugz8+pFFg9F8Mpk0pRWd210uc3IC1WpSNHa0BXlNqDeE+WoU/WO6B4rVPg4OIkPHAt3Q7v60ovke+iY2CtL2m8+JTrFz314SsMx0pHRguY07PvHhbteLGQh49kKEPi0ITd6YgV9xNQLu23neOCOEg9qIUX8rlOGEFSIWqtstMhwe2cLRBzXDw9dJGUlMxmaQMoCLeK4fn4o3SryoEMOv1l4wYffvCDz8OvNtloLvNvshVjIlXqRxLXAiO6nIugBFE/cqPAh6qNUc5HW7+fR4OoTVNFWkQe9ksUqyUj6s64UHexgmqA3qQpQdvZuUdJJwuPj3636eZavg8fdIddRLfha/3PSCIb7s+soZyaBMlnqD1bPfOs3NdHsW83NO53/+JnLAEBEf0wMDeX7AGLy4I9J0XnwfZAhefDHqOA8+D6I6yz4afYxYSNCBXj87//r//751+NuX/UlyeI7WVWdAVfP/ic5t2WwXZQDqLeW8cLRJThgzN/O6z89rbNhDONnu/GcGtzA5xkBcV+EGxnHyg55JqvOyUVfuLoUH1RzBnq2rp5lVg1PyftCWlXYob8oVZVVIfK5yhbJEko9kGHANn1pWMe0LcMCVMesEpWdiajq5g324BoUZcA7kVGyEml/rrmTrFuKA0qt9X1t/dhfKNigJAOcGRrm24PY/nSjomx4w/1vTzSiuxMWrK9SPQudKCPoc3ZLMUA1Y+ZNoer//Gd3BbCTzVKYBTGpEpE2XcFl90SGB+GwLAtg9fmXEKbq8y8cGPnLL4GVa1OEAedCZcvjXz8HEnVL8UH9qYo4YObWLcUEBenp++UYwK7kUlWJDj4gevwJERbkspLxxjMhmL2CbGhX69WTLEBwnaIMeH+sZfGuP04qw2blg4IMaLeyOUlQh93DxqhxWRbAzYOY3xL5+rvI4jQkTGEvzwZ6q171z4zC656cg+JsmP3zXJ5wJIe5LEidJM3z4fMnO9EshXkRBxntQwBNUTa8C7VchjSNbik2qGZtHrw8tZVmgLwXxVIGTAzM33OAtHsQD92Q8W6ebjEGrO5J0bDRYViSD+7edqbZl69bmANxncSBc89NEQac7pZA2OcclmSDu+2covWkulWvbDiDV3Y9iWie1v3U4fnz5nF+ffFwefV4/6+b0w3PiyiSOomJm6lfknQ/+cv51ez2X0iY5rhZ+A7XQOvxsT6x5kK9vr44nV2hWZVKpQDkiAmD/dc99hMfwd51CsKc/z67xWJGzwJwICcI82SG92YMOiwShnk6P7+cXfx8/Bsa1gTQjdIekI9//UyEbJT2gNyJKuKIayFW4OuHLxf4+ltvXewF9XF2eztDDwoG+FE00RxO7LOL69k9lneRKgE4pREE+vX0+vfZ3e9Y1KXZ92CGPb9C+xR0FS8M8uYbuiNI8hfuPuDi+uorFjNVkKO7wZg0rV/D7qXta2SC4Ss12xjMsHe/X9+iG1X5rAruZnX3r8sv1xdo0jptBjPq/fnl6d397PIGS1t1DqbuBfjxanZ1fUeG/VjnO2KGf3g4P8ESr9eQJDFBmN9mtxRrmxdR8CxvRoGBy5vb07u78+urx/n1yek8GHxQnjRIcDv7k4TnqBCA69FWuemv/9fdfXgltRL/KCtoVd2JPKgDzbrm8XL2n49381nwimFUnrQO9FakeCzsCnesOl0jeqtTOnzojAGIH7zmnaYHrnw94EdV+2z2cHH/ePpVN4nHLw9nZ6e3jzfX1xePd+d/hVf0STXimO3g186vzu/PZxeP89vTk/PQqdykFi/33entt9Pbx/Ors+tHPQG5frh/vAyde3hpUtvxcDurYxLzfz7+efrl7nr+z9P7x5vb6/vrefAMdVqMmLzx0nx2M/tyfnF+H7qmGpUn7dA7AxQJVzf3MKhbGctO94l/PJze/uvx7GL2NbQaOwz4W5/VeRxn9+Uy4K/rq9Cuz0EOS/nlh2yv1Jend3ezr0D8pjBtdZ7Nfz99vD29Ow3tlC1QR5GInuVjIUsJW2gPNHdswsyu5qehPZmdOosgOb+DgSFjnxW4kDHkZlwo8Ol/ns4fT8DNrccs32T0GCMbnB+26eFOb2+vQxeyNnDTvenUhrD1LAD99vSPh9M7ippi4AtZF+PHvz290/OcL7P7eWiw3kZf1KfvHuuns/YGf3oVugaeQAdlNAgF78wnCchNNszHJFsoFnT7wHgzuwfWGF2SegZ6Nb8+Ob/6+vj1+vb84mIWCjYozkX3cNVORk+Dq6xNg5hTTzH10uf0Vkd1LoNHvlF5Hr6L+9mjCds/npzP78+vASd7pqRYqDvrsZvri9BJkFWDgxPWfrpFOagAYc5NOWKe309nJ6e3kGhOpyQx0/kVuEvuFuWiur02/88/z4MHaqsG6XJqNv/n1fWfF6cnX4O7ZDfcEe6NM6f2jjMt2/6BwaTm6bPHDzHt5uHLxfnd75T2bJ4d5TRi1CSaOOj11dXp/P7x8voktBexSdA2iLt/XYXuyjmhjkT5ngHeF3NpTteS67MzMnC1WOwLm9ThzP4eVOjL2dfzUPa6DPFYo3do6tM8jyfnl6dX9WbkxenV1+Dx0C3ETxwa3bVJMFDWsYHH2+s/7x4fbm5Obx+/XD8ED+RuIQbi+enFxd1j/QuAuIZFgYPRHJq4ml2ewmvqWISN1HjjfhZ+wNeqwcBZhw/bKAbcpzYZBtrzk9Or+/Ozc13F/nV/Cmn7QwkGyrqxYj58X4CBcLR6hyzN3EIMxLUnkA1/pMHAqRfP+gjEJtoE9axdiJh4u0lq9sbgoaEJJWLm29N2snX67fTqHrJAtknQLgfu708vb+4fz2bnF8HLLyfdkagqucqrR/2iAnAJZhXfsZ9nChDa0aSV2J8JvSDg6e3d+d29Lqk/z8NtaOt029VZ7T/m+oVLnY/afK51AdsRRBr7cDX7Nju/AIxDflauM/EikhT2lBvMPNOmHq+/nYZue7pNMg3qUb9Ssi8zbm7PL/V4yfGF8iJZieL9I77Opghhd1HIvXcYmyLnwRdzPOwAJdn3NmQ0Xurh+HJ290/46G7VIB7X706vdKTfrCTms/vTr9fBnFYN0pH9ZHY/e7y4vgtderjJ9NVj8ZiqEnaszC68Y4TYTte/zkIvxkxZktT5RnXXsxSwezIQc86v7k9vr2YXoLMwE+bU+bwzkSJOxUDMubq+f/zz9hyyeJwwJlPVo07kDx4MIKbczG7vTqk/Sy6KUu75m7RngB+/nV9fzO7DT8VO2dO8FPT4kqi0Teu8H7Pu5r+fXs4eL8/vLgExvwmbyjo/yuMqKVfgU00gg07nD7fn97BjcVP2yGhdJBXmgBzEnIcrvSlJWNfWmd6H3F8F0/0YeQdQv0fC/yWm5ic31xfnc9TsxCjQRh2+zK70MdbQmbeL60g8iUyfZYVNu22yu2bd97fn2GGva4B+ZzyhGfHCDHi8vv89eH2624xHVT0Dl6jhxtyf3l6eX81CT105rWgeZYKdQPfCH7XY+sDo7TWgQm1KkrbQZtGPpGnX90BHbsX8IhRzPTV4CF8m2KEfIz0hWENXB77wt6c3F+fz0CN5I+hC5mkSCWbYu/vZ1cnsIvwGwoi3rEQWixR6/2ASedi67mf3D8HL4LoQ8dUefT8mPMzeQWmuxkBj6o0S55q7w0qxyPYC3oaRMbTbYDEfKi4A0KGlWPH7AF+cX57fP57+5/z09ARVcdNklVSP8i2SMuasvZiYRAcXH4Twgb3GVFjFWE8R4ZAOITr+4YOKjAx0cElCAV7IqLV/l5hgse8HDL3Q1KfFXWTyQkWs4zuo6IW7A3UwM7mf3X4NvudrCtGux69CZ/odiCORwSb4jYbX3B6Bh1mDeCHCZvBdRMzk3YU4qGoPN19vZydNy4AcRRkL0FbBh/vf9Xm3OSRm7WA7EsM3CMPda5HedQu+zUEAHZNc1kQiF09Jqvt71BgVbNLv9/d6Z/X/hWyWu4x5rqr8sZD/Dd8sDzbj+mb2x0PopNDFr3Lx9xo2NQwG16tcav8XKpV79n+TWofIgMo8l7An9NvZ1d1NeM5LJ3whsjKHpr8Mxv+mT5JdX1F3SC/65JjK+Hsjx1jW5mq6+X12B23XPY3DG9HGeJSDWl/d63AlnR3NESNOAwb1pmkGgSY0pYgPEdWnps3diW2CxYvTb8EJdNxCLMTnV0TENiEs8edtdRVxrN/JNUG9WfT9T/l0p6LvsrppDjFsoBfrrI5TuqF3ahFyb54g8YUzBSgJVJBz9J/T/rp5Onv0qHYYk0OEktQ8a+LN9F6hn3Xq/HqTztf31/WfU/66yiJRfXmvZBkCsS1Fy6LHke2nnps/DuKyKXAymmdSMYxGgZOxefkZA9lIcFKa5/kwkEaBk3EzYGAwNyKUpIUUlcQ0HasAJ2FzMH38kCMAdqjFyB1eTa0CHIQnohIXqiw774NCOC0yHLTtnOtbewYViT2lx8GPxOWhax4V86XRf0756+Oc7B4Mm0L0JN306t4kx79+pifpZEr3Bvn8Cy2HinXFm6tM3xE8zSIVm2tBAVhWCQ5KMyu6lGUplkE12irAQXhW6H8AkNUFOYiaSdqtLHOVlSC2gQQj5V29iXxu9pChnFsRTtL60aCTzSGoE5lWAgNt0+Pg/yaKJGgSOChJzvRQLQJHhqYQMYnJC//Ha/5XWcWQdjwWICVciHVa9ecIv4ssToPmnVMypLRlssxEJeP7znNV/pCj0pRs7ROV3jh1AXKCWfPsXBhGXYqQRWZN655FkcwhEwCHAgfjlySLQyIW/YIcRPM2mX0wkinJwtTmqw9nqktyMG3D6zcqTUN71ykZDlowHxvRV1UkaRoywRgW5aBq5i5ztVolFdhpYxVGViwlN99wHojltepx8P+h3z643Tx9EMzbLc/BFzzvHZQkZwqc924LEZIsUp3LKdODd+BsZFCSlEmJ6tNxEEtdgpohKDrTlCBk2D6f7cvQlCBkSLKwL1H/Pe3vB32F+u8pf9+8ue398/kL6a+XZIccdkkRUjcPgPuS6T8n/vWwAG9TgpghtD/dlCHk+Ps1PzHr/c5i/0alSRRCNqFCy9pMVO+SHyETnn5BWqKOwXNRyWXYhqRdgJbQzE3CXbYtR8hTSBFDZlq9clw8V+vVU1CozFKakK18NueDfWnqv6f8/fbRe2+AugAhQQWIDFYM8cB1tfjtQmbLKmSqtS1ESWJenPdmWCcx4a9vX5H3BWhKEDLUN/PaOMrvUoQdqrCUZmAL7t76BbFE22O7m7/NVCz/u2z/tAVbqXjdO6zb+zMLRudo+PFv2+tBVZXfdy40GPGmmEu8V8jT4r4VW5hff96aHKmsrIq1Ps4DRznqq4Rg9dVch+mfVYnw1FFTfMtVlfE/kvIfOkFbfaHnfzT/kGTPUtetmAo8VUsEtyn9Adi5KMtXVcQI9o7ERxiAalpHo6tGewNvnjq9TLL750Ktl8/5GmNII7dKsqor93GG3W+uoSFNst1n26MxVfFOYUpVvH+oIXVGBIkwYSPwAfBVWnaukYezV2k5uES+T/RvskgW7zj8l1bjI0xQ32WGwW/KfwD6utTJgFaYet+R2I8Bx//+y3YKGaWqxNC35Ufo9KSbW4uIueXo3iMX7VJWTWRutq7UWboun2/Va4mgX8qqOSEk1pVaaMXCKPJbU5rXu+Fdu8/j357Nr7P+0adH9PXne6UuRLGU/bPdOzithT92PeRGwqyL7F5ydGj1C/M69ti/YgYHrwXL5Id8agT52Ffi7Qs1/kq8MVswqM9JFn8T6ToMflPo4+tvHwVbb7fe6PRMn49//qV752udeY8KVkJTnoat22sWspQYsrY8PVkpqy9KpVJ4T7sseKWsnjYiPIzdi74wQKPAQjfvRmRBdE1Ml4XuRGB911xN4qEbXzyCMXZ1OEm7+9YYUqPDSdo5lIEBrWV4OPuHvGGQrQYL4Zk+MIQDXDQSLHxfB8ePQITbM0wsjOeoQbmUVcI0KpeyuugegwHBNQdp2OjQ3c32rA0L49U6TXGAmVFgo6MaYTQn/yjTIUZ/+g4xcw0gGnE6wHyjjuYl6Tc1LXPfefccsOlkp2zPsLDwba5+XSZRoRArVU26OTeyasV4ma9ERoacNVosxA/dIycgzubQCgvdt8GRFBDg9lwLPWOlHpKs+q1/2DOcslJrLSMaGRLOYcznvZK3/QM1HpybQh8f8+mjYGM+W2+4YpShsb0BHziOZyMbRKNyVSZVN2daOF5HgoOwkCuRZEnYDHiA2NWgYey2XPmWy6g69d9ysBAaDcC+gxehPn/6JVme965AQNwo4qdk2d6kYON8SGhA18ykyHZdU5K27SHh2eD+E4xxe4uKkRL9vbf3rHgoz7Pq56CVjoUxaTTYCNHfur2lxUYYtLy1A4JWtV58DwnBR17zfuWHhOAzr3m/80OC/9Br3i/dvTELBDQSNPPF8Qz7T30iInRaawodxAy7g0Iww2684Zwlpv1bF+GIGwEaum59y/V+Oqhr6QLWKqi+xZMyuHOxUoJ7F0/K0NZrhYS232lG6Ep+QIldye/krC+TAJcFXdBaB7ku8CMFLQxsqKiVgQ8rYGkw5kSsDTwYYYuDESVudeDLif/quPWBBydkgTCi5OvfW0b8F+fr3VtGTOfeIvL07bU6eiyvVRjH8g0l/mMzjuUbSvTn5hvLDWP4YmGMCF8teBD+JQuFH3B+tCoklIM1zSBj/G7EoAzxfGuZDgZyHTMfwg3WMCtZFUkU8hW7bNvSeK5u/XpSRaFe65RaQDKj8HejwEE3yJkPwStbCVq+oPsqw9o2uqtCQhR2L2XcAvze4tpNZe0f5trmOPRixKjsgfQaQyKSDqTrIqc3t/mZHjLxIpJU/7dQt7pFPty/O9CQjp7wnmOPNxLroHa+k7/R25ID7krBLNHVa65iUmu0ZmQ0929R/U90xjRy+7ejvjQtws4i7zKmq7l/i8xD77eK1CQjWqgPtekvldHb9MOIfoBNlajWJXGnYEQ/rFcwPz98AITKrNVGd/+WNXkVbp4F6ZDUyOaN7AfYVbxfyTd90CZXSdBScqdpxXsm3/Txm1Z5/9ZpW+gsMmp7sWKwik3Ke5WrVC2TSKS6a7+t3w4ntC0pq+0v6H6+aH/hg+zV1761BJe1rf7+bR2sKsybR6GxgE6pD183DFmQC4WuQ1zzabXKUxl4xtKC2VFhoGxyC52HTJRHkI0I6BC3nXF0FrRKColkbCVYCNelLL8l0j9zho1Ri7w0IkSU3QjQf4nyPYvOK1mIShX/G0NaKyWNEg+tKFaB+aasoMXKlm6KjvJVJNWcpKnXUgztvR8F7D82A+mRWgUGuoVIUGxNeQ6yJEuCbmSN2VoFNrrQMJ8DUTYyDJzLQmTV4G0hAGctE7UyDJxJeRK2jh8hJmU8XLST0aWiknWuFrwntVSdN4bTm7lOKiXrUAZqCNc6xVaHmVSP5lS0L0aLgbiQqRSlvBSVLBKRJj9kXFcN3FSp1lx1NJ8aTT4LtLupyLW7WYnrr0rs81pzbz6vf43I57XWHnxenaniVuZp0OlKK2+1UEXRKrHQ6sUGfrnCQ6fnmWeq0F//pBAJatKqtRaq0F8/brSIVqnOCMTsSWR6cA3eLXZqHFJ0wkJGF6sYuI4nJmCzgChC4OZ31hbzRmazkkTUmLHOIdUaBx1dzbG4kaf2uCwhqkH+djRhg0tgRMdlRyO7QsZ4pu1wtgZEAzi4Os9RzTlrNn1l9tjnRKNuZCg5nfUT308fag/N2DfvoVdm64/30BOz9cEhva9Zt+i3afUuBKx+WzQOpIa7yEjquM115LXcaQG+nu/gt9WWO1mWkN2EptyB1IouDUlNaN3i/PoiDg8N9ChbCSq+YQ7o+ggv0pGtBhPjs8ji8ll8Dw9f9DC7MkykmVyqKhGVjPUGXEHQZo62klFPkt2Cv8oqvpAvgK05B/+PsorTRpCJ3hxOPM8WCgdtdBKjQ8ba26U1cQMc5laEhRG4M9vvG3B7s7sIA++0uDovLj75lkOirj3AjQYLIWb/s4dJsQO6gzUvpJY60w81q/TW7KzgqBvJhSpypdJiI8nC3947Q/BCr67588GODI0hcaeGdpCao4eg/dDB5ErroPZCd5CWz+sqVq/ZmSo6d6aQo1OjuVCF+TvWPiz8lp59rg28rOecZ0+sUmBX91wSh7V2Ib7I5/Tb1J0szARrzK//gWKq5XUb8UwkqXppXq8PsKFX7sNrxJgGWQ36bnF8e1FVcpVXIaODBbSjwkIZev/R5svRlUckX78Onmd1tZ1F34HBZbvAh9fKCSxk9XR4zFVPo++Zek1lvJTxnQ6WZVFIhZgyoytdbqV57an062cVsSVGdH82ACLmk/iIgPlucmt7vRLRd2BL3RQ9lDbaB6JpnVv/OKOhZa6yoM7ZjtsR4mI1SThIPveR0YIeYp4ittbT8FB9v+Ch1FHCYP3AM874Ybd7PyvESsI73X6grqO70Lr4fpc+sm91OzS070sJie3bQDHBfV9WzCupNmaKF1K92YMzOlmJwZmdfDnz9VOqb3yQNr+NKF/bw0SlnQ2PjTBXabq9dYv1rkrT2IiJWoyJ2XzEul7Q1IdFI8XKe6/9gm13jVbVau2B+ESmlaDEjhtBHvZCLpOykkUdBPpdKWylbvXqdvhs9HjI9eyQolJrHd4avSH9M6meb/S3jUTgFdNJ8Nekes57snx2kDRLLcTcJresFA1yC8zbGgfU9PWlY8T+aw2XOfuypLltM+ssSpA2NHduRF+Rlb6ZvtCAxxsxphlh8B6aYw0M3ERzUk5GEGDbaE6NA4srEG+kuV1HuJO2ywLEVpoXf7+2XMqVKt7NYdJZnuv+EbafMS304fXGAw9ZeXZ40lGDdDwiMAzhY4oOSwCjETA7iuYgMocxrfZ+LYLsdvhYg9n1gFmyLnm+i9bl/ybu/qpex9wrdaG3wTD91VjooPorBx5hf2XxJE9/5TKFqL/ytyMX76kKf3TTx5ZGer/2UPS/LoMo+99pi/pt/UrF8izRSRB1ybtKBR3DtZT+8FbtYkI2ZZujCHcRnNTwrYQdxL1T9tsF6f1zodbLkLwiTvSOarVR5bSiHuNIyFsldtq799WTSk+SSEcyRNCx5x30Za0cd5UZrQndy3G3UuCGTgCrHjkoUBsdZtJbuZCF3ocj6U60YtFVZKbnqN9ad7+1W18uvDFTDgoDtFy+kWPlzlMRSY6P0EizfwfLfOW6yJ9FVidACnpCaFT2IOYqYyKCmUrfRcTzFAsxbpbipMWOMDbfIsYXL84yEtmVCrlw4yDVQpmC3LjxZ61EERJtd5E2MpSclnb/EOfhZ/b6BZEtfrRMhx5zslCRHHMauIn1AJ/NBsoDfL62oI9C2QwhOwrla8U6zi9BvfGQfB3nuN7YTovtioeYmH54NyH0UNFEVQAfYPAjBh22sOGiDlv4seI2xm3QNBvjQfThG+MT4PCNcc8WF7wxbm1z4I1xJ+VgmNYXzmdRHcoE7nE6FD58qj7FhZywu5zmvh6i1kXQCDcJ39FjJods902iY/b5PNjHdfu28RWkVvfKHkR9HhMR1OS+i4gun7pogRdQvTmBbc2CimxlTtpBHS1UpSKVBtfPbrmPr5sjGmy97LnF6b06N+BFYLaUbaEP99sABem0jjeI0rYN+aA526xkBBemhnyYy1I+jNg0aENeqhxoYeyQBGhuckz2Mx9uUOqzIS4q75mdErO+HrV74Np6N1loaqYhGTQvkydZaFImKx40I5O9v+yPMLeyWYOdvj2LdVmFHxt2KHz42DPFhRyIXE6jS78yCY9IxBJIHjornvY5cG7swTys03rfbru7GF6jLeUPoD67qNC12eYushrhpgbXhx2807XhRhalviGYhS+ad0kdXB2xAhJXl5E/mWqOw5ZRJYI9ZRxila1+mTebIaPnqPSB1KIxE0nF6TvKUVcWwI0zJzl20yyAPfixDCc0+L2MHbS2+lufckHdBZrUOZA6PUVHUrtdbiTtE6etIO0QA+0BXBTwMAlxTwBkBfRYvYcp2FP1QHuq4j1wa9HLlq3sh9Q2yO6Nh12YPRxvO5x98PxZRt9zlWSYDnggcki9rw2Nrusdeo++37Xz03e6npbESSG120JiYLus6Wryfwtk72S1gKlrmrDI3Z5VUaxzvarAtOe+yEG1ZwsaYXseeI+hPVv5GdqznyXY1mCzxt4aKC1w1n5EnT+4ms5Rv7lqNVmYbTclssbS11P/2nm2ToMPJtiKH1It7UPR1dStr+hr65CZvvfdSY9buQ4MoFmw7mQmWKcOwAmXpx70qH5jRM4y47Na4exPLlT0HRoCHgocUp8yxKLrVboeo+9Xxtz0PYuHBcg12sgKovWZB/mzSmNZ3CQhp+mnyI1eDnoBNYwc2buMyJn6F4clkz3MhSoxsaGexKH1Mn0w2n5m6zeenmbIztPX7LSCoLcZWELY3+ykJ2i3A3qiVYWT3tlW/1gL/bRikqGmBEOVQ2qxVja6RjtyIH27dVhA33R9bUG2Xrs9RA3Y14a/N393wmHNVn7PdiH7Jrs1TBOLKZucPdadXK5kVkGz6+1QOqSey8lH13tZnUnfg01YQt+Lhdi0Em/NnyPiE27rVuKtNP8jQbAixC5gEkEvo5BZBIEWIfs1tzlMfdsu24b9W7lOqzoxRpB9m1IH0G/1WdB91NYhjhpRp/0Anb8bkJr8IZhzdztZI5WuV1lYUxz5s5Vg4NMxVVlW52ExlAFhIwIMnOxmVK9ztc5CrvOPEdVr1GgwENYJJK5E0GMrI8RaJBOgl1bsjL0bP0uJcqApzsBVqKA7SLYvS9g2nL3ziYxUHJQ/bVz4kPrqLhJdl916yZ3EM32fi+hZ3soSWh975LVipBWLRpGPPq7/ZwLojRA3q77gR8b7YsT4mAuZ6uuARNCN2h6oS1nVlxnvome5CnlLyY1eyqq+XFm2kqT87l4O7ntd8pD6tw0PXedWO8d5U5pgVrplJpyZTlObySVietX1s5YimGX5EAOnDENa5MxhmhQ5t96yEs2vd9CiZtkdWJKZ9jQrcr69hSWac0/Tvog0cI/aQtqKEFL2M6bp5kDTqPgoF6o4FdHzbVC+WCvpQhVSRM8FKGGsJy14xbXFRK+6pvhS8a7WaMSNChPlSlSySESa/EC39r4UE28z78T3+a0MGyd4BdalRC+6JhnxDZ26hVvnzvO63wNNnrdFD2T2PAAimT53/DMZJz3PYvlGQKz/e9JoERIPZ/x6I+RGFt9Eug5v70PqWi2XxUujxsedQSZOQ1zUpMmLUmVX6zSFzUlHtCrL1mmKm5j6UOeFjBKdR+xLEphSx4a9UXtKgAl1fLnBs/8hMnr670NbRiJwP9CG2qowcg5eErmDTGJG2IM3RErMdMbHiuo9x1M3IpSU2Ln2EBEz2fYinBWFzuKykln9GC5Je1vKSmjZuJXlaH02OyhmOxt6VJTWi/lLotsKFfRTrbYHaqVSKcLX5TbkjRQn73uF7yo0rNFhJJ0/C/zceCmryOgwkp7IKFmJ9CGrx6vwQJIFOjaS660kJ79ah57ecWG3Soy0Z6kSJL3yohFiZP0q1bMon0mmmktZLY0cw1xzwH1OM+4l3OPchcqWFKCp0WEmPf7185+qIOkfUiP3auQYue+eVdCLWk7ishHiZK2KhKY+lK0SJ229SiChbZXYac9UAdgscyIvVIHYMgngpoTmJn6oFr9RzYLX1eI3/jnwwzqJf0+C3lh2E6+T+DkBvawcSHwBCF07gFNECHs3b1LqMBuaNSkzI8PF2cQDaULETTyQI0bsYiZpdF1u5panf+dLUq1EHn6ZwIq+TtOnWg9+gcCP3AyvNNRGi524GUZI6kg7jjDXj3qfoqTxstFi8LJ1t+5WvYIc3ZQ7kH26Lg3JJl3rlukTbjjOVoKJsFCvsPGhB1moV9zAYOHExqp7gJhA9W42iij1EJcyRO1pAbqJ0wSnd9MiItNDXIKwtAcvMCY9gkUGpD1IIdHoESYmFL2bERSHHjKigtC7GbER6CEuVfjZgxwWex4B4wLPuzlhUechJi7kvJsSE28eslIEm3cTQyLNQ1JMmHk3ISjGPEREBZj9GKHRZRsqNrS8mxgWVx6y4oLKHpSwiPIIExdO9uCExZJHnLhAsi8nIBprJ0WEYnezguOwQ1Z0ENaDFRqBHbFiw69+rJDYqw0VE3jdQQqMuvYokSHXHYRqgaOry1ORDeM4KpWXSbnS0YTgPEDDsh8fz7ESYWM6IxdRZfdx0FLl8/Hm1q+2zgMvZDvYtdToSjYzf/1PaPRGZW/UoGw8dnTS/Du+/OYpYv3neAOMVqE+woK/VEZmwQ+jtT8L6pfjaJqu0dp32zW/einLUizJjFht5PZmR6UzVAWtk+wGbHTYRqnm2aub58CrdQ5eo5Y/D2/Ycfu7eL+Sb9VpFtfv9BAYUrxn8q2SW8G92aLJ0fxGhJN5sEOUlPcqV6laJpFIdTnzDiXekqSstsJ6TCha4f1aV4is1CWJbWtl92bZYMZ/J7N4rqdpwdmKByU/fLZv40HO9YfOIZrp20mJ5vm+zPpPzrOFQnPrf0iMEKF/x7U0KEeYKXAQdZImF1jjAWcPJaLvmXpNZbyUMSBZThe0K4VIlbOLeCWrIolCNjy6kNvS1Fz5+ilNyme0Gzc6pD7sZX3Tm+jz0AQjvU+tFcDJRXbQhQyQPSjIhHcXy1VQ6LKPk4HCldNE5kQB6uMZCaav1xwiwAEaDS7C90ri8N4rycQWiSySaVg6nd5YUZeHpdjYQfYsCpTXtACX1/RUAIrVlCUmUqtVAu3HNoWpmbIsbPUxmIVkw0UGBVUskK1RCzDVq+ZYzM/Hv+EINzK8nMe/fqbgPP71My/n518oMD//wktJwbgPwnv5Bm3TPcrK6BCT1ue0cK6sJZg8WR/Q+nSMAmw0OAnxfGx06zLkFEKfy5RlIJpl8VdZIddhtZLIYn38hGsl1hy6Q33fRoPpCycZtn3UCnx0WDYusvwFN8xpASa25sgfCq/RYCScoaMUWoUzUqH10YBMbHkhtcaZKm6USm+Dc2D2IlJGa6GKXKkUnghzmjg0/2WXEJr2cpqoPm6K+sC1AtMXNqdMcXi1BBdf6CHVHhn0aOo0UxV4UqaLVAFfBd1BlKxkWYlVjvqQGxWmb6kPX6IAtQAT26tIqjNVzDq7D0BIrbRQhegrEdPqLTnoVtCmMJrJuk9Wb6ndm6MYkF1di8KB7KTZuUi21sZOc+yckuy0OQwh3XrztsicMqK2xaju0QrzN5fhO4ouA8y/rOCbjJPs45YLaauH0zpJ2yP5eYvJkxYIrvFXRHS8B9fjcnS1Pm35ab1YyELGs5W+0R6aC8NO3mqKWhOaE8PXAkgdtXib8FAQZx9K33l69pqDPNAh2IOiH9/ebEDY9jb0j/PUSxmWQdsOC8yYPUXZO8URx7cyUi+6FaNZRRwXHTEuZn20M5HlWaFWaORGa2G0uIiXsrouZjHew0tZqULEnN4NjjVZQcFRJ0/KqlhnkQhKmWEH7QhxsdYZo2Z4n9Y6sJNcbtJB73+vIzlf6mE9gLdT6sP7/CELsrvvOsQxwtf/gQBsytOQoV8gtPgvBb4+6EUIeM1jhIh4yMPBOOq954WEHMcZodY9eFSLgYN+Hsz6pk3YWblxtZRvFey4nAdf6Cgz/uTAAcaHTaXpk4i+I/3XyrD5sNxkO7oLfGNmxFpuEh1BH5rx422y8ty0bwUhmZv9/bwjx8GdJpG8DXv6fUyrRYDvvzvGIMvI/WdoCL1T6jBG7g4LxcjdOMQZpQ1/m3VEiXmX1c6IeFdwRIfofXZyBTeIARi4LYy/ar8tPMT5iajEshCre6UudNQ8NHbokvjwVjIJhmwyTr852k/c/HHgW2HTJrSiwMhHqA0r8XbCYcZKvO3ZkrCeYpoe1m2EEkP632luTGfsQz/oZ7Im3qV/9VbmqU5G2y6yg3ucnWIf3/f4IWJ7od1eJdoh8LaHaM8gzLJBXcuXhYjDx7FOsY+vP0MYbE3p+oSqTowZgZubXnSAzFAWQmtSKDrKwPxPY8BR6ic6NkiWpzGgPcETHSUol9MY05HGiZozMGOTi3OUrImQE5KXycJpT8lEzRmefcmFaku8REcLzFw0hnUmLSJkBecnsuBOpCaiIw7LQjSmHCYgQpGR5xoa8/qkGSK2AZVRyGrBjmRCuNrRn+F9k4UOYkKThdqKf/iMzwmFnPlZfUU0A5xgJloHBNHXpS7FW1OIxpD6b1bi7WUjyvoFALPcKXi6FKghVgTOgt0GjGbD/OyQWbLbAMp0qCFWgGbRbjNIk6KG20HZoI3iflozaI2wC50osWuQHZA1xIQdlOldw+0IX2PsMoUuyWuINcA1iNsY4gSqQbaA1ygT5pCnUQ2xKGwN47ZiuJZhISdf47jt4UupGmwjag00aSFLYtVdta+/RjI7v7fqNXR11C/44esiCw5yRTTwDNFayMoJjId7Etan4gI3Be3u1ELAvUA/VsAbnjZSxCOefpyQfVYbKGZ31U3aaeFhSWkPISMtUTpazly01Ilo2bK9kqZ6Jc/zSpTklSrDKy69K2FuV3RiV4asqWQpUwmza6JTa5LmsCRIYMmQK5AsUSBTlkDSFIHk2e2IUtsR5rVDJ7Vjy2hHnM6OOBcbSSI2wpxS6IRS9PmaqJI1UWZqwqdpIszRhE7QxJKdiTA1E1vuI+LER5RZj/Apj3b0qIVazVW2SLyf+u11pIVaRW1pYqrT7AWKJOuiOJ7xKtVcyPn2cxhVW+oQVq09FvzqdeOQyQwkGMSNwijomBeqklEl4/8BiEL64acqsE302U3xjwDPVZlUARuNNvqOxp5MoFmu961AL9t3cgbOqAZ46Hi6L2Xw8n4IOlzms7Eilv+DvsMZBmBjj55l9H0uchEllXceHWvXrIWirdBHNMFoXWiZG4LOpJGa6lPo+RHxiD69Oy6xH/bweMUE/yhuwWYDOJ7Rp0fHNXZxAtfqfUr7mp3Nt4C1fJ93vKZnY4Wv9fvIzjU/HzkgFjBgHscE2GgBsYI+7DhmwMeKiSUMqCdiCnz8yvzFlURNqyplFjiZ3NPkquX+ltCAvyT7Iq9jH/ftt8aw10pVR8k9uUKtyFzxg2MQ/PEBxQ+OaeMHxwzxg+P9xQ+c+ND4wfGe4gdOcFT84Hif8YNjrvjBMW38wM0Jm8Uds8QPJiih8YNjrviBkxUfPzjmjh842fHxg+O9xw/cxhDED4754wdOfnz84Jg7fuDHDo4f2PjJ4wdOG7Dxg2Pa+IGTExc/OOaMHziZ4fGDY674gZMVHT84Zo4fuMnh8YNjtviBkxYePzjmih+4WQniB8f88QM3PzJ+cMwbP9jJDY4fHDPHD5zkBPGDY/b4wbFP/OATCP7TAcUPPtHGDz4xxA8+7S9+4MSHxg8+7Sl+4ARHxQ8+7TN+8IkrfvBpOn7Axw2b1X1iiSdMUELjCZ+44glOVnw84RN3PMHJjo8nfNp7PMFtDEE84RN/PMHJj48nfCK7JxHECo4f2Hjh9yZ2MWPjBZ+m4wVs9QIXP/jEGT9wMsPjB5+44gdOVnT84BNz/MBNDo8ffGKLHzhp4fGDT1zxAzcrQfzgE3/8wM2PjB984o0f7OQGxw8+MccPnOQE8YNPwfED2iXaKKBwnevZj/ejDr1ChxBO6KLgowmtNxyLWv3WIQKvKc7AtZT+iYZsYE15BrJ1FXhvtA9mirNwqUfArdYh3VaEk/HxKeTl3knSR9CDvaG8SVbJ4kUEzltcyB01VuqQ122miSHv3HjRPqsS09Cb4vRcSZZUj0/rxWPI+7cWwKEOPel/v35H8JnS9FTBcd4e1TDMS0W1Em8UH3Ugw8PZvrtDATvU4iHW/+9jKgNXHiPYjgwD5zqtkkiU1WNVYXrzoQ49aS7K8lUVgfe9e5AdCQY+VWB67qY4A1ehKhWFhhj6bFsJPr7Hl7DMuROcj8CUuV68f78Grgx7iKY0PVUh68KPqyR7rJ4LtV4+52tMfXQK8rE3OV0JoLdKHLRV8U7C2tehJy2rOE2eHp+rClNj+yr0lFVaPkYCAbgR4GErlKowq4auBiPhI8H4aBXjYX6RRbII3K4csW5EGBjVd9TErS3PRPbonZ3UxfYYnJg0gA71YTcK9HTrUhb1/wvH60iQ8JEk6ukRYvP1eDAGp+0ZAYKz9+ygK2Sp0hc5W1cKNTbXKsKosFGeSP3IuahCk1jZWOOuFk3L6ewl3Ed5nYI8ZJHTLfOxOwkjEsxGQs8VFClELXSQRKJeXGHpRK1+gyQV9WFbyupELsQ6rXTjPdNh1tuAeO2YdSmr2AjqdlzHbSFhWx/2Uvo/IjQGbUpTUHXb7EMWJ1ES3GwHxT625dpgMI136BOCKLwVcRSHp9j19YQPCDVb2TnOFHuih65hrPzWtcs+jUA2tnEocI/wTQTlMsnug6NIVmMawVWSOaJI+zfuPix2M2WWLXyzV4Oq4p3GnKp4/2BjShmtC+9Zk9WMjcSHGFCl5XyG4q/SchDJ2i/+t6CojMsES1hmn2aERG7sJoxiN3vEDw1RWC2wBil4jYCvf+xzvNESiJE2bFXkmpOOFkZ8xMi1ktUCn+USn0UhKyhH1x++iJpg+/XzBu2P13ymr3h9E+l6W6nrM0sLEbkxe+WQy6heFxEnK5mVvVOdEJqjnk6Q4/oucWC+6P8RibjRIMEbfNUvSSaK97lpud2dZy/UYWnKL1z3fjEBz9FGKdSBI+c4UOXwGUwErPX5S1pc/SJI+Sy+BzZkO29XjAs4yZaFLMsTmVbirr7pcZLUBUTxfprp2xQk9aT5mVj/jLlQEm9+Rm5+htnIoX2Ehg1tYjRmJTKxlOV5Y1MdXa+fxwvsC+32NOqtWbW6bNXZTDKPBtMYsNUixJ2YfmJ7T0ZQvWmTqCKpkh9y9J4wgrury9OpDqejTW0/K8RKjl5tQliylFVT1RdaGvqSE8iYS1kVSURS6bdmrDaiXAbkSbakYG50uDB7M34EJmTiH4L5Pcmb+jCvC1PWbi3e1Avzp0z1uz/vnYvoWd7qy7nD5+W9TBkVp5z5LlKxDGxxdp6jVgm1erV6y4Fe/xMFeSMUWgG8QXPxnioRX8hs2bm8hSFuFNNWcW8+r6+YX4ryO4kZtdrKqLF5v74BPVfr0JHewVzLRY3c3vw+PKuNscB2WJsef9gH1oXOhN5ETkIntoPClP2fmdDfparSdhQvIjwwYaM7MrplqqqipxtczQd+o5uBW7GhE3BfzEKKSv6xlsX7nSwBNdrKXIv+rUXLjSirAWaNSYZetnJM0GUlitDOz1qnGx1CTFsnAVoG9Ip+dGhwDAOPC/Z9MgEZvApxUMJWIV6Yuo0CO/8e5laHBbPpu9GYWx0qTGt7uVEqHWadCEDuFKdsNyL6e50UsjkscUmCdtSINgcnVnDXdn3mPJO2LuV3KfWC8by5ME9kRle6vYvPa0wSp8SfQivu5TusxNtFsqh/iwh9Jd7SRpEXvZ6T6L+7FG8k5LVgrlS6EsHXSWDgCWiGNgGewGdnHuCmy6V0uVHcg8876ERO76CTe304EtV5ur70cwP7sXdKkm7amwSJd5FIQxdGQ6I22WLZaAX7sescB+5SqmdRPt8UMkoA66IRcqOXd/Q4sHsnp4CokNtdvnjrNA0dM8Z8jQgHYC+3BpAPklPDE696z9F4jQYHHuTEyxgQfObFgTjqGtsdgrleA55nCxVKPBIgXeCqOPgb24GOGqlwP4495Iw/izK8c3TgbsQYgV9FOdcZqomQX0UZNXK00INKe2JGPMARvG5J0kBt+EA+QjmCjuA9d7gOEme1emCoaMzY0SHCHHzb03r/sw7CgsIG4/KkcYN1peaFjJPAiKWDqk4iF7V6oQ61uMqB/ZRkMWCTyUWt5cabTBzQN2YrkQ473wjygtPU2qNWig9Wp+BLREpZqRvJPdTreosWekLQhV+Los4G+htQwaJeLnRMyGsS2tpJ38o8Fe/1Duvpi4R1LkMN0rmjWIduO05AHbVyMMeOnEV6gnqKG3HkLww9L+RLotYl7OzilAmt8t5MaS50noNGHrsNjWQCH3t84fWOvizCl3JT9EYzMZrk+PbuRa31MUnELLCvQNm1VKJYSlgFHzMdbdSAju07yoH8Q2WwztAC3GgR41prQXMSA1EL+gqUtcC8SKBD0HfBkTInW/MCnQ6MA2NnVsc5x8gskulJIZIMuAPnNsRox1obPzHxMwc9m7XZQTOf9TNAZZ1uksoAlRW16PgJHnoD6r008qpUq+6rEhVyeAcYSd8V5ATfDvzkH2A7/jN+BesAoB+OqVclEFM2hWlj0qtcHy8IDfXakI56YjB/bj3EMZsd8OKnsTbc/luRryKp5pROrhWZPN0/gVkPeRTVohViAl0WIqvgw+SAtlZDjo87kZPyBDiFHdAmZYyZv1pBBz1Xpjeb4i+AQGCnJG2fFR79HaIcgWO+XXdMX0PBIiLiu3bI4Zd9k5GuhqC7UoPCH3xTykZDdk9q6CeyW1JWauAdKU9IlcuiviFxH3zgwEq70QMePvDERlzssmJTX+vyNAM6ebGZgJq8eOKq13K2WNQ5lSiI1WsptnJM0NBbaFZk0jtongaAbqBZ6enun7nRB2PKmUhS9SKLWVXJVR74CQaFKccU2FaEDQizBzH0jgtWZ72gIG106DCHX7sIPpVYF/ng2cKWgWyOYDxBOR/sQMIngj5YkEF1DEc2lE4iQzv3Di9plz4JC+rIO6R03fcQ09aMf5ci+Kplp+AhNOkOiaVhB7iq8QVTu+liulsPGS6qzXRZHS2HDBTeXrqUwBfSnIiDtvLVHPcHnNfsliTduUuqwMYyIjlqNEI91nOG+ySFuRjxhYBzI0YLPPjGv7fZ7y5lJfSLmmHYo+K0wa+skllVR3iCLyLbyY4aTbnVDPXr2GOuy0LrQvcis+g7KMWfw4BGVUTf4Rn9vE1Yibcvooqe9T75l97r1hgTVuLtSavqfXLQQ9chJmRyqapEP6mjtyYKSIIIhxlb5ainzGbK36/5N8i44eD/+zWHDyDe0GYH8laFnrN3QBu5QoFO3AdC/xW8bzEJDTx9MwU96MybFGcmJA46emNTIO7SF0mxkvGleDOJQkMDhE7Co432SryZTKGggKHVic77oAtZzNVqFbod57ailoxaSVZ44AHsCXbM6esQ9KUqkjQNnKu4ubdyxNCO9qljVYCj16PipFHD8ECcnQcciht7x5nIFpDLxwELz+TpjVvIMldZ6JF2B29HjA243GY7JmHu5DfmxK5ksUoyEXiMw8HcEeMDTlayrMQqDz3v5WJu9UCHvKaw7f0YKKdWvyxtcqDvmXpNZbyUMSy/q4XtqKuKSOw6cJlzPpEmL7LQa5X2Xu2VqpJFEgn4ZK5nzuYXos0vZINfYDatrmBcVtWdzJ4NuilU/fdcNuWN/p7MKlSec1ZAo7/36md+lqXyGel9Vj3zi1wVz6jvt9qloqzgM44ev5ZCTjZ2wK7kShXv5sT/pXgDRMZs3EbVHPnXITJgZCzchIdSxgw2rEsZ8xqRy0wHcU9MSPRe/x8CGxrVJtBataqsJpgPQfQRGk3zFfbyBQx+valEzb9oRZkNMMsq6i9gVPf0DcyP0eMzoq+f0qR8ppyvbyTZJ+umfs466wPEwyk2U8wPdBcg2OdTQgy7aR3JYtXmM+3HpEpVIp1F3wkaRy0ljBQjbN0Tbb4BEXbdEeUdUW4DzMBAy19sNbnx76AxWgd6KUEHSgKwTzabzlRVfbvhzFzjIc+rOanBr6kFALdnR6mYFx09Tuy6x6XuWkw/vqe+xZhA3LkYC/bTuxgDCLsXA8/fv1x21o2zXE8UmzvLVI2gu4IU9Q9U2x/Yk2FfRPS9PtCxLuRdJYLTvXoZ99T5kbL9EUYDrwTdmJDxjwa37eX75mQ+FfrmUr/Y6u7DDNLRbWPEPoa5jQnl3TqKpIzJetyNGWXZUeYxRV8lT7LluP8i+CaN9rjrIv0y9k24NggMP0/QU6DckAu/Jeokgl4VtTqJ4ViBhRh9ssALGnW4wEKNP1/ghV1iwgkWbHwAwQsbu2NvISfZtHfB27uMNlVRpApY++wKkF6GQUeaRmRHRPGlns8o7745weE34aaRp+vEQhZo5zcah1kzunDUlaN1Hv1lqikjsPeqdhoxVWPuKlVgPkhdHllT+lmItuHy++dCrZcYT2/puueAqo0uytXGdS4z6vkjEXqrxY1rTnRDEztP45sT3RQnjD3MAbx86qKHPn7qD4scczqsFOPNTtRNDwObQlmhi64mLz5PHdfKe63hhRSwLP0uC7Qg0ZRlB3ieikjyfIZGnP1LOAZUxFKuLUx+JQCUMcsGZe4FrDZyYI82XuJYWfaBKSaGO2ArUa2h/WAPtRViAq3gZ6v6oNjjVBZQe2vSz2nfynKdwoJi2+JMR9RX0HDdgOxorAl0bcdjU8d1IsBr9g70vh4bNqpLGDLjOwUrsKsSw7OoWyWIKzMwX7EbTddmTKZiu99c2SRE9H1ZqHUW1wP0LIvPVPEqgEExa074zS+U+hdEFi82v8BqWqTv1z/kc5F/Ffllkp3q57/qhvZnksXqlfCL1T+1ziORL0W+SjK5+anX+qf4v+P2LsFFUlYy07m0n9TbXOQiSirY1NBq6eZ30uZ3Ev070fZ3WM3c5pP4p5S50LdDCD/j9pjP91ad/8vVU0Kej1VL7/X7tI8YmFpyGfwC5oQx7VMGRnoFexEzyBhk5o8JWyiyf4SZMtryPZEiTpOMsvmM937j5kf4WxHZdZRdhuFvpfi+prE9xkhmiMq2XRy/AfC7THZ25IUmX+x2I5GQPN9KMsOjAiiuB1iw2+G+8HfIS/d2fpKr954mFPmz6DTcy6Rc6X7+Urzpf9RlCftb82vbFr1qfm0l3uLm1/g7XgPBtXgw6nteOAAf0ZkwA/OKTiA6bkPSDq81y0aTGt+x6B+Etk9kGpqWb0qJNtV0VSTQyYYT7mgrC/W41YXuIGwRnKd+txG1LCYD1S4j7LWnvgWLCHx2ylPWlPo/KHiOGiWgU7vu2REuvC9EVooIHO0ckbfCVU+Y1BBHndA/mKuiwmRsGYpwpW7BXze0kh4x3KMc+XWfaV3sVvLldwk2lijXxg47KdNueJtInyPFYSVTspRAQ3m/JH36FF/zyAIndstoQycQo3CZPXZbRZDiw9csokQZdpsoM2aADEKkzvCwCJtDw9skupQBDquIswb4GkZ0od1uFOXN9nCDwHdQdxmDu4saZAjuIvaEIQQ3ssMMwd9rnrKG6IIzwCSOWkZx5TnIFOK7zxOmcVyCBptKdBva01zKa9FBJtNcOJ6wkvDmMcwwxBVkH7Owd5FhRiEvJfsYRnE7Oci4u/q9APiF/QmjzFMEyPv7vsYQ37m2m8Vx+XrCwEGE7UJly+NfPwPeSOqWpIylvaoi1L8jlKNWJNR/PXc4nXalYjmvy8z1mw1L0PlGhwjp9gZgL3wK62gjGOpXl8cmwe8gj9148Jfgp24CzUgyJjsa4b0ZkisVmJZ+Er+RY4Y2ZwcIsTeC3OCYDflpC9Cb8YGmvMqnOxV9l4GLp0kjXuVT2WrS4zu7emQnf3DdO3XHvo8unakz9+vGSaC3Wty4lK4mHG92wKfix/sccoLHQa714Od3vLExAyTt0Mg6KFIPh17d72ld98Ddb6846ab/8v/n7l2b20aSbdH/0v6Ky4t6Atjf1La6W3ds2SPLPWf2xA4FW6JtnpFFjUT1Y86fv5ErCyCZSFAECGg6zhebQaiKqwr1yMfKzN5GQR3Pq7qnDaphtaPbs9UF/Wn9dfWw/PeAULiuIYgeX3QoA8O49gxFj+GaeCjcpC+Tq2MU/AeCvzX5AFaPi3FfBrr8z7yNoeVWu0ZyVJHVIbDfLn5dDLiV9mO/TZ1OOADc1iMvI+70P7KQaifcp4e+noeOwdQdPj3cvuhAvqLe+jhj2PT1YvDrkLCL1W/jjKGOBHvgDifbEd/mvw+qm9wNW6ubPPHs3z+s1qvr1UhbYLu3FxvCwwJ9DI3t6hhK6rUjuGviIT1unMAjnbSPG2fvyx6z6/nDlyF2JmUMTVcvBp4wj4Gc+3kx2I1574f59XrVN4tRxxga+97nptMXG9C/e5dM7xhEq1j6+MAVVfmHJXHqmzCiQXT57n7GVJ63aRIfVrfL6wFrZw/KV9v939f9D7mhO6a0S3j9urj+5/1qebf+wATGcUe16f6+6X76Qd0sH9YpD+u4a+kVen5oen6BodAtu7ztnajh2YFs9zv9MKhE5OtmLQwIun1uPPQDm8U2NP62/8AaUvEEi62hFL/ccks/+XHxhVJbTTOex03n0w+I65AdwR18blBcnexYyuDAgR3LFDxocKMQBIcOsH/cwWFjGhhyMHAYm6OPmNM0jxOMaXP8fd78yIsPcNqRvcyQEsNtEHXuuQGlvocz5g4azvOi9mD3lN7PqH6qo3IGPYPx1QjJgvZM58tpD628d1NoD8+ZvpuNeUaj+HV+O/bb2vzCMv3CC72xm+XDYqCJYN94trt9gUGMpz20c9eNqD0cYAgfRRBoWcPHEwOeH0ISqqcZSRKqX25AlP3pmpw6f7yZr+dvV0PoRvvGRKmguP+b+Xp+uxpMQXp2WMpdmVjpg+/I3fZ/Ag6HAmhUEoeYsGlYHOogJqBxHD6YIzwRXYMZ2xVx2GCGMzm0cYxM5Th0CMdxOfSBTEDmOHA4R7MK1AFNQys4bEjH8Qq04UxALDhsKIOZBdooxqUWHDaAY1z02hhG99EfNowjnPTaKMb20h82iBHc9NpgpvLTHzYoJtuOdaNwb4OSu/WDPPJ5OwW54MDBHBuTow5nlHCcwwYwlB6h4R6VH3EY/EEECQ37eAyJw4AfT5HQRjERR+KwIQ0jSWjDGJEl0Qld0Uffc6rfh/nybvEwmCSh9TJuUsH18tcBE92J61XT45BjRp20fcrDgEOyG3rT40tAH8Tb2I99OFejD/jRchfuH9C4yQsHD3L68b340JaPMNKNu3d2ep18CPircfE3XU4PfpR8kc8NZ7yEkQMHOPXYXnpYZLgYd801PU4N/ZbEuFGhNz1ODf1fT4uncaE3PU4N/WGxfvhj5Gt+q8+p4T9ez++GU1+6h0D9Hsl26TuM0fFPBPw5NWKwc0vrZUw1YlN27cPq9vYYYkEn0q3ia/er29ujqQXqxHbllTiyBNv+gY1Sha33kH4l59FkQ6LeX3xIv1/fPt0sPt6uBpig9oyFu33kbqcexLf5769Xd9dPDw+DnMbdw/g2//16u+OpB7K6O/115CGs7ha/vhT4waWmnhvCcdWm+g7kYXG7mD+OvidSty+0Jx5Wq/Wb4dyuPcNYrdbHsrv6DIREh2kuR+r5Ra/ENSWrRKLMsdfWmjJUoufplpfNfbmxxj0s5uvF4FRG3SPhjo9LaPTcQPZKjMefwOhhVIMz5/UdCdSrTXdHTi5P1fjEUh32iIfOXuCL++Xj6mYQH1sHnjoc44jZD/zoi3cb9DiX7V7A+GokvKmvKeF+G8EhtYV4092UoB9Hksm2gI8qje0FT5SCx/X82/14m7HpcortuP9mGeO2TH2MebuM4BPcRjWaR7Cergl34w7u0fZjG/iu6ETzM/J0Tw6azEfHELe60FO/R5XPfn4Y7T3JNPmaz3+xoITs/Yek9TKqfXC4ANUJ7WghSp26PU7T+cPNoEphz40i9XxMHEifoTws5o9DTu7uITQ9Tg09RcxQeMiY+FO3n7nbCQbRuW/reJyBiprSyZ9q17aRjbRpd+dtXB2iE/oxUmIP2P96mj/MyXq+OMLm1TmITe8v+CaOEtw7h3K87N5jCEeJ751DOF6C3z8E5eD5uEsyHuxS1Pv5c8WU78G4P6Z8OEW0Y3onjDHfN8hnYsxfapjXlMHy0/3r+f2P8/t3y7vTx+v5LYg7f1ve3ax+G/vd4vee7q/n91/m99+Wd4vm937D7w3ebT2HPUqo/d6BPhdq/1Jv+AhRYd/49DvqxQZFWhgrZCO/N/S8anqefikek1Vg70D0rAIv9YaWd8v1cn6bijm8W90M0Av2DS/1n0Iwv3H/078tZBG+/ueXh9XT3U3y/Yy8ApFXuP6Jm81PvMzghinRz42npUC/1DI8OlPEMyPrzBTxUgNc3R3vVdw3xlU6DHkhHkHy6Dus4xNi7B/W3oQYL/f26mH+tdH+phrov7Z/4QXeIJbNxyN5FXuHxrf0GPyKZ4emqGufbu4HB49t2v4ZfCwCzXG+la1p6Qozf/rldvn4dXFDW/vLw/zbR4qtvrsesPIl8qbrm9T146braQZzTCl6bQRHV6DvAbue/zGhNxM/PXwiAg4pCt2Jnc1WA8tAdwLXj47B1p1N2zGPjq+rx2ErYDf3BvcycOqeF+WaA2P572GHhRTfmtXKHU4E/Ol2vbyeP7KtgZCOgL3uc7nV58TwL9cDgvA7ga/XQ+sIPQ95dTfQoi3Rru6OMWI/D3SYT1iiTL1MA/HxuPQFEmt33oIjQevHLCdfeLv857A9t2l+5GG7Q3P4ZRAjrQ3n1S/D6Wi7czMuiURBegx/5CCoQ/zmCs7BzvKDQJKAMQrM1NGEQNfvxrm12sjXY95d/YZyefl29EEcdY/p8JWT7P7Lw5xciv97cT0s3aPsYVThcWj+NhWUmsGtx6zKqepM6TRfPz2+HmSY1nFzj9fDTdH9oL9bPD7Ov4yM/lvT6cgDUBb13+rcSIN1IdnDnyA3rApJyQ57+KS2pmmafLAdwDszwk4zgCMyxnUPQM8ZN/4AuNWQLH06dv6bwXn6Dod9XK7XLvATZHs9eEhH53vtGNQ0GV8PHdZxOV/1IU2Q9fXQ4QyWG/SRHCc3HAr6mFyvOu6ObK/jQz8iv6uOfOwMr4cOZIQcr/qA9md5HX8gRyZJ1QcxRZrUQwc0KFGnPozxUnUeCv74ZJ36SPam6xxlGEK6/rBa3VLwN8eBDxAIlQ7GNICt7k5+nS9vaY+Ng+vV6m6+1WPf2dXmqwN6yq8wEuxNbxNC3iQEGAn1Jg/A+MB3F/Jfn4g6TSb4Qdpuq/mYCuLn2/mXnme2judV3dOxZ117tkYLie5APjAe+mCg38Z766+GmzYOhns//+N2Nb95u7j7sv46CujU423d44stkSQJ9VVmO4aRehukyh4MmS1Yo+BtupoM7Jquqterp742pg7A6O46dfdii+TXIYpQxwjGK3ixD752v1zw4hwwitRyzFuFXHpDl8U2HPgG2wuixwzW07IH6Ac+ncaAet90NRXYISdDC+bgM+EZgDVd/mFxszz61dfc+LqzKQD/i/7mh4Ei0A5a9NQWgcaDesxVtoP06FvsGaCP/+qpPLchchcjgROHJVGVYf4cQDPfbfsfT1ulwDkiYZWYmM5Qu6e+eqMGs+5mGpCLuxsErR2Pc6uniaAOy6ClYj0id9ZhYPtrXBrOgerWYRDvHxa/LldPj6ejLYG6x8mXwuAQcA31cZHfnYC7DtNBzm/ZeswDNUkPFOa2+vx58Ixq0Xq/cJ/HTezz/pkTPslHQf5t/vt8092EoEed7xRD+AJzTT4Y+pvRcN+kDqcG/sPD/NuGqTIW+s/U68N2r1MNYVAWZx338OzNh4K9Xy0fV3cj5BfQB8Ddj5VOYM+gWof44+rp4XpBNu1BsWBKB6PKxsO8LF2oXh3jY9HmqktSpuy8vctzdKLe6m5C0HDk9JbxOiA3nU0I+Nv89+W3p28jId70NiXk5d2YkJveJoSMwLKRANd9TQj3t/lyxJ236W1cyO1j+Ol2ffLwMP/j5/ntU//Tbqf1uHnavi3uHofIFwqmVzu9DZjR3UnqsunTw1HgNj2NCFV9899TUqFBDuJ2+1Ft+dTvsODmDmCv0OURQc3KhHWBX930JMB0YuaepoM6wBPfhXUsV/zh4IdYhnTsg61Dh0I9wr3dhXls//bhgxnoFegayDG+gcNBD/Uad6Ee1W18+DAG+Y27xjCe43jvANSb5/Xq9unboHFwyzFvG/x3HJJXqY9xJjJNTre5+XpJL+77ZX9LncTddPbLcqCd7nnAZFo4eoLrTqYAuP7j/mh8qY+XWADDBc1twEcKmS2Ie7b5x+uvi2/z4YC5/Z9ny2/haW/8/pOYpmeSxbkNtbVER4CqvvXTu5sjlIpN61FJp8s7SnwzXKUQsF6hw6MViq2pGltGbwEeVUJ/HvhQ+VziPko6fx7m0bK5xDuNZP78QI6Sy+UgjpfKnwd8nEwuEU8gkR8wBDIsXqx+G2WDorMH7myqOT9CgZBwx1YfVPDifkklTO8oV8Ag/7/SwZi3zJfVw/L2tqeo04Xp1aa3Y2dYm7cuaf2Pb7+sbt8s4Yed943l6hwLd3uz3W3fNb5/EPpK6Z/NaqvhqB7F+/vb5eJmSEZ8iehV6kvJft9rIjE3nZy79eJL70i+FtKtbiYAebNYL67Xi5uT3jGgLaB1V/NhyQ4OAPv5YfXth8eeJ28LJ/Xy+XFIvPMBEFOgzzApuQU19XaEiHwA5K3aLx/mfeW3FuSt3u7nQngbDTLi/4aF5bUAc1/DA7QOhvsRcT2UrnUUxBwm9At3NwFoCIPnvZX8Flr0M1DFPwTm6vgTYb0a8zzovkUvFo/3VOr99epuvfi9p8je3c+f747VAI555cqJnPJyU8cy3l134FCOvfrUUYxyEx44gLEuRnUgo96TBw7o+PNRHcpIx+Whgzjq9NQHcPxh2g1ePVsHsR93mo56gj6tVz/cPj1+HVD0ow2KcoitPlN3QwuB7s7RvjRcPa1eCtjByfsPBtmbH9aBchgz7DCYTOAdYzq3epoE6s3i8+LhYXHT3/ymoK07G2h+Owjw4vNnYrf/ujgZd5c1/b7MdlvefaHye0fD3vQzCUwqhbi8+zLOFKfOJp3X9BujLOfU15SrGdZqrOIR5hedfW46mxjwD/Pl7dPDqLA/b7qcDjwtjQ91oZZxwNMKud/qclrwH9fzL2Mif6z7mw725cP87nHOQUmvV9++LddjXI7oer3V9fVW12MNRpU3j/CaTJEruL4Jj8WzETUHT+DzSXaPvLR1vMfdKYeCHlpKbB/y5d0RxcP6wR96KerAj7oWn4NMoRtD82tqoKm/o9JqHg77I2yzo6FmU++EoKGxYX0MTAWqIEefWCXHJL8+CP5Ns0rGg79ZKZPDX9xd964BoCBuupkE5O1qkMq9g5D7mATet/nvZIUbQj5SgH6b/079KsyjESFvSS194/MUyLK3sSCrss/HxWN/8stO01Fln+t/3q1+u13cfFncIKb+GPPyNr5X2z0jrv5o23I9c/uyMFDI1vLfi8Hi0c4QkIeBIraW/14cJyE9B324/XUXcNPPJDAbHW3cldJ0O9UyObYqloJ5aEWsgyCmCbmkS/T4RZF6W9e9TQ/5zeJ2PYh+1o37JnU5CXjkcR9nspG9fdKZ3oAdZ5o3iF9ojgH7b8v1V1iRrgfkN39+FL8t11/vd7qfeFDTjeeFhkK60g8r0ruaW/voQVCfn1cP890+p4SfdN6xkN803Y0GWoqEpJ2e3X1eDaRiieZjiobX8/v5L8vb5XrZ/1zUYL0SHfafUjlXnRr50+N68dA3BqELNfc2KAjhcMirb/cPvFiopt31OMg3nV6nTl9iAG8Xvy5662TPDeA2dTrZABb3q+veyq+Kuu5pMqgDgrE6oI4VjXUw9P7hWB3IB8ZjHQyUWo50gNDfTXt6HBE91oF57PCxg4fysOp/e6sjSB1NNudsV/7b/Pb2fH63eqSkc31zlHcg555/m9/e3u30PNlQhobAdeAfNQbu4EEMiinrGMF4QWUHw//36m6s84a6Gv28kXIrAqkGpOvaajhq1tu+UydgvBo2YVuz0LW3+rPUJbTUxTjgxHtMJX/BGX2zWM+XfasLKh2Mq4P0zrrehWhw6nVtjvb5AvsXpe6GfIvzf5Bnqgfs/lJZJ+KBclkPsA+L9cMf/ZX8TsTb/U0Im2/1i97iTSdu7nCgmNMb+H+v7sYF/m/ucErgA0vEdwM/pkJ8b+CDDELPYB8eo9cDfvL5f/jau9BiJ/rU5f3XYdUW+4B/+ON88ft6WBWFbvwPf9wtfl8fUUmhxxCe+hZx7YT99DDE5LIfqhRAnpY3A8TIptmYwsbX5Zee2vMujFepg94T1sxBJ3/kt6NwcfsxYIm319S0fX1ENYyOTkYVI0mOGshv2gePBbRjSE5dM7g/MmaSofB3LziYunb9p4e+WsfegdTdPj3cvsAgBlZl3zsCvTD7JPCPKGG+dwjdVcwnGUbvO28v+GH33vOQu07Pt8t/9rz/dpqOeVL+sqS8OJe90/21Ab3irgbm+9udmy6wT58/Lx4WNyff+hsvNcCpu/m3gYU3DwJdb+6j4W51NAnQh8X85g9KuXH8UkBXj6mrscDu+P/nNzcoqPN2+bhe3C16pltSIM9vblBW53bT4STAB9DRFLRD6WgHQbzvHTKsrdZh8cIHAXxYfFv9uhj3/XOfL7IE6K48Gm/qZDKARHB6Pb9FEbhRwBKv6XrT4STA14uHb8u7MU6w7Z5GgyrEAfIrPQzJSb7dckxh4OrqX3XnZ3f3T31vWInqFfr7DV8vU3+953J7kjpj8h+XX+hdQbJFMchjkW+6bOpL/j9mIvj9rfFtvAPN8IfAOyLzexvnsZnfDwE8IPN7G+jQzO+HAHy661s0vI0v9TESvO2TiZmM30M27oFyu9mRZ9KuoEd1hXofki00r9DPdd1Pr3nbmZEumH3eqIKu98s8DNR5L0uihuuuvy3xEGi/rFa3i/ndCC829TTdq71+eqBOPqwelz2p3m2wqa/7TV+jw71ZXC+/zW9HmNnU03QzuwP1sh9z4hm4Q0gUh0D+fLua968j0waLfqab2eXdGCCXdxNCfFg8Lo574XUPo0N7XD8s776MMIHc0XRzyPmtj4NYdzE6uHVP0kYb23oITeMgaLVQP8I7bvqa7jWvV/z8fHHcbb5escnzbjHJnV7D/Hk5Es5fl+MBbUm4yLFzv3rofwY1LceUc/vaBTUow8yCcjq6ALKX42iITTdTgPyyWL9ZfJ5TPdKBWVxU0F8W6xvu9qh0LgcOoqd9UEU8xDy4D15VmGAbhKe/rx/m0vlPnp9OkNstRs1n8GWx5QnqB+FV3bjXLO2M/YAEID0xDcj7cQiif/12PxQRNx0DkVxGb1dfvmwZGfbi4r89+sTdBfDX3+6/X97dfFys14fi2GkyDZwdb+whYKjB+FCSWtYHzKbJFHBwcSxXdz/Mr9fbVUKeRyVajg/uDeueu1bz53BtN5oA0uqJ8jyR2asnLNFwfGinyMH6ehPteTC2VsupwEk27mHAxonK1kD1vnBFozHv3EfICcNhvGo66Gu+PuSme+y7pBR4j61wsPHwPd0ceBOr2Lj1FLh+q72WR6D7bfHLY93HSBiVzfDjYvV1Ttkl+5xq243G359nnFb6w8MK//9l26X3HDKl7RQAl+slmVpxFb7bDpJ4Hp9sOgW8dfQ9EK2jnwDE/a++36JqWowP5u3q7osNsR+e7UaTQfrb6uHm8Etou9E0kAaIOLvNxod1vviyWi+JPjBc1NnTxxSAH9eLm2ZaeoDcaTcVsPOnb78sHoZA22o5ATiqCflw/3V+9+ZhvrwDIa3X0d/VwfhQ//q0ePiDltDtYt1nFYp24wO7WCSLYP/ZazedAh5ViUWeSTJr/zS/u7ntIfvqzaeCebH6jX6lRds7DKVoPRXI3eivw6CNEfqlAGKDJwLXXsvSo88BU9pOClDUl+sBj1tOBa6PDU80mgpSysLW10ykNZ4A4ufv59f/xE3+9LDo+1aVxlNARFo9ytzWB9qm0fiQLucPX3popvznE8Cofb2ftil8z6LZbjU+qO342173p2w4GbRLLUL8QHTbbScA+LS86ac6NC3GB7NNzuz1ImXDqaBdbLEJD8N0sfptKjAfr78uvs174uFGI0MacCqMeSS4rcn524er1+/ffnp3fnX59w+nDZZf5w9LJJftnKDdhmNai78/Oz+5+PtxUFJsYW+roujq6grRiV1A379/e3pyfixSpqNODPXvl0e+3FeDymD3Avn6p5OLI0Fef533dxP0Avnm5OiZvBkSJdQP5Onrs3cnb40tj4XKHkfuaHrANsRxAHNH0wPeskEfhRf9TAn3/afv3x69buHnfQmgVycXFyfHXgEM92qejIATgv7h7fuTyyPRgt89LcwfT9//dPLxpyOBfmG/2LRQz86Pnc8hWZT6Qfzw87Gbf3n/68T7/u378x+PBHm7GhCX3RvkKDueoL7EfifAx19Vt+zomhbqx5/eXxy7lR6/Mul5Sph/f/f9+7fH4hwUmdAP6OXZu9OPlyfvPhyJdb0VePwScK/OT87ffxwL9BUST08L/dOnszdH4n16GpC1txfIn08uRtBafp0/TKK4tBT9dx8uTj9+PHt/fvX6/ZvT131hi+ZjKv0XJ38bA82rh3n/THZqb/vf+39/vOy9OFW8/35cD1yizwIWbz9pLFfvTv7X1cfXJ321gVbzMd/+jpp5NKgjtdZ2p/vXwo7KORr4gZLBQPB99dj92IdpswdAby3pH04+vb28Ov2RtsLV959++OH04urD+/dvrz6e/XfvBb63s3FtruLHzs7PLs9O3l69vjh9c9ZTXNvb1aSoP55e/Hx6cXV2/sP7KxI03n+6vHrXU8Y4qMuRR/Hp4gQ2htd/ufrb6fcf37/+y+nl1YeL95fvX/eVQff3NS7uNEWvTz6cfH/29uyyp7bUaj7mAb51HY2Baruw05CjpN3r/lPwr59OL/5+9cPbkx97Lt8O+P8iEtRVu3jSRPD/+/15z8OuA/egnOuHAdYX87vTjx9PfhwGPrUddRmfvP7p9Ori9ONpz0NYgfTqen79dXE1KOeA0uUzrpOT89enPc8uHfPd9YAyar3hDrjnVLgPi5sBaY36wj39X6evr94M3WQ7iBe/L66vbo7bZoeB5jPt9OLifU/1VIPNBxpVkxikpQ4AfnH610+nH0dYIwz9YYFWk4O/OP1I4sz3J5evexraNewPIDNeocb4S0E/Pe+p2e4BPiTlZF/YWyLj8bi59MjV8u7zagrg+iX44eRy2FqhhiPLmOev3785O//x6sf3F2dv3570hCVaT4Tt03ktbp72XapaF+OiJCGStJrTC7LRvOt7y7WaT4Lu7eXJFZvcr96cvb48e9+fcbOvpykwbylaH96/7SnqqF1MgHLQrtluOQGm/qbKptm4aH46PXlzejHAMrPVcFxEZ+dDD+DtlhNhunjPH/5y1vdCVrsYU006ef2X8/d/e3v65se+B3A3tFdHFYHv7PoZtsnmSBh/QKk2/NV/YmAfPn3/9uzjTyOO5v7pl9vl49dJh9DaCsmU+f78/PT15dW79296nhxaD6NuhI9/P+/pReuE9Gr++Mdd/xLsXV3uXx/vf/hhLNirz59fCPSYkz3tXIuF/O7kx7OeyNFk3JuF/Cpg2Vy9OXt3eg7X4dvT8x/73n3d/UyOt6d1VuthfIzQ9a8u3v/t49WnDx9OL66+f/+p75Xd3c/4eF+fvn378Qo/0N9KoXQwAUImNZyfvDsdvELbfUyFk6fi8qQ3wVbtYnyUMAHWNonB86n1Mj7Wszen55dnP5zR2vr75emA/S57GB8jdugRr3y3/fj4Wur4AJWru5/x8WIajtvsrS7GR0n6MFEUGrPRwFnV+xkX7/uLDz+dnF+9uTg5O786/fn0/HKAFtnRyZji85uzj6/f/3x60VdN2Qft1c3y8ZqqOw7TVLq6fkaPpD8fdxSU3uNFh7BtHTu9+Hj28ZJa/nBy9vbTRc91vn9kG7X46n7x8IjaWesrKsr59DDILXb8gD+dn/x8cva2/3l+8Eif7ua/zpe3g8rIDx4ivbxRVyW9pJdclG+JTzPmAG4p2dwLDuDDxdk7ujmnWWD3D8tv84c//jOL6+L08uLvZ33DS/aO52GxfvhjSBnAwYP4+Prk/Gr0bfJ4Pb+7eum98vHy5OJy3GGs5w/riYegSy78Rq4+kih/3pfXovcxroS1IYwxW2iwG21PR+MivjitTVZDBUKth1GNqZeXp+8+XA7ajp3YXs3X68W3+/UR21Ht+xl+EzcYbxQpb9mLDWBMabB7VOOLgkcOdfA1fdgYj7ynBwwuHYKkZI01IN5IV6RivdAgjhaiugczkgQ1YFBNk/EOiYfFSx8TTZPecuABoxgoCB42jNbtSHfvu5OPfxl8k6tdjHuHfzw9JyIEm2Jfn1ye/vi+L0q1i1FtOieXJ1dv33/sabrtxkXpUuZXt6vHQVR6vd9nboSNxfPHk56BvvvGsURJCTpuvswHxf0OGczZ+eXpxfnJ2yFM4D2DQZWhu/ntcE7wkMGcv7+8+tvF2QDD+56h3K3WV1RzeujhP2QgH04uPp6O/Eru5w+Pi5d9H3Wg09XPZ+/fnlz2jv7ZN5qH1Xp1vbq9+nW5up0PqX06dFAfX/90+u7k6t3Zx3f9/aN7RvSIVHJX35aP34byuQcN5/T1p4uzy0GhAPtGs7h+eliujwgKGDKYT+dE1BpvlT3dETfrxZYWnV1jb3s6uqbf9vskkQ/v3569PkYO4Q5GtSV8f3JOATs9ZesuVK/mv8zvKGpnkGCt9fq8bfXsyEtuGz4ZVpej3G/94F+9v/ypr+b5/CCuVuuvw5TP/kO5PL14d3Z+0tME2TmG9eLh2/JuPijC7iDwrX2K4JiL9/2XUtNwzH2ZVPnjsNRa+7BJ3PR1mNXhNQkBn3qrATrkq2u6+p8GSv+HQr84/fD27HXPMIQW5IfF/e3yej4t1I+XJ+dvTt72jq1soX1cz+9u5rcDIyv3ApZ76oer709e/wX0jU8Xp8OuP7WPMXfaECGjG9Sr4fKF3ukzctJJ37ipfdh/mw+LET4Uent9wKY8IEfDTtNR5aEPH3rHdbbBvJrf3w8M6dztbP/bf3f6rr9dqw322+LbauAl0QPsh9OLs/dv+tK7Fbj3i4fl6mY5iJX+DGC5QC9PLj/1tc+hzbi5FihlQW9P3xaQlK1goFsvdTShKXAL6Qi2v4PgbnxZR2DdeKwmA3qUVXIL6whmyEPgvj17d3Z5dfq/Xp+evjlmwd4uvy3XV4vfrxeLmwlX7RFm0i2wR9tFD4H6/oiFuppufQ63z27hO9YgewjQ44yVW2DHsE4eBPgYc+Q23uPtj4fBHZhdYhfrUVklDgI63LS4BfRYW2IHUCF/XJ5c/Ng3wRK3GVUkPu8pYm5BeDW/GyRYpi4OMjwMB3eEeeQggIPMC9sAj7AsdAEUS+zThx8vTt6k/TCA69ZuP+rS+3T5E9ESXw9wmXUgezV/Wn9d3K2X14N9ZUrPzyUdq9O9Dbx/usZyPb9PheGOuo96D+iny0vicfx/A1g5XUP5ul7fXz0s/vdgVk7vQbz/cPLXTz3Fvi70q/v5v54GCX+9YZPpbeS5f1jdLl527lPK0nHgr7mQ3csAvzg5//ihd6mATugP87vH+4FVA3qD/5kIqu/PRz6EfiVC6upu8hOo4+aqs99++Onk48DdvNPFn+3+aoMb8Qrb7fwgrvZoo0jsxQnhixWTln+/AaRG4/ITEdvKUe2b7PRvT3/uG8HR3c8UeM/Ox8Gr9XMk3rhZpvObm7/+dv+GbXMn1//82+KXjxTMtv6QiFIN5M9PdzA2dkJ+tqvxUP+y/LK8W1+uLn9bPXJR+G+Lu/X3f6wXj4fj3dPJmEhTwciDQdHfj/j7qz6vkP56xN/m0o6H/vYf62ML6279dip8cuBv01+P+Nuru+t539W41WhUJHRp/PW3+/PVzeI1/2UfSK3WU2E7/UJ52Ydi49ZTYTu7Owpcaj4VOi4XPxQct54K26eb+6HAPt3cT4jquElrOpgKYXN1DkXYdDAiwofFfL34/unz514Tt9VqbCwkbczX87erx0d+HafJwtwLm97LBFgHHcHtxlMhS9FRq7sf5tfrVR+ZZX8/E+HtvYPbjSdCNuR4UdtPgK8WxX+uox+O2zj7upsA/XFgJ8R2uWWY6gWraTgeolQG/EAY9Ncj/na74NrzCJo2o+PYrp12KA4b4ug4tsqgHQoj+lFRrG5oB7xe3VEk/Ond9eqGw2APB6X2MAFGFuXfLR4f51/6rGO1/QT4fnigL/rjQrsJ8CTt4mLxeL+6exyCTPQwHcaP4CucMV1hIMpNHxPiRMHfNw237s3idj0/ArLW3QTof54/LPvIlqLh2Ig+rT/3uwVSm3FxcL23v/52/9+P65sBe7fdfkx8n+dPt+td0eSn+d3NbR/ZcV8vY2J9XH65m68XN5dbJaYPhthqPCKy1RMzCQ8Fg78f+/dPUnn4XiDQaDwki7u0oU+urxf3A675jg4mQPj98u6mh0Ftt90EeF7XBer6AuKGUyCqa9D1RoSGEyDaeHY+rG5ve56l+3qZAOtQdFPh+XH1sLy97SFEyJYTYEriyevVt2/L9dAJa3cyHdIjMU6MTop5R6JVu5sA/V+pjuFFU8awL9rt5hOg6yvTioZjI+on027ajIfj8y1lHryjO7qfxCEajoloNV872wcJGoyMoI+NJTUYD8GXxerr/PHr4QhSg/EQLO96vQP8+ai/3mf+8ecj/vr9r31+/P7XMX/7cSz6zHM9jYf5dtVHKaC/Hve3e5llU4NxEfQ8O5sm46FAENeOX+/16u7zssd76ephPIz/+u3+DRsXtiwLH1a3y+sec7enk1GRJjn54/LfPYSu3Xaj4tka7ev5evGll0dVbz8qPhaQek/Xptl4aB4W85sBst5Os4nQnD99+6WPMU5pPCay9cMface/f7j/Or/7eLvqNWFa+/HwUX76Vvc9rDx6+xHxfe3locWfj/jrUOh6/Dz+frzfX/e3267Ht9Y+rT+Xbxd3X9Y9hOVNmxFxPC1veiB4Wt6M99u/zh/60VNTg/EQICC3Nnf9tJj3IqsojcdH1vcy2G03DM//ZN8t724Wv3/3X//nuxSm891/fWdnblZ9l333ebm4vXn87r/+wVCz765X34gw/l323c3q+gkf/yf92c8Loj7RH/Nf/7/5d9k/8izkMxuLzGS2nMXg/ud/sn/UveAvbOZn0WQuczObm8xnYZZXMQuZnRlbZTHzszwPWZHZWZkXWZnFWeFcVmVhFkqD7moo6A/f4PfNd9k/TJ5FP6u83/ldU/9d09LstLTU0mTOzpwxOy1tq6Xdaemopc2KfFba3bG6Vku309JTS5eFMAuh3GnpWy39TstALb32mwHza2Yu2ixk+aysYhYzM4tlkRma4bwImYmZnVnnM1NkZlYWRWbKzM6iD5mp0gebU6uqzKzJzMznZWZtamVdZmY2LzLr6z8O9JNVkdmYuZmnb4rMz8qqymyZmVkVXWapZ1uWmctTK0c9W1oJQJw5l9lZ8DYjZGjjQuZmrqoyR4jL0meOVoX3MXOEmJ5XBM/b3ekKO9MVO19ubE103GlZfJf9w+dZ8LPC7i6ootWy2GlZUkuT+Wq2+4LKVrtyp11F7dTlVLVaVruLn/aCd1pTk7eXv9g5tB28uqSMsnd2N4+hHeGD2pgeuZD5mS995rEUjc08LY8ixMyXWZiZmPkqc7Mij1nI6ThwVRZM+pNAqyMYmwXHfxs8HSB5JTDtbktDey3omNob0+zuTEPbLUS1cXtvmt3NaWjxhUJtHNqNd5eqofUXSrVxe7Ga3dVqaAmGSm3cXq9md8EaWocxVxu3F63ZXbWGlmI0auP2ujW7C9fSUozqmrfthWt3F66lpRjVVW/bC9eKU5/WTFRXvVXO/d0VZmnNRHWF2fYKs7srzNKaieoKs+0VZndXmKU1E9UVZtsrzO6uMEtrJqorzLZXmN1dYZbWTFRXmG2vMLu7wogu+Y9CXWF4pBwTrdPh2UMhK0zmZlURs8JmYVbFIDDtLlxLS7HQl081HiafDrUiaEeX3d0PjlZ4oa4N194Pbnc/OFrhhbo2XHs/uN39QLb1fxSlJkC59n5wQhCiFV7QGGeVE7/sIJY4iA88k1WRlSQIFDFkJc2gDTEr6ZW5PGYlCYdVsFlJM1eVNitDFme2zMrIXwgou7vL+c4b37V3l9vdXY72S1lk3sxCKcbR3l1ud3c52i9lmYU483FXqHPt3eV2d5ej/VJWmY8z48Ju4/bucru7y9HCrnJ1+tvnt9vdBo5WYGUyH2Zlbncbt89vt7tePa3AympSkm+vV7+7Xj2twMqpjdvr1e+uV08rsPKqCN1er353vXpaM1VQG7fPby9kd1ozpLS03zM/KjI3C0WVVSUJwt5lFekv3sbM5HnmZjFWmclNRhNOn7A7Cp+Z3GV+ZtLW3fz87hr10AByr/5+e5H63UXqIRDnIfPlzO9uD99epH53kfoCjWMWylkod9cKnnmS1R0J93mRjkhD6sPMVCEzeZX5mSMlxNA0lLnPDO3UmTX0iXRDaCYG6mFwmTF0ijorznG/u/p9iR0fVFRljcoULqFysQQqM6sqD1R8aTAqHwqgsrPKZsZAI7UEippG6sSU6V4wBk1puJaahlwC3d1pvoK+qIpKeNYcj8ZaPvKMpfvEO5cZS3NRkEpmQxZmRagyY2PzHUnVIfjM2JLmtqC2FToRmHY3cMg7j8vQ3sBhdwMHKN0uz7wlFLut2zs47O7gAMXbmSzYWSiELtvewmF3Cwco385qF1Zo7+Gwu4cDK+Dq5R/a10QQOji2oNO18PYWDLtbMGALOlV6DO09GHb3YMAedKqIENo3RdjdKwF7xakyQmhfFWF3AQcsYKcKkKF9V4TdpRax1JwqQcb2Wou7ay1irXlVhIzttRZ311rEWvPq1ovttRZ311rEWtNV89hea3F3rUWsNV07j+21FnfXWmSDj7rWYnutRWH+wFrT9fOoWEB211rEWvPqWotFfWAVdFDidKrKgNMpzuh4xeHEX8EihO/o7MzpMvBFFmeRzk6PazJ6AWV34cay25DTXrdxd91GrFtfkXmyzMU42us27q7bAus2qCuvaK/bYnfdFli3QV15RXvdFrvrtsC6DerKK9rrtthdt4XrlOmL9rItdpdtgWUb1GVbtJdtsbtsCyzboC7bor1si91lW2DZ6iacor1sC2G5w7LVbTiFYrzbXWkFVppuxCnaS63YXWoFlppuxSnaS63YXWolLzX1iCzbS63cXWollppuxynbS63cXWollppuyCnbS63cXWoljkjdklO211q5u9ZKrDXdlFO211q5u9ZKrDXdllO211q5u9ZKrDXdmFO211q5u9ZKrLUYtT2GZ2Q5JzGSzeQuI1k0WjaAVyTDRagLJLnFsvlUQT02mSnIFl+R6lAYWL1dZgoyNlSuyEimdbOCHC6FhxIRMlOE5rvYfEcHMGTeArZzknaLqv67Mq+flgZGdyEylsK83X0Ul4qJe3d/lNgfpOy3j+KyvT/K3f1RYX+U6jrBM2dxv9ikdpB6BaOQTfNmZ5Ek44KupkDydYl7y7jMlKHWXWBnICWhLOoPZPixuZiXanf7Vdh+pbp58SzgVQY2foTcQPUjf0gHooreiw/kmaExGJOZytaDqUhXLKoyMxW5YshLRkqWm/kYBczdfV5hn1c28/ksxF11Cc/gjKt4hXpDJglyJuVpWaalVpH+Q1PJ6kdJGALpsBUcTFWVGfIJ8buo8C5ItSOv0MxHm9mcJoHULvID2hkdmRbT4QqX2Zz8QbkpMptTz578V3lIPds8pl+DJyp9VzZt4W2KRWYN/UYebGah0BXkhzK0eaL3mTWuxmJ88xTbVBoOq92zroJJi1RDMvUVuzPo0gzm1m6sgjRTpFqmN8ngaE9W5FMzJZaZySzN9oyue2vzZFm0Fhayosos9Uh/J8DtHqUVnY3W6lsExyzNe2AfqyOt1sL1VjJaR6o43oMnD2FsdF2YL4tQbxSS5OgtuVmgeYQ7MFA7G2D9CJmF/EdeP2uxEgoJfPcUr0LnsVK1D/Fq9xCvIkatXrt4Bkk1uMCjpNdvq7TjrCPlHSejw+TQYsNBQvZb63DG0hAclgktV+pnhiUH76SLMbNwTxalMN5Uu9dFVXQPsi2ZVLsHb1V2Wier9sFbCedi1WmdrBT3ovQv5p3mSX6223zru9TedBoZ+ZlsL7yMue20M/Iz2V54BHPXaWrkZ7K9cArmvtPayM9ke+EXzEOnwZGfyfbCNZjHTptjejay0ZF73UZQdJsd+aHsQPgY87LT8sjPZHvhZsyrbuMjP3wZ6yP/1hYwk3fbH/nhf8wAaVquftpXVrfg8MP67qLTLMbI10NFNA3cCgVdUzjtSxyYFTvaMktUiZknYohhD5j1tr64PC0xLy2RRlIJTDcTxxhlk0u3Pzz51nt1l2uef+n6hzff+qAeU5r3X7r/4dG3XtUG+GGAPlAW7OYqAr9YP8u9zahhmsOCV15mYRyBlOCr+ruQ83RmNvBcS1Ti8ABVwOpGBKNRCyS3AHQBq5sCjEYvkPwCUAasbg3gh5gXF0qcR25WVi6zgUSOirhHgTSZSDMTSCT3JIKEMhmQbKCZsVaSQiRPAdQDG3WOGB6CUBRKxyJvUSZpgZY0rX7D6ldOHKgiD2kP57FM8h2OWLJ3zQykXl8zo1iuJbGC5UvaU2Bb5dZAOjK8YWhb0WYhYlR6GOlnC2pAPKpZRYJxhLBFRwRtvPRdBG+KaFgQtkpqUTZPIRIbm9kiT1Q7C30yp19ivYgE4QIiMUk4RfoNOafi7AMjg1TM4GZyxykEDiMYHAakDFvoG0YhcRjB4jAgZtii0DtQDg3B5DAgZ9hCPxH5YZmW4cbaidddunovYo0WdBQWJA+Web0yoU9j3ZZ035bBSzTiBALbw5Yui0SPE4KWwg4xgh5iwPiwpdfnI51ABtrG1ih8baDlUwafymTHtWWsh1gWbKnNSKrHECUacfKAQmJ1lZgfNtyK5jysD7XMQv+lG9tWJpEobAXbRyBOoUuHJYll/AGUQVJFKz5IJTpxrIGjYiudrmUbfyb0MeKrzYh4WNGhFGj3VjQHtPtJ/yxmLm+9XHEKMtMl13lWtvFUQu3F75G05HKTFTMffeZy6H90QeY09jwQodJncVZ6ee8LqothQkvewdOrtn+aOBgRTAyXF80nnG/EvczJqQgbgIGKKi8fQWgx4Kg4o/PD8JA1xtxv9hf9ZJhFklHzovlUgkRSAQLfAQyBNDkH+c0FwQ0xgiFjQHqh96VtDzx0FjJYbUEKJGbhndOGoJsggkdLR7SflWWVlh3ZQ/gF2UJeRIJpY0CewZvTQNjtKXFs76kSGl+Dyel1AUI0EevBz3zlNvOQOcNCl8QiqZIOr0e/mZ0iMwm6jQGDhu42tQOfJEpb2xdg5gCPil9g5EPGsTXEEm56WNIqMFV6aEm2NFbuL0HeMeDjON2/zg95yhxdeIWBcY16tzC6uMxZl8hIDu4sb+gF1M52B3tGpN1gi8Rbd7ZUXOxGEIMMuD7O6scgHkL8IUGABQm67x3J+3lRJEE6kPkL94kPPnMuZ4IAqTvpQ/oriUUceq7bAmEUppERVCMD9lCXjKywjYygGxkwiJzueueHOAd8hPRlceyzUEiGX54g8p6TVEUCsYXtL31ybIcskjkm0rt0PqlLDow+0pQdrjR5Pwhuk/Hd3AijsJuMoDcZMJa6pkphOBlBcTJgLTlXZIFEz0p0oAg3guZkwFxyHeqewnQygupkfDebznhFHBFcJePDvilQDB+CrmTAQHKkZtIlKKdA0V0EZcmALOR03gA/pD1FVwrFQxAzgs5ZlvZqpV1ZfLAKhlBkFoZRuhIccf4hWzvsWFpoHlJNa1MKBpPx3S4V45U9JYhFBqSezmlWWNiCBWRA7OlaaQoRyAgmkAG3x3VEEYTa+0F2aYSQkCjhDVQMmMlzWF3JUmVmZRmTakXHq0l6SpEcBZFEFA9HB52QPjafCrwbCiEpYagoM+dJ7yEKggt5/XcB75sovHwhOZ+RWucQ1uLobJ2RhOEC/UbEJ/jsqP9QNE/xG0ZazQTFyYQ9Jg2F42QEyckEt+fFKjQnI3hOBswl1xEYgIfQcEmBgu5rMmijeQr+oenEH5ClyUGfTXKaqVnPeelJeDezEGHLMPCYFfAe2eSsJM8Rbx4otBWUVZjZC3TOkpWdEc+FFGAzw3EN3ZeaRdJavaNP8JvS+498ZbrMRfw6SUuRVG5fFpmLIQ3HQTOmoDIHzRhOC2jGnjygrBlTL9CM8R2wRxMyCAoARY4lMytNmTloxrmRgpWgiJnQ7c4wCkfMCJKYAe2LlB/NqhhircjBu9QYOR3ciHQLuoKWaoVBlMnI6YoqOZUcXL5kc3Jl0rZcWXs8XOk0q5LgoRkwy2g2fTGrKrmyFElCUNEMyGWkrmnmOoWMZgQbzYBf5khPVZRlPKzPdIdzgFcsSxTsuCEj0kZSIOMIW2IddN5Y0mQU6Sin8wRmH1eyZT1zVc7XgcQpDlcw2VxlVHk/NiqQCduvkgxJbkbHEdzReEMgT9GLrWzSjB3cwGR7wMaEpdCRn4EGKWAJQp0BRY7WhGbRj/WRncOHDhKD27iC2S5G5AG2gRFg7HSfu2QDI8cldvaMlPZk5SJ9KUDlp1kvTfrkYPByWLcx/RatYFb1aWmyl5dmnD3ODn5m8tnCwobr2cHPjF7gZ6anHn5m8h/DrIW362GP408u+Z597psWWDWkKeS1Tuzz5MuWUyqOe/AGKWBSjeFSzntBNDTRddvTFKahEVRDw1xD3blvFLKhEWxDA/6gz/X7QuEbGkE4NDHusWAplEMjOIeGiYUdobx4WEsScPdQFDGtyqoIG0GizHH6V3BH25nHp5hkAXZHk7RmIQsQ2ZqtpmhR5Om25+WJFrA6EWuFb6I8kBPWJInCG5ukAg9KQYg288anXkBdSJ8gR9AhDF8OyRuebhRqKydFnJVMfyxonc4q6UBUCJBGMCANOI3E5tAO21ifldh9mF6Sb2n4adfTFi/SfW8ruJvoyq0yA/8QsXpxx89CsoabGYQvg1BicqNj/0dr0rTSCrG8m8mQXdGbK8iyWIGGQwYfMDWCMzBlWD56ITzytV7/rk/hyjbz1jTfweLuXOZJ4kyffPMpNJ9i04Lwkf0RjpX0tKo/IXA5J8cbZoWuB8csrAzkfAhIHsHLeBa4W/lSxMVQcNiuHmyqUEuN4JYasEW9Tufmh0zqoGljh6NhxhnJrvWJXRTpjZXkIGDuB4K/Dbx8Lm2dUBno12bm+fo0MzIosghHzAryQZBvwWUkTqamFS+ZCJssf8cSGXGC8V7Jm+jTPUHzyyoWzWtVf/J5auq9aT7Z5ilWB9Gm2IFS2MxDQTBOiueCXWvAl+1g8hiFX2sEwdaAM+t1hrRROLZGkGxNsUfBVli2RtBsDYiznsjUynGrEG2NYNoacGe9168sPKyTMWAnlTGwUTKnrAKQjysK44coRHYruj7drKLX6zn+JPMc2E3HXUhWNwlKBjPvMVIpDF4jKLwGpFwy4kbyXUfRgXJcChavAS9Xp9oYhcZrBI/XgJnrOzymeIjZ9IXbMnLh5gm0xG3VGLRAXCu3TVtbBq0I1idNKvTWkiTEEFg69VBbi1xauQRp2DBrWJ9rhTVsBG3YlJwBoFAtNApx2AjmsAEXmCyx2oWvcIeNIA8b0IG7jBcKfdgI/rABI9h3KMkKg9gICrEpu9NMGIVDbASJ2DCLmLwZyjlUKgteUHJNyd6jXD0GFFauEbRcU7LhVddR8NDB1+1ZPjDwKtPBn4iiRQhsKaCzHjcimc4gmYXoWTILtiLJLOdbKIewVvDNRJd/CdqLIUM/9gI9LXHG5HCvkAiRz0pkIcElDGUCggjdSBArKJiEhYlICoHFdUUMawgnFTF6IN7TzUXivYGRn0WIAFEHu5F2PqwKpJx4WBUq2luwKviKvsNdR0pC8rfTd2Xzqao/MX+bfoPtWPjONt85nkJfsGxC2TECpBlSq2P9Y9DlSd3zuKfpXCFDBbqT71UcRNUeG3alSBiC02xAHvZlnoWKpl50wEcHdCiaELAbSpLCIC/ZdIA5uH8scryQ/8TiZDLwmEEo8UkmIEGRTjI7K4n3AiY7deKhFOK7EjZAoqXCo04/6+E6JpkLZ2L6Dix4smmUsflUJA3AQ5e3pXQoCaa0Ae+3y8RRKUebIAobcHO7TByVcrQJMq+puhMFmUo52QSn1jCpVqfbG4VWawSv1jB5liwe2vuHrgdLShFZsLOziEuIJh3OyIIuK4sXRzc/JU+A35ikgapOn+Arq/IJBHvWMH22Q71VCLRGMGgNSLHkKI0FEe1EB3Bt4bQpM5fOH1YurUvEYWvY2EnKW1XUZxTIQaFy6dxC8iNsAXIf0xbIWY9kVSFUSWqOtO092ywtWDqGCel8gpFXD7ZIy1Y8Aw49mYGIp22gZJLFpUjai0+3vgH10WNLIQoPJwlsg2gbSp779B14SiUJbGTFga/TJwttmdH5U39irY4+VQlyyFkPDBnCGWB+D5iMPFK0nUu/RsZZM4uU4ic0TeufCDk0CJIgcBzwp6p+avK6E9wOrXxPRhCdDbjLQc/LxQ+hs1JkAuwEJb0FaOShFrgQz4CrIDhmetAZZnFRkOgboWu6WZXXchaFPXq2HxILAEIwsWQC667OZQF0UiK1BcO5rei7WEuBcIDzUxgayS6LcAAbpH1RkLMtyNbB5pq0yw9hrSNjB1T7kn1dECqTsZ8Xd15meN30J3wrlxUzYkmH9LU6X4R0F5PlzeXgxVXpUiYOIy9uU6WD3pmax0YagcUygksG6wQZvmK64vlyphngdUwxGnznxSotQDo4AnwTmCLLWjhZdmGhJ3c9bv1Itg7IKoUnF1NMXqfAhghaqMhPVhKXxjLxj9K38MVVZMHVYSkBck00MnOL4LhbcNaDc5oIlR5iY1hOXFbkdWiNT5YVqOmNTs520oL0hNLW9ypbOMnBAWsGaevk+OKXwjMKkxLPGZ3EWGHgLpii1pODSXp3sLW2Hzi4ga4qh9VB0bIuJsdncMlkEFxZf6hSAFuAdo6WjXZOmdzqT44/yckT2ZhA2A+6/5AfbkKMt2+cHHtl6+oJPiT2XQCdlkhrAcHGtMxr5olEI9I7gf5PUqWKRsnwJOIFLPj/QY8ZTg8Tz7rhV28FBIH4SnYoB+WwSGdMRaZDuJtpssFTp3cDnZq+CZavU4lNZJBCbEEITj8ulCRSIhjBIjSA4hTVhQ6/VfIdMo+WuT/5rKCbATcGWUx4pZMgzjFjZIXj6KwAQhdsg7A2+Nrlh5UOFx1cvNSC1zxxkz2siRVoI/AX0EEBQ1xBOZg4jWAs08otsEDgG4hp4dLaDgHvhY5hbL+yiBm8/NBPQmAzGX1XJXgBFmG0hRMDP4vTriJSAbQIsoYEZu3i72DsIpkKqzZv7Q2ReAuRFCGqHFd+SHvZzUrP4ySOZkaG2ABOWIiJnpiFglhDZesgE7m6EHmBv20rk+khndeuVN4vblQyhkK+KKokG9EBy9d3VQe14izLmfIcU3Qf1oBLrmO+RuB9b96KhTyEgAPISBWpEzAZx1hsL5Ak/Nh0FNLPsuBEzDK+DfBucRvQdOEKwGUQmtVTNMsChi4sAVCkKWYD8Yfp76Cz0YfYNCjSog1FqdmCrQhvsYgqoQAeRVWweUOLopAMdnMgdqdMGqzB4R5znwztWPgcY0zQsTJxb7MrzbnkyTC1wgUpPMRkImaFC4nNELFCISehRIgJScJQwspgs1Da1HGAElbSVYtYUNoZ0MH4q8if5BwIQQbRKiTqaScTHlresQVLLhSOyM4JV/LKIzcAx/P5kHRPCivfmoCCI0FL8Pksu/RtPUY+TQrHXkCiwtukLJKFHPGNM8oiEkwdMhjwlGelqj9VfCPGLCDcM8jgKivCciyH5eh2aKsk4bQidMYiFCZUeiJFJXbGitgZi1CYjiy4SuiMFaEzFpEwHXZoq4TOWBE6Yzk6purI5hjYYsoxFs8YRT0M1uSyb8yjgZ3jRRaYvk3brMJBaei7OrIqIHguSAKvFQE11nSb+6wST2NFPI1NyTpVz7tV4mmsiKexKWGnfkIrKTutiIWxHO6im0ytkrbTyrydnLgzV7m2VkvdKXN3cvLOXF+xHBZSv2/H8SmJdQACDgLfCnImNW+bPP2ekhdsLN/Qr4hYsfWOI7jSRO2g0xJWdolUJgrdkyBaTRUqthZiPLretpYuVOYL5YShedCMFlZLGSpzhnLS0I7loqUNlXlDOXForucstbWTSLAmoT7TbbThT9LLnMVEC7MzSFtQpMlzsUXexQVTlSaLZNxIb6vkTxKq2Fu223VktTylMlGp5a1V6XOlbC2ZVdTu21pW2VoiYsIiZiEaXXdwTTwpherg0uW0Cyn1A9IuuFmFe7COXPeIBchJIIeyQJ9ga0WeCYoo4Wh25DiljBMCoYiisK7bZWSVTKNWBEBYBCHo3jWrJBu1ImjBIgah6x0pQQtWBC1YRA5Eo182eFiHvzbxSZz2gByliT9Ccw+2Flnyo/EpEBa8QXCkKDKj/q5oPpVaDgcrQhms62YPWiUTqRURBxasfzKBqAOMtQ2scnZrhJto6JgCUTl5CtkQDOy4JBE2uSw2mSlYDfdZRKCGMjqxR/fEIFglBsGKGASLkIIObiQ/fFlupBUxDpZjHHS7v1WyqloRemARStBBbbRK7IEVsQcWoQQdnEOrxB5YEXtgOfZAZwfyw0by3lJDoOzA7wNGR5nSZrB9me/qnFzVESRsUzt+SMV2II2Q7s58vyoy34/7I74fS9fM94PcDrsseqlq2Zt4fPWnmFQi5oWQPB75svFEDLCqSiIiKCwCIjo8DlaJoLAigsJyBIXO3LJKCIUVIRQWEREdzC2rhFBYEUJhERER9Yww/LBmeGzyjzJDkphQpkoRU/Az4C0HzmlN0YGwyZWwJYWUqDTayE8lLHEI+D2HgFcOAREzYf0ejodVgiasCJqwvpvjYZWYCStiJmzg3PUdudhzNelMky6GwxVystYgWbmFEd7Dmhlhcq4/xXSxB6QHqwzNOnROij9lokhLKBLRGTbsubCVRK1WhDHYsOfCVsIYrAhjsIhKIAuYdhrhIQwywSiR92xRarxsFKuxCZmHQ60iXR2hHhQwi2hJ9MWUABL9k7GIvoOPC8Ecnt1Otc+Mgiwqk2Ln2QhekOcVTLeiMXmXpE3AqE0R84H5AuTyiSHFzgfmAdB3fBx6l0X2KxibIS48fbIpPj/CNU7WxMgycJlFF9JPRGaqe2lDEJEeNnQzyayS0taKgAUbunOfWyVgwYqABZS8/EfUuYj8sM5/BuoTmUaa/cHUJ6Qg2KRbikyCqq3fxOOHZXhWYDr5WSRj2ywnwwNykcRSwhTHTthz7ChRC1ZELVgEIUSv61JK1IIVUQuWoxb0iDarZNG1IpzAgscfvX61K4l0rSD+27jnNFBS6VrBcreRS2dYdQoUlrsVLHcL1nqk2LhqFp3sQLlUBc3dxj2JWqxCc7eC5m7BWo96qhir0NytoLlbsNapklGgYgWyA62YhliHYHjDIanIsFFZiIISbiNb6YOWfNsqlHArKOEWbGQ6CdVyIFWSC6gW0yb4vADpxtbZfZCgDtl9ELoNaQC5MMuUCyP6lBVDYhFruuimKVmFCG0FEdqC2Bs7/G1Kml0rmMAWxN4Y9E2lMIGtYALbgkvC6PYsPGSeuo0poo5Wb0zkNVx2lFyLBa+cLPdwlyCijXV7cpfA713i4vMgoXgE0xOZwScfO91xBfteinTbEcPcIdKFymq4vPa2OTj22UsMvzLZb3A95WCzu+STjgEHNhkN4CugDM/wyFDkQ2RHfZXFUKTxRQRH8t9X9XdwN9AvxGjqp7g8+bv0C3KSxb4vuHSO7iXGw40Ii1krXZ09iNwcDf04EZQoEXpEuhEivcSY6ozAxwKdkZxK7fh+K0jYFpzq2FFYRyFhW0HCtuA/k6lTXX0p2C+HlYxDW3ydXyg2AS02ecZJkqmjrCob0xqoiDOANUChjMy7o8gLjq2gyHhn66cB0U5w3CD9FFlNEQpBtCK6eBzLLwXnHKNPpvlkm08uGWYj56ZFi5B+LCI3bchbL1zWHNpzWytEcCuI4LYo91xVChHcCiK4BbE76vmFrMIEt4IJbst8z12n5HS2gp9tyz1ZBKxC0LaCoG1Lu+euUwjaVhC0ben23HUKQdsKgrYF35oS76odKNe1IGjbkqteUQwy0ZVEB8oGEwRtW3LlKzWojB/Cs0hpSJr4dFvTRREQhF/ngOTkiPBl3IpYtw0Fj7cZQlAMDjhS3Gzz3SbqHHmA6TtiE3AoGxKnzihfc+TcUWTkLG2KZwenGZFmsfSJQxzhZSU3dISXlVsU/EnOithd5T4ZRKGdW0E7t2CR0wWiTquyvQTt3Ja8vXTFUMkHbQW/2YKvDP+A0gEectbc3G7ln2nSyW0lzuHaXohFqtInurNAG4pVzjlpBBhBlrZMllYqP9pK2aeCbWxBHo4odEmcedGBra2aFVljkfO5rDhUhIS0MoVaIa8cCBGU0pKXImxqnO0YkXMuRT6zjxvZbDwsaQUTVlg5i7wo6/VHbAlef+RNiMy0Jlc9ZzS2WeTc1PSBoyClAUiQoy24zrHSL/OUR5mrmBYp+nJTmLQuOirriLLdN7dM0iVJgXcomRQTAYXaQvChvWrAAYT+jYhNhAaCxo700JYJ7TWrguI+SfA17F6GX4tCInifU/Y9FqlATbP1UyY1kujkOekf2QNgGcgN3YQVGJcxixUT70wWq5jqpMaqSKgw7PQJQpVMN2YFd9xWewwCCnfcCu64BRW80NOVWYU7bgV33IK9XXT4iJk7DiMxzWCsM59u8qLC4YJ80nmZ0qQXSFtLxJKCd25Jq8dzwFPRkG5Bf4bXEDTzGee+1P2HgmJuwRgHN7NoMr5vQCtnoqCYW9CRiw73nZKn2Qr+sgW9l2Qz7VRWcjVbwQd2ed594TslW7MTTFYHcmYB9j6x2kUHSilBweZ04FMWun/NKfmanSBgOvApC7I1t52oTiFgOkHAdCA9dlxt/LBxgCHOFNlNTWC3V2Fi/aGoP5TJg1eYOuVtYVPKW8pHwAncC6sWnnSCgunAqCRqr1ZtUaFgOkHBdKAIFla9N/nhJoVckxIOAU44+UlXS59wkBP7vLB1utUCFnxS3wtbaHxZJxiKDgxCIlWpcDh7LNImmozKhQWiZBAuuphdRlkXAik38ldElUNQ5gqnCnFOSSHtBMfO5VzxU1XyXd7eWU4Q1Bx4W4VedMwplZadIHo58LYKnSPuFKKXE0QvB95W4fQXrxC9nCB6ORC3ClLBtA6UnSWYXg7ELbbtKB0oJTYF08uBTVU4VaVySplkJ+hXDnSqwumrTeFfOcG/cqBTEctP7UAptyn4Vw50qsLrK1HhXznBv3KgUxW6Ddcp/Csn+FcOdCqKbFDrtSorUfCvHFhOhV6VzCmZd52gRTnQnAqvr0SFF+UEL8qB5lR4fSVy5l3EyZhy2zFZpogS45j6X6YDDiUTIdhTnrDCx+SYLDi3LT5BAoiyfqygWzmwpwqvrw6FbuUE3cqBPVUEfXUodCsn6FaOGVW6MdIplZqdIEE5kJoK3RjpFBaUEywox/Wa9fh1foiabiSUMhkzrxonJ2fQz/HI1W7MnKM1abV4dllCBmYnDtJuoBYHwogRmlRwxl1bSKRiJ3EVZ71cFz8kuyjlBS6RFczN4PpH3qScSC1c/4AMngjQQvQosu4TH5evx5yCyUKZFiMC5MCcKmKeXLcF52KVlZOcLO+c6jvrL8bldTlNLqUSmY3lwCmiVLv4QaT7zUjF4LTAlDUFn8RPy+LQXB1ar3PFD/EmS9gtXJ0IK9ZsJnLTEbHCIM6BTf8RtYrIZsFvC/E7mCcI5jGqwpAsPM2Vp/Wa5vywDjfg2LSClxwUMXC485ILoFpwElLedUTU5CkMYysIoHIpqQ/nfqIWHLFINtWQlykeAYVhOBAErmAy2LCZpqq2Qz0istWRDZ/MIEz+R5zLDFWqXOI2FqwDUols6CY56r/jLKt4zgwyGxbwHdO/RaoBVmZFUUeYFExex3cpIEHOrazLjTNWL3LOD6FzEvWGAzcs5/3LkdmF1G8aKeaf9jO86DbFfZDh3KZMgFC3qUwWh/XTB2jCFAEKFZu2FSWeqZVtxPmBRMbh+HWGMOfZuUGKP5fXITpA7cnIydCMGCHb+C9sKFNeIFDROOSPmP8I8iAOFWUiMiktU5Xs4wExsZAFQ5FGzt4KfIp1jFDklUPCBizv+A7xeNQLm+Hy3MAMwmNjNZ5SKuPC4hYcuugMlIb0qcA6peCcIjTfxeZTPVcF5+ahRcSmAmrBWFrHjayFvq8YulYNXZZD56TJxJnPZ6aUHXAhaizYlH6d+HVs0nBIukUhmumUoiM9fSIKXRWM/G1xCTKFsVJN9/ywEQ+4DHJZ82uQIjRvcjlD66Gibg4HFwVjcbggQgVxwCOLMpLMe5kzyMlC7W6P0dRptdplsXaQBbuUP61euyzYDrJgl/KHh83MbIhcGykpJDawS2ogPjXTwGogueZYDSQ3IKuBpmqd5OJ2AwmxSw30+UA1UFAbnTd71ECF2ugEtdF5u0cNVNIqO0EKdN7tUQMVUqATpEDn/R41UCEFOkEKdD7sUQMVUqATpEDHvL8ONVDJq+wEfc/5Yo8aqPD3nODvOV/uUQMV/p4T/D3nqz1qoELgc4LA50K+Rw1Ukh47QatzwexRAxVenRO8OhfsHjVQIdY5Qaxzwe1RA5X8wE6wxlzwe7Q4hTbmBG3MgQbWpcXh4ctocYKO5sD76tLilDLrThDFXCj2aHEKU8wJppgL5R4tTmGKOcEUcyB+IbTXk0tQdLCd33Y7Yz5HSLGHiIJmyN/DZnG+9ShAhCQdB29QBLXdlGwJJDdPgGTCZcWKImXMR3h/q5qEE9Q0B6ZZUeo6BqfBLTZhB3WgELL7uZ1ovjqzLrjbjCUggwyVQuMwFRj1y5gSpRXI0wu1A2lyZXJ/J0hwbg8JzikkOCdIcC52U2KdwoFzggPnQGnT6zE6hQLnBAXOgdGmV2R0CgPOCQacA6FNr8joFAKcEwQ4Bz6bXpHRKfw3J/hvDnQ2vSKjU+hvTtDfHNhsekVGp7DfnGC/OTDQ9IqMTqkJ7wRjzRUcq0w60CwUYgEolDUnKGsODDS9pGN6NnJJRyc4b46Ly+slHZ3CeXOC8+ZAz9JLOjol+aUTdC7HyS/1ko6upnO9QElHJwhdjqvX6yUd+eF/rKSjE9wxxzk69Rpb/NDV1+meMI9GXSICNt/DFHTgZrHkvExc8IaJ14XMnOYEb8vt4W05hbflBG/LFd2lbp1C23KCtuXAwuoIR3cKbcsJ2pYDC6uo9Nu/rHMqwTAGy0eBbNS02hBVEEPKcoFg15JZmHUJi1CHPSHGrDYCecvpP5Eeys4KOhsLl7LdRb4QyQ4JvgWpS1VNtaM/rT/55lNoPiF3DGW3BJmAP5XN0yqR88q8pvOVudHIeU4w09yezKFOIaY5QUxz+zKH8kPmABTIKIhIRLLZNUX1QOnkzBWI7cOnJs9cmdfEH2T1ULyYgufmQFvrSJbnFJ6bEzw3B9oaQj/bIUn8kOt/Iwbk+LR5Eo04xMCBKyk3Wzt1Hz+sAzLAvoh1hUNYepEEhRKgRA6+qCipekyW+zKvg5dKVGWjLAolDmM6vkqceVXwWWmMFp7kBDvPgZdGcqq3jb14g1QRJgSRzYGXRumr1aEq540gsjnw0soOwoJCZHOCyObASyuJsKAhUA4cQWRz4JKVeh01p2TqdIJ85kAnI2642kFKqMD0KYQb5VVdoN66FFtF9gC+p2D/BccbKxRpFIj35TgXQ4GCKJTXLnl2KJgKFco4q07BNd9CShADOlnDGCts3W+JC5D6RThn+oQQH2uzEhegTM/gBG3OgVZW6lXfnJKk0wkemqv2hHg6JUmnE0QrB+JUaXWnDh5y2vo85colDYf53K5iPjci0CA+UBawZAm3pk6zTlHKCDkD0w1XB6U74UIagfMZWQTLBc6Z2tD3iMZYWmaNFlnJhZujyUoo5WT5K9GWskYjRBdJ20pbNi2qugUi2CKK/Zj6qbPNJ6YNSrlAcMocKGIdiW6cwilzglPmQNeiHMDqXMfaWUS6LKYa7oSUvpAz1RebGEPEYCKHY4WaUCYLoOQGyseYSvZyZitKMFhX520CEreq+VquVRJSklm6RCKnFyTzEo5QyrPJZD8qI1UgRqIMFY7VuuM6IhFFWRFMWLqYCvaWUJ0NvSjHGSJbO0OcimCmEQB1rpRTUVDZHJhppV47zSlUNieobA7MNGI0qh0op6Kgsnkw0xCJ0D5WvUJl84LK5sFMI91J7aAtpHhBZfNgppV6YkKvUNm8oLJ5MNPgstU6aB8vXlDZPMhjJTkctCG0VX0v2GYe5DHa3SGf2SKKDkITyp/XqcV8nVqMdgyEV7hDmeFOC5wzi9X1IFK5xcSUZdeKtTuEZCx2kiua+4Pj/kloQHQ+kVr5GKOmnFutQr61+iopsVtxMfjmssDGpa+4vht9hZ0bKiMnJYhJwUGih4/zw5p9zE5OH3njm+hTblTkLsfs5PQv3M6opQ23s2ANc0ZEfEJmurzOb4e6GA2nOMVw0eGCWGSYxBC8XNK8VjVrmCmxNlWn20qhnRd1PDPdHx4+Rgq78YiFLnDzuJTOnJMTlunaoEhpSldXU5TL3CYAfPRjBhAJRsTkMtR06RJiAuWqLXHOkaWxDByRVMqXEMVLwAkV1MApn7dPKC8ogR4Mv1LP3O9TqkLKA2j4OiCaBma9KOvkq6R5VUjHu7mKk3s6XcV0nKdz3SOHBAfchXSuk4+Tz3W6bJj+QGIOn+sBmgh+MpbpNCcLDp/mPp3mTDYpI8oZl1nJ6TGkK9ILPqMHPbGMHadj+3j1gs/oQU8soyr2eoXP6AWf0YOeWOoBeV7hM3rBZ/SgJ5Y6K4UfYuOUrGdTDR8uS0TTXEdPcp5I4/mNxiRLtZIX46yi+5MTbTcpiEkA2KQgdpxHkI5bKOu+rEMcXZm4AsQD4TdOFuoyr5NJ8numTVrmZUoyDMsc1l3JyZMraEWpASwskIRjkbINl2CAkFmvBAME3yHSjoLcS65NSFuRqwjQV45HU/Jmz+kZNLtCzr24mcDsJBzq3Ls/3+aB3kBa5zPbCOpse/jiXuWUhXqhMX5oUq2vuuAYKBl1GbxUcCzFgcEEQ1VF+R4glFsVx3ip5nVScRSb5hDMWOtUSB+5VV1sU0msrh/G1yPixZooMZa7SSIvkdSIEJRcQzqnT1WKFysRa+acl3MiRAUTOiKYvELM9YKY68GzLUtV3PMKMdcLYq4Hz5bYf9qBphBzvSDmevBsyYDhyThnRAdtidULYq4HzxY6bTXzQipQeLle8HI9aLZlqcubCi/XC16uB822LPUTVeHlesHL9aDZdiSS5YdNxq8mSxZb13LieoEjQ6UouUK6haLpU6Kv7TzUMX0XEGJDB3SJjMNy2wnarwfdluq0BBJbCoHP8fpLSWXrRLdYvRQOYuD9Rf53yEcGGW8DJ5zdSn0b6iqSFodozflJmaaaDFKeLSH5dm5X9oNSXisOZIshKxFjGWNWIsSSsjmVZZHyTBEzS8kk5QWt2IMlXOp2en5YpwTnbChktzWw8foU8E8bPaJevUNqYV+leBVOF0T+BsqXiJRlJUIWKUylrDg/uIQndr7tzjjnFc6yF5xlDwpyhzHXK5xlLzjLHhTkjjJQXuEse8FZ9mAGdyRe9ErmRi+oxB5k3VJPossPXTKFFomQ2SRirLnPTUJGCw97bNQgWjgBN1SsthLalGTlbyWt8YI27F13egzvlENFcH89KLe6O8UrCRu9oOh6Ttioh2ryQ05gZLerp0tGgpIQ1OZlU+isnfxzk9N3k9GVnVKmlKKxIL56153P2CsZIr1gTnrH7nVdLlWok15QJ73rTqfklQyOXtAfPSdp7FjNTtlPgqfoQTtE9Kw2gmIrx2VTfbghgG95UUE0JtM6Z3ehE2mLCs6eA7/FCS9qp4yEJ3ar6y557xUSpBckSO94sxZaTgGvpFj0gqzoQSskkrE2P0xWBAGfkmhSIvQZyfrwwJNMX1Fc7Cy3LqvIs5o+2eaTo8jxQnicvKAyejATq1zfVwqV0QsqowczkfKfqR0o1ilBZfTedQl8Co/RCx6jBy2x0vMC88PmHEih42aH74PTMhRbu39rp1d1YmAOLiAbaIXrzdusqqN5M1RZQJL3yuQKR8kL6qT3THzRhVSFOukFddIzdbJSmWV+J5/iplDmhvCGu50sbOw4oVpKJKZzCpqKuOdkAaDokvorr6Sg8YKO6fdkU/QKG9MLNqZnNqYe9uwVNqYXbEzvq+48J15hY3rBxvRhT9SyV9iYXrAxPciVle5B44dcapOWJBvXaqtdRbpTXqZwkFSLjiJfyqa0DedAiEltzRtbPvHTITAaiu2AKYyUfzbv++Stp1opFXxolG2ogteLAl4qwwlMae1W/HdyjGK/g/9ZWdUizw9rn24TjQVXP+lUG5+uQymHEuRIlwTarUinOpYp7TJai1UKcc4qm7JVSqDiXAmd54rCSvWClepBMq30lKX8kFWXisUr0pkj9GiPYmSbGrQR5kyqD7VhuFdgs5DoX1mfthuylwaZuNgLsqsHyZSSKqm4lLNDsFJ9YNacGuvkFVaqF6xUD5IpMXbUDora/xWTzabxOTqq/uVSlTp2gNWVRCi2nX1alEqMY6bIuus5FZlLRmkk+nD1d5wdjKzKKXqKzjDD9bcRYUAWcOTf52ybgR1qqXBO2VRNoSMnecVsSPE0VDeOthIV9irq4KgQsoqdkc5nlcubTybxZypOAUIXQTKK0He++RSaT7H5VPAnOefibAQvF/Fn2pyXu9nCE11tI0BBYyWS2lZUHRh9EcozPKhQI2Mq/1S5ij9JWOLEBfm248jnJJMk0s0iqvMhlNCVWUXePOiFFbnl0ifLn+QPihM6JZ5U80B5JfGkF5xbDw5tR4YbfsjOnpJvTos4rSLlFGwSFaA4KnEukzhGtT4r8vCh1heu1nbGQS/4uz7a7vSJ/FBN/c4c6TKzPiV/YI87JeNlqAY3Ah0nDmFhHF3qgTW9Xx80O4kgCHsQfiu9tDg/pGJJM5uyUVF6FBatDHzeyDdL88MF4EquY+fJBpjKYpHzhstikQknoKA63UoB3gdL9pfIfjsWaNM9BiY+CTQV3HCOnvqq/g5+ONxyyLVnWheF4DH72B2i5hUesxc8Zh/36FkKj9kLHrNnHrMeKs0P2bKbtfY4JFpSdzbrg1V80lir4FJOEDpLAwp8gx9A1iYJSZzxcY8sp1CjvaBG+9hNnvQKNdoLarQH1VmnpnuFGu0FNdoXeSc13SvMaC+Y0Z7Zzyo13Su5PL3gNfvCdlLTvUJr9oLW7EFT1qnpXqE1e0Fr9mAT69R0XyhLWrCPPRi+OjXdK9kkvWAEe5BxdWZ5ejYys9wL+q9n+q/OLPcK/9cL/q8Hn1dnlnuF/+sF/9eDztvBLOeHL8Ms94JX7Mu8m1nOD/9jzHIv+L0efN0q6DcQHuKKJCIySznkCOYcqyi+tO/qrlxzdSMINcgKoV6whX3ZXYbJK1ksvWD3+tJ1n4oKudcLcq8Hvbbq4CooSSy94ON6TmKpBzR7JYmlFzRZX+7JOe0VmqwXNFkP1mvVwZZQaLJe0GR9oska9WhUaLJe0GQ9WK+kAyj8P6/QZL2gyXqwXquoK8B4CJIS6zrQZKGl+8S4JIK8SemR/XaxXfj6wd5hymVelwpE/F1EuD3Z8DnvgknVSkjtDKwTUQQdGH/k8C2gqZhoklZCAlYFscqQuISEJPzJJQNDxeVBK5OhuCV0pgpUKvLvI7VgalE231X1d2xTol6KugIvyj7Sd2I+BWvYMzFYL3HvlayVXtBvPdi0FK+n1Dfhhz7FjmyiTdioDCV1K2AftpBYbgXncxgg0SiIa8WaOmqPtELRvSD1enB0OyrweIXU6wWp14O5Sj5+daGB1Au2PtFvLEnNcBlyWQ8SCrkQKyVlpuLJMAZn8NCRUYYMTpEIuxKEODLAfqUK8yoIeOxADYERBFNJxgkNRGORTjMIGzbsjkGmifWCc+vBK63Kjj2HWoghJVZozDJ8eZFFnEMePCkrSJxD1PLNy0aRN7xYBKJiKyBBRF5JDU7QWz3YqrSDNAafQm/1gt7qwVbtIH57hd7qBb3Vg61adfjqqzpAl5KbNLWSUKbElUXNuEc5XDgI6RZHflBkyWbuZChTulYSVLZLJbmmBBKu8kjZofOUbzSyXmZpJusKrFVZl09CIvD0Xcle7QqVS6mOUoXuKP9NVXGNzdZL2D2PA/i2le4z5YdatNnGZoelSrxIriFG+hNHH1NyKE4QRYxGBL+Qc7iqnGIOD4L2G8DirXSHJT9EnA9eyWbjeCSANCTBQDImUxGRGtPGIdYSdi/J1rNQVRKEESBw+lUqE4MfNm5jhH7lJJr55CPmAloxr7meFJTtq1TuB3Wqme1UkQsMOaiQmAg22ooroZAwn7MBVyK1Aimstch81RKOgsJRDoKjHHKO5NRzvaaniF9IBXuLlDsICZu4fFg0qbAr3gmcwIYs60hmTCI2lw9DTWcu01sViTHF3dYCvQd5mKLoTM75Yqm2cw4LBmWvN3nOKwwfw+Zj3HwsNh9L/ignwIsJ4GQ4OTkBZpWTE4CwB9paZH1JhP49XqLNSWlyeLUc1BOEU6HOWo6sKS6PElYQsHA6k4dB0Tf4ac1p2BTjYU9IldgnNPUbDgNK6nq8NYO0/sRXyXH+O7j2oYMg31GOmtRUDtrkcHS0nMJB8IFDznojpVjV4CKRaOL7IaG6KRJuIh1zxSjca/Q3dNSlxAestpJ1kCUPsjAyc5uUDx4cWb2QegCm8gTesB4MVmJAlBTSVXg5jEIMg7VXq6bw5aeAj8BG6Ge23JCxNlUlXaN88jlPnnkiCHEtQkOe5qTb5Tg3yLpocs6x41sgSwGSVWRK8tNWJ4LCHg6CPRxABjY5XfvKMPGU6eI1HSRPu564unzqknGysumCcIavpjItM1L2UFmChG0mfuQUiJHSS3leg+RNbV6YC80L861LQpCXA7jIJtfzFfHTrUPKpqpiqDHGZLTk76jqK7yoI4GohrllqZzYdc1hxqTPhqyPipIs48a64njpMo/LmjJmYZ/TisSLr5oDDFlTkFItd6Y54VLROGrGnnUrbyjBvg5MsFbNLEFJJhsEgzhw1fCc8hC1ddr0dGM8NzV/15FjENkC+f2WqKSAg47spCZ3oVnNri5FmruiWe2uzsZs6O/jzEl9IAiyb2A+b07eD+V8xtP6fK7D5bn4KSpHI6WIJZMgQqMLLAgeBM5oClCCR9Egufus4I9eSygWBOc2mLDvjMbT/+QZLUi+gYuf56Aht5h+/LQ+ow1nZMeeBNs5uRVpc6JulJtRUb3NAey4PDGFHzLzkSYf1GpqhQRz9NDbxva1fU57bBsaCvsoLD7i1blCjkrcPCZZLFVWBz+t722bp6RpKCbqPLLXJ72mWSiE3zMZH7ZEcp2YnLIJkbJj8mA0MVbQmYMp992HePpnvA8FqTqYat99iKcvfx8K5nawfJvpniB+CqIxJbZlj1sOvqcFh2cjxHLeB+TN5MB+0qQqLk9hkZ8BJZRg76GyFFSXmJS5KtmUaM04ZG/gYx/1pGkQeAfsOzc5ArFK+PwC9jhGHTbSLPOoEKwWClWEFczzYPk61C2c/BRrixNzInKbBkLhY8SNYytvAaA+smGBfpx0EeKJmxzhTXLJC/p6sHafWKLklQ6CYB6YYE5kdsUwwE8hcVOpKnbtbQjXFjerI0sOyNV0vXjLxSYpXjgw1R0jZA8+Thh29pochMGW5z4IKniwfp/ghKd/KsFJcMUDc8W7BCcb0rFEdwT49dZzygHr7GbrbMlMW8oe75Mtta6RipIjG+V4QlrbHAhEMlYVQkqV0ikouUZQiq4RlKLffAxaVHUQRPcA3npH4id+2LiWKrqGqHJ14x3a5hKA8OFxG3DBOUIJS2JaTGldSTzi5rJFtwyn0OaDoM0HW+6T4ZhUz9tkU1R9Mwi8sAp3AdYR5OJn5bdYJee4yYtcH6W4QixfISjDO5O2FDwMVb1NN0d+hhYRfiaTF7YRMAvXAIQ5lGe8CJs/iJuPhVLyPQiKfmCKfk62WkXAVEj6QZD0g+Pz14A0V4g47aDQ9IOg6QfHZ2ehOnj46aTvMMNvKzm/g6DnB6bn51SQrSBBTiB1W5yqjVd2AxQZMdgXSzehTxcMbcqkFlACBlIL6KPbfPSbj0HVG0QcQOAMynmpRt/z08DB2xbeYI8c1cRzLmYmT1pZ85GQROgGlFVrVobWz4uTNuVYVjc3njUWRLwik0Ruontu6QMw6lK0ySZvDxigcBDnZV1B2eSUlX9G/5q8SpZD+ojEyHKHioCF4FgvIFOwtgHa7skgIhaCK/YuCuUsE0EFwZXdtZWDElUQRFRBcNWe+VZMIiKoIHg+BSqnTgI/bWroMj8PhcJRhNRhM1VYo7gMIIJgZeVVXZLH5JSOMy0oil6gjwKVCDMIPp0salxlUOIMgogzCAgbIIVYcZUEJc4giDiD4HnD66z5oMQaBBFrEMDjh7sL6rnooO1/D4L4HzxHuOVqwG9QmP9BMP8DSPdUhFdJEBaUpMlBsPQDs/TJIVZSVnvRgbLABU0/eI6aITu7tr5qhZCYIFwizzH9l5z+7KemPA2ILUZifHZJI4kEE9FJsaIqsE34O5EEoVhbZIblogSGzTVu5qFrwA5WwoLNkYg4NhDGWeFcqWJiDxrmEuEPOAyMuIKGqEIKWTCIIIPgq70LWdmgIsoghHzPQlaiDIKIMgjB7FvIStLnIDj8Idg9C1lJ+hwEtz4Et28hK/z6IPj1Abz2roWsZH0OgggfwGvvWsgKET4IInwAr93QSastZIUJHwQTPoRin9kMT/+TZjNBIg9hrykn/FlNOYJ0HsJeU074z5hyBFE9xL2mnPh/nylHEO1DNPuMKEp66yDo8SHafUYUPH1hI4rgx4fo9hlRmCD/ZzKiCOJ7iH6fESX6/6uMKIK1H+IevUZh7QfB2g8x7jNabNP2X9JoIYj8IRH59fjYoFD5g6Dyh5jkvUK1CShk/iDI/IHJ/CoZPyhk/iDI/KFgwjJSZLRlBYXOHwSdPxScJjjXrRIKoT8IQn/gROXEeFZyhgSF0h8EpT9wpnJy2ColbYNC6g+C1B84VzkyMGujUGQmQesPKam4nmQ2PeWYZ1/rhhRU6okyQmyjxIIiUhftQC68ZgzigykS2hCtXIk6DyI+IICt38F7Sw8pqoiNKvBj0c1sU+x1hD7AiAwpGhzyboypSZCGSOfElJI4xPZIScJNx8JQtocIEwgcJmD0eNigBAoEESgQwM8n2pumUOAhygNH0sdZECk5My6XymVDVCDLIiaFBLECT4kWjDnhtAhpTloajogPCCk+QE9nG0pluwnWfij3MHqDkpY7CKJ9AHGegr6U+JOgMO2DYNqHlEdbv9gUqn0QVPsA5rylw1sx4yhU+yCo9qHkraan5A0K1z4Irn0oWT/RQ5D5aZ34nbOB+RRNW5ZlbbLE/VynV4cBc2aQBB53DmU4s8hVSRQRyKNI9MPJf6sKMqdDQRa+sAxxJcEGNSRGwe9mfZJDiHde1HJlQOI7W+eCN4blZlqFKJXWok8HESkQSt6aeqAyP61rz7AvvkTAsQWVg+FS7ZhN7Rmip3Kml3TnkkRlqDYg8jkYY+s0DoYrYPKJ5kyqTmMMohdlUrcgIhRCySeC63hvZS36R5tYl6ZRnRzhqDiSsUJFBIq+qd8I9j1kLCr8lcRGG5P0j5RYhoWjWA8RnwJXtscIcH5QEUTDskZsjUacTiWH+DpdgCirndG4ZFZURwPuDZIWK2PYQe4TtbhrEMVmEOXmY6WPR5xuFZ9uekbh9BT+GFZokbmA1HYY9smJw8xhst8wed3mtlbXKfsQMJMhizBhgxgrbyERAREqFk+ohpZidcbTht+xoQRZU3+3CWHY0JqxHECIT7NYYu58ndTDGFTBrWR12yCiK0LFko+ePTg9RQQAlNbN1G0mLPfJsryZur4TJo53zpdu9Mhkfro1YXW6k6kmTFwdFYtpXj+4KuXuEDEXgcMqKKBN7UG5O0S8ROCQCON1uaZSbFsitCEgUqEjG0tQQhuCCG0IiFQgO62mG+MhDLU+cKSGn0HnpTCCmS+4FDIT4TesjgKxzeBBGuYxtVxCIj4iVHtyNPFD6JOVr69MsqghJn9Gi6RAVg6LQstNLCqBqiVOyt7cFjNFiEJEbACpeEomrajkEI8imCCC198RtMYP63xw7RLYwdZpSQOYMA4McQ6WtFulpI2BH5MKsxiDiHGZFC6K8IKY89kQ1LMhKqnJo6D9x5x3clDDZKPC+4+C9x+Z2W9CoR2eUUlOHgVxPuap1JK63WLe3m5RcNwjc9yNHtYY8/Z2i4J2HsHfLlB2paUcRiUNdRSE78iEb0PlwNsSa8zbekgUbOzIbGyKGFQH0VbUo2BjR2ZjG722OD/dYjMj0WwBVgSEsToxeSSKZuBDu6zzaMKo1LCZGwNoE5jRmD09YhfJhuY53xdyphXJ6lnF2hRacYre2nLWWEKrlDDXmOhq86fZWJdMDJr5Mwpid2RiN1msFA0kPS1SQBGneIphK/piUyA3ICiNIpCMiakGLn0sNx+r5mORazdUFJzryJxruodVcPZlwYkTAbRpFxSSDT+CN4QqL+I2R7L42kAGkwRJ7RBzYa6MiaXbFH3caAaGpFTuwVCwey37F75WCEDGkdncouB1R+Z1GwqsbBsC+WmT3tA2rB/LXCFoKFBfMm+Tyc83A/IuGaKT9ReqVM656AIQFpo3Igp+d2R+t9GjOGOdcZnj/po4wSb17VacIPsljN+OGHS8ubYDBvFnSAeNbDVFSvdiaWFA9HJwtnBBLYQxQEktMetl3nxbmhQyaChWrvkDl4ILDQIa6m85I6STUyGOarMnqSU/bF7Wxm6b19XNWCGgovGbSIL0+hxOBqRp2H5DYMWAEGTKlOVBIhRXAfPASeNUX5ZyFwjOdjTd2VeikoE6CrJ0NFV3QHVUUlBHQWSOTGQ2peqhjkoO6iiYwBGcXKq5oWRg4YeGYzVhG/P/P3vvkiy7DiNbzqXa28LEPzn/iZW5OyjFgcDIfK+yMjvZurriPgqFguIHWO7YbnPU/d7S7o7Biot31ZidzbgnW+cnOhRrcb/sT/19uaGTpG/Bxu9tK6JGCQPnNuAa9JoallXkQ6XLNT+xa6W28n1UtLL0d+HGSPHFKRaXqpWxkcvWg1QryiTN3mG8OcotNm7bWA8ABZ6S6v1thSPHzrVzkI0+g9AIW/leLsoZVVqLDlhwjO9KniKYtKRBgYwXLzHn60XcwkoC8nA8fzCfs2sfZi4V7DA9h3n/MwRKWMnIPzY3UsviOuZm1fj/KzfbHcTcDWIOiVI1/vcSpd2Bxj0rEh+7w/bAUrs7NLjLUjsu8m6Nt8fZQ1nWYs8313nPlzd5KSc57NO+UvSMJxQd3gm1RIFU93RadwByF4CcYxNbtd4usl9OOLf/jRapMBJQxBRUY+YmEo8/z2GOOV+LD92w1r/f0ci2Y5TE8/Cv/L27wVpY8mn5kdf3jPZMX/fq4+u53hNYvOz4eqzz9ivKV49S5d2xyV1sMuLO0RNmK4sdAQ9Nd1kTLjFkKpdYn8yqqsB8aKg6VP/Dz6YqZ6r7AVhUAVNsBVRypUjslWg5mTnuoWB8ZkkibIgzjfa5ysiXXa6oWmGS7luVlUrdJdWsXgu2BsQp4WqK9XBi+ZfGMXPk/tdVbwM0MFvxbxlukBES2IskOV9al/1lyqKEOMzRxwXfDMaY92Gyck0JkX2wHzgq91F9mtt9UfPhw5GViHO/muPBu3jwnOKlCFufvvXPIglZqvVtS/d0LuJCGtK+10lPl0orMCfsjjTv5LlrnJZWI2um9KwOxVwAnz/QAZZkoNsB17G0+hwcftr236FdKGvi9K2kHZvVwi2qT9C4ghkRLny5zOgsFndZSbFuho2IVRKvQiGeXSFM+QU18xlgh4UZjSkNRBxTpsbP16DsDmfvwtlznG+y1m2dZlFPeDN+LU1mvQ3YGJFNywaknWjl/iQ1P6s5Xr2LV89x3kqtmVeteOeK6s3OPywW+geBJ+x/mpJJsD4KfEG7g9Q7SfAcOzf3wOu+O3S8EwQ/rTcDcrw7cryTA889BCLV+Di1fVU4oQ9Tg1VzZ3C6yspHcgLZEFU+nLrd4PTsuKPIuT+HIyix0R2d3gmb01fzveAI4PTu4PRO2DxDzhDMNQGd3h2d3uVqDxFE8JhqsDFwIHkXSJ5j++EegOTdgeS9auAIu0rAkXfHkXdS4T21MBYbYOTdYeS92gsSRwsDjrw7jrxvjjwe/AKOvDuOvBMLj5mcHmDk3WHkXRg5MLvwOwSbVMeR982Rh1RPD/zeu0OxO8Hq0zsbkNjdkdhdJDYsX6IvEaDY3aHYXSj2AQvqAYrdHYrdW/6BBfWAxe6Oxe5iscHkhd8i6JCOxe5Eq0/7/YDF7o7F7kSrMyY/VKd0v0TAYnfHYnex2DnOmfeAxe6Oxe5t/HolWtAhHR7dyRmf3usW9EcHJndzwz706BZ0SEcNd1HDp+4U+Ft3h932nn51pwC77Q677cJuc1zfuPegQzostvfy66foQYd0YGo3R+bDkww8mbujO3tvP59k0CUd39nFdx6fZNAlHXvZu42R8a4yYC+7Yy+72EuskaL5NmAvu2MvO1nKkcIsbA/gy+7gyz5sjIw3buMyzBVRdBlEWiUqRMAeXq/S6gzLyEFrPRzJi3okvzl38GYXvJlj83trHWkjgo8rAG2zakl/XSwtaibMGxvM5fYblpH6y0ytOwa0iwHNcS1ptT5fncQyF7iJDvgAvs287bnHXAksLqz4aeCyvFVLdxhpF0YaknFqY3AcOY0bytCTwBeuTHPQ+UNpFKxB5qYGzR6B2GCmiYxnBbtDUjsB0wIha9A/x3dFuSeYPtJdUI7LW1anZHoGVeKnxdMK5al1O98gYMXfzNQZXOsDhfY36MYBMa05plfU+u62txvf80thr1g/SYWpkvrtVyfyd+HGElleh3bgaruj2LdVoaVVxrctaWWwBcEm/WjIo465826T8T/aV8qaz28ZHfzaibLGJuNq+7+/q/e9KGpZ70IHX7flBj0RtTmGe3pA1HZH1HZZb+eYwOkPUkvX7Ke62LPbmkku+awAZTUmn4pjDG+gFyr9qeDHI0DPFJJc5TWcuJFVnG2OGZ0ecLbdcbad2CwNVgF4urdvplvpW+hZXc1O8iFrvgxi+7ZhT0i9G+iSWwrolu5o3S5bbKRko1EgwHW7w3W7cF38HOGjCFYMjtft4nUPK9CA1+2O1+3idTFNBSuOANftDtfthute4fIxsMbuDnjtU9HwK36KY2cnEJyRtcDtFz/qfjM7glANOD5r6GVSUslL6rpDVrug1DnCxVJgqt0dJdpFiWZQQ8E6I3DV7o7L7CIv8wElWtdXXam+y1pS/gNcidE5Jq1uBWVhBgcARrvk3Mrn0c29NGXUCaVDaW4zcijtjtLsBCMP5UXVmJTH3wmNr5K290x3J/uvHe6R2eyg2ewuaPsMQIkVaIIImMM0u0DMUwBqBS+hgyq7oMoc6+16YFjdHfnYRT7mA/8TkI/dkY9d5CMEY+G3CF5DRz52kY8ZJsTRtwjeQ0c+9qVle1wQvgfoY3foY5erMyJ64RWC98lhi33pfeqhsrCv4H1yyOG49D71UAIxAuZwOOZwyKMYRsDBtxjXeys5HB44hAcidh7ew7tPDocHDuGBOSaVRoAHDocHDuGBSN2E3+LdJ4fDA4fwQJA74RXefXI4PHAQ9kPuI+iSakxmj1L22HZjnMygICOdCcMkqwWgP1d6rgDl5yiCGlCVfzeTVEAyYklZXLZfzw+HIQ653+bYc16tzOxb6sII08coWtseplKY10AtRJnUU7rEwqxITtNjlNQpMacPGFUuN+QjnTJT1Z4/HQ56HIIe8wgXemrd5bwUaUcpkbrLw5qajYVCuDrCFowJWMqlIFUwGxSMybDNSf5+prsfvbaxT/0IEMrhEMohhDLHBexHCl5bRx4OsYVYCkWvTApeW4cHDgGAjPIH/TUwZR2O4RsC9XJsSz9S8No6qG4IqsPSNLxC8No65m2IeQOqET6H4LV1qNiQzSj1dNE9vKeS4VCuIZQrxzq0EaBcw6FcQ/abOSap1HrXbE9rJ1aZPZxtfLsd0OYeVFmVn8RoX7WTuUkDcKGNGymTvOu3+1t0nV7WmnnFY3SAiw2Hiw3hYkhKRlcIcLHhcLEhIgxJ/OinykGnd2DXkOkjLBXCewg6vYOyhqAsMMnhFYJO7/ikIVPGvOIum4NO74iiIaIorxFfIej0DvkZhvzERalHgPwMh/yMPI6ppRG4AQ4H4wzBOPFObOT34mk4ImYYERPuxEYO+qODVQYxCMyoFRoJdweBj95w3MQwbiLeDo3AR284umEU647x4qsE3dEBAYNp+V7D7eQI6s8Pl8cfzLEf8iEjqD8/XFJ+yDmuXPFsqJT97dPxtUV6RiphVZh8+y5NKts4jG2pXLfZnpgw+niUq5hzQqHwzIMkw6X+R+m/HlTQ4V3qf1h2P85WjMA0bri0/FAl+hLzZ2rd1uBbwnaH5KhtyHQWYcUGrMwQGpDLc7nGfTQtOJcK/aCzNwwaLts/ZEUHn5xAWjOCdP9w6f7B7H2DncpEROLfCwTp/uHS/UPpfgSwoluotyMwrZX4KFAJBjREVg2Te9WszgS5MIJdMADDCnkvPlXgnSZLJeW9DC2sgQ6NSGHQzMvNhmMLhkzqYl+DEcAFw8EFQyZ1BTYK0fcNXltHFwzRBVi2hleoFkipIE0ENRWpI/AOSZ5DOxw6t2SVXs40SVfFAEji5ObSQQTzSWORxe4FjUxPuzpBb9LNJKLBhV5wSSY3sLNMhT41+MzCyoAQUKXCxcsrsj8cAzHEQJR86BnfzpTfuxIicF010woLqimyC9w56hiPTeWqO+6bCkuQs1/kEvYLN8YQn+BXiwYZtt71hm+3B902kndPtmbmul0Yy20GkVi4WluVAuQQh/6O3KAlgKPkUDan1qx6Od1ATCpBdq8QTY7CU2Jf4RBCvJApJvr8wdTHivvCOpJ2VKjpNFWNqxUWSi5WofaynsLwOXay/FpM/SBKXzS48ZDifDtM+5+p9PrViv/ebqgVdlJi0mAE2Mlw2MmQA2CJE+Qj4E6G405G+7W+CLCT4bCTQYjkYDI6AupkOOpkNCUrcyRjGAF0Mhx0MgSdlDi3rVbwvAN8LgBXqADIYWJ+IoeZtmYDKg2QqubvhL6mAqhEfaHtIa8LvF20JsYnvBswpmOcISHEzXf7IsmM0Ag+AAnWSugXJPkimrvYYaVTwo7j+gwURFuEReFpR3KYeydBv4gya1gsqpCSmFSuxG4R/aBU6dOHdlZ2xM+AeLySU2bJ+8Uq8tRkCDDl6JlMtovbq4vvTvogDAEAVXfS5MQzlXvS90mFrxYN+Aq/76i+7zvOZxDbmVdoV6jG2zjvS/t0V6S9/fKedG2WtRxiOHT9xe7xXrQ98ew7ZeZv0I3pxIJaO3RLLhjhZPUpNCCiHy2OBhMeXNmU+Tc/zdfaGI43GqSHjm/gjsi17zpx+qqtl6dO3FMm8qkYp68KpdESOssxjeG3l7poOIhpCGLCix2+VcFK0kFMQx6PJba3GAHFNBzFNEQxlZhyUGsg2fgSxzyd444qPJjrs7h/xBuNRm+TBtdDWRF/j24AFSdVYsMMtZKahiqCAlZYjGT6/2Ghp0UhjF8S9aH9Zh+IGRTKTyAlKaQMtMPAr2w7jNoj9mA4FmuIxSpxGtpa6W/b/1R1Lq+vBRgR9dvKhja6EgXgzbr2ZMriNVyJzXLZtDqHDFTq35K5Zr5FzosLBxWkafxWy2bgVDgFs1wmPDrvw/wcFs3q/mu7uYU4WDuEg8y2MTESi4kBCwnOFJiRKaiAnMYGT4i7PqXv+QH8PDLsn4aHk/HnqPeMuSDTd8Ymg2QKVHpVUqYxzUAR3oUciEn102rz0yHcuG7P4ot6DBQ4vCiioDpLS+D81ygnge0WnVc/FVDHlB1jMdEHbg5rHMkKKKDl9SiIsDKUnB7a1eUqYmeRuM0SIdOsk5+digTwOivJAQ+n3Ugqbd1/wCrBk7uYnp6zWX/rfzg3p4vbg5ohfKvKf7K/zmzLwKjn/mf766LeF1DUvz037q5PH+317trmA/l6S92kKNawxEp9tW7/Gi1XZtKeW5DVdlLXzmJ0eREoq9C5WMfmUxVv6/SxVIctDmGLId2lNmqyUZwK/4Uuh6IprJa4uqHY5YIUUuWn4U1Mc7A6rTIusjbzYh3qwtUWjMMoN6PCSdzYRXwAaR0MXJ1VYbFkqiasQrAX4zy8xoopq4rVzLw4LmlphdvQMg4jP/4KmLI0VCyvJb8yjMKdt4u+whfWFhLXR9AalF6FmXzJzykz+qCKJotsfiq29VxVQS7T+Q7D7QT1sGFg2v86F4y4Std1YYbHd3jgiXCthS+40rAnufgOUml18YKdoA/1WpkPj+uAi8wP9Vi4C25l9TRS6eM+27X4Q8+F7TGA9b9UpETjvo0S3N5eL6lbuQhLLYd8Zu97ek5bqQg9rpJx6Xu3ljk+oA7412vK8qZYrMhSrLXy17j9XzBkoN0wXrOmpRCcdandnY8VrZUGvd9YqRg5A2VWEOdun0kG7eBGjd9StzIyljZOiKuVnZXev/jJe5GBQR+aPIo5/MDSgA5vdwyE5vRp/klpB4fknDSMVuvGs3d7NhXBi85V/hwWEIEEV+v9Qjvz7WvSVIudN7IYOCl8XrBSyHQWUqs9LoiYd5daSQSmzO8ZyNyHo23HOHme9Mqz4zk7n79d9x9wFVR4lO52jtQN/bVMGRW/xie3vBSQXA6JvQBIHg5IHl3LyxkHuPqyhBZv8+FkBTliWaJlGp4VZejMkQsaJbIGbDRQ0A+HNQ9hzSU2WVBrUW096UArbQIWMWY9xrU9oCCcuA+TXKlwmGWK6m7E0c1D/HI5ZK4Ca9rhsOQhLLmsGC8Y+csMgJE55LrvcZddYKTt2I4erN0z4zUSrA4+f4TNbF0Fs1HzVQSavpYdaSJPTCMyQJiSFVW/2M8ldaTdwuQIgfVXWffZsppxXKmwXgOUp6kowq3DuTX/hZ8Ks+dU+cq17hemDp0eQqdrXCHaWp/6x1yeYHYRxsU4QN1uNp2pBQXTq9WK33sEWkvUK++z9Srh5s+x1EP2vjUW21trugs55m0kkUkm41wRBb/+SQVoX8P56p/bo4gQv2+1XIG/O7cyIdJ8kNuqcVuPUoMNbWOyaVZLEljV8k+q+ZViPc5+WFkXCJN+W3/sGCivsbi8GHvU7kWeZliucLGJKW5yAYaeXGwghxflxfEea3YuYMatB8eArpuZUobjtixuhHfAej7eAa0RyI3wX5a9EUMJhnKHprBNTpTsKlpEWwwtZeAsz6vQOCNxpmCEfdpCS7NCynvfkJtND/BPwtJRX6IXvnKUd8mDda9WbEfB6FTizqfQ8Zc7irJV1YnloKgohp1Hl6IYW5e0I0eUHBOalGNslc1PY+fV1IWzWmNisVi53LGz+TksOvRdyS1h5O+MlULY0fv3itvonWfBzRcU2/I3saNez/WXTNPXa73tEPgh/2fE7cJbCQIujlYfZM/56W+DwRHA6sPB6kOwOvQQ4S0E4WQHkg+B5FiaRFcIQPLhQPJBovtg9jgCw+bhEPAhBLzGRgEjQMCHQ8CHEPAaO3Fb661oeTZbglkQBcCuvqKKdS/JRC6z3SWrUPwBehYcpZDlckT5ICBeZ2jLrcZdjVdGP1gUKbqFbXoV9IZueIcGRfUS7mOtIaxaZOqCDVxlTuk9TThQfcz2A/QdAak+HKk+Zv8B+o4AVR8OVR9z/MB0xwxeG0ecjzl/YLojQM6HQ87H/IXIqpU7gdm/vaGe+V20FC099o/29bswEMlMwfcESi+dl3X1cCz7WD/ZW2PZDdq62KcTs/JGcD6WLD09nmJf8Ul6NtDoB6t9wQ6Vi1SknlPNNQS3HNA+xKyf8NwVvPYOOh8GnR/w3AA6Hw46H0TIK6JtwVvGxvu3y3QqpX3vHZT/T75wAj3pHl2ZKn0tdxzKPgxlP1CUAco+HMo+DGU/UJQByj4cyj4MZT8QjAHKPhzKPtZP/jBA2YdD2cf6yR+uXZqV/Mk9HL4XqV+/EwdslCUSfTgRVWWiAOt+wgU48rflXv71kzkM+Pjh+Ph5/WIOZ8DHT8fHT+PjY2JwBnz8dHz8ND4+JgZnwMdPx8dP4+NjYnAGfPx0fPwUH09x7HvenQEfPx0fP69fzOEM+Pjp+PgpBL7GVv8zsM+djluf4tZZ6Ou9DFNrqduzjQUI6BpG0xTAEwKGEAUZzGP2dFv/C/hoxSy8+CFGyFarGPCXarkC1Gw6Xn2KV6+x4YS13jOUKrFU2LUx3oY3pVwymZNhvoa0r8FNZm8UhsHDy7wNK4u++iFvOnh9Cl6vMdcwA3h9Onh9EkXv8VgxA3Z9OnZ9il2vMVUwA3Z9OnZ9il2vcbmCGbDr07HrM/1yL5gBuz4duz5JoscA7AzI9enI9ZmsMlVIj86AXJ+OXJ+p/yASZ0CuT0euT3LoB2+VGYDr04HrM/2ogzsDE9LpsPIprPzg4DADrHw6rHyaC2ns4DADrHw6rHxm64/xwBRg5dNh5VNY+cEH2lr/u6yWpyPWp9mIxss3a/3vuzn3FhFtD32g1ZStFAAl54gMcF2HKD/iWQNMgWJcrw9yr1s2c+TQZEOtWZtMoj6dctskEKV7RdV0PP0UT19j9wprvYt2S5/KAtpc48+1mIBpHxSDoPNxp+Ek80ks5O0/3r3H+dd7HND409H4k3B9XvGzCV5jB+PPbLNKuK+fAY0/HY0/raw9Ztg3FaTWx1+729xcsvl9f/NLj/9a1bzNeCY3TSoORN1XoQyNe6p6iflwd+hw/0l4P/ZbngHsPx3sP4nuH/yXZsD6T8f6T9n7IVMSJFXU+t4Ls4ojQ9RSTQ/k5+a1971fce3vYLZsShg4rmFcezodwSw/psNARjCdjGBKRnAwk56Bud90hP9UXfiDHGMGiP90iP8U4l9rvDYKEP/pEP8pxL/GRiRqfUqDcjXKAWEqOZK+qoSqq6K+VCWnQ1N1pmDAbFHz2Wr5pyCoKlcNJFwrK57a4XgOiQRNP5o5RcCUIqDGnhVq5bt4mbVmJg35heM/pY6bKV3/LXV8FzWutKFA1rzyfdTRxq6Bjbz1odPJD6aq3UPwGd2uvAjvmkSN7jIoKvRPSSKu7BVDBEagGGId+6jd5DUiEAF5PZ2kYUrSgFE8vKl/TFX/saBvTHc90aFn9PqniOhjqop520aytiJkbTr9wpR+ocbBRbUyTYjykLoNBFesfEdYtZh4xkRKhRmNddeXa8wrvirGkvCBclQpdcJOXwU6Uk1mO8Osr5UqLmtaqQ+OVdabcCdWwaNK/avDElXwmE6JMaXEqIhQBmNqoMSYTokxpcSosZGAWpmUAbPQzGmYXx9vDjNjY2yXduwZlaKlaQ3zoNBQZGo4sGmzFFc2U2OQiplQ5oCTOhO4sCsS0YDMK0Z92BEne9RkjsnLgPlHJou3NkgRXO1mCIvK4hGrtOR75j9mv9HnJatWzF5I7sMO53O47kPG9e0w6dA/WjcnSPxRYwpmBgaY0ykypgwwKVZ4BwwCB8zpBBSTwoJ56B3BhOCECFNChBrbCsxAiDCdEGFKiIDhs16gw90VgqWVEyJM6grQhaKVVSBEmE6IMJuNZWGAcQZKhOmUCNOUCCVa5wdChOmECFNCBMR8o8cYuF9OR8VPUfHYz7x9sGbgfjkdtT6NWg+Lc83A/XI6Gn0KOK+x68IM3C+nA8cnMXCkZqNlesCNT8eNz2a9Md6ntvk18D+Vvzke9fJdt+ldmYlkLVRKhTMt6jeVtevNP4O8CFyYmUszhHwG9FgQqGHlt2yAX1zFq24Tg8oc9VVDiiVq6hj3UE+7+/dQ75D3uY0745BPYNw5HZA+iX4f1riBbed0qPg0285DwCaw7ZyOup4Cqw8bvcC1czr6d5pr5yHeErh2TkfSTnPtPMRbAtfO6fDX2W1Yj5eXgWvndHjkJDd4yGTPwLRzOtBw9nE2zJyBZ+d0iNwk8Hao5DYDQm46Qm6aZWdYLHAGlp3TsW2TiNlpPArKpU/HpE0xaTW2MZkBkzYdkzaJXp1+hqBa+nSs1iQcdXoIQbH06WiqaTRVbKQyg2Lp0xFPU9aRcC4NrxB0Rge6zNF/jSsj6I2OT5nj7KUwAzplOjpljl8m2zPAU6bDU+b4YbI9AzplOjplzl+exjOgU6ajU+a07hivlAI8ZTo8Zc5fJtszwFOmw1Om4Skx+DoDh8LpeJI5lVGOXRJnYFE4Hfkxp3XIeJnAVhKWeO9u/6hnE35vvVmVexG/H6Z0VxF5JmUXlVppl+QgpabqAsyE1Rk5M03HmEwxJjX2zlErI8rwVWXwmD5+komsL0O/x//qMfQTcIrd+BQ93jqB052rWzzE9SpwxOBm3VsmnKUeIkwBzjIdzjKFsyATF37d4D1zOMsUzlLjjPYMHBSno06mqJN6yGjf1Em2ZRdsQeU+WD/5txXWIlHVgR+iZkdAT03HlkyxJfWQHA/YkunYkim2pK64t68c+tN+ZU8lngf6wO5CGeY1d3q0XHdd57r61mnWNaJMqYNWppwSa+yRMwOnxOnwkim8pMb2LjPAS6bDSyZhEbD90f41oEumo0um6BLIFcJfKJicHF0yRZe0mKeeAV0yHV0yRZecXrzAKHE6EGSun69NAIJMB4Ks69drswIQZDkQZF2/+voKQJDlQJBFrOPwY66AA1mOA1niQA4/5go4kOU4kCUOBLuw4MdU65eFt+I83RiFbBWSrJ7pLNvCezt3a+wmPvf4i11MJSSDM9/0xHKgyRJoAoFm+CXffX450GQRG4HQpo7PWv47vrv8cpzJItAxSyjQW0GZ5uUIkHWpyESOL/Du8MtRGkuURotVAiugNJajNJYsBltsNrQCTGM5TGNZ+eLrr5D8chcI+rujNJYoDehfw1uQoOWyGuyFldcbq2cyCF7mXx8KE5e/KbB3tL9FBxVC4oXlDaf3G1iO9VhWUDiuSrcC1mM51mMle23CqWAFtMdytMdK1qvDqWAFtMdytMcS7dFSuPhQ61dxVBXWRoa0G4h3+xl8JfSZMCIJO5rNo6mxnCpTqRgF3hPlchTJkv8hLhveWfC+OIxkyf+wxVqBFXAky3EkSxxJixF/tWYWJGj01mJJBCojy6P3vVUHqhiL0qMa+TpL+LHK5PB2WsvxKEs8SotJ/xXwKMvxKEs8ymkEZGuj6IYa+szYWJWc1tTLVM5IBonfkWKULvgYobxsaYRr7oxju5ZF82hCNCSuVEGEJGcfyT6gwFWBPdwe3VFSznxTsd+pTCTseo5SPKr+IdZuc5r4EVVoeYd84Ffd2kZKc5qyXBc7IP+AvZK1/IDmNSZD9Kc53X+asySvqcneRCerPsA/cTdSidDBUjj8zYKp2UE2SxhNiwHLFdhCLkfCLAIrs8VzuzwjGf5sKm/8NUknVbFQMcyazCkBroKmCEa/piIYcHWmQQKKNqju4YLkdci4a24N/7Vlr71vHzAYHkBFpn876d8EmGBSLkclE3aaOpka95LJgzPLYTmLIA0uEWyX1SjV7Ho69pR3EKvefr252Bp0i1ZT2Uep1JwWtwYFmvmVMLFL77WqpdGwg3xeBBP8ApkY06ojAxJhT3zeDXn+TEa177ck70+Avw0KTQ4a1+yjpZ3vZCljdGGJvHCRyX+AlwFmQOzK83GVSNOMuL7fKuXx+HrkRZug5h+3m0REIrW4oJta2zZVanQbMhnfXNWKTM/d/VBe1PSkYJUUeMAjZRfqMIjhb7AriqRPz3u8uVDdXQVxgTIwR7mmvKMzETO9xdXmpsSBrNGlDTNX53jSZxdYkFRaU4+GOpvEkRE3QXG6/CxSk9oX/UbOcHZYdOgfnpvnsua5GJ1dAWa1HGa1ZHraYvJVrSxF2vsXE/Asvh/zzHvN/fgwPWvuXrpFSaa0ovTXqFbjxN+im0hFciE7E2QLV0ByLUdyLZFcLcYpV9k1HgY6TZ4mK0hM/VCeyTGbiaIbhlHaCLJTJryZNhp1m2Syk/LI7AlWTf+AMbDeMha8WXEh/1s72GsJ9sohs69GvoX8VQg3TBmfASvTXLx1r/hOUyV9E1P68EzYbwSy7FCXZroiygTmYk3QZiyEvMgG3ggyD3Teo0cXPHSwWMsfWInwJUFMcAy9VulvsPo6JOODBg44os8H9Z9T+FHKf5PYgOplcULFP6Y5jR1qnOEftLor3qbG+56+KPRy3NuSyW2r8Z6z/FtQK6vA9/oqzwTAgw4AY106mOTXcMUEBipwAliOnVulnHV+K/DJXY5vW6p3G28JA75tOb5tlR+VZ1aAty2Hty3CagcN1QrotuXotiW6LXYtXQHcthzctgxfuyKp0wrqyi7HlS2CWxhtw5cqGFoc6bVkNNtCsdUKjGaXo7IWSSiIlqI7CMrKLodOLcFRPVTBrMD6dTngaBEfaiO0CF0Bb7Qcb7Tk7YoB/w0yrKCs7HJUzZJp6hzxQwx6ooNqlmxOW4xJWiuFlaV+TWOUz7Jk+V0aDjAsC9U/kaOnYs6YZbO68N6zslut9kj1uBy3s2R82mpofLoCcmc5cmeJ3Gk13vMH5M5y5M4SudNijHIF5M5y5M5S6dqrhpHTFaA7y6E7iyBOCytmrwDcWQ7cWVa4Nk5urgDdWQ7dWQRx4uTmCsCd5cCd1RR7afGSNSB3liN3Vms/0qMrQHeWQ3cWQRzW4HvjAisgd5Yjd5YsH5FwC79E0B0durOE7hwSrCuwfFyOf1nGv8Tp0RXwL8vxL0uGjNjZRN8iIGCWI2CWCJgWl4BbAQGzHAGzrHAtqu8F82CAwCyHwCwhMJgE2vXJvksHCMxyCMzq1iXjZXyAwCyHwCwCLbGWYAUAzHIAzCLPgrRnsEYP+Jfl+JfVz8TBCvCX5fCXRZoFVRPfuIDabrv2252b3t8L+zsu16EfLpwJhs0DrFCYaF+PYVsiD7gMyacSSuNGqwes7huT2t6ReznKZhGaiUtyrgCyWQ6yWWRm4uKZK2BslmNsFokZbJ2DVWNA2CxH2CwCM9j5Rv8+6OsOsFnkZVa8lwv4muX4miW+BjZI0QXINtdbXbv9Vp8Sm4VJ/86gvk31i7U2Wb8mtV3ugc56e7K/cfYWF8VbDuFZw5SUKD9QXZ4iAHiWA3iWAJ7W4xkmAHiWA3gWgZwWY8YrIHiWI3iWCJ7W4/khIHiWI3iWDGZaXJVvBQjPcgjPEsIDL8lolpuqAZ0/c1kmnsUYbpZj5S27esQfu0rjv+KPtkswwGTJEI4vSchFd9B3nN3xQku80JVWfLsUWFh8SVv0YfedaUtsUUVujGv5o7SANtvIG6pQMqEaag0Z5VoIyylIiMj4kNfBHWlQzF3WpgzRssKBL8q5HLS0BC1dOUQR1XrbdVOTTvn7XlV/VX66fbvvVy3BesRwmev2YU6X1YjlYVPMyN+kG0bERV059D9aARe1HBe1xEVdOV5CsVXehFk/Ur0slUGlVJE57ldESPUlGIVmN8PugrNJGbtgHszox5Brbz2FhvSD1VeOy1FZa/4YYwI3nuVIqTVtjIkXPoEbz3L40hK+1Hq86wrwpeXwpTVtkImXLdOq01geUJEYmvnx6fekhy4Hj3St2/Zc+kkazsGFVHVouEW2oyktob87N4ARdGIBbgS+/CNeZkFyfW0pb8/vb1vyd7FxGpKzRuFs1aaWC8VqbOrZ5cb9/bnhUeBViwtlrhUsBhwttURLYe8fXiFYDjhaaq0fhWdWYMSzHNO0ZMSz4uDB+i4sf3uxyW8Qfms1m93aN1m2lpkfs84L3Y//UoPQ7jOX/80dILUESLVY4KNWqgmglLOpZ5cHthLlWSM4Wr6qvtRNPJpFXObY3IrVpUycqik2bCTjxvLvvwOxFrkqjCvRij0AsZYDsZaBWLH6Q63V5Ld3Gv8u+vRlaFWrFR6QX6nMq/glBw3gCW4SfkOyNMjeO75rkdZCiY8oxKBGcAO7S7S7SzDknKUULmX//vRnxeRoDqmolBAFaB0ktgSJ4erhfezUCEP5TGXW9bX6oLAV1ChNGlnw57s0NO27qfGjVy4M2/XWY+2BrFKwlXAM2hKD1mLUXK30oWyddt3If5RCa2J21uvT9/rDjFmpY5HDddnpegiv8TJdn4UU4bWY528cjS8+exni21B3fRqrLsATkjkD5R3kSH1Bkbd4LdQlkQn4UA0P3CcSFc2cK5HLvFjLB5CduVkW5vbgls+UfbqSuWoj749HeXElJH/tRHAb5+YfiP6LvvZIRuj7kS3hNUiUfTAdjzXsCPlNGHYm7g/0b9NFK0rm91K77BulxIxtkh/3uA9l5ckSaDTN598ioqivz55lf9umHDYxEMxi3zbJ6j/rD9rzB1ZOILU5nvb5tK/7rHax+HYsnGsftvLzB1yHYqyEy/Z9tj2H/Tl8Po05Izu8P63TJd0O03OY9wfLHt3O1ueQTwS5N0BB99n9M6Vu2EjB4fNp6bqvS1fQ9FozOuIyXUIu2Vfeb7Q1/3uN75P7IukYqLDG1yWSv0Q+a32s9ZeFoMaMRa8h4lJY3OPlax9YLqXO6nE0Ce4QaRK2SsBoAtsE+7x/bq+clUTWeqfHdFdt1i80TnfVsdhNROxhatXpk4IOihJjNNV+3UjxN1L1e40oTGrNr4tUf5Gmi4RRc2t+XaT5i3RdJFxq7WYrjs0l6YVkvi+qvWj9eKcSsTB4lATECbRE6Hl7EIBPuE9izri8y619+D83y3V5R0aaE6G/2fGFnnH8H3f3qtXsSvAjKS0igQRrE/JdTmQcqcsGYgM7gtcdDX9HU3fUDnc0/9N3tJ1Y/0/vaPo7Wrqjfrij9f88pZisAiZXddeIoP/HFC3JMIHbkM7VKVZGCfUg4pfPj1GiZDtowOjGAkz2++S+CNf1KD0RXyQapZIfpYTDgjSML/Je3H+f3Bcpuki4nrbm10X8OCDYtcdpWmu+a7ux/gjMK6sWBhabKAw/sEbCbLbKIazDIh/wLRGq0/4SS2Ysq7JiVWaWSlpUroBY88wsJpAKk8UEgpJmMQHmSHU1gI6QmcL15GKfsMHn+oWFYunQjSJEXA5gNSl77cFadKz6IdXxpGS5WClLeX5zGlQ5uASn8vuwPYd3uTayRyphC3Pv+w/W/rqdKIX+liiF/qDm55CoGSeZap/2+vX8ACzQuMcONNZcVRrFmLrJDQaBYVBmfJLLol7ACDctWsz1vJCqw2RTZCuTZAiBRVTlTFqWhQWAMmE70FQ0pGnBle+JqcpuqHNFT3ORLrU3OkwnYmiHS4evr++nDlHSPc4WWjPRmmJDC9bA9k1ZjY8f1MZfJ8sIZSlKKhRGpiYHROik+EUlN8I3KmSTUseaWtEmVJGpHwxbndvO5E137F7+uXfNJLGPjTW/LuIHf6HUPc5NWfPrIn68Fk2NxX58kaVFCZVipvFHv1FVmdsw4DEAEB48eT2GasCmJfWaqtXUNnZRsBMdqIkYILXVVDtq7Q7ET7SVV+1aecl1INkPh4v1pl/utfZJfh4QtI13PPzCAbX9fXJfRPNAnEew5tdF/DwgFLnHmQBrfl3EzwOikXucDLDmoPfLFTWzXApD81M/U/kMVNNh9Qk9+u9O39m/ISDtdFzDFhbY8D7q9xE9A3t53b6fgUQ7935YRMozUMUwjbZT3SsrdqLyUXTv4wTEqkQcgBbNYKE0LTbQTcxFT1EUTi3tzmLQnbPRWi5lPh6ZIVmVvFStAplVNlHnzRoI56bnKtlcljNR5+XEpDGPN9JZVA2x09QfSWwfaRdE6aw/ZoflOazPYdPh69H66UE2iH0c3mw200OX98AcLONKXFy1tl3LgEmbcy5XXLLLHfO2y+XWSH65lF92WkNenru0T/znDjWCjxAfteb/0xGc+QA8uv/aETz7EVyEMHTY8b1HI3j2I7gg4R7bY1izovNYj+NbVpZ5m3zTrmZfo7NcLm++qZZsn+Anpy//ZJf85xY0/sexLmsOb0HV57HI+j+9BT8iiyLusS+DNfuLFD8iF43IsaeANb8u4kdk8as9NgWwZklidqVEsOt60xnOo9nW2mlEwrScBcGsa2kFBX1m0UnAu6KAIcugIzQmy0LBADQuCHBm9uqS73McCa5tuUM6kUvHiSKPk5QuV9o0nOPii65qAwlZljjD4etB+FlF/pM9rqZlzZrKQWeoZjoq9ybOI4yIpr6uqHSv/et/Pk2TwDr0wYC5/T65L6LhLpb9WvPrIn5EkrFkj4t3WfPrIn5oEH3b16kXRUND8UOD7CV7rJa3Zpb9QSGLorKqpg3rbXs6sdgSg4fMinAnV7LtzSiT0IYHgyud66juUOEhk3mwmhe3Uu3ZkI1swek1THMz2i4Rie2V7cLm3oWhvJC2YdiwSuFiQS6+AJxneSvcWkjiwrPYm2K0H3sbZoftOZzPIeumMnLJXZbOcpdlh5zKOWzW52Ji5yF662IEEFruS2X3XjNB8SOoLDR7LP235juFdXtkKAZE77/H0eOOXimliYUEN9yffGfEZ/8DnfiGKUoRnfN+3fx4KxPNcR0WrwFc/X1yX8Qkw4d3NgCsv0/ui8guO9aoWzOLWJWvlBTLAmExx3wke/GldSncJrMFQ6VtfSmG7aL/3ISVIjq886K4WYPVIh6VYWFu+dH3syIY5c8sFNHCBWdtNg8g2p75S11rhzIQZtM8kBHeYNJjIpSR9eZWk2EikibrRhwtqTtI17DbJttzI6wxtL5JXBozuq8PTOPS/I2+dpHJaQz5yaqNh8PqIaeh4KD0hus+yx2cHSb73EFNqP6UU5W11+ewPYddh6+fw08GMtkccfEra35dxE8GspMcp9g0m+938gUwCmBn0YWdbPzKOD8pxkXj0TT+5tx401ey8fhCVj/rGEd/CoIH5pXfJ/dFOOuMfHqro1mn+lmHXDus1uu8l25f14giCtWPhyTbaTzzJhmt9XUNP0SJhsdOo3zGKxkU0PDfJ/c1NEKhOthbf2HNr4v4EYqIOxiDNxRpja9L+PFFhpaDJcaDrFQAxn+f3BfRW5FDYs+aXxfxb4XoeJT2iy8SLZGa76xNVk8zTmIFhPz3yX0N66uHxU0AyX+f3BeZukiPKlJY8+sivrOKlGd4KfyFo97afG+VWWS/4h4fwPLfJ/c1LKIUgqXW/LqI763k37E5DOxQrPV1Dd9du3XXw/gbQPPfJ/dFLKyz4scacPPfJ/dFrLvOw3ONumv33ZU4PAb3gDWz1tc1fHc1C8nI+9AaX5fwnbVbZz0M8YGN5PfJfZGllcrh/Q0o9++TdhFy6wOiCpgs+BsJSPfvk/saGlpjbbk1vy7iO+uQ41eJc9AB8v59cl+DHBuo/7dSxlpf1/B9VeD7iGs4WbOUyiheixAUxdVGCwzukWlGzkRMMdhQGfrrEiJjGLzg6DItJ9Kj1cHwr8FoP+ePAHv/Prkv0n/OHwH5/n1yX2T8nD8C+v375L6I3oRT4lLNiIU89CekynrmYNB0VMhCIHynTD+ePjaW0hYulguZY5secq074NXwFhnYZ/5zj3rRYvm/Ne/AJN0HUK9U4dJnsZgRTSYohDJjtez7FbIMiEp3DgRp0u5Adga8YZSDYUkaC1GOoij769b96y20f8T1sqw5QF8UVQXBB+cMwYaoR6/ku4rXwP3lqcpoN7oQVaBXTJSFn37kmDZyHBbmxvJbVpcheNWfxs/N+ADYZBXTmLsqwbWd9FninJuwtMsq4EdoLMmJd68rVF9UcTkT2JvzMo4Ya1myElXUMHz76WE57J+nIuf7zh+EJoSI+o16PYc0EUJnq+IuXlTJ9OOgZAFIfMePJBoIpx8IDduHejZaDAXc/vfJfRH5PSOUGK2YA0vT75P7IsLnWX/DG8Bb6+safrQSQj9i2bE1vy7iRytR9OOUHg8w+u+T+yKMs42oapU1vi7hBxNVrl2hmtNaX9fwb7WIdPhghN8lQNK/T+6L6N2rh5VdQKV/n9wXIUTXQnGHtb6u4Tur7DYvaL6CpxrYbX6f3NfQrB1rtq35dRHfV5fiAMgufapf2AWo9/fJfQnrqoeFXWC7+X1yX0Rd9YQyBM6b3yf3RTSxxsJja35dxHdWoc8j1v1a8+sirrcm4aAjlv5as7tI8jhokgnnOCAS1iyWBTTJnU3N1LUkhlMTSYFmcgYEwWWiw4QiBYtI0TwRncblG6I3TXEyAEOowPT2HLY7+OeONZAfUIoU2H5+n9wX0fYrLiZkza+LFH8RvRwtfjlSxHcmz3cm8Z3jAEqkiO9Mnu9MAjjHAZRIgUXn98l9Eb0ePayGZs2viwx/Eb0eB1AiBVad3yf3RRZFNSncs6TArPP7pF1DIOKI9WHWzBgwBlmub+YDOizla8yXC4WWktz6wNdxOQTrSvF1dNdj4JaK7CwHv83IzVK5wssqznQN4xlkpAfeoXFpC4TBEjIQrHB5tVDBixQQjxiUTvOvy4MPEeVEhB5ViSxgTChi9JtjGL09h/05HCHdkDyJmUhVLrAnb28Aa931q29X6adSXtqGUoUVrSmyeSpaq/Yv6rHVmzlNpI/b5TcPyeOdSXjniIvFW/P27eatkdvezpR5WE2yQlaZEoIsbBkrbSAqKlQ2+oqI3ORJ0SRSdMCHJnp7Uvk23qYzHTYyrPkNd7ZC/h3DgMqTsWTmVQ2ER1LCkPhB7uO9CUgeO03CTseIZyo1MzpPm/q19290BhvG+PiCz71JCPqHLyrlH520sN/DYXkOa7gLTJ6vTOIrR6zjsubt4QY6ThvTx0v5USiKT1HlszsCoPwAnZMHd1r612OYifIY83CjfpAVCTkOGImaN2T9+MU+Ys9eN8cjvgMc6izbTdbuWRkuyc+4IyXf8cqPJs86JrGO40CYWDNfR5Fa9QMxcyZbBBUMcAxQC6R2cZLPCd6i9YP3YCrl97oPPwEIlxwHQkTNlEvB+RLKJBgUFaS0CV1QPaTd58WkV1k8sKQ7yi+QSwNxNCHAKkVg9EW3TfTe6wOnNRi7XDZK8mPkbX99qrrR9YHzEXa0F7PSkoYhmMB/yFE5sdES8rjltPGSLrzk+hSshWq2yyHBCA2XRGTXh+6LUx+/mGS2VubwobgDkXJ9KmuU8yrA0Xgr4D0zT11yHb4+zYBhbBuLWdoV84e4PvhnEBJeH1jzF34E/wXhbyR3YRGlG0BJTrRmk6zRBYo3ULNwgusza2Vdi+uD+laNyi8QNpiIrg/+Bpl+/FwA20ks0LiW0ry1TNC2RmHfQlU+An/4BfLf4Ecwna/qeIhU0esTux6sqaDdg+CCz6KJm4QKkMiZ7n3y2+JOVq96ZKxupYuwH+uJpiSIHjnSzK/JZC80uHoeLOF1mQ1fvv8ACrSLUiAq0OwPutw6GZK5+ESXvSH2B3xzlcAmrrMP+3M4nr+dz9lFvSGuyw+2w/Qc3olxwGH32foc8tXCc8TQeJ8dz+F8Du9Pm9f1HKbnMD+H5Tmsz2F7DvtzOJ7D+Rw+n5aeT0vPp6Xn01LZ32Im+7TXuOPXjCKsZ+xXbc17Grn9H6TO1893zyesEws6RYaYEO3JEJPyva9I50x9T3cYsuJZxK9LBUbPQ848RWB08mB0Ehg9D6nsFIHRyYPRSWD0PKSy1TwoYk1/JCNgG8SxNxkLdZkoBTbWzcZe5MUkvaWMldpGlrVlUJAqJ5pmwxE38U2mNJXWv0ujIHCowVEwSfVLkkkfT7UJB79iHsSFFAj8bjTQIZJaiVkVpF8o5bxgeUrkA/vRJhddbOHJDAMUZnHyT0VAl3AH/g6CDpTsgyyqVrs0TXayiplrEb/o6Fnm/ttGQEpna7NPogwwf+DfY0gILzb4fPjPJjkz4C9p8lbtsAhiTJNQmJ1sz2F/DsdzrWnfP4Gh3H/ABz/bazHhcfck3H0ekjUpcN/+PrkvUnWRw1YwR9tjj1cn4dXwf44vEm2PPQGdREDPQ6JBzV9W/3RIQBFcJqGwf5k0NZALDVdEmXLeNjeNPZk4YH1vLOlk1IH4R7R285RzEuU8D+H6FFHOyVPOSZTzjCvGW/PrIn4UFac862lMiLbenjROpIYPxTqs1V/Dg8ZJoPGMS7Nb85TepdmeBVp1haVQtSDTmTrn8s82qm2SHno681j5KtM+a1SfwT7un9vT2FnL4fbyd6Gu21Xhq6B3FD5j6Q4Mrk1OFP22GeJhoq0C1zdKWalWFwWb+b1D9bRxEm08D+H+FHj2fp/cF9FLXdvhi1cBs/huWHl3m0q/plfpRWb5qsh9GyxV6mUG3KP63l7OYUczdiq1D/3nJrWlLKGprDW/LuIHDQLHkI9GpEiKGOXkGeUkRnkecgEpYpSTZ5STGOV5iMGnwCf4++S+iL3Yh9Eh8Ar+PmkXEdJ6KKlgzf4iHmlNQlqRGYwvEq1dPNKahLTC4T++SBQE9khqEpKKqFR8keiF8CBlEkg5TwH0CKRMHqRMAilnCz1jrPl1Ed9hxTdCExhfJOqxnm9MZBVhchIlnlKENyaPNyYZ/UKoG99I1GE935jk9TtPwfUIcEwecEwCHOHUEgFSKSIckycckwhHLC6jfG2KCMfkCcfUrMMe3r+IcUyecUzkFeGkF+U4U4Q4Jo84JiGO85QqiBDH5BHHZAbAPaSbUkQ4Jk84JlXvngddZooQx+QRxyTEMa53a62va/juKsJx9hxPaRHhmDzhmEQ4zlO6ISIckyccE3FFxBXC3hoRjskTjqmnn2N0RDgmTzgmeQLP2BrPml8X8b21l58DfYQ4Jo84pm7d9bCtjxDH5BHH1NvP2SJCHJNHHFNXuYAZj4wR4pg84pj6+DnjRJBj8pBj6vPnZBFBjslDjqmvn5NFBDkmDzkmEouHxH6KGMfkGcc00s+5ImIck2cc07DuekgJR5Bj8pBjGhpcD2NaBDkmDzkmQY6ncTGonv19cl+j/RrSIg4xeQ4xDRtbD+vOiENMnkNM4hAv+MO8neqt+XUR31mHDa6HaTww4v0+uS9ig+th8gy8eL9P2kXMjRfZqiiXOS8z8cFfmDHmsiIJj0Hmrv6TqZLjVr9cll9arRmLanaNrK8AjSiKL77ydR7aS4L2Zuxmac2vi/hXQZgbQmpBMQprfl3EvwpE1lDarCJH5ifEiHJLnnJLotxm7ERpza+L+HdBlNsch1k1wtySx9ySMLdjP44wt+Qxt0RkjQnt8JFE74Kn3JIMYRFLir9N9C54zi2p4PU8pUsj0C150C0JdJsH04IUgW7Jg25pWX89DDJsfuBiBjxKmRaYQb23TFiXdfMyNc4gMPptsLzra/0lEuEyl4V4r6lE3WQ8JIgHeJouEY076a3UyijMZOUUE4zKJwtRLFrbYkT+YtFxMZ5DuqOwXFWac9dhSXPedrisZxfepn/tBOzNgy5ezQxYI5slvzqg5hCBdXIOtPdS7mtyqGrvxIlH/JIQvzkPHco8Y5FSSwRlkCBUrUsvhL56+5tb5btUIrN9SZinJM/9W8JsgM2uDIj/lw4TmcvOGlywrev0dGStQqb3oAGccwuKL0bRB43RVbePbuisQjdoy93r/bd0F9EhI3R2SJAGZDjyPyjYaPlBFeak6bL97eR3pAWkCn3Z4XwO13247otNSLztCis/f1Cew6rD10/mh0XikSfdXjJn3W1DRyMRuEurx6Q/iVMvVnwbBr+oF39REqwcoA798vK1j/jnjvovFaCa+YoBw6dqANY2t9uIXiwWAmTODa/TvORDPHZdA5mIyKZ8TqVR0NsZsk/mrmP3nPQ7mBjgdft+dBcZOg82ACkiQ5MnQ5PI0LliTz5r/teTb7NTL28+9HwVEv7Xke9JOKw4t+BR0yTUdK55uKv1wxL6Ldm9/YFvXT1gNhUTKUO+F+MP4I1hnXI4T+m1/PE4axbOOlfsHKhmtz6jS1zOz1D9GJh/pXObxaBVr7nue6JUaF3xHJI9KJtFwq4rthNU8769fV8732zORMlyS5NFKLFspI3XXY4yrWpymvsOX7eV/G3JwBy6jfC2sv28SNlSXY+nBuZhchCn3z8zjCJu1mXGg0xoc9iH9qfZWC6UhmIUncOwzvEdIkQN5nA+Vp4WcJEysSBpGhPFJW28ZLG6xy4W25TPhfJ77X/Rs3yHk00JC0XMmpyEm80EGA9U8hX/drIVBsBLf0fbAvnrDs4OdBGAn8DFD6RXcBL3iTHFFC74Azll0CVR9s8kjSA4sb+dz2NZtPrgP1sX+j7EL4nTiVz61qXxmH8wnsP5HK77kNbAuq7Sw+PdD7LvB1w7wK8t7gfFBh9kocwPbkurZMaRv7yf+jJ/vsQSqItpGAqSeE9Mj6K4psqB1D+5ZUHem5i+eiuosoens+BpFNqIb7h+vU/3+63xJ5n3CV7mO3UEc0MSfboZiMHSomcwAY31sBrrwGpkz2ZnsdnQu8X32AIZFWHEdquoeOtX2RVGOhdGcEz/ElOltZVTerh04kQ1P1JuWBzLNRL/WmoqrqvoFQ7zh0J/pMHyohj8VIgUit4Eqs20ViuZlOr1tZv/2pzM1wEMyRFNnj1Nni8rjhmHd3NEk2dPk2fR5CvHm8gc0eTZ0+RZNHlsLqBGOnhjQ5J2wepdeZameCiweZegJQm0CMzmDyUMVgiWUjdsmu8itfRYTFZfG+tzWa4WgwjRJelThcFZYx2WxKrEDEBPJo+FtsP0nLqSjXpIcE+68xRUby27yi2VkipGW1j0ByPQnLJyLVoR8d+8HpqfjQ2fP8hh1fwULWJlHtjBScELfd7to/OUCJCAl8oNep1ijtHC88LIOVRcZ907Kdr2sODIWxeSPaqeU/qlvVXzf/kdl/COwwoYdgv/3HL+pfRV83/5LdfwlkPxTfaUexbljiLC4QvJZoJUuyJDNUCM5r3a7NHh9qJuAdEA+qqBqaJNjyGxQJ4GGVY5vRUujcBg1i6qf1jh+DIlMsqfirdRCwUcTb59WDwIEM/Jihc10tZtsWIRQWFSGbTiXhI0w3lI9elpMUMNBs92Cng7wYQqUB5Da5Z9Ep/kfA6XDl9P1c+FIvXXQehvzdo57J0Ci61z18qVeLOS8IU1zGkxzIdawS9IOEDrTc6dCdNKsHnIns3PYvPXAeHKKYi/Zc/NZ3Hz64BwqdkW9MH6/bsU0XcICWv6we+nzWrnRAg6NSHsEk7wHprPouJXXD7emu+92gOI3Wx/Ng9OLn0R1PpHxH0/+542SMboAn+F1735uU8g/TpIzNX8w1Pp8T29jc8KDSaB0D/6vCanYKy7+zSfpUU/JgbhCoeL3P32LXv+Nou/XXGRemt+XcTPOoJj1wGEyxEcmz0cmwXHrgMIp+YnuHeXU4ZskdH7R1CC8ceqKPOBYZ0H0nwXe0OEXGGJVa97eVlT3Ps8gJsF4K64jrs1c+9mi3XUsiF7CqUIKaor26BKr9uhQbXZOgO+uoXfD+N14/tBIxCahLHsvYx0R/4Dip4ZZIDDg/kNXIPWkxgxCu0taSC4qBNhnTIq/nHJtKpqJ72/tJ9BzEn5UMREzXd5jqGwDpIZtwsdDVLZ1xXjxEY74W1kUSQcysOhv27FD7uETU8eS3mbIjv3smfy/Z5zaSta+9dMK5Nzzr6MFWNjRJdjFVP6Z/Yl2PZG+LKnYbNo2HXgydR81/Pa5TvuW9eGDbmp59bDG76W1ZOVVzWCO5P1RMsTYcvtNa957jaLu10Hci1/cbffsRtaEF71a7P3FDgfqphW/sm2XWQ59Rqu+zVsKkLircqzp2+z6Nt1UHSreT/UuzjKe+TlCgXmJ7lt48giR2Ro60a98xpzV+RFOpzWH7zdHK7APOabhfmug+rbmtnJvtSOkvlhbcW4IirOaY2AJ9sYPAXsoXkKs1paXC2M8n6L/MAvZHgd/PGt+XxDjIPR2OX/9ob8JCKj43UQiKv5Hv+jvFRh5gnBcKnXsbxohIgRRGD0HL+kbhOzA8pMWbp3tUNwwRPOWYTzOgBuObJSzp5DzuKQ1wErU/NeMt4LGI0DgJf1FSA/bFtWyjiCAe6ZnZK89OpbwZuwU45Wjx44zgKO10ELniPgOHvgOAs4XgewSs2UFJeuRTELZNCVnKUUWYGojqYFsBloL/rqt1cdg+xR4iyUeB34IzU/FXUo56VRKAXP6EyPlY8MEHhTDOljZPv6ATheQy2BRBF59NfN+fFVPsrrwL1Y8/nVU8Xv/w9jgeeds3jndeBOcsQ7Z887Z/HO64CM5Ih3zp53zuKd1wEZyRHvnD3vnMU7rwORoWb++EivfBcQ46Pbq4HB6ZOvD40uqOWm9u9ewPg78dB0FjSNhUK4aqrpP38n19w1zQpt+C2XfroTP+KIvD5ZOar5Tj7K0j5/pR65oJ7tO+GoqiMsEK16b+h+x9xj9hh3NmdhVMgMco/ZnIURhW2K8HVVO5UwS9kOJLt4izKPToyraRm9LccQ12e0DvEJheuwDlG4rrVu4bqah9W1AG+owB10QZauwPtNswjUelLgDvVyFbiDKZgCd1jP0uqSEQxAstgNL2Vn5QussN6o7wfkh1Ah6mvEIKaaGQLFLHhZ7CUzW3sVPam6hoVAsTdk6DMnC2xC2cwnRWGLHJsBbPBhXVy8M7YJ6rFtn3I9rEz3dqVRlj2sYctoZJKWRTkz/O4V5Sz2rC4sXvms+Expp5aoLmTNKaAcVhJEtglDNsx3PLTpMOvqr2fo5wER+ifjUDW/N5WP2OULHWENHVrq3egInXLMEZ3VX1qt//Aka5ssvG7UzwlSAawDMqXmO0/JYR8v3rVFrTGnsys/P9XR/xMFn+3D/rm58cM5Va36rPKVjtKOi06ODyZwF9Z5MAHts1ga4S5PYjsupqYGt7PzIXUICSzuMZc3iMxe1ZClajiZrao5oHO+mJxOL5HSznRO9jKILBnEOjBj1sx3qihG0JS8SJMFHBP39HyFyy7t04cJdFndib42XBEWFR1Yf1SbcbKy8AGWJp2JLDx2vvVd0tpMowT4WWhpqdc6tQ3qIHkxGaFAHnxaknNyDEyUeE+mOFlMnL8aBK8AVxGRxRH3gBwN1z5nVRSl/+etMDbLO56sVocaBZM5MhjAJ4mKV71f/ncSzEtHcrO5/7Dwi6Qj2UtHsqQj68DZqZnrPtSXZtV3PGH1fNNMW73q2cxWSG8D15KqXVoyPezLpyBGwtrwEK6uot0JKjRxVmVMejA6+UZishesZAlWToU+ciRYyV6wkmXKvQ6IoJr5tbWfepWh5/dHtZkbjXgsYvWl0da5jeZQtIZlgdMiKJhea1YviMkSxKx5WOQ1BYmYNxgKiELoO/Sx/cPSxfjY/sHqDh+Lo9fH+glFEpp18GvJkYYmew1NboZdxd7Nar4XY2/+65v6uhSDYrx43of/MQuWvSYnU2Bz8oJW671U5Z/JBqvtoPOzfH1Wrf/E+mIrmuyFPZkinYOhtBrvGZsBJrynd9/TJnn8FbJnZbSvKZwFIIxN7QwJCVP9mq8P0Gf2wqEs4dA6QJ85Eg5lLxzK3WqNHBKJ/foP5yb8W+vMx8nJa40ydUMnk2u1Pr908Pvyn35y+j/9pb1eKUuvtA4Ma470StnrlbL0SlAexxcp/wwEP99+3Po4PEQ//kjh9JfnpzO6gvBg+czlR1z+HbUGiHQyDDnI8pRuCgWmyzTZwqSkb2eIwopVFasPxnZYuJnLJsyIiX4nF3OejFU01tjiKlWR1Kn4NYbXJYn6+xfxw5skV+tQbErNhCHAv3LyHlXyCvhBZWZn+85fQJQj143JfDJ/BPSVi6Z3kyvYrDjLvnW2l2vtf1WG8h9TUK4dIhlWPoPwHeuM2CEd9nDQ9qXW6mp+fXU/KFP1hTwcPZ/9F7+H5GSxY3IbfON24UTsgBLd/hbLQvFHbra1GqY9wfidlfdGJkdrMXouMLU3q0FC2DYNlTND2kEPh3t+XJw5m/3dmfTRVw7i5l7MliVmA+QWCWpyJGbLXsyWJWZD8CtcVUdituzFbFliNlTtCdcqkZgtezFbHlrwHWS+OZKzZS9ny5KzrUOVqhzJ2bKXs2VK0/J1qByVIzlb9nK2LDnbdagclSM9W/Z6tixX/us6LAIjQVv2grZMdVq+DpWfcqRoy17RlqlOy9d1mCYjRVv2irYsRRsXj9E0GSnasle0ZarTMtbp8TOJeqxXtGUp2pBevu5J4usaaOVigntY2u/OslP5DaAfxwA6sd/lOWWyiXBC4ThOP/xaDSOsvRgtT0BXBWgLo/oo95Wvq4fgptfRZQnlDkootXI4Q8Vdm43gXMRbRzSIRdY+MOqyAQ1UY2GcEJwNc+pIN2twgzmbvg6iEPo6mengIks/cqCQ4+TLjDPh5cdc5sivZbgX3WVZ4UOgEf6abKbxPGIN3+O1xlj9NnCZefLw2qKg9LmmMaTrmwGbg154mWuuSX9NpEcms5qjWl0CcxqqdYdiUiP7hRXppYzsq3B39krAPBWJ7ViGt+lfGLbya2RWZ98hNSvd2wbnHVGpYrRYj5rxu0kvQ4EF2Rg05LSLApvX7YKLVUjdFXyLHlpXV8ysMt9YVhDn2KmZCtUEhrtCjLx8yoYeWe/0YlQR/zYpuMbnaesAxKpY8Q3JChxiVkuvNbjXO2az9T90gWik9HLHbKb+8aQfiR2zFztmiR0PybJI6pi91DFL6lji3WAkdMxe6Jjl538YZqWCpAVbpo4AapduC06WobuQaVNUGy89F3aQo5Gaw0rDDhhnZvVHxmMQysj2r7gczOiRdf9xM084rvFgC8kXBloRxeHRoRgewuB32b9iHIw9j3f8x16MxS0DXfg3RUtCmGra+8zoI0AOdsxCOSDBm9dz89OCxJ2nDhBNCl7amSnTBEiEEtGvbhgtYrywM1OkWal09b9dJOrMXtSZqZ08EUlR7YLs1ZaZusZDnY0clS7IXgmZJXU8dMKockH2ssZM0dyhKoUaGXpQqRiLApDtRrRjawluV2TCJCgw2TZveEerWIsZYECNg+BevZcpnjvhElE5hOzldpkCthPgEBVDyF7ylilfO/jf50jwlr3gLVNrduIXokII2avTMlVgJ3ohKoOQvW6sUKl1sLMpURGE4rVdhaqqgxFNuYJOX7wOq1CQcyAdSlSToHgJT6FE5sA5lKgiQfGimkIFy8G1pUT1CIrXvBSqQQ64Q4mqERSvHynUghyghBKpR4pXjxQqQQ4YQYm0I8VrRwp1IAeIoETKkeKVI4WqiANCUKIyBMXrKAplCgeAQI3BMPQaff7DQecJYB4ShcXrJQrVCIc0oRr/i+6s2tA5WjhAFi+LKFQdHHImJUXvktcpFAkRDv0nRe+Sh/ILYfgRlxcoKXqXPD5fhM8fJFBqfdihnM02lIIXQlcSkmCFRH0Ghb/cNOjcuGEjkpsQ0OcVoVnFM/kl/YhRlRS9op6dL2Ln4X5CL2V/jegd9Yx7EeMOTj4IIJcUvaQePC8Cz0scoigRd148d17EnR8UCiXCzovHzouw84PHbIksmYsnwouI8IMcoeSo63vAugiwPsgGSmTdWzwZXWTde2D5S+TcWzyrXMQqH1D+Ehn3Fg8QFwHEB5K/5Kifery3CO89ONqWyFu3eOi2iKqthxc5stYtnpMtAmEPS+sSOesWj7YWoa2HtXWJnHWL506LuNPD4rpE2Gnx2GkRdnpYXZcS9VNPhxbRoYdaa2r9MrGohi1jJFRlFkVPeIqbQZ7bXhepwh4B7q0qLj67T1kUT5qW8mMLX0rU5z0tWkSLAkIPCheVyHe2eKiziNo8FCkrke9s8RxmEYd5INpLhGEWj2EWYZiHEmUlojCLpzALicrTHBoxmMUzmEUM5gF3L5HlbPH0ZBE9ebBoLZHjbPHcYxH3eNi2lchwtnhSsYhUPOzbSuQ3WzzMVwTzHTZuJbKbLR5mK4LZDju3ErnNFs+ZFXFmh61bicxmi8fBChmrdNq7RWazxWNZxbCsQ1ePvGaLh6yKKKrT7i2ymi2eFyrihU7btwgXKh4XKsKFTvu3yGi2eG6nGLdz6KcRtlM8tlOE7fQYNlYr47tY5lsOt1u+ljAX1qksMcbVab/TsUS4UGDOpGoUetIViSVNxJoNuk5YJnYoLwDhTbvP9fscY+Us08Vw3WDdk/1389qtTKWUF7pSPAtU2q8pILLGLZ7rKeJ6UGUimgIirKd4rKeYNe6pL1Ewy7rXd3Hhfyx5HsVnIr/TWIygbhEaUTK60dBkHVF1eqzzYIZKtOIhn9LMMelwi9+aqS8ffN5XPdzXesRQpDORos63Rq5IU/+ls6INCKSrr5v1Y0SbP7Jtam2iquVHAxkHsfDLOq11xKWKXCQXE28d2xbqSciFL0vb83ehz8dFKG0Rv0D2hsrhwdwEaRwA10zvQ8t+UQqPOiEX+QME1K9mV4blvz4tS6/Oc/P+twx/912K8EKuSBUBERhPVH8QaS/7XlK9W/kSv0MGnlUq5I5OqT+1buujR1fLbGNtXz5HOW0RH6oRys1PlfCw3MhMYyKm8FWQPOdQzFc8CVVkoZwPr444KVZnaZTSw+SHdmStWnqvEG4mV4H0RZ93bfDG2yrfLCbw+A9AxIfKzJnICI3v6E8GXzgJCMbr9j1RVeTefBiAIu/m4lmoQq4p58Pk3nMkG6dTE9/HqDCFfBoRy8my1MsbQmFFNJY+ybdwurDA0lAxytfd+qlG0NXpC0crIg9QFbJHSJsGec4SGUQXTysVMjx0oXozKCWyhy6e+inEY0BKddgk+UtEyyEP1BTCMYBy2mdefpSKcJricZpCNAZWQUEKqUQwTfEwTSEYA5l4eIloLeRRmkIsBundIA1ZIpCmeJCmEIqBQC68RPQieIymEIlZMVhkjVyjQNTPPWItf+uWmRMyo5YQBsAfousU8GUM+KxgnPKrf3sMpwyrGH+4i6iDewqnyFUa1O78VP+ORAxO8QxOkak0ArYB+KLWx6TuFoN8KbCvZRRsIoDAmZFeE5hHEkLDyvgy5c4Zm9D528KueLSnyKwaOFx4b/1mV8nCUOspEoBuB5X3ZoZf6TIWJnHuwzSsGrQgUOgcSaAm3YM6PbGAx8hd5114tniIqBAIyqe41hhfk+D2/+MMA03LU1z14dihnpFmPVerqZppIYE0es17BqTBQn2Hdj2fVMaPRHSJ6KTi6aRC5iej/GM0BkR228VjQkWYEJT50Wg2o0HAwzlFcM5B9KjWW8I0ywaIpdGHO0/t+3luw8dct5NwZilZnrsL/tHi9q0ZKJ6tKSRI8ik2EplsFw+dFDPZPqxUIuqkeOqkkCHJp8AGW5vo0ckxC1xxMUtMsjskq2Bn9CUGZu1jhM7EYuUXRVU8ulIIouQeEzRqJUhBbp4ywmmLD9FHXHixuhq9KKEIvOT9OG0ByZGYbFLi4rpuIkTL57RNT1Dik4rEz5V3tVa+SKSuqvxKrbETXMI/IJAEE8dMo+CG0YM+UzpH6SAWrl2SRvyLebdy5Q3qaFxm50qQTdI5bcWw3qZbFVQ9mED4Ga/H6kdGwjnY6TYotv1TjdYUHucpInbG6RWKFhUebSkEVfKI6doSsS3Fsy2FpEoeh1FTrdN65RP25U9PCSjfUXZZ6nrId7E0MjuqWEl040nPs/aK9npSppB8gQghon1LBMsUD8sUoi8wSA0fzGOATtz6+S51x6s1BvFoWlgbBSjsi5IEpDPGtDfydUN+XCJIk0+78pW/JS/3gLlHPfPiwCSfZUGAQpZ3kTY4Qmg0xcpOB82qbOelkfZ1g37QI6aT1yF6+a/DeZbyZvJz6qfhrV6sy46XD4j1p7zMsosHgYpYnwNMrVbJhLHx5kdiqQUzj/GpvbIiaft0zKi0bbhA0FMts63kvz/dj4xEd8p1CMet9v3pEAF2SgELLd11xNEPRBoYVdpXFJoJjNc05TGhYrbbh9goW7Vbverz0hWCwJ0Cw3EfUYgIb/xr2Tyhu8DOsXD5V5qH5IqHjgoRIvx08QszFNjCEm4Htih24e+PV4RUKBlDDOMyVFAvRGhGv1Qer/nKk0uFHBJ/xPA+5veDKYpBLbuhuu/nwu/Gu4A0uXBHUKGj2E/jD17v9Y1UF09BFTJN5QBdlgiDKh6DqoSayqH8sVplnPjtBUKeQz9m1/gj52mMP4WxGVjVwwZfjfmSX7y7m+qJqko+qhzIB7Xq2RVMkJRgox5uoQUosj1FdS+xPmHuDxa25aYgSu5mm1LysOKdKIQesA/Vo1qV4BXEsvG95b1oytv2GAYDX+aKXJPDBkjTDgyQS7kEbxRzVvwrxf7qdTvZ386PCEiNsK/qsa8qs+TDWrtG3Ff13FclxVUOCIRab0U+Hw9mB60pEal+DKKzPD4wg9FqhEePXIGRIfhAlFJtF1YIGmEzXnqkxa+eMKvXD3ylRoRZ9YRZJS92fmbBwqh6xKwSGCtlhKWaasSYVc+YVRJj5bCdrBFkVj1kVmXWe3gcKVjKVE+D1ZR+PY4UBFyq57YqKayC7SxmTv84InCrenCrEsMqB1pDrWZjHtiX7xhB0CkZuGyQNTCOiylENl9Yrxe+0ktqBbomv+7Tv3LpR6aoRnRY9XRYJZR1fuRB1LF6jqvKPPXUAyOQq3qQq5LKKgcgRK10d8mm1MRipN5yQxWE33XgURtFW7fCqr/aBw3Ld7B4B/0lK4bT2u8j+ecCzZ+msS5msA35z7X/rvG3r9uYokM2RIMP5EQKBuIPFiiw9S0MWBeKSbAzgKRlt/Izkg/eVY+n1fQjlFIjOq16Oq2m9fNHjl5vT6fVLEz0MGHlaxfowQ7NHD+47b1MXYHnms2ph8Ue4OFpGtRdugd2NKxh0BlAoatGGvLKsNRsorEaF4LYOa9uvh257EIJmd0CbBF22jDh4TwIQyGV38ifCsuensyOo8jbHvlelWPAeqvTB5oK5rYNTB73kdsapHALDo+goi04rsItOM/JGgSmp9yC46aKXELSpB0jvUH8s/dUX80/EjI1gvqqh/oqCT3aBAbxTbU+2bKnwAFTpBTGsa7K4leZFnGFr4eSZIVpboS7ILDiPq7Mna8ps0QBreqhwZqlFi2hMU+NoMHqocEqw9QZRw1rBA1WDw1WEoDwB4t25fX2N8W4Xzg+qANrNaL8E+JXzyoj0ywGweHCnTXsgWgZzuF+bpN8GJsi8G/1JHYG5/tW/dBL0LCs2FRLrdxDpPb9syKGBYk9b8d+LUJrZneu/XdZ21+CryoDlmXFhQSrJx5rlo9SbFRUbz9TVqVivYBUnny3onJ9u7UTpuPrj0LzisAhL8vXnVUpLMaGnRjVgXB6V6kvHBWG2wq7cbfPQoe2wu/y34ICa12WVi9MpiMxzfgeJ/LCZDqvwmQ6y68xmY4kOYNqkrsyGqijYgn2etX7X7DvYN9x7T13vSxh/3qqfjIg0AmhWjwKR7OBZ0BrXueAXo0Q0OoR0GrupjHLUCMEtHoEtBapWg8TSoSAVo+A1pJ/xM9qhIBWj4BWQZ4H4aVa98qDyanLHKzgynQvPGhH1dD9MiWkPOq2djADNsxLXDuAslcUl/9CJul1d9jti7vts0RSILOcbAVSKYrFKoI64w9rA6ZqVyGxYUdcd2CUZtoJ65OKiQf/9vVc/EgqEhUeehdqyvjnEo2kHkWtciZNKx6Ny+2/gbeSDxkrZDqHJat9eC3zjEVNvUTBZpMnPEuWLKIxiaWkBMVwyabiRdd2Auw52cNFX8l6y1PaJQDp+9vMyrwQU0FNqMKHy4GZS06tAfbnVhOP5r9Kl0KdYx4AlVpyuY/qfdTuo37/C4qKoVPJ825d+4gy0guZQj4VTB5FgNoftRlcTbHMk9qaLvv6Xfy0QVa3HvD8GuG91eO9laxuPeD5ahXLcuvSUfwnsd+PtMfzMex3Qz0y5VEqBbm72oVeIxTdeGpz0FMO8cxspXEa8yPIe5Q/rEDtny51nM7QsM5pEQdYm78uMqDVZhE85V03o5a1j1iCA/+01nQf5bu1mLdLVXIHLu3cXLxLblRPN1eiyieUqUZ0c/V0cyWrXA8Ee43w5urx5lp/bd0jurl6urnK/LXG9qc1opurp5urbFsPRu1q3TbSfL1QkP2rBAlnXSituXpCvKzSoBGFUWqVJumvSkSLkbBZwO91X36GqL+CYxEwXT0wXauEwDksiFwjYLp6YLqSfj4gRjXipavnpSvh53pI96qVT1Z1GXZ8jRNUQ+/P646lEeub31G1r1haJzGLB8ztMOwu6aqAxa2qWIzrFWDzZHYVmX167lFwzIPZVYaYSCtH0aAIzK4ezK6krBEYDhcIEZhdPZhdSVkfQyQRmF09mF1JWdfTDjwCs6sHs6vA7MMjjbjs6rnsKi4beZdo0GrR6+AR59qU8Lri0SJinKtnnGtTNPiw9WFrYfa+am2RmCenRajWGPCYL3uG4GzKSoOdlSmtdhSKk8yLizyhVyoZKetSeoGoriPNZfh3BJ75d7NaWcpJP4lk5qDIIqdPwzzGJQm0QVqIwGMSiQbEtwcDComOZ9oyYL6rl6yLqy0/GpdJMrUZtDTRhqcyfAEXN5ZSNOfkbrWoqhEEODfvo7WPhMXjMxQ547l8n1PFyr86tK6BYYGqcGLf3veHMVzAojWc3THm1GEf8fpp/TDVfkXWW7Q68Wh4lcMjinMHtmBqTVwBQGEgamNiHVdkpZPNeZgF84qqI3fuLbEXrFZlFFVouJ7AUrOy0h5qmVbKBHCRyu0mz9F4poPkJR2Aj63MfmPVxkHTzlFigOAJq93oaNhOojJWkF/OqdXT5rXNX7GUFg18nrSu5JqPsZTIFLJ6FLoKdo7NYGqPxj2PI1fhyAcxQ4145Op55CriGPGVqDuIVmbgBkZQhIc/nZMWfXHpysEaVPwdsWqA/JwJcKwk1hagVzicBpBE9chxNZ/Hww46Yo6rZ44rCWJke/sAb+ivoeKlF4eBYkOTdq65GHQN+/RtdLPGHr4u1egoNqTR4YbvBcvZFVbfnUpnJCaltQzvGBGq4qbZ/Js5OGlwQz6S8dDcrM46dAkIPIF3T9zBIsAzbFNUba2QSIFWvmfUW3KQYWSS/7ZN/QJ2jlAWvaxUwQ9DlUWJYRo/7iNtFnG07JZVHBhJVQpFmAtol4rKq16GPg0BYnhJswzL/qf7I5pqY2LJITMuHq3dmq59EU4cb1Of6rnwKpvKgyeTWrkbhuCDcQi4WGbu+NteqVEmwomiFREsGNsypxGZMqIvyH1RCzRoXOswny9+U7Mf0664oIRXNevGsKyXLGnZOq1YV6O+4l2gq3qQvXZVZbri9XLvO9uAeArjBlOpOC5ILe2gLn7NP/7o+BNN2yhxb47j2JvfhY41WbOGIYsvsGOzw9ZuXTwtmwNK2uherXqecDm1jNBFM6duawDN3ngI6s1Qv2hSRLE3/svBwjjJ6o21vP3Om0xJQRxwWdARScly50TWq1sirOVd1oFqJoySLKuGngYzDM1pg5WuFY9sXPj0V0X06vUAtctIucTLrH7HcaG7o7H6tXVL1SI3DADcu33FZ4cZh9qsq8gqsi2MliAOgHScfhc9VAau9NgwMLOfkcBIY+/AVfwW3tj5LsUphQjmsMIOAlk0zc4R0W3FghGygOPBMu1g476f//Le97ea76Oio9fz8xMxpQ3tlNiU8+gtLP+egy7VzHkmo4YC00QNm2q1YRShxBydfVM0rxvyszqFElh+xjcUzepeW1GplGgHebi1Gn5+Y+dfgiuivwh4bX/rp7JhHVZG7XZWb9yn40yzOlj+9rxuo1KF0VDqIxo9IuFG9cKNShEFBKNhv2drsbzmXZNE5WZZtZDF3Gu3jo+1u/R5CPpJCdcIrTEUyThG3enIusuOKA+Nf6FXAJx2ZfBykX9h2gJDB+N+8Ndt8pCD/75eRnYWpii69WN0dfkcXuZziAkD3sZ8F7pMshGP61bIFrfXGIbmv2UuhR/L8Q814xs3HoizNKHL/LtdM4V2lJ/r9ap4mUql5oT2lOEjZ3aQtXxm1VetNGVmtQEsg1A+jDjmX6Mv9ssAv3pVS6VGhX8dbUTHd1Ga9698V57h6mMsWzlh4NXkvrbImAPcJfq7m56SPaFYclvTC0GB+7fJXC1Rk8EV1MIOhHHq3sd3N7GlUbbxER+rZRW4Oc0S/IVVFafb1MBJot19aNydg4E0dgTS4lC2UPFpf6dCOX+NWxv9g2Fdt40ZB6C9FKhSfwPRU7i1GDfoBeGKkiyjWhSZ60QO+v2qFuDnGyDZN+6eXZRTulJ7pVgeJe1tGhfrrVtQWts0VdgjBIKVmlzWWcmb4z/K0M1sF25TRRWzlXjGK8Kdm071sMh99aqjSl0PVoPxQCW7Yb69Q+saSD+VFylT/Q8ZCOkla7NNKwwAvp7BkAJ3EljMQg/y/poaWUZRYhLagGy7TITlqSX9wHOmpS3JbGzVg1n7aGmy7H+NAtv2EqVVL2GqkjCdgt+RDXL1MqNK0RBKvsTzULTL9TqjKhfkQ7AhkhlVLzOq1Awdg9+RzKh6mVGVkGgdQrMSIRXToPwH0de6ixE9cdimDP74a8LY8eYtDqAJ57YorVGD2F7wcvXiozp/BRIj7VH12qNKIVFbB0Ig0h5Vrz2qFAH16zB+R5a31euGqpRBp9hs5HlbvUimUvLSDz7nNVLJVK+SqZS8AHSPrzG+e0CRosdICZJEVBHCUPv5/UEnVNYYuIPu3KEV+v7fv3q/ikEpGFEZ4H/drH/zfjnN1kiNU70ap8718/eP3jwvoKlLvpstDoFEAprqBTSVApZjH4rsZqvXvFRpXuBTPdpnFf/jsZnbQExhnK4JiyMugoOFFcWwlSR4FAMLrLodzLamIslJcRpaPzAc3btta7mBTIlx6cK0OPL2iTYXiYQpwJmLaWzNlcWQctzEpuUubKK4ZsEMgwj1JXCu8mPBL/Leek3E5a7PRISlcXGE2A3jzVdSTFOfq1AHAvHofOzc3NBen8Xyr/tfaKmCiE3TVhwDV9fs3zly6bk1haoaSonwWxIi4d8hpS3cEGnEsZ/BsHKK06p/IeI/GRa4xvxbXL/hXBJ2iJtJic/oksn3ZR+dhAdwwz9ZZgwXZaGV68MqYovBJi34VJAw0xu8WxAgX/wIO5z8dWGikdZ9Nu+bzdf9XTJr0ezD8vwtg2Y06cjtObt/8nzl59MYG7Cz6/5nDHZcL2uy6mVTdf1Kg0Z+yNXLoCqVScdQ7oqGay9mqkuWfgB2wnc+Gq+9KKmadzHWOreC6usa0XjtdUR1yXuSdSSjGxnfJheJo+1auxwjFZsqAIjdv+qlYA6iUdFK449QCO0whjRo+CXR/eRzAbNQ7HtQfiBRC/cKOnvFUV2/LGhqZJZcvUyoLjldptOQGw3bXifULmUFWE3xfZFmQiHFgLkARryRwRrKq3YFhKfwwRPb1G5oIHp5aa+UOJxgW/zXESnhwqkT+kBVhM4UNQJvi+A9wpiJliwqVlOS1rs4IhTSiB9dYYGa5kVI7ZIjZ457iZpvB5Qv35PtYtJIOHBpLad47EOCTjK5X8F0vuoucgVjzt1HZOj6vt/k71funzmeDpvplkyNRkoGyjPdOZiqp2TzzFaxWQXrzCex0DsRPz1sMnX4uqnsb0p2onkcHiJDA2kXr90x8O+6GAxVYKum8G39jlRzMYUSRQqfsJD62JWP5pK+Cs+1WtWi1S7rMiiWid6x+M3I0EJBXghK6LBcz2Ha/0zKrKv5bVLzsqomWdVVUriyULPq3mYFujmvXdrVbQk7oguM21/FDNCY4WBsatDsisEa7BTp4USFsCp3IsLOUAMrcHG7garYrInKOACjOzS00KYcOgt2kMYKC9S8qB4K66vYIbOclGcxGmuHGiNxNPWnr8dT/eNp50mpRU7hzeu4mnRch0mpRUKu5oVc7Ro/RtYWCbmaF3K1y1K2cWTWmpl8yekJq75qBbar7VqWM5udVYJOtBIFTejajRhrvuj1YYfQDJbl48fNa8UahV+owRalUtXazIJAioJlN4k4okK/sCJIFwt/M4WmW+LC9KLgE1JK9qLGstV2oy0Ybv3MQk1ZhhIhfF3SnllQ94ZLvfpFnDIpAtdWDiFYOq2NlSthBLxPET2uC7meXixTwzXs2DmkInAXS2ymKuTwoLUxhmghE7DBUH6xWIIHgVmth7kbz1tJo+QFkHVlKviUGNdFybyuWmw82pHFztUyih5RDQVilIXqkDBCd9ifD+M/u2BSeAvcAgtAqwR3WmRJ6FBxMetx0eqt1uew7XLdV71j3Bd3oU19ayel8lWNbfU/pNcCtpTO4ECLpIDNSwFbyj8YkBZJAZuXAjZq7k7gQItM3JuX6bWkIRzpkrBPBsve5oV6TTbupxEmEuo1L9Rr6VdWtUVCveaFek1SvENupUWO681L2hoFaqfob4s0bc1r2hoFaqfQaYs0bc1r2lr+VUWsRY7rzWuzWlb0IOZv1RqIQ7laxYL+kYl2rqXqPwXWmJMHPfmlXWY0GiV7O0uNKWwzdfS6W/9K5B9huxZZuzev0mqUXLGEarQ+jFRazau0mlRah5Bbi1Razau0GrVQPcU5SLVWFfFashmRSabZdX5vyG4/wUqvBMw7goNxRLSL3qBw39Dei1Uo4BL6ukn/xuUfJFuLrOObF1I1aoAO2G+LnOObVw01SoDOP1n0vnnVUKMGCCXs48e9rJ8jYXO7u8ikEkS3SWDwO1CFBpSwc9uK/BYVkhR+YS+xz437aIa+m83Lklr5wbG3SJXUvCqpFVXhOazBStqwzSr563s+JnTd3L5khMu5n+QYy1dvF9LHU1Rp/vrXaWoRfEcveWrl1xscKZ6aVzw1Sn1OWlC1/jdrQZtXH7XyS8fZIvVR8+qjVn7pOFtkhN+8UqaVXwLLFillmlfKtPJLCqnWO6f3leNkMpUoKmMV0wxPRbcpyH8hdtGTlUhU6AOJ/MLtKwgBiRtXl7hR1yusIoy8ncSNzAgSCeNV1s7qQbS4j7rlWyVzQTiia3Kq0DTkMN/ZvOCllV8BqBYJXpoXvDSqV066tBYJXpoXvLR6/dCltUjx0rzipVG+0g8mv2rdapWnvo6koZB6pWXGM9UqhlZm2aVN6YyXTK6ymhXiwR6Pra8780NE/TVEREUCmpe8NApYTnqVFmlemte8tPrDErdFkpfmJS+NApaeY42ZWt82wnfoTI4OCDDUscOmLdc7nPYE1rotCp7AGnHVD/KhXaKX9+LKq2ta/TXZR8UImpe8tPprso8kL81LXhr1K+BwwhGLrarJngIXRCEtNwQMX4vHvpC8L0ptZ+7i4FBGSypeS2IGpGaMVsE5bULrplVIcJG6gBfFSuZjqN3twGaPMadxg3go0CvUDiGDJqUDIsC9mY9hk4IB5zReVuyCL3NDpC+fHWXzSuzE+RGM0L4Zwp7S7CO61Pv1tT/1qqBWf2ADLRIFNS8KalT4nH7sSBPUvCaoUeDTDyJMtW4H/FxMR/nkJKQ87t9LYOQllGv4ykmw0PHgc1VbL4zSgHigcWz3IrrmlUftl/KoRcqj5pVHjTKiXg+R6Uh51LzyqLX6wx6oRcqj5pVHramWeDuEZKPyCs1rXNovjUuLNC7Na1xa+zVYtGiw8DKRprIDhxx8i2QizctEGjUfp/x5i2QizctEGkUfB9dxNRK6y6S8UTWXeWRu3pCMRrg42RzLUQq6XFXN1SCHBDcHOTBPiwoGBB4TRyqEwVLZiXNqHnrrJuNCitkS57Z/RMq7clF2cb+umF8jLl6ZkVfw/KLTbK7KKEtjfn0GREMkLFEewTxlti0MErdYayEzPne2HOOiwoqVw+L1maNxgrpoddSVqEalFH4CULlFWQdMpyy5neqfMtOJuW2luZENKBI4YHEiURP/QDgq7jB18us8i4XexRLhO8WsxPO8D9v2LcjMeOzD+hw2u698cSKws+M5nM/hug8ZTLXD9Bw+n8Z4JSoS5as/n8aAqx0+n9afT+vPp3X7NN9XvR6pUVx0DF5EeqTm9UhNiqNrHEKCPRoPvYao/Spb0CIJUfMSovarbEGLyhY0L09pXaNhuSI/kRbVLWhe7tGoOji+/v1/X///ff3/x15/P+tS4ZGvcZj9+7f3PrfIrBVFhBiJEibKsa9Qyhymi421Ay8EFrnrAG+lFRZSc/wFrBbNNeISNM3LTlr/wee1qAZH80KRRtXHcWyIpnWvE2nj17TORhIENbcnV5gsuDrtWcGH7zFy6LSDR1Y5ccjhdtmei1KFSIOBF/E351UijZKPfI2Y2WuRTKR5mUgbv5a1IxrGveyhUZRwes5RcY7mZQyNfP3xOdf/HT//d/z8nxo/vfSjDa0XRju8dtGCwQsn2vi1exrR7snrD9r4tXuK5AfNyw/a+Ll7iuQHzcsP2vi5e4r0B83rD9r8NczO/909/e/r/z/2+nudS5s26/b49Z/RrOvVJ+2X+qRF6pPm1Sdt/pp1I/FJ8+KTNuuvnU8kPmlefNKmjYbj8Dii0dCrT9r8tX2a/7t9+t/3/3/u/ffT7rTt0zx0+GGSW0Tp2ZcgWkSXa6qMetH8v6DDQfPOYDZ9bvDT4mm37XlC3yak3/RrNgHBTBqadma2ZpIZQnf8WSmD4efAo0A9Fuq5JBeJuUuVDpjyUMRSN/YHSN1MUaBMZXeGJ5O6M3OoTI2QI8/MvaRmepp662mooOTzJvOe+IY2dnH9g8qOjbuD98H1mUAuat0CnMG3KMl/BG9MogDnIsKnl6JRVbuxPumCUV+zSZEEj4QuB95GmaA+Q2Ib/B6db/S835nOkkoUA139b9L6AiD2WnZqcdDA7SWNEPDcTOAGLkGGRffKwzbt4igmYxeAbyvHrPKX9G14tsvQA0/20gMlhchXSrVue7n/gHKdi6+f+E4dJrtuvuhHYWfLc1ifw/Yc9udwPId0hQIRfc3n09bzaRpOdPh82io28GRipfvs82nr+TSz6eHfzufs2mdlPXC9qgI1L+FrvyR8LZLwNS/ha/NXiCBS8DWv4GsqgXWaRCMFX/MKvkY53mn+U32sy0gw5TD70jABtM5qBhSD4FPZOC8dv+S7so1kafxc5P5zu/TWXUUAlUOUmZxrsPfraKkodsp8D2ClyNkkfwa6s9AocBRJMmpVLid3M5d1Z/0tf/2iCr+oCFoI72eVBLXDoj94PTO/hJJiEUVBw3F4l+myapzE5uDtwEQhDffpX06fcBVOwKNEnEallKGX2mx54lkaGbD6Jw5f9+fXZ7/UZi1Sm/2/7H1ZkiRLbuSFUlJsX+5/sRFVhblHweExJIfsbvbUz3suYVm+2gIDdOmebdb3t+R2RDbrnmzW99fkdsQ1655r1mVxBdPQsEgbkc26J5v1/cWWV41/Q7y/Id4/JcTzNMMu6zIg2eKpJVpaPM+w72/4iohl2D3LcKQv+IqRgpVlePbekEkYJFAi4tlIwW51eErdIJ3tjaQ+UrBdHZ4BN8gLexn/avw7/v+O/3/G+B+esjhI0oNAQIRAG5ET3PC8vvGN1zciXt/wvL5Bkh76X5RZHRGvb3he3yBJb3CsYBfmzxFkiYfn9Q0R917cl0Zk0DY86W6Q5zZezLBHZNA2PDVukGI1Gn0/dvfniBzahmdlDXKsKDkYQMdHRMsanpY1yLGCjlQjldGfI5oOPS1rZEHB4pBKrSTwjvrhmDkpvFuOozkrqnQ0p9MkIbeYI6ikB9oyN8I4etyO7/HfjNhGxO8ant81SNYaL5J6I+J3Dc/vGiRrjf4y8CJ+1/D8rkGy1nhxFVernC/KMEMv9Oxhktaa3qCPmiQfn00LjYZaIt+g9k1di0VQaTv5EqrdYQMsYU0KkUhYbRqSFI4VlZ46u8sj1uTxSM6ULiSVJEG2Ysk90R+jmgrl6MRAgtVDFTBQ0Ul/hKPKkDonREWmPd+gSZv+fp/fKCSGK4yRTyuBqfrNrvB4z35WIKENCj7xe16XAhoWHr64VY9XOjTMLvsCEynGvnXQRBkCKlhi6XdAATXyNyAaF1iTDs+sG6TJQak6vrNorvHMukGaHISKwp7I1msDLu+cdgzVx+WYU9ZhoUP1XsKhuwzrDNBcUWdAaKClGQIEsm2BnWctp7Wns8dmzhB09UGXFUgLU8pGIGHq3WJ7Pbhl11G5jqptyaF0fP5Ft4uNOcKd9/CMwfHNzW1Ebm7D0/gGSXmvS1vE4xuexzfE43sxVB8Rj294Ht8o7dvyGPH4hufxjfLNgXKUaOLzNLtRxrflMeLZDc+zG2V+Wx4jot3wRLshoh1E8sJzREu9J9oNEe3ADauQMfbniIafJ8IN0trGij2t1MqMGGROLjvNclTmmW/iHcg20XYGjbaax2CzXCLdGoR0vcmcB0GiKNdvt0kmVQDwG7cCzA11jp+MXkgxHJIUSVkC7IeGCDS6GszCkjVImUVIUQ7KLOpfTB35F+PpfaN+jV8i07HhGXiDfDosNvHLjQafp+CNqsEXkzRGjQafp8gN8t1I/w3PweJkMe/0222biwUpGbdZOCMe6hCBEsUjLHMUFh5QesBO8nE/fiCb2ddP279+0Yi4dsNz7QaJc/jSdcKBw59jHF7iBqty021hy5RGmiTKx4JZJZlU7F7VOcmE47rQluossmiURBDtu6njkqbEbEWUGOqmp0dCQ1U9EjzhIcsGENCpEIPFf5dzIFs2T9Uanho4yJDD3jtca+sphq0ttQptxBgVLLmagMEnkfKRNV5N2x8rFve4YzQbtuACmjIty13VBnDm/pS8GLrI0ahM2mV4Z0V6GUdrFXtRBNBSmqlMOGNLqcG/IU3LM1PFupxWqaAj7EIfK9TdGFNaFlg8N0tQw3QrFjaPW665/WfsaXfFx7YjBmTJy5YNzzkc9UuZY0SUw+Eph4P8wZleYvOIcjg85XCQPzhfFCLVSn+4hVepIO/YX27TGgF7sxuBetAmi6HJ6iy+SfxzpiabpXkp9tM+gToBrA/+Fgb3sWLA8DTHISIjSPATBDp/39GM6QmJQ9ZdL1T9ETESh2ckDtILEd+FM3fESByekTha+xYmRJTE4SmJg6y/SUcQeGT4c0QTnScKDtL+5guXfkRMweGZgoO8P+iXRSyEEVEFh6cKDpEB31ZDtl5kdwJ1oUCOEjQp7jOPczDPwbJCz+RCwiMy29FYstWGJpelZxFoeJLdIGUOle5wiYtYdsOz7AY5c7O8LLU9HYtc2j4Te0yJNUpBYI3AdtCOOOXD2ILyckwbTLJyJ3UnZ6jAPzxpb5AbB0Hm+I6y7qj9UjeTIOes2Apref2Bi2zHzulxIT/uyKCb9SUMjEh3w5PuBhl00JGPzxGNO0+6G2TQAWoQnyMad550N0igmy8+FCPi3A3PuRtk0M361hWicedJd4MUOiirxeeIxp1n3Q1S6JRhis4RrUmedTdIoZv1ZfsWse6GZ90NUptmfemIkTvT8GyoQWoT9MPjc0Trg2dDDTKbZnvppxEZangy1CC1ab4lnSM21PBsqEFuE6xV4nNE/dTToQa5TbO9zO0RHWp4OtQg52S2l346on7qaSqDjBBUIuJzzDPtQd7yQ59gmbnNzb3QnAh7gcG9Q0EMQzk56BNM+Azb0ZK02+PWfPcn02S2ty4TdX9PThmkmsz+1mWi7u/ZKYOMkPmWLp1R9/ckkkFCyHxLl0YckuE5JIOMkPniyqlWMoQQ/Ur5Pe1L7oC6db+JTfUIGiRZzKEDNYkXMNgWeftTWJZ5xVVuidnHzfpxRu4JdKDjm2UlctCfGn1nEDdCzRFKK0ELh7FqRmKWBlJ0vcvUloEeLNfYBLervqx70sOLEkxzJNNxmPAxxBt53LAf1CQgzPH2hZi5J7GdsJoh8lE1HVPAlLkTBncLOxqwxtsP4Gs8elzdTwdkLkBsIL56l42MuGH6gNC61nuDIJJEX6Ht3cdxmcM7gkp/t89GdyG+Le4ARgyxGZ5EMUiJmOPtU44P8xMhBKe6H3d/9JJIlEClEg2wfczfUlWcvjbQ5f6wJNnV3M0b9+T4F1I/RO63p2XuKJ3pJ5rTUBcCqSMljPb+tJ8Z1CtH0QG5GFmR0HsHWK7Jbeni7kdyitv2QahzTk1zW68tEy05KSSB/04KpPJoHtebKRMN/mb2KI/X62dg0kUgyB+/3uOctQhZ5GabhEGUnbvK9gsPy0+A4c1SfDEjGmT6WWvpw/Ct60K1Ltvbo3qt0jNqu2Nem3wKhVOHSsai5cDb2rAS/drLSvTEuQqKiJy45M+vmgvCYrmjU81K3mSQuWX1G9pL8GPPZlG/LZXf57C/U62FmXRWWHg0jnXRUOdBgMIiAX8jShNnUU4wpcwMjJ5N6QMIYnM507+QxxoUdMs5C5KaKErtnzn79du4js67mvImXxKTz5Ttlpj8DKYev9DNb6mHiB40PD1okOuD99/Sb16PcxAkw96MhZh/91uqEiqYDnC3+UxamOftCCpcu3sa6fDMokH6BvJaYaFhpc8oAtAMGqZJgQeDSjlG5hy5n8pI2XIeA5dT1ma0NeOsX6s+JfWO/K15IslYX1O5EY9keB7JICvkdX8ZEUmGJ5IM0kJe95dsvV7Rrf90R1XdRAer7TR5dL0P7TRRYNROE8VM7TTzQ6t3eIbKIN3kdacp/sp/ZafpaSxj9W87zYjFMjyLZazxbacZeagMzy4Ya37baa5od+Zx0WOtbzvNCBg9PDB6rP1tpxkho4dHRo+dvu00I2T08MjosfO3nWbkbTI8Unjs8m2nuaNB4tG8Y9dvO80Izjs8nHfs9m2nGeF5h8fzjt2/7TQjPO/weN6xx7edZgTnHR7OO/b8ttPcUT/1MMux17ddYoSyHB5lOYiZfN0lsvUftEv06M1JLObbLnFG8M3p4Zsz5S+7xBmhN6dHb85UvuwSZ4TenB69OdPlXdhQ7PTnqCcUrKYV2Ea/nZ1U6gIhAIUr5fS1dIKThLipsqw1JPe+lKgsBEFOIhU2Y5ouBzBamv4+xBqnh/FNYvLmircuaqUlh0mhHmnjRNWwP6zJNm0t4HPezu10+mjDvkZiuixKLG1CcTRPPQOgnt/t96XTAwbnN8DgjACD0wMGJ9F/L3jhGeEFp8cLToL/4NUaEMJnBBecHi44if2D18LAK/OnCEb19GjBKYn+GCw8I7Dg9GDBSeQf7CODSsmMsILTYwUngX+QuA9PEY09DxWcxP0B1RuIwM8IKTg9UnASq7f7yymChWd6dN8kVo8y/OjPvmNE8L7p4X2TWD1QYqKOoUZSe+b+oQffavUHwgK/rQh1Id83sH7wUeU1Wn8LbU4YWj8sF6fHB06C/YDOi+8i6uEeHzgJZAOjp61jJ/NxiqiHe+jbJOKMTKAgcp8H+mbMIxpyYut+KxHntI16lOlKiTzJhwSz5pN6UmOoWeUsXzsfMU8PfptZtINYG1mtH3Md7g1wl486LXIezOHo3ljFzZRsR/F92KSX8zHfA95eU12mvRAmx1yEQ3verh+mBJfNFctVq7We1fiLGO21TQO/S8s45FDhdiMeqSw5JAA5l097TY9xm98wbjPCuE2PcZsErL3NxhHEbXqI2yRe7c2Xc0YQt+khbpN4tblfQojSzImeuTqmYeYPJURrY9IMfEA5hlDafwnGioyPMQFFcKBa9klKNWKzOkq73Zh/suIFKHNoMUWClLgTbNP2gSjiT89Ru476dTSMXkhIrR2tq3UbqHGlA4NcKYegxulRfLN8W34jEN/0IL5JRB7GSyTNqVahIFBJltMtM4kCjiHC7EdpvVGrnEdLMFnYQh0wFJ2Pg0rt9JjASYDfmzXQjDCB02MCJwF+1LYPpJTVmvklKVb7YQB/fN+F72WATSn58vFQsANv4n3vHLqjTw8wnEQLLnAyA0sTtR7NWAJRRim3ZqxchuAWPaQPu/vPYmIVRYaVjuLyQrhKd9nF2RqT2+KMuHv7gVNipKk8PZJxEsGHuLeVK6d932yk6T896G8SwbeQ1YgeOAL9TQ/6m0TwrRfIxoxAf9OD/iYRfAuQjfA+ounIg/4mUXeQRY7vI4pFPFBvEngH8H18ji6fKYHMKJactiEI++WlRm8xrmvMVhNFz25Le1kA5Ko8aieZW3QIU1kKdOFO3B1NyQkABfxX7kPE3V3QulnOeRcXTJyXavV2tM06bHHBfNjWTg8xnATgrRKjsGYk5z89Zm/Wb15fs0aTggelTULMVomLUmqVyWfagvfRIovZ/bqFmKeQNuOOkudJ4JdsiHk6M1A5m8BA6Uk0U98GvaMXKV0Lg1t/ywV4BPxzFSFvJ138CisRi5kApCcX/23r/YdWBL9r4rd1/Yt9/gWFuPE5F6drtlJ2XUcCWj6iCY+/mwTTvfn0zAh/Nz3+bhLYtmqMtFQrKy3YOvOFsxbCdwpAKj/BLZhOyvuGVzq6IMzTOsHNvf6At/JbswmRIPcmrgk8fI+6ukowHCH8bhu2aCi0EOE5WLTBh9Yc23YzhGRF3ZWkFLjuUtbCTnzk1encR2X0xaUENmyL2/SMz0Vfvfng8E0P+5vE8K0XL7oZwf6mh/1NYvhWi2F/M4L9TQ/7m8TwAQ4anyOaMz3sb8qIoL3M/xHsb3rY3ySGD5uy+BxRgONhf5MYvtXeumA0+XjY35RBQHuZ/yPY3/Swv0mY3ULlJHyWKMXgkXmTMDuM/p5+y/RRTduXpwm2eaxcAqtwmVEyDmalVzQC9H3OcwB0kxKGqiiHHScgVYqgqPqB8V7mVHmvNDJAQcBBmxKAhDXP4Z+Cniyg+L3oLA5kLiHtWlY4pvETbT75Ewd1375gNj3acErT/8U4Q60H0K36bTPD0jyaGXlv7AolNYP/sqgO40EV1R0QW0bePCJ/NxV7bWWPD5i2Ueow9VCChMk5ihFBaEevEkBsgYthAlpFtzobFCisavHAGtNYPgX5qdH2YXJ1Un1/EUEvDHdncRXuqEhsCPW9uO3EDWh54BsgMQ9YbzLHifBejCmg+rQ4CyLtubp4Yb4gNT3GchILuXpMYps9mr88fHISC4mJNj7H8XTPWHT5FrDSsurM9B0/c9MK8bloW/3dFm1M+Tb3N7rqiAXZbe5H+VZzPxYkoTwQGWnu79zX8JJj2YyPhJFm/GYzvpA1a2CrAKdgGQY9SqzTYz8ngZxrvEycEfZzeuznJJBzjZfAOcJ+To/9nARyrheu5Iywn9NjPyeBnOsFiKNWDqWlfXzDqk8iPV75obfy6wJdIM0Ei8D0nYW5QLSgCQzrbRmyRzEWFAKGSnUshBl1JWNe0SXzt63DQa3LgBHAvejrI3tOyStGzfrmGLYribtRzNkefXBlUUS4zbJ/wGwOA+mhYAD9gAk1hApEvPA3MiBh7LFmtrtbtAmGohElGFb/WRr+0PriAH+m5z1sdppXxcueo89/vbHEnQd2sv+XUcUtcvAG/KpLBG/LL4kktnI7hQQIPi6cpYhNqnr+PfsHD4+JHripan3AjRaudPsizQLQLl/03C+C7Dgbs77kPqcr0pXZUDr0wsEmTVE+tmYXS08xO6L5RRM43MGiCRx4eIuoE/D1Frl+tT5ie49jngQlh6yxGYGYpwcxTyKS13qJECMQ8/Qg5klEMnCQ4UQXgZinBzFPIpKRImnIBvoUWARinh7EPIlI5jZ5/zYfPEQY5ukxzFOODuslUI0wzNNjmCcByWu9zLcRhnl6DPMkIPnN41Wtl3/i5TaorB4UTApRQvCuhgiA8vnK7wFoJm+ijSO5EuI6JDdh+kYI8tseI9JDpCeByZk6Mr+7+W0lW1tmZHlUJWczK2vUGVjJBjIzM6RCXaUwXzG6RWjoDTKcp/s6Z9kDfDKjvsuArynTglw7A6TcstVxYQsoRuHoP4v0V4grkv0KH7xF9ivCL+DUIiO+6SHYk3jq9VY3YOvxMJBLFHLHmXnmZqoNmAYgAYHfNv28jBckYzWUQOBQSwPIRRIp6ECLacpnBcEDvOf4ov80I3z39PjuSbD2a0I5wndPj++eBGtDZSGctSN89/T47kkM9ZthzIxMAqaHXU+ZBOyXhBFbq6Vjp2FWL/fbAxW/XHALMQPj2k6hK3WuaGN/+H7Rn/zp7TU9xHrOL/Inc0ZTjsdJT4KT36o9kSb89HDmSQDueuHSqlXOb+Xnw5HZgy0Ca+aSluESIhvmipmJBTy9OSbjGRXk9QixPUh4EjP6+uqi7bxHmc4pwMBLZBvBTKeHmc75RchzRiL000NFJwGar718RaPNYzonAZokO0ePso6nJkRNmIzE+nBbq9wlYGK0kfGXqg8mrg8gvaoa7QNRP0/l6HGHfix/Ux6fEWB0esDolPL4nqFIxIykx6cHdk4CMAHSjl9UMxYGHp0kgf6L/QMBBdgnbHCZf1OpPxuVYTsq1xEsVNJ81Mc87HMSw7nTy5CLYJ/Twz6nxMvTS/eNYJ/Twz7nmq9BZIT5nB7zOQng3C8u7mq95gqTAMh/YJ04tfb5MUN8zAb72LiLr4Gc7ObS2MrPPkzsH9AlsFrWn51TCNHyMNO5BPR5iX0jmOn0MNMpmOl+wdmp1aADXNipd3wjABkdILmn6k7b2gZIhmgDzo+cA8g756cWyRBND12d+xuEIEKuTo9cnUKuvnDXZ4RcnR65Onf9InEzI+Tq9MjVub/xzmeEXJ0euToJQ91vhT+2sjSb0E+V4zvJw42tWlrGuVHwCYEoCbIQDyiBi2Eb5XQVHID8ZxCaQaBhRm5R15M6DAZB6BA0Zt0PAlSblTrQinaWpzQ69NbfPR7TzwZEyu7yUjPY46M4fTHgCGHA7u0uTuPREAlv0oQUJ39Qyw55zEYfeuc2evrPLuYV/LhXP+vs91knQvBOj+CdhOPuFwtptV62WIjUsFcf3L+DrdY/bbGYXt11fFAHNnE72Ffs0mwY0k26P/zDpgcGT8JxIbYV31o0s3gE70rCEMb8shUheJdH8C7CcYFRis+RT9luWN7oKpjWnqV02rclUEiMY00um+gYJedEVUPauUmyrlq2nLou9fwmFTmku420hkmOBXBMbcz9Lenry/G4qw5I6lrm7Klk+EwnpQe2lDhMHSAeCn/uOg8nrfefrUpqbT+7pusoG0xoS+4F64VlZfBbu476dTSuo6mjx2vP/rUTeFnfXnv5CMRuqN4df3FrTG3wm9JIWOPgRp0VYO5XsRxu3tnW0ePOir+zL7rgasQsUGUFkcjmrOtnowrJDehGLdGOio4e16z+mu2LNNiKBE2Xxycvoo3fxI3UqqLU0jJbyJObpkl5KU8M8mgBB1M01xYeBIsrBNm5DgeKlctjnVcaX0Q41XqlZ4S6ahgsAgOgjNRM1kPoARga6m4zl4xu2u+6XcAvcbv2rVuP8jPLg6kXodEFOMpIR1rNT/t3oSgR0HNUQJCosxdiVA4iHTHffxi8X+vCHs3WhQJbBdGlC1bUaEFYHrq90he+34qg28tDt1cS1CHH7pIrAm8vD95ehGIXYgSj4RvBt5eHby9Jvb512Ai/vTx+e+XybeBEAO7lAdxLAO4Wr0RqrZsjx1TO2rLIPBPCQecHjJXMiQrJQS6g2Ld3oSWwKnKSRA4RbGTFL52FsoLs31CtWXsji3jIaUEkvFk6rmht+/zG2jHjIQp15mf38Tjz9U1FdkUw8+Vh5it/SaOsSER2eZD4IuJ7vygaqFW1h5/HCsCdEfbS94yhvBKyI7tX0//B4Oy/qe4f4l6Q9HzclZ8L8pc8yYpg58vDzhex3q/vJhqdHh2+iL9+oXesSBl1ecT2IlT6hd6xSjQyPbh6lS92JysCVy8Prl6ESr/QO1YErl4eXL2IlH6hd6wIW708tnoRRfxC71iReujyuONFFPELvWNFuOPlcceLqN8XaoY1/ndTM5ZHGi8hjV+oGStCGi+PNF7CEsfUjBWJjy6PDV5lf6FmqPUfRM1YHgq8iOt9o2ao9Z9HzVgedbyqrCMAGY5Wcjb/z0+kHsi86pcE5opwzMvjmFf9Yp24Ihjz8jDmJXVSfPv45URj3+OYl3DML7NxJDm6PB54CQ/8MhtHcODl4cCrfnHmWREaeHk08Krry2wcSVQuD5FdBpHNsavuijCyy2NkF1Ggb1N6i9YmjxtdBIG+TekRbHR52OgiBvRtSo9Qo8ujRhchoG9Tuhr/26d0DztdxJC+TukR7HR52OkihvRtSo9Qp8ujTlcb36b0Nv6BU7pHs642v03pbf5zp3QPnF1Ewe7+shVpV5UAhCzlQABak1L/ZAboy34eKRDbz1MH5DBNP2/Ij/r2pUi+InnM5QGrq38xSVo9GvMea7kInNwvWMsVYS2Xx1ouYg/fhGZWJFW5PFxx9fpttxnBFZeHKy5iD8nHWpA+8udgKw2Vyg9SiSiGVP5//NC8CEW0n/TLujr+EM6+tC+FUANFnaABTK/K0psMTZOJO8F1kppLJNHgNBV/AoAhoLkwNgITh8lM8DtoeQopLFWvW5HlKYGydLVkrSr9YvDTNjKZgnviH0stqkIfiyDGkTWMEjew8kWlYSWtn1ARp/MTPITk/ERIGZ2fYD6R6Ro1qAPGp6FxBa8PpS7eAAZdpldlpoGlvYVCm6kyj2kU8GqF3k4UoM78B8T44e8ycr3lMleVuVTKplyVjnAV/T/5FgGtLp33DnwObUwhV8Q2AA6X7ncZJBpESL4RZIRr6/R4LZRJT1K84smgiYEssx5QNq4QrZaNa76wnpnurTwq1EODkhPpnommUPJzxa21olua5udKHu5e9uLwjOm3oqfx2xRogJkDVqGfayIvtBc8IPKrvZ3rA9yYZFBJe1W8084XgW/d5f1UFnmm+iZ94Xw4yU52K3JbRVZl8FVDuLvqe+Fo2NtBxSWphtSnXUHesXhjg10C/U9mXPCdHewSKJNOyp3hXcyirz6oy6VrzJ3tBpBPx1k2uUiJadPFb4HvBTwrb28tdefNZHkisnbrpvL42fpQ/WerY4/8s69rAVwGmbX6sxfPhmx7MgNhhgYcYlAjS/SxpUmrXGhR4M0gBcHhDEe8RRQV0tSAwJDjszDKWPO6AF82ScNp69ViTBZ7edncihsN2XgCLrftnD/r/LAAA1zTbiUv+QTjBBqHWKZKztY7MrK512E5Q7awqzScoSZ9jQZv5G0vPNfez8nq4PulS9rs9oxVz4jKRuUYo7/vPoM4t3zee1anxUjMXT0JfbK38w56m9efdo2Nn9z1iF3OqXbXI11POPSE+EdDBnHzB9Lsp33qFaN91ftwZjvr4mBt+M3Oj6PrnAAtnNnNngTTW74+7OZ710TIpwaCMW+NAARZjGF+M4o2KZ0/KKlZvyypteuo29csifMQJ0Y5QYMMk2kFDQxeQYyThDjMmjqBVcp008YusnDO+wWmN3NCtR/r/ad06dPE3O9fx304z0KWy7p/vS9W032Y78P7avW+mnyyeTV+BGrJ5XpfTZO4DhcdsvHAdV8P3O6rtfPyS1ZH1q/1/rXdv97P1u6rtXndJF90JySday0dp3K/r9bvZ+v31fr9bP2+Wr+v1u+r9ftqHG1VBuP7ut9xX23cVxv3m+QkhmR2wTp3/UG/7nfcVxv3mxzrPsN9takAgof3m5z3s837avO+Gke8voUmUP2zMxBLnncvmffVVroP72db99XW/SbXfbV1v8k1rttZ99W0tOq899U0hOmSvO+r7ftq+77avq+276vt+03u+03u+9n2NQJKSvdhvg/LfVjvw3a+RUn9/nXch/M+XPfhfbV8X+2aforCKvv1vlpu9+GZv4vCLfv1TLWlaCrBO0PEgF/KCX1KKfdzlftK9zyiIM0O+3Wiex5R7GaH93Pd80i555FyzyOl3le75xGFf3Z4v8V7Hin1GmvlBNWl1Pti9zRS2n0xTSN8n+2+2BWflXJPI+WeRkqzR3vsOPwWv39RzlDj0v5UIXb7xVJLLbFfcEkJTifngkZNv3M+ir+eC7X6N6UNtR7IlxKQJA5W2dyISgKU/jJEPqEoxOHziOIUoE9ScKNy8zFD2MnyNKElmtCLWIZaT7L0IzXKXAWAZZ+8ALwgyLfqfgCsl1gGmQR8kWAN6CaZQuVN1vS8SZ8G6OuLgIZaCUEBVZM9oh2OwyKLy9g9ElKgti41cpPZvJBpnilpK3ZBI4+82jZptWK29KsdGy4yaWszQr/6PEJz7Wxo16X4A1sbzhUbG5pCZ1t8Td5d6dO2LMMICYnmi9oo4F8MrlxgeyK0RwbjhPZ454NTO31NMuOa1EkoK3TXFaEMUBoDrtTBWBxE1qwIU83mootNDbwkcCKo8CqYAgetMPj8rTSS5KvTYafJEaIhbI2uX4sOH5/VJ1PILHqTGlErSblniz/oxYSt+NAWnw5I3D/RBIlvamtrT80+Lp3lsAiJieG/MoIbAgDzz9w/Yt8BCU268LS9PWI97eD7tg18L4eFWORczHN225mPszGv7ErsjbgIF632o1UI+uRaedBWcvlQisbTLNtvozsdwvbZb6MzFHaLgUCJoUKvAsjbb1ykG7qjFlui2RifAOmS9Q4ruyMCoEn1aG08Kt8eMrCVky90uKsitHz23LufnTamtMoIBf+2KXonAFdxfLY9NE0x+fLgAqnddJPcEvIG07bV9dhJ4+ydcRbO1se5uz4lGSHxEmtloNI4hZG2R9UsnH/YHhnveOguVzsIsCGF6fQLmfTFFRhvcXEB5lHRsB224wUFcXH9XBR74PkgCsEv0GY/W95VbMuLe9rjnG8zHELMlhU9oFcoNsAbyK2cL5kb42AmTQcHNvefgxbZwNvBnYgDFumgoj0jZSv71dsYxZJIuhmksjckzmeNihvaakJ/RKECu1Dp51uWQotspDFKYSRNcqc63mI0wmfsNA/lyexwX4e8BzvM92G5DyttynGycd5DKYykkbwr+u76cV7C6Xyj9itnQgyRdD3NzPbxi40N2ZvW8xIKw2h8r1IURj9w98tzHpc4jy+KRmolmxZaE7wTzVRAQHGmIgWCN7Y0CYHcrCzFtKUKLuvzGMst9eBmcw8yIkdrvtjkU2f++chOcPIBq1SzT1cv4AheNvsA8475J2te4fwzKY7CZal1m3+YyNOOkwp1Gn2W24M2inoB0gL6miOdiQjAV01EbOVEBAVaTUSY4baM67PNQxBp0+wDoeGqhQqzStOIXDbnUJNrCFV65hz8C8052BlrzpnG58MidyYYyo+NkwoWKhTjSnMIfhvl6FhoxpjzaLoi4tQ8AedWzRN4SM0TPJI/IGcMMhaSVHqQIK42J5wiA/KV3eYE/FvNCcij2ZywGJyU8ypzswz20ujO+jYax6QGa/DytStE5nLdz+uxYUwJiKK1P00N2OtwX4f0tLfDfB+W+7BaNo5j6/w6xRZ/jCLP+l1i/b7ocalVpA/zWqhX0HZh7Fc/the0GM6XvB8DNFJaybCbFmTBHfXIMYEISnsBirwRrAiNd/lawA5GagB9TwMMN6oXUCN+F4uiuC0YDOSgDsgoCQWodpJLiylyigw9Q1tPYl5kJL9pW6m1G0OeYYBR89u83xDj24oblqoA6gv8Og13QaURZGlKEvQuk15XZAueCdvDzpNvLZWjTcKogFhsbKyqBEFQut3SKyk2jtZhKeASKx0nzyU/aXwUcd41E+ZlN8hcBWi9WlKK9FYKie1V/WtTwvhRkvMU7kVC9ptWlVrpwIltG7kZBl1Fpk1aKl21zqxaiiRh8CckteHLa4Lksii4+eEbw2xEA2icYGvZ6zK3ziEXziPqglk/y1abUxeZ6+bFkvY+Si6YkujumWCPXkgMAc+HqEjspZnCVnOWzE6mSAFtWPEHzF/iabPSauU3sXZDBkrHPxahEu4UpUgJTr9yWMwHLtzz3tf4JvKkVkbvuVhojoicO6Zsul7rVNKgX4gMpiYqbc64MbOIep09GRNenBxp3cIFrB9NI6STpASyWzt7Mmh8cprfZ0u20cnIQUj9RLRYomwjNraVjNYxsgebjx8UyV3VWsjvEHNnVcaWmTHrZEcBlrhIQ2RTJaTQEEYqISiScJnH2WisC6yCTS7ATmuLhn+aM4cOYzvt1ljOG8TVwqHmbNww9rlc/FasH9q4bWbm8tIN2ZSUuBAwAi78ldvfsh8pDC9RsMY3PS618jPj9XISR01Vu9x6vuQyz/lEL3mNqnxte0AUUNgBnUETpSrnS1KdiFY0eZ9JHilIfcqaTqBAr4+hgD3bfE88B7/lIlKGt7dZmZMHj5aAfAnCYBDLCwcKHfpeVTWtomWdH6nl68vgc3WutRRtGoRC0LJHawWmkbxs0mhn2cizn2+EO7WPVBj8GlGlnY/0dNteXgNijfFFaGzdGhCsVfLbsNbS7a1qiR3rGEvt+YdwHp8unbUCwq5KY0CfR/MbZvVq7+3wAUDS1Psdpdv7bccmiuo5QpcAua3wD+UbWxXw7ypTEqDX2LvsLCVRLIUzrd7lwNSpJZhOxnqtOK291skE65KS4nNd8ZkxSji8Ca6pVWZOW7KFOD0DDpSYtZ5UpsHLUYzkq5wnVklXLR1zlF5VPTNYasddm1brfEMJCQx5cEH/VSENNHr0wunaJf+uQ3PKlC9m98QGlGZRgI2oHyP/lpNFKkAi8D1DrIrlSSyMU3XCyj0Jw04k2YpKYpngC3vP5KpwTaoPq4nl1TCW1DBeRNuWqWFAZOdH8mPZeGQcOXxT8wjxNL5jeY53e3sAAEmSB2GFBX+weqA+J72EuxSITFgNLBlmWJgaa5IX2RbsoGylFbpRdY1rsCUoCsMIC4XooZ3sClJdw30qFkIpSPprDMu6/M2YcrOML6Ie6omUJxDJq3Usam+8KYWplTNuqZqN88XYScbFSyiYUviy28AHf0gvFR1HHbe2bB0XtWj13DzP/nDPT+0tGYXlM0Ew4ab1HVnQW4aL/RV9WhMECj6aIBD53YpcmiEamFdD7nad6k8SydJcgRu1CWJwbSxK8VZMEMv+UTajMa4+nCsqkr2aKxpzGZOcVWYyOSBa8Al8TkBqJxEbVU1a8cSSJGXdpiNJmU2bLFDfZ+exDDTKJfYR9rGOy9cGJ1sYg51AtTD29HDoAmu0Y9Wgyx1T3Fv0NuISLKTFztJiTm4y5bm2NdrtV71KvGV7UaKjNVmxaZOZTypoXL8uUoOwESgcC3YoE8HHS/XyL2t+E4ZSq3LC26IM69ZMEHGriJ2L+vOsZ6/YajkrGWN3/XZeJfb82ivSSFPTRRpns5iqdVSgGzpFzMo6S9o4SxqxhaKZAlbTjqyj+ilu4Kxe61r+4R1vbxdWTdb31hMY6RVu1vymfWWtHHjZJFeuLXU/y/wuZ7T3dgRyUVSynTQQYdpJtzOMWz9k9prOMIbllm2l4VxAzQ/GUe3ayEgBGZIV22ZT20m3dd7JZN6K72S1axlf1yvZ/bGmeMGeNb9IealR+qizauRxulJ973oTO1uhD8kjm8Xs4ee+mPxpH/Go2U08qpD6t22ja8F81uMVwXgY8gJz93gSv7Oa3xTF1MqdT7PqR5dPKKIGU05G8V5brLLP0rl+uORgX8gM6KYbp9GumW/s+ThaIjtpITu2GQIatm7BO/LxVrPYzSoVJOyZBle7gphsey4Y1qvaME0mGxalp19hnFrIvqbtvob1K+VUUUXIxHiqPkCRZ/2LUa0CgOysdmfjWkawRKtI1ruB3iYrYypjnJ6IjSAz7dCw6VemfZ4NFrBMzLlnbpIt576ILRrJXqEl2teJ7jHHX4l2aA6c5HlvJ9+9OeiZ715KiI/7V04Q9AxVulC/7utXprx1Xqa8qTqrPDfugSOHj/vob35rR2GnN+U4tdK9YlgsQbS1AqL26QshkQdmYp6+EGR6/tZnSdrLSi1KNr2pxKlV26j+8zmilY0UVL0R7KYiNUa5HDz6PaQnAztknKllQVQoiQlY5e4hbdoOsADe0tTfJsGBUoKk4SZrdL1dh1MQeUwlW8Jsz2f2mw1qTBXELSGke17Ma8TkN+uWyPtiSf/6S69exr0ILLspjE1CFgqXGKHfVVmiqSXQ+oTyiGNLhEr/Hcwn09FkPExLlxfEWlS3KuVFEcuaJQ1hyQHgDK1Ar8fIHPt6jMxoaRzvWkqfgFCsx6jY3lCNc1PZm3oi0E+l/gO0B/hohawKezTFIM18XAqkmrtgfABQ8vDxkD7iljFseZGAsmbGs9N8PDD/qT9Ogu2SeclJfZZ5cPqB9yu1UESxkGZRE/oUZkJNG+JKaX9mHOovlag7+11dFkcUauVzkwsiBmYMqgA83ZOXlxNbK32Bxayb7McAgvASUPmfYJgfvaNI58HLj60lxzJAvS/5q4+LRkQGLxC2JBDW6wv5KJIIW14ibFHvC5F45Di2Iomw5SXCliTC3lAqkghj1CCl40JItDYDe985aVMiH+UokbeTbU5cWYuligU/ydn2XcwU3Rlo+VSMa5cw82cKmkVAuoEkoV5OiqZdKTBCx7sSK9kkUlC4X/RWosTxpHZoOvEObpQI5PJb5xXu1mkLpv4RV8mqjq4oj5ouSeynRf0AS1E/Xrhft6hs9oofWd3ipEpyA2MAs/nGqqRkJbpeMdg5K/p9kQmi2oWMv8tBiSwLoSpJf7x1K9T2K1xKJ8PJIqRKkrlauLTzAXb0VO2j4r0rXCKaRZXVWU/FtGULnMa2uKnWEzf1eXAYWJEFH+LEmqSj3QxpQcEpAiygIK0Iio4kqomWE0sBlaJYClOjuX3XaTVM7CT7PJAYAReQYjW0AnIRFkMpsGcMtec+QRSTU8Pqe1cQNZnAa3p348RT/Qqn5h1D2Vbzws/oXbFErXAKd2PhFF2xy5UGZjhVfi/0gP3GvbLqB+m6AIOpycBWVWYFU7yAUAX9HFaCW/ZDxXB5zb1FAb1X/ABbmSfYENXiFtxiGlQIpvB03Yp53aobuPf8h2cDl5Dy6crACHq3kxCEnq4GK6IYVpeLaEgUlJVlmhaSPK2kLFcHvvii6vIcMpR5PLWPaqj691rvXbecTLJdCqJ57eI7y1nNKuumOn427LS+kfSYZftVANWWdNWzYcd407wGyUTNa/SIlib5tuIu5iytv0i22rxFKXdtSSE3YpMVN3FFqZXxiKy9yuFainwS8EfYPvs3cBKd+DqGCNwWMuAZizx+4StB/VjEpdyxIg3EWIpHg0ZylH1TgYJgEYSeiK6KPOEnzc5Suw+7Dh8P4QOfpcgmxZ7W1pxtm4uvYnEpS1j1CL9dG+xMBeZpW2318HtjLYk87KfuLTb732+zZErlFtZ6qcCHTSJJWBOTpJl5uHT4eEAf+VAJ8bWSuU8ur/FlCgkop4JqGENMocTmARph2vmnR7dqe2p8PS0S25LXiYrAQunsva20iQ2qCmIQUjs2U6cg1teBmwIbY7triOKy06DMaCnXa1ygtDmbrfy2uUaEyRgAXUGTRKFLGOcgbKm1VZ0HhncKn7MclETJp9w5IKhACDmoOooUIJe2CJeHBJpZG+QTKWBrtlksI66Cq8oi8VEIlLYPm2qvq8LGBYVzFZg0Z2e+sNSoYnrQcHoqK4iy7qbt+GyKM7LV9PMyjtbZKi8ruyWOeCu7IVdQqlJXLNSoivvwMlxeT3NRHfO1YspWoXugZSorjK3+BM0jbW0t9MDYVi+aV2ZmjFMVR/l585u0qypep6XoRysnvjCjAiafrRaO7QMBD/PyzMCQsvF42JPIuqu/gOZiVXGoNnIjU7lJ0+0eBMNdKsdIOEW/zt6BPrGtT9wLUkvF+gQz4FmV0Lv0jb+0CiugJlYFJw/WquCsu/ZpxVj1BNzvqYeX8/lFwmHZKl1ffxMQqLKZSjGEUvCdKbHHjsCv355JPC+Funb5VoplazNQkhJ06biVgollaPR8FsF6JbApas9VEIHcwZHXU4AtxVbBdk2h7QIwYDxrPUSCz74KxWy5CBGdvyRPfiDj5ZRiNZsr54/5V58Csn62YLKSNs3kzgJ91NP6WUb76leBNs/zBdYQC0oSi5bvHfoCEuM/RdtaH5tYLx679jcDHbUK3m22vPlz5yVbqHqVxa5Vi1uwDwzQ8Z393Jbx76rbjGUa1EVbMCuC7ZM9r1eo8ucWTJ+mw1tG3iGwlxvDYEta70kj1acBXuJz4zXlyILaj4pg6y6Yt1X+2IOpCFbur9DHY+30SruLsrlxEWyfRPXieLO1yEyrDAJkgY/KqFYJu0q7JZ+6xGGgY0Y44A8oh9TL0svebBmnBAYjGcZpuZ64gVlalcD4lVUC06GKheUqIdY75mtzn8IXrWms8NXnKXzpVxW++lX36lfZ67lU+J3s7t+qXtIaDso52LKMEzDHxZw/SjiFiJX/SwmHBa4eFXAsbMZy34QRPHkr8tn/y6Ucr0m8pDr8Vt26NInzmn9kExMlv23XsLq9Bux5bdswD2IFU9HtA61dA06mXQMVC5GYA7Ha9g/jbB+wiHE9QB5STz/7le1YZ70pNOKzKDVp3zjVzx+P77dNlC1+q2Tta9cESn8/hSZFw2dpGOWqaBqyrTCGMETOPmUrYKkns4mQFrdRcqaQYiUfPs1qz/v2u529vtWtpK7MiCRbGuZkYZJlYUCOsO1sNwlhAZWIm2fAgCBNe/GelYQBIvoDLi8yxo/Y1QwyuKjWdapXbZ3JfI1TvYLDkybzdYFIiEjdKv9YkNR2OdmYcbA4yNlbp7qQZ4KoL4PmN1F+qmVogOW3DE2eJxtTTwoGsDSGoMNQEYlQKut8xapZDXbQLGeNnC2ogguPClsrXzmbfHIybV51rZ3vnAxCDeVkmLJT0FRnOYmYNi4GyWwXJ4x+HErKcG5UIqZeeRiu2crDcJZUHka/akpuVyZGv27LAlgmZl5EjjmuqhYnHUvEyH+s34dT7+LRR/1mdmszm3O8md37z7hMhfDPuOyOxvTN92dcJgymFglmHfbpVuRzMja1LIYmiKqOeG2BFb8p8tzEeGGQHoVq+65VX5OiLYiKJBFxYq07wCKIjOtbYW5RPPNKsFR5WIEvLye+qQ1egO6L3tVpZrL6GPTVH4J3T3UHqj2y6SNfQLvfC8FPNq/gcWvbKxv2ylRo0HZlz7Pmk8zHjQj2wQYpagcMV6fJgbNgx59QUNOGFIaIctHbhTIl6nCWUUCoJ9DbulQi8E8M6MZeqm9FkHGu11G/X/G4D+d9+PEN9nVYkg7dN9hejn0nbRNLDjNIOwUlku3FxTc1vUt9cb9WMydJPhK7K2WQpKdr3hP77Peo3svCFQLZaZLp1ZinSG4Y/oH4QlT5WVrMygpN4QvVi/OpayHbmLXPmncxq0lxQ7fBXYD+kVW1nhK/26uX72/q5Wr8j1a1+PqCqtb26uWbUuRvVa0dqZdvr16+qR/+WtXaKVCi215zfFPg+62qtVOgtri9JvhO80tVS60M29sy6vUw2DdSM6KmXNCshoWZEwTz18KeEy/RDKti6P5+wY33gZVg2KrkhUBG2yyi2blEY400UD+SoUXbgoNbSv2g+/c+qzBoF0L1w59YJChLVSRO1yJcI7YfbAWkV6vvIlOPMUK3elg5UJK2DzgcShi2zCIPbcxqLh2JvBMWHji99ItX3S9aNUGoolX3i1XdDS9ibsMG5H8korcXSt+UPX8rlan1b6nsb6nsH18q216Qf1Nc/61Utk2u/395qWx7B4Gd05dSmVr/nUpl27sf7Jy/lcrU/C9WKtvefmHn8q1UZs3/e0pl23tDbFoovJXK1Pq3VPa3VPa9VLa9EcfO7UupTK1/S2X/JqWy7T1UtjxUXkplav1aKvtHFshE7EVi549SmagjL6Wyl/rYf6IoNugNvfJ/sj62vdfMps3LW31MrX/rY//1+tj2Njo7v1oWqulvfexZH9veSWjLSeilPqbWf9/62PauSJseR2/1MWv9t6mPbe/otOnP9FIfU+O/Qn1sexupXfKX+pha/9bH/tbH/oH1se19ynYp3+pjav7/sz62vSHbpr3ae33Mmv/Wx/4b62Pe0W6X9rU+Fnnabe9pt+lQV+qLL7SaM3MkSPVhcqpUitXytqZqW9K8ZW1LArfcjrXfhEwhVBnlIcaSlsxauXOiR+/jFn3gTv+6tzpWGf+pOlaN61jeIm/T7+61jhVZ5G1vkbdlkfdax4pM8rY3ydsyyXurY5XAdmd7M7tNu7jXOhZb/9ax/tXqWN7jb8vE762OVfPfOtbfOtY/p47lfR83XRxf61i1/FvUsbxV5ZZV5Vsdq9Z/tzqWN9rcMtp8rWPV9i9Yx/JGn7v2r3UsNf8vqmN5G9ItG9K3OlY9CONUuolX4n1L7t923thXKRttYYGKSyZjWY7HALxIrAix5gkQ0jgBQjmay/PSTynpFLD2OOrL4w+rNMUH4ygeIoC3sGCeshVSBlZ8WCc+qGfDjDrTuLRITL0SeRsJMpZx5Ct7P/KV1EhPVjYz/Urkd6RfuS79ys0Fo9D4bjO2KfSKlJBlv4UsqebHHUG5t9uLFSgFTPmuRvR9CVmWcSlZ5jtwKPuUoLAZK1VnWI90uLeS3TSGfa071XmJWlqRCYuOBQbDPjr2sBYIHG3LeklqKw/AJ2tHUnuezzvacc7DKmHalvXILtHEjq8cfb1a+q5eq7/NfNSrk7RlPpEgdkPStrx0qpAZ1SfHIJK0JRI9Jm157WUQqOkLIissQTe8WBO5LPkUlqrE6YnIu7WtUl1H5VLdXSqXd2GjmgmO2u1TZkomL4lynS+JtUcfcj5EgLc39N20530tIdX1t4T0Xy0heePjXb8pD6r1bwnp/6GE5E2id3vVGVTT3xJSUELyPtm7fRMWVOu/cQnJe37v9k1A0Fr/fUpI3q98ty9igWr8lygheYvz3b5JA6r1bwnpbwnpH1lCan7z2PrXElLr//+WkJrfh7bxtYRkzX9LSP+NJaTmN4Ftfi0htajO0fwGpFHpB9vCsITEZlpcYra1bDANK7ecopIZRQ2qvZnxPCdYbNbMctuSETB54DyXYG1DK1PMmsdNilab8ECWlVprx8B+ysAemQNa6l7WU/gMsoZdU072yA3KwR4dnh5UCAtljkL9fdoVUUNc/qezHt96BAjnmbRGUE9afqPYqspjtP7I4zchNuCURFtCWd/205UhnC/9WVk5yxC0mL19ohOgrnWsQTjysy5ru2/EEfJbTTKmPf9Ut7nmWaMuFHE55vbXCKHdhwxNcd4mw/NjxUc7G5kIY/fPUZOQTp3nRuS3i88tu91y1Eix+9XaN+o0VyzcZs3Xd5TZKzzvueQkauhm3no1nz7ghCuz8gxHuKBjlqu9nrMMdRnMg/QWBIWPKwivprWRAsK6+bO+UqVQZrXjp9mH3T+N2Yy0ThUi9fnTZM/ckS2hXStyj+MY0Ta9i1UptZ+o29pkLZyaGQV2QquL9e3Ol0GtfX3QyygwSYg/cUfbrce3ny5fxynxU7yVYmaawMx2Pe22QACTjQwF4YylkACdfKTTZ4cuMeTBg4s1Mxek7YuscJFmlhPuLmYfxr9Tv2Bgof5mHoTp+Haiuw+7qgRm05X7TFTFn0n/shhoHK2TGHjMDtMskRGbaaguA5Inug5wPqjbTAubSaRqilljneE+zxeVDTvWCcU4CdGOTDBX/VlLr3b/LF4XuZktQ+TULBaqyP+pcw2Y0PJf/Gw9LRb4pN5N/WBZ1xGtzcISD6ZNVExm60XmVIa98ZzqGbA5yVEYeanEsYA9Vk7qAJiWklzBOX9pPFJcld8Aj0ELJXlZZ8W0eJCcUzvnNWtPnCHb28TEx09R6Jghh1RseTUdcZ5LZ8RlWUNzB1tsuGBurKcvMajguMpV5nrJ/E701XOlvR517Cu/E29R7nc6XOlcrLVj7p1bP6MqN3VDHHZ5S2Md7nlfv5YzxnK3hYe/tnNehc+0h+ntuodOo/UkK4t2Pmrf6T6s5zUNc9NmoYUzhCR6E80oskJwJKDylOM6LjDVQ5VUse+oHsq7WvIaxZtZXB6K9oLlPOLWUnHtD4n8p/lgoZ+UBo/+cpmVaUlyA0dMcj5iSa3dh90+UrGOtpfiF33PknRRLjzpPtRMPlkTZgKBFU5NGjg4X6tkdupCmyu+dqxaJesF6XBZoblo9dTf9vtvx33ZYQ+j1ZXZNDOZZzOnPkXXMkJlrKh9XhnH4B3dwQzedQEZvKfb4P2yQE+XmWg+jt9yNZKjd9rHo7CdxI4toWWcDm1bkqSM8PmuxdbMtA/rpHADsK9fq9zfkxWB7HvVqxeXqvvKPBz34bwP1324r0PFKjrk7bCQxogWYJJStQjoDySEz8P7avW+Wr2vVu+r1ftq7b4a57TauFUu96/31WSIrsP7au2+Wruv1u6rqdfwKfr9zrgw6Nb7/VL7fbV+X63fV+vjPtl9tX5frd/PNu5n0yjX4f1s477auK827quN+2pj3of31cbVNW225K/zvtq8ryZD2M7Ddp1s3leb99Xm/Wzzvtq8r7buq618/3pfbd3Ptu5ns+CHfzuuz73uZ1v31db9Jvd9tX0/2z5LWKn7vtq+r7bvq5ECBWmGUvf9bPu+2r6eraXrai3l+7Dch/Xcb7vWztJSv//gLEGlpXn/uu6/vZ6t5atPtnyNt5bvq+V6H95Xk2+2/nbcf3BfLa/78H62cj9bua/GZdAO76uVdh/ez3bPJe2eS1q5n63cV7vnklbvN1nL/Qf31ep9tXsuafdc0jSX6J/dV6v31e65pLX7avdU3No16bZmS1G7Z5KmmeRhOqet7B9b3/0FmtguQZ1+OVC1o/MvfmaR3TeT4rPIrQBeCuPI/GMLiBivcUvF0KExM85SYvvd2eT9+29qD4hK8/WULt2anuKtek/Bfr/7ckInXRlbyRDX2CNdlu6T8J0gIETms//u+riTQLr+48dzEsGMV2y+oObHSXxeuQu1svfLSSIYbfeJv87EHyaB+CSRZkn3GbGulNeLl4SaHyfxKZ0+6ar7hvbsUUqn+5ROX3qc2PBBzY+T+MHRpVeAUnCU4OsRdtW7j28aKb+DaEfUY7358qb1MCfY8HFG1GO9XfEe6rH15Z2MqMd6x94tx17MYPFJoh7r7Wc3DUo5n8UniXqsNzXdNNDkXiD8OiPqsd50c9M5kruIgRy1B6WOqMd6t8ktP0nYmEf1uBF1WO+quIdykPMlBzmiDuuNBTdt7pCHAl7UnyHqrd4Xb9PTjctseBsz6q3eB25P660vc8mMeqt3SdtTvbW83UnUW72v2J7WW1862ox6q7f02lPz63y7k6i3ep+mPTW/ok4XLRcz6q3eXWnTeqg0KHeFJ4l6q7cr2rIrQrwdP07UXb1r0KbDDjfyff325TvbjLqrd+XZst1pWMzDx4l6rLe+2Us9luXO4CQr6rHey2bTmIYZhHAuicxstjez2TSmefWBUjOjqXHoCRQRIPgyHf+kOoehhUfKZgOF1IX8k2ASJRsoovJp0iZMthyhqNss26RaLkcoOQDTQqk9LMW299PZNMd5dYSy5nogy8OwTXKDQyoqqxB9eRWuZU+EDZgcoZDK0BOVdByhJkpQdISaxQyhgMS1hzRXGkSPlspg2Z3AFyaJILZg5lA0iJU5VHuCvb33z5a7D0g+cQ86hfl9iAl00WXJNR2IKUD+8kjtAuwg8bzk6zeszLLusmi/BCzSOmXRfmrsvV0CFiQbzFMoJgVkXg7ayPuZqgX0LXYym2Z5CiPf3HQH9dhWIRWDl42SZGeWWeigRYoH/oUwhbjnJbd5vX8wT5YSsKhVGmkqC1CiXBO2pciOyvs0GzhxGoZKKIajRkGQZSUK7yTGsjZtWaXudoHZ+bdEiYlzw0qSCjpNjsk6HDp8fGw/Dy/Nw/0lbF/RPOwdYbY8X1p/iaTYrKI4oEX4boV6NwJC7DKYV68qTqeUaA6WlVeHO3w+eooLieZOohl9DjHhAiSNxGmyI5NbVGEWmE6+adCS7JD6PXbInRiqR3zpmBqUjGT747n90kGPlLfd4jLErfB0gg/KEav8TkKdpj1cYbW9HmuseThVhg8s9A1Wj+0w8pOL9Vo2ZQBPhlp1FVK5nAdlT60q+7GkJk1MpCL1+vjF+PiPJ/XrG41UkDdvSK355U3eL+XAolG3JUIoE+O/DMBQtuWPm+zq9Mb7fuzKvWvLNlsWmDHCe9pf3bbliVAVbcuJc5vHrlQ3ZBI6gFEKs16V6CZ8gKNtHanPD55GZukdyDGSTVA/o/N1R4VMUzZ8m9VVkcMq1msxVyV+2UVpJ1b2CU2Vm+PU8N10nHz2Nb+u05KjwCs4XJLZ3C5XPiG2N8vdINVUo2XQ7rTIzKB+jMRuy+lkFa+wioj5sP1u4KW4zsCqk5NbY1mURRobsyi8cPSVRGdAG1zW5/yzeXeRTcMJgDIakBp++pC7SDawoLmsFrOlh4AWvq9MizWSNslNtC/MRwWpH6ztmp8oxybT7m2fF6Ui0uyYkhnsQSgYAl0FcBzrmY02rpnEHgKq7IvjD+2Lj1HOZ+YbrzzTwuvWFy/5AU72rht7K+IfL5vcfXCNs96O8hzbGNZ6J8ihaZmEq6QgftWw9UXgiUlUUOk/g1jsxsoje81op1vD55ilS/0lK5PCYtu8jDNxCpZzvc3BW4jhbufCbFzIPiFtWI7xLB2dXzEF/m7CRwgu5tSFBOQ5HDm0lN/eO2Pvb1K5+4mJxEtajDfHsDmarGdgDUT75Hed7YAq9uo2TSPtr2kamFBN0yghK/WHt63JmUJweBvtF7EYPyyG1yPJ530pNi0W3jNAO9pweVuGvb+L8O5oofdmBnuPrzmGHW24vCXA3vNrjmFHGy6vz7+3FqS3JN+ONlxeQH1LQB29PT5JtOFyyuL41zpJjk5izX+e5PPHcxLbcJXoxVrz4yTZn6ToJOGGy5ofJyn+JFUnCZMV1vw4SfUnaV+2w9b8OEnzJ+lfNqHW/DhJ9ycZ74GLtXLckznGaQ/bEqH+RbgmJdggUVW+8QUjF3XkSsFvgENQdqi/G3zBof1o4cajSqevcrDDlxoXxUasPG59+Fuf71GPtXK6h6epVndisXnrJMdwkoUJuMWVQ1RtzKCZj4PKw7DJC4AiPQ52WHqcE2tix4MnQE2DT4AYANUrKuvx18fTTP8060v8Ys3P+IUOrIz6/2mBzOPJln+y/R69WCsfo9BCeR7krULRPk8oSrZftWSAMUUMYoc90tnlopZkQWmSqkIhHEKhDHzLZVuLVIAFNagmYdvI3xTeYK+oIAB3JQZuzQRfZfI3iSjSv82KkPk+FQ8wzaBIBzv9gp1o/c398ar8XEml3N3DSdta/TmynyopVEuC4NPh3Vof5/AzJbVgAaYLh1aOJsrsJ0oKf+44OrNW0qZ+yGgEGqidQEzhGl6tSAq7nXgN84xSE/P4hoPpzAFLTYdKnD07AQc6WGLic4O1J0Nl7AxFUEIhjZ4GYESJ1grv+j1kTV5/MCjOUTXDApmd9Z1/9lDshn/BuWTi7+b1L9b12z6/yTcBZ+Ets5U3uld7vFK/bEgcFXzv+bv345VGq0b2qwZlIpH8achQLX8OAvZRXN1iQpXfXZZNmnucIi7w8CJgk5rMh0caqjLLAJj6nvVngJO6qUaT96PzZ78UUU8RkIm44z5jp88fzzkoYzPDVKS1yoQCpEawJzozRsDPVKbFEAA26pt2MKeZ5ML6NVDKxtZ3YEZ43IefzSnjt2eY8LFWILWqEMx8p5tqyc/7gOrtuQ+8SsA+kQ7qyKc+bsRPvhTU2+ttFLKcz7r6RdDY2jpajoRJ5rNNkkzF/eH3yvaRAQ3W4ABfCb3ncWd+rqNkHkbVwH5guDsr0VxX/FxHSbPX6KVEc13xcx2lvjAiwzdULr8UsOyHZSMbGWvraARRbWMeWYmSpYt7QN+FnH2RSw5ntSNM4tYSqiQaNogwUNyoJIAMZQeAvAW77hcCBpuuKL3Un82cvX6jdwso+NuWyc3TgTXM7Blmw8db8LM1xbYAqYzfwrUjHHt+zAvGvOmn52K5ViCEzBm3dRgECPQak7sNEw834MBsou887sxPelTwwoY5vjNF0sUG8j2UGksZGZR5Ztk2GKwgsmgogQ7GIc0KwCPJZif+4z44NYLgHE1PbD2QF6XCE81vMIqHyAnld9jwKUxGtL0N3IKgQ4WKzTJESXhBiypiP9jE8YB5B0JgHjfr51JTCXuJuUo0lxY/l1LfK79u3NTMmMoUE2a2wBRREqPQxhUjn7w8A6+MOEn5IqTpm2ich0o6t3rQOe2wYYPOhPQuGSrFREAy9nKVDMiMHRl6Gw/7fTjuw3kfLh0+3oGfxyVpllNYZrfmx0n8HCxJM0yqbUOXzZ/kuY3+/NFOIk0zJOJAUHenqNFsWf1sSdmijC/TyRT0J4mmy+qnS6oAZcTW4RupUWxY/WxTlX7K6acz4vMniTbR1U8MVLKRGt+EKYA/SRQOVT+qqRaTkQeNHyfaRFc/2qoE+/JLYkHNXEmXUccraQlIJnLysXlR1cDNnTC2YKAK/o5FekLXzPW4Fz9q6/y2JKoVWdzfQmEArNvgX5TE8ifEwprNiLjq+Jmss2SSa6X5gUw95s7HrfjBUzV4YnCINT9O4gdP1eApLxFMjQZP9YOHCgFYFoMEobVWSjChLsI5m/nLrshInPZKQTa9nbJQD0RrJyUzc9vcx3k53qzMrvHHLWkwlpfZtUWDsfnB2Mq3DUGLxmLzY5G8c4hq9Q61NH+OaCg2PxSbVuEV5y5aNBKbH4miEoMdNn+bSWV8nCMaiM0PxKaBGJvfWfPjJH4ENVUtY4VQa/7QSxBl9tZLkAoGaS2qZCCK7mbKUFQyIc/h1CtBoyiqbMvVBLn+wU3KFUPmNi2GBJmQehXUCiKfSgIgZFRlZSTQh6s0Cvn3v+yaTPMEO7HmB64YrLmEoD1rpgkInwgbBKbTmIq6RDKQ3h/VElaIaxtZJ7zNQsnNDJ4oJh0RrZBqILEUf6pZj/JimaCBiplqz8e9+/nCEMr17esxpc1YrFigRrISN7hV+ni0G0Qer1rB46OkwRAG5bUPZQjmA7Ala8zzdOAk9JQ84n6TNSfUcljSwGGPqht2g58PRDhyBls+fCAhnK8HYqIJl40eiDEoKFHRY/xx8812Jm/PMe/nWPfhjh+p+7mva+5rL3O6mrnZLfw2i1p3yAmi31NxghsPpIe0FS6s5wMy8TuHdTJshnBbHCy5PNar7mfTrtCmhXJ+1sxtOcUaJ5WBUanK57c7OXJvjNgvuL22d7n4BqFD9jtRekSxlH/wuD8/U3dFTe0lu6FmZhUYfd8v8H5tmF4piHi/wP/0a/OTf1cc1t46aPvztTEBBqzO/9Rr8wtLV4jXXmazAKr++eM5iVaW9pLRCaDqnz+ek2hlaS8BUQBV//zxnAQzHtjCbWKf6M8RBVXdT5JEnSPlGpaf2CphjK5kUPvFJAd+VqN+E7C62lwj640ChzyMMM9gNu8pzpN1P7kN7Wog3ALXJXcjbK3khbSzsoK7jei4kReIC/dfSADwaZgcy7ivE7b2Esaqw09JRL+zQIKig48+AsD854/nHF+z5mxlzaCumxzDSRd6GaC4KwWAM5iSN18pUpWT2TeQX6hT0n4n2KzUAP4dj2Bz+JljaOboLzNHgML//PGcROMcefSo1wQo/M8fz0k0GPuM59gAhf/54zmJBmOMp7Pmx0n8YBwajG/FlgCG//njOQkHI3Rcoy1ogML//PGcQxHLSHHkGwDxP3+0k0xFCeNlOxEA8T9/PCfRujxewmc2f2R4kgm4qmjXyCgVrrL9QBC8yDBOIofM+vC3fMnPfOaBpoqxmyISEMhaP43RDDhxizgTKLSi5FLJ098MkZX7YeTL4lxipROhWs6MPpnwgTRDFcY4jx7nfqYfzlPhwAiFcU/ztHQmI0noVpuDNupvUAj7TdhZMT1OcGtmzQEbgkwR93O4r8OZ4nVt+kE9NahnqL10mv+B9+fnC3IaQARELtrfXDuFB4rEj1P85+2yq9gegBFzYmHvoAumAYjufQZVCHWGDGTU2UkwAuL2guYIT0zB9NPT1PSEgs8Te2PN0AmWnBJETX53Vc9u0mBh9rv9MJMC5bR2PVPTFgZPxK7L7RlinSYNHOjKNGCZH3fp57+p+e+tvMRmouBRhLiKFtyi0i/wLlooS5vbZ/miaqx9Vi+kXQqZGEIKJgX9hOzsYnUSAVezKAN4lm31iQyVgfPryla/yEjZX39QrdKRM6MJ+7Xrbx9vw0/kJJtUqlUFS7foK+eTqcSFLTk3d/wE3F60btsjQfP1ESvnioW9/Od3WoPUCQNJVGznHzfpFwoRYOgxEH6yaKWYfqUgmQVZhyipH9BfPn+0U6z0rfwbsF8+fzzn0DKxXqAqAfvl88dzEqE3UfKCrYI/BydX1ZGYmQMP+65PXuVowEu1IRicw1avgmbY3gGyFWfDsO1PH7fmJ1YyPsDfCpCA1qpaBdVgMleWRphBk8g5ZwOIGvGiQ4HeLtdRVXj6uBE/gy5FXG+1r3X05dcxRGUN5eiXyvoW2KApebZleAoIiZZcDYdrfiOYWbcU2zIFkqTWPfp5+5PAGGQTCN2WSCkx+L/g/+VE/gVAvln5rt35AsZ9OO8/WPev+xwWRhR2mO/Dcv4ZMjKECT/enJ/HScGAqF3gImOt7ZSWNvc3wDvkxiTVYSGAfQdAevulni2UhToFfBIXSdhDZGKEcfi4JT9pi+WRUCQLFkS2di0jhBArbccU32QfoqbSvPyG0rX45MR1TrfDhc7+YNyHXNQfs9TyU6kIG+UN3LiimHj5qY70hFZfYInr0uceuZxsKfoLpD35oiFqdpZVYrsQHqL6zlUUq4eOLHFRdTgN4JbFWRlPbMTy0yk5BBmMsvhpLzmDNs5wopQhkW/N9OAQbGbL1wIkWbg3pUTdmgZ+/IhUdM+Kmj8Tof2kR5G847963L6fynf6Gqvs9Lnw3avcFap8vN1rnYtjlI+XexY8DMahP/U3uv16QeR+Ruo7fM/7OPkggK/HboCSkkpBShi/miIhRNez5L7xapMS0Uc3NNF4gqLnR3kWqWP+c9INCydDQBwL2W4E6G9m1hGSlGSnq6QsjqwStRQ3q9Qr65HQAxKskjzTMayJRAS/rHMihXg7iQ488xC7AurZPAugsNQty7+wKeFKpb/MhYOC8AE5MuDJijCNOuQsLtUxpnmhNMYJXUftbu7XSYl2VfvUj48P59do0gZyyS9BiwgYVw/7I6JqFOa959GPLka/Ek1yn0HV3bHAUvr14h52uT9uj6iUt7L6rrY6Qmqf3YolCRGXINm7D0iDoS+WN9z4rZRqBjyU2MV/mLmcx7MBd6meQeANazOMlRmHDNqNJHPXGUUOJsiQkgU2sjFWOzXUa7bmLJoqBVELKyuk0qBWVH/L84P5gEFMiPJW/1Iz3z/KWzft7CN2Wc3qwJYMpoV6ZppNNWFubHJ/LHjbr8FiVJS3OhqbC09MLU5u8CCEiWhi0N+qiaSMFDawnYGTnZ3mj8sa6T3G6QQUjM8fzznmt+A0YGB8/njOQUD3SPEyKH4Gs3+0XhrZRgJ7q2yrxjBHQfTBxoK9oJaNb6nZPJ71ErkhKWXch/hHT1Tp9sugWB4z3qgHHI/PH3WKLI4HiGDBSpQjikf2FI8siscK1ZGt9XGO7M+hCavEE1aOGB7ZMzxy0rQS9p8c8Tuy53dkcjVG7mEmOEf0juzpHTnZ8Inzkzmid2RP78iid7xBj3IKRkP2RIssKkUMPcopGAzZsxsySQG51Ljil1Ow/c2eSJDT/oZfyinoqtlD7HNOX4Z2jiD22UPsc1ZAU+Nka44w9tlj7HMu3zBQOQLZZw+yz7l+w0DlHPVWDyvPWatGrHpizY+T+O76FVeec9RbPQI8E84NQkeDR8nj20Sd1SPAc9YW5qX+n3PUXT18O+f1ddjkqLt66HUm4vl1EshRb/Ug6VzS1y4foaSzR0lnoaRfe1oEk84eJp1L+drTStRdPco4E9ybEZaHH6dE3dUDgrMgv68fJ7AB/vzxnKR/f7FRf/Uo22xGva8vNuqwHmabhaN9f7FRh/U41Vxsfo23sjnCqWaPU83CqSIQC5fxCKeaPU41E3U6c1xazhFQNXugaq42v8YbRjVzswGNJTJrTNcAGbkbydgIC0fUOslK6LSs69JXeNyE7/ACupZY/uw0y7uT+i5kQkNRguBiqD5CIFx3MteFqYTmiQXYcEiIsOfZ42Wz8LKgosX38kdKEqtsNTsF8qpBnDO4+32bpRHQCe0VuVhvb71m5/3jPjj0YqigGpnKRx3mwqHofeCxKdMhKXSVfhDbrIOnzEkcUOzQsF0PUJTZw3czobjQQwx7LFvJVS+fqf+pMsUw4AmBMaoqlfpTl2X5QO9gBQfKrE3y13wK7pMydxiQYXnco58lBP8tL7AdNT978UVkuD8ZJeMkNiAPcln1ng71uBE/0xCgu0sc2MoA8SR7L66HVYPmJ9+rMfOD5Je+HqrB9LpiIm8xMUkukFgNftuaPVA4E/S768uNrf+nG3vejjKquNTz2/kpUejj8oJtyhH6OHv0cSb2N5cXAFK+4ce0QpuSRCmfe711HOfpQsidoCy14EzfmW5Bp1QdV8mYmU5OuFBHJnl3FLvwHzeqefcFn5QjTHL2mORsbmPjpwH86odk07R5FHCqwNb46wtS9EHBG8S/IHcHPIHBeyBlFmB6skc256ZZExCgaHaIoM3ZQ5uzoM34MvE7iaINj23Owja/BbYRtjl7bHMWthlLWhSwRNDm7KHN2aDNKQ5JA/ebzx/POZTATy8vdZ3SCtJHyHv+wlpYef7ZzrgdyJTBkFaWl4WAsTyf39QPSOF314wjrhaNR4+ozULUFoCnohglEAX+/PGcRGPlBVKlZppWQTqGPnAUgatCbjGXyOobkSlIYlbWoQA36UkkuS6HUFHEMo1LSAMrhIU9aWDZI1ozIaSoVETIIrVmwRVOQUYxQ7qYvm19YBrSSUiJ2jdJ7Rs2Id2TVC5EsD2TddlDWrMwq29ZshwIGn/+eE6iIYpycEAlzYGg8eeP5yRKt7xgn3KEEs0eJZqFEgVzLX6caJB6lGgWShTeb/HjRKPUo0Rz14ZgvHXPaEPgYaK5a/UbL6tfIGj8+aOdRILG5FpHoy0QNP788ZxEo23EDJMc4TOzx2fmoYg+VjK15sdJfI8VlBLFgPhOoh7roZRZUMryAt3KEZQyeyhlFpQSdan4caIe66GUWVBKIJnik0Q91kMpM2GRqO2EHVZASxNOqWcuvBCwLBFVystRdcFEHExnhVVIWGxoyoGJcOPfwagQNZv6u1jeEtr9uWXwkM08NDJeNALUXOpdmzF87k3g1f6KtSIWbjZaSI4gdaw3E68HfFGYXaK/aGjJ4EX83lxYoH+gd7MHiGYBRCGjGd/xtl0OAj2VDzaWDd4ISGkiF076aDUW2X86C85krIEPMn4rmW8TlZdRH3Gxh5tmwU3Li65AjuCm2cNNs+Cm+OzxSaJB7VGaWTBMKpVGQynQff788ZxEg3rFYgQ50H3+/PGcRIP6RUkgB7rPnz+ek2hQxwr/1vw4iR/UwgcCuhK/k2hQe1hdnlqG1kuvC3SfP388J9Fge2EG5gj2lj3sLU/1/xe8mZrPLh9sU6sus34KX2kx7jeQF1QpAAqvMQNRWfUSjthI+ECgaM9I8E0hgCwIZTyyLi8Nif0yp0fQuuyhdVnQOhRm45NEQ8JD67LAcy/C0tb8OIkfEktDYr/U41Y0JDyALQvAVnaMBcorGhIey5WXhsR+6c2BBO/nj+ckGhKxMp81P07ih4TBo/ZLSjCCR2UPj8qEIb1V01Y0IDxwKQu49LYVXFFY5tFD2dBDL1vBHfVWD+zJ0iXFUgeWir+PHXVWjzHJhjF524ztqLN6JEje1llfIrsddVYPnsgEMIz2srENVCQ/fzzn6N9qPoGI5OeP5xzsqjVWXbRmxSD1UjzhNu2e3IRLw2o+DKRFfW5NhxlZIQMJCFdHJ3oYATPfmStZgg8ATvYwiUzMw/sbi4aDh0lkA0K8FWICocrPH89Jtl7Zy6ophANDyvZzWIdXJpHcEgBOJb2QJKc9pGyUIU17jpblFKnqykTj487cMCuEOWQI0kV8pxJBI4qHRhTiHPooP21dzvEf5wjGWfHQiCJoBJ26wxspn95N0nWFHJCpP4/9EZ6re4H8jQSdTJtmPyHuVMoCGDy6rirYBaSpSyiRib4HN7B4HEYhquJNyaJEQIzigRhFQpuwlY6fOhjUxSMxipAYiJDjk3RL9cCgpggkVkVTwfAKtKYLUYHdcISZ9EmYqBVqVfYiq4KfwQ4H1tIQNQF6IF1MpkwUtiTLcxVtF5LuAFBJ5x1+o0DmYWgXk4x/PGr3j6q5J/bHtuazc/vcB13OXqSNMvteRdQqYSfRbAS87G4ne52Bf7U+UmrcR4a/36n7jacgNfOWqE56JD505yhS3oWoRVEUsD9yvRRAMoWftTmClSQPHzc1/U0t3VTMcVRzkXLSMJzr/FPBk8BcYPC7yDW0Qjade2oYQtNezJsOAat5pPKX9No63mszm4BNTTGSFegX/ds3n2wdl4CqeY+HFF+ww3z+WSUqOHWfcCweolME0akv6IsSQXSKh+iUrDnzBSVQIoxO8Ridkr/FJiWC6BQP0SmE28CzM0qflgihUzxCp2TVZktIJikRQKd4gE4RQKe+VPfVTHcQmsHQkKMJ8orFjJDXfPgzYMwAGsxMs7DV5PUJWw3mFQHStO6YZ+rCgMlYBIvw2RDjkn9Jl+MILkBPQZNHLdTyBHlESq1IWcvCdUI7bxOXu9cPlwlt0oSyRopcMyaqQAR3o5TeiHNGHoZEst8xtYWzoy5nkmpeJvAjhuobTHHAjBGWlxNrNvo1bq9tjqZMTfZOTxPcSZc+01JlzeReKwcb/e4rn3e2x2jwmKhCgNNCXBWEtmplpmmW/MlMO5QIEQ1Wah/labw6cR4aVc6wU72ivTsZf9UEH/foZ3wCqHp/658MNiF/9lspTjW4IvUf2BoPUW4Rha7fXn28WDw2qxBn9T4mT56wfwoL6oHhfHQJC956o7fEoB4Y9K8trDKnOyYFn5Sv4gFfRYAvDPV4hAVRaPGAryI1zfoia1IixFfxiK8ixFd9gXqoOaDPfDCW7r5yJTRuUPG9SbiJNJ0i1pDu6axBpQdRtnhMWRGmrL5opaiZqHXwU0g+pn8ClcERISqahP5PJrF3XAAQAi0q2UC0TSfOQpsVfHHbrLQRoi+Kx60V4dbqS/HdmvHqwLs5bjR3zEaWwCVqhGhL2SQ0tnSWXYRyCt5WTbYAr9nM22QzJsMEaUz1zSijmbkMDvex9Kn9+INkGDVfh+U+rFr/H0/uVx4C5/pLPkqtIgCNn0rHpvLDRQSLN8ktIDjZvAr23S+8nLV00DEoU2Z8/8jufg1FhIXyQ7ZOZKMOJ+qCDrJ3pqk6Fs3R5FZACRNTOTAuJMxABiORGwN1zERCC5lzCpzLTye1B9ps4CpiLscRUSLAepGAg5tDQCRyB5nPPB+ZKaZkypWjpyFBGfsVlekiDnmlaRTdqaqEDPSriB88XHYjufZ9/QFlqBc3QSPfv5pW+ePb+RVfGEewSuLh1f6DvfayR4r673+0125ytYES+7P/xp327qmjXR18yOb3OVz9kilcZn0RXVDz0TBSPLOydvBCnRXTv9KWBJL+lYsiqh+DIT52sFJSbstndYuHeBZBPGPEmxrJqocHCw2WFscO7OIz/dV6keMbvG0odA4teKrGtWWKy6gx0VR8bnksQFGOTEDSzoSlSwRKyAQOev2gHyGsasZ2Q95Z1m8LNjuku1XTXE1y0WL4hdtQqLcoiQ8ftUNsg624LOEXJuXB20WP4ci1SCP9CsgH+l0lVEECAiR+/UKFlSKtv7B8VeQF9tLgYIayDZTXCx3jBoNKnGXovJBO5GCGj5E85vCA9ITmm9wcjKS/JZ5wEOpEEl3hy2OIkIh6IkkOd8GdsN5Ghpn8+XUsc03MlS5yi51W9EBu+EiWPs6on53ExzWC8NaXSqyaL4Oz256hXnK4Z5tXOFdAdP5jvFIhF6GMhOZ6rz+dCYQNcQ3aNGK8dQVKu3MVP64QVHJjzPwxdMUx5YJUKFfPfAGLHtr6zfYyXH3cZLjjl+K+mtll6T9PJyRzYxpTa0k1ZSdIU1D970qk4O2jT4gEmZACyZpVm3XmNYa9Hti/Y5LBfDAtqwKytHYGyGf0S66mS/ufN0KHRCQbeC/wSpxc89Bqbwyk89OxdhY2tRCLyETpOZz9SAlKymY0/jrvX9f9t/v6A4ZGlUf5apfhCHptXVV/+vgSPvgUeLu+VB9LBN4uHrxdqoLP9ZIuY7Pcv/InlFjYT4QriuDwxigewAK/ELWE7gFTG0kfFA8BL4KA1xe9DDUzQcFliKEsVB42Id96n/uIgIGVch1mKZPhsEhd93EvPoQU1Lu+1NNKJHlcPIS7CMINT7CoxKzmo+XAbB+91s5kvORlsY4tSsm27Wbq5zJuE3t0WNQFvVqT4ASmnyYmONIan1no7ObRJgX/xG4vOio1M9blIUfrPv1aZVmGYKbSQRIM4VyVR9fhOpINlVeFfWhuHIF9PCJXDzMvgpm3F+Vxa750jJTNw6oj8BpzCO0IFg0WM5S1b/U4W2grQYmQlsr5taUa7xc97rxINrq9CCVYM0vRHY6P5QiCFEK48VsVdWD/UXbQDohL2R93SH4nvnKzusTjBn3cQvD3GzdarUexlrR5mmDaIlyPaW3lnzSTuW3DZG6xfCpggSvcZFADxw+G92c2H1XqdghmfmTTms13D1262gQP5VKa6tB0WB63F4UfE71uxtzkcFuWecJgsCFQ5AeUJDfGFZ7hH5fQlI/rLZJblea09WL1U95EgU4bFt9QACVzBWH6flkYptUil7O9KN2WDQhlDZnywteORrWdbDpJ955YxjYezG9lbpAq1aNbu37V20TgUahEwPWr0NyxsS7FJyY/nJhRCQ03yTh1dmEtafhVEShCySaPZf1a7sOqw0dv8gGOZMNfHNqt+YrKDYl0B+UcqdjGP9FH6vsM0JJEZZ8xuacLFOmKI/0X302UrPHI/kKUPm8hUJ4sEbC/eGB/EbAfTJLwRlqUqPag+yLQPQKY+CRRptqD7gux72+ioCUSAi8eLl8El28vag8lgssXD5cvgsu3F813a764QfcuTXgcJBFk14ENYM1GF4Jvh9VmGko9v+jvDduKAKxWPPq+EErfVqz+rlYGb2WbnBOiqNvQpAnbh+55O5oQ7EwYY6NE9bBO3bHzayxkBeuIB/WXNr7hn0uE6i8e1V/a/IZ/LhGsv3hYf2nrG3S5tGhQeXR+afsbdLlE8Pzi4fmlfwUMq5l7iTU+NcHueEAQMOq1nG/48ZmY4mRV4nO1pW7SUxu9eNx/6V+RyMVw/wZGo1U33AGyYpS9PlR3UHs86nIfmU9KcVDXCVsGITIaA1zUwnMrLQSkFQ/+L4L3vyGVS48mBQ/QLwbQf0EqlwigXzxAvxBs35DJiwYgW6/vePtMReZCX8fibS6UG+u2zzjJw/6Lwf5f4KMlgv0XD/svBvt/gY+WCPZfPOy/GOz/BbRZIth/8bD/0r/iLUsE+y8e9l/6V7ylmuvBzFwT5zPY/fhsnN1HGoa2hIeo4JbYRRD+gKPHnfmpYXzFWJaIS1A8l6CMrxjLEnEJiucSFOMSvMAjS8QlKJ5LUIxL8AKPLBGXoHguQTEuwQs8skRcguK5BEVcAlKVo2U74hIUzyUo4yvGskRcguK5BEVsgfbiPVEiWebiMf5FGH9UvsLojs21HWE/+mJQUY6SOcB8CPaE7MtkmXXky45CUJVeTduNVzGwcDMXi5/caoogdMVj+wuR9aVR4vuiYH/c6dZih8LEMinXZNswiujTiQ5be+UQE4ZcMQFXs5oDMbQTgoP9L2sUSPAJNAI4zNj1eMJyZ0/iQ6Lrqpzc6SZCP9u80zkEM77+0rm9SWkZ7u2ks+mIKf9CW/Jx/Shc2eO1+IEufgG2yBH2a548VFfGsGnLNMWN7dKiHJToy6nROQmHeYpB90hAeGZCETMh7ZcZMGImFM9MKKYfnV7W34iZUDwzoZBlUJi/fKIjS0RMKJ6YUERMABJk/c7lk3oRL6F4XkKR8nEbL5sD0RYuR0slN/r8w84KqWWzkBrlaDSUBjoWPmRpM/HocS9+niFVAWWJ37Qfb3V8RvOX2cSHW0cQX2SYGXcRVaXCDL54XsT+S5xy5jCe8KyJIrnhNl/G8/zAODBJn2YARL1j2cbeLdMVJqeIaxiS9kLvH7TQCbYanopRRMV4HVJRZOCZGEVMjPdxEcX3nihRRJRAX+HTunNEPInieRLFJIjfxlbEkyieJ1FMghjct+hbrWP92ZiHYt3b5uCerPrIzAtLRjDCkbxupRsoMzJJ/DYU4OATs0zbTwmd2YrVztGoEpSsjJWfqSbgOw42Cngp1XjhCMmEE9alzorFbMpVYb+x6V2N3M+wFBWOqHBIp1Aq7CPlJ+thppMyK0V2OO2ENMM5v47rD2ghpENhcbhcyqKRciNNeWUdDh0+Poef6tbXqS4inBRPOCnr61QX8U2K55uUpakOdLuwW/S/qssfb+KPNzf0BWPlbDUfgZireH+lB4EJboTYSamD+cwktxMeSsu70jttSzXmcUt+PhZlhx7K4UQRhZOeslNIwCnAFoZlMTbzi1Qm0xFT8fFQuRTOqR6tfUCgJfFRLm+McZnXd+yaOM7W4FvADMnaLdNSnXFZN4tt0+Vlbj0JrEH1c4K9H1IKxbOIylLoCZZI+FTRZO5pREU0IoAawmQNm9l9tjEuSC8QBEX+kyaAyedf40MQwbSSi0zj+iEVJLpFIBy2F9BV6F50zn7Edp60VKRGjLAwRPWymUl2ivsyPWnW9PQp4nfdzaCzKLdbrQKBLrFL+0cl4kK1YZV6f6rkXiHhqTkDmBpW+1ESJPLidwK1KyliTQGoQ/8Q7SGo+JAOc1FUC2APA9xhZ8mJtfDRTiGBGsCVNvT8VyOd9i17FgbvhOKDAV7ErC7zEYJ51lYRayu1l0Bgn6VzdnMIBMacmPc5xAGvyQYJ+CDCLPaj+I1gqJLe0PHftc4RXxNwGqgmV+n6JyrDcMZNJFfnh+Zp8YSxIsJYwjQSdluB4wpFODjZ5uOkUda6Bzfn1gY7bvM5lLhuuRwFMchVst19fgxybL6uAf2fGcWetVZM07fEgmtqvkDG3KpyW3zQxh/c2AttLDtyOnqUcna86UN33UR72odM/eM+/aq6Ddg147UhosYVT40rosalF+MnNfMrrb0+JdnoZQcNtMutjMOmc3NMzyDzw1Ui9hHme+ZbMYHg9QJ/jahvxVPfiqhvqbzUoCLqW/HUtyLqW3pL/kf6vcWz1KpYaqnEKXc1C4hSNAraCYA1egWM/By9Wf70y+ZzrF/cgtV5RB0QucwpxOaFVNrtGhG1XyOiPTZe1XPkqjhyL44Pav2fdXyonnJXRbkD1XAdLtPHHQXb/+o5cFUcuATaYsBvUvOVOr/3j1StQPj4xx73slFMtR87J1VdMTfcpMaf3M1Rwq+m1RPsqgh2L64Wav0Hu1pUz9+rSQH9ivd5NVJSrp4ZV5NkxaFPHYz4GikpV09Xq8mmjf5yJ8G0UT29rIo/1tZ4OUkwbVRP1KokXRH8H8zGNeJpVc/TqialvOKNUo14WtXztCpJV6UB6BWeJNjJV0/Uqlk7eUC+wpNEY80ztSppVwVFpPgkwQa0eqpWJQeoAKsRnyTYgVbPG6piBgHSHp8k6rCe2FNF3YG0fnySqMN6xk7N6rBwIApPEnVYT66pWR12v/T6iFxTPbmmZu1W9kuvj8g11ZNralGP3XFxQc1kULKMT0MLuJNpcSilfzi5CNLerLZc6RTSSbOZIJgRxEBDuj9sXsapGiAD3yhiVAB4CMzpqufc1JK/TkHF7BmFGLwTEshcrbU/UxM3iFB7l3UYG6PLywagVaaiwHlrP2WeLEMhFyAfcFVvn1hDWpQgr1RJg8YUpX0PwFrCLHPnvvv5jWkI5DQGLayZbxDsFpWEnIhuJnqwXqhEyMycu2kCNeJmO4vtJT9CBU8JqkW+JS/wQmu+/JE5SZpC7Yc9soxn1zy+6uS/IFwxf+UEwqEOOxGGaz+/sJ+CRMpJKB4Fqylbb1/eK9ue51EXvlVjbySu0iqdkGpWpcr0mZzq+Se1tG/LQznGYwnQbHIBUGBiojGDZg1H8WrbJFqn8DPPg9vesxlaG3G6MNqNEJojHNm1IUQIwDHVLJNpspL9tO58zmzdBtQdJjALXYT4RZAqwyE75QLMPsmsXoflHKpf8TY7c170buqp3Yf9Phz34bwPlw4fL9hP8SSa4JPFL7h/WiIdLoc8afISHHM0e8PY9X+YJIk4AD+a2xDpw+mIHFyE3Xxz9Dz68DLKYspWstIxpRm1FtgNsWf3NvNHEjxEQec2OttIHenYDw1CJzt9nzgZzWbwehgxcazz75imICaUu4BM/LY+Z1oEiipfjcQK052469JJCwDFpXPe0VG+jsp1VK+jds5DtPBvfc4VfhUVxSenOEmm5s6nbD/EggKgUo5fFMkMvdkH29tm5aJCE/MjSLCKs3wY0gDcGoOhbWP30ixdI4b+y+RGSBeCTldDPAcwaEil6xQ55FqV889kRXicN01L3JSO+Y9tvXqe561n5ZX4B3XJUu3AwElY4D9rHPmLDA1BmfSS2SWARO+WnuPhug+3Dh/v3gcfRdadJebTWnM61sPZaEW1ajOqSYC9kbTEyhvlUKWaiZ6uftqc0sSJugeqnHeJf4u72gzMtUygg3ZfyUoleEmZE34tQrpjMICcxSWDbB8mr3DUSYzVkbmgPt6Fj6HI9yjg7IbhjxFt/hYlqifGVJJcXosSav4fLUpUT7Opotm8FSVqZJJQPUOm1vytKFHNJOFfuyhRPdem1vKtKFEjrk31XJta67eihJr/mUWJ6hkv1SgtL/loNf8r5qOrZ8ZUUV/e8tFq/sfno6snyFS5Mrzlo2uNtseeF1Hr1zRwrdH22NMZav2aBq4RnaF6OkOtX9PANeIzVM9nqE2WoSWmeaiZgTnGJ2Z2eZZaN0r5p3aBsSqBK5X03clSDbZDrEo15o6zVj7KOnQCyXg4+CU5t/8f9t5tx5IbyRL9F72cl0C08042Gv1+vmFwIEQqo1SBSmWoI1NSawbz7we2jDTSzY2eoarqmsKg0UC1a3ukb9/upNFoti6M2Mbqw8h6GhM+Ya/VDwsf6p+lGRaBGRbb6nZ0/3TVbc3vCNHfwa/4NPb0h+cNGSWWV+d3Tv/SwKzUw3WnS6JBL4kQ+ojY3OeD25FLRlRYPwB1D8/Rgzfvnc9Drprk5VG7Ohjv2zLkoQ9OfKa7u1he0v8eSNRqvT4PHeHBO9lW+3H2v7jar5kvgaktm2q/ZRMRNFElxHRb7Wemyj+w2q/pKiHeeVjz2X90tV+zYULsu4ZNQdliwwTNhglgthB1wrKZCBYZJmgyTGCrClqAy2PTvl7B4sIEzYUJoJ/UYOtPBMupImjGShiMlc3SkKwKuyaThMQ1M7+psFtkkqDJJAHEEJ82LpDBIpMETSYJoHBgL29OEsvtIWjaRxhuDzZRKVi0j6BpHwEUDp/CptZv0T6Cpn0EUDhiAurNa6JusFgfQbM+QuLN4cbAMVisj6BZH4FpHVR7s0Kq5fUQND8jgGuRiJZlLbT5WLhRlvCZsKUgZUFcwARIN0H1k+/xoSAmRCwXsR8lmiEGKSpo8kcAkcOTepD5zizyR9Dkj5B5MoRNQ8MifwRN/gi5T4ZNQ8MifwRN/giZJ0PYtOD6aXHoHmphTEMH0YGVRcMQxWRJ4eEBszh0eyQS/LSP8S485W75MVx4c0EzTEJOd4pG/XRu3dVsWlNTPSs+Kj9qovdniHlAWkgUHRILTVxuRs/c3GfuJpoxkWXeDIUJfNu8rd3dIAHOqPpHE84eNOMlZF6w4iaM4PTVxG5Mkan/HGEbRDV7KIthWYXYIZLXFOdLg7seVBwSiBLW+9Mxhn0xSHTPvktsYighg9LHfFARkzSNOmOArunJWxzgHHABENTpQV7uRgcrtsSgHpE9mqxopUkmgUkmO0kOPi3SdYvU3nj0rKhBEOzKZMSYoKjBwnX+qCN3WYXrsGvCu+H3ZVQHNB0lFA5acRMqijvDnqQHdGT2dXwI8MSkIxLP6WM5kSYemBc0YCxZlqA5LaFw5IubiYPTiB4uD/otDTEeBNCJAUSP2mKOivWJ3RcPyM6S4q5PsAIj7renvy2P7jqfNUsmMEuGVCzt2+LTo1UxqiVLeQu0UvSFsWej+haZQBg1LaowP6LflNiwnirh4JTQDPa8patUYT5gFslqqHDOwh8kKK4D8Ji4jHhxgAmawRMKx/G42f7hdMy9/EoQeiaiN2gnxhC43N/ZIzF0dvqDSyDWN7RT4vgDn2DMVi5+oEGzggKzgojJaT/3NBqWRy2sAllyWugtPJVqWKnaIvPJQ4WEWTImFTEh+ip05bsEzRIKhWN82k0aKzvTdJ7AfB30Ta0qEbN9YBFEwyt3vQJRnJZ6ckAZOUKjjJuwVJKL3eaUHnnZDHUdh8HJ2baILRpP0DSewDQeQkaa0bM0k2HvwxDK4apHITIfl8dTPqnrrJI67C0L7Zpoq+sEzRAKoPvs7BKDxRAKmiEUwPbZ2GwEix8UND8osI/KxmYjWDYqQZNaQg03bonjrFSgPapQtc8L6GRSCxCUHq6MFlppUPmiKijnx0fwDxU7ew8Xp3SMWjQvMbQX9kQBpbF2efqaQxO6acvGkSNYJJqgSTSh9siwWSiqXcrgtSu7JQpwAwbJ6JRE8kfoY4m6FYfEsGyy9IImqgQmquy25pYZTNDMksDMku3osDZumlgSwMnYWRwEywwmaBpHACWDIos5l3HWyBpv0/5ZhCfkF3R2H2g94UG37AQcxYcrzTlomkhoXFne0Jz5NAsiPYgEFFSZcvemPAb3ghZkoBOongsIAVVFPdhwQ/iTip3+wTP7szsEQ4gpQlQpk1YsoxmouscS4Cl1DSfS64YePygHpIfFTfTeEKcFHr3efugZ9FS61pnn1lBC2xiHTLrA3+bQ5LDl/gcD3oD2NMqnJJ/vjyaHic0CPEZ4kE9ZDRIJPQh9/TDPwzIP6zzkCu2loKmJMgGkj+1KY7n7BM0TCY3zxbLBVApPBGYiqLiLEwJlBSybggnPGUCFDJhn2TvWPafVlUXuSPXiQGisjd9RP4xYopHa5GBi7TRBJDBBxCXbwyFYjkJBczMCky9S2ezsLEuhoIkToXEk3TCK+fSSWuGB1TW1IpBDfHQ0FnlPAp4zISlHvrekfqTGCqF/Fng3kkBNyggt344TK5hqPkVgPsVuxbfoFEHTKUJjX41jA+606BRB0ykCqBHb9dpiUwTNpojHrb9wtDx/ouYzxIP3fMVOrfn0mDm5I1ED4yfynEqMs+h7Md8ZbAJX9WyfyYiWgP6XP6AvFD0rOPBRQsmtOh0zoqY8xMPfAWz59D8LwDZqtkU8wh3ANg62xX8DbNVzDPo5xjtzAD6NH0CLoOtuQsy073sm2mtX9iYBMAhUSCCAALdqHdxIZT+PK1Gk5md91AF4JFJBh9R1DIdj69kAxB4FQHQ1pSLlOiaGxduJ88koSZhaMyGxRM5DPQfUgF03zQ/HELeI3moe5yF0ifP+mIfdr4cO6jho/QChHAduHEDr+fLQo37o6Q7V3E//A1DNUVNn4nG3QPDZ/wpUc9T0m8j0m7QhAvHpC6wZI/CRNEIxl0iqx7PuM0DFSPmAPxxzk3PRGHLvrVObPAsgeQKgI+NpGTVB+77Yu+wED4XEDqThqXbGVdPI09K3hxZY8kwkQzFYWbQItjgJCGlCNbuaBeB8IHeiC/gjCJSZ82SyW/IJ/Ej6xT7ViXWumT+9POCiH3C9gY3z2f+Gjb8fNh41bSt2f62NPUI/jeBDkBRUoaZYFIO6Q+vGTkBxIoySOQJr6UJbHYVP2D6xViHVebqmue+mJ5CNAkKHPmNB6Uj2IHhuRRaZTLT0AgM4qM0WTG/qIngCA7MqVALB+6IKFTXhLDLhLG1oXnyaexCoKs9gF5Zgx6Urf4576EGQMwR6EB3QSVPAUhiNmsQWQUjbofn72f9G8/+j0fxR8wQj8wR3aH4+/d9o/r8Hmj9qemVkemXa0Cv59JCMcDCFqnCzYqEK1+0ygKRHB4UiKRxmaL1phUuiYh2Fl0JKTPypP/CPWui1w4h35RP+AJ9SHdLSjoia4hmZ4rmjJfTT/1fSEqJmqkZmqqYNyZRPp+Hrx8JWrkOua1+QKJzzo8BAxdYFQH9EKOof8AYIXj7ImRppyjFg+WCAgEdMQ3kjYJ51jwJ4eEb2KMAogD8QxTrvWNeRwo478jikhLL/beL+Hz5lV00eJnH099hwpebLuqWpuJGpuGnDXR2nx2KV2Tmrrwa+jxhSpPO40SQ6KSiIIyA2QvdjroZax/uvaFiyWa9sjg8nBi+0F3WJ5VlaHb1Lrj7Go/8Bepdk3xo45e+fcu8SnTxWVI4Xha+o2cSR2cQUG+3nYJR5omYTR1dvavfRIhNHTSaOTCam1c++EaPMEzWZOLJT3071N3qrzKM5vZE5vfnYREVvVFmjJrRGZqwShte+iNGpipp7Gpl7mg+7zhi9UfGMmjca2ZiskWGFIfXcTwP7C8G7nNEAJSemnIFGQKGE+vgAhHvyjSoGsCtqQmUEc8+TaZYZlbuv2PEQl7wwQV2RYiwKY+jd2hmih2EVWrUxNDsr1NzByNxBCvX2E81iYwJzhNw7xJNytBKNMirXB2Yk43douucjDbREhkvntYUcNa8uMnGOlkX7vgqr4+ZH8kNhRXzvRswh5fLoJ8aXgxMlenSHDPf13OoFDApbsPEybbBY1Gy3yLZSOx8iPr34XKJHSrcTXRZCSWYny4d6DJAxe9wAj5zRLkVIjQOkjDDf75qzAWvc6VDCvkutVbNizKex4uc80VvzUU7k0Poo6XJllBrk/vIR5qNsm0epoxR4Yz67TYBhKhpVA+qjx9aDsISP8XplTUCLTEAj8yD7yq7/cjbc5SofO5MBROWYODG9w6cneH893GyMg2TVO70A0WR+U2SY5s22dtTMssguTsXE2UaLVxY1ryyGO0f6GKwIqZleEYQpciKvlL7pSxgdoagpVhFcJqp3R8qeLs/dwC5HTX+K7ABEe0L75Rldm6jpT5HpT9ltApxFf4qa/hRBZfK097EvYi3omv4UQ7tjc0WL/hQ1/SmCOEQNbwNrHy0zl6ipRhHEHYKhmpewFnNN9YlM9aECi/k8LC+XqPkxMYY70iuf/i8lvUbNj4kx3pFeo8WQiZohE2O6I73y6X920mvUtJoY8x3pNVouMFHzXiK7wOxIr3z6/yTpNWqSTWTLmR3plU//M5Jeo6b6RPa92ZFe+fQ/nvQaNZ0osrXONkxafKKo+UQxuTvSa7T4RFHziSKb0+xIr9HiE0XNJ4psTrMjvUaLTxQ1nyimeMcO5dP/VOzQqNlMkdlMNvUwWlymqLlMkS1sdtRDPv0PpB5GzZOKbI8Tw6a9yacXYDFXbNoKMT5KB79MVf3FMohph4yNmQhpAk8bkMKoOViRnXeo02Dfn5XGaA5WBKEqb4x3osXBipqDFdkjJwbbHS9aHjlR06Qie+RQD9O+iDXHNU0q5m6AYZtQRosmFTVNKoLytMHxRoskFTVJKubOzN3gcSyHnKj5S5EJSjTCLLxntBxyouYdReYd0Y6GaDKXG7HWeU0XimDmhNBM+epo+eNETeaJzNZxzua3xmwNVs3BiczBofpALI/pUomzODhRc3Bi6aN1Axgq1mjV/JjIdi1u1xXl06VPdIOGwHUgYIpRIiDvOOdAAkfsciCB98Mmh+VgOPLl/vRE6E4wGxO1fvofeH96joFvQqHagGcwdwa1Xv8AggNhrphzR2U7Agl6UjUHCPv6XXoyshkNgTvMhaYzXQA9eYjggxUqS3hUt/JxCcSakxLZiiYGmyvTT4tpkGeCIzUD0EKqaOKiFYTuCVV32UiIdNSp1KYBkVEzT2K5neUW8SRq4klkZknbPCRrkmumSCx9RbKtOKNFFYmaKhK748uGAMOnhWDqcy9VBbanXymnrguCBHTtwEmEPTFcDWM6xt4ygPUD08N4MGvucpM6iID8QdoiVj3EootETReJIH9Qzh0PEsjQ17DWO80XicwX2dFq+PQ/jlYTNRcl1rul1PJXiZobEpkb4nYZisUNiZobEmu6xcdWaynVFI4IPoYjBIE5ui0OR9Qcjsgcjhg3uRJOz1Qf/VUEjcpQSbdk/TyEHfWWE/qoxCwAdhLE3tQtDNYE36EnDdAAVcnlsMzDynWYyw/Rc52JJDFu5jobmNA9HJS1Y1ecCXuIn0StmGXXj6oGb/WXXb/s7yP8FqhlA9WCfuS5Muti4sr/5Y51YKnMQUu799dOcMUELqWL+QzbCYf4Ao/+DJ5fPyJOB/okjipYOLzcl44lTEWhiG/eVyey9IC3aBdglua0dkkkvK2l/cmod7Ta91AHB4JcLz0lTcqI7F4SN4bBfBrY49iRKy2RMWpHI3Dli4BaXQCIHEiAOqnAKhNch1rJ3HsHwANDnTDNveX8QGz3ANAyo4nahBQTvgnwksA9vQDgIP2pC6jO0K4EYawPK7qTwAiWCMBeP2RzxevT0BGXbUh2NPDYrB2GpnpEpnoQHsd+46zJy2YsPg0kVYO6kWd0HbHy+InSBpVrTbTUsbjhQU1NRoSEuNjFAPdDrHAPrGUhIAPEkHwY2C5C6TBwuRLkEw88AdNXuymM+MQUoCWPUc0ii5nGU4QtX+hlePxjDB/+PtfrERiOMGfsh3UeNjkEeK8fOj68PF29YrA/CQzxzKdrrRiaAxOZ5FKCXSyyvEOipqlEcE520H6LpRI1SyU2Xi7IMM78LVZapmkqsbt+EBvteHRVl2ssmkrUNJXYWNRyk5VZNJWoaSrp6JHO9gpOFk0laZpKYh6KD9beIR1G2pQ0ZySxTQa5x1lPNVlGGUlTNxI4CJX2SlRkutyIkdwkTVtI7FZBXH5KT4u+hjFSk0bhp4N3H8UOJsnymkgaMJ8AXKd6o5X9J8tqImmsezr6WLX3xEmw7rRQcAfoGLwVwpuxKtxEGj14BmaR7y2whgStClifG1WwYVtFNc+5KCTGJDrfgaWEMGQiYiEIBSIoLQgNO4OM6idUALBKOGDiiAjqGOvFS0Op5tKQNBQ9HfWu+JQsl4yk4dYJCORNxpwsk4ykMcuJQcm7ulGyTDKSxhenbpJh7yiT5ZGRNPY1OX9X80mWR0bSIM7kwl3NJ1keGUkDKJPrS4KdrCbLIyNp5GECyI60JWN59EfV17DmmcblJQDUCFJr7UOS5ZCRNKYtAZ9G7mr2AzGWhKQhbQn4NMoOE6Glm76GNVQ1pC0BnrYNXhaiLWlEW2JEGxnCmS/GQrQljWhLQKdtX4wFaEsa0JYATts+EAvPljSeLXlO66rdMEgWni1pPFtiwBr1uuyLWENVQ9OST7ehyFtjVYPJElBcu1DkraGqcV8JMKt9KPLWWNXQrMTYq10o8tZY1Zip5NttKPLWYNXAphT6YLWTsWSJaCeNYUrB3cazYI1WjSxKjCyiTp59J9Zw1diixNginzaLvgUuShpclEIfrpvsg7FH4IseTTBZSzlAigCebdxJEp6qzABWY+klSSmUNRIU28HhIVVIkM96lxqcrGsNIGkgU2KtaBhImbcr1HZiNaLyXQA2GJgFENdIuJ75w4DIA4yMVgCYq1QXYIwdVRdZnQs3yUJcrJJCaALrfvUcZM3ouKl+JQs0lTRoKgVOzNomyFqgqaRBU4k1o8lmyr4Taxpq0FRi0BTZTNkXaV14zve0jtSuaJ2DRnNvnZYuBEyIhgcWaqYHTQTF9EhgYgdpEgMvmzT+KkWe0aTHZt2PhcBKGoGVWOwZZlPW42Wx52FkINbrS+cYkhOl1j6AqjvJJoQjSGtYFBQcEd6NLnHS4K4UOVQ0uzmRLHBX0uCuFHlla5v8KVqhQsOxEqBVOSZzS50sNFbSaKzEesVp48KeorWwafBTYk3hHW85WeCnpMFPicFP25lpif4mjUdK8X5SWaq/SUOBUryfVJbsb9I4nZTuZ4KF00kap5OAudm+YQumkzRMJzFMZ/uGLZhO0jCdxDAd2g6abziFk0RR4dpU7hgOJgIDb06iELSvpN4R0Vyog1d6xKeTqQTRm0Z/BJVff1gSRUnjgBLjgNIGn5gsXeGkkTgJuJodOSVZUJykoTgJ0Jed5nOyVIWTRsskwFMIG21fw5oOGtGSGNECKqT52qzpoBEtCfAURwxo+yLWdNCQlsSQFqo+AnakrmEhWpJGtCRGtBD/2rwRPg0ZSqp9BADMiZRKlY0EFf3M0pCVRDSPLqLZIFwQoOwJgfKr+mzSuJiU+6TapD0WLiZpXEzKfVJtVhILGZM0MiblPuY3K4mFjEkaGZMYGUO0XfsiJ6k3nt4NPWKgR4+8KD5OnANaY6T4SAo4vB53wVA0kxNg/9cFVyNuEkv50oXtm7Nmk4bcJOBnHBGO7YtY00ljbhJjboiqbF+E0a4BogMZ6g7tgWmhwYBoO0CwiSfJYTID1xydqSibNHYnMXaHGIP2zVjTUmN3EmN3thGzHJ323qj7xSTzxOTpEtzUeaHfRJ2O1LnT1KcKR5d6WzRdustI66VIYgxQKd5Dso7yh67p4uB/AT6773puGfxxUmSBCO8jQRAKjojwfNJ+Qfmyoqc1ZWAO0G0jSLuFOzSFBiFKlEdaxV86IRqKMOj58J96J38K5QiIMzMkmD+M/AX6oWusU2IwE2Xa5psr1rKuAUmJEUfEkbIvYkUgjRpKgPbUtEkNCmNz0TYGlrouC7yDmgu/Wyr8MsvbQ+sF/cZjIPJJRZX1kkCAhY5KIy0rsHPRVeTt4DFo9jmTFDYE8UjB+xj/FoBcoCvolaZHqP1BcBYfOvaTcReEUdIQpgTIEV3E3KnjbOnYdEPM6CjLXKbdR+5ld+e6RhTI3SjAO+rP41dRPkC/nuolSt6IJ0XXGCAYSandxY0QNBiVc55EnKwoz8uM8eMbSEGZtZQgZ9KPGm+3K8RNaDgT8oEvUvEPaGKwuEqIXTux1Na1E7W6ErcuMVWgduirppoljelKDNraGUfwaTxCmmYQESAQBUWaSnwedDDqGITEfe8CXYTr4poHPVUMJKKedamuNlRRshc9rhqA72ZSAndmIc1DnRLg8nhSx75yOcS1BGYbrWus7JBrHuIYteTxdCCJ5hAo6SbA1/YQRncJ0pwEYHaJMw4+DHx4eX56FSy8Cm7AycmCpCUNSUushZw2wGI+fWE6Lkm8CNXP3J3lMBqRkCR3zyH3Gk0trnM4+8p21XhKGvWWGPVGHSerNZos1FvSqLfEqLe0QaXyadGq8HWg4dHSItk1z9ZIh18AQtwOI7U9tPzRDiuA/RyUHQP1c0AVz2QDEIuyUz8YDJON965XaAbG+WSHaJwtQ2wJBJtaWaaBUHi8UvPLPPCzUItzgNdjZocxQQhqECIiN5lE4q0STrSrNVHl+8DzogkC+AdFpASyC8kLsdILUZcK6xG1h1J4lrmHAqlUkukvoPrQUcXccBR3GJXl/EMFdgJOAx7LLf1jF+BVGZNEHvxBQmTMfNhFnfTT1BjBVHnpjZt9bHUj0S2VwTQBD2bKl0XoExNkrR18MGXMHKHDLBH+pHGGCcA+6tJYHPtk6VInjQVMQPa5zR7TwgImjQVMjAXcCCAnCwqYNBQwAdZHZRv7p1j7ZY0ETIwE3LBykwUETBoImDrS7zCJRMkSc04ag5cAcaOYbE82K/BoVFxiueZkS0qkaqXlGsGWgBmDhr91H83aLWuYWWIcWS6ba1hJpgZnJRb3LbaUarKwWUljsxLgRJSBmLAOS4U3aQRSApqIsGzmM7UASEkDkFLjBGSDNu2nS/cqmoufaCc44KVInkB4fbNuxe4QtM6RilbHQZNGWjdES0NC4XKbeio0XuejrVqcLJRT0iinxCinndlEslBOSaOcEqOc0gaQmiyUU9Iop9QYfB43VV0L5pQ0zCkDskRYY2MEZQvklDXIKR/uroGbLZhT1jCnDMjSpoGbLZBT1iCnfHDNJ9lpcLZQTlmjnDKLs+66wNmCOWUNc8qALJVqcyazhXLKGuWUDx6syS78ZwvmlDXMKTPMaddKzocxWLPGCuWOFdp0gbOFFcoaK5RZexEeNObPMQZr1mihzGghWgDMi1hooazRQtmx4V+s5qqaLbhQ1nChzHAhWkYswYxswYWyhgtl1wesvWXIFlwoa7hQBvRnw+7IFlgoa7BQBvKHurzGliBbWKGssULZ3eAvsgUVyhoqlIH7IcU5AznBJ4WXP3rzJK+A7s2DxwYBRiBYS0pfSQg7nCDQS+C5zBQcX7FH53/ZXX5oP5HQzY+6FZs1IikDXkQaOFbQtABJWQOSMtBFVDw3L2HNBo1Hyv5GkCNbaKSs0UjZ3whyZAuMlDUYKQNZ1Oz9ZLawSFljkTJjkQ5bF5fP8vuFJR8LVVSUdEiEtj6EOiQAJH1obVj4OQdKAaQAvHOSQAi7IIFdcM0fsoY7ZX/jYZstsFPWYKfMYCeS6jGnvAV2yhrslAFdShu8d7bQTlmjnTKjnUjnwb4Ra2XQaKfMQlTwLTEvYs0EDXfKDHfaKXPw6f+TyhxZY6tyV4XaKHPw6X9GZY6sAV45dO9vG+PJp//xyhxZY8hyuNXDyBaGLGsMWWYM2U7Kgk//U0lZZI1gy+EuAlnyWFmjynLoEWiTQ1n6WFlDvTJDvchjx76IFYI01CuHHoI26Q+fFrwRV4yOcLLBwqYSELcD7qYMN4qd2f3gYO1Ai4XDbr0fVSaHXm5QhzdAwqgREGEGr++vdpWoY9nTTkPM6aEuld2pyUerD6mGpoeaukuvO7KXdaqXMHU9N2uQWmaQGisgW8/QSiQ0siwzsgw2RNZFLGRZ1siyzMiyTZ0pW+JeWeO/MrBchNwysyKcHbJcEFmhg4AyOCl8R7SoiHmxwPFa69LlLiANhMk6eQ5Z5gxZg8kyg8nShp/Fp8HyINJjX6QQsOm/QRgrfsr6TTU/KkIwcLRLv3hWo2XiPWWlJXT2aAKisLRLdNCgtRzjjfJdtkBrWYPWcgetbRg6fDp2nrWheCNAQR9hMEOEGB8GW3tRuTmAggVokLrBBlohayxcBq4tBLte388SVGIMjyTDA4Vzz6xwEirnsUBWvI4W0+6QTH4jVo05a0BdZkAdXd++lbKIO8IDxXOfvOcsIC4TCjeA3FMoaRFkLncAHFib1AnJiYA0bWQs1C2zNikar5cZr5c2CH8+DTc58gB9OB6pnxMCabcXjN2DtNZjt+jgJgcatGj0hQFMIKo9Ta/jkbTrKK05ENc8PkN31NO1eiA8Hkme07OXHfU/uIcS0Ls9iGLZcK0WIN3NrhbUHaH7dJCFP8DLpU7t8VipG9Zc/4yda4jRnABOcIdDS/F4hA5moXuDCWKFHR6w8fRZfSAyxfFIHTVqrPDvA6gG1wDc7pHW7gJNeTqi7i055zhsNfjfusMd/bRz6ei/yDn0o+lCzrPkPA5xY41WJPio8N9SwZN/PkZX/1uolj86AraQPQv/Woesladd4hY5/wHGG12glnm+zvNNPuUtMv261OQeE0ur8yGyV4qepOwtn6Z5mOfh/Db0v/qhfFtm2wY+dPPQjy/OsIrqn8Z5iCdCrURCQ8mn4zW53AEyJIx6zG9zh1zXOf7xlymjV1NGp2KwmNPaWk01OjUDaborhVjY1KyxqRlA0x3zis9elTJm7OW40SBPBaAYjBdCHm7H8AoDJsSROinDzFyGFP5VKSNr2GtO/obUxWel0cd3lWpcAIJ8V+Tog4vARR5fj6afgyzlo0uXEKehs5mhs5lla6/FWkvhLmtkawZKlYo8dpzEaUaYxcGuIGYnbIDIQIaxKPgl7GIQB7ui9RYmxfDSwZAAcWV4CQBX0xqgTQ+eTR2gHu9cGerxbPgETXsyrpXDwFr1lx+nl/PEJBFbcJzPBkardOyZ7O+o5Q5YDnA1Bwvqg7BAkykMkAikMBCzC2xYYOoB36TYO99dLYHGJAyWegZ/PGaSV6jg8XcEyQF7ldCjKDlF++4MEngfRpZ4eRjCRKxWlW4YLH/6FxzuK0lpA7uUySWkn619CSgkOQJ4CeXfQFzCgBuGMJR8NZaGONwDws7jQbElILYgN6PfTNYldJgQs6jO1GrBKoitSu1mKB4Lfz/EkkAoGO8BEeqfej68vEud/gDGTHYX0Uu5anmXua/n8MuiJSlx+eF4JJpzoE8wBPkxB17rYV4FyJ7vzi1pDABCO8CBL0B8GBscGhHHI+lx0NunBz3efsJRBRSCxwFpZdSRB1Bu0PFsAePAIT3nRZByXX79hJIIeLqkqxDZDCj3tw+PP7z90nx/++gDYjVxtJFy9CsJzkbYzQNxjeAglMr4B9rzHo/Fs7HdARUxCkwHAhAnBZShtsCWUHj7fNJ5ThCCY9kZWoE9R0//mKD05dhtpo3hQU/fZa4jAFLla0+2HOmZ07OjQyQqNBrIk4ilMrxL+AY8vRT6nXp+VnQB7/O4G+85hyL0n8ev6p/indJBnZ/BLCdV9jQbX1DwyBqUN8K4AzJcpBEOFGIah9RupaX4UiHTAPsMtHwlgL0ZdqDeJdYvDKDr2wl65kjZHYp7w8elM+5hdFM6us6zo2Pzi/MLh2ByJKT36YFM5+kMpGrkYha/gsghy0c2l8z8tP14LrANdddpqfNsgPorsXHN31qlcRG72VhFqY/dmgOmD2HehtsYjdleyqTxDgQh5Wz8G8mApWMUx49thJnCVGm5I+gKrAHgGAeQFsTzSKkZymOFHjTiNhIIBC56ZnjZKCf6Sy1cMxdyYtHewxZQ6KdhaZO7WXViwCf7AQIm9VgorwUXzpX+IJCIMpDJx76KJKh/wfQpQvwrsKYULKeKr93khn5BZxzQKnqwS1eQVwwXl8BmQjToevUyXBMendKBLlHjZjjnYxj30aafpxNFL2z7+rJKTqu8h+qr6cHZEZDcFaozkroisuZaemSlOMHrKhUVaJDQVepQyjlaj6ctD3vvLPZaTVZTkqzhG0h1rKHYHiN5Pnx/mtQVYRc0bLMDFo0yllVCzRQEZeLQIsYTDJMUDikAp4cKnzJHNh2cf5NxAdZVBymL3IG5fYVtCLEsAQS1NmqlkkojgLkpjlWX/jHSQjZwHQswsjC2k3RzAYZFK7C1zvchTas/HfKGiXC1nhGuqOvyFS4yYlmTZDKoKpUE5cxh4DiZoifW9805jqU095feDlk6++ttZCSGR0mQT369lcNxd6/j15tlB01x1fOOUJyw6Anxm6axHnqcCbJe9qhB+kf8oiEGw0ZqVP3HDtSPQEI+evzKKT3NjJiuqb9K+gccSSiN4TcYWn+BqK/xW6O/Q4ucxkvP60m+RKIPBWdXOS3AcMeGFYZO/AKxbHFUrGW8Stq391cZw3iTFLj5RV49ubOmGGXQhSrcL60X6UfIJrHC6WgsrsU9UtELwc1RftWdmeNIVmA4zTGLmiJ4hCXEHrMJG8KPuvYaBb3gB5AdoElWWRky+/6kYcXLmH8K5I5NUR21WfhZFvhHeq6Mu/EsabDiWfK1+hNssGFFnaBc/Iqz5lJl1hjOu+gXhn1jy33Y+y79RjshRvYTEpzZK7LikWcgj/soMmvejTTxoFGFcU/+QzzaYV/aeJD1p0wrRYf2l7EygrmMpxyy70851uEaCNxW6oI+FYGGmGoVF6G8kp83dezwkNlZrT/kSPXaApws/SNXSu62jv15w2qxky+SBJ98Ia5kzTXL4I0RjLs96h0oTuEJI0BgD9ERyYdf/BY7OwErKhwT+4OOI7VwmET82SBHkCJhf5bFLVaM01qxck5Qx+JK8kSOG5SIa5GHKg7xjiFFxA8MN0oRyfVNYRo+mEhnx6e1DX6Lb8c8dHx4eXZ6k5sZv7IboCv9gad16QXNGoZLJTDGkoDw4wKBiad1zT0TE7vKIJO6jo0/YUq4sUNF1z69k1tYDZyTEWCRzAe5QczjjfzHx0zOc/ryOtDTs8tz0BtEMPOoCGr2q/hsdxsUk0G2v8XyydEzjzw0iVqjG/vvHIYJM0QI8fupyBFBtaAGFn17wAYRG71HUkXmFw2hXSD7j/Gj61jLHSoAIxHNRRLRi3FR1uzDDCYhaubtMWocVhbYUIg9KDWO31iyeivXj7fr44CVHC70t0rVTiYnUOpGPyGgyMaTATQp+KI8Epe8/4QaLwm0JjxmkA7BNDIH7ajO8+YHhfbKySXVnLFDJXGXUDpzj+kIR88ySZYQa3WhIhtvFnkPT1OUN/Eh9JwE+v6d8ZN7VgJKFKIzJae8iY9lyDNKUoLEGpkmlWkDdwTq2MKnvoX3ecSYKJk8sa0C7oxq6pyGEuOJTNUOqaYdyGp5g09JSd/M02aLExsgFyKS2dLz1ZRkxsVe8cF4bCj/w2USX1aIjOX7DfCev9bWN/10ub69j3jFrOCJT3l7T3ua3IcW7+75iHf0iIR9S+/byE0TOxWjEECllb6ld35s6QtyG97SU17IW/r+aRGxTGzq+6etB7S+qefr8qY+VtnUI+eVx9039f2w8HO5jFi95cu85XO2AASf5vg6UN7IRLpoXu0RhkryPLLoJ3ffZpJgZXlVjDZE3zK2ONR64F2/63VCeg4PqB89ZteGqTMZ/4IOHTr4wqFwVbv2DU9Y6LyiID46gdgmw+AzspEq5dk89Kn9ERwvWdesUu8SQUb1tKcwn08/TXdaCwdgArRg3xdif2JINvCNro6NQh2Pjmhe/OjgA+v5s9gfGIlqdBFm3/pWAc7A3M+hbQEoiqnE/sRqh6lQMoaKGWVb5SFzLgQf8dqnLyRcOR1ARhdo78Z77YjW6oGteBpPMRc3nmIByYdpx3yYugSih/jwOCzzsM7DJocsMlsv70FzgHPpznu2jyqfZhBWGk1ZYl7Rf0VAmCqbuCdgmDIatB0/0MtSlRWuEfCxluA5e2hce5pUFA0juFuE26RGt0ff8+DDy0/QGxRmIO/AFMWP4grtueBGTK09LqSFsczlB9xlAvgNz47uF3kb+b1wbQ3OiTwsvJgK15F7l5PxPY8jmm8gilaYs6MC5dGO9cidItfs6M168NYaTSjeg6Tj0tPRvOnMjgqE0DOU+sbZ3U/nWXV0Uh76L/MZYJ/K26T+DLwbtjzv+OX0NpkvnbDejl9u/l42QqNHKr+c8lyAmfwFdqI51Jl9Ho5jgy/A6Xh02CBbIVNqeSB59sInz7lzLQ+28+4q27wC02wlXARtLksvl9L0rwBJt+xBHoyPhYYNbR16IzHii0Oowx2ZUqeDyYrH9afpZL3c2TPyWY6E1Fo+StdN5TdF7F7PuSZNW25cxNzXBAoWlHAE1pmHTDYI3JCXJ11tygH6DOUXBLdQYkcGgBvwssLjheSeNWU4s4kF9fHsGGMB4DRlOLOLxc6GMHfKsGOUg/a9Ro30aCxQ54ZLe5eBnwbYtNkIj7Tew5eQG4h4nV15oPrQDawdFO2Q5noXohxC8h6HbGcIuXMf6mhXZuA8r+oMWbOP8z37OFvs46zZx7mwZ2ygLcolMlodes37zaCuetKHtnhgfLrAXAWSBIS4qY+Oi1GEEKJkD/8cn+qv08TYzMRYrEdWg9lyz8ia1ZrZPYMWE/siOA0HR0IPQyeG+iLdrEAMZmEFLKxb+MqK/B+4+IxJ45Uq91a1fEjD4/CX96zps5npsyRQYt+s1ZfXBNrMBNq8YYzy6cAl6PSAmiaVtLga4npURHJycIc39a0+arEoYtPmhLUFaPNdR/+uSDxtHEUjcE20mQqXhJVoC0uaik0XbQo9Eia6Hq+WiCzYGlA9kJOtELrMCsHEGnA5BWZwSE9Z8rnmmamilpQag1to8tNLQZ+hH6Z5iFp5rWwWTHUI2rmEOv+gSbsSZG/+W5C9+Q+in4fYyQM5Evu3Xd6eDvPsYpI33iF8Gu+KqgvJ90oovysoY+BJto6A5zYfP2eCTAcE94AONWIWPTFuG6B8wn2+0DoamFQXGteiPfcKCEblBW0S2TYmA6qHHUJmqW0aMBnKKP2w8eHl5+v1gVnXecdFrKMWU0PfChCyrf9SWpcTvoiSccAYSKeXSsIB4PQKUEgL/YeyCiP9osCbBYrKHW1Pfe/4SAXKDGypu7Y5NNs7s+1L3niPZIvunTXdO7PlSt5x2yy+d9Z878w2J9nbNszZInxnTfjObFlCQhuV6u7qGkwHp8FCZRVIb9QHBt+S6zeBBVCWrgIuhtoGa54RweehPKbLnkQzxjPo3zT/4/EYLvfglntgxA+Ge+DXDMGViLySTVMI2TXv4vLdeukAbRxO4SZ5AqcLsqYFN8uGVDU8ZCjBVrhlQMMNvTjSj0LRma/7GC57Gk1Wz627KmfKWvQ9hKVG65CTtshaG4TPFYmaVoY+DGSSqETpWOqiwee+N3m54ig7FTSesDOmLBcICLbN4VnGCCHGOcK16JE0/PFrKden3S3YHVRZz753jx0HHvqwombNldM2pGw8e4Cg3Zw9+xtlOsx8eHlcevljZxCqZJkTqHVYGiOyOEl2gOtg5RlOBdN5gGvPFdcDNQGezxwxI8MDhwMNgySQ+vA2BrIqCJ4o2XLwrAx7AZQw5lEtI7sD14MWRkriqHVJk7SIQGaVAHoV9g+2iCma4p+Z4p93/DqL4p81xT8zxT/vuHEWxT9rin9min/eceOa4ELOkZ9eDm/rCnQPyO60YnNVqPNOb4EpR6eAnxHbqdCYYR9H3QxS+hpHWY7gipivAUuHXhYXyHmTtOI0MgtC0zHEocxeKWpEgD15Tr4I8Mx0TQJTYSdO+QQWeWpSzuZqh3wIoQ/GpDxlnV86pGsLlIDk1H8RhI/nJKAObZsILS2qmPbBi8DA6z1uhPKD3mXNUzc7F1aooL8tfh6GeRjnYeLDy6NVC1JhZ5lc7JnNp0McxCbsxkGZwArE+E9EpgNR2nWaBX5u5FIV/dyO9Y1sAoasEeaXx0UYqWhFh8KKDlQDt+/Q/RXZCyqy9Oj+rtlL0UIShf1yCLNg37tBFSxaSqKwlETeeHDwaSakZXS4UCch1Atm2pH6z8iPrnSQaEJh2eVK0kbX/lDRQhSFhSjyhr7Bp81bgBo34V3+8C1EfQuJ069ipl/F0rEoWseisI5F3nhEFEvHomgdi8I6FnnjZlAsHYuidSwK61jkjREBn2aooO9bEmrycLgAywWmYm00y6CXhaWUm9jXAiILfZEcY+MKVBsleNpJO1bCcyBI9c8QTo5hGJTL6KJSHoYKa8WaWlOve7oMD7lC9c0Mt4FrdbFoLY7CWhy57qYI93mQqND89Rg6FV/cHipwGC63gw8v36bDHYt25LYZyJZoR9GiHYVFO/JGN7xYoh1Fi3YUFu3IbTOKLNGOokU7Cot25LYZRZZoR9GiHYU9fvJGhp9Po8rtW+DaRsgdSUgZITfXqP3OnJo8YAwu+F7cgDAiVwwoQqN5Aj1Hx4UHFnYMKHoyOlUqGsV38Fkrg9yQBj6f6hO9jFFHGSM61+sYVPFhWctO/cAEwGKNW8HenHUt8WlGhx1Th+sY/TDNwzoPkaOD0IMyBX+KMkU/RD6A2BvnxVgej8qbmQn3IGCgaH9lXBetjVJcj4F2M6lY6ihFq6MU16vFbXMRKwZqfZTiOAZufA34tFBOxReEpbWAu5lGJlL84xI6OskVqsrCb6/5oaDmr2USQmAljsvE1yopBZonrhx2Ll4snZSidVIKVE9c2fgfFEsppWillOK7QvpmDfKHtQNuqAWQPgOSKZ+7PoOjpIXc51A+YtXtq6550VorxbPP+rGJPt4NlFfqxcsI2tZoADB+jhAE3bSSzhSmkfQViehw3o2+QAfSUeOEsRpUqQQFpVJV0nMMiV0RmjamTJSho9YR2CDWY4PdBpQTFEhK1xyjInNntcLik9MRGmtogAJPUg5u8uKw9C6OKyzVzYrHTT7FhrQfuv69wEf0P8Wi2c/HeZjmYebDy+vQi4FnW/kN7atYyjVFK9cUVq4pm9I+n5Y5eZEvYgE8ejxCCF4Y4pMG3LhDUx5qHcIlCyF4OyG1fk1h/Zqy6SEUS8GmaAWb4jkgktiwFctwGgSOVoZMC1PJ4FLIsrWcIpPOx0N9PCglopUDh5fv17EUSjau+E1UsdRvila/KVCyIVBMrJIJL9ew0kktflMgZAPElSGeVCztm6K1bwpr39DGjXCVl/uwgptWoymsRkM7REtJslhOX0ULwRQorpAmgdGXK5bPV9EaLYU1WigVtVirxdJoKVqjpbBGS/G2GFCxfL6KVkkprJJSvO2gXiydlKJ1Ukq316omybVYMilFy6SU0MfqJs2zZFKKlkkprIMC2lohYJ++iDVYtZRJYUcsVOvMN2yNVi02UiAcQphpc8RbWiNFa40U1hqhgpVVjC6W1kjRWiMFwiG017Z8ZIqlNVK01kiJfbhu4r/lNVW0PEhheRAUVqzHanlNFS3bUWIfrtV+rpZuR9G6HQViGTvsb7G8porW1ygx99htWFMWy2mqaGGMEvtg3SwxltNU0coVJfbEcTN/Laeporn8Bbx8gkAmgrxdbsQarJrKX9hoqmyE9YtF5i+azF8Se6oFk6NeLKOpohn3Bcx3Yp1bMp7F8pkqmixfmCxP3iL2jxk1LapnVChtQFK+qwkUVAtgQo+ebuhaRczgJzhOgZQAJPdYdk1IP9nKTjQPv6R4u35YDlNF891LSrfrh2UxVTTRuqR8u35YHlNFU2BL4pmwwUD001QVmuJRpMvOz5ykavgoQCuBqqEM1KCn34BEDEVw9GV4TSLXLmRVYQgaFk1dLexhVTbGB3x61HnhvFDzqD7PZBUEHfS9WskPMYz77RhcssHAnZNGSQUCjq0ccMOBKpIenQ5UfEvgpsXl1vX0ZuesEjZFdJw2pDG4SA1scc5dligUfD01qgpQboRngoIUZA74Roki6WCbY0hkFM0YLd2TK2w2BpklAjtABB2NCt0oet2olJC6GXZyVOVBs7MeA0ydgE48mO3TYZn07w80Hwm4xJ2PMPqZ1JtgIYrIDezK3GYWHQuPDqUk/E6I15AcD21M8EJg80j1zwLgXz+EqxINtuhNnnTR7MnCHmKEobEfiRUINXOvsDkYes5WMmSZgxVNaCtsDkYj08yYLXOwoglbhc3BDtrNhEetVVYsb7CiiUuFvcHKRje9ZCtaadZPYQ+vskHaFMvDq2gWTQE9hVCC5o+xVm1NaCls0dVsqemSrUVbUwwKG28Ri9L+LdaqrXH4hY23aNNpXqRYq7YGkRcGkSdbK7JYPlJFo7gLo7gP0pc1nqplI1U0HLqwUVTZyM2XYo1VDScuAOGyKOVj1IldsYaqhu2W0ofqJrEr1lDVqNnCRjtlg4oqFmq2aNRsYaOdshFGL8UarBqJWhiJWjZy5MVCohaNRC3sg1M2cuTFAqMWDUYtlUfrBm3VTzMsjoBp0pxmdJBDORewc9K39UNKkK2D0J+FODJUXaSilJC+UfUocZ2O0D0lJcvnuWg8a2E8a9kgU4qFZy0az1oYsEoOQfZFrMmhcaaFcabEUrEvYk0OjTMtjDMtG9xJsZxaioY7FoY7lg3upFhWLUWDBguDBqlJYuGSi+XVUjR6rzB6r2xwJ8VC7xWN3itA4pGwnrlnscB7RYP3SuXZsRGa5dOoQVOQRX5TJ26EgV6xExkIcc6sKBqkHfvFXOzB00XhGOQUz2aGA25bwdRlOGwEgI7hIewp6CDKkjoipLemiDOA9KoRoxegKhw15qQ+ZLYjpIq2A0+3tTYK1sCYlCywkJLTPMzzsJhgkaLRiwVIQqIpWDTRIuhFwv6Ib7fkvd4NAy1iT3U5zsji0aSfcnSUSCRNCxbPZe2xdFw2DxrSWBqHgVztlqyAGkGqbdDiGp1LIuVA9JMgrYCaQ2PQM+qcMm1C/OC0K7nh6HI/OqI0jijFRvXz6fUREVIODgIRmlwB+ngUBhhkSekxaQSwUF4GEBSSeQUwGmMToBGPpXF4KpuVqkl3gLZU7I52sFCI5waql32RH/L3ObGs9AP9UBYOhm0YqCyulDAPo70L1EjDwkjDspF85dPDuY7AhrwxnU7UU+CY4T6wEpwVAO5PwHe6YKfF/7qUbkFdSt3cqA6y3Udoh8rBaRCGjrI46U656BwHLIrhMoTTrGH47PZ75g4bC9ViRwq4zLVTrKGHhaGHZQfY4dOYjgx8i48HKHd0TySTScCUAFYWp4z8nMhmlSDJjnQGqOV4uQ+9ADB6seywMjgN9i90hYgdTSbDUF+jfXroakVQgKWmGyjXBJpnuH7znVwXuhpcCMyxOIC9ZUo52cqR68zRoyS+huWOiJyOYXQAEcscV4BhoMVKxQT8wyRiR6FDE+iW3QDaZAbaHBD5oHIJX44anESZZJXZgyX2Kn99Q5O7nwWaAZDhlsCG9yi1sBhdwK0QfJa53gf7MRO3nLkHndOeGbQ4BJXonwVQr8FzZ0FD+hfgkVBzObpxAzFCF48AbqB7w8WK6fh+yNeRwiAz3klhJrEQGx01ULapshb5dRFHBtgNePiWLgjC5PdWAsYWccOBn6Q34B+KG7ohBdiMQJUqPzjAzJGPtKlMrJ/HMFSSCQaCj++94tfSnTDXnR4NAZ74IhjH/ESdYww39Wj94fsVwb7m5+HoJx7dc9DLH5BE7QGpUEjU9j/I7FSKksyBJ9r6DOl/UFgeCKgHlEz6YZ6HZf5tnZ9CH5JglwVf3A/dPJTGPGHt5NM4D1PXGHQUGuXTMg/rPJRvq8cxD9089PMwzMM4D9M8zPOwzMM6D+e3ufltbn6bm9/mhlKiq65/2yXu6JyRAc914+DNp8cyIp4SrO/Pr0/WE/CoCafDBqCk6ssGoND3XSqd1eWx3EFgzVxFdF7KyOa669lbtmlFY3grY3jrppVeLeO0qmG2lWG2ddPK5tOlyzcCmZG68kdzHRV2dH4bCbekHnshZYT3DklLCO0dUJwGUCMN1TlQLVgIkmAkLObCUZCAYeWBVbwgCw5MF389iGsIfqFbMAegUIj8zIGOKqkRgDNSWxriHYQOZ+0mUolx/XoFEGzCXReEjUgF3Rr63zUWZ6DZDrQjS5U47G3x95zEN9iXhjr+NgEqxp/G1L8JEh5EsfEDkoKLFTwf/LMKxF2FGgqrI/JhYDinq4DH9Q/TPMzzsMxr1f77XfVN/oDl/pJOJqpGMVdGMddNs6ZaKOaqUcyVUcx104KoliFe1Tjkyjhksr+2L2Jsj6tGEleggl3dNBr49KzWA6jWSCAGTSjav1SYILC5TWaZhcDkkg5ur2gcQIOOUjq2+qD6h5G7VQ1SrgcD9IJNNKsWSLlqkHIF4NjvbLirBVKuGqRcAdb1iTYfRm26WmZ7VQN8KwN8a7QLCdUy26sat1sZt0sAOPMiFm63atxuBQaXKBiWTV61YLtVw3YrILg+7YaxBdutGrZbGbZLkh32r7HmgobtVqBDE2SDr0VhPit0COwwh0+Cg5CiAwwclDDpoUUY0RGhJ6GrR+uYO8KB7R4JK4AJAsUC2NrQeZ/Q4bt0yapGr9aOXqV5Z41rC71aNXq1dvQqOZubF7Emh0avVqBDyQRoc5GTv99S7xSIHEPoKXcgTA4KmhFMESK6k3c7W/xNOnu3BAL+vvMVgTnjwoLevVcNX60MX62bFl614KtVw1crw1dZ/My6iDUTNXy1+j4TN4HFsvqrGn5aAYAkawMLZFItr7+qMZOVMZPbuILTlSmFqdcxyOCCS9XQRgwsixJOpZU0yEoBqsxwbiK+NbvMuAqw7TWKazRm9eE2YuH0BEYPNxaei1Q0N0vqmIqUcCX2scliZ4ZDBzsW7Hm4jQ1MJqmYJQHRrPesQwpjMuumBVgtTGbVmMzKroLb4MaQTYffBnndnl4vKTdT8uog3TUqFA4jtwhKYiGjujxKTrX0o7pxX64auFk7cDPYrtnVAm5WDdysQGESbtRCj1ULuFk1cLOya2Hd9AerhdysGrlZfV9iN9HBgm5WDd2soU/sTXSwoJtVQzcre/jVdNjP1QJvVg3erAzehFqleRFrjdXgzcrgzZpskk61wJtVgzcrgzepUm1fxJoQGrxZ2eSubppq1UJvVo3erIzerMn2m6oWerNq9GZl9CbRru2LWCNWozcrW801G49ULfBm1eDNyk5xpANi34g1YDV6szJ6s24abtVCb1aN3qzsFEf2ThZoslrwzarhm5Xhm7ThNPNkC75ZNXyzxj5gN/PPgm9WDd+sgGKSaaeFe6gWerNq9GZl17W6aR9WC75ZNXyzAozp0GS5Ih6rhd+sGr9ZGb9ZN9T3auE3q8ZvVsZvkkgn1Fr0NazhquGbla2YSHXaXNIs/GbV+M0KLCYZHtpJp4XfrBq/WdluibroJGStJ5+F36wav1kBxvQbwHK18JtV4zcrAzRJrcf+MdZg1SDLym5G5DRpEB+qhbGsGmNZgZekGrT9Y6yxqiGWFXBJT548Fo6rWhDLqiGWNfXBuilNJGuwagxkBbKQSslmMErWYNVgxJra7RKcrMGqIYOVIYN146laszVYNciuZne7jlsgu6pBdpVBdnVjzFotkF3VILua75MBC2RXNciusio6aRxaC58FsqsaZFdzuk0oLJBd1SC7mu9zAQtkVzXIrub7XMCC2VUNs6vAzG2wXNVC2VWNsqv5PhWwUHZVo+xq6cPVRgFVC2VXNcquMsqubpYsC2VXNcquMsput+xZKLuqUXaVUXa7FcsC2VUNsquFE9e82VZYMLuqYXaVYXYkThnbY7kUzCyYXdUwu8owu7rRbqkWzK5qmF0tPbhuciMLZlc1zK4yzI7IjxZ8hU/7To/sVspQyx3G27BURpEn+m4fh+ouNuUEKWgpdfpBN/UNIIhXVH4uzj5VQ/gqQ/jqxvq4WhC+qiF8lTF61EXJsF1UF6nWVNCouloZcNpM3dtqgeqqBtVVBtXVjVtxtUB1VYPqKoPqatmsqhaormpQXWVQ3XYcW6C6qkF1taYbKeBqYeqqxtRVxtRRVdD+NdZc0Ji6ypi6ukHIVAtTVzWmrrIiXt3I/lQLVFc1qK7WPl43QaZO2oMbEFFS6uW62wErLg9aO/ARAXZw9RhdVojKdYAOSEBsR0588QTUlqsodxnlHg1cq41z72gr5PbTwxfMDzeM1Z+DJd96e5PKVR5EDAIWEOTiMVDJDuQkGAJPNzCgkAi0UaC2QiSRofXEjWbqGpF1DDp2/M89WQZDz41cwEWuDXLC/TDOw6nnFm09t6rhchV4tR3lmM+GoSoOmBppNuBxoGgLc3haoRY6lm/9MyZXk2EjpMmJj0F10yqG8vCZsF6bRtFVRtHVjUgOn8ajJEAHW7oS24p40BlQvwr0HOAfFaE7XbADVSPlKiPlNjxoPhuHQQ/+7JGK+/z17oHFBqgoW2CrDiE/PJIFdYaWAD+dq5d61aC42kFxG1Y1n8b7IloTW7jkOMWw+C1RW4IxDPRuWFYebkHUwh4aV64M5StWfIMhaAEVjPR2+j2Tzmx/o7FqEFjVULnKULm6EZiplphe1Yi22rjxE239p9oRbRDwAtCTckEYEnZVMAosjXVUywNxkIhlRQVoVMcbnFOoG0PIXnosmRALl7vSwbhxJynaiix8GvqE1P7plhIX6YVW+l9MU3YRSiF0MKrL4IPR+C4PhGTsOPnA7rzuklxoREwF9IQcojd3WrthPbVCYNvg54Rn22DfH+ART/iY1Av4ATErjnsC97IdmwitgTC1sbpq2hQUcHrc1wDudMU8t9wWO/mQoQAlZZCZbHiUuXSnHcJIyx1ebkstHO3ghSPZCwefHqREFvTxc97hvQLDNLuueHAJ8EPMNUjPZVbNaCf1RH+QrTD3WMlYQw4T/4G696YRPe1g6wmilZj3PjDRJG/Irrv8RI/Qbx3IGDReyE6NPaaLn45F/ScAI80/4aAoxPdNkOV+3/0wsXnF5b6dvm+uTyUb/8CnQ3+yl2fOS3WJ8+FPYmh3zSLjSF6tTnGwQZmf1tv11RD7EN3vy217fdtcEkt2e3ucBjEAOFY4DvCNQ9WMjVFcHyzkkckES9eGN10W4+IGN2U2M+yGdDAlZJ9NIBywtlATDpYP1AMtzHyNDfLehDin7j6/VWqRUns7AkvqPERswOzx+Pd8iKgEy7mMGVTb9W0G/ViwdrUNrqxZkJ2mITuti/8lu+vPp3mac5SlIitGdvcqIOQVBgTMxTKzSAk3ic4o1uXa0RAcL0i7bNrdspwmxxVozJZLwG0axtM6jCfZIAM+LRFWIhj713VhMwqn0vmkRQYgdb4b4jc7+jkdc9gm/LBt4IdNo4Qao4RyshdVPj2s/cAELn35OkofsdQ6Z/cHHx8gZAZLviH5y+HDwdLNdSvDTgQJuZsmByhWhC7+m+ECgnXe8YSk2Ju6W0jPkSHcCvezBEMrB4f7onV9m0Y0NUY0EVTL/sGDZH5kpj53R0PqzzbW1S/8Kx2pxx3sGNl/LpWOXGO32NrF7SABBaBdSaF38rP4uVASwErRMKDMrHXGyGxSvMoPiTWo2qAIHbBYYSYLE0H8I5m+8FOjs6TjR8Qguj2QgIBMhjMvcwgyqNzkMdhjcisIz429Si6PsOpH2HhC212ZZuG5msZzNcZzNW8XB5qF52oaz9VcX+DslGac7rK7rN8dEode8AvFTHXyuio/vNYjbilsZEJZo+uBtpZVDBhx1kHSOvQ4yXGWKl4N9C+Kxxxx6R1iW9s1f4/GO+HEEZcCKtwqy2B0tYMHfh1xmK6FOIwfdXlCeikF8myjpMQnE2ztHFt50u4Xz4k8q/sIDd3ND/0iRn9hxIKviTyACpVYYVt3laZGSmS9tHoMg09YVcj6RSEAti/EFgA8lcDqbJVHbAQWCKcckj3TCIgOJg2QOxWijORTVrG4kz9GZfdBcsyCGwzEmWm7QiJygbcr+DeXh6YXchfutD/49ESHwXUILx8TlMQIRLSQN17sO+A7Eoyl2imV410hxUF8WUfr8A4LGokNYqcXEmzTUMDm4p3QSJtYwL/nHQfzjiOUf6+3rFd0l+5kTfj03/2Wo3nLJtO4aeRhA4rQNW+XzPg0Lxk8CyjbR7kIpiccYuAMAvsz2L+HYfiFVK7zfzjqBLhIkcBvQLoNN7M8Aj8cMR9DZUY1LwEJgol04QT7LFqh2HoletZEpTpUZiKMH6yoMlYIpJmPVGmFgR5f0wWEGnya61iYobPRVxMYHcLjyjUUwvrhZjXRUMzGQqJtg1PupyHY8jDYdHD6I4EQ3iWDCdt1DctwZKTEL7jBkkSkRlblKKk38HtNIy4bIy7bBujbLMRl04jLxojLtsGr8+m+6ZY99txb82oUY1XFU9pvF/w+riRlJEpExXEt2ETLpnGcjXGcLdhFGT4Nlfnhn8loeCEy+q7fDluLgP3Kolgjzz67gZp3RKXBW9D3puGhjdVJ20ZPh0/fCFhOzXxRmQ0QJye+4BQjSOwyQfKWdPMQtWwQv0T5OSBc+HzJ9DUStTESlUzP7Ns1Oh1N40Ub40XbBuHbvNHpaBrA2RjA2TYIXz49y7ijNE8bothd7AZ7luIPyzrxAyNtIKLV9RruAXc31AxbPGTjARsYY/RpkGhjkGiLm9nFIFGkEA47ukiw2+EYWIYrihgLMgufPMM4zyBPBjb5bUiiAXUn1TMk1hGbl9atNmEcA75543QYVy4opVPECFA1h250A5ybcooIeSO6pGsod/h0/dF6BWHQKT16q9XNp9lukHi7XHN1qFV0yV+I62OsczWbimGOZmNBwdm1yIJVugHQNHS1AYe6E5Tks1ep2Ln4rmsuoPAxLystm0Nh9UWdBX5+5JARYF5+Wn2B2L1ik5sGyjYGyrYNUJZPjwqrWM3Jrfe9fFlv3bzhg9sYw+eECq8VdZEwy9/+4rHUNCS3MSS3bSC5fHp2lS/1VSkD8BpHG8LCpk/h1GfmaiBPwybTMLFh3mV/oDG/jTG/bSNfw6fHQxUjv2vkRYZCSm8+DZXuwG4atKsEW4o7erUOfDrN2Igum6PJamVgGlzcGFzcNhI3/TQG2SLtwJoGlFuh5k+mWZwjwHQJnQ2COfE65VFNQLZQwmUWaaByY6By2/iK9dP7G0KNGip2f+0N6UWEQc9to4bDpyX+Wx3ZgJ4rdapYqofSiwR2BJWX0NqiN8m3SasDPE0Z6EDNUDP6a1h1Y1h12yB3mwWrbhpW3RhW3TZ4WT49UkZJYDgOUO2PfwJpLaShoYE6U2fzeQxK9HFbHnIljnbKVvao4dqN4dptI3zTLLh203DtxnDttoEU8mnop4TMSTGMBeFoExu/JOJFJE6Au/lKgydTunhgNQ30bizT2zbIOz7NgwldxNYNgRzUXWgwTd1CVnvCTaHdRpFteQGI10QNpfoHiDaXm9PxlRHkbYP46qf3Uw9DNf4tsUCHUkajtw3iqllo9KbR6I3R6G0DlmoWGr1pNHpjNHrbgKWahUZvGo3eGI3eNlgkPs191VZ779tD5QOPbmQDBcvnMTz7oBvNQgeSwFzuRAcLViSmRMHMmmJ8/52ArEeS88hG4kCR7O5ERxwGxu90q/m0IAPYDskvuAAk1DWtaAB2rCO8PhzrePhtgQFNo+wbo+ypXmABA/g0mzEkrvDB/a90FjqXtqkjjVtkpw6Huhqn0UNf9Yi9Wkf1CS7XUR7C5TpqsnC5LvrSPdEqmo4oWFbfC3dQ1Uc5mzxyuXDnwyjcUb+eC3cwSs/YKmay6kJ/CtoYZZg/cFmvxOsD0iGUKQSt2BBkPo0SKK2Cx6j4AkpxBH5SsZVeAqW9IUqf3vXCJsm44EmBscf2GARVwsM6kLzXXvvvtU3n+sPyMO1Bm4Q0hBJ7E+T+sCIqmij3kM0RVzlDf1YHJa9seU3PFNqxDlIK8Ool0E6HGLFGVGHPC6mHJj70fPXLM9TrQOzmgbZKOp++bioni28BCcF/EfrBAhKCLGA3wkHvO8V4Qg61oSh1uVG9JjBNo23Agnx6ADyRB0Aw7RgKHjZCDV1DSmaRkrehU9v3wZ6tLy6OK03TPxqoHDuZeD7L3xWWbjbvuCBbPTE8Yso4e9e8z4Ijlljb9R0XhRgMgY7rWRA8rVtj6HvXrJOW2PBqoyzPpw0c1oK+yhBOC2mPw2qap9ISr2MbtGQ/jTkVuEaQuHnhKszGHfb0mMJh2ELm0tVI4AwKET9khIG9ptoDaLRYrHr5gFKTjI4nPXbM+sw6Ih6qUNQ75dSSp7UjbSJMYWpeVLZ8pdjGpt2tIgY66NlUyH1QD5ffGql7EGSbKrJ0hD0gomEbnyG5o75jZXPv+oDaLO64wuWbCEaVu4H0G1lBpUWZ/BeiftPkntbJPdnG4vDpsStknEhl1FkReWvaRqOl3rpjJiOXuFaSMNlZRQ67agbrUEnchyo0eeTaV3J80zyixmLtbYOIbRaRqGkiUWOx9rZBxPJp5KmBgHFHL2ryTO2CNuHRH0PVGwI3mL3IffFQSDUiwco4UE2HxhGs1Frg3RQ1tZEFoIZeUE294uua5i81lojf+dE1i7/UNH+ppb5SbvJXnMbP5v2frlzw7ydTRIFZTf3+NPyb4WfaQ2crHc/gGiC97pJja3pUY4n4VjdJKU53UffCBdwEACC+Nj8min/0tfmRhh19LR1dvlavK8yoahsxvWYxqppmVDVWWUd/2JpUfBrNFqo3clNgMTTFg6aGz6LbwVrwpMHmcrd/jqhfR0CqITNBnRos3Y8UbTw78vbDxIf61jWPq2VupOcNkiWvZmEcZmtcXMO4k449IgIufMWRnbi2mIXRuHFQJCXtAiR6lmsYSYZ61rnDVzgCPXPQJIGE2K8bIdFEiVMFFpvkAhqaWaRk0gDOgJMZeonQFvQH/LDRW4Nr9hA96S5erHqC7/H0I/qHEf5++NPEBoCJUJHDO8xn3BxswTJCej9scsgNHz50fHh5J3pxzN0xxvZp4dOyF7lik1dE8sElWLRLqhx+G6fcNM2ugTK3833hs7JTw5+x5G0aPZe5e5ubtlOp25adbJqq18C725jH8ElJWFFfpUkkoYxrROUhABdNRa6ZwcJsrpMSGAPH/IQlXd2g25vmAjbmArYNur1ZXMCmuYCtC+5v7Gn49H1qRv+2x8ZtbqbZgw1MwJ2hDZ+db9p4v/inj9794Tetl4Tcl4TN2m9xEJvmILbcA/xm7cfpua7cLiZ062XzEHlJ+P8evnv5/Ovz29fnj//v54/P//ndv/6P//Hddw//67vvX/g/Q3nAjX33r//rfz+M2/juX//Xd4k/CfnoBy3zQTxqP3B+HAQ+yOOAXJpw0JocND5wh4/jKMhnMY2j6vqRc1GO+jeSgKYcjX/rvXwmV/YxyNH8rMiR/Ns6PktH/500RMdRlM+Sl6MsR+PfElF8HI0rE8GxH9Xx2wiIzkftkCM/fi9V/PvRuGd/jKt4f6Rx1Prf+TB+rw/jTr28HR/HEyIujxyNs3n8DlonxtF4R7A07Ud+/NsyRgKElMfR+Ls2BgNakeNIzsYsR/JZOuQI//Z/P8gAxn/SiD5OYzXej1UZfiQuNwbbePjzVRNReryQ8YLb+KcEBJDXMB7M8hrkYUX5cXk+GB5E5g95xP+tPwfFBvsHOR/lIfJdmNf8z9PlaD3fXE7m3HjH1uXc6Vnn+2ftRzgIY3hLOEhj8KQxUlO7BIhjPOVDjvx8V3OCpjEp5vvLc1r68XdzWi6TsYyz8iZpARzvdExpf6TrdHN5vF3fxt8FGdjRjSmY6xz2Y2Q0uTLtnnYP+4G8iBKRj5dXSKv+5hVKEByTzrzqaYB50q7eXO5mWLlDX2U7ru6voi5z/JU389OX0zMitPxmaI4hQhpA/Y3KHI715qmdv8KT9NlfcasEI1hulMRY7atQt7kvbbJ4zWiTxriFOur2y+rpy2hDb39ZmUPZ7+896re1vfnlTqPfj+90fmskeLJ5ayMApBG1kzvGQRoH/SvTCGRprL4pjVO5x5ZWj3HgxoHE7zru3Y/Y770sZ2EkAT4EWWSDnA1zuR1no8TpKAtbnM9HIkv2shiPd0+iq9unp8LCsYvG7pBMZ2ZJc1C5JsFV1vs48htYQ+t1vA85OhqpG/Ze2xv9y+lOibbyNwWw86imot9uhZwJh9uPws/r5UhV3L5aGGE8ympE1sljbRmztS6rzMwmtpPKnxbU7fi/LHBzCWuHCg/m19BSopaR7eR911vwj+58vbaNh++7nj/HgrRbUCR1GMuuebV4ilRUj7Kv5oOXQe/2l0t6haGW1+b+/IhMeZvpeSqZrpE0bifvGkn3Dy+dgwEVcXeRfsSCme/5uUfII1Td5ID+3/7l5XT3VAH740uh/97F8n2s4ft8HprUYbUvJxuXZfNWZibFGwjru8Lp6cS0m+NVdptzhxLr9ieEQ62vu80HWQqOZz1Gb/BzPzZ+QnT7n3D8pFKc3fxNdawjR9qOwHB8Uc9kd7kqC1koY5mLbX/h0zym3tIunOo9gTvc3P9fV6w19T++08NgbgfMhF+2c7J1W7Zkuc2dj4pU56Rd9sjLXnW/Lw3+p5fzwrdfot8VIoNe+MJ+4ZN9s9smEPE8nuI24I4sKI1tTh6DmEQjx1BzcjSe0iGJ1AyyYW6O5R5lBPjYtslsdGqFIFGNbyU8I30xL+jPC9ixmwAjaZMNopSVxi8IY08YyhjUIz+PI9F0cs6FImmDjO4iY7/K+K2yBahRRnLcDo90fp/btCjVPF5j//Y88tcm7+mQVPWY4UTmSfDyxqTmlMo2xUrOq4nwt21hk97t7YLM3O2NkFFn3WzZ9o1fGNt2tmS1+SFxhD++6OVD3/r+Lc396XYpzuc5TACL3QLUp+da9pKq51ITlVKKUROtecZTGaMjJrjapDw2tmTEAZe4K+WVcCm5eKnPLsWzkGek9nIkVUqpQ64VRNmN6Aqi/fDOWVjbvtEkC/Sx382WpKsZZBPzx4eIKvcQg3Eb5Z28j3eVK5c1r3Hl3LqB0ypTtrldGGt0SP11LyULiWbrPnOMipylTCdvcRY0iqzB6z5zOy/b5amnv+apt+M4VWZ83q4HPvrthGwHMctPd/PXhIn2vffh+1D890fI39cUvy8lfV+Pet6rboPHbMTIIFiaITK11+aFUaTfLzXff/8fv/3829vL1+e3l88///L1XNOqM5OYPZ3RVzjdqHXtp/Vi+33RuEtJzvpgDGMBHutv///9/Bh6I2xJ8eOyqM8xLMmobPdHyHFestPxxzIHJMxJ/JQt+wjGbpkIMn/HpJXwL4mVrM1yajxbSaVmniVVqrm3G4ucrN3jE+mySOomlSBJ+SXFkWkf9PwPo7M1G4KXxEhyH8n/l6bhSJ723cM4Gn5xxJU4nnwM45MxvuMY1HHU/+JYSeJYXK6JWhwVwTiGrvT90iiL/cF65GhxSGFyvKabCuXM0sYF8/jnkn7PBG481VzGqZH55NFgKWMFLmORL+N9lbHtl31qGe9L1ndpQkqOKM3INtbsm6pqG+N5afLOVET29YdMw0Nau4ekE4ekE3ODuLSKZXoekmzMpvFS+mxzSZJt5khtl2VqFkZlMjond++lGSwz9NR6nnFXEqlDNrAzFku0mYu5VC2dXza/cnZJ2+R6Mx5J+PHB2i7P9E6+w2yDz387G+Kz9mJ03mSr7aXq6ZP8iyx3IKHPSzLp5dkHeRpBnkaQty8BxwW5chgT2txeSYxxQeJqkLcf5O2HeQfy9iUsOYlLbq6mcRRvnPR4XZT7i/LsY5IjSdGiRP1YvBzJWfkdUX5HlHuOTRYTGRFJxkGSVSgJUCCFufzIZ/J+k7zVJO8yydtK8o6S3F+SDa1EKJcmRELuNMmdZpkL2clnMrKXZLBc0kJX5N8ukAtj01zkfSzln5ndzOLQLUijyLiSQEim0XIkn429mZMUlexI5Ui2SRLhqkQuc3svb+uy0ceRfNsCIpmfyfXkvdU8NwZyV3OLMKK9vaErcpUiV5FxWuVpLOAVGbFVnlCVd0mq0v1IRk6t8m8n8GW2NmQLU5vcgbHNlOYTKXnKkdxBm9eTO5Bx1SR6NxlhTd7WAr1xxnZKViZzoytjvLl5lVlWlutNUM/s78qqJousm5uqBfwTLls7UnuQI2urLVeRKNBkDZDC5oQVEXVfji5oFyJ3j6MqV6lyFXmXbeaz0kE9/NxNz8+k3DQLT2M0+WOMkqWkOMtSbvxy76Sn76S04KQ/IGu3l3XaeynCz9R5luhnzuyz/NsiZwXXYfWJBfyydowll584kaU0ettFDpK2B6luhiJnBZUSqpyVu5pYlKXUOsbuaDp8sz8t64dPRTYMY+57WRe8RPm7jjY2hPJ3C+RMGlATfJbk7CzZjwhnF4dGrCNMuRzNIshEZc0ry53Kb8uy95GVyWdBCWQZk7nKlWXTtsDk5J2XETdWwNxsqsvZKs9PIqGXSOglEnqJhEsbXuKfr/PKs6Ayi2YyJpvcnzSSvcREqEiOI2l5jDjpJU6uVQMn/1Z+kcRJL1EPEizjyGi1yHiRWOclrp0aMbPkN9FwBpBQRtgKKZR70WgsABrk72SsNRlrTcZak7HWZIS1vK2cPH14+vzx9VTGiUs1/DsnC3YUJM436iV8xeeP537ecSwXdfKjezdPVYdlfV8KsTMazZalzI0ZZeJ8y/va6NOHV4IXn6GIW6jmuveTdz5nXtmXLp8+vP76rGAOW3TIu1oNTx++0H+ul/RlV9IrMh/cXC/qtkH49OHL17enH76+nMeDC9s+YZkL3L4///TDD88/n2+ZhHo3MDOjPre54tOHT+dnSzzHzX3KstCX0Jurfv7hdNXk9qAyqajl/UjDRdVQC1sEexkVL7ljE3Hu3Dy6Qn+9TIGZMJjoaAnTpX3jB5y6RCR7vSlFSxo76+yzCCFFrjuQIH3fl9PX5S3k2Et+n/22rcIXfH07XbP4LQ5g7Ovm9l7SgbEl3HzN608/P31+Ob9rahv9FQV3utrbx5fPP54H+Hamy/7L9wrg7qK/qOBx1N2cSXuoX7+QGtVuD5GSSX3sMUhPP/zl/OB2V5tbqrkvG3NA9hKyMZBvHkvFTbV6zKoFWicl4FGfHeNaaq95LFazsjkSkjmbxyq6FDT1RJcERKA+c9PlZFvvwjyaZcNZqpMi1iy8yXZpFqeifLYUhKTssJaGZrlI/sV9aUi4MLNItMASl8KHFEPmpn/iYyRez42YkwRg2UJlI+ZJfFs3TteIGCT1C3luVqTzfsyEQpoQZR7JJkTuVF6/L7L1qJK+VLmrWmfSO9NV+Wyml/s2+9MPf/mXz2q+wEpi15s6Zt5+E3L+8vn1t0/PH388rX91i6OMUvktezzpctVztCgLqo4GuCxPdSzQ7w6V8xv+9Pb00/OX5//45Vmt4rGu6adMVOkGzFKpzIIkW4hJbEmSPH0jB17uybydpSdMJTgddsaEkTHlZAO8zqZZGpPNqUt/+A5/UgllXZKz74pst71Ali0QsgSt5f6k2OejbKT6zHnHLZ2BKSVuR7fQ7wbd6xsX//rnt9dffvzzOSNbcIHfldkqbZI3Ck3qm8/1dOOkLra578uqJa2ZNIJFmhHbitPy/nu3b3dDj//2L5/OEFmXtpudXtTdXOvry0/Pr798VejjENfHl2ZQbu98aP/xy8vbs31tt8AKZxdy1OXece2313M+SboRm9xPcGfSSpQcf9ZclsrSXVT6+nLe+vmyPCMfDPTB0uJP82v7SBglM6lc5bH+N+lEzP5SlOm4dIGkUnPMEdeu1bmU9onfx6efvz6/nQb45lmO7nWTzKffpbQtmtSGpKPqpYfofZjVuP3mhO/n9HpJU3CzYMlPPPYo1KePH88rVdsDMSdwRRowdwvVx4/Pvz5//vrp5cvX58/np+jSOjqavJ/OG/z2KP942SyQyPnfssf++PFFVwKoNPvXbGb6pZ4+nbcefkuKG4lRGhMijQkxy/t1PnM3GxHx/B5Q+JbsbrRBfYyzELzfiX78+B+//fzxlzcqNDz98Jffnj98ef3hL89ff357/fr6w+v519SyLukzpvd07z1v8O28/0mnlCVdB8K3Hvrb8w+vvz6/nYdzXVCF38XZkqzx3bep9ufUMt2TeCWNXui8dzf95ZyK7MabBdfZonSWHZLZjr1rwi4v0Ljd12sZypNoyI7cc1OIpWup2sGxJWA52X6uuxfZi4Rv3LOe1yS2srnlXm3eXurMQt0HHR9vChMff6WymyonbEdVlrVwvGnZZ81t1lFktZs0EelPdaTDzc2cs7htlURGVh5VBNn9z0zNH/LF7mar9JFEL16+nJ8DCYJvpsDoCORxdekQXfFXE6/gpXDgXZifqZ7l7Q2q5C9v68+jfFCXZqYctfkybhb4X1++vL79fp4WW97wQH6Yl/qTylx82tNfBCl0AVYKlGHmUDOr6AdSHZCO8cg84hifcaxKMm4EPDfLR/FaR7rg4QT9tkiUSOlVFssgCAcLZbRghpYikJR0BBGTFuSMPA9BEOQw6wGTFmqgQSRLXXANi5CI1OekQHe460Ra+u2zCHRY5aDb7vmkBE32tOTJC1N69pVnF3Z2hKe4yLGfPT8+vZzh0lt6eZjLlSC3Lk9y+x1fFPx5O8zb5DbIiO3Ncvvi53aL2+vwvIfx9/SjqjvQdvGU8Th5SbNmJ/W5qQ9TrhvBBZr07n9t3+B5ecvblPX2Qn9+fjrF9LiVg5A+XR4DWDAL7hBtEMHz+WMWSmVz0/ff5p18enk6J1h+F0pndFjpOLKazN737JbfvOtPLz/qjvCxJV3N3ObYU4yfPqn9tctbbueC8ZW4sGA86r7g8emU5Ift63fjjcyi6KpyMHkosrBIaI5S2y9jPtpwum+oMxlgsUWnyQBtxeteaYZcE/gi8WJRYZCpfte0/vTp9Ycn3S/ye3ry6LaUWdeciiA3EYq/R+2Ek9vWut7Vwf306fU3PXa3IjUTiS+4e+HfC7h+LIWCTzc1Piw9D0u7I+/pP0+fXj+fJ0nd9sgX/MEit3H3Ut+enz6eMrNUt+FxDCxZ2mKSxFln87PENSLeNb9fmk9SLVhWy9K26+ZmbzBrYm4Kk71rv/Dpy6uqJW9lbcaUk8T4RAMQSLN00ReA7YSKLvI7ExI5U6BZSruqp0xY2e2L/e3p9/O6t9YuLwvt31c+Lkon6BJ4zJtVainbym6fcRem0wIIHSNBMC0XGpKQhca7WqoP1zLESPUl55cAMCARQqq9mQRzFzDG76THzF2Anh+1yr5A2JDCgbmTNbQWK6vpvPJNJntk6qLJkcXnSPJZmp9NdsbkVRhchoXBMNvPsyV95SjMXcuMFEnuWYhSK/dg6rZNHL+gmNfCkVVCkhxpjrGJyl8Q8zNC3WPnJ2L+fqZNHPptUjABQ/fpwYLVNhIFqdMfUkq9j6rL3kySh6UYMNvyE9M3y1lzTZSG3AxqUzRywRDryI1ZPPdw0r6fvZyJ7DNTn4nlNTC1E1yl06EdCEAwsCtSdVLI5cgUoZxHcpUFHTrxn/u84PMpn9klMyNx6SFlUFWFeDcCiwSMMW/ndJRZNg6ksbXlnBoFlbkhkD6yPBehf44u+uSBjpxjvGSraPzt6vHC8RQi5/gbYXSOn7OQNIV3OaL+mD3SxJQ4k8fAuHb38hireUwCQXUK8WjmFQbgSFaPQa4cE0JWD+nICR6iyHeNcSrKKhKtBAxuMTHHKUNKd+FMypGsEXOPu4rqzhVpfibgJyOB+tv4kfIvFpFeg9lo8hTl38raNCXSQrquawtzUGZGkJptlHtemH7C51l6vPecP6OSN6EDy9q5rJiyJh4zKTVWzHuO3qwbvputZ3D0hEeUJ9pzqWoLG26ikcwWz7uaPSfGnRwtgMPJrpur91zR71dvWZ8X5ptkCHPr/27mm8VZm8yyiY18Lz9tZg2ywViyhln2nryz93LMjHx/NmoXxth72WGXfGRq5Lo2UQ6T1zU5XDODmaq6BhpC6jlelq4FQ+9EHGTZbpkARMl5hD8yeQxLPVq6LxNq5mf5M8m3SW40OwsL60sYQGs1QVhaU1TP4nrJnS5Vh8nwkuwiSBlmzcmm+JJkXbKLW8S9TdbXhFnOyoYcyZsRXQOfxiixWV/yZr4lID6ZW+9lac164az7y1UmYmPpBUiRbJbLJGNbpMmXnFE+m2DShTf1t3CpJm/KyDwX+XOD8WTmoJOhNJlH+07x59evfz5338hhZAfTGViPcskeljXqb2KFz3T0suIscUPSsmUM3f7Kc59y6dx+55zsiXo01xhdKReO1q25OZd0YVKRrQKXhIKF4jlF6OZ25aYK9Pn3r3/WmKJ8/G2Yop9PKMSyHQKSsUuVcF3fm7H2lj2t5+eXMz7sfrOzKkRNHPdUfio3v+5nBcjeojd64rm5yrPanS2Atu9mShMlHMx90sRunmqv4w8vbeQ6GQZT0mGKQMzJdi2NmMqhC6ZelptFTXRi6ueiIKG77rUW+Zl8fH76+Onl87MWPl9UzQwV028jmnDxc1es7SF/Y2DKhk8yPCu+lKksMDPG42as4l5+/+nD66ePL2DiPZ3BD6REso6GybaQ9/L+3/3phboliu2XtoSlJmlFT8q/cdn/RxWR/VaT/w5zPK93JqHd+AToff8st8893awezshihdJZo7rjk9JNKvBW3FpbLNlb3gvJ9Wv+/Prp5YfzEIiLVPZ3olMxU4EkiWV6L0ga36VgPXV3/yu8Y25CZvp7FyE/nX9K3WvGurkMC/T6jjdI1/7h6Yc/P789f3k+AQvyiqT0spfKBqFhc+W315/fXp6+nltpfjsCp7rA3FssHHZ39yPeXn9VAylvxbNH2c+81NvpbrdsOl3cG/PjogA3lxEpuY1kUfYMswg39pqznDb2OLMLMhaVa9FrqWMNqk6WT6RqNWa3dEFG1jP3mau2lnQkFuVBOTKrNGPRM/WTZJlc6iH/kCqIvCHpgM7KyNKlmNUNqaTZfYj77oOh0lMuk/7Uh5iVh6mWc9+RMJRsvtGbMFRm7qsCkrZYu/117z6VWOZnc58ue/KlX3FVMEmCq1z6C2ZXwdIPmf2F2S2YahtTw0Iiy9zH7cW/n96eT0KWbQuKmNzQsldVf3r7qbN1FPR53fHIXuW92PK3118+a07x7jbzokN586vfnn4/LwU7zJooS4YR52YjYey/Vml4o4otGNHDzw3blag/o86iPyb1yUV1TPSLTKeoOW/nKJ+zKxjjTuoWS2XimNWKiflQsgAn5ZfbMfb29PsPr59++Uk15ef6NVswS5n2D/6Ud4yjNwVgqFv8wv0rXfBaC3M539633/+CpYH6jdfSyrse98uv56Qt7RX9JQR3drd5xTPofYuyln7muMk+TwwqhsCdhQ82ID3SUJLAPd0T1yXaaLPcCjTOhdRmeFxz10V8bQqeXUXGBmvsVCJaUOzL0USXzoLeTP8kXOvCmv1Ofnj+rMldWw1u1XXaXPDl7ANR9p5+bmpE3exUvrx8VBSYLbRvdPDt65wZ7W6L8xcN2iHKtrncufCzNaqTQTl7iOMV2xf+8vx2Xvj2mK/70TVGyOZrXn78fFG5yVt60d+gU4SvelZlp13MlJ3AUpeVLOiu/Pflyy8/nX/MseUL3cmPfPn98w9nymda8w43w6hBmpNQJMyLdWILbuROJ4m+/89vr59ffzkPsLjbqM0i0SxA1OM9hQX6KpI1f/r6qsgrYf3Jsnup4fKLzeuehm/IYV2h52Zi4hBlrb4jb4zUVpNyJGcdu8NxuYmWuEIiZC84N3wauLBIMVtOcoso7qxkTmiXbCJmE3lZExao020bcW4ilm3CtcW3iJbNdtkEo0u9/uJqZ7+9z6+/nakkfn2BE6UvO7CFfSMLmyyyTRhhOb2zYPT19aeX0xysdZdZC/5xbuCmv2PfIO2/4+nTuWTUtuFPSrJlwo7mm52NWqk9hxstiq9fn344S0QceauUJL2bPBOYffT4+vX5J6WHdiyOMd/VqZUvha8uC6++dwQ64wakObqM5Hm5erMU8O19/6enl08K9V7jScNPQGrVX8PszaU1ln6fzLyDBv430Lv5bs4p7+LERoWTcT3JnSUHTfEaaKVRbn7dL1//fE7h4koYb+/Vbfnl65+fP3+liriq1x5bKs/dOOkJzze+SvcIjrUJMbW9nexdnWxqppRTNcoAC0xOHvLEcS34LKk+zWqbEazJol6OJrB7IiSuKIeJNmg33L3To7DkVG7w8bcP+PXt5euTlgqhsuUu852gtJm01Lm32GfD/cvO9fe23zDehTC+1v+8Doy2agd5qar6IDGtT6E/9mWmMoxf5fBuX+57ZtWJw1H9lnQtiEYZ9V7qq7P6eo8Ks3FLko1OpPTEv8w64qRq3pTnfvn6+v2fPv3y5RxyQk2nkGOEsFtJCbnq9x9+V+HHh3Ymb1pv+X3Xfvn89fnt17NYCMG9T5c3tKjeefm319/0nZ+fyh/VH/vl6+sPb88fX85Lulu79stWwwB/7K56eYFu9Y39TrTbfcrX7OD2opfXR5nScuURlKc8cpqy2+Wdq/34svE+la5TWgwBvxNSk09/9AHp9+mSPz2kqdn37hX29aenr+fsNm3FgQV8UaZW1+JeLiX8G9bi+EKd6oa4l/+YxW9Zvfhg8oYmPWZChmfzKMwAZex5Jm3D71eUX59ePunyRC3LOBL3CB+bMWGl9nqVqyh3hVdbYV+Qf9MW9q54+uvry0ct1bqVV72d/789qakftox7GSNjZGwvSGKyn56vQi2nDHxwxcPc/k86eL088O2XTY2j06tc2+ZTHr0LIKhXKeiWciNbSd91fuZx7yYoDejefd1e8cvz26/Pb/rml+r15MAt8oPWzU8NDREqXX7u9dv/84wsc2U/gKQRNNor1gU//Pufv349gci837vK3mSWH/59aFT92798OI/xsFey2Y/xeb3vf31++/Ly+vly3W86htvX/Y+PH77/4dPL8+ev3//w+vlP+rK+bS87jVxa3b6iD//+9e3p85efX1W91u2Z/HePVQ2yPcJv8XwTqpCMLUHpT2ZB2Wti0ZeeJ03eIrREeWYyoadSy30hWvARdyVpupcfjS6t2yqBjMguwOyFxLMSTmRf/cdhvRaEN8xK3aTYX6HwE4d40+ueP/rL19e356fPH//0+vbb05vSdI8rLm9C9t9ZG6EvUU2eVnZTdVFydNvYSFd8/dOfzsix7cpU47VeFPcqYnTxn0kI7pe35ytCzcelrE8Y5KvmcseCvuu50LNWtY62rRnJ+7a+y7j829vLGZhetxABIQsuYrfpuBlUN2K3/YvPO5Fj30Xqr9y+1BfVIdo7zk5LewunYyBxhhn97osVVO0bbclRkwmjLj49xyYCa2Kscr2p9Vyro+9F2X14+nLO7r1L2wd/+Wb7il/P1WK/7inDDMZWGdewcZXawuTETnDniFhalGC2VyZwZRTXRKxeVOoEYTd+n6TXAmuYIKwpKbYgImSw2BaGk5J4pekvePK/R7xfgTSLHNmVLiiCIjZrYpp+zTbghIZNkVrhTyy0pNnpX9YUQXHs63IYOv9Cez+dXfitmsV0AL4ReuwXvsBx8taUXYaUu1EBw2VV7SfeaMrIG5d40ofkgNtM99jrznWhwhq9vLxUQLc711lG2/4aS6E9x6V842XbNnLOmuYglGE2axjHtYa0//aX//l8rces/TyJkO8rZn44YzG+AfyVNzOjxg2MR7C7QmofYduQKVksbKS2v2iKvBPsU69DYFH0mOja2did7OB7AJCJhjUwsJOHuzBtBQRW54o6AUUz55YuhADNFrbsgmiVsxIu2zUlO9WLpziC1IstjY3JMl24oJOdKVnLXPEXp7upxSMdtcmSnJiPuRYteKGpfyEdlj306eEWzP7h+elNCWbXLXlsaSC9b/F+/uHpl3MKlfbUrYGjm1D3KTkzu6/LYJQtngzflQp+d1evCiwTtyq9dcon7WFafMUzYqXsMra4f4ZTkNP+lufTchP8NhmdHGudlCyieHEynedSLGnR9H27YdJ8eP7T65kN4bfclmVplbi+VSqZ3AhZa8YvmF4eY5xMs/Z9O1b0oERBcy6KookndukXPag64uwVXDlloCS5spQzpi91nLZd73WUvneFNg1MJpT2pkt2cmNVyqJn5bZZeF8cVa8JwWTAr/FPtovStbQgnVM7KGvXTHvs/fjy+bOGb/p98FpWm7mezBVDtrd5D+PFdyruXdrbkKR9Cvn856dfXxTwzG3tM9Z1NM+1xqKEKiLo5stfznUmn9IuLPtwUwN8Vo8/uy0zT5hIVzdzf+wRgR+eP708Kyw2JaXb9s0envvh+dPr5x818TqEbUtBNoR+D+rhi55vz209GBf+6u2y+ekMQnNpW1SdO8kZdJaNvAzqZRd1N6U+P//pRSnzur0/0F1B9/kslhz3gvtCG4x77uuH56+/qeWvbt1DpFcjbTBJqlfBIWl/rX5qBhdhErWksLOUcyZhavHFkPRtiodNSP++dfbh+fdXNTH3zpu3I+jlPMjLPus6pniBxJN6U7J9+fFFGR3mfYNiokiPRerY2BL8XQQuLT3dxbxZdBQWys2eHcA/9Ovr199ev3DLkE5f0Rl5rU4vT+4bO8mXT580wmcv2vA+A9sPL5rSXvyKHDkWHJjsumX9nyIB60waVRgJJILxXgigWaQkcr3mtub8mVyepTAq27OFhHiNaRNSPmfXjcIBPxWDO+VOyGxJocL7sAx82a+//3zeSKS8lOcnNTKHd5Yt1LrsVgGGaNzad7MZ1ZfXdzh4TAbOISWWRX5tKQvMqp20RfaWH7TKCK0tS0+oc7R2P/dqnOqWJtz8PVPrcd5MfCeihb7n56ffP70+qe7aIiSwPrBg/Nh3D4tzQyWk9RuGhKzka4fkvNFq/staNcb5eKbqOstLu6VwLd9i3PrpNZTjhhvfg8LYgFUh5M0uiPgTLaF/3RdN3Yi5MEjHQsQ3bC1A+a2ySGf5t7MsUKf96bKtFJJIMwKPMwJPmDtRWVTyXODnLnnWYSfP2NAsnXS1qe9vutxb2lCycO2ZuR9evv70dII6lG0PRnwb8nFNI2ft6ZhV2rvlh773XATZYruirhVurqhKt2GNDPI6nLd6P4esMTIovaxUt823T6/KADZuN2bvcofGBS88wz2R74Yg9eH1o1IsWepM5t388TTi9fUvf3l+/vmyUdpCcKYSqiRWiz5f2r/fV+30Vk8s94Wl/87o+/r66fmsW16OckqBZEKKlooV2eZ3L5GlSrlKlGFckxg77YX/f+rebMmRJLkS/Zd8IUdkpNP3pVndXzIiKZ6AR4QzATgKSy799VccgJ2jpmZq8IjK5sx9IBmMyjDfbNHlLA2+QpdoLd9vNRaXVDIuQT6CAtE7b2PtS/ONPE3jKez5bqM1hrycLyd/F1panU9pFK51USdyGzf4nz+OXxeI6ng6j+ezjqY7D5FNMsZKbPzX+XQKzCnMJas1XtAaqN2KqNnJjciggOhRZMSf2mahj5vzwRXm8kT/IU/419yH/PM6KlGszgMv48ZXhrO3Qf093PbOfIyuRbDd1WLqvomLjodt0NcQj5L7Q654FJ9klNvu3ZJmQ2kX1vpJpCF9RstWUf4xfjMKJ1b2Zn4P+RNXuK4w6+LMThSYSx7RCJpwn6vYlLf7HE6B/pWpGcH+tKim2Djpx/i+U4p9rlIaD21YR4B1pXbijySkAiwUHB/wY0gikZbbU1uI3WiyZKJQXBe83UC6CQ1hIbddcI4nhZdiWEH8LcvNko8bkfIhHxcSryUkXoVIapF+XSrgsw2H3RMlYHr+8u9NZFt0mYpjNpZHPFGQJqmbhS32tbGwulRe8kxVGv3vLFJWEd1xFi1ZdIlkPE+UpvG3RLiKzjp/xyJOkEE9VZ/m4g+Kqp7iVLiBNkTBtPZ2dJrGl51yvLTxab8BQBW/i1vM4t2FcQsudrj/H/eSKWfGN+LW6OMvoM9IEqhL0WsXDQAmVLvvXbtPy/YpjBXcxlG7/QCdVVDzhDMCdAVc+9TtE5hRvaP2wcggz1hFQMEXtmlx14KOxUjSblljeL9DAZEz2FBF6wB/gflZNGEFhtutgFbh31VCv+53APSkxqZbA3RckorpFFEgbsX91xK6ahVBysj/a1Y+KBnFvkFEbxsUgoJcOqGFbTNZHqvjPxQq2Tw2a5do1kADNHa88EgXfECKGce5mUR/EExsABY4LTmh8LGFxL14/UiN2wQs57p9VVKbpkA0DvoWKMOc/tzok7D5XuBji3a9kHZBtdEW6vl6fXlRquOlJPh3eC091lRfRGoS9Hlh/I+6DXgoH5FX5PHNIx3HqDjScRivPtxxQLNrFT3wY5A2Hv1UOEsBxz8SGOAa2AqfBQsR6choAEE4e6x/Q1DF+8MLEmhwjVjIUTC8iIlXxgIS3JUITSIilx8IV0j1IY4rEcKIzlVNUUoGLjEZSwETxBYsAIMxkctIafqJ/QKuIQrcuAZxA7VdMrztBkqUuizsjfWjwl33C6mUqja5HWIy4SPUz0cf9kFPqpY6WDG17ifVA2/koIncSU5DRbNvKOKvGV0XmBuTAJrSN7iPdZzn3YKe9hEcspXF1mAXKaSuoph+vU67rUrRbFx+ApS1jOPHC31CYVGojttRyHXaKeZxa7XDWP12GznF2yKJpVcaZzBll7Ouh63Sqas7c8Kv8Yq4j6girLq3WhzvGNMfsjEVUFYO6X2B2iwcAuUO2CAFA0iloattFJP+OwAocqsXou2IwGkdUnAzDyErKUqLn7ya0LWeGYPbS3C37oLAcWIWwoOVLCf3G/c4VB+HvR88/ChE6iqNPZJPV993sUTtspbanVJAxtUuzgGjio59bmukdZ/7qrAJAj+3dWexkDx3KSv8+RzIWTCzUAl0v3FXRwUC+3/PFe/+E934AK3IEIyQrUHjmQwhQyaIvBFtY5bzKIfBaiUCmUqIZaC9gPBZlPhw3b/m9xOhjJEoJhSXwReBlrNUVCebhEF9JNBPq6fT6Q3XJZVdBOExfqYIixn4RkLbaPUrEk7GKlgoWkJjzRHsZX1LamZSjBWd24zYEThFZThcSK9juTYDKEfwAlADyDFKLmxvIswApIqifgC0QglnrBL7XemCZs/vDLpVuFNKa9D5DNuL9Duj+BQlcwRNEL8De7XuEBazLQoCu/CljTqaxQJzhsqR0BuhMsFPLRx0BX+HJlHEudO9LKIRjlQtWT7/pV00hIZXDlA/oTdFS51BUDwiNXZ8MhrCOiAZ/EmRjZHPSutR4ftE2A8wg7SsLrHt4QqePUMkUxcgHi5C5m/AQdOxHVgqaXLHjAofhFxTwmLpBoWJKuA8mCZ9IlP6dRljPX/Jacb21JZh09oaVHXYhL4MXnSZR+ovRHgW+LquiOzS39I9GfAdqEqgEMCpgMKGmABsNCLl56SQPh44OQgpJTcIZxJhpkBYiRMhCslGIS4tFyu8Eqlsg32MaTsnj9hPWAelKyHapC39BBMN0+VLHm+aXldvMbeZRBkjvHuAU57OkI3nUFHkttB1AtK/GXSG330Em35zEvLJJyY6PdR+MEf8EpgT5W0lISAZ6p8tKsTP3trmbVR9dhPqHvTZw1Z6/BpbTZAu6sxqCBVlwZMUZy9RqmizVnYOsRl2m+tOWS4t0/1D3/I+lhbTqkzZmiejeXiw2tQFFuJsLtcgklk0dtjMpzobKHI4EeP1Y8Uk/d/PrHie2O5oJe/bsZ6YFqFqQWk7elEGA+EGVGJxNX16kwDpNrO+Z86CYxxHMSHigkPKKwt5CP4UHN5SRoCvBEqx4thF5JQAkLvX5EsN2254CBz6PniAh22ddRmlx2JC2fBt3VsTsuhhnSBgepuX98EMrdkQ8wzcSaNU3R37Mt6rbBpzJ2r1LKJUiPvG1kUCKKxNoey7xHG02/lQe/udMJfEd2drtk58d29bkxqh+j4JDHrsR+hrocARcJKTwg6h4YRUiOEeF7FdrMjgENgeCjv83nRf9Oaw70VLbMLfgMk2C2tMrFlYiwRySSGGCrAsUVgTaRlTMCZZyG7Ynrb7spvhsBl9/dxWBhliE3TLA+EwAmP6QFTIwDJSBZoYdNtNlQblE9SR80qotXDTTCzB5Rm2p2E6RNWfl3NTVvo7f/AVYdMyfiQqsIMn8taRrJDyngqebldSW3MmG9E5OWIRPDTRdshLyqBiStAcLbaoyvn89jSQvjBDLeYSma1aeB/1pG0qGplBArCI7suaT+afoiaVG1oAQGs7+QJj6O201eFmbbrPpbwjNsPhMPuoiMJMHVx4t8Y7i7jTGJ4jLaPzpJC61qQyBu15IrLDYmPYYY5KMhDjQV7y+zyzNsNhPix6yopebB7gAb04PqpiCpgGSQCmgpwUanWwIQfNTrctooAhafPuNWIXKqCgFNW4EA081FPZRGGlAWm60MKQNHxE6FkiADkOX6fddNEQ6lpaXJAgVvdhZvskp39cQDWWTLkBp2SHrCAnlB6dB1oWlyit8ZCqUB3tbI013tjny3ycd/PrL7WBmHaTD2mz9Khf9tN5r7UFl1xPnh0AsXXt2orBMXAVy02xuYZtnpJ7DWmumCC2ovVywY36erVJBoDQXl7RubZj2sreJgFirMgnEofjUWEyctt6BglpltpmfCLh4vb+l7jxm5soqEIbVLZUEZZ9ouB+H1Oty85cOUg50QRNdb83Wno0MzVra5A40Q6MG7igSYRPUGJjKqtEweisNA9y0xAnvdfo5WYCd6hU4A5xlxhVcJ53n527PPr12JdzSoi11Gel+Ro2Tmrcyz0dexde0wMmZj7e9bgZjq/DcT8dxvNmuAfBP6bDdv6hguxSyiDyPC6Etu666v/iV/Q6K9pW1UvbK+CV6pV4pUC2TViQfXIvCK+b/gAoqEPjHd0WB2J3h0TpNle09VCKQSaLDnbl2qTQ3O7cX/WUtCTGmiEZa7AiYMPvUAKsEVYJoy9Ui2pAPtFbzGt0bqEoltcAZjZIcaEulrdI1ZnmtwSOYm/ukObzlO2oycUTlc41eThVRC2BotZFaa/ycQoyFWmkpdhK1EPDAUFxr8SxPvol3tYsFGNrkoz0as1WP6q6UN6ank8rD4/xdJlebsZgqo1iFmRXySOKcVVjxRQPXDtcxEjLtNBM7tlvw+HVf+oyUQX7KK7zfhn/JdiItGR3YxkpgELaKrpI+zs7CH8bVPFVFJiXzjpSCYT08Pd0tvLBZZvgv0evG+kVy4uzStKuVJhZBn1VgZBtFSII/TaNf/M2Knnl0uZUCD/vxExeRoyFtkWZ+SwDM3Zd8SJG7f6Qmw0WTTf00YECZbz24Y6zUtEqMrsI/nucG9g6rBIJJ24u7i1V1F4jOjbmitd+u8AxZr4tSftFGRGxSI+p79UuMq3qKy/jqp3ZlvZLT8bI4Ar9XNSm1WFRok3UN/Y63A3T3s9w7DidHocJuOlmN+x1Zleb3TVhaZbY6XfD2Zea6K03WgrMoP0al/G0PKSpXvHv4nTe7mJ6URlmaRY5wQtr2KpB9yxP7B2LUsfVK5bVZoyGUgIplhUZHlXiYUb/wFukKP7HX+iiS63q+qZG6b/1LpTcq01R+A36sne/Jm+9cT9ER7mJhBT8xK7v6Aq7ADfRqgJFVICmIYHs2mahxoBENJOVC3ytqKMyDyezFu3MQjSwUFYHKIOinGTWlvi4qCEKec4oYrhh9EncLBkrQLwS+0p9Y6zDqMwBcAwSqwr4IHGfQIqKOi9+EnIIuBoBIvQMp/ZYQjT0PmcUnaMrzWiCzTZyUlIl6Nvwk1IlbYWWSlGg61iyAlCtjEdvw++Hnw/3Mz9nkVpIebc2srsNqVT47A0MUBaxVIEbZXabwOrsZlWrE/mm0PPAnkBA8/0HeLtUbuXA3Kt1aDJAy6F5kBeoHUhZDzLOwXzBegRaOq8Rx9ZE4VPwBZV8Ut2w+osM5zzNwAqGmfgLaJoXrPk2wFI2EUVFyPJUoIe4q+KGUanBO+rdhO6xzxMzQcmgtgk2CvkAWM7slwEpJSXM2WuB/XaDLn5rC0XfZslm3vplNdFucHugqxOX7gNWrg7V42yDxkre4pN2osCJO8tXRuXLzanTVgg/5XiJpPY4tg50HHA6VMj9ITVUAM5SZOQCoEoIQERRoR5Tx4yIATt2dCSY4BFcwW59w/gxAfUTyveck/E3dHPFjWMm6tKz9KQ60Ds+wHR48byyfR97TLccDw8wE+jJlYiZQ0i/eWXFnrTiLHx9d27SBAGgHQKX+CXoxhiHWrJvGFLmo5T0GOWb+w4dBVghjVGqib1LoMyW1xP/5JlUEE5sVNiW5CbDrQUbShORsbRuSSXOXSlvxUUh4PUX7MKXKCTXMTtu8vsoGidoWoHiQjofnRcTQ39XMTmhPM7cURMf83q+KCxkJi2dANavq/BtCjkcpOgR1RnngpK4gUklxcJP+pPgmqOm/OSLzsNuPG90NSSzG5R1omqhzpg8r6VqKp08o2kEPUwEaQdrh6bbggpjr515O3pugGVnwsLdhCLJgwSM3OaQL5fQvUkzS0zVueedjnBN+eOEPOtm3qkQNlEDSNUIQ285L+LGPhnbMyTU23XdIhgopIcuzHCRMNh1wLBGWD4uzuxdcCmZr8COI/8TutBRlxzONeJKAI7A6SGAWBAlKbCSCwCfhaa0sHvAXwh1aSo6hcCuouLfQgMKnWmRqSIXLRAnFdgqi4Z5OzhNAhcPuGEU7MUsF2Es3gv73VIeh1xZStfwJ7LcKRIT47tTrIXiLzEOPEVYmHGnePExBpWg3/EsFDxMEieo9kZGJiVLKG2C35FLSXNPocTNPQyIJKE5jJ4/IXKiaMr/SmlDQonB5Y8qe1MHS0Hubj/hb6n2jXfQ4x30DX/CNcAJ6im92NiJ622XUZWDxqwn0s3DuWrYg/oFxN40MancG+hEsIiJkci5b1cJvAOaVhomYG01kVh4be/3dqH78B4fT4T7BZZQU64M4W6j3v5eviZR4PxUCSuN/B2D+vwN0aFeqIpubjulBuxhDVZ8g72zjZGCUeFBDhC1sBXmfH1QbnqHOBf1OWJCXNzNYvvaWjGtmIQWWznc/9YKXfFvKW/FHZM0h7UiVDHpKXLTuf+tFYOKSUCRdbVqTey/TgeN+DM7Y/SnY92YkpWsqHa9HfbP+71vT9EV0se1V8nKirWyDOiLMJnd39jwxogX/63YVpqocAkSEIIRwNfI2kH1ymPAYONP39f1EIJXbN+zdGDqRgtqtZUJsknG3fvjsPG+bF+ah8Ua04jHiGeVAllj0vhCKAFSLY8adDi0M7Go7DRxuYvLFEKry9rO6SCSg0oaStJCPgpRSymggnYL63EjCpSc9VZ6U7s4lER+XpwElc72KV0uOSlSTl+aUE+3g1VUhKWCAPZmSpAzMkTM1CYA0veb8Vd6Zz08S5dU+OxsnwP6gvlwI5Nen3JWuw/mr9LaNB7BLlC53R7CP3iRQvPX9RXcy8auAx1eKXguuH38AE3wAQpdMHkqKdskqLyPx/c76CYRgSaycNlG88DdNKWpgozWM2Gl3gZSKwRGrYhLY+lWqpyZ3kdvT+urUeeZiXmrq6dvzq+DtDbmUWHeYs+RuI4Gieam78RzEPc6fHbqZrTwgGcgAw4m4eDkW8aYMozY3DpxeSOp8YIEiHSLMkkPKrt1y6fxrBoteWYmXDgIutCyQZwD1EBKx2y3i2ublExqZPDkyeD+zOu08JzyqRJYiOA1FG0W/HVCbVHcW1AyzOtK2nYQWN6vDvTc2Lvxu0/dXeyA5MO7LZFcLtQF6n5tYnfYDKHAZ+4pfOa5qEGs7A/dxh0Pg9ogWxPaWpKXnyBUzofN9XQaDxe1EZku3i6/ShnbhCY2N70S9GfyxBZzeJler6eARNWnuMMBzj20CEheTO8ieWM7qzOFEelsRG5upexbUaCtx0QI+MGUB4x39wr3WFgvywEaUgP6L77KTa+WUFAzyS9ww/t1dlu7l4VgbLJuArlitovTSf2EIGPDHRPyYnV48FXCTAGHehZE3R6OiKggMEIp5EpttycT7+RDNVszQCYlOSUY/xhTvV0TBeROM/RAyd4s8gSZ211lP/w8/9p/nXeqEVbIljWK24uH9Npt7mU3qeytqEw6RLoXcziMfmpZSJY8JhjawG7ZlWw90FaTIQumUYVyTUVakIgmGVNR5JE9TrahUZbrwsMgoovqKYKQrV4Hd/oEEMfucERIkj5UUisDBelUZfb22j8PV2WY1dgeVXigJIDmPrA/wxvZ+yVUIAf/Al34oivCMipt6QEWYa0Ogi15mwUvMolxvt9p4O9sReSwfgJMFTQyCDngB9Yo3TfDWYvtCHW9x7+BOmxMo/fxb0SyiDqEyxppB+MOfdrBOKCWqxaADQd2M+Gm0PF1G0PjtraERm/n1hHQIcxZpUoOxXPdayTSUdi+UEMH7QG5tnlYEM4ESB0FsCi3S7m3mnkB/iu215i31u8xgGkIiiWpW6x8FE7CPYCgGe5MeU9FM4S/AvzHXSMmU0uTZkTqmaAuYhQAZSlTmwNrmQMi9kR+tmMyxKoDymOsPwCgC2KotLkR7FjWuXCga4Gu20/Yu4WiEA5+muAA5ouKYtEnVDCwbeym82U8LJL+X+efMR5UXspzFlhtB1lec84+LuX79bZmlQ5S3oz7g3MpzjrHN6eEsUgP1MmTuFnafUZQ8p3ID9BdWPsaOPL4uuSKamRPEmJ9HuiPPB1iQ4v005lUvX/oiNVlL1labvd7/8g/xq/nefNNyWr2HgePHqprBz8sOX7wCQvZeJbNoPcMG36/BUngDbsWkSWHjXy8oqk8QM67Z9wybvjliqbOvGHfO92WYa/boxrTE4999zx7jBm928Ib+b3zbBk5OsmKpi69kd81yRQizISzBpnhinzCwoLKSpJdd0fosrpQ/vSBz+Pmepm+q7TdlJCFSRjEBTqEMDALkxZhQm+A8g5AztgWYcutzX4ZZ+lYfqhJeDhfTlfVJqyswkTFYnaqL/YYU5WzWzNLWUdHdMPO/oYsF+B9lMexCMPrx+CubgGLCRfzUqXKTVq2MtwPgE6C8IHwk50N9wN5A+4HQq4fXxcRHmQVWfwADwrxFmgT4LeAKoFSNXWw3ERHbQPCWERCPV6Re0el+8el+zelu3mARUGAAfsEWTqQ1pggKOAjYscczwuE5AXQJQVZBqiU4+7yEuEPbjQHiSOH3kleIXSnaHGFpIXAa3Ah8gopQyU+NkYBOpGYnIrpC1kCeDaEvDmw8jmg0XlNZXbW9lFNhs9iXlf8ic04aKGIuUcxPIqQYooyRUJuCwR7Tl4Hw8wa7fkG99zgrSHbzCm92eDdt/gLaLTnLfN84I1asU6AvMRdtUQUiVYp2oO4l479e7y1Dm+ow6LssLX0GA8cEq+ADKRQDJv1bA+97pUIY57QGVeigYkx/Y6t3XdEE9btL5QORo4ts8qEiPD9wn4sUpnlJdpKIFtznUFk9CX74BAxEYLGMDkz70VJqJq6Ng2R073/pE8Ewe7X8fO1xroK3rF7ocaQl2HyilS2rjSAfw7blxhR1ejMKjStP9A7zIkWS8/m5TqaMFGabStR90wxVu7D+u/YlLsBYF5gsiMuFA6ZZ11Qp7KFGdg0LatYEaVlVJ3SMjzQGhTKyCkNiPstjktrNlCyLUVuhbtzV1gTvF7Gn/7TV521jEEJK+oUH2RRLrmqqNPWf+pa7rKppXc5KZRa0Zidq5QF+W2o2etCV3azkXL1j8mLYw5a8j37ZBFRftpOR1tZ6D3SIqpB7a3tktP2NO9U19Hu0lQpuN7h+3i66NzaPJiSAKr52+TTa607crFhjTeVp2hMy7jakNrEzwUy70/GPm2ng1YAyyXzXY3PjyYKoYks7Kh1LzvbtiUXRIjEJzuq12HrzKyEbB59jKQZl6CvQ8FUIM9CowsXXcc8jvCDY1sHZkd5ScgPoF/C7yLmcgE9wbh/EX6CdmDUySgJP02CTo+/dDprKimXjrQugSK4rRRkRFE6qw8CiU+n61FJYCVix9Ajynk1JUbX6LDMtLP4tynIzGflSWQi/NbyUa4+U7HJTIioyyCANRe9J6oh9AQUpyALimkjgpRPj/fQkE2HFkiexfKUmPlbQcd4xq4AmIIWus5m7Xa7OiavTENq2IDCrF5YG9YpDM73wI26qK29EGo7TnfGHNK779qMiECCrGgCQjxlY4d0p+H85i8MUwFQiIemTvHTqPgFeWZSH3DoFsh2HeTJHvvzirZHa4La19Xu9IXiJfq2t97VO65yt9lWFW+BXXWv5nkMfRtOUXFM/HnYtAPrhHAuMr+FUEoEIiKcjRGJpE75253+eR1Pv84RIGruVdBpF7WW0HMfPdUj7HO/b/WeFywGRuv0ZVjKvL/UNSrvGisVi9Q1Yh29vPMGXgkbdQNvh8uwm8/n+8jj6aTq073YG5fhqVT1ziewmnt17U3ud97+bdTUi18EPrwLrOweygtE+1utN+rK5qEc1Wqcdd7A75zkx9N8mTfz7vs03yXG7e/qN4JRW89WNhbdFe0L+IsKVRaAfNdcwCet2tILON3ynskVAD1UmwPYRsDiENY1Qg0IdZuoY/TaqmwA65WVWgGsQcevJTAkogknVh+/lx1J399guGXkck3X8LLI1nKUH+MGW/UizSQmb7lW4ug23uU0HM7H+aS3B9lMZzCwYkSthCP2X8r7VzG2RwLmehq3Pt1Uyj4uiFyadYUrV9hZueSUVvYo4ED8kLbvFESU7ldI92ntLeo26JqBCNyXdnB5mrVTR5FItkUiKiqKmOyitggaNMW+kZenZL9Ps4LEZObaD10u0HFJGVrc3MLtPOG6v9mX+t1zG29M/0CC5HATLp922wGicspsoggnwIKkpNG/gP4AhYrnb7sWfqImGOKzIpEV36keXqnFNKfJoZgFcK57y9SOoWSMEwSjWyj2R2YntbD1ptoJlQKobIInFC8cE4+dZkr1UZdTOA6BMADkQkPhqkQOFqPElI1N3NVwXejKlBD1w93H4KQ1K0hCKwxN80TD4n6nx/k8BcWOMpOQuPSLfLyW5zvu9XSelTawDemoEvWE6/ky+wyI0rTOEVxckXyYB+J22A/Ks6BubCVwu6B8H0iJdNuexFSETRRSlkDcC3Z6s42AFM11yNEBoY4F7T8LRCGsR8aaQ0KlWOhnMOZhFRL/VahhYNfjJKJsqlC5YBeMRwOOkFAXSBShQLAqCmjklTSza7jiWQdEhQJAZNYY6N7ekNyAXaABhVOoBmFXfViCWt/xy04fX50EBebwkiy6lXCuZdSvyvmpsBm2Bs47LamyTiDlidxJWrwk9JyKIc2fSZZEkdfJD/J6Gvb+5lSZJzk7zjEjxSr95ZcLKWMAW6svUQXHWBG/ltoOQuzGOAac/qUnkUwEu5Xpn/bt9ARJUROiLlvRUsU6j7KFH+diTNKNKitgAnCQ6K1F3FFKEf/zKu1K/pmvnChR1KqtRbE2Wzp0Ow7b3XRQZVFbCesxIjBhQh7v8QM4Lkhs3MZYuuSggj4TpHFtNGrjUGyN+2wNQaFOjSNKtqFCB+GI+AmLPuqXi41FNCOwsbTkN1GmHBpqmXCRJ4GEyig8LyjmHKODsKJNFVasfJziggQSM44u7Ljh8eE1JMhsDLi3D/yZeL7ClszYjl+vr4HZUW7q5yQ3jnEzKSnN3K50r+tVLWPulVVsY7tHSbVINx+oFhk91iJHWPTgCo8rqSYYO4bczHXKgYnny4tO8fwKb5fEUyGrQ+OmaAHGQW7oVAb1zplnwX9P3FFsa+zl1ghUY7Wu2PIYehG79B+29B4WWwQWckHXYQR0LZt4Mes68Te9TQjlHUUeVvYn2b5/yOmvfdimUs9aec/KSQqWMELRllhDJNJ9RI1d/k1vp+64odijeoZg1BNd1994jBwbV0xjgiLbKrIisQ7F6ovg395xJxoY1kklwwpQp5YKUWJX4F7AHQDrvnnXfFfuL6Zy1F/bn4pEYB3uT6nb3Qzafljyn2rgvLKI+rcx7m5QmhC12dqlCDAPiFWmjY/LqGzKJLGsPn+UFrbPhELE0uSR8nCBo+dhOmpfwkdA23cN6xuEW64ERIUf6aiDn5DlU4oQO7gDJ/tKQMJ/BgR4uzd7f4w/fxxTaM++kAWlmIHY06l0v8idpbcfz2dVp1kEhb1L8Ehc13nDJV5Ow14P7TWqqNqbhxX85NAPMuBpPB+Xvpu6SOldhDv/ynxLXeQ8nr4v3FHfF2KpqniXoe7w6p3Wv8xNymM73bqrw+nXdtz5VaplX/KuiGZDRFQhecXvw2nSffmy9cYmVXr1YT2r3pckZ2pwrlMTEJpaaNdgTktNrdShrOXnq9IsAzTMkREtoE2ZM1qixgDJLVQGav0Z5X104wavl5dOvXAPS4BkqV9pGXIf9vs0/vD3VskFJZTwPUtXbTllZvsXIieG2kN0uwzAnOs3xIdam/JGMdH560zjObAGwHQ2jhINxZRRF0f+88fxX+fLNrYHeoTzyP0++0bb63EXUX/Nq9ya9y0FrmxxqO34Mlx3/raQm2YRLFugJ8j6BeBMGtdEcxkesey9Zh3nEX+iQkiorBGtckagi7Jogf8qyhcEUYVwKiH2ippYWbF8AYIllSta/hQJBtAlblECp15F2yfinNsH8oEgb8Nhu9M4pco7ZsEXZSn92RS7XUizq02JxCyscjmBChjxuOkiBG3cv4HPmAvNQG+kzxi1btxfORojoMJABdfQxwv0cGLKGe5abhIQJ+ACw8bdmJjrgO6hbITfNO4HF1fCpx0qOm4iocIH/jfdnGDz4RITeqyBvtbhB1QKQzEeQTZBzTITv4uIxETFQagsCEQi4RD0qKMoklhqbBlSuY2/QyUVng1SVobdWNQWMyzELFlvxNXoNSz61exokUktpGtCbYCy4laAUdgNg5Y7dRZLtE2iFU8gJVCbLeAOIyVzUHcS/XTRccMoCHZp3IMOd1HBIk9K8OC/svqK7wsGcwGNPiHVU+FLs15LVQLhACZcRsg9R2+QziO4P1H1ZbnKFrveji/j4azlFzrTJJnBSTr8eFmQplr0fgFVCMIdsAL56hrey3g6jdsFZepn9MIp+xN81orH1rdm3KDc3dlczN9v94OI+v9t45+CHMnfbz6DXPP/bzY0t9kT4FcWzo1ZZWJtiUpciSwoIhSdFWYlLwFb2Y676et4GrRwd2kSsQEOYmmMZPdmDSl5uWZAM2la0ysehfcmlRg+xiSo+jBfppdHaK8aV4JD9qlxs7VFhyTH6VysNP3F5W9hZOrKubyyi3KA9ssJB8JEKFe65eAezMsXdSFRqWW9thzxGPh4mm/VldTzFfL5KjzW2lj5diVvJnYmbKBiqOziRsCVW4ZfDI2qRBKr6kN1Y3KUmKPgJHbCLG7DoApkhR9c0sbtlMKNtGxGlaFCPwtPl9fYxoWmACVtKDKY8SdQzIm8ZpZcJ3Lv3WVQbDOTfIxE1KVt8RH3w8Gn9ZSmrHNL/RXxLIkNV9UtW9OksHRHCBQvw28UffkUFm4peaVfdPzmjqP/4LXNBneXE2bXxDel2qTH0zSfpsv0r3E8bI+zKkn2EkVeUadjdUHyqLRvW7snj2KES9PjI55UiFnUpmxASuT7PpByeu5tc3TSGBIgpvG8OU1f/Qi4NS3fcprRtrYY5n3QkMKbJ7QcUuiG+2iKvGK3amgFwnDvt8i1FnRTTSCQt+N5eg3E/hegvV18pCJIxJPk48bKUevkR7SZvvVFKe98GfaeGGHuKTbnGfrKZBM2VR9c5tmCO18WEQU9WRoTbVWAE+1S0/i4l2HzFqBibKGhdV3Jy6DtkYrcbKmmp/UylFI7rM1QND3STVt70MKGlbixT0AHU8+2BjugrlYmi67NpEQkCtsIxYE1k8N5YVAt+1WIL9yhb5wiPP4TQQuktN2RU8Ey26WwVL5GYOMmBt3eXA4TjXCEgEsoJc2oh0ZNsVhHyEELqWbKN7NA3fivZ/kJlRFh8oSScaAOZkVMti0dP9uX18HfITopgZpT3pD0ig6Vsm4lcWE7KV8pE9lG1X5UVyAtiC9ANCMiL7huOQxj/DYW2veoyLFFbfaUODuEJy8rVALRDiqHLT60nV6V5+Ei9GEe09ghm97eIaf9Uv7y993WDFQpySLk2qHSR/UU2KOk9lF3ad/bTeKcUS8URHYxYSMwOyBWK1FdCkOBFu1Hge5itSVxsE+nwJPC3vZSG/ZtoDnYRGvTX7B4IHRTo/mc6sxDY9SANdUo9Ne0LOhZ2ibNFaVooTGCsm6Eoy3lJsQ42JYAMhZ/HXsYXy29qDOTUczifGVTbG8jnsbNfNoqZ5JOss7LlTTu7XTWBkdLYf4j/IH7SCqWsDl9QfcyMahqwZl1BTbT0B+LeEE42muAOIcABRHnOD/iJgIkxIZocEaRJYqQog/CHgVNpm166v1FaHx1UTyj4xhjbYaT/6HsvKvAW4LelegYJzb420W0Io1p4cThI0mDkKVMceTcJQPPs+XNysXRr0ye7wN6Q5U2aBzawABYCqiqrRO4XCX0B8orE3CyDsawjBrUaou69F4EyqXdSj2J7XT+pvJWa+Ygb2VXgQlnXaVmznG46FynN3s4FQbN8kRwsKRl6hXXpkgPBVETMqhbHwnX2IBcYUvesXuELDlieQ3ph5QepvtFxAHZpkukNobHLzQt3/YDS23ds4KjmUpAcBQiV9ktfEa3edIshyY45C+zYETPpFYoHiMMixjKEJiV6obM2mM7b0xyb56xqJDAtsxXdRp3mQTIZWzzIVhoqWFVhIs4FkZm9NZE8BhvV7JJmQg3JaVTkDaJI0eLjIRK9E3wbYxmIbXW7Ijx9tK+DKfT4CsgtUXtvTt83XJlLec2cDhu73EPaQfUFyuDrtu4MZqDrPXCFbhd28q6DatKMO2zYs6TAGwZU4cdCWRpcqgfytqytF3YQn5fyHyLXuSkVKSLzLTcIDLzsUDcLk1lnRI5L3ciwQvEfCaOI2FGfrs3fR5Lka4Csv1FF4nfPdCOtjBNy6PfL63UEuxGQE3pko7lFfbU09dR06WyK8UQkX7s3+aQ8+n4NuiWaCdtWaq1AcxJq2oXZppKtXaq6IQi7VIgx27z6KZJU5idJHoZpN7K9H30JnptHvmoGlG3pWIjrEhcI5DoycvaVsbQEMAQ5Za4yrruv/jijcvyW2CyqBNTrC0A3y/+tPdfyes6YCMQYDlOFpyTRbm2cX6/g0Tnv8y9HWLlWXAfdk3fv5bPxle5/gX6immmGvU6EpPSS8/LZwWE+Ch3aLgqHFSmvtS6lMoBzv39rTc7d+uo+YvF9LRTrnpV550LGcvdYY3IO3/dIkSJVog6MQ8jMLNgFcvOUEMX7ML8LoDMRWSpUMZ24T3Qyy44rt2RiqY9ngRxLrr9wBoIy05A7Z2MLuJaeBcLeXqw33KqePPGBRUG1Qk4PtOlOK6bxb2WXjLEgyC4qzVIgK7H3odi6izMVZGvSHNKQvdxiLP+RKwq+UWUNEGVpUXlpUXvoSNaFNdAF67ohdwifofneCgUJibXl2HjFxY6uenmOUKtbi2Fl+N+OY6n883H8vLlZZh22ljcoy0L57EOy65bC7cQF70ehu/DtAv84z36MOdd0eWZ/37fc7XD/GM3bpVCVN5KUFee4xM9jsy1Fxg238ZDUNxd0kNxdNC6unzX3Q+bb9/G8Tgs+DG/4Su5vVzZbobrGAelgETLC1c8zrvdtMhyfx92e33eSp3Vx9J5x8PETflqKQbDTLduVx7n15NOwkytdTA5oLrikm2YrFBgGQlOkYHkTXqENOELSQJVIg37Me58sY3SLP9wrylsDM44bDwYU2WGGTkL/Y4VArgrJTxEOQl1OCGEiJBeeCVjhknXElIsUM4HSlqwzWvVREzX9cbhtJtUtlbUiVK/KphHx9y8zYoc3pllPdrK29pT48uLLln3Nt4DB6g7FOwhVaJUmuBF3qabQWCc5SB+5BW+TG7X4HHl4XqZX3bX81vQQ8hl5+5Tj826Xtn2H19elNRsZddV1yTGEYNgUez5BEu8Gv0qGhsWbSR4DL3fCV9FJHH/AT2r2gUeYJI1LjgAwQBfBeD+HJVWqa8DimPGmA29eSD18hy05BwLW9CXRe2mxE+IxUhphvZljU1OGL6R74WqE0/qHBVX+UYRlUHhs+jYiAY0ifZG+K+EW8N5RZp4owcJLVLnHezTsG3w2Phg8IdM30z6PtfuHddgVxQtf1o5z6fLmyJiZmahr6YCiV04HHfD8axij8oE4nYkrGm72Pjg4/L/K20Vs8rxFAVjbzG76XVSsWBlagPkSBqqyr73/RR0IGyLDva0qA6a6G6N++NFwfmtExxwfgqME8Xf2MCre1Dp5/smej+V70ei07o3WSlg5QYyzmhCk7sdelIAOi4yO9FNosk8dURJrI74VAQ60fYT+rt9aX6QWLAcGVJL0uS1FPNgF+ZhgfF86R8CBZraxl4Ty+765gEiC4l146BSjYuukI0iGe1dtk7xVx4aMZufCiiiCkK0EeCXswsz5DkzJD4ZwR0EJbK3iX8H9JKghLaa2pE2Fbq/6T9/HIfNZjwaGjmVL61BcNjKjdxd4+t08DEQeV97Vg5s3K3UlsXQm+GwGXdqbE+3hq3Ach2ZimMHyvl5X3tiNQULFevSe4ztZ5IRdYva06iJBL8rLxQb2vusFDkt12WqGPp1Pk27ndLWqT1VDkQ8fbmumo3BH2o+d95w5CG8wgTlJvqVnVl9ndgVvGmEWlhfvmcvs3WJYlf0Jhe78ittg3DFmy/RabEnUoZtfePPKqzolTYauEJM+ajxetooBvQr5WrvY/txnxn1ua24e7LB+eUgk8heCOYG0noiG1i7RY0X80Hg2/E+WbsterG8uBY4W/mNW/9re1/FeLpQDKnxlzZREO/Y+tQx0JhSPjgIkaj1RVgUCTyQDVoPD0TmSbTOShU7DpvTr6PGHOW2hrXALXTPRg1wUXbKvQoOwWFjKtkfoYMtEjaKD2A+OSdoZucEh+00+Dmeyc5zzUrXgoCvNuMckYqjoiJTcUgA0ticxhpMiSlozwQ3C2OfVqgO2JWsCEuylJ44qN41rqIAckKHuYm6Q5GRXYHfPfrjZr0J/AoKjTxeIZBr7ibA2axd+b12wWzt7g+LrK6RkrhnQHjriie9e5k96irwW8i5r2WxUhiqHxpj4KHkoJ4j31LBigiqJPh3rHRAOl3UPErskxX646IOggpGQ4gcezBEV9kwezclFE7ctAMRn8J9Ab74MnypYTHo/Q8sHymRk73Mp40fV5SmRzKklVIWJI8hlSKK3VZfJQqzgES122htdteRmbnUxxzyeJqUjGVm4n7p4JPcXt2o/6HegE3eTXhkj4fLpDyHC9PWeGVh/nDRzJlWZg8tkMBFJMwjKx6STUAdVA48CZ68oMeTFN/Q4QlkGmJbitRRc7vzl5PvntNLAZ8Kl36IdKwIYi7KyDI3OWm1gxawxVOg153RWSV1oH+fTvPh9jvvq9reyHTle3jsPRlXmXOYrmLJuOM4nXWxpqqsfUGWQKFFsqoYer+M5t3KPm+6vPrk0x5nv4snbWap6NNkYcoBuw7WAx7TPmnfUaJpV0KsaZWlx3h6nQ/zftp4FVCTbkMt5DZR7tSOlUUnNVdc3Yc4V6jBFjl11hCtUHW5i+jMLPkDDnH3w+MFUaQPmwAtKQmqp40TYCMeCRg8fpEYIFmIOv9gO8RGA50pWU4H7AaFdc/bJxD0MywugT9CVEmAv7B2osqMMHliXxfdbsrVAWwjqK7CNQjp4RpL6dukmA5f55+b4ThsFAwsL2UZQyIr2ZFbua0u14npXEv+8SfwI4pipWnqbVzF1DHxcIBOcgICkQDiQER2Hq1B72vlwXersDusEqofF4n6ECVo6llBlNKp+lmDHpXuvdnUWFPXjleYdV05fivHcTMNO18grEg0/lNH0PmyMBDPbypTr0yT+XAxpsfV/gZ2XIV18LDjSo6r4Zq1aXtEGgt2oK58fuNB8JCZ2Bzurm4vMkae9oHiStlaoyIPaF1jD1J9JLAKUVGctDV2rQT1dFRA89w+Bj2/dmz2wtuPP6V8KYTLH4Vhol4VlIjBTxU3Z7p+gFaXSGm+689Ym/raTuPUHMfc0Iu69ITcmpX17u86kMxLk7YAJH6HvgOR+FVrVyW/q/ptbcrLrOJHjloQrjK572FFJad+rkYNFz1LsfcfINXK0oorG9QNkiD3A8DC0EN0hwqmkTAPKVlHCasnAjIWxfsCY8IGI443emNCgKWAR7hE9NJJEts/FJ4EyKxLYLV+DgqqlZkwAdhVcnUVAq/ITpa9YSxX88+cvKkSXC+FiDHG3B8VVqCwK6WpisB9JJWVJQ6xxAR/DPX3Pz5/VfRbG52YurPNGNDDbAkNLXxjD6kOkoSV4OODoxEPKz/MaXrr0Skv8MezbkXB1XJT+qamjraNHB9/bnZX1SQqbFJhiprnhtrN+gjwyDfNyt7hMlogxGw6eWvcHc7BGIKtjNA5KE8CJE6KpIfb89doUySOO7ePURc8AUv6OW7UZmOtrjoByfw5br5sZ+VZ2VayBSo855uVLdCf4+Z6UYB8M95v/Q+RGNBfYbkJPmrcpkpt7wx4wYScxuMyvhRFY5YwaQMeKP06M5f4Vd6G61k9TWPKE5MLQx4NPY6ZRDN1Jo8icu2bRIMfq5s+lbB7FyFnK3R/FEvqSej383hTnPPzGnM5pI+E21BaLlUk8fg05UqHpPHnUdWcpTTIAlnCh13ZJr4N6DcdTTgl/F2Fvyq5Qwnn0ftV/M3ZVvgi9pctRYGeDWdSYzPqblf2o24bVUhD21TwsRD/fCxRaXsiu6lJlWMXozFNEdrsMTkLwJSBo/RctVnPCqUrykiQ2Depl3V7NlUjaE3YHnqquY6ehceuhMfR0QG9v0TW/vM4H5YESnm1VrW1CXXCzF1ZMlgXUC2m2tRI6OkJ0PE7UPEdNek2tSHMCrzcm9EhCYLiy1L7LlG4v13Fh8uYTwUZReEvVMGSok2+u5sNlfc0dmBvVOxT5fn4VS8BSKKwqbzp3fkyng7+1Cry5iPu5uPPy2nw05H8I5o7t3HmY8j7bgoZejYriWJyuP/1x2eFUOk+cocvw2ZQrSfTYoCSO4+JTDQIg1mK9TStGZG/DBFJwbyxYUEflgy5X8nb/nozYAWaIwIM5x0IOLiQQAih37y/Vb2Chbvq79K+EyK24YgMUJ7TD9gOM18COeCq9uUH0HPKw2BD4GHQc0KY6zYYSE/AD0rWU9BeQgHHqQXcsktUA7HVA3BTlMCl0KWnQxUlkfzdH/rLrMtfnhxPTlJ2t9Kibhk3YG32NukOd53QuFvG1Ddamg0XCGJQrxZrw71zUB16QZ7D90DNi2iLmm0ZNNYaG9Li7vh62ml4jmSr2YicnnwqQm1sgM2qz6Ik/WwfRW3tWNC54vkNyw5q6P4oZJRF3zSmmB7ppUbIR7HOmdcjZUG1D18oO558tex4RlBMsve5otcW4d2XNqUVIbT7AKK8itj4t4jg51CKWNf1eTyHqsWb/pAtrFYLu2D6Mux2X7X+QWbWi0LekjA3JP8o4BV51oexm/Dj4sI2EkLS5LZrCI9jN0F1G+cjq+y4bSYMlQZz+QLh7OCzVI5TVHhPMvdpE+e9v83buN06YiGeURnD1oN+GXzkfpVgtaFAY1MHA0PSojI7M8IAMIESDo0tC1tV93cstOhNjONWT/siNwW7kzHqePGRRYudqjhdIvwe8cvIcJ6dcN7ap/Yqn4WXadwpoUhTNEhA2YQNBXIo2+3iZdISPXYHPZEjLcMoRYzMpBCskqy+jfjrfBn3/qZpKvO1iAw/MPOQgsQdT8zk9mXa+WFBb/JNqpolx8TnOCw76p/X8aAAvZWEfXV0gFwbVk4HjcAw07rerpItRnfnNz+d8GUtsUnHhKbWKBTcL6Ey98I8K/PONuZ7DBUg6FqfMYNbXtcNuJn9qUPP7NytqRS+aHRwZoI0haBIxMi56NH27u3C9ct08g+a1iyT0T0LsRVchVvEE64z7qJc5mhkPaNHLfhUwmILga6gzYKOnFXsegDlSBR7UjmBWV1Pfw+7l/Hi12sT/jsu9G1ZSccmEmP8Fqk9WJ2qtdnBCCVj4gNqry1zU3c2uvFhfvrLsDXPn8LlJ2WpM0RR6sBsyBBIU1zniUUqs33I7DQo4pI0JO3IKT7EecG3l/vv8ZacME1B2aNmiYU/oQBD+9LKnlO7QaXzjZ3O/5uCpt3wqrroAm32qXbV6cYhhuGUBWAqhOaQbHbupdNMFcrQBdAABWOdnOc+NXDRakPtpmiySJVI2gwhXueStqmCL7vBR7E0NuVOalyz3EbVaXtP3Q2Xy3hYuPWh8HPj06PBH494OcXHntUTSH+aRaIawWSEpiz9g98vud3b0IzbbZWF/6jSMYEXFsOseNSyiIlcC5p0xTS8WoetuI3bVP69eqAAqJY79XD9EjF3e1v79naZ2M038uYB+idJ9n3fZd3jBrYftiSWuD76iuJOsnX3FLsNLyOq7Cyt0f1BBzhgMzB+heuoKemmxKhD6SEkQ71ZRCdEymB7K2oiQYFg7FGrFnFFIptZ1MF8Vkgr5wTIVLQaiMCGyBgRPWEGVjTwKtOVQfaEg9J43gMdk3FrJnAQHb+mz/wXYD72f/6vz5vQCb428cCo9JBHjyPQ3Yp5seGwfR0vsQyqE9s+u+xerzySsuBmksWh5cqa2GjigCXsuFv5WKp0aBrvuEleERCBCd2KenFlzQDzDvR2krd/jRT/svv1Y5xe3/xMxOw1lpjIbSLMmXe7+YdqRZnfIaeCAQuK7YqvfbuKfh+2uXHyLdzG8i1P7H3afV0Alp2IcHxopadmrTeH1QHexC0IUOMQmTObRdsQlHBgudA9QnSOfYlYfewx7gfIjOlWU4FiOHIMl3Gi61C6tkLpNorSZR3YMrHJYu0j8ocJYuWOv8rteJDyrN2f12jmuNOwdlEjncRcYbpGukxvMfePe5x5ms5D/03IIrqWWuMyW6qOubQzhKi3btFAw5+kD1f0RnoGh4oWJqnuAAjpRSAJdO4zwZcXgQMOWmDCw2I+TDN698J795X7kKYbTSCpOgGECnpVOWQaBGqFRSop382ggAkpdMDwFxSKpLkNyMMeC5JwXIKgEWQQGI1eBoiZeQkiixSeZHMVPS1SC9C5i1MLkgq1YYs2b/DkIsVGk71BjAhdjhz+sHmLPYJRCKManrY0AmpJ5kHntKOxG+CiHUCFaVEZIN3yjmzTNhLhdjj7RISLs7lgrBsU2rwoiexQtHtAfaAqMltAMWYeLWxZ4soIZ+d+CXnAnJWlCOmjQD+HNAzB5YKwFYskFG8qhPQri2wYGelwQbo/3obkwNItAHs7geBkzQqbQ4wi4H9kaaJ0A/v1CgTnCu8ZBcqoil5F+jneJLgrBWHIDPlJXgn6hJ6FT4OnRBGlaHBX7MXEikzCBhf3zEi7RQiK/bzAHl2gYCphK5Tap+SrKA2Ta4wWL8phlN3ohUUVjmDcc2+j21/m06K8ffKzv6aTJoKoXzUR9b9PUg4oFeCMr6f5qhobtYlKdlSM2wzDrAP+u2IxL4FImE+voyJu2h51v6NaP5/8DljWmy1+t1lTfouqS9z3sNs5DSXrsoNSt0gAAWPE/ShHk0UFEvcjHE1RaEgCVcoggV0JLFlo7T4eymyCMLStEnCw+aQEMFobbIqvUjQoV6e/xQ9lvVrb9Ao3qWALKQI7J2jVh1GRiBcYJYA8mQtECs6EimcCSEWI2quM+2u4a6VKJcGKttPdZHd/vvoJUGOLqKyyCg/EGysb/IBAkMY2DB0el2LugxOfOgp4dziQ3Dt0CY77mCLBcVmMCxYJhCqR6bjUgL44rpEVpg9uLYuswf3GXR3ia0wE3CVQscd0g1QgIfIiQGc1jjYLZJNCcVE41tNLRzjoxAJgAtbDZh2nO3WOuIFC1FwgSlsoHooQl4EtrkuQGN3/oJGUd6w0Rv160NWMhnf4dyKow++4MKnBT+2PWDcMoWusL8ZQqcJ2xdYLlzeVG0lykCELmdgINlhVtxVwbuvuPxTuyBbiqm190NtIW1VJM7HhhWjzpYf0KRSlGX9gxbvJUrnnh6cVPBKEX1WQ3zfIsAPxas566RtFXU43/4XPLUqA9JKip4H0jYokHHQrpx+5Fqq2X1ukSttUMkp0O0jr4sGWpFJsjTnwqDnAVE/6I8vVf8wnhZ00RU9hOFC71Va7DbW2NR1eTqP3aLUZuBW0jES4A8S7gHgnXFCWi2kAls3M1dQ4Y0SF9EmYRT5i5/g4vtJbbrLTdSEyUoBkIkwov9tfKLB7/yFWC3TlcRfEVG6GRSp/KMu54xSHnrAkwYKiNxDrSwL+gv8aqwKJqg3h9IzCymAm5JC/lIIQ0RoMZfoQhOOuflO9pcW/g8hdRxEfQDggfOzhy/FfoZwvhERBzuCRVlFWgmKgPGSwIdEzSWbIWEmJaPs07zfz4WVS7dNeSoPzmfuVMnbLsOPhuxqz9sYMJQKfj/ly9nV2ql7iBBF58ejhcQ2RhCdX8a/Qm9F4zYg2wew4/zps1EZlgqzWIUWXEVV4kJtwwce6ig509e3HalP3WajjoUmMwxnAMNTKXRzs8nMKGXHXkBp5WPkMQ38LayEur8SEPSKglBRLCqx6PcBdhc26Rr0xJq+UgkBftQxZQlegseXVXq6HTSDrXZhS84JO0DBqXUW2e1xo0NasRWF7miSS2FdtTJ6byrbpYY4+itoEmMJXD8kLtTuZlgqXUBZUkcaS6Z9QDX0djkpBzwSMtyjWFTZy83U8jKch+Mh1bs6XVVo4r+P8Nvhx0NIe8eBXaAQx6xEq/KHSotfIysO/FiVihLYJfOHjFiO4I2n6Ab5L3kaEPxPjHk/jZgoswPJKMiWQtmb1ughc1VAbiZAqUKV1WwlISaVoUSEvx8aEInveRJTYjdu44fW20348LM+4ma8+nqgVcuCfStcDLpH3N2htNSsDAXfN75PPF2mLSl4JeFqMr02G1lzp67TYjoSX6uSl3Ftmr49FD1ABnl9qnnejbyLQCsHwT6U7VEqoO6MDkjcrdZ6X6/zywfettDqH5zUKkXkDCnkD54enF9m8+XyvViT1nyBAUKK63lA7Z6VL7ut42Y6baT/srodFUVQhraX4C1SIS2htgUeZNxGihXm9G4MLbpUnhTxZOJ4SEomMIYYJa9rVM2M7X5VjWyvlY0uXIZRIphqCSFaSMl7HS4CTbaUxWekOJfAac4Cc8wbc6adXeeyKXxVmvxXFg0+lK5CUSKEagK+afvXeJH2KYsWSXsb6seh77RX24+U0+TF0L5J9Sow4YZF1Y6uv0co35HZS7tk0yuhX76S72YdhtaXc2Vx5rOQmTVBSv/olLRcp6ubHfFJrtJfXcrsot2ngnpp+9UubT8PW132RBrLUPGjX72XzaXMah8sYBgh9LsfGpv9g46wZW6ddNid+5WYSvcib8sBtZZGwdClUyVMLjYHmHY9y0W7ZrdTxK12gVvLQAtCk6Vd/jbslmH8ZebY44AaVTxo0N5qV9U1c5mU+Tf6clTbyKAPmzUo9cAysR5XbuUsKS56JyFmbfvVptRhdBYGLpAEQBsjDkCClfvW5cb1O27fp9c2/kFzbLrkllgvZa95GfAcSF1KQ+LaWM9klJMTsNpDiayOkmOh1pq8qAUqUgdckQLv5q5LVykytc7M+vthFgRpUmW2BV6XcuDD3P9DniZkUSnfeT40rO3YkFKInX0fsFuO2huGFT8NmfLmq15WbfYUQSuq+PPCQElxFyXoCi2z/5tfTcLiEfpZF53kcco0+vhMSDrR08oom1mtn+3JtLUtbWUW54iEZGx/J32vyziwXrqPVL5AhrxrSmQ27VQYdr6f5h3Ykzs1HxQZV2r2c1+tw0o58i+HYB1bC27B78U5808KpYjvaBiMt9ArtqW0W5OjLVtiVqWVE9aidWWAGnsG11q0R1dcoCqtx1aCQlfXmqr6P6INJSrOZB1MKZzFhD6kAQ6ZYodsTyOkAZ922iLpdImBtmHM9VT5chjq/Dd98IZ5OCl3R4Jwd4fsPwAzk6HbnFWJ9loazPIyhPjUAAgrcDIpinb3Kl1tWBVhzEqQf/XgcVQnWZEHntS2S9qYQXHlh1mAbYPNRCcuJ2LVN294GPxy3feLZb0avHuAfyiEAwg78nvD1JHiakOkQKC2xAoCQCc01Wq9g3iSgYW+DFmzJTSr1v9ck5W3wGfYJ9g/QxagauTlNy6J08xhQOukSwFYw269hc1agMYKGaPS5xkH58bamEWikRkl6RMQSPdQu8HzFjXtRmlnScHjpn7qhqeuE/mZVR5DFlGvIML8F1gp3iD5I0duCSG+j6qt0ma1VvCY2eRuH0+XrqIC3menlmDz6x91Rv742IVQCrGdCy/VtPKmT1QS5CqnyxKY4/lQQTfNJV0E0dRpXi/E+9YSnFZGjBpASmKX1ru1Ai2PiM1ocZgLaDXF9opMEKM4WaFzu2192RZESmP29bdygKWveohIos3uRtFFze4TLuIBbBVwVmWHj/gpKMiHMNGecSvhni21HQDPJ60XWHoFcelgU5oLAPYPvIARnNLwy/roULz2z/SiBSilsbMLbvNNpUGIpr9lq5p3a6RdPgY/sM7eBjn6WVmSeLFS9sr2zjKVKirbtWPKe9vPSEJ6vyh7MtD8SapoRlUynWxm/1mG+qn22MN1S6FYcswUuE8HdPGu4onUwP4crClJAD0kBYo8B8S+BeSgJwMJLqbhvkNBg96mXJ1ABamMCCovG3uTns5Z8kDAqtI/zHssdD1lUkf6FjLWI/EigPJY7OCgyQFHasjJCY76wd4xZNZ6tKVTSpFtIISUmzvU8fhvH43R4nRZj4+/DTlFVpNn7J8JNs2pdsfZt1sKHz7cm8aJTm9Tl4pdqzHcSSu4XiPSETWnENoGdCQrvSoEEwBLDg4IHvmcXCjBEjBpKjgfok6RFktAIbbQC2WDKMGB5Uf+plJJNJRQQHsVHIK2TdE0SLRMsvOXKn5f/pRQqbIA+P0yMFLfqUp8vm+PyP1r83ix1N3aBcRnw738fttvTP5Z1/fejajYtZZyPFA4ul+OX0/jfgUfM4mgrtqscdfEOT/5kuV0ux/mgrNIycysNTe1SXuWxL2mu5tgSiy4YvUzMK//98+c/r+P5sv36t2n+vJ0358+n8WU8LZ3vz8Nx+jztjp+H6+VtcfvYDEqGMTdfwrMvdTkNh7P+8osBqXewxKBchU0AmNQ0+ohG7fTPz+fxsvTpz9qYoTI9SVMKntM/F/zHlxsA5MuCpQnHte0SEkom0z/Hw3Xvn4imU9uTgYQdhb65wmympN/iTag6GMyshqcHW2bM34bX8XD54/P0z89/3H9zxq/8i9gCNE8v8tiXdvNm2N02pz7LMn2BsvmIYm/8AuHYH3FMebyPlXf/sdl2u0IwmO1IlRxsiYteVTZUmfyZVWMFt2Y2DNLDLZHbsJu+h5O3eCbAFB9wP/z8sh0uw+tp2H85T/8KBq7M5CV9p/PLS/DQtmxveqhDcFP2IZweKb6NJKi7yb3zOJzPCw4onMUf2+qOp/kyb+bdso/8/e9/LJ/71/LzP/6Y/vl92F2Xj/PP/zJ+/7e//S24D7Nam76P03g7dr/sp8OXy9tpvr6+Ha/BblDZiPlVo1+m/ThHhv2QdNb0z/Pi8HT6m0XY+VCrKzLqf/6fT+Ze/H8+LX5J//zj806ZJvW2XvVHLh7fS//rel6cqfbjP4btfjr8l5ud/ziPm9N4sW/uYztH7OYum+iL6a1r25WRD107+l56vpflh/+6zN/Gwz+Op+n7cBm/fBt/2Tf3kUg/fnPXbfTFtP8V7L//yCt7IhUmK+L9t/QjPovsS3/Ejzh26fv/CQF2RW53NtZeYjx8/88n/mUffIzLdjd9/bKsvn+E55Hd+Hg26vISgm3brksmd0Br9YXH08dOz2WFhTHWB8daVmF4Y+bHSQ52PWynzWQE3GZhNP1profz8DJ+iYUx7cfCSrcNhU/9sVP6R5gOmRTF9LP+OEe+64dCGA1qEPjrHjLSTcz3DX0fKBxQqUC0YAobDzBtx/1xvoyq1WLniKuaktN2KS68TNrYzc48oYtZtH3qZh/D+vWVrjAnF8AAqZb9pNFO5unuqjDwSa/B0i5DbVhnon5jddEdyKzOLffxeTe9jEuUp8rLVtgYqzVHB35Ejrpu7VkkR9zf04W0ye811WYmWhDpAIdKJ6gDJQHUvcjfbcD1b4QADHj4uVkLm178I8y0/QF8BRVu1rppI4ePK83j2L7mp0+KKcY0vho2VJM9aareCFUwjNzzv1KkDApKbMexn03MkVAPg9QSu91A0/SweZGKYhHkUlRlDHdVsWBNMQP8RaxDAJxOT0kEdsdF1wDygbTIE315pWl2+wnVeZoLA2PVQLaQ/QNMx3Vc5un1MCu4R2PXVz7spHq/jLI+N7sHbcu2PlnB9jPsjv55absexaUt7OLufj9uJ1WBzuvWOtl7ikthGtcQOBGE8+r5Jf3af2l7zrtpjXMvz7FycnqroCtWkUPGLhbUn2KO6hBvTRnETvv99aIx0qWdImOOO7pTBR1kdznIDROkU6Gjje1OGJFS7giYStCshQIocTtUF6nsM2+/QKzmQ8A7zztTKyfVfZn2x924/CYcsMysY50iZLHOmtjeYxu48HsI5RqlLi63WWxtQiE3smlCiYKoKGJ4o8ACbnfvcwn139rf//j89Z9/fFVoKlP9PBknewMrJKJZC2vdIYHdXa59hrWcXwiTYbmZ6FfiprThgImdI/qhse1clmG1cXZtVhMp3FbbSPxpf5zP50mt+4VD8gFIz7Q/nubvilKS24lZ6rP6eG5TzsZ9M/Ai0ePkLulmNgFj7vtSSvjxQ4cg0vHW3EIDzwQ2v5DXE47B+AHWwe43NQB00P60NeGh5QiZd+CRXTzUd/iBguuQSqdiBdu/VFOkZDkdu4RiIzUZ8RMCYmoyiiNS+Ar8DgkcnoTETQuRLCFZHir3ygiBkleMFfDvKIMlxMZjSM2YQi7+ncBxMhamfi5j4cjWjK8gY2FGwMSAYmQR2cbi2VCci8dnjvgzrlWJ/0phccrJCp9u0IWEwTR+ouIMDmuKzQo/d8BtqUxDbUKa/grFS4KzkPBWuCthfUMwoLCz5k88vBCDQ9pcROgt4yX3u84GBk+Hr7MPkS5NVkeoPBUVaCrBCCgT0IXDZndVVK/OlMpcxZN7DKmAkiZIqaVtXGtCAe9jatq2yUgLGQvYguHGwf0R6y/CZRB7hiiYYBbKOY9ZuIZte3ugs7JvzCuTEddRdrNMvaV5fxwuwUlc2EiP5PG5DLcbFfimqBNlV5VaxYfdjselru9X82qzNA7dZHqSuu2MLCZq/1IGkLtUCZRdaZuMiRvzE686s8u4qEWhMqjFmaVoMetEjv4vZIS79K1524MZsLm5hyAEPjJ0OSGlSBySJEglJsR2/KkWtt2v77jz2uZF01LfDz3sMjuMJ6BTHLWxnCY8Lh1Q07qR79P2qijwZWvNShKM6FNJWlHCp3I6vKiKc2P2SmGJUySAv9PhVRE2itw0lk0v+GWgoHdnA3eej+Unt53J4quEASw9ZMjnAFi7NpkTD4kbH6EsVBdqF/nUjutBMwxMqqLGxhUh5hJ0zBofNoAOIzxWnvvHFH+G/ZSrOdBIygVt9I9yTZUa5XwXONEtqtXlEqEdDQV3p+8XCreHtRUKTaPKR7clBPCUcBduS+Ca5oLuCGFf4cXkRkZWtTRy8FOLn1DtIFUSf0tmtEg6hCcSd1wkDrjT351qkCFGiW3hdRTBPmeIeTLUoTKGFwy9EQwzCJei3BSCx+9Q+sVBZBhnk6wJeLrwdADLMiYJH6uiCTFwlKHxt8L9AqETZeLpFtEiIYBRuAyfqe+LEAvJkzRZQZeuhzuSlKJHcdz2pn/sJZ/HcEtpzCaXsCPjtkBrpa5PHAn3y53G4843Fe5MgYnASzQx8HbcXYa7oM92uuuWnn6Nh6VS62tQtVInCh8jgyz+kz7b/WqH8XW+TLdSWrydl0mJFm6BkZ03cZXzeA7FKjNvv4fWCs5nSjasFI50F1Pvzn9pUn0T8/axxldcYbp8+Xp9uUGEFD5TDJz3dTheumN/mC6TH86UrdnapuIRugCu6qQ+0BJKutMG+2zMf0962bE+j2ZgbF8EJb9gEsXIWrtiJZ7667D5Nr+8qIlXSVmpjg6UK6UcH2Nv5sNh3Fz289bP3krB5OWrCR9FM+Bs7Vx3wUDbJ8/Fl6SZMrzJuEFmOBAyVkdWSjo+Lj/9S1djbbSvKKJF1OITevD29S9+xpFALUOHr7Ktu6fDwtTRPaqiMVvvRZXoOh52ky9kZbsBh1LicSnwPJE4H6/KW9mE1QkvTcRlwvQecVmGYmHGiA8sa7qKwaFDOtwhmmhAKqOQcYMjEO6RRYt30PI8bhI5xPF6USAam7qcg3xEMyak5TlDJKSIRUfVZepUJJqAh/MiHnzRVZustSlZrmDP9Uj+HXS0y0Qr+HAeNU3NbD3nHVZAHMRB7hZBHPgLQkYEsANrOGnyJiEeLHrjaixw42x4YgZHiEfOv0XKJUQOWOpOmsYJYAdL4tiBhEACARYxIAb6dwKSwQ7mCnGF++dU22lp254lj/fzcdz4+0Fr1/mQvEnFk0Sr9T66DrAqE1/aMXlI4AMO58ugtG1bc/Wwf0bldQpMsqsjbA+IQ2InhT9hsrBDgq1OdEOI5EHawmr+OkTN4zm9vasz26GoFLhU0hpzHPxWcGOjQiMwHVFGYadkHXTHL9V2goj2KWc1vC0j8Q0aMIsQlntMSKuKnajnjsC1zBYLXntqs7yUhb8gPHUK3IAYxId3Ui4Lq4VnhC0NcbtwTJdfEID5HlrUsJ6FX5em8hGfrcdTxb7FQ8urlEI8sq8TQcVl2CgZIpvBlurbHy6RNyBohZ8Qyecs27/z+695ZwHBr7DFhmTDIq21E2IYZWc2gkgUmMMAVegh/hS6z3qq08uwUTGrydsQJsopgMky6mHYfRlPp1lhoGs50XIcmjnutoNUaNesTaXvl1OE9tbWP1olVeWkLhQS2doYqacm0s7UKXuZvezDpiei1e0KYACzCSkjdBNp7NUSY4CyagcsrIR1/Z7gDjM5it/FKAz9Oi7RvxIEMpSMCPBwLWGTkMFiEp62OmzEeGKVxjDCNDJjeJmEwK0ONCmZEBHEIDQ0hgyG3bkIUgUC4a+EqxgZ6IUkUuH7oGTBbTU3FKFFw1PuuRE0dsTfORlp3e5mUDG1LVaLZerWYnpUL4JrTAK+MCVER1U49yUCq+/zJsCANmZ9JWanHh30mwINNra8Vga16Tazd9njdz8UaRrPGwl7E0XTgKwqmMV410UUXtCtFDt+RaMxqknCErCJlf0Tu/jxexULTsQjVNht2npldex0Gl+vu0EFG1UCgPpB5bFJiZuZYE3qvmASuh+ol49t+HFhQsGBYAHmk20btzGgwe1yM4IcNbYRHQoaVrttHs5hOCPhwoleG3g+QCnQwtptbexyujInTk668bk9OWyA4iBAu1PWzZBAsYIGJHSGQ4LpFatlGeCAQvC1DDugcLSVDqKiZMUFgn+HpSIJQG7qAh6XlzjSWXuvcJwxDJE9U0I28Tvimdk9hTb3X3ItJASUosMxGCeODYFVoTmYgHaGgM5WIAm4uKElWUcCsP8RstRagOhfoU3FwqS/Qpv6nyBLkSJFsiB/oio4ekRCzjdE4AibdMJWBQg1RXaRFodo3gi/SzSOYt1xlIpEJ1wEeb+b9BXKx7Gkwwp9i12+BVClBZKA1fgWwaWQkca/o6A2G0yyIMYiOo4J1iDwDlJlx/NWO4N46vPuqg0WOYCdRb7SOGU6H5T/ayuzXYhXk02J15232crSzXne6Zi0qE2ym3TStJVBp/OfP47b62lpXg2bbz/Gr+d58228OG0ZVSry3T/4QVZyZs/nq9/m6Do7UU8wnc6X+Tjv5tdpM+xO8268S+X5DRTRywRFHAZ/bueEc7ew1qYVtrC4jtT2jDtbxOCm8XAx7stzzcI5nrn7ctENj1+SfxGBScPtlV1mBeWzMhnR7nLzlNhHzCb32cNwzYzJarevUl/W7dKNO0QYk4GBEoZiSHwZSzEeEnEO8V2RiIfLMBrT1JEIBWfUvzEuicUgVSrKeOKtvrq4E4stSE2JFHw+0MMjrjZWyIkVaNaWZXDuRws0scLL2nILCS6xwgvjkiAaKTI2Z0FcyQDbYrwh6C+RKIOw/xLQjxJJlogokGZVOaMH0kve34Ek+SRW3EHMwEYPTnZxYvM812d3fI+KeDCXtsoivmJlmyxgTN9vs7XyaWxleUJK9j6oX93uzYpz5VYIEE5FhpM5S1TSFUrBNI5B4unyTuSYhMy6H3SiHoEaszLifmDGHmz4oMK4TQLHFWIo+H+BtQfl6cotezBbaxqvuKjMhYcQeomm01i+eeRYkEBh9ypokycSXGxwYILJAyIJBaacNnRo8hpi2zXSo7riT0h1SZ7IwyNA8Iaw/bWkepB/SIopOtAdDr/1aSi3e/60FrwR85Hg9pze2vEX0Up6cvMW2zPxpdiAwWPLhHcPFAqopM8lICDNTOm4KQPcTC8ipnnY/uIJWnor5mZrymDcNlsqKCChEkBmbMAJ9aPLeVTubibuq8I5/Vuw7cUDKRa7rf8evnvOi50J5MB7DpmLMFEjF4E1NmyKGRCEorKGhRNhjEQJkQUK3SXyZ0kZxbkMYXSBdG9xyqKGgNhOZM0d2P4d3D4SnJXlHf6HlomyBSXWyEQtQ/7tepl2f1tsSP1Wq5lLsHbpagHW0OfNaTp6GUpTm21o9BoLWtvm5ln939PlophRVWUd1Szvos0gKzT2rJ2vSxPaw2yaBXVRG39cDZoPgnkoCqexE4bLMLIgEc6vT0vCRUqJCcF4ZDiKromc7Ag9GYTa+J/HS/NnalHnZttvFQf2v3988zNd6cWb93UETBRDjTsFktglFlFln4dsum00LvZB/or6Z5GRso54//GZrKvepJyVhN9H9Ckfhh5+eGlqhSKKKTIbAr0MqTROalPjxGUcjOSo5caya4Kd+G308R61iXbkJUQTODmwNuMxjRZZ3Sbo4322PN+U3XJlyvSkQJvfpq+flTWOKUuSHmc33zT9/aPDFlxZZRX1bTr4QgDSGxt1OVf/cls6mNe1yzUhb9O6rQcl5M71vhDFgifQux21b3gtpADYMlG/zjtCCrAqM/RTc3QRchqwAAQLFb+iQmAIla6iJnwKPQaUvoom4jcYe527YfNN6Ria6PWGIbAtRbYLXXlz0zf7wwZUu+F49ifWMsIHEIG74fSqGE0fUi2/jaPwabaS+Kog6TakcpdqE9QCpHpyHTX4KfEK/Iv0Ji6lgm4PmFgUpujoSWeHT8u1Nm/j5ttxvmHiFKZvCY+4nIsyXzuJz+FYuTQt/EQwRh45s+ODLnfqzeTa7C6wUFzZAgk7bRFjy7K5tR3y2hY5wtQFvi63HTNal3EL0hIO9vRl+LO7Tqjv926LdFOjYh6KhKOzI7nlYsrcMeFXwYskBvyl3QVtQOdvyEnNe1DNtsoUnBaiRFVqh/g1+2yrphN0TJh65Y+4UdcmKQ2b2M7/NflgWDtBoxccgv2cRYve1rXYDf/y46/ejNqXAAoDJlbav349GJD+C5cNR0FqfDL5x+GswmRr9qBWivDAFb0qVKZYH5RgF/z72j5jlvvw85vGTBXQVnOtJnNE/7D2XlFHOSqcHyveldK3sIJQF6NzJZVUGrDr+sslVA/UFINCHhBmToQvEzHFlFgqT1M+J8ywiNt4oBeMO/6utaZya+8nhqigBgTLpCyxopAsMDUiGwzFwBLkkOUOdQbX2F5RKJS7cmJ8zK950amGvy2Zg1pFgfS5IKGmZqGKEA2zgLIbXxRdxHb/YjpK4TZKs6WOqfF12Pzy9wVzrucdDIITOr678fB68U3lZb0B3dQSsGC592DikDorBU9YpWfIhkJFCPaM4p1r6NhFZVGFNIZq5MWf1petqG3HE3fKI9UX8i5FGfRRRNZPCY/eloPbjcprz5YmYh0rdaCN30df1KAzJz4WNI6FoIcG7l3t1h1smhsXmcT8wbmvIeCLtZoi7uFFjqpyAcVcFulwmL3XW1y5upS2LVIq15r++cfXm+faH5/9gkduQ+oT5SuOdw4G/JB9x33Ayya8P7NUvGq48PZSFtJPxrtuw9szq+TPh/tx/nzz8dAjfsRTZ3d31PNHslOA5yOd1VAf0V/eTf/884cScM8+YvG5u1nZqIE+Yst4H0jh7s22fnqkryelGLPsAM8FNAJb1fjoL+Pm10arWSQ0ye+/IyytZE0IqBEBR7Q1OWMWIG1ncpwZj9PM2sZt7KbXt8uPcfnffqXaVP9Ac73IbaGl3bT/6gfQdhl3VUtsN+2VMktt9u/YLCfIQR7/7jBN1a2Wy30Zf27GUVcCu0aao+c4rh/B0fPsYhla1ZNzc4de5YSzm3QyYS5qAiySh9P5Mh5UKbA0CTfoNrtucHzMy0X56pgZqOszuwQUWb7MdwBzKRgeIj1FyCjBS2G+IxVTgNu2HZWDVlP11OIpplObhmYvF1FVYdvT699V6JmVAEIpIbptxS0GstPZOjLXMrL2JjEDnhbkhpoUhETEOg9bmFtrcwEpGQXMa+AesOr+1dCm3VRLuh2lcjN7z1yGTuif5aUolDwZ/NlDbHRn3i4K6848+Geknbl1J8TxHUbZVU2JNnnSxic9UDCNiDsBHEkIoxOLQjgSUaCo/gh6I88CO5yYN37XPLNbIeL2/xIQOkQcpPaJhZOgiA8iFflUlATL5CuPptmvntTCYf1TD+A/9s9FJcqdJqiWMnfrmQ1Cra9usfe2MbJqKsCbXwPFCeHUs9RxwlaL5wRkIwmWsbUeb2GfykLgReeTxujTRjXonhn/PglH7zwPbw2bdSkhmQs9WyfOHtpZ1OHyRNAgXQRjzgSJptn8qrZN062opXhkmTih/K/VyUZZnqHEQm0WkFqFSksMO4eYXOqzYU5LhmiLnwLIhtBUFsyEd2vwCB03bglabSaNfFte1CKV7HPKe+9l8ZFQCKaCTx2yveLhF/86FlZxHOsWY8RxwUlD8JS39dqz+vD6ZTidBiWEUrTe0+NlV2t3ycNrOGrfevw50Cj7lSJEGDX2GiQ/Chodjym2auTIoFLYknO+rVf2SOaD2owre7tEVx31TtE1IjIvgXCYZ6/YUNkMiIZlY2SBaBKCjy1s1h5cy/hlz3rTMkPVAF8r8VsExNIrBGkoqa+1rZSx3MrnP6/DaThclBTmwh63ulOoez4g9sbQfvZYmaYAaDxJLhT6hYJ1xGMCXaYm0duef/hxh1DC/9QzYijWzs0fKk6obKxvKg0++zWM3hboL6DS1NvfcD9s3qbDqKJ6u272YWTRftCKuWYvGqciNQgc3zs+8qJjNh10w9Vs4UIV32mnm6OOB61YuEi1/w8nvvvhm/JWs5seQQXRGFD3JG168ioXiv2we5lPe/8LNKaXMqX8H4IS8TEPw+vNA08VYj/ibnEf7Az58MVZ/oZs8vbSXlQwyaYQPIj0Gt8Ph+3CN/PrvaVd/E9+o4OyIykSva7kQArvm5tUjUbQ3RJv8vRN7WSN2cyRRAjbFGgZUn0HEymH2ZjZZb39DWGmlPtNlLPs6hL6iT5rZ6NhbtdRK6k38VuVQx2ERBgUIgRljsoy4L/QFyFq+RVhswjmCsNo0pNsa579gktb5L/P+vnMb002dqKGjXF9uftGBqoFSI8Nci2PWKJkxZ5cR0HDzOMSoRmoh9hLBcsQG3a16uI+ndXcEInwIpxAfDG71c9Lqe/UmMcUiDmRkiN5jaL4GLnoT8O0IJfasH1EG/LJVvXzy3a4DK+nYR8ZvPQcESKdjaeDL3/9ZTce1LitN25kyj0Zd7hcxv1R9U8qCTCBDJXTj1lxlvxcbAxeT/P1sN2elvDmpBg4EnlaVCshvY9xI/YIYsJ4vKq1Z9/PG0L2NP/Qfh/is4FeWxT9ukTRjbtMh5D0IDXcEPfcf4AkDsu9NB/qCNtAUIY+CI13WLytu3BaWLer77Lu5YfiAkO22fEnFG2AJWJxMsar8oIqLWZk3OBmPmyup5MOrGrpj1GUzep56pZssGJz4avwKe+EvN+6Wsky9vUuCxBM1lZOVkoprF4EL6dh/5B9CVyfKylxHdM2l9ksiMyV3aPbDz+n/XXvr4vcW2/YH/pIBbp2ZFh0ImqXn9fu/K/hj+iiCiGm5+j7LkSArVjjSsWNqybCkhB7FnTSAWHrO/yAklvLE4y9T9DbY34v1Pei8gakt4U3Fhg8GRQ1ClS4oblZlCjrSfojui8sd2C6lOi5VpybghKJD8+Fh3sGZUMogtUtOwpImsAnbii1SaEG7XllzB4Ht1DLIJe9kJqijis5Svvh5/LHEXBkLc9EylTW7erD4Dy+Lr8KNsMiz2Sdt0ExmZ+qWtkZ2g8/v48n7QO12PpKLDoqCGXJb7dOLGzvl1NtO0hGig5iKBaqW41YqKS/O2Shm4dYThFT+ZhKRgUSHOGHssKHdAETM7pIuH9y8rNBhQOLEzhmZh8oHETfqF+86uxcfhVncT8OB90hK22eu9BNFJxQiqXbAf04+EdEZaLMSVBwU6EssPs+fgP9qMZRErEjdGxJi2o0yQ/YFKH+nJGAha/CPliVSOTH4XxVzJm8te1IVn6Rvap/1L1knnWRNskn2axz78P1At1pRMdmN5fhHOj5UAKjGzGcR0t86dGGJwntnMlCoJk5lB+EXbnwTMS7EX6GT97S3fBvOC62w9tx2C52UmqDL1uxl+FduJt+voGJ68Qi07xoxMdpQndsPAxA1KHTu8ZdhTeZvK/redzGbkxkDWjYsUFCp7pi7WY+ns/DqzJv8nIeqn2iKyBjsJAxiGVGb9KiZ8HLpgc97sWveXmC1NhMsyLykqvQYNRpVaUuN/55HVU5O696+QogTYVjGW7bRb02qhgvw5IS+NBQa1sp3SEFnU2qx/KgAzagQHUmCmGrUSFLHULj5W32ue6tqdmlO5s+VYh6S9RRoipSTOff9vm435YKltbAPZpE2Xu8nKaN1piRH9zd4eO9QSkJ6rGtW++0QSiFwBY7NSwmAWWAvTXHKUvp8ZI7KkKWuoutPJrn2Q5Je1+iIS9NMDIVn/lZuKnFwtDIxTan+TxuZl8yYSGX2p8rbTFDGa6YvFYomyUVhO0pxfv0C5GmFi3kNm0nwr3GZRelbUqXKpxNu90UubnabKM/5ksZc/jD9GuFGCQYbalPeZj2qtFS21YPBMxX2IgSfhm30f3E34OHdYgvHo0NzTxzB7LTyxDKqKFeSiEKQGuqEtPhqojzxYd4NPeBdHXSJtBmiZHOt6aKv2FVtjYDP0KiHTSdgy6GiY+tqN6KaQbF8pQZyX5ptN2T3r9d5tPSkd6F2W9t7hCufxMdW7vNFiY/iSkFJVAQpuaJrWLeanpxkyAAAJhui23t5+30omicNlw1Nb3m7XWnbq6385QCyrl0SVjXFp8P82U++KjIvDTNU1qgZQrbJXY/n5SStklxBykHGKrIaAoWI4u2arhIGMWYYV2gpK1HEsSlkD397zrTrmp7aG0C27qU9bq7TOqlWquLPPCEAthtwI3i0hdV9UzrKWZ3kxz/y+WyU9eQBfc+lsYkF4Ab2LKck03ax60+TwfcoPpmm6z1hluZ0i7DHRXlLS8+QvPbX9UnsgYBtwy7HpwQIAGgFXdBJEY9Du3WTsB12ZFCkQLtbaEeGBEmlxZT6N5GhMlj2rGB+52vIxgjJgtTDBVapMWi99fAb7s37bYr7Flt4jC8XgJ3hdLOkz5e9Fuuo3FSnS1EAOF9UWfq7OPRK1pXCVtnCCBBGIAqCg+YZnT8weeK5L0t1UC6Sm9Lvt7+VCYRspCPMnvlJhSgba2LSCBkT336Bki/Fu8sI58GgUtNOGoRlnsEbCbqdRjxIYw6A0a9+pAYohAi9IK1j5713rRum3VifRhduYzmR0h1aQGRqERBuTQ+XQlgNJtYiRjhMBzimXACovh/IRPmbfqJsKkTBUUQwIL+79z3qHa64qkSQ3rPty6iLtPbqGJYhyitM89qq4NEVp/zJZhH8XIHqkpl2iCmDGcO4+twUYTY2qwEoXxAJ/BKtHDtrXV8nS+TFp2zcZWpIARj+RaWthyhO2XoSyQUW0CZKLDGAcsTpVOh3cKd3+Y18R438/64AGi1+UMn6edoTbk9pSGnEu0tqmvnEc2d9F3863zZhkowvXQtpuIQDmV847pcF7nign4aZHPO02vsPlggBmWrQURcE9Y5tuNa6iuZRBG8rtpNB3Y74NGIf9MU/iuN38J0efNhwn1lIkfpqGc3MQ7j+aJ9TW2VlgzQkxhhLiMoAxF8jNbW2pWqw3i9nJTxtK3vqbQDQi2A+CW+KzaTrWbCdpjr4CAkEn5JqGMJWfCGsaTCCsfvyWfFmCwf5hCud4eVyLIM7Z2E2SS3CCHOxgOXJwztEElcpKUhKYwx+x/EgZSeo9hvbcfA408vZSxtd3l3y/RXEV8kKQv/cWq2MBmJhAApyPzyYCef8dTLQnVQFn2+e75Oh5/+AWkXFFMHpG/BbkKoQ0IfgBcSFhDj+OVhnt0we+Dca8KsgLR9Yaxku3kcVCHXXD4oDCDKoniZ0+CGAqarGNQuL6zd80LLGnjSxqEXIGVAujWNdd2/CUEX7lp9h40GUCWAl9y/wQzNkFoRq1FQLBdJm1BRoyKDsFPAyqAiA3bStJmO1H6nVhDwhCSOCzdRsi0iyBCgyQros6EKVJSEteEwKYERIG6kBKW5hHW2aDHRqAGhW8Wpx/yBPtICmEWwFgQFRdmDmnL4C5rVAB1DAAiS6KIj0AtzEr5w0pIbd99ja+2FyB8Cz0wtnJsjmb0Fz1ul1VpUdgyVisjm7fi3/1awE5Ml1KA8TzuTZKdh0ZBLXVrZs9Si7fmpZ28fSO4nu+28nw7DbqftFkw5qjWsvoMfP9amPgT3JEc5Ba4z3EJY/uQab2iJBR0PsWLhfBddicARJHqdB+0sW9oa4ck5c1j8Yzfz1QfPt5L4UqAU0Kxk7z+GvQ/mlSdEPR8xW960YQ3s2bDfJz9ya4WA4Cc4ZeUPS+EVQ6vY3iqLrovt59M+5GrZ1Gbgn1Kop/ug/opoerMW4IaEJmjegC8ihDwTpZpZoSLMtcfCCQ4xNzzwbTh72BBye3Jg7ucCPCF+5HRD3I3T3M+tPpq4gj8vfO1xggrYMV6MKDNAZUQ40sOlS3rT44SHJg+luVn2J2pTGPH9Tzi6Ruz8WkQbbegxlrfYp2jn16Lo9czEj9MJ0YvwdsVf/GYH+b/kFv+XnOFjiRdGEd7vYFzTBZ6O73RbZczyVxzVI+7pwh+druiMd0DFkr7nVAFHlGMLZB3my5cfpynoieWd3OrznPEyxDk6wO+7CEfRuJgqUTbPVGOfFIrni+Y7F7aMg3QLFk04puhMp5ii8ysiQLFbdIf5Mr1Mm6DSlRemWgi2PcqQcIpJa0CCS1ERtuVK5I1odRZThaLFBkxtKU52+lQmTjjvSC9t8BYE76tcbYjRcRXuI5W3/9/oiVx3Oz+UMV2CXQWgdNdCgJqwQAdRH6VzMjdQao96mUcczAvMbZYUmpa/w9mR9A9vadiKOxAnQXT/xykSW4WxXZ9fJrpGuT+Eq1XQUYRLNdukai1bPtTaffr2U3IefJ0u++EY4OxayQ9lHejxCZ5vnNfdzh9Piv/ULMXEbApxOdTM6l7dgEQXFpktjHG47r8GpphWBNuRnYk6N4ojRcfFhqXIKJqClCQuosDRCPtTuyd2u1W1cfQ2HLrlpDSLkvPX/1ZGM40J+wNopXJTFtj0yk1JRpcZV2XJiI76FVxtrClFYjtmrYTbQK1DvGjEjz21KUH6phc9vYlaW/9r/noeT9+D+KG3xRsRrrtgJTGsPkmL2uxEpnC899H8NmwCooPCeGPOr8eISlvUROOkb25Ri/KWt6l+xGywtbkH9wH9ud+0dqZph4fzi7+ABOCOgO82UhOiQRK4MC6ExvQHk508bncaYsuIerYLXcWgg+YJ1YQiJgXLvcABFOjSQLwmp6uSkLBE1CI8bLCEpRs8sXPu38USP7GYRXrGhkksUcPfssAnEjUGPziAhRd7BIkUC5LQx6JoXfzwjiVv2E4ocxc7xqPJG49xYkRihzyTN/zXyMkh5YfQUkRkTSxRDRtOccJEHdgRCiK4aCBsnw4u5ImFn4T+p+rvKlEkkvMZonAHJdEPpeyCwQp+J2RQ+TsTIza/eAt/qS7IbBARUhuToJDtqMysm88vL398Vi4xJhY9lQTOLy9n39anNYnxq6zw7gMqeXkbgJSQ5JynrQ/e72zJtASKaPaAk1XnfQtA/ro4vBrrC7TsAInjmQXZr2W31S6lpcmNCjXFQo2w6EX2i++634rIbNc3t4fDsZa5a0YrAxvScLuchsAUZgWBja3MzlY1yEWCysvIJ4I6CbR2kLu4JNFtCyheY/tq3O6KzA0FQ+pXuzQGJQap+5rzd5EaK/6CgHD0A6XvFA4h2R1NC77jd+9TpJRKZK6G/7+fgQ0pONsjO0aw4cHP8YHRxclxLOSAPQmJbxwuUk+fvDdosUDUoqmZyEQqfj0zUlb8uN+y18luJg+DxKQcvg/TLgjTq1LSraHTV8GcMp2Yzpq+XZikhdCYUcy9ShS/sF+gvtqmFtuiyPR1N2p0eSlcoj+hOF4URZghx8f1nsu0L3NSXuCK49HcasEPWA0o4xMG7iYiVj5SMGoDuZdhcz3cX6ETg0YGcs/apZI1cfFQAXEfCI7pQGKgV4pqJeJjiQrkVhJqw+RkyYYyBXJ7QUsjFlFT77CkITBVZ3BXlaCE4ydsOREJDrldIZ4VSBx8xyc8F2xNonHDchgT+dh29RcKlVJ/hQgyAEEIJ2HTAsASIUpFXR4AMoCrlgJUiJ6riP2oiIWJDmUjQ+vy3DYzU67iFqfa5/chtAP3wL8tonEA/xxGas0e8H08KNnvxlNJo2Q5X8i6hvV8UNgIM+hA/smTCyk1gnq3BbiEmM1Yd7qyB+s+Xu1WH6rImMcg60bgEm620LUto+MUpqvAVaHahOnlhZ6kl4Voqt+gYK1oaIz/sTyxWQlJDZQTuipM0pMUrihORIgiiaMogh1hw4c4LSRxRFiRFiAKpEw3GS8AL7WqGzgf5tPxbTjc9T315C/qXqJK2Ah6NtOPp/kmc62WqVTUiVCXn416Gjfz9/H0a9F20SYERe2Z3sQMaNYOH/cTKOq+9N7FOs7AMuwiMnQaVcqaF5J9Gsszng98Ps6Hs9K0l3fZulpQUURs4uOjCmVyNbAMr7QZDVuXuvSLHLAv7NLqcfjzqoSRWq8bDVhKF/NHqgskR26VZAiWSrpAV3aqe/Q1eevczP7dwsaOxtNZZNWMMoghZ+MsGQvETL+x8blT13oIXZ82GRcp5uYykNarMEXFNU54FSrYlTp70RBh3IgYEdETAbpJuK0EwALsSsAqVxkBpgH403oh2jrPNGriGwnpMzlbDTjLcxrAFCIkZb6JgqSm3vwVLKzxmKegAVOZZGkA3pE2EN3uItDkZVTDwsy5ija1dzwGu/w6qsxU2OF8atDPzbOVweBt4FkZDZjetil7gfnoczZqsxHlGg7cDYCXKGqbvTgfAwBK2ZgGjPSrj4DTCT9vbDDr/XqKfJSZjtDkckGcwIV5BAFCJLsP10/NSJixFnFZBaMpoBa5/4NqJbQtBDYdP3VPXq9SFTclOmjl7u4VXG+6ALJMG7VcXtevFbpANaLMwK399hMSL8aHidrR/Xn/Hpqwf8TzWwUSprNGWFIBaAFpN4ojSB/dqeMOSIi6EYuaoSbi/lMTnkxQzQiIK+6b1a7agdMLKQo4LY07sdsMJRVXlIUXFmQ43DgoVtKGA4rXYLmgZkRMheAY4l1lSFBk1xT/LgKwzZBUZTiNJbgIBWGcxlBrkB1Swm8xZaOVHVFMBui2iFRnmBIiNftNySGBTnhDLe5FQm1ZxUGkQUHxMhbNMXUkczDW1cXIUSAuQR3vB91ib0/Dr1YDcRGVpsFZq8G53OIScC6v50v+GUYR9VNCd1EPA30pQ1pMbp4Q3mWsgtJ/lECFv6C/gaRSAScJklGFVL4qwiiUqjXYfgqS0WmYQzF3Qa8iD3s18HgdHA5ym4KRTA+iBsGl7GKTrxwBLaM+Ce1Aj6GIFkaMfEVH8JzlDHSxE+WM01Y5Q1lpC7YyXVxHLbh2l67dk/Aslr0xHFL4SJCCLKAtW1TwV2jWyDbeHkQbB9mChkWiX7KMpCqPlamNsspMYj5tJ23DXdlqGW5zSgBen0BYWTxMglRTQM74c0yvPgwr7zNboiiRN91qaH74bcriYTm08IchbInwa/rU0AwGs0nAxAVXEzmhzbJ+VPvQQXNylwmjk0zaq6MXVUQa2/YFz5tFJv+y4Pt2StOvFlJES1FiHbf7MexlPo3DYfsyn34MJ2W+mHn1KvUOn19AoUFMK0fRyQz81aMDa1WKoqgTmpQMGQjmItQrFhSEEC55xEdAWqK5FINh8cDhMaNAUOZz/pj88mRlBv9CuwyLPQUBuqpiamFtP2gF8B0xQCU0T+SVDCRw6FXZmiT8etHy7pWpDxLz44qNuJl9zbPCxhMBm1EmToPr5XXWBJrK3KuefIJXJdiwhEAfG2mnJJMK094Cqw30Mmp4NLbg/f0agfaNKX8gFG8SOjfz9XK+DIetGreyNfq0mF50VKW60thF3FD10c2CumEa/VvabEnZj7juI9GaOMwYefJDJjbL7+NpNxyP6v2WtrYbrplwR1xGPU1K0DgrrW9W2/wnN5L2NzU3I1aEIx1UkUhGUrtIelZkrHexn9Y2wStOlfLuj6BXhq0S3IBvmLX2cvs+nhaWoXIutinvKUzlj4Ma5cnWgBZ2DEvOygb3DTaiKZ7AZi03f4ESZine1vWdf6h2TG0Kd1H7B18PmXwFOm4D3EtDVAelPGwFyOVOFIvHLLZ3BMNQrQnxU5WAEC8X+Q+NorVL3Rogb455fpuO/qimgrWbAQADldTyxLytyJhNpJI/dAsst7MmN9/CynWJ1kjJ5DxRaf7hl5nr1nSwdp+ppKAQNvDGXpn+m8ztuDZR0T0Om2861GnMjqnQRqWqiB0NLIP7y6Y1vzfLv6xrFmGdrxFbYuK6r6rhn5mm0qsi/eMw+WtOanToaANVpLYwp+RxWCwjL2olN7asd6wB2cf2MDvCwTX1EWcipkOAbsrM+zhoG9DWLHOgmuLceIwBp4tXmuhMjVUgpFibR7xThJILIdjBq9w9ADfGTak5XWSJJuBfL3MbN3Eev4TIjk4KseQ5hTdQM+1Qguiadenz7Vp+Ql5Upus7TpccLzMRsdzG/vPHcdEw2uym8XDZzIeXyc9pmsbTwwGgcMVtK58HM7NcR2tchtQJV2HHQXEep9kNNK6odGDsAkMiKzsOPsKqq81lhPYIm0VtbYbfy8BqMeQmgT6Olfmfo/ovN/tjVrWlXjoK5shp8h49nD6iUfjsbfsVjbyxXncDF4CsTZwVl83bdTpc8sYX5BLLAoWkcqUeLQYtC39QWSIE/B3J+MpBO39M6TuIhmK5DrtxHHw72aJI6Y68S6p6Gfo2kDd/zQglh9FxCnC1DKr8sUw+MHKVZAx11Usss0tvidzrNpDSujM59ev8v4/Dr908eDfXCMfUT0BkNC50b3H85KhxAxxQ5H14JBEgULquDN3Y3QZA1gOV9djYRqUUiWBeoBEN/K60yCB2S8g98GRDYkmbC8L1azYbQ/C9aIIRtoZOAVhNxUP5KPHSA5mKJpOW62iH096xQdfj2Yq7XSDi5JwLJZxPIIEhlQa3FlXlFhVb9zXAgsaZyG5ghhcBuPzixoKfKLWJfB6CRqiHFTXwwg1o+08eePRN1ST96B3b/hgUD7vSyhJKl1/iwA1JlzlUVSV+QUDQV1AyHzcVeqLWYpv7xGZbjSz62Su7jfvoPt0UqLTnai9PJ3zYZuUcvI+/4LhPysbSo9AWZf6uG74juCMWsa13mLpcoud9o1tWv/MJlgu+nAJLjaKVJ62rvrYC5oBdZm3E6654x5LHHlIexNx/1yYC/vh6bAlWx774YDivHnv+oaep9I5HkF6/bxo9rPT0POq9ebR2RC/jKs3QjprdgW4UWXtuV2yAegfozGVs6C7hmGARRlL1CMBiqSTUiIrJTgv6GfCGGUUZhM815BkEjIbVE0JmsD07JRAJesd1JZnMDqfG08uivam4qoVdtfCaFXYaz3H/9sfnna9lkJcfsRZ8DKl97ux0qEnd3TRvffPAIsvlxtfVsbREUiJ7fKOwq12i+w9spfxuiRx4XF7aeLgoLVQb8I2qaZ06O0/7yU+aOrNtuM6J7ziezpPyZKvNj5GS+HmMpGogpQmgbQGCo9lwkaXe6G14NcPr0ixZulFpk1wJvfoYpT314u8X915UXZh5AVgIJMc7tBVk3UMxeABnyUh0EWIgBu+purNxgv2N1TMAVkV5Es2UuB47OmuCv4cYPqrHDpwONXOJu7a5DI8Xq5kmJpCf1jJsFmF/TOCVFuzMZd77h6aZ1q1S6j6+DT7UwxxuBWEohw6roAWtpQA1AHO2dgv4+PbrPG18AkOV2+40pI4+bhUYf8iM58EE7hCfgbjOXg0a5i06VABsF3kbmYcVYzs7z9NIBPEZPiFDFahZ5EcxpXCS3fCnII9gDLLexGiCRVLZ03A6qAZpY4ruoB1KnjNycCroEXzQ2O6ox+k47qaDKmsUpjlQ8vjeKY/S1mzYsU8XgUgUpc0eul3jbRHj8ev2udnXQA8m2XnhsN6bMNNPwqfs83OndO3y2qZ7EJZOwHFig9kN1/MUiBbbLbV1B/5jVN8lOqtNGNO/qzezG9X+uWS9H5qQV81TMpVM3NbSkyFLYQiKqTHPt2UZjvOkdBNsVCNoSZwBhaBBkPxAWTp0PVBjE8QEavSgiiYNMu3tZ7lntf90pronxVcgKCDUQOxAbbmIX9o1ly23tpoFRBwTickzT2fF/qvMyfP/VfduS24ryZLov+hln4e2XsjEfezMl4yNlUEslIotFslFgqqlNpt/PwaQ6R55iawstXr3nKdma0mRQCIvcfFwB4+LY/LQLb6th+RuOmyw4Pf98fn0HoKDRSSbxI5/EBefDvudHxJYtb/SZWvJWtOF7qDwAsm+JOhRbOy1SdJANCzomrnnk8/83Op4cXd1st1SSCIyT4OLMiN/uI6aZj1qZLu/oZ8B+zXe5cNPcQjxLnWrCs8LPZTsU/vLpVdLiJSQdNKQaYsnX8HbSlJkt0BaYrgIVx/iFfmFCwOrIeRc4jFeoRzL3SklIiK2IFuh5Zj9RQRS9TqQan3NQI1Tp7iPXFOsMvqoLqPSuiCEGC+gnZO+KaXwhJeKTpYKFJNUoKKMr9SIRk0j/9IRMFnVX8lmaU6ncN1pswcImgWSFYAtJxWVHuJ8O0wBjF8HCuTu681SqCZuVS+ylOLOkc6lx/QBAmYcJcEkoKRmxGUMITXbJPjUfD26zOF53cd96nILI9ZDjnJg1zOF6JKPIIg22CGRoLZNUtbGVLQx6WvulYJWeO3rPUaBfz6QfjgmKLMVUZd95pR1T+A7saMq1dmwh0oHIt7N+qB8Y1T2gwiTwFNPsR758mvuSd3tqVZS4eMFbY/KkD4JgwozM8CFoJTGjpEG2SVwGbiOkeSolzADrUVDWOYhI6Do1ObNk+C4yyIHL/Nufg6zhbZRdTUcUF83FpzVnQqwsKwoGP1Iusy7/XUO4jBT6d8o47xutsJzptNTwxWiCrbRkw7898OOsgR6JMZr9OvHveHXfRBbCBDuF+uWEtp9yRzOVyYBhkEDK0SY3cN+7D5e5udQjrzRZc8z/arny/ziJzp6o6qrFGFhNoPh2jejymRsBJ8IOVcz1xoGCDCyattOB6e/ovQCGjlr+8G7XEK6ZFX2OdHM7IqG6GoGZxbAhYI8S7QyM0nANlR02UbtzdrT7z15wVFtiADxMQliUCGx8A5aUmox5TUG9ZPt633wUKGud6/mXpikfXTCK0ZXYPPL6bI6o5c5yu40Uq4RPjw8duGM94XEvPchQ9YuNT0GFT6nGafYvM5RXkfl+3XsIR18GUHlhbAIymLW4AavBzjdmZz9+jSBpkejfygUXACDIUVsmz1bY50P04yaS8fkFJF+n9TuckP6CWm9hxAdDiAfEAdVVMHLEw2cLxvrY1g2yAhuFTQwPoyGLdSqzSYTzF3mH/vTbWUljHOMRqIx0Bw14HwCn41t4Ol+uIu24XxHZFA9U0CyACYUOEDUNCVneAaye5nfpwAKYAa9gcoIwjc9/3jZv02Xvf8+vSqpU9ZisdkMyGwFMswYbDSDEAa0qnZIKLx+Ad+LCyRAHdsgpw4MpampWgaPjey0Q8PMA+g9MlXX++v8sXXyToeACNxUatsXKKJA/yS4ZwR/TKMHb/ehn3YbBvrsf3mp+GUYlAwgtPhoKd9t3xQi+EF0fYu5tUNizj4cyW8p6dS8TWEwsP8ROpOZlt8cvPJy2gU0tLbSdaoEEObfI/Saecbg9K9UJIfrVlVsPd8CR7dXsxGFXWd3m959OKhUf+wFyXWA3E1egjfWMfTwPTMNm87oH1GWuFU75HI9s85eQFei9kW7hGfGVsB3qdIu5C/s1ZR/D/a6jE5ZFfRuM1Req/tfgjSv3M/T29u0BFC0Rk8RZLMNMZV0W+mEFQjCHYASu9GQZjUuxIiMCtUAekY68LR6gBJzBZvL6S2kPsl0oCDpIISrY10DyXpGPng046N8lRIZeYA5tWc9Bahf26o0IzmFp9XUOUDP1aqhOpNW3Qx5j9ToPXPZslfK9nm+LPvgjWs1iP5gsa/GfF9IPyPKoFSX03LanYI6o3AGxEIAhK9LdEYJrmqX8465SQHzRdYKJJRCyyMls869I2j08916LOkX8xkyvUYQQJaxUPAUEiIQpeHSqnJCHJxZMbfDWvb8Ut0tkcIbBNUyG0TxZ5ku4ceH/+M6X8P8pbaLcvfEZuzpxwoqDE5200t3fexSaydXtILt/ekQa18PbSM9S2Q+h0LuLWc+ALJY8cwAKrduibauFQY8udYiQKoRiUES13YJSvx1eceCLVKwlkdrzhEJ6ia1iqnqofXRYq3YDPL2cvoRENd0qtA40phkJBfdatgmgvIkU/C8DxxUKXXApJSoZZGNpZuooTlburkP7p/XRndrpW4HqUrb+BtmV/hf/qE+6ooA7HrO7Znb14PvEJleLacRyJIFo2wmo/3Xeq2zaJSzCWo9oTvlDlXAVzljQkaJPHrApbYZiOH6gNdX/xr/0HVr2IbIGybToX0fJABMjF7wLKA9yWuSFLPupRwSy90NyJdCZG8UosE2emDcr6ZH/2gPWWxBD4xrgwpzOd/AveyqgbK62df5z1tU15NY7jUJUggBcrbvjWcJw173E2atd3clq7ScERBrW4tzph0K7wH3QKlnGSS/Iz5Tz/DTRpPtWICLB/ZPHFUCw1i6DW43oRzuAOMRG/kq1YLponYZp5CcpwlG6gys8f7w20f0jgUjFVUSitdFcxJixFq1hwcU5o7IOGM17gX15XzcdJI+WqhIoVjJPvaiV7kP+jwflsnvoZE6P+77jfUYfhjbJg6T9HjX11RVwfo0Er2/Lj5l9sd+9pgY60rWl7AVakjO5k3/+fz1KeWE6sjVxwCZppM/nzcB9f1unna70y1I5Q+jGh5ndHL/vE1B4U5vrayEsIV6vqa1n0yjJhzI3cvyDzlmM/aDxrBG82bInm0Fu0JuRtwA+8u8W06XEFIh11w7Auoylq1lYT8k2TDybmddTFS+PjI9X4Ko3Mi27RaOfdXEyxjyEU51QBvCd+t6iXzDXUzmaZQc2jq+RUn85ppiwJ1Epgj3A4x0PP/dIIgBAGfAiR+pgbrqaeveN9UPDHEVyPxBXIkhfQx5FJSIhBpRsIFc50yqoYPX4IYzpPUWQp0oLAl0KcLyFr1zFdAx6b5ioroBViCMF8GEDMEze3G+/PT5CuteBYWUEItuFtc60jJvf+7ll9RyLpVjdda+zXKCu6tv5Hbm9PQJMTbd7sth+hZgVwaJKydK5cEzEYL/QqVIocXd6Jf/ffDL6txFPbfeW6Ghr49Pki9CqgRXKPlZ4BlaSFTkeFK2R4qnoxFZTPGWfLeCWV7LEm/TX/40N+KabrnMC1FXtBt0XRnR9POl5aZJ0FBkcZrbAMv+bT7dlqAwbWVjOTUNqkI11M3y6rX4SV4jAwxKrPLsIIIjXuDrbscnvv9AS02Hg1z+ikiLbTXmlut1ef7qByFalQ6nZkr1wLofrssAr0X5dSPukga/+PdiyjWhocBmaBwuFrBn0vygIcZYYIItmooJgUxpMvCjWNxfFkGvyBcjN2wR9FpUV22H0RA6s/bRg0gprfvAUmOCczpB6N7hOu3RCkRZdXosA64UoVMMqgqhMiEIAPH3gPgZwSkE2S87ouJKQCj0b2LViu0X/i3aLcY2yCZvlVH+whgd7Ol1zsey9i/CQS3GZlQ8UtzjJNTKKnzkdT164Kk+qfDxeLc/vq4cMX69WRy8iZPELcec0ZV/8h8BWeTgNVJ0hdHWbQ7UT2u1/6MXQkaZKGa++QGGlKv8Yus24csCAQJWrNYdFG2HnOBj0lEzGZHTapDzqPkLXXgNsz9C1ggRmU7cv71L0KCsZ2LjQDc2+O7hbKSnMQpdpMQMuSQEF7d79ciBh/vD1jLA8shF7BYwLivX4+7+OVTtgAxt3JHYuL8Mf7pxlhvnxSNJAj631jnp6F6gFDio2cCu4Y4UajNSyBBVQ8fIRqYh9ydkX8feJ52D1Jpj+ybpPEBXZHkj8h7EPZOQTmVgIxXr8GeoFFoERZbAMurUoW6RDGfwHlKnDuEMFetwm5IXWbodzFYmNOkQjEF2x5DwpMf8kb5wYCHiF3TqYhpMIToDDvqBtFB4FnHXEoaN+4iiRqylsu1VinfiaBNo1pgOsaGgJ/WRhfwlDxom6phaT3D5C8HMgDokUC9DFIT7qCcGs6IfAaheiRAvg1RVDQ03HUDUI1mnETqPenv+n+/nv7/5uce6UUPdDN7hz/ez8QNQnVLLGM5X7smedqfD7e34FAn49jIHbNhO+xBr/Phe3Wy/rfDqNZP5tFsJo4PGUG8EuodtYfj8fn56nnf7t+nw9Db99bR2ywfvIPsbTSXaf8uSvPcRXqbbYXmaNyDU09fby8t8eVrjvqfr/p/heNYbD+gvtIN+erz9cb/sp8PT7jI/B3xbfd14o6HMVti0kBhty2dfnvbHl9PTI/J8egvbeTsvOYENmnBr1FHv7fRP0+770/v89XrafZ+XpxT0ZoVxe8NhkXTli+TxcrvpPH3dHwKCetM3frYFp1AhsaYY4W2+XkNphr7xvhKae/rC0o8wH+V8+2b0UjWwXZoaWW0fd6e1n/Dp2+myPxwmf4DW26LoQ37chJ8b4HZ050GQf+9bb5viVO/78m2zpo3W1Txf1lMnpKbrW29jUkWzL98qjxEOy/R0/fn29XR4et5vsM0Qjd/7lP64fPqEW5sfTGyTkPvC9D7OJ8HgUDhK+rt7HQXUxxnK98Rm/J/XJfzWvWcZGOihMHX1fn56nafn+ZI4ftvBs43jPsE/odm+06Wm9lo7esbhhBQ2t3jGH7Dap+/7YzA9UjnBgKXN9oVV+/sojyvjdDzOu+Xp7RSobfWdt0ATaJePB3mbvoWAmt5DfST8jxKrfz1Nl8v08+l5/zYfN8chwWotWXxECOOcxF8dKrjoeu+Dww/sE5TnuUG+ru0lT2sO5Ol2Ps+Xp6+nW/jVB3/qmHopP2bXoXbz4XB92sZYBw3G8D96jEwvHOPuL66WUp9mMP6nwUlbWuL0hrm/zBK3zxhvpzNeGD+zgv+613TctZ1+HW8RoAg8VOWn7DrQ/nnt83vZr58mYnkePFcbMbeLaooH2daYMmGeJyyElwsZ5t0Q0c0Xn8KD5wQbdGcN1efOge0l9HVWe8uZGeVCBnk3ynpBrVEEPJPUC3k3LYJiciB/PNRdMvfp+TLtj09bf2Z09q8ZOZEmHKryvf+w/jLtD+sLbC2l8yEwLpV+B1O+6VmlfFoBL4vm9gy1vydBFW3Kd/7KHn+/tNJzZIba349IABTC7O6DrG/xNl2/q6/iFXYNgvzBlr/K2jQ7Xx4nzG5a5m+naBRvwoCSHhIMO0WjxIxuZvBdIhAFDIkKnj7GFhReTuGZ4vsqyDEPTfm+uL48fZ1237e9d7vMiVewnfzkdvhEcuD6cneho5jPdrL0a4dPpANWfEFA8jj47hSYi4ZPRP3LdPk2B6CszvPB0Vc0lKIL3s9Pt/O3y/T80B1L7KbOW+fImw99+SHqhnDpgoiL2Ay9Nz9o0h0+4ZYn+ijW1hbPLvbPUH5ybsf/3d9gquow//BPTzP4Dif60YZC0COH2h8/HMr77CiqD4V4xz/fz5tb+2M6+HWtUQYwDdRFK1O8nDancjmdDutijeEw8jS4GzFJ5j8UNFz6WRtuvx7Mjwsh+PijPHsaYjdM8ZL6uj8+X+clEnT0PMsKYV5XHvmulqN0auc5kuwc7Mrj3tXu9lUDeIwAm2Dai5flozh7l/K755lO5yUKhjrrJcmqX7P/iH/TA7TeAMWL3RsgbdlLWFbFlyss367LfEmZriqP4Lv42ntY1qe78qBJpvg0fhh+mcPor7Pe8vvEnr9bfOCRp9vyun7HuO9lJeT1RijfLt4IX0+n5bpcpnMsK2k6P51fqP+iDxEdYTLir8tPkrT15HeViNe2/sWvgCEu8/V28L2GapTgs0JpQTHC+bBf9L1UScbbFAosb/12XEPgZ92+5Jz7QvWRUvuoJaRtixOmTWixaLZ/LvNlyzgGEDx5AT0O3tqUnwE/l3kTaQ+kwcVhhSaUunwx7qbd67xFOKlihJRK/NJSgqQ8rrl/uwD2L+CZeOSET/1FKPfpRGUYZHc4XefnaJNaeRWNAXag/BVephWfH0LejezTYTW7KmyehfW3ebnsd4FtjzAZhbOqFKHvbK+1x+QCt/LZ4WZXTfFSvyfd7iXOANIqAoUWpfiqLZ+WzXTsFvkVUwAYuvLYgA7itmDW2qX/7K3Ut0Qpo0qAqT8c4b5ofgav4LlGyHt3icKA0ALP6X7/+X5+FLb3x/Mt2G0ec1Bl6KAWf4uH7ShCWGNXOVXAkxd27m2mt3ryPS2xbd1EUmL0oikrkJXFjs7z6bbysa+BTmKK/Kol+TTLA0MSvgsGp8RRJK5zgILrQqm7P9/Pdw9QBITBe3ilY5QFunL3/j5A6iroWn8dAZBTHmfejUdtPdZPBMYNFp8xPH2djs+nY+IeqD2Xlt2v5RltMcxulZU7PHIY8VDeFmfrULnHL4ZKWJfzBXxuUx6pCOvJi0GSmXUAZFfliSQxgDpHMmQhKLMpj8jvg9zlN9e+lP0l9dU9OAX0xJryhLY/zHVetlRz0IAj2yuoKVCOFngMcrqtMW/ymwhGnZiStnyIRKNo7VWqE61TnzP993S3SNfrNHNQhMp0awWjKM5eI/1hAyRmk+gHLBkl9SVs5X0JXHqfPWTXGYpbHD1vFd0votPlY+srfmZ+3vJVgWm5SgH2Np84AP+ad+vhmrofrDxeO/RLmE+ceX8tlykx49ZLJtmuHHWwFrhOP+bLtCxzwHW1AorF80JHwpQnT5z1xCKUBzRWd5MQbdNMh3wAxsorhURGpvwc22y+JgJTWa7sCAS2xQfxt/n0Oq1sA7Fj5aXSUNzvynEXD9ux72lr6aYjNDXloekDyBTVb0cvo25F+07xUn6djs/X1+n7/DYv08r+ETy6iI6A5HfQ6xL7j8TotPuuXa+NByoDBWZTHtw9xrgfJ8mD0M89gBql/Kx1Q6wPH9+qVr5Ch35yk+gnz9tPBdfWi/JQEDClzZwwfpx23xOzL69U9GQ3n7bukF+J2ZEyMD2uIVN+DQVDRNW+znuJCqCZrvwuegzhnKdV6j14CakhDzkcUw4BC0ZIiQd49BpkiTTlYClvkOtyuoQDtHIAbIRycBEGuAuv+9blyd+jEcKWu/sP62uQncgA1/IgBaGU44z6lP3ItWw8WuQEG7lwA6mG1A65ZJ8/muIHtjKtzW7aptwj94dJnX+1BBqw/9GW41fcGBtIitiSiOXGSLXbLz1bbxI8kvmhNpBUYhE0suWQrKm2PNfoBrhMx+uqWZU6dL2O8Z7Uf5850Tes6iPDFiFVOw9iUgF+131mry9dExj1ykiA23XlSMj9+UeTcI86L5mZoLz50PDhdPxm2y5l26tLJ3QwSm3HrlcjxAG+DEj72fK038P0++kSxCid53tRorgvx0+utrVEX+9RVeDS7MuBk2/z2+ny834TTOfzfHzWPLBW1r4s2iabcmCAHGvz3lUsRVfJ4w6tjU25M3Ccv52W/apt90GOsffXFaDH5VHkcb4u8zM+U2De9zmQ/C4Hg97NH29vX+dLYoDB38zAG5fjQNdW8kfFaas5p0JWKekqGbM/YTxptvfMlmdIV7MqfMA2cl84caNSsy/79UZJeka2kmgToRzyK4YTF4ptJEjd1uW5hoT95MSMEgVbl4NGV/s6esW2XkajLo9ZV7t39O4GDY5Y+eXibj+3pD2zqcluZf7I1oXKFinrySnxAMd1ebUttB5GSrZtPcvlrWgpyzEKW16Gtv/cEhTWE96zbWUEZuvP7cv70l6TD4fTFrsEQqK29c6oeiy+YWl9Del+rDWKeNJ73/inHn3bj9Px+eV0eZ8uz8nlImuCtim/wFf7t+dzcoHLkNp+okTwsJnMXXo3c1OeVHvYTC0LqRIPToPP2NwwL4f9d/+w7jw9wqY8G7WZvcN6L/M/YvCl7Yz3scqjltVyDqRjOyllZD+RYV1BEdNuKxppDlwnwS9gPnDMEaWDXObr6XbZJXw2CYClyEVbqOPzMH89nJY7r2gMsWvkdQAVaaHr/PEIjxbrxMN7Fxh4F8tbEe5l1NPb+TDH2MDBC4zABdeX97iwdJoqVjTyshlIVlHewr3Zj5QCPdRuCr1UZjfBsrcyMYpHJrtoeVEWzTJxMrGRqYyBlB3l6RLfeJxL9CkKQQXSlyODOcJfr9MKsY2zPn6hiCJv5VkMDJJK+DSyljygy6cph4k87mRkehJvIL0JMEi35S0y4RDntRPiuqxZ0ThEFZz2pFE3KSDlF+hzmgaScPr493sg9Ylq7wXxicoz48JlzwfgKzOjvCfLyzdiiN3rvPu+iRkm7Pcy29SWw3ik/dPlctvWWsq+9/zl6RVhP2XVQ0QkWDo/tvpyO8R3gq08nFZbTiohLB/Wqz4B4qy8xFBbnkQJbB9O1+SXHDzrvzLTgmw5HsBU3qSXB8JigOv8bf2vahLImsp7i/IsovNRNnbSVN5WOvMDGBCb8jLuPeWcbjRahSqEeVQg2vKU0t181MTeNdKjBVNWlwiMi1EoYqi1DTiopncS+ArSOMcn9slXSXouco+BGsx+oplEDBAS0XeddF8SEyLmC2Stjo+1bLi/v6zn9u71cnr3HYRRZbiqwDzY62pm8UhrYH0Iu7UGDyGJilNf3j90H+UOTw6+jPSh0LP2iW4cafq6e53fgiKQkGj/MoD+qi3HackBom8vAWZgOTQkeyy0Ph+fk+tW5l0H0r6VB8R385fTe/jkktUsuSw/Z3o3HQ5rj3GwcLy0d2JFFg4Sn36dp35TAfLVl8f1a6v12/76ti79uElJCEF8IWXiJxLea+FYqbP2UmSmDhkSS437u7QXRwFJNR3X4q8Vje+j/D1uoBkbTRKlIceirgcL09v8aJ7oIGeJikl9QsotO4iOxWnkwTagstomZNPyI0Rdus0g407QkbXlHS7CcpLXoBv9aB9dqaBL7oUgX3Gsle8p6EY/IEUpsLxLUAzggBu703GZ//Jja08NcgAlWluOxLoPdDh9+xZeZ6N/naEYV+5u3G2nnL5WZi5HkGO15eQHd9up5drKhCB4ah0xaLntcJ/JldSQj7z/5CMnkq6mlUnX1LHzOdvJzqDRa7FIdP4UjaGeQnK1gJHYfIKb787ssXZMpa74VqZHyPjdluf+ry+S3CPF7eGVoPry7snri8bsYT2XNyG7p1kMQDv+THdypqEN9om1slmPnYVWYiLHDviWcl9hQwDFLXu9ROKBnNrRFQeuf+JOVodKXLmyfwX01+YT1FIJ9pNu9IADNer65VWcdd9cl+ntfDuGdJCVz22LfHQ51Ov2fHYqgJkA3uPEGMpzo7fjoxJ3h3d9kGQcJXC3cXTxZii/NR7Vntiy5M2k6ttQvq2k5ed5mfaBsGwrcbsjJKQ/EWDLEWI+xcpzxtEk0ZeX/h/2H6dwTG/TV56708DJKfeZb7f9c4xu6ivPpYF4RV9esFoNJw4diaobwfrTlVeqfkyX/XFJIO09TT1kfsbyClWK6MfKllcQ5DtC809YVWMqa4wHry+v76CamakzeIX5ETzNXTlMBKOERV7TSvKpEe58V+7O3w/0RAKklQkQCAs4FvcgbivRVg8GS+D5Ki1p9Cv248PAeHBB9DT1tvimutsPk129TxqEhGpfng2B4cQZLE93CA8M5ewYd9uJDFTvkxLBxUuF5inbl8mH5o9qTyAomowQxKKAjy4/fZl2geppI5KaQWYxAUhPGNwH/NOd0ZSgU4XwhMGj7ziPtToLDlQ+YDrQjmSoYdBAh6CF3zPm3ujoJ7f6Qc29pmhskwbDblVtzqPW1b8BcPg3iYRLDLK8hi6rKnvkHFShc4JgEOkXCmFIyYnMA/iirX4CAXR7faKSuZYc7v+5dkEp5GOgiDs4TxiySeyYY6raNACidD1TncBgY7e0XBQMEyiKocvW+xBOCcUIFjsuDyfEgdwr9Cg7h1OAyqWRv/gmCJEqLg9qRuq7fS0leJu91VXu4x64tL2IDLcfNOE48iIbw8QtcjyD1ffMOlKgMKwq9oKudoAGF0Q9baPrddwHCUBuqjA2XqUe1KhusxjKSteqrEiPEICb7SH8qFj3b2CjJmfdgnHsBLUrKTTOXwJtoKmhgtMi/d1SkrrHn1EeGIulHfD33Gc1HTSLeswZQDNmwLVItZzchTVPz9GKqzptxbXOZoeWjorHJ66Eusktiuev+29xn4vsA24xd4XBw8PqLWFW0miB86TQhdrMRgTYEr0FTaS60HlKcXNpt1WCqCtp7+VwmpbaBrtCPCS2Vqmn5IyGkyn54PBwhYHYajTYu7V6URvcGg2vHooBZ/fw/riYzn9ujygIz10WkD1MBvMr+UMgTFnbssjxYXLwLUraEwuLZX7zavE8/TycgiOslmTtPTFpIJayiTTPlx4HfQs9EKurI6+DI8z2Q+zB05YH6fJYKPrjWb431QT2jWcfSleF8j6r/WBzW8295uxZygi3TTijyin/ybPdfZfNvzHRrCVf5BYvfFk1QKatLoTJOpvhypd98XAICtkhnc1g6UsOCJz9hWmNzeTyElj06BlgsfzY9zLqtQ8tg04j9CwhPNgmuA/XSM8dliP+JqVcu06P1tYn2UTCgzSKl+tA+FDIfXCZp2uQtDJS6KZDua0CkKQubIq/zLt5/8N73Ea94Che39SZ02WzGLhmo+b0deiQrYaMF7LZDBswVI4g6DraBhR2TSZanHdzcBK2jRYupmpVKYuHaf8WuOyVHhoQRZiytf3F53BOK83fbSHWHIkmawNs6UVvFVRyhbmAskd+tIKQsk1kSqVD8Jiu3v3J4GK6AQqaUAkyRihyUnrV2bJYLE1F2Dl+cWch3unBpt6PKhAZE/DHvRriu3u9th16NEmY3JHggOhBurD2VSDAW59wc9JKePpQgdvmMxtTqXNIuD8EdqPj3nA5/aa7sslEjGlGdtOocuxcklyIut1A1NmoG9LiUB2rvNnrcrn5e2ds1Rxdon6WsxosmrrWtnxPoWLhk2S3fMCu0lg1Lkc+EYSg5L2UaScky9AsLxVnsSZGaAYTGUHiJOrC1tBspUJsB+aMDoFsx8IEUlYdVfiqTDS9CzkFrKRCCHZGNi7fCqpB8VFbXCMkbZmM7Uz2AliZw7zvNVba52pcxRmLrXGitqZ3aVS3/rZMeXaZbBiOb/49pnsIDb4ZW0Q1s/45a4yW0KKsOJFXNbgEapz6cr3kTpifu0OYlJKhcjA4LpCqzbxQikqos9pupUTD7zlSzUBgBKLbAd4+8MWr7At+IQWFlBZgwmZwGepVXwW/YI+rCGxVA8i5WX4YRtgbYQ9g4QH02gPWJwSWDbBvZoAkOCSUDSSUzQjXYYTrMEIIfMSBBallA6llMxpaoccPe2DGGJGFRonAjIAOj8hMQ7DZjIg1RhC6jg2swPEZG8YXsIfSYYcAmRx+XYtSKeSrO+bswcjXuc1vO+IeOsYwsAcyvA6nEmVD9JXu03WqGUiLRdPljpmXy3wNcs9NpUX2v55s+rb2tt3Rt6+nk1fX8vBoVJxvCkGGzrZ/SDed+g5hNfEjF/1u/uo/snZipp4/ZfN2mMIEjar5bbHwHkCatM1/BHHEoN6nuNSNRea0p3x9V/vTsiVUMuvyH5GPLUVDFCcXPYVAo7MQwBqQcIBRUIWMvBnaMXxSM+LWYFp/6DOfItEIr9bS3MnTuSO3d6c1EdeGFRS0H7S5oP3xAEF2bdCi4N5dH4Pbg6w2MUpDo7ptwgrUlin74HkCZ12NyJHNYR7k8Sd1y5wPKnq82wjap5uauL2im0V/5LDdxOsdN7hpm0J00WWOu8Zle4B1TzvgGiQNcFOcVd0G8fZObdXKHyD3Tc6z3ky+Tct82U+H/T/n56jfziNCNKiIlbJ/PIa4+ia1NSLsI5Ku/RG3Xygyg5e77j96y5VVwc9MNT6HTGlidLMWt9nJiylxnRZbjb+A5PDAxLhZKDEdKJzYyqrgEBEZoBjZ5CLUt2nvnYe9mogBlKB1u6B1Q+Dobt19gnMK/t0Idxb6GsTTG8uMFA4aQhASR4itUYSpkbmqe2QkcUrxJERVeZVpdb+AeOmh+jGaTCpina+o2O7pNbnvm4CnluZ210GCgFmNBelTAzrz2F64+hsH8BE+QE0GXEQw4t6Fh9DDc7VAf2Q9hLdTmJZu1AhNBMqJL2yb3BSdfswbr8VhY1IIswKyO3pkvgIl+I/23Go+TFloTpsdu0zMejrPwbesRhVUkk1Qn+cpTFU1akiADZgHo5wPAf5vDZHU0x0uR9ap2Gy+hbl+oxYSiEgSVYqiesU2Uliq1K7UTyfv7tYzPSVraVsWknP2P1pwYUa8bvQqBtGX7gcVDx4/UJCxYRRHrZ4WiSuH03FXNArcrcvPAiLGIoUL60HfDKLi3m1mYI1wiiCPjK2P2kHvDuoeqQgqzzRIJzSQVKOL2eDCJrgW8qjiRsHruACWPC5+ADJmnFdLFR9K7pAplvybFWGyuIMM6ijGeUGO1nkLY3D2wYq1jDHwL1Am5dckjsBCvphHdo3PX/f8hapdArfKmh4y0/nc7Ea/GqKmahWY2yM90wCcZ6vMxbLZ330/nt4P8/O3+XmjX72utExhStDKynSPNzLFWYZ1qDDKVRPW7qrsCWnDPUp9vmzZZB0vcCpGNRBrK1r/fFYT/zZRMqr01rH7M55vXw/762t+7uWRmKjdlcx9zOfeSNbSntjaQiqE1ex83B7dL8Kr8VeZo+ashmJNah3e4BDJBTznw37nw/w90lYDgJ3BFh6QihgSrbUl4MwYxihQmnlU5afhjivxZchkquIMUlWIbO1h7Vadj0t0GHWN6kWnRPgylqPK6WB1PDLQLawQIIctsspsHhhyRZ3HI4SF7l7NgRpcL33OE4zp7XQgReuyH3DdTIWL16DigOtHICRSGceKema4sPqcT7096ybEvrxeTrdvr0EXkvVY7c2YIOT7IBF8H+HRMBiYbjzTqdaCj01TnDAAGUt9UedTtKg3WBRca7SVNIXd/9HI/3O5BF2Fdadtwp6PYD9aRAEeRy1jsgoF/8KtFwN3z+JXDfwne6E7kkTAnRp1rqjHA+79B/TUOJGbII4NfgoiZncCIsGN4s+AUAZdFu5Udf9qcG80uHNtcG829AzPkcSEzwrfzDTCL8ZDIRHQoWzXgaxftm7AH8JlhLZg2+BqbUHw0RbDbbfZfdsf07vSjF5DuEUddCykRXsMEOqsq46SK0a27mPEO8q0mKwIxbU9GXce4FFYaXX/0VZIHSCV17luEcSMhTIQD13FYONqGUFs3BVihXMZ2Xds64oxZMXQI/t++6AO16k7nVEK6Igsc1KiAuQWc8e6T3xfmHHoo2cdK+YmMp72/amj9gXZtfoFSGZrsbmQqGroQSM0bPtPfbn1z3znxxo1oTu0GUdnM+ebMp3ebRUvcFujhJahjLvM190U0qCr2TCRhM0lUpP8yLUKQItLC2BPTpUWXD3A41bOPUXgjVbqUq5xpj5OYd1wvjLjCW0ZQB7qhITNunnxnzN7chs0OB51/CoBzXSwCQsDH1xjMkH5NmK67OHRa6JXsk6c9V+M5afMLPerTzAie0NQvH5MI+7FxiE+GmTmUc4zHapsSFmtnYXuF8ExBO/AuRUAF5CPdKjZp/J1SMN34tLGYMgUVH121yxKLtL0nuBtG1n76Gy6zsvLSpsVZSI9hxeAnLr4urrOy9t09R1dIzbYlxZ7tSpuRrjOy8bLHbfEezJLFhFsV0gXspn29tDQa4dSA2+xJzTs02sl9Qj7Z981X3m41BMpFy6uBB/+2SYrwuENMRCqRQhW7jlPB7+todXRIC6nLHDo9Dt7KMhqo0y35RS47ZK0ZSxUXX5Ye17j6F1YT1nvCs9maZF8s+kDq3o1tUSsI7Lsjx/IoI/ukmGTPN03YpkEnU8KZEEYNHLmqMJKhw+5a2aikUZ07febh8ACYO4SOh1+RFhWowWWIkkAOI8ZmTmv6J8g2OozGZo7a7Lv20koB7LpOMgQFJhUoJOUgVDHDQp9KhUKbsNc81pCeNIno3p8VCCze8slQmlWhHsp3jD3X+FbAzXuNikcASQDkTFsnOfeuVnt4WKydZ2lfaYTBS0p/l6PAFakGDH5SDaaAbnMlENbERMmChiIErIe6TbfX09+m5vPfFp6HgSZtFrzlhFlt27HI7fWoi5vsk7R7eubz41mrZrGic8JzeghyHqpIGtcFEjFuNQ5GIsbd9SAPKVlY4MTKRYrpgbwowHEssMq6kjeBzEG5k4Zp1P+sx9zEcDtsDxF3qupfKVdFPD6PvZQmGDoRItVH70XdwLLslUf5xV68StYuVt8m10Nh+VpDkmTPPHziowjhSScd7M+BszoyDUsWyS+G9z93KhDDte2BKgkq/osSNC4aJBkJiRoEQBFwuuTN2ZAcOL1O2QJWtYnDsB9erXBRQSAVPXuchsItsYC6oS4LEEdH8xeiFGq1D6Z1uVa2tyJsESQJNupZ0LhKbPMxzABIMVGQ4x7gRjAqhzlp8ZqSWvLtdkUipfeDQZOogSbIpSrE0yiyf+sjBFsL3XthK/wt+KUxHLZx/V5qa9mCC0ZErJdAgENNAFxz/kVeR/66RQSRq1LxXsAiwcoPZuWyz5ERTXa4TTUzPZiiQIJJ/pdoIYEGInoDsnivdbH8RsIdOgAKvl0gglvaU18aFa62sI6sue4dFbdSs6XQouc6AhGH3DyhGRCnNjdHn82Zk/Iy890Qc36tbrEwssX1JZLhDuR/P1oDXKzXXdwZE04DzWn/1/iJcrALExf0dHFXLMgnGDH5e0mLrDf0yZcA3LU5MA3yyUEqLSdFBCEiIEdEszYAqGGLBzRWxJ3xZat/Af3xDojaLin4i2268dHyc90xcYjVEEgWpVfIreLd801OsgSVTEBfoPHO6B7rM8lfdbhwiqs6kAB6w5X01HGubMdiUssJbQ/GKpXNHXsoQsWOWwswftBt5AMIIjGwQViSAZRIZqtRgA2OqYIPpiToNVFg0xgFYIFv6fz7ebG/XCHJIMcl0RpAAMTBz0S+dwJNfmyhrgAJin5EDTzLEEIxAxkz0bPX2hY5b/4t7WuIs/z72tiTS6yDxpbsQT/fS2uqWX+QdsrIUP/pgbY5Eb7oCkWCNN/Y3tsuK03YBRSCwj4MX+OovojnOrtGnoLtVFPY4SQeBqc/tGZwIPTpcPqOgz06hqpEEctijVbhUUyog9lr1T6bBBHLvtn8G9xDHcs/aNg3uFQzUa4Nz8X2egZAHxNHA3ibsgxMNyuIamKSo1J21mLK2t40N+g19rZ0Jurur/sfJyuGlfDGnnfK0RqZICPRwhitKFS03nILWVzXCffoORO/TJyeyf06ihOxcsHQg5Or2rjcccnz3yN02F+SrUQrzeMDAIHRKGlwIrTXR8sIPUepfYsOsJMXwo3Oh1iCotBcxpSJdekSc8JGVXAQ4PzuR/0XXkKPN9a7WfOutSn0/K8v8yR/o1tG1k8qRPBWdre7dvr4Wewij9m7M2v4ltA+DaqIA2TayQ43ULqppVHXs3PA+maIMnGxVRGl+2vTk/4EJdIA0eNh/tQwekxiSRTgvXZ+aKC21q9iYAkhSsLImM4/nBlG5egwXZCTh9Pjh4c4u7cfYa+f9Y1k4nygZOQuOvg80q67dxd1w2MvfGL2A+EJvSckYI37EZCkUF+EPiUg6CDS2WWiSXBZxW+MVmbSMNC/ib6hfTjCFFESwG6LSp2AAHQw/KCqOgK3w4eGCq/URlCWdS7083v9es8lg/4ewNWp6NO4ofEy3bkUucBmBC1pZMl1g0r1Pknvj+nfGKpUkYfjeEY3aY2kZ8icLnLtPmc3v28oOxSss7vbYDX7BCQDOBGS6WEsP2xbWs09Ll1mgpO+2DA1m1JRDij+xZcgak9ypQUd6vYXYhRekFAEketcnflUQ1sp8AeyO8GxCgtJWDROCOEBsTKz62e6/TyEvswVvL/4YiyJnFiJ+3eDnNYX9FpbTgvfSLSFlRO+CVolhgVkgKJkZh+fd38FJbOu1XUO3U7hjWqbtRJ4cKUVJxjUgbxyyp6lwy8Q0ZhzNWQ1oQZC2QJRMzKfrcR1L+ph1qTjP7i0Z7LbRmiA91uxQ93n7QOlsG7tWb5CRPX4GkZjFCmkkJqGdmN6/TiI2yUh3d1xfv/IP/TRhlHlwpD10Pnphmnx+Ca0phhsbiF0ZhuLEapmWkmKQ1BP7+XbZMSJpY1NINsheivRfqgxXfINNOvM70EfnSd6TQDC5LOS36d/LXXtSrUGc+qq4ngqoF/TIYihL2yJYycWezKZlUFX09kO3KSJOJ2ES1mLc8/ZsoSxbSE2g3hI/AvBIOIyIoREKIH8tdp2V9fgupkq5J/WThBjY5wu9v86cfz2rmJ6F1o7SBmz27wH2H5QD/tM7mS624Kuc86nbuGiMQMNd5q0s9mGOm6uY3WuS9PJ6HCounQPl9hE7YoZ3YpD0/8a0HPimPCIo1sUbWx9N+Yco+vDyZ23ch+WhVLs6cWH1W3BGmNvmTC5o2qUb8B26Facj8DejRwOD3Dug739DLtD8ES6qSYiR0KhWJXa6uxW9jo0nrm6q7MzVrN7Y/L2vVxeAsMSjyx6O362GDAPrOi2PX5bRLzq6JpN+t+2sBWUrjDfbB0D45mMqqehlQ3kllr/MTUhp/ILxGXJY9iRP+gEuSJBAW2Ofk8sClF8bGKImgh8ac8y5MTnQyQK37fKBgNcaYMJAHvyrowrrvTOaSIU13Wmg6/O84iQhjniqOEjDRl5+I3Bg6U8IDD4oHNY+IVojJ7JG0rFEYMOWcAXBsz1+XKyB1ArfRrJ5POvBsKOBlUNAyL2u7GBz0Y+GsQCUv3gbVofgBgK63oqSJOi8T6/EXsFjASmes/wdpueu0479xlhFzCYPGDWFQ45uyrRCKlYjcfO+rYCKCHOdd5d/OheLbyZHbBsrfiKxI3rkB3D2pS5T6Kv2hqlbH1g0Vzu+yXn0+ReqcZWg/xByQ6i4KE7zwSfR/v9Pnb9v/9Lgn1wSWvH1PNaNYTvBj4SHrb3mPsl/0h6DES2p+r0bKOmut8CNhoba3HKNkvcEilVVTZSrcekbdBEpidJIC6ee3DLFtZ/GrxC2dnjQitUWF394cOSWVVxQPk6ESGrAgtex8n6O3s1DMt5nKRcc+n2Fqu8+HFCxhrVWsVITev3k+qpKxspsdlvwsaaXJQapT/9eL1dX7b704H/yPZWu1Vyz/hMWCflsp3LkoYUUkQ/U2JriZoOFhU+m3HvFwfO8mC1Nrl+dBN00DB1LmZCa5Oh8noWNpyDGzObQBpgCDtTLafIUOPFEvP8sbvQXyRTk2o4iC7Sn4t1OdSxKCkAxXUnwmASa/3Y63f/Y9pt5vPyx8bh8r+sA9SM/WosjCwUlhn/J9jqIIocayt8zfalicHS5HMZeBVUw1fbg0io+r+eqLWNyKz6FJ4Ljoc3fpCNm5EOUaQuhL2huiZSTsBe2PzNaykinIyxZOAuCWLBYS9cV3+C8A2UtKib4mANXHFNNyjbG9EBRm4QkptCOI9Ys9RlGsFlToyFj0zFjGJbRIqBV9zFLwZ2RU/X/7Lv3Nanc4HbBlVKiknCqAox/TBc+tP8fX28hJE2Z2UgndzENYNqsy1Rrs/TMBYJJsiUiCUtdySuaqFZetfOdbrWa5SmabSZ64Dy1L7ekzIj65/mvGjV8uR69t0XsOpi49IrYmVZwSlCE7iQoqA++ABK1NrvfxOkzjLxJ8qNk/nmNvDDNKb501fmtPcDJ9Pp8Pb9Jcfh0kiqZaNaYX8XMLwPgjwZMdRi8xzlVjyuuHQoxIW2RPeJJZN1GHXhu3LsgbsXpdHWuaRDqdFIYJZG1IkQQQrVYWJlG2A5TIdryvXYHBwSIKIrpA4frW40V56yXwPHoVLrilku4TN9/3yupFr7qKZaFtJTUQysASnnTZE2I1vMtwE+YXvAxhUYXfkn1ClJFswitOi54l3rrgl6DPmHWgEbqDWJQFuhipgfZ2NIjJ4p1rOtosngalGEsSwYNAO5V/7PuLzfFgmf1hPF8dlnUg9h4xLm3DqCgbLLbBO3qPOMSBkXJQsx/Ktch87N2zvrWvnWKJmyBOlHRPXpCUZXibXvT7IvaHnLQidJXN3JkhirqAorCiamnVSdtMdser7VhIRNAq8VmGpZD5e90ugM2szGiP5KNc7NGu9YcmFJSgFU9xHbHEmilMtmGLb/wY8i4cHio8MiYlDwS1EwHx4eCz743zwkj5q0gusZI17fk4Hi74WS99S5AcRcY8jqHdVQkOGA/pcaGOxjKFHZKqzX/w8XUJNZZX5KIRZ5CwG2VmVOS6/Hu+2fFCxlHn4oGEybTQmsG56STTtUAMArEEYwxoSQhr+SnG3MV/jgHqIuXlPggrIJWWceSZlQDQO6nznCrKsLJMzwFeQ0YzhNakzEIYPAhnEhgXQx6A4LZIuuAMlIz3O7TqTNb3PfBBs6BiDIPmUsxmg6FRwhHvfnK3NgZwSQrnShezBrWoLm9avGytg0AKhhdXuQ9VtJnq7swxGl6zVa0MyNmcGg21ubC0TcOHMVnI8h/5m0oloir7oatTf8rqAbhHscOMrDLoIMurEmRLKJVCiterxDyg5kEzooMEnYAYMtxl7/t0Pd3lEqF7BP+OquyyOWPcjrJKgpQfAKmrdUcrDnTXU9GjdD/cnbggoIMTAYVZk2D4EtjEDVi5jQf0EtQtRwxEU0MSRIaEocH4kbgVMlMlqsqYJ3RBsgRRKWaax8GfAA8r0Nrs/saUEZp/p7bgWJcrmoioFGq9EfYpNVyCrloqEVBWhGIzQF4EPVDM1DR64OvaQ2D8gVOEtk5EJPHXNg4P9nQzN4HuFzG5sI2OCcnNt2KHO7k+WopHwzFXr1m37XwFTn3qiuEWLJ2T/CmTNsNSEhFkoNrp9IiIDWOePM6EZhrL7wz/tjy8eEWItFTcM+uKNYVUD1oeEBMoKfH38E7Tw4su5KLAZESL5bEFCtQmllQFoiQrwWUMiQ0r5kCBKKMuihkhQMlW0mUMH0qLhokHcP5A3SycLus9nPJ0SXhWJ4HbJZ+7951Pm15JTHlbaOuM/u+dLx7ASk8nJdzNT4oT8mC9v8/U6fQvEVmT+e6B+XoITSDccdp3KDhvEMu4Ur92R3PC6AkcN0OyAcvYoLw8U1+AZKLhUyjo970/sk76qympZRV0BIM6o5D3GW6bldl25yIPZH+Xsw5su9y1/3M1HvqBHhTXUpVmr1dj7dDgcp+MpBa1qW1GlZBN+l+ha0gf45+noLxfZuut8IpdgBUsPclNwYUbU9OqOPhWqe6g1D2h04OVZShG8PvE+0D2q1MbbD3b4fjdPu6iFbj05RYIK0psJvaMsP+RjhAALp7bux80kcW9IephruNykJMFai05BvOJOEODi4GOgGQ+da7iKoqBZkGi6H87rI9rBwexyEbZbOiT7SImWksiY5MYiQ59oO0HzjWmoFkasDFJZaIuTd19KYo6Fkb4wUIe7VxMxIcThAA0CSKghdxOpOxKyqRDys0jY2bHKeDPbkvmvIP2uStxXdNoz5+pm849k3acZdSwpbHcf2f56Oi3X5TKdg3SyuDETnK5JXvhseLkNFtTstGCanTSuAdS5XdgrQPQIJUYX2ZGVl+U88tKjsYkrkVgbKU6IIAPBQ61TnAdU/kOrvRvCOwpPIgBluOl+sNkD3ansOGJkJ+K5PkpzEKgho7ORr9xFLyrYstlGwg6hHKpv+Xo6HeYpaK0U96l7jeLayxJ6FI1EBuCcShRVFHu7V7+Hp/G06GCv1D1ZnoMss6e/BN+q2Itdnufd/m06rCIS3lEirCKzXih7TqurvKNXLBNWkb4olPKm1a7x09iyoQNGS4HEzmjUGjUKzPsXBqV9MSxjeT7dAq6kVkJjkKEpZFRZJSYOp8nb+h7/J8jRyh/x23x6na5ea4YPWnGHWyFZH02uCgH70K0ZZeGwQeQ8lFvfB2QJklsQoU4CEa+YO5yCLmrZxQRfOIE0ytgLFn0na+Kg+66KN/zb7bDsd9N12TqvXgLFadtJqIdtihEetLssh8Bi41ks3knH28Gz1Ek7QL1UxUfTai99PPnsmbBcfDwJy+HXks1boGksDsakZf+Y8nAC8BmqT01uard6aByYLT5RgthGLQmyV6lUoqXitZ71ZK6vgavZSVQQKqVV8Zm2hDDgVnVd2WeU7cVZTQbsl1oI6I5gXYp+Nbd/m6/L9HZ+2+8uJ8+yh+vA6WuKz0eY3vINnmV5TpDjoPioDHmfrOnLxdI0mwGAyHRqJY/UEjmiveu83G4+FlJwon9BH3ttio/0H9Ml9OFkzpjSY6bwSPf6QUxTaQl00Nob1jTIYT9k+i5fnp73fnmtUSuZ1maAGi8ezmpt89assJ0q1zr1Op0DUWEdz1Axe8CMOHHyVDnJERZI0gEQffXAdIMKwfaVfka9TkGTnKlUGsTWRVytOzDRw0MaARY2yIOZIxQIT8gViiCjEkKGkEnt0QYzJjqHxd+UiH5QxgqaVhSGSEc6ZuL89VF3p8PtzW/pGoSfhlZN0xdyS19fT7dD0NmmaiKBRonlJiFbkWjoIhQFa5h8dVGhLf18t+X59B6QVKvVe1ddJilKI9KE+sXxGOXldNkd9vNx2R1OAatoLWMhFMOLXbj915Cy0baDClMVTRdGvzz3zz5brD4t6NDBzgEDnvtYTFrg2KXCFGFhYn+JWizyDVRMziSX98/zzj/1WxXFx9Zx0uaQCj+zsffPc0geoAq5Z2TDrvtvfrGiU2V9c/Loq5kAWFNJuH9bJxJxQFqmkryWZCFR0lN7gkibXXsVQ30Qw4bBJA6QtOGfPPUkfgfnX4+yEst7SbwgeMxyJ+b2yn/cjol379QWVcN8djdm1te34/5lv5uC8kij9m6SkZHQcCsYujJr5zAHDdmtSj9ga8ujI7MYVpMBEaoZ1HPfrQbcswIVkrtd9wGIUc2mAhyKBP39f4FQlXhUxEa1odOJCgWipDHjLe2P34KUlEpqh1S15BikPgYdoyYujQiZLF0c67oPaE5aVSo5J6l53cdxhB3U/uTfIAe6Snh6rsgoU1Bun7ekeCJIIuGdCI2yJu5cFHpkKaKBiF5gQ0bo3ncICLStWnos9L8jOVN1i+JQZHtprrtl/8/wqzY6MjAXE37fn/fHb5f5er37OCmUsfEkRSIl5o/9nO/7c1CEVyUGR4E30r/UIShod6N6/2Jiu1r39FZ73qcfa223NUQg65pwm8GQQnWUQUTKzAezeIhK451KNVQYvQdSNGZUdVcYdbsfqMICuIUDzhW4nb8txM/ZRj3yF1m8gDBFyu4XGA/J3SiUjVB1TPAhCv4+kHuTMKfGsUfdIGA6bA3gmixHo1iNMjNk3y3UvkXLNLnKBuIGM5W4w2n5r+B+ULsfbJOJtUM6rUblywBiGehJofcgDgVRUEWTLYK6KIb5gJ3tbTocgvRi3WnXFwJQF1kmTR6n8/XVX/e1ynIYo+FGAZkIYHFsfU8O7AP3VI1VZ5N49y8Prwes5g5PRJhjCCDB/ZrgME+DQ/CL7KSoRZOnVMBE2t+9QxM8pWSdId9yQgovpcwjVXMYcMCpEX1YCfAK9jlTFEkmI+FWJ1wN7Okkh3kKlALPkBKxuVPgtPvuIxPWS07P7+HlkYEhJJo8Dp2g5ABcGbB+yc+g3yvbg71MsUJDJzWhbVPHHl/a3ov/mnpiFFBnh1dO2/Mbo9eGoF/pMT6FjTZqBwl4Zh0/a9peqAZetzr3JbJNuLxyj3oO6pimrrVn7RG+gyDIkr+9xgQ3GeqH8xSyqRnVVWlG3YE+z7s1oPbTQqqvB5Zpt99ihKAAtSWTkyluaZ2y+/F8If1sppHo93Khe+1OcU/oiO836lKr13PYKr9C+/4l9/x82Af4ULXoAseBlzcBg7lr/E+/iC5RyDzU+jhAcRcsC6xWEAnqG2gJdbgalT8JCD3AANFDSRSoc6LAws7+SPcnolGSrURgH2B7JJoiJeqSjTdoYmGzo+DqybQ9LtM3f2UPKh0ynBMK02Uo4zZ0pr4gl+lbEN0OvebzEWiLviupLahfAcvqWfr7ttLDqTZTX16m4/N0CBDhq/KDBJ4BIjs0cWE9zz6y2b8Eat3qffXJe2GZ/FLXOEokN05N8v9WKW6bZhwz37qy+gG/jh9RL0u0yYAWow98hNXSPpBWFpfOl56MG4lX0E0GeZa60lt02TtNeUZKIoqjOrfnLmHiT/3UIlZ1PxzHntuO+ILoNhLob5xKjp/FV+xFbhU7WVyKbM4bMvnw9W1uHvh4VBEvDcVQiN3NUKsv0yXkfa863V3614UTlKdY5pdbAMNSs4YgOGZ/GcLJTmDn6XfrF+AyLb5jZI0up5Y/ApabLzzicXaj0OJWUOuixt6dNQOpmLHwW7L9MSIqpZvdHml38kuZQgH3C/he3PQh2YAFPKIeixSN6clyD7DZQIIUuny2+IRYbtdEV5pIpH1BhcxBFdDrg7aKEbd6jafuiQBDMoxS9g2eurOF+cNl8mKxQQVVRbSvmrmApUbNQLWuctPxTdhEA5xIjXOsbjLr/vmw//r0uizeuWKb2iOrSxDi5xPhy+zbs3pOaOgy58Hex2QaNVIFgh8VDhPmflDeQmqxwUGVyuKAWtXU1LtixoZNPOTYAIh3QF1/YOYzmemI8xskAy+rOi+nc9jjpXIDo6RYZfL3y+kSbMBOZWJmTbptMz7oyQ9gW5XZnpQHoF2BS+8eniQozkUYRAGRXj7wKaJJBjdTE38+UqoKLx8hrKQ5wb0N+g/B9M5ThdI/6G1rgUhiz1Qu97POXJCb7tSAOJv8XS1Nx+eX0+U98H9t4+FwLTPvpV2/vu2/K3qiqg59lsxxte3HTbWqefPLZEt5iqUMsZL6yP6WrCttwcMdRHeWZcRrdUXR63KZp7egaKljTR7T0fFmZMUscztcAgBUr74HEEqmy33MSxABtLrKM6jwyB8CN8Oi8mVB8yuoBAVnB65IQfObIvJFYEE9d2qtN1F++wOaX0uuC6KJhEo6cEXUlyJpL8V+BGgxs9rWOV0bv3zAdOfJWrrl3SEp89GWXq2msJCyl5+tgiRb5jyn5rQJ5qDwQfwTsFZPgLxTcrntltvFB4nZSgWt/fvCHPcgATyoq9SKMUKc5DMRrvdZ9bostuj2Ncjr61CXEhizMsY/AgkJ06o8T2X1iNvX8+W0nHanAA2ocoy1LtHXkvweZw67SXH2c0/3SMvkmGduX+9AkLDNX/vaOdqA1VYwXSqykww57aA7Brfdbp6fgx2WaZgm23MRDm41f70GUX1TqclBniSkGrr/SJBWIVEKiUgkLsnRIoqrONJ/i/RjmoYJPfitYE/FgZ/BYWOm/GPBVkZvARp456Uo8LEXmUxLsGLm2C5vvvTVqLZiEMRHP7rKyJvdXl72u32wKazuKJlhwBljWMXJLLyXl73P0l2pxlMwrITFfVS3WGG36seB0Vzm6XaeL3ufet0atWSUzT3dzudDUD4bVdlbREXNCA8Ubfb8gEhWIj8qKQAy62Z7ltAL1sL3nttnzJyj5/Ph5x+nlWEo8Etto+JYsi1Ct3NIFWFso3YROgdzHKOQUVJVk5aagiOZFMz9GYIYR43xkDEYEyLoCVYiqZLRJdxiZK2TsI8k2IM48wiYK2gTpEYFTj9qW8IZ7jFdAsqb4U+6T1eQLKv0jibAbvjOobrYdiyCfSa/qFdwZ9hPpbpthcdKUN03GUhxUWviLW60bvXWDEH3khGoudv0GVv07poiOdvNZBAPmlbVJB7ZkpvpHrxdLqdbxHvfjSqe7xf1xa+3y4+Ab1sHkFqssy7TqbtZDB58VDErjQsOEwgTM7Dok0ku3VbERdBcoTZtlIFu3mc/89vomBsoV7nYBpk4ZE8HsuY13J/4BU+0QTWyyZSW12fzzw396YaaFvUJfN8vu1f/NOhULKKQSBNZhI+M+355BlCaM/Xz7asfDHnyrA3ujwq+MBMs8AqkPhKSPZRkh2rAqiHrTnTkTMcuUUJIiJZCDt7Bc2oXw6DLsKGKGdhsXV3Wjcd4mS+SEjFtgYRMydaBk9Qg9KDYKfdY8cRIeVSkkPHqxEQ2FWsByJeIVi7kIDOlk+2TP+83/urJz8SaRhzc1IC1qQ65Etthk0ovST1qXMD0gUrM759/7GdP/biXvBlYHyYllKeb9S8wtZ0zXpnx8kkPcvSqyKaXlAirso3bGUlhi6bSnd+fx93r5XQ8+dXlTr+D3Z2TYoPvWJ/SQ5Mo2GkrL/kHX7PjRsAvuMgjSmYQ+LBdonQtaK/dVqydi4tDgLBtSNa5gJ9AbufwNuStDvnD4BQjSUW6asAGKSdtcEwIXxtYFEEvjWOCx0lHxWQeIvi3UuuOn4StbzhYeLWnAn2hV0dq6swXkE3GIpzDKYzGApJBCnJnlDQ71Cx6WOnx94Q/X6t307bIYg5MYzzklMsAOYwEAgmk0ge3HAe3Ggf3ICMZFUmqjZKOQU7RsAuNSnv4eyi7266Q92Z7te0dPDyLxGPhKHeVg8a9WIN2HxeeM8JkG1+HJNcA6MtAWAalA+G8sUxb8PD+aSk5h1EmszbBHCKZ66ODlD0RokQb38020xG2TPtDEN7o3B46bHGZvodZBNWDt516NK9m/A72qsu0wPwr/R0F1YX1aXwvd1ALr/wW+dm+fPNzzq1kxAAgKKE8zG6MvPJwKnZEf5jAWvQEtZS0qOpvExAi6RG31ZtzH5ZSSjIeVR72NjPSzNMUStAtOx9QY1RYnFf9jI57Jmhk8gMrbGSOVWSMcX2M+hrZnf9YduegiqAXjDLRymbqECR4VKw0rqrW3cwt7nbnqq9KMO4XCq7wIG0NWqIG8UpDd1uP2KMXtmp/v/It1GB12Z3TCob+0np8w8iR0u3OPi2I6Wq965UtV4O+CebpErLH6PQ2lNoAnJDEszokaZkP89u8BGGMVRmDO6pE66ikZX47ny7TZe+XW0Y1i0OcKRUdmI0Y9LzhMl/e9gE5iRVcYV86bDuDK9u4M8I+Mi/hweSCX+ckx6IsSNmYOu7dtRUKbCkdkIbkIHrP3OPFfE7Xuvak7OArJsI/IYfTVB9NX4hsr9SuctFAm2kFcGZDgadWlchMvUzC7DWo86rPKbiC3NkFFXd4hjWqjTVPJ3ywRleGWh8kOI9VBgZPnZ0HFQreerV9mf8KytFSz5IIli7BWbceMe5mhzA2yWFM1TDoSSF6SILDmjGqMKJgEfOHCCwO/JIOxDgdItHe6sfHq8/c3KspZlByixgT979tooeTkZZ+S75OYVeYdmwB2srg1B0BCD+QUoLMgovB3UndODvIvyHaRTxC9jL3l5GcpqwUOLzhVwj0LdcDu6kTwbfoqybCD+vBIkeHXnnRwCOzf/C2EV6nuqmZLWFpAJ6o6fHMPY5xkRsE/d+QWs2JYF4qWCKKwpeq0DtKuhZBAgeZd/aYCjUo8pdjCVC0RHjUZGbAbSEZF7B3BL1c4Cp4O0sKBcTJAXZnk60BxcicKNLyGoCKVR/eWXU23XZ8/K/DjiBCRd4H643pZLeycLUCJQ6IFu5ahHUEkbiFxPSl+xHJwCEWpCuOb6+G1/zuFD2NpJ0AYIa3QbqR+EjApwAK1h0SzM+5P3EvCIcD0rACnOvg9+6Z2XbotjXTe+5jNagDuOltWvyJ++dIAeKIcg/fuNFREKZyCCIIqJxDFBhyDggu3EqgGBaEg8EbEd/mbrVQnKRFtOIMuk0HAWLyTzhnAEmBzn13Hr0ohkDkBMqAUOlys9q5kD0jEUhpFJ7l7j/FEoFuPSMXDAyJEA10V0EkGggsPNt2ISjmFi06i+DVIomLEgboRECGhzJMrNpGpUL3vXg/uw8HFwjyEggqAftFRI2QGayCgiYEybsKVw0O9fQFKBwiNqfACq5HOknMV1cANgsVNsNr1OIXSHrF1YpjUCi44d+CyUyouhFjAUFGAbfnBU2O/wTNG/LQBtcUGa4VmUiMKwQjyaXIWiYsI1NvWekkwDPLekcqMSamKErJFiH2gQJYIcKvBi5HUsZS6OPh7wnVOfw9ZpKoL8R6BIUviYYU4sCoVuTSr1IgkxRFpK4ZU04ULj58faHpx44bODjpZCj+7LfQ3gg6Kks3jr/QMYo1JPEfrN3Q8YsJdShsLhpJEmKiopke7ysqQKji9KwUAUTfj/wzuJmiUoRCgEm5mWQ3SMFEYYW1ftb0UqgxPL2QbycVCNlJyYAo2i+IJ2DVCmOwfpVq0yBajTQfFGNlJxcd8YFBYoxvY7hIHlVyNbKBgWV9VtVILyJFYOHEEwZP8hFKxOJ8EVBWdrmnUHdUZybPK7kk2WBB2AGrfiQ9oTQto3zsBUGJQuHamn+PATWeJYUFpHq04KFlcMNWGjJQMuPAaJid/hijrPnGS3EXpLMl+s+MSHuJQIu95G52LQlmhSIb6qKV20cySGPvJZCRFfoHhbopAijQA34kBkytNxH+IdRLigGjegeNL0vKXIaJQuMLVWKSUrMPku3p7JykNDoCQQYVMiPPUBT/gpJbSMrUiHlERoyhDd5DFobYqQDiwETWv8ac1mhPErp4yLqlCQvxLD3/DKOx6CWoC+PgWnaKIgErcpjAuVF+WZS6UP7CW5J/rAHmIlXbQB3KNoLNBvbwBXHvW/JKMEEs0AKY05alZzS7tayqJ9SPhYw0ZpKZsBa1HNFLQcE1thqReY3g6xSht+APwb9ATbvDbAjsMimhqHPa0gqVBmKMc4fzgDzcHUD2oh0QWV8iK0hAJQW0MQbOl7SoNsbA7HbEZeP7ppI2sjWRiVF0L2NNiuQOdqNAg2CuqNCMxJ/tsfd77AWZGHLPIgg9nS9gB6E5SfYUIE7QcS1ou7BOcevK1l7sI9yclv1maDCyJO0asd/Y6paWJwcKUgiVIxmFPUj93Ewf9/I6B+ooqpoHLjWsG3eEElSJFIwbm+6ooFKgkpqo1jGggONMtxUur3BRBZCUjgFhSDzksGQGLhm9jPM6e23QY6cVOWSHUoJXSe9VWoe4zocfPq5iUEU5kfB36X3FaFB3UmmXyQmfLQjMIUugjhEgPE1WVujaJhzalOuYc9Li40p56mvw1CqU59/Vj7u8zn7DndH7T+9/1FG2TXTXDNEU0JejD1TrPf3L696nDtIVFPDVesYP7gdLBe4HvUy3xxye0jlzTNy6WUQeAxncVOI2ytciO8v2MpfgdIcubmsE2gLA7bKG1GMOsZs4odj0iZQeFkOqJ4rZH6nPzC5RAsVTADUcfILTCwvuX+kr1eFd6y8eJcw7EF6FrJjMQKTyDolsA6PbX8gTlGYHfktOoDjq/7891v+/PcL/D8f1st2UXT5sWCZAXLRPILakznkC0SwiWVxLeRJqyePDPvcAYOgRN4l+77BV7f8XkQT8+JI+zOX1dPv2GuBMVbhfGc709TL7zdW9CiQTZQ/hp2IUEYowTMiPfH09BSptumCJ2G+MpSuug4y7cwmnrVGhjUaoPLnhwlu6dWdFzGGRJJ8XbruoCCDHnECjMF+BqsMHU7m+4fkWwBat+iVzUNDXSwgt1Jm2s4b2Pgq/VrWIhFQAMYSPmXVuj6gPo+QbVYPd1k7UdV3uCzBFVANY9mzg27haIlBEQ8KNYaFniJdmhRCT0B+Kx8pUHFNs+CUSZgSi4c/YeMl0lUg04eBkeolJJSaQmJxgk4XOgbJ+zEBP0WqOckcKcp1ZdTV48heskR34aykU59fgNne2L5tp6MyAbwFRqNQ/xC3KSBp5YaRYHTbCXW6Nu7cBAjBIHhoqVzaw2cChwsI1Ldy8nvTY2AxVggU884rn1+kaqICJFrIQmUawmcskjPAHCeUi4CoBrrId+JE/fsBAZlYnpRHHIS75JoNMWnWMAw4XtSOEiXIysmREZqCR7LdciqVTwce38Cp6EtehocFrFnT/glV4xAK1SBakes+y3nmKhJ++cYqOn54pmpaSHhOF/eDHidxcpm3DTeBTpDJtekl9byoO0ZSv+odtn41Np83DJJt4Qjl5wuFo/AnIPkaKwE005/IC6RnHfXr4winxTztjJU64AwDIrYOB5S2UmQzRiDgMAaiwj7Os/IGCHiCd7ppAk9+sOJHWmdBpgvHot6OvCbFWQERbYW8Lp8Jvs1lZrIqZR/6W6CVSxnja+UrcdS932ZigJMw7c4fr0+UUSG2tVS3PaPEM3G09nafr9f0UMqM2/pOWdbCvRn/Ml/2Ln1aUWlursWQPdPa1g3mUQiAr/hnBc1V4BR6uiceULFmrVaYT4269pFVPmsvqTHK4vHMY2Bj6iiIi6jooZkT5SXhIgIgiGwntEHj17CSL3HtEWcCBfg7+6Tx/wj/dWMCBfgb+iZwhQonWTVQLGTOoFrj5aZ3DLUCjEZWGCwvQVgSXvHOvg8qqwIEiWxvjQJG2xd8BDjTsBBDwTwftdLmmwaV8BA40bHNKAEIzqE90/jOwArQTjqmAXgJSifQXuSEFfBIwGUIlQWiwCr/jFzsY8PeSIEcCFQE2HBnOZ+F/KdAfk9GE4SV7HpAyTiWUCVVjavS/GW72W0BmydQy077/8TSySB7/51PG/K//IdjXB8AuJIqpr8M0MsWcCNNKZEgqeJsV26QADKlw6VQkuRQQL/4Z2zF4cWEM9vUQbgaQhQRsMaWdAmwxuc2/R8BWoteHMC08Xyr7kwJipeFX+DMBxIKPnoRkJRLtqQwTwVnUi/0AnMVfGENophJWxbR+CmqF3BWT/kn4FaKObElAgrNgmbS5BGylcmZMLSfBWfh7+IINKXMFTAvPlwdsfQDJQl5OZOgIusoXOVh2+O8taKQAUQkYFEFNhDJh55HhWQCTmHAINQG3X/wzgIZQ2BqpSJyECuksI6evt5eX+XL0WbFsbWTuR1wsPNaJegOJyUcu/H2skILL1kbGBqVNwx8N9nINkDCil/ULLlLONVoJbNsWRjqnb/MKkPEykSrzQZEq+XIKWF9WnLoXOOFqBGatQlrPK47RuUgG2ejIGzKL4/t8fPrLj13bygs3Sz/9aulnYEkusTGRAVctBUw0Os/Kw1tOGwrEhFSG3jJ+1OV0Ph1O33wyiUplUW91cvPldPGXQKdiuBhAiIpaRtZnOS0+YYTxmD8GXOqPQDYk6+Sprxcj1yGm3fcgLzeIZdPjxjPFu3k1el5lUB8lgCBNIyURbZ0gj1Ktfp123zf+3dtl3hQnQ8vGsxxvNd1yqJ5hG5G2WI0VZpNg7Hz7ethfX8M6leRN6uFDmFISIJi/zOfD9DOyLgm63H3Uw7E0uBBt+9n3uc4BQZtU9PoCJTiAiqzBnWvpsxQKwW3D7l7n3ffzaX9cXqb94XaJPlDtfaDCzJxvOTTZeCYLU/6ryedpmb5dprdonmxXeRrdhfJyd6O3y0rCltihjZx6tIYUSrtstufL5XQJzbbSLFfNJ9bK+qlWfvbQslQONcj6IMPPaKT9xKS/HG7XgCDXtEJQ9Av9rzZBP5u3m1p0a1+jtM7Ghk/M/MtletPPh15OFOfnM7Oy2VcOiEGad2k4+LUGjsqj+PeZERMnxChHG9xoAHFRQrYt9U2W6fA2v50uP+/vp98zxo7eUYv+ctxnqGjb7hOnkhw9fx8Zmfn+0rtQeUAcbUhP1n3iVDjG54E8Col8MkOpb7xMh8u8Ox2P826ZlpUXK5pMeeK4wILEHUYIX/3KoKnTSIp+op8EhKiWcAj7mb2NIa8P3Ztwi0h8AuBQAxVf2Mv2mU1/Ob0HzOPy+hzY3FR/Yi2uRtPnSCvZU9nY0hZqbsF6LFpuWknuzGD2UaMoM32dj8/xJ7ddZbyr8hMr6TpffsyXeGs0Ahj0BRSp1n5mIjYevmkju76uf32/LNGcSMorCKW0hQK9y+m2Py7DdLn4mrIynHQZAsJhkAWu68LT8zLtvofye2pAhpoF+d8bXtO6AMNymVZhn+DDqrSqjdULx5dpf4gEw3WMaS7i4yf03l5tnYq5qJkHT9E9p8i7MrKe4nn8IE9StXJIZzToMkLVoc/QK4vF663ZWlVCxwfnKyfahQTKmk+XHn4VeQokrVpdJYFdtkMmOl7tHmLuvkpVGCqSYtnMvu2v18DuKmb9q8suYvBsJQqAN+cwxE6AUKV3hQeWtMloBIeaPCdgQRGkaChCJrhKWAxkae8XiGRFbz1BbQQQoPAieuvpBMbFCdndyk7WoIszO/N//ON0uwSbzOj6kT3y1zaD7nPGvR3VjDq5IvN/7pO54JyseO6AB41Biqglzxwn66moNLISxcqRiTexJSYpzJun52AOCTlNo2oMCaX1DIXwZQ7CrUqd0lbXmF0u+2/f/EyvaVWMPcugNgZOewR8KLLQnQp7JzKPE3CX2j6Twgw+bMZqwD6sSicVG33zneFeu7d7bGWTQSFe9m9vocMxqnoVyAuw6Y6666xIsm6ISKfJ9HZebn5LiXpBuLobPj5QqpCjcGcQBeTd6QoleZ7KbtMDbBO3K0qOKoI7wLZEAXpEz1JiHhARKTZPPDSBEoRWoCKRL5qT5QS9SCxyJ7ucEgW2jCDqcrkddwFr8Fh5wHC4NkNhIuJh0tdiMroqAC4+++FjertsVKXrG/bkZloNAg5rXf9a9rLiQCavQu4c/Xmc/1rm4/OW4wyqHGKSyRbnlrUD0rkrls9QE/ADqMlA8QleHrYwGF+TKFvU5l8hkjt6YGm9NEUDq9fDKfC6JDzU1inMqa17fVKXQxDSqLrlTca/vB2Di6Ad1CWaEQNa3k/+QV2pmpUozNKVzHSlf0C18H76ryBkV6tmjpUjaefnORCMEssS8JLOeUuAehHL1QmqJRxM9JUTqQPJxUggWF62WnDEoIJOAttWv3R+nsMMgebHBLcGAJnkcKfmPRAf4oDGESyY23kY60J92yP+cZi/TTv/QNI74wDpdBgk3e5l8pW7jOaY1GBRzjFb/DwHjmGndxOSVFMwUZDOBznR9Ci7y/7sn5i9zjJfIYHdt7mHDyUuu0qVuCwh+rg9++XwylcUSgmLyQCeRIG58M425GNJ0Ep15AKumO8AnkXPfNyez//vH/ugo1Od4UxsfXs+r4oQ+13QZ+WBHtrCIvCaf6utt2RVnWtw5xiDFByIb2yvb7l1kK7xe056XXeGpKX4ThY7UFAFAV016gpFvvi6NaoPUwaguB0jGeyhUYEPFMvISGTc1tzt8fR+mJ+DjPOK91Kbp3SepNtx+jHtD+GDjr3q/xPEK7STeeSSA0D/wsevq/ZsWFJQs5/A0gFQxjPFGIDCCBdt2KOPzz7oudjbcfc6HcP5rFXeIgGCFhBkbnpda2J965d94Nw0ldrz6C5tEPPVbC5AzAf3suO9n0JuJ1HaxD4ncNOJdrZqJA4xs/Cf55B8SxchIb2fyaz75/ly+Bmq/o7audCSyye3mZ73O/+QNbpwbBJ0xtnQJZDvw/x9+jYHmAf74WC6OUX1qPOwbMk7bswcvcetRu3vy7FVnzLbiPFZ4ObtuH/ZB2N3+gmMLKlhzklwluiJr3Wc08XP4HSDmreu8I4VLnhwDNleb0C+Hfd/BkkVPYop82iCbkYjA/d1HtguGUeXkucC3Feie/m/o2VTSFgAbZXpQF7fN4ijVFYL0cuZsuNBPlu9x9w9nns4N0065Xl6wPWO9kojtYQGGIO4bKjjxM1KbHX/z0KfGedzg94ABl4Z9Y/b8TBffUI+le8OsP6R1Fno+Gj1LPfteNj7EpGNjj1F81FGyGw1+LaPEufqhyuSIb8dH2iYwNdZ30zNfwk5l8z6uk4v89Pp5SXy2VX3OO83XnfTIXh5I4/2joWhBFRdyiOIPjEWnXjX19ykCOeH3Ivuvx3DM1p/R7YF5t10thZ81mE/Xk+H9Tb0n2nsNL+ADR8ZHsvb8Xqed9Ft1Ks0gymV7ZTV2z7Sih7VKwEtuK47SzN6Xt2AMKOrpt3CjC67M9L2l0BCtle9YqTcweOKVQoIr/NQqWCTULeiIAQInz2ZBfRDo4cPtRBBQk6+kKgC8resVOPteLtGgZsOqIB0F9WxnKpU0vo5iHq0z49OWTdBzrlhWcUl2YgadLPEdtNUa+4HjbZ1XFlh26yorIh6CrKCuJIEbBHrwaJOwlayVFvRB61BOozjdv52mZ79DaYCOFAuZku6OyyF8I/LOLpcPxq1he6LUHYh0w7z/4Kfnr9QGES/g2hUTPC/p7jZU6zlZH4W3M568eYxY37e0AzanKEB3gi2VGR1Hg2H6XEOp8m/OGpVl5TQGtHFyXKaflpdfACDCDq/oOTnDiWUaho3YxAxb11ITSIC52CApn10u3CkfA3OKICGzYDwYWAPJqauhrvcMHWOd36IroRtNo6vAGQLOhHKLah8q/w60v3/nU5/+qHC03UYVI8UdZm+071FnzqrVoVJKTBHqAnquthobn3EtBTuSBO8CTgMyGIg6HNJkEuMClgHyDBQJ/I2IutMHoB8zz8DuFR/f6qr//Md/Km+/X+lCz/Ve/+vdNKn+uejvI2tRLe024uyx5fr+fM9ualO3Mxe8B23QU1EoFkaZxJ0lSA4R04w5kdJvkHdqV5E/ZicXJNqCs9myaAtVFRwc9U4sMXNhToqqGcsw9mUGgf1LERxqmdtEdVYHa6zTnHUuOZjqMkPmdAJV2y+BMWCWg0is6HeNYBg6QuA/a+5DtfV4PbPvWqPvAiN7fktiWqJkd4fPvhq8Y8kH5TRScJymc2r/5FaNeDCgvNUalmYSKD/fkdSK0ljW3jnzde1S9y/j4UU6xeDQ7EubEi5XUPQWPvxfAnIq5D2s9Fsyvsncetk7wsBEsjfA4I9JXu+C5AsggqKsoQnc3KulhcvlFfLcDi26haHKniTcUelUqmdoOdizgzBDTyOKAmqPO9wmI/fFp86ehw8zjGEU2Nho8bttvd98N4jMavgQljSOTDJmOhdkQTT4t9QDwb/Jvksfz9Pl2vIZa2n/HEtZPTGV7MpOkeJ6MIe7wvn7YfxP4NOiyzyKyk60DxZv/C4fke6fdQTrD/MHz+sf3+paIvkbo92dnIUfwjbqUXA8oM6Rf7UJqYoRcw0fDxtydeo/ZnS22F+faamg78zO9nVZLFYOqiiePV9d2ZFQtNCCjcv18mzHSew8HR7PZ+1PXmIKLVm/MgB8G5TFu4yWCw3ko8zVZF1jXt6YPugmiIkSSieJeB+FEtIAP9GHdD2eMKwSq0WUEXDQpwt+hcXU9T0M6rpFuLHhyYODD5iqUt4B0PsJ6Q3bp5HLfIJ0rxJycgst179qmyne0099lXoFCCSEXLtj9ei2I97PYj9IHdIdkUU+IiSEprQvFuxdIVOb+xwrMTM7hcVdh0tm9S3AXWP1LfBh/7dKQdx2f3mlINwNX938oFL8t+VhrCC5g1RMIrQgu4/hbD9nakJIvJ8rT+yoTe6K7FuqMkDJoySawupadMjbP3A21otBiCUQafHxVK2Hd1OvIyeNtyG8QNO2XMIlrjOOeyDm3xQadoKc0UtW7ZQtiZRnGWyxOVKKDPoXgVDmgrLtaLcO7EAvMkp1c38HjUFE6d5AsotM2R4HXE3C3g3FhCWTU9cq45GuM95lI7pBXZRIEe7QiLpH9Nl9zpd/AhPkoxV4D7lGiGyYUw0oCfOfP7b5NErjsCRhw1dTm7NNhpXe6M/vu6Pk98K06uxRy2WQMY3uOzD9HuvN2mM4RI1XZNYaCKjlHGOLvvJR8C1ap3eYnGSVJ6A/Qz1+zpKQFjUq02EqZUWW5wvz/tdAESqMsI4/x71xRRRul6Lz2TuVtqhsB/cyAMbnWsQ+sW9gotjAE+LcxAGl5AZ2QyHjLoh+xJ4Tw35PXHCtPh7LeG0VSLkQf+cPNool4p6LAs0yQw2sYrZiV/n6+ltf32bll2g5dV7KTxYHgpzeA/jgbyAui1k8KQDGH7sff57Y1Wp0Ew2NiS1rHWYMmqcwHliq/bhZUf6XPZGuJMW7hWjVrI31HD6hAuMzL24/5CBITe3iH0HRiQFrJwfxL5hRre2aobDFWsYmhPckgEP/9hf9yEWRReszcmcoRCUK5asw/lHXTeokMuh8yc/ZfB9ChU4OpnXSPCSCiARZI5cQE45AcgAuIcgEb/71olG4gpfX2CMsDbI4C4rhWhtELzUBGXEyKJU42/fqgWbdYL8GlCjA+oigbtYnk4b4+V00VpHWqkzgugT1WtGLg1O3Zad1owGSYOMJI4HLu3CzwGifDdVmYd/sPx5zy1p4MhAg3iDSLEWnYAtcu75g/kx6rrDny/T3sfyWsnzSkEBkOB+bDqs3FhJc9UykkhwnYAdAxMKMQpoxyO/wPYYwY7BaSfrOmqgIuzMtMw83uFOLBfSQ8o+u9qUNaY/DMasdSHdmWT+cwuJSTxD5FQh09Y6cAAyL0A4ghkG9xTSiGDOc5/J7SMcTNwy3Mc8j2p4g7zxJA4yXt2pLcgzikwQg96y/D4dvvs9SWq8jSAo08C9mgsK26Pa3GssAuaxUt3w9yB2sPX4K97v+xS0fhid0JjKMO5ElwoHmQMrYDuu1TalnlSz5PTXG8JXyyHTl1WfP+fcvU/+mld5ppBzI8QTuS3cgjg6cPO5sNGtUTJVMHkhuDzIncbUhtAZJ2QZ9w9FWVxy1ZNnQcaTym0Ds5t4lQSrBxH/gqNDT2i8T9fdYZ6CcEpSUHesMBQfSct8eZsu/o7U27Do0uJ1HFTYTTEOITDS9C5fzbtCqKNy8nHe9DZx3rTxyWOQUjSM63RYPF42YIlSQxYeinrn0PsUaHXp4JyMCvH7HBw4v9LM8T5/vZ5232fv8JKczILGK+U2MUsebjvnB3CTgfMMCUVAnBOavA5PRnFeFzNktLoo1+vQkboOl0BRR/RdkLtyrxPr9oJ9Z3Tn7+iCwRF3pNCWwjbgrYo5khBttx+NFUqv9KKJ1MR65yFCehcmv1Klu5YpBkK+YyyzgHwja5ICfycVhxOA8JT2cAoknlIcjjSFt18oMgAn2VFCFtEy1T8IPx+oA4IlKyDpYMYajR4bub3zMu2WU0AJVInu2S9IHHGlUt6GQjycgLbwKHZPEBBcdyodEa4ZIIJtp+Mw3l+noKVTzcUKYhah6Y5rMNYmE2UjofOe8QFfp2X+ETRuV/qh9xhSM/X+LZgytfcVyzw7Ub6KSK3Ok6FWbHQvYgvizGX51v1w5yKqtiBVE1SS7vBzl6hA5btGAbehqRno0hk4DhPdJejkoUIgjkwcou7sdN4jFAKZ8oBvxbYqAVHnYYhUmuBYg0cFX8jUY+waoIFMpuFE1Zm/kCsQteYMi4SUr/u99AXJLDCaJSVhEKSL2N3C7CaP8lQfj+h8EnJkASmQf/jjwBVyZAA4ovlYyoch3qzBjFoP9GHBhEHxLsPDP2jp3XZeAJ3wCS4xVyz3QSLPMdht0BjdzXudL34W06guHsm4UlihDC2XMnCoaGQb1blkexM2rcuCuXkRZG8kiMWS/A3Vn20x4cOAUKXLZGNe90FdolP5G3mNJIFYPCqxhjKMBO+v+6CTR/OzkfXmDGMCWNJwY2NpoTKAvlHn4rotLbr4nGfbwAVAktjpGjAbzsyN+IoIQ9kRkAo+cddKTB2yyPQRGXJmfURqKfKYEOJHev3h/fUUlAY6PctS4dCv2AULeaQOmfjUKAFGtzVa6oXoGpylpgIGBhNnwaFoW6JScM71Vo/J9s/z1XeZBvVpBJlfzu/ZP/sw616tdCXq4ZXY3FgMcY3cgsI4x/j3vj8cguxOAR+NQONR4xXny4DIkfqrIyBmAPtIRVS83IjkttQoja/QDm37HeKYLhOe+/l021kVgpfBg77v/brpqNKYxgA8uiLuGk6PcAmYIvTmHBxrBK49bhAA01J+u8gpC7rxuLojTgtQzEZePfsM0m/jL/O6VXO8lHQl0Mn9wHPAL0Ls5zaHu5cJhWzxw7nHzk7rFhbqDsTrtqHHa6w7ugx8K2PJE4yFa1HKI8MNW18aw9wi/FrRmInDH/AqJtsRDRg4/96FAK+XSEyiLolgrGIXp1wOuhSTyWMhhc7EE+SbEv4bZJ7/Y5LO/zH5Zh6hGINNTijYVIgGhNNrEvEDYwXq7tCTA3T9PyU7K9M+yKlTHJbQekQ1IwotowB1Zi6U5fX1dPCr2npDS1EKeb+8BmVfVQjVYC0Y1i/wpv2ou1b75fV0CwAS6qnMtYwwNeRVEUSBYZWYiQ23Xgk3RWXSbT+0SrCkLJDqTHGBPQRJVQGs4HnLUAgJWQHiEbSFbockswc8nQhs7WKHRzg3ghsyLkqINg/EshJpzSsViUxE2CJ9mXHPg7ZY06gkMqSbFXwt2CGWMTnZUirdizxdnkN9J6mJime3aJMofRR5qdCb5MGLvnrxyDwuyh7+dghIodUUZGpNtjmVYJkSzQQEl/0SNFCrIChx9ye5sTPAn3WUp03fyw9nWknqZ8gDBL+PbGdDoVD1Ntaqw+e/lVWPGgpJUJ6+yaz0zf7+W0zd20rNPSROwM5Y8tj7bwlK4FYq7eHsaQoROJvdEIRuWimlh25scNoU2Hw5nKaAIdm0UikP0I4mLvXlrYbvL3EwgF82heCm1er+uJguoKcUNhGWNGUtss5m+PajPHhgs7D68rA5BLktYRIxExpUCkz++X7eiE5f5+k5hKkMHlVCRzekEMf0sH/HoQemO880W1Q+sYn9p5VNDhFVzt/A9RJipx/Xv/sHjbti0Qg4kj4MaDoCV4V95JRE5yDr2agy9pnc5fZWvl7C6iqr1R2kyMZM4L4ZDRpFtSO8YbSlo8A3i8FDqtR9/LgiXEwGer+p0S4Vyv2eRrtEsPabGu1S4djvabSLAq5/Q9tcIsjRaSq3xbPK716X6c1XJfCxpNAGMGRgHT9zst3iU13AGb8QcdV84jiLaf+NCFK+QImtbj5xkN3ic11iYOEqtJ9wFVbWDN+k8BIIsGw/4SX8c76cQlUNqb0DNyGBZNeMRhA+nZA4mQkSSV5mabhlmeRlfoXpXm4nbjtuHfqtCYYEJnkzYfNl1X3107u1WowSyWmRiGbSmVCGjB8dOHEqx93IvFRCyCODEiHoJT3+31eh3v9n1bYPnkTLF4wj7eoV0+sf79eAv08VZPyIVYFikDp7QTSa3nMbTWDKnse5bXqdEZoYLglMYAcRqoE6DPPnHIiqq8kfZmgZ/1dYZ0Vx/c/TzZ8ovQBVpBOynjF+mUFDJLvDBo40+heRVmGxx70I/cz7j7rCj8fLste1xg8UulxCyZ0N6IdF40yqK8eVYFkFadwPdC24/+ROIlRnofMBilmEvIO78aGlBv+BQNZKqLkSTI+jmuk/Ql/ZxYGSiyjusp7X8leiGRjxHdvMOlyPHVlcyNdABjagKFgOrgjnxwamykklxB5xdiHZAnpTQVVGTU4qpECb0VGnb+qjwI9g9VDFkw0iGT2n+N60GTq3DOb9n6djqEwpe1WxiESPFxldCAHEtAjMYhwsJ+zx6KQ5Yn0iw9orBLxbraR+G+mJVmUe1D+vi2evHmUbasXuy2R/HXYpugWdV8DIhAk1AdiHp07SRpYZ2M/Z4hBl6hTRgB0VVbT//bcv5/15PuyP85f/8b/+9//5P/8fSxDL8g=="; \ No newline at end of file +window.searchData = "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"; \ No newline at end of file diff --git a/docs/classes/_questdb_browser-client.QwpBatchTooLargeError.html b/docs/classes/_questdb_browser-client.QwpBatchTooLargeError.html index b7b5373..ed31804 100644 --- a/docs/classes/_questdb_browser-client.QwpBatchTooLargeError.html +++ b/docs/classes/_questdb_browser-client.QwpBatchTooLargeError.html @@ -1,7 +1,7 @@ -QwpBatchTooLargeError | QuestDB JavaScript Client - v4.2.0

    Hierarchy

    • RangeError
      • QwpBatchTooLargeError
    Index

    Constructors

    constructor +QwpBatchTooLargeError | QuestDB JavaScript Client - v4.2.0

    Hierarchy

    • RangeError
      • QwpBatchTooLargeError
    Index

    Constructors

    Properties

    batchSizeBytes: number
    maxBatchSizeBytes: number
    message: string
    name: string
    stack?: string
    +

    Constructors

    Properties

    batchSizeBytes: number
    maxBatchSizeBytes: number
    message: string
    name: string
    stack?: string
    diff --git a/docs/classes/_questdb_browser-client.QwpBindValues.html b/docs/classes/_questdb_browser-client.QwpBindValues.html index dbeec65..246de3e 100644 --- a/docs/classes/_questdb_browser-client.QwpBindValues.html +++ b/docs/classes/_questdb_browser-client.QwpBindValues.html @@ -1,7 +1,7 @@ QwpBindValues | QuestDB JavaScript Client - v4.2.0

    Browser-safe typed positional bind encoder.

    Setters must be called in ascending zero-based index order. SQL placeholders are one-based, so index 0 binds $1, index 1 binds $2, and so on.

    -
    Index

    Constructors

    Index

    Constructors

    Accessors

    Methods

    Constructors

    Accessors

    Methods

    +

    Constructors

    Accessors

    Methods

    diff --git a/docs/classes/_questdb_browser-client.QwpBrowserSessionBootstrapError.html b/docs/classes/_questdb_browser-client.QwpBrowserSessionBootstrapError.html index a4686c2..3b4e204 100644 --- a/docs/classes/_questdb_browser-client.QwpBrowserSessionBootstrapError.html +++ b/docs/classes/_questdb_browser-client.QwpBrowserSessionBootstrapError.html @@ -1,5 +1,5 @@ QwpBrowserSessionBootstrapError | QuestDB JavaScript Client - v4.2.0

    Class QwpBrowserSessionBootstrapError

    An HTTP rejection while creating a browser qdb_session cookie.

    -

    Hierarchy (View Summary)

    Index

    Constructors

    Hierarchy (View Summary)

    Index

    Constructors

    Properties

    cause? closeCode? kind @@ -17,7 +17,7 @@ url?

    Accessors

    Constructors

    Properties

    cause?: unknown
    closeCode?: number
    message: string
    name: string
    responseBody: string
    retryable?: boolean
    serverRole?: string
    serverZone?: string
    stack?: string
    statusCode?: number
    statusMessage?: string
    timeoutPhase?: QwpUpgradeTimeoutPhase

    Node opening phase that exceeded its deadline.

    -
    tryNextEndpoint?: boolean
    url?: string | URL

    Accessors

    +

    Constructors

    Properties

    cause?: unknown
    closeCode?: number
    message: string
    name: string
    responseBody: string
    retryable?: boolean
    serverRole?: string
    serverZone?: string
    stack?: string
    statusCode?: number
    statusMessage?: string
    timeoutPhase?: QwpUpgradeTimeoutPhase

    Node opening phase that exceeded its deadline.

    +
    tryNextEndpoint?: boolean
    url?: string | URL

    Accessors

    diff --git a/docs/classes/_questdb_browser-client.QwpByteReader.html b/docs/classes/_questdb_browser-client.QwpByteReader.html index 46ceaf1..13b4304 100644 --- a/docs/classes/_questdb_browser-client.QwpByteReader.html +++ b/docs/classes/_questdb_browser-client.QwpByteReader.html @@ -1,5 +1,5 @@ QwpByteReader | QuestDB JavaScript Client - v4.2.0

    A bounds-checked, runtime-neutral little-endian byte reader.

    -
    Index

    Constructors

    Index

    Constructors

    Properties

    Accessors

    Constructors

    Properties

    bytes: Uint8Array

    Accessors

    Methods

    +

    Constructors

    Properties

    bytes: Uint8Array

    Accessors

    Methods

    diff --git a/docs/classes/_questdb_browser-client.QwpByteWriter.html b/docs/classes/_questdb_browser-client.QwpByteWriter.html index 07642eb..b5e07d1 100644 --- a/docs/classes/_questdb_browser-client.QwpByteWriter.html +++ b/docs/classes/_questdb_browser-client.QwpByteWriter.html @@ -1,5 +1,5 @@ QwpByteWriter | QuestDB JavaScript Client - v4.2.0

    A growable, runtime-neutral little-endian byte writer.

    -
    Index

    Constructors

    Index

    Constructors

    Accessors

    Methods

    Constructors

    Accessors

    Methods

    +

    Constructors

    Accessors

    Methods

    diff --git a/docs/classes/_questdb_browser-client.QwpClient.html b/docs/classes/_questdb_browser-client.QwpClient.html index b11d8d8..b78a10f 100644 --- a/docs/classes/_questdb_browser-client.QwpClient.html +++ b/docs/classes/_questdb_browser-client.QwpClient.html @@ -1,15 +1,15 @@ QwpClient | QuestDB JavaScript Client - v4.2.0

    Browser-safe facade owning bounded ingress and egress connection pools. Borrowed handles are exclusive; separate query leases execute concurrently.

    -
    Index

    Constructors

    Index

    Constructors

    Accessors

    Methods

    • Rejects new borrows and closes idle resources. Borrowed query sessions are +

    Constructors

    Accessors

    Methods

    • Rejects new borrows and closes idle resources. Borrowed query sessions are cancelled and closed; borrowed senders retain ownership during a bounded drain and own their teardown if they outlive it.

      -

      Returns Promise<void>

    +

    Returns Promise<void>

    diff --git a/docs/classes/_questdb_browser-client.QwpClientClosedError.html b/docs/classes/_questdb_browser-client.QwpClientClosedError.html index 71cbf68..63222b9 100644 --- a/docs/classes/_questdb_browser-client.QwpClientClosedError.html +++ b/docs/classes/_questdb_browser-client.QwpClientClosedError.html @@ -1,6 +1,6 @@ QwpClientClosedError | QuestDB JavaScript Client - v4.2.0

    The owning QWP client, or one of its returned lease handles, is closed.

    -

    Hierarchy

    • Error
      • QwpClientClosedError
    Index

    Constructors

    Hierarchy

    • Error
      • QwpClientClosedError
    Index

    Constructors

    Properties

    Constructors

    Properties

    message: string
    name: string
    stack?: string
    +

    Constructors

    Properties

    message: string
    name: string
    stack?: string
    diff --git a/docs/classes/_questdb_browser-client.QwpDurableAckUnavailableError.html b/docs/classes/_questdb_browser-client.QwpDurableAckUnavailableError.html index e6d06aa..c9c4d60 100644 --- a/docs/classes/_questdb_browser-client.QwpDurableAckUnavailableError.html +++ b/docs/classes/_questdb_browser-client.QwpDurableAckUnavailableError.html @@ -1,5 +1,5 @@ QwpDurableAckUnavailableError | QuestDB JavaScript Client - v4.2.0

    Class QwpDurableAckUnavailableError

    A requested durable-ACK capability was not confirmed by the server.

    -

    Hierarchy (View Summary)

    Index

    Constructors

    Hierarchy (View Summary)

    Index

    Constructors

    Properties

    cause? closeCode? kind @@ -16,7 +16,7 @@ url

    Accessors

    Constructors

    Properties

    cause?: unknown
    closeCode?: number
    message: string
    name: string
    retryable?: boolean
    serverRole?: string
    serverZone?: string
    stack?: string
    statusCode?: number
    statusMessage?: string
    timeoutPhase?: QwpUpgradeTimeoutPhase

    Node opening phase that exceeded its deadline.

    -
    tryNextEndpoint?: boolean
    url: string | URL

    Accessors

    +

    Constructors

    Properties

    cause?: unknown
    closeCode?: number
    message: string
    name: string
    retryable?: boolean
    serverRole?: string
    serverZone?: string
    stack?: string
    statusCode?: number
    statusMessage?: string
    timeoutPhase?: QwpUpgradeTimeoutPhase

    Node opening phase that exceeded its deadline.

    +
    tryNextEndpoint?: boolean
    url: string | URL

    Accessors

    diff --git a/docs/classes/_questdb_browser-client.QwpEgressQuery.html b/docs/classes/_questdb_browser-client.QwpEgressQuery.html index c693b0c..ceff9d8 100644 --- a/docs/classes/_questdb_browser-client.QwpEgressQuery.html +++ b/docs/classes/_questdb_browser-client.QwpEgressQuery.html @@ -1,5 +1,5 @@ QwpEgressQuery | QuestDB JavaScript Client - v4.2.0

    One QWP query/statement and its stream of materialized result batches.

    -

    Implements

    Index

    Constructors

    Implements

    Index

    Constructors

    Properties

    Accessors

    Constructors

    Properties

    completion: Promise<QwpQueryCompletion>
    requestId: bigint

    Accessors

    Methods

    • Waits for completion without changing the query lifecycle. A finite wait +

    Constructors

    Properties

    completion: Promise<QwpQueryCompletion>
    requestId: bigint

    Accessors

    Methods

    • Waits for completion without changing the query lifecycle. A finite wait returns false on expiry; the query remains active until it completes, is cancelled explicitly, or its configured query deadline expires.

      -

      Parameters

      • timeoutMs: number

      Returns Promise<boolean>

    +

    Parameters

    • timeoutMs: number

    Returns Promise<boolean>

    diff --git a/docs/classes/_questdb_browser-client.QwpEgressQueryAbandonedError.html b/docs/classes/_questdb_browser-client.QwpEgressQueryAbandonedError.html index 537aa0c..0431420 100644 --- a/docs/classes/_questdb_browser-client.QwpEgressQueryAbandonedError.html +++ b/docs/classes/_questdb_browser-client.QwpEgressQueryAbandonedError.html @@ -1,7 +1,7 @@ QwpEgressQueryAbandonedError | QuestDB JavaScript Client - v4.2.0

    Class QwpEgressQueryAbandonedError

    Result iteration ended before the server completed the query.

    -

    Hierarchy

    • Error
      • QwpEgressQueryAbandonedError
    Index

    Constructors

    Hierarchy

    • Error
      • QwpEgressQueryAbandonedError
    Index

    Constructors

    Properties

    Constructors

    Properties

    message: string
    name: string
    requestId: bigint
    stack?: string
    +

    Constructors

    Properties

    message: string
    name: string
    requestId: bigint
    stack?: string
    diff --git a/docs/classes/_questdb_browser-client.QwpEgressQueryCancelTimeoutError.html b/docs/classes/_questdb_browser-client.QwpEgressQueryCancelTimeoutError.html index 72cca1b..f22b112 100644 --- a/docs/classes/_questdb_browser-client.QwpEgressQueryCancelTimeoutError.html +++ b/docs/classes/_questdb_browser-client.QwpEgressQueryCancelTimeoutError.html @@ -1,8 +1,8 @@ QwpEgressQueryCancelTimeoutError | QuestDB JavaScript Client - v4.2.0

    Class QwpEgressQueryCancelTimeoutError

    The server did not terminate a cancelled query within the drain deadline.

    -

    Hierarchy

    • Error
      • QwpEgressQueryCancelTimeoutError
    Index

    Constructors

    Hierarchy

    • Error
      • QwpEgressQueryCancelTimeoutError
    Index

    Constructors

    Properties

    message: string
    name: string
    requestId: bigint
    stack?: string
    timeoutMs: number
    +

    Constructors

    Properties

    message: string
    name: string
    requestId: bigint
    stack?: string
    timeoutMs: number
    diff --git a/docs/classes/_questdb_browser-client.QwpEgressQueryError.html b/docs/classes/_questdb_browser-client.QwpEgressQueryError.html index 20b3aeb..a041dbb 100644 --- a/docs/classes/_questdb_browser-client.QwpEgressQueryError.html +++ b/docs/classes/_questdb_browser-client.QwpEgressQueryError.html @@ -1,7 +1,7 @@ -QwpEgressQueryError | QuestDB JavaScript Client - v4.2.0

    Hierarchy

    • Error
      • QwpEgressQueryError
    Index

    Constructors

    constructor +QwpEgressQueryError | QuestDB JavaScript Client - v4.2.0

    Hierarchy

    • Error
      • QwpEgressQueryError
    Index

    Constructors

    Properties

    Constructors

    Properties

    message: string
    name: string
    requestId: bigint
    stack?: string
    status: number
    +

    Constructors

    Properties

    message: string
    name: string
    requestId: bigint
    stack?: string
    status: number
    diff --git a/docs/classes/_questdb_browser-client.QwpEgressQueryTimeoutError.html b/docs/classes/_questdb_browser-client.QwpEgressQueryTimeoutError.html index 1d354ee..929ecb1 100644 --- a/docs/classes/_questdb_browser-client.QwpEgressQueryTimeoutError.html +++ b/docs/classes/_questdb_browser-client.QwpEgressQueryTimeoutError.html @@ -1,8 +1,8 @@ QwpEgressQueryTimeoutError | QuestDB JavaScript Client - v4.2.0

    A client-side query deadline expired and a QWP CANCEL was sent.

    -

    Hierarchy

    • Error
      • QwpEgressQueryTimeoutError
    Index

    Constructors

    Hierarchy

    • Error
      • QwpEgressQueryTimeoutError
    Index

    Constructors

    Properties

    message: string
    name: string
    requestId: bigint
    stack?: string
    timeoutMs: number
    +

    Constructors

    Properties

    message: string
    name: string
    requestId: bigint
    stack?: string
    timeoutMs: number
    diff --git a/docs/classes/_questdb_browser-client.QwpEgressReplayRequiredError.html b/docs/classes/_questdb_browser-client.QwpEgressReplayRequiredError.html index b6dab7b..ef614dd 100644 --- a/docs/classes/_questdb_browser-client.QwpEgressReplayRequiredError.html +++ b/docs/classes/_questdb_browser-client.QwpEgressReplayRequiredError.html @@ -1,9 +1,9 @@ QwpEgressReplayRequiredError | QuestDB JavaScript Client - v4.2.0

    Class QwpEgressReplayRequiredError

    Standard egress sessions now reset and replay automatically. Retained for source compatibility with clients that classified the former explicit-replay opt-in failure.

    -

    Hierarchy

    • Error
      • QwpEgressReplayRequiredError
    Index

    Constructors

    Hierarchy

    • Error
      • QwpEgressReplayRequiredError
    Index

    Constructors

    Properties

    Constructors

    Properties

    message: string
    name: string
    requestId?: bigint
    stack?: string
    +

    Constructors

    Properties

    message: string
    name: string
    requestId?: bigint
    stack?: string
    diff --git a/docs/classes/_questdb_browser-client.QwpEgressSession.html b/docs/classes/_questdb_browser-client.QwpEgressSession.html index eb3fa25..8da3a83 100644 --- a/docs/classes/_questdb_browser-client.QwpEgressSession.html +++ b/docs/classes/_questdb_browser-client.QwpEgressSession.html @@ -2,7 +2,7 @@

    The server currently executes one query at a time per connection, so this session deliberately rejects overlapping query calls. A completed query's materialized batches may still be consumed while the next query runs.

    -

    Implements

    • QwpEgressQueryControl
    Index

    Constructors

    Implements

    • QwpEgressQueryControl
    Index

    Constructors

    Properties

    Accessors

    Constructors

    Properties

    ready: Promise<QwpServerInfoMessage>

    Initial SERVER_INFO; use serverInfo for the current post-failover snapshot.

    -

    Accessors

    • get serverInfo(): undefined | QwpServerInfoMessage

      Cached immutable SERVER_INFO for the currently bound endpoint. Reading it +

    Constructors

    Properties

    ready: Promise<QwpServerInfoMessage>

    Initial SERVER_INFO; use serverInfo for the current post-failover snapshot.

    +

    Accessors

    Methods

    • Parameters

      • requestId: bigint
      • additionalBytes: number | bigint

      Returns Promise<void>

    Methods

    • Parameters

      • requestId: bigint
      • additionalBytes: number | bigint

      Returns Promise<void>

    • Internal

      Cancels and drains an active operation before a pooled lease is returned. False means the physical session is no longer safe to reuse.

      -

      Returns Promise<boolean>

    • Internal

      Best-effort cancellation followed by physical connection teardown for facade shutdown. Unlike pooled lease return, this does not wait for the server to finish draining the cancelled query.

      -

      Returns Promise<void>

    +

    Returns Promise<void>

    diff --git a/docs/classes/_questdb_browser-client.QwpEgressSessionClosedError.html b/docs/classes/_questdb_browser-client.QwpEgressSessionClosedError.html index 4c66862..ec34627 100644 --- a/docs/classes/_questdb_browser-client.QwpEgressSessionClosedError.html +++ b/docs/classes/_questdb_browser-client.QwpEgressSessionClosedError.html @@ -1,6 +1,6 @@ -QwpEgressSessionClosedError | QuestDB JavaScript Client - v4.2.0

    Hierarchy

    • Error
      • QwpEgressSessionClosedError
    Index

    Constructors

    constructor +QwpEgressSessionClosedError | QuestDB JavaScript Client - v4.2.0

    Hierarchy

    • Error
      • QwpEgressSessionClosedError
    Index

    Constructors

    Properties

    Constructors

    Properties

    message: string
    name: string
    stack?: string
    +

    Constructors

    Properties

    message: string
    name: string
    stack?: string
    diff --git a/docs/classes/_questdb_browser-client.QwpFailoverError.html b/docs/classes/_questdb_browser-client.QwpFailoverError.html index 0dc2a1d..49b836e 100644 --- a/docs/classes/_questdb_browser-client.QwpFailoverError.html +++ b/docs/classes/_questdb_browser-client.QwpFailoverError.html @@ -1,8 +1,8 @@ QwpFailoverError | QuestDB JavaScript Client - v4.2.0

    Every eligible QWP endpoint in one connection sweep failed.

    -

    Hierarchy

    • Error
      • QwpFailoverError
    Index

    Constructors

    Hierarchy

    • Error
      • QwpFailoverError
    Index

    Constructors

    Properties

    Constructors

    Properties

    attempts: readonly QwpFailoverAttempt[]
    cause?: unknown
    message: string
    name: string
    stack?: string
    +

    Constructors

    Properties

    attempts: readonly QwpFailoverAttempt[]
    cause?: unknown
    message: string
    name: string
    stack?: string
    diff --git a/docs/classes/_questdb_browser-client.QwpIngressAckTimeoutError.html b/docs/classes/_questdb_browser-client.QwpIngressAckTimeoutError.html index 8baed70..520dc82 100644 --- a/docs/classes/_questdb_browser-client.QwpIngressAckTimeoutError.html +++ b/docs/classes/_questdb_browser-client.QwpIngressAckTimeoutError.html @@ -1,9 +1,9 @@ QwpIngressAckTimeoutError | QuestDB JavaScript Client - v4.2.0

    The ingress ACK watermark did not reach the requested frame in time.

    -

    Hierarchy

    • Error
      • QwpIngressAckTimeoutError
    Index

    Constructors

    Hierarchy

    • Error
      • QwpIngressAckTimeoutError
    Index

    Constructors

    Properties

    acknowledgedSequence: bigint
    message: string
    name: string
    stack?: string
    targetSequence: bigint
    timeoutMs: number
    +

    Constructors

    Properties

    acknowledgedSequence: bigint
    message: string
    name: string
    stack?: string
    targetSequence: bigint
    timeoutMs: number
    diff --git a/docs/classes/_questdb_browser-client.QwpIngressNackError.html b/docs/classes/_questdb_browser-client.QwpIngressNackError.html index 62fcf79..cd82a4c 100644 --- a/docs/classes/_questdb_browser-client.QwpIngressNackError.html +++ b/docs/classes/_questdb_browser-client.QwpIngressNackError.html @@ -1,7 +1,7 @@ -QwpIngressNackError | QuestDB JavaScript Client - v4.2.0

    Hierarchy

    • Error
      • QwpIngressNackError
    Index

    Constructors

    constructor +QwpIngressNackError | QuestDB JavaScript Client - v4.2.0

    Hierarchy

    • Error
      • QwpIngressNackError
    Index

    Constructors

    Properties

    message: string
    name: string
    senderError: QwpSenderError = ...
    stack?: string
    +

    Constructors

    Properties

    message: string
    name: string
    senderError: QwpSenderError = ...
    stack?: string
    diff --git a/docs/classes/_questdb_browser-client.QwpIngressSession.html b/docs/classes/_questdb_browser-client.QwpIngressSession.html index b020144..dd865f1 100644 --- a/docs/classes/_questdb_browser-client.QwpIngressSession.html +++ b/docs/classes/_questdb_browser-client.QwpIngressSession.html @@ -3,7 +3,7 @@ from racing its waiter. Calls are serialized to preserve the server's zero-based wire sequence. Successful ACKs are cumulative, so an ACK for sequence N resolves every outstanding send through N.

    -
    Index

    Constructors

    Index

    Constructors

    Accessors

    • get acknowledgedFrameSequence(): bigint

      Highest cumulative ACK watermark. When durable ACK was negotiated this +

    Constructors

    Accessors

    Methods

    Methods

    • Prompts the server to publish its latest durable-ingress watermarks. Node transports use a WebSocket PING; browsers send the protocol-level table-less durable-ACK poll frame. Browser completion means the control frame was published; durable progress arrives independently because the server may withhold its cumulative OK while a transaction remains open.

      -

      Returns Promise<void>

    • Publishes one pre-encoded frame without allocating an ACK waiter. Applications can observe later acceptance through progress callbacks.

      -

      Parameters

      • frame: Uint8Array

      Returns Promise<void>

    • Waits independently for the cumulative frame ACK watermark. A negative target is already satisfied, but still surfaces a latched session error.

      -

      Parameters

      • targetSequence: bigint
      • timeoutMs: number = ...

      Returns Promise<void>

    +

    Returns Promise<QwpIngressSession>

    diff --git a/docs/classes/_questdb_browser-client.QwpIngressSessionClosedError.html b/docs/classes/_questdb_browser-client.QwpIngressSessionClosedError.html index bf8ee61..72d6590 100644 --- a/docs/classes/_questdb_browser-client.QwpIngressSessionClosedError.html +++ b/docs/classes/_questdb_browser-client.QwpIngressSessionClosedError.html @@ -1,6 +1,6 @@ -QwpIngressSessionClosedError | QuestDB JavaScript Client - v4.2.0

    Class QwpIngressSessionClosedError

    Hierarchy

    • Error
      • QwpIngressSessionClosedError
    Index

    Constructors

    constructor +QwpIngressSessionClosedError | QuestDB JavaScript Client - v4.2.0

    Class QwpIngressSessionClosedError

    Hierarchy

    • Error
      • QwpIngressSessionClosedError
    Index

    Constructors

    Properties

    Constructors

    Properties

    message: string
    name: string
    stack?: string
    +

    Constructors

    Properties

    message: string
    name: string
    stack?: string
    diff --git a/docs/classes/_questdb_browser-client.QwpMemoryReplayAppendTimeoutError.html b/docs/classes/_questdb_browser-client.QwpMemoryReplayAppendTimeoutError.html index 2a11cff..6e6a055 100644 --- a/docs/classes/_questdb_browser-client.QwpMemoryReplayAppendTimeoutError.html +++ b/docs/classes/_questdb_browser-client.QwpMemoryReplayAppendTimeoutError.html @@ -1,5 +1,5 @@ QwpMemoryReplayAppendTimeoutError | QuestDB JavaScript Client - v4.2.0

    Class QwpMemoryReplayAppendTimeoutError

    ACK-driven trimming did not free in-memory replay capacity in time.

    -

    Hierarchy

    • Error
      • QwpMemoryReplayAppendTimeoutError
    Index

    Constructors

    Hierarchy

    • Error
      • QwpMemoryReplayAppendTimeoutError
    Index

    Constructors

    Properties

    Constructors

    Properties

    maxBytes: number
    message: string
    name: string
    requiredBytes: number
    stack?: string
    timeoutMs: number
    usedBytes: number
    +

    Constructors

    Properties

    maxBytes: number
    message: string
    name: string
    requiredBytes: number
    stack?: string
    timeoutMs: number
    usedBytes: number
    diff --git a/docs/classes/_questdb_browser-client.QwpMemoryReplayFrameTooLargeError.html b/docs/classes/_questdb_browser-client.QwpMemoryReplayFrameTooLargeError.html index 8d4bf4a..a2c657e 100644 --- a/docs/classes/_questdb_browser-client.QwpMemoryReplayFrameTooLargeError.html +++ b/docs/classes/_questdb_browser-client.QwpMemoryReplayFrameTooLargeError.html @@ -1,9 +1,9 @@ QwpMemoryReplayFrameTooLargeError | QuestDB JavaScript Client - v4.2.0

    Class QwpMemoryReplayFrameTooLargeError

    One frame can never fit in the configured in-memory replay budget.

    -

    Hierarchy

    • RangeError
      • QwpMemoryReplayFrameTooLargeError
    Index

    Constructors

    Hierarchy

    • RangeError
      • QwpMemoryReplayFrameTooLargeError
    Index

    Constructors

    Properties

    maxBytes: number
    message: string
    name: string
    payloadBytes: number
    requiredBytes: number
    stack?: string
    +

    Constructors

    Properties

    maxBytes: number
    message: string
    name: string
    payloadBytes: number
    requiredBytes: number
    stack?: string
    diff --git a/docs/classes/_questdb_browser-client.QwpPoolAcquireTimeoutError.html b/docs/classes/_questdb_browser-client.QwpPoolAcquireTimeoutError.html index dc1c193..8e9bec5 100644 --- a/docs/classes/_questdb_browser-client.QwpPoolAcquireTimeoutError.html +++ b/docs/classes/_questdb_browser-client.QwpPoolAcquireTimeoutError.html @@ -1,8 +1,8 @@ QwpPoolAcquireTimeoutError | QuestDB JavaScript Client - v4.2.0

    A bounded QWP pool could not provide a connection before its deadline.

    -

    Hierarchy

    • Error
      • QwpPoolAcquireTimeoutError
    Index

    Constructors

    Hierarchy

    • Error
      • QwpPoolAcquireTimeoutError
    Index

    Constructors

    Properties

    message: string
    name: string
    resource: "sender" | "query"
    stack?: string
    timeoutMs: number
    +

    Constructors

    Properties

    message: string
    name: string
    resource: "sender" | "query"
    stack?: string
    timeoutMs: number
    diff --git a/docs/classes/_questdb_browser-client.QwpPoolResourceError.html b/docs/classes/_questdb_browser-client.QwpPoolResourceError.html index 859cf43..5e8bbf3 100644 --- a/docs/classes/_questdb_browser-client.QwpPoolResourceError.html +++ b/docs/classes/_questdb_browser-client.QwpPoolResourceError.html @@ -1,8 +1,8 @@ QwpPoolResourceError | QuestDB JavaScript Client - v4.2.0

    A pooled resource failed while a new slot was being connected.

    -

    Hierarchy

    • Error
      • QwpPoolResourceError
    Index

    Constructors

    Hierarchy

    • Error
      • QwpPoolResourceError
    Index

    Constructors

    Properties

    Constructors

    Properties

    cause: unknown
    message: string
    name: string
    resource: "sender" | "query"
    stack?: string
    +

    Constructors

    Properties

    cause: unknown
    message: string
    name: string
    resource: "sender" | "query"
    stack?: string
    diff --git a/docs/classes/_questdb_browser-client.QwpProtocolError.html b/docs/classes/_questdb_browser-client.QwpProtocolError.html index aefc4e4..1fc6957 100644 --- a/docs/classes/_questdb_browser-client.QwpProtocolError.html +++ b/docs/classes/_questdb_browser-client.QwpProtocolError.html @@ -1,6 +1,6 @@ QwpProtocolError | QuestDB JavaScript Client - v4.2.0

    Raised when a QWP payload is malformed, truncated, or unsupported.

    -

    Hierarchy

    • Error
      • QwpProtocolError
    Index

    Constructors

    Hierarchy

    • Error
      • QwpProtocolError
    Index

    Constructors

    Properties

    Constructors

    Properties

    message: string
    name: string
    stack?: string
    +

    Constructors

    Properties

    message: string
    name: string
    stack?: string
    diff --git a/docs/classes/_questdb_browser-client.QwpQueryLease.html b/docs/classes/_questdb_browser-client.QwpQueryLease.html index 38a60be..8546fc4 100644 --- a/docs/classes/_questdb_browser-client.QwpQueryLease.html +++ b/docs/classes/_questdb_browser-client.QwpQueryLease.html @@ -1,5 +1,5 @@ QwpQueryLease | QuestDB JavaScript Client - v4.2.0

    One exclusively borrowed egress session from a QwpClient query pool.

    -
    Index

    Constructors

    Index

    Constructors

    Properties

    Accessors

    Methods

    Constructors

    Properties

    ready: Promise<QwpServerInfoMessage>

    Initial SERVER_INFO; use serverInfo for the current post-failover snapshot.

    -

    Accessors

    • get serverInfo(): undefined | QwpServerInfoMessage

      Cached immutable SERVER_INFO for this lease's currently bound endpoint. +

    Constructors

    Properties

    ready: Promise<QwpServerInfoMessage>

    Initial SERVER_INFO; use serverInfo for the current post-failover snapshot.

    +

    Accessors

    Methods

    +

    Returns undefined | QwpServerInfoMessage

    Methods

    diff --git a/docs/classes/_questdb_browser-client.QwpReconnectExhaustedError.html b/docs/classes/_questdb_browser-client.QwpReconnectExhaustedError.html index 829b31e..2ab2ec6 100644 --- a/docs/classes/_questdb_browser-client.QwpReconnectExhaustedError.html +++ b/docs/classes/_questdb_browser-client.QwpReconnectExhaustedError.html @@ -1,8 +1,8 @@ QwpReconnectExhaustedError | QuestDB JavaScript Client - v4.2.0

    A configured QWP reconnect policy exhausted its retry boundary.

    -

    Hierarchy

    • Error
      • QwpReconnectExhaustedError
    Index

    Constructors

    Hierarchy

    • Error
      • QwpReconnectExhaustedError
    Index

    Constructors

    Properties

    Constructors

    Properties

    attempts: number
    cause: unknown
    message: string
    name: string
    stack?: string
    +

    Constructors

    Properties

    attempts: number
    cause: unknown
    message: string
    name: string
    stack?: string
    diff --git a/docs/classes/_questdb_browser-client.QwpReplayDictionaryError.html b/docs/classes/_questdb_browser-client.QwpReplayDictionaryError.html index 354e008..14c20c7 100644 --- a/docs/classes/_questdb_browser-client.QwpReplayDictionaryError.html +++ b/docs/classes/_questdb_browser-client.QwpReplayDictionaryError.html @@ -1,7 +1,7 @@ QwpReplayDictionaryError | QuestDB JavaScript Client - v4.2.0

    A replay store cannot preserve the dictionary required by delta frames.

    -

    Hierarchy (View Summary)

    Index

    Constructors

    Hierarchy (View Summary)

    Index

    Constructors

    Properties

    Constructors

    Properties

    cause?: unknown
    message: string
    name: string
    stack?: string
    +

    Constructors

    Properties

    cause?: unknown
    message: string
    name: string
    stack?: string
    diff --git a/docs/classes/_questdb_browser-client.QwpReplayDictionaryPersistenceError.html b/docs/classes/_questdb_browser-client.QwpReplayDictionaryPersistenceError.html index e851edc..292ce4f 100644 --- a/docs/classes/_questdb_browser-client.QwpReplayDictionaryPersistenceError.html +++ b/docs/classes/_questdb_browser-client.QwpReplayDictionaryPersistenceError.html @@ -1,9 +1,9 @@ QwpReplayDictionaryPersistenceError | QuestDB JavaScript Client - v4.2.0

    Class QwpReplayDictionaryPersistenceError

    A replay dictionary sidecar rejected an append before its delta frame was published. The reconnecting transport has permanently switched to full, self-contained symbol encoding; retrying the logical batch is safe.

    -

    Hierarchy (View Summary)

    Index

    Constructors

    Hierarchy (View Summary)

    Index

    Constructors

    Properties

    Constructors

    Properties

    cause?: unknown
    message: string
    name: string
    stack?: string
    +

    Constructors

    Properties

    cause?: unknown
    message: string
    name: string
    stack?: string
    diff --git a/docs/classes/_questdb_browser-client.QwpReplayRejectedError.html b/docs/classes/_questdb_browser-client.QwpReplayRejectedError.html index bb9e617..a984683 100644 --- a/docs/classes/_questdb_browser-client.QwpReplayRejectedError.html +++ b/docs/classes/_questdb_browser-client.QwpReplayRejectedError.html @@ -1,8 +1,8 @@ QwpReplayRejectedError | QuestDB JavaScript Client - v4.2.0

    A replayed ingress frame was rejected and remains in persistent storage.

    -

    Hierarchy

    • Error
      • QwpReplayRejectedError
    Index

    Constructors

    Hierarchy

    • Error
      • QwpReplayRejectedError
    Index

    Constructors

    Properties

    frameSequence: bigint
    message: string
    name: string
    stack?: string
    status: number
    +

    Constructors

    Properties

    frameSequence: bigint
    message: string
    name: string
    stack?: string
    status: number
    diff --git a/docs/classes/_questdb_browser-client.QwpResultBatch.html b/docs/classes/_questdb_browser-client.QwpResultBatch.html index f510743..43e8d72 100644 --- a/docs/classes/_questdb_browser-client.QwpResultBatch.html +++ b/docs/classes/_questdb_browser-client.QwpResultBatch.html @@ -1,4 +1,4 @@ -QwpResultBatch | QuestDB JavaScript Client - v4.2.0
    Index

    Constructors

    constructor +QwpResultBatch | QuestDB JavaScript Client - v4.2.0
    Index

    Constructors

    Properties

    Methods

    Constructors

    Properties

    batchSequence: bigint
    columns: readonly QwpResultColumn[]
    requestId: bigint
    rowCount: number
    tableName: string

    Methods

    +

    Constructors

    Properties

    batchSequence: bigint
    columns: readonly QwpResultColumn[]
    requestId: bigint
    rowCount: number
    tableName: string

    Methods

    diff --git a/docs/classes/_questdb_browser-client.QwpResultBatchDecoder.html b/docs/classes/_questdb_browser-client.QwpResultBatchDecoder.html index 60a9aa1..42e576d 100644 --- a/docs/classes/_questdb_browser-client.QwpResultBatchDecoder.html +++ b/docs/classes/_questdb_browser-client.QwpResultBatchDecoder.html @@ -1,13 +1,24 @@ QwpResultBatchDecoder | QuestDB JavaScript Client - v4.2.0

    Stateful decoder for connection-scoped QWP result batches.

    -
    Index

    Constructors

    Index

    Constructors

    Methods

    Constructors

    Properties

    maxBatchRows?: number

    Upper bound on a batch's declared row count, taken from the client's own +maxBatchRows request.

    +

    That request only ever reached the wire -- an upgrade header on Node, a +query parameter in the browser -- and nothing checked the answer against +it. Scratch arrays are sized from the declared row count and deliberately +retained per pool slot for reuse, so a peer that ignores the request, or a +hostile one, sets this session's memory floor for its lifetime: bounded +only by QWP_MAX_CELLS_PER_BATCH times the pool size, which the cap's own +comment puts at roughly half a gigabyte per slot.

    +

    Left undefined the batch is bounded by the cell cap alone, as before.

    +

    Methods

    +

    Parameters

    Returns QwpResultBatchView

    diff --git a/docs/classes/_questdb_browser-client.QwpResultBatchView.html b/docs/classes/_questdb_browser-client.QwpResultBatchView.html index 5d74992..85b2d11 100644 --- a/docs/classes/_questdb_browser-client.QwpResultBatchView.html +++ b/docs/classes/_questdb_browser-client.QwpResultBatchView.html @@ -1,7 +1,7 @@ QwpResultBatchView | QuestDB JavaScript Client - v4.2.0

    Batch-owned reusable view delivered by QwpEgressSession.queryViews(). Access is invalid after the callback returns. materialize() creates an independently owned QwpResultBatch when retention is required.

    -
    Index

    Constructors

    Index

    Constructors

    Accessors

    Constructors

    Accessors

    Methods

    • Visits rows in index order with one re-pointed row view. The callback is +

    Constructors

    Accessors

    Methods

    • Internal

      Parameters

      • requestId: bigint
      • batchSequence: bigint
      • tableName: string
      • rowCount: number
      • layouts: QwpResultColumnViewLayout[]

      Returns this

    • Internal

      Parameters

      • requestId: bigint
      • batchSequence: bigint
      • tableName: string
      • rowCount: number
      • layouts: QwpResultColumnViewLayout[]

      Returns this

    +

    Parameters

    • rowIndex: number

    Returns QwpResultRowView

    diff --git a/docs/classes/_questdb_browser-client.QwpResultColumnView.html b/docs/classes/_questdb_browser-client.QwpResultColumnView.html index 72a5aa1..925d5ab 100644 --- a/docs/classes/_questdb_browser-client.QwpResultColumnView.html +++ b/docs/classes/_questdb_browser-client.QwpResultColumnView.html @@ -2,7 +2,7 @@

    The view and every byte slice returned from it are valid only while the surrounding queryViews() callback is running. Copy data that must outlive the callback.

    -
    Index

    Constructors

    Index

    Constructors

    Properties

    Accessors

    Constructors

    Properties

    columnIndex: number

    Accessors

    Methods

    • Raw packed non-null values. Fixed-width values use QWP little-endian +

    Constructors

    Properties

    columnIndex: number

    Accessors

    Methods

    • Raw packed non-null values. Fixed-width values use QWP little-endian layout; booleans are bit-packed and variable-width columns contain their uint32 offset table. SYMBOL returns undefined because IDs are varints.

      -

      Returns undefined | Uint8Array<ArrayBufferLike>

    +

    Returns undefined | Uint8Array<ArrayBufferLike>

    diff --git a/docs/classes/_questdb_browser-client.QwpResultRowView.html b/docs/classes/_questdb_browser-client.QwpResultRowView.html index f68de4e..1376f5f 100644 --- a/docs/classes/_questdb_browser-client.QwpResultRowView.html +++ b/docs/classes/_questdb_browser-client.QwpResultRowView.html @@ -3,7 +3,7 @@ while the surrounding queryViews() callback is running, and must not be retained across forEachRow() iterations. Byte and array views returned by its accessors remain zero-copy and have the same lifetime.

    -
    Index

    Constructors

    Index

    Constructors

    Accessors

    Methods

    get @@ -29,10 +29,10 @@ getUuidLow isNull of -

    Constructors

    Accessors

    Methods

    +

    Constructors

    Accessors

    Methods

    diff --git a/docs/classes/_questdb_browser-client.QwpRoleMismatchError.html b/docs/classes/_questdb_browser-client.QwpRoleMismatchError.html index 42ba81e..e64ae9a 100644 --- a/docs/classes/_questdb_browser-client.QwpRoleMismatchError.html +++ b/docs/classes/_questdb_browser-client.QwpRoleMismatchError.html @@ -1,5 +1,5 @@ QwpRoleMismatchError | QuestDB JavaScript Client - v4.2.0

    A connected endpoint advertised a role that does not satisfy target.

    -

    Hierarchy (View Summary)

    Index

    Constructors

    Hierarchy (View Summary)

    Index

    Constructors

    Properties

    cause? closeCode? kind @@ -17,7 +17,7 @@ url?

    Accessors

    Constructors

    Properties

    cause?: unknown
    closeCode?: number
    message: string
    name: string
    retryable?: boolean
    serverRole?: string
    serverZone?: string
    stack?: string
    statusCode?: number
    statusMessage?: string
    target: QwpTarget
    timeoutPhase?: QwpUpgradeTimeoutPhase

    Node opening phase that exceeded its deadline.

    -
    tryNextEndpoint?: boolean
    url?: string | URL

    Accessors

    +

    Constructors

    Properties

    cause?: unknown
    closeCode?: number
    message: string
    name: string
    retryable?: boolean
    serverRole?: string
    serverZone?: string
    stack?: string
    statusCode?: number
    statusMessage?: string
    target: QwpTarget
    timeoutPhase?: QwpUpgradeTimeoutPhase

    Node opening phase that exceeded its deadline.

    +
    tryNextEndpoint?: boolean
    url?: string | URL

    Accessors

    diff --git a/docs/classes/_questdb_browser-client.QwpSendClosedError.html b/docs/classes/_questdb_browser-client.QwpSendClosedError.html index 500e8ad..c402f2c 100644 --- a/docs/classes/_questdb_browser-client.QwpSendClosedError.html +++ b/docs/classes/_questdb_browser-client.QwpSendClosedError.html @@ -1,8 +1,8 @@ QwpSendClosedError | QuestDB JavaScript Client - v4.2.0

    A QWP send was rejected because its WebSocket closed.

    -

    Hierarchy (View Summary)

    Index

    Constructors

    Hierarchy (View Summary)

    Index

    Constructors

    Properties

    cause?: unknown
    message: string
    name: string
    stack?: string
    +

    Constructors

    Properties

    cause?: unknown
    message: string
    name: string
    stack?: string
    diff --git a/docs/classes/_questdb_browser-client.QwpSendError.html b/docs/classes/_questdb_browser-client.QwpSendError.html index 862abe0..3abe783 100644 --- a/docs/classes/_questdb_browser-client.QwpSendError.html +++ b/docs/classes/_questdb_browser-client.QwpSendError.html @@ -1,7 +1,7 @@ QwpSendError | QuestDB JavaScript Client - v4.2.0

    A failure while handing a QWP frame to the WebSocket transport.

    -

    Hierarchy (View Summary)

    Index

    Constructors

    Hierarchy (View Summary)

    Index

    Constructors

    Properties

    Constructors

    Properties

    cause?: unknown
    message: string
    name: string
    stack?: string
    +

    Constructors

    Properties

    cause?: unknown
    message: string
    name: string
    stack?: string
    diff --git a/docs/classes/_questdb_browser-client.QwpSendTimeoutError.html b/docs/classes/_questdb_browser-client.QwpSendTimeoutError.html index 92d178f..d501ecf 100644 --- a/docs/classes/_questdb_browser-client.QwpSendTimeoutError.html +++ b/docs/classes/_questdb_browser-client.QwpSendTimeoutError.html @@ -1,9 +1,9 @@ QwpSendTimeoutError | QuestDB JavaScript Client - v4.2.0

    The WebSocket did not drain a QWP frame before its send deadline.

    -

    Hierarchy (View Summary)

    Index

    Constructors

    Hierarchy (View Summary)

    Index

    Constructors

    Properties

    bufferedAmountBytes?: number
    cause?: unknown
    message: string
    name: string
    stack?: string
    timeoutMs: number
    +

    Constructors

    Properties

    bufferedAmountBytes?: number
    cause?: unknown
    message: string
    name: string
    stack?: string
    timeoutMs: number
    diff --git a/docs/classes/_questdb_browser-client.QwpSender.html b/docs/classes/_questdb_browser-client.QwpSender.html index 66dcba6..c21f7d7 100644 --- a/docs/classes/_questdb_browser-client.QwpSender.html +++ b/docs/classes/_questdb_browser-client.QwpSender.html @@ -2,7 +2,7 @@

    Applications normally obtain this class through create/connectQwpNodeSender or create/connectQwpBrowserSender, rather than constructing sessions and QwpTableBuffer instances themselves.

    -
    Index

    Constructors

    Index

    Constructors

    Accessors

    Methods

    • Discards the row in progress, including its table selection, so the next +

    Constructors

    Accessors

    Methods

    • Commits rows previously sent by transactional auto-flush. This is an ergonomic alias for flush(); pending local rows are included in the same group-closing frame.

      -

      Returns Promise<boolean>

    • Adds a QuestDB DATE column value in milliseconds since the epoch. -9_223_372_036_854_775_808n is QuestDB's DATE NULL sentinel: it is stored as NULL and cannot be stored as an ordinary value.

      -

      Parameters

      • name: string
      • millisecondsSinceEpoch: undefined | null | number | bigint

      Returns QwpSender

    • Parameters

      • name: string
      • unscaled: undefined | null | bigint | Int8Array<ArrayBufferLike>
      • scale: number

      Returns QwpSender

    • Parameters

      • name: string
      • unscaled: undefined | null | bigint | Int8Array<ArrayBufferLike>
      • scale: number

      Returns QwpSender

    • Publishes completed rows to the local ingress/replay boundary. This does not wait for a server ACK unless awaitServerAck or awaitDurableAck is set.

      -

      Returns Promise<boolean>

    • Publishes pending rows without waiting for their server ACK and returns the highest frame sequence produced by this call, or -1n when empty. Pass the result to waitForAcknowledged() when an explicit delivery barrier is needed.

      -

      Returns Promise<bigint>

    • Adds a QuestDB INT column value. -2_147_483_648 is QuestDB's INT NULL sentinel: it is stored as NULL and cannot be stored as an ordinary value.

      -

      Parameters

      • name: string
      • value: undefined | null | number

      Returns QwpSender

    • Parameters

      • name: string
      • word0: undefined | null | bigint
      • word1: undefined | null | bigint
      • word2: undefined | null | bigint
      • word3: undefined | null | bigint

      Returns QwpSender

    • Parameters

      • name: string
      • word0: undefined | null | bigint
      • word1: undefined | null | bigint
      • word2: undefined | null | bigint
      • word3: undefined | null | bigint

      Returns QwpSender

    • Adds a protocol LONG[] column value with between 1 and 32 dimensions.

      Current QuestDB servers reject LONG-array ingestion with long arrays are not supported, only double arrays. This method remains available for Java-client and protocol parity.

      -

      Parameters

      • name: string
      • value: undefined | null | unknown[]

      Returns QwpSender

    • Adds a QuestDB LONG column value. -9_223_372_036_854_775_808n is QuestDB's LONG NULL sentinel: it is stored as NULL and cannot be stored as an ordinary value.

      -

      Parameters

      • name: string
      • value: undefined | null | number | bigint

      Returns QwpSender

    • Internal

      Flushes completed rows and resets borrower-local staging without closing the physical session. Used by the pooled QWP client when a lease returns.

      -

      Returns Promise<void>

    • Independently waits until the cumulative ACK watermark covers a frame.

      -

      Parameters

      • targetSequence: bigint
      • OptionaltimeoutMs: number

      Returns Promise<void>

    • Independently waits until the cumulative ACK watermark covers a frame.

      +

      Parameters

      • targetSequence: bigint
      • OptionaltimeoutMs: number

      Returns Promise<void>

    +

    Type Parameters

    • const Schema extends Readonly<Record<string, QwpWriterColumn<unknown, boolean>>>

    Parameters

    • tableName: string
    • schema: Schema

    Returns QwpTableWriter<Schema>

    diff --git a/docs/classes/_questdb_browser-client.QwpSenderCloseTimeoutError.html b/docs/classes/_questdb_browser-client.QwpSenderCloseTimeoutError.html index 0c63cbf..35a02c8 100644 --- a/docs/classes/_questdb_browser-client.QwpSenderCloseTimeoutError.html +++ b/docs/classes/_questdb_browser-client.QwpSenderCloseTimeoutError.html @@ -1,9 +1,9 @@ QwpSenderCloseTimeoutError | QuestDB JavaScript Client - v4.2.0

    close() could not publish and acknowledge all committed ingress frames.

    -

    Hierarchy

    • Error
      • QwpSenderCloseTimeoutError
    Index

    Constructors

    Hierarchy

    • Error
      • QwpSenderCloseTimeoutError
    Index

    Constructors

    Properties

    acknowledgedSequence: bigint
    message: string
    name: string
    stack?: string
    targetSequence: bigint
    timeoutMs: number
    +

    Constructors

    Properties

    acknowledgedSequence: bigint
    message: string
    name: string
    stack?: string
    targetSequence: bigint
    timeoutMs: number
    diff --git a/docs/classes/_questdb_browser-client.QwpSymbolDictionary.html b/docs/classes/_questdb_browser-client.QwpSymbolDictionary.html index c356be5..9882886 100644 --- a/docs/classes/_questdb_browser-client.QwpSymbolDictionary.html +++ b/docs/classes/_questdb_browser-client.QwpSymbolDictionary.html @@ -1,5 +1,5 @@ QwpSymbolDictionary | QuestDB JavaScript Client - v4.2.0

    Connection-scoped QWP symbol dictionary. IDs are dense from zero.

    -
    Index

    Constructors

    Index

    Constructors

    Accessors

    Methods

    Constructors

    Accessors

    Methods

    +

    Constructors

    Accessors

    Methods

    diff --git a/docs/classes/_questdb_browser-client.QwpTableBuffer.html b/docs/classes/_questdb_browser-client.QwpTableBuffer.html index e183607..0d9400d 100644 --- a/docs/classes/_questdb_browser-client.QwpTableBuffer.html +++ b/docs/classes/_questdb_browser-client.QwpTableBuffer.html @@ -1,5 +1,5 @@ QwpTableBuffer | QuestDB JavaScript Client - v4.2.0

    Mutable columnar staging area for one QWP ingress table.

    -
    Index

    Constructors

    Index

    Constructors

    Properties

    Accessors

    Constructors

    Properties

    name: string

    Accessors

    Methods

    • Returns null when the current row already contains this column. The first +

    Constructors

    Properties

    name: string

    Accessors

    Methods

    +

    Parameters

    • start: number
    • end: number

    Returns QwpTableBuffer

    diff --git a/docs/classes/_questdb_browser-client.QwpTableWriter.html b/docs/classes/_questdb_browser-client.QwpTableWriter.html index 52f396a..0737f58 100644 --- a/docs/classes/_questdb_browser-client.QwpTableWriter.html +++ b/docs/classes/_questdb_browser-client.QwpTableWriter.html @@ -1,9 +1,9 @@ QwpTableWriter | QuestDB JavaScript Client - v4.2.0

    Class QwpTableWriter<Schema>

    A reusable table-bound writer compiled from a QWP schema.

    -

    Type Parameters

    Index

    Constructors

    Type Parameters

    Index

    Constructors

    Properties

    Methods

    Constructors

    • Internal

      Construct table writers with QwpSender.writer().

      -

      Type Parameters

      Parameters

      • token: typeof QWP_TABLE_WRITER_CONSTRUCTOR
      • tableName: string
      • appendRow: (row: unknown, rowIndex?: number) => Promise<void>

      Returns QwpTableWriter<Schema>

    Properties

    tableName: string

    Methods

    +

    Type Parameters

    Parameters

    • token: typeof QWP_TABLE_WRITER_CONSTRUCTOR
    • tableName: string
    • appendRow: (row: unknown, rowIndex?: number) => Promise<void>

    Returns QwpTableWriter<Schema>

    Properties

    tableName: string

    Methods

    diff --git a/docs/classes/_questdb_browser-client.QwpUnrecoverableReplayDictionaryError.html b/docs/classes/_questdb_browser-client.QwpUnrecoverableReplayDictionaryError.html index b99dcde..7fc9495 100644 --- a/docs/classes/_questdb_browser-client.QwpUnrecoverableReplayDictionaryError.html +++ b/docs/classes/_questdb_browser-client.QwpUnrecoverableReplayDictionaryError.html @@ -1,8 +1,8 @@ QwpUnrecoverableReplayDictionaryError | QuestDB JavaScript Client - v4.2.0

    Class QwpUnrecoverableReplayDictionaryError

    Recovered delta frames depend on symbol IDs that neither the durable dictionary prefix nor the surviving frames can reconstruct.

    -

    Hierarchy (View Summary)

    Index

    Constructors

    Hierarchy (View Summary)

    Index

    Constructors

    Properties

    Constructors

    Properties

    cause?: unknown
    message: string
    name: string
    stack?: string
    +

    Constructors

    Properties

    cause?: unknown
    message: string
    name: string
    stack?: string
    diff --git a/docs/classes/_questdb_browser-client.QwpUpgradeError.html b/docs/classes/_questdb_browser-client.QwpUpgradeError.html index 9d563f8..ccb22c9 100644 --- a/docs/classes/_questdb_browser-client.QwpUpgradeError.html +++ b/docs/classes/_questdb_browser-client.QwpUpgradeError.html @@ -1,5 +1,5 @@ QwpUpgradeError | QuestDB JavaScript Client - v4.2.0

    A failure while establishing or validating a QWP WebSocket upgrade.

    -

    Hierarchy (View Summary)

    Index

    Constructors

    Hierarchy (View Summary)

    Index

    Constructors

    Properties

    cause? closeCode? kind @@ -16,7 +16,7 @@ url?

    Accessors

    Constructors

    Properties

    cause?: unknown
    closeCode?: number
    message: string
    name: string
    retryable?: boolean
    serverRole?: string
    serverZone?: string
    stack?: string
    statusCode?: number
    statusMessage?: string
    timeoutPhase?: QwpUpgradeTimeoutPhase

    Node opening phase that exceeded its deadline.

    -
    tryNextEndpoint?: boolean
    url?: string | URL

    Accessors

    +

    Constructors

    Properties

    cause?: unknown
    closeCode?: number
    message: string
    name: string
    retryable?: boolean
    serverRole?: string
    serverZone?: string
    stack?: string
    statusCode?: number
    statusMessage?: string
    timeoutPhase?: QwpUpgradeTimeoutPhase

    Node opening phase that exceeded its deadline.

    +
    tryNextEndpoint?: boolean
    url?: string | URL

    Accessors

    diff --git a/docs/classes/_questdb_browser-client.QwpWriterRowError.html b/docs/classes/_questdb_browser-client.QwpWriterRowError.html index 3a30702..74dc239 100644 --- a/docs/classes/_questdb_browser-client.QwpWriterRowError.html +++ b/docs/classes/_questdb_browser-client.QwpWriterRowError.html @@ -1,5 +1,5 @@ QwpWriterRowError | QuestDB JavaScript Client - v4.2.0

    A complete object row failed compiled-writer validation.

    -

    Hierarchy

    • Error
      • QwpWriterRowError
    Index

    Constructors

    Hierarchy

    • Error
      • QwpWriterRowError
    Index

    Constructors

    Properties

    Constructors

    Properties

    cause: unknown
    columnName: undefined | string
    message: string
    name: string
    rowIndex: undefined | number
    stack?: string
    tableName: string
    +

    Constructors

    Properties

    cause: unknown
    columnName: undefined | string
    message: string
    name: string
    rowIndex: undefined | number
    stack?: string
    tableName: string
    diff --git a/docs/classes/_questdb_nodejs-client.HttpTransport.html b/docs/classes/_questdb_nodejs-client.HttpTransport.html index baf5b62..12e5a6b 100644 --- a/docs/classes/_questdb_nodejs-client.HttpTransport.html +++ b/docs/classes/_questdb_nodejs-client.HttpTransport.html @@ -1,6 +1,6 @@ HttpTransport | QuestDB JavaScript Client - v4.2.0

    HTTP transport implementation using Node.js built-in http/https modules.
    Supports both HTTP and HTTPS protocols with configurable authentication.

    -

    Hierarchy

    • HttpTransportBase
      • HttpTransport
    Index

    Constructors

    Hierarchy

    • HttpTransportBase
      • HttpTransport
    Index

    Constructors

    Properties

    host log password @@ -20,16 +20,16 @@

    Constructors

    Properties

    host: string
    log: Logger
    password: string
    port: number
    requestMinThroughput: number
    requestTimeout: number
    retryTimeout: number
    secure: boolean
    tlsCA: Buffer
    tlsVerify: boolean
    token: string
    username: string

    Methods

    Properties

    host: string
    log: Logger
    password: string
    port: number
    requestMinThroughput: number
    requestTimeout: number
    retryTimeout: number
    secure: boolean
    tlsCA: Buffer
    tlsVerify: boolean
    token: string
    username: string

    Methods

    • HTTP transport does not require explicit connection establishment.

      Returns Promise<boolean>

      Error indicating connect is not required for HTTP transport

      -
    • Gets the default auto-flush row count for HTTP transport.

      Returns number

      Default number of rows that trigger auto-flush

      -
    • Sends data to QuestDB using HTTP POST.

      Parameters

      • data: Buffer

        Buffer containing the data to send

      • retryBegin: number = -1

        Internal parameter for tracking retry start time

      • retryInterval: number = -1

        Internal parameter for tracking retry intervals

      Returns Promise<boolean>

      Promise resolving to true if data was sent successfully

      Error if request fails after all retries or times out

      -
    +
    diff --git a/docs/classes/_questdb_nodejs-client.QwpBatchTooLargeError.html b/docs/classes/_questdb_nodejs-client.QwpBatchTooLargeError.html index 816c4a3..48654e5 100644 --- a/docs/classes/_questdb_nodejs-client.QwpBatchTooLargeError.html +++ b/docs/classes/_questdb_nodejs-client.QwpBatchTooLargeError.html @@ -1,4 +1,4 @@ -QwpBatchTooLargeError | QuestDB JavaScript Client - v4.2.0

    Hierarchy

    • RangeError
      • QwpBatchTooLargeError
    Index

    Constructors

    constructor +QwpBatchTooLargeError | QuestDB JavaScript Client - v4.2.0

    Hierarchy

    • RangeError
      • QwpBatchTooLargeError
    Index

    Constructors

    Properties

    batchSizeBytes: number
    cause?: unknown
    maxBatchSizeBytes: number
    message: string
    name: string
    stack?: string
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames +

    Constructors

    Properties

    batchSizeBytes: number
    cause?: unknown
    maxBatchSizeBytes: number
    message: string
    name: string
    stack?: string
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames collected by a stack trace (whether generated by new Error().stack or Error.captureStackTrace(obj)).

    The default value is 10 but may be set to any valid JavaScript number. Changes diff --git a/docs/classes/_questdb_nodejs-client.QwpBindValues.html b/docs/classes/_questdb_nodejs-client.QwpBindValues.html index 409ed58..53edff4 100644 --- a/docs/classes/_questdb_nodejs-client.QwpBindValues.html +++ b/docs/classes/_questdb_nodejs-client.QwpBindValues.html @@ -1,7 +1,7 @@ QwpBindValues | QuestDB JavaScript Client - v4.2.0

    Browser-safe typed positional bind encoder.

    Setters must be called in ascending zero-based index order. SQL placeholders are one-based, so index 0 binds $1, index 1 binds $2, and so on.

    -
    Index

    Constructors

    Index

    Constructors

    Accessors

    Methods

    Constructors

    Accessors

    Methods

    +

    Constructors

    Accessors

    Methods

    diff --git a/docs/classes/_questdb_nodejs-client.QwpByteReader.html b/docs/classes/_questdb_nodejs-client.QwpByteReader.html index c3479c3..940ec9e 100644 --- a/docs/classes/_questdb_nodejs-client.QwpByteReader.html +++ b/docs/classes/_questdb_nodejs-client.QwpByteReader.html @@ -1,5 +1,5 @@ QwpByteReader | QuestDB JavaScript Client - v4.2.0

    A bounds-checked, runtime-neutral little-endian byte reader.

    -
    Index

    Constructors

    Index

    Constructors

    Properties

    Accessors

    Constructors

    Properties

    bytes: Uint8Array

    Accessors

    Methods

    +

    Constructors

    Properties

    bytes: Uint8Array

    Accessors

    Methods

    diff --git a/docs/classes/_questdb_nodejs-client.QwpByteWriter.html b/docs/classes/_questdb_nodejs-client.QwpByteWriter.html index e393334..2d10070 100644 --- a/docs/classes/_questdb_nodejs-client.QwpByteWriter.html +++ b/docs/classes/_questdb_nodejs-client.QwpByteWriter.html @@ -1,5 +1,5 @@ QwpByteWriter | QuestDB JavaScript Client - v4.2.0

    A growable, runtime-neutral little-endian byte writer.

    -
    Index

    Constructors

    Index

    Constructors

    Accessors

    Methods

    Constructors

    Accessors

    Methods

    +

    Constructors

    Accessors

    Methods

    diff --git a/docs/classes/_questdb_nodejs-client.QwpClient.html b/docs/classes/_questdb_nodejs-client.QwpClient.html index 8cc3f50..a8b4f96 100644 --- a/docs/classes/_questdb_nodejs-client.QwpClient.html +++ b/docs/classes/_questdb_nodejs-client.QwpClient.html @@ -1,15 +1,15 @@ QwpClient | QuestDB JavaScript Client - v4.2.0

    Browser-safe facade owning bounded ingress and egress connection pools. Borrowed handles are exclusive; separate query leases execute concurrently.

    -
    Index

    Constructors

    Index

    Constructors

    Accessors

    Methods

    • Rejects new borrows and closes idle resources. Borrowed query sessions are +

    Constructors

    Accessors

    Methods

    • Rejects new borrows and closes idle resources. Borrowed query sessions are cancelled and closed; borrowed senders retain ownership during a bounded drain and own their teardown if they outlive it.

      -

      Returns Promise<void>

    +

    Returns Promise<void>

    diff --git a/docs/classes/_questdb_nodejs-client.QwpClientClosedError.html b/docs/classes/_questdb_nodejs-client.QwpClientClosedError.html index 6d362c6..77ebc9b 100644 --- a/docs/classes/_questdb_nodejs-client.QwpClientClosedError.html +++ b/docs/classes/_questdb_nodejs-client.QwpClientClosedError.html @@ -1,5 +1,5 @@ QwpClientClosedError | QuestDB JavaScript Client - v4.2.0

    The owning QWP client, or one of its returned lease handles, is closed.

    -

    Hierarchy

    • Error
      • QwpClientClosedError
    Index

    Constructors

    Hierarchy

    • Error
      • QwpClientClosedError
    Index

    Constructors

    Properties

    Methods

    Constructors

    Properties

    cause?: unknown
    message: string
    name: string
    stack?: string
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames +

    Constructors

    Properties

    cause?: unknown
    message: string
    name: string
    stack?: string
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames collected by a stack trace (whether generated by new Error().stack or Error.captureStackTrace(obj)).

    The default value is 10 but may be set to any valid JavaScript number. Changes diff --git a/docs/classes/_questdb_nodejs-client.QwpDurableAckUnavailableError.html b/docs/classes/_questdb_nodejs-client.QwpDurableAckUnavailableError.html index a5691f1..05a3943 100644 --- a/docs/classes/_questdb_nodejs-client.QwpDurableAckUnavailableError.html +++ b/docs/classes/_questdb_nodejs-client.QwpDurableAckUnavailableError.html @@ -1,5 +1,5 @@ QwpDurableAckUnavailableError | QuestDB JavaScript Client - v4.2.0

    Class QwpDurableAckUnavailableError

    A requested durable-ACK capability was not confirmed by the server.

    -

    Hierarchy (View Summary)

    Index

    Constructors

    Hierarchy (View Summary)

    Index

    Constructors

    Properties

    cause?: unknown
    closeCode?: number
    message: string
    name: string
    retryable?: boolean
    serverRole?: string
    serverZone?: string
    stack?: string
    statusCode?: number
    statusMessage?: string
    timeoutPhase?: QwpUpgradeTimeoutPhase

    Node opening phase that exceeded its deadline.

    -
    tryNextEndpoint?: boolean
    url: string | URL
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames +

    Constructors

    Properties

    cause?: unknown
    closeCode?: number
    message: string
    name: string
    retryable?: boolean
    serverRole?: string
    serverZone?: string
    stack?: string
    statusCode?: number
    statusMessage?: string
    timeoutPhase?: QwpUpgradeTimeoutPhase

    Node opening phase that exceeded its deadline.

    +
    tryNextEndpoint?: boolean
    url: string | URL
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames collected by a stack trace (whether generated by new Error().stack or Error.captureStackTrace(obj)).

    The default value is 10 but may be set to any valid JavaScript number. Changes @@ -28,8 +28,8 @@

    If set to a non-number value, or set to a negative number, stack traces will not capture any frames.

    Accessors

    Methods

    • Creates a .stack property on targetObject, which when accessed returns +

      Returns boolean

    Methods

    diff --git a/docs/classes/_questdb_nodejs-client.QwpEgressQueryAbandonedError.html b/docs/classes/_questdb_nodejs-client.QwpEgressQueryAbandonedError.html index d56a90b..323f745 100644 --- a/docs/classes/_questdb_nodejs-client.QwpEgressQueryAbandonedError.html +++ b/docs/classes/_questdb_nodejs-client.QwpEgressQueryAbandonedError.html @@ -1,5 +1,5 @@ QwpEgressQueryAbandonedError | QuestDB JavaScript Client - v4.2.0

    Class QwpEgressQueryAbandonedError

    Result iteration ended before the server completed the query.

    -

    Hierarchy

    • Error
      • QwpEgressQueryAbandonedError
    Index

    Constructors

    Hierarchy

    • Error
      • QwpEgressQueryAbandonedError
    Index

    Constructors

    Properties

    Methods

    Constructors

    Properties

    cause?: unknown
    message: string
    name: string
    requestId: bigint
    stack?: string
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames +

    Constructors

    Properties

    cause?: unknown
    message: string
    name: string
    requestId: bigint
    stack?: string
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames collected by a stack trace (whether generated by new Error().stack or Error.captureStackTrace(obj)).

    The default value is 10 but may be set to any valid JavaScript number. Changes diff --git a/docs/classes/_questdb_nodejs-client.QwpEgressQueryCancelTimeoutError.html b/docs/classes/_questdb_nodejs-client.QwpEgressQueryCancelTimeoutError.html index 97c763d..d524cb8 100644 --- a/docs/classes/_questdb_nodejs-client.QwpEgressQueryCancelTimeoutError.html +++ b/docs/classes/_questdb_nodejs-client.QwpEgressQueryCancelTimeoutError.html @@ -1,5 +1,5 @@ QwpEgressQueryCancelTimeoutError | QuestDB JavaScript Client - v4.2.0

    Class QwpEgressQueryCancelTimeoutError

    The server did not terminate a cancelled query within the drain deadline.

    -

    Hierarchy

    • Error
      • QwpEgressQueryCancelTimeoutError
    Index

    Constructors

    Hierarchy

    • Error
      • QwpEgressQueryCancelTimeoutError
    Index

    Constructors

    Properties

    Methods

    Constructors

    Properties

    cause?: unknown
    message: string
    name: string
    requestId: bigint
    stack?: string
    timeoutMs: number
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames +

    Constructors

    Properties

    cause?: unknown
    message: string
    name: string
    requestId: bigint
    stack?: string
    timeoutMs: number
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames collected by a stack trace (whether generated by new Error().stack or Error.captureStackTrace(obj)).

    The default value is 10 but may be set to any valid JavaScript number. Changes diff --git a/docs/classes/_questdb_nodejs-client.QwpEgressQueryError.html b/docs/classes/_questdb_nodejs-client.QwpEgressQueryError.html index a4370e7..1190d7c 100644 --- a/docs/classes/_questdb_nodejs-client.QwpEgressQueryError.html +++ b/docs/classes/_questdb_nodejs-client.QwpEgressQueryError.html @@ -1,4 +1,4 @@ -QwpEgressQueryError | QuestDB JavaScript Client - v4.2.0

    Hierarchy

    • Error
      • QwpEgressQueryError
    Index

    Constructors

    constructor +QwpEgressQueryError | QuestDB JavaScript Client - v4.2.0

    Hierarchy

    • Error
      • QwpEgressQueryError
    Index

    Constructors

    Properties

    Methods

    Constructors

    Properties

    cause?: unknown
    message: string
    name: string
    requestId: bigint
    stack?: string
    status: number
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames +

    Constructors

    Properties

    cause?: unknown
    message: string
    name: string
    requestId: bigint
    stack?: string
    status: number
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames collected by a stack trace (whether generated by new Error().stack or Error.captureStackTrace(obj)).

    The default value is 10 but may be set to any valid JavaScript number. Changes diff --git a/docs/classes/_questdb_nodejs-client.QwpEgressQueryTimeoutError.html b/docs/classes/_questdb_nodejs-client.QwpEgressQueryTimeoutError.html index dfb9106..10f45f5 100644 --- a/docs/classes/_questdb_nodejs-client.QwpEgressQueryTimeoutError.html +++ b/docs/classes/_questdb_nodejs-client.QwpEgressQueryTimeoutError.html @@ -1,5 +1,5 @@ QwpEgressQueryTimeoutError | QuestDB JavaScript Client - v4.2.0

    Class QwpEgressQueryTimeoutError

    A client-side query deadline expired and a QWP CANCEL was sent.

    -

    Hierarchy

    • Error
      • QwpEgressQueryTimeoutError
    Index

    Constructors

    Hierarchy

    • Error
      • QwpEgressQueryTimeoutError
    Index

    Constructors

    Properties

    Methods

    Constructors

    Properties

    cause?: unknown
    message: string
    name: string
    requestId: bigint
    stack?: string
    timeoutMs: number
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames +

    Constructors

    Properties

    cause?: unknown
    message: string
    name: string
    requestId: bigint
    stack?: string
    timeoutMs: number
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames collected by a stack trace (whether generated by new Error().stack or Error.captureStackTrace(obj)).

    The default value is 10 but may be set to any valid JavaScript number. Changes diff --git a/docs/classes/_questdb_nodejs-client.QwpEgressReplayRequiredError.html b/docs/classes/_questdb_nodejs-client.QwpEgressReplayRequiredError.html index 056a5ee..ec1edc3 100644 --- a/docs/classes/_questdb_nodejs-client.QwpEgressReplayRequiredError.html +++ b/docs/classes/_questdb_nodejs-client.QwpEgressReplayRequiredError.html @@ -1,7 +1,7 @@ QwpEgressReplayRequiredError | QuestDB JavaScript Client - v4.2.0

    Class QwpEgressReplayRequiredError

    Standard egress sessions now reset and replay automatically. Retained for source compatibility with clients that classified the former explicit-replay opt-in failure.

    -

    Hierarchy

    • Error
      • QwpEgressReplayRequiredError
    Index

    Constructors

    Hierarchy

    • Error
      • QwpEgressReplayRequiredError
    Index

    Constructors

    Properties

    cause? message name @@ -10,7 +10,7 @@ stackTraceLimit

    Methods

    Constructors

    Properties

    cause?: unknown
    message: string
    name: string
    requestId?: bigint
    stack?: string
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames +

    Constructors

    Properties

    cause?: unknown
    message: string
    name: string
    requestId?: bigint
    stack?: string
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames collected by a stack trace (whether generated by new Error().stack or Error.captureStackTrace(obj)).

    The default value is 10 but may be set to any valid JavaScript number. Changes diff --git a/docs/classes/_questdb_nodejs-client.QwpEgressSession.html b/docs/classes/_questdb_nodejs-client.QwpEgressSession.html index 8864ebe..d39966e 100644 --- a/docs/classes/_questdb_nodejs-client.QwpEgressSession.html +++ b/docs/classes/_questdb_nodejs-client.QwpEgressSession.html @@ -2,7 +2,7 @@

    The server currently executes one query at a time per connection, so this session deliberately rejects overlapping query calls. A completed query's materialized batches may still be consumed while the next query runs.

    -

    Implements

    • QwpEgressQueryControl
    Index

    Constructors

    Implements

    • QwpEgressQueryControl
    Index

    Constructors

    Properties

    Accessors

    Constructors

    Properties

    ready: Promise<QwpServerInfoMessage>

    Initial SERVER_INFO; use serverInfo for the current post-failover snapshot.

    -

    Accessors

    • get serverInfo(): QwpServerInfoMessage

      Cached immutable SERVER_INFO for the currently bound endpoint. Reading it +

    Constructors

    Properties

    ready: Promise<QwpServerInfoMessage>

    Initial SERVER_INFO; use serverInfo for the current post-failover snapshot.

    +

    Accessors

    Methods

    • Parameters

      • requestId: bigint
      • additionalBytes: number | bigint

      Returns Promise<void>

    Methods

    • Parameters

      • requestId: bigint
      • additionalBytes: number | bigint

      Returns Promise<void>

    • Internal

      Cancels and drains an active operation before a pooled lease is returned. False means the physical session is no longer safe to reuse.

      -

      Returns Promise<boolean>

    • Internal

      Best-effort cancellation followed by physical connection teardown for facade shutdown. Unlike pooled lease return, this does not wait for the server to finish draining the cancelled query.

      -

      Returns Promise<void>

    +

    Returns Promise<void>

    diff --git a/docs/classes/_questdb_nodejs-client.QwpEgressSessionClosedError.html b/docs/classes/_questdb_nodejs-client.QwpEgressSessionClosedError.html index 223f6c6..4fd2b13 100644 --- a/docs/classes/_questdb_nodejs-client.QwpEgressSessionClosedError.html +++ b/docs/classes/_questdb_nodejs-client.QwpEgressSessionClosedError.html @@ -1,4 +1,4 @@ -QwpEgressSessionClosedError | QuestDB JavaScript Client - v4.2.0

    Class QwpEgressSessionClosedError

    Hierarchy

    • Error
      • QwpEgressSessionClosedError
    Index

    Constructors

    constructor +QwpEgressSessionClosedError | QuestDB JavaScript Client - v4.2.0

    Class QwpEgressSessionClosedError

    Hierarchy

    • Error
      • QwpEgressSessionClosedError
    Index

    Constructors

    Properties

    cause?: unknown
    message: string
    name: string
    stack?: string
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames +

    Constructors

    Properties

    cause?: unknown
    message: string
    name: string
    stack?: string
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames collected by a stack trace (whether generated by new Error().stack or Error.captureStackTrace(obj)).

    The default value is 10 but may be set to any valid JavaScript number. Changes diff --git a/docs/classes/_questdb_nodejs-client.QwpFailoverError.html b/docs/classes/_questdb_nodejs-client.QwpFailoverError.html index ba7ddd2..ebf985a 100644 --- a/docs/classes/_questdb_nodejs-client.QwpFailoverError.html +++ b/docs/classes/_questdb_nodejs-client.QwpFailoverError.html @@ -1,5 +1,5 @@ QwpFailoverError | QuestDB JavaScript Client - v4.2.0

    Every eligible QWP endpoint in one connection sweep failed.

    -

    Hierarchy

    • Error
      • QwpFailoverError
    Index

    Constructors

    Hierarchy

    • Error
      • QwpFailoverError
    Index

    Constructors

    Properties

    attempts: readonly QwpFailoverAttempt[]
    cause?: unknown
    message: string
    name: string
    stack?: string
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames +

    Constructors

    Properties

    attempts: readonly QwpFailoverAttempt[]
    cause?: unknown
    message: string
    name: string
    stack?: string
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames collected by a stack trace (whether generated by new Error().stack or Error.captureStackTrace(obj)).

    The default value is 10 but may be set to any valid JavaScript number. Changes diff --git a/docs/classes/_questdb_nodejs-client.QwpIngressAckTimeoutError.html b/docs/classes/_questdb_nodejs-client.QwpIngressAckTimeoutError.html index 38fc50e..75f75ec 100644 --- a/docs/classes/_questdb_nodejs-client.QwpIngressAckTimeoutError.html +++ b/docs/classes/_questdb_nodejs-client.QwpIngressAckTimeoutError.html @@ -1,5 +1,5 @@ QwpIngressAckTimeoutError | QuestDB JavaScript Client - v4.2.0

    The ingress ACK watermark did not reach the requested frame in time.

    -

    Hierarchy

    • Error
      • QwpIngressAckTimeoutError
    Index

    Constructors

    Hierarchy

    • Error
      • QwpIngressAckTimeoutError
    Index

    Constructors

    Properties

    acknowledgedSequence: bigint
    cause?: unknown
    message: string
    name: string
    stack?: string
    targetSequence: bigint
    timeoutMs: number
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames +

    Constructors

    Properties

    acknowledgedSequence: bigint
    cause?: unknown
    message: string
    name: string
    stack?: string
    targetSequence: bigint
    timeoutMs: number
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames collected by a stack trace (whether generated by new Error().stack or Error.captureStackTrace(obj)).

    The default value is 10 but may be set to any valid JavaScript number. Changes diff --git a/docs/classes/_questdb_nodejs-client.QwpIngressNackError.html b/docs/classes/_questdb_nodejs-client.QwpIngressNackError.html index 3515c78..54fd624 100644 --- a/docs/classes/_questdb_nodejs-client.QwpIngressNackError.html +++ b/docs/classes/_questdb_nodejs-client.QwpIngressNackError.html @@ -1,4 +1,4 @@ -QwpIngressNackError | QuestDB JavaScript Client - v4.2.0

    Hierarchy

    • Error
      • QwpIngressNackError
    Index

    Constructors

    constructor +QwpIngressNackError | QuestDB JavaScript Client - v4.2.0

    Hierarchy

    • Error
      • QwpIngressNackError
    Index

    Constructors

    Properties

    Methods

    Constructors

    Properties

    cause?: unknown
    message: string
    name: string
    senderError: QwpSenderError = ...
    stack?: string
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames +

    Constructors

    Properties

    cause?: unknown
    message: string
    name: string
    senderError: QwpSenderError = ...
    stack?: string
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames collected by a stack trace (whether generated by new Error().stack or Error.captureStackTrace(obj)).

    The default value is 10 but may be set to any valid JavaScript number. Changes diff --git a/docs/classes/_questdb_nodejs-client.QwpIngressSession.html b/docs/classes/_questdb_nodejs-client.QwpIngressSession.html index 79a69ec..730a433 100644 --- a/docs/classes/_questdb_nodejs-client.QwpIngressSession.html +++ b/docs/classes/_questdb_nodejs-client.QwpIngressSession.html @@ -3,7 +3,7 @@ from racing its waiter. Calls are serialized to preserve the server's zero-based wire sequence. Successful ACKs are cumulative, so an ACK for sequence N resolves every outstanding send through N.

    -
    Index

    Constructors

    Index

    Constructors

    Accessors

    • get acknowledgedFrameSequence(): bigint

      Highest cumulative ACK watermark. When durable ACK was negotiated this +

    Constructors

    Accessors

    Methods

    Methods

    • Prompts the server to publish its latest durable-ingress watermarks. Node transports use a WebSocket PING; browsers send the protocol-level table-less durable-ACK poll frame. Browser completion means the control frame was published; durable progress arrives independently because the server may withhold its cumulative OK while a transaction remains open.

      -

      Returns Promise<void>

    • Publishes one pre-encoded frame without allocating an ACK waiter. Applications can observe later acceptance through progress callbacks.

      -

      Parameters

      • frame: Uint8Array

      Returns Promise<void>

    • Waits independently for the cumulative frame ACK watermark. A negative target is already satisfied, but still surfaces a latched session error.

      -

      Parameters

      • targetSequence: bigint
      • timeoutMs: number = ...

      Returns Promise<void>

    +

    Returns Promise<QwpIngressSession>

    diff --git a/docs/classes/_questdb_nodejs-client.QwpIngressSessionClosedError.html b/docs/classes/_questdb_nodejs-client.QwpIngressSessionClosedError.html index 68fd4e2..3d8134b 100644 --- a/docs/classes/_questdb_nodejs-client.QwpIngressSessionClosedError.html +++ b/docs/classes/_questdb_nodejs-client.QwpIngressSessionClosedError.html @@ -1,4 +1,4 @@ -QwpIngressSessionClosedError | QuestDB JavaScript Client - v4.2.0

    Class QwpIngressSessionClosedError

    Hierarchy

    • Error
      • QwpIngressSessionClosedError
    Index

    Constructors

    constructor +QwpIngressSessionClosedError | QuestDB JavaScript Client - v4.2.0

    Class QwpIngressSessionClosedError

    Hierarchy

    • Error
      • QwpIngressSessionClosedError
    Index

    Constructors

    Properties

    cause?: unknown
    message: string
    name: string
    stack?: string
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames +

    Constructors

    Properties

    cause?: unknown
    message: string
    name: string
    stack?: string
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames collected by a stack trace (whether generated by new Error().stack or Error.captureStackTrace(obj)).

    The default value is 10 but may be set to any valid JavaScript number. Changes diff --git a/docs/classes/_questdb_nodejs-client.QwpMemoryReplayAppendTimeoutError.html b/docs/classes/_questdb_nodejs-client.QwpMemoryReplayAppendTimeoutError.html index 3b63f9d..92f213a 100644 --- a/docs/classes/_questdb_nodejs-client.QwpMemoryReplayAppendTimeoutError.html +++ b/docs/classes/_questdb_nodejs-client.QwpMemoryReplayAppendTimeoutError.html @@ -1,5 +1,5 @@ QwpMemoryReplayAppendTimeoutError | QuestDB JavaScript Client - v4.2.0

    Class QwpMemoryReplayAppendTimeoutError

    ACK-driven trimming did not free in-memory replay capacity in time.

    -

    Hierarchy

    • Error
      • QwpMemoryReplayAppendTimeoutError
    Index

    Constructors

    Hierarchy

    • Error
      • QwpMemoryReplayAppendTimeoutError
    Index

    Constructors

    Properties

    Methods

    Constructors

    Properties

    cause?: unknown
    maxBytes: number
    message: string
    name: string
    requiredBytes: number
    stack?: string
    timeoutMs: number
    usedBytes: number
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames +

    Constructors

    Properties

    cause?: unknown
    maxBytes: number
    message: string
    name: string
    requiredBytes: number
    stack?: string
    timeoutMs: number
    usedBytes: number
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames collected by a stack trace (whether generated by new Error().stack or Error.captureStackTrace(obj)).

    The default value is 10 but may be set to any valid JavaScript number. Changes diff --git a/docs/classes/_questdb_nodejs-client.QwpMemoryReplayFrameTooLargeError.html b/docs/classes/_questdb_nodejs-client.QwpMemoryReplayFrameTooLargeError.html index ff9d32d..6149fed 100644 --- a/docs/classes/_questdb_nodejs-client.QwpMemoryReplayFrameTooLargeError.html +++ b/docs/classes/_questdb_nodejs-client.QwpMemoryReplayFrameTooLargeError.html @@ -1,5 +1,5 @@ QwpMemoryReplayFrameTooLargeError | QuestDB JavaScript Client - v4.2.0

    Class QwpMemoryReplayFrameTooLargeError

    One frame can never fit in the configured in-memory replay budget.

    -

    Hierarchy

    • RangeError
      • QwpMemoryReplayFrameTooLargeError
    Index

    Constructors

    Hierarchy

    • RangeError
      • QwpMemoryReplayFrameTooLargeError
    Index

    Constructors

    Properties

    Methods

    Constructors

    Properties

    cause?: unknown
    maxBytes: number
    message: string
    name: string
    payloadBytes: number
    requiredBytes: number
    stack?: string
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames +

    Constructors

    Properties

    cause?: unknown
    maxBytes: number
    message: string
    name: string
    payloadBytes: number
    requiredBytes: number
    stack?: string
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames collected by a stack trace (whether generated by new Error().stack or Error.captureStackTrace(obj)).

    The default value is 10 but may be set to any valid JavaScript number. Changes diff --git a/docs/classes/_questdb_nodejs-client.QwpNodeFileReplayStore.html b/docs/classes/_questdb_nodejs-client.QwpNodeFileReplayStore.html index 099621a..819a71f 100644 --- a/docs/classes/_questdb_nodejs-client.QwpNodeFileReplayStore.html +++ b/docs/classes/_questdb_nodejs-client.QwpNodeFileReplayStore.html @@ -5,7 +5,7 @@ trimming. A crash between the server ACK and local deletion can cause at-least-once replay. An exclusive, lifetime lock prevents another process from recovering or mutating the same directory.

    -

    Implements

    Index

    Constructors

    Implements

    Index

    Constructors

    Accessors

    Methods

    • Opens and validates the journal without materializing every payload. +

    Constructors

    Accessors

    Methods

    +

    Parameters

    • entries: readonly string[]

    Returns Promise<void>

    diff --git a/docs/classes/_questdb_nodejs-client.QwpNodeOrphanDrainer.html b/docs/classes/_questdb_nodejs-client.QwpNodeOrphanDrainer.html index ba69c92..49ded8e 100644 --- a/docs/classes/_questdb_nodejs-client.QwpNodeOrphanDrainer.html +++ b/docs/classes/_questdb_nodejs-client.QwpNodeOrphanDrainer.html @@ -1,10 +1,10 @@ QwpNodeOrphanDrainer | QuestDB JavaScript Client - v4.2.0

    Bounded Node-only scanner and background drainer for replay slots left by terminated producer processes. Each adopted slot uses its own connection.

    -
    Index

    Constructors

    Index

    Constructors

    Accessors

    Methods

    Constructors

    Accessors

    Methods

    +

    Constructors

    Accessors

    Methods

    diff --git a/docs/classes/_questdb_nodejs-client.QwpNodeUdpSession.html b/docs/classes/_questdb_nodejs-client.QwpNodeUdpSession.html index 8b42a25..cc1145c 100644 --- a/docs/classes/_questdb_nodejs-client.QwpNodeUdpSession.html +++ b/docs/classes/_questdb_nodejs-client.QwpNodeUdpSession.html @@ -2,7 +2,7 @@

    Each datagram is self-contained: it carries one table, an inline schema and local symbol dictionaries. There are no ACKs, retries, transactions, authentication, compression, or store-and-forward semantics.

    -

    Implements

    Index

    Properties

    Implements

    Index

    Properties

    maxBatchSizeBytes: number

    Accessors

    Methods

    +

    Properties

    maxBatchSizeBytes: number

    Accessors

    Methods

    diff --git a/docs/classes/_questdb_nodejs-client.QwpPoolAcquireTimeoutError.html b/docs/classes/_questdb_nodejs-client.QwpPoolAcquireTimeoutError.html index fb56555..1a5c988 100644 --- a/docs/classes/_questdb_nodejs-client.QwpPoolAcquireTimeoutError.html +++ b/docs/classes/_questdb_nodejs-client.QwpPoolAcquireTimeoutError.html @@ -1,5 +1,5 @@ QwpPoolAcquireTimeoutError | QuestDB JavaScript Client - v4.2.0

    Class QwpPoolAcquireTimeoutError

    A bounded QWP pool could not provide a connection before its deadline.

    -

    Hierarchy

    • Error
      • QwpPoolAcquireTimeoutError
    Index

    Constructors

    Hierarchy

    • Error
      • QwpPoolAcquireTimeoutError
    Index

    Constructors

    Properties

    Methods

    Constructors

    Properties

    cause?: unknown
    message: string
    name: string
    resource: "query" | "sender"
    stack?: string
    timeoutMs: number
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames +

    Constructors

    Properties

    cause?: unknown
    message: string
    name: string
    resource: "query" | "sender"
    stack?: string
    timeoutMs: number
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames collected by a stack trace (whether generated by new Error().stack or Error.captureStackTrace(obj)).

    The default value is 10 but may be set to any valid JavaScript number. Changes diff --git a/docs/classes/_questdb_nodejs-client.QwpPoolResourceError.html b/docs/classes/_questdb_nodejs-client.QwpPoolResourceError.html index e3f3d64..2f96e03 100644 --- a/docs/classes/_questdb_nodejs-client.QwpPoolResourceError.html +++ b/docs/classes/_questdb_nodejs-client.QwpPoolResourceError.html @@ -1,5 +1,5 @@ QwpPoolResourceError | QuestDB JavaScript Client - v4.2.0

    A pooled resource failed while a new slot was being connected.

    -

    Hierarchy

    • Error
      • QwpPoolResourceError
    Index

    Constructors

    Hierarchy

    • Error
      • QwpPoolResourceError
    Index

    Constructors

    Properties

    Methods

    Constructors

    Properties

    cause: unknown
    message: string
    name: string
    resource: "query" | "sender"
    stack?: string
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames +

    Constructors

    Properties

    cause: unknown
    message: string
    name: string
    resource: "query" | "sender"
    stack?: string
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames collected by a stack trace (whether generated by new Error().stack or Error.captureStackTrace(obj)).

    The default value is 10 but may be set to any valid JavaScript number. Changes diff --git a/docs/classes/_questdb_nodejs-client.QwpProtocolError.html b/docs/classes/_questdb_nodejs-client.QwpProtocolError.html index 0a2adab..954d84e 100644 --- a/docs/classes/_questdb_nodejs-client.QwpProtocolError.html +++ b/docs/classes/_questdb_nodejs-client.QwpProtocolError.html @@ -1,5 +1,5 @@ QwpProtocolError | QuestDB JavaScript Client - v4.2.0

    Raised when a QWP payload is malformed, truncated, or unsupported.

    -

    Hierarchy

    • Error
      • QwpProtocolError
    Index

    Constructors

    Hierarchy

    • Error
      • QwpProtocolError
    Index

    Constructors

    Properties

    Methods

    Constructors

    Properties

    cause?: unknown
    message: string
    name: string
    stack?: string
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames +

    Constructors

    Properties

    cause?: unknown
    message: string
    name: string
    stack?: string
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames collected by a stack trace (whether generated by new Error().stack or Error.captureStackTrace(obj)).

    The default value is 10 but may be set to any valid JavaScript number. Changes diff --git a/docs/classes/_questdb_nodejs-client.QwpQueryLease.html b/docs/classes/_questdb_nodejs-client.QwpQueryLease.html index bfcea15..ac5de01 100644 --- a/docs/classes/_questdb_nodejs-client.QwpQueryLease.html +++ b/docs/classes/_questdb_nodejs-client.QwpQueryLease.html @@ -1,5 +1,5 @@ QwpQueryLease | QuestDB JavaScript Client - v4.2.0

    One exclusively borrowed egress session from a QwpClient query pool.

    -
    Index

    Constructors

    Index

    Constructors

    Properties

    Accessors

    Methods

    Constructors

    Properties

    ready: Promise<QwpServerInfoMessage>

    Initial SERVER_INFO; use serverInfo for the current post-failover snapshot.

    -

    Accessors

    • get serverInfo(): QwpServerInfoMessage

      Cached immutable SERVER_INFO for this lease's currently bound endpoint. +

    Constructors

    Properties

    ready: Promise<QwpServerInfoMessage>

    Initial SERVER_INFO; use serverInfo for the current post-failover snapshot.

    +

    Accessors

    Methods

    +

    Returns QwpServerInfoMessage

    Methods

    diff --git a/docs/classes/_questdb_nodejs-client.QwpReconnectExhaustedError.html b/docs/classes/_questdb_nodejs-client.QwpReconnectExhaustedError.html index 45bf905..100931f 100644 --- a/docs/classes/_questdb_nodejs-client.QwpReconnectExhaustedError.html +++ b/docs/classes/_questdb_nodejs-client.QwpReconnectExhaustedError.html @@ -1,5 +1,5 @@ QwpReconnectExhaustedError | QuestDB JavaScript Client - v4.2.0

    Class QwpReconnectExhaustedError

    A configured QWP reconnect policy exhausted its retry boundary.

    -

    Hierarchy

    • Error
      • QwpReconnectExhaustedError
    Index

    Constructors

    Hierarchy

    • Error
      • QwpReconnectExhaustedError
    Index

    Constructors

    Properties

    attempts: number
    cause: unknown
    message: string
    name: string
    stack?: string
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames +

    Constructors

    Properties

    attempts: number
    cause: unknown
    message: string
    name: string
    stack?: string
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames collected by a stack trace (whether generated by new Error().stack or Error.captureStackTrace(obj)).

    The default value is 10 but may be set to any valid JavaScript number. Changes diff --git a/docs/classes/_questdb_nodejs-client.QwpReplayDictionaryError.html b/docs/classes/_questdb_nodejs-client.QwpReplayDictionaryError.html index 3640fae..d6ed412 100644 --- a/docs/classes/_questdb_nodejs-client.QwpReplayDictionaryError.html +++ b/docs/classes/_questdb_nodejs-client.QwpReplayDictionaryError.html @@ -1,5 +1,5 @@ QwpReplayDictionaryError | QuestDB JavaScript Client - v4.2.0

    A replay store cannot preserve the dictionary required by delta frames.

    -

    Hierarchy (View Summary)

    Index

    Constructors

    Hierarchy (View Summary)

    Index

    Constructors

    Properties

    Methods

    Constructors

    Properties

    cause?: unknown
    message: string
    name: string
    stack?: string
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames +

    Constructors

    Properties

    cause?: unknown
    message: string
    name: string
    stack?: string
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames collected by a stack trace (whether generated by new Error().stack or Error.captureStackTrace(obj)).

    The default value is 10 but may be set to any valid JavaScript number. Changes diff --git a/docs/classes/_questdb_nodejs-client.QwpReplayDictionaryPersistenceError.html b/docs/classes/_questdb_nodejs-client.QwpReplayDictionaryPersistenceError.html index 98a19cc..8516016 100644 --- a/docs/classes/_questdb_nodejs-client.QwpReplayDictionaryPersistenceError.html +++ b/docs/classes/_questdb_nodejs-client.QwpReplayDictionaryPersistenceError.html @@ -1,7 +1,7 @@ QwpReplayDictionaryPersistenceError | QuestDB JavaScript Client - v4.2.0

    Class QwpReplayDictionaryPersistenceError

    A replay dictionary sidecar rejected an append before its delta frame was published. The reconnecting transport has permanently switched to full, self-contained symbol encoding; retrying the logical batch is safe.

    -

    Hierarchy (View Summary)

    Index

    Constructors

    Hierarchy (View Summary)

    Index

    Constructors

    Properties

    Methods

    Constructors

    Properties

    cause?: unknown
    message: string
    name: string
    stack?: string
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames +

    Constructors

    Properties

    cause?: unknown
    message: string
    name: string
    stack?: string
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames collected by a stack trace (whether generated by new Error().stack or Error.captureStackTrace(obj)).

    The default value is 10 but may be set to any valid JavaScript number. Changes diff --git a/docs/classes/_questdb_nodejs-client.QwpReplayRejectedError.html b/docs/classes/_questdb_nodejs-client.QwpReplayRejectedError.html index e084ded..289a980 100644 --- a/docs/classes/_questdb_nodejs-client.QwpReplayRejectedError.html +++ b/docs/classes/_questdb_nodejs-client.QwpReplayRejectedError.html @@ -1,5 +1,5 @@ QwpReplayRejectedError | QuestDB JavaScript Client - v4.2.0

    A replayed ingress frame was rejected and remains in persistent storage.

    -

    Hierarchy

    • Error
      • QwpReplayRejectedError
    Index

    Constructors

    Hierarchy

    • Error
      • QwpReplayRejectedError
    Index

    Constructors

    Properties

    cause?: unknown
    frameSequence: bigint
    message: string
    name: string
    stack?: string
    status: number
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames +

    Constructors

    Properties

    cause?: unknown
    frameSequence: bigint
    message: string
    name: string
    stack?: string
    status: number
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames collected by a stack trace (whether generated by new Error().stack or Error.captureStackTrace(obj)).

    The default value is 10 but may be set to any valid JavaScript number. Changes diff --git a/docs/classes/_questdb_nodejs-client.QwpReplayStoreAppendTimeoutError.html b/docs/classes/_questdb_nodejs-client.QwpReplayStoreAppendTimeoutError.html index 83f6048..cd08926 100644 --- a/docs/classes/_questdb_nodejs-client.QwpReplayStoreAppendTimeoutError.html +++ b/docs/classes/_questdb_nodejs-client.QwpReplayStoreAppendTimeoutError.html @@ -1,4 +1,4 @@ -QwpReplayStoreAppendTimeoutError | QuestDB JavaScript Client - v4.2.0

    Class QwpReplayStoreAppendTimeoutError

    Hierarchy (View Summary)

    Index

    Constructors

    constructor +QwpReplayStoreAppendTimeoutError | QuestDB JavaScript Client - v4.2.0

    Class QwpReplayStoreAppendTimeoutError

    Hierarchy (View Summary)

    Index

    Constructors

    Properties

    cause? maxBytes message @@ -10,14 +10,14 @@ stackTraceLimit

    Methods

    Constructors

    Properties

    cause?: unknown
    maxBytes: number
    message: string
    name: string
    requiredBytes: number
    retryable: boolean = true

    Whether reconnecting and replaying can plausibly clear this failure.

    +

    Constructors

    Properties

    cause?: unknown
    maxBytes: number
    message: string
    name: string
    requiredBytes: number
    retryable: boolean = true

    Whether reconnecting and replaying can plausibly clear this failure.

    Background maintenance and checkpoint faults are parked and cleared on the next successful batch, so a briefly full, read-only or descriptor-starved filesystem is retryable. Structural corruption and a slot lock taken over by another process are verdicts on the journal itself and are not. The ingress connection lives in the browser-safe layer and cannot reference these classes, so it reads this flag structurally.

    -
    stack?: string
    timeoutMs: number
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames +

    stack?: string
    timeoutMs: number
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames collected by a stack trace (whether generated by new Error().stack or Error.captureStackTrace(obj)).

    The default value is 10 but may be set to any valid JavaScript number. Changes diff --git a/docs/classes/_questdb_nodejs-client.QwpReplayStoreCheckpointError.html b/docs/classes/_questdb_nodejs-client.QwpReplayStoreCheckpointError.html index 33ef3c9..74b0f88 100644 --- a/docs/classes/_questdb_nodejs-client.QwpReplayStoreCheckpointError.html +++ b/docs/classes/_questdb_nodejs-client.QwpReplayStoreCheckpointError.html @@ -1,4 +1,4 @@ -QwpReplayStoreCheckpointError | QuestDB JavaScript Client - v4.2.0

    Class QwpReplayStoreCheckpointError

    Hierarchy (View Summary)

    Index

    Constructors

    constructor +QwpReplayStoreCheckpointError | QuestDB JavaScript Client - v4.2.0

    Class QwpReplayStoreCheckpointError

    Hierarchy (View Summary)

    Index

    Constructors

    Properties

    Methods

    Constructors

    Properties

    cause?: unknown
    directory: string
    message: string
    name: string
    retryable: boolean = true

    Whether reconnecting and replaying can plausibly clear this failure.

    +

    Constructors

    Properties

    cause?: unknown
    directory: string
    message: string
    name: string
    retryable: boolean = true

    Whether reconnecting and replaying can plausibly clear this failure.

    Background maintenance and checkpoint faults are parked and cleared on the next successful batch, so a briefly full, read-only or descriptor-starved filesystem is retryable. Structural corruption and a slot lock taken over by another process are verdicts on the journal itself and are not. The ingress connection lives in the browser-safe layer and cannot reference these classes, so it reads this flag structurally.

    -
    stack?: string
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames +

    stack?: string
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames collected by a stack trace (whether generated by new Error().stack or Error.captureStackTrace(obj)).

    The default value is 10 but may be set to any valid JavaScript number. Changes diff --git a/docs/classes/_questdb_nodejs-client.QwpReplayStoreCorruptionError.html b/docs/classes/_questdb_nodejs-client.QwpReplayStoreCorruptionError.html index 0a739fa..75bf83d 100644 --- a/docs/classes/_questdb_nodejs-client.QwpReplayStoreCorruptionError.html +++ b/docs/classes/_questdb_nodejs-client.QwpReplayStoreCorruptionError.html @@ -1,5 +1,5 @@ QwpReplayStoreCorruptionError | QuestDB JavaScript Client - v4.2.0

    Class QwpReplayStoreCorruptionError

    Durable journal bytes are structurally corrupt and cannot be replayed.

    -

    Hierarchy (View Summary)

    Index

    Constructors

    Hierarchy (View Summary)

    Index

    Constructors

    Properties

    Methods

    Constructors

    Properties

    cause?: unknown
    message: string
    name: string
    retryable: false

    Corrupt bytes read the same way on every attempt.

    -
    stack?: string
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames +

    Constructors

    Properties

    cause?: unknown
    message: string
    name: string
    retryable: false

    Corrupt bytes read the same way on every attempt.

    +
    stack?: string
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames collected by a stack trace (whether generated by new Error().stack or Error.captureStackTrace(obj)).

    The default value is 10 but may be set to any valid JavaScript number. Changes diff --git a/docs/classes/_questdb_nodejs-client.QwpReplayStoreError.html b/docs/classes/_questdb_nodejs-client.QwpReplayStoreError.html index 7584c49..d2a48b6 100644 --- a/docs/classes/_questdb_nodejs-client.QwpReplayStoreError.html +++ b/docs/classes/_questdb_nodejs-client.QwpReplayStoreError.html @@ -1,4 +1,4 @@ -QwpReplayStoreError | QuestDB JavaScript Client - v4.2.0

    Hierarchy (View Summary)

    Index

    Constructors

    constructor +QwpReplayStoreError | QuestDB JavaScript Client - v4.2.0

    Hierarchy (View Summary)

    Index

    Constructors

    Properties

    cause? message name @@ -7,14 +7,14 @@ stackTraceLimit

    Methods

    Constructors

    Properties

    cause?: unknown
    message: string
    name: string
    retryable: boolean = true

    Whether reconnecting and replaying can plausibly clear this failure.

    +

    Constructors

    Properties

    cause?: unknown
    message: string
    name: string
    retryable: boolean = true

    Whether reconnecting and replaying can plausibly clear this failure.

    Background maintenance and checkpoint faults are parked and cleared on the next successful batch, so a briefly full, read-only or descriptor-starved filesystem is retryable. Structural corruption and a slot lock taken over by another process are verdicts on the journal itself and are not. The ingress connection lives in the browser-safe layer and cannot reference these classes, so it reads this flag structurally.

    -
    stack?: string
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames +

    stack?: string
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames collected by a stack trace (whether generated by new Error().stack or Error.captureStackTrace(obj)).

    The default value is 10 but may be set to any valid JavaScript number. Changes diff --git a/docs/classes/_questdb_nodejs-client.QwpReplayStoreFullError.html b/docs/classes/_questdb_nodejs-client.QwpReplayStoreFullError.html index 45a257b..a9704df 100644 --- a/docs/classes/_questdb_nodejs-client.QwpReplayStoreFullError.html +++ b/docs/classes/_questdb_nodejs-client.QwpReplayStoreFullError.html @@ -1,4 +1,4 @@ -QwpReplayStoreFullError | QuestDB JavaScript Client - v4.2.0

    Hierarchy (View Summary)

    Index

    Constructors

    constructor +QwpReplayStoreFullError | QuestDB JavaScript Client - v4.2.0

    Hierarchy (View Summary)

    Index

    Constructors

    Properties

    Methods

    Constructors

    Properties

    cause?: unknown
    maxBytes: number
    message: string
    name: string
    requiredBytes: number
    retryable: boolean = true

    Whether reconnecting and replaying can plausibly clear this failure.

    +

    Constructors

    Properties

    cause?: unknown
    maxBytes: number
    message: string
    name: string
    requiredBytes: number
    retryable: boolean = true

    Whether reconnecting and replaying can plausibly clear this failure.

    Background maintenance and checkpoint faults are parked and cleared on the next successful batch, so a briefly full, read-only or descriptor-starved filesystem is retryable. Structural corruption and a slot lock taken over by another process are verdicts on the journal itself and are not. The ingress connection lives in the browser-safe layer and cannot reference these classes, so it reads this flag structurally.

    -
    stack?: string
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames +

    stack?: string
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames collected by a stack trace (whether generated by new Error().stack or Error.captureStackTrace(obj)).

    The default value is 10 but may be set to any valid JavaScript number. Changes diff --git a/docs/classes/_questdb_nodejs-client.QwpReplayStoreLockLostError.html b/docs/classes/_questdb_nodejs-client.QwpReplayStoreLockLostError.html index 3c395c0..58073f9 100644 --- a/docs/classes/_questdb_nodejs-client.QwpReplayStoreLockLostError.html +++ b/docs/classes/_questdb_nodejs-client.QwpReplayStoreLockLostError.html @@ -7,7 +7,7 @@ because a frame's sequence is derived from its position, an overwrite of the same width leaves a journal that reopens as intact with the new owner's frames gone. Failing the append is what keeps that loss impossible.

    -

    Hierarchy (View Summary)

    Index

    Constructors

    Hierarchy (View Summary)

    Index

    Constructors

    Properties

    Methods

    Constructors

    Properties

    cause?: unknown
    directory: string
    message: string
    name: string
    retryable: false

    Retrying is precisely what must not happen: the slot belongs to another +

    Constructors

    Properties

    cause?: unknown
    directory: string
    message: string
    name: string
    retryable: false

    Retrying is precisely what must not happen: the slot belongs to another process now, so replaying out of it would race that owner's appends.

    -
    stack?: string
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames +

    stack?: string
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames collected by a stack trace (whether generated by new Error().stack or Error.captureStackTrace(obj)).

    The default value is 10 but may be set to any valid JavaScript number. Changes diff --git a/docs/classes/_questdb_nodejs-client.QwpReplayStoreLockedError.html b/docs/classes/_questdb_nodejs-client.QwpReplayStoreLockedError.html index 58d4f24..fa6b2a3 100644 --- a/docs/classes/_questdb_nodejs-client.QwpReplayStoreLockedError.html +++ b/docs/classes/_questdb_nodejs-client.QwpReplayStoreLockedError.html @@ -1,4 +1,4 @@ -QwpReplayStoreLockedError | QuestDB JavaScript Client - v4.2.0

    Hierarchy (View Summary)

    Index

    Constructors

    constructor +QwpReplayStoreLockedError | QuestDB JavaScript Client - v4.2.0

    Hierarchy (View Summary)

    Index

    Constructors

    Properties

    cause?: unknown
    directory: string
    holderPid?: number
    message: string
    name: string
    retryable: boolean = true

    Whether reconnecting and replaying can plausibly clear this failure.

    +

    Constructors

    Properties

    cause?: unknown
    directory: string
    holderPid?: number
    message: string
    name: string
    retryable: boolean = true

    Whether reconnecting and replaying can plausibly clear this failure.

    Background maintenance and checkpoint faults are parked and cleared on the next successful batch, so a briefly full, read-only or descriptor-starved filesystem is retryable. Structural corruption and a slot lock taken over by another process are verdicts on the journal itself and are not. The ingress connection lives in the browser-safe layer and cannot reference these classes, so it reads this flag structurally.

    -
    stack?: string
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames +

    stack?: string
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames collected by a stack trace (whether generated by new Error().stack or Error.captureStackTrace(obj)).

    The default value is 10 but may be set to any valid JavaScript number. Changes diff --git a/docs/classes/_questdb_nodejs-client.QwpReplayStoreQuarantinedError.html b/docs/classes/_questdb_nodejs-client.QwpReplayStoreQuarantinedError.html index becff61..45c9933 100644 --- a/docs/classes/_questdb_nodejs-client.QwpReplayStoreQuarantinedError.html +++ b/docs/classes/_questdb_nodejs-client.QwpReplayStoreQuarantinedError.html @@ -1,5 +1,5 @@ QwpReplayStoreQuarantinedError | QuestDB JavaScript Client - v4.2.0

    Class QwpReplayStoreQuarantinedError

    A terminal replay slot was preserved under a quarantine pathname.

    -

    Hierarchy (View Summary)

    Index

    Constructors

    Hierarchy (View Summary)

    Index

    Constructors

    Properties

    Methods

    Constructors

    Properties

    cause?: unknown
    directory: string
    message: string
    name: string
    quarantineDirectory: string
    retryable: boolean = true

    Whether reconnecting and replaying can plausibly clear this failure.

    +

    Constructors

    Properties

    cause?: unknown
    directory: string
    message: string
    name: string
    quarantineDirectory: string
    retryable: boolean = true

    Whether reconnecting and replaying can plausibly clear this failure.

    Background maintenance and checkpoint faults are parked and cleared on the next successful batch, so a briefly full, read-only or descriptor-starved filesystem is retryable. Structural corruption and a slot lock taken over by another process are verdicts on the journal itself and are not. The ingress connection lives in the browser-safe layer and cannot reference these classes, so it reads this flag structurally.

    -
    stack?: string
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames +

    stack?: string
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames collected by a stack trace (whether generated by new Error().stack or Error.captureStackTrace(obj)).

    The default value is 10 but may be set to any valid JavaScript number. Changes diff --git a/docs/classes/_questdb_nodejs-client.QwpReplayStoreSegmentTooLargeError.html b/docs/classes/_questdb_nodejs-client.QwpReplayStoreSegmentTooLargeError.html index e7afbf1..8fa2945 100644 --- a/docs/classes/_questdb_nodejs-client.QwpReplayStoreSegmentTooLargeError.html +++ b/docs/classes/_questdb_nodejs-client.QwpReplayStoreSegmentTooLargeError.html @@ -1,4 +1,4 @@ -QwpReplayStoreSegmentTooLargeError | QuestDB JavaScript Client - v4.2.0

    Class QwpReplayStoreSegmentTooLargeError

    Hierarchy (View Summary)

    Index

    Constructors

    constructor +QwpReplayStoreSegmentTooLargeError | QuestDB JavaScript Client - v4.2.0

    Class QwpReplayStoreSegmentTooLargeError

    Hierarchy (View Summary)

    Index

    Constructors

    Properties

    cause?: unknown
    maxSegmentBytes: number
    message: string
    name: string
    payloadBytes: number
    retryable: boolean = true

    Whether reconnecting and replaying can plausibly clear this failure.

    +

    Constructors

    Properties

    cause?: unknown
    maxSegmentBytes: number
    message: string
    name: string
    payloadBytes: number
    retryable: boolean = true

    Whether reconnecting and replaying can plausibly clear this failure.

    Background maintenance and checkpoint faults are parked and cleared on the next successful batch, so a briefly full, read-only or descriptor-starved filesystem is retryable. Structural corruption and a slot lock taken over by another process are verdicts on the journal itself and are not. The ingress connection lives in the browser-safe layer and cannot reference these classes, so it reads this flag structurally.

    -
    stack?: string
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames +

    stack?: string
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames collected by a stack trace (whether generated by new Error().stack or Error.captureStackTrace(obj)).

    The default value is 10 but may be set to any valid JavaScript number. Changes diff --git a/docs/classes/_questdb_nodejs-client.QwpResultBatch.html b/docs/classes/_questdb_nodejs-client.QwpResultBatch.html index d20c72d..a767939 100644 --- a/docs/classes/_questdb_nodejs-client.QwpResultBatch.html +++ b/docs/classes/_questdb_nodejs-client.QwpResultBatch.html @@ -1,4 +1,4 @@ -QwpResultBatch | QuestDB JavaScript Client - v4.2.0

    Index

    Constructors

    constructor +QwpResultBatch | QuestDB JavaScript Client - v4.2.0
    Index

    Constructors

    Properties

    Methods

    Constructors

    Properties

    batchSequence: bigint
    columns: readonly QwpResultColumn[]
    requestId: bigint
    rowCount: number
    tableName: string

    Methods

    +

    Constructors

    Properties

    batchSequence: bigint
    columns: readonly QwpResultColumn[]
    requestId: bigint
    rowCount: number
    tableName: string

    Methods

    diff --git a/docs/classes/_questdb_nodejs-client.QwpResultBatchDecoder.html b/docs/classes/_questdb_nodejs-client.QwpResultBatchDecoder.html index 27fcd8b..e49818f 100644 --- a/docs/classes/_questdb_nodejs-client.QwpResultBatchDecoder.html +++ b/docs/classes/_questdb_nodejs-client.QwpResultBatchDecoder.html @@ -1,13 +1,24 @@ QwpResultBatchDecoder | QuestDB JavaScript Client - v4.2.0

    Stateful decoder for connection-scoped QWP result batches.

    -
    Index

    Constructors

    Index

    Constructors

    Methods

    Constructors

    Properties

    maxBatchRows?: number

    Upper bound on a batch's declared row count, taken from the client's own +maxBatchRows request.

    +

    That request only ever reached the wire -- an upgrade header on Node, a +query parameter in the browser -- and nothing checked the answer against +it. Scratch arrays are sized from the declared row count and deliberately +retained per pool slot for reuse, so a peer that ignores the request, or a +hostile one, sets this session's memory floor for its lifetime: bounded +only by QWP_MAX_CELLS_PER_BATCH times the pool size, which the cap's own +comment puts at roughly half a gigabyte per slot.

    +

    Left undefined the batch is bounded by the cell cap alone, as before.

    +

    Methods

    +

    Parameters

    Returns QwpResultBatchView

    diff --git a/docs/classes/_questdb_nodejs-client.QwpResultBatchView.html b/docs/classes/_questdb_nodejs-client.QwpResultBatchView.html index e6783c3..553b88d 100644 --- a/docs/classes/_questdb_nodejs-client.QwpResultBatchView.html +++ b/docs/classes/_questdb_nodejs-client.QwpResultBatchView.html @@ -1,7 +1,7 @@ QwpResultBatchView | QuestDB JavaScript Client - v4.2.0

    Batch-owned reusable view delivered by QwpEgressSession.queryViews(). Access is invalid after the callback returns. materialize() creates an independently owned QwpResultBatch when retention is required.

    -
    Index

    Constructors

    Index

    Constructors

    Accessors

    Constructors

    Accessors

    Methods

    • Visits rows in index order with one re-pointed row view. The callback is +

    Constructors

    Accessors

    Methods

    • Internal

      Parameters

      • requestId: bigint
      • batchSequence: bigint
      • tableName: string
      • rowCount: number
      • layouts: QwpResultColumnViewLayout[]

      Returns this

    • Internal

      Parameters

      • requestId: bigint
      • batchSequence: bigint
      • tableName: string
      • rowCount: number
      • layouts: QwpResultColumnViewLayout[]

      Returns this

    +

    Parameters

    • rowIndex: number

    Returns QwpResultRowView

    diff --git a/docs/classes/_questdb_nodejs-client.QwpResultColumnView.html b/docs/classes/_questdb_nodejs-client.QwpResultColumnView.html index 93453b1..cd6f41b 100644 --- a/docs/classes/_questdb_nodejs-client.QwpResultColumnView.html +++ b/docs/classes/_questdb_nodejs-client.QwpResultColumnView.html @@ -2,7 +2,7 @@

    The view and every byte slice returned from it are valid only while the surrounding queryViews() callback is running. Copy data that must outlive the callback.

    -
    Index

    Constructors

    Index

    Constructors

    Properties

    Accessors

    Constructors

    Properties

    columnIndex: number

    Accessors

    Methods

    • Raw packed non-null values. Fixed-width values use QWP little-endian +

    Constructors

    Properties

    columnIndex: number

    Accessors

    Methods

    • Raw packed non-null values. Fixed-width values use QWP little-endian layout; booleans are bit-packed and variable-width columns contain their uint32 offset table. SYMBOL returns undefined because IDs are varints.

      -

      Returns Uint8Array<ArrayBufferLike>

    +

    Returns Uint8Array<ArrayBufferLike>

    diff --git a/docs/classes/_questdb_nodejs-client.QwpResultRowView.html b/docs/classes/_questdb_nodejs-client.QwpResultRowView.html index b5363f9..2bec10f 100644 --- a/docs/classes/_questdb_nodejs-client.QwpResultRowView.html +++ b/docs/classes/_questdb_nodejs-client.QwpResultRowView.html @@ -3,7 +3,7 @@ while the surrounding queryViews() callback is running, and must not be retained across forEachRow() iterations. Byte and array views returned by its accessors remain zero-copy and have the same lifetime.

    -
    Index

    Constructors

    Index

    Constructors

    Accessors

    Methods

    get @@ -29,10 +29,10 @@ getUuidLow isNull of -

    Constructors

    Accessors

    Methods

    +

    Constructors

    Accessors

    Methods

    diff --git a/docs/classes/_questdb_nodejs-client.QwpRoleMismatchError.html b/docs/classes/_questdb_nodejs-client.QwpRoleMismatchError.html index 7128c73..fdbd4e4 100644 --- a/docs/classes/_questdb_nodejs-client.QwpRoleMismatchError.html +++ b/docs/classes/_questdb_nodejs-client.QwpRoleMismatchError.html @@ -1,5 +1,5 @@ QwpRoleMismatchError | QuestDB JavaScript Client - v4.2.0

    A connected endpoint advertised a role that does not satisfy target.

    -

    Hierarchy (View Summary)

    Index

    Constructors

    Hierarchy (View Summary)

    Index

    Constructors

    Properties

    cause?: unknown
    closeCode?: number
    message: string
    name: string
    retryable?: boolean
    serverRole?: string
    serverZone?: string
    stack?: string
    statusCode?: number
    statusMessage?: string
    target: QwpTarget
    timeoutPhase?: QwpUpgradeTimeoutPhase

    Node opening phase that exceeded its deadline.

    -
    tryNextEndpoint?: boolean
    url?: string | URL
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames +

    Constructors

    Properties

    cause?: unknown
    closeCode?: number
    message: string
    name: string
    retryable?: boolean
    serverRole?: string
    serverZone?: string
    stack?: string
    statusCode?: number
    statusMessage?: string
    target: QwpTarget
    timeoutPhase?: QwpUpgradeTimeoutPhase

    Node opening phase that exceeded its deadline.

    +
    tryNextEndpoint?: boolean
    url?: string | URL
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames collected by a stack trace (whether generated by new Error().stack or Error.captureStackTrace(obj)).

    The default value is 10 but may be set to any valid JavaScript number. Changes @@ -29,8 +29,8 @@

    If set to a non-number value, or set to a negative number, stack traces will not capture any frames.

    Accessors

    Methods

    • Creates a .stack property on targetObject, which when accessed returns +

      Returns boolean

    Methods

    diff --git a/docs/classes/_questdb_nodejs-client.QwpSenderCloseTimeoutError.html b/docs/classes/_questdb_nodejs-client.QwpSenderCloseTimeoutError.html index 3ce17eb..32f8d4b 100644 --- a/docs/classes/_questdb_nodejs-client.QwpSenderCloseTimeoutError.html +++ b/docs/classes/_questdb_nodejs-client.QwpSenderCloseTimeoutError.html @@ -1,5 +1,5 @@ QwpSenderCloseTimeoutError | QuestDB JavaScript Client - v4.2.0

    Class QwpSenderCloseTimeoutError

    close() could not publish and acknowledge all committed ingress frames.

    -

    Hierarchy

    • Error
      • QwpSenderCloseTimeoutError
    Index

    Constructors

    Hierarchy

    • Error
      • QwpSenderCloseTimeoutError
    Index

    Constructors

    Properties

    acknowledgedSequence: bigint
    cause?: unknown
    message: string
    name: string
    stack?: string
    targetSequence: bigint
    timeoutMs: number
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames +

    Constructors

    Properties

    acknowledgedSequence: bigint
    cause?: unknown
    message: string
    name: string
    stack?: string
    targetSequence: bigint
    timeoutMs: number
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames collected by a stack trace (whether generated by new Error().stack or Error.captureStackTrace(obj)).

    The default value is 10 but may be set to any valid JavaScript number. Changes diff --git a/docs/classes/_questdb_nodejs-client.QwpSymbolDictionary.html b/docs/classes/_questdb_nodejs-client.QwpSymbolDictionary.html index 9d92eda..c5edf1b 100644 --- a/docs/classes/_questdb_nodejs-client.QwpSymbolDictionary.html +++ b/docs/classes/_questdb_nodejs-client.QwpSymbolDictionary.html @@ -1,5 +1,5 @@ QwpSymbolDictionary | QuestDB JavaScript Client - v4.2.0

    Connection-scoped QWP symbol dictionary. IDs are dense from zero.

    -
    Index

    Constructors

    Index

    Constructors

    Accessors

    Methods

    Constructors

    Accessors

    Methods

    +

    Constructors

    Accessors

    Methods

    diff --git a/docs/classes/_questdb_nodejs-client.QwpTableBuffer.html b/docs/classes/_questdb_nodejs-client.QwpTableBuffer.html index e33ab40..c75d8f2 100644 --- a/docs/classes/_questdb_nodejs-client.QwpTableBuffer.html +++ b/docs/classes/_questdb_nodejs-client.QwpTableBuffer.html @@ -1,5 +1,5 @@ QwpTableBuffer | QuestDB JavaScript Client - v4.2.0

    Mutable columnar staging area for one QWP ingress table.

    -
    Index

    Constructors

    Index

    Constructors

    Properties

    Accessors

    Constructors

    Properties

    name: string

    Accessors

    Methods

    • Returns null when the current row already contains this column. The first +

    Constructors

    Properties

    name: string

    Accessors

    Methods

    +

    Parameters

    • start: number
    • end: number

    Returns QwpTableBuffer

    diff --git a/docs/classes/_questdb_nodejs-client.QwpTableWriter.html b/docs/classes/_questdb_nodejs-client.QwpTableWriter.html index fe19c85..4992056 100644 --- a/docs/classes/_questdb_nodejs-client.QwpTableWriter.html +++ b/docs/classes/_questdb_nodejs-client.QwpTableWriter.html @@ -1,9 +1,9 @@ QwpTableWriter | QuestDB JavaScript Client - v4.2.0

    Class QwpTableWriter<Schema>

    A reusable table-bound writer compiled from a QWP schema.

    -

    Type Parameters

    Index

    Constructors

    Type Parameters

    Index

    Constructors

    Properties

    Methods

    Constructors

    • Internal

      Construct table writers with QwpSender.writer().

      -

      Type Parameters

      Parameters

      • token: typeof QWP_TABLE_WRITER_CONSTRUCTOR
      • tableName: string
      • appendRow: (row: unknown, rowIndex?: number) => Promise<void>

      Returns QwpTableWriter<Schema>

    Properties

    tableName: string

    Methods

    +

    Type Parameters

    Parameters

    • token: typeof QWP_TABLE_WRITER_CONSTRUCTOR
    • tableName: string
    • appendRow: (row: unknown, rowIndex?: number) => Promise<void>

    Returns QwpTableWriter<Schema>

    Properties

    tableName: string

    Methods

    diff --git a/docs/classes/_questdb_nodejs-client.QwpUdpDatagramTooLargeError.html b/docs/classes/_questdb_nodejs-client.QwpUdpDatagramTooLargeError.html index 2dc0e53..c5a50a3 100644 --- a/docs/classes/_questdb_nodejs-client.QwpUdpDatagramTooLargeError.html +++ b/docs/classes/_questdb_nodejs-client.QwpUdpDatagramTooLargeError.html @@ -1,5 +1,5 @@ QwpUdpDatagramTooLargeError | QuestDB JavaScript Client - v4.2.0

    Class QwpUdpDatagramTooLargeError

    A single encoded row cannot fit into the configured UDP datagram.

    -

    Hierarchy

    • Error
      • QwpUdpDatagramTooLargeError
    Index

    Constructors

    Hierarchy

    • Error
      • QwpUdpDatagramTooLargeError
    Index

    Constructors

    Properties

    cause?: unknown
    datagramSize: number
    maxDatagramSize: number
    message: string
    name: string
    row: number
    stack?: string
    tableName: string
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames +

    Constructors

    Properties

    cause?: unknown
    datagramSize: number
    maxDatagramSize: number
    message: string
    name: string
    row: number
    stack?: string
    tableName: string
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames collected by a stack trace (whether generated by new Error().stack or Error.captureStackTrace(obj)).

    The default value is 10 but may be set to any valid JavaScript number. Changes diff --git a/docs/classes/_questdb_nodejs-client.QwpUnrecoverableReplayDictionaryError.html b/docs/classes/_questdb_nodejs-client.QwpUnrecoverableReplayDictionaryError.html index fd27d1a..941fee5 100644 --- a/docs/classes/_questdb_nodejs-client.QwpUnrecoverableReplayDictionaryError.html +++ b/docs/classes/_questdb_nodejs-client.QwpUnrecoverableReplayDictionaryError.html @@ -1,6 +1,6 @@ QwpUnrecoverableReplayDictionaryError | QuestDB JavaScript Client - v4.2.0

    Class QwpUnrecoverableReplayDictionaryError

    Recovered delta frames depend on symbol IDs that neither the durable dictionary prefix nor the surviving frames can reconstruct.

    -

    Hierarchy (View Summary)

    Index

    Constructors

    Hierarchy (View Summary)

    Index

    Constructors

    Properties

    Methods

    Constructors

    Properties

    cause?: unknown
    message: string
    name: string
    stack?: string
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames +

    Constructors

    Properties

    cause?: unknown
    message: string
    name: string
    stack?: string
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames collected by a stack trace (whether generated by new Error().stack or Error.captureStackTrace(obj)).

    The default value is 10 but may be set to any valid JavaScript number. Changes diff --git a/docs/classes/_questdb_nodejs-client.QwpUpgradeError.html b/docs/classes/_questdb_nodejs-client.QwpUpgradeError.html index 89f20ea..7de0129 100644 --- a/docs/classes/_questdb_nodejs-client.QwpUpgradeError.html +++ b/docs/classes/_questdb_nodejs-client.QwpUpgradeError.html @@ -1,5 +1,5 @@ QwpUpgradeError | QuestDB JavaScript Client - v4.2.0

    A failure while establishing or validating a QWP WebSocket upgrade.

    -

    Hierarchy (View Summary)

    Index

    Constructors

    Hierarchy (View Summary)

    Index

    Constructors

    Properties

    cause?: unknown
    closeCode?: number
    message: string
    name: string
    retryable?: boolean
    serverRole?: string
    serverZone?: string
    stack?: string
    statusCode?: number
    statusMessage?: string
    timeoutPhase?: QwpUpgradeTimeoutPhase

    Node opening phase that exceeded its deadline.

    -
    tryNextEndpoint?: boolean
    url?: string | URL
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames +

    Constructors

    Properties

    cause?: unknown
    closeCode?: number
    message: string
    name: string
    retryable?: boolean
    serverRole?: string
    serverZone?: string
    stack?: string
    statusCode?: number
    statusMessage?: string
    timeoutPhase?: QwpUpgradeTimeoutPhase

    Node opening phase that exceeded its deadline.

    +
    tryNextEndpoint?: boolean
    url?: string | URL
    stackTraceLimit: number

    The Error.stackTraceLimit property specifies the number of stack frames collected by a stack trace (whether generated by new Error().stack or Error.captureStackTrace(obj)).

    The default value is 10 but may be set to any valid JavaScript number. Changes @@ -28,8 +28,8 @@

    If set to a non-number value, or set to a negative number, stack traces will not capture any frames.

    Accessors

    Methods

    Methods

    • Closes the row after writing the designated timestamp. On ILP, an invalid timestamp unit is rejected before closing begins and leaves the row open so this method can be retried. If other validation or encoding rejects the row before it is completed, the incomplete row and its @@ -146,7 +146,7 @@

      If timestamp is not an integer or BigInt.

      If unit is 'ns' but timestamp is not a BigInt.

      If unit is not one of 'ns', 'us', or 'ms'.

      -
    • Closes the row without writing a designated timestamp. Designated timestamp will be populated by the server on this record. If validation or encoding rejects the row before it is completed, the incomplete row and its table selection are discarded; rows completed @@ -156,19 +156,19 @@ retry ILP rows must retain and resubmit them. QWP retains successfully closed rows for its retry and replay path.

      Returns Promise<void>

      Resolves after the row is closed and any triggered auto-flush completes.

      -
    • Writes a boolean column with its value into the buffer of the sender.
      Use it to insert into BOOLEAN columns.

      Parameters

      • name: string

        Column name.

      • value: boolean

        Column value, accepts only boolean values. A null or undefined value omits the column entirely (stored as NULL).

      Returns Sender

      Returns with a reference to this sender.

      -
    • Closes the connection to the database. QWP publishes completed rows and performs a bounded acknowledgement drain first. Other transports retain their legacy behavior and require an explicit flush().

      -

      Returns Promise<void>

    • Establishes the transport connection for TCP, TCPS, WS, WSS, and UDP. HTTP and HTTPS connect per request and reject this call because no explicit connection step is required.

      Returns Promise<boolean>

      Resolves to true if the client is connected.

      -
    • Writes a decimal value into the buffer using the binary format.

      +
    • Writes a decimal value into the buffer using the binary format.

      Use it to insert into DECIMAL database columns.

      Parameters

      • name: string

        Column name.

      • unscaled: bigint | Int8Array<ArrayBufferLike>

        The unscaled value of the decimal in two's @@ -187,7 +187,7 @@

      • scale is not between 0 and 76
      • unscaled value contains invalid bytes
      -
    • Writes a decimal value into the buffer using the text format.

      Use it to insert into DECIMAL database columns.

      Parameters

      • name: string

        Column name.

      • value: string | number

        Column value, accepts only number/string values. A null or undefined value omits the column entirely when decimals are supported; ILP protocol v1/v2 reject the call for every value.

        @@ -196,40 +196,40 @@
        • string value is not a valid decimal representation
        -
    • Writes a 64-bit floating point value into the buffer of the sender.
      Use it to insert into DOUBLE or FLOAT database columns.

      Parameters

      • name: string

        Column name.

      • value: number

        Column value, accepts only number values. A null or undefined value omits the column entirely (stored as NULL).

      Returns Sender

      Returns with a reference to this sender.

      -
    • Sends the content of the sender's buffer to the database and compacts the buffer. If the last row is not finished it stays in the sender's buffer.

      Returns Promise<boolean>

      Resolves to true when there was data in the buffer to send, and it was sent successfully.

      -
    • Flushes pending rows and returns the highest QWP frame sequence published by this call. Non-QWP transports flush normally and return -1n because they do not expose frame sequences.

      -

      Returns Promise<bigint>

    • Writes a 64-bit signed integer into the buffer of the sender.
      Use it to insert into LONG, INT, SHORT and BYTE columns.

      Parameters

      • name: string

        Column name.

      • value: number

        Column value, accepts only number values. A null or undefined value omits the column entirely (stored as NULL).

      Returns Sender

      Returns with a reference to this sender.

      Error if the value is not an integer

      -
    • Resets the sender's buffer, data sitting in the buffer will be lost.
      In other words it clears the buffer, and sets the writing position to the beginning of the buffer.

      Returns Sender

      Returns with a reference to this sender.

      -
    • Writes a string column with its value into the buffer of the sender.
      Use it to insert into VARCHAR and STRING columns.

      Parameters

      • name: string

        Column name.

      • value: string

        Column value, accepts only string values. A null or undefined value omits the column entirely (stored as NULL).

      Returns Sender

      Returns with a reference to this sender.

      -
    • Writes a symbol name and value into the buffer of the sender.
      Use it to insert into SYMBOL columns.

      Parameters

      • name: string

        Symbol name.

      • value: unknown

        Symbol value, toString() is called to extract the actual symbol value from the parameter. A null or undefined value omits the symbol entirely (stored as NULL).

      Returns Sender

      Returns with a reference to this sender.

      -
    • Writes a timestamp column and its value into the buffer of the sender.

      Use this method to insert data into TIMESTAMP or TIMESTAMP_NS columns.

      Precision rules:

        @@ -251,10 +251,10 @@

    Returns Sender

    Returns with a reference to this buffer.

    If value is not an integer or BigInt.

    If unit is 'ns' but value is not a BigInt.

    -
    • Waits independently for a cumulative QWP ACK watermark.

      -

      Parameters

      • targetSequence: bigint
      • OptionaltimeoutMs: number

      Returns Promise<void>

    • Waits independently for a cumulative QWP ACK watermark.

      +

      Parameters

      • targetSequence: bigint
      • OptionaltimeoutMs: number

      Returns Promise<void>

    • Creates a Sender object by parsing the provided configuration string.

      Parameters

      • configurationString: string

        Configuration string.

      • OptionalextraOptions: ExtraOptions

        Optional extra configuration.

          @@ -264,7 +264,7 @@ Depends on which transport implementation and protocol used, one of the followings expected: undici.Agent, http.Agent or https.Agent.

      Returns Promise<Sender>

      A Sender object initialized from the provided configuration string.

      -
    • Creates a Sender object by parsing the configuration string set in the QDB_CLIENT_CONF environment variable.

      Parameters

      • OptionalextraOptions: ExtraOptions

        Optional extra configuration.

        • 'log' is a logging function used by the Sender. @@ -273,4 +273,4 @@ Depends on which transport implementation and protocol used, one of the followings expected: undici.Agent, http.Agent or https.Agent.

      Returns Promise<Sender>

      A Sender object initialized from the QDB_CLIENT_CONF environment variable.

      -
    +
    diff --git a/docs/classes/_questdb_nodejs-client.SenderBufferV1.html b/docs/classes/_questdb_nodejs-client.SenderBufferV1.html index 0e945e3..4aca1e6 100644 --- a/docs/classes/_questdb_nodejs-client.SenderBufferV1.html +++ b/docs/classes/_questdb_nodejs-client.SenderBufferV1.html @@ -1,6 +1,6 @@ SenderBufferV1 | QuestDB JavaScript Client - v4.2.0

    Buffer implementation for protocol version 1.
    Sends floating point numbers in their text form.

    -

    Hierarchy

    • SenderBufferBase
      • SenderBufferV1
    Index

    Constructors

    Hierarchy

    • SenderBufferBase
      • SenderBufferV1
    Index

    Constructors

    Properties

    buffer log position @@ -25,13 +25,13 @@

    Constructors

    Properties

    buffer: Buffer
    log: Logger
    position: number

    Methods

    • Array columns are not supported in protocol v1.
      +

    Returns SenderBufferV1

    Properties

    buffer: Buffer
    log: Logger
    position: number

    Methods

    • Array columns are not supported in protocol v1.
      The capability check applies even when the value is null or undefined.

      Parameters

      • name: string

        Column name.

      • value: unknown[]

        Array values.

      Returns SenderBuffer

      Returns with a reference to this buffer.

      Error indicating arrays are not supported in v1

      -
    • Closes the row after writing the designated timestamp into the buffer.

      Precision rules:

      • Protocol v2 and higher: @@ -53,19 +53,19 @@

      If unit is 'ns' but timestamp is not a BigInt.

      If unit is not one of 'ns', 'us', or 'ms'. This validation leaves the open row unchanged so the call can be retried.

      -
    • Closes the row without writing designated timestamp into the buffer.
      Designated timestamp will be populated by the server on this record.

      -

      Returns void

    • Writes a boolean column with its value into the buffer.
      Use it to insert into BOOLEAN columns.

      Parameters

      • name: string

        Column name.

      • value: boolean

        Column value, accepts only boolean values. A null or undefined value omits the column entirely (stored as NULL).

      Returns SenderBuffer

      Returns with a reference to this buffer.

      -
    • Checks if the buffer has sufficient capacity for additional data and resizes if needed.

      +
    • Checks if the buffer has sufficient capacity for additional data and resizes if needed.

      Parameters

      • data: string[]

        Array of strings to calculate the required capacity for

      • base: number = 0

        Base number of bytes to add to the calculation

        -

      Returns void

    • Returns the current position of the buffer.
      +

    Returns void

    • Returns the current position of the buffer.
      New data will be written into the buffer starting from this position.

      -

      Returns number

    • Writes a decimal value into the buffer using its binary format.

      +

      Returns number

    • Writes a decimal value into the buffer using its binary format.

      Use it to insert into DECIMAL database columns.

      Decimals are not supported by protocol v1/v2, so this base implementation rejects the call even when the value is null or undefined. Protocol v3 @@ -78,7 +78,7 @@

      If scale is not between 0 and 76. Scale validation runs even when unscaled is null or undefined.

      Indicating decimals are not supported in protocol v1/v2.

      -
    • Writes a decimal value into the buffer using its text format.

      Use it to insert into DECIMAL database columns.

      Decimals are not supported by protocol v1/v2, so this base implementation rejects the call even when the value is null or undefined. Protocol v3 @@ -88,34 +88,34 @@ write.

    Returns SenderBuffer

    Returns with a reference to this buffer.

    Indicating decimals are not supported in protocol v1/v2.

    -
    • Writes a 64-bit floating point value into the buffer using v1 serialization (text format).
      Use it to insert into DOUBLE or FLOAT database columns.

      Parameters

      • name: string

        Column name.

      • value: number

        Column value, accepts only number values. A null or undefined value omits the column entirely (stored as NULL).

      Returns SenderBuffer

      Returns with a reference to this sender.

      -
    • Writes a 64-bit signed integer into the buffer.
      Use it to insert into LONG, INT, SHORT and BYTE columns.

      Parameters

      • name: string

        Column name.

      • value: number

        Column value, accepts only number values. A null or undefined value omits the column entirely (stored as NULL).

      Returns SenderBuffer

      Returns with a reference to this buffer.

      Error if the value is not an integer

      -
    • Writes a string column with its value into the buffer.
      Use it to insert into VARCHAR and STRING columns.

      Parameters

      • name: string

        Column name.

      • value: string

        Column value, accepts only string values. A null or undefined value omits the column entirely (stored as NULL).

      Returns SenderBuffer

      Returns with a reference to this buffer.

      -
    • Writes a symbol name and value into the buffer.
      Use it to insert into SYMBOL columns.

      Parameters

      • name: string

        Symbol name.

      • value: unknown

        Symbol value, toString() is called to extract the actual symbol value from the parameter. A null or undefined value omits the symbol entirely (stored as NULL).

      Returns SenderBuffer

      Returns with a reference to this buffer.

      -
    • Writes a timestamp column and its value into the buffer.

      Use this method to insert data into TIMESTAMP or TIMESTAMP_NS columns.

      Precision rules:

        @@ -139,10 +139,10 @@ even when value is null or undefined).

      If value is not an integer or BigInt.

      If unit is 'ns' but value is not a BigInt.

      -
    • Parameters

      • pos: number = ...

      Returns Buffer

      Returns a cropped buffer ready to send to the server, or null if there is nothing to send.
      +

    • Parameters

      • pos: number = ...

      Returns Buffer

      Returns a cropped buffer ready to send to the server, or null if there is nothing to send.
      The returned buffer is a copy of this buffer. It also compacts the buffer.

      -
    • Parameters

      • pos: number = ...

      Returns Buffer

      Returns a cropped buffer, or null if there is nothing to send.
      +

    • Parameters

      • pos: number = ...

      Returns Buffer

      Returns a cropped buffer, or null if there is nothing to send.
      The returned buffer is backed by this buffer instance, meaning the view can change as the buffer is mutated. Used only in tests to assert the buffer's content.

      -
    +
    diff --git a/docs/classes/_questdb_nodejs-client.SenderBufferV2.html b/docs/classes/_questdb_nodejs-client.SenderBufferV2.html index 7b74a3e..c56644a 100644 --- a/docs/classes/_questdb_nodejs-client.SenderBufferV2.html +++ b/docs/classes/_questdb_nodejs-client.SenderBufferV2.html @@ -1,6 +1,6 @@ SenderBufferV2 | QuestDB JavaScript Client - v4.2.0

    Buffer implementation for protocol version 2.
    Sends floating point numbers in binary form, and provides support for arrays.

    -

    Hierarchy (View Summary)

    Index

    Constructors

    Hierarchy (View Summary)

    Index

    Constructors

    Properties

    buffer log position @@ -25,7 +25,7 @@

    Constructors

    Properties

    buffer: Buffer
    log: Logger
    position: number

    Methods

    • Write an array column with its values into the buffer using v2 format.

      +

    Returns SenderBufferV2

    Properties

    buffer: Buffer
    log: Logger
    position: number

    Methods

    • Write an array column with its values into the buffer using v2 format.

      Parameters

      • name: string

        Column name

      • value: unknown[]

        Array values to write (currently supports double arrays). A null or undefined value omits the column entirely, storing NULL.

      Returns SenderBuffer

      Returns with a reference to this buffer.

      @@ -35,7 +35,7 @@
    • or the shape of the array is irregular: the length of sub-arrays are different
    • or the array is not homogeneous: its elements are not all the same type
    -
    • Closes the row after writing the designated timestamp into the buffer.

      Precision rules:

      • Protocol v2 and higher: @@ -57,19 +57,19 @@

      If unit is 'ns' but timestamp is not a BigInt.

      If unit is not one of 'ns', 'us', or 'ms'. This validation leaves the open row unchanged so the call can be retried.

      -
    • Closes the row without writing designated timestamp into the buffer.
      Designated timestamp will be populated by the server on this record.

      -

      Returns void

    • Writes a boolean column with its value into the buffer.
      Use it to insert into BOOLEAN columns.

      Parameters

      • name: string

        Column name.

      • value: boolean

        Column value, accepts only boolean values. A null or undefined value omits the column entirely (stored as NULL).

      Returns SenderBuffer

      Returns with a reference to this buffer.

      -
    • Checks if the buffer has sufficient capacity for additional data and resizes if needed.

      +
    • Checks if the buffer has sufficient capacity for additional data and resizes if needed.

      Parameters

      • data: string[]

        Array of strings to calculate the required capacity for

      • base: number = 0

        Base number of bytes to add to the calculation

        -

      Returns void

    • Returns the current position of the buffer.
      +

    Returns void

    • Returns the current position of the buffer.
      New data will be written into the buffer starting from this position.

      -

      Returns number

    • Writes a decimal value into the buffer using its binary format.

      +

      Returns number

    • Writes a decimal value into the buffer using its binary format.

      Use it to insert into DECIMAL database columns.

      Decimals are not supported by protocol v1/v2, so this base implementation rejects the call even when the value is null or undefined. Protocol v3 @@ -82,7 +82,7 @@

      If scale is not between 0 and 76. Scale validation runs even when unscaled is null or undefined.

      Indicating decimals are not supported in protocol v1/v2.

      -
    • Writes a decimal value into the buffer using its text format.

      Use it to insert into DECIMAL database columns.

      Decimals are not supported by protocol v1/v2, so this base implementation rejects the call even when the value is null or undefined. Protocol v3 @@ -92,34 +92,34 @@ write.

    Returns SenderBuffer

    Returns with a reference to this buffer.

    Indicating decimals are not supported in protocol v1/v2.

    -
    • Writes a 64-bit floating point value into the buffer using v2 serialization (binary format).
      Use it to insert into DOUBLE or FLOAT database columns.

      Parameters

      • name: string

        Column name.

      • value: number

        Column value, accepts only number values. A null or undefined value omits the column entirely (stored as NULL).

      Returns SenderBuffer

      Returns with a reference to this buffer.

      -
    • Writes a 64-bit signed integer into the buffer.
      Use it to insert into LONG, INT, SHORT and BYTE columns.

      Parameters

      • name: string

        Column name.

      • value: number

        Column value, accepts only number values. A null or undefined value omits the column entirely (stored as NULL).

      Returns SenderBuffer

      Returns with a reference to this buffer.

      Error if the value is not an integer

      -
    • Writes a string column with its value into the buffer.
      Use it to insert into VARCHAR and STRING columns.

      Parameters

      • name: string

        Column name.

      • value: string

        Column value, accepts only string values. A null or undefined value omits the column entirely (stored as NULL).

      Returns SenderBuffer

      Returns with a reference to this buffer.

      -
    • Writes a symbol name and value into the buffer.
      Use it to insert into SYMBOL columns.

      Parameters

      • name: string

        Symbol name.

      • value: unknown

        Symbol value, toString() is called to extract the actual symbol value from the parameter. A null or undefined value omits the symbol entirely (stored as NULL).

      Returns SenderBuffer

      Returns with a reference to this buffer.

      -
    • Writes a timestamp column and its value into the buffer.

      Use this method to insert data into TIMESTAMP or TIMESTAMP_NS columns.

      Precision rules:

        @@ -143,10 +143,10 @@ even when value is null or undefined).

      If value is not an integer or BigInt.

      If unit is 'ns' but value is not a BigInt.

      -
    • Parameters

      • pos: number = ...

      Returns Buffer

      Returns a cropped buffer ready to send to the server, or null if there is nothing to send.
      +

    • Parameters

      • pos: number = ...

      Returns Buffer

      Returns a cropped buffer ready to send to the server, or null if there is nothing to send.
      The returned buffer is a copy of this buffer. It also compacts the buffer.

      -
    • Parameters

      • pos: number = ...

      Returns Buffer

      Returns a cropped buffer, or null if there is nothing to send.
      +

    • Parameters

      • pos: number = ...

      Returns Buffer

      Returns a cropped buffer, or null if there is nothing to send.
      The returned buffer is backed by this buffer instance, meaning the view can change as the buffer is mutated. Used only in tests to assert the buffer's content.

      -
    +
    diff --git a/docs/classes/_questdb_nodejs-client.SenderBufferV3.html b/docs/classes/_questdb_nodejs-client.SenderBufferV3.html index 71419d6..10dac1b 100644 --- a/docs/classes/_questdb_nodejs-client.SenderBufferV3.html +++ b/docs/classes/_questdb_nodejs-client.SenderBufferV3.html @@ -1,6 +1,6 @@ SenderBufferV3 | QuestDB JavaScript Client - v4.2.0

    Buffer implementation for protocol version 3.

    Provides support for decimals.

    -

    Hierarchy (View Summary)

    Index

    Constructors

    Hierarchy (View Summary)

    Index

    Constructors

    Properties

    buffer log position @@ -25,7 +25,7 @@

    Constructors

    Properties

    buffer: Buffer
    log: Logger
    position: number

    Methods

    • Write an array column with its values into the buffer using v2 format.

      +

    Returns SenderBufferV3

    Properties

    buffer: Buffer
    log: Logger
    position: number

    Methods

    • Write an array column with its values into the buffer using v2 format.

      Parameters

      • name: string

        Column name

      • value: unknown[]

        Array values to write (currently supports double arrays). A null or undefined value omits the column entirely, storing NULL.

      Returns SenderBuffer

      Returns with a reference to this buffer.

      @@ -35,7 +35,7 @@
    • or the shape of the array is irregular: the length of sub-arrays are different
    • or the array is not homogeneous: its elements are not all the same type
    -
    • Closes the row after writing the designated timestamp into the buffer.

      Precision rules:

      • Protocol v2 and higher: @@ -57,19 +57,19 @@

      If unit is 'ns' but timestamp is not a BigInt.

      If unit is not one of 'ns', 'us', or 'ms'. This validation leaves the open row unchanged so the call can be retried.

      -
    • Checks if the buffer has sufficient capacity for additional data and resizes if needed.

      Parameters

      • data: string[]

        Array of strings to calculate the required capacity for

      • base: number = 0

        Base number of bytes to add to the calculation

        -

      Returns void

    • Returns the current position of the buffer.
      +

    Returns void

    • Writes a decimal value into the buffer using its binary format.

      Use it to insert into DECIMAL database columns.

      Parameters

      • name: string

        Column name.

      • unscaled: bigint | Int8Array<ArrayBufferLike>

        The unscaled integer portion of the decimal value.

        @@ -88,7 +88,7 @@
      • scale is not between 0 and 76.
      • unscaled contains invalid bytes.
      -
    • Writes a 64-bit floating point value into the buffer using v2 serialization (binary format).
      Use it to insert into DOUBLE or FLOAT database columns.

      Parameters

      • name: string

        Column name.

      • value: number

        Column value, accepts only number values. A null or undefined value omits the column entirely (stored as NULL).

      Returns SenderBuffer

      Returns with a reference to this buffer.

      -
    • Writes a 64-bit signed integer into the buffer.
      Use it to insert into LONG, INT, SHORT and BYTE columns.

      Parameters

      • name: string

        Column name.

      • value: number

        Column value, accepts only number values. A null or undefined value omits the column entirely (stored as NULL).

      Returns SenderBuffer

      Returns with a reference to this buffer.

      Error if the value is not an integer

      -
    • Writes a string column with its value into the buffer.
      Use it to insert into VARCHAR and STRING columns.

      Parameters

      • name: string

        Column name.

      • value: string

        Column value, accepts only string values. A null or undefined value omits the column entirely (stored as NULL).

      Returns SenderBuffer

      Returns with a reference to this buffer.

      -
    • Writes a symbol name and value into the buffer.
      Use it to insert into SYMBOL columns.

      Parameters

      • name: string

        Symbol name.

      • value: unknown

        Symbol value, toString() is called to extract the actual symbol value from the parameter. A null or undefined value omits the symbol entirely (stored as NULL).

      Returns SenderBuffer

      Returns with a reference to this buffer.

      -
    • Writes a timestamp column and its value into the buffer.

      Use this method to insert data into TIMESTAMP or TIMESTAMP_NS columns.

      Precision rules:

        @@ -153,10 +153,10 @@ even when value is null or undefined).

      If value is not an integer or BigInt.

      If unit is 'ns' but value is not a BigInt.

      -
    • Parameters

      • pos: number = ...

      Returns Buffer

      Returns a cropped buffer, or null if there is nothing to send.
      The returned buffer is backed by this buffer instance, meaning the view can change as the buffer is mutated. Used only in tests to assert the buffer's content.

      -
    +
    diff --git a/docs/classes/_questdb_nodejs-client.SenderOptions.html b/docs/classes/_questdb_nodejs-client.SenderOptions.html index 82ce714..0065ba3 100644 --- a/docs/classes/_questdb_nodejs-client.SenderOptions.html +++ b/docs/classes/_questdb_nodejs-client.SenderOptions.html @@ -19,9 +19,11 @@
  • addr: string - Hostname and port, separated by colon. This key is mandatory, but the port part is optional.
    If no port is specified, a default will be used.
    -When the protocol is HTTP/HTTPS, the port defaults to 9000. When the protocol is TCP/TCPS, the port defaults to 9009.
    +When the protocol is HTTP/HTTPS, the port defaults to 9000. When the protocol is TCP/TCPS, the port defaults to 9009. +When the protocol is UDP, the port defaults to 9007.
    +WS/WSS resolve their address through the QWP configuration schema instead, documented in QWP.md.

    -Examples: http::addr=localhost:9000, https::addr=localhost:9000, http::addr=localhost, tcp::addr=localhost:9009 +Examples: http::addr=localhost:9000, https::addr=localhost:9000, http::addr=localhost, tcp::addr=localhost:9009, udp::addr=localhost:9007

  • @@ -109,14 +111,18 @@
    UDP specific options
      -
    • max_datagram_size: integer - Maximum encoded datagram size in bytes, defaults to 1400.
      +
    • max_datagram_size: integer - Maximum encoded datagram size in bytes, from 1 to 65507, +defaults to 1400.
      A row that cannot fit a single datagram is rejected before transmission. It is also the default for -auto_flush_bytes. Supported by the udp transport only; http, tcp and ws/wss reject it. +auto_flush_bytes. Supported by the udp transport only; http, tcp and ws/wss reject it.
      +65507 is the IPv4 maximum, but many hosts refuse well below it, so keep this at or under the path +MTU unless the receiver is known to accept more. A datagram the operating system refuses is +discarded before transmission and does not advance the published or acknowledged sequence.
    • multicast_ttl: integer - Multicast time-to-live for outgoing datagrams, from 0 to 255, defaults to 0.
      Supported by the udp transport only; http, tcp and ws/wss reject it.
    • -
    Index

    Constructors

    Index

    Constructors

    Properties

    addr? agent? auth? @@ -162,7 +168,7 @@
  • 'agent' is a custom http/https agent used by the Sender when http/https transport is used. Depends on which transport implementation and protocol used, one of the followings expected: undici.Agent, http.Agent or https.Agent.
  • -

    Returns SenderOptions

    Properties

    addr?: string
    agent?: Agent | Agent | Agent
    auth?: { keyId?: string; password?: string; token?: string; username?: string }
    auto_flush?: boolean
    auto_flush_bytes?: number
    auto_flush_interval?: number
    auto_flush_rows?: number
    host?: string
    init_buf_size?: number
    jwk?: Record<string, string>
    log?: Logger
    max_buf_size?: number
    max_datagram_size?: number
    max_name_len?: number
    multicast_ttl?: number
    password?: string
    port?: number
    protocol: string
    protocol_version?: string
    request_min_throughput?: number
    request_timeout?: number
    retry_timeout?: number
    stdlib_http?: boolean
    tls_ca?: PathOrFileDescriptor
    tls_roots?: never
    tls_roots_password?: never
    tls_verify?: boolean
    token?: string
    token_x?: string
    token_y?: string
    username?: string

    Methods

    • Creates a Sender options object by parsing the provided configuration string.

      +

    Returns SenderOptions

    Properties

    addr?: string
    agent?: Agent | Agent | Agent
    auth?: { keyId?: string; password?: string; token?: string; username?: string }
    auto_flush?: boolean
    auto_flush_bytes?: number
    auto_flush_interval?: number
    auto_flush_rows?: number
    host?: string
    init_buf_size?: number
    jwk?: Record<string, string>
    log?: Logger
    max_buf_size?: number
    max_datagram_size?: number
    max_name_len?: number
    multicast_ttl?: number
    password?: string
    port?: number
    protocol: string
    protocol_version?: string
    request_min_throughput?: number
    request_timeout?: number
    retry_timeout?: number
    stdlib_http?: boolean
    tls_ca?: PathOrFileDescriptor
    tls_roots?: never
    tls_roots_password?: never
    tls_verify?: boolean
    token?: string
    token_x?: string
    token_y?: string
    username?: string

    Methods

    • Creates a Sender options object by parsing the provided configuration string.

      Parameters

      • configurationString: string

        Configuration string.

      • OptionalextraOptions: ExtraOptions

        Optional extra configuration.

          @@ -172,7 +178,7 @@ Depends on which transport implementation and protocol used, one of the followings expected: undici.Agent, http.Agent or https.Agent.

      Returns Promise<SenderOptions>

      A Sender configuration object initialized from the provided configuration string.

      -
    • Creates a Sender options object by parsing the configuration string set in the QDB_CLIENT_CONF environment variable.

      Parameters

      • OptionalextraOptions: ExtraOptions

        Optional extra configuration.

        • 'log' is a logging function used by the Sender. @@ -181,10 +187,10 @@ Depends on which transport implementation and protocol used, one of the followings expected: undici.Agent, http.Agent or https.Agent.

      Returns Promise<SenderOptions>

      A Sender configuration object initialized from the QDB_CLIENT_CONF environment variable.

      -
    • Resolves the protocol version, if it is set to 'auto'.
      If TCP transport is used, the protocol version will default to 1. In case of HTTP transport the /settings endpoint of the database is used to find the protocol versions supported by the server, and the highest will be selected. When calling the /settings endpoint the timeout and TLS options are used from the options object.

      Parameters

      • options: SenderOptions

        SenderOptions instance needs resolving protocol version

        -

      Returns Promise<SenderOptions>

    +

    Returns Promise<SenderOptions>

    diff --git a/docs/classes/_questdb_nodejs-client.TcpTransport.html b/docs/classes/_questdb_nodejs-client.TcpTransport.html index a8c9079..d35ec2c 100644 --- a/docs/classes/_questdb_nodejs-client.TcpTransport.html +++ b/docs/classes/_questdb_nodejs-client.TcpTransport.html @@ -1,6 +1,6 @@ TcpTransport | QuestDB JavaScript Client - v4.2.0

    TCP transport implementation.
    Supports both plain TCP or secure TLS-encrypted connections with configurable JWK token authentication.

    -

    Implements

    Index

    Constructors

    Implements

    Index

    Constructors

    Methods

    Constructors

    Methods

    Methods

    • Sends data over the established TCP connection.

      Parameters

      • data: Buffer

        Buffer containing the data to send

      Returns Promise<boolean>

      Promise resolving to true if data was sent successfully

      Error if the data could not be written to the socket

      -
    +
    diff --git a/docs/classes/_questdb_nodejs-client.UndiciTransport.html b/docs/classes/_questdb_nodejs-client.UndiciTransport.html index 5c79cd2..a1cd571 100644 --- a/docs/classes/_questdb_nodejs-client.UndiciTransport.html +++ b/docs/classes/_questdb_nodejs-client.UndiciTransport.html @@ -1,7 +1,7 @@ UndiciTransport | QuestDB JavaScript Client - v4.2.0

    HTTP transport implementation using the Undici library.
    Provides high-performance HTTP requests with connection pooling and retry logic.
    Supports both HTTP and HTTPS protocols with configurable authentication.

    -

    Hierarchy

    • HttpTransportBase
      • UndiciTransport
    Index

    Constructors

    Hierarchy

    • HttpTransportBase
      • UndiciTransport
    Index

    Constructors

    Properties

    host log password @@ -21,14 +21,14 @@

    Constructors

    Properties

    host: string
    log: Logger
    password: string
    port: number
    requestMinThroughput: number
    requestTimeout: number
    retryTimeout: number
    secure: boolean
    tlsCA: Buffer
    tlsVerify: boolean
    token: string
    username: string

    Methods

    Properties

    host: string
    log: Logger
    password: string
    port: number
    requestMinThroughput: number
    requestTimeout: number
    retryTimeout: number
    secure: boolean
    tlsCA: Buffer
    tlsVerify: boolean
    token: string
    username: string

    Methods

    • HTTP transport does not require explicit connection establishment.

      Returns Promise<boolean>

      Error indicating connect is not required for HTTP transport

      -
    • Gets the default auto-flush row count for HTTP transport.

      Returns number

      Default number of rows that trigger auto-flush

      -
    • Sends data to QuestDB using HTTP POST.

      Parameters

      • data: Buffer

        Buffer containing the data to send

      Returns Promise<boolean>

      Promise resolving to true if data was sent successfully

      Error if request fails after all retries or times out

      -
    +
    diff --git a/docs/functions/_questdb_browser-client.addQwpDurableAckWebSocketProtocol.html b/docs/functions/_questdb_browser-client.addQwpDurableAckWebSocketProtocol.html index e548d37..d323372 100644 --- a/docs/functions/_questdb_browser-client.addQwpDurableAckWebSocketProtocol.html +++ b/docs/functions/_questdb_browser-client.addQwpDurableAckWebSocketProtocol.html @@ -1,2 +1,2 @@ addQwpDurableAckWebSocketProtocol | QuestDB JavaScript Client - v4.2.0

    Function addQwpDurableAckWebSocketProtocol

    • Adds the durable-ACK capability token without mutating user options.

      -

      Parameters

      • protocols: undefined | string | readonly string[]

      Returns string | string[]

    +

    Parameters

    • protocols: undefined | string | readonly string[]

    Returns string | string[]

    diff --git a/docs/functions/_questdb_browser-client.binary.html b/docs/functions/_questdb_browser-client.binary.html index 19e428e..1550192 100644 --- a/docs/functions/_questdb_browser-client.binary.html +++ b/docs/functions/_questdb_browser-client.binary.html @@ -1,2 +1,2 @@ binary | QuestDB JavaScript Client - v4.2.0
    +

    Returns QwpWriterColumn<Uint8Array<ArrayBufferLike>>

    diff --git a/docs/functions/_questdb_browser-client.bool.html b/docs/functions/_questdb_browser-client.bool.html index d55c52a..d42ce25 100644 --- a/docs/functions/_questdb_browser-client.bool.html +++ b/docs/functions/_questdb_browser-client.bool.html @@ -1,2 +1,2 @@ bool | QuestDB JavaScript Client - v4.2.0
    +

    Returns QwpWriterColumn<boolean>

    diff --git a/docs/functions/_questdb_browser-client.bootstrapQwpBrowserSession.html b/docs/functions/_questdb_browser-client.bootstrapQwpBrowserSession.html index c9fa902..2bd5d8d 100644 --- a/docs/functions/_questdb_browser-client.bootstrapQwpBrowserSession.html +++ b/docs/functions/_questdb_browser-client.bootstrapQwpBrowserSession.html @@ -2,4 +2,4 @@ browser needs before opening QWP WebSockets. REST and OIDC tokens both use Bearer authentication. When serviceAccount is present the same request also creates Enterprise's qdbServiceAccount impersonation cookie.

    -

    Returns Promise<QwpBrowserSessionBootstrapResult>

    +

    Returns Promise<QwpBrowserSessionBootstrapResult>

    diff --git a/docs/functions/_questdb_browser-client.byte.html b/docs/functions/_questdb_browser-client.byte.html index f5abd09..c979b12 100644 --- a/docs/functions/_questdb_browser-client.byte.html +++ b/docs/functions/_questdb_browser-client.byte.html @@ -1,2 +1,2 @@ byte | QuestDB JavaScript Client - v4.2.0
    +

    Returns QwpWriterColumn<number>

    diff --git a/docs/functions/_questdb_browser-client.char.html b/docs/functions/_questdb_browser-client.char.html index 1887d85..c07bfc1 100644 --- a/docs/functions/_questdb_browser-client.char.html +++ b/docs/functions/_questdb_browser-client.char.html @@ -1,2 +1,2 @@ char | QuestDB JavaScript Client - v4.2.0
    +

    Returns QwpWriterColumn<string>

    diff --git a/docs/functions/_questdb_browser-client.concatBytes.html b/docs/functions/_questdb_browser-client.concatBytes.html index a97bc90..6cdf9a0 100644 --- a/docs/functions/_questdb_browser-client.concatBytes.html +++ b/docs/functions/_questdb_browser-client.concatBytes.html @@ -1 +1 @@ -concatBytes | QuestDB JavaScript Client - v4.2.0
    +concatBytes | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_browser-client.connectQwpBrowserClient.html b/docs/functions/_questdb_browser-client.connectQwpBrowserClient.html index 37def69..4fa8d82 100644 --- a/docs/functions/_questdb_browser-client.connectQwpBrowserClient.html +++ b/docs/functions/_questdb_browser-client.connectQwpBrowserClient.html @@ -1,2 +1,2 @@ connectQwpBrowserClient | QuestDB JavaScript Client - v4.2.0

    Function connectQwpBrowserClient

    +

    Parameters

    Returns Promise<QwpClient>

    diff --git a/docs/functions/_questdb_browser-client.connectQwpBrowserEgress.html b/docs/functions/_questdb_browser-client.connectQwpBrowserEgress.html index ba0a301..88fe2b1 100644 --- a/docs/functions/_questdb_browser-client.connectQwpBrowserEgress.html +++ b/docs/functions/_questdb_browser-client.connectQwpBrowserEgress.html @@ -1,3 +1,3 @@ connectQwpBrowserEgress | QuestDB JavaScript Client - v4.2.0

    Function connectQwpBrowserEgress

    +

    Returns Promise<QwpEgressSession>

    diff --git a/docs/functions/_questdb_browser-client.connectQwpBrowserIngress.html b/docs/functions/_questdb_browser-client.connectQwpBrowserIngress.html index faef3c0..a58973b 100644 --- a/docs/functions/_questdb_browser-client.connectQwpBrowserIngress.html +++ b/docs/functions/_questdb_browser-client.connectQwpBrowserIngress.html @@ -1,3 +1,3 @@ connectQwpBrowserIngress | QuestDB JavaScript Client - v4.2.0

    Function connectQwpBrowserIngress

    +

    Returns Promise<QwpIngressSession>

    diff --git a/docs/functions/_questdb_browser-client.connectQwpBrowserSender.html b/docs/functions/_questdb_browser-client.connectQwpBrowserSender.html index cfbf33c..cb340b1 100644 --- a/docs/functions/_questdb_browser-client.connectQwpBrowserSender.html +++ b/docs/functions/_questdb_browser-client.connectQwpBrowserSender.html @@ -1,2 +1,2 @@ connectQwpBrowserSender | QuestDB JavaScript Client - v4.2.0

    Function connectQwpBrowserSender

    +

    Parameters

    Returns Promise<QwpSender>

    diff --git a/docs/functions/_questdb_browser-client.connectQwpBrowserWebSocket.html b/docs/functions/_questdb_browser-client.connectQwpBrowserWebSocket.html index 275baf9..afcb768 100644 --- a/docs/functions/_questdb_browser-client.connectQwpBrowserWebSocket.html +++ b/docs/functions/_questdb_browser-client.connectQwpBrowserWebSocket.html @@ -4,4 +4,4 @@ app from the QuestDB origin or route QWP through a same-origin reverse proxy. When authentication is enabled, pass sessionBootstrap or call bootstrapQwpBrowserSession first so the browser can attach qdb_session.

    -

    Parameters

    Returns Promise<QwpBinaryConnection>

    +

    Parameters

    Returns Promise<QwpBinaryConnection>

    diff --git a/docs/functions/_questdb_browser-client.createQwpBrowserClient.html b/docs/functions/_questdb_browser-client.createQwpBrowserClient.html index 950e83d..2b481a2 100644 --- a/docs/functions/_questdb_browser-client.createQwpBrowserClient.html +++ b/docs/functions/_questdb_browser-client.createQwpBrowserClient.html @@ -1,2 +1,2 @@ createQwpBrowserClient | QuestDB JavaScript Client - v4.2.0

    Function createQwpBrowserClient

    +

    Parameters

    Returns QwpClient

    diff --git a/docs/functions/_questdb_browser-client.createQwpBrowserConnectionFactory.html b/docs/functions/_questdb_browser-client.createQwpBrowserConnectionFactory.html index 7bee4c6..db57023 100644 --- a/docs/functions/_questdb_browser-client.createQwpBrowserConnectionFactory.html +++ b/docs/functions/_questdb_browser-client.createQwpBrowserConnectionFactory.html @@ -1,2 +1,2 @@ createQwpBrowserConnectionFactory | QuestDB JavaScript Client - v4.2.0

    Function createQwpBrowserConnectionFactory

    +

    Parameters

    Returns QwpConnectionFactory

    diff --git a/docs/functions/_questdb_browser-client.createQwpBrowserSender.html b/docs/functions/_questdb_browser-client.createQwpBrowserSender.html index 85cb9bb..3bd2aa9 100644 --- a/docs/functions/_questdb_browser-client.createQwpBrowserSender.html +++ b/docs/functions/_questdb_browser-client.createQwpBrowserSender.html @@ -1,3 +1,3 @@ createQwpBrowserSender | QuestDB JavaScript Client - v4.2.0

    Function createQwpBrowserSender

    +

    Parameters

    Returns QwpSender

    diff --git a/docs/functions/_questdb_browser-client.createQwpDataLossSenderError.html b/docs/functions/_questdb_browser-client.createQwpDataLossSenderError.html index 3d4cb72..0d589a9 100644 --- a/docs/functions/_questdb_browser-client.createQwpDataLossSenderError.html +++ b/docs/functions/_questdb_browser-client.createQwpDataLossSenderError.html @@ -1,2 +1,2 @@ createQwpDataLossSenderError | QuestDB JavaScript Client - v4.2.0

    Function createQwpDataLossSenderError

    +

    Returns QwpSenderError

    diff --git a/docs/functions/_questdb_browser-client.createQwpProtocolViolationSenderError.html b/docs/functions/_questdb_browser-client.createQwpProtocolViolationSenderError.html index 0946f58..cd71aa4 100644 --- a/docs/functions/_questdb_browser-client.createQwpProtocolViolationSenderError.html +++ b/docs/functions/_questdb_browser-client.createQwpProtocolViolationSenderError.html @@ -1 +1 @@ -createQwpProtocolViolationSenderError | QuestDB JavaScript Client - v4.2.0

    Function createQwpProtocolViolationSenderError

    +createQwpProtocolViolationSenderError | QuestDB JavaScript Client - v4.2.0

    Function createQwpProtocolViolationSenderError

    diff --git a/docs/functions/_questdb_browser-client.createQwpSenderError.html b/docs/functions/_questdb_browser-client.createQwpSenderError.html index ddd8ad2..a5c625e 100644 --- a/docs/functions/_questdb_browser-client.createQwpSenderError.html +++ b/docs/functions/_questdb_browser-client.createQwpSenderError.html @@ -1 +1 @@ -createQwpSenderError | QuestDB JavaScript Client - v4.2.0

    Function createQwpSenderError

    +createQwpSenderError | QuestDB JavaScript Client - v4.2.0

    Function createQwpSenderError

    diff --git a/docs/functions/_questdb_browser-client.date.html b/docs/functions/_questdb_browser-client.date.html index 3e30fad..00a3124 100644 --- a/docs/functions/_questdb_browser-client.date.html +++ b/docs/functions/_questdb_browser-client.date.html @@ -1,4 +1,4 @@ date | QuestDB JavaScript Client - v4.2.0
    +

    Returns QwpWriterColumn<number | bigint>

    diff --git a/docs/functions/_questdb_browser-client.decimal128.html b/docs/functions/_questdb_browser-client.decimal128.html index 78ca685..2af8bb7 100644 --- a/docs/functions/_questdb_browser-client.decimal128.html +++ b/docs/functions/_questdb_browser-client.decimal128.html @@ -1,2 +1,2 @@ decimal128 | QuestDB JavaScript Client - v4.2.0
    +

    Parameters

    • scale: number

    Returns QwpWriterColumn<QwpDecimalInput>

    diff --git a/docs/functions/_questdb_browser-client.decimal256.html b/docs/functions/_questdb_browser-client.decimal256.html index adfff70..840453e 100644 --- a/docs/functions/_questdb_browser-client.decimal256.html +++ b/docs/functions/_questdb_browser-client.decimal256.html @@ -1,2 +1,2 @@ decimal256 | QuestDB JavaScript Client - v4.2.0
    +

    Parameters

    • scale: number

    Returns QwpWriterColumn<QwpDecimalInput>

    diff --git a/docs/functions/_questdb_browser-client.decimal64.html b/docs/functions/_questdb_browser-client.decimal64.html index 04808b2..e482127 100644 --- a/docs/functions/_questdb_browser-client.decimal64.html +++ b/docs/functions/_questdb_browser-client.decimal64.html @@ -1,2 +1,2 @@ decimal64 | QuestDB JavaScript Client - v4.2.0
    +

    Parameters

    • scale: number

    Returns QwpWriterColumn<QwpDecimalInput>

    diff --git a/docs/functions/_questdb_browser-client.decodeQwpContentEncoding.html b/docs/functions/_questdb_browser-client.decodeQwpContentEncoding.html index 0d6b936..f63b180 100644 --- a/docs/functions/_questdb_browser-client.decodeQwpContentEncoding.html +++ b/docs/functions/_questdb_browser-client.decodeQwpContentEncoding.html @@ -1,4 +1,4 @@ decodeQwpContentEncoding | QuestDB JavaScript Client - v4.2.0

    Function decodeQwpContentEncoding

    +

    Parameters

    • value: undefined | string

    Returns QwpNegotiatedEgressCompression

    diff --git a/docs/functions/_questdb_browser-client.decodeQwpEgressMessage.html b/docs/functions/_questdb_browser-client.decodeQwpEgressMessage.html index b3d11fd..d8ec597 100644 --- a/docs/functions/_questdb_browser-client.decodeQwpEgressMessage.html +++ b/docs/functions/_questdb_browser-client.decodeQwpEgressMessage.html @@ -1,2 +1,2 @@ decodeQwpEgressMessage | QuestDB JavaScript Client - v4.2.0

    Function decodeQwpEgressMessage

    +

    Parameters

    • bytes: Uint8Array

    Returns QwpEgressMessage

    diff --git a/docs/functions/_questdb_browser-client.decodeQwpFrame.html b/docs/functions/_questdb_browser-client.decodeQwpFrame.html index 3b1ff1b..012fa9c 100644 --- a/docs/functions/_questdb_browser-client.decodeQwpFrame.html +++ b/docs/functions/_questdb_browser-client.decodeQwpFrame.html @@ -1 +1 @@ -decodeQwpFrame | QuestDB JavaScript Client - v4.2.0
    +decodeQwpFrame | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_browser-client.decodeQwpIngressResponse.html b/docs/functions/_questdb_browser-client.decodeQwpIngressResponse.html index b8c5426..fe3ef77 100644 --- a/docs/functions/_questdb_browser-client.decodeQwpIngressResponse.html +++ b/docs/functions/_questdb_browser-client.decodeQwpIngressResponse.html @@ -1,2 +1,2 @@ decodeQwpIngressResponse | QuestDB JavaScript Client - v4.2.0

    Function decodeQwpIngressResponse

    +

    Parameters

    • payload: Uint8Array

    Returns QwpIngressResponse

    diff --git a/docs/functions/_questdb_browser-client.decodeQwpIngressServerInfo.html b/docs/functions/_questdb_browser-client.decodeQwpIngressServerInfo.html index edc0ac2..17fbb7e 100644 --- a/docs/functions/_questdb_browser-client.decodeQwpIngressServerInfo.html +++ b/docs/functions/_questdb_browser-client.decodeQwpIngressServerInfo.html @@ -1,2 +1,2 @@ decodeQwpIngressServerInfo | QuestDB JavaScript Client - v4.2.0

    Function decodeQwpIngressServerInfo

    • Decodes the browser-requested ingress SERVER_INFO payload when present.

      -

      Parameters

      • payload: Uint8Array

      Returns undefined | number

    +

    Parameters

    • payload: Uint8Array

    Returns undefined | number

    diff --git a/docs/functions/_questdb_browser-client.decodeQwpIngressSymbolDictionaryDelta.html b/docs/functions/_questdb_browser-client.decodeQwpIngressSymbolDictionaryDelta.html index b226018..195e6dd 100644 --- a/docs/functions/_questdb_browser-client.decodeQwpIngressSymbolDictionaryDelta.html +++ b/docs/functions/_questdb_browser-client.decodeQwpIngressSymbolDictionaryDelta.html @@ -1,2 +1,2 @@ decodeQwpIngressSymbolDictionaryDelta | QuestDB JavaScript Client - v4.2.0

    Function decodeQwpIngressSymbolDictionaryDelta

    +

    Parameters

    • bytes: Uint8Array

    Returns undefined | QwpIngressSymbolDictionaryDelta

    diff --git a/docs/functions/_questdb_browser-client.decodeQwpVarint.html b/docs/functions/_questdb_browser-client.decodeQwpVarint.html index 7a3e306..fd2c287 100644 --- a/docs/functions/_questdb_browser-client.decodeQwpVarint.html +++ b/docs/functions/_questdb_browser-client.decodeQwpVarint.html @@ -1 +1 @@ -decodeQwpVarint | QuestDB JavaScript Client - v4.2.0
    • Parameters

      • bytes: Uint8Array
      • offset: number = 0

      Returns { offset: number; value: bigint }

    +decodeQwpVarint | QuestDB JavaScript Client - v4.2.0
    • Parameters

      • bytes: Uint8Array
      • offset: number = 0

      Returns { offset: number; value: bigint }

    diff --git a/docs/functions/_questdb_browser-client.decodeUtf8.html b/docs/functions/_questdb_browser-client.decodeUtf8.html index 7bb9086..6077f7a 100644 --- a/docs/functions/_questdb_browser-client.decodeUtf8.html +++ b/docs/functions/_questdb_browser-client.decodeUtf8.html @@ -1 +1 @@ -decodeUtf8 | QuestDB JavaScript Client - v4.2.0
    +decodeUtf8 | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_browser-client.decompressQwpZstdFrame.html b/docs/functions/_questdb_browser-client.decompressQwpZstdFrame.html index 78d7d0f..19bdd2a 100644 --- a/docs/functions/_questdb_browser-client.decompressQwpZstdFrame.html +++ b/docs/functions/_questdb_browser-client.decompressQwpZstdFrame.html @@ -1,2 +1,2 @@ decompressQwpZstdFrame | QuestDB JavaScript Client - v4.2.0

    Function decompressQwpZstdFrame

    +

    Parameters

    • frame: Uint8Array

    Returns Uint8Array

    diff --git a/docs/functions/_questdb_browser-client.defaultQwpSenderErrorHandler.html b/docs/functions/_questdb_browser-client.defaultQwpSenderErrorHandler.html index f1ad22d..d9e481d 100644 --- a/docs/functions/_questdb_browser-client.defaultQwpSenderErrorHandler.html +++ b/docs/functions/_questdb_browser-client.defaultQwpSenderErrorHandler.html @@ -1,3 +1,3 @@ defaultQwpSenderErrorHandler | QuestDB JavaScript Client - v4.2.0

    Function defaultQwpSenderErrorHandler

    +

    Parameters

    Returns void

    diff --git a/docs/functions/_questdb_browser-client.designatedTimestamp.html b/docs/functions/_questdb_browser-client.designatedTimestamp.html index 1102ff3..0a7f559 100644 --- a/docs/functions/_questdb_browser-client.designatedTimestamp.html +++ b/docs/functions/_questdb_browser-client.designatedTimestamp.html @@ -1,2 +1,2 @@ designatedTimestamp | QuestDB JavaScript Client - v4.2.0

    Function designatedTimestamp

    +

    Type Parameters

    Parameters

    Returns QwpWriterColumn<TimestampInput<Unit>, true>

    diff --git a/docs/functions/_questdb_browser-client.double.html b/docs/functions/_questdb_browser-client.double.html index c9bba22..5e620a5 100644 --- a/docs/functions/_questdb_browser-client.double.html +++ b/docs/functions/_questdb_browser-client.double.html @@ -1,2 +1,2 @@ double | QuestDB JavaScript Client - v4.2.0
    +

    Returns QwpWriterColumn<number>

    diff --git a/docs/functions/_questdb_browser-client.doubleArray.html b/docs/functions/_questdb_browser-client.doubleArray.html index cd65116..78a3af5 100644 --- a/docs/functions/_questdb_browser-client.doubleArray.html +++ b/docs/functions/_questdb_browser-client.doubleArray.html @@ -1,2 +1,2 @@ doubleArray | QuestDB JavaScript Client - v4.2.0
    +

    Returns QwpWriterColumn<QwpDoubleArrayInput>

    diff --git a/docs/functions/_questdb_browser-client.encodeQwpAcceptEncoding.html b/docs/functions/_questdb_browser-client.encodeQwpAcceptEncoding.html index 7833031..4c1dbcd 100644 --- a/docs/functions/_questdb_browser-client.encodeQwpAcceptEncoding.html +++ b/docs/functions/_questdb_browser-client.encodeQwpAcceptEncoding.html @@ -1,2 +1,2 @@ encodeQwpAcceptEncoding | QuestDB JavaScript Client - v4.2.0

    Function encodeQwpAcceptEncoding

    +

    Parameters

    Returns undefined | string

    diff --git a/docs/functions/_questdb_browser-client.encodeQwpBinds.html b/docs/functions/_questdb_browser-client.encodeQwpBinds.html index 8463be5..9e9ed84 100644 --- a/docs/functions/_questdb_browser-client.encodeQwpBinds.html +++ b/docs/functions/_questdb_browser-client.encodeQwpBinds.html @@ -1,2 +1,2 @@ encodeQwpBinds | QuestDB JavaScript Client - v4.2.0
    +

    Parameters

    Returns QwpEncodedBinds

    diff --git a/docs/functions/_questdb_browser-client.encodeQwpCancel.html b/docs/functions/_questdb_browser-client.encodeQwpCancel.html index c51bd15..aee5cd9 100644 --- a/docs/functions/_questdb_browser-client.encodeQwpCancel.html +++ b/docs/functions/_questdb_browser-client.encodeQwpCancel.html @@ -1,2 +1,2 @@ encodeQwpCancel | QuestDB JavaScript Client - v4.2.0
    +

    Parameters

    • request: number | bigint

    Returns Uint8Array

    diff --git a/docs/functions/_questdb_browser-client.encodeQwpCredit.html b/docs/functions/_questdb_browser-client.encodeQwpCredit.html index 429bc9d..e63c94c 100644 --- a/docs/functions/_questdb_browser-client.encodeQwpCredit.html +++ b/docs/functions/_questdb_browser-client.encodeQwpCredit.html @@ -1,2 +1,2 @@ encodeQwpCredit | QuestDB JavaScript Client - v4.2.0
    • Encodes the unframed client-to-server CREDIT payload.

      -

      Parameters

      • request: number | bigint
      • additionalBytes: number | bigint

      Returns Uint8Array

    +

    Parameters

    • request: number | bigint
    • additionalBytes: number | bigint

    Returns Uint8Array

    diff --git a/docs/functions/_questdb_browser-client.encodeQwpDurableAckPollFrame.html b/docs/functions/_questdb_browser-client.encodeQwpDurableAckPollFrame.html index ab410e7..42fe43d 100644 --- a/docs/functions/_questdb_browser-client.encodeQwpDurableAckPollFrame.html +++ b/docs/functions/_questdb_browser-client.encodeQwpDurableAckPollFrame.html @@ -1,2 +1,2 @@ encodeQwpDurableAckPollFrame | QuestDB JavaScript Client - v4.2.0

    Function encodeQwpDurableAckPollFrame

    +

    Returns Uint8Array

    diff --git a/docs/functions/_questdb_browser-client.encodeQwpFrame.html b/docs/functions/_questdb_browser-client.encodeQwpFrame.html index 6e58bd2..71c05da 100644 --- a/docs/functions/_questdb_browser-client.encodeQwpFrame.html +++ b/docs/functions/_questdb_browser-client.encodeQwpFrame.html @@ -1 +1 @@ -encodeQwpFrame | QuestDB JavaScript Client - v4.2.0
    • Parameters

      • payload: Uint8Array
      • flags: number = 0
      • tableCount: number = 0

      Returns Uint8Array

    +encodeQwpFrame | QuestDB JavaScript Client - v4.2.0
    • Parameters

      • payload: Uint8Array
      • flags: number = 0
      • tableCount: number = 0

      Returns Uint8Array

    diff --git a/docs/functions/_questdb_browser-client.encodeQwpGorilla.html b/docs/functions/_questdb_browser-client.encodeQwpGorilla.html index eda4bf7..28baae5 100644 --- a/docs/functions/_questdb_browser-client.encodeQwpGorilla.html +++ b/docs/functions/_questdb_browser-client.encodeQwpGorilla.html @@ -1,2 +1,2 @@ encodeQwpGorilla | QuestDB JavaScript Client - v4.2.0
    +

    Parameters

    • timestamps: readonly bigint[]

    Returns Uint8Array

    diff --git a/docs/functions/_questdb_browser-client.encodeQwpIngressCommitFrame.html b/docs/functions/_questdb_browser-client.encodeQwpIngressCommitFrame.html index 36d134d..f58ace1 100644 --- a/docs/functions/_questdb_browser-client.encodeQwpIngressCommitFrame.html +++ b/docs/functions/_questdb_browser-client.encodeQwpIngressCommitFrame.html @@ -1 +1 @@ -encodeQwpIngressCommitFrame | QuestDB JavaScript Client - v4.2.0

    Function encodeQwpIngressCommitFrame

    +encodeQwpIngressCommitFrame | QuestDB JavaScript Client - v4.2.0

    Function encodeQwpIngressCommitFrame

    diff --git a/docs/functions/_questdb_browser-client.encodeQwpIngressFrame.html b/docs/functions/_questdb_browser-client.encodeQwpIngressFrame.html index efce9ec..02f412f 100644 --- a/docs/functions/_questdb_browser-client.encodeQwpIngressFrame.html +++ b/docs/functions/_questdb_browser-client.encodeQwpIngressFrame.html @@ -1,2 +1,2 @@ encodeQwpIngressFrame | QuestDB JavaScript Client - v4.2.0

    Function encodeQwpIngressFrame

    +

    Parameters

    Returns Uint8Array

    diff --git a/docs/functions/_questdb_browser-client.encodeQwpIngressSymbolDictionaryFrame.html b/docs/functions/_questdb_browser-client.encodeQwpIngressSymbolDictionaryFrame.html index 3ea7954..0305e41 100644 --- a/docs/functions/_questdb_browser-client.encodeQwpIngressSymbolDictionaryFrame.html +++ b/docs/functions/_questdb_browser-client.encodeQwpIngressSymbolDictionaryFrame.html @@ -1,2 +1,2 @@ encodeQwpIngressSymbolDictionaryFrame | QuestDB JavaScript Client - v4.2.0

    Function encodeQwpIngressSymbolDictionaryFrame

    • Encodes a table-less committed dictionary catch-up frame.

      -

      Parameters

      • startId: number
      • entries: readonly string[]

      Returns Uint8Array

    +

    Parameters

    • startId: number
    • entries: readonly string[]

    Returns Uint8Array

    diff --git a/docs/functions/_questdb_browser-client.encodeQwpQueryRequest.html b/docs/functions/_questdb_browser-client.encodeQwpQueryRequest.html index 0badf5c..ef751d3 100644 --- a/docs/functions/_questdb_browser-client.encodeQwpQueryRequest.html +++ b/docs/functions/_questdb_browser-client.encodeQwpQueryRequest.html @@ -1,2 +1,2 @@ encodeQwpQueryRequest | QuestDB JavaScript Client - v4.2.0

    Function encodeQwpQueryRequest

    +

    Parameters

    Returns Uint8Array

    diff --git a/docs/functions/_questdb_browser-client.encodeQwpVarint.html b/docs/functions/_questdb_browser-client.encodeQwpVarint.html index 598e67c..0b53194 100644 --- a/docs/functions/_questdb_browser-client.encodeQwpVarint.html +++ b/docs/functions/_questdb_browser-client.encodeQwpVarint.html @@ -1 +1 @@ -encodeQwpVarint | QuestDB JavaScript Client - v4.2.0
    +encodeQwpVarint | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_browser-client.encodeUtf8.html b/docs/functions/_questdb_browser-client.encodeUtf8.html index c9e0dda..cae202e 100644 --- a/docs/functions/_questdb_browser-client.encodeUtf8.html +++ b/docs/functions/_questdb_browser-client.encodeUtf8.html @@ -1 +1 @@ -encodeUtf8 | QuestDB JavaScript Client - v4.2.0
    +encodeUtf8 | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_browser-client.flattenQwpArray.html b/docs/functions/_questdb_browser-client.flattenQwpArray.html index f8b0448..682566c 100644 --- a/docs/functions/_questdb_browser-client.flattenQwpArray.html +++ b/docs/functions/_questdb_browser-client.flattenQwpArray.html @@ -1 +1 @@ -flattenQwpArray | QuestDB JavaScript Client - v4.2.0
    +flattenQwpArray | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_browser-client.float32.html b/docs/functions/_questdb_browser-client.float32.html index 6d5b57c..59f7366 100644 --- a/docs/functions/_questdb_browser-client.float32.html +++ b/docs/functions/_questdb_browser-client.float32.html @@ -1,2 +1,2 @@ float32 | QuestDB JavaScript Client - v4.2.0
    +

    Returns QwpWriterColumn<number>

    diff --git a/docs/functions/_questdb_browser-client.float64.html b/docs/functions/_questdb_browser-client.float64.html index 2734fc7..00f078e 100644 --- a/docs/functions/_questdb_browser-client.float64.html +++ b/docs/functions/_questdb_browser-client.float64.html @@ -1,2 +1,2 @@ float64 | QuestDB JavaScript Client - v4.2.0
    +

    Returns QwpWriterColumn<number>

    diff --git a/docs/functions/_questdb_browser-client.geohash.html b/docs/functions/_questdb_browser-client.geohash.html index eb50d12..979f3f2 100644 --- a/docs/functions/_questdb_browser-client.geohash.html +++ b/docs/functions/_questdb_browser-client.geohash.html @@ -1,4 +1,4 @@ geohash | QuestDB JavaScript Client - v4.2.0
    +

    Returns QwpWriterColumn<QwpGeohashInput>

    diff --git a/docs/functions/_questdb_browser-client.int32.html b/docs/functions/_questdb_browser-client.int32.html index 1f5cb49..f89bb3e 100644 --- a/docs/functions/_questdb_browser-client.int32.html +++ b/docs/functions/_questdb_browser-client.int32.html @@ -1,4 +1,4 @@ int32 | QuestDB JavaScript Client - v4.2.0
    +

    Returns QwpWriterColumn<number>

    diff --git a/docs/functions/_questdb_browser-client.int64.html b/docs/functions/_questdb_browser-client.int64.html index cd9eb93..ed0c3a5 100644 --- a/docs/functions/_questdb_browser-client.int64.html +++ b/docs/functions/_questdb_browser-client.int64.html @@ -1,4 +1,4 @@ int64 | QuestDB JavaScript Client - v4.2.0
    +

    Returns QwpWriterColumn<bigint>

    diff --git a/docs/functions/_questdb_browser-client.ipv4.html b/docs/functions/_questdb_browser-client.ipv4.html index f32e37d..2ec9633 100644 --- a/docs/functions/_questdb_browser-client.ipv4.html +++ b/docs/functions/_questdb_browser-client.ipv4.html @@ -1,2 +1,2 @@ ipv4 | QuestDB JavaScript Client - v4.2.0
    +

    Returns QwpWriterColumn<QwpIpv4Input>

    diff --git a/docs/functions/_questdb_browser-client.isQwpDurableAckWebSocketProtocol.html b/docs/functions/_questdb_browser-client.isQwpDurableAckWebSocketProtocol.html index b08eb94..42e59d5 100644 --- a/docs/functions/_questdb_browser-client.isQwpDurableAckWebSocketProtocol.html +++ b/docs/functions/_questdb_browser-client.isQwpDurableAckWebSocketProtocol.html @@ -1,2 +1,2 @@ isQwpDurableAckWebSocketProtocol | QuestDB JavaScript Client - v4.2.0

    Function isQwpDurableAckWebSocketProtocol

    +

    Parameters

    • protocol: undefined | string

    Returns boolean

    diff --git a/docs/functions/_questdb_browser-client.long.html b/docs/functions/_questdb_browser-client.long.html index 672d415..834a4e5 100644 --- a/docs/functions/_questdb_browser-client.long.html +++ b/docs/functions/_questdb_browser-client.long.html @@ -1,2 +1,2 @@ long | QuestDB JavaScript Client - v4.2.0
    +

    Returns QwpWriterColumn<bigint>

    diff --git a/docs/functions/_questdb_browser-client.long256.html b/docs/functions/_questdb_browser-client.long256.html index d358c92..d435196 100644 --- a/docs/functions/_questdb_browser-client.long256.html +++ b/docs/functions/_questdb_browser-client.long256.html @@ -1,2 +1,2 @@ long256 | QuestDB JavaScript Client - v4.2.0
    +

    Returns QwpWriterColumn<QwpLong256Input>

    diff --git a/docs/functions/_questdb_browser-client.longArray.html b/docs/functions/_questdb_browser-client.longArray.html index 761b8e6..c228246 100644 --- a/docs/functions/_questdb_browser-client.longArray.html +++ b/docs/functions/_questdb_browser-client.longArray.html @@ -1,4 +1,4 @@ longArray | QuestDB JavaScript Client - v4.2.0
    +

    Returns QwpWriterColumn<QwpLongArrayInput>

    diff --git a/docs/functions/_questdb_browser-client.qwpDefaultSenderErrorPolicy.html b/docs/functions/_questdb_browser-client.qwpDefaultSenderErrorPolicy.html index 3229ca7..2efc062 100644 --- a/docs/functions/_questdb_browser-client.qwpDefaultSenderErrorPolicy.html +++ b/docs/functions/_questdb_browser-client.qwpDefaultSenderErrorPolicy.html @@ -1 +1 @@ -qwpDefaultSenderErrorPolicy | QuestDB JavaScript Client - v4.2.0

    Function qwpDefaultSenderErrorPolicy

    +qwpDefaultSenderErrorPolicy | QuestDB JavaScript Client - v4.2.0

    Function qwpDefaultSenderErrorPolicy

    diff --git a/docs/functions/_questdb_browser-client.qwpGorillaSize.html b/docs/functions/_questdb_browser-client.qwpGorillaSize.html index fc07a38..14bb8ad 100644 --- a/docs/functions/_questdb_browser-client.qwpGorillaSize.html +++ b/docs/functions/_questdb_browser-client.qwpGorillaSize.html @@ -1,2 +1,2 @@ qwpGorillaSize | QuestDB JavaScript Client - v4.2.0
    +

    Parameters

    • timestamps: readonly bigint[]

    Returns number

    diff --git a/docs/functions/_questdb_browser-client.qwpSenderErrorCategory.html b/docs/functions/_questdb_browser-client.qwpSenderErrorCategory.html index 08b692a..e4ab690 100644 --- a/docs/functions/_questdb_browser-client.qwpSenderErrorCategory.html +++ b/docs/functions/_questdb_browser-client.qwpSenderErrorCategory.html @@ -1 +1 @@ -qwpSenderErrorCategory | QuestDB JavaScript Client - v4.2.0

    Function qwpSenderErrorCategory

    +qwpSenderErrorCategory | QuestDB JavaScript Client - v4.2.0

    Function qwpSenderErrorCategory

    diff --git a/docs/functions/_questdb_browser-client.qwpVarintSize.html b/docs/functions/_questdb_browser-client.qwpVarintSize.html index cc0b79f..752c0b3 100644 --- a/docs/functions/_questdb_browser-client.qwpVarintSize.html +++ b/docs/functions/_questdb_browser-client.qwpVarintSize.html @@ -1,2 +1,2 @@ qwpVarintSize | QuestDB JavaScript Client - v4.2.0
    +

    Parameters

    • value: number | bigint

    Returns number

    diff --git a/docs/functions/_questdb_browser-client.readQwpVarint.html b/docs/functions/_questdb_browser-client.readQwpVarint.html index 1bce398..606251a 100644 --- a/docs/functions/_questdb_browser-client.readQwpVarint.html +++ b/docs/functions/_questdb_browser-client.readQwpVarint.html @@ -1,2 +1,2 @@ readQwpVarint | QuestDB JavaScript Client - v4.2.0
    +

    Parameters

    Returns bigint

    diff --git a/docs/functions/_questdb_browser-client.readQwpVarintNumber.html b/docs/functions/_questdb_browser-client.readQwpVarintNumber.html index ed05629..51b339f 100644 --- a/docs/functions/_questdb_browser-client.readQwpVarintNumber.html +++ b/docs/functions/_questdb_browser-client.readQwpVarintNumber.html @@ -1 +1 @@ -readQwpVarintNumber | QuestDB JavaScript Client - v4.2.0

    Function readQwpVarintNumber

    +readQwpVarintNumber | QuestDB JavaScript Client - v4.2.0

    Function readQwpVarintNumber

    diff --git a/docs/functions/_questdb_browser-client.short.html b/docs/functions/_questdb_browser-client.short.html index 1d9f759..64efdb3 100644 --- a/docs/functions/_questdb_browser-client.short.html +++ b/docs/functions/_questdb_browser-client.short.html @@ -1,2 +1,2 @@ short | QuestDB JavaScript Client - v4.2.0
    +

    Returns QwpWriterColumn<number>

    diff --git a/docs/functions/_questdb_browser-client.symbol.html b/docs/functions/_questdb_browser-client.symbol.html index 489c8e9..c480d6f 100644 --- a/docs/functions/_questdb_browser-client.symbol.html +++ b/docs/functions/_questdb_browser-client.symbol.html @@ -1,2 +1,2 @@ symbol | QuestDB JavaScript Client - v4.2.0
    +

    Returns QwpWriterColumn<string>

    diff --git a/docs/functions/_questdb_browser-client.timestamp.html b/docs/functions/_questdb_browser-client.timestamp.html index 1bf0e93..51557b9 100644 --- a/docs/functions/_questdb_browser-client.timestamp.html +++ b/docs/functions/_questdb_browser-client.timestamp.html @@ -1,2 +1,2 @@ timestamp | QuestDB JavaScript Client - v4.2.0
    +

    Type Parameters

    Parameters

    Returns QwpWriterColumn<TimestampInput<Unit>>

    diff --git a/docs/functions/_questdb_browser-client.utf8Length.html b/docs/functions/_questdb_browser-client.utf8Length.html index caf2844..e062b1d 100644 --- a/docs/functions/_questdb_browser-client.utf8Length.html +++ b/docs/functions/_questdb_browser-client.utf8Length.html @@ -1 +1 @@ -utf8Length | QuestDB JavaScript Client - v4.2.0
    +utf8Length | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_browser-client.uuid.html b/docs/functions/_questdb_browser-client.uuid.html index 712fa18..43456f5 100644 --- a/docs/functions/_questdb_browser-client.uuid.html +++ b/docs/functions/_questdb_browser-client.uuid.html @@ -1,2 +1,2 @@ uuid | QuestDB JavaScript Client - v4.2.0
    +

    Returns QwpWriterColumn<QwpUuidInput>

    diff --git a/docs/functions/_questdb_browser-client.varchar.html b/docs/functions/_questdb_browser-client.varchar.html index 29c533e..e25add0 100644 --- a/docs/functions/_questdb_browser-client.varchar.html +++ b/docs/functions/_questdb_browser-client.varchar.html @@ -1,2 +1,2 @@ varchar | QuestDB JavaScript Client - v4.2.0
    +

    Returns QwpWriterColumn<string>

    diff --git a/docs/functions/_questdb_browser-client.writeQwpFrameHeader.html b/docs/functions/_questdb_browser-client.writeQwpFrameHeader.html index 7ed1f40..eddbb96 100644 --- a/docs/functions/_questdb_browser-client.writeQwpFrameHeader.html +++ b/docs/functions/_questdb_browser-client.writeQwpFrameHeader.html @@ -1 +1 @@ -writeQwpFrameHeader | QuestDB JavaScript Client - v4.2.0

    Function writeQwpFrameHeader

    +writeQwpFrameHeader | QuestDB JavaScript Client - v4.2.0

    Function writeQwpFrameHeader

    diff --git a/docs/functions/_questdb_browser-client.writeQwpVarint.html b/docs/functions/_questdb_browser-client.writeQwpVarint.html index 3939ebe..cdd3e8c 100644 --- a/docs/functions/_questdb_browser-client.writeQwpVarint.html +++ b/docs/functions/_questdb_browser-client.writeQwpVarint.html @@ -1,2 +1,2 @@ writeQwpVarint | QuestDB JavaScript Client - v4.2.0
    +

    Parameters

    Returns void

    diff --git a/docs/functions/_questdb_nodejs-client.addQwpDurableAckWebSocketProtocol.html b/docs/functions/_questdb_nodejs-client.addQwpDurableAckWebSocketProtocol.html index 5b54859..be278c7 100644 --- a/docs/functions/_questdb_nodejs-client.addQwpDurableAckWebSocketProtocol.html +++ b/docs/functions/_questdb_nodejs-client.addQwpDurableAckWebSocketProtocol.html @@ -1,2 +1,2 @@ addQwpDurableAckWebSocketProtocol | QuestDB JavaScript Client - v4.2.0

    Function addQwpDurableAckWebSocketProtocol

    • Adds the durable-ACK capability token without mutating user options.

      -

      Parameters

      • protocols: string | readonly string[]

      Returns string | string[]

    +

    Parameters

    • protocols: string | readonly string[]

    Returns string | string[]

    diff --git a/docs/functions/_questdb_nodejs-client.bigintToTwosComplementBytes.html b/docs/functions/_questdb_nodejs-client.bigintToTwosComplementBytes.html index 63cb98c..575d626 100644 --- a/docs/functions/_questdb_nodejs-client.bigintToTwosComplementBytes.html +++ b/docs/functions/_questdb_nodejs-client.bigintToTwosComplementBytes.html @@ -2,4 +2,4 @@ Produces the minimal-width representation that preserves the sign.

    Parameters

    • value: bigint

      The value to serialise

    Returns number[]

    Byte array in big-endian order

    -
    +
    diff --git a/docs/functions/_questdb_nodejs-client.binary.html b/docs/functions/_questdb_nodejs-client.binary.html index fbba93e..056fe3c 100644 --- a/docs/functions/_questdb_nodejs-client.binary.html +++ b/docs/functions/_questdb_nodejs-client.binary.html @@ -1,2 +1,2 @@ binary | QuestDB JavaScript Client - v4.2.0
    +

    Returns QwpWriterColumn<Uint8Array<ArrayBufferLike>>

    diff --git a/docs/functions/_questdb_nodejs-client.bool.html b/docs/functions/_questdb_nodejs-client.bool.html index 996247e..121da12 100644 --- a/docs/functions/_questdb_nodejs-client.bool.html +++ b/docs/functions/_questdb_nodejs-client.bool.html @@ -1,2 +1,2 @@ bool | QuestDB JavaScript Client - v4.2.0
    +

    Returns QwpWriterColumn<boolean>

    diff --git a/docs/functions/_questdb_nodejs-client.byte.html b/docs/functions/_questdb_nodejs-client.byte.html index f169d23..df35aed 100644 --- a/docs/functions/_questdb_nodejs-client.byte.html +++ b/docs/functions/_questdb_nodejs-client.byte.html @@ -1,2 +1,2 @@ byte | QuestDB JavaScript Client - v4.2.0
    +

    Returns QwpWriterColumn<number>

    diff --git a/docs/functions/_questdb_nodejs-client.char.html b/docs/functions/_questdb_nodejs-client.char.html index 5155582..01a9420 100644 --- a/docs/functions/_questdb_nodejs-client.char.html +++ b/docs/functions/_questdb_nodejs-client.char.html @@ -1,2 +1,2 @@ char | QuestDB JavaScript Client - v4.2.0
    +

    Returns QwpWriterColumn<string>

    diff --git a/docs/functions/_questdb_nodejs-client.concatBytes.html b/docs/functions/_questdb_nodejs-client.concatBytes.html index a312472..392150e 100644 --- a/docs/functions/_questdb_nodejs-client.concatBytes.html +++ b/docs/functions/_questdb_nodejs-client.concatBytes.html @@ -1 +1 @@ -concatBytes | QuestDB JavaScript Client - v4.2.0
    +concatBytes | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_nodejs-client.connectQwpNodeClient.html b/docs/functions/_questdb_nodejs-client.connectQwpNodeClient.html index 3ee340b..528ad21 100644 --- a/docs/functions/_questdb_nodejs-client.connectQwpNodeClient.html +++ b/docs/functions/_questdb_nodejs-client.connectQwpNodeClient.html @@ -1,3 +1,3 @@ connectQwpNodeClient | QuestDB JavaScript Client - v4.2.0

    Function connectQwpNodeClient

    +

    Parameters

    Returns Promise<QwpClient>

  • Creates and prewarms a combined Node QWP ingress/egress client.

    +

    Parameters

    Returns Promise<QwpClient>

  • diff --git a/docs/functions/_questdb_nodejs-client.connectQwpNodeEgress.html b/docs/functions/_questdb_nodejs-client.connectQwpNodeEgress.html index a7a30a9..1f1c7f4 100644 --- a/docs/functions/_questdb_nodejs-client.connectQwpNodeEgress.html +++ b/docs/functions/_questdb_nodejs-client.connectQwpNodeEgress.html @@ -1,3 +1,3 @@ connectQwpNodeEgress | QuestDB JavaScript Client - v4.2.0

    Function connectQwpNodeEgress

    +

    Returns Promise<QwpEgressSession>

    diff --git a/docs/functions/_questdb_nodejs-client.connectQwpNodeIngress.html b/docs/functions/_questdb_nodejs-client.connectQwpNodeIngress.html index 7ee8516..7fe83ba 100644 --- a/docs/functions/_questdb_nodejs-client.connectQwpNodeIngress.html +++ b/docs/functions/_questdb_nodejs-client.connectQwpNodeIngress.html @@ -1,3 +1,3 @@ connectQwpNodeIngress | QuestDB JavaScript Client - v4.2.0

    Function connectQwpNodeIngress

    +

    Returns Promise<QwpIngressSession>

    diff --git a/docs/functions/_questdb_nodejs-client.connectQwpNodeSender.html b/docs/functions/_questdb_nodejs-client.connectQwpNodeSender.html index 083819e..4110c05 100644 --- a/docs/functions/_questdb_nodejs-client.connectQwpNodeSender.html +++ b/docs/functions/_questdb_nodejs-client.connectQwpNodeSender.html @@ -1,2 +1,2 @@ connectQwpNodeSender | QuestDB JavaScript Client - v4.2.0

    Function connectQwpNodeSender

    +

    Parameters

    Returns Promise<QwpSender>

    diff --git a/docs/functions/_questdb_nodejs-client.connectQwpNodeUdp.html b/docs/functions/_questdb_nodejs-client.connectQwpNodeUdp.html index e301e89..3bcecc2 100644 --- a/docs/functions/_questdb_nodejs-client.connectQwpNodeUdp.html +++ b/docs/functions/_questdb_nodejs-client.connectQwpNodeUdp.html @@ -1,2 +1,2 @@ connectQwpNodeUdp | QuestDB JavaScript Client - v4.2.0

    Function connectQwpNodeUdp

    +

    Parameters

    Returns Promise<QwpNodeUdpSession>

    diff --git a/docs/functions/_questdb_nodejs-client.connectQwpNodeUdpSender.html b/docs/functions/_questdb_nodejs-client.connectQwpNodeUdpSender.html index d6aa9fe..618216c 100644 --- a/docs/functions/_questdb_nodejs-client.connectQwpNodeUdpSender.html +++ b/docs/functions/_questdb_nodejs-client.connectQwpNodeUdpSender.html @@ -1,2 +1,2 @@ connectQwpNodeUdpSender | QuestDB JavaScript Client - v4.2.0

    Function connectQwpNodeUdpSender

    +

    Parameters

    Returns Promise<QwpSender>

    diff --git a/docs/functions/_questdb_nodejs-client.connectQwpNodeWebSocket.html b/docs/functions/_questdb_nodejs-client.connectQwpNodeWebSocket.html index c05d47c..7b43980 100644 --- a/docs/functions/_questdb_nodejs-client.connectQwpNodeWebSocket.html +++ b/docs/functions/_questdb_nodejs-client.connectQwpNodeWebSocket.html @@ -1,2 +1,2 @@ connectQwpNodeWebSocket | QuestDB JavaScript Client - v4.2.0

    Function connectQwpNodeWebSocket

    +

    Parameters

    Returns Promise<QwpBinaryConnection>

    diff --git a/docs/functions/_questdb_nodejs-client.createBuffer.html b/docs/functions/_questdb_nodejs-client.createBuffer.html index 9c77d5b..d8b5d42 100644 --- a/docs/functions/_questdb_nodejs-client.createBuffer.html +++ b/docs/functions/_questdb_nodejs-client.createBuffer.html @@ -3,4 +3,4 @@ See SenderOptions documentation for detailed description of configuration options.

    Returns SenderBuffer

    A SenderBuffer instance appropriate for the specified protocol version

    Error if protocol version is not specified or is unsupported

    -
    +
    diff --git a/docs/functions/_questdb_nodejs-client.createQwpDataLossSenderError.html b/docs/functions/_questdb_nodejs-client.createQwpDataLossSenderError.html index 96b7741..033356e 100644 --- a/docs/functions/_questdb_nodejs-client.createQwpDataLossSenderError.html +++ b/docs/functions/_questdb_nodejs-client.createQwpDataLossSenderError.html @@ -1,2 +1,2 @@ createQwpDataLossSenderError | QuestDB JavaScript Client - v4.2.0

    Function createQwpDataLossSenderError

    +

    Returns QwpSenderError

    diff --git a/docs/functions/_questdb_nodejs-client.createQwpNodeClient.html b/docs/functions/_questdb_nodejs-client.createQwpNodeClient.html index dfdc88d..4ae2864 100644 --- a/docs/functions/_questdb_nodejs-client.createQwpNodeClient.html +++ b/docs/functions/_questdb_nodejs-client.createQwpNodeClient.html @@ -1,3 +1,3 @@ createQwpNodeClient | QuestDB JavaScript Client - v4.2.0

    Function createQwpNodeClient

    +

    Parameters

    Returns QwpClient

  • Creates a lazy Node QWP client with bounded sender and query pools.

    +

    Parameters

    Returns QwpClient

  • diff --git a/docs/functions/_questdb_nodejs-client.createQwpNodeConnectionFactory.html b/docs/functions/_questdb_nodejs-client.createQwpNodeConnectionFactory.html index c7e2645..31c5946 100644 --- a/docs/functions/_questdb_nodejs-client.createQwpNodeConnectionFactory.html +++ b/docs/functions/_questdb_nodejs-client.createQwpNodeConnectionFactory.html @@ -1,2 +1,2 @@ createQwpNodeConnectionFactory | QuestDB JavaScript Client - v4.2.0

    Function createQwpNodeConnectionFactory

    +

    Parameters

    Returns QwpConnectionFactory

    diff --git a/docs/functions/_questdb_nodejs-client.createQwpNodeSender.html b/docs/functions/_questdb_nodejs-client.createQwpNodeSender.html index 8987491..e577cec 100644 --- a/docs/functions/_questdb_nodejs-client.createQwpNodeSender.html +++ b/docs/functions/_questdb_nodejs-client.createQwpNodeSender.html @@ -1,3 +1,3 @@ createQwpNodeSender | QuestDB JavaScript Client - v4.2.0

    Function createQwpNodeSender

    +

    Parameters

    Returns QwpSender

    diff --git a/docs/functions/_questdb_nodejs-client.createQwpNodeUdpSender.html b/docs/functions/_questdb_nodejs-client.createQwpNodeUdpSender.html index 71ee519..f6fe41d 100644 --- a/docs/functions/_questdb_nodejs-client.createQwpNodeUdpSender.html +++ b/docs/functions/_questdb_nodejs-client.createQwpNodeUdpSender.html @@ -1,4 +1,4 @@ createQwpNodeUdpSender | QuestDB JavaScript Client - v4.2.0

    Function createQwpNodeUdpSender

    +

    Parameters

    Returns QwpSender

    diff --git a/docs/functions/_questdb_nodejs-client.createQwpProtocolViolationSenderError.html b/docs/functions/_questdb_nodejs-client.createQwpProtocolViolationSenderError.html index dbf1dbc..6746ff3 100644 --- a/docs/functions/_questdb_nodejs-client.createQwpProtocolViolationSenderError.html +++ b/docs/functions/_questdb_nodejs-client.createQwpProtocolViolationSenderError.html @@ -1 +1 @@ -createQwpProtocolViolationSenderError | QuestDB JavaScript Client - v4.2.0

    Function createQwpProtocolViolationSenderError

    +createQwpProtocolViolationSenderError | QuestDB JavaScript Client - v4.2.0

    Function createQwpProtocolViolationSenderError

    diff --git a/docs/functions/_questdb_nodejs-client.createQwpSenderError.html b/docs/functions/_questdb_nodejs-client.createQwpSenderError.html index f2179e8..7a97aac 100644 --- a/docs/functions/_questdb_nodejs-client.createQwpSenderError.html +++ b/docs/functions/_questdb_nodejs-client.createQwpSenderError.html @@ -1 +1 @@ -createQwpSenderError | QuestDB JavaScript Client - v4.2.0

    Function createQwpSenderError

    +createQwpSenderError | QuestDB JavaScript Client - v4.2.0

    Function createQwpSenderError

    diff --git a/docs/functions/_questdb_nodejs-client.createTransport.html b/docs/functions/_questdb_nodejs-client.createTransport.html index 43da34d..c5e695a 100644 --- a/docs/functions/_questdb_nodejs-client.createTransport.html +++ b/docs/functions/_questdb_nodejs-client.createTransport.html @@ -2,4 +2,4 @@

    Parameters

    • options: SenderOptions

      Sender configuration options including protocol and connection details

    Returns SenderTransport

    Transport instance appropriate for the specified protocol

    Error if protocol or host options are missing or invalid

    -
    +
    diff --git a/docs/functions/_questdb_nodejs-client.date.html b/docs/functions/_questdb_nodejs-client.date.html index 7e525d6..51c45dc 100644 --- a/docs/functions/_questdb_nodejs-client.date.html +++ b/docs/functions/_questdb_nodejs-client.date.html @@ -1,4 +1,4 @@ date | QuestDB JavaScript Client - v4.2.0
    +

    Returns QwpWriterColumn<number | bigint>

    diff --git a/docs/functions/_questdb_nodejs-client.decimal128.html b/docs/functions/_questdb_nodejs-client.decimal128.html index 2779439..b2b1212 100644 --- a/docs/functions/_questdb_nodejs-client.decimal128.html +++ b/docs/functions/_questdb_nodejs-client.decimal128.html @@ -1,2 +1,2 @@ decimal128 | QuestDB JavaScript Client - v4.2.0
    +

    Parameters

    • scale: number

    Returns QwpWriterColumn<QwpDecimalInput>

    diff --git a/docs/functions/_questdb_nodejs-client.decimal256.html b/docs/functions/_questdb_nodejs-client.decimal256.html index 0ba4586..372606c 100644 --- a/docs/functions/_questdb_nodejs-client.decimal256.html +++ b/docs/functions/_questdb_nodejs-client.decimal256.html @@ -1,2 +1,2 @@ decimal256 | QuestDB JavaScript Client - v4.2.0
    +

    Parameters

    • scale: number

    Returns QwpWriterColumn<QwpDecimalInput>

    diff --git a/docs/functions/_questdb_nodejs-client.decimal64.html b/docs/functions/_questdb_nodejs-client.decimal64.html index 1c272b1..7ab15f3 100644 --- a/docs/functions/_questdb_nodejs-client.decimal64.html +++ b/docs/functions/_questdb_nodejs-client.decimal64.html @@ -1,2 +1,2 @@ decimal64 | QuestDB JavaScript Client - v4.2.0
    +

    Parameters

    • scale: number

    Returns QwpWriterColumn<QwpDecimalInput>

    diff --git a/docs/functions/_questdb_nodejs-client.decodeQwpContentEncoding.html b/docs/functions/_questdb_nodejs-client.decodeQwpContentEncoding.html index 4317676..46544d1 100644 --- a/docs/functions/_questdb_nodejs-client.decodeQwpContentEncoding.html +++ b/docs/functions/_questdb_nodejs-client.decodeQwpContentEncoding.html @@ -1,4 +1,4 @@ decodeQwpContentEncoding | QuestDB JavaScript Client - v4.2.0

    Function decodeQwpContentEncoding

    +

    Parameters

    • value: string

    Returns QwpNegotiatedEgressCompression

    diff --git a/docs/functions/_questdb_nodejs-client.decodeQwpEgressMessage.html b/docs/functions/_questdb_nodejs-client.decodeQwpEgressMessage.html index 59b9b0c..92ec0c1 100644 --- a/docs/functions/_questdb_nodejs-client.decodeQwpEgressMessage.html +++ b/docs/functions/_questdb_nodejs-client.decodeQwpEgressMessage.html @@ -1,2 +1,2 @@ decodeQwpEgressMessage | QuestDB JavaScript Client - v4.2.0

    Function decodeQwpEgressMessage

    +

    Parameters

    • bytes: Uint8Array

    Returns QwpEgressMessage

    diff --git a/docs/functions/_questdb_nodejs-client.decodeQwpFrame.html b/docs/functions/_questdb_nodejs-client.decodeQwpFrame.html index 284e684..07d4f8d 100644 --- a/docs/functions/_questdb_nodejs-client.decodeQwpFrame.html +++ b/docs/functions/_questdb_nodejs-client.decodeQwpFrame.html @@ -1 +1 @@ -decodeQwpFrame | QuestDB JavaScript Client - v4.2.0
    +decodeQwpFrame | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_nodejs-client.decodeQwpIngressResponse.html b/docs/functions/_questdb_nodejs-client.decodeQwpIngressResponse.html index e374999..08d38fb 100644 --- a/docs/functions/_questdb_nodejs-client.decodeQwpIngressResponse.html +++ b/docs/functions/_questdb_nodejs-client.decodeQwpIngressResponse.html @@ -1,2 +1,2 @@ decodeQwpIngressResponse | QuestDB JavaScript Client - v4.2.0

    Function decodeQwpIngressResponse

    +

    Parameters

    • payload: Uint8Array

    Returns QwpIngressResponse

    diff --git a/docs/functions/_questdb_nodejs-client.decodeQwpIngressServerInfo.html b/docs/functions/_questdb_nodejs-client.decodeQwpIngressServerInfo.html index 2c072cd..ed15833 100644 --- a/docs/functions/_questdb_nodejs-client.decodeQwpIngressServerInfo.html +++ b/docs/functions/_questdb_nodejs-client.decodeQwpIngressServerInfo.html @@ -1,2 +1,2 @@ decodeQwpIngressServerInfo | QuestDB JavaScript Client - v4.2.0

    Function decodeQwpIngressServerInfo

    +

    Parameters

    • payload: Uint8Array

    Returns number

    diff --git a/docs/functions/_questdb_nodejs-client.decodeQwpIngressSymbolDictionaryDelta.html b/docs/functions/_questdb_nodejs-client.decodeQwpIngressSymbolDictionaryDelta.html index 322a031..06d1c45 100644 --- a/docs/functions/_questdb_nodejs-client.decodeQwpIngressSymbolDictionaryDelta.html +++ b/docs/functions/_questdb_nodejs-client.decodeQwpIngressSymbolDictionaryDelta.html @@ -1,2 +1,2 @@ decodeQwpIngressSymbolDictionaryDelta | QuestDB JavaScript Client - v4.2.0

    Function decodeQwpIngressSymbolDictionaryDelta

    +

    Parameters

    • bytes: Uint8Array

    Returns QwpIngressSymbolDictionaryDelta

    diff --git a/docs/functions/_questdb_nodejs-client.decodeQwpVarint.html b/docs/functions/_questdb_nodejs-client.decodeQwpVarint.html index a8a4ae1..c509f7b 100644 --- a/docs/functions/_questdb_nodejs-client.decodeQwpVarint.html +++ b/docs/functions/_questdb_nodejs-client.decodeQwpVarint.html @@ -1 +1 @@ -decodeQwpVarint | QuestDB JavaScript Client - v4.2.0
    • Parameters

      • bytes: Uint8Array
      • offset: number = 0

      Returns { offset: number; value: bigint }

    +decodeQwpVarint | QuestDB JavaScript Client - v4.2.0
    • Parameters

      • bytes: Uint8Array
      • offset: number = 0

      Returns { offset: number; value: bigint }

    diff --git a/docs/functions/_questdb_nodejs-client.decodeUtf8.html b/docs/functions/_questdb_nodejs-client.decodeUtf8.html index 70c9bcb..946f494 100644 --- a/docs/functions/_questdb_nodejs-client.decodeUtf8.html +++ b/docs/functions/_questdb_nodejs-client.decodeUtf8.html @@ -1 +1 @@ -decodeUtf8 | QuestDB JavaScript Client - v4.2.0
    +decodeUtf8 | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_nodejs-client.decompressQwpZstdFrame.html b/docs/functions/_questdb_nodejs-client.decompressQwpZstdFrame.html index 87a7b3d..c55d94a 100644 --- a/docs/functions/_questdb_nodejs-client.decompressQwpZstdFrame.html +++ b/docs/functions/_questdb_nodejs-client.decompressQwpZstdFrame.html @@ -1,2 +1,2 @@ decompressQwpZstdFrame | QuestDB JavaScript Client - v4.2.0

    Function decompressQwpZstdFrame

    +

    Parameters

    • frame: Uint8Array

    Returns Uint8Array

    diff --git a/docs/functions/_questdb_nodejs-client.defaultQwpSenderErrorHandler.html b/docs/functions/_questdb_nodejs-client.defaultQwpSenderErrorHandler.html index 820c64f..9b381a1 100644 --- a/docs/functions/_questdb_nodejs-client.defaultQwpSenderErrorHandler.html +++ b/docs/functions/_questdb_nodejs-client.defaultQwpSenderErrorHandler.html @@ -1,3 +1,3 @@ defaultQwpSenderErrorHandler | QuestDB JavaScript Client - v4.2.0

    Function defaultQwpSenderErrorHandler

    +

    Parameters

    Returns void

    diff --git a/docs/functions/_questdb_nodejs-client.designatedTimestamp.html b/docs/functions/_questdb_nodejs-client.designatedTimestamp.html index 532df8a..dcce872 100644 --- a/docs/functions/_questdb_nodejs-client.designatedTimestamp.html +++ b/docs/functions/_questdb_nodejs-client.designatedTimestamp.html @@ -1,2 +1,2 @@ designatedTimestamp | QuestDB JavaScript Client - v4.2.0

    Function designatedTimestamp

    +

    Type Parameters

    Parameters

    Returns QwpWriterColumn<TimestampInput<Unit>, true>

    diff --git a/docs/functions/_questdb_nodejs-client.double.html b/docs/functions/_questdb_nodejs-client.double.html index d4ba984..f503e4a 100644 --- a/docs/functions/_questdb_nodejs-client.double.html +++ b/docs/functions/_questdb_nodejs-client.double.html @@ -1,2 +1,2 @@ double | QuestDB JavaScript Client - v4.2.0
    +

    Returns QwpWriterColumn<number>

    diff --git a/docs/functions/_questdb_nodejs-client.doubleArray.html b/docs/functions/_questdb_nodejs-client.doubleArray.html index 5fd7e5b..5dffa08 100644 --- a/docs/functions/_questdb_nodejs-client.doubleArray.html +++ b/docs/functions/_questdb_nodejs-client.doubleArray.html @@ -1,2 +1,2 @@ doubleArray | QuestDB JavaScript Client - v4.2.0
    +

    Returns QwpWriterColumn<QwpDoubleArrayInput>

    diff --git a/docs/functions/_questdb_nodejs-client.encodeQwpAcceptEncoding.html b/docs/functions/_questdb_nodejs-client.encodeQwpAcceptEncoding.html index d975902..9129734 100644 --- a/docs/functions/_questdb_nodejs-client.encodeQwpAcceptEncoding.html +++ b/docs/functions/_questdb_nodejs-client.encodeQwpAcceptEncoding.html @@ -1,2 +1,2 @@ encodeQwpAcceptEncoding | QuestDB JavaScript Client - v4.2.0

    Function encodeQwpAcceptEncoding

    +

    Parameters

    Returns string

    diff --git a/docs/functions/_questdb_nodejs-client.encodeQwpBinds.html b/docs/functions/_questdb_nodejs-client.encodeQwpBinds.html index 5f769f1..a1af398 100644 --- a/docs/functions/_questdb_nodejs-client.encodeQwpBinds.html +++ b/docs/functions/_questdb_nodejs-client.encodeQwpBinds.html @@ -1,2 +1,2 @@ encodeQwpBinds | QuestDB JavaScript Client - v4.2.0
    +

    Parameters

    Returns QwpEncodedBinds

    diff --git a/docs/functions/_questdb_nodejs-client.encodeQwpCancel.html b/docs/functions/_questdb_nodejs-client.encodeQwpCancel.html index 0569d49..a147253 100644 --- a/docs/functions/_questdb_nodejs-client.encodeQwpCancel.html +++ b/docs/functions/_questdb_nodejs-client.encodeQwpCancel.html @@ -1,2 +1,2 @@ encodeQwpCancel | QuestDB JavaScript Client - v4.2.0
    +

    Parameters

    • request: number | bigint

    Returns Uint8Array

    diff --git a/docs/functions/_questdb_nodejs-client.encodeQwpCredit.html b/docs/functions/_questdb_nodejs-client.encodeQwpCredit.html index 964b1b6..834fbad 100644 --- a/docs/functions/_questdb_nodejs-client.encodeQwpCredit.html +++ b/docs/functions/_questdb_nodejs-client.encodeQwpCredit.html @@ -1,2 +1,2 @@ encodeQwpCredit | QuestDB JavaScript Client - v4.2.0
    • Encodes the unframed client-to-server CREDIT payload.

      -

      Parameters

      • request: number | bigint
      • additionalBytes: number | bigint

      Returns Uint8Array

    +

    Parameters

    • request: number | bigint
    • additionalBytes: number | bigint

    Returns Uint8Array

    diff --git a/docs/functions/_questdb_nodejs-client.encodeQwpDurableAckPollFrame.html b/docs/functions/_questdb_nodejs-client.encodeQwpDurableAckPollFrame.html index b68b79b..b7976e2 100644 --- a/docs/functions/_questdb_nodejs-client.encodeQwpDurableAckPollFrame.html +++ b/docs/functions/_questdb_nodejs-client.encodeQwpDurableAckPollFrame.html @@ -1,2 +1,2 @@ encodeQwpDurableAckPollFrame | QuestDB JavaScript Client - v4.2.0

    Function encodeQwpDurableAckPollFrame

    +

    Returns Uint8Array

    diff --git a/docs/functions/_questdb_nodejs-client.encodeQwpFrame.html b/docs/functions/_questdb_nodejs-client.encodeQwpFrame.html index 2a245d6..8561286 100644 --- a/docs/functions/_questdb_nodejs-client.encodeQwpFrame.html +++ b/docs/functions/_questdb_nodejs-client.encodeQwpFrame.html @@ -1 +1 @@ -encodeQwpFrame | QuestDB JavaScript Client - v4.2.0
    • Parameters

      • payload: Uint8Array
      • flags: number = 0
      • tableCount: number = 0

      Returns Uint8Array

    +encodeQwpFrame | QuestDB JavaScript Client - v4.2.0
    • Parameters

      • payload: Uint8Array
      • flags: number = 0
      • tableCount: number = 0

      Returns Uint8Array

    diff --git a/docs/functions/_questdb_nodejs-client.encodeQwpGorilla.html b/docs/functions/_questdb_nodejs-client.encodeQwpGorilla.html index 30fa0e7..a390bc0 100644 --- a/docs/functions/_questdb_nodejs-client.encodeQwpGorilla.html +++ b/docs/functions/_questdb_nodejs-client.encodeQwpGorilla.html @@ -1,2 +1,2 @@ encodeQwpGorilla | QuestDB JavaScript Client - v4.2.0
    +

    Parameters

    • timestamps: readonly bigint[]

    Returns Uint8Array

    diff --git a/docs/functions/_questdb_nodejs-client.encodeQwpIngressCommitFrame.html b/docs/functions/_questdb_nodejs-client.encodeQwpIngressCommitFrame.html index 3d1cfe7..314236b 100644 --- a/docs/functions/_questdb_nodejs-client.encodeQwpIngressCommitFrame.html +++ b/docs/functions/_questdb_nodejs-client.encodeQwpIngressCommitFrame.html @@ -1 +1 @@ -encodeQwpIngressCommitFrame | QuestDB JavaScript Client - v4.2.0

    Function encodeQwpIngressCommitFrame

    +encodeQwpIngressCommitFrame | QuestDB JavaScript Client - v4.2.0

    Function encodeQwpIngressCommitFrame

    diff --git a/docs/functions/_questdb_nodejs-client.encodeQwpIngressFrame.html b/docs/functions/_questdb_nodejs-client.encodeQwpIngressFrame.html index 591676a..a31f70c 100644 --- a/docs/functions/_questdb_nodejs-client.encodeQwpIngressFrame.html +++ b/docs/functions/_questdb_nodejs-client.encodeQwpIngressFrame.html @@ -1,2 +1,2 @@ encodeQwpIngressFrame | QuestDB JavaScript Client - v4.2.0

    Function encodeQwpIngressFrame

    +

    Parameters

    Returns Uint8Array

    diff --git a/docs/functions/_questdb_nodejs-client.encodeQwpIngressSymbolDictionaryFrame.html b/docs/functions/_questdb_nodejs-client.encodeQwpIngressSymbolDictionaryFrame.html index 3fe04a1..c0a1031 100644 --- a/docs/functions/_questdb_nodejs-client.encodeQwpIngressSymbolDictionaryFrame.html +++ b/docs/functions/_questdb_nodejs-client.encodeQwpIngressSymbolDictionaryFrame.html @@ -1,2 +1,2 @@ encodeQwpIngressSymbolDictionaryFrame | QuestDB JavaScript Client - v4.2.0

    Function encodeQwpIngressSymbolDictionaryFrame

    • Encodes a table-less committed dictionary catch-up frame.

      -

      Parameters

      • startId: number
      • entries: readonly string[]

      Returns Uint8Array

    +

    Parameters

    • startId: number
    • entries: readonly string[]

    Returns Uint8Array

    diff --git a/docs/functions/_questdb_nodejs-client.encodeQwpQueryRequest.html b/docs/functions/_questdb_nodejs-client.encodeQwpQueryRequest.html index e92e1dc..02a0aef 100644 --- a/docs/functions/_questdb_nodejs-client.encodeQwpQueryRequest.html +++ b/docs/functions/_questdb_nodejs-client.encodeQwpQueryRequest.html @@ -1,2 +1,2 @@ encodeQwpQueryRequest | QuestDB JavaScript Client - v4.2.0

    Function encodeQwpQueryRequest

    +

    Parameters

    Returns Uint8Array

    diff --git a/docs/functions/_questdb_nodejs-client.encodeQwpVarint.html b/docs/functions/_questdb_nodejs-client.encodeQwpVarint.html index 8cc6c04..0633a36 100644 --- a/docs/functions/_questdb_nodejs-client.encodeQwpVarint.html +++ b/docs/functions/_questdb_nodejs-client.encodeQwpVarint.html @@ -1 +1 @@ -encodeQwpVarint | QuestDB JavaScript Client - v4.2.0
    +encodeQwpVarint | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_nodejs-client.encodeUtf8.html b/docs/functions/_questdb_nodejs-client.encodeUtf8.html index bb58e7f..a6a93d1 100644 --- a/docs/functions/_questdb_nodejs-client.encodeUtf8.html +++ b/docs/functions/_questdb_nodejs-client.encodeUtf8.html @@ -1 +1 @@ -encodeUtf8 | QuestDB JavaScript Client - v4.2.0
    +encodeUtf8 | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_nodejs-client.flattenQwpArray.html b/docs/functions/_questdb_nodejs-client.flattenQwpArray.html index c0a4d96..5983fd1 100644 --- a/docs/functions/_questdb_nodejs-client.flattenQwpArray.html +++ b/docs/functions/_questdb_nodejs-client.flattenQwpArray.html @@ -1 +1 @@ -flattenQwpArray | QuestDB JavaScript Client - v4.2.0
    +flattenQwpArray | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_nodejs-client.float32.html b/docs/functions/_questdb_nodejs-client.float32.html index 2ba6a61..20db29a 100644 --- a/docs/functions/_questdb_nodejs-client.float32.html +++ b/docs/functions/_questdb_nodejs-client.float32.html @@ -1,2 +1,2 @@ float32 | QuestDB JavaScript Client - v4.2.0
    +

    Returns QwpWriterColumn<number>

    diff --git a/docs/functions/_questdb_nodejs-client.float64.html b/docs/functions/_questdb_nodejs-client.float64.html index 96f3cde..5165ce2 100644 --- a/docs/functions/_questdb_nodejs-client.float64.html +++ b/docs/functions/_questdb_nodejs-client.float64.html @@ -1,2 +1,2 @@ float64 | QuestDB JavaScript Client - v4.2.0
    +

    Returns QwpWriterColumn<number>

    diff --git a/docs/functions/_questdb_nodejs-client.geohash.html b/docs/functions/_questdb_nodejs-client.geohash.html index 62dbbad..c859868 100644 --- a/docs/functions/_questdb_nodejs-client.geohash.html +++ b/docs/functions/_questdb_nodejs-client.geohash.html @@ -1,4 +1,4 @@ geohash | QuestDB JavaScript Client - v4.2.0
    +

    Returns QwpWriterColumn<QwpGeohashInput>

    diff --git a/docs/functions/_questdb_nodejs-client.int32.html b/docs/functions/_questdb_nodejs-client.int32.html index bedd1ce..60310ac 100644 --- a/docs/functions/_questdb_nodejs-client.int32.html +++ b/docs/functions/_questdb_nodejs-client.int32.html @@ -1,4 +1,4 @@ int32 | QuestDB JavaScript Client - v4.2.0
    +

    Returns QwpWriterColumn<number>

    diff --git a/docs/functions/_questdb_nodejs-client.int64.html b/docs/functions/_questdb_nodejs-client.int64.html index 48d1b1c..c98bd3f 100644 --- a/docs/functions/_questdb_nodejs-client.int64.html +++ b/docs/functions/_questdb_nodejs-client.int64.html @@ -1,4 +1,4 @@ int64 | QuestDB JavaScript Client - v4.2.0
    +

    Returns QwpWriterColumn<bigint>

    diff --git a/docs/functions/_questdb_nodejs-client.ipv4.html b/docs/functions/_questdb_nodejs-client.ipv4.html index 02cc613..5599204 100644 --- a/docs/functions/_questdb_nodejs-client.ipv4.html +++ b/docs/functions/_questdb_nodejs-client.ipv4.html @@ -1,2 +1,2 @@ ipv4 | QuestDB JavaScript Client - v4.2.0
    +

    Returns QwpWriterColumn<QwpIpv4Input>

    diff --git a/docs/functions/_questdb_nodejs-client.isQwpDurableAckWebSocketProtocol.html b/docs/functions/_questdb_nodejs-client.isQwpDurableAckWebSocketProtocol.html index cc1b0a9..e8d8462 100644 --- a/docs/functions/_questdb_nodejs-client.isQwpDurableAckWebSocketProtocol.html +++ b/docs/functions/_questdb_nodejs-client.isQwpDurableAckWebSocketProtocol.html @@ -1,2 +1,2 @@ isQwpDurableAckWebSocketProtocol | QuestDB JavaScript Client - v4.2.0

    Function isQwpDurableAckWebSocketProtocol

    +

    Parameters

    • protocol: string

    Returns boolean

    diff --git a/docs/functions/_questdb_nodejs-client.long.html b/docs/functions/_questdb_nodejs-client.long.html index 86a342d..ca88eda 100644 --- a/docs/functions/_questdb_nodejs-client.long.html +++ b/docs/functions/_questdb_nodejs-client.long.html @@ -1,2 +1,2 @@ long | QuestDB JavaScript Client - v4.2.0
    +

    Returns QwpWriterColumn<bigint>

    diff --git a/docs/functions/_questdb_nodejs-client.long256.html b/docs/functions/_questdb_nodejs-client.long256.html index c23d925..625a3b6 100644 --- a/docs/functions/_questdb_nodejs-client.long256.html +++ b/docs/functions/_questdb_nodejs-client.long256.html @@ -1,2 +1,2 @@ long256 | QuestDB JavaScript Client - v4.2.0
    +

    Returns QwpWriterColumn<QwpLong256Input>

    diff --git a/docs/functions/_questdb_nodejs-client.longArray.html b/docs/functions/_questdb_nodejs-client.longArray.html index 4844a01..d55cf00 100644 --- a/docs/functions/_questdb_nodejs-client.longArray.html +++ b/docs/functions/_questdb_nodejs-client.longArray.html @@ -1,4 +1,4 @@ longArray | QuestDB JavaScript Client - v4.2.0
    +

    Returns QwpWriterColumn<QwpLongArrayInput>

    diff --git a/docs/functions/_questdb_nodejs-client.parseQwpNodeClientConfig.html b/docs/functions/_questdb_nodejs-client.parseQwpNodeClientConfig.html index 6ca28ed..70f23ba 100644 --- a/docs/functions/_questdb_nodejs-client.parseQwpNodeClientConfig.html +++ b/docs/functions/_questdb_nodejs-client.parseQwpNodeClientConfig.html @@ -1,2 +1,2 @@ parseQwpNodeClientConfig | QuestDB JavaScript Client - v4.2.0

    Function parseQwpNodeClientConfig

    +

    Parameters

    Returns QwpNodeClientOptions

    diff --git a/docs/functions/_questdb_nodejs-client.qwpDefaultSenderErrorPolicy.html b/docs/functions/_questdb_nodejs-client.qwpDefaultSenderErrorPolicy.html index cc64f21..96d47d3 100644 --- a/docs/functions/_questdb_nodejs-client.qwpDefaultSenderErrorPolicy.html +++ b/docs/functions/_questdb_nodejs-client.qwpDefaultSenderErrorPolicy.html @@ -1 +1 @@ -qwpDefaultSenderErrorPolicy | QuestDB JavaScript Client - v4.2.0

    Function qwpDefaultSenderErrorPolicy

    +qwpDefaultSenderErrorPolicy | QuestDB JavaScript Client - v4.2.0

    Function qwpDefaultSenderErrorPolicy

    diff --git a/docs/functions/_questdb_nodejs-client.qwpGorillaSize.html b/docs/functions/_questdb_nodejs-client.qwpGorillaSize.html index 5407357..b049059 100644 --- a/docs/functions/_questdb_nodejs-client.qwpGorillaSize.html +++ b/docs/functions/_questdb_nodejs-client.qwpGorillaSize.html @@ -1,2 +1,2 @@ qwpGorillaSize | QuestDB JavaScript Client - v4.2.0
    +

    Parameters

    • timestamps: readonly bigint[]

    Returns number

    diff --git a/docs/functions/_questdb_nodejs-client.qwpSenderErrorCategory.html b/docs/functions/_questdb_nodejs-client.qwpSenderErrorCategory.html index ce781c9..fdc2270 100644 --- a/docs/functions/_questdb_nodejs-client.qwpSenderErrorCategory.html +++ b/docs/functions/_questdb_nodejs-client.qwpSenderErrorCategory.html @@ -1 +1 @@ -qwpSenderErrorCategory | QuestDB JavaScript Client - v4.2.0

    Function qwpSenderErrorCategory

    +qwpSenderErrorCategory | QuestDB JavaScript Client - v4.2.0

    Function qwpSenderErrorCategory

    diff --git a/docs/functions/_questdb_nodejs-client.qwpVarintSize.html b/docs/functions/_questdb_nodejs-client.qwpVarintSize.html index 578e5f7..c6b82f4 100644 --- a/docs/functions/_questdb_nodejs-client.qwpVarintSize.html +++ b/docs/functions/_questdb_nodejs-client.qwpVarintSize.html @@ -1,2 +1,2 @@ qwpVarintSize | QuestDB JavaScript Client - v4.2.0
    +

    Parameters

    • value: number | bigint

    Returns number

    diff --git a/docs/functions/_questdb_nodejs-client.readQwpVarint.html b/docs/functions/_questdb_nodejs-client.readQwpVarint.html index e0f4b7b..fb6a905 100644 --- a/docs/functions/_questdb_nodejs-client.readQwpVarint.html +++ b/docs/functions/_questdb_nodejs-client.readQwpVarint.html @@ -1,2 +1,2 @@ readQwpVarint | QuestDB JavaScript Client - v4.2.0
    +

    Parameters

    Returns bigint

    diff --git a/docs/functions/_questdb_nodejs-client.readQwpVarintNumber.html b/docs/functions/_questdb_nodejs-client.readQwpVarintNumber.html index 7faa300..65e0cbb 100644 --- a/docs/functions/_questdb_nodejs-client.readQwpVarintNumber.html +++ b/docs/functions/_questdb_nodejs-client.readQwpVarintNumber.html @@ -1 +1 @@ -readQwpVarintNumber | QuestDB JavaScript Client - v4.2.0

    Function readQwpVarintNumber

    +readQwpVarintNumber | QuestDB JavaScript Client - v4.2.0

    Function readQwpVarintNumber

    diff --git a/docs/functions/_questdb_nodejs-client.retryQwpNodeOrphanSlot.html b/docs/functions/_questdb_nodejs-client.retryQwpNodeOrphanSlot.html index c401945..019561e 100644 --- a/docs/functions/_questdb_nodejs-client.retryQwpNodeOrphanSlot.html +++ b/docs/functions/_questdb_nodejs-client.retryQwpNodeOrphanSlot.html @@ -1,2 +1,2 @@ retryQwpNodeOrphanSlot | QuestDB JavaScript Client - v4.2.0

    Function retryQwpNodeOrphanSlot

    +

    Parameters

    • directory: string

    Returns Promise<void>

    diff --git a/docs/functions/_questdb_nodejs-client.scanQwpNodeOrphanSlots.html b/docs/functions/_questdb_nodejs-client.scanQwpNodeOrphanSlots.html index 6148ccf..8e19cd4 100644 --- a/docs/functions/_questdb_nodejs-client.scanQwpNodeOrphanSlots.html +++ b/docs/functions/_questdb_nodejs-client.scanQwpNodeOrphanSlots.html @@ -2,4 +2,4 @@

    The scan is deliberately read-only and does not inspect lock ownership. Adoption obtains the replay store's exclusive lock, closing the race with a live foreground producer or another drainer.

    -

    Parameters

    • rootDirectory: string
    • OptionalexcludeSlot: (slotName: string) => boolean

    Returns Promise<readonly string[]>

    +

    Parameters

    • rootDirectory: string
    • OptionalexcludeSlot: (slotName: string) => boolean

    Returns Promise<readonly string[]>

    diff --git a/docs/functions/_questdb_nodejs-client.short.html b/docs/functions/_questdb_nodejs-client.short.html index f671d9c..d623c23 100644 --- a/docs/functions/_questdb_nodejs-client.short.html +++ b/docs/functions/_questdb_nodejs-client.short.html @@ -1,2 +1,2 @@ short | QuestDB JavaScript Client - v4.2.0
    +

    Returns QwpWriterColumn<number>

    diff --git a/docs/functions/_questdb_nodejs-client.symbol.html b/docs/functions/_questdb_nodejs-client.symbol.html index dd5fb97..1a5b964 100644 --- a/docs/functions/_questdb_nodejs-client.symbol.html +++ b/docs/functions/_questdb_nodejs-client.symbol.html @@ -1,2 +1,2 @@ symbol | QuestDB JavaScript Client - v4.2.0
    +

    Returns QwpWriterColumn<string>

    diff --git a/docs/functions/_questdb_nodejs-client.timestamp.html b/docs/functions/_questdb_nodejs-client.timestamp.html index ba9620d..e0c794b 100644 --- a/docs/functions/_questdb_nodejs-client.timestamp.html +++ b/docs/functions/_questdb_nodejs-client.timestamp.html @@ -1,2 +1,2 @@ timestamp | QuestDB JavaScript Client - v4.2.0
    +

    Type Parameters

    Parameters

    Returns QwpWriterColumn<TimestampInput<Unit>>

    diff --git a/docs/functions/_questdb_nodejs-client.utf8Length.html b/docs/functions/_questdb_nodejs-client.utf8Length.html index 1bca6b3..9716279 100644 --- a/docs/functions/_questdb_nodejs-client.utf8Length.html +++ b/docs/functions/_questdb_nodejs-client.utf8Length.html @@ -1 +1 @@ -utf8Length | QuestDB JavaScript Client - v4.2.0
    +utf8Length | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/functions/_questdb_nodejs-client.uuid.html b/docs/functions/_questdb_nodejs-client.uuid.html index 5add3be..aae7349 100644 --- a/docs/functions/_questdb_nodejs-client.uuid.html +++ b/docs/functions/_questdb_nodejs-client.uuid.html @@ -1,2 +1,2 @@ uuid | QuestDB JavaScript Client - v4.2.0
    +

    Returns QwpWriterColumn<QwpUuidInput>

    diff --git a/docs/functions/_questdb_nodejs-client.varchar.html b/docs/functions/_questdb_nodejs-client.varchar.html index 0644c23..4d4dca4 100644 --- a/docs/functions/_questdb_nodejs-client.varchar.html +++ b/docs/functions/_questdb_nodejs-client.varchar.html @@ -1,2 +1,2 @@ varchar | QuestDB JavaScript Client - v4.2.0
    +

    Returns QwpWriterColumn<string>

    diff --git a/docs/functions/_questdb_nodejs-client.writeQwpFrameHeader.html b/docs/functions/_questdb_nodejs-client.writeQwpFrameHeader.html index fa7688e..ce3a7b6 100644 --- a/docs/functions/_questdb_nodejs-client.writeQwpFrameHeader.html +++ b/docs/functions/_questdb_nodejs-client.writeQwpFrameHeader.html @@ -1 +1 @@ -writeQwpFrameHeader | QuestDB JavaScript Client - v4.2.0

    Function writeQwpFrameHeader

    +writeQwpFrameHeader | QuestDB JavaScript Client - v4.2.0

    Function writeQwpFrameHeader

    diff --git a/docs/functions/_questdb_nodejs-client.writeQwpVarint.html b/docs/functions/_questdb_nodejs-client.writeQwpVarint.html index 6d60996..0c97248 100644 --- a/docs/functions/_questdb_nodejs-client.writeQwpVarint.html +++ b/docs/functions/_questdb_nodejs-client.writeQwpVarint.html @@ -1,2 +1,2 @@ writeQwpVarint | QuestDB JavaScript Client - v4.2.0
    +

    Parameters

    Returns void

    diff --git a/docs/hierarchy.html b/docs/hierarchy.html index 4369450..0b89926 100644 --- a/docs/hierarchy.html +++ b/docs/hierarchy.html @@ -1 +1 @@ -QuestDB JavaScript Client - v4.2.0

    QuestDB JavaScript Client - v4.2.0

    Hierarchy Summary

    +QuestDB JavaScript Client - v4.2.0

    QuestDB JavaScript Client - v4.2.0

    Hierarchy Summary

    diff --git a/docs/index.html b/docs/index.html index 337c54d..edc7a5e 100644 --- a/docs/index.html +++ b/docs/index.html @@ -20,7 +20,7 @@

    Returns Promise<void>

    diff --git a/docs/interfaces/_questdb_browser-client.QwpIngressResponse.html b/docs/interfaces/_questdb_browser-client.QwpIngressResponse.html index d6e340c..0859d0e 100644 --- a/docs/interfaces/_questdb_browser-client.QwpIngressResponse.html +++ b/docs/interfaces/_questdb_browser-client.QwpIngressResponse.html @@ -1,5 +1,5 @@ -QwpIngressResponse | QuestDB JavaScript Client - v4.2.0

    Interface QwpIngressResponse

    interface QwpIngressResponse {
        errorMessage?: string;
        sequence: null | bigint;
        status: number;
        tables: QwpIngressTableResult[];
    }
    Index

    Properties

    errorMessage? +QwpIngressResponse | QuestDB JavaScript Client - v4.2.0

    Interface QwpIngressResponse

    interface QwpIngressResponse {
        errorMessage?: string;
        sequence: null | bigint;
        status: number;
        tables: QwpIngressTableResult[];
    }
    Index

    Properties

    errorMessage?: string
    sequence: null | bigint
    status: number
    +

    Properties

    errorMessage?: string
    sequence: null | bigint
    status: number
    diff --git a/docs/interfaces/_questdb_browser-client.QwpIngressSendResult.html b/docs/interfaces/_questdb_browser-client.QwpIngressSendResult.html index 6cf6192..54ae0bf 100644 --- a/docs/interfaces/_questdb_browser-client.QwpIngressSendResult.html +++ b/docs/interfaces/_questdb_browser-client.QwpIngressSendResult.html @@ -2,10 +2,10 @@ completion. Publication resolves after every physical frame belonging to the logical batch has been accepted by the connection. For persistent Node transports that means the frames are durable in the replay journal.

    -
    interface QwpIngressSendResult {
        acknowledgement: Promise<QwpIngressResponse>;
        publication: Promise<void>;
        sequence: bigint;
    }
    Index

    Properties

    interface QwpIngressSendResult {
        acknowledgement: Promise<QwpIngressResponse>;
        publication: Promise<void>;
        sequence: bigint;
    }
    Index

    Properties

    acknowledgement: Promise<QwpIngressResponse>

    Cumulative server response for every frame in the logical batch.

    -
    publication: Promise<void>

    Local transport/journal ownership boundary.

    -
    sequence: bigint

    Last client-session sequence allocated to this logical batch.

    -
    +
    publication: Promise<void>

    Local transport/journal ownership boundary.

    +
    sequence: bigint

    Last client-session sequence allocated to this logical batch.

    +
    diff --git a/docs/interfaces/_questdb_browser-client.QwpIngressSessionOptions.html b/docs/interfaces/_questdb_browser-client.QwpIngressSessionOptions.html index 2bdd0fd..b21ce78 100644 --- a/docs/interfaces/_questdb_browser-client.QwpIngressSessionOptions.html +++ b/docs/interfaces/_questdb_browser-client.QwpIngressSessionOptions.html @@ -1,4 +1,4 @@ -QwpIngressSessionOptions | QuestDB JavaScript Client - v4.2.0

    Interface QwpIngressSessionOptions

    interface QwpIngressSessionOptions {
        ackTimeoutMs?: number;
        backgroundStoreAndForward?: boolean;
        catchUpCapGapMinEscalationWindowMs?: number;
        connectionListenerInboxCapacity?: number;
        durableAckKeepaliveMs?: number;
        errorInboxCapacity?: number;
        initialConnectMode?: QwpInitialConnectMode;
        maxBatchSizeBytes?: number;
        memoryReplayAppendDeadlineMs?: number;
        memoryReplayMaxBytes?: number;
        onDurableAck?: (response: QwpIngressResponse) => void;
        onError?: (event: QwpIngressErrorEvent) => void;
        onProgress?: (event: QwpIngressProgressEvent) => void;
        onResponse?: (response: QwpIngressResponse) => void;
        onSenderError?: (error: QwpSenderError) => void;
        orphanDurableAckMismatchMaxDurationMs?: number;
        orphanStoreAndForward?: boolean;
        reconnect?: false | QwpReconnectOptions;
        replayStore?: QwpIngressReplayStore;
    }
    Index

    Properties

    ackTimeoutMs? +QwpIngressSessionOptions | QuestDB JavaScript Client - v4.2.0

    Interface QwpIngressSessionOptions

    interface QwpIngressSessionOptions {
        ackTimeoutMs?: number;
        backgroundStoreAndForward?: boolean;
        catchUpCapGapMinEscalationWindowMs?: number;
        connectionListenerInboxCapacity?: number;
        durableAckKeepaliveMs?: number;
        errorInboxCapacity?: number;
        initialConnectMode?: QwpInitialConnectMode;
        maxBatchSizeBytes?: number;
        memoryReplayAppendDeadlineMs?: number;
        memoryReplayMaxBytes?: number;
        onDurableAck?: (response: QwpIngressResponse) => void;
        onError?: (event: QwpIngressErrorEvent) => void;
        onProgress?: (event: QwpIngressProgressEvent) => void;
        onResponse?: (response: QwpIngressResponse) => void;
        onSenderError?: (error: QwpSenderError) => void;
        orphanDurableAckMismatchMaxDurationMs?: number;
        orphanStoreAndForward?: boolean;
        reconnect?: false | QwpReconnectOptions;
        replayStore?: QwpIngressReplayStore;
    }
    Index

    Properties

    ackTimeoutMs?: number
    backgroundStoreAndForward?: boolean

    Starts memory or persistent replay without waiting for a server.

    -
    catchUpCapGapMinEscalationWindowMs?: number

    Minimum cap-gap dwell before an orphan can be quarantined.

    -
    connectionListenerInboxCapacity?: number

    Bounded reconnect-listener inbox. Oldest pending events are dropped when +

    Properties

    ackTimeoutMs?: number
    backgroundStoreAndForward?: boolean

    Starts memory or persistent replay without waiting for a server.

    +
    catchUpCapGapMinEscalationWindowMs?: number

    Minimum cap-gap dwell before an orphan can be quarantined.

    +
    connectionListenerInboxCapacity?: number

    Bounded reconnect-listener inbox. Oldest pending events are dropped when full. Defaults to 64, matching the Java client.

    -
    durableAckKeepaliveMs?: number

    Enables durable-ACK tracking. While committed table transactions await +

    durableAckKeepaliveMs?: number

    Enables durable-ACK tracking. While committed table transactions await durable upload, Node transports send WebSocket PING frames and browser transports send table-less QWP poll frames. Zero keeps tracking enabled but disables automatic polling. Factory-created browser sessions require requestDurableAck=true when this option is supplied.

    -
    errorInboxCapacity?: number

    Bounded typed/legacy error inbox. Oldest pending errors are dropped when +

    errorInboxCapacity?: number

    Bounded typed/legacy error inbox. Oldest pending errors are dropped when full. Defaults to 256, matching the Java client.

    -
    initialConnectMode?: QwpInitialConnectMode

    Initial connection policy supplied by the Node adapter.

    -
    maxBatchSizeBytes?: number

    Optional local ingress frame cap. Browsers cannot read WebSocket upgrade +

    initialConnectMode?: QwpInitialConnectMode

    Initial connection policy supplied by the Node adapter.

    +
    maxBatchSizeBytes?: number

    Optional local ingress frame cap. Browsers cannot read WebSocket upgrade headers, so browser applications should set this to the server's configured QWP cap. When the server also advertises a cap, the smaller value wins. Table batches are split at row boundaries automatically; an individual row that cannot fit is rejected with QwpBatchTooLargeError before it is sent.

    -
    memoryReplayAppendDeadlineMs?: number

    Maximum time a memory replay append waits for ACK-driven trimming after +

    memoryReplayAppendDeadlineMs?: number

    Maximum time a memory replay append waits for ACK-driven trimming after reaching memoryReplayMaxBytes. Defaults to 30 seconds.

    -
    memoryReplayMaxBytes?: number

    Hard cap for the built-in memory-only replay queue, including estimated +

    memoryReplayMaxBytes?: number

    Hard cap for the built-in memory-only replay queue, including estimated per-frame bookkeeping. Defaults to 128 MiB. This applies in browsers and non-persistent Node sessions; custom replay stores enforce their own cap.

    -
    onDurableAck?: (response: QwpIngressResponse) => void
    onError?: (event: QwpIngressErrorEvent) => void

    Server rejections, deadlines, and terminal session failures.

    -
    onProgress?: (event: QwpIngressProgressEvent) => void

    Monotonic send/accept/durability notifications. Callback errors are ignored.

    -
    onResponse?: (response: QwpIngressResponse) => void
    onSenderError?: (error: QwpSenderError) => void

    Java-parity typed server-rejection and data-loss notifications. When +

    onDurableAck?: (response: QwpIngressResponse) => void
    onError?: (event: QwpIngressErrorEvent) => void

    Server rejections, deadlines, and terminal session failures.

    +
    onProgress?: (event: QwpIngressProgressEvent) => void

    Monotonic send/accept/durability notifications. Callback errors are ignored.

    +
    onResponse?: (response: QwpIngressResponse) => void
    onSenderError?: (error: QwpSenderError) => void

    Java-parity typed server-rejection and data-loss notifications. When omitted, the default handler logs retriable errors at warn and terminal errors or abandoned data at error.

    -
    orphanDurableAckMismatchMaxDurationMs?: number

    Consecutive durable-ACK gap budget retained for orphan SF.

    -
    orphanStoreAndForward?: boolean

    Orphan sessions may quarantine persistent catch-up cap gaps.

    -
    reconnect?: false | QwpReconnectOptions

    Bounded reconnection and at-least-once replay policy. Reconnection is +

    orphanDurableAckMismatchMaxDurationMs?: number

    Consecutive durable-ACK gap budget retained for orphan SF.

    +
    orphanStoreAndForward?: boolean

    Orphan sessions may quarantine persistent catch-up cap gaps.

    +
    reconnect?: false | QwpReconnectOptions

    Bounded reconnection and at-least-once replay policy. Reconnection is enabled by default for factory-created sessions; set false to keep one fixed connection. Browser and non-persistent Node replay is memory-only.

    An ACK lost during disconnect can cause a frame to be replayed after the server accepted it; configure server-side deduplication when duplicates are not acceptable.

    -
    replayStore?: QwpIngressReplayStore

    Node adapter hook for persistent store-and-forward.

    -
    +
    replayStore?: QwpIngressReplayStore

    Node adapter hook for persistent store-and-forward.

    +
    diff --git a/docs/interfaces/_questdb_browser-client.QwpIngressSymbolDictionaryDelta.html b/docs/interfaces/_questdb_browser-client.QwpIngressSymbolDictionaryDelta.html index 91065e4..58bbf3d 100644 --- a/docs/interfaces/_questdb_browser-client.QwpIngressSymbolDictionaryDelta.html +++ b/docs/interfaces/_questdb_browser-client.QwpIngressSymbolDictionaryDelta.html @@ -1,3 +1,3 @@ -QwpIngressSymbolDictionaryDelta | QuestDB JavaScript Client - v4.2.0

    Interface QwpIngressSymbolDictionaryDelta

    interface QwpIngressSymbolDictionaryDelta {
        entries: readonly string[];
        startId: number;
    }
    Index

    Properties

    entries +QwpIngressSymbolDictionaryDelta | QuestDB JavaScript Client - v4.2.0

    Interface QwpIngressSymbolDictionaryDelta

    interface QwpIngressSymbolDictionaryDelta {
        entries: readonly string[];
        startId: number;
    }
    Index

    Properties

    Properties

    entries: readonly string[]
    startId: number
    +

    Properties

    entries: readonly string[]
    startId: number
    diff --git a/docs/interfaces/_questdb_browser-client.QwpIngressTableResult.html b/docs/interfaces/_questdb_browser-client.QwpIngressTableResult.html index 3d6f24e..4888df7 100644 --- a/docs/interfaces/_questdb_browser-client.QwpIngressTableResult.html +++ b/docs/interfaces/_questdb_browser-client.QwpIngressTableResult.html @@ -1,3 +1,3 @@ -QwpIngressTableResult | QuestDB JavaScript Client - v4.2.0

    Interface QwpIngressTableResult

    interface QwpIngressTableResult {
        name: string;
        sequenceTransaction: bigint;
    }
    Index

    Properties

    name +QwpIngressTableResult | QuestDB JavaScript Client - v4.2.0

    Interface QwpIngressTableResult

    interface QwpIngressTableResult {
        name: string;
        sequenceTransaction: bigint;
    }
    Index

    Properties

    name: string
    sequenceTransaction: bigint
    +

    Properties

    name: string
    sequenceTransaction: bigint
    diff --git a/docs/interfaces/_questdb_browser-client.QwpIngressTransportMetrics.html b/docs/interfaces/_questdb_browser-client.QwpIngressTransportMetrics.html index cbfdfd7..71cb1af 100644 --- a/docs/interfaces/_questdb_browser-client.QwpIngressTransportMetrics.html +++ b/docs/interfaces/_questdb_browser-client.QwpIngressTransportMetrics.html @@ -1,5 +1,5 @@ QwpIngressTransportMetrics | QuestDB JavaScript Client - v4.2.0

    Interface QwpIngressTransportMetrics

    Physical ingress delivery counters maintained by reconnecting transports.

    -
    interface QwpIngressTransportMetrics {
        acknowledgedFrameSequence: bigint;
        deliveredConnectionNotifications?: number;
        deliveredErrorNotifications?: number;
        droppedConnectionNotifications?: number;
        droppedErrorNotifications?: number;
        memoryReplayMaxBytes?: number;
        memoryReplayUsedBytes?: number;
        pendingReplayBytes: number;
        pendingReplayFrames: number;
        publishedFrameSequence: bigint;
        totalBytesReplayed: number;
        totalBytesSent: number;
        totalFailovers: number;
        totalFramesReplayed: number;
        totalFramesSent: number;
        totalMemoryReplayAppendTimeouts: number;
        totalMemoryReplayBackpressureStalls: number;
        totalReconnectAttempts: number;
        totalReconnectErrors: number;
        totalReconnectsSucceeded: number;
        totalServerNacks: number;
        waitingMemoryReplayAppends: number;
    }
    Index

    Properties

    interface QwpIngressTransportMetrics {
        acknowledgedFrameSequence: bigint;
        deliveredConnectionNotifications?: number;
        deliveredErrorNotifications?: number;
        droppedConnectionNotifications?: number;
        droppedErrorNotifications?: number;
        memoryReplayMaxBytes?: number;
        memoryReplayUsedBytes?: number;
        pendingReplayBytes: number;
        pendingReplayFrames: number;
        publishedFrameSequence: bigint;
        totalBytesReplayed: number;
        totalBytesSent: number;
        totalFailovers: number;
        totalFramesReplayed: number;
        totalFramesSent: number;
        totalMemoryReplayAppendTimeouts: number;
        totalMemoryReplayBackpressureStalls: number;
        totalReconnectAttempts: number;
        totalReconnectErrors: number;
        totalReconnectsSucceeded: number;
        totalServerNacks: number;
        waitingMemoryReplayAppends: number;
    }
    Index

    Properties

    acknowledgedFrameSequence: bigint

    Highest replay-frame sequence removed from store-and-forward.

    -
    deliveredConnectionNotifications?: number
    deliveredErrorNotifications?: number
    droppedConnectionNotifications?: number
    droppedErrorNotifications?: number
    memoryReplayMaxBytes?: number

    Configured cap for the built-in memory replay store.

    -
    memoryReplayUsedBytes?: number

    Estimated payload and record-bookkeeping bytes charged to that cap.

    -
    pendingReplayBytes: number
    pendingReplayFrames: number
    publishedFrameSequence: bigint

    Highest stable replay-frame sequence handed to the transport.

    -
    totalBytesReplayed: number
    totalBytesSent: number
    totalFailovers: number
    totalFramesReplayed: number
    totalFramesSent: number

    Physical WebSocket sends, including replay and dictionary catch-up.

    -
    totalMemoryReplayAppendTimeouts: number
    totalMemoryReplayBackpressureStalls: number
    totalReconnectAttempts: number
    totalReconnectErrors: number
    totalReconnectsSucceeded: number
    totalServerNacks: number
    waitingMemoryReplayAppends: number
    +
    deliveredConnectionNotifications?: number
    deliveredErrorNotifications?: number
    droppedConnectionNotifications?: number
    droppedErrorNotifications?: number
    memoryReplayMaxBytes?: number

    Configured cap for the built-in memory replay store.

    +
    memoryReplayUsedBytes?: number

    Estimated payload and record-bookkeeping bytes charged to that cap.

    +
    pendingReplayBytes: number
    pendingReplayFrames: number
    publishedFrameSequence: bigint

    Highest stable replay-frame sequence handed to the transport.

    +
    totalBytesReplayed: number
    totalBytesSent: number
    totalFailovers: number
    totalFramesReplayed: number
    totalFramesSent: number

    Physical WebSocket sends, including replay and dictionary catch-up.

    +
    totalMemoryReplayAppendTimeouts: number
    totalMemoryReplayBackpressureStalls: number
    totalReconnectAttempts: number
    totalReconnectErrors: number
    totalReconnectsSucceeded: number
    totalServerNacks: number
    waitingMemoryReplayAppends: number
    diff --git a/docs/interfaces/_questdb_browser-client.QwpLong256Value.html b/docs/interfaces/_questdb_browser-client.QwpLong256Value.html index ad486b6..1a0103f 100644 --- a/docs/interfaces/_questdb_browser-client.QwpLong256Value.html +++ b/docs/interfaces/_questdb_browser-client.QwpLong256Value.html @@ -1,3 +1,3 @@ -QwpLong256Value | QuestDB JavaScript Client - v4.2.0
    interface QwpLong256Value {
        words: readonly [bigint, bigint, bigint, bigint];
    }
    Index

    Properties

    words +QwpLong256Value | QuestDB JavaScript Client - v4.2.0
    interface QwpLong256Value {
        words: readonly [bigint, bigint, bigint, bigint];
    }
    Index

    Properties

    Properties

    words: readonly [bigint, bigint, bigint, bigint]

    Little-endian 64-bit words; word 0 is least significant.

    -
    +
    diff --git a/docs/interfaces/_questdb_browser-client.QwpPoolSlotReservation.html b/docs/interfaces/_questdb_browser-client.QwpPoolSlotReservation.html index 36bef4c..2294d0a 100644 --- a/docs/interfaces/_questdb_browser-client.QwpPoolSlotReservation.html +++ b/docs/interfaces/_questdb_browser-client.QwpPoolSlotReservation.html @@ -1,5 +1,5 @@ QwpPoolSlotReservation | QuestDB JavaScript Client - v4.2.0

    Interface QwpPoolSlotReservationInternal

    Cross-owner reservation for stable pooled sender slot indexes.

    -
    interface QwpPoolSlotReservation {
        onAvailable(listener: () => void): () => void;
        release(slot: number): void;
        tryReserve(slot: number): boolean;
    }
    Index

    Methods

    interface QwpPoolSlotReservation {
        onAvailable(listener: () => void): () => void;
        release(slot: number): void;
        tryReserve(slot: number): boolean;
    }
    Index

    Methods

    +

    Methods

    diff --git a/docs/interfaces/_questdb_browser-client.QwpQueryErrorMessage.html b/docs/interfaces/_questdb_browser-client.QwpQueryErrorMessage.html index 28fc249..94c44dc 100644 --- a/docs/interfaces/_questdb_browser-client.QwpQueryErrorMessage.html +++ b/docs/interfaces/_questdb_browser-client.QwpQueryErrorMessage.html @@ -1,4 +1,4 @@ -QwpQueryErrorMessage | QuestDB JavaScript Client - v4.2.0

    Interface QwpQueryErrorMessage

    interface QwpQueryErrorMessage {
        flags: number;
        kind: "query-error";
        message: string;
        payloadLength: number;
        requestId: bigint;
        status: number;
        tableCount: number;
        version: number;
    }

    Hierarchy (View Summary)

    Index

    Properties

    flags +QwpQueryErrorMessage | QuestDB JavaScript Client - v4.2.0

    Interface QwpQueryErrorMessage

    interface QwpQueryErrorMessage {
        flags: number;
        kind: "query-error";
        message: string;
        payloadLength: number;
        requestId: bigint;
        status: number;
        tableCount: number;
        version: number;
    }

    Hierarchy (View Summary)

    Index

    Properties

    flags: number
    kind: "query-error"
    message: string
    payloadLength: number
    requestId: bigint
    status: number
    tableCount: number
    version: number
    +

    Properties

    flags: number
    kind: "query-error"
    message: string
    payloadLength: number
    requestId: bigint
    status: number
    tableCount: number
    version: number
    diff --git a/docs/interfaces/_questdb_browser-client.QwpQueryRequest.html b/docs/interfaces/_questdb_browser-client.QwpQueryRequest.html index ed7f7be..c199f9f 100644 --- a/docs/interfaces/_questdb_browser-client.QwpQueryRequest.html +++ b/docs/interfaces/_questdb_browser-client.QwpQueryRequest.html @@ -1,4 +1,4 @@ -QwpQueryRequest | QuestDB JavaScript Client - v4.2.0
    interface QwpQueryRequest {
        bindCount?: number;
        bindPayload?: Uint8Array<ArrayBufferLike>;
        binds?: QwpBindSetter;
        initialCredit?: number | bigint;
        queryFlags?: number | bigint;
        requestId: number | bigint;
        sql: string;
    }
    Index

    Properties

    bindCount? +QwpQueryRequest | QuestDB JavaScript Client - v4.2.0
    interface QwpQueryRequest {
        bindCount?: number;
        bindPayload?: Uint8Array<ArrayBufferLike>;
        binds?: QwpBindSetter;
        initialCredit?: number | bigint;
        queryFlags?: number | bigint;
        requestId: number | bigint;
        sql: string;
    }
    Index

    Properties

    bindCount?: number

    Advanced escape hatch for an already encoded bind section.

    -
    bindPayload?: Uint8Array<ArrayBufferLike>

    Advanced escape hatch for an already encoded bind section.

    -

    Browser-safe typed positional binds.

    -
    initialCredit?: number | bigint

    Zero means unbounded.

    -
    queryFlags?: number | bigint

    Append only after SERVER_INFO advertises QUERY_FLAGS.

    -
    requestId: number | bigint
    sql: string
    +
    bindPayload?: Uint8Array<ArrayBufferLike>

    Advanced escape hatch for an already encoded bind section.

    +

    Browser-safe typed positional binds.

    +
    initialCredit?: number | bigint

    Zero means unbounded.

    +
    queryFlags?: number | bigint

    Append only after SERVER_INFO advertises QUERY_FLAGS.

    +
    requestId: number | bigint
    sql: string
    diff --git a/docs/interfaces/_questdb_browser-client.QwpReconnectEvent.html b/docs/interfaces/_questdb_browser-client.QwpReconnectEvent.html index 2b46bdc..3c4ceef 100644 --- a/docs/interfaces/_questdb_browser-client.QwpReconnectEvent.html +++ b/docs/interfaces/_questdb_browser-client.QwpReconnectEvent.html @@ -1,4 +1,4 @@ -QwpReconnectEvent | QuestDB JavaScript Client - v4.2.0

    Interface QwpReconnectEvent

    interface QwpReconnectEvent {
        attempt: number;
        cause?: unknown;
        endpoint?: string | URL;
        episodeMs?: number;
        kind: QwpReconnectEventKind;
        previousEndpoint?: string | URL;
        timestampMs: number;
    }
    Index

    Properties

    attempt +QwpReconnectEvent | QuestDB JavaScript Client - v4.2.0

    Interface QwpReconnectEvent

    interface QwpReconnectEvent {
        attempt: number;
        cause?: unknown;
        endpoint?: string | URL;
        episodeMs?: number;
        kind: QwpReconnectEventKind;
        previousEndpoint?: string | URL;
        timestampMs: number;
    }
    Index

    Properties

    attempt: number

    One-based reconnect sweep number; zero for lifecycle-only events.

    -
    cause?: unknown
    endpoint?: string | URL
    episodeMs?: number

    Elapsed time in the current consecutive capability-gap episode.

    -
    previousEndpoint?: string | URL
    timestampMs: number
    +
    cause?: unknown
    endpoint?: string | URL
    episodeMs?: number

    Elapsed time in the current consecutive capability-gap episode.

    +
    previousEndpoint?: string | URL
    timestampMs: number
    diff --git a/docs/interfaces/_questdb_browser-client.QwpReconnectOptions.html b/docs/interfaces/_questdb_browser-client.QwpReconnectOptions.html index 04a4863..a74d6b2 100644 --- a/docs/interfaces/_questdb_browser-client.QwpReconnectOptions.html +++ b/docs/interfaces/_questdb_browser-client.QwpReconnectOptions.html @@ -1,4 +1,4 @@ -QwpReconnectOptions | QuestDB JavaScript Client - v4.2.0

    Interface QwpReconnectOptions

    interface QwpReconnectOptions {
        initialBackoffMs?: number;
        maxAttempts?: number;
        maxBackoffMs?: number;
        maxDurationMs?: number;
        maxFrameRejections?: number;
        onEvent?: (event: QwpReconnectEvent) => void;
        poisonMinEscalationWindowMs?: number;
    }
    Index

    Properties

    initialBackoffMs? +QwpReconnectOptions | QuestDB JavaScript Client - v4.2.0

    Interface QwpReconnectOptions

    interface QwpReconnectOptions {
        initialBackoffMs?: number;
        maxAttempts?: number;
        maxBackoffMs?: number;
        maxDurationMs?: number;
        maxFrameRejections?: number;
        onEvent?: (event: QwpReconnectEvent) => void;
        poisonMinEscalationWindowMs?: number;
    }
    Index

    Properties

    initialBackoffMs?: number

    Full-jitter ceiling before the first failed sweep is retried. Defaults to 100ms.

    -
    maxAttempts?: number

    Maximum connection sweeps per outage. Defaults to 3; zero is unlimited.

    -
    maxBackoffMs?: number

    Full-jitter exponential-backoff ceiling. Defaults to 5s.

    -
    maxDurationMs?: number

    Total reconnect deadline. Defaults to 30s; zero disables the deadline.

    -
    maxFrameRejections?: number

    Consecutive retriable rejections of one ingress frame before it is treated +

    maxAttempts?: number

    Maximum connection sweeps per outage. Defaults to 3; zero is unlimited.

    +
    maxBackoffMs?: number

    Full-jitter exponential-backoff ceiling. Defaults to 5s.

    +
    maxDurationMs?: number

    Total reconnect deadline. Defaults to 30s; zero disables the deadline.

    +
    maxFrameRejections?: number

    Consecutive retriable rejections of one ingress frame before it is treated as poison and retained for inspection. Defaults to 4.

    -
    onEvent?: (event: QwpReconnectEvent) => void
    poisonMinEscalationWindowMs?: number

    Minimum time the same ingress frame must remain suspect before repeated +

    onEvent?: (event: QwpReconnectEvent) => void
    poisonMinEscalationWindowMs?: number

    Minimum time the same ingress frame must remain suspect before repeated rejections or non-orderly closes become terminal. Defaults to 5s; zero escalates as soon as maxFrameRejections is reached.

    -
    +
    diff --git a/docs/interfaces/_questdb_browser-client.QwpResourcePoolMetrics.html b/docs/interfaces/_questdb_browser-client.QwpResourcePoolMetrics.html index 58c65e9..dd600b6 100644 --- a/docs/interfaces/_questdb_browser-client.QwpResourcePoolMetrics.html +++ b/docs/interfaces/_questdb_browser-client.QwpResourcePoolMetrics.html @@ -1,8 +1,8 @@ -QwpResourcePoolMetrics | QuestDB JavaScript Client - v4.2.0

    Interface QwpResourcePoolMetrics

    interface QwpResourcePoolMetrics {
        available: number;
        creating: number;
        leased: number;
        maximum: number;
        minimum: number;
        total: number;
        waiting: number;
    }
    Index

    Properties

    available +QwpResourcePoolMetrics | QuestDB JavaScript Client - v4.2.0

    Interface QwpResourcePoolMetrics

    interface QwpResourcePoolMetrics {
        available: number;
        creating: number;
        leased: number;
        maximum: number;
        minimum: number;
        total: number;
        waiting: number;
    }
    Index

    Properties

    available: number
    creating: number
    leased: number
    maximum: number
    minimum: number
    total: number
    waiting: number
    +

    Properties

    available: number
    creating: number
    leased: number
    maximum: number
    minimum: number
    total: number
    waiting: number
    diff --git a/docs/interfaces/_questdb_browser-client.QwpResultArrayValue.html b/docs/interfaces/_questdb_browser-client.QwpResultArrayValue.html index 8d6dcc0..91f03e3 100644 --- a/docs/interfaces/_questdb_browser-client.QwpResultArrayValue.html +++ b/docs/interfaces/_questdb_browser-client.QwpResultArrayValue.html @@ -1,3 +1,3 @@ -QwpResultArrayValue | QuestDB JavaScript Client - v4.2.0

    Interface QwpResultArrayValue

    interface QwpResultArrayValue {
        dimensions: readonly number[];
        values: readonly number[] | readonly bigint[];
    }
    Index

    Properties

    dimensions +QwpResultArrayValue | QuestDB JavaScript Client - v4.2.0

    Interface QwpResultArrayValue

    interface QwpResultArrayValue {
        dimensions: readonly number[];
        values: readonly number[] | readonly bigint[];
    }
    Index

    Properties

    Properties

    dimensions: readonly number[]
    values: readonly number[] | readonly bigint[]
    +

    Properties

    dimensions: readonly number[]
    values: readonly number[] | readonly bigint[]
    diff --git a/docs/interfaces/_questdb_browser-client.QwpResultBatchMessage.html b/docs/interfaces/_questdb_browser-client.QwpResultBatchMessage.html index 9cc1f0d..4981e5e 100644 --- a/docs/interfaces/_questdb_browser-client.QwpResultBatchMessage.html +++ b/docs/interfaces/_questdb_browser-client.QwpResultBatchMessage.html @@ -1,4 +1,4 @@ -QwpResultBatchMessage | QuestDB JavaScript Client - v4.2.0

    Interface QwpResultBatchMessage

    interface QwpResultBatchMessage {
        batchSequence: bigint;
        body: Uint8Array;
        flags: number;
        kind: "result-batch";
        payloadLength: number;
        requestId: bigint;
        tableCount: number;
        version: number;
    }

    Hierarchy (View Summary)

    Index

    Properties

    batchSequence +QwpResultBatchMessage | QuestDB JavaScript Client - v4.2.0

    Interface QwpResultBatchMessage

    interface QwpResultBatchMessage {
        batchSequence: bigint;
        body: Uint8Array;
        flags: number;
        kind: "result-batch";
        payloadLength: number;
        requestId: bigint;
        tableCount: number;
        version: number;
    }

    Hierarchy (View Summary)

    Index

    Properties

    batchSequence: bigint
    body: Uint8Array

    Raw or Zstd-compressed delta dictionary and columnar table block; decoded +

    Properties

    batchSequence: bigint
    body: Uint8Array

    Raw or Zstd-compressed delta dictionary and columnar table block; decoded by the batch decoder according to the frame flags.

    -
    flags: number
    kind: "result-batch"
    payloadLength: number
    requestId: bigint
    tableCount: number
    version: number
    +
    flags: number
    kind: "result-batch"
    payloadLength: number
    requestId: bigint
    tableCount: number
    version: number
    diff --git a/docs/interfaces/_questdb_browser-client.QwpResultColumn.html b/docs/interfaces/_questdb_browser-client.QwpResultColumn.html index c05f025..28e23cc 100644 --- a/docs/interfaces/_questdb_browser-client.QwpResultColumn.html +++ b/docs/interfaces/_questdb_browser-client.QwpResultColumn.html @@ -1,6 +1,6 @@ -QwpResultColumn | QuestDB JavaScript Client - v4.2.0
    interface QwpResultColumn {
        name: string;
        precisionBits?: number;
        scale?: number;
        type: QwpColumnType;
        values: readonly QwpResultValue[];
    }

    Hierarchy (View Summary)

    Index

    Properties

    name +QwpResultColumn | QuestDB JavaScript Client - v4.2.0
    interface QwpResultColumn {
        name: string;
        precisionBits?: number;
        scale?: number;
        type: QwpColumnType;
        values: readonly QwpResultValue[];
    }

    Hierarchy (View Summary)

    Index

    Properties

    name: string
    precisionBits?: number
    scale?: number
    values: readonly QwpResultValue[]
    +

    Properties

    name: string
    precisionBits?: number
    scale?: number
    values: readonly QwpResultValue[]
    diff --git a/docs/interfaces/_questdb_browser-client.QwpResultColumnSchema.html b/docs/interfaces/_questdb_browser-client.QwpResultColumnSchema.html index 4e8698a..94009d7 100644 --- a/docs/interfaces/_questdb_browser-client.QwpResultColumnSchema.html +++ b/docs/interfaces/_questdb_browser-client.QwpResultColumnSchema.html @@ -1,3 +1,3 @@ -QwpResultColumnSchema | QuestDB JavaScript Client - v4.2.0

    Interface QwpResultColumnSchema

    interface QwpResultColumnSchema {
        name: string;
        type: QwpColumnType;
    }

    Hierarchy (View Summary)

    Index

    Properties

    name +QwpResultColumnSchema | QuestDB JavaScript Client - v4.2.0

    Interface QwpResultColumnSchema

    interface QwpResultColumnSchema {
        name: string;
        type: QwpColumnType;
    }

    Hierarchy (View Summary)

    Index

    Properties

    Properties

    name: string
    +

    Properties

    name: string
    diff --git a/docs/interfaces/_questdb_browser-client.QwpResultEndMessage.html b/docs/interfaces/_questdb_browser-client.QwpResultEndMessage.html index ae0e630..a0a627a 100644 --- a/docs/interfaces/_questdb_browser-client.QwpResultEndMessage.html +++ b/docs/interfaces/_questdb_browser-client.QwpResultEndMessage.html @@ -1,4 +1,4 @@ -QwpResultEndMessage | QuestDB JavaScript Client - v4.2.0

    Interface QwpResultEndMessage

    interface QwpResultEndMessage {
        finalSequence: bigint;
        flags: number;
        kind: "result-end";
        payloadLength: number;
        requestId: bigint;
        tableCount: number;
        totalRows: bigint;
        version: number;
    }

    Hierarchy (View Summary)

    Index

    Properties

    finalSequence +QwpResultEndMessage | QuestDB JavaScript Client - v4.2.0

    Interface QwpResultEndMessage

    interface QwpResultEndMessage {
        finalSequence: bigint;
        flags: number;
        kind: "result-end";
        payloadLength: number;
        requestId: bigint;
        tableCount: number;
        totalRows: bigint;
        version: number;
    }

    Hierarchy (View Summary)

    Index

    Properties

    finalSequence: bigint
    flags: number
    kind: "result-end"
    payloadLength: number
    requestId: bigint
    tableCount: number
    totalRows: bigint
    version: number
    +

    Properties

    finalSequence: bigint
    flags: number
    kind: "result-end"
    payloadLength: number
    requestId: bigint
    tableCount: number
    totalRows: bigint
    version: number
    diff --git a/docs/interfaces/_questdb_browser-client.QwpSenderEncodeOptions.html b/docs/interfaces/_questdb_browser-client.QwpSenderEncodeOptions.html index bddc67c..1871969 100644 --- a/docs/interfaces/_questdb_browser-client.QwpSenderEncodeOptions.html +++ b/docs/interfaces/_questdb_browser-client.QwpSenderEncodeOptions.html @@ -1,4 +1,4 @@ -QwpSenderEncodeOptions | QuestDB JavaScript Client - v4.2.0

    Interface QwpSenderEncodeOptions

    interface QwpSenderEncodeOptions {
        gorilla?: boolean;
        symbolDictionary?: "delta" | "full";
    }

    Hierarchy

    Index

    Properties

    gorilla? +QwpSenderEncodeOptions | QuestDB JavaScript Client - v4.2.0

    Interface QwpSenderEncodeOptions

    interface QwpSenderEncodeOptions {
        gorilla?: boolean;
        symbolDictionary?: "delta" | "full";
    }

    Hierarchy

    Index

    Properties

    gorilla?: boolean
    symbolDictionary?: "delta" | "full"

    Connection-scoped deltas are the default; use full to opt out.

    -
    +

    Properties

    gorilla?: boolean
    symbolDictionary?: "delta" | "full"

    Connection-scoped deltas are the default; use full to opt out.

    +
    diff --git a/docs/interfaces/_questdb_browser-client.QwpSenderError.html b/docs/interfaces/_questdb_browser-client.QwpSenderError.html index b848885..f7949f8 100644 --- a/docs/interfaces/_questdb_browser-client.QwpSenderError.html +++ b/docs/interfaces/_questdb_browser-client.QwpSenderError.html @@ -1,5 +1,5 @@ QwpSenderError | QuestDB JavaScript Client - v4.2.0

    Immutable Java-parity context for an ingress rejection or data loss.

    -
    interface QwpSenderError {
        appliedPolicy: QwpSenderErrorPolicy;
        category: QwpSenderErrorCategory;
        detectedAtMs: number;
        fromFsn?: bigint;
        messageSequence?: bigint;
        quarantinedPath?: string;
        serverMessage?: string;
        serverStatusByte?: number;
        tableName?: string;
        toFsn?: bigint;
    }
    Index

    Properties

    interface QwpSenderError {
        appliedPolicy: QwpSenderErrorPolicy;
        category: QwpSenderErrorCategory;
        detectedAtMs: number;
        fromFsn?: bigint;
        messageSequence?: bigint;
        quarantinedPath?: string;
        serverMessage?: string;
        serverStatusByte?: number;
        tableName?: string;
        toFsn?: bigint;
    }
    Index

    Properties

    appliedPolicy: QwpSenderErrorPolicy
    detectedAtMs: number
    fromFsn?: bigint

    Inclusive stable store-and-forward frame-sequence range.

    -
    messageSequence?: bigint
    quarantinedPath?: string

    Preserved on-disk bytes for a data-loss/quarantine notification.

    -
    serverMessage?: string
    serverStatusByte?: number
    tableName?: string
    toFsn?: bigint
    +

    Properties

    appliedPolicy: QwpSenderErrorPolicy
    detectedAtMs: number
    fromFsn?: bigint

    Inclusive stable store-and-forward frame-sequence range.

    +
    messageSequence?: bigint
    quarantinedPath?: string

    Preserved on-disk bytes for a data-loss/quarantine notification.

    +
    serverMessage?: string
    serverStatusByte?: number
    tableName?: string
    toFsn?: bigint
    diff --git a/docs/interfaces/_questdb_browser-client.QwpSenderErrorResponseContext.html b/docs/interfaces/_questdb_browser-client.QwpSenderErrorResponseContext.html index fd235f6..5acb549 100644 --- a/docs/interfaces/_questdb_browser-client.QwpSenderErrorResponseContext.html +++ b/docs/interfaces/_questdb_browser-client.QwpSenderErrorResponseContext.html @@ -1,7 +1,7 @@ -QwpSenderErrorResponseContext | QuestDB JavaScript Client - v4.2.0

    Interface QwpSenderErrorResponseContext

    interface QwpSenderErrorResponseContext {
        appliedPolicy?: QwpSenderErrorPolicy;
        detectedAtMs?: number;
        fromFsn?: bigint;
        messageSequence?: bigint;
        tableName?: string;
        toFsn?: bigint;
    }
    Index

    Properties

    appliedPolicy? +QwpSenderErrorResponseContext | QuestDB JavaScript Client - v4.2.0

    Interface QwpSenderErrorResponseContext

    interface QwpSenderErrorResponseContext {
        appliedPolicy?: QwpSenderErrorPolicy;
        detectedAtMs?: number;
        fromFsn?: bigint;
        messageSequence?: bigint;
        tableName?: string;
        toFsn?: bigint;
    }
    Index

    Properties

    appliedPolicy?: QwpSenderErrorPolicy
    detectedAtMs?: number
    fromFsn?: bigint
    messageSequence?: bigint
    tableName?: string
    toFsn?: bigint
    +

    Properties

    appliedPolicy?: QwpSenderErrorPolicy
    detectedAtMs?: number
    fromFsn?: bigint
    messageSequence?: bigint
    tableName?: string
    toFsn?: bigint
    diff --git a/docs/interfaces/_questdb_browser-client.QwpSenderMetrics.html b/docs/interfaces/_questdb_browser-client.QwpSenderMetrics.html index 1d40427..9b51fe3 100644 --- a/docs/interfaces/_questdb_browser-client.QwpSenderMetrics.html +++ b/docs/interfaces/_questdb_browser-client.QwpSenderMetrics.html @@ -1,5 +1,5 @@ QwpSenderMetrics | QuestDB JavaScript Client - v4.2.0

    Interface QwpSenderMetrics

    Immutable high-level sender counters plus the active ingress snapshot.

    -
    interface QwpSenderMetrics {
        autoFlushBytes: number;
        closed: boolean;
        closing: boolean;
        connected: boolean;
        deferredRows: number;
        effectiveAutoFlushBytes: number;
        ingress?: QwpIngressMetrics;
        pendingBytes: number;
        pendingRows: number;
        totalFlushes: number;
        totalFlushFailures: number;
        totalRowsPublished: number;
        totalRowsStaged: number;
        totalTransactionsCommitted: number;
    }
    Index

    Properties

    interface QwpSenderMetrics {
        autoFlushBytes: number;
        closed: boolean;
        closing: boolean;
        connected: boolean;
        deferredRows: number;
        effectiveAutoFlushBytes: number;
        ingress?: QwpIngressMetrics;
        pendingBytes: number;
        pendingRows: number;
        totalFlushes: number;
        totalFlushFailures: number;
        totalRowsPublished: number;
        totalRowsStaged: number;
        totalTransactionsCommitted: number;
    }
    Index

    Properties

    autoFlushBytes: number
    closed: boolean
    closing: boolean
    connected: boolean
    deferredRows: number
    effectiveAutoFlushBytes: number
    pendingBytes: number

    Estimated raw column-buffer bytes currently staged.

    -
    pendingRows: number
    totalFlushes: number
    totalFlushFailures: number
    totalRowsPublished: number

    Rows whose encoded frames have entered the ingress session.

    -
    totalRowsStaged: number
    totalTransactionsCommitted: number
    +

    Properties

    autoFlushBytes: number
    closed: boolean
    closing: boolean
    connected: boolean
    deferredRows: number
    effectiveAutoFlushBytes: number
    pendingBytes: number

    Estimated raw column-buffer bytes currently staged.

    +
    pendingRows: number
    totalFlushes: number
    totalFlushFailures: number
    totalRowsPublished: number

    Rows whose encoded frames have entered the ingress session.

    +
    totalRowsStaged: number
    totalTransactionsCommitted: number
    diff --git a/docs/interfaces/_questdb_browser-client.QwpSenderOptions.html b/docs/interfaces/_questdb_browser-client.QwpSenderOptions.html index f89e9a1..597adda 100644 --- a/docs/interfaces/_questdb_browser-client.QwpSenderOptions.html +++ b/docs/interfaces/_questdb_browser-client.QwpSenderOptions.html @@ -1,5 +1,5 @@ QwpSenderOptions | QuestDB JavaScript Client - v4.2.0

    Interface QwpSenderOptions

    Options for the browser-safe, fluent QWP sender.

    -
    interface QwpSenderOptions {
        autoFlush?: boolean;
        autoFlushBytes?: number;
        autoFlushIntervalMs?: number;
        autoFlushRows?: number;
        awaitDurableAck?: boolean;
        awaitServerAck?: boolean;
        closeFlushTimeoutMs?: number;
        durableAckTimeoutMs?: number;
        encode?: QwpSenderEncodeOptions;
        log?: QwpSenderLogger;
        maxNameLength?: number;
        transactional?: boolean;
    }
    Index

    Properties

    interface QwpSenderOptions {
        autoFlush?: boolean;
        autoFlushBytes?: number;
        autoFlushIntervalMs?: number;
        autoFlushRows?: number;
        awaitDurableAck?: boolean;
        awaitServerAck?: boolean;
        closeFlushTimeoutMs?: number;
        durableAckTimeoutMs?: number;
        encode?: QwpSenderEncodeOptions;
        log?: QwpSenderLogger;
        maxNameLength?: number;
        transactional?: boolean;
    }
    Index

    Properties

    autoFlush?: boolean
    autoFlushBytes?: number

    Soft threshold for estimated buffered column bytes. Zero disables the byte +

    Properties

    autoFlush?: boolean
    autoFlushBytes?: number

    Soft threshold for estimated buffered column bytes. Zero disables the byte trigger. Defaults to zero and is clamped below a connected server's batch cap; exact encoded frames remain subject to the protocol batch limit.

    -
    autoFlushIntervalMs?: number
    autoFlushRows?: number
    awaitDurableAck?: boolean

    Wait for durable upload after every successful ingress ACK. When true, +

    autoFlushIntervalMs?: number
    autoFlushRows?: number
    awaitDurableAck?: boolean

    Wait for durable upload after every successful ingress ACK. When true, this implies awaitServerAck unless awaitServerAck is explicitly false.

    -
    awaitServerAck?: boolean

    Wait for the server's protocol ACK before flush()/commit() resolves. +

    awaitServerAck?: boolean

    Wait for the server's protocol ACK before flush()/commit() resolves. Defaults to false, matching the Java QWP sender's local-publication boundary. Set this to true for an acknowledgement barrier, or use flushAndGetSequence() followed by waitForAcknowledged().

    -
    closeFlushTimeoutMs?: number

    Maximum time close() spends publishing queued rows and waiting for the +

    closeFlushTimeoutMs?: number

    Maximum time close() spends publishing queued rows and waiting for the server ACK watermark. Zero or a negative value skips the drain. Defaults to 5 seconds.

    -
    durableAckTimeoutMs?: number

    QWP frame encoding options supported by the high-level sender.

    -
    maxNameLength?: number

    Maximum UTF-8 byte length of table and column names. Defaults to 127.

    -
    transactional?: boolean

    Keep auto-flushed rows in an open server-side transaction. An explicit +

    durableAckTimeoutMs?: number

    QWP frame encoding options supported by the high-level sender.

    +
    maxNameLength?: number

    Maximum UTF-8 byte length of table and column names. Defaults to 127.

    +
    transactional?: boolean

    Keep auto-flushed rows in an open server-side transaction. An explicit flush()/commit() closes the transaction. QWP transactions are atomic per table, rather than across every table in a multi-table flush.

    -
    +
    diff --git a/docs/interfaces/_questdb_browser-client.QwpSenderSession.html b/docs/interfaces/_questdb_browser-client.QwpSenderSession.html index c38b35a..55fb1b3 100644 --- a/docs/interfaces/_questdb_browser-client.QwpSenderSession.html +++ b/docs/interfaces/_questdb_browser-client.QwpSenderSession.html @@ -1,5 +1,5 @@ QwpSenderSession | QuestDB JavaScript Client - v4.2.0

    Interface QwpSenderSession

    The subset of QwpIngressSession used by QwpSender.

    -
    interface QwpSenderSession {
        acknowledgedFrameSequence?: bigint;
        maxBatchSizeBytes?: number;
        metrics?: QwpIngressMetrics;
        publishedFrameSequence?: bigint;
        close(code?: number, reason?: string): Promise<void>;
        publishTables(
            tables: readonly QwpTableBuffer[],
            options?: QwpIngressEncodeOptions,
        ): Promise<void>;
        publishTablesDelta(
            tables: readonly QwpTableBuffer[],
            options?: Pick<QwpIngressEncodeOptions, "gorilla" | "deferCommit">,
        ): Promise<void>;
        sendTables(
            tables: readonly QwpTableBuffer[],
            options?: QwpIngressEncodeOptions,
        ): Promise<QwpIngressResponse>;
        sendTablesDelta(
            tables: readonly QwpTableBuffer[],
            options?: Pick<QwpIngressEncodeOptions, "gorilla" | "deferCommit">,
        ): Promise<QwpIngressResponse>;
        sendTablesDeltaWithPublication(
            tables: readonly QwpTableBuffer[],
            options?: Pick<QwpIngressEncodeOptions, "gorilla" | "deferCommit">,
        ): QwpIngressSendResult;
        sendTablesWithPublication(
            tables: readonly QwpTableBuffer[],
            options?: QwpIngressEncodeOptions,
        ): QwpIngressSendResult;
        waitForAcknowledged(
            targetSequence: bigint,
            timeoutMs?: number,
        ): Promise<void>;
        waitForDurable(
            response: QwpIngressResponse,
            timeoutMs?: number,
        ): Promise<void>;
    }
    Index

    Properties

    interface QwpSenderSession {
        acknowledgedFrameSequence?: bigint;
        maxBatchSizeBytes?: number;
        metrics?: QwpIngressMetrics;
        publishedFrameSequence?: bigint;
        close(code?: number, reason?: string): Promise<void>;
        publishTables(
            tables: readonly QwpTableBuffer[],
            options?: QwpIngressEncodeOptions,
        ): Promise<void>;
        publishTablesDelta(
            tables: readonly QwpTableBuffer[],
            options?: Pick<QwpIngressEncodeOptions, "gorilla" | "deferCommit">,
        ): Promise<void>;
        sendTables(
            tables: readonly QwpTableBuffer[],
            options?: QwpIngressEncodeOptions,
        ): Promise<QwpIngressResponse>;
        sendTablesDelta(
            tables: readonly QwpTableBuffer[],
            options?: Pick<QwpIngressEncodeOptions, "gorilla" | "deferCommit">,
        ): Promise<QwpIngressResponse>;
        sendTablesDeltaWithPublication(
            tables: readonly QwpTableBuffer[],
            options?: Pick<QwpIngressEncodeOptions, "gorilla" | "deferCommit">,
        ): QwpIngressSendResult;
        sendTablesWithPublication(
            tables: readonly QwpTableBuffer[],
            options?: QwpIngressEncodeOptions,
        ): QwpIngressSendResult;
        waitForAcknowledged(
            targetSequence: bigint,
            timeoutMs?: number,
        ): Promise<void>;
        waitForDurable(
            response: QwpIngressResponse,
            timeoutMs?: number,
        ): Promise<void>;
    }
    Index

    Properties

    acknowledgedFrameSequence?: bigint
    maxBatchSizeBytes?: number
    publishedFrameSequence?: bigint

    Methods

    +

    Properties

    acknowledgedFrameSequence?: bigint
    maxBatchSizeBytes?: number
    publishedFrameSequence?: bigint

    Methods

    diff --git a/docs/interfaces/_questdb_browser-client.QwpServerInfoMessage.html b/docs/interfaces/_questdb_browser-client.QwpServerInfoMessage.html index 946ea70..906b48c 100644 --- a/docs/interfaces/_questdb_browser-client.QwpServerInfoMessage.html +++ b/docs/interfaces/_questdb_browser-client.QwpServerInfoMessage.html @@ -1,5 +1,5 @@ QwpServerInfoMessage | QuestDB JavaScript Client - v4.2.0

    Interface QwpServerInfoMessage

    Immutable endpoint metadata from the most recent successful egress bind.

    -
    interface QwpServerInfoMessage {
        capabilities: number;
        clusterId: string;
        compressionCodec: null | number;
        compressionLevel: null | number;
        epoch: bigint;
        flags: number;
        kind: "server-info";
        nodeId: string;
        payloadLength: number;
        role: number;
        serverWallNanoseconds: bigint;
        tableCount: number;
        version: number;
        zoneId: null | string;
    }

    Hierarchy (View Summary)

    Index

    Properties

    interface QwpServerInfoMessage {
        capabilities: number;
        clusterId: string;
        compressionCodec: null | number;
        compressionLevel: null | number;
        epoch: bigint;
        flags: number;
        kind: "server-info";
        nodeId: string;
        payloadLength: number;
        role: number;
        serverWallNanoseconds: bigint;
        tableCount: number;
        version: number;
        zoneId: null | string;
    }

    Hierarchy (View Summary)

    Index

    Properties

    capabilities: number
    clusterId: string
    compressionCodec: null | number
    compressionLevel: null | number
    epoch: bigint
    flags: number
    kind: "server-info"
    nodeId: string
    payloadLength: number
    role: number
    serverWallNanoseconds: bigint
    tableCount: number
    version: number
    zoneId: null | string
    +

    Properties

    capabilities: number
    clusterId: string
    compressionCodec: null | number
    compressionLevel: null | number
    epoch: bigint
    flags: number
    kind: "server-info"
    nodeId: string
    payloadLength: number
    role: number
    serverWallNanoseconds: bigint
    tableCount: number
    version: number
    zoneId: null | string
    diff --git a/docs/interfaces/_questdb_browser-client.QwpSymbolValue.html b/docs/interfaces/_questdb_browser-client.QwpSymbolValue.html index 90159d0..055abc7 100644 --- a/docs/interfaces/_questdb_browser-client.QwpSymbolValue.html +++ b/docs/interfaces/_questdb_browser-client.QwpSymbolValue.html @@ -1,3 +1,3 @@ -QwpSymbolValue | QuestDB JavaScript Client - v4.2.0
    interface QwpSymbolValue {
        id: number;
        text: string;
    }
    Index

    Properties

    id +QwpSymbolValue | QuestDB JavaScript Client - v4.2.0
    interface QwpSymbolValue {
        id: number;
        text: string;
    }
    Index

    Properties

    Properties

    id: number
    text: string
    +

    Properties

    id: number
    text: string
    diff --git a/docs/interfaces/_questdb_browser-client.QwpUpgradeErrorDetails.html b/docs/interfaces/_questdb_browser-client.QwpUpgradeErrorDetails.html index fff96fb..751dd96 100644 --- a/docs/interfaces/_questdb_browser-client.QwpUpgradeErrorDetails.html +++ b/docs/interfaces/_questdb_browser-client.QwpUpgradeErrorDetails.html @@ -1,4 +1,4 @@ -QwpUpgradeErrorDetails | QuestDB JavaScript Client - v4.2.0

    Interface QwpUpgradeErrorDetails

    interface QwpUpgradeErrorDetails {
        cause?: unknown;
        closeCode?: number;
        kind: QwpUpgradeErrorKind;
        retryable?: boolean;
        serverRole?: string;
        serverZone?: string;
        statusCode?: number;
        statusMessage?: string;
        timeoutPhase?: QwpUpgradeTimeoutPhase;
        tryNextEndpoint?: boolean;
        url?: string | URL;
    }
    Index

    Properties

    cause? +QwpUpgradeErrorDetails | QuestDB JavaScript Client - v4.2.0

    Interface QwpUpgradeErrorDetails

    interface QwpUpgradeErrorDetails {
        cause?: unknown;
        closeCode?: number;
        kind: QwpUpgradeErrorKind;
        retryable?: boolean;
        serverRole?: string;
        serverZone?: string;
        statusCode?: number;
        statusMessage?: string;
        timeoutPhase?: QwpUpgradeTimeoutPhase;
        tryNextEndpoint?: boolean;
        url?: string | URL;
    }
    Index

    Properties

    cause?: unknown
    closeCode?: number
    retryable?: boolean

    Whether a later retry against the configured endpoint set may recover.

    -
    serverRole?: string
    serverZone?: string
    statusCode?: number
    statusMessage?: string
    timeoutPhase?: QwpUpgradeTimeoutPhase
    tryNextEndpoint?: boolean

    Whether failover code should try another endpoint before surfacing this.

    -
    url?: string | URL
    +

    Properties

    cause?: unknown
    closeCode?: number
    retryable?: boolean

    Whether a later retry against the configured endpoint set may recover.

    +
    serverRole?: string
    serverZone?: string
    statusCode?: number
    statusMessage?: string
    timeoutPhase?: QwpUpgradeTimeoutPhase
    tryNextEndpoint?: boolean

    Whether failover code should try another endpoint before surfacing this.

    +
    url?: string | URL
    diff --git a/docs/interfaces/_questdb_browser-client.QwpUuidValue.html b/docs/interfaces/_questdb_browser-client.QwpUuidValue.html index 56d8e59..e3f51bb 100644 --- a/docs/interfaces/_questdb_browser-client.QwpUuidValue.html +++ b/docs/interfaces/_questdb_browser-client.QwpUuidValue.html @@ -1,3 +1,3 @@ -QwpUuidValue | QuestDB JavaScript Client - v4.2.0
    interface QwpUuidValue {
        high: bigint;
        low: bigint;
    }
    Index

    Properties

    high +QwpUuidValue | QuestDB JavaScript Client - v4.2.0
    interface QwpUuidValue {
        high: bigint;
        low: bigint;
    }
    Index

    Properties

    Properties

    high: bigint
    low: bigint
    +

    Properties

    high: bigint
    low: bigint
    diff --git a/docs/interfaces/_questdb_browser-client.QwpWebSocketConnectOptions.html b/docs/interfaces/_questdb_browser-client.QwpWebSocketConnectOptions.html index 594a181..f3b78f7 100644 --- a/docs/interfaces/_questdb_browser-client.QwpWebSocketConnectOptions.html +++ b/docs/interfaces/_questdb_browser-client.QwpWebSocketConnectOptions.html @@ -1,12 +1,12 @@ -QwpWebSocketConnectOptions | QuestDB JavaScript Client - v4.2.0

    Interface QwpWebSocketConnectOptions

    interface QwpWebSocketConnectOptions {
        closeTimeoutMs?: number;
        connectTimeoutMs?: number;
        failoverUrls?: readonly (string | URL)[];
        protocols?: string | string[];
        sendTimeoutMs?: number;
        url: string | URL;
    }

    Hierarchy (View Summary)

    Index

    Properties

    closeTimeoutMs? +QwpWebSocketConnectOptions | QuestDB JavaScript Client - v4.2.0

    Interface QwpWebSocketConnectOptions

    interface QwpWebSocketConnectOptions {
        closeTimeoutMs?: number;
        connectTimeoutMs?: number;
        failoverUrls?: readonly (string | URL)[];
        protocols?: string | string[];
        sendTimeoutMs?: number;
        url: string | URL;
    }

    Hierarchy (View Summary)

    Index

    Properties

    closeTimeoutMs?: number

    Maximum time allowed for a graceful WebSocket close. Defaults to 15s.

    -
    connectTimeoutMs?: number

    Node TCP/TLS connection deadline, or the complete opening deadline in a +

    connectTimeoutMs?: number

    Node TCP/TLS connection deadline, or the complete opening deadline in a browser. Defaults to 15s.

    -
    failoverUrls?: readonly (string | URL)[]

    Additional endpoints attempted in order when the preferred endpoint fails.

    -
    protocols?: string | string[]
    sendTimeoutMs?: number

    Maximum time a send may remain queued by the WebSocket. Defaults to 15s.

    -
    url: string | URL
    +
    failoverUrls?: readonly (string | URL)[]

    Additional endpoints attempted in order when the preferred endpoint fails.

    +
    protocols?: string | string[]
    sendTimeoutMs?: number

    Maximum time a send may remain queued by the WebSocket. Defaults to 15s.

    +
    url: string | URL
    diff --git a/docs/interfaces/_questdb_browser-client.QwpWebSocketLike.html b/docs/interfaces/_questdb_browser-client.QwpWebSocketLike.html index f21c07b..3f96f61 100644 --- a/docs/interfaces/_questdb_browser-client.QwpWebSocketLike.html +++ b/docs/interfaces/_questdb_browser-client.QwpWebSocketLike.html @@ -1,4 +1,4 @@ -QwpWebSocketLike | QuestDB JavaScript Client - v4.2.0

    Interface QwpWebSocketLike

    interface QwpWebSocketLike {
        binaryType: string;
        bufferedAmount?: number;
        protocol?: string;
        readyState: number;
        addEventListener(
            type: "open",
            listener: (event: unknown) => void,
            options?: { once?: boolean },
        ): void;
        addEventListener(
            type: "message",
            listener: (event: QwpWebSocketMessageEvent) => void,
        ): void;
        addEventListener(
            type: "error",
            listener: (event: unknown) => void,
            options?: { once?: boolean },
        ): void;
        addEventListener(
            type: "close",
            listener: (event: QwpWebSocketCloseEvent) => void,
            options?: { once?: boolean },
        ): void;
        close(code?: number, reason?: string): void;
        ping(): void;
        removeEventListener(type: "open", listener: (event: unknown) => void): void;
        removeEventListener(
            type: "message",
            listener: (event: QwpWebSocketMessageEvent) => void,
        ): void;
        removeEventListener(
            type: "error",
            listener: (event: unknown) => void,
        ): void;
        removeEventListener(
            type: "close",
            listener: (event: QwpWebSocketCloseEvent) => void,
        ): void;
        send(data: Uint8Array): void;
        sendWithCallback(data: Uint8Array, callback: (error?: Error) => void): void;
        terminate(): void;
    }
    Index

    Properties

    binaryType +QwpWebSocketLike | QuestDB JavaScript Client - v4.2.0

    Interface QwpWebSocketLike

    interface QwpWebSocketLike {
        binaryType: string;
        bufferedAmount?: number;
        protocol?: string;
        readyState: number;
        addEventListener(
            type: "open",
            listener: (event: unknown) => void,
            options?: { once?: boolean },
        ): void;
        addEventListener(
            type: "message",
            listener: (event: QwpWebSocketMessageEvent) => void,
        ): void;
        addEventListener(
            type: "error",
            listener: (event: unknown) => void,
            options?: { once?: boolean },
        ): void;
        addEventListener(
            type: "close",
            listener: (event: QwpWebSocketCloseEvent) => void,
            options?: { once?: boolean },
        ): void;
        close(code?: number, reason?: string): void;
        ping(): void;
        removeEventListener(type: "open", listener: (event: unknown) => void): void;
        removeEventListener(
            type: "message",
            listener: (event: QwpWebSocketMessageEvent) => void,
        ): void;
        removeEventListener(
            type: "error",
            listener: (event: unknown) => void,
        ): void;
        removeEventListener(
            type: "close",
            listener: (event: QwpWebSocketCloseEvent) => void,
        ): void;
        send(data: Uint8Array): void;
        sendWithCallback(data: Uint8Array, callback: (error?: Error) => void): void;
        terminate(): void;
    }
    Index

    Properties

    binaryType: string
    bufferedAmount?: number

    Number of application bytes queued by WHATWG-compatible WebSockets.

    -
    protocol?: string

    WebSocket subprotocol selected by the server, or an empty string.

    -
    readyState: number

    Methods

    +

    Properties

    binaryType: string
    bufferedAmount?: number

    Number of application bytes queued by WHATWG-compatible WebSockets.

    +
    protocol?: string

    WebSocket subprotocol selected by the server, or an empty string.

    +
    readyState: number

    Methods

    diff --git a/docs/interfaces/_questdb_browser-client.QwpWriterColumn.html b/docs/interfaces/_questdb_browser-client.QwpWriterColumn.html index d3acdec..2c762fc 100644 --- a/docs/interfaces/_questdb_browser-client.QwpWriterColumn.html +++ b/docs/interfaces/_questdb_browser-client.QwpWriterColumn.html @@ -1,5 +1,5 @@ QwpWriterColumn | QuestDB JavaScript Client - v4.2.0

    Interface QwpWriterColumn<T, DesignatedTimestamp>

    A reusable, immutable column definition for a compiled QWP table writer.

    -
    interface QwpWriterColumn<T, DesignatedTimestamp extends boolean = false> {
        __qwpWriterInput?: T;
        designatedTimestamp: DesignatedTimestamp;
        kind: QwpWriterColumnKind;
        precisionBits?: number;
        scale?: number;
        unit?: QwpTimestampUnit;
    }

    Type Parameters

    • T
    • DesignatedTimestamp extends boolean = false
    Index

    Properties

    interface QwpWriterColumn<T, DesignatedTimestamp extends boolean = false> {
        __qwpWriterInput?: T;
        designatedTimestamp: DesignatedTimestamp;
        kind: QwpWriterColumnKind;
        precisionBits?: number;
        scale?: number;
        unit?: QwpTimestampUnit;
    }

    Type Parameters

    • T
    • DesignatedTimestamp extends boolean = false
    Index

    Properties

    __qwpWriterInput? designatedTimestamp kind precisionBits? @@ -14,6 +14,6 @@ silently accept anything. A shared property name resolves structurally across bundles, which is what keeps row typing alive for consumers of the published package.

    -
    designatedTimestamp: DesignatedTimestamp
    precisionBits?: number

    GEOHASH precision in bits, fixed for the whole column.

    -
    scale?: number

    DECIMAL scale, fixed for the whole column.

    -
    +
    designatedTimestamp: DesignatedTimestamp
    precisionBits?: number

    GEOHASH precision in bits, fixed for the whole column.

    +
    scale?: number

    DECIMAL scale, fixed for the whole column.

    +
    diff --git a/docs/interfaces/_questdb_nodejs-client.QwpArrayValue.html b/docs/interfaces/_questdb_nodejs-client.QwpArrayValue.html index 3a0e068..54da3a4 100644 --- a/docs/interfaces/_questdb_nodejs-client.QwpArrayValue.html +++ b/docs/interfaces/_questdb_nodejs-client.QwpArrayValue.html @@ -1,3 +1,3 @@ -QwpArrayValue | QuestDB JavaScript Client - v4.2.0
    interface QwpArrayValue {
        dimensions: number[];
        values: (number | bigint)[];
    }
    Index

    Properties

    dimensions +QwpArrayValue | QuestDB JavaScript Client - v4.2.0
    interface QwpArrayValue {
        dimensions: number[];
        values: (number | bigint)[];
    }
    Index

    Properties

    Properties

    dimensions: number[]
    values: (number | bigint)[]
    +

    Properties

    dimensions: number[]
    values: (number | bigint)[]
    diff --git a/docs/interfaces/_questdb_nodejs-client.QwpBinaryConnection.html b/docs/interfaces/_questdb_nodejs-client.QwpBinaryConnection.html index aeb5278..48a9e8b 100644 --- a/docs/interfaces/_questdb_nodejs-client.QwpBinaryConnection.html +++ b/docs/interfaces/_questdb_nodejs-client.QwpBinaryConnection.html @@ -1,7 +1,7 @@ QwpBinaryConnection | QuestDB JavaScript Client - v4.2.0

    Interface QwpBinaryConnection

    Normalized binary connection consumed by QWP sessions.

    Adapters buffer messages until the single async iterator consumes them, so unsolicited frames such as egress SERVER_INFO cannot race session startup.

    -
    interface QwpBinaryConnection {
        closed: Promise<QwpConnectionCloseInfo>;
        endpoint?: string | URL;
        handshake: QwpHandshakeMetadata;
        ingressDeltaSymbolDictionaryEnabled?: boolean;
        ingressSymbolDictionary?: readonly string[];
        managesIngressSenderErrors?: boolean;
        messages: AsyncIterable<Uint8Array<ArrayBufferLike>>;
        close(code?: number, reason?: string): Promise<void>;
        deprioritizeEndpoint(): void;
        getIngressFrameSequence(clientSequence: bigint): bigint;
        getIngressMetrics(): QwpIngressTransportMetrics;
        ping(): Promise<void>;
        send(payload: Uint8Array): Promise<void>;
        skipIngressClientSequence(): void;
    }
    Index

    Properties

    interface QwpBinaryConnection {
        closed: Promise<QwpConnectionCloseInfo>;
        endpoint?: string | URL;
        handshake: QwpHandshakeMetadata;
        ingressDeltaSymbolDictionaryEnabled?: boolean;
        ingressSymbolDictionary?: readonly string[];
        managesIngressSenderErrors?: boolean;
        messages: AsyncIterable<Uint8Array<ArrayBufferLike>>;
        close(code?: number, reason?: string): Promise<void>;
        deprioritizeEndpoint(): void;
        getIngressFrameSequence(clientSequence: bigint): bigint;
        getIngressMetrics(): QwpIngressTransportMetrics;
        ping(): Promise<void>;
        send(payload: Uint8Array): Promise<void>;
        skipIngressClientSequence(): void;
    }
    Index

    Properties

    closed: Promise<QwpConnectionCloseInfo>
    endpoint?: string | URL

    Endpoint backing this connection, when supplied by its adapter.

    -
    ingressDeltaSymbolDictionaryEnabled?: boolean

    False after replay dictionary persistence becomes unavailable.

    -
    ingressSymbolDictionary?: readonly string[]

    Recovered ingress dictionary supplied by replay connections.

    -
    managesIngressSenderErrors?: boolean

    True when the transport dispatches typed sender errors itself.

    -
    messages: AsyncIterable<Uint8Array<ArrayBufferLike>>

    Methods

    • Internal

      Marks this endpoint as temporarily unsuitable and asks a stateful +

    Properties

    closed: Promise<QwpConnectionCloseInfo>
    endpoint?: string | URL

    Endpoint backing this connection, when supplied by its adapter.

    +
    ingressDeltaSymbolDictionaryEnabled?: boolean

    False after replay dictionary persistence becomes unavailable.

    +
    ingressSymbolDictionary?: readonly string[]

    Recovered ingress dictionary supplied by replay connections.

    +
    managesIngressSenderErrors?: boolean

    True when the transport dispatches typed sender errors itself.

    +
    messages: AsyncIterable<Uint8Array<ArrayBufferLike>>

    Methods

    • Internal

      Marks this endpoint as temporarily unsuitable and asks a stateful connection factory to start its next sweep at another configured endpoint.

      -

      Returns void

    • Internal

      Reserves a client sequence for a split-batch suffix suppressed before send(), keeping replay ACK translation aligned with the session.

      -

      Returns void

    +

    Returns void

    diff --git a/docs/interfaces/_questdb_nodejs-client.QwpCacheResetMessage.html b/docs/interfaces/_questdb_nodejs-client.QwpCacheResetMessage.html index 2867d86..c740d66 100644 --- a/docs/interfaces/_questdb_nodejs-client.QwpCacheResetMessage.html +++ b/docs/interfaces/_questdb_nodejs-client.QwpCacheResetMessage.html @@ -1,7 +1,7 @@ -QwpCacheResetMessage | QuestDB JavaScript Client - v4.2.0

    Interface QwpCacheResetMessage

    interface QwpCacheResetMessage {
        flags: number;
        kind: "cache-reset";
        payloadLength: number;
        resetMask: number;
        tableCount: number;
        version: number;
    }

    Hierarchy (View Summary)

    Index

    Properties

    flags +QwpCacheResetMessage | QuestDB JavaScript Client - v4.2.0

    Interface QwpCacheResetMessage

    interface QwpCacheResetMessage {
        flags: number;
        kind: "cache-reset";
        payloadLength: number;
        resetMask: number;
        tableCount: number;
        version: number;
    }

    Hierarchy (View Summary)

    Index

    Properties

    flags: number
    kind: "cache-reset"
    payloadLength: number
    resetMask: number
    tableCount: number
    version: number
    +

    Properties

    flags: number
    kind: "cache-reset"
    payloadLength: number
    resetMask: number
    tableCount: number
    version: number
    diff --git a/docs/interfaces/_questdb_nodejs-client.QwpClientFactories.html b/docs/interfaces/_questdb_nodejs-client.QwpClientFactories.html index 0b84fbc..9369124 100644 --- a/docs/interfaces/_questdb_nodejs-client.QwpClientFactories.html +++ b/docs/interfaces/_questdb_nodejs-client.QwpClientFactories.html @@ -1,9 +1,9 @@ -QwpClientFactories | QuestDB JavaScript Client - v4.2.0

    Interface QwpClientFactories

    interface QwpClientFactories {
        senderSlotReservation?: QwpPoolSlotReservation;
        close(): void | Promise<void>;
        createQuerySession(
            slot: number,
            signal?: AbortSignal,
        ): Promise<QwpEgressSession>;
        createSender(slot: number, signal?: AbortSignal): Promise<QwpSender>;
        start(): void | Promise<void>;
    }
    Index

    Properties

    senderSlotReservation? +QwpClientFactories | QuestDB JavaScript Client - v4.2.0

    Interface QwpClientFactories

    interface QwpClientFactories {
        senderSlotReservation?: QwpPoolSlotReservation;
        close(): void | Promise<void>;
        createQuerySession(
            slot: number,
            signal?: AbortSignal,
        ): Promise<QwpEgressSession>;
        createSender(slot: number, signal?: AbortSignal): Promise<QwpSender>;
        start(): void | Promise<void>;
    }
    Index

    Properties

    senderSlotReservation?: QwpPoolSlotReservation

    Coordinates stable persistent sender slots with recovery.

    -

    Methods

    +

    Methods

    diff --git a/docs/interfaces/_questdb_nodejs-client.QwpClientMetrics.html b/docs/interfaces/_questdb_nodejs-client.QwpClientMetrics.html index 21cb28e..9a121b7 100644 --- a/docs/interfaces/_questdb_nodejs-client.QwpClientMetrics.html +++ b/docs/interfaces/_questdb_nodejs-client.QwpClientMetrics.html @@ -1,5 +1,5 @@ -QwpClientMetrics | QuestDB JavaScript Client - v4.2.0

    Interface QwpClientMetrics

    interface QwpClientMetrics {
        closed: boolean;
        closing: boolean;
        queries: QwpResourcePoolMetrics;
        senders: QwpResourcePoolMetrics;
    }
    Index

    Properties

    closed +QwpClientMetrics | QuestDB JavaScript Client - v4.2.0

    Interface QwpClientMetrics

    interface QwpClientMetrics {
        closed: boolean;
        closing: boolean;
        queries: QwpResourcePoolMetrics;
        senders: QwpResourcePoolMetrics;
    }
    Index

    Properties

    closed: boolean
    closing: boolean
    +

    Properties

    closed: boolean
    closing: boolean
    diff --git a/docs/interfaces/_questdb_nodejs-client.QwpClientPoolOptions.html b/docs/interfaces/_questdb_nodejs-client.QwpClientPoolOptions.html index a893d06..647b12e 100644 --- a/docs/interfaces/_questdb_nodejs-client.QwpClientPoolOptions.html +++ b/docs/interfaces/_questdb_nodejs-client.QwpClientPoolOptions.html @@ -1,4 +1,4 @@ -QwpClientPoolOptions | QuestDB JavaScript Client - v4.2.0

    Interface QwpClientPoolOptions

    interface QwpClientPoolOptions {
        acquireTimeoutMs?: number;
        housekeepingIntervalMs?: number;
        idleTimeoutMs?: number;
        maxLifetimeMs?: number;
        queryPoolMax?: number;
        queryPoolMin?: number;
        senderPoolMax?: number;
        senderPoolMin?: number;
    }
    Index

    Properties

    acquireTimeoutMs? +QwpClientPoolOptions | QuestDB JavaScript Client - v4.2.0

    Interface QwpClientPoolOptions

    interface QwpClientPoolOptions {
        acquireTimeoutMs?: number;
        housekeepingIntervalMs?: number;
        idleTimeoutMs?: number;
        maxLifetimeMs?: number;
        queryPoolMax?: number;
        queryPoolMin?: number;
        senderPoolMax?: number;
        senderPoolMin?: number;
    }
    Index

    Properties

    acquireTimeoutMs?: number

    Maximum wait for a returned pool slot and for leases during shutdown. The shutdown wait is capped at 5 seconds. Defaults to 5 seconds.

    -
    housekeepingIntervalMs?: number

    Idle/lifetime sweep interval. Defaults to 5s and must be at least 100ms.

    -
    idleTimeoutMs?: number

    Idle time before an excess pooled connection is closed. Defaults to 60s; zero disables.

    -
    maxLifetimeMs?: number

    Maximum pooled connection age before recycling it while idle. Defaults to 30m; zero disables.

    -
    queryPoolMax?: number

    Maximum concurrently borrowed query connections. Defaults to 4.

    -
    queryPoolMin?: number

    Warm egress connections created by connect(). Defaults to 1.

    -
    senderPoolMax?: number

    Maximum concurrently borrowed ingress senders. Defaults to 4.

    -
    senderPoolMin?: number

    Warm ingress connections created by connect(). Defaults to 1.

    -
    +
    housekeepingIntervalMs?: number

    Idle/lifetime sweep interval. Defaults to 5s and must be at least 100ms.

    +
    idleTimeoutMs?: number

    Idle time before an excess pooled connection is closed. Defaults to 60s; zero disables.

    +
    maxLifetimeMs?: number

    Maximum pooled connection age before recycling it while idle. Defaults to 30m; zero disables.

    +
    queryPoolMax?: number

    Maximum concurrently borrowed query connections. Defaults to 4.

    +
    queryPoolMin?: number

    Warm egress connections created by connect(). Defaults to 1.

    +
    senderPoolMax?: number

    Maximum concurrently borrowed ingress senders. Defaults to 4.

    +
    senderPoolMin?: number

    Warm ingress connections created by connect(). Defaults to 1.

    +
    diff --git a/docs/interfaces/_questdb_nodejs-client.QwpColumnBuffer.html b/docs/interfaces/_questdb_nodejs-client.QwpColumnBuffer.html index ccfa818..a3cd42c 100644 --- a/docs/interfaces/_questdb_nodejs-client.QwpColumnBuffer.html +++ b/docs/interfaces/_questdb_nodejs-client.QwpColumnBuffer.html @@ -1,11 +1,11 @@ -QwpColumnBuffer | QuestDB JavaScript Client - v4.2.0

    Interface QwpColumnBuffer

    interface QwpColumnBuffer {
        decimalScale?: number;
        geohashPrecision?: number;
        name: string;
        nulls: boolean[];
        size: number;
        type: QwpColumnType;
        values: unknown[];
    }
    Index

    Properties

    decimalScale? +QwpColumnBuffer | QuestDB JavaScript Client - v4.2.0

    Interface QwpColumnBuffer

    interface QwpColumnBuffer {
        decimalScale?: number;
        geohashPrecision?: number;
        name: string;
        nulls: boolean[];
        size: number;
        type: QwpColumnType;
        values: unknown[];
    }
    Index

    Properties

    decimalScale?: number
    geohashPrecision?: number
    name: string
    nulls: boolean[]

    One entry per row; true means NULL.

    -
    size: number

    Rows accounted for so far, including nulls.

    -
    values: unknown[]

    Non-null values only; QWP compacts values around the null bitmap.

    -
    +

    Properties

    decimalScale?: number
    geohashPrecision?: number
    name: string
    nulls: boolean[]

    One entry per row; true means NULL.

    +
    size: number

    Rows accounted for so far, including nulls.

    +
    values: unknown[]

    Non-null values only; QWP compacts values around the null bitmap.

    +
    diff --git a/docs/interfaces/_questdb_nodejs-client.QwpConnectionCloseInfo.html b/docs/interfaces/_questdb_nodejs-client.QwpConnectionCloseInfo.html index 6210aa2..0971aec 100644 --- a/docs/interfaces/_questdb_nodejs-client.QwpConnectionCloseInfo.html +++ b/docs/interfaces/_questdb_nodejs-client.QwpConnectionCloseInfo.html @@ -1,4 +1,4 @@ -QwpConnectionCloseInfo | QuestDB JavaScript Client - v4.2.0

    Interface QwpConnectionCloseInfo

    interface QwpConnectionCloseInfo {
        code: number;
        reason: string;
        wasClean: boolean;
    }
    Index

    Properties

    code +QwpConnectionCloseInfo | QuestDB JavaScript Client - v4.2.0

    Interface QwpConnectionCloseInfo

    interface QwpConnectionCloseInfo {
        code: number;
        reason: string;
        wasClean: boolean;
    }
    Index

    Properties

    Properties

    code: number
    reason: string
    wasClean: boolean
    +

    Properties

    code: number
    reason: string
    wasClean: boolean
    diff --git a/docs/interfaces/_questdb_nodejs-client.QwpDecimalValue.html b/docs/interfaces/_questdb_nodejs-client.QwpDecimalValue.html index 8f998be..353d6a0 100644 --- a/docs/interfaces/_questdb_nodejs-client.QwpDecimalValue.html +++ b/docs/interfaces/_questdb_nodejs-client.QwpDecimalValue.html @@ -1,3 +1,3 @@ -QwpDecimalValue | QuestDB JavaScript Client - v4.2.0

    Interface QwpDecimalValue

    interface QwpDecimalValue {
        scale: number;
        unscaled: bigint;
    }
    Index

    Properties

    scale +QwpDecimalValue | QuestDB JavaScript Client - v4.2.0

    Interface QwpDecimalValue

    interface QwpDecimalValue {
        scale: number;
        unscaled: bigint;
    }
    Index

    Properties

    Properties

    scale: number
    unscaled: bigint
    +

    Properties

    scale: number
    unscaled: bigint
    diff --git a/docs/interfaces/_questdb_nodejs-client.QwpEgressQueryOptions.html b/docs/interfaces/_questdb_nodejs-client.QwpEgressQueryOptions.html index b0ac8c5..8b5c04c 100644 --- a/docs/interfaces/_questdb_nodejs-client.QwpEgressQueryOptions.html +++ b/docs/interfaces/_questdb_nodejs-client.QwpEgressQueryOptions.html @@ -1,4 +1,4 @@ -QwpEgressQueryOptions | QuestDB JavaScript Client - v4.2.0

    Interface QwpEgressQueryOptions

    interface QwpEgressQueryOptions {
        autoCredit?: boolean;
        bindCount?: number;
        bindPayload?: Uint8Array;
        binds?: QwpBindSetter;
        initialCredit?: number | bigint;
        resetDictionary?: boolean;
        timeoutMs?: number;
    }
    Index

    Properties

    autoCredit? +QwpEgressQueryOptions | QuestDB JavaScript Client - v4.2.0

    Interface QwpEgressQueryOptions

    interface QwpEgressQueryOptions {
        autoCredit?: boolean;
        bindCount?: number;
        bindPayload?: Uint8Array;
        binds?: QwpBindSetter;
        initialCredit?: number | bigint;
        resetDictionary?: boolean;
        timeoutMs?: number;
    }
    Index

    Properties

    Properties

    autoCredit?: boolean

    Replenishes positive initial credit by each RESULT_BATCH wire size after the async iterator advances past that batch. Defaults to true.

    -
    bindCount?: number

    Advanced escape hatch for an already encoded bind section.

    -
    bindPayload?: Uint8Array

    Advanced escape hatch for an already encoded bind section.

    -

    Sets typed positional parameters; index 0 maps to SQL placeholder $1.

    -
    initialCredit?: number | bigint

    Overrides session send-ahead credit. Zero explicitly disables flow control.

    -
    resetDictionary?: boolean

    Ask a capable server to reset its connection-scoped symbol dictionary. +

    bindCount?: number

    Advanced escape hatch for an already encoded bind section.

    +
    bindPayload?: Uint8Array

    Advanced escape hatch for an already encoded bind section.

    +

    Sets typed positional parameters; index 0 maps to SQL placeholder $1.

    +
    initialCredit?: number | bigint

    Overrides session send-ahead credit. Zero explicitly disables flow control.

    +
    resetDictionary?: boolean

    Ask a capable server to reset its connection-scoped symbol dictionary. Silently omitted when the server lacks QUERY_FLAGS for rolling upgrades.

    -
    timeoutMs?: number

    Per-query deadline overriding the session default. Zero disables it.

    -
    +
    timeoutMs?: number

    Per-query deadline overriding the session default. Zero disables it.

    +
    diff --git a/docs/interfaces/_questdb_nodejs-client.QwpEgressReplayResetEvent.html b/docs/interfaces/_questdb_nodejs-client.QwpEgressReplayResetEvent.html index 45d2963..2b60eac 100644 --- a/docs/interfaces/_questdb_nodejs-client.QwpEgressReplayResetEvent.html +++ b/docs/interfaces/_questdb_nodejs-client.QwpEgressReplayResetEvent.html @@ -1,8 +1,8 @@ -QwpEgressReplayResetEvent | QuestDB JavaScript Client - v4.2.0

    Interface QwpEgressReplayResetEvent

    interface QwpEgressReplayResetEvent {
        cause?: unknown;
        endpoint?: string | URL;
        previousEndpoint?: string | URL;
        requestId: bigint;
        serverInfo: QwpServerInfoMessage;
    }
    Index

    Properties

    cause? +QwpEgressReplayResetEvent | QuestDB JavaScript Client - v4.2.0

    Interface QwpEgressReplayResetEvent

    interface QwpEgressReplayResetEvent {
        cause?: unknown;
        endpoint?: string | URL;
        previousEndpoint?: string | URL;
        requestId: bigint;
        serverInfo: QwpServerInfoMessage;
    }
    Index

    Properties

    cause?: unknown
    endpoint?: string | URL
    previousEndpoint?: string | URL
    requestId: bigint

    Client request being re-executed on the replacement connection.

    -

    Authoritative SERVER_INFO received from the replacement endpoint.

    -
    +

    Properties

    cause?: unknown
    endpoint?: string | URL
    previousEndpoint?: string | URL
    requestId: bigint

    Client request being re-executed on the replacement connection.

    +

    Authoritative SERVER_INFO received from the replacement endpoint.

    +
    diff --git a/docs/interfaces/_questdb_nodejs-client.QwpEgressRoutingOptions.html b/docs/interfaces/_questdb_nodejs-client.QwpEgressRoutingOptions.html index d2ce187..8193228 100644 --- a/docs/interfaces/_questdb_nodejs-client.QwpEgressRoutingOptions.html +++ b/docs/interfaces/_questdb_nodejs-client.QwpEgressRoutingOptions.html @@ -1,8 +1,8 @@ QwpEgressRoutingOptions | QuestDB JavaScript Client - v4.2.0

    Interface QwpEgressRoutingOptions

    Endpoint routing preferences. Named for egress, where they landed first, but ingress ranks and validates its endpoints with the same machinery and honours the same two keys.

    -
    interface QwpEgressRoutingOptions {
        target?: QwpTarget;
        zone?: string;
    }

    Hierarchy (View Summary)

    Index

    Properties

    interface QwpEgressRoutingOptions {
        target?: QwpTarget;
        zone?: string;
    }

    Hierarchy (View Summary)

    Index

    Properties

    Properties

    target?: QwpTarget

    Selects any readable node, a primary/standalone node, or a replica.

    -
    zone?: string

    Opaque, case-insensitive preferred zone; cross-zone fallback stays enabled.

    -
    +
    zone?: string

    Opaque, case-insensitive preferred zone; cross-zone fallback stays enabled.

    +
    diff --git a/docs/interfaces/_questdb_nodejs-client.QwpEgressSessionOptions.html b/docs/interfaces/_questdb_nodejs-client.QwpEgressSessionOptions.html index 10f3a24..fc43896 100644 --- a/docs/interfaces/_questdb_nodejs-client.QwpEgressSessionOptions.html +++ b/docs/interfaces/_questdb_nodejs-client.QwpEgressSessionOptions.html @@ -1,19 +1,25 @@ -QwpEgressSessionOptions | QuestDB JavaScript Client - v4.2.0

    Interface QwpEgressSessionOptions

    interface QwpEgressSessionOptions {
        bufferPoolSize?: number;
        cancelDrainTimeoutMs?: number;
        initialCredit?: number | bigint;
        onReplayReset?: (event: QwpEgressReplayResetEvent) => void | Promise<void>;
        queryTimeoutMs?: number;
        reconnect?: false | QwpReconnectOptions;
        serverInfoTimeoutMs?: number;
    }
    Index

    Properties

    bufferPoolSize? +QwpEgressSessionOptions | QuestDB JavaScript Client - v4.2.0

    Interface QwpEgressSessionOptions

    interface QwpEgressSessionOptions {
        bufferPoolSize?: number;
        cancelDrainTimeoutMs?: number;
        initialCredit?: number | bigint;
        maxBatchRows?: number;
        onReplayReset?: (event: QwpEgressReplayResetEvent) => void | Promise<void>;
        queryTimeoutMs?: number;
        reconnect?: false | QwpReconnectOptions;
        serverInfoTimeoutMs?: number;
    }
    Index

    Properties

    bufferPoolSize?: number

    Maximum decoded batches waiting for a consumer. Defaults to 4.

    -
    cancelDrainTimeoutMs?: number

    Maximum wait for a terminal response after CANCEL. Defaults to 5 seconds.

    -
    initialCredit?: number | bigint

    Default per-query send-ahead credit. Defaults to zero (unbounded).

    -
    onReplayReset?: (event: QwpEgressReplayResetEvent) => void | Promise<void>

    Optional notification immediately before an active query is re-executed. +

    cancelDrainTimeoutMs?: number

    Maximum wait for a terminal response after CANCEL. Defaults to 5 seconds.

    +
    initialCredit?: number | bigint

    Default per-query send-ahead credit. Defaults to zero (unbounded).

    +
    maxBatchRows?: number

    Rejects a RESULT_BATCH declaring more rows than this. The connect helpers +default it to the maxBatchRows they put on the wire, so the request the +client makes is also the bound it enforces; decoder scratch is sized from +the declared row count and retained per pool slot, so an answer above the +request would set this session's memory floor for its lifetime.

    +
    onReplayReset?: (event: QwpEgressReplayResetEvent) => void | Promise<void>

    Optional notification immediately before an active query is re-executed. Not-yet-consumed batches are discarded automatically; callers that retain an already-consumed prefix should discard it here. Omitting this callback leaves replay enabled and is appropriate for idempotent consumers.

    -
    queryTimeoutMs?: number

    Default per-query deadline. Zero or undefined disables query deadlines.

    -
    reconnect?: false | QwpReconnectOptions

    Bounded failover policy. Failover and at-least-once active-query replay +

    queryTimeoutMs?: number

    Default per-query deadline. Zero or undefined disables query deadlines.

    +
    reconnect?: false | QwpReconnectOptions

    Bounded failover policy. Failover and at-least-once active-query replay are enabled by default; set false to keep one fixed connection.

    -
    serverInfoTimeoutMs?: number

    SERVER_INFO handshake deadline. Defaults to 5 seconds.

    -
    +
    serverInfoTimeoutMs?: number

    SERVER_INFO handshake deadline. Defaults to 5 seconds.

    +
    diff --git a/docs/interfaces/_questdb_nodejs-client.QwpEgressViewQuery.html b/docs/interfaces/_questdb_nodejs-client.QwpEgressViewQuery.html index 7cb2fbe..36b12bf 100644 --- a/docs/interfaces/_questdb_nodejs-client.QwpEgressViewQuery.html +++ b/docs/interfaces/_questdb_nodejs-client.QwpEgressViewQuery.html @@ -1,9 +1,9 @@ QwpEgressViewQuery | QuestDB JavaScript Client - v4.2.0

    Interface QwpEgressViewQuery

    Control handle returned by queryViews().

    -
    interface QwpEgressViewQuery {
        completion: Promise<QwpQueryCompletion>;
        requestId: bigint;
        awaitCompletion(timeoutMs: number): Promise<boolean>;
        cancel(): Promise<void>;
        grantCredit(additionalBytes: number | bigint): Promise<void>;
        isDone(): boolean;
    }
    Index

    Properties

    interface QwpEgressViewQuery {
        completion: Promise<QwpQueryCompletion>;
        requestId: bigint;
        awaitCompletion(timeoutMs: number): Promise<boolean>;
        cancel(): Promise<void>;
        grantCredit(additionalBytes: number | bigint): Promise<void>;
        isDone(): boolean;
    }
    Index

    Properties

    completion: Promise<QwpQueryCompletion>
    requestId: bigint

    Methods

    +

    Properties

    completion: Promise<QwpQueryCompletion>
    requestId: bigint

    Methods

    diff --git a/docs/interfaces/_questdb_nodejs-client.QwpEncodedBinds.html b/docs/interfaces/_questdb_nodejs-client.QwpEncodedBinds.html index de8088c..d747f8e 100644 --- a/docs/interfaces/_questdb_nodejs-client.QwpEncodedBinds.html +++ b/docs/interfaces/_questdb_nodejs-client.QwpEncodedBinds.html @@ -1,3 +1,3 @@ -QwpEncodedBinds | QuestDB JavaScript Client - v4.2.0

    Interface QwpEncodedBinds

    interface QwpEncodedBinds {
        count: number;
        payload: Uint8Array;
    }
    Index

    Properties

    count +QwpEncodedBinds | QuestDB JavaScript Client - v4.2.0

    Interface QwpEncodedBinds

    interface QwpEncodedBinds {
        count: number;
        payload: Uint8Array;
    }
    Index

    Properties

    Properties

    count: number
    payload: Uint8Array
    +

    Properties

    count: number
    payload: Uint8Array
    diff --git a/docs/interfaces/_questdb_nodejs-client.QwpExecDoneMessage.html b/docs/interfaces/_questdb_nodejs-client.QwpExecDoneMessage.html index 6cdd534..0c8f25f 100644 --- a/docs/interfaces/_questdb_nodejs-client.QwpExecDoneMessage.html +++ b/docs/interfaces/_questdb_nodejs-client.QwpExecDoneMessage.html @@ -1,4 +1,4 @@ -QwpExecDoneMessage | QuestDB JavaScript Client - v4.2.0

    Interface QwpExecDoneMessage

    interface QwpExecDoneMessage {
        flags: number;
        kind: "exec-done";
        operationType: number;
        payloadLength: number;
        requestId: bigint;
        rowsAffected: bigint;
        tableCount: number;
        version: number;
    }

    Hierarchy (View Summary)

    Index

    Properties

    flags +QwpExecDoneMessage | QuestDB JavaScript Client - v4.2.0

    Interface QwpExecDoneMessage

    interface QwpExecDoneMessage {
        flags: number;
        kind: "exec-done";
        operationType: number;
        payloadLength: number;
        requestId: bigint;
        rowsAffected: bigint;
        tableCount: number;
        version: number;
    }

    Hierarchy (View Summary)

    Index

    Properties

    flags: number
    kind: "exec-done"
    operationType: number
    payloadLength: number
    requestId: bigint
    rowsAffected: bigint
    tableCount: number
    version: number
    +

    Properties

    flags: number
    kind: "exec-done"
    operationType: number
    payloadLength: number
    requestId: bigint
    rowsAffected: bigint
    tableCount: number
    version: number
    diff --git a/docs/interfaces/_questdb_nodejs-client.QwpFailoverAttempt.html b/docs/interfaces/_questdb_nodejs-client.QwpFailoverAttempt.html index 851dce2..3aa06a8 100644 --- a/docs/interfaces/_questdb_nodejs-client.QwpFailoverAttempt.html +++ b/docs/interfaces/_questdb_nodejs-client.QwpFailoverAttempt.html @@ -1,3 +1,3 @@ -QwpFailoverAttempt | QuestDB JavaScript Client - v4.2.0

    Interface QwpFailoverAttempt

    interface QwpFailoverAttempt {
        endpoint: string | URL;
        error: unknown;
    }
    Index

    Properties

    endpoint +QwpFailoverAttempt | QuestDB JavaScript Client - v4.2.0

    Interface QwpFailoverAttempt

    interface QwpFailoverAttempt {
        endpoint: string | URL;
        error: unknown;
    }
    Index

    Properties

    Properties

    endpoint: string | URL
    error: unknown
    +

    Properties

    endpoint: string | URL
    error: unknown
    diff --git a/docs/interfaces/_questdb_nodejs-client.QwpFrame.html b/docs/interfaces/_questdb_nodejs-client.QwpFrame.html index 75d4eee..070e998 100644 --- a/docs/interfaces/_questdb_nodejs-client.QwpFrame.html +++ b/docs/interfaces/_questdb_nodejs-client.QwpFrame.html @@ -1,6 +1,6 @@ -QwpFrame | QuestDB JavaScript Client - v4.2.0
    interface QwpFrame {
        flags: number;
        payload: Uint8Array;
        payloadLength: number;
        tableCount: number;
        version: number;
    }

    Hierarchy (View Summary)

    Index

    Properties

    flags +QwpFrame | QuestDB JavaScript Client - v4.2.0
    interface QwpFrame {
        flags: number;
        payload: Uint8Array;
        payloadLength: number;
        tableCount: number;
        version: number;
    }

    Hierarchy (View Summary)

    Index

    Properties

    flags: number
    payload: Uint8Array
    payloadLength: number
    tableCount: number
    version: number
    +

    Properties

    flags: number
    payload: Uint8Array
    payloadLength: number
    tableCount: number
    version: number
    diff --git a/docs/interfaces/_questdb_nodejs-client.QwpFrameHeader.html b/docs/interfaces/_questdb_nodejs-client.QwpFrameHeader.html index 1066225..903dee8 100644 --- a/docs/interfaces/_questdb_nodejs-client.QwpFrameHeader.html +++ b/docs/interfaces/_questdb_nodejs-client.QwpFrameHeader.html @@ -1,5 +1,5 @@ -QwpFrameHeader | QuestDB JavaScript Client - v4.2.0

    Interface QwpFrameHeader

    interface QwpFrameHeader {
        flags: number;
        payloadLength: number;
        tableCount: number;
        version: number;
    }

    Hierarchy (View Summary)

    Index

    Properties

    flags +QwpFrameHeader | QuestDB JavaScript Client - v4.2.0

    Interface QwpFrameHeader

    interface QwpFrameHeader {
        flags: number;
        payloadLength: number;
        tableCount: number;
        version: number;
    }

    Hierarchy (View Summary)

    Index

    Properties

    flags: number
    payloadLength: number
    tableCount: number
    version: number
    +

    Properties

    flags: number
    payloadLength: number
    tableCount: number
    version: number
    diff --git a/docs/interfaces/_questdb_nodejs-client.QwpGeohashValue.html b/docs/interfaces/_questdb_nodejs-client.QwpGeohashValue.html index 4137306..de29c3f 100644 --- a/docs/interfaces/_questdb_nodejs-client.QwpGeohashValue.html +++ b/docs/interfaces/_questdb_nodejs-client.QwpGeohashValue.html @@ -1,3 +1,3 @@ -QwpGeohashValue | QuestDB JavaScript Client - v4.2.0

    Interface QwpGeohashValue

    interface QwpGeohashValue {
        bits: bigint;
        precisionBits: number;
    }
    Index

    Properties

    bits +QwpGeohashValue | QuestDB JavaScript Client - v4.2.0

    Interface QwpGeohashValue

    interface QwpGeohashValue {
        bits: bigint;
        precisionBits: number;
    }
    Index

    Properties

    Properties

    bits: bigint
    precisionBits: number
    +

    Properties

    bits: bigint
    precisionBits: number
    diff --git a/docs/interfaces/_questdb_nodejs-client.QwpHandshakeMetadata.html b/docs/interfaces/_questdb_nodejs-client.QwpHandshakeMetadata.html index a018d36..613afe7 100644 --- a/docs/interfaces/_questdb_nodejs-client.QwpHandshakeMetadata.html +++ b/docs/interfaces/_questdb_nodejs-client.QwpHandshakeMetadata.html @@ -1,5 +1,5 @@ QwpHandshakeMetadata | QuestDB JavaScript Client - v4.2.0

    Interface QwpHandshakeMetadata

    Metadata negotiated during the QWP WebSocket upgrade.

    -
    interface QwpHandshakeMetadata {
        contentEncoding?: string;
        durableAckEnabled?: boolean;
        maxBatchSizeBytes?: number;
        negotiatedCompression?: QwpNegotiatedEgressCompression;
        qwpVersion: number;
        serverRole?: string;
        serverZone?: string;
    }
    Index

    Properties

    interface QwpHandshakeMetadata {
        contentEncoding?: string;
        durableAckEnabled?: boolean;
        maxBatchSizeBytes?: number;
        negotiatedCompression?: QwpNegotiatedEgressCompression;
        qwpVersion: number;
        serverRole?: string;
        serverZone?: string;
    }
    Index

    Properties

    contentEncoding?: string

    Server-selected egress content encoding, when advertised.

    -
    durableAckEnabled?: boolean

    Whether the server confirmed durable-ACK support.

    -
    maxBatchSizeBytes?: number

    Server's hard ingress WebSocket-payload cap, when advertised.

    -
    negotiatedCompression?: QwpNegotiatedEgressCompression

    Parsed effective egress codec and level selected by the server.

    -
    qwpVersion: number

    QWP protocol version selected by the server.

    -
    serverRole?: string

    Server role advertised on a successful upgrade, when available.

    -
    serverZone?: string

    Server zone advertised on a successful upgrade, when available.

    -
    +
    durableAckEnabled?: boolean

    Whether the server confirmed durable-ACK support.

    +
    maxBatchSizeBytes?: number

    Server's hard ingress WebSocket-payload cap, when advertised.

    +
    negotiatedCompression?: QwpNegotiatedEgressCompression

    Parsed effective egress codec and level selected by the server.

    +
    qwpVersion: number

    QWP protocol version selected by the server.

    +
    serverRole?: string

    Server role advertised on a successful upgrade, when available.

    +
    serverZone?: string

    Server zone advertised on a successful upgrade, when available.

    +
    diff --git a/docs/interfaces/_questdb_nodejs-client.QwpIngressEncodeOptions.html b/docs/interfaces/_questdb_nodejs-client.QwpIngressEncodeOptions.html index 111e440..62b8c57 100644 --- a/docs/interfaces/_questdb_nodejs-client.QwpIngressEncodeOptions.html +++ b/docs/interfaces/_questdb_nodejs-client.QwpIngressEncodeOptions.html @@ -1,7 +1,7 @@ -QwpIngressEncodeOptions | QuestDB JavaScript Client - v4.2.0

    Interface QwpIngressEncodeOptions

    interface QwpIngressEncodeOptions {
        confirmedMaxSymbolId?: number;
        deferCommit?: boolean;
        dictionary?: QwpSymbolDictionary;
        gorilla?: boolean;
    }
    Index

    Properties

    confirmedMaxSymbolId? +QwpIngressEncodeOptions | QuestDB JavaScript Client - v4.2.0

    Interface QwpIngressEncodeOptions

    interface QwpIngressEncodeOptions {
        confirmedMaxSymbolId?: number;
        deferCommit?: boolean;
        dictionary?: QwpSymbolDictionary;
        gorilla?: boolean;
    }
    Index

    Properties

    confirmedMaxSymbolId?: number

    Highest global symbol ID already published on this logical connection.

    -
    deferCommit?: boolean
    dictionary?: QwpSymbolDictionary

    Present means connection-scoped delta dictionary mode.

    -
    gorilla?: boolean
    +
    deferCommit?: boolean
    dictionary?: QwpSymbolDictionary

    Present means connection-scoped delta dictionary mode.

    +
    gorilla?: boolean
    diff --git a/docs/interfaces/_questdb_nodejs-client.QwpIngressErrorEvent.html b/docs/interfaces/_questdb_nodejs-client.QwpIngressErrorEvent.html index ce123d7..9492b00 100644 --- a/docs/interfaces/_questdb_nodejs-client.QwpIngressErrorEvent.html +++ b/docs/interfaces/_questdb_nodejs-client.QwpIngressErrorEvent.html @@ -1,8 +1,8 @@ -QwpIngressErrorEvent | QuestDB JavaScript Client - v4.2.0

    Interface QwpIngressErrorEvent

    interface QwpIngressErrorEvent {
        error: Error;
        metrics: QwpIngressMetrics;
        response?: QwpIngressResponse;
        senderError?: QwpSenderError;
        terminal: boolean;
        timestampMs: number;
    }
    Index

    Properties

    error +QwpIngressErrorEvent | QuestDB JavaScript Client - v4.2.0

    Interface QwpIngressErrorEvent

    interface QwpIngressErrorEvent {
        error: Error;
        metrics: QwpIngressMetrics;
        response?: QwpIngressResponse;
        senderError?: QwpSenderError;
        terminal: boolean;
        timestampMs: number;
    }
    Index

    Properties

    error: Error
    senderError?: QwpSenderError

    Present for a classified server rejection.

    -
    terminal: boolean
    timestampMs: number
    +

    Properties

    error: Error
    senderError?: QwpSenderError

    Present for a classified server rejection.

    +
    terminal: boolean
    timestampMs: number
    diff --git a/docs/interfaces/_questdb_nodejs-client.QwpIngressMetrics.html b/docs/interfaces/_questdb_nodejs-client.QwpIngressMetrics.html index 78ff936..78ce2c6 100644 --- a/docs/interfaces/_questdb_nodejs-client.QwpIngressMetrics.html +++ b/docs/interfaces/_questdb_nodejs-client.QwpIngressMetrics.html @@ -1,5 +1,5 @@ QwpIngressMetrics | QuestDB JavaScript Client - v4.2.0

    Interface QwpIngressMetrics

    Immutable point-in-time ingress telemetry, safe in browsers and Node.js.

    -
    interface QwpIngressMetrics {
        acknowledgedSequence: bigint;
        deliveredConnectionNotifications: number;
        deliveredErrorNotifications: number;
        deliveredProgressNotifications: number;
        droppedConnectionNotifications: number;
        droppedErrorNotifications: number;
        droppedProgressNotifications: number;
        lastError?: Error;
        memoryReplayMaxBytes?: number;
        memoryReplayUsedBytes?: number;
        pendingDurableTables: number;
        pendingReplayBytes: number;
        pendingReplayFrames: number;
        pendingResponseBytes: number;
        pendingResponses: number;
        publishedSequence: bigint;
        replayAcknowledgedFrameSequence?: bigint;
        replayPublishedFrameSequence?: bigint;
        totalAcks: number;
        totalBytesPublished: number;
        totalBytesReplayed: number;
        totalBytesSent: number;
        totalDurableAcks: number;
        totalErrors: number;
        totalFailovers: number;
        totalFramesPublished: number;
        totalFramesReplayed: number;
        totalFramesSent: number;
        totalMemoryReplayAppendTimeouts: number;
        totalMemoryReplayBackpressureStalls: number;
        totalNacks: number;
        totalReconnectAttempts: number;
        totalReconnectErrors: number;
        totalReconnectsSucceeded: number;
        waitingMemoryReplayAppends: number;
    }
    Index

    Properties

    interface QwpIngressMetrics {
        acknowledgedSequence: bigint;
        deliveredConnectionNotifications: number;
        deliveredErrorNotifications: number;
        deliveredProgressNotifications: number;
        droppedConnectionNotifications: number;
        droppedErrorNotifications: number;
        droppedProgressNotifications: number;
        lastError?: Error;
        memoryReplayMaxBytes?: number;
        memoryReplayUsedBytes?: number;
        pendingDurableTables: number;
        pendingReplayBytes: number;
        pendingReplayFrames: number;
        pendingResponseBytes: number;
        pendingResponses: number;
        publishedSequence: bigint;
        replayAcknowledgedFrameSequence?: bigint;
        replayPublishedFrameSequence?: bigint;
        totalAcks: number;
        totalBytesPublished: number;
        totalBytesReplayed: number;
        totalBytesSent: number;
        totalDurableAcks: number;
        totalErrors: number;
        totalFailovers: number;
        totalFramesPublished: number;
        totalFramesReplayed: number;
        totalFramesSent: number;
        totalMemoryReplayAppendTimeouts: number;
        totalMemoryReplayBackpressureStalls: number;
        totalNacks: number;
        totalReconnectAttempts: number;
        totalReconnectErrors: number;
        totalReconnectsSucceeded: number;
        waitingMemoryReplayAppends: number;
    }
    Index

    Properties

    acknowledgedSequence: bigint

    Highest client-session sequence covered by a successful cumulative ACK.

    -
    deliveredConnectionNotifications: number
    deliveredErrorNotifications: number
    deliveredProgressNotifications: number
    droppedConnectionNotifications: number
    droppedErrorNotifications: number
    droppedProgressNotifications: number
    lastError?: Error
    memoryReplayMaxBytes?: number
    memoryReplayUsedBytes?: number
    pendingDurableTables: number
    pendingReplayBytes: number
    pendingReplayFrames: number
    pendingResponseBytes: number
    pendingResponses: number
    publishedSequence: bigint

    Highest client-session sequence allocated, or -1 before the first send.

    -
    replayAcknowledgedFrameSequence?: bigint

    Trim watermark; in durable-ACK mode it advances only after durability.

    -
    replayPublishedFrameSequence?: bigint

    Stable store-and-forward watermark; absent without reconnect/replay.

    -
    totalAcks: number
    totalBytesPublished: number
    totalBytesReplayed: number
    totalBytesSent: number
    totalDurableAcks: number
    totalErrors: number
    totalFailovers: number
    totalFramesPublished: number
    totalFramesReplayed: number
    totalFramesSent: number

    Physical sends; includes replay and dictionary catch-up when available.

    -
    totalMemoryReplayAppendTimeouts: number
    totalMemoryReplayBackpressureStalls: number
    totalNacks: number
    totalReconnectAttempts: number
    totalReconnectErrors: number
    totalReconnectsSucceeded: number
    waitingMemoryReplayAppends: number
    +
    deliveredConnectionNotifications: number
    deliveredErrorNotifications: number
    deliveredProgressNotifications: number
    droppedConnectionNotifications: number
    droppedErrorNotifications: number
    droppedProgressNotifications: number
    lastError?: Error
    memoryReplayMaxBytes?: number
    memoryReplayUsedBytes?: number
    pendingDurableTables: number
    pendingReplayBytes: number
    pendingReplayFrames: number
    pendingResponseBytes: number
    pendingResponses: number
    publishedSequence: bigint

    Highest client-session sequence allocated, or -1 before the first send.

    +
    replayAcknowledgedFrameSequence?: bigint

    Trim watermark; in durable-ACK mode it advances only after durability.

    +
    replayPublishedFrameSequence?: bigint

    Stable store-and-forward watermark; absent without reconnect/replay.

    +
    totalAcks: number
    totalBytesPublished: number
    totalBytesReplayed: number
    totalBytesSent: number
    totalDurableAcks: number
    totalErrors: number
    totalFailovers: number
    totalFramesPublished: number
    totalFramesReplayed: number
    totalFramesSent: number

    Physical sends; includes replay and dictionary catch-up when available.

    +
    totalMemoryReplayAppendTimeouts: number
    totalMemoryReplayBackpressureStalls: number
    totalNacks: number
    totalReconnectAttempts: number
    totalReconnectErrors: number
    totalReconnectsSucceeded: number
    waitingMemoryReplayAppends: number
    diff --git a/docs/interfaces/_questdb_nodejs-client.QwpIngressProgressEvent.html b/docs/interfaces/_questdb_nodejs-client.QwpIngressProgressEvent.html index faf2f0a..19f1a0a 100644 --- a/docs/interfaces/_questdb_nodejs-client.QwpIngressProgressEvent.html +++ b/docs/interfaces/_questdb_nodejs-client.QwpIngressProgressEvent.html @@ -1,6 +1,6 @@ -QwpIngressProgressEvent | QuestDB JavaScript Client - v4.2.0

    Interface QwpIngressProgressEvent

    interface QwpIngressProgressEvent {
        kind: QwpIngressProgressKind;
        metrics: QwpIngressMetrics;
        response?: QwpIngressResponse;
        sequence?: bigint;
        timestampMs: number;
    }
    Index

    Properties

    kind +QwpIngressProgressEvent | QuestDB JavaScript Client - v4.2.0

    Interface QwpIngressProgressEvent

    interface QwpIngressProgressEvent {
        kind: QwpIngressProgressKind;
        metrics: QwpIngressMetrics;
        response?: QwpIngressResponse;
        sequence?: bigint;
        timestampMs: number;
    }
    Index

    Properties

    sequence?: bigint
    timestampMs: number
    +

    Properties

    sequence?: bigint
    timestampMs: number
    diff --git a/docs/interfaces/_questdb_nodejs-client.QwpIngressReplayRecord.html b/docs/interfaces/_questdb_nodejs-client.QwpIngressReplayRecord.html index 7971faf..5739b0c 100644 --- a/docs/interfaces/_questdb_nodejs-client.QwpIngressReplayRecord.html +++ b/docs/interfaces/_questdb_nodejs-client.QwpIngressReplayRecord.html @@ -1,3 +1,3 @@ -QwpIngressReplayRecord | QuestDB JavaScript Client - v4.2.0

    Interface QwpIngressReplayRecord

    interface QwpIngressReplayRecord {
        frameSequence: bigint;
        payload: Uint8Array;
    }
    Index

    Properties

    frameSequence +QwpIngressReplayRecord | QuestDB JavaScript Client - v4.2.0

    Interface QwpIngressReplayRecord

    interface QwpIngressReplayRecord {
        frameSequence: bigint;
        payload: Uint8Array;
    }
    Index

    Properties

    frameSequence: bigint
    payload: Uint8Array
    +

    Properties

    frameSequence: bigint
    payload: Uint8Array
    diff --git a/docs/interfaces/_questdb_nodejs-client.QwpIngressReplayReference.html b/docs/interfaces/_questdb_nodejs-client.QwpIngressReplayReference.html index 30ed278..e25bd2c 100644 --- a/docs/interfaces/_questdb_nodejs-client.QwpIngressReplayReference.html +++ b/docs/interfaces/_questdb_nodejs-client.QwpIngressReplayReference.html @@ -1,4 +1,4 @@ QwpIngressReplayReference | QuestDB JavaScript Client - v4.2.0

    Interface QwpIngressReplayReference

    Lightweight durable-frame descriptor used by disk-backed replay stores.

    -
    interface QwpIngressReplayReference {
        frameSequence: bigint;
        payloadLength: number;
    }
    Index

    Properties

    interface QwpIngressReplayReference {
        frameSequence: bigint;
        payloadLength: number;
    }
    Index

    Properties

    frameSequence: bigint
    payloadLength: number
    +

    Properties

    frameSequence: bigint
    payloadLength: number
    diff --git a/docs/interfaces/_questdb_nodejs-client.QwpIngressReplayStore.html b/docs/interfaces/_questdb_nodejs-client.QwpIngressReplayStore.html index 5f8e7d6..6da3efc 100644 --- a/docs/interfaces/_questdb_nodejs-client.QwpIngressReplayStore.html +++ b/docs/interfaces/_questdb_nodejs-client.QwpIngressReplayStore.html @@ -1,5 +1,5 @@ QwpIngressReplayStore | QuestDB JavaScript Client - v4.2.0

    Interface QwpIngressReplayStore

    Browser-safe abstraction; Node supplies a persistent filesystem implementation.

    -
    interface QwpIngressReplayStore {
        acknowledgeThrough(frameSequence: bigint): Promise<void>;
        append(record: QwpIngressReplayRecord): Promise<void>;
        appendSymbolDictionary(
            startId: number,
            entries: readonly string[],
        ): Promise<void>;
        close(): Promise<void>;
        load(): Promise<readonly QwpIngressReplayRecord[]>;
        loadReferences(): Promise<readonly QwpIngressReplayReference[]>;
        loadSymbolDictionary(): Promise<readonly string[]>;
        readPayload(frameSequence: bigint): Promise<Uint8Array<ArrayBufferLike>>;
        replaceSymbolDictionary(entries: readonly string[]): Promise<void>;
    }

    Implemented by

    Index

    Methods

    interface QwpIngressReplayStore {
        acknowledgeThrough(frameSequence: bigint): Promise<void>;
        append(record: QwpIngressReplayRecord): Promise<void>;
        appendSymbolDictionary(
            startId: number,
            entries: readonly string[],
        ): Promise<void>;
        close(): Promise<void>;
        load(): Promise<readonly QwpIngressReplayRecord[]>;
        loadReferences(): Promise<readonly QwpIngressReplayReference[]>;
        loadSymbolDictionary(): Promise<readonly string[]>;
        readPayload(frameSequence: bigint): Promise<Uint8Array<ArrayBufferLike>>;
        replaceSymbolDictionary(entries: readonly string[]): Promise<void>;
    }

    Implemented by

    Index

    Methods

    • Persists new dense entries before a delta frame is made replayable.

      -

      Parameters

      • startId: number
      • entries: readonly string[]

      Returns Promise<void>

    • Opens and validates the journal without materializing every payload. +

    Methods

    • Persists new dense entries before a delta frame is made replayable.

      +

      Parameters

      • startId: number
      • entries: readonly string[]

      Returns Promise<void>

    • Reads one previously loaded durable payload on demand.

      -

      Parameters

      • frameSequence: bigint

      Returns Promise<Uint8Array<ArrayBufferLike>>

    • Reads one previously loaded durable payload on demand.

      +

      Parameters

      • frameSequence: bigint

      Returns Promise<Uint8Array<ArrayBufferLike>>

    • Atomically replaces an unusable dictionary after surviving committed frames prove that its complete ID space can be reconstructed.

      -

      Parameters

      • entries: readonly string[]

      Returns Promise<void>

    +

    Parameters

    • entries: readonly string[]

    Returns Promise<void>

    diff --git a/docs/interfaces/_questdb_nodejs-client.QwpIngressResponse.html b/docs/interfaces/_questdb_nodejs-client.QwpIngressResponse.html index 08f7381..9efcc20 100644 --- a/docs/interfaces/_questdb_nodejs-client.QwpIngressResponse.html +++ b/docs/interfaces/_questdb_nodejs-client.QwpIngressResponse.html @@ -1,5 +1,5 @@ -QwpIngressResponse | QuestDB JavaScript Client - v4.2.0

    Interface QwpIngressResponse

    interface QwpIngressResponse {
        errorMessage?: string;
        sequence: bigint;
        status: number;
        tables: QwpIngressTableResult[];
    }
    Index

    Properties

    errorMessage? +QwpIngressResponse | QuestDB JavaScript Client - v4.2.0

    Interface QwpIngressResponse

    interface QwpIngressResponse {
        errorMessage?: string;
        sequence: bigint;
        status: number;
        tables: QwpIngressTableResult[];
    }
    Index

    Properties

    errorMessage?: string
    sequence: bigint
    status: number
    +

    Properties

    errorMessage?: string
    sequence: bigint
    status: number
    diff --git a/docs/interfaces/_questdb_nodejs-client.QwpIngressSendResult.html b/docs/interfaces/_questdb_nodejs-client.QwpIngressSendResult.html index e0b6c9b..be90562 100644 --- a/docs/interfaces/_questdb_nodejs-client.QwpIngressSendResult.html +++ b/docs/interfaces/_questdb_nodejs-client.QwpIngressSendResult.html @@ -2,10 +2,10 @@ completion. Publication resolves after every physical frame belonging to the logical batch has been accepted by the connection. For persistent Node transports that means the frames are durable in the replay journal.

    -
    interface QwpIngressSendResult {
        acknowledgement: Promise<QwpIngressResponse>;
        publication: Promise<void>;
        sequence: bigint;
    }
    Index

    Properties

    interface QwpIngressSendResult {
        acknowledgement: Promise<QwpIngressResponse>;
        publication: Promise<void>;
        sequence: bigint;
    }
    Index

    Properties

    acknowledgement: Promise<QwpIngressResponse>

    Cumulative server response for every frame in the logical batch.

    -
    publication: Promise<void>

    Local transport/journal ownership boundary.

    -
    sequence: bigint

    Last client-session sequence allocated to this logical batch.

    -
    +
    publication: Promise<void>

    Local transport/journal ownership boundary.

    +
    sequence: bigint

    Last client-session sequence allocated to this logical batch.

    +
    diff --git a/docs/interfaces/_questdb_nodejs-client.QwpIngressSessionOptions.html b/docs/interfaces/_questdb_nodejs-client.QwpIngressSessionOptions.html index 46a2596..2d369e0 100644 --- a/docs/interfaces/_questdb_nodejs-client.QwpIngressSessionOptions.html +++ b/docs/interfaces/_questdb_nodejs-client.QwpIngressSessionOptions.html @@ -1,4 +1,4 @@ -QwpIngressSessionOptions | QuestDB JavaScript Client - v4.2.0

    Interface QwpIngressSessionOptions

    interface QwpIngressSessionOptions {
        ackTimeoutMs?: number;
        backgroundStoreAndForward?: boolean;
        catchUpCapGapMinEscalationWindowMs?: number;
        connectionListenerInboxCapacity?: number;
        durableAckKeepaliveMs?: number;
        errorInboxCapacity?: number;
        initialConnectMode?: QwpInitialConnectMode;
        maxBatchSizeBytes?: number;
        memoryReplayAppendDeadlineMs?: number;
        memoryReplayMaxBytes?: number;
        onDurableAck?: (response: QwpIngressResponse) => void;
        onError?: (event: QwpIngressErrorEvent) => void;
        onProgress?: (event: QwpIngressProgressEvent) => void;
        onResponse?: (response: QwpIngressResponse) => void;
        onSenderError?: (error: QwpSenderError) => void;
        orphanDurableAckMismatchMaxDurationMs?: number;
        orphanStoreAndForward?: boolean;
        reconnect?: false | QwpReconnectOptions;
        replayStore?: QwpIngressReplayStore;
    }
    Index

    Properties

    ackTimeoutMs? +QwpIngressSessionOptions | QuestDB JavaScript Client - v4.2.0

    Interface QwpIngressSessionOptions

    interface QwpIngressSessionOptions {
        ackTimeoutMs?: number;
        backgroundStoreAndForward?: boolean;
        catchUpCapGapMinEscalationWindowMs?: number;
        connectionListenerInboxCapacity?: number;
        durableAckKeepaliveMs?: number;
        errorInboxCapacity?: number;
        initialConnectMode?: QwpInitialConnectMode;
        maxBatchSizeBytes?: number;
        memoryReplayAppendDeadlineMs?: number;
        memoryReplayMaxBytes?: number;
        onDurableAck?: (response: QwpIngressResponse) => void;
        onError?: (event: QwpIngressErrorEvent) => void;
        onProgress?: (event: QwpIngressProgressEvent) => void;
        onResponse?: (response: QwpIngressResponse) => void;
        onSenderError?: (error: QwpSenderError) => void;
        orphanDurableAckMismatchMaxDurationMs?: number;
        orphanStoreAndForward?: boolean;
        reconnect?: false | QwpReconnectOptions;
        replayStore?: QwpIngressReplayStore;
    }
    Index

    Properties

    ackTimeoutMs?: number
    backgroundStoreAndForward?: boolean

    Starts memory or persistent replay without waiting for a server.

    -
    catchUpCapGapMinEscalationWindowMs?: number

    Minimum cap-gap dwell before an orphan can be quarantined.

    -
    connectionListenerInboxCapacity?: number

    Bounded reconnect-listener inbox. Oldest pending events are dropped when +

    Properties

    ackTimeoutMs?: number
    backgroundStoreAndForward?: boolean

    Starts memory or persistent replay without waiting for a server.

    +
    catchUpCapGapMinEscalationWindowMs?: number

    Minimum cap-gap dwell before an orphan can be quarantined.

    +
    connectionListenerInboxCapacity?: number

    Bounded reconnect-listener inbox. Oldest pending events are dropped when full. Defaults to 64, matching the Java client.

    -
    durableAckKeepaliveMs?: number

    Enables durable-ACK tracking. While committed table transactions await +

    durableAckKeepaliveMs?: number

    Enables durable-ACK tracking. While committed table transactions await durable upload, Node transports send WebSocket PING frames and browser transports send table-less QWP poll frames. Zero keeps tracking enabled but disables automatic polling. Factory-created browser sessions require requestDurableAck=true when this option is supplied.

    -
    errorInboxCapacity?: number

    Bounded typed/legacy error inbox. Oldest pending errors are dropped when +

    errorInboxCapacity?: number

    Bounded typed/legacy error inbox. Oldest pending errors are dropped when full. Defaults to 256, matching the Java client.

    -
    initialConnectMode?: QwpInitialConnectMode

    Initial connection policy supplied by the Node adapter.

    -
    maxBatchSizeBytes?: number

    Optional local ingress frame cap. Browsers cannot read WebSocket upgrade +

    initialConnectMode?: QwpInitialConnectMode

    Initial connection policy supplied by the Node adapter.

    +
    maxBatchSizeBytes?: number

    Optional local ingress frame cap. Browsers cannot read WebSocket upgrade headers, so browser applications should set this to the server's configured QWP cap. When the server also advertises a cap, the smaller value wins. Table batches are split at row boundaries automatically; an individual row that cannot fit is rejected with QwpBatchTooLargeError before it is sent.

    -
    memoryReplayAppendDeadlineMs?: number

    Maximum time a memory replay append waits for ACK-driven trimming after +

    memoryReplayAppendDeadlineMs?: number

    Maximum time a memory replay append waits for ACK-driven trimming after reaching memoryReplayMaxBytes. Defaults to 30 seconds.

    -
    memoryReplayMaxBytes?: number

    Hard cap for the built-in memory-only replay queue, including estimated +

    memoryReplayMaxBytes?: number

    Hard cap for the built-in memory-only replay queue, including estimated per-frame bookkeeping. Defaults to 128 MiB. This applies in browsers and non-persistent Node sessions; custom replay stores enforce their own cap.

    -
    onDurableAck?: (response: QwpIngressResponse) => void
    onError?: (event: QwpIngressErrorEvent) => void

    Server rejections, deadlines, and terminal session failures.

    -
    onProgress?: (event: QwpIngressProgressEvent) => void

    Monotonic send/accept/durability notifications. Callback errors are ignored.

    -
    onResponse?: (response: QwpIngressResponse) => void
    onSenderError?: (error: QwpSenderError) => void

    Java-parity typed server-rejection and data-loss notifications. When +

    onDurableAck?: (response: QwpIngressResponse) => void
    onError?: (event: QwpIngressErrorEvent) => void

    Server rejections, deadlines, and terminal session failures.

    +
    onProgress?: (event: QwpIngressProgressEvent) => void

    Monotonic send/accept/durability notifications. Callback errors are ignored.

    +
    onResponse?: (response: QwpIngressResponse) => void
    onSenderError?: (error: QwpSenderError) => void

    Java-parity typed server-rejection and data-loss notifications. When omitted, the default handler logs retriable errors at warn and terminal errors or abandoned data at error.

    -
    orphanDurableAckMismatchMaxDurationMs?: number

    Consecutive durable-ACK gap budget retained for orphan SF.

    -
    orphanStoreAndForward?: boolean

    Orphan sessions may quarantine persistent catch-up cap gaps.

    -
    reconnect?: false | QwpReconnectOptions

    Bounded reconnection and at-least-once replay policy. Reconnection is +

    orphanDurableAckMismatchMaxDurationMs?: number

    Consecutive durable-ACK gap budget retained for orphan SF.

    +
    orphanStoreAndForward?: boolean

    Orphan sessions may quarantine persistent catch-up cap gaps.

    +
    reconnect?: false | QwpReconnectOptions

    Bounded reconnection and at-least-once replay policy. Reconnection is enabled by default for factory-created sessions; set false to keep one fixed connection. Browser and non-persistent Node replay is memory-only.

    An ACK lost during disconnect can cause a frame to be replayed after the server accepted it; configure server-side deduplication when duplicates are not acceptable.

    -
    replayStore?: QwpIngressReplayStore

    Node adapter hook for persistent store-and-forward.

    -
    +
    replayStore?: QwpIngressReplayStore

    Node adapter hook for persistent store-and-forward.

    +
    diff --git a/docs/interfaces/_questdb_nodejs-client.QwpIngressSymbolDictionaryDelta.html b/docs/interfaces/_questdb_nodejs-client.QwpIngressSymbolDictionaryDelta.html index 022e4aa..04ccf3d 100644 --- a/docs/interfaces/_questdb_nodejs-client.QwpIngressSymbolDictionaryDelta.html +++ b/docs/interfaces/_questdb_nodejs-client.QwpIngressSymbolDictionaryDelta.html @@ -1,3 +1,3 @@ -QwpIngressSymbolDictionaryDelta | QuestDB JavaScript Client - v4.2.0

    Interface QwpIngressSymbolDictionaryDelta

    interface QwpIngressSymbolDictionaryDelta {
        entries: readonly string[];
        startId: number;
    }
    Index

    Properties

    entries +QwpIngressSymbolDictionaryDelta | QuestDB JavaScript Client - v4.2.0

    Interface QwpIngressSymbolDictionaryDelta

    interface QwpIngressSymbolDictionaryDelta {
        entries: readonly string[];
        startId: number;
    }
    Index

    Properties

    Properties

    entries: readonly string[]
    startId: number
    +

    Properties

    entries: readonly string[]
    startId: number
    diff --git a/docs/interfaces/_questdb_nodejs-client.QwpIngressTableResult.html b/docs/interfaces/_questdb_nodejs-client.QwpIngressTableResult.html index 6eb9ee0..fc151ed 100644 --- a/docs/interfaces/_questdb_nodejs-client.QwpIngressTableResult.html +++ b/docs/interfaces/_questdb_nodejs-client.QwpIngressTableResult.html @@ -1,3 +1,3 @@ -QwpIngressTableResult | QuestDB JavaScript Client - v4.2.0

    Interface QwpIngressTableResult

    interface QwpIngressTableResult {
        name: string;
        sequenceTransaction: bigint;
    }
    Index

    Properties

    name +QwpIngressTableResult | QuestDB JavaScript Client - v4.2.0

    Interface QwpIngressTableResult

    interface QwpIngressTableResult {
        name: string;
        sequenceTransaction: bigint;
    }
    Index

    Properties

    name: string
    sequenceTransaction: bigint
    +

    Properties

    name: string
    sequenceTransaction: bigint
    diff --git a/docs/interfaces/_questdb_nodejs-client.QwpIngressTransportMetrics.html b/docs/interfaces/_questdb_nodejs-client.QwpIngressTransportMetrics.html index 2bf6fb0..0270c0d 100644 --- a/docs/interfaces/_questdb_nodejs-client.QwpIngressTransportMetrics.html +++ b/docs/interfaces/_questdb_nodejs-client.QwpIngressTransportMetrics.html @@ -1,5 +1,5 @@ QwpIngressTransportMetrics | QuestDB JavaScript Client - v4.2.0

    Interface QwpIngressTransportMetrics

    Physical ingress delivery counters maintained by reconnecting transports.

    -
    interface QwpIngressTransportMetrics {
        acknowledgedFrameSequence: bigint;
        deliveredConnectionNotifications?: number;
        deliveredErrorNotifications?: number;
        droppedConnectionNotifications?: number;
        droppedErrorNotifications?: number;
        memoryReplayMaxBytes?: number;
        memoryReplayUsedBytes?: number;
        pendingReplayBytes: number;
        pendingReplayFrames: number;
        publishedFrameSequence: bigint;
        totalBytesReplayed: number;
        totalBytesSent: number;
        totalFailovers: number;
        totalFramesReplayed: number;
        totalFramesSent: number;
        totalMemoryReplayAppendTimeouts: number;
        totalMemoryReplayBackpressureStalls: number;
        totalReconnectAttempts: number;
        totalReconnectErrors: number;
        totalReconnectsSucceeded: number;
        totalServerNacks: number;
        waitingMemoryReplayAppends: number;
    }
    Index

    Properties

    interface QwpIngressTransportMetrics {
        acknowledgedFrameSequence: bigint;
        deliveredConnectionNotifications?: number;
        deliveredErrorNotifications?: number;
        droppedConnectionNotifications?: number;
        droppedErrorNotifications?: number;
        memoryReplayMaxBytes?: number;
        memoryReplayUsedBytes?: number;
        pendingReplayBytes: number;
        pendingReplayFrames: number;
        publishedFrameSequence: bigint;
        totalBytesReplayed: number;
        totalBytesSent: number;
        totalFailovers: number;
        totalFramesReplayed: number;
        totalFramesSent: number;
        totalMemoryReplayAppendTimeouts: number;
        totalMemoryReplayBackpressureStalls: number;
        totalReconnectAttempts: number;
        totalReconnectErrors: number;
        totalReconnectsSucceeded: number;
        totalServerNacks: number;
        waitingMemoryReplayAppends: number;
    }
    Index

    Properties

    acknowledgedFrameSequence: bigint

    Highest replay-frame sequence removed from store-and-forward.

    -
    deliveredConnectionNotifications?: number
    deliveredErrorNotifications?: number
    droppedConnectionNotifications?: number
    droppedErrorNotifications?: number
    memoryReplayMaxBytes?: number

    Configured cap for the built-in memory replay store.

    -
    memoryReplayUsedBytes?: number

    Estimated payload and record-bookkeeping bytes charged to that cap.

    -
    pendingReplayBytes: number
    pendingReplayFrames: number
    publishedFrameSequence: bigint

    Highest stable replay-frame sequence handed to the transport.

    -
    totalBytesReplayed: number
    totalBytesSent: number
    totalFailovers: number
    totalFramesReplayed: number
    totalFramesSent: number

    Physical WebSocket sends, including replay and dictionary catch-up.

    -
    totalMemoryReplayAppendTimeouts: number
    totalMemoryReplayBackpressureStalls: number
    totalReconnectAttempts: number
    totalReconnectErrors: number
    totalReconnectsSucceeded: number
    totalServerNacks: number
    waitingMemoryReplayAppends: number
    +
    deliveredConnectionNotifications?: number
    deliveredErrorNotifications?: number
    droppedConnectionNotifications?: number
    droppedErrorNotifications?: number
    memoryReplayMaxBytes?: number

    Configured cap for the built-in memory replay store.

    +
    memoryReplayUsedBytes?: number

    Estimated payload and record-bookkeeping bytes charged to that cap.

    +
    pendingReplayBytes: number
    pendingReplayFrames: number
    publishedFrameSequence: bigint

    Highest stable replay-frame sequence handed to the transport.

    +
    totalBytesReplayed: number
    totalBytesSent: number
    totalFailovers: number
    totalFramesReplayed: number
    totalFramesSent: number

    Physical WebSocket sends, including replay and dictionary catch-up.

    +
    totalMemoryReplayAppendTimeouts: number
    totalMemoryReplayBackpressureStalls: number
    totalReconnectAttempts: number
    totalReconnectErrors: number
    totalReconnectsSucceeded: number
    totalServerNacks: number
    waitingMemoryReplayAppends: number
    diff --git a/docs/interfaces/_questdb_nodejs-client.QwpLong256Value.html b/docs/interfaces/_questdb_nodejs-client.QwpLong256Value.html index b29f140..4e91de7 100644 --- a/docs/interfaces/_questdb_nodejs-client.QwpLong256Value.html +++ b/docs/interfaces/_questdb_nodejs-client.QwpLong256Value.html @@ -1,3 +1,3 @@ -QwpLong256Value | QuestDB JavaScript Client - v4.2.0

    Interface QwpLong256Value

    interface QwpLong256Value {
        words: readonly [bigint, bigint, bigint, bigint];
    }
    Index

    Properties

    words +QwpLong256Value | QuestDB JavaScript Client - v4.2.0

    Interface QwpLong256Value

    interface QwpLong256Value {
        words: readonly [bigint, bigint, bigint, bigint];
    }
    Index

    Properties

    Properties

    words: readonly [bigint, bigint, bigint, bigint]

    Little-endian 64-bit words; word 0 is least significant.

    -
    +
    diff --git a/docs/interfaces/_questdb_nodejs-client.QwpNodeClientConfigOptions.html b/docs/interfaces/_questdb_nodejs-client.QwpNodeClientConfigOptions.html index 990e50b..a4e31b1 100644 --- a/docs/interfaces/_questdb_nodejs-client.QwpNodeClientConfigOptions.html +++ b/docs/interfaces/_questdb_nodejs-client.QwpNodeClientConfigOptions.html @@ -1,6 +1,6 @@ QwpNodeClientConfigOptions | QuestDB JavaScript Client - v4.2.0

    Interface QwpNodeClientConfigOptions

    Programmatic hooks layered over a unified ws/wss cluster string. Values in this object take precedence after the complete string has been validated.

    -
    interface QwpNodeClientConfigOptions {
        egress?: Partial<
            Pick<
                QwpNodeEgressOptions,
                "target"
                | "zone"
                | "compression"
                | "compressionLevel"
                | "maxBatchRows",
            >,
        >;
        egressSession?: QwpEgressSessionOptions;
        ingressSession?: QwpIngressSessionOptions;
        pool?: QwpClientPoolOptions;
        sender?: QwpSenderOptions;
        storeAndForward?: QwpNodeStoreAndForwardOptions;
        webSocket?: Partial<Omit<QwpNodeWebSocketOptions, "url" | "failoverUrls">>;
    }
    Index

    Properties

    interface QwpNodeClientConfigOptions {
        egress?: Partial<
            Pick<
                QwpNodeEgressOptions,
                "target"
                | "zone"
                | "compression"
                | "compressionLevel"
                | "maxBatchRows",
            >,
        >;
        egressSession?: QwpEgressSessionOptions;
        ingressSession?: QwpIngressSessionOptions;
        pool?: QwpClientPoolOptions;
        sender?: QwpSenderOptions;
        storeAndForward?: QwpNodeStoreAndForwardOptions;
        webSocket?: Partial<Omit<QwpNodeWebSocketOptions, "url" | "failoverUrls">>;
    }
    Index

    Properties

    egress?: Partial<
        Pick<
            QwpNodeEgressOptions,
            "target"
            | "zone"
            | "compression"
            | "compressionLevel"
            | "maxBatchRows",
        >,
    >

    Egress-only routing and compression overrides.

    -
    egressSession?: QwpEgressSessionOptions
    ingressSession?: QwpIngressSessionOptions

    Optional persistent ingress configuration; may supply/override sf_dir.

    -
    webSocket?: Partial<Omit<QwpNodeWebSocketOptions, "url" | "failoverUrls">>

    Shared transport overrides applied to both ingress and egress.

    -
    +
    egressSession?: QwpEgressSessionOptions
    ingressSession?: QwpIngressSessionOptions

    Optional persistent ingress configuration; may supply/override sf_dir.

    +
    webSocket?: Partial<Omit<QwpNodeWebSocketOptions, "url" | "failoverUrls">>

    Shared transport overrides applied to both ingress and egress.

    +
    diff --git a/docs/interfaces/_questdb_nodejs-client.QwpNodeClientOptions.html b/docs/interfaces/_questdb_nodejs-client.QwpNodeClientOptions.html index 9c9bcb1..b993eeb 100644 --- a/docs/interfaces/_questdb_nodejs-client.QwpNodeClientOptions.html +++ b/docs/interfaces/_questdb_nodejs-client.QwpNodeClientOptions.html @@ -1,13 +1,13 @@ QwpNodeClientOptions | QuestDB JavaScript Client - v4.2.0

    Interface QwpNodeClientOptions

    Node configuration for a combined pooled QWP ingress/egress client.

    -
    Index

    Properties

    Index

    Properties

    egressSession?: QwpEgressSessionOptions
    ingressSession?: QwpIngressSessionOptions
    lazyConnect?: boolean

    Coordinates a non-blocking startup: ingress connects in the background, +

    Properties

    egressSession?: QwpEgressSessionOptions
    ingressSession?: QwpIngressSessionOptions
    lazyConnect?: boolean

    Coordinates a non-blocking startup: ingress connects in the background, using memory replay when store-and-forward is absent, and the egress pool remains cold until the first query. Conflicts with a positive queryPoolMin or a non-async initialConnectMode.

    -
    +
    diff --git a/docs/interfaces/_questdb_nodejs-client.QwpNodeEgressOptions.html b/docs/interfaces/_questdb_nodejs-client.QwpNodeEgressOptions.html index 6c46300..3a4fb50 100644 --- a/docs/interfaces/_questdb_nodejs-client.QwpNodeEgressOptions.html +++ b/docs/interfaces/_questdb_nodejs-client.QwpNodeEgressOptions.html @@ -1,7 +1,7 @@ QwpNodeEgressOptions | QuestDB JavaScript Client - v4.2.0

    Interface QwpNodeEgressOptions

    Endpoint routing preferences. Named for egress, where they landed first, but ingress ranks and validates its endpoints with the same machinery and honours the same two keys.

    -
    interface QwpNodeEgressOptions {
        agent?: Agent;
        authorization?: string;
        authTimeoutMs?: number;
        clientId?: string;
        closeTimeoutMs?: number;
        compression?: QwpEgressCompression;
        compressionLevel?: number;
        connectTimeoutMs?: number;
        failoverUrls?: readonly (string | URL)[];
        headers?: Record<string, string>;
        maxBatchRows?: number;
        maxVersion?: number;
        protocols?: string | string[];
        requestDurableAck?: boolean;
        sendTimeoutMs?: number;
        target?: QwpTarget;
        url: string | URL;
        webSocketFactory?: (
            url: string | URL,
            options: {
                agent?: Agent;
                headers: Record<string, string>;
                onConnected: () => void;
                onUpgrade: (headers: IncomingHttpHeaders) => void;
                onUpgradeRejected: (rejection: QwpNodeUpgradeRejection) => void;
                protocols?: string | string[];
            },
        ) => QwpWebSocketLike;
        zone?: string;
    }

    Hierarchy (View Summary)

    Index

    Properties

    interface QwpNodeEgressOptions {
        agent?: Agent;
        authorization?: string;
        authTimeoutMs?: number;
        clientId?: string;
        closeTimeoutMs?: number;
        compression?: QwpEgressCompression;
        compressionLevel?: number;
        connectTimeoutMs?: number;
        failoverUrls?: readonly (string | URL)[];
        headers?: Record<string, string>;
        maxBatchRows?: number;
        maxVersion?: number;
        protocols?: string | string[];
        requestDurableAck?: boolean;
        sendTimeoutMs?: number;
        target?: QwpTarget;
        url: string | URL;
        webSocketFactory?: (
            url: string | URL,
            options: {
                agent?: Agent;
                headers: Record<string, string>;
                onConnected: () => void;
                onUpgrade: (headers: IncomingHttpHeaders) => void;
                onUpgradeRejected: (rejection: QwpNodeUpgradeRejection) => void;
                protocols?: string | string[];
            },
        ) => QwpWebSocketLike;
        zone?: string;
    }

    Hierarchy (View Summary)

    Index

    Properties

    agent?: Agent

    Optional HTTP(S) agent used for the WebSocket upgrade.

    -
    authorization?: string
    authTimeoutMs?: number

    Time allowed after TCP/TLS connection for HTTP authentication and the +

    authorization?: string
    authTimeoutMs?: number

    Time allowed after TCP/TLS connection for HTTP authentication and the WebSocket upgrade. Defaults to 15s.

    -
    clientId?: string
    closeTimeoutMs?: number

    Maximum time allowed for a graceful WebSocket close. Defaults to 15s.

    -
    compression?: QwpEgressCompression

    Requests Zstd-compressed result batches. The default is raw, which +

    clientId?: string
    closeTimeoutMs?: number

    Maximum time allowed for a graceful WebSocket close. Defaults to 15s.

    +
    compression?: QwpEgressCompression

    Requests Zstd-compressed result batches. The default is raw, which preserves compatibility with servers that predate QWP compression. auto currently advertises the same ordered preference as zstd.

    -
    compressionLevel?: number

    Zstd level hint sent to the server. Must be between 1 and 22.

    -
    connectTimeoutMs?: number

    Node TCP/TLS connection deadline, or the complete opening deadline in a +

    compressionLevel?: number

    Zstd level hint sent to the server. Must be between 1 and 22.

    +
    connectTimeoutMs?: number

    Node TCP/TLS connection deadline, or the complete opening deadline in a browser. Defaults to 15s.

    -
    failoverUrls?: readonly (string | URL)[]

    Additional endpoints attempted in order when the preferred endpoint fails.

    -
    headers?: Record<string, string>
    maxBatchRows?: number

    Requests a server-side RESULT_BATCH row cap.

    -
    maxVersion?: number
    protocols?: string | string[]
    requestDurableAck?: boolean
    sendTimeoutMs?: number

    Maximum time a send may remain queued by the WebSocket. Defaults to 15s.

    -
    target?: QwpTarget

    Selects any readable node, a primary/standalone node, or a replica.

    -
    url: string | URL
    webSocketFactory?: (
        url: string | URL,
        options: {
            agent?: Agent;
            headers: Record<string, string>;
            onConnected: () => void;
            onUpgrade: (headers: IncomingHttpHeaders) => void;
            onUpgradeRejected: (rejection: QwpNodeUpgradeRejection) => void;
            protocols?: string | string[];
        },
    ) => QwpWebSocketLike

    Test hook; defaults to the Node-only ws implementation.

    -
    zone?: string

    Opaque, case-insensitive preferred zone; cross-zone fallback stays enabled.

    -
    +
    failoverUrls?: readonly (string | URL)[]

    Additional endpoints attempted in order when the preferred endpoint fails.

    +
    headers?: Record<string, string>
    maxBatchRows?: number

    Requests a server-side RESULT_BATCH row cap.

    +
    maxVersion?: number
    protocols?: string | string[]
    requestDurableAck?: boolean

    Ingress-only. Durable ACK is negotiated on /write/v4; egress ignores it +and egressTransportOptions() strips it, because sending the header on +/read/v1 makes every query session fail the capability check.

    +
    sendTimeoutMs?: number

    Maximum time a send may remain queued by the WebSocket. Defaults to 15s.

    +
    target?: QwpTarget

    Selects any readable node, a primary/standalone node, or a replica.

    +
    url: string | URL
    webSocketFactory?: (
        url: string | URL,
        options: {
            agent?: Agent;
            headers: Record<string, string>;
            onConnected: () => void;
            onUpgrade: (headers: IncomingHttpHeaders) => void;
            onUpgradeRejected: (rejection: QwpNodeUpgradeRejection) => void;
            protocols?: string | string[];
        },
    ) => QwpWebSocketLike

    Test hook; defaults to the Node-only ws implementation.

    +
    zone?: string

    Opaque, case-insensitive preferred zone; cross-zone fallback stays enabled.

    +
    diff --git a/docs/interfaces/_questdb_nodejs-client.QwpNodeFileReplayStoreMetrics.html b/docs/interfaces/_questdb_nodejs-client.QwpNodeFileReplayStoreMetrics.html index eb3d267..bfa526a 100644 --- a/docs/interfaces/_questdb_nodejs-client.QwpNodeFileReplayStoreMetrics.html +++ b/docs/interfaces/_questdb_nodejs-client.QwpNodeFileReplayStoreMetrics.html @@ -1,4 +1,4 @@ -QwpNodeFileReplayStoreMetrics | QuestDB JavaScript Client - v4.2.0

    Interface QwpNodeFileReplayStoreMetrics

    interface QwpNodeFileReplayStoreMetrics {
        backpressurePolicy: QwpSfBackpressurePolicy;
        checkpointPending: boolean;
        dirtyRecords: number;
        durability: QwpSfDurability;
        lastCheckpointError?: QwpReplayStoreCheckpointError;
        pendingRecords: number;
        pendingSegments: number;
        totalAppendTimeouts: number;
        totalBackpressureStalls: number;
        totalBytes: number;
        totalCheckpointFailures: number;
        totalCheckpoints: number;
        waitingAppends: number;
    }
    Index

    Properties

    backpressurePolicy +QwpNodeFileReplayStoreMetrics | QuestDB JavaScript Client - v4.2.0

    Interface QwpNodeFileReplayStoreMetrics

    interface QwpNodeFileReplayStoreMetrics {
        backpressurePolicy: QwpSfBackpressurePolicy;
        checkpointPending: boolean;
        dirtyRecords: number;
        durability: QwpSfDurability;
        lastCheckpointError?: QwpReplayStoreCheckpointError;
        pendingRecords: number;
        pendingSegments: number;
        totalAppendTimeouts: number;
        totalBackpressureStalls: number;
        totalBytes: number;
        totalCheckpointFailures: number;
        totalCheckpoints: number;
        waitingAppends: number;
    }
    Index

    Properties

    backpressurePolicy: QwpSfBackpressurePolicy
    checkpointPending: boolean
    dirtyRecords: number
    durability: QwpSfDurability
    lastCheckpointError?: QwpReplayStoreCheckpointError
    pendingRecords: number
    pendingSegments: number
    totalAppendTimeouts: number
    totalBackpressureStalls: number
    totalBytes: number
    totalCheckpointFailures: number
    totalCheckpoints: number
    waitingAppends: number
    +

    Properties

    backpressurePolicy: QwpSfBackpressurePolicy
    checkpointPending: boolean
    dirtyRecords: number
    durability: QwpSfDurability
    lastCheckpointError?: QwpReplayStoreCheckpointError
    pendingRecords: number
    pendingSegments: number
    totalAppendTimeouts: number
    totalBackpressureStalls: number
    totalBytes: number
    totalCheckpointFailures: number
    totalCheckpoints: number
    waitingAppends: number
    diff --git a/docs/interfaces/_questdb_nodejs-client.QwpNodeFileReplayStoreOptions.html b/docs/interfaces/_questdb_nodejs-client.QwpNodeFileReplayStoreOptions.html index eeac681..070d2ad 100644 --- a/docs/interfaces/_questdb_nodejs-client.QwpNodeFileReplayStoreOptions.html +++ b/docs/interfaces/_questdb_nodejs-client.QwpNodeFileReplayStoreOptions.html @@ -1,4 +1,4 @@ -QwpNodeFileReplayStoreOptions | QuestDB JavaScript Client - v4.2.0

    Interface QwpNodeFileReplayStoreOptions

    interface QwpNodeFileReplayStoreOptions {
        appendDeadlineMs?: number;
        backpressurePolicy?: QwpSfBackpressurePolicy;
        checkpointIntervalMs?: number;
        directory: string;
        durability?: QwpSfDurability;
        maxBytes?: number;
        maxSegmentBytes?: number;
        onRecoveryDataLoss?: (report: QwpNodeReplayDataLossReport) => void;
    }

    Hierarchy (View Summary)

    Index

    Properties

    appendDeadlineMs? +QwpNodeFileReplayStoreOptions | QuestDB JavaScript Client - v4.2.0

    Interface QwpNodeFileReplayStoreOptions

    interface QwpNodeFileReplayStoreOptions {
        appendDeadlineMs?: number;
        backpressurePolicy?: QwpSfBackpressurePolicy;
        checkpointIntervalMs?: number;
        directory: string;
        durability?: QwpSfDurability;
        maxBytes?: number;
        maxSegmentBytes?: number;
        onRecoveryDataLoss?: (report: QwpNodeReplayDataLossReport) => void;
    }

    Hierarchy (View Summary)

    Index

    Properties

    appendDeadlineMs?: number

    Per-append capacity or retryable store-fault deadline. Defaults to 30 seconds.

    -
    backpressurePolicy?: QwpSfBackpressurePolicy

    Behavior when maxBytes is exhausted. error fails immediately; wait +

    backpressurePolicy?: QwpSfBackpressurePolicy

    Behavior when maxBytes is exhausted. error fails immediately; wait pauses the append until ACK trimming frees space or its deadline expires. Defaults to error for backwards compatibility.

    -
    checkpointIntervalMs?: number

    Periodic durability checkpoint cadence. Defaults to 5 seconds.

    -
    directory: string

    Exclusive directory used by one ingress session.

    -
    durability?: QwpSfDurability

    Local persistence barrier. append preserves the existing fsync-per-frame +

    checkpointIntervalMs?: number

    Periodic durability checkpoint cadence. Defaults to 5 seconds.

    +
    directory: string

    Exclusive directory used by one ingress session.

    +
    durability?: QwpSfDurability

    Local persistence barrier. append preserves the existing fsync-per-frame behavior, periodic checkpoints dirty files in the background, and memory relies on OS page-cache writeback. Defaults to append.

    -
    maxBytes?: number

    Target maximum journal size including fixed segment reservations and +

    maxBytes?: number

    Target maximum journal size including fixed segment reservations and symbol metadata. Defaults to 1 GiB. The current symbol dictionary may exceed this target so it cannot consume the journal's live frame budget before a drained close retires that dictionary generation.

    -
    maxSegmentBytes?: number

    Maximum QWP frame payload and target segment data size. Each fixed segment +

    maxSegmentBytes?: number

    Maximum QWP frame payload and target segment data size. Each fixed segment reserves this value plus one record header and its 24-byte SFA header, so a maximum-sized frame still fits. Defaults to 4 MiB.

    -
    onRecoveryDataLoss?: (report: QwpNodeReplayDataLossReport) => void

    Reports journal bytes abandoned during recovery. Defaults to logging at +

    onRecoveryDataLoss?: (report: QwpNodeReplayDataLossReport) => void

    Reports journal bytes abandoned during recovery. Defaults to logging at error level; recovery still succeeds, so this must never be silent.

    -
    +
    diff --git a/docs/interfaces/_questdb_nodejs-client.QwpNodeIngressOptions.html b/docs/interfaces/_questdb_nodejs-client.QwpNodeIngressOptions.html index e0bffdf..4a2372a 100644 --- a/docs/interfaces/_questdb_nodejs-client.QwpNodeIngressOptions.html +++ b/docs/interfaces/_questdb_nodejs-client.QwpNodeIngressOptions.html @@ -1,7 +1,7 @@ QwpNodeIngressOptions | QuestDB JavaScript Client - v4.2.0

    Interface QwpNodeIngressOptions

    Endpoint routing preferences. Named for egress, where they landed first, but ingress ranks and validates its endpoints with the same machinery and honours the same two keys.

    -
    interface QwpNodeIngressOptions {
        agent?: Agent;
        authorization?: string;
        authTimeoutMs?: number;
        clientId?: string;
        closeTimeoutMs?: number;
        connectTimeoutMs?: number;
        failoverUrls?: readonly (string | URL)[];
        headers?: Record<string, string>;
        maxVersion?: number;
        protocols?: string | string[];
        requestDurableAck?: boolean;
        senderId?: string;
        sendTimeoutMs?: number;
        storeAndForward?: QwpNodeStoreAndForwardOptions;
        target?: QwpTarget;
        url: string | URL;
        webSocketFactory?: (
            url: string | URL,
            options: {
                agent?: Agent;
                headers: Record<string, string>;
                onConnected: () => void;
                onUpgrade: (headers: IncomingHttpHeaders) => void;
                onUpgradeRejected: (rejection: QwpNodeUpgradeRejection) => void;
                protocols?: string | string[];
            },
        ) => QwpWebSocketLike;
        zone?: string;
    }

    Hierarchy (View Summary)

    Index

    Properties

    interface QwpNodeIngressOptions {
        agent?: Agent;
        authorization?: string;
        authTimeoutMs?: number;
        clientId?: string;
        closeTimeoutMs?: number;
        connectTimeoutMs?: number;
        failoverUrls?: readonly (string | URL)[];
        headers?: Record<string, string>;
        maxVersion?: number;
        protocols?: string | string[];
        requestDurableAck?: boolean;
        senderId?: string;
        sendTimeoutMs?: number;
        storeAndForward?: QwpNodeStoreAndForwardOptions;
        target?: QwpTarget;
        url: string | URL;
        webSocketFactory?: (
            url: string | URL,
            options: {
                agent?: Agent;
                headers: Record<string, string>;
                onConnected: () => void;
                onUpgrade: (headers: IncomingHttpHeaders) => void;
                onUpgradeRejected: (rejection: QwpNodeUpgradeRejection) => void;
                protocols?: string | string[];
            },
        ) => QwpWebSocketLike;
        zone?: string;
    }

    Hierarchy (View Summary)

    Index

    Properties

    agent?: Agent

    Optional HTTP(S) agent used for the WebSocket upgrade.

    -
    authorization?: string
    authTimeoutMs?: number

    Time allowed after TCP/TLS connection for HTTP authentication and the +

    authorization?: string
    authTimeoutMs?: number

    Time allowed after TCP/TLS connection for HTTP authentication and the WebSocket upgrade. Defaults to 15s.

    -
    clientId?: string
    closeTimeoutMs?: number

    Maximum time allowed for a graceful WebSocket close. Defaults to 15s.

    -
    connectTimeoutMs?: number

    Node TCP/TLS connection deadline, or the complete opening deadline in a +

    clientId?: string
    closeTimeoutMs?: number

    Maximum time allowed for a graceful WebSocket close. Defaults to 15s.

    +
    connectTimeoutMs?: number

    Node TCP/TLS connection deadline, or the complete opening deadline in a browser. Defaults to 15s.

    -
    failoverUrls?: readonly (string | URL)[]

    Additional endpoints attempted in order when the preferred endpoint fails.

    -
    headers?: Record<string, string>
    maxVersion?: number
    protocols?: string | string[]
    requestDurableAck?: boolean
    senderId?: string

    Slot name below storeAndForward.directory. Unified configurations default -to default; pooled clients derive <senderId>-<slot> names.

    -
    sendTimeoutMs?: number

    Maximum time a send may remain queued by the WebSocket. Defaults to 15s.

    -

    Upgrades the default in-memory ingress replay to persistent Node +

    failoverUrls?: readonly (string | URL)[]

    Additional endpoints attempted in order when the preferred endpoint fails.

    +
    headers?: Record<string, string>
    maxVersion?: number
    protocols?: string | string[]
    requestDurableAck?: boolean

    Ingress-only. Durable ACK is negotiated on /write/v4; egress ignores it +and egressTransportOptions() strips it, because sending the header on +/read/v1 makes every query session fail the capability check.

    +
    senderId?: string

    Slot name below storeAndForward.directory.

    +

    A connect string defaults it to default. Through the typed API it has no +default: a standalone sender writes straight into directory, and a +pooled client derives sender-<slot> names, or <senderId>-<slot> when +this is set.

    +
    sendTimeoutMs?: number

    Maximum time a send may remain queued by the WebSocket. Defaults to 15s.

    +

    Upgrades the default in-memory ingress replay to persistent Node store-and-forward. Use a directory owned exclusively by this session.

    -
    target?: QwpTarget

    Selects any readable node, a primary/standalone node, or a replica.

    -
    url: string | URL
    webSocketFactory?: (
        url: string | URL,
        options: {
            agent?: Agent;
            headers: Record<string, string>;
            onConnected: () => void;
            onUpgrade: (headers: IncomingHttpHeaders) => void;
            onUpgradeRejected: (rejection: QwpNodeUpgradeRejection) => void;
            protocols?: string | string[];
        },
    ) => QwpWebSocketLike

    Test hook; defaults to the Node-only ws implementation.

    -
    zone?: string

    Opaque, case-insensitive preferred zone; cross-zone fallback stays enabled.

    -
    +
    target?: QwpTarget

    Selects any readable node, a primary/standalone node, or a replica.

    +
    url: string | URL
    webSocketFactory?: (
        url: string | URL,
        options: {
            agent?: Agent;
            headers: Record<string, string>;
            onConnected: () => void;
            onUpgrade: (headers: IncomingHttpHeaders) => void;
            onUpgradeRejected: (rejection: QwpNodeUpgradeRejection) => void;
            protocols?: string | string[];
        },
    ) => QwpWebSocketLike

    Test hook; defaults to the Node-only ws implementation.

    +
    zone?: string

    Opaque, case-insensitive preferred zone; cross-zone fallback stays enabled.

    +
    diff --git a/docs/interfaces/_questdb_nodejs-client.QwpNodeOrphanDrainEvent.html b/docs/interfaces/_questdb_nodejs-client.QwpNodeOrphanDrainEvent.html index 582a1d7..ed865ce 100644 --- a/docs/interfaces/_questdb_nodejs-client.QwpNodeOrphanDrainEvent.html +++ b/docs/interfaces/_questdb_nodejs-client.QwpNodeOrphanDrainEvent.html @@ -1,4 +1,4 @@ -QwpNodeOrphanDrainEvent | QuestDB JavaScript Client - v4.2.0

    Interface QwpNodeOrphanDrainEvent

    interface QwpNodeOrphanDrainEvent {
        attempt?: number;
        directory?: string;
        episodeMs?: number;
        error?: Error;
        kind: QwpNodeOrphanDrainEventKind;
        metrics: QwpNodeOrphanDrainerMetrics;
        senderError?: QwpSenderError;
        timestampMs: number;
    }
    Index

    Properties

    attempt? +QwpNodeOrphanDrainEvent | QuestDB JavaScript Client - v4.2.0

    Interface QwpNodeOrphanDrainEvent

    interface QwpNodeOrphanDrainEvent {
        attempt?: number;
        directory?: string;
        episodeMs?: number;
        error?: Error;
        kind: QwpNodeOrphanDrainEventKind;
        metrics: QwpNodeOrphanDrainerMetrics;
        senderError?: QwpSenderError;
        timestampMs: number;
    }
    Index

    Properties

    attempt?: number

    One-based attempt in the current capability/topology episode.

    -
    directory?: string
    episodeMs?: number

    Elapsed time in the current consecutive capability-gap episode.

    -
    error?: Error
    senderError?: QwpSenderError

    Present when a failed slot has been abandoned behind its sentinel.

    -
    timestampMs: number
    +
    directory?: string
    episodeMs?: number

    Elapsed time in the current consecutive capability-gap episode.

    +
    error?: Error
    senderError?: QwpSenderError

    Present when a failed slot has been abandoned behind its sentinel.

    +
    timestampMs: number
    diff --git a/docs/interfaces/_questdb_nodejs-client.QwpNodeOrphanDrainSession.html b/docs/interfaces/_questdb_nodejs-client.QwpNodeOrphanDrainSession.html index fb3c235..f1223cd 100644 --- a/docs/interfaces/_questdb_nodejs-client.QwpNodeOrphanDrainSession.html +++ b/docs/interfaces/_questdb_nodejs-client.QwpNodeOrphanDrainSession.html @@ -1,7 +1,7 @@ QwpNodeOrphanDrainSession | QuestDB JavaScript Client - v4.2.0

    Interface QwpNodeOrphanDrainSession

    Minimal session surface used by the Node orphan drainer.

    -
    interface QwpNodeOrphanDrainSession {
        closed: Promise<QwpConnectionCloseInfo>;
        metrics: Pick<
            QwpIngressTransportMetrics,
            "pendingReplayFrames"
            | "pendingReplayBytes",
        > & { lastError?: Error };
        close(code?: number, reason?: string): Promise<void>;
        pollDurableAck(): Promise<void>;
    }
    Index

    Properties

    interface QwpNodeOrphanDrainSession {
        closed: Promise<QwpConnectionCloseInfo>;
        metrics: Pick<
            QwpIngressTransportMetrics,
            "pendingReplayFrames"
            | "pendingReplayBytes",
        > & { lastError?: Error };
        close(code?: number, reason?: string): Promise<void>;
        pollDurableAck(): Promise<void>;
    }
    Index

    Properties

    Methods

    Properties

    closed: Promise<QwpConnectionCloseInfo>
    metrics: Pick<
        QwpIngressTransportMetrics,
        "pendingReplayFrames"
        | "pendingReplayBytes",
    > & { lastError?: Error }

    Methods

    +

    Properties

    closed: Promise<QwpConnectionCloseInfo>
    metrics: Pick<
        QwpIngressTransportMetrics,
        "pendingReplayFrames"
        | "pendingReplayBytes",
    > & { lastError?: Error }

    Methods

    diff --git a/docs/interfaces/_questdb_nodejs-client.QwpNodeOrphanDrainerMetrics.html b/docs/interfaces/_questdb_nodejs-client.QwpNodeOrphanDrainerMetrics.html index a9d3833..5a257c0 100644 --- a/docs/interfaces/_questdb_nodejs-client.QwpNodeOrphanDrainerMetrics.html +++ b/docs/interfaces/_questdb_nodejs-client.QwpNodeOrphanDrainerMetrics.html @@ -1,4 +1,4 @@ -QwpNodeOrphanDrainerMetrics | QuestDB JavaScript Client - v4.2.0

    Interface QwpNodeOrphanDrainerMetrics

    interface QwpNodeOrphanDrainerMetrics {
        active: number;
        closed: boolean;
        closing: boolean;
        deliveredErrorNotifications: number;
        deliveredNotifications: number;
        discovered: number;
        drained: number;
        droppedErrorNotifications: number;
        droppedNotifications: number;
        failed: number;
        locked: number;
        queued: number;
        retrying: number;
        scanFailures: number;
        scans: number;
    }
    Index

    Properties

    active +QwpNodeOrphanDrainerMetrics | QuestDB JavaScript Client - v4.2.0

    Interface QwpNodeOrphanDrainerMetrics

    interface QwpNodeOrphanDrainerMetrics {
        active: number;
        closed: boolean;
        closing: boolean;
        deliveredErrorNotifications: number;
        deliveredNotifications: number;
        discovered: number;
        drained: number;
        droppedErrorNotifications: number;
        droppedNotifications: number;
        failed: number;
        locked: number;
        queued: number;
        retrying: number;
        scanFailures: number;
        scans: number;
    }
    Index

    Properties

    active: number
    closed: boolean
    closing: boolean
    deliveredErrorNotifications: number
    deliveredNotifications: number
    discovered: number
    drained: number
    droppedErrorNotifications: number
    droppedNotifications: number
    failed: number
    locked: number
    queued: number
    retrying: number

    Attempts that failed transiently and left the slot in place.

    -
    scanFailures: number
    scans: number
    +

    Properties

    active: number
    closed: boolean
    closing: boolean
    deliveredErrorNotifications: number
    deliveredNotifications: number
    discovered: number
    drained: number
    droppedErrorNotifications: number
    droppedNotifications: number
    failed: number
    locked: number
    queued: number
    retrying: number

    Attempts that failed transiently and left the slot in place.

    +
    scanFailures: number
    scans: number
    diff --git a/docs/interfaces/_questdb_nodejs-client.QwpNodeOrphanDrainerOptions.html b/docs/interfaces/_questdb_nodejs-client.QwpNodeOrphanDrainerOptions.html index ee828f9..b29304f 100644 --- a/docs/interfaces/_questdb_nodejs-client.QwpNodeOrphanDrainerOptions.html +++ b/docs/interfaces/_questdb_nodejs-client.QwpNodeOrphanDrainerOptions.html @@ -1,4 +1,4 @@ -QwpNodeOrphanDrainerOptions | QuestDB JavaScript Client - v4.2.0

    Interface QwpNodeOrphanDrainerOptions

    interface QwpNodeOrphanDrainerOptions {
        durableAckPollIntervalMs?: number;
        errorInboxCapacity?: number;
        eventInboxCapacity?: number;
        excludeSlot?: (slotName: string) => boolean;
        maxConcurrent?: number;
        onEvent?: (event: QwpNodeOrphanDrainEvent) => void;
        onSenderError?: (error: QwpSenderError) => void;
        releaseSlot?: (directory: string) => void;
        rootDirectory: string;
        scanIntervalMs?: number;
        tryReserveSlot?: (directory: string) => boolean;
        createSession(
            directory: string,
            onReconnectEvent?: (event: QwpReconnectEvent) => void,
        ): Promise<QwpNodeOrphanDrainSession>;
    }
    Index

    Properties

    durableAckPollIntervalMs? +QwpNodeOrphanDrainerOptions | QuestDB JavaScript Client - v4.2.0

    Interface QwpNodeOrphanDrainerOptions

    interface QwpNodeOrphanDrainerOptions {
        durableAckPollIntervalMs?: number;
        errorInboxCapacity?: number;
        eventInboxCapacity?: number;
        excludeSlot?: (slotName: string) => boolean;
        maxConcurrent?: number;
        onEvent?: (event: QwpNodeOrphanDrainEvent) => void;
        onSenderError?: (error: QwpSenderError) => void;
        releaseSlot?: (directory: string) => void;
        rootDirectory: string;
        scanIntervalMs?: number;
        tryReserveSlot?: (directory: string) => boolean;
        createSession(
            directory: string,
            onReconnectEvent?: (event: QwpReconnectEvent) => void,
        ): Promise<QwpNodeOrphanDrainSession>;
    }
    Index

    Properties

    durableAckPollIntervalMs?: number

    Durable-ACK prompt cadence for adopted sessions. Zero disables it.

    -
    errorInboxCapacity?: number

    Bounded data-loss inbox. Defaults to 256.

    -
    eventInboxCapacity?: number

    Bounded lifecycle-event inbox. Defaults to 64.

    -
    excludeSlot?: (slotName: string) => boolean

    Slot names owned by the foreground producer/pool and never adoptable.

    -
    maxConcurrent?: number

    Maximum slots drained concurrently. Defaults to 4.

    -
    onEvent?: (event: QwpNodeOrphanDrainEvent) => void
    onSenderError?: (error: QwpSenderError) => void

    Java-parity data-loss notification for an abandoned orphan slot.

    -
    releaseSlot?: (directory: string) => void

    Releases a reservation previously granted by tryReserveSlot.

    -
    rootDirectory: string

    Directory whose child directories are independent replay slots.

    -
    scanIntervalMs?: number

    Periodic rescan cadence; zero disables the timer. Explicit scanNow() +

    errorInboxCapacity?: number

    Bounded data-loss inbox. Defaults to 256.

    +
    eventInboxCapacity?: number

    Bounded lifecycle-event inbox. Defaults to 64.

    +
    excludeSlot?: (slotName: string) => boolean

    Slot names owned by the foreground producer/pool and never adoptable.

    +
    maxConcurrent?: number

    Maximum slots drained concurrently. Defaults to 4.

    +
    onEvent?: (event: QwpNodeOrphanDrainEvent) => void
    onSenderError?: (error: QwpSenderError) => void

    Java-parity data-loss notification for an abandoned orphan slot.

    +
    releaseSlot?: (directory: string) => void

    Releases a reservation previously granted by tryReserveSlot.

    +
    rootDirectory: string

    Directory whose child directories are independent replay slots.

    +
    scanIntervalMs?: number

    Periodic rescan cadence; zero disables the timer. Explicit scanNow() requests remain available. Defaults to 30s.

    -
    tryReserveSlot?: (directory: string) => boolean

    Atomically reserves a candidate against a foreground pool owner.

    -

    Methods

    +
    tryReserveSlot?: (directory: string) => boolean

    Atomically reserves a candidate against a foreground pool owner.

    +

    Methods

    diff --git a/docs/interfaces/_questdb_nodejs-client.QwpNodeReplayDataLossReport.html b/docs/interfaces/_questdb_nodejs-client.QwpNodeReplayDataLossReport.html index f9ce148..d16d772 100644 --- a/docs/interfaces/_questdb_nodejs-client.QwpNodeReplayDataLossReport.html +++ b/docs/interfaces/_questdb_nodejs-client.QwpNodeReplayDataLossReport.html @@ -2,9 +2,12 @@ failing recovery when the damage sits in the active segment, matching the Java client, which zeroes an active torn tail by policy and reports the residue through a WARN plus MmapSegment.tornTailBytes().

    -
    interface QwpNodeReplayDataLossReport {
        directory: string;
        discardedBytes: number;
        reason: string;
        segmentFile: string;
    }
    Index

    Properties

    interface QwpNodeReplayDataLossReport {
        directory: string;
        discardedBytes: number;
        reason: string;
        segmentFile: string;
    }
    Index

    Properties

    directory: string
    discardedBytes: number

    Bytes at and after the damaged record that recovery could not retain.

    -
    reason: string
    segmentFile: string
    +

    Properties

    directory: string
    discardedBytes: number

    Bytes at and after the damaged record that recovery could not retain.

    +

    Zero means a loss was detected whose extent the journal cannot measure -- +a segment whose records are gone leaves nothing to count. Treat it as +"unknown", not as "nothing lost", and read reason.

    +
    reason: string
    segmentFile: string
    diff --git a/docs/interfaces/_questdb_nodejs-client.QwpNodeReplayRecoveryEvent.html b/docs/interfaces/_questdb_nodejs-client.QwpNodeReplayRecoveryEvent.html index 9ecdd0c..9255939 100644 --- a/docs/interfaces/_questdb_nodejs-client.QwpNodeReplayRecoveryEvent.html +++ b/docs/interfaces/_questdb_nodejs-client.QwpNodeReplayRecoveryEvent.html @@ -1,7 +1,7 @@ QwpNodeReplayRecoveryEvent | QuestDB JavaScript Client - v4.2.0

    Interface QwpNodeReplayRecoveryEvent

    Notification that an unreplayable foreground slot was preserved aside.

    -
    interface QwpNodeReplayRecoveryEvent {
        directory: string;
        error: QwpReplayStoreQuarantinedError;
        quarantineDirectory: string;
        senderError: QwpSenderError;
        timestampMs: number;
    }
    Index

    Properties

    interface QwpNodeReplayRecoveryEvent {
        directory: string;
        error: QwpReplayStoreQuarantinedError;
        quarantineDirectory: string;
        senderError: QwpSenderError;
        timestampMs: number;
    }
    Index

    Properties

    directory: string
    quarantineDirectory: string
    senderError: QwpSenderError
    timestampMs: number
    +

    Properties

    directory: string
    quarantineDirectory: string
    senderError: QwpSenderError
    timestampMs: number
    diff --git a/docs/interfaces/_questdb_nodejs-client.QwpNodeStoreAndForwardOptions.html b/docs/interfaces/_questdb_nodejs-client.QwpNodeStoreAndForwardOptions.html index 9023d19..eeada2c 100644 --- a/docs/interfaces/_questdb_nodejs-client.QwpNodeStoreAndForwardOptions.html +++ b/docs/interfaces/_questdb_nodejs-client.QwpNodeStoreAndForwardOptions.html @@ -1,5 +1,5 @@ QwpNodeStoreAndForwardOptions | QuestDB JavaScript Client - v4.2.0

    Interface QwpNodeStoreAndForwardOptions

    Node store-and-forward controls layered on the crash-safe replay journal.

    -
    interface QwpNodeStoreAndForwardOptions {
        appendDeadlineMs?: number;
        backpressurePolicy?: QwpSfBackpressurePolicy;
        catchUpCapGapMinEscalationWindowMs?: number;
        checkpointIntervalMs?: number;
        directory: string;
        drainOrphans?: boolean;
        durability?: QwpSfDurability;
        initialConnectMode?: QwpInitialConnectMode;
        maxBackgroundDrainers?: number;
        maxBytes?: number;
        maxSegmentBytes?: number;
        onOrphanDrainEvent?: (event: QwpNodeOrphanDrainEvent) => void;
        onRecoveryDataLoss?: (report: QwpNodeReplayDataLossReport) => void;
        onRecoveryQuarantine?: (event: QwpNodeReplayRecoveryEvent) => void;
        orphanScanIntervalMs?: number;
    }

    Hierarchy (View Summary)

    Index

    Properties

    interface QwpNodeStoreAndForwardOptions {
        appendDeadlineMs?: number;
        backpressurePolicy?: QwpSfBackpressurePolicy;
        catchUpCapGapMinEscalationWindowMs?: number;
        checkpointIntervalMs?: number;
        directory: string;
        drainOrphans?: boolean;
        durability?: QwpSfDurability;
        initialConnectMode?: QwpInitialConnectMode;
        maxBackgroundDrainers?: number;
        maxBytes?: number;
        maxSegmentBytes?: number;
        onOrphanDrainEvent?: (event: QwpNodeOrphanDrainEvent) => void;
        onRecoveryDataLoss?: (report: QwpNodeReplayDataLossReport) => void;
        onRecoveryQuarantine?: (event: QwpNodeReplayRecoveryEvent) => void;
        orphanScanIntervalMs?: number;
    }

    Hierarchy (View Summary)

    Index

    Properties

    appendDeadlineMs?: number

    Per-append capacity or retryable store-fault deadline. Defaults to 30 seconds.

    -
    backpressurePolicy?: QwpSfBackpressurePolicy

    Behavior when maxBytes is exhausted. error fails immediately; wait +

    backpressurePolicy?: QwpSfBackpressurePolicy

    Behavior when maxBytes is exhausted. error fails immediately; wait pauses the append until ACK trimming frees space or its deadline expires. Defaults to error for backwards compatibility.

    -
    catchUpCapGapMinEscalationWindowMs?: number

    Minimum time an orphan slot's symbol catch-up cap gap must persist before +

    catchUpCapGapMinEscalationWindowMs?: number

    Minimum time an orphan slot's symbol catch-up cap gap must persist before it is quarantined. The gap must also be observed 16 times. Defaults to five minutes; zero uses the observation threshold alone.

    -
    checkpointIntervalMs?: number

    Periodic durability checkpoint cadence. Defaults to 5 seconds.

    -
    directory: string

    Exclusive directory used by one ingress session.

    -
    drainOrphans?: boolean

    Adopts sibling replay slots left by terminated producers. Standalone +

    checkpointIntervalMs?: number

    Periodic durability checkpoint cadence. Defaults to 5 seconds.

    +
    directory: string

    Exclusive directory used by one ingress session.

    +
    drainOrphans?: boolean

    Adopts sibling replay slots left by terminated producers. Standalone senders default this to false; pooled clients always recover their own idle in-range and out-of-range sender-N slots.

    -
    durability?: QwpSfDurability

    Local persistence barrier. append preserves the existing fsync-per-frame +

    durability?: QwpSfDurability

    Local persistence barrier. append preserves the existing fsync-per-frame behavior, periodic checkpoints dirty files in the background, and memory relies on OS page-cache writeback. Defaults to append.

    -
    initialConnectMode?: QwpInitialConnectMode

    Initial server connection policy. Defaults to off; an explicitly tuned +

    initialConnectMode?: QwpInitialConnectMode

    Initial server connection policy. Defaults to off; an explicitly tuned reconnect policy promotes it to sync, matching the Java client.

    -
    maxBackgroundDrainers?: number

    Maximum sibling slots drained concurrently. Defaults to 4.

    -
    maxBytes?: number

    Target maximum journal size including fixed segment reservations and +

    maxBackgroundDrainers?: number

    Maximum sibling slots drained concurrently. Defaults to 4.

    +
    maxBytes?: number

    Target maximum journal size including fixed segment reservations and symbol metadata. Defaults to 1 GiB. The current symbol dictionary may exceed this target so it cannot consume the journal's live frame budget before a drained close retires that dictionary generation.

    -
    maxSegmentBytes?: number

    Maximum QWP frame payload and target segment data size. Each fixed segment +

    maxSegmentBytes?: number

    Maximum QWP frame payload and target segment data size. Each fixed segment reserves this value plus one record header and its 24-byte SFA header, so a maximum-sized frame still fits. Defaults to 4 MiB.

    -
    onOrphanDrainEvent?: (event: QwpNodeOrphanDrainEvent) => void

    Receives isolated scanner, drainer, durable-ACK capability-gap, and +

    onOrphanDrainEvent?: (event: QwpNodeOrphanDrainEvent) => void

    Receives isolated scanner, drainer, durable-ACK capability-gap, and primary-unavailable lifecycle notifications.

    -
    onRecoveryDataLoss?: (report: QwpNodeReplayDataLossReport) => void

    Reports journal bytes abandoned during recovery. Defaults to logging at +

    onRecoveryDataLoss?: (report: QwpNodeReplayDataLossReport) => void

    Reports journal bytes abandoned during recovery. Defaults to logging at error level; recovery still succeeds, so this must never be silent.

    -
    onRecoveryQuarantine?: (event: QwpNodeReplayRecoveryEvent) => void

    Receives a data-loss notification when corrupt foreground replay bytes are +

    onRecoveryQuarantine?: (event: QwpNodeReplayRecoveryEvent) => void

    Receives a data-loss notification when corrupt foreground replay bytes are preserved under an .unreplayable-N pathname and a fresh slot is opened.

    -
    orphanScanIntervalMs?: number

    Periodic rescan cadence; zero disables the timer. Pooled ownership +

    orphanScanIntervalMs?: number

    Periodic rescan cadence; zero disables the timer. Pooled ownership changes can still trigger a scan. Defaults to 30 seconds.

    -
    +
    diff --git a/docs/interfaces/_questdb_nodejs-client.QwpNodeUdpMetrics.html b/docs/interfaces/_questdb_nodejs-client.QwpNodeUdpMetrics.html index 9e43fa9..eb72c83 100644 --- a/docs/interfaces/_questdb_nodejs-client.QwpNodeUdpMetrics.html +++ b/docs/interfaces/_questdb_nodejs-client.QwpNodeUdpMetrics.html @@ -1,6 +1,6 @@ -QwpNodeUdpMetrics | QuestDB JavaScript Client - v4.2.0

    Interface QwpNodeUdpMetrics

    interface QwpNodeUdpMetrics {
        closed: boolean;
        publishedDatagramSequence: bigint;
        totalBytesSent: number;
        totalDatagramsSent: number;
        totalSendErrors: number;
    }
    Index

    Properties

    closed +QwpNodeUdpMetrics | QuestDB JavaScript Client - v4.2.0

    Interface QwpNodeUdpMetrics

    interface QwpNodeUdpMetrics {
        closed: boolean;
        publishedDatagramSequence: bigint;
        totalBytesSent: number;
        totalDatagramsSent: number;
        totalSendErrors: number;
    }
    Index

    Properties

    closed: boolean
    publishedDatagramSequence: bigint
    totalBytesSent: number
    totalDatagramsSent: number
    totalSendErrors: number
    +

    Properties

    closed: boolean
    publishedDatagramSequence: bigint
    totalBytesSent: number
    totalDatagramsSent: number
    totalSendErrors: number
    diff --git a/docs/interfaces/_questdb_nodejs-client.QwpNodeUdpOptions.html b/docs/interfaces/_questdb_nodejs-client.QwpNodeUdpOptions.html index 0f45aac..22f1163 100644 --- a/docs/interfaces/_questdb_nodejs-client.QwpNodeUdpOptions.html +++ b/docs/interfaces/_questdb_nodejs-client.QwpNodeUdpOptions.html @@ -1,4 +1,4 @@ -QwpNodeUdpOptions | QuestDB JavaScript Client - v4.2.0

    Interface QwpNodeUdpOptions

    interface QwpNodeUdpOptions {
        host: string;
        maxDatagramSize?: number;
        multicastInterface?: string;
        multicastTtl?: number;
        onError?: (error: Error) => void;
        port?: number;
        socketFactory?: () => QwpNodeUdpSocketLike;
    }
    Index

    Properties

    host +QwpNodeUdpOptions | QuestDB JavaScript Client - v4.2.0

    Interface QwpNodeUdpOptions

    interface QwpNodeUdpOptions {
        host: string;
        maxDatagramSize?: number;
        multicastInterface?: string;
        multicastTtl?: number;
        onError?: (error: Error) => void;
        port?: number;
        socketFactory?: () => QwpNodeUdpSocketLike;
    }
    Index

    Properties

    host: string

    Destination hostname or IPv4 address.

    -
    maxDatagramSize?: number

    Maximum encoded datagram size. Defaults to 1400 bytes.

    -
    multicastInterface?: string

    Optional local IPv4 interface used for multicast traffic.

    -
    multicastTtl?: number

    IPv4 multicast TTL from 0 through 255. Defaults to 0.

    -
    onError?: (error: Error) => void

    Receives isolated local socket errors; UDP has no server acknowledgement.

    -
    port?: number

    Destination port. Defaults to the Java QWP UDP port, 9007.

    -
    socketFactory?: () => QwpNodeUdpSocketLike

    Test hook.

    -
    +
    maxDatagramSize?: number

    Maximum encoded datagram size. Defaults to 1400 bytes.

    +
    multicastInterface?: string

    Optional local IPv4 interface used for multicast traffic.

    +
    multicastTtl?: number

    IPv4 multicast TTL from 0 through 255. Defaults to 0.

    +
    onError?: (error: Error) => void

    Receives isolated local socket errors; UDP has no server acknowledgement.

    +
    port?: number

    Destination port. Defaults to the Java QWP UDP port, 9007.

    +
    socketFactory?: () => QwpNodeUdpSocketLike

    Test hook.

    +
    diff --git a/docs/interfaces/_questdb_nodejs-client.QwpNodeUdpSocketLike.html b/docs/interfaces/_questdb_nodejs-client.QwpNodeUdpSocketLike.html index e388a21..c6a5504 100644 --- a/docs/interfaces/_questdb_nodejs-client.QwpNodeUdpSocketLike.html +++ b/docs/interfaces/_questdb_nodejs-client.QwpNodeUdpSocketLike.html @@ -1,8 +1,8 @@ QwpNodeUdpSocketLike | QuestDB JavaScript Client - v4.2.0

    Interface QwpNodeUdpSocketLike

    Minimal injectable UDP socket surface used by the Node QWP sender.

    -
    interface QwpNodeUdpSocketLike {
        bind(port: number, address: string, callback: () => void): void;
        close(callback: () => void): void;
        on(event: "error", listener: (error: Error) => void): unknown;
        send(
            message: Uint8Array,
            port: number,
            address: string,
            callback: (error: Error, bytes: number) => void,
        ): void;
        setMulticastInterface(multicastInterface: string): void;
        setMulticastTTL(ttl: number): number;
    }
    Index

    Methods

    interface QwpNodeUdpSocketLike {
        bind(port: number, address: string, callback: () => void): void;
        close(callback: () => void): void;
        on(event: "error", listener: (error: Error) => void): unknown;
        send(
            message: Uint8Array,
            port: number,
            address: string,
            callback: (error: Error, bytes: number) => void,
        ): void;
        setMulticastInterface(multicastInterface: string): void;
        setMulticastTTL(ttl: number): number;
    }
    Index

    Methods

    • Parameters

      • message: Uint8Array
      • port: number
      • address: string
      • callback: (error: Error, bytes: number) => void

      Returns void

    +

    Methods

    • Parameters

      • message: Uint8Array
      • port: number
      • address: string
      • callback: (error: Error, bytes: number) => void

      Returns void

    diff --git a/docs/interfaces/_questdb_nodejs-client.QwpNodeUpgradeRejection.html b/docs/interfaces/_questdb_nodejs-client.QwpNodeUpgradeRejection.html index 2329153..73c298f 100644 --- a/docs/interfaces/_questdb_nodejs-client.QwpNodeUpgradeRejection.html +++ b/docs/interfaces/_questdb_nodejs-client.QwpNodeUpgradeRejection.html @@ -1,4 +1,4 @@ -QwpNodeUpgradeRejection | QuestDB JavaScript Client - v4.2.0

    Interface QwpNodeUpgradeRejection

    interface QwpNodeUpgradeRejection {
        headers: IncomingHttpHeaders;
        statusCode: number;
        statusMessage?: string;
    }
    Index

    Properties

    headers +QwpNodeUpgradeRejection | QuestDB JavaScript Client - v4.2.0

    Interface QwpNodeUpgradeRejection

    interface QwpNodeUpgradeRejection {
        headers: IncomingHttpHeaders;
        statusCode: number;
        statusMessage?: string;
    }
    Index

    Properties

    headers: IncomingHttpHeaders
    statusCode: number
    statusMessage?: string
    +

    Properties

    headers: IncomingHttpHeaders
    statusCode: number
    statusMessage?: string
    diff --git a/docs/interfaces/_questdb_nodejs-client.QwpNodeWebSocketOptions.html b/docs/interfaces/_questdb_nodejs-client.QwpNodeWebSocketOptions.html index 520c89b..5054366 100644 --- a/docs/interfaces/_questdb_nodejs-client.QwpNodeWebSocketOptions.html +++ b/docs/interfaces/_questdb_nodejs-client.QwpNodeWebSocketOptions.html @@ -1,4 +1,4 @@ -QwpNodeWebSocketOptions | QuestDB JavaScript Client - v4.2.0

    Interface QwpNodeWebSocketOptions

    interface QwpNodeWebSocketOptions {
        agent?: Agent;
        authorization?: string;
        authTimeoutMs?: number;
        clientId?: string;
        closeTimeoutMs?: number;
        connectTimeoutMs?: number;
        failoverUrls?: readonly (string | URL)[];
        headers?: Record<string, string>;
        maxVersion?: number;
        protocols?: string | string[];
        requestDurableAck?: boolean;
        sendTimeoutMs?: number;
        url: string | URL;
        webSocketFactory?: (
            url: string | URL,
            options: {
                agent?: Agent;
                headers: Record<string, string>;
                onConnected: () => void;
                onUpgrade: (headers: IncomingHttpHeaders) => void;
                onUpgradeRejected: (rejection: QwpNodeUpgradeRejection) => void;
                protocols?: string | string[];
            },
        ) => QwpWebSocketLike;
    }

    Hierarchy (View Summary)

    Index

    Properties

    agent? +QwpNodeWebSocketOptions | QuestDB JavaScript Client - v4.2.0

    Interface QwpNodeWebSocketOptions

    interface QwpNodeWebSocketOptions {
        agent?: Agent;
        authorization?: string;
        authTimeoutMs?: number;
        clientId?: string;
        closeTimeoutMs?: number;
        connectTimeoutMs?: number;
        failoverUrls?: readonly (string | URL)[];
        headers?: Record<string, string>;
        maxVersion?: number;
        protocols?: string | string[];
        requestDurableAck?: boolean;
        sendTimeoutMs?: number;
        url: string | URL;
        webSocketFactory?: (
            url: string | URL,
            options: {
                agent?: Agent;
                headers: Record<string, string>;
                onConnected: () => void;
                onUpgrade: (headers: IncomingHttpHeaders) => void;
                onUpgradeRejected: (rejection: QwpNodeUpgradeRejection) => void;
                protocols?: string | string[];
            },
        ) => QwpWebSocketLike;
    }

    Hierarchy (View Summary)

    Index

    Properties

    agent?: Agent

    Optional HTTP(S) agent used for the WebSocket upgrade.

    -
    authorization?: string
    authTimeoutMs?: number

    Time allowed after TCP/TLS connection for HTTP authentication and the +

    authorization?: string
    authTimeoutMs?: number

    Time allowed after TCP/TLS connection for HTTP authentication and the WebSocket upgrade. Defaults to 15s.

    -
    clientId?: string
    closeTimeoutMs?: number

    Maximum time allowed for a graceful WebSocket close. Defaults to 15s.

    -
    connectTimeoutMs?: number

    Node TCP/TLS connection deadline, or the complete opening deadline in a +

    clientId?: string
    closeTimeoutMs?: number

    Maximum time allowed for a graceful WebSocket close. Defaults to 15s.

    +
    connectTimeoutMs?: number

    Node TCP/TLS connection deadline, or the complete opening deadline in a browser. Defaults to 15s.

    -
    failoverUrls?: readonly (string | URL)[]

    Additional endpoints attempted in order when the preferred endpoint fails.

    -
    headers?: Record<string, string>
    maxVersion?: number
    protocols?: string | string[]
    requestDurableAck?: boolean
    sendTimeoutMs?: number

    Maximum time a send may remain queued by the WebSocket. Defaults to 15s.

    -
    url: string | URL
    webSocketFactory?: (
        url: string | URL,
        options: {
            agent?: Agent;
            headers: Record<string, string>;
            onConnected: () => void;
            onUpgrade: (headers: IncomingHttpHeaders) => void;
            onUpgradeRejected: (rejection: QwpNodeUpgradeRejection) => void;
            protocols?: string | string[];
        },
    ) => QwpWebSocketLike

    Test hook; defaults to the Node-only ws implementation.

    -
    +
    failoverUrls?: readonly (string | URL)[]

    Additional endpoints attempted in order when the preferred endpoint fails.

    +
    headers?: Record<string, string>
    maxVersion?: number
    protocols?: string | string[]
    requestDurableAck?: boolean

    Ingress-only. Durable ACK is negotiated on /write/v4; egress ignores it +and egressTransportOptions() strips it, because sending the header on +/read/v1 makes every query session fail the capability check.

    +
    sendTimeoutMs?: number

    Maximum time a send may remain queued by the WebSocket. Defaults to 15s.

    +
    url: string | URL
    webSocketFactory?: (
        url: string | URL,
        options: {
            agent?: Agent;
            headers: Record<string, string>;
            onConnected: () => void;
            onUpgrade: (headers: IncomingHttpHeaders) => void;
            onUpgradeRejected: (rejection: QwpNodeUpgradeRejection) => void;
            protocols?: string | string[];
        },
    ) => QwpWebSocketLike

    Test hook; defaults to the Node-only ws implementation.

    +
    diff --git a/docs/interfaces/_questdb_nodejs-client.QwpPoolSlotReservation.html b/docs/interfaces/_questdb_nodejs-client.QwpPoolSlotReservation.html index 42d37f2..d4d6a1e 100644 --- a/docs/interfaces/_questdb_nodejs-client.QwpPoolSlotReservation.html +++ b/docs/interfaces/_questdb_nodejs-client.QwpPoolSlotReservation.html @@ -1,5 +1,5 @@ QwpPoolSlotReservation | QuestDB JavaScript Client - v4.2.0

    Interface QwpPoolSlotReservationInternal

    Cross-owner reservation for stable pooled sender slot indexes.

    -
    interface QwpPoolSlotReservation {
        onAvailable(listener: () => void): () => void;
        release(slot: number): void;
        tryReserve(slot: number): boolean;
    }
    Index

    Methods

    interface QwpPoolSlotReservation {
        onAvailable(listener: () => void): () => void;
        release(slot: number): void;
        tryReserve(slot: number): boolean;
    }
    Index

    Methods

    +

    Methods

    diff --git a/docs/interfaces/_questdb_nodejs-client.QwpQueryErrorMessage.html b/docs/interfaces/_questdb_nodejs-client.QwpQueryErrorMessage.html index 0768e9f..20d5b5a 100644 --- a/docs/interfaces/_questdb_nodejs-client.QwpQueryErrorMessage.html +++ b/docs/interfaces/_questdb_nodejs-client.QwpQueryErrorMessage.html @@ -1,4 +1,4 @@ -QwpQueryErrorMessage | QuestDB JavaScript Client - v4.2.0

    Interface QwpQueryErrorMessage

    interface QwpQueryErrorMessage {
        flags: number;
        kind: "query-error";
        message: string;
        payloadLength: number;
        requestId: bigint;
        status: number;
        tableCount: number;
        version: number;
    }

    Hierarchy (View Summary)

    Index

    Properties

    flags +QwpQueryErrorMessage | QuestDB JavaScript Client - v4.2.0

    Interface QwpQueryErrorMessage

    interface QwpQueryErrorMessage {
        flags: number;
        kind: "query-error";
        message: string;
        payloadLength: number;
        requestId: bigint;
        status: number;
        tableCount: number;
        version: number;
    }

    Hierarchy (View Summary)

    Index

    Properties

    flags: number
    kind: "query-error"
    message: string
    payloadLength: number
    requestId: bigint
    status: number
    tableCount: number
    version: number
    +

    Properties

    flags: number
    kind: "query-error"
    message: string
    payloadLength: number
    requestId: bigint
    status: number
    tableCount: number
    version: number
    diff --git a/docs/interfaces/_questdb_nodejs-client.QwpQueryRequest.html b/docs/interfaces/_questdb_nodejs-client.QwpQueryRequest.html index 464ee45..efdbb7f 100644 --- a/docs/interfaces/_questdb_nodejs-client.QwpQueryRequest.html +++ b/docs/interfaces/_questdb_nodejs-client.QwpQueryRequest.html @@ -1,4 +1,4 @@ -QwpQueryRequest | QuestDB JavaScript Client - v4.2.0

    Interface QwpQueryRequest

    interface QwpQueryRequest {
        bindCount?: number;
        bindPayload?: Uint8Array;
        binds?: QwpBindSetter;
        initialCredit?: number | bigint;
        queryFlags?: number | bigint;
        requestId: number | bigint;
        sql: string;
    }
    Index

    Properties

    bindCount? +QwpQueryRequest | QuestDB JavaScript Client - v4.2.0

    Interface QwpQueryRequest

    interface QwpQueryRequest {
        bindCount?: number;
        bindPayload?: Uint8Array;
        binds?: QwpBindSetter;
        initialCredit?: number | bigint;
        queryFlags?: number | bigint;
        requestId: number | bigint;
        sql: string;
    }
    Index

    Properties

    bindCount?: number

    Advanced escape hatch for an already encoded bind section.

    -
    bindPayload?: Uint8Array

    Advanced escape hatch for an already encoded bind section.

    -

    Browser-safe typed positional binds.

    -
    initialCredit?: number | bigint

    Zero means unbounded.

    -
    queryFlags?: number | bigint

    Append only after SERVER_INFO advertises QUERY_FLAGS.

    -
    requestId: number | bigint
    sql: string
    +
    bindPayload?: Uint8Array

    Advanced escape hatch for an already encoded bind section.

    +

    Browser-safe typed positional binds.

    +
    initialCredit?: number | bigint

    Zero means unbounded.

    +
    queryFlags?: number | bigint

    Append only after SERVER_INFO advertises QUERY_FLAGS.

    +
    requestId: number | bigint
    sql: string
    diff --git a/docs/interfaces/_questdb_nodejs-client.QwpReconnectEvent.html b/docs/interfaces/_questdb_nodejs-client.QwpReconnectEvent.html index d85cbec..f6c0714 100644 --- a/docs/interfaces/_questdb_nodejs-client.QwpReconnectEvent.html +++ b/docs/interfaces/_questdb_nodejs-client.QwpReconnectEvent.html @@ -1,4 +1,4 @@ -QwpReconnectEvent | QuestDB JavaScript Client - v4.2.0

    Interface QwpReconnectEvent

    interface QwpReconnectEvent {
        attempt: number;
        cause?: unknown;
        endpoint?: string | URL;
        episodeMs?: number;
        kind: QwpReconnectEventKind;
        previousEndpoint?: string | URL;
        timestampMs: number;
    }
    Index

    Properties

    attempt +QwpReconnectEvent | QuestDB JavaScript Client - v4.2.0

    Interface QwpReconnectEvent

    interface QwpReconnectEvent {
        attempt: number;
        cause?: unknown;
        endpoint?: string | URL;
        episodeMs?: number;
        kind: QwpReconnectEventKind;
        previousEndpoint?: string | URL;
        timestampMs: number;
    }
    Index

    Properties

    attempt: number

    One-based reconnect sweep number; zero for lifecycle-only events.

    -
    cause?: unknown
    endpoint?: string | URL
    episodeMs?: number

    Elapsed time in the current consecutive capability-gap episode.

    -
    previousEndpoint?: string | URL
    timestampMs: number
    +
    cause?: unknown
    endpoint?: string | URL
    episodeMs?: number

    Elapsed time in the current consecutive capability-gap episode.

    +
    previousEndpoint?: string | URL
    timestampMs: number
    diff --git a/docs/interfaces/_questdb_nodejs-client.QwpReconnectOptions.html b/docs/interfaces/_questdb_nodejs-client.QwpReconnectOptions.html index 4bf0221..0b7aee9 100644 --- a/docs/interfaces/_questdb_nodejs-client.QwpReconnectOptions.html +++ b/docs/interfaces/_questdb_nodejs-client.QwpReconnectOptions.html @@ -1,4 +1,4 @@ -QwpReconnectOptions | QuestDB JavaScript Client - v4.2.0

    Interface QwpReconnectOptions

    interface QwpReconnectOptions {
        initialBackoffMs?: number;
        maxAttempts?: number;
        maxBackoffMs?: number;
        maxDurationMs?: number;
        maxFrameRejections?: number;
        onEvent?: (event: QwpReconnectEvent) => void;
        poisonMinEscalationWindowMs?: number;
    }
    Index

    Properties

    initialBackoffMs? +QwpReconnectOptions | QuestDB JavaScript Client - v4.2.0

    Interface QwpReconnectOptions

    interface QwpReconnectOptions {
        initialBackoffMs?: number;
        maxAttempts?: number;
        maxBackoffMs?: number;
        maxDurationMs?: number;
        maxFrameRejections?: number;
        onEvent?: (event: QwpReconnectEvent) => void;
        poisonMinEscalationWindowMs?: number;
    }
    Index

    Properties

    initialBackoffMs?: number

    Full-jitter ceiling before the first failed sweep is retried. Defaults to 100ms.

    -
    maxAttempts?: number

    Maximum connection sweeps per outage. Defaults to 3; zero is unlimited.

    -
    maxBackoffMs?: number

    Full-jitter exponential-backoff ceiling. Defaults to 5s.

    -
    maxDurationMs?: number

    Total reconnect deadline. Defaults to 30s; zero disables the deadline.

    -
    maxFrameRejections?: number

    Consecutive retriable rejections of one ingress frame before it is treated +

    maxAttempts?: number

    Maximum connection sweeps per outage. Defaults to 3; zero is unlimited.

    +
    maxBackoffMs?: number

    Full-jitter exponential-backoff ceiling. Defaults to 5s.

    +
    maxDurationMs?: number

    Total reconnect deadline. Defaults to 30s; zero disables the deadline.

    +
    maxFrameRejections?: number

    Consecutive retriable rejections of one ingress frame before it is treated as poison and retained for inspection. Defaults to 4.

    -
    onEvent?: (event: QwpReconnectEvent) => void
    poisonMinEscalationWindowMs?: number

    Minimum time the same ingress frame must remain suspect before repeated +

    onEvent?: (event: QwpReconnectEvent) => void
    poisonMinEscalationWindowMs?: number

    Minimum time the same ingress frame must remain suspect before repeated rejections or non-orderly closes become terminal. Defaults to 5s; zero escalates as soon as maxFrameRejections is reached.

    -
    +
    diff --git a/docs/interfaces/_questdb_nodejs-client.QwpResourcePoolMetrics.html b/docs/interfaces/_questdb_nodejs-client.QwpResourcePoolMetrics.html index d759a79..8f92e65 100644 --- a/docs/interfaces/_questdb_nodejs-client.QwpResourcePoolMetrics.html +++ b/docs/interfaces/_questdb_nodejs-client.QwpResourcePoolMetrics.html @@ -1,8 +1,8 @@ -QwpResourcePoolMetrics | QuestDB JavaScript Client - v4.2.0

    Interface QwpResourcePoolMetrics

    interface QwpResourcePoolMetrics {
        available: number;
        creating: number;
        leased: number;
        maximum: number;
        minimum: number;
        total: number;
        waiting: number;
    }
    Index

    Properties

    available +QwpResourcePoolMetrics | QuestDB JavaScript Client - v4.2.0

    Interface QwpResourcePoolMetrics

    interface QwpResourcePoolMetrics {
        available: number;
        creating: number;
        leased: number;
        maximum: number;
        minimum: number;
        total: number;
        waiting: number;
    }
    Index

    Properties

    available: number
    creating: number
    leased: number
    maximum: number
    minimum: number
    total: number
    waiting: number
    +

    Properties

    available: number
    creating: number
    leased: number
    maximum: number
    minimum: number
    total: number
    waiting: number
    diff --git a/docs/interfaces/_questdb_nodejs-client.QwpResultArrayValue.html b/docs/interfaces/_questdb_nodejs-client.QwpResultArrayValue.html index 0c9daf3..1d25daf 100644 --- a/docs/interfaces/_questdb_nodejs-client.QwpResultArrayValue.html +++ b/docs/interfaces/_questdb_nodejs-client.QwpResultArrayValue.html @@ -1,3 +1,3 @@ -QwpResultArrayValue | QuestDB JavaScript Client - v4.2.0

    Interface QwpResultArrayValue

    interface QwpResultArrayValue {
        dimensions: readonly number[];
        values: readonly number[] | readonly bigint[];
    }
    Index

    Properties

    dimensions +QwpResultArrayValue | QuestDB JavaScript Client - v4.2.0

    Interface QwpResultArrayValue

    interface QwpResultArrayValue {
        dimensions: readonly number[];
        values: readonly number[] | readonly bigint[];
    }
    Index

    Properties

    Properties

    dimensions: readonly number[]
    values: readonly number[] | readonly bigint[]
    +

    Properties

    dimensions: readonly number[]
    values: readonly number[] | readonly bigint[]
    diff --git a/docs/interfaces/_questdb_nodejs-client.QwpResultBatchMessage.html b/docs/interfaces/_questdb_nodejs-client.QwpResultBatchMessage.html index 180cfcf..17e26f9 100644 --- a/docs/interfaces/_questdb_nodejs-client.QwpResultBatchMessage.html +++ b/docs/interfaces/_questdb_nodejs-client.QwpResultBatchMessage.html @@ -1,4 +1,4 @@ -QwpResultBatchMessage | QuestDB JavaScript Client - v4.2.0

    Interface QwpResultBatchMessage

    interface QwpResultBatchMessage {
        batchSequence: bigint;
        body: Uint8Array;
        flags: number;
        kind: "result-batch";
        payloadLength: number;
        requestId: bigint;
        tableCount: number;
        version: number;
    }

    Hierarchy (View Summary)

    Index

    Properties

    batchSequence +QwpResultBatchMessage | QuestDB JavaScript Client - v4.2.0

    Interface QwpResultBatchMessage

    interface QwpResultBatchMessage {
        batchSequence: bigint;
        body: Uint8Array;
        flags: number;
        kind: "result-batch";
        payloadLength: number;
        requestId: bigint;
        tableCount: number;
        version: number;
    }

    Hierarchy (View Summary)

    Index

    Properties

    batchSequence: bigint
    body: Uint8Array

    Raw or Zstd-compressed delta dictionary and columnar table block; decoded +

    Properties

    batchSequence: bigint
    body: Uint8Array

    Raw or Zstd-compressed delta dictionary and columnar table block; decoded by the batch decoder according to the frame flags.

    -
    flags: number
    kind: "result-batch"
    payloadLength: number
    requestId: bigint
    tableCount: number
    version: number
    +
    flags: number
    kind: "result-batch"
    payloadLength: number
    requestId: bigint
    tableCount: number
    version: number
    diff --git a/docs/interfaces/_questdb_nodejs-client.QwpResultColumn.html b/docs/interfaces/_questdb_nodejs-client.QwpResultColumn.html index beb6668..0d30e1c 100644 --- a/docs/interfaces/_questdb_nodejs-client.QwpResultColumn.html +++ b/docs/interfaces/_questdb_nodejs-client.QwpResultColumn.html @@ -1,6 +1,6 @@ -QwpResultColumn | QuestDB JavaScript Client - v4.2.0

    Interface QwpResultColumn

    interface QwpResultColumn {
        name: string;
        precisionBits?: number;
        scale?: number;
        type: QwpColumnType;
        values: readonly QwpResultValue[];
    }

    Hierarchy (View Summary)

    Index

    Properties

    name +QwpResultColumn | QuestDB JavaScript Client - v4.2.0

    Interface QwpResultColumn

    interface QwpResultColumn {
        name: string;
        precisionBits?: number;
        scale?: number;
        type: QwpColumnType;
        values: readonly QwpResultValue[];
    }

    Hierarchy (View Summary)

    Index

    Properties

    name: string
    precisionBits?: number
    scale?: number
    values: readonly QwpResultValue[]
    +

    Properties

    name: string
    precisionBits?: number
    scale?: number
    values: readonly QwpResultValue[]
    diff --git a/docs/interfaces/_questdb_nodejs-client.QwpResultColumnSchema.html b/docs/interfaces/_questdb_nodejs-client.QwpResultColumnSchema.html index 4353115..08fb7eb 100644 --- a/docs/interfaces/_questdb_nodejs-client.QwpResultColumnSchema.html +++ b/docs/interfaces/_questdb_nodejs-client.QwpResultColumnSchema.html @@ -1,3 +1,3 @@ -QwpResultColumnSchema | QuestDB JavaScript Client - v4.2.0

    Interface QwpResultColumnSchema

    interface QwpResultColumnSchema {
        name: string;
        type: QwpColumnType;
    }

    Hierarchy (View Summary)

    Index

    Properties

    name +QwpResultColumnSchema | QuestDB JavaScript Client - v4.2.0

    Interface QwpResultColumnSchema

    interface QwpResultColumnSchema {
        name: string;
        type: QwpColumnType;
    }

    Hierarchy (View Summary)

    Index

    Properties

    Properties

    name: string
    +

    Properties

    name: string
    diff --git a/docs/interfaces/_questdb_nodejs-client.QwpResultEndMessage.html b/docs/interfaces/_questdb_nodejs-client.QwpResultEndMessage.html index 26b686f..55bfd60 100644 --- a/docs/interfaces/_questdb_nodejs-client.QwpResultEndMessage.html +++ b/docs/interfaces/_questdb_nodejs-client.QwpResultEndMessage.html @@ -1,4 +1,4 @@ -QwpResultEndMessage | QuestDB JavaScript Client - v4.2.0

    Interface QwpResultEndMessage

    interface QwpResultEndMessage {
        finalSequence: bigint;
        flags: number;
        kind: "result-end";
        payloadLength: number;
        requestId: bigint;
        tableCount: number;
        totalRows: bigint;
        version: number;
    }

    Hierarchy (View Summary)

    Index

    Properties

    finalSequence +QwpResultEndMessage | QuestDB JavaScript Client - v4.2.0

    Interface QwpResultEndMessage

    interface QwpResultEndMessage {
        finalSequence: bigint;
        flags: number;
        kind: "result-end";
        payloadLength: number;
        requestId: bigint;
        tableCount: number;
        totalRows: bigint;
        version: number;
    }

    Hierarchy (View Summary)

    Index

    Properties

    finalSequence: bigint
    flags: number
    kind: "result-end"
    payloadLength: number
    requestId: bigint
    tableCount: number
    totalRows: bigint
    version: number
    +

    Properties

    finalSequence: bigint
    flags: number
    kind: "result-end"
    payloadLength: number
    requestId: bigint
    tableCount: number
    totalRows: bigint
    version: number
    diff --git a/docs/interfaces/_questdb_nodejs-client.QwpSenderEncodeOptions.html b/docs/interfaces/_questdb_nodejs-client.QwpSenderEncodeOptions.html index 40c2b3c..1a33317 100644 --- a/docs/interfaces/_questdb_nodejs-client.QwpSenderEncodeOptions.html +++ b/docs/interfaces/_questdb_nodejs-client.QwpSenderEncodeOptions.html @@ -1,4 +1,4 @@ -QwpSenderEncodeOptions | QuestDB JavaScript Client - v4.2.0

    Interface QwpSenderEncodeOptions

    interface QwpSenderEncodeOptions {
        gorilla?: boolean;
        symbolDictionary?: "full" | "delta";
    }

    Hierarchy

    Index

    Properties

    gorilla? +QwpSenderEncodeOptions | QuestDB JavaScript Client - v4.2.0

    Interface QwpSenderEncodeOptions

    interface QwpSenderEncodeOptions {
        gorilla?: boolean;
        symbolDictionary?: "full" | "delta";
    }

    Hierarchy

    Index

    Properties

    gorilla?: boolean
    symbolDictionary?: "full" | "delta"

    Connection-scoped deltas are the default; use full to opt out.

    -
    +

    Properties

    gorilla?: boolean
    symbolDictionary?: "full" | "delta"

    Connection-scoped deltas are the default; use full to opt out.

    +
    diff --git a/docs/interfaces/_questdb_nodejs-client.QwpSenderError.html b/docs/interfaces/_questdb_nodejs-client.QwpSenderError.html index eab6dd7..f1acd1d 100644 --- a/docs/interfaces/_questdb_nodejs-client.QwpSenderError.html +++ b/docs/interfaces/_questdb_nodejs-client.QwpSenderError.html @@ -1,5 +1,5 @@ QwpSenderError | QuestDB JavaScript Client - v4.2.0

    Interface QwpSenderError

    Immutable Java-parity context for an ingress rejection or data loss.

    -
    interface QwpSenderError {
        appliedPolicy: QwpSenderErrorPolicy;
        category: QwpSenderErrorCategory;
        detectedAtMs: number;
        fromFsn?: bigint;
        messageSequence?: bigint;
        quarantinedPath?: string;
        serverMessage?: string;
        serverStatusByte?: number;
        tableName?: string;
        toFsn?: bigint;
    }
    Index

    Properties

    interface QwpSenderError {
        appliedPolicy: QwpSenderErrorPolicy;
        category: QwpSenderErrorCategory;
        detectedAtMs: number;
        fromFsn?: bigint;
        messageSequence?: bigint;
        quarantinedPath?: string;
        serverMessage?: string;
        serverStatusByte?: number;
        tableName?: string;
        toFsn?: bigint;
    }
    Index

    Properties

    appliedPolicy: QwpSenderErrorPolicy
    detectedAtMs: number
    fromFsn?: bigint

    Inclusive stable store-and-forward frame-sequence range.

    -
    messageSequence?: bigint
    quarantinedPath?: string

    Preserved on-disk bytes for a data-loss/quarantine notification.

    -
    serverMessage?: string
    serverStatusByte?: number
    tableName?: string
    toFsn?: bigint
    +

    Properties

    appliedPolicy: QwpSenderErrorPolicy
    detectedAtMs: number
    fromFsn?: bigint

    Inclusive stable store-and-forward frame-sequence range.

    +
    messageSequence?: bigint
    quarantinedPath?: string

    Preserved on-disk bytes for a data-loss/quarantine notification.

    +
    serverMessage?: string
    serverStatusByte?: number
    tableName?: string
    toFsn?: bigint
    diff --git a/docs/interfaces/_questdb_nodejs-client.QwpSenderErrorResponseContext.html b/docs/interfaces/_questdb_nodejs-client.QwpSenderErrorResponseContext.html index 0a8344b..6537ed2 100644 --- a/docs/interfaces/_questdb_nodejs-client.QwpSenderErrorResponseContext.html +++ b/docs/interfaces/_questdb_nodejs-client.QwpSenderErrorResponseContext.html @@ -1,7 +1,7 @@ -QwpSenderErrorResponseContext | QuestDB JavaScript Client - v4.2.0

    Interface QwpSenderErrorResponseContext

    interface QwpSenderErrorResponseContext {
        appliedPolicy?: QwpSenderErrorPolicy;
        detectedAtMs?: number;
        fromFsn?: bigint;
        messageSequence?: bigint;
        tableName?: string;
        toFsn?: bigint;
    }
    Index

    Properties

    appliedPolicy? +QwpSenderErrorResponseContext | QuestDB JavaScript Client - v4.2.0

    Interface QwpSenderErrorResponseContext

    interface QwpSenderErrorResponseContext {
        appliedPolicy?: QwpSenderErrorPolicy;
        detectedAtMs?: number;
        fromFsn?: bigint;
        messageSequence?: bigint;
        tableName?: string;
        toFsn?: bigint;
    }
    Index

    Properties

    appliedPolicy?: QwpSenderErrorPolicy
    detectedAtMs?: number
    fromFsn?: bigint
    messageSequence?: bigint
    tableName?: string
    toFsn?: bigint
    +

    Properties

    appliedPolicy?: QwpSenderErrorPolicy
    detectedAtMs?: number
    fromFsn?: bigint
    messageSequence?: bigint
    tableName?: string
    toFsn?: bigint
    diff --git a/docs/interfaces/_questdb_nodejs-client.QwpSenderMetrics.html b/docs/interfaces/_questdb_nodejs-client.QwpSenderMetrics.html index 972af04..c8ecd61 100644 --- a/docs/interfaces/_questdb_nodejs-client.QwpSenderMetrics.html +++ b/docs/interfaces/_questdb_nodejs-client.QwpSenderMetrics.html @@ -1,5 +1,5 @@ QwpSenderMetrics | QuestDB JavaScript Client - v4.2.0

    Interface QwpSenderMetrics

    Immutable high-level sender counters plus the active ingress snapshot.

    -
    interface QwpSenderMetrics {
        autoFlushBytes: number;
        closed: boolean;
        closing: boolean;
        connected: boolean;
        deferredRows: number;
        effectiveAutoFlushBytes: number;
        ingress?: QwpIngressMetrics;
        pendingBytes: number;
        pendingRows: number;
        totalFlushes: number;
        totalFlushFailures: number;
        totalRowsPublished: number;
        totalRowsStaged: number;
        totalTransactionsCommitted: number;
    }
    Index

    Properties

    interface QwpSenderMetrics {
        autoFlushBytes: number;
        closed: boolean;
        closing: boolean;
        connected: boolean;
        deferredRows: number;
        effectiveAutoFlushBytes: number;
        ingress?: QwpIngressMetrics;
        pendingBytes: number;
        pendingRows: number;
        totalFlushes: number;
        totalFlushFailures: number;
        totalRowsPublished: number;
        totalRowsStaged: number;
        totalTransactionsCommitted: number;
    }
    Index

    Properties

    autoFlushBytes: number
    closed: boolean
    closing: boolean
    connected: boolean
    deferredRows: number
    effectiveAutoFlushBytes: number
    pendingBytes: number

    Estimated raw column-buffer bytes currently staged.

    -
    pendingRows: number
    totalFlushes: number
    totalFlushFailures: number
    totalRowsPublished: number

    Rows whose encoded frames have entered the ingress session.

    -
    totalRowsStaged: number
    totalTransactionsCommitted: number
    +

    Properties

    autoFlushBytes: number
    closed: boolean
    closing: boolean
    connected: boolean
    deferredRows: number
    effectiveAutoFlushBytes: number
    pendingBytes: number

    Estimated raw column-buffer bytes currently staged.

    +
    pendingRows: number
    totalFlushes: number
    totalFlushFailures: number
    totalRowsPublished: number

    Rows whose encoded frames have entered the ingress session.

    +
    totalRowsStaged: number
    totalTransactionsCommitted: number
    diff --git a/docs/interfaces/_questdb_nodejs-client.QwpSenderOptions.html b/docs/interfaces/_questdb_nodejs-client.QwpSenderOptions.html index cb32b1e..4daf2c5 100644 --- a/docs/interfaces/_questdb_nodejs-client.QwpSenderOptions.html +++ b/docs/interfaces/_questdb_nodejs-client.QwpSenderOptions.html @@ -1,5 +1,5 @@ QwpSenderOptions | QuestDB JavaScript Client - v4.2.0

    Interface QwpSenderOptions

    Options for the browser-safe, fluent QWP sender.

    -
    interface QwpSenderOptions {
        autoFlush?: boolean;
        autoFlushBytes?: number;
        autoFlushIntervalMs?: number;
        autoFlushRows?: number;
        awaitDurableAck?: boolean;
        awaitServerAck?: boolean;
        closeFlushTimeoutMs?: number;
        durableAckTimeoutMs?: number;
        encode?: QwpSenderEncodeOptions;
        log?: QwpSenderLogger;
        maxNameLength?: number;
        transactional?: boolean;
    }
    Index

    Properties

    interface QwpSenderOptions {
        autoFlush?: boolean;
        autoFlushBytes?: number;
        autoFlushIntervalMs?: number;
        autoFlushRows?: number;
        awaitDurableAck?: boolean;
        awaitServerAck?: boolean;
        closeFlushTimeoutMs?: number;
        durableAckTimeoutMs?: number;
        encode?: QwpSenderEncodeOptions;
        log?: QwpSenderLogger;
        maxNameLength?: number;
        transactional?: boolean;
    }
    Index

    Properties

    autoFlush?: boolean
    autoFlushBytes?: number

    Soft threshold for estimated buffered column bytes. Zero disables the byte +

    Properties

    autoFlush?: boolean
    autoFlushBytes?: number

    Soft threshold for estimated buffered column bytes. Zero disables the byte trigger. Defaults to zero and is clamped below a connected server's batch cap; exact encoded frames remain subject to the protocol batch limit.

    -
    autoFlushIntervalMs?: number
    autoFlushRows?: number
    awaitDurableAck?: boolean

    Wait for durable upload after every successful ingress ACK. When true, +

    autoFlushIntervalMs?: number
    autoFlushRows?: number
    awaitDurableAck?: boolean

    Wait for durable upload after every successful ingress ACK. When true, this implies awaitServerAck unless awaitServerAck is explicitly false.

    -
    awaitServerAck?: boolean

    Wait for the server's protocol ACK before flush()/commit() resolves. +

    awaitServerAck?: boolean

    Wait for the server's protocol ACK before flush()/commit() resolves. Defaults to false, matching the Java QWP sender's local-publication boundary. Set this to true for an acknowledgement barrier, or use flushAndGetSequence() followed by waitForAcknowledged().

    -
    closeFlushTimeoutMs?: number

    Maximum time close() spends publishing queued rows and waiting for the +

    closeFlushTimeoutMs?: number

    Maximum time close() spends publishing queued rows and waiting for the server ACK watermark. Zero or a negative value skips the drain. Defaults to 5 seconds.

    -
    durableAckTimeoutMs?: number

    QWP frame encoding options supported by the high-level sender.

    -
    maxNameLength?: number

    Maximum UTF-8 byte length of table and column names. Defaults to 127.

    -
    transactional?: boolean

    Keep auto-flushed rows in an open server-side transaction. An explicit +

    durableAckTimeoutMs?: number

    QWP frame encoding options supported by the high-level sender.

    +
    maxNameLength?: number

    Maximum UTF-8 byte length of table and column names. Defaults to 127.

    +
    transactional?: boolean

    Keep auto-flushed rows in an open server-side transaction. An explicit flush()/commit() closes the transaction. QWP transactions are atomic per table, rather than across every table in a multi-table flush.

    -
    +
    diff --git a/docs/interfaces/_questdb_nodejs-client.QwpSenderSession.html b/docs/interfaces/_questdb_nodejs-client.QwpSenderSession.html index 2707ba8..f402e5e 100644 --- a/docs/interfaces/_questdb_nodejs-client.QwpSenderSession.html +++ b/docs/interfaces/_questdb_nodejs-client.QwpSenderSession.html @@ -1,5 +1,5 @@ QwpSenderSession | QuestDB JavaScript Client - v4.2.0

    Interface QwpSenderSession

    The subset of QwpIngressSession used by QwpSender.

    -
    interface QwpSenderSession {
        acknowledgedFrameSequence?: bigint;
        maxBatchSizeBytes?: number;
        metrics?: QwpIngressMetrics;
        publishedFrameSequence?: bigint;
        close(code?: number, reason?: string): Promise<void>;
        publishTables(
            tables: readonly QwpTableBuffer[],
            options?: QwpIngressEncodeOptions,
        ): Promise<void>;
        publishTablesDelta(
            tables: readonly QwpTableBuffer[],
            options?: Pick<QwpIngressEncodeOptions, "gorilla" | "deferCommit">,
        ): Promise<void>;
        sendTables(
            tables: readonly QwpTableBuffer[],
            options?: QwpIngressEncodeOptions,
        ): Promise<QwpIngressResponse>;
        sendTablesDelta(
            tables: readonly QwpTableBuffer[],
            options?: Pick<QwpIngressEncodeOptions, "gorilla" | "deferCommit">,
        ): Promise<QwpIngressResponse>;
        sendTablesDeltaWithPublication(
            tables: readonly QwpTableBuffer[],
            options?: Pick<QwpIngressEncodeOptions, "gorilla" | "deferCommit">,
        ): QwpIngressSendResult;
        sendTablesWithPublication(
            tables: readonly QwpTableBuffer[],
            options?: QwpIngressEncodeOptions,
        ): QwpIngressSendResult;
        waitForAcknowledged(
            targetSequence: bigint,
            timeoutMs?: number,
        ): Promise<void>;
        waitForDurable(
            response: QwpIngressResponse,
            timeoutMs?: number,
        ): Promise<void>;
    }

    Implemented by

    Index

    Properties

    interface QwpSenderSession {
        acknowledgedFrameSequence?: bigint;
        maxBatchSizeBytes?: number;
        metrics?: QwpIngressMetrics;
        publishedFrameSequence?: bigint;
        close(code?: number, reason?: string): Promise<void>;
        publishTables(
            tables: readonly QwpTableBuffer[],
            options?: QwpIngressEncodeOptions,
        ): Promise<void>;
        publishTablesDelta(
            tables: readonly QwpTableBuffer[],
            options?: Pick<QwpIngressEncodeOptions, "gorilla" | "deferCommit">,
        ): Promise<void>;
        sendTables(
            tables: readonly QwpTableBuffer[],
            options?: QwpIngressEncodeOptions,
        ): Promise<QwpIngressResponse>;
        sendTablesDelta(
            tables: readonly QwpTableBuffer[],
            options?: Pick<QwpIngressEncodeOptions, "gorilla" | "deferCommit">,
        ): Promise<QwpIngressResponse>;
        sendTablesDeltaWithPublication(
            tables: readonly QwpTableBuffer[],
            options?: Pick<QwpIngressEncodeOptions, "gorilla" | "deferCommit">,
        ): QwpIngressSendResult;
        sendTablesWithPublication(
            tables: readonly QwpTableBuffer[],
            options?: QwpIngressEncodeOptions,
        ): QwpIngressSendResult;
        waitForAcknowledged(
            targetSequence: bigint,
            timeoutMs?: number,
        ): Promise<void>;
        waitForDurable(
            response: QwpIngressResponse,
            timeoutMs?: number,
        ): Promise<void>;
    }

    Implemented by

    Index

    Properties

    acknowledgedFrameSequence?: bigint
    maxBatchSizeBytes?: number
    publishedFrameSequence?: bigint

    Methods

    +

    Properties

    acknowledgedFrameSequence?: bigint
    maxBatchSizeBytes?: number
    publishedFrameSequence?: bigint

    Methods

    diff --git a/docs/interfaces/_questdb_nodejs-client.QwpServerInfoMessage.html b/docs/interfaces/_questdb_nodejs-client.QwpServerInfoMessage.html index 6bcb3ed..4920834 100644 --- a/docs/interfaces/_questdb_nodejs-client.QwpServerInfoMessage.html +++ b/docs/interfaces/_questdb_nodejs-client.QwpServerInfoMessage.html @@ -1,5 +1,5 @@ QwpServerInfoMessage | QuestDB JavaScript Client - v4.2.0

    Interface QwpServerInfoMessage

    Immutable endpoint metadata from the most recent successful egress bind.

    -
    interface QwpServerInfoMessage {
        capabilities: number;
        clusterId: string;
        compressionCodec: number;
        compressionLevel: number;
        epoch: bigint;
        flags: number;
        kind: "server-info";
        nodeId: string;
        payloadLength: number;
        role: number;
        serverWallNanoseconds: bigint;
        tableCount: number;
        version: number;
        zoneId: string;
    }

    Hierarchy (View Summary)

    Index

    Properties

    interface QwpServerInfoMessage {
        capabilities: number;
        clusterId: string;
        compressionCodec: number;
        compressionLevel: number;
        epoch: bigint;
        flags: number;
        kind: "server-info";
        nodeId: string;
        payloadLength: number;
        role: number;
        serverWallNanoseconds: bigint;
        tableCount: number;
        version: number;
        zoneId: string;
    }

    Hierarchy (View Summary)

    Index

    Properties

    capabilities: number
    clusterId: string
    compressionCodec: number
    compressionLevel: number
    epoch: bigint
    flags: number
    kind: "server-info"
    nodeId: string
    payloadLength: number
    role: number
    serverWallNanoseconds: bigint
    tableCount: number
    version: number
    zoneId: string
    +

    Properties

    capabilities: number
    clusterId: string
    compressionCodec: number
    compressionLevel: number
    epoch: bigint
    flags: number
    kind: "server-info"
    nodeId: string
    payloadLength: number
    role: number
    serverWallNanoseconds: bigint
    tableCount: number
    version: number
    zoneId: string
    diff --git a/docs/interfaces/_questdb_nodejs-client.QwpSymbolValue.html b/docs/interfaces/_questdb_nodejs-client.QwpSymbolValue.html index 986fb49..23a8bcd 100644 --- a/docs/interfaces/_questdb_nodejs-client.QwpSymbolValue.html +++ b/docs/interfaces/_questdb_nodejs-client.QwpSymbolValue.html @@ -1,3 +1,3 @@ -QwpSymbolValue | QuestDB JavaScript Client - v4.2.0

    Interface QwpSymbolValue

    interface QwpSymbolValue {
        id: number;
        text: string;
    }
    Index

    Properties

    id +QwpSymbolValue | QuestDB JavaScript Client - v4.2.0

    Interface QwpSymbolValue

    interface QwpSymbolValue {
        id: number;
        text: string;
    }
    Index

    Properties

    Properties

    id: number
    text: string
    +

    Properties

    id: number
    text: string
    diff --git a/docs/interfaces/_questdb_nodejs-client.QwpUpgradeErrorDetails.html b/docs/interfaces/_questdb_nodejs-client.QwpUpgradeErrorDetails.html index d807b6b..8b00163 100644 --- a/docs/interfaces/_questdb_nodejs-client.QwpUpgradeErrorDetails.html +++ b/docs/interfaces/_questdb_nodejs-client.QwpUpgradeErrorDetails.html @@ -1,4 +1,4 @@ -QwpUpgradeErrorDetails | QuestDB JavaScript Client - v4.2.0

    Interface QwpUpgradeErrorDetails

    interface QwpUpgradeErrorDetails {
        cause?: unknown;
        closeCode?: number;
        kind: QwpUpgradeErrorKind;
        retryable?: boolean;
        serverRole?: string;
        serverZone?: string;
        statusCode?: number;
        statusMessage?: string;
        timeoutPhase?: QwpUpgradeTimeoutPhase;
        tryNextEndpoint?: boolean;
        url?: string | URL;
    }
    Index

    Properties

    cause? +QwpUpgradeErrorDetails | QuestDB JavaScript Client - v4.2.0

    Interface QwpUpgradeErrorDetails

    interface QwpUpgradeErrorDetails {
        cause?: unknown;
        closeCode?: number;
        kind: QwpUpgradeErrorKind;
        retryable?: boolean;
        serverRole?: string;
        serverZone?: string;
        statusCode?: number;
        statusMessage?: string;
        timeoutPhase?: QwpUpgradeTimeoutPhase;
        tryNextEndpoint?: boolean;
        url?: string | URL;
    }
    Index

    Properties

    cause?: unknown
    closeCode?: number
    retryable?: boolean

    Whether a later retry against the configured endpoint set may recover.

    -
    serverRole?: string
    serverZone?: string
    statusCode?: number
    statusMessage?: string
    timeoutPhase?: QwpUpgradeTimeoutPhase
    tryNextEndpoint?: boolean

    Whether failover code should try another endpoint before surfacing this.

    -
    url?: string | URL
    +

    Properties

    cause?: unknown
    closeCode?: number
    retryable?: boolean

    Whether a later retry against the configured endpoint set may recover.

    +
    serverRole?: string
    serverZone?: string
    statusCode?: number
    statusMessage?: string
    timeoutPhase?: QwpUpgradeTimeoutPhase
    tryNextEndpoint?: boolean

    Whether failover code should try another endpoint before surfacing this.

    +
    url?: string | URL
    diff --git a/docs/interfaces/_questdb_nodejs-client.QwpUuidValue.html b/docs/interfaces/_questdb_nodejs-client.QwpUuidValue.html index b8fd562..35745ed 100644 --- a/docs/interfaces/_questdb_nodejs-client.QwpUuidValue.html +++ b/docs/interfaces/_questdb_nodejs-client.QwpUuidValue.html @@ -1,3 +1,3 @@ -QwpUuidValue | QuestDB JavaScript Client - v4.2.0
    interface QwpUuidValue {
        high: bigint;
        low: bigint;
    }
    Index

    Properties

    high +QwpUuidValue | QuestDB JavaScript Client - v4.2.0
    interface QwpUuidValue {
        high: bigint;
        low: bigint;
    }
    Index

    Properties

    Properties

    high: bigint
    low: bigint
    +

    Properties

    high: bigint
    low: bigint
    diff --git a/docs/interfaces/_questdb_nodejs-client.QwpWebSocketConnectOptions.html b/docs/interfaces/_questdb_nodejs-client.QwpWebSocketConnectOptions.html index 15826d4..54e563f 100644 --- a/docs/interfaces/_questdb_nodejs-client.QwpWebSocketConnectOptions.html +++ b/docs/interfaces/_questdb_nodejs-client.QwpWebSocketConnectOptions.html @@ -1,12 +1,12 @@ -QwpWebSocketConnectOptions | QuestDB JavaScript Client - v4.2.0

    Interface QwpWebSocketConnectOptions

    interface QwpWebSocketConnectOptions {
        closeTimeoutMs?: number;
        connectTimeoutMs?: number;
        failoverUrls?: readonly (string | URL)[];
        protocols?: string | string[];
        sendTimeoutMs?: number;
        url: string | URL;
    }

    Hierarchy (View Summary)

    Index

    Properties

    closeTimeoutMs? +QwpWebSocketConnectOptions | QuestDB JavaScript Client - v4.2.0

    Interface QwpWebSocketConnectOptions

    interface QwpWebSocketConnectOptions {
        closeTimeoutMs?: number;
        connectTimeoutMs?: number;
        failoverUrls?: readonly (string | URL)[];
        protocols?: string | string[];
        sendTimeoutMs?: number;
        url: string | URL;
    }

    Hierarchy (View Summary)

    Index

    Properties

    closeTimeoutMs?: number

    Maximum time allowed for a graceful WebSocket close. Defaults to 15s.

    -
    connectTimeoutMs?: number

    Node TCP/TLS connection deadline, or the complete opening deadline in a +

    connectTimeoutMs?: number

    Node TCP/TLS connection deadline, or the complete opening deadline in a browser. Defaults to 15s.

    -
    failoverUrls?: readonly (string | URL)[]

    Additional endpoints attempted in order when the preferred endpoint fails.

    -
    protocols?: string | string[]
    sendTimeoutMs?: number

    Maximum time a send may remain queued by the WebSocket. Defaults to 15s.

    -
    url: string | URL
    +
    failoverUrls?: readonly (string | URL)[]

    Additional endpoints attempted in order when the preferred endpoint fails.

    +
    protocols?: string | string[]
    sendTimeoutMs?: number

    Maximum time a send may remain queued by the WebSocket. Defaults to 15s.

    +
    url: string | URL
    diff --git a/docs/interfaces/_questdb_nodejs-client.QwpWebSocketLike.html b/docs/interfaces/_questdb_nodejs-client.QwpWebSocketLike.html index e15a6a6..bbbee5a 100644 --- a/docs/interfaces/_questdb_nodejs-client.QwpWebSocketLike.html +++ b/docs/interfaces/_questdb_nodejs-client.QwpWebSocketLike.html @@ -1,4 +1,4 @@ -QwpWebSocketLike | QuestDB JavaScript Client - v4.2.0

    Interface QwpWebSocketLike

    interface QwpWebSocketLike {
        binaryType: string;
        bufferedAmount?: number;
        protocol?: string;
        readyState: number;
        addEventListener(
            type: "open",
            listener: (event: unknown) => void,
            options?: { once?: boolean },
        ): void;
        addEventListener(
            type: "message",
            listener: (event: QwpWebSocketMessageEvent) => void,
        ): void;
        addEventListener(
            type: "error",
            listener: (event: unknown) => void,
            options?: { once?: boolean },
        ): void;
        addEventListener(
            type: "close",
            listener: (event: QwpWebSocketCloseEvent) => void,
            options?: { once?: boolean },
        ): void;
        close(code?: number, reason?: string): void;
        ping(): void;
        removeEventListener(type: "open", listener: (event: unknown) => void): void;
        removeEventListener(
            type: "message",
            listener: (event: QwpWebSocketMessageEvent) => void,
        ): void;
        removeEventListener(
            type: "error",
            listener: (event: unknown) => void,
        ): void;
        removeEventListener(
            type: "close",
            listener: (event: QwpWebSocketCloseEvent) => void,
        ): void;
        send(data: Uint8Array): void;
        sendWithCallback(data: Uint8Array, callback: (error?: Error) => void): void;
        terminate(): void;
    }
    Index

    Properties

    binaryType +QwpWebSocketLike | QuestDB JavaScript Client - v4.2.0

    Interface QwpWebSocketLike

    interface QwpWebSocketLike {
        binaryType: string;
        bufferedAmount?: number;
        protocol?: string;
        readyState: number;
        addEventListener(
            type: "open",
            listener: (event: unknown) => void,
            options?: { once?: boolean },
        ): void;
        addEventListener(
            type: "message",
            listener: (event: QwpWebSocketMessageEvent) => void,
        ): void;
        addEventListener(
            type: "error",
            listener: (event: unknown) => void,
            options?: { once?: boolean },
        ): void;
        addEventListener(
            type: "close",
            listener: (event: QwpWebSocketCloseEvent) => void,
            options?: { once?: boolean },
        ): void;
        close(code?: number, reason?: string): void;
        ping(): void;
        removeEventListener(type: "open", listener: (event: unknown) => void): void;
        removeEventListener(
            type: "message",
            listener: (event: QwpWebSocketMessageEvent) => void,
        ): void;
        removeEventListener(
            type: "error",
            listener: (event: unknown) => void,
        ): void;
        removeEventListener(
            type: "close",
            listener: (event: QwpWebSocketCloseEvent) => void,
        ): void;
        send(data: Uint8Array): void;
        sendWithCallback(data: Uint8Array, callback: (error?: Error) => void): void;
        terminate(): void;
    }
    Index

    Properties

    binaryType: string
    bufferedAmount?: number

    Number of application bytes queued by WHATWG-compatible WebSockets.

    -
    protocol?: string

    WebSocket subprotocol selected by the server, or an empty string.

    -
    readyState: number

    Methods

    +

    Properties

    binaryType: string
    bufferedAmount?: number

    Number of application bytes queued by WHATWG-compatible WebSockets.

    +
    protocol?: string

    WebSocket subprotocol selected by the server, or an empty string.

    +
    readyState: number

    Methods

    diff --git a/docs/interfaces/_questdb_nodejs-client.QwpWriterColumn.html b/docs/interfaces/_questdb_nodejs-client.QwpWriterColumn.html index b1ff0e7..9e1d9d1 100644 --- a/docs/interfaces/_questdb_nodejs-client.QwpWriterColumn.html +++ b/docs/interfaces/_questdb_nodejs-client.QwpWriterColumn.html @@ -1,5 +1,5 @@ QwpWriterColumn | QuestDB JavaScript Client - v4.2.0

    Interface QwpWriterColumn<T, DesignatedTimestamp>

    A reusable, immutable column definition for a compiled QWP table writer.

    -
    interface QwpWriterColumn<T, DesignatedTimestamp extends boolean = false> {
        __qwpWriterInput?: T;
        designatedTimestamp: DesignatedTimestamp;
        kind: QwpWriterColumnKind;
        precisionBits?: number;
        scale?: number;
        unit?: QwpTimestampUnit;
    }

    Type Parameters

    • T
    • DesignatedTimestamp extends boolean = false
    Index

    Properties

    interface QwpWriterColumn<T, DesignatedTimestamp extends boolean = false> {
        __qwpWriterInput?: T;
        designatedTimestamp: DesignatedTimestamp;
        kind: QwpWriterColumnKind;
        precisionBits?: number;
        scale?: number;
        unit?: QwpTimestampUnit;
    }

    Type Parameters

    • T
    • DesignatedTimestamp extends boolean = false
    Index

    Properties

    __qwpWriterInput? designatedTimestamp kind precisionBits? @@ -14,6 +14,6 @@ silently accept anything. A shared property name resolves structurally across bundles, which is what keeps row typing alive for consumers of the published package.

    -
    designatedTimestamp: DesignatedTimestamp
    precisionBits?: number

    GEOHASH precision in bits, fixed for the whole column.

    -
    scale?: number

    DECIMAL scale, fixed for the whole column.

    -
    +
    designatedTimestamp: DesignatedTimestamp
    precisionBits?: number

    GEOHASH precision in bits, fixed for the whole column.

    +
    scale?: number

    DECIMAL scale, fixed for the whole column.

    +
    diff --git a/docs/interfaces/_questdb_nodejs-client.SenderBuffer.html b/docs/interfaces/_questdb_nodejs-client.SenderBuffer.html index 092cf0b..65c9a5f 100644 --- a/docs/interfaces/_questdb_nodejs-client.SenderBuffer.html +++ b/docs/interfaces/_questdb_nodejs-client.SenderBuffer.html @@ -1,6 +1,6 @@ SenderBuffer | QuestDB JavaScript Client - v4.2.0

    Buffer used by the Sender for data serialization.
    Provides methods for writing different data types into the buffer.

    -
    interface SenderBuffer {
        arrayColumn(name: string, value: unknown[]): SenderBuffer;
        at(timestamp: number | bigint, unit?: TimestampUnit): void;
        atNow(): void;
        booleanColumn(name: string, value: boolean): SenderBuffer;
        currentPosition(): number;
        decimalColumn(
            name: string,
            unscaled: bigint | Int8Array<ArrayBufferLike>,
            scale: number,
        ): SenderBuffer;
        decimalColumnText(name: string, value: string | number): SenderBuffer;
        floatColumn(name: string, value: number): SenderBuffer;
        intColumn(name: string, value: number): SenderBuffer;
        reset(): SenderBuffer;
        stringColumn(name: string, value: string): SenderBuffer;
        symbol(name: string, value: unknown): SenderBuffer;
        table(table: string): SenderBuffer;
        timestampColumn(
            name: string,
            value: number | bigint,
            unit?: TimestampUnit,
        ): SenderBuffer;
        toBufferNew(pos?: number): Buffer<ArrayBufferLike>;
        toBufferView(pos?: number): Buffer;
    }
    Index

    Methods

    interface SenderBuffer {
        arrayColumn(name: string, value: unknown[]): SenderBuffer;
        at(timestamp: number | bigint, unit?: TimestampUnit): void;
        atNow(): void;
        booleanColumn(name: string, value: boolean): SenderBuffer;
        currentPosition(): number;
        decimalColumn(
            name: string,
            unscaled: bigint | Int8Array<ArrayBufferLike>,
            scale: number,
        ): SenderBuffer;
        decimalColumnText(name: string, value: string | number): SenderBuffer;
        floatColumn(name: string, value: number): SenderBuffer;
        intColumn(name: string, value: number): SenderBuffer;
        reset(): SenderBuffer;
        stringColumn(name: string, value: string): SenderBuffer;
        symbol(name: string, value: unknown): SenderBuffer;
        table(table: string): SenderBuffer;
        timestampColumn(
            name: string,
            value: number | bigint,
            unit?: TimestampUnit,
        ): SenderBuffer;
        toBufferNew(pos?: number): Buffer<ArrayBufferLike>;
        toBufferView(pos?: number): Buffer;
    }
    Index

    Methods

    arrayColumn at atNow booleanColumn @@ -26,7 +26,7 @@
  • or the shape of the array is irregular: the length of sub-arrays are different
  • or the array is not homogeneous: its elements are not all the same type
  • -
    • Closes the row after writing the designated timestamp into the buffer.

      Precision rules:

      • Protocol v2 and higher: @@ -48,17 +48,17 @@

      If unit is 'ns' but timestamp is not a BigInt.

      If unit is not one of 'ns', 'us', or 'ms'. This validation leaves the open row unchanged so the call can be retried.

      -
    • Writes a boolean column with its value into the buffer. Use it to insert into BOOLEAN columns.

      Parameters

      • name: string

        Column name.

      • value: boolean

        Column value, accepts only boolean values. A null or undefined value omits the column entirely (stored as NULL).

      Returns SenderBuffer

      Returns with a reference to this buffer.

      -
    • Returns the current position of the buffer. New data will be written into the buffer starting from this position.

      Returns number

      The current write position in the buffer

      -
    • Writes a decimal value into the buffer using its binary format.

      Use it to insert into DECIMAL database columns.

      Parameters

      • name: string

        Column name.

      • unscaled: bigint | Int8Array<ArrayBufferLike>

        The unscaled integer portion of the decimal value.

        @@ -79,7 +79,7 @@
      • scale is not between 0 and 76.
      • unscaled contains invalid bytes.
      -
    • Writes a decimal value into the buffer using its text format.

      Use it to insert into DECIMAL database columns.

      Parameters

      • name: string

        Column name.

      • value: string | number

        The decimal value to write.

        @@ -95,34 +95,34 @@
        • The provided string is not a valid decimal representation.
        -
    • Writes a 64-bit floating point value into the buffer. Use it to insert into DOUBLE or FLOAT database columns.

      Parameters

      • name: string

        Column name.

      • value: number

        Column value, accepts only number values. A null or undefined value omits the column entirely (stored as NULL).

      Returns SenderBuffer

      Returns with a reference to this buffer.

      -
    • Writes a 64-bit signed integer into the buffer. Use it to insert into LONG, INT, SHORT and BYTE columns.

      Parameters

      • name: string

        Column name.

      • value: number

        Column value, accepts only number values. A null or undefined value omits the column entirely (stored as NULL).

      Returns SenderBuffer

      Returns with a reference to this buffer.

      Error if the value is not an integer

      -
    • Writes a string column with its value into the buffer. Use it to insert into VARCHAR and STRING columns.

      Parameters

      • name: string

        Column name.

      • value: string

        Column value, accepts only string values. A null or undefined value omits the column entirely (stored as NULL).

      Returns SenderBuffer

      Returns with a reference to this buffer.

      -
    • Writes a symbol name and value into the buffer. Use it to insert into SYMBOL columns.

      Parameters

      • name: string

        Symbol name.

      • value: unknown

        Symbol value, toString() is called to extract the actual symbol value from the parameter. A null or undefined value omits the symbol entirely (stored as NULL).

      Returns SenderBuffer

      Returns with a reference to this buffer.

      -
    • Writes a timestamp column and its value into the buffer.

      Use this method to insert data into TIMESTAMP or TIMESTAMP_NS columns.

      Precision rules:

        @@ -146,14 +146,14 @@

      If unit is 'ns' but value is not a BigInt.

      If unit is not one of 'ns', 'us', or 'ms'. This validation runs even when value is null or undefined.

      -
    • Returns a cropped buffer ready to send to the server, or null if there is nothing to send. The returned buffer is a copy of this buffer. It also compacts the buffer.

      Parameters

      • Optionalpos: number

        Optional position parameter

      Returns Buffer<ArrayBufferLike>

      A copy of the buffer ready to send, or null

      -
    • Returns a cropped buffer, or null if there is nothing to send. The returned buffer is backed by this buffer instance, meaning the view can change as the buffer is mutated. Used only in tests to assert the buffer's content.

      Parameters

      • Optionalpos: number

        Optional position parameter

      Returns Buffer

      A view of the buffer

      -
    +
    diff --git a/docs/interfaces/_questdb_nodejs-client.SenderTransport.html b/docs/interfaces/_questdb_nodejs-client.SenderTransport.html index a16b840..76b8df0 100644 --- a/docs/interfaces/_questdb_nodejs-client.SenderTransport.html +++ b/docs/interfaces/_questdb_nodejs-client.SenderTransport.html @@ -1,18 +1,18 @@ SenderTransport | QuestDB JavaScript Client - v4.2.0

    Interface SenderTransport

    Interface for QuestDB transport implementations.
    Defines the contract for different transport protocols (HTTP/HTTPS/TCP/TCPS).

    -
    interface SenderTransport {
        close(): Promise<void>;
        connect(): Promise<boolean>;
        getDefaultAutoFlushRows(): number;
        send(data: Buffer): Promise<boolean>;
    }

    Implemented by

    Index

    Methods

    interface SenderTransport {
        close(): Promise<void>;
        connect(): Promise<boolean>;
        getDefaultAutoFlushRows(): number;
        send(data: Buffer): Promise<boolean>;
    }

    Implemented by

    Index

    Methods

    • Closes the connection to the database server. Should not be called on HTTP transports.

      Returns Promise<void>

      Promise that resolves when the connection is closed

      -
    • Establishes a connection to the database server. Should not be called on HTTP transports.

      Returns Promise<boolean>

      Promise resolving to true if connection is successful

      -
    • Sends the data to the database server.

      Parameters

      • data: Buffer

        Buffer containing the data to send

      Returns Promise<boolean>

      Promise resolving to true if data was sent successfully

      -
    +
    diff --git a/docs/media/QWP.md b/docs/media/QWP.md index 413fe11..bea8462 100644 --- a/docs/media/QWP.md +++ b/docs/media/QWP.md @@ -5,10 +5,9 @@ It describes the supported public entry points, delivery semantics, authenticati failure handling, and migration from the existing Node.js sender and the low-level QWP API. -QWP support is currently a preview. The documented exports are the compatibility -baseline for the first QWP release, but may still change before that release. Once -released, changes to this documented surface follow the package's semantic-versioning -policy. Imports from internal source paths are never supported. +The documented exports are the public compatibility baseline. Changes to this +surface follow the package's semantic-versioning policy. Imports from internal +source paths are never supported. ## Choose an entry point @@ -70,7 +69,7 @@ keys owned only by egress or the pooled facade are accepted as intentional no-op Every `ws::`/`wss::` connect string is parsed by one schema, shared with the other QuestDB clients, whichever entry point builds the client — `Sender.fromConfig()`, `SenderOptions.fromConfig()`, `connectQwpNodeClient()`, -or `connectQwpNodeQuery()`. An unrecognised key is rejected with +or `connectQwpNodeEgress()`. An unrecognised key is rejected with `unknown configuration key: `; a legacy ILP key adds a hint pointing at where it applies instead. @@ -116,7 +115,7 @@ continues to come from `addr`, because the typed object intentionally omits | `max_name_len` | integer | `127` | Maximum table and column name length, in UTF-8 bytes. | | `sender_id` | string | `default` | Identifies this producer to the server and in the journal. | | `max_frame_rejections` | integer | `4` | Consecutive suspect outcomes for one frame before terminal escalation. | -| `poison_min_escalation_window_millis` | integer ms | `5000` | Minimum dwell before a poison frame may escalate. | +| `poison_min_escalation_window_millis` | integer ms | `300000` | Minimum connected dwell before a poison frame may escalate. | | `catch_up_cap_gap_min_escalation_window_millis` | integer ms | `300000` | Minimum dwell before an orphan symbol-dictionary cap gap is quarantined. | | `connection_listener_inbox_capacity` | integer | — | Bound on the connection-event inbox before events are dropped. | | `error_inbox_capacity` | integer | — | Bound on the `onSenderError` inbox before events are dropped. | @@ -142,17 +141,17 @@ Setting `sf_dir` turns on the persistent journal; the rest tune it. A default shown as a dash is applied downstream of the connect string, by the sender or session that consumes it. -| Key | Value | Default | Meaning | -| --------------------------- | ------------------------------ | ------------- | ----------------------------------------------------------------- | -| `sf_dir` | path | — | Journal directory. Enables store-and-forward. | -| `sf_durability` | `memory`, `periodic`, `append` | `memory` | Local durability barrier after each vectored append. | -| `sf_max_total_bytes` | integer bytes | `10737418240` | Journal ceiling. Reaching it is the one error a producer sees. | -| `sf_max_segment_bytes` | integer bytes | `4194304` | Size of one segment file. | -| `sf_sync_interval_millis` | integer ms | — | Checkpoint interval when `sf_durability=periodic`. | -| `sf_append_deadline_millis` | integer ms | `30000` | How long an append waits for space or a retryable journal fault. | -| `initial_connect_retry` | `off`, `sync`, `async` | `off` | Startup policy when the server is unreachable. Requires `sf_dir`. | -| `drain_orphans` | `on`, `off` | off | Adopt and drain journals left by crashed producers. | -| `max_background_drainers` | integer | — | Concurrent orphan drainers. | +| Key | Value | Default | Meaning | +| --------------------------- | ------------------------------ | ------------- | -------------------------------------------------------------------------------------------------------------------- | +| `sf_dir` | path | — | Journal directory. Enables store-and-forward. | +| `sf_durability` | `memory`, `periodic`, `append` | `memory` | Local durability barrier after each vectored append. | +| `sf_max_total_bytes` | integer bytes | `10737418240` | Journal ceiling. Reaching it is the one error a producer sees. | +| `sf_max_segment_bytes` | integer bytes | `4194304` | Size of one segment file. Also caps one ingress frame, including without `sf_dir`, since a frame must fit a segment. | +| `sf_sync_interval_millis` | integer ms | — | Checkpoint interval when `sf_durability=periodic`. | +| `sf_append_deadline_millis` | integer ms | `30000` | How long an append waits for space or a retryable journal fault. | +| `initial_connect_retry` | `off`, `sync`, `async` | `off` | Startup policy when the server is unreachable. Requires `sf_dir`. | +| `drain_orphans` | `on`, `off` | off | Adopt and drain journals left by crashed producers. | +| `max_background_drainers` | integer | — | Concurrent orphan drainers. | ### Egress @@ -167,20 +166,21 @@ session that consumes it. ### Pool -Applied by the pooled facade; a standalone sender or query client ignores them. - -| Key | Value | Default | Meaning | -| ------------------------- | ----------- | ------- | --------------------------------------------- | -| `sender_pool_min` | integer | — | Senders kept warm. | -| `sender_pool_max` | integer | — | Sender ceiling. | -| `query_pool_min` | integer | — | Query sessions kept warm. | -| `query_pool_max` | integer | — | Query-session ceiling. | -| `acquire_timeout_ms` | integer ms | — | How long `acquire()` waits for a free entry. | -| `query_close_timeout_ms` | integer ms | — | Bound on closing a borrowed query session. | -| `idle_timeout_ms` | integer ms | — | Idle time before a pooled entry is reaped. | -| `max_lifetime_ms` | integer ms | — | Absolute lifetime of a pooled entry. | -| `housekeeper_interval_ms` | integer ms | — | How often the pool reaps aged entries. | -| `lazy_connect` | `on`, `off` | off | Start without blocking on a first connection. | +Applied by the pooled facade. A standalone sender or query client ignores +them, with one exception noted in the table. + +| Key | Value | Default | Meaning | +| ------------------------- | ----------- | ------- | ------------------------------------------------------------------------------------------------------------------------------- | +| `sender_pool_min` | integer | — | Senders kept warm. | +| `sender_pool_max` | integer | — | Sender ceiling. | +| `query_pool_min` | integer | — | Query sessions kept warm. | +| `query_pool_max` | integer | — | Query-session ceiling. | +| `acquire_timeout_ms` | integer ms | — | How long `acquire()` waits for a free entry. | +| `query_close_timeout_ms` | integer ms | — | Bound on the CANCEL drain when a query session closes. Also honoured by a standalone egress session built from `egressSession`. | +| `idle_timeout_ms` | integer ms | — | Idle time before a pooled entry is reaped. | +| `max_lifetime_ms` | integer ms | — | Absolute lifetime of a pooled entry. | +| `housekeeper_interval_ms` | integer ms | — | How often the pool reaps aged entries. | +| `lazy_connect` | `on`, `off` | off | Start without blocking on a first connection. | ### Reserved @@ -210,7 +210,13 @@ await sender.close(); ``` The default port is 9007, the maximum datagram size (`max_datagram_size`) is 1400 -bytes, and the multicast TTL (`multicast_ttl`) is zero. Each datagram is +bytes, and the multicast TTL (`multicast_ttl`) is zero. `max_datagram_size` accepts +1 through 65507, the IPv4 payload maximum; a larger value is rejected when the sender +is created. Many hosts refuse datagrams well below that ceiling — macOS defaults +`net.inet.udp.maxdgram` to 9216 — so keep the value at or under the path MTU unless +the receiver is known to accept more. A datagram the operating system refuses is +discarded before transmission: it is reported through `onError` and does not advance +`publishedSequence` or `acknowledgedSequence`. Each datagram is self-contained, contains exactly one table, and uses an inline schema plus table-local symbol dictionaries. Batches are split at row boundaries; `QwpUdpDatagramTooLargeError` is raised before transmission when one row cannot @@ -308,15 +314,23 @@ The connect-string key `durability` controls the local persistence barrier: -- `"append"` (the default) issues a data-only durability barrier after every vectored - positional frame write; manifest and directory metadata retain full barriers; - hot-spare creation and activation are durable before publication resolves. +- `"append"` (the default for this object form) issues a data-only durability + barrier after every vectored positional frame write; manifest and directory + metadata retain full barriers; hot-spare creation and activation are durable + before publication resolves. - `"periodic"` checkpoints segment files, symbol metadata, and directory changes in the background. The default interval is 5 seconds, and `close()` performs a final checkpoint. A power failure can lose the most recent checkpoint window. - `"memory"` relies on operating-system writeback. It survives an orderly close and normally a process failure, but it makes no power-loss durability promise. +The two surfaces do not share a default. `storeAndForward.durability` above +defaults to `"append"`, while the `sf_durability` connect-string key defaults to +`"memory"` — so a journal configured with `sf_dir=` alone never issues a +per-append barrier, and a host crash can lose whatever writeback had not yet +reached disk. Set `sf_durability=append` explicitly when a connect-string journal +has to survive power loss. + `backpressurePolicy: "error"` preserves the existing immediate `QwpReplayStoreFullError` behavior. Set it to `"wait"` to pause publication until an ACK advances the checksummed cursor, then a bounded background trimmer deletes fully @@ -647,7 +661,10 @@ ambiguity: `float32()`/`float64()` and `int32()`/`int64()` mean exactly what the Geohash precision and decimal scale belong to the column, not the value, so they are fixed when the schema is compiled and validated against the sender's staged schema on -every append. Decimal text and `{ unscaled, scale }` values are rescaled to the +every append. On the fluent row API there is no schema to fix them, so a decimal +column instead locks its scale on the first value staged for it and holds that lock +for the rest of the frame; the lock is released once those rows are published, so the +next frame's first value sets it afresh. Decimal text and `{ unscaled, scale }` values are rescaled to the column's scale when that is exact, and rejected when it would round: at `decimal64(2)`, `"1.50"` stages as `150n` and `"1.005"` raises `QwpWriterRowError`. Base-32 geohash text carries five bits per character, so `geohash(20)` accepts @@ -880,11 +897,13 @@ process or page; configuring a Node directory makes the same replay crash-safe. Ingress also detects a replay head that is repeatedly NACKed or followed by a non-orderly WebSocket close. `maxFrameRejections` defaults to 4 consecutive strikes, -and `poisonMinEscalationWindowMs` defaults to 5 seconds. Both conditions must be met -before escalation. Normal (1000), going-away (1001), service-restart (1012), and -try-again-later (1013) closes, `NOT_WRITABLE`, retriable symbol-dictionary catch-up -rejections, and intervening connection-establishment failures reset the strike -episode. Abnormal closes (1006), internal-error closes (1011), and transport errors +and `poisonMinEscalationWindowMs` defaults to 5 minutes. Both conditions must be met +before escalation. The window measures _connected_ dwell only: time spent unable to +reach a server is banked and withheld, so an outage never supplies the dwell, and the +strikes a frame has already earned survive the reconnect it caused. Normal (1000), +going-away (1001), service-restart (1012), and try-again-later (1013) closes, +`NOT_WRITABLE`, `DICTIONARY_GAP`, and retriable symbol-dictionary catch-up rejections +reset the strike episode. Abnormal closes (1006), internal-error closes (1011), and transport errors without close information may count when an unacknowledged replay head exists. Escalation is terminal for that producer; store-and-forward retains and quarantines the affected rows for explicit `retryQwpNodeOrphanSlot()` recovery rather than @@ -896,6 +915,27 @@ an opaque upgrade error because their WebSocket API hides the HTTP response. Avo placing ingress replica endpoints in a browser endpoint list unless the proxy routes writers to a primary. +On Node.js a `401` or `403` on the upgrade is classified as an authentication +failure, and it is the one endpoint verdict that is terminal for the entire endpoint +set rather than for the endpoint that returned it. It short-circuits the sweep: the +endpoints ranked after it are never tried, and the reconnect loop rethrows before the +attempt and duration budgets are consulted, so no reconnect setting extends it. A +credential is cluster-wide, so a node rejecting it reports a configuration error that +walking on to a peer would only mask; the Java client applies the same rule. Every +other rejected status keeps the sweep walking, including `404`, which one node can +return mid-deploy while its peers are healthy, and a sweep mixing such attempts stays +retryable if any one of them was. + +Note how this composes with health ranking. A non-orderly close demotes the endpoint +it happened on, so a peer that answers `401` can rank ahead of the endpoint that just +dropped; that sweep then ends without the dropped endpoint being retried at all, and +the sender stays terminal even after it recovers. Two cases are exempt. A Node +foreground store-and-forward sender that has already connected once retries these +failures indefinitely, so a credential can rotate under a running producer without +losing journaled rows. A browser cannot distinguish them at all, because its upgrade +error carries no status; browser authentication failures surface from the REST +session bootstrap instead. + ### Observability Use immutable metrics snapshots for polling and callbacks for event-driven telemetry: @@ -1073,7 +1113,10 @@ credit window bounds server read-ahead while application work is in progress. `target` accepts `any` (the default), `primary`, or `replica`. Primary routing also accepts standalone servers and a primary completing catch-up, matching the Java -client. Both keys apply to ingress and egress alike. `zone` is an opaque, case-insensitive preference for `any` and `replica`; +client. Both keys apply to ingress and egress on Node.js. In browsers they apply +to egress only: ingress cannot learn a server's role or zone there, because the +WebSocket API hides the upgrade response that carries them. `zone` is an opaque, +case-insensitive preference for `any` and `replica`; cross-zone endpoints remain eligible. It is ignored for `primary`, which must be followed across zones. The client validates the authoritative role and zone from the first QWP `SERVER_INFO` frame before accepting an endpoint, so the same guarantees @@ -1144,6 +1187,14 @@ sends `X-QWP-Max-Batch-Rows`; browsers use the `qwp_max_batch_rows` URL paramete which requires a server that supports browser QWP negotiation. Older servers ignore the browser parameter and keep their configured batch size. +The connect helpers also enforce that request on what comes back: a `RESULT_BATCH` +declaring more rows than were asked for is rejected as a `QwpProtocolError` before +any column is read. Decoder scratch is sized from the declared row count and +retained per buffer-pool slot for reuse, so an answer above the request would set +the session's memory floor for its lifetime. Set `maxBatchRows` on the session +options to bound a session built directly from a connection; left unset, the cell +cap below is the only bound. + A single `RESULT_BATCH` may declare at most `QWP_MAX_CELLS_PER_BATCH` cells -- 32Mi, its rows multiplied by its columns. The row and column caps bound each dimension on its own, and a compressed body detaches the grid they describe from @@ -1263,11 +1314,14 @@ const db = await connectQwpNodeClient( ``` For unified strings with `sf_dir`, Java-compatible defaults apply: memory -durability, a 10 GiB total journal cap, 4 MiB frame/segment batches, a 30-second -capacity wait, a 60-second close drain, and fail-fast initial connection. Set +durability, a 10 GiB total journal cap, 4 MiB journal segments, a 30-second +capacity wait, a 5-second close drain, and fail-fast initial connection. Set `sender_id` to name the disk slot base; pooled senders use `-`. +The 4 MiB default sizes journal segments only; it does not install an ingress +frame cap. Set `sf_max_segment_bytes` explicitly, or `qwp.session.maxBatchSizeBytes`, +to bound frames before the first publication tells the client the server's cap. Without `sf_dir`, `sf_max_total_bytes` and `sf_append_deadline_millis` tune the -built-in memory replay queue instead. +built-in memory replay queue instead, and `sf_max_segment_bytes` still caps a frame. The parser also supports `max_name_len` and the Java listener/error inbox capacity keys. Those capacities actively bound asynchronous connection and typed-error delivery and are reflected in ingress drop counters. @@ -1370,7 +1424,10 @@ form with complete `ingress` and `egress` trees remains supported for advanced cases that intentionally connect the two sides differently. `connectQwpNodeClient()` and `connectQwpBrowserClient()` prewarm each configured -pool minimum. Their `createQwp*Client()` counterparts are lazy. Pools grow to +pool minimum. Their `createQwp*Client()` counterparts are lazy. A prewarm that +fails rejects but does not close the client: connections it did establish stay +pooled, and calling `connect()` again makes a fresh attempt, so a transient +outage at start-up can be retried rather than requiring a new client. Pools grow to their maximum under concurrent borrows and apply one FIFO acquisition deadline; exhaustion raises `QwpPoolAcquireTimeoutError`. Query handles are single-flight, but separate borrowed handles run concurrently. Returning a handle with an active @@ -1522,10 +1579,13 @@ They are intentionally not CI performance gates. ## Public API policy -Only the four package entry points listed at the top are public. In particular, -paths containing `internal`, `qwp-node`, or `src` are implementation details even if -a bundler can resolve them. The compatibility contract checks the documented -high-level constructors, session classes, errors, constants, and option signatures -from the shared, browser, and Node entry points. Additional low-level codec exports -from `qwp` are intended for advanced integrations; prefer high-level APIs when no -custom encoder or transport is required. +Only the two package roots listed at the top are public: `@questdb/nodejs-client` +and `@questdb/browser-client`. Each declares exactly one `exports` subpath, so +those two specifiers are the whole supported surface. In particular, paths +containing `internal`, `qwp-node`, `client-core`, or `src` are implementation +details even if a bundler can resolve them, and `@questdb/client-core` is a private +workspace package that is never published. The compatibility contract checks the +documented high-level constructors, session classes, errors, constants, and option +signatures exported from both package roots. Additional low-level codec exports +share those roots and are intended for advanced integrations; prefer the high-level +APIs when no custom encoder or transport is required. diff --git a/docs/modules/_questdb_browser-client.html b/docs/modules/_questdb_browser-client.html index 4aa088f..ba01a69 100644 --- a/docs/modules/_questdb_browser-client.html +++ b/docs/modules/_questdb_browser-client.html @@ -106,4 +106,4 @@

    Returns void

    diff --git a/docs/types/_questdb_nodejs-client.QwpBindSetter.html b/docs/types/_questdb_nodejs-client.QwpBindSetter.html index c47142e..6826200 100644 --- a/docs/types/_questdb_nodejs-client.QwpBindSetter.html +++ b/docs/types/_questdb_nodejs-client.QwpBindSetter.html @@ -1 +1 @@ -QwpBindSetter | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpBindSetter

    QwpBindSetter: (binds: QwpBindValues) => void

    Type declaration

    +QwpBindSetter | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpBindSetter

    QwpBindSetter: (binds: QwpBindValues) => void

    Type declaration

    diff --git a/docs/types/_questdb_nodejs-client.QwpBindType.html b/docs/types/_questdb_nodejs-client.QwpBindType.html index e420a35..cb71f25 100644 --- a/docs/types/_questdb_nodejs-client.QwpBindType.html +++ b/docs/types/_questdb_nodejs-client.QwpBindType.html @@ -1,2 +1,2 @@ QwpBindType | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpBindType

    QwpBindType:
        | typeof BOOLEAN
        | typeof BYTE
        | typeof SHORT
        | typeof INT
        | typeof LONG
        | typeof FLOAT
        | typeof DOUBLE
        | typeof TIMESTAMP
        | typeof DATE
        | typeof UUID
        | typeof LONG256
        | typeof GEOHASH
        | typeof VARCHAR
        | typeof TIMESTAMP_NANOS
        | typeof DECIMAL64
        | typeof DECIMAL128
        | typeof DECIMAL256
        | typeof CHAR

    Phase-1 scalar bind types exposed by the Java reference client.

    -
    +
    diff --git a/docs/types/_questdb_nodejs-client.QwpColumnType.html b/docs/types/_questdb_nodejs-client.QwpColumnType.html index f173eda..c18b4eb 100644 --- a/docs/types/_questdb_nodejs-client.QwpColumnType.html +++ b/docs/types/_questdb_nodejs-client.QwpColumnType.html @@ -1 +1 @@ -QwpColumnType | QuestDB JavaScript Client - v4.2.0
    +QwpColumnType | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/types/_questdb_nodejs-client.QwpConnectionFactory.html b/docs/types/_questdb_nodejs-client.QwpConnectionFactory.html index 3d83bbb..09a4ac8 100644 --- a/docs/types/_questdb_nodejs-client.QwpConnectionFactory.html +++ b/docs/types/_questdb_nodejs-client.QwpConnectionFactory.html @@ -2,4 +2,4 @@ closes, so a factory that is still negotiating can tear its socket down instead of leaving it alive until its own deadline expires. Factories that ignore the parameter remain assignable.

    -

    Type declaration

    +

    Type declaration

    diff --git a/docs/types/_questdb_nodejs-client.QwpDecimalInput.html b/docs/types/_questdb_nodejs-client.QwpDecimalInput.html index 6bb2b5f..10208c7 100644 --- a/docs/types/_questdb_nodejs-client.QwpDecimalInput.html +++ b/docs/types/_questdb_nodejs-client.QwpDecimalInput.html @@ -1,3 +1,3 @@ QwpDecimalInput | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpDecimalInput

    QwpDecimalInput: bigint | number | string | { scale: number; unscaled: bigint }

    DECIMAL input: the unscaled bigint at the column's scale, decimal text (or a number) that is exactly representable at that scale, or the egress record.

    -
    +
    diff --git a/docs/types/_questdb_nodejs-client.QwpDoubleArrayInput.html b/docs/types/_questdb_nodejs-client.QwpDoubleArrayInput.html index a8b88e0..3cd9f71 100644 --- a/docs/types/_questdb_nodejs-client.QwpDoubleArrayInput.html +++ b/docs/types/_questdb_nodejs-client.QwpDoubleArrayInput.html @@ -1,2 +1,2 @@ QwpDoubleArrayInput | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpDoubleArrayInput

    QwpDoubleArrayInput:
        | QwpNestedNumberArray
        | { dimensions: readonly number[]; values: readonly number[] }

    DOUBLE array input: nested arrays or a flat shape-and-values record.

    -
    +
    diff --git a/docs/types/_questdb_nodejs-client.QwpEgressCompression.html b/docs/types/_questdb_nodejs-client.QwpEgressCompression.html index 7bc063c..fd982bc 100644 --- a/docs/types/_questdb_nodejs-client.QwpEgressCompression.html +++ b/docs/types/_questdb_nodejs-client.QwpEgressCompression.html @@ -1 +1 @@ -QwpEgressCompression | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpEgressCompression

    QwpEgressCompression: "raw" | "zstd" | "auto"
    +QwpEgressCompression | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpEgressCompression

    QwpEgressCompression: "raw" | "zstd" | "auto"
    diff --git a/docs/types/_questdb_nodejs-client.QwpEgressMessage.html b/docs/types/_questdb_nodejs-client.QwpEgressMessage.html index 45c3ea0..349c40f 100644 --- a/docs/types/_questdb_nodejs-client.QwpEgressMessage.html +++ b/docs/types/_questdb_nodejs-client.QwpEgressMessage.html @@ -1 +1 @@ -QwpEgressMessage | QuestDB JavaScript Client - v4.2.0
    +QwpEgressMessage | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/types/_questdb_nodejs-client.QwpExtraOptions.html b/docs/types/_questdb_nodejs-client.QwpExtraOptions.html index ff0fe2a..324d119 100644 --- a/docs/types/_questdb_nodejs-client.QwpExtraOptions.html +++ b/docs/types/_questdb_nodejs-client.QwpExtraOptions.html @@ -1,11 +1,11 @@ -QwpExtraOptions | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpExtraOptions

    type QwpExtraOptions = {
        sender?: QwpSenderOptions;
        session?: QwpIngressSessionOptions;
        udp?: Omit<QwpNodeUdpOptions, "host" | "port">;
        webSocket?: Omit<QwpNodeIngressOptions, "url">;
    }
    Index

    Properties

    sender? +QwpExtraOptions | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpExtraOptions

    type QwpExtraOptions = {
        sender?: QwpSenderOptions;
        session?: QwpIngressSessionOptions;
        udp?: Omit<QwpNodeUdpOptions, "host" | "port">;
        webSocket?: Omit<QwpNodeIngressOptions, "url">;
    }
    Index

    Properties

    High-level buffering and auto-flush options.

    -

    Ingress ACK, durable-ACK, and reconnect options.

    -
    udp?: Omit<QwpNodeUdpOptions, "host" | "port">

    Node-only QWP-over-UDP socket overrides.

    -
    webSocket?: Omit<QwpNodeIngressOptions, "url">

    Node ingress overrides. Values are applied after the connect string has +

    Ingress ACK, durable-ACK, and reconnect options.

    +
    udp?: Omit<QwpNodeUdpOptions, "host" | "port">

    Node-only QWP-over-UDP socket overrides.

    +
    webSocket?: Omit<QwpNodeIngressOptions, "url">

    Node ingress overrides. Values are applied after the connect string has been fully parsed and validated; typed values win when both forms set the same option.

    -
    +
    diff --git a/docs/types/_questdb_nodejs-client.QwpGeohashInput.html b/docs/types/_questdb_nodejs-client.QwpGeohashInput.html index 93c701d..49a98c6 100644 --- a/docs/types/_questdb_nodejs-client.QwpGeohashInput.html +++ b/docs/types/_questdb_nodejs-client.QwpGeohashInput.html @@ -1,3 +1,3 @@ QwpGeohashInput | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpGeohashInput

    QwpGeohashInput:
        | bigint
        | number
        | string
        | { bits: bigint; precisionBits: number }

    GEOHASH input: the raw bits, base-32 geohash text whose length matches the column precision, or the egress bit record.

    -
    +
    diff --git a/docs/types/_questdb_nodejs-client.QwpIngressProgressKind.html b/docs/types/_questdb_nodejs-client.QwpIngressProgressKind.html index 40b466a..9e8c15c 100644 --- a/docs/types/_questdb_nodejs-client.QwpIngressProgressKind.html +++ b/docs/types/_questdb_nodejs-client.QwpIngressProgressKind.html @@ -1 +1 @@ -QwpIngressProgressKind | QuestDB JavaScript Client - v4.2.0
    +QwpIngressProgressKind | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/types/_questdb_nodejs-client.QwpInitialConnectMode.html b/docs/types/_questdb_nodejs-client.QwpInitialConnectMode.html index d88b35b..860b68f 100644 --- a/docs/types/_questdb_nodejs-client.QwpInitialConnectMode.html +++ b/docs/types/_questdb_nodejs-client.QwpInitialConnectMode.html @@ -1 +1 @@ -QwpInitialConnectMode | QuestDB JavaScript Client - v4.2.0
    +QwpInitialConnectMode | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/types/_questdb_nodejs-client.QwpInt64.html b/docs/types/_questdb_nodejs-client.QwpInt64.html index cb1738d..a963cce 100644 --- a/docs/types/_questdb_nodejs-client.QwpInt64.html +++ b/docs/types/_questdb_nodejs-client.QwpInt64.html @@ -1 +1 @@ -QwpInt64 | QuestDB JavaScript Client - v4.2.0
    QwpInt64: number | bigint
    +QwpInt64 | QuestDB JavaScript Client - v4.2.0
    QwpInt64: number | bigint
    diff --git a/docs/types/_questdb_nodejs-client.QwpIpv4Input.html b/docs/types/_questdb_nodejs-client.QwpIpv4Input.html index ff6d189..396c277 100644 --- a/docs/types/_questdb_nodejs-client.QwpIpv4Input.html +++ b/docs/types/_questdb_nodejs-client.QwpIpv4Input.html @@ -1,2 +1,2 @@ QwpIpv4Input | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpIpv4Input

    QwpIpv4Input: string | number

    IPV4 input: dotted-quad text or a signed/unsigned packed 32-bit address.

    -
    +
    diff --git a/docs/types/_questdb_nodejs-client.QwpLong256Input.html b/docs/types/_questdb_nodejs-client.QwpLong256Input.html index 9f30d46..c2a01c7 100644 --- a/docs/types/_questdb_nodejs-client.QwpLong256Input.html +++ b/docs/types/_questdb_nodejs-client.QwpLong256Input.html @@ -1,3 +1,3 @@ QwpLong256Input | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpLong256Input

    QwpLong256Input: bigint | string | QwpLong256Words | { words: QwpLong256Words }

    LONG256 input: an unsigned 256-bit bigint, a 0x-prefixed hex string of up to 64 digits, four little-endian words, or the egress word record.

    -
    +
    diff --git a/docs/types/_questdb_nodejs-client.QwpLong256Words.html b/docs/types/_questdb_nodejs-client.QwpLong256Words.html index 4132858..9d64af8 100644 --- a/docs/types/_questdb_nodejs-client.QwpLong256Words.html +++ b/docs/types/_questdb_nodejs-client.QwpLong256Words.html @@ -1,2 +1,2 @@ QwpLong256Words | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpLong256Words

    QwpLong256Words: readonly [bigint, bigint, bigint, bigint]

    LONG256 little-endian words; word 0 is least significant.

    -
    +
    diff --git a/docs/types/_questdb_nodejs-client.QwpLongArrayInput.html b/docs/types/_questdb_nodejs-client.QwpLongArrayInput.html index 7452c24..bd97b5e 100644 --- a/docs/types/_questdb_nodejs-client.QwpLongArrayInput.html +++ b/docs/types/_questdb_nodejs-client.QwpLongArrayInput.html @@ -1,2 +1,2 @@ QwpLongArrayInput | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpLongArrayInput

    QwpLongArrayInput:
        | QwpNestedLongArray
        | { dimensions: readonly number[]; values: readonly (number | bigint)[] }

    LONG array input: nested arrays or a flat shape-and-values record.

    -
    +
    diff --git a/docs/types/_questdb_nodejs-client.QwpNegotiatedEgressCompression.html b/docs/types/_questdb_nodejs-client.QwpNegotiatedEgressCompression.html index f1a25cd..6e63444 100644 --- a/docs/types/_questdb_nodejs-client.QwpNegotiatedEgressCompression.html +++ b/docs/types/_questdb_nodejs-client.QwpNegotiatedEgressCompression.html @@ -1 +1 @@ -QwpNegotiatedEgressCompression | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpNegotiatedEgressCompression

    QwpNegotiatedEgressCompression:
        | { codec: "raw"; level: 0 }
        | { codec: "zstd"; level: number }
        | { codec: "unknown"; contentEncoding: string; level: 0 }
    +QwpNegotiatedEgressCompression | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpNegotiatedEgressCompression

    QwpNegotiatedEgressCompression:
        | { codec: "raw"; level: 0 }
        | { codec: "zstd"; level: number }
        | { codec: "unknown"; contentEncoding: string; level: 0 }
    diff --git a/docs/types/_questdb_nodejs-client.QwpNestedLongArray.html b/docs/types/_questdb_nodejs-client.QwpNestedLongArray.html index cdea330..79ac9a4 100644 --- a/docs/types/_questdb_nodejs-client.QwpNestedLongArray.html +++ b/docs/types/_questdb_nodejs-client.QwpNestedLongArray.html @@ -1,2 +1,2 @@ QwpNestedLongArray | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpNestedLongArray

    QwpNestedLongArray: readonly (number | bigint | QwpNestedLongArray)[]

    Nested LONG array of uniform shape.

    -
    +
    diff --git a/docs/types/_questdb_nodejs-client.QwpNestedNumberArray.html b/docs/types/_questdb_nodejs-client.QwpNestedNumberArray.html index d7dfb75..ce5579a 100644 --- a/docs/types/_questdb_nodejs-client.QwpNestedNumberArray.html +++ b/docs/types/_questdb_nodejs-client.QwpNestedNumberArray.html @@ -1,2 +1,2 @@ QwpNestedNumberArray | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpNestedNumberArray

    QwpNestedNumberArray: readonly (number | QwpNestedNumberArray)[]

    Nested DOUBLE array of uniform shape.

    -
    +
    diff --git a/docs/types/_questdb_nodejs-client.QwpNodeOrphanDrainEventKind.html b/docs/types/_questdb_nodejs-client.QwpNodeOrphanDrainEventKind.html index ed1b51a..035e990 100644 --- a/docs/types/_questdb_nodejs-client.QwpNodeOrphanDrainEventKind.html +++ b/docs/types/_questdb_nodejs-client.QwpNodeOrphanDrainEventKind.html @@ -1 +1 @@ -QwpNodeOrphanDrainEventKind | QuestDB JavaScript Client - v4.2.0
    +QwpNodeOrphanDrainEventKind | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/types/_questdb_nodejs-client.QwpQueryCompletion.html b/docs/types/_questdb_nodejs-client.QwpQueryCompletion.html index e963919..bd1fdf8 100644 --- a/docs/types/_questdb_nodejs-client.QwpQueryCompletion.html +++ b/docs/types/_questdb_nodejs-client.QwpQueryCompletion.html @@ -1 +1 @@ -QwpQueryCompletion | QuestDB JavaScript Client - v4.2.0
    +QwpQueryCompletion | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/types/_questdb_nodejs-client.QwpReconnectEventKind.html b/docs/types/_questdb_nodejs-client.QwpReconnectEventKind.html index f0f9bcd..042bf1b 100644 --- a/docs/types/_questdb_nodejs-client.QwpReconnectEventKind.html +++ b/docs/types/_questdb_nodejs-client.QwpReconnectEventKind.html @@ -1 +1 @@ -QwpReconnectEventKind | QuestDB JavaScript Client - v4.2.0
    +QwpReconnectEventKind | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/types/_questdb_nodejs-client.QwpResultBatchViewHandler.html b/docs/types/_questdb_nodejs-client.QwpResultBatchViewHandler.html index 0dea2d1..9b914cd 100644 --- a/docs/types/_questdb_nodejs-client.QwpResultBatchViewHandler.html +++ b/docs/types/_questdb_nodejs-client.QwpResultBatchViewHandler.html @@ -1,3 +1,3 @@ QwpResultBatchViewHandler | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpResultBatchViewHandler

    QwpResultBatchViewHandler: (
        batch: QwpResultBatchView,
        query: QwpEgressViewQuery,
    ) => void | Promise<void>

    Runs while one reusable batch view is valid. Do not retain the batch, columns, or raw byte slices after the callback settles.

    -

    Type declaration

    +

    Type declaration

    diff --git a/docs/types/_questdb_nodejs-client.QwpResultRowViewCallback.html b/docs/types/_questdb_nodejs-client.QwpResultRowViewCallback.html index 536e1d8..3ecd9d1 100644 --- a/docs/types/_questdb_nodejs-client.QwpResultRowViewCallback.html +++ b/docs/types/_questdb_nodejs-client.QwpResultRowViewCallback.html @@ -1,2 +1,2 @@ QwpResultRowViewCallback | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpResultRowViewCallback

    QwpResultRowViewCallback: (row: QwpResultRowView) => void

    Callback invoked by QwpResultBatchView.forEachRow().

    -

    Type declaration

    +

    Type declaration

    diff --git a/docs/types/_questdb_nodejs-client.QwpResultValue.html b/docs/types/_questdb_nodejs-client.QwpResultValue.html index 6fd57e8..d058640 100644 --- a/docs/types/_questdb_nodejs-client.QwpResultValue.html +++ b/docs/types/_questdb_nodejs-client.QwpResultValue.html @@ -1 +1 @@ -QwpResultValue | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpResultValue

    QwpResultValue:
        | boolean
        | number
        | bigint
        | string
        | Uint8Array
        | QwpDecimalValue
        | QwpUuidValue
        | QwpLong256Value
        | QwpGeohashValue
        | QwpResultArrayValue
        | null
    +QwpResultValue | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpResultValue

    QwpResultValue:
        | boolean
        | number
        | bigint
        | string
        | Uint8Array
        | QwpDecimalValue
        | QwpUuidValue
        | QwpLong256Value
        | QwpGeohashValue
        | QwpResultArrayValue
        | null
    diff --git a/docs/types/_questdb_nodejs-client.QwpSenderErrorCategory.html b/docs/types/_questdb_nodejs-client.QwpSenderErrorCategory.html index 006618b..f1c763f 100644 --- a/docs/types/_questdb_nodejs-client.QwpSenderErrorCategory.html +++ b/docs/types/_questdb_nodejs-client.QwpSenderErrorCategory.html @@ -1 +1 @@ -QwpSenderErrorCategory | QuestDB JavaScript Client - v4.2.0
    +QwpSenderErrorCategory | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/types/_questdb_nodejs-client.QwpSenderErrorPolicy.html b/docs/types/_questdb_nodejs-client.QwpSenderErrorPolicy.html index 5399dc4..b751e05 100644 --- a/docs/types/_questdb_nodejs-client.QwpSenderErrorPolicy.html +++ b/docs/types/_questdb_nodejs-client.QwpSenderErrorPolicy.html @@ -1 +1 @@ -QwpSenderErrorPolicy | QuestDB JavaScript Client - v4.2.0
    +QwpSenderErrorPolicy | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/types/_questdb_nodejs-client.QwpSenderLogger.html b/docs/types/_questdb_nodejs-client.QwpSenderLogger.html index df340a3..65f4812 100644 --- a/docs/types/_questdb_nodejs-client.QwpSenderLogger.html +++ b/docs/types/_questdb_nodejs-client.QwpSenderLogger.html @@ -1 +1 @@ -QwpSenderLogger | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpSenderLogger

    QwpSenderLogger: (
        level: "error" | "warn" | "info" | "debug",
        message: string | Error,
    ) => void

    Type declaration

      • (level: "error" | "warn" | "info" | "debug", message: string | Error): void
      • Parameters

        • level: "error" | "warn" | "info" | "debug"
        • message: string | Error

        Returns void

    +QwpSenderLogger | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpSenderLogger

    QwpSenderLogger: (
        level: "error" | "warn" | "info" | "debug",
        message: string | Error,
    ) => void

    Type declaration

      • (level: "error" | "warn" | "info" | "debug", message: string | Error): void
      • Parameters

        • level: "error" | "warn" | "info" | "debug"
        • message: string | Error

        Returns void

    diff --git a/docs/types/_questdb_nodejs-client.QwpSenderSessionFactory.html b/docs/types/_questdb_nodejs-client.QwpSenderSessionFactory.html index 1df3f38..a9fbbd0 100644 --- a/docs/types/_questdb_nodejs-client.QwpSenderSessionFactory.html +++ b/docs/types/_questdb_nodejs-client.QwpSenderSessionFactory.html @@ -2,4 +2,4 @@ still negotiating can be torn down instead of outliving the sender by up to its connect/auth deadline. Factories that ignore the parameter remain assignable, matching QwpConnectionFactory.

    -

    Type declaration

    +

    Type declaration

    diff --git a/docs/types/_questdb_nodejs-client.QwpSfBackpressurePolicy.html b/docs/types/_questdb_nodejs-client.QwpSfBackpressurePolicy.html index 86900db..df0c06c 100644 --- a/docs/types/_questdb_nodejs-client.QwpSfBackpressurePolicy.html +++ b/docs/types/_questdb_nodejs-client.QwpSfBackpressurePolicy.html @@ -1 +1 @@ -QwpSfBackpressurePolicy | QuestDB JavaScript Client - v4.2.0
    +QwpSfBackpressurePolicy | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/types/_questdb_nodejs-client.QwpSfDurability.html b/docs/types/_questdb_nodejs-client.QwpSfDurability.html index 4e25840..9265a8e 100644 --- a/docs/types/_questdb_nodejs-client.QwpSfDurability.html +++ b/docs/types/_questdb_nodejs-client.QwpSfDurability.html @@ -1 +1 @@ -QwpSfDurability | QuestDB JavaScript Client - v4.2.0
    +QwpSfDurability | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/types/_questdb_nodejs-client.QwpTarget.html b/docs/types/_questdb_nodejs-client.QwpTarget.html index 9c2981a..5326293 100644 --- a/docs/types/_questdb_nodejs-client.QwpTarget.html +++ b/docs/types/_questdb_nodejs-client.QwpTarget.html @@ -1,2 +1,2 @@ QwpTarget | QuestDB JavaScript Client - v4.2.0
    QwpTarget: typeof QWP_TARGET[keyof typeof QWP_TARGET]

    Server role accepted by an egress connection. Defaults to any.

    -
    +
    diff --git a/docs/types/_questdb_nodejs-client.QwpTimestampUnit.html b/docs/types/_questdb_nodejs-client.QwpTimestampUnit.html index 984a91e..7e715c7 100644 --- a/docs/types/_questdb_nodejs-client.QwpTimestampUnit.html +++ b/docs/types/_questdb_nodejs-client.QwpTimestampUnit.html @@ -1 +1 @@ -QwpTimestampUnit | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpTimestampUnit

    QwpTimestampUnit: "ns" | "us" | "ms"
    +QwpTimestampUnit | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpTimestampUnit

    QwpTimestampUnit: "ns" | "us" | "ms"
    diff --git a/docs/types/_questdb_nodejs-client.QwpUpgradeErrorKind.html b/docs/types/_questdb_nodejs-client.QwpUpgradeErrorKind.html index 8eb4d33..1a16917 100644 --- a/docs/types/_questdb_nodejs-client.QwpUpgradeErrorKind.html +++ b/docs/types/_questdb_nodejs-client.QwpUpgradeErrorKind.html @@ -1 +1 @@ -QwpUpgradeErrorKind | QuestDB JavaScript Client - v4.2.0
    +QwpUpgradeErrorKind | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/types/_questdb_nodejs-client.QwpUpgradeTimeoutPhase.html b/docs/types/_questdb_nodejs-client.QwpUpgradeTimeoutPhase.html index b7cfe84..5d0628f 100644 --- a/docs/types/_questdb_nodejs-client.QwpUpgradeTimeoutPhase.html +++ b/docs/types/_questdb_nodejs-client.QwpUpgradeTimeoutPhase.html @@ -1,2 +1,2 @@ QwpUpgradeTimeoutPhase | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpUpgradeTimeoutPhase

    QwpUpgradeTimeoutPhase: typeof QWP_UPGRADE_TIMEOUT_PHASE[keyof typeof QWP_UPGRADE_TIMEOUT_PHASE]

    Opening phase whose Node QWP deadline expired.

    -
    +
    diff --git a/docs/types/_questdb_nodejs-client.QwpUuidInput.html b/docs/types/_questdb_nodejs-client.QwpUuidInput.html index d3997a9..634991e 100644 --- a/docs/types/_questdb_nodejs-client.QwpUuidInput.html +++ b/docs/types/_questdb_nodejs-client.QwpUuidInput.html @@ -1,4 +1,4 @@ QwpUuidInput | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpUuidInput

    QwpUuidInput: string | Uint8Array | { high: bigint; low: bigint }

    UUID input: canonical text, 16 canonical (RFC 4122) big-endian bytes, or the egress limb pair. All three forms describe the same UUID; the byte form is what uuid.parse() and java.util.UUID produce, not pre-encoded wire bytes.

    -
    +
    diff --git a/docs/types/_questdb_nodejs-client.QwpWriterColumnKind.html b/docs/types/_questdb_nodejs-client.QwpWriterColumnKind.html index c59847b..ce8784e 100644 --- a/docs/types/_questdb_nodejs-client.QwpWriterColumnKind.html +++ b/docs/types/_questdb_nodejs-client.QwpWriterColumnKind.html @@ -1 +1 @@ -QwpWriterColumnKind | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpWriterColumnKind

    QwpWriterColumnKind:
        | "symbol"
        | "varchar"
        | "bool"
        | "byte"
        | "short"
        | "int32"
        | "int64"
        | "float32"
        | "float64"
        | "timestamp"
        | "date"
        | "char"
        | "binary"
        | "uuid"
        | "long256"
        | "ipv4"
        | "geohash"
        | "decimal64"
        | "decimal128"
        | "decimal256"
        | "doubleArray"
        | "longArray"
    +QwpWriterColumnKind | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpWriterColumnKind

    QwpWriterColumnKind:
        | "symbol"
        | "varchar"
        | "bool"
        | "byte"
        | "short"
        | "int32"
        | "int64"
        | "float32"
        | "float64"
        | "timestamp"
        | "date"
        | "char"
        | "binary"
        | "uuid"
        | "long256"
        | "ipv4"
        | "geohash"
        | "decimal64"
        | "decimal128"
        | "decimal256"
        | "doubleArray"
        | "longArray"
    diff --git a/docs/types/_questdb_nodejs-client.QwpWriterRow.html b/docs/types/_questdb_nodejs-client.QwpWriterRow.html index c08701a..2163c56 100644 --- a/docs/types/_questdb_nodejs-client.QwpWriterRow.html +++ b/docs/types/_questdb_nodejs-client.QwpWriterRow.html @@ -1,2 +1,2 @@ QwpWriterRow | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpWriterRow<Schema>

    QwpWriterRow: {
        [Key in QwpDesignatedTimestampKey<Schema>]-?: QwpWriterColumnInput<
            Schema[Key],
        >
    } & {
        [Key in QwpRegularColumnKey<Schema>]?:
            | QwpWriterColumnInput<Schema[Key]>
            | null
    }

    The object accepted by a table writer compiled from Schema.

    -

    Type Parameters

    +

    Type Parameters

    diff --git a/docs/types/_questdb_nodejs-client.QwpWriterSchema.html b/docs/types/_questdb_nodejs-client.QwpWriterSchema.html index 912c770..284316a 100644 --- a/docs/types/_questdb_nodejs-client.QwpWriterSchema.html +++ b/docs/types/_questdb_nodejs-client.QwpWriterSchema.html @@ -1 +1 @@ -QwpWriterSchema | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpWriterSchema

    QwpWriterSchema: Readonly<Record<string, QwpWriterColumn<unknown, boolean>>>
    +QwpWriterSchema | QuestDB JavaScript Client - v4.2.0

    Type Alias QwpWriterSchema

    QwpWriterSchema: Readonly<Record<string, QwpWriterColumn<unknown, boolean>>>
    diff --git a/docs/types/_questdb_nodejs-client.TimestampUnit.html b/docs/types/_questdb_nodejs-client.TimestampUnit.html index 9321730..1feca70 100644 --- a/docs/types/_questdb_nodejs-client.TimestampUnit.html +++ b/docs/types/_questdb_nodejs-client.TimestampUnit.html @@ -1,2 +1,2 @@ TimestampUnit | QuestDB JavaScript Client - v4.2.0

    Type Alias TimestampUnit

    TimestampUnit: "ns" | "us" | "ms"

    Supported timestamp units for QuestDB operations.

    -
    +
    diff --git a/docs/variables/_questdb_browser-client.QWP_COLUMN_TYPE.html b/docs/variables/_questdb_browser-client.QWP_COLUMN_TYPE.html index a0cae32..e33a481 100644 --- a/docs/variables/_questdb_browser-client.QWP_COLUMN_TYPE.html +++ b/docs/variables/_questdb_browser-client.QWP_COLUMN_TYPE.html @@ -1 +1 @@ -QWP_COLUMN_TYPE | QuestDB JavaScript Client - v4.2.0

    Variable QWP_COLUMN_TYPEConst

    QWP_COLUMN_TYPE: {
        BINARY: 23;
        BOOLEAN: 1;
        BYTE: 2;
        CHAR: 22;
        DATE: 11;
        DECIMAL128: 20;
        DECIMAL256: 21;
        DECIMAL64: 19;
        DOUBLE: 7;
        DOUBLE_ARRAY: 17;
        FLOAT: 6;
        GEOHASH: 14;
        INT: 4;
        IPV4: 24;
        LONG: 5;
        LONG_ARRAY: 18;
        LONG256: 13;
        SHORT: 3;
        SYMBOL: 9;
        TIMESTAMP: 10;
        TIMESTAMP_NANOS: 16;
        UUID: 12;
        VARCHAR: 15;
    } = ...

    Type declaration

    • ReadonlyBINARY: 23
    • ReadonlyBOOLEAN: 1
    • ReadonlyBYTE: 2
    • ReadonlyCHAR: 22
    • ReadonlyDATE: 11
    • ReadonlyDECIMAL128: 20
    • ReadonlyDECIMAL256: 21
    • ReadonlyDECIMAL64: 19
    • ReadonlyDOUBLE: 7
    • ReadonlyDOUBLE_ARRAY: 17
    • ReadonlyFLOAT: 6
    • ReadonlyGEOHASH: 14
    • ReadonlyINT: 4
    • ReadonlyIPV4: 24
    • ReadonlyLONG: 5
    • ReadonlyLONG_ARRAY: 18
    • ReadonlyLONG256: 13
    • ReadonlySHORT: 3
    • ReadonlySYMBOL: 9
    • ReadonlyTIMESTAMP: 10
    • ReadonlyTIMESTAMP_NANOS: 16
    • ReadonlyUUID: 12
    • ReadonlyVARCHAR: 15
    +QWP_COLUMN_TYPE | QuestDB JavaScript Client - v4.2.0

    Variable QWP_COLUMN_TYPEConst

    QWP_COLUMN_TYPE: {
        BINARY: 23;
        BOOLEAN: 1;
        BYTE: 2;
        CHAR: 22;
        DATE: 11;
        DECIMAL128: 20;
        DECIMAL256: 21;
        DECIMAL64: 19;
        DOUBLE: 7;
        DOUBLE_ARRAY: 17;
        FLOAT: 6;
        GEOHASH: 14;
        INT: 4;
        IPV4: 24;
        LONG: 5;
        LONG_ARRAY: 18;
        LONG256: 13;
        SHORT: 3;
        SYMBOL: 9;
        TIMESTAMP: 10;
        TIMESTAMP_NANOS: 16;
        UUID: 12;
        VARCHAR: 15;
    } = ...

    Type declaration

    • ReadonlyBINARY: 23
    • ReadonlyBOOLEAN: 1
    • ReadonlyBYTE: 2
    • ReadonlyCHAR: 22
    • ReadonlyDATE: 11
    • ReadonlyDECIMAL128: 20
    • ReadonlyDECIMAL256: 21
    • ReadonlyDECIMAL64: 19
    • ReadonlyDOUBLE: 7
    • ReadonlyDOUBLE_ARRAY: 17
    • ReadonlyFLOAT: 6
    • ReadonlyGEOHASH: 14
    • ReadonlyINT: 4
    • ReadonlyIPV4: 24
    • ReadonlyLONG: 5
    • ReadonlyLONG_ARRAY: 18
    • ReadonlyLONG256: 13
    • ReadonlySHORT: 3
    • ReadonlySYMBOL: 9
    • ReadonlyTIMESTAMP: 10
    • ReadonlyTIMESTAMP_NANOS: 16
    • ReadonlyUUID: 12
    • ReadonlyVARCHAR: 15
    diff --git a/docs/variables/_questdb_browser-client.QWP_COMPRESSION_CODEC.html b/docs/variables/_questdb_browser-client.QWP_COMPRESSION_CODEC.html index 9f58c9a..c70c005 100644 --- a/docs/variables/_questdb_browser-client.QWP_COMPRESSION_CODEC.html +++ b/docs/variables/_questdb_browser-client.QWP_COMPRESSION_CODEC.html @@ -1 +1 @@ -QWP_COMPRESSION_CODEC | QuestDB JavaScript Client - v4.2.0

    Variable QWP_COMPRESSION_CODECConst

    QWP_COMPRESSION_CODEC: { RAW: 0; ZSTD: 1 } = ...

    Type declaration

    • ReadonlyRAW: 0
    • ReadonlyZSTD: 1
    +QWP_COMPRESSION_CODEC | QuestDB JavaScript Client - v4.2.0

    Variable QWP_COMPRESSION_CODECConst

    QWP_COMPRESSION_CODEC: { RAW: 0; ZSTD: 1 } = ...

    Type declaration

    • ReadonlyRAW: 0
    • ReadonlyZSTD: 1
    diff --git a/docs/variables/_questdb_browser-client.QWP_DECIMAL_MAX_SCALE.html b/docs/variables/_questdb_browser-client.QWP_DECIMAL_MAX_SCALE.html index f00138c..a2d5919 100644 --- a/docs/variables/_questdb_browser-client.QWP_DECIMAL_MAX_SCALE.html +++ b/docs/variables/_questdb_browser-client.QWP_DECIMAL_MAX_SCALE.html @@ -1,2 +1,2 @@ QWP_DECIMAL_MAX_SCALE | QuestDB JavaScript Client - v4.2.0

    Variable QWP_DECIMAL_MAX_SCALEConst

    QWP_DECIMAL_MAX_SCALE: { decimal128: 38; decimal256: 76; decimal64: 18 } = ...

    Maximum DECIMAL scale of each fixed-width decimal column type.

    -

    Type declaration

    • Readonlydecimal128: 38
    • Readonlydecimal256: 76
    • Readonlydecimal64: 18
    +

    Type declaration

    • Readonlydecimal128: 38
    • Readonlydecimal256: 76
    • Readonlydecimal64: 18
    diff --git a/docs/variables/_questdb_browser-client.QWP_DEFAULT_EGRESS_BUFFER_POOL_SIZE.html b/docs/variables/_questdb_browser-client.QWP_DEFAULT_EGRESS_BUFFER_POOL_SIZE.html index a4169b5..5f78d72 100644 --- a/docs/variables/_questdb_browser-client.QWP_DEFAULT_EGRESS_BUFFER_POOL_SIZE.html +++ b/docs/variables/_questdb_browser-client.QWP_DEFAULT_EGRESS_BUFFER_POOL_SIZE.html @@ -1,2 +1,2 @@ QWP_DEFAULT_EGRESS_BUFFER_POOL_SIZE | QuestDB JavaScript Client - v4.2.0

    Variable QWP_DEFAULT_EGRESS_BUFFER_POOL_SIZEConst

    QWP_DEFAULT_EGRESS_BUFFER_POOL_SIZE: 4

    Default decoded result-buffer pool depth, matching the Java client.

    -
    +
    diff --git a/docs/variables/_questdb_browser-client.QWP_DEFAULT_EGRESS_INITIAL_CREDIT.html b/docs/variables/_questdb_browser-client.QWP_DEFAULT_EGRESS_INITIAL_CREDIT.html index ae7ed55..e332fe1 100644 --- a/docs/variables/_questdb_browser-client.QWP_DEFAULT_EGRESS_INITIAL_CREDIT.html +++ b/docs/variables/_questdb_browser-client.QWP_DEFAULT_EGRESS_INITIAL_CREDIT.html @@ -1,2 +1,2 @@ QWP_DEFAULT_EGRESS_INITIAL_CREDIT | QuestDB JavaScript Client - v4.2.0

    Variable QWP_DEFAULT_EGRESS_INITIAL_CREDITConst

    QWP_DEFAULT_EGRESS_INITIAL_CREDIT: 0

    Default send-ahead credit used by Java and TypeScript: zero is unbounded.

    -
    +
    diff --git a/docs/variables/_questdb_browser-client.QWP_DEFAULT_EGRESS_SERVER_INFO_TIMEOUT_MS.html b/docs/variables/_questdb_browser-client.QWP_DEFAULT_EGRESS_SERVER_INFO_TIMEOUT_MS.html index c7c41a9..d15a39a 100644 --- a/docs/variables/_questdb_browser-client.QWP_DEFAULT_EGRESS_SERVER_INFO_TIMEOUT_MS.html +++ b/docs/variables/_questdb_browser-client.QWP_DEFAULT_EGRESS_SERVER_INFO_TIMEOUT_MS.html @@ -1,2 +1,2 @@ QWP_DEFAULT_EGRESS_SERVER_INFO_TIMEOUT_MS | QuestDB JavaScript Client - v4.2.0

    Variable QWP_DEFAULT_EGRESS_SERVER_INFO_TIMEOUT_MSConst

    QWP_DEFAULT_EGRESS_SERVER_INFO_TIMEOUT_MS: 5000 = 5_000

    Default wait for the initial or reconnected SERVER_INFO frame.

    -
    +
    diff --git a/docs/variables/_questdb_browser-client.QWP_DURABLE_ACK_WEBSOCKET_PROTOCOL.html b/docs/variables/_questdb_browser-client.QWP_DURABLE_ACK_WEBSOCKET_PROTOCOL.html index 761f495..1e14a03 100644 --- a/docs/variables/_questdb_browser-client.QWP_DURABLE_ACK_WEBSOCKET_PROTOCOL.html +++ b/docs/variables/_questdb_browser-client.QWP_DURABLE_ACK_WEBSOCKET_PROTOCOL.html @@ -1,3 +1,3 @@ QWP_DURABLE_ACK_WEBSOCKET_PROTOCOL | QuestDB JavaScript Client - v4.2.0

    Variable QWP_DURABLE_ACK_WEBSOCKET_PROTOCOLConst

    QWP_DURABLE_ACK_WEBSOCKET_PROTOCOL: "questdb.qwp.durable-ack.v1"

    Browser-visible WebSocket subprotocol used to request and confirm durable ingress acknowledgements. Browsers cannot set or inspect X-QWP-* headers.

    -
    +
    diff --git a/docs/variables/_questdb_browser-client.QWP_EGRESS_CAPABILITY.html b/docs/variables/_questdb_browser-client.QWP_EGRESS_CAPABILITY.html index 415fe29..ce00e21 100644 --- a/docs/variables/_questdb_browser-client.QWP_EGRESS_CAPABILITY.html +++ b/docs/variables/_questdb_browser-client.QWP_EGRESS_CAPABILITY.html @@ -1 +1 @@ -QWP_EGRESS_CAPABILITY | QuestDB JavaScript Client - v4.2.0

    Variable QWP_EGRESS_CAPABILITYConst

    QWP_EGRESS_CAPABILITY: { COMPRESSION: 4; QUERY_FLAGS: 2; ZONE: 1 } = ...

    Type declaration

    • ReadonlyCOMPRESSION: 4
    • ReadonlyQUERY_FLAGS: 2
    • ReadonlyZONE: 1
    +QWP_EGRESS_CAPABILITY | QuestDB JavaScript Client - v4.2.0

    Variable QWP_EGRESS_CAPABILITYConst

    QWP_EGRESS_CAPABILITY: { COMPRESSION: 4; QUERY_FLAGS: 2; ZONE: 1 } = ...

    Type declaration

    • ReadonlyCOMPRESSION: 4
    • ReadonlyQUERY_FLAGS: 2
    • ReadonlyZONE: 1
    diff --git a/docs/variables/_questdb_browser-client.QWP_EGRESS_MESSAGE.html b/docs/variables/_questdb_browser-client.QWP_EGRESS_MESSAGE.html index 26cc165..0733fd7 100644 --- a/docs/variables/_questdb_browser-client.QWP_EGRESS_MESSAGE.html +++ b/docs/variables/_questdb_browser-client.QWP_EGRESS_MESSAGE.html @@ -1 +1 @@ -QWP_EGRESS_MESSAGE | QuestDB JavaScript Client - v4.2.0

    Variable QWP_EGRESS_MESSAGEConst

    QWP_EGRESS_MESSAGE: {
        CACHE_RESET: 23;
        CANCEL: 20;
        CREDIT: 21;
        EXEC_DONE: 22;
        QUERY_ERROR: 19;
        QUERY_REQUEST: 16;
        RESULT_BATCH: 17;
        RESULT_END: 18;
        SERVER_INFO: 24;
    } = ...

    Type declaration

    • ReadonlyCACHE_RESET: 23
    • ReadonlyCANCEL: 20
    • ReadonlyCREDIT: 21
    • ReadonlyEXEC_DONE: 22
    • ReadonlyQUERY_ERROR: 19
    • ReadonlyQUERY_REQUEST: 16
    • ReadonlyRESULT_BATCH: 17
    • ReadonlyRESULT_END: 18
    • ReadonlySERVER_INFO: 24
    +QWP_EGRESS_MESSAGE | QuestDB JavaScript Client - v4.2.0

    Variable QWP_EGRESS_MESSAGEConst

    QWP_EGRESS_MESSAGE: {
        CACHE_RESET: 23;
        CANCEL: 20;
        CREDIT: 21;
        EXEC_DONE: 22;
        QUERY_ERROR: 19;
        QUERY_REQUEST: 16;
        RESULT_BATCH: 17;
        RESULT_END: 18;
        SERVER_INFO: 24;
    } = ...

    Type declaration

    • ReadonlyCACHE_RESET: 23
    • ReadonlyCANCEL: 20
    • ReadonlyCREDIT: 21
    • ReadonlyEXEC_DONE: 22
    • ReadonlyQUERY_ERROR: 19
    • ReadonlyQUERY_REQUEST: 16
    • ReadonlyRESULT_BATCH: 17
    • ReadonlyRESULT_END: 18
    • ReadonlySERVER_INFO: 24
    diff --git a/docs/variables/_questdb_browser-client.QWP_EGRESS_PATH.html b/docs/variables/_questdb_browser-client.QWP_EGRESS_PATH.html index 0575b49..f4e6ed2 100644 --- a/docs/variables/_questdb_browser-client.QWP_EGRESS_PATH.html +++ b/docs/variables/_questdb_browser-client.QWP_EGRESS_PATH.html @@ -1 +1 @@ -QWP_EGRESS_PATH | QuestDB JavaScript Client - v4.2.0

    Variable QWP_EGRESS_PATHConst

    QWP_EGRESS_PATH: "/read/v1"
    +QWP_EGRESS_PATH | QuestDB JavaScript Client - v4.2.0

    Variable QWP_EGRESS_PATHConst

    QWP_EGRESS_PATH: "/read/v1"
    diff --git a/docs/variables/_questdb_browser-client.QWP_ENCODING_GORILLA.html b/docs/variables/_questdb_browser-client.QWP_ENCODING_GORILLA.html index fdc2689..e20446f 100644 --- a/docs/variables/_questdb_browser-client.QWP_ENCODING_GORILLA.html +++ b/docs/variables/_questdb_browser-client.QWP_ENCODING_GORILLA.html @@ -1 +1 @@ -QWP_ENCODING_GORILLA | QuestDB JavaScript Client - v4.2.0

    Variable QWP_ENCODING_GORILLAConst

    QWP_ENCODING_GORILLA: 1 = 0x01
    +QWP_ENCODING_GORILLA | QuestDB JavaScript Client - v4.2.0

    Variable QWP_ENCODING_GORILLAConst

    QWP_ENCODING_GORILLA: 1 = 0x01
    diff --git a/docs/variables/_questdb_browser-client.QWP_ENCODING_UNCOMPRESSED.html b/docs/variables/_questdb_browser-client.QWP_ENCODING_UNCOMPRESSED.html index 7ef3f21..6115be8 100644 --- a/docs/variables/_questdb_browser-client.QWP_ENCODING_UNCOMPRESSED.html +++ b/docs/variables/_questdb_browser-client.QWP_ENCODING_UNCOMPRESSED.html @@ -1 +1 @@ -QWP_ENCODING_UNCOMPRESSED | QuestDB JavaScript Client - v4.2.0

    Variable QWP_ENCODING_UNCOMPRESSEDConst

    QWP_ENCODING_UNCOMPRESSED: 0 = 0x00
    +QWP_ENCODING_UNCOMPRESSED | QuestDB JavaScript Client - v4.2.0

    Variable QWP_ENCODING_UNCOMPRESSEDConst

    QWP_ENCODING_UNCOMPRESSED: 0 = 0x00
    diff --git a/docs/variables/_questdb_browser-client.QWP_FLAG_DEFER_COMMIT.html b/docs/variables/_questdb_browser-client.QWP_FLAG_DEFER_COMMIT.html index 2836d2e..3fa6a42 100644 --- a/docs/variables/_questdb_browser-client.QWP_FLAG_DEFER_COMMIT.html +++ b/docs/variables/_questdb_browser-client.QWP_FLAG_DEFER_COMMIT.html @@ -1 +1 @@ -QWP_FLAG_DEFER_COMMIT | QuestDB JavaScript Client - v4.2.0

    Variable QWP_FLAG_DEFER_COMMITConst

    QWP_FLAG_DEFER_COMMIT: 1 = 0x01
    +QWP_FLAG_DEFER_COMMIT | QuestDB JavaScript Client - v4.2.0

    Variable QWP_FLAG_DEFER_COMMITConst

    QWP_FLAG_DEFER_COMMIT: 1 = 0x01
    diff --git a/docs/variables/_questdb_browser-client.QWP_FLAG_DELTA_SYMBOL_DICTIONARY.html b/docs/variables/_questdb_browser-client.QWP_FLAG_DELTA_SYMBOL_DICTIONARY.html index 67bebae..b95befc 100644 --- a/docs/variables/_questdb_browser-client.QWP_FLAG_DELTA_SYMBOL_DICTIONARY.html +++ b/docs/variables/_questdb_browser-client.QWP_FLAG_DELTA_SYMBOL_DICTIONARY.html @@ -1 +1 @@ -QWP_FLAG_DELTA_SYMBOL_DICTIONARY | QuestDB JavaScript Client - v4.2.0

    Variable QWP_FLAG_DELTA_SYMBOL_DICTIONARYConst

    QWP_FLAG_DELTA_SYMBOL_DICTIONARY: 8 = 0x08
    +QWP_FLAG_DELTA_SYMBOL_DICTIONARY | QuestDB JavaScript Client - v4.2.0

    Variable QWP_FLAG_DELTA_SYMBOL_DICTIONARYConst

    QWP_FLAG_DELTA_SYMBOL_DICTIONARY: 8 = 0x08
    diff --git a/docs/variables/_questdb_browser-client.QWP_FLAG_DURABLE_ACK_POLL.html b/docs/variables/_questdb_browser-client.QWP_FLAG_DURABLE_ACK_POLL.html index 27edeb7..d445af3 100644 --- a/docs/variables/_questdb_browser-client.QWP_FLAG_DURABLE_ACK_POLL.html +++ b/docs/variables/_questdb_browser-client.QWP_FLAG_DURABLE_ACK_POLL.html @@ -1,2 +1,2 @@ QWP_FLAG_DURABLE_ACK_POLL | QuestDB JavaScript Client - v4.2.0

    Variable QWP_FLAG_DURABLE_ACK_POLLConst

    QWP_FLAG_DURABLE_ACK_POLL: 2 = 0x02

    Table-less ingress control frame that polls negotiated durable-ACK progress.

    -
    +
    diff --git a/docs/variables/_questdb_browser-client.QWP_FLAG_GORILLA.html b/docs/variables/_questdb_browser-client.QWP_FLAG_GORILLA.html index 51c4aa5..c1344e1 100644 --- a/docs/variables/_questdb_browser-client.QWP_FLAG_GORILLA.html +++ b/docs/variables/_questdb_browser-client.QWP_FLAG_GORILLA.html @@ -1 +1 @@ -QWP_FLAG_GORILLA | QuestDB JavaScript Client - v4.2.0

    Variable QWP_FLAG_GORILLAConst

    QWP_FLAG_GORILLA: 4 = 0x04
    +QWP_FLAG_GORILLA | QuestDB JavaScript Client - v4.2.0

    Variable QWP_FLAG_GORILLAConst

    QWP_FLAG_GORILLA: 4 = 0x04
    diff --git a/docs/variables/_questdb_browser-client.QWP_FLAG_ZSTD.html b/docs/variables/_questdb_browser-client.QWP_FLAG_ZSTD.html index f93040c..2915724 100644 --- a/docs/variables/_questdb_browser-client.QWP_FLAG_ZSTD.html +++ b/docs/variables/_questdb_browser-client.QWP_FLAG_ZSTD.html @@ -1 +1 @@ -QWP_FLAG_ZSTD | QuestDB JavaScript Client - v4.2.0

    Variable QWP_FLAG_ZSTDConst

    QWP_FLAG_ZSTD: 16 = 0x10
    +QWP_FLAG_ZSTD | QuestDB JavaScript Client - v4.2.0

    Variable QWP_FLAG_ZSTDConst

    QWP_FLAG_ZSTD: 16 = 0x10
    diff --git a/docs/variables/_questdb_browser-client.QWP_HEADER_SIZE.html b/docs/variables/_questdb_browser-client.QWP_HEADER_SIZE.html index 2179ab6..c8ea646 100644 --- a/docs/variables/_questdb_browser-client.QWP_HEADER_SIZE.html +++ b/docs/variables/_questdb_browser-client.QWP_HEADER_SIZE.html @@ -1 +1 @@ -QWP_HEADER_SIZE | QuestDB JavaScript Client - v4.2.0

    Variable QWP_HEADER_SIZEConst

    QWP_HEADER_SIZE: 12
    +QWP_HEADER_SIZE | QuestDB JavaScript Client - v4.2.0

    Variable QWP_HEADER_SIZEConst

    QWP_HEADER_SIZE: 12
    diff --git a/docs/variables/_questdb_browser-client.QWP_INGRESS_PATH.html b/docs/variables/_questdb_browser-client.QWP_INGRESS_PATH.html index fd42a3a..01c685e 100644 --- a/docs/variables/_questdb_browser-client.QWP_INGRESS_PATH.html +++ b/docs/variables/_questdb_browser-client.QWP_INGRESS_PATH.html @@ -1 +1 @@ -QWP_INGRESS_PATH | QuestDB JavaScript Client - v4.2.0

    Variable QWP_INGRESS_PATHConst

    QWP_INGRESS_PATH: "/write/v4"
    +QWP_INGRESS_PATH | QuestDB JavaScript Client - v4.2.0

    Variable QWP_INGRESS_PATHConst

    QWP_INGRESS_PATH: "/write/v4"
    diff --git a/docs/variables/_questdb_browser-client.QWP_INGRESS_PROGRESS_KIND.html b/docs/variables/_questdb_browser-client.QWP_INGRESS_PROGRESS_KIND.html index f556099..ee775bd 100644 --- a/docs/variables/_questdb_browser-client.QWP_INGRESS_PROGRESS_KIND.html +++ b/docs/variables/_questdb_browser-client.QWP_INGRESS_PROGRESS_KIND.html @@ -1 +1 @@ -QWP_INGRESS_PROGRESS_KIND | QuestDB JavaScript Client - v4.2.0

    Variable QWP_INGRESS_PROGRESS_KINDConst

    QWP_INGRESS_PROGRESS_KIND: {
        ACKNOWLEDGED: "acknowledged";
        DURABLE_ACKNOWLEDGED: "durable-acknowledged";
        PUBLISHED: "published";
    } = ...

    Type declaration

    • ReadonlyACKNOWLEDGED: "acknowledged"
    • ReadonlyDURABLE_ACKNOWLEDGED: "durable-acknowledged"
    • ReadonlyPUBLISHED: "published"
    +QWP_INGRESS_PROGRESS_KIND | QuestDB JavaScript Client - v4.2.0

    Variable QWP_INGRESS_PROGRESS_KINDConst

    QWP_INGRESS_PROGRESS_KIND: {
        ACKNOWLEDGED: "acknowledged";
        DURABLE_ACKNOWLEDGED: "durable-acknowledged";
        PUBLISHED: "published";
    } = ...

    Type declaration

    • ReadonlyACKNOWLEDGED: "acknowledged"
    • ReadonlyDURABLE_ACKNOWLEDGED: "durable-acknowledged"
    • ReadonlyPUBLISHED: "published"
    diff --git a/docs/variables/_questdb_browser-client.QWP_INITIAL_CONNECT_MODE.html b/docs/variables/_questdb_browser-client.QWP_INITIAL_CONNECT_MODE.html index c3bab54..6e7509b 100644 --- a/docs/variables/_questdb_browser-client.QWP_INITIAL_CONNECT_MODE.html +++ b/docs/variables/_questdb_browser-client.QWP_INITIAL_CONNECT_MODE.html @@ -4,4 +4,4 @@

    Type declaration

    • ReadonlyASYNC: "async"

      Return immediately and connect on the background replay loop.

    • ReadonlyOFF: "off"

      Try once on the caller and fail immediately.

    • ReadonlySYNC: "sync"

      Retry on the caller within the configured reconnect budget.

      -
    +
    diff --git a/docs/variables/_questdb_browser-client.QWP_MAGIC.html b/docs/variables/_questdb_browser-client.QWP_MAGIC.html index 6d9e27b..5553e88 100644 --- a/docs/variables/_questdb_browser-client.QWP_MAGIC.html +++ b/docs/variables/_questdb_browser-client.QWP_MAGIC.html @@ -1,2 +1,2 @@ QWP_MAGIC | QuestDB JavaScript Client - v4.2.0

    Variable QWP_MAGICConst

    QWP_MAGIC: 827348817 = 0x31505751

    ASCII QWP1, represented as its little-endian uint32 value.

    -
    +
    diff --git a/docs/variables/_questdb_browser-client.QWP_MAX_ARRAY_DIMENSIONS.html b/docs/variables/_questdb_browser-client.QWP_MAX_ARRAY_DIMENSIONS.html index 2987a81..abf2051 100644 --- a/docs/variables/_questdb_browser-client.QWP_MAX_ARRAY_DIMENSIONS.html +++ b/docs/variables/_questdb_browser-client.QWP_MAX_ARRAY_DIMENSIONS.html @@ -1,2 +1,2 @@ QWP_MAX_ARRAY_DIMENSIONS | QuestDB JavaScript Client - v4.2.0

    Variable QWP_MAX_ARRAY_DIMENSIONSConst

    QWP_MAX_ARRAY_DIMENSIONS: 32

    Maximum array rank accepted by QuestDB's QWP ingress decoder.

    -
    +
    diff --git a/docs/variables/_questdb_browser-client.QWP_MAX_ARRAY_DIMENSION_LENGTH.html b/docs/variables/_questdb_browser-client.QWP_MAX_ARRAY_DIMENSION_LENGTH.html index 1ddc669..bb6b9df 100644 --- a/docs/variables/_questdb_browser-client.QWP_MAX_ARRAY_DIMENSION_LENGTH.html +++ b/docs/variables/_questdb_browser-client.QWP_MAX_ARRAY_DIMENSION_LENGTH.html @@ -1,2 +1,2 @@ QWP_MAX_ARRAY_DIMENSION_LENGTH | QuestDB JavaScript Client - v4.2.0

    Variable QWP_MAX_ARRAY_DIMENSION_LENGTHConst

    QWP_MAX_ARRAY_DIMENSION_LENGTH: 2147483647 = 2_147_483_647

    Maximum signed int32 array-axis length accepted by QWP ingress.

    -
    +
    diff --git a/docs/variables/_questdb_browser-client.QWP_MAX_BATCH_ROWS_UPPER_BOUND.html b/docs/variables/_questdb_browser-client.QWP_MAX_BATCH_ROWS_UPPER_BOUND.html index 7937161..3c6e98e 100644 --- a/docs/variables/_questdb_browser-client.QWP_MAX_BATCH_ROWS_UPPER_BOUND.html +++ b/docs/variables/_questdb_browser-client.QWP_MAX_BATCH_ROWS_UPPER_BOUND.html @@ -1,2 +1,2 @@ QWP_MAX_BATCH_ROWS_UPPER_BOUND | QuestDB JavaScript Client - v4.2.0

    Variable QWP_MAX_BATCH_ROWS_UPPER_BOUNDConst

    QWP_MAX_BATCH_ROWS_UPPER_BOUND: 1048576 = 1_048_576

    Largest client-requested egress RESULT_BATCH row cap.

    -
    +
    diff --git a/docs/variables/_questdb_browser-client.QWP_MAX_CELLS_PER_BATCH.html b/docs/variables/_questdb_browser-client.QWP_MAX_CELLS_PER_BATCH.html index 75450b2..85b9dd0 100644 --- a/docs/variables/_questdb_browser-client.QWP_MAX_CELLS_PER_BATCH.html +++ b/docs/variables/_questdb_browser-client.QWP_MAX_CELLS_PER_BATCH.html @@ -10,4 +10,4 @@

    32Mi cells is roughly 512 MB decoded. That is far above any plausible result -- the widest supported table at 16k rows, or a full 1,048,576-row batch at 32 columns -- and far below what the caps alone would permit.

    -
    +
    diff --git a/docs/variables/_questdb_browser-client.QWP_MAX_COLUMNS_PER_TABLE.html b/docs/variables/_questdb_browser-client.QWP_MAX_COLUMNS_PER_TABLE.html index 2b93c41..67bca41 100644 --- a/docs/variables/_questdb_browser-client.QWP_MAX_COLUMNS_PER_TABLE.html +++ b/docs/variables/_questdb_browser-client.QWP_MAX_COLUMNS_PER_TABLE.html @@ -1 +1 @@ -QWP_MAX_COLUMNS_PER_TABLE | QuestDB JavaScript Client - v4.2.0

    Variable QWP_MAX_COLUMNS_PER_TABLEConst

    QWP_MAX_COLUMNS_PER_TABLE: 2048
    +QWP_MAX_COLUMNS_PER_TABLE | QuestDB JavaScript Client - v4.2.0

    Variable QWP_MAX_COLUMNS_PER_TABLEConst

    QWP_MAX_COLUMNS_PER_TABLE: 2048
    diff --git a/docs/variables/_questdb_browser-client.QWP_MAX_COLUMN_NAME_LENGTH.html b/docs/variables/_questdb_browser-client.QWP_MAX_COLUMN_NAME_LENGTH.html index 98d1d51..d76ea13 100644 --- a/docs/variables/_questdb_browser-client.QWP_MAX_COLUMN_NAME_LENGTH.html +++ b/docs/variables/_questdb_browser-client.QWP_MAX_COLUMN_NAME_LENGTH.html @@ -1,2 +1,2 @@ QWP_MAX_COLUMN_NAME_LENGTH | QuestDB JavaScript Client - v4.2.0

    Variable QWP_MAX_COLUMN_NAME_LENGTHConst

    QWP_MAX_COLUMN_NAME_LENGTH: 127

    Default QWP ingress identifier limits, in UTF-8 wire bytes.

    -
    +
    diff --git a/docs/variables/_questdb_browser-client.QWP_MAX_ERROR_MESSAGE_LENGTH.html b/docs/variables/_questdb_browser-client.QWP_MAX_ERROR_MESSAGE_LENGTH.html deleted file mode 100644 index dd8c9d2..0000000 --- a/docs/variables/_questdb_browser-client.QWP_MAX_ERROR_MESSAGE_LENGTH.html +++ /dev/null @@ -1 +0,0 @@ -QWP_MAX_ERROR_MESSAGE_LENGTH | QuestDB JavaScript Client - v4.2.0

    Variable QWP_MAX_ERROR_MESSAGE_LENGTHConst

    QWP_MAX_ERROR_MESSAGE_LENGTH: 1024
    diff --git a/docs/variables/_questdb_browser-client.QWP_MAX_IDENTIFIER_BYTES.html b/docs/variables/_questdb_browser-client.QWP_MAX_IDENTIFIER_BYTES.html index 36ccdba..4949f12 100644 --- a/docs/variables/_questdb_browser-client.QWP_MAX_IDENTIFIER_BYTES.html +++ b/docs/variables/_questdb_browser-client.QWP_MAX_IDENTIFIER_BYTES.html @@ -3,4 +3,4 @@ 127-UTF-16-code-unit metadata limit. One code unit takes at most three UTF-8 bytes, so query decoding accepts that larger representation even though QWP ingress enforces its 127-byte protocol limit.

    -
    +
    diff --git a/docs/variables/_questdb_browser-client.QWP_MAX_ROWS_PER_TABLE.html b/docs/variables/_questdb_browser-client.QWP_MAX_ROWS_PER_TABLE.html index 674ecab..386ceca 100644 --- a/docs/variables/_questdb_browser-client.QWP_MAX_ROWS_PER_TABLE.html +++ b/docs/variables/_questdb_browser-client.QWP_MAX_ROWS_PER_TABLE.html @@ -1 +1 @@ -QWP_MAX_ROWS_PER_TABLE | QuestDB JavaScript Client - v4.2.0

    Variable QWP_MAX_ROWS_PER_TABLEConst

    QWP_MAX_ROWS_PER_TABLE: 1000000 = 1_000_000
    +QWP_MAX_ROWS_PER_TABLE | QuestDB JavaScript Client - v4.2.0

    Variable QWP_MAX_ROWS_PER_TABLEConst

    QWP_MAX_ROWS_PER_TABLE: 1000000 = 1_000_000
    diff --git a/docs/variables/_questdb_browser-client.QWP_MAX_SYMBOL_DICTIONARY_SIZE.html b/docs/variables/_questdb_browser-client.QWP_MAX_SYMBOL_DICTIONARY_SIZE.html index 3386ba5..f6359d7 100644 --- a/docs/variables/_questdb_browser-client.QWP_MAX_SYMBOL_DICTIONARY_SIZE.html +++ b/docs/variables/_questdb_browser-client.QWP_MAX_SYMBOL_DICTIONARY_SIZE.html @@ -1 +1 @@ -QWP_MAX_SYMBOL_DICTIONARY_SIZE | QuestDB JavaScript Client - v4.2.0

    Variable QWP_MAX_SYMBOL_DICTIONARY_SIZEConst

    QWP_MAX_SYMBOL_DICTIONARY_SIZE: 1000000 = 1_000_000
    +QWP_MAX_SYMBOL_DICTIONARY_SIZE | QuestDB JavaScript Client - v4.2.0

    Variable QWP_MAX_SYMBOL_DICTIONARY_SIZEConst

    QWP_MAX_SYMBOL_DICTIONARY_SIZE: 1000000 = 1_000_000
    diff --git a/docs/variables/_questdb_browser-client.QWP_MAX_TABLE_NAME_LENGTH.html b/docs/variables/_questdb_browser-client.QWP_MAX_TABLE_NAME_LENGTH.html index f57ced0..756c530 100644 --- a/docs/variables/_questdb_browser-client.QWP_MAX_TABLE_NAME_LENGTH.html +++ b/docs/variables/_questdb_browser-client.QWP_MAX_TABLE_NAME_LENGTH.html @@ -1 +1 @@ -QWP_MAX_TABLE_NAME_LENGTH | QuestDB JavaScript Client - v4.2.0

    Variable QWP_MAX_TABLE_NAME_LENGTHConst

    QWP_MAX_TABLE_NAME_LENGTH: 127
    +QWP_MAX_TABLE_NAME_LENGTH | QuestDB JavaScript Client - v4.2.0

    Variable QWP_MAX_TABLE_NAME_LENGTHConst

    QWP_MAX_TABLE_NAME_LENGTH: 127
    diff --git a/docs/variables/_questdb_browser-client.QWP_MAX_ZSTD_DECOMPRESSED_SIZE.html b/docs/variables/_questdb_browser-client.QWP_MAX_ZSTD_DECOMPRESSED_SIZE.html index 1e5daa5..0b3026e 100644 --- a/docs/variables/_questdb_browser-client.QWP_MAX_ZSTD_DECOMPRESSED_SIZE.html +++ b/docs/variables/_questdb_browser-client.QWP_MAX_ZSTD_DECOMPRESSED_SIZE.html @@ -1,2 +1,2 @@ QWP_MAX_ZSTD_DECOMPRESSED_SIZE | QuestDB JavaScript Client - v4.2.0

    Variable QWP_MAX_ZSTD_DECOMPRESSED_SIZEConst

    QWP_MAX_ZSTD_DECOMPRESSED_SIZE: number = ...

    Matches the Java client's per-connection decompression safety cap.

    -
    +
    diff --git a/docs/variables/_questdb_browser-client.QWP_QUERY_FLAG_RESET_DICTIONARY.html b/docs/variables/_questdb_browser-client.QWP_QUERY_FLAG_RESET_DICTIONARY.html index 3aa1500..9606f26 100644 --- a/docs/variables/_questdb_browser-client.QWP_QUERY_FLAG_RESET_DICTIONARY.html +++ b/docs/variables/_questdb_browser-client.QWP_QUERY_FLAG_RESET_DICTIONARY.html @@ -1 +1 @@ -QWP_QUERY_FLAG_RESET_DICTIONARY | QuestDB JavaScript Client - v4.2.0

    Variable QWP_QUERY_FLAG_RESET_DICTIONARYConst

    QWP_QUERY_FLAG_RESET_DICTIONARY: 1 = 0x01
    +QWP_QUERY_FLAG_RESET_DICTIONARY | QuestDB JavaScript Client - v4.2.0

    Variable QWP_QUERY_FLAG_RESET_DICTIONARYConst

    QWP_QUERY_FLAG_RESET_DICTIONARY: 1 = 0x01
    diff --git a/docs/variables/_questdb_browser-client.QWP_RECONNECT_EVENT_KIND.html b/docs/variables/_questdb_browser-client.QWP_RECONNECT_EVENT_KIND.html index 105883c..1a8baf6 100644 --- a/docs/variables/_questdb_browser-client.QWP_RECONNECT_EVENT_KIND.html +++ b/docs/variables/_questdb_browser-client.QWP_RECONNECT_EVENT_KIND.html @@ -1,4 +1,4 @@ QWP_RECONNECT_EVENT_KIND | QuestDB JavaScript Client - v4.2.0

    Variable QWP_RECONNECT_EVENT_KINDConst

    QWP_RECONNECT_EVENT_KIND: {
        ATTEMPT_FAILED: "attempt-failed";
        CONNECTED: "connected";
        DURABLE_ACK_PERSISTENT_FAILURE: "durable-ack-persistent-failure";
        DURABLE_ACK_UNAVAILABLE: "durable-ack-unavailable";
        FAILED_OVER: "failed-over";
        PRIMARY_UNAVAILABLE: "primary-unavailable";
        RECONNECTED: "reconnected";
        RECONNECTING: "reconnecting";
    } = ...

    Type declaration

    • ReadonlyATTEMPT_FAILED: "attempt-failed"
    • ReadonlyCONNECTED: "connected"
    • ReadonlyDURABLE_ACK_PERSISTENT_FAILURE: "durable-ack-persistent-failure"

      An orphan exhausted its consecutive durable-ACK mismatch budget.

    • ReadonlyDURABLE_ACK_UNAVAILABLE: "durable-ack-unavailable"

      An unbounded SF loop is waiting for durable-ACK-capable endpoints.

    • ReadonlyFAILED_OVER: "failed-over"
    • ReadonlyPRIMARY_UNAVAILABLE: "primary-unavailable"

      Every reachable ingress endpoint is temporarily unable to be primary.

      -
    • ReadonlyRECONNECTED: "reconnected"
    • ReadonlyRECONNECTING: "reconnecting"
    +
  • ReadonlyRECONNECTED: "reconnected"
  • ReadonlyRECONNECTING: "reconnecting"
  • diff --git a/docs/variables/_questdb_browser-client.QWP_RESET_MASK_DICTIONARY.html b/docs/variables/_questdb_browser-client.QWP_RESET_MASK_DICTIONARY.html index ff948d7..06516ea 100644 --- a/docs/variables/_questdb_browser-client.QWP_RESET_MASK_DICTIONARY.html +++ b/docs/variables/_questdb_browser-client.QWP_RESET_MASK_DICTIONARY.html @@ -1 +1 @@ -QWP_RESET_MASK_DICTIONARY | QuestDB JavaScript Client - v4.2.0

    Variable QWP_RESET_MASK_DICTIONARYConst

    QWP_RESET_MASK_DICTIONARY: 1 = 0x01
    +QWP_RESET_MASK_DICTIONARY | QuestDB JavaScript Client - v4.2.0

    Variable QWP_RESET_MASK_DICTIONARYConst

    QWP_RESET_MASK_DICTIONARY: 1 = 0x01
    diff --git a/docs/variables/_questdb_browser-client.QWP_SENDER_ERROR_CATEGORY.html b/docs/variables/_questdb_browser-client.QWP_SENDER_ERROR_CATEGORY.html index de4a2e1..a08fec2 100644 --- a/docs/variables/_questdb_browser-client.QWP_SENDER_ERROR_CATEGORY.html +++ b/docs/variables/_questdb_browser-client.QWP_SENDER_ERROR_CATEGORY.html @@ -1 +1 @@ -QWP_SENDER_ERROR_CATEGORY | QuestDB JavaScript Client - v4.2.0

    Variable QWP_SENDER_ERROR_CATEGORYConst

    QWP_SENDER_ERROR_CATEGORY: {
        DATA_LOSS: "data-loss";
        DICTIONARY_GAP: "dictionary-gap";
        INTERNAL_ERROR: "internal-error";
        NOT_WRITABLE: "not-writable";
        PARSE_ERROR: "parse-error";
        PROTOCOL_VIOLATION: "protocol-violation";
        SCHEMA_MISMATCH: "schema-mismatch";
        SECURITY_ERROR: "security-error";
        UNKNOWN: "unknown";
        WRITE_ERROR: "write-error";
    } = ...

    Type declaration

    • ReadonlyDATA_LOSS: "data-loss"
    • ReadonlyDICTIONARY_GAP: "dictionary-gap"
    • ReadonlyINTERNAL_ERROR: "internal-error"
    • ReadonlyNOT_WRITABLE: "not-writable"
    • ReadonlyPARSE_ERROR: "parse-error"
    • ReadonlyPROTOCOL_VIOLATION: "protocol-violation"
    • ReadonlySCHEMA_MISMATCH: "schema-mismatch"
    • ReadonlySECURITY_ERROR: "security-error"
    • ReadonlyUNKNOWN: "unknown"
    • ReadonlyWRITE_ERROR: "write-error"
    +QWP_SENDER_ERROR_CATEGORY | QuestDB JavaScript Client - v4.2.0

    Variable QWP_SENDER_ERROR_CATEGORYConst

    QWP_SENDER_ERROR_CATEGORY: {
        CANCELLED: "cancelled";
        DATA_LOSS: "data-loss";
        DICTIONARY_GAP: "dictionary-gap";
        INTERNAL_ERROR: "internal-error";
        LIMIT_EXCEEDED: "limit-exceeded";
        NOT_WRITABLE: "not-writable";
        PARSE_ERROR: "parse-error";
        PROTOCOL_VIOLATION: "protocol-violation";
        SCHEMA_MISMATCH: "schema-mismatch";
        SECURITY_ERROR: "security-error";
        UNKNOWN: "unknown";
        WRITE_ERROR: "write-error";
    } = ...

    Type declaration

    • ReadonlyCANCELLED: "cancelled"
    • ReadonlyDATA_LOSS: "data-loss"
    • ReadonlyDICTIONARY_GAP: "dictionary-gap"
    • ReadonlyINTERNAL_ERROR: "internal-error"
    • ReadonlyLIMIT_EXCEEDED: "limit-exceeded"
    • ReadonlyNOT_WRITABLE: "not-writable"
    • ReadonlyPARSE_ERROR: "parse-error"
    • ReadonlyPROTOCOL_VIOLATION: "protocol-violation"
    • ReadonlySCHEMA_MISMATCH: "schema-mismatch"
    • ReadonlySECURITY_ERROR: "security-error"
    • ReadonlyUNKNOWN: "unknown"
    • ReadonlyWRITE_ERROR: "write-error"
    diff --git a/docs/variables/_questdb_browser-client.QWP_SENDER_ERROR_POLICY.html b/docs/variables/_questdb_browser-client.QWP_SENDER_ERROR_POLICY.html index 2e7aa88..7ab685e 100644 --- a/docs/variables/_questdb_browser-client.QWP_SENDER_ERROR_POLICY.html +++ b/docs/variables/_questdb_browser-client.QWP_SENDER_ERROR_POLICY.html @@ -1 +1 @@ -QWP_SENDER_ERROR_POLICY | QuestDB JavaScript Client - v4.2.0

    Variable QWP_SENDER_ERROR_POLICYConst

    QWP_SENDER_ERROR_POLICY: {
        ABANDONED: "abandoned";
        RETRIABLE: "retriable";
        RETRIABLE_OTHER: "retriable-other";
        TERMINAL: "terminal";
    } = ...

    Type declaration

    • ReadonlyABANDONED: "abandoned"
    • ReadonlyRETRIABLE: "retriable"
    • ReadonlyRETRIABLE_OTHER: "retriable-other"
    • ReadonlyTERMINAL: "terminal"
    +QWP_SENDER_ERROR_POLICY | QuestDB JavaScript Client - v4.2.0

    Variable QWP_SENDER_ERROR_POLICYConst

    QWP_SENDER_ERROR_POLICY: {
        ABANDONED: "abandoned";
        RETRIABLE: "retriable";
        RETRIABLE_OTHER: "retriable-other";
        TERMINAL: "terminal";
    } = ...

    Type declaration

    • ReadonlyABANDONED: "abandoned"
    • ReadonlyRETRIABLE: "retriable"
    • ReadonlyRETRIABLE_OTHER: "retriable-other"
    • ReadonlyTERMINAL: "terminal"
    diff --git a/docs/variables/_questdb_browser-client.QWP_SERVER_ROLE.html b/docs/variables/_questdb_browser-client.QWP_SERVER_ROLE.html index 46c21bf..32cdf70 100644 --- a/docs/variables/_questdb_browser-client.QWP_SERVER_ROLE.html +++ b/docs/variables/_questdb_browser-client.QWP_SERVER_ROLE.html @@ -1 +1 @@ -QWP_SERVER_ROLE | QuestDB JavaScript Client - v4.2.0

    Variable QWP_SERVER_ROLEConst

    QWP_SERVER_ROLE: { PRIMARY: 1; PRIMARY_CATCHUP: 3; REPLICA: 2; STANDALONE: 0 } = ...

    Type declaration

    • ReadonlyPRIMARY: 1
    • ReadonlyPRIMARY_CATCHUP: 3
    • ReadonlyREPLICA: 2
    • ReadonlySTANDALONE: 0
    +QWP_SERVER_ROLE | QuestDB JavaScript Client - v4.2.0

    Variable QWP_SERVER_ROLEConst

    QWP_SERVER_ROLE: { PRIMARY: 1; PRIMARY_CATCHUP: 3; REPLICA: 2; STANDALONE: 0 } = ...

    Type declaration

    • ReadonlyPRIMARY: 1
    • ReadonlyPRIMARY_CATCHUP: 3
    • ReadonlyREPLICA: 2
    • ReadonlySTANDALONE: 0
    diff --git a/docs/variables/_questdb_browser-client.QWP_STATUS.html b/docs/variables/_questdb_browser-client.QWP_STATUS.html index e590ff7..8afd5fb 100644 --- a/docs/variables/_questdb_browser-client.QWP_STATUS.html +++ b/docs/variables/_questdb_browser-client.QWP_STATUS.html @@ -1 +1 @@ -QWP_STATUS | QuestDB JavaScript Client - v4.2.0

    Variable QWP_STATUSConst

    QWP_STATUS: {
        CANCELLED: 10;
        DICTIONARY_GAP: 13;
        DURABLE_ACK: 2;
        INTERNAL_ERROR: 6;
        LIMIT_EXCEEDED: 11;
        NOT_WRITABLE: 12;
        OK: 0;
        PARSE_ERROR: 5;
        SCHEMA_MISMATCH: 3;
        SECURITY_ERROR: 8;
        SERVER_INFO: 1;
        WRITE_ERROR: 9;
    } = ...

    Type declaration

    • ReadonlyCANCELLED: 10
    • ReadonlyDICTIONARY_GAP: 13
    • ReadonlyDURABLE_ACK: 2
    • ReadonlyINTERNAL_ERROR: 6
    • ReadonlyLIMIT_EXCEEDED: 11
    • ReadonlyNOT_WRITABLE: 12
    • ReadonlyOK: 0
    • ReadonlyPARSE_ERROR: 5
    • ReadonlySCHEMA_MISMATCH: 3
    • ReadonlySECURITY_ERROR: 8
    • ReadonlySERVER_INFO: 1
    • ReadonlyWRITE_ERROR: 9
    +QWP_STATUS | QuestDB JavaScript Client - v4.2.0

    Variable QWP_STATUSConst

    QWP_STATUS: {
        CANCELLED: 10;
        DICTIONARY_GAP: 13;
        DURABLE_ACK: 2;
        INTERNAL_ERROR: 6;
        LIMIT_EXCEEDED: 11;
        NOT_WRITABLE: 12;
        OK: 0;
        PARSE_ERROR: 5;
        SCHEMA_MISMATCH: 3;
        SECURITY_ERROR: 8;
        SERVER_INFO: 1;
        WRITE_ERROR: 9;
    } = ...

    Type declaration

    • ReadonlyCANCELLED: 10
    • ReadonlyDICTIONARY_GAP: 13
    • ReadonlyDURABLE_ACK: 2
    • ReadonlyINTERNAL_ERROR: 6
    • ReadonlyLIMIT_EXCEEDED: 11
    • ReadonlyNOT_WRITABLE: 12
    • ReadonlyOK: 0
    • ReadonlyPARSE_ERROR: 5
    • ReadonlySCHEMA_MISMATCH: 3
    • ReadonlySECURITY_ERROR: 8
    • ReadonlySERVER_INFO: 1
    • ReadonlyWRITE_ERROR: 9
    diff --git a/docs/variables/_questdb_browser-client.QWP_TARGET.html b/docs/variables/_questdb_browser-client.QWP_TARGET.html index 49bbef9..8d3e0d2 100644 --- a/docs/variables/_questdb_browser-client.QWP_TARGET.html +++ b/docs/variables/_questdb_browser-client.QWP_TARGET.html @@ -1 +1 @@ -QWP_TARGET | QuestDB JavaScript Client - v4.2.0

    Variable QWP_TARGETConst

    QWP_TARGET: { ANY: "any"; PRIMARY: "primary"; REPLICA: "replica" } = ...

    Type declaration

    • ReadonlyANY: "any"
    • ReadonlyPRIMARY: "primary"
    • ReadonlyREPLICA: "replica"
    +QWP_TARGET | QuestDB JavaScript Client - v4.2.0

    Variable QWP_TARGETConst

    QWP_TARGET: { ANY: "any"; PRIMARY: "primary"; REPLICA: "replica" } = ...

    Type declaration

    • ReadonlyANY: "any"
    • ReadonlyPRIMARY: "primary"
    • ReadonlyREPLICA: "replica"
    diff --git a/docs/variables/_questdb_browser-client.QWP_UPGRADE_ERROR_KIND.html b/docs/variables/_questdb_browser-client.QWP_UPGRADE_ERROR_KIND.html index ce04a48..f3964d9 100644 --- a/docs/variables/_questdb_browser-client.QWP_UPGRADE_ERROR_KIND.html +++ b/docs/variables/_questdb_browser-client.QWP_UPGRADE_ERROR_KIND.html @@ -1,2 +1,2 @@ QWP_UPGRADE_ERROR_KIND | QuestDB JavaScript Client - v4.2.0

    Variable QWP_UPGRADE_ERROR_KINDConst

    QWP_UPGRADE_ERROR_KIND: {
        AUTHENTICATION: "authentication";
        CAPABILITY_MISMATCH: "capability-mismatch";
        HTTP_REJECTED: "http-rejected";
        OPAQUE: "opaque";
        ROLE_REJECTED: "role-rejected";
        TIMEOUT: "timeout";
        TRANSPORT: "transport";
        VERSION_MISMATCH: "version-mismatch";
    } = ...

    Type declaration

    • ReadonlyAUTHENTICATION: "authentication"
    • ReadonlyCAPABILITY_MISMATCH: "capability-mismatch"
    • ReadonlyHTTP_REJECTED: "http-rejected"
    • ReadonlyOPAQUE: "opaque"

      Browser WebSocket APIs do not expose the rejected HTTP upgrade.

      -
    • ReadonlyROLE_REJECTED: "role-rejected"
    • ReadonlyTIMEOUT: "timeout"
    • ReadonlyTRANSPORT: "transport"
    • ReadonlyVERSION_MISMATCH: "version-mismatch"
    +
  • ReadonlyROLE_REJECTED: "role-rejected"
  • ReadonlyTIMEOUT: "timeout"
  • ReadonlyTRANSPORT: "transport"
  • ReadonlyVERSION_MISMATCH: "version-mismatch"
  • diff --git a/docs/variables/_questdb_browser-client.QWP_UPGRADE_TIMEOUT_PHASE.html b/docs/variables/_questdb_browser-client.QWP_UPGRADE_TIMEOUT_PHASE.html index eacaa72..9f3bc02 100644 --- a/docs/variables/_questdb_browser-client.QWP_UPGRADE_TIMEOUT_PHASE.html +++ b/docs/variables/_questdb_browser-client.QWP_UPGRADE_TIMEOUT_PHASE.html @@ -1 +1 @@ -QWP_UPGRADE_TIMEOUT_PHASE | QuestDB JavaScript Client - v4.2.0

    Variable QWP_UPGRADE_TIMEOUT_PHASEConst

    QWP_UPGRADE_TIMEOUT_PHASE: {
        AUTHENTICATION: "authentication";
        CONNECT: "connect";
    } = ...

    Type declaration

    • ReadonlyAUTHENTICATION: "authentication"
    • ReadonlyCONNECT: "connect"
    +QWP_UPGRADE_TIMEOUT_PHASE | QuestDB JavaScript Client - v4.2.0

    Variable QWP_UPGRADE_TIMEOUT_PHASEConst

    QWP_UPGRADE_TIMEOUT_PHASE: {
        AUTHENTICATION: "authentication";
        CONNECT: "connect";
    } = ...

    Type declaration

    • ReadonlyAUTHENTICATION: "authentication"
    • ReadonlyCONNECT: "connect"
    diff --git a/docs/variables/_questdb_browser-client.QWP_VERSION.html b/docs/variables/_questdb_browser-client.QWP_VERSION.html index d50e948..df346d7 100644 --- a/docs/variables/_questdb_browser-client.QWP_VERSION.html +++ b/docs/variables/_questdb_browser-client.QWP_VERSION.html @@ -1 +1 @@ -QWP_VERSION | QuestDB JavaScript Client - v4.2.0
    +QWP_VERSION | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/variables/_questdb_browser-client.QWP_ZSTD_MAX_COMPRESSION_LEVEL.html b/docs/variables/_questdb_browser-client.QWP_ZSTD_MAX_COMPRESSION_LEVEL.html index 51a4e97..681f895 100644 --- a/docs/variables/_questdb_browser-client.QWP_ZSTD_MAX_COMPRESSION_LEVEL.html +++ b/docs/variables/_questdb_browser-client.QWP_ZSTD_MAX_COMPRESSION_LEVEL.html @@ -1 +1 @@ -QWP_ZSTD_MAX_COMPRESSION_LEVEL | QuestDB JavaScript Client - v4.2.0

    Variable QWP_ZSTD_MAX_COMPRESSION_LEVELConst

    QWP_ZSTD_MAX_COMPRESSION_LEVEL: 22
    +QWP_ZSTD_MAX_COMPRESSION_LEVEL | QuestDB JavaScript Client - v4.2.0

    Variable QWP_ZSTD_MAX_COMPRESSION_LEVELConst

    QWP_ZSTD_MAX_COMPRESSION_LEVEL: 22
    diff --git a/docs/variables/_questdb_browser-client.QWP_ZSTD_MIN_COMPRESSION_LEVEL.html b/docs/variables/_questdb_browser-client.QWP_ZSTD_MIN_COMPRESSION_LEVEL.html index debbba4..9b0c588 100644 --- a/docs/variables/_questdb_browser-client.QWP_ZSTD_MIN_COMPRESSION_LEVEL.html +++ b/docs/variables/_questdb_browser-client.QWP_ZSTD_MIN_COMPRESSION_LEVEL.html @@ -1 +1 @@ -QWP_ZSTD_MIN_COMPRESSION_LEVEL | QuestDB JavaScript Client - v4.2.0

    Variable QWP_ZSTD_MIN_COMPRESSION_LEVELConst

    QWP_ZSTD_MIN_COMPRESSION_LEVEL: 1
    +QWP_ZSTD_MIN_COMPRESSION_LEVEL | QuestDB JavaScript Client - v4.2.0

    Variable QWP_ZSTD_MIN_COMPRESSION_LEVELConst

    QWP_ZSTD_MIN_COMPRESSION_LEVEL: 1
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_COLUMN_TYPE.html b/docs/variables/_questdb_nodejs-client.QWP_COLUMN_TYPE.html index 81b18ff..920bc23 100644 --- a/docs/variables/_questdb_nodejs-client.QWP_COLUMN_TYPE.html +++ b/docs/variables/_questdb_nodejs-client.QWP_COLUMN_TYPE.html @@ -1 +1 @@ -QWP_COLUMN_TYPE | QuestDB JavaScript Client - v4.2.0

    Variable QWP_COLUMN_TYPEConst

    QWP_COLUMN_TYPE: {
        BINARY: 23;
        BOOLEAN: 1;
        BYTE: 2;
        CHAR: 22;
        DATE: 11;
        DECIMAL128: 20;
        DECIMAL256: 21;
        DECIMAL64: 19;
        DOUBLE: 7;
        DOUBLE_ARRAY: 17;
        FLOAT: 6;
        GEOHASH: 14;
        INT: 4;
        IPV4: 24;
        LONG: 5;
        LONG_ARRAY: 18;
        LONG256: 13;
        SHORT: 3;
        SYMBOL: 9;
        TIMESTAMP: 10;
        TIMESTAMP_NANOS: 16;
        UUID: 12;
        VARCHAR: 15;
    } = ...

    Type declaration

    • ReadonlyBINARY: 23
    • ReadonlyBOOLEAN: 1
    • ReadonlyBYTE: 2
    • ReadonlyCHAR: 22
    • ReadonlyDATE: 11
    • ReadonlyDECIMAL128: 20
    • ReadonlyDECIMAL256: 21
    • ReadonlyDECIMAL64: 19
    • ReadonlyDOUBLE: 7
    • ReadonlyDOUBLE_ARRAY: 17
    • ReadonlyFLOAT: 6
    • ReadonlyGEOHASH: 14
    • ReadonlyINT: 4
    • ReadonlyIPV4: 24
    • ReadonlyLONG: 5
    • ReadonlyLONG_ARRAY: 18
    • ReadonlyLONG256: 13
    • ReadonlySHORT: 3
    • ReadonlySYMBOL: 9
    • ReadonlyTIMESTAMP: 10
    • ReadonlyTIMESTAMP_NANOS: 16
    • ReadonlyUUID: 12
    • ReadonlyVARCHAR: 15
    +QWP_COLUMN_TYPE | QuestDB JavaScript Client - v4.2.0

    Variable QWP_COLUMN_TYPEConst

    QWP_COLUMN_TYPE: {
        BINARY: 23;
        BOOLEAN: 1;
        BYTE: 2;
        CHAR: 22;
        DATE: 11;
        DECIMAL128: 20;
        DECIMAL256: 21;
        DECIMAL64: 19;
        DOUBLE: 7;
        DOUBLE_ARRAY: 17;
        FLOAT: 6;
        GEOHASH: 14;
        INT: 4;
        IPV4: 24;
        LONG: 5;
        LONG_ARRAY: 18;
        LONG256: 13;
        SHORT: 3;
        SYMBOL: 9;
        TIMESTAMP: 10;
        TIMESTAMP_NANOS: 16;
        UUID: 12;
        VARCHAR: 15;
    } = ...

    Type declaration

    • ReadonlyBINARY: 23
    • ReadonlyBOOLEAN: 1
    • ReadonlyBYTE: 2
    • ReadonlyCHAR: 22
    • ReadonlyDATE: 11
    • ReadonlyDECIMAL128: 20
    • ReadonlyDECIMAL256: 21
    • ReadonlyDECIMAL64: 19
    • ReadonlyDOUBLE: 7
    • ReadonlyDOUBLE_ARRAY: 17
    • ReadonlyFLOAT: 6
    • ReadonlyGEOHASH: 14
    • ReadonlyINT: 4
    • ReadonlyIPV4: 24
    • ReadonlyLONG: 5
    • ReadonlyLONG_ARRAY: 18
    • ReadonlyLONG256: 13
    • ReadonlySHORT: 3
    • ReadonlySYMBOL: 9
    • ReadonlyTIMESTAMP: 10
    • ReadonlyTIMESTAMP_NANOS: 16
    • ReadonlyUUID: 12
    • ReadonlyVARCHAR: 15
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_COMPRESSION_CODEC.html b/docs/variables/_questdb_nodejs-client.QWP_COMPRESSION_CODEC.html index ed4fc3e..9222f2a 100644 --- a/docs/variables/_questdb_nodejs-client.QWP_COMPRESSION_CODEC.html +++ b/docs/variables/_questdb_nodejs-client.QWP_COMPRESSION_CODEC.html @@ -1 +1 @@ -QWP_COMPRESSION_CODEC | QuestDB JavaScript Client - v4.2.0

    Variable QWP_COMPRESSION_CODECConst

    QWP_COMPRESSION_CODEC: { RAW: 0; ZSTD: 1 } = ...

    Type declaration

    • ReadonlyRAW: 0
    • ReadonlyZSTD: 1
    +QWP_COMPRESSION_CODEC | QuestDB JavaScript Client - v4.2.0

    Variable QWP_COMPRESSION_CODECConst

    QWP_COMPRESSION_CODEC: { RAW: 0; ZSTD: 1 } = ...

    Type declaration

    • ReadonlyRAW: 0
    • ReadonlyZSTD: 1
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_DECIMAL_MAX_SCALE.html b/docs/variables/_questdb_nodejs-client.QWP_DECIMAL_MAX_SCALE.html index f85a47c..b8a9da2 100644 --- a/docs/variables/_questdb_nodejs-client.QWP_DECIMAL_MAX_SCALE.html +++ b/docs/variables/_questdb_nodejs-client.QWP_DECIMAL_MAX_SCALE.html @@ -1,2 +1,2 @@ QWP_DECIMAL_MAX_SCALE | QuestDB JavaScript Client - v4.2.0

    Variable QWP_DECIMAL_MAX_SCALEConst

    QWP_DECIMAL_MAX_SCALE: { decimal128: 38; decimal256: 76; decimal64: 18 } = ...

    Maximum DECIMAL scale of each fixed-width decimal column type.

    -

    Type declaration

    • Readonlydecimal128: 38
    • Readonlydecimal256: 76
    • Readonlydecimal64: 18
    +

    Type declaration

    • Readonlydecimal128: 38
    • Readonlydecimal256: 76
    • Readonlydecimal64: 18
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_DEFAULT_EGRESS_BUFFER_POOL_SIZE.html b/docs/variables/_questdb_nodejs-client.QWP_DEFAULT_EGRESS_BUFFER_POOL_SIZE.html index 16bf23e..15ec73d 100644 --- a/docs/variables/_questdb_nodejs-client.QWP_DEFAULT_EGRESS_BUFFER_POOL_SIZE.html +++ b/docs/variables/_questdb_nodejs-client.QWP_DEFAULT_EGRESS_BUFFER_POOL_SIZE.html @@ -1,2 +1,2 @@ QWP_DEFAULT_EGRESS_BUFFER_POOL_SIZE | QuestDB JavaScript Client - v4.2.0

    Variable QWP_DEFAULT_EGRESS_BUFFER_POOL_SIZEConst

    QWP_DEFAULT_EGRESS_BUFFER_POOL_SIZE: 4

    Default decoded result-buffer pool depth, matching the Java client.

    -
    +
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_DEFAULT_EGRESS_INITIAL_CREDIT.html b/docs/variables/_questdb_nodejs-client.QWP_DEFAULT_EGRESS_INITIAL_CREDIT.html index fa2d6ed..54e236f 100644 --- a/docs/variables/_questdb_nodejs-client.QWP_DEFAULT_EGRESS_INITIAL_CREDIT.html +++ b/docs/variables/_questdb_nodejs-client.QWP_DEFAULT_EGRESS_INITIAL_CREDIT.html @@ -1,2 +1,2 @@ QWP_DEFAULT_EGRESS_INITIAL_CREDIT | QuestDB JavaScript Client - v4.2.0

    Variable QWP_DEFAULT_EGRESS_INITIAL_CREDITConst

    QWP_DEFAULT_EGRESS_INITIAL_CREDIT: 0

    Default send-ahead credit used by Java and TypeScript: zero is unbounded.

    -
    +
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_DEFAULT_EGRESS_SERVER_INFO_TIMEOUT_MS.html b/docs/variables/_questdb_nodejs-client.QWP_DEFAULT_EGRESS_SERVER_INFO_TIMEOUT_MS.html index 9d0eba7..003a9fb 100644 --- a/docs/variables/_questdb_nodejs-client.QWP_DEFAULT_EGRESS_SERVER_INFO_TIMEOUT_MS.html +++ b/docs/variables/_questdb_nodejs-client.QWP_DEFAULT_EGRESS_SERVER_INFO_TIMEOUT_MS.html @@ -1,2 +1,2 @@ QWP_DEFAULT_EGRESS_SERVER_INFO_TIMEOUT_MS | QuestDB JavaScript Client - v4.2.0

    Variable QWP_DEFAULT_EGRESS_SERVER_INFO_TIMEOUT_MSConst

    QWP_DEFAULT_EGRESS_SERVER_INFO_TIMEOUT_MS: 5000 = 5_000

    Default wait for the initial or reconnected SERVER_INFO frame.

    -
    +
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_DURABLE_ACK_WEBSOCKET_PROTOCOL.html b/docs/variables/_questdb_nodejs-client.QWP_DURABLE_ACK_WEBSOCKET_PROTOCOL.html index 784d78d..00fbf51 100644 --- a/docs/variables/_questdb_nodejs-client.QWP_DURABLE_ACK_WEBSOCKET_PROTOCOL.html +++ b/docs/variables/_questdb_nodejs-client.QWP_DURABLE_ACK_WEBSOCKET_PROTOCOL.html @@ -1,3 +1,3 @@ QWP_DURABLE_ACK_WEBSOCKET_PROTOCOL | QuestDB JavaScript Client - v4.2.0

    Variable QWP_DURABLE_ACK_WEBSOCKET_PROTOCOLConst

    QWP_DURABLE_ACK_WEBSOCKET_PROTOCOL: "questdb.qwp.durable-ack.v1"

    Browser-visible WebSocket subprotocol used to request and confirm durable ingress acknowledgements. Browsers cannot set or inspect X-QWP-* headers.

    -
    +
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_EGRESS_CAPABILITY.html b/docs/variables/_questdb_nodejs-client.QWP_EGRESS_CAPABILITY.html index c39f835..854418c 100644 --- a/docs/variables/_questdb_nodejs-client.QWP_EGRESS_CAPABILITY.html +++ b/docs/variables/_questdb_nodejs-client.QWP_EGRESS_CAPABILITY.html @@ -1 +1 @@ -QWP_EGRESS_CAPABILITY | QuestDB JavaScript Client - v4.2.0

    Variable QWP_EGRESS_CAPABILITYConst

    QWP_EGRESS_CAPABILITY: { COMPRESSION: 4; QUERY_FLAGS: 2; ZONE: 1 } = ...

    Type declaration

    • ReadonlyCOMPRESSION: 4
    • ReadonlyQUERY_FLAGS: 2
    • ReadonlyZONE: 1
    +QWP_EGRESS_CAPABILITY | QuestDB JavaScript Client - v4.2.0

    Variable QWP_EGRESS_CAPABILITYConst

    QWP_EGRESS_CAPABILITY: { COMPRESSION: 4; QUERY_FLAGS: 2; ZONE: 1 } = ...

    Type declaration

    • ReadonlyCOMPRESSION: 4
    • ReadonlyQUERY_FLAGS: 2
    • ReadonlyZONE: 1
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_EGRESS_MESSAGE.html b/docs/variables/_questdb_nodejs-client.QWP_EGRESS_MESSAGE.html index 99a48c1..fbf5b15 100644 --- a/docs/variables/_questdb_nodejs-client.QWP_EGRESS_MESSAGE.html +++ b/docs/variables/_questdb_nodejs-client.QWP_EGRESS_MESSAGE.html @@ -1 +1 @@ -QWP_EGRESS_MESSAGE | QuestDB JavaScript Client - v4.2.0

    Variable QWP_EGRESS_MESSAGEConst

    QWP_EGRESS_MESSAGE: {
        CACHE_RESET: 23;
        CANCEL: 20;
        CREDIT: 21;
        EXEC_DONE: 22;
        QUERY_ERROR: 19;
        QUERY_REQUEST: 16;
        RESULT_BATCH: 17;
        RESULT_END: 18;
        SERVER_INFO: 24;
    } = ...

    Type declaration

    • ReadonlyCACHE_RESET: 23
    • ReadonlyCANCEL: 20
    • ReadonlyCREDIT: 21
    • ReadonlyEXEC_DONE: 22
    • ReadonlyQUERY_ERROR: 19
    • ReadonlyQUERY_REQUEST: 16
    • ReadonlyRESULT_BATCH: 17
    • ReadonlyRESULT_END: 18
    • ReadonlySERVER_INFO: 24
    +QWP_EGRESS_MESSAGE | QuestDB JavaScript Client - v4.2.0

    Variable QWP_EGRESS_MESSAGEConst

    QWP_EGRESS_MESSAGE: {
        CACHE_RESET: 23;
        CANCEL: 20;
        CREDIT: 21;
        EXEC_DONE: 22;
        QUERY_ERROR: 19;
        QUERY_REQUEST: 16;
        RESULT_BATCH: 17;
        RESULT_END: 18;
        SERVER_INFO: 24;
    } = ...

    Type declaration

    • ReadonlyCACHE_RESET: 23
    • ReadonlyCANCEL: 20
    • ReadonlyCREDIT: 21
    • ReadonlyEXEC_DONE: 22
    • ReadonlyQUERY_ERROR: 19
    • ReadonlyQUERY_REQUEST: 16
    • ReadonlyRESULT_BATCH: 17
    • ReadonlyRESULT_END: 18
    • ReadonlySERVER_INFO: 24
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_EGRESS_PATH.html b/docs/variables/_questdb_nodejs-client.QWP_EGRESS_PATH.html index 64d051e..9e01d24 100644 --- a/docs/variables/_questdb_nodejs-client.QWP_EGRESS_PATH.html +++ b/docs/variables/_questdb_nodejs-client.QWP_EGRESS_PATH.html @@ -1 +1 @@ -QWP_EGRESS_PATH | QuestDB JavaScript Client - v4.2.0

    Variable QWP_EGRESS_PATHConst

    QWP_EGRESS_PATH: "/read/v1"
    +QWP_EGRESS_PATH | QuestDB JavaScript Client - v4.2.0

    Variable QWP_EGRESS_PATHConst

    QWP_EGRESS_PATH: "/read/v1"
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_ENCODING_GORILLA.html b/docs/variables/_questdb_nodejs-client.QWP_ENCODING_GORILLA.html index 2338aa1..4e8f3b0 100644 --- a/docs/variables/_questdb_nodejs-client.QWP_ENCODING_GORILLA.html +++ b/docs/variables/_questdb_nodejs-client.QWP_ENCODING_GORILLA.html @@ -1 +1 @@ -QWP_ENCODING_GORILLA | QuestDB JavaScript Client - v4.2.0

    Variable QWP_ENCODING_GORILLAConst

    QWP_ENCODING_GORILLA: 1 = 0x01
    +QWP_ENCODING_GORILLA | QuestDB JavaScript Client - v4.2.0

    Variable QWP_ENCODING_GORILLAConst

    QWP_ENCODING_GORILLA: 1 = 0x01
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_ENCODING_UNCOMPRESSED.html b/docs/variables/_questdb_nodejs-client.QWP_ENCODING_UNCOMPRESSED.html index ef8e866..fae06ba 100644 --- a/docs/variables/_questdb_nodejs-client.QWP_ENCODING_UNCOMPRESSED.html +++ b/docs/variables/_questdb_nodejs-client.QWP_ENCODING_UNCOMPRESSED.html @@ -1 +1 @@ -QWP_ENCODING_UNCOMPRESSED | QuestDB JavaScript Client - v4.2.0

    Variable QWP_ENCODING_UNCOMPRESSEDConst

    QWP_ENCODING_UNCOMPRESSED: 0 = 0x00
    +QWP_ENCODING_UNCOMPRESSED | QuestDB JavaScript Client - v4.2.0

    Variable QWP_ENCODING_UNCOMPRESSEDConst

    QWP_ENCODING_UNCOMPRESSED: 0 = 0x00
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_FLAG_DEFER_COMMIT.html b/docs/variables/_questdb_nodejs-client.QWP_FLAG_DEFER_COMMIT.html index 628fe5a..a73f678 100644 --- a/docs/variables/_questdb_nodejs-client.QWP_FLAG_DEFER_COMMIT.html +++ b/docs/variables/_questdb_nodejs-client.QWP_FLAG_DEFER_COMMIT.html @@ -1 +1 @@ -QWP_FLAG_DEFER_COMMIT | QuestDB JavaScript Client - v4.2.0

    Variable QWP_FLAG_DEFER_COMMITConst

    QWP_FLAG_DEFER_COMMIT: 1 = 0x01
    +QWP_FLAG_DEFER_COMMIT | QuestDB JavaScript Client - v4.2.0

    Variable QWP_FLAG_DEFER_COMMITConst

    QWP_FLAG_DEFER_COMMIT: 1 = 0x01
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_FLAG_DELTA_SYMBOL_DICTIONARY.html b/docs/variables/_questdb_nodejs-client.QWP_FLAG_DELTA_SYMBOL_DICTIONARY.html index 7598fb4..ce432b6 100644 --- a/docs/variables/_questdb_nodejs-client.QWP_FLAG_DELTA_SYMBOL_DICTIONARY.html +++ b/docs/variables/_questdb_nodejs-client.QWP_FLAG_DELTA_SYMBOL_DICTIONARY.html @@ -1 +1 @@ -QWP_FLAG_DELTA_SYMBOL_DICTIONARY | QuestDB JavaScript Client - v4.2.0

    Variable QWP_FLAG_DELTA_SYMBOL_DICTIONARYConst

    QWP_FLAG_DELTA_SYMBOL_DICTIONARY: 8 = 0x08
    +QWP_FLAG_DELTA_SYMBOL_DICTIONARY | QuestDB JavaScript Client - v4.2.0

    Variable QWP_FLAG_DELTA_SYMBOL_DICTIONARYConst

    QWP_FLAG_DELTA_SYMBOL_DICTIONARY: 8 = 0x08
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_FLAG_DURABLE_ACK_POLL.html b/docs/variables/_questdb_nodejs-client.QWP_FLAG_DURABLE_ACK_POLL.html index 388bda7..35a308a 100644 --- a/docs/variables/_questdb_nodejs-client.QWP_FLAG_DURABLE_ACK_POLL.html +++ b/docs/variables/_questdb_nodejs-client.QWP_FLAG_DURABLE_ACK_POLL.html @@ -1,2 +1,2 @@ QWP_FLAG_DURABLE_ACK_POLL | QuestDB JavaScript Client - v4.2.0

    Variable QWP_FLAG_DURABLE_ACK_POLLConst

    QWP_FLAG_DURABLE_ACK_POLL: 2 = 0x02

    Table-less ingress control frame that polls negotiated durable-ACK progress.

    -
    +
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_FLAG_GORILLA.html b/docs/variables/_questdb_nodejs-client.QWP_FLAG_GORILLA.html index 8e4794c..8974353 100644 --- a/docs/variables/_questdb_nodejs-client.QWP_FLAG_GORILLA.html +++ b/docs/variables/_questdb_nodejs-client.QWP_FLAG_GORILLA.html @@ -1 +1 @@ -QWP_FLAG_GORILLA | QuestDB JavaScript Client - v4.2.0

    Variable QWP_FLAG_GORILLAConst

    QWP_FLAG_GORILLA: 4 = 0x04
    +QWP_FLAG_GORILLA | QuestDB JavaScript Client - v4.2.0

    Variable QWP_FLAG_GORILLAConst

    QWP_FLAG_GORILLA: 4 = 0x04
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_FLAG_ZSTD.html b/docs/variables/_questdb_nodejs-client.QWP_FLAG_ZSTD.html index c824852..b03d394 100644 --- a/docs/variables/_questdb_nodejs-client.QWP_FLAG_ZSTD.html +++ b/docs/variables/_questdb_nodejs-client.QWP_FLAG_ZSTD.html @@ -1 +1 @@ -QWP_FLAG_ZSTD | QuestDB JavaScript Client - v4.2.0

    Variable QWP_FLAG_ZSTDConst

    QWP_FLAG_ZSTD: 16 = 0x10
    +QWP_FLAG_ZSTD | QuestDB JavaScript Client - v4.2.0

    Variable QWP_FLAG_ZSTDConst

    QWP_FLAG_ZSTD: 16 = 0x10
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_HEADER_SIZE.html b/docs/variables/_questdb_nodejs-client.QWP_HEADER_SIZE.html index e4d90ef..d034e13 100644 --- a/docs/variables/_questdb_nodejs-client.QWP_HEADER_SIZE.html +++ b/docs/variables/_questdb_nodejs-client.QWP_HEADER_SIZE.html @@ -1 +1 @@ -QWP_HEADER_SIZE | QuestDB JavaScript Client - v4.2.0

    Variable QWP_HEADER_SIZEConst

    QWP_HEADER_SIZE: 12
    +QWP_HEADER_SIZE | QuestDB JavaScript Client - v4.2.0

    Variable QWP_HEADER_SIZEConst

    QWP_HEADER_SIZE: 12
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_INGRESS_PATH.html b/docs/variables/_questdb_nodejs-client.QWP_INGRESS_PATH.html index 59c8f04..82c972e 100644 --- a/docs/variables/_questdb_nodejs-client.QWP_INGRESS_PATH.html +++ b/docs/variables/_questdb_nodejs-client.QWP_INGRESS_PATH.html @@ -1 +1 @@ -QWP_INGRESS_PATH | QuestDB JavaScript Client - v4.2.0

    Variable QWP_INGRESS_PATHConst

    QWP_INGRESS_PATH: "/write/v4"
    +QWP_INGRESS_PATH | QuestDB JavaScript Client - v4.2.0

    Variable QWP_INGRESS_PATHConst

    QWP_INGRESS_PATH: "/write/v4"
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_INGRESS_PROGRESS_KIND.html b/docs/variables/_questdb_nodejs-client.QWP_INGRESS_PROGRESS_KIND.html index d178da3..edb1694 100644 --- a/docs/variables/_questdb_nodejs-client.QWP_INGRESS_PROGRESS_KIND.html +++ b/docs/variables/_questdb_nodejs-client.QWP_INGRESS_PROGRESS_KIND.html @@ -1 +1 @@ -QWP_INGRESS_PROGRESS_KIND | QuestDB JavaScript Client - v4.2.0

    Variable QWP_INGRESS_PROGRESS_KINDConst

    QWP_INGRESS_PROGRESS_KIND: {
        ACKNOWLEDGED: "acknowledged";
        DURABLE_ACKNOWLEDGED: "durable-acknowledged";
        PUBLISHED: "published";
    } = ...

    Type declaration

    • ReadonlyACKNOWLEDGED: "acknowledged"
    • ReadonlyDURABLE_ACKNOWLEDGED: "durable-acknowledged"
    • ReadonlyPUBLISHED: "published"
    +QWP_INGRESS_PROGRESS_KIND | QuestDB JavaScript Client - v4.2.0

    Variable QWP_INGRESS_PROGRESS_KINDConst

    QWP_INGRESS_PROGRESS_KIND: {
        ACKNOWLEDGED: "acknowledged";
        DURABLE_ACKNOWLEDGED: "durable-acknowledged";
        PUBLISHED: "published";
    } = ...

    Type declaration

    • ReadonlyACKNOWLEDGED: "acknowledged"
    • ReadonlyDURABLE_ACKNOWLEDGED: "durable-acknowledged"
    • ReadonlyPUBLISHED: "published"
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_INITIAL_CONNECT_MODE.html b/docs/variables/_questdb_nodejs-client.QWP_INITIAL_CONNECT_MODE.html index 8d21653..28eb80c 100644 --- a/docs/variables/_questdb_nodejs-client.QWP_INITIAL_CONNECT_MODE.html +++ b/docs/variables/_questdb_nodejs-client.QWP_INITIAL_CONNECT_MODE.html @@ -4,4 +4,4 @@

    Type declaration

    • ReadonlyASYNC: "async"

      Return immediately and connect on the background replay loop.

    • ReadonlyOFF: "off"

      Try once on the caller and fail immediately.

    • ReadonlySYNC: "sync"

      Retry on the caller within the configured reconnect budget.

      -
    +
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_MAGIC.html b/docs/variables/_questdb_nodejs-client.QWP_MAGIC.html index c8c9ff6..9e37681 100644 --- a/docs/variables/_questdb_nodejs-client.QWP_MAGIC.html +++ b/docs/variables/_questdb_nodejs-client.QWP_MAGIC.html @@ -1,2 +1,2 @@ QWP_MAGIC | QuestDB JavaScript Client - v4.2.0

    Variable QWP_MAGICConst

    QWP_MAGIC: 827348817 = 0x31505751

    ASCII QWP1, represented as its little-endian uint32 value.

    -
    +
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_MAX_ARRAY_DIMENSIONS.html b/docs/variables/_questdb_nodejs-client.QWP_MAX_ARRAY_DIMENSIONS.html index 2f08df0..aae79e1 100644 --- a/docs/variables/_questdb_nodejs-client.QWP_MAX_ARRAY_DIMENSIONS.html +++ b/docs/variables/_questdb_nodejs-client.QWP_MAX_ARRAY_DIMENSIONS.html @@ -1,2 +1,2 @@ QWP_MAX_ARRAY_DIMENSIONS | QuestDB JavaScript Client - v4.2.0

    Variable QWP_MAX_ARRAY_DIMENSIONSConst

    QWP_MAX_ARRAY_DIMENSIONS: 32

    Maximum array rank accepted by QuestDB's QWP ingress decoder.

    -
    +
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_MAX_ARRAY_DIMENSION_LENGTH.html b/docs/variables/_questdb_nodejs-client.QWP_MAX_ARRAY_DIMENSION_LENGTH.html index 4845d27..ed9c8cd 100644 --- a/docs/variables/_questdb_nodejs-client.QWP_MAX_ARRAY_DIMENSION_LENGTH.html +++ b/docs/variables/_questdb_nodejs-client.QWP_MAX_ARRAY_DIMENSION_LENGTH.html @@ -1,2 +1,2 @@ QWP_MAX_ARRAY_DIMENSION_LENGTH | QuestDB JavaScript Client - v4.2.0

    Variable QWP_MAX_ARRAY_DIMENSION_LENGTHConst

    QWP_MAX_ARRAY_DIMENSION_LENGTH: 2147483647 = 2_147_483_647

    Maximum signed int32 array-axis length accepted by QWP ingress.

    -
    +
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_MAX_BATCH_ROWS_UPPER_BOUND.html b/docs/variables/_questdb_nodejs-client.QWP_MAX_BATCH_ROWS_UPPER_BOUND.html index 282184d..feb6604 100644 --- a/docs/variables/_questdb_nodejs-client.QWP_MAX_BATCH_ROWS_UPPER_BOUND.html +++ b/docs/variables/_questdb_nodejs-client.QWP_MAX_BATCH_ROWS_UPPER_BOUND.html @@ -1,2 +1,2 @@ QWP_MAX_BATCH_ROWS_UPPER_BOUND | QuestDB JavaScript Client - v4.2.0

    Variable QWP_MAX_BATCH_ROWS_UPPER_BOUNDConst

    QWP_MAX_BATCH_ROWS_UPPER_BOUND: 1048576 = 1_048_576

    Largest client-requested egress RESULT_BATCH row cap.

    -
    +
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_MAX_CELLS_PER_BATCH.html b/docs/variables/_questdb_nodejs-client.QWP_MAX_CELLS_PER_BATCH.html index 1897215..c6c98fe 100644 --- a/docs/variables/_questdb_nodejs-client.QWP_MAX_CELLS_PER_BATCH.html +++ b/docs/variables/_questdb_nodejs-client.QWP_MAX_CELLS_PER_BATCH.html @@ -10,4 +10,4 @@

    32Mi cells is roughly 512 MB decoded. That is far above any plausible result -- the widest supported table at 16k rows, or a full 1,048,576-row batch at 32 columns -- and far below what the caps alone would permit.

    -
    +
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_MAX_COLUMNS_PER_TABLE.html b/docs/variables/_questdb_nodejs-client.QWP_MAX_COLUMNS_PER_TABLE.html index b95a8c7..3d3c776 100644 --- a/docs/variables/_questdb_nodejs-client.QWP_MAX_COLUMNS_PER_TABLE.html +++ b/docs/variables/_questdb_nodejs-client.QWP_MAX_COLUMNS_PER_TABLE.html @@ -1 +1 @@ -QWP_MAX_COLUMNS_PER_TABLE | QuestDB JavaScript Client - v4.2.0

    Variable QWP_MAX_COLUMNS_PER_TABLEConst

    QWP_MAX_COLUMNS_PER_TABLE: 2048
    +QWP_MAX_COLUMNS_PER_TABLE | QuestDB JavaScript Client - v4.2.0

    Variable QWP_MAX_COLUMNS_PER_TABLEConst

    QWP_MAX_COLUMNS_PER_TABLE: 2048
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_MAX_COLUMN_NAME_LENGTH.html b/docs/variables/_questdb_nodejs-client.QWP_MAX_COLUMN_NAME_LENGTH.html index 43c3599..34f315d 100644 --- a/docs/variables/_questdb_nodejs-client.QWP_MAX_COLUMN_NAME_LENGTH.html +++ b/docs/variables/_questdb_nodejs-client.QWP_MAX_COLUMN_NAME_LENGTH.html @@ -1,2 +1,2 @@ QWP_MAX_COLUMN_NAME_LENGTH | QuestDB JavaScript Client - v4.2.0

    Variable QWP_MAX_COLUMN_NAME_LENGTHConst

    QWP_MAX_COLUMN_NAME_LENGTH: 127

    Default QWP ingress identifier limits, in UTF-8 wire bytes.

    -
    +
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_MAX_ERROR_MESSAGE_LENGTH.html b/docs/variables/_questdb_nodejs-client.QWP_MAX_ERROR_MESSAGE_LENGTH.html deleted file mode 100644 index 734901c..0000000 --- a/docs/variables/_questdb_nodejs-client.QWP_MAX_ERROR_MESSAGE_LENGTH.html +++ /dev/null @@ -1 +0,0 @@ -QWP_MAX_ERROR_MESSAGE_LENGTH | QuestDB JavaScript Client - v4.2.0

    Variable QWP_MAX_ERROR_MESSAGE_LENGTHConst

    QWP_MAX_ERROR_MESSAGE_LENGTH: 1024
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_MAX_IDENTIFIER_BYTES.html b/docs/variables/_questdb_nodejs-client.QWP_MAX_IDENTIFIER_BYTES.html index 1900a5e..b9412dc 100644 --- a/docs/variables/_questdb_nodejs-client.QWP_MAX_IDENTIFIER_BYTES.html +++ b/docs/variables/_questdb_nodejs-client.QWP_MAX_IDENTIFIER_BYTES.html @@ -3,4 +3,4 @@ 127-UTF-16-code-unit metadata limit. One code unit takes at most three UTF-8 bytes, so query decoding accepts that larger representation even though QWP ingress enforces its 127-byte protocol limit.

    -
    +
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_MAX_ROWS_PER_TABLE.html b/docs/variables/_questdb_nodejs-client.QWP_MAX_ROWS_PER_TABLE.html index 9a74ed9..4dc68ec 100644 --- a/docs/variables/_questdb_nodejs-client.QWP_MAX_ROWS_PER_TABLE.html +++ b/docs/variables/_questdb_nodejs-client.QWP_MAX_ROWS_PER_TABLE.html @@ -1 +1 @@ -QWP_MAX_ROWS_PER_TABLE | QuestDB JavaScript Client - v4.2.0

    Variable QWP_MAX_ROWS_PER_TABLEConst

    QWP_MAX_ROWS_PER_TABLE: 1000000 = 1_000_000
    +QWP_MAX_ROWS_PER_TABLE | QuestDB JavaScript Client - v4.2.0

    Variable QWP_MAX_ROWS_PER_TABLEConst

    QWP_MAX_ROWS_PER_TABLE: 1000000 = 1_000_000
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_MAX_SYMBOL_DICTIONARY_SIZE.html b/docs/variables/_questdb_nodejs-client.QWP_MAX_SYMBOL_DICTIONARY_SIZE.html index 26435f5..67be63f 100644 --- a/docs/variables/_questdb_nodejs-client.QWP_MAX_SYMBOL_DICTIONARY_SIZE.html +++ b/docs/variables/_questdb_nodejs-client.QWP_MAX_SYMBOL_DICTIONARY_SIZE.html @@ -1 +1 @@ -QWP_MAX_SYMBOL_DICTIONARY_SIZE | QuestDB JavaScript Client - v4.2.0

    Variable QWP_MAX_SYMBOL_DICTIONARY_SIZEConst

    QWP_MAX_SYMBOL_DICTIONARY_SIZE: 1000000 = 1_000_000
    +QWP_MAX_SYMBOL_DICTIONARY_SIZE | QuestDB JavaScript Client - v4.2.0

    Variable QWP_MAX_SYMBOL_DICTIONARY_SIZEConst

    QWP_MAX_SYMBOL_DICTIONARY_SIZE: 1000000 = 1_000_000
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_MAX_TABLE_NAME_LENGTH.html b/docs/variables/_questdb_nodejs-client.QWP_MAX_TABLE_NAME_LENGTH.html index 5d9cb0a..f5e3202 100644 --- a/docs/variables/_questdb_nodejs-client.QWP_MAX_TABLE_NAME_LENGTH.html +++ b/docs/variables/_questdb_nodejs-client.QWP_MAX_TABLE_NAME_LENGTH.html @@ -1 +1 @@ -QWP_MAX_TABLE_NAME_LENGTH | QuestDB JavaScript Client - v4.2.0

    Variable QWP_MAX_TABLE_NAME_LENGTHConst

    QWP_MAX_TABLE_NAME_LENGTH: 127
    +QWP_MAX_TABLE_NAME_LENGTH | QuestDB JavaScript Client - v4.2.0

    Variable QWP_MAX_TABLE_NAME_LENGTHConst

    QWP_MAX_TABLE_NAME_LENGTH: 127
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_MAX_ZSTD_DECOMPRESSED_SIZE.html b/docs/variables/_questdb_nodejs-client.QWP_MAX_ZSTD_DECOMPRESSED_SIZE.html index 5898dfd..56fe4d2 100644 --- a/docs/variables/_questdb_nodejs-client.QWP_MAX_ZSTD_DECOMPRESSED_SIZE.html +++ b/docs/variables/_questdb_nodejs-client.QWP_MAX_ZSTD_DECOMPRESSED_SIZE.html @@ -1,2 +1,2 @@ QWP_MAX_ZSTD_DECOMPRESSED_SIZE | QuestDB JavaScript Client - v4.2.0

    Variable QWP_MAX_ZSTD_DECOMPRESSED_SIZEConst

    QWP_MAX_ZSTD_DECOMPRESSED_SIZE: number = ...

    Matches the Java client's per-connection decompression safety cap.

    -
    +
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_ORPHAN_DRAIN_EVENT_KIND.html b/docs/variables/_questdb_nodejs-client.QWP_ORPHAN_DRAIN_EVENT_KIND.html index 781dd66..dceb1db 100644 --- a/docs/variables/_questdb_nodejs-client.QWP_ORPHAN_DRAIN_EVENT_KIND.html +++ b/docs/variables/_questdb_nodejs-client.QWP_ORPHAN_DRAIN_EVENT_KIND.html @@ -1,2 +1,2 @@ QWP_ORPHAN_DRAIN_EVENT_KIND | QuestDB JavaScript Client - v4.2.0

    Variable QWP_ORPHAN_DRAIN_EVENT_KINDConst

    QWP_ORPHAN_DRAIN_EVENT_KIND: {
        DISCOVERED: "discovered";
        DRAINED: "drained";
        DURABLE_ACK_PERSISTENT_FAILURE: "durable-ack-persistent-failure";
        DURABLE_ACK_UNAVAILABLE: "durable-ack-unavailable";
        FAILED: "failed";
        LOCKED: "locked";
        PRIMARY_UNAVAILABLE: "primary-unavailable";
        RETRYING: "retrying";
        SCAN_FAILED: "scan-failed";
        STARTED: "started";
    } = ...

    Type declaration

    • ReadonlyDISCOVERED: "discovered"
    • ReadonlyDRAINED: "drained"
    • ReadonlyDURABLE_ACK_PERSISTENT_FAILURE: "durable-ack-persistent-failure"
    • ReadonlyDURABLE_ACK_UNAVAILABLE: "durable-ack-unavailable"
    • ReadonlyFAILED: "failed"
    • ReadonlyLOCKED: "locked"
    • ReadonlyPRIMARY_UNAVAILABLE: "primary-unavailable"
    • ReadonlyRETRYING: "retrying"

      The attempt failed transiently; the slot is left for a later scan.

      -
    • ReadonlySCAN_FAILED: "scan-failed"
    • ReadonlySTARTED: "started"
    +
  • ReadonlySCAN_FAILED: "scan-failed"
  • ReadonlySTARTED: "started"
  • diff --git a/docs/variables/_questdb_nodejs-client.QWP_ORPHAN_FAILED_SENTINEL.html b/docs/variables/_questdb_nodejs-client.QWP_ORPHAN_FAILED_SENTINEL.html index 912c028..4372e05 100644 --- a/docs/variables/_questdb_nodejs-client.QWP_ORPHAN_FAILED_SENTINEL.html +++ b/docs/variables/_questdb_nodejs-client.QWP_ORPHAN_FAILED_SENTINEL.html @@ -1,2 +1,2 @@ QWP_ORPHAN_FAILED_SENTINEL | QuestDB JavaScript Client - v4.2.0

    Variable QWP_ORPHAN_FAILED_SENTINELConst

    QWP_ORPHAN_FAILED_SENTINEL: ".failed"

    Java-compatible marker that excludes a failed slot from automatic drain.

    -
    +
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_QUERY_FLAG_RESET_DICTIONARY.html b/docs/variables/_questdb_nodejs-client.QWP_QUERY_FLAG_RESET_DICTIONARY.html index 021580b..5577107 100644 --- a/docs/variables/_questdb_nodejs-client.QWP_QUERY_FLAG_RESET_DICTIONARY.html +++ b/docs/variables/_questdb_nodejs-client.QWP_QUERY_FLAG_RESET_DICTIONARY.html @@ -1 +1 @@ -QWP_QUERY_FLAG_RESET_DICTIONARY | QuestDB JavaScript Client - v4.2.0

    Variable QWP_QUERY_FLAG_RESET_DICTIONARYConst

    QWP_QUERY_FLAG_RESET_DICTIONARY: 1 = 0x01
    +QWP_QUERY_FLAG_RESET_DICTIONARY | QuestDB JavaScript Client - v4.2.0

    Variable QWP_QUERY_FLAG_RESET_DICTIONARYConst

    QWP_QUERY_FLAG_RESET_DICTIONARY: 1 = 0x01
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_RECONNECT_EVENT_KIND.html b/docs/variables/_questdb_nodejs-client.QWP_RECONNECT_EVENT_KIND.html index deedbc6..1cc9744 100644 --- a/docs/variables/_questdb_nodejs-client.QWP_RECONNECT_EVENT_KIND.html +++ b/docs/variables/_questdb_nodejs-client.QWP_RECONNECT_EVENT_KIND.html @@ -1,4 +1,4 @@ QWP_RECONNECT_EVENT_KIND | QuestDB JavaScript Client - v4.2.0

    Variable QWP_RECONNECT_EVENT_KINDConst

    QWP_RECONNECT_EVENT_KIND: {
        ATTEMPT_FAILED: "attempt-failed";
        CONNECTED: "connected";
        DURABLE_ACK_PERSISTENT_FAILURE: "durable-ack-persistent-failure";
        DURABLE_ACK_UNAVAILABLE: "durable-ack-unavailable";
        FAILED_OVER: "failed-over";
        PRIMARY_UNAVAILABLE: "primary-unavailable";
        RECONNECTED: "reconnected";
        RECONNECTING: "reconnecting";
    } = ...

    Type declaration

    • ReadonlyATTEMPT_FAILED: "attempt-failed"
    • ReadonlyCONNECTED: "connected"
    • ReadonlyDURABLE_ACK_PERSISTENT_FAILURE: "durable-ack-persistent-failure"

      An orphan exhausted its consecutive durable-ACK mismatch budget.

    • ReadonlyDURABLE_ACK_UNAVAILABLE: "durable-ack-unavailable"

      An unbounded SF loop is waiting for durable-ACK-capable endpoints.

    • ReadonlyFAILED_OVER: "failed-over"
    • ReadonlyPRIMARY_UNAVAILABLE: "primary-unavailable"

      Every reachable ingress endpoint is temporarily unable to be primary.

      -
    • ReadonlyRECONNECTED: "reconnected"
    • ReadonlyRECONNECTING: "reconnecting"
    +
  • ReadonlyRECONNECTED: "reconnected"
  • ReadonlyRECONNECTING: "reconnecting"
  • diff --git a/docs/variables/_questdb_nodejs-client.QWP_RESET_MASK_DICTIONARY.html b/docs/variables/_questdb_nodejs-client.QWP_RESET_MASK_DICTIONARY.html index 742c91a..26a35c9 100644 --- a/docs/variables/_questdb_nodejs-client.QWP_RESET_MASK_DICTIONARY.html +++ b/docs/variables/_questdb_nodejs-client.QWP_RESET_MASK_DICTIONARY.html @@ -1 +1 @@ -QWP_RESET_MASK_DICTIONARY | QuestDB JavaScript Client - v4.2.0

    Variable QWP_RESET_MASK_DICTIONARYConst

    QWP_RESET_MASK_DICTIONARY: 1 = 0x01
    +QWP_RESET_MASK_DICTIONARY | QuestDB JavaScript Client - v4.2.0

    Variable QWP_RESET_MASK_DICTIONARYConst

    QWP_RESET_MASK_DICTIONARY: 1 = 0x01
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_SENDER_ERROR_CATEGORY.html b/docs/variables/_questdb_nodejs-client.QWP_SENDER_ERROR_CATEGORY.html index 36a4e1d..012a6f5 100644 --- a/docs/variables/_questdb_nodejs-client.QWP_SENDER_ERROR_CATEGORY.html +++ b/docs/variables/_questdb_nodejs-client.QWP_SENDER_ERROR_CATEGORY.html @@ -1 +1 @@ -QWP_SENDER_ERROR_CATEGORY | QuestDB JavaScript Client - v4.2.0

    Variable QWP_SENDER_ERROR_CATEGORYConst

    QWP_SENDER_ERROR_CATEGORY: {
        DATA_LOSS: "data-loss";
        DICTIONARY_GAP: "dictionary-gap";
        INTERNAL_ERROR: "internal-error";
        NOT_WRITABLE: "not-writable";
        PARSE_ERROR: "parse-error";
        PROTOCOL_VIOLATION: "protocol-violation";
        SCHEMA_MISMATCH: "schema-mismatch";
        SECURITY_ERROR: "security-error";
        UNKNOWN: "unknown";
        WRITE_ERROR: "write-error";
    } = ...

    Type declaration

    • ReadonlyDATA_LOSS: "data-loss"
    • ReadonlyDICTIONARY_GAP: "dictionary-gap"
    • ReadonlyINTERNAL_ERROR: "internal-error"
    • ReadonlyNOT_WRITABLE: "not-writable"
    • ReadonlyPARSE_ERROR: "parse-error"
    • ReadonlyPROTOCOL_VIOLATION: "protocol-violation"
    • ReadonlySCHEMA_MISMATCH: "schema-mismatch"
    • ReadonlySECURITY_ERROR: "security-error"
    • ReadonlyUNKNOWN: "unknown"
    • ReadonlyWRITE_ERROR: "write-error"
    +QWP_SENDER_ERROR_CATEGORY | QuestDB JavaScript Client - v4.2.0

    Variable QWP_SENDER_ERROR_CATEGORYConst

    QWP_SENDER_ERROR_CATEGORY: {
        CANCELLED: "cancelled";
        DATA_LOSS: "data-loss";
        DICTIONARY_GAP: "dictionary-gap";
        INTERNAL_ERROR: "internal-error";
        LIMIT_EXCEEDED: "limit-exceeded";
        NOT_WRITABLE: "not-writable";
        PARSE_ERROR: "parse-error";
        PROTOCOL_VIOLATION: "protocol-violation";
        SCHEMA_MISMATCH: "schema-mismatch";
        SECURITY_ERROR: "security-error";
        UNKNOWN: "unknown";
        WRITE_ERROR: "write-error";
    } = ...

    Type declaration

    • ReadonlyCANCELLED: "cancelled"
    • ReadonlyDATA_LOSS: "data-loss"
    • ReadonlyDICTIONARY_GAP: "dictionary-gap"
    • ReadonlyINTERNAL_ERROR: "internal-error"
    • ReadonlyLIMIT_EXCEEDED: "limit-exceeded"
    • ReadonlyNOT_WRITABLE: "not-writable"
    • ReadonlyPARSE_ERROR: "parse-error"
    • ReadonlyPROTOCOL_VIOLATION: "protocol-violation"
    • ReadonlySCHEMA_MISMATCH: "schema-mismatch"
    • ReadonlySECURITY_ERROR: "security-error"
    • ReadonlyUNKNOWN: "unknown"
    • ReadonlyWRITE_ERROR: "write-error"
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_SENDER_ERROR_POLICY.html b/docs/variables/_questdb_nodejs-client.QWP_SENDER_ERROR_POLICY.html index 5aced61..2eb7757 100644 --- a/docs/variables/_questdb_nodejs-client.QWP_SENDER_ERROR_POLICY.html +++ b/docs/variables/_questdb_nodejs-client.QWP_SENDER_ERROR_POLICY.html @@ -1 +1 @@ -QWP_SENDER_ERROR_POLICY | QuestDB JavaScript Client - v4.2.0

    Variable QWP_SENDER_ERROR_POLICYConst

    QWP_SENDER_ERROR_POLICY: {
        ABANDONED: "abandoned";
        RETRIABLE: "retriable";
        RETRIABLE_OTHER: "retriable-other";
        TERMINAL: "terminal";
    } = ...

    Type declaration

    • ReadonlyABANDONED: "abandoned"
    • ReadonlyRETRIABLE: "retriable"
    • ReadonlyRETRIABLE_OTHER: "retriable-other"
    • ReadonlyTERMINAL: "terminal"
    +QWP_SENDER_ERROR_POLICY | QuestDB JavaScript Client - v4.2.0

    Variable QWP_SENDER_ERROR_POLICYConst

    QWP_SENDER_ERROR_POLICY: {
        ABANDONED: "abandoned";
        RETRIABLE: "retriable";
        RETRIABLE_OTHER: "retriable-other";
        TERMINAL: "terminal";
    } = ...

    Type declaration

    • ReadonlyABANDONED: "abandoned"
    • ReadonlyRETRIABLE: "retriable"
    • ReadonlyRETRIABLE_OTHER: "retriable-other"
    • ReadonlyTERMINAL: "terminal"
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_SERVER_ROLE.html b/docs/variables/_questdb_nodejs-client.QWP_SERVER_ROLE.html index d69040c..f0595b9 100644 --- a/docs/variables/_questdb_nodejs-client.QWP_SERVER_ROLE.html +++ b/docs/variables/_questdb_nodejs-client.QWP_SERVER_ROLE.html @@ -1 +1 @@ -QWP_SERVER_ROLE | QuestDB JavaScript Client - v4.2.0

    Variable QWP_SERVER_ROLEConst

    QWP_SERVER_ROLE: { PRIMARY: 1; PRIMARY_CATCHUP: 3; REPLICA: 2; STANDALONE: 0 } = ...

    Type declaration

    • ReadonlyPRIMARY: 1
    • ReadonlyPRIMARY_CATCHUP: 3
    • ReadonlyREPLICA: 2
    • ReadonlySTANDALONE: 0
    +QWP_SERVER_ROLE | QuestDB JavaScript Client - v4.2.0

    Variable QWP_SERVER_ROLEConst

    QWP_SERVER_ROLE: { PRIMARY: 1; PRIMARY_CATCHUP: 3; REPLICA: 2; STANDALONE: 0 } = ...

    Type declaration

    • ReadonlyPRIMARY: 1
    • ReadonlyPRIMARY_CATCHUP: 3
    • ReadonlyREPLICA: 2
    • ReadonlySTANDALONE: 0
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_SF_BACKPRESSURE_POLICY.html b/docs/variables/_questdb_nodejs-client.QWP_SF_BACKPRESSURE_POLICY.html index 21265b8..59d0adc 100644 --- a/docs/variables/_questdb_nodejs-client.QWP_SF_BACKPRESSURE_POLICY.html +++ b/docs/variables/_questdb_nodejs-client.QWP_SF_BACKPRESSURE_POLICY.html @@ -1 +1 @@ -QWP_SF_BACKPRESSURE_POLICY | QuestDB JavaScript Client - v4.2.0

    Variable QWP_SF_BACKPRESSURE_POLICYConst

    QWP_SF_BACKPRESSURE_POLICY: { ERROR: "error"; WAIT: "wait" } = ...

    Type declaration

    • ReadonlyERROR: "error"
    • ReadonlyWAIT: "wait"
    +QWP_SF_BACKPRESSURE_POLICY | QuestDB JavaScript Client - v4.2.0

    Variable QWP_SF_BACKPRESSURE_POLICYConst

    QWP_SF_BACKPRESSURE_POLICY: { ERROR: "error"; WAIT: "wait" } = ...

    Type declaration

    • ReadonlyERROR: "error"
    • ReadonlyWAIT: "wait"
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_SF_DURABILITY.html b/docs/variables/_questdb_nodejs-client.QWP_SF_DURABILITY.html index c991f64..ba88c4d 100644 --- a/docs/variables/_questdb_nodejs-client.QWP_SF_DURABILITY.html +++ b/docs/variables/_questdb_nodejs-client.QWP_SF_DURABILITY.html @@ -1 +1 @@ -QWP_SF_DURABILITY | QuestDB JavaScript Client - v4.2.0

    Variable QWP_SF_DURABILITYConst

    QWP_SF_DURABILITY: { APPEND: "append"; MEMORY: "memory"; PERIODIC: "periodic" } = ...

    Type declaration

    • ReadonlyAPPEND: "append"
    • ReadonlyMEMORY: "memory"
    • ReadonlyPERIODIC: "periodic"
    +QWP_SF_DURABILITY | QuestDB JavaScript Client - v4.2.0

    Variable QWP_SF_DURABILITYConst

    QWP_SF_DURABILITY: { APPEND: "append"; MEMORY: "memory"; PERIODIC: "periodic" } = ...

    Type declaration

    • ReadonlyAPPEND: "append"
    • ReadonlyMEMORY: "memory"
    • ReadonlyPERIODIC: "periodic"
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_STATUS.html b/docs/variables/_questdb_nodejs-client.QWP_STATUS.html index aa00259..e6eef54 100644 --- a/docs/variables/_questdb_nodejs-client.QWP_STATUS.html +++ b/docs/variables/_questdb_nodejs-client.QWP_STATUS.html @@ -1 +1 @@ -QWP_STATUS | QuestDB JavaScript Client - v4.2.0

    Variable QWP_STATUSConst

    QWP_STATUS: {
        CANCELLED: 10;
        DICTIONARY_GAP: 13;
        DURABLE_ACK: 2;
        INTERNAL_ERROR: 6;
        LIMIT_EXCEEDED: 11;
        NOT_WRITABLE: 12;
        OK: 0;
        PARSE_ERROR: 5;
        SCHEMA_MISMATCH: 3;
        SECURITY_ERROR: 8;
        SERVER_INFO: 1;
        WRITE_ERROR: 9;
    } = ...

    Type declaration

    • ReadonlyCANCELLED: 10
    • ReadonlyDICTIONARY_GAP: 13
    • ReadonlyDURABLE_ACK: 2
    • ReadonlyINTERNAL_ERROR: 6
    • ReadonlyLIMIT_EXCEEDED: 11
    • ReadonlyNOT_WRITABLE: 12
    • ReadonlyOK: 0
    • ReadonlyPARSE_ERROR: 5
    • ReadonlySCHEMA_MISMATCH: 3
    • ReadonlySECURITY_ERROR: 8
    • ReadonlySERVER_INFO: 1
    • ReadonlyWRITE_ERROR: 9
    +QWP_STATUS | QuestDB JavaScript Client - v4.2.0

    Variable QWP_STATUSConst

    QWP_STATUS: {
        CANCELLED: 10;
        DICTIONARY_GAP: 13;
        DURABLE_ACK: 2;
        INTERNAL_ERROR: 6;
        LIMIT_EXCEEDED: 11;
        NOT_WRITABLE: 12;
        OK: 0;
        PARSE_ERROR: 5;
        SCHEMA_MISMATCH: 3;
        SECURITY_ERROR: 8;
        SERVER_INFO: 1;
        WRITE_ERROR: 9;
    } = ...

    Type declaration

    • ReadonlyCANCELLED: 10
    • ReadonlyDICTIONARY_GAP: 13
    • ReadonlyDURABLE_ACK: 2
    • ReadonlyINTERNAL_ERROR: 6
    • ReadonlyLIMIT_EXCEEDED: 11
    • ReadonlyNOT_WRITABLE: 12
    • ReadonlyOK: 0
    • ReadonlyPARSE_ERROR: 5
    • ReadonlySCHEMA_MISMATCH: 3
    • ReadonlySECURITY_ERROR: 8
    • ReadonlySERVER_INFO: 1
    • ReadonlyWRITE_ERROR: 9
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_TARGET.html b/docs/variables/_questdb_nodejs-client.QWP_TARGET.html index d24f2ee..d85c236 100644 --- a/docs/variables/_questdb_nodejs-client.QWP_TARGET.html +++ b/docs/variables/_questdb_nodejs-client.QWP_TARGET.html @@ -1 +1 @@ -QWP_TARGET | QuestDB JavaScript Client - v4.2.0

    Variable QWP_TARGETConst

    QWP_TARGET: { ANY: "any"; PRIMARY: "primary"; REPLICA: "replica" } = ...

    Type declaration

    • ReadonlyANY: "any"
    • ReadonlyPRIMARY: "primary"
    • ReadonlyREPLICA: "replica"
    +QWP_TARGET | QuestDB JavaScript Client - v4.2.0

    Variable QWP_TARGETConst

    QWP_TARGET: { ANY: "any"; PRIMARY: "primary"; REPLICA: "replica" } = ...

    Type declaration

    • ReadonlyANY: "any"
    • ReadonlyPRIMARY: "primary"
    • ReadonlyREPLICA: "replica"
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_UPGRADE_ERROR_KIND.html b/docs/variables/_questdb_nodejs-client.QWP_UPGRADE_ERROR_KIND.html index 97c329c..c5a5622 100644 --- a/docs/variables/_questdb_nodejs-client.QWP_UPGRADE_ERROR_KIND.html +++ b/docs/variables/_questdb_nodejs-client.QWP_UPGRADE_ERROR_KIND.html @@ -1,2 +1,2 @@ QWP_UPGRADE_ERROR_KIND | QuestDB JavaScript Client - v4.2.0

    Variable QWP_UPGRADE_ERROR_KINDConst

    QWP_UPGRADE_ERROR_KIND: {
        AUTHENTICATION: "authentication";
        CAPABILITY_MISMATCH: "capability-mismatch";
        HTTP_REJECTED: "http-rejected";
        OPAQUE: "opaque";
        ROLE_REJECTED: "role-rejected";
        TIMEOUT: "timeout";
        TRANSPORT: "transport";
        VERSION_MISMATCH: "version-mismatch";
    } = ...

    Type declaration

    • ReadonlyAUTHENTICATION: "authentication"
    • ReadonlyCAPABILITY_MISMATCH: "capability-mismatch"
    • ReadonlyHTTP_REJECTED: "http-rejected"
    • ReadonlyOPAQUE: "opaque"

      Browser WebSocket APIs do not expose the rejected HTTP upgrade.

      -
    • ReadonlyROLE_REJECTED: "role-rejected"
    • ReadonlyTIMEOUT: "timeout"
    • ReadonlyTRANSPORT: "transport"
    • ReadonlyVERSION_MISMATCH: "version-mismatch"
    +
  • ReadonlyROLE_REJECTED: "role-rejected"
  • ReadonlyTIMEOUT: "timeout"
  • ReadonlyTRANSPORT: "transport"
  • ReadonlyVERSION_MISMATCH: "version-mismatch"
  • diff --git a/docs/variables/_questdb_nodejs-client.QWP_UPGRADE_TIMEOUT_PHASE.html b/docs/variables/_questdb_nodejs-client.QWP_UPGRADE_TIMEOUT_PHASE.html index c8626e2..e9738c2 100644 --- a/docs/variables/_questdb_nodejs-client.QWP_UPGRADE_TIMEOUT_PHASE.html +++ b/docs/variables/_questdb_nodejs-client.QWP_UPGRADE_TIMEOUT_PHASE.html @@ -1 +1 @@ -QWP_UPGRADE_TIMEOUT_PHASE | QuestDB JavaScript Client - v4.2.0

    Variable QWP_UPGRADE_TIMEOUT_PHASEConst

    QWP_UPGRADE_TIMEOUT_PHASE: {
        AUTHENTICATION: "authentication";
        CONNECT: "connect";
    } = ...

    Type declaration

    • ReadonlyAUTHENTICATION: "authentication"
    • ReadonlyCONNECT: "connect"
    +QWP_UPGRADE_TIMEOUT_PHASE | QuestDB JavaScript Client - v4.2.0

    Variable QWP_UPGRADE_TIMEOUT_PHASEConst

    QWP_UPGRADE_TIMEOUT_PHASE: {
        AUTHENTICATION: "authentication";
        CONNECT: "connect";
    } = ...

    Type declaration

    • ReadonlyAUTHENTICATION: "authentication"
    • ReadonlyCONNECT: "connect"
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_VERSION.html b/docs/variables/_questdb_nodejs-client.QWP_VERSION.html index cc5ec8b..83add5e 100644 --- a/docs/variables/_questdb_nodejs-client.QWP_VERSION.html +++ b/docs/variables/_questdb_nodejs-client.QWP_VERSION.html @@ -1 +1 @@ -QWP_VERSION | QuestDB JavaScript Client - v4.2.0
    +QWP_VERSION | QuestDB JavaScript Client - v4.2.0
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_ZSTD_MAX_COMPRESSION_LEVEL.html b/docs/variables/_questdb_nodejs-client.QWP_ZSTD_MAX_COMPRESSION_LEVEL.html index aabd1bb..7d1a60c 100644 --- a/docs/variables/_questdb_nodejs-client.QWP_ZSTD_MAX_COMPRESSION_LEVEL.html +++ b/docs/variables/_questdb_nodejs-client.QWP_ZSTD_MAX_COMPRESSION_LEVEL.html @@ -1 +1 @@ -QWP_ZSTD_MAX_COMPRESSION_LEVEL | QuestDB JavaScript Client - v4.2.0

    Variable QWP_ZSTD_MAX_COMPRESSION_LEVELConst

    QWP_ZSTD_MAX_COMPRESSION_LEVEL: 22
    +QWP_ZSTD_MAX_COMPRESSION_LEVEL | QuestDB JavaScript Client - v4.2.0

    Variable QWP_ZSTD_MAX_COMPRESSION_LEVELConst

    QWP_ZSTD_MAX_COMPRESSION_LEVEL: 22
    diff --git a/docs/variables/_questdb_nodejs-client.QWP_ZSTD_MIN_COMPRESSION_LEVEL.html b/docs/variables/_questdb_nodejs-client.QWP_ZSTD_MIN_COMPRESSION_LEVEL.html index 2a54c37..585e704 100644 --- a/docs/variables/_questdb_nodejs-client.QWP_ZSTD_MIN_COMPRESSION_LEVEL.html +++ b/docs/variables/_questdb_nodejs-client.QWP_ZSTD_MIN_COMPRESSION_LEVEL.html @@ -1 +1 @@ -QWP_ZSTD_MIN_COMPRESSION_LEVEL | QuestDB JavaScript Client - v4.2.0

    Variable QWP_ZSTD_MIN_COMPRESSION_LEVELConst

    QWP_ZSTD_MIN_COMPRESSION_LEVEL: 1
    +QWP_ZSTD_MIN_COMPRESSION_LEVEL | QuestDB JavaScript Client - v4.2.0

    Variable QWP_ZSTD_MIN_COMPRESSION_LEVELConst

    QWP_ZSTD_MIN_COMPRESSION_LEVEL: 1