Skip to content

fix(qwp): fix store-and-forward recovery data loss and unbounded client shutdown - #73

Open
bluestreak01 wants to merge 13 commits into
mainfrom
fix/qwp-deferred-close-echo-wait
Open

fix(qwp): fix store-and-forward recovery data loss and unbounded client shutdown#73
bluestreak01 wants to merge 13 commits into
mainfrom
fix/qwp-deferred-close-echo-wait

Conversation

@bluestreak01

@bluestreak01 bluestreak01 commented Jul 27, 2026

Copy link
Copy Markdown
Member

Important

Tandem PR — merge all three together.

questdb/questdb#7366 pins this branch through its java-questdb-client submodule, and questdb/questdb-enterprise#1113 pins that branch in turn. All three use the branch name fix/qwp-deferred-close-echo-wait.

Problem

The QWP role-change handoff (questdb/questdb#7366) flushes a final durable ACK, sends a WebSocket CLOSE, and drains the tail before tearing down TCP so a store-and-forward producer cannot replay rows the promoted replica already owns. Reviewing that work surfaced a set of independent store-and-forward defects on the client side — SIGBUS on recovered sparse segments, silent data loss on unreadable segments, an unbounded shutdown daemon, and a slot released before its worker quiesced. This PR carries those fixes plus the test-stability work the same review required.

Changes

Store-and-forward recovery correctness

  • Fix SIGBUS risk when appending into recovered sparse SF segments. A segment recovered with holes could be appended into beyond its materialised extent, faulting the writer.
  • Fail closed on unreadable SF segments and enumeration errors during recovery. Previously a segment that could not be read was skipped, silently dropping buffered rows; recovery now surfaces the failure instead of losing data quietly.
  • Avoid a double directory scan when registering a recovered SF ring.
  • Make bulk SF trim O(S) via a single range removal in drainTrimmable, instead of per-entry removal.

Shutdown and lifecycle

  • Make engine close a worker-quiescence barrier before releasing slot resources — the slot stayed locked until the manager worker actually stopped, closing a window where resources were freed under a live worker.
  • Bound the deferred-close daemon in SenderPool / QueryClientPool and abandon permanently stuck engines rather than hanging shutdown indefinitely.
  • Cover deferred-close caller fallbacks.

Tests

  • Deflake the close-lifecycle interrupt tests on saturated CI agents; keep the interrupted-close regression revert-proof and cover the mid-join interrupt.

Close-code handling — added then reverted

This branch briefly classified a private-use ROLE_CHANGE (4001) close as orderly, to match a server that emitted 4001 on the role-change CLOSE. Both halves are now reverted: questdb/questdb#7366 keeps NORMAL_CLOSURE on the wire, and this PR strips the client-side classification.

The reasoning, recorded in design/qwp-nack-policy-v2.md: 4001 only let the server distinguish a genuine CLOSE echo from a voluntary client CLOSE that crossed it, and the server takes identical action on both branches — the code selected a log line. Meanwhile every client released before that change treats any code outside NORMAL_CLOSURE/GOING_AWAY as a head-of-line poison strike, escalating a routine demote to a PROTOCOL_VIOLATION terminal that quarantines the store-and-forward slot holding the un-acked rows. That trades data on mixed-version fleets for a diagnostic. A distinguishable role-change code needs a negotiated capability with NORMAL_CLOSURE as the fallback; the design doc now says so explicitly rather than leaving the absence unexplained.

Net effect on this PR's diff: zero. The commits remain in history because the branch is squash-merged.

Test plan

  • CursorWebSocketSendLoopPoisonFrameTest, CursorWebSocketSendLoopErrorClassificationTest — 25 tests, green.
  • CursorSendEngineSlotReacquisitionTest, CursorDeferredCloseCallerTest.
  • MmapSegmentRecoveryFaultTest, SegmentRingEdgeRecoveryTest, SegmentRingRecoveryGenerationTest, SegmentRingTest.
  • Full core suite including QuestDBImplCloseLifecycleTest; repeat both acknowledged-interrupt cases 15× pinned to one CPU; verify a deliberately restarted deadline fails the regression assertion.
  • End-to-end: SqlFailoverQwpDeferredCloseExactlyOnceTest from questdb/questdb-enterprise#1113 with fix(qwp): fix duplicate writes and store-and-forward recovery during failover questdb#7366 pinned to this branch.

…ing slot resources

SegmentManager.close() gives up after a bounded join, but CursorSendEngine.close()
could not observe the incomplete shutdown: it closed the ring and watermark,
unlinked segment files and released the slot flock while the worker could still
be mid service pass - able to unlink a spare/trim path inside a slot directory
that a replacement engine had already re-acquired (SF data loss after restart).

- serviceRing now claims the entry as in-service under the manager lock and
  skips entries deregistered before the pass starts
- new SegmentManager.awaitRingQuiescence(ring): bounded, interrupt-preserving
  barrier that confirms the worker can never touch the ring/slot again
- CursorSendEngine.close() releases ring/watermark/segment files/slot lock only
  after confirmed quiescence (or a reaped owned worker); otherwise it leaks them
  deliberately, logs, and allows close() to be retried
- a timed-out SegmentManager.close() hands pathScratch ownership to the worker,
  which frees it on exit - no permanent native leak when the worker outlives
  the join
- regression: CursorSendEngineSlotReacquisitionTest (slot retained while worker
  mid-pass + retry completes cleanup; same-slot reacquisition after normal
  close) and an awaitRingQuiescence contract test
…er the mid-join interrupt

testInterruptedCallerDoesNotAbandonReapableWorker relied on a negative
wall-clock assertion (closeReturned must NOT count down within 300 ms) that
never proved the closer reached the join path: a descheduled closer plus the
old immediate-InterruptedException behavior could satisfy every assertion.
The 'interrupt arrives during join' branch of the retry loop had no test.

- SegmentManager gains a @testonly beforeJoinAttemptHook that runs on the
  closer thread after interrupt clearing, immediately before each join attempt
- reworked test positively awaits the hook and asserts the interrupt is clear
  and the worker still live at the join point (fails on both revert shapes)
- new testInterruptDuringJoinRetriesUntilWorkerReaped drives a mid-join
  interrupt and uses the second hook invocation as deterministic proof the
  loop retried instead of abandoning the reap

Verified: both tests fail against a simulated revert (single join, no
interrupt clearing, abandon on InterruptedException) and pass 3/3 runs with
the fix.
Cursor engine closure now retains slot ownership until manager
quiescence and transfers stalled cleanup to a dedicated daemon.

Recovery validates persisted segments through bounded positional reads
before mmap, preserves facade ownership, and retains corrupt data for
diagnosis.

Deterministic tests cover lifecycle races, compatibility, recovery
faults, and slot reuse.
The server's role-change CLOSE now carries the private-use code 4001
(RFC 6455 s7.4.2) instead of NORMAL_CLOSURE, so the client's verbatim
CLOSE echo proves it actually read the server's CLOSE frame. Classify
ROLE_CHANGE as orderly in the send loop (strike-exempt, paced recycle,
reconnect-eligible), matching the treatment the handoff got as 1000.
Pin the classification with a poison-frame test: repeated demote closes
at an unadvanced head FSN must never latch a PROTOCOL_VIOLATION
terminal.
Recovery mmap'd the segment file's complete logical length RW and left
appendCursor at lastGoodOffset, exposing the unvalidated tail as
writable capacity. If that tail was sparse/unbacked (fallocate
reservations do not survive external sparsification, and
FilesFacade.allocate cannot retroactively fill holes below EOF), the
next producer append stored straight into the hole — which under
ENOSPC raises SIGBUS and aborts the JVM.

MmapSegment.openExisting now maps only the validated prefix
[0, lastGoodOffset), making recovered segments born full: the first
append backpressures until the segment manager installs a hot spare
(created with genuine block reservation and clean ENOSPC failure),
then rotates onto it. Consumers are unaffected — they only read below
publishedOffset(), which equals lastGoodOffset.

Because a recovered segment's mapped size no longer reflects its
on-disk footprint, sf_max_total_bytes accounting (register seed and
trim decrement) now uses the new MmapSegment.fileBytes() — the file's
logical length — instead of sizeBytes().

Regression tests: recovered sparse-tail segment must be born full and
refuse tryAppend; a ring recovered around a sparse-tail active must
backpressure the first append and resume with a contiguous FSN after
spare rotation.
SegmentRing.openExisting() previously skipped every per-file
MmapSegmentException and treated directory-enumeration failure
(findFirst < 0) as an empty store. The interior FSN-contiguity check
cannot expose a skipped segment at the lowest, highest, or sole
position -- the survivors stay mutually contiguous -- so recovery
silently retired the skipped segment's unacked frames (lowest/sole:
the engine seeds ackedFsn past them), minted duplicate FSNs
(highest: nextSeq resumes inside the skipped range), or -- for a
sole unreadable segment -- returned null, routing CursorSendEngine
onto the fresh-start path whose truncating openCleanRW
(O_CREAT|O_TRUNC / CREATE_ALWAYS) destroyed the only surviving
sf-initial.sfa.

Recovery now fails closed:
- an unreadable .sfa aborts recovery with MmapSegmentException
  naming the file and remediation options (restore, or move the
  file out to explicitly accept the loss);
- enumeration failure throws instead of returning null;
- quarantine-rename and empty-leftover-unlink failures throw
  instead of warn-and-continue;
- CursorSendEngine's fresh-start path refuses to create
  sf-initial.sfa over an existing file (defense in depth), checked
  before the stale-watermark unlink to preserve forensic state.

openExisting() returning null now strictly means "no recoverable
data existed". BackgroundDrainer already quarantines a slot
(markFailed + FAILED) when engine construction throws, so corrupt
orphan slots are preserved for postmortem rather than drained over.

New SegmentRingEdgeRecoveryTest pins the contract with sole /
lowest / highest unreadable-segment tests, an engine-level
no-truncation test, and a POSIX enumeration-failure test; all five
reproduced the data loss before the fix.
CursorSendEngine.closeEventually() used to submit a forever-retrying task
to an unbounded Executors.newCachedThreadPool(): each stuck engine pinned
one daemon thread in a while-loop whose every close() attempt can block
~10s (5s awaitRingQuiescence + 5s owned-manager join), so E stuck engines
retained E threads plus E rings, mmaps, watermarks and flocks with no
global bound and no give-up.

Replace it with a single shared single-thread scheduled daemon. Each task
performs exactly ONE close() attempt and reschedules itself with
exponential backoff (10ms doubling to a 10s cap), so E stuck engines cost
E queued tasks, not E threads. After 40 attempts the engine is abandoned:
terminal for the daemon, one ERROR logged, resources deliberately stay
leaked until process exit (the kernel releases the flock; releasing them
early would hand the slot to a replacement engine the stale worker could
still corrupt), and the process-wide count is observable via
getAbandonedDeferredCloseCount(). close() may still be invoked directly
after abandonment. The per-engine deferredCloseScheduled flag keeps
scheduling deduplicated.

Add a many-stuck-engines test pinning the single-thread bound plus
eventual completion, and a permanently-stuck-manager test pinning the
retry budget, the retained slot lock after abandonment, and the direct
close() recovery path. Also reattach the unlinkAllSegmentFiles javadoc
that had drifted onto the old retry-loop method.
SegmentRing.openExisting walked every slot-directory entry to recover
segments, then SegmentManager.register immediately re-enumerated the
same directory (scanMaxGeneration) to seed the spare file-generation
counter, re-allocating a name String plus substring per entry.

The recovery scan now tracks the maximum sf-<gen:016x>.sfa generation
it observes and carries it on the returned ring
(maxRecoveredFileGeneration); register() uses that value and only
falls back to the legacy directory rescan for rings that were not
built by recovery (FILE_GENERATION_UNKNOWN: fresh start, memory mode,
tests), so behavior for non-recovered rings is unchanged.

Safety properties, pinned by SegmentRingRecoveryGenerationTest:
- the maximum covers every scanned entry, including empty leftovers
  recovery unlinks and corrupt files it quarantines, so a generation
  is never reused even for files gone by registration time;
- name->generation parsing is a single shared helper
  (SegmentRing.parseFileGeneration) used by both the recovery scan
  and the fallback rescan, so the two can never disagree;
- freshly minted spares always land strictly above everything on
  disk -- never at a name whose truncating openCleanRW would destroy
  a recovered segment.

The carried value cannot be stale for its intended (first)
registration: recovery and register run on one thread under the
engine slot lock, and no spare can be minted into the slot before
register wires the ring up. Same-manager re-registration stays safe
because advanceFileGeneration only ratchets upward.
drainTrimmable retired fully-acked sealed segments with per-element
remove(0), shifting the remaining suffix on every iteration -- a bulk
trim of S segments cost O(S^2) element moves. Collect the eligible
prefix first, then remove it with one ObjList.remove(0, eligible - 1)
range removal (inclusive bounds), all under the same monitor as
before, so the I/O thread's snapshotSealedSegments() still cannot
observe a partially shifted list.

Covered by the existing multi-segment bulk-drain assertion in
SegmentRingTest.
Add deterministic gray-box coverage for BackgroundDrainer and the cursor WebSocket send loop. The tests stall SegmentManager quiescence, verify callers return without releasing the slot lock, and confirm deferred cleanup releases the slot after quiescence resumes.

Add narrow test-only seams for engine injection and I/O thread joining so the tests avoid a live server and wait for the complete exit path.
Merge the latest client main, which supersedes the branch's earlier
store-and-forward lifecycle and recovery implementation.

Retain the QWP ROLE_CHANGE close code and classify it as orderly so
demotions remain reconnect-eligible without poison-frame strikes.
The lifecycle tests inferred that close had left its creation wait whenever
ReentrantLock.hasWaiters() briefly returned false. Interrupt delivery moves a
condition waiter to the lock queue while awaitNanos() reacquires the lock, so
the observer could mistake that transition for deadline expiry.

Add test-only retry hooks that fire only after each pool catches an interrupt
and confirms the original deadline still permits another wait. The tests now
send each next interrupt after that acknowledgement, preventing coalescing and
removing the transient queue-membership check while retaining deadline-restart
coverage.
bluestreak01 added a commit to questdb/questdb that referenced this pull request Jul 27, 2026
The previous "bump to the latest main" moved the submodule to 1d54a253,
the tip of client main. That commit predates the client half of this
feature: main carries only the deflake work re-landed via client PR #72,
not 9f3cb329 "Add ROLE_CHANGE (4001) close code for the role-change
handoff". The two share a subject line, which hid the loss.

Without 9f3cb329 the client classifies the 4001 CLOSE this branch emits
as non-orderly. CursorWebSocketSendLoop.onClose then records a poison
strike per demote, takes failPaced instead of failExemptPaced, and after
four strikes at an unadvanced head FSN escalates to a PROTOCOL_VIOLATION
terminal. BackgroundDrainer quarantines the orphan store-and-forward slot
holding exactly the rows this branch protects.

Pin dde3c15d8f instead: the tandem client branch, which already merges
client main (1d54a253 is an ancestor) and adds ROLE_CHANGE to both
WebSocketCloseCode and the orderly-close classification.

Tandem: questdb/java-questdb-client#73
Reverts 9f3cb32. The server keeps NORMAL_CLOSURE on the role-change
CLOSE, so there is no 4001 on the wire for this client to classify.

4001 existed so the client's verbatim CLOSE echo would prove to the
server that it had read the server's CLOSE rather than having sent a
voluntary CLOSE that crossed it. The server takes identical action on
both branches -- the code only selected a log line -- while every client
released before 9f3cb32 treats a code outside NORMAL_CLOSURE/GOING_AWAY
as a head-of-line poison strike, escalating a routine demote to a
PROTOCOL_VIOLATION terminal that quarantines the store-and-forward slot.
Putting the code on the wire therefore costs data on mixed-version
fleets and buys a diagnostic; it needs a negotiated capability first.

Carrying the classification here anyway would leave the client claiming
to understand a code nothing sends, and the poison-frame test would pin
a contract with no counterparty. Strip both, and record in
qwp-nack-policy-v2.md that a distinguishable role-change code is gated
behind capability negotiation rather than simply absent.
@bluestreak01 bluestreak01 changed the title fix(qwp): classify ROLE_CHANGE (4001) close as orderly; harden SF recovery and shutdown fix(qwp): fix store-and-forward recovery data loss and unbounded client shutdown Jul 27, 2026
@mtopolnik

Copy link
Copy Markdown
Contributor

[PR Coverage check]

😍 pass : 16 / 16 (100.00%)

file detail

path covered line new line coverage
🔵 io/questdb/client/impl/QueryClientPool.java 8 8 100.00%
🔵 io/questdb/client/impl/SenderPool.java 8 8 100.00%

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.

2 participants