diff --git a/.claude/skills/review-pr/SKILL.md b/.claude/skills/review-pr/SKILL.md new file mode 100644 index 0000000..7f5bb93 --- /dev/null +++ b/.claude/skills/review-pr/SKILL.md @@ -0,0 +1,857 @@ +--- +name: review-pr +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 +--- + +# 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: 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 +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 +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. 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. +- **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 `--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. | + +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 +changes stay high risk regardless of how small the diff is. + +## Spawning review agents + +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. + +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. + +## Step 1: Gather review context + +Every mode must end with `$BASE` and `$HEAD` identified. Behavioral findings require +the same trigger at both revisions unless the surface is genuinely new. + +### GitHub PR + +```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) +``` + +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. + +### Local range (`--range`) + +```bash +BASE='' +HEAD='' +git diff "$BASE"${HEAD:+"...$HEAD"} --stat +git diff "$BASE"${HEAD:+"...$HEAD"} +git diff "$BASE"${HEAD:+"...$HEAD"} --name-only +git status --porcelain +``` + +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. + +## Step 2: PR title and description + +Skip this step in range mode. + +Check the repository conventions in `CONTRIBUTING.md` and recent accepted PRs: + +- 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`. +- 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) + +Treat submodule gitlink changes as opaque. Detect mode `160000` pointer moves, record +the path and old/new hashes, and classify each as exactly: + +```bash +git diff --raw "$BASE"${HEAD:+"...$HEAD"} | awk '$1 ~ /^:160000/ || $2 == "160000"' +``` + +- **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. + +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. + +Repeat each `OPAQUE` verdict in Step 4 so the scope decision is auditable. If no +gitlinks changed, state `Submodules: none` in the summary. + +## Step 2.5: Map the change surface + +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. + +### 2.5a Semantic delta per changed symbol + +For every modified or added function, method, class, abstract/protected member, +exported type/constant, interface, and configuration option, record: + +- **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. + +### 2.5b Callsite inventory + +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. + +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. +- `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. +- `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. + +### 2.5c Implicit contract list + +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. + +### 2.5d Cross-context exposure list + +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 (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) 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. + +### 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. +- 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. +- 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. + +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, 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 +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. + +**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. + +## 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. 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 + +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. + +## Review checklists + +### Correctness and wire format + +- 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. + +### Buffer and byte safety + +- 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. + +### Transport, protocol, auth, and TLS + +- 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. +- 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 + +- 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. +- 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 + +- 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. +- 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, 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. + +### 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. +- 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, + 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. + +## Step 4: Output + +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. + +Every Critical and Moderate finding begins with three lines written from the completed +admission form: + +- **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. + +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. + +### Severity classification + +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/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 + 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. + +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. + +**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. + +**Minor** covers concrete cosmetics on changed lines: naming, ordering, formatting, +or comment wording. + +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. + +### Critical + +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. + +### Moderate + +List non-blocking admitted issues with the three summary lines and decisive evidence. + +### Minor + +List optional, concrete cosmetics. Omit the section when empty. + +### Adjacent findings (not blocking — file as GitHub issues) + +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: + +- **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. + +Offer to file them; never file without permission. + +### Coverage map + +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 + +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. diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7c87581..4d67c3c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -6,7 +6,11 @@ on: - main pull_request: schedule: - - cron: '15 2,10,18 * * *' + - 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: @@ -37,5 +41,125 @@ 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 + + # `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 + + - name: Linting (benchmarks) + run: pnpm lint:bench + - 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 + + # 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 + + # 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. + qwp-browser: + name: QWP browser bundle + runs-on: ubuntu-latest + timeout-minutes: 10 + 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: Browser bundle tests + run: pnpm test:qwp-browser + + 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-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" \ + '.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: { + javascriptClientCommit: $commit, + javascriptClientPrNumber: $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/.github/workflows/publish.yml b/.github/workflows/publish.yml index 2e30f98..1c91ae3 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -29,16 +29,32 @@ 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 - - name: Check for build artifacts + # Guards the emitted .d.ts row typing, which no runtime suite can see. + - name: Type-checking (built package consumer) run: | - [ -f dist/cjs/index.js ] || (echo "CJS build missing" && exit 1) - [ -f dist/es/index.mjs ] || (echo "ESM build missing" && exit 1) + 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. + - name: Check for build artifacts + run: node scripts/check-build-artifacts.mjs - name: Publish uses: JS-DevTools/npm-publish@v3 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 new file mode 100644 index 0000000..08af0a5 --- /dev/null +++ b/QWP.md @@ -0,0 +1,1510 @@ +# 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::`, `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. + +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. + +`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. + +## 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. + +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 | +| -------------------- | ------------------ | --------- | ---------------------------------------------------------------------------------------- | +| `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 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. | + +### Ingress + +| Key | Value | Default | Meaning | +| ----------------------------------------------- | ---------------- | --------- | ------------------------------------------------------------------------ | +| `auto_flush` | `on`, `off` | on | Master switch for all auto-flush triggers. | +| `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 | `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. | +| `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. | +| `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, on both ingress and egress. | +| `zone` | string | — | Preferred topology zone when ranking endpoints, on both ingress and egress. | + +### 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: + +```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, 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 +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 +const sender = await Sender.fromConfig( + "wss::addr=questdb.example:9000;token=REST_OR_OIDC_TOKEN;initial_connect_retry=async", + { + qwp: { + webSocket: { + requestDurableAck: true, + 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, + durability: "periodic", + checkpointIntervalMs: 5_000, + backpressurePolicy: "wait", + appendDeadlineMs: 30_000, + catchUpCapGapMinEscalationWindowMs: 300_000, + drainOrphans: true, + maxBackgroundDrainers: 4, + }, + }, + sender: { + awaitDurableAck: true, + autoFlushRows: 10_000, + autoFlushBytes: 4 * 1024 * 1024, + }, + session: { + reconnect: { + maxAttempts: 0, + maxDurationMs: 0, + }, + }, + }, + }, +); +``` + +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. 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, +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. + +`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"` 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 +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 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 + 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 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. + +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 +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 +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, +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 +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. + +**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. 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 +`[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 +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. + +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 +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. + +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. 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 `.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 +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. + +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 +`.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 +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 `.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 +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. + +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 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 +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 + +Use `QwpSender` directly when QWP-only column types or detailed session controls are +needed: + +```typescript +import * as qwp from "@questdb/nodejs-client/qwp"; +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, + autoFlushBytes: 4 * 1024 * 1024, + 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(); +} +``` + +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 +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. + +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 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 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 | +| `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 +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 +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: + +```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 +`ms` timestamp. `close()` publishes completed rows and waits for the committed-frame +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 +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 +`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. +`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 + +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()`) publishes the group-closing frame. +The example also waits for its cumulative durable acknowledgement because it enables +`awaitDurableAck`: + +```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 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 +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. + +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 +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. +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 +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 +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 +`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 +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), + onSenderError: (error) => { + console.error( + "QWP rejection", + error.category, + error.appliedPolicy, + error.fromFsn, + error.toFsn, + ); + }, + }, +); + +await sender.connect(); +console.info(sender.metrics); +``` + +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`. 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 + +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", + 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, + maxBatchRows: 4096, + }, + { queryTimeoutMs: 30_000, bufferPoolSize: 4 }, +); + +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(); +} +``` + +### 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; +``` + +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 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 +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. 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 +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, +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. + +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 +`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 +`QwpEgressQueryTimeoutError`, sends QWP `CANCEL`, and drains the terminal response +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. + +`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 +`QwpEgressQueryCancelTimeoutError` and closes the unusable connection instead of +leaving the session permanently occupied. + +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. + +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. + +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. + +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 +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. + +`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 +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: + +```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, + failoverUrls: ["wss://replica-2.example/read/v1"], + target: "replica", + zone: "eu-west-1a", + compression: "zstd", + compressionLevel: 3, + sessionBootstrap: { + authentication: { type: "bearer", token: oidcOrRestAccessToken }, + }, +}); +``` + +## 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: + +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. + +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`). + +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 +`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;" + "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 `-`. +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` 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: + +```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, + idleTimeoutMs: 60_000, + maxLifetimeMs: 30 * 60_000, + housekeepingIntervalMs: 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(); +} +``` + +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; +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; +`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 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 +configured directory is treated as a pool root and each stable sender slot owns a +`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 + +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 | +| `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 | +| `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 | +| `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 | +| `QwpEgressQueryTimeoutError` | The client deadline expired and cancellation began | +| `QwpEgressQueryCancelTimeoutError` | A cancelled query did not produce a terminal server response before the drain deadline | +| `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 +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 + +### 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()` 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. +- 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. 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. +- 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. + +### 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()` | +| 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()` | +| 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 | +| Egress row/buffer bounds | `maxBatchRows` and session `bufferPoolSize` | + +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. + +## 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, +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 0ce8d45..bf43535 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 | @@ -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 @@ -65,6 +65,421 @@ 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 JavaScript 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. 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: + +- 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. + +**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 +pooled client, browser authentication, delivery semantics, migration guidance, and +the public API policy. + +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(); +``` + +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 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 +pool-only keys. + +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. + +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. + +```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(); +``` + +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. + +```typescript +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. + +```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), + 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. + +```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, +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. + +```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. 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 + +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, + }, + { + 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. + ### Authentication and secure connection #### Username and password authentication with HTTP transport @@ -80,7 +495,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 @@ -114,7 +529,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 @@ -148,7 +563,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(); @@ -178,42 +593,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/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/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..0e43148 --- /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..5267855 --- /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..36a89d5 --- /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..e71a08c --- /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..335034c --- /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/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/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/package.json b/package.json index 2290156..0cc7620 100644 --- a/package.json +++ b/package.json @@ -1,31 +1,89 @@ { "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", + "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/**", "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", + "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": [ + "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" + ".": { + "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" + } }, - "require": { - "types": "./dist/cjs/index.d.ts", - "default": "./dist/cjs/index.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": { @@ -42,8 +100,11 @@ "@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", + "fzstd": "0.1.1", + "playwright": "^1.62.1", "prettier": "^3.5.3", "serve": "^14.2.4", "testcontainers": "^10.25.0", @@ -53,6 +114,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 d824b63..9040014 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,12 +24,21 @@ 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) 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 @@ -726,6 +738,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} @@ -1397,6 +1412,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} @@ -1405,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.*} @@ -1442,6 +1465,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: @@ -1868,6 +1892,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} @@ -2308,6 +2342,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: @@ -2407,6 +2442,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'} @@ -2970,6 +3017,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 @@ -3762,11 +3813,16 @@ snapshots: fs-constants@1.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true function-bind@1.1.2: {} + fzstd@0.1.1: {} + get-caller-file@2.0.5: {} get-east-asian-width@1.3.0: {} @@ -4149,6 +4205,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 @@ -4797,6 +4861,8 @@ snapshots: wrappy@1.0.2: {} + ws@8.21.3: {} + y18n@5.0.8: {} yaml@2.6.1: {} diff --git a/scripts/check-build-artifacts.mjs b/scripts/check-build-artifacts.mjs new file mode 100644 index 0000000..eaa2808 --- /dev/null +++ b/scripts/check-build-artifacts.mjs @@ -0,0 +1,101 @@ +// 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 { 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 +// 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, typesVersions } = 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); + } + } +} + +// 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 (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}`); + } +} + +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/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/bytes.ts b/src/_qwp/_core/bytes.ts new file mode 100644 index 0000000..b18eebc --- /dev/null +++ b/src/_qwp/_core/bytes.ts @@ -0,0 +1,343 @@ +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); +} + +// 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 { + 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 { + 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/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/constants.ts b/src/_qwp/_core/constants.ts new file mode 100644 index 0000000..ffd2fc2 --- /dev/null +++ b/src/_qwp/_core/constants.ts @@ -0,0 +1,130 @@ +/** 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; +/** 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; + +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, + SERVER_INFO: 0x01, + 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, + COMPRESSION: 0x00000004, +} as const; + +export const QWP_COMPRESSION_CODEC = { + RAW: 0, + ZSTD: 1, +} 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; +/** 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; +/** + * 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. + */ +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; +/** 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/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/egress.ts b/src/_qwp/_core/egress.ts new file mode 100644 index 0000000..a214c8a --- /dev/null +++ b/src/_qwp/_core/egress.ts @@ -0,0 +1,277 @@ +import { encodeUtf8, QwpByteReader, QwpByteWriter } from "./bytes"; +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"; + +export interface QwpQueryRequest { + requestId: number | bigint; + 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; + /** Advanced escape hatch for an already encoded bind section. */ + bindPayload?: Uint8Array; + /** Append only after SERVER_INFO advertises QUERY_FLAGS. */ + queryFlags?: number | bigint; +} + +/** Immutable endpoint metadata from the most recent successful egress bind. */ +export interface QwpServerInfoMessage extends QwpFrameHeader { + kind: "server-info"; + role: number; + epoch: bigint; + capabilities: number; + serverWallNanoseconds: bigint; + clusterId: string; + nodeId: string; + zoneId: string | null; + compressionCodec: number | null; + compressionLevel: number | null; +} + +export interface QwpResultBatchMessage extends QwpFrameHeader { + kind: "result-batch"; + requestId: bigint; + batchSequence: bigint; + /** + * Raw or Zstd-compressed delta dictionary and columnar table block; decoded + * by the batch decoder according to the frame flags. + */ + body: Uint8Array; +} + +export interface QwpResultEndMessage extends QwpFrameHeader { + kind: "result-end"; + requestId: bigint; + finalSequence: bigint; + totalRows: bigint; +} + +export interface QwpQueryErrorMessage extends QwpFrameHeader { + kind: "query-error"; + requestId: bigint; + status: number; + message: string; +} + +export interface QwpExecDoneMessage extends QwpFrameHeader { + kind: "exec-done"; + requestId: bigint; + operationType: number; + rowsAffected: bigint; +} + +export interface QwpCacheResetMessage extends QwpFrameHeader { + kind: "cache-reset"; + resetMask: number; +} + +export type QwpEgressMessage = + | QwpServerInfoMessage + | QwpResultBatchMessage + | QwpResultEndMessage + | QwpQueryErrorMessage + | QwpExecDoneMessage + | QwpCacheResetMessage; + +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 { + if ( + request.binds !== undefined && + (request.bindCount !== undefined || request.bindPayload !== undefined) + ) { + throw new Error( + "typed binds cannot be mixed with raw bindCount/bindPayload", + ); + } + 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"); + } + + 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; + 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 Object.freeze({ + ...header, + kind: "server-info", + role, + epoch, + capabilities, + serverWallNanoseconds, + clusterId, + nodeId, + zoneId, + compressionCodec, + compressionLevel, + }); + } + 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/identifiers.ts b/src/_qwp/_core/identifiers.ts new file mode 100644 index 0000000..017592e --- /dev/null +++ b/src/_qwp/_core/identifiers.ts @@ -0,0 +1,102 @@ +import { utf8Length } from "./bytes"; + +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 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 (utf8Length(name) > 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 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 (utf8Length(name) > 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 { + // 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); + } + return key; +} diff --git a/src/_qwp/_core/index.ts b/src/_qwp/_core/index.ts new file mode 100644 index 0000000..e7c3db3 --- /dev/null +++ b/src/_qwp/_core/index.ts @@ -0,0 +1,15 @@ +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"; +export * from "./gorilla"; +export * from "./ingress"; +export * from "./result-batch"; +export * from "./symbol-dictionary"; +export * from "./table"; +export * from "./varint"; +export * from "./zstd"; diff --git a/src/_qwp/_core/ingress.ts b/src/_qwp/_core/ingress.ts new file mode 100644 index 0000000..5aa4331 --- /dev/null +++ b/src/_qwp/_core/ingress.ts @@ -0,0 +1,757 @@ +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_DURABLE_ACK_POLL, + QWP_FLAG_GORILLA, + QWP_HEADER_SIZE, + QWP_MAX_ERROR_MESSAGE_LENGTH, + QWP_MAX_ROWS_PER_TABLE, + QWP_MAX_SYMBOL_DICTIONARY_SIZE, + QWP_STATUS, + QwpColumnType, +} from "./constants"; +import { decodeQwpFrame, writeQwpFrameHeader } from "./frame"; +import { QwpProtocolError } from "./errors"; +import { encodeQwpGorilla, qwpGorillaSize } from "./gorilla"; +import { QwpSymbolDictionary } from "./symbol-dictionary"; +import { + QwpArrayValue, + QwpColumnBuffer, + QwpSymbolValue, + QwpTableBuffer, +} from "./table"; +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 published on this logical connection. */ + confirmedMaxSymbolId?: number; + deferCommit?: boolean; +} + +interface ColumnEncodeOptions { + gorilla: boolean; + deltaSymbols: boolean; + dictionary?: QwpSymbolDictionary; +} + +export interface QwpIngressTableResult { + name: string; + sequenceTransaction: bigint; +} + +export interface QwpIngressResponse { + status: number; + sequence: bigint | null; + tables: QwpIngressTableResult[]; + 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 { + 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 { + 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; +} + +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++; + 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); + } + + // 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.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, options.dictionary!)); + } + return size; + } + 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; + } + + 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; + // 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.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, options.dictionary!)); + } + return; + } + 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: + 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 { + 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"); + } + 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; + 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, + dictionary: options.dictionary, + }; + + 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 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, +): Uint8Array { + return encodeQwpIngressFrame([], { + gorilla: false, + dictionary, + confirmedMaxSymbolId, + }); +} + +/** 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, +): 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 QwpProtocolError( + `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/result-batch.ts b/src/_qwp/_core/result-batch.ts new file mode 100644 index 0000000..7c4a309 --- /dev/null +++ b/src/_qwp/_core/result-batch.ts @@ -0,0 +1,1956 @@ +import { decodeUtf8, QwpByteReader } from "./bytes"; +import { + QWP_COLUMN_TYPE, + 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, + QwpColumnType, +} from "./constants"; +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; +// 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 { + 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]); + } + } +} + +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), + }; + } +} + +/** 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 + * 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[] = []; + private rowView?: QwpResultRowView; + + 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); + } + + /** + * 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( + 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", + ); + } + } + + private reusableRowView(): QwpResultRowView { + return (this.rowView ??= new QwpResultRowView(this)); + } +} + +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`); + } +} + +// 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, + 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; +} + +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"), + ), + }; +} + +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 viewBatches: QwpResultBatchView[] = []; + private readonly viewLayouts: QwpResultColumnViewLayout[][] = []; + private readonly viewLayoutPools: QwpResultColumnViewLayout[][] = []; + private schema?: QwpResultColumnSchema[]; + private expectedBatchSequence = 0n; + + resetQuerySchema(): void { + for (const batch of this.viewBatches) batch.release(); + this.schema = undefined; + this.expectedBatchSequence = 0n; + } + + applyCacheReset(resetMask: number): void { + if ((resetMask & QWP_RESET_MASK_DICTIONARY) !== 0) { + this.symbolDictionary.length = 0; + } + } + + /** @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); + + 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 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, 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 (viewLayoutPool.length < schema.length) { + viewLayoutPool.push(new QwpResultColumnViewLayout()); + } + viewLayouts.length = schema.length; + for (let index = 0; index < schema.length; index++) { + 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 viewBatch.reset( + message.requestId, + message.batchSequence, + tableName, + rowCount, + viewLayouts, + ); + } + + private prepare(message: QwpResultBatchMessage): PreparedResultBatch { + 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 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, body.length); + + const tableNameLength = readCount( + reader, + QWP_MAX_IDENTIFIER_BYTES, + "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_IDENTIFIER_BYTES, + "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", + ); + } + // 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 }; + } + + 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 = readDecimalScale(reader, type); + 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", + ); + 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, + 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 = readDecimalScale(reader, schema.type); + 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, + decompressedPayloadBytes: number, + ): 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`, + ); + } + // 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 ${decompressedPayloadBytes}-byte decompressed payload`, + ); + } + 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/_core/symbol-dictionary.ts b/src/_qwp/_core/symbol-dictionary.ts new file mode 100644 index 0000000..cfa70b0 --- /dev/null +++ b/src/_qwp/_core/symbol-dictionary.ts @@ -0,0 +1,62 @@ +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; + } + + 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) { + 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)); + } + + /** 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/_core/table.ts b/src/_qwp/_core/table.ts new file mode 100644 index 0000000..b1a793c --- /dev/null +++ b/src/_qwp/_core/table.ts @@ -0,0 +1,315 @@ +import { + QWP_COLUMN_TYPE, + QWP_MAX_COLUMNS_PER_TABLE, + QWP_MAX_TABLE_NAME_LENGTH, + QwpColumnType, +} from "./constants"; +import { + qwpColumnNameKey, + validateQwpColumnName, + validateQwpTableName, +} from "./identifiers"; + +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 maxNameLength: number; + 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) { + throw new RangeError("maxNameLength must be a positive safe integer"); + } + validateQwpTableName(name, maxNameLength); + this.name = name; + this.maxNameLength = maxNameLength; + } + + 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, + // 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 || + type === QWP_COLUMN_TYPE.TIMESTAMP_NANOS); + if (!name && !designatedTimestamp) { + throw new Error("column name cannot be empty"); + } + + const existing = this.columnsByName.get(nameKey); + 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 (!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}`, + ); + } + + 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(nameKey, 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(qwpColumnNameKey(column.name)); + this.columnList.splice(index, 1); + } + } + } + + /** + * 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, this.maxNameLength); + result.rows = end - start; + // `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) { + valueEnd = end; + } else { + 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(qwpColumnNameKey(sliced.name), sliced); + } + 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; + } +} + +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/_core/zstd.ts b/src/_qwp/_core/zstd.ts new file mode 100644 index 0000000..3afa49f --- /dev/null +++ b/src/_qwp/_core/zstd.ts @@ -0,0 +1,316 @@ +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; + +/** + * 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; +/** + * 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; + 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, + 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, +): 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; + 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; + } + const blocksEnd = offset; + 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}]`, + ); + } + return { lastBlockOffset, blocksEnd }; +} + +/** + * 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 { + // 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 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 + ZSTD_SIZE_SLACK_BYTES, + ); + for (let index = 0; index < 8; index++) { + reframed[5 + index] = Number(size & 0xffn); + size >>= 8n; + } + 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; + // 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.set(ZSTD_SIZE_MARKER, marker + 3); + 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[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; + const decoded = findSizeMarker(output, contentSize + ZSTD_SIZE_SLACK_BYTES); + if (decoded >= 0 && decoded !== contentSize) { + throw new QwpProtocolError( + 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( + `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); + const layout = validateSingleZstdFrame(frame, info); + let output: Uint8Array; + try { + // 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}`); + } + requireDeclaredSize(output, info.contentSize); + return output.subarray(0, info.contentSize); +} diff --git a/src/_qwp/_internal/async-queue.ts b/src/_qwp/_internal/async-queue.ts new file mode 100644 index 0000000..42a4a28 --- /dev/null +++ b/src/_qwp/_internal/async-queue.ts @@ -0,0 +1,111 @@ +interface PendingNext { + resolve: (result: IteratorResult) => void; + 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: (QueueValue | QueueBarrier)[] = []; + 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({ 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 }); + } + } + + 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 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 === "value") dropped.push(entry.value); + else entry.resolve(); + } + return dropped; + } + + /** 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"); + } + this.iteratorCreated = true; + return { + next: () => this.next(), + }; + } + + private next(): Promise> { + 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) { + return Promise.resolve({ value: undefined, done: true }); + } + return new Promise((resolve, reject) => { + 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/egress-limits.ts b/src/_qwp/_internal/egress-limits.ts new file mode 100644 index 0000000..1afc651 --- /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/egress-routing.ts b/src/_qwp/_internal/egress-routing.ts new file mode 100644 index 0000000..5d15aec --- /dev/null +++ b/src/_qwp/_internal/egress-routing.ts @@ -0,0 +1,140 @@ +import { + decodeQwpEgressMessage, + QWP_SERVER_ROLE, + QwpProtocolError, +} from "../_core"; +import { + QwpBinaryConnection, + QwpConnectionFactory, + QwpSendClosedError, +} from "../transport"; +import { + createQwpFailoverConnectionFactory, + QwpFailoverSelectionOptions, + 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, + signal?: AbortSignal, + ) => Promise, + routing: QwpFailoverSelectionOptions, + 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, + get ingressSymbolDictionary() { + return connection.ingressSymbolDictionary; + }, + get ingressDeltaSymbolDictionaryEnabled() { + return connection.ingressDeltaSymbolDictionaryEnabled; + }, + 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 new file mode 100644 index 0000000..c6d0ee9 --- /dev/null +++ b/src/_qwp/_internal/failover.ts @@ -0,0 +1,451 @@ +import { + QWP_TARGET, + QWP_UPGRADE_ERROR_KIND, + QwpBinaryConnection, + QwpConnectionFactory, + QwpEgressRoutingOptions, + QwpFailoverAttempt, + QwpFailoverError, + QwpRoleMismatchError, + QwpTarget, + QwpUpgradeError, +} from "../transport"; + +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; + 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 { + 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; + /** @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), + ); +} + +/** + * 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, + signal?: AbortSignal, + ) => Promise, + options: QwpFailoverSelectionOptions = {}, +): QwpConnectionFactory { + const endpoints = [preferredUrl, ...(failoverUrls ?? [])]; + const target = normalizeTarget(options.target); + const configuredZone = normalizeZone(options.zone); + 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 (signal?: AbortSignal): Promise => { + if ( + resetClassificationsBeforeSweep && + resetClassificationsAfterExhaustion + ) { + healthTracker.forgetClassifications(); + } + resetClassificationsBeforeSweep = false; + const attempts: QwpFailoverAttempt[] = []; + const deferredForSweep = deferredEndpoint; + deferredEndpoint = undefined; + const cursor = healthTracker.newRoundCursor(deferredForSweep); + + while (true) { + const index = cursor.next(); + if (index === undefined) break; + const endpoint = endpoints[index]; + let candidate: QwpBinaryConnection | undefined; + try { + candidate = await connect(endpoint, signal); + 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; + healthTracker.recordZone(index, validated.serverZone); + if (!matchesTarget(validated.serverRole, target)) { + throw new QwpRoleMismatchError( + target, + validated.serverRole, + endpoint, + validated.serverZone, + ); + } + healthTracker.recordSuccess(index); + resetClassificationsBeforeSweep = cursor.exhausted; + return observeConnectionHealth( + candidate, + () => { + healthTracker.recordMidStreamFailure(index); + }, + () => { + healthTracker.recordTransientReject(index); + deferredEndpoint = index; + }, + ); + } catch (error) { + healthTracker.recordFailure(index, error); + attempts.push({ endpoint, error }); + if (candidate) await candidate.close().catch(() => undefined); + // 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; + } + } + } + resetClassificationsBeforeSweep = true; + if (attempts.length === 1) throw attempts[0].error; + 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); + // 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" || + normalized === "PRIMARY_CATCHUP" || + normalized === "STANDALONE" + ); +} + +function pickNextEndpoint( + health: readonly QwpEndpointHealth[], + attempted: ReadonlySet, + deferredEndpoint?: number, +): number | undefined { + let selected = -1; + for (let index = 0; index < health.length; index++) { + if (attempted.has(index) || index === deferredEndpoint) continue; + if (selected < 0 || compareHealth(health[index], health[selected]) < 0) { + selected = index; + } + } + if (selected >= 0) return selected; + if (deferredEndpoint !== undefined && !attempted.has(deferredEndpoint)) { + return deferredEndpoint; + } + return undefined; +} + +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 observeConnectionHealth( + connection: QwpBinaryConnection, + demoteEndpoint: () => void, + deprioritizeEndpoint: () => void, +): QwpBinaryConnection { + void connection.closed.then((info) => { + if (!info.wasClean) demoteEndpoint(); + }, demoteEndpoint); + const observed: QwpBinaryConnection = { + messages: connection.messages, + closed: connection.closed, + handshake: connection.handshake, + endpoint: connection.endpoint, + get ingressSymbolDictionary() { + return connection.ingressSymbolDictionary; + }, + get ingressDeltaSymbolDictionaryEnabled() { + return connection.ingressDeltaSymbolDictionaryEnabled; + }, + deprioritizeEndpoint, + send: async (payload) => { + try { + await connection.send(payload); + } catch (error) { + demoteEndpoint(); + throw error; + } + }, + close: (code, reason) => connection.close(code, reason), + }; + if (connection.ping) { + observed.ping = async () => { + try { + await connection.ping!(); + } catch (error) { + demoteEndpoint(); + throw error; + } + }; + } + if (connection.getIngressMetrics) { + observed.getIngressMetrics = () => connection.getIngressMetrics!(); + } + return observed; +} + +function endpointKeys( + preferredUrl: string | URL, + failoverUrls: readonly (string | URL)[] | undefined, +): readonly string[] { + return [preferredUrl, ...(failoverUrls ?? [])].map(String); +} diff --git a/src/_qwp/_internal/notification-dispatcher.ts b/src/_qwp/_internal/notification-dispatcher.ts new file mode 100644 index 0000000..3eb1d7b --- /dev/null +++ b/src/_qwp/_internal/notification-dispatcher.ts @@ -0,0 +1,143 @@ +import { isPromiseLike } from "./safe-callback"; + +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.then(undefined, () => 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 unrefTimer(timer: ReturnType): void { + (timer as ReturnType & { unref?: () => void }).unref?.(); +} 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 new file mode 100644 index 0000000..0131eac --- /dev/null +++ b/src/_qwp/_internal/reconnecting-egress-connection.ts @@ -0,0 +1,729 @@ +import { + decodeQwpEgressMessage, + QWP_EGRESS_MESSAGE, + QwpProtocolError, + QwpServerInfoMessage, +} from "../_core"; +import { + QWP_RECONNECT_EVENT_KIND, + QWP_UPGRADE_ERROR_KIND, + QwpBinaryConnection, + QwpConnectionCloseInfo, + QwpConnectionFactory, + QwpEgressReplayResetEvent, + QwpFailoverError, + QwpHandshakeMetadata, + QwpReconnectEvent, + QwpReconnectExhaustedError, + QwpReconnectOptions, + QwpSendClosedError, + QwpUpgradeError, +} from "../transport"; +import { QwpAsyncQueue } from "./async-queue"; +import { jitterReconnectDelayMs } from "./reconnect-backoff"; +import { safelyInvoke } from "./safe-callback"; + +type ReplayResetHandler = ( + event: QwpEgressReplayResetEvent, +) => void | Promise; +type ConnectionResetHandler = ( + serverInfo: QwpServerInfoMessage, +) => void | Promise; +type QueryRequestEncoder = ( + serverInfo: QwpServerInfoMessage, + requestId: bigint, +) => Uint8Array | Promise; + +class ReplayResetCallbackError extends Error { + readonly cause: unknown; + + constructor(cause: unknown) { + super("QWP egress replay reset callback failed"); + this.name = "ReplayResetCallbackError"; + this.cause = cause; + } +} + +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 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 connectAbort?: AbortController; + private lastHandshake?: QwpHandshakeMetadata; + private lastEndpoint?: string | URL; + 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; + 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 encodeQueryRequest: QueryRequestEncoder, + private readonly onReplayReset?: ReplayResetHandler, + private readonly retryInitialConnection = true, + ) { + 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, + 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, + encodeQueryRequest: QueryRequestEncoder, + onReplayReset?: ReplayResetHandler, + retryInitialConnection = true, + ): Promise { + const reconnecting = new QwpReconnectingEgressConnection( + factory, + reconnectOptions, + serverInfoTimeoutMs, + onConnectionReset, + encodeQueryRequest, + onReplayReset, + retryInitialConnection, + ); + 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(); + const prepared = await this.prepareOutboundQuery(copy); + this.trackOutbound(prepared); + try { + await connection.send(prepared); + } 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; + // 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; + 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, + skipQueueBarrier = false, + ): 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 (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(jitterReconnectDelayMs(backoffMs)); + backoffMs = Math.min(Math.max(backoffMs * 2, 1), this.maxBackoffMs); + } + this.throwIfUnavailable(); + attempt++; + let candidate: QwpBinaryConnection | undefined; + try { + 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); + 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, + serverInfo, + previousEndpoint, + initialCause, + skipQueueBarrier, + ); + } 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) { + 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, + }); + } else { + this.emitEvent({ + kind: QWP_RECONNECT_EVENT_KIND.CONNECTED, + attempt: 0, + endpoint: candidate.endpoint, + }); + } + 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; + if (!reconnecting && !this.retryInitialConnection) 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" + ) { + const activeRequestId = replayRequestId(this.outboundReplay); + if (activeRequestId === message.requestId) 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; + } + await this.requestReconnect( + error, + connection, + error instanceof QwpProtocolError ? 1002 : 1000, + error instanceof QwpProtocolError ? "invalid QWP egress message" : "", + ).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 ReplayStateError( + "QWP reconnect started before the initial SERVER_INFO was received", + ); + } + 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, + 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. + if (skipQueueBarrier) this.messagesQueue.clear(); + else await this.messagesQueue.barrier(); + 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(serverInfo); + const requestId = replayRequestId(this.outboundReplay); + if (requestId === undefined) { + throw new ReplayStateError( + "QWP egress replay is missing its QUERY_REQUEST", + ); + } + if (this.onReplayReset) { + try { + await this.onReplayReset({ + requestId, + serverInfo, + previousEndpoint, + endpoint: connection.endpoint, + cause, + }); + } catch (error) { + throw new ReplayResetCallbackError(error); + } + } + const request = await this.encodeReplayRequest(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.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(); + // 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, + 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: + 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: + 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, + closeCode = 1000, + closeReason = "", + skipQueueBarrier = false, + ): 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, + closeCode, + closeReason, + skipQueueBarrier, + ); + } + return; + } + + this.connection = undefined; + 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; + } 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: Omit): void { + // 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 { + 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 validateEncodedRequest( + payload: Uint8Array, + expectedRequestId: bigint, +): void { + const requestId = replayRequestId([payload]); + if (requestId !== expectedRequestId) { + throw new ReplayStateError( + `QWP query encoder returned the wrong request [expected=${expectedRequestId}, actual=${requestId ?? "missing"}]`, + ); + } +} + +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 ReplayStateError || + error instanceof ReplayResetCallbackError + ); +} diff --git a/src/_qwp/_internal/reconnecting-ingress-connection.ts b/src/_qwp/_internal/reconnecting-ingress-connection.ts new file mode 100644 index 0000000..2693807 --- /dev/null +++ b/src/_qwp/_internal/reconnecting-ingress-connection.ts @@ -0,0 +1,2299 @@ +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, + qwpVarintSize, + utf8Length, +} from "../_core"; +import { + QWP_INITIAL_CONNECT_MODE, + QWP_RECONNECT_EVENT_KIND, + QWP_UPGRADE_ERROR_KIND, + QwpBinaryConnection, + QwpConnectionCloseInfo, + QwpConnectionFactory, + QwpDurableAckUnavailableError, + QwpFailoverError, + QwpHandshakeMetadata, + QwpIngressReplayRecord, + QwpIngressReplayReference, + QwpIngressReplayStore, + QwpIngressTransportMetrics, + QwpInitialConnectMode, + QwpMemoryReplayAppendTimeoutError, + QwpMemoryReplayFrameTooLargeError, + QwpReconnectEvent, + QwpReconnectExhaustedError, + QwpReconnectOptions, + QwpReplayDictionaryError, + QwpReplayDictionaryPersistenceError, + QwpReplayRejectedError, + QwpSendClosedError, + QwpUnrecoverableReplayDictionaryError, + QwpUpgradeError, +} from "../transport"; +import { QwpAsyncQueue } from "./async-queue"; +import { jitterReconnectDelayMs } from "./reconnect-backoff"; +import { QwpNotificationDispatcher } from "./notification-dispatcher"; +import { + createQwpProtocolViolationSenderError, + createQwpSenderError, + defaultQwpSenderErrorHandler, + 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; +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"; + +export 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"; + } +} + +export 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 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; + transmitted: boolean; + durableTargets?: Map; + dictionaryCatchup?: boolean; +} + +type LoadedReplayRecord = QwpIngressReplayReference & { + readonly payload?: Uint8Array; +}; + +type LazyReplayStore = QwpIngressReplayStore & + Required>; + +interface RecoveredDiscardTail { + readonly startSequence: bigint; + readonly tipSequence: bigint; + readonly predecessorSequence?: bigint; +} + +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 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[] = []; + 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]) => ({ + frameSequence, + payload: payload.slice(), + })); + } + + async append(record: QwpIngressReplayRecord): Promise { + 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 { + 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 { + 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(); + } +} + +/** + * 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 symbolDictionary: string[]; + private readonly store: QwpIngressReplayStore; + private readonly lazyReplayStore?: LazyReplayStore; + private readonly maxAttempts: number; + private readonly initialBackoffMs: number; + private readonly maxBackoffMs: number; + private readonly maxDurationMs: number; + 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; + 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 + // 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; + private acknowledgedFrameSequence = -1n; + private highestOkFrameSequence = -1n; + private poisonFrameSequence?: bigint; + private poisonFirstStrikeMs = 0; + private poisonStrikes = 0; + private catchUpCapGapAttempts = 0; + private catchUpCapGapFirstMs = 0; + private durableAckMismatchAttempts = 0; + private durableAckMismatchFirstMs = 0; + private progressAtLastExemptRecycle = -1n; + private zeroProgressRecycles = 0; + private recoveredDiscardTail?: RecoveredDiscardTail; + private generation = 0; + private sendTail: Promise = Promise.resolve(); + private drainTail: Promise = Promise.resolve(); + private reconnectTask?: Promise; + private storeClosePromise?: Promise; + private terminalError?: Error; + 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; + private hasEverConnected = false; + private deltaSymbolDictionaryEnabled: boolean; + readonly messages: AsyncIterable = this.messagesQueue; + readonly closed: Promise; + readonly managesIngressSenderErrors = true; + ping?: () => Promise; + + private constructor( + private readonly factory: QwpConnectionFactory, + private readonly reconnectOptions: QwpReconnectOptions, + store: QwpIngressReplayStore, + records: readonly LoadedReplayRecord[], + symbolDictionary: readonly string[], + recoveredDiscardTail: RecoveredDiscardTail | undefined, + 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, + onSenderError?: (error: QwpSenderError) => void, + ) { + this.store = store; + this.lazyReplayStore = isLazyReplayStore(store) ? store : undefined; + this.symbolDictionary = [...symbolDictionary]; + this.deltaSymbolDictionaryEnabled = + store.loadSymbolDictionary !== undefined && + store.appendSymbolDictionary !== undefined; + this.recoveredDiscardTail = recoveredDiscardTail; + this.localMaxBatchSizeBytes = localMaxBatchSizeBytes; + 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; + this.poisonMinEscalationWindowMs = + reconnectOptions.poisonMinEscalationWindowMs ?? 5_000; + this.catchUpCapGapMinEscalationWindowMs = + catchUpCapGapMinEscalationWindowMs; + this.orphanDurableAckMismatchMaxDurationMs = + orphanDurableAckMismatchMaxDurationMs; + if (reconnectOptions.onEvent) { + this.connectionDispatcher = new QwpNotificationDispatcher( + reconnectOptions.onEvent, + connectionListenerInboxCapacity, + ); + } + this.errorDispatcher = new QwpNotificationDispatcher( + onSenderError ?? defaultQwpSenderErrorHandler, + errorInboxCapacity, + ); + validateReconnectPolicy( + this.maxAttempts, + this.initialBackoffMs, + this.maxBackoffMs, + this.maxDurationMs, + this.maxFrameRejections, + this.poisonMinEscalationWindowMs, + this.catchUpCapGapMinEscalationWindowMs, + ); + 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", + ); + } + 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, + payloadLength: record.payloadLength, + payload: record.payload?.slice(), + ackDelivered: true, + transmitted: true, + }; + this.frames.set(frame.frameSequence, frame); + previous = frame.frameSequence; + } + if (records.length > 0) { + this.acknowledgedFrameSequence = records[0].frameSequence - 1n; + } + this.nextFrameSequence = previous + 1n; + this.publishedFrameSequence = previous; + } + + static async connect( + factory: QwpConnectionFactory, + 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 + : 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, + errorInboxCapacity = 256, + onSenderError?: (error: QwpSenderError) => void, + signal?: AbortSignal, + ): Promise { + const store: QwpIngressReplayStore = + replayStore ?? + new QwpMemoryReplayStore( + memoryReplayMaxBytes, + 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 + ? await lazyStore.loadReferences() + : (await store.load()).map((record) => ({ + ...record, + payloadLength: record.payload.byteLength, + })); + const sortedRecords = [...records].sort((a, b) => + a.frameSequence < b.frameSequence + ? -1 + : a.frameSequence > b.frameSequence + ? 1 + : 0, + ); + 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 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, + persistedSymbolDictionaryFailure, + ); + connection = new QwpReconnectingIngressConnection( + factory, + reconnectOptions, + store, + sortedRecords, + symbolDictionary, + recoveredDiscardTail, + localMaxBatchSizeBytes, + backgroundStoreAndForward, + orphanStoreAndForward, + orphanDurableAckMismatchMaxDurationMs, + catchUpCapGapMinEscalationWindowMs, + connectionListenerInboxCapacity, + errorInboxCapacity, + onSenderError, + ); + // 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, + ); + } + } finally { + signal?.removeEventListener("abort", onAbort); + } + if (signal?.aborted) throw abortError(); + return connection; + } catch (error) { + await connection?.close().catch(() => undefined); + if (!connection) { + const opened = await initialConnection?.catch(() => undefined); + await opened?.close().catch(() => undefined); + await store.close().catch(() => undefined); + } + throw error; + } + } + + /** 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 }; + throw new Error("QWP connection is not established"); + } + return this.lastHandshake; + } + + get endpoint(): string | URL | undefined { + return this.lastEndpoint; + } + + get ingressSymbolDictionary(): readonly string[] { + return this.symbolDictionary.slice(); + } + + get ingressDeltaSymbolDictionaryEnabled(): boolean { + return this.deltaSymbolDictionaryEnabled; + } + + getIngressMetrics(): QwpIngressTransportMetrics { + let pendingReplayBytes = 0; + for (const frame of this.frames.values()) { + pendingReplayBytes += frame.payloadLength; + } + 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, + totalBytesReplayed: this.totalBytesReplayed, + totalReconnectAttempts: this.totalReconnectAttempts, + totalReconnectsSucceeded: this.totalReconnectsSucceeded, + 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; + } + + 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++; + } + + send(payload: Uint8Array): Promise { + if (this.terminalError) return Promise.reject(this.terminalError); + if (this.closing) return Promise.reject(new QwpSendClosedError()); + const frame: ReplayFrame = { + // 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, + ackDelivered: false, + transmitted: false, + }; + const publishing = this.sendTail.then(async () => { + this.throwIfUnavailable(); + const delta = readSymbolDictionaryDelta(frame.payload!); + 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); + } + // 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, + payload: frame.payload!, + }); + this.nextFrameSequence = frameSequence + 1n; + frame.frameSequence = frameSequence; + this.frames.set(frame.frameSequence, frame); + this.publishedFrameSequence = frame.frameSequence; + if (this.backgroundStoreAndForward) { + if (this.lazyReplayStore) frame.payload = undefined; + this.enqueueDrain(frame); + return; + } + try { + await this.transmit(frame); + } catch (error) { + this.failTerminal(error); + throw error; + } + }); + 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, "unbounded"); + 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 { + if (this.closing) { + await this.closed; + return; + } + this.closing = true; + 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; + 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); + } + try { + await this.closeStore(); + } finally { + this.releaseMemoryReplayReferences(); + await Promise.all([ + this.connectionDispatcher?.close(), + this.errorDispatcher?.close(), + ]); + this.settleClosed(closeInfo); + } + } + + private async connectLoop( + initialCause: unknown, + reconnecting: boolean, + attemptPolicy: ConnectAttemptPolicy = this.backgroundStoreAndForward + ? "unbounded" + : "configured", + initialConnection?: Promise, + ): Promise { + const outageStarted = Date.now(); + const previousEndpoint = this.lastEndpoint; + let attempt = 0; + let backoffMs = this.initialBackoffMs; + let lastError = initialCause; + let primaryUnavailableAttempts = 0; + if (reconnecting) { + this.emitEvent({ + kind: QWP_RECONNECT_EVENT_KIND.RECONNECTING, + attempt: 0, + previousEndpoint, + cause: initialCause, + }); + } + + const initialRetryDelayMs = reconnectDelayMs(initialCause); + if (initialRetryDelayMs > 0) { + 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(jitterReconnectDelayMs(backoffMs)); + backoffMs = Math.min(Math.max(backoffMs * 2, 1), this.maxBackoffMs); + } + this.throwIfUnavailable(); + attempt++; + if (reconnecting) this.totalReconnectAttempts++; + let candidate: QwpBinaryConnection | undefined; + try { + 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) { + 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.resetCatchUpCapGapEpisode(); + this.resetDurableAckMismatchEpisode(); + this.connectingCandidate = undefined; + if (reconnecting) { + this.totalReconnectsSucceeded++; + const failedOver = + previousEndpoint !== undefined && + String(previousEndpoint) !== String(candidate.endpoint); + if (failedOver) this.totalFailovers++; + this.emitEvent({ + 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; + } + if (candidate) await candidate.close().catch(() => undefined); + this.emitEvent({ + kind: QWP_RECONNECT_EVENT_KIND.ATTEMPT_FAILED, + attempt, + endpoint: candidate?.endpoint, + previousEndpoint, + cause: 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; + } + 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" && + 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); + } + } + } + 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 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 && + !this.orphanStoreAndForward && + this.hasEverConnected && + isEndpointPolicyFailure(error) + ) { + return true; + } + return isRetryableReconnectError(error); + } + + private async replayInto( + connection: QwpBinaryConnection, + ): Promise { + const replayed: ReplayFrame[] = []; + 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, + payloadLength: payload.byteLength, + ackDelivered: true, + transmitted: true, + dictionaryCatchup: true, + }; + replayed.push(frame); + await this.sendPhysical(connection, payload, false); + } + for (const frame of this.frames.values()) { + if (!frame.transmitted) continue; + if (this.isRecoveredDiscardFrame(frame.frameSequence)) continue; + frame.durableTargets = undefined; + if (cap !== undefined && frame.payloadLength > cap) { + throw new RangeError( + `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, payload, true); + } + return replayed; + } + + private install( + connection: QwpBinaryConnection, + wireFrames: ReplayFrame[], + ): void { + this.hasEverConnected = true; + this.connection = connection; + 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. + 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 || + 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. + 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. + // 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") + .catch(() => undefined); + return; + } + if (translated) this.messagesQueue.push(translated); + if (this.terminalError) return; + } + if ( + this.closing || + this.connection !== connection || + generation !== this.generation + ) { + return; + } + const info = await connection.closed; + const cause = this.classifyConnectionLoss( + new QwpSendClosedError(info), + 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 ( + 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; + } + 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); + }); + } + } + + 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) { + throw new QwpProtocolError( + `QWP response sequence is negative: ${response.sequence}`, + ); + } + const highestWireIndex = this.wireFramesBase + this.wireFrames.length - 1; + 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) { + // 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(); + this.emitSenderError( + createQwpSenderError(response, { + messageSequence: response.sequence ?? undefined, + fromFsn: pending?.from, + toFsn: pending?.to, + }), + ); + if (isRetriableIngressStatus(response.status)) { + throw new RetriableIngressNackError( + -1n, + response.status, + this.nextExemptRecycleDelay(), + response.errorMessage, + ); + } + throw new QwpProtocolError( + `QuestDB rejected ingress before any frame was sent [status=0x${response.status.toString(16)}]${ + response.errorMessage ? `: ${response.errorMessage}` : "" + }`, + ); + } + + if (response.status === QWP_STATUS.OK) { + if (frame.dictionaryCatchup) return undefined; + const covered = this.wireFrames.slice(0, localIndex + 1); + const clientTarget = findLastClientFrame(covered); + const shouldDeliver = covered.some( + (candidate) => + candidate.clientSequence !== undefined && !candidate.ackDelivered, + ); + for (const candidate of covered) candidate.ackDelivered = true; + 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) => [ + table.name, + table.sequenceTransaction, + ]), + ); + await this.trimDurablePrefix(); + } 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; + } + return rewriteResponseSequence(payload, clientTarget.clientSequence); + } + + 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 = + 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)) { + this.emitSenderError( + createQwpProtocolViolationSenderError( + `frame remained rejected after ${this.poisonStrikes} attempts${ + response.errorMessage ? `: ${response.errorMessage}` : "" + }`, + frame.frameSequence, + ), + ); + throw new QwpReplayRejectedError( + frame.frameSequence, + response.status, + `frame remained rejected after ${this.poisonStrikes} attempts${ + response.errorMessage ? `: ${response.errorMessage}` : "" + }`, + ); + } + throw new RetriableIngressNackError( + frame.frameSequence, + response.status, + 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, + 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()) { + // 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; + } + lastCovered = frame.frameSequence; + } + 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"; + 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( + 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(); + } + + private async acknowledgeStoredFramesThrough( + frameSequence: bigint, + ): Promise { + await this.store.acknowledgeThrough(frameSequence); + for (const sequence of this.frames.keys()) { + if (sequence > frameSequence) break; + this.frames.delete(sequence); + } + if (frameSequence > this.acknowledgedFrameSequence) { + this.acknowledgedFrameSequence = frameSequence; + } + } + + 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 { + 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; + try { + await this.store.appendSymbolDictionary(startId, newEntries); + } catch (error) { + this.deltaSymbolDictionaryEnabled = false; + throw new QwpReplayDictionaryPersistenceError(error); + } + this.symbolDictionary.push(...newEntries); + } + + 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, + ); + if (cap !== undefined && frame.payloadLength > 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 true; + } + 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 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); + try { + 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; + } + return true; + } + + 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( + 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(); + 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; + } + + 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( + cause, + true, + this.backgroundStoreAndForward ? "unbounded" : "configured", + ); + 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: Omit): void { + 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 { + 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.closeStore() + .catch(() => undefined) + .finally(() => this.releaseMemoryReplayReferences()); + 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); + } + + private closeStore(): Promise { + if (!this.storeClosePromise) { + this.storeClosePromise = Promise.resolve().then(() => this.store.close()); + } + return this.storeClosePromise; + } + + private releaseMemoryReplayReferences(): void { + if (!(this.store instanceof QwpMemoryReplayStore)) return; + this.frames.clear(); + this.wireFrames = []; + this.wireFramesBase = 0; + this.symbolDictionary.length = 0; + this.durableWatermarks.clear(); + } +} + +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 ( + payload.byteLength < QWP_HEADER_SIZE || + (payload[5] & QWP_FLAG_DELTA_SYMBOL_DICTIONARY) === 0 + ) { + return undefined; + } + return decodeQwpIngressSymbolDictionaryDelta(payload); +} + +async function analyzeRecoveredDiscardTail( + records: readonly LoadedReplayRecord[], + loadPayload: (record: LoadedReplayRecord) => Promise, +): Promise { + let boundaryIndex = -1; + for (let index = 0; index < records.length; index++) { + if (isRecoveredCommitBarrier(await loadPayload(records[index]))) { + 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; + } +} + +async function recoverSymbolDictionary( + records: readonly LoadedReplayRecord[], + loadPayload: (record: LoadedReplayRecord) => Promise, + persistedDictionary: readonly string[], + discardTail: RecoveredDiscardTail | undefined, + store: QwpIngressReplayStore, + persistedDictionaryFailure?: unknown, +): Promise { + const hasDictionaryPersistence = + store.loadSymbolDictionary !== undefined && + store.appendSymbolDictionary !== undefined; + let recoveredFromPersisted = true; + let dictionary: string[]; + try { + dictionary = await reconstructSymbolDictionary( + records, + loadPayload, + 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 = await reconstructSymbolDictionary( + records, + loadPayload, + [], + 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; +} + +async function reconstructSymbolDictionary( + records: readonly LoadedReplayRecord[], + loadPayload: (record: LoadedReplayRecord) => Promise, + baseline: readonly string[], + discardTail: RecoveredDiscardTail | undefined, + hasDictionaryPersistence: boolean, + recoveryCause?: unknown, +): Promise { + const dictionary = [...baseline]; + const dictionaryIds = new Map(dictionary.map((entry, id) => [entry, id])); + 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. + if ( + discardTail !== undefined && + record.frameSequence >= discardTail.startSequence + ) { + break; + } + let delta: ReturnType; + try { + delta = readSymbolDictionaryDelta(await loadPayload(record)); + } catch (error) { + throw new QwpUnrecoverableReplayDictionaryError( + `persisted QWP frame contains an invalid symbol dictionary delta [sequence=${record.frameSequence}]`, + recoveryCause ?? error, + ); + } + if (!delta) continue; + if (!hasDictionaryPersistence) { + throw new QwpUnrecoverableReplayDictionaryError( + "persisted QWP delta frames require a replay store with dictionary persistence", + ); + } + 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) => { + const id = delta.startId + index; + const existing = dictionary[id]; + if (existing !== undefined && existing !== entry) { + throw new QwpUnrecoverableReplayDictionaryError( + `persisted QWP frame conflicts with symbol dictionary at ID ${id}`, + recoveryCause, + ); + } + 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}`, + recoveryCause, + ); + } + dictionary.push(entry); + dictionaryIds.set(entry, id); + } + }); + } + return dictionary; +} + +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) { + 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( + 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 monotonicNowMs(): number { + return typeof performance === "undefined" ? Date.now() : performance.now(); +} + +function validateReconnectPolicy( + maxAttempts: number, + initialBackoffMs: number, + maxBackoffMs: number, + maxDurationMs: number, + maxFrameRejections: number, + poisonMinEscalationWindowMs: number, + catchUpCapGapMinEscalationWindowMs: 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], + ["poisonMinEscalationWindowMs", poisonMinEscalationWindowMs], + ["catchUpCapGapMinEscalationWindowMs", catchUpCapGapMinEscalationWindowMs], + ] 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), + ); + } + 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 + ); +} + +function isEndpointPolicyFailure(error: unknown): boolean { + if (error instanceof QwpUpgradeError) return true; + return ( + error instanceof QwpFailoverError && + error.attempts.some((attempt) => isEndpointPolicyFailure(attempt.error)) + ); +} + +/** 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 + ? error.retryDelayMs + : 0; +} + +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/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/_internal/websocket-connection.ts b/src/_qwp/_internal/websocket-connection.ts new file mode 100644 index 0000000..206b6f8 --- /dev/null +++ b/src/_qwp/_internal/websocket-connection.ts @@ -0,0 +1,623 @@ +import { QwpProtocolError } from "../_core"; +import { + QWP_UPGRADE_ERROR_KIND, + QWP_UPGRADE_TIMEOUT_PHASE, + QwpBinaryConnection, + QwpConnectionCloseInfo, + QwpHandshakeMetadata, + QwpSendClosedError, + QwpSendError, + QwpSendTimeoutError, + QwpUpgradeError, +} 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; + /** 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; + /** 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", + 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; + /** 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; + /** 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; + /** 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; + /** + * 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; +const WEBSOCKET_CLOSED = 3; +const BUFFERED_AMOUNT_POLL_MS = 4; +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) { + 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); + 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. */ +/** Absorbs a socket `error` raised before the real listeners are attached. */ +const ignoreSocketError = (): void => undefined; + +export function openQwpWebSocket( + socket: QwpWebSocketLike, + options: QwpWebSocketOpenOptions, +): Promise { + try { + 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 { + // Configuration validation remains authoritative. + } + return Promise.reject(error); + } + const connectTimeoutMs = options.connectTimeoutMs ?? 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; + + 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(); + 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; + rejectActiveSend?.(terminalSendError); + return terminalSendError; + }; + + 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, + }); + } + } + 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 => { + 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) => { + 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, + closeCode = 1000, + closeReason = "QWP upgrade failed", + ): void => { + 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", + ); + }; + // 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) { + abortedBeforeListening = true; + } else { + options.signal.addEventListener("abort", abortOpening, { once: true }); + } + } + + 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; + 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; + if (timeout) clearTimeout(timeout); + options.signal?.removeEventListener("abort", abortOpening); + 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"); + } + socket.ping!(); + }; + } + resolve(connection); + }; + + 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); + void closeSocket(1002, "invalid QWP payload"); + }); + }; + + 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, + }), + ); + }); + + const onError = (event: unknown): void => { + 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")); + abortAfterSendFailure(); + 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); + }; + + 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"), + ); + } + // 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/src/_qwp/client.ts b/src/_qwp/client.ts new file mode 100644 index 0000000..fa220e3 --- /dev/null +++ b/src/_qwp/client.ts @@ -0,0 +1,898 @@ +import { + QwpEgressQuery, + QwpEgressQueryOptions, + QwpEgressSession, + QwpEgressViewQuery, + QwpResultBatchViewHandler, +} 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 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; + +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; + /** 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. + */ + acquireTimeoutMs?: number; +} + +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; + 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; + 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; +} + +interface PoolWaiter { + readonly resolve: () => void; + readonly reject: (error: unknown) => void; + 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 destroyingSlots = new Set(); + 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; + + constructor( + private readonly resource: "sender" | "query", + 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, + private readonly closeLeasedOnShutdown = false, + private readonly slotReservation?: QwpPoolSlotReservation, + ) { + this.unsubscribeSlotAvailability = slotReservation?.onAvailable(() => + this.wakeWaiters(), + ); + } + + 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); + this.pendingLeaseTeardowns++; + try { + await this.destroyRetired(entry); + } finally { + this.pendingLeaseTeardowns--; + this.wakeWaiters(); + this.wakeCloseWaiters(); + } + return; + } + entry.idleSinceMs = Date.now(); + this.available.push(entry); + this.wakeWaiters(); + this.wakeCloseWaiters(); + } + + close(): Promise { + if (!this.closePromise) this.closePromise = this.closeNow(); + 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; + this.unsubscribeSlotAvailability?.(); + for (const waiter of this.waiters) { + if (waiter.timer) clearTimeout(waiter.timer); + waiter.reject(new QwpClientClosedError()); + } + this.waiters.clear(); + // 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); + 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( + 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 (leasedTeardown) { + await leasedTeardown; + this.wakeCloseWaiters(); + return; + } + await this.waitForLeases( + Math.min(this.acquireTimeoutMs, MAX_CLOSE_LEASE_WAIT_MS), + ); + } + + private reserveSlot(): number | undefined { + 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) && + !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; + } + } + return undefined; + } + + private async createLeased(slot: number): Promise> { + let finishCreation!: () => void; + const operation = new Promise((resolve) => { + finishCreation = resolve; + }); + this.creationOperations.add(operation); + let retained = false; + 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 nowMs = Date.now(); + const entry: PoolEntry = { + slot, + value, + createdAtMs: nowMs, + idleSinceMs: nowMs, + 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(); + } + } + + 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 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(() => 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 { + await this.destroy(entry); + } finally { + this.destroyingSlots.delete(entry.slot); + this.wakeWaiters(); + this.wakeCloseWaiters(); + } + } + + 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; + + /** Initial SERVER_INFO; use serverInfo for the current post-failover snapshot. */ + 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; + } + + /** + * 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; + } + + get negotiatedZstdLevel(): number { + this.throwIfReleased(); + return this.session.negotiatedZstdLevel; + } + + query( + sql: string, + options: QwpEgressQueryOptions = {}, + ): Promise { + this.throwIfReleased(); + 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; + } + + 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 startPromise?: Promise; + 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; + + constructor( + factories: QwpClientFactories, + options: QwpClientPoolOptions = {}, + ) { + const validated = validatePoolOptions(options); + this.senderPool = new QwpResourcePool( + "sender", + validated.senderPoolMin, + validated.senderPoolMax, + validated.acquireTimeoutMs, + validated.idleTimeoutMs, + validated.maxLifetimeMs, + factories.createSender, + (sender) => sender.close(), + false, + factories.senderSlotReservation, + ); + this.queryPool = new QwpResourcePool( + "query", + validated.queryPoolMin, + validated.queryPoolMax, + validated.acquireTimeoutMs, + validated.idleTimeoutMs, + validated.maxLifetimeMs, + factories.createQuerySession, + (session) => session.shutdownForClientClose(), + true, + ); + this.startFactories = factories.start; + this.closeFactories = factories.close; + this.housekeepingIntervalMs = validated.housekeepingIntervalMs; + } + + /** 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(); + await this.ensureStarted(); + 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(); + await this.ensureStarted(); + this.throwIfUnavailable(); + const entry = await this.queryPool.acquire(); + return new QwpQueryLease(entry.value, async (reusable) => { + await this.queryPool.release(entry, reusable); + }); + } + + /** + * 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(); + return this.closePromise; + } + + 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; + } + } + + private async closeNow(): Promise { + if (this.closed) return; + this.closing = true; + this.stopHousekeeper(); + await this.startPromise?.catch(() => undefined); + await this.housekeepingTask; + try { + 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; + } + } + + private ensureStarted(): Promise { + if (!this.startPromise) { + 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(); + } +} + +function createSenderLease( + sender: QwpSender, + releaseSender: (reusable: boolean) => Promise, +): QwpSender { + let released = false; + 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; + 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); + if (property === "writer" && typeof result === "object" && result) { + return guardTableWriter(result); + } + 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, + idleTimeoutMs: options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS, + maxLifetimeMs: options.maxLifetimeMs ?? DEFAULT_MAX_LIFETIME_MS, + housekeepingIntervalMs: + options.housekeepingIntervalMs ?? DEFAULT_HOUSEKEEPING_INTERVAL_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"); + } + 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, + 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 new file mode 100644 index 0000000..5e7e766 --- /dev/null +++ b/src/_qwp/egress-session.ts @@ -0,0 +1,1313 @@ +import { + decodeQwpEgressMessage, + encodeQwpBinds, + encodeQwpCancel, + encodeQwpCredit, + encodeQwpQueryRequest, + QWP_COMPRESSION_CODEC, + QWP_EGRESS_CAPABILITY, + QWP_QUERY_FLAG_RESET_DICTIONARY, + QWP_RESET_MASK_DICTIONARY, + QwpBindSetter, + QwpExecDoneMessage, + type QwpNegotiatedEgressCompression, + QwpProtocolError, + QwpResultBatch, + QwpResultBatchDecoder, + QwpResultBatchView, + QwpResultEndMessage, + 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 { + /** SERVER_INFO handshake deadline. Defaults to 5 seconds. */ + serverInfoTimeoutMs?: number; + /** 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; + /** 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; + /** + * 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; +} + +export interface QwpEgressQueryOptions { + /** Overrides session send-ahead credit. Zero explicitly disables flow control. */ + 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. */ + bindCount?: number; + /** Advanced escape hatch for an already encoded bind section. */ + bindPayload?: Uint8Array; + /** + * Ask a capable server to reset its connection-scoped symbol dictionary. + * Silently omitted when the server lacks QUERY_FLAGS for rolling upgrades. + */ + resetDictionary?: boolean; +} + +interface QwpValidatedEgressSessionOptions { + readonly serverInfoTimeoutMs: number; + readonly initialCredit: number | bigint; + readonly bufferPoolSize: number; + readonly queryTimeoutMs: number; + 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 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; + +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, + 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, +): QwpValidatedEgressSessionOptions { + 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", + ); + } + return { + serverInfoTimeoutMs, + initialCredit: validateInitialCredit( + options.initialCredit ?? QWP_DEFAULT_EGRESS_INITIAL_CREDIT, + "initialCredit", + ), + bufferPoolSize: validateBufferPoolSize( + options.bufferPoolSize ?? QWP_DEFAULT_EGRESS_BUFFER_POOL_SIZE, + ), + queryTimeoutMs: validateOptionalTimeout( + options.queryTimeoutMs, + "queryTimeoutMs", + ), + cancelDrainTimeoutMs: validatePositiveTimeout( + options.cancelDrainTimeoutMs ?? 5_000, + "cancelDrainTimeoutMs", + ), + }; +} + +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, +): 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`); + } + return value; +} + +export type QwpQueryCompletion = QwpResultEndMessage | QwpExecDoneMessage; + +export class QwpEgressQueryError extends Error { + constructor( + readonly requestId: bigint, + readonly status: number, + message: string, + ) { + super(message); + this.name = "QwpEgressQueryError"; + } +} + +/** 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"; + } +} + +/** 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( + 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; + abandon(requestId: bigint): Promise; + grantCredit( + requestId: bigint, + additionalBytes: number | bigint, + ): Promise; + expire(requestId: bigint, timeoutMs: number): void; + rejectView(requestId: bigint, error: Error): Promise; +} + +interface QwpQueuedResultBatch { + readonly batch: QwpResultBatch; + readonly creditBytes: number; +} + +type QwpBatchReservation = "reserved" | "retired" | "reset"; + +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; +} + +/** + * 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(); + 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; + private readonly bufferWaiters = new Set<() => void>(); + private readonly availableViewSlots: number[]; + private viewTail: Promise = Promise.resolve(); + private wireComplete = false; + private terminal = false; + private timeoutTimer?: ReturnType; + readonly completion: Promise; + + constructor( + readonly requestId: bigint, + 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; + 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; + this.availableViewSlots = viewHandler + ? Array.from({ length: bufferPoolSize }, (_, slot) => slot) + : []; + } + + [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 () => { + 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 }; + }, + return: async () => { + if (this.terminal) this.discardBufferedResults(); + else await this.control.abandon(this.requestId); + return { value: undefined, done: true }; + }, + }; + } + + cancel(): Promise { + return this.control.cancel(this.requestId); + } + + grantCredit(additionalBytes: number | bigint): Promise { + 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; + this.timeoutTimer = setTimeout(() => { + this.timeoutTimer = undefined; + this.control.expire(this.requestId, timeoutMs); + }, timeoutMs); + } + + /** @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 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, + slot: number, + ): void { + if (this.terminal) { + batch.release(); + this.releaseViewSlot(slot); + return; + } + const generation = this.bufferGeneration; + 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); + }); + } + + /** @internal Releases a reservation when zero-copy decoding fails. */ + releaseViewBatch(slot: number): void { + this.releaseViewSlot(slot); + } + + /** @internal */ + get usesViews(): boolean { + return this.viewHandler !== undefined; + } + + /** @internal */ + async finish(completion: QwpQueryCompletion): Promise { + this.wireComplete = true; + if (this.viewHandler) await this.viewTail; + if (this.terminal) return; + this.terminal = true; + this.wakeBufferWaiters(); + this.clearTimeout(); + 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. */ + 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; + this.terminal = true; + this.wakeBufferWaiters(); + this.clearTimeout(); + 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. */ + 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 */ + async resetForReplay(): Promise { + this.deliveredCreditBytes = 0; + this.bufferGeneration++; + this.wireComplete = false; + this.releaseBufferedBatches(this.batches.clear().length); + this.wakeBufferWaiters(); + await this.viewTail; + } + + /** @internal Waits until all callback-scoped views have been released. */ + waitForViewDrain(): Promise { + return this.viewTail; + } + + private clearTimeout(): void { + if (!this.timeoutTimer) return; + clearTimeout(this.timeoutTimer); + this.timeoutTimer = undefined; + } + + private discardBufferedResults(): number { + let creditBytes = this.deliveredCreditBytes; + this.deliveredCreditBytes = 0; + 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 releaseViewSlot(slot: number): void { + this.availableViewSlots.push(slot); + this.wakeBufferWaiters(); + } + + 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; + } + } +} + +/** + * 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 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; + private activeRequest?: QwpReplayableQueryRequest; + private nextRequestId = 0n; + private sendTail: Promise = Promise.resolve(); + 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( + private readonly connection: QwpBinaryConnection, + options: QwpEgressSessionOptions = {}, + ) { + let validated: QwpValidatedEgressSessionOptions; + try { + if ( + options.reconnect && + !(connection instanceof QwpReconnectingEgressConnection) + ) { + throw new Error( + "egress reconnect options require QwpEgressSession.connect(factory, options)", + ); + } + validated = 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; + } + 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; + this.ready = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + void this.ready.catch(() => undefined); + this.resolveServerInfo = resolve; + this.rejectServerInfo = reject; + this.serverInfoTimer = setTimeout(() => { + 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); + }, validated.serverInfoTimeoutMs); + this.receiveLoop = this.consumeMessages(); + } + + static async connect( + factory: QwpConnectionFactory, + options: QwpEgressSessionOptions = {}, + ): Promise { + const validated = validateEgressSessionOptions(options); + const state: { session?: QwpEgressSession } = {}; + const reconnectOptions = + options.reconnect === false + ? undefined + : (options.reconnect ?? DEFAULT_EGRESS_RECONNECT_OPTIONS); + const connection = reconnectOptions + ? await QwpReconnectingEgressConnection.connect( + factory, + reconnectOptions, + validated.serverInfoTimeoutMs, + (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); + } + : undefined, + options.reconnect !== undefined, + ) + : await factory(); + let session: QwpEgressSession; + try { + session = new QwpEgressSession(connection, options); + state.session = session; + await session.ready; + return session; + } catch (error) { + 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; + } + } + + get closed(): Promise { + return this.connection.closed; + } + + get handshake(): QwpHandshakeMetadata { + 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.currentServerInfo; + 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 or unknown negotiation. */ + get negotiatedZstdLevel(): number { + const compression = this.negotiatedCompression; + return compression?.codec === "zstd" ? compression.level : 0; + } + + async query( + sql: string, + options: QwpEgressQueryOptions = {}, + ): Promise { + return this.startQuery(sql, options); + } + + /** + * Executes a query through a bounded, reusable, zero-copy batch callback. + * 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, + 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, + "timeoutMs", + ); + const initialCredit = validateInitialCredit( + options.initialCredit ?? this.defaultInitialCredit, + "initialCredit", + ); + if ( + options.autoCredit !== undefined && + typeof options.autoCredit !== "boolean" + ) { + throw new TypeError("autoCredit must be a boolean"); + } + await this.ready; + this.throwIfUnavailable(); + if (this.active) { + throw new Error("a QWP query is already active on this connection"); + } + const requestId = this.nextRequestId++; + const creditEnabled = + typeof initialCredit === "bigint" + ? initialCredit > 0n + : initialCredit > 0; + const query = new QwpEgressQuery( + requestId, + this, + creditEnabled, + creditEnabled && (options.autoCredit ?? true), + this.bufferPoolSize, + viewHandler, + ); + 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, + 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( + this.encodeQueryRequest(request, this.currentServerInfo!), + ); + } catch (error) { + this.clearActive(query); + query.fail(error); + throw error; + } + query.armTimeout(timeoutMs); + return query; + } + + cancel(requestId: bigint): Promise { + this.requireActive(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( + requestId: bigint, + additionalBytes: number | bigint, + ): Promise { + this.requireActive(requestId); + return this.sendWhileActive( + requestId, + encodeQwpCredit(requestId, additionalBytes), + ); + } + + expire(requestId: bigint, timeoutMs: number): void { + if (!this.active || this.active.requestId !== requestId) return; + const discardedCredit = this.active.retire( + new QwpEgressQueryTimeoutError(requestId, timeoutMs), + ); + try { + void this.cancelAndDrain(requestId, discardedCredit).catch( + () => undefined, + ); + } catch (error) { + this.fail(error); + } + } + + 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; + } + + /** + * 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. + * + * @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); + this.clearCancelDrain(); + const error = new QwpEgressSessionClosedError(); + this.rejectServerInfo(error); + const active = this.active; + active?.fail(error); + this.clearActive(); + 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, + active?.waitForViewDrain() ?? Promise.resolve(), + ]); + if (closeResult.status === "rejected") throw closeResult.reason; + } + + private async consumeMessages(): Promise { + try { + for await (const payload of this.connection.messages) { + try { + const message = decodeQwpEgressMessage(payload); + switch (message.kind) { + case "server-info": + if (this.currentServerInfo) { + throw new QwpProtocolError( + "received duplicate QWP SERVER_INFO", + ); + } + this.currentServerInfo = message; + clearTimeout(this.serverInfoTimer); + 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": { + 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); + } + } else if (query.usesViews) { + 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") { + 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.clearCancelDrain(message.requestId); + await query.finish(message); + this.clearActive(query); + break; + } + case "exec-done": { + const query = this.requireActive(message.requestId); + this.clearCancelDrain(message.requestId); + await query.finish(message); + this.clearActive(query); + break; + } + case "query-error": { + const query = this.requireActive(message.requestId); + this.clearCancelDrain(message.requestId); + await query.finishError( + new QwpEgressQueryError( + message.requestId, + message.status, + message.message, + ), + ); + this.clearActive(query); + break; + } + } + } catch (error) { + if ( + error instanceof QwpProtocolError && + this.connection instanceof QwpReconnectingEgressConnection + ) { + await this.connection.recoverProtocolFailure(error); + continue; + } + throw error; + } + } + 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 async prepareConnectionReset( + serverInfo: QwpServerInfoMessage, + ): Promise { + this.currentServerInfo = 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, + ): 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 () => { + this.throwIfUnavailable(); + await this.connection.send(payload); + }); + this.sendTail = sending.catch((error: unknown) => this.fail(error)); + 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(); + } + + private clearActive(expected?: QwpEgressQuery): void { + 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(); + } + + 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); + this.clearCancelDrain(); + this.failure = + error instanceof Error ? error : new Error(`QWP egress failed: ${error}`); + this.rejectServerInfo(this.failure); + this.active?.fail(this.failure); + this.clearActive(); + } +} diff --git a/src/_qwp/ingress-session.ts b/src/_qwp/ingress-session.ts new file mode 100644 index 0000000..f3154cd --- /dev/null +++ b/src/_qwp/ingress-session.ts @@ -0,0 +1,1705 @@ +import { + decodeQwpIngressSymbolDictionaryDelta, + decodeQwpIngressResponse, + encodeQwpDurableAckPollFrame, + encodeQwpIngressFrame, + QWP_FLAG_DEFER_COMMIT, + QWP_STATUS, + QwpIngressEncodeOptions, + QwpIngressResponse, + QwpProtocolError, + QwpSymbolDictionary, + QwpTableBuffer, +} from "./_core"; +import { + QWP_INITIAL_CONNECT_MODE, + QwpBinaryConnection, + QwpConnectionCloseInfo, + QwpConnectionFactory, + QwpHandshakeMetadata, + QwpInitialConnectMode, + QwpIngressReplayStore, + QwpReconnectOptions, + QwpReplayDictionaryPersistenceError, +} from "./transport"; +import { QwpReconnectingIngressConnection } from "./_internal/reconnecting-ingress-connection"; +import { QwpNotificationDispatcher } from "./_internal/notification-dispatcher"; +import { safelyInvoke } from "./_internal/safe-callback"; +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; +const DEFAULT_ERROR_INBOX_CAPACITY = 256; +const DEFAULT_PROGRESS_INBOX_CAPACITY = 256; + +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; + /** + * 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 | 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. */ + backgroundStoreAndForward?: boolean; + /** @internal Initial connection policy supplied by the Node adapter. */ + 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; + /** + * 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; + /** + * 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. + */ + durableAckKeepaliveMs?: number; + /** + * Bounded reconnect-listener inbox. Oldest pending events are dropped when + * full. Defaults to 64, matching the Java client. + */ + connectionListenerInboxCapacity?: number; + /** + * 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. 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; + /** 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; + +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]; + +/** 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; + 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. */ + 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; +} + +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; + /** Present for a classified server rejection. */ + readonly senderError?: QwpSenderError; + 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; + readonly payloadBytes: number; + timer?: ReturnType; +} + +interface PendingDurableResponse { + readonly targets: Map; + resolve: () => void; + reject: (error: unknown) => void; + timer?: ReturnType; +} + +interface PendingAcknowledgedSequence { + readonly targetSequence: bigint; + resolve: () => void; + reject: (error: unknown) => void; + timer?: ReturnType; +} + +export class QwpIngressNackError extends Error { + constructor( + readonly response: QwpIngressResponse, + readonly senderError: QwpSenderError = createQwpSenderError(response), + ) { + 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"; + } +} + +/** 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, + readonly maxBatchSizeBytes: number, + ) { + super( + `QWP batch exceeds the negotiated limit [size=${batchSizeBytes}, max=${maxBatchSizeBytes}]`, + ); + this.name = "QwpBatchTooLargeError"; + } +} + +function validateIngressSessionOptions( + options: QwpIngressSessionOptions, +): 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 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 && + (!Number.isFinite(keepalive) || keepalive < 0) + ) { + throw new RangeError( + "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", + 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}`); + } + } +} + +/** + * 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. + */ +export class QwpIngressSession { + private readonly pending = new Map(); + 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; + private readonly localMaxBatchSizeBytes?: number; + private readonly symbolDictionary = new QwpSymbolDictionary(); + private publishedMaxSymbolId = -1; + private deltaSymbolsPublished = false; + private acknowledgedSequence = -1n; + private durableAcknowledgedSequence = -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; + private readonly closeHooks: (() => void | Promise)[] = []; + private readonly receiveLoop: Promise; + private readonly progressDispatcher?: QwpNotificationDispatcher<() => void>; + private readonly errorDispatcher?: QwpNotificationDispatcher<() => void>; + + constructor( + private readonly connection: QwpBinaryConnection, + private readonly options: QwpIngressSessionOptions = {}, + ) { + try { + if ( + options.reconnect && + !(connection instanceof QwpReconnectingIngressConnection) + ) { + throw new Error( + "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 { + 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 = 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); + } + this.publishedMaxSymbolId = this.symbolDictionary.size - 1; + this.deltaSymbolsPublished = this.symbolDictionary.size > 0; + this.receiveLoop = this.consumeMessages(); + } + + 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) { + throw new RangeError("a QWP replayStore requires ingress 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(signal) + : undefined; + const connection = reconnectOptions + ? await QwpReconnectingIngressConnection.connect( + factory, + reconnectOptions, + options.replayStore, + options.maxBatchSizeBytes, + options.memoryReplayMaxBytes, + options.memoryReplayAppendDeadlineMs, + options.backgroundStoreAndForward, + initialConnectMode, + options.orphanStoreAndForward, + options.orphanDurableAckMismatchMaxDurationMs, + options.catchUpCapGapMinEscalationWindowMs, + initialConnection, + options.connectionListenerInboxCapacity ?? + 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 { + return new QwpIngressSession(connection, options); + } catch (error) { + await connection.close().catch(() => undefined); + throw error; + } + } + + get closed(): Promise { + return this.connection.closed; + } + + get handshake(): QwpHandshakeMetadata { + return this.connection.handshake; + } + + get maxBatchSizeBytes(): number | undefined { + const serverBatchCap = this.connection.handshake.maxBatchSizeBytes; + return this.localMaxBatchSizeBytes === undefined + ? serverBatchCap + : serverBatchCap === undefined + ? this.localMaxBatchSizeBytes + : 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; + 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, + 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, + 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, + }); + } + + sendTables( + tables: readonly QwpTableBuffer[], + encodeOptions: QwpIngressEncodeOptions = {}, + ): Promise { + try { + return this.sendTablesWithPublication(tables, encodeOptions) + .acknowledgement; + } catch (error) { + if (error instanceof QwpBatchTooLargeError) return Promise.reject(error); + throw error; + } + } + + /** + * 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); + } + + /** + * 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. + * If a replay dictionary append fails, that call rejects with + * QwpReplayDictionaryPersistenceError; retrying uses full inline symbols. + */ + sendTablesDelta( + tables: readonly QwpTableBuffer[], + encodeOptions: Pick< + QwpIngressEncodeOptions, + "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.sendTablesWithPublication(tables, encodeOptions); + } + 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, + ); + }; + let sending: QwpIngressSendResult; + 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; + 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), + ), + }; + } + } 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 }; + } + + /** + * 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< + QwpIngressEncodeOptions, + "gorilla" | "deferCommit" + > = {}, + ): 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; + 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) { + 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, recordPublishedDelta); + 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); + recordPublishedDelta(frame); + } catch (error) { + this.restoreDeltaStateAfterPublishFailure(previousSize); + this.publishedMaxSymbolId = successfullyPublishedMaxSymbolId; + this.deltaSymbolsPublished = successfullyPublishedDelta; + 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. + */ + 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 { + 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, + ackTimeoutEnabled = true, + ): QwpIngressSendResult { + 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 + ) { + throw new QwpBatchTooLargeError(frame.byteLength, this.maxBatchSizeBytes); + } + const sequence = this.nextSequence++; + let pending!: PendingResponse; + const response = new Promise((resolve, reject) => { + pending = { resolve, reject, payloadBytes: frame.byteLength }; + }); + this.pending.set(sequence, pending); + this.totalFramesPublished++; + this.totalBytesPublished += frame.byteLength; + + 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); + } + }); + // 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 + // this waiter, so starting a per-frame timer here would make valid + // transactions fail merely because they stayed open for ackTimeoutMs. + if (ackDeferredUntilCommit || !ackTimeoutEnabled) return; + pending.timer = setTimeout(() => { + if (!this.pending.delete(sequence)) return; + 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, + ); + 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 { + sequence, + publication: sending, + acknowledgement: response, + }; + } + + private sendPlannedFramesWithPublication( + frames: readonly Uint8Array[], + 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); + onFramePublished?.(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. + */ + 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 (!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"), + ); + } + 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; + const error = new Error("timed out waiting for QWP durable ACK"); + reject(error); + this.recordError(error, false); + }, timeoutMs); + this.durableWaiters.add(pending); + }); + } + + /** + * 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. + */ + 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(); + } + + /** + * 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. */ + 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; + } + + private async closeNow(code: number, reason: string): Promise { + 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); + } catch (error) { + transportClose = Promise.reject(error); + } + const [, closeResult] = await Promise.allSettled([ + this.sendTail, + transportClose, + this.receiveLoop, + ...closeHooks, + ]); + await Promise.all([ + this.progressDispatcher?.close(), + this.errorDispatcher?.close(), + ]); + if (closeResult.status === "rejected") throw closeResult.reason; + } + + 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.dispatchProgressCallback(this.options.onResponse, response); + if (response.status === QWP_STATUS.DURABLE_ACK) { + this.totalDurableAcks++; + const advanced = this.applyDurableAck(response); + this.dispatchProgressCallback(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.trackDurableFrame(response); + this.trackDurableTargets(response); + 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); + } + if (response.sequence > this.acknowledgedSequence) { + this.acknowledgedSequence = response.sequence; + this.emitProgress( + QWP_INGRESS_PROGRESS_KIND.ACKNOWLEDGED, + response.sequence, + response, + ); + } + this.resolveAcknowledgedSequenceWaiters(); + return; + } + + this.totalNacks++; + const pending = this.pending.get(response.sequence); + 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 + ) { + 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; + this.recordError(error, dictionaryGap, response, senderError); + if (dictionaryGap) { + // This wire cannot repair a missing prefix without reconnect catch-up. + this.fail(error, true); + void this.connection.close(1002, "QWP symbol dictionary gap"); + } + } + + private dispatchProgressCallback( + callback: ((event: T) => void) | undefined, + event: T, + ): void { + if (!callback || !this.progressDispatcher) return; + this.progressDispatcher.offer(() => safelyInvoke(callback, event)); + } + + private emitProgress( + kind: QwpIngressProgressKind, + sequence?: bigint, + response?: QwpIngressResponse, + ): void { + this.dispatchProgressCallback(this.options.onProgress, { + kind, + timestampMs: Date.now(), + sequence, + response, + metrics: this.metrics, + }); + } + + private recordError( + error: unknown, + terminal: boolean, + response?: QwpIngressResponse, + senderError?: QwpSenderError, + ): Error { + const observed = + error instanceof Error + ? error + : new Error(`QWP ingress failed: ${error}`); + this.lastError = observed; + this.totalErrors++; + const event: QwpIngressErrorEvent = { + error: observed, + terminal, + timestampMs: Date.now(), + response, + senderError, + metrics: this.metrics, + }; + const notify = (): void => { + safelyInvoke(this.options.onError, event); + if (senderError && !this.connection.managesIngressSenderErrors) { + 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; + } + + private trackDurableTargets(response: QwpIngressResponse): void { + 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) { + continue; + } + const pending = this.pendingDurableTargets.get(table.name); + if (pending === undefined || table.sequenceTransaction > pending) { + this.pendingDurableTargets.set(table.name, table.sequenceTransaction); + } + } + 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) { + 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) { + 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(); + } + 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 { + for (const [table, target] of targets) { + const watermark = this.durableWatermarks.get(table); + if (watermark === undefined || watermark < target) return false; + } + return true; + } + + private scheduleDurablePoll(): void { + const interval = this.options.durableAckKeepaliveMs; + if ( + interval === undefined || + interval === 0 || + !this.connection.handshake.durableAckEnabled || + this.pendingDurableTargets.size === 0 || + this.durablePollTimer + ) { + return; + } + this.durablePollTimer = setTimeout(() => { + this.durablePollTimer = undefined; + if ( + this.closing || + this.failure || + this.pendingDurableTargets.size === 0 + ) { + return; + } + const poll = this.connection.ping + ? this.connection.ping() + : this.publishBrowserDurableAckPoll(); + void poll + .then(() => this.scheduleDurablePoll()) + .catch((error: unknown) => this.fail(error)); + }, interval); + } + + private clearDurablePoll(): void { + if (!this.durablePollTimer) return; + clearTimeout(this.durablePollTimer); + this.durablePollTimer = undefined; + } + + private throwIfUnavailable(): void { + if (this.failure) throw this.failure; + if (this.closing) throw new QwpIngressSessionClosedError(); + } + + private fail(error: unknown, alreadyObserved = false): void { + if (this.failure) return; + this.clearDurablePoll(); + this.failure = alreadyObserved + ? error instanceof Error + ? error + : new Error(`QWP ingress failed: ${error}`) + : this.recordError(error, true); + 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(); + for (const pending of this.durableWaiters) { + if (pending.timer) clearTimeout(pending.timer); + pending.reject(error); + } + this.durableWaiters.clear(); + for (const pending of this.acknowledgedSequenceWaiters) { + if (pending.timer) clearTimeout(pending.timer); + pending.reject(error); + } + this.acknowledgedSequenceWaiters.clear(); + } +} + +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/sender-error.ts b/src/_qwp/sender-error.ts new file mode 100644 index 0000000..5f34f81 --- /dev/null +++ b/src/_qwp/sender-error.ts @@ -0,0 +1,178 @@ +import { QWP_STATUS, type QwpIngressResponse } from "./_core"; +import { log } from "../logging"; + +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; +} + +/** + * 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 = {}, +): 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, + /** Omitted when the bytes were abandoned rather than preserved on disk. */ + 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/sender.ts b/src/_qwp/sender.ts new file mode 100644 index 0000000..5ca9e95 --- /dev/null +++ b/src/_qwp/sender.ts @@ -0,0 +1,2580 @@ +import { + QWP_COLUMN_TYPE, + QwpColumnType, + QwpIngressEncodeOptions, + QwpIngressResponse, + QwpTableBuffer, + flattenQwpArray, + utf8Length, + type QwpArrayValue, +} from "./_core"; +import { + QwpBatchTooLargeError, + QwpIngressAckTimeoutError, + type QwpIngressSendResult, + type QwpIngressMetrics, +} from "./ingress-session"; +import { qwpColumnNameKey, validateQwpColumnName } from "./_core/identifiers"; +import { + isQwpWriterColumn, + QwpWriterRowError, + validateDecimalScale, + validateGeohashPrecision, + type QwpTimestampUnit, + type QwpWriterColumn, + type QwpWriterRow, + type QwpWriterSchema, +} from "./writer"; + +export type { QwpTimestampUnit } from "./writer"; + +export type QwpSenderLogger = ( + level: "error" | "warn" | "info" | "debug", + 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; + 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; + /** 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 + * 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 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. When true, + * this implies awaitServerAck unless awaitServerAck is explicitly false. + */ + awaitDurableAck?: boolean; + durableAckTimeoutMs?: number; + /** + * Maximum time close() spends publishing queued rows and waiting for the + * server ACK watermark. Zero skips the drain. Defaults to 60 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; + readonly maxBatchSizeBytes?: number; + readonly publishedFrameSequence?: bigint; + readonly acknowledgedFrameSequence?: bigint; + sendTables( + tables: readonly QwpTableBuffer[], + options?: QwpIngressEncodeOptions, + ): Promise; + sendTablesDelta?( + 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, + ): Promise; + publishTablesDelta?( + tables: readonly QwpTableBuffer[], + options?: Pick, + ): Promise; + waitForAcknowledged?( + targetSequence: bigint, + timeoutMs?: number, + ): Promise; + waitForDurable( + response: QwpIngressResponse, + timeoutMs?: number, + ): Promise; + close(code?: number, reason?: string): 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 { + 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; + /** 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; + readonly closed: boolean; + readonly ingress?: QwpIngressMetrics; +} + +interface StagedColumn { + name: string; + type: QwpColumnType; + value: unknown; + geohashPrecision?: number; + decimalScale?: number; +} + +interface StagedTable { + name: string; + rows: StagedRow[]; + schema: Map< + string, + Pick + >; +} + +interface StagedRow { + readonly columns: Map; + readonly estimatedBytes: number; +} + +interface CompiledQwpWriterColumn { + readonly inputName: string; + readonly wireName: string; + 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 { + readonly tableName: string; + readonly columns: readonly CompiledQwpWriterColumn[]; + readonly inputNames: ReadonlySet; +} + +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; +// 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 { + 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; +} + +/** 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; +} + +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): { + unscaled: bigint; + scale: number; +} { + const text = String(value); + 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 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, + }; +} + +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"); + } + // 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( + 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") { + 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) + 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; +} + +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 { + 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; + 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 {}; + } +} + +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; + } + 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"); + } +} + +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. + * + * 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(); + // 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; + /** + * 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; + private flushTail: Promise = Promise.resolve(); + private closePromise?: Promise; + private closing = false; + private closed = false; + 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 lastCommitBoundarySequence = -1n; + + private readonly autoFlush: boolean; + private readonly autoFlushRows: number; + private readonly autoFlushBytes: number; + private readonly autoFlushIntervalMs: number; + private readonly transactional: boolean; + private readonly awaitServerAck: boolean; + private readonly closeFlushTimeoutMs: number; + private readonly maxNameLength: number; + private readonly log: QwpSenderLogger; + + private readonly connectAbort = new AbortController(); + + constructor( + private readonly sessionFactory: QwpSenderSessionFactory, + private readonly options: QwpSenderOptions = {}, + ) { + 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 ?? options.awaitDurableAck === 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) || + options.durableAckTimeoutMs <= 0) + ) { + 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); + } + + async connect(): Promise { + this.throwIfUnavailable(); + await this.getSession(); + 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, + pendingBytes: this.pendingByteCount, + autoFlushBytes: this.autoFlushBytes, + effectiveAutoFlushBytes: this.effectiveAutoFlushByteThreshold(), + 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; + this.tablesByName.clear(); + 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; + } + + /** + * 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"); + // Validate eagerly rather than waiting for flush. + new QwpTableBuffer(name, this.maxNameLength); + 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; + } + + /** + * 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 (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). + // 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 { + if (this.omitsNullish(name, value)) 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 (this.omitsNullish(name, value)) 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 (this.omitsNullish(name, value)) 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 (this.omitsNullish(name, value)) 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 (this.omitsNullish(name, value)) 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 (this.omitsNullish(name, value)) 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 (this.omitsNullish(name, value)) 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 (this.omitsNullish(name, value)) 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 (this.omitsNullish(name, value)) 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 (this.omitsNullish(name, value)) 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 (this.omitsNullish(name, value)) 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 (this.omitsNullish(name, value)) 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 (this.omitsNullish(name, millisecondsSinceEpoch)) 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 (this.omitsNullish(name, value)) 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 (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"), + ); + } + return this.addColumn(name, QWP_COLUMN_TYPE.CHAR, value); + } + + uuidColumn( + name: string, + value: string | Uint8Array | null | undefined, + ): QwpSender { + if (this.omitsNullish(name, value)) return this; + try { + return this.addColumn(name, QWP_COLUMN_TYPE.UUID, uuidBytes(value)); + } catch (error) { + return this.failRow(error); + } + } + + long256Column( + name: string, + 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) { + // 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( + "long256Column needs all four words, or none of them for a NULL value", + ), + ); + } + try { + 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, + QWP_COLUMN_TYPE.LONG256, + littleEndianWords(words), + ); + } catch (error) { + return this.failRow(error); + } + } + + ipv4Column( + name: string, + value: string | number | null | undefined, + ): QwpSender { + if (this.omitsNullish(name, value)) 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 (this.omitsNullish(name, value)) 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 { + // 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 (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"); + } + const value = + typeof unscaled === "bigint" + ? unscaled + : signedBigEndianToBigInt(unscaled); + 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) { + 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 { + // 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 + // 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"), + ); + } + 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, + }); + } + + /** + * 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.discardRow(); + return this; + } + + async at( + value: number | bigint, + unit: QwpTimestampUnit = "us", + ): Promise { + try { + const timestamp = timestampValue(value, unit); + this.addColumn("", timestamp.type, timestamp.value, {}, true); + this.finishRow(); + } catch (error) { + this.failRow(error); + } + await this.tryFlush(); + } + + async atNow(): Promise { + this.throwIfUnavailable(); + this.requireTable(); + this.finishRow(); + 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. + */ + // `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); + } + + /** + * 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. + */ + async 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 + * group-closing frame. + */ + async commit(): Promise { + return this.flush(); + } + + 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, publicationOnly), + ); + void flushing.catch(() => { + this.totalFlushFailures++; + }); + this.flushTail = flushing.then( + () => undefined, + () => undefined, + ); + return flushing; + } + + close(): Promise { + if (!this.closePromise) this.closePromise = this.closeNow(); + 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; + // 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 { + // 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, publishDeadline); + + const session = this.activeSession; + const target = this.lastCommitBoundarySequence; + if ( + drainDeadline !== 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 = drainDeadline - Date.now(); + if (remaining <= 0) throw this.closeTimeoutError(); + try { + await this.withCloseDeadline( + session.waitForAcknowledged(target, remaining), + drainDeadline, + ); + } catch (error) { + if (error instanceof QwpIngressAckTimeoutError) { + throw this.closeTimeoutError(); + } + throw error; + } + } + } 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; + const session = this.activeSession; + if (session) { + try { + await session.close(); + } catch (error) { + closeError = error; + } + } else if (this.sessionPromise) { + // A close deadline can expire while the connection factory is still in + // 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); + } + + 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`, + ); + } + if (this.hasDeferredMessages) { + this.log( + "warn", + `QWP sender is closing with ${this.deferredRowCount} deferred row(s) awaiting commit; QuestDB will roll the open transaction back`, + ); + } + this.closed = true; + 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 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, + ...qwpWriterColumnMetadata(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 { + 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, + column.inputName, + rowIndex, + error, + ); + } + } + + // 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) }; + } + + 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, + scale: number, + type: QwpColumnType, + bits: number, + maximumScale: number, + ): QwpSender { + // 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 (!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 = {}, + designatedTimestamp = false, + ): QwpSender { + try { + this.throwIfUnavailable(); + const table = this.requireTable(); + if (typeof name !== "string") { + throw new TypeError("column name must be a string"); + } + if (!designatedTimestamp) { + validateQwpColumnName(name, this.maxNameLength); + } + const nameKey = qwpColumnNameKey(name); + const existingSchema = table.schema.get(nameKey); + if ( + existingSchema && + (existingSchema.type !== type || + existingSchema.geohashPrecision !== metadata.geohashPrecision) + ) { + 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; + if (!existingSchema) this.currentRowSchemaKeys.push(nameKey); + 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); + } + } + + private finishRow(): void { + 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++; + this.pendingByteCount += estimatedBytes; + this.totalRowsStaged++; + this.log( + "debug", + `Pending QWP rows: ${this.pendingRowCount}, estimated bytes: ${this.pendingByteCount}`, + ); + } + + private requireTable(): StagedTable { + if (!this.current) { + throw new Error("table name must be set before adding columns"); + } + return this.current; + } + + /** + * 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 { + 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; + } + + private failRow(error: unknown): never { + this.discardRow(); + throw error; + } + + /** 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); + } + 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; + } + + /** + * Discards every staged row, the way the Java client's close() does with + * resetTableBuffersAfterFlush() when the batch cap rejects the batch. + */ + private discardStagedRows(): number { + const snapshots = this.tables + .filter((table) => table.rows.length > 0) + .map((table) => ({ table, rows: table.rows.slice() })); + return this.releaseStagedRows(snapshots, this.stagingGeneration); + } + + 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)) + ) { + await this.enqueueFlush(this.transactional); + } + } + + private async flushNow( + deferCommit: boolean, + publicationOnly: boolean, + ): Promise { + if ( + this.pendingRowCount === 0 && + (deferCommit || !this.hasDeferredMessages) + ) { + if (this.activeSession?.waitForAcknowledged) { + await this.activeSession.waitForAcknowledged(-1n); + } + 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() })); + if (snapshots.length === 0 && !this.hasDeferredMessages) { + return { flushed: false, sequence: -1n }; + } + + 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; + const useDelta = + (encode?.symbolDictionary ?? "delta") === "delta" && + session.sendTablesDelta; + const beforeSequence = sessionPublishedSequence(session); + let response: Promise | undefined; + let publication: Promise | undefined; + let publishedSequence = -1n; + const waitForServerAck = this.awaitServerAck && !publicationOnly; + // planIngressFrames runs synchronously here, so an unfittable row throws + // 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 { + 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), + ); + }); + } + publishedSequence = advancedSequence( + beforeSequence, + sessionPublishedSequence(session), + ); + this.totalFlushes++; + // 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; + } + // 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"} ${publishedRows} QWP row(s)${deferCommit ? " with commit deferred" : ""}`, + ); + if (!deferCommit && publishedSequence >= 0n) { + this.lastCommitBoundarySequence = publishedSequence; + } + + if (deferCommit) { + this.hasDeferredMessages = true; + this.deferredRowCount += publishedRows; + 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; an ACK-waiting flush/commit + // still awaits it. + void response.catch(() => undefined); + } + return { flushed: true, sequence: publishedSequence }; + } + + const deferredAcks = this.deferredAcks.splice(0); + this.hasDeferredMessages = false; + this.deferredRowCount = 0; + const ack = response ? await response : undefined; + if (response) { + 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); + } + if ( + this.transactional && + (closesDeferredTransaction || publishedRows > 0) + ) { + this.totalTransactionsCommitted++; + } + if (this.options.awaitDurableAck && ack) { + await session.waitForDurable(ack, this.options.durableAckTimeoutMs); + } + return { flushed: true, sequence: publishedSequence }; + } + + private buildTable(name: string, rows: readonly StagedRow[]): QwpTableBuffer { + const result = new QwpTableBuffer(name, this.maxNameLength); + for (const row of rows) { + // 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); + } + if (column.decimalScale !== undefined) { + result.setDecimalScale(target, column.decimalScale); + } + target.values.push(column.value); + } + result.nextRow(); + } + return result; + } + + private getSession(): Promise { + if (!this.sessionPromise) { + // 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; + return session; + }) + .catch((error: unknown) => { + if (this.sessionPromise === tracked) this.sessionPromise = undefined; + throw error; + }); + this.sessionPromise = tracked; + } + return this.sessionPromise; + } + + 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 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"); + } + + private throwIfUnavailable(): void { + this.throwIfClosed(); + 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/_qwp/transport.ts b/src/_qwp/transport.ts new file mode 100644 index 0000000..7907b9b --- /dev/null +++ b/src/_qwp/transport.ts @@ -0,0 +1,565 @@ +import type { QwpNegotiatedEgressCompression } from "./_core/compression"; +import type { QwpServerInfoMessage } from "./_core/egress"; + +export interface QwpConnectionCloseInfo { + code: number; + reason: string; + 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"; + } +} + +/** 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; +} + +/** 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"; + } +} + +/** 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; + } +} + +/** + * 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, + * 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"; + } +} + +/** + * @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( + `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; +} + +/** 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. */ + loadSymbolDictionary?(): Promise; + /** Persists new dense entries before a delta frame is made replayable. */ + appendSymbolDictionary?( + 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; +} + +/** 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; + /** 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; + readonly totalFramesReplayed: number; + readonly totalBytesReplayed: number; + readonly totalReconnectAttempts: number; + readonly totalReconnectsSucceeded: number; + 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 = { + CONNECTED: "connected", + RECONNECTING: "reconnecting", + 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 = + (typeof QWP_RECONNECT_EVENT_KIND)[keyof typeof QWP_RECONNECT_EVENT_KIND]; + +export interface QwpReconnectEvent { + readonly kind: QwpReconnectEventKind; + /** 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; + /** Elapsed time in the current consecutive capability-gap episode. */ + readonly episodeMs?: number; +} + +/** + * 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. */ + 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; + /** Full-jitter ceiling before the first failed sweep is retried. Defaults to 100ms. */ + initialBackoffMs?: number; + /** Full-jitter 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; + /** + * 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; +} + +export interface QwpEgressReplayResetEvent { + /** 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; +} + +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 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. */ + 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; + timeoutPhase?: QwpUpgradeTimeoutPhase; + 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. */ +/** + * 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; + /** 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; + 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; + /** Node opening phase that exceeded its deadline. */ + readonly timeoutPhase?: QwpUpgradeTimeoutPhase; + 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.timeoutPhase = details.timeoutPhase; + 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" + ); + } +} + +/** 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) { + 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. */ + 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; + /** 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. */ + readonly serverRole?: string; + /** Server zone advertised on a successful upgrade, when available. */ + readonly serverZone?: string; +} + +/** + * 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; + 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; + /** @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 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. + */ + deprioritizeEndpoint?(): void; + + send(payload: Uint8Array): Promise; + /** Sends an RFC 6455 PING when the underlying runtime supports it. */ + ping?(): Promise; + close(code?: number, reason?: string): Promise; +} + +export interface QwpWebSocketConnectOptions { + url: string | URL; + /** 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; + /** Maximum time allowed for a graceful WebSocket close. Defaults to 15s. */ + closeTimeoutMs?: number; +} + +/** + * 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/src/_qwp/writer.ts b/src/_qwp/writer.ts new file mode 100644 index 0000000..7b6a715 --- /dev/null +++ b/src/_qwp/writer.ts @@ -0,0 +1,410 @@ +export type QwpTimestampUnit = "ns" | "us" | "ms"; + +export type QwpWriterColumnKind = + | "symbol" + | "varchar" + | "bool" + | "byte" + | "short" + | "int32" + | "int64" + | "float32" + | "float64" + | "timestamp" + | "date" + | "char" + | "binary" + | "uuid" + | "long256" + | "ipv4" + | "geohash" + | "decimal64" + | "decimal128" + | "decimal256" + | "doubleArray" + | "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 +// so a future incompatible descriptor shape cannot interop with this one. +const QWP_WRITER_COLUMN: unique symbol = Symbol.for( + "questdb.qwp.writer.column.v1", +); + +/** 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, + DesignatedTimestamp extends boolean = false, +> { + 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. 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 + 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; + +/** + * 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 + | { 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 a signed/unsigned 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, + metadata: QwpWriterColumnMetadata = {}, +): QwpWriterColumn { + return Object.freeze({ + kind, + designatedTimestamp, + ...metadata, + [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)}'`); + } +} + +/** @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); +} + +/** 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 }); +} + +/** 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. */ +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/buffer/base.ts b/src/buffer/base.ts index 3fcf6de..f16e777 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; @@ -152,22 +171,18 @@ 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 { - 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", - ); + this.validateSymbolCall(name); + // A null or undefined value omits the symbol entirely (see issue #28). + if (this.isNullOrUndefined(value)) { + return this; } 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); @@ -180,10 +195,15 @@ 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 { + this.validateColumnCall(name); + // A null or undefined value omits the column entirely (see issue #28). + if (this.isNullOrUndefined(value)) { + return this; + } this.writeColumn( name, value, @@ -203,10 +223,15 @@ 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 { + this.validateColumnCall(name); + // A null or undefined value omits the column entirely (see issue #28). + if (this.isNullOrUndefined(value)) { + return this; + } this.writeColumn( name, value, @@ -224,34 +249,45 @@ 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 { + this.validateColumnCall(name); + // 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 +319,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`) @@ -292,14 +328,29 @@ 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`. */ timestampColumn( name: string, - value: number | bigint, + value: number | bigint | null | undefined, 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; + } if (typeof value !== "bigint" && !Number.isInteger(value)) { throw new Error( `Timestamp value must be an integer or BigInt, received ${value}`, @@ -339,26 +390,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(); } /** @@ -366,14 +422,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(); } /** @@ -416,6 +477,61 @@ 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 + * 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. @@ -430,20 +546,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(); @@ -529,16 +641,28 @@ 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, value: string | number): SenderBuffer { + 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"); } @@ -547,25 +671,30 @@ 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, - unscaled: bigint | Int8Array, + 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; + } 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..03001d5 100644 --- a/src/buffer/bufferv1.ts +++ b/src/buffer/bufferv1.ts @@ -24,10 +24,15 @@ 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 { + this.validateColumnCall(name); + // A null or undefined value omits the column entirely (see issue #28). + if (this.isNullOrUndefined(value)) { + return this; + } this.writeColumn( name, value, @@ -59,11 +64,22 @@ 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 { + 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"); } } diff --git a/src/buffer/bufferv2.ts b/src/buffer/bufferv2.ts index b24c176..d2166f5 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,15 @@ 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 { + this.validateColumnCall(name); + // A null or undefined value omits the column entirely (see issue #28). + if (this.isNullOrUndefined(value)) { + return this; + } this.writeColumn( name, value, @@ -85,17 +89,23 @@ 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 { + this.validateColumnCall(name); + // 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 +114,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..d22c3c1 100644 --- a/src/buffer/bufferv3.ts +++ b/src/buffer/bufferv3.ts @@ -42,7 +42,15 @@ 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 { + this.validateColumnCall(name); + // 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,12 +89,20 @@ class SenderBufferV3 extends SenderBufferV2 { */ decimalColumn( name: string, - unscaled: bigint | Int8Array, + 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; + } let arr: number[]; if (typeof unscaled === "bigint") { arr = bigintToTwosComplementBytes(unscaled); 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..116558f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,16 +1,19 @@ /** - * A Node.js client for QuestDB. + * The QuestDB JavaScript client. + * + * This entry point targets Node.js. See `./qwp/browser` for the browser build. * @packageDocumentation */ 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"; 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/options.ts b/src/options.ts index f09cc6c..1f157ed 100644 --- a/src/options.ts +++ b/src/options.ts @@ -4,17 +4,137 @@ 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 { + getQwpNodeModule, + preloadQwpNodeModule, +} from "./qwp-node/module-registry"; +import type { QwpNodeClientOptions } from "./qwp/node"; +import type { + QwpNodeIngressOptions, + QwpNodeUdpOptions, + 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); +} + +/** + * @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, + logger?: Logger, +): http.Agent | undefined { + if (secure) { + 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 undefined; +} + +function resolveQwpConfig( + options: SenderOptions, + configString: string, +): QwpNodeClientOptions { + const configuredWebSocket = options.qwp?.webSocket; + const { + storeAndForward, + failoverUrls, + target, + zone, + senderId, + ...webSocketOverrides + } = configuredWebSocket ?? {}; + const logger = options.log ?? options.qwp?.sender?.log ?? log; + const agent = + webSocketOverrides.agent ?? + selectQwpSchemeAgent(options.agent, options.protocol === WSS, logger); + const resolved = getQwpNodeModule().parseQwpNodeClientConfig(configString, { + webSocket: { ...webSocketOverrides, agent }, + storeAndForward, + // 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: logger, + }, + ingressSession: options.qwp?.session, + }); + + // 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 === undefined ? {} : { failoverUrls }), + ...(target === undefined ? {} : { target }), + ...(zone === undefined ? {} : { zone }), + ...(senderId === undefined ? {} : { senderId }), + }, + }; +} const HTTP_PORT = 9000; const TCP_PORT = 9009; +const QWP_UDP_PORT = 9007; const HTTP = "http"; const HTTPS = "https"; const TCP = "tcp"; const TCPS = "tcps"; +const WS = "ws"; +const WSS = "wss"; +const UDP = "udp"; const ON = "on"; const OFF = "off"; @@ -27,9 +147,29 @@ const PROTOCOL_VERSION_V3 = "3"; const LINE_PROTO_SUPPORT_VERSION = "line.proto.support.versions"; +type QwpExtraOptions = { + /** + * 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; + /** High-level buffering and auto-flush options. */ + sender?: QwpSenderOptions; + /** Node-only QWP-over-UDP socket overrides. */ + udp?: Omit; +}; + 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; }; type DeprecatedOptions = { @@ -51,8 +191,10 @@ 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, 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.
    @@ -104,6 +246,12 @@ 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.
    + * 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. @@ -142,6 +290,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; @@ -160,6 +319,7 @@ class SenderOptions { auto_flush?: boolean; auto_flush_rows?: number; + auto_flush_bytes?: number; auto_flush_interval?: number; request_min_throughput?: number; @@ -176,12 +336,16 @@ 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; stdlib_http?: boolean; + qwp?: QwpExtraOptions; + auth?: { username?: string; keyId?: string; @@ -219,6 +383,14 @@ class SenderOptions { throw new Error("Invalid HTTP agent"); } 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)); } } @@ -322,6 +494,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; @@ -346,6 +521,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, @@ -355,6 +537,14 @@ 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); @@ -363,6 +553,7 @@ function parseConfigurationString( parseTlsOptions(options); parseRequestTimeoutOptions(options); parseMaxNameLength(options); + parseUdpOptions(options); parseStdlibTransport(options); } @@ -419,6 +610,7 @@ const ValidConfigKeys = [ "token_y", "auto_flush", "auto_flush_rows", + "auto_flush_bytes", "auto_flush_interval", "request_min_throughput", "request_timeout", @@ -431,6 +623,8 @@ const ValidConfigKeys = [ "tls_ca", "tls_roots", "tls_roots_password", + "max_datagram_size", + "multicast_ttl", ]; function validateConfigKey(key: string) { @@ -467,16 +661,25 @@ function parseProtocol(options: SenderOptions, configString: string) { case HTTPS: case TCP: case TCPS: + case WS: + case WSS: + case UDP: 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', 'udp'`, ); } return index + 2; } function parseProtocolVersion(options: SenderOptions) { + if (options.protocol === UDP) { + if (options.protocol_version !== undefined) { + throw new Error("'protocol_version' is not used by the udp transport"); + } + return; + } const protocol_version = options.protocol_version ?? PROTOCOL_VERSION_AUTO; switch (protocol_version) { case PROTOCOL_VERSION_AUTO: @@ -518,9 +721,12 @@ function parseAddress(options: SenderOptions) { case TCPS: options.port = TCP_PORT; return; + case UDP: + options.port = QWP_UDP_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', 'udp'`, ); } } @@ -551,12 +757,22 @@ 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 !== UDP) { + throw new Error("auto_flush_bytes is only supported for the udp transport"); + } parseInteger(options, "auto_flush_interval", "auto flush interval", 0); } function parseTlsOptions(options: SenderOptions) { parseBoolean(options, "tls_verify", "TLS verify", UNSAFE_OFF); + validateUdpSecurityOptions(options); + 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`, @@ -574,6 +790,38 @@ 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", + ); + } +} + +/** @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.username !== undefined || + options.password !== undefined || + options.token !== undefined + ) { + throw new Error("authentication is not supported for QWP UDP transport"); + } +} + function parseStdlibTransport(options: SenderOptions) { parseBoolean(options, "stdlib_http", "stdlib http"); } @@ -624,11 +872,17 @@ function parseInteger( export { SenderOptions, + qwpConfig, ExtraOptions, + QwpExtraOptions, HTTP, HTTPS, TCP, TCPS, + WS, + WSS, + UDP, + validateUdpSecurityOptions, PROTOCOL_VERSION_AUTO, PROTOCOL_VERSION_V1, PROTOCOL_VERSION_V2, diff --git a/src/qwp-node/advisory-lock.ts b/src/qwp-node/advisory-lock.ts new file mode 100644 index 0000000..9c037b5 --- /dev/null +++ b/src/qwp-node/advisory-lock.ts @@ -0,0 +1,538 @@ +import { + mkdir, + readFile, + rename, + rm, + rmdir, + stat, + unlink, + utimes, + writeFile, +} from "node:fs/promises"; +import { randomUUID } from "node:crypto"; +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"; + +// 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(); + +// 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; + +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. */ +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 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 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; + /** When this object last proved it still owned the directory. */ + private provenAtMs = Date.now(); + private heartbeat?: NodeJS.Timeout; + + private constructor( + readonly lockPath: string, + readonly pidPath: string, + private readonly ownerPath: string, + private ownerMtimeMs: number, + private readonly token: string, + ) { + this.startHeartbeat(); + } + + 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 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 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); + } + } + + private static async acquireAt( + lockPath: string, + pidPath: string, + ): Promise { + await retryPendingReleases(); + 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)) { + claimed = await claimOwnerDirectory(ownerPath); + } + if (!claimed) { + throw new QwpNodeAdvisoryLockBusyError( + lockPath, + await readHolderPid(pidPath), + ); + } + } + + const token = newOwnerToken(); + let ownerMtimeMs: number; + try { + await writeFile( + join(ownerPath, OWNER_FILE), + JSON.stringify({ pid: process.pid, host: hostname(), token }), + { 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) { + await removeOwnerDirectory(ownerPath).catch(() => undefined); + throw new QwpNodeAdvisoryLockError( + "could not establish QWP advisory lock", + lockPath, + error, + ); + } + + // Diagnostic-only, matching Java SlotLock: failure to refresh the sidecar + // 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, + ownerPath, + ownerMtimeMs, + token, + ); + } + + 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, + ); + } + // 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. + 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) { + // 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", + this.lockPath, + error, + ); + } + this.released = true; + pendingReleases.delete(this); + } + + /** + * 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 ownershipState(): Promise<"owned" | "foreign" | "unknown"> { + const owner = await readOwnerFile(this.ownerPath); + 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 { + 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 { + // 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)) { + // Someone judged this lock stale and took it. Stop refreshing so the + // new owner's heartbeat is the only one advancing the mtime. + 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. + 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) { + // 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 { + for (const lock of [...pendingReleases]) { + await lock.release().catch(() => undefined); + } +} + +/** 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); +} + +/** + * 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): 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, 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; +} + +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 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.state === "present" && + owner.record.host === hostname() && + !isPidAlive(owner.record.pid) + ); +} + +/** + * 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 { + 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 { + 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 torn write, caught mid-`writeFile` by a contender that is still + // establishing itself. Not proof that this acquisition lost anything. + return { state: "unreadable" }; + } +} + +/** Identifies one acquisition, so a release can prove what it is removing. */ +function newOwnerToken(): string { + return `${process.pid}-${randomUUID()}`; +} + +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 { + 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/client-config.ts b/src/qwp-node/client-config.ts new file mode 100644 index 0000000..d9ccaaa --- /dev/null +++ b/src/qwp-node/client-config.ts @@ -0,0 +1,941 @@ +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 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; + +/** + * 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 the legacy udp transport only)"], + ["multicast_ttl", "(applies to the legacy udp transport only)"], +]); + +/** + * @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", + "user", + "pass", + "token", + "tls_verify", + "tls_roots", + "tls_roots_password", + "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", + "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 JavaScript client exposes these policies. + "on_internal_error", + "on_parse_error", + "on_schema_error", + "on_security_error", + "on_server_error", + "on_write_error", +]); + +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); + const lazyConnect = + optionalBoolean(value("lazy_connect"), "lazy_connect") ?? false; + const initialConnectMode = resolveInitialConnectMode( + parsed.values, + lazyConnect, + ); + + validateAuthentication(parsed.values); + validateTls(parsed); + + 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, + 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: callerAgent ?? configuredAgent, + }; + + const ingressReconnect = parseIngressReconnect(parsed.values); + const egressReconnect = parseEgressReconnect(parsed.values); + const configuredStoreAndForward = parseStoreAndForward( + parsed.values, + extraOptions.storeAndForward?.directory, + initialConnectMode, + ); + 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, + ) ?? 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, + 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", + 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", + 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 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"), + storeAndForward, + senderId: validateSenderId(value("sender_id") ?? "default"), + }; + const egress: QwpNodeEgressOptions = { + ...common, + url: withPath(endpoints[0], "/read/v1"), + failoverUrls: endpoints + .slice(1) + .map((endpoint) => withPath(endpoint, "/read/v1")), + target, + 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, + }; +} + +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 (!QWP_SUPPORTED_CONFIG_KEYS.has(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; + 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]; + 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 || + tlsRoots !== undefined || + tlsRootsPassword !== undefined) + ) { + throw new Error( + "tls_verify, tls_roots, and tls_roots_password are only supported by the wss schema", + ); + } + 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( + "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", + ); + } +} + +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; + const roots = tlsRoots ? readPemTlsRoots(tlsRoots) : undefined; + return new HttpsAgent({ + ca: roots, + rejectUnauthorized: tlsVerify !== "unsafe_off", + }); +} + +function readPemTlsRoots(path: string): Buffer { + const roots = readFileSync(path); + 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 PEM-encoded CA certificates (CERTIFICATE or TRUSTED CERTIFICATE); PKCS#12 trust stores are not supported by the Node.js QWP client", + ); + } + return roots; +} + +function parseIngressReconnect( + values: ReadonlyMap, +): QwpReconnectOptions | undefined { + const reconnect: QwpReconnectOptions = { + initialBackoffMs: optionalPositiveInteger( + values.get("reconnect_initial_backoff_millis")?.[0], + "reconnect_initial_backoff_millis", + ), + maxBackoffMs: optionalPositiveInteger( + values.get("reconnect_max_backoff_millis")?.[0], + "reconnect_max_backoff_millis", + ), + maxDurationMs: optionalPositiveInteger( + values.get("reconnect_max_duration_millis")?.[0], + "reconnect_max_duration_millis", + ), + 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 | false | 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 false; + // The Java facade defaults egress failover to on for cluster strings. + return failover === true || hasDefinedValue(reconnect) + ? reconnect + : undefined; +} + +function parseStoreAndForward( + values: ReadonlyMap, + fallbackDirectory?: string, + initialConnectMode?: "off" | "sync" | "async", +): 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, + ) ?? 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: "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", + 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 = [ + "catch_up_cap_gap_min_escalation_window_millis", + "drain_orphans", + "max_background_drainers", + "sf_durability", + "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 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, +): 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/file-replay-store.ts b/src/qwp-node/file-replay-store.ts new file mode 100644 index 0000000..25a5973 --- /dev/null +++ b/src/qwp-node/file-replay-store.ts @@ -0,0 +1,3204 @@ +import { randomUUID } from "node:crypto"; +import { + mkdir, + open, + readdir, + readFile, + rename, + stat, + unlink, + writeFile, +} 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 { + QwpIngressReplayRecord, + QwpIngressReplayReference, + QwpIngressReplayStore, +} from "../_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"; + +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("AKW1"); +const ACK_FILE = ".ack-watermark"; +const DICTIONARY_MAGIC = Buffer.from("SYD1"); +const DICTIONARY_FILE = ".symbol-dict"; +const DICTIONARY_HEADER_SIZE = 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 QUARANTINE_SLOT_INFIX = ".unreplayable-"; +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. +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; +// 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 }); + +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; + readonly payloadOffset?: number; + readonly payloadLength?: number; + readonly segment?: StoredSegment; +} + +interface StoredSegment { + readonly path: string; + readonly firstSequence: bigint; + readonly capacity: number; + readonly size: number; + logicalSize: number; + liveRecords: number; + frameCount: number; + manifestFlagPending: boolean; + handle?: FileHandle; +} + +interface HotSpareSegment { + path: string; + readonly generation: bigint; + readonly size: number; + readonly handle: FileHandle; +} + +interface ScannedRecord extends QwpIngressReplayReference { + readonly payloadOffset: number; +} + +interface RecoveredStoredRecord { + 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; + 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 at and after the damaged record that recovery could not retain. */ + readonly discardedBytes: number; + readonly reason: string; +} + +export interface QwpNodeFileReplayStoreOptions { + /** Exclusive directory used by one ingress session. */ + directory: string; + /** + * 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. 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. + */ + maxSegmentBytes?: 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; + /** + * 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 { + readonly durability: QwpSfDurability; + readonly backpressurePolicy: QwpSfBackpressurePolicy; + readonly pendingRecords: number; + readonly pendingSegments: 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 { + 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"; + this.cause = cause; + } +} + +/** 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"; + } +} + +/** 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"; + } +} + +/** + * 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 { + /** + * 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}]`, + ); + this.name = "QwpReplayStoreLockLostError"; + } +} + +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"; + } +} + +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, + 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, + readonly holderPid?: number, + ) { + const holder = holderPid === undefined ? "unknown" : String(holderPid); + super( + `QWP store-and-forward directory is already in use [directory=${directory}, holder=${holder}]`, + ); + this.name = "QwpReplayStoreLockedError"; + } +} + +/** + * 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. + */ +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; + 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[] = []; + private readonly symbols: string[] = []; + 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; + private dictionaryLoadError?: unknown; + 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; + private checkpointFailure?: QwpReplayStoreCheckpointError; + private maintenanceFailure?: QwpReplayStoreError; + private maintenanceRetryTimer?: ReturnType; + private totalCheckpoints = 0; + private totalCheckpointFailures = 0; + private totalBackpressureStalls = 0; + private totalAppendTimeouts = 0; + private slotLock?: QwpNodeAdvisoryLock; + private closePromise?: Promise; + private loaded = false; + private closing = false; + private closed = false; + private activeSegment?: StoredSegment; + private hotSpare?: HotSpareSegment; + private hotSpareTask?: Promise; + private nextSegmentGeneration = 0n; + private manifestGeneration = 0n; + private manifestHeadBase?: bigint; + private manifestActiveBase?: bigint; + private manifestInvalid = false; + private ackGeneration = 0n; + private maintenanceScheduled = 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 <= SEGMENT_HEADER_SIZE) { + throw new RangeError( + `store-and-forward maxBytes must be a safe integer greater than ${SEGMENT_HEADER_SIZE}`, + ); + } + this.directory = directory; + this.maxBytes = maxBytes; + this.maxSegmentBytes = validatePositiveSafeInteger( + 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 + FRAME_HEADER_SIZE + this.maxSegmentBytes; + 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, + ); + 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.onRecoveryDataLoss = options.onRecoveryDataLoss; + 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, + 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, + totalCheckpointFailures: this.totalCheckpointFailures, + totalBackpressureStalls: this.totalBackpressureStalls, + totalAppendTimeouts: this.totalAppendTimeouts, + lastCheckpointError: this.checkpointFailure, + }); + } + + 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(); + if (this.loaded) { + throw new QwpReplayStoreError( + "QWP store-and-forward journal has already been loaded", + ); + } + 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 }); + const segmentNames: 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(SEGMENT_SUFFIX)) { + segmentNames.push(entry.name); + } + } + if (removedTemporaryFile) await syncDirectory(this.directory); + segmentNames.sort(); + + await this.loadManifest(); + const acknowledgedThrough = await this.loadAcknowledgedThrough(); + const recoveredEntries: RecoveredStoredRecord[] = []; + const recoveredSegments: Array<{ + 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 handle: FileHandle | undefined; + try { + 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 scan QWP store-and-forward segment [file=${name}]`, + error, + ); + } + } + recoveredSegments.sort((left, right) => + compareBigInt( + left.decoded.firstSequence, + 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, handle } = recoveredSegments[index]; + if (manifestStalePaths.has(path)) { + await handle.close(); + recoveryHandles.delete(handle); + removalPaths.push(path); + changedDirectory = true; + continue; + } + if (decoded.tornTail) { + if (path !== selectedActivePath) { + throw corruptRecord( + name, + "non-active segment has a torn record tail", + ); + } + 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, + discardedBytes: Math.max( + 0, + decoded.size - SEGMENT_HEADER_SIZE - decoded.logicalSize, + ), + 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( + path, + SEGMENT_HEADER_SIZE + decoded.logicalSize, + decoded.size, + this.directory, + ); + } + 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( + (record) => record.frameSequence > acknowledgedThrough, + ); + 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; + } + const segment: StoredSegment = { + path, + firstSequence: decoded.firstSequence, + capacity: decoded.capacity, + size: decoded.size, + logicalSize: decoded.logicalSize, + 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, + payloadOffset: record.payloadOffset, + payloadLength: record.payloadLength, + 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 (changedDirectory) await syncDirectory(this.directory); + recoveredEntries.sort((left, right) => + left.record.frameSequence < right.record.frameSequence + ? -1 + : left.record.frameSequence > right.record.frameSequence + ? 1 + : 0, + ); + let previous = acknowledgedThrough; + const recovered: QwpIngressReplayReference[] = []; + 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({ + frameSequence: record.frameSequence, + payloadLength: record.payloadLength, + }); + previous = record.frameSequence; + } + if (recovered.length === 0 && acknowledgedThrough >= 0n) { + await this.removeAcknowledgedThrough(); + } + 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; + await this.ensureHotSpare(false); + loadSucceeded = true; + this.scheduleCheckpoint(); + return recovered; + } finally { + if (!loadSucceeded) { + try { + await Promise.all([ + this.closeSegmentHandles(), + ...[...recoveryHandles].map((handle) => + handle.close().catch(() => undefined), + ), + ]); + } finally { + await this.releaseDirectoryLock(); + } + } + } + }); + } + + 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) { + return Promise.reject( + new QwpReplayStoreSegmentTooLargeError( + this.maxSegmentBytes, + record.payload.byteLength, + ), + ); + } + const bytes = encodeRecord(record); + if (bytes.byteLength > this.maxBytes) { + return Promise.reject( + new QwpReplayStoreFullError(this.maxBytes, bytes.byteLength), + ); + } + return this.appendWithBackpressure(record, bytes); + } + + acknowledgeThrough(frameSequence: bigint): Promise { + if (this.closing || this.closed) return Promise.reject(this.closedError()); + return this.enqueue(async () => { + this.assertReady(); + 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) { + if (this.activeSegment === segment) this.activeSegment = undefined; + this.pendingTrimSegments.push(segment); + } + this.scheduleMaintenance(); + }); + } + + loadSymbolDictionary(): Promise { + 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(); + }); + } + + appendSymbolDictionary( + startId: number, + entries: readonly string[], + ): Promise { + 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}]`, + ); + } + 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; + 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]), + ); + 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.dictionaryDirty = true; + this.directoryDirty = true; + } + } 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); + 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(); + } + } 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; + }); + } + + 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; + 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; + try { + await this.drainPendingMaintenance(); + if (this.durability === QWP_SF_DURABILITY.PERIODIC) { + await this.checkpointDirty(); + } + if (this.checkpointFailure) throw this.checkpointFailure; + await this.retireDrainedDictionary(); + } catch (error) { + failure = error; + } + try { + await this.hotSpareTask?.catch((error) => { + 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; + } + 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. + await QwpNodeAdvisoryLock.removeOrphanLogical(this.directory); + } + 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: EncodedRecord, + ): 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) { + 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; + this.totalBackpressureStalls++; + } + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) { + this.totalAppendTimeouts++; + throw new QwpReplayStoreAppendTimeoutError( + this.maxBytes, + requiredBytes, + this.appendDeadlineMs, + ); + } + await this.waitForCapacity( + capacityGeneration, + remainingMs, + requiredBytes, + ); + } + } + } + + private async appendOnce( + record: QwpIngressReplayRecord, + bytes: EncodedRecord, + ): 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 lastSequence = + lastMapKey(this.records) ?? + (this.acknowledgedThrough >= 0n ? this.acknowledgedThrough : undefined); + if ( + 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 writevFully(handle, [bytes.header, bytes.payload], writeOffset); + if (this.durability === QWP_SF_DURABILITY.APPEND) { + await handle.datasync(); + } 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++; + segment.frameCount++; + this.records.set(record.frameSequence, { + path: segment.path, + size: 0, + payloadOffset: writeOffset + FRAME_HEADER_SIZE, + payloadLength: record.payload.byteLength, + 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.datasync(); + this.dirtyRecordPaths.delete(previous.path); + } + } + await this.ensureHotSpare(true); + const spare = this.hotSpare; + if (!spare) { + throw new QwpReplayStoreFullError( + this.maxBytes, + 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(firstSequence, false), + 0, + ); + 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}]`, + error, + ); + } + const segment: StoredSegment = { + path: finalPath, + firstSequence, + 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.segmentOrder.push(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); + } + return segment; + } + + private async ensureHotSpare(required: boolean): Promise { + 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 = + this.dictionaryFileSize > 0 && + (frameBytes < this.liveFrameBytes || this.segments.size === 0); + if (requiredBytes > this.maxBytes && !preservesLiveness) { + if (required) { + throw new QwpReplayStoreFullError(this.maxBytes, requiredBytes); + } + return; + } + const generation = this.nextSegmentGeneration++; + const name = segmentFileName(generation); + const temporaryPath = join( + this.directory, + `${name}${TEMP_MARKER}${process.pid}-${randomUUID()}`, + ); + let handle: FileHandle | undefined; + this.totalBytes = requiredBytes; + try { + 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; + } + this.hotSpare = { + path: temporaryPath, + generation, + size: this.segmentFileSize, + handle, + }; + handle = undefined; + } catch (error) { + await handle?.close().catch(() => undefined); + 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, + ); + } + } + + private scheduleHotSpare(): void { + if (this.hotSpare || this.closing || this.closed) return; + queueMicrotask(() => { + if (this.hotSpare || this.closing || this.closed) return; + 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. + }); + }); + } + + 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, + ); + // 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(); + }); + }); + } + + 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]; + await this.trimSegment(segment); + this.pendingTrimSegments.shift(); + trimmed++; + } + if (trimmed > 0) { + if (this.durability === QWP_SF_DURABILITY.APPEND) { + await qwpSegmentMaintenanceWorker.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(); + // 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 { + 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(); + } + if (this.records.size === 0) await this.removeAcknowledgedThrough(); + } + + private async trimSegment(segment: StoredSegment): Promise { + try { + await segment.handle?.close(); + segment.handle = undefined; + 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 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}]`, + error, + ); + } + 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; + } + + 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 { + await handle.close(); + } catch (error) { + failure ??= error; + } + } + 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(); + // 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) { + await qwpSegmentMaintenanceWorker.syncDirectory(this.directory); + } + } catch (error) { + throw new QwpReplayStoreError( + `could not discard QWP store-and-forward hot spare [file=${spare.path}]`, + error, + ); + } + } + + private waitForCapacity( + capacityGeneration: number, + timeoutMs: number, + requiredBytes: 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 }; + pending.timer = setTimeout(() => { + if (!this.capacityWaiters.delete(pending)) return; + this.totalAppendTimeouts++; + reject( + new QwpReplayStoreAppendTimeoutError( + this.maxBytes, + 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.acknowledgementDirty && + !this.directoryDirty + ) { + return; + } + try { + const paths = [...this.dirtyRecordPaths]; + if (this.dictionaryDirty) { + paths.push(join(this.directory, DICTIONARY_FILE)); + } + if (this.acknowledgementDirty) { + paths.push(join(this.directory, ACK_FILE)); + } + await qwpSegmentMaintenanceWorker.checkpoint( + paths, + this.directoryDirty ? this.directory : undefined, + ); + this.dirtyRecordPaths.clear(); + this.dictionaryDirty = false; + this.acknowledgementDirty = false; + this.acknowledgementUnsynced = 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 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 { + if (this.segmentOrder.length === 0) { + await this.removeManifest(); + return; + } + await this.writeManifest( + this.segmentOrder[0].firstSequence, + this.segmentOrder[this.segmentOrder.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}]`, + ); + } + 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. + await this.syncAcknowledgement(); + 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 { + if (!this.ownsDirectory) return; + 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; + 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, + ); + } + 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; + } + + private async persistAcknowledgedThrough( + frameSequence: bigint, + ): Promise { + if (frameSequence <= this.acknowledgedThrough) return; + const finalPath = join(this.directory, ACK_FILE); + const nextGeneration = this.ackGeneration + 1n; + const record = encodeMetadataRecord( + ACK_MAGIC, + nextGeneration, + frameSequence, + 0n, + ); + try { + const file = await openMetadataFile(finalPath); + try { + await writeFully( + file, + record, + Number((nextGeneration & 1n) * BigInt(RECORD_SLOT_SIZE)), + ); + if (this.durability === QWP_SF_DURABILITY.APPEND) { + await file.sync(); + this.acknowledgementUnsynced = false; + } else { + this.acknowledgementUnsynced = true; + } + } finally { + await file.close(); + } + if (this.durability === QWP_SF_DURABILITY.PERIODIC) { + this.acknowledgementDirty = true; + } + this.ackGeneration = nextGeneration; + this.acknowledgedThrough = frameSequence; + } catch (error) { + throw new QwpReplayStoreError( + `could not persist QWP store-and-forward ACK watermark [frameSequence=${frameSequence}]`, + error, + ); + } + } + + /** + * 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; + } + // 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)); + } + + /** + * 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; + if (!this.ownsDirectory) 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) { + this.directoryDirty = true; + } + } + + /** + * 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 || + !this.ownsDirectory + ) { + 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( + () => undefined, + () => undefined, + ); + return result; + } + + private assertOpen(): void { + if (this.closed) throw this.closedError(); + } + + private async acquireDirectoryLock(): Promise { + 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, + error.holderPid, + ); + } else { + failure = new QwpReplayStoreError( + `could not acquire QWP store-and-forward directory lock [directory=${this.directory}]`, + error, + ); + } + } + if (logicalLock) { + try { + await logicalLock.release(); + } catch (error) { + 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 slotLock = this.slotLock; + if (!slotLock) return; + try { + await slotLock.release(); + this.slotLock = 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; + 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)}`); + } + 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) { + const chunk = decodeDictionaryChunk(bytes, offset); + if (!chunk) { + await truncateDictionaryTail(path, offset, this.directory); + break; + } + 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}`, + ); + } + 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}'`, + ); + } + this.symbolValues.add(entry); + this.symbols.push(entry); + } + offset = chunk.end; + } + this.dictionaryFileSize = offset; + this.totalBytes += offset; + // 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. + } + + /** + * 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) { + throw new QwpReplayStoreError( + "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; + } + + private closedError(): QwpReplayStoreError { + return new QwpReplayStoreError("QWP store-and-forward journal is closed"); + } +} + +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 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 { + readonly firstSequence: bigint; + readonly manifestRequired: boolean; + readonly capacity: number; + readonly size: number; + readonly records: ScannedRecord[]; + /** 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 + * records that are still on disk, so recovery quarantines instead. + */ + readonly interiorDamage?: 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( + firstSequence: bigint, + manifestRequired: boolean, +): Buffer { + validateFrameSequence(firstSequence); + const bytes = Buffer.alloc(SEGMENT_HEADER_SIZE); + SEGMENT_MAGIC.copy(bytes, 0); + bytes.writeUInt8(FORMAT_VERSION, 4); + 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; +} + +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"); + } + 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 (segmentHeader.readUInt8(4) !== FORMAT_VERSION) { + throw corruptRecord( + name, + `unsupported segment version ${segmentHeader.readUInt8(4)}`, + ); + } + const flags = segmentHeader.readUInt8(5); + if ((flags & ~MANIFEST_REQUIRED_FLAG) !== 0) { + throw corruptRecord(name, `unsupported segment flags ${flags}`); + } + if (segmentHeader.readUInt16LE(6) !== 0) { + throw corruptRecord(name, "segment reserved field is not zero"); + } + const firstSequence = segmentHeader.readBigUInt64LE(8); + validateFrameSequence(firstSequence); + const capacity = fileSize - SEGMENT_HEADER_SIZE; + const records: ScannedRecord[] = []; + const frameHeader = scratch.frameHeader; + const scanBuffer = scratch.data; + let offset = SEGMENT_HEADER_SIZE; + while (offset < fileSize) { + const remaining = fileSize - offset; + const headerBytes = Math.min(remaining, FRAME_HEADER_SIZE); + await readFully(handle, frameHeader.subarray(0, headerBytes), offset); + 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, + capacity, + size: fileSize, + records, + logicalSize: offset - SEGMENT_HEADER_SIZE, + // 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) { + return { + firstSequence, + manifestRequired: (flags & MANIFEST_REQUIRED_FLAG) !== 0, + capacity, + size: fileSize, + records, + logicalSize: offset - SEGMENT_HEADER_SIZE, + tornTail: true, + }; + } + const payloadLength = frameHeader.readUInt32LE(4); + const recordEnd = offset + FRAME_HEADER_SIZE + payloadLength; + if (recordEnd > fileSize) { + return { + firstSequence, + manifestRequired: (flags & MANIFEST_REQUIRED_FLAG) !== 0, + capacity, + size: fileSize, + records, + logicalSize: offset - SEGMENT_HEADER_SIZE, + tornTail: true, + }; + } + 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: fileSize, + 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( + handle, + recordEnd, + fileSize, + scratch, + ), + }; + } + const frameSequence = firstSequence + BigInt(records.length); + validateFrameSequence(frameSequence); + records.push({ + frameSequence, + payloadLength, + payloadOffset: offset + FRAME_HEADER_SIZE, + }); + offset = recordEnd; + } + return { + firstSequence, + manifestRequired: (flags & MANIFEST_REQUIRED_FLAG) !== 0, + capacity, + size: fileSize, + records, + logicalSize: offset - SEGMENT_HEADER_SIZE, + tornTail: false, + }; +} + +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; +} + +/** + * 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, + 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); + header.writeUInt8(FORMAT_VERSION, 4); + return header; +} + +function encodeDictionaryBlock( + startId: number, + entries: readonly string[], +): Buffer { + if (!Number.isSafeInteger(startId) || startId < 0) { + 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}`, + ); + } + 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 entryBytes = 0; + for (const entry of encoded) { + entryBytes += unsignedVarintSize(entry.byteLength) + entry.byteLength; + if (entryBytes > 0xffffffff) { + throw new QwpReplayStoreError( + "QWP symbol dictionary block payload is too large", + ); + } + } + 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) { + offset = writeUnsignedVarint(block, offset, entry.byteLength); + entry.copy(block, offset); + offset += entry.byteLength; + } + 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 { + 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; +} + +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}`, + ); +} + +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); +} + +async function repairSegmentTail( + path: string, + logicalEnd: number, + fixedSize: number, + directory: string, +): Promise { + const file = await open(path, "r+"); + try { + await file.truncate(logicalEnd); + await file.truncate(fixedSize); + await file.sync(); + } finally { + await file.close(); + } + 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, +): 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 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, + ); + } + 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; ${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; + } + try { + await logicalLock.release(); + } catch (error) { + failure ??= new QwpReplayStoreError( + `could not release QWP store-and-forward logical lock after quarantine [directory=${normalized}]`, + error, + ); + } + if (failure) throw failure; + return result!; +} + +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( + `QWP store-and-forward sequence is outside uint64 range [frameSequence=${frameSequence}]`, + ); + } +} + +function segmentFileName(generation: bigint): string { + 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 { + 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; + } +} + +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, + 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 { + 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(); + } +} + +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; +} + +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; + } 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-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/qwp-node/orphan-drainer.ts b/src/qwp-node/orphan-drainer.ts new file mode 100644 index 0000000..e8ce7a4 --- /dev/null +++ b/src/qwp-node/orphan-drainer.ts @@ -0,0 +1,659 @@ +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, + QwpReplayRejectedError, + QwpUpgradeError, +} from "../_qwp/transport"; +import { + isQwpNodeReplayQuarantineSlotName, + QwpReplayStoreCorruptionError, + QwpReplayStoreLockedError, +} from "./file-replay-store"; +import { QwpProtocolError } from "../_qwp/_core/errors"; +import { + QwpCatchUpCapGapError, + QwpDurableAckPersistentFailureError, +} from "../_qwp/_internal/reconnecting-ingress-connection"; +import { QwpNotificationDispatcher } from "../_qwp/_internal/notification-dispatcher"; +import { + createQwpDataLossSenderError, + defaultQwpSenderErrorHandler, + type QwpSenderError, +} from "../_qwp/sender-error"; + +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; +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. */ +/** 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", + 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", + 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; + /** 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; +} + +export interface QwpNodeOrphanDrainerMetrics { + readonly scans: number; + readonly discovered: number; + readonly queued: number; + 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; + readonly droppedNotifications: number; + readonly deliveredErrorNotifications: number; + readonly droppedErrorNotifications: 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, + 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; + /** + * 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; + 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; +} + +/** + * 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() || + isQwpNodeReplayQuarantineSlotName(entry.name) || + 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; + } + 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); + } + } + candidates.sort(); + 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 < SEGMENT_HEADER_SIZE || + header.toString("ascii", 0, 4) !== "SF01" || + header.readUInt8(4) !== 1 || + header.readUInt16LE(6) !== 0 + ) { + return true; + } + 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. + 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. + */ +export class QwpNodeOrphanDrainer { + private readonly rootDirectory: string; + private readonly excludeSlot?: (slotName: string) => boolean; + private readonly createSession: ( + 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; + private readonly eventDispatcher?: QwpNotificationDispatcher; + private readonly errorDispatcher?: QwpNotificationDispatcher; + 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 scanRequested = false; + 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 retrying = 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", + ); + } + 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; + 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; + if (options.onEvent) { + this.eventDispatcher = new QwpNotificationDispatcher( + options.onEvent, + options.eventInboxCapacity ?? + DEFAULT_CONNECTION_LISTENER_INBOX_CAPACITY, + ); + } + this.errorDispatcher = new QwpNotificationDispatcher( + options.onSenderError ?? defaultQwpSenderErrorHandler, + options.errorInboxCapacity ?? DEFAULT_ERROR_INBOX_CAPACITY, + ); + } + + 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, + retrying: this.retrying, + 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, + }); + } + + /** Starts an immediate scan and the optional periodic scanner. */ + start(): void { + if (this.started || this.closing || this.closed) return; + this.started = true; + 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 { + 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"), + ); + } + } + + 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?.(); + } + } + + 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; + 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), + ); + 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"); + 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); + } finally { + if (session) { + this.active.delete(directory); + await session + .close(1000, "QWP orphan slot drained") + .catch(() => undefined); + } + if (reserved) this.releaseSlot?.(directory); + } + } + + 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.scanRequested = false; + 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(); + await Promise.all([ + this.eventDispatcher?.close(), + this.errorDispatcher?.close(), + ]); + this.closed = true; + } + + private emit( + kind: QwpNodeOrphanDrainEventKind, + directory?: string, + error?: Error, + attempt?: number, + episodeMs?: number, + ): void { + 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, + 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 { + 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)); +} + +/** + * 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 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. + * + * 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 QwpCatchUpCapGapError) 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 }); +} + +function nodeErrorCode(error: unknown): string | undefined { + return error && typeof error === "object" && "code" in error + ? String(error.code) + : undefined; +} 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-node/udp-sender.ts b/src/qwp-node/udp-sender.ts new file mode 100644 index 0000000..d55cb9c --- /dev/null +++ b/src/qwp-node/udp-sender.ts @@ -0,0 +1,398 @@ +import { createSocket, type Socket } from "node:dgram"; +import { + encodeQwpIngressFrame, + type QwpIngressEncodeOptions, + type QwpIngressResponse, + 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; + +/** 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, + }); + } + + // 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 { + this.assertOpen(); + if (options.deferCommit) { + throw new Error( + "QWP UDP does not support transactions or deferred commit", + ); + } + // 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); + } + + publishTables( + tables: readonly QwpTableBuffer[], + options: QwpIngressEncodeOptions = {}, + ): Promise { + 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 { + 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) => { + // `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; + } + + 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++; + // 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 { + if (this.closing || this.closed) + throw new Error("QWP UDP sender is closed"); + } +} + +function encodeUdpDatagrams( + tables: readonly QwpTableBuffer[], + maxDatagramSize: number, + gorilla = false, +): Uint8Array[] { + 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; + // 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; + while (low <= high) { + const end = Math.floor((low + high) / 2); + const encoded = encodeQwpIngressFrame([table.sliceRows(start, end)], { + gorilla, + }); + 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 }, + ); + throw new QwpUdpDatagramTooLargeError( + maxDatagramSize, + smallestRejectedSize || oneRow.byteLength, + table.name, + start, + ); + } + result.push(accepted); + window = acceptedEnd - start; + 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/browser.ts b/src/qwp/browser.ts new file mode 100644 index 0000000..f027f2e --- /dev/null +++ b/src/qwp/browser.ts @@ -0,0 +1,869 @@ +/** Browser WebSocket adapter and browser-safe QWP protocol/session APIs. */ +export * from "./index"; + +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"; +import { + addQwpDurableAckWebSocketProtocol, + decodeQwpIngressServerInfo, + encodeQwpAcceptEncoding, + isQwpDurableAckWebSocketProtocol, + QwpEgressCompression, + QWP_VERSION, +} from "../_qwp/_core"; +import { + QwpBinaryConnection, + QwpConnectionFactory, + QwpDurableAckUnavailableError, + QwpEgressRoutingOptions, + QWP_UPGRADE_ERROR_KIND, + QwpUpgradeError, + QwpWebSocketConnectOptions, +} from "../_qwp/transport"; +import { + QWP_DEFAULT_EGRESS_SERVER_INFO_TIMEOUT_MS, + QwpEgressSession, + QwpEgressSessionOptions, +} 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 "../_qwp/_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 === 429 || 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; + /** + * 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. + */ + sessionBootstrap?: QwpBrowserSessionBootstrapConfig; + /** Test or framework hook; defaults to the browser's global WebSocket. */ + webSocketFactory?: ( + url: string | URL, + protocols?: string | string[], + ) => QwpWebSocketLike; +} + +/** Browser WebSocket options plus protocol-level egress topology routing. */ +export interface QwpBrowserEgressOptions + extends QwpBrowserWebSocketOptions, + 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; + /** Requests a server-side RESULT_BATCH row cap. */ + maxBatchRows?: number; +} + +/** 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. + * + * 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, pass sessionBootstrap or call + * bootstrapQwpBrowserSession first so the browser can attach qdb_session. + */ +export function connectQwpBrowserWebSocket( + options: QwpBrowserWebSocketOptions, +): Promise { + return createQwpFailoverConnectionFactory( + options.url, + options.failoverUrls, + (endpoint, signal) => + connectQwpBrowserRawEndpoint(options, endpoint, signal), + )(); +} + +/** Creates a stateful browser endpoint walker suitable for session reconnects. */ +export function createQwpBrowserConnectionFactory( + options: QwpBrowserWebSocketOptions, +): QwpConnectionFactory { + return createQwpFailoverConnectionFactory( + options.url, + options.failoverUrls, + (endpoint, signal) => + connectQwpBrowserIngressEndpoint(options, endpoint, signal), + ); +} + +async function connectQwpBrowserEndpoint( + options: QwpBrowserWebSocketOptions, + endpoint: string | URL, + requestEndpoint: string | URL, + protocols: string | string[] | undefined, + signal: AbortSignal | undefined, + completeHandshake: ( + selectedProtocol: string | undefined, + ) => QwpBinaryConnection["handshake"], +): 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[]) => { + 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); + return openQwpWebSocket(socket, { + signal, + url: endpoint, + connectTimeoutMs: options.connectTimeoutMs, + sendTimeoutMs: options.sendTimeoutMs, + closeTimeoutMs: options.closeTimeoutMs, + 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, + signal?: AbortSignal, +): Promise { + const protocols = options.requestDurableAck + ? addQwpDurableAckWebSocketProtocol(options.protocols) + : options.protocols; + return connectQwpBrowserEndpoint( + options, + endpoint, + endpoint, + protocols, + signal, + (selectedProtocol) => { + const durableAckEnabled = + isQwpDurableAckWebSocketProtocol(selectedProtocol); + if (options.requestDurableAck && !durableAckEnabled) { + throw new QwpDurableAckUnavailableError(endpoint); + } + return durableAckEnabled + ? { qwpVersion: QWP_VERSION, durableAckEnabled: true } + : { qwpVersion: QWP_VERSION }; + }, + ); +} + +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, + signal?: AbortSignal, +): 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, + signal, + (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, + signal?: AbortSignal, +): Promise { + const compression = options.compression ?? "raw"; + const acceptEncoding = encodeQwpAcceptEncoding( + compression, + options.compressionLevel ?? 1, + ); + 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, + requestEndpoint, + options.protocols, + signal, + () => ({ + qwpVersion: QWP_VERSION, + negotiatedCompression: { codec: "raw", level: 0 }, + }), + ); +} + +/** Opens a browser WebSocket and starts an ingress ACK/NACK session. */ +export async function connectQwpBrowserIngress( + options: QwpBrowserWebSocketOptions, + sessionOptions: QwpIngressSessionOptions = {}, + /** 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 + ? (sessionOptions.durableAckKeepaliveMs ?? 200) + : sessionOptions.durableAckKeepaliveMs, + }; + return QwpIngressSession.connect( + createQwpBrowserConnectionFactory(options), + effectiveSessionOptions, + signal, + ); +} + +/** + * 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( + (signal) => + connectQwpBrowserIngress( + { + ...options, + requestDurableAck: + options.requestDurableAck ?? senderOptions.awaitDurableAck, + }, + sessionOptions, + signal, + ), + 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: QwpBrowserEgressOptions, + sessionOptions: QwpEgressSessionOptions = {}, +): Promise { + return QwpEgressSession.connect( + createQwpEgressFailoverConnectionFactory( + options.url, + options.failoverUrls, + (endpoint, signal) => + connectQwpBrowserEgressEndpoint(options, endpoint, signal), + { target: options.target, zone: options.zone }, + sessionOptions.serverInfoTimeoutMs ?? + QWP_DEFAULT_EGRESS_SERVER_INFO_TIMEOUT_MS, + ), + sessionOptions, + ); +} + +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( + resolved.ingress, + resolved.sender, + resolved.ingressSession, + ); + try { + await sender.connect(); + return sender; + } catch (error) { + await sender.close().catch(() => undefined); + throw error; + } + }, + createQuerySession: () => + connectQwpBrowserEgress(resolved.egress, resolved.egressSession), + }, + resolved.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/index.ts b/src/qwp/index.ts new file mode 100644 index 0000000..0073d50 --- /dev/null +++ b/src/qwp/index.ts @@ -0,0 +1,61 @@ +/** + * 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 "../_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, + 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 "../_qwp/writer"; +export type { + QwpDecimalInput, + QwpDoubleArrayInput, + QwpGeohashInput, + QwpIpv4Input, + QwpLong256Input, + QwpLong256Words, + QwpLongArrayInput, + QwpNestedLongArray, + QwpNestedNumberArray, + QwpTimestampUnit, + QwpUuidInput, + QwpWriterColumn, + QwpWriterColumnKind, + QwpWriterRow, + QwpWriterSchema, +} from "../_qwp/writer"; diff --git a/src/qwp/node.ts b/src/qwp/node.ts new file mode 100644 index 0000000..4be5a1e --- /dev/null +++ b/src/qwp/node.ts @@ -0,0 +1,1350 @@ +/** Node.js WebSocket adapter and shared QWP protocol/session APIs. */ +export * from "./index"; + +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, + QWP_VERSION, + type QwpEgressCompression, +} from "../_qwp/_core"; +import { + openQwpWebSocket, + QwpWebSocketLike, + validateQwpWebSocketTimeouts, +} from "../_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"; +import { + QWP_INITIAL_CONNECT_MODE, + QWP_UPGRADE_ERROR_KIND, + QwpBinaryConnection, + QwpConnectionFactory, + QwpDurableAckUnavailableError, + QwpEgressRoutingOptions, + QwpHandshakeMetadata, + QwpInitialConnectMode, + type QwpReconnectEvent, + QwpUnrecoverableReplayDictionaryError, + QwpUpgradeError, + QwpWebSocketConnectOptions, +} from "../_qwp/transport"; +import { + QWP_DEFAULT_EGRESS_SERVER_INFO_TIMEOUT_MS, + QwpEgressSession, + QwpEgressSessionOptions, +} from "../_qwp/egress-session"; +import { + QwpIngressSession, + QwpIngressSessionOptions, +} from "../_qwp/ingress-session"; +import { + createQwpDataLossSenderError, + defaultQwpSenderErrorHandler, + type QwpSenderError, +} from "../_qwp/sender-error"; +import { QwpSender, QwpSenderOptions } from "../_qwp/sender"; +import { + QwpClient, + QwpClientPoolOptions, + type QwpPoolSlotReservation, +} from "../_qwp/client"; +import { + quarantineQwpNodeReplayStore, + QwpNodeFileReplayStore, + QwpReplayStoreCorruptionError, + QwpReplayStoreQuarantinedError, +} from "../qwp-node/file-replay-store"; +import type { + QwpNodeFileReplayStoreOptions, + QwpNodeReplayDataLossReport, +} from "../qwp-node/file-replay-store"; +import { + QwpNodeOrphanDrainer, + type QwpNodeOrphanDrainEvent, +} from "../qwp-node/orphan-drainer"; +import { + QwpNodeUdpSession, + type QwpNodeUdpOptions, +} from "../qwp-node/udp-sender"; + +export { + QWP_SF_BACKPRESSURE_POLICY, + QWP_SF_DURABILITY, + QwpNodeFileReplayStore, + QwpReplayStoreAppendTimeoutError, + QwpReplayStoreCheckpointError, + QwpReplayStoreCorruptionError, + QwpReplayStoreError, + QwpReplayStoreFullError, + QwpReplayStoreLockedError, + QwpReplayStoreLockLostError, + QwpReplayStoreQuarantinedError, + QwpReplayStoreSegmentTooLargeError, +} from "../qwp-node/file-replay-store"; +export type { + QwpNodeFileReplayStoreMetrics, + QwpNodeFileReplayStoreOptions, + QwpNodeReplayDataLossReport, + QwpSfBackpressurePolicy, + QwpSfDurability, +} 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 { + QwpNodeUdpSession, + QwpUdpDatagramTooLargeError, +} from "../qwp-node/udp-sender"; +export type { + QwpNodeUdpMetrics, + QwpNodeUdpOptions, + QwpNodeUdpSocketLike, +} from "../qwp-node/udp-sender"; +export type { + QwpNodeOrphanDrainEvent, + QwpNodeOrphanDrainEventKind, + QwpNodeOrphanDrainerMetrics, + QwpNodeOrphanDrainerOptions, + QwpNodeOrphanDrainSession, +} from "../qwp-node/orphan-drainer"; + +export type { QwpWebSocketLike } from "../_qwp/_internal/websocket-connection"; + +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, + // 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, + statusMessage, + serverRole, + serverZone, + }, + ); +} + +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. */ + 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; + requestDurableAck?: boolean; + /** Test hook; defaults to the Node-only `ws` implementation. */ + webSocketFactory?: ( + url: string | URL, + options: { + 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; + }, + ) => QwpWebSocketLike; +} + +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. + */ + 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. */ +export interface QwpNodeReplayRecoveryEvent { + readonly timestampMs: number; + readonly directory: string; + readonly quarantineDirectory: string; + readonly error: QwpReplayStoreQuarantinedError; + readonly senderError: QwpSenderError; +} + +/** Node store-and-forward controls layered on the crash-safe replay journal. */ +export interface QwpNodeStoreAndForwardOptions + extends QwpNodeFileReplayStoreOptions { + /** + * Initial server connection policy. Defaults to `off`; an explicitly tuned + * reconnect policy promotes it to `sync`, matching the Java client. + */ + 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 + * idle in-range and out-of-range `sender-N` slots. + */ + drainOrphans?: boolean; + /** Maximum sibling slots drained concurrently. Defaults to 4. */ + maxBackgroundDrainers?: number; + /** + * 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 + * primary-unavailable 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 + extends QwpNodeWebSocketOptions, + QwpEgressRoutingOptions { + /** + * 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; + /** Requests a server-side RESULT_BATCH row cap. */ + maxBatchRows?: 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; + /** + * 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; +} + +/** + * 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( + options: QwpNodeEgressOptions, +): 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"; + const acceptEncoding = encodeQwpAcceptEncoding(preference, compressionLevel); + + // Keep the low-level headers escape hatch backwards compatible unless the + // typed compression option was explicitly selected. + if (compression === undefined && maxBatchRows === undefined) return transport; + + const headers = { ...transport.headers }; + 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); + } + return { ...transport, headers }; +} + +/** 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 createQwpNodeConnectionFactoryInternal(options); +} + +function createQwpNodeConnectionFactoryInternal( + options: QwpNodeWebSocketOptions, + healthTracker?: QwpFailoverHealthTracker, + resetClassificationsAfterExhaustion = true, +): QwpConnectionFactory { + const routing = options as QwpEgressRoutingOptions; + return createQwpFailoverConnectionFactory( + options.url, + options.failoverUrls, + (endpoint, signal) => connectQwpNodeEndpoint(options, endpoint, signal), + { + // 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, + }, + ); +} + +function connectQwpNodeEndpoint( + options: QwpNodeWebSocketOptions, + endpoint: string | URL, + signal?: AbortSignal, +): Promise { + validateQwpWebSocketTimeouts(options); + 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(clientMaxVersion), + "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[]; + agent?: Agent; + headers: Record; + onConnected: () => void; + onUpgrade: (headers: IncomingHttpHeaders) => void; + onUpgradeRejected: (rejection: QwpNodeUpgradeRejection) => void; + }, + ) => { + const wsOptions: WebSocket.ClientOptions = { + 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) + : 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(); + }); + const qwpSocket = socket as unknown as QwpWebSocketLike; + qwpSocket.sendWithCallback = (data, callback) => { + socket.send(data, callback); + }; + return qwpSocket; + }); + + 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; + }); + const socket = factory(endpoint, { + protocols: options.protocols, + agent: options.agent, + headers, + onConnected: resolveConnected, + onUpgrade: (receivedHeaders) => { + upgradeHeaders = receivedHeaders; + }, + onUpgradeRejected: (rejection) => { + rejectOpening(classifyUpgradeRejection(endpoint, rejection)); + }, + }); + return openQwpWebSocket(socket, { + url: endpoint, + signal, + connectTimeoutMs: options.connectTimeoutMs, + authTimeoutMs: options.authTimeoutMs, + transportConnected, + sendTimeoutMs: options.sendTimeoutMs, + closeTimeoutMs: options.closeTimeoutMs, + openingFailure, + completeHandshake: () => { + const qwpVersion = parseQwpVersion(upgradeHeaders); + if (qwpVersion < 1 || qwpVersion > clientMaxVersion) { + throw new QwpVersionMismatchError( + qwpVersion, + clientMaxVersion, + endpoint, + ); + } + const durableAckEnabled = + headerValue(upgradeHeaders, "x-qwp-durable-ack")?.toLowerCase() === + "enabled"; + if (options.requestDurableAck && !durableAckEnabled) { + throw new QwpDurableAckUnavailableError(endpoint); + } + const contentEncoding = headerValue( + upgradeHeaders, + "x-qwp-content-encoding", + ); + const handshake: QwpHandshakeMetadata = { + qwpVersion, + maxBatchSizeBytes: parseMaxBatchSize(upgradeHeaders), + contentEncoding, + negotiatedCompression: decodeQwpContentEncoding(contentEncoding), + durableAckEnabled, + serverRole: headerValue(upgradeHeaders, "x-questdb-role"), + serverZone: headerValue(upgradeHeaders, "x-questdb-zone"), + }; + return handshake; + }, + }); +} + +/** Opens a Node WebSocket and starts an ingress ACK/NACK session. */ +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, + undefined, + signal, + ); +} + +async function connectQwpNodeIngressInternal( + options: QwpNodeIngressOptions, + sessionOptions: QwpIngressSessionOptions, + startOrphanDrainer: boolean, + sharedHealthTracker?: QwpFailoverHealthTracker, + signal?: AbortSignal, +): Promise { + const healthTracker = + sharedHealthTracker ?? + createQwpFailoverHealthTracker(options.url, options.failoverUrls, { + target: options.target, + zone: options.zone, + }); + const storeAndForward = resolveNodeStoreAndForwardOptions(options); + if (storeAndForward && sessionOptions.replayStore) { + throw new RangeError( + "storeAndForward and a custom replayStore cannot both be configured", + ); + } + let replayStore = storeAndForward + ? new QwpNodeFileReplayStore( + withRecoveryDataLossReporter( + storeAndForward, + sessionOptions.onSenderError, + ), + ) + : sessionOptions.replayStore; + const reconnect = storeAndForward + ? (sessionOptions.reconnect ?? {}) + : sessionOptions.reconnect; + const initialConnectMode = storeAndForward + ? validateInitialConnectMode( + storeAndForward.initialConnectMode ?? + (sessionOptions.reconnect === undefined + ? QWP_INITIAL_CONNECT_MODE.OFF + : QWP_INITIAL_CONNECT_MODE.SYNC), + ) + : 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: backgroundReplay, + initialConnectMode, + maxBatchSizeBytes: minimumDefined( + sessionOptions.maxBatchSizeBytes, + storeBatchCap, + ), + catchUpCapGapMinEscalationWindowMs: + storeAndForward?.catchUpCapGapMinEscalationWindowMs, + durableAckKeepaliveMs: options.requestDurableAck + ? (sessionOptions.durableAckKeepaliveMs ?? 200) + : sessionOptions.durableAckKeepaliveMs, + }; + const orphanDrainer = + startOrphanDrainer && storeAndForward?.drainOrphans === true + ? createStandaloneOrphanDrainer( + { ...options, senderId: undefined, storeAndForward }, + sessionOptions, + healthTracker, + ) + : undefined; + const connectionFactory = createQwpNodeConnectionFactoryInternal( + options, + healthTracker, + startOrphanDrainer, + ); + let session: QwpIngressSession; + try { + session = await QwpIngressSession.connect( + connectionFactory, + effectiveSessionOptions, + signal, + ); + } catch (error) { + if ( + !storeAndForward || + sessionOptions.orphanStoreAndForward === true || + !isQuarantinableReplayRecoveryError(error) + ) { + throw error; + } + // 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, + ), + ); + 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()); + orphanDrainer.start(); + } + 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}`, + ); + // 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), + ); + }, + }; +} + +function isQuarantinableReplayRecoveryError(error: unknown): boolean { + return ( + error instanceof QwpReplayStoreCorruptionError || + error instanceof QwpUnrecoverableReplayDictionaryError + ); +} + +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 && !onSenderError) { + log("error", error); + return; + } + 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. 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); +} + +/** + * 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( + (signal) => + connectQwpNodeIngress( + { + ...options, + requestDurableAck: + options.requestDurableAck ?? senderOptions.awaitDurableAck, + }, + sessionOptions, + signal, + ), + 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 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, + sessionOptions: QwpEgressSessionOptions = {}, +): Promise { + const transport = egressTransportOptions(options); + return QwpEgressSession.connect( + createQwpEgressFailoverConnectionFactory( + transport.url, + transport.failoverUrls, + (endpoint, signal) => connectQwpNodeEndpoint(transport, endpoint, signal), + { target: options.target, zone: options.zone }, + sessionOptions.serverInfoTimeoutMs ?? + QWP_DEFAULT_EGRESS_SERVER_INFO_TIMEOUT_MS, + ), + sessionOptions, + ); +} + +/** 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( + configurationString: string, + extraOptions?: QwpNodeClientConfigOptions, +): QwpClient; +export function createQwpNodeClient( + optionsOrConfiguration: QwpNodeClientOptions | string, + extraOptions: QwpNodeClientConfigOptions = {}, +): QwpClient { + const options = resolveNodeClientOptions( + optionsOrConfiguration, + extraOptions, + ); + const slotCoordinator = createPooledSlotCoordinator(options); + const orphanDrainer = createPooledOrphanDrainer(options, slotCoordinator); + let unsubscribeRecoveryScan: (() => void) | undefined; + 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), + senderSlotReservation: slotCoordinator, + start: () => { + if (orphanDrainer && slotCoordinator) { + unsubscribeRecoveryScan = slotCoordinator.onAvailable(() => + orphanDrainer.scanNow(), + ); + } + orphanDrainer?.start(); + }, + close: async () => { + unsubscribeRecoveryScan?.(); + unsubscribeRecoveryScan = undefined; + await orphanDrainer?.close(); + }, + }, + options.pool, + ); +} + +/** Creates and prewarms a combined Node QWP ingress/egress client. */ +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( + 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 { + const storeAndForward = options.ingress.storeAndForward; + const storeInitialConnectMode = storeAndForward?.initialConnectMode; + const sessionInitialConnectMode = options.ingressSession?.initialConnectMode; + if ( + storeInitialConnectMode !== undefined && + sessionInitialConnectMode !== undefined && + storeInitialConnectMode !== sessionInitialConnectMode + ) { + throw new RangeError( + `conflicting configuration: storeAndForward.initialConnectMode='${storeInitialConnectMode}' differs from ingressSession.initialConnectMode='${sessionInitialConnectMode}'`, + ); + } + 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 initialConnectMode='async', got '${configuredInitialConnectMode}'`, + ); + } + 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 + ? { + ...storeAndForward, + initialConnectMode: QWP_INITIAL_CONNECT_MODE.ASYNC, + } + : undefined, + }, + ingressSession: { + ...options.ingressSession, + backgroundStoreAndForward: true, + initialConnectMode: QWP_INITIAL_CONNECT_MODE.ASYNC, + }, + pool: { ...options.pool, queryPoolMin: 0 }, + }; +} + +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, + senderId: undefined, + storeAndForward: { + ...options.storeAndForward, + 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, + }, + }; +} + +function createStandaloneOrphanDrainer( + options: QwpNodeIngressOptions, + sessionOptions: QwpIngressSessionOptions, + healthTracker: QwpFailoverHealthTracker, +): QwpNodeOrphanDrainer { + const storeAndForward = options.storeAndForward!; + const ownDirectory = storeAndForward.directory.trim(); + return createNodeOrphanDrainer( + options, + sessionOptions, + dirname(ownDirectory), + (slotName) => slotName === basename(ownDirectory), + healthTracker, + ); +} + +function createPooledOrphanDrainer( + options: QwpNodeClientOptions, + slotCoordinator?: QwpPooledSfaSlotCoordinator, +): 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; + const senderId = validateQwpSenderId(options.ingress.senderId ?? "sender"); + const healthTracker = createQwpFailoverHealthTracker( + options.ingress.url, + options.ingress.failoverUrls, + { + target: options.ingress.target, + zone: options.ingress.zone, + }, + ); + return createNodeOrphanDrainer( + options.ingress, + options.ingressSession ?? {}, + rootDirectory, + (slotName) => { + const managedIndex = parseCanonicalSenderSlot(slotName, senderId); + if (managedIndex !== undefined) { + return ( + managedIndex < managedSlotCount && + slotCoordinator?.isForegroundReserved(managedIndex) === 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; + }, + healthTracker, + slotCoordinator, + ); +} + +function createNodeOrphanDrainer( + options: QwpNodeIngressOptions, + sessionOptions: QwpIngressSessionOptions, + rootDirectory: string, + excludeSlot: (slotName: string) => boolean, + healthTracker: QwpFailoverHealthTracker, + 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 + ? (sessionOptions.durableAckKeepaliveMs ?? 200) + : 0, + onEvent: storeAndForward.onOrphanDrainEvent, + onSenderError: sessionOptions.onSenderError, + eventInboxCapacity: sessionOptions.connectionListenerInboxCapacity, + errorInboxCapacity: sessionOptions.errorInboxCapacity, + createSession: (directory, onReconnectEvent) => + connectQwpNodeIngressInternal( + { + ...options, + senderId: undefined, + storeAndForward: { + ...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, onReconnectEvent), + false, + healthTracker, + ), + }); +} + +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, +): 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: { + ...configuredReconnect, + maxAttempts: 0, + maxDurationMs: 0, + onEvent: (event) => { + // 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, + backgroundStoreAndForward: undefined, + initialConnectMode: undefined, + orphanStoreAndForward: true, + orphanDurableAckMismatchMaxDurationMs: + options.orphanDurableAckMismatchMaxDurationMs ?? + configuredReconnect?.maxDurationMs ?? + 300_000, + onResponse: undefined, + onDurableAck: undefined, + onProgress: undefined, + onError: undefined, + }; +} + +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 { + 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/sender.ts b/src/sender.ts index 4a63a3b..2558679 100644 --- a/src/sender.ts +++ b/src/sender.ts @@ -1,9 +1,36 @@ // @ts-check +import { readFileSync } from "node:fs"; +import * as https from "node:https"; import { log, Logger } from "./logging"; -import { SenderOptions, ExtraOptions } from "./options"; +import { + SenderOptions, + ExtraOptions, + qwpConfig, + selectQwpSchemeAgent, + UDP, + validateUdpSecurityOptions, + WS, + WSS, +} from "./options"; 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 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 @@ -19,6 +46,8 @@ 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.
  • + *
  • UDP: Node-only fire-and-forget QWP ingress in self-contained datagrams.
  • * *

    *

    @@ -61,6 +90,8 @@ 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")
  • + *
  • QWP UDP: Sender.fromConfig("udp::addr=localhost:9007;max_datagram_size=1400")
  • * *

    *

    @@ -79,14 +110,18 @@ 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. *

    */ 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 +138,34 @@ 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 || + 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. + getQwpNodeModule().createQwpNodeSender( + resolved.ingress, + resolved.sender, + resolved.ingressSession, + ) + : options.protocol === UDP + ? createConfiguredQwpUdpSender(options, this.log) + : 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 @@ -152,9 +210,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); } /** @@ -164,18 +220,42 @@ 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; } /** - * Creates a TCP connection to the database. + * 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); + } + + /** + * 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. */ connect(): Promise { - return this.transport.connect(); + return this.qwpSender + ? this.qwpSender.connect() + : this.transport!.connect(); } /** @@ -185,7 +265,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,23 +277,71 @@ class Sender { ); this.resetAutoFlush(); - await this.transport.send(dataToSend); + await this.transport!.send(dataToSend); return true; } /** - * Closes the connection to the database.
    - * Data sitting in the Sender's buffer will be lost unless flush() is called before close(). + * 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. 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 { - 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 +351,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; } @@ -231,11 +361,12 @@ 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 { - this.buffer.symbol(name, value); + if (this.qwpSender) this.qwpSender.symbol(name, value); + else this.buffer!.symbol(name, value); return this; } @@ -244,11 +375,12 @@ 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 { - this.buffer.stringColumn(name, value); + stringColumn(name: string, value: string | null | undefined): Sender { + if (this.qwpSender) this.qwpSender.stringColumn(name, value); + else this.buffer!.stringColumn(name, value); return this; } @@ -257,11 +389,12 @@ 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 { - this.buffer.booleanColumn(name, value); + booleanColumn(name: string, value: boolean | null | undefined): Sender { + if (this.qwpSender) this.qwpSender.booleanColumn(name, value); + else this.buffer!.booleanColumn(name, value); return this; } @@ -270,11 +403,12 @@ 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 { - this.buffer.floatColumn(name, value); + floatColumn(name: string, value: number | null | undefined): Sender { + if (this.qwpSender) this.qwpSender.floatColumn(name, value); + else this.buffer!.floatColumn(name, value); return this; } @@ -282,15 +416,16 @@ 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 { - this.buffer.arrayColumn(name, value); + arrayColumn(name: string, value: unknown[] | null | undefined): Sender { + if (this.qwpSender) this.qwpSender.arrayColumn(name, value); + else this.buffer!.arrayColumn(name, value); return this; } @@ -299,12 +434,13 @@ 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 { - this.buffer.intColumn(name, value); + intColumn(name: string, value: number | null | undefined): Sender { + if (this.qwpSender) this.qwpSender.intColumn(name, value); + else this.buffer!.intColumn(name, value); return this; } @@ -321,7 +457,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,10 +471,11 @@ class Sender { */ timestampColumn( name: string, - value: number | bigint, + 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; } @@ -348,13 +485,17 @@ 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 { - this.buffer.decimalColumnText(name, value); + decimalColumnText( + name: string, + value: string | number | null | undefined, + ): Sender { + if (this.qwpSender) this.qwpSender.decimalColumnText(name, value); + else this.buffer!.decimalColumnText(name, value); return this; } @@ -364,9 +505,13 @@ 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). + * 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: @@ -376,15 +521,20 @@ class Sender { */ decimalColumn( name: string, - unscaled: Int8Array | bigint, + 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; } /** * 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:** @@ -400,16 +550,17 @@ 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, 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(); @@ -418,9 +569,16 @@ 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 { - 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(); @@ -445,4 +603,144 @@ 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 ?? {}; + const secure = options.protocol === WSS; + let agent = + 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 + // 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, + }); + } + const configuredAuthorization = qwpAuthorization(options); + const authorization = + 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. + const storeAndForward = configuredWebSocket.storeAndForward; + return getQwpNodeModule().createQwpNodeSender( + { + ...configuredWebSocket, + storeAndForward, + 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, + autoFlushBytes: configuredSender.autoFlushBytes, + autoFlushIntervalMs: isInteger(options.auto_flush_interval, 0) + ? options.auto_flush_interval + : configuredSender.autoFlushIntervalMs, + closeFlushTimeoutMs: configuredSender.closeFlushTimeoutMs, + maxNameLength: isInteger(options.max_name_len, 1) + ? options.max_name_len + : configuredSender.maxNameLength, + log: logger, + }, + options.qwp?.session, + ); +} + +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"); + } + const configuredUdp = options.qwp?.udp ?? {}; + const configuredSender = options.qwp?.sender ?? {}; + const maxDatagramSize = + options.max_datagram_size ?? configuredUdp.maxDatagramSize ?? 1_400; + return getQwpNodeModule().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 { + 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; +} + export { Sender }; 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/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/dist-types/class-identity.ts b/test/dist-types/class-identity.ts new file mode 100644 index 0000000..13915ec --- /dev/null +++ b/test/dist-types/class-identity.ts @@ -0,0 +1,56 @@ +// 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. +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(); + +const annotated: QwpSender = createQwpNodeSender(senderOptions); +void annotated; + +async function annotatedClient(): Promise { + const client: QwpClient = await connectQwpNodeClient(clientOptions); + void client; +} +void annotatedClient; + +const schema = { ticker: symbol(), ts: designatedTimestamp("ns") } as const; + +// 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; + +// 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; 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/test/options.test.ts b/test/options.test.ts index 125df3c..9366bb7 100644 --- a/test/options.test.ts +++ b/test/options.test.ts @@ -2,7 +2,15 @@ import { describe, it, expect, beforeAll, afterAll } from "vitest"; import { Agent } from "undici"; +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"; @@ -64,36 +72,45 @@ 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"); + + 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'", + "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'", + "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'", + "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'", + "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'", + "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'", + "Invalid protocol: 'Tcps', accepted protocols: 'http', 'https', 'tcp', 'tcps', 'ws', 'wss', 'udp'", ); }); @@ -230,9 +247,22 @@ describe("Configuration string parser suite", function () { expect(options.port).toBe(9009); expect(options.username).toBe("user1"); expect(options.token).toBe("jwkprivkey123"); + + // 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); + expect(options.protocol_version).toBeUndefined(); }); 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( + "unknown configuration key: protocol_version (QWP negotiates the protocol version during the WebSocket upgrade)", + ); + // invalid protocol version await expect( async () => @@ -790,6 +820,157 @@ describe("Configuration string parser suite", function () { ).rejects.toThrow("Invalid auto flush rows option, not a number: '1w23'"); }); + 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("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 the udp transport", + ); + }); + + 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"); + // 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 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)", + ); + }); + + 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("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 + // 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;close_flush_timeout_millis=123;initial_connect_retry=off;", + ), + ).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 () { let options = await SenderOptions.fromConfig( "http::addr=host:9000;protocol_version=2;auto_flush_interval=30", @@ -1143,6 +1324,36 @@ 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); + + // 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 () { const agent = new Agent({ connect: { keepAlive: true } }); 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 new file mode 100644 index 0000000..95b91a4 --- /dev/null +++ b/test/qwp/browser.e2e.ts @@ -0,0 +1,655 @@ +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 { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { WebSocketServer } from "ws"; +import { + encodeQwpFrame, + QWP_COMPRESSION_CODEC, + QWP_DURABLE_ACK_WEBSOCKET_PROTOCOL, + QWP_DEFAULT_EGRESS_INITIAL_CREDIT, + QWP_EGRESS_CAPABILITY, + QWP_EGRESS_MESSAGE, + QWP_STATUS, + QwpByteReader, + QwpByteWriter, + readQwpVarint, + writeQwpVarint, +} from "../../src/qwp/node"; + +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 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())); + }); +} + +// 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"); + 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 writeU16String(writer: QwpByteWriter, value: string): void { + const bytes = new TextEncoder().encode(value); + writer.writeUint16(bytes.length).writeBytes(bytes); +} + +function browserServerInfo(compression?: { + codec: number; + level: number; +}): Uint8Array { + const payload = new QwpByteWriter(); + payload + .writeUint8(QWP_EGRESS_MESSAGE.SERVER_INFO) + .writeUint8(0) + .writeBigUint64(1n) + .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); + 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; + let browser: Browser; + + beforeAll(async () => { + assetServer = createModuleServer(); + await listen(assetServer); + const address = assetServer.address() as AddressInfo; + assetUrl = `http://127.0.0.1:${address.port}/es/qwp/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); + }); + + it("decompresses Zstd and reuses row views 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().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: lastRow.getInt(0), + lastRowIndex: lastRow.rowIndex, + reusesRow, + rowViewExported: lastRow instanceof qwp.QwpResultRowView, + visits, + }; + }, assetUrl); + + expect(result).toEqual({ + requestId: "7", + rowCount: 100, + lastValue: 42, + lastRowIndex: 99, + reusesRow: true, + rowViewExported: true, + visits: 100, + }); + } finally { + await page.close(); + } + }); + + 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, + handleProtocols: (protocols) => { + offeredProtocols.push(...protocols); + return protocols.has(QWP_DURABLE_ACK_WEBSOCKET_PROTOCOL) + ? QWP_DURABLE_ACK_WEBSOCKET_PROTOCOL + : 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(); + 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, + requestDurableAck: true, + }); + try { + return connection.handshake; + } finally { + await connection.close(); + } + }, + { + moduleUrl: assetUrl, + url: `ws://127.0.0.1:${address.port}/write/v4`, + }, + ); + + expect(offeredProtocols).toContain(QWP_DURABLE_ACK_WEBSOCKET_PROTOCOL); + 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("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())); + 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, + maxBatchRows: 512, + }); + 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("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", + 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, + maxBatchRows: 512, + }); + 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( + new URL(requestedPath!, "http://localhost").searchParams.get( + "qwp_max_batch_rows", + ), + ).toBe("512"); + expect(result).toEqual({ + compression: { codec: "zstd", level: 3 }, + level: 3, + }); + } finally { + await page.close(); + await closeWebSocketServer(server); + } + }); + + 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)); + 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/client.test.ts b/test/qwp/client.test.ts new file mode 100644 index 0000000..f3b2b7c --- /dev/null +++ b/test/qwp/client.test.ts @@ -0,0 +1,925 @@ +import { describe, expect, it, vi } from "vitest"; +import { + encodeQwpFrame, + QWP_EGRESS_CAPABILITY, + QWP_EGRESS_MESSAGE, + QWP_SERVER_ROLE, + QWP_STATUS, + QwpBinaryConnection, + QwpByteWriter, + QwpClient, + QwpClientClosedError, + QwpConnectionCloseInfo, + QwpEgressSession, + QwpEgressSessionClosedError, + QwpEgressSessionOptions, + QwpHandshakeMetadata, + QwpIngressResponse, + QwpPoolAcquireTimeoutError, + type QwpPoolSlotReservation, + QwpSender, + QwpSenderSession, + designatedTimestamp, + long, + symbol as qwpSymbol, +} 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, + 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(options.role ?? QWP_SERVER_ROLE.STANDALONE) + .writeBigUint64(1n) + .writeUint32(capabilities) + .writeBigInt64(123n); + writeString(payload, options.clusterId ?? "cluster"); + writeString(payload, nodeId); + if (options.zoneId !== undefined) writeString(payload, options.zoneId); + 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()); +} + +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; + readonly sent: Uint8Array[] = []; + closeCount = 0; + 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 { + this.closeCount++; + 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 { + 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, + tables: [], + }); + } + + publishTables(): Promise { + this.flushes++; + this.acknowledgedFrameSequence = ++this.publishedFrameSequence; + return Promise.resolve(); + } + + 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("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 () => { + 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; + 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; + 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(); + 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); + await second.close(); + expect(client.metrics.senders).toMatchObject({ + total: 1, + available: 1, + leased: 0, + }); + + await client.close(); + 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( + { + 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; + 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("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 { + 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("cancels and closes every borrowed query session during client shutdown", 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: 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(); + 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: 0, leased: 0 }, + }); + + await lease.close(); + await idleLease.close(); + expect(connections[0].closeCount).toBe(1); + expect(connections[1].closeCount).toBe(1); + }); + + 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( + { + 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/config-docs.test.ts b/test/qwp/config-docs.test.ts new file mode 100644 index 0000000..dc30aa6 --- /dev/null +++ b/test/qwp/config-docs.test.ts @@ -0,0 +1,119 @@ +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"; + +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); + }); + + 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; + + // 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(); + } + + 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(); + } + }); +}); diff --git a/test/qwp/core.test.ts b/test/qwp/core.test.ts new file mode 100644 index 0000000..1e4676d --- /dev/null +++ b/test/qwp/core.test.ts @@ -0,0 +1,768 @@ +import { describe, expect, it } from "vitest"; +import { + decodeQwpEgressMessage, + decodeQwpContentEncoding, + decodeQwpFrame, + decodeQwpIngressResponse, + decodeQwpIngressServerInfo, + decodeQwpIngressSymbolDictionaryDelta, + decodeQwpVarint, + addQwpDurableAckWebSocketProtocol, + encodeQwpCancel, + encodeQwpAcceptEncoding, + encodeQwpCredit, + encodeQwpDurableAckPollFrame, + encodeQwpFrame, + encodeQwpGorilla, + encodeQwpIngressFrame, + 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, + QWP_COMPRESSION_CODEC, + 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, + QwpByteReader, + QwpByteWriter, + QwpSymbolDictionary, + QwpTableBuffer, + qwpGorillaSize, + qwpVarintSize, + 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); +} + +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); + }); + + 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", () => { + 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(), + }); + }); + + 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", () => { + 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); + 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("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/, + ); + } + + 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"); + expect(() => + 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", () => { + 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); + 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("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("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); + 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("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("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"); + 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); + 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 | 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); + + const message = decodeQwpEgressMessage( + encodeQwpFrame(payload.toUint8Array()), + ); + expect(message).toMatchObject({ + kind: "server-info", + role: 1, + epoch: 3n, + clusterId: "cluster-a", + nodeId: "node-1", + zoneId: "eu-west-1a", + compressionCodec: QWP_COMPRESSION_CODEC.ZSTD, + compressionLevel: 3, + }); + expect(Object.isFrozen(message)).toBe(true); + }); + + 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); + }); +}); + +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(); + }); +}); + +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/dist.e2e.ts b/test/qwp/dist.e2e.ts new file mode 100644 index 0000000..b9d49d2 --- /dev/null +++ b/test/qwp/dist.e2e.ts @@ -0,0 +1,281 @@ +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("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 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); + + 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 () => { + 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("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, + }), + ); + child.on("error", () => + resolve({ code: 1, stdout: "", stderr: "spawn failed" }), + ); + }, + ); + + 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) => { + 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))`; + + // 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_}`, + ); + + expect(stderr).toBe(""); + expect(code).toBe(0); + expect(stdout.trim()).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( + resolveExport("./qwp/node", format), + "utf8", + ); + + // 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/egress.test.ts b/test/qwp/egress.test.ts new file mode 100644 index 0000000..2620cf8 --- /dev/null +++ b/test/qwp/egress.test.ts @@ -0,0 +1,1810 @@ +import { describe, expect, it, vi } from "vitest"; +import { + decodeQwpEgressMessage, + encodeQwpFrame, + encodeQwpGorilla, + QWP_COLUMN_TYPE, + QWP_EGRESS_CAPABILITY, + QWP_EGRESS_MESSAGE, + QWP_FLAG_DELTA_SYMBOL_DICTIONARY, + QWP_FLAG_GORILLA, + 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_RESET_MASK_DICTIONARY, + QWP_STATUS, + QwpBinaryConnection, + QwpByteReader, + QwpByteWriter, + QwpConnectionCloseInfo, + QwpEgressQueryAbandonedError, + QwpEgressQueryCancelTimeoutError, + QwpEgressQueryError, + QwpEgressQueryTimeoutError, + QwpEgressSession, + QwpResultBatchDecoder, + QwpTableBuffer, + QwpResultBatchView, + QwpResultRowView, + 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; + +// 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, +]); + +// 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); + writer.writeBytes(bytes); +} + +function writeU16String(writer: QwpByteWriter, value: string): void { + const bytes = new TextEncoder().encode(value); + writer.writeUint16(bytes.length).writeBytes(bytes); +} + +function serverInfo( + capabilities: number = QWP_EGRESS_CAPABILITY.QUERY_FLAGS, +): Uint8Array { + const payload = new QwpByteWriter(); + payload + .writeUint8(QWP_EGRESS_MESSAGE.SERVER_INFO) + .writeUint8(0) + .writeBigUint64(1n) + .writeUint32(capabilities) + .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 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); + writeQwpVarint(payload, 1); + writeQwpVarint(payload, totalRows); + 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); + writeQwpVarint(payload, 0); + payload.writeBytes(COMPRESSED_INT_RESULT_BODY); + return encodeQwpFrame( + payload.toUint8Array(), + QWP_FLAG_DELTA_SYMBOL_DICTIONARY | QWP_FLAG_ZSTD, + 1, + ); +} + +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 + * 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); +} + +/** + * 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); + 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, + status: number = 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(status) + .writeUint16(bytes.length) + .writeBytes(bytes); + return encodeQwpFrame(payload.toUint8Array()); +} + +class FakeConnection implements QwpBinaryConnection { + readonly handshake = { qwpVersion: 1 }; + private readonly incoming = new QwpAsyncQueue(); + 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; + this.closed = new Promise((res) => { + resolve = res; + }); + this.resolveClosed = resolve; + } + + send(payload: Uint8Array): Promise { + this.sent.push(payload.slice()); + 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(); + } + + 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("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)]) { + 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"); + + 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("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); + 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 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)); + }); + + 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); + const firstRow = first.row(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.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", () => { + 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); + }); + + 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("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"); + + 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("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("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 + // 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( + /delta dictionary count out of range: 2000001/, + ); + // 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("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 + // 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(); + 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); + }); + + 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", () => { + 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); + + 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 () => { + factoryCalls++; + return new FakeConnection(); + }, + { bufferPoolSize: 0 }, + ), + ).rejects.toThrow("bufferPoolSize must be a positive safe integer"); + 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); + + 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 () => { + 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("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); + 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); + 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("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); + 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("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("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); + 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("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("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); + 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), + ); + 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 Promise.resolve(); + expect(connection.sent).toHaveLength(1); + connection.receive(resultEnd(query.requestId)); + await next; + await query.completion; + await session.close(); + + const boundedConnection = new FakeConnection(); + const bounded = new QwpEgressSession(boundedConnection, { + initialCredit: 64, + }); + 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 () => { + 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); + 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("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("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 { + 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", + ); + 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, + "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("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 { + 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); + 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); + 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(); + }); +}); 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/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); + }); +}); diff --git a/test/qwp/node-client-config.test.ts b/test/qwp/node-client-config.test.ts new file mode 100644 index 0000000..2b0db07 --- /dev/null +++ b/test/qwp/node-client-config.test.ts @@ -0,0 +1,422 @@ +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"; +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, + }); + expect(options.egressSession?.reconnect).toBeUndefined(); + 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("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: 5_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;", + ); + + 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};sender_id=producer_1;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); + expect(await readdir(directory)).toContain("producer_1-0"); + } finally { + await client?.close(); + await rm(directory, { recursive: true, force: true }); + } + }); + + 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;", + ), + ).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(() => + createQwpNodeClient({ + ingress: { + url: "ws://localhost:9000/write/v4", + storeAndForward: { + directory: "/tmp/qwp", + initialConnectMode: "off", + }, + }, + egress: { url: "ws://localhost:9000/read/v1" }, + ingressSession: { initialConnectMode: "sync" }, + }), + ).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", () => { + 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 and unknown 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 configuration key: made_up/); + }); + + 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", () => { + // 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;", + ); + 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/node-transport.test.ts b/test/qwp/node-transport.test.ts new file mode 100644 index 0000000..a12a815 --- /dev/null +++ b/test/qwp/node-transport.test.ts @@ -0,0 +1,952 @@ +import type { AddressInfo, Socket } from "node:net"; +import { createServer as createTcpServer } from "node:net"; +import { mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { WebSocketServer } from "ws"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + connectQwpNodeClient, + connectQwpNodeEgress, + connectQwpNodeIngress, + connectQwpNodeWebSocket, + createQwpNodeSender, + encodeQwpFrame, + encodeQwpIngressFrame, + QWP_COLUMN_TYPE, + 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, + QwpByteWriter, + QwpNodeFileReplayStore, + QwpReplayStoreCorruptionError, + QwpReplayStoreQuarantinedError, + QwpSymbolDictionary, + QwpTableBuffer, + QwpUpgradeError, + type QwpSenderError, + writeQwpVarint, +} from "../../src/qwp/node"; + +function serverInfo( + role: number = 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(role) + .writeBigUint64(1n) + .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()); +} + +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(); +} + +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; + + afterEach(async () => { + await new Promise((resolve, reject) => { + if (!server) return resolve(); + server.close((error) => (error ? reject(error) : resolve())); + }); + 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("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; + let requestedDurableAck: string | string[] | undefined; + let pingCount = 0; + + 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-QuestDB-Zone: eu-west-1a"); + 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 { + expect(session.handshake).toMatchObject({ + qwpVersion: 1, + maxBatchSizeBytes: 64, + durableAckEnabled: true, + serverRole: "primary", + serverZone: "eu-west-1a", + }); + expect(session.maxBatchSizeBytes).toBe(64); + 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(); + } + }); + + it("surfaces the server-clamped Zstd level from a real upgrade", async () => { + 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"); + 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", + 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); + }); + + 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("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("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"); + headers.push("X-QuestDB-Role: PRIMARY"); + headers.push("X-QuestDB-Zone: eu-west-1"); + }); + 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`, + target: "primary", + zone: "eu-west-1", + 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 assignedReplaySegments(orphanDirectory)).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("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) => { + 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 assignedReplaySegments(directory); + 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); + expect(event.senderError.quarantinedPath).toBe( + event.quarantineDirectory, + ); + }, + }, + }, + { onSenderError: (error) => senderErrors.push(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(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, ".failed"]), + ); + expect(await assignedReplaySegments(directory)).toEqual([]); + } finally { + await rm(rootDirectory, { recursive: true, force: true }); + } + }); + + 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, + // 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) => { + 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, ".symbol-dict"), 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)).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"]); + 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 }); + 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 }); + } + }); + + 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, initialConnectMode: "async" }, + }, + { 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 assignedReplaySegments(directory)).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 assignedReplaySegments(directory)).toEqual([]), + { timeout: 2_000 }, + ); + } finally { + await sender.close(); + 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())); + }); +} + +async function assignedReplaySegments(directory: string): Promise { + return (await readdir(directory)).filter((name) => name.endsWith(".sfa")); +} diff --git a/test/qwp/notification-dispatcher.test.ts b/test/qwp/notification-dispatcher.test.ts new file mode 100644 index 0000000..0b2c195 --- /dev/null +++ b/test/qwp/notification-dispatcher.test.ts @@ -0,0 +1,92 @@ +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("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( + (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 new file mode 100644 index 0000000..32568ab --- /dev/null +++ b/test/qwp/orphan-drainer.test.ts @@ -0,0 +1,416 @@ +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, + QwpReplayStoreCorruptionError, + QwpReplayStoreLockedError, + retryQwpNodeOrphanSlot, + scanQwpNodeOrphanSlots, + type QwpNodeOrphanDrainSession, +} from "../../src/qwp/node"; +import { + QWP_RECONNECT_EVENT_KIND, + QWP_SENDER_ERROR_CATEGORY, + QWP_SENDER_ERROR_POLICY, + QWP_STATUS, + QwpReplayRejectedError, + type QwpSenderError, +} from "../../src/qwp"; + +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 }); + } +} + +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[] = []; + + 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, "sf-0000000000000000.sfa"), + assignedSfaSegment(), + ); + 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"); + const segmented = join(rootDirectory, "segmented"); + await mkdir(segmented); + 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"); + await recordSlot(rootDirectory, "sender-0.unreplayable-0"); + await mkdir(join(rootDirectory, "empty")); + + await expect( + scanQwpNodeOrphanSlots(rootDirectory, (name) => name === "live"), + ).resolves.toEqual([orphan, segmented]); + 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, "sf-0000000000000000.sfa")); + }; + 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("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"); + 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("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 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"); + // 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({ + 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); + 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 new file mode 100644 index 0000000..4e4f1de --- /dev/null +++ b/test/qwp/public-api-contract.ts @@ -0,0 +1,525 @@ +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, + connectQwpBrowserClient, + connectQwpBrowserEgress, + connectQwpBrowserIngress, + connectQwpBrowserSender, +} from "../../src/qwp/browser"; +import type { + QwpBrowserClusterOptions, + QwpBrowserClientEgressOptions, + QwpBrowserClientIngressOptions, + QwpBrowserClientOptions, + QwpBrowserSessionBootstrapOptions, + QwpBrowserSessionBootstrapResult, + QwpBrowserEgressOptions, + QwpBrowserSplitClientOptions, + QwpBrowserUnifiedClientOptions, + QwpBrowserWebSocketOptions, +} from "../../src/qwp/browser"; +import { + connectQwpNodeEgress, + connectQwpNodeClient, + connectQwpNodeIngress, + connectQwpNodeSender, + connectQwpNodeUdp, + connectQwpNodeUdpSender, + connectQwpNodeWebSocket, + parseQwpNodeClientConfig, + retryQwpNodeOrphanSlot, + scanQwpNodeOrphanSlots, +} from "../../src/qwp/node"; +import type { + QwpNodeClientOptions, + QwpNodeClientConfigOptions, + QwpNodeEgressOptions, + QwpNodeIngressOptions, + QwpNodeUdpOptions, + QwpNodeUdpSession, + QwpNodeOrphanDrainEvent, + QwpNodeOrphanDrainSession, + QwpNodeReplayRecoveryEvent, + QwpNodeStoreAndForwardOptions, + QwpNodeWebSocketOptions, +} from "../../src/qwp/node"; +import type { + QwpBinaryConnection, + QwpClient, + QwpClientPoolOptions, + QwpEgressQueryOptions, + QwpEgressSession, + QwpEgressSessionOptions, + QwpEgressViewQuery, + QwpIngressSession, + QwpIngressSessionOptions, + QwpIngressSendResult, + QwpSenderError, + QwpQueryLease, + QwpResultBatchView, + QwpResultBatchViewHandler, + QwpResultRowView, + QwpResultRowViewCallback, + QwpServerInfoMessage, + QwpSender, + QwpSenderOptions, + QwpTableWriter, + QwpWriterRow, +} 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 defaultSenderErrorHandlerSignature: (error: QwpSenderError) => void = + defaultQwpSenderErrorHandler; + +const browserIngressSignature: ( + options: QwpBrowserWebSocketOptions, + sessionOptions?: QwpIngressSessionOptions, +) => Promise = connectQwpBrowserIngress; + +const browserEgressSignature: ( + options: QwpBrowserEgressOptions, + sessionOptions?: QwpEgressSessionOptions, +) => Promise = connectQwpBrowserEgress; + +const bootstrapSignature: ( + options: QwpBrowserSessionBootstrapOptions, +) => Promise = bootstrapQwpBrowserSession; + +const browserClientSignature: ( + options: QwpBrowserClientOptions, +) => Promise = connectQwpBrowserClient; + +const nodeSenderSignature: ( + options: QwpNodeIngressOptions, + senderOptions?: QwpSenderOptions, + sessionOptions?: QwpIngressSessionOptions, +) => Promise = connectQwpNodeSender; + +const nodeUdpSignature: ( + options: QwpNodeUdpOptions, +) => Promise = connectQwpNodeUdp; + +const nodeUdpSenderSignature: ( + options: QwpNodeUdpOptions, + senderOptions?: QwpSenderOptions, +) => Promise = connectQwpNodeUdpSender; + +const nodeIngressSignature: ( + options: QwpNodeIngressOptions, + sessionOptions?: QwpIngressSessionOptions, +) => Promise = connectQwpNodeIngress; + +const nodeEgressSignature: ( + options: QwpNodeEgressOptions, + sessionOptions?: QwpEgressSessionOptions, +) => Promise = connectQwpNodeEgress; + +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; + +const nodeClusterClientSignature: ( + configurationString: string, + extraOptions?: QwpNodeClientConfigOptions, +) => Promise = connectQwpNodeClient; + +const nodeClusterParserSignature: ( + configurationString: string, + extraOptions?: QwpNodeClientConfigOptions, +) => QwpNodeClientOptions = parseQwpNodeClientConfig; + +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, +) => Promise = scanQwpNodeOrphanSlots; + +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, + durability: "periodic", + checkpointIntervalMs: 5_000, + backpressurePolicy: "wait", + appendDeadlineMs: 30_000, + drainOrphans: true, + maxBackgroundDrainers: 2, + orphanScanIntervalMs: 30_000, + onOrphanDrainEvent: (event: QwpNodeOrphanDrainEvent) => void event.metrics, + onRecoveryQuarantine: (event: QwpNodeReplayRecoveryEvent) => + void event.quarantineDirectory, +}; + +const queryOptionsContract: QwpEgressQueryOptions = { + initialCredit: 1024, + autoCredit: true, + timeoutMs: 30_000, + resetDictionary: true, + binds: (binds) => binds.setVarchar(0, "ETH-USD"), +}; + +const egressSessionOptionsContract: QwpEgressSessionOptions = { + initialCredit: 256 * 1024, + bufferPoolSize: 4, + queryTimeoutMs: 30_000, + cancelDrainTimeoutMs: 5_000, +}; + +const fixedConnectionIngressContract: QwpIngressSessionOptions = { + reconnect: false, + connectionListenerInboxCapacity: 64, + errorInboxCapacity: 256, + onSenderError: (error: QwpSenderError) => + void [ + error.category, + error.appliedPolicy, + error.fromFsn, + error.toFsn, + error.quarantinedPath, + ], +}; + +const memoryReplayIngressContract: QwpIngressSessionOptions = { + memoryReplayMaxBytes: 128 * 1024 * 1024, + memoryReplayAppendDeadlineMs: 30_000, +}; + +const fixedConnectionEgressContract: QwpEgressSessionOptions = { + reconnect: false, +}; + +const browserEgressOptionsContract: QwpBrowserEgressOptions = { + url: "wss://node-1.example/read/v1", + failoverUrls: ["wss://node-2.example/read/v1"], + target: "replica", + zone: "eu-west-1a", + 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"], + target: "primary", + maxBatchRows: 512, +}; + +const qwpExtraOptionsContract: QwpExtraOptions = { + webSocket: { + requestDurableAck: true, + storeAndForward: { + directory: "/tmp/qwp-public-api-contract", + initialConnectMode: "sync", + catchUpCapGapMinEscalationWindowMs: 300_000, + }, + }, + sender: { + transactional: true, + autoFlushBytes: 4 * 1024 * 1024, + maxNameLength: 255, + closeFlushTimeoutMs: 5_000, + awaitDurableAck: true, + }, + session: { + reconnect: { maxAttempts: 3 }, + }, + udp: { + maxDatagramSize: 1_400, + multicastTtl: 1, + }, +}; + +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; + 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; + void senderAcknowledged; + void sessionWait; + void sessionPublished; + void sessionAcknowledged; + void localPublication; + void serverAcknowledgement; + void trackedSequence; +} + +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; +} + +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 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, +): 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; + 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) => { + const rowIndex: number = row.rowIndex; + const value: bigint = row.getLong(0); + void rowIndex; + void value; + }; + batch.forEachRow(rowCallback); + void typedBatch; + void requestId; + void completionWait; + void done; + void rawValues; + void directRow; + }; + const direct: Promise = session.queryViews( + "select * from trades", + handler, + ); + const pooled: Promise = lease.queryViews( + "select * from trades", + handler, + ); + void direct; + void pooled; + void sessionServerInfo; + void leaseServerInfo; +} + +const rootExtraOptionsContract: ExtraOptions = { + qwp: qwpExtraOptionsContract, +}; + +void browserSenderSignature; +void defaultSenderErrorHandlerSignature; +void browserIngressSignature; +void memoryReplayIngressContract; +void browserEgressSignature; +void bootstrapSignature; +void browserClientSignature; +void nodeSenderSignature; +void nodeUdpSignature; +void nodeUdpSenderSignature; +void nodeIngressSignature; +void nodeEgressSignature; +void nodeWebSocketSignature; +void nodeWebSocketOptionsContract; +void nodeClientSignature; +void poolOptionsContract; +void nodeOrphanScanSignature; +void nodeOrphanRetrySignature; +void nodeOrphanDrainSessionContract; +void nodeStoreAndForwardContract; +void queryOptionsContract; +void egressSessionOptionsContract; +void fixedConnectionIngressContract; +void fixedConnectionEgressContract; +void browserEgressOptionsContract; +void nodeEgressOptionsContract; +void rootExtraOptionsContract; +void senderSequenceContract; +void rootSenderSequenceContract; +void compiledWriterContract; +void compiledWriterTypeContract; +void queryViewContract; +void Sender; diff --git a/test/qwp/public-api.test.ts b/test/qwp/public-api.test.ts new file mode 100644 index 0000000..e109419 --- /dev/null +++ b/test/qwp/public-api.test.ts @@ -0,0 +1,126 @@ +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_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", + "QWP_SENDER_ERROR_POLICY", + "QWP_TARGET", + "QWP_UPGRADE_ERROR_KIND", + "QWP_VERSION", + "QwpBatchTooLargeError", + "QwpBindValues", + "QwpClient", + "QwpClientClosedError", + "QwpDurableAckUnavailableError", + "QwpEgressQuery", + "QwpEgressQueryAbandonedError", + "QwpEgressQueryCancelTimeoutError", + "QwpEgressQueryError", + "QwpEgressQueryTimeoutError", + "QwpEgressReplayRequiredError", + "QwpEgressSession", + "QwpIngressNackError", + "QwpIngressAckTimeoutError", + "QwpIngressSession", + "QwpMemoryReplayAppendTimeoutError", + "QwpMemoryReplayFrameTooLargeError", + "QwpProtocolError", + "QwpPoolAcquireTimeoutError", + "QwpPoolResourceError", + "QwpReconnectExhaustedError", + "QwpRoleMismatchError", + "QwpReplayRejectedError", + "QwpResultBatch", + "QwpResultBatchView", + "QwpResultColumnView", + "QwpResultRowView", + "QwpQueryLease", + "QwpSendTimeoutError", + "QwpSender", + "QwpSenderCloseTimeoutError", + "QwpUnrecoverableReplayDictionaryError", + "QwpUpgradeError", + "defaultQwpSenderErrorHandler", +] as const; + +const browserRuntimeContract = [ + "QwpBrowserSessionBootstrapError", + "bootstrapQwpBrowserSession", + "connectQwpBrowserEgress", + "connectQwpBrowserIngress", + "connectQwpBrowserClient", + "connectQwpBrowserSender", + "connectQwpBrowserWebSocket", + "createQwpBrowserConnectionFactory", + "createQwpBrowserClient", + "createQwpBrowserSender", +] as const; + +const nodeRuntimeContract = [ + "QWP_ORPHAN_DRAIN_EVENT_KIND", + "QWP_ORPHAN_FAILED_SENTINEL", + "QWP_SF_BACKPRESSURE_POLICY", + "QWP_SF_DURABILITY", + "QwpNodeFileReplayStore", + "QwpNodeOrphanDrainer", + "QwpNodeUdpSession", + "QwpReplayStoreAppendTimeoutError", + "QwpReplayStoreCheckpointError", + "QwpReplayStoreCorruptionError", + "QwpReplayStoreError", + "QwpReplayStoreFullError", + "QwpReplayStoreLockedError", + "QwpReplayStoreLockLostError", + "QwpReplayStoreQuarantinedError", + "QwpUdpDatagramTooLargeError", + "QwpVersionMismatchError", + "connectQwpNodeEgress", + "connectQwpNodeIngress", + "connectQwpNodeClient", + "connectQwpNodeSender", + "connectQwpNodeUdp", + "connectQwpNodeUdpSender", + "connectQwpNodeWebSocket", + "createQwpNodeConnectionFactory", + "createQwpNodeClient", + "createQwpNodeSender", + "createQwpNodeUdpSender", + "retryQwpNodeOrphanSlot", + "scanQwpNodeOrphanSlots", +] 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/test/qwp/reconnect.test.ts b/test/qwp/reconnect.test.ts new file mode 100644 index 0000000..578aaf2 --- /dev/null +++ b/test/qwp/reconnect.test.ts @@ -0,0 +1,5152 @@ +import { + mkdir, + mkdtemp, + open, + readdir, + readFile, + rm, + stat, + truncate, + unlink, + utimes, + 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_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"; +import { + QWP_RECONNECT_EVENT_KIND, + QWP_COLUMN_TYPE, + QWP_EGRESS_CAPABILITY, + QWP_EGRESS_MESSAGE, + 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, + QwpDurableAckUnavailableError, + QwpFailoverError, + type QwpSenderError, + QwpEgressSession, + QwpEgressSessionClosedError, + QwpIngressSession, + QwpIngressSessionClosedError, + QwpIngressReplayRecord, + QwpIngressReplayReference, + QwpIngressReplayStore, + QwpHandshakeMetadata, + QwpMemoryReplayAppendTimeoutError, + QwpMemoryReplayFrameTooLargeError, + QwpProtocolError, + QwpSymbolDictionary, + QwpTableBuffer, + QwpReconnectEvent, + QwpReconnectExhaustedError, + QwpReplayRejectedError, + QwpReplayDictionaryPersistenceError, + QwpSender, + QwpUnrecoverableReplayDictionaryError, + QwpUpgradeError, + encodeQwpFrame, + encodeQwpDurableAckPollFrame, + encodeQwpIngressFrame, + 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"; +import { + createQwpFailoverConnectionFactory, + createQwpFailoverHealthTracker, +} from "../../src/_qwp/_internal/failover"; + +async function expectOnlyJavaSlotLockMetadata( + directory: string, +): Promise { + expect((await readdir(directory)).sort()).toEqual([".lock", ".lock.pid"]); +} + +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, + role: number = QWP_SERVER_ROLE.STANDALONE, + zone?: string, + capabilities: number = QWP_EGRESS_CAPABILITY.QUERY_FLAGS, +): Uint8Array { + 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(advertisedCapabilities) + .writeBigInt64(123n); + writeUint16String(payload, "cluster"); + writeUint16String(payload, node); + if (zone !== undefined) writeUint16String(payload, zone); + 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); +} + +/** 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) + .writeBigUint64(requestId); + writeQwpVarint(payload, 1); + writeQwpVarint(payload, 0); + 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; +} + +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[] = []; + 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); + } +} + +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++; + } +} + +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; + + 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"); + } + 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); + } +} + +/** 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; + + async loadSymbolDictionary(): Promise { + return []; + } + + async appendSymbolDictionary(): Promise { + this.appendSymbolDictionaryCalls++; + throw new Error("symbol dictionary disk is full"); + } +} + +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; + let lastSecondary: FakeConnection | undefined; + 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, + }); + } + 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", "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("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")); + 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 () => { + 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("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; + + 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); + 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; + 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("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(); + 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, + }); + 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, + totalFramesSent: 2, + }); + 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("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 + // 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("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; + 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("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"); + 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 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 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 () => { + 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("BTC-USD")]), + ).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(0n)!), + ).toEqual({ startId: 0, entries: ["ETH-USD", "BTC-USD"] }); + 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++) { + // 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))); + } + + // Retaining the acknowledged prefix pinned every payload for the life of + // the connection and made each ACK scan it three times over. + await vi.waitFor(() => 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( + 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(); + 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(); + 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"); + 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.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, + memoryReplayMaxBytes: 128 * 1024 * 1024, + memoryReplayUsedBytes: 0, + waitingMemoryReplayAppends: 0, + totalMemoryReplayBackpressureStalls: 0, + totalMemoryReplayAppendTimeouts: 0, + }); + 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("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", { + 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"); + 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"); + const replayStore = new TrackingReplayStore(); + 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, + }); + }, + { + replayStore, + 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 vi.waitFor(() => expect(replayStore.closeCount).toBe(1)); + await session.close(); + 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"); + const connections = [first, second]; + const senderErrors: QwpSenderError[] = []; + const session = await QwpIngressSession.connect( + async () => { + const connection = connections.shift(); + if (!connection) throw new Error("no connection available"); + return connection; + }, + { + onSenderError: (error) => senderErrors.push(error), + 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 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(); + }); + + 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, + 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.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("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 vi.waitFor(() => + expect(session.metrics.deliveredErrorNotifications).toBe(2), + ); + 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("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 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(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(); + }); + + 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]); + 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(); + }); + + 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"); + 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 assignedReplaySegments(directory)).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([]); + await expect( + verify.appendSymbolDictionary(0, ["BTC-USD"]), + ).resolves.toBeUndefined(); + await verify.close(); + await expectOnlyJavaSlotLockMetadata(directory); + 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, ".symbol-dict"))) + .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, ".symbol-dict"), 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.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, ".symbol-dict"), 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, ".symbol-dict"), 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(); + 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 }); + 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("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([]); + await vi.waitFor(async () => + expect(await assignedReplaySegments(directory)).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 assignedReplaySegments(directory)).toEqual([]), + ); + expect(session.metrics).toMatchObject({ + replayAcknowledgedFrameSequence: 7n, + pendingReplayFrames: 0, + totalFramesReplayed: 1, + }); + expect(session.publishedFrameSequence).toBe(7n); + await vi.waitFor(() => 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]])); + await vi.waitFor(async () => + expect(await assignedReplaySegments(directory)).toEqual([]), + ); + await vi.waitFor(() => expect(session.acknowledgedFrameSequence).toBe(8n)); + 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", { + 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, [["trades", 42n]])); + await vi.waitFor(async () => + expect(await assignedReplaySegments(directory)).toHaveLength(1), + ); + connection.receive(durableResponse([["trades", 42n]])); + await vi.waitFor(async () => + expect(await assignedReplaySegments(directory)).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", { + 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 assignedReplaySegments(directory)).toHaveLength(1); + + connection.receive(durableResponse([["trades", 42n]])); + await vi.waitFor(async () => + expect(await assignedReplaySegments(directory)).toEqual([]), + ); + await session.close(); + await rm(directory, { recursive: true, force: true }); + }); +}); + +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); + 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("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"); + 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("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("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); + expect(event.serverInfo).toMatchObject({ + nodeId: "secondary", + capabilities: 0, + }); + }, + }, + ); + 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"); + 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, + }, + bufferPoolSize: 1, + onReplayReset: (event) => void 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)); + // 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])); + 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("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) => void 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("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]; + 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("select * from x"); + first.receive(emptyResultBatch(0n, 1)); + + 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(); + }); +}); + +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("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/); + 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({ + 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 }); + 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(); + expect(await assignedReplaySegments(directory)).toHaveLength(1); + + 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(); + expect(await readdir(directory)).toEqual( + expect.arrayContaining([".ack-watermark"]), + ); + + const third = new QwpNodeFileReplayStore({ directory }); + await expect(third.load()).resolves.toEqual([ + { frameSequence: 1n, payload: Uint8Array.of(3, 4) }, + ]); + 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("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 }); + 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("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("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({ + directory, + maxSegmentBytes: 1, + }); + 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 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(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.toBeInstanceOf( + QwpReplayStoreCorruptionError, + ); + 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 < 100n; sequence++) { + await store.append({ + frameSequence: sequence, + payload: Uint8Array.of(1), + }); + } + const segments = await assignedReplaySegments(directory); + expect(segments.length).toBeGreaterThan(1); + expect(segments.length).toBeLessThan(100); + expect(store.metrics).toMatchObject({ + pendingRecords: 100, + pendingSegments: segments.length, + }); + for (const segment of segments) { + expect((await stat(join(directory, segment))).size).toBe(24 + 8 + 256); + } + await store.close(); + }); + + it("recovers SFA 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 assignedReplaySegments(directory); + 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, 24 + 8 + 3); + await file.sync(); + } finally { + await file.close(); + } + + 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("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"], + ] 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, + 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(".symbol-dict"); + await first.close(); + await expectOnlyJavaSlotLockMetadata(directory); + + 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(); + await expectOnlyJavaSlotLockMetadata(directory); + }, + ); + + 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(); + await expectOnlyJavaSlotLockMetadata(directory); + }); + + 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, + } 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("arbitrates acquisition over stale Java lock metadata", async () => { + const directory = await trackedDirectory(); + await writeFile(join(directory, ".lock"), ""); + await writeFile(join(directory, ".lock.pid"), "2147483647\n"); + + 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" }, + }); + await stores[winner].close(); + await expect(stores[loser].load()).resolves.toEqual([]); + await stores[loser].close(); + 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` + // 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("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("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); + // 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. + 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 + // 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("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"); + 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()).resolves.toEqual([]); + expect(await readFile(join(directory, ".lock.pid"), "utf8")).toBe( + `${process.pid}\n`, + ); + await store.close(); + await expectOnlyJavaSlotLockMetadata(directory); + }); + + 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: 4242, + } satisfies Partial); + }); + + 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 () => { + const directory = await trackedDirectory(); + 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, ".symbol-dict"), 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.acknowledgeThrough(0n); + await verify.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); + // 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 () => { + 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: 66, + maxSegmentBytes: 1, + 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) }); + await store.appendSymbolDictionary(0, ["BTC-USD"]); + await unlink(join(directory, ".symbol-dict")); + + 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, + ); + // 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("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 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({ + directory, + maxBytes: 66, + maxSegmentBytes: 1, + 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: 66, + maxSegmentBytes: 1, + 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: 66, + requiredBytes: 99, + 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({ + directory, + maxBytes: 32, + maxSegmentBytes: 1, + }); + await first.load(); + // 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(); + 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: 32, + maxSegmentBytes: 1, + }); + await expect(recovered.load()).resolves.toEqual([ + { frameSequence: 1n, payload: Uint8Array.of(2) }, + ]); + await expect(recovered.loadSymbolDictionary()).resolves.toEqual([ + "abcdefghijklmnopqrstuvwxyz1234", + ]); + 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 }); + await first.load(); + await first.append({ frameSequence: 0n, payload: Uint8Array.of(1) }); + await first.close(); + const [record] = await assignedReplaySegments(directory); + await writeFile(join(directory, record), Uint8Array.of(0)); + + const recovered = new QwpNodeFileReplayStore({ directory }); + await expect(recovered.load()).rejects.toBeInstanceOf( + QwpReplayStoreCorruptionError, + ); + await recovered.close(); + }); + + 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.toBeInstanceOf(QwpReconnectExhaustedError); + }); +}); + +async function createTemporaryDirectory(): Promise { + return mkdtemp(join(tmpdir(), "qwp-replay-")); +} + +async function assignedReplaySegments(directory: string): Promise { + return (await readdir(directory)).filter((name) => name.endsWith(".sfa")); +} 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); + }); +}); diff --git a/test/qwp/sender-error.test.ts b/test/qwp/sender-error.test.ts new file mode 100644 index 0000000..038be51 --- /dev/null +++ b/test/qwp/sender-error.test.ts @@ -0,0 +1,123 @@ +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, + 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", + }); + }); + + 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]", + ); + }); +}); diff --git a/test/qwp/sender-node-integration.test.ts b/test/qwp/sender-node-integration.test.ts new file mode 100644 index 0000000..504aea8 --- /dev/null +++ b/test/qwp/sender-node-integration.test.ts @@ -0,0 +1,415 @@ +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"; +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, + QWP_STATUS, + QwpByteWriter, + QwpNodeFileReplayStore, + decodeQwpIngressSymbolDictionaryDelta, +} 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("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};sf_dir=${directory};initial_connect_retry=off`, + { + qwp: { + webSocket: { + connectTimeoutMs: 100, + }, + 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; + 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)); + acknowledge = () => + 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.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"); + 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, + frames[0].byteOffset, + frames[0].byteLength, + ).getUint32(0, true), + ).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("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 }); + 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(sender.publishedSequence).toBe(0n); + await vi.waitFor(() => expect(frames).toHaveLength(1)); + await expect(sender.flush()).resolves.toBe(false); + } finally { + 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("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 }); + 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 new file mode 100644 index 0000000..83dedcc --- /dev/null +++ b/test/qwp/sender.test.ts @@ -0,0 +1,2368 @@ +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, + QwpTableBuffer, + QwpWriterRowError, + binary, + bool, + byte, + char, + date, + decodeQwpEgressMessage, + decimal64, + decimal128, + decimal256, + designatedTimestamp, + double, + doubleArray, + encodeQwpFrame, + encodeQwpIngressFrame, + float32, + float64, + geohash, + int32, + int64, + ipv4, + long, + long256, + longArray, + short, + symbol as qwpSymbol, + timestamp, + uuid, + varchar, + writeQwpVarint, +} from "../../src/qwp"; + +class RecordingSession implements QwpSenderSession { + readonly sends: { + tables: readonly QwpTableBuffer[]; + options?: QwpIngressEncodeOptions; + }[] = []; + readonly durable: QwpIngressResponse[] = []; + deltaSendCount = 0; + publicationCount = 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, + tables: tables.map((table) => ({ + name: table.name, + sequenceTransaction: BigInt(table.rowCount), + })), + }; + } + + sendTablesDelta( + tables: readonly QwpTableBuffer[], + options?: Pick, + ): Promise { + this.deltaSendCount++; + 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); + } + + async close(): Promise { + this.closeCount++; + } +} + +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 sequence = ++this.publishedFrameSequence; + const response = { + status: QWP_STATUS.OK, + sequence, + tables: tables.map((table) => ({ + name: table.name, + sequenceTransaction: BigInt(table.rowCount), + })), + } satisfies QwpIngressResponse; + 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); + } +} + +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")); + } +} + +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"); + this.publishedFrameSequence++; + } + + publishTablesDelta( + tables: readonly QwpTableBuffer[], + options?: Pick, + ): Promise { + this.deltaSendCount++; + return this.publishTables(tables, options); + } + + async waitForAcknowledged(target: bigint): Promise { + if (target > this.acknowledgedFrameSequence) { + this.acknowledgedFrameSequence = target; + } + } +} + +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); + } +} + +/** + * 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}'`); + 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 + // 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); + // 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. + const sender = build(); + await sender + .table("t") + .stringColumn("skipped", null) + .dateColumn("dateval", null) + .decimal64Column("decval", null, 2) + .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 }); + 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("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( + () => + 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("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("applies a configurable UTF-8 identifier byte length", 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/, + ); + 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, { + 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("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, { + 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, { + 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("bounds an in-flight flush before closing its session", async () => { + const session = new ClosingUnblocksSession(); + const sender = new QwpSender(async () => session, { + autoFlush: false, + awaitServerAck: true, + 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()).rejects.toBeInstanceOf( + QwpSenderCloseTimeoutError, + ); + await expect(flushing).resolves.toEqual( + expect.objectContaining({ message: "session closed" }), + ); + expect(session.closeCount).toBe(1); + expect(sender.metrics.totalFlushFailures).toBe(1); + 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 }); + + 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); + expect(session.deltaSendCount).toBe(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") + .ipv4Column("signed_ip", -1_062_731_775) + .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(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 + // 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 }); + + sender.table("events").floatColumn("discarded", 1.5); + expect(() => sender.stringColumn("bad", 42 as unknown as string)).toThrow( + /only strings/, + ); + // 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("encodes DATE on ingress as a raw int64, unlike 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)); + + // 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("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 }); + + // 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("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 }); + + 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 }); + 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("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("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 + // 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("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("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("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. + 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, + }); + 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({ 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( + { 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("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, + }); + 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("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("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()); + 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, + } satisfies Partial); + 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, { + 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); + }); + + 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, { + 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); + 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); + }); + + 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 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"), + ]); + }); + + 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 new file mode 100644 index 0000000..8dba3e6 --- /dev/null +++ b/test/qwp/session.test.ts @@ -0,0 +1,2715 @@ +import { describe, expect, it, vi } from "vitest"; +import { + bootstrapQwpBrowserSession, + connectQwpBrowserClient, + connectQwpBrowserEgress, + connectQwpBrowserIngress, + connectQwpBrowserWebSocket, + createQwpBrowserClient, + createQwpBrowserSender, + QwpBrowserSessionBootstrapError, + QwpWebSocketLike, +} from "../../src/qwp/browser"; +import { + connectQwpNodeEgress, + connectQwpNodeWebSocket, + QwpDurableAckUnavailableError, + QwpVersionMismatchError, +} 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, + QWP_INGRESS_PROGRESS_KIND, + QWP_STATUS, + QWP_SENDER_ERROR_CATEGORY, + QWP_SENDER_ERROR_POLICY, + QWP_UPGRADE_ERROR_KIND, + QWP_UPGRADE_TIMEOUT_PHASE, + QwpBatchTooLargeError, + QwpByteReader, + QwpByteWriter, + decodeQwpFrame, + decodeQwpIngressSymbolDictionaryDelta, + encodeQwpDurableAckPollFrame, + encodeQwpFrame, + encodeQwpIngressFrame, + QWP_DURABLE_ACK_WEBSOCKET_PROTOCOL, + QwpIngressAckTimeoutError, + QwpIngressNackError, + QwpIngressResponse, + QwpIngressSession, + QwpIngressSessionClosedError, + type QwpSenderError, + QwpTableBuffer, + QwpSendClosedError, + QwpSendTimeoutError, + QwpUpgradeError, + QwpSymbolDictionary, + readQwpVarintNumber, +} from "../../src/qwp"; +import { openQwpWebSocket } from "../../src/_qwp/_internal/websocket-connection"; + +type Listener = (event: unknown) => void; + +class FakeWebSocket { + binaryType = "blob"; + readyState = 0; + protocol = ""; + bufferedAmount = 0; + readonly sent: Uint8Array[] = []; + readonly closeCalls: { code?: number; reason?: string }[] = []; + onSend?: (payload: Uint8Array) => void; + protected readonly listeners = new Map(); + + addEventListener(type: string, listener: Listener): void { + const listeners = this.listeners.get(type) ?? []; + listeners.push(listener); + 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); + } + + 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", {}); + } + + 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 { + 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; + 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; + + ping(): void { + this.pingCalls++; + this.onPing?.(); + } +} + +function asQwpSocket(socket: FakeWebSocket): QwpWebSocketLike { + return socket as unknown as QwpWebSocketLike; +} + +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) { + writeIngressTables(writer, tables); + } else { + const encoded = new TextEncoder().encode(message ?? "rejected"); + writer.writeUint16(encoded.length).writeBytes(encoded); + } + 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); + } +} + +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(compression?: { + codec: number; + level: number; +}): Uint8Array { + const writer = new QwpByteWriter(); + writer + .writeUint8(QWP_EGRESS_MESSAGE.SERVER_INFO) + .writeUint8(0) + .writeBigUint64(1n) + .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(); +} + +/** + * `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", + 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("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("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({ + 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("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("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("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, + 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)); + 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("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; + 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("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("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( + { + 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( + { + 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, + maxBatchRows: 512, + 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(new URL(capturedUrl!).searchParams.get("qwp_max_batch_rows")).toBe( + "512", + ); + 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(); + 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("retains an over-cap batch at flush and discards it on close", 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(); + + // 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); + + // 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); + expect(socket.sent).toHaveLength(0); + }); + + 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, + ); + 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(); + }); + + 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; + 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); + }, + }); + 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", + }); + expect(connection.handshake).toEqual({ + qwpVersion: 1, + maxBatchSizeBytes: 4096, + contentEncoding: "raw", + negotiatedCompression: { codec: "raw", level: 0 }, + 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); + 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(); + }); + + 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, + maxBatchRows: 512, + 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", + "X-QWP-Max-Batch-Rows": "512", + }); + expect(session.handshake.contentEncoding).toBe("zstd;level=5"); + expect(session.negotiatedCompression).toEqual({ + codec: "zstd", + level: 5, + }); + expect(session.negotiatedZstdLevel).toBe(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"); + expect(session.negotiatedCompression).toEqual({ + codec: "raw", + level: 0, + }); + expect(session.negotiatedZstdLevel).toBe(0); + 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.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({ + 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, + negotiatedCompression: { codec: "raw", level: 0 }, + 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, + kind: QWP_UPGRADE_ERROR_KIND.VERSION_MISMATCH, + retryable: true, + tryNextEndpoint: true, + url: "ws://localhost:9000/write/v4", + } 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({ + url: "ws://localhost:9000/write/v4", + requestDurableAck: true, + webSocketFactory: (_url, options) => { + options.onUpgrade({}); + return asQwpSocket(socket); + }, + }); + socket.open(); + + 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, + }, + { + // 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, + }, + ])( + "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); + await vi.waitFor(() => expect(socket.listenerCount()).toBe(0)); + expect(socket.closeCalls).toHaveLength(1); + }); + + 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 { + const socket = new FakeWebSocket(); + const connecting = connectQwpBrowserWebSocket({ + url: "ws://localhost:9000/write/v4", + connectTimeoutMs: 25, + webSocketFactory: () => asQwpSocket(socket), + }); + 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); + } finally { + vi.useRealTimers(); + } + }); + + 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 { + 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 { + 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("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({ + 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({ + 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("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 QwpIngressNackMatch); + 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( + 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("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 { + 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({ + 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("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({ + 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("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 senderErrors: QwpSenderError[] = []; + 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"); + }, + onSenderError: (error) => senderErrors.push(error), + }); + 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", + }); + + 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, + QWP_INGRESS_PROGRESS_KIND.DURABLE_ACKNOWLEDGED, + 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, + 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 { + 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({ + 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("pings an idle durable session until its table targets are covered", async () => { + 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(); + 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("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 { + 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: 100, + durableAckKeepaliveMs: 25, + }, + ); + 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("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({ + 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 QwpIngressNackMatch); + await expect(session.sendFrame(Uint8Array.of(2))).resolves.toMatchObject({ + sequence: 1n, + status: QWP_STATUS.OK, + }); + 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 { + 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/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); +} 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..f5617c3 --- /dev/null +++ b/test/qwp/sfa-multiprocess.e2e.ts @@ -0,0 +1,331 @@ +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); +} + +// 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; + 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; +} + +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/test/qwp/udp-sender.test.ts b/test/qwp/udp-sender.test.ts new file mode 100644 index 0000000..4e47440 --- /dev/null +++ b/test/qwp/udp-sender.test.ts @@ -0,0 +1,367 @@ +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, + 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; + + bindError?: Error; + + bind(_port: number, _address: string, callback: () => void): void { + queueMicrotask(() => { + if (this.bindError) { + this.errorListener?.(this.bindError); + return; + } + 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("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({ + 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("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({ + host: "localhost", + maxDatagramSize: 64, + socketFactory: () => socket, + }); + + // 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("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"); + 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 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", + socketFactory: () => new FakeUdpSocket(), + }; + expect(() => + createQwpNodeUdpSender(options, { transactional: true }), + ).toThrow(/does not support transactions/); + expect(() => + createQwpNodeUdpSender(options, { awaitDurableAck: true }), + ).toThrow(/does not support durable acknowledgements/); + }); +}); diff --git a/test/qwp/wss-tls-security.test.ts b/test/qwp/wss-tls-security.test.ts new file mode 100644 index 0000000..ff0b6a7 --- /dev/null +++ b/test/qwp/wss-tls-security.test.ts @@ -0,0 +1,361 @@ +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"; +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 + * 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"; +const TRUSTED_CA_PATH = "test/certs/ca/ca-trusted.crt"; + +interface AgentTlsOptions { + rejectUnauthorized?: boolean; + ca?: Buffer | string; + pfx?: Buffer | 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.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"), + 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=${rootsPath};`, + ); + 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( + "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(/PEM-encoded CA certificates.*PKCS#12/); + }); + + 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(); + }); + + 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 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(); + } + }); +}); + +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]; + } + + /** + * 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); + 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("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 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", () => { + // 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", + 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", + ); + }); + + 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'", + ); + }); +}); diff --git a/test/sender.buffer.test.ts b/test/sender.buffer.test.ts index 9b0cc96..9527ba7 100644 --- a/test/sender.buffer.test.ts +++ b/test/sender.buffer.test.ts @@ -437,28 +437,258 @@ 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 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("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", + ); + // 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. + 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("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", + 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(); }); @@ -483,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", 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); 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/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"] + } + } +} 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", diff --git a/tsconfig.qwp-browser.json b/tsconfig.qwp-browser.json new file mode 100644 index 0000000..23469db --- /dev/null +++ b/tsconfig.qwp-browser.json @@ -0,0 +1,13 @@ +{ + "include": ["src/_qwp/**/*.ts", "src/qwp/**/*.ts"], + "exclude": ["src/qwp/node.ts"], + "compilerOptions": { + "moduleResolution": "bundler", + "module": "ESNext", + "target": "ES2020", + "lib": ["ES2020", "DOM"], + "types": [], + "noEmit": true, + "strict": true + } +} diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 0000000..e38df0d --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "skipLibCheck": true + }, + "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"] +} diff --git a/typedoc.json b/typedoc.json index d76ef0d..543693b 100644 --- a/typedoc.json +++ b/typedoc.json @@ -1,8 +1,13 @@ { "$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", + "name": "QuestDB JavaScript Client", "readme": "./README.md", "tsconfig": "./tsconfig.json", "exclude": ["**/test/**/*", "**/examples/**/*", "**/node_modules/**/*"], 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, + }, +}); diff --git a/vitest.dist.config.ts b/vitest.dist.config.ts new file mode 100644 index 0000000..16b663e --- /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", "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. + server: { deps: { external: [/dist[\\/]/] } }, + }, +}); diff --git a/vitest.qwp-browser.config.ts b/vitest.qwp-browser.config.ts new file mode 100644 index 0000000..179afd2 --- /dev/null +++ b/vitest.qwp-browser.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["test/qwp/browser.e2e.ts"], + // Only a Chromium launch happens in beforeAll now, not a container pull. + hookTimeout: 120_000, + testTimeout: 30_000, + }, +});