Skip to content

feat(qwp): connect timeout, ingest callbacks, and lazy_connect (tolerant startup) on the QuestDB facade#60

Merged
bluestreak01 merged 112 commits into
mainfrom
feat/connect-timeout
Jul 6, 2026
Merged

feat(qwp): connect timeout, ingest callbacks, and lazy_connect (tolerant startup) on the QuestDB facade#60
bluestreak01 merged 112 commits into
mainfrom
feat/connect-timeout

Conversation

@bluestreak01

@bluestreak01 bluestreak01 commented Jun 28, 2026

Copy link
Copy Markdown
Member

Tandem PRs:

Summary

Related ergonomics/resilience improvements for the QWP (WebSocket) client:

  1. Application-level TCP connect timeout (transport-wide) — bound the connect itself instead of riding the OS-level timeout.
  2. Expose the ingest callbacks on the QuestDB facadeerrorHandler / connectionListener, previously unreachable from the pooled facade.
  3. Tolerant startup via lazy_connect=true — start the handle even when the server is down, buffer writes meanwhile, and read once it's up. Reads stay fully enabled.
  4. Single cluster config on the facade — one ws/wss string (a single addr server list) configures the whole cluster, driving both the ingest and query pools.
  5. Pooled-lease refactor + correctness fixes — reads and writes share one pooled-lease model (borrowQuery() mirrors borrowSender()), and a per-borrow generation closes a family of pool-corruption bugs (double-close, use-after-close, lost dispatch, stale cancel). This is internal hardening — see §5.
  6. Invariant-B rework of the SF reconnect model — a store-and-forward sender never terminates on a connection error: async initial connect and mid-stream reconnects retry indefinitely; only genuine terminals (auth / non-421 upgrade / durable-ack gap) or SF exhaustion surface. Breaking: removes SenderConnectionEvent.Kind.RECONNECT_BUDGET_EXHAUSTED and narrows reconnect_max_duration_millis to the blocking SYNC initial connect — both shipped in 1.3.4. See §6.
  7. NACK policy v2 — no drop, no lists, no dead senders — replaces the two-policy server-error model (DROP_AND_CONTINUE / HALT) with retriable/terminal classification that never silently loses data; a behavioral poison-frame detector (configurable via max_frame_rejections + a minimum escalation dwell poison_min_escalation_window_millis, and paced so a transient close never false-positives) replaces close-code lists. Breaking: SenderError.Policy renames; ackedFsn advances only on server OKs. See §7.
  8. Review-milestone hardening (M8–M11) — drainer observability on the facade/builder, role-reject stream split out of durable-ack episodes, foreground-priority then lock-free connect walks, fast close() during outages, and a coverage-gap burn-down. See §8.

Items 1–3 are independently usable and off by default.


1. Configurable TCP connect timeout

A connect to a black-holed/firewalled host blocks on the OS-level TCP connect timeout (60–120s): the socket is created blocking, connect() runs, then it's switched to non-blocking. The code calls this out:

// SenderPool.java
// connect to a black-holed/firewalled host blocks on the OS connect timeout
// (the transport exposes no application-level connect timeout to clamp it).

Approach (native, cross-platform): non-blocking connect() (EINPROGRESS) → poll/select for writability bounded by the caller's budget → confirm via getsockopt(SO_ERROR). A sentinel (CONNECT_TIMEOUT = -3) lets Java raise a timeout-flagged exception. Generalises the existing handleEintrInConnect helper.

Sender.builder("https::addr=host:9000;connect_timeout=5000;")...   // or .connectTimeoutMillis(5000)
QwpQueryClient.fromConfig("ws::addr=host:9000;connect_timeout=5000;");

Touches: native share/net.c + windows/net.c + net.h; Net / NetworkFacade(Impl); HttpClientConfiguration.getConnectTimeout(); HttpClient.connect() / WebSocketClient.doConnect(); ConfigSchema COMMON key connect_timeout; Sender builder + both parsers; QwpWebSocketSender / QwpQueryClient (withConnectTimeout). Bounds the TCP connect and the TLS handshake (see below); the WebSocket upgrade stays under the request/auth timeout (auth_timeout_ms).

1b. Bound the TLS handshake (and stop it busy-spinning)

JavaTlsClientSocket.startTlsSession ran the TLS handshake with raw recv/send on the now-non-blocking socket and never waited on readiness. A peer that completed the TCP connect but stalled before its half of the handshake left the engine in NEED_UNWRAP with recv returning 0 (would-block), so the loop re-read in a tight cycle: a 100% CPU busy-spin with no deadline. connect_timeout bounded only the TCP connect, and the upgrade's request/auth timeout never covered the handshake, so a stalled wss:// host (e.g. QuestDB Cloud) could pin a core indefinitely and defeat the bounded connect. The busy-spin pre-dates this PR (it predates the connect_timeout work), but the same change closes it.

The handshake now runs through the client's existing deadline-aware ioWait (the same primitive recvOrDie/doSend use): when the socket would block it parks on epoll/kqueue/select for the remaining connect budget and throws a timeout-flagged exception once it is spent. doConnect registers the fd with the event loop before the handshake and bounds it by connect_timeout, falling back to the request timeout when connect_timeout is unset — so the handshake can no longer hang or spin even with the default config. Both HttpClient (https) and WebSocketClient (wss) share the fix, and the connect TLS block disconnects on any handshake error so the fd/native buffers do not leak.

Tradeoff: with connect_timeout unset, a wss:// handshake that stalls now fails after the request timeout instead of spinning/hanging forever. That is strictly better, but it is a behavior change for that edge.

Touches: JavaTlsClientSocket (handshake extracted into runHandshake), new SocketReadinessWaiter, Socket/PlainSocket, HttpClient.connect() / WebSocketClient.doConnect(). Tests: JavaTlsClientSocketTest.testHandshakeWaitsForReadabilityInsteadOfBusySpinning (a stalled peer must yield to the readiness waiter, not spin; a method-level timeout fails the test if the spin returns) and testHandshakeCompletesWithoutWaitingWhenEngineMakesProgress (happy path).


2. Ingest callbacks on the QuestDB facade

The facade built ingest senders from config strings only (SenderPool → Sender.fromConfig), so the programmatic SenderErrorHandler / SenderConnectionListener were unreachable — a facade user got the default loud-not-silent handlers with no way to observe async ingest errors or connection transitions.

QuestDB.builder()
    .fromConfig("ws::addr=host:9000;")
    .errorHandler(myErrorHandler)
    .connectionListener(myConnectionListener)
    .build();

QuestDBImpl / SenderPool each gain a full constructor carrying the callbacks; the white-box test-seam constructors are preserved as delegating shims. SenderPool.applyUserCallbacks() applies them to every pooled sender (non-SF and SF paths); internal recovery delegates are excluded. Defaults null.


3. Tolerant startup: lazy_connect=true

The facade prewarms a reader (QueryClientPool) synchronously and fail-fast (default query_pool_min=1; queries have no async connect), so a down server failed the whole build. The fix is a single connect-string flag that makes the handle tolerate a down server without giving up reads — "starts when the server is down" and "never reads" are different things, and you almost always want the first.

lazy_connect=true:

  • a) starts even when the server is down — the ingest side goes async and the read pool defaults to query_pool_min=0, so neither side fail-fasts and build() returns promptly;
  • b) buffers writes while down — the async sender accepts rows that flush once the wire is up;
  • c) reads once the server is up — the read pool stays enabled (it is not disabled the way a write-only mode would). It defaults to query_pool_min=0, so nothing connects eagerly and borrowQuery() connects lazily on first use. While the server is still down a borrowQuery() throws (it has nowhere to connect — exactly like any read against a down server); once the server is up it connects and reads. The point is that lazy_connect defers the read connect rather than refusing reads.
// starts with no server present; writes buffer, reads work once it's up
try (QuestDB db = QuestDB.connect("ws::addr=host:9000;lazy_connect=true;")) {
    try (Sender s = db.borrowSender()) {
        s.table("t").longColumn("v", 1).atNow();    // buffers while the server is down
    }
    // ... later, once the server is up:
    try (Query q = db.borrowQuery()) {               // read pool connects lazily on first borrow
        q.sql("select 1").handler(handler).submit().await();
    }
}

Because both sides must start non-blocking, a knob that forces a blocking / fail-fast startup is a configuration conflict, rejected up front with a clear remedy rather than silently overridden:

  • initial_connect_retry other than async (i.e. off/false/on/true/sync), and
  • an explicit query_pool_min > 0 (connect string or builder call).

lazy_connect is a Side.POOL registry key — the two ws clients ignore it; the facade reads it, defaults query_pool_min to 0, and injects initial_connect_retry=async when the user set none.


4. Single cluster config on the facade

A QuestDB cluster is one logical target reached over QWP for both ingest and query, so the facade takes one cluster config: a single ws/wss string that lists every node in one addr server list and drives both the sender and query pools.

QuestDB.builder()
    .fromConfig("ws::addr=node1:9000,node2:9000,node3:9000;")  // whole cluster
    .errorHandler(myErrorHandler)
    .connectionListener(myConnectionListener)
    .build();

build() validates the one string with both the ingest (validateWsConfigString) and egress (QwpQueryClient.validateConfig) validators; each side applies the keys it owns and ignores the rest. Pool keys are read from a single ConfigView, and QuestDBImpl passes the one config to both pool slots (preserving the white-box reflection seam).


5. Pooled-handle lifecycle: lease/generation refactor + correctness fixes

This is the bulk of the diff and it is internal (no user-facing API beyond the borrow methods). It reshapes the facade so reads and writes share one pooled-lease model and fixes a family of pool-corruption bugs that the previous thread-affine / flag-guarded design allowed.

Refactor — symmetric pooled lease (1245c17). Reads and writes now use one model:

  • Ingest: remove QuestDB.sender() / releaseSender() and the entire thread-pin subsystem behind them (pinToCurrentThread, releaseCurrentThread, clearPinIfCurrent, the threadAffine ThreadLocal, the PooledSender invalidated flag). borrowSender() is now the only way to lease a Sender.
  • Egress: remove query(), newQuery(), and executeSql(); add QuestDB.borrowQuery(), a closeable, non-allocating Query lease that mirrors borrowSender(). Each pooled QueryWorker owns one pre-allocated QueryImpl, handed out reset on borrow; submit() dispatches on the held worker (single-flight) and close() returns it to the pool. Reads now connect at borrow time, so under lazy_connect the read pool defaults to query_pool_min=0 and build() does not fail-fast while the server is down. (This is why §3's read example uses borrowQuery() and not the removed executeSql.)

Correctness fixes carried on top of the refactor:

  • f39e846 (M1) — stale pooled handle can corrupt the pool under double-close / use-after-close. The pooled handle was the reused per-slot object guarded by a non-volatile in-use flag, so a stale handle's close()/cancel()/write could leak into a different borrow (double-release → two concurrent borrowers on one non-thread-safe client). Fix: every borrow gets its own immutable generation, stamped under the pool lock and re-checked on every op — close()/cancel() no-op on a stale generation (idempotency preserved), submit()/writes throw, and release re-checks the generation under the lock so a slot can never be enqueued twice. Introduces QueryLease (wrapping QueryImpl) and SenderSlot (with PooledSender as the per-borrow wrapper).
  • e30a59c — lost query-worker dispatch under single-flight reuse. runLoop() cleared current outside signalLock, so a fast submit() → await() → submit() loop could clobber a freshly dispatched job and discard its signal, hanging the caller forever. Fix: clear current under signalLock at the moment of consumption. (Surfaced as a 60s hang in testSustainedMixedConcurrency, mostly on aarch64 CI; 15/15 clean with the fix.)
  • bcb1e7a — watchdog cancel must re-check the lease generation under the pool lock. cancel(gen) validated the generation with an unlocked volatile read then issued the wire cancel separately — a TOCTOU where a watchdog could abort a new borrower's query with a spurious STATUS_CANCELLED. Fix: route through QueryClientPool.cancelIfCurrent(worker, gen), which re-checks and cancels under the same lock the generation is bumped under; close()'s abort-on-unawaited-close uses the same path.
  • f64567e — assert the lease wraps the same pooled QueryImpl. Pins the invariant that the freshly allocated per-borrow wrapper delegates back to the one pre-allocated, reused QueryImpl (so a regression that allocated per borrow would be caught).

6. Invariant B: store-and-forward senders never terminate on a connection error

This is the riskiest part of the diff and a behavior change for existing QWP senders. Previously connectLoop (async initial connect + every mid-stream reconnect on the I/O thread) enforced reconnect_max_duration_millis as a give-up deadline: on expiry it latched a terminal ...-budget-exhausted error that the next producer call surfaced. A long-but-recoverable outage (failover window, all-endpoints-replica, rolling upgrade) became a permanent producer-visible failure even though the rows were safe in SF.

Invariant B — once rows are accepted into store-and-forward, the background loop must never give up on a wall-clock budget (6be20d6, e9a1e7d):

  • Async initial connect and mid-stream reconnect retry forever with capped exponential backoff + jitter. The only terminals are genuine ones — auth (401/403), non-421 upgrade reject, durable-ack capability gap — plus SF exhaustion, which surfaces to the producer as append backpressure (sf_append_deadline_millis, default 30s stall, then throws).
  • SYNC initial connect (initial_connect_retry=on/sync) is unchanged: it still blocks up to reconnect_max_duration_millis on the user thread and fails loud.
  • Outages stay loud: connectLoop WARNs on entry and per attempt (throttled to 5s), and the default connection listener still fires ENDPOINT_ATTEMPT_FAILED (INFO) per endpoint and ALL_ENDPOINTS_UNREACHABLE (WARN) per failed round — an outage is visible in logs, it just no longer kills the sender.

Breaking API change (e9a1e7d): SenderConnectionEvent.Kind.RECONNECT_BUDGET_EXHAUSTED is removed — under Invariant B it can never fire. It shipped in 1.3.4, so connection listeners that reference it (e.g. exhaustive switches) break at compile time. reconnect_max_duration_millis now bounds only the blocking SYNC initial connect; for async/mid-stream it is intentionally not consulted.

Drainer semantics aligned with the invariant:

  • Orphan/background drainers no longer quarantine a down server (f9ece1a) or an all-replica window (d0a794a); both retry with capped backoff. An all-replica window is transient for durable-ack senders too (d920b9c).
  • A mid-drain durable-ack capability gap re-enters the bounded settle-retry instead of quarantining on the first sweep (5b51236); transport windows between gap sweeps pause the settle wall clock instead of burning it (0f9857e, 4200697).
  • Foreground role-reject retry backs off exponentially instead of storming fresh TLS handshakes at a fixed interval (70c706a).
  • Background drainer connects no longer commit foreground sender state or fire foreground connection events (b990535); orphan drainers gate connects on their own lifecycle, not the foreground sender's (766832d).

Lifecycle/teardown hardening shaken out by the rework (retry-forever keeps the I/O thread alive in windows the old code never had):

  • C5 (SEGV): never free the engine or client under a live I/O thread — failed-stop teardown protocol (4340371); the I/O thread disposes its own client on exit, and a connect racing close() discards the fresh client instead of installing it (2e0719d).
  • C4 residuals: finite default connect deadline for background drainers; CAS-guarded WebSocketClient.close() (0e5125f).

Each behavior change landed red-first (a8fea31, 0d65c3a, 85dd6e8, ad25cdf, f6dfb32, c7419f4).


7. NACK policy v2 — no drop, no lists, no dead senders

Invariant B (§6) covers connection errors; NACK policy v2 (8302335) applies the same philosophy to server error ACKs. The old model had exactly two policies — DROP_AND_CONTINUE (silently lose the frame, advance the watermark) and HALT (kill the sender) — so a transient server-side condition either lost data or killed the producer. The new classification (design doc: design/qwp-nack-policy-v2.md):

  • RETRIABLE (WRITE_ERROR, INTERNAL_ERROR, UNKNOWN fail-open): recycle the connection and replay from ackedFsn+1 through the existing wire-failure reconnect machinery. No watermark advance, nothing dropped.
  • RETRIABLE_OTHER (NOT_WRITABLE, reserved wire byte 0x0C): same, biased to endpoint rotation. Read-only/demotion today arrives as a role-change NORMAL_CLOSURE close (see the OSS PR), so the byte is reserved for future servers.
  • TERMINAL (SCHEMA_MISMATCH, PARSE_ERROR, SECURITY_ERROR on a writable node, PROTOCOL_VIOLATION): deterministic under byte-identical replay; latch loudly, bytes preserved in the SF log.

WS close codes carry no policy semantics anymore: every close is reconnect-eligible. A frame that deterministically kills the connection is caught behaviorally by the poison-frame detector — consecutive rejections of the same head-of-line FSN with no ack progress escalate to a typed PROTOCOL_VIOLATION terminal — instead of a close-code list.

The detector threshold is configurable (4278296): max_frame_rejections=N (>= 1, default 4) on the connect string (registered in ConfigSchema; the query client accepts it as a syntactic no-op — one vocabulary, side-owned application), LineSenderBuilder.maxFrameRejections(int), and through the QuestDB facade to every pooled sender. The threshold is forwarded to the cursor send loop and every BackgroundDrainer (drainers replay the owner's SF data, so they honor the same threshold).

Two refinements keep the detector true to its contract — never false-positive on an outage (a genuine outage fails at connect, not deterministically on one FSN):

  • Below-threshold non-orderly-close recycles are paced, matching the NACK path. A middlebox/LB that completes the WS upgrade, accepts the head frame, then non-orderly-closes (e.g. 1006/1011) while its backend is briefly down succeeds at connect every cycle, so connectLoop's failed-connect backoff never engages. An unpaced recycle would burn max_frame_rejections strikes at connect+send+close RTT rate — well under a second — and latch a PROTOCOL_VIOLATION on a recoverable outage. The below-threshold close recycle now routes through the same strike-escalated failPaced the NACK path uses (previously it used the unpaced fail), so replay gaps widen and a transient gets a reset chance; genuine transport closes (orderly, or before any send) still reconnect immediately.
  • Minimum escalation dwell (poison_min_escalation_window_millis, default 5000): even once the strike count is reached, escalation holds until the suspect has stayed poisoned for the window — decoupling "how many strikes prove determinism" from "how long a transient is allowed to look poisoned." With paced recycles strikes can accrue in well under a second, so the count alone could turn a brief outage into a producer-fatal terminal; the dwell guarantees an OK at/beyond the suspect a chance to reset the detector first. 0 = legacy immediate escalation at the threshold. Configurable on the connect string (registered in ConfigSchema; query-client no-op like max_frame_rejections), LineSenderBuilder.poisonMinEscalationWindowMillis(long), and forwarded to the cursor send loop and every BackgroundDrainer.

Breaking (pre-release surface): SenderError.Policy constants renamed; ackedFsn now advances only on server OKs (a NACKed frame no longer advances the watermark).


8. Review-milestone hardening (M8–M11)

Follow-ups from the milestone review of §6, mostly around observability and close/reconnect latency under outage:

  • M10 — drainer observability (370e187, 514e789): role-reject notifications split out of onDurableAckUnavailable (a role reject is not a durable-ack capability episode), and BackgroundDrainerListener is now settable from the QuestDB facade and the Sender builder instead of only the internal pool.
  • M11 — connect-walk concurrency (720d05f, then 9b33ae6): buildAndConnect no longer holds the sender monitor across network I/O. First pass: a dedicated foreground-priority lock (drainers tryLock and defer on contention). Final form: background walks are lock-free via walker-private round cursors on QwpHostHealthTracker (claim-at-pick, health-only ledger records), so concurrent drainer sweeps run in parallel with each other and the foreground, and can neither consume nor poison the foreground's round. close() during an outage also got a split stop policy: connect-phase drainers (never drained a row) stop within ~50ms; only actively-draining drainers get the graceful window — cutting outage-time close() from ~3s to one park chunk.
  • Error containment (1582e3a): a half-built WebSocket client is closed (fd + native buffers) when an Error — not just an exception — escapes connect/upgrade.
  • M8 — coverage-gap burn-down (2986827): mutant-verified pins plus a down-then-up outage-recovery e2e.
  • M4–M7 residuals: stale Invariant-B docs fixed, assertMemoryLeak strict per-tag equality (single-tag leaks used to pass), native scratch-sink leak on failed engine construction fixed.

9. Deferred-commit ack integrity: never trim what the server can still roll back

Tandem with the server-side fix in questdb#7341: the server now withholds cumulative OK acks for FLAG_DEFER_COMMIT frames until their group-closing commit lands — an OK ack was letting the SF client trim slots whose rows the server rolls back on any error/demote/disconnect (silent data loss; the inverse of the deferred-commit feature's own replay contract). Client-side consequences land here:

  • Close drains to the commit boundary. QwpWebSocketSender tracks lastCommitBoundaryFsn (updated at all four publish sites, residual seals included); drainOnClose targets min(publishedFsn, boundary) instead of publishedFsn — waiting for acks of an uncommitted deferred tail could only ever time out (was a 300s hang on abandoned transactions). Abandoning frames on close is WARN-logged with actionable guidance.
  • Orphaned deferred tails abort instead of resurrecting. A producer killed mid-transaction leaves deferred frames with no covering commit frame at the top of its recovered SF log; replaying them would let the NEW session's next commit resurrect a partial transaction. Recovery locates the last commit-bearing frame (recoveredCommitBoundaryFsn); the send loop never transmits the tail and retires it with a cumulative self-acknowledge once everything below is server-acked (tryRetireOrphanTail). Fast path (everything below already acked — the common crash profile): retire at start(), zero wire cost. Slow path: replay below the tail first, then a single reconnect re-anchors the linear wireSeq↔FSN mapping past the gap. Crash-safe and idempotent; the BackgroundDrainer builds through the same constructor and inherits the containment.
  • Positive identification only. The recovery walk classifies a frame as deferred only when it parses as a QWP message (min length + QWP1 magic before reading the flags byte). Anything unidentifiable is a retirement barrier, never trimmable — misclassifying an unknown frame as deferred would silently discard data that should replay.

Tests: CursorWebSocketSendLoopOrphanTailTest (recovery detection both ways, zero-send fast path, slow path end-to-end incl. the single recycle and ack attribution across the FSN gap).

Testing

  • NetConnectTimeoutTest — loopback success, refused-vs-timeout disambiguation, black-hole timeout within budget.
  • QuestDBFacadeCallbacksTest — facade-wired errorHandler receives the async auth-terminal SenderError from a 401-rejecting server (under Invariant B a plain connection error retries forever and never surfaces — only a genuine terminal like auth does); connectionListener observes connection events (no server needed).
  • Invariant-B suite — InitialConnectAsyncTest (dead port: retries forever, no terminal; wasEverConnected() contract), ReconnectTest.testReconnectNeverGivesUpInvariantB, CloseSafetyNetTest / CloseOwnershipRaceTest (close() rethrows an unsurfaced genuine 401 terminal), orphan-drainer guards (down server, all-replica window, settle-clock pauses), and the C5 teardown races (interrupted close/stop under a live I/O thread).
  • QuestDBLazyConnectTestlazy_connect=true starts + buffers a write with the server down, keeps the read pool enabled (borrowQuery() deferred to first use, not disabled), and rejects both conflicts (blocking initial_connect_retry, explicit query_pool_min > 0) from the connect string and from builder calls; QuestDBServerRecoveryTest dogfoods lazy_connect=true for the full down → write → up → read lifecycle.
  • QueryLeaseGenerationTest / SenderLeaseGenerationTest — the per-borrow generation guard: double-release, cross-borrow cancel/write, and the concurrent stale-cancel-after-reborrow TOCTOU (testConcurrentCancelDoesNotReachClientAfterReborrow).
  • CursorWebSocketSendLoopPoisonFrameTest — poison-frame detector: OK-level strike keying under durable-ack replay re-OKs, terminal FSN attribution, NACK-recycle pacing, non-orderly-close-recycle pacing against an accepting-then-closing middlebox (unpaced code recorded ~200k recycles/1.2s; paced ≤10), and the minimum-escalation-dwell gate (strikes reaching the threshold must not escalate until the wall-clock window elapses). ServerErrorAckTerminalTest pins the end-to-end escalation with the dwell disabled (poison_min_escalation_window_millis=0) so the exact per-strike replay count stays assertable.
  • QuestDBServerRecoveryTest — full lifecycle: server down → facade starts → client writes (buffered) → server starts → write side reconnects and the reader connects on the first borrowQuery().
  • QuestDBBuilderTest — covers the single cluster-config surface; the shared-vocabulary and sender-pool-unwind tests run against one server with one config.
  • TestWebSocketServer now serves both pools from one config like a real node: SERVER_INFO is emitted only on the egress /read path (the ingest /write ACK stream would choke on it), plus a setRejectReadUpgrade() toggle to fail only the query upgrade.

Full impl + network + facade suites pass locally on JDK 8 (source of truth) and the surface compiles on JDK 25 (java11+ front).

CI / native

  • ci(native): the rebuild_native_libs.yml linux-x86-64 job moved from manylinux2014 (glibc 2.17) to manylinux_2_28 (glibc 2.28), mirroring linux-aarch64 — GitHub now forces actions onto Node 24 (glibc ≥ 2.27), which couldn't run in the 2.17 container (pre-existing breakage, unrelated to the C change).
  • Native libraries are built on the test/release runners (no longer committed).

Compatibility

The connect_timeout knob and the ingest callbacks are additive and off by default. The direct QwpQueryClient API is unaffected.

The QuestDB facade shipped in 1.3.4 as unmarked public API, and §4/§5 make source-breaking changes to it (needs release-note coverage; note these removals ship without a deprecation cycle):

  • Source-breaking (§5): sender(), releaseSender(), query(), newQuery(), and executeSql(CharSequence, QwpColumnBatchHandler) are removed. Migration: borrowSender() (already in 1.3.4) replaces the thread-pinned sender()/releaseSender(); the new borrowQuery() lease replaces the egress trio — db.executeSql(sql, handler) becomes try (Query q = db.borrowQuery()) { q.sql(sql).handler(handler).submit().await(); }.
  • Source-breaking (§4): the two-arg connect(CharSequence ingest, CharSequence query) is removed. Migration: configure the whole cluster with a single ws/wss string (one addr server list) via connect(CharSequence) or builder().
  • Behavior (§4): connect(CharSequence) now requires a ws/wss schema; 1.3.4 also accepted Sender-/QwpQueryClient-style http/https strings and mapped them.

The direct Sender API is affected by §6 (both surfaces shipped in 1.3.4 — needs release-note coverage):

  • Source-breaking: SenderConnectionEvent.Kind.RECONNECT_BUDGET_EXHAUSTED is removed; connection listeners that reference it must drop that case (the event can no longer fire).
  • Behavior: reconnect_max_duration_millis now bounds only the blocking SYNC initial connect. Async initial connect and mid-stream reconnects retry indefinitely instead of latching a producer-visible terminal on budget expiry; outages remain visible via the default connection listener and throttled WARN logs, and SF exhaustion still surfaces as append backpressure.

And by §7 (NACK policy v2):

  • Source-breaking: SenderError.Policy constants renamed to the retriable/terminal taxonomy; error handlers that switch on them need the rename.
  • Behavior: server error ACKs no longer drop frames (DROP_AND_CONTINUE is gone) — retriable NACKs replay from SF, deterministic rejections escalate to a typed terminal after max_frame_rejections deliveries; ackedFsn advances only on server OKs. A non-orderly close after a send now paces its recycle like a NACK, and poison escalation is gated on a minimum wall-clock dwell (poison_min_escalation_window_millis, default 5s) so a transient outage can't false-positive into a terminal — both are safer defaults (additive config; set the dwell to 0 for the previous escalate-at-threshold timing).

Note: the PR bundles several independent features. Happy to split any of them into standalone PRs off main if that's easier to review.


BREAKING CHANGE: removes QuestDB.sender(), QuestDB.releaseSender(), QuestDB.query(), QuestDB.newQuery(), QuestDB.executeSql(CharSequence, QwpColumnBatchHandler), and QuestDB.connect(CharSequence, CharSequence) (migrate to borrowSender() / borrowQuery() / single-config connect()); QuestDB.connect(CharSequence) now requires a ws/wss schema; removes SenderConnectionEvent.Kind.RECONNECT_BUDGET_EXHAUSTED; renames SenderError.Policy constants to the retriable/terminal taxonomy.

bluestreak01 and others added 5 commits June 28, 2026 21:41
Establish a real, cross-platform connect timeout for the HTTP and
WebSocket (QWP) transports. Previously a connect to a black-holed or
firewalled host blocked on the OS-level TCP connect timeout (often
60-120s) because the socket was created blocking and only switched to
non-blocking *after* connect; the transport exposed no knob to clamp it.

Approach: a new native primitive switches the socket to non-blocking
*before* connect, so connect() returns EINPROGRESS immediately, then
polls for writability bounded by the caller's budget and confirms the
outcome via SO_ERROR. A distinct return code (CONNECT_TIMEOUT, -3) lets
the Java layer raise a timeout-flagged exception rather than decode errno.

Native:
- share/net.c: connectAddrInfoTimeout + awaitConnectComplete (poll +
  getsockopt(SO_ERROR), monotonic-clock EINTR handling)
- windows/net.c: Winsock equivalent (select write/except sets)
- share/net.h: ECONNTIMEOUT (-3) sentinel

Java:
- Net / NetworkFacade(Impl): connectAddrInfoTimeout + CONNECT_TIMEOUT
- HttpClientConfiguration.getConnectTimeout() (default 0 = OS fallback)
- HttpClient.connect() / WebSocketClient.doConnect() honor it and throw a
  timeout-flagged HttpClientException on CONNECT_TIMEOUT
- Sender builder: connectTimeoutMillis() + connect_timeout connect-string
  key (legacy http and ws/wss parsers) + ConfigSchema COMMON key
- QwpWebSocketSender / QwpQueryClient: thread the value through to their
  WebSocketClient (adds QwpQueryClient.withConnectTimeout)

Default is unset (0): behaviour is unchanged unless connect_timeout is
configured.

Tests: NetConnectTimeoutTest covers loopback success, refused-vs-timeout
disambiguation, and a black-hole timeout that fires within budget;
config-honored drift guards updated for the new COMMON key.
On a runner with no route to TEST-NET-1 (192.0.2.0/24) connect() fails
fast with ENETUNREACH instead of dropping the SYN, so the timeout path
can't be exercised. Skip (Assume) in that case rather than asserting a
timeout, while still proving the call never blocked on the OS connect
timeout.
GitHub now forces actions onto Node 24 (glibc >= 2.27), which cannot run
inside the manylinux2014 (glibc 2.17) container the linux-x86-64 native
build used; actions/checkout failed before compilation. The old
Node-20-glibc-217 override only patched /__e/node20, not /__e/node24.

Switch the job to quay.io/pypa/manylinux_2_28_x86_64 (glibc 2.28, runs
stock Node 24) and drop the Node hack, nasm src.rpm rebuild, and manual
CMake download, mirroring the linux-aarch64 job that already builds on
manylinux_2_28.
The pooled QuestDB facade built its ingest Senders from config strings
only (SenderPool -> Sender.fromConfig), so the programmatic ingest
callbacks -- SenderErrorHandler and SenderConnectionListener -- were
unreachable: a facade user got the default loud-not-silent handlers with
no way to observe async ingest errors or connection transitions.

Expose both as QuestDBBuilder setters and thread them to every pooled
Sender:
- QuestDBBuilder.errorHandler(...) / .connectionListener(...)
- QuestDBImpl gains a full constructor carrying the callbacks; the public
  constructor forwards them and the 12-arg white-box test-seam constructor
  is preserved as a delegating shim (null callbacks).
- SenderPool gains a full constructor + applyUserCallbacks() that applies
  the callbacks to every sender it builds (both the non-SF and SF paths);
  the 8-arg test-seam constructor is preserved as a shim.

Recovery delegates (internal, short-lived, OFF-mode drain senders) are
deliberately excluded so the user's callbacks never see events from
internal machinery.

Defaults are null -> behaviour is unchanged unless a callback is set.

Tests: QuestDBFacadeCallbacksTest prewarms one ingest sender at a dead
port in async mode with a tight reconnect budget and asserts the
facade-wired errorHandler receives the budget-exhaustion SenderError and
the facade-wired connectionListener observes connection events -- no
server required.
@bluestreak01 bluestreak01 changed the title feat(net): add application-level TCP connect timeout feat: TCP connect timeout + expose ingest callbacks on the QuestDB facade Jun 28, 2026
The QuestDB facade always built a reader (QueryClientPool), which prewarms
synchronously and fail-fast (default query_pool_min=1, QwpQueryClient has no
async connect). So a down server / read primary sank the whole facade build,
taking the write side with it.

Add QuestDBBuilder.writeOnly(): build an ingest-only handle that never
constructs the query pool, so the read side cannot fail startup. A query
config is no longer required in this mode (any query config set is ignored),
and query()/newQuery() throw a clear "write-only" IllegalStateException.

- QuestDBImpl gains a write-only public constructor + a writeOnly flag on the
  full constructor; the 12-arg white-box test-seam constructor stays unchanged
  (delegates with writeOnly=false). queryPool/queryThreadLocal are null in
  write-only mode.
- PoolHousekeeper tolerates a null query pool.
- QuestDBBuilder.buildWriteOnly() validates + resolves only the sender/shared
  pool knobs from the ingest config.

Pair with initial_connect_retry=async (or sender_pool_min=0) on the ingest
config so the write side does not fail-fast either -> the facade starts with
no server present.

Tests: QuestDBWriteOnlyTest proves the facade builds with no server, that
query()/newQuery() are disabled, that no query config is required, and that an
async warm sender can buffer a write while serverless.
@bluestreak01 bluestreak01 changed the title feat: TCP connect timeout + expose ingest callbacks on the QuestDB facade feat: connect timeout, ingest callbacks, and write-only mode on the QuestDB facade Jun 28, 2026
…nnects

End-to-end resilience test for the QuestDB facade: build with the server
down (ingest initial_connect_retry=async + query_pool_min=0), buffer a
write, then bring the server up and assert the write side reconnects and
the previously-deferred reader connects on the first query.

Uses two TestWebSocketServers bound-but-not-accepting to model a reachable
-but-down server (handshakeCount stays 0 until start()). The mock cannot
serve real SELECT rows, so the read step asserts the query client connects
once the server is up, not the row contents. Stable across repeated runs.
@bluestreak01 bluestreak01 changed the title feat: connect timeout, ingest callbacks, and write-only mode on the QuestDB facade feat(qwp): connect timeout, ingest callbacks, and write-only mode on the QuestDB facade Jun 28, 2026
bluestreak01 and others added 17 commits June 29, 2026 00:20
Remove the committed Linux/Windows native binaries (libquestdb.so,
libquestdb.dll) and compile them locally during the Azure test CI.

- New ci/build_native.yaml template compiles libquestdb on the runner:
  Linux (cmake+nasm+build-essential) and Windows (MinGW-w64+NASM via choco).
  macOS keeps using the committed .dylib. Inits the zstd submodule first.
- Output is copied into src/main/resources/.../bin/<platform>/ so mvn install
  packages it into the client jar for both client and OSS server tests; the
  loader also picks up the CMake bin-local output directly.
- Wired the template into run_tests_pipeline.yaml before client install.

Committed binaries are still produced by the release GitHub Action.
Remove the committed darwin-aarch64/darwin-x86-64 libquestdb.dylib and build
them on the macOS runners, matching the Linux/Windows approach. No native
binaries remain committed; all are compiled during the test CI.

- build_native.yaml: add a macOS build step (brew cmake/nasm,
  MACOSX_DEPLOYMENT_TARGET=13.0), detect darwin-aarch64 vs darwin-x86-64 via
  uname -m, and copy the dylib into src/main/resources/.../bin/<platform>/.
- Init the zstd submodule on all platforms (it was skipped on Darwin).

Release artifacts are still produced by the release GitHub Action.
The macos-15 (x64) agent hardware no longer exists, so remove the mac-x64
matrix entry. macOS is now tested on mac-aarch64 only. The darwin-x86-64
.dylib is still produced by the release GitHub Action, and build_native.yaml
keeps its uname-based arch detection so an x64 macOS runner would still build
correctly if ever reintroduced.
The GitHub Actions build-jdk8 job ran the full test suite against the
committed native libraries, which are now removed. Without the .so the
io.questdb.client.std.{Os,Files,Unsafe,...} static initializers fail with
NoClassDefFound (1289 errors).

Compile the native .so from source first (zstd submodule + cmake/nasm/
build-essential), against the JDK 8 JNI headers, and copy it into
src/main/resources/.../bin/linux-x86-64 so it survives 'mvn clean' and loads
via the production bin/<platform> path. Update the now-stale comment.
glibc 2.17 moved clock_gettime() into libc under a new GLIBC_2.17 version
node. Building the release .so in a modern container (manylinux_2_28) binds
clock_gettime@GLIBC_2.17, which raises the whole library's glibc floor to 2.17
and breaks loading on glibc 2.14-2.16 hosts.

Add src/main/c/share/glibc_compat.h with a .symver directive forcing the
reference back to clock_gettime@GLIBC_2.2.5 (x86-64 glibc only; no-op on
aarch64/macOS/Windows), include it from net.c and os.c, list it in the
CMake sources, and document the glibc floor in rebuild_native_libs.yml.
The Coverage Report job runs 'mvn -P jacoco test' on core but had no native
build step, so after dropping the committed binaries it failed to load
libquestdb.so (NoClassDefFound in io.questdb.client.std.*). Add the
build_native.yaml template before the coverage test run, matching the
BuildAndTest job. The job runs on Linux, so it compiles libquestdb.so.
Collapse the dual ingest/query config surface on the QuestDB facade into a
single configuration string for the whole cluster. A QuestDB cluster is one
logical target reached over QWP for both ingest and query, so one ws/wss
string -- listing every node in a single `addr` server list -- now drives
both the sender and query pools.

- QuestDBBuilder: drop ingestConfig()/queryConfig(); fromConfig() sets the
  one cluster config. Remove the cross-side pool-key conflict resolution
  (no two strings to reconcile) -- resolvePoolInt/Long read one ConfigView.
  build() validates the single string with both the ingest and egress
  validators; each side applies the keys it owns and ignores the rest.
- QuestDB: remove the connect(ingest, query) overload; connect(config) and
  builder() now document the one-config/server-list model.
- QuestDBImpl is unchanged: the builder passes the one config to both pool
  slots, preserving the white-box reflection seam.

Tests: TestWebSocketServer now serves both pools from one config like a real
node -- SERVER_INFO is emitted only on the egress /read path (the ingest
/write ACK stream would choke on it), plus a setRejectReadUpgrade() toggle to
fail just the query upgrade. Rewrote QuestDBBuilderTest and updated the
facade callback/recovery/write-only tests and the examples accordingly.
…n-string key

Make writeOnly() deliver its own promise and reach it from the connect string.

Previously "start even when the server is down" needed two knobs that look
unrelated: writeOnly() (skip the fail-fast read pool) plus
initial_connect_retry=async (keep the write prewarm from fail-fast-ing). The
former governs the read side, the latter the write side, so writeOnly() alone
still hard-failed build() when sender_pool_min >= 1 and the server was down.

- writeOnly() now defaults the ingest side to a non-blocking async initial
  connect (injected right after the schema so an explicit initial_connect_retry
  in the user's string still wins, last-write-wins). build() returns promptly
  with the server down and the sender pool warm; writes buffer until the wire
  comes up.
- New POOL-side connect-string key write_only=on, equivalent to .writeOnly(),
  so the mode is reachable from any config string (and QuestDB.connect). The
  two ws clients ignore it; the facade routes on it.

Tests: writeOnly() with sender_pool_min defaulting to 1 and no
initial_connect_retry now builds without fail-fast; write_only=on routes to
the ingest-only path via builder and via connect(). PoolConfigHonoredTest's
drift guard skips the routing flag (not a numeric sizing knob).
…nabled

Strengthen the server-recovery test to assert what the write-only mode is NOT:
on a normal facade built while the server is down (lazy read pool via
query_pool_min=0, async ingest), query() must still hand back a usable builder
*before* the server is up -- reads are enabled, just deferred -- and the
deferred reader connects on the first submit once the server comes up. This is
the read-capable counterpart to write-only, where query() throws for the life
of the handle.
…ant startup)

Drop write-only mode (it permanently disabled reads -- query()/newQuery()
threw for the life of the handle) in favour of a read-capable tolerant-startup
flag, lazy_connect, reachable from the connect string.

lazy_connect=true:
- a) starts even when the server is down -- the ingest side connects async and
     the read pool defaults to query_pool_min=0, so neither side fail-fasts;
- b) buffers writes while the server is down (async sender);
- c) reads once the server is up -- the read pool stays ENABLED and connects
     lazily on the first query.

Because both sides must start non-blocking, a knob that forces a blocking /
fail-fast startup is a configuration conflict, rejected up front with a clear
remedy:
- initial_connect_retry other than async (off/false/on/true/sync), and
- an explicit query_pool_min > 0 (connect string or builder call).

Changes:
- ConfigSchema: write_only -> lazy_connect (Side.POOL; both clients ignore it).
- ConfigView.getBool: accept true/false (and on/off).
- QuestDBBuilder: remove writeOnly()/buildWriteOnly(); build() resolves
  lazy_connect, validates the two conflicts, defaults query_pool_min to 0 and
  injects initial_connect_retry=async when unset.
- QuestDBImpl: remove the write-only constructor/flag and requireQueryEnabled;
  the query pool is always built (the white-box reflection seam is unchanged).
- Tests: QuestDBWriteOnlyTest -> QuestDBLazyConnectTest (start+write while down,
  reads stay enabled, both conflicts via string and builder, true/on parsing);
  QuestDBServerRecoveryTest now dogfoods lazy_connect=true for the full
  down->write->up->read lifecycle; PoolConfigHonoredTest drift guard skips the
  flag.
…e timeout

QwpQueryClient.runUpgradeWithTimeout wrapped connect() and upgrade() in one
try block, so a connect_timeout overage -- the timeout-flagged
HttpClientException from doConnect()'s CONNECT_TIMEOUT path -- was caught by
the isTimeout() branch meant for upgrade() and rewritten as
"WebSocket upgrade to host:port exceeded auth_timeout=<authTimeoutMs>ms".
A user with connect_timeout=500 and auth_timeout_ms=15000 saw, after ~500ms,
an error blaming a 15000ms auth timeout (wrong phase and wrong value).

Move connect() outside the upgrade try so the auth_timeout rewrite only
applies to genuine upgrade-phase timeouts; connect-phase failures propagate
with their own "connect timed out ..." message. The failover walk is
unchanged (the exception is still a transport error and the next endpoint is
tried). The ingest side (QwpWebSocketSender) was already correct -- it routes
through QwpUpgradeFailures.classify, which leaves the connect-timeout
exception unmodified.

Add QwpQueryClientConnectTimeoutTest: a TEST-NET-1 blackhole connect with
connect_timeout < auth_timeout must report connect_timeout, not auth_timeout.
It skips gracefully when the runner has no route to the blackhole, mirroring
NetConnectTimeoutTest. Verified it fails on the pre-fix code with the exact
misreported message.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nly removal

The PoolHousekeeper reap loop wrapped queryPool.reapIdle() in an
`if (queryPool != null)` guard whose comment ("null for a write-only
handle") described write-only mode. That mode was removed in the
lazy_connect change (7491d95): QuestDBImpl now builds the query pool
unconditionally and is the sole PoolHousekeeper caller, so the field is
never null in a live handle. The null branch is unreachable and the
comment is stale -- drop both. The outer best-effort Throwable catch
stays; it has nothing to do with write-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ConfigView.getBool accepts true/false and on/off, but its invalid-value
error read "(expected true, false)", under-reporting the accepted forms
(e.g. lazy_connect=on is valid yet the message implies otherwise). List
all four, matching getBoolOnOff's convention of naming exactly what it
accepts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reshape the QuestDB facade so reads and writes share one pooled-lease
model, and remove the thread-affine footguns on both sides.

Ingest:
- Remove QuestDB.sender() and releaseSender(), along with the entire
  thread-pin subsystem behind them (SenderPool.pinToCurrentThread,
  releaseCurrentThread, clearPinIfCurrent, the threadAffine ThreadLocal,
  and the PooledSender invalidated flag that existed only to make pinning
  safe). borrowSender() is now the only way to lease a Sender.

Egress:
- Add QuestDB.borrowQuery(), a closeable, non-allocating Query lease that
  mirrors borrowSender(). Each pooled QueryWorker owns one pre-allocated
  QueryImpl, handed out reset on borrow; submit() dispatches on the held
  worker (single-flight) and close() returns it to the pool. The worker
  no longer auto-releases per query.
- Remove query(), newQuery(), and executeSql(). Reads now connect at
  borrow time rather than submit time; under lazy_connect the read pool
  still defaults to min=0, so build() does not fail-fast while the server
  is down.

Test seams:
- Make the white-box seam constructors public and annotate @testonly
  where production never calls them (QuestDBImpl, SenderPool). The
  QueryClientPool connectHook ctor stays public without @testonly because
  QuestDBImpl constructs the query pool through it. Tests now call these
  constructors directly instead of via reflection.

Update the client tests, the usage example, and the startup/failover
design doc to the new API.
try (server) on an effectively-final existing variable is Java 9+ syntax
(JEP 213) and fails the JDK 8 test-compile (the source-of-truth target)
with -source 1.8, breaking the build-jdk8 CI job and the release build
before any test runs. Inline the resource construction into the
try-with-resources declaration, which is valid on Java 8 and keeps the
server variable name used throughout the body.
…se-after-close

A pooled Query/Sender handle was the reused per-slot object itself, guarded
only by a non-volatile in-use/borrowed flag. Once a worker/slot was released
and re-borrowed, that flag flips back to "live", so a stale handle's
close()/cancel()/write would leak into a *different* borrow: a duplicate close
double-released the worker/slot (enqueued twice -> two concurrent borrowers on
one non-thread-safe client/delegate), and a cached Completion.cancel() or stale
write hit whatever borrow now owns it. Idempotent close() and no-op cancel()
are documented contracts, so this was reachable from contract-legal code, not
just misuse, with pool-wide blast radius and no -ea guard.

Fix: give every borrow its own immutable generation, stamped under the pool
lock when the worker/slot is handed out and bumped again when it is returned.
The reused state stays on the slot; callers get a thin per-borrow handle that
carries the generation and validates it on every operation:
  - close()/cancel() are no-ops on a stale generation (idempotency preserved),
  - submit()/data writes throw,
  - release/giveBack/discardBroken re-check the generation under the pool lock
    so a worker/slot can never be enqueued twice, plus an -ea assert that it is
    not already in the available deque.

Egress: QueryImpl stops being the user-facing Query; new QueryLease wraps it.
Ingest: new SenderSlot is the reused slot; PooledSender becomes the per-borrow
wrapper (keeps the public name, so borrow() still returns it). The per-submit
path stays allocation-free; only the small lease handle is created per borrow
(routinely scalar-replaced under try-with-resources).

Adds QueryLeaseGenerationTest and SenderLeaseGenerationTest covering the
double-release and cross-borrow cancel/write paths; updates the white-box
tests to the new shapes. Full core suite green under -ea (the lone failure is
the unrelated pre-existing FilesTest M2, which fails identically on master).
QueryWorker.runLoop() consumed the dispatch hand-off (q = current) under
signalLock but cleared the slot (current = null) only after runOn()
returned, outside the lock. A Query lease is single-flight but reused:
the user thread loops submit() -> await() on the same handle. The
terminal callback inside runOn() wakes the user thread, which can call
submit() -> dispatch() -- setting current = q and signalling -- before
the worker thread reaches its post-run finally block. That stale
current = null then clobbered the freshly dispatched job and discarded
its already-consumed signal, so the worker parked forever on the
condition while the user thread blocked on a Completion that never
fired. The borrowed worker never returned to the pool and the caller
hung indefinitely.

Clear current under signalLock at the moment of consumption and drop the
post-run finally clear. dispatch() now cannot be clobbered: by the time
the next dispatch runs, the worker is either already awaiting (so the
signal wakes it) or will observe current != null on the while check and
skip awaiting. The exception path leaves current already null, and the
shutdown branch still clears under the lock.

Surfaced as a 60s hang in QuestDBFacadeE2ETest.testSustainedMixedConcurrency
(more threads than pool slots, repeated submit/await per lease). Was
intermittent and timing-sensitive, so it showed up mainly on aarch64 CI;
reproduced locally on x86 about one run in four, and 15/15 clean with
this fix.
…ected client

The connect walk's catch (Error) arm (1582e3a, cc5e9cc) guarded only
connect/upgrade and the failure arms. After a successful upgrade, the
durable-ack check, success classification, dispatchConnectionEvent and
applyServerBatchSizeLimit ran unguarded before return: an Error thrown
there (the success dispatch allocates the event plus a deque node, and
on a clean first connect lazy-starts the dispatcher thread) escaped
with the fully connected client's fd and native buffers unreachable by
GC.

Wrap the durable-ack-check-to-return tail in the same quiet-close /
rethrow contract as the connect/upgrade arm, extracted into
closeQuietlyOnError so the two arms cannot drift. close() is CAS-gated,
so re-closing after the durable-ack arm's own close is a no-op.

Tests bracket the guarded window at both ends -- first expression
(durable-ack check) and last call (getServerMaxBatchSize) -- so
narrowing the try block later trips one of the two.
…inding parse path

CursorWebSocketSendLoop's NACK-recycle (handleServerRejection /
handlePreSendRejection / onClose -> failPaced()/fail() -> connectLoop ->
swapClient -> oldClient.close()) closes the client synchronously while
the I/O thread is still inside that client's tryParseFrame. Control then
unwinds into the post-handler tail, which advanced recvReadPos and
compacted the recv buffer on the just-closed client: recvPos went
negative, and only the accident of disconnect() zeroing positions plus
compactRecvBuffer's remaining > 0 guard stood between that and a
Vect.memmove on the freed recvBufPtr.

Make in-callback close a supported contract instead: tryParseFrame's
tail detects closed and returns PARSE_OK without touching recv state
(the frame was fully dispatched), covering every present and future
frame handler rather than just the cursor send loop. Pin the restored
invariant with an assert in compactRecvBuffer.

Red/green: without the guard the new tests observe recvPos=-6/-4 after
an in-callback close from onBinaryMessage/onClose; with it, positions
stay at disconnect()'s reset. NACK-recycle e2e suites unaffected.
…ch close

Review hardening on top of the in-callback close guard:

- WebSocketFrameHandler javadoc now states the contract where implementors
  read it, including the sharp edge: close() frees the buffers the payload
  pointers point into, so payload must be consumed before closing.
- New test pins the CONTINUATION branch: resetFragmentState() runs after
  the handler returns and before the closed-client guard, and must only
  write fields (close() has already freed fragmentBufPtr).
- Test-file standards: inner classes and reflection helpers in alphabetical
  order; getIntField declares throws Exception like its siblings.

Reviewed and accepted without change: in-callback disconnect() (zeroes
positions without setting closed) has no callers anywhere in-tree and is
caught loudly by the compactRecvBuffer assert under -ea; the guard's
closed.get() is one volatile load per parsed frame, noise next to the
recv syscall and frame parse it sits behind.
…ror in mixed connect sweep

In a mixed sweep -- e.g. [replica(421+role), replica(421+role),
node(503)] -- the exhausted-round epilogue threw the latched non-421
WebSocketUpgradeException before checking lastRoleReject, misclassifying
a transient failover/promotion window as terminal: dead foreground
sender, or a drainer slot quarantine via BackgroundDrainer markFailed.

Demote a plain non-421 WebSocketUpgradeException below role-reject
evidence: when any endpoint answered 421+role in the same sweep, surface
the retriable QwpRoleMismatchException so the connect/reconnect loops
keep rows in store-and-forward and retry through the promotion window.
The demoted upgrade error rides along as a suppressed diagnostic.

Typed capability gaps are NOT demoted: QwpVersionMismatchException and
QwpDurableAckMismatchException extend HttpClientException directly, so
they fall through the instanceof check and stay terminal even when
replicas role-rejected in the same sweep -- preserving the documented
durable-ack contract.

Covered by WriteFailoverTest#testMixedSweepRoleRejectOutranksLatchedTerminalUpgradeError,
which also asserts the suppressed 503 diagnostic.
The server (as of the companion core fix) withholds cumulative OK acks
for FLAG_DEFER_COMMIT frames until their group-closing commit lands:
acking them earlier let the store-and-forward client trim slots whose
rows the server rolls back on any error, demote, or disconnect --
silent data loss (#7144's stated replay contract, previously
unenforced).

Client-side consequences fixed here:

- QwpWebSocketSender tracks lastCommitBoundaryFsn: the FSN of the last
  commit-bearing (non-deferred) frame published. Updated by
  flushPendingRows, flushPendingRowsSplit, sendCommitMessage, and the
  close()-time residual seal.
- drainOnClose() now drains to min(publishedFsn, commit boundary)
  instead of publishedFsn. Waiting for acks of an uncommitted deferred
  tail could only ever time out (300s hangs in
  testDeferredCommitConnectionDropRollsBack). Abandoning frames is
  WARN-logged with an actionable message (flush() to commit, or the
  abort is intentional).
- CursorSendEngine computes recoveredCommitBoundaryFsn at disk
  recovery: the last commit-bearing frame below a potentially orphaned
  deferred tail left by a producer that died mid-transaction. Close
  drains combine it with the session boundary. Recovery WARNs when an
  orphaned deferred tail exists, because replaying it into a NEW
  session's commit would resurrect a partial transaction -- the
  skip-and-self-ack replay policy for that tail is a documented
  follow-up.
- MmapSegment/SegmentRing grow a read-only recovery-time frame walk
  (findLastFsnWithoutPayloadFlag) that locates the boundary by peeking
  the QWP flags byte of each published frame.

Full client suite passes (2505 tests).
…ndary

Self-review finding: flushAndGetSequence() has the same belt-and-braces
residual sealAndSwapBuffer() as close(), but the commit-boundary update
was only added to close()'s copy. A non-deferred residual publish there
left lastCommitBoundaryFsn stale, so drainOnClose targeted a lower FSN
and close() could return before the residual frame's ack -- memory-mode
data loss on JVM exit, the exact bug class this series fixes.
Closes the resurrect-partial-transaction hole documented in the
deferred-ack series: a producer that crashes (or closes)
mid-transaction leaves FLAG_DEFER_COMMIT frames with no covering
commit frame at the top of its disk-recovered SF log. Replaying them
would let a commit-bearing frame from the NEW session commit them --
half a dead transaction resurrected, violating transaction=on
atomicity. Previously detected + WARN'd only.

Mechanism (CursorWebSocketSendLoop):
- The loop adopts the engine's recovered orphan range
  [recoveredCommitBoundaryFsn+1 .. recoveredOrphanTipFsn] at
  construction; the BackgroundDrainer path builds through the same
  constructor and gets identical containment.
- trySendOne stops the cursor at the tail: orphan frames are never
  transmitted.
- tryRetireOrphanTail retires the tail with a cumulative
  self-acknowledge once every frame below it is server-acked (legal:
  the tail was never sent, no wire sequence references it). Crash-safe
  and idempotent -- dying before the self-ack re-detects the same
  range at next recovery.
- Fast path (common crash profile: acks current, only the in-flight
  transaction unacked; trivially when the whole log is the tail): the
  tail retires in positionCursorForStart before any connection exists.
  Zero wire cost, no reconnect.
- Slow path (unacked committed frames below the tail): they replay
  first; on retirement the connection recycles exactly once so the
  linear wireSeq<->FSN mapping re-anchors past the FSN gap. swapClient
  also re-attempts retirement so a reconnect never replays into the
  tail.

Positive-identification hardening (found by the drainer test suite):
the recovery walk classifies a frame as deferred ONLY when it parses
as a QWP message -- payload >= HEADER_SIZE and u32 'QWP1' magic at
offset 0 -- before reading the flags byte. Anything unidentifiable is
a retirement barrier, never trimmable: misclassifying an unknown
frame as deferred would silently discard data that should replay (the
drainer tests' filler payloads had bit 0 set at offset 5 and were
being trimmed wholesale).

Tests: CursorWebSocketSendLoopOrphanTailTest covers recovery
detection (orphan tail present/absent), the zero-send fast path, and
the slow path end-to-end (replay below tail, single recycle, correct
ack attribution across the FSN gap for new-session frames). Full
client suite: 2509 green.
…ugh (int) cast

Both ws config paths read connect_timeout via view.getLong and cast to
int: connect_timeout=4294967297 silently became a 1 ms timeout, and
2147483648 wrapped to Integer.MIN_VALUE, producing the misleading
'connect_timeout must be > 0: -2147483648'.

- ConfigView.getInt now parses in the long domain: schema range still
  reports first (tighter, key-specific), then an over-int value fails
  with the actual limit ('connect_timeout must be <= 2147483647: ...')
  instead of a generic 'invalid' or a wrapped value.
- Sender.fromConfigWebSocket and QwpQueryClient.fromConfig/validateConfig
  read connect_timeout via getInt, aligning with the legacy http/tcp
  parseIntValue path that already rejected over-int values.

Covered at ConfigView level (ConfigViewTest) and end-to-end on both
facades (QwpConfigKeysTest.testConnectTimeoutOverIntRejectedOnBoth).
Three recycle paths are strike-exempt by design -- orderly closes
(NORMAL_CLOSURE/GOING_AWAY), non-orderly closes before any send on the
connection, and pre-send RETRIABLE_OTHER (NOT_WRITABLE) rejections: none
of them are a verdict on the bytes. But strike-exempt also meant
pace-exempt: failPaced keys its dose on poisonStrikes, and connectLoop's
backoff engages only on FAILED connect attempts, while a peer that
completes TCP+TLS+upgrade before closing makes every attempt "succeed".
With Invariant B having (correctly) removed the wall-clock reconnect
budget, nothing bounded these recycles at all: an LB drain window, a
GOING_AWAY rolling restart, or a health-checking middlebox in front of a
dead backend drove full recycles -- fresh WebSocketClient + SSLContext +
trust-store read -- at handshake RTT rate, forever. Measured red: ~100k
reconnects in 1.2s.

Fix: failExemptPaced, a zero-progress recycle pacer. It counts
consecutive exempt recycles with no acceptance progress in between
(max(ackedFsn, highestOkFsn) -- monotonic, so replay re-OKs cannot
launder it) and parks with the same doubling, capped dose failPaced
uses. The FIRST recycle after any progress stays immediate, preserving
failover latency for genuine role-change/drain handoffs. Pacing only:
the counter never escalates to a terminal (Invariant B).

Tests: orderly-close churn and pre-send close churn (both red at ~85k
recycles/sec unpaced, now bounded by the backoff), plus a recycle-level
contract test pinning first-immediate, consecutive-paced, and
reset-on-progress.
…erred tails

Regression tests for c6eadf0 (stop close() waiting on acks of
uncommitted deferred frames), which shipped with the fix in 4
production files and no client-side test -- the only coverage was
testDeferredCommitConnectionDropRollsBack in the parent server repo,
where the pre-fix symptom was a 300s close() hang.

Two tests in CloseDrainTest, both verified red on the pre-fix
behavior (target = publishedFsn) and green on the fix:

- testCloseSkipsDrainForUncommittedDeferredTail: defer-commit mode,
  publish rows, never commit, close() against a server that never
  acks (what the real server does to a deferred tail by design).
  Fixed close() drains to min(publishedFsn, commitBoundary) = -1 and
  returns immediately with the abandonment WARN; broken close()
  throws 'drain timed out' after the full close_flush_timeout.

- testCloseDrainsToCommitBoundaryAndAbandonsDeferredTail: one
  committed frame below an uncommitted deferred tail, server acks
  only the commit-bearing frame (AckFirstFrameOnlyHandler). Pins
  that the boundary drain is not an opt-out of draining altogether:
  close() still waits for the committed frame's ack, then abandons
  the tail instead of timing out on it.
… lock

When borrow()/acquire() re-locks after building a fresh delegate and finds
the pool closed, both pools tore the delegate down while holding the pool
lock. A delegate close() can block for seconds (5s ack drain, ~3s drainer-
pool wind-down, 5s dispatch-thread join) or longer (unbounded I/O-thread
latch await behind an OS-level connect when connect_timeout=0), stalling
close(), giveBack/retireLease, reapIdle and watchdog cancels behind it --
violating retireLease's close-outside-the-lock contract on the ingest side
and cancelIfCurrent's held-only-briefly contract on the egress side.

SenderPool now mirrors retireLease: accounting under the lock (move the SF
slot reservation from inFlightCreations to closingSlots, bump
pendingLeaseTeardowns so close()'s outstanding-teardown wait sees the
in-flight close), delegate close outside, re-lock to reclaim the slot and
signal. QueryClientPool simply unlocks before shutdown(): the fresh worker
never entered `all`, so close()'s snapshot loop cannot race the teardown.

RED/GREEN regression tests park a fake delegate mid-close and prove the
pool lock stays free (probe close() completes), close() still waits for the
teardown to finish, and the closed-mid-creation worker teardown leaks no
native scratch.
The "retries forever" tests asserted only no-terminal-in-window, but a
regressed I/O thread that gives up WITHOUT latching a terminal also
delivers nothing: flush() publishes to the cursor engine on the user
thread regardless of wire liveness, so both tests passed green with a
dead retry loop.

Back the no-terminal assertions with two discriminators:

- LIVENESS: snapshot getTotalReconnectAttempts() only after the
  (ignored) reconnect budget has elapsed and require it to advance --
  connectLoop bumps the counter on every attempt in both the
  async-initial-connect and mid-stream reconnect phases, so a frozen
  counter means the loop exited silently. A plain attempts >= 1 check
  would not discriminate: pre-budget attempts would satisfy it.
  Applied to testAsyncNoServerRetriesForeverNoTerminal,
  testConnectionLostRetriesForeverNoTerminal, and
  testReconnectNeverGivesUpInvariantB.

- RECOVERY: in testReconnectNeverGivesUpInvariantB, revive a server on
  the SAME port (the requestedPort constructor exists for down-then-up
  outage realism) and require a replayed frame to arrive -- end-to-end
  proof that "retry forever" converts into delivery once the server
  returns. The revival handler is receipt-only: faking ACKs would
  couple the test to the exact replay FSN sequence and a wrong-seq ACK
  risks the invalid-ACK terminal.

Also fix the test comment claiming flush publishes to on-disk SF: no
sf_dir is configured there, so rows buffer in the in-RAM cursor ring.
A post-send RETRIABLE_OTHER (NOT_WRITABLE) rejection recorded a poison
strike and recycled through the unpaced fail(). NOT_WRITABLE is a
node-state verdict, not a frame verdict: in an all-replica /
demoting-primary window every rotated endpoint NACKs the same head
frame with no OK progress, so strikes accrued at handshake-RTT rate
and, once the escalation dwell elapsed (~5s default), a transient
cluster condition latched a producer-fatal PROTOCOL_VIOLATION terminal
-- violating Invariant B and the RETRIABLE_OTHER rotate-endpoints
contract. Latent today (no shipping server NACKs 0x0C mid-stream; it
is reserved for forward compat), but the client IS the forward-compat
contract.

Mirror the pre-send path: no strike for RETRIABLE_OTHER, and recycle
through the zero-progress pacer (failExemptPaced) -- first recycle
immediate for failover latency, consecutive no-progress recycles paced
so the window does not churn full TLS reconnects at handshake RTT rate.
Only RETRIABLE rejections now feed the poison detector. Design doc
updated to match (its strike rule predated the pre-send carve-out).
…oint reads

Add QwpServerInfo serverInfo() to the public Query interface, implemented
in QueryLease -> QueryImpl.serverInfo(gen). It returns the leased pooled
query client's cached SERVER_INFO snapshot (live role, zone, cluster/node
ids) without dispatching a query, so it does NOT drive the per-submit
failover reconnect loop. Lets a caller observe which endpoint it is bound
to -- and that endpoint's live role -- without itself triggering a
failover walk.
…tory() instead of reflection

Replace the Class.forName("...$ReconnectSupplier") + private-constructor +
getDeclaredMethod("buildAndConnect", ...) invocation with the existing
public production accessor: newReconnectFactory().reconnect() is a
one-line delegation to the private buildAndConnect(this), so the tests
exercise the identical code path while staying compile-time safe against
renames.

Failures now surface unwrapped: the Error-path tests catch
OutOfMemoryError directly (not InvocationTargetException.getCause()),
and the Exception-path test catches LineSenderException directly.
Unused reflection imports dropped; setField wiring for the bare
Unsafe-allocated sender is unchanged.
@bluestreak01 bluestreak01 changed the title feat(qwp): connect timeout, ingest callbacks, and lazy_connect (tolerant startup) on the QuestDB facade feat(qwp)!: connect timeout, ingest callbacks, and lazy_connect (tolerant startup) on the QuestDB facade Jul 4, 2026
@kafka1991 kafka1991 changed the title feat(qwp)!: connect timeout, ingest callbacks, and lazy_connect (tolerant startup) on the QuestDB facade feat(qwp): connect timeout, ingest callbacks, and lazy_connect (tolerant startup) on the QuestDB facade Jul 6, 2026
nwoolmer
nwoolmer previously approved these changes Jul 6, 2026
The native libraries are no longer committed to the repository -- they
are built from source in ci.yml, run_tests_pipeline.yaml, and
maven_central_release.yml, and the bin/ directory is gitignored.

The manually-dispatched "Build and Push Release CXX Libraries" workflow
existed solely to build those binaries and commit+push them to the
branch. That purpose is gone: dispatching it would re-add (git add on an
explicit path overrides .gitignore) and re-commit the very binaries we
removed. Remove it, and fix the stale build_native.yaml comment that
pointed at it as the committing authority.
No CI runs the client test suite on x64 macOS: run_tests_pipeline.yaml
has no mac-x64 agent and the release verify job runs the suite on
linux-x86-64 only. Publishing a darwin-x86-64 binary that only ever gets
a dylib load-check -- never a functional test -- ships behavior (e.g. the
native connect-timeout path) that nothing exercises on that platform.

Drop darwin-x86-64 from the release:
- remove the macos-15-intel build leg from build-macos,
- drop it from stage-native-artifacts.sh so verify/publish neither
  require nor bundle it,
- update the release README (build x5 -> x4, four bundled libraries).

Local source builds on Intel Macs are unaffected: the pom
platform-osx-x86-64 profile and the build_native.yaml arch detection
stay, so a developer on an Intel Mac can still build the lib from source
-- we just no longer publish a prebuilt one.
The README predated the QuestDB facade entirely -- every example used
direct Sender.fromConfig / LineUdpSender instantiation and legacy ILP
HTTP/TCP/UDP transports, none of which reflect the recommended API.

Rewrite it facade-first:
- lead with QuestDB.connect / builder and borrowSender / borrowQuery over
  QWP (ws/wss), one cluster config string driving both pools;
- convert every example to the facade -- ingest, query, binds, cancel,
  pool sizing, ingest callbacks, lazy_connect, connect_timeout, auth/TLS,
  explicit timestamps;
- add a callout discouraging direct Sender / LineUdpSender / QwpQueryClient
  / Query instantiation, steering application code to the facade;
- replace the ILP config table with the ws/wss common + pool key tables;
- fix stale infra notes: native libs are built from source (not
  committed), Java 8 floor, and the darwin-x86-64 release drop.

API references verified against source (getServerMessage, getCategory,
getKind, QwpColumnBatch/Handler, QwpBindValues, builder methods).
Follow-up to the facade README rewrite:
- Sending Batches of Rows: per-batch flush loop, auto_flush_rows /
  auto_flush_interval, and durability confirmation via
  flushAndGetSequence() + awaitAckedFsn().
- Multiple Servers and Failover: one addr list drives both pools;
  target/failover on the query side, store-and-forward reconnect on
  the ingest side.
- Zone-Aware Query Routing: zone/client_id query-routing hints.
- Config reference: new Query routing keys table (target, failover,
  zone, client_id).

API and config keys verified against Sender.java and ConfigSchema.
@mtopolnik

Copy link
Copy Markdown
Contributor

[PR Coverage check]

😍 pass : 856 / 1108 (77.26%)

file detail

path covered line new line coverage
🔵 io/questdb/client/cutlass/qwp/client/WebSocketResponse.java 0 1 00.00%
🔵 io/questdb/client/std/MemoryTag.java 0 12 00.00%
🔵 io/questdb/client/cutlass/qwp/client/sf/cursor/BackgroundDrainerListener.java 0 1 00.00%
🔵 io/questdb/client/impl/PooledSender.java 14 54 25.93%
🔵 io/questdb/client/impl/QueryLease.java 6 23 26.09%
🔵 io/questdb/client/cutlass/http/client/HttpClient.java 5 14 35.71%
🔵 io/questdb/client/network/JavaTlsClientSocket.java 21 46 45.65%
🔵 io/questdb/client/impl/QueryImpl.java 37 77 48.05%
🔵 io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentManager.java 1 2 50.00%
🔵 io/questdb/client/impl/QuestDBImpl.java 3 5 60.00%
🔵 io/questdb/client/Sender.java 43 60 71.67%
🔵 io/questdb/client/cutlass/http/client/WebSocketClient.java 22 28 78.57%
🔵 io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java 122 148 82.43%
🔵 io/questdb/client/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoop.java 160 188 85.11%
🔵 io/questdb/client/impl/QueryClientPool.java 31 35 88.57%
🔵 io/questdb/client/cutlass/qwp/client/sf/cursor/BackgroundDrainer.java 116 129 89.92%
🔵 io/questdb/client/cutlass/qwp/client/sf/cursor/CursorSendEngine.java 18 20 90.00%
🔵 io/questdb/client/impl/ConfigView.java 13 14 92.86%
🔵 io/questdb/client/cutlass/qwp/client/QwpQueryClient.java 12 13 92.31%
🔵 io/questdb/client/impl/SenderSlot.java 19 20 95.00%
🔵 io/questdb/client/impl/QueryWorker.java 19 20 95.00%
🔵 io/questdb/client/impl/SenderPool.java 79 82 96.34%
🔵 io/questdb/client/QuestDBBuilder.java 49 50 98.00%
🔵 io/questdb/client/cutlass/qwp/client/sf/cursor/BackgroundDrainerPool.java 7 7 100.00%
🔵 io/questdb/client/network/NetworkFacadeImpl.java 1 1 100.00%
🔵 io/questdb/client/cutlass/qwp/client/sf/cursor/MmapSegment.java 13 13 100.00%
🔵 io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentRing.java 7 7 100.00%
🔵 io/questdb/client/cutlass/qwp/client/QwpHostHealthTracker.java 26 26 100.00%
🔵 io/questdb/client/HttpClientConfiguration.java 1 1 100.00%
🔵 io/questdb/client/cutlass/qwp/client/sf/cursor/DefaultSenderErrorHandler.java 1 1 100.00%
🔵 io/questdb/client/impl/ConfigSchema.java 5 5 100.00%
🔵 io/questdb/client/SenderConnectionEvent.java 1 1 100.00%
🔵 io/questdb/client/SenderError.java 4 4 100.00%

@bluestreak01 bluestreak01 enabled auto-merge (squash) July 6, 2026 12:48
@bluestreak01 bluestreak01 merged commit f0b8b21 into main Jul 6, 2026
15 checks passed
@bluestreak01 bluestreak01 deleted the feat/connect-timeout branch July 6, 2026 12:49
bluestreak01 added a commit that referenced this pull request Jul 6, 2026
…er API

PR #60 made the QuestDB facade pooled-only, removing the convenience/
thread-affine methods the examples were written against: executeSql(sql,
handler), query(), newQuery(), sender(), releaseSender() and the two-arg
connect(ingest, query). After the merge of main the examples module no
longer compiled, failing both the build-jdk8 and compile-jdk25 CI jobs.

Rewrite every example against the surviving API:
- executeSql(sql, handler).await()  -> borrowQuery(); q.sql(sql).handler(h)
                                       .submit().await()
- query()/newQuery()                -> borrowQuery() (one handle per
                                       concurrent in-flight query; reuse a
                                       single handle for sequential queries)
- sender()                          -> borrowSender() (try-with-resources)
- refresh the surrounding javadoc/comments that referenced the removed
  methods.

Remove QuestDBSeparateConfigExample: it demonstrated connect(ingest, query)
/ separate ingest+query configs, which no longer exist on the facade.
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.

6 participants