Skip to content

feat(qwp)!: stop flushing pooled senders on close, add a commit window and a lease-scoped reset - #108

Open
jovfer wants to merge 57 commits into
mainfrom
sm_pool_flush_on_close
Open

jovfer wants to merge 57 commits into
mainfrom
sm_pool_flush_on_close

Conversation

@jovfer

@jovfer jovfer commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Tandem: questdb/questdb#7979, questdb/questdb-enterprise#1308

Why

Applications that borrow a pooled Sender per message paid one QWP frame and one server-side WAL commit per message, because the pooled close() flushed. A customer benchmark of that shape took 54 s for 1M rows against 6.5 s for the batched HTTP sender, and the server's commit rate was the ceiling. The quick start taught exactly that shape.

What changes

Close releases, flush ships, drain waits. close() on a borrowed sender now returns the slot and ships the slot's pending rows once the oldest is older than auto_flush_interval or another auto-flush threshold trips. Rows left in the slot go out when an auto-flush threshold trips on a later append or release, or when the pool's housekeeper settles the idle slot on the auto_flush_interval cadence (100 ms by default). A lease that holds the slot without appending or closing holds earlier leases' rows back until it does. Borrowing per message therefore batches exactly like a sender held for the whole run. flush() on a lease still ships now, drain() still waits for the server.

  • flush_on_close=on (builder flushOnClose(true)) restores the pre-1.4 behaviour: one frame and one commit per close.
  • The housekeeper gains a settle step on its own cadence; reaping stays on housekeeper_interval_ms. A slot nobody borrows again no longer keeps its last rows until the handle closes.
  • The free list is LIFO, so per-message borrows concentrate on hot slots and batches fill.
  • A partial row at close() is cancelled and the slot kept (the close still throws); a dead delegate is retired by an explicit probe instead of a throwing flush.
  • Close-time errors are wrapped: a LineSenderServerException keeps its type, other errors arrive as a LineSenderException whose cause is the delegate's error. A lease body that already threw keeps its exception as the primary one (previously try-with-resources failed with "Self-suppression not permitted").
  • Sender gains nine default methods used by the pool: checkError, hasRowInProgress, pendingRowCount, rowsAppended, flushDeferred, hasDeferredFrames, markSavepoint, resetToSavepoint, autoFlushIfDue.

Commit window. With flush_on_close=on, commit_rows and commit_interval_ms (builder commitRows, commitIntervalMillis; setting one defaults the other to 1000 rows / 100 ms) make each close ship a frame flagged defer-commit and the pool commit once per window: the release that fills the window ships the committing frame, and the settle commits an idle open group. Rows are in the store-and-forward ring the moment close() returns, and the server commits once per window instead of once per close. Acks arrive at the commit, so a drain() should follow a flush(). The builder injects transaction=on for this mode and rejects a window with flush_on_close=off (designed, gated until measured) or with transaction=off.

Wording trap. In this client "flush" means published to the store-and-forward ring, not acknowledged by the server. flush_on_close=on does not mean committed when close() returns.

Review round 1 fixes

  • Lease-scoped reset(). borrow() marks a savepoint on the delegate and the pooled reset() rolls back to it, so a borrower following the BatchTooLargeForCapException remedy no longer discards rows that earlier leases left in the slot. The WebSocket sender rolls every table buffer back to the mark, restores the pending counters and the batch symbol watermark, reclaims the dictionary ids the dropped rows registered (delta mode), and drops column definitions the server has not seen. A pooled sender ignores a borrower's markSavepoint(); its resetToSavepoint() is the lease-scoped reset().
  • Byte trigger bounded by the server cap. With auto_flush_bytes unset, the WebSocket sender now flushes at 90 percent of the batch cap the server advertises; an explicit off keeps byte flushing off, and a node that advertises no cap gets no byte trigger. This changes the standalone default too: a batch the server would reject ships earlier instead. Rows appended before the first handshake (async connect) carry no byte trigger; the first append after it flushes the backlog.
  • Over-cap batch. The sender remembers a cap rejection until reset(), a rollback or a later flush that passes the pre-flight check, and reports it through checkError(). At release the pool first rolls the lease back to its borrow mark and retries the flush: when the rows earlier leases left ship, the slot stays in service and the borrower gets a wrapper naming its own dropped rows. Only an inherited batch the cap still rejects retires the slot. The settle step retires such a slot directly. Both retirements log at ERROR with the dropped row count, and the pool's messages no longer repeat the standalone "call reset() and keep this sender" remedy.
  • Housekeeper. A failed startup recovery step is retried once a second instead of on the settle cadence. Deadline arithmetic saturates, so housekeeper_interval_ms=Long.MAX_VALUE disables reaping and nothing else.
  • Docs. The README no longer promises replay after a rejection. With the default one frame carries several leases' rows; the README, Javadoc and examples say so and name flush() before close() and flush_on_close=on as the isolation tools. Example and README comments that said close() flushes are corrected; license headers added to two test classes.
  • Tests. Five tests that fail when their target line is mutated, wire tests for each item above, and a wire test that pins the documented fate-sharing behaviour.

Review round 2 fixes

  • Borrow cost grew with the number of tables per sender (89ee6d9). borrow() called markSavepoint() under the pool lock, and the mark walks every table buffer. The pool now marks after unlock(). At 4 threads, the reviewer measured 151-233k borrows/s at 1,000 tables and 37-54k at 5,000 on the base, 51-53k and 13k on the head before the fix, and 211-233k and 52-54k with the mark outside the lock. The walk itself still runs on every borrow, now outside the lock and at parity with the pre-PR pool's per-close walk; see Follow-ups.
  • A closed lease's rows could stay unshipped while the next lease held the slot without appending (24be1a0). Sender.autoFlushIfDue() runs at every pooled release and ships the slot's pending rows once the oldest is older than auto_flush_interval. The documented bound now names the holding lease: a slot whose current lease neither appends nor closes still holds earlier rows (see Follow-ups).
  • A row above about 10 percent of the cap, appended after inherited rows, was rejected (f26fa8d, 7747319, c9259f8, d2bba3b, b2de8e4). A table buffer can now hold back its last committed row. When the cap rejects the auto-flush that follows a row and other rows are pending, the sender ships the rows before that row and keeps the row buffered; the path runs only where the sender used to throw. A held row that alone reaches 90 percent of the advertised cap flushes at once, also with auto_flush_bytes=off, so a row whose own frame exceeds the cap fails on the lease that wrote it instead of on a later one. A held row alone in its table keeps that table's savepoint columns, so a later reset() still drops the columns the server never saw. The "Trade-off" paragraph on the byte trigger below is updated to match.
  • The docs promised lease isolation with a commit window (b5d5fb4). The sentence now says rejection isolation holds only without a commit window, and the Sender.autoFlushBytes Javadoc states the byte-trigger default.
  • Housekeeper deadlines followed the wall clock (49bf8ae). They now use a monotonic clock, so a clock step no longer delays or bursts settles and reaping.
  • One blocked flush stalled the whole settle step (95d540f). Settle held every dirty idle slot while it flushed; it now takes one slot at a time. This is a partial fix: with sender_pool_max=1 a borrower can still wait behind a settle flush (see Follow-ups).
  • The housekeeper spun at 100 percent CPU after close (4255613). appendBlocking now parks instead of spinning when the thread is interrupted.
  • The empty-savepoint branch had no failing test, and the stored symbol watermark was 0 where -1 means "none" (df6b841). The branch stores -1, and a test fails when it does not.
  • Doc and comment corrections from the final review (b5d5fb4, b2de8e4): close() is documented as shipping only rows older than auto_flush_interval, the autoFlushIfDue() Javadoc names every threshold it runs, and comments that named the byte-trigger crossing row as the reason for the release-time retry are reworded.

Verification on the branch: the nine touched test classes pass, and the full client suite passes (3706 tests, 0 failures, 0 errors, 6 skipped). The Java 8 floor is not checked locally (no JDK 8 installed); CI's JDK 8 job covers it. Re-measured with the reviewer's probe on the head (4 threads, pass 2, two runs): 1 table 5.4-6.4M borrows/s, 1,000 tables 232-239k, 5,000 tables 53-54k. Single-threaded borrow() costs 0.03 us at 1 table, about 17 us at 1,000 and 72-74 us at 5,000, so cost still grows linearly with the table count.

Review round 3 fixes

  • Rows released into an idle slot could be dropped without an exception or an ERROR line, at shutdown or by the reaper (d8ab9be). SenderPool.close() now logs ERROR with the row count when an idle slot's delegate close fails, as retireLease already did. reapIdle() skips idle slots that still hold rows or an open commit group, so the settle keeps retrying instead of the reaper closing the slot. Trade-off: a slot whose settle keeps failing is not reaped, by idle timeout or max_lifetime_ms, until a settle succeeds. The shutdown loss itself remains: the rows are lost on the base too, which threw from the lease's close() instead.
  • A directly built SenderPool stopped shipping rows at release, and nothing settled them (d8ab9be). SenderPool now defaults flushOnClose to true. QuestDBImpl sets it from flush_on_close, so the facade default stays off. The three direct-pool test classes that earlier rounds changed to call setFlushOnClose(true) are back to their base form.
  • Two hold-back branches had no failing test (d8ab9be). New tests: one row whose own frame exceeds the cap, alone in the batch, throws and stays buffered; with transaction=on, the rows before a crossing row ship with the defer flag and only flush() commits. Each fails when its target is mutated (pendingRowCount < 2 to < 1; the retry's transactional to false).
  • The docs said a release ships only rows older than auto_flush_interval (47603c7). autoFlushIfDue() measures the age of the oldest pending row and then ships every pending row, and the row and byte thresholds apply too. The README, QuestDB, PooledSender and this description now say so.
  • Implementation notes:
    • flush_on_close in a direct pool's config string stays accepted and ignored. ConfigSchema marks pool keys as applied by the facade and ignored by the clients, and a direct pool ignores sender_pool_max, idle_timeout_ms and the other pool keys the same way. Rejecting only this key would break that rule.
    • Reporting the shutdown drop through SenderErrorHandler stays with the existing follow-up for over-cap drops, which needs the same client-side error category.

Verification: the 17 affected test classes pass (196 tests). Each new test fails against a mutant of its target line (reap skip removed, flushOnClose default false, ERROR log demoted, gate < 1, retry not transactional). The full client suite was not re-run locally.

Trade-off

With the default, a terminal rejection of one lease's row discards the other leases' rows in the same frame, after their close() calls returned, and the error surfaces on a later call on that slot, possibly to a lease that wrote none of the rejected rows. This is the HTTP sender's failure span moved across threads. The alternative, flush_on_close=on as the default, keeps one frame and one commit per message, which is the regression this PR removes.

The byte trigger fires after the row that crosses it is committed. When the cap then rejects the batch, the sender holds that row back and ships the rows before it, so a lease's valid rows no longer fail because of rows earlier leases left. Two effects remain:

  • Columns first used by a held row ride in the prefix frame, all-null, so the server creates them even if the lease then calls reset().
  • When the rows before the crossing row still exceed the cap (only with auto_flush_bytes=off or after failed flushes), atNow() throws the standalone remedy text, which tells a pooled lease to call reset(). On a lease reset() keeps the inherited rows, so the next append is rejected again until close() rolls the lease back or retires the slot.

One existing test with a 150-byte advertised cap needed auto_flush_bytes=off to keep its explicit-flush control flow. Downstream tests that call setAdvertisedMaxBatchSize with small values may need the same.

Benchmark

Manual benchmark (PoolBorrowPerRowBenchmark, @Ignored), 1M rows against the in-repo ack-all test server, which has no WAL:

configuration time data frames
held lease, default 271 ms 1000
borrow per row, default 98 ms 1000
borrow per row, flush_on_close=on (previous behaviour) 8462 ms 1,000,000
borrow per row, flush_on_close=on + window 1000/100 8335 ms 1,000,000

The frame count dominates on the client and the wire. The window's commit saving needs a real server to show; the test server commits nothing.

Release note (1.4.0)

close() on a sender borrowed from QuestDB no longer flushes; it ships the slot's pending rows only when the oldest is older than auto_flush_interval or another auto-flush threshold trips. Set flush_on_close=on to restore flushing. A directly built SenderPool still flushes on close. Rows left in a slot ship within auto_flush_interval once the slot is idle, or at the next append or close on it after that interval. Close-time errors are wrapped with the delegate's error as cause. New keys commit_rows and commit_interval_ms. reset() on a pooled sender discards only the rows appended through that lease. With auto_flush_bytes unset, the WebSocket sender flushes at 90 percent of the server's advertised batch cap.

Follow-ups

  • Settle-failure WARN flag clears only on a settle-side success.
  • Server-side QWP commit interval and per-connection schema caching (OSS), and the fourth combination gate, per the spec.
  • Documentation repository: quick start, pool and migration sections.
  • A slot retired with an over-cap batch, or an idle slot whose close fails at shutdown, logs at ERROR but does not reach SenderErrorHandler; routing client-side drops there needs a client-side error category.
  • O(1) savepoint: mark each table buffer on its first touch after borrow() instead of walking every buffer. The walk now sits outside the pool lock, but it still costs about 17 us per borrow at 1,000 tables and 72-74 us at 5,000 on one thread.
  • Settling a slot that is leased but idle. It needs a guard between the lease's row boundaries and the settle flush. Today a lease that never appends or closes still holds earlier rows.
  • A non-blocking settle. With sender_pool_max=1, a borrower can wait behind a settle flush during an outage, up to sf_append_deadline_millis.
  • A non-cap failure partway through the hold-back retry's split publish leaves the restored over-cap batch with no retained rejection until the next flush attempt.
  • A pooled-specific remedy in the rejection that atNow() throws when the rows before a crossing row still exceed the cap (see Trade-off).
  • Encoding only the columns that existed before a held row, so a rolled-back held row leaves no empty column on the server.
  • Known narrow gaps left from review:
    • In full-dictionary mode a symbol value larger than the cap stays in the dictionary after the rollback (id reclaim is delta-mode only), so the release path keeps the slot and later leases that add a new symbol fail with the "dropped N row(s)" wrapper. Retiring the slot when the rejection came from the dictionary would restart the id space.
    • In the full-dictionary chunk fallback, a non-cap failure can leave an older cap rejection latched.
    • No test drives the release-time rollback and retry through close's own flush with flush_on_close=on, with or without a commit window.
    • A node that advertises no cap gets no byte bound on the config-string path, while the programmatic path keeps 8 MiB.
    • The housekeeper wakes once a second for the life of the handle, and a settle drop logs two lines.

🤖 Generated with Claude Code

https://claude.ai/code/session_011PpCzTucC66DiMgFiSuduj

@jovfer
jovfer marked this pull request as ready for review October 14, 2026 18:21
jovfer and others added 25 commits October 15, 2026 12:10
…s to Sender

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…_on_close restores the old path

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ure test, stronger assertions

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…nal lease

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ettle slots

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…lows auto_flush_interval

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…connect-string number syntax

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…he settle cadence

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…at relied on close flushing

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ent the defaults

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…window, settle commits idle groups

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ten close() docs

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…flush_on_close=on

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… string window keys

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…minal and retriable rejections

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…urces keeps the primary exception

close() threw the delegate's latched instance, which a lease body may
already have thrown; try-with-resources then failed with a
self-suppression IllegalArgumentException. close() now throws a new
LineSenderException with the original as cause. Tests updated, terminal
test restored to try-with-resources and hardened.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…rs; tolerate a late-latched terminal in the test

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… mark on drain, typed close errors

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…skips the mark

With a window of 2 the close right after the explicit drain or flush reached
the threshold on its own, performed a no-op committing flush and marked the
slot, so the tests passed with the mark removed from the pooled drain() and
flush(). Use a window of 3, so the stale counter would commit on the third
row, and assert where the window closes after the reset.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@jovfer
jovfer force-pushed the sm_pool_flush_on_close branch from e8c74ec to 0b2d5ba Compare October 15, 2026 11:11
@jovfer jovfer changed the title feat(pool)!: close releases, flush ships, drain waits; commit window for per-message borrow feat(pool): close releases, flush ships, drain waits; commit window for per-message borrow Oct 15, 2026
@jovfer jovfer changed the title feat(pool): close releases, flush ships, drain waits; commit window for per-message borrow feat(qwp): close releases, flush ships, drain waits; commit window for per-message borrow Oct 15, 2026
…_flush_bytes is unset

The WebSocket config-string default for auto_flush_bytes was 0, which the
clamp read as an explicit opt-out, so nothing capped a batch's bytes. With
cross-lease accumulation in the pool, 1000 wide rows exceed the server's
cap and the batch cannot ship. Unset now means AUTO_FLUSH_BYTES_SERVER_CAP:
no byte trigger before the handshake, 90 percent of the advertised cap
after it, none on a node that advertises no cap. An explicit off stays off.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
jovfer and others added 7 commits October 15, 2026 16:16
…ekeeper deadlines

The two-deadline loop retried a failed recovery step on the settle cadence
(about 100 ms) instead of the 5 s reap interval, and computed now + interval
without overflow protection, so housekeeper_interval_ms=Long.MAX_VALUE made
the loop wait forever and the settle never ran. Recovery retries now have
their own one-second deadline, matching the direct pool's driver, and every
deadline saturates. Drops the unused parameter of resolveSettleIntervalMillis
and corrects the constructor Javadoc.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…etical positions

deadline() had landed between joinIgnoringCallerInterrupts and its Javadoc,
leaving that method undocumented and a dangling comment above the helper.
Also moves the retry constant and the test accessor into order and corrects
two comments.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…he retryable wrapper and the settle Error path

Each of the four tests fails when its line is mutated: markCommitted without
the age reset, flushAndGetSequence without the mark, a hard-coded
retryable=false on the close wrapper, and a settle Error that requeues
instead of retiring.

testResetAfterAFlushInsideTheLeaseDropsEveryPendingRow additionally pins the
savepointRows reset in RecordingSender.flush().

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…y; fix the examples; license headers

The README said a rejected frame makes the client replay the healthy
frames. It does not: a terminal halts the slot and a retriable replays the
whole frame. The pool docs now say that one frame carries several leases'
rows and name flush() and flush_on_close=on as the isolation tools. The
examples no longer claim close() flushes. A test pins the documented
behaviour on the wire.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…; wrap two long lines

WsExample still said that close() flushes pending rows, which is false
with the default flush_on_close=off and contradicted the class Javadoc
above it. The README's pooled-ingest example made the same claim in a
comment.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…arlier leases' rows down with it

The byte trigger fires after the crossing row is committed, so a row
larger than the 10 percent margin makes the auto-flush reject a batch that
is mostly other leases' rows. The release path used to retire the slot on
the retained rejection and drop them all. It now rolls the lease back to
its borrow mark, retries the flush, keeps the slot when the inherited rows
ship, and retires only a batch the cap still rejects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s log at ERROR, savepoint Javadoc for pooled senders

BatchTooLargeForCapException now separates its detail from the standalone
remedy ("call reset() and keep this sender"), and the pool's close wrapper
and settle log use the detail only. Slot retirements that drop rows log at
ERROR, the level the default error handler uses for data loss. The Sender
Javadoc states that a pooled sender ignores markSavepoint() and that
resetToSavepoint() on it is the lease-scoped reset(). The retained
rejection is cleared once a later flush attempt passes the pre-flight, the
housekeeper reuses the pool's one-second recovery constant, and the
cap-bound default documents the pre-handshake window.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jovfer jovfer changed the title feat(qwp): close releases, flush ships, drain waits; commit window for per-message borrow feat(pool)!: stop flushing pooled senders on close, add a commit window and a lease-scoped reset Oct 15, 2026
@jovfer jovfer changed the title feat(pool)!: stop flushing pooled senders on close, add a commit window and a lease-scoped reset feat(qwp)!: stop flushing pooled senders on close, add a commit window and a lease-scoped reset Oct 15, 2026
jovfer and others added 13 commits October 16, 2026 18:16
The mark walks every table the delegate has buffered; under the lock it
serialized all borrowers (review round 2, critical 1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Settle cannot reach a leased slot, so a lease that held the slot without
appending kept earlier leases' rows indefinitely (review round 2,
critical 2). The release now runs the age check every append runs; the
documented bound names the holding lease.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The column writer encodes the rows before it and the buffer then keeps
the held row alone, as retainInProgressRow does for the UDP sender.

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

The byte trigger fires after the crossing row is committed; with rows
inherited from earlier leases a row above a tenth of the cap made a
batch the pre-PR pool shipped unshippable (review round 2, moderate 3).
The sender now holds that row back, ships the rest and keeps it
buffered; only the path that used to throw changes.

QuestDBFlushOnCloseTest's crossing-row test asserted the old rejection
and close()'s dropped-row report; it now asserts the hold-back.
SelfSufficientFramesTest pins full-dict mode: the frame that ships the
held row must carry the dictionary again, which the delta-mode test
cannot observe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When the crossing row is the only row of its table in the batch, the
prefix frame leaves that table out, so the server has seen none of the
columns the row added. retainHeldRow() still moved the savepoint to
every current column, and a lease reset() after the hold-back kept
those columns: a later lease padded them with nulls and shipped them.
retainHeldRow() now moves the savepoint columns only when rows of the
table shipped before the held row, and otherwise keeps the count the
last mark or flush recorded.

QuestDBFlushOnCloseTest also regains a wire test of PooledSender
close()'s rollback-and-retry success branch, which the replaced
crossing-row test used to cover: an explicit over-cap flush() with
auto_flush_bytes=off, then close() drops only the lease's rows and
ships the rest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nextRow()'s per-row guard measures raw column bytes, so a row just
under the cap can still have a frame over it once the table name and
schema are added. The hold-back shipped the rows before such a row and
kept it buffered, so it outlived its lease: the next lease's flush was
rejected, and the slot was retired with the rows of that batch.

After the hold-back ships the rows before it, autoFlushAfterRow() now
runs the normal auto-flush check once for the held row, with no
further hold-back. A row at or above the byte budget would flush with
the next append anyway; flushing it now puts the rejection of its own
frame on its own at() call, so a pooled close() rolls back only that
row and the slot stays in service, as before the hold-back. A held row
below the budget keeps batching.

The column test from the previous commit now holds a 2.6 KB row, below
the budget, so its held row still stays buffered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A backward wall-clock step delayed settling idle slots by the size of
the step (review round 2, moderate 5).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Taking every dirty slot up front kept them all out of the free list
while one flush blocked on a full ring, so borrowSender() timed out with
idle slots (review round 2, moderate 6).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
parkNanos returns at once while the interrupt flag is set, so an
interrupted producer, such as the housekeeper after stop(), spun at full
CPU until the append deadline (review round 2, moderate 6). The flag is
restored on exit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Only the empty-savepoint branch of resetToSavepoint() kept watermark 0
from reaching the next flush, and no test covered it (review round 2,
moderate 7).

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

Also corrects stale comments on the byte trigger, the savepoint counters,
discardPendingBatchState and the new-slot borrow path.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A. autoFlushAfterRow post-retain recheck also flushes a held row at 90 percent of the advertised cap with auto_flush_bytes=off
B. README and QuestDB.borrowSender Javadoc: close() ships rows older than auto_flush_interval
C. Sender.autoFlushIfDue Javadoc names the row, byte and age thresholds
D. PooledSender.close Javadoc and catch comment give cap rejection sources as examples
E. testAutoFlushIfDueShipsOnlyRowsOlderThanTheInterval uses a 2000 ms interval
F. QwpTableBuffer.holdBackLastRow Javadoc covers prefix columns and symbols
G. rename wasInterrupted, wrap long Javadoc lines

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jovfer jovfer added enhancement New feature or request QWP labels Oct 16, 2026
jovfer and others added 2 commits October 17, 2026 22:46
SenderPool.close() now logs ERROR with the row count when an idle
slot's delegate close fails, as retireLease already did. Before, rows
earlier leases released into the slot were dropped with no exception
and no ERROR line.

reapIdle() skips idle slots that still hold rows or an open commit
group, so the settle keeps retrying instead of the reaper closing the
slot and dropping them.

A directly built SenderPool defaults flushOnClose to true again: it has
no housekeeper to settle released rows. QuestDBImpl sets the value from
flush_on_close, so the facade default stays off. The three older pool
test classes go back to their base form, and the release-only test
helper sets flushOnClose(false) explicitly.

Adds tests for the reap skip, the shutdown ERROR line, the direct-pool
default, a single over-cap row alone in the batch, and the defer flag
on a transactional hold-back prefix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011PpCzTucC66DiMgFiSuduj
autoFlushIfDue() measures the age of the oldest pending row and then
ships every pending row, and the row and byte thresholds apply too. The
README, QuestDB.borrowSender and PooledSender.ship Javadoc said a
release ships only rows older than auto_flush_interval.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011PpCzTucC66DiMgFiSuduj
@mtopolnik

Copy link
Copy Markdown
Contributor

[PR Coverage check]

😍 pass : 576 / 610 (94.43%)

file detail

path covered line new line coverage
🔵 io/questdb/client/impl/PoolHousekeeper.java 32 40 80.00%
🔵 io/questdb/client/Sender.java 8 10 80.00%
🔵 io/questdb/client/impl/PooledSender.java 82 91 90.11%
🔵 io/questdb/client/cutlass/qwp/client/sf/cursor/CursorSendEngine.java 18 19 94.74%
🔵 io/questdb/client/impl/SenderPool.java 125 131 95.42%
🔵 io/questdb/client/QuestDBBuilder.java 51 53 96.23%
🔵 io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java 134 139 96.40%
🔵 io/questdb/client/cutlass/qwp/protocol/QwpTableBuffer.java 94 95 98.95%
🔵 io/questdb/client/impl/ConfigSchema.java 3 3 100.00%
🔵 io/questdb/client/impl/SenderSlot.java 13 13 100.00%
🔵 io/questdb/client/cutlass/qwp/client/BatchTooLargeForCapException.java 5 5 100.00%
🔵 io/questdb/client/cutlass/qwp/client/QwpColumnWriter.java 7 7 100.00%
🔵 io/questdb/client/impl/QuestDBImpl.java 4 4 100.00%

@jovfer

jovfer commented Oct 19, 2026

Copy link
Copy Markdown
Contributor Author

PR #108 tandem review, round 4 (level 3)

Reviewed at level 3, tandem mode, as one logical change across three PRs:

PR Head Base
#108 47603c71 70a7f853 (round 3 ended at b2de8e4f)
questdb/questdb#7979 1f84849722 69796aab30
questdb/questdb-enterprise#1308 3087dda29 73dd5c863

Verdicts:

Every behavioural finding below was executed on both the head and the base revision.

  • PR metadata:
    • All three titles follow the convention and pass the title check.
    • Labels are set: enhancement, tandem, QWP on feat(qwp)!: stop flushing pooled senders on close, add a commit window and a lease-scoped reset #108; tandem, non-breaking-tandem, QWP on #7979; tandem, QWP on #1308.
    • The OSS body's claim holds. The base version of testTwoSendersPerTableWhileReaderThreadStreamsTwoQueries fails against the new client with txn timed out [table=alpha, expectedTxn=2, writerTxn=1], and the PR version passes.
    • The Enterprise PR body's claims about its tests hold.
  • Submodules:
  • CI:
    • Client and Enterprise CI are green.
    • The OSS Azure test pipeline did not run on draft #7979, so no OSS test has run against the new client in CI.
    • A local run of the OSS QWP ingress, QWP e2e, WebSocket and facade test classes against the head client passed: 594 tests, 0 failures, 1 skipped.

Critical

None.

Moderate

1. [client] QuestDB.close() returns while the settle step still holds a slot blocked on a full ring

  • Problem: a settle flush blocked on a full store-and-forward ring outlives QuestDB.close().
  • Net impact: pool users who close the handle while the SF ring is full. The slot's lock and connection stay held for up to about 111 s after close() returns.
  • Evidence: probe at 47603c71 and 70a7f853, all timeouts at their defaults, ring capped at 128 KiB, server never acks:
    • Head: the lease's close() returned in 0 ms. QuestDB.close() returned after 9,009 ms. An immediate QuestDB.connect on the same sf_dir failed with SlotLockContentionException: sf slot already in use by another process [... default-0, holder=pid=<own pid>]. The lock was released 110,981 ms after close() returned.
    • Base: the lease's close() blocked 120,001 ms and threw cursor SF append failed. QuestDB.close() then returned in 0 ms, and the immediate re-open succeeded.
    • The same result holds after a real outage (server closed after connect), and in two other probes with shorter deadlines.

Trigger:

  • Facade default flush_on_close=off, with sf_dir set.
  • The server is down or not acking long enough to fill sf_max_total_bytes (10 GiB by default with sf_dir, 128 MiB in memory).
  • A lease closes with rows still in the slot. The housekeeper's settle takes the slot and blocks in CursorSendEngine.appendBlocking for up to sf_append_deadline_millis (30 s).
  • The application calls QuestDB.close(), then reconnects in the same process.

Path:

  • SenderPool.settleIdle() (SenderPool.java:1769-1816) takes the slot off the free list and flushes it outside the lock. appendBlocking deliberately does not stop on an interrupt.
  • QuestDBImpl.close() (QuestDBImpl.java:237) first runs PoolHousekeeper.stop(): a 2 s join, an interrupt, then another 2 s join.
  • SenderPool.close() then waits min(acquire_timeout_ms, 5 s) for the internal lease (SenderPool.java:1626). It logs "1 borrowed sender lease(s) still outstanding ... or leaks if it never is", and returns.
  • Later, on the housekeeper, the append fails. requeueSettled sees closed (:1997) and calls retireLease, so delegate.close() appends again (another 30 s) and then drains for up to close_flush_timeout_millis (60 s).

What the application sees on head:

  • close() returns normally, yet a re-open on the same sf_dir fails until the housekeeper finishes.
  • The close-time WARN describes the internal settle lease as a borrowed lease that may leak, although the application closed every lease.
  • The dropped rows are reported only by an ERROR on the housekeeper about 100 s after close() returned ("slot 0 retired with 1 unshipped row(s); delegate close failed"). If the JVM exits first, that line is never written. Base threw on the lease's close().
  • In memory mode there is no slot lock, but the delegate's I/O thread and native buffers still outlive close() (4.5 s close, delegate still open, in a separate probe).

Base comparison:

  • For the same steps, base returns from QuestDB.close() with the slot released, and re-open works.
  • Base can show the same symptom through other triggers:
    • An idle reap that is closing a delegate on the housekeeper when close() runs. This was reproduced on both revisions: close() took 4,015 ms, the re-open failed, and the lock was released about 10.8 s later.
    • A startup-recovery drain.
  • What this PR adds is a deterministic path. A single thread that runs lease.close(); db.close(); and then reconnects hits it every time once the ring is full.

Why Moderate, not Critical:

  • The rows are lost on both revisions.
  • The trigger needs a full ring.
  • The kernel releases the lock at process exit.
  • The window is bounded at about two minutes with defaults.

Suggested fix:

  • Make the settle flush non-blocking: one append attempt, and on backpressure requeue the slot and retry on the next tick. This is the "non-blocking settle" follow-up the PR already lists, and it also removes the borrower wait behind a settle. A settle then never holds a slot across close(), and close()'s idle loop closes the delegate on the caller's thread, as on base.
  • PoolHousekeeper.runLoop sets nextSettleAt from a now read before settleIdle() runs (PoolHousekeeper.java:246-251). After a 30 s settle the next one therefore starts with no wait. Read the clock after the settle.
  • At minimum, report the internal lease separately from borrowed leases in the close-time WARN. Also state in the QuestDB.close() Javadoc that a settle in progress can outlive it.

2. [OSS, cross-repo] testElasticPoolGrowsUnderBurstAndShrinksWhenIdle is flaky with the new client

  • Problem: the test assumes one WAL transaction per pooled close().
  • Net impact: OSS CI. Under CPU load about one run in three fails once #7979 pins the new client.
  • Evidence: the unmodified test, at OSS 1f84849722:
    • In a 40-iteration loop under CPU load (14-core Mac, 16 busy processes):
      • Head client 47603c71: 13 of 40 failed with txn timed out [table=el, expectedTxn=4, writerTxn=3] (10 runs) or writerTxn=2 (3 runs).
      • Base client 70a7f853: 0 of 40 failed.
    • Without load, in a warm-JVM loop:
      • Head: 6 of 100 iterations reused a slot, and all 6 ended at 3 transactions.
      • Base: 2 of 100 reused a slot, and both still reached 4.
    • Forced reuse (threads 1-3 wait until thread 0's lease has closed): head failed, base passed.

Details:

  • The test is at core/src/test/java/io/questdb/test/client/QuestDBFacadeE2ETest.java:838-910; #7979 does not touch it.
  • Four latch-released threads each borrow a sender, write one row and close it. The comment at :884 says "Each of the 4 burst senders flushes once on close -> 4 WAL txns", and :886 then waits for awaitTxn("el", 4).
  • With the new client, a release ships nothing until a threshold trips or the settle runs (PooledSender.java:595-599). borrow() hands out the most recently returned slot (SenderPool.java:1252).
  • A burst thread that borrows after another thread's lease has come home therefore appends to that slot. The settle ships both rows in one frame (server log: seqTxn=2, rowLo=0, rowHi=2), the table reaches 3 transactions, and awaitTxn throws after 15 s.
  • On base every close flushed, so a reused slot still produced 4 frames.
  • This test never ran in CI against the new client, because OSS Azure did not run on #7979.

Suggested fix:

  • Call s.flush() inside the burst lease before it closes, the same remedy #7979 applied to testTwoSendersPerTableWhileReaderThreadStreamsTwoQueries.
  • Correct the comment at :884.
  • Waiting on the row count instead of the transaction count would also work.

Minor

  • [client] Stale PooledSender Javadoc. The class Javadoc (PooledSender.java:50-52, "by default, ships nothing") and the close() Javadoc (:150-154, "By default nothing is shipped") still say that a release ships nothing.
    • The ship() Javadoc, corrected in round 3 (:576-578), says a release ships every pending row once the oldest is older than auto_flush_interval or another threshold trips.
    • A directly built SenderPool flushes on close by default (SenderPool.java:258-260).
    • Suggested: reuse the ship() wording and name the direct-pool default.
  • [OSS] Stale bytes=0 in a test comment. QuestDBFacadeE2ETest.java:700 still reasons with "rows=1000 (> both row counts) and bytes=0". With the new client, an unset auto_flush_bytes means 90 percent of the advertised cap, not 0.
    • The conclusion (exactly two flushes per table) still holds, because the batches are a few KB.
    • #7979 edited the lines on either side of this one.

Coverage map

The test gate passes. There are no admitted coverage gaps.

  • Round-3 tests: mutation runs at 47603c71 turn at least one test red for each of these mutations:
    • removing the reapIdle guard;
    • removing the SenderPool.close() ERROR log;
    • setting the direct-pool default flushOnClose to false;
    • removing QuestDBImpl's setFlushOnClose(...);
    • changing the pendingRowCount < 2 gate to < 1;
    • making the hold-back retry non-transactional.
  • OSS facade fix: a working regression test. The base version of the test is red against the new client and the PR version is green.

Summary

  • Verdicts:
  • Severity: 0 Critical, 2 Moderate, 2 Minor.
  • Where the findings sit:
    • Moderate 1 is in-diff: the new settle step meets the unchanged close budgets.
    • Moderate 2 is out-of-diff breakage: an unchanged OSS test is broken by the client contract that #7979 pins.
    • The Minor items are in-diff: Javadoc from this PR, and an OSS comment block that #7979 edited.
  • Trade-off: Moderate 1 is the cost of moving the close-time flush off the borrower's thread. With a full ring, base blocked the borrower's close() for up to two minutes and then threw.
  • Store-and-forward contract:
    • The pool's new calls on borrower and housekeeper threads (checkError(), autoFlushIfDue(), the settle flush()) surface only latched terminal errors and cap rejections, the same set base surfaced through the close-time flush().
    • A settle that fails transiently requeues the slot instead of retiring it.
    • No reconnect budget or new terminal path was added.
  • Submodule provenance:
    • Enterprise questdb: OFF-DEFAULT, in scope, except the upstream commit 69796aab30.
    • OSS java-questdb-client: OFF-DEFAULT, in scope, except the upstream commits 21e32683 and 70a7f853.
  • Callsite analysis covered:
    • every OSS and Enterprise consumer of the client:
      • the OSS facade E2E tests;
      • the OSS QWP e2e tests, including the tight-cap and oversize-row tests, under the new 90 percent byte trigger;
      • the Enterprise consumers in the tandem PR;
    • the QuestDBImpl constructor and direct SenderPool callers (none outside the client).

Artifacts

Probe sources and run logs for every finding, on head and base, are kept locally and are available on request.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request QWP tandem

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants