Skip to content

feat(qwp): align ACK waits, reconnect and startup with other QuestDB clients - #71

Merged
bluestreak01 merged 73 commits into
mainfrom
ia_qwp_fixes
Oct 14, 2026
Merged

bluestreak01 merged 73 commits into
mainfrom
ia_qwp_fixes

Conversation

@glasstiger

@glasstiger glasstiger commented Oct 2, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Aligns the QWP client with the Java, Rust and Python clients. ACK waits, reconnect, startup and store-and-forward defaults now behave the same way. Applications reach QWP only through senders, query sessions and pooled clients: the layers below them are internal, and each entry point takes one options object. Along the way, the PR fixes ACK-wait, in-memory replay, pool teardown and query-timeout bugs. Fixes #70.

QWP has not shipped in a release yet: npm latest is 4.2.0, and @questdb/browser-client is unpublished. The API changes below therefore break only code written against main. For the released ILP transports, the only change is the new Sender.flushAndWait().

Fixes

  • Replay ACK waiters (waitForAcknowledged() never resolves for frames replayed from the store-and-forward journal #70). waitForAck() now wakes when a store-and-forward replay advances the ACK watermark without forwarding the replayed OK to the session. The session-visible watermark advances only after the response carrying it has been queued, so a woken waiter or close() cannot overtake that response's onResponse and progress notifications.

  • In-memory replay delivery. Without a journal, flush() completes once frames enter the bounded in-memory replay queue. A background drainer then sends them in order, so a sender keeps accepting flushes during an outage until the queue applies backpressure. The queue's cap (memoryReplayMaxBytes, 128 MiB by default) charges each frame its payload plus 1 KiB for what a queued frame keeps in memory, so it bounds a backlog of small frames too: at the 64 bytes it charged before, one-row flushes filled about 1 GiB of heap before the cap applied backpressure, and a process with a 512 MB heap ran out of memory. The default now holds about 127,000 one-row frames. To keep those frames from being dropped silently:

    • a fast close (closeFlushTimeoutMs <= 0) still waits up to 5 s for them to reach the socket, and otherwise rejects with QwpSenderCloseTimeoutError;
    • pool housekeeping no longer reaps an idle in-memory sender that still has unacknowledged frames;
    • durable-ACK polls (browser senders with requestDurableAck) are admitted above the queue's cap, and none is queued while frames still wait for the socket. Before this, a poll that met a full queue during an outage, or a queue full of frames awaiting durable upload, ended the session and lost every queued row.
  • Pooled sender teardown is best effort, as in the other clients' pools. A failed close logs a warning, and the warning notes when frames remain in the store-and-forward journal. Neither QwpClient.close() nor a lease's close() rejects.

  • Query timeouts settle on the server's answer (commit f8ad871c, marked BREAKING CHANGE against main). When the timeout expires:

    • no further batch reaches the consumer and the query is cancelled;
    • the outcome then waits up to cancelDrainTimeoutMs (query_close_timeout_ms) for the server's terminal response;
    • a statement that completed past its timeout succeeds;
    • a CANCELLED reply, or a result that lost a batch to the timeout, rejects with QwpEgressQueryTimeoutError.

    The timeout now runs from the query() call, so waiting for a reconnect or for a previous query to drain counts against it. query() waits for a released query that is still draining instead of throwing "a QWP query is already active". The timeout is still enforced only by the client.

  • Egress: resetting a query schema no longer throws on skipped batch slots. QwpEgressQueryOptions.onFailoverReset adds a per-query replay callback, which pooled leases need because request IDs are not unique across pooled sessions.

  • Durable-ACK keepalive. durableAckKeepaliveMs (durable_ack_keepalive_interval_millis) takes effect only together with requestDurableAck, as in the Java and Rust clients. Node no longer treats it as a request for durable ACK. The browser client no longer rejects it.

API changes

ACK waiting

  • The awaitServerAck, awaitDurableAck and durableAckTimeoutMs sender options are removed. Instead, flushAndWait(timeoutMs?) on QwpSender and on the Node Sender flushes and then:
    • resolves true once every published frame is acknowledged;
    • resolves false when the ACK watermark makes no progress for timeoutMs. That timeout defaults to ackTimeoutMs (15 s) and restarts whenever the watermark advances.
    • With a timeout of zero or less, it checks without waiting.
    • A transactional sender commits first.
    • UDP and the ILP transports resolve true once the data is sent.
  • waitForAcknowledged() is renamed waitForAck() and resolves a boolean with the same timeout semantics. QwpIngressAckTimeoutError is removed, and a timed-out wait is reported only to its caller, not to onError.
  • acknowledgedSequence is renamed ackedSequence on QwpSender, the Node Sender, QwpIngressMetrics and QwpSenderCloseTimeoutError.
  • QwpIngressMetrics drops pendingResponses and pendingResponseBytes, which counted the per-frame ACK waiters that waitForAck() replaced.
  • Durable ACK is requested explicitly with requestDurableAck: true. The watermark then follows durable progress, so flushAndWait(), waitForAck() and the close drain all wait for durability.
  • QwpSender.commit() is removed. flush() is the commit point of a transactional sender, as in the Java client.

One options object per entry point

  • connectQwpNodeSender() / createQwpNodeSender() take one QwpNodeIngressOptions, which holds connection, buffering and delivery settings.
  • connectQwpBrowserSender() / createQwpBrowserSender() take one QwpBrowserIngressOptions.
  • The UDP senders take one QwpNodeUdpOptions.
  • connectQwpNodeEgress() / connectQwpBrowserEgress() take one egress options object plus an optional signal.
  • QwpSenderOptions, QwpIngressSessionOptions, QwpEgressSessionOptions and QwpWebSocketConnectOptions are now non-exported bases of those types.
  • gorilla and symbolDictionary replace the encode object.
  • requestDurableAck is an ingress option only; the egress upgrade never requests durable ACK.
  • QwpEgressRoutingOptions becomes QwpRoutingOptions, and only query sessions take it. Node ingress no longer routes by target or zone: only the primary accepts writes, and the endpoint sweep reaches it through each replica's 421, so both keys route query sessions only. Node rejects typed target/zone on a sender and under a pooled client's cluster or ingress; in a connect string they route egress, and Sender.fromConfig() warns that it ignores them.
  • The pooled clients take a cluster form: { cluster, ingress, egress, pool, lazyConnect } in Node and { cluster, ingress, egress, pool } in browsers. cluster owns the endpoints and authorization, and each side derives its route from the cluster URL. The browser split form is removed.
  • Connect-string overrides take the same sections, and an unknown section is rejected by name.
  • Sender.fromConfig() keeps two qwp sections, webSocket and udp. Each takes that sender's complete typed options.

Internal layers

No package root exports the following any more. Applications use senders, connectQwp*Egress() and the pooled clients.

  • the ingress session: QwpIngressSession, connectQwpNodeIngress() and connectQwpBrowserIngress();
  • the sender session interface: QwpSenderSession and QwpSenderSessionFactory;
  • the raw connection helpers: connectQwpNodeWebSocket(), createQwpNodeConnectionFactory(), connectQwpBrowserWebSocket() and createQwpBrowserConnectionFactory(), together with QwpBinaryConnection, QwpConnectionFactory and the transport metrics types;
  • the store-and-forward journal and its replay-store contract: QwpNodeFileReplayStore and its options and metrics, plus QwpIngressReplayStore, QwpIngressReplayRecord and QwpIngressReplayReference;
  • the orphan drainer: QwpNodeOrphanDrainer, its options and session types, and scanQwpNodeOrphanSlots(). Orphan recovery is configured through storeAndForward and observed through onOrphanDrainEvent.
  • the UDP session: QwpNodeUdpSession, QwpNodeUdpMetrics and connectQwpNodeUdp();
  • QwpEgressSession.connect(). The QwpSender and QwpEgressSession constructors now require a token that only the package holds.

Each package root therefore lists its runtime adapter's exports by name. The browser adapter moved from src/index.ts to src/qwp.ts, matching the Node package's layout.

Reconnect

  • QwpReconnectOptions is split in two: QwpIngressReconnectOptions (reconnect* names) and QwpEgressReconnectOptions (failover* names), mirroring the connect-string keys.
  • Once a session has connected, ingress retries every outage until close() or a terminal error. reconnectMaxDurationMs bounds only a synchronous startup, and 0 now allows one attempt without retries. Egress failover episodes remain bounded.
  • Only the three reconnect* tuning settings promote an unset startup mode to "sync".
  • poisonMinEscalationWindowMs (poison_min_escalation_window_millis) defaults to 5 seconds instead of 5 minutes, as in the Java, Rust and Python clients. A frame that keeps drawing retriable rejections (maxFrameRejections, 4 by default) is now treated as poison, and its sender goes terminal, after 5 seconds of connected dwell rather than 5 minutes.
  • reconnect.onEvent reports the connection events the Java, Rust, Go and Python clients share. disconnected replaces reconnecting. attempt-failed is replaced by endpoint-attempt-failed, raised as each endpoint fails, and all-endpoints-unreachable, raised once per failed sweep. A 401 or 403 is reported as auth-failed instead.

Query sessions

  • The session-wide onReplayReset option is renamed onFailoverReset, as the other clients name it (onFailoverReset() in Java, on_failover_reset() in Rust), and its event type QwpEgressReplayResetEvent is renamed QwpEgressFailoverResetEvent. A query's own onFailoverReset overrides the session's.
  • QwpEgressReplayRequiredError is removed. It was deprecated, and nothing threw it.

Startup

  • initialConnectMode is an ingress option of every sender, with or without a journal. "async" now also works in browsers.
  • initial_connect_retry is case-insensitive.
  • lazy_connect accepts on, off, true and false in any case. Only the pooled client applies it: Sender.fromConfig() validates it, ignores it and logs a warning, as the other clients' standalone senders do.

Store-and-forward defaults and layout

  • The typed storeAndForward options now default exactly as the sf_* keys do:

    • durability: "memory" (was "append");
    • maxBytes 10 GiB (was 1 GiB);
    • backpressurePolicy: "wait" (was "error");
    • appendDeadlineMs 30 s.

    Set durability: "append" if every append must survive power loss.

  • Each journal is a slot below the configured directory:

    • a sender uses <directory>/<senderId>, where senderId defaults to default;
    • pooled senders use <directory>/<senderId>-<slot>, i.e. default-N where they used sender-N before.

    A typed sender built from main journalled directly into <directory>. Move such a backlog into the slot directory before upgrading. A typed pool started without senderId adopts only default-N slots and warns when an earlier sender-N slot still holds journal segments; rename those to default-N, or start the pool once with drainOrphans: true. The client warns when the slot root itself holds journal segments.

Connect-string keys

  • query_timeout_ms sets the default query timeout; 0 disables it.

Documentation

QWP.md, CLAUDE.md, the root, browser-client and nodejs-client READMEs and the benchmark README are updated to match. The regenerated TypeDoc reference under docs/ accounts for most of the changed files.

Validation

Local runs on the head of this branch:

  • ESLint, the Prettier check and every typecheck target (source, browser, tests, benchmarks, built packages) pass.
  • pnpm test: 1,266 tests pass.
  • pnpm test:dist, pnpm check:packages and pnpm test:qwp-browser (10 real-browser tests) pass.

GitHub Actions, including the Enterprise durable-ACK E2E, passed on the previous head dfaa95d and runs again on this one.

QwpSenderSession and QwpSenderSessionFactory were exported from both
package roots, which made the seam between QwpSender and its transports
a public extension point. No other QuestDB client exposes a pluggable
sender session: Java, Rust and Python keep that layer internal and hand
out senders from their factories.

Move both types to an internal module so neither package exports them,
and mark the QwpSender constructor @internal. Applications keep obtaining
senders from the runtime factories or a pooled client. The public type
contracts now also assert that neither root re-exports the two names.

Remove the QWP.md paragraph that documented custom session
implementations, and regenerate the API reference.
Bring the QWP API closer to the Java, Rust and Python clients.

ACK waiting:
- Replace the awaitServerAck/awaitDurableAck sender options with
  flushAndWait(timeoutMs?), which resolves false when acknowledgements
  stop progressing. waitForAcknowledged() resolves a boolean too, and
  QwpIngressAckTimeoutError is removed.
- Replace sendFrameWithPublication()/QwpIngressSendResult with
  publishFrame().
- Add QwpIngressSessionCloseTimeoutError.

Reconnect:
- Split QwpReconnectOptions into QwpIngressReconnectOptions (reconnect*
  names) and QwpEgressReconnectOptions (failover* names). A connected
  ingress session retries every outage until close();
  reconnectMaxDurationMs bounds only a synchronous startup.

Startup (initial_connect_retry, lazy_connect):
- Accept lazy_connect=on|off|true|false in any case, and read
  initial_connect_retry case-insensitively, as the Java and Rust parsers
  do.
- A standalone Sender ignores lazy_connect and warns, as the other
  clients' standalone senders do; it stays a pooled-client flag.
- Make QwpIngressSessionOptions.initialConnectMode public and validate
  it. "async" now starts in the background in browsers as well, and the
  Node adapter honours the session spelling with store-and-forward and
  rejects two spellings that disagree.
- Stop documenting initial_connect_retry as requiring sf_dir, and note
  that the Rust and Python pools do not promote startup to sync from
  reconnect_* keys.
@glasstiger glasstiger changed the title fix(qwp): wake replay ACK waiters and tighten sender/pooled APIs feat(qwp): align ACK waits, reconnect and startup with other QuestDB clients Oct 5, 2026
QwpIngressSession was exported from both package roots, along with
connectQwpNodeIngress() and connectQwpBrowserIngress(), which hand one
out. That made the layer below QwpSender a second, parallel ingress API.
The other QuestDB clients keep that layer internal: Java, Go and Python
publish through their senders, and the batches Rust and C accept -- a
row Buffer, or a column Chunk for zero-copy Arrow and Polars ingest --
are payloads handed to a sender, not a session.

Stop exporting QwpIngressSession, the two factories, and
QwpIngressSessionCloseTimeoutError, which only the session's own close()
throws. The session options, metrics, notification and error types that
the sender surfaces stay public.

Both roots now re-export their runtime adapter by name instead of with
`export *`, because the adapter module also exports the factory its
senders use. The browser adapter therefore moves from index.ts to
qwp.ts, verbatim apart from its header (git diff -C shows the move),
matching the Node package's layout. The runtime contract asserts that
the four names stay out of all three barrels, and the type contracts pin
the same for the source and the built declarations.

The browser bundle tests read the negotiated handshake from
createQwpBrowserConnectionFactory() instead, which exercises the same
ingress negotiation. QWP.md drops the low-level session guide and notes
where the ordinary OK remains observable once durable ACK tracking moves
the watermark.
Regenerated with pnpm run docs after the ingress session became internal. The pages for QwpIngressSession, QwpIngressSessionCloseTimeoutError, connectQwpNodeIngress and connectQwpBrowserIngress are removed, and source links now point at the browser adapter's new qwp.ts.
QwpNodeOrphanDrainer was exported from the Node root together with its
options and session types and scanQwpNodeOrphanSlots(), so applications
could construct and drive the drainer themselves. The other QuestDB
clients configure orphan recovery instead -- drain_orphans and
max_background_drainers in Java, Go, Rust and Python -- and expose at
most a drainer listener and statistics. QwpNodeUdpSession was likewise
a session-level UDP API beside connectQwpNodeUdpSender(), while Java
publishes UDP rows through its Sender.

Stop exporting QwpNodeOrphanDrainer, QwpNodeOrphanDrainerOptions,
QwpNodeOrphanDrainSession, scanQwpNodeOrphanSlots, QwpNodeUdpSession
and QwpNodeUdpMetrics, and remove connectQwpNodeUdp(), which only
returned the session: createQwpNodeUdpSender() connects it directly.
The drainer's notification surface -- onOrphanDrainEvent with its
event, kind and metrics types -- stays public, as do
retryQwpNodeOrphanSlot() and QWP_ORPHAN_FAILED_SENTINEL for re-enabling
a quarantined slot. The UDP datagram counters were reachable only
through the session; UDP senders keep their own metrics and onError.

The runtime contract asserts that the four runtime names stay out of the
Node root, and the type contracts pin all seven for the source and the
built declarations. Tests reach the internals through their qwp-node/
modules, and config-docs no longer counts connectQwpNodeUdp() as a
high-level entry point. QWP.md drops connectQwpNodeUdp() and
scanQwpNodeOrphanSlots() and says how to configure and observe orphan
recovery instead.
Regenerated with pnpm run docs after the orphan drainer and UDP session became internal. The pages for QwpNodeOrphanDrainer, QwpNodeOrphanDrainerOptions, QwpNodeOrphanDrainSession, scanQwpNodeOrphanSlots, QwpNodeUdpSession, QwpNodeUdpMetrics and connectQwpNodeUdp are removed, and the embedded QWP.md matches the updated guide.
The public option interfaces mirrored internal layers more than caller
concerns, and several carried fields that belonged elsewhere. None of
this API has shipped yet, so trim it before 5.0.0 does.

The browser client accepted a split form with complete ingress and
egress trees beside the cluster form. Both arrived in the same commit,
so the split form was compatible with nothing published. Drop
QwpBrowserSplitClientOptions and QwpBrowserUnifiedClientOptions:
QwpBrowserClientOptions is now the cluster form, and a client without a
cluster is rejected by name. Callers that must connect the two sides
differently can give QwpClient their own factories.

requestDurableAck sat on the WebSocket options both sides extend, so
egress inherited it and two places had to strip it again: sent on
/read/v1 it failed every query session. It now lives on the ingress
types only -- QwpNodeIngressOptions and the new QwpBrowserIngressOptions,
which also takes ingressNegotiationTimeoutMs -- and the Node egress path
passes false to the endpoint connector instead of relying on a strip.
The pooled client's typed override moves from webSocket.requestDurableAck
to a new ingress section, and Sender.fromConfig() routes
qwp.webSocket.requestDurableAck there. Without the ingress fields,
QwpBrowserClusterOptions was identical to QwpBrowserWebSocketOptions and
is removed.

QwpEgressRoutingOptions becomes QwpRoutingOptions, since ingress honours
target and zone too, and QwpSenderOptions takes gorilla and
symbolDictionary directly instead of an encode object, which removes
QwpSenderEncodeOptions.

Five @internal fields on QwpIngressSessionOptions -- replayStore and the
background and orphan store-and-forward handoffs -- were published in
both packages' declarations, because nothing strips @internal. They move
to QwpIngressSessionInternalOptions, which no root exports, together
with the pre-session error delivery count, which had been attached under
a symbol to avoid exactly that leak. Lazy startup no longer sets
backgroundStoreAndForward on the public ingressSession options: the
async initial connect mode already implies it.

The type contracts pin the renamed and removed names for the source and
the built declarations, and fail if an internal session field or an
ingress-only field returns to a shared or egress type. New tests cover
the Sender's requestDurableAck routing, an egress upgrade given
requestDurableAck anyway, and a browser client without a cluster.
QWP.md drops the split form and the encode object.
Regenerated with pnpm run docs after the option types were reduced. The pages for QwpBrowserClusterOptions, QwpBrowserSplitClientOptions, QwpBrowserUnifiedClientOptions, QwpEgressRoutingOptions and QwpSenderEncodeOptions are removed, QwpBrowserClientOptions is now an interface page rather than a type alias, and QwpBrowserIngressOptions and QwpRoutingOptions are added.
Each QWP entry point took its options in the shape of the layers behind
it: a sender took connection, sender and session options as three
arguments, an egress connection took connection and session options as
two, and the pooled client took five sections split the same way. With
the ingress session internal, two of those types named objects no
caller can reach, and some settings lived in two places --
initialConnectMode on the session and on storeAndForward, maxBatchRows
on the egress connection and on its session -- with code to reconcile
them.

The ingress and egress options of each runtime now absorb the sender's
buffering options and the session's delivery options.
connectQwpNodeSender(), connectQwpBrowserSender() and their create forms
take one QwpNodeIngressOptions or QwpBrowserIngressOptions, the UDP
senders one QwpNodeUdpOptions, and connectQwpNodeEgress() and
connectQwpBrowserEgress() one egress options object plus an optional
signal. QwpSenderOptions and QwpIngressSessionOptions become internal
building blocks, which the published declarations still carry for the
types that extend them; QwpEgressSessionOptions stays public for
QwpEgressSession.connect(). storeAndForward loses its own
initialConnectMode, so assertConsistentQwpInitialConnectMode() and the
conflict it reported are gone, and one maxBatchRows is both the
requested row cap and the decoder bound.

The Node pooled client takes the cluster form the browser client
already had -- cluster, ingress, egress, pool and lazyConnect -- with
the endpoints and authorization owned by cluster and each side deriving
its route from the cluster URL through a helper both runtimes now share.
Overrides for a connect string take the same sections, all optional, so
QwpNodeClientConfigOptions is no longer a vocabulary of its own, and
lazyConnect gains a typed override. On the browser side
QwpBrowserClientIngressOptions and QwpBrowserClientEgressOptions give
way to the merged side options, minus what the cluster owns.

Sender.fromConfig() keeps two qwp sections, webSocket and udp, each
taking that sender's complete typed options; session and sender are
folded into them. A section from the old shapes would otherwise be
skipped with every setting inside it -- two tests here were passing
qwp.session settings that no longer applied -- so the qwp sections, the
pooled client's options and its overrides now reject an unknown section
by name.

Public option types fall from 19 to 17 on the Node root and from 18 to
14 on the browser root. The contracts pin the new signatures and
shapes, and fail if a side accepts a cluster-owned field, if
storeAndForward regains a startup mode, or if the merged ingress options
regain an adapter-only session field. QWP.md, the READMEs and the
example use the one-object forms.
Regenerated with pnpm run docs after each QWP entry point took one options object. The pages for QwpSenderOptions, QwpIngressSessionOptions, QwpBrowserClientIngressOptions and QwpBrowserClientEgressOptions are removed; the ingress and egress option pages now list the buffering, delivery and session members they include.
The connect-string overrides gained lazyConnect when they took the pooled
client's own sections. Pin that a typed value wins over lazy_connect in
both directions, and that enabling it applies the same async startup and
cold query pool the key does.
Both packages exported a raw WebSocket connector and a stateful endpoint
walker -- connectQwpNodeWebSocket() and createQwpNodeConnectionFactory(),
connectQwpBrowserWebSocket() and createQwpBrowserConnectionFactory() --
each returning a bare QwpBinaryConnection with no session over it. With
the ingress session internal, their only public consumer was
QwpEgressSession.connect(), which connectQwpNodeEgress() and
connectQwpBrowserEgress() already drive with factories of their own, and
every sender, query session and pooled client opens and reconnects its
own connections from the same endpoint options.

Stop exporting all four. connectQwpNodeWebSocket() and
connectQwpBrowserWebSocket() stay in their adapters as test seams, and
createQwpBrowserConnectionFactory() as the factory behind the browser
sender. The Node factory had no caller besides its own connector, so its
public wrapper is removed and the internal walker takes its name. The
same-origin and session-cookie guidance on the browser connector moves
to QwpBrowserWebSocketOptions, so the API reference keeps it.

The runtime contract asserts that the four names stay out of all three
roots, and the type contracts pin them for the source and the built
declarations. Three browser e2e tests drove the raw factory from the
built bundle to read its handshake; they now connect a public sender and
read its metrics, where effectiveAutoFlushBytes reflects the batch cap
the SERVER_INFO frame advertised. QWP.md says the raw connectors are
internal.
QwpWebSocketConnectOptions holds the endpoints and WebSocket deadlines
both runtime adapters read, and both packages exported it. Nothing takes
it on its own: QwpNodeWebSocketOptions and QwpBrowserWebSocketOptions
extend it, and since the raw connection helpers became internal no
public signature names it either. Exported, it was one more option type
for a caller to tell apart from the two that are used.

Move it from transport.ts, which the shared barrel re-exports whole, to
_internal/websocket-connection.ts beside validateQwpWebSocketTimeouts(),
which checks the same deadlines. The published declarations still carry
it as the base of the two runtime options, as they do QwpSenderOptions,
so its fields keep their documentation on those pages. The authTimeoutMs
note now names connectTimeoutMs in prose rather than linking through the
unexported base.

The type contracts assert that neither root exports it, for the source
and the built declarations, and fail if either runtime's WebSocket
options lose one of its fields.
QwpNodeFileReplayStore was exported with its options and metrics, and
both packages exported QwpIngressReplayStore with its record and
reference types. The only way to hand a sender a replay store was the
replayStore session option, which is internal, so applications could
construct the journal but not give it to a sender: storeAndForward
builds it, and QwpNodeStoreAndForwardOptions merely extended its
options.

Stop exporting QwpNodeFileReplayStore, QwpNodeFileReplayStoreOptions and
QwpNodeFileReplayStoreMetrics, and move QwpIngressReplayStore,
QwpIngressReplayRecord and QwpIngressReplayReference from transport.ts
to a new _internal/replay-store.ts, which no root re-exports; the
browser package carried the contract for a store it can never be given.
QwpNodeStoreAndForwardOptions now declares the journal settings itself
-- directory, maxBytes, maxSegmentBytes, durability,
checkpointIntervalMs, backpressurePolicy, appendDeadlineMs and
onRecoveryDataLoss -- and the store's internal options are a Pick of it,
so the two cannot drift. The policies, the replay-store errors and the
data-loss report stay public, since a sender surfaces them.

The store's metrics were reachable only through a store a caller built;
QWP.md now points at the backlog and watermarks a sender's
metrics.ingress reports. Two dist tests built the store from the
published bundle: the ESM worker check now opens a journal through an
async sender, and the multi-process child drives its slot through a
sender whose endpoint never answers, one journalled frame per flushed
row, which keeps all five cross-process lock and recovery contracts.

The runtime contract asserts that the store stays out of the Node root,
the type contracts pin all six names for the source and the built
declarations, and they fail if the store-and-forward options lose one of
the journal settings. Unit tests import the store from its qwp-node/
module.
The typed storeAndForward options and the sf_* connect-string keys
configure the same journal, yet they disagreed on four of its defaults:
durability (append typed, memory in a connect string), the size cap
(1 GiB against 10 GiB), a full journal (failing against waiting), and
the journal's location (the directory itself against its `default`
slot). Moving between Sender.fromConfig() and the typed options -- a
change that looks like pure configuration style -- changed how durable
and how bounded the journal was and pointed it at a different
directory, stranding any backlog in the old one. The typed defaults
were the journal's historical ones, the backpressure policy labelled
backwards compatible, but no release exists to be compatible with; the
connect string's follow the Java client.

Take the connect string's everywhere. The journal's defaults come from
one QWP_SF_DEFAULTS object, which the parser now resolves the sf_* keys
from as well, so the two spellings cannot drift apart again. senderId
defaults to `default`, as sender_id does, so storeAndForward.directory
is always the slot root: a sender journals into <directory>/<senderId>,
and pooled senders into <directory>/<senderId>-<slot>, `default-N`
rather than `sender-N`. An adopted orphan still opens the slot the
scanner found.

With every journal a named slot, a standalone drainer always scans the
configured directory, so the branch that ignored drainOrphans for an
unnamed journal is gone. The warning that one construction style had
left a journal where the other would not look becomes a warning that
the slot root itself holds segments, which is what a directory naming a
slot rather than its root leaves behind.

Store tests that relied on the old defaults now ask for append
durability or the error policy explicitly, and the transport tests use
the default slot names. New tests pin that a typed storeAndForward
object gets the parser's journal defaults, that a typed sender journals
into its slot rather than the root, and the slot-root warning. QWP.md
and the READMEs describe one set of defaults and one layout.
Regenerated with pnpm run docs after the four follow-ups. The pages for connectQwpNodeWebSocket, createQwpNodeConnectionFactory, connectQwpBrowserWebSocket, createQwpBrowserConnectionFactory, QwpWebSocketConnectOptions, QwpNodeFileReplayStore, QwpNodeFileReplayStoreOptions, QwpNodeFileReplayStoreMetrics, QwpIngressReplayStore, QwpIngressReplayRecord and QwpIngressReplayReference are removed. The WebSocket option pages still list the members of their unexported base, QwpNodeStoreAndForwardOptions lists the journal settings with the connect-string defaults, and the embedded QWP.md matches the updated guide.
QwpEgressSession is the query API, which connectQwpNodeEgress(),
connectQwpBrowserEgress() and the pooled clients return, so the class
has to stay public. Constructing one did not: the static connect()
took a QwpConnectionFactory and the constructor a QwpBinaryConnection,
and since the raw connection helpers became internal, nothing public
produced either. A caller would have had to reimplement the QWP upgrade
to use them; the documented ways to connect differently -- the
webSocketFactory option and custom QwpClient factories -- go through
the public egress functions instead. They were the egress counterpart
of the ingress session made internal earlier, left public only because
the class itself had to stay.

The constructor now takes a token only this module holds, as
QwpTableWriter's does, and the static connect() becomes
connectQwpEgressSession(), with createQwpEgressSession() for one fixed
connection. Neither root exports them, so the shared barrel lists the
egress session's exports by name, as it already did for the ingress
session and the sender. Opening a reconnecting connection used three
of the session's private replay methods from inside the class; the
constructor now fills in an internal hooks object with them instead,
which the reconnecting connection calls through.

With no public signature left taking them, QwpBinaryConnection,
QwpConnectionFactory and the two transport-metrics types only that
connection exposes move from transport.ts to a new
_internal/binary-connection.ts. QwpEgressSessionOptions becomes a
non-exported base of the runtime egress options, as
QwpIngressSessionOptions did. QwpHandshakeMetadata stays public for the
session's handshake getter.

The runtime contract asserts that the factories stay internal, that the
class has no static connect(), and that its constructor rejects a call
without the token. The type contracts pin all five type names for the
source and the built declarations, and fail if connect() returns, if
the constructor stops leading with its token, or if the runtime egress
options lose a session field. Tests construct sessions through the
internal factories. QWP.md says how query sessions are opened.
QwpSender's constructor was documented as internal but callable: it
took a QwpSenderSessionFactory, a type neither root exports since the
sender session interface became internal, so the published
declarations offered a constructor no caller could satisfy without
reaching into unexported types. The runtime factories and the pooled
clients' borrowSender() are how applications obtain a sender.

The constructor now takes a token only the sender module holds, as
QwpTableWriter's and now QwpEgressSession's do, and createQwpSender()
builds senders for the runtime adapters. The shared barrel already lists
the sender module's exports by name, so the factory stays internal.

The runtime contract asserts that createQwpSender stays out of all three
roots and that the constructor rejects a call without the token, and
the type contracts fail, for the source and the built declarations, if
the constructor stops leading with its token. Tests and the sender
benchmark build senders through createQwpSender(). QWP.md says where a
QwpSender comes from.
Regenerated with pnpm run docs after sender and query-session construction became internal. The pages for QwpBinaryConnection, QwpConnectionFactory, QwpEgressSessionOptions, QwpEgressTransportMetrics and QwpIngressTransportMetrics are removed from both packages. QwpEgressSession no longer lists a static connect(), its constructor and QwpSender's are marked internal, the egress option pages list the session members they include, and the embedded QWP.md matches the updated guide.
QwpSender, QwpEgressSession and QwpTableWriter are public classes whose
constructors take a token only the package holds, so a caller can
never use them. TypeDoc still rendered each one, token parameter and
all: excludeInternal does not remove a constructor tagged @internal.
Tag them @hidden as well, so the reference pages list only what a
caller can use. The comments keep @internal and say where instances
come from, for readers of the source and the published typings, which
still declare the constructors.
Regenerated with pnpm run docs after the token-guarded constructors were hidden. The QwpSender, QwpEgressSession and QwpTableWriter pages in both packages no longer list a constructor; QwpClient's public constructor is still documented, and no page is added or removed.
…ested

Without requestDurableAck, the Node client took durableAckKeepaliveMs
(durable_ack_keepalive_interval_millis) as a request for durable ACKs
and rejected it next to requestDurableAck=false, while the browser
client rejected it outright. The Java and Rust clients accept the
keepalive in both cases and ignore it: it only paces the durable-ACK
poll once durable ACK has been requested. Promoting it also changed the
upgrade Node sent, so a connect string those clients use against a
server without durable ACK failed to connect from Node with
QwpDurableAckUnavailableError.

Both adapters now hand the session a keepalive only when durable ACK
was requested -- the configured interval, or the 200ms default --
through resolveQwpDurableAckKeepaliveMs(), which the Node orphan
drainer uses as well. The session reads a defined interval as the
request to track durable progress, so dropping it is what keeps an
ignored keepalive inert. The value is still validated when it is
ignored. The connect-string parser no longer sets request_durable_ack
from the keepalive or rejects it next to request_durable_ack=off.

The Node and browser tests connect to a server that offers durable ACK
unasked and check that the ordinary OK still advances the watermark,
with no durable-ACK request and no polls. The Node test that polls with
the keepalive now requests durable ACK explicitly. QWP.md documents the
200ms default, which config-docs pins to the code, and says the key is
ignored without request_durable_ack=on.
Regenerated with pnpm run docs after the durable-ACK keepalive change. The QwpNodeIngressOptions and QwpBrowserIngressOptions pages say durableAckKeepaliveMs takes effect only alongside requestDurableAck and is otherwise ignored, the embedded README and QWP.md match the updated guides, and no page is added or removed.
QwpIngressSession.close() gave frames still in the in-memory replay
queue up to 5 seconds to reach the socket, and rejected with
QwpIngressSessionCloseTimeoutError when some could not. QwpSender never
used it: its close() already flushes, waits up to closeFlushTimeoutMs
for the committed-frame ACK watermark -- or, in a fast close, for those
frames to reach the socket -- and rejects when that drain cannot
finish, then closed the session through closeWithoutDrain() so the two
budgets could not stack. Since the ingress session became internal, its
only other caller is the orphan drainer, whose sessions journal to
disk, where the drain did nothing.

As with the Java client's close() and the Rust and Python clients'
close_drain(), the drain belongs to the sender, which is what
applications call. The session's close() now closes without a drain, as
closeWithoutDrain() did, and closeWithoutDrain() goes from the session,
the sender session interface and the sender. So do
QwpIngressSessionCloseTimeoutError and the connection's
stopPublishing() and unsentFrameCount, which only that drain used. The
unsent-frame count stays behind waitForPendingSends(), which the
sender's fast close still waits on.

The three tests of the session drain go, the five-minute RAM replay
test no longer closes into a timeout, and the public API contracts stop
listing the removed error.
glasstiger and others added 3 commits October 10, 2026 14:42
The QwpSender pages of both packages and the Node Sender page now say that
flushAndWait() also waits for the frames a store-and-forward journal
recovered; the index page carries the README's corrected browser
durable-ACK paragraph, the embedded QWP.md matches the updated guide, and
the search index follows. Every other page changes only its source links
and line anchors, and no page is added or removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main's browser credential authentication (#68) changed
packages/browser-client/src/index.ts, which on this branch only
re-exports; the implementation moved to qwp.ts, so the change is merged
there instead. auth is cluster-owned like url, failoverUrls and
sessionBootstrap, and main's credential tests use this branch's
single-options-object API. docs/ keeps this branch's pages until the
next commit regenerates them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The browser package gains pages for QwpBrowserAuthContext and
QwpBrowserAuthProvider. QwpBrowserWebSocketOptions, and the ingress and
egress option pages that inherit from it, document the new auth option;
QwpBrowserClientOptions lists auth among the cluster-owned keys, and
QwpBrowserSessionAuthentication says how sessionBootstrap and auth each
use the credential. The browser module page carries the package README's
third-party web application section and lists the two new types, the
index page carries the README's auth example, the embedded QWP.md matches
the updated guide, and the search, navigation and hierarchy indexes and
the highlight stylesheet follow. Every other page changes only its source
links and line anchors, and no page is removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
glasstiger and others added 11 commits October 11, 2026 18:53
QWP.md said the legacy onError callback reports timeouts. Since ACK
waits were aligned with the other clients, a waitForAck() or
flushAndWait() that times out returns false and a close() whose drain
times out rejects, and none of them reaches onError. The guide now says
so, as the onError TSDoc already did.

It also said every backoff key requires a positive value, but
failover_backoff_initial_ms and failover_backoff_max_ms accept zero, as
in the Java and Rust clients, and failover_max_attempts still bounds the
episode. The rule is now scoped to the reconnect_* keys, and a parser
test pins both.

And it said a failed prewarm does not close the client. That holds for
connect() on a client from createQwpNodeClient() or
createQwpBrowserClient(), but connectQwpNodeClient() and
connectQwpBrowserClient() close the client they never hand back. The
guide now separates the two.

A Sender's qwp.webSocket section passes through the pooled client's
resolver, so a url there was reported as an ingress.url that belongs
under cluster.url, and an invalid initialConnectMode as
ingress.initialConnectMode -- options a Sender does not have. Both are
now checked first under their qwp.webSocket names. A programmatic Sender
used to replace such a url silently; it now rejects it too.

Two regression tests pin paths nothing covered: waking an ACK waiter on
a recovered frame when retiring the recovered tail fails after the store
accepted the OK, and reporting the timeout for a result whose batch
arrives after the deadline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The embedded QWP.md page carries the corrected onError, failover backoff
and prewarm statements; every other page changes only its source links
and line anchors, and no page is added or removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…g and leases

A browser sender started with initialConnectMode "async" runs as a
background store-and-forward sender, which retries every upgrade
rejection once it has connected, so a credential can rotate on the
server. That rule also caught the verdicts the browser client reaches on
its own: an auth provider error carrying retryable: false, a returned
credential that could never be sent, and a server that echoes the
credential. The sender then asked the provider again, or re-sent the
credential to the server that exposed it, for as long as it lived,
without ever reaching onError -- contrary to the browser README, the
auth provider TSDoc and QWP.md. Those three errors are now marked as
final, and the reconnect classifier honours the mark before its
store-and-forward rules, so they end the sender after one attempt in
every mode. QWP.md names the exception.

A durable flushAndWait() never confirmed frames a journal recovered.
Their OK is not forwarded to the session, so the session tracked no
durable targets for them and never polled, and QuestDB reports durable
progress only in reply to inbound traffic: the wait returned false until
new rows were published, including for this sender's own rows behind a
recovered frame. The transport now reports acknowledged frames still
awaiting durability and wakes the session when an unforwarded OK leaves
one, and the session polls while either it or the transport has such a
frame, as the Java client does.

Closing a query lease while its query() waited to start -- for an
abandoned query to drain, or for SERVER_INFO -- returned the session to
the pool, and the waiting query then started under the next borrower,
whose own query() failed because one was already active. Returning a
lease now rejects every query() of it that has not started with
QwpClientClosedError, including one called in the same turn as close().

QWP.md's lazy_connect row said query_pool_min defaults to 0 under it; an
explicit positive value is rejected, and the row now says so.

Each code fix has a regression test that fails without it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The QwpEgressSession pages of both packages show prepareForPoolRelease()
taking the error that rejects a returned lease's unstarted queries, the
embedded QWP.md matches the updated guide, and the search and hierarchy
indexes follow. Every other page changes only its source links and line
anchors, and no page is added or removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nd durable polls

A typed reconnect object replaced the connect string's whole reconnect
policy. Adding only an onEvent observer to a string that tunes
reconnect_* therefore discarded those budgets, and with them the sync
startup the keys select: connect() made one fail-fast attempt where
QWP.md promises a retrying one. A typed reconnect is now merged field by
field with the string's keys, on ingress and egress alike, as
storeAndForward already was, and startup is chosen from the merged
policy. reconnect: false still disables reconnecting.

A browser auth provider that threw, or returned a credential that could
never be sent, was reported as auth-failed, which the event's
documentation reserves for a server refusing the credentials. These
failures are now marked as the client's own and reported as
endpoint-attempt-failed. Like auth-failed they end the sweep, so no
all-endpoints-unreachable follows. A 401 or 403 is still auth-failed.

A browser sender with durable ACK queued one poll per keepalive tick
while an acknowledged commit awaited its durable upload. No poll could
be trimmed before that commit was durable, so the in-memory replay queue
grew for as long as the upload stalled: about 18,000 frames an hour,
each OK costing time in proportion to the backlog, all resent on
reconnect. Publishing a poll now discards the earlier polls already on
the wire. A late OK that names table transactions for a discarded poll
hands them to the newest frame still queued before it, so the durable
watermark cannot pass rows that are not durable. This applies only to
durable-tracking sessions on the in-memory queue; Node pings instead.

The fix for frames a journal recovered (811f52a) gains the regression
test it lacked: a restarted durable sender that publishes nothing new
must still poll and confirm its recovered frames. The cluster-endpoint
helper's comment no longer claims relative URLs resolve.

Each code fix has a regression test that fails without it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The QWP_RECONNECT_EVENT_KIND pages of both packages say a browser auth
provider that cannot produce a credential is reported as
endpoint-attempt-failed, and the QwpBrowserAuthProvider page and the
browser package page, which embeds its README, say the same. The
embedded QWP.md carries the field-by-field merge of typed reconnect
overrides and the auth-provider event wording, and the search index
follows. Every other page changes only its source links and line
anchors, and no page is added or removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rrect the auth retry docs

An egress query whose timeout expired during a reconnect's reset was
still replayed. The session counted its connection as restored as soon
as the reset began, before it waited for a running queryViews callback
and the transport waited for onFailoverReset. A timeout firing then
queued a CANCEL behind the replay instead of dropping it, so the
replacement received QUERY_REQUEST and then CANCEL: a statement could
run again after its caller's deadline, and the caller waited for the
server or the full cancelDrainTimeoutMs grace instead of being released
at the deadline. QWP.md and the timeoutMs TSDoc promise the query is not
replayed. The transport now tells the session when the replacement takes
over -- just before the replay is sent, or when nothing is -- and until
then a query that ends is withdrawn from the replay. The transport
checks for a withdrawal after the reset and again after onFailoverReset.
A replayed query's cancel bound now starts when the replay leaves, so an
application cancel issued during onFailoverReset is still bounded. A
query abandoned in that window is likewise dropped rather than replayed
and cancelled.

QWP.md contradicted itself on authentication rejections. One passage
said a 401 or 403 is rethrown without retrying and exempted only a
foreground store-and-forward sender and the browser; another said a
sender started with "async" retries it indefinitely once connected,
which is what the code does. The first passage now names async senders,
including every pooled sender under lazy_connect=on.

The two egress timeout tests fail without the fix. The cancel test
guards the moved cancel bound, and a new test pins the async in-memory
sender's retry that QWP.md now documents.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The QwpEgressQueryOptions pages of both packages say a query's timeout
keeps running while its onFailoverReset callback does, and that a query
whose timeout expires first is not replayed. The embedded QWP.md carries
the same egress wording and the corrected account of which senders
retry an authentication rejection, and the search and hierarchy indexes
follow. Every other page changes only its source links and line
anchors, and no page is added or removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Without a journal, flush() completes once its frames enter the in-memory
replay queue, and a background drainer hands them to the socket. close()
drained that queue -- to the socket in a fast close, to the ACK watermark
otherwise -- only after its own final flush succeeded. When that flush
failed, as when the staged batch held a row larger than the batch cap,
close() closed the session at once: frames that earlier flushes had
published but the drainer had not sent yet were discarded, and close()
reported only the over-cap batch it discarded. On a connection whose
sends complete a turn later, as with ws, two of three rows flushed just
before close() were lost this way.

close() now drains the published frames whatever became of its final
flush, within the same deadlines. It still rejects with the flush's
failure, and a drain that fails as well is logged rather than lost. Only
an expired close deadline skips the drain: there is no time left to wait,
and its timeout already reports the pending data.

Two regression tests cover a fast close on a connection whose sends
complete a turn later, and a default close during an outage. Both fail
without the change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When a newer durable-ACK poll discards an earlier one whose OK has
already arrived, discardSupersededDurableAckPolls() hands the table
transactions that OK named to the frame it covered. Nothing tested that
hand-off: the existing test delivers the poll's OK after the discard,
which keepDiscardedPollTargets() handles, and the whole suite stayed
green with the hand-off deleted.

The new test delivers the OK before the next keepalive tick. Without the
hand-off, the next table-less OK carries the durable watermark past a
frame whose commit is not yet durable, and the test fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fzstd 0.1.1, which both packages inline to decompress Zstd RESULT_BATCH
bodies, built three pieces of decoder state by viewing the same memory
through typed arrays of different widths, so each took on the host's
byte order. The extra-bit tables for literals length and match length
codes were bytes of an Int32Array, and the initial repeat offsets 1, 4
and 8 were written as bytes and read back through one. On a big-endian
host such as s390x, a client that opted into Zstd egress compression
failed to decode results ("zstd output exceeds declared content size
409 [actual=1375]"). Frames whose first sequences reuse an initial
repeat offset fared worse once the tables were right: they decoded
without an error to wrong values, so fixing only the tables would have
traded the failure for silently wrong results -- a level-19 INT column
of 42s came back with some rows reading 0. Raw egress, the default, and
little-endian hosts were unaffected.

The patch now spells both tables out as bytes and sets the repeat
offsets through the Int32Array view, in the ESM and CommonJS builds.
The tables are byte-identical to what little-endian hosts read before,
so nothing changes there.

CI runs only on little-endian hosts, so the new tests evaluate the
installed fzstd builds with their multi-byte typed arrays storing
reversed bytes, which is how a big-endian host lays them out. They
decode a hand-built frame that uses every literals length and match
length code and each initial repeat offset, plus two libzstd frames,
and compare the bytes. They fail without the change, and with any one
of its three hunks reverted. Under QEMU s390x emulation, with timeouts
raised for its speed, all 75 egress tests pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@glasstiger

Copy link
Copy Markdown
Collaborator Author

3929500 fixes Zstd egress decoding on big-endian hosts such as s390x. The vendored fzstd 0.1.1 built its literals length and match length extra-bit tables, and its initial repeat offsets 1, 4 and 8, through views that read back in the host's byte order. Results failed to decode, or with the tables alone fixed, decoded to wrong values without an error. patches/fzstd@0.1.1.patch now spells the tables out as bytes and sets the offsets through the Int32Array view.

The bug is still present on upstream master and is reported as 101arrowz/fzstd#26. Once a release fixes it, the byte-order hunks can come out of the patch; the size-check hunks are ours and stay. Until then, the "Zstd decoding on hosts of either byte order" tests in test/qwp/egress.test.ts run the installed fzstd with reversed-byte typed arrays, so an upgrade that drops the hunks too early fails on little-endian CI.

glasstiger and others added 6 commits October 12, 2026 17:25
In-memory publication completes once a frame enters the replay queue, so
a sender keeps accepting flushes during an outage until
memoryReplayMaxBytes applies backpressure. That cap charged each frame its
payload plus 64 bytes, but on Node a queued frame keeps about 800 bytes of
heap beside its payload while it waits to be sent, and about 450 once it
awaits its ACK. Flushing one row at a time through an outage, the default
128 MiB cap engaged only after 1,427,848 frames and about 1 GiB of heap,
and a process with --max-old-space-size=512 ran out of memory with 58 MiB
of the 128 counted. Before this branch a default sender's flush() blocked
during the same outage instead of growing the queue.

Each frame is now charged its payload plus 1 KiB
(QWP_MEMORY_REPLAY_RECORD_OVERHEAD_BYTES), which covers what a queued
frame keeps on Node 20, 22 and 24. The default cap now applies
backpressure at about 127,000 one-row frames and 105 MiB of heap, inside a
512 MB heap. Frames of many rows are barely affected: a 20 KB frame is
charged about 5% more than before.

The memoryReplayMaxBytes TSDoc and QWP.md say what each frame is charged.
The tests that hard-coded the old allowance compute their capacities from
the constant, and a new test pins the allowance and the backpressure it
gives an offline backlog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hooks

The QwpEgressSession class comment ended up above the constructor token
when the token, the query-start helpers and the replay hooks were added
between it and the class. The emitted declarations and the API reference
therefore documented a private constant and left the query session class
without a description. The comment is back above the class.

expire() and expireGrace() are the query timer's hooks into the session.
They were untagged, so the API reference listed them beside query() and
cancel(), and calling expireGrace() on a running query ended it with
QwpEgressQueryTimeoutError. Both are now @internal and @hidden, as the
token-guarded constructors are: the reference still lists an @internal
member, with an Internal badge, and only @hidden leaves it out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The QwpEgressSession pages of both packages carry the class description
again and no longer list expire() and expireGrace(). The
QwpNodeIngressOptions and QwpBrowserIngressOptions pages say that each
queued frame counts its payload plus 1 KiB toward memoryReplayMaxBytes,
the embedded QWP.md matches the updated guide, and the search and
hierarchy indexes follow. Every other page changes only its source links,
line anchors and reflection IDs, and no page is added or removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A browser sender or query session given auth and failover endpoints
stops its sweep at an endpoint that echoes the credential subprotocol,
so the endpoints after it are never tried. reconnect.onEvent still
reported all-endpoints-unreachable for that sweep, naming the endpoint
that echoed, although the event means that every endpoint was tried and
none accepted the connection. Only credential failures were spared it.

Any QwpUpgradeError that tells the sweep not to try the next endpoint is
now reported like a credential failure: endpoint-attempt-failed for the
endpoint that returned it, once, and no all-endpoints-unreachable. The
error the caller and onError receive is unchanged.

Regression tests cover an ingress factory that walks endpoints and one
that does not, an egress factory, and the browser credential echo on
both sides. They fail without the change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…room

A browser sender with requestDurableAck asks for durable progress with
table-less poll frames, which went through the publication queue like
data. When the in-memory replay queue filled with frames awaiting their
durable upload, the next publication waited for room that only durable
progress could make, and QuestDB reports durable progress only in reply
to inbound traffic. Every keepalive poll queued behind that publication,
so none reached the server: the publication waited out its whole append
deadline, 30 seconds by default, and failed with
QwpMemoryReplayAppendTimeoutError against a server with progress to
report. Only then did a poll go out and the queue drain. Before this
branch the stalled poll also timed out and ended the session.

While a publication waits for replay-queue capacity, the session now has
the connection send the poll straight to the socket, as Node sends a
WebSocket PING. The poll takes no frame or client sequence -- the
waiting publication has already claimed the next frame sequence -- and
is not replayed after a reconnect. It does take a wire sequence, and its
OK is handled as a discarded poll's, whose durable targets pass to the
last frame published before it. QuestDB rejecting such a poll blames no
frame: a retriable status recycles the connection without a poison
strike, and any other ends the session with an error naming the poll. A
poll that finds frames still waiting for the socket is skipped as
before, and nothing changes while no publication waits.

Three regression tests cover the poll that clears the wait, a retriable
rejection under a one-strike poison limit, and a terminal rejection.
They fail without the change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Regenerated with pnpm run docs after the sweep-event and durable-ACK
poll fixes. Every page changes only its source links and line anchors;
no page's content changes, and no page is added or removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@glasstiger

glasstiger commented Oct 13, 2026 •

Copy link
Copy Markdown
Collaborator Author

Review: level 3 (base 87cd6273 → head 84ebac47)

Verdict: approve. I found no Critical or Moderate defects. The one issue I could prove is older than this PR and didn't block it; it is now fixed on this branch in d6cafe9a.

PR title and description

  • The title follows Conventional Commits, and the description states Fixes #70.
  • No QWP change can break released users: npm latest is 4.2.0 and contains no QWP code, and @questdb/browser-client is unpublished.

Critical / Moderate

None.

Adjacent finding (pre-existing, not blocking) — fixed in d6cafe9a

After a query times out during an outage, a for await loop can stay blocked for up to 30 s.

  • Who is affected: query sessions with initialCredit > 0 (per query, session option, or the initial_credit connect-string key).
  • Location:
    • packages/client-core/src/_qwp/egress-session.ts:492-494: next() awaits releaseDeliveredCredit().
    • egress-session.ts:922-933: that waits for a CREDIT send, which waits for the reconnect in reconnecting-egress-connection.ts (sendPayload → requireConnection()).
  • What happens:
    1. The consumer has taken one batch, then the connection drops.
    2. next() is called during the outage.
    3. The query's timeout expires before a replacement connects.
    4. completion rejects with QwpEgressQueryTimeoutError at the deadline. next() stays pending until the reconnect ends: +30 000 ms in the probe, which is the failoverMaxDurationMs default.
  • Evidence: a vitest probe (fake connections, fake timers, timeoutMs: 25, initialCredit: 1 << 20, replacement connection held back) gives identical results at base and head, so this PR didn't cause it. But this PR's new wording promises the opposite: QWP.md:1503-1504 and 1512-1514, and the timeoutMs doc comment at egress-session.ts:142-147, say the timeout "is reported at once" when the connection is lost.
  • Fix (d6cafe9a): next() now stops waiting for the CREDIT send as soon as the query ends, so the blocked iterator rejects with QwpEgressQueryTimeoutError at the deadline. A query that is still running waits for its CREDIT send as before, and the CREDIT frame itself is unchanged. The regression test "releases an iterator returning credit when the timeout expires during the outage" (test/qwp/reconnect.test.ts) fails without the change. The full vitest suite, eslint, format:check, typecheck, typecheck:test and typecheck:qwp-browser pass with it.
  • Standalone severity: Moderate.

Test coverage

The test gate passes, with no coverage gaps worth reporting. The waitForAck() fix for #70 is genuinely tested: "wakes a sender ACK waiter for a frame replayed from a Node journal" fails when the fix is reverted.

Validation at head 84ebac47

  • Gates: eslint, format:check, typecheck, typecheck:test, typecheck:qwp-browser, typecheck:bench, lint:bench, test:dist (39 tests), check:packages and typecheck:dist all pass.
  • Vitest: 1,261 tests pass, excluding the testcontainers integration tests. The single failure was setup-only: the questdb-client-test submodule was not checked out in the scratch tree.
  • Not run: test:qwp-browser.

Deliberate trade-offs (documented in the PR; not findings)

  • Poison-frame window: the default dropped from 5 minutes to 5 seconds. A server write stall longer than about 6 s now ends an in-memory sender. This matches the 5000 ms default in the Python client's docs, and poison_min_escalation_window_millis restores the old tolerance.
  • flush() without a journal: it now resolves once frames enter the in-memory replay queue, not when they reach the socket. Callers need close() or flushAndWait() before the process exits.
  • Typed storeAndForward defaults: these changed to memory durability, wait backpressure and 10 GiB.
  • Journal location: journals now live in <dir>/<senderId|default>. A typed sender's earlier journal in <dir> itself only triggers a warning.
  • lazy_connect: Sender.fromConfig() now ignores it.
  • Ingress reconnect: after the first connection it never gives up.

Submodules: none changed.

Generated by an automated review (Claude Code).

With flow control on (initialCredit > 0), a query iterator's next() first
returns the credit of the batch it consumed, and it awaited that CREDIT
send. During an outage the send waits for the replacement connection, so
when the query's timeout expired meanwhile, completion rejected with
QwpEgressQueryTimeoutError at the deadline while the iterator stayed
blocked until the reconnect settled: up to failoverMaxDurationMs, 30
seconds by default, later. QWP.md and the timeoutMs TSDoc say the timeout
is reported at once when the connection is lost. The behaviour predates
this branch.

next() now races the grant against the query's end, so a query that ends
while its CREDIT waits releases its consumer with the outcome at once. A
grant for a query that is still running is awaited as before, and the
CREDIT frame itself is unchanged.

The regression test fails without the change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@glasstiger

Copy link
Copy Markdown
Collaborator Author

Review: level 3 (base 87cd6273 → head d6cafe9a)

Verdict: approve. I found no Critical, Moderate or Minor defects.

PR title and description

  • The title follows Conventional Commits and carries no !, which is correct. npm latest (4.2.0) contains no QWP code (checked by unpacking its tarball), and @questdb/browser-client is unpublished. The only change to the released ILP API is the new Sender.flushAndWait(), which only adds a method.
  • The description states Fixes #70, explains the user impact, and lists every option, default and export change. The new query_timeout_ms key is parsed in client-config.ts and documented in QWP.md, and test/qwp/public-api-contract.ts reflects the narrower public surface.

Critical / Moderate / Minor

None.

Validation at d6cafe9a

Check Result
vitest (all files except the Docker-only test/sender.integration.test.ts) 1,263 / 1,263 pass
eslint, format:check, typecheck, typecheck:test, typecheck:qwp-browser, typecheck:bench, lint:bench pass
test:dist (39 tests), check:packages, typecheck:dist pass
test:qwp-browser (10 real Chromium tests) pass
Regression test of d6cafe9a fails with the production change reverted, passes at head

I also mutated these new safety checks one at a time, and an existing test failed for each: ACK-watermark publication, the close drain after a failed final flush, withdrawal of a query's replay after failover, the single CANCEL per query, the lost-connection terminal check, final upgrade verdicts, and admission of durable-ACK polls above the replay-queue cap.

Test coverage

The test gate passes, with no coverage gaps worth reporting.

Deliberate trade-offs (documented in the PR; not findings)

  • The poison escalation window defaults to 5 seconds.
  • Without a journal, flush() completes once frames enter the in-memory replay queue.
  • Typed store-and-forward defaults are memory durability, wait backpressure and 10 GiB.
  • Journals live in <dir>/<senderId|default>.
  • Ingress never stops reconnecting after its first connection.
  • query() waits for a released query that is still draining instead of throwing.
  • Sender.fromConfig() warns about and ignores lazy_connect, target and zone.

Submodules: none changed. Findings: 0 Critical, 0 Moderate, 0 Minor.

Generated by an automated review (Claude Code).

@glasstiger

Copy link
Copy Markdown
Collaborator Author

Review: level 3 (base 87cd6273 → head d6cafe9a)

Verdict: approve. I found no Critical, Moderate or Minor defects, and the test gate passes.

PR title and description

  • The title follows Conventional Commits, and the description says Fixes #70. It explains the user impact and lists every option, default, export and behaviour change.
  • No released user is broken. The claim that QWP never shipped holds:
    • npm latest is 4.2.0, and its tarball contains no QWP name at all (no Qwp*, waitForAck, ackedSequence, flushAndGetSequence or acknowledgedSequence).
    • @questdb/browser-client returns 404 on npm.
    • So every QWP rename and removal only affects unreleased code on main. The released ILP Sender only gains flushAndWait().
  • The new query_timeout_ms key is parsed in client-config.ts, validated, documented in QWP.md and tested. test/qwp/public-api-contract.ts and the type contracts match the narrower public surface.

Critical / Moderate

None.

Test coverage

The test gate passes, with no coverage gaps worth reporting. Each fix I checked has a test that targets it, for example:

  • the waitForAcknowledged() never resolves for frames replayed from the store-and-forward journal #70 replay-ACK wake-up;
  • the recovered commit boundary;
  • a durable trim whose tail retirement fails;
  • the hand-off of durable-ACK poll targets;
  • direct polls sent while a publication waits for queue space;
  • the close drain after a failed final flush;
  • egress replay withdrawal and the iterator released at its credit deadline;
  • returned query leases;
  • scheme-less redaction;
  • per-side agent plus TLS-key rejection;
  • the big-endian Zstd patch;
  • slot layout and its warnings;
  • lazy pools keeping a RAM sender until its frames are acknowledged.

Checks run at d6cafe9a (clean export of the head commit)

Check Result
eslint, format:check, lint:bench, typecheck, typecheck:test, typecheck:qwp-browser, typecheck:bench pass
vitest (all files except the Docker-only test/sender.integration.test.ts) 1,263 / 1,263 pass
test:dist, check:packages, typecheck:dist pass (39 dist tests)

Not run: test:qwp-browser (real Chromium) and the Docker integration tests.

Deliberate trade-offs (documented in the PR; not findings)

  • Poison window: the default dropped from 5 minutes to 5 seconds. A run of retriable rejections lasting about 6 s now ends the sender.
  • flush() without a journal: it now resolves when the frames enter the in-memory queue. Call close() or flushAndWait() before the process exits.
  • Typed store-and-forward defaults: they now match the sf_* keys: memory durability, wait backpressure, 10 GiB. A journal no longer survives power loss unless durability: "append" is set.
  • Journal location: <dir>/<senderId|default>, and default-N for pooled senders.
  • Ingress reconnect never gives up after the first connection.
  • query() waits for a query that is still draining instead of throwing.
  • Sender.fromConfig() warns about and ignores lazy_connect, target and zone.
  • Pooled sender teardown logs a warning on failure instead of rejecting.

Summary

  • Findings: 0 Critical, 0 Moderate, 0 Minor (none in the diff, none in unchanged callers).
  • Submodules: none changed.
  • Not checked: crash and lock probes against a real journal directory (full journal, a lock held by a second process, a torn segment, a kill between append and checkpoint) were not run. This PR changes only the defaults on those paths, not the logic.

Generated by an automated review (Claude Code).

@bluestreak01 bluestreak01 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving at d6cafe9a. Level-3 review: no Critical or Moderate issues.

Minor (optional): packages/browser-client/README.md:138-139 says "the default "off" makes a single attempt" without mentioning that setting reconnect.reconnectMaxDurationMs, reconnectInitialBackoffMs or reconnectMaxBackoffMs without an explicit initialConnectMode makes the default "sync". A probe through connectQwpBrowserSender confirmed this: 1 attempt with defaults, and 36 attempts then QwpReconnectExhaustedError with a 400 ms reconnect budget. QWP.md:397, README.md:256 and the TSDoc already state the rule.

Validated at d6cafe9a:

  • vitest: 1,262 of 1,263 pass. The one failure is setup only (the questdb-client-test interop JSON is absent), and the Docker integration test was excluded.
  • eslint, format:check, typecheck, typecheck:test, typecheck:qwp-browser, typecheck:bench, lint:bench, test:dist, check:packages and typecheck:dist all pass.
  • The #70 regression test and three sibling recovered-frame tests fail with the unforwarded-ACK wake-up stubbed out.
  • Three more new conditions each fail their targeted test when mutated: the reaper keeping RAM senders that still hold frames, the close drain after a failed final flush, and the merge of a superseded poll's durable targets.

@bluestreak01
bluestreak01 merged commit 26dcaa7 into main Oct 14, 2026
7 checks passed
@bluestreak01
bluestreak01 deleted the ia_qwp_fixes branch October 14, 2026 13:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

waitForAcknowledged() never resolves for frames replayed from the store-and-forward journal

2 participants