Skip to content

perf(qwp): event-driven I/O loop, zero CPU when idle - #114

Draft
jerrinot wants to merge 36 commits into
mainfrom
qwp-event-driven-io-loop
Draft

jerrinot wants to merge 36 commits into
mainfrom
qwp-event-driven-io-loop

Conversation

@jerrinot

@jerrinot jerrinot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

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, kqueue EVFILT_USER on 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 in awaitAckedFsn/drain is 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 Sender API 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)

flushes/s CPU main CPU PR ack latency main ack latency PR
idle 20.3% 0%
1k 8.4% 4.4% 171 µs 100 µs
10k 24.2% 22.7% 170 µs 30 µs
25k 43.0% 49.5% 131 µs 27 µs
50k 75.9% 79.1% 99 µs 33 µs
saturation 107k fl/s 134k fl/s

domapc (Ryzen 9 9950X, 16C/32T)

flushes/s CPU main CPU PR ack latency main ack latency PR
idle 1.8% 0%
1k 2.0% 0.7% 149 µs 11 µs
10k 6.0% 5.8% 171 µs 10 µs
25k 10.7% 12.8% 106 µs 7 µs
50k 18.8% 25.4% 82 µs 7 µs
saturation 400k fl/s 477k fl/s

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 default auto_flush_rows=1000 this 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/tla holds TLA+ models of the two wake protocols; IdleWait_bug.cfg reproduces the reconnect race the models caught.
  • macOS and Windows natives compile only in CI.
  • Server side: durable-ack frames are still only flushed on inbound traffic, so the 200ms durable-ack PING stays until the server pushes them.
  • The native wait entry point is Net.awaitReadableOrRing: it consumes the doorbell ring itself, so a stale local libquestdb.so fails with UnsatisfiedLinkError instead of spinning.

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.
@jerrinot
jerrinot force-pushed the qwp-event-driven-io-loop branch from 855470b to 459f909 Compare October 21, 2026 18:38
…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.
@jerrinot

Copy link
Copy Markdown
Contributor Author

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 kill -9, recovery replayed them.

Fix: the first ACK past the persisted watermark wakes the worker; it then re-persists on a 1 ms cadence (WATERMARK_LAG_NANOS) until the watermark is current, then parks again. Same bound as before, no polling when idle. New regression test SegmentManagerAckWatermarkLagTest fails on the previous commit and passes now. The three SenderPoolSfTest tests that assumed the tick now wake the worker explicitly.

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().
@jerrinot

Copy link
Copy Markdown
Contributor Author

Pushed a fix for the lost-waiter finding (awaitAckedFsn / drain sleeping to their deadline).

Root cause: one ackWaiter slot on the I/O loop. A second waiter overwrote the first, and a waiter parked on a loop that a symbol-dictionary recycle closed had no wake source left.

Fix: waiters register in a Sender-owned AckWaiters registry (any number of threads, allocation-free signal) and every loop the sender creates signals it on ack advance, terminal latch and close(). Register-then-check against advance-then-signal, same hand-off the TLA+ model checks.

testAwaitAckedFsnParkedAcrossARecycleSeesTheFreshEpochAck is back from 10 s to sub-second and now asserts the monitor returns within 2 s; new testEveryWaiterIsWokenByTheAck covers three concurrent monitors. Full suite: 3726 tests, 0 failures.

…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.
@jerrinot

Copy link
Copy Markdown
Contributor Author

Pushed a fix for the stranded-frame schedule (idle re-check vs. the two-store publish).

Root cause: a publish stores publishedFsn first and the bytes second (deliberately, for the ack clamp). The loop can only send below the bytes, but its idle re-check keyed on publishedFsn. Your schedule: snapshot the new FSN, find no bytes, producer finishes the publish and sees ioParked == false, loop re-checks publishedFsn, unchanged, blocks with no timeout.

Fix: SegmentRing bumps a publishStamp after the bytes store; the loop snapshots and re-checks that. The Dekker argument now covers the last store of the publish.

The TLA+ model splits the publish into both stores. IdleWait_stampbug.cfg (re-check on publishedFsn) reproduces exactly this schedule; IdleWait_fix.cfg passes all invariants and liveness. Full suite: 3726 tests, 0 failures.

@jerrinot

Copy link
Copy Markdown
Contributor Author

Review (level 3): request changes

HEAD 5d7f6bab vs BASE 82b2e144. No submodule pointers moved. Every behavioural finding below was reproduced on both revisions with the same trigger; reproduction sources are at the end.

Critical

1. 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

  • Problem: Over wss, a large server frame can sit in JavaTlsClientSocket's buffers while the I/O loop waits on the raw socket.
  • Net impact: On Enterprise TLS, the ACK is never processed until another publish. drain()/awaitAckedFsn time out and close() fails.
  • Evidence: Repro test below:
    • awaitAckedFsn: with a single 70 KiB or 150 KiB STATUS_OK frame, HEAD returned acked=false after 5000 ms in 6/6 runs. BASE acked in 3–37 ms in 6/6.
    • close() (with close_flush_timeout_millis=3000): HEAD threw "close() drain timed out … data may be lost" in 5/6 runs. BASE closed cleanly in 6/6.
    • Controls: plain ws passed at every size. TLS frames of 16–60 KiB passed.

Trace

  1. tryReceiveFrame makes one socket.recv call.
  2. JavaTlsClientSocket.recv reads up to 256 KiB of ciphertext. It returns on BUFFER_OVERFLOW when the 64 KiB receive buffer fills, or it spills plaintext into unwrapOutputBuffer. Either way, the rest of the frame stays in user space.
  3. The frame is still incomplete, so tryReceiveFrame returns false and tryReceiveAcks stops.
  4. awaitIdle then polls the kernel fd, which is empty. Keepalive is off by default, so the timeout is -1.

On BASE the 50 µs park simply called recv again.

Who hits it

  • On the server, QwpIngressUpgradeProcessor.trySendAck writes one entry (nameLen + name + seqTxn) for every table committed since the last ACK, which is sent every ≤8 frames. Durable-ack frames have the same table-list layout.
  • Enterprise serves QWP over TLS (http.tls.enabled).
  • So a wss sender that commits roughly 2,000+ distinct tables in one ACK window and then goes idle hits this. One example is the last batch before close().

Fix (validated) In WebSocketClient.tryReceiveFrame, keep calling recv while it returns > 0 and the frame is still PARSE_NEED_MORE. Alternatively, treat "bytes received" as didWork in the loop.

  • With the patch below, the repro acks in 1–7 ms at every size.
  • WebSocketClientTest, WebSocketClientDoorbellTest and CursorWebSocketSendLoopIdleWaitTest still pass.
  • Please add a TLS regression test along the lines of the repro.
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;
     }
 
     /**

Moderate

2. A failed segment trim is never retried while the slot is idle

  • Problem: The manager's park deadline ignores trimRetryAtNanos.
  • Net impact: On disk SF with default durability (sf_durability=memory), acked segments and their budget charge are held until the next ACK or rotation.
  • Evidence: One transient failure, then 3 s with no wake (repro below):
    • HEAD: fileExists=true pending=1 for unlink, ackedStillSealed=true for fsyncDir; 3/3 runs.
    • BASE: retried and removed; 3/3 runs.

recordTrimFailure sets a 4 ms–1 s backoff. The workerLoop park computation only folds in the provisioning retry, the periodic-sync deadline and the watermark lag. With the watermark already persisted, the worker parks indefinitely. The existing trim-retry tests call wakeWorker() explicitly, so they can't see this.

  • Fix: when e.trimRetryDelayNanos != 0, include e.trimRetryAtNanos - now in parkNanos.

3. A reconnect triggered by a failed PING leaves the replay unsent

  • Problem: recycledThisIteration is checked before steps 3–4 (sendPingIfDue) but not before the step-5 park.
  • Net impact:
    • Durable-ack mode: the replay waits until the next publish.
    • With keepalive on: the replay waits one keepalive interval.
  • Evidence: Repro below:
    • HEAD, durable: replayedOnReplacement=0 after 5 s.
    • HEAD, keepalive=1 s: replayed after 1000 ms.
    • BASE: 0–2 ms in both modes.

Trace

  1. sendPing throws, which calls fail → swapClient. The cursor is rewound and pendingDurable is cleared.
  2. Step 5 parks. idleTimeoutMillis() is -1 because pendingDurable is now empty.

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.

  • Fix: add !recycledThisIteration to the step-5 guard.

4. The new replay regression test passes with the fix removed

  • Problem: testReplayAfterReconnectIsNotStrandedByIdleWait stays green with recycledThisIteration = true deleted from swapClient.
  • Net impact: A regression that strands rewound replays would ship undetected.
  • Evidence: Mutation run on HEAD:
    • CursorWebSocketSendLoopIdleWaitTest is 2/2 green with the line removed.
    • A probe whose first sendBinary throws replays in 3 ms with the flag and stays stranded for 3 s without it (testSendFailureReplay below).

The test's transport fails on receive after a successful send, so didWork is already true in that iteration. It should fail on sendBinary (or sendPing) instead, with the producer idle.

5. AckWaiters allocates on every wait iteration, and every ACK triggers a new iteration

  • Problem: add calls Arrays.copyOf and remove calls new Thread[n-1] on every loop pass in awaitAckedFsn/drainOnClose. remove allocates a fresh Thread[0] instead of reusing NONE.
  • Net impact: drain()/awaitAckedFsn allocate about 2 arrays per ACK received while they wait. BASE allocated nothing.
  • Evidence: Static: AckWaiters.java:42-58. The arrays are published through a volatile field, so escape analysis can't remove them. signal() wakes every waiter on each ACK advance.

This is per call, not per row, but it is still garbage on a flush-and-wait API.

  • Fix:
    • Register once per call, outside the loop. The unpark permit already covers the gap between check and park.
    • Use a slot array that only grows when needed, so steady-state waits allocate nothing.

6. The documented binary-compatibility constructor no longer exists, and SegmentManager(long,long) changed meaning

  • Problem: CursorWebSocketSendLoop's overload documented as "as released in 1.3.x … kept so callers compiled against a released jar keep linking" dropped parkNanos.
  • Net impact: Code compiled against 1.3.x that calls these exported sf.cursor constructors either fails to link or misbehaves (details below). I found no callers in the questdb or questdb-enterprise sources.
  • Evidence: Static:
    • Tags 1.3.7–1.3.9 have (…, long fsnAtZero, long parkNanos, …) and SegmentManager(long segmentSizeBytes, long pollNanos).
    • HEAD has SegmentManager(long segmentSizeBytes, long maxTotalBytes), which has the same (JJ)V descriptor.

Old callers of the loop constructors get NoSuchMethodError. An old new SegmentManager(seg, 1_000_000L) links fine and is then read as a 1 MB cap: it throws IAE if the segment is larger than 1 MB, and silently caps the budget otherwise.

  • Fix:
    • Keep @Deprecated overloads with the old descriptors that ignore parkNanos.
    • Don't reuse (long,long) with a new meaning.

Minor

  • SegmentRing.java:1507-1521: the publishedFsn() javadoc now sits above publishStamp()'s javadoc. That leaves it dangling, and publishedFsn() has no javadoc.
  • These comments are stale:
    • the awaitAckedFsn javadoc ("Polls on a 50us park"; "observed on the next pass");
    • SenderErrorDispatcher.close() ("100ms poll timeout");
    • design/tla/AckWait.tla, which models a CursorWebSocketSendLoop.awaitAckProgress()/single-waiter design that doesn't exist (the code uses AckWaiters);
    • the CursorWebSocketSendLoopRotationRaceTest javadoc, which still describes 1 µs polling.
  • Member ordering:
    • SegmentManager: PROVISION_RETRY_NANOS and WATERMARK_LAG_NANOS come before DISK_FULL_LOG_THROTTLE_NANOS, and provisionRetryDeadlineNanos sits among the final fields.
    • CursorSendEngine.ioWakeup sits among the final fields.
    • The AckWaiters import in QwpWebSocketSender is out of order.

Coverage and CI

  • Test gate: passes, with no Critical coverage gap. The tests missing for findings 1–3 are listed in their fixes.
  • Tandem: No OSS or Enterprise tandem PR exists (gh pr list --head qwp-event-driven-io-loop returned nothing in both repos). Finding 1 is exactly the kind of thing an Enterprise TLS e2e run with many tables would exercise.
  • CI:
    • Everything is green, including the Azure macOS and Windows build-and-test legs.
    • The exception is the linux-aarch64 GLIBC floor guard. It failed in "Install tooling": the EPEL mirror returned a certificate error and a checksum mismatch on repomd.xml, before any build step ran. That is an infrastructure failure, but it means the new native code is unchecked on aarch64. Please re-run it.

Summary

  • Verdict: request changes, because finding 1 is Critical.
  • Split: 1 Critical, 5 Moderate and a few Minors. All are in-diff.
  • Tradeoffs: The idle-CPU win is real. Two I/O-thread ACK-path costs are possible but unmeasured, so they are not filed here:
    • the I/O thread now takes the ring monitor via firstTrimmable();
    • the manager wakes once per ACK under load.
Repro: TLS large-ACK stall (finding 1), core/src/test/java/io/questdb/client/test/cutlass/qwp/client/TlsLargeAckStallScratchTest.java
package io.questdb.client.test.cutlass.qwp.client;

import io.questdb.client.Sender;
import io.questdb.client.test.cutlass.qwp.websocket.TestWebSocketServer;
import org.junit.Assert;
import org.junit.Test;

import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.IdentityHashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;

public class TlsLargeAckStallScratchTest {

    @Test(timeout = 180_000)
    public void testBigAck150k() throws Exception { for (int i = 0; i < 3; i++) runOnce(150 * 1024, i); }

    @Test(timeout = 180_000)
    public void testBigAck70k() throws Exception { for (int i = 0; i < 3; i++) runOnce(70 * 1024, i); }

    private static void runOnce(int bigSize, int run) throws Exception {
        BigFirstAckHandler handler = new BigFirstAckHandler(bigSize);
        try (TestWebSocketServer server = TestWebSocketServer.tls(handler)) {
            server.start();
            Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS));
            try (Sender sender = Sender.fromConfig("wss::addr=localhost:" + server.getPort() + ";tls_verify=unsafe_off;")) {
                sender.table("t").longColumn("v", 1).atNow();
                long fsn = sender.flushAndGetSequence();
                long t0 = System.nanoTime();
                boolean acked = sender.awaitAckedFsn(fsn, 5_000);
                long waitMs = (System.nanoTime() - t0) / 1_000_000L;
                System.out.println("RESULT run=" + run + " size=" + bigSize + " ackPayload=" + handler.bigPayloadLen
                        + " acked=" + acked + " waitMs=" + waitMs + " framesAtServer=" + handler.frames.get()
                        + " fsn=" + fsn + " ackedFsn=" + sender.getAckedFsn());
                // Unrelated event: a second publish should unstick a stalled loop.
                sender.table("t").longColumn("v", 2).atNow();
                long fsn2 = sender.flushAndGetSequence();
                long t1 = System.nanoTime();
                boolean acked2 = sender.awaitAckedFsn(fsn2, 5_000);
                System.out.println("RESULT2 run=" + run + " size=" + bigSize + " ackedAfterSecondPublish=" + acked2
                        + " waitMs=" + (System.nanoTime() - t1) / 1_000_000L + " ackedFsn=" + sender.getAckedFsn());
            }
        }
    }

    static byte[] buildBigAck(long seq, int targetPayload) {
        int header = 1 + 8 + 2;
        int entry = 2 + 8 + 8; // nameLen + "t_NNNNNN" + seqTxn
        int count = (targetPayload - header + entry - 1) / entry;
        ByteBuffer bb = ByteBuffer.allocate(header + count * entry).order(ByteOrder.LITTLE_ENDIAN);
        bb.put((byte) 0x00).putLong(seq).putShort((short) count);
        for (int i = 0; i < count; i++) {
            byte[] name = String.format("t_%06d", i).getBytes(StandardCharsets.US_ASCII);
            bb.putShort((short) name.length).put(name).putLong(1L);
        }
        return bb.array();
    }

    static final class BigFirstAckHandler implements TestWebSocketServer.WebSocketServerHandler {
        final int bigSize;
        final AtomicLong frames = new AtomicLong();
        volatile int bigPayloadLen;
        private final Map<TestWebSocketServer.ClientHandler, AtomicLong> seqs =
                Collections.synchronizedMap(new IdentityHashMap<TestWebSocketServer.ClientHandler, AtomicLong>());

        BigFirstAckHandler(int bigSize) { this.bigSize = bigSize; }

        @Override
        public void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] data) {
            frames.incrementAndGet();
            AtomicLong s = seqs.get(client);
            if (s == null) { s = new AtomicLong(); seqs.put(client, s); }
            long seq = s.getAndIncrement();
            byte[] ack = seq == 0 ? buildBigAck(seq, bigSize) : WebSocketDynamicCredentialTest.buildAck(seq);
            if (seq == 0) bigPayloadLen = ack.length;
            try { client.sendBinary(ack); } catch (IOException e) { throw new RuntimeException(e); }
        }
    }
}
Repro: idle trim retry (finding 2), sf/cursor/ScratchTrimIdleRetryTest.java (BASE: use the commented DEFAULT_POLL_NANOS ctor)
package io.questdb.client.test.cutlass.qwp.client.sf.cursor;

import io.questdb.client.cutlass.qwp.client.sf.cursor.AckWatermark;
import io.questdb.client.cutlass.qwp.client.sf.cursor.MmapSegment;
import io.questdb.client.cutlass.qwp.client.sf.cursor.SegmentManager;
import io.questdb.client.cutlass.qwp.client.sf.cursor.SegmentRing;
import io.questdb.client.std.Files;
import io.questdb.client.std.FilesFacade;
import io.questdb.client.std.MemoryTag;
import io.questdb.client.std.Unsafe;
import org.junit.After;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;

import java.nio.file.Paths;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;

public class ScratchTrimIdleRetryTest {
    private static final String REV = "HEAD"; // "BASE" in the base worktree
    private String tmpDir;

    private static SegmentManager newManager(long seg, FilesFacade ff) {
        return new SegmentManager(seg, seg * 8, ff);                                   // HEAD: System::nanoTime, production-equivalent
        // return new SegmentManager(seg, SegmentManager.DEFAULT_POLL_NANOS, seg * 8, ff); // BASE: 1 ms poll as in CursorSendEngine, System::nanoTime
    }

    @Before
    public void setUp() {
        tmpDir = Paths.get(System.getProperty("java.io.tmpdir"), "qdb-scratch-trim-" + System.nanoTime()).toString();
        Assert.assertEquals(0, Files.mkdir(tmpDir, Files.DIR_MODE_DEFAULT));
    }

    @After
    public void tearDown() { removeRecursive(tmpDir); }

    @Test public void unlinkOnce() throws Exception { run(false); }
    @Test public void fsyncDirOnce() throws Exception { run(true); }

    private void run(boolean fsyncFault) throws Exception {
        long seg = MmapSegment.HEADER_SIZE + MmapSegment.FRAME_HEADER_SIZE + 32L;
        String dir = tmpDir + (fsyncFault ? "/fsync" : "/unlink");
        Assert.assertEquals(0, Files.mkdir(dir, Files.DIR_MODE_DEFAULT));
        String acked = dir + "/sf-0000000000000000.sfa";
        SegmentRing ring = new SegmentRing(MmapSegment.create(acked, 0L, seg), seg);
        AckWatermark wm = AckWatermark.open(dir);
        Assert.assertNotNull(wm);
        wm.write(-1L);
        long payload = Unsafe.malloc(32, MemoryTag.NATIVE_DEFAULT);
        try {
            Assert.assertEquals(0L, ring.appendOrFsn(payload, 32));
            ring.installHotSpare(MmapSegment.create(dir + "/sf-0000000000000001.sfa", 1L, seg));
            Assert.assertEquals(1L, ring.appendOrFsn(payload, 32));
            // Idle steady state: spare already provisioned -> no provision fsync / provision-retry timer.
            ring.installHotSpare(MmapSegment.create(dir + "/sf-0000000000000002.sfa", 2L, seg));
            ring.acknowledge(0L);
            OneShotFacade ff = new OneShotFacade(fsyncFault ? null : acked, fsyncFault ? dir : null);
            try (SegmentManager m = newManager(seg, ff)) {
                m.register(ring, dir, wm);
                ff.armed = true;
                m.start();
                Assert.assertTrue("fault never hit", ff.firstFault.await(5, TimeUnit.SECONDS));
                Thread.sleep(3_000L); // no ACK, no append, no wakeWorker()
                MmapSegment fs = ring.firstSealed();
                System.out.println("SCRATCH_RESULT rev=" + REV + " kind=" + (fsyncFault ? "fsyncDir" : "unlink")
                        + " faults=" + ff.faults.get() + " removeCalls=" + ff.removeCalls.get()
                        + " fsyncDirCalls=" + ff.fsyncDirCalls.get() + " fileExists=" + Files.exists(acked)
                        + " pending=" + ring.getPendingTrimCount()
                        + " ackedStillSealed=" + (fs != null && acked.equals(fs.path()))
                        + " managerBytes=" + m.getTotalBytesForTesting() + " ringBytes=" + ring.totalSegmentBytes()
                        + " segSize=" + seg);
            }
        } finally {
            Unsafe.free(payload, 32, MemoryTag.NATIVE_DEFAULT);
            ring.close();
            wm.close();
        }
    }

    private static void removeRecursive(String dir) {
        long find = Files.findFirst(dir);
        if (find > 0) {
            try {
                int rc = 1;
                while (rc > 0) {
                    String name = Files.utf8ToString(Files.findName(find));
                    if (name != null && !".".equals(name) && !"..".equals(name)) {
                        String child = dir + "/" + name;
                        if (!Files.remove(child)) removeRecursive(child);
                    }
                    rc = Files.findNext(find);
                }
            } finally { Files.findClose(find); }
        }
        Files.remove(dir);
    }

    // Delegating facade; method set mirrors FailingFilesFacade in SegmentManagerUnlinkFailureTest of the same revision.
    private static final class OneShotFacade implements FilesFacade {
        final String removeFailPath; final String fsyncFailDir;
        final CountDownLatch firstFault = new CountDownLatch(1);
        final AtomicInteger faults = new AtomicInteger(), removeCalls = new AtomicInteger(), fsyncDirCalls = new AtomicInteger();
        volatile boolean armed;
        OneShotFacade(String removeFailPath, String fsyncFailDir) { this.removeFailPath = removeFailPath; this.fsyncFailDir = fsyncFailDir; }
        @Override public boolean remove(String path) {
            if (path.equals(removeFailPath)) {
                removeCalls.incrementAndGet();
                if (armed && faults.get() == 0) { faults.incrementAndGet(); firstFault.countDown(); return false; }
            }
            return INSTANCE.remove(path);
        }
        @Override public int fsyncDir(String d) {
            if (d.equals(fsyncFailDir) && armed) {
                fsyncDirCalls.incrementAndGet();
                if (faults.get() == 0) { faults.incrementAndGet(); firstFault.countDown(); return -1; }
            }
            return INSTANCE.fsyncDir(d);
        }
        @Override public boolean allocate(int fd, long size) { return INSTANCE.allocate(fd, size); }
        @Override public long allocNativePath(String p) { return INSTANCE.allocNativePath(p); }
        @Override public int close(int fd) { return INSTANCE.close(fd); }
        @Override public boolean exists(String p) { return INSTANCE.exists(p); }
        @Override public void findClose(long f) { INSTANCE.findClose(f); }
        @Override public long findFirst(String d) { return INSTANCE.findFirst(d); }
        @Override public long findName(long f) { return INSTANCE.findName(f); }
        @Override public int findNext(long f) { return INSTANCE.findNext(f); }
        @Override public int findType(long f) { return INSTANCE.findType(f); }
        @Override public void freeNativePath(long p) { INSTANCE.freeNativePath(p); }
        @Override public int fsync(int fd) { return INSTANCE.fsync(fd); }
        @Override public long length(int fd) { return INSTANCE.length(fd); }
        @Override public long length(String p) { return INSTANCE.length(p); }
        @Override public long length(long p) { return INSTANCE.length(p); }
        @Override public int lock(int fd) { return INSTANCE.lock(fd); }
        @Override public int mkdir(String p, int m) { return INSTANCE.mkdir(p, m); }
        @Override public int openCleanRW(String p) { return INSTANCE.openCleanRW(p); }
        @Override public int openCleanRW(long p) { return INSTANCE.openCleanRW(p); }
        @Override public int openRW(String p) { return INSTANCE.openRW(p); }
        @Override public int openRW(long p) { return INSTANCE.openRW(p); }
        @Override public long read(int fd, long a, long l, long o) { return INSTANCE.read(fd, a, l, o); }
        @Override public boolean remove(long p) { return INSTANCE.remove(p); }
        @Override public int rename(String a, String b) { return INSTANCE.rename(a, b); }
        @Override public boolean truncate(int fd, long s) { return INSTANCE.truncate(fd, s); }
        @Override public long write(int fd, long a, long l, long o) { return INSTANCE.write(fd, a, l, o); }
    }
}
Repro: PING/send-failure replay (findings 3 and 4), sf/cursor/PingFailureReplayProbeTest.java + ProbeLoops.java (BASE ctor takes DEFAULT_PARK_NANOS after fsnAtZero)
package io.questdb.client.test.cutlass.qwp.client.sf.cursor;

import io.questdb.client.DefaultHttpClientConfiguration;
import io.questdb.client.cutlass.http.client.WebSocketClient;
import io.questdb.client.cutlass.http.client.WebSocketFrameHandler;
import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorSendEngine;
import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorWebSocketSendLoop;
import io.questdb.client.network.PlainSocketFactory;
import io.questdb.client.std.Files;
import io.questdb.client.std.MemoryTag;
import io.questdb.client.std.Unsafe;
import org.junit.Test;

import java.nio.file.Paths;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;

public class PingFailureReplayProbeTest {

    @Test
    public void testKeepalivePingFailure() throws Exception {
        run(false);
    }

    @Test
    public void testDurableAckPingFailure() throws Exception {
        run(true);
    }

    @Test
    public void testSendFailureReplay() throws Exception {
        String dir = Paths.get(System.getProperty("java.io.tmpdir"), "qdb-send-probe-" + System.nanoTime()).toString();
        Files.mkdir(dir, Files.DIR_MODE_DEFAULT);
        try (CursorSendEngine engine = new CursorSendEngine(dir, 16384)) {
            StubClient first = new StubClient(false, false);
            first.failFirstSend = true;
            StubClient replacement = new StubClient(false, false);
            CursorWebSocketSendLoop loop = ProbeLoops.newLoop(first, engine, () -> replacement, false);
            try {
                loop.start();
                long buf = Unsafe.malloc(16, MemoryTag.NATIVE_DEFAULT);
                try {
                    engine.appendBlocking(buf, 16);
                } finally {
                    Unsafe.free(buf, 16, MemoryTag.NATIVE_DEFAULT);
                }
                long t0 = System.nanoTime();
                long d2 = t0 + TimeUnit.SECONDS.toNanos(3);
                while (replacement.sentFrames.get() == 0 && System.nanoTime() < d2) Thread.sleep(1);
                System.out.println("SEND_PROBE replayedOnReplacement=" + replacement.sentFrames.get()
                        + " delayMs=" + TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - t0));
            } finally {
                loop.close();
            }
        }
    }

    private void run(boolean durable) throws Exception {
        String dir = Paths.get(System.getProperty("java.io.tmpdir"), "qdb-ping-probe-" + System.nanoTime()).toString();
        Files.mkdir(dir, Files.DIR_MODE_DEFAULT);
        try (CursorSendEngine engine = new CursorSendEngine(dir, 16384)) {
            StubClient first = new StubClient(true, durable);
            StubClient replacement = new StubClient(false, false);
            CursorWebSocketSendLoop loop = ProbeLoops.newLoop(first, engine, () -> replacement, durable);
            if (!durable) {
                loop.setKeepaliveIntervalMillis(1_000L);
            }
            try {
                loop.start();
                long buf = Unsafe.malloc(16, MemoryTag.NATIVE_DEFAULT);
                try {
                    for (int i = 0; i < 16; i++) Unsafe.getUnsafe().putByte(buf + i, (byte) 'x');
                    engine.appendBlocking(buf, 16);
                } finally {
                    Unsafe.free(buf, 16, MemoryTag.NATIVE_DEFAULT);
                }
                long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10);
                while (first.pingAttempts.get() == 0 && System.nanoTime() < deadline) Thread.sleep(1);
                long tFail = System.nanoTime();
                long d2 = tFail + TimeUnit.SECONDS.toNanos(5);
                while (replacement.sentFrames.get() == 0 && System.nanoTime() < d2) Thread.sleep(1);
                System.out.println("PING_PROBE durable=" + durable
                        + " firstSent=" + first.sentFrames.get()
                        + " pingAttempts=" + first.pingAttempts.get()
                        + " replayedOnReplacement=" + replacement.sentFrames.get()
                        + " replayDelayMs=" + TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - tFail)
                        + " ackedFsn=" + engine.ackedFsn());
            } finally {
                loop.close();
            }
        }
    }

    static final class StubClient extends WebSocketClient {
        final AtomicInteger sentFrames = new AtomicInteger();
        final AtomicInteger pingAttempts = new AtomicInteger();
        private final boolean failPing;
        private final boolean deliverDurableOk;
        private final Semaphore doorbell = new Semaphore(0);
        private boolean okDelivered;

        StubClient(boolean failPing, boolean deliverDurableOk) {
            super(DefaultHttpClientConfiguration.INSTANCE, PlainSocketFactory.INSTANCE);
            this.failPing = failPing;
            this.deliverDurableOk = deliverDurableOk;
        }

        // Head-only hooks (no @Override so the file also compiles at base).
        public boolean awaitReadableOrWake(int timeoutMillis) {
            try {
                boolean woken;
                if (timeoutMillis < 0) {
                    doorbell.acquire();
                    woken = true;
                } else {
                    woken = doorbell.tryAcquire(timeoutMillis, TimeUnit.MILLISECONDS);
                }
                doorbell.drainPermits();
                return woken;
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                return false;
            }
        }

        public void wake() {
            doorbell.release();
        }

        volatile boolean failFirstSend;

        @Override
        public void sendBinary(long dataPtr, int length) {
            if (failFirstSend) {
                failFirstSend = false;
                throw new io.questdb.client.cutlass.http.client.HttpClientException("peer disconnect [errno=104]");
            }
            sentFrames.incrementAndGet();
        }

        @Override
        public void sendPing() {
            pingAttempts.incrementAndGet();
            if (failPing) {
                throw new io.questdb.client.cutlass.http.client.HttpClientException("timed out [errno=11]");
            }
        }

        @Override
        public boolean tryReceiveFrame(WebSocketFrameHandler handler) {
            if (deliverDurableOk && !okDelivered && sentFrames.get() > 0) {
                okDelivered = true;
                // STATUS_OK, seq 0, 1 table "t" seqTxn 1
                int len = 1 + 8 + 2 + 2 + 1 + 8;
                long p = Unsafe.malloc(len, MemoryTag.NATIVE_DEFAULT);
                try {
                    Unsafe.getUnsafe().putByte(p, (byte) 0);
                    Unsafe.getUnsafe().putLong(p + 1, 0L);
                    Unsafe.getUnsafe().putShort(p + 9, (short) 1);
                    Unsafe.getUnsafe().putShort(p + 11, (short) 1);
                    Unsafe.getUnsafe().putByte(p + 13, (byte) 't');
                    Unsafe.getUnsafe().putLong(p + 14, 1L);
                    handler.onBinaryMessage(p, len);
                } finally {
                    Unsafe.free(p, len, MemoryTag.NATIVE_DEFAULT);
                }
                return true;
            }
            return false;
        }

        @Override
        protected void ioWait(int timeout, int op) {
        }

        @Override
        protected void setupIoWait() {
        }
    }
}
package io.questdb.client.test.cutlass.qwp.client.sf.cursor;
import io.questdb.client.cutlass.http.client.WebSocketClient;
import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorSendEngine;
import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorWebSocketSendLoop;
final class ProbeLoops {
    static CursorWebSocketSendLoop newLoop(WebSocketClient c, CursorSendEngine e, CursorWebSocketSendLoop.ReconnectFactory f, boolean durable) {
        return new CursorWebSocketSendLoop(c, e, 0L, f, 1L, 10L, durable,
                CursorWebSocketSendLoop.DEFAULT_DURABLE_ACK_KEEPALIVE_INTERVAL_MILLIS,
                CursorWebSocketSendLoop.DEFAULT_MAX_HEAD_FRAME_REJECTIONS);
    }
}

…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.
@jerrinot

Copy link
Copy Markdown
Contributor Author

Pushed a fix for the TLS large-frame finding.

Root cause: JavaTlsClientSocket holds decrypted plaintext (spill buffer) and complete undecrypted TLS records in user space when a server frame exceeds the caller's buffer. The idle wait polled the fd, which was empty, so a partly received ACK was never finished until the next publish.

Fix: Socket.hasBufferedInput() (default false; the TLS socket reports a non-empty spill buffer or an unprocessed record). awaitReadableOrWake() returns readable without blocking when it is set. The WebSocket layer itself already parses buffered bytes before every recv, so TLS was the only layer hiding progress from poll.

New TlsLargeAckIdleWaitTest sends a 150 KiB STATUS_OK over wss and over plain ws; the TLS variant hangs to its timeout on the previous commit and passes now. Full suite: 3728 tests, 0 failures.

… 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.
@jerrinot

Copy link
Copy Markdown
Contributor Author

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 trimRetryAtNanos too. SegmentManagerUnlinkFailureTest#testFailedUnlinkIsRetriedWithoutExternalWakeup: real clock, one failed unlink, no wakeWorker(); the retry has to come on its own. Fails in 5 s without the fix.

3. PING-failure replay. Step 5 skips the wait when recycledThisIteration was set again by a PING that reconnected. testReplayAfterPingFailureIsNotStrandedByIdleWait (durable-ack mode, PING throws) fails without the guard.

4. Weak replay test. The transport now throws on sendBinary, so the iteration does no work. Mutation-checked: deleting recycledThisIteration = true in swapClient fails this test and the PING one; deleting the step-5 guard fails the PING one only.

5. AckWaiters allocation. Register once per call, loop check/park, deregister on return; remove() reuses the empty array. The park permit covers an ack between check and park; AckWait.tla models the register-once protocol and still passes.

6. 1.3.x descriptors. The seven loop constructors with parkNanos and SegmentManager(long, long pollNanos) / (long, long, long) are back as @Deprecated delegates. (long, long) keeps its old meaning, so a byte cap goes through SegmentBudget (the existing (long, SegmentBudget) overload). Verified with javap that the descriptors are present and, by scanning every call site, that nothing binds to the deprecated ones.

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.

jerrinot and others added 13 commits October 22, 2026 13:17
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.
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.
@mtopolnik

Copy link
Copy Markdown
Contributor

[PR Coverage check]

😍 pass : 332 / 376 (88.30%)

file detail

path covered line new line coverage
🔵 io/questdb/client/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoop.java 51 71 71.83%
🔵 io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentProvisioner.java 56 65 86.15%
🔵 io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentManager.java 25 29 86.21%
🔵 io/questdb/client/cutlass/qwp/client/sf/cursor/CursorSendEngine.java 23 26 88.46%
🔵 io/questdb/client/cutlass/http/client/WebSocketClient.java 26 29 89.66%
🔵 io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentRing.java 55 58 94.83%
🔵 io/questdb/client/cutlass/qwp/client/sf/cursor/AckWaiters.java 24 25 96.00%
🔵 io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java 55 56 98.21%
🔵 io/questdb/client/network/JavaTlsClientSocket.java 4 4 100.00%
🔵 io/questdb/client/cutlass/qwp/client/sf/cursor/SenderErrorDispatcher.java 1 1 100.00%
🔵 io/questdb/client/cutlass/qwp/client/sf/cursor/SenderConnectionDispatcher.java 1 1 100.00%
🔵 io/questdb/client/cutlass/qwp/client/sf/cursor/SenderProgressDispatcher.java 10 10 100.00%
🔵 io/questdb/client/network/Socket.java 1 1 100.00%

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

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants