Repository navigation
Conversation
jovfer
marked this pull request as ready for review
October 14, 2026 18:21
…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
force-pushed
the
sm_pool_flush_on_close
branch
from
October 15, 2026 11:11
e8c74ec to
0b2d5ba
Compare
…_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>
…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>
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>
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
Contributor
[PR Coverage check]😍 pass : 576 / 610 (94.43%) file detail
|
Contributor
Author
PR #108 tandem review, round 4 (level 3)Reviewed at level 3, tandem mode, as one logical change across three PRs:
Verdicts:
Every behavioural finding below was executed on both the head and the base revision.
CriticalNone. Moderate1. [client]
|
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Tandem: questdb/questdb#7979, questdb/questdb-enterprise#1308
Why
Applications that borrow a pooled
Senderper message paid one QWP frame and one server-side WAL commit per message, because the pooledclose()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 thanauto_flush_intervalor 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 theauto_flush_intervalcadence (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(builderflushOnClose(true)) restores the pre-1.4 behaviour: one frame and one commit per close.housekeeper_interval_ms. A slot nobody borrows again no longer keeps its last rows until the handle closes.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.LineSenderServerExceptionkeeps its type, other errors arrive as aLineSenderExceptionwhose 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").Sendergains nine default methods used by the pool:checkError,hasRowInProgress,pendingRowCount,rowsAppended,flushDeferred,hasDeferredFrames,markSavepoint,resetToSavepoint,autoFlushIfDue.Commit window. With
flush_on_close=on,commit_rowsandcommit_interval_ms(buildercommitRows,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 momentclose()returns, and the server commits once per window instead of once per close. Acks arrive at the commit, so adrain()should follow aflush(). The builder injectstransaction=onfor this mode and rejects a window withflush_on_close=off(designed, gated until measured) or withtransaction=off.Wording trap. In this client "flush" means published to the store-and-forward ring, not acknowledged by the server.
flush_on_close=ondoes not mean committed whenclose()returns.Review round 1 fixes
reset().borrow()marks a savepoint on the delegate and the pooledreset()rolls back to it, so a borrower following theBatchTooLargeForCapExceptionremedy 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'smarkSavepoint(); itsresetToSavepoint()is the lease-scopedreset().auto_flush_bytesunset, the WebSocket sender now flushes at 90 percent of the batch cap the server advertises; an explicitoffkeeps 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.reset(), a rollback or a later flush that passes the pre-flight check, and reports it throughcheckError(). 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_interval_ms=Long.MAX_VALUEdisables reaping and nothing else.flush()beforeclose()andflush_on_close=onas the isolation tools. Example and README comments that saidclose()flushes are corrected; license headers added to two test classes.Review round 2 fixes
borrow()calledmarkSavepoint()under the pool lock, and the mark walks every table buffer. The pool now marks afterunlock(). 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.Sender.autoFlushIfDue()runs at every pooled release and ships the slot's pending rows once the oldest is older thanauto_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).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 laterreset()still drops the columns the server never saw. The "Trade-off" paragraph on the byte trigger below is updated to match.Sender.autoFlushBytesJavadoc states the byte-trigger default.sender_pool_max=1a borrower can still wait behind a settle flush (see Follow-ups).appendBlockingnow parks instead of spinning when the thread is interrupted.close()is documented as shipping only rows older thanauto_flush_interval, theautoFlushIfDue()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
SenderPool.close()now logs ERROR with the row count when an idle slot's delegate close fails, asretireLeasealready 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 ormax_lifetime_ms, until a settle succeeds. The shutdown loss itself remains: the rows are lost on the base too, which threw from the lease'sclose()instead.SenderPoolstopped shipping rows at release, and nothing settled them (d8ab9be).SenderPoolnow defaultsflushOnCloseto true.QuestDBImplsets it fromflush_on_close, so the facade default stays off. The three direct-pool test classes that earlier rounds changed to callsetFlushOnClose(true)are back to their base form.transaction=on, the rows before a crossing row ship with the defer flag and onlyflush()commits. Each fails when its target is mutated (pendingRowCount < 2to< 1; the retry'stransactionaltofalse).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,PooledSenderand this description now say so.flush_on_closein a direct pool's config string stays accepted and ignored.ConfigSchemamarks pool keys as applied by the facade and ignored by the clients, and a direct pool ignoressender_pool_max,idle_timeout_msand the other pool keys the same way. Rejecting only this key would break that rule.SenderErrorHandlerstays 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,
flushOnClosedefault 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=onas 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:
reset().auto_flush_bytes=offor after failed flushes),atNow()throws the standalone remedy text, which tells a pooled lease to callreset(). On a leasereset()keeps the inherited rows, so the next append is rejected again untilclose()rolls the lease back or retires the slot.One existing test with a 150-byte advertised cap needed
auto_flush_bytes=offto keep its explicit-flush control flow. Downstream tests that callsetAdvertisedMaxBatchSizewith 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:flush_on_close=on(previous behaviour)flush_on_close=on+ window 1000/100The 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 fromQuestDBno longer flushes; it ships the slot's pending rows only when the oldest is older thanauto_flush_intervalor another auto-flush threshold trips. Setflush_on_close=onto restore flushing. A directly builtSenderPoolstill flushes on close. Rows left in a slot ship withinauto_flush_intervalonce 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 keyscommit_rowsandcommit_interval_ms.reset()on a pooled sender discards only the rows appended through that lease. Withauto_flush_bytesunset, the WebSocket sender flushes at 90 percent of the server's advertised batch cap.Follow-ups
SenderErrorHandler; routing client-side drops there needs a client-side error category.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.sender_pool_max=1, a borrower can wait behind a settle flush during an outage, up tosf_append_deadline_millis.atNow()throws when the rows before a crossing row still exceed the cap (see Trade-off).flush_on_close=on, with or without a commit window.🤖 Generated with Claude Code
https://claude.ai/code/session_011PpCzTucC66DiMgFiSuduj