Repository navigation
Conversation
The QWP I/O thread polled the socket and the segment ring every 50us, costing ~70% of a core per idle connection (Kafka connector: 30% CPU on QWP vs 3% on HTTP for the same quiet sink). It now blocks in poll(2) on the socket plus a doorbell (eventfd / kqueue EVFILT_USER / Windows Event) that the producer rings only when the thread is parked. Ack waiters, the segment manager and the dispatchers are event-driven too. Idle: 1% of a core (was 98%). 1 row/s: 1.9% vs HTTP 1.7%. Saturation (1 producer, 1000 rows/flush): 12.3M rows/s vs 12.4M before. TLA+ models of the wake protocols under design/tla.
855470b to
459f909
Compare
…tive segment The watermark write rode on the manager's 1 ms tick. With the worker event-driven it only woke when an ACK completed a sealed segment, so ACKs inside the active segment were never persisted and a crash replayed them (duplicates without DEDUP). The first ACK past the persisted watermark now wakes the worker, which re-persists on a 1 ms cadence until current. Tests that relied on the tick wake the worker explicitly.
|
Pushed a fix for the ack-watermark regression found in review (39ac01e). Root cause: the mmap watermark write ran on the manager's 1 ms tick. With the worker event-driven, only an ACK that completed a sealed segment woke it, so ACKs inside the active segment were never persisted. After Fix: the first ACK past the persisted watermark wakes the worker; it then re-persists on a 1 ms cadence ( Full suite: 3725 tests, 0 failures. |
The single ackWaiter slot on the I/O loop lost concurrent waiters (monitor threads in awaitAckedFsn) and stranded a waiter parked on a loop a symbol-dictionary recycle had closed; both slept to their deadline. Waiters now register in a Sender-owned AckWaiters registry that every loop signals on ack advance, terminal and close().
|
Pushed a fix for the lost-waiter finding (awaitAckedFsn / drain sleeping to their deadline). Root cause: one Fix: waiters register in a Sender-owned
|
…not publishedFsn A publish is two volatile stores, publishedFsn then the bytes, and the loop can only send below the bytes. A loop that snapshotted the new FSN but found the bytes not yet published re-checked publishedFsn, saw no change, and blocked on a frame nobody announced. SegmentRing now bumps a publishStamp after the bytes and the loop snapshots that. The TLA+ model splits the publish into both stores; IdleWait_stampbug.cfg reproduces the review schedule, IdleWait_fix.cfg passes.
|
Pushed a fix for the stranded-frame schedule (idle re-check vs. the two-store publish). Root cause: a publish stores Fix: The TLA+ model splits the publish into both stores. |
Review (level 3): request changesHEAD Critical1. TLS: an ACK frame larger than ~64 KiB is left in the TLS layer's user-space buffers, and the I/O loop then blocks with no timeout
Trace
On BASE the 50 µs park simply called Who hits it
Fix (validated) In
Validated fix (WebSocketClient.tryReceiveFrame)diff --git a/core/src/main/java/io/questdb/client/cutlass/http/client/WebSocketClient.java b/core/src/main/java/io/questdb/client/cutlass/http/client/WebSocketClient.java
index 68b2c2d0..d8ed020d 100644
--- a/core/src/main/java/io/questdb/client/cutlass/http/client/WebSocketClient.java
+++ b/core/src/main/java/io/questdb/client/cutlass/http/client/WebSocketClient.java
@@ -640,18 +640,25 @@ public abstract class WebSocketClient implements QuietCloseable {
growRecvBuffer();
}
- int n = socket.recv(recvBufPtr + recvPos, recvBufSize - recvPos);
- if (n < 0) {
- throw new HttpClientException("peer disconnect [errno=").errno(nf.errno()).put(']');
+ while (true) {
+ int n = socket.recv(recvBufPtr + recvPos, recvBufSize - recvPos);
+ if (n < 0) {
+ throw new HttpClientException("peer disconnect [errno=").errno(nf.errno()).put(']');
+ }
+ if (n == 0) {
+ return false; // No data available
+ }
+ recvPos += n;
+ result = tryParseFrame(handler);
+ if (result != PARSE_NEED_MORE) {
+ return result == PARSE_OK;
+ }
+ // Frame still incomplete: keep reading until the transport has
+ // nothing more (TLS may hold already-received bytes in user space).
+ if (recvPos >= recvBufSize - 1024) {
+ growRecvBuffer();
+ }
}
- if (n == 0) {
- return false; // No data available
- }
- recvPos += n;
-
- // Try to parse again
- result = tryParseFrame(handler);
- return result == PARSE_OK;
}
/**Moderate2. A failed segment trim is never retried while the slot is idle
3. A reconnect triggered by a failed PING leaves the replay unsent
Trace
The trigger is narrow: the PING write itself has to fail, for example a send timeout or an RST that races the PING. But once it fires, the stall in durable mode is unbounded.
4. The new replay regression test passes with the fix removed
The test's transport fails on receive after a successful send, so 5.
|
…nput JavaTlsClientSocket keeps decrypted plaintext (spill buffer) and complete undecrypted records in user space when a server frame exceeds the caller's buffer. poll(2) cannot see them, so the idle wait blocked with a partly received ACK and awaitAckedFsn()/close() timed out. Socket gains hasBufferedInput(); awaitReadableOrWake() reports readable without blocking when it is set. Regression test with a 150 KiB ACK over wss.
|
Pushed a fix for the TLS large-frame finding. Root cause: Fix: New |
… wake The event-driven manager parked past a failed trim's retry deadline: only ACKs, rotations and the watermark cadence brought it back, so on an idle slot an acked segment and its budget charge stayed until the next event. The park now ends at trimRetryAtNanos as well.
A PING that throws in step 3/4 reconnects and rewinds the cursor after the iteration already consumed recycledThisIteration, so step 5 blocked on the rewound cursor (in durable-ack mode with no timeout). Step 5 now skips the wait when the flag is set again. The replay regression test fails on sendBinary instead of on a receive after a successful send, so it no longer passes with the recycle flag removed; a PING-failure variant covers the new guard. Both verified by mutation.
awaitAckedFsn() and the close drain registered and deregistered on every loop pass, allocating two arrays per ACK while waiting. Register once, loop check/park, deregister at the end; the unpark permit covers an ack between check and park. remove() reuses the empty array.
Restore the loop constructors with parkNanos and SegmentManager(long, long pollNanos) / (long, long, long) as @deprecated delegates, since the exported sf.cursor classes documented that promise. (long, long) keeps its old meaning, so a byte cap now goes through SegmentBudget.
Re-attach the publishedFsn() javadoc, drop stale poll-era comments (awaitAckedFsn, SenderErrorDispatcher.close, rotation race test), model the AckWaiters register-once protocol in AckWait.tla, fix member and import ordering.
|
Pushed fixes for findings 2 to 6 and the minors, one commit each (98ac6ae, b5e6d52, 05221f2, 898e771, plus the nits commit). Every behavioural fix has a test that fails on the previous commit. 2. Idle trim retry. The manager's park now ends at 3. PING-failure replay. Step 5 skips the wait when 4. Weak replay test. The transport now throws on 5. AckWaiters allocation. Register once per call, loop check/park, deregister on return; 6. 1.3.x descriptors. The seven loop constructors with Minors: javadoc re-attached, the four stale comments rewritten, ordering fixed. The aarch64 GLIBC guard passed on the latest run without a rerun. Full suite: 3730 tests, 0 failures. |
In disk store-and-forward mode the segment manager worker was woken once per advancing ACK instead of once per WATERMARK_LAG_NANOS. After each ACK-driven pass serviceRing0 re-armed the ring's ACK wake (setPersistedAckFsn(currentAck)) right away, and workerLoop then saw ackedFsn == lastPersistedAck and parked without a timeout, so the very next ACK had before == persistedAckFsn and unparked it again. At 50k ACK/s that was ~50k wakes/s and 5-6% of a core for the worker. The worker now tracks whether the ring's ACK wake is armed (RingEntry.ackWakeArmed). Writing the mmap watermark from the no-trim branch leaves the ring's persistedAckFsn stale (disarmed) and the loop parks on WATERMARK_LAG_NANOS while disarmed; only a timed pass that finds no new ACK re-arms the wake and lets the worker park without a timeout. The trim path keeps re-arming after its watermark write so a failed barrier still parks until the trim retry instead of polling. The watermark lag bound, the firstTrimmable() wake clause and the register-time seeding (setPersistedAckFsn(-1L)) are unchanged. Regression test: SegmentManagerAckWatermarkLagTest .testStreamingAcksWakeWorkerOnWatermarkCadenceNotPerAck streams 20k ACKs spaced 20 us apart into the active segment, counts manager passes through the setBeforeTrimSyncHook seam, asserts passes <= 2*elapsedMs + 16 and no passes once idle. Without the production change it fails with "worker ran 19877 passes for 20000 ACKs over 400 ms; bound 816"; with it the run is green. Measured (AckWakeBench, 3 s, JDK 8): 10k ACK/s 10016 -> 952 wakes/s, worker 1.61% -> 0.33% core; 50k ACK/s 49925 -> 952 wakes/s, 5.52% -> 0.29% core.
SegmentRing.acknowledge runs on the I/O thread for every server ACK. Since the event-driven manager landed it called firstTrimmable(), which is synchronized on the ring. In disk store-and-forward mode two paths hold that monitor across a manifest fsync: the producer's rotation in appendOrFsn (manifest.update inside synchronized(this)) and the manager's stagePendingTrims -> advanceManifestHeadPast. While the ACK waited for the fsync the I/O thread neither sent nor received. Replace the monitored lookup with a volatile threshold, oldestTrimmableLastSeq: the last FSN of the oldest sealed segment that is not I/O-pinned, or Long.MAX_VALUE when there is none. It is re-derived under the monitor by every writer of the sealed head or the I/O pin (rotation, removeSealedHead for drain/remove/stage incl. compaction, switchIoPin, releasePinnedSegment, close, recovery fold). acknowledge now wakes the manager on `seq >= oldestTrimmableLastSeq` with no lock and no allocation; the "first ACK past persistedAckFsn" clause is unchanged. A stale read can only add a spurious wake: each concurrent mutation wakes the manager itself (rotation's post-block wakeup, pin switch/release), and the manager re-reads the volatile ackedFsn after staging. Test: SegmentRingAckOffMonitorTest recovers a disk slot through a FilesFacade whose fsync stalls 300 ms, triggers a rotation on a producer thread and calls acknowledge() from the test thread while that fsync holds the monitor; it must return in < 50 ms. It then asserts the trim wake: the ACK inside the oldest sealed segment does not wake the manager, the ACK completing it does. Mutation evidence: without the production change the test fails with "acknowledge waited 305 ms behind the manifest fsync"; with the threshold compare flipped to `>` it fails with "ACK completing the oldest sealed segment must wake expected:<1> but was:<0>". Numbers (JDK 8, 200 ms injected manifest fsync stall): acknowledge took 207.4-207.7 ms before on both the rotation and trim-staging paths, 0.001 ms after. End-to-end over 3 s with real NVMe manifest fsyncs (avg 5.2 ms): 23-24 ACKs > 1 ms, 104-109 ms blocked in total, max 5.5 ms before; 0 ACKs > 1 ms, max 0.041 ms after.
Threads parked in awaitAckedFsn / drain were unparked only from the I/O loop's ackProgressListener (dispatchProgress, recordFatal, close). Two state changes the waiter re-checks never signalled: - orphan-tail retirement: CursorSendEngine.retireRecoveredOrphanTail IfReady self-acks the orphan tip on the I/O thread via engine.acknowledge, bypassing dispatchProgress. A recovered sender calling drain() before publishing anything slept to its deadline (3000 ms measured) although the target was reached ~200 ms in. - periodic-sync durability failure: SegmentManager.servicePeriodicSync latches via ring.recordDurabilityFailure; awaitAckedFsn only runs checkDurability on a wake, so the MmapSegmentException surfaced at the deadline (2800 ms measured) or never, once the latch cleared. Every ack path funnels through SegmentRing.acknowledge, and the latch lives on the ring, so the hook goes there: a volatile Runnable ackStateListener run after an ackedFsn advance and after a latch set. CursorSendEngine.setAckStateListener exposes it; the sender installs ackWaiters::signal on every engine it attaches (setCursorEngine, which all construction paths funnel through, and the symbol-dict recycle swap before the rebuilt engine is published). The loop keeps its listener for the terminal latch and close() but no longer signals per ACK, so each ACK unparks once. No allocation and no lock on the ACK path: a volatile read plus a call. Tests: AckWaiterSignalTest. (a) in-process crash image (defer-commit tail closed with close_flush_timeout_millis=0 against a silent server, same construction as SymbolDictRecycleCrashWindowsTest), recovered against a 100 ms-delayed-ack server, drain(3000) asserted < 1000 ms. (b) sf_durability=periodic engine whose FilesFacade.fsync fails; a thread parked in awaitAckedFsn(0, 3000) must throw within 500 ms. Both fail at the previous HEAD and with the production diff stashed (3000 ms / 2800 ms), pass with it (~350 ms each). JDK 8 target suite (SymbolDict*, QwpWebSocketSender*, CloseDrain*, CursorWebSocketSendLoop*, CursorSendEngine*, SegmentManager*, SenderPool*, BackgroundDrainer*, TlsLargeAckIdleWaitTest, *Orphan*, *Recovery*): 689 tests, 0 failures. JDK 25 compile clean.
Every Sender.awaitAckedFsn(fsn, timeout) / drain(timeout) call on a QWP sender that has to wait registered the calling thread in AckWaiters, and AckWaiters.add built a new Thread[] with Arrays.copyOf (published through a volatile field, so escape analysis cannot elide it) while remove allocated a Thread[n-1] whenever more than one waiter was registered. A producer looping row + flushAndGetSequence + awaitAckedFsn against a TestWebSocketServer allocated 24 B/op on both JDK 8 and JDK 25 (ThreadMXBean.getThreadAllocatedBytes on the producer thread; 0 B/op before the waiter class was introduced). Replace the copy-on-write array with a fixed slot array (initial length 4). add takes the first null slot and ends with a volatile self-store of the array reference; remove nulls the slot; signal reads the volatile reference and unparks every non-null entry. The array only grows (doubles, under the lock) when more threads wait at once than ever before, which is a one-off. The volatile self-store is what keeps the register-then-check / advance-then-signal argument sound with in-place slot writes: a signal whose volatile read of the array predates the store is ordered before the waiter's first check, which therefore sees the advanced watermark. A slot nulled concurrently with a signal is read as null (unpark(null) is a no-op) or hands the departing thread one extra permit, which its next re-checking park consumes harmlessly; the class javadoc states this. Test: AckWaitersTest registers and deregisters the calling thread 10k times after warm-up, alone and with two other threads registered, and asserts 0 bytes per add+remove on the calling thread. Without the fix it fails with 24 B/op (one waiter) and 56 B/op (three). It also checks that one signal unparks five registered waiters (past the initial capacity, so growth is covered) and that a waiter which deregistered before the signal gets no permit. End-to-end probe (row + flushAndGetSequence + awaitAckedFsn, 50k ops, logback at INFO, JDK 8): 24.000 B/op before, 0.000 B/op after; the spin-poll control stays at 0 B/op in both runs.
Since the dispatcher moved to an untimed LockSupport.park, offer()
unparked the thread on every ACK regardless of whether it was parked.
Mirror the I/O loop's ioParked Dekker: the dispatcher publishes
`volatile boolean parked = true`, re-reads latestFsn/closed, parks,
and clears the flag; offer() writes latestFsn (volatile) and then
unparks only if parked is set. Either the dispatcher sees the new
value before parking or offer() sees the flag, so no wake is lost;
close() still unparks unconditionally. offer() stays lock-free and
allocation-free; coalescing delivery and the close drain deadline
are unchanged.
Tests (SenderProgressDispatcherTest):
- testIdleDispatcherIsUnparkedByOffer: dispatcher idle for 200 ms is
parked (isParkedForTesting) and delivers the next offer within
100 ms. Mutation proof: with offer() never unparking, this test
fails ("parked dispatcher must be woken by offer()"), plus three
pre-existing delivery tests.
- testOfferWhileHandlerBusyDoesNotSeeParked: 1000 offers against a
handler blocked on a latch never observe parked=true and the
newest value (1000) is delivered once the handler unblocks.
Measured (perf stat, syscalls:sys_enter_futex): FlushBench with the
default no-op handler at 50k/100k ACK/s is unchanged (I/O thread
1.00 futex/flush, dispatcher ~2.0/flush before and after) -- with a
~100 ns handler and ACKs 10-20 us apart the dispatcher really is
parked at every offer, so each unpark is legitimate. The per-ACK
futex on the I/O thread is therefore the wake cadence, not a stray
unpark. With a busy handler (20 us handler, 10 us offer gap, 1M
offers) HotSpot's Parker::unpark was already syscall-free for a
non-parked thread (~11k futex before vs ~8k after, both baseline);
the Dekker now also skips the Parker mutex lock/unlock pair.
…ked" This reverts commit 19644be.
QwpWebSocketSender always builds a SenderProgressDispatcher, seeded
with DefaultSenderProgressHandler.INSTANCE, and the cursor loop offers
every ACK into it. For the vast majority of users, who never call
setProgressHandler, each ACK therefore cost a futex wake on the I/O
thread plus two futex syscalls and a context switch on a dispatcher
thread that then invoked a no-op.
Mechanism: offer() still records latestFsn, then returns early when
the installed handler is the no-op default: no thread start, no
unpark, no totalOffered increment. setHandler() switching to a real
handler wakes (and lazily starts) the dispatcher when a watermark is
pending, so a handler installed after ACKs were skipped receives the
newest recorded watermark immediately on install, and subsequent
advances flow as before. The volatile write of latestFsn before the
read of handler in offer(), and the reverse order in setHandler(),
form a Dekker pair so a concurrent install cannot lose an advance.
Delivery semantics with a real handler are unchanged: latest value,
strictly monotonic, on the dispatcher thread, DRAIN_DEADLINE on close.
Counter semantics: offers made under the no-op handler are no longer
counted in totalOffered, so getDroppedNotifications stays 0 for a
sender without a handler instead of growing with every ACK.
Tests: SenderProgressDispatcherTest gains
testNoOpHandlerDoesNotStartDispatcherThread (1000 offers under the
default handler, dispatcher thread must stay null, counters 0) and
testHandlerInstalledAfterSkippedOffersDelivers (same, then
setHandler(real) + one offer delivers 1001 within 1 s). Both fail at
the parent commit and with the production change stashed ("expected
null, but was: Thread[noop-progress-test,5,main]"), pass with it.
JDK 8: SenderProgressDispatcherTest, *Progress*Test,
SlotLockReleasedContractTest, QwpWebSocketSender*Test,
CursorWebSocketSendLoop*Test, SymbolDict*Test, CloseDrain*Test,
SenderPool*Test: 440 tests, 0 failures. JDK 25 compile clean.
FlushBench, mem mode, 10 s, no handler installed, perf syscall
counts per flush (before -> after):
50k/s: io futex 1.00 -> 0.00, dispatcher futex 2.00 -> 0.00,
io_pct 46.2 -> 43.2, dispatcher_pct 7.95 -> 0.00
100k/s: io futex 0.99 -> 0.00, dispatcher futex 1.99 -> 0.00,
io_pct 74.4 -> 70.7, dispatcher_pct 15.9 -> 0.00
Mutation testing showed these production lines could be deleted or
inverted with the whole suite staying green. One deterministic test
per line, each verified red with the mutation applied and green at
HEAD (20/20 repeats on JDK 8):
- CursorWebSocketSendLoop.recordFatal: signalAckProgress() after the
terminal is latched. Deleted -> a producer parked in awaitAckedFsn
(and close()'s drain) only sees the terminal NACK at its deadline.
AckWaiterSignalTest.testAwaitAckedFsnWakesOnTerminalNack and
testCloseDrainWakesOnTerminalNack.
- CursorWebSocketSendLoop.wakeIoIfParked: the `if (ioParked)` gate.
Inverted to `if (true)` -> every publish rings the doorbell even
while the I/O thread is busy. CursorWebSocketSendLoopIdleWaitTest
.testBusyLoopIsNotWokenPerPublish (transport counts wake() calls
while its receive holds the loop; 200 publishes must ring 0 times).
- CursorWebSocketSendLoop.ioLoop step 5: the publishStamp re-check
before awaitIdle(). Inverted to `if (true)` -> a frame published
after trySendOne found nothing and before ioParked is set rings no
doorbell and the loop blocks on an untimed wait with the frame
stranded. CursorWebSocketSendLoopIdleWaitTest
.testPublishInsideIdleDecisionWindowIsNotStranded (the transport's
receive is gated inside that window so the publish lands there).
- SegmentManager.register: ring.setPersistedAckFsn(-1L). Deleted ->
the ring's mark stays Long.MIN_VALUE, the first ACK never wakes the
worker, and the watermark is not persisted until some other event.
SegmentManagerAckWatermarkLagTest
.testFirstAckAfterRegisterWakesParkedWorker (waits for the worker
to park untimed before the ACK, so the register wake cannot mask
it).
- WebSocketClient.closeDoorbell: doorbell = -1. Deleted -> wake()
after close() writes to the released descriptor number, i.e. to
whoever reused it. WebSocketClientDoorbellTest step 6 creates fresh
eventfds after close() (they take the lowest free numbers) and
asserts none becomes readable; Linux only, the kqueue pollReadable
cannot probe a bare doorbell.
Already pinned at HEAD, verified red under the mutation, no new test:
- SegmentManager.workerLoop `parkNanos = min(parkNanos,
WATERMARK_LAG_NANOS)`: both SegmentManagerAckWatermarkLagTest tests
fail (watermark stuck at 2 / 19).
- SegmentRing.acknowledge `|| seq >= oldestTrimmableLastSeq`:
SegmentRingAckOffMonitorTest fails ("ACK completing the oldest
sealed segment must wake expected:<1> but was:<0>").
No production code changed.
Per flush at a few 10k flushes/s the I/O thread did ~11 syscalls: 2 poll, 2 eventfd reads, 4.5 recvfrom, 1 sendto. Two of them never did anything: - doorbellDrain ran after every wait, including waits that ended on the socket (an ACK), where the eventfd read is a guaranteed EAGAIN. The wait now consumes the ring itself: Linux reads the eventfd only when its revents say so, Windows resets the Event only when it was the signalled handle, kqueue already cleared it (EV_CLEAR). doorbellDrain is gone. - The pre-park receive pass repeated the recv that had just returned EAGAIN on the previous iteration. WebSocketClient.tryReceiveFrame now remembers an empty recv and answers false without a syscall until the next send or awaitReadableOrWake; bytes that land in between end the level-triggered wait at once, so nothing is lost. The loop's send/receive/park protocol (and design/tla/IdleWait.tla) is unchanged: a skipped recv is the model's RecvNone step. Measured on markbook (i7-4870HQ, JDK 25, in-JVM TestWebSocketServer acking every frame in one write, server threads excluded; client CPU = I/O thread + segment manager + producer time inside flush(), % of one core, medians of interleaved runs): flushes/s HEAD this I/O-thread syscalls/flush (25k/s) idle 0.00 0.00 HEAD 10.97 -> 8.55 10k 24.27 22.41 (eventfd read 1.73 -> 0.90, 25k 54.53 50.72 recvfrom 4.51 -> 2.87, poll 1.73) 50k 81.03 80.03 saturation 133.7k 135.8k flushes/s (unchanged, never parks) 25k/s is 5 paired repeats, never worse than HEAD (-1.7 to -4.5 pp); 10k/50k are 2-3. flush() and flush-to-ack latencies are unchanged. Two protocol changes were measured and rejected: one extra non-blocking pass before parking while frames are in flight (+1 to +4 pp everywhere), and skipping the doorbell while frames are in flight so the ACK wakes the loop (no gain while ACK RTT < publish interval; with a 150 us ACK delay -25..-32% CPU but +60% flush-to-ack p50, +96% p90, and it would strand a deferred-commit group whose ACK is withheld until the commit frame the producer cannot get sent). The wait's native entry point is renamed pollReadable -> awaitReadableOrRing because its contract changed (it now consumes the ring): new Java on a stale libquestdb.so fails with UnsatisfiedLinkError at the first wait instead of spinning at 100% on a ring nobody drains.
…e budget A ring at sf_max_total_bytes was refused a spare in step 1, armed the 10 ms provision-retry timer, and only then trimmed its ACKed segment in step 2. The release woke nobody, so the worker parked for the full timer before installing the spare: memory-mode senders stalled ~8 ms per rotation at the cap (p50 10.06 ms from segment-completing ACK to spare install, vs 1.81 ms before the event-driven loop). Step 1 is now provisionSpare(), which reports a cap refusal, and the trim remainder is trimAcked(). serviceRing0 provisions, trims, and on a refusal provisions once more in the same pass; the timer is armed only when that second attempt is refused too. The uncapped path is unchanged; a capped pass gains one tryCharge. The timer stays. It is still the only recovery for a failed create (transient ENOSPC/EMFILE/native OOM on a ring holding only its active segment: no ACK, rotation or pin move will ever wake the worker) and for room freed by a sibling ring or manager. Two tests pin both halves: the in-pass re-provision, proven to fail without it, and a one-shot allocate failure that must recover with no further event, proven to fail with the timer disabled. Co-Authored-By: Claude <noreply@anthropic.com>
JavaTlsClientSocket.recv() returned 0 ("no data yet") when the socket
hit EOF or a reset while a partial TLS record was still buffered. The
record can never complete, so the disconnect was never reported. With
the event-driven I/O loop this became a busy spin: the socket stays
readable at EOF, so every idle wait returned at once and the I/O thread
burned a full core while the sender never reconnected.
recv() now returns the negative read result in that case, matching the
handshake path, and callers take their existing peer-disconnect path.
TlsTruncatedRecordReconnectTest cuts the ACK of a batch mid-record
behind a proxy and asserts the batch is replayed and acked after a
reconnect. It fails without the fix (no reconnect, ack wait times out).
register() stored persistedAckFsn = -1 after adding the entry to the worker's ring list. A running worker that serviced the entry in between (a recovered ring, acked before register) could persist the watermark and arm the ACK wake, and the late store then disarmed it while the worker believed it was armed. Later ACKs inside the active segment woke nothing, so the on-disk watermark stopped until an unrelated rotation, and a crash in that window replayed already-acked frames. The seed now happens before the entry is visible. Every later write comes from the worker, under the manager lock. testRegisterKeepsAckWakeArmedByAnEarlyWorkerPass uses a new test hook that runs right after publication to let the worker arm first. With the old ordering the watermark stays at 9 while acks reach 19.
Pooled senders share one SegmentBudget by default. When the budget refused a ring its hot spare and a different manager then freed room (a sibling's trim or deregister), nothing woke the refused manager: it only noticed on its 10 ms provision-retry timer. The base revision's 1 ms poll noticed within about 1 ms, so the event-driven worker made the starved producer stall up to 10 ms longer per episode, and turned short sf_append_deadline_millis settings into append errors. A refused tryCharge now registers the manager's wake with the budget, in the same monitor step as the check, and every call that frees room (release, removeSideFileGauge) runs and clears the registered wakes. The capped path no longer arms the retry timer; it remains only for a failed spare create, which nothing else announces. testSiblingDeregisterWakesRefusedManager runs the refused manager on a clock that steps backwards, stretching any timer-driven retry to about ten seconds. It fails without this change and passes with it.
appendOrFsn sealed the active segment and promoted the hot spare before finding the payload did not fit even an empty segment. That rotation published nothing, so the parked I/O loop was never woken to move its pin off the sealed segment, and every retried flush of the same oversized batch stranded one more. Once sf_max_total_bytes filled, the flush blocked for the append deadline and failed with the backpressure error instead of "payload too large for segment". Check the payload against the empty spare's capacity first; the check is exact and also drops the pointless rotation. SegmentRecoveryIntegrityTest used that rotation to stage a committed rotation with an empty successor; it now writes that manifest record directly.
…-wake test testRegisterKeepsAckWakeArmedByAnEarlyWorkerPass asserted the worker was WAITING right after register(), but register() ends by waking it, so the assert failed most standalone runs (6/6 locally). Acking during that pass could also mask the bug: the pass itself persists an ACK it reads, so the test passed some runs with the 1304f71 seed fix reverted. Wait until the worker has run a pass after the register wake (counted through the before-trim hook, since a just-unparked thread still reports WAITING until it runs) and parked again, then ack. With the seed fix reverted it now fails 10/10 with the watermark stuck at 9.
awaitAckedFsn checked closed and recycleFailure only on entry. A monitor thread already parked when the producer closed the sender (drain skipped or timed out, or its target in abandoned deferred frames) was woken by the loop's close signal, re-checked the same predicate and parked again, sleeping to its own deadline and returning false after the sender was gone. A recycle breach was never signalled at all and the latch was a plain field. The re-check now throws "Sender is closed" once the loop is down (not during the drain, which still acks), and checks the recycle latch, which is volatile and signals the waiters when set. Regression test testAwaitAckedFsnWakesOnClose fails with the closed check removed (waiter still parked after close()). The recycle-breach path has no test: reaching it needs a breached drain contract. Also adds design/wake-protocol.md: the park predicates, inputs, writers and doorbells of the three parked consumers, with the open items and bounded residuals.
…nning parkNanos returns at once on a pending interrupt and leaves the flag set, so a caller that entered awaitAckedFsn interrupted (an executor cancel, a pool housekeeper) re-checked and re-parked in a full-core loop until the ack or the deadline. Consume the flag before each park and restore it on return, as the close() drain already does. testAwaitAckedFsnDoesNotSpinWhenInterrupted counts re-check passes via the ackedFsn read witness: 6.9 million in 300 ms with the consume removed, single digits with it.
…own retry provisionSpare charges past the budget while the ring is under the liveness floor, but tryCharge had already refused it and registered the manager's own wake for the next release. When the create then failed, budget.release ran that wake: the worker unparked out of its 10 ms retry at once and looped, one create attempt per pass (~200k/s, one core, an unthrottled WARN with a stack trace each) for as long as the disk fault lasted, and every refused sibling on a shared budget was woken each time. The floor charge now withdraws the refused registration: the manager is not waiting for room any more, and the retry timer alone paces it. A later refusal re-registers it. testFailedFloorCreateDoesNotWakeItsOwnRetry: 21,176 create attempts in 300 ms with the withdrawal reverted, ~30 with it.
…egment manager The watermark is a store into an mmap (no syscall), so CursorSendEngine.acknowledge now writes it on every ACK advance. The manager only msyncs it before unlinking a trimmed segment. That deletes the ACK-wake state machine the manager needed to learn about ACKs inside the active segment: SegmentRing.persistedAckFsn, RingEntry.ackWakeArmed/lastPersistedAck, WATERMARK_LAG_NANOS, the 1 ms timed park and the Dekker between them (39ac01e, 779a99b, 1304f71, 3362671). An ACK inside the active segment no longer wakes the worker at all. A write racing the worker's msync can tear one on-disk record; it fails its CRC, recovery takes the other slot and the max(lowestBase - 1, watermark) clamp, which is the contract the watermark always had (a discardable optimization). Tests: testAckWritesWatermarkBeforeReturning (fails with the engine write removed: INVALID instead of 9) and testStreamingAcksInsideActiveSegmentNeverWakeWorker (fails with the old per-ACK wake: 2102 passes instead of 0).
e1b30a9 moved the watermark write to the I/O thread's ACK path. Recovery seeds ackedFsn through the ring directly, so a watermark rejected as past publishedFsn (what a host crash in MEMORY/PERIODIC durability leaves after a trim msync) now stayed on disk until the first ACK advance. During an outage that is never: new frames push publishedFsn past the stale value and the next recovery accepts it, skipping every frame between. Before e1b30a9 the manager's first pass overwrote it within 1 ms. Write the seed into the file at recovery when it differs, and sync it when the on-disk value was rejected so a host crash cannot resurrect it. Test: testRejectedWatermarkIsOverwrittenAtRecovery (three sessions; fails without the write: expected 3, was 100). Found by review.
…comments and tests after e1b30a9 finishClose only syncs now: the final ACK (or the recovery seed) already wrote the value. Comments in AckWatermark, CursorSendEngine, SegmentManager and the wake-protocol doc no longer describe the manager as the writer; the trim comment says what protects the trim (the fsync'd manifest head, not the watermark). The streaming-ACK test waits for the spare and a parked worker before counting passes instead of sleeping 50 ms.
…tion The segment manager worker no longer pre-creates a hot spare. SegmentRing.appendOrFsn takes the segment it rotates into from a SegmentSource; the production source, SegmentProvisioner (one per ring, installed by SegmentManager.register), creates it with its final baseSeq, syncs header and directory, and charges the SegmentBudget, all on the producer thread. Refused by the cap, or after a failed create within CREATE_RETRY_NANOS, it returns null and the producer is backpressured on its existing 50 us poll. Deleted: hotSpare/installHotSpare/needsHotSpare, the 3/4 high-water wake, the rotation wake for provisioning, the provision-retry timer, the liveness-floor charge and the budget's refused-wakeups callback list (the class that produced f37967c), fileGeneration, pathScratch and its close-time ownership handoff. The worker's park predicate drops from eight inputs to four; SegmentManager loses a third of its lines. The floor rule survives as one branch in the provisioner: a ring holding only its active segment is charged past the cap. Tests: SegmentProvisionerTest covers cap, floor, create retry and naming (each proven by mutation); QueueSegmentSource hand-feeds rings in the existing ring and manager tests; tests of the deleted machinery are removed.
SfManifest.update writes the record, then fsyncs. When the fsync fails the record naming the new base is already in the page cache, so a later recovery can select it and needs a segment at that base. 6e4f4ca's discard() unlinked that very file, and recovery then threw 'missing expected SF active segment' (quarantining the slot at the Sender level); the old hot spare survived this because it stayed installed. The provisioner now retains a discarded segment, file and charge intact, and hands it back on the next rotation at that base; the ring's rollback covers only rebase, header sync and the manifest update, so a committed rotation is never undone. Test: testFailedManifestSyncKeepsTheSegmentTheManifestMayName injects a manifest fsync failure at rotation, closes undrained and recovers; without the fix recovery fails with SfRecoveryException at base 1. Found by review.
- register rejects a ring whose segment size differs from the manager's: the ring's oversized-payload pre-check assumes the provisioner's size. - The liveness floor counts live segments (O(1)) instead of walking the sealed list under the ring monitor on every 50 us backpressure poll. - Null-path guard in the create-failure cleanup; addSuppressed on a failed discard; the never-read SegmentRing.closed flag is gone. - Tests: a failed create under the floor is retried on the timer (the shape of f37967c's spin); register wakes a parked worker for a recovered ring with trimmable segments (fails with the wake removed); the constructor-failure tests poison the budget's gauge list instead of the deleted fileGeneration. - Stale hot-spare wording in Sender, SegmentManager, SegmentRing and tests.
[PR Coverage check]😍 pass : 332 / 376 (88.30%) file detail
|
Problem
A QWP sender burns ~70% of a core per connection even when idle: the I/O thread polls the socket and the segment ring every 50µs. Kafka Connect users see 30% CPU on QWP vs 3% on HTTP for the same quiet sink.
Change
The I/O thread now blocks until there is something to do: socket readable, a producer published a frame,
close(), or a PING is due. The producer wakes it through a native doorbell (eventfd on Linux, kqueueEVFILT_USERon macOS/FreeBSD, kernel Event on Windows) and only when the thread is actually parked, so a busy connection never pays a syscall per flush. Ack waiters, the segment manager and the dispatchers are event-driven as well: a waiter inawaitAckedFsn/drainis unparked by the engine on every ack advance, terminal and durability latch; the segment manager parks until a rotation, ACK or timer needs it and persists the ack watermark on a 1 ms cadence while ACKs stream; the progress dispatcher thread is not started at all unless a handler is installed.No change to the
SenderAPI or configuration.Numbers
Single sender, one connection, memory mode, in-JVM loopback server acking every frame. Client CPU is the I/O thread, the segment manager and the producer's time inside
flush(), as % of one core; latency is flush-to-ack p50;main=82b2e144,PR= this branch. Medians of 3 interleaved 5 s runs.markbook (i7-4870HQ, 4C/8T)
domapc (Ryzen 9 9950X, 16C/32T)
Trade-off: above ~25k small flushes per second per connection the client uses more CPU than
main(1.1–1.45x), because the I/O thread now takes two prompt wakes per flush (doorbell, then ACK) where the old loop batched several flushes per 50 µs park. In exchange, flush-to-ack latency drops 3–15x, saturation throughput rises ~20%, and idle costs nothing. With the defaultauto_flush_rows=1000this band starts around 25M rows/s per connection; it matters for explicit per-event flushes.Kafka Connect (4 connections, 1 row/s): 69% → 1.9% of a core, HTTP 1.7%.
Notes for review
design/tlaholds TLA+ models of the two wake protocols;IdleWait_bug.cfgreproduces the reconnect race the models caught.Net.awaitReadableOrRing: it consumes the doorbell ring itself, so a stale locallibquestdb.sofails withUnsatisfiedLinkErrorinstead of spinning.