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>
…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
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]
|
The test server acks every frame and commits nothing, so the benchmark could not show the commit window's effect. A real-server benchmark moves to questdb/questdb benchmarks/. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jovfer
added a commit
to questdb/questdb
that referenced
this pull request
Oct 21, 2026
Re-pin the submodule at questdb/java-questdb-client#108 after its branch merged the client's main, which moved the client version to 1.3.11-SNAPSHOT. Set questdb.client.version to match in every pom. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ll ring The WebSocket sender publishes the first frame of a tryFlush() only when the store-and-forward ring takes it within a millisecond; otherwise it returns false, publishes nothing and keeps every pending row. Once a frame is on the ring the flush completes as flush() does. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The settle step publishes through tryFlush(), so a full store-and-forward ring returns the slot at once instead of holding it for sf_append_deadline_millis, past QuestDB.close(). The housekeeper schedules the next settle from the clock after the settle, and close() tells leases the housekeeper holds apart from borrowed ones in its WARN. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
flush(), drain() and table() already throw the latched recycle failure; the pool's health probe did not see it, so an idle slot with the latch was settled and requeued forever instead of retired. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…error types Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
settleOne() decrements the settle counter in a finally that wraps its whole body; the full-ring tryFlush() test uses a TemporaryFolder, a 2 s append deadline and a 1 s bound and checks the cause before reading it; the busy-ring settle test pins that nothing is logged; Javadoc and comments are re-wrapped and the stale lazy-connect comments reworded. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
# Conflicts: # core/src/main/java/io/questdb/client/Sender.java # core/src/main/java/io/questdb/client/impl/PooledSender.java
Client 1.3.11 was released today from main without this change, so the five sentences that contrast the old flush-on-close behaviour with this release now say "before 1.3.12". The poms stay at 1.3.11-SNAPSHOT; the bump to 1.3.12-SNAPSHOT lands on main separately. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Contributor
[PR Coverage check]😍 pass : 609 / 646 (94.27%) file detail
|
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 behaviour before 1.3.12: one frame and one commit per close. A directly builtSenderPoolkeeps flushing on close; only theQuestDBfacade defaults tooff.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 ten default methods used by the pool:checkError,hasRowInProgress,pendingRowCount,rowsAppended,flushDeferred,hasDeferredFrames,markSavepoint,resetToSavepoint,autoFlushIfDue,tryFlush.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(). A rejection rolls back the whole open group, every lease since the last commit. The builder injectstransaction=onfor this mode and rejects a window withflush_on_close=off(designed, gated until measured) or withtransaction=off.Lease-scoped
reset().borrow()marks a savepoint on the delegate and the pooledreset()rolls back to it, so a borrower following theBatchTooLargeForCapExceptionremedy discards only its own rows, not those 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().Byte trigger bounded by the server cap. With
auto_flush_bytesunset, the WebSocket sender 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.Over-cap batches. The sender remembers a cap rejection until
reset(), a rollback or a later flush that passes the pre-flight check, and reports it throughcheckError(). 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. A held row that alone reaches 90 percent of the cap flushes at once, also withauto_flush_bytes=off, so a row whose own frame exceeds the cap fails on the lease that wrote it. At release the pool 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, and the settle step retires such a slot directly. Both retirements log at ERROR with the dropped row count.Housekeeper and shutdown. Housekeeper deadlines use a monotonic clock and saturate, so
housekeeper_interval_ms=Long.MAX_VALUEdisables reaping and nothing else. The settle step flushes one idle slot at a time, a failed startup recovery step is retried once a second, and an interruptedappendBlockingparks instead of spinning. The reaper skips idle slots that still hold rows or an open commit group.SenderPool.close()logs ERROR with the row count when an idle slot's delegate close fails. The settle publishes throughSender.tryFlush()and never waits for a full store-and-forward ring: a busy ring returns the slot at once and the rows wait for the next settle; a settle holds a slot pastQuestDB.close()only while frames after its first wait for ring room.checkError()also reports a failed symbol-dictionary recycle.Docs. The README, Javadoc and examples say that by default one frame carries several leases' rows, name
flush()beforeclose()andflush_on_close=onas the isolation tools, and no longer promise replay after a rejection. Comments that saidclose()flushes are corrected.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.Design decisions and trade-offs
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.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.max_lifetime_ms, until a settle succeeds; reaping it would drop the rows silently. Rows in an idle slot whose delegate close fails at shutdown are lost, as before this PR, where the lease'sclose()threw instead; they are now logged at ERROR.SenderPooldefaultsflushOnCloseto true, because nothing settles a direct pool's slots; the facade sets it fromflush_on_close.flush_on_closein a direct pool's config string is accepted and ignored, likesender_pool_max,idle_timeout_msand the other pool keys, whichConfigSchemamarks as applied by the facade only.auto_flush_bytes=offto keep its explicit-flush control flow. Downstream tests that callsetAdvertisedMaxBatchSizewith small values may need the same.tryFlush()refuses to wait only for the first frame of a flush; once a frame is on the ring the flush completes as before, so a split batch or dictionary chunks never leave a partial group behind.Testing
Wire tests cover each behaviour above, and each new test fails when its target line is mutated. The PR's test classes pass on the branch after merging
main(365 tests in 16 classes). CI runs the full suite, including the JDK 8 job for the Java 8 floor.Benchmark
QwpPoolBorrowPerRowBenchmark(questdb/questdb#7979,benchmarks/) ran against a standalone server on the same machine (macOS on Apple Silicon, local SSD, other load running): a WAL table, one thread,sender_pool_max=1. Each time runs until the server has applied every row; ranges cover two runs.Default
cairo.commit.mode(nosync), 1M rows:flush_on_close=on(previous behaviour)flush_on_close=on+ window 1000/100cairo.commit.mode=sync, 100k rows:flush_on_close=on(previous behaviour)flush_on_close=on+ window 1000/100The default ships one frame and one commit per
auto_flush_rowsbatch. The window removes the per-message commits but keeps one frame perclose(), so its gain depends on what a commit costs on the server: compared withflush_on_close=onalone, it cuts the time by about 30% withnosyncand by about 85x withsync. macOSfsyncdoes not flush the drive cache, so asynccommit typically costs more on Linux.Release note (1.3.12)
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. OnlyLineSenderServerExceptionkeeps its type at a pooledclose(); other close-time errors arrive as aLineSenderExceptionwith the original 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. The free list is LIFO, so an idle pool shrinks to its working set; setsender_pool_minequal tosender_pool_maxto keep every connection warm.Follow-ups
SenderErrorHandler; routing client-side drops there needs a client-side error category.borrow()instead of walking every buffer.atNow()throws when the rows before a crossing row still exceed the cap (see the hold-back trade-off).flush_on_close=on, with or without a commit window.🤖 Generated with Claude Code
https://claude.ai/code/session_011PpCzTucC66DiMgFiSuduj