Skip to content

feat(qwp): recycle the symbol dictionary at a threshold to lift the cardinality cap - #91

Open
jovfer wants to merge 117 commits into
mainfrom
qwp-dict-recycle-r1
Open

jovfer wants to merge 117 commits into
mainfrom
qwp-dict-recycle-r1

Conversation

@jovfer

@jovfer jovfer commented Aug 19, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #80

Tandem:

The WebSocket sender no longer has a hard symbol-cardinality stop. When its global symbol dictionary reaches a configurable threshold (symbol_dict_reset_threshold, default 100 000), the sender recycles its send stack at the next provably-safe instant — all published frames acknowledged, no row in progress, no open transaction: it closes the send loop, retires the store-and-forward slot with the existing fully-drained close (emptying it), starts a fresh dictionary epoch on the same slot, rebuilds the engine through the same code path build() uses, and reconnects. The server needs no change: supported servers (10.x+) already clear per-connection dictionary state on disconnect, and this branch pins that behavior as a wire contract. Zero wire change, zero on-disk format change; all production code is client-side.

User-visible surface

Three connect-string keys / builder methods — symbol_dict_reset (default on), symbol_dict_reset_threshold, symbol_dict_reset_max_wait_millis (bounded wait for a starved reset; default 2000, at most one wait per armed window; 0 makes the recycle opportunistic-only); an advisory Sender.resetSymbolDictionary() (no-op on non-QWP transports; producer-thread-only, and PooledSender forwards it to the live delegate); observability getters on QwpWebSocketSender (getSymbolDictEpoch, getSymbolDictResetStarvationTimeouts, isResetArmed, isSymbolDictResetEnabled, getSymbolDictResetThreshold, getSymbolDictResetMaxWaitMillis). Externally visible frame sequence numbers stay strictly monotone across recycles via an epoch-base offset applied on every public surface (flush sequence, ack watermark, drain, progress stream, error spans). The client-side dictionary cap rises 1M → 2M (now equal to the server cap, with an OSS ordering test), and the cap error now points at the reset valve. A recycle also naturally heals a mid-life full-dict degrade (the fresh engine re-derives delta capability). Senders built via the public connect(...) overloads (no rebuild factory) and senders that don't own their engine never recycle and never arm, so isResetArmed() cannot read true against a permanently-zero resets counter.

Release-note items:

  • CursorWebSocketSendLoop's policy-aware constructor and QwpWebSocketSender.connectWithCredentialSupplier(...) each gain their recycle parameters as a new overload; the 13-argument constructor shipped in 1.3.x and the 23-argument connectWithCredentialSupplier present at the merge base (on main since the OIDC device-flow change, not in any release) are retained (delegating with externalFsnBase = 0 and the recycle defaults respectively), and ExportedApiCompatibilityTest pins every public CursorWebSocketSendLoop constructor present in the published 1.3.9 jar plus both connectWithCredentialSupplier overloads present at the merge base, copied from javap output rather than derived from the current classes. An exported-signature diff of this head against the merge base shows no other removed public or protected member (against the 1.3.9 jar: 18 lines, all synthetic Outer$1 anonymous-class artifacts already absent at the merge base). QwpWebSocketSender.connect(...) is additive: a new overload carries the recycle settings and every pre-existing overload is unchanged.
  • The per-connection counters (getTotalFramesSent, getTotalFramesReplayed, getTotalReconnectAttempts, getTotalReconnectsSucceeded, getTotalServerErrors, getTotalAcks, getTotalBackpressureStalls) are sender-lifetime: the sender owns one counter holder that every rebuilt I/O loop and every attached engine adopts before use, so the values carry across recycles and are retained after close(). A recycle's own reconnect counts as one attempt and one success.
  • Servers released before QuestDB 10.0.0 cap the symbol dictionary at 1M, and QWP has no wire-level negotiation of the limit. Only 10.0.0+ servers are supported; note that the re-arm floor lets a default sender's dictionary grow to 1M entries per epoch, so this is not an edge case of exotic configuration.
  • getAckedFsn()'s contract is restated on the Sender interface: on a live sender it never collapses back to −1 and keeps reporting the last durable watermark across a recycle.
  • markEverConnected() is a new public method on CursorWebSocketSendLoop (loop-side seeding of the sender-lifetime ever-connected flag; QwpWebSocketSender calls it when it rebuilds the loop).
  • symbol_dict_reset_threshold accepts 1..1,000,000 (half the protocol cap, the re-arm floor's own ceiling); symbol_dict_reset_max_wait_millis rejects values whose nanosecond conversion overflows.
  • getSymbolDictResetsPerformed() is gone — it could never differ from getSymbolDictEpoch().
  • The recycle barrier and its bounded wait run only while the I/O loop holds a live connection; the resetSymbolDictionary(), symbolDictReset and symbolDictResetMaxWaitMillis javadocs and both monitoring paragraphs state the gate (a table() call made while the loop is reconnecting skips the wait and leaves the recycle armed; armed with a flat epoch also means the sender is between connections).

Recycle robustness

The through-line: the recycle prefers abandoning and resuming over latching or re-arming.

  • Reconnect is deferred to the I/O loop. The post-swap reconnect never re-runs the initial-connect policy on the producer thread: ensureConnected() latches hasInitialConnectRun on its first completion and routes every later entry through the ASYNC branch — the loop is built and started synchronously, the socket connect happens on the I/O thread with indefinite retry, and the producer keeps buffering into the fresh epoch's slot. A default (OFF) or SYNC store-and-forward sender therefore never sees a transport error or a reconnect budget past its initial connect, matching the steady-state store-and-forward contract. The barrier swaps only while the I/O loop holds a live connection (CursorWebSocketSendLoop.isLinkUp()): step 2's loop close cancels a live socket in milliseconds, but a reconnect blocked in a hostname resolve or a credential pull ignores the cancel and would hold table() for the join budget, so while the loop is between connections the recycle stays armed and runs at the first drained barrier after the reconnect; SymbolDictRecycleOutageTest pins both halves. Deferring has a bound: the dictionary keeps growing until the reconnect, and the protocol caps a sender at 2,000,000 distinct symbol values (MAX_SYMBOL_DICTIONARY_SIZE), so a producer that registers that many new values above the threshold within one outage fails symbol() with the dictionary-full error instead of swapping mid-outage. The rebuilt loop is seeded with the sender-lifetime ever-connected flag (markEverConnected, read after the outgoing loop's close() has joined the I/O thread), so wasEverConnected() stays sticky and endpoint-policy failures keep their post-init retry classification across the swap.
  • Anti-thrash re-arm floor. A live symbol set larger than the threshold would otherwise re-arm on every refill, recycling (teardown + reconnect + dictionary re-ship) every ~L·ln(L/(L−T)) rows forever. Each swap raises the re-arm bar to max(threshold, 2 × size-at-swap) and never lowers it -- a manual resetSymbolDictionary() swap bypasses the floor for that one swap without resetting it --, capped at half the protocol cap so genuinely unbounded-cardinality producers keep recycling. No new config key; the default threshold is unchanged. Red-proofed: without the floor a bounded 6-symbol set recycles 4×, with it once. The floor arithmetic itself is pinned (doubling per swap, organic re-arm refused below it, advisory request bypasses it) — the integration suites' bounded symbol sets rely on that bypass to keep recycling.
  • The slot stays owned across the swap. The recycle takes the slot's parent-anchored logical lock before it closes the outgoing loop, closes the outgoing (and any healing) engine without reclaiming that lock, rebuilds through the already-locked construction path, and releases the lock once the fresh engine holds the slot's directory flock — also on the breach latch and in close(), so an abandoned swap never keeps the slot from a successor. A build() colliding on the same sf_dir/sender_id therefore fails fast throughout the swap, as the senderId contract states; a contention on the lock itself (a sibling's startup orphan drainer holding it for microseconds) refuses one row before anything is torn down and the recycle re-fires at a later drained barrier. SymbolDictRecycleSlotOwnershipTest attempts the colliding build through the real builder's factory at the rebuild boundary, while a rebuild is pending, after the breach latch and after close(), each red against the mutation it guards. One residue remains: a swap abandoned at the rebuild and then closed leaves the slot's .slot-locks lock-file pair on disk (no engine retires the slot in that state); it is one tiny pair per sender_id, and the next fully-drained retirement of that slot reclaims it.
  • A deferred engine close is awaited. The fully-drained close can return with the slot flock retained when the SF worker is wedged in a syscall past SegmentManager's bounded join (a stalled disk/NFS — exactly what the deferred-close machinery exists to survive). awaitDeferredEngineClose parks until the worker's exit path confirms the release (30 s budget) instead of letting the rebuild throw SlotLockContentionException. SymbolDictRecycleDeferredCloseTest pins both branches with a wedged-worker harness and a positive parked witness; red-proofed against the pre-fix code (dies with the exact SlotLockContentionException trace). The same protocol closes an engine the rebuild cannot keep (recovered fully-acked leftovers): a wedged worker there retains the engine and resumes on the next send instead of colliding with the next rebuild, and close() keeps reporting the flock held until the worker exits; pinned with a wedged recovered engine both riding the stall out and exhausting the await. Step 3 detaches the outgoing engine's slot-lock listener before closing it, so a release completing after the swap cannot mark the rebuilt engine's flock released; a recycle-retained engine reaches pools through the isSlotLockReleased() re-probe. The await budget is spent once per pending close: a resume while the worker is still wedged probes and rethrows without parking (pinned: the retried table() returns in well under the budget).
  • build() never throws for an error the I/O thread latched during construction. The rebuild-factory setter refuses only a closed sender (a rejection latched while build() runs surfaces on the first send, as before, and close() cleans up), and both post-connect setters sit inside build()'s close-on-failure guard, so a throw there can no longer leak the connected sender, its threads and its slot lock. This was the Windows CI failure of SenderPoolDataLossNotificationTest on the pool's startup recovery.
  • The recycle is resumable instead of terminal. The swap commits only after a fresh engine stands on the emptied slot (dictionary, epoch counters, and wiring move after the rebuild), and every transient mid-recycle failure — an interrupt arriving while the loop close joins the I/O thread (a flag the producer carries in is cleared for the join and handed back, so it never abandons), a wedged SF worker exhausting the deferred-close budget, a momentary EMFILE at rebuild, a post-cleanup fsync warning — records a two-state resume point (CLOSE_LOOP/REBUILD) that the next send completes. The terminal latch survives for exactly one case: a rebuilt engine that recovered unacknowledged frames, which proves the outgoing close's fully-drained contract was breached. Benign recovered fully-acked leftovers (a transiently failed segment unlink) heal by closing the recovered engine — which retries the unlink by design — and rebuilding. Error passes through the recycle machinery unwrapped and unlatched. Suites pin the interrupt, rebuild-fault, await-exhaustion, heal, and breach paths, each red-proofed.
  • A rebuild-time quarantine reaches the handler installed on the sender. The rebuild factory receives the sender's current user handler (rebuild(SenderErrorHandler)), not the builder's field captured at build time, so setErrorHandler() after build() is honoured when a recycle rebuild sets a torn slot aside; the builder's factory also snapshots sf_dir/sender id at build time.
  • A failed post-swap reconnect never latches. A @TestOnly fault hook drives a real failed reconnect: one test proves ids registered mid-window survive into the flushed delta (fails with the resetSymbolDictStateForNewConnection guard reverted to the unconditional clear), the other proves the failure leaves the sender usable (fails with recycleFailure latched in the reconnect catch). Both counterfactuals re-run red against the final tree.
  • close()'s drain guard is split by true dependency (flush/commit/seal need only the engine; checkUnsurfacedError/drainOnClose need the loop). Staged rows cannot coexist with a null loop — sendRow() calls ensureConnected() before a row commits and every failure path rolls the in-progress row back — so this is hardening that keeps the guard honest for any future state, not a fix for a reachable row drop.
  • Accessors are truthful across the recycle window. getAckedFsn()/awaitAckedFsn() snapshot cursorEngine/cursorSendLoop into locals and, while the engine is null (mid-swap), report the durable watermark the recycle barrier proved instead of collapsing to −1; both accessors read the epoch base before the engine on every pass (awaitAckedFsn() polls through getAckedFsn()'s clamped read, pinned by a monitor thread parked across a real recycle), so a torn pair can never fabricate an FSN above anything published nor pin a wait to the outgoing engine; while the engine is null mid-swap the wait keeps polling to its deadline with the durable watermark standing in, instead of answering false at once for a target above it; cursorEngine, cursorSendLoop, fsnEpochBase, lastRecycleDurableFsn and the ever-connected flag are volatile for monitoring readers, and the seven getTotal* counters read one sender-lifetime holder; the getAckedFsn javadocs state the real post-recycle sentinel behavior, including the HTTP/TCP/UDP always-−1 caveat. The torn (old base, fresh engine) clamp is pinned by a test that parks a monitor thread between the accessor's two reads across a real recycle; the null-engine window is asserted from inside the deferred-close park witness — both go red with the clamp or the durable write removed. checkConnectionError() snapshots the loop before checking it, so a monitor thread cannot race the swap's null write.
  • Delta-dict healing is epoch-scoped by design. The rebuilt engine re-derives delta capability rather than inheriting a one-way degrade latch: SymbolDictRecycleHealingTest pins that a degraded sender heals back into delta mode once the fault clears, and that a persistent fault degrades the fresh engine again as a normal, catchable error.
  • A rebuild that recovers unacknowledged frames latches the sender terminal (empties-the-slot contract breach); fully-acked leftovers heal. The stale slot-lock latch a pool-style re-probe can leave behind is cleared as soon as the outgoing close is confirmed, before the rebuild takes the flock (pinned through a rebuild-factory probe), and the fresh dictionary is allocated before the rebuild so the commit block genuinely cannot throw.
  • The fresh dictionary is allocated at the outgoing dictionary's size (the recovery path already did this), so an epoch's refill no longer re-climbs the rehash ladder.
  • Diagnostics use the external FSN scale. drainOnClose's two WARNs (the abandon-uncommitted-frames one and the timeout one) and its thrown message, plus the I/O loop's two replay log lines, report epoch-rebased FSNs — the same scale flushAndGetSequence() and the SenderError spans use (pinned after a real recycle).
  • Contracts the recycle touches are stated where readers look. A recycle's reconnect fires RECONNECTED/FAILED_OVER with no DISCONNECTED (now on SenderConnectionEvent.Kind and SenderConnectionListener, and pinned); a rebuild-time quarantine is delivered synchronously on the producer thread like the build-time one (SenderErrorHandler); setEngineRebuildFactory is documented and refuses a closed sender. setEngineRebuildFactory(null) stops future arming but never cancels an armed or pending recycle, and its javadoc now says so.

This update sets the recycle's default starvation wait to 2 s and closes two resume gaps found under interrupt and fault injection. symbol_dict_reset_max_wait_millis defaults to 2000: a recycle armed that long without a table() call finding the backlog drained pauses the next table() call for at most 2 s so the backlog can drain, then swaps — at most once per armed window; 0 opts out to opportunistic-only. A continuous producer keeps frames in flight at every row start and never exposes a drained instant on its own, so the wait is what makes the recycle reachable for the population that crosses the threshold; on a healthy link the pause is about one ack round trip, because the paused producer stops refilling the ring. The value is sized against the sender pool's acquire timeout (see Design decisions) and pinned by SymbolDictResetDefaultsTest. The two park loops a recycle can enter (maybeBlockForStarvedReset()'s starvation wait, awaitDeferredEngineClose()'s deferred-close wait) and the loop-close join share one interrupt policy — clear the flag for the park or join and hand it back on every exit, so the recycle never consumes the caller's interrupt — after a carried interrupt flag was found manufacturing a false CLOSE_LOOP abandon out of an otherwise successful wait. A CLOSE_LOOP resume now re-registers the delta baseline it used to drop, republishing it as deferred dictionary chunks the fresh loop replays ahead of any data frame, and closes the outstanding chunk-commit debt on both the resume's Throwable and Error paths, so an interrupted or faulted recycle converges the same way a clean one does. resetSymbolDictionary(), the dictionary-cap error, and armIfEligible()'s javadoc now state the trigger point in one sentence each: table(...) is the only place a recycle ever starts; a recycle that failed transiently mid-swap is resumed and completed by the next table()/flush()/drain() call (an at()/atNow() that finds the swap still pending fails and rolls back its row, and the next table() completes it), and SenderErrorHandler's threading contract says so too, since a rebuild-time quarantine can therefore arrive synchronously on the producer thread from any of those calls. Tests pin these: the default wait's sizing (positive, and at most half the pool's acquire timeout), the interrupt policy under both park loops, the CLOSE_LOOP re-registration and its fallback/debt-close paths (including a crash-replay run proving the abandoned resume group survives a restart), a post-recycle endpoint-policy-rejection retry, and FSN epoch-base accumulation across two real recycles.

Design decisions

  • A 2 s bounded wait by default (symbol_dict_reset_max_wait_millis=2000): the recycle runs when a
    table() call finds the backlog drained; a recycle armed for 2 s without one pauses the next table()
    call for at most 2 s so the backlog can drain, at most once per armed window. A continuous producer
    keeps frames in flight at every row start and never exposes a drained instant on its own, so an
    opportunistic-only default would leave the recycle unreachable for exactly the population that crosses
    the threshold; with the producer paused, a healthy link drains in about one ack round trip. The wait
    surfaces no transport error, carries no reconnect budget and never latches: on timeout the call
    returns, the sender stays armed, and no second wait runs until a swap commits, so a drop that lands mid-wait
    costs at most the rest of that one pause, and a loop already between connections when the barrier
    runs is skipped outright (the live-connection gate above). The earlier 30 s default failed on pool arithmetic: a give-back flush arms the sender,
    the slot idles past the window (idle_timeout_ms 60 s), and the next borrower paid the wait while
    holding its lease, longer than acquire_timeout_ms (5 s), so every other acquirer timed out. 2 s
    clears that with margin, covers one round trip plus a modest backlog, and survives the first four
    attempts of the reconnect backoff ladder. A producer that outruns the wire still times out and stays
    armed until a natural gap; the signal is getSymbolDictResetStarvationTimeouts() at 1 with
    isResetArmed() true while getSymbolDictEpoch() does not advance, and the planned escalation for
    that population is an asynchronous handover of the dictionary swap to the I/O loop, not a longer
    wait.
  • Recycle bounds mirror close()'s stall class, not always its blocking behavior. The swap's
    loop-close wait is literally close()'s own budget (CursorWebSocketSendLoop.close()'s 30 s
    shutdown-await) — the same method a plain close() calls. The deferred-engine-close wait is a
    separate, dedicated budget (RECYCLE_DEFERRED_CLOSE_MAX_WAIT_MILLIS, also 30 s) sized to survive the
    same disk/NFS stall class SegmentManager's bounded worker join is built to survive; unlike a plain
    close(), which spends only its own join budget and returns, leaving eventual release to a pool's
    background re-probe (isSlotLockReleased()), the recycle parks the producer on this wait so the
    rebuild never collides with a retained flock. Rows are refused only for that window, and only while
    the SF worker is genuinely wedged — a durability-failure class, the same kind of signal
    SegmentRing.checkDurability() already surfaces on the row-send path — never on a transport outage,
    and no reconnect budget applies to the producer. Both parks' interrupt policies are pinned: SymbolDictRecycleStarvationTest for the starvation wait, SymbolDictRecycleDeferredCloseTest for the deferred-close wait (an interrupt carried into either park is handed back on every exit; both tests fail against a mutated exit policy).
  • The CLOSE_LOOP resume's re-registration has its own producer-visible bound. Republishing the
    dropped delta baseline runs through the same sealAndSwapBuffer/appendBlocking path as any other
    publish, on the producer thread, bounded by sf_append_deadline_millis (default 30 s) — stated as its
    own bound, like the deferred-engine-close wait. A failure mid-publish clamps the delta baseline to
    the chunks that reached the ring and closes their group best-effort; a failure after the publish
    keeps the baseline (coverage is full) and retries the closing commit (commitOrphanedDictionaryChunks,
    which never throws); if that best-effort close still leaves the group open, every subsequent flush()
    and close() re-attempts the closing commit whenever a group is open (hasDeferredMessages), so the
    residual is a retried, not permanent, state. The re-registration is one-shot, so a remainder wider than
    the server cap after a mid-publish failure stays wedged on the split pre-flight -- a narrower
    population than the former baseline drop, which re-shipped the whole range.
  • Server support floor is 10.x. QWP was experimental through the 9.x line and never released with a
    9.x server, so no compatibility shim for 9.x server behaviour exists or is argued from.
  • Sender stays single-threaded. A concurrent close() while a recycle waits is outside the
    documented contract (Sender's own thread-safety javadoc).
  • The trigger point is table(), by contract. Sender.resetSymbolDictionary(), the dictionary-cap
    error message, and armIfEligible()'s javadoc all state the trigger explicitly: the recycle runs at
    a table() call that finds the backlog acknowledged, and a caller that never starts another row never
    recycles. No per-row branch is added to the hot path.
  • Pooled senders inherit an armed recycle at give-back. On a healthy link the ring drains during the
    idle window, so the next borrower's first table() swaps without waiting; only an unhealthy link makes
    it pay the bounded wait while holding its lease, which is why the default stays at half the pool's
    acquire_timeout_ms. An abandon during a give-back flush ejects the slot via discardBroken, which removes it from the pool's slot
    set so the pool can grow back into a fresh one on demand — safe, at a capacity cost until it does. The
    pooled-path recycle has no end-to-end coverage yet.
  • FSNs are scoped to the sender instance. The epoch offset a recycle applies is memory-only; a process restarted on the same slot numbers from the slot's on-disk state, so replayed frames are reported on the new process's scale. flushAndGetSequence(), getAckedFsn() and SenderError say so. Persisting the offset would be an on-disk format addition and is deliberately not part of this change.

Tests

New client suites cover arming, FSN continuity, the swap itself, memory mode, starvation, refusals, outage/orphans, catch-up skip, crash windows, partial re-registration, healing/metrics, deferred close, slot heal/breach, reconnect fault injection, and the reset defaults. Alongside them: the OSS disconnect-clear contract pin, real-client e2e in SF and memory modes, a seeded recycle×reconnect fuzz, and ENT failover losslessness with recycles in flight. Verified green from a clean reactor build; the reconnect-fault counterfactuals re-run red at the final tree.

Two pins close coverage gaps the review found: InitialConnectAsyncTest#testAsyncAuthFailureLeavesRebuildFactorySetterUsable reaches a 401-latched, still-open async sender (the state build() can race into) without timing and asserts setEngineRebuildFactory accepts it, red if the setter goes back to the connection-error check; the two timeout-exit interrupt tests (SymbolDictRecycleStarvationTest#testInterruptedWaitTimesOutAndRestoresFlag, SymbolDictRecycleDeferredCloseTest#testInterruptAtParkEntryIsRestoredWhenAwaitTimesOut) bound the caller's CPU time over the call to half its wall time, pinning the per-iteration interrupt clear that keeps parkNanos sleeping (deleting the clear in either park turns them red; skipped where the JVM cannot report thread CPU time).

🤖 Generated with Claude Code

jovfer and others added 23 commits August 17, 2026 16:13
Pull the lock/quarantine engine-construction block out of build() into
LineSenderBuilder.constructEngineOnSlotLocked()/constructEngineOnSlot(),
and expose a QwpWebSocketSender.EngineRebuildFactory seam that build()
installs on the connected sender once connect() succeeds. Pure refactor,
zero behavior change: build() keeps its wide logical-lock scope spanning
the connect loop; the standalone constructEngineOnSlot() acquires the
lock only around construction, for a later symbol-dictionary epoch
rebuild to reuse the identical construct/quarantine code path.
constructEngineOnSlotLocked() now returns a small ConstructedEngine
result (engine + whether construction itself quarantined the slot)
instead of a bare CursorSendEngine. build() seeds its own quarantined
local from that verdict, restoring the pre-refactor invariant that a
construction-time quarantine counts toward the one quarantine per
build() attempt the connect loop's retry guard allows. Without this,
a construction-time quarantine followed by an UnreplayableSlotException
from connect() would take a second quarantineTornSlot pass instead of
the original close-and-rethrow.

constructEngineOnSlot(), the public factory entry Task 5 consumes,
keeps its 8-arg signature and CursorSendEngine return type: it just
unwraps ConstructedEngine.engine and discards the quarantined verdict,
since the recycle path latches terminal on connect failure rather than
quarantining.
Adds three connect-string keys for the upcoming QWP symbol-dictionary
recycle feature: symbol_dict_reset (on/off, default on),
symbol_dict_reset_threshold (distinct-symbol count that triggers a
recycle, default 100_000, bounded by QwpConstants.MAX_SYMBOL_DICTIONARY_SIZE),
and symbol_dict_reset_max_wait_millis (upper bound on how long a
triggered recycle waits for an opportunistic window before forcing,
default 30_000, 0 means opportunistic-only).

This is config plumbing only: the three values land on QwpWebSocketSender
as resetEnabled/resetThresholdSymbols/resetMaxWaitMillis fields with
@testonly getters, following the catch_up_cap_gap_min_escalation_window_millis
knob end-to-end (ConfigSchema registry, builder methods with WS-transport
guards, both connect-string parse paths, wsConfigSnapshotForTest). Actual
recycle behavior is a follow-up task.
Adds Sender.resetSymbolDictionary(), an advisory request to start a fresh
symbol-dictionary epoch (default no-op; QwpWebSocketSender overrides it).
QwpWebSocketSender.armIfEligible() re-evaluates arming at the tail of
resetTableBuffersAfterFlush() -- the shared exit point for the plain flush,
split flush, and close-path callers -- so the recycle arms once
symbol_dict_reset is enabled and either the global dictionary reaches
symbol_dict_reset_threshold distinct entries or a caller requested a manual
reset. Arming deliberately ignores deltaDictEnabled: a sender degraded to
full self-sufficient frames still benefits from bounding dictionary growth,
and a manual request is honoured regardless of mode.

Covered by SymbolDictRecycleArmingTest: threshold crossing, symbol_dict_reset=off
never arming, the manual advisory API (both immediate and mid-batch-deferred
arming), the split-flush path sharing the same arming tail, arming in
full-dict (degraded) mode, and the no-op default on a non-WebSocket sender.
testArmsInFullDictMode previously substituted the manual
resetSymbolDictionary() advisory request for crossing
symbol_dict_reset_threshold, which never touches globalSymbolDictionary and
so left a future deltaDictEnabled-conditioned regression in threshold-based
arming undetected. Rewrite it to construct the sender through the widest
connect(List<Endpoint>, ...) overload, which accepts both a custom
symbolDictResetThresholdSymbols and the fault-injecting CursorSendEngine, and
genuinely cross the threshold (registering a, b, c) while the sender stays
degraded to full self-sufficient frames. No manual reset call remains in the
test.
- rollFsnEpochBase now throws IllegalStateException when cursorSendLoop is
  non-null: the loop's externalFsnBase is a construction-time snapshot,
  never updated on a live loop, so rolling with a loop attached would
  silently desync sender-level FSN accessors from loop-level FSN emission.
- testPreRollTargetAnswersTrueAfterRoll rolled the same already-connected
  sender that produced fsn1, so its raw engine watermark never reset and
  the pre-fix comparison (ackedFsn() == fsn1 >= fsn1) was also true --
  the test could not fail. Rebuilt around a fresh rolled sender/engine via
  the existing createRolledSender helper, matching tests 3/5/6. Tests 2, 4,
  and 7 also rolled an already-connected sender (now rejected by the new
  guard) and are rewritten the same way.
Implement the table() barrier hook and recycleForDictReset() swap: once
the symbol-dictionary recycle is armed (Task 3) and the ring is proven
drained, table() tears the cursor I/O loop and engine down, rolls the
FSN epoch base (Task 4), replaces the producer's symbol dictionary, and
rebuilds the engine via the Task 1 EngineRebuildFactory before
reconnecting -- all synchronously inside a single call.

A failed rebuild latches recycleFailure as a terminal state: every frame
existed before the swap was already proven acked, so no data is at
risk, but the sender that observed the torn-down engine/loop refuses
further use. checkRecycleFailure() covers table() and the flush-family
entry points (flush, flushAndGetSequence, drain, awaitAckedFsn) --
deliberately not close(), which must still be able to tear down a
latched sender.
Fix round 1 from review:

- maybeRecycleForDictReset() now refuses before any teardown when
  engineRebuildFactory is null (every public connect() overload leaves
  it unset -- only Sender.build() installs one) or the cursor engine
  isn't owned by this sender (setCursorEngine(engine, false)'s
  contract). Without this, a connect()-built sender with the
  default-on recycle feature armed (via resetSymbolDictionary() or a
  threshold crossing) would reach step 6, NPE against the null
  factory, and latch itself terminal for no reason.
- Step 5 also resets lastCommitBoundaryFsn -- it held a raw old-epoch
  FSN that does not survive the roll.
- checkRecycleFailure() now also guards sendRow(), closing the
  fluent-chain corner where a caller continues .symbol(...).atNow()
  against a currentTableBuffer selected before the latch, without an
  intervening table() call.
- Step 6 rewires cursorEngine.setSlotLockReleaseListener(...) on the
  rebuilt engine, restoring the pool early-wakeup notification that
  bypassing setCursorEngine had dropped.
- testPostRecycleSlotContents now asserts the post-recycle slot
  directory listing against the exact expected fresh-state file set,
  replacing a bare Files.exists(...) check that proved nothing (the
  outgoing engine had a same-named file too).
- New testConnectBuiltSenderNeverRecyclesWithoutFactory covers both
  ways such a sender can arm (manual request, threshold crossing):
  neither may recycle, throw, or stop the sender from working.
Adds SymbolDictRecycleMemoryModeTest, the sf_dir-omitted counterpart of
SymbolDictRecycleTest: threshold-triggered recycle at an empty backlog,
a content oracle proving the epoch boundary loses (and duplicates)
nothing acked, and the same recycle under initial_connect_retry=async.

All three pass unmodified against the existing recycle swap -- zero
production changes -- confirming the factory's slotPath == null arm,
CursorSendEngine's file-less close, and the table() barrier are already
mode-agnostic.
Fills the maybeBlockForStarvedReset() stub: when a symbol-dict recycle
is armed but the ring is not yet drained, opportunistically waits
(parked, awaitAckedFsn-shaped) up to symbol_dict_reset_max_wait_millis
for the outstanding acks before giving up for this armed window.
resetMaxWaitMillis<=0 disables the wait entirely; at most one blocking
wait runs per armed window (starvationWaitDoneThisArm); an open
deferred-commit group is never waited on, since the server withholds
its acks by design until the closing commit lands and this producer
thread is the only one that could ever send that commit -- blocking
there would just run out the clock every time. A timeout increments
the new symbolDictResetStarvationTimeouts counter and leaves the
recycle armed so a later drained table() call can still fire it.

Verified the deferred-commit guard is load-bearing by temporarily
removing it and confirming the test fails (blocks the full deadline
instead of returning immediately) before restoring it.
Javadoc for symbol_dict_reset_max_wait_millis (Sender.java builder
method, and the DEFAULT_.../field comments in QwpWebSocketSender.java)
said the knob controls how long the recycle waits "before forcing the
rebuild" and that 0 means "never forces". That is backwards: nothing
is ever forced through. On timeout the wait gives up, increments the
starvation counter, logs a warning, and stays armed -- the dictionary
threshold is the only actual backstop. Rewrite all three to state the
real policy: once the armed window exceeds the knob, the NEXT
table(...) call may block the calling thread for up to the knob's
value waiting for the backlog to drain; 0 disables blocking entirely.

WARN log wording "re-arming opportunistically" -> "staying armed":
the arm is never consumed on a timeout, so nothing re-arms.

Test fixes in SymbolDictRecycleStarvationTest:
- testTimeoutLogsAndReArms: the "second table() must not re-block"
  probe was placed after a row had been queued (pendingRowCount==1),
  so it short-circuited on maybeRecycleForDictReset()'s precondition
  guard before ever reaching the wait -- vacuously true regardless of
  starvationWaitDoneThisArm. Moved the probe earlier, to a table()
  call with pendingRowCount==0, so it actually exercises the "at most
  one blocking wait per armed window" guard.
- testBlocksThenRecyclesWhenAcksArrive: raised maxWaitMillis from 400
  to 700ms (keeping the 150ms release delay) so the full recycle path
  (I/O loop join, engine close, rebuild, fresh handshake) has real
  headroom under the elapsedMs<maxWaitMillis assertion instead of ~250ms.
- Both testBlocksThenRecyclesWhenAcksArrive and
  testLatchedErrorDuringWaitThrows now join their helper thread
  (releaser/poisoner) in a finally block, so an assertion failure
  can no longer leak a non-daemon thread.

Re-ran SymbolDictRecycleStarvationTest (5/5) and the full
SymbolDictRecycle* battery (27/27), all green.
Adds SymbolDictRecycleOutageTest covering two interleavings between the
symbol-dictionary recycle swap and events outside the producer's own
control: a real connection outage on its own stream (recycle triggers
while the pre-recycle I/O thread is mid-reconnect against a killed
server; step 2's close() joins it, step 7's fresh connect recovers once
the endpoint accepts again), and a sibling orphan drainer mid-drain
(the recycle only tears down the foreground sender's own cursor
engine/I/O loop, leaving a concurrently-gated BackgroundDrainer
untouched and able to complete afterward).

The third scenario from the task brief -- OrphanScanner.isCandidateOrphan
rejecting an empty slot directory -- turned out to already be pinned by
OrphanScannerTest#testIsCandidateOrphanDirect and
#testEmptySlotDirIsNotAnOrphan, so no new test was added for it.

Test-only change; no production code touched.
SymbolDictRecycleCatchUpSkipTest pins that recycleForDictReset()'s step 7
reconnect never pays for a delta-dictionary catch-up frame: the rebuilt
engine sits on a freshly-emptied slot, so the new loop's sentDictCount
mirror seeds from PersistedSymbolDict.recoveredSize() == 0 and
setWireBaselineWithCatchUp's gate stays false for the whole first
post-recycle connection.

The core scenario chains the negative and positive observations in one
test so the zero count is provably a property, not a handler blind spot:
after the recycle sends zero zero-table frames and tiles ids from 0, the
handler force-drops the connection, and the resulting UNPLANNED reconnect
does catch up -- bounded to exactly the new epoch's symbols, never
replaying the retired epoch's. A follow-up symbol then ships with a delta
start above 0, pinning that resetSymbolDictStateForNewConnection() on the
plain-reconnect path preserves sentMaxSymbolId rather than folding the
recycle's baseline reset into itself.

A second test repeats the zero-catch-up observation under
initial_connect_retry=async, where step 7's reconnect funnels through
ensureConnected()'s ASYNC arm and the handshake completes on the I/O
thread.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K2Hw6TfgX9sBB2L8oSdaWn
rev-t10 traced resetSymbolDictStateForNewConnection() to its single call
site in ensureConnected(), which the unplanned I/O-thread reconnect
(swapClient) never reaches -- so the old comment credited a function that
does not even run on this path. sentMaxSymbolId survives the plain
reconnect because nothing touches it there; only recycleForDictReset()'s
step 5 ever zeroes the baseline. The class javadoc's failure-mode
attribution carried the same imprecision and is reworded to match. The
assertion itself was traced sound (symbolDeltaBaseline() ->
encoder.beginMessage -> deltaStart) and is unchanged; comment-only diff.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K2Hw6TfgX9sBB2L8oSdaWn
Adds SymbolDictRecycleCrashWindowsTest, pinning what a restarted sender
recovers if the process crashes at each of four points around
QwpWebSocketSender.recycleForDictReset()'s 8-step symbol-dictionary
recycle swap:

- (a) before step 2 (the barrier that starts the swap): the pre-recycle
  epoch's slot holds a fully-acked batch on disk. Recovery must find
  that residue, recognize it as already acked (nothing to replay) and
  resume the SAME dictionary rather than starting fresh. Constructed by
  closing fast against a server that never acks (so the fully-drained
  unlink never fires) and then stamping the ack watermark directly to
  declare the batch acked retroactively, mirroring
  DeltaDictRecoveryTest#writeAckWatermark.

- (b) between step 3 (fully-drained close of the old engine) and step 6
  (rebuild): the slot is empty. Constructed by driving a real recycle to
  completion and then closing immediately, before any flush touches the
  freshly-rebuilt engine -- finishClose treats "nothing published yet"
  as fully drained too, so this unlinks everything step 6 just created,
  leaving the same empty state step 3 alone would have left.

- (c) after step 7 (reconnect), before the new epoch's first flush: the
  slot holds a freshly-rebuilt engine's own state files but no data.
  Constructed by snapshotting the rebuilt slot's bytes before closing
  (there is no supported way to release just the slot's OS flock without
  running finishClose's unlink), closing for real so nothing leaks, then
  restoring the snapshot on top of the vacated directory.

- (d) an ordinary mid-operation crash one epoch into the post-recycle
  steady state, to prove the epoch swap does not corrupt normal backlog
  recovery. Uses the same close-fast-against-a-non-acking-server idiom
  as RecoveryReplayTest, but only after the recycle's fresh connection
  is established.

Arms (b) and (c) both replay nothing and both look "empty" at first
glance, but they are not the same recoverable state and a restarted
engine can tell them apart: (c)'s slot carries a manifest with collapsed
boundaries alongside a same-based, zero-frame active segment, which
SegmentRing.recover()'s chain-building accepts as a RECOVERED (if empty)
chain, while (b)'s slot carries no engine state at all and recovers as
EMPTY. wasRecoveredFromDisk() is the pinned, distinguishing observable
between the two, asserted explicitly instead of writing two
assertion-for-assertion duplicate tests.

Every arm's oracle: the recovered sender keeps ingesting; the symbols it
and its predecessor registered are exactly and correctly reconstructable
from the wire (each fresh server handler rebuilds the per-connection
delta dictionary); and no data frame is delivered more than the
at-least-once contract allows (each handler counts data frames so a
spurious re-send would show up as an unexpected count).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K2Hw6TfgX9sBB2L8oSdaWn
Applies review findings 1, 2, 3, 4, 6, 7, 8 and 10 from the task-11
review (findings 5, 9, 11 are deferred to the whole-branch review):

- Arm (b) never actually pinned the "empty slot" disk image it exists
  to test -- it inferred emptiness from wasRecoveredFromDisk()==false
  on the successor instead of asserting the crashed sender's own slot
  dir. Adds an explicit listDir(slot) == [".lock", ".lock.pid"]
  assertion right after the crashed sender closes (symmetric to arm
  (c)'s pre-existing file-set assertion), plus a second one after the
  successor's own fully-drained close, proving the empty-slot state is
  stable rather than a one-shot coincidence. This also replaces arm
  (b)'s closing assertion, whose message previously credited the
  successor's "recovery" for cleaning up a stale manifest that the
  crashed sender's own close had already removed.

- Arm (c) snapshotted the freshly-rebuilt slot before waiting for the
  manager worker's asynchronous hot-spare provisioning to settle, so a
  mid-provision snapshot could race-capture a zero-magic spare that
  recovery would hard-fail on. Adds a bounded poll
  (awaitExactFileSet) before the snapshot, reusing the same expected
  file list (now a shared FRESH_REBUILD_FILES constant) as the
  post-restore assertion so the two can never drift apart.

- Arms (a) and (d) loosely asserted recoveredMaxSymbolId() >= 1, which
  a leaked epoch-0-plus-epoch-1 dictionary would also satisfy. Tightens
  both to the deterministic exact value (1L), keeping their explanatory
  messages as-is now that the assertion actually proves what the
  message claims.

- Widens QwpWireTestUtils.tableCount to public (its sibling frame
  helpers already are) and deletes this suite's local copy plus its
  now-unnecessary justification javadoc.

- Inlines the AckAllHandler bindings in arms (b) and (c) that were
  never read (dict()/dataFrameCount() were only meaningful on the
  "fresh" server's handler, not the "crashed" one).

- Class javadoc: notes that arm (c)'s snapshot/restore does not cover
  the logical slot lock (lives outside the slot dir; sender.close()
  reclaims it, acquireLogical recreates it -- benign), and corrects
  three mechanism imprecisions the review traced: the "never published"
  fully-drained check lives in close(boolean), not finishClose; a
  fully-drained close does not remove .lock/.lock.pid; and arm (b)'s
  SegmentRing.recover() counterpart is the no-manifest fall-through to
  Recovery.empty(), not the manifest-present collapse branch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K2Hw6TfgX9sBB2L8oSdaWn
A sender that degrades to full self-sufficient frames after a symbol-dict
persistence fault (disableDeltaDict) used to stay degraded for the rest of
its life -- deltaDictEnabled was set once at engine construction and never
re-evaluated. The symbol-dictionary recycle already rebuilds the cursor
engine from scratch on every swap; make that rebuild also re-derive
deltaDictEnabled from the fresh engine instead of carrying the old one's
verdict forward. If the underlying fault has cleared, the next recycle
heals the sender back into delta mode; if it has not, the fresh engine
degrades again on its own first append, the same ordinary catchable
LineSenderException as any other persistence fault -- a degrade, never a
latched recycleFailure terminal state.

Promote the epoch and starvation-timeout counters from @testonly
accessors to permanent public API: getSymbolDictEpochForTest() becomes
getSymbolDictEpoch(), and getSymbolDictResetStarvationTimeoutsForTest()
becomes getSymbolDictResetStarvationTimeouts(). Both counters already
existed; this only changes their visibility and documents their
thread-safety contract (producer-thread-written, so a read from another
thread is an eventually-consistent snapshot). Add a third counter,
getSymbolDictResetsPerformed(), incremented alongside the epoch inside
recycleForDictReset() -- the two move together today but are defined and
incremented independently, since a future change could roll the epoch by
some path other than a completed recycle swap.

Migrate every existing call site of the two renamed getters (7 test
files, 48 + 15 occurrences) to the new public names.

SymbolDictRecycleHealingTest covers all of this: a fault-then-heal
recycle proves deltaDictEnabled and wire framing both return to proper
delta encoding (the second post-recycle frame's delta starts where the
first left off and carries only the newly-added symbol, the shape only
delta mode produces); a fault-persists variant proves the fresh engine
degrades again without escaping as a raw error or latching the sender
terminal; and a two-recycle run asserts the three metrics getters track
correctly in lockstep while the starvation counter stays untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K2Hw6TfgX9sBB2L8oSdaWn
Applies task-12-review.md findings Q1-Q6.

Q1: the three symbol-dictionary-recycle metrics fields (symbolDictEpoch,
symbolDictResetsPerformed, symbolDictResetStarvationTimeouts) become
volatile -- they are permanent public API now, and their obvious reader
is a monitoring thread on some other thread, unlike every other
symbol-dictionary-recycle field they were modelled on. A plain,
non-volatile long gives a cross-thread reader no visibility guarantee at
all under the JMM (a polling loop can legally observe 0 forever), whereas
the javadoc claimed an "eventually-consistent snapshot". Reworded the
three javadocs to state the guarantee volatile actually gives: each read
sees the latest write the producer thread completed, with no atomicity
across the three counters -- a concurrent reader can see the epoch
already advanced while resets-performed still reflects the prior value,
even though the producer thread writes them on adjacent lines.

Q3: getSymbolDictResetStarvationTimeouts()'s javadoc named
recycleForDictReset() as the writer by importing getSymbolDictEpoch()'s
caveat sentence verbatim; the starvation counter is actually written in
maybeBlockForStarvedReset(). Named that method directly instead.

Q4: getSymbolDictResetsPerformed() said swaps this sender has
"completed" without stating that the counter advances at step 5, before
the engine rebuild (step 6) and reconnect (step 7) -- so a recycle that
later latches recycleFailure at step 6/7 still counts. Made that
explicit, mirroring the precision getSymbolDictEpoch() already had.

Q2: SymbolDictRecycleHealingTest's healing test asserted
isDeltaDictEnabledForTest() right after the recycle and attributed the
result to the healed facade, but the same assertion holds unconditionally
-- a fresh engine's construction never touches mmap, so it reports true
whether or not the facade was healed (the persistent-fault sibling test
proves this directly). Reworded the message to state what that assertion
actually pins, and added the discriminating check: re-assert after the
first post-recycle flush, the fresh engine's first real append -- a
still-armed facade would degrade it there, so staying true is real
evidence of healing.

Q5: AckAllHandler never reset its ack sequence per connection, unlike
CapturingAckHandler right below it in the same file. A rebuilt engine
restarts its raw FSNs at 0 after a recycle, so the stale, unreset
sequence could satisfy awaitAckedFsn with an ack for a frame that was
never published on the new connection -- a weaker gate than it looks,
even though the specific sequences in these tests happened not to
collide. Reset nextSeq on every new connection, matching the sibling
handler and existing repo precedent for connection-aware ack sequencing.

Q6: both fault-injection tests caught LineSenderException with a comment
claiming parity with MmapFaultDegradesTest's guard, but never actually
asserted the message MmapFaultDegradesTest checks
("failed to persist symbol dictionary before publish") -- so the catch
could equally have swallowed an unrelated connection-level exception.
Added the same message assertion to all three catch sites (two in the
persistent-fault test, one in the healing test) so the comment's claim
now holds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K2Hw6TfgX9sBB2L8oSdaWn
The symbol-dictionary cap error told callers to close the sender and
build a new one, but gave no way to avoid hitting the cap in the first
place. Append a sentence pointing at the automatic dictionary reset
knobs (symbol_dict_reset, symbol_dict_reset_threshold) and the manual
Sender.resetSymbolDictionary() escape hatch, so the error message
matches the recycle feature this client now ships.

Raise QwpConstants.MAX_SYMBOL_DICTIONARY_SIZE from 1_000_000 to
2_000_000 to mirror the server-side constant of the same name, which
moved to 2_000_000 in questdb OSS commit 306062e243 (#7468). That
commit is contained in release tag 10.0.0, the support floor for this
client, so client <= server holds across the whole supported fleet.

Update DeltaDictCeilingTest and GlobalSymbolDictionaryTest, which
pinned the old 1,000,000 value and message text, to the new cap and
add a case that drives the dictionary to the cap with automatic reset
disabled (symbol_dict_reset=off) and a threshold configured at the
cap, confirming the refusal still fires and still names the reset
valve even on a sender that has it switched off.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K2Hw6TfgX9sBB2L8oSdaWn
The new cap-reached-while-armed test never calls sender.flush(): the
2M fill goes through the raw GlobalSymbolDictionary test accessor, and
the one Sender-routed call throws inside symbol() before a row
completes. armIfEligible() only runs from the tail of a completed
flush(), so it never had a code path available to flip isResetArmed()
to true regardless of whether symbol_dict_reset was on or off -- the
two assertFalse(ws.isResetArmed()) checks passed identically either
way and proved nothing about the knob under test.

Drop both assertions and note in the test's javadoc that arming
semantics are out of scope here and are pinned instead by
SymbolDictRecycleArmingTest.testArmsAtThreshold, which drives real
rows through flush() and is the idiom that actually exercises
armIfEligible(). The test's brief-mandated assertion -- the cap
refusal still fires, with the new reset-valve message, when reset is
disabled -- is untouched and remains the load-bearing check here.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K2Hw6TfgX9sBB2L8oSdaWn
recycleForDictReset() no longer latches the sender terminal when the
recycle's reconnect fails. Step 7 moves out of the latching try block:
steps 1-6 still latch recycleFailure (a half-swapped sender genuinely
cannot make progress), but a failed ensureConnected() logs a warning and
rethrows to the triggering caller without latching. By step 7 the swap has
committed, so the sender is coherent - connected == false, loop and client
already closed and nulled by ensureConnected's own catch, the fresh engine
attached, the step-5 epoch and swap counters correctly left incremented -
and the ordinary sendRow() -> ensureConnected() path retries the connect,
and only the connect, on the next send. Nothing re-runs a teardown step,
and nothing can fire a second swap meanwhile: the fresh dictionary sits
below the threshold, manualResetRequested was consumed at step 5, and
maybeRecycleForDictReset requires connected.

This removes a default-configuration brick. A sender built with no
reconnect_* knob resolves initialConnectMode to OFF, so step 7 is a
single-shot connect; a server restart or an LB blip across a drained,
armed sender therefore latched every later table()/flush() call forever,
including seconds later once the endpoint was back.

Deferring the connect to the next send exposed a second defect, which this
commit also fixes. ensureConnected() calls
resetSymbolDictStateForNewConnection(), which cleared
currentBatchMaxSymbolId unconditionally. That watermark is batch-scoped,
not connection-scoped - a flush ships exactly [sentMaxSymbolId+1 ..
currentBatchMaxSymbolId] - and clearing it was harmless only because
build() connects before the application can register a symbol. On the
deferred path symbol() runs first, so the clear made the next flush ship
an empty delta while its rows referenced symbol id 0: rows on the wire
pointing at ids the server never received. The reset now runs only from
the drained state the old code assumed (no pending rows, no row in
progress).

testDefaultConfigRecycleSurvivesFailedReconnect pins both. It drives a
default-config sender (no reconnect knobs), kills the listener at a
drained instant, asserts the recycle's throw reaches the caller, asserts
recycleFailure is not latched by ingesting successfully once the endpoint
returns on the same port, and asserts the recovered stream defines every
symbol its rows reference. Against the pre-fix code it fails on the latch;
with the latch fixed but the watermark clear restored it fails on the
empty dictionary.

Documentation and hygiene alongside:

- getTotalFramesReplayed/getTotalFramesSent/getTotalReconnectAttempts/
  getTotalReconnectsSucceeded/getTotalServerErrors now state that they
  read the live send loop and therefore restart at 0 on every recycle
  ("since the last recycle"), and point at the lifetime-scoped
  getSymbolDictEpoch/getSymbolDictResetsPerformed for correlation.
- Sender.resetSymbolDictionary(), its QwpWebSocketSender override and
  LineSenderBuilder.symbolDictReset() now say that the manual valve is a
  permanent no-op while symbol_dict_reset is off, because armIfEligible
  gates on that knob.
- armIfEligible's javadoc names both call sites; resetSymbolDictionary()
  calls it too, and the stale single-call-site claim invited a wrong
  inlining refactor.
- The builder's symbolDictReset default references
  DEFAULT_SYMBOL_DICT_RESET_ENABLED instead of a hardcoded true.
- SymbolDictRecycleOutageTest joins its trigger thread in a finally so an
  assert failure cannot leave a thread inside the sender, and records why
  the revived server's handshake count stays >= 1 rather than == 1.
- SymbolDictRecycleCrashWindowsTest inlines an unread handler binding, and
  SymbolDictRecycleHealingTest's AckAllHandler carries a warning that its
  per-connection ack reset assumes every connection change is a recycle
  and must not be copied into a plain-reconnect test.

Client suite: 3045 run, 0 failures, 0 errors, 2 skipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K2Hw6TfgX9sBB2L8oSdaWn
jovfer and others added 4 commits August 19, 2026 13:46
Review C1: recycleForDictReset step 3 re-acquired the slot flock without
waiting for a deferred close to release it. When the SF worker is wedged
in a syscall past SegmentManager's bounded join, CursorSendEngine.close()
returns with the flock retained and isCloseCompleted() false, releasing
both from the worker's exit path; the step-6 rebuild then threw
SlotLockContentionException on the retained flock and latched the sender
permanently terminal -- converting exactly the transient disk stall the
deferred-close machinery exists to survive into a hard sender death on
the default (SF + recycle-on) path.

recycleForDictReset now mirrors close()'s deferred-close discipline:
awaitDeferredEngineClose parks (awaitAckedFsn-shaped) until the deferred
cleanup confirms the flock release, re-arming the shared flock-release
retry driver each pass like isSlotLockReleased() does. Only exhausting
the 30 s budget -- a genuinely dead worker -- latches terminal, and that
path hands the still-locked engine to retainedEngine so a pool re-probe
recovers the slot's capacity if the worker ever exits; close() no longer
clobbers slotLockReleased to true while such an engine is pending.
SymbolDictRecycleDeferredCloseTest pins both branches with a wedged-
worker harness (red-proofed: without the await, the survival test dies
with the exact SlotLockContentionException the review traced).

Review Mo1: getAckedFsn and awaitAckedFsn snapshot cursorEngine into a
local (the recycle transitions it non-null -> null -> non-null), and
while it is null they report the durable watermark the recycle barrier
proved (new lastRecycleDurableFsn) instead of collapsing to -1.

Review Mo2: PooledSender forwards resetSymbolDictionary() to the live
delegate instead of inheriting the interface's no-op default; pinned by
a pooled-path test.

Review Mo5 (verified: released 9.4.x servers cap the dictionary at 1M):
document the pre-10.0.0 compatibility constraint on the 2M client cap at
QwpConstants.MAX_SYMBOL_DICTIONARY_SIZE and symbolDictReset(boolean).

Review mi7/mi8: step 6 refuses a rebuilt engine that recovered from disk
(the empties-the-slot contract was breached) and resets slotLockReleased
after a successful swap. Review mi3: recycle javadoc now says seven
steps / steps 2-6, the resetArmed and resetSymbolDictionary docs match
the code, and EngineRebuildFactory moves out of the field block.

Review Mo4: SymbolDictRecycleFsnContinuityTest wraps every test in
assertMemoryLeak, and LineSenderBuilderWebSocketTest pins the config
boundaries: threshold == 2M accepted on both config paths, the
symbol_dict_reset=on parse branch, the invalid-value message, and the
three fluent-setter transport guards.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
getAckedFsn()/awaitAckedFsn() read cursorEngine, fsnEpochBase and
lastRecycleDurableFsn from monitoring threads while the recycle
reassigns all three on the producer thread. The sibling observability
counters went volatile in an earlier round for exactly this reader;
these three carry the same contract, so a monitor could observe a
fresh engine with a stale epoch base and report an FSN dip to -1.
Also document resetSymbolDictionary() as producer-thread-only: it
mutates unsynchronized producer state, and the fire-and-forget javadoc
framing invited cross-thread calls (notably via PooledSender).
Red first: the outage tests now assert the store-and-forward contract
(producer never sees a transport error nor a reconnect budget after the
initial connect) instead of pinning the step-7 foreground connect they
used to. The production change lands in the next commit.
Step 7 of the symbol-dict recycle re-ran the initial-connect policy on
the producer thread: OFF senders got a single-shot connect whose failure
made every subsequent send throw before buffering until the endpoint
returned, and SYNC senders blocked the producer up to
reconnect_max_duration_millis. Both violate the store-and-forward
contract (post-init, the client never exposes transport problems and
never imposes a reconnect budget on the producer).

ensureConnected() now latches hasConnectedOnce on its first completion
and routes every later entry through the existing ASYNC (deferred)
branch: the loop is built and started synchronously, the socket connect
happens on the I/O thread with indefinite retry, and the producer keeps
buffering into the fresh epoch's slot. This is the same path ASYNC-mode
senders already took at step 7. The server clears its per-connection
dictionary on disconnect, so the buffered fresh-epoch frames
(deltaStart=0) replay correctly on reconnect.
jovfer and others added 2 commits September 18, 2026 21:08
…not terminal

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

The recycle barrier checked only the producer-side connected flag,
which stays true while the I/O thread reconnects, and then joined the
loop through close(). close() cancels a live socket in milliseconds
but cannot reach a reconnect blocked in a hostname resolve (the
resolve runs after the socket exists) or in an in-flight credential
pull, so during such an outage table() blocked for the 30 s join
budget and then threw, where base returned at once. The barrier now
also requires the loop to be up (running, not in its reconnect loop,
client connected and upgraded); while the loop is between connections
the recycle stays armed and runs at the first drained barrier after
the reconnect. The two outage tests pin the deferred swap instead of
a mid-outage one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@jovfer

jovfer commented Sep 22, 2026

Copy link
Copy Markdown
Contributor Author

Tandem code review — level 3

questdb/questdb-enterprise#1171 · questdb/questdb#7525 · #91 (branch qwp-dict-recycle-r1), reviewed as one logical change.

Verdict: client approve with comments, OSS approve, ENT approve with comments. Nothing blocks. The three fixes this round are correct, and CI shows no failure this change causes.

PR Head reviewed Base
#91 91fd4451 981bdb02
questdb/questdb#7525 b8acf7098a 6f1d196f57
questdb/questdb-enterprise#1171 5532b1590 0e4f18001

Scope. Both submodule pointers move to commits that exist only on the feature branch, so both are in scope:

  • questdb: OFF-DEFAULT, in scope.
  • java-questdb-client: OFF-DEFAULT, in scope.

The OSS and ENT test files are byte-identical to the previous review. The only new code is the four client commits. The OSS/ENT merges brought in upstream code only.

PR metadata. The titles follow the convention, Fixes #80 heads the client body, and the labels fit. The body issues are Minor 5.


Moderate

1. [client] The public javadocs don't mention the new live-connection gate

  • Problem: the javadocs describing when the recycle runs, and the monitoring advice, leave out the live-connection gate.
  • Net impact: API users and operators will misread a recycle that is waiting for an outage to end.
  • Evidence: compare Sender.java:698-704 with QwpWebSocketSender.java:5632-5635, plus scratch runs at 91fd4451 and ce20e09b.

Commit 91fd445 makes the table() barrier return early while isLinkUp() is false, before it checks the ring or enters the starvation wait. That is the design the PR body describes. These texts were not updated:

  • resetSymbolDictionary(), Sender.java:698-704: says "The reset runs at the next table(...) call that finds all published data acknowledged and no row in progress", and blames indefinite deferral only on "sustained saturation".
  • symbolDictReset, Sender.java:1923: states the same condition.
  • symbolDictResetMaxWaitMillis, Sender.java:1979-1995, and the constant comment at QwpWebSocketSender.java:149-158: say the next table() "BLOCKS … an outage costs the producer one bounded pause". Now a table() call made while the loop is reconnecting skips the wait.
  • Monitoring advice, Sender.java:2001-2005 and QwpWebSocketSender.java:2616-2625: say that "armed while the epoch does not advance" means no row start saw a drained backlog, or that the one wait already timed out. During an outage the backlog can be drained and the wait is never entered.

Scratch tests, same source at both revisions:

Trigger 91fd445 ce20e09
All data acked, resetSymbolDictionary(), server killed, reconnect started, then table() 0 ms, epoch 0, still armed epoch 1
Armed longer than max wait (500 ms) with unacked rows, server killed, then table() 0 ms, 0 starvation timeouts, no WARN 500 ms, 1 timeout

Base has no recycle, so this is a new surface.

Fix:

  • Add "and the I/O loop holds a live connection" to both trigger sentences.
  • In the max-wait javadoc and the constant comment, say that a call made while the loop is reconnecting skips the wait.
  • Add "or the sender is between connections" to both monitoring paragraphs.

2. [ENT] The failover test's javadoc describes mid-outage swaps the pinned client no longer does

  • Problem: the test's javadoc says a swap can run mid-outage, and the pinned client no longer allows that.
  • Net impact: readers will misjudge what the test and its recycledDuringOutageWindow log line exercise.
  • Evidence: compare SqlFailoverQwpRecycleLosslessTest.java:52-68 with client QwpWebSocketSender.java:5632-5635 and CursorWebSocketSendLoop.java:1848.

What the javadoc says:

  • :52-53: "What actually withholds a swap during the outage is isRingDrained()".
  • :59-61: "if the ring happens to be drained the instant the outage begins, the very next table() call runs the swap".
  • :63-64: "the swap and the epoch bump return promptly even mid-outage".

What happens at 91fd445:

  • Once the I/O thread sees the drop, connectLoop sets lastReconnectError, isLinkUp() reads false, and the barrier returns before it checks isRingDrained().
  • The client's rewritten SymbolDictRecycleOutageTest asserts exactly this.
  • The same gap applies to :117-120 ("an arm attempt that catches the ring undrained during the outage gives up quickly"): during an observed outage, an arm attempt never reaches the wait.

How it happened: the javadoc was last edited in f522c77e5, when the pin pointed at a client without the gate. d82692f34 moved the pin without touching the javadoc. The ENT PR body now says the javadoc is "written against that path", which isn't true.

No assertion depends on this text. The test passed on the Windows lifecycle leg in both builds.

Fix: rewrite :52-68 and :117-120 in terms of the gate. A swap between the demote and the promote can now only land in three places: before the drop is observed, after reconnecting to B, or inside the connect race that earlier reviews accepted. That is what recycledDuringOutageWindow measures. Once the javadoc is rewritten, the body sentence becomes true.

3. [client] Coverage gap: the fix for the previous review's build() leak has no deterministic test

  • Problem: the closed-only check in setEngineRebuildFactory is not pinned by any test.
  • Net impact: if the check reverts, build() throws again, and if the setters also move back outside the guard, the leak returns.
  • Evidence: with the setter changed back to checkNotClosed() at 91fd445, the whole client suite (3,550 tests) stays green.

Only five test classes call the setter, and all of them use healthy senders. SenderPoolDataLossNotificationTest catches the full regression only when a delay is inserted into build():

  • Setter reverted and both setters moved back outside the guard: 4/4 red, MMAP_DEFAULT … 4206592.
  • Setter reverted alone: the pool logs "could not open slot … deferring this and remaining slots", but the test still passes.

A regression would expose the same users as before:

  • pool startup recovery where the server rejects the replayed rows as terminal;
  • initial_connect_retry=async with a 401;
  • an SF restart whose backlog gets a terminal NACK.

Fix: add a timing-free test that needs no production hook and runs in 0.2 s:

  1. Use a raw server that answers every upgrade with 401, and build with initial_connect_retry=async.
  2. Wait until the error handler receives the TERMINAL error.
  3. Check that table() throws, which shows the error is latched but the sender is not closed.
  4. Call setEngineRebuildFactory(f) directly and assert it does not throw.

In a scratch run this test was green 6/6 at head and red 2/2 with the setter reverted.

4. [client] Coverage gap: inverting the interrupt assertions removed the only test of the parks' anti-spin clear

  • Problem: nothing tests any more that each loop iteration in the two recycle parks clears the interrupt flag.
  • Net impact: if that clear is removed, an interrupted producer spins a core for up to 2 s (starvation wait) or 30 s (deferred close).
  • Evidence: with the clear deleted in both parks, 79/79 recycle tests are green at 91fd445. The same edit at ce20e09 turns 2 tests red.

Without wasInterrupted |= Thread.interrupted() (:5337, :5597), parkNanos returns immediately while the flag is set, so the loop spins instead of parking.

  • The old swallow assertions caught this indirectly.
  • The inverted "flag handed back" assertions also pass with the clear deleted, because the flag is simply never cleared.
  • The timeout tests don't catch it either, because the loop still respects its deadline.
  • Both test javadocs still say the park "cannot busy-spin".

Producer-thread CPU time vs wall time with the clear deleted:

  • 150 ms drained wait: head uses 7–8 ms of CPU, the mutant 149–154 ms.
  • 300 ms timeout wait: head uses 14–17 ms, the mutant 300 ms.
  • Under 2× CPU oversubscription the mutant still used 215 of 300 ms.

Fix: in the two timeout-exit interrupt tests, assert that ThreadMXBean.getCurrentThreadCpuTime() over the table() call stays below half the wall time. Guard it with Assume on isCurrentThreadCpuTimeSupported(). The API has existed since JDK 5, so it respects the Java 8 floor.


Minor

5. [OSS][ENT] The PR bodies predate the gate

  • Problem: the OSS body leaves the gate out of its list of client changes, and the ENT body says its test javadoc matches the gate when it does not.
  • Net impact: reviewers read a description that disagrees with the pinned client.
  • Evidence: the OSS body text, compared with QwpWebSocketSender.java:5632-5639.

The OSS body says "the fuzz producer can pause up to 2 s … when a bounce catches the ring undrained". With the gate, the producer pauses only if the wait began while the link was up. For ENT, see finding 2: the body claims the javadoc is "written against that path", and it repeats the "gives up quickly rather than parking the writer" wording.


Coverage map

The test gate passes: there is no Critical coverage gap, and two Moderate gaps (findings 3 and 4). The new gate itself is well tested:

  • Deleting the gate, or its lastReconnectError == null condition, turns both rewritten outage tests red.
  • Reverting either interrupt hand-back turns its inverted assertion red.

Fix verification

build() leak. A 300–500 ms delay was forced after connect:

  • At head, build() returns, the first call surfaces the terminal error, and nothing leaks.
  • At ce20e09, it leaks MMAP_DEFAULT … 4206592, the same number as the earlier Windows CI failure.
  • The client's Windows CI leg is now green.

Live-connection gate.

  • With a 2 s token pull that ignores interrupts, ce20e09's table() took 2,001 ms. At head it returns in 0 ms and the recycle stays armed.
  • On healthy links the gate read "up" in all 12 combinations of connect mode × memory/SF × durable-ack, and every barrier swapped.
  • A stress run made 2.45 billion concurrent isLinkUp() calls across 4,145 client swaps, with no exceptions.

Interrupt hand-back. With the interrupt flag set on entry, all of these committed and handed the flag back:

  • the heal pass;
  • a deferred close of a recovered engine;
  • a memory-mode REBUILD resume;
  • 20,000 rows.

Store-and-forward contract. Nothing changed this round lets a transport error, a reconnect time limit, or a hard failure reach the producer.

CI

  • Client: all 16 checks green, including Windows.

  • ENT PR-merge build 272038: green.

  • enterprise-ci build 272039: one red test on windows-amd64-lifecycle, EntLifecycleOrchestratorTest.testSwitchRoleOwnershipRefusalTornArmFuzz. It timed out after 30 s inside LOG.advisory → nextBully. It is not caused by this change:

    • Lines logged within the same millisecond reached the agent 63–65 s later. The log consumer was blocked writing to the agent's stdout, so the log ring filled up.
    • The PR changes no ENT or OSS production code, and the test imports nothing from the client.
    • The same test passed in 281 ms in build 272038.
    • SqlFailoverQwpRecycleLosslessTest passed on that same leg.

    Rerun the stage.

Summary

PR Verdict
#91 approve with comments (findings 1, 3, 4)
questdb/questdb#7525 approve (Minor 5 only)
questdb/questdb-enterprise#1171 approve with comments (finding 2)
  • Severity: 0 Critical, 4 Moderate, 1 Minor, all inside the diff.
  • Test gate: passes.

🤖 Generated with Claude Code

@bluestreak01 bluestreak01 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jovfer

PR #91 — level 3

Critical

Problem: A released public constructor no longer links after upgrading.
Net impact: Direct policy-aware loop users cannot construct their ingestion loop.
Evidence: Identical 1.3.8-compiled caller passes release/base 981bdb02; HEAD 91fd4451 throws NoSuchMethodError.

Location: core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoop.java:945–964 — in-diff.

Adding externalFsnBase replaces the published 13-argument constructor instead of preserving it. Applications compiled against that constructor fail when replacing the dependency, before connection or ingestion begins. This is a deterministic, net-negative compatibility regression; facade-only callers are unaffected.

Independent falsification checked whether this was internal or unpublished: the constructor is documented, exported, and present in the published 1.3.8 JAR. Retained shorter overloads cannot satisfy its JVM descriptor.

Fix: Preserve the 13-argument overload, delegating with externalFsnBase = 0L, and pin its signature in compatibility tests. That bridge passed the unchanged caller and 135 focused tests in scratch.

Reproducer · Base output · HEAD failure

Coverage and validation

  • Test gate: pass — 0 admitted coverage gaps.
  • JDK 8: 490 tests across focused runs; zero failures/errors, one skipped.
  • JDK 25: core production and test compilation passed.
  • Reviewed linked OSS #7525 real-server/fuzz coverage and Enterprise #1171 failover coverage, including assertions and CI wiring; not executed locally.

Provenance

  • Client PR #91: no submodule-pointer changes.
  • Enterprise → OSS dd7ad37a: OFF-DEFAULT.
  • OSS → client 91fd4451: OFF-DEFAULT.

Summary

Verdict: request changes.
Severity: 1 Critical, 0 Moderate, 0 Minor. Attribution: 1 in-diff, 0 out-of-diff-breakage.

Primary checkout unchanged; no review posted.

jovfer and others added 7 commits September 23, 2026 16:59
Adding externalFsnBase widened the policy-aware constructor in place, so
a caller compiled against 1.3.8 or 1.3.9 failed with NoSuchMethodError
before it could connect. The 13-argument form is back as an overload that
delegates with externalFsnBase = 0, and ExportedApiCompatibilityTest now
pins every public constructor present in the 1.3.9 jar, copied from its
javap output rather than derived from the current class.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…upplier overload linking

Adding the symbol-dictionary recycle knobs widened the twenty-three-arg
connectWithCredentialSupplier master in place, so a caller compiled
against the merge base failed with NoSuchMethodError before it could
connect - the same in-place-widening defect Task 1 found and fixed for
CursorWebSocketSendLoop's constructor. The twenty-three-arg form is back
as an overload that delegates to the new master with the symbol-dictionary
knobs defaulted, and ExportedApiCompatibilityTest now pins every
connectWithCredentialSupplier signature present at the merge base, the
same way it already pins connect and createLineSender.

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

Reverting the setter to the connection-error check kept every test
green: nothing reached it with a latched terminal and an open sender,
which is the state build() races into under async initial connect. A raw
always-401 server plus initial_connect_retry=async reaches that state
without timing; the test then calls the setter directly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Handing the flag back on every exit removed the only test that noticed
the parks' per-iteration Thread.interrupted() clear: without it parkNanos
returns at once while the flag is set and the wait burns a core until its
deadline, and the flag-restored assertions pass either way. The two
timeout-exit tests now also bound the caller's CPU time over the call to
half its wall time (head: about 15 ms of 300; the mutant: all 300).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DygjtwxuAgHHZi8CEb7U1c
The trigger sentences, the max-wait javadoc, the default's comment and
both monitoring paragraphs described the barrier before it was gated on
the I/O loop holding a live connection: a table() call made while the
loop is reconnecting now skips the wait and leaves the recycle armed, and
armed with a flat epoch also means the sender is between connections.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DygjtwxuAgHHZi8CEb7U1c
… guard main

The connectWithCredentialSupplier overload restored this round is on
main since the OIDC device-flow change and has not shipped in a
release; the compat test's javadoc said every pinned signature came
from an earlier release and cited a merge base that predates the
method. It now cites the branch's merge base and names the one pin
that protects callers building against main rather than a jar.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DygjtwxuAgHHZi8CEb7U1c
@jovfer

jovfer commented Sep 23, 2026

Copy link
Copy Markdown
Contributor Author

The released 13-argument CursorWebSocketSendLoop constructor is back as an overload delegating with externalFsnBase = 0, and ExportedApiCompatibilityTest now pins all seven public constructors present in the 1.3.9 jar, copied from its javap output. An exported-signature diff of this head against the merge base (javap over every exported package, at the merge base, at the head and in the 1.3.9 jar) found one more in-place widening, QwpWebSocketSender.connectWithCredentialSupplier (23 → 26 arguments; on main since the OIDC device-flow change, not in any release); its 23-argument form is back the same way and both merge-base overloads are pinned. After both, the diff against the merge base is empty; against the 1.3.9 jar it lists 18 synthetic Outer$1 anonymous-class artifacts that were already absent at the merge base. Head: ba54a13.

@jovfer
jovfer requested a review from bluestreak01 September 23, 2026 17:34
@jovfer jovfer added the READY label Sep 24, 2026
Document that the connect and connectWithCredentialSupplier overloads
require a non-null engine rebuild factory before recycling can run.
Link to the setter and the WebSocket builder that installs it.

Validated compilation and Javadoc generation on JDK 8 and JDK 25.

@bluestreak01 bluestreak01 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jovfer

PR #91 review, level 3

Verdict: request changes. One Critical finding, one Moderate coverage gap, no Minor.

  • Revisions: PR head aaabff3e, merge base 916e51f6.
  • Submodules: the PR moves no submodule pointers.
  • Title and description: the title follows Conventional Commits, Fixes #80 is at the top of the body, and the labels fit.

Critical

Problem: A recycle unlocks the slot, so a second sender can take it.
Net impact: The running sender with the duplicate sender_id throws on every call while the intruder lives.
Evidence: A probe run on JDK 8. Head aaabff3e reproduced it in 2 of 2 runs. Base 916e51f6 did not reproduce it: 583,500 of 583,500 attempts by B were refused.

Where: this is in-diff. The chain is:

  1. QwpWebSocketSender.java:5871-5872: step 3 calls outgoing.close(). This releases the slot's directory lock and removes the logical lock file.
  2. :5481: rebuildEngineOrAbandon.
  3. :5726: engineRebuildFactory.rebuild.
  4. Sender.java:3449: constructEngineOnSlot takes the locks again.

The contract this breaks: the senderId javadoc at Sender.java:2987-2996 says the slot is "locked exclusively for the sender's lifetime via flock" and that "the second sender to start with a colliding id fails fast with 'sf slot already in use'".

Trigger:

  • Sender A is running with the default symbol_dict_reset=on and sf_dir set.
  • Sender B is configured with the same sf_dir and sender_id, and retries build() after it fails. Realistic sources of B are a rolling-deploy overlap, a supervisor restart loop, or an accidental duplicate instance.
  • B's build() lands between A's step-3 close and its step-4 rebuild, and succeeds.
  • A's rebuild then fails with SlotLockContentionException and stays in the REBUILD resume state with cursorEngine == null.
  • Every later table(), at() or flush() on A throws "symbol dictionary recycle could not rebuild its engine; retried on the next send". The message says the failure is transient, but it lasts as long as B holds the slot.

Probe results: the probe is a scratch JUnit test. Sender A ingests and flushes in a loop while a second thread calls Sender.fromConfig(cfg) for B in a tight loop, with the same sf_dir and sender_id at both revisions.

Revision B's build() A while B is open A after B closes
Head, run 1 succeeded on attempt 95,639, when A was at epoch 1 0 rows ok, 2,630 exceptions 50 of 50 ok
Head, run 2 succeeded when A was at epoch 3 0 rows ok, 2,599 exceptions 50 of 50 ok
Base 0 of 583,500 attempts succeeded, all SlotLockContentionException 2,057,013 rows ok, 0 errors –

The probe triggers recycles with resetSymbolDictionary() to raise their frequency. Even so, B won within the first few completed recycles, so the window is the whole close-and-rebuild span, not a few microseconds. A natural threshold recycle opens the same window.

Net effect: this is worse than base (net-negative).

  • Who is affected: deployments where two senders share sf_dir and sender_id at the same time. The product explicitly anticipates this case and documents the outcome.
  • Change from base: at base the newcomer fails fast and the running producer carries on. At head the running producer stops accepting rows for an unbounded time, and its error message is misleading.
  • Other safeguards: there is no silent loss, because the barrier proved everything acknowledged and new rows are rejected loudly. Nothing else found holds the slot during the window; I searched SlotLock, constructEngineOnSlot and the recycle steps.
  • Store-and-forward rule: this also breaks the rule that a running store-and-forward producer never hard-fails after initialization.

Suggested fix: keep the slot exclusively owned across steps 3 and 4.

  1. Before step 3, have the rebuild factory take SlotLock.acquireLogical(slotPath).
  2. Close the outgoing engine with close(false), which already exists for callers that hold the logical lock and skips removing the lock file.
  3. Rebuild through constructEngineOnSlotLocked while the lock is still held, so a colliding build() fails when it tries to take the logical lock.
  4. Add a regression test shaped like the probe, and check that it is red on the current head.

Moderate

Problem: The per-window starvation resets and the age guard are untested.
Net impact: A regression would pause each table() right after arming, or stop recycling after the first starvation wait. Either way the harm is bounded.
Evidence: mutation runs at aaabff3e. Under each mutation below, all 148 tests matching SymbolDictRecycle*Test, SymbolDictResetDefaultsTest and SenderPoolSfTest stay green.

  • M1: delete QwpWebSocketSender.java:5348 (starvationWaitDoneThisArm = false).
  • M2: delete :5347 (armedSinceNanos = …).
  • M3: delete the age guard at :5644.

Why nothing catches them:

  • No test arms a second window after a starvation wait has already run.
  • Every test that enters the wait first sleeps for maxWait + 50 ms.
  • testContinuousFlushesPreserveStarvationWindow, added in this PR's latest commit, only asserts the final count of 1.

Suggested fix: extend that test to cover a second armed window.

  1. Release acks until the first recycle commits (epoch 1).
  2. Gate acks again and re-arm with resetSymbolDictionary().
  3. Assert that a table() inside maxWait returns promptly with the timeout count unchanged. This catches M2 and M3.
  4. Assert that the count then reaches 2. This catches M1.

Coverage map

  • Test gate: passes. The only admitted coverage gap is the Moderate one above.
  • Local run: 484 related tests on JDK 8 at head, with 0 failures, 0 errors and 1 skipped.
  • Tandem PRs: both exist and are cross-linked with this PR: OSS questdb/questdb#7525 (e2e and fuzz) and Enterprise questdb/questdb-enterprise#1171 (failover). #7525's submodule pin points at ba54a138; head adds only javadoc and test commits on top of it.

Summary

  • Severity: 1 Critical, 1 Moderate, 0 Minor. Both are in-diff; no out-of-diff breakage was admitted.
  • Checked and not reported: lost or duplicated rows, epoch-offset errors in the public sequence numbers (FSNs), symbol IDs referenced before registration, durable-ack mode, deferred-commit groups, and binary compatibility.

jovfer and others added 9 commits September 25, 2026 03:58
The recycle's step-3 fully-drained close released the slot's directory
flock and unlinked its parent-anchored logical lock, and step 4 took both
again, so a colliding build() with the same sf_dir and sender_id could
land in between. The incumbent's rebuild then failed with contention on
every later row start for as long as the intruder lived, and latched
terminal if the intruder exited with unacknowledged frames -- against the
senderId contract that the second sender fails fast and the first is
untouched.

Take the logical lock before tearing the outgoing stack down, close the
outgoing and any healing engine without reclaiming it, rebuild through
constructEngineOnSlotLocked under it, and release it once the fresh engine
holds the directory flock -- also on the breach latch and in close(), so
an abandoned swap never keeps the slot from a successor.

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

The recycle previously delegated taking the parent-anchored logical lock
to a new pair of EngineRebuildFactory methods (acquireLogicalSlotLock /
rebuildLocked). That broke SymbolDictRecycleDeferredCloseTest's mid-recycle
factory swap: its stand-in lambda fell back to the real factory's
rebuild(), which self-locked through constructEngineOnSlot and contended
with the lock the recycle already held.

The sender now takes the logical lock itself in recycleForDictReset's
step 0, from the live engine's slot directory (CursorSendEngine.sfDir()),
instead of asking the factory for it. Every factory's rebuild(...)
constructs under that lock without taking it again: Sender.build()'s
factory now calls constructEngineOnSlotLocked directly, so the unlocked
constructEngineOnSlot entry point has no callers left and is deleted.
EngineRebuildFactory keeps its original two methods; only its javadoc
documents the lock contract.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A colliding build at the rebuild boundary, while a rebuild is pending,
after the breach latch and after close(); and a contended step-0 lock
acquisition that refuses one row and tears nothing down. Each test is red
against the mutation of the production line it guards.

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

Window 2's "must not wait" assertion raced the post-recycle reconnect,
which runs asynchronously on the I/O thread: maybeRecycleForDictReset()
refuses to touch the age guard while the loop is not yet linked, so the
timing check was passing for an unrelated reason and never exercised
armedSinceNanos or the age guard it was meant to pin. Publish a row and
await its ack first, proving the fresh loop is linked, before gating
acks and taking the timing measurement.

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

The wait snapshotted the engine once and read the epoch base afterwards,
so a monitor thread could pair the outgoing engine with the rolled base
(returning true for an FSN the fresh epoch had not published) or keep
polling the closed engine and time out. Poll through getAckedFsn's
base-first clamped read instead; pinned by a monitor parked across a real
recycle.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The epoch offset a recycle applies is memory-only, so a process restarted
on the same slot numbers from the slot's on-disk state. Say so where users
read FSN contracts: flushAndGetSequence, getAckedFsn and SenderError.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…cle lock before the owned engine's close

checkConnectionError read the volatile cursorSendLoop twice, so a monitor
thread polling awaitAckedFsn could NPE when the recycle nulls the loop.
closeRemainingResources now releases the recycle's logical lock before the
owned engine's reclaiming close, so an abandoned swap followed by close()
no longer leaves the .slot-locks pair behind. Contract docs on the engine
close and SlotLock now name the recycle as a logical-lock holder.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@mtopolnik

Copy link
Copy Markdown
Contributor

[PR Coverage check]

😍 pass : 511 / 579 (88.26%)

file detail

path covered line new line coverage
🔵 io/questdb/client/Sender.java 75 90 83.33%
🔵 io/questdb/client/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoop.java 41 47 87.23%
🔵 io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java 363 410 88.54%
🔵 io/questdb/client/impl/PooledSender.java 2 2 100.00%
🔵 io/questdb/client/cutlass/qwp/client/sf/cursor/CursorSendEngine.java 11 11 100.00%
🔵 io/questdb/client/impl/ConfigSchema.java 3 3 100.00%
🔵 io/questdb/client/cutlass/qwp/client/sf/cursor/CursorSendCounters.java 16 16 100.00%

@jovfer

jovfer commented Sep 25, 2026

Copy link
Copy Markdown
Contributor Author

Tandem review: client #91 / OSS #7525 / ENT #1171 (level 3)

Reviewed heads: client 0496f037, OSS 186cd29978, ENT f0e373da3.

Scope: both submodule pointers point at unmerged feature-branch commits, so both are reviewed as part of this change. The OSS and ENT test files are unchanged since the previous review. The new code under review is client-side: the recycle now holds the slot's logical lock for the whole swap, awaitAckedFsn re-reads the acked position on every poll, and the docs now say FSNs are scoped to one sender instance.

Critical

None.

Moderate

[client] Recycle refuses every row forever when the slot's logical lock can never be taken. Location: QwpWebSocketSender.java:5870-5881.

  • Problem: step 0 treats a failure that will always repeat as brief contention, so the sender stays armed and refuses rows.
  • Net impact: a sender built with direct connect() and a custom rebuild factory stops ingesting once the recycle arms.
  • Evidence:
    • A probe test at 0496f037: 20 of 20 rows refused, 0 frames sent.
    • The same probe at the previous head ba54a138: 0 refused, the recycle commits.
    • Control run at 0496f037 with a normal slot path that has a parent directory: the recycle commits.

Step 0 turns any non-Error throw from SlotLock.acquireLogical(cursorEngine.sfDir()) into "recycle deferred … retried at a later row start" and leaves the sender armed. Two cases make that throw repeat on every attempt:

  • The slot path has no parent directory, for example a relative "sfslot". resolveLogicalLock returns null, so acquireLogical throws IllegalArgumentException (SlotLock.java:148-150).
  • The parent directory is not writable. acquireLogical throws SfOperationalException: could not create logical slot lock dir.

The refused row never starts, so the ring stays drained and every later table() goes straight back to step 0. An empty flush() does not re-run armIfEligible(), and setEngineRebuildFactory(null) does not disarm the sender either. On the previous head, step 3's close(true) → removeOrphanLogical silently did nothing for these paths, and the custom factory rebuilt the engine and the recycle committed.

How it is reached: a caller uses the public connect(...) with its own CursorSendEngine and installs a factory with setEngineRebuildFactory, as the new javadoc on all four connect overloads tells them to. build() is not affected: it builds slotPath as sfDir + "/" + senderId and takes acquireLogical itself at startup, so a bad parent directory fails the build instead.

Why this is Moderate rather than Critical: EngineRebuildFactory is new in this PR (not in 1.3.9, not at the base), and the failure also needs a slot path that build() never produces. No caller in any of the three repos does this.

Suggested fix:

  • Skip step 0 when resolveLogicalLock would return null, the same way removeOrphanLogical silently does nothing for such a path.
  • Keep the refuse-this-row-and-retry-later path only for SlotLockContentionException. For any other failure, disarm the recycle or latch recycleFailure instead of refusing rows forever.
  • Add a direct-connect test with a slot path that has no parent directory.

Minor

None.

Coverage

The test gate passes, with no coverage gaps admitted.

A mutation run over 232 recycle-related tests caught three changes:

  • removing the step-0 lock;
  • removing the lock release when the recycle breach terminates the sender;
  • reverting awaitAckedFsn to a single engine snapshot.

These mutants survived, but none changes user-visible behavior beyond leftover lock files:

  • keeping the lock after the recycle commits;
  • reordering the release in close();
  • closing the outgoing engine with close() instead of close(false). This behaves identically, because removeOrphanLogical takes the lock before it unlinks anything.
  • dropping the in-loop error check in awaitAckedFsn.

Verdict

  • client: approve with comments. The Moderate above should be fixed in this PR.
  • OSS: approve.
  • ENT: approve.

At review time CI was green on the client and ENT PRs. The OSS macwin run was still pending.

🤖 Generated with Claude Code

@jovfer jovfer added the READY label Sep 25, 2026

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 million of district symbols limit

3 participants