Skip to content

fix(qwp)!: make sf_max_total_bytes cap the whole sender pool and close sender connections cleanly - #104

Merged
bluestreak01 merged 2 commits into
mainfrom
vi_qwp_clean_close
Oct 5, 2026
Merged

bluestreak01 merged 2 commits into
mainfrom
vi_qwp_clean_close

Conversation

@bluestreak01

@bluestreak01 bluestreak01 commented Oct 4, 2026 •

Copy link
Copy Markdown
Member

Tandem: questdb/questdb#7736 (submodule bump), questdb/questdb-enterprise#1258

Breaking change: sf_max_total_bytes now caps the store-and-forward data of a whole sender pool instead of each sender in it. A pool of N senders reaches the cap after buffering 1× the setting rather than N×: the default pool of 4 buffers at most 128 MiB instead of 512 MiB in memory mode, and 10 GiB instead of 40 GiB with sf_dir. Once the pool reaches the cap, appends block until acknowledgements free space, and throw after sf_append_deadline_millis (30 s by default). To keep the old headroom, multiply sf_max_total_bytes by sender_pool_max. A standalone Sender is unaffected.

This PR carries two fixes for pooled QWP senders, one commit each:

  1. A sender close now reaches the server as a clean close instead of looking like a dropped connection.
  2. sf_max_total_bytes now caps the buffered data of a whole sender pool instead of each sender in it. This is the breaking change described above.

1. Close sender connections cleanly

A sender close never reached the server as a clean close. CursorWebSocketSendLoop.close() shut the socket down (closeTraffic(), i.e. shutdown(SHUT_RDWR)) right after unparking the I/O thread. The thread's exit path then wrote its WebSocket CLOSE frame and TLS close_notify into the dead socket, and both writes failed silently. So the server saw every sender close as a dropped connection. That includes the pool's idle reaping and lifetime recycling. Over TLS that is an unclean shutdown (unexpected end of file or a connection reset), which TLS servers log as an error, once per close.

QwpQueryClient was not affected: it joins its I/O thread before it closes the socket.

Change

close() now gives a live I/O thread up to 100 ms (DEFAULT_CLOSE_GRACEFUL_STOP_MILLIS) to stop on its own. Its exit path closes the client in order, first the CLOSE frame and then the close_notify, before it counts down the shutdown latch. So in the normal case nothing is left to break.

What stays the same:

  • Stuck I/O thread. A thread still running after the window, stuck in a native send or receive (say, a server that stopped reading), has its traffic broken exactly as before, so close() still cannot hang on it.
  • Closes during an outage. A connect walk blocked on a published in-flight client or a credential pull skips the window and is cancelled at once (ConnectCancellation.isConnectInFlight()). Reconnect backoff still wakes on close()'s unpark, so closes during an outage are not delayed.
  • Interrupts and the backstop timeout. An interrupt during the window ends it early and the flag stays set. Traffic is then broken, and the backstop await takes its usual failed-stop branch.

Cost

A normal close costs nothing extra: the wait ends as soon as the I/O thread finishes. Over 200 TLS sender closes against a server, after the ACK arrived, p50 was about 285 µs before and 275 µs after. p90 was about 390 µs in both. The only slower case is a stuck I/O thread, which now gets up to 100 ms before its traffic is broken, where before it got none.

Test plan

  • New CursorWebSocketSendLoopGracefulCloseTest:
    • testCloseLetsIdleWorkerCloseClientBeforeBreakingTraffic fails without the fix (close() calls closeTraffic() on an idle worker) and passes with it.
    • testCloseCancelsInFlightConnectWithoutWaitingOutGracefulStop times out if the in-flight-connect skip is removed.
  • Existing close-path tests pass unchanged, including CursorWebSocketSendLoopBlockedSendCloseTest, which still sees the closer break a stuck send.
  • Full client suite with this fix alone: 3471 tests, 0 failures, 5 skipped (JDK 25).
  • End to end against a TLS server: a pooled-sender test saw one unclean close per sender close before this change (4 per pool of 4, 10 under connection churn) and none after, stable over 3 runs.

2. Make sf_max_total_bytes cap the whole sender pool

Every sender the pool built got its own sf_max_total_bytes budget. A server that stopped acknowledging therefore grew the client's native memory by the cap times the pool size: about 133 MiB per sender with the 128 MiB default, 2.1 GiB for sender_pool_max=16. Users size the pool for concurrency, not memory, so a large pool could run them out of RAM.

Change

The pool hands one SegmentBudget to every sender it builds, live and recovery delegates alike. Each sender's SegmentManager charges its segments to that budget, so sf_max_total_bytes caps the pool as a whole.

  • Last segment. SegmentManager charges a spare in one atomic check-and-charge before it provisions it, so two managers can never both take the budget's last segment.
  • Minimum working set. Each sender always gets two segments (2 × sf_max_segment_bytes, 8 MiB by default), so a sibling stuck on a dead connection cannot starve it. These working sets are the only way the pool's segments can exceed the cap.
  • Unchanged paths. A standalone Sender keeps a private budget and behaves as before. Background orphan drainers keep their own cap.
  • Docs. The storeAndForwardMaxTotalBytes Javadoc claimed the 128 MiB default applies with sf_dir too; it is 10 GiB there, and the Javadoc now says so. The README documents the pool-wide cap.

Effect and cost

With the server down, 16 pooled senders now buffer 206 MiB instead of 2126 MiB. What remains above the cap is per-connection buffers outside the segment ring.

The pool's senders now share one lock in SegmentBudget. A sender takes it when it provisions or releases a segment, not per row.

Test plan

  • New SegmentManagerSharedBudgetTest covers the cap across managers, the minimum working set, and a concurrent run that must release every charge.
  • New SenderPoolSharedBudgetTest measures native memory for a pool writing to a server that never acknowledges. With the pool's budget wiring removed it fails at 3.6 MiB of growth against a 1 MiB budget.

Both fixes together

  • CursorWebSocketSendLoop*, SegmentManager*, CursorSendEngine* and SenderPool* suites: 329 tests, 0 failures (JDK 25).
  • Full client suite: 3475 tests, 0 failures, 5 skipped (JDK 25).
  • Main and test sources compile on JDK 8 (java8 profile).

…cket first

CursorWebSocketSendLoop.close() shut the socket down (closeTraffic) right
after unparking the I/O thread, so the thread's exit path wrote its
WebSocket CLOSE frame and TLS close_notify into a dead socket. Every
sender close, including pool idle reaping and lifetime recycling, looked
like a dropped connection to the server, which logs those over TLS.

close() now gives a live I/O thread up to 100 ms to stop on its own and
close its client in order. Only a thread still running after that, stuck
in a native send or receive, has its traffic broken, exactly as before.
A connect walk blocked on a published in-flight client or a credential
pull is cancelled at once, so outage-time closes are not delayed. The
normal close path takes no longer than before.
Every sender the pool built got its own sf_max_total_bytes budget, so a
server that stopped acknowledging grew the client's native memory by the
cap times the pool size: about 133 MiB per sender with the 128 MiB
default, 2.1 GiB for sender_pool_max=16. Users size the pool for
concurrency, not memory, and a large pool ran them out of RAM.

The pool now hands one SegmentBudget to every sender it builds, live and
recovery delegates alike, and each sender's SegmentManager charges its
segments to it, so sf_max_total_bytes caps the pool as a whole. A spare
is charged in one atomic check-and-charge before it is provisioned, so
two managers can never both take the budget's last segment. Each sender
still always gets its minimum working set of two segments, so a sibling
stuck on a dead connection cannot starve it. A standalone Sender keeps a
private budget and behaves as before.

With the server down, 16 pooled senders now buffer 206 MiB instead of
2126 MiB. What remains above the cap is per-connection buffers outside
the segment ring.

This changes an explicit sf_max_total_bytes in a pool config from a
per-sender to a per-pool limit, and the default pool of 4 now buffers at
most 128 MiB rather than 512 MiB. With sf_dir, the 10 GiB default is
shared the same way. Background orphan drainers keep their own cap.

Also corrects the storeAndForwardMaxTotalBytes Javadoc, which claimed
the 128 MiB default applies with sf_dir too; it is 10 GiB there.

SegmentManagerSharedBudgetTest covers the cap across managers, the
minimum working set, and a concurrent run that must release every
charge. SenderPoolSharedBudgetTest measures native memory for a pool
writing to a server that never acknowledges; with the pool's budget
wiring removed it fails at 3.6 MiB of growth against a 1 MiB budget.
bluestreak01 added a commit to questdb/questdb that referenced this pull request Oct 4, 2026
Re-points java-questdb-client from 3fc81136 to 74fb1f71, the head of
the client's vi_qwp_clean_close branch
(questdb/java-questdb-client#104). On top of the clean sender close,
that branch now carries "make sf_max_total_bytes cap the whole sender
pool".

The sender pool hands one SegmentBudget to every sender it builds, so
sf_max_total_bytes caps the store-and-forward memory of the pool as a
whole rather than of each sender. A server that stops acknowledging no
longer grows the client's native memory by the cap times the pool
size. An explicit sf_max_total_bytes in a pool config changes from a
per-sender to a per-pool limit.
@mtopolnik

Copy link
Copy Markdown
Contributor

[PR Coverage check]

😍 pass : 99 / 109 (90.83%)

file detail

path covered line new line coverage
🔵 io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentManager.java 33 40 82.50%
🔵 io/questdb/client/Sender.java 16 18 88.89%
🔵 io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentBudget.java 28 29 96.55%
🔵 io/questdb/client/cutlass/qwp/client/sf/cursor/CursorSendEngine.java 5 5 100.00%
🔵 io/questdb/client/impl/SenderPool.java 6 6 100.00%
🔵 io/questdb/client/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoop.java 11 11 100.00%

@bluestreak01 bluestreak01 changed the title fix(qwp): close sender connections cleanly instead of breaking the socket first fix(qwp): close sender connections cleanly and make sf_max_total_bytes cap the whole sender pool Oct 4, 2026
@bluestreak01 bluestreak01 changed the title fix(qwp): close sender connections cleanly and make sf_max_total_bytes cap the whole sender pool fix(qwp)!: make sf_max_total_bytes cap the whole sender pool and close sender connections cleanly Oct 4, 2026
@bluestreak01

Copy link
Copy Markdown
Member Author

Reviewing PR questdb/questdb-enterprise#1258 at level 3 in tandem mode (ENT questdb/questdb-enterprise#1258 and OSS questdb/questdb#7736). As you asked, I also reviewed the client branch #104. The diff gets no Critical findings and both gates pass. There is one Moderate documentation issue in the client and three Minor items.

Critical

None.

Moderate

1. [CLIENT] The docs understate how far a pool can go over sf_max_total_bytes

  • Problem: The docs say two segments per sender is the only overshoot.
  • Net impact: Store-and-forward (SF) pools restarting over a backlog get the overshoot and producer headroom wrong.
  • Evidence: Static, at commit 74fb1f7. SegmentManager.register charges recovered bytes with no cap check, and the SegmentBudget Javadoc says so itself.

Where the claim appears:

  • README.md:559-561: "The only overshoot is the minimum working set every live sender keeps."
  • SenderPool.java:75-80 and QuestDBBuilder.java:403-407.
  • The same wording in the Sender.storeAndForwardSharedBudget Javadoc and the Javadoc of the shared-budget CursorSendEngine constructor.

What the code does:

  1. A pooled sender or a startup-recovery delegate can adopt a slot that already holds data (SenderPool.java:2104, :2127 → newCursorEngine → recovery in CursorSendEngine).
  2. That ring is registered with budget.charge(ring.totalSegmentBytes()) (SegmentManager.java:685, :713), with no cap check.
  3. SegmentBudget.java:43-47 says recovered bytes are "charged even when they exceed it."
  4. Until acks bring the budget back under the cap, tryCharge refuses every spare and live senders run on the two-segment floor.

When it matters:

Base comparison doesn't apply: the text and the shared budget are both new. An independent check found nothing that limits the charge.

Fix: add one sentence to the README and these Javadocs: "Data recovered from sf_dir at startup is charged in full, even above the cap. New spares are refused until acks bring it back under the cap."

Minor

2. [CLIENT] A comment points to a method this PR removed

  • Problem: CursorSendEngine.java:826 says "see sideFileBytesLocked()", which no longer exists.
  • Net impact: Developer-facing only. The reason the gauge must stay wait-free is hard to find.
  • Evidence: The only match for that name in the head tree is this comment. The method lived in SegmentManager at base.
  • Note: The comment line itself is unchanged, so this counts as out-of-diff breakage.
  • Fix: Point it at SegmentBudget.sideFileBytes() and the gauge comment on SegmentBudget's sideFileGauges field.

3. [ENT] QwpFacadeTlsTest comments still describe a per-sender cap

  • Problem: Lines 85 and 130-132 describe the old per-sender sf_max_total_bytes cap.
  • Net impact: Developer-facing only. The memory assertion at line 556 is about 4× looser than the real contract.
  • Evidence: The facade's pool shares one budget at the pinned client (independent check confirmed this). In my local run the outage scenario peaked at about 155 MiB against the 640 MiB bound.
  • Fix: Reword the comments. Optionally tighten the bound to roughly cap + senderPoolMax × (2 segments + overhead). The 13 System.out.println diagnostics could also move to the test log.

4. PR metadata

Coverage map

Test gate: pass. Admitted coverage gaps: 0.

Tests I ran locally (macOS arm64, JDK 25):

  • Client at 74fb1f7: the GracefulClose, SharedBudget, PoolSharedBudget and BlockedSendClose suites, 8/8 green.
  • Client regression checks, both pass with the fix and fail without it:
    • Removing the graceful-stop guard makes testCloseLetsIdleWorkerCloseClientBeforeBreakingTraffic fail with expected:<0> but was:<1>.
    • Removing the in-flight skip makes testCloseCancelsInFlightConnectWithoutWaitingOutGracefulStop time out at 30 s.
  • ENT at 19689f31c: built with OSS 0ff184a6 and client 74fb1f7 via -P local-client.
    • TlsPeerDisconnectTest: 3/3 green.
    • QwpFacadeTlsTest: 6/6 green, with 0 unclean TLS closes in every scenario, including 14 connections under churn.

Summary

Logs and the review context are in /tmp/rv1258/. I've removed the scratch worktrees, and the primary working trees are untouched.

@bluestreak01 bluestreak01 added the QUEUED FOR MERGE Approved PR in the merge queue. Do not merge master into this PR. label Oct 4, 2026
@bluestreak01
bluestreak01 merged commit a7e7db3 into main Oct 5, 2026
22 checks passed
@bluestreak01
bluestreak01 deleted the vi_qwp_clean_close branch October 5, 2026 12:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

QUEUED FOR MERGE Approved PR in the merge queue. Do not merge master into this PR. QWP

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants