From 459f9092717b8c056b46e2dfc4ccd09e588dd0e9 Mon Sep 17 00:00:00 2001 From: Jaromir Hamala Date: Wed, 21 Oct 2026 20:28:01 +0200 Subject: [PATCH 01/36] perf(qwp): event-driven I/O loop, zero CPU when idle 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. --- core/src/main/c/share/net.c | 111 ++++++++ core/src/main/c/windows/net.c | 61 +++++ .../cutlass/http/client/WebSocketClient.java | 51 ++++ .../qwp/client/QwpWebSocketSender.java | 30 ++- .../client/sf/cursor/BackgroundDrainer.java | 2 +- .../client/sf/cursor/CursorSendEngine.java | 33 ++- .../sf/cursor/CursorWebSocketSendLoop.java | 217 ++++++++++++--- .../qwp/client/sf/cursor/SegmentManager.java | 92 ++++--- .../qwp/client/sf/cursor/SegmentRing.java | 5 + .../sf/cursor/SenderConnectionDispatcher.java | 3 +- .../sf/cursor/SenderErrorDispatcher.java | 3 +- .../sf/cursor/SenderProgressDispatcher.java | 11 +- .../java/io/questdb/client/network/Net.java | 28 ++ .../client/WebSocketClientDoorbellTest.java | 100 +++++++ .../http/client/WebSocketClientTest.java | 9 +- ...QwpConnectWalkBackgroundIsolationTest.java | 4 +- ...QwpWebSocketSenderJvmErrorCleanupTest.java | 3 +- .../client/SlotLockReleasedContractTest.java | 13 +- .../SymbolDictRecycleLoopTerminalTest.java | 4 +- .../client/SymbolDictRecycleSlotHealTest.java | 4 +- .../BackgroundDrainerDurableAckRetryTest.java | 3 +- ...kgroundDrainerInterruptedTeardownTest.java | 3 +- .../sf/cursor/CloseOwnershipRaceTest.java | 2 +- ...SendEngineClosePartialEnumerationTest.java | 1 - ...ursorSendEngineCloseUnlinkFailureTest.java | 3 +- ...gineCloseUnlinkStopOnFirstFailureTest.java | 1 - .../CursorSendEngineCrashConsistencyTest.java | 6 +- ...CursorSendEngineSlotReacquisitionTest.java | 2 +- ...WebSocketSendLoopBlockedSendCloseTest.java | 6 +- ...WebSocketSendLoopCatchUpAlignmentTest.java | 10 +- ...ursorWebSocketSendLoopConnectGateTest.java | 7 +- ...ebSocketSendLoopConnectPhaseCloseTest.java | 9 +- ...orWebSocketSendLoopDurableAckFuzzTest.java | 2 +- ...CursorWebSocketSendLoopDurableAckTest.java | 4 +- ...SendLoopForegroundReconnectPolicyTest.java | 6 +- ...sorWebSocketSendLoopGracefulCloseTest.java | 7 +- .../CursorWebSocketSendLoopIdleWaitTest.java | 250 ++++++++++++++++++ ...ocketSendLoopInterruptedCloseLeakTest.java | 5 +- ...CursorWebSocketSendLoopMirrorLeakTest.java | 10 +- ...CursorWebSocketSendLoopOrphanTailTest.java | 6 +- ...ursorWebSocketSendLoopPoisonFrameTest.java | 19 +- ...rsorWebSocketSendLoopRotationRaceTest.java | 9 +- ...sorWebSocketSendLoopTornDictGuardTest.java | 5 +- ...ursorWebSocketSendLoopZeroBackoffTest.java | 4 +- .../cursor/SegmentManagerCloseRaceTest.java | 16 +- .../SegmentManagerCrashConsistencyTest.java | 12 +- .../SegmentManagerManifestFsyncTest.java | 2 +- .../SegmentManagerPeriodicSyncTest.java | 6 - .../cursor/SegmentManagerPinnedTrimTest.java | 4 +- .../cursor/SegmentManagerRecoveryCapTest.java | 2 +- .../SegmentManagerSharedBudgetTest.java | 7 +- .../cursor/SegmentManagerSideFileCapTest.java | 6 +- .../client/sf/cursor/SegmentManagerTest.java | 30 +-- .../SegmentManagerTotalBytesRaceTest.java | 6 +- .../SegmentManagerTrimDeregisterRaceTest.java | 12 +- .../SegmentManagerUnlinkFailureTest.java | 6 +- ...entManagerWatermarkDeregisterRaceTest.java | 2 +- .../cursor/SegmentRecoveryIntegrityTest.java | 4 +- .../client/test/impl/SenderPoolSfTest.java | 2 +- .../test/tools/TestWebSocketClient.java | 76 ++++++ design/tla/AckWait.cfg | 4 + design/tla/AckWait.tla | 71 +++++ design/tla/IdleWait.tla | 167 ++++++++++++ design/tla/IdleWait_bug.cfg | 6 + design/tla/IdleWait_fix.cfg | 6 + design/tla/README.md | 28 ++ 66 files changed, 1401 insertions(+), 238 deletions(-) create mode 100644 core/src/test/java/io/questdb/client/test/cutlass/http/client/WebSocketClientDoorbellTest.java create mode 100644 core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopIdleWaitTest.java create mode 100644 core/src/test/java/io/questdb/client/test/tools/TestWebSocketClient.java create mode 100644 design/tla/AckWait.cfg create mode 100644 design/tla/AckWait.tla create mode 100644 design/tla/IdleWait.tla create mode 100644 design/tla/IdleWait_bug.cfg create mode 100644 design/tla/IdleWait_fix.cfg create mode 100644 design/tla/README.md diff --git a/core/src/main/c/share/net.c b/core/src/main/c/share/net.c index f3bc1ddd5..b98f4f340 100644 --- a/core/src/main/c/share/net.c +++ b/core/src/main/c/share/net.c @@ -34,6 +34,11 @@ #include #include #include +#ifdef __linux__ +#include +#else +#include +#endif #include #include "glibc_compat.h" #include "net.h" @@ -439,6 +444,112 @@ JNIEXPORT jint JNICALL Java_io_questdb_client_network_Net_socketUdp return fd; } +// Doorbell + idle wait for the QWP I/O thread. +// Linux: eventfd; the wait is poll(2) on socket + eventfd. +// macOS/FreeBSD: a kqueue holding an EVFILT_USER; the kqueue is both the +// doorbell and the wait, the socket is (re)armed per wait. +// A ring is one syscall on the producer and touches no network stack. EINTR +// in a wait returns 0: the caller re-checks its inputs and waits again. +#ifdef __linux__ + +JNIEXPORT jint JNICALL Java_io_questdb_client_network_Net_doorbellCreate + (JNIEnv *e, jclass cl) { + return eventfd(0, EFD_NONBLOCK | EFD_CLOEXEC); +} + +JNIEXPORT void JNICALL Java_io_questdb_client_network_Net_doorbellDrain + (JNIEnv *e, jclass cl, jint fd) { + uint64_t v; + ssize_t n; + RESTARTABLE(read((int) fd, &v, sizeof(v)), n); +} + +JNIEXPORT jint JNICALL Java_io_questdb_client_network_Net_doorbellRing + (JNIEnv *e, jclass cl, jint fd) { + uint64_t one = 1; + ssize_t n; + RESTARTABLE(write((int) fd, &one, sizeof(one)), n); + // EAGAIN = counter saturated = already ringing. + return (n == (ssize_t) sizeof(one) || (n < 0 && errno == EAGAIN)) ? 0 : -1; +} + +JNIEXPORT void JNICALL Java_io_questdb_client_network_Net_doorbellClose + (JNIEnv *e, jclass cl, jint fd) { + close((int) fd); +} + +JNIEXPORT jint JNICALL Java_io_questdb_client_network_Net_pollReadable + (JNIEnv *e, jclass cl, jint socketFd, jint doorbell, jint timeoutMillis) { + struct pollfd fds[2]; + fds[0].fd = (int) socketFd; + fds[0].events = POLLIN; + fds[0].revents = 0; + fds[1].fd = (int) doorbell; + fds[1].events = POLLIN; + fds[1].revents = 0; + int n = poll(fds, 2, (int) timeoutMillis); + return (n < 0 && errno == EINTR) ? 0 : n; +} + +#else // kqueue platforms + +JNIEXPORT jint JNICALL Java_io_questdb_client_network_Net_doorbellCreate + (JNIEnv *e, jclass cl) { + int kq = kqueue(); + if (kq < 0) { + return -1; + } + struct kevent ev; + // EV_CLEAR: a delivered ring resets itself, an undelivered one stays pending. + EV_SET(&ev, 0, EVFILT_USER, EV_ADD | EV_CLEAR, 0, 0, NULL); + if (kevent(kq, &ev, 1, NULL, 0, NULL) < 0) { + int saved = errno; + close(kq); + errno = saved; + return -1; + } + return kq; +} + +JNIEXPORT void JNICALL Java_io_questdb_client_network_Net_doorbellDrain + (JNIEnv *e, jclass cl, jint kq) { + // EV_CLEAR consumed the ring when the wait delivered it. +} + +JNIEXPORT jint JNICALL Java_io_questdb_client_network_Net_doorbellRing + (JNIEnv *e, jclass cl, jint kq) { + struct kevent ev; + EV_SET(&ev, 0, EVFILT_USER, 0, NOTE_TRIGGER, 0, NULL); + int n; + RESTARTABLE(kevent((int) kq, &ev, 1, NULL, 0, NULL), n); + return n < 0 ? -1 : 0; +} + +JNIEXPORT void JNICALL Java_io_questdb_client_network_Net_doorbellClose + (JNIEnv *e, jclass cl, jint kq) { + close((int) kq); +} + +JNIEXPORT jint JNICALL Java_io_questdb_client_network_Net_pollReadable + (JNIEnv *e, jclass cl, jint socketFd, jint kq, jint timeoutMillis) { + // Re-armed on every wait; a closed (reconnected) socket drops out of the + // kqueue by itself, so there is nothing to unregister. + struct kevent change; + EV_SET(&change, (uintptr_t) socketFd, EVFILT_READ, EV_ADD | EV_ONESHOT, 0, 0, NULL); + struct timespec ts; + struct timespec *pts = NULL; + if (timeoutMillis >= 0) { + ts.tv_sec = timeoutMillis / 1000; + ts.tv_nsec = (long) (timeoutMillis % 1000) * 1000000L; + pts = &ts; + } + struct kevent out[2]; + int n = kevent((int) kq, &change, 1, out, 2, pts); + return (n < 0 && errno == EINTR) ? 0 : n; +} + +#endif + JNIEXPORT jint JNICALL Java_io_questdb_client_network_Net_setMulticastInterface (JNIEnv *e, jclass cl, jint fd, jint ipv4address) { struct in_addr address; diff --git a/core/src/main/c/windows/net.c b/core/src/main/c/windows/net.c index 191295ab4..583174abf 100644 --- a/core/src/main/c/windows/net.c +++ b/core/src/main/c/windows/net.c @@ -325,6 +325,67 @@ JNIEXPORT jint JNICALL Java_io_questdb_client_network_Net_setTcpNoDelay return set_int_sockopt((SOCKET) fd, IPPROTO_TCP, TCP_NODELAY, noDelay); } +// The doorbell is a manual-reset kernel Event (kernel handles fit in 32 bits +// on 64-bit Windows). The wait is WSAEventSelect + WaitForMultipleObjects, +// because WSAPoll cannot wait on anything that is not a socket. +JNIEXPORT jint JNICALL Java_io_questdb_client_network_Net_doorbellCreate + (JNIEnv *e, jclass cl) { + HANDLE h = CreateEvent(NULL, TRUE, FALSE, NULL); + if (h == NULL) { + SaveLastError(); + return -1; + } + return (jint) (uintptr_t) h; +} + +JNIEXPORT void JNICALL Java_io_questdb_client_network_Net_doorbellDrain + (JNIEnv *e, jclass cl, jint doorbell) { + ResetEvent((HANDLE) (uintptr_t) (uint32_t) doorbell); +} + +JNIEXPORT jint JNICALL Java_io_questdb_client_network_Net_doorbellRing + (JNIEnv *e, jclass cl, jint doorbell) { + if (!SetEvent((HANDLE) (uintptr_t) (uint32_t) doorbell)) { + SaveLastError(); + return -1; + } + return 0; +} + +JNIEXPORT void JNICALL Java_io_questdb_client_network_Net_doorbellClose + (JNIEnv *e, jclass cl, jint doorbell) { + CloseHandle((HANDLE) (uintptr_t) (uint32_t) doorbell); +} + +JNIEXPORT jint JNICALL Java_io_questdb_client_network_Net_pollReadable + (JNIEnv *e, jclass cl, jint socketFd, jint doorbell, jint timeoutMillis) { + WSAEVENT sockEvent = WSACreateEvent(); + if (sockEvent == WSA_INVALID_EVENT) { + SaveLastError(); + return -1; + } + // FD_READ is recorded immediately when data is already pending, so this is + // a level-triggered readability wait. + if (WSAEventSelect((SOCKET) socketFd, sockEvent, FD_READ | FD_CLOSE) == SOCKET_ERROR) { + SaveLastError(); + WSACloseEvent(sockEvent); + return -1; + } + HANDLE handles[2] = {sockEvent, (HANDLE) (uintptr_t) (uint32_t) doorbell}; + DWORD r = WaitForMultipleObjects(2, handles, FALSE, timeoutMillis < 0 ? INFINITE : (DWORD) timeoutMillis); + // Detach the event; the socket stays non-blocking, which it already was. + WSAEventSelect((SOCKET) socketFd, NULL, 0); + WSACloseEvent(sockEvent); + if (r == WAIT_TIMEOUT) { + return 0; + } + if (r == WAIT_FAILED) { + SaveLastError(); + return -1; + } + return 1; +} + JNIEXPORT jint JNICALL Java_io_questdb_client_network_Net_socketUdp (JNIEnv *e, jclass cl) { SOCKET s = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); 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 c54889569..68b2c2d09 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 @@ -30,6 +30,7 @@ import io.questdb.client.cutlass.qwp.websocket.WebSocketFrameParser; import io.questdb.client.cutlass.qwp.websocket.WebSocketOpcode; import io.questdb.client.network.IOOperation; +import io.questdb.client.network.Net; import io.questdb.client.network.NetworkFacade; import io.questdb.client.network.Socket; import io.questdb.client.network.SocketFactory; @@ -105,6 +106,9 @@ public abstract class WebSocketClient implements QuietCloseable { // (not a bare volatile check-then-act) so concurrent closers cannot both // enter close() and double-run disconnect()/Unsafe.free. private final AtomicBoolean closed = new AtomicBoolean(); + // See awaitReadableOrWake/wake. wakeLock serialises wake() with close(). + private final Object wakeLock = new Object(); + private int doorbell = -1; // Upper bound (ms) on the TCP connect. <= 0 disables the application-level // timeout and falls back to the OS connect timeout. Seeded from the // configuration; the QWP sender may override it via setConnectTimeout(). @@ -207,7 +211,13 @@ public WebSocketClient(HttpClientConfiguration configuration, SocketFactory sock this.rnd = new SecureRnd(); this.upgraded = false; this.closed.set(false); + + this.doorbell = Net.doorbellCreate(); + if (doorbell < 0) { + throw new HttpClientException("could not create doorbell [errno=").put(nf.errno()).put(']'); + } } catch (Throwable t) { + closeDoorbell(); if (recvBufPtr != 0) { Unsafe.free(recvBufPtr, recvBufSize, MemoryTag.NATIVE_DEFAULT); recvBufPtr = 0; @@ -250,6 +260,7 @@ public void close() { Unsafe.free(recvBufPtr, recvBufSize, MemoryTag.NATIVE_DEFAULT); recvBufPtr = 0; } + closeDoorbell(); } } @@ -1472,4 +1483,44 @@ protected void dieWaiting(int n) { * Sets up platform-specific I/O wait mechanism after connection. */ protected abstract void setupIoWait(); + + /** + * Blocks until the socket becomes readable, another thread calls + * {@link #wake()}, or {@code timeoutMillis} elapses ({@code -1} = no + * timeout). Returns {@code true} unless it timed out; spurious returns are + * possible and callers re-check their inputs. The doorbell is stateful: a + * wake that lands before the wait begins ends it at once. Independent of + * the readiness queue {@link #ioWait} uses, so a pending wake never leaks + * into a handshake or send wait. + */ + public boolean awaitReadableOrWake(int timeoutMillis) { + int n = Net.pollReadable(socket.getFd(), doorbell, timeoutMillis); + if (n < 0) { + throw new HttpClientException("internal error: poll failure [errno=").put(nf.errno()).put(']'); + } + Net.doorbellDrain(doorbell); + return n > 0; + } + + /** + * Wakes a thread blocked in {@link #awaitReadableOrWake}; with nobody + * waiting the wake is remembered until the next wait. Safe from any thread, + * including concurrently with {@link #close()}. + */ + public void wake() { + synchronized (wakeLock) { + if (doorbell >= 0) { + Net.doorbellRing(doorbell); + } + } + } + + private void closeDoorbell() { + synchronized (wakeLock) { + if (doorbell >= 0) { + Net.doorbellClose(doorbell); + doorbell = -1; + } + } + } } diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java index 4da8464c4..b2cf52114 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java @@ -1522,14 +1522,26 @@ public boolean awaitAckedFsn(long targetFsn, long timeoutMillis) { return false; } long deadlineNanos = System.nanoTime() + timeoutMillis * 1_000_000L; - while (getAckedFsn() < targetFsn) { + while (true) { + // Read the watermark before the comparison: awaitAckProgress() returns + // at once if it has moved since, so no ack is slept through. + CursorSendEngine engine = cursorEngine; + long seen = engine == null ? -1L : engine.ackedFsn(); + if (getAckedFsn() >= targetFsn) { + return true; + } checkAwaitAckedFsnErrors(); - if (System.nanoTime() >= deadlineNanos) { + long now = System.nanoTime(); + if (now >= deadlineNanos) { return false; } - java.util.concurrent.locks.LockSupport.parkNanos(50_000L); + CursorWebSocketSendLoop loop = cursorSendLoop; + if (loop == null) { + java.util.concurrent.locks.LockSupport.parkNanos(this, deadlineNanos - now); + } else { + loop.awaitAckProgress(seen, deadlineNanos); + } } - return true; } /** @@ -4870,7 +4882,11 @@ private void drainOnClose(boolean errorOwnedByCustomHandler, boolean[] restoreIn long deadlineNanos = System.nanoTime() + closeFlushTimeoutMillis * 1_000_000L; restoreInterrupt[0] |= Thread.interrupted(); try { - while (cursorEngine.ackedFsn() < target) { + while (true) { + long seen = cursorEngine.ackedFsn(); + if (seen >= target) { + break; + } // PoolHousekeeper.stop() escalates to interrupt when this close runs past its join budget. A carried // interrupt makes parkNanos return immediately without clearing the flag, turning the remainder of a // close drain (up to 60s by default) into a full-core spin. Consume any later interrupt before the @@ -4909,7 +4925,7 @@ private void drainOnClose(boolean errorOwnedByCustomHandler, boolean[] restoreIn ? " pending batches; data may be lost (use larger closeFlushTimeoutMillis or smaller batches)" : " pending batches; the wire is not draining: " + outage.getMessage()); } - java.util.concurrent.locks.LockSupport.parkNanos(50_000L); + cursorSendLoop.awaitAckProgress(seen, deadlineNanos); } } finally { // Close the last-iteration race: an interrupt can land after the loop observes its final ACK but @@ -5024,7 +5040,7 @@ private void ensureConnected() { try { cursorSendLoop = new CursorWebSocketSendLoop( client, cursorEngine, - 0L, CursorWebSocketSendLoop.DEFAULT_PARK_NANOS, + 0L, reconnectFactory, reconnectInitialBackoffMillis, reconnectMaxBackoffMillis, diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/BackgroundDrainer.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/BackgroundDrainer.java index 0889d1ba2..320c83790 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/BackgroundDrainer.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/BackgroundDrainer.java @@ -1131,7 +1131,7 @@ public void run() { while (!stopRequested) { loop = new CursorWebSocketSendLoop( client, engine, - 0L, CursorWebSocketSendLoop.DEFAULT_PARK_NANOS, + 0L, clientFactory, reconnectInitialBackoffMillis, reconnectMaxBackoffMillis, diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorSendEngine.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorSendEngine.java index daf38b77f..899c62ae8 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorSendEngine.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorSendEngine.java @@ -108,6 +108,10 @@ public final class CursorSendEngine implements QuietCloseable { // many engines (one per Sender), the caller owns and closes it. private final boolean ownsManager; private final SegmentRing ring; + // Rings the I/O loop's doorbell after a frame is published, so the loop + // can block instead of polling publishedFsn(). + // Installed by CursorWebSocketSendLoop; null until a loop attaches. + private volatile Runnable ioWakeup; private final long segmentSizeBytes; private final String sfDir; private final long syncIntervalNanos; @@ -372,7 +376,7 @@ private CursorSendEngine(String sfDir, long segmentSizeBytes, SegmentManager man } if (ownsManager && manager == null) { manager = new SegmentManager( - segmentSizeBytes, SegmentManager.DEFAULT_POLL_NANOS, budget); + segmentSizeBytes, budget); } SlotLock acquiredLock = null; if (!memoryMode) { @@ -934,7 +938,7 @@ MmapSegment pinActiveSegment() { */ public long appendBlocking(long payloadAddr, int payloadLen) { long fsn = ring.appendOrFsn(payloadAddr, payloadLen); - if (fsn >= 0) return fsn; + if (fsn >= 0) return published(fsn); if (fsn == SegmentRing.PAYLOAD_TOO_LARGE) { throw new MmapSegmentException("payload too large for segment"); } @@ -958,7 +962,7 @@ public long appendBlocking(long payloadAddr, int payloadLen) { } LockSupport.parkNanos(50_000L); // 50 µs fsn = ring.appendOrFsn(payloadAddr, payloadLen); - if (fsn >= 0) return fsn; + if (fsn >= 0) return published(fsn); if (fsn == SegmentRing.PAYLOAD_TOO_LARGE) { throw new MmapSegmentException("payload too large for segment"); } @@ -978,7 +982,7 @@ public long appendBlocking(long payloadAddr, int payloadLen) { public long appendOrFsn(long payloadAddr, int payloadLen, long spinDeadlineNanos) { long fsn = ring.appendOrFsn(payloadAddr, payloadLen); if (fsn >= 0) { - return fsn; + return published(fsn); } if (fsn == SegmentRing.PAYLOAD_TOO_LARGE) { return fsn; @@ -989,13 +993,32 @@ public long appendOrFsn(long payloadAddr, int payloadLen, long spinDeadlineNanos while (System.nanoTime() < spinDeadlineNanos) { Compat.onSpinWait(); fsn = ring.appendOrFsn(payloadAddr, payloadLen); - if (fsn >= 0 || fsn == SegmentRing.PAYLOAD_TOO_LARGE) { + if (fsn >= 0) { + return published(fsn); + } + if (fsn == SegmentRing.PAYLOAD_TOO_LARGE) { return fsn; } } return SegmentRing.BACKPRESSURE_NO_SPARE; } + /** + * Callback run after every successful append; every producer path goes + * through the append methods, so this is the single wake point. + */ + public void setIoWakeup(Runnable wakeup) { + this.ioWakeup = wakeup; + } + + private long published(long fsn) { + Runnable w = ioWakeup; + if (w != null) { + w.run(); + } + return fsn; + } + public void checkDurability() { ring.checkDurability(); } diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoop.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoop.java index 7f04fa73c..295b69a7c 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoop.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoop.java @@ -61,13 +61,13 @@ /** * The cursor-engine I/O loop. Owns one I/O thread that: *
    - *
  1. Polls {@link CursorSendEngine#publishedFsn()} and walks newly-published - * frames from the engine's segments, sending each as one WebSocket - * binary frame to the server.
  2. - *
  3. Polls the WebSocket for server ACK frames; on each ACK with - * cumulative wire sequence {@code N}, calls - * {@code engine.acknowledge(fsnAtZero + N)} so the segment manager - * can trim fully-acked segments.
  4. + *
  5. Walks newly-published frames (past {@link CursorSendEngine#publishedFsn()}) + * from the engine's segments, sending each as one WebSocket binary frame + * to the server.
  6. + *
  7. Receives server ACK frames; on each ACK with cumulative wire sequence + * {@code N}, calls {@code engine.acknowledge(fsnAtZero + N)} so the + * segment manager can trim fully-acked segments, and unparks a producer + * waiting in {@link #awaitAckProgress}.
  8. *
  9. On wire failure, runs the configured reconnect policy: capped * exponential backoff with jitter, retried indefinitely (Invariant B -- * a store-and-forward drainer never gives up on a wall-clock budget). @@ -79,9 +79,14 @@ * has ever come up). On reconnect success, repositions the cursor at * {@code ackedFsn+1} and replays.
  10. *
- * No locks on the steady-state path. The producer thread (user) writes - * into the engine; this thread reads. {@code engine.publishedFsn()} is - * the volatile publish barrier. + * No locks and no polling on the steady-state path. The producer thread (user) + * writes into the engine; this thread reads. {@code engine.publishedFsn()} is + * the volatile publish barrier. With nothing to send and nothing on the wire + * the thread blocks in one poll(2) on the socket and a doorbell + * ({@link WebSocketClient#awaitReadableOrWake}); the engine rings the + * doorbell when it publishes while the loop is parked ({@link CursorSendEngine#setIoWakeup}), + * inbound bytes end the wait on their own, and the only timers are the PING + * deadlines. An idle connection therefore costs no CPU. *

* Errors are reported via {@link #getTerminalError()}; the I/O thread sets it * and exits. Producers polling {@link #checkError()} surface the failure. @@ -148,7 +153,8 @@ public final class CursorWebSocketSendLoop implements QuietCloseable { * disables it. Opt-in: off by default, {@code 30000} suits an AWS ALB. */ public static final long DEFAULT_KEEPALIVE_INTERVAL_MILLIS = 0L; - public static final long DEFAULT_PARK_NANOS = 50_000L; // 50us idle backoff + // Connect-gate and close-race sleeps only; the idle path blocks in awaitIdle(). + private static final long PARK_NANOS = 50_000L; /** * Default initial reconnect backoff (100 ms). */ @@ -307,7 +313,6 @@ public final class CursorWebSocketSendLoop implements QuietCloseable { private final long catchUpCapGapMinEscalationWindowNanos; private final CatchUpCapGapPolicy catchUpCapGapPolicy; private final CursorSendEngine engine; - private final long parkNanos; private final ReconnectPolicy reconnectPolicy; // FIFO of OK-acked batches awaiting durable-upload confirmation. Used only // when durableAckMode is true. Each entry binds a wireSeq to the per-table @@ -487,6 +492,22 @@ public final class CursorWebSocketSendLoop implements QuietCloseable { // is why a revoked token surfaced to operators as "sf_max_total_bytes too small". private volatile Throwable lastReconnectError; private volatile Thread ioThread; + // The one producer thread (Sender is single-threaded) parked in + // awaitAckProgress(), or null. The I/O thread unparks it on every ack + // advance and on a terminal latch, so producers never poll ackedFsn. + private volatile Thread ackWaiter; + // True while the I/O thread is committed to blocking in awaitIdle(). The + // engine's publish hook rings the doorbell only when this is set, so a busy + // loop costs the producer no syscall per flush. Dekker-ordered with the + // publishedFsn re-check in ioLoop: the I/O thread sets the flag BEFORE its + // final publishedFsn read, the producer publishes BEFORE reading the flag, + // so a publish that misses the flag is seen by the re-check and vice versa. + private volatile boolean ioParked; + // I/O-thread-only. Set by swapClient(): a reconnect rewinds the send cursor + // to ackedFsn+1, which creates sendable work from inside the I/O thread -- + // no producer publish and no inbound byte will announce it. The loop treats + // the recycle as work done, so it never blocks on a cursor it just rewound. + private boolean recycledThisIteration; // Typed marker for a ROTATING-credential auth terminal (401/403): set (before the // terminalError latch, so a checkError() caller that observes the latch is guaranteed to // observe this marker too) when a mid-drain reconnect sweep on an ORPHAN drainer whose @@ -646,11 +667,11 @@ public final class CursorWebSocketSendLoop implements QuietCloseable { * performs its fail-fast policy before constructing this loop. */ public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine, - long fsnAtZero, long parkNanos, + long fsnAtZero, ReconnectFactory reconnectFactory, long reconnectInitialBackoffMillis, long reconnectMaxBackoffMillis) { - this(client, engine, fsnAtZero, parkNanos, reconnectFactory, + this(client, engine, fsnAtZero, reconnectFactory, reconnectInitialBackoffMillis, reconnectMaxBackoffMillis, false); } @@ -665,12 +686,12 @@ public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine, * a server should not emit them when the client did not opt in). */ public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine, - long fsnAtZero, long parkNanos, + long fsnAtZero, ReconnectFactory reconnectFactory, long reconnectInitialBackoffMillis, long reconnectMaxBackoffMillis, boolean durableAckMode) { - this(client, engine, fsnAtZero, parkNanos, reconnectFactory, + this(client, engine, fsnAtZero, reconnectFactory, reconnectInitialBackoffMillis, reconnectMaxBackoffMillis, durableAckMode, DEFAULT_DURABLE_ACK_KEEPALIVE_INTERVAL_MILLIS); @@ -684,13 +705,13 @@ public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine, * data, or by accepting indefinite waits on idle connections). */ public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine, - long fsnAtZero, long parkNanos, + long fsnAtZero, ReconnectFactory reconnectFactory, long reconnectInitialBackoffMillis, long reconnectMaxBackoffMillis, boolean durableAckMode, long durableAckKeepaliveIntervalMillis) { - this(client, engine, fsnAtZero, parkNanos, reconnectFactory, + this(client, engine, fsnAtZero, reconnectFactory, reconnectInitialBackoffMillis, reconnectMaxBackoffMillis, durableAckMode, durableAckKeepaliveIntervalMillis, DEFAULT_MAX_HEAD_FRAME_REJECTIONS); @@ -706,14 +727,14 @@ public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine, * strike-count escalation. */ public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine, - long fsnAtZero, long parkNanos, + long fsnAtZero, ReconnectFactory reconnectFactory, long reconnectInitialBackoffMillis, long reconnectMaxBackoffMillis, boolean durableAckMode, long durableAckKeepaliveIntervalMillis, int maxHeadFrameRejections) { - this(client, engine, fsnAtZero, parkNanos, reconnectFactory, + this(client, engine, fsnAtZero, reconnectFactory, reconnectInitialBackoffMillis, reconnectMaxBackoffMillis, durableAckMode, durableAckKeepaliveIntervalMillis, maxHeadFrameRejections, 0L); @@ -726,7 +747,7 @@ public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine, * the policy-aware master constructor. */ public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine, - long fsnAtZero, long parkNanos, + long fsnAtZero, ReconnectFactory reconnectFactory, long reconnectInitialBackoffMillis, long reconnectMaxBackoffMillis, @@ -734,7 +755,7 @@ public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine, long durableAckKeepaliveIntervalMillis, int maxHeadFrameRejections, long poisonMinEscalationWindowMillis) { - this(client, engine, fsnAtZero, parkNanos, reconnectFactory, + this(client, engine, fsnAtZero, reconnectFactory, reconnectInitialBackoffMillis, reconnectMaxBackoffMillis, durableAckMode, durableAckKeepaliveIntervalMillis, maxHeadFrameRejections, @@ -759,7 +780,7 @@ public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine, * {@link CatchUpCapGapPolicy#TERMINAL_AFTER_SETTLE_BUDGET}. */ public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine, - long fsnAtZero, long parkNanos, + long fsnAtZero, ReconnectFactory reconnectFactory, long reconnectInitialBackoffMillis, long reconnectMaxBackoffMillis, @@ -768,7 +789,7 @@ public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine, int maxHeadFrameRejections, long poisonMinEscalationWindowMillis, long catchUpCapGapMinEscalationWindowMillis) { - this(client, engine, fsnAtZero, parkNanos, reconnectFactory, + this(client, engine, fsnAtZero, reconnectFactory, reconnectInitialBackoffMillis, reconnectMaxBackoffMillis, durableAckMode, durableAckKeepaliveIntervalMillis, maxHeadFrameRejections, @@ -782,7 +803,7 @@ public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine, * and maps it onto the {@link CatchUpCapGapPolicy} this constructor takes. */ private CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine, - long fsnAtZero, long parkNanos, + long fsnAtZero, ReconnectFactory reconnectFactory, long reconnectInitialBackoffMillis, long reconnectMaxBackoffMillis, @@ -945,7 +966,6 @@ private CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine, // (ensureSentDictCapacity), and releaseSentDictBytes frees only what the loop owns. this.fsnAtZero = fsnAtZero; this.externalFsnBase = externalFsnBase; - this.parkNanos = parkNanos; this.reconnectFactory = reconnectFactory; this.reconnectInitialBackoffMillis = reconnectInitialBackoffMillis; this.reconnectMaxBackoffMillis = reconnectMaxBackoffMillis; @@ -988,7 +1008,7 @@ private CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine, * jar keep linking. */ public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine, - long fsnAtZero, long parkNanos, + long fsnAtZero, ReconnectFactory reconnectFactory, long reconnectInitialBackoffMillis, long reconnectMaxBackoffMillis, @@ -998,7 +1018,7 @@ public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine, long poisonMinEscalationWindowMillis, long catchUpCapGapMinEscalationWindowMillis, ReconnectPolicy reconnectPolicy) { - this(client, engine, fsnAtZero, parkNanos, reconnectFactory, + this(client, engine, fsnAtZero, reconnectFactory, reconnectInitialBackoffMillis, reconnectMaxBackoffMillis, durableAckMode, durableAckKeepaliveIntervalMillis, maxHeadFrameRejections, @@ -1018,7 +1038,7 @@ public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine, * unless the caller is replacing an engine a symbol-dict recycle rebuilt. */ public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine, - long fsnAtZero, long parkNanos, + long fsnAtZero, ReconnectFactory reconnectFactory, long reconnectInitialBackoffMillis, long reconnectMaxBackoffMillis, @@ -1029,7 +1049,7 @@ public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine, long catchUpCapGapMinEscalationWindowMillis, ReconnectPolicy reconnectPolicy, long externalFsnBase) { - this(client, engine, fsnAtZero, parkNanos, reconnectFactory, + this(client, engine, fsnAtZero, reconnectFactory, reconnectInitialBackoffMillis, reconnectMaxBackoffMillis, durableAckMode, durableAckKeepaliveIntervalMillis, maxHeadFrameRejections, @@ -1366,8 +1386,15 @@ public synchronized void close() { // that free never happens and a seeded (recovery / orphan-drain) mirror // would leak. Capture that here; free it below, after client teardown. boolean loopNeverRan = t == null; + // The engine's wake hook is deliberately left in place: a replacement + // loop over the same engine (connection recycle) may already have + // installed its own, and a stale hook into a closed loop is a no-op + // (wakeIo() finds a null or closed client). if (t != null) { LockSupport.unpark(t); + // The I/O thread may be blocked in the transport wait, not a park. + wakeIo(); + signalAckProgress(); // Only await the shutdown latch if the I/O thread actually ran. // If start() failed after assigning ioThread but before t.start() // succeeded (e.g. native stack OOM), ioLoop never ran and its @@ -1839,6 +1866,7 @@ public synchronized void start() { } Thread t = new Thread(this::ioLoop, "qdb-cursor-ws-io"); t.setDaemon(true); + engine.setIoWakeup(this::wakeIoIfParked); try { t.start(); } catch (Throwable th) { @@ -1976,7 +2004,7 @@ private void connectLoop(Throwable initial, String phase, long paceFirstAttemptM if (connectGate == CONNECT_GATE_STOP_CLAIMED) { // close() is running or imminent and unparks this thread. while (running) { - LockSupport.parkNanos(parkNanos); + LockSupport.parkNanos(PARK_NANOS); } return; } @@ -2489,12 +2517,45 @@ private void haltOnPoisonedFrame(String lastRejection, long toFsnHint) { * {@link #externalFsnBase} before it reaches the user-visible dispatcher. */ private void dispatchProgress(long ackedFsn) { + signalAckProgress(); SenderProgressDispatcher d = progressDispatcher; if (d != null) { d.offer(externalFsnBase + ackedFsn); } } + private void signalAckProgress() { + Thread w = ackWaiter; + if (w != null) { + LockSupport.unpark(w); + } + } + + /** + * Parks the calling producer thread until the engine's acked FSN moves past + * {@code seenAckedFsn}, a terminal error is latched, the loop stops, or + * {@code deadlineNanos} passes -- whichever comes first. Spurious returns + * are possible; callers re-check and call again. {@code seenAckedFsn} must + * be a value the caller read before deciding to wait, so an ack + * landing in between is detected by the re-check below rather than lost. + */ + public void awaitAckProgress(long seenAckedFsn, long deadlineNanos) { + ackWaiter = Thread.currentThread(); + try { + if (engine.ackedFsn() != seenAckedFsn || terminalError != null) { + return; + } + // A stopped loop (close() raced us) has already unparked any earlier + // waiter; a later one simply waits out its own deadline rather than spin. + long remaining = deadlineNanos - System.nanoTime(); + if (remaining > 0L) { + LockSupport.parkNanos(this, remaining); + } + } finally { + ackWaiter = null; + } + } + /** * Pop every head entry whose tables are all covered by the durable * watermarks and call {@link CursorSendEngine#acknowledge} once with @@ -2663,12 +2724,22 @@ private void ioLoop() { // keepalive clock here rather than pinging on the first idle pass. lastFrameOrPingNanos = System.nanoTime(); while (running) { + // Snapshot before the send attempt: a publish after this point is + // caught by the re-check below, so the idle decision cannot race it. + long publishedBeforeWork = engine.publishedFsn(); boolean didWork = trySendOne(); // 1. Try to send next frame(s). // 2. Try to receive ACKs. if (tryReceiveAcks()) { didWork = true; } + // A reconnect anywhere in this iteration (inside trySendOne, + // tryReceiveAcks or a previous awaitIdle) repositioned the cursor: + // go straight back to trySendOne instead of blocking. + if (recycledThisIteration) { + recycledThisIteration = false; + didWork = true; + } // 3. In durable-ack mode, prod the server with a keepalive // PING when there are pending OKs awaiting confirmation // and we have nothing else to do. The server only flushes @@ -2685,8 +2756,19 @@ private void ioLoop() { if (!didWork && running && keepaliveIntervalNanos > 0L) { sendPingIfDue(keepaliveIntervalNanos); } + // 5. Nothing to do: block until the socket has bytes (an ACK), + // a producer publishes a frame (engine rings the doorbell via + // setIoWakeup), close() rings it, or the next PING is due. + // No spinning: an idle connection costs zero CPU. if (!didWork && running) { - LockSupport.parkNanos(parkNanos); + ioParked = true; + try { + if (engine.publishedFsn() == publishedBeforeWork) { + awaitIdle(); + } + } finally { + ioParked = false; + } } } } catch (Throwable t) { @@ -2848,6 +2930,8 @@ private void recordFatal(Throwable t) { terminalError = t instanceof LineSenderException ? (LineSenderException) t : new LineSenderException("I/O thread failed: " + t.getMessage(), t); + // A producer waiting for acks must see the terminal now, not at its deadline. + signalAckProgress(); // Tell the async dispatcher which SenderError IS the terminal, so // close() can distinguish "the custom handler owns THIS terminal" // from "the custom handler saw some earlier DROP_AND_CONTINUE". @@ -2890,6 +2974,74 @@ private void releaseSendingSegment() { } } + /** + * Blocks the I/O thread until there is something to do: socket readable, + * doorbell rung, or the earliest PING deadline (none = no timeout). The + * doorbell is stateful, so a wake that landed before this call returns + * immediately. + */ + private void awaitIdle() { + WebSocketClient c = client; + if (c == null) { + // close() racing the final iterations: running is already false. + LockSupport.parkNanos(PARK_NANOS); + return; + } + try { + c.awaitReadableOrWake(idleTimeoutMillis()); + } catch (Throwable t) { + fail(t); + } + } + + /** + * Milliseconds until the next PING is due, or -1 when no PING is + * configured (or none is pending), i.e. wait without a timeout. + */ + private int idleTimeoutMillis() { + long sinceLastNanos = System.nanoTime() - lastFrameOrPingNanos; + long remainingNanos = Long.MAX_VALUE; + if (keepaliveIntervalNanos > 0L) { + remainingNanos = keepaliveIntervalNanos - sinceLastNanos; + } + if (durableAckMode && durableAckKeepaliveIntervalNanos > 0L && !pendingDurable.isEmpty()) { + remainingNanos = Math.min(remainingNanos, durableAckKeepaliveIntervalNanos - sinceLastNanos); + } + if (remainingNanos == Long.MAX_VALUE) { + return -1; + } + if (remainingNanos <= 0L) { + return 0; + } + // Round up so a due PING is never missed by a sub-millisecond early wake. + long millis = (remainingNanos + 999_999L) / 1_000_000L; + return (int) Math.min(Integer.MAX_VALUE, millis); + } + + /** + * Rings the current transport's doorbell so a blocked {@link #awaitIdle} + * returns. Called by the engine after every publish and by close(). + * Safe from any thread; a wake aimed at a connection being swapped is + * harmless, because the loop re-reads the engine on every iteration. + */ + public void wakeIo() { + WebSocketClient c = client; + if (c != null) { + c.wake(); + } + } + + /** + * The engine's publish hook: rings the doorbell only when the I/O thread is + * parked (or about to park), see {@link #ioParked}. A busy loop picks the + * frame up on its next iteration without any syscall from the producer. + */ + private void wakeIoIfParked() { + if (ioParked) { + wakeIo(); + } + } + /** * Send a WebSocket PING, but only when {@code intervalNanos} has elapsed * since the last outbound activity -- a sent frame or a prior PING. @@ -2945,6 +3097,7 @@ private void snapshotReplayTarget() { private void swapClient(WebSocketClient newClient) { WebSocketClient old = this.client; this.client = newClient; + recycledThisIteration = true; this.lastReconnectError = null; if (old != null) { try { diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentManager.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentManager.java index 87cda92c1..3a0139c74 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentManager.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentManager.java @@ -56,7 +56,13 @@ */ public final class SegmentManager implements QuietCloseable { - public static final long DEFAULT_POLL_NANOS = 1_000_000L; // 1 ms + /** + * Retry cadence after a failed hot-spare provisioning attempt (disk full, + * budget exhausted, I/O error). Nothing else wakes the worker for that + * ring until its next rotation or ACK, so the retry needs its own timer. + * A wake from SegmentBudget.release() would make it fully event-driven. + */ + public static final long PROVISION_RETRY_NANOS = 10_000_000L; // 10 ms public static final long DISK_FULL_LOG_THROTTLE_NANOS = 30_000_000_000L; // 30 s public static final long UNLIMITED_TOTAL_BYTES = Long.MAX_VALUE; private static final int ENTRY_DEREGISTERED = 3; @@ -87,6 +93,10 @@ public final class SegmentManager implements QuietCloseable { // budget to several managers -- a sender pool does, so sf_max_total_bytes // caps all of its senders together. private final SegmentBudget budget; + // Worker-local: when a hot-spare provisioning attempt fails (disk full, budget + // exhausted, I/O error) the worker schedules its own retry here instead of + // relying on a tick. Reset every pass; Long.MAX_VALUE = nothing to retry. + private long provisionRetryDeadlineNanos = Long.MAX_VALUE; private final AtomicLong fileGeneration = new AtomicLong(); private final FilesFacade filesFacade; // Per-ring segment bytes below which the cap check never refuses to @@ -114,7 +124,6 @@ public final class SegmentManager implements QuietCloseable { // otherwise allocate per call. Sized for typical SF directory paths; // grows on demand if a longer path is registered. Closed in close(). private final DirectUtf8Sink pathScratch = new DirectUtf8Sink(256); - private final long pollNanos; // Reused by the worker thread each tick to snapshot `rings` under the // lock without per-tick allocation. Owned exclusively by workerLoop(). private final ObjList ringSnapshot = new ObjList<>(); @@ -193,7 +202,6 @@ public final class SegmentManager implements QuietCloseable { // returns. private boolean scratchFreed; private boolean scratchHandedToWorker; - private volatile long shortestSyncIntervalNanos = Long.MAX_VALUE; private boolean workerLoopExited; // volatile: read by awaitRingQuiescence() from arbitrary caller threads // while the @TestOnly setter may run on another. @@ -204,19 +212,13 @@ public final class SegmentManager implements QuietCloseable { private volatile Thread workerThread; public SegmentManager(long segmentSizeBytes) { - this(segmentSizeBytes, DEFAULT_POLL_NANOS, UNLIMITED_TOTAL_BYTES); - } - - public SegmentManager(long segmentSizeBytes, long pollNanos) { - this(segmentSizeBytes, pollNanos, UNLIMITED_TOTAL_BYTES); + this(segmentSizeBytes, UNLIMITED_TOTAL_BYTES); } /** * Full constructor. * * @param segmentSizeBytes per-segment file size in bytes - * @param pollNanos how often the worker polls each registered ring; - * default {@link #DEFAULT_POLL_NANOS} * @param maxTotalBytes upper bound on total bytes the manager tracks * across all registered rings — counts every segment * the ring owns (initial active + sealed + hot @@ -233,40 +235,38 @@ public SegmentManager(long segmentSizeBytes, long pollNanos) { * {@code 2 × segmentSizeBytes} so the manager can * hold an initial active plus one hot spare. */ - public SegmentManager(long segmentSizeBytes, long pollNanos, long maxTotalBytes) { - this(segmentSizeBytes, pollNanos, new SegmentBudget(maxTotalBytes), FilesFacade.INSTANCE, System::nanoTime); + public SegmentManager(long segmentSizeBytes, long maxTotalBytes) { + this(segmentSizeBytes, new SegmentBudget(maxTotalBytes), FilesFacade.INSTANCE, System::nanoTime); } /** - * As {@link #SegmentManager(long, long, long)}, but charging + * As {@link #SegmentManager(long, long)}, but charging * {@code budget}, which other managers may share: its capacity then caps * the rings of all of them together. The capacity must allow at least one * {@code segmentSizeBytes}. */ - public SegmentManager(long segmentSizeBytes, long pollNanos, SegmentBudget budget) { - this(segmentSizeBytes, pollNanos, budget, FilesFacade.INSTANCE, System::nanoTime); + public SegmentManager(long segmentSizeBytes, SegmentBudget budget) { + this(segmentSizeBytes, budget, FilesFacade.INSTANCE, System::nanoTime); } @TestOnly - public SegmentManager(long segmentSizeBytes, long pollNanos, long maxTotalBytes, FilesFacade filesFacade) { - this(segmentSizeBytes, pollNanos, new SegmentBudget(maxTotalBytes), filesFacade, System::nanoTime); + public SegmentManager(long segmentSizeBytes, long maxTotalBytes, FilesFacade filesFacade) { + this(segmentSizeBytes, new SegmentBudget(maxTotalBytes), filesFacade, System::nanoTime); } @TestOnly public SegmentManager( long segmentSizeBytes, - long pollNanos, long maxTotalBytes, FilesFacade filesFacade, LongSupplier ticks ) { - this(segmentSizeBytes, pollNanos, new SegmentBudget(maxTotalBytes), filesFacade, ticks); + this(segmentSizeBytes, new SegmentBudget(maxTotalBytes), filesFacade, ticks); } @TestOnly public SegmentManager( long segmentSizeBytes, - long pollNanos, SegmentBudget budget, FilesFacade filesFacade, LongSupplier ticks @@ -292,7 +292,6 @@ public SegmentManager( this.budget = budget; this.filesFacade = filesFacade; this.segmentSizeBytes = segmentSizeBytes; - this.pollNanos = pollNanos; // Clamp rather than multiply blind: segmentSizeBytes is user-supplied // and only bounded below, so a pathological value would wrap the // product negative and make the floor test trivially false -- silently @@ -628,9 +627,9 @@ public void deregister(SegmentRing ring) { * MUST already have its initial active segment in place. *

* Also wires the ring's "I need a spare" wakeup callback to - * {@link #wakeWorker()}, so the producer thread can preempt the polling - * tick the moment a rotation consumes the spare or the active crosses - * the high-water mark — no waiting on the next tick. + * {@link #wakeWorker()}, so the producer thread wakes the parked worker + * the moment a rotation consumes the spare or the active crosses the + * high-water mark. */ public void register(SegmentRing ring, String dir) { register(ring, dir, null, 0L); @@ -713,9 +712,6 @@ public void register(SegmentRing ring, String dir, AckWatermark watermark, long budget.charge(ringBytes); if (syncIntervalNanos > 0L) { ring.enablePeriodicSync(); - if (syncIntervalNanos < shortestSyncIntervalNanos) { - shortestSyncIntervalNanos = syncIntervalNanos; - } } } ring.setManagerWakeup(managerWakeup); @@ -726,7 +722,7 @@ public void register(SegmentRing ring, String dir, AckWatermark watermark, long // an empty `rings` snapshot, services nothing, then parkNanos // (potentially seconds). A new ring whose first append does not // cross the high-water mark fires no producer-side wakeup either, - // leaving the ring without a spare for the full poll interval. + // leaving the ring without a spare until some later event. // wakeWorker is cheap (a single LockSupport.unpark) and a no-op // when the worker has not been started yet. wakeWorker(); @@ -827,7 +823,7 @@ public synchronized void start() { } /** - * Unparks the worker thread out of its poll-park so it processes + * Unparks the worker thread out of its idle park so it processes * registered rings on the very next loop iteration. Cheap — a single * {@code LockSupport.unpark}; safe to call from any thread; idempotent * (multiple unparks coalesce into a single permit). No-op if the worker @@ -960,6 +956,13 @@ private boolean serviceRing(RingEntry e) { return hasMoreTrimmable; } + private void scheduleProvisionRetry() { + long at = ticks.getAsLong() + PROVISION_RETRY_NANOS; + if (at < provisionRetryDeadlineNanos) { + provisionRetryDeadlineNanos = at; + } + } + private boolean serviceRing0(RingEntry e) { boolean memoryMode = e.dir == null; if (!memoryMode) { @@ -1006,6 +1009,7 @@ private boolean serviceRing0(RingEntry e) { sideFileBytes, budget.getCapacityBytes(), segmentSizeBytes); lastDiskFullLogNs = now; } + scheduleProvisionRetry(); } else { if (belowLivenessFloor) { // Exceeding a configured cap is worth saying out loud, and @@ -1094,6 +1098,7 @@ private boolean serviceRing0(RingEntry e) { memoryMode ? "" : e.dir, t); } if (!installed) { + scheduleProvisionRetry(); budget.release(segmentSizeBytes); if (spare != null) { try { @@ -1392,6 +1397,7 @@ private void workerLoop() { // (potentially slow) syscalls during creation/unlink. ringSnapshot // is a thread-confined field — no per-tick allocation. ringSnapshot.clear(); + provisionRetryDeadlineNanos = Long.MAX_VALUE; synchronized (lock) { for (int i = 0, n = rings.size(); i < n; i++) { ringSnapshot.add(rings.getQuick(i)); @@ -1404,13 +1410,35 @@ private void workerLoop() { hasMoreTrimmable = true; } } + // Event-driven idle: every state change the worker services + // wakes it (register/deregister, rotation, high-water publish, + // an ACK that completes a sealed segment), so with nothing + // trimmable the only timed work left is periodic sync. Park + // until the nearest sync deadline, or indefinitely without one. + long parkNanos = Long.MAX_VALUE; + if (!hasMoreTrimmable) { + long now = ticks.getAsLong(); + if (provisionRetryDeadlineNanos != Long.MAX_VALUE) { + parkNanos = Math.max(0L, provisionRetryDeadlineNanos - now); + } + for (int i = 0, n = ringSnapshot.size(); i < n; i++) { + RingEntry e = ringSnapshot.getQuick(i); + if (e.syncIntervalNanos > 0L) { + parkNanos = Math.min(parkNanos, Math.max(0L, e.nextDataSyncNanos - now)); + } + } + } // Drop strong refs so a deregistered ring becomes collectable - // before the next tick (otherwise the snapshot pins it for up - // to pollNanos after deregister). + // before the next tick (otherwise the snapshot pins it until + // the next wakeup). ringSnapshot.clear(); if (!running) break; if (!hasMoreTrimmable) { - LockSupport.parkNanos(Math.min(pollNanos, shortestSyncIntervalNanos)); + if (parkNanos == Long.MAX_VALUE) { + LockSupport.park(this); + } else if (parkNanos > 0L) { + LockSupport.parkNanos(this, parkNanos); + } } } } finally { diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentRing.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentRing.java index d18589e91..3cca230ce 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentRing.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentRing.java @@ -993,6 +993,11 @@ public boolean acknowledge(long seq) { } if (seq > ackedFsn) { ackedFsn = seq; + // Trim is ACK-driven: the manager no longer polls for it, so tell it + // when this ACK completes the oldest sealed segment. + if (firstTrimmable() != null) { + wakeManager(); + } return true; } return false; diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SenderConnectionDispatcher.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SenderConnectionDispatcher.java index 541f7dcf2..e3737f812 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SenderConnectionDispatcher.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SenderConnectionDispatcher.java @@ -32,7 +32,6 @@ import org.slf4j.LoggerFactory; import java.util.concurrent.LinkedBlockingDeque; -import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicLong; /** @@ -199,7 +198,7 @@ private void dispatchLoop() { while (!closed || !inbox.isEmpty()) { SenderConnectionEvent ev; try { - ev = inbox.poll(100, TimeUnit.MILLISECONDS); + ev = inbox.take(); // close() enqueues POISON, so blocking here cannot wedge shutdown } catch (InterruptedException e) { if (closed) { return; diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SenderErrorDispatcher.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SenderErrorDispatcher.java index fb796712a..37bfd3d83 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SenderErrorDispatcher.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SenderErrorDispatcher.java @@ -32,7 +32,6 @@ import org.slf4j.LoggerFactory; import java.util.concurrent.LinkedBlockingDeque; -import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicLong; @@ -310,7 +309,7 @@ private void dispatchLoop() { while (!closed || !inbox.isEmpty()) { SenderError err; try { - err = inbox.poll(100, TimeUnit.MILLISECONDS); + err = inbox.take(); // close() enqueues POISON, so blocking here cannot wedge shutdown } catch (InterruptedException e) { if (closed) { return; diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SenderProgressDispatcher.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SenderProgressDispatcher.java index 9e01a630d..517215415 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SenderProgressDispatcher.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SenderProgressDispatcher.java @@ -70,9 +70,6 @@ public final class SenderProgressDispatcher implements QuietCloseable { public static final int DEFAULT_CAPACITY = 256; private static final long DRAIN_DEADLINE_NANOS = 100_000_000L; // 100 ms - // Park interval used when the mailbox is idle. Bounded so a missed unpark - // (e.g. from a future code path) cannot wedge the dispatcher. - private static final long IDLE_PARK_NANOS = 100_000_000L; // 100 ms private static final Logger LOG = LoggerFactory.getLogger(SenderProgressDispatcher.class); // Sentinel for "no value ever offered". Real FSNs are non-negative, so // Long.MIN_VALUE is unambiguous and the dispatcher's "deliver if greater @@ -237,9 +234,11 @@ private void dispatchLoop() { if (closed) { return; } - // No new value; park until the producer unparks us or the idle - // tick fires. Spurious wakeups loop back to the top and re-check. - LockSupport.parkNanos(this, IDLE_PARK_NANOS); + // No new value; park until offer() or close() unparks us. Both write + // their state (latestFsn / closed) before the unpark and this loop + // re-reads it before parking, so no wake can be lost. + // Spurious wakeups loop back to the top and re-check. + LockSupport.park(this); } } diff --git a/core/src/main/java/io/questdb/client/network/Net.java b/core/src/main/java/io/questdb/client/network/Net.java index b9e669d0c..11c5709be 100644 --- a/core/src/main/java/io/questdb/client/network/Net.java +++ b/core/src/main/java/io/questdb/client/network/Net.java @@ -164,6 +164,34 @@ public static long sockaddr(int ipv4address, int port) { public static native int socketUdp(); + /** + * Doorbell for a thread blocked in {@link #pollReadable}: an {@code eventfd} + * on Linux, a kqueue {@code EVFILT_USER} on macOS/FreeBSD, a manual-reset + * Event on Windows. Returns a non-negative handle, or -1 on failure (errno + * set). Release with {@link #doorbellClose}, never with a socket close. + */ + public static native int doorbellCreate(); + + /** + * Clears every pending ring (non-blocking). + */ + public static native void doorbellDrain(int doorbell); + + /** + * Rings the doorbell; a repeat ring is a no-op. Returns 0, or -1 on failure. + */ + public static native int doorbellRing(int doorbell); + + public static native void doorbellClose(int doorbell); + + /** + * Blocks until {@code socketFd} is readable (or hung up), {@code doorbell} + * rings, or {@code timeoutMillis} elapses (negative = no timeout). Returns + * positive when ready, 0 on timeout or signal, -1 on error (errno set). + * Callers re-check their inputs after every return. + */ + public static native int pollReadable(int socketFd, int doorbell, int timeoutMillis); + private static native void freeAddrInfo0(long pAddrInfo); private static native void freeSockAddr0(long sockaddr); diff --git a/core/src/test/java/io/questdb/client/test/cutlass/http/client/WebSocketClientDoorbellTest.java b/core/src/test/java/io/questdb/client/test/cutlass/http/client/WebSocketClientDoorbellTest.java new file mode 100644 index 000000000..5ffebb2d6 --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/http/client/WebSocketClientDoorbellTest.java @@ -0,0 +1,100 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +package io.questdb.client.test.cutlass.http.client; + +import io.questdb.client.cutlass.http.client.WebSocketClient; +import io.questdb.client.cutlass.http.client.WebSocketClientFactory; +import io.questdb.client.test.cutlass.qwp.websocket.TestWebSocketServer; +import io.questdb.client.test.tools.TestUtils; +import org.junit.Test; + +import java.util.concurrent.TimeUnit; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * The platform transport's idle wait against a real socket: it blocks for the + * requested time, a cross-thread {@link WebSocketClient#wake()} ends it at + * once, a wake that lands before the wait is not lost, a drained wake does not + * linger, and inbound bytes end it too. Exercises whichever doorbell the build + * platform uses (eventfd, kqueue EVFILT_USER or a Windows Event). + */ +public class WebSocketClientDoorbellTest { + + @Test + public void testDoorbell() throws Exception { + TestUtils.assertMemoryLeak(() -> { + try (TestWebSocketServer server = new TestWebSocketServer(new TestWebSocketServer.WebSocketServerHandler() { + })) { + server.start(); + assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); + try (WebSocketClient client = WebSocketClientFactory.newPlainTextInstance()) { + client.connect("127.0.0.1", server.getPort()); + client.upgrade("/write/v4", null); + + // 1. Nothing happens: the wait runs to its timeout. + long t0 = System.nanoTime(); + assertFalse(client.awaitReadableOrWake(150)); + long waited = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - t0); + assertTrue("wait returned early after " + waited + " ms", waited >= 120); + + // 2. A wake from another thread ends a long wait promptly. + Thread ringer = new Thread(() -> { + try { + Thread.sleep(100); + } catch (InterruptedException ignored) { + } + client.wake(); + }, "ringer"); + ringer.start(); + t0 = System.nanoTime(); + assertTrue(client.awaitReadableOrWake(5_000)); + waited = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - t0); + ringer.join(); + assertTrue("wake took " + waited + " ms", waited >= 80 && waited < 2_000); + + // 3. A wake before the wait is remembered (stateful doorbell). + client.wake(); + t0 = System.nanoTime(); + assertTrue(client.awaitReadableOrWake(5_000)); + waited = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - t0); + assertTrue("pre-rung wait took " + waited + " ms", waited < 500); + + // 4. ...and once consumed it does not linger. + assertFalse(client.awaitReadableOrWake(100)); + + // 5. Inbound bytes end the wait: the server answers a PING with a PONG. + client.sendPing(); + assertTrue("socket readability did not end the wait", client.awaitReadableOrWake(5_000)); + + // 6. wake() on a closed client is a safe no-op. + client.close(); + client.wake(); + } + } + }); + } +} diff --git a/core/src/test/java/io/questdb/client/test/cutlass/http/client/WebSocketClientTest.java b/core/src/test/java/io/questdb/client/test/cutlass/http/client/WebSocketClientTest.java index f1d690b99..d03358864 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/http/client/WebSocketClientTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/http/client/WebSocketClientTest.java @@ -24,6 +24,7 @@ package io.questdb.client.test.cutlass.http.client; +import io.questdb.client.test.tools.TestWebSocketClient; import io.questdb.client.DefaultHttpClientConfiguration; import io.questdb.client.cutlass.http.client.HttpClientException; import io.questdb.client.cutlass.http.client.WebSocketClient; @@ -512,7 +513,7 @@ public int send(long bufferPtr, int bufferLen) { } } - private static class CapturingWebSocketClient extends WebSocketClient { + private static class CapturingWebSocketClient extends TestWebSocketClient { CapturingWebSocketClient(CapturingSocket socket) { super(DefaultHttpClientConfiguration.INSTANCE, (nf, log) -> socket); @@ -598,7 +599,7 @@ public boolean wantsTlsWrite() { * sends always succeed. Used to drive a real tryReceiveFrame -> * tryParseFrame -> handler dispatch on the test thread. */ - private static class FrameFeedWebSocketClient extends WebSocketClient { + private static class FrameFeedWebSocketClient extends TestWebSocketClient { FrameFeedWebSocketClient(byte[] frames) { super(DefaultHttpClientConfiguration.INSTANCE, (nf, log) -> new FrameFeedSocket(frames)); @@ -619,7 +620,7 @@ protected void setupIoWait() { * WebSocketClient subclass with a fake socket that always returns 0 * from recv(), forcing the ioWait path in recvOrTimeout(). */ - private static class RecvTestWebSocketClient extends WebSocketClient { + private static class RecvTestWebSocketClient extends TestWebSocketClient { Runnable ioWaitAction; RecvTestWebSocketClient() { @@ -641,7 +642,7 @@ protected void setupIoWait() { * Minimal concrete WebSocketClient that throws on any I/O, * allowing us to test buffer management without a real socket. */ - private static class StubWebSocketClient extends WebSocketClient { + private static class StubWebSocketClient extends TestWebSocketClient { StubWebSocketClient() { super(DefaultHttpClientConfiguration.INSTANCE, PlainSocketFactory.INSTANCE); diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpConnectWalkBackgroundIsolationTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpConnectWalkBackgroundIsolationTest.java index 51ef603a9..c040b4c08 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpConnectWalkBackgroundIsolationTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpConnectWalkBackgroundIsolationTest.java @@ -24,8 +24,8 @@ package io.questdb.client.test.cutlass.qwp.client; +import io.questdb.client.test.tools.TestWebSocketClient; import io.questdb.client.DefaultHttpClientConfiguration; -import io.questdb.client.cutlass.http.client.WebSocketClient; import io.questdb.client.cutlass.line.LineSenderException; import io.questdb.client.cutlass.qwp.client.QwpWebSocketSender; import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorWebSocketSendLoop; @@ -183,7 +183,7 @@ private static void closeAllStubs() { * path, the connect-walk lock — then always throws, so no walk ever * "succeeds" and reaches upgrade or lifecycle commits. */ - private static final class StubClient extends WebSocketClient { + private static final class StubClient extends TestWebSocketClient { private final CountDownLatch entered; private final CountDownLatch release; diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpWebSocketSenderJvmErrorCleanupTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpWebSocketSenderJvmErrorCleanupTest.java index 4d463fa7e..244e44dc4 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpWebSocketSenderJvmErrorCleanupTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpWebSocketSenderJvmErrorCleanupTest.java @@ -24,6 +24,7 @@ package io.questdb.client.test.cutlass.qwp.client; +import io.questdb.client.test.tools.TestWebSocketClient; import io.questdb.client.cutlass.http.client.WebSocketClient; import io.questdb.client.cutlass.line.LineSenderException; import io.questdb.client.cutlass.qwp.client.QwpHostHealthTracker; @@ -301,7 +302,7 @@ private static void setField(Object target, String name, Object value) throws Ex * constructor never ran. Fields rely on zero-defaults; tests assign them * post-allocation. */ - private static final class StubClient extends WebSocketClient { + private static final class StubClient extends TestWebSocketClient { int closeCalls; Error connectError; RuntimeException connectRuntimeError; diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SlotLockReleasedContractTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SlotLockReleasedContractTest.java index 2ae605b7f..aeac65639 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SlotLockReleasedContractTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SlotLockReleasedContractTest.java @@ -24,6 +24,7 @@ package io.questdb.client.test.cutlass.qwp.client; +import io.questdb.client.test.tools.TestWebSocketClient; import io.questdb.client.DefaultHttpClientConfiguration; import io.questdb.client.Sender; import io.questdb.client.SenderConnectionEvent; @@ -165,7 +166,6 @@ public void testCarriedInterruptNeitherFailsCloseNorRetainsTheSlotLock() throws }); } - /** * Leak path: when {@code close()} cannot wind the I/O loop down it bails * out via the {@code !ioThreadStopped} early-return and must leave the slot @@ -206,6 +206,9 @@ public void testSlotLockNotReleasedWhenIoThreadRefusesToStop() throws Exception setField(loop, "running", Boolean.FALSE); if (realIo != null) { LockSupport.unpark(realIo); + // The idle I/O thread blocks in the transport wait, not a park: + // ring its doorbell the way close() does. + loop.wakeIo(); realIo.join(TimeUnit.SECONDS.toMillis(5)); Assert.assertFalse("real I/O thread must have stopped", realIo.isAlive()); } @@ -267,7 +270,7 @@ public void testSlotLockNotReleasedUntilManagerWorkerQuiesces() throws Exception Assert.assertEquals(0, Files.mkdir(tmpDir, Files.DIR_MODE_DEFAULT)); String slot = tmpDir + "/slot"; long segSize = MmapSegment.HEADER_SIZE + MmapSegment.FRAME_HEADER_SIZE + 32L; - SegmentManager manager = new SegmentManager(segSize, TimeUnit.SECONDS.toNanos(60)); + SegmentManager manager = new SegmentManager(segSize); CountDownLatch cleanupFinished = new CountDownLatch(1); CountDownLatch workerBlocked = new CountDownLatch(1); CountDownLatch releaseWorker = new CountDownLatch(1); @@ -410,7 +413,7 @@ public void testDelegatedIoThreadEngineCloseFlipsSlotLockReleased() throws Excep }; loop = new CursorWebSocketSendLoop( null /* async-initial-connect: the I/O thread drives the connect */, - engine, 0L, 1_000L, + engine, 0L, stuckConnect, 100L, 5_000L, false); loop.start(); @@ -542,7 +545,7 @@ public void testFailedIoStopReclaimsSenderResourcesAfterWorkerExit() throws Exce return loopClient; }; loop = new CursorWebSocketSendLoop( - null, engine, 0L, 1_000L, stuckConnect, + null, engine, 0L, stuckConnect, 100L, 5_000L, false); loop.start(); Assert.assertTrue("I/O thread never reached the connect factory", @@ -702,7 +705,7 @@ private static void setField(Object target, String name, Object value) throws Ex * to the loop by the stuck-connect factory; the loop's exit path closes * it (close is idempotent via the superclass). */ - private static final class StubWebSocketClient extends WebSocketClient { + private static final class StubWebSocketClient extends TestWebSocketClient { private final AtomicBoolean isClosed = new AtomicBoolean(); StubWebSocketClient() { diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleLoopTerminalTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleLoopTerminalTest.java index 907077bb4..6fccb2d5d 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleLoopTerminalTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleLoopTerminalTest.java @@ -24,6 +24,7 @@ package io.questdb.client.test.cutlass.qwp.client; +import io.questdb.client.test.tools.TestWebSocketClient; import io.questdb.client.DefaultHttpClientConfiguration; import io.questdb.client.LineSenderServerException; import io.questdb.client.SenderError; @@ -210,7 +211,6 @@ private static CursorWebSocketSendLoop newLoop(WebSocketClient client, CursorSen client, engine, 0L, - CursorWebSocketSendLoop.DEFAULT_PARK_NANOS, () -> { throw new LineSenderException("no reconnect expected"); }, @@ -226,7 +226,7 @@ private static CursorWebSocketSendLoop newLoop(WebSocketClient client, CursorSen * {@code closeTraffic()} does not break, and can run a hook inside a * link check made from a named caller. */ - private static final class NackOnDemandClient extends WebSocketClient { + private static final class NackOnDemandClient extends TestWebSocketClient { private final CountDownLatch receiveBlocked = new CountDownLatch(1); private final CountDownLatch receiveRelease = new CountDownLatch(1); private volatile boolean blockNextReceive; diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleSlotHealTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleSlotHealTest.java index cf976503e..6511611b5 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleSlotHealTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleSlotHealTest.java @@ -489,7 +489,7 @@ private static void fill(long address, int len, byte value) { private static void prepareFullyAckedLeftoverSlot(String slot) throws Exception { Path slotPath = Paths.get(slot); long payload = Unsafe.malloc(PAYLOAD_BYTES, MemoryTag.NATIVE_DEFAULT); - SegmentManager manager = new SegmentManager(SEGMENT_BYTES, TimeUnit.SECONDS.toNanos(60)); + SegmentManager manager = new SegmentManager(SEGMENT_BYTES); CursorSendEngine pred = null; boolean slotDirReadOnly = false; try { @@ -561,7 +561,7 @@ private static void prepareFullyAckedLeftoverSlot(String slot) throws Exception */ static void prepareUnackedLeftoverSlot(String slot) { long payload = Unsafe.malloc(PAYLOAD_BYTES, MemoryTag.NATIVE_DEFAULT); - SegmentManager manager = new SegmentManager(SEGMENT_BYTES, TimeUnit.SECONDS.toNanos(60)); + SegmentManager manager = new SegmentManager(SEGMENT_BYTES); CursorSendEngine pred = null; try { fill(payload, PAYLOAD_BYTES, (byte) 0x44); diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerDurableAckRetryTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerDurableAckRetryTest.java index fb6bbfb23..c08a20c21 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerDurableAckRetryTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerDurableAckRetryTest.java @@ -24,6 +24,7 @@ package io.questdb.client.test.cutlass.qwp.client.sf.cursor; +import io.questdb.client.test.tools.TestWebSocketClient; import io.questdb.client.DefaultHttpClientConfiguration; import io.questdb.client.SenderError; import io.questdb.client.cutlass.http.client.WebSocketClient; @@ -1689,7 +1690,7 @@ ScriptedFactory withDynamicCredential() { * Minimal concrete {@link WebSocketClient} returned as the success * sentinel — tests only need referential identity, never invoke I/O. */ - private static final class StubWebSocketClient extends WebSocketClient { + private static final class StubWebSocketClient extends TestWebSocketClient { StubWebSocketClient() { super(DefaultHttpClientConfiguration.INSTANCE, PlainSocketFactory.INSTANCE); } diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerInterruptedTeardownTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerInterruptedTeardownTest.java index d1526146a..0e0da0638 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerInterruptedTeardownTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerInterruptedTeardownTest.java @@ -24,6 +24,7 @@ package io.questdb.client.test.cutlass.qwp.client.sf.cursor; +import io.questdb.client.test.tools.TestWebSocketClient; import io.questdb.client.DefaultHttpClientConfiguration; import io.questdb.client.cutlass.http.client.WebSocketClient; import io.questdb.client.cutlass.qwp.client.sf.cursor.BackgroundDrainer; @@ -259,7 +260,7 @@ private boolean isSlotLockFree() { * only. Every send throws, so handing it to a live loop deterministically * drives the I/O thread into its reconnect path without native I/O. */ - private static final class StubWebSocketClient extends WebSocketClient { + private static final class StubWebSocketClient extends TestWebSocketClient { StubWebSocketClient() { super(DefaultHttpClientConfiguration.INSTANCE, PlainSocketFactory.INSTANCE); } diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CloseOwnershipRaceTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CloseOwnershipRaceTest.java index da3c79e9d..deefdf82a 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CloseOwnershipRaceTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CloseOwnershipRaceTest.java @@ -66,7 +66,7 @@ public void closeOwnershipSnapshotNeverClaimsAnUnsurfacedError() { // policy intentionally retries this state forever, while orphan // policy must hand it back to the quarantine owner. CursorWebSocketSendLoop loop = new CursorWebSocketSendLoop( - null, engine, 0, 1_000_000L, + null, engine, 0, () -> { throw new QwpAuthFailedException(401, "localhost", 1); }, diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorSendEngineClosePartialEnumerationTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorSendEngineClosePartialEnumerationTest.java index 9aefc2c1b..52209c609 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorSendEngineClosePartialEnumerationTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorSendEngineClosePartialEnumerationTest.java @@ -101,7 +101,6 @@ public void testTornCloseTimeEnumerationUnlinksNothingAndSuccessorRetries() thro Assert.assertEquals(0, Files.mkdir(slot, Files.DIR_MODE_DEFAULT)); SegmentManager manager = new SegmentManager( segSize, - SegmentManager.DEFAULT_POLL_NANOS, segSize * 4L, faultFacade, System::nanoTime); diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorSendEngineCloseUnlinkFailureTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorSendEngineCloseUnlinkFailureTest.java index a9df2b677..f074cc6f1 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorSendEngineCloseUnlinkFailureTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorSendEngineCloseUnlinkFailureTest.java @@ -43,7 +43,6 @@ import java.nio.file.attribute.PosixFilePermission; import java.nio.file.attribute.PosixFilePermissions; import java.util.Set; -import java.util.concurrent.TimeUnit; /** * Regression for the close-time segment cleanup on a fully-drained slot. @@ -88,7 +87,7 @@ public void testFailedCloseTimeUnlinkMustNotExposeAckedFramesToSuccessor() throw String slot = tmpDir + "/slot"; Path slotPath = Paths.get(slot); long payload = Unsafe.malloc(32, MemoryTag.NATIVE_DEFAULT); - SegmentManager manager = new SegmentManager(segmentSize, TimeUnit.SECONDS.toNanos(60)); + SegmentManager manager = new SegmentManager(segmentSize); CursorSendEngine pred = null; CursorSendEngine succ = null; boolean slotDirReadOnly = false; diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorSendEngineCloseUnlinkStopOnFirstFailureTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorSendEngineCloseUnlinkStopOnFirstFailureTest.java index 8fe040d75..2221940a9 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorSendEngineCloseUnlinkStopOnFirstFailureTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorSendEngineCloseUnlinkStopOnFirstFailureTest.java @@ -124,7 +124,6 @@ public void testCloseUnlinkStopsAtFirstFailedRemoveOnLegacySlot() throws Excepti manager = new SegmentManager( SEGMENT_SIZE, - SegmentManager.DEFAULT_POLL_NANOS, SEGMENT_SIZE * 4L, faultFacade, System::nanoTime); diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorSendEngineCrashConsistencyTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorSendEngineCrashConsistencyTest.java index a7017961b..24e2406c9 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorSendEngineCrashConsistencyTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorSendEngineCrashConsistencyTest.java @@ -59,7 +59,7 @@ public void testCloseDurabilityOrderAndPostCleanupSyncFailurePropagation() throw Assert.assertEquals(0, Files.mkdir(root, Files.DIR_MODE_DEFAULT)); CrashImageFilesFacade ff = new CrashImageFilesFacade(slot, failAt); long segmentSize = MmapSegment.HEADER_SIZE + MmapSegment.FRAME_HEADER_SIZE + 32L; - manager = new SegmentManager(segmentSize, TimeUnit.SECONDS.toNanos(60), + manager = new SegmentManager(segmentSize, SegmentManager.UNLIMITED_TOTAL_BYTES, ff); payload = Unsafe.malloc(32, MemoryTag.NATIVE_DEFAULT); engine = new CursorSendEngine(slot, segmentSize, manager); @@ -114,7 +114,7 @@ public void testFinalWatermarkBarrierFailureRetainsSlotUntilRetry() throws Excep Assert.assertEquals(0, Files.mkdir(root, Files.DIR_MODE_DEFAULT)); ff = new CrashImageFilesFacade(slot, -1); long segmentSize = MmapSegment.HEADER_SIZE + MmapSegment.FRAME_HEADER_SIZE + 32L; - manager = new SegmentManager(segmentSize, TimeUnit.SECONDS.toNanos(60), + manager = new SegmentManager(segmentSize, SegmentManager.UNLIMITED_TOTAL_BYTES, ff); payload = Unsafe.malloc(32, MemoryTag.NATIVE_DEFAULT); predecessor = new CursorSendEngine(slot, segmentSize, manager); @@ -183,7 +183,7 @@ public void testRecoveredSlotRejectsMissingWatermark() throws Exception { Assert.assertEquals(0, Files.mkdir(root, Files.DIR_MODE_DEFAULT)); CrashImageFilesFacade ff = new CrashImageFilesFacade(slot, -1); long segmentSize = MmapSegment.HEADER_SIZE + MmapSegment.FRAME_HEADER_SIZE + 32L; - manager = new SegmentManager(segmentSize, TimeUnit.SECONDS.toNanos(60), + manager = new SegmentManager(segmentSize, SegmentManager.UNLIMITED_TOTAL_BYTES, ff); payload = Unsafe.malloc(32, MemoryTag.NATIVE_DEFAULT); predecessor = new CursorSendEngine(slot, segmentSize, manager); diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorSendEngineSlotReacquisitionTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorSendEngineSlotReacquisitionTest.java index 4c59bcfe4..1b55ab421 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorSendEngineSlotReacquisitionTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorSendEngineSlotReacquisitionTest.java @@ -98,7 +98,7 @@ public void testRepeatedCloseRetainsSlotWhileWorkerIsMidServicePass() throws Exc String slot = tmpDir + "/slot"; // 60 s poll: the worker only acts when explicitly woken, so the // single pass we park below is the only pass in flight. - SegmentManager manager = new SegmentManager(segSize, TimeUnit.SECONDS.toNanos(60)); + SegmentManager manager = new SegmentManager(segSize); CountDownLatch cleanupFinished = new CountDownLatch(1); CountDownLatch workerBlocked = new CountDownLatch(1); CountDownLatch releaseWorker = new CountDownLatch(1); diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopBlockedSendCloseTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopBlockedSendCloseTest.java index 385785866..59162c693 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopBlockedSendCloseTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopBlockedSendCloseTest.java @@ -24,8 +24,8 @@ package io.questdb.client.test.cutlass.qwp.client.sf.cursor; +import io.questdb.client.test.tools.TestWebSocketClient; import io.questdb.client.DefaultHttpClientConfiguration; -import io.questdb.client.cutlass.http.client.WebSocketClient; import io.questdb.client.cutlass.line.LineSenderException; import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorSendEngine; import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorWebSocketSendLoop; @@ -53,7 +53,6 @@ public void testCloseBreaksBlockedSendBeforeJoiningWorker() throws Exception { client, engine, 0L, - CursorWebSocketSendLoop.DEFAULT_PARK_NANOS, null, 1_000L, 5_000L, @@ -129,7 +128,6 @@ public void testUnsupportedCustomTransportFailsWithoutDestroyingWorkerResources( client, engine, 0L, - CursorWebSocketSendLoop.DEFAULT_PARK_NANOS, null, 1_000L, 5_000L, @@ -191,7 +189,7 @@ public void testUnsupportedCustomTransportFailsWithoutDestroyingWorkerResources( }); } - private static final class BlockingSendClient extends WebSocketClient { + private static final class BlockingSendClient extends TestWebSocketClient { private final AtomicBoolean cleanupClaimed = new AtomicBoolean(); private final AtomicInteger cleanupCount = new AtomicInteger(); private final CountDownLatch cleanupDone = new CountDownLatch(1); diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopCatchUpAlignmentTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopCatchUpAlignmentTest.java index 2bae99e59..fcd2bf206 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopCatchUpAlignmentTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopCatchUpAlignmentTest.java @@ -24,6 +24,7 @@ package io.questdb.client.test.cutlass.qwp.client.sf.cursor; +import io.questdb.client.test.tools.TestWebSocketClient; import io.questdb.client.DefaultHttpClientConfiguration; import io.questdb.client.cutlass.http.client.WebSocketClient; import io.questdb.client.cutlass.line.LineSenderException; @@ -1144,7 +1145,7 @@ private void assertUnrelatedReconnectStateRestartsCapGapEpisode(boolean roleReje CursorWebSocketSendLoop[] loopRef = new CursorWebSocketSendLoop[1]; try (CursorSendEngine engine = newEngine()) { CursorWebSocketSendLoop loop = new CursorWebSocketSendLoop( - null, engine, 0L, CursorWebSocketSendLoop.DEFAULT_PARK_NANOS, + null, engine, 0L, () -> { int call = ++reconnectCalls[0]; if (call < maxAttempts) { @@ -1323,7 +1324,7 @@ private CursorWebSocketSendLoop newLoop( CursorSendEngine engine, WebSocketClient client, long capGapWindowMillis ) { return new CursorWebSocketSendLoop( - client, engine, 0L, CursorWebSocketSendLoop.DEFAULT_PARK_NANOS, + client, engine, 0L, () -> { throw new UnsupportedOperationException("test loop is never started"); }, @@ -1340,7 +1341,7 @@ private CursorWebSocketSendLoop newForegroundLoop( // Deliberately use the compatibility overload: its safe default must remain the // foreground RETRY_FOREVER policy for external callers. return new CursorWebSocketSendLoop( - client, engine, 0L, CursorWebSocketSendLoop.DEFAULT_PARK_NANOS, + client, engine, 0L, () -> { throw new UnsupportedOperationException("test loop is never started"); }, @@ -1425,7 +1426,6 @@ private void assertConnectLoopEntry(boolean reenterWithCapGap) throws Exception // A 160-byte cap against a 200-byte mirror entry: the catch-up // cannot re-register it, which is a genuine cap gap. new CatchUpCapturingClient(160), engine, 0L, - CursorWebSocketSendLoop.DEFAULT_PARK_NANOS, () -> { // First point after the entry guard; the no-cap client lets // the ensuing catch-up succeed so the loop exits promptly. @@ -1642,7 +1642,7 @@ private static long writeVarint(long addr, long value) { // catch-up split; sendBinary counts the frames the catch-up emitted, or -- // when throwOnSend is set -- raises a transient wire error to model the fresh // connection dropping mid-catch-up. - private static final class CatchUpCapturingClient extends WebSocketClient { + private static final class CatchUpCapturingClient extends TestWebSocketClient { // Mutable so a test can model a rolling-cap cluster: raise it for a node that // accepts the dictionary, lower it for a smaller-cap node that cap-gaps. private int cap; diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopConnectGateTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopConnectGateTest.java index 344860e88..881745126 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopConnectGateTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopConnectGateTest.java @@ -24,8 +24,8 @@ package io.questdb.client.test.cutlass.qwp.client.sf.cursor; +import io.questdb.client.test.tools.TestWebSocketClient; 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.line.LineSenderException; import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorSendCounters; @@ -78,7 +78,6 @@ public void testClaimedStopKeepsIoThreadOutOfConnectWalk() throws Exception { // walk would touch if it wrongly started. setField(loop, "reconnectFactory", factory); setField(loop, "counters", new CursorSendCounters()); - setField(loop, "parkNanos", TimeUnit.MILLISECONDS.toNanos(1)); setField(loop, "running", true); setField(loop, "connectGate", getStaticInt("CONNECT_GATE_STOP_CLAIMED")); @@ -141,7 +140,6 @@ public void testCloseIfLinkUpRefusesDuringConnectWalkThenStopsLiveLoop() throws initialClient, engine, 0L, - CursorWebSocketSendLoop.DEFAULT_PARK_NANOS, factory, /* reconnectInitialBackoffMillis */ 1_000L, /* reconnectMaxBackoffMillis */ 5_000L, @@ -227,7 +225,6 @@ public void testCloseIfLinkUpRefusesWhenTheWalkStartsBehindItsLinkCheck() throws initialClient, engine, 0L, - CursorWebSocketSendLoop.DEFAULT_PARK_NANOS, factory, /* reconnectInitialBackoffMillis */ 1_000L, /* reconnectMaxBackoffMillis */ 5_000L, @@ -289,7 +286,7 @@ private static void setField(Object target, String name, Object value) throws Ex * {@link #drop()} (or a {@code closeTraffic()} from the loop's close), * after which its next receive fails the way a peer disconnect does. */ - private static final class IdleLinkClient extends WebSocketClient { + private static final class IdleLinkClient extends TestWebSocketClient { private volatile boolean closed; private volatile boolean dropped; private volatile Runnable linkCheckHook; diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopConnectPhaseCloseTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopConnectPhaseCloseTest.java index 77e6028d1..76b218936 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopConnectPhaseCloseTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopConnectPhaseCloseTest.java @@ -24,6 +24,7 @@ package io.questdb.client.test.cutlass.qwp.client.sf.cursor; +import io.questdb.client.test.tools.TestWebSocketClient; import io.questdb.client.DefaultHttpClientConfiguration; import io.questdb.client.cutlass.http.client.WebSocketClient; import io.questdb.client.cutlass.http.client.WebSocketFrameHandler; @@ -150,7 +151,6 @@ public WebSocketClient reconnect(CursorWebSocketSendLoop.ConnectCancellation can null, engine, 0L, - CursorWebSocketSendLoop.DEFAULT_PARK_NANOS, factory, /* reconnectInitialBackoffMillis */ 1_000L, /* reconnectMaxBackoffMillis */ 5_000L, @@ -268,7 +268,6 @@ public WebSocketClient reconnect(CursorWebSocketSendLoop.ConnectCancellation can initialClient, engine, 0L, - CursorWebSocketSendLoop.DEFAULT_PARK_NANOS, factory, /* reconnectInitialBackoffMillis */ 1_000L, /* reconnectMaxBackoffMillis */ 5_000L, @@ -342,7 +341,7 @@ public WebSocketClient reconnect(CursorWebSocketSendLoop.ConnectCancellation can * {@link #awaitTrafficBreak()} until {@link #closeTraffic()} releases it, * which is the ONLY path out -- neither unpark nor interrupt can cancel it. */ - private static final class InFlightConnectClient extends WebSocketClient { + private static final class InFlightConnectClient extends TestWebSocketClient { final CountDownLatch trafficBroken = new CountDownLatch(1); final AtomicInteger trafficCloseCount = new AtomicInteger(); final AtomicReference trafficCloseThread = new AtomicReference<>(); @@ -394,7 +393,7 @@ protected void setupIoWait() { * the ONLY release is the test-owned {@code release} latch, counted down * AFTER the backstop assertions. */ - private static final class UninterruptibleInFlightClient extends WebSocketClient { + private static final class UninterruptibleInFlightClient extends TestWebSocketClient { final AtomicInteger trafficCloseCount = new AtomicInteger(); private final CountDownLatch release; @@ -443,7 +442,7 @@ protected void setupIoWait() { * into a reconnect so the in-flight client is published while the * {@code client} field still points here (the mid-flight-reconnect path). */ - private static final class FailingInitialClient extends WebSocketClient { + private static final class FailingInitialClient extends TestWebSocketClient { private FailingInitialClient() { super(DefaultHttpClientConfiguration.INSTANCE, PlainSocketFactory.INSTANCE); diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopDurableAckFuzzTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopDurableAckFuzzTest.java index 11c6388e6..ddf5b45b8 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopDurableAckFuzzTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopDurableAckFuzzTest.java @@ -193,7 +193,7 @@ private static void runOneIteration(Rnd rnd, int iter) throws Exception { engine.appendBlocking(buf, 8); } CursorWebSocketSendLoop loop = new CursorWebSocketSendLoop( - null, engine, 0L, CursorWebSocketSendLoop.DEFAULT_PARK_NANOS, + null, engine, 0L, () -> { throw new UnsupportedOperationException(); }, diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopDurableAckTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopDurableAckTest.java index 80cb68b51..777868557 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopDurableAckTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopDurableAckTest.java @@ -664,7 +664,7 @@ private CursorSendEngine newEngine() { private CursorWebSocketSendLoop newDefaultLoop(CursorSendEngine engine) { return new CursorWebSocketSendLoop( - null, engine, 0L, CursorWebSocketSendLoop.DEFAULT_PARK_NANOS, + null, engine, 0L, () -> { throw new UnsupportedOperationException("test loop is never started"); }, @@ -673,7 +673,7 @@ private CursorWebSocketSendLoop newDefaultLoop(CursorSendEngine engine) { private CursorWebSocketSendLoop newDurableLoop(CursorSendEngine engine) { return new CursorWebSocketSendLoop( - null, engine, 0L, CursorWebSocketSendLoop.DEFAULT_PARK_NANOS, + null, engine, 0L, () -> { throw new UnsupportedOperationException("test loop is never started"); }, diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopForegroundReconnectPolicyTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopForegroundReconnectPolicyTest.java index 5aff5e5fb..9bf07c02b 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopForegroundReconnectPolicyTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopForegroundReconnectPolicyTest.java @@ -24,6 +24,7 @@ package io.questdb.client.test.cutlass.qwp.client.sf.cursor; +import io.questdb.client.test.tools.TestWebSocketClient; import io.questdb.client.DefaultHttpClientConfiguration; import io.questdb.client.cutlass.http.client.WebSocketClient; import io.questdb.client.cutlass.http.client.WebSocketClientFactory; @@ -91,7 +92,6 @@ public void testFirstConnectCatchUpFailureKeepsStartupTerminalArmed() throws Exc null, engine, 0L, - CursorWebSocketSendLoop.DEFAULT_PARK_NANOS, factory, 1L, 4L, @@ -162,7 +162,6 @@ private void assertAsyncInitialForegroundSurfacesTerminal( null, engine, 0L, - CursorWebSocketSendLoop.DEFAULT_PARK_NANOS, factory, 1L, 4L, @@ -220,7 +219,6 @@ private void assertForegroundRecovers(boolean durableAck, FailureSupplier failur initialClient, engine, 0L, - CursorWebSocketSendLoop.DEFAULT_PARK_NANOS, factory, 1L, 4L, @@ -474,7 +472,7 @@ public WebSocketClient reconnect() throws Exception { } } - private static final class FailingSendClient extends WebSocketClient { + private static final class FailingSendClient extends TestWebSocketClient { private FailingSendClient() { super(DefaultHttpClientConfiguration.INSTANCE, PlainSocketFactory.INSTANCE); } diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopGracefulCloseTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopGracefulCloseTest.java index ba947f9d7..0a6236102 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopGracefulCloseTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopGracefulCloseTest.java @@ -24,6 +24,7 @@ package io.questdb.client.test.cutlass.qwp.client.sf.cursor; +import io.questdb.client.test.tools.TestWebSocketClient; import io.questdb.client.DefaultHttpClientConfiguration; import io.questdb.client.cutlass.http.client.WebSocketClient; import io.questdb.client.cutlass.http.client.WebSocketFrameHandler; @@ -79,7 +80,6 @@ public WebSocketClient reconnect(CursorWebSocketSendLoop.ConnectCancellation can null, engine, 0L, - CursorWebSocketSendLoop.DEFAULT_PARK_NANOS, factory, 1_000L, 5_000L, @@ -122,7 +122,6 @@ public void testCloseLetsIdleWorkerCloseClientBeforeBreakingTraffic() throws Exc client, engine, 0L, - CursorWebSocketSendLoop.DEFAULT_PARK_NANOS, null, 1_000L, 5_000L, @@ -154,7 +153,7 @@ public void testCloseLetsIdleWorkerCloseClientBeforeBreakingTraffic() throws Exc * Connected client with nothing to receive. Records which thread closes it * first and whether anyone broke its traffic. */ - private static final class IdleClient extends WebSocketClient { + private static final class IdleClient extends TestWebSocketClient { final AtomicReference firstCloseThread = new AtomicReference<>(); final AtomicReference pollThread = new AtomicReference<>(); final CountDownLatch polled = new CountDownLatch(1); @@ -197,7 +196,7 @@ protected void setupIoWait() { * Fake in-flight connect: parks until {@link #closeTraffic()} releases it, * like a native connect that neither unpark nor interrupt can cancel. */ - private static final class InFlightConnectClient extends WebSocketClient { + private static final class InFlightConnectClient extends TestWebSocketClient { final CountDownLatch trafficBroken = new CountDownLatch(1); final AtomicInteger trafficCloseCount = new AtomicInteger(); diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopIdleWaitTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopIdleWaitTest.java new file mode 100644 index 000000000..efe34e774 --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopIdleWaitTest.java @@ -0,0 +1,250 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +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 io.questdb.client.test.tools.TestUtils; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Paths; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * The I/O loop must not poll while idle: with nothing to send and nothing on + * the wire it blocks in the transport's doorbell-aware wait, and a producer + * publish (or close()) is what brings it back. The fake transport here blocks + * for real, so a loop that still polled would either spin the receive counter + * or hang close(). + */ +public class CursorWebSocketSendLoopIdleWaitTest { + private String tmpDir; + + @Before + public void setUp() { + tmpDir = Paths.get(System.getProperty("java.io.tmpdir"), + "qdb-cursor-idle-" + System.nanoTime()).toString(); + assertEquals(0, Files.mkdir(tmpDir, Files.DIR_MODE_DEFAULT)); + } + + @After + public void tearDown() { + if (tmpDir == null) return; + long find = Files.findFirst(tmpDir); + if (find > 0) { + try { + int rc = 1; + while (rc > 0) { + String name = Files.utf8ToString(Files.findName(find)); + if (name != null && !".".equals(name) && !"..".equals(name)) { + Files.remove(tmpDir + "/" + name); + } + rc = Files.findNext(find); + } + } finally { + Files.findClose(find); + } + } + Files.remove(tmpDir); + } + + @Test + public void testIdleLoopBlocksAndPublishWakesIt() throws Exception { + TestUtils.assertMemoryLeak(() -> { + try (CursorSendEngine engine = new CursorSendEngine(tmpDir, 16384)) { + BlockingDoorbellClient client = new BlockingDoorbellClient(); + CursorWebSocketSendLoop loop = new CursorWebSocketSendLoop( + client, engine, 0L, + BlockingDoorbellClient::new, + 100L, 1_000L, + false, + CursorWebSocketSendLoop.DEFAULT_DURABLE_ACK_KEEPALIVE_INTERVAL_MILLIS, + CursorWebSocketSendLoop.DEFAULT_MAX_HEAD_FRAME_REJECTIONS); + try { + loop.start(); + + // Idle: a polling loop would rack up thousands of receive + // attempts in 500 ms; a blocking one makes a handful at most. + Thread.sleep(500); + int idleReceives = client.receiveAttempts.get(); + assertTrue("idle loop polled the socket " + idleReceives + " times", idleReceives <= 5); + assertTrue("idle loop never blocked in the transport wait", client.waits.get() >= 1); + + // Publish: the engine rings the doorbell and the frame goes + // out promptly, without any timer involved (the wait has no + // timeout -- keepalives are off). + long t0 = System.nanoTime(); + appendFrame(engine); + long deadline = t0 + TimeUnit.SECONDS.toNanos(5); + while (client.sentFrames.get() < 1 && System.nanoTime() < deadline) { + Thread.sleep(1); + } + assertEquals("frame was not sent after publish", 1, client.sentFrames.get()); + long latencyMillis = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - t0); + assertTrue("publish-to-send took " + latencyMillis + " ms", latencyMillis < 500); + + // Idle again: still no polling. + int before = client.receiveAttempts.get(); + Thread.sleep(300); + assertTrue("loop resumed polling after a send", client.receiveAttempts.get() - before <= 5); + } finally { + // close() must ring the doorbell: the thread is blocked + // without a timeout, so without the wake this would hang. + long t0 = System.nanoTime(); + loop.close(); + long closeMillis = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - t0); + assertTrue("close() took " + closeMillis + " ms", closeMillis < 2_000); + } + } + }); + } + + /** + * Regression: a reconnect rewinds the send cursor from inside the I/O + * thread. That creates sendable work that no producer publish and no + * inbound byte will ever announce, so the loop must not block on it. Here + * the first transport dies on its first receive after the send; the replay + * must reach the replacement transport with the producer completely idle. + */ + @Test + public void testReplayAfterReconnectIsNotStrandedByIdleWait() throws Exception { + TestUtils.assertMemoryLeak(() -> { + try (CursorSendEngine engine = new CursorSendEngine(tmpDir, 16384)) { + BlockingDoorbellClient first = new BlockingDoorbellClient(); + first.failOnFirstReceiveAfterSend = true; + BlockingDoorbellClient replacement = new BlockingDoorbellClient(); + CursorWebSocketSendLoop loop = new CursorWebSocketSendLoop( + first, engine, 0L, + () -> replacement, + 1L, 10L, + false, + CursorWebSocketSendLoop.DEFAULT_DURABLE_ACK_KEEPALIVE_INTERVAL_MILLIS, + CursorWebSocketSendLoop.DEFAULT_MAX_HEAD_FRAME_REJECTIONS); + try { + loop.start(); + appendFrame(engine); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (replacement.sentFrames.get() < 1 && System.nanoTime() < deadline) { + Thread.sleep(1); + } + assertEquals("frame must be replayed on the replacement transport", 1, replacement.sentFrames.get()); + assertEquals("original transport sent the frame once", 1, first.sentFrames.get()); + } finally { + loop.close(); + } + } + }); + } + + private static void appendFrame(CursorSendEngine engine) { + long buf = Unsafe.malloc(16, MemoryTag.NATIVE_DEFAULT); + try { + byte[] payload = "frame-bytes-padd".getBytes(StandardCharsets.US_ASCII); + for (int i = 0; i < payload.length; i++) { + Unsafe.getUnsafe().putByte(buf + i, payload[i]); + } + engine.appendBlocking(buf, 16); + } finally { + Unsafe.free(buf, 16, MemoryTag.NATIVE_DEFAULT); + } + } + + /** + * A transport whose idle wait really blocks (no bounded park), like the + * real one, with a stateful doorbell. + */ + private static final class BlockingDoorbellClient extends WebSocketClient { + final AtomicInteger receiveAttempts = new AtomicInteger(); + final AtomicInteger sentFrames = new AtomicInteger(); + final AtomicInteger waits = new AtomicInteger(); + volatile boolean failOnFirstReceiveAfterSend; + private final Semaphore doorbell = new Semaphore(0); + + BlockingDoorbellClient() { + super(DefaultHttpClientConfiguration.INSTANCE, PlainSocketFactory.INSTANCE); + } + + @Override + public boolean awaitReadableOrWake(int timeoutMillis) { + waits.incrementAndGet(); + try { + if (timeoutMillis < 0) { + doorbell.acquire(); + doorbell.drainPermits(); + return true; + } + boolean woken = doorbell.tryAcquire(timeoutMillis, TimeUnit.MILLISECONDS); + doorbell.drainPermits(); + return woken; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } + } + + @Override + public void wake() { + doorbell.release(); + } + + @Override + public void sendBinary(long dataPtr, int length) { + sentFrames.incrementAndGet(); + } + + @Override + public boolean tryReceiveFrame(WebSocketFrameHandler handler) { + receiveAttempts.incrementAndGet(); + if (failOnFirstReceiveAfterSend && sentFrames.get() > 0) { + failOnFirstReceiveAfterSend = false; + throw new io.questdb.client.cutlass.http.client.HttpClientException("peer disconnect [errno=104]"); + } + return false; + } + + @Override + protected void ioWait(int timeout, int op) { + } + + @Override + protected void setupIoWait() { + } + } +} diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopInterruptedCloseLeakTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopInterruptedCloseLeakTest.java index ad17a1cab..b1342a286 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopInterruptedCloseLeakTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopInterruptedCloseLeakTest.java @@ -24,6 +24,7 @@ package io.questdb.client.test.cutlass.qwp.client.sf.cursor; +import io.questdb.client.test.tools.TestWebSocketClient; import io.questdb.client.DefaultHttpClientConfiguration; import io.questdb.client.cutlass.http.client.WebSocketClient; import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorSendEngine; @@ -105,7 +106,7 @@ public void testC5_interruptedCloseMustNotLeakClientInstalledByInFlightReconnect try { CursorWebSocketSendLoop loop = new CursorWebSocketSendLoop( null /* async-initial-connect: the I/O thread drives the connect */, - engine, 0L, 1_000L, + engine, 0L, stuckConnect, 100L, 5_000L, false); loop.start(); @@ -178,7 +179,7 @@ public void testC5_interruptedCloseMustNotLeakClientInstalledByInFlightReconnect * Close remains idempotent via the superclass, matching the production * contract owners rely on. */ - private static final class TrackingStubWebSocketClient extends WebSocketClient { + private static final class TrackingStubWebSocketClient extends TestWebSocketClient { private final AtomicInteger closeCount = new AtomicInteger(); TrackingStubWebSocketClient() { diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopMirrorLeakTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopMirrorLeakTest.java index 5de5ada43..2b9ed6aef 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopMirrorLeakTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopMirrorLeakTest.java @@ -87,7 +87,7 @@ public void testSeededMirrorFreedWhenLoopClosedWithoutStart() throws Exception { long persistedAddr = pd.loadedEntriesAddr(); CursorWebSocketSendLoop loop = new CursorWebSocketSendLoop( - null, engine, 0, 1_000_000L, + null, engine, 0, () -> { throw new IOException("no reconnect in this test"); }, @@ -207,7 +207,7 @@ public void testCtorFreesSeededMirrorWhenFrameSeedThrows() throws Exception { CursorWebSocketSendLoop.forceMirrorSeedFailureForTest = true; try { new CursorWebSocketSendLoop( - null, engine, 0, 1_000_000L, + null, engine, 0, () -> { throw new IOException("no reconnect in this test"); }, @@ -241,7 +241,7 @@ public void testForegroundLoopRetainsRecoveredSuffixForRetry() throws Exception Assert.assertTrue("recovery must retain c@2 above the persisted [a,b] prefix", engine.recoverySymbolNativeCapacity() > 0); CursorWebSocketSendLoop loop = new CursorWebSocketSendLoop( - null, engine, 0, 1_000_000L, + null, engine, 0, () -> { throw new IOException("no reconnect in this test"); }, @@ -335,7 +335,7 @@ public void testOrphanLoopsRetainRecoveredSuffixForRecycle() throws Exception { // reconnect factory is never invoked. private static CursorWebSocketSendLoop newRecoveryLoop(CursorSendEngine engine) { return new CursorWebSocketSendLoop( - null, engine, 0, 1_000_000L, + null, engine, 0, () -> { throw new IOException("no reconnect in this test"); }, @@ -349,7 +349,7 @@ private static CursorWebSocketSendLoop newForegroundLoop(CursorSendEngine engine // builds an ORPHAN loop, which borrows by construction and so would not exercise // the foreground path this test exists for. return new CursorWebSocketSendLoop( - null, engine, 0, 1_000_000L, + null, engine, 0, () -> { throw new IOException("no reconnect in this test"); }, diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopOrphanTailTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopOrphanTailTest.java index 89fd39bb6..22c4a3978 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopOrphanTailTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopOrphanTailTest.java @@ -24,8 +24,8 @@ package io.questdb.client.test.cutlass.qwp.client.sf.cursor; +import io.questdb.client.test.tools.TestWebSocketClient; 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.GlobalSymbolDictionary; import io.questdb.client.cutlass.qwp.client.NativeBufferWriter; @@ -651,7 +651,7 @@ public void testSelfSufficientFrameCannotHideUnackedRecoveryGap() throws Excepti * for the highest wire seq seen. sendBinary and tryReceiveFrame both run * on the I/O thread; sentCount is volatile for test-thread assertions. */ - private static final class AckingClient extends WebSocketClient { + private static final class AckingClient extends TestWebSocketClient { private int ackedUpTo; private volatile int sentCount; @@ -842,7 +842,7 @@ private CursorSendEngine newEngine() { private CursorWebSocketSendLoop newLoop(CursorSendEngine engine, List clients) { return new CursorWebSocketSendLoop( - null, engine, 0L, CursorWebSocketSendLoop.DEFAULT_PARK_NANOS, + null, engine, 0L, () -> { AckingClient c = new AckingClient(); synchronized (clients) { diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopPoisonFrameTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopPoisonFrameTest.java index 1f446a113..856336fb5 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopPoisonFrameTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopPoisonFrameTest.java @@ -24,6 +24,7 @@ package io.questdb.client.test.cutlass.qwp.client.sf.cursor; +import io.questdb.client.test.tools.TestWebSocketClient; import io.questdb.client.DefaultHttpClientConfiguration; import io.questdb.client.LineSenderServerException; import io.questdb.client.SenderError; @@ -345,7 +346,6 @@ public void testNackRecycleIsPacedAgainstHealthyServer() throws Exception { }; CursorWebSocketSendLoop loop = new CursorWebSocketSendLoop( new NackingClient(), engine, 0L, - CursorWebSocketSendLoop.DEFAULT_PARK_NANOS, factory, initialBackoffMillis, 1_000L, false, @@ -412,7 +412,6 @@ public void testNonOrderlyCloseRecycleIsPacedAgainstAcceptingMiddlebox() throws }; CursorWebSocketSendLoop loop = new CursorWebSocketSendLoop( new ClosingMiddleboxClient(), engine, 0L, - CursorWebSocketSendLoop.DEFAULT_PARK_NANOS, factory, initialBackoffMillis, 1_000L, false, @@ -479,7 +478,6 @@ public void testOrderlyCloseChurnIsPacedAfterFirstRecycle() throws Exception { }; CursorWebSocketSendLoop loop = new CursorWebSocketSendLoop( new OrderlyClosingClient(), engine, 0L, - CursorWebSocketSendLoop.DEFAULT_PARK_NANOS, factory, initialBackoffMillis, 1_000L, false, @@ -549,7 +547,6 @@ public void testPreSendCloseChurnIsPaced() throws Exception { }; CursorWebSocketSendLoop loop = new CursorWebSocketSendLoop( new PreSendClosingClient(), engine, 0L, - CursorWebSocketSendLoop.DEFAULT_PARK_NANOS, factory, initialBackoffMillis, 1_000L, false, @@ -886,7 +883,7 @@ public void testNotWritableRecycleFirstImmediateThenPaced() throws Exception { * this connection, then delivers exactly one WRITE_ERROR (RETRIABLE) * rejection for wireSeq 0. */ - private static final class NackingClient extends WebSocketClient { + private static final class NackingClient extends TestWebSocketClient { private boolean nackDelivered; private volatile int sentCount; @@ -933,7 +930,7 @@ protected void setupIoWait() { * close. No OK ever arrives, so the poison suspect FSN never advances -- * the accepting-middlebox outage scenario. */ - private static final class ClosingMiddleboxClient extends WebSocketClient { + private static final class ClosingMiddleboxClient extends TestWebSocketClient { private boolean closeDelivered; private volatile int sentCount; @@ -972,7 +969,7 @@ protected void setupIoWait() { * Orderly closes are strike-exempt by design, so before the zero-progress * pacer the recycle loop churned at connect+close RTT rate. */ - private static final class OrderlyClosingClient extends WebSocketClient { + private static final class OrderlyClosingClient extends TestWebSocketClient { private boolean closeDelivered; OrderlyClosingClient() { @@ -1010,7 +1007,7 @@ protected void setupIoWait() { * connection (nextWireSeq == 0) the close is strike-exempt -- the * exemption re-arms every cycle because swapClient resets nextWireSeq. */ - private static final class PreSendClosingClient extends WebSocketClient { + private static final class PreSendClosingClient extends TestWebSocketClient { private boolean closeDelivered; PreSendClosingClient() { @@ -1206,7 +1203,7 @@ private CursorSendEngine newEngine() { private CursorWebSocketSendLoop newDurableLoop(CursorSendEngine engine, List clients) throws Exception { CursorWebSocketSendLoop loop = new CursorWebSocketSendLoop( - null, engine, 0L, CursorWebSocketSendLoop.DEFAULT_PARK_NANOS, + null, engine, 0L, () -> { NackingClient c = new NackingClient(); clients.add(c); @@ -1233,7 +1230,7 @@ private CursorWebSocketSendLoop newExemptPacerLoop(CursorSendEngine engine, List clients, long initialBackoffMillis) throws Exception { CursorWebSocketSendLoop loop = new CursorWebSocketSendLoop( - null, engine, 0L, CursorWebSocketSendLoop.DEFAULT_PARK_NANOS, + null, engine, 0L, () -> { NackingClient c = new NackingClient(); clients.add(c); @@ -1257,7 +1254,7 @@ private CursorWebSocketSendLoop newDurableLoopWithWindow(CursorSendEngine engine int maxRejections, long windowMillis) throws Exception { CursorWebSocketSendLoop loop = new CursorWebSocketSendLoop( - null, engine, 0L, CursorWebSocketSendLoop.DEFAULT_PARK_NANOS, + null, engine, 0L, () -> { NackingClient c = new NackingClient(); clients.add(c); diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopRotationRaceTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopRotationRaceTest.java index afe6baaab..d231294b6 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopRotationRaceTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopRotationRaceTest.java @@ -24,8 +24,8 @@ package io.questdb.client.test.cutlass.qwp.client.sf.cursor; +import io.questdb.client.test.tools.TestWebSocketClient; 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; @@ -69,7 +69,6 @@ public class CursorWebSocketSendLoopRotationRaceTest { // rescans the sealed list (~FRAMES/FRAMES_PER_SEGMENT entries) on every // rotation — keep the product small enough that scan stays cheap. private static final int FRAMES = 50_000; - private static final long MANAGER_POLL_NANOS = 1_000L; // 1us: keep a hot spare ready // Upper bound on the drain wait. A healthy run exits the instant the last // frame lands, so this only caps how long a *failing* (frame-dropped) run // takes — set high enough that a stalled, overloaded CI box is never @@ -82,13 +81,13 @@ public void tailFrameSurvivesSegmentRotation() throws Exception { final int n = FRAMES; SegmentManager manager = new SegmentManager( - SEGMENT_BYTES, MANAGER_POLL_NANOS, SegmentManager.UNLIMITED_TOTAL_BYTES); + SEGMENT_BYTES, SegmentManager.UNLIMITED_TOTAL_BYTES); manager.start(); // sfDir == null => memory-only segments (no files/fds); same trySendOne() path. CursorSendEngine engine = new CursorSendEngine(null, SEGMENT_BYTES, manager); RecordingClient client = new RecordingClient(n); CursorWebSocketSendLoop loop = new CursorWebSocketSendLoop( - client, engine, 0L, 0L /* busy-spin so the consumer sits on the active tip */, + client, engine, 0L, () -> { throw new UnsupportedOperationException("no reconnect in this test"); }, @@ -141,7 +140,7 @@ private static void assertContiguous(RecordingClient client, int expected) { * test never receives ACKs and never reconnects. Only {@code sentCount} is * read cross-thread (by the test, for progress) and so is volatile. */ - private static final class RecordingClient extends WebSocketClient { + private static final class RecordingClient extends TestWebSocketClient { final long[] recorded; volatile long sentCount; private int recordedCount; diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopTornDictGuardTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopTornDictGuardTest.java index c7ced70e8..c47c06cd0 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopTornDictGuardTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopTornDictGuardTest.java @@ -24,6 +24,7 @@ package io.questdb.client.test.cutlass.qwp.client.sf.cursor; +import io.questdb.client.test.tools.TestWebSocketClient; import io.questdb.client.DefaultHttpClientConfiguration; import io.questdb.client.Sender; import io.questdb.client.cutlass.http.client.WebSocketClient; @@ -141,7 +142,7 @@ private static int countSegmentFiles(Path dir) { // because no reconnect is expected before the guard latches its terminal. private CursorWebSocketSendLoop newLoop(CursorSendEngine engine, WebSocketClient client) { return new CursorWebSocketSendLoop( - client, engine, 0, 1_000_000L, + client, engine, 0, () -> { throw new IOException("no reconnect in this test"); }, @@ -185,7 +186,7 @@ private void writeAndTearGappedSlot() throws Exception { // Frame-counting stub transport: completes no real I/O. If the guard ever lets a // gapped frame through, sendBinary bumps framesSent and the test fails. - private static final class CountingClient extends WebSocketClient { + private static final class CountingClient extends TestWebSocketClient { private int framesSent; CountingClient() { diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopZeroBackoffTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopZeroBackoffTest.java index f242bba7f..2e7ed02eb 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopZeroBackoffTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopZeroBackoffTest.java @@ -103,7 +103,7 @@ public void zeroBackoffConnectFailuresKeepRetryingInsteadOfKillingIoThread() thr try (CursorSendEngine engine = new CursorSendEngine( sfDir.getRoot().getAbsolutePath(), 16_384)) { CursorWebSocketSendLoop loop = new CursorWebSocketSendLoop( - null, engine, 0, 1_000_000L, + null, engine, 0, () -> { // Plain connect failure: retried forever under // Invariant B — never terminal, never latched. @@ -147,7 +147,7 @@ public void adoptCountersSharesBeforeStartAndRefusesAfter() throws Exception { try (CursorSendEngine engine = new CursorSendEngine( sfDir.getRoot().getAbsolutePath(), 16_384)) { CursorWebSocketSendLoop loop = new CursorWebSocketSendLoop( - null, engine, 0, 1_000_000L, + null, engine, 0, () -> { throw new IOException("connection refused (test)"); }, diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerCloseRaceTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerCloseRaceTest.java index 46ea1b0d1..37bd92562 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerCloseRaceTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerCloseRaceTest.java @@ -87,7 +87,7 @@ public void testManagerDoesNotInstallSpareIntoClosedRing() throws Exception { TestUtils.assertMemoryLeak(() -> { // Aggressive 1us poll so the worker is almost always running // serviceRing — maximizes overlap with concurrent deregister/close. - SegmentManager manager = new SegmentManager(SEGMENT_SIZE, 1_000L, + SegmentManager manager = new SegmentManager(SEGMENT_SIZE, Long.MAX_VALUE); manager.start(); @@ -138,7 +138,7 @@ public void testCloseDoesNotFreePathScratchWhenWorkerStillAlive() throws Excepti Assert.assertEquals(0, Files.mkdir(slot, Files.DIR_MODE_DEFAULT)); MmapSegment initial = MmapSegment.create(slot + "/sf-initial.sfa", 0L, segSize); SegmentRing ring = new SegmentRing(initial, segSize); - SegmentManager manager = new SegmentManager(segSize, TimeUnit.SECONDS.toNanos(60)); + SegmentManager manager = new SegmentManager(segSize); CountDownLatch workerBlocked = new CountDownLatch(1); CountDownLatch releaseWorker = new CountDownLatch(1); AtomicBoolean fired = new AtomicBoolean(); @@ -225,7 +225,7 @@ public void testCloseDoesNotFreePathScratchWhenWorkerStillAlive() throws Excepti public void testStaleSnapshotEntrySkippedAfterDeregisterBeforeServiceClaim() throws Exception { TestUtils.assertMemoryLeak(() -> { long segSize = MmapSegment.HEADER_SIZE + (MmapSegment.FRAME_HEADER_SIZE + 32); - SegmentManager manager = new SegmentManager(segSize, TimeUnit.SECONDS.toNanos(60)); + SegmentManager manager = new SegmentManager(segSize); SegmentRing[] rings = new SegmentRing[3]; String[] slots = new String[3]; CountDownLatch workerBlocked = new CountDownLatch(1); @@ -354,7 +354,7 @@ public void testWorkerAloneFreesPathScratchAfterTimedOutClose() throws Exception Assert.assertEquals(0, Files.mkdir(slot, Files.DIR_MODE_DEFAULT)); MmapSegment initial = MmapSegment.create(slot + "/sf-initial.sfa", 0L, segSize); SegmentRing ring = new SegmentRing(initial, segSize); - SegmentManager manager = new SegmentManager(segSize, TimeUnit.SECONDS.toNanos(60)); + SegmentManager manager = new SegmentManager(segSize); CountDownLatch workerBlocked = new CountDownLatch(1); CountDownLatch releaseWorker = new CountDownLatch(1); AtomicBoolean fired = new AtomicBoolean(); @@ -438,7 +438,7 @@ public void testDeferUntilWorkerExitRunsCleanupAfterFinalPass() throws Exception Assert.assertEquals(0, Files.mkdir(slot, Files.DIR_MODE_DEFAULT)); MmapSegment initial = MmapSegment.create(slot + "/sf-initial.sfa", 0L, segSize); SegmentRing ring = new SegmentRing(initial, segSize); - SegmentManager manager = new SegmentManager(segSize, TimeUnit.SECONDS.toNanos(60)); + SegmentManager manager = new SegmentManager(segSize); CountDownLatch workerBlocked = new CountDownLatch(1); CountDownLatch releaseWorker = new CountDownLatch(1); CountDownLatch cleanupRan = new CountDownLatch(1); @@ -526,7 +526,7 @@ public void testAwaitRingQuiescenceBlocksWhileServicePassInFlight() throws Excep Assert.assertEquals(0, Files.mkdir(slot, Files.DIR_MODE_DEFAULT)); MmapSegment initial = MmapSegment.create(slot + "/sf-initial.sfa", 0L, segSize); SegmentRing ring = new SegmentRing(initial, segSize); - SegmentManager manager = new SegmentManager(segSize, TimeUnit.SECONDS.toNanos(60)); + SegmentManager manager = new SegmentManager(segSize); CountDownLatch workerBlocked = new CountDownLatch(1); CountDownLatch releaseWorker = new CountDownLatch(1); AtomicBoolean fired = new AtomicBoolean(); @@ -591,7 +591,7 @@ public void testInterruptedCallerDoesNotAbandonReapableWorker() throws Exception Assert.assertEquals(0, Files.mkdir(slot, Files.DIR_MODE_DEFAULT)); MmapSegment initial = MmapSegment.create(slot + "/sf-initial.sfa", 0L, segSize); SegmentRing ring = new SegmentRing(initial, segSize); - SegmentManager manager = new SegmentManager(segSize, TimeUnit.SECONDS.toNanos(60)); + SegmentManager manager = new SegmentManager(segSize); CountDownLatch workerBlocked = new CountDownLatch(1); CountDownLatch releaseWorker = new CountDownLatch(1); CountDownLatch closeReturned = new CountDownLatch(1); @@ -713,7 +713,7 @@ public void testSecondBoundedJoinReapsWorkerFinishingExitCleanups() throws Excep Assert.assertEquals(0, Files.mkdir(slot, Files.DIR_MODE_DEFAULT)); MmapSegment initial = MmapSegment.create(slot + "/sf-initial.sfa", 0L, segSize); SegmentRing ring = new SegmentRing(initial, segSize); - SegmentManager manager = new SegmentManager(segSize, TimeUnit.SECONDS.toNanos(60)); + SegmentManager manager = new SegmentManager(segSize); CountDownLatch cleanupEntered = new CountDownLatch(1); CountDownLatch releaseCleanup = new CountDownLatch(1); AtomicReference err = new AtomicReference<>(); diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerCrashConsistencyTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerCrashConsistencyTest.java index 126c79c84..29129ef33 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerCrashConsistencyTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerCrashConsistencyTest.java @@ -116,7 +116,7 @@ public void testBackgroundTrimDurabilityOrder() throws Exception { watermark = openWatermark(ff, root); Assert.assertNotNull(watermark); try (SegmentManager manager = new SegmentManager( - segmentSize, TimeUnit.SECONDS.toNanos(60), segmentSize * 8L, ff, ff::ticks)) { + segmentSize, segmentSize * 8L, ff, ff::ticks)) { manager.register(ring, root, watermark); ff.active = true; manager.start(); @@ -151,7 +151,7 @@ public void testBarrierFailuresPreserveCrashSafetyAndRetry() throws Exception { watermark = openWatermark(ff, root); Assert.assertNotNull(watermark); try (SegmentManager manager = new SegmentManager( - segmentSize, TimeUnit.SECONDS.toNanos(60), segmentSize * 8L, ff, ff::ticks)) { + segmentSize, segmentSize * 8L, ff, ff::ticks)) { manager.register(ring, root, watermark); ff.active = true; manager.start(); @@ -203,7 +203,7 @@ public void testDiskTrimWithoutWatermarkIsPreserved() throws Exception { ring = createRing(root, segmentSize, payload, 1); CountDownLatch servicePass = new CountDownLatch(1); try (SegmentManager manager = new SegmentManager( - segmentSize, TimeUnit.SECONDS.toNanos(60), segmentSize * 8L)) { + segmentSize, segmentSize * 8L)) { manager.setBeforeTrimSyncHook(servicePass::countDown); manager.register(ring, root, null); manager.start(); @@ -237,7 +237,7 @@ public void testMoreThanOneQuantumBatchesBarriers() throws Exception { watermark = openWatermark(ff, root); Assert.assertNotNull(watermark); try (SegmentManager manager = new SegmentManager( - segmentSize, TimeUnit.SECONDS.toNanos(60), segmentSize * 70L, ff, ff::ticks)) { + segmentSize, segmentSize * 70L, ff, ff::ticks)) { manager.register(ring, root, watermark); ff.active = true; manager.start(); @@ -279,7 +279,7 @@ public void testPersistentFailureDoesNotStarveSibling() throws Exception { badWatermark = openWatermark(ff, badRoot); goodWatermark = openWatermark(ff, goodRoot); try (SegmentManager manager = new SegmentManager( - segmentSize, TimeUnit.SECONDS.toNanos(60), segmentSize * 16L, ff, ff::ticks)) { + segmentSize, segmentSize * 16L, ff, ff::ticks)) { manager.register(badRing, badRoot, badWatermark); manager.register(goodRing, goodRoot, goodWatermark); ff.active = true; @@ -326,7 +326,7 @@ public void testPersistentFailuresBackOffAndRecover() throws Exception { AtomicInteger logs = new AtomicInteger(); AtomicInteger passes = new AtomicInteger(); try (SegmentManager manager = new SegmentManager( - segmentSize, TimeUnit.SECONDS.toNanos(60), segmentSize * 8L, ff, ff::ticks)) { + segmentSize, segmentSize * 8L, ff, ff::ticks)) { manager.setBeforeTrimSyncHook(passes::incrementAndGet); manager.setRetryLogHook(logs::incrementAndGet); manager.register(ring, root, watermark); diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerManifestFsyncTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerManifestFsyncTest.java index 8b45567bc..0c44f82d2 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerManifestFsyncTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerManifestFsyncTest.java @@ -83,7 +83,7 @@ public void testTrimPassCommitsManifestHeadOncePerBatch() throws Exception { watermark = openWatermark(ff, tmpDir); Assert.assertNotNull(watermark); try (SegmentManager manager = new SegmentManager( - SEGMENT_SIZE, TimeUnit.SECONDS.toNanos(60), SEGMENT_SIZE * 8L, ff)) { + SEGMENT_SIZE, SEGMENT_SIZE * 8L, ff)) { manager.register(ring, tmpDir, watermark); ff.active = true; manager.start(); diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerPeriodicSyncTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerPeriodicSyncTest.java index 967e64202..4b833e2ab 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerPeriodicSyncTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerPeriodicSyncTest.java @@ -64,7 +64,6 @@ public void testDeadlineAndFailurePropagation() throws Exception { manager = new SegmentManager( segmentSize, - SegmentManager.DEFAULT_POLL_NANOS, segmentSize * 4L, filesFacade, ticks::get); @@ -149,7 +148,6 @@ public void testRotationGateDefersRotationUntilPredecessorDurable() throws Excep filesFacade, dir + "/spare.sfa", 2L, segmentSize)); manager = new SegmentManager( segmentSize, - SegmentManager.DEFAULT_POLL_NANOS, segmentSize * 4L, filesFacade, ticks::get); @@ -250,7 +248,6 @@ public void testSyncPassStopsAtFirstFailureThenRetryCoversAllSegments() throws E manager = new SegmentManager( segmentSize, - SegmentManager.DEFAULT_POLL_NANOS, segmentSize * 8L, filesFacade, ticks::get); @@ -344,7 +341,6 @@ public void testPeriodicFrontierSkipsSealedPrefixOnceDurable() throws Exception manager = new SegmentManager( segmentSize, - SegmentManager.DEFAULT_POLL_NANOS, segmentSize * 8L, filesFacade, ticks::get); @@ -407,7 +403,6 @@ public void testTransientSyncFailureClearsOnNextSuccess() throws Exception { manager = new SegmentManager( segmentSize, - SegmentManager.DEFAULT_POLL_NANOS, segmentSize * 4L, filesFacade, ticks::get); @@ -555,7 +550,6 @@ public void testConsumedErrorRetryClearsLatchOnlyOverRedirtiedPages() throws Exc manager = new SegmentManager( segmentSize, - SegmentManager.DEFAULT_POLL_NANOS, segmentSize * 4L, filesFacade, ticks::get); diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerPinnedTrimTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerPinnedTrimTest.java index 8e8a90abd..f2b01222e 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerPinnedTrimTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerPinnedTrimTest.java @@ -47,7 +47,7 @@ public void testPinnedActiveSurvivesRotationUntilIoRelease() throws Exception { CountDownLatch trimPass = new CountDownLatch(1); try (SegmentManager manager = new SegmentManager( - segmentSize, TimeUnit.SECONDS.toNanos(60), segmentSize * 8L)) { + segmentSize, segmentSize * 8L)) { manager.setBeforeTrimSyncHook(trimPass::countDown); manager.register(ring, null); manager.start(); @@ -63,7 +63,7 @@ public void testPinnedActiveSurvivesRotationUntilIoRelease() throws Exception { ring.releasePinnedSegmentForTest(pinned); try (SegmentManager manager = new SegmentManager( - segmentSize, TimeUnit.SECONDS.toNanos(60), segmentSize * 8L)) { + segmentSize, segmentSize * 8L)) { manager.register(ring, null); manager.start(); awaitTrimmed(ring); diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerRecoveryCapTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerRecoveryCapTest.java index eed6bc878..571a62815 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerRecoveryCapTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerRecoveryCapTest.java @@ -85,7 +85,7 @@ public void testManagerHonorsCapAgainstRecoveredSegmentsOnRegister() throws Exce SegmentRing ring = SegmentRing.openExisting(slotDir, SEGMENT_SIZE); Assert.assertNotNull("recovery should produce a ring", ring); - SegmentManager manager = new SegmentManager(SEGMENT_SIZE, 1_000_000L /* 1ms */, cap); + SegmentManager manager = new SegmentManager(SEGMENT_SIZE, cap); try (SegmentManager ignored = manager) { manager.start(); manager.register(ring, slotDir); diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerSharedBudgetTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerSharedBudgetTest.java index 7efcc48ba..04fd938c5 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerSharedBudgetTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerSharedBudgetTest.java @@ -47,7 +47,6 @@ public class SegmentManagerSharedBudgetTest { private static final int FRAME_PAYLOAD = 64; - private static final long POLL_NANOS = 200_000L; // Exactly two frames per segment, so the rotation arithmetic below is exact. private static final long SEGMENT_SIZE = MmapSegment.HEADER_SIZE + 2 * (MmapSegment.FRAME_HEADER_SIZE + FRAME_PAYLOAD); @@ -70,7 +69,7 @@ public void testConcurrentManagersStayWithinBudgetAndReleaseEveryCharge() throws try { for (int i = 0; i < ringCount; i++) { rings[i] = newRing(); - managers[i] = new SegmentManager(SEGMENT_SIZE, POLL_NANOS, budget); + managers[i] = new SegmentManager(SEGMENT_SIZE, budget); managers[i].start(); managers[i].register(rings[i], null); } @@ -148,8 +147,8 @@ public void testRingsOfDifferentManagersShareOneBudget() throws Exception { long buf = Unsafe.malloc(FRAME_PAYLOAD, MemoryTag.NATIVE_DEFAULT); try (SegmentRing busy = newRing(); SegmentRing sibling = newRing(); - SegmentManager busyManager = new SegmentManager(SEGMENT_SIZE, POLL_NANOS, budget); - SegmentManager siblingManager = new SegmentManager(SEGMENT_SIZE, POLL_NANOS, budget)) { + SegmentManager busyManager = new SegmentManager(SEGMENT_SIZE, budget); + SegmentManager siblingManager = new SegmentManager(SEGMENT_SIZE, budget)) { busyManager.start(); siblingManager.start(); diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerSideFileCapTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerSideFileCapTest.java index 3006ec1b2..4c9d79fb7 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerSideFileCapTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerSideFileCapTest.java @@ -81,7 +81,7 @@ public void testCapCountsSideFileBytesAgainstProvisioning() throws Exception { SegmentRing ring = SegmentRing.openExisting(slotDir, SEGMENT_SIZE); Assert.assertNotNull("recovery should produce a ring", ring); - SegmentManager manager = new SegmentManager(SEGMENT_SIZE, 1_000_000L /* 1ms */, cap); + SegmentManager manager = new SegmentManager(SEGMENT_SIZE, cap); try (SegmentManager ignored = manager) { manager.start(); manager.register(ring, slotDir, null, 0L, () -> SEGMENT_SIZE); @@ -110,7 +110,7 @@ public void testControlWithoutSideFileGaugeProvisionsToTheCap() throws Exception SegmentRing ring = SegmentRing.openExisting(slotDir, SEGMENT_SIZE); Assert.assertNotNull("recovery should produce a ring", ring); - SegmentManager manager = new SegmentManager(SEGMENT_SIZE, 1_000_000L /* 1ms */, cap); + SegmentManager manager = new SegmentManager(SEGMENT_SIZE, cap); try (SegmentManager ignored = manager) { manager.start(); manager.register(ring, slotDir, null, 0L, null); @@ -146,7 +146,7 @@ public void testLivenessFloorProvisionsDespiteSideFileBytesOverTheCap() throws E SegmentRing ring = SegmentRing.openExisting(slotDir, SEGMENT_SIZE); Assert.assertNotNull("recovery should produce a ring", ring); - SegmentManager manager = new SegmentManager(SEGMENT_SIZE, 1_000_000L /* 1ms */, cap); + SegmentManager manager = new SegmentManager(SEGMENT_SIZE, cap); try (SegmentManager ignored = manager) { manager.start(); // 10 segments' worth of dictionary against a 3-segment cap: no diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerTest.java index 2381d8b14..07d8ce137 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerTest.java @@ -90,7 +90,7 @@ public void testAckBatchDoesNotDelaySiblingSpareProvisioning() throws Exception AtomicReference failure = new AtomicReference<>(); try (SegmentRing busyRing = new SegmentRing(MmapSegment.createInMemory(0, segSize), segSize); SegmentRing siblingRing = new SegmentRing(MmapSegment.createInMemory(0, segSize), segSize); - SegmentManager manager = new SegmentManager(segSize, TimeUnit.SECONDS.toNanos(60))) { + SegmentManager manager = new SegmentManager(segSize)) { for (int i = 0; i < sealedCount; i++) { assertEquals(i, busyRing.appendOrFsn(buf, 1)); busyRing.installHotSpare(MmapSegment.createInMemory(busyRing.nextSeqHint(), segSize)); @@ -144,7 +144,7 @@ public void testManagerProvisionsSpareWithinPollingTick() throws Exception { + 4 * (MmapSegment.FRAME_HEADER_SIZE + 32); MmapSegment seg0 = MmapSegment.create(tmpDir + "/0000000000000000.sfa", 0, segSize); try (SegmentRing ring = new SegmentRing(seg0, segSize); - SegmentManager mgr = new SegmentManager(segSize, 200_000L /* 0.2ms */)) { + SegmentManager mgr = new SegmentManager(segSize)) { mgr.start(); mgr.register(ring, tmpDir); @@ -163,7 +163,7 @@ public void testProducerCanRotateAcrossManySegmentsWithoutBackpressure() throws MmapSegment seg0 = MmapSegment.create(tmpDir + "/0000000000000000.sfa", 0, segSize); long buf = Unsafe.malloc(32, MemoryTag.NATIVE_DEFAULT); try (SegmentRing ring = new SegmentRing(seg0, segSize); - SegmentManager mgr = new SegmentManager(segSize, 200_000L)) { + SegmentManager mgr = new SegmentManager(segSize)) { mgr.start(); mgr.register(ring, tmpDir); @@ -202,7 +202,7 @@ public void testManagerTrimsAckedSegmentFiles() throws Exception { long buf = Unsafe.malloc(32, MemoryTag.NATIVE_DEFAULT); try (AckWatermark watermark = AckWatermark.open(tmpDir); SegmentRing ring = new SegmentRing(seg0, segSize); - SegmentManager mgr = new SegmentManager(segSize, 200_000L)) { + SegmentManager mgr = new SegmentManager(segSize)) { assertTrue("watermark must open", watermark != null); mgr.start(); mgr.register(ring, tmpDir, watermark); @@ -244,7 +244,7 @@ public void testRotationSpareCarriesManifestRequiredFlagOnDisk() throws Exceptio MmapSegment seg0 = MmapSegment.create(tmpDir + "/0000000000000000.sfa", 0, segSize); long buf = Unsafe.malloc(32, MemoryTag.NATIVE_DEFAULT); try (SegmentRing ring = new SegmentRing(seg0, segSize); - SegmentManager mgr = new SegmentManager(segSize, 200_000L)) { + SegmentManager mgr = new SegmentManager(segSize)) { mgr.start(); mgr.register(ring, tmpDir); @@ -281,7 +281,7 @@ public void testMaxTotalBytesCapBlocksProvisioningUntilTrimFrees() throws Except long buf = Unsafe.malloc(64, MemoryTag.NATIVE_DEFAULT); try (AckWatermark watermark = AckWatermark.open(tmpDir); SegmentRing ring = new SegmentRing(seg0, segSize); - SegmentManager mgr = new SegmentManager(segSize, 200_000L, cap)) { + SegmentManager mgr = new SegmentManager(segSize, cap)) { assertTrue("watermark must open", watermark != null); mgr.start(); // register seeds totalBytes = 1*segSize (initial active). @@ -327,9 +327,8 @@ public void testMaxTotalBytesCapBlocksProvisioningUntilTrimFrees() throws Except @Test public void testFirstSpareLandsBeforeFirstPoll() throws Exception { TestUtils.assertMemoryLeak(() -> { - // pollNanos is intentionally long enough that the 5s park can be - // ruled out as the mechanism by which the first spare arrives. - // register() unparks the worker after publishing the new ring, + // The worker never polls, so the first spare can only arrive via a + // wakeup. register() unparks the worker after publishing the new ring, // so the worker re-iterates and provisions the spare even when // its first loop snapshot ran before register() acquired `lock`. // The spare must therefore land within seconds of register(), @@ -343,13 +342,12 @@ public void testFirstSpareLandsBeforeFirstPoll() throws Exception { // testRotationWakeupTriggersImmediateSparePrep, and the // deterministic register-after-park case is covered by // testRegisterAfterWorkerParkedWakesWorker. - long pollNanos = 5_000_000_000L; // 5 seconds long segSize = MmapSegment.HEADER_SIZE + 4 * (MmapSegment.FRAME_HEADER_SIZE + 16); MmapSegment seg0 = MmapSegment.create(tmpDir + "/0000000000000000.sfa", 0, segSize); long buf = Unsafe.malloc(16, MemoryTag.NATIVE_DEFAULT); try (SegmentRing ring = new SegmentRing(seg0, segSize); - SegmentManager mgr = new SegmentManager(segSize, pollNanos)) { + SegmentManager mgr = new SegmentManager(segSize)) { mgr.start(); mgr.register(ring, tmpDir); long t0 = System.nanoTime(); @@ -380,12 +378,11 @@ public void testRegisterAfterWorkerParkedWakesWorker() throws Exception { // promptly because register() unparks the worker out of its park. // No append at all, so no producer-side wakeup can mask a missing // register-side wakeup. - long pollNanos = 5_000_000_000L; // 5 seconds long segSize = MmapSegment.HEADER_SIZE + 4 * (MmapSegment.FRAME_HEADER_SIZE + 16); MmapSegment seg0 = MmapSegment.create(tmpDir + "/0000000000000000.sfa", 0, segSize); try (SegmentRing ring = new SegmentRing(seg0, segSize); - SegmentManager mgr = new SegmentManager(segSize, pollNanos)) { + SegmentManager mgr = new SegmentManager(segSize)) { mgr.start(); // Give the worker plenty of time to enter workerLoop, snapshot // an empty rings list, and reach parkNanos. 250ms is far more @@ -408,13 +405,12 @@ public void testRotationWakeupTriggersImmediateSparePrep() throws Exception { TestUtils.assertMemoryLeak(() -> { // Segment small enough that one frame fills it; verifies that the // post-rotation wakeup runs before the next 5s poll. - long pollNanos = 5_000_000_000L; long segSize = MmapSegment.HEADER_SIZE + (MmapSegment.FRAME_HEADER_SIZE + 16); MmapSegment seg0 = MmapSegment.create(tmpDir + "/0000000000000000.sfa", 0, segSize); long buf = Unsafe.malloc(16, MemoryTag.NATIVE_DEFAULT); try (SegmentRing ring = new SegmentRing(seg0, segSize); - SegmentManager mgr = new SegmentManager(segSize, pollNanos)) { + SegmentManager mgr = new SegmentManager(segSize)) { mgr.start(); mgr.register(ring, tmpDir); // First spare via high-water signal on the very first append. @@ -443,7 +439,7 @@ public void testRotationWakeupTriggersImmediateSparePrep() throws Exception { @Test public void testCloseStopsWorkerAndIsIdempotent() throws Exception { TestUtils.assertMemoryLeak(() -> { - SegmentManager mgr = new SegmentManager(8192, 200_000L); + SegmentManager mgr = new SegmentManager(8192); mgr.start(); // Give the worker a moment to exist. Thread.sleep(50); @@ -466,7 +462,7 @@ public void testMultipleRingsServedByOneManager() throws Exception { SegmentRing ringA = new SegmentRing(MmapSegment.create(dirA + "/0000000000000000.sfa", 0, segSize), segSize); SegmentRing ringB = new SegmentRing(MmapSegment.create(dirB + "/0000000000000000.sfa", 0, segSize), segSize); SegmentRing ringC = new SegmentRing(MmapSegment.create(dirC + "/0000000000000000.sfa", 0, segSize), segSize); - SegmentManager mgr = new SegmentManager(segSize, 200_000L) + SegmentManager mgr = new SegmentManager(segSize) ) { mgr.start(); mgr.register(ringA, dirA); diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerTotalBytesRaceTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerTotalBytesRaceTest.java index 15b8e8a65..3ca7324de 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerTotalBytesRaceTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerTotalBytesRaceTest.java @@ -93,7 +93,7 @@ public void testInstallPathDoesNotCommitAfterDeregister() throws Exception { long maxTotal = segSize * 8L; try (SegmentManager mgr = new SegmentManager( - segSize, TimeUnit.SECONDS.toNanos(60), maxTotal)) { + segSize, maxTotal)) { String dir = tmpDir + "/single-ring"; assertEquals(0, Files.mkdir(dir, Files.DIR_MODE_DEFAULT)); String activePath = dir + "/sf-initial.sfa"; @@ -149,8 +149,8 @@ public void testInstallPathDoesNotCommitAfterDeregister() throws Exception { // Wait for the worker to park again. With the entry // deregistered, the next loop iteration finds rings - // empty and parks for the full 60 s pollNanos. That - // TIMED_WAITING transition is the test's signal that + // empty and parks until the next wakeup. That parked + // state is the test's signal that // the worker has finished serviceRing -- including the // stillRegistered re-check and the (skipped) commit -- // so readTotalBytes below observes the final state. diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerTrimDeregisterRaceTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerTrimDeregisterRaceTest.java index e99898af6..2d07eca1e 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerTrimDeregisterRaceTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerTrimDeregisterRaceTest.java @@ -88,12 +88,12 @@ public void testTrimPathDoesNotDoubleSubtractAfterDeregister() throws Exception long segSize = MmapSegment.HEADER_SIZE + (MmapSegment.FRAME_HEADER_SIZE + 32); long maxTotal = segSize * 8L; - // Large pollNanos: the worker parks until explicitly woken, so we - // own the tick boundaries. SegmentRing fires managerWakeup on + // The worker parks until explicitly woken, so we own the tick + // boundaries. SegmentRing fires managerWakeup on // appendOrFsn whenever a spare is needed, so the producer-driven // setup phase still gets prompt service. try (SegmentManager mgr = new SegmentManager( - segSize, TimeUnit.SECONDS.toNanos(60), maxTotal)) { + segSize, maxTotal)) { mgr.start(); String dir = tmpDir + "/single-ring"; @@ -167,9 +167,9 @@ public void testTrimPathDoesNotDoubleSubtractAfterDeregister() throws Exception // Wait for the worker to park again, which is our signal // that the trim block following the hook has finished // (drain + maybe-subtract + lock release + iteration - // exit). The 60 s pollNanos means TIMED_WAITING is a - // strong signal; with the entry deregistered, no further - // wakeups arrive. + // exit). The worker parks indefinitely between wakeups, so a + // parked state is a strong signal; with the entry + // deregistered, no further wakeups arrive. Thread worker = workerThread(mgr); awaitParked(worker); diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerUnlinkFailureTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerUnlinkFailureTest.java index b4b60d6fb..0e0691788 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerUnlinkFailureTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerUnlinkFailureTest.java @@ -79,7 +79,7 @@ public void testEnumerationFindNextFailureRefusesGenerationAllocation() throws E byte[] originalHigher = java.nio.file.Files.readAllBytes(Paths.get(higherPath)); FailingFilesFacade facade = new FailingFilesFacade(null, dir, lowerName); try (SegmentManager manager = new SegmentManager( - segmentSize, TimeUnit.SECONDS.toNanos(60), segmentSize * 4, facade)) { + segmentSize, segmentSize * 4, facade)) { try { manager.register(ring, dir); Assert.fail("register accepted a partially enumerated SF directory"); @@ -125,7 +125,7 @@ public void testFailedUnlinkRetainsBookkeepingAndUsesSuccessorPath() throws Exce FailingFilesFacade facade = new FailingFilesFacade(failedPath, null, null); AtomicLong ticks = new AtomicLong(); try (SegmentManager manager = new SegmentManager( - segmentSize, TimeUnit.SECONDS.toNanos(60), segmentSize * 8, facade, ticks::get)) { + segmentSize, segmentSize * 8, facade, ticks::get)) { manager.register(ring, dir, watermark); manager.start(); Assert.assertTrue("manager never attempted the injected unlink", @@ -164,7 +164,7 @@ public void testFailedUnlinkRetainsBookkeepingAndUsesSuccessorPath() throws Exce facade.allowUnlink(); try (SegmentManager retryManager = new SegmentManager( - segmentSize, TimeUnit.SECONDS.toNanos(60), segmentSize * 8, facade)) { + segmentSize, segmentSize * 8, facade)) { retryManager.register(ring, dir, watermark); retryManager.start(); retryManager.wakeWorker(); diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerWatermarkDeregisterRaceTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerWatermarkDeregisterRaceTest.java index 605b8defe..211afadd2 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerWatermarkDeregisterRaceTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerWatermarkDeregisterRaceTest.java @@ -88,7 +88,7 @@ public void testStaleWorkerDoesNotWriteThroughUnmappedWatermarkAfterDeregister() SegmentRing ring = new SegmentRing(seg0, segSize); AckWatermark watermark = AckWatermark.open(slot); assertNotNull(watermark); - SegmentManager mgr = new SegmentManager(segSize, TimeUnit.SECONDS.toNanos(60)); + SegmentManager mgr = new SegmentManager(segSize); long buf = Unsafe.malloc(32, MemoryTag.NATIVE_DEFAULT); boolean managerClosed = false; boolean watermarkStorageReleased = false; diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentRecoveryIntegrityTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentRecoveryIntegrityTest.java index 039e77de3..9abd3551c 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentRecoveryIntegrityTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentRecoveryIntegrityTest.java @@ -611,7 +611,7 @@ public long findFirst(String dir) { } }; SegmentManager manager = new SegmentManager( - SEGMENT_SIZE, SegmentManager.DEFAULT_POLL_NANOS, + SEGMENT_SIZE, SegmentManager.UNLIMITED_TOTAL_BYTES, facade); manager.start(); try { @@ -643,7 +643,7 @@ public long findFirst(String dir) { } }; SegmentManager manager = new SegmentManager( - SEGMENT_SIZE, SegmentManager.DEFAULT_POLL_NANOS, + SEGMENT_SIZE, SegmentManager.UNLIMITED_TOTAL_BYTES, facade); manager.start(); try { diff --git a/core/src/test/java/io/questdb/client/test/impl/SenderPoolSfTest.java b/core/src/test/java/io/questdb/client/test/impl/SenderPoolSfTest.java index 7744cd1a1..078f776a6 100644 --- a/core/src/test/java/io/questdb/client/test/impl/SenderPoolSfTest.java +++ b/core/src/test/java/io/questdb/client/test/impl/SenderPoolSfTest.java @@ -1626,7 +1626,7 @@ public void testSharedManagerPassCompletionRecoversRetiredPoolSlot() throws Exce long segSize = io.questdb.client.cutlass.qwp.client.sf.cursor.MmapSegment.HEADER_SIZE + io.questdb.client.cutlass.qwp.client.sf.cursor.MmapSegment.FRAME_HEADER_SIZE + 32L; - SegmentManager manager = new SegmentManager(segSize, TimeUnit.SECONDS.toNanos(60)); + SegmentManager manager = new SegmentManager(segSize); CountDownLatch cleanupFinished = new CountDownLatch(1); CountDownLatch workerBlocked = new CountDownLatch(1); CountDownLatch releaseWorker = new CountDownLatch(1); diff --git a/core/src/test/java/io/questdb/client/test/tools/TestWebSocketClient.java b/core/src/test/java/io/questdb/client/test/tools/TestWebSocketClient.java new file mode 100644 index 000000000..13965f41e --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/tools/TestWebSocketClient.java @@ -0,0 +1,76 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +package io.questdb.client.test.tools; + +import io.questdb.client.HttpClientConfiguration; +import io.questdb.client.cutlass.http.client.WebSocketClient; +import io.questdb.client.network.SocketFactory; + +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.locks.LockSupport; + +/** + * Base for {@link WebSocketClient} test doubles without a real socket. The + * idle wait honours {@link #wake()} like the real transport, but also returns + * after at most {@link #MAX_PARK_NANOS} so frames a scripted + * {@code tryReceiveFrame} produces "spontaneously" are still picked up. The + * bounded park is test-only; production blocks in the OS wait. + */ +public abstract class TestWebSocketClient extends WebSocketClient { + public static final long MAX_PARK_NANOS = 1_000_000L; + private final AtomicBoolean permit = new AtomicBoolean(); + private volatile Thread waiter; + + protected TestWebSocketClient(HttpClientConfiguration configuration, SocketFactory socketFactory) { + super(configuration, socketFactory); + } + + @Override + public boolean awaitReadableOrWake(int timeoutMillis) { + if (permit.getAndSet(false)) { + return true; + } + long parkNanos = MAX_PARK_NANOS; + if (timeoutMillis >= 0) { + parkNanos = Math.min(parkNanos, timeoutMillis * 1_000_000L); + } + waiter = Thread.currentThread(); + try { + LockSupport.parkNanos(this, parkNanos); + } finally { + waiter = null; + } + return permit.getAndSet(false); + } + + @Override + public void wake() { + permit.set(true); + Thread t = waiter; + if (t != null) { + LockSupport.unpark(t); + } + } +} diff --git a/design/tla/AckWait.cfg b/design/tla/AckWait.cfg new file mode 100644 index 000000000..17d8be998 --- /dev/null +++ b/design/tla/AckWait.cfg @@ -0,0 +1,4 @@ +SPECIFICATION Spec +CONSTANT Target = 3 +INVARIANT NoLostAck +PROPERTY Returns diff --git a/design/tla/AckWait.tla b/design/tla/AckWait.tla new file mode 100644 index 000000000..7947ce724 --- /dev/null +++ b/design/tla/AckWait.tla @@ -0,0 +1,71 @@ +-------------------------------- MODULE AckWait -------------------------------- +(* Producer-side ack wait: CursorWebSocketSendLoop.awaitAckProgress() and the *) +(* I/O thread's signalAckProgress(), replacing the 50us parkNanos poll in *) +(* QwpWebSocketSender.awaitAckedFsn()/drainOnClose(). *) +(* *) +(* LockSupport semantics: unpark sets a one-shot permit; park consumes it and *) +(* returns at once if it was already set. We ignore deadlines and spurious *) +(* wakeups -- both only make the wait return earlier, and the caller re-checks. *) +EXTENDS Naturals + +CONSTANT Target \* the FSN the producer waits for + +VARIABLES acked, \* engine.ackedFsn(), written by the I/O thread + seen, \* the watermark the producer read before deciding to wait + waiter, \* ackWaiter != null + permit, \* the producer thread's park permit + ppc, \* producer pc + iopc \* I/O thread pc + +vars == <> + +Init == /\ acked = 0 /\ seen = 0 /\ waiter = FALSE /\ permit = FALSE + /\ ppc = "read" /\ iopc = "idle" + +---------------------------------------------------------------------------- +(* Producer: QwpWebSocketSender loop body + awaitAckProgress(seen, deadline). *) +Read == /\ ppc = "read" /\ seen' = acked \* long seen = engine.ackedFsn() + /\ ppc' = (IF acked >= Target THEN "done" ELSE "setWaiter") + /\ UNCHANGED <> + +SetWaiter == /\ ppc = "setWaiter" /\ waiter' = TRUE /\ ppc' = "recheck" + /\ UNCHANGED <> + +Recheck == /\ ppc = "recheck" + /\ IF acked /= seen THEN waiter' = FALSE /\ ppc' = "read" + ELSE waiter' = waiter /\ ppc' = "park" + /\ UNCHANGED <> + +(* parkNanos: blocks until the permit is available, consumes it. *) +Park == /\ ppc = "park" /\ permit + /\ permit' = FALSE /\ waiter' = FALSE /\ ppc' = "read" + /\ UNCHANGED <> + +---------------------------------------------------------------------------- +(* I/O thread: engine.acknowledge() (volatile write) then signalAckProgress() *) +(* reads ackWaiter and unparks. Two steps: the write and the read are not *) +(* atomic together. *) +Ack == /\ iopc = "idle" /\ acked < Target + /\ acked' = acked + 1 /\ iopc' = "signal" + /\ UNCHANGED <> + +Signal == /\ iopc = "signal" + /\ permit' = (permit \/ waiter) /\ iopc' = "idle" + /\ UNCHANGED <> + +Next == Read \/ SetWaiter \/ Recheck \/ Park \/ Ack \/ Signal + \/ (ppc = "done" /\ acked = Target /\ UNCHANGED vars) + +Spec == Init /\ [][Next]_vars + /\ WF_vars(Read \/ SetWaiter \/ Recheck \/ Park) + /\ WF_vars(Ack \/ Signal) + +---------------------------------------------------------------------------- +(* SAFETY: the producer is never parked with unsignalled progress. If it is *) +(* parked and the watermark moved past what it saw, the permit is set or the *) +(* I/O thread is about to set it (it has the waiter flag visible). *) +NoLostAck == (ppc = "park" /\ acked /= seen) => (permit \/ (iopc = "signal" /\ waiter)) + +(* LIVENESS: reaching the target eventually returns the producer. *) +Returns == (acked >= Target) ~> (ppc = "done") +============================================================================= diff --git a/design/tla/IdleWait.tla b/design/tla/IdleWait.tla new file mode 100644 index 000000000..6fd76f707 --- /dev/null +++ b/design/tla/IdleWait.tla @@ -0,0 +1,167 @@ +------------------------------- MODULE IdleWait ------------------------------- +(* Idle-wait protocol of CursorWebSocketSendLoop (java-questdb-client). *) +(* *) +(* Threads: the producer (publishes frames, rings the doorbell), the I/O loop *) +(* (sends, receives acks, reconnects, blocks when idle) and the environment *) +(* (server acks, connection drops, close()). *) +(* *) +(* Doorbell = a loopback datagram pair polled with the socket: a STATEFUL flag *) +(* a wait consumes. parkedFlag = ioParked. snap = publishedBeforeWork. *) +(* recycled = recycledThisIteration (RecycleFix = FALSE models the bug found *) +(* by ReconnectTest: a reconnect rewinds the cursor and the loop blocks on it). *) +EXTENDS Naturals + +CONSTANTS MaxFrames, \* frames the producer publishes in total + MaxDrops, \* connection drops the environment may inject + RecycleFix \* TRUE: swapClient() counts as work for the iteration + +VARIABLES published, \* frames published (producer side, volatile publishedFsn) + sent, \* send cursor: frames sent on the current connection + acked, \* server-acknowledged watermark + bell, \* doorbell rung and not yet consumed + parkedFlag, \* ioParked + pc, \* I/O thread program counter + snap, \* publishedBeforeWork + recycled, \* recycledThisIteration + didWork, + running, \* loop not closed + prodPc, \* producer program counter + serverDrop, \* the current connection has been dropped by the peer + drops \* drops injected so far + +vars == <> + +Init == /\ published = 0 /\ sent = 0 /\ acked = 0 + /\ bell = FALSE /\ parkedFlag = FALSE + /\ pc = "top" /\ snap = 0 /\ recycled = FALSE /\ didWork = FALSE + /\ running = TRUE /\ prodPc = "idle" + /\ serverDrop = FALSE /\ drops = 0 + +---------------------------------------------------------------------------- +(* Producer: publish (volatile write), THEN read ioParked and ring. *) +Publish == /\ prodPc = "idle" /\ published < MaxFrames + /\ published' = published + 1 /\ prodPc' = "ring" + /\ UNCHANGED <> + +Ring == /\ prodPc = "ring" + /\ bell' = (bell \/ parkedFlag) \* wakeIoIfParked() + /\ prodPc' = "idle" + /\ UNCHANGED <> + +(* close(): drainOnClose() first (acked = published), then running := FALSE, *) +(* then ring unconditionally. *) +Close == /\ prodPc = "idle" /\ running /\ published = MaxFrames /\ acked = MaxFrames + /\ running' = FALSE /\ bell' = TRUE /\ prodPc' = "closed" + /\ UNCHANGED <> + +---------------------------------------------------------------------------- +(* Environment: the peer drops the connection. *) +ServerDrop == /\ ~serverDrop /\ drops < MaxDrops /\ running + /\ serverDrop' = TRUE /\ drops' = drops + 1 + /\ UNCHANGED <> + +---------------------------------------------------------------------------- +(* I/O thread. *) +Top == /\ pc = "top" + /\ IF running THEN snap' = published /\ pc' = "send" + ELSE snap' = snap /\ pc' = "done" + /\ UNCHANGED <> + +(* trySendOne(): send the next frame, or find nothing, or hit the drop. *) +SendOne == /\ pc = "send" /\ ~serverDrop /\ sent < published + /\ sent' = sent + 1 /\ didWork' = TRUE /\ pc' = "recv" + /\ UNCHANGED <> + +SendNone == /\ pc = "send" /\ ~serverDrop /\ sent = published + /\ didWork' = FALSE /\ pc' = "recv" + /\ UNCHANGED <> + +(* fail() -> connectLoop() -> swapClient(): cursor rewinds to acked+1. *) +Reconnect(from, to) == /\ pc = from /\ serverDrop + /\ sent' = acked /\ recycled' = TRUE /\ serverDrop' = FALSE + /\ didWork' = (IF from = "send" THEN FALSE ELSE didWork) + /\ pc' = to + /\ UNCHANGED <> + +(* tryReceiveAcks(): an ack arrives, or nothing is there, or EOF (drop). *) +RecvAck == /\ pc = "recv" /\ ~serverDrop /\ acked < sent + /\ acked' \in (acked + 1)..sent /\ didWork' = TRUE /\ pc' = "check" + /\ UNCHANGED <> + +RecvNone == /\ pc = "recv" /\ pc' = "check" + /\ UNCHANGED <> + +(* Idle decision, including the recycledThisIteration consumption. *) +Check == /\ pc = "check" + /\ LET work == didWork \/ (RecycleFix /\ recycled) + IN /\ recycled' = FALSE + /\ pc' = (IF work THEN "top" ELSE "setParked") + /\ UNCHANGED <> + +SetParked == /\ pc = "setParked" /\ parkedFlag' = TRUE /\ pc' = "recheck" + /\ UNCHANGED <> + +Recheck == /\ pc = "recheck" + /\ IF published /= snap + THEN parkedFlag' = FALSE /\ pc' = "top" + ELSE parkedFlag' = parkedFlag /\ pc' = "wait" + /\ UNCHANGED <> + +(* awaitReadableOrWake(): returns on doorbell, on socket readability (an ack *) +(* or EOF is coming because frames are in flight or the peer dropped), or *) +(* never otherwise. Consumes the doorbell. *) +Wake == /\ pc = "wait" + /\ (bell \/ acked < sent \/ serverDrop) + /\ bell' = FALSE /\ parkedFlag' = FALSE /\ pc' = "top" + /\ UNCHANGED <> + +IoStep == Top \/ SendOne \/ SendNone \/ Reconnect("send", "recv") + \/ RecvAck \/ RecvNone \/ Reconnect("recv", "check") + \/ Check \/ SetParked \/ Recheck \/ Wake + +Next == Publish \/ Ring \/ Close \/ ServerDrop \/ IoStep + \/ (pc = "done" /\ prodPc = "closed" /\ UNCHANGED vars) + +(* The I/O thread and the producer's ring are scheduled fairly; acks are *) +(* eventually delivered (strong: RecvNone may interleave). Drops are not *) +(* fair: the adversary may stop dropping, and is bounded anyway. *) +Fairness == /\ WF_vars(IoStep) /\ WF_vars(Ring) /\ WF_vars(Close) + /\ SF_vars(RecvAck) + +Spec == Init /\ [][Next]_vars /\ Fairness + +---------------------------------------------------------------------------- +(* SAFETY. Never block with unannounced work: whenever the I/O thread is in *) +(* the blocking wait and frames are unsent, either the doorbell is rung, or *) +(* the producer is one step from ringing it and will see the parked flag, or *) +(* the socket is about to become readable. *) +NoStrandedWork == + (pc = "wait" /\ sent < published) => + (bell \/ (prodPc = "ring" /\ parkedFlag) \/ serverDrop \/ acked < sent) + +(* The loop never waits on a cursor it rewound in the same iteration. *) +NoWaitAfterRecycle == pc = "wait" => ~recycled + +TypeOK == /\ published \in 0..MaxFrames /\ sent \in 0..MaxFrames + /\ acked \in 0..MaxFrames /\ acked <= sent /\ sent <= published + +(* LIVENESS. Every published frame is eventually acknowledged, and close() *) +(* eventually stops the loop. *) +AllAcked == \A f \in 1..MaxFrames : (published >= f) ~> (acked >= f) +Terminates == (~running) ~> (pc = "done") +============================================================================= diff --git a/design/tla/IdleWait_bug.cfg b/design/tla/IdleWait_bug.cfg new file mode 100644 index 000000000..52b315e2a --- /dev/null +++ b/design/tla/IdleWait_bug.cfg @@ -0,0 +1,6 @@ +SPECIFICATION Spec +CONSTANTS MaxFrames = 3 + MaxDrops = 2 + RecycleFix = FALSE +INVARIANTS TypeOK NoStrandedWork NoWaitAfterRecycle +PROPERTIES AllAcked Terminates diff --git a/design/tla/IdleWait_fix.cfg b/design/tla/IdleWait_fix.cfg new file mode 100644 index 000000000..1f5baece2 --- /dev/null +++ b/design/tla/IdleWait_fix.cfg @@ -0,0 +1,6 @@ +SPECIFICATION Spec +CONSTANTS MaxFrames = 3 + MaxDrops = 2 + RecycleFix = TRUE +INVARIANTS TypeOK NoStrandedWork NoWaitAfterRecycle +PROPERTIES AllAcked Terminates diff --git a/design/tla/README.md b/design/tla/README.md new file mode 100644 index 000000000..604df0c8b --- /dev/null +++ b/design/tla/README.md @@ -0,0 +1,28 @@ +# TLA+ models of the QWP I/O loop wake protocols + +The cursor I/O loop (`CursorWebSocketSendLoop`) does not poll: when idle it +blocks in `poll(2)` on the socket plus a stateful doorbell (eventfd, +kqueue EVFILT_USER or a Windows Event) and is woken by the doorbell, by socket readability, +or by a PING deadline. Producers waiting for acks park and are unparked by the +I/O thread. These two models check the hand-offs. + +| Model | What it checks | +|---|---| +| `IdleWait.tla` | Producer publish/ring vs. the loop's idle decision (`ioParked`, `publishedBeforeWork` re-check, `recycledThisIteration`), with server drops, reconnect cursor rewinds and `close()`. Invariants: `NoStrandedWork`, `NoWaitAfterRecycle`, `TypeOK`. Liveness: every published frame is acked (`AllAcked`), `close()` stops the loop (`Terminates`). | +| `AckWait.tla` | `awaitAckProgress()` / `signalAckProgress()`: the producer never parks on progress it has not been signalled about (`NoLostAck`); reaching the target returns the producer (`Returns`). | + +`IdleWait_bug.cfg` sets `RecycleFix = FALSE`, the design before a reconnect +counted as work for its iteration. TLC finds the stranded wait in 10 steps, +the hang `ReconnectTest.testReplayResendsUnackedFramesAcrossReconnect` hit. + +Run (needs `tla2tools.jar` from https://github.com/tlaplus/tlaplus/releases): + +``` +java -cp tla2tools.jar tlc2.TLC -workers 8 -config IdleWait_fix.cfg IdleWait.tla +java -cp tla2tools.jar tlc2.TLC -workers 8 -config IdleWait_bug.cfg IdleWait.tla +java -cp tla2tools.jar tlc2.TLC -config AckWait.cfg AckWait.tla +``` + +Modeling notes: one Java statement per step where threads interact (volatile +write, then flag read), coarser elsewhere. `MaxFrames` and `MaxDrops` bound +the state space; drops are adversarial but not fair, acks are strongly fair. From 39ac01e9974faeb8795209161f75ea86d51dac39 Mon Sep 17 00:00:00 2001 From: Jaromir Hamala Date: Wed, 21 Oct 2026 21:38:16 +0200 Subject: [PATCH 02/36] fix(qwp): keep the ack watermark current when ACKs land inside the active 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. --- .../qwp/client/sf/cursor/SegmentManager.java | 25 +++- .../qwp/client/sf/cursor/SegmentRing.java | 23 +++- .../SegmentManagerAckWatermarkLagTest.java | 113 ++++++++++++++++++ .../client/test/impl/SenderPoolSfTest.java | 6 +- 4 files changed, 158 insertions(+), 9 deletions(-) create mode 100644 core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerAckWatermarkLagTest.java diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentManager.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentManager.java index 3a0139c74..a11002431 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentManager.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentManager.java @@ -63,6 +63,13 @@ public final class SegmentManager implements QuietCloseable { * A wake from SegmentBudget.release() would make it fully event-driven. */ public static final long PROVISION_RETRY_NANOS = 10_000_000L; // 10 ms + /** + * Upper bound on how far the mmap ack watermark trails {@code ackedFsn} + * while ACKs keep landing inside the active segment. The first such ACK + * wakes the worker; it then re-parks on this cadence until the watermark + * is current, so a crash replays at most this much already-acked data. + */ + public static final long WATERMARK_LAG_NANOS = 1_000_000L; // 1 ms public static final long DISK_FULL_LOG_THROTTLE_NANOS = 30_000_000_000L; // 30 s public static final long UNLIMITED_TOTAL_BYTES = Long.MAX_VALUE; private static final int ENTRY_DEREGISTERED = 3; @@ -714,6 +721,9 @@ public void register(SegmentRing ring, String dir, AckWatermark watermark, long ring.enablePeriodicSync(); } } + if (watermark != null) { + ring.setPersistedAckFsn(-1L); // the first ACK wakes the worker to persist it + } ring.setManagerWakeup(managerWakeup); // Nudge the worker so it picks up the new ring on its very next // iteration. Without this, register-after-start has a race window: @@ -1142,6 +1152,7 @@ private boolean serviceRing0(RingEntry e) { if (currentAck > e.lastPersistedAck) { e.watermark.write(currentAck); e.lastPersistedAck = currentAck; + e.ring.setPersistedAckFsn(currentAck); } } } @@ -1210,6 +1221,7 @@ private boolean serviceRing0(RingEntry e) { if (durableAck > e.lastPersistedAck) { e.watermark.write(durableAck); e.lastPersistedAck = durableAck; + e.ring.setPersistedAckFsn(durableAck); } } try { @@ -1412,9 +1424,10 @@ private void workerLoop() { } // Event-driven idle: every state change the worker services // wakes it (register/deregister, rotation, high-water publish, - // an ACK that completes a sealed segment), so with nothing - // trimmable the only timed work left is periodic sync. Park - // until the nearest sync deadline, or indefinitely without one. + // the first ACK past the watermark, an ACK that completes a + // sealed segment). Timed work: periodic sync, a provision retry, + // and re-persisting the watermark while ACKs keep landing. + // Otherwise park indefinitely. long parkNanos = Long.MAX_VALUE; if (!hasMoreTrimmable) { long now = ticks.getAsLong(); @@ -1426,6 +1439,12 @@ private void workerLoop() { if (e.syncIntervalNanos > 0L) { parkNanos = Math.min(parkNanos, Math.max(0L, e.nextDataSyncNanos - now)); } + // Dekker with SegmentRing.acknowledge: it writes ackedFsn + // then reads persistedAckFsn; we wrote persistedAckFsn + // (in serviceRing) and read ackedFsn here. + if (e.watermark != null && e.ring.ackedFsn() > e.lastPersistedAck) { + parkNanos = Math.min(parkNanos, WATERMARK_LAG_NANOS); + } } } // Drop strong refs so a deregistered ring becomes collectable diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentRing.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentRing.java index 3cca230ce..7923696fb 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentRing.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentRing.java @@ -120,6 +120,12 @@ public final class SegmentRing implements QuietCloseable { // producer would otherwise hit BACKPRESSURE_NO_SPARE. private final long signalAtBytes; private volatile long ackedFsn = -1L; + // Highest FSN the manager has written to the slot's ack watermark; + // Long.MIN_VALUE until a manager registers one, so no ACK counts as "past + // it". acknowledge() wakes the manager on the first ACK past it; while it + // is stale the manager is already awake or on its WATERMARK_LAG_NANOS + // cadence, so later ACKs ring nothing. + private volatile long persistedAckFsn = Long.MIN_VALUE; // active: written by producer (constructor + appendOrFsn rotation), // read by I/O thread via getActive(). Volatile so the I/O thread sees // rotations promptly and never observes a torn reference. @@ -991,11 +997,12 @@ public boolean acknowledge(long seq) { if (seq > pub) { seq = pub; } - if (seq > ackedFsn) { + long before = ackedFsn; + if (seq > before) { ackedFsn = seq; - // Trim is ACK-driven: the manager no longer polls for it, so tell it - // when this ACK completes the oldest sealed segment. - if (firstTrimmable() != null) { + // The manager does not poll: wake it when this ACK is the first past + // the persisted watermark or completes the oldest sealed segment. + if (before <= persistedAckFsn || firstTrimmable() != null) { wakeManager(); } return true; @@ -1671,6 +1678,14 @@ public void setManagerWakeup(Runnable wakeup) { this.managerWakeup = wakeup; } + /** + * Manager-only: records the highest FSN written to the ack watermark, see + * {@link #acknowledge}. + */ + public void setPersistedAckFsn(long fsn) { + this.persistedAckFsn = fsn; + } + /** * Thread-safe snapshot of the current sealed-segment list. Copies * references into the caller-supplied {@code target} array (oldest diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerAckWatermarkLagTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerAckWatermarkLagTest.java new file mode 100644 index 000000000..9c771e39a --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerAckWatermarkLagTest.java @@ -0,0 +1,113 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +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.MemoryTag; +import io.questdb.client.std.Unsafe; +import io.questdb.client.test.tools.TestUtils; +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.TimeUnit; + +/** + * ACKs that land inside the active segment never complete a sealed segment, so + * nothing but the watermark cadence brings the manager back. The on-disk + * watermark must still catch up on its own: after a crash, recovery replays + * everything above it as duplicates. Regression for the event-driven worker. + */ +public class SegmentManagerAckWatermarkLagTest { + private String tmpDir; + + @Before + public void setUp() { + tmpDir = Paths.get(System.getProperty("java.io.tmpdir"), + "qdb-wm-lag-" + System.nanoTime()).toString(); + Assert.assertEquals(0, Files.mkdir(tmpDir, Files.DIR_MODE_DEFAULT)); + } + + @After + public void tearDown() { + long find = Files.findFirst(tmpDir); + if (find > 0) { + try { + int rc = 1; + while (rc > 0) { + String name = Files.utf8ToString(Files.findName(find)); + if (name != null && !".".equals(name) && !"..".equals(name)) { + Files.remove(tmpDir + "/" + name); + } + rc = Files.findNext(find); + } + } finally { + Files.findClose(find); + } + } + Files.remove(tmpDir); + } + + @Test(timeout = 20_000L) + public void testAcksInsideActiveSegmentReachWatermarkWithoutOtherActivity() throws Exception { + TestUtils.assertMemoryLeak(() -> { + long segmentSize = MmapSegment.HEADER_SIZE + 64 * (MmapSegment.FRAME_HEADER_SIZE + 32L); + MmapSegment initial = MmapSegment.create(tmpDir + "/sf-0000000000000000.sfa", 0L, segmentSize); + SegmentRing ring = new SegmentRing(initial, segmentSize); + AckWatermark watermark = AckWatermark.open(tmpDir); + Assert.assertNotNull(watermark); + long payload = Unsafe.malloc(32, MemoryTag.NATIVE_DEFAULT); + try (SegmentManager manager = new SegmentManager(segmentSize, segmentSize * 8)) { + manager.register(ring, tmpDir, watermark); + manager.start(); + // Three rounds: the first ACK after a fully persisted watermark + // must wake a worker that is parked without a timeout, every time. + for (int round = 0; round < 3; round++) { + for (int i = 0; i < 10; i++) { + Assert.assertTrue(ring.appendOrFsn(payload, 32) >= 0); + } + long acked = round * 10 + 9; + ring.acknowledge(acked); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (watermark.read() != acked && System.nanoTime() < deadline) { + Thread.sleep(1); + } + Assert.assertEquals("watermark did not catch up with ACKs in round " + round, + acked, watermark.read()); + } + } finally { + Unsafe.free(payload, 32, MemoryTag.NATIVE_DEFAULT); + watermark.close(); + ring.close(); + } + }); + } +} diff --git a/core/src/test/java/io/questdb/client/test/impl/SenderPoolSfTest.java b/core/src/test/java/io/questdb/client/test/impl/SenderPoolSfTest.java index 078f776a6..99ceb5198 100644 --- a/core/src/test/java/io/questdb/client/test/impl/SenderPoolSfTest.java +++ b/core/src/test/java/io/questdb/client/test/impl/SenderPoolSfTest.java @@ -1311,8 +1311,8 @@ public void testPoolRetiresAndRecoversSlotThroughRealManagerWorkerWedge() throws AtomicReference hookErr = new AtomicReference<>(); try { // Park the manager worker inside a service pass for - // the delegate's ring. The trim-sync point is reached - // on every ~1ms tick, so this wedges deterministically. + // the delegate's ring. The worker is event-driven, so + // wake it explicitly; the hook sits in every pass. manager.setBeforeTrimSyncHook(() -> { if (!fired.compareAndSet(false, true)) return; workerBlocked.countDown(); @@ -1325,6 +1325,7 @@ public void testPoolRetiresAndRecoversSlotThroughRealManagerWorkerWedge() throws hookErr.compareAndSet(null, t); } }); + manager.wakeWorker(); Assert.assertTrue("manager worker never entered a service pass", workerBlocked.await(5, TimeUnit.SECONDS)); @@ -4620,6 +4621,7 @@ private void assertPreallocatedExitHandoffCleansStartupRecoverer( hookErr.compareAndSet(null, t); } }); + manager.wakeWorker(); if (!workerBlocked.await(5, TimeUnit.SECONDS)) { throw new AssertionError("recovery manager never entered a service pass"); } From cf4f4b79ccd8e20ecef61d44cf1b28d650de9190 Mon Sep 17 00:00:00 2001 From: Jaromir Hamala Date: Wed, 21 Oct 2026 21:55:15 +0200 Subject: [PATCH 03/36] fix(qwp): wake every ack waiter, across loop and engine recycles 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(). --- .../qwp/client/QwpWebSocketSender.java | 126 ++++++++++-------- .../qwp/client/sf/cursor/AckWaiters.java | 72 ++++++++++ .../sf/cursor/CursorWebSocketSendLoop.java | 42 ++---- .../SymbolDictRecycleFsnContinuityTest.java | 60 +++++++++ design/tla/README.md | 2 +- 5 files changed, 213 insertions(+), 89 deletions(-) create mode 100644 core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/AckWaiters.java diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java index b2cf52114..fb97a2917 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java @@ -45,6 +45,7 @@ import io.questdb.client.cutlass.qwp.client.sf.cursor.BackgroundDrainerListener; import io.questdb.client.cutlass.qwp.client.sf.cursor.BackgroundDrainerPool; import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorSendCounters; +import io.questdb.client.cutlass.qwp.client.sf.cursor.AckWaiters; import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorSendEngine; import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorWebSocketSendLoop; import io.questdb.client.cutlass.qwp.client.sf.cursor.DefaultSenderConnectionListener; @@ -312,6 +313,8 @@ public class QwpWebSocketSender implements Sender { // symbolDictEpoch. private volatile CursorSendEngine cursorEngine; private volatile CursorWebSocketSendLoop cursorSendLoop; + // Threads parked for ack progress; outlives the loops and engines a recycle swaps. + private final AckWaiters ackWaiters = new AckWaiters(); // Sender-lifetime observability counters (see CursorSendCounters). Every // loop generation and every attached engine adopts this instance before // use, so the getTotal* accessors survive a symbol-dictionary recycle @@ -1522,24 +1525,23 @@ public boolean awaitAckedFsn(long targetFsn, long timeoutMillis) { return false; } long deadlineNanos = System.nanoTime() + timeoutMillis * 1_000_000L; + Thread me = Thread.currentThread(); while (true) { - // Read the watermark before the comparison: awaitAckProgress() returns - // at once if it has moved since, so no ack is slept through. - CursorSendEngine engine = cursorEngine; - long seen = engine == null ? -1L : engine.ackedFsn(); - if (getAckedFsn() >= targetFsn) { - return true; - } - checkAwaitAckedFsnErrors(); - long now = System.nanoTime(); - if (now >= deadlineNanos) { - return false; - } - CursorWebSocketSendLoop loop = cursorSendLoop; - if (loop == null) { - java.util.concurrent.locks.LockSupport.parkNanos(this, deadlineNanos - now); - } else { - loop.awaitAckProgress(seen, deadlineNanos); + // Register before the check: an ack that lands after it unparks us, + // one that landed before it is seen by the check. + ackWaiters.add(me); + try { + if (getAckedFsn() >= targetFsn) { + return true; + } + checkAwaitAckedFsnErrors(); + long remaining = deadlineNanos - System.nanoTime(); + if (remaining <= 0L) { + return false; + } + java.util.concurrent.locks.LockSupport.parkNanos(this, remaining); + } finally { + ackWaiters.remove(me); } } } @@ -3418,6 +3420,7 @@ public void setCursorEngine(CursorSendEngine engine, boolean takeOwnership) { @TestOnly public void setCursorSendLoopForTesting(CursorWebSocketSendLoop loop) { cursorSendLoop = loop; + loop.setAckProgressListener(ackWaiters::signal); if (connectionDispatcher == null) { connectionDispatcher = new SenderConnectionDispatcher( connectionListener, connectionListenerInboxCapacity); @@ -4882,50 +4885,56 @@ private void drainOnClose(boolean errorOwnedByCustomHandler, boolean[] restoreIn long deadlineNanos = System.nanoTime() + closeFlushTimeoutMillis * 1_000_000L; restoreInterrupt[0] |= Thread.interrupted(); try { + Thread me = Thread.currentThread(); while (true) { - long seen = cursorEngine.ackedFsn(); - if (seen >= target) { - break; - } - // PoolHousekeeper.stop() escalates to interrupt when this close runs past its join budget. A carried - // interrupt makes parkNanos return immediately without clearing the flag, turning the remainder of a - // close drain (up to 60s by default) into a full-core spin. Consume any later interrupt before the - // next paced wait, remember it, and restore the flag once the whole close exits. - if (Thread.interrupted()) { - restoreInterrupt[0] = true; - } - // Stop on a latched terminal (acks will never reach target); - // surface it only when no other channel already delivered it. - if (errorOwnedByCustomHandler) { - if (cursorSendLoop.getTerminalError() != null) { - return; + // Register before the check, see AckWaiters. + ackWaiters.add(me); + try { + if (cursorEngine.ackedFsn() >= target) { + break; } - } else { - cursorSendLoop.checkError(); - } - if (System.nanoTime() >= deadlineNanos) { - long acked = cursorEngine.ackedFsn(); - long externalTarget = fsnEpochBase + target; - long externalAcked = fsnEpochBase + acked; - // Name the outage the I/O thread is riding out, when there is one. A - // foreground sender now retries endpoint-policy rejections indefinitely, - // so a revoked token reaches the operator HERE, and blaming timeout - // tuning for what is actually an auth failure would misdirect them. - CursorWebSocketSendLoop loop = cursorSendLoop; - Throwable outage = loop == null ? null : loop.lastReconnectError(); - LOG.warn("close() drain timed out after {}ms [target={} acked={}], pending data may be lost{}", - closeFlushTimeoutMillis, externalTarget, externalAcked, - outage == null ? "" : "; wire is not draining: " + outage.getMessage()); - throw new LineSenderException("close() drain timed out after ") - .put(closeFlushTimeoutMillis).put(" ms [targetFsn=") - .put(externalTarget).put(", ackedFsn=").put(externalAcked) - .put("] - server did not acknowledge ") - .put(target - acked) - .put(outage == null - ? " pending batches; data may be lost (use larger closeFlushTimeoutMillis or smaller batches)" - : " pending batches; the wire is not draining: " + outage.getMessage()); + // PoolHousekeeper.stop() escalates to interrupt when this close runs past its join budget. A carried + // interrupt makes parkNanos return immediately without clearing the flag, turning the remainder of a + // close drain (up to 60s by default) into a full-core spin. Consume any later interrupt before the + // next paced wait, remember it, and restore the flag once the whole close exits. + if (Thread.interrupted()) { + restoreInterrupt[0] = true; + } + // Stop on a latched terminal (acks will never reach target); + // surface it only when no other channel already delivered it. + if (errorOwnedByCustomHandler) { + if (cursorSendLoop.getTerminalError() != null) { + return; + } + } else { + cursorSendLoop.checkError(); + } + if (System.nanoTime() >= deadlineNanos) { + long acked = cursorEngine.ackedFsn(); + long externalTarget = fsnEpochBase + target; + long externalAcked = fsnEpochBase + acked; + // Name the outage the I/O thread is riding out, when there is one. A + // foreground sender now retries endpoint-policy rejections indefinitely, + // so a revoked token reaches the operator HERE, and blaming timeout + // tuning for what is actually an auth failure would misdirect them. + CursorWebSocketSendLoop loop = cursorSendLoop; + Throwable outage = loop == null ? null : loop.lastReconnectError(); + LOG.warn("close() drain timed out after {}ms [target={} acked={}], pending data may be lost{}", + closeFlushTimeoutMillis, externalTarget, externalAcked, + outage == null ? "" : "; wire is not draining: " + outage.getMessage()); + throw new LineSenderException("close() drain timed out after ") + .put(closeFlushTimeoutMillis).put(" ms [targetFsn=") + .put(externalTarget).put(", ackedFsn=").put(externalAcked) + .put("] - server did not acknowledge ") + .put(target - acked) + .put(outage == null + ? " pending batches; data may be lost (use larger closeFlushTimeoutMillis or smaller batches)" + : " pending batches; the wire is not draining: " + outage.getMessage()); + } + java.util.concurrent.locks.LockSupport.parkNanos(this, deadlineNanos - System.nanoTime()); + } finally { + ackWaiters.remove(me); } - cursorSendLoop.awaitAckProgress(seen, deadlineNanos); } } finally { // Close the last-iteration race: an interrupt can land after the loop observes its final ACK but @@ -5051,6 +5060,7 @@ private void ensureConnected() { catchUpCapGapMinEscalationWindowMillis, CursorWebSocketSendLoop.ReconnectPolicy.FOREGROUND, fsnEpochBase); + cursorSendLoop.setAckProgressListener(ackWaiters::signal); // Sender-lifetime counters: adopted before start(), like the // dispatchers, so the fresh loop keeps counting where the recycle's // outgoing loop stopped. diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/AckWaiters.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/AckWaiters.java new file mode 100644 index 000000000..f0818427e --- /dev/null +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/AckWaiters.java @@ -0,0 +1,72 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +package io.questdb.client.cutlass.qwp.client.sf.cursor; + +import java.util.Arrays; +import java.util.concurrent.locks.LockSupport; + +/** + * Threads parked for ack progress: the producer in a close drain plus any + * monitor threads in {@code awaitAckedFsn}. Owned by the Sender, so it outlives + * the I/O loops and engines a recycle replaces. A waiter registers, re-checks + * its condition, parks, deregisters; the I/O thread advances the watermark and + * then calls {@link #signal()}. Either the waiter sees the new value or the + * signal sees the waiter. {@link #signal()} allocates nothing. + */ +public final class AckWaiters { + private static final Thread[] NONE = new Thread[0]; + private volatile Thread[] waiters = NONE; + + public synchronized void add(Thread t) { + Thread[] w = waiters; + Thread[] n = Arrays.copyOf(w, w.length + 1); + n[w.length] = t; + waiters = n; + } + + public synchronized void remove(Thread t) { + Thread[] w = waiters; + for (int i = 0; i < w.length; i++) { + if (w[i] == t) { + Thread[] n = new Thread[w.length - 1]; + System.arraycopy(w, 0, n, 0, i); + System.arraycopy(w, i + 1, n, i, w.length - i - 1); + waiters = n; + return; + } + } + } + + /** + * Unparks every registered waiter. Safe from any thread; spurious wakes are + * fine because waiters re-check their condition. + */ + public void signal() { + Thread[] w = waiters; + for (int i = 0; i < w.length; i++) { + LockSupport.unpark(w[i]); + } + } +} diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoop.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoop.java index 295b69a7c..9f3eeeb0a 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoop.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoop.java @@ -66,8 +66,8 @@ * to the server. *

  • Receives server ACK frames; on each ACK with cumulative wire sequence * {@code N}, calls {@code engine.acknowledge(fsnAtZero + N)} so the - * segment manager can trim fully-acked segments, and unparks a producer - * waiting in {@link #awaitAckProgress}.
  • + * segment manager can trim fully-acked segments, and runs the + * {@link #setAckProgressListener ack-progress listener}. *
  • On wire failure, runs the configured reconnect policy: capped * exponential backoff with jitter, retried indefinitely (Invariant B -- * a store-and-forward drainer never gives up on a wall-clock budget). @@ -492,10 +492,9 @@ public final class CursorWebSocketSendLoop implements QuietCloseable { // is why a revoked token surfaced to operators as "sf_max_total_bytes too small". private volatile Throwable lastReconnectError; private volatile Thread ioThread; - // The one producer thread (Sender is single-threaded) parked in - // awaitAckProgress(), or null. The I/O thread unparks it on every ack - // advance and on a terminal latch, so producers never poll ackedFsn. - private volatile Thread ackWaiter; + // Run on every ack advance, on a terminal latch and on close(): the Sender + // installs AckWaiters::signal so threads waiting for acks never poll. + private volatile Runnable ackProgressListener; // True while the I/O thread is committed to blocking in awaitIdle(). The // engine's publish hook rings the doorbell only when this is set, so a busy // loop costs the producer no syscall per flush. Dekker-ordered with the @@ -2525,35 +2524,18 @@ private void dispatchProgress(long ackedFsn) { } private void signalAckProgress() { - Thread w = ackWaiter; - if (w != null) { - LockSupport.unpark(w); + Runnable l = ackProgressListener; + if (l != null) { + l.run(); } } /** - * Parks the calling producer thread until the engine's acked FSN moves past - * {@code seenAckedFsn}, a terminal error is latched, the loop stops, or - * {@code deadlineNanos} passes -- whichever comes first. Spurious returns - * are possible; callers re-check and call again. {@code seenAckedFsn} must - * be a value the caller read before deciding to wait, so an ack - * landing in between is detected by the re-check below rather than lost. + * Installs the callback run after every ack advance, on a terminal latch + * and on {@link #close()}; see {@link AckWaiters}. */ - public void awaitAckProgress(long seenAckedFsn, long deadlineNanos) { - ackWaiter = Thread.currentThread(); - try { - if (engine.ackedFsn() != seenAckedFsn || terminalError != null) { - return; - } - // A stopped loop (close() raced us) has already unparked any earlier - // waiter; a later one simply waits out its own deadline rather than spin. - long remaining = deadlineNanos - System.nanoTime(); - if (remaining > 0L) { - LockSupport.parkNanos(this, remaining); - } - } finally { - ackWaiter = null; - } + public void setAckProgressListener(Runnable listener) { + this.ackProgressListener = listener; } /** diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleFsnContinuityTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleFsnContinuityTest.java index 5c77f3403..7ebbfd1db 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleFsnContinuityTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleFsnContinuityTest.java @@ -470,8 +470,12 @@ public void testAwaitAckedFsnParkedAcrossARecycleSeesTheFreshEpochAck() throws E Assert.assertEquals("the fresh epoch's first FSN continues the external scale", target, ws.flushAndGetSequence()); + long t0 = System.nanoTime(); monitor.join(15_000); Assert.assertFalse("monitor still parked", monitor.isAlive()); + long joinMillis = (System.nanoTime() - t0) / 1_000_000L; + Assert.assertTrue("monitor returned " + joinMillis + " ms after the ack: it slept to its " + + "deadline instead of being woken across the recycle", joinMillis < 2_000); if (monitorError.get() != null) { throw new AssertionError("monitor failed", monitorError.get()); } @@ -482,6 +486,62 @@ public void testAwaitAckedFsnParkedAcrossARecycleSeesTheFreshEpochAck() throws E }); } + /** + * Several threads may wait for the same ack (the producer plus monitors); + * every one of them must be woken by it, not just the last to register. + */ + @Test(timeout = 60_000L) + public void testEveryWaiterIsWokenByTheAck() throws Exception { + assertMemoryLeak(() -> { + try (TestWebSocketServer server = ackingServer()) { + String config = cfg(server) + "auto_flush_bytes=off;auto_flush_rows=1000000;auto_flush_interval=60000;"; + try (QwpWebSocketSender ws = (QwpWebSocketSender) Sender.fromConfig(config)) { + ws.table("t").longColumn("v", 1L).atNow(); + long ext0 = ws.flushAndGetSequence(); + Assert.assertTrue(ws.awaitAckedFsn(ext0, 5_000)); + final long target = ext0 + 1; + + Thread[] monitors = new Thread[3]; + long[] returnedAtNanos = new long[monitors.length]; + AtomicReference monitorError = new AtomicReference<>(); + for (int i = 0; i < monitors.length; i++) { + final int idx = i; + monitors[i] = new Thread(() -> { + try { + if (!ws.awaitAckedFsn(target, 10_000)) { + monitorError.compareAndSet(null, new AssertionError("monitor timed out")); + } + returnedAtNanos[idx] = System.nanoTime(); + } catch (Throwable t) { + monitorError.compareAndSet(null, t); + } + }, "acked-fsn-monitor-" + i); + monitors[i].start(); + } + for (Thread m : monitors) { + waitFor(() -> m.getState() == Thread.State.TIMED_WAITING, 5_000); + } + + ws.table("t").longColumn("v", 2L).atNow(); + long ackedAtNanos = System.nanoTime(); + Assert.assertEquals(target, ws.flushAndGetSequence()); + for (Thread m : monitors) { + m.join(15_000); + Assert.assertFalse("monitor still parked", m.isAlive()); + } + if (monitorError.get() != null) { + throw new AssertionError("monitor failed", monitorError.get()); + } + for (int i = 0; i < monitors.length; i++) { + long lateMillis = (returnedAtNanos[i] - ackedAtNanos) / 1_000_000L; + Assert.assertTrue("monitor " + i + " returned " + lateMillis + " ms after the ack", + lateMillis < 2_000); + } + } + } + }); + } + private static TestWebSocketServer ackingServer() throws Exception { TestWebSocketServer server = new TestWebSocketServer(new AckAllHandler()); server.start(); diff --git a/design/tla/README.md b/design/tla/README.md index 604df0c8b..0cc4afe15 100644 --- a/design/tla/README.md +++ b/design/tla/README.md @@ -9,7 +9,7 @@ I/O thread. These two models check the hand-offs. | Model | What it checks | |---|---| | `IdleWait.tla` | Producer publish/ring vs. the loop's idle decision (`ioParked`, `publishedBeforeWork` re-check, `recycledThisIteration`), with server drops, reconnect cursor rewinds and `close()`. Invariants: `NoStrandedWork`, `NoWaitAfterRecycle`, `TypeOK`. Liveness: every published frame is acked (`AllAcked`), `close()` stops the loop (`Terminates`). | -| `AckWait.tla` | `awaitAckProgress()` / `signalAckProgress()`: the producer never parks on progress it has not been signalled about (`NoLostAck`); reaching the target returns the producer (`Returns`). | +| `AckWait.tla` | The `AckWaiters` hand-off (register, re-check, park vs. advance, signal): a waiter never parks on progress it has not been signalled about (`NoLostAck`); reaching the target returns it (`Returns`). One waiter modelled; the registry holds any number, each following the same protocol. | `IdleWait_bug.cfg` sets `RecycleFix = FALSE`, the design before a reconnect counted as work for its iteration. TLC finds the stranded wait in 10 steps, From 5d7f6babe9a34a5651b39a7911e8f2cd6f78a5eb Mon Sep 17 00:00:00 2001 From: Jaromir Hamala Date: Wed, 21 Oct 2026 22:12:05 +0200 Subject: [PATCH 04/36] fix(qwp): key the I/O loop's idle re-check on the completed publish, 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. --- .../client/sf/cursor/CursorSendEngine.java | 9 +++ .../sf/cursor/CursorWebSocketSendLoop.java | 11 +-- .../qwp/client/sf/cursor/SegmentRing.java | 14 ++++ design/tla/IdleWait.tla | 81 +++++++++++-------- design/tla/IdleWait_bug.cfg | 1 + design/tla/IdleWait_fix.cfg | 1 + design/tla/IdleWait_stampbug.cfg | 7 ++ design/tla/README.md | 7 +- 8 files changed, 92 insertions(+), 39 deletions(-) create mode 100644 design/tla/IdleWait_stampbug.cfg diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorSendEngine.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorSendEngine.java index 899c62ae8..4ec4b3c70 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorSendEngine.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorSendEngine.java @@ -1864,6 +1864,15 @@ public long publishedFsn() { return ring.publishedFsn(); } + /** + * I/O thread accessor: changes after every completed publish (FSN and + * bytes). The idle re-check in the I/O loop keys on this, see + * {@link SegmentRing#publishStamp()}. + */ + public long publishStamp() { + return ring.publishStamp(); + } + /** * I/O thread accessor: sealed segments waiting to drain. Direct view — * NOT thread-safe under producer-thread rotation. The I/O loop should diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoop.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoop.java index 9f3eeeb0a..4cdc81727 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoop.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoop.java @@ -498,9 +498,10 @@ public final class CursorWebSocketSendLoop implements QuietCloseable { // True while the I/O thread is committed to blocking in awaitIdle(). The // engine's publish hook rings the doorbell only when this is set, so a busy // loop costs the producer no syscall per flush. Dekker-ordered with the - // publishedFsn re-check in ioLoop: the I/O thread sets the flag BEFORE its - // final publishedFsn read, the producer publishes BEFORE reading the flag, - // so a publish that misses the flag is seen by the re-check and vice versa. + // publishStamp re-check in ioLoop: the I/O thread sets the flag BEFORE its + // final publishStamp read, the producer completes the publish (FSN, bytes, + // stamp) BEFORE reading the flag, so a publish that misses the flag is seen + // by the re-check and vice versa. private volatile boolean ioParked; // I/O-thread-only. Set by swapClient(): a reconnect rewinds the send cursor // to ackedFsn+1, which creates sendable work from inside the I/O thread -- @@ -2708,7 +2709,7 @@ private void ioLoop() { while (running) { // Snapshot before the send attempt: a publish after this point is // caught by the re-check below, so the idle decision cannot race it. - long publishedBeforeWork = engine.publishedFsn(); + long publishedBeforeWork = engine.publishStamp(); boolean didWork = trySendOne(); // 1. Try to send next frame(s). // 2. Try to receive ACKs. @@ -2745,7 +2746,7 @@ private void ioLoop() { if (!didWork && running) { ioParked = true; try { - if (engine.publishedFsn() == publishedBeforeWork) { + if (engine.publishStamp() == publishedBeforeWork) { awaitIdle(); } } finally { diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentRing.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentRing.java index 7923696fb..cf040971f 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentRing.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentRing.java @@ -158,6 +158,12 @@ public final class SegmentRing implements QuietCloseable { private long nextSeq; private boolean periodicSyncEnabled; private volatile long publishedFsn; + // Bumped after the LAST store of a publish (the bytes, publishWritten()). + // The I/O loop snapshots this, not publishedFsn, before deciding to block: + // publishedFsn is stored before the bytes, so a loop that saw the new FSN + // but not yet the bytes would find nothing to send and, re-checking + // publishedFsn alone, block on a frame nobody announces. + private volatile long publishStamp; private volatile boolean syncRequested; // Plain (producer-thread-only) flag; set to true the first time we ask // the manager for a spare for the current active segment, cleared on @@ -1117,6 +1123,7 @@ public long appendOrFsn(long payloadAddr, int payloadLen) { // publishedOffset() for an instant, but never behind it. publishedFsn = fsn; target.publishWritten(); + publishStamp = publishStamp + 1; // single producer if (!rotated && !wakeupRequestedForActive && hotSpare == null @@ -1505,6 +1512,13 @@ public long nextSeqHint() { * instant, never behind them. Returns -1 when nothing has been appended yet. * Volatile read; safe to call from any thread. */ + /** + * Changes after every completed publish; see the field comment. + */ + public long publishStamp() { + return publishStamp; + } + public long publishedFsn() { return publishedFsn; } diff --git a/design/tla/IdleWait.tla b/design/tla/IdleWait.tla index 6fd76f707..6a401ab19 100644 --- a/design/tla/IdleWait.tla +++ b/design/tla/IdleWait.tla @@ -5,17 +5,22 @@ (* (sends, receives acks, reconnects, blocks when idle) and the environment *) (* (server acks, connection drops, close()). *) (* *) -(* Doorbell = a loopback datagram pair polled with the socket: a STATEFUL flag *) -(* a wait consumes. parkedFlag = ioParked. snap = publishedBeforeWork. *) +(* Doorbell = eventfd / EVFILT_USER / Event polled with the socket: a STATEFUL *) +(* flag a wait consumes. parkedFlag = ioParked. snap = publishedBeforeWork. *) (* recycled = recycledThisIteration (RecycleFix = FALSE models the bug found *) (* by ReconnectTest: a reconnect rewinds the cursor and the loop blocks on it). *) +(* A publish is TWO volatile stores, publishedFsn then the bytes; the loop can *) +(* only send below the bytes. StampFix = TRUE snapshots publishStamp (bumped *) +(* after the bytes); FALSE snapshots publishedFsn, the review-found stranding. *) EXTENDS Naturals CONSTANTS MaxFrames, \* frames the producer publishes in total MaxDrops, \* connection drops the environment may inject - RecycleFix \* TRUE: swapClient() counts as work for the iteration + RecycleFix, \* TRUE: swapClient() counts as work for the iteration + StampFix \* TRUE: the idle re-check keys on publishStamp -VARIABLES published, \* frames published (producer side, volatile publishedFsn) +VARIABLES published, \* frames whose FSN is published (volatile publishedFsn) + bytes, \* frames whose bytes are published (publishedOffset) sent, \* send cursor: frames sent on the current connection acked, \* server-acknowledged watermark bell, \* doorbell rung and not yet consumed @@ -29,59 +34,67 @@ VARIABLES published, \* frames published (producer side, volatile publishedFsn) serverDrop, \* the current connection has been dropped by the peer drops \* drops injected so far -vars == <> -Init == /\ published = 0 /\ sent = 0 /\ acked = 0 +Init == /\ published = 0 /\ bytes = 0 /\ sent = 0 /\ acked = 0 /\ bell = FALSE /\ parkedFlag = FALSE /\ pc = "top" /\ snap = 0 /\ recycled = FALSE /\ didWork = FALSE /\ running = TRUE /\ prodPc = "idle" /\ serverDrop = FALSE /\ drops = 0 ---------------------------------------------------------------------------- -(* Producer: publish (volatile write), THEN read ioParked and ring. *) -Publish == /\ prodPc = "idle" /\ published < MaxFrames - /\ published' = published + 1 /\ prodPc' = "ring" - /\ UNCHANGED <> +(* Producer: store publishedFsn, store the bytes (+ stamp), THEN read ioParked *) +(* and ring. Key = what the loop snapshots. *) +Key == IF StampFix THEN bytes ELSE published + +PublishFsn == /\ prodPc = "idle" /\ published < MaxFrames + /\ published' = published + 1 /\ prodPc' = "bytes" + /\ UNCHANGED <> + +PublishBytes == /\ prodPc = "bytes" + /\ bytes' = published /\ prodPc' = "ring" + /\ UNCHANGED <> Ring == /\ prodPc = "ring" /\ bell' = (bell \/ parkedFlag) \* wakeIoIfParked() /\ prodPc' = "idle" - /\ UNCHANGED <> (* close(): drainOnClose() first (acked = published), then running := FALSE, *) (* then ring unconditionally. *) Close == /\ prodPc = "idle" /\ running /\ published = MaxFrames /\ acked = MaxFrames /\ running' = FALSE /\ bell' = TRUE /\ prodPc' = "closed" - /\ UNCHANGED <> ---------------------------------------------------------------------------- (* Environment: the peer drops the connection. *) ServerDrop == /\ ~serverDrop /\ drops < MaxDrops /\ running /\ serverDrop' = TRUE /\ drops' = drops + 1 - /\ UNCHANGED <> ---------------------------------------------------------------------------- (* I/O thread. *) Top == /\ pc = "top" - /\ IF running THEN snap' = published /\ pc' = "send" + /\ IF running THEN snap' = Key /\ pc' = "send" ELSE snap' = snap /\ pc' = "done" - /\ UNCHANGED <> (* trySendOne(): send the next frame, or find nothing, or hit the drop. *) -SendOne == /\ pc = "send" /\ ~serverDrop /\ sent < published +SendOne == /\ pc = "send" /\ ~serverDrop /\ sent < bytes /\ sent' = sent + 1 /\ didWork' = TRUE /\ pc' = "recv" - /\ UNCHANGED <> -SendNone == /\ pc = "send" /\ ~serverDrop /\ sent = published +SendNone == /\ pc = "send" /\ ~serverDrop /\ sent = bytes /\ didWork' = FALSE /\ pc' = "recv" - /\ UNCHANGED <> (* fail() -> connectLoop() -> swapClient(): cursor rewinds to acked+1. *) @@ -89,17 +102,17 @@ Reconnect(from, to) == /\ pc = from /\ serverDrop /\ sent' = acked /\ recycled' = TRUE /\ serverDrop' = FALSE /\ didWork' = (IF from = "send" THEN FALSE ELSE didWork) /\ pc' = to - /\ UNCHANGED <> (* tryReceiveAcks(): an ack arrives, or nothing is there, or EOF (drop). *) RecvAck == /\ pc = "recv" /\ ~serverDrop /\ acked < sent /\ acked' \in (acked + 1)..sent /\ didWork' = TRUE /\ pc' = "check" - /\ UNCHANGED <> RecvNone == /\ pc = "recv" /\ pc' = "check" - /\ UNCHANGED <> (* Idle decision, including the recycledThisIteration consumption. *) @@ -107,18 +120,18 @@ Check == /\ pc = "check" /\ LET work == didWork \/ (RecycleFix /\ recycled) IN /\ recycled' = FALSE /\ pc' = (IF work THEN "top" ELSE "setParked") - /\ UNCHANGED <> SetParked == /\ pc = "setParked" /\ parkedFlag' = TRUE /\ pc' = "recheck" - /\ UNCHANGED <> Recheck == /\ pc = "recheck" - /\ IF published /= snap + /\ IF Key /= snap THEN parkedFlag' = FALSE /\ pc' = "top" ELSE parkedFlag' = parkedFlag /\ pc' = "wait" - /\ UNCHANGED <> (* awaitReadableOrWake(): returns on doorbell, on socket readability (an ack *) @@ -127,20 +140,21 @@ Recheck == /\ pc = "recheck" Wake == /\ pc = "wait" /\ (bell \/ acked < sent \/ serverDrop) /\ bell' = FALSE /\ parkedFlag' = FALSE /\ pc' = "top" - /\ UNCHANGED <> IoStep == Top \/ SendOne \/ SendNone \/ Reconnect("send", "recv") \/ RecvAck \/ RecvNone \/ Reconnect("recv", "check") \/ Check \/ SetParked \/ Recheck \/ Wake -Next == Publish \/ Ring \/ Close \/ ServerDrop \/ IoStep +Next == PublishFsn \/ PublishBytes \/ Ring \/ Close \/ ServerDrop \/ IoStep \/ (pc = "done" /\ prodPc = "closed" /\ UNCHANGED vars) (* The I/O thread and the producer's ring are scheduled fairly; acks are *) (* eventually delivered (strong: RecvNone may interleave). Drops are not *) (* fair: the adversary may stop dropping, and is bounded anyway. *) -Fairness == /\ WF_vars(IoStep) /\ WF_vars(Ring) /\ WF_vars(Close) +Fairness == /\ WF_vars(IoStep) /\ WF_vars(PublishBytes) /\ WF_vars(Ring) + /\ WF_vars(Close) /\ SF_vars(RecvAck) Spec == Init /\ [][Next]_vars /\ Fairness @@ -151,14 +165,15 @@ Spec == Init /\ [][Next]_vars /\ Fairness (* the producer is one step from ringing it and will see the parked flag, or *) (* the socket is about to become readable. *) NoStrandedWork == - (pc = "wait" /\ sent < published) => + (pc = "wait" /\ sent < bytes) => (bell \/ (prodPc = "ring" /\ parkedFlag) \/ serverDrop \/ acked < sent) (* The loop never waits on a cursor it rewound in the same iteration. *) NoWaitAfterRecycle == pc = "wait" => ~recycled -TypeOK == /\ published \in 0..MaxFrames /\ sent \in 0..MaxFrames - /\ acked \in 0..MaxFrames /\ acked <= sent /\ sent <= published +TypeOK == /\ published \in 0..MaxFrames /\ bytes \in 0..MaxFrames + /\ sent \in 0..MaxFrames /\ acked \in 0..MaxFrames + /\ acked <= sent /\ sent <= bytes /\ bytes <= published (* LIVENESS. Every published frame is eventually acknowledged, and close() *) (* eventually stops the loop. *) diff --git a/design/tla/IdleWait_bug.cfg b/design/tla/IdleWait_bug.cfg index 52b315e2a..357198ae0 100644 --- a/design/tla/IdleWait_bug.cfg +++ b/design/tla/IdleWait_bug.cfg @@ -2,5 +2,6 @@ SPECIFICATION Spec CONSTANTS MaxFrames = 3 MaxDrops = 2 RecycleFix = FALSE + StampFix = TRUE INVARIANTS TypeOK NoStrandedWork NoWaitAfterRecycle PROPERTIES AllAcked Terminates diff --git a/design/tla/IdleWait_fix.cfg b/design/tla/IdleWait_fix.cfg index 1f5baece2..31d832a8d 100644 --- a/design/tla/IdleWait_fix.cfg +++ b/design/tla/IdleWait_fix.cfg @@ -2,5 +2,6 @@ SPECIFICATION Spec CONSTANTS MaxFrames = 3 MaxDrops = 2 RecycleFix = TRUE + StampFix = TRUE INVARIANTS TypeOK NoStrandedWork NoWaitAfterRecycle PROPERTIES AllAcked Terminates diff --git a/design/tla/IdleWait_stampbug.cfg b/design/tla/IdleWait_stampbug.cfg new file mode 100644 index 000000000..b43ff5b0c --- /dev/null +++ b/design/tla/IdleWait_stampbug.cfg @@ -0,0 +1,7 @@ +SPECIFICATION Spec +CONSTANTS MaxFrames = 3 + MaxDrops = 2 + RecycleFix = TRUE + StampFix = FALSE +INVARIANTS TypeOK NoStrandedWork NoWaitAfterRecycle +PROPERTIES AllAcked Terminates diff --git a/design/tla/README.md b/design/tla/README.md index 0cc4afe15..30fd3cfbb 100644 --- a/design/tla/README.md +++ b/design/tla/README.md @@ -8,18 +8,23 @@ I/O thread. These two models check the hand-offs. | Model | What it checks | |---|---| -| `IdleWait.tla` | Producer publish/ring vs. the loop's idle decision (`ioParked`, `publishedBeforeWork` re-check, `recycledThisIteration`), with server drops, reconnect cursor rewinds and `close()`. Invariants: `NoStrandedWork`, `NoWaitAfterRecycle`, `TypeOK`. Liveness: every published frame is acked (`AllAcked`), `close()` stops the loop (`Terminates`). | +| `IdleWait.tla` | Producer publish (two stores: `publishedFsn`, then the bytes) and ring vs. the loop's idle decision (`ioParked`, `publishStamp` re-check, `recycledThisIteration`), with server drops, reconnect cursor rewinds and `close()`. Invariants: `NoStrandedWork`, `NoWaitAfterRecycle`, `TypeOK`. Liveness: every published frame is acked (`AllAcked`), `close()` stops the loop (`Terminates`). | | `AckWait.tla` | The `AckWaiters` hand-off (register, re-check, park vs. advance, signal): a waiter never parks on progress it has not been signalled about (`NoLostAck`); reaching the target returns it (`Returns`). One waiter modelled; the registry holds any number, each following the same protocol. | `IdleWait_bug.cfg` sets `RecycleFix = FALSE`, the design before a reconnect counted as work for its iteration. TLC finds the stranded wait in 10 steps, the hang `ReconnectTest.testReplayResendsUnackedFramesAcrossReconnect` hit. +`IdleWait_stampbug.cfg` sets `StampFix = FALSE`: the re-check keys on +`publishedFsn`, which the producer stores before the bytes, so a loop that +saw the FSN but not the bytes blocks on a frame nobody announces (found in +review with a forced interleaving). Run (needs `tla2tools.jar` from https://github.com/tlaplus/tlaplus/releases): ``` java -cp tla2tools.jar tlc2.TLC -workers 8 -config IdleWait_fix.cfg IdleWait.tla java -cp tla2tools.jar tlc2.TLC -workers 8 -config IdleWait_bug.cfg IdleWait.tla +java -cp tla2tools.jar tlc2.TLC -workers 8 -config IdleWait_stampbug.cfg IdleWait.tla java -cp tla2tools.jar tlc2.TLC -config AckWait.cfg AckWait.tla ``` From 3119a0ee0f7c6b281e88c912fdb9e0befb67ef65 Mon Sep 17 00:00:00 2001 From: Jaromir Hamala Date: Wed, 21 Oct 2026 23:11:23 +0200 Subject: [PATCH 05/36] fix(qwp): never block on the fd while the TLS socket holds buffered input 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. --- .../cutlass/http/client/WebSocketClient.java | 5 + .../client/network/JavaTlsClientSocket.java | 13 +- .../io/questdb/client/network/Socket.java | 11 ++ .../qwp/client/TlsLargeAckIdleWaitTest.java | 120 ++++++++++++++++++ 4 files changed, 148 insertions(+), 1 deletion(-) create mode 100644 core/src/test/java/io/questdb/client/test/cutlass/qwp/client/TlsLargeAckIdleWaitTest.java 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 68b2c2d09..985606036 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 @@ -1494,6 +1494,11 @@ protected void dieWaiting(int n) { * into a handshake or send wait. */ public boolean awaitReadableOrWake(int timeoutMillis) { + if (socket.hasBufferedInput()) { + // TLS: decrypted or undecrypted input already sits in user space, + // invisible to poll(2). Report readable without blocking. + return true; + } int n = Net.pollReadable(socket.getFd(), doorbell, timeoutMillis); if (n < 0) { throw new HttpClientException("internal error: poll failure [errno=").put(nf.errno()).put(']'); diff --git a/core/src/main/java/io/questdb/client/network/JavaTlsClientSocket.java b/core/src/main/java/io/questdb/client/network/JavaTlsClientSocket.java index 4649b596c..05d746f7d 100644 --- a/core/src/main/java/io/questdb/client/network/JavaTlsClientSocket.java +++ b/core/src/main/java/io/questdb/client/network/JavaTlsClientSocket.java @@ -67,6 +67,9 @@ public final class JavaTlsClientSocket implements Socket { private int state = STATE_EMPTY; private long unwrapInputBufferPtr; private long unwrapOutputBufferPtr; + // A complete TLS record was left in unwrapInputBuffer because the caller's + // buffer was full; see hasBufferedInput(). + private boolean unprocessedRecordPending; private long wrapOutputBufferPtr; JavaTlsClientSocket(NetworkFacade nf, Logger log, ClientTlsConfiguration tlsConfig) { @@ -172,6 +175,11 @@ public TlsStateForTesting snapshotTlsStateForTesting() { return new TlsStateForTesting(this); } + @Override + public boolean hasBufferedInput() { + return unwrapOutputBuffer.position() != 0 || unprocessedRecordPending; + } + @Override public int recv(long bufferPtr, int bufferLen) { assert sslEngine != null; @@ -181,6 +189,7 @@ public int recv(long bufferPtr, int bufferLen) { if (unwrapOutputBuffer.position() != 0) { return drainUnwrapOutputBuffer(bufferPtr, bufferLen); } + unprocessedRecordPending = false; // Fast path: have the SSLEngine decrypt straight into the caller's buffer. We only fall // back to the internal spill buffer if a single TLS record does not fit in the caller's @@ -226,7 +235,9 @@ public int recv(long bufferPtr, int bufferLen) { case BUFFER_OVERFLOW: if (output.position() != 0) { // Output already has plaintext: hand it to the caller and let the - // unprocessed record be picked up on the next recv() call. + // unprocessed record be picked up on the next recv() call. The fd may + // be empty by then, so record that we hold progress the kernel cannot see. + unprocessedRecordPending = true; return spilling ? drainUnwrapOutputBuffer(bufferPtr, bufferLen) : output.position(); } if (spilling) { diff --git a/core/src/main/java/io/questdb/client/network/Socket.java b/core/src/main/java/io/questdb/client/network/Socket.java index 6fceb30bd..bbb356b18 100644 --- a/core/src/main/java/io/questdb/client/network/Socket.java +++ b/core/src/main/java/io/questdb/client/network/Socket.java @@ -107,6 +107,17 @@ default void closeTraffic() { */ boolean supportsTls(); + /** + * True when a {@link #recv} call would make progress without the kernel: + * input already read from the fd sits in user-space buffers (decrypted + * plaintext, or a complete TLS record not yet unwrapped). A caller that is + * about to block on fd readability must check this first; the kernel + * cannot see those bytes. + */ + default boolean hasBufferedInput() { + return false; + } + /** * Reads or writes encrypted data to/from the internal buffer from/to * the socket. Can be called safely even if the socket doesn't diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/TlsLargeAckIdleWaitTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/TlsLargeAckIdleWaitTest.java new file mode 100644 index 000000000..30397eba8 --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/TlsLargeAckIdleWaitTest.java @@ -0,0 +1,120 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +package io.questdb.client.test.cutlass.qwp.client; + +import io.questdb.client.Sender; +import io.questdb.client.cutlass.qwp.client.QwpWebSocketSender; +import io.questdb.client.test.cutlass.qwp.websocket.TestWebSocketServer; +import io.questdb.client.test.tools.TestUtils; +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.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Over TLS a server frame larger than the client's receive buffer is partly + * decrypted into user-space buffers that {@code poll(2)} cannot see. The I/O + * loop must not block on the fd while such input is pending, or the ACK is + * never processed and {@code awaitAckedFsn}/{@code close()} time out. Plain + * ws is the control. + */ +public class TlsLargeAckIdleWaitTest { + + @Test(timeout = 60_000L) + public void testLargeAckOverTlsIsProcessedWhileIdle() throws Exception { + run(true, 150 * 1024); + } + + @Test(timeout = 60_000L) + public void testLargeAckOverPlainWsIsProcessedWhileIdle() throws Exception { + run(false, 150 * 1024); + } + + private static void run(boolean tls, int ackBytes) throws Exception { + TestUtils.assertMemoryLeak(() -> { + BigAckHandler handler = new BigAckHandler(ackBytes); + try (TestWebSocketServer server = tls ? TestWebSocketServer.tls(handler) : new TestWebSocketServer(handler)) { + server.start(); + Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); + String cfg = (tls ? "wss::addr=localhost:" + server.getPort() + ";tls_verify=unsafe_off;" + : "ws::addr=localhost:" + server.getPort() + ";") + + "auto_flush_bytes=off;auto_flush_rows=1000000;auto_flush_interval=60000;"; + try (QwpWebSocketSender sender = (QwpWebSocketSender) Sender.fromConfig(cfg)) { + sender.table("t").longColumn("v", 1L).atNow(); + long fsn = sender.flushAndGetSequence(); + long t0 = System.nanoTime(); + Assert.assertTrue("ACK of " + ackBytes + " bytes was not processed (tls=" + tls + ")", + sender.awaitAckedFsn(fsn, 5_000)); + long millis = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - t0); + Assert.assertTrue("ACK took " + millis + " ms", millis < 2_000); + } + } + }); + } + + /** + * ACKs every frame with a STATUS_OK carrying enough table entries to reach + * the requested payload size. + */ + private static final class BigAckHandler implements TestWebSocketServer.WebSocketServerHandler { + private final int ackBytes; + private final AtomicLong seq = new AtomicLong(); + + BigAckHandler(int ackBytes) { + this.ackBytes = ackBytes; + } + + @Override + public void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] data) { + try { + client.sendBinary(buildAck(seq.getAndIncrement(), ackBytes)); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + // STATUS_OK: status u8 | sequence u64 | tableCount u16 | [nameLen u16 + name + seqTxn u64] * tableCount + private static byte[] buildAck(long sequence, int targetBytes) { + byte[] name = "table_name_padding_0000".getBytes(StandardCharsets.UTF_8); + int entry = 2 + name.length + 8; + int count = Math.max(1, (targetBytes - 11) / entry); + ByteBuffer bb = ByteBuffer.allocate(11 + count * entry).order(ByteOrder.LITTLE_ENDIAN); + bb.put((byte) 0x00); + bb.putLong(sequence); + bb.putShort((short) count); + for (int i = 0; i < count; i++) { + bb.putShort((short) name.length); + bb.put(name); + bb.putLong(i); + } + return bb.array(); + } + } +} From 98ac6ae412e747388f8bfe9e7e09a9c7720f91d3 Mon Sep 17 00:00:00 2001 From: Jaromir Hamala Date: Wed, 21 Oct 2026 23:15:53 +0200 Subject: [PATCH 06/36] fix(qwp): retry a failed segment trim without waiting for an external 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. --- .../qwp/client/sf/cursor/SegmentManager.java | 7 ++- .../SegmentManagerUnlinkFailureTest.java | 43 +++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentManager.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentManager.java index a11002431..54c353a29 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentManager.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentManager.java @@ -1426,8 +1426,8 @@ private void workerLoop() { // wakes it (register/deregister, rotation, high-water publish, // the first ACK past the watermark, an ACK that completes a // sealed segment). Timed work: periodic sync, a provision retry, - // and re-persisting the watermark while ACKs keep landing. - // Otherwise park indefinitely. + // a failed trim's retry, and re-persisting the watermark while + // ACKs keep landing. Otherwise park indefinitely. long parkNanos = Long.MAX_VALUE; if (!hasMoreTrimmable) { long now = ticks.getAsLong(); @@ -1439,6 +1439,9 @@ private void workerLoop() { if (e.syncIntervalNanos > 0L) { parkNanos = Math.min(parkNanos, Math.max(0L, e.nextDataSyncNanos - now)); } + if (e.trimRetryDelayNanos != 0L) { + parkNanos = Math.min(parkNanos, Math.max(0L, e.trimRetryAtNanos - now)); + } // Dekker with SegmentRing.acknowledge: it writes ackedFsn // then reads persistedAckFsn; we wrote persistedAckFsn // (in serviceRing) and read ackedFsn here. diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerUnlinkFailureTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerUnlinkFailureTest.java index 0e0691788..7638f8802 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerUnlinkFailureTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerUnlinkFailureTest.java @@ -100,6 +100,49 @@ public void testEnumerationFindNextFailureRefusesGenerationAllocation() throws E }); } + /** + * A failed trim schedules its own retry: with no ACK, append or + * {@code wakeWorker()} after the failure, the worker must still come back + * on the retry backoff rather than park indefinitely. + */ + @Test(timeout = 15_000L) + public void testFailedUnlinkIsRetriedWithoutExternalWakeup() throws Exception { + TestUtils.assertMemoryLeak(() -> { + long segmentSize = MmapSegment.HEADER_SIZE + MmapSegment.FRAME_HEADER_SIZE + 32L; + String dir = tmpDir + "/unlink-idle"; + Assert.assertEquals(0, Files.mkdir(dir, Files.DIR_MODE_DEFAULT)); + String failedPath = dir + "/sf-0000000000000000.sfa"; + MmapSegment initial = MmapSegment.create(failedPath, 0L, segmentSize); + SegmentRing ring = new SegmentRing(initial, segmentSize); + AckWatermark watermark = AckWatermark.open(dir); + Assert.assertNotNull(watermark); + watermark.write(-1L); + long payload = Unsafe.malloc(32, MemoryTag.NATIVE_DEFAULT); + try { + fill(payload, 32, (byte) 0x11); + Assert.assertEquals(0L, ring.appendOrFsn(payload, 32)); + ring.installHotSpare(MmapSegment.create(dir + "/sf-0000000000000001.sfa", 1L, segmentSize)); + Assert.assertEquals(1L, ring.appendOrFsn(payload, 32)); + // A spare is already in place, so provisioning schedules no retry of its own. + ring.installHotSpare(MmapSegment.create(dir + "/sf-0000000000000002.sfa", 2L, segmentSize)); + ring.acknowledge(0L); + FailingFilesFacade facade = new FailingFilesFacade(failedPath, null, null); + try (SegmentManager manager = new SegmentManager(segmentSize, segmentSize * 8, facade)) { + manager.register(ring, dir, watermark); + manager.start(); + Assert.assertTrue("manager never attempted the injected unlink", + facade.removeAttempted.await(5, TimeUnit.SECONDS)); + Assert.assertTrue("manager never retried the failed unlink on its own", + facade.removeRetried.await(5, TimeUnit.SECONDS)); + } + } finally { + Unsafe.free(payload, 32, MemoryTag.NATIVE_DEFAULT); + watermark.close(); + ring.close(); + } + }); + } + @Test(timeout = 15_000L) public void testFailedUnlinkRetainsBookkeepingAndUsesSuccessorPath() throws Exception { TestUtils.assertMemoryLeak(() -> { From b5e6d529600630b7eeddf01ca326612fbffe70f1 Mon Sep 17 00:00:00 2001 From: Jaromir Hamala Date: Wed, 21 Oct 2026 23:20:05 +0200 Subject: [PATCH 07/36] fix(qwp): do not block after a reconnect triggered by a failed PING 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. --- .../sf/cursor/CursorWebSocketSendLoop.java | 6 +- .../CursorWebSocketSendLoopIdleWaitTest.java | 90 +++++++++++++++++-- 2 files changed, 86 insertions(+), 10 deletions(-) diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoop.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoop.java index 4cdc81727..f03940662 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoop.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoop.java @@ -2742,8 +2742,10 @@ private void ioLoop() { // 5. Nothing to do: block until the socket has bytes (an ACK), // a producer publishes a frame (engine rings the doorbell via // setIoWakeup), close() rings it, or the next PING is due. - // No spinning: an idle connection costs zero CPU. - if (!didWork && running) { + // No spinning: an idle connection costs zero CPU. A PING that + // failed in 3/4 reconnected and rewound the cursor, which set + // recycledThisIteration again: go back to trySendOne instead. + if (!didWork && running && !recycledThisIteration) { ioParked = true; try { if (engine.publishStamp() == publishedBeforeWork) { diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopIdleWaitTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopIdleWaitTest.java index efe34e774..5791923f8 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopIdleWaitTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopIdleWaitTest.java @@ -25,6 +25,7 @@ package io.questdb.client.test.cutlass.qwp.client.sf.cursor; import io.questdb.client.DefaultHttpClientConfiguration; +import io.questdb.client.cutlass.http.client.HttpClientException; 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; @@ -140,15 +141,15 @@ public void testIdleLoopBlocksAndPublishWakesIt() throws Exception { * Regression: a reconnect rewinds the send cursor from inside the I/O * thread. That creates sendable work that no producer publish and no * inbound byte will ever announce, so the loop must not block on it. Here - * the first transport dies on its first receive after the send; the replay - * must reach the replacement transport with the producer completely idle. + * the first transport's send throws, so the iteration did no work at all; + * the replay must reach the replacement transport with the producer idle. */ @Test public void testReplayAfterReconnectIsNotStrandedByIdleWait() throws Exception { TestUtils.assertMemoryLeak(() -> { try (CursorSendEngine engine = new CursorSendEngine(tmpDir, 16384)) { BlockingDoorbellClient first = new BlockingDoorbellClient(); - first.failOnFirstReceiveAfterSend = true; + first.failFirstSend = true; BlockingDoorbellClient replacement = new BlockingDoorbellClient(); CursorWebSocketSendLoop loop = new CursorWebSocketSendLoop( first, engine, 0L, @@ -165,7 +166,48 @@ public void testReplayAfterReconnectIsNotStrandedByIdleWait() throws Exception { Thread.sleep(1); } assertEquals("frame must be replayed on the replacement transport", 1, replacement.sentFrames.get()); - assertEquals("original transport sent the frame once", 1, first.sentFrames.get()); + assertEquals("original transport never completed a send", 0, first.sentFrames.get()); + } finally { + loop.close(); + } + } + }); + } + + /** + * Same stranding through the PING path: in durable-ack mode the loop PINGs + * while OKs await durable confirmation (steps 3/4), and a PING that throws + * reconnects and rewinds the cursor AFTER the iteration consumed + * recycledThisIteration. Step 5 must not block on that rewound cursor. + */ + @Test + public void testReplayAfterPingFailureIsNotStrandedByIdleWait() throws Exception { + TestUtils.assertMemoryLeak(() -> { + try (CursorSendEngine engine = new CursorSendEngine(tmpDir, 16384)) { + BlockingDoorbellClient first = new BlockingDoorbellClient(); + first.deliverOkWithPendingTable = true; + first.failPing = true; + BlockingDoorbellClient replacement = new BlockingDoorbellClient(); + CursorWebSocketSendLoop loop = new CursorWebSocketSendLoop( + first, engine, 0L, + () -> replacement, + 1L, 10L, + true, + CursorWebSocketSendLoop.DEFAULT_DURABLE_ACK_KEEPALIVE_INTERVAL_MILLIS, + CursorWebSocketSendLoop.DEFAULT_MAX_HEAD_FRAME_REJECTIONS); + try { + loop.start(); + appendFrame(engine); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (first.pingAttempts.get() == 0 && System.nanoTime() < deadline) { + Thread.sleep(1); + } + assertTrue("durable-ack PING never attempted", first.pingAttempts.get() > 0); + while (replacement.sentFrames.get() < 1 && System.nanoTime() < deadline) { + Thread.sleep(1); + } + assertEquals("frame must be replayed on the replacement transport after the PING failure", + 1, replacement.sentFrames.get()); } finally { loop.close(); } @@ -194,7 +236,13 @@ private static final class BlockingDoorbellClient extends WebSocketClient { final AtomicInteger receiveAttempts = new AtomicInteger(); final AtomicInteger sentFrames = new AtomicInteger(); final AtomicInteger waits = new AtomicInteger(); - volatile boolean failOnFirstReceiveAfterSend; + final AtomicInteger pingAttempts = new AtomicInteger(); + volatile boolean failFirstSend; + volatile boolean failPing; + // Answer the first sent frame with a STATUS_OK naming one table, so a + // durable-ack loop has a pending entry and starts PINGing. + volatile boolean deliverOkWithPendingTable; + private boolean okDelivered; private final Semaphore doorbell = new Semaphore(0); BlockingDoorbellClient() { @@ -226,15 +274,41 @@ public void wake() { @Override public void sendBinary(long dataPtr, int length) { + if (failFirstSend) { + failFirstSend = false; + throw new HttpClientException("peer disconnect [errno=104]"); + } sentFrames.incrementAndGet(); } + @Override + public void sendPing() { + pingAttempts.incrementAndGet(); + if (failPing) { + throw new HttpClientException("timed out [errno=11]"); + } + } + @Override public boolean tryReceiveFrame(WebSocketFrameHandler handler) { receiveAttempts.incrementAndGet(); - if (failOnFirstReceiveAfterSend && sentFrames.get() > 0) { - failOnFirstReceiveAfterSend = false; - throw new io.questdb.client.cutlass.http.client.HttpClientException("peer disconnect [errno=104]"); + if (deliverOkWithPendingTable && !okDelivered && sentFrames.get() > 0) { + okDelivered = true; + // STATUS_OK | sequence 0 | tableCount 1 | nameLen 1 '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; } From 05221f21b7adcd6e140b82689b3399d649ccfc05 Mon Sep 17 00:00:00 2001 From: Jaromir Hamala Date: Wed, 21 Oct 2026 23:22:36 +0200 Subject: [PATCH 08/36] perf(qwp): register ack waiters once per call 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. --- .../qwp/client/QwpWebSocketSender.java | 25 +++++++++---------- .../qwp/client/sf/cursor/AckWaiters.java | 14 ++++++++--- 2 files changed, 22 insertions(+), 17 deletions(-) diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java index fb97a2917..a63683d78 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java @@ -1526,11 +1526,11 @@ public boolean awaitAckedFsn(long targetFsn, long timeoutMillis) { } long deadlineNanos = System.nanoTime() + timeoutMillis * 1_000_000L; Thread me = Thread.currentThread(); - while (true) { - // Register before the check: an ack that lands after it unparks us, - // one that landed before it is seen by the check. - ackWaiters.add(me); - try { + // Registered before the first check: an ack that lands after a check + // unparks us (or leaves the permit), one that landed before is seen by it. + ackWaiters.add(me); + try { + while (true) { if (getAckedFsn() >= targetFsn) { return true; } @@ -1540,9 +1540,9 @@ public boolean awaitAckedFsn(long targetFsn, long timeoutMillis) { return false; } java.util.concurrent.locks.LockSupport.parkNanos(this, remaining); - } finally { - ackWaiters.remove(me); } + } finally { + ackWaiters.remove(me); } } @@ -4886,10 +4886,9 @@ private void drainOnClose(boolean errorOwnedByCustomHandler, boolean[] restoreIn restoreInterrupt[0] |= Thread.interrupted(); try { Thread me = Thread.currentThread(); - while (true) { - // Register before the check, see AckWaiters. - ackWaiters.add(me); - try { + ackWaiters.add(me); // once per drain, see AckWaiters + try { + while (true) { if (cursorEngine.ackedFsn() >= target) { break; } @@ -4932,9 +4931,9 @@ private void drainOnClose(boolean errorOwnedByCustomHandler, boolean[] restoreIn : " pending batches; the wire is not draining: " + outage.getMessage()); } java.util.concurrent.locks.LockSupport.parkNanos(this, deadlineNanos - System.nanoTime()); - } finally { - ackWaiters.remove(me); } + } finally { + ackWaiters.remove(me); } } finally { // Close the last-iteration race: an interrupt can land after the loop observes its final ACK but diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/AckWaiters.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/AckWaiters.java index f0818427e..d87ee4cd0 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/AckWaiters.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/AckWaiters.java @@ -30,10 +30,12 @@ /** * Threads parked for ack progress: the producer in a close drain plus any * monitor threads in {@code awaitAckedFsn}. Owned by the Sender, so it outlives - * the I/O loops and engines a recycle replaces. A waiter registers, re-checks - * its condition, parks, deregisters; the I/O thread advances the watermark and - * then calls {@link #signal()}. Either the waiter sees the new value or the - * signal sees the waiter. {@link #signal()} allocates nothing. + * the I/O loops and engines a recycle replaces. A waiter registers once per + * call, then loops check / park until done, and deregisters; the I/O thread + * advances the watermark and then calls {@link #signal()}. Either the waiter's + * check sees the new value or the signal sees the waiter, and an unpark that + * lands between check and park is kept as the park permit. Registration + * allocates; {@link #signal()} and the check/park loop do not. */ public final class AckWaiters { private static final Thread[] NONE = new Thread[0]; @@ -50,6 +52,10 @@ public synchronized void remove(Thread t) { Thread[] w = waiters; for (int i = 0; i < w.length; i++) { if (w[i] == t) { + if (w.length == 1) { + waiters = NONE; + return; + } Thread[] n = new Thread[w.length - 1]; System.arraycopy(w, 0, n, 0, i); System.arraycopy(w, i + 1, n, i, w.length - i - 1); From 898e771c6ea581f4c72776203d92960e763ba235 Mon Sep 17 00:00:00 2001 From: Jaromir Hamala Date: Wed, 21 Oct 2026 23:25:04 +0200 Subject: [PATCH 09/36] fix(qwp): keep the 1.3.x constructor descriptors linking 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. --- .../sf/cursor/CursorWebSocketSendLoop.java | 90 +++++++++++++++++++ .../qwp/client/sf/cursor/SegmentManager.java | 35 +++++--- ...rsorWebSocketSendLoopRotationRaceTest.java | 3 +- .../SegmentManagerAckWatermarkLagTest.java | 3 +- .../cursor/SegmentManagerCloseRaceTest.java | 4 +- .../SegmentManagerCrashConsistencyTest.java | 3 +- .../cursor/SegmentManagerPinnedTrimTest.java | 5 +- .../cursor/SegmentManagerRecoveryCapTest.java | 3 +- .../cursor/SegmentManagerSideFileCapTest.java | 7 +- .../client/sf/cursor/SegmentManagerTest.java | 3 +- .../SegmentManagerTotalBytesRaceTest.java | 3 +- .../SegmentManagerTrimDeregisterRaceTest.java | 3 +- 12 files changed, 135 insertions(+), 27 deletions(-) diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoop.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoop.java index f03940662..0e029156a 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoop.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoop.java @@ -1001,6 +1001,96 @@ private CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine, } } + /** + * The 1.3.x constructors, with the {@code parkNanos} the loop no longer + * uses. Kept so callers compiled against a released jar keep linking. + * + * @deprecated use the overload without {@code parkNanos}. + */ + @Deprecated + public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine, + long fsnAtZero, long parkNanos, + ReconnectFactory reconnectFactory, + long reconnectInitialBackoffMillis, + long reconnectMaxBackoffMillis) { + this(client, engine, fsnAtZero, reconnectFactory, reconnectInitialBackoffMillis, reconnectMaxBackoffMillis); + } + + @Deprecated + public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine, + long fsnAtZero, long parkNanos, + ReconnectFactory reconnectFactory, + long reconnectInitialBackoffMillis, + long reconnectMaxBackoffMillis, + boolean durableAckMode) { + this(client, engine, fsnAtZero, reconnectFactory, reconnectInitialBackoffMillis, reconnectMaxBackoffMillis, durableAckMode); + } + + @Deprecated + public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine, + long fsnAtZero, long parkNanos, + ReconnectFactory reconnectFactory, + long reconnectInitialBackoffMillis, + long reconnectMaxBackoffMillis, + boolean durableAckMode, + long durableAckKeepaliveIntervalMillis) { + this(client, engine, fsnAtZero, reconnectFactory, reconnectInitialBackoffMillis, reconnectMaxBackoffMillis, durableAckMode, durableAckKeepaliveIntervalMillis); + } + + @Deprecated + public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine, + long fsnAtZero, long parkNanos, + ReconnectFactory reconnectFactory, + long reconnectInitialBackoffMillis, + long reconnectMaxBackoffMillis, + boolean durableAckMode, + long durableAckKeepaliveIntervalMillis, + int maxHeadFrameRejections) { + this(client, engine, fsnAtZero, reconnectFactory, reconnectInitialBackoffMillis, reconnectMaxBackoffMillis, durableAckMode, durableAckKeepaliveIntervalMillis, maxHeadFrameRejections); + } + + @Deprecated + public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine, + long fsnAtZero, long parkNanos, + ReconnectFactory reconnectFactory, + long reconnectInitialBackoffMillis, + long reconnectMaxBackoffMillis, + boolean durableAckMode, + long durableAckKeepaliveIntervalMillis, + int maxHeadFrameRejections, + long poisonMinEscalationWindowMillis) { + this(client, engine, fsnAtZero, reconnectFactory, reconnectInitialBackoffMillis, reconnectMaxBackoffMillis, durableAckMode, durableAckKeepaliveIntervalMillis, maxHeadFrameRejections, poisonMinEscalationWindowMillis); + } + + @Deprecated + public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine, + long fsnAtZero, long parkNanos, + ReconnectFactory reconnectFactory, + long reconnectInitialBackoffMillis, + long reconnectMaxBackoffMillis, + boolean durableAckMode, + long durableAckKeepaliveIntervalMillis, + int maxHeadFrameRejections, + long poisonMinEscalationWindowMillis, + long catchUpCapGapMinEscalationWindowMillis) { + this(client, engine, fsnAtZero, reconnectFactory, reconnectInitialBackoffMillis, reconnectMaxBackoffMillis, durableAckMode, durableAckKeepaliveIntervalMillis, maxHeadFrameRejections, poisonMinEscalationWindowMillis, catchUpCapGapMinEscalationWindowMillis); + } + + @Deprecated + public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine, + long fsnAtZero, long parkNanos, + ReconnectFactory reconnectFactory, + long reconnectInitialBackoffMillis, + long reconnectMaxBackoffMillis, + boolean durableAckMode, + long durableAckKeepaliveIntervalMillis, + int maxHeadFrameRejections, + long poisonMinEscalationWindowMillis, + long catchUpCapGapMinEscalationWindowMillis, + ReconnectPolicy reconnectPolicy) { + this(client, engine, fsnAtZero, reconnectFactory, reconnectInitialBackoffMillis, reconnectMaxBackoffMillis, durableAckMode, durableAckKeepaliveIntervalMillis, maxHeadFrameRejections, poisonMinEscalationWindowMillis, catchUpCapGapMinEscalationWindowMillis, reconnectPolicy); + } + /** * The policy-aware constructor as released in 1.3.x: equivalent to the overload * below with {@code externalFsnBase = 0L}, the value every caller outside a diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentManager.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentManager.java index 54c353a29..c1bef572f 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentManager.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentManager.java @@ -219,15 +219,16 @@ public final class SegmentManager implements QuietCloseable { private volatile Thread workerThread; public SegmentManager(long segmentSizeBytes) { - this(segmentSizeBytes, UNLIMITED_TOTAL_BYTES); + this(segmentSizeBytes, new SegmentBudget(UNLIMITED_TOTAL_BYTES)); } /** - * Full constructor. - * * @param segmentSizeBytes per-segment file size in bytes - * @param maxTotalBytes upper bound on total bytes the manager tracks - * across all registered rings — counts every segment + * @param budget charged for every segment the manager tracks, and + * shareable between managers (a sender pool does, so + * sf_max_total_bytes caps all of its senders + * together). Its capacity is the upper bound on total + * bytes across all registered rings — counts every segment * the ring owns (initial active + sealed + hot * spare), including bytes already on disk at * register-time (e.g. after recovery or orphan @@ -242,18 +243,26 @@ public SegmentManager(long segmentSizeBytes) { * {@code 2 × segmentSizeBytes} so the manager can * hold an initial active plus one hot spare. */ - public SegmentManager(long segmentSizeBytes, long maxTotalBytes) { - this(segmentSizeBytes, new SegmentBudget(maxTotalBytes), FilesFacade.INSTANCE, System::nanoTime); + public SegmentManager(long segmentSizeBytes, SegmentBudget budget) { + this(segmentSizeBytes, budget, FilesFacade.INSTANCE, System::nanoTime); } /** - * As {@link #SegmentManager(long, long)}, but charging - * {@code budget}, which other managers may share: its capacity then caps - * the rings of all of them together. The capacity must allow at least one - * {@code segmentSizeBytes}. + * @deprecated {@code pollNanos} is ignored, the worker is event-driven. Kept + * so callers compiled against 1.3.x keep linking; {@code (long, long)} must + * keep this meaning, so a byte cap goes through {@link SegmentBudget}. */ - public SegmentManager(long segmentSizeBytes, SegmentBudget budget) { - this(segmentSizeBytes, budget, FilesFacade.INSTANCE, System::nanoTime); + @Deprecated + public SegmentManager(long segmentSizeBytes, long pollNanos) { + this(segmentSizeBytes); + } + + /** + * @deprecated see {@link #SegmentManager(long, long)}. + */ + @Deprecated + public SegmentManager(long segmentSizeBytes, long pollNanos, long maxTotalBytes) { + this(segmentSizeBytes, new SegmentBudget(maxTotalBytes)); } @TestOnly diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopRotationRaceTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopRotationRaceTest.java index d231294b6..6a4bb1c3c 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopRotationRaceTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopRotationRaceTest.java @@ -30,6 +30,7 @@ import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorSendEngine; import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorWebSocketSendLoop; import io.questdb.client.cutlass.qwp.client.sf.cursor.MmapSegment; +import io.questdb.client.cutlass.qwp.client.sf.cursor.SegmentBudget; import io.questdb.client.cutlass.qwp.client.sf.cursor.SegmentManager; import io.questdb.client.network.PlainSocketFactory; import io.questdb.client.std.MemoryTag; @@ -81,7 +82,7 @@ public void tailFrameSurvivesSegmentRotation() throws Exception { final int n = FRAMES; SegmentManager manager = new SegmentManager( - SEGMENT_BYTES, SegmentManager.UNLIMITED_TOTAL_BYTES); + SEGMENT_BYTES, new SegmentBudget(SegmentManager.UNLIMITED_TOTAL_BYTES)); manager.start(); // sfDir == null => memory-only segments (no files/fds); same trySendOne() path. CursorSendEngine engine = new CursorSendEngine(null, SEGMENT_BYTES, manager); diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerAckWatermarkLagTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerAckWatermarkLagTest.java index 9c771e39a..836df62d3 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerAckWatermarkLagTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerAckWatermarkLagTest.java @@ -26,6 +26,7 @@ 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.SegmentBudget; 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; @@ -85,7 +86,7 @@ public void testAcksInsideActiveSegmentReachWatermarkWithoutOtherActivity() thro AckWatermark watermark = AckWatermark.open(tmpDir); Assert.assertNotNull(watermark); long payload = Unsafe.malloc(32, MemoryTag.NATIVE_DEFAULT); - try (SegmentManager manager = new SegmentManager(segmentSize, segmentSize * 8)) { + try (SegmentManager manager = new SegmentManager(segmentSize, new SegmentBudget(segmentSize * 8))) { manager.register(ring, tmpDir, watermark); manager.start(); // Three rounds: the first ACK after a fully persisted watermark diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerCloseRaceTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerCloseRaceTest.java index 37bd92562..a10884d4e 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerCloseRaceTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerCloseRaceTest.java @@ -25,6 +25,7 @@ package io.questdb.client.test.cutlass.qwp.client.sf.cursor; import io.questdb.client.cutlass.qwp.client.sf.cursor.MmapSegment; +import io.questdb.client.cutlass.qwp.client.sf.cursor.SegmentBudget; 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; @@ -87,8 +88,7 @@ public void testManagerDoesNotInstallSpareIntoClosedRing() throws Exception { TestUtils.assertMemoryLeak(() -> { // Aggressive 1us poll so the worker is almost always running // serviceRing — maximizes overlap with concurrent deregister/close. - SegmentManager manager = new SegmentManager(SEGMENT_SIZE, - Long.MAX_VALUE); + SegmentManager manager = new SegmentManager(SEGMENT_SIZE, new SegmentBudget(Long.MAX_VALUE)); manager.start(); SegmentRing[] rings = new SegmentRing[ITERATIONS]; diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerCrashConsistencyTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerCrashConsistencyTest.java index 29129ef33..1758f8f8e 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerCrashConsistencyTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerCrashConsistencyTest.java @@ -15,6 +15,7 @@ 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.SegmentBudget; 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; @@ -203,7 +204,7 @@ public void testDiskTrimWithoutWatermarkIsPreserved() throws Exception { ring = createRing(root, segmentSize, payload, 1); CountDownLatch servicePass = new CountDownLatch(1); try (SegmentManager manager = new SegmentManager( - segmentSize, segmentSize * 8L)) { + segmentSize, new SegmentBudget(segmentSize * 8L))) { manager.setBeforeTrimSyncHook(servicePass::countDown); manager.register(ring, root, null); manager.start(); diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerPinnedTrimTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerPinnedTrimTest.java index f2b01222e..2311b5030 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerPinnedTrimTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerPinnedTrimTest.java @@ -14,6 +14,7 @@ package io.questdb.client.test.cutlass.qwp.client.sf.cursor; import io.questdb.client.cutlass.qwp.client.sf.cursor.MmapSegment; +import io.questdb.client.cutlass.qwp.client.sf.cursor.SegmentBudget; 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.MemoryTag; @@ -47,7 +48,7 @@ public void testPinnedActiveSurvivesRotationUntilIoRelease() throws Exception { CountDownLatch trimPass = new CountDownLatch(1); try (SegmentManager manager = new SegmentManager( - segmentSize, segmentSize * 8L)) { + segmentSize, new SegmentBudget(segmentSize * 8L))) { manager.setBeforeTrimSyncHook(trimPass::countDown); manager.register(ring, null); manager.start(); @@ -63,7 +64,7 @@ public void testPinnedActiveSurvivesRotationUntilIoRelease() throws Exception { ring.releasePinnedSegmentForTest(pinned); try (SegmentManager manager = new SegmentManager( - segmentSize, segmentSize * 8L)) { + segmentSize, new SegmentBudget(segmentSize * 8L))) { manager.register(ring, null); manager.start(); awaitTrimmed(ring); diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerRecoveryCapTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerRecoveryCapTest.java index 571a62815..1b35a378e 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerRecoveryCapTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerRecoveryCapTest.java @@ -25,6 +25,7 @@ package io.questdb.client.test.cutlass.qwp.client.sf.cursor; import io.questdb.client.cutlass.qwp.client.sf.cursor.MmapSegment; +import io.questdb.client.cutlass.qwp.client.sf.cursor.SegmentBudget; 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; @@ -85,7 +86,7 @@ public void testManagerHonorsCapAgainstRecoveredSegmentsOnRegister() throws Exce SegmentRing ring = SegmentRing.openExisting(slotDir, SEGMENT_SIZE); Assert.assertNotNull("recovery should produce a ring", ring); - SegmentManager manager = new SegmentManager(SEGMENT_SIZE, cap); + SegmentManager manager = new SegmentManager(SEGMENT_SIZE, new SegmentBudget(cap)); try (SegmentManager ignored = manager) { manager.start(); manager.register(ring, slotDir); diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerSideFileCapTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerSideFileCapTest.java index 4c9d79fb7..ff759ad70 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerSideFileCapTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerSideFileCapTest.java @@ -25,6 +25,7 @@ package io.questdb.client.test.cutlass.qwp.client.sf.cursor; import io.questdb.client.cutlass.qwp.client.sf.cursor.MmapSegment; +import io.questdb.client.cutlass.qwp.client.sf.cursor.SegmentBudget; 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; @@ -81,7 +82,7 @@ public void testCapCountsSideFileBytesAgainstProvisioning() throws Exception { SegmentRing ring = SegmentRing.openExisting(slotDir, SEGMENT_SIZE); Assert.assertNotNull("recovery should produce a ring", ring); - SegmentManager manager = new SegmentManager(SEGMENT_SIZE, cap); + SegmentManager manager = new SegmentManager(SEGMENT_SIZE, new SegmentBudget(cap)); try (SegmentManager ignored = manager) { manager.start(); manager.register(ring, slotDir, null, 0L, () -> SEGMENT_SIZE); @@ -110,7 +111,7 @@ public void testControlWithoutSideFileGaugeProvisionsToTheCap() throws Exception SegmentRing ring = SegmentRing.openExisting(slotDir, SEGMENT_SIZE); Assert.assertNotNull("recovery should produce a ring", ring); - SegmentManager manager = new SegmentManager(SEGMENT_SIZE, cap); + SegmentManager manager = new SegmentManager(SEGMENT_SIZE, new SegmentBudget(cap)); try (SegmentManager ignored = manager) { manager.start(); manager.register(ring, slotDir, null, 0L, null); @@ -146,7 +147,7 @@ public void testLivenessFloorProvisionsDespiteSideFileBytesOverTheCap() throws E SegmentRing ring = SegmentRing.openExisting(slotDir, SEGMENT_SIZE); Assert.assertNotNull("recovery should produce a ring", ring); - SegmentManager manager = new SegmentManager(SEGMENT_SIZE, cap); + SegmentManager manager = new SegmentManager(SEGMENT_SIZE, new SegmentBudget(cap)); try (SegmentManager ignored = manager) { manager.start(); // 10 segments' worth of dictionary against a 3-segment cap: no diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerTest.java index 07d8ce137..f8a206f36 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerTest.java @@ -26,6 +26,7 @@ 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.SegmentBudget; 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; @@ -281,7 +282,7 @@ public void testMaxTotalBytesCapBlocksProvisioningUntilTrimFrees() throws Except long buf = Unsafe.malloc(64, MemoryTag.NATIVE_DEFAULT); try (AckWatermark watermark = AckWatermark.open(tmpDir); SegmentRing ring = new SegmentRing(seg0, segSize); - SegmentManager mgr = new SegmentManager(segSize, cap)) { + SegmentManager mgr = new SegmentManager(segSize, new SegmentBudget(cap))) { assertTrue("watermark must open", watermark != null); mgr.start(); // register seeds totalBytes = 1*segSize (initial active). diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerTotalBytesRaceTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerTotalBytesRaceTest.java index 3ca7324de..6a77f50d9 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerTotalBytesRaceTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerTotalBytesRaceTest.java @@ -25,6 +25,7 @@ package io.questdb.client.test.cutlass.qwp.client.sf.cursor; import io.questdb.client.cutlass.qwp.client.sf.cursor.MmapSegment; +import io.questdb.client.cutlass.qwp.client.sf.cursor.SegmentBudget; 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; @@ -93,7 +94,7 @@ public void testInstallPathDoesNotCommitAfterDeregister() throws Exception { long maxTotal = segSize * 8L; try (SegmentManager mgr = new SegmentManager( - segSize, maxTotal)) { + segSize, new SegmentBudget(maxTotal))) { String dir = tmpDir + "/single-ring"; assertEquals(0, Files.mkdir(dir, Files.DIR_MODE_DEFAULT)); String activePath = dir + "/sf-initial.sfa"; diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerTrimDeregisterRaceTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerTrimDeregisterRaceTest.java index 2d07eca1e..0758d866d 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerTrimDeregisterRaceTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerTrimDeregisterRaceTest.java @@ -25,6 +25,7 @@ package io.questdb.client.test.cutlass.qwp.client.sf.cursor; import io.questdb.client.cutlass.qwp.client.sf.cursor.MmapSegment; +import io.questdb.client.cutlass.qwp.client.sf.cursor.SegmentBudget; 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; @@ -93,7 +94,7 @@ public void testTrimPathDoesNotDoubleSubtractAfterDeregister() throws Exception // appendOrFsn whenever a spare is needed, so the producer-driven // setup phase still gets prompt service. try (SegmentManager mgr = new SegmentManager( - segSize, maxTotal)) { + segSize, new SegmentBudget(maxTotal))) { mgr.start(); String dir = tmpDir + "/single-ring"; From 9399051796069cc37ddab8b3b5381f19cec61d0f Mon Sep 17 00:00:00 2001 From: Jaromir Hamala Date: Wed, 21 Oct 2026 23:33:13 +0200 Subject: [PATCH 10/36] chore(qwp): review nits 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. --- .../qwp/client/QwpWebSocketSender.java | 12 ++-- .../client/sf/cursor/CursorSendEngine.java | 8 +-- .../qwp/client/sf/cursor/SegmentManager.java | 12 ++-- .../qwp/client/sf/cursor/SegmentRing.java | 8 +-- .../sf/cursor/SenderErrorDispatcher.java | 14 ++--- ...rsorWebSocketSendLoopRotationRaceTest.java | 4 +- design/tla/AckWait.tla | 56 +++++++++---------- 7 files changed, 56 insertions(+), 58 deletions(-) diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java index a63683d78..547c43356 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java @@ -41,11 +41,11 @@ import io.questdb.client.cutlass.line.LineSenderException; import io.questdb.client.cutlass.line.array.DoubleArray; import io.questdb.client.cutlass.line.array.LongArray; +import io.questdb.client.cutlass.qwp.client.sf.cursor.AckWaiters; import io.questdb.client.cutlass.qwp.client.sf.cursor.BackgroundDrainer; import io.questdb.client.cutlass.qwp.client.sf.cursor.BackgroundDrainerListener; import io.questdb.client.cutlass.qwp.client.sf.cursor.BackgroundDrainerPool; import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorSendCounters; -import io.questdb.client.cutlass.qwp.client.sf.cursor.AckWaiters; import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorSendEngine; import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorWebSocketSendLoop; import io.questdb.client.cutlass.qwp.client.sf.cursor.DefaultSenderConnectionListener; @@ -1487,15 +1487,17 @@ public void atNow() { /** * Blocks until {@link #getAckedFsn()} {@code >= targetFsn}, or until - * {@code timeoutMillis} elapses. Polls on a 50us park; surfaces I/O loop - * errors synchronously via {@code cursorSendLoop.checkError()}. + * {@code timeoutMillis} elapses. Parks in {@link AckWaiters}: the I/O + * thread wakes it on every ack advance, terminal error and loop close; + * I/O loop errors surface synchronously via {@code checkError()}. *

    - * Every pass re-reads the epoch base and the cursor engine through + * Every wake re-reads the epoch base and the cursor engine through * {@link #getAckedFsn()} (base first, then engine, clamped to the durable * watermark), so a symbol-dictionary recycle that swaps the engine while a - * monitor thread is parked here is observed on the next pass instead of + * monitor thread is parked here is observed on the next wake instead of * pinning the wait to the engine it started on. While the engine is null * mid-swap the durable watermark stands in, as in {@link #getAckedFsn()}. + * Any number of threads may wait at once. *

    * Useful for tests and user code that need to confirm a specific publish * has been server-acknowledged. Pair with {@link #flushAndGetSequence()} to diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorSendEngine.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorSendEngine.java index 4ec4b3c70..642cd0883 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorSendEngine.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorSendEngine.java @@ -108,10 +108,6 @@ public final class CursorSendEngine implements QuietCloseable { // many engines (one per Sender), the caller owns and closes it. private final boolean ownsManager; private final SegmentRing ring; - // Rings the I/O loop's doorbell after a frame is published, so the loop - // can block instead of polling publishedFsn(). - // Installed by CursorWebSocketSendLoop; null until a loop attaches. - private volatile Runnable ioWakeup; private final long segmentSizeBytes; private final String sfDir; private final long syncIntervalNanos; @@ -199,6 +195,10 @@ public final class CursorSendEngine implements QuietCloseable { // isCloseCompleted() from pool threads re-probing a retired slot (see the // getter for why it must not synchronize). private volatile boolean closeCompleted; + // Rings the I/O loop's doorbell after a frame is published, so the loop + // can block instead of polling publishedFsn(). + // Installed by CursorWebSocketSendLoop; null until a loop attaches. + private volatile Runnable ioWakeup; // Invoked after closeCompleted publishes a confirmed flock release. Used // by an owning sender pool to wake capacity-starved borrowers immediately. private volatile Runnable slotLockReleaseListener; diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentManager.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentManager.java index c1bef572f..50342fa35 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentManager.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentManager.java @@ -56,6 +56,7 @@ */ public final class SegmentManager implements QuietCloseable { + public static final long DISK_FULL_LOG_THROTTLE_NANOS = 30_000_000_000L; // 30 s /** * Retry cadence after a failed hot-spare provisioning attempt (disk full, * budget exhausted, I/O error). Nothing else wakes the worker for that @@ -63,6 +64,7 @@ public final class SegmentManager implements QuietCloseable { * A wake from SegmentBudget.release() would make it fully event-driven. */ public static final long PROVISION_RETRY_NANOS = 10_000_000L; // 10 ms + public static final long UNLIMITED_TOTAL_BYTES = Long.MAX_VALUE; /** * Upper bound on how far the mmap ack watermark trails {@code ackedFsn} * while ACKs keep landing inside the active segment. The first such ACK @@ -70,8 +72,6 @@ public final class SegmentManager implements QuietCloseable { * is current, so a crash replays at most this much already-acked data. */ public static final long WATERMARK_LAG_NANOS = 1_000_000L; // 1 ms - public static final long DISK_FULL_LOG_THROTTLE_NANOS = 30_000_000_000L; // 30 s - public static final long UNLIMITED_TOTAL_BYTES = Long.MAX_VALUE; private static final int ENTRY_DEREGISTERED = 3; private static final int ENTRY_DEREGISTERED_IN_SERVICE = 2; private static final int ENTRY_IN_SERVICE = 1; @@ -100,10 +100,6 @@ public final class SegmentManager implements QuietCloseable { // budget to several managers -- a sender pool does, so sf_max_total_bytes // caps all of its senders together. private final SegmentBudget budget; - // Worker-local: when a hot-spare provisioning attempt fails (disk full, budget - // exhausted, I/O error) the worker schedules its own retry here instead of - // relying on a tick. Reset every pass; Long.MAX_VALUE = nothing to retry. - private long provisionRetryDeadlineNanos = Long.MAX_VALUE; private final AtomicLong fileGeneration = new AtomicLong(); private final FilesFacade filesFacade; // Per-ring segment bytes below which the cap check never refuses to @@ -197,6 +193,10 @@ public final class SegmentManager implements QuietCloseable { // array. Guarded by lock and consumed on worker-loop exit outside lock. private Runnable ownedEngineExitCleanup; private long lastDiskFullLogNs; + // Worker-local: when a hot-spare provisioning attempt fails (disk full, budget + // exhausted, I/O error) the worker schedules its own retry here instead of + // relying on a tick. Reset every pass; Long.MAX_VALUE = nothing to retry. + private long provisionRetryDeadlineNanos = Long.MAX_VALUE; private volatile boolean running; // pathScratch free-exactly-once coordination between a timed-out close() // and the worker's exit path. All three are guarded by {@link #lock}. diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentRing.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentRing.java index cf040971f..ecae75045 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentRing.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentRing.java @@ -1512,6 +1512,10 @@ public long nextSeqHint() { * instant, never behind them. Returns -1 when nothing has been appended yet. * Volatile read; safe to call from any thread. */ + public long publishedFsn() { + return publishedFsn; + } + /** * Changes after every completed publish; see the field comment. */ @@ -1519,10 +1523,6 @@ public long publishStamp() { return publishStamp; } - public long publishedFsn() { - return publishedFsn; - } - /** * Drops a directory-durable prefix from hidden trim ownership and returns * its exact byte total for manager accounting. diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SenderErrorDispatcher.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SenderErrorDispatcher.java index 37bfd3d83..0277fe6a6 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SenderErrorDispatcher.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SenderErrorDispatcher.java @@ -150,14 +150,12 @@ public void close() { return; } closed = true; - // Wake the dispatcher even if the inbox is empty — POISON also - // forces it past any pending poll() without losing real entries - // already queued (they're delivered before POISON since the - // queue is FIFO). The offer's return value is intentionally - // ignored: if the inbox is at capacity the dispatcher will - // still wake on its 100ms poll timeout and re-check `closed`, - // so failure to enqueue POISON only adds at most one tick of - // shutdown latency — not a correctness issue. + // Wake the dispatcher even if the inbox is empty — POISON ends + // its blocking take() without losing real entries already queued + // (they're delivered before POISON since the queue is FIFO). The + // offer's return value is intentionally ignored: an inbox at + // capacity means the dispatcher is awake draining it, and its + // loop condition re-checks `closed` once the inbox is empty. //noinspection ResultOfMethodCallIgnored inbox.offer(POISON); Thread t = dispatcherThread; diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopRotationRaceTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopRotationRaceTest.java index 6a4bb1c3c..f436f41ef 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopRotationRaceTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopRotationRaceTest.java @@ -56,8 +56,8 @@ * The one non-obvious knob: segments hold enough frames that the producer never * out-runs the hot-spare manager. With tiny segments it parks ~50 µs on * every rotation, and that gap lets the consumer observe the tail frame before - * the rotation — masking the race. Larger segments plus a microsecond - * spare-provision poll keep a spare ready so the seal follows the last publish + * the rotation — masking the race. Larger segments, with the manager woken on + * every rotation, keep a spare ready so the seal follows the last publish * back-to-back, which is the only arrangement that opens the window. */ public class CursorWebSocketSendLoopRotationRaceTest { diff --git a/design/tla/AckWait.tla b/design/tla/AckWait.tla index 7947ce724..39f5c8955 100644 --- a/design/tla/AckWait.tla +++ b/design/tla/AckWait.tla @@ -1,50 +1,48 @@ -------------------------------- MODULE AckWait -------------------------------- -(* Producer-side ack wait: CursorWebSocketSendLoop.awaitAckProgress() and the *) -(* I/O thread's signalAckProgress(), replacing the 50us parkNanos poll in *) -(* QwpWebSocketSender.awaitAckedFsn()/drainOnClose(). *) +(* Ack wait: QwpWebSocketSender.awaitAckedFsn() / drainOnClose() against the *) +(* I/O thread's AckWaiters.signal(), replacing the 50us parkNanos poll. The *) +(* waiter registers once (AckWaiters.add), then loops: re-read ackedFsn, park. *) +(* It deregisters when done. One waiter is modelled; the registry holds any *) +(* number, each following this protocol independently. *) (* *) (* LockSupport semantics: unpark sets a one-shot permit; park consumes it and *) (* returns at once if it was already set. We ignore deadlines and spurious *) (* wakeups -- both only make the wait return earlier, and the caller re-checks. *) EXTENDS Naturals -CONSTANT Target \* the FSN the producer waits for +CONSTANT Target \* the FSN the waiter waits for VARIABLES acked, \* engine.ackedFsn(), written by the I/O thread - seen, \* the watermark the producer read before deciding to wait - waiter, \* ackWaiter != null - permit, \* the producer thread's park permit - ppc, \* producer pc + seen, \* the watermark the waiter read on its last check + waiter, \* registered in AckWaiters + permit, \* the waiter thread's park permit + ppc, \* waiter pc iopc \* I/O thread pc vars == <> Init == /\ acked = 0 /\ seen = 0 /\ waiter = FALSE /\ permit = FALSE - /\ ppc = "read" /\ iopc = "idle" + /\ ppc = "register" /\ iopc = "idle" ---------------------------------------------------------------------------- -(* Producer: QwpWebSocketSender loop body + awaitAckProgress(seen, deadline). *) -Read == /\ ppc = "read" /\ seen' = acked \* long seen = engine.ackedFsn() - /\ ppc' = (IF acked >= Target THEN "done" ELSE "setWaiter") - /\ UNCHANGED <> +(* Waiter: ackWaiters.add(me); loop { check; parkNanos }; ackWaiters.remove. *) +Register == /\ ppc = "register" /\ waiter' = TRUE /\ ppc' = "read" + /\ UNCHANGED <> -SetWaiter == /\ ppc = "setWaiter" /\ waiter' = TRUE /\ ppc' = "recheck" - /\ UNCHANGED <> - -Recheck == /\ ppc = "recheck" - /\ IF acked /= seen THEN waiter' = FALSE /\ ppc' = "read" +Read == /\ ppc = "read" /\ seen' = acked \* getAckedFsn() >= targetFsn ? + /\ IF acked >= Target THEN waiter' = FALSE /\ ppc' = "done" ELSE waiter' = waiter /\ ppc' = "park" - /\ UNCHANGED <> + /\ UNCHANGED <> (* parkNanos: blocks until the permit is available, consumes it. *) Park == /\ ppc = "park" /\ permit - /\ permit' = FALSE /\ waiter' = FALSE /\ ppc' = "read" - /\ UNCHANGED <> + /\ permit' = FALSE /\ ppc' = "read" + /\ UNCHANGED <> ---------------------------------------------------------------------------- -(* I/O thread: engine.acknowledge() (volatile write) then signalAckProgress() *) -(* reads ackWaiter and unparks. Two steps: the write and the read are not *) -(* atomic together. *) +(* I/O thread: engine.acknowledge() (volatile write) then signal() reads the *) +(* registry and unparks. Two steps: the write and the read are not atomic *) +(* together. *) Ack == /\ iopc = "idle" /\ acked < Target /\ acked' = acked + 1 /\ iopc' = "signal" /\ UNCHANGED <> @@ -53,19 +51,19 @@ Signal == /\ iopc = "signal" /\ permit' = (permit \/ waiter) /\ iopc' = "idle" /\ UNCHANGED <> -Next == Read \/ SetWaiter \/ Recheck \/ Park \/ Ack \/ Signal +Next == Register \/ Read \/ Park \/ Ack \/ Signal \/ (ppc = "done" /\ acked = Target /\ UNCHANGED vars) Spec == Init /\ [][Next]_vars - /\ WF_vars(Read \/ SetWaiter \/ Recheck \/ Park) + /\ WF_vars(Register \/ Read \/ Park) /\ WF_vars(Ack \/ Signal) ---------------------------------------------------------------------------- -(* SAFETY: the producer is never parked with unsignalled progress. If it is *) +(* SAFETY: the waiter is never parked with unsignalled progress. If it is *) (* parked and the watermark moved past what it saw, the permit is set or the *) -(* I/O thread is about to set it (it has the waiter flag visible). *) +(* I/O thread is about to set it (it has the registration visible). *) NoLostAck == (ppc = "park" /\ acked /= seen) => (permit \/ (iopc = "signal" /\ waiter)) -(* LIVENESS: reaching the target eventually returns the producer. *) +(* LIVENESS: reaching the target eventually returns the waiter. *) Returns == (acked >= Target) ~> (ppc = "done") ============================================================================= From 779a99bcc1026c504d793641844525654f715486 Mon Sep 17 00:00:00 2001 From: Jaromir Hamala Date: Thu, 22 Oct 2026 13:17:26 +0200 Subject: [PATCH 11/36] fix(qwp): rate-limit segment manager wakes to the watermark cadence 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. --- .../qwp/client/sf/cursor/SegmentManager.java | 35 ++++++++-- .../qwp/client/sf/cursor/SegmentRing.java | 15 +++-- .../SegmentManagerAckWatermarkLagTest.java | 67 +++++++++++++++++++ 3 files changed, 103 insertions(+), 14 deletions(-) diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentManager.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentManager.java index 50342fa35..dd31baf42 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentManager.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentManager.java @@ -68,8 +68,10 @@ public final class SegmentManager implements QuietCloseable { /** * Upper bound on how far the mmap ack watermark trails {@code ackedFsn} * while ACKs keep landing inside the active segment. The first such ACK - * wakes the worker; it then re-parks on this cadence until the watermark - * is current, so a crash replays at most this much already-acked data. + * wakes the worker; writing the watermark disarms the ACK wake, so later + * ACKs ring nothing while the worker re-parks on this cadence. A timed + * pass that finds no new ACK re-arms the wake and parks without a + * timeout. A crash replays at most this much already-acked data. */ public static final long WATERMARK_LAG_NANOS = 1_000_000L; // 1 ms private static final int ENTRY_DEREGISTERED = 3; @@ -1161,7 +1163,14 @@ private boolean serviceRing0(RingEntry e) { if (currentAck > e.lastPersistedAck) { e.watermark.write(currentAck); e.lastPersistedAck = currentAck; + // Stay disarmed: the worker re-parks on + // WATERMARK_LAG_NANOS, so later ACKs need not wake it. + e.ackWakeArmed = false; + } else if (!e.ackWakeArmed) { + // A timed pass found no new ACK: re-arm so the next + // one wakes the worker, then park without a timeout. e.ring.setPersistedAckFsn(currentAck); + e.ackWakeArmed = true; } } } @@ -1230,7 +1239,10 @@ private boolean serviceRing0(RingEntry e) { if (durableAck > e.lastPersistedAck) { e.watermark.write(durableAck); e.lastPersistedAck = durableAck; + // Re-arm here rather than poll: a failed barrier below must + // leave the worker parked until its trim retry, not ticking. e.ring.setPersistedAckFsn(durableAck); + e.ackWakeArmed = true; } } try { @@ -1436,7 +1448,7 @@ private void workerLoop() { // the first ACK past the watermark, an ACK that completes a // sealed segment). Timed work: periodic sync, a provision retry, // a failed trim's retry, and re-persisting the watermark while - // ACKs keep landing. Otherwise park indefinitely. + // the ACK wake is disarmed. Otherwise park indefinitely. long parkNanos = Long.MAX_VALUE; if (!hasMoreTrimmable) { long now = ticks.getAsLong(); @@ -1451,10 +1463,13 @@ private void workerLoop() { if (e.trimRetryDelayNanos != 0L) { parkNanos = Math.min(parkNanos, Math.max(0L, e.trimRetryAtNanos - now)); } - // Dekker with SegmentRing.acknowledge: it writes ackedFsn - // then reads persistedAckFsn; we wrote persistedAckFsn - // (in serviceRing) and read ackedFsn here. - if (e.watermark != null && e.ring.ackedFsn() > e.lastPersistedAck) { + // Disarmed: nothing wakes us for ACKs, so poll on the + // watermark cadence. Armed: Dekker with + // SegmentRing.acknowledge -- it writes ackedFsn then + // reads persistedAckFsn; we wrote persistedAckFsn (in + // serviceRing) and read ackedFsn here, so an ACK that + // raced the re-arm is seen by one side or the other. + if (e.watermark != null && (!e.ackWakeArmed || e.ring.ackedFsn() > e.lastPersistedAck)) { parkNanos = Math.min(parkNanos, WATERMARK_LAG_NANOS); } } @@ -1537,6 +1552,12 @@ private static final class RingEntry { // Survives across multiple serviceRing ticks and avoids a // write-storm when ackedFsn is steady. long lastPersistedAck = -1L; + // Whether the ring's persistedAckFsn equals lastPersistedAck, i.e. + // the next ACK wakes the worker. Writing the watermark clears it and + // the worker then polls on WATERMARK_LAG_NANOS; a timed pass that + // finds no new ACK sets it again. Manager-thread only. Starts true: + // register seeds persistedAckFsn to -1, matching lastPersistedAck. + boolean ackWakeArmed = true; // Prevents a legacy disk registration without a watermark from // flooding the log on every manager tick. boolean missingWatermarkLogged; diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentRing.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentRing.java index ecae75045..f868ea32b 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentRing.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentRing.java @@ -120,11 +120,12 @@ public final class SegmentRing implements QuietCloseable { // producer would otherwise hit BACKPRESSURE_NO_SPARE. private final long signalAtBytes; private volatile long ackedFsn = -1L; - // Highest FSN the manager has written to the slot's ack watermark; - // Long.MIN_VALUE until a manager registers one, so no ACK counts as "past - // it". acknowledge() wakes the manager on the first ACK past it; while it - // is stale the manager is already awake or on its WATERMARK_LAG_NANOS - // cadence, so later ACKs ring nothing. + // The ACK wake arm: the acked FSN at which the manager last went idle. + // Long.MIN_VALUE until a manager registers a watermark, so no ACK counts + // as "past it". acknowledge() wakes the manager on the first ACK past it. + // The manager deliberately leaves it stale while it persists the watermark + // on its WATERMARK_LAG_NANOS cadence, so later ACKs ring nothing; it is + // re-armed by a timed pass that finds no new ACK. private volatile long persistedAckFsn = Long.MIN_VALUE; // active: written by producer (constructor + appendOrFsn rotation), // read by I/O thread via getActive(). Volatile so the I/O thread sees @@ -1693,8 +1694,8 @@ public void setManagerWakeup(Runnable wakeup) { } /** - * Manager-only: records the highest FSN written to the ack watermark, see - * {@link #acknowledge}. + * Manager-only: re-arms the ACK wake at {@code fsn}, the current acked + * FSN at the moment the manager goes idle; see {@link #acknowledge}. */ public void setPersistedAckFsn(long fsn) { this.persistedAckFsn = fsn; diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerAckWatermarkLagTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerAckWatermarkLagTest.java index 836df62d3..ed3cfe5be 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerAckWatermarkLagTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerAckWatermarkLagTest.java @@ -40,12 +40,17 @@ import java.nio.file.Paths; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; /** * ACKs that land inside the active segment never complete a sealed segment, so * nothing but the watermark cadence brings the manager back. The on-disk * watermark must still catch up on its own: after a crash, recovery replays * everything above it as duplicates. Regression for the event-driven worker. + *

    + * The converse bound: while ACKs keep streaming in, the worker must run on the + * {@link SegmentManager#WATERMARK_LAG_NANOS} cadence, not once per ACK, and + * must go back to an untimed park once the stream stops. */ public class SegmentManagerAckWatermarkLagTest { private String tmpDir; @@ -111,4 +116,66 @@ public void testAcksInsideActiveSegmentReachWatermarkWithoutOtherActivity() thro } }); } + + @Test(timeout = 30_000L) + public void testStreamingAcksWakeWorkerOnWatermarkCadenceNotPerAck() throws Exception { + TestUtils.assertMemoryLeak(() -> { + final int acks = 20_000; + final long spacingNanos = 20_000L; // 50k ACK/s + // Segment large enough that every frame lands in the active + // segment below the hot-spare high-water mark: no rotation, no + // trim, nothing but ACKs reaches the worker. + long segmentSize = MmapSegment.HEADER_SIZE + (acks * 2L + 64) * (MmapSegment.FRAME_HEADER_SIZE + 32L); + MmapSegment initial = MmapSegment.create(tmpDir + "/sf-0000000000000000.sfa", 0L, segmentSize); + SegmentRing ring = new SegmentRing(initial, segmentSize); + AckWatermark watermark = AckWatermark.open(tmpDir); + Assert.assertNotNull(watermark); + long payload = Unsafe.malloc(32, MemoryTag.NATIVE_DEFAULT); + AtomicLong passes = new AtomicLong(); + try (SegmentManager manager = new SegmentManager(segmentSize, new SegmentBudget(segmentSize * 8))) { + // Runs once per serviceRing pass, i.e. once per worker wake or + // timed park expiry for this ring. + manager.setBeforeTrimSyncHook(passes::incrementAndGet); + manager.register(ring, tmpDir, watermark); + manager.start(); + for (int i = 0; i < acks; i++) { + Assert.assertTrue(ring.appendOrFsn(payload, 32) >= 0); + } + Thread.sleep(50); // let the register wake settle + passes.set(0); + + long start = System.nanoTime(); + long next = start; + for (int seq = 0; seq < acks; ) { + if (System.nanoTime() - next >= 0) { + ring.acknowledge(seq++); + next += spacingNanos; + } + } + long elapsedMillis = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start); + + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (watermark.read() != acks - 1 && System.nanoTime() < deadline) { + Thread.sleep(1); + } + Assert.assertEquals("watermark did not catch up after the ACK stream", acks - 1, watermark.read()); + long streamPasses = passes.get(); + // One wake for the first ACK, then at most one pass per + // WATERMARK_LAG_NANOS; x2 leaves room for scheduler jitter. + long bound = elapsedMillis * 2 + 16; + Assert.assertTrue("worker ran " + streamPasses + " passes for " + acks + " ACKs over " + + elapsedMillis + " ms; bound " + bound, streamPasses <= bound); + + // Once the stream stops the worker re-arms the ACK wake and + // parks without a timeout: no further passes while idle. + Thread.sleep(100); + long idlePasses = passes.get() - streamPasses; + Assert.assertTrue("worker kept ticking while idle: " + idlePasses + " passes", idlePasses <= 2); + } finally { + Unsafe.free(payload, 32, MemoryTag.NATIVE_DEFAULT); + watermark.close(); + ring.close(); + } + }); + } } From 38c5d69dc2de06b30d9c6b62b0847c4432fa1c79 Mon Sep 17 00:00:00 2001 From: Jaromir Hamala Date: Thu, 22 Oct 2026 13:27:29 +0200 Subject: [PATCH 12/36] fix(qwp): keep the ACK path off the ring monitor 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. --- .../qwp/client/sf/cursor/SegmentRing.java | 22 ++- .../cursor/SegmentRingAckOffMonitorTest.java | 144 ++++++++++++++++++ 2 files changed, 165 insertions(+), 1 deletion(-) create mode 100644 core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentRingAckOffMonitorTest.java diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentRing.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentRing.java index f868ea32b..00144aa35 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentRing.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentRing.java @@ -127,6 +127,13 @@ public final class SegmentRing implements QuietCloseable { // on its WATERMARK_LAG_NANOS cadence, so later ACKs ring nothing; it is // re-armed by a timed pass that finds no new ACK. private volatile long persistedAckFsn = Long.MIN_VALUE; + // Last FSN of the oldest sealed segment that is not I/O-pinned, or + // Long.MAX_VALUE when there is none: the ACK at or past it completes a + // trimmable segment. Re-derived under the monitor by every writer of the + // sealed head or the I/O pin (refreshOldestTrimmable), so acknowledge() + // can detect "completes the oldest sealed segment" without taking the + // monitor, which rotation and trim staging hold across a manifest fsync. + private volatile long oldestTrimmableLastSeq = Long.MAX_VALUE; // active: written by producer (constructor + appendOrFsn rotation), // read by I/O thread via getActive(). Volatile so the I/O thread sees // rotations promptly and never observes a torn reference. @@ -678,6 +685,7 @@ static Recovery recover( for (int i = 0, n = chain.size() - 1; i < n; i++) { ring.sealedSegments.add(chain.get(i)); } + ring.refreshOldestTrimmable(); // Allocate the return wrapper before transfer. Until this succeeds, // the outer catch remains the sole owner and can close all segments // and the manifest if construction or sealed-list growth fails. @@ -1009,7 +1017,7 @@ public boolean acknowledge(long seq) { ackedFsn = seq; // The manager does not poll: wake it when this ACK is the first past // the persisted watermark or completes the oldest sealed segment. - if (before <= persistedAckFsn || firstTrimmable() != null) { + if (before <= persistedAckFsn || seq >= oldestTrimmableLastSeq) { wakeManager(); } return true; @@ -1091,6 +1099,7 @@ public long appendOrFsn(long payloadAddr, int payloadLen) { } previous.linkSuccessor(spare); sealedSegments.add(previous); + refreshOldestTrimmable(); active = spare; } hotSpare = null; @@ -1266,6 +1275,7 @@ public synchronized void close() { sealedSegments.clear(); sealedHead = 0; firstNonDurableSealed = 0; + oldestTrimmableLastSeq = Long.MAX_VALUE; for (int i = 0, n = pendingTrims.size(); i < n; i++) { pendingTrims.getQuick(i).close(); } @@ -1616,6 +1626,7 @@ private synchronized boolean requestSyncBeforeRotation(MmapSegment previous) { synchronized void releasePinnedSegment(MmapSegment expected) { if (ioPinnedSegment == expected) { ioPinnedSegment = null; + refreshOldestTrimmable(); wakeManager(); } } @@ -1823,11 +1834,20 @@ private void removeSealedHead() { } else if (sealedHead >= 64 && sealedHead >= size - sealedHead) { compactSealedSegments(); } + refreshOldestTrimmable(); + } + + private void refreshOldestTrimmable() { + MmapSegment head = sealedHead < sealedSegments.size() ? sealedSegments.get(sealedHead) : null; + oldestTrimmableLastSeq = head == null || head == ioPinnedSegment + ? Long.MAX_VALUE + : head.baseSeq() + head.frameCount() - 1; } private void switchIoPin(MmapSegment segment) { MmapSegment previous = ioPinnedSegment; ioPinnedSegment = segment; + refreshOldestTrimmable(); if (previous != null && previous != segment) { wakeManager(); } diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentRingAckOffMonitorTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentRingAckOffMonitorTest.java new file mode 100644 index 000000000..b8dedda56 --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentRingAckOffMonitorTest.java @@ -0,0 +1,144 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +package io.questdb.client.test.cutlass.qwp.client.sf.cursor; + +import io.questdb.client.cutlass.qwp.client.sf.cursor.MmapSegment; +import io.questdb.client.cutlass.qwp.client.sf.cursor.SegmentRing; +import io.questdb.client.std.MemoryTag; +import io.questdb.client.std.Unsafe; +import io.questdb.client.test.tools.DelegatingFilesFacade; +import io.questdb.client.test.tools.TestUtils; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * {@link SegmentRing#acknowledge} runs on the I/O thread for every server + * ACK. In disk store-and-forward mode the producer's rotation and the + * manager's trim staging both hold the ring monitor across a manifest + * fsync, so the ACK path must never take that monitor: while it waits, the + * I/O thread neither sends nor receives. The trim wake it used to derive + * from the monitored {@code firstTrimmable()} must still fire. + */ +public class SegmentRingAckOffMonitorTest { + private static final int PAYLOAD = 64; + private static final long SEGMENT_SIZE = MmapSegment.HEADER_SIZE + 4 * (MmapSegment.FRAME_HEADER_SIZE + PAYLOAD); + private String tmpDir; + + @Before + public void setUp() { + tmpDir = TestUtils.createTmpDir("qdb-sf-ack-monitor-"); + } + + @After + public void tearDown() { + TestUtils.removeTmpDir(tmpDir); + } + + @Test(timeout = 20_000L) + public void testAcknowledgeDoesNotWaitForManifestFsyncHeldUnderRingMonitor() throws Exception { + TestUtils.assertMemoryLeak(() -> { + // Legacy slot with one frame (seq 0): recovery resumes it as the + // active segment and creates a manifest that goes through ff. + writeSegmentWithFrames(tmpDir + "/sf-initial.sfa"); + StallingFsyncFacade ff = new StallingFsyncFacade(); + SegmentRing ring = SegmentRing.openExisting(ff, tmpDir, SEGMENT_SIZE); + Assert.assertNotNull("legacy slot must recover", ring); + long payload = Unsafe.malloc(PAYLOAD, MemoryTag.NATIVE_DEFAULT); + try { + ring.installHotSpare(MmapSegment.create(tmpDir + "/sf-0000000000000004.sfa", 4L, SEGMENT_SIZE)); + // Fill the active: seqs 1..3. The next append rotates. + for (int i = 0; i < 3; i++) { + Assert.assertTrue(ring.appendOrFsn(payload, PAYLOAD) >= 0); + } + ff.stall = true; + Thread producer = new Thread(() -> Assert.assertEquals(4L, ring.appendOrFsn(payload, PAYLOAD)), "producer"); + producer.start(); + Assert.assertTrue("rotation must reach the manifest fsync", ff.inFsync.await(5, TimeUnit.SECONDS)); + // The rotation now holds the ring monitor inside the stalled fsync. + long start = System.nanoTime(); + Assert.assertTrue(ring.acknowledge(1)); + long tookMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start); + producer.join(); + ff.stall = false; + Assert.assertTrue("acknowledge waited " + tookMs + " ms behind the manifest fsync", tookMs < 50); + + // The sealed segment holds seqs 0..3. The ACK that completes it + // must still wake the manager; the one before it must not. + Assert.assertNotNull(ring.firstSealed()); + AtomicInteger wakes = new AtomicInteger(); + ring.setManagerWakeup(wakes::incrementAndGet); + Assert.assertTrue(ring.acknowledge(2)); + Assert.assertEquals("ACK inside the oldest sealed segment must not wake", 0, wakes.get()); + Assert.assertTrue(ring.acknowledge(3)); + Assert.assertEquals("ACK completing the oldest sealed segment must wake", 1, wakes.get()); + Assert.assertNotNull(ring.firstTrimmable()); + } finally { + Unsafe.free(payload, PAYLOAD, MemoryTag.NATIVE_DEFAULT); + ring.close(); + } + }); + } + + private static void writeSegmentWithFrames(String path) { + long buf = Unsafe.malloc(PAYLOAD, MemoryTag.NATIVE_DEFAULT); + try { + MmapSegment segment = MmapSegment.create(path, 0L, SEGMENT_SIZE); + try { + for (int b = 0; b < PAYLOAD; b++) { + Unsafe.getUnsafe().putByte(buf + b, (byte) b); + } + Assert.assertTrue("test frame must fit", segment.tryAppend(buf, PAYLOAD) >= 0); + } finally { + segment.close(); + } + } finally { + Unsafe.free(buf, PAYLOAD, MemoryTag.NATIVE_DEFAULT); + } + } + + private static final class StallingFsyncFacade extends DelegatingFilesFacade { + private final CountDownLatch inFsync = new CountDownLatch(1); + private volatile boolean stall; + + @Override + public int fsync(int fd) { + if (stall) { + inFsync.countDown(); + try { + Thread.sleep(300); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + return super.fsync(fd); + } + } +} From 50a7f4bc388087ca1c4c06e4b1846353dab48f09 Mon Sep 17 00:00:00 2001 From: Jaromir Hamala Date: Thu, 22 Oct 2026 13:37:54 +0200 Subject: [PATCH 13/36] fix(qwp): signal ack waiters from the engine, not the loop 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. --- .../qwp/client/QwpWebSocketSender.java | 4 + .../client/sf/cursor/CursorSendEngine.java | 9 + .../sf/cursor/CursorWebSocketSendLoop.java | 10 +- .../qwp/client/sf/cursor/SegmentRing.java | 20 ++ .../qwp/client/AckWaiterSignalTest.java | 192 ++++++++++++++++++ 5 files changed, 230 insertions(+), 5 deletions(-) create mode 100644 core/src/test/java/io/questdb/client/test/cutlass/qwp/client/AckWaiterSignalTest.java diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java index 547c43356..3f5635c61 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java @@ -3388,6 +3388,9 @@ public void setCursorEngine(CursorSendEngine engine, boolean takeOwnership) { if (cursorEngine != null) { throw new LineSenderException("CursorSendEngine is already attached"); } + if (engine != null) { + engine.setAckStateListener(ackWaiters::signal); + } this.cursorEngine = engine; this.ownsCursorEngine = takeOwnership && engine != null; // Delta encoding is available in memory-mode (in-process catch-up) and in @@ -5828,6 +5831,7 @@ private void completeRecycleRebuild(int dictSizeAtSwap, long startNanos) { // again on its first append (SymbolDictRecycleHealingTest). deltaDictEnabled = rebuilt.isDeltaDictEnabled(); rebuilt.adoptCounters(counters); + rebuilt.setAckStateListener(ackWaiters::signal); cursorEngine = rebuilt; ownsCursorEngine = true; cursorEngine.setSlotLockReleaseListener(this::onSlotLockReleased); diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorSendEngine.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorSendEngine.java index 642cd0883..ce41027e7 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorSendEngine.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorSendEngine.java @@ -908,6 +908,15 @@ public boolean acknowledge(long seq) { return ring.acknowledge(seq); } + /** + * Installs the callback run after every ack advance -- from any caller of + * {@link #acknowledge}, including the orphan-tail retirement self-ack -- + * and after a durability failure is latched. See {@link AckWaiters}. + */ + public void setAckStateListener(Runnable listener) { + ring.setAckStateListener(listener); + } + /** * I/O thread accessor: the current active mmap'd segment. */ diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoop.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoop.java index 0e029156a..52afeb6f0 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoop.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoop.java @@ -66,8 +66,8 @@ * to the server.

  • *
  • Receives server ACK frames; on each ACK with cumulative wire sequence * {@code N}, calls {@code engine.acknowledge(fsnAtZero + N)} so the - * segment manager can trim fully-acked segments, and runs the - * {@link #setAckProgressListener ack-progress listener}.
  • + * segment manager can trim fully-acked segments and the engine's + * ack-state listener wakes any ack waiters. *
  • On wire failure, runs the configured reconnect policy: capped * exponential backoff with jitter, retried indefinitely (Invariant B -- * a store-and-forward drainer never gives up on a wall-clock budget). @@ -2607,7 +2607,6 @@ private void haltOnPoisonedFrame(String lastRejection, long toFsnHint) { * {@link #externalFsnBase} before it reaches the user-visible dispatcher. */ private void dispatchProgress(long ackedFsn) { - signalAckProgress(); SenderProgressDispatcher d = progressDispatcher; if (d != null) { d.offer(externalFsnBase + ackedFsn); @@ -2622,8 +2621,9 @@ private void signalAckProgress() { } /** - * Installs the callback run after every ack advance, on a terminal latch - * and on {@link #close()}; see {@link AckWaiters}. + * Installs the callback run on a terminal latch and on {@link #close()}. + * Ack advances signal from the engine ({@link CursorSendEngine#setAckStateListener}), + * which also covers acks the loop never sees; see {@link AckWaiters}. */ public void setAckProgressListener(Runnable listener) { this.ackProgressListener = listener; diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentRing.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentRing.java index 00144aa35..3ba3f9745 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentRing.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentRing.java @@ -150,6 +150,9 @@ public final class SegmentRing implements QuietCloseable { // again once a subsequent periodic sync pass succeeds, so a transient // disk fault does not permanently brick the producer. private volatile MmapSegmentException durabilityFailure; + // Run after every ackedFsn advance and durability-latch set, from whichever + // thread made the change: the sender's ack waiters re-check both. + private volatile Runnable ackStateListener; // hotSpare: written by segment manager (installHotSpare), read+cleared by // producer thread on rotation. Volatile so the producer sees fresh installs. private volatile MmapSegment hotSpare; @@ -1020,11 +1023,27 @@ public boolean acknowledge(long seq) { if (before <= persistedAckFsn || seq >= oldestTrimmableLastSeq) { wakeManager(); } + signalAckState(); return true; } return false; } + private void signalAckState() { + Runnable l = ackStateListener; + if (l != null) { + l.run(); + } + } + + /** + * Installs the callback run after every {@link #acknowledge} advance and + * every {@link #recordDurabilityFailure} latch, on the calling thread. + */ + public void setAckStateListener(Runnable listener) { + this.ackStateListener = listener; + } + /** * Single-producer append path. Reserves an FSN, writes the frame into * the active segment, advances {@link #publishedFsn}. Returns the assigned @@ -1248,6 +1267,7 @@ void recordDurabilityFailure(Throwable failure) { durabilityFailure = wrapped; } } + signalAckState(); } } diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/AckWaiterSignalTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/AckWaiterSignalTest.java new file mode 100644 index 000000000..039016311 --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/AckWaiterSignalTest.java @@ -0,0 +1,192 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +package io.questdb.client.test.cutlass.qwp.client; + +import io.questdb.client.Sender; +import io.questdb.client.cutlass.qwp.client.QwpWebSocketSender; +import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorSendEngine; +import io.questdb.client.cutlass.qwp.client.sf.cursor.MmapSegmentException; +import io.questdb.client.cutlass.qwp.client.sf.cursor.SegmentManager; +import io.questdb.client.std.FilesFacade; +import io.questdb.client.std.MemoryTag; +import io.questdb.client.std.Unsafe; +import io.questdb.client.test.cutlass.qwp.websocket.TestWebSocketServer; +import org.junit.Assert; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.lang.reflect.Proxy; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; + +import static io.questdb.client.test.tools.TestUtils.assertMemoryLeak; + +/** + * Every state change an {@code awaitAckedFsn} / {@code drain} waiter re-checks + * must unpark it. Two of them bypass the I/O loop's ack-progress listener: + * the orphan-tail retirement self-ack and the periodic-sync durability latch. + * Without a wake, the waiter only notices at its deadline. + */ +public class AckWaiterSignalTest { + private static final long SEGMENT_SIZE = 64 * 1024L; + + @Rule + public final TemporaryFolder temporaryFolder = TemporaryFolder.builder().assureDeletion().build(); + + /** + * A store-and-forward producer dies mid-transaction: two committed frames + * (FSN 0, 1) and two FLAG_DEFER_COMMIT frames (FSN 2, 3) whose commit never + * went out. The successor recovers, replays 0 and 1, and once both are + * acked retires the orphan tail with a cumulative self-acknowledge of 3 on + * the I/O thread. {@code drain()} targets publishedFsn = 3, so that + * self-ack is exactly what it waits for. + */ + @Test + public void testDrainWakesOnOrphanTailRetirement() throws Exception { + assertMemoryLeak(() -> { + String sfDir = temporaryFolder.getRoot().toPath().resolve("orphan-tail").toString(); + String opts = ";sf_dir=" + sfDir + ";close_flush_timeout_millis=0;"; + try (TestWebSocketServer silent = startedServer(new TestWebSocketServer.WebSocketServerHandler() { + })) { + // Defer-commit mode leaves the tail uncommitted through close(), and + // close_flush_timeout_millis=0 against a never-acking server does not + // drain, so the segment and manifest survive exactly as a crash leaves them. + try (Sender s = Sender.fromConfig("ws::addr=localhost:" + silent.getPort() + opts)) { + s.table("t").longColumn("v", 1).atNow(); + s.flush(); // FSN 0, commit-bearing + s.table("t").longColumn("v", 2).atNow(); + s.flush(); // FSN 1, commit-bearing + ((QwpWebSocketSender) s).setDeferCommit(true); + s.table("t").longColumn("v", 3).atNow(); + s.flush(); // FSN 2, deferred + s.table("t").longColumn("v", 4).atNow(); + s.flush(); // FSN 3, deferred; the commit never goes out + } + } + try (TestWebSocketServer acking = startedServer(new DelayingAckHandler(100))) { + try (Sender s = Sender.fromConfig("ws::addr=localhost:" + acking.getPort() + opts)) { + CursorSendEngine recovered = ((QwpWebSocketSender) s).getCursorEngineForTesting(); + Assert.assertEquals("the recovered log must end in the orphan tail [2..3]", + 3L, recovered.recoveredOrphanTipFsn()); + Assert.assertEquals(1L, recovered.recoveredCommitBoundaryFsn()); + long t0 = System.nanoTime(); + boolean drained = s.drain(3000); + long elapsedMs = (System.nanoTime() - t0) / 1_000_000L; + Assert.assertTrue("drain must reach the retired orphan tip", drained); + Assert.assertEquals(3L, s.getAckedFsn()); + Assert.assertTrue("drain returned only after " + elapsedMs + "ms: the orphan-tail " + + "self-ack did not wake the waiter", elapsedMs < 1000L); + } + } + }); + } + + /** + * sf_durability=periodic: the segment manager's sync pass hits an fsync + * failure and latches it on the ring. A thread parked in awaitAckedFsn + * must see the MmapSegmentException on the latch, not at its deadline. + */ + @Test + public void testAwaitAckedFsnWakesOnPeriodicSyncDurabilityFailure() throws Exception { + assertMemoryLeak(() -> { + AtomicBoolean failFsync = new AtomicBoolean(); + FilesFacade ff = (FilesFacade) Proxy.newProxyInstance(FilesFacade.class.getClassLoader(), + new Class[]{FilesFacade.class}, (p, m, a) -> { + if (m.getName().equals("fsync") && failFsync.get()) { + return -1; // EIO + } + return m.invoke(FilesFacade.INSTANCE, a); + }); + SegmentManager manager = new SegmentManager(SEGMENT_SIZE, SegmentManager.UNLIMITED_TOTAL_BYTES, ff); + manager.start(); + CursorSendEngine engine = new CursorSendEngine( + temporaryFolder.newFolder().getAbsolutePath(), SEGMENT_SIZE, manager, + TimeUnit.MILLISECONDS.toNanos(100)); + QwpWebSocketSender sender = QwpWebSocketSender.createForTesting("localhost", 1); + long payload = Unsafe.malloc(16, MemoryTag.NATIVE_DEFAULT); + try { + sender.setCursorEngine(engine, false); + AtomicReference outcome = new AtomicReference<>(); + AtomicLong doneAt = new AtomicLong(); + Thread waiter = new Thread(() -> { + try { + sender.awaitAckedFsn(0L, 3000); + } catch (Throwable t) { + outcome.set(t); + } + doneAt.set(System.nanoTime()); + }, "awaiter"); + waiter.start(); + Thread.sleep(200); // parked + failFsync.set(true); + engine.getRingForTesting().appendOrFsn(payload, 16); // dirties the active segment + long triggerAt = System.nanoTime(); + waiter.join(5000); + Assert.assertFalse("waiter stuck", waiter.isAlive()); + long elapsedMs = (doneAt.get() - triggerAt) / 1_000_000L; + Assert.assertTrue("awaitAckedFsn must throw the latched durability failure, got: " + + outcome.get(), outcome.get() instanceof MmapSegmentException); + Assert.assertTrue("the latch surfaced only after " + elapsedMs + "ms: it did not wake " + + "the waiter", elapsedMs < 500L); + } finally { + failFsync.set(false); + sender.close(); + engine.close(); + manager.close(); + Unsafe.free(payload, 16, MemoryTag.NATIVE_DEFAULT); + } + }); + } + + private static TestWebSocketServer startedServer(TestWebSocketServer.WebSocketServerHandler handler) + throws Exception { + TestWebSocketServer server = new TestWebSocketServer(handler); + server.start(); + Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); + return server; + } + + private static class DelayingAckHandler implements TestWebSocketServer.WebSocketServerHandler { + private final long delayMs; + private final AtomicLong nextSeq = new AtomicLong(); + + DelayingAckHandler(long delayMs) { + this.delayMs = delayMs; + } + + @Override + public void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] data) { + try { + Thread.sleep(delayMs); + client.sendBinary(CloseDrainTest.buildAck(nextSeq.getAndIncrement())); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + } +} From a30b5e5224198e7246b01baa9097b2ef42e88abd Mon Sep 17 00:00:00 2001 From: Jaromir Hamala Date: Thu, 22 Oct 2026 13:44:52 +0200 Subject: [PATCH 14/36] perf(qwp): make ack waiter registration allocation-free 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. --- .../qwp/client/sf/cursor/AckWaiters.java | 61 +++-- .../qwp/client/sf/cursor/AckWaitersTest.java | 226 ++++++++++++++++++ 2 files changed, 264 insertions(+), 23 deletions(-) create mode 100644 core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/AckWaitersTest.java diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/AckWaiters.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/AckWaiters.java index d87ee4cd0..7f3ee52c0 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/AckWaiters.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/AckWaiters.java @@ -24,7 +24,6 @@ package io.questdb.client.cutlass.qwp.client.sf.cursor; -import java.util.Arrays; import java.util.concurrent.locks.LockSupport; /** @@ -34,32 +33,45 @@ * call, then loops check / park until done, and deregisters; the I/O thread * advances the watermark and then calls {@link #signal()}. Either the waiter's * check sees the new value or the signal sees the waiter, and an unpark that - * lands between check and park is kept as the park permit. Registration - * allocates; {@link #signal()} and the check/park loop do not. + * lands between check and park is kept as the park permit. + *

    + * Waiters live in a fixed slot array: {@link #add} takes a null slot and + * {@link #remove} nulls it again, so steady-state registration allocates + * nothing; the array only grows (doubles, under the lock) when more threads + * wait at once than ever before. {@link #signal()} is lock-free and + * allocation-free: it reads the volatile array and unparks every non-null + * slot. A slot nulled concurrently is read as null (unpark(null) is a no-op) + * or as the departing thread, which then receives one extra permit; that is + * harmless because a permit is per thread, consumed by its next park, and + * every park in this codebase sits in a re-check loop. */ public final class AckWaiters { - private static final Thread[] NONE = new Thread[0]; - private volatile Thread[] waiters = NONE; + private volatile Thread[] slots = new Thread[4]; public synchronized void add(Thread t) { - Thread[] w = waiters; - Thread[] n = Arrays.copyOf(w, w.length + 1); - n[w.length] = t; - waiters = n; + Thread[] s = slots; + for (int i = 0; i < s.length; i++) { + if (s[i] == null) { + s[i] = t; + // Volatile self-store: orders the slot write before the waiter's + // first check, so a signal() that misses the slot (its volatile + // read of the array predates this store) is one whose watermark + // write that check is guaranteed to see. + slots = s; + return; + } + } + Thread[] n = new Thread[s.length * 2]; + System.arraycopy(s, 0, n, 0, s.length); + n[s.length] = t; + slots = n; } public synchronized void remove(Thread t) { - Thread[] w = waiters; - for (int i = 0; i < w.length; i++) { - if (w[i] == t) { - if (w.length == 1) { - waiters = NONE; - return; - } - Thread[] n = new Thread[w.length - 1]; - System.arraycopy(w, 0, n, 0, i); - System.arraycopy(w, i + 1, n, i, w.length - i - 1); - waiters = n; + Thread[] s = slots; + for (int i = 0; i < s.length; i++) { + if (s[i] == t) { + s[i] = null; return; } } @@ -70,9 +82,12 @@ public synchronized void remove(Thread t) { * fine because waiters re-check their condition. */ public void signal() { - Thread[] w = waiters; - for (int i = 0; i < w.length; i++) { - LockSupport.unpark(w[i]); + Thread[] s = slots; + for (int i = 0; i < s.length; i++) { + Thread t = s[i]; + if (t != null) { + LockSupport.unpark(t); + } } } } diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/AckWaitersTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/AckWaitersTest.java new file mode 100644 index 000000000..3864d9c34 --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/AckWaitersTest.java @@ -0,0 +1,226 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + + +package io.questdb.client.test.cutlass.qwp.client.sf.cursor; + +import io.questdb.client.cutlass.qwp.client.sf.cursor.AckWaiters; +import org.junit.Assert; +import org.junit.Assume; +import org.junit.Test; + +import java.lang.management.ManagementFactory; +import java.lang.management.ThreadMXBean; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.locks.LockSupport; + +/** + * {@link AckWaiters} alone, no sender: every {@code awaitAckedFsn} / {@code drain} + * call that has to wait registers and deregisters once, so steady-state + * add + remove must not allocate; and a signal must reach every registered + * waiter and none of the deregistered ones. + */ +public class AckWaitersTest { + private static final int ITERATIONS = 10_000; + private static final int WARMUP = 20_000; + + @Test(timeout = 60_000L) + public void testAddRemoveDoesNotAllocateWithOneWaiter() { + AckWaiters w = new AckWaiters(); + assertAddRemoveAllocatesNothing(w); + } + + @Test(timeout = 60_000L) + public void testAddRemoveDoesNotAllocateWithThreeWaiters() throws Exception { + AckWaiters w = new AckWaiters(); + CountDownLatch registered = new CountDownLatch(2); + CountDownLatch release = new CountDownLatch(1); + Thread[] others = new Thread[2]; + for (int i = 0; i < others.length; i++) { + others[i] = new Thread(() -> { + Thread me = Thread.currentThread(); + w.add(me); + registered.countDown(); + try { + release.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + w.remove(me); + } + }, "ack-waiters-bystander-" + i); + others[i].start(); + } + try { + Assert.assertTrue(registered.await(5, TimeUnit.SECONDS)); + // two waiters stay registered the whole time; we are the third + assertAddRemoveAllocatesNothing(w); + } finally { + release.countDown(); + for (Thread t : others) { + t.join(5_000); + } + } + } + + /** + * Five waiters: more than the initial capacity, so this also covers + * growth. Every one of them must be unparked by a single signal. + */ + @Test(timeout = 60_000L) + public void testSignalUnparksEveryRegisteredWaiter() throws Exception { + AckWaiters w = new AckWaiters(); + AtomicBoolean go = new AtomicBoolean(); + AtomicInteger woken = new AtomicInteger(); + CountDownLatch registered = new CountDownLatch(5); + Thread[] waiters = new Thread[5]; + for (int i = 0; i < waiters.length; i++) { + waiters[i] = new Thread(() -> { + Thread me = Thread.currentThread(); + w.add(me); + registered.countDown(); + try { + while (!go.get()) { + LockSupport.park(w); + } + woken.incrementAndGet(); + } finally { + w.remove(me); + } + }, "ack-waiter-" + i); + waiters[i].start(); + } + Assert.assertTrue(registered.await(5, TimeUnit.SECONDS)); + for (Thread t : waiters) { + awaitParked(t); + } + go.set(true); + w.signal(); + for (Thread t : waiters) { + t.join(10_000); + Assert.assertFalse(t.getName() + " still parked after signal()", t.isAlive()); + } + Assert.assertEquals(waiters.length, woken.get()); + } + + /** + * A waiter that deregistered before the signal must not be handed a + * permit: its next park has to actually wait. The remaining registered + * waiter proves the signal was delivered. + */ + @Test(timeout = 60_000L) + public void testSignalSkipsRemovedWaiter() throws Exception { + AckWaiters w = new AckWaiters(); + AtomicBoolean go = new AtomicBoolean(); + CountDownLatch bothRegistered = new CountDownLatch(2); + CountDownLatch removedOne = new CountDownLatch(1); + CountDownLatch signalled = new CountDownLatch(1); + long[] departedParkedNanos = new long[1]; + + Thread departed = new Thread(() -> { + Thread me = Thread.currentThread(); + w.add(me); + bothRegistered.countDown(); + w.remove(me); + removedOne.countDown(); + try { + signalled.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + long t0 = System.nanoTime(); + LockSupport.parkNanos(w, TimeUnit.MILLISECONDS.toNanos(300)); + departedParkedNanos[0] = System.nanoTime() - t0; + }, "ack-waiter-departed"); + Thread staying = new Thread(() -> { + Thread me = Thread.currentThread(); + w.add(me); + bothRegistered.countDown(); + try { + while (!go.get()) { + LockSupport.park(w); + } + } finally { + w.remove(me); + } + }, "ack-waiter-staying"); + departed.start(); + staying.start(); + + Assert.assertTrue(bothRegistered.await(5, TimeUnit.SECONDS)); + Assert.assertTrue(removedOne.await(5, TimeUnit.SECONDS)); + awaitParked(staying); + go.set(true); + w.signal(); + signalled.countDown(); + staying.join(10_000); + Assert.assertFalse("registered waiter not woken", staying.isAlive()); + departed.join(10_000); + Assert.assertFalse(departed.isAlive()); + // a leaked permit would make parkNanos return in microseconds + Assert.assertTrue("removed waiter was unparked: parked only " + departedParkedNanos[0] / 1_000_000L + " ms", + departedParkedNanos[0] >= TimeUnit.MILLISECONDS.toNanos(100)); + } + + private static void assertAddRemoveAllocatesNothing(AckWaiters w) { + ThreadMXBean plain = ManagementFactory.getThreadMXBean(); + Assume.assumeTrue("com.sun.management.ThreadMXBean required", plain instanceof com.sun.management.ThreadMXBean); + com.sun.management.ThreadMXBean mx = (com.sun.management.ThreadMXBean) plain; + Assume.assumeTrue("thread allocation accounting required", mx.isThreadAllocatedMemorySupported()); + if (!mx.isThreadAllocatedMemoryEnabled()) { + mx.setThreadAllocatedMemoryEnabled(true); + } + Thread me = Thread.currentThread(); + long tid = me.getId(); + for (int i = 0; i < WARMUP; i++) { + w.add(me); + w.remove(me); + } + long before = mx.getThreadAllocatedBytes(tid); + for (int i = 0; i < ITERATIONS; i++) { + w.add(me); + w.remove(me); + } + long bytes = mx.getThreadAllocatedBytes(tid) - before; + // getThreadAllocatedBytes itself may allocate a few dozen bytes on + // JDK 8; anything per iteration shows up as kilobytes. + System.out.println("AckWaiters add+remove: " + bytes + " B over " + ITERATIONS + " iterations"); + Assert.assertEquals("bytes per add+remove", 0L, bytes / ITERATIONS); + Assert.assertTrue("add+remove allocated " + bytes + " B over " + ITERATIONS + " iterations", bytes < 1024); + } + + private static void awaitParked(Thread t) throws InterruptedException { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (t.getState() != Thread.State.WAITING && t.getState() != Thread.State.TIMED_WAITING) { + if (System.nanoTime() > deadline) { + throw new AssertionError(t.getName() + " never parked: " + t.getState()); + } + Thread.sleep(1); + } + } +} From 19644be9d51bf92906a48bfa29db0eddd4df0972 Mon Sep 17 00:00:00 2001 From: Jaromir Hamala Date: Thu, 22 Oct 2026 13:56:12 +0200 Subject: [PATCH 15/36] perf(qwp): unpark the progress dispatcher only when it is parked 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. --- .../sf/cursor/SenderProgressDispatcher.java | 31 ++++++++--- .../cursor/SenderProgressDispatcherTest.java | 54 +++++++++++++++++++ 2 files changed, 79 insertions(+), 6 deletions(-) diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SenderProgressDispatcher.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SenderProgressDispatcher.java index 517215415..d06893a0d 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SenderProgressDispatcher.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SenderProgressDispatcher.java @@ -96,6 +96,12 @@ public final class SenderProgressDispatcher implements QuietCloseable { // delivered. Monotonic FSNs make overwrite safe -- the latest value // subsumes any prior undelivered value. private volatile long latestFsn = EMPTY; + // Dekker flag mirroring CursorWebSocketSendLoop.ioParked: the dispatcher + // sets it, re-reads latestFsn/closed, then parks; offer() writes + // latestFsn, then reads it and unparks only if set. Either the dispatcher + // sees the new value before parking, or offer() sees parked=true and + // unparks -- no lost wake, and a busy dispatcher costs offer() no futex. + private volatile boolean parked; public SenderProgressDispatcher(SenderProgressHandler handler, int capacity) { this(handler, capacity, "qdb-sf-progress-dispatcher"); @@ -175,6 +181,11 @@ public Thread getWorkerThreadForTesting() { return dispatcherThread; } + @TestOnly + public boolean isParkedForTesting() { + return parked; + } + /** * Replace the user-supplied handler. Effective immediately for any * subsequent delivery. Pass {@code null} to install the no-op default. @@ -205,7 +216,7 @@ public boolean offer(long ackedFsn) { startDispatcherIfNeeded(); t = dispatcherThread; } - if (t != null) { + if (t != null && parked) { LockSupport.unpark(t); } return true; @@ -234,11 +245,19 @@ private void dispatchLoop() { if (closed) { return; } - // No new value; park until offer() or close() unparks us. Both write - // their state (latestFsn / closed) before the unpark and this loop - // re-reads it before parking, so no wake can be lost. - // Spurious wakeups loop back to the top and re-check. - LockSupport.park(this); + // No new value; park until offer() or close() unparks us. Publish + // parked=true first, then re-read latestFsn/closed: offer() writes + // latestFsn before reading parked, so either we see the value here + // or it sees the flag and unparks -- no wake can be lost. close() + // unparks unconditionally. Spurious wakeups loop back and re-check. + parked = true; + try { + if (latestFsn == v && !closed) { + LockSupport.park(this); + } + } finally { + parked = false; + } } } diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SenderProgressDispatcherTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SenderProgressDispatcherTest.java index b94154e80..f94f8fc03 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SenderProgressDispatcherTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SenderProgressDispatcherTest.java @@ -217,6 +217,27 @@ public void testHandlerThrowDoesNotKillDispatcher() throws Exception { } } + @Test + public void testIdleDispatcherIsUnparkedByOffer() throws Exception { + // Lost-wake guard for the parked/offer Dekker: a dispatcher that has + // been idle (parked, no timeout) must be woken by the next offer and + // deliver it promptly. Without the unpark it would sleep until close. + CountDownLatch second = new CountDownLatch(1); + try (SenderProgressDispatcher d = new SenderProgressDispatcher(fsn -> { + if (fsn == 2) { + second.countDown(); + } + }, 4)) { + Assert.assertTrue(d.offer(1)); + waitForDelivered(d, 1); + TimeUnit.MILLISECONDS.sleep(200); + Assert.assertTrue("idle dispatcher must be parked", d.isParkedForTesting()); + Assert.assertTrue(d.offer(2)); + Assert.assertTrue("parked dispatcher must be woken by offer()", + second.await(100, TimeUnit.MILLISECONDS)); + } + } + @Test public void testLazyStartOnFirstOffer() throws Exception { // No thread should exist before the first offer; workloads with zero @@ -246,6 +267,39 @@ public void testOfferAfterCloseReturnsFalse() { Assert.assertEquals(0L, d.getTotalDelivered()); } + @Test + public void testOfferWhileHandlerBusyDoesNotSeeParked() throws Exception { + // While the handler is running the dispatcher is not parked, so + // offer() must not pay an unpark; the busy dispatcher picks the + // newest value up on its next loop iteration instead. + CountDownLatch unblock = new CountDownLatch(1); + CountDownLatch entered = new CountDownLatch(1); + AtomicLong last = new AtomicLong(-1); + CountDownLatch delivered = new CountDownLatch(2); + try (SenderProgressDispatcher d = new SenderProgressDispatcher(fsn -> { + entered.countDown(); + try { + unblock.await(); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + last.set(fsn); + delivered.countDown(); + }, 4)) { + Assert.assertTrue(d.offer(0)); + Assert.assertTrue(entered.await(2, TimeUnit.SECONDS)); + for (int i = 1; i <= 1000; i++) { + Assert.assertTrue(d.offer(i)); + Assert.assertFalse("dispatcher must not report parked while the handler runs", + d.isParkedForTesting()); + } + unblock.countDown(); + Assert.assertTrue(delivered.await(2, TimeUnit.SECONDS)); + Assert.assertEquals("busy dispatcher must deliver the newest value", 1000L, last.get()); + Assert.assertEquals(2L, d.getTotalDelivered()); + } + } + @Test public void testTotalDeliveredCounter() throws Exception { CountDownLatch reachedLast = new CountDownLatch(1); From 705333385bbf84e18ecf01431a6df3d0e23de0eb Mon Sep 17 00:00:00 2001 From: Jaromir Hamala Date: Thu, 22 Oct 2026 13:57:00 +0200 Subject: [PATCH 16/36] Revert "perf(qwp): unpark the progress dispatcher only when it is parked" This reverts commit 19644be9d51bf92906a48bfa29db0eddd4df0972. --- .../sf/cursor/SenderProgressDispatcher.java | 31 +++-------- .../cursor/SenderProgressDispatcherTest.java | 54 ------------------- 2 files changed, 6 insertions(+), 79 deletions(-) diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SenderProgressDispatcher.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SenderProgressDispatcher.java index d06893a0d..517215415 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SenderProgressDispatcher.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SenderProgressDispatcher.java @@ -96,12 +96,6 @@ public final class SenderProgressDispatcher implements QuietCloseable { // delivered. Monotonic FSNs make overwrite safe -- the latest value // subsumes any prior undelivered value. private volatile long latestFsn = EMPTY; - // Dekker flag mirroring CursorWebSocketSendLoop.ioParked: the dispatcher - // sets it, re-reads latestFsn/closed, then parks; offer() writes - // latestFsn, then reads it and unparks only if set. Either the dispatcher - // sees the new value before parking, or offer() sees parked=true and - // unparks -- no lost wake, and a busy dispatcher costs offer() no futex. - private volatile boolean parked; public SenderProgressDispatcher(SenderProgressHandler handler, int capacity) { this(handler, capacity, "qdb-sf-progress-dispatcher"); @@ -181,11 +175,6 @@ public Thread getWorkerThreadForTesting() { return dispatcherThread; } - @TestOnly - public boolean isParkedForTesting() { - return parked; - } - /** * Replace the user-supplied handler. Effective immediately for any * subsequent delivery. Pass {@code null} to install the no-op default. @@ -216,7 +205,7 @@ public boolean offer(long ackedFsn) { startDispatcherIfNeeded(); t = dispatcherThread; } - if (t != null && parked) { + if (t != null) { LockSupport.unpark(t); } return true; @@ -245,19 +234,11 @@ private void dispatchLoop() { if (closed) { return; } - // No new value; park until offer() or close() unparks us. Publish - // parked=true first, then re-read latestFsn/closed: offer() writes - // latestFsn before reading parked, so either we see the value here - // or it sees the flag and unparks -- no wake can be lost. close() - // unparks unconditionally. Spurious wakeups loop back and re-check. - parked = true; - try { - if (latestFsn == v && !closed) { - LockSupport.park(this); - } - } finally { - parked = false; - } + // No new value; park until offer() or close() unparks us. Both write + // their state (latestFsn / closed) before the unpark and this loop + // re-reads it before parking, so no wake can be lost. + // Spurious wakeups loop back to the top and re-check. + LockSupport.park(this); } } diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SenderProgressDispatcherTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SenderProgressDispatcherTest.java index f94f8fc03..b94154e80 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SenderProgressDispatcherTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SenderProgressDispatcherTest.java @@ -217,27 +217,6 @@ public void testHandlerThrowDoesNotKillDispatcher() throws Exception { } } - @Test - public void testIdleDispatcherIsUnparkedByOffer() throws Exception { - // Lost-wake guard for the parked/offer Dekker: a dispatcher that has - // been idle (parked, no timeout) must be woken by the next offer and - // deliver it promptly. Without the unpark it would sleep until close. - CountDownLatch second = new CountDownLatch(1); - try (SenderProgressDispatcher d = new SenderProgressDispatcher(fsn -> { - if (fsn == 2) { - second.countDown(); - } - }, 4)) { - Assert.assertTrue(d.offer(1)); - waitForDelivered(d, 1); - TimeUnit.MILLISECONDS.sleep(200); - Assert.assertTrue("idle dispatcher must be parked", d.isParkedForTesting()); - Assert.assertTrue(d.offer(2)); - Assert.assertTrue("parked dispatcher must be woken by offer()", - second.await(100, TimeUnit.MILLISECONDS)); - } - } - @Test public void testLazyStartOnFirstOffer() throws Exception { // No thread should exist before the first offer; workloads with zero @@ -267,39 +246,6 @@ public void testOfferAfterCloseReturnsFalse() { Assert.assertEquals(0L, d.getTotalDelivered()); } - @Test - public void testOfferWhileHandlerBusyDoesNotSeeParked() throws Exception { - // While the handler is running the dispatcher is not parked, so - // offer() must not pay an unpark; the busy dispatcher picks the - // newest value up on its next loop iteration instead. - CountDownLatch unblock = new CountDownLatch(1); - CountDownLatch entered = new CountDownLatch(1); - AtomicLong last = new AtomicLong(-1); - CountDownLatch delivered = new CountDownLatch(2); - try (SenderProgressDispatcher d = new SenderProgressDispatcher(fsn -> { - entered.countDown(); - try { - unblock.await(); - } catch (InterruptedException ignored) { - Thread.currentThread().interrupt(); - } - last.set(fsn); - delivered.countDown(); - }, 4)) { - Assert.assertTrue(d.offer(0)); - Assert.assertTrue(entered.await(2, TimeUnit.SECONDS)); - for (int i = 1; i <= 1000; i++) { - Assert.assertTrue(d.offer(i)); - Assert.assertFalse("dispatcher must not report parked while the handler runs", - d.isParkedForTesting()); - } - unblock.countDown(); - Assert.assertTrue(delivered.await(2, TimeUnit.SECONDS)); - Assert.assertEquals("busy dispatcher must deliver the newest value", 1000L, last.get()); - Assert.assertEquals(2L, d.getTotalDelivered()); - } - } - @Test public void testTotalDeliveredCounter() throws Exception { CountDownLatch reachedLast = new CountDownLatch(1); From e7ec089e2f6fc3f74178e0c3773f629bc1346d1b Mon Sep 17 00:00:00 2001 From: Jaromir Hamala Date: Thu, 22 Oct 2026 14:04:54 +0200 Subject: [PATCH 17/36] perf(qwp): do not run the progress dispatcher for the no-op handler 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 --- .../sf/cursor/SenderProgressDispatcher.java | 39 ++++++++++++---- .../cursor/SenderProgressDispatcherTest.java | 46 +++++++++++++++++++ 2 files changed, 77 insertions(+), 8 deletions(-) diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SenderProgressDispatcher.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SenderProgressDispatcher.java index 517215415..c348a24c0 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SenderProgressDispatcher.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SenderProgressDispatcher.java @@ -59,8 +59,11 @@ * *

    Lifecycle

    * The dispatcher thread is started lazily on the first successful - * {@link #offer}, so workloads that never receive acks (none in practice) pay - * zero thread cost. {@link #close()} is idempotent. + * {@link #offer} made while a real handler is installed. With the no-op + * {@link DefaultSenderProgressHandler} (the common case: nobody called + * {@code setProgressHandler}) an offer only records the watermark, so acks + * cost no thread, no unpark and no syscall; installing a handler later + * delivers the newest recorded watermark. {@link #close()} is idempotent. * *

    Exception safety

    * Any {@link Throwable} thrown by the handler is caught and logged. The @@ -149,9 +152,11 @@ public void close() { /** * Watermark advances overwritten before the handler observed them, since - * startup. Computed as {@code totalOffered - totalDelivered}; a small - * non-zero value typically reflects a single in-flight advance, while a - * persistently growing value signals a handler slower than the ack rate. + * startup. Offers made while the no-op default handler was installed are + * not counted: there was no handler to observe them. Computed as + * {@code totalOffered - totalDelivered}; a small non-zero value typically + * reflects a single in-flight advance, while a persistently growing value + * signals a handler slower than the ack rate. * Useful as an ops signal when handler latency matters. */ public long getDroppedNotifications() { @@ -181,7 +186,15 @@ public Thread getWorkerThreadForTesting() { * Callable both before and after {@link #offer(long) start of dispatch}. */ public void setHandler(SenderProgressHandler handler) { - this.handler = handler != null ? handler : DefaultSenderProgressHandler.INSTANCE; + SenderProgressHandler h = handler != null ? handler : DefaultSenderProgressHandler.INSTANCE; + this.handler = h; + // Watermarks offered under the no-op default were skipped (see + // offer()); hand the newest one to the real handler now. The + // volatile handler write above / latestFsn read below pair with the + // reverse order in offer(), so a concurrent offer cannot be lost. + if (h != DefaultSenderProgressHandler.INSTANCE && latestFsn != EMPTY) { + wakeDispatcher(); + } } /** @@ -198,8 +211,19 @@ public boolean offer(long ackedFsn) { if (closed) { return false; } - totalOffered.incrementAndGet(); latestFsn = ackedFsn; + if (handler == DefaultSenderProgressHandler.INSTANCE) { + // No-op handler installed: nothing to deliver, so no thread + // start and no unpark. The value stays in the slot for a + // handler installed later (see setHandler()). + return true; + } + totalOffered.incrementAndGet(); + wakeDispatcher(); + return true; + } + + private void wakeDispatcher() { Thread t = dispatcherThread; if (t == null) { startDispatcherIfNeeded(); @@ -208,7 +232,6 @@ public boolean offer(long ackedFsn) { if (t != null) { LockSupport.unpark(t); } - return true; } private void dispatchLoop() { diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SenderProgressDispatcherTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SenderProgressDispatcherTest.java index b94154e80..2184967bf 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SenderProgressDispatcherTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SenderProgressDispatcherTest.java @@ -24,6 +24,7 @@ package io.questdb.client.test.cutlass.qwp.client.sf.cursor; +import io.questdb.client.cutlass.qwp.client.sf.cursor.DefaultSenderProgressHandler; import io.questdb.client.cutlass.qwp.client.sf.cursor.SenderProgressDispatcher; import org.junit.Assert; import org.junit.Test; @@ -189,6 +190,33 @@ public void testDroppedNotificationsCounter() throws Exception { } } + @Test + public void testHandlerInstalledAfterSkippedOffersDelivers() throws Exception { + // Offers skipped under the no-op default must not be lost: once a + // real handler is installed, the newest watermark reaches it. + AtomicLong last = new AtomicLong(-1); + CountDownLatch gotNewest = new CountDownLatch(1); + try (SenderProgressDispatcher d = new SenderProgressDispatcher( + DefaultSenderProgressHandler.INSTANCE, 4, "late-handler-progress-test")) { + for (int i = 1; i <= 1000; i++) { + Assert.assertTrue(d.offer(i)); + } + Assert.assertNull("no-op handler must not start the dispatcher thread", + d.getWorkerThreadForTesting()); + d.setHandler(fsn -> { + last.set(fsn); + if (fsn == 1001) { + gotNewest.countDown(); + } + }); + Assert.assertTrue(d.offer(1001)); + Assert.assertTrue("real handler must receive the newest watermark", + gotNewest.await(1, TimeUnit.SECONDS)); + Assert.assertEquals(1001L, last.get()); + Assert.assertNotNull(d.getWorkerThreadForTesting()); + } + } + @Test public void testHandlerThrowDoesNotKillDispatcher() throws Exception { // A handler that throws on the first call must not poison the @@ -237,6 +265,24 @@ public void testLazyStartOnFirstOffer() throws Exception { } } + @Test + public void testNoOpHandlerDoesNotStartDispatcherThread() { + // The vast majority of senders never install a progress handler. + // For them an ACK must cost nothing on the dispatcher path: no + // thread start, no unpark. + try (SenderProgressDispatcher d = new SenderProgressDispatcher( + DefaultSenderProgressHandler.INSTANCE, 4, "noop-progress-test")) { + for (int i = 0; i < 1000; i++) { + Assert.assertTrue(d.offer(i)); + } + Assert.assertNull("no-op handler must not start the dispatcher thread", + d.getWorkerThreadForTesting()); + Assert.assertNull(findThreadByName("noop-progress-test")); + Assert.assertEquals(0L, d.getTotalDelivered()); + Assert.assertEquals(0L, d.getDroppedNotifications()); + } + } + @Test public void testOfferAfterCloseReturnsFalse() { SenderProgressDispatcher d = new SenderProgressDispatcher(fsn -> { /* no-op */ }, 4); From 1346c0985c6c2eeb64c1196ab91718c92115aa6f Mon Sep 17 00:00:00 2001 From: Jaromir Hamala Date: Thu, 22 Oct 2026 14:18:07 +0200 Subject: [PATCH 18/36] test(qwp): pin the wake-protocol guard lines 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. --- .../client/WebSocketClientDoorbellTest.java | 32 ++++- .../qwp/client/AckWaiterSignalTest.java | 86 +++++++++++++ .../CursorWebSocketSendLoopIdleWaitTest.java | 119 ++++++++++++++++++ .../SegmentManagerAckWatermarkLagTest.java | 44 +++++++ 4 files changed, 279 insertions(+), 2 deletions(-) diff --git a/core/src/test/java/io/questdb/client/test/cutlass/http/client/WebSocketClientDoorbellTest.java b/core/src/test/java/io/questdb/client/test/cutlass/http/client/WebSocketClientDoorbellTest.java index 5ffebb2d6..ff97ed66d 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/http/client/WebSocketClientDoorbellTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/http/client/WebSocketClientDoorbellTest.java @@ -26,12 +26,15 @@ import io.questdb.client.cutlass.http.client.WebSocketClient; import io.questdb.client.cutlass.http.client.WebSocketClientFactory; +import io.questdb.client.network.Net; +import io.questdb.client.std.Os; import io.questdb.client.test.cutlass.qwp.websocket.TestWebSocketServer; import io.questdb.client.test.tools.TestUtils; import org.junit.Test; import java.util.concurrent.TimeUnit; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; @@ -90,9 +93,34 @@ public void testDoorbell() throws Exception { client.sendPing(); assertTrue("socket readability did not end the wait", client.awaitReadableOrWake(5_000)); - // 6. wake() on a closed client is a safe no-op. + // 6. wake() on a closed client is a safe no-op: close() + // forgets the doorbell descriptor, so a late wake cannot + // ring whoever now owns that number. Fresh eventfds take + // the lowest free numbers, i.e. the ones close() just + // released; a stale ring would make one of them readable. + // Linux only: the probe polls a bare doorbell (socket -1), + // which kqueue's pollReadable cannot register. client.close(); - client.wake(); + if (Os.isLinux()) { + int[] fresh = new int[4]; + for (int i = 0; i < fresh.length; i++) { + fresh[i] = Net.doorbellCreate(); + assertTrue(fresh[i] >= 0); + } + try { + client.wake(); + for (int fd : fresh) { + assertEquals("wake() after close() rang a reused descriptor " + fd, + 0, Net.pollReadable(-1, fd, 0)); + } + } finally { + for (int fd : fresh) { + Net.doorbellClose(fd); + } + } + } else { + client.wake(); + } } } }); diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/AckWaiterSignalTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/AckWaiterSignalTest.java index 039016311..112fa1a5d 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/AckWaiterSignalTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/AckWaiterSignalTest.java @@ -25,7 +25,9 @@ package io.questdb.client.test.cutlass.qwp.client; import io.questdb.client.Sender; +import io.questdb.client.cutlass.line.LineSenderException; import io.questdb.client.cutlass.qwp.client.QwpWebSocketSender; +import io.questdb.client.cutlass.qwp.client.WebSocketResponse; import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorSendEngine; import io.questdb.client.cutlass.qwp.client.sf.cursor.MmapSegmentException; import io.questdb.client.cutlass.qwp.client.sf.cursor.SegmentManager; @@ -163,6 +165,68 @@ public void testAwaitAckedFsnWakesOnPeriodicSyncDurabilityFailure() throws Excep }); } + /** + * A terminal NACK ({@code STATUS_PARSE_ERROR}) latched by the I/O loop + * while a thread is parked in {@code awaitAckedFsn} must end that wait + * at once: the loop signals ack progress when it records the terminal, + * and nothing else ever will (no ack advances after a terminal). + */ + @Test + public void testAwaitAckedFsnWakesOnTerminalNack() throws Exception { + assertMemoryLeak(() -> { + try (TestWebSocketServer server = startedServer(new DelayedTerminalNackHandler(300))) { + QwpWebSocketSender sender = (QwpWebSocketSender) Sender.fromConfig( + "ws::addr=localhost:" + server.getPort() + ";close_flush_timeout_millis=0;"); + try { + sender.table("t").longColumn("v", 1L).atNow(); + long fsn = sender.flushAndGetSequence(); + long t0 = System.nanoTime(); + try { + sender.awaitAckedFsn(fsn, 20_000); + Assert.fail("the terminal NACK must surface from awaitAckedFsn"); + } catch (LineSenderException expected) { + // terminal + } + long elapsedMs = (System.nanoTime() - t0) / 1_000_000L; + Assert.assertTrue("terminal surfaced only after " + elapsedMs + "ms: recording it did not " + + "wake the waiter", elapsedMs < 2000L); + } finally { + try { + sender.close(); + } catch (LineSenderException ignored) { + // close() rethrows the terminal + } + } + } + }); + } + + /** + * Same latch, observed through {@code close()}'s drain: the drain parks + * on the same ack waiter and must return as soon as the terminal lands, + * not at {@code close_flush_timeout_millis}. + */ + @Test + public void testCloseDrainWakesOnTerminalNack() throws Exception { + assertMemoryLeak(() -> { + try (TestWebSocketServer server = startedServer(new DelayedTerminalNackHandler(300))) { + Sender sender = Sender.fromConfig( + "ws::addr=localhost:" + server.getPort() + ";close_flush_timeout_millis=20000;"); + sender.table("t").longColumn("v", 1L).atNow(); + sender.flush(); + long t0 = System.nanoTime(); + try { + sender.close(); + } catch (LineSenderException ignored) { + // close() rethrows the terminal + } + long elapsedMs = (System.nanoTime() - t0) / 1_000_000L; + Assert.assertTrue("close() drained for " + elapsedMs + "ms despite the terminal NACK: recording " + + "it did not wake the drain", elapsedMs < 2000L); + } + }); + } + private static TestWebSocketServer startedServer(TestWebSocketServer.WebSocketServerHandler handler) throws Exception { TestWebSocketServer server = new TestWebSocketServer(handler); @@ -171,6 +235,28 @@ private static TestWebSocketServer startedServer(TestWebSocketServer.WebSocketSe return server; } + /** + * Answers the first frame with a TERMINAL-policy NACK after a delay long + * enough for the producer to park in its wait first. + */ + private static class DelayedTerminalNackHandler implements TestWebSocketServer.WebSocketServerHandler { + private final long delayMs; + + DelayedTerminalNackHandler(long delayMs) { + this.delayMs = delayMs; + } + + @Override + public void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] data) { + try { + Thread.sleep(delayMs); + client.sendBinary(QwpWireTestUtils.buildNack(0L, WebSocketResponse.STATUS_PARSE_ERROR)); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + } + private static class DelayingAckHandler implements TestWebSocketServer.WebSocketServerHandler { private final long delayMs; private final AtomicLong nextSeq = new AtomicLong(); diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopIdleWaitTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopIdleWaitTest.java index 5791923f8..37fbc3ffa 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopIdleWaitTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopIdleWaitTest.java @@ -41,6 +41,7 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Paths; +import java.util.concurrent.CountDownLatch; import java.util.concurrent.Semaphore; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; @@ -215,6 +216,101 @@ public void testReplayAfterPingFailureIsNotStrandedByIdleWait() throws Exception }); } + /** + * The publish hook rings the doorbell only while the I/O thread is parked + * (or committing to park). A loop that is busy -- here, inside the + * transport's receive -- picks the frames up on its next iteration, so a + * burst of publishes must not cost one wake syscall each. + */ + @Test + public void testBusyLoopIsNotWokenPerPublish() throws Exception { + TestUtils.assertMemoryLeak(() -> { + try (CursorSendEngine engine = new CursorSendEngine(tmpDir, 16384)) { + BlockingDoorbellClient client = new BlockingDoorbellClient(); + // Hold the very first receive: the loop is busy from its first + // iteration and never reaches the idle wait before the burst. + CountDownLatch gate = new CountDownLatch(1); + client.receiveGate = gate; + CursorWebSocketSendLoop loop = new CursorWebSocketSendLoop( + client, engine, 0L, + BlockingDoorbellClient::new, + 100L, 1_000L, + false, + CursorWebSocketSendLoop.DEFAULT_DURABLE_ACK_KEEPALIVE_INTERVAL_MILLIS, + CursorWebSocketSendLoop.DEFAULT_MAX_HEAD_FRAME_REJECTIONS); + try { + loop.start(); + assertTrue("loop never entered the held receive", client.receiveHeld.await(5, TimeUnit.SECONDS)); + int wakesBefore = client.wakes.get(); + final int frames = 200; + for (int i = 0; i < frames; i++) { + appendFrame(engine); + } + int wakes = client.wakes.get() - wakesBefore; + assertEquals("busy loop was rung " + wakes + " times for " + frames + " publishes", 0, wakes); + gate.countDown(); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (client.sentFrames.get() < frames && System.nanoTime() < deadline) { + Thread.sleep(1); + } + assertEquals("frames published to a busy loop were not all sent", frames, client.sentFrames.get()); + } finally { + gate.countDown(); + loop.close(); + } + } + }); + } + + /** + * The idle decision re-checks the engine's publish stamp after committing + * to park: a publish that lands after trySendOne found nothing but before + * ioParked is set rings no doorbell, so without the re-check the loop + * would block on an untimed wait with the frame stranded behind it. The + * transport's receive, which runs inside that window, is gated so the + * publish lands exactly there. + */ + @Test + public void testPublishInsideIdleDecisionWindowIsNotStranded() throws Exception { + TestUtils.assertMemoryLeak(() -> { + try (CursorSendEngine engine = new CursorSendEngine(tmpDir, 16384)) { + BlockingDoorbellClient client = new BlockingDoorbellClient(); + CountDownLatch gate = new CountDownLatch(1); + client.receiveGate = gate; + client.gateInIdleWindow = true; + CursorWebSocketSendLoop loop = new CursorWebSocketSendLoop( + client, engine, 0L, + BlockingDoorbellClient::new, + 100L, 1_000L, + false, + CursorWebSocketSendLoop.DEFAULT_DURABLE_ACK_KEEPALIVE_INTERVAL_MILLIS, + CursorWebSocketSendLoop.DEFAULT_MAX_HEAD_FRAME_REJECTIONS); + try { + loop.start(); + appendFrame(engine); + assertTrue("loop never reached the idle-decision window", + client.receiveHeld.await(5, TimeUnit.SECONDS)); + assertEquals(1, client.sentFrames.get()); + // The loop is between trySendOne (found nothing) and the + // park decision: this publish rings no doorbell. + int wakesBefore = client.wakes.get(); + appendFrame(engine); + assertEquals("publish to a non-parked loop must not ring", 0, client.wakes.get() - wakesBefore); + gate.countDown(); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(3); + while (client.sentFrames.get() < 2 && System.nanoTime() < deadline) { + Thread.sleep(1); + } + assertEquals("frame published inside the idle-decision window was stranded", + 2, client.sentFrames.get()); + } finally { + gate.countDown(); + loop.close(); + } + } + }); + } + private static void appendFrame(CursorSendEngine engine) { long buf = Unsafe.malloc(16, MemoryTag.NATIVE_DEFAULT); try { @@ -237,12 +333,22 @@ private static final class BlockingDoorbellClient extends WebSocketClient { final AtomicInteger sentFrames = new AtomicInteger(); final AtomicInteger waits = new AtomicInteger(); final AtomicInteger pingAttempts = new AtomicInteger(); + final AtomicInteger wakes = new AtomicInteger(); + // Counted down once the receive below is held at receiveGate. + final CountDownLatch receiveHeld = new CountDownLatch(1); volatile boolean failFirstSend; volatile boolean failPing; // Answer the first sent frame with a STATUS_OK naming one table, so a // durable-ack loop has a pending entry and starts PINGing. volatile boolean deliverOkWithPendingTable; + // One-shot: the receive that takes the gate blocks on it (5 s cap). + // Plain: the very first receive. gateInIdleWindow: the second + // receive after the first send, i.e. the one that runs after + // trySendOne found nothing and before the park decision. + volatile CountDownLatch receiveGate; + volatile boolean gateInIdleWindow; private boolean okDelivered; + private int receivesSinceSend; private final Semaphore doorbell = new Semaphore(0); BlockingDoorbellClient() { @@ -269,6 +375,7 @@ public boolean awaitReadableOrWake(int timeoutMillis) { @Override public void wake() { + wakes.incrementAndGet(); doorbell.release(); } @@ -279,6 +386,7 @@ public void sendBinary(long dataPtr, int length) { throw new HttpClientException("peer disconnect [errno=104]"); } sentFrames.incrementAndGet(); + receivesSinceSend = 0; } @Override @@ -292,6 +400,17 @@ public void sendPing() { @Override public boolean tryReceiveFrame(WebSocketFrameHandler handler) { receiveAttempts.incrementAndGet(); + receivesSinceSend++; + CountDownLatch gate = receiveGate; + if (gate != null && (!gateInIdleWindow || (sentFrames.get() == 1 && receivesSinceSend == 2))) { + receiveGate = null; + receiveHeld.countDown(); + try { + gate.await(5, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } if (deliverOkWithPendingTable && !okDelivered && sentFrames.get() > 0) { okDelivered = true; // STATUS_OK | sequence 0 | tableCount 1 | nameLen 1 't' seqTxn 1 diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerAckWatermarkLagTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerAckWatermarkLagTest.java index ed3cfe5be..beb90611d 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerAckWatermarkLagTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerAckWatermarkLagTest.java @@ -117,6 +117,50 @@ public void testAcksInsideActiveSegmentReachWatermarkWithoutOtherActivity() thro }); } + /** + * The round-0 case above with the startup race removed: register seeds the + * ring's persisted-ACK mark so that the very first ACK wakes the worker. + * Wait until the worker is parked without a timeout before acking, so the + * register wake cannot service the ACK by accident. + */ + @Test(timeout = 20_000L) + public void testFirstAckAfterRegisterWakesParkedWorker() throws Exception { + TestUtils.assertMemoryLeak(() -> { + long segmentSize = MmapSegment.HEADER_SIZE + 64 * (MmapSegment.FRAME_HEADER_SIZE + 32L); + MmapSegment initial = MmapSegment.create(tmpDir + "/sf-0000000000000000.sfa", 0L, segmentSize); + SegmentRing ring = new SegmentRing(initial, segmentSize); + AckWatermark watermark = AckWatermark.open(tmpDir); + Assert.assertNotNull(watermark); + long payload = Unsafe.malloc(32, MemoryTag.NATIVE_DEFAULT); + try (SegmentManager manager = new SegmentManager(segmentSize, new SegmentBudget(segmentSize * 8))) { + manager.register(ring, tmpDir, watermark); + manager.start(); + Thread worker = manager.getWorkerThreadForTesting(); + Assert.assertNotNull(worker); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (worker.getState() != Thread.State.WAITING && System.nanoTime() < deadline) { + Thread.sleep(1); + } + Assert.assertEquals("worker must park without a timeout once the register wake is serviced", + Thread.State.WAITING, worker.getState()); + for (int i = 0; i < 10; i++) { + Assert.assertTrue(ring.appendOrFsn(payload, 32) >= 0); + } + ring.acknowledge(9L); + deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2); + while (watermark.read() != 9L && System.nanoTime() < deadline) { + Thread.sleep(1); + } + Assert.assertEquals("the first ACK after register did not wake the parked worker", + 9L, watermark.read()); + } finally { + Unsafe.free(payload, 32, MemoryTag.NATIVE_DEFAULT); + watermark.close(); + ring.close(); + } + }); + } + @Test(timeout = 30_000L) public void testStreamingAcksWakeWorkerOnWatermarkCadenceNotPerAck() throws Exception { TestUtils.assertMemoryLeak(() -> { From 859ac1e95da7acdde3ce683370f19a242fefa03e Mon Sep 17 00:00:00 2001 From: Jaromir Hamala Date: Thu, 22 Oct 2026 15:52:59 +0200 Subject: [PATCH 19/36] perf(qwp): drop two redundant syscalls per I/O-loop wake 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. --- core/src/main/c/share/net.c | 23 ++++++++----------- core/src/main/c/windows/net.c | 11 ++++----- .../cutlass/http/client/WebSocketClient.java | 18 +++++++++++++-- .../java/io/questdb/client/network/Net.java | 13 ++++------- .../client/WebSocketClientDoorbellTest.java | 4 ++-- 5 files changed, 37 insertions(+), 32 deletions(-) diff --git a/core/src/main/c/share/net.c b/core/src/main/c/share/net.c index b98f4f340..4bded793a 100644 --- a/core/src/main/c/share/net.c +++ b/core/src/main/c/share/net.c @@ -457,13 +457,6 @@ JNIEXPORT jint JNICALL Java_io_questdb_client_network_Net_doorbellCreate return eventfd(0, EFD_NONBLOCK | EFD_CLOEXEC); } -JNIEXPORT void JNICALL Java_io_questdb_client_network_Net_doorbellDrain - (JNIEnv *e, jclass cl, jint fd) { - uint64_t v; - ssize_t n; - RESTARTABLE(read((int) fd, &v, sizeof(v)), n); -} - JNIEXPORT jint JNICALL Java_io_questdb_client_network_Net_doorbellRing (JNIEnv *e, jclass cl, jint fd) { uint64_t one = 1; @@ -478,7 +471,7 @@ JNIEXPORT void JNICALL Java_io_questdb_client_network_Net_doorbellClose close((int) fd); } -JNIEXPORT jint JNICALL Java_io_questdb_client_network_Net_pollReadable +JNIEXPORT jint JNICALL Java_io_questdb_client_network_Net_awaitReadableOrRing (JNIEnv *e, jclass cl, jint socketFd, jint doorbell, jint timeoutMillis) { struct pollfd fds[2]; fds[0].fd = (int) socketFd; @@ -488,6 +481,13 @@ JNIEXPORT jint JNICALL Java_io_questdb_client_network_Net_pollReadable fds[1].events = POLLIN; fds[1].revents = 0; int n = poll(fds, 2, (int) timeoutMillis); + if (n > 0 && (fds[1].revents & POLLIN)) { + // Consume the ring here: a wait that ended on the socket alone skips + // the (would-be EAGAIN) read entirely. + uint64_t v; + ssize_t r; + RESTARTABLE(read((int) doorbell, &v, sizeof(v)), r); + } return (n < 0 && errno == EINTR) ? 0 : n; } @@ -511,11 +511,6 @@ JNIEXPORT jint JNICALL Java_io_questdb_client_network_Net_doorbellCreate return kq; } -JNIEXPORT void JNICALL Java_io_questdb_client_network_Net_doorbellDrain - (JNIEnv *e, jclass cl, jint kq) { - // EV_CLEAR consumed the ring when the wait delivered it. -} - JNIEXPORT jint JNICALL Java_io_questdb_client_network_Net_doorbellRing (JNIEnv *e, jclass cl, jint kq) { struct kevent ev; @@ -530,7 +525,7 @@ JNIEXPORT void JNICALL Java_io_questdb_client_network_Net_doorbellClose close((int) kq); } -JNIEXPORT jint JNICALL Java_io_questdb_client_network_Net_pollReadable +JNIEXPORT jint JNICALL Java_io_questdb_client_network_Net_awaitReadableOrRing (JNIEnv *e, jclass cl, jint socketFd, jint kq, jint timeoutMillis) { // Re-armed on every wait; a closed (reconnected) socket drops out of the // kqueue by itself, so there is nothing to unregister. diff --git a/core/src/main/c/windows/net.c b/core/src/main/c/windows/net.c index 583174abf..277316561 100644 --- a/core/src/main/c/windows/net.c +++ b/core/src/main/c/windows/net.c @@ -338,11 +338,6 @@ JNIEXPORT jint JNICALL Java_io_questdb_client_network_Net_doorbellCreate return (jint) (uintptr_t) h; } -JNIEXPORT void JNICALL Java_io_questdb_client_network_Net_doorbellDrain - (JNIEnv *e, jclass cl, jint doorbell) { - ResetEvent((HANDLE) (uintptr_t) (uint32_t) doorbell); -} - JNIEXPORT jint JNICALL Java_io_questdb_client_network_Net_doorbellRing (JNIEnv *e, jclass cl, jint doorbell) { if (!SetEvent((HANDLE) (uintptr_t) (uint32_t) doorbell)) { @@ -357,7 +352,7 @@ JNIEXPORT void JNICALL Java_io_questdb_client_network_Net_doorbellClose CloseHandle((HANDLE) (uintptr_t) (uint32_t) doorbell); } -JNIEXPORT jint JNICALL Java_io_questdb_client_network_Net_pollReadable +JNIEXPORT jint JNICALL Java_io_questdb_client_network_Net_awaitReadableOrRing (JNIEnv *e, jclass cl, jint socketFd, jint doorbell, jint timeoutMillis) { WSAEVENT sockEvent = WSACreateEvent(); if (sockEvent == WSA_INVALID_EVENT) { @@ -379,6 +374,10 @@ JNIEXPORT jint JNICALL Java_io_questdb_client_network_Net_pollReadable if (r == WAIT_TIMEOUT) { return 0; } + if (r == WAIT_OBJECT_0 + 1) { + // Consume the ring (manual-reset event). + ResetEvent((HANDLE) (uintptr_t) (uint32_t) doorbell); + } if (r == WAIT_FAILED) { SaveLastError(); return -1; 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 985606036..76e5b281b 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 @@ -109,6 +109,11 @@ public abstract class WebSocketClient implements QuietCloseable { // See awaitReadableOrWake/wake. wakeLock serialises wake() with close(). private final Object wakeLock = new Object(); private int doorbell = -1; + // True after a recv() found nothing. Until the next send or + // awaitReadableOrWake, tryReceiveFrame answers false without a syscall: + // the I/O loop's pre-park receive pass otherwise repeats the EAGAIN it got + // on the previous iteration. I/O-thread-only, like the recv buffer. + private boolean recvDrained; // Upper bound (ms) on the TCP connect. <= 0 disables the application-level // timeout and falls back to the OS connect timeout. Seeded from the // configuration; the QWP sender may override it via setConnectTimeout(). @@ -635,6 +640,13 @@ public boolean tryReceiveFrame(WebSocketFrameHandler handler) { return result == PARSE_OK; } + if (recvDrained) { + // The last recv found nothing and nothing has been sent or waited + // for since: a repeat recv would be another EAGAIN. Bytes that land + // meanwhile end the next awaitReadableOrWake at once (level-triggered). + return false; + } + // Try one non-blocking recv if (recvPos >= recvBufSize - 1024) { growRecvBuffer(); @@ -645,6 +657,7 @@ public boolean tryReceiveFrame(WebSocketFrameHandler handler) { throw new HttpClientException("peer disconnect [errno=").errno(nf.errno()).put(']'); } if (n == 0) { + recvDrained = true; return false; // No data available } recvPos += n; @@ -1110,6 +1123,7 @@ private void doConnect(CharSequence host, int port) { private void doSend(long ptr, int len, int timeout) { long startTime = System.nanoTime(); + recvDrained = false; while (len > 0) { int remainingTimeout = getRemainingTimeOrThrow(timeout, startTime); ioWait(remainingTimeout, IOOperation.WRITE); @@ -1494,16 +1508,16 @@ protected void dieWaiting(int n) { * into a handshake or send wait. */ public boolean awaitReadableOrWake(int timeoutMillis) { + recvDrained = false; if (socket.hasBufferedInput()) { // TLS: decrypted or undecrypted input already sits in user space, // invisible to poll(2). Report readable without blocking. return true; } - int n = Net.pollReadable(socket.getFd(), doorbell, timeoutMillis); + int n = Net.awaitReadableOrRing(socket.getFd(), doorbell, timeoutMillis); if (n < 0) { throw new HttpClientException("internal error: poll failure [errno=").put(nf.errno()).put(']'); } - Net.doorbellDrain(doorbell); return n > 0; } diff --git a/core/src/main/java/io/questdb/client/network/Net.java b/core/src/main/java/io/questdb/client/network/Net.java index 11c5709be..63cd61b64 100644 --- a/core/src/main/java/io/questdb/client/network/Net.java +++ b/core/src/main/java/io/questdb/client/network/Net.java @@ -165,18 +165,13 @@ public static long sockaddr(int ipv4address, int port) { public static native int socketUdp(); /** - * Doorbell for a thread blocked in {@link #pollReadable}: an {@code eventfd} + * Doorbell for a thread blocked in {@link #awaitReadableOrRing}: an {@code eventfd} * on Linux, a kqueue {@code EVFILT_USER} on macOS/FreeBSD, a manual-reset * Event on Windows. Returns a non-negative handle, or -1 on failure (errno * set). Release with {@link #doorbellClose}, never with a socket close. */ public static native int doorbellCreate(); - /** - * Clears every pending ring (non-blocking). - */ - public static native void doorbellDrain(int doorbell); - /** * Rings the doorbell; a repeat ring is a no-op. Returns 0, or -1 on failure. */ @@ -188,9 +183,11 @@ public static long sockaddr(int ipv4address, int port) { * Blocks until {@code socketFd} is readable (or hung up), {@code doorbell} * rings, or {@code timeoutMillis} elapses (negative = no timeout). Returns * positive when ready, 0 on timeout or signal, -1 on error (errno set). - * Callers re-check their inputs after every return. + * A ring that ended the wait is consumed by it, so a wait that ended on the + * socket alone costs no extra syscall. Callers re-check their inputs after + * every return. */ - public static native int pollReadable(int socketFd, int doorbell, int timeoutMillis); + public static native int awaitReadableOrRing(int socketFd, int doorbell, int timeoutMillis); private static native void freeAddrInfo0(long pAddrInfo); diff --git a/core/src/test/java/io/questdb/client/test/cutlass/http/client/WebSocketClientDoorbellTest.java b/core/src/test/java/io/questdb/client/test/cutlass/http/client/WebSocketClientDoorbellTest.java index ff97ed66d..b01aa99ae 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/http/client/WebSocketClientDoorbellTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/http/client/WebSocketClientDoorbellTest.java @@ -99,7 +99,7 @@ public void testDoorbell() throws Exception { // the lowest free numbers, i.e. the ones close() just // released; a stale ring would make one of them readable. // Linux only: the probe polls a bare doorbell (socket -1), - // which kqueue's pollReadable cannot register. + // which kqueue's awaitReadableOrRing cannot register. client.close(); if (Os.isLinux()) { int[] fresh = new int[4]; @@ -111,7 +111,7 @@ public void testDoorbell() throws Exception { client.wake(); for (int fd : fresh) { assertEquals("wake() after close() rang a reused descriptor " + fd, - 0, Net.pollReadable(-1, fd, 0)); + 0, Net.awaitReadableOrRing(-1, fd, 0)); } } finally { for (int fd : fresh) { From 94cda5420658e2c05248b15c14f7e37f8bde02d7 Mon Sep 17 00:00:00 2001 From: Jaromir Hamala Date: Fri, 23 Oct 2026 10:28:21 +0200 Subject: [PATCH 20/36] perf(qwp): re-provision the hot spare in the pass whose trim freed the 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 --- .../qwp/client/sf/cursor/SegmentManager.java | 288 ++++++++++-------- .../SegmentManagerProvisionRetryTest.java | 130 ++++++++ 2 files changed, 284 insertions(+), 134 deletions(-) create mode 100644 core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerProvisionRetryTest.java diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentManager.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentManager.java index dd31baf42..53bc59c8b 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentManager.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentManager.java @@ -991,157 +991,177 @@ private boolean serviceRing0(RingEntry e) { } // 1. Provision a hot spare if the ring needs one AND we have headroom - // under the disk-total cap. Cap check is per-tick; if we're capped - // here, the ring stays in BACKPRESSURE_NO_SPARE until trim (step 2) - // on this or a subsequent tick frees space. Logged at most once per - // DISK_FULL_LOG_THROTTLE_NANOS so a sustained-disk-full state - // doesn't drown the log. - if (e.ring.needsHotSpare()) { - // Charge the spare BEFORE provisioning it, as one atomic check-and- - // charge against everything else on the budget, live side-file bytes - // included. A snapshot-then-commit check would let two managers that - // share the budget both see room for its last segment and both take - // it. Installing the spare keeps the charge; every other outcome - // below releases it. - boolean withinCap = budget.tryCharge(segmentSizeBytes); - // Liveness floor. Refusing on segment bytes is productive -- an ack - // trims a sealed segment and the shortfall clears. Refusing on - // side-file bytes is not: the dictionary never shrinks, so a ring - // held below its minimum working set by them would never rotate - // again, on this run or any later one. Nor is refusing on the - // segments of OTHER rings that share the budget: their acks may - // never come (a sibling whose connection is down). Provision anyway - // while this ring is under the floor, and charge it honestly. - long ringSegmentBytes = withinCap ? 0L : e.ring.totalSegmentBytes(); - boolean belowLivenessFloor = !withinCap && ringSegmentBytes < livenessFloorBytes; - if (belowLivenessFloor) { - budget.charge(segmentSizeBytes); + // under the disk-total cap. Cap check is per attempt; if we're + // capped here, the ring stays in BACKPRESSURE_NO_SPARE until trim + // (step 2) frees space, which is retried below in this same pass. + // Logged at most once per DISK_FULL_LOG_THROTTLE_NANOS so a + // sustained-disk-full state doesn't drown the log. + boolean capped = e.ring.needsHotSpare() && provisionSpare(e, memoryMode); + boolean hasMoreTrimmable = trimAcked(e, memoryMode); + // The trim may have just released the bytes the spare was refused + // for: retry once in this pass rather than parking on the retry + // timer. The timer remains the fallback for room freed elsewhere (a + // sibling ring or manager, a deregister) and for a failed create. + if (capped && provisionSpare(e, memoryMode)) { + scheduleProvisionRetry(); + } + return hasMoreTrimmable; + } + + /** + * Provisions a hot spare for a ring that needs one. Returns {@code true} + * only when the budget refused the charge (nothing provisioned, nothing + * charged); a failed create returns {@code false} after arming the retry + * timer itself. + */ + private boolean provisionSpare(RingEntry e, boolean memoryMode) { + // Charge the spare BEFORE provisioning it, as one atomic check-and- + // charge against everything else on the budget, live side-file bytes + // included. A snapshot-then-commit check would let two managers that + // share the budget both see room for its last segment and both take + // it. Installing the spare keeps the charge; every other outcome + // below releases it. + boolean withinCap = budget.tryCharge(segmentSizeBytes); + // Liveness floor. Refusing on segment bytes is productive -- an ack + // trims a sealed segment and the shortfall clears. Refusing on + // side-file bytes is not: the dictionary never shrinks, so a ring + // held below its minimum working set by them would never rotate + // again, on this run or any later one. Nor is refusing on the + // segments of OTHER rings that share the budget: their acks may + // never come (a sibling whose connection is down). Provision anyway + // while this ring is under the floor, and charge it honestly. + long ringSegmentBytes = withinCap ? 0L : e.ring.totalSegmentBytes(); + boolean belowLivenessFloor = !withinCap && ringSegmentBytes < livenessFloorBytes; + if (belowLivenessFloor) { + budget.charge(segmentSizeBytes); + } + if (!withinCap && !belowLivenessFloor) { + long now = System.nanoTime(); + if (now - lastDiskFullLogNs >= DISK_FULL_LOG_THROTTLE_NANOS) { + long sideFileBytes = budget.getSideFileBytes(); + LOG.warn("SF {}: cannot provision spare in {} " + + "(totalBytes={}, sideFileBytes={}, cap={}, segmentSize={}). " + + "Producer is backpressured until ACK-driven trim frees segment " + + "space; side-file bytes are not reclaimed by trim.", + memoryMode ? "memory cap reached" : "disk-full", + memoryMode ? "" : e.dir, budget.getSegmentBytes() + sideFileBytes, + sideFileBytes, budget.getCapacityBytes(), segmentSizeBytes); + lastDiskFullLogNs = now; } - if (!withinCap && !belowLivenessFloor) { + return true; + } else { + if (belowLivenessFloor) { + // Exceeding a configured cap is worth saying out loud, and + // saying WHY: the operator's remedy is to raise + // sf_max_total_bytes, or shrink the symbol dictionary or the + // number of senders sharing the budget, never to wait for a + // trim. long now = System.nanoTime(); if (now - lastDiskFullLogNs >= DISK_FULL_LOG_THROTTLE_NANOS) { long sideFileBytes = budget.getSideFileBytes(); - LOG.warn("SF {}: cannot provision spare in {} " - + "(totalBytes={}, sideFileBytes={}, cap={}, segmentSize={}). " - + "Producer is backpressured until ACK-driven trim frees segment " - + "space; side-file bytes are not reclaimed by trim.", - memoryMode ? "memory cap reached" : "disk-full", + LOG.warn("SF {}: provisioning past sf_max_total_bytes to keep the slot " + + "usable (totalBytes={}, sideFileBytes={}, cap={}, " + + "segmentSize={}, ringSegmentBytes={}, minWorkingSet={}). " + + "The rest of the budget -- symbol dictionaries, which trim " + + "cannot reclaim, and the segments of other senders sharing " + + "it -- leaves no room for this slot's active segment plus " + + "one spare; raise sf_max_total_bytes, or reduce symbol " + + "cardinality or the number of pooled senders.", memoryMode ? "" : e.dir, budget.getSegmentBytes() + sideFileBytes, - sideFileBytes, budget.getCapacityBytes(), segmentSizeBytes); + sideFileBytes, budget.getCapacityBytes(), segmentSizeBytes, + ringSegmentBytes, livenessFloorBytes); lastDiskFullLogNs = now; } - scheduleProvisionRetry(); - } else { - if (belowLivenessFloor) { - // Exceeding a configured cap is worth saying out loud, and - // saying WHY: the operator's remedy is to raise - // sf_max_total_bytes, or shrink the symbol dictionary or the - // number of senders sharing the budget, never to wait for a - // trim. - long now = System.nanoTime(); - if (now - lastDiskFullLogNs >= DISK_FULL_LOG_THROTTLE_NANOS) { - long sideFileBytes = budget.getSideFileBytes(); - LOG.warn("SF {}: provisioning past sf_max_total_bytes to keep the slot " - + "usable (totalBytes={}, sideFileBytes={}, cap={}, " - + "segmentSize={}, ringSegmentBytes={}, minWorkingSet={}). " - + "The rest of the budget -- symbol dictionaries, which trim " - + "cannot reclaim, and the segments of other senders sharing " - + "it -- leaves no room for this slot's active segment plus " - + "one spare; raise sf_max_total_bytes, or reduce symbol " - + "cardinality or the number of pooled senders.", - memoryMode ? "" : e.dir, budget.getSegmentBytes() + sideFileBytes, - sideFileBytes, budget.getCapacityBytes(), segmentSizeBytes, - ringSegmentBytes, livenessFloorBytes); - lastDiskFullLogNs = now; + } + MmapSegment spare = null; + String path = null; + boolean installed = false; + try { + // Null if the ring closed between this tick's snapshot and now: + // close() nulls it under the ring's own monitor with no + // coordination with the manager thread. A closed ring rejects + // installHotSpare regardless, so there is nothing to provision + // this tick; skip cleanly rather than provision a spare that will + // be abandoned. + MmapSegment active = e.ring.getActive(); + if (active != null) { + // baseSeq is provisional -- SegmentRing.appendOrFsn calls + // rebaseSeq() at rotation time to pin the real value. We + // pass the manager's best guess (nextSeqHint at this + // instant), which is fine since it's overwritten anyway. + if (memoryMode) { + spare = MmapSegment.createInMemory(e.ring.nextSeqHint(), segmentSizeBytes); + } else { + path = nextSparePath(e.dir); + // Native path bytes (NUL-terminated) live in pathScratch + // from the call above. Hand them straight to MmapSegment.create + // via its long-ptr overload, bypassing the byte[] + native + // malloc that the String overload would incur on every + // rotation. + spare = MmapSegment.create(filesFacade, + pathScratch.ptr(), path, + e.ring.nextSeqHint(), segmentSizeBytes, true); } - } - MmapSegment spare = null; - String path = null; - boolean installed = false; - try { - // Null if the ring closed between this tick's snapshot and now: - // close() nulls it under the ring's own monitor with no - // coordination with the manager thread. A closed ring rejects - // installHotSpare regardless, so there is nothing to provision - // this tick; skip cleanly rather than provision a spare that will - // be abandoned. - MmapSegment active = e.ring.getActive(); - if (active != null) { - // baseSeq is provisional -- SegmentRing.appendOrFsn calls - // rebaseSeq() at rotation time to pin the real value. We - // pass the manager's best guess (nextSeqHint at this - // instant), which is fine since it's overwritten anyway. - if (memoryMode) { - spare = MmapSegment.createInMemory(e.ring.nextSeqHint(), segmentSizeBytes); - } else { - path = nextSparePath(e.dir); - // Native path bytes (NUL-terminated) live in pathScratch - // from the call above. Hand them straight to MmapSegment.create - // via its long-ptr overload, bypassing the byte[] + native - // malloc that the String overload would incur on every - // rotation. - spare = MmapSegment.create(filesFacade, - pathScratch.ptr(), path, - e.ring.nextSeqHint(), segmentSizeBytes, true); - } - Runnable installHook = beforeInstallSyncHook; - if (installHook != null) { - installHook.run(); - } - if (!memoryMode) { - spare.syncHeader(); - if (filesFacade.fsyncDir(e.dir) != 0) { - throw new MmapSegmentException( - "could not sync hot-spare directory " + e.dir); - } - } - // Install under the manager lock, gated on the entry - // still being registered. If `e.ring` was deregistered - // since the charge above, abandoning the spare here is - // the only way to keep the budget consistent: - // deregister already released ring.totalSegmentBytes() - // (without the spare, since it wasn't installed yet), so - // installing now would keep the spare's charge with no - // future releaser. By holding `lock` across the - // registration check AND installHotSpare, deregister is - // forced to either observe the spare in the ring (and - // release it) or run before installation (so no install - // happens and the !installed path releases the charge). - synchronized (lock) { - if (e.isRegistered()) { - e.ring.installHotSpare(spare); - installed = true; - } + Runnable installHook = beforeInstallSyncHook; + if (installHook != null) { + installHook.run(); + } + if (!memoryMode) { + spare.syncHeader(); + if (filesFacade.fsyncDir(e.dir) != 0) { + throw new MmapSegmentException( + "could not sync hot-spare directory " + e.dir); } } - } catch (Throwable t) { - LOG.warn("Failed to provision hot spare in {} (will retry next tick)", - memoryMode ? "" : e.dir, t); - } - if (!installed) { - scheduleProvisionRetry(); - budget.release(segmentSizeBytes); - if (spare != null) { - try { - spare.close(); - } catch (Throwable ignored) { + // Install under the manager lock, gated on the entry + // still being registered. If `e.ring` was deregistered + // since the charge above, abandoning the spare here is + // the only way to keep the budget consistent: + // deregister already released ring.totalSegmentBytes() + // (without the spare, since it wasn't installed yet), so + // installing now would keep the spare's charge with no + // future releaser. By holding `lock` across the + // registration check AND installHotSpare, deregister is + // forced to either observe the spare in the ring (and + // release it) or run before installation (so no install + // happens and the !installed path releases the charge). + synchronized (lock) { + if (e.isRegistered()) { + e.ring.installHotSpare(spare); + installed = true; } } - // Only remove the file when the spare object exists, i.e. - // MmapSegment.create succeeded and ownership is ours but - // installation was rejected (ring deregistered/closed). - // When create() itself threw, its catch already removed - // anything it put on disk -- and with exclusive create - // (O_EXCL) a failure can also mean the path was ALREADY - // occupied by a file some other lifecycle owns, which a - // blanket unlink here would destroy. - if (path != null && spare != null) { - filesFacade.remove(path); + } + } catch (Throwable t) { + LOG.warn("Failed to provision hot spare in {} (will retry next tick)", + memoryMode ? "" : e.dir, t); + } + if (!installed) { + scheduleProvisionRetry(); + budget.release(segmentSizeBytes); + if (spare != null) { + try { + spare.close(); + } catch (Throwable ignored) { } } + // Only remove the file when the spare object exists, i.e. + // MmapSegment.create succeeded and ownership is ours but + // installation was rejected (ring deregistered/closed). + // When create() itself threw, its catch already removed + // anything it put on disk -- and with exclusive create + // (O_EXCL) a failure can also mean the path was ALREADY + // occupied by a file some other lifecycle owns, which a + // blanket unlink here would destroy. + if (path != null && spare != null) { + filesFacade.remove(path); + } } } + return false; + } + private boolean trimAcked(RingEntry e, boolean memoryMode) { // 2. Trim fully ACKed segments. The ring first transfers one bounded, // unpinned prefix out of live traversal and into hidden pending // ownership. Only then may this worker unmap it. Disk mode keeps the diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerProvisionRetryTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerProvisionRetryTest.java new file mode 100644 index 000000000..029cc894e --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerProvisionRetryTest.java @@ -0,0 +1,130 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +package io.questdb.client.test.cutlass.qwp.client.sf.cursor; + +import io.questdb.client.cutlass.qwp.client.sf.cursor.MmapSegment; +import io.questdb.client.cutlass.qwp.client.sf.cursor.SegmentBudget; +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.FilesFacade; +import io.questdb.client.std.MemoryTag; +import io.questdb.client.std.Unsafe; +import io.questdb.client.test.tools.DelegatingFilesFacade; +import io.questdb.client.test.tools.TestUtils; +import org.junit.Assert; +import org.junit.Test; + +import java.io.File; +import java.nio.file.Files; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * The two ways a ring gets its spare after a refusal: the pass whose own trim + * frees the budget re-provisions at once, and a failed create is retried on + * the timer with no further event. The second guards the timer against + * deletion -- without it a transient allocation failure wedges the producer. + */ +public class SegmentManagerProvisionRetryTest { + private static final int FRAME_PAYLOAD = 64; + // Exactly two frames per segment. + private static final long SEGMENT_SIZE = MmapSegment.HEADER_SIZE + + 2 * (MmapSegment.FRAME_HEADER_SIZE + FRAME_PAYLOAD); + + @Test + public void testPassWhoseTrimFreesBudgetInstallsSpareInSamePass() throws Exception { + TestUtils.assertMemoryLeak(() -> { + SegmentBudget budget = new SegmentBudget(2 * SEGMENT_SIZE); + long buf = Unsafe.malloc(FRAME_PAYLOAD, MemoryTag.NATIVE_DEFAULT); + try (SegmentRing ring = new SegmentRing(MmapSegment.createInMemory(0, SEGMENT_SIZE), SEGMENT_SIZE); + SegmentManager manager = new SegmentManager(SEGMENT_SIZE, budget)) { + // Stopped manager: every pass is driven by hand, so no timer can + // disguise a stall. + manager.register(ring, null); + manager.serviceRingForTesting(ring); + Assert.assertFalse(ring.needsHotSpare()); + Assert.assertEquals(2 * SEGMENT_SIZE, budget.getSegmentBytes()); + + // Third frame rotates into the spare and seals the first segment. + Assert.assertEquals(0L, ring.appendOrFsn(buf, FRAME_PAYLOAD)); + Assert.assertEquals(1L, ring.appendOrFsn(buf, FRAME_PAYLOAD)); + Assert.assertEquals(2L, ring.appendOrFsn(buf, FRAME_PAYLOAD)); + Assert.assertTrue(ring.needsHotSpare()); + + // One pass: refused on the cap, then trims the acked segment. + Assert.assertTrue(ring.acknowledge(1L)); + manager.serviceRingForTesting(ring); + Assert.assertFalse("the pass whose trim freed the budget must install the spare", + ring.needsHotSpare()); + Assert.assertEquals(2 * SEGMENT_SIZE, budget.getSegmentBytes()); + + manager.deregister(ring); + Assert.assertEquals(0, budget.getSegmentBytes()); + } finally { + Unsafe.free(buf, FRAME_PAYLOAD, MemoryTag.NATIVE_DEFAULT); + } + }); + } + + @Test + public void testFailedCreateIsRetriedWithNoFurtherEvent() throws Exception { + TestUtils.assertMemoryLeak(() -> { + File dir = Files.createTempDirectory("qdb-segmgr-provision-retry").toFile(); + AtomicBoolean failNextAllocate = new AtomicBoolean(true); + FilesFacade facade = new DelegatingFilesFacade() { + @Override + public boolean allocate(int fd, long size) { + return !failNextAllocate.compareAndSet(true, false) && super.allocate(fd, size); + } + }; + String path = new File(dir, "sf-0000000000000000.sfa").getPath(); + try (SegmentRing ring = new SegmentRing(MmapSegment.create(path, 0, SEGMENT_SIZE), SEGMENT_SIZE); + SegmentManager manager = new SegmentManager(SEGMENT_SIZE, 8 * SEGMENT_SIZE, facade)) { + manager.start(); + // The ring holds only its active segment: no ACK, no rotation and + // no pin move will ever wake the worker. Only the timer can. + manager.register(ring, dir.getPath()); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (ring.needsHotSpare()) { + Assert.assertTrue("a failed create must be retried with no further event", + System.nanoTime() < deadline); + Thread.sleep(1); + } + Assert.assertFalse("the first create must have been the failed one", failNextAllocate.get()); + manager.deregister(ring); + Assert.assertTrue(manager.awaitRingQuiescence(ring)); + } finally { + // Best effort: a cleanup assertion here would mask the real failure. + File[] files = dir.listFiles(); + if (files != null) { + for (File f : files) { + f.delete(); + } + } + dir.delete(); + } + }); + } +} From a013e56055ac47b8e1eef3542d1340ad899c5acc Mon Sep 17 00:00:00 2001 From: Jaromir Hamala Date: Fri, 23 Oct 2026 11:38:48 +0200 Subject: [PATCH 21/36] fix(tls): treat a stream that ends inside a record as a disconnect 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). --- .../client/network/JavaTlsClientSocket.java | 5 +- .../TlsTruncatedRecordReconnectTest.java | 209 ++++++++++++++++++ 2 files changed, 213 insertions(+), 1 deletion(-) create mode 100644 core/src/test/java/io/questdb/client/test/cutlass/qwp/client/TlsTruncatedRecordReconnectTest.java diff --git a/core/src/main/java/io/questdb/client/network/JavaTlsClientSocket.java b/core/src/main/java/io/questdb/client/network/JavaTlsClientSocket.java index 05d746f7d..426727e81 100644 --- a/core/src/main/java/io/questdb/client/network/JavaTlsClientSocket.java +++ b/core/src/main/java/io/questdb/client/network/JavaTlsClientSocket.java @@ -231,7 +231,10 @@ public int recv(long bufferPtr, int bufferLen) { if (output.position() != 0) { return spilling ? drainUnwrapOutputBuffer(bufferPtr, bufferLen) : output.position(); } - return 0; + // The stream ended inside a record, which can now never complete: + // report the disconnect. Returning 0 would read as "no data yet" + // while the fd stays readable at EOF, spinning a readiness wait. + return n < 0 ? n : 0; case BUFFER_OVERFLOW: if (output.position() != 0) { // Output already has plaintext: hand it to the caller and let the diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/TlsTruncatedRecordReconnectTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/TlsTruncatedRecordReconnectTest.java new file mode 100644 index 000000000..65738202e --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/TlsTruncatedRecordReconnectTest.java @@ -0,0 +1,209 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +package io.questdb.client.test.cutlass.qwp.client; + +import io.questdb.client.Sender; +import io.questdb.client.cutlass.qwp.client.QwpWebSocketSender; +import io.questdb.client.test.cutlass.qwp.websocket.TestWebSocketServer; +import io.questdb.client.test.tools.TestUtils; +import org.junit.Assert; +import org.junit.Test; + +import java.io.DataInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * A TLS stream that ends inside a record (peer killed mid-write, proxy or load + * balancer dropping the connection) must read as a disconnect: the sender + * reconnects and replays. Before the fix the truncated record read as "no data + * yet" forever, so the batch was never acked and the idle I/O loop spun on the + * readable-at-EOF socket. + */ +public class TlsTruncatedRecordReconnectTest { + private static final int TLS_APPLICATION_DATA = 23; + private static final int TLS_RECORD_HEADER = 5; + + @Test(timeout = 60_000L) + public void testStreamEndingInsideTlsRecordReconnectsAndReplays() throws Exception { + TestUtils.assertMemoryLeak(() -> { + try (TestWebSocketServer server = TestWebSocketServer.tls(new PerConnectionAckHandler())) { + server.start(); + Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); + try (TruncatingProxy proxy = new TruncatingProxy(server.getPort())) { + String cfg = "wss::addr=localhost:" + proxy.getPort() + ";tls_verify=unsafe_off;" + + "auto_flush_bytes=off;auto_flush_rows=1000000;auto_flush_interval=60000;" + + "close_flush_timeout_millis=0;"; + try (QwpWebSocketSender sender = (QwpWebSocketSender) Sender.fromConfig(cfg)) { + sender.table("t").longColumn("v", 1L).atNow(); + Assert.assertTrue("first batch not acked", sender.awaitAckedFsn(sender.flushAndGetSequence(), 10_000)); + + // The ACK of the second batch is cut mid-record and followed by FIN. + proxy.truncateNextServerRecord.set(true); + sender.table("t").longColumn("v", 2L).atNow(); + long fsn = sender.flushAndGetSequence(); + Assert.assertTrue("batch whose ACK was truncated was not replayed and acked after reconnect", + sender.awaitAckedFsn(fsn, 10_000)); + Assert.assertTrue("the record was never truncated", proxy.truncated.get()); + Assert.assertTrue("sender did not reconnect", proxy.accepts.get() >= 2); + } + } + } + }); + } + + private static void closeQuietly(Socket s) { + try { + s.close(); + } catch (IOException ignored) { + } + } + + private static byte[] okFrame(long wireSeq) { + ByteBuffer bb = ByteBuffer.allocate(1 + 8 + 2).order(ByteOrder.LITTLE_ENDIAN); + bb.put((byte) 0x00); // STATUS_OK + bb.putLong(wireSeq); + bb.putShort((short) 0); + return bb.array(); + } + + // Acks every binary message with the per-connection wire sequence: a reconnect restarts it at 0. + private static final class PerConnectionAckHandler implements TestWebSocketServer.WebSocketServerHandler { + private TestWebSocketServer.ClientHandler client; + private long nextSeq; + + @Override + public synchronized void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] data) { + if (this.client != client) { + this.client = client; + nextSeq = 0; + } + try { + client.sendBinary(okFrame(nextSeq++)); + } catch (IOException ignored) { + // the connection died under us; the sender reconnects + } + } + } + + // Relays TCP to the TLS server. Once armed, forwards only the first half of the next + // server application-data record and then half-closes towards the client. + private static final class TruncatingProxy implements AutoCloseable { + final AtomicInteger accepts = new AtomicInteger(); + final AtomicBoolean truncateNextServerRecord = new AtomicBoolean(); + final AtomicBoolean truncated = new AtomicBoolean(); + private final ServerSocket listener; + private final int upstreamPort; + + TruncatingProxy(int upstreamPort) throws IOException { + this.upstreamPort = upstreamPort; + this.listener = new ServerSocket(0, 50, InetAddress.getLoopbackAddress()); + Thread acceptor = new Thread(this::acceptLoop, "truncating-proxy-accept"); + acceptor.setDaemon(true); + acceptor.start(); + } + + @Override + public void close() throws IOException { + listener.close(); + } + + int getPort() { + return listener.getLocalPort(); + } + + private void acceptLoop() { + try { + while (true) { + Socket client = listener.accept(); + accepts.incrementAndGet(); + Socket upstream = new Socket(InetAddress.getLoopbackAddress(), upstreamPort); + start(() -> pump(client, upstream), "truncating-proxy-up"); + start(() -> pumpServerRecords(upstream, client), "truncating-proxy-down"); + } + } catch (IOException ignored) { + // listener closed + } + } + + private void pump(Socket from, Socket to) { + try { + InputStream in = from.getInputStream(); + OutputStream out = to.getOutputStream(); + byte[] buf = new byte[16_384]; + int n; + while ((n = in.read(buf)) > 0) { + out.write(buf, 0, n); + out.flush(); + } + } catch (IOException ignored) { + } finally { + closeQuietly(from); + closeQuietly(to); + } + } + + private void pumpServerRecords(Socket server, Socket client) { + try { + DataInputStream in = new DataInputStream(server.getInputStream()); + OutputStream out = client.getOutputStream(); + byte[] header = new byte[TLS_RECORD_HEADER]; + while (true) { + in.readFully(header); + byte[] body = new byte[((header[3] & 0xff) << 8) | (header[4] & 0xff)]; + in.readFully(body); + out.write(header); + if (header[0] == TLS_APPLICATION_DATA && truncateNextServerRecord.compareAndSet(true, false)) { + out.write(body, 0, body.length / 2); + out.flush(); + truncated.set(true); + client.shutdownOutput(); + return; // the client must notice the dead stream and close its socket + } + out.write(body); + out.flush(); + } + } catch (IOException ignored) { + closeQuietly(server); + closeQuietly(client); + } + } + + private void start(Runnable r, String name) { + Thread t = new Thread(r, name); + t.setDaemon(true); + t.start(); + } + } +} From 1304f71d4e4471298a4584a30a51be5980ec146b Mon Sep 17 00:00:00 2001 From: Jaromir Hamala Date: Fri, 23 Oct 2026 11:41:36 +0200 Subject: [PATCH 22/36] fix(qwp): seed the ACK-wake mark before register() publishes the ring 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. --- .../qwp/client/sf/cursor/SegmentManager.java | 20 ++++++- .../SegmentManagerAckWatermarkLagTest.java | 54 +++++++++++++++++++ 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentManager.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentManager.java index 53bc59c8b..d6ed1220b 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentManager.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentManager.java @@ -142,6 +142,10 @@ public final class SegmentManager implements QuietCloseable { // prefix here until the post-unlink directory barrier succeeds avoids both // per-pass allocation and publishing logical removal before it is durable. private final MmapSegment[] trimBatch = new MmapSegment[MAX_TRIMS_PER_RING_PASS]; + // Test seam: runs in register() right after the new entry became visible to + // the worker. Null in production; tests use it to let the worker service the + // entry before register() finishes. + private volatile Runnable afterRegisterPublishHook; // Test seam: runs after a deferred ring-pass cleanup returns. Null in // production; public sender/pool lifecycle tests use it to observe exact // callback completion without sleeps or polling. @@ -708,6 +712,12 @@ public void register(SegmentRing ring, String dir, AckWatermark watermark, long long minNextGeneration = dir == null ? -1L : scanMaxGeneration(dir) + 1L; Runnable managerWakeup = this::wakeWorker; RingEntry e = new RingEntry(ring, dir, watermark, sideFileBytes, syncIntervalNanos, ticks.getAsLong()); + if (watermark != null) { + // The first ACK wakes the worker to persist it. Seeded before the entry + // is visible: from then on the worker owns the mark, and a store after + // publication could undo a wake the worker has already armed. + ring.setPersistedAckFsn(-1L); + } // ObjList.add either throws before storing e or makes the entry visible. // Once visible, only non-throwing state commits may remain, so the gauge // (whose list can also throw on growth) is added first and withdrawn if @@ -732,8 +742,9 @@ public void register(SegmentRing ring, String dir, AckWatermark watermark, long ring.enablePeriodicSync(); } } - if (watermark != null) { - ring.setPersistedAckFsn(-1L); // the first ACK wakes the worker to persist it + Runnable publishHook = afterRegisterPublishHook; + if (publishHook != null) { + publishHook.run(); } ring.setManagerWakeup(managerWakeup); // Nudge the worker so it picks up the new ring on its very next @@ -798,6 +809,11 @@ public boolean serviceRingForTesting(SegmentRing ring) { return serviceRing(selected); } + @TestOnly + public void setAfterRegisterPublishHook(Runnable hook) { + this.afterRegisterPublishHook = hook; + } + @TestOnly public void setAfterRingCleanupHook(Runnable hook) { this.afterRingCleanupHook = hook; diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerAckWatermarkLagTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerAckWatermarkLagTest.java index beb90611d..6bee9eb25 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerAckWatermarkLagTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerAckWatermarkLagTest.java @@ -41,6 +41,7 @@ import java.nio.file.Paths; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.locks.LockSupport; /** * ACKs that land inside the active segment never complete a sealed segment, so @@ -161,6 +162,59 @@ public void testFirstAckAfterRegisterWakesParkedWorker() throws Exception { }); } + /** + * A recovered ring (acked before register) on a running worker: the worker + * services the entry and arms the ACK wake before register() returns. + * register() must not overwrite that arm, or later ACKs inside the active + * segment wake nothing and the watermark stops. + */ + @Test(timeout = 20_000L) + public void testRegisterKeepsAckWakeArmedByAnEarlyWorkerPass() throws Exception { + TestUtils.assertMemoryLeak(() -> { + long segmentSize = MmapSegment.HEADER_SIZE + 64 * (MmapSegment.FRAME_HEADER_SIZE + 32L); + MmapSegment initial = MmapSegment.create(tmpDir + "/sf-0000000000000000.sfa", 0L, segmentSize); + SegmentRing ring = new SegmentRing(initial, segmentSize); + AckWatermark watermark = AckWatermark.open(tmpDir); + Assert.assertNotNull(watermark); + long payload = Unsafe.malloc(32, MemoryTag.NATIVE_DEFAULT); + try (SegmentManager manager = new SegmentManager(segmentSize, new SegmentBudget(segmentSize * 8))) { + for (int i = 0; i < 10; i++) { + Assert.assertTrue(ring.appendOrFsn(payload, 32) >= 0); + } + ring.acknowledge(9L); + manager.start(); + Thread worker = manager.getWorkerThreadForTesting(); + Assert.assertNotNull(worker); + manager.setAfterRegisterPublishHook(() -> { + // Persisted, then re-armed by a timed pass: only an armed worker parks untimed. + manager.wakeWorker(); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while ((watermark.read() != 9L || worker.getState() != Thread.State.WAITING) + && System.nanoTime() < deadline) { + LockSupport.parkNanos(1_000_000L); + } + }); + manager.register(ring, tmpDir, watermark); + Assert.assertEquals("the worker did not service the entry inside register()", 9L, watermark.read()); + Assert.assertEquals(Thread.State.WAITING, worker.getState()); + + for (int i = 0; i < 10; i++) { + Assert.assertTrue(ring.appendOrFsn(payload, 32) >= 0); + } + ring.acknowledge(19L); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (watermark.read() != 19L && System.nanoTime() < deadline) { + Thread.sleep(1); + } + Assert.assertEquals("an ACK after register did not wake the armed worker", 19L, watermark.read()); + } finally { + Unsafe.free(payload, 32, MemoryTag.NATIVE_DEFAULT); + watermark.close(); + ring.close(); + } + }); + } + @Test(timeout = 30_000L) public void testStreamingAcksWakeWorkerOnWatermarkCadenceNotPerAck() throws Exception { TestUtils.assertMemoryLeak(() -> { From 152478116d5143da1fc4dfdff74df8ad1301b21d Mon Sep 17 00:00:00 2001 From: Jaromir Hamala Date: Fri, 23 Oct 2026 11:47:13 +0200 Subject: [PATCH 23/36] perf(qwp): wake a budget-refused segment manager when room appears 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. --- .../qwp/client/sf/cursor/SegmentBudget.java | 30 ++++++++++--- .../qwp/client/sf/cursor/SegmentManager.java | 38 ++++++++-------- .../SegmentManagerSharedBudgetTest.java | 43 +++++++++++++++++++ 3 files changed, 88 insertions(+), 23 deletions(-) diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentBudget.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentBudget.java index 579f19001..e4ef26e00 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentBudget.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentBudget.java @@ -46,14 +46,21 @@ * plus one spare -- is provisioned past the cap, so a budget shared by * {@code n} rings can be exceeded by up to {@code n} such working sets. *

    + * A manager refused a spare is woken when room appears, so a ring starved by + * the segments of other rings resumes as soon as one of them trims or leaves. + *

    * Thread-safe. All state is guarded by this object's monitor, a leaf lock: - * managers call in while holding their own lock, and nothing here calls back - * into a manager. Side-file gauges are read under the monitor, so they must - * be wait-free and must not throw. + * managers call in while holding their own lock, and the only call back into + * a manager is a refused manager's wake, which takes no lock. Side-file gauges + * are read under the monitor, so they must be wait-free and must not throw. */ public final class SegmentBudget { private final long capacityBytes; + // Wakes of managers refused by tryCharge since room last appeared. Run and + // cleared by every call that frees room; refusal and registration are one + // step under the monitor, so a release cannot slip between them. + private final ObjList refusedWakeups = new ObjList<>(); // Live gauges of each charged slot's .symbol-dict side-file bytes. Read on // every cap check rather than folded into segmentBytes: a dictionary // grows out-of-band on producer threads, so an incremental mirror would @@ -105,6 +112,7 @@ synchronized void charge(long bytes) { synchronized void release(long bytes) { segmentBytes -= bytes; + wakeRefused(); } // Identity, not equals(): each registration hands in its own gauge instance. @@ -112,6 +120,7 @@ synchronized void removeSideFileGauge(LongSupplier gauge) { for (int i = 0, n = sideFileGauges.size(); i < n; i++) { if (sideFileGauges.getQuick(i) == gauge) { sideFileGauges.remove(i); + wakeRefused(); return; } } @@ -121,10 +130,14 @@ synchronized void removeSideFileGauge(LongSupplier gauge) { * Charges {@code bytes} if they fit under the capacity together with * everything already charged and the live side-file bytes. Check and charge * are one atomic step, so managers sharing the budget can never both take - * its last free segment. + * its last free segment. On refusal, {@code onRoom} runs once the next time + * room appears (a release or a removed gauge); it must not block or throw. */ - synchronized boolean tryCharge(long bytes) { + synchronized boolean tryCharge(long bytes, Runnable onRoom) { if (segmentBytes + sideFileBytes() + bytes > capacityBytes) { + if (refusedWakeups.indexOf(onRoom) < 0) { + refusedWakeups.add(onRoom); + } return false; } segmentBytes += bytes; @@ -138,4 +151,11 @@ private long sideFileBytes() { } return total; } + + private void wakeRefused() { + for (int i = 0, n = refusedWakeups.size(); i < n; i++) { + refusedWakeups.getQuick(i).run(); + } + refusedWakeups.clear(); + } } diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentManager.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentManager.java index d6ed1220b..a24333e83 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentManager.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentManager.java @@ -58,10 +58,10 @@ public final class SegmentManager implements QuietCloseable { public static final long DISK_FULL_LOG_THROTTLE_NANOS = 30_000_000_000L; // 30 s /** - * Retry cadence after a failed hot-spare provisioning attempt (disk full, - * budget exhausted, I/O error). Nothing else wakes the worker for that - * ring until its next rotation or ACK, so the retry needs its own timer. - * A wake from SegmentBudget.release() would make it fully event-driven. + * Retry cadence after a failed hot-spare create (disk full, I/O error). + * Nothing announces that the disk recovered, so the retry needs its own + * timer. A budget refusal needs none: the budget wakes the worker when + * room appears. */ public static final long PROVISION_RETRY_NANOS = 10_000_000L; // 10 ms public static final long UNLIMITED_TOTAL_BYTES = Long.MAX_VALUE; @@ -142,6 +142,8 @@ public final class SegmentManager implements QuietCloseable { // prefix here until the post-unlink directory barrier succeeds avoids both // per-pass allocation and publishing logical removal before it is durable. private final MmapSegment[] trimBatch = new MmapSegment[MAX_TRIMS_PER_RING_PASS]; + // Handed to each ring (spare needed, ACK wake) and to the budget (room after a refusal). + private final Runnable workerWakeup = this::wakeWorker; // Test seam: runs in register() right after the new entry became visible to // the worker. Null in production; tests use it to let the worker service the // entry before register() finishes. @@ -199,9 +201,9 @@ public final class SegmentManager implements QuietCloseable { // array. Guarded by lock and consumed on worker-loop exit outside lock. private Runnable ownedEngineExitCleanup; private long lastDiskFullLogNs; - // Worker-local: when a hot-spare provisioning attempt fails (disk full, budget - // exhausted, I/O error) the worker schedules its own retry here instead of - // relying on a tick. Reset every pass; Long.MAX_VALUE = nothing to retry. + // Worker-local: when a hot-spare create fails (disk full, I/O error) the + // worker schedules its own retry here instead of relying on a tick. Reset + // every pass; Long.MAX_VALUE = nothing to retry. private long provisionRetryDeadlineNanos = Long.MAX_VALUE; private volatile boolean running; // pathScratch free-exactly-once coordination between a timed-out close() @@ -710,7 +712,6 @@ public void register(SegmentRing ring, String dir, AckWatermark watermark, long // user's existing active file out from under the I/O loop, scrambling // the in-flight mmap. Memory-mode rings have no dir; nothing to scan. long minNextGeneration = dir == null ? -1L : scanMaxGeneration(dir) + 1L; - Runnable managerWakeup = this::wakeWorker; RingEntry e = new RingEntry(ring, dir, watermark, sideFileBytes, syncIntervalNanos, ticks.getAsLong()); if (watermark != null) { // The first ACK wakes the worker to persist it. Seeded before the entry @@ -746,7 +747,7 @@ public void register(SegmentRing ring, String dir, AckWatermark watermark, long if (publishHook != null) { publishHook.run(); } - ring.setManagerWakeup(managerWakeup); + ring.setManagerWakeup(workerWakeup); // Nudge the worker so it picks up the new ring on its very next // iteration. Without this, register-after-start has a race window: // start() schedules the worker thread, and if that thread reaches @@ -1010,16 +1011,16 @@ private boolean serviceRing0(RingEntry e) { // under the disk-total cap. Cap check is per attempt; if we're // capped here, the ring stays in BACKPRESSURE_NO_SPARE until trim // (step 2) frees space, which is retried below in this same pass. - // Logged at most once per DISK_FULL_LOG_THROTTLE_NANOS so a - // sustained-disk-full state doesn't drown the log. + // Room freed elsewhere (a sibling ring or manager, a deregister) + // wakes this worker through the budget. Logged at most once per + // DISK_FULL_LOG_THROTTLE_NANOS so a sustained-disk-full state + // doesn't drown the log. boolean capped = e.ring.needsHotSpare() && provisionSpare(e, memoryMode); boolean hasMoreTrimmable = trimAcked(e, memoryMode); // The trim may have just released the bytes the spare was refused - // for: retry once in this pass rather than parking on the retry - // timer. The timer remains the fallback for room freed elsewhere (a - // sibling ring or manager, a deregister) and for a failed create. - if (capped && provisionSpare(e, memoryMode)) { - scheduleProvisionRetry(); + // for: retry once in this pass rather than waiting for the wake. + if (capped) { + provisionSpare(e, memoryMode); } return hasMoreTrimmable; } @@ -1037,7 +1038,7 @@ private boolean provisionSpare(RingEntry e, boolean memoryMode) { // share the budget both see room for its last segment and both take // it. Installing the spare keeps the charge; every other outcome // below releases it. - boolean withinCap = budget.tryCharge(segmentSizeBytes); + boolean withinCap = budget.tryCharge(segmentSizeBytes, workerWakeup); // Liveness floor. Refusing on segment bytes is productive -- an ack // trims a sealed segment and the shortfall clears. Refusing on // side-file bytes is not: the dictionary never shrinks, so a ring @@ -1482,7 +1483,8 @@ private void workerLoop() { // Event-driven idle: every state change the worker services // wakes it (register/deregister, rotation, high-water publish, // the first ACK past the watermark, an ACK that completes a - // sealed segment). Timed work: periodic sync, a provision retry, + // sealed segment, budget room after a refused spare). Timed + // work: periodic sync, a failed spare create's retry, // a failed trim's retry, and re-persisting the watermark while // the ACK wake is disarmed. Otherwise park indefinitely. long parkNanos = Long.MAX_VALUE; diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerSharedBudgetTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerSharedBudgetTest.java index 04fd938c5..db342e4c1 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerSharedBudgetTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerSharedBudgetTest.java @@ -28,6 +28,7 @@ import io.questdb.client.cutlass.qwp.client.sf.cursor.SegmentBudget; 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.FilesFacade; import io.questdb.client.std.MemoryTag; import io.questdb.client.std.Unsafe; import io.questdb.client.test.tools.TestUtils; @@ -36,6 +37,7 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicReference; import java.util.function.BooleanSupplier; @@ -192,6 +194,47 @@ public void testRingsOfDifferentManagersShareOneBudget() throws Exception { }); } + /** + * Room another manager frees must wake a manager the budget refused, not + * wait for a timer. The starved manager's clock runs backwards, which + * stretches any timer-driven provision retry to about ten seconds; only the + * wake can deliver the spare within the deadline. + */ + @Test + public void testSiblingDeregisterWakesRefusedManager() throws Exception { + TestUtils.assertMemoryLeak(() -> { + SegmentBudget budget = new SegmentBudget(4 * SEGMENT_SIZE); + AtomicLong clock = new AtomicLong(); + long buf = Unsafe.malloc(FRAME_PAYLOAD, MemoryTag.NATIVE_DEFAULT); + try (SegmentRing busy = newRing(); + SegmentRing starved = newRing(); + SegmentManager busyManager = new SegmentManager(SEGMENT_SIZE, budget); + SegmentManager starvedManager = new SegmentManager(SEGMENT_SIZE, budget, FilesFacade.INSTANCE, + () -> clock.addAndGet(-TimeUnit.SECONDS.toNanos(10)))) { + busyManager.start(); + starvedManager.start(); + busyManager.register(busy, null); + appendFrames(busy, buf, 8); + assertCapped(busy, buf); + starvedManager.register(starved, null); + appendFrames(starved, buf, 4); + assertCapped(starved, buf); + + busyManager.deregister(busy); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2); + while (starved.needsHotSpare() && System.nanoTime() < deadline) { + Thread.sleep(1); + } + Assert.assertFalse("freed budget did not wake the refused manager", starved.needsHotSpare()); + Assert.assertTrue(busyManager.awaitRingQuiescence(busy)); + starvedManager.deregister(starved); + Assert.assertTrue(starvedManager.awaitRingQuiescence(starved)); + } finally { + Unsafe.free(buf, FRAME_PAYLOAD, MemoryTag.NATIVE_DEFAULT); + } + }); + } + // Appends `frames` frames, waiting out the moments a rotation finds the // spare not yet provisioned. Every frame is expected to fit eventually. private static void appendFrames(SegmentRing ring, long buf, int frames) throws InterruptedException { From d4fbb67433a31eab78201f4e502c3e2f72c7e099 Mon Sep 17 00:00:00 2001 From: Jaromir Hamala Date: Fri, 23 Oct 2026 12:54:45 +0200 Subject: [PATCH 24/36] fix(qwp): reject an oversized append before rotating the segment ring 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. --- .../qwp/client/sf/cursor/SegmentRing.java | 8 +++++ .../cursor/SegmentRecoveryIntegrityTest.java | 21 +++++------- .../qwp/client/sf/cursor/SegmentRingTest.java | 33 +++++++++++++++++++ 3 files changed, 50 insertions(+), 12 deletions(-) diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentRing.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentRing.java index 3ba3f9745..c9bbe151c 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentRing.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentRing.java @@ -1065,6 +1065,14 @@ public long appendOrFsn(long payloadAddr, int payloadLen) { if (spare == null) { return BACKPRESSURE_NO_SPARE; } + // Reject before rotating: the spare is empty, so this is exactly the + // check its tryWrite below would make. Rotating first would seal the + // active and consume the spare without publishing a frame, so nothing + // wakes the I/O thread to move its pin off the sealed segment, and each + // retried flush of the same oversized batch would strand one more. + if (payloadLen > spare.capacityRemaining()) { + return PAYLOAD_TOO_LARGE; + } // Periodic mode must make the predecessor's complete published // range durable before the manifest can name its successor. The // manager performs the barrier; the producer uses the existing diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentRecoveryIntegrityTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentRecoveryIntegrityTest.java index 9abd3551c..2d59a138a 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentRecoveryIntegrityTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentRecoveryIntegrityTest.java @@ -549,7 +549,7 @@ public void testFreshStartCrashBeforeManifestCreationRecoversViaLegacyPath() thr public void testSingleSectorTearLeavesPriorManifestRecordRecoverable() throws Exception { TestUtils.assertMemoryLeak(() -> { long segmentSize = MmapSegment.HEADER_SIZE + MmapSegment.FRAME_HEADER_SIZE + 64; - long payload = Unsafe.malloc(65, MemoryTag.NATIVE_DEFAULT); + long payload = Unsafe.malloc(64, MemoryTag.NATIVE_DEFAULT); try { MmapSegment initial = MmapSegment.create( tmpDir + "/sf-initial.sfa", 0, segmentSize); @@ -562,16 +562,13 @@ public void testSingleSectorTearLeavesPriorManifestRecordRecoverable() throws Ex SegmentRing ring = SegmentRing.openExisting( FilesFacade.INSTANCE, tmpDir, segmentSize); Assert.assertNotNull(ring); - try { - MmapSegment spare = MmapSegment.create( - tmpDir + "/sf-0000000000000001.sfa", 1, segmentSize); - ring.installHotSpare(spare); - Assert.assertEquals("oversized append must rotate but leave the new active empty", - SegmentRing.PAYLOAD_TOO_LARGE, ring.appendOrFsn(payload, 65)); - Assert.assertEquals(0L, ring.publishedFsn()); - Assert.assertEquals(1L, ring.getActive().baseSeq()); - } finally { - ring.close(); + ring.close(); + // A rotation that committed its manifest record, then died before + // writing the first frame into the empty successor. + MmapSegment.create(tmpDir + "/sf-0000000000000001.sfa", 1, segmentSize).close(); + try (SfManifest manifest = SfManifest.open(FilesFacade.INSTANCE, tmpDir)) { + Assert.assertNotNull(manifest); + manifest.update(0, 1); } String manifestPath = tmpDir + "/" + MANIFEST_NAME; @@ -592,7 +589,7 @@ public void testSingleSectorTearLeavesPriorManifestRecordRecoverable() throws Ex Assert.assertFalse("a surviving record must prevent manifest quarantine", Files.exists(tmpDir + "/" + MANIFEST_NAME + ".corrupt")); } finally { - Unsafe.free(payload, 65, MemoryTag.NATIVE_DEFAULT); + Unsafe.free(payload, 64, MemoryTag.NATIVE_DEFAULT); } }); } diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentRingTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentRingTest.java index a192d6a8f..ea3bab329 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentRingTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentRingTest.java @@ -146,6 +146,39 @@ public void testRotationConsumesHotSpare() throws Exception { }); } + @Test + public void testOversizedAppendLeavesActiveAndSpareUntouched() throws Exception { + TestUtils.assertMemoryLeak(() -> { + // Sized so one 100-byte payload fits and fills the segment. + long segSize = MmapSegment.HEADER_SIZE + MmapSegment.FRAME_HEADER_SIZE + 100; + long buf = Unsafe.malloc(101, MemoryTag.NATIVE_DEFAULT); + try { + MmapSegment seg0 = MmapSegment.create(tmpDir + "/seg0.sfa", 0, segSize); + try (SegmentRing ring = new SegmentRing(seg0, segSize)) { + fillPattern(buf, 101, 0); + assertEquals(0, ring.appendOrFsn(buf, 100)); + MmapSegment spare = MmapSegment.create(tmpDir + "/seg1.sfa", 1, segSize); + ring.installHotSpare(spare); + // A payload that no segment can hold must not seal the active and + // consume the spare: that rotation publishes nothing, so nothing + // wakes the I/O thread to trim the sealed segment, and every retry + // of the same batch would strand another one. + for (int i = 0; i < 3; i++) { + assertEquals(SegmentRing.PAYLOAD_TOO_LARGE, ring.appendOrFsn(buf, 101)); + } + assertSame(seg0, ring.getActive()); + assertSame(spare, ring.getHotSpareForTesting()); + assertEquals(0, ring.getSealedSegments().size()); + assertEquals(0, ring.publishedFsn()); + assertEquals(1, ring.appendOrFsn(buf, 100)); + assertSame(spare, ring.getActive()); + } + } finally { + Unsafe.free(buf, 101, MemoryTag.NATIVE_DEFAULT); + } + }); + } + @Test public void testRotationRebasesSpareToCorrectFsnRegardlessOfManagerGuess() throws Exception { TestUtils.assertMemoryLeak(() -> { From 3362671f2629c87df1cb986368619d10c49f2e00 Mon Sep 17 00:00:00 2001 From: Jaromir Hamala Date: Fri, 23 Oct 2026 13:06:33 +0200 Subject: [PATCH 25/36] test(qwp): wait out register()'s worker wake before acking in the ACK-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 1304f71d 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. --- .../SegmentManagerAckWatermarkLagTest.java | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerAckWatermarkLagTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerAckWatermarkLagTest.java index 6bee9eb25..c98a1ace4 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerAckWatermarkLagTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerAckWatermarkLagTest.java @@ -185,6 +185,9 @@ public void testRegisterKeepsAckWakeArmedByAnEarlyWorkerPass() throws Exception manager.start(); Thread worker = manager.getWorkerThreadForTesting(); Assert.assertNotNull(worker); + AtomicLong passes = new AtomicLong(); + AtomicLong passesBeforeRegisterWake = new AtomicLong(); + manager.setBeforeTrimSyncHook(passes::incrementAndGet); manager.setAfterRegisterPublishHook(() -> { // Persisted, then re-armed by a timed pass: only an armed worker parks untimed. manager.wakeWorker(); @@ -193,16 +196,28 @@ public void testRegisterKeepsAckWakeArmedByAnEarlyWorkerPass() throws Exception && System.nanoTime() < deadline) { LockSupport.parkNanos(1_000_000L); } + passesBeforeRegisterWake.set(passes.get()); }); manager.register(ring, tmpDir, watermark); Assert.assertEquals("the worker did not service the entry inside register()", 9L, watermark.read()); - Assert.assertEquals(Thread.State.WAITING, worker.getState()); + // register() ends by waking the worker. Ack only after that pass has + // read the ACK and the worker has parked again: an ACK it reads would + // be persisted by the pass itself and mask a disarmed wake. A pass + // count is needed because a just-unparked thread still reports + // WAITING until it runs. + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while ((passes.get() == passesBeforeRegisterWake.get() || worker.getState() != Thread.State.WAITING) + && System.nanoTime() < deadline) { + Thread.sleep(1); + } + Assert.assertEquals("the worker did not re-park after the register wake", + Thread.State.WAITING, worker.getState()); for (int i = 0; i < 10; i++) { Assert.assertTrue(ring.appendOrFsn(payload, 32) >= 0); } ring.acknowledge(19L); - long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); while (watermark.read() != 19L && System.nanoTime() < deadline) { Thread.sleep(1); } From 98b00346f1b096ba3a9912a84b7ee9bac60d92cd Mon Sep 17 00:00:00 2001 From: Jaromir Hamala Date: Fri, 23 Oct 2026 13:47:11 +0200 Subject: [PATCH 26/36] fix(qwp): let a parked awaitAckedFsn see close() and a recycle breach 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. --- .../qwp/client/QwpWebSocketSender.java | 14 +- .../qwp/client/AckWaiterSignalTest.java | 44 ++++++ design/wake-protocol.md | 136 ++++++++++++++++++ 3 files changed, 193 insertions(+), 1 deletion(-) create mode 100644 design/wake-protocol.md diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java index 3f5635c61..95b5dea91 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java @@ -576,7 +576,7 @@ public class QwpWebSocketSender implements Sender { // cause on every later table()/flush-family call; close() still works // normally. Every other recycle failure is transient and resumable (see // recycleResume), never latched here. - private Throwable recycleFailure; + private volatile Throwable recycleFailure; // Resumable recycle: a transient failure mid-recycle no // longer latches the sender terminal. CLOSE_LOOP = step 2 failed, the // old loop is still dying and the old engine/dictionary are intact. @@ -1563,6 +1563,17 @@ private void checkAwaitAckedFsnErrors() { engine.checkDurability(); } checkConnectionError(); + checkRecycleFailure(); + if (closed) { + // close() keeps acking through its drain, so a waiter whose target the + // drain reaches still returns true. Once the loop is down no ack can + // ever land: throw as the entry check would, instead of sleeping to + // the deadline. The loop signals the waiters after clearing running. + CursorWebSocketSendLoop loop = cursorSendLoop; + if (loop == null || !loop.isRunning()) { + throw new LineSenderException("Sender is closed"); + } + } } /** @@ -5893,6 +5904,7 @@ private RuntimeException latchRecycleBreach(CursorSendEngine rebuilt, int dictSi + "frames: the outgoing engine's fully-drained close contract " + "was breached"); recycleFailure = breach; + ackWaiters.signal(); // parked monitor threads re-check the latch recycleResume = RecycleResume.NONE; try { rebuilt.close(recycleSlotLock == null); diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/AckWaiterSignalTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/AckWaiterSignalTest.java index 112fa1a5d..e75a80741 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/AckWaiterSignalTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/AckWaiterSignalTest.java @@ -227,6 +227,50 @@ public void testCloseDrainWakesOnTerminalNack() throws Exception { }); } + /** + * close() with the drain skipped against a server that never acks: the + * loop goes down and no ack can ever reach the waiter. It must throw + * "Sender is closed" from the loop's close signal, not sleep to its + * own deadline and return false. + */ + @Test + public void testAwaitAckedFsnWakesOnClose() throws Exception { + assertMemoryLeak(() -> { + try (TestWebSocketServer silent = startedServer(new TestWebSocketServer.WebSocketServerHandler() { + })) { + QwpWebSocketSender sender = (QwpWebSocketSender) Sender.fromConfig( + "ws::addr=localhost:" + silent.getPort() + ";close_flush_timeout_millis=0;"); + sender.table("t").longColumn("v", 1L).atNow(); + long fsn = sender.flushAndGetSequence(); + AtomicReference outcome = new AtomicReference<>(); + AtomicLong elapsedMs = new AtomicLong(-1L); + Thread waiter = new Thread(() -> { + long t0 = System.nanoTime(); + try { + sender.awaitAckedFsn(fsn, 20_000); + } catch (Throwable t) { + outcome.set(t); + } finally { + elapsedMs.set((System.nanoTime() - t0) / 1_000_000L); + } + }); + waiter.start(); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (waiter.getState() != Thread.State.TIMED_WAITING && System.nanoTime() < deadline) { + Thread.sleep(1); + } + Assert.assertEquals(Thread.State.TIMED_WAITING, waiter.getState()); + sender.close(); + waiter.join(5_000); + Assert.assertFalse("waiter still parked " + elapsedMs.get() + "ms after close()", waiter.isAlive()); + Throwable t = outcome.get(); + Assert.assertTrue("expected 'Sender is closed', got " + t, + t instanceof LineSenderException && t.getMessage().contains("Sender is closed")); + Assert.assertTrue("waiter returned only after " + elapsedMs.get() + "ms", elapsedMs.get() < 2000L); + } + }); + } + private static TestWebSocketServer startedServer(TestWebSocketServer.WebSocketServerHandler handler) throws Exception { TestWebSocketServer server = new TestWebSocketServer(handler); diff --git a/design/wake-protocol.md b/design/wake-protocol.md new file mode 100644 index 000000000..521b9d6ba --- /dev/null +++ b/design/wake-protocol.md @@ -0,0 +1,136 @@ +# QWP client wake protocol + +The event-driven I/O loop (459f9092) replaced three polls with parks. A park +is only correct if every writer of every variable in its park predicate rings +the parked thread's doorbell (or the predicate is re-checked on a timer). This +file is that enumeration. Any new writer of a listed input must add itself +here and ring the listed doorbell; any new input to a park predicate must add +a row. + +Line numbers are as of 3362671f. + +## Doorbells + +| Doorbell | Mechanism | Lost-wake safety | +|---|---|---| +| I/O loop | `WebSocketClient.wake()` → eventfd/pipe, level-triggered; `awaitReadableOrWake` also returns when `socket.hasBufferedInput()` | stateful: a ring before the wait ends it | +| Segment manager worker | `SegmentManager.wakeWorker()` → `LockSupport.unpark` | permit is kept; worker re-checks after every park | +| Ack waiters | `AckWaiters.signal()` → unpark every registered thread | waiter registers before its first check; permit kept | + +## Consumer A — I/O loop idle wait + +`CursorWebSocketSendLoop.ioLoop` step 5 (`:2838-2847`) → `awaitIdle` → +`WebSocketClient.awaitReadableOrWake(idleTimeoutMillis())`. + +Park predicate: `!didWork && running && !recycledThisIteration && engine.publishStamp() == publishedBeforeWork`. +Blocks until: socket readable, doorbell, TLS user-space input, or next PING deadline. + +| Input | Writer | Doorbell / cover | Fixed by | +|---|---|---|---| +| `SegmentRing.publishStamp` | `appendOrFsn` `:1163` (sole writer, after the bytes are published) | `CursorSendEngine.published` → `wakeIoIfParked`; Dekker on volatile `ioParked` (`:505`, set before the stamp re-check) | 5d7f6bab | +| active segment / successor link (loop follows `sendingSegment → successor`, `:3978`) | `appendOrFsn` rotation `:1119-1122` | none of its own. **Invariant: a rotation is always followed by a publish in the same `appendOrFsn` call.** `requestSyncBeforeRotation` and the oversized check both return before any mutation | d4fbb674 | +| socket bytes (ACK, DURABLE_ACK, server frames, EOF) | kernel | `poll(2)`; EOF inside a TLS record now returns `<0` so the readable-at-EOF spin cannot happen | a013e560 | +| TLS plaintext spill / undecrypted records | `JavaTlsClientSocket` | `hasBufferedInput()` checked before `poll` | 3119a0ee | +| `running` | `close()` `:1486` | `unpark` + `wakeIo` + `signalAckProgress` | — | +| `recycledThisIteration` (reconnect in steps 3/4) | `fail` path | step 5 skipped when set | b5e6d529 | +| PING deadlines (idle keepalive, durable-ack prod) | clock | `idleTimeoutMillis()` timed wait | — | +| orphan tail retirement (`trySendOne` `:3929`) | server ACK watermark | socket readable | — | +| `pendingDurable` in durable-ack mode | server | socket; with `durable_ack keepalive <= 0` only inbound traffic flushes it (pre-existing design, not a wake bug) | — | +| engine swap (symbol-dict recycle) | producer | the loop is closed and replaced; old client's doorbell is irrelevant after `swapClient`, which goes straight to `trySendOne` | — | + +No open gaps. + +## Consumer B — segment manager worker + +`SegmentManager.workerLoop` `:1463-1525`. Untimed park unless a timer below is pending; `hasMoreTrimmable` skips the park. + +| Input | Writer | Doorbell / cover | Fixed by | +|---|---|---|---| +| `rings` | `register` `:761` | `wakeWorker` (after the entry is visible) | — | +| `rings` | `deregister` `:623` | none needed (nothing to service); `budget.release` wakes refused siblings | — | +| `ring.needsHotSpare()` | rotation `appendOrFsn` `:1142` | `managerWakeup.run()` | — | +| `ring.needsHotSpare()` | high-water backup `appendOrFsn` `:1175` (once per active) | `managerWakeup.run()` | — | +| `ring.needsHotSpare()` | recovered ring opened with no spare | `register` wake | — | +| trimmable head: `ackedFsn >= oldestTrimmableLastSeq` | `acknowledge` `:1023` | wakes when `seq >= oldestTrimmableLastSeq` (volatile threshold, no ring monitor on the ACK path) | 38c5d69d | +| `oldestTrimmableLastSeq` | rotation `:1129`; `releasePinnedSegment` `:1657`; `switchIoPin` `:1878` (pin moved off a segment); worker's own removal `:1865`; recovery `:691` (pre-register) | each non-worker writer that can *increase* trimmability calls `wakeManager`; a pin being *added* cannot | — | +| pending / failed trim | worker | `hasMoreTrimmable` → no park; failed unlink/barrier → `trimRetryAtNanos` timed | 98ac6ae4 | +| budget room (spare refused) | `SegmentBudget.release` / `removeSideFileGauge` | `tryCharge(bytes, onRoom)` registers the manager; `wakeRefused` runs it | 15247811 | +| failed spare create | worker | `provisionRetryDeadlineNanos` timed | — | +| periodic data sync | clock | `nextDataSyncNanos` timed | — | +| sync-before-rotation request | `appendOrFsn` `:1082` | `wakeManager`; the producer then polls at 50 µs in `CursorSendEngine.appendBlocking` `:972` (poll, see Residuals) | — | +| ACK watermark: `ackedFsn > lastPersistedAck` | `acknowledge` `:1023` | wakes iff `before <= persistedAckFsn` (armed); disarmed → `WATERMARK_LAG_NANOS` timed; Dekker at `:1510` | 39ac01e9, 779a99bc, 1304f71d | +| `running` | `close` `:335` | `unpark` | — | +| durability failure | `servicePeriodicSync` | producer throws on next append; sync retry timed | — | + +No open gaps. The ACK-watermark row is the one that took four commits: its +state is three variables in two objects under two different disciplines — +`SegmentRing.persistedAckFsn` (volatile, read by the ACK path), and +`RingEntry.ackWakeArmed` + `RingEntry.lastPersistedAck` (manager lock) — with +the invariant "`ackWakeArmed` ⇔ `persistedAckFsn == lastPersistedAck`" +maintained by hand at three sites (`:720`, `:1209-1210`, `:1281-1282`). See +Recommendation. + +## Consumer C — ack waiters + +`QwpWebSocketSender.awaitAckedFsn` `:1512-1548` and the `close()` drain `:4894-4941`. +Predicate: `getAckedFsn() >= target`, or a terminal: connection error, durability latch, closed. + +| Input | Writer | Doorbell / cover | Fixed by | +|---|---|---|---| +| `engine.ackedFsn` | `SegmentRing.acknowledge` `:1026` (server ACKs, orphan-tail self-ack via `engine.acknowledge`) | `signalAckState` → `ackWaiters::signal`, wired on every engine incl. recycled (`:3392`, `:5834`) | cf4f4b79, 50a7f4bc | +| durability latch | `recordDurabilityFailure` `:1278` | `signalAckState` | 50a7f4bc | +| `terminalError` | `recordFatal` `:3009` | `signalAckProgress` | — | +| loop close | `close()` `:1487` | `signalAckProgress` | — | +| `fsnEpochBase` / `cursorEngine` swap / `lastRecycleDurableFsn` | recycle (producer thread) | no signal; a waiter re-reads all three on its next wake, and its target can only be reached by an ACK on the new engine, which signals. The clamp cannot strand a waiter: it is the durable value at recycle time, so any target at or below it was signalled by the ACK that set it | — | +| `recycleFailure` | `latchRecycleBreach` `:5895` (producer thread; plain field `:579`) | **none**, and not in the loop predicate — Open 2 | — | +| `closed` | `close()` `:1741` | **none**, and not in the loop predicate; `loop.close` `:1487` signals but the waiter re-parks on the same predicate — Open 1 | — | + +## Open (found by the independent audit, not yet fixed) + +1. **`awaitAckedFsn` cannot see `close()`.** A monitor thread in + `awaitAckedFsn(T, 60_000)` while the producer closes with the drain skipped + (`closeFlushTimeoutMillis <= 0`), timed out, or `T` in abandoned deferred + frames: `close()` returns, the waiter stays parked for the rest of its + timeout, then returns `false` instead of throwing "Sender is closed". Fix: + check `closed` in the loop; `ackWaiters.signal()` right after `closed = true`. +2. **`recycleFailure` invisible to waiters.** Checked only on entry (`:1514`), + never signalled, non-volatile. Same shape as 1; terminal state, reach + requires a breached drain contract. Fix: volatile, check in + `checkAwaitAckedFsnErrors`, signal after the latch. +3. **Interrupt spin in `awaitAckedFsn`** (`:1536-1545`): the flag is never + cleared, so `parkNanos` returns at once on every pass and a caller with a + pending interrupt burns a core until the ACK or the deadline. The close + drain (`:4904`) already consumes-and-restores; do the same here. + +## Residuals (known, bounded) + +1. Producer backpressure (`appendBlocking` `:972`) polls at 50 µs for the + spare install (`installHotSpare` `:1469`), the sync-request clear + (`:1205`) and the durability latch (`:1275`); bounded by the 30 s append + deadline. Converting it to a park needs those three writers to ring it. + `maybeBlockForStarvedReset` (`:5972`, 2 s bound) and + `awaitDeferredEngineClose` (`:5684`, 30 s bound) are the same shape; the + first could register with `AckWaiters` instead. +2. The worker's doorbell is a one-bit park permit. Any contended j.u.c lock + the worker blocks on between servicing and `park` (an SLF4J appender lock + behind its LOG calls is the only candidate found) consumes a pending + permit. In disk mode the `ackedFsn > lastPersistedAck` timed park covers + it; in memory mode an ACK that completed a sealed segment during that + window is trimmed one segment late. Cheap guard if wanted: skip the park + when `firstTrimmable() != null`. +3. `CursorSendEngine.appendOrFsn(addr, len, spinDeadline)` (`:991`) has no + callers in `core/src/main`. +4. `BackgroundDrainer` `:1264` polls `POLL_NANOS`; not the foreground path. + +## Recommendation + +Collapse the ACK-watermark arm state to one variable owned by the worker: +`SegmentRing.ackWakeMark` (volatile long), `Long.MAX_VALUE` = disarmed, +otherwise the FSN the mark was armed at. `acknowledge` wakes iff +`before <= ackWakeMark`; `ackWakeArmed` becomes `mark != MAX_VALUE`; +`lastPersistedAck` stays as plain watermark bookkeeping. That removes the +cross-object invariant that 779a99bc, 1304f71d and 3362671f were each +repairing, and makes the register seed a single store with no ordering story. +Model it in `design/tla` next to `IdleWait` before changing it: IdleWait is +the only wake path on this branch whose bug was found mechanically rather +than by a reviewer probe. From 28fc30cda95aeb9e2afc03a4e8a5c290036b0247 Mon Sep 17 00:00:00 2001 From: Jaromir Hamala Date: Fri, 23 Oct 2026 13:48:06 +0200 Subject: [PATCH 27/36] fix(qwp): consume a pending interrupt in awaitAckedFsn instead of spinning 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. --- .../qwp/client/QwpWebSocketSender.java | 8 +++++ .../qwp/client/AckWaiterSignalTest.java | 33 +++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java index 95b5dea91..0230c223a 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java @@ -1531,6 +1531,10 @@ public boolean awaitAckedFsn(long targetFsn, long timeoutMillis) { // Registered before the first check: an ack that lands after a check // unparks us (or leaves the permit), one that landed before is seen by it. ackWaiters.add(me); + // A pending interrupt makes parkNanos return at once without clearing the + // flag, turning the wait into a full-core spin. Consume it, remember it, + // and restore it on the way out, as the close() drain does. + boolean interrupted = false; try { while (true) { if (getAckedFsn() >= targetFsn) { @@ -1541,10 +1545,14 @@ public boolean awaitAckedFsn(long targetFsn, long timeoutMillis) { if (remaining <= 0L) { return false; } + interrupted |= Thread.interrupted(); java.util.concurrent.locks.LockSupport.parkNanos(this, remaining); } } finally { ackWaiters.remove(me); + if (interrupted) { + me.interrupt(); + } } } diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/AckWaiterSignalTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/AckWaiterSignalTest.java index e75a80741..f4d5f88c0 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/AckWaiterSignalTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/AckWaiterSignalTest.java @@ -271,6 +271,39 @@ public void testAwaitAckedFsnWakesOnClose() throws Exception { }); } + /** + * A caller with a pending interrupt: parkNanos returns at once and leaves + * the flag set, so without consuming it the wait is a spin. Count the + * re-check passes through the ackedFsn read witness; a parked waiter makes + * a handful, a spinning one millions. The flag must be restored on return. + */ + @Test + public void testAwaitAckedFsnDoesNotSpinWhenInterrupted() throws Exception { + assertMemoryLeak(() -> { + try (TestWebSocketServer silent = startedServer(new TestWebSocketServer.WebSocketServerHandler() { + })) { + QwpWebSocketSender sender = (QwpWebSocketSender) Sender.fromConfig( + "ws::addr=localhost:" + silent.getPort() + ";close_flush_timeout_millis=0;"); + try { + sender.table("t").longColumn("v", 1L).atNow(); + long fsn = sender.flushAndGetSequence(); + AtomicLong reads = new AtomicLong(); + sender.setAckedFsnReadWitnessForTesting(reads::incrementAndGet); + Thread.currentThread().interrupt(); + boolean acked = sender.awaitAckedFsn(fsn, 300); + boolean flagRestored = Thread.interrupted(); + Assert.assertFalse(acked); + Assert.assertTrue("interrupt flag must be restored on return", flagRestored); + Assert.assertTrue("awaitAckedFsn re-checked " + reads.get() + " times in 300ms: it spun " + + "instead of parking", reads.get() < 100L); + } finally { + Thread.interrupted(); + sender.close(); + } + } + }); + } + private static TestWebSocketServer startedServer(TestWebSocketServer.WebSocketServerHandler handler) throws Exception { TestWebSocketServer server = new TestWebSocketServer(handler); From f8f33c0cc321e6555faca13ab616f86849126746 Mon Sep 17 00:00:00 2001 From: Jaromir Hamala Date: Fri, 23 Oct 2026 13:48:06 +0200 Subject: [PATCH 28/36] docs(qwp): mark the audit's open wake items as fixed --- design/wake-protocol.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/design/wake-protocol.md b/design/wake-protocol.md index 521b9d6ba..729c66e0e 100644 --- a/design/wake-protocol.md +++ b/design/wake-protocol.md @@ -85,7 +85,7 @@ Predicate: `getAckedFsn() >= target`, or a terminal: connection error, durabilit | `recycleFailure` | `latchRecycleBreach` `:5895` (producer thread; plain field `:579`) | **none**, and not in the loop predicate — Open 2 | — | | `closed` | `close()` `:1741` | **none**, and not in the loop predicate; `loop.close` `:1487` signals but the waiter re-parks on the same predicate — Open 1 | — | -## Open (found by the independent audit, not yet fixed) +## Fixed after the independent audit (98b00346, next commit) 1. **`awaitAckedFsn` cannot see `close()`.** A monitor thread in `awaitAckedFsn(T, 60_000)` while the producer closes with the drain skipped From f37967c4da5bb9b228c4341c4176ff3c031cf8a9 Mon Sep 17 00:00:00 2001 From: Jaromir Hamala Date: Fri, 23 Oct 2026 15:12:31 +0200 Subject: [PATCH 29/36] fix(qwp): keep a floor-charged spare's failed create from waking its 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. --- .../qwp/client/sf/cursor/SegmentBudget.java | 15 ++++++ .../qwp/client/sf/cursor/SegmentManager.java | 2 +- .../SegmentManagerProvisionRetryTest.java | 46 +++++++++++++++++++ design/wake-protocol.md | 2 +- 4 files changed, 63 insertions(+), 2 deletions(-) diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentBudget.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentBudget.java index e4ef26e00..9cc4e2363 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentBudget.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentBudget.java @@ -110,6 +110,21 @@ synchronized void charge(long bytes) { segmentBytes += bytes; } + /** + * The liveness-floor charge: {@code bytes} were just refused by + * {@link #tryCharge}, which registered {@code onRoom}, and the manager + * provisions anyway. Withdraw the registration: it no longer waits for + * room, and a release of its own (a failed create) must not wake it, or + * the worker spins through its retry timer. + */ + synchronized void chargeBelowFloor(long bytes, Runnable onRoom) { + segmentBytes += bytes; + int i = refusedWakeups.indexOf(onRoom); + if (i >= 0) { + refusedWakeups.remove(i); + } + } + synchronized void release(long bytes) { segmentBytes -= bytes; wakeRefused(); diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentManager.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentManager.java index a24333e83..1c8ee60bc 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentManager.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentManager.java @@ -1050,7 +1050,7 @@ private boolean provisionSpare(RingEntry e, boolean memoryMode) { long ringSegmentBytes = withinCap ? 0L : e.ring.totalSegmentBytes(); boolean belowLivenessFloor = !withinCap && ringSegmentBytes < livenessFloorBytes; if (belowLivenessFloor) { - budget.charge(segmentSizeBytes); + budget.chargeBelowFloor(segmentSizeBytes, workerWakeup); } if (!withinCap && !belowLivenessFloor) { long now = System.nanoTime(); diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerProvisionRetryTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerProvisionRetryTest.java index 029cc894e..d46dde19e 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerProvisionRetryTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerProvisionRetryTest.java @@ -40,6 +40,7 @@ import java.nio.file.Files; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; /** * The two ways a ring gets its spare after a refusal: the pass whose own trim @@ -88,6 +89,51 @@ public void testPassWhoseTrimFreesBudgetInstallsSpareInSamePass() throws Excepti }); } + /** + * A cap under two segments refuses every spare, so each one takes the + * liveness-floor path. A create that keeps failing then releases the floor + * charge, and that release must not wake the manager that was refused a + * moment earlier: it would unpark itself out of its own retry timer and + * spin, one create attempt per loop pass instead of one per 10 ms. + */ + @Test + public void testFailedFloorCreateDoesNotWakeItsOwnRetry() throws Exception { + TestUtils.assertMemoryLeak(() -> { + File dir = Files.createTempDirectory("qdb-segmgr-floor-spin").toFile(); + AtomicLong attempts = new AtomicLong(); + FilesFacade facade = new DelegatingFilesFacade() { + @Override + public boolean allocate(int fd, long size) { + attempts.incrementAndGet(); + return false; + } + }; + String path = new File(dir, "sf-0000000000000000.sfa").getPath(); + SegmentBudget budget = new SegmentBudget(SEGMENT_SIZE + SEGMENT_SIZE / 2); + try (SegmentRing ring = new SegmentRing(MmapSegment.create(path, 0, SEGMENT_SIZE), SEGMENT_SIZE); + SegmentManager manager = new SegmentManager(SEGMENT_SIZE, budget, facade, System::nanoTime)) { + manager.start(); + manager.register(ring, dir.getPath()); + Thread.sleep(300); + long n = attempts.get(); + Assert.assertTrue("ring must still be waiting for a spare", ring.needsHotSpare()); + Assert.assertTrue("expected about 30 create attempts in 300 ms on the retry timer, got " + n + + ": the failed floor charge's release woke the worker itself", n < 100); + Assert.assertTrue("the retry timer must keep the create attempts coming, got " + n, n >= 5); + manager.deregister(ring); + Assert.assertTrue(manager.awaitRingQuiescence(ring)); + } finally { + File[] files = dir.listFiles(); + if (files != null) { + for (File f : files) { + f.delete(); + } + } + dir.delete(); + } + }); + } + @Test public void testFailedCreateIsRetriedWithNoFurtherEvent() throws Exception { TestUtils.assertMemoryLeak(() -> { diff --git a/design/wake-protocol.md b/design/wake-protocol.md index 729c66e0e..b5da132af 100644 --- a/design/wake-protocol.md +++ b/design/wake-protocol.md @@ -54,7 +54,7 @@ No open gaps. | trimmable head: `ackedFsn >= oldestTrimmableLastSeq` | `acknowledge` `:1023` | wakes when `seq >= oldestTrimmableLastSeq` (volatile threshold, no ring monitor on the ACK path) | 38c5d69d | | `oldestTrimmableLastSeq` | rotation `:1129`; `releasePinnedSegment` `:1657`; `switchIoPin` `:1878` (pin moved off a segment); worker's own removal `:1865`; recovery `:691` (pre-register) | each non-worker writer that can *increase* trimmability calls `wakeManager`; a pin being *added* cannot | — | | pending / failed trim | worker | `hasMoreTrimmable` → no park; failed unlink/barrier → `trimRetryAtNanos` timed | 98ac6ae4 | -| budget room (spare refused) | `SegmentBudget.release` / `removeSideFileGauge` | `tryCharge(bytes, onRoom)` registers the manager; `wakeRefused` runs it | 15247811 | +| budget room (spare refused) | `SegmentBudget.release` / `removeSideFileGauge` | `tryCharge(bytes, onRoom)` registers the manager; `wakeRefused` runs it. The liveness-floor charge withdraws the registration: a refused manager that provisions anyway and then releases (failed create) must not wake itself out of its 10 ms retry | 15247811, next commit | | failed spare create | worker | `provisionRetryDeadlineNanos` timed | — | | periodic data sync | clock | `nextDataSyncNanos` timed | — | | sync-before-rotation request | `appendOrFsn` `:1082` | `wakeManager`; the producer then polls at 50 µs in `CursorSendEngine.appendBlocking` `:972` (poll, see Residuals) | — | From e1b30a9969694f1c1bb50f09e7b9a3d8f9a59754 Mon Sep 17 00:00:00 2001 From: Jaromir Hamala Date: Fri, 23 Oct 2026 16:37:40 +0200 Subject: [PATCH 30/36] refactor(qwp): write the ack watermark from the I/O thread, not the segment 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 (39ac01e9, 779a99bc, 1304f71d, 3362671f). 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). --- .../qwp/client/sf/cursor/AckWatermark.java | 6 +- .../client/sf/cursor/CursorSendEngine.java | 24 ++- .../qwp/client/sf/cursor/SegmentManager.java | 104 ++------- .../qwp/client/sf/cursor/SegmentRing.java | 23 +- .../SegmentManagerAckWatermarkLagTest.java | 200 +++--------------- .../SegmentManagerUnlinkFailureTest.java | 1 + design/wake-protocol.md | 44 ++-- 7 files changed, 89 insertions(+), 313 deletions(-) diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/AckWatermark.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/AckWatermark.java index b11ae5615..f4e34f941 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/AckWatermark.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/AckWatermark.java @@ -237,8 +237,10 @@ public void sync() { * has been stored. The next {@link #sync()} makes the complete update * durable before any covered segment is deleted. *

    - * Caller responsibility: monotonic ordering. The manager's tick loop only - * calls this when {@code fsn} has advanced past the last write. + * Caller responsibility: monotonic ordering and a single writer. The + * engine's I/O thread calls this on every ACK advance; {@link #sync()} + * may run concurrently on the manager thread -- a record torn by that + * race fails its CRC and recovery falls back to the other slot. */ public void write(long fsn) { if (closed) return; diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorSendEngine.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorSendEngine.java index ce41027e7..b3379cd2b 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorSendEngine.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorSendEngine.java @@ -905,7 +905,16 @@ public long ackedFsn() { * at publishedFsn). */ public boolean acknowledge(long seq) { - return ring.acknowledge(seq); + if (!ring.acknowledge(seq)) { + return false; + } + // Single writer of the watermark: a store into the mmap, no syscall. + // The manager msyncs it before it unlinks a trimmed segment, and a + // crash replays only what landed after the last store. + if (watermark != null) { + watermark.write(ring.ackedFsn()); + } + return true; } /** @@ -1255,9 +1264,10 @@ private void finishClose(boolean fullyDrained) { } // On a fully-drained close, persist the final acked FSN through the // still-mapped watermark BEFORE closing the ring/watermark and BEFORE - // unlinking any segment file. The manager persists the watermark only - // on its own tick, so it may lag the final ack. If any part of this - // covering barrier fails, retain the ring, watermark and slot flock: + // unlinking any segment file. The I/O thread wrote it on every ACK + // but only the manager's trim syncs it, so it may not be durable yet. + // If any part of this covering barrier fails, retain the ring, + // watermark and slot flock: // publishing the slot would let a successor recover acknowledged // residue from a stale durable watermark. The shared retry driver owns // convergence after the one-shot public close reports the failure. @@ -1287,9 +1297,9 @@ private void finishClose(boolean fullyDrained) { ring.close(); } catch (Throwable ignored) { } - // Close the watermark mmap/fd after the manager (which - // writes through it) is gone but before the slot lock is - // released. On fully-drained close, also unlink the file + // Close the watermark mmap/fd after the I/O loop (which writes + // it) and the manager (which syncs it) are gone but before the + // slot lock is released. On fully-drained close, also unlink the file // -- a stale watermark with no segments behind it would // confuse a future recovery cycle if (it wouldn't actually // confuse current recovery, which only reads the watermark diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentManager.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentManager.java index 1c8ee60bc..e9a5d79e0 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentManager.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentManager.java @@ -65,15 +65,6 @@ public final class SegmentManager implements QuietCloseable { */ public static final long PROVISION_RETRY_NANOS = 10_000_000L; // 10 ms public static final long UNLIMITED_TOTAL_BYTES = Long.MAX_VALUE; - /** - * Upper bound on how far the mmap ack watermark trails {@code ackedFsn} - * while ACKs keep landing inside the active segment. The first such ACK - * wakes the worker; writing the watermark disarms the ACK wake, so later - * ACKs ring nothing while the worker re-parks on this cadence. A timed - * pass that finds no new ACK re-arms the wake and parks without a - * timeout. A crash replays at most this much already-acked data. - */ - public static final long WATERMARK_LAG_NANOS = 1_000_000L; // 1 ms private static final int ENTRY_DEREGISTERED = 3; private static final int ENTRY_DEREGISTERED_IN_SERVICE = 2; private static final int ENTRY_IN_SERVICE = 1; @@ -147,7 +138,6 @@ public final class SegmentManager implements QuietCloseable { // Test seam: runs in register() right after the new entry became visible to // the worker. Null in production; tests use it to let the worker service the // entry before register() finishes. - private volatile Runnable afterRegisterPublishHook; // Test seam: runs after a deferred ring-pass cleanup returns. Null in // production; public sender/pool lifecycle tests use it to observe exact // callback completion without sleeps or polling. @@ -660,10 +650,10 @@ public void register(SegmentRing ring, String dir) { } /** - * Same as {@link #register(SegmentRing, String)} but also wires an - * {@link AckWatermark} the manager will keep up to date on every - * tick. The watermark is owned by the caller (typically - * {@link CursorSendEngine}); the manager only writes through it + * Same as {@link #register(SegmentRing, String)} but also wires the + * {@link AckWatermark} the manager syncs before it unlinks a trimmed + * segment. The watermark is owned and written by the caller (typically + * {@link CursorSendEngine}, on every ACK); the manager only syncs it * and never closes it. {@code null} watermark falls back to the * legacy behaviour: no on-disk watermark, recovery seeds from * {@code lowestSurvivingBaseSeq - 1}. @@ -713,12 +703,6 @@ public void register(SegmentRing ring, String dir, AckWatermark watermark, long // the in-flight mmap. Memory-mode rings have no dir; nothing to scan. long minNextGeneration = dir == null ? -1L : scanMaxGeneration(dir) + 1L; RingEntry e = new RingEntry(ring, dir, watermark, sideFileBytes, syncIntervalNanos, ticks.getAsLong()); - if (watermark != null) { - // The first ACK wakes the worker to persist it. Seeded before the entry - // is visible: from then on the worker owns the mark, and a store after - // publication could undo a wake the worker has already armed. - ring.setPersistedAckFsn(-1L); - } // ObjList.add either throws before storing e or makes the entry visible. // Once visible, only non-throwing state commits may remain, so the gauge // (whose list can also throw on growth) is added first and withdrawn if @@ -743,10 +727,6 @@ public void register(SegmentRing ring, String dir, AckWatermark watermark, long ring.enablePeriodicSync(); } } - Runnable publishHook = afterRegisterPublishHook; - if (publishHook != null) { - publishHook.run(); - } ring.setManagerWakeup(workerWakeup); // Nudge the worker so it picks up the new ring on its very next // iteration. Without this, register-after-start has a race window: @@ -810,11 +790,6 @@ public boolean serviceRingForTesting(SegmentRing ring) { return serviceRing(selected); } - @TestOnly - public void setAfterRegisterPublishHook(Runnable hook) { - this.afterRegisterPublishHook = hook; - } - @TestOnly public void setAfterRingCleanupHook(Runnable hook) { this.afterRingCleanupHook = hook; @@ -1192,25 +1167,6 @@ private boolean trimAcked(RingEntry e, boolean memoryMode) { int pendingCount = e.ring.pendingTrimCount(); MmapSegment first = pendingCount == 0 ? e.ring.firstTrimmable() : null; if (pendingCount == 0 && first == null) { - // Preserve the cheap mmap-only watermark cadence for ACKs that do - // not yet cover a complete, unpinned sealed segment. - synchronized (lock) { - if (e.isRegistered() && e.watermark != null) { - long currentAck = e.ring.ackedFsn(); - if (currentAck > e.lastPersistedAck) { - e.watermark.write(currentAck); - e.lastPersistedAck = currentAck; - // Stay disarmed: the worker re-parks on - // WATERMARK_LAG_NANOS, so later ACKs need not wake it. - e.ackWakeArmed = false; - } else if (!e.ackWakeArmed) { - // A timed pass found no new ACK: re-arm so the next - // one wakes the worker, then park without a timeout. - e.ring.setPersistedAckFsn(currentAck); - e.ackWakeArmed = true; - } - } - } return false; } @@ -1257,9 +1213,12 @@ private boolean trimAcked(RingEntry e, boolean memoryMode) { return false; } - // Every attempt repeats the cheap covering barrier. Besides keeping - // the latest cumulative ACK durable, this preserves the same strict - // pre-unlink/post-unlink directory ordering on pending retries. + // Every attempt repeats the cheap covering barrier. The I/O thread + // writes the watermark on every ACK, so the sync makes the ACKs this + // trim relies on durable (an ACK racing the msync is at worst torn + // into the record recovery's max() clamp discards), and it preserves + // the same strict pre-unlink/post-unlink directory ordering on + // pending retries. if (e.watermark == null) { if (!e.missingWatermarkLogged) { e.missingWatermarkLogged = true; @@ -1273,14 +1232,6 @@ private boolean trimAcked(RingEntry e, boolean memoryMode) { return false; } durableAck = e.ring.ackedFsn(); - if (durableAck > e.lastPersistedAck) { - e.watermark.write(durableAck); - e.lastPersistedAck = durableAck; - // Re-arm here rather than poll: a failed barrier below must - // leave the worker parked until its trim retry, not ticking. - e.ring.setPersistedAckFsn(durableAck); - e.ackWakeArmed = true; - } } try { e.watermark.sync(); @@ -1482,11 +1433,10 @@ private void workerLoop() { } // Event-driven idle: every state change the worker services // wakes it (register/deregister, rotation, high-water publish, - // the first ACK past the watermark, an ACK that completes a - // sealed segment, budget room after a refused spare). Timed - // work: periodic sync, a failed spare create's retry, - // a failed trim's retry, and re-persisting the watermark while - // the ACK wake is disarmed. Otherwise park indefinitely. + // an ACK that completes a sealed segment, budget room after a + // refused spare). Timed work: periodic sync, a failed spare + // create's retry, a failed trim's retry. Otherwise park + // indefinitely. long parkNanos = Long.MAX_VALUE; if (!hasMoreTrimmable) { long now = ticks.getAsLong(); @@ -1501,15 +1451,6 @@ private void workerLoop() { if (e.trimRetryDelayNanos != 0L) { parkNanos = Math.min(parkNanos, Math.max(0L, e.trimRetryAtNanos - now)); } - // Disarmed: nothing wakes us for ACKs, so poll on the - // watermark cadence. Armed: Dekker with - // SegmentRing.acknowledge -- it writes ackedFsn then - // reads persistedAckFsn; we wrote persistedAckFsn (in - // serviceRing) and read ackedFsn here, so an ACK that - // raced the re-arm is seen by one side or the other. - if (e.watermark != null && (!e.ackWakeArmed || e.ring.ackedFsn() > e.lastPersistedAck)) { - parkNanos = Math.min(parkNanos, WATERMARK_LAG_NANOS); - } } } // Drop strong refs so a deregistered ring becomes collectable @@ -1581,21 +1522,10 @@ private static final class RingEntry { final LongSupplier sideFileBytes; final long syncIntervalNanos; // Engine-owned ack watermark for this slot, or null in memory - // mode and for callers that didn't supply one. Manager-thread - // only after register; never closed here (owner closes). + // mode and for callers that didn't supply one. The owner's I/O + // thread writes it; the worker only syncs it before a trim and + // never closes it. final AckWatermark watermark; - // Highest FSN this entry has written to its watermark. Manager- - // thread only -- no concurrent access. Initialized to -1 so the - // first observed acked FSN (>= 0) triggers the first write. - // Survives across multiple serviceRing ticks and avoids a - // write-storm when ackedFsn is steady. - long lastPersistedAck = -1L; - // Whether the ring's persistedAckFsn equals lastPersistedAck, i.e. - // the next ACK wakes the worker. Writing the watermark clears it and - // the worker then polls on WATERMARK_LAG_NANOS; a timed pass that - // finds no new ACK sets it again. Manager-thread only. Starts true: - // register seeds persistedAckFsn to -1, matching lastPersistedAck. - boolean ackWakeArmed = true; // Prevents a legacy disk registration without a watermark from // flooding the log on every manager tick. boolean missingWatermarkLogged; diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentRing.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentRing.java index c9bbe151c..6c598f261 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentRing.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentRing.java @@ -120,13 +120,6 @@ public final class SegmentRing implements QuietCloseable { // producer would otherwise hit BACKPRESSURE_NO_SPARE. private final long signalAtBytes; private volatile long ackedFsn = -1L; - // The ACK wake arm: the acked FSN at which the manager last went idle. - // Long.MIN_VALUE until a manager registers a watermark, so no ACK counts - // as "past it". acknowledge() wakes the manager on the first ACK past it. - // The manager deliberately leaves it stale while it persists the watermark - // on its WATERMARK_LAG_NANOS cadence, so later ACKs ring nothing; it is - // re-armed by a timed pass that finds no new ACK. - private volatile long persistedAckFsn = Long.MIN_VALUE; // Last FSN of the oldest sealed segment that is not I/O-pinned, or // Long.MAX_VALUE when there is none: the ACK at or past it completes a // trimmable segment. Re-derived under the monitor by every writer of the @@ -1018,9 +1011,11 @@ public boolean acknowledge(long seq) { long before = ackedFsn; if (seq > before) { ackedFsn = seq; - // The manager does not poll: wake it when this ACK is the first past - // the persisted watermark or completes the oldest sealed segment. - if (before <= persistedAckFsn || seq >= oldestTrimmableLastSeq) { + // The manager does not poll: wake it only when this ACK completes + // the oldest sealed segment. The ack watermark is written by the + // I/O thread itself (CursorSendEngine.acknowledge), so an ACK + // inside the active segment is nothing the manager must see. + if (seq >= oldestTrimmableLastSeq) { wakeManager(); } signalAckState(); @@ -1732,14 +1727,6 @@ public void setManagerWakeup(Runnable wakeup) { this.managerWakeup = wakeup; } - /** - * Manager-only: re-arms the ACK wake at {@code fsn}, the current acked - * FSN at the moment the manager goes idle; see {@link #acknowledge}. - */ - public void setPersistedAckFsn(long fsn) { - this.persistedAckFsn = fsn; - } - /** * Thread-safe snapshot of the current sealed-segment list. Copies * references into the caller-supplied {@code target} array (oldest diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerAckWatermarkLagTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerAckWatermarkLagTest.java index c98a1ace4..9b8187d09 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerAckWatermarkLagTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerAckWatermarkLagTest.java @@ -25,6 +25,7 @@ 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.CursorSendEngine; import io.questdb.client.cutlass.qwp.client.sf.cursor.MmapSegment; import io.questdb.client.cutlass.qwp.client.sf.cursor.SegmentBudget; import io.questdb.client.cutlass.qwp.client.sf.cursor.SegmentManager; @@ -39,19 +40,13 @@ import org.junit.Test; import java.nio.file.Paths; -import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicLong; -import java.util.concurrent.locks.LockSupport; /** - * ACKs that land inside the active segment never complete a sealed segment, so - * nothing but the watermark cadence brings the manager back. The on-disk - * watermark must still catch up on its own: after a crash, recovery replays - * everything above it as duplicates. Regression for the event-driven worker. - *

    - * The converse bound: while ACKs keep streaming in, the worker must run on the - * {@link SegmentManager#WATERMARK_LAG_NANOS} cadence, not once per ACK, and - * must go back to an untimed park once the stream stops. + * The ack watermark is written by the I/O thread on every ACK, so an ACK that + * lands inside the active segment is visible in the mmap the moment + * {@link CursorSendEngine#acknowledge} returns and costs the segment manager + * nothing: no wake, no timed pass, no state to re-arm. */ public class SegmentManagerAckWatermarkLagTest { private String tmpDir; @@ -83,161 +78,39 @@ public void tearDown() { Files.remove(tmpDir); } - @Test(timeout = 20_000L) - public void testAcksInsideActiveSegmentReachWatermarkWithoutOtherActivity() throws Exception { - TestUtils.assertMemoryLeak(() -> { - long segmentSize = MmapSegment.HEADER_SIZE + 64 * (MmapSegment.FRAME_HEADER_SIZE + 32L); - MmapSegment initial = MmapSegment.create(tmpDir + "/sf-0000000000000000.sfa", 0L, segmentSize); - SegmentRing ring = new SegmentRing(initial, segmentSize); - AckWatermark watermark = AckWatermark.open(tmpDir); - Assert.assertNotNull(watermark); - long payload = Unsafe.malloc(32, MemoryTag.NATIVE_DEFAULT); - try (SegmentManager manager = new SegmentManager(segmentSize, new SegmentBudget(segmentSize * 8))) { - manager.register(ring, tmpDir, watermark); - manager.start(); - // Three rounds: the first ACK after a fully persisted watermark - // must wake a worker that is parked without a timeout, every time. - for (int round = 0; round < 3; round++) { - for (int i = 0; i < 10; i++) { - Assert.assertTrue(ring.appendOrFsn(payload, 32) >= 0); - } - long acked = round * 10 + 9; - ring.acknowledge(acked); - long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); - while (watermark.read() != acked && System.nanoTime() < deadline) { - Thread.sleep(1); - } - Assert.assertEquals("watermark did not catch up with ACKs in round " + round, - acked, watermark.read()); - } - } finally { - Unsafe.free(payload, 32, MemoryTag.NATIVE_DEFAULT); - watermark.close(); - ring.close(); - } - }); - } - /** - * The round-0 case above with the startup race removed: register seeds the - * ring's persisted-ACK mark so that the very first ACK wakes the worker. - * Wait until the worker is parked without a timeout before acking, so the - * register wake cannot service the ACK by accident. + * The manager is never started, so nothing but the acknowledging thread + * can have written the watermark. */ @Test(timeout = 20_000L) - public void testFirstAckAfterRegisterWakesParkedWorker() throws Exception { + public void testAckWritesWatermarkBeforeReturning() throws Exception { TestUtils.assertMemoryLeak(() -> { long segmentSize = MmapSegment.HEADER_SIZE + 64 * (MmapSegment.FRAME_HEADER_SIZE + 32L); - MmapSegment initial = MmapSegment.create(tmpDir + "/sf-0000000000000000.sfa", 0L, segmentSize); - SegmentRing ring = new SegmentRing(initial, segmentSize); - AckWatermark watermark = AckWatermark.open(tmpDir); - Assert.assertNotNull(watermark); long payload = Unsafe.malloc(32, MemoryTag.NATIVE_DEFAULT); - try (SegmentManager manager = new SegmentManager(segmentSize, new SegmentBudget(segmentSize * 8))) { - manager.register(ring, tmpDir, watermark); - manager.start(); - Thread worker = manager.getWorkerThreadForTesting(); - Assert.assertNotNull(worker); - long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); - while (worker.getState() != Thread.State.WAITING && System.nanoTime() < deadline) { - Thread.sleep(1); - } - Assert.assertEquals("worker must park without a timeout once the register wake is serviced", - Thread.State.WAITING, worker.getState()); + try (SegmentManager manager = new SegmentManager(segmentSize, new SegmentBudget(segmentSize * 8)); + CursorSendEngine engine = new CursorSendEngine(tmpDir, segmentSize, manager); + AckWatermark probe = AckWatermark.open(tmpDir)) { + Assert.assertNotNull(probe); for (int i = 0; i < 10; i++) { - Assert.assertTrue(ring.appendOrFsn(payload, 32) >= 0); - } - ring.acknowledge(9L); - deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2); - while (watermark.read() != 9L && System.nanoTime() < deadline) { - Thread.sleep(1); + engine.appendBlocking(payload, 32); } - Assert.assertEquals("the first ACK after register did not wake the parked worker", - 9L, watermark.read()); + Assert.assertTrue(engine.acknowledge(9L)); + Assert.assertEquals(9L, probe.read()); + Assert.assertFalse("re-ack must be a no-op", engine.acknowledge(9L)); + Assert.assertFalse("clamped at publishedFsn, already acked", engine.acknowledge(100L)); + Assert.assertEquals(9L, probe.read()); } finally { Unsafe.free(payload, 32, MemoryTag.NATIVE_DEFAULT); - watermark.close(); - ring.close(); - } - }); - } - - /** - * A recovered ring (acked before register) on a running worker: the worker - * services the entry and arms the ACK wake before register() returns. - * register() must not overwrite that arm, or later ACKs inside the active - * segment wake nothing and the watermark stops. - */ - @Test(timeout = 20_000L) - public void testRegisterKeepsAckWakeArmedByAnEarlyWorkerPass() throws Exception { - TestUtils.assertMemoryLeak(() -> { - long segmentSize = MmapSegment.HEADER_SIZE + 64 * (MmapSegment.FRAME_HEADER_SIZE + 32L); - MmapSegment initial = MmapSegment.create(tmpDir + "/sf-0000000000000000.sfa", 0L, segmentSize); - SegmentRing ring = new SegmentRing(initial, segmentSize); - AckWatermark watermark = AckWatermark.open(tmpDir); - Assert.assertNotNull(watermark); - long payload = Unsafe.malloc(32, MemoryTag.NATIVE_DEFAULT); - try (SegmentManager manager = new SegmentManager(segmentSize, new SegmentBudget(segmentSize * 8))) { - for (int i = 0; i < 10; i++) { - Assert.assertTrue(ring.appendOrFsn(payload, 32) >= 0); - } - ring.acknowledge(9L); - manager.start(); - Thread worker = manager.getWorkerThreadForTesting(); - Assert.assertNotNull(worker); - AtomicLong passes = new AtomicLong(); - AtomicLong passesBeforeRegisterWake = new AtomicLong(); - manager.setBeforeTrimSyncHook(passes::incrementAndGet); - manager.setAfterRegisterPublishHook(() -> { - // Persisted, then re-armed by a timed pass: only an armed worker parks untimed. - manager.wakeWorker(); - long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); - while ((watermark.read() != 9L || worker.getState() != Thread.State.WAITING) - && System.nanoTime() < deadline) { - LockSupport.parkNanos(1_000_000L); - } - passesBeforeRegisterWake.set(passes.get()); - }); - manager.register(ring, tmpDir, watermark); - Assert.assertEquals("the worker did not service the entry inside register()", 9L, watermark.read()); - // register() ends by waking the worker. Ack only after that pass has - // read the ACK and the worker has parked again: an ACK it reads would - // be persisted by the pass itself and mask a disarmed wake. A pass - // count is needed because a just-unparked thread still reports - // WAITING until it runs. - long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); - while ((passes.get() == passesBeforeRegisterWake.get() || worker.getState() != Thread.State.WAITING) - && System.nanoTime() < deadline) { - Thread.sleep(1); - } - Assert.assertEquals("the worker did not re-park after the register wake", - Thread.State.WAITING, worker.getState()); - - for (int i = 0; i < 10; i++) { - Assert.assertTrue(ring.appendOrFsn(payload, 32) >= 0); - } - ring.acknowledge(19L); - deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); - while (watermark.read() != 19L && System.nanoTime() < deadline) { - Thread.sleep(1); - } - Assert.assertEquals("an ACK after register did not wake the armed worker", 19L, watermark.read()); - } finally { - Unsafe.free(payload, 32, MemoryTag.NATIVE_DEFAULT); - watermark.close(); - ring.close(); } }); } @Test(timeout = 30_000L) - public void testStreamingAcksWakeWorkerOnWatermarkCadenceNotPerAck() throws Exception { + public void testStreamingAcksInsideActiveSegmentNeverWakeWorker() throws Exception { TestUtils.assertMemoryLeak(() -> { final int acks = 20_000; - final long spacingNanos = 20_000L; // 50k ACK/s - // Segment large enough that every frame lands in the active - // segment below the hot-spare high-water mark: no rotation, no - // trim, nothing but ACKs reaches the worker. + // Every frame lands in the active segment below the hot-spare + // high-water mark: no rotation, no trim, nothing for the worker. long segmentSize = MmapSegment.HEADER_SIZE + (acks * 2L + 64) * (MmapSegment.FRAME_HEADER_SIZE + 32L); MmapSegment initial = MmapSegment.create(tmpDir + "/sf-0000000000000000.sfa", 0L, segmentSize); SegmentRing ring = new SegmentRing(initial, segmentSize); @@ -246,8 +119,6 @@ public void testStreamingAcksWakeWorkerOnWatermarkCadenceNotPerAck() throws Exce long payload = Unsafe.malloc(32, MemoryTag.NATIVE_DEFAULT); AtomicLong passes = new AtomicLong(); try (SegmentManager manager = new SegmentManager(segmentSize, new SegmentBudget(segmentSize * 8))) { - // Runs once per serviceRing pass, i.e. once per worker wake or - // timed park expiry for this ring. manager.setBeforeTrimSyncHook(passes::incrementAndGet); manager.register(ring, tmpDir, watermark); manager.start(); @@ -256,34 +127,11 @@ public void testStreamingAcksWakeWorkerOnWatermarkCadenceNotPerAck() throws Exce } Thread.sleep(50); // let the register wake settle passes.set(0); - - long start = System.nanoTime(); - long next = start; - for (int seq = 0; seq < acks; ) { - if (System.nanoTime() - next >= 0) { - ring.acknowledge(seq++); - next += spacingNanos; - } - } - long elapsedMillis = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start); - - long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); - while (watermark.read() != acks - 1 && System.nanoTime() < deadline) { - Thread.sleep(1); + for (int seq = 0; seq < acks; seq++) { + ring.acknowledge(seq); } - Assert.assertEquals("watermark did not catch up after the ACK stream", acks - 1, watermark.read()); - long streamPasses = passes.get(); - // One wake for the first ACK, then at most one pass per - // WATERMARK_LAG_NANOS; x2 leaves room for scheduler jitter. - long bound = elapsedMillis * 2 + 16; - Assert.assertTrue("worker ran " + streamPasses + " passes for " + acks + " ACKs over " - + elapsedMillis + " ms; bound " + bound, streamPasses <= bound); - - // Once the stream stops the worker re-arms the ACK wake and - // parks without a timeout: no further passes while idle. Thread.sleep(100); - long idlePasses = passes.get() - streamPasses; - Assert.assertTrue("worker kept ticking while idle: " + idlePasses + " passes", idlePasses <= 2); + Assert.assertEquals("worker ran for ACKs inside the active segment", 0L, passes.get()); } finally { Unsafe.free(payload, 32, MemoryTag.NATIVE_DEFAULT); watermark.close(); diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerUnlinkFailureTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerUnlinkFailureTest.java index 7638f8802..6f8ca792d 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerUnlinkFailureTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerUnlinkFailureTest.java @@ -163,6 +163,7 @@ public void testFailedUnlinkRetainsBookkeepingAndUsesSuccessorPath() throws Exce ring.installHotSpare(MmapSegment.create(activePath, 1L, segmentSize)); Assert.assertEquals(1L, ring.appendOrFsn(payload, 32)); ring.acknowledge(0L); + watermark.write(0L); // what CursorSendEngine.acknowledge does on the I/O thread byte[] original = java.nio.file.Files.readAllBytes(Paths.get(failedPath)); FailingFilesFacade facade = new FailingFilesFacade(failedPath, null, null); diff --git a/design/wake-protocol.md b/design/wake-protocol.md index b5da132af..ca2b70746 100644 --- a/design/wake-protocol.md +++ b/design/wake-protocol.md @@ -7,7 +7,7 @@ file is that enumeration. Any new writer of a listed input must add itself here and ring the listed doorbell; any new input to a park predicate must add a row. -Line numbers are as of 3362671f. +Line numbers are as of 3362671f; the ACK-watermark row was removed after it (see Consumer B). ## Doorbells @@ -58,17 +58,19 @@ No open gaps. | failed spare create | worker | `provisionRetryDeadlineNanos` timed | — | | periodic data sync | clock | `nextDataSyncNanos` timed | — | | sync-before-rotation request | `appendOrFsn` `:1082` | `wakeManager`; the producer then polls at 50 µs in `CursorSendEngine.appendBlocking` `:972` (poll, see Residuals) | — | -| ACK watermark: `ackedFsn > lastPersistedAck` | `acknowledge` `:1023` | wakes iff `before <= persistedAckFsn` (armed); disarmed → `WATERMARK_LAG_NANOS` timed; Dekker at `:1510` | 39ac01e9, 779a99bc, 1304f71d | | `running` | `close` `:335` | `unpark` | — | | durability failure | `servicePeriodicSync` | producer throws on next append; sync retry timed | — | -No open gaps. The ACK-watermark row is the one that took four commits: its -state is three variables in two objects under two different disciplines — -`SegmentRing.persistedAckFsn` (volatile, read by the ACK path), and -`RingEntry.ackWakeArmed` + `RingEntry.lastPersistedAck` (manager lock) — with -the invariant "`ackWakeArmed` ⇔ `persistedAckFsn == lastPersistedAck`" -maintained by hand at three sites (`:720`, `:1209-1210`, `:1281-1282`). See -Recommendation. +No open gaps. The ACK watermark is no longer a manager input: the I/O thread +writes it in `CursorSendEngine.acknowledge` (a store into the mmap, no +syscall), and the worker only `sync()`s it before unlinking a trimmed segment. +That removed the row that took four commits (39ac01e9, 779a99bc, 1304f71d, +3362671f): `SegmentRing.persistedAckFsn`, `RingEntry.ackWakeArmed`, +`RingEntry.lastPersistedAck`, `WATERMARK_LAG_NANOS` and the Dekker between +them. A `write` racing the worker's msync can leave one torn record on disk; +the record fails its CRC and recovery takes the other slot, then the +`max(lowestBase - 1, watermark)` clamp — the watermark was always a +discardable optimization under that clamp. ## Consumer C — ack waiters @@ -114,23 +116,19 @@ Predicate: `getAckedFsn() >= target`, or a terminal: connection error, durabilit 2. The worker's doorbell is a one-bit park permit. Any contended j.u.c lock the worker blocks on between servicing and `park` (an SLF4J appender lock behind its LOG calls is the only candidate found) consumes a pending - permit. In disk mode the `ackedFsn > lastPersistedAck` timed park covers - it; in memory mode an ACK that completed a sealed segment during that - window is trimmed one segment late. Cheap guard if wanted: skip the park - when `firstTrimmable() != null`. + permit. An ACK that completed a sealed segment during that window is + trimmed one segment late. Cheap guard if wanted: skip the park when + `firstTrimmable() != null`. 3. `CursorSendEngine.appendOrFsn(addr, len, spinDeadline)` (`:991`) has no callers in `core/src/main`. 4. `BackgroundDrainer` `:1264` polls `POLL_NANOS`; not the foreground path. ## Recommendation -Collapse the ACK-watermark arm state to one variable owned by the worker: -`SegmentRing.ackWakeMark` (volatile long), `Long.MAX_VALUE` = disarmed, -otherwise the FSN the mark was armed at. `acknowledge` wakes iff -`before <= ackWakeMark`; `ackWakeArmed` becomes `mark != MAX_VALUE`; -`lastPersistedAck` stays as plain watermark bookkeeping. That removes the -cross-object invariant that 779a99bc, 1304f71d and 3362671f were each -repairing, and makes the register seed a single store with no ordering story. -Model it in `design/tla` next to `IdleWait` before changing it: IdleWait is -the only wake path on this branch whose bug was found mechanically rather -than by a reviewer probe. +Keep moving state to the thread that produces it instead of adding wake +protocols for it. Done: the ACK watermark (above). Next: the producer creates +its own next segment at rotation (removes `needsHotSpare`, the high-water +backup wake, the provision retry, the liveness floor and the budget's refused +list), then the worker becomes a queue of blocking disk work (trim unlink + +directory fsync, periodic sync) whose park-if-empty is lost-wake-free by +construction. From c86689f2d5d5f09f78c0e0a5b9bf29e06f8f9abf Mon Sep 17 00:00:00 2001 From: Jaromir Hamala Date: Fri, 23 Oct 2026 17:00:27 +0200 Subject: [PATCH 31/36] fix(qwp): overwrite a rejected ack watermark at recovery e1b30a99 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 e1b30a99 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. --- .../client/sf/cursor/CursorSendEngine.java | 24 ++++++--- .../sf/cursor/CursorSendEngineTest.java | 50 +++++++++++++++++++ 2 files changed, 68 insertions(+), 6 deletions(-) diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorSendEngine.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorSendEngine.java index b3379cd2b..94ff8ad1c 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorSendEngine.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorSendEngine.java @@ -530,21 +530,33 @@ private CursorSendEngine(String sfDir, long segmentSizeBytes, SegmentManager man } long baseSeed = lowestBase - 1; long watermarkFsn = watermarkInProgress.read(); - // Reject watermarks past publishedFsn: a correctly - // operating prior session cannot have produced one, so - // a value above the on-disk frame ceiling is corruption - // (torn write on a non-atomic filesystem, hardware - // fault, manual edit). Trusting it would seed ackedFsn + // Reject watermarks past publishedFsn. A host crash in MEMORY + // or PERIODIC durability produces one routinely: a trim + // msyncs the watermark while the segment data pages it + // covers are still dirty. Trusting it would seed ackedFsn // = publishedFsn after ring.acknowledge clamps it, and // the cursor would position past every un-acked frame // -- silent data loss. Fall back to the segment-derived // seed so the un-acked tail still replays. long publishedFsn = recovered.publishedFsn(); long candidate = Math.max(watermarkFsn, baseSeed); - long seed = candidate > publishedFsn ? baseSeed : candidate; + boolean rejected = candidate > publishedFsn; + long seed = rejected ? baseSeed : candidate; if (seed >= 0) { recovered.acknowledge(seed); } + // Bring the file in line with the seed now: the I/O thread + // writes it only on the next ACK advance, which during an + // outage may be never, and a rejected value left on disk + // would be accepted by a later recovery once new frames push + // publishedFsn past it. Durable in the rejected case so a + // host crash cannot resurrect it. + if (seed != watermarkFsn) { + watermarkInProgress.write(seed); + if (rejected) { + watermarkInProgress.sync(); + } + } // Fold the whole recovered ring once. The result checkpoints all // running metadata and raw symbol bytes at each commit-bearing // frame, so its final snapshot excludes an orphan deferred tail diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorSendEngineTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorSendEngineTest.java index 7f802535a..8d1696f5b 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorSendEngineTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorSendEngineTest.java @@ -667,6 +667,56 @@ public void testRecoveryIgnoresWatermarkAbovePublishedFsn() throws Exception { }); } + /** + * A watermark rejected as past publishedFsn (a host crash in MEMORY + * durability leaves one after a trim msync) must be overwritten at + * recovery. Nothing else writes it until the next ACK advance, which + * during an outage is never, and once new frames push publishedFsn past + * the stale value a later recovery would accept it and skip them. + */ + @Test(timeout = 60_000L) + public void testRejectedWatermarkIsOverwrittenAtRecovery() throws Exception { + TestUtils.assertMemoryLeak(() -> { + long segSize = MmapSegment.HEADER_SIZE + 4 * (MmapSegment.FRAME_HEADER_SIZE + 64); + long buf = Unsafe.malloc(64, MemoryTag.NATIVE_DEFAULT); + Unsafe.getUnsafe().setMemory(buf, 64, (byte) 0); + try { + // Session 1: three 4-frame segments, ACK 3 so the first one is trimmed. + try (CursorSendEngine e = new CursorSendEngine(tmpDir, segSize)) { + for (int i = 0; i < 12; i++) { + e.appendBlocking(buf, 64); + } + assertTrue(e.acknowledge(3L)); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10); + while (Files.exists(tmpDir + "/sf-initial.sfa") && System.nanoTime() < deadline) { + LockSupport.parkNanos(1_000_000L); + } + assertFalse("trim did not happen", Files.exists(tmpDir + "/sf-initial.sfa")); + } + try (AckWatermark w = AckWatermark.open(tmpDir)) { + assertNotNull(w); + w.write(100L); + } + // Session 2: 100 > publishedFsn 11 is rejected, seed is 3. The + // server is unreachable: frames up to 120 are appended, none acked. + try (CursorSendEngine e = new CursorSendEngine(tmpDir, segSize)) { + assertEquals(3L, e.ackedFsn()); + assertEquals(11L, e.publishedFsn()); + while (e.publishedFsn() < 120L) { + e.appendBlocking(buf, 64); + } + } + // Session 3: nothing above FSN 3 was ever acknowledged. + try (CursorSendEngine e = new CursorSendEngine(tmpDir, segSize)) { + assertEquals(120L, e.publishedFsn()); + assertEquals("frames 12..100 were never acknowledged", 3L, e.ackedFsn()); + } + } finally { + Unsafe.free(buf, 64, MemoryTag.NATIVE_DEFAULT); + } + }); + } + @Test(timeout = 30_000L) public void testManagerPersistedWatermarkSurvivesRestart() throws Exception { // Positive twin of testRecoveryAdvancesAckedFsnPastWatermark. That test From 75e40622734ef668be0b8003979e81149b7e6bb6 Mon Sep 17 00:00:00 2001 From: Jaromir Hamala Date: Fri, 23 Oct 2026 17:02:26 +0200 Subject: [PATCH 32/36] chore(qwp): drop the redundant close-time watermark write, fix stale comments and tests after e1b30a99 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. --- .../qwp/client/sf/cursor/AckWatermark.java | 27 +++++----- .../client/sf/cursor/CursorSendEngine.java | 54 +++++++------------ .../qwp/client/sf/cursor/SegmentManager.java | 20 ++++--- ...ursorSendEngineCloseUnlinkFailureTest.java | 20 +++---- .../sf/cursor/CursorSendEngineTest.java | 25 ++++----- .../SegmentManagerAckWatermarkLagTest.java | 13 ++++- design/wake-protocol.md | 12 +++-- 7 files changed, 84 insertions(+), 87 deletions(-) diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/AckWatermark.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/AckWatermark.java index f4e34f941..65c0586ba 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/AckWatermark.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/AckWatermark.java @@ -64,20 +64,23 @@ * segment-derived seed. *

    * Zero-alloc, store-only ACK writes: {@link #open(String)} maps both - * records once. Ordinary ACK-only manager updates mutate the inactive record - * in the mapping and require no malloc/free, read/write syscalls, or rename. + * records once. The I/O thread's per-ACK {@link #write(long)} mutates the + * inactive record in the mapping and needs no malloc/free, syscall or rename. *

    - * fsync cadence: ordinary ACK-only manager updates call - * {@link #write(long)} and stay syscall-free. Each non-empty background disk-trim - * quantum calls {@link #sync()} once (one mmap msync and one fd fsync), fsyncs - * the slot directory before unlinking, and fsyncs it again after the batch. A - * fully drained close uses the same covering order so the durable watermark - * guards any acknowledged segment that a host crash restores. + * fsync cadence: each non-empty background disk-trim quantum calls + * {@link #sync()} once (one mmap msync and one fd fsync), fsyncs the slot + * directory before unlinking, and fsyncs it again after the batch. A fully + * drained close uses the same covering order so the durable watermark guards + * any acknowledged segment that a host crash restores. A write racing the + * msync can tear either record, or both; recovery then falls back to the older + * valid record or the segment-derived seed, which only costs duplicates. *

    - * Lifecycle: single-writer (the {@link SegmentManager} worker thread) - * after construction. Read once at engine startup (any thread, before the - * manager observes the entry). Close releases the mapping and fd. Not - * thread-safe for concurrent writers. + * Lifecycle: single-writer: the engine's I/O thread + * ({@link CursorSendEngine#acknowledge}); the engine constructor (recovery + * seed) and a fully drained close write from the owning thread while no I/O + * thread runs. {@link #sync()} may run concurrently on the + * {@link SegmentManager} worker. Read once at engine startup. Close releases + * the mapping and fd. Not thread-safe for concurrent writers. */ public final class AckWatermark implements QuietCloseable { diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorSendEngine.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorSendEngine.java index 94ff8ad1c..d876d6882 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorSendEngine.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorSendEngine.java @@ -162,8 +162,9 @@ public final class CursorSendEngine implements QuietCloseable { // disk mode fails construction unless the watermark is usable because // segment-derived recovery cannot distinguish acknowledged residue from // replayable frames. Lifetime tied to the engine: opened in the - // constructor, closed by {@link #close()}. The segment manager writes - // through this on every tick where ackedFsn has advanced. + // constructor, closed by {@link #close()}. Written by the I/O thread on + // every ACK advance ({@link #acknowledge}); the segment manager only + // syncs it before a trim. private final AckWatermark watermark; // Engine-owned per-slot symbol dictionary file (disk mode only; {@code null} // in memory mode and if open() failed). Enables delta-encoded SF frames: @@ -449,22 +450,6 @@ private CursorSendEngine(String sfDir, long segmentSizeBytes, SegmentManager man // not exist on disk if earlier segments have been trimmed, // causing it to fall through to the active segment's tip // and skip the unacked sealed segments entirely. - // - // Then check the persisted ack watermark. If present and - // larger than the segment-derived seed, prefer it: it - // captures durable-acks that landed inside the lowest - // surviving sealed segment before the previous sender - // crashed. Without this, those already-acked frames would - // be re-replayed on reconnect, producing row-level - // duplicates unless the target table dedupes them. - // max(lowestBase - 1, watermark) absorbs both write - // orderings of the manager's "persist then trim" tick: - // - persist crashed before trim: segments still on disk - // are >= lowest, watermark is correct; max picks - // watermark. - // - trim ran before persist: segments are gone (so - // lowestBase is higher than watermark), watermark is - // stale; max picks lowestBase - 1. MmapSegment first = recovered.firstSealed(); long lowestBase = first != null ? first.baseSeq() @@ -475,15 +460,14 @@ private CursorSendEngine(String sfDir, long segmentSizeBytes, SegmentManager man // surviving sealed segment -- without it, those frames // get re-replayed across process restart, producing // row-level duplicates unless the target table dedupes - // them. max(lowestBase - 1, watermark) absorbs both - // write orderings of the manager's "persist then trim" - // tick: - // - persist crashed before trim: segments still on - // disk are >= lowest, watermark is correct; max - // picks watermark. - // - trim ran before persist: segments are gone (so - // lowestBase is higher than watermark), watermark - // is stale; max picks lowestBase - 1. + // them. max(lowestBase - 1, watermark) absorbs a watermark + // that is behind the trim (the I/O thread writes it per ACK, + // the manager syncs it before unlinking; a crash between the + // two, or a torn record, leaves it stale or invalid): + // - watermark durable, segments still on disk: max picks + // the watermark. + // - segments gone, watermark stale: lowestBase is higher + // than the watermark; max picks lowestBase - 1. // Segment-derived state cannot tell whether frames in the // lowest surviving segment were acknowledged. Starting // without the watermark would therefore expose acknowledged @@ -1274,20 +1258,18 @@ private void finishClose(boolean fullyDrained) { throw e; } } - // On a fully-drained close, persist the final acked FSN through the - // still-mapped watermark BEFORE closing the ring/watermark and BEFORE - // unlinking any segment file. The I/O thread wrote it on every ACK - // but only the manager's trim syncs it, so it may not be durable yet. - // If any part of this covering barrier fails, retain the ring, - // watermark and slot flock: + // On a fully-drained close, make the final acked FSN durable through + // the still-mapped watermark BEFORE closing the ring/watermark and + // BEFORE unlinking any segment file. The I/O thread wrote it on the + // final ACK but only the manager's trim syncs it, so it may not be + // durable yet. If any part of this covering barrier fails, retain the + // ring, watermark and slot flock: // publishing the slot would let a successor recover acknowledged // residue from a stale durable watermark. The shared retry driver owns // convergence after the one-shot public close reports the failure. if (fullyDrained && watermark != null) { try { - long finalAckedFsn = ring.ackedFsn(); - if (finalAckedFsn >= 0) { - watermark.write(finalAckedFsn); + if (ring.ackedFsn() >= 0) { watermark.sync(); if (filesFacade.fsyncDir(sfDir) != 0) { throw new IllegalStateException( diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentManager.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentManager.java index e9a5d79e0..4eb3190f4 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentManager.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentManager.java @@ -135,9 +135,6 @@ public final class SegmentManager implements QuietCloseable { private final MmapSegment[] trimBatch = new MmapSegment[MAX_TRIMS_PER_RING_PASS]; // Handed to each ring (spare needed, ACK wake) and to the budget (room after a refusal). private final Runnable workerWakeup = this::wakeWorker; - // Test seam: runs in register() right after the new entry became visible to - // the worker. Null in production; tests use it to let the worker service the - // entry before register() finishes. // Test seam: runs after a deferred ring-pass cleanup returns. Null in // production; public sender/pool lifecycle tests use it to observe exact // callback completion without sleeps or polling. @@ -164,8 +161,7 @@ public final class SegmentManager implements QuietCloseable { // synchronized(lock) entry. Null in production; only // SegmentManagerTrimDeregisterRaceTest installs it, to deterministically // inject a deregister(ring) call into the exact race window that the - // entry-state check inside the trim block closes for watermark writes and - // budget accounting. + // entry-state check inside the trim block closes for budget accounting. private volatile Runnable beforeTrimSyncHook; // Test seam invoked exactly when a retry transition/recovery is logged. // Null in production; persistent-failure tests use it to prove log bounds @@ -1213,12 +1209,14 @@ private boolean trimAcked(RingEntry e, boolean memoryMode) { return false; } - // Every attempt repeats the cheap covering barrier. The I/O thread - // writes the watermark on every ACK, so the sync makes the ACKs this - // trim relies on durable (an ACK racing the msync is at worst torn - // into the record recovery's max() clamp discards), and it preserves - // the same strict pre-unlink/post-unlink directory ordering on - // pending retries. + // Every attempt repeats the cheap covering barrier. The trim itself + // is protected by the fsync'd manifest head, not by the watermark: + // recovery skips files below the head and seeds at least headBase-1. + // The sync only makes the watermark the I/O thread has written so far + // (possibly one ACK behind durableAck, or torn by a racing write) + // as durable as it can be, so recovery replays fewer duplicates, and + // it preserves the same strict pre-unlink/post-unlink directory + // ordering on pending retries. if (e.watermark == null) { if (!e.missingWatermarkLogged) { e.missingWatermarkLogged = true; diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorSendEngineCloseUnlinkFailureTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorSendEngineCloseUnlinkFailureTest.java index f074cc6f1..ef46ecf9b 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorSendEngineCloseUnlinkFailureTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorSendEngineCloseUnlinkFailureTest.java @@ -47,20 +47,22 @@ /** * Regression for the close-time segment cleanup on a fully-drained slot. *

    - * {@code CursorSendEngine.finishClose} unlinks the acknowledged {@code .sfa} - * files and then removes the ack watermark. When the unlink fails (transient - * I/O error, permission problem), the residual segment files hold rows the - * server already acknowledged. If the watermark does not cover them, a - * successor engine on the same slot seeds recovery from {@code lowestBase - 1} - * and replays every acknowledged row — duplicates on a non-DEDUP table. + * {@code CursorSendEngine.finishClose} syncs the ack watermark, unlinks the + * acknowledged {@code .sfa} files and then removes the watermark. When the + * unlink fails (transient I/O error, permission problem), the residual segment + * files hold rows the server already acknowledged. If the watermark does not + * cover them, a successor engine on the same slot seeds recovery from + * {@code lowestBase - 1} and replays every acknowledged row — duplicates on a + * non-DEDUP table. The final ACK wrote the watermark; this guards that close + * keeps it ahead of the residue. *

    * The test injects the unlink failure by dropping write permission on the * slot directory (POSIX: unlink requires a writable parent directory), so it * is skipped on Windows and when permissions are not enforced (root). * The shared {@link SegmentManager} is deliberately never started: no worker - * thread exists, so no manager tick can persist the watermark behind the - * test's back, and the close-path quiescence barrier is trivially satisfied — - * fully deterministic, no timing coordination needed. + * thread exists, so no trim can run behind the test's back, and the + * close-path quiescence barrier is trivially satisfied — fully deterministic, + * no timing coordination needed. */ public class CursorSendEngineCloseUnlinkFailureTest { diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorSendEngineTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorSendEngineTest.java index 8d1696f5b..43e567a8d 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorSendEngineTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorSendEngineTest.java @@ -720,14 +720,11 @@ public void testRejectedWatermarkIsOverwrittenAtRecovery() throws Exception { @Test(timeout = 30_000L) public void testManagerPersistedWatermarkSurvivesRestart() throws Exception { // Positive twin of testRecoveryAdvancesAckedFsnPastWatermark. That test - // FORGES the .ack-watermark by hand; this one drives a real, started - // SegmentManager to PERSIST it from real acks, then proves a second - // session recovers the manager-written value. Without this, a regression - // that silently stopped the manager's trim-path watermark.write() (e.g. - // an inverted `registered` gate) would pass the whole suite: the durable- - // ack tests assert on the in-memory engine.ackedFsn(), and the recovery - // tests forge the watermark, so nothing observes the manager doing the - // write. + // FORGES the .ack-watermark by hand; this one lets a real ack write it + // (engine.acknowledge, the I/O thread's path), then proves a second + // session recovers that value. The durable-ack tests assert on the + // in-memory engine.ackedFsn() and the recovery tests forge the file, + // so nothing else observes the ACK path doing the write. TestUtils.assertMemoryLeak(() -> { long segSize = MmapSegment.HEADER_SIZE + 4 * (MmapSegment.FRAME_HEADER_SIZE + 64); @@ -749,7 +746,7 @@ public void testManagerPersistedWatermarkSurvivesRestart() throws Exception { // watermark to disk. If the trim-path write were gated off this // never reaches 2 and the helper fails with a clear message, // rather than the test flaking on a close()-before-tick race. - awaitManagerPersistedWatermark(tmpDir, 2L); + awaitPersistedWatermark(tmpDir, 2L); } // Session 2: recovery must seed ackedFsn from the manager-written // watermark (2), not the bare segment-derived seed (-1). @@ -1117,10 +1114,10 @@ private static Thread workerThread(SegmentManager manager) throws Exception { } // Polls the on-disk watermark until it reads {@code expected}, or fails after - // a bounded wait. The probe is a second mapping of the same file the manager - // worker writes through; its MAP_SHARED reads observe the worker's writes, and - // it is closed before the next session opens the slot. - private static void awaitManagerPersistedWatermark(String slotDir, long expected) { + // a bounded wait. The probe is a second mapping of the same file the engine + // writes through; its MAP_SHARED reads observe those writes, and it is + // closed before the next session opens the slot. + private static void awaitPersistedWatermark(String slotDir, long expected) { long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10); long last = AckWatermark.INVALID; try (AckWatermark probe = AckWatermark.open(slotDir)) { @@ -1133,7 +1130,7 @@ private static void awaitManagerPersistedWatermark(String slotDir, long expected LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(2)); } } - fail("manager did not persist watermark=" + expected + fail("watermark=" + expected + " was not persisted" + " within 10s (last on-disk read=" + last + ")"); } diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerAckWatermarkLagTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerAckWatermarkLagTest.java index 9b8187d09..00553d358 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerAckWatermarkLagTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerAckWatermarkLagTest.java @@ -40,6 +40,7 @@ import org.junit.Test; import java.nio.file.Paths; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicLong; /** @@ -125,7 +126,17 @@ public void testStreamingAcksInsideActiveSegmentNeverWakeWorker() throws Excepti for (int i = 0; i < acks; i++) { Assert.assertTrue(ring.appendOrFsn(payload, 32) >= 0); } - Thread.sleep(50); // let the register wake settle + // Let the register wake's pass finish (it installs the spare) and + // the worker park before counting, so a slow spare create cannot + // be mistaken for an ACK-driven pass. + Thread worker = manager.getWorkerThreadForTesting(); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10); + while ((ring.needsHotSpare() || worker.getState() != Thread.State.WAITING) + && System.nanoTime() < deadline) { + Thread.sleep(1); + } + Assert.assertFalse(ring.needsHotSpare()); + Assert.assertEquals(Thread.State.WAITING, worker.getState()); passes.set(0); for (int seq = 0; seq < acks; seq++) { ring.acknowledge(seq); diff --git a/design/wake-protocol.md b/design/wake-protocol.md index ca2b70746..52a493abe 100644 --- a/design/wake-protocol.md +++ b/design/wake-protocol.md @@ -67,10 +67,14 @@ syscall), and the worker only `sync()`s it before unlinking a trimmed segment. That removed the row that took four commits (39ac01e9, 779a99bc, 1304f71d, 3362671f): `SegmentRing.persistedAckFsn`, `RingEntry.ackWakeArmed`, `RingEntry.lastPersistedAck`, `WATERMARK_LAG_NANOS` and the Dekker between -them. A `write` racing the worker's msync can leave one torn record on disk; -the record fails its CRC and recovery takes the other slot, then the -`max(lowestBase - 1, watermark)` clamp — the watermark was always a -discardable optimization under that clamp. +them. A `write` racing the worker's msync can tear one or both on-disk +records; a torn record fails its CRC and recovery takes the other slot or +the segment-derived seed, then the `max(lowestBase - 1, watermark)` clamp. +The trim itself is protected by the fsync'd manifest head, not the watermark. +A watermark that is too *high* (host crash after a trim msync with the data +pages still dirty) is rejected at recovery and must be overwritten there +(c86689f2): nothing else writes it until the next ACK, which during an outage +is never. ## Consumer C — ack waiters From 6e4f4ca445952523743b2e6f336e68e1488c31f1 Mon Sep 17 00:00:00 2001 From: Jaromir Hamala Date: Fri, 23 Oct 2026 17:59:28 +0200 Subject: [PATCH 33/36] refactor(qwp): create the next segment on the producer thread at rotation 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 f37967c4), 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. --- .../client/sf/cursor/CursorSendEngine.java | 4 +- .../qwp/client/sf/cursor/MmapSegment.java | 7 +- .../qwp/client/sf/cursor/SegmentBudget.java | 66 +-- .../qwp/client/sf/cursor/SegmentManager.java | 447 ++---------------- .../client/sf/cursor/SegmentProvisioner.java | 186 ++++++++ .../qwp/client/sf/cursor/SegmentRing.java | 282 +++++------ .../qwp/client/sf/cursor/SegmentSource.java | 54 +++ .../qwp/client/SegmentSkipQuarantineTest.java | 5 +- .../client/SlotLockReleasedContractTest.java | 4 +- .../qwp/client/SymbolDictRecycleTest.java | 3 +- ...CursorSendEngineSlotReacquisitionTest.java | 67 +-- .../sf/cursor/CursorSendEngineTest.java | 6 +- .../client/sf/cursor/QueueSegmentSource.java | 83 ++++ .../SegmentManagerAckWatermarkLagTest.java | 9 +- .../cursor/SegmentManagerCloseRaceTest.java | 278 +---------- .../SegmentManagerCrashConsistencyTest.java | 4 +- .../SegmentManagerPeriodicSyncTest.java | 10 +- .../cursor/SegmentManagerPinnedTrimTest.java | 2 +- .../SegmentManagerProvisionRetryTest.java | 176 ------- .../SegmentManagerSharedBudgetTest.java | 35 +- .../cursor/SegmentManagerSideFileCapTest.java | 107 +++-- .../client/sf/cursor/SegmentManagerTest.java | 215 ++------- .../SegmentManagerTotalBytesRaceTest.java | 225 --------- .../SegmentManagerTrimDeregisterRaceTest.java | 12 - .../SegmentManagerUnlinkFailureTest.java | 9 +- ...entManagerWatermarkDeregisterRaceTest.java | 191 -------- .../sf/cursor/SegmentProvisionerTest.java | 196 ++++++++ .../cursor/SegmentRingAckOffMonitorTest.java | 2 +- .../qwp/client/sf/cursor/SegmentRingTest.java | 157 +----- .../SymbolDictRecycleCrashWindowsTest.java | 27 +- .../client/test/impl/SenderPoolSfTest.java | 4 +- .../test/impl/SenderPoolSharedBudgetTest.java | 13 +- design/wake-protocol.md | 34 +- 33 files changed, 921 insertions(+), 1999 deletions(-) create mode 100644 core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentProvisioner.java create mode 100644 core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentSource.java create mode 100644 core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/QueueSegmentSource.java delete mode 100644 core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerProvisionRetryTest.java delete mode 100644 core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerTotalBytesRaceTest.java delete mode 100644 core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerWatermarkDeregisterRaceTest.java create mode 100644 core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentProvisionerTest.java diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorSendEngine.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorSendEngine.java index d876d6882..7f7c66b8f 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorSendEngine.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorSendEngine.java @@ -984,8 +984,8 @@ public long appendBlocking(long payloadAddr, int payloadLen) { } /** - * User-thread append path. Spins briefly while waiting for the segment - * manager to provision a hot spare; if backpressure persists past + * User-thread append path. Spins briefly while backpressured (budget + * exhausted, periodic-sync barrier pending); if backpressure persists past * {@code spinDeadlineNanos}, returns {@link SegmentRing#BACKPRESSURE_NO_SPARE} * so the caller can decide whether to {@code parkNanos} or surface the * pressure to the user. diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/MmapSegment.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/MmapSegment.java index fe0795977..0da666e0e 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/MmapSegment.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/MmapSegment.java @@ -88,10 +88,9 @@ public final class MmapSegment implements QuietCloseable { // appendCursor: written only by the producer thread, never read by anyone else // — it's the reservation cursor. Plain field is fine. private long appendCursor; - // baseSeq: provisional at create time, finalized by rebaseSeq() at rotation - // time. Mutable to support the cursor engine's hot-spare design — the - // segment manager pre-creates spares before the producer knows the exact - // baseSeq the new active will need. + // baseSeq: final when the producer creates the segment at rotation; + // rebaseSeq() exists for a segment created ahead of its rotation (tests, + // and files an older client left behind) whose base was a guess. private long baseSeq; // Highest published byte offset covered by a successful data barrier. // The manager writes this after msync+fsync; the producer reads it before diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentBudget.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentBudget.java index 9cc4e2363..f188d1be4 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentBudget.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentBudget.java @@ -30,9 +30,8 @@ /** * The {@code sf_max_total_bytes} budget: an upper bound on the bytes the - * cursor rings charged to it may hold -- every segment a ring owns (active, - * sealed and hot spare), in memory or on disk, plus each slot's live - * side-file bytes. + * cursor rings charged to it may hold -- every segment a ring owns (active + * and sealed), in memory or on disk, plus each slot's live side-file bytes. *

    * A {@code Sender} built on its own charges a private budget. A sender pool * hands one budget to every sender it builds, so {@code sf_max_total_bytes} @@ -40,27 +39,22 @@ * memory. Each {@link SegmentManager} keeps its own worker thread and charges * the budget as it provisions and trims segments. *

    - * The cap is enforced when a manager provisions a hot spare; bytes a ring - * already owns when it registers (a recovered slot) are charged even when - * they exceed it. A ring below its minimum working set -- the active segment - * plus one spare -- is provisioned past the cap, so a budget shared by - * {@code n} rings can be exceeded by up to {@code n} such working sets. - *

    - * A manager refused a spare is woken when room appears, so a ring starved by - * the segments of other rings resumes as soon as one of them trims or leaves. + * The cap is enforced when a ring's {@link SegmentProvisioner} creates the + * segment a rotation needs; bytes a ring already owns when it registers (a + * recovered slot) are charged even when they exceed it. A ring below its + * minimum working set -- the active segment plus the one it rotates into -- + * is charged past the cap, so a budget shared by {@code n} rings can be + * exceeded by up to {@code n} segments. A refused producer retries on its + * backpressure poll; room appears when a manager trims. *

    * Thread-safe. All state is guarded by this object's monitor, a leaf lock: - * managers call in while holding their own lock, and the only call back into - * a manager is a refused manager's wake, which takes no lock. Side-file gauges - * are read under the monitor, so they must be wait-free and must not throw. + * managers and provisioners call in while holding their own lock and nothing + * calls back out. Side-file gauges are read under the monitor, so they must be + * wait-free and must not throw. */ public final class SegmentBudget { private final long capacityBytes; - // Wakes of managers refused by tryCharge since room last appeared. Run and - // cleared by every call that frees room; refusal and registration are one - // step under the monitor, so a release cannot slip between them. - private final ObjList refusedWakeups = new ObjList<>(); // Live gauges of each charged slot's .symbol-dict side-file bytes. Read on // every cap check rather than folded into segmentBytes: a dictionary // grows out-of-band on producer threads, so an incremental mirror would @@ -104,30 +98,14 @@ synchronized void addSideFileGauge(LongSupplier gauge) { /** * Charges {@code bytes} unconditionally: segments a ring already owns when - * it registers, and spares provisioned under the liveness floor. + * it registers, and segments created under the liveness floor. */ synchronized void charge(long bytes) { segmentBytes += bytes; } - /** - * The liveness-floor charge: {@code bytes} were just refused by - * {@link #tryCharge}, which registered {@code onRoom}, and the manager - * provisions anyway. Withdraw the registration: it no longer waits for - * room, and a release of its own (a failed create) must not wake it, or - * the worker spins through its retry timer. - */ - synchronized void chargeBelowFloor(long bytes, Runnable onRoom) { - segmentBytes += bytes; - int i = refusedWakeups.indexOf(onRoom); - if (i >= 0) { - refusedWakeups.remove(i); - } - } - synchronized void release(long bytes) { segmentBytes -= bytes; - wakeRefused(); } // Identity, not equals(): each registration hands in its own gauge instance. @@ -135,7 +113,6 @@ synchronized void removeSideFileGauge(LongSupplier gauge) { for (int i = 0, n = sideFileGauges.size(); i < n; i++) { if (sideFileGauges.getQuick(i) == gauge) { sideFileGauges.remove(i); - wakeRefused(); return; } } @@ -144,15 +121,11 @@ synchronized void removeSideFileGauge(LongSupplier gauge) { /** * Charges {@code bytes} if they fit under the capacity together with * everything already charged and the live side-file bytes. Check and charge - * are one atomic step, so managers sharing the budget can never both take - * its last free segment. On refusal, {@code onRoom} runs once the next time - * room appears (a release or a removed gauge); it must not block or throw. + * are one atomic step, so rings sharing the budget can never both take its + * last free segment. */ - synchronized boolean tryCharge(long bytes, Runnable onRoom) { + synchronized boolean tryCharge(long bytes) { if (segmentBytes + sideFileBytes() + bytes > capacityBytes) { - if (refusedWakeups.indexOf(onRoom) < 0) { - refusedWakeups.add(onRoom); - } return false; } segmentBytes += bytes; @@ -166,11 +139,4 @@ private long sideFileBytes() { } return total; } - - private void wakeRefused() { - for (int i = 0, n = refusedWakeups.size(); i < n; i++) { - refusedWakeups.getQuick(i).run(); - } - refusedWakeups.clear(); - } } diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentManager.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentManager.java index 4eb3190f4..dc75991f9 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentManager.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentManager.java @@ -26,28 +26,25 @@ import io.questdb.client.std.Files; import io.questdb.client.std.FilesFacade; -import io.questdb.client.std.Numbers; import io.questdb.client.std.ObjList; import io.questdb.client.std.QuietCloseable; -import io.questdb.client.std.str.DirectUtf8Sink; import org.jetbrains.annotations.TestOnly; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.Arrays; import java.util.concurrent.atomic.AtomicIntegerFieldUpdater; -import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicReferenceFieldUpdater; import java.util.concurrent.locks.LockSupport; import java.util.function.LongSupplier; /** - * Background worker that keeps every registered {@link SegmentRing} supplied - * with a hot-spare segment and trims segments after their frames have been - * ACK'd by the server. Off the user-thread / I/O-thread hot path entirely: - * the expensive {@code openCleanRW + allocate + mmap} for spare creation and - * {@code munmap + unlink} for trim happen on this thread, never on the - * latency-sensitive paths. + * Background worker that trims every registered {@link SegmentRing}'s + * segments after their frames have been ACK'd by the server, and runs the + * disk barriers (periodic sync, the watermark sync before an unlink). The + * {@code munmap + unlink + fsync} of a trim happen on this thread, never on + * the user-thread / I/O-thread paths. The ring's next segment is created by + * the producer itself at rotation ({@link SegmentProvisioner}). *

    * One instance can serve many rings (typically all {@code Sender} instances * in a JVM). Polls each ring on a configurable tick (default 1 ms) — short @@ -56,14 +53,6 @@ */ public final class SegmentManager implements QuietCloseable { - public static final long DISK_FULL_LOG_THROTTLE_NANOS = 30_000_000_000L; // 30 s - /** - * Retry cadence after a failed hot-spare create (disk full, I/O error). - * Nothing announces that the disk recovered, so the retry needs its own - * timer. A budget refusal needs none: the budget wakes the worker when - * room appears. - */ - public static final long PROVISION_RETRY_NANOS = 10_000_000L; // 10 ms public static final long UNLIMITED_TOTAL_BYTES = Long.MAX_VALUE; private static final int ENTRY_DEREGISTERED = 3; private static final int ENTRY_DEREGISTERED_IN_SERVICE = 2; @@ -75,11 +64,6 @@ public final class SegmentManager implements QuietCloseable { AtomicIntegerFieldUpdater.newUpdater(RingEntry.class, "state"); private static final Logger LOG = LoggerFactory.getLogger(SegmentManager.class); private static final int MAX_TRIMS_PER_RING_PASS = 64; - // The minimum working set a ring needs to keep making progress: the active - // segment plus one hot spare to rotate into. Below this a producer cannot - // advance at all, so the cap check must never hold a ring here (see - // livenessFloorBytes). - private static final int MIN_LIVE_SEGMENTS = 2; private static final long TRIM_RETRY_INITIAL_NANOS = 4_000_000L; private static final long TRIM_RETRY_MAX_NANOS = 1_024_000_000L; private static final int TRIM_RETRY_NONE = 0; @@ -93,33 +77,8 @@ public final class SegmentManager implements QuietCloseable { // budget to several managers -- a sender pool does, so sf_max_total_bytes // caps all of its senders together. private final SegmentBudget budget; - private final AtomicLong fileGeneration = new AtomicLong(); private final FilesFacade filesFacade; - // Per-ring segment bytes below which the cap check never refuses to - // provision: MIN_LIVE_SEGMENTS * segmentSizeBytes, clamped against - // overflow. Segment bytes are reclaimable by ACK-driven trim, so refusing - // on them is productive backpressure that clears itself. Side-file bytes - // are NOT -- the .symbol-dict is lifetime-monotonic and nothing shrinks it - // -- so once they alone pushed a ring under the cap, no ack could ever - // free the shortfall and the producer stalled permanently, across - // restarts, with the disk-full warning pointing at a trim that cannot - // help. Guaranteeing the minimum working set turns that deadlock into - // ordinary backpressure: the ring cycles between one and two segments as - // acks arrive, ingestion continues, and the cap degrades to best-effort by - // exactly the dictionary's overshoot rather than stopping the pipeline. - // A budget shared with other managers needs the floor for the same reason: - // a sibling ring whose connection is down can hold the whole budget, and - // only the floor keeps this ring cycling through its own two segments. - private final long livenessFloorBytes; private final Object lock = new Object(); - // Reused by the manager worker thread to build spare-segment paths - // directly into native memory. Each rotation writes the path bytes plus - // a trailing NUL terminator into the same buffer, and passes the - // pointer to the long-ptr Files / MmapSegment overloads -- eliminating - // the byte[] + native malloc pair that Files.pathPtr(String) would - // otherwise allocate per call. Sized for typical SF directory paths; - // grows on demand if a longer path is registered. Closed in close(). - private final DirectUtf8Sink pathScratch = new DirectUtf8Sink(256); // Reused by the worker thread each tick to snapshot `rings` under the // lock without per-tick allocation. Owned exclusively by workerLoop(). private final ObjList ringSnapshot = new ObjList<>(); @@ -133,7 +92,7 @@ public final class SegmentManager implements QuietCloseable { // prefix here until the post-unlink directory barrier succeeds avoids both // per-pass allocation and publishing logical removal before it is durable. private final MmapSegment[] trimBatch = new MmapSegment[MAX_TRIMS_PER_RING_PASS]; - // Handed to each ring (spare needed, ACK wake) and to the budget (room after a refusal). + // Handed to each ring: rotation and pin release may make a segment trimmable. private final Runnable workerWakeup = this::wakeWorker; // Test seam: runs after a deferred ring-pass cleanup returns. Null in // production; public sender/pool lifecycle tests use it to observe exact @@ -146,13 +105,6 @@ public final class SegmentManager implements QuietCloseable { // live. Callers must treat such a throw as "worker state unknown", // never as the exact false return meaning the worker loop has exited. private volatile Runnable beforeExitCleanupRegistrationHook; - // Test seam: runs on the worker thread just before the install path's - // synchronized(lock) entry (the one that performs installHotSpare and - // keeps the spare's budget charge). Null in production; tests use it to - // pause after the worker has snapshotted a RingEntry and created a spare, - // but before ownership/accounting commit. Callers may inject a deregister - // or hold this stale worker snapshot while caller-side cleanup runs. - private volatile Runnable beforeInstallSyncHook; // Test seam: records entry into the per-ring quiescence wait. Null in // production; owned-engine close tests use it to prove they take only the // stronger whole-manager join path, not two sequential timeout budgets. @@ -186,23 +138,8 @@ public final class SegmentManager implements QuietCloseable { // timed-out-close handoff never allocates an ObjList or grows a backing // array. Guarded by lock and consumed on worker-loop exit outside lock. private Runnable ownedEngineExitCleanup; - private long lastDiskFullLogNs; - // Worker-local: when a hot-spare create fails (disk full, I/O error) the - // worker schedules its own retry here instead of relying on a tick. Reset - // every pass; Long.MAX_VALUE = nothing to retry. - private long provisionRetryDeadlineNanos = Long.MAX_VALUE; private volatile boolean running; - // pathScratch free-exactly-once coordination between a timed-out close() - // and the worker's exit path. All three are guarded by {@link #lock}. - // When close() gives up on the join while the worker loop has not yet - // exited, it hands scratch ownership to the worker - // (scratchHandedToWorker=true) and the worker frees the buffer in its - // exit block; in every other case close() frees it. Without the handoff, - // a worker that outlives the bounded join leaks the native scratch - // buffer forever, because nobody retries manager cleanup after close() - // returns. - private boolean scratchFreed; - private boolean scratchHandedToWorker; + // Guarded by lock: set by the worker when it leaves its service loop. private boolean workerLoopExited; // volatile: read by awaitRingQuiescence() from arbitrary caller threads // while the @TestOnly setter may run on another. @@ -223,19 +160,17 @@ public SegmentManager(long segmentSizeBytes) { * sf_max_total_bytes caps all of its senders * together). Its capacity is the upper bound on total * bytes across all registered rings — counts every segment - * the ring owns (initial active + sealed + hot - * spare), including bytes already on disk at - * register-time (e.g. after recovery or orphan - * adoption). When provisioning a hot spare would - * exceed this, the manager skips the install — the - * requesting ring stays in the + * the ring owns (active + sealed), including bytes + * already on disk at register-time (e.g. after + * recovery or orphan adoption). When a rotation's + * segment would exceed this, the producer stays in the * {@link SegmentRing#BACKPRESSURE_NO_SPARE} state * until ACK-driven trim frees space. Pass * {@link #UNLIMITED_TOTAL_BYTES} to disable. Must be * at least one {@code segmentSizeBytes}; a sensible * lower bound for a single ring is - * {@code 2 × segmentSizeBytes} so the manager can - * hold an initial active plus one hot spare. + * {@code 2 × segmentSizeBytes}, the active segment + * plus the one it rotates into. */ public SegmentManager(long segmentSizeBytes, SegmentBudget budget) { this(segmentSizeBytes, budget, FilesFacade.INSTANCE, System::nanoTime); @@ -281,20 +216,13 @@ public SegmentManager( FilesFacade filesFacade, LongSupplier ticks ) { - // The pathScratch field initializer has already allocated its native - // buffer by the time this body runs, so a validation throw must free - // it or every failed construction leaks 256 bytes of native memory - // (e.g. a drainer retry loop hitting the same bad config). if (segmentSizeBytes < MmapSegment.HEADER_SIZE + MmapSegment.FRAME_HEADER_SIZE + 1) { - pathScratch.close(); throw new IllegalArgumentException("segmentSizeBytes too small: " + segmentSizeBytes); } if (budget == null) { - pathScratch.close(); throw new IllegalArgumentException("budget must not be null"); } if (budget.getCapacityBytes() < segmentSizeBytes) { - pathScratch.close(); throw new IllegalArgumentException( "maxTotalBytes (" + budget.getCapacityBytes() + ") must allow at least one segment of " + segmentSizeBytes + " bytes"); @@ -302,13 +230,6 @@ public SegmentManager( this.budget = budget; this.filesFacade = filesFacade; this.segmentSizeBytes = segmentSizeBytes; - // Clamp rather than multiply blind: segmentSizeBytes is user-supplied - // and only bounded below, so a pathological value would wrap the - // product negative and make the floor test trivially false -- silently - // restoring the deadlock this field exists to remove. - this.livenessFloorBytes = segmentSizeBytes > Long.MAX_VALUE / MIN_LIVE_SEGMENTS - ? Long.MAX_VALUE - : segmentSizeBytes * MIN_LIVE_SEGMENTS; this.ticks = ticks; } @@ -348,15 +269,10 @@ public synchronized void close() { if (t.isAlive()) { synchronized (lock) { if (!workerLoopExited) { - // Hand pathScratch ownership to the worker: its exit - // block frees the buffer under the same lock, so the - // native allocation is reclaimed even though this - // close() could not confirm termination. workerThread - // stays set so isWorkerReaped() reports the incomplete - // shutdown and a later close() can retry the join. - scratchHandedToWorker = true; - LOG.warn("SegmentManager worker did not stop before close wait completed; " - + "worker frees its native scratch buffer on exit"); + // workerThread stays set so isWorkerReaped() reports + // the incomplete shutdown and a later close() can + // retry the join. + LOG.warn("SegmentManager worker did not stop before close wait completed"); return; } } @@ -407,17 +323,6 @@ public synchronized void close() { } workerThread = null; } - // Free the rotation-path native scratch buffer only after worker - // termination (or worker-loop exit) has been observed. The worker is - // the only thread that touches the buffer; the scratchFreed flag - // (shared with the worker's exit block) makes the free exactly-once - // no matter which side runs last. - synchronized (lock) { - if (!scratchFreed) { - scratchFreed = true; - pathScratch.close(); - } - } } /** @@ -594,10 +499,7 @@ public synchronized boolean isWorkerReaped() { } /** - * Stop tracking {@code ring}. Pending spares for the ring are NOT - * created after this returns, but already-installed spares stay with - * the ring (the ring closes them on its own {@link SegmentRing#close}). - * Idempotent; safe to call from any thread. + * Stop tracking {@code ring}. Idempotent; safe to call from any thread. *

    * Non-blocking: a worker service pass already in flight for this ring * may still be running when this returns. Callers about to release @@ -611,13 +513,10 @@ public void deregister(SegmentRing ring) { for (int i = 0, n = rings.size(); i < n; i++) { RingEntry e = rings.get(i); if (e.ring == ring) { - // Reverse the ring's charge to the budget — - // mirrors the seed in register(). Any spares the - // manager provisioned during the ring's lifetime - // are also part of totalSegmentBytes() now, so a - // single subtraction covers both the initial seed - // and the net manager activity (provisions minus - // trims) for this ring. + // Reverse the ring's charge to the budget: the seed from + // register() plus every segment the producer created + // since, minus the trims -- all of it is + // totalSegmentBytes() now. e.deregister(); budget.release(ring.totalSegmentBytes()); if (e.sideFileBytes != null) { @@ -631,15 +530,15 @@ public void deregister(SegmentRing ring) { } /** - * Register a ring for ongoing spare-creation + trim. {@code dir} is the - * filesystem directory the ring's segments live in — used by the manager - * both for creating spare files and unlinking trimmed ones. The ring - * MUST already have its initial active segment in place. + * Register a ring for trim and disk barriers, and give it a + * {@link SegmentProvisioner} that creates its segments in {@code dir} + * ({@code null} = memory mode) on the producer thread, charged to this + * manager's budget. The ring MUST already have its initial active segment + * in place. *

    - * Also wires the ring's "I need a spare" wakeup callback to - * {@link #wakeWorker()}, so the producer thread wakes the parked worker - * the moment a rotation consumes the spare or the active crosses the - * high-water mark. + * Also wires the ring's wakeup callback to {@link #wakeWorker()}, so a + * rotation or a pin release that makes a segment trimmable wakes the + * parked worker. */ public void register(SegmentRing ring, String dir) { register(ring, dir, null, 0L); @@ -692,21 +591,21 @@ public void register(SegmentRing ring, String dir, AckWatermark watermark, long // at serviceRing would let the manager keep provisioning new spares on // top of the recovered set, effectively doubling the documented cap. long ringBytes = ring.totalSegmentBytes(); - // Skip the file-generation counter past whatever's already on disk in - // this slot. Without this, on recovery the manager would mint a new - // spare at sf-0000000000000000.sfa — and openCleanRW would truncate the - // user's existing active file out from under the I/O loop, scrambling - // the in-flight mmap. Memory-mode rings have no dir; nothing to scan. - long minNextGeneration = dir == null ? -1L : scanMaxGeneration(dir) + 1L; + // Start the file-generation counter past whatever's already on disk in + // this slot. Without this, on recovery the first rotation would mint + // sf-0000000000000000.sfa — and openCleanRW would truncate the user's + // existing active file out from under the I/O loop, scrambling the + // in-flight mmap. Memory-mode rings have no dir; nothing to scan. + if (!ring.hasSegmentSource()) { + long firstGeneration = dir == null ? 0L : scanMaxGeneration(dir) + 1L; + ring.setSegmentSource(new SegmentProvisioner(ring, dir, firstGeneration, segmentSizeBytes, budget, filesFacade)); + } RingEntry e = new RingEntry(ring, dir, watermark, sideFileBytes, syncIntervalNanos, ticks.getAsLong()); // ObjList.add either throws before storing e or makes the entry visible. // Once visible, only non-throwing state commits may remain, so the gauge // (whose list can also throw on growth) is added first and withdrawn if // the entry cannot be published. synchronized (lock) { - if (dir != null) { - advanceFileGeneration(minNextGeneration); - } if (sideFileBytes != null) { budget.addSideFileGauge(sideFileBytes); } @@ -725,15 +624,10 @@ public void register(SegmentRing ring, String dir, AckWatermark watermark, long } ring.setManagerWakeup(workerWakeup); // Nudge the worker so it picks up the new ring on its very next - // iteration. Without this, register-after-start has a race window: - // start() schedules the worker thread, and if that thread reaches - // workerLoop and takes `lock` before this method does, it observes - // an empty `rings` snapshot, services nothing, then parkNanos - // (potentially seconds). A new ring whose first append does not - // cross the high-water mark fires no producer-side wakeup either, - // leaving the ring without a spare until some later event. - // wakeWorker is cheap (a single LockSupport.unpark) and a no-op - // when the worker has not been started yet. + // iteration: a recovered ring may already hold trimmable segments, + // and the first periodic-sync pass runs immediately. wakeWorker is + // cheap (a single LockSupport.unpark) and a no-op when the worker has + // not been started yet. wakeWorker(); } @@ -758,13 +652,6 @@ public Thread getWorkerThreadForTesting() { return workerThread; } - @TestOnly - public boolean isPathScratchAllocatedForTesting() { - synchronized (lock) { - return !scratchFreed; - } - } - @TestOnly public boolean serviceRingForTesting(SegmentRing ring) { if (workerThread != null) { @@ -796,11 +683,6 @@ public void setBeforeExitCleanupRegistrationHook(Runnable hook) { this.beforeExitCleanupRegistrationHook = hook; } - @TestOnly - public void setBeforeInstallSyncHook(Runnable hook) { - this.beforeInstallSyncHook = hook; - } - @TestOnly public void setBeforeRingQuiescenceAwaitHook(Runnable hook) { this.beforeRingQuiescenceAwaitHook = hook; @@ -884,43 +766,6 @@ private long scanMaxGeneration(String dir) { return max; } - private void advanceFileGeneration(long minNext) { - while (true) { - long cur = fileGeneration.get(); - if (cur >= minNext) break; - if (fileGeneration.compareAndSet(cur, minNext)) break; - } - } - - /** - * Spare files are named with a JVM-wide monotonic generation counter - * rather than a baseSeq-derived name, because the spare's baseSeq is - * provisional at create time (SegmentRing.appendOrFsn rebases it at - * rotation). Pattern: {@code

    /sf-.sfa}. Recovery - * discovers segments by extension + header magic, not by filename. - *

    - * Builds the path bytes directly into {@link #pathScratch} and writes - * a trailing NUL so the same buffer can be handed to the long-ptr - * {@link FilesFacade} overloads (no per-rotation byte[] + native - * malloc). The String returned is the same path without the NUL -- - * captured before terminating so it is suitable for {@link MmapSegment#path()} - * and exception messages. - */ - private String nextSparePath(String dir) { - pathScratch.clear(); - pathScratch.putAscii(dir).putAscii("/sf-"); - Numbers.appendHex(pathScratch, fileGeneration.getAndIncrement(), true); - pathScratch.putAscii(".sfa"); - String displayPath = pathScratch.toString(); - // Trailing NUL must be appended AFTER toString() captures the path - // text -- DirectUtf8Sink.toString reads the full sink contents and - // would otherwise include the terminator in the result. Use putAny - // so the assertion in DirectUtf8Sink.put(byte) does not trip on a - // non-negative byte. - pathScratch.putAny((byte) 0); - return displayPath; - } - private boolean serviceRing(RingEntry e) { // Publish before the CAS so deregister + await can always find the // entry. The state CAS is the ownership decision: if deregister won, @@ -965,192 +810,16 @@ private boolean serviceRing(RingEntry e) { return hasMoreTrimmable; } - private void scheduleProvisionRetry() { - long at = ticks.getAsLong() + PROVISION_RETRY_NANOS; - if (at < provisionRetryDeadlineNanos) { - provisionRetryDeadlineNanos = at; - } - } - private boolean serviceRing0(RingEntry e) { boolean memoryMode = e.dir == null; if (!memoryMode) { servicePeriodicSync(e, ticks.getAsLong()); } - - // 1. Provision a hot spare if the ring needs one AND we have headroom - // under the disk-total cap. Cap check is per attempt; if we're - // capped here, the ring stays in BACKPRESSURE_NO_SPARE until trim - // (step 2) frees space, which is retried below in this same pass. - // Room freed elsewhere (a sibling ring or manager, a deregister) - // wakes this worker through the budget. Logged at most once per - // DISK_FULL_LOG_THROTTLE_NANOS so a sustained-disk-full state - // doesn't drown the log. - boolean capped = e.ring.needsHotSpare() && provisionSpare(e, memoryMode); - boolean hasMoreTrimmable = trimAcked(e, memoryMode); - // The trim may have just released the bytes the spare was refused - // for: retry once in this pass rather than waiting for the wake. - if (capped) { - provisionSpare(e, memoryMode); - } - return hasMoreTrimmable; - } - - /** - * Provisions a hot spare for a ring that needs one. Returns {@code true} - * only when the budget refused the charge (nothing provisioned, nothing - * charged); a failed create returns {@code false} after arming the retry - * timer itself. - */ - private boolean provisionSpare(RingEntry e, boolean memoryMode) { - // Charge the spare BEFORE provisioning it, as one atomic check-and- - // charge against everything else on the budget, live side-file bytes - // included. A snapshot-then-commit check would let two managers that - // share the budget both see room for its last segment and both take - // it. Installing the spare keeps the charge; every other outcome - // below releases it. - boolean withinCap = budget.tryCharge(segmentSizeBytes, workerWakeup); - // Liveness floor. Refusing on segment bytes is productive -- an ack - // trims a sealed segment and the shortfall clears. Refusing on - // side-file bytes is not: the dictionary never shrinks, so a ring - // held below its minimum working set by them would never rotate - // again, on this run or any later one. Nor is refusing on the - // segments of OTHER rings that share the budget: their acks may - // never come (a sibling whose connection is down). Provision anyway - // while this ring is under the floor, and charge it honestly. - long ringSegmentBytes = withinCap ? 0L : e.ring.totalSegmentBytes(); - boolean belowLivenessFloor = !withinCap && ringSegmentBytes < livenessFloorBytes; - if (belowLivenessFloor) { - budget.chargeBelowFloor(segmentSizeBytes, workerWakeup); - } - if (!withinCap && !belowLivenessFloor) { - long now = System.nanoTime(); - if (now - lastDiskFullLogNs >= DISK_FULL_LOG_THROTTLE_NANOS) { - long sideFileBytes = budget.getSideFileBytes(); - LOG.warn("SF {}: cannot provision spare in {} " - + "(totalBytes={}, sideFileBytes={}, cap={}, segmentSize={}). " - + "Producer is backpressured until ACK-driven trim frees segment " - + "space; side-file bytes are not reclaimed by trim.", - memoryMode ? "memory cap reached" : "disk-full", - memoryMode ? "" : e.dir, budget.getSegmentBytes() + sideFileBytes, - sideFileBytes, budget.getCapacityBytes(), segmentSizeBytes); - lastDiskFullLogNs = now; - } - return true; - } else { - if (belowLivenessFloor) { - // Exceeding a configured cap is worth saying out loud, and - // saying WHY: the operator's remedy is to raise - // sf_max_total_bytes, or shrink the symbol dictionary or the - // number of senders sharing the budget, never to wait for a - // trim. - long now = System.nanoTime(); - if (now - lastDiskFullLogNs >= DISK_FULL_LOG_THROTTLE_NANOS) { - long sideFileBytes = budget.getSideFileBytes(); - LOG.warn("SF {}: provisioning past sf_max_total_bytes to keep the slot " - + "usable (totalBytes={}, sideFileBytes={}, cap={}, " - + "segmentSize={}, ringSegmentBytes={}, minWorkingSet={}). " - + "The rest of the budget -- symbol dictionaries, which trim " - + "cannot reclaim, and the segments of other senders sharing " - + "it -- leaves no room for this slot's active segment plus " - + "one spare; raise sf_max_total_bytes, or reduce symbol " - + "cardinality or the number of pooled senders.", - memoryMode ? "" : e.dir, budget.getSegmentBytes() + sideFileBytes, - sideFileBytes, budget.getCapacityBytes(), segmentSizeBytes, - ringSegmentBytes, livenessFloorBytes); - lastDiskFullLogNs = now; - } - } - MmapSegment spare = null; - String path = null; - boolean installed = false; - try { - // Null if the ring closed between this tick's snapshot and now: - // close() nulls it under the ring's own monitor with no - // coordination with the manager thread. A closed ring rejects - // installHotSpare regardless, so there is nothing to provision - // this tick; skip cleanly rather than provision a spare that will - // be abandoned. - MmapSegment active = e.ring.getActive(); - if (active != null) { - // baseSeq is provisional -- SegmentRing.appendOrFsn calls - // rebaseSeq() at rotation time to pin the real value. We - // pass the manager's best guess (nextSeqHint at this - // instant), which is fine since it's overwritten anyway. - if (memoryMode) { - spare = MmapSegment.createInMemory(e.ring.nextSeqHint(), segmentSizeBytes); - } else { - path = nextSparePath(e.dir); - // Native path bytes (NUL-terminated) live in pathScratch - // from the call above. Hand them straight to MmapSegment.create - // via its long-ptr overload, bypassing the byte[] + native - // malloc that the String overload would incur on every - // rotation. - spare = MmapSegment.create(filesFacade, - pathScratch.ptr(), path, - e.ring.nextSeqHint(), segmentSizeBytes, true); - } - Runnable installHook = beforeInstallSyncHook; - if (installHook != null) { - installHook.run(); - } - if (!memoryMode) { - spare.syncHeader(); - if (filesFacade.fsyncDir(e.dir) != 0) { - throw new MmapSegmentException( - "could not sync hot-spare directory " + e.dir); - } - } - // Install under the manager lock, gated on the entry - // still being registered. If `e.ring` was deregistered - // since the charge above, abandoning the spare here is - // the only way to keep the budget consistent: - // deregister already released ring.totalSegmentBytes() - // (without the spare, since it wasn't installed yet), so - // installing now would keep the spare's charge with no - // future releaser. By holding `lock` across the - // registration check AND installHotSpare, deregister is - // forced to either observe the spare in the ring (and - // release it) or run before installation (so no install - // happens and the !installed path releases the charge). - synchronized (lock) { - if (e.isRegistered()) { - e.ring.installHotSpare(spare); - installed = true; - } - } - } - } catch (Throwable t) { - LOG.warn("Failed to provision hot spare in {} (will retry next tick)", - memoryMode ? "" : e.dir, t); - } - if (!installed) { - scheduleProvisionRetry(); - budget.release(segmentSizeBytes); - if (spare != null) { - try { - spare.close(); - } catch (Throwable ignored) { - } - } - // Only remove the file when the spare object exists, i.e. - // MmapSegment.create succeeded and ownership is ours but - // installation was rejected (ring deregistered/closed). - // When create() itself threw, its catch already removed - // anything it put on disk -- and with exclusive create - // (O_EXCL) a failure can also mean the path was ALREADY - // occupied by a file some other lifecycle owns, which a - // blanket unlink here would destroy. - if (path != null && spare != null) { - filesFacade.remove(path); - } - } - } - return false; + return trimAcked(e, memoryMode); } private boolean trimAcked(RingEntry e, boolean memoryMode) { - // 2. Trim fully ACKed segments. The ring first transfers one bounded, + // Trim fully ACKed segments. The ring first transfers one bounded, // unpinned prefix out of live traversal and into hidden pending // ownership. Only then may this worker unmap it. Disk mode keeps the // pending prefix until unlink + directory fsync are durable; memory @@ -1413,10 +1082,9 @@ private void workerLoop() { try { while (running) { // Snapshot the rings under the lock so we don't hold it through the - // (potentially slow) syscalls during creation/unlink. ringSnapshot - // is a thread-confined field — no per-tick allocation. + // (potentially slow) syscalls during unlink. ringSnapshot is a + // thread-confined field — no per-tick allocation. ringSnapshot.clear(); - provisionRetryDeadlineNanos = Long.MAX_VALUE; synchronized (lock) { for (int i = 0, n = rings.size(); i < n; i++) { ringSnapshot.add(rings.getQuick(i)); @@ -1430,17 +1098,12 @@ private void workerLoop() { } } // Event-driven idle: every state change the worker services - // wakes it (register/deregister, rotation, high-water publish, - // an ACK that completes a sealed segment, budget room after a - // refused spare). Timed work: periodic sync, a failed spare - // create's retry, a failed trim's retry. Otherwise park - // indefinitely. + // wakes it (register/deregister, rotation, pin release, an ACK + // that completes a sealed segment). Timed work: periodic sync + // and a failed trim's retry. Otherwise park indefinitely. long parkNanos = Long.MAX_VALUE; if (!hasMoreTrimmable) { long now = ticks.getAsLong(); - if (provisionRetryDeadlineNanos != Long.MAX_VALUE) { - parkNanos = Math.max(0L, provisionRetryDeadlineNanos - now); - } for (int i = 0, n = ringSnapshot.size(); i < n; i++) { RingEntry e = ringSnapshot.getQuick(i); if (e.syncIntervalNanos > 0L) { @@ -1465,18 +1128,10 @@ private void workerLoop() { } } } finally { - // If a timed-out close() abandoned the reap, it handed - // pathScratch ownership to this thread (see close()). Freeing it - // here reclaims the native buffer even when the worker outlives - // every close() attempt — nobody else retries manager cleanup. ObjList cleanups; Runnable ownedEngineCleanup; synchronized (lock) { workerLoopExited = true; - if (scratchHandedToWorker && !scratchFreed) { - scratchFreed = true; - pathScratch.close(); - } cleanups = exitCleanups; exitCleanups = null; ownedEngineCleanup = ownedEngineExitCleanup; diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentProvisioner.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentProvisioner.java new file mode 100644 index 000000000..ec8128bd5 --- /dev/null +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentProvisioner.java @@ -0,0 +1,186 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +package io.questdb.client.cutlass.qwp.client.sf.cursor; + +import io.questdb.client.std.FilesFacade; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Creates a ring's next segment on the producer thread, at rotation, charged + * to the {@link SegmentBudget}. One per registered ring; producer-thread only + * after {@link SegmentManager#register}. + *

    + * Disk mode creates {@code

    /sf-.sfa} with its final + * {@code baseSeq} in the header, makes the header and the directory entry + * durable, and only then hands the segment to the ring, whose manifest update + * may name it. Memory mode allocates a heap-backed segment. + *

    + * Budget: the charge is one atomic check-and-charge against everything + * on the budget, side-file bytes included. Refused, the producer is + * backpressured until ACK-driven trim releases segment bytes -- except below + * the liveness floor: a ring holding fewer than {@link #MIN_LIVE_SEGMENTS} + * segments is charged past the cap, because what fills the budget then is + * something no ACK of this ring can shrink (its symbol dictionary, or the + * segments of a sibling sender whose connection is down), and refusing would + * stall the slot for good. The cap degrades by at most one segment per ring. + *

    + * Failure: a failed create (disk full, I/O error) releases the charge + * and backpressures the producer; the next attempt waits + * {@link #CREATE_RETRY_NANOS}, so the producer's 50 µs backpressure poll does + * not turn into a create storm. Logged at most once per + * {@link #LOG_THROTTLE_NANOS}. + */ +public final class SegmentProvisioner implements SegmentSource { + + /** Wait between create attempts after a failed one. */ + public static final long CREATE_RETRY_NANOS = 10_000_000L; // 10 ms + public static final long LOG_THROTTLE_NANOS = 30_000_000_000L; // 30 s + // Active segment plus the one being rotated into: below this a producer + // cannot advance at all. + static final int MIN_LIVE_SEGMENTS = 2; + private static final Logger LOG = LoggerFactory.getLogger(SegmentProvisioner.class); + private final SegmentBudget budget; + // null = memory mode + private final String dir; + private final FilesFacade ff; + private final long floorBytes; + private final SegmentRing ring; + private final long segmentSizeBytes; + private long lastLogNs; + private long nextGeneration; + private long retryAtNanos; + + SegmentProvisioner(SegmentRing ring, String dir, long firstGeneration, long segmentSizeBytes, + SegmentBudget budget, FilesFacade ff) { + this.ring = ring; + this.dir = dir; + this.nextGeneration = firstGeneration; + this.segmentSizeBytes = segmentSizeBytes; + this.budget = budget; + this.ff = ff; + // Clamp: segmentSizeBytes is user-supplied and only bounded below, so + // a pathological value would wrap the product negative and make the + // floor test trivially false. + this.floorBytes = segmentSizeBytes > Long.MAX_VALUE / MIN_LIVE_SEGMENTS + ? Long.MAX_VALUE + : segmentSizeBytes * MIN_LIVE_SEGMENTS; + } + + @Override + public void close() { + // Holds no segments: every one it created belongs to the ring. + } + + @Override + public void discard(MmapSegment segment) { + budget.release(segmentSizeBytes); + String path = dir == null ? null : segment.path(); + try { + segment.close(); + } finally { + if (path != null) { + ff.remove(path); + } + } + } + + @Override + public MmapSegment next(long baseSeq) { + long now = System.nanoTime(); + if (retryAtNanos != 0L && now - retryAtNanos < 0L) { + return null; + } + if (!budget.tryCharge(segmentSizeBytes)) { + if (ring.totalSegmentBytes() >= floorBytes) { + if (now - lastLogNs >= LOG_THROTTLE_NANOS) { + lastLogNs = now; + long sideFileBytes = budget.getSideFileBytes(); + LOG.warn("SF {}: cannot create next segment in {} " + + "(totalBytes={}, sideFileBytes={}, cap={}, segmentSize={}). " + + "Producer is backpressured until ACK-driven trim frees segment " + + "space; side-file bytes are not reclaimed by trim.", + dir == null ? "memory cap reached" : "disk-full", + dir == null ? "" : dir, budget.getSegmentBytes() + sideFileBytes, + sideFileBytes, budget.getCapacityBytes(), segmentSizeBytes); + } + return null; + } + budget.charge(segmentSizeBytes); + if (now - lastLogNs >= LOG_THROTTLE_NANOS) { + lastLogNs = now; + long sideFileBytes = budget.getSideFileBytes(); + LOG.warn("SF {}: creating a segment past sf_max_total_bytes to keep the slot " + + "usable (totalBytes={}, sideFileBytes={}, cap={}, segmentSize={}). " + + "The rest of the budget -- symbol dictionaries, which trim cannot " + + "reclaim, and the segments of other senders sharing it -- leaves no " + + "room for this slot's active segment plus the next one; raise " + + "sf_max_total_bytes, or reduce symbol cardinality or the number of " + + "pooled senders.", + dir == null ? "" : dir, budget.getSegmentBytes() + sideFileBytes, + sideFileBytes, budget.getCapacityBytes(), segmentSizeBytes); + } + } + MmapSegment segment = null; + String path = null; + try { + if (dir == null) { + segment = MmapSegment.createInMemory(baseSeq, segmentSizeBytes); + } else { + // Generation-named, not baseSeq-named: recovery discovers + // segments by extension and header magic, and a name that + // never repeats within the directory is all unlink needs. + path = dir + "/sf-" + String.format("%016x", nextGeneration++) + ".sfa"; + segment = MmapSegment.create(ff, path, baseSeq, segmentSizeBytes, true); + // Header and directory entry durable before the ring's + // manifest can name this segment. + segment.syncHeader(); + if (ff.fsyncDir(dir) != 0) { + throw new MmapSegmentException("could not sync segment directory " + dir); + } + } + retryAtNanos = 0L; + return segment; + } catch (Throwable t) { + budget.release(segmentSizeBytes); + if (segment != null) { + try { + segment.close(); + } catch (Throwable ignored) { + } + // create() removes its own file when it throws; only a + // segment that exists needs unlinking here. + ff.remove(path); + } + retryAtNanos = now + CREATE_RETRY_NANOS; + if (now - lastLogNs >= LOG_THROTTLE_NANOS) { + lastLogNs = now; + LOG.warn("Failed to create next segment in {}; producer is backpressured, retrying every {} ms", + dir == null ? "" : dir, CREATE_RETRY_NANOS / 1_000_000L, t); + } + return null; + } + } +} diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentRing.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentRing.java index 6c598f261..b8175c6bd 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentRing.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentRing.java @@ -41,27 +41,26 @@ * oldest sealed segments. Built for the cursor engine's split-brain threading: *

      *
    • Producer thread (single user thread): {@link #appendOrFsn}, - * {@link #installHotSpare}, {@link #publishedFsn}.
    • + * which rotates into a segment it takes from the {@link SegmentSource} + * the moment the active one is full; {@link #publishedFsn}. *
    • I/O thread: {@link #publishedFsn} (read-only), {@link #acknowledge} * (single writer), and one pinned segment cursor for native reads.
    • - *
    • Segment manager: polls {@link #needsHotSpare}, hands new - * segments via {@link #installHotSpare}, and stages trim-eligible - * segments into hidden cleanup ownership on its own cadence.
    • + *
    • Segment manager: stages trim-eligible segments into hidden + * cleanup ownership and runs the disk barriers.
    • *
    * No locks; the only cross-thread state is {@link #publishedFsn} (volatile, - * single-writer) and {@link #ackedFsn} (volatile, single-writer). Hot-spare - * handoff uses {@code volatile} as well -- the segment manager publishes a - * spare; the producer thread consumes it on the next rotation. + * single-writer) and {@link #ackedFsn} (volatile, single-writer). *

    * Backpressure model: {@link #appendOrFsn} returns - * {@link #BACKPRESSURE_NO_SPARE} when the active is full and no spare is - * available. The caller (engine) is expected to spin-park until the segment - * manager catches up, OR until {@link #acknowledge} advances {@link #ackedFsn} - * far enough that the segment manager can recycle a sealed segment. + * {@link #BACKPRESSURE_NO_SPARE} when the active is full and the source has + * no segment to give (budget exhausted, create failed, periodic-sync barrier + * pending). The caller (engine) is expected to spin-park until + * {@link #acknowledge} advances {@link #ackedFsn} far enough that the segment + * manager trims a sealed segment, or the barrier completes. */ public final class SegmentRing implements QuietCloseable { - /** Sentinel: append failed because no hot spare was available to rotate into. */ + /** Sentinel: append failed because there was no segment to rotate into. */ public static final long BACKPRESSURE_NO_SPARE = -1L; /** Sentinel: append failed because the payload doesn't fit in a fresh segment. */ public static final long PAYLOAD_TOO_LARGE = -2L; @@ -113,12 +112,6 @@ public final class SegmentRing implements QuietCloseable { // frontier starts at 0. Maintained entirely under this monitor, in the same // coordinate space as sealedHead (shifted by compaction, reset on clear). private int firstNonDurableSealed; - // High-water byte offset within the active segment at which we proactively - // ask the segment manager to provision a spare (if one isn't already - // installed). Computed once as 3/4 of segment capacity -- leaves the manager - // a quarter-of-a-segment of producer runway to do its open+mmap before the - // producer would otherwise hit BACKPRESSURE_NO_SPARE. - private final long signalAtBytes; private volatile long ackedFsn = -1L; // Last FSN of the oldest sealed segment that is not I/O-pinned, or // Long.MAX_VALUE when there is none: the ACK at or past it completes a @@ -131,12 +124,7 @@ public final class SegmentRing implements QuietCloseable { // read by I/O thread via getActive(). Volatile so the I/O thread sees // rotations promptly and never observes a torn reference. private volatile MmapSegment active; - // Set to true by close(); checked by installHotSpare under the ring's - // monitor to reject spares that arrive after the ring has been torn - // down. Without this, a manager's serviceRing tick that snapshotted - // the ring before deregister could create a fresh MmapSegment, then - // call installHotSpare on a closed ring (whose hotSpare was just - // zeroed by close()) -- the spare's mmap + fd would never be reclaimed. + // Set to true by close(). private boolean closed; // Latest periodic data-barrier failure. The manager latches it and the // producer observes it before its next append; the manager clears it @@ -146,18 +134,14 @@ public final class SegmentRing implements QuietCloseable { // Run after every ackedFsn advance and durability-latch set, from whichever // thread made the change: the sender's ack waiters re-check both. private volatile Runnable ackStateListener; - // hotSpare: written by segment manager (installHotSpare), read+cleared by - // producer thread on rotation. Volatile so the producer sees fresh installs. - private volatile MmapSegment hotSpare; // Segment whose native mapping the single I/O consumer may dereference. // Guarded by this monitor: cursor lookup/switch and manager trim staging // are atomic, so a pinned segment cannot be hidden or unmapped. private MmapSegment ioPinnedSegment; // Optional callback the segment manager registers via setManagerWakeup - // so the producer can wake the manager out of its poll-park the moment - // a spare is needed, and the I/O thread can wake it after releasing a - // segment that may now be trimmable. Without this, the manager only - // notices on its next polling tick. + // so the producer can wake it when a rotation seals a segment that may + // already be fully acked, and the I/O thread can wake it after releasing + // a segment that may now be trimmable. private Runnable managerWakeup; private long nextSeq; private boolean periodicSyncEnabled; @@ -168,12 +152,10 @@ public final class SegmentRing implements QuietCloseable { // but not yet the bytes would find nothing to send and, re-checking // publishedFsn alone, block on a frame nobody announces. private volatile long publishStamp; + // Where rotation gets the next segment. Set by SegmentManager.register + // before the producer starts appending; tests supply their own. + private volatile SegmentSource segmentSource; private volatile boolean syncRequested; - // Plain (producer-thread-only) flag; set to true the first time we ask - // the manager for a spare for the current active segment, cleared on - // every rotation. Coalesces multiple high-water-mark crossings into a - // single unpark per active. - private boolean wakeupRequestedForActive; /** * Creates a ring with the given segment cap and an already-prepared @@ -191,9 +173,6 @@ public SegmentRing(MmapSegment initialActive, long maxBytesPerSegment) { this.active = initialActive; this.manifest = manifest; this.maxBytesPerSegment = maxBytesPerSegment; - // 3/4 of capacity gives the manager a full quarter-segment of producer - // runway before backpressure kicks in. Long math, no float, no alloc. - this.signalAtBytes = (maxBytesPerSegment >> 2) * 3; this.nextSeq = initialActive.baseSeq() + initialActive.frameCount(); this.publishedFsn = nextSeq - 1; } @@ -839,8 +818,8 @@ private static void quarantineFile(FilesFacade filesFacade, String path) { * * Multiple equivalent empties at the same base are NOT an error: a fresh * start or a rotation crash routinely leaves both the initial/promoted - * segment and a provisioned hot spare carrying the same provisional - * baseSeq. They are interchangeable blanks — pick one deterministically + * segment and a segment an older client pre-created as a hot spare, with + * the same provisional baseSeq. They are interchangeable blanks — pick one deterministically * and let the extras cleanup discard the rest. Bricking startup on this * state would turn every "kill -9 shortly after start" into a manual * repair. @@ -1045,27 +1024,24 @@ public void setAckStateListener(Runnable listener) { * FSN on success, or one of the {@code BACKPRESSURE_*} / {@code PAYLOAD_*} * sentinels on failure. *

    - * Rotation is automatic: when the active segment is full, the hot spare - * (if installed) is promoted, the previous active joins the sealed list, - * and the segment manager is signaled (implicitly -- it polls - * {@link #needsHotSpare}) to prepare the next spare. + * Rotation is automatic: when the active segment is full, the next + * segment is taken from the {@link SegmentSource} (created on this + * thread, in production), the previous active joins the sealed list, and + * the segment manager is woken in case the sealed segment is already + * fully acked. */ public long appendOrFsn(long payloadAddr, int payloadLen) { checkDurability(); MmapSegment target = active; boolean rotated = false; if (target.tryWrite(payloadAddr, payloadLen) == -1L) { - // Active is full. Try to rotate. - MmapSegment spare = hotSpare; - if (spare == null) { - return BACKPRESSURE_NO_SPARE; - } - // Reject before rotating: the spare is empty, so this is exactly the - // check its tryWrite below would make. Rotating first would seal the - // active and consume the spare without publishing a frame, so nothing - // wakes the I/O thread to move its pin off the sealed segment, and each - // retried flush of the same oversized batch would strand one more. - if (payloadLen > spare.capacityRemaining()) { + // Active is full. Reject an oversized payload before rotating: a + // fresh segment would make exactly this check, and rotating first + // would seal the active without publishing a frame, so nothing + // wakes the I/O thread to move its pin off the sealed segment, and + // each retried flush of the same oversized batch would strand one + // more segment. + if (payloadLen > maxBytesPerSegment - MmapSegment.HEADER_SIZE - MmapSegment.FRAME_HEADER_SIZE) { return PAYLOAD_TOO_LARGE; } // Periodic mode must make the predecessor's complete published @@ -1077,61 +1053,61 @@ public long appendOrFsn(long payloadAddr, int payloadLen) { wakeManager(); return BACKPRESSURE_NO_SPARE; } - // Pin the spare's baseSeq to whatever the active's nextSeq actually - // is right now. This is the right moment because (a) the active is - // full, so its frameCount is stable, and (b) the spare hasn't been - // appended to yet (rebaseSeq enforces that). The segment manager's - // earlier guess at baseSeq is irrelevant. + SegmentSource source = segmentSource; + if (source == null) { + return BACKPRESSURE_NO_SPARE; + } + // The active is full, so its frameCount is stable: this is the + // successor's exact base. long actualBase = previous.baseSeq() + previous.frameCount(); - spare.rebaseSeq(actualBase); - if (manifest != null) { - // Make the spare's rebased identity durable BEFORE the manifest - // references it. Without this barrier an OS crash could leave a - // durable manifest pointing at baseSeq=actualBase while the - // spare's on-disk header still carries the manager's - // provisional guess -- recovery would then find no segment at - // the committed active boundary and fail a startup that lost - // nothing. One msync per rotation, amortized over a whole - // segment of appends; runs outside the monitor because the - // spare is not yet visible to any other thread. - // - // MEMORY mode deliberately does not sync the predecessor's - // data pages. PERIODIC mode reached this point only after the - // manager covered the predecessor's complete published range. - spare.syncHeader(); + MmapSegment spare = source.next(actualBase); + if (spare == null) { + return BACKPRESSURE_NO_SPARE; } - // Publish the successor before the volatile active promotion. The - // same monitor protects the sealed list and nextSealedAfter's trim - // fallback, while the volatile link also remains readable from a - // current segment after the manager removes and closes it. - synchronized (this) { - if (manifest != null) { - // Inside the monitor: serialized with the trim path's - // advanceManifestHeadPast so neither writer publishes a - // boundary computed from a state the other has already - // moved past (SfManifest additionally clamps monotonic). - // BEFORE any ring mutation: if the manifest fsync throws, - // the rotation never happened -- previous stays active, - // the spare stays installed, and the producer's retry - // re-runs this block from a consistent state. - long headBase = sealedHead < sealedSegments.size() - ? sealedSegments.get(sealedHead).baseSeq() - : previous.baseSeq(); - manifest.update(headBase, actualBase); + try { + if (spare.baseSeq() != actualBase) { + // A source that pre-created the segment (tests) guessed + // the base. Pin it, and make it durable BEFORE the + // manifest references it: an OS crash could otherwise + // leave a durable manifest naming baseSeq=actualBase + // while the on-disk header still carries the guess, and + // recovery would fail a startup that lost nothing. + spare.rebaseSeq(actualBase); + if (manifest != null) { + spare.syncHeader(); + } } - previous.linkSuccessor(spare); - sealedSegments.add(previous); - refreshOldestTrimmable(); - active = spare; + // Publish the successor before the volatile active promotion. The + // same monitor protects the sealed list and nextSealedAfter's trim + // fallback, while the volatile link also remains readable from a + // current segment after the manager removes and closes it. + synchronized (this) { + if (manifest != null) { + // Inside the monitor: serialized with the trim path's + // advanceManifestHeadPast so neither writer publishes a + // boundary computed from a state the other has already + // moved past (SfManifest additionally clamps monotonic). + // BEFORE any ring mutation: if the manifest fsync throws, + // the rotation never happened -- previous stays active, + // the new segment goes back to the source, and the + // producer's retry re-runs this block from a consistent + // state. + long headBase = sealedHead < sealedSegments.size() + ? sealedSegments.get(sealedHead).baseSeq() + : previous.baseSeq(); + manifest.update(headBase, actualBase); + } + previous.linkSuccessor(spare); + sealedSegments.add(previous); + refreshOldestTrimmable(); + active = spare; + } + } catch (Throwable t) { + source.discard(spare); + throw t; } - hotSpare = null; - // Fresh active just consumed the spare → ask the manager to start - // making the next one immediately, before this segment fills. - // The flag is per-active and tracks whether the backup-signal - // branch has fired for the *current* active. Rotation installs a - // new active, so the flag resets here to re-arm the backup branch. - // Plain field reset is safe (producer-only state). - wakeupRequestedForActive = false; + // The sealed segment may already be fully acked (the ACK landed + // while it was still active, so it woke nothing): let trim look. Runnable wakeup = managerWakeup; if (wakeup != null) { wakeup.run(); @@ -1156,19 +1132,6 @@ public long appendOrFsn(long payloadAddr, int payloadLen) { publishedFsn = fsn; target.publishWritten(); publishStamp = publishStamp + 1; // single producer - if (!rotated - && !wakeupRequestedForActive - && hotSpare == null - && managerWakeup != null - && target.publishedOffset() >= signalAtBytes) { - // Backup signal: we're past the high-water mark and still don't - // have a spare (manager hasn't caught up yet, or this is the very - // first active and rotation hasn't fired the on-rotation wakeup). - // Fire once per active segment, after the frame is fully published - // so the wakeup never delays its send. - wakeupRequestedForActive = true; - managerWakeup.run(); - } return fsn; } @@ -1276,10 +1239,7 @@ void recordDurabilityFailure(Throwable failure) { @Override public synchronized void close() { - // Marking closed BEFORE freeing fields ensures any concurrent - // installHotSpare (waiting on this monitor) will observe closed - // when it acquires the lock and reject the spare cleanly. The - // monitor also serializes against drainTrimmable / nextSealedAfter + // The monitor serializes against drainTrimmable / nextSealedAfter // / firstSealed / findSegmentContaining, so they don't iterate // half-freed state. closed = true; @@ -1288,9 +1248,10 @@ public synchronized void close() { active.close(); active = null; } - if (hotSpare != null) { - hotSpare.close(); - hotSpare = null; + SegmentSource source = segmentSource; + if (source != null) { + segmentSource = null; + source.close(); } for (int i = sealedHead, n = sealedSegments.size(); i < n; i++) { sealedSegments.get(i).close(); @@ -1442,37 +1403,10 @@ public synchronized ObjList getSealedSegments() { return sealedSegments; } - /** - * Segment manager pre-creates the next segment and parks it here. The - * producer consumes the spare on its next rotation. Throws if a spare - * is already installed (the manager should have polled {@link #needsHotSpare} - * first; double-install is a programming error), or if the ring has - * been closed since the manager started provisioning the spare. The - * latter is a benign race -- the manager's catch block already closes - * the unused spare and unlinks its file. - */ - public synchronized void installHotSpare(MmapSegment spare) { - if (closed) { - throw new IllegalStateException("ring closed"); - } - if (hotSpare != null) { - throw new IllegalStateException("hot spare already installed"); - } - if (spare == null) { - throw new IllegalArgumentException("spare must not be null"); - } - hotSpare = spare; - } - public long maxBytesPerSegment() { return maxBytesPerSegment; } - /** True when the segment manager should prepare and install a fresh spare. */ - public boolean needsHotSpare() { - return hotSpare == null; - } - /** * Atomically advances the I/O pin from {@code current} to the next live * sealed segment, or to the active segment when no sealed successor @@ -1598,11 +1532,6 @@ synchronized int pendingTrimCount() { return pendingTrims.size(); } - @TestOnly - public synchronized MmapSegment getHotSpareForTesting() { - return hotSpare; - } - @TestOnly public synchronized int getPendingTrimCount() { return pendingTrims.size(); @@ -1714,11 +1643,11 @@ synchronized int stagePendingTrims(MmapSegment[] target, int maxCount, long cove } /** - * Registers a wakeup callback that the producer thread will invoke when - * a hot spare is needed -- either right after a rotation has consumed the - * previous spare, or when the active segment crosses the 75% high-water - * mark while no spare is installed. The callback is expected to be cheap - * (e.g. {@code LockSupport.unpark} of the segment manager's worker). + * Registers a wakeup callback the producer thread invokes right after a + * rotation sealed a segment, and the I/O thread after it released its pin + * from one: either may have made a segment trimmable. The callback is + * expected to be cheap (e.g. {@code LockSupport.unpark} of the segment + * manager's worker). *

    * Set once, before the producer starts appending. Idempotent re-set is * allowed but not thread-safe. @@ -1727,6 +1656,25 @@ public void setManagerWakeup(Runnable wakeup) { this.managerWakeup = wakeup; } + /** + * Installs where {@link #appendOrFsn} gets the segment it rotates into. + * Set before the producer starts appending; the producer thread is the + * only caller of the source afterwards. + */ + public void setSegmentSource(SegmentSource source) { + this.segmentSource = source; + } + + @TestOnly + public SegmentSource getSegmentSourceForTesting() { + return segmentSource; + } + + /** True once a source is installed; a test-supplied one is kept by {@code SegmentManager.register}. */ + public boolean hasSegmentSource() { + return segmentSource != null; + } + /** * Thread-safe snapshot of the current sealed-segment list. Copies * references into the caller-supplied {@code target} array (oldest @@ -1760,8 +1708,8 @@ public void syncAllLiveSegments() { } /** - * Total mmap'd bytes the ring currently owns: active + hot spare (if - * installed) + every sealed segment. Used by {@code SegmentManager} + * Total mmap'd bytes the ring currently owns: active + every sealed + * segment. Used by {@code SegmentManager} * to seed its {@code totalBytes} accounting at register time and to * reverse the contribution at deregister time. Synchronized against * rotation so we never read a half-resized sealed list. @@ -1770,8 +1718,6 @@ public synchronized long totalSegmentBytes() { long total = 0L; MmapSegment a = active; if (a != null) total += a.sizeBytes(); - MmapSegment hs = hotSpare; - if (hs != null) total += hs.sizeBytes(); for (int i = sealedHead, n = sealedSegments.size(); i < n; i++) { total += sealedSegments.get(i).sizeBytes(); } diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentSource.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentSource.java new file mode 100644 index 000000000..29ce9f650 --- /dev/null +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentSource.java @@ -0,0 +1,54 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +package io.questdb.client.cutlass.qwp.client.sf.cursor; + +import io.questdb.client.std.QuietCloseable; + +/** + * Where a {@link SegmentRing} gets the segment it rotates into. Called on the + * producer thread, inside {@link SegmentRing#appendOrFsn}, when the active + * segment is full. {@link SegmentProvisioner} is the production source. + */ +public interface SegmentSource extends QuietCloseable { + + /** + * The next segment, starting at {@code baseSeq}, or {@code null} to + * backpressure the producer (budget exhausted, create failed and its retry + * is pending). Must not throw. A segment returned here is owned by the + * ring unless it is handed back through {@link #discard}. + */ + MmapSegment next(long baseSeq); + + /** + * Takes back a segment {@link #next} returned that the rotation could not + * complete with (the manifest update threw). Closes it and reverses any + * accounting {@link #next} did. + */ + void discard(MmapSegment segment); + + /** Releases whatever the source still holds; the owning ring calls this from its own close. */ + @Override + void close(); +} diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SegmentSkipQuarantineTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SegmentSkipQuarantineTest.java index 95beb1c38..3c6af3aef 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SegmentSkipQuarantineTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SegmentSkipQuarantineTest.java @@ -288,9 +288,8 @@ public void testThrowingErrorHandlerDoesNotFailBuild() throws Exception { * the append path itself performs a genuine rotation -- {@code SegmentRing.appendOrFsn} * only rotates when {@code tryAppend} reports the active segment full. Without this, the * setup's old {@code > 1} segment-file precondition could be satisfied by nothing more than - * the empty hot spare {@code SegmentManager} provisions asynchronously the moment the ring - * registers ({@code SegmentRing.needsHotSpare} is simply {@code hotSpare == null}, independent - * of bytes written) -- a background-thread race against this method's {@code Sender} closing, + * the empty hot spare an earlier {@code SegmentManager} provisioned asynchronously the moment + * the ring registered -- a background-thread race against this method's {@code Sender} closing, * observed to fail consistently on Windows CI and to coin-flip on the JDK 8 Linux leg. Forcing * a genuine rotation makes the precondition -- and the corruption scenario it sets up, a skip * among data-bearing survivors -- true by construction rather than by timing. diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SlotLockReleasedContractTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SlotLockReleasedContractTest.java index aeac65639..d796bffe2 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SlotLockReleasedContractTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SlotLockReleasedContractTest.java @@ -281,7 +281,7 @@ public void testSlotLockNotReleasedUntilManagerWorkerQuiesces() throws Exception boolean managerClosed = false; try { manager.setAfterRingCleanupHook(cleanupFinished::countDown); - manager.setBeforeInstallSyncHook(() -> { + manager.setBeforeTrimSyncHook(() -> { if (!fired.compareAndSet(false, true)) return; workerBlocked.countDown(); try { @@ -345,7 +345,7 @@ public void testSlotLockNotReleasedUntilManagerWorkerQuiesces() throws Exception } } finally { manager.setAfterRingCleanupHook(null); - manager.setBeforeInstallSyncHook(null); + manager.setBeforeTrimSyncHook(null); releaseWorker.countDown(); if (wss != null) { try { diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleTest.java index 5e39bca53..302022aad 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleTest.java @@ -265,10 +265,9 @@ public void testPostRecycleSlotContents() throws Exception { // rebuilt slot's structure instead: exactly the well-known set of state // files a brand-new (never-recovered) slot has, nothing left over from // the outgoing epoch's segments. - // The hot spare is provisioned by the segment-manager worker, so await it. List freshSlotFiles = Arrays.asList( ".ack-watermark", ".lock", ".lock.pid", ".symbol-dict", - "sf-0000000000000000.sfa", "sf-initial.sfa", "sf-manifest.bin"); + "sf-initial.sfa", "sf-manifest.bin"); Assert.assertEquals("post-recycle slot must contain exactly a fresh engine's " + "own state files", freshSlotFiles, awaitDir(slot, freshSlotFiles)); diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorSendEngineSlotReacquisitionTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorSendEngineSlotReacquisitionTest.java index 1b55ab421..a3f292bda 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorSendEngineSlotReacquisitionTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorSendEngineSlotReacquisitionTest.java @@ -112,7 +112,7 @@ public void testRepeatedCloseRetainsSlotWhileWorkerIsMidServicePass() throws Exc cleanupCount.incrementAndGet(); cleanupFinished.countDown(); }); - manager.setBeforeInstallSyncHook(() -> { + manager.setBeforeTrimSyncHook(() -> { if (!fired.compareAndSet(false, true)) return; workerBlocked.countDown(); try { @@ -186,7 +186,7 @@ public void testRepeatedCloseRetainsSlotWhileWorkerIsMidServicePass() throws Exc } } finally { manager.setAfterRingCleanupHook(null); - manager.setBeforeInstallSyncHook(null); + manager.setBeforeTrimSyncHook(null); releaseWorker.countDown(); if (!managerClosed) { manager.close(); @@ -229,17 +229,10 @@ public void testOwnedEngineCloseRetainsSlotWhileWorkerIsMidServicePass() throws SegmentManager manager = engine.getManagerForTesting(); long buf = Unsafe.malloc(payloadLen, MemoryTag.NATIVE_DEFAULT); try { - // Phase 1: let the worker finish the initial spare install so - // the hook below can only fire on the rotation-triggered pass. + // Phase 1: the ring is registered; the hook below fires on the + // next worker pass, which the rotation below triggers. SegmentRing ring = engine.getRingForTesting(); - long deadlineNs = System.nanoTime() + TimeUnit.SECONDS.toNanos(10); - while (ring.needsHotSpare()) { - if (System.nanoTime() > deadlineNs) { - throw new AssertionError("manager worker never installed the initial hot spare"); - } - Thread.sleep(1); - } - manager.setBeforeInstallSyncHook(() -> { + manager.setBeforeTrimSyncHook(() -> { if (!fired.compareAndSet(false, true)) return; workerBlocked.countDown(); try { @@ -253,8 +246,8 @@ public void testOwnedEngineCloseRetainsSlotWhileWorkerIsMidServicePass() throws }); // Phase 2: one frame fills the active segment exactly; the - // second forces rotation onto the spare. needsHotSpare() is - // true again, so the worker's next tick parks in the hook. + // second forces a rotation, which wakes the worker, whose + // next pass parks in the hook. Unsafe.getUnsafe().putLong(buf, 0L); Assert.assertEquals(0L, engine.appendBlocking(buf, payloadLen)); Assert.assertEquals(1L, engine.appendBlocking(buf, payloadLen)); @@ -298,7 +291,7 @@ public void testOwnedEngineCloseRetainsSlotWhileWorkerIsMidServicePass() throws } } finally { Unsafe.free(buf, payloadLen, MemoryTag.NATIVE_DEFAULT); - manager.setBeforeInstallSyncHook(null); + manager.setBeforeTrimSyncHook(null); releaseWorker.countDown(); manager.setWorkerJoinTimeoutMillis(TimeUnit.SECONDS.toMillis(60)); try { @@ -339,14 +332,7 @@ public void testOwnedEngineCloseHandsCleanupToWorkerExit() throws Exception { // can only fire on the rotation-triggered pass (see // testOwnedEngineCloseRetainsSlotWhileWorkerIsMidServicePass). SegmentRing ring = engine.getRingForTesting(); - long deadlineNs = System.nanoTime() + TimeUnit.SECONDS.toNanos(10); - while (ring.needsHotSpare()) { - if (System.nanoTime() > deadlineNs) { - throw new AssertionError("manager worker never installed the initial hot spare"); - } - Thread.sleep(1); - } - manager.setBeforeInstallSyncHook(() -> { + manager.setBeforeTrimSyncHook(() -> { if (!fired.compareAndSet(false, true)) return; workerBlocked.countDown(); try { @@ -409,7 +395,7 @@ public void testOwnedEngineCloseHandsCleanupToWorkerExit() throws Exception { } } finally { Unsafe.free(buf, payloadLen, MemoryTag.NATIVE_DEFAULT); - manager.setBeforeInstallSyncHook(null); + manager.setBeforeTrimSyncHook(null); releaseWorker.countDown(); manager.setWorkerJoinTimeoutMillis(TimeUnit.SECONDS.toMillis(60)); try { @@ -451,14 +437,7 @@ public void testOwnedEngineCloseRetainsSlotWhenHandoffRegistrationFails() throws // can only fire on the rotation-triggered pass (see // testOwnedEngineCloseRetainsSlotWhileWorkerIsMidServicePass). SegmentRing ring = engine.getRingForTesting(); - long deadlineNs = System.nanoTime() + TimeUnit.SECONDS.toNanos(10); - while (ring.needsHotSpare()) { - if (System.nanoTime() > deadlineNs) { - throw new AssertionError("manager worker never installed the initial hot spare"); - } - Thread.sleep(1); - } - manager.setBeforeInstallSyncHook(() -> { + manager.setBeforeTrimSyncHook(() -> { if (!fired.compareAndSet(false, true)) return; workerBlocked.countDown(); try { @@ -522,7 +501,7 @@ public void testOwnedEngineCloseRetainsSlotWhenHandoffRegistrationFails() throws } } finally { Unsafe.free(buf, payloadLen, MemoryTag.NATIVE_DEFAULT); - manager.setBeforeInstallSyncHook(null); + manager.setBeforeTrimSyncHook(null); manager.setBeforeExitCleanupRegistrationHook(null); releaseWorker.countDown(); manager.setWorkerJoinTimeoutMillis(TimeUnit.SECONDS.toMillis(60)); @@ -567,14 +546,7 @@ public void testTerminalCleanupRunsExactlyOnceWhenRetriedCloseRacesWorkerHandoff // Phase 1: wait out the initial spare install so the park hook // can only fire on the rotation-triggered pass. SegmentRing ring = engine.getRingForTesting(); - long deadlineNs = System.nanoTime() + TimeUnit.SECONDS.toNanos(10); - while (ring.needsHotSpare()) { - if (System.nanoTime() > deadlineNs) { - throw new AssertionError("manager worker never installed the initial hot spare"); - } - Thread.sleep(1); - } - manager.setBeforeInstallSyncHook(() -> { + manager.setBeforeTrimSyncHook(() -> { if (!fired.compareAndSet(false, true)) return; workerBlocked.countDown(); try { @@ -674,7 +646,7 @@ public void testTerminalCleanupRunsExactlyOnceWhenRetriedCloseRacesWorkerHandoff } } finally { Unsafe.free(buf, payloadLen, MemoryTag.NATIVE_DEFAULT); - manager.setBeforeInstallSyncHook(null); + manager.setBeforeTrimSyncHook(null); engine.setBeforeFlockReleaseHook(null); releaseWorker.countDown(); releaseFinishClose.countDown(); @@ -716,14 +688,7 @@ public void testMemoryModeOwnedCloseHandsCleanupToWorkerExit() throws Exception // Phase 1: wait out the initial spare install so the park hook // can only fire on the rotation-triggered pass. SegmentRing ring = engine.getRingForTesting(); - long deadlineNs = System.nanoTime() + TimeUnit.SECONDS.toNanos(10); - while (ring.needsHotSpare()) { - if (System.nanoTime() > deadlineNs) { - throw new AssertionError("manager worker never installed the initial hot spare"); - } - Thread.sleep(1); - } - manager.setBeforeInstallSyncHook(() -> { + manager.setBeforeTrimSyncHook(() -> { if (!fired.compareAndSet(false, true)) return; workerBlocked.countDown(); try { @@ -772,7 +737,7 @@ public void testMemoryModeOwnedCloseHandsCleanupToWorkerExit() throws Exception } } finally { Unsafe.free(buf, payloadLen, MemoryTag.NATIVE_DEFAULT); - manager.setBeforeInstallSyncHook(null); + manager.setBeforeTrimSyncHook(null); releaseWorker.countDown(); manager.setWorkerJoinTimeoutMillis(TimeUnit.SECONDS.toMillis(60)); try { diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorSendEngineTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorSendEngineTest.java index 43e567a8d..52d8f308e 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorSendEngineTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorSendEngineTest.java @@ -1099,10 +1099,10 @@ private static Throwable invokeSharedConstructorExpectingFailure( } private static void poisonRegisterGeneration(SegmentManager manager) throws Exception { - // register() advances fileGeneration before publishing the ring. Nulling - // it forces a deterministic constructor failure after the ring and + // register() publishes the ring into `rings` last. Nulling the list + // forces a deterministic constructor failure after the ring and // watermark exist, without adding a production test hook. - Field f = SegmentManager.class.getDeclaredField("fileGeneration"); + Field f = SegmentManager.class.getDeclaredField("rings"); f.setAccessible(true); f.set(manager, null); } diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/QueueSegmentSource.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/QueueSegmentSource.java new file mode 100644 index 000000000..7d38d5fe7 --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/QueueSegmentSource.java @@ -0,0 +1,83 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +package io.questdb.client.test.cutlass.qwp.client.sf.cursor; + +import io.questdb.client.cutlass.qwp.client.sf.cursor.MmapSegment; +import io.questdb.client.cutlass.qwp.client.sf.cursor.SegmentRing; +import io.questdb.client.cutlass.qwp.client.sf.cursor.SegmentSource; + +import java.util.ArrayDeque; + +/** + * Test stand-in for {@code SegmentProvisioner}: hands a ring pre-built + * segments in order, like the manager's hot spare used to. Segments never + * consumed are closed with the ring. + */ +public final class QueueSegmentSource implements SegmentSource { + private final ArrayDeque queue = new ArrayDeque<>(); + + /** Queues {@code segment} as the ring's next rotation target, installing a queue source if needed. */ + public static void install(SegmentRing ring, MmapSegment segment) { + of(ring).queue.add(segment); + } + + /** True when the ring has no queued segment to rotate into. */ + public static boolean isEmpty(SegmentRing ring) { + SegmentSource s = ring.getSegmentSourceForTesting(); + return !(s instanceof QueueSegmentSource) || ((QueueSegmentSource) s).queue.isEmpty(); + } + + public static MmapSegment peek(SegmentRing ring) { + return of(ring).queue.peek(); + } + + private static QueueSegmentSource of(SegmentRing ring) { + SegmentSource s = ring.getSegmentSourceForTesting(); + if (s instanceof QueueSegmentSource) { + return (QueueSegmentSource) s; + } + QueueSegmentSource q = new QueueSegmentSource(); + ring.setSegmentSource(q); + return q; + } + + @Override + public void close() { + MmapSegment s; + while ((s = queue.poll()) != null) { + s.close(); + } + } + + @Override + public void discard(MmapSegment segment) { + segment.close(); + } + + @Override + public MmapSegment next(long baseSeq) { + return queue.poll(); + } +} diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerAckWatermarkLagTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerAckWatermarkLagTest.java index 00553d358..bfcede06c 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerAckWatermarkLagTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerAckWatermarkLagTest.java @@ -126,16 +126,13 @@ public void testStreamingAcksInsideActiveSegmentNeverWakeWorker() throws Excepti for (int i = 0; i < acks; i++) { Assert.assertTrue(ring.appendOrFsn(payload, 32) >= 0); } - // Let the register wake's pass finish (it installs the spare) and - // the worker park before counting, so a slow spare create cannot - // be mistaken for an ACK-driven pass. + // Let the register wake's pass finish and the worker park before + // counting, so that pass cannot be mistaken for an ACK-driven one. Thread worker = manager.getWorkerThreadForTesting(); long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10); - while ((ring.needsHotSpare() || worker.getState() != Thread.State.WAITING) - && System.nanoTime() < deadline) { + while (worker.getState() != Thread.State.WAITING && System.nanoTime() < deadline) { Thread.sleep(1); } - Assert.assertFalse(ring.needsHotSpare()); Assert.assertEquals(Thread.State.WAITING, worker.getState()); passes.set(0); for (int seq = 0; seq < acks; seq++) { diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerCloseRaceTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerCloseRaceTest.java index a10884d4e..46f4df138 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerCloseRaceTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerCloseRaceTest.java @@ -42,26 +42,11 @@ import java.util.concurrent.atomic.AtomicReference; /** - * Concurrent regression for the {@code SegmentManager} worker race vs - * ring deregister/close. - *

    - * The manager's worker loop snapshots {@code rings} under a lock, then - * services each ring outside the lock. If a user thread calls - * {@code deregister(ring)} + {@code ring.close()} between the snapshot - * and {@code installHotSpare}, the manager: - *

      - *
    • creates a new {@code MmapSegment} (mmap + fd + on-disk file)
    • - *
    • calls {@code ring.installHotSpare(spare)} on the closed ring — - * which sees {@code hotSpare == null} (just zeroed by close) and - * silently accepts the install
    • - *
    - * The spare's mmap + fd are now permanently leaked: nothing will ever - * close them because {@code close()} already ran. - *

    - * Detection: after the manager has joined, reflect into each closed - * ring's {@code hotSpare} field. A non-null value means a spare was - * installed AFTER {@code close()} zeroed the field — i.e. exactly the - * leak path. We close any survivors so the test itself doesn't leak. + * Concurrent regressions for the {@code SegmentManager} worker vs ring + * deregister/close: the worker snapshots {@code rings} under a lock, then + * services each ring outside it, so a deregister that lands mid-pass must be + * observed by the pass and by close()'s bounded join. The worker is parked + * mid-pass through the {@code beforeTrimSyncHook} seam. */ public class SegmentManagerCloseRaceTest { @@ -83,121 +68,6 @@ public void tearDown() { Files.remove(tmpDir); } - @Test - public void testManagerDoesNotInstallSpareIntoClosedRing() throws Exception { - TestUtils.assertMemoryLeak(() -> { - // Aggressive 1us poll so the worker is almost always running - // serviceRing — maximizes overlap with concurrent deregister/close. - SegmentManager manager = new SegmentManager(SEGMENT_SIZE, new SegmentBudget(Long.MAX_VALUE)); - manager.start(); - - SegmentRing[] rings = new SegmentRing[ITERATIONS]; - try { - for (int i = 0; i < ITERATIONS; i++) { - String slot = tmpDir + "/slot-" + i; - Assert.assertEquals(0, Files.mkdir(slot, Files.DIR_MODE_DEFAULT)); - MmapSegment initial = MmapSegment.create( - slot + "/sf-initial.sfa", 0L, SEGMENT_SIZE); - rings[i] = new SegmentRing(initial, SEGMENT_SIZE); - manager.register(rings[i], slot); - // Immediately deregister + close. The manager may be mid- - // serviceRing for this very ring, having already created a - // spare and not yet installed it — that's the race window. - manager.deregister(rings[i]); - rings[i].close(); - } - } finally { - // join the worker so any in-flight serviceRing finishes - // BEFORE we inspect the rings — otherwise a later install - // could escape detection. - manager.close(); - } - - int leaked = 0; - for (int i = 0; i < ITERATIONS; i++) { - Object hs = rings[i].getHotSpareForTesting(); - if (hs != null) { - leaked++; - // Don't leak in the test: close the survivor. - ((MmapSegment) hs).close(); - } - } - - Assert.assertEquals( - "SegmentManager installed hot spares into closed rings — " - + "spare mmap/fd permanently leaked", - 0, leaked); - }); - } - - @Test(timeout = 15_000L) - public void testCloseDoesNotFreePathScratchWhenWorkerStillAlive() throws Exception { - TestUtils.assertMemoryLeak(() -> { - long segSize = MmapSegment.HEADER_SIZE + (MmapSegment.FRAME_HEADER_SIZE + 32); - String slot = tmpDir + "/timeout-slot"; - Assert.assertEquals(0, Files.mkdir(slot, Files.DIR_MODE_DEFAULT)); - MmapSegment initial = MmapSegment.create(slot + "/sf-initial.sfa", 0L, segSize); - SegmentRing ring = new SegmentRing(initial, segSize); - SegmentManager manager = new SegmentManager(segSize); - CountDownLatch workerBlocked = new CountDownLatch(1); - CountDownLatch releaseWorker = new CountDownLatch(1); - AtomicBoolean fired = new AtomicBoolean(); - AtomicReference hookErr = new AtomicReference<>(); - boolean managerClosed = false; - try { - manager.register(ring, slot); - manager.setBeforeInstallSyncHook(() -> { - if (!fired.compareAndSet(false, true)) return; - workerBlocked.countDown(); - try { - if (!releaseWorker.await(10, TimeUnit.SECONDS)) { - hookErr.compareAndSet(null, - new AssertionError("timed out waiting for test to release worker")); - } - } catch (Throwable t) { - hookErr.compareAndSet(null, t); - } - }); - manager.start(); - Assert.assertTrue("worker did not reach install hook", - workerBlocked.await(5, TimeUnit.SECONDS)); - Assert.assertTrue("precondition: path scratch should be allocated", - readPathScratchImpl(manager) != 0L); - - manager.setWorkerJoinTimeoutMillis(50L); - Thread.currentThread().interrupt(); - manager.close(); - Assert.assertTrue("close should preserve interrupted status", - Thread.interrupted()); - Thread worker = readWorkerThread(manager); - Assert.assertTrue("worker should still be tracked after incomplete close", - worker != null && worker.isAlive()); - Assert.assertTrue("path scratch was freed while worker was still alive", - readPathScratchImpl(manager) != 0L); - - releaseWorker.countDown(); - manager.setWorkerJoinTimeoutMillis(TimeUnit.SECONDS.toMillis(60)); - manager.close(); - managerClosed = true; - Assert.assertNull("successful close should clear workerThread", - readWorkerThread(manager)); - Assert.assertEquals("successful close should free path scratch", - 0L, readPathScratchImpl(manager)); - if (hookErr.get() != null) { - throw new AssertionError("install hook failed", hookErr.get()); - } - } finally { - manager.setBeforeInstallSyncHook(null); - releaseWorker.countDown(); - if (!managerClosed) { - Thread.interrupted(); - manager.close(); - } - ring.close(); - } - }); - } - /** * Pins the claim-time registration gate at the top of * {@code SegmentManager.serviceRing}: a snapshot entry whose ring was @@ -247,36 +117,33 @@ public void testStaleSnapshotEntrySkippedAfterDeregisterBeforeServiceClaim() thr slots[i] + "/sf-initial.sfa", 0L, segSize); rings[i] = new SegmentRing(initial, segSize); } - manager.setBeforeInstallSyncHook(() -> { - if (!fired.compareAndSet(false, true)) return; - workerBlocked.countDown(); + manager.setBeforeTrimSyncHook(() -> { try { - if (!releaseWorker.await(10, TimeUnit.SECONDS)) { - hookErr.compareAndSet(null, - new AssertionError("timed out waiting for test to release worker")); + Object ring = readInServiceRing(manager); + if (ring != null) { + serviced.add(ring); } } catch (Throwable t) { hookErr.compareAndSet(null, t); } - }); - manager.setBeforeTrimSyncHook(() -> { + if (!fired.compareAndSet(false, true)) return; + workerBlocked.countDown(); try { - Object ring = readInServiceRing(manager); - if (ring != null) { - serviced.add(ring); + if (!releaseWorker.await(10, TimeUnit.SECONDS)) { + hookErr.compareAndSet(null, + new AssertionError("timed out waiting for test to release worker")); } } catch (Throwable t) { hookErr.compareAndSet(null, t); } }); // Register all three BEFORE start: the worker's first snapshot - // is [A, B, C] and every fresh ring wants a hot spare, so the - // install hook parks the worker inside A's pass. + // is [A, B, C], so the hook parks the worker inside A's pass. manager.register(rings[0], slots[0]); manager.register(rings[1], slots[1]); manager.register(rings[2], slots[2]); manager.start(); - Assert.assertTrue("worker did not reach A's install pass", + Assert.assertTrue("worker did not reach A's pass", workerBlocked.await(5, TimeUnit.SECONDS)); // B is deregistered while its snapshot entry is still ahead of @@ -307,10 +174,6 @@ public void testStaleSnapshotEntrySkippedAfterDeregisterBeforeServiceClaim() thr + "be releasing the ring/watermark/slot lock, so a stale snapshot " + "entry must be skipped at claim time", serviced.contains(rings[1])); - // Defense in depth: nothing may have been installed into the - // deregistered ring either. - Assert.assertNull("no hot spare may be installed into a deregistered ring", - readHotSpare(rings[1])); manager.close(); managerClosed = true; @@ -318,7 +181,7 @@ public void testStaleSnapshotEntrySkippedAfterDeregisterBeforeServiceClaim() thr throw new AssertionError("worker-side hook failed", hookErr.get()); } } finally { - manager.setBeforeInstallSyncHook(null); + manager.setBeforeTrimSyncHook(null); manager.setBeforeTrimSyncHook(null); releaseWorker.countDown(); if (!managerClosed) { @@ -333,89 +196,6 @@ public void testStaleSnapshotEntrySkippedAfterDeregisterBeforeServiceClaim() thr }); } - /** - * Pins the scratch-handoff half of the timed-out-close contract in - * isolation: after {@code close()} gives up on the bounded join and hands - * {@code pathScratch} ownership to the worker, the WORKER's exit block - * alone must free the native buffer — with no retried {@code close()} - * ever running. The sibling test - * ({@link #testCloseDoesNotFreePathScratchWhenWorkerStillAlive}) retries - * {@code close()} before asserting the free, so a regression that dropped - * the worker-side free (leaving reclaim to a retry nobody is required to - * make) would stay green there: production owners do NOT retry — a - * timed-out close returns to the pool and the worker is the only thread - * left that can reclaim the allocation. - */ - @Test(timeout = 15_000L) - public void testWorkerAloneFreesPathScratchAfterTimedOutClose() throws Exception { - TestUtils.assertMemoryLeak(() -> { - long segSize = MmapSegment.HEADER_SIZE + (MmapSegment.FRAME_HEADER_SIZE + 32); - String slot = tmpDir + "/worker-frees-scratch-slot"; - Assert.assertEquals(0, Files.mkdir(slot, Files.DIR_MODE_DEFAULT)); - MmapSegment initial = MmapSegment.create(slot + "/sf-initial.sfa", 0L, segSize); - SegmentRing ring = new SegmentRing(initial, segSize); - SegmentManager manager = new SegmentManager(segSize); - CountDownLatch workerBlocked = new CountDownLatch(1); - CountDownLatch releaseWorker = new CountDownLatch(1); - AtomicBoolean fired = new AtomicBoolean(); - AtomicReference hookErr = new AtomicReference<>(); - try { - manager.register(ring, slot); - manager.setBeforeInstallSyncHook(() -> { - if (!fired.compareAndSet(false, true)) return; - workerBlocked.countDown(); - try { - if (!releaseWorker.await(10, TimeUnit.SECONDS)) { - hookErr.compareAndSet(null, - new AssertionError("timed out waiting for test to release worker")); - } - } catch (Throwable t) { - hookErr.compareAndSet(null, t); - } - }); - manager.start(); - Assert.assertTrue("worker did not reach install hook", - workerBlocked.await(5, TimeUnit.SECONDS)); - - // Timed-out close: hands scratch ownership to the worker and - // returns. This is the last close() call this test makes. - manager.setWorkerJoinTimeoutMillis(50L); - manager.close(); - Thread worker = readWorkerThread(manager); - Assert.assertTrue("worker must still be live after the timed-out close", - worker != null && worker.isAlive()); - Assert.assertTrue("scratch must still be allocated while the worker may use it", - readPathScratchImpl(manager) != 0L); - - // Release the pass. running=false already, so the worker - // finishes the pass and exits — its exit block must free the - // handed-over scratch buffer without ANY further caller action. - releaseWorker.countDown(); - worker.join(TimeUnit.SECONDS.toMillis(10)); - Assert.assertFalse("worker never exited after release", worker.isAlive()); - Assert.assertEquals( - "worker exit block must free the handed-over path scratch — no retried " - + "close() runs in production after a timed-out close, so leaving " - + "the free to a retry leaks the native buffer for the process " - + "lifetime", - 0L, readPathScratchImpl(manager)); - - // Reap the dead thread for tidiness; must not double-free. - manager.close(); - Assert.assertNull("retried close must reap the exited worker", - readWorkerThread(manager)); - if (hookErr.get() != null) { - throw new AssertionError("install hook failed", hookErr.get()); - } - } finally { - manager.setBeforeInstallSyncHook(null); - releaseWorker.countDown(); - manager.close(); - ring.close(); - } - }); - } - /** * Pins the {@link SegmentManager#deferUntilWorkerExit} handoff contract * that {@code CursorSendEngine.close()}'s slot-ownership transfer depends @@ -452,7 +232,7 @@ public void testDeferUntilWorkerExitRunsCleanupAfterFinalPass() throws Exception manager.deferUntilWorkerExit(cleanupRan::countDown)); manager.register(ring, slot); - manager.setBeforeInstallSyncHook(() -> { + manager.setBeforeTrimSyncHook(() -> { if (!fired.compareAndSet(false, true)) return; workerBlocked.countDown(); try { @@ -497,7 +277,7 @@ public void testDeferUntilWorkerExitRunsCleanupAfterFinalPass() throws Exception throw new AssertionError("install hook failed", hookErr.get()); } } finally { - manager.setBeforeInstallSyncHook(null); + manager.setBeforeTrimSyncHook(null); releaseWorker.countDown(); if (!managerClosed) { Thread.interrupted(); @@ -534,7 +314,7 @@ public void testAwaitRingQuiescenceBlocksWhileServicePassInFlight() throws Excep boolean managerClosed = false; try { manager.register(ring, slot); - manager.setBeforeInstallSyncHook(() -> { + manager.setBeforeTrimSyncHook(() -> { if (!fired.compareAndSet(false, true)) return; workerBlocked.countDown(); try { @@ -572,7 +352,7 @@ public void testAwaitRingQuiescenceBlocksWhileServicePassInFlight() throws Excep throw new AssertionError("install hook failed", hookErr.get()); } } finally { - manager.setBeforeInstallSyncHook(null); + manager.setBeforeTrimSyncHook(null); releaseWorker.countDown(); if (!managerClosed) { Thread.interrupted(); @@ -602,7 +382,7 @@ public void testInterruptedCallerDoesNotAbandonReapableWorker() throws Exception boolean managerClosed = false; try { manager.register(ring, slot); - manager.setBeforeInstallSyncHook(() -> { + manager.setBeforeTrimSyncHook(() -> { if (!fired.compareAndSet(false, true)) return; workerBlocked.countDown(); try { @@ -651,7 +431,7 @@ public void testInterruptedCallerDoesNotAbandonReapableWorker() throws Exception throw new AssertionError("install hook failed", hookErr.get()); } } finally { - manager.setBeforeInstallSyncHook(null); + manager.setBeforeTrimSyncHook(null); releaseWorker.countDown(); if (!managerClosed) { Thread.interrupted(); @@ -685,10 +465,6 @@ private static void cleanupRecursively(String dir) { } } - private static Object readHotSpare(SegmentRing ring) { - return ring.getHotSpareForTesting(); - } - private static Object readInServiceRing(SegmentManager manager) { return manager.getInServiceRingForTesting(); } @@ -789,10 +565,6 @@ public void testSecondBoundedJoinReapsWorkerFinishingExitCleanups() throws Excep worker.join(TimeUnit.SECONDS.toMillis(5)); Assert.assertFalse("worker must be dead after the second join reaped it", worker.isAlive()); - Assert.assertEquals( - "close() must free the path scratch itself after the second join " - + "confirmed termination (no handoff on this branch)", - 0L, readPathScratchImpl(manager)); if (err.get() != null) { throw new AssertionError("async participant failed", err.get()); } @@ -808,10 +580,6 @@ public void testSecondBoundedJoinReapsWorkerFinishingExitCleanups() throws Excep }); } - private static long readPathScratchImpl(SegmentManager manager) { - return manager.isPathScratchAllocatedForTesting() ? 1L : 0L; - } - private static Thread readWorkerThread(SegmentManager manager) { return manager.getWorkerThreadForTesting(); } diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerCrashConsistencyTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerCrashConsistencyTest.java index 1758f8f8e..4fae01c50 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerCrashConsistencyTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerCrashConsistencyTest.java @@ -64,10 +64,10 @@ private static SegmentRing createRing(String root, long segmentSize, long payloa ring = new SegmentRing(MmapSegment.create(root + "/sf-0.sfa", 0, segmentSize), segmentSize); Assert.assertEquals(0L, ring.appendOrFsn(payload, 1)); for (int i = 1; i <= sealedCount; i++) { - ring.installHotSpare(MmapSegment.create(root + "/sf-" + i + ".sfa", i, segmentSize)); + QueueSegmentSource.install(ring, MmapSegment.create(root + "/sf-" + i + ".sfa", i, segmentSize)); Assert.assertEquals(i, ring.appendOrFsn(payload, 1)); } - ring.installHotSpare(MmapSegment.create(root + "/sf-" + (sealedCount + 1) + ".sfa", + QueueSegmentSource.install(ring, MmapSegment.create(root + "/sf-" + (sealedCount + 1) + ".sfa", sealedCount + 1L, segmentSize)); Assert.assertTrue(ring.acknowledge(sealedCount - 1L)); success = true; diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerPeriodicSyncTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerPeriodicSyncTest.java index 4b833e2ab..9e080f64b 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerPeriodicSyncTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerPeriodicSyncTest.java @@ -58,7 +58,7 @@ public void testDeadlineAndFailurePropagation() throws Exception { MmapSegment active = MmapSegment.create( filesFacade, dir + "/active.sfa", 0L, segmentSize); ring = new SegmentRing(active, segmentSize); - ring.installHotSpare(MmapSegment.create( + QueueSegmentSource.install(ring, MmapSegment.create( filesFacade, dir + "/spare.sfa", 1L, segmentSize)); assertEquals(0L, ring.appendOrFsn(payload, 16)); @@ -144,7 +144,7 @@ public void testRotationGateDefersRotationUntilPredecessorDurable() throws Excep MmapSegment active = MmapSegment.create( filesFacade, dir + "/active.sfa", 0L, segmentSize); ring = new SegmentRing(active, segmentSize); - ring.installHotSpare(MmapSegment.create( + QueueSegmentSource.install(ring, MmapSegment.create( filesFacade, dir + "/spare.sfa", 2L, segmentSize)); manager = new SegmentManager( segmentSize, @@ -243,7 +243,7 @@ public void testSyncPassStopsAtFirstFailureThenRetryCoversAllSegments() throws E // Pre-install the hot spare so the service pass does not // provision one mid-test: its header write would pollute the // exact barrier-call accounting below. - ring.installHotSpare(MmapSegment.create( + QueueSegmentSource.install(ring, MmapSegment.create( filesFacade, dir + "/spare.sfa", 3L, segmentSize)); manager = new SegmentManager( @@ -397,7 +397,7 @@ public void testTransientSyncFailureClearsOnNextSuccess() throws Exception { MmapSegment active = MmapSegment.create( filesFacade, dir + "/active.sfa", 0L, segmentSize); ring = new SegmentRing(active, segmentSize); - ring.installHotSpare(MmapSegment.create( + QueueSegmentSource.install(ring, MmapSegment.create( filesFacade, dir + "/spare.sfa", 1L, segmentSize)); assertEquals(0L, ring.appendOrFsn(payload, 16)); @@ -544,7 +544,7 @@ public void testConsumedErrorRetryClearsLatchOnlyOverRedirtiedPages() throws Exc MmapSegment active = MmapSegment.create( filesFacade, dir + "/active.sfa", 0L, segmentSize); ring = new SegmentRing(active, segmentSize); - ring.installHotSpare(MmapSegment.create( + QueueSegmentSource.install(ring, MmapSegment.create( filesFacade, dir + "/spare.sfa", 1L, segmentSize)); assertEquals(0L, ring.appendOrFsn(payload, 16)); diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerPinnedTrimTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerPinnedTrimTest.java index 2311b5030..260bb83f6 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerPinnedTrimTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerPinnedTrimTest.java @@ -42,7 +42,7 @@ public void testPinnedActiveSurvivesRotationUntilIoRelease() throws Exception { Assert.assertNotNull(pinned); Assert.assertTrue(ring.acknowledge(0L)); - ring.installHotSpare(MmapSegment.createInMemory(1L, segmentSize)); + QueueSegmentSource.install(ring, MmapSegment.createInMemory(1L, segmentSize)); Assert.assertEquals(1L, ring.appendOrFsn(payload, 1)); Assert.assertSame(pinned, ring.firstSealed()); diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerProvisionRetryTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerProvisionRetryTest.java deleted file mode 100644 index d46dde19e..000000000 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerProvisionRetryTest.java +++ /dev/null @@ -1,176 +0,0 @@ -/******************************************************************************* - * ___ _ ____ ____ - * / _ \ _ _ ___ ___| |_| _ \| __ ) - * | | | | | | |/ _ \/ __| __| | | | _ \ - * | |_| | |_| | __/\__ \ |_| |_| | |_) | - * \__\_\\__,_|\___||___/\__|____/|____/ - * - * Copyright (c) 2014-2019 Appsicle - * Copyright (c) 2019-2026 QuestDB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - ******************************************************************************/ - -package io.questdb.client.test.cutlass.qwp.client.sf.cursor; - -import io.questdb.client.cutlass.qwp.client.sf.cursor.MmapSegment; -import io.questdb.client.cutlass.qwp.client.sf.cursor.SegmentBudget; -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.FilesFacade; -import io.questdb.client.std.MemoryTag; -import io.questdb.client.std.Unsafe; -import io.questdb.client.test.tools.DelegatingFilesFacade; -import io.questdb.client.test.tools.TestUtils; -import org.junit.Assert; -import org.junit.Test; - -import java.io.File; -import java.nio.file.Files; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicLong; - -/** - * The two ways a ring gets its spare after a refusal: the pass whose own trim - * frees the budget re-provisions at once, and a failed create is retried on - * the timer with no further event. The second guards the timer against - * deletion -- without it a transient allocation failure wedges the producer. - */ -public class SegmentManagerProvisionRetryTest { - private static final int FRAME_PAYLOAD = 64; - // Exactly two frames per segment. - private static final long SEGMENT_SIZE = MmapSegment.HEADER_SIZE - + 2 * (MmapSegment.FRAME_HEADER_SIZE + FRAME_PAYLOAD); - - @Test - public void testPassWhoseTrimFreesBudgetInstallsSpareInSamePass() throws Exception { - TestUtils.assertMemoryLeak(() -> { - SegmentBudget budget = new SegmentBudget(2 * SEGMENT_SIZE); - long buf = Unsafe.malloc(FRAME_PAYLOAD, MemoryTag.NATIVE_DEFAULT); - try (SegmentRing ring = new SegmentRing(MmapSegment.createInMemory(0, SEGMENT_SIZE), SEGMENT_SIZE); - SegmentManager manager = new SegmentManager(SEGMENT_SIZE, budget)) { - // Stopped manager: every pass is driven by hand, so no timer can - // disguise a stall. - manager.register(ring, null); - manager.serviceRingForTesting(ring); - Assert.assertFalse(ring.needsHotSpare()); - Assert.assertEquals(2 * SEGMENT_SIZE, budget.getSegmentBytes()); - - // Third frame rotates into the spare and seals the first segment. - Assert.assertEquals(0L, ring.appendOrFsn(buf, FRAME_PAYLOAD)); - Assert.assertEquals(1L, ring.appendOrFsn(buf, FRAME_PAYLOAD)); - Assert.assertEquals(2L, ring.appendOrFsn(buf, FRAME_PAYLOAD)); - Assert.assertTrue(ring.needsHotSpare()); - - // One pass: refused on the cap, then trims the acked segment. - Assert.assertTrue(ring.acknowledge(1L)); - manager.serviceRingForTesting(ring); - Assert.assertFalse("the pass whose trim freed the budget must install the spare", - ring.needsHotSpare()); - Assert.assertEquals(2 * SEGMENT_SIZE, budget.getSegmentBytes()); - - manager.deregister(ring); - Assert.assertEquals(0, budget.getSegmentBytes()); - } finally { - Unsafe.free(buf, FRAME_PAYLOAD, MemoryTag.NATIVE_DEFAULT); - } - }); - } - - /** - * A cap under two segments refuses every spare, so each one takes the - * liveness-floor path. A create that keeps failing then releases the floor - * charge, and that release must not wake the manager that was refused a - * moment earlier: it would unpark itself out of its own retry timer and - * spin, one create attempt per loop pass instead of one per 10 ms. - */ - @Test - public void testFailedFloorCreateDoesNotWakeItsOwnRetry() throws Exception { - TestUtils.assertMemoryLeak(() -> { - File dir = Files.createTempDirectory("qdb-segmgr-floor-spin").toFile(); - AtomicLong attempts = new AtomicLong(); - FilesFacade facade = new DelegatingFilesFacade() { - @Override - public boolean allocate(int fd, long size) { - attempts.incrementAndGet(); - return false; - } - }; - String path = new File(dir, "sf-0000000000000000.sfa").getPath(); - SegmentBudget budget = new SegmentBudget(SEGMENT_SIZE + SEGMENT_SIZE / 2); - try (SegmentRing ring = new SegmentRing(MmapSegment.create(path, 0, SEGMENT_SIZE), SEGMENT_SIZE); - SegmentManager manager = new SegmentManager(SEGMENT_SIZE, budget, facade, System::nanoTime)) { - manager.start(); - manager.register(ring, dir.getPath()); - Thread.sleep(300); - long n = attempts.get(); - Assert.assertTrue("ring must still be waiting for a spare", ring.needsHotSpare()); - Assert.assertTrue("expected about 30 create attempts in 300 ms on the retry timer, got " + n - + ": the failed floor charge's release woke the worker itself", n < 100); - Assert.assertTrue("the retry timer must keep the create attempts coming, got " + n, n >= 5); - manager.deregister(ring); - Assert.assertTrue(manager.awaitRingQuiescence(ring)); - } finally { - File[] files = dir.listFiles(); - if (files != null) { - for (File f : files) { - f.delete(); - } - } - dir.delete(); - } - }); - } - - @Test - public void testFailedCreateIsRetriedWithNoFurtherEvent() throws Exception { - TestUtils.assertMemoryLeak(() -> { - File dir = Files.createTempDirectory("qdb-segmgr-provision-retry").toFile(); - AtomicBoolean failNextAllocate = new AtomicBoolean(true); - FilesFacade facade = new DelegatingFilesFacade() { - @Override - public boolean allocate(int fd, long size) { - return !failNextAllocate.compareAndSet(true, false) && super.allocate(fd, size); - } - }; - String path = new File(dir, "sf-0000000000000000.sfa").getPath(); - try (SegmentRing ring = new SegmentRing(MmapSegment.create(path, 0, SEGMENT_SIZE), SEGMENT_SIZE); - SegmentManager manager = new SegmentManager(SEGMENT_SIZE, 8 * SEGMENT_SIZE, facade)) { - manager.start(); - // The ring holds only its active segment: no ACK, no rotation and - // no pin move will ever wake the worker. Only the timer can. - manager.register(ring, dir.getPath()); - long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); - while (ring.needsHotSpare()) { - Assert.assertTrue("a failed create must be retried with no further event", - System.nanoTime() < deadline); - Thread.sleep(1); - } - Assert.assertFalse("the first create must have been the failed one", failNextAllocate.get()); - manager.deregister(ring); - Assert.assertTrue(manager.awaitRingQuiescence(ring)); - } finally { - // Best effort: a cleanup assertion here would mask the real failure. - File[] files = dir.listFiles(); - if (files != null) { - for (File f : files) { - f.delete(); - } - } - dir.delete(); - } - }); - } -} diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerSharedBudgetTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerSharedBudgetTest.java index db342e4c1..aa621f34d 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerSharedBudgetTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerSharedBudgetTest.java @@ -58,8 +58,8 @@ public void testConcurrentManagersStayWithinBudgetAndReleaseEveryCharge() throws TestUtils.assertMemoryLeak(() -> { // Producers on four managers race for one budget while acks trim behind // them. tryCharge must never let the budget exceed its capacity by more - // than the minimum working sets the managers grant unconditionally, and - // once every ring is deregistered nothing may remain charged. + // than the minimum working sets the provisioners grant unconditionally, + // and once every ring is deregistered nothing may remain charged. final int ringCount = 4; final SegmentBudget budget = new SegmentBudget(6 * SEGMENT_SIZE); final long bound = budget.getCapacityBytes() + ringCount * 2 * SEGMENT_SIZE; @@ -163,9 +163,9 @@ public void testRingsOfDifferentManagersShareOneBudget() throws Exception { Assert.assertEquals(4 * SEGMENT_SIZE, budget.getSegmentBytes()); // A ring on ANOTHER manager finds the budget spent. It still gets its - // minimum working set -- the active segment plus one spare, four - // frames -- but nothing beyond: a private budget of the same size - // would have let it grow to four segments of its own. + // minimum working set -- the active segment plus the one it rotates + // into, four frames -- but nothing beyond: a private budget of the + // same size would have let it grow to four segments of its own. siblingManager.register(sibling, null); appendFrames(sibling, buf, 4); assertCapped(sibling, buf); @@ -177,9 +177,7 @@ public void testRingsOfDifferentManagersShareOneBudget() throws Exception { // the room they leave -- up to the whole budget, and no further. busyManager.deregister(busy); Assert.assertTrue(busyManager.awaitRingQuiescence(busy)); - Assert.assertTrue("the sibling must get a spare once the budget has room", - waitFor(() -> !sibling.needsHotSpare())); - Assert.assertEquals(3 * SEGMENT_SIZE, budget.getSegmentBytes()); + Assert.assertEquals(2 * SEGMENT_SIZE, budget.getSegmentBytes()); appendFrames(sibling, buf, 4); assertCapped(sibling, buf); Assert.assertEquals(4 * SEGMENT_SIZE, sibling.totalSegmentBytes()); @@ -195,13 +193,12 @@ public void testRingsOfDifferentManagersShareOneBudget() throws Exception { } /** - * Room another manager frees must wake a manager the budget refused, not - * wait for a timer. The starved manager's clock runs backwards, which - * stretches any timer-driven provision retry to about ten seconds; only the - * wake can deliver the spare within the deadline. + * Room another manager frees is taken by a refused producer on its very + * next append: no wake, no timer, nothing for the starved ring's manager + * to do (its clock runs backwards so no timed pass can help). */ @Test - public void testSiblingDeregisterWakesRefusedManager() throws Exception { + public void testSiblingDeregisterFreesRoomForRefusedProducer() throws Exception { TestUtils.assertMemoryLeak(() -> { SegmentBudget budget = new SegmentBudget(4 * SEGMENT_SIZE); AtomicLong clock = new AtomicLong(); @@ -221,11 +218,8 @@ public void testSiblingDeregisterWakesRefusedManager() throws Exception { assertCapped(starved, buf); busyManager.deregister(busy); - long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2); - while (starved.needsHotSpare() && System.nanoTime() < deadline) { - Thread.sleep(1); - } - Assert.assertFalse("freed budget did not wake the refused manager", starved.needsHotSpare()); + Assert.assertTrue("freed budget must let the refused producer rotate at once", + starved.appendOrFsn(buf, FRAME_PAYLOAD) >= 0); Assert.assertTrue(busyManager.awaitRingQuiescence(busy)); starvedManager.deregister(starved); Assert.assertTrue(starvedManager.awaitRingQuiescence(starved)); @@ -249,10 +243,7 @@ private static void appendFrames(SegmentRing ring, long buf, int frames) throws } } - private static void assertCapped(SegmentRing ring, long buf) throws InterruptedException { - // Ample time for a manager worker to (wrongly) provision past the budget. - Thread.sleep(100); - Assert.assertTrue("the budget must refuse this ring another spare", ring.needsHotSpare()); + private static void assertCapped(SegmentRing ring, long buf) { Assert.assertEquals(SegmentRing.BACKPRESSURE_NO_SPARE, ring.appendOrFsn(buf, FRAME_PAYLOAD)); } diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerSideFileCapTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerSideFileCapTest.java index ff759ad70..c12e33248 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerSideFileCapTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerSideFileCapTest.java @@ -38,16 +38,15 @@ import org.junit.Test; import java.nio.file.Paths; -import java.util.concurrent.TimeUnit; /** * Regression for P-C8: the {@code sf_max_total_bytes} cap check compared * {@code .sfa} segment bytes only, while the {@code .symbol-dict} side-file * is lifetime-monotonic on symbol-heavy workloads -- so a producer could - * fill the SF filesystem while the cap reported headroom. The manager now - * reads a per-slot side-file gauge at the provisioning cap check. The two + * fill the SF filesystem while the cap reported headroom. The budget now + * reads a per-slot side-file gauge at the rotation's cap check. The two * tests differ ONLY in the gauge, proving the gauge alone flips the - * provisioning decision. + * decision. */ public class SegmentManagerSideFileCapTest { @@ -73,10 +72,10 @@ public void testCapCountsSideFileBytesAgainstProvisioning() throws Exception { // Cap = 3 segments. The slot holds 2 recovered segments plus a // side-file gauge reporting one further segment's worth of // dictionary bytes: 2 x seg (segments) + 1 x seg (side-file) - // + 1 x seg (the spare under consideration) > cap, so the - // manager must refuse to provision. Pre-fix the gauge was - // invisible and the manager provisioned a third .sfa, taking - // real disk usage past the cap by the side-file's size. + // + 1 x seg (the rotation's segment) > cap, so the rotation + // must be refused. Pre-fix the gauge was invisible and a third + // .sfa was created, taking real disk usage past the cap by the + // side-file's size. long cap = 3 * SEGMENT_SIZE; prepopulate(slotDir, 2); SegmentRing ring = SegmentRing.openExisting(slotDir, SEGMENT_SIZE); @@ -84,17 +83,15 @@ public void testCapCountsSideFileBytesAgainstProvisioning() throws Exception { SegmentManager manager = new SegmentManager(SEGMENT_SIZE, new SegmentBudget(cap)); try (SegmentManager ignored = manager) { - manager.start(); manager.register(ring, slotDir, null, 0L, () -> SEGMENT_SIZE); Assert.assertEquals("accounted bytes must be segments + side-file gauge", 3 * SEGMENT_SIZE, manager.getCapAccountedBytesForTesting()); - Thread.sleep(100); + Assert.assertEquals("side-file bytes must count against sf_max_total_bytes -- " + + "pre-fix the cap check saw only .sfa bytes and created a " + + "segment past the cap", + SegmentRing.BACKPRESSURE_NO_SPARE, fillActiveAndRotate(ring)); } - - Assert.assertEquals("side-file bytes must count against sf_max_total_bytes -- " - + "pre-fix the cap check saw only .sfa bytes and provisioned " - + "past the cap", - 2, countSfaFiles(slotDir)); + Assert.assertEquals(2, countSfaFiles(slotDir)); ring.close(); }); } @@ -102,10 +99,10 @@ public void testCapCountsSideFileBytesAgainstProvisioning() throws Exception { @Test public void testControlWithoutSideFileGaugeProvisionsToTheCap() throws Exception { TestUtils.assertMemoryLeak(() -> { - // Identical setup, no gauge: 2 x seg + 1 x seg spare == cap - // (not above it), so the manager provisions exactly one spare - // and then stops. Pins that the refusal in the sibling test is - // caused by the gauge alone. + // Identical setup, no gauge: 2 x seg + 1 x seg == cap (not above + // it), so exactly one rotation fits and the next is refused. + // Pins that the refusal in the sibling test is caused by the + // gauge alone. long cap = 3 * SEGMENT_SIZE; prepopulate(slotDir, 2); SegmentRing ring = SegmentRing.openExisting(slotDir, SEGMENT_SIZE); @@ -113,19 +110,14 @@ public void testControlWithoutSideFileGaugeProvisionsToTheCap() throws Exception SegmentManager manager = new SegmentManager(SEGMENT_SIZE, new SegmentBudget(cap)); try (SegmentManager ignored = manager) { - manager.start(); manager.register(ring, slotDir, null, 0L, null); - // Poll instead of a fixed sleep: this is a must-provision assertion, and - // a flat 100ms sleep flakes on a loaded CI box where the worker's 1ms tick - // gets delayed past the sleep window. Bound the poll with a deadline so a - // genuine regression still fails instead of hanging. - long deadlineNanos = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); - while (countSfaFiles(slotDir) != 3 && System.nanoTime() < deadlineNanos) { - Thread.sleep(5); - } + Assert.assertTrue("without the gauge the rotation must fit under the cap", + fillActiveAndRotate(ring) >= 0); + Assert.assertEquals(3, countSfaFiles(slotDir)); + Assert.assertEquals("the cap is now full", + SegmentRing.BACKPRESSURE_NO_SPARE, fillActiveAndRotate(ring)); } - - Assert.assertEquals("without the gauge the manager must fill the cap exactly", + Assert.assertEquals("without the gauge the producer must fill the cap exactly", 3, countSfaFiles(slotDir)); ring.close(); }); @@ -136,12 +128,12 @@ public void testLivenessFloorProvisionsDespiteSideFileBytesOverTheCap() throws E TestUtils.assertMemoryLeak(() -> { // The dictionary is lifetime-monotonic and no trim reclaims it, so a // side-file that alone eats the cap used to hold the ring at ONE - // segment forever: no spare, no rotation, and no ack could ever free - // the shortfall -- a permanent stall that survived restarts, while - // the disk-full warning pointed at a trim that cannot help. The cap - // must still guarantee each ring its minimum working set (the active - // segment plus one spare) and exceed itself by the dictionary's - // overshoot instead. + // segment forever: no rotation, and no ack could ever free the + // shortfall -- a permanent stall that survived restarts, while the + // disk-full warning pointed at a trim that cannot help. The cap + // must still guarantee each ring its minimum working set (the + // active segment plus the one it rotates into) and exceed itself + // by the dictionary's overshoot instead. long cap = 3 * SEGMENT_SIZE; prepopulate(slotDir, 1); SegmentRing ring = SegmentRing.openExisting(slotDir, SEGMENT_SIZE); @@ -149,33 +141,44 @@ public void testLivenessFloorProvisionsDespiteSideFileBytesOverTheCap() throws E SegmentManager manager = new SegmentManager(SEGMENT_SIZE, new SegmentBudget(cap)); try (SegmentManager ignored = manager) { - manager.start(); // 10 segments' worth of dictionary against a 3-segment cap: no - // arithmetic makes this fit, so only the floor can provision. + // arithmetic makes this fit, so only the floor can allow it. manager.register(ring, slotDir, null, 0L, () -> 10 * SEGMENT_SIZE); - long deadlineNanos = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); - while (countSfaFiles(slotDir) < 2 && System.nanoTime() < deadlineNanos) { - Thread.sleep(5); - } - Assert.assertEquals("a ring below its minimum working set must be provisioned " - + "even when non-reclaimable side-file bytes have eaten the cap", - 2, countSfaFiles(slotDir)); + Assert.assertTrue("a ring below its minimum working set must rotate even when " + + "non-reclaimable side-file bytes have eaten the cap", + fillActiveAndRotate(ring) >= 0); + Assert.assertEquals(2, countSfaFiles(slotDir)); // ...and the floor is a floor, not a bypass: once the working set // is whole, the cap governs again. - Thread.sleep(100); - Assert.assertEquals("the floor must not license unbounded provisioning past the cap", - 2, countSfaFiles(slotDir)); + Assert.assertEquals("the floor must not license unbounded creation past the cap", + SegmentRing.BACKPRESSURE_NO_SPARE, fillActiveAndRotate(ring)); + Assert.assertEquals(2, countSfaFiles(slotDir)); } ring.close(); }); } /** - * Pre-populates {@code dir} with {@code n} valid {@code .sfa} segment - * files, each containing one frame so {@link SegmentRing#openExisting} - * doesn't filter them as empty orphans. Each segment's baseSeq is - * positioned so the contiguity check in {@code openExisting} passes. + * Appends 16 KiB frames until the active segment is full and the next + * append needs a rotation; returns that append's result (the new FSN, + * or BACKPRESSURE_NO_SPARE when the rotation was refused). */ + private static long fillActiveAndRotate(SegmentRing ring) { + int len = 16 * 1024; + long buf = Unsafe.malloc(len, MemoryTag.NATIVE_DEFAULT); + try { + MmapSegment active = ring.getActive(); + while (true) { + long fsn = ring.appendOrFsn(buf, len); + if (fsn < 0 || ring.getActive() != active) { + return fsn; + } + } + } finally { + Unsafe.free(buf, len, MemoryTag.NATIVE_DEFAULT); + } + } + private static void prepopulate(String dir, int n) { long buf = Unsafe.malloc(64, MemoryTag.NATIVE_DEFAULT); try { diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerTest.java index f8a206f36..336bd59a1 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerTest.java @@ -94,26 +94,28 @@ public void testAckBatchDoesNotDelaySiblingSpareProvisioning() throws Exception SegmentManager manager = new SegmentManager(segSize)) { for (int i = 0; i < sealedCount; i++) { assertEquals(i, busyRing.appendOrFsn(buf, 1)); - busyRing.installHotSpare(MmapSegment.createInMemory(busyRing.nextSeqHint(), segSize)); + QueueSegmentSource.install(busyRing, MmapSegment.createInMemory(busyRing.nextSeqHint(), segSize)); } assertEquals(sealedCount, busyRing.appendOrFsn(buf, 1)); - busyRing.installHotSpare(MmapSegment.createInMemory(busyRing.nextSeqHint(), segSize)); + QueueSegmentSource.install(busyRing, MmapSegment.createInMemory(busyRing.nextSeqHint(), segSize)); busyRing.acknowledge(sealedCount - 1L); manager.register(busyRing, null); manager.register(siblingRing, null); - manager.setBeforeInstallSyncHook(() -> { - try { - assertEquals("the busy ring must stop at the 64-segment quantum before sibling service", - 64, busyRing.getSealedSegments().size()); - } catch (Throwable t) { - failure.compareAndSet(null, t); - } finally { - siblingInstall.countDown(); - } - }); manager.setBeforeTrimSyncHook(() -> { - if (trimPass.incrementAndGet() == 4) { + int pass = trimPass.incrementAndGet(); + if (pass == 2) { + // The sibling's turn, right after the busy ring's first quantum. + try { + assertEquals("the busy ring must stop at the 64-segment quantum before sibling service", + 64, busyRing.getSealedSegments().size()); + } catch (Throwable t) { + failure.compareAndSet(null, t); + } finally { + siblingInstall.countDown(); + } + } + if (pass == 4) { try { assertEquals("the next fair pass must fully drain the busy ring", 0, busyRing.getSealedSegments().size()); @@ -125,7 +127,7 @@ public void testAckBatchDoesNotDelaySiblingSpareProvisioning() throws Exception } }); manager.start(); - assertTrue("manager did not reach sibling spare provisioning", + assertTrue("manager did not reach the sibling ring", siblingInstall.await(10, TimeUnit.SECONDS)); assertTrue("manager parked instead of immediately rescheduling the remaining ACK batch", busyDrained.await(10, TimeUnit.SECONDS)); @@ -138,24 +140,6 @@ public void testAckBatchDoesNotDelaySiblingSpareProvisioning() throws Exception }); } - @Test - public void testManagerProvisionsSpareWithinPollingTick() throws Exception { - TestUtils.assertMemoryLeak(() -> { - long segSize = MmapSegment.HEADER_SIZE - + 4 * (MmapSegment.FRAME_HEADER_SIZE + 32); - MmapSegment seg0 = MmapSegment.create(tmpDir + "/0000000000000000.sfa", 0, segSize); - try (SegmentRing ring = new SegmentRing(seg0, segSize); - SegmentManager mgr = new SegmentManager(segSize)) { - mgr.start(); - mgr.register(ring, tmpDir); - - // Wait for the manager to install a spare. Should happen within ~ms. - assertTrue("manager should install hot spare within 2 seconds", - waitFor(() -> !ring.needsHotSpare(), 2000)); - } - }); - } - @Test public void testProducerCanRotateAcrossManySegmentsWithoutBackpressure() throws Exception { TestUtils.assertMemoryLeak(() -> { @@ -181,7 +165,7 @@ public void testProducerCanRotateAcrossManySegmentsWithoutBackpressure() throws // BACKPRESSURE_NO_SPARE — wait for the manager to catch up. if (System.nanoTime() > deadline) { throw new AssertionError( - "stuck waiting for spare at i=" + i + ", needsSpare=" + ring.needsHotSpare()); + "stuck waiting for a segment at i=" + i); } io.questdb.client.std.Compat.onSpinWait(); } @@ -210,8 +194,6 @@ public void testManagerTrimsAckedSegmentFiles() throws Exception { // Fill seg0 (2 frames) and force rotation by appending a third. for (int i = 0; i < 2; i++) ring.appendOrFsn(buf, 32); - // Wait for the spare for seg1 to land. - assertTrue(waitFor(() -> !ring.needsHotSpare(), 2000)); ring.appendOrFsn(buf, 32); // FSN 2, rotates active to seg1 assertTrue("seg0 should still exist before ack", Files.exists(seg0Path)); @@ -251,8 +233,6 @@ public void testRotationSpareCarriesManifestRequiredFlagOnDisk() throws Exceptio // Fill seg0 (2 frames) and force rotation by appending a third. for (int i = 0; i < 2; i++) ring.appendOrFsn(buf, 32); - assertTrue("spare must land before rotation", - waitFor(() -> !ring.needsHotSpare(), 2000)); ring.appendOrFsn(buf, 32); // FSN 2, rotates active to the spare MmapSegment spare = ring.getActive(); @@ -276,7 +256,7 @@ public void testMaxTotalBytesCapBlocksProvisioningUntilTrimFrees() throws Except + 2 * (MmapSegment.FRAME_HEADER_SIZE + 64); // Cap = 3 segments total. The ring's initial active counts toward // the cap (counted at register-time), so this leaves headroom for - // exactly 2 manager-provisioned spares before backpressure kicks in. + // exactly 2 rotations before backpressure kicks in. long cap = 3 * segSize; MmapSegment seg0 = MmapSegment.create(tmpDir + "/0000000000000000.sfa", 0, segSize); long buf = Unsafe.malloc(64, MemoryTag.NATIVE_DEFAULT); @@ -288,155 +268,33 @@ public void testMaxTotalBytesCapBlocksProvisioningUntilTrimFrees() throws Except // register seeds totalBytes = 1*segSize (initial active). mgr.register(ring, tmpDir, watermark); - // Manager provisions spare 1 → totalBytes = 2*segSize. - assertTrue(waitFor(() -> !ring.needsHotSpare(), 2000)); - // Fill initial (becomes sealed), rotate to spare 1. + // Fill initial (becomes sealed), rotate into segment 2 → 2*segSize. ring.appendOrFsn(buf, 64); ring.appendOrFsn(buf, 64); - ring.appendOrFsn(buf, 64); // forces rotation - // Manager provisions spare 2 → totalBytes = 3*segSize. At cap. - assertTrue(waitFor(() -> !ring.needsHotSpare(), 2000)); - // Fill spare 1 (becomes sealed), rotate to spare 2. + assertEquals(2L, ring.appendOrFsn(buf, 64)); // rotation 1 + assertEquals(2 * segSize, mgr.getTotalBytesForTesting()); + // Fill segment 2, rotate into segment 3 → 3*segSize. At cap. + ring.appendOrFsn(buf, 64); + assertEquals(4L, ring.appendOrFsn(buf, 64)); // rotation 2 + assertEquals(3 * segSize, mgr.getTotalBytesForTesting()); + // Fill segment 3. The next rotation would make 4*segSize > cap: + // refused, and the producer is backpressured. ring.appendOrFsn(buf, 64); - ring.appendOrFsn(buf, 64); // forces rotation again - // Manager would provision spare 3 → would be 4*segSize > cap. Refused. - // The ring should sit in needsHotSpare=true indefinitely. - // Verify: after ample time, still no spare. - Thread.sleep(150); - assertTrue("manager must respect cap and not provision spare 3", ring.needsHotSpare()); - // Producer's appendOrFsn must report backpressure. - ring.appendOrFsn(buf, 64); // fills the second-to-last slot of spare 2 - ring.appendOrFsn(buf, 64); // fills the last slot, spare 2 now full assertEquals(SegmentRing.BACKPRESSURE_NO_SPARE, ring.appendOrFsn(buf, 64)); + assertEquals(3 * segSize, mgr.getTotalBytesForTesting()); - // Now ACK enough frames to make the oldest sealed segment trimmable. - // The initial held FSN 0..1 (2 frames). ACK frame 1 → initial trims. + // ACK the initial segment's frames (FSN 0..1): the manager trims it + // and the once-stuck producer's rotation now fits under the cap. ring.acknowledge(1L); - // The manager should trim → totalBytes drops by 1*segSize → headroom - // for one more spare → spare 3 gets installed. - assertTrue("manager must provision a spare once trim freed space", - waitFor(() -> !ring.needsHotSpare(), 2000)); - // And the once-stuck producer's append now succeeds. - assertNotEquals(SegmentRing.BACKPRESSURE_NO_SPARE, - ring.appendOrFsn(buf, 64)); + assertTrue("the rotation must succeed once trim freed space", + waitFor(() -> ring.appendOrFsn(buf, 64) >= 0, 2000)); + assertEquals(3 * segSize, mgr.getTotalBytesForTesting()); } finally { Unsafe.free(buf, 64, MemoryTag.NATIVE_DEFAULT); } }); } - @Test - public void testFirstSpareLandsBeforeFirstPoll() throws Exception { - TestUtils.assertMemoryLeak(() -> { - // The worker never polls, so the first spare can only arrive via a - // wakeup. register() unparks the worker after publishing the new ring, - // so the worker re-iterates and provisions the spare even when - // its first loop snapshot ran before register() acquired `lock`. - // The spare must therefore land within seconds of register(), - // not minutes -- the 5s park is never reached. - // - // The append below is incidental to the contract under test; it - // does NOT cross the SegmentRing high-water mark for this 4-frame - // segment (HEADER_SIZE 24 + FRAME_HEADER_SIZE 8 + 16 = 48 vs - // signalAtBytes = (120 >> 2) * 3 = 90), so no producer-side wakeup - // fires. The rotation/high-water wakeup paths are covered by - // testRotationWakeupTriggersImmediateSparePrep, and the - // deterministic register-after-park case is covered by - // testRegisterAfterWorkerParkedWakesWorker. - long segSize = MmapSegment.HEADER_SIZE - + 4 * (MmapSegment.FRAME_HEADER_SIZE + 16); - MmapSegment seg0 = MmapSegment.create(tmpDir + "/0000000000000000.sfa", 0, segSize); - long buf = Unsafe.malloc(16, MemoryTag.NATIVE_DEFAULT); - try (SegmentRing ring = new SegmentRing(seg0, segSize); - SegmentManager mgr = new SegmentManager(segSize)) { - mgr.start(); - mgr.register(ring, tmpDir); - long t0 = System.nanoTime(); - ring.appendOrFsn(buf, 16); - // 2s is the budget used across the other manager tests; it - // tolerates the slower file-open + truncate + mmap that runs - // under JaCoCo coverage instrumentation while still being - // orders of magnitude below the 5s poll interval. - assertTrue("first spare must land without waiting for the 5s poll tick", - waitFor(() -> !ring.needsHotSpare(), 2000)); - long elapsedMs = (System.nanoTime() - t0) / 1_000_000L; - assertTrue("spare arrived in " + elapsedMs + "ms -- should be <<5000ms", - elapsedMs < 4000); - } finally { - Unsafe.free(buf, 16, MemoryTag.NATIVE_DEFAULT); - } - }); - } - - @Test - public void testRegisterAfterWorkerParkedWakesWorker() throws Exception { - TestUtils.assertMemoryLeak(() -> { - // Deterministic version of testFirstSpareLandsBeforeFirstPoll: - // sleep between start() and register() long enough for the worker - // to definitely complete its first (empty) iteration and enter - // parkNanos. Without register()'s wakeWorker() the spare would - // not land for the full 5s poll interval; with it the spare lands - // promptly because register() unparks the worker out of its park. - // No append at all, so no producer-side wakeup can mask a missing - // register-side wakeup. - long segSize = MmapSegment.HEADER_SIZE - + 4 * (MmapSegment.FRAME_HEADER_SIZE + 16); - MmapSegment seg0 = MmapSegment.create(tmpDir + "/0000000000000000.sfa", 0, segSize); - try (SegmentRing ring = new SegmentRing(seg0, segSize); - SegmentManager mgr = new SegmentManager(segSize)) { - mgr.start(); - // Give the worker plenty of time to enter workerLoop, snapshot - // an empty rings list, and reach parkNanos. 250ms is far more - // than the OS scheduling + thread startup cost on any sane - // CI runner, and still well below the 5s poll interval. - Thread.sleep(250); - long t0 = System.nanoTime(); - mgr.register(ring, tmpDir); - assertTrue("register must wake a worker that has already parked", - waitFor(() -> !ring.needsHotSpare(), 2000)); - long elapsedMs = (System.nanoTime() - t0) / 1_000_000L; - assertTrue("spare arrived in " + elapsedMs + "ms -- should be <<5000ms", - elapsedMs < 4000); - } - }); - } - - @Test - public void testRotationWakeupTriggersImmediateSparePrep() throws Exception { - TestUtils.assertMemoryLeak(() -> { - // Segment small enough that one frame fills it; verifies that the - // post-rotation wakeup runs before the next 5s poll. - long segSize = MmapSegment.HEADER_SIZE - + (MmapSegment.FRAME_HEADER_SIZE + 16); - MmapSegment seg0 = MmapSegment.create(tmpDir + "/0000000000000000.sfa", 0, segSize); - long buf = Unsafe.malloc(16, MemoryTag.NATIVE_DEFAULT); - try (SegmentRing ring = new SegmentRing(seg0, segSize); - SegmentManager mgr = new SegmentManager(segSize)) { - mgr.start(); - mgr.register(ring, tmpDir); - // First spare via high-water signal on the very first append. - // 2000ms budget: same wakeup-vs-poll rationale as - // testProducerWakeupBeatsThePollInterval -- still 2.5x below - // the 5s poll, but tolerant of CI scheduling jitter. - ring.appendOrFsn(buf, 16); - assertTrue(waitFor(() -> !ring.needsHotSpare(), 2_000)); - // Now active is full → next append rotates → consumes the spare → - // hotSpare goes back to null → rotation-time wakeup runs → - // manager promptly provisions the next spare. - long beforeRotate = System.nanoTime(); - long fsn = ring.appendOrFsn(buf, 16); - assertEquals(1, fsn); - assertTrue("rotation-time wakeup must trigger spare 2 well before 5s poll", - waitFor(() -> !ring.needsHotSpare(), 2_000)); - long elapsedMs = (System.nanoTime() - beforeRotate) / 1_000_000L; - assertTrue("spare 2 arrived in " + elapsedMs + "ms — should be <<5000ms", - elapsedMs < 4_000); - } finally { - Unsafe.free(buf, 16, MemoryTag.NATIVE_DEFAULT); - } - }); - } - @Test public void testCloseStopsWorkerAndIsIdempotent() throws Exception { TestUtils.assertMemoryLeak(() -> { @@ -470,12 +328,7 @@ public void testMultipleRingsServedByOneManager() throws Exception { mgr.register(ringB, dirB); mgr.register(ringC, dirC); - assertTrue("ringA spare", waitFor(() -> !ringA.needsHotSpare(), 2000)); - assertTrue("ringB spare", waitFor(() -> !ringB.needsHotSpare(), 2000)); - assertTrue("ringC spare", waitFor(() -> !ringC.needsHotSpare(), 2000)); - - // Deregister B. After deregister, B's spare-installation pipeline - // halts — but B still owns whatever spare the manager already gave it. + // Deregister B; A and C stay registered until close. mgr.deregister(ringB); } finally { Files.remove(dirA); diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerTotalBytesRaceTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerTotalBytesRaceTest.java deleted file mode 100644 index 6a77f50d9..000000000 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerTotalBytesRaceTest.java +++ /dev/null @@ -1,225 +0,0 @@ -/*+***************************************************************************** - * ___ _ ____ ____ - * / _ \ _ _ ___ ___| |_| _ \| __ ) - * | | | | | | |/ _ \/ __| __| | | | _ \ - * | |_| | |_| | __/\__ \ |_| |_| | |_) | - * \__\_\\__,_|\___||___/\__|____/|____/ - * - * Copyright (c) 2014-2019 Appsicle - * Copyright (c) 2019-2026 QuestDB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - ******************************************************************************/ - -package io.questdb.client.test.cutlass.qwp.client.sf.cursor; - -import io.questdb.client.cutlass.qwp.client.sf.cursor.MmapSegment; -import io.questdb.client.cutlass.qwp.client.sf.cursor.SegmentBudget; -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.test.tools.TestUtils; -import org.junit.After; -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.AtomicBoolean; -import java.util.concurrent.atomic.AtomicReference; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertTrue; - -/** - * Deterministic test for {@code SegmentManager.totalBytes} drift on the - * install path of {@code serviceRing}. The bug: between the worker's - * "decide to install a spare" and "commit the +segmentSize under lock", a - * concurrent {@code deregister(ring)} would subtract the ring's bytes (which - * at that moment don't include the in-flight spare) and the worker would - * still commit, inflating {@code totalBytes} by one segment per occurrence - * with no future subtractor. - * - *

    Drives the race with the {@code beforeInstallSyncHook} seam on - * {@code SegmentManager}, which fires on the worker thread immediately - * before the install block's {@code synchronized(lock)}. The hook performs - * the deregister synchronously; when the worker subsequently enters the - * synchronized block, the stillRegistered re-check sees the entry removed - * and skips the (otherwise drifting) install + commit. - * - *

    Concurrent but non-racy: {@code mgr.register} and - * {@code setBeforeInstallSyncHook} both run before {@code mgr.start}, so - * the worker's first iteration deterministically observes the registered - * ring (with {@code hotSpare == null}, so {@code needsHotSpare()} is true) - * and the hook. The hook fires exactly once and signals the test via a - * {@code CountDownLatch}; the test then waits for the worker to park - * before reading {@code totalBytes}. - * - *

    Pre-fix the test ends with {@code totalBytes > 0}; post-fix it ends - * at exactly {@code 0}. - */ -public class SegmentManagerTotalBytesRaceTest { - - private String tmpDir; - - @Before - public void setUp() { - tmpDir = Paths.get(System.getProperty("java.io.tmpdir"), - "qdb-segmgr-install-race-" + System.nanoTime()).toString(); - assertEquals(0, Files.mkdir(tmpDir, Files.DIR_MODE_DEFAULT)); - } - - @After - public void tearDown() { - if (tmpDir == null) return; - rmDirRecursive(tmpDir); - } - - @Test(timeout = 15_000L) - public void testInstallPathDoesNotCommitAfterDeregister() throws Exception { - TestUtils.assertMemoryLeak(() -> { - long segSize = MmapSegment.HEADER_SIZE + (MmapSegment.FRAME_HEADER_SIZE + 32); - long maxTotal = segSize * 8L; - - try (SegmentManager mgr = new SegmentManager( - segSize, new SegmentBudget(maxTotal))) { - String dir = tmpDir + "/single-ring"; - assertEquals(0, Files.mkdir(dir, Files.DIR_MODE_DEFAULT)); - String activePath = dir + "/sf-initial.sfa"; - MmapSegment seg0 = MmapSegment.create(activePath, 0L, segSize); - SegmentRing ring = new SegmentRing(seg0, segSize); - - try { - // Register + read totalBytes BEFORE the worker exists. - // No worker thread means readTotalBytes here is the - // pure effect of register: exactly one initial segment. - mgr.register(ring, dir); - long bytesAfterRegister = readTotalBytes(mgr); - assertEquals("register should account for the initial segment", - segSize, bytesAfterRegister); - - // Hook fires on the worker thread immediately before the - // install block's synchronized(lock) — i.e. AFTER the - // worker has snapshotted observedTotal, dropped the lock, - // and finished MmapSegment.create, but BEFORE it tries to - // commit +segmentSize. The hook deregisters the ring - // synchronously, then the worker enters the lock, the - // stillRegistered check sees the entry removed, and skips - // the install + commit. Without the guard the worker - // would still commit and drift totalBytes by +segSize. - CountDownLatch hookDone = new CountDownLatch(1); - AtomicBoolean fired = new AtomicBoolean(); - AtomicReference hookErr = new AtomicReference<>(); - mgr.setBeforeInstallSyncHook(() -> { - if (!fired.compareAndSet(false, true)) return; - try { - mgr.deregister(ring); - } catch (Throwable t) { - hookErr.compareAndSet(null, t); - } finally { - hookDone.countDown(); - } - }); - - // Concurrent but non-racy: register + hook are visible - // before the worker exists (Thread.start() establishes - // a happens-before edge). hotSpare is null at construction, - // so needsHotSpare() returns true on the worker's first - // and only relevant iteration -- no producer-side append - // needed to trigger the install. The hook then fires - // exactly once and hookDone signals the test. - mgr.start(); - - assertTrue("install hook never fired", - hookDone.await(5, TimeUnit.SECONDS)); - if (hookErr.get() != null) { - throw new AssertionError("install hook failed", hookErr.get()); - } - - // Wait for the worker to park again. With the entry - // deregistered, the next loop iteration finds rings - // empty and parks until the next wakeup. That parked - // state is the test's signal that - // the worker has finished serviceRing -- including the - // stillRegistered re-check and the (skipped) commit -- - // so readTotalBytes below observes the final state. - Thread worker = workerThread(mgr); - awaitParked(worker); - - long observed = readTotalBytes(mgr); - assertEquals("totalBytes drifted away from 0. Observed " - + observed + " (segSize=" + segSize + ", " - + "bytesAfterRegister=" + bytesAfterRegister + "). " - + "Positive drift means SegmentManager.serviceRing's " - + "install block committed +segmentSize after deregister " - + "had already subtracted ring.totalSegmentBytes(): no " - + "stillRegistered guard. Fix: gate installHotSpare + " - + "totalBytes += segmentSize on a stillRegistered re-check " - + "under the same lock that covers deregister.", - 0L, observed); - } finally { - mgr.setBeforeInstallSyncHook(null); - try { - ring.close(); - } catch (Throwable ignored) { - // best-effort - } - } - } - }); - } - - private static void awaitParked(Thread t) { - long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); - while (true) { - Thread.State s = t.getState(); - if (s == Thread.State.TIMED_WAITING || s == Thread.State.WAITING) return; - if (System.nanoTime() > deadline) { - throw new AssertionError("worker did not park within 5 s; state=" + s); - } - io.questdb.client.std.Compat.onSpinWait(); - } - } - - private static long readTotalBytes(SegmentManager mgr) { - return mgr.getTotalBytesForTesting(); - } - - private static void rmDirRecursive(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)) { - rmDirRecursive(child); - } - } - rc = Files.findNext(find); - } - } finally { - Files.findClose(find); - } - } - Files.remove(dir); - } - - private static Thread workerThread(SegmentManager mgr) { - return mgr.getWorkerThreadForTesting(); - } -} diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerTrimDeregisterRaceTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerTrimDeregisterRaceTest.java index 0758d866d..b51f4ec75 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerTrimDeregisterRaceTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerTrimDeregisterRaceTest.java @@ -110,14 +110,12 @@ segSize, new SegmentBudget(maxTotal))) { // provision a spare; wait for that spare so the next // append can rotate. ring.appendOrFsn(buf, 32); - awaitSpare(ring, "after seg0 fill"); // Rotate: seg0 joins sealedSegments, the spare becomes // active. Worker auto-wakes again to provision the next // spare; whether that completes before we install the // hook does not matter for the test (the hook fires only // on a worker tick we trigger explicitly below). ring.appendOrFsn(buf, 32); - awaitSpare(ring, "after rotation"); // FSN 0 acked: seg0 (baseSeq=0, lastSeq=0) is now // trimmable. Acknowledge does NOT wake the worker, so the // trim does not happen until we wakeWorker() below. @@ -213,16 +211,6 @@ private static void awaitParked(Thread t) { } } - private static void awaitSpare(SegmentRing ring, String where) { - long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); - while (ring.needsHotSpare()) { - if (System.nanoTime() > deadline) { - throw new AssertionError("spare never installed " + where); - } - io.questdb.client.std.Compat.onSpinWait(); - } - } - private static long readTotalBytes(SegmentManager mgr) { return mgr.getTotalBytesForTesting(); } diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerUnlinkFailureTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerUnlinkFailureTest.java index 6f8ca792d..40a741d34 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerUnlinkFailureTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerUnlinkFailureTest.java @@ -121,10 +121,10 @@ public void testFailedUnlinkIsRetriedWithoutExternalWakeup() throws Exception { try { fill(payload, 32, (byte) 0x11); Assert.assertEquals(0L, ring.appendOrFsn(payload, 32)); - ring.installHotSpare(MmapSegment.create(dir + "/sf-0000000000000001.sfa", 1L, segmentSize)); + QueueSegmentSource.install(ring, MmapSegment.create(dir + "/sf-0000000000000001.sfa", 1L, segmentSize)); Assert.assertEquals(1L, ring.appendOrFsn(payload, 32)); // A spare is already in place, so provisioning schedules no retry of its own. - ring.installHotSpare(MmapSegment.create(dir + "/sf-0000000000000002.sfa", 2L, segmentSize)); + QueueSegmentSource.install(ring, MmapSegment.create(dir + "/sf-0000000000000002.sfa", 2L, segmentSize)); ring.acknowledge(0L); FailingFilesFacade facade = new FailingFilesFacade(failedPath, null, null); try (SegmentManager manager = new SegmentManager(segmentSize, segmentSize * 8, facade)) { @@ -160,8 +160,11 @@ public void testFailedUnlinkRetainsBookkeepingAndUsesSuccessorPath() throws Exce try { fill(payload, 32, (byte) 0x11); Assert.assertEquals(0L, ring.appendOrFsn(payload, 32)); - ring.installHotSpare(MmapSegment.create(activePath, 1L, segmentSize)); + QueueSegmentSource.install(ring, MmapSegment.create(activePath, 1L, segmentSize)); Assert.assertEquals(1L, ring.appendOrFsn(payload, 32)); + // Hand-fed segments end here: the successor below must come from + // the manager's provisioner, named past the generations on disk. + ring.setSegmentSource(null); ring.acknowledge(0L); watermark.write(0L); // what CursorSendEngine.acknowledge does on the I/O thread byte[] original = java.nio.file.Files.readAllBytes(Paths.get(failedPath)); diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerWatermarkDeregisterRaceTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerWatermarkDeregisterRaceTest.java deleted file mode 100644 index 211afadd2..000000000 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerWatermarkDeregisterRaceTest.java +++ /dev/null @@ -1,191 +0,0 @@ -/******************************************************************************* - * ___ _ ____ ____ - * / _ \ _ _ ___ ___| |_| _ \| __ ) - * | | | | | | |/ _ \/ __| __| | | | _ \ - * | |_| | |_| | __/\__ \ |_| |_| | |_) | - * \__\_\\__,_|\___||___/\__|____/|____/ - * - * Copyright (c) 2014-2019 Appsicle - * Copyright (c) 2019-2026 QuestDB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - ******************************************************************************/ - -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.MemoryTag; -import io.questdb.client.std.Unsafe; -import io.questdb.client.test.tools.TestUtils; -import org.junit.After; -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.AtomicBoolean; -import java.util.concurrent.atomic.AtomicReference; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertTrue; - -/** - * Deterministic regression for the stale snapshot path where the manager worker - * keeps servicing a snapshotted ring entry after - * {@link SegmentManager#deregister(SegmentRing)} has removed it from the live - * registry. - * - *

    This is intentionally a crash-capable regression. The worker is stopped - * at the stale-snapshot window, the test deregisters the ring and releases the - * watermark mapping, then the worker is allowed to continue. Correct code - * observes {@code registered=false} and never calls {@code watermark.write()}. - * Buggy code writes through the stale {@link AckWatermark} object into an - * unmapped page and should take down the test JVM. - */ -public class SegmentManagerWatermarkDeregisterRaceTest { - - private String tmpDir; - - @Before - public void setUp() { - tmpDir = Paths.get(System.getProperty("java.io.tmpdir"), - "qdb-segmgr-watermark-race-" + System.nanoTime()).toString(); - assertEquals(0, Files.mkdir(tmpDir, Files.DIR_MODE_DEFAULT)); - } - - @After - public void tearDown() { - if (tmpDir == null) return; - rmDirRecursive(tmpDir); - } - - @Test(timeout = 15_000L) - public void testStaleWorkerDoesNotWriteThroughUnmappedWatermarkAfterDeregister() throws Exception { - TestUtils.assertMemoryLeak(() -> { - long segSize = MmapSegment.HEADER_SIZE - + 4 * (MmapSegment.FRAME_HEADER_SIZE + 32); - String slot = tmpDir + "/single-ring"; - assertEquals(0, Files.mkdir(slot, Files.DIR_MODE_DEFAULT)); - - MmapSegment seg0 = MmapSegment.create(slot + "/sf-initial.sfa", 0L, segSize); - SegmentRing ring = new SegmentRing(seg0, segSize); - AckWatermark watermark = AckWatermark.open(slot); - assertNotNull(watermark); - SegmentManager mgr = new SegmentManager(segSize); - long buf = Unsafe.malloc(32, MemoryTag.NATIVE_DEFAULT); - boolean managerClosed = false; - boolean watermarkStorageReleased = false; - CountDownLatch workerAtStaleSnapshot = new CountDownLatch(1); - CountDownLatch resumeWorker = new CountDownLatch(1); - try { - Unsafe.getUnsafe().putInt(buf, 42); - assertEquals(0L, ring.appendOrFsn(buf, 32)); - assertTrue("precondition: ack must advance", - ring.acknowledge(0L)); - - mgr.register(ring, slot, watermark); - - AtomicBoolean fired = new AtomicBoolean(); - AtomicReference hookErr = new AtomicReference<>(); - mgr.setBeforeInstallSyncHook(() -> { - if (!fired.compareAndSet(false, true)) return; - try { - workerAtStaleSnapshot.countDown(); - if (!resumeWorker.await(10, TimeUnit.SECONDS)) { - hookErr.compareAndSet(null, - new AssertionError("timed out waiting for test to release stale worker")); - } - } catch (Throwable t) { - hookErr.compareAndSet(null, t); - } - }); - - // The first manager tick snapshots the registered entry, creates - // a spare, and stops before the install block. The main test - // thread now performs the production-close sequence's dangerous - // part: deregister, then release the engine-owned watermark - // mapping while the stale worker snapshot is still live. - mgr.start(); - - assertTrue("install hook never fired", - workerAtStaleSnapshot.await(5, TimeUnit.SECONDS)); - mgr.deregister(ring); - // Do not call watermark.close(): synchronizing the worker with - // a latch after close() would give it a happens-before edge to - // closed=true, masking the original bug's plain-boolean guard. - // Releasing the mmap/fd directly leaves the object in the stale - // state that a racing worker is allowed to observe. - watermarkStorageReleased = - releaseWatermarkStorageButLeaveObjectWritable(watermark); - resumeWorker.countDown(); - if (hookErr.get() != null) { - throw new AssertionError("install hook failed", hookErr.get()); - } - mgr.close(); - managerClosed = true; - } finally { - mgr.setBeforeInstallSyncHook(null); - if (!watermarkStorageReleased) { - resumeWorker.countDown(); - } - if (!managerClosed) { - mgr.close(); - } - try { - ring.close(); - } catch (Throwable ignored) { - // best-effort - } - try { - watermark.close(); - } catch (Throwable ignored) { - // best-effort - } - Unsafe.free(buf, 32, MemoryTag.NATIVE_DEFAULT); - } - }); - } - - private static boolean releaseWatermarkStorageButLeaveObjectWritable(AckWatermark watermark) { - return watermark.releaseStorageButKeepWritableForTesting(); - } - - private static void rmDirRecursive(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)) { - rmDirRecursive(child); - } - } - rc = Files.findNext(find); - } - } finally { - Files.findClose(find); - } - } - Files.remove(dir); - } -} diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentProvisionerTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentProvisionerTest.java new file mode 100644 index 000000000..2591896a0 --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentProvisionerTest.java @@ -0,0 +1,196 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +package io.questdb.client.test.cutlass.qwp.client.sf.cursor; + +import io.questdb.client.cutlass.qwp.client.sf.cursor.MmapSegment; +import io.questdb.client.cutlass.qwp.client.sf.cursor.SegmentBudget; +import io.questdb.client.cutlass.qwp.client.sf.cursor.SegmentManager; +import io.questdb.client.cutlass.qwp.client.sf.cursor.SegmentProvisioner; +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 io.questdb.client.test.tools.DelegatingFilesFacade; +import io.questdb.client.test.tools.TestUtils; +import org.junit.Assert; +import org.junit.Test; + +import java.io.File; +import java.nio.file.Paths; +import java.util.concurrent.atomic.AtomicLong; + +/** + * The producer creates the segment it rotates into, on its own thread, at + * rotation: the budget cap and the liveness floor are applied there, a failed + * create backpressures the producer and is retried on a timer, and nothing + * about any of it involves the manager worker. + */ +public class SegmentProvisionerTest { + private static final int FRAME_PAYLOAD = 64; + // Exactly two frames per segment. + private static final long SEGMENT_SIZE = MmapSegment.HEADER_SIZE + + 2 * (MmapSegment.FRAME_HEADER_SIZE + FRAME_PAYLOAD); + + /** A stopped manager: no worker exists, so only the producer can create segments. */ + @Test + public void testRotationCreatesSegmentOnProducerThreadWithinCap() throws Exception { + TestUtils.assertMemoryLeak(() -> { + SegmentBudget budget = new SegmentBudget(3 * SEGMENT_SIZE); + long buf = Unsafe.malloc(FRAME_PAYLOAD, MemoryTag.NATIVE_DEFAULT); + try (SegmentRing ring = new SegmentRing(MmapSegment.createInMemory(0, SEGMENT_SIZE), SEGMENT_SIZE); + SegmentManager manager = new SegmentManager(SEGMENT_SIZE, budget)) { + manager.register(ring, null); + Assert.assertEquals(SEGMENT_SIZE, budget.getSegmentBytes()); + Assert.assertEquals(0L, ring.appendOrFsn(buf, FRAME_PAYLOAD)); + Assert.assertEquals(1L, ring.appendOrFsn(buf, FRAME_PAYLOAD)); + Assert.assertEquals("third frame rotates into a segment the producer created", + 2L, ring.appendOrFsn(buf, FRAME_PAYLOAD)); + Assert.assertEquals(2L, ring.getActive().baseSeq()); + Assert.assertEquals(2 * SEGMENT_SIZE, budget.getSegmentBytes()); + Assert.assertEquals(3L, ring.appendOrFsn(buf, FRAME_PAYLOAD)); + Assert.assertEquals(4L, ring.appendOrFsn(buf, FRAME_PAYLOAD)); + Assert.assertEquals(3 * SEGMENT_SIZE, budget.getSegmentBytes()); + Assert.assertEquals(5L, ring.appendOrFsn(buf, FRAME_PAYLOAD)); + // The fourth segment would exceed the cap; the ring is above the floor. + Assert.assertEquals(SegmentRing.BACKPRESSURE_NO_SPARE, ring.appendOrFsn(buf, FRAME_PAYLOAD)); + Assert.assertEquals(3 * SEGMENT_SIZE, budget.getSegmentBytes()); + // One driven trim pass frees the acked first segment; the retry fits. + Assert.assertTrue(ring.acknowledge(1L)); + manager.serviceRingForTesting(ring); + Assert.assertEquals(2 * SEGMENT_SIZE, budget.getSegmentBytes()); + Assert.assertEquals(6L, ring.appendOrFsn(buf, FRAME_PAYLOAD)); + Assert.assertEquals(3 * SEGMENT_SIZE, budget.getSegmentBytes()); + manager.deregister(ring); + Assert.assertEquals(0, budget.getSegmentBytes()); + } finally { + Unsafe.free(buf, FRAME_PAYLOAD, MemoryTag.NATIVE_DEFAULT); + } + }); + } + + /** + * A ring holding only its active segment is charged past the cap: what + * fills the budget then is nothing this ring's ACKs can shrink. + */ + @Test + public void testRingBelowLivenessFloorIsChargedPastCap() throws Exception { + TestUtils.assertMemoryLeak(() -> { + SegmentBudget budget = new SegmentBudget(SEGMENT_SIZE + SEGMENT_SIZE / 2); + long buf = Unsafe.malloc(FRAME_PAYLOAD, MemoryTag.NATIVE_DEFAULT); + try (SegmentRing ring = new SegmentRing(MmapSegment.createInMemory(0, SEGMENT_SIZE), SEGMENT_SIZE); + SegmentManager manager = new SegmentManager(SEGMENT_SIZE, budget)) { + manager.register(ring, null); + Assert.assertEquals(0L, ring.appendOrFsn(buf, FRAME_PAYLOAD)); + Assert.assertEquals(1L, ring.appendOrFsn(buf, FRAME_PAYLOAD)); + Assert.assertEquals("one active segment is below the floor: rotate past the cap", + 2L, ring.appendOrFsn(buf, FRAME_PAYLOAD)); + Assert.assertEquals(2 * SEGMENT_SIZE, budget.getSegmentBytes()); + Assert.assertEquals(3L, ring.appendOrFsn(buf, FRAME_PAYLOAD)); + Assert.assertEquals("two segments meet the floor: the cap holds", + SegmentRing.BACKPRESSURE_NO_SPARE, ring.appendOrFsn(buf, FRAME_PAYLOAD)); + manager.deregister(ring); + Assert.assertEquals(0, budget.getSegmentBytes()); + } finally { + Unsafe.free(buf, FRAME_PAYLOAD, MemoryTag.NATIVE_DEFAULT); + } + }); + } + + /** + * A failing create backpressures the producer, releases its charge, and + * is retried on the timer rather than on every 50 µs poll. + */ + @Test + public void testFailedCreateIsRetriedOnTimerNotPerPoll() throws Exception { + TestUtils.assertMemoryLeak(() -> { + File dir = java.nio.file.Files.createTempDirectory("qdb-provisioner-fail").toFile(); + AtomicLong attempts = new AtomicLong(); + FilesFacade facade = new DelegatingFilesFacade() { + @Override + public boolean allocate(int fd, long size) { + attempts.incrementAndGet(); + return false; + } + }; + String path = new File(dir, "sf-0000000000000000.sfa").getPath(); + SegmentBudget budget = new SegmentBudget(8 * SEGMENT_SIZE); + long buf = Unsafe.malloc(FRAME_PAYLOAD, MemoryTag.NATIVE_DEFAULT); + try (SegmentRing ring = new SegmentRing(MmapSegment.create(path, 0, SEGMENT_SIZE), SEGMENT_SIZE); + SegmentManager manager = new SegmentManager(SEGMENT_SIZE, budget, facade, System::nanoTime)) { + manager.register(ring, dir.getPath()); + Assert.assertEquals(0L, ring.appendOrFsn(buf, FRAME_PAYLOAD)); + Assert.assertEquals(1L, ring.appendOrFsn(buf, FRAME_PAYLOAD)); + long end = System.nanoTime() + 300_000_000L; + while (System.nanoTime() < end) { + Assert.assertEquals(SegmentRing.BACKPRESSURE_NO_SPARE, ring.appendOrFsn(buf, FRAME_PAYLOAD)); + Assert.assertEquals("a failed create must release its charge", SEGMENT_SIZE, budget.getSegmentBytes()); + java.util.concurrent.locks.LockSupport.parkNanos(50_000L); + } + long n = attempts.get(); + Assert.assertTrue("expected about 30 create attempts in 300 ms, got " + n, n >= 5 && n < 100); + Assert.assertEquals("no stray segment file may survive a failed create", + 1, dir.list().length); + manager.deregister(ring); + } finally { + Unsafe.free(buf, FRAME_PAYLOAD, MemoryTag.NATIVE_DEFAULT); + File[] files = dir.listFiles(); + if (files != null) { + for (File f : files) { + f.delete(); + } + } + dir.delete(); + } + }); + } + + /** Disk mode: the file is generation-named past what the slot already holds, with the final base. */ + @Test + public void testDiskRotationNamesFilePastExistingGenerations() throws Exception { + TestUtils.assertMemoryLeak(() -> { + String dir = Paths.get(System.getProperty("java.io.tmpdir"), "qdb-provisioner-" + System.nanoTime()).toString(); + Assert.assertEquals(0, Files.mkdir(dir, Files.DIR_MODE_DEFAULT)); + long buf = Unsafe.malloc(FRAME_PAYLOAD, MemoryTag.NATIVE_DEFAULT); + try (SegmentRing ring = new SegmentRing(MmapSegment.create(dir + "/sf-0000000000000007.sfa", 0, SEGMENT_SIZE), SEGMENT_SIZE); + SegmentManager manager = new SegmentManager(SEGMENT_SIZE)) { + manager.register(ring, dir); + Assert.assertEquals(0L, ring.appendOrFsn(buf, FRAME_PAYLOAD)); + Assert.assertEquals(1L, ring.appendOrFsn(buf, FRAME_PAYLOAD)); + Assert.assertEquals(2L, ring.appendOrFsn(buf, FRAME_PAYLOAD)); + Assert.assertEquals(dir + "/sf-0000000000000008.sfa", ring.getActive().path()); + Assert.assertEquals(2L, ring.getActive().baseSeq()); + Assert.assertTrue(Files.exists(dir + "/sf-0000000000000008.sfa")); + manager.deregister(ring); + } finally { + Unsafe.free(buf, FRAME_PAYLOAD, MemoryTag.NATIVE_DEFAULT); + for (File f : new File(dir).listFiles()) { + f.delete(); + } + Files.remove(dir); + } + }); + } +} diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentRingAckOffMonitorTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentRingAckOffMonitorTest.java index b8dedda56..a59145ac6 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentRingAckOffMonitorTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentRingAckOffMonitorTest.java @@ -73,7 +73,7 @@ public void testAcknowledgeDoesNotWaitForManifestFsyncHeldUnderRingMonitor() thr Assert.assertNotNull("legacy slot must recover", ring); long payload = Unsafe.malloc(PAYLOAD, MemoryTag.NATIVE_DEFAULT); try { - ring.installHotSpare(MmapSegment.create(tmpDir + "/sf-0000000000000004.sfa", 4L, SEGMENT_SIZE)); + QueueSegmentSource.install(ring, MmapSegment.create(tmpDir + "/sf-0000000000000004.sfa", 4L, SEGMENT_SIZE)); // Fill the active: seqs 1..3. The next append rotates. for (int i = 0; i < 3; i++) { Assert.assertTrue(ring.appendOrFsn(payload, PAYLOAD) >= 0); diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentRingTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentRingTest.java index ea3bab329..f04d48cc7 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentRingTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentRingTest.java @@ -124,12 +124,12 @@ public void testRotationConsumesHotSpare() throws Exception { // Active is now full. Without a spare, append must report backpressure. assertEquals(SegmentRing.BACKPRESSURE_NO_SPARE, ring.appendOrFsn(buf, 100)); - assertTrue("ring should be asking for a spare", ring.needsHotSpare()); + assertTrue("ring has no segment to rotate into", QueueSegmentSource.isEmpty(ring)); // Manager installs a fresh spare with the right baseSeq. MmapSegment spare = MmapSegment.create(tmpDir + "/seg1.sfa", ring.nextSeqHint(), segSize); - ring.installHotSpare(spare); + QueueSegmentSource.install(ring, spare); // Now the same append succeeds, and FSN keeps incrementing across // segment boundaries (no reset to 0 in the new segment). @@ -137,8 +137,8 @@ public void testRotationConsumesHotSpare() throws Exception { // didn't burn an FSN, so this one is FSN 2. assertEquals(2, ring.appendOrFsn(buf, 100)); assertEquals(2, ring.publishedFsn()); - // After the rotation succeeded, ring should ask for the next spare. - assertTrue(ring.needsHotSpare()); + // The rotation consumed the queued segment. + assertTrue(QueueSegmentSource.isEmpty(ring)); } } finally { Unsafe.free(buf, 100, MemoryTag.NATIVE_DEFAULT); @@ -158,7 +158,7 @@ public void testOversizedAppendLeavesActiveAndSpareUntouched() throws Exception fillPattern(buf, 101, 0); assertEquals(0, ring.appendOrFsn(buf, 100)); MmapSegment spare = MmapSegment.create(tmpDir + "/seg1.sfa", 1, segSize); - ring.installHotSpare(spare); + QueueSegmentSource.install(ring, spare); // A payload that no segment can hold must not seal the active and // consume the spare: that rotation publishes nothing, so nothing // wakes the I/O thread to trim the sealed segment, and every retry @@ -167,7 +167,7 @@ public void testOversizedAppendLeavesActiveAndSpareUntouched() throws Exception assertEquals(SegmentRing.PAYLOAD_TOO_LARGE, ring.appendOrFsn(buf, 101)); } assertSame(seg0, ring.getActive()); - assertSame(spare, ring.getHotSpareForTesting()); + assertSame(spare, QueueSegmentSource.peek(ring)); assertEquals(0, ring.getSealedSegments().size()); assertEquals(0, ring.publishedFsn()); assertEquals(1, ring.appendOrFsn(buf, 100)); @@ -196,7 +196,7 @@ public void testRotationRebasesSpareToCorrectFsnRegardlessOfManagerGuess() throw assertEquals(0, ring.appendOrFsn(buf, 64)); // active full // Manager guessed baseSeq=999 long before the active filled. MmapSegment lateSpare = MmapSegment.create(tmpDir + "/lateseg.sfa", 999, segSize); - ring.installHotSpare(lateSpare); + QueueSegmentSource.install(ring, lateSpare); // Rotation must rebase the spare to baseSeq=1 (the actual nextSeq). assertEquals(1, ring.appendOrFsn(buf, 64)); assertEquals(1, ring.publishedFsn()); @@ -208,67 +208,6 @@ public void testRotationRebasesSpareToCorrectFsnRegardlessOfManagerGuess() throw }); } - @Test - public void testRotationRearmsHighWaterBackupWakeup() throws Exception { - // The producer-thread flag that gates the high-water backup wakeup is - // per-active. Rotation must reset it so the new active can fire the - // backup again if the rotation-time wakeup didn't get a fresh spare - // installed in time. Pre-fix the flag stayed sticky-true after the - // first set, so on every active past the first the backup branch was - // a dead path. - TestUtils.assertMemoryLeak(() -> { - // 4 100-byte frames per segment. signalAtBytes is 75% of segSize, - // and HEADER (24) + 3 frames (3*108 = 324) lands publishedOffset - // at 348, just past the 342-byte threshold. - long segSize = MmapSegment.HEADER_SIZE - + 4 * (MmapSegment.FRAME_HEADER_SIZE + 100); - long buf = Unsafe.malloc(100, MemoryTag.NATIVE_DEFAULT); - try { - MmapSegment seg0 = MmapSegment.create(tmpDir + "/wseg0.sfa", 0, segSize); - try (SegmentRing ring = new SegmentRing(seg0, segSize)) { - int[] wakeups = {0}; - ring.setManagerWakeup(() -> wakeups[0]++); - fillPattern(buf, 100, 0); - - // First active: two frames stay below high-water. - assertEquals(0, ring.appendOrFsn(buf, 100)); - assertEquals(1, ring.appendOrFsn(buf, 100)); - assertEquals("no wakeup before high-water", 0, wakeups[0]); - // Third frame crosses 75%: backup branch fires once. - assertEquals(2, ring.appendOrFsn(buf, 100)); - assertEquals("backup signal fires on high-water crossing", 1, wakeups[0]); - // Fourth frame fills the active. Still same active, so the - // backup branch must coalesce and not fire again. - assertEquals(3, ring.appendOrFsn(buf, 100)); - assertEquals("backup signal coalesces within an active", 1, wakeups[0]); - // Active is full and there is still no spare. - assertEquals(SegmentRing.BACKPRESSURE_NO_SPARE, - ring.appendOrFsn(buf, 100)); - assertEquals("backpressure does not fire wakeup", 1, wakeups[0]); - - // Install spare, then retry. The retry triggers rotation, - // which fires the wakeup unconditionally. - ring.installHotSpare(MmapSegment.create( - tmpDir + "/wseg1.sfa", ring.nextSeqHint(), segSize)); - assertEquals(4, ring.appendOrFsn(buf, 100)); - assertEquals("rotation fires wakeup", 2, wakeups[0]); - - // New active. Two frames keep publishedOffset below the - // high-water mark (the rotated frame counts as the first). - assertEquals(5, ring.appendOrFsn(buf, 100)); - assertEquals(2, wakeups[0]); - // Third frame on the new active crosses 75% again. Without - // rotation re-arming the per-active flag, this assertion - // catches the regression: pre-fix wakeups stayed at 2. - assertEquals(6, ring.appendOrFsn(buf, 100)); - assertEquals("backup signal re-arms on the new active", 3, wakeups[0]); - } - } finally { - Unsafe.free(buf, 100, MemoryTag.NATIVE_DEFAULT); - } - }); - } - @Test public void testDurabilityFrontierSurvivesSealedListCompaction() throws Exception { // The periodic durability frontier (firstNonDurableSealed) is an @@ -294,7 +233,7 @@ public void testDurabilityFrontierSurvivesSealedListCompaction() throws Exceptio // fill the active until it is full } assertEquals(SegmentRing.BACKPRESSURE_NO_SPARE, r); - ring.installHotSpare(MmapSegment.create( + QueueSegmentSource.install(ring, MmapSegment.create( tmpDir + "/c" + (i + 1) + ".sfa", ring.nextSeqHint(), segSize)); assertTrue("retry must rotate", ring.appendOrFsn(buf, 16) >= 0); } @@ -341,10 +280,10 @@ public void testAcknowledgeAndDrainTrimsOldestFirstUntilUnackedFound() throws Ex // Fill seg0 (FSN 0..3). for (int i = 0; i < 4; i++) ring.appendOrFsn(buf, 16); // Spare for seg1 (FSN 4..7). - ring.installHotSpare(MmapSegment.create(tmpDir + "/t1.sfa", 4, segSize)); + QueueSegmentSource.install(ring, MmapSegment.create(tmpDir + "/t1.sfa", 4, segSize)); for (int i = 0; i < 4; i++) ring.appendOrFsn(buf, 16); // Spare for seg2 (FSN 8..11). - ring.installHotSpare(MmapSegment.create(tmpDir + "/t2.sfa", 8, segSize)); + QueueSegmentSource.install(ring, MmapSegment.create(tmpDir + "/t2.sfa", 8, segSize)); for (int i = 0; i < 4; i++) ring.appendOrFsn(buf, 16); // No acks yet — nothing to trim. @@ -466,7 +405,7 @@ public void testResealedTornRecoveredSegmentDoesNotBrickNextRecovery() throws Ex assertEquals(2, ring.nextSeqHint()); assertEquals(2, ring.appendOrFsn(buf, 16)); assertEquals(3, ring.appendOrFsn(buf, 16)); - ring.installHotSpare(MmapSegment.create(tmpDir + "/r1.sfa", 4, segSize)); + QueueSegmentSource.install(ring, MmapSegment.create(tmpDir + "/r1.sfa", 4, segSize)); assertEquals("append must rotate into the spare", 4, ring.appendOrFsn(buf, 16)); assertEquals(1, ring.getSealedSegments().size()); @@ -1201,63 +1140,7 @@ public void testAcknowledgeClampsAtPublishedFsn() throws Exception { } /** - * The I/O thread sends a frame as soon as its bytes lie below - * {@link MmapSegment#publishedOffset()}, and the server can ACK it before - * {@code appendOrFsn} returns. Because {@code acknowledge} clamps at - * {@code publishedFsn}, the ring must publish a frame's FSN no later than its bytes: - * an ACK clamped below a frame the I/O thread could already send is consumed and - * lost, and the server's cumulative ACKs never re-deliver it once the producer goes - * quiet. Losing the final frame's ACK this way would stall close() and drain() - * until their timeout. - *

    - * The high-water manager wakeup runs inside {@code appendOrFsn} once the appended - * frame's bytes are visible, so it observes the ring exactly where an I/O-thread - * ACK can race the publication: the wakeup plays the I/O thread and ACKs the - * newest frame visible to it. - */ - @Test - public void testAckOfFrameVisibleDuringAppendIsNotClampedAway() throws Exception { - TestUtils.assertMemoryLeak(() -> { - final int payloadLen = 32; - final long frameSize = MmapSegment.FRAME_HEADER_SIZE + payloadLen; - final long segSize = 64 * 1024; - long buf = Unsafe.malloc(payloadLen, MemoryTag.NATIVE_DEFAULT); - try { - fillPattern(buf, payloadLen, 0); - try (SegmentRing ring = new SegmentRing(MmapSegment.createInMemory(0L, segSize), segSize)) { - final long[] newestVisibleAtWakeup = {-1L}; - final long[] ackedAtWakeup = {-1L}; - final int[] wakeups = {0}; - ring.setManagerWakeup(() -> { - MmapSegment active = ring.getActive(); - long newestVisible = active.baseSeq() - + (active.publishedOffset() - MmapSegment.HEADER_SIZE) / frameSize - 1L; - ring.acknowledge(newestVisible); - newestVisibleAtWakeup[0] = newestVisible; - ackedAtWakeup[0] = ring.ackedFsn(); - wakeups[0]++; - }); - long fsn; - do { - fsn = ring.appendOrFsn(buf, payloadLen); - assertTrue("setup: append must succeed, got " + fsn, fsn >= 0); - } while (wakeups[0] == 0); - - assertEquals("setup: the high-water wakeup fires once per active", 1, wakeups[0]); - assertEquals("setup: the frame appended by the waking call is visible to the I/O thread", - fsn, newestVisibleAtWakeup[0]); - assertEquals("an ACK for a frame the I/O thread could already send must not be clamped away", - fsn, ackedAtWakeup[0]); - assertEquals(fsn, ring.ackedFsn()); - } - } finally { - Unsafe.free(buf, payloadLen, MemoryTag.NATIVE_DEFAULT); - } - }); - } - - /** - * Concurrent form of {@link #testAckOfFrameVisibleDuringAppendIsNotClampedAway}: an + * An * observer thread plays the I/O thread against a producer that appends across many * rotations, ACKing each frame the moment its bytes become visible below * {@link MmapSegment#publishedOffset()}. Every such ACK must land; none may be @@ -1308,7 +1191,7 @@ public void testAckOfFrameVisibleToConsumerLandsUnderConcurrentAppends() throws for (long i = 0; i < frameCount && failure.get() == null; i++) { long fsn; while ((fsn = ring.appendOrFsn(buf, payloadLen)) == SegmentRing.BACKPRESSURE_NO_SPARE) { - ring.installHotSpare(MmapSegment.createInMemory(ring.nextSeqHint(), segSize)); + QueueSegmentSource.install(ring, MmapSegment.createInMemory(ring.nextSeqHint(), segSize)); } assertEquals(i, fsn); } @@ -1358,7 +1241,7 @@ public void testNextSealedAfterWalksThousandsOfSegmentsInLinearOperations() thro MmapSegment spare = MmapSegment.create( tmpDir + "/seg-" + String.format("%04d", i + 1) + ".sfa", ring.nextSeqHint(), segSize); - ring.installHotSpare(spare); + QueueSegmentSource.install(ring, spare); } // After the loop we have `sealedCount` sealed segments and one // active (containing nothing yet — its base = sealedCount). @@ -1422,7 +1305,7 @@ public void testNextSealedAfterStillReturnsCorrectlyWhenCursorWasTrimmed() throw // Build sealed: [seg0, seg1, seg2, seg3]; active = seg4. for (int i = 0; i < 4; i++) { ring.appendOrFsn(buf, 16); - ring.installHotSpare(MmapSegment.create( + QueueSegmentSource.install(ring, MmapSegment.create( tmpDir + "/t-" + (i + 1) + ".sfa", ring.nextSeqHint(), segSize)); } MmapSegment seg0Snapshot = ring.firstSealed(); @@ -1457,7 +1340,7 @@ public void testNextSealedAfterSurvivesConcurrentRotationAndHeadTrim() throws Ex fillPattern(buf, 16, 0); for (int i = 0; i < 3; i++) { assertEquals(i, ring.appendOrFsn(buf, 16)); - ring.installHotSpare(MmapSegment.create( + QueueSegmentSource.install(ring, MmapSegment.create( tmpDir + "/race-" + (i + 1) + ".sfa", ring.nextSeqHint(), segSize)); } @@ -1689,7 +1572,7 @@ public void testRemovingAcknowledgedPrefixMovesLinearReferences() throws Excepti try (SegmentRing ring = new SegmentRing(initial, segSize)) { for (int i = 0; i < 2 * sealedCount; i++) { assertEquals(i, ring.appendOrFsn(buf, 1)); - ring.installHotSpare(MmapSegment.createInMemory(ring.nextSeqHint(), segSize)); + QueueSegmentSource.install(ring, MmapSegment.createInMemory(ring.nextSeqHint(), segSize)); } assertEquals(2L * sealedCount, ring.appendOrFsn(buf, 1)); ring.acknowledge(sealedCount - 1L); @@ -1836,8 +1719,8 @@ public void testFindSegmentContainingBoundaryMatrix() throws Exception { assertNull(ring.findSegmentContaining(0)); for (int i = 0; i < 12; i++) { - if (ring.needsHotSpare()) { - ring.installHotSpare( + if (QueueSegmentSource.isEmpty(ring)) { + QueueSegmentSource.install(ring, MmapSegment.createInMemory(ring.nextSeqHint(), segSize)); } assertEquals(i, ring.appendOrFsn(buf, 8)); @@ -1899,7 +1782,7 @@ public void testFindSegmentContainingSingleFrameSegments() throws Exception { try (SegmentRing ring = new SegmentRing(initial, segSize)) { for (int i = 0; i < 34; i++) { assertEquals(i, ring.appendOrFsn(buf, 1)); - ring.installHotSpare( + QueueSegmentSource.install(ring, MmapSegment.createInMemory(ring.nextSeqHint(), segSize)); } // Sealed: FSNs 0..32, one frame each; active: [33]. diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SymbolDictRecycleCrashWindowsTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SymbolDictRecycleCrashWindowsTest.java index fe9d08413..0e7ec7433 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SymbolDictRecycleCrashWindowsTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SymbolDictRecycleCrashWindowsTest.java @@ -180,7 +180,7 @@ public class SymbolDictRecycleCrashWindowsTest { */ private static final List FRESH_REBUILD_FILES = Arrays.asList( ".ack-watermark", ".lock", ".lock.pid", ".symbol-dict", - "sf-0000000000000000.sfa", "sf-initial.sfa", "sf-manifest.bin"); + "sf-initial.sfa", "sf-manifest.bin"); /** The reusable lock pair: never removed by any close() in this suite, and {@code .lock} is range-locked on Windows while a sender holds the slot. */ private static final List LOCK_FILES = Arrays.asList(".lock", ".lock.pid"); @@ -357,14 +357,6 @@ public void testCrashAfterRebuildBeforeFirstFlushRecoversAsRecoveredButEmpty() t Assert.assertFalse(ws.isResetArmed()); Assert.assertEquals(1, ws.getSymbolDictEpoch()); - // The manager worker provisions the fresh engine's hot-spare - // segment asynchronously and links its file under the final - // name BEFORE sizing or stamping it, so a name-level wait can - // still snapshot an empty or zero-magic spare that recovery - // hard-fails on. Wait for the ring to report the spare - // installed (only after create + header sync), then pin the - // resulting file set. - awaitHotSpareInstalled(ws.getCursorEngineForTesting()); awaitExactFileSet(slot, FRESH_REBUILD_FILES); // The true pre-first-flush crash image, frozen before the @@ -610,23 +602,6 @@ private static void awaitExactFileSet(String dir, List expected) throws expected, listDir(dir)); } - /** - * Polls (5s deadline) until the engine's ring reports an installed hot - * spare. The segment manager links the spare's file under its final name - * before pre-allocating and stamping it, so a directory listing settles - * before the file is readable; {@code installHotSpare} runs only after - * create + header sync, which is the barrier a byte-level snapshot needs. - */ - private static void awaitHotSpareInstalled(CursorSendEngine engine) throws InterruptedException { - SegmentRing ring = engine.getRingForTesting(); - long deadline = System.currentTimeMillis() + 5_000; - while (System.currentTimeMillis() < deadline && ring.getHotSpareForTesting() == null) { - Thread.sleep(20); - } - Assert.assertNotNull("the manager worker must install the rebuilt engine's hot spare " - + "before the slot can be snapshotted", ring.getHotSpareForTesting()); - } - /** * Copies every file directly inside {@code dir} (by name -> bytes) for later * {@link #restoreDir}, except {@link #LOCK_FILES}: no close in this suite diff --git a/core/src/test/java/io/questdb/client/test/impl/SenderPoolSfTest.java b/core/src/test/java/io/questdb/client/test/impl/SenderPoolSfTest.java index 99ceb5198..1cd3908f1 100644 --- a/core/src/test/java/io/questdb/client/test/impl/SenderPoolSfTest.java +++ b/core/src/test/java/io/questdb/client/test/impl/SenderPoolSfTest.java @@ -1635,7 +1635,7 @@ public void testSharedManagerPassCompletionRecoversRetiredPoolSlot() throws Exce AtomicReference hookErr = new AtomicReference<>(); AtomicReference engineRef = new AtomicReference<>(); manager.setAfterRingCleanupHook(cleanupFinished::countDown); - manager.setBeforeInstallSyncHook(() -> { + manager.setBeforeTrimSyncHook(() -> { if (!fired.compareAndSet(false, true)) { return; } @@ -1694,7 +1694,7 @@ public void testSharedManagerPassCompletionRecoversRetiredPoolSlot() throws Exce } finally { releaseWorker.countDown(); manager.setAfterRingCleanupHook(null); - manager.setBeforeInstallSyncHook(null); + manager.setBeforeTrimSyncHook(null); // The 50 ms timeout above is only for forcing the slot- // retirement path under test. Restore a normal teardown // budget before closing the recovered sender: otherwise a diff --git a/core/src/test/java/io/questdb/client/test/impl/SenderPoolSharedBudgetTest.java b/core/src/test/java/io/questdb/client/test/impl/SenderPoolSharedBudgetTest.java index 184ca8696..deb97f0e3 100644 --- a/core/src/test/java/io/questdb/client/test/impl/SenderPoolSharedBudgetTest.java +++ b/core/src/test/java/io/questdb/client/test/impl/SenderPoolSharedBudgetTest.java @@ -50,8 +50,8 @@ public class SenderPoolSharedBudgetTest { private static final long SEGMENT_BYTES = 64 * 1024; private static final long BUDGET_BYTES = 16 * SEGMENT_BYTES; - // Every live sender is always granted its active segment plus one spare, - // budget or not. + // Every live sender is always granted its active segment plus the one it + // rotates into, budget or not. private static final long MIN_WORKING_SET_BYTES = 2 * SEGMENT_BYTES; private static final int POOL_SIZE = 4; @@ -105,8 +105,11 @@ public void testPooledSendersShareOneBudget() throws Exception { for (int i = 0; i < POOL_SIZE; i++) { senders[i] = pool.borrow(); } - // Every sender is built and holds its starting segments, so - // from here on native memory grows only by buffered data. + // Every sender is built and holds its active segment, so from + // here on native memory grows only by buffered data: the rest of + // the budget, plus the one segment past the cap the floor grants + // each sender that found the budget spent, plus socket and write + // buffers (well under two segments). long nativeBefore = Unsafe.getMemUsedByTag(MemoryTag.NATIVE_DEFAULT); long bufferedBefore = budget.getSegmentBytes(); for (int i = 0; i < POOL_SIZE; i++) { @@ -117,7 +120,7 @@ public void testPooledSendersShareOneBudget() throws Exception { long nativeGrowth = Unsafe.getMemUsedByTag(MemoryTag.NATIVE_DEFAULT) - nativeBefore; Assert.assertTrue("native memory must grow by no more than the rest of the one budget " + "[growth=" + nativeGrowth + ", bufferedBefore=" + bufferedBefore + ']', - nativeGrowth <= BUDGET_BYTES - bufferedBefore + 4 * SEGMENT_BYTES); + nativeGrowth <= BUDGET_BYTES - bufferedBefore + (POOL_SIZE + 2) * SEGMENT_BYTES); long buffered = budget.getSegmentBytes(); Assert.assertTrue("the senders together must fill the budget [buffered=" + buffered + ']', buffered >= BUDGET_BYTES - SEGMENT_BYTES); diff --git a/design/wake-protocol.md b/design/wake-protocol.md index 52a493abe..e3868647a 100644 --- a/design/wake-protocol.md +++ b/design/wake-protocol.md @@ -47,21 +47,24 @@ No open gaps. | Input | Writer | Doorbell / cover | Fixed by | |---|---|---|---| | `rings` | `register` `:761` | `wakeWorker` (after the entry is visible) | — | -| `rings` | `deregister` `:623` | none needed (nothing to service); `budget.release` wakes refused siblings | — | -| `ring.needsHotSpare()` | rotation `appendOrFsn` `:1142` | `managerWakeup.run()` | — | -| `ring.needsHotSpare()` | high-water backup `appendOrFsn` `:1175` (once per active) | `managerWakeup.run()` | — | -| `ring.needsHotSpare()` | recovered ring opened with no spare | `register` wake | — | +| `rings` | `deregister` `:623` | none needed (nothing to service) | — | | trimmable head: `ackedFsn >= oldestTrimmableLastSeq` | `acknowledge` `:1023` | wakes when `seq >= oldestTrimmableLastSeq` (volatile threshold, no ring monitor on the ACK path) | 38c5d69d | | `oldestTrimmableLastSeq` | rotation `:1129`; `releasePinnedSegment` `:1657`; `switchIoPin` `:1878` (pin moved off a segment); worker's own removal `:1865`; recovery `:691` (pre-register) | each non-worker writer that can *increase* trimmability calls `wakeManager`; a pin being *added* cannot | — | | pending / failed trim | worker | `hasMoreTrimmable` → no park; failed unlink/barrier → `trimRetryAtNanos` timed | 98ac6ae4 | -| budget room (spare refused) | `SegmentBudget.release` / `removeSideFileGauge` | `tryCharge(bytes, onRoom)` registers the manager; `wakeRefused` runs it. The liveness-floor charge withdraws the registration: a refused manager that provisions anyway and then releases (failed create) must not wake itself out of its 10 ms retry | 15247811, next commit | -| failed spare create | worker | `provisionRetryDeadlineNanos` timed | — | | periodic data sync | clock | `nextDataSyncNanos` timed | — | | sync-before-rotation request | `appendOrFsn` `:1082` | `wakeManager`; the producer then polls at 50 µs in `CursorSendEngine.appendBlocking` `:972` (poll, see Residuals) | — | | `running` | `close` `:335` | `unpark` | — | | durability failure | `servicePeriodicSync` | producer throws on next append; sync retry timed | — | -No open gaps. The ACK watermark is no longer a manager input: the I/O thread +No open gaps. Spare provisioning is no longer a manager input either: the +producer creates its next segment itself at rotation (`SegmentProvisioner`, +called from `appendOrFsn`), which removed `needsHotSpare`, the high-water +backup wake, the provision-retry timer, the liveness-floor charge and the +budget's refused-wake list (f37967c4's bug class). A refused producer retries +on its own 50 µs backpressure poll; a failed create is retried by the same +poll after `CREATE_RETRY_NANOS`. + +The ACK watermark is no longer a manager input: the I/O thread writes it in `CursorSendEngine.acknowledge` (a store into the mmap, no syscall), and the worker only `sync()`s it before unlinking a trimmed segment. That removed the row that took four commits (39ac01e9, 779a99bc, 1304f71d, @@ -110,10 +113,10 @@ Predicate: `getAckedFsn() >= target`, or a terminal: connection error, durabilit ## Residuals (known, bounded) -1. Producer backpressure (`appendBlocking` `:972`) polls at 50 µs for the - spare install (`installHotSpare` `:1469`), the sync-request clear +1. Producer backpressure (`appendBlocking` `:972`) polls at 50 µs for budget + room (a trim's `release`), the create retry, the sync-request clear (`:1205`) and the durability latch (`:1275`); bounded by the 30 s append - deadline. Converting it to a park needs those three writers to ring it. + deadline. Converting it to a park needs those writers to ring it. `maybeBlockForStarvedReset` (`:5972`, 2 s bound) and `awaitDeferredEngineClose` (`:5684`, 30 s bound) are the same shape; the first could register with `AckWaiters` instead. @@ -130,9 +133,8 @@ Predicate: `getAckedFsn() >= target`, or a terminal: connection error, durabilit ## Recommendation Keep moving state to the thread that produces it instead of adding wake -protocols for it. Done: the ACK watermark (above). Next: the producer creates -its own next segment at rotation (removes `needsHotSpare`, the high-water -backup wake, the provision retry, the liveness floor and the budget's refused -list), then the worker becomes a queue of blocking disk work (trim unlink + -directory fsync, periodic sync) whose park-if-empty is lost-wake-free by -construction. +protocols for it. Done: the ACK watermark and spare provisioning (above). +Next: the worker becomes a queue of blocking disk work (trim unlink + +directory fsync, periodic sync, sync-before-rotation) whose park-if-empty is +lost-wake-free by construction, and the producer's backpressure poll becomes +a park rung by the trim's release. From 5d342e41a3464c10b1071a3b616177f9b1eb4011 Mon Sep 17 00:00:00 2001 From: Jaromir Hamala Date: Fri, 23 Oct 2026 18:02:45 +0200 Subject: [PATCH 34/36] fix(qwp): keep the segment a failed manifest commit may already name 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. 6e4f4ca4'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. --- .../client/sf/cursor/SegmentProvisioner.java | 34 +++++--- .../qwp/client/sf/cursor/SegmentRing.java | 60 +++++++------- .../qwp/client/sf/cursor/SegmentSource.java | 6 +- .../sf/cursor/CursorSendEngineTest.java | 15 ++-- .../sf/cursor/SegmentProvisionerTest.java | 78 ++++++++++++++++++- 5 files changed, 147 insertions(+), 46 deletions(-) diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentProvisioner.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentProvisioner.java index ec8128bd5..d8664c2cc 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentProvisioner.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentProvisioner.java @@ -71,6 +71,12 @@ public final class SegmentProvisioner implements SegmentSource { private final long segmentSizeBytes; private long lastLogNs; private long nextGeneration; + // A segment handed back by discard(): the rotation's manifest update + // failed after its record may already have reached the page cache, so + // the file must stay (recovery would otherwise find the manifest naming + // a base with no segment and quarantine the slot). Still charged; the + // next rotation at the same base reuses it. + private MmapSegment retained; private long retryAtNanos; SegmentProvisioner(SegmentRing ring, String dir, long firstGeneration, long segmentSizeBytes, @@ -91,24 +97,28 @@ public final class SegmentProvisioner implements SegmentSource { @Override public void close() { - // Holds no segments: every one it created belongs to the ring. + // Every segment it created belongs to the ring, except a retained + // one. Its file stays: an empty segment at a base the manifest may + // name, which recovery accepts either way. + if (retained != null) { + MmapSegment r = retained; + retained = null; + r.close(); + } } @Override public void discard(MmapSegment segment) { - budget.release(segmentSizeBytes); - String path = dir == null ? null : segment.path(); - try { - segment.close(); - } finally { - if (path != null) { - ff.remove(path); - } - } + retained = segment; } @Override public MmapSegment next(long baseSeq) { + if (retained != null) { + MmapSegment r = retained; + retained = null; + return r; + } long now = System.nanoTime(); if (retryAtNanos != 0L && now - retryAtNanos < 0L) { return null; @@ -172,7 +182,9 @@ public MmapSegment next(long baseSeq) { } // create() removes its own file when it throws; only a // segment that exists needs unlinking here. - ff.remove(path); + if (path != null) { + ff.remove(path); + } } retryAtNanos = now + CREATE_RETRY_NANOS; if (now - lastLogNs >= LOG_THROTTLE_NANOS) { diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentRing.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentRing.java index b8175c6bd..31e61b0f7 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentRing.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentRing.java @@ -1064,24 +1064,24 @@ public long appendOrFsn(long payloadAddr, int payloadLen) { if (spare == null) { return BACKPRESSURE_NO_SPARE; } - try { - if (spare.baseSeq() != actualBase) { - // A source that pre-created the segment (tests) guessed - // the base. Pin it, and make it durable BEFORE the - // manifest references it: an OS crash could otherwise - // leave a durable manifest naming baseSeq=actualBase - // while the on-disk header still carries the guess, and - // recovery would fail a startup that lost nothing. - spare.rebaseSeq(actualBase); - if (manifest != null) { - spare.syncHeader(); + // Publish the successor before the volatile active promotion. The + // same monitor protects the sealed list and nextSealedAfter's trim + // fallback, while the volatile link also remains readable from a + // current segment after the manager removes and closes it. + synchronized (this) { + try { + if (spare.baseSeq() != actualBase) { + // A source that pre-created the segment (tests) guessed + // the base. Pin it, and make it durable BEFORE the + // manifest references it: an OS crash could otherwise + // leave a durable manifest naming baseSeq=actualBase + // while the on-disk header still carries the guess, and + // recovery would fail a startup that lost nothing. + spare.rebaseSeq(actualBase); + if (manifest != null) { + spare.syncHeader(); + } } - } - // Publish the successor before the volatile active promotion. The - // same monitor protects the sealed list and nextSealedAfter's trim - // fallback, while the volatile link also remains readable from a - // current segment after the manager removes and closes it. - synchronized (this) { if (manifest != null) { // Inside the monitor: serialized with the trim path's // advanceManifestHeadPast so neither writer publishes a @@ -1089,22 +1089,28 @@ public long appendOrFsn(long payloadAddr, int payloadLen) { // moved past (SfManifest additionally clamps monotonic). // BEFORE any ring mutation: if the manifest fsync throws, // the rotation never happened -- previous stays active, - // the new segment goes back to the source, and the - // producer's retry re-runs this block from a consistent - // state. + // the new segment goes back to the source (which keeps + // its file: the record may already be in the page + // cache), and the producer's retry re-runs this block + // from a consistent state. long headBase = sealedHead < sealedSegments.size() ? sealedSegments.get(sealedHead).baseSeq() : previous.baseSeq(); manifest.update(headBase, actualBase); } - previous.linkSuccessor(spare); - sealedSegments.add(previous); - refreshOldestTrimmable(); - active = spare; + } catch (Throwable t) { + try { + source.discard(spare); + } catch (Throwable suppressed) { + t.addSuppressed(suppressed); + } + throw t; } - } catch (Throwable t) { - source.discard(spare); - throw t; + // Committed: from here the rotation must not be undone. + previous.linkSuccessor(spare); + sealedSegments.add(previous); + refreshOldestTrimmable(); + active = spare; } // The sealed segment may already be fully acked (the ACK landed // while it was still active, so it woke nothing): let trim look. diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentSource.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentSource.java index 29ce9f650..6adbbe07d 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentSource.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentSource.java @@ -43,8 +43,10 @@ public interface SegmentSource extends QuietCloseable { /** * Takes back a segment {@link #next} returned that the rotation could not - * complete with (the manifest update threw). Closes it and reverses any - * accounting {@link #next} did. + * commit (the manifest update threw). The segment keeps its file and its + * accounting: the manifest record naming its base may already be in the + * page cache, so recovery may need the file. The next {@link #next} at + * the same base returns it again. */ void discard(MmapSegment segment); diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorSendEngineTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorSendEngineTest.java index 52d8f308e..fd621f8ee 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorSendEngineTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorSendEngineTest.java @@ -1099,12 +1099,17 @@ private static Throwable invokeSharedConstructorExpectingFailure( } private static void poisonRegisterGeneration(SegmentManager manager) throws Exception { - // register() publishes the ring into `rings` last. Nulling the list - // forces a deterministic constructor failure after the ring and - // watermark exist, without adding a production test hook. - Field f = SegmentManager.class.getDeclaredField("rings"); + // register() adds the slot's side-file gauge to the budget before it + // publishes the ring. Nulling the budget's gauge list forces a + // deterministic constructor failure after the ring and watermark + // exist, without adding a production test hook; the worker never + // touches that list, so a shared manager keeps running. + Field b = SegmentManager.class.getDeclaredField("budget"); + b.setAccessible(true); + Object budget = b.get(manager); + Field f = budget.getClass().getDeclaredField("sideFileGauges"); f.setAccessible(true); - f.set(manager, null); + f.set(budget, null); } private static Thread workerThread(SegmentManager manager) throws Exception { diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentProvisionerTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentProvisionerTest.java index 2591896a0..aa286f7c1 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentProvisionerTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentProvisionerTest.java @@ -24,10 +24,10 @@ package io.questdb.client.test.cutlass.qwp.client.sf.cursor; +import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorSendEngine; import io.questdb.client.cutlass.qwp.client.sf.cursor.MmapSegment; import io.questdb.client.cutlass.qwp.client.sf.cursor.SegmentBudget; import io.questdb.client.cutlass.qwp.client.sf.cursor.SegmentManager; -import io.questdb.client.cutlass.qwp.client.sf.cursor.SegmentProvisioner; import io.questdb.client.cutlass.qwp.client.sf.cursor.SegmentRing; import io.questdb.client.std.Files; import io.questdb.client.std.FilesFacade; @@ -40,6 +40,8 @@ import java.io.File; import java.nio.file.Paths; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; /** @@ -193,4 +195,78 @@ public void testDiskRotationNamesFilePastExistingGenerations() throws Exception } }); } + + /** + * The manifest update is the last step of a rotation: write, then fsync. + * When the fsync fails the record is already in the page cache and names + * the new segment's base, so the file the rotation created must survive + * the failure: a later recovery that selects that record needs a segment + * at that base, and without one it quarantines the whole slot. + */ + @Test + public void testFailedManifestSyncKeepsTheSegmentTheManifestMayName() throws Exception { + TestUtils.assertMemoryLeak(() -> { + String dir = Paths.get(System.getProperty("java.io.tmpdir"), "qdb-provisioner-manifest-" + System.nanoTime()).toString(); + Assert.assertEquals(0, Files.mkdir(dir, Files.DIR_MODE_DEFAULT)); + // One frame per segment, so the second append rotates. + long segSize = MmapSegment.HEADER_SIZE + MmapSegment.FRAME_HEADER_SIZE + FRAME_PAYLOAD; + AtomicInteger manifestFd = new AtomicInteger(-1); + AtomicBoolean failManifestSync = new AtomicBoolean(); + FilesFacade facade = new DelegatingFilesFacade() { + @Override + public int openRWExclusive(String path) { + int fd = super.openRWExclusive(path); + if (path.endsWith("sf-manifest.bin")) { + manifestFd.set(fd); + } + return fd; + } + + @Override + public int openRW(String path) { + int fd = super.openRW(path); + if (path.endsWith("sf-manifest.bin")) { + manifestFd.set(fd); + } + return fd; + } + + @Override + public int fsync(int fd) { + if (failManifestSync.get() && fd == manifestFd.get()) { + return -1; + } + return super.fsync(fd); + } + }; + long buf = Unsafe.malloc(FRAME_PAYLOAD, MemoryTag.NATIVE_DEFAULT); + try { + try (SegmentManager manager = new SegmentManager(segSize, new SegmentBudget(8 * segSize), facade, System::nanoTime); + CursorSendEngine engine = new CursorSendEngine(dir, segSize, manager)) { + Assert.assertTrue(manifestFd.get() >= 0); + Assert.assertEquals(0L, engine.appendBlocking(buf, FRAME_PAYLOAD)); + failManifestSync.set(true); + try { + engine.appendBlocking(buf, FRAME_PAYLOAD); + Assert.fail("the rotation must surface the failed manifest sync"); + } catch (RuntimeException expected) { + // the record is written, only its fsync failed + } + failManifestSync.set(false); + // Not drained: close keeps the slot's files for the next start. + } + try (SegmentManager manager = new SegmentManager(segSize, new SegmentBudget(8 * segSize), facade, System::nanoTime); + CursorSendEngine engine = new CursorSendEngine(dir, segSize, manager)) { + Assert.assertEquals("the one frame before the failed rotation must be recovered", + 0L, engine.publishedFsn()); + } + } finally { + Unsafe.free(buf, FRAME_PAYLOAD, MemoryTag.NATIVE_DEFAULT); + for (File f : new File(dir).listFiles()) { + f.delete(); + } + Files.remove(dir); + } + }); + } } From acfa9b3d89cac9b4aee57f88f0fb8f43f0279f96 Mon Sep 17 00:00:00 2001 From: Jaromir Hamala Date: Fri, 23 Oct 2026 18:07:34 +0200 Subject: [PATCH 35/36] chore(qwp): review follow-ups for producer-side provisioning - 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 f37967c4'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. --- .../main/java/io/questdb/client/Sender.java | 9 ++-- .../qwp/client/sf/cursor/SegmentManager.java | 14 +++-- .../client/sf/cursor/SegmentProvisioner.java | 10 +--- .../qwp/client/sf/cursor/SegmentRing.java | 18 ++++--- ...CursorSendEngineSlotReacquisitionTest.java | 53 +++++++++---------- .../client/sf/cursor/SegmentManagerTest.java | 28 ++++++++++ .../sf/cursor/SegmentProvisionerTest.java | 48 +++++++++++++++++ design/wake-protocol.md | 5 +- 8 files changed, 131 insertions(+), 54 deletions(-) diff --git a/core/src/main/java/io/questdb/client/Sender.java b/core/src/main/java/io/questdb/client/Sender.java index 95488d535..818292baa 100644 --- a/core/src/main/java/io/questdb/client/Sender.java +++ b/core/src/main/java/io/questdb/client/Sender.java @@ -1047,7 +1047,7 @@ final class LineSenderBuilder { private static final int DEFAULT_MAXIMUM_BUFFER_CAPACITY = 100 * 1024 * 1024; private static final int DEFAULT_MAX_BACKGROUND_DRAINERS = 4; private static final int DEFAULT_MAX_BACKOFF_MILLIS = 1_000; - // Default ceiling on cursor-allocated bytes (active + spare + sealed). + // Default ceiling on cursor-allocated bytes (active + sealed segments). // RAM is precious; if you're not persisting to disk, you don't get // to balloon. Memory mode = 128 MiB (32 segments at default size). private static final long DEFAULT_MAX_BYTES_MEMORY = 128L * 1024 * 1024; @@ -2962,8 +2962,8 @@ public LineSenderBuilder storeAndForwardMaxSegmentBytes(long maxSegmentBytes) { } /** - * Hard cap on cursor-allocated bytes: active + spare + sealed - * segments, plus the symbol-dictionary side-file with {@code sf_dir}. + * Hard cap on cursor-allocated bytes: active + sealed segments, plus + * the symbol-dictionary side-file with {@code sf_dir}. * When the cap is reached, the producer's {@code Sender.flush()} * blocks until ACK-driven trim frees space; if the cap is exhausted * past the configured deadline (default 30 s), {@code flush()} throws. @@ -2992,7 +2992,8 @@ public LineSenderBuilder storeAndForwardMaxTotalBytes(long maxTotalBytes) { * every other sender built on it, instead of to a private budget of * {@code sf_max_total_bytes}. Together those senders never hold more * than the budget's capacity, except that each is always granted its - * minimum working set: the active segment plus one spare. This + * minimum working set: the active segment plus the one it rotates + * into. This * sender's own {@code sf_max_total_bytes} then caps only the * background orphan drainers it starts. The connection pool behind * {@link QuestDB} builds every sender this way. WebSocket transport diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentManager.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentManager.java index dc75991f9..0c27e794d 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentManager.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentManager.java @@ -585,6 +585,12 @@ public void register(SegmentRing ring, String dir, AckWatermark watermark, long if (syncIntervalNanos > 0L && dir == null) { throw new IllegalArgumentException("periodic sync requires a segment directory"); } + if (ring.maxBytesPerSegment() != segmentSizeBytes) { + // The ring's oversized-payload check assumes the segments this + // manager's provisioner creates are maxBytesPerSegment large. + throw new IllegalArgumentException("ring segment size " + ring.maxBytesPerSegment() + + " does not match the manager's " + segmentSizeBytes); + } // Account for bytes the ring already owns when it joins. A recovered // ring (post-restart, orphan adoption) can come up at-or-above the cap; // without this seed, the budget stays at 0 and the per-tick cap check @@ -592,10 +598,10 @@ public void register(SegmentRing ring, String dir, AckWatermark watermark, long // top of the recovered set, effectively doubling the documented cap. long ringBytes = ring.totalSegmentBytes(); // Start the file-generation counter past whatever's already on disk in - // this slot. Without this, on recovery the first rotation would mint - // sf-0000000000000000.sfa — and openCleanRW would truncate the user's - // existing active file out from under the I/O loop, scrambling the - // in-flight mmap. Memory-mode rings have no dir; nothing to scan. + // this slot, or on recovery the first rotation would try to create + // sf-0000000000000000.sfa, collide with the recovered active (O_EXCL + // refuses it) and backpressure the producer on every retry. + // Memory-mode rings have no dir; nothing to scan. if (!ring.hasSegmentSource()) { long firstGeneration = dir == null ? 0L : scanMaxGeneration(dir) + 1L; ring.setSegmentSource(new SegmentProvisioner(ring, dir, firstGeneration, segmentSizeBytes, budget, filesFacade)); diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentProvisioner.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentProvisioner.java index d8664c2cc..a950c08f8 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentProvisioner.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentProvisioner.java @@ -66,7 +66,6 @@ public final class SegmentProvisioner implements SegmentSource { // null = memory mode private final String dir; private final FilesFacade ff; - private final long floorBytes; private final SegmentRing ring; private final long segmentSizeBytes; private long lastLogNs; @@ -87,12 +86,6 @@ public final class SegmentProvisioner implements SegmentSource { this.segmentSizeBytes = segmentSizeBytes; this.budget = budget; this.ff = ff; - // Clamp: segmentSizeBytes is user-supplied and only bounded below, so - // a pathological value would wrap the product negative and make the - // floor test trivially false. - this.floorBytes = segmentSizeBytes > Long.MAX_VALUE / MIN_LIVE_SEGMENTS - ? Long.MAX_VALUE - : segmentSizeBytes * MIN_LIVE_SEGMENTS; } @Override @@ -124,7 +117,8 @@ public MmapSegment next(long baseSeq) { return null; } if (!budget.tryCharge(segmentSizeBytes)) { - if (ring.totalSegmentBytes() >= floorBytes) { + // O(1): this runs on every 50 us backpressure poll while the cap refuses. + if (ring.liveSegmentCount() >= MIN_LIVE_SEGMENTS) { if (now - lastLogNs >= LOG_THROTTLE_NANOS) { lastLogNs = now; long sideFileBytes = budget.getSideFileBytes(); diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentRing.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentRing.java index 31e61b0f7..fc273dbca 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentRing.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentRing.java @@ -124,8 +124,6 @@ public final class SegmentRing implements QuietCloseable { // read by I/O thread via getActive(). Volatile so the I/O thread sees // rotations promptly and never observes a torn reference. private volatile MmapSegment active; - // Set to true by close(). - private boolean closed; // Latest periodic data-barrier failure. The manager latches it and the // producer observes it before its next append; the manager clears it // again once a subsequent periodic sync pass succeeds, so a transient @@ -816,11 +814,11 @@ private static void quarantineFile(FilesFacade filesFacade, String path) { * the one rotation actually exposed);

  • *
  • a clean empty segment.
  • * - * Multiple equivalent empties at the same base are NOT an error: a fresh - * start or a rotation crash routinely leaves both the initial/promoted - * segment and a segment an older client pre-created as a hot spare, with - * the same provisional baseSeq. They are interchangeable blanks — pick one deterministically - * and let the extras cleanup discard the rest. Bricking startup on this + * Multiple equivalent empties at the same base are NOT an error: a crash + * between a rotation's create and its manifest commit, a create whose + * cleanup unlink failed, or a hot spare an older client pre-created all + * leave a second blank at the same base. They are interchangeable — pick + * one deterministically and let the extras cleanup discard the rest. Bricking startup on this * state would turn every "kill -9 shortly after start" into a manual * repair. */ @@ -1248,7 +1246,6 @@ public synchronized void close() { // The monitor serializes against drainTrimmable / nextSealedAfter // / firstSealed / findSegmentContaining, so they don't iterate // half-freed state. - closed = true; ioPinnedSegment = null; if (active != null) { active.close(); @@ -1720,6 +1717,11 @@ public void syncAllLiveSegments() { * reverse the contribution at deregister time. Synchronized against * rotation so we never read a half-resized sealed list. */ + /** Live segments: the active plus every sealed one, including those hidden for a pending trim. */ + public synchronized int liveSegmentCount() { + return (active != null ? 1 : 0) + (sealedSegments.size() - sealedHead) + pendingTrims.size(); + } + public synchronized long totalSegmentBytes() { long total = 0L; MmapSegment a = active; diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorSendEngineSlotReacquisitionTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorSendEngineSlotReacquisitionTest.java index a3f292bda..e5a55dc1a 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorSendEngineSlotReacquisitionTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorSendEngineSlotReacquisitionTest.java @@ -162,8 +162,8 @@ public void testRepeatedCloseRetainsSlotWhileWorkerIsMidServicePass() throws Exc // good — slot retained. } - // Let the worker finish its pass (it abandons the spare: the - // ring was deregistered by the close attempt above). + // Let the worker finish its pass (the ring was deregistered by + // the close attempt above, so the pass skips it). releaseWorker.countDown(); Assert.assertTrue("ring pass did not finish deferred cleanup", cleanupFinished.await(5, TimeUnit.SECONDS)); @@ -206,13 +206,10 @@ public void testRepeatedCloseRetainsSlotWhileWorkerIsMidServicePass() throws Exc * lock mid service pass and the shared-manager tests would stay green — this * test is the red gate for the production path. *

    - * Determinism: the owned manager starts inside the engine ctor (1 ms poll), - * so its first spare-install pass races test setup. We first wait until the - * initial hot spare is installed — after that the worker cannot enter another - * install pass until a rotation consumes the spare, so the park hook installed - * afterwards can neither be missed nor fire early. Two appends then fill the - * active segment and rotate onto the spare; the worker's next poll tick - * re-enters the install pass and parks in the hook. + * Determinism: the owned manager starts inside the engine ctor, and its + * register-wake pass may race test setup, so the park hook is installed + * only after construction. Two appends then fill the active segment and + * rotate; the rotation wakes the worker, whose next pass parks in the hook. */ @Test(timeout = 30_000L) public void testOwnedEngineCloseRetainsSlotWhileWorkerIsMidServicePass() throws Exception { @@ -251,7 +248,7 @@ public void testOwnedEngineCloseRetainsSlotWhileWorkerIsMidServicePass() throws Unsafe.getUnsafe().putLong(buf, 0L); Assert.assertEquals(0L, engine.appendBlocking(buf, payloadLen)); Assert.assertEquals(1L, engine.appendBlocking(buf, payloadLen)); - Assert.assertTrue("worker never re-entered a spare-install pass", + Assert.assertTrue("worker never ran the rotation-triggered pass", workerBlocked.await(5, TimeUnit.SECONDS)); // Phase 3: owned close with the worker provably mid service @@ -273,8 +270,8 @@ public void testOwnedEngineCloseRetainsSlotWhileWorkerIsMidServicePass() throws // good — slot retained. } - // Phase 4: release the worker (it abandons the spare: the ring - // was deregistered by the close attempt) and retry. The join + // Phase 4: release the worker (the ring was deregistered by the + // close attempt, so the pass skips it) and retry. The join // now reaps the worker and the full cleanup must complete. releaseWorker.countDown(); manager.setWorkerJoinTimeoutMillis(TimeUnit.SECONDS.toMillis(60)); @@ -328,8 +325,8 @@ public void testOwnedEngineCloseHandsCleanupToWorkerExit() throws Exception { SegmentManager manager = engine.getManagerForTesting(); long buf = Unsafe.malloc(payloadLen, MemoryTag.NATIVE_DEFAULT); try { - // Phase 1: wait out the initial spare install so the park hook - // can only fire on the rotation-triggered pass (see + // Phase 1: install the park hook after construction so it can + // only fire on the rotation-triggered pass (see // testOwnedEngineCloseRetainsSlotWhileWorkerIsMidServicePass). SegmentRing ring = engine.getRingForTesting(); manager.setBeforeTrimSyncHook(() -> { @@ -345,12 +342,12 @@ public void testOwnedEngineCloseHandsCleanupToWorkerExit() throws Exception { } }); - // Phase 2: fill the active segment and rotate onto the spare; - // the worker's next tick re-enters the install pass and parks. + // Phase 2: fill the active segment and rotate; the rotation + // wakes the worker, whose next pass parks in the hook. Unsafe.getUnsafe().putLong(buf, 0L); Assert.assertEquals(0L, engine.appendBlocking(buf, payloadLen)); Assert.assertEquals(1L, engine.appendBlocking(buf, payloadLen)); - Assert.assertTrue("worker never re-entered a spare-install pass", + Assert.assertTrue("worker never ran the rotation-triggered pass", workerBlocked.await(5, TimeUnit.SECONDS)); // Phase 3: owned close with the worker provably mid-pass. The @@ -433,8 +430,8 @@ public void testOwnedEngineCloseRetainsSlotWhenHandoffRegistrationFails() throws SegmentManager manager = engine.getManagerForTesting(); long buf = Unsafe.malloc(payloadLen, MemoryTag.NATIVE_DEFAULT); try { - // Phase 1: wait out the initial spare install so the park hook - // can only fire on the rotation-triggered pass (see + // Phase 1: install the park hook after construction so it can + // only fire on the rotation-triggered pass (see // testOwnedEngineCloseRetainsSlotWhileWorkerIsMidServicePass). SegmentRing ring = engine.getRingForTesting(); manager.setBeforeTrimSyncHook(() -> { @@ -450,12 +447,12 @@ public void testOwnedEngineCloseRetainsSlotWhenHandoffRegistrationFails() throws } }); - // Phase 2: fill the active segment and rotate onto the spare; - // the worker's next tick re-enters the install pass and parks. + // Phase 2: fill the active segment and rotate; the rotation + // wakes the worker, whose next pass parks in the hook. Unsafe.getUnsafe().putLong(buf, 0L); Assert.assertEquals(0L, engine.appendBlocking(buf, payloadLen)); Assert.assertEquals(1L, engine.appendBlocking(buf, payloadLen)); - Assert.assertTrue("worker never re-entered a spare-install pass", + Assert.assertTrue("worker never ran the rotation-triggered pass", workerBlocked.await(5, TimeUnit.SECONDS)); // Phase 3: make the handoff registration throw — simulating @@ -543,7 +540,7 @@ public void testTerminalCleanupRunsExactlyOnceWhenRetriedCloseRacesWorkerHandoff SegmentManager manager = engine.getManagerForTesting(); long buf = Unsafe.malloc(payloadLen, MemoryTag.NATIVE_DEFAULT); try { - // Phase 1: wait out the initial spare install so the park hook + // Phase 1: install the park hook after construction so it // can only fire on the rotation-triggered pass. SegmentRing ring = engine.getRingForTesting(); manager.setBeforeTrimSyncHook(() -> { @@ -575,12 +572,12 @@ public void testTerminalCleanupRunsExactlyOnceWhenRetriedCloseRacesWorkerHandoff } }); - // Phase 2: rotate onto the spare so the worker parks in the + // Phase 2: rotate so the rotation wake makes the worker park in the // next install pass. Unsafe.getUnsafe().putLong(buf, 0L); Assert.assertEquals(0L, engine.appendBlocking(buf, payloadLen)); Assert.assertEquals(1L, engine.appendBlocking(buf, payloadLen)); - Assert.assertTrue("worker never re-entered a spare-install pass", + Assert.assertTrue("worker never ran the rotation-triggered pass", workerBlocked.await(5, TimeUnit.SECONDS)); // Phase 3: timed-out close — cleanup ownership transfers to @@ -685,7 +682,7 @@ public void testMemoryModeOwnedCloseHandsCleanupToWorkerExit() throws Exception SegmentManager manager = engine.getManagerForTesting(); long buf = Unsafe.malloc(payloadLen, MemoryTag.NATIVE_DEFAULT); try { - // Phase 1: wait out the initial spare install so the park hook + // Phase 1: install the park hook after construction so it // can only fire on the rotation-triggered pass. SegmentRing ring = engine.getRingForTesting(); manager.setBeforeTrimSyncHook(() -> { @@ -701,12 +698,12 @@ public void testMemoryModeOwnedCloseHandsCleanupToWorkerExit() throws Exception } }); - // Phase 2: rotate onto the spare; the worker's next tick + // Phase 2: rotate; the rotation wakes the worker, whose next pass // re-enters the (in-memory) install pass and parks. Unsafe.getUnsafe().putLong(buf, 0L); Assert.assertEquals(0L, engine.appendBlocking(buf, payloadLen)); Assert.assertEquals(1L, engine.appendBlocking(buf, payloadLen)); - Assert.assertTrue("worker never re-entered a spare-install pass", + Assert.assertTrue("worker never ran the rotation-triggered pass", workerBlocked.await(5, TimeUnit.SECONDS)); // Phase 3: timed-out memory-mode close. The worker can still diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerTest.java index 336bd59a1..fe3cdf255 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerTest.java @@ -45,6 +45,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; public class SegmentManagerTest { @@ -177,6 +178,33 @@ public void testProducerCanRotateAcrossManySegmentsWithoutBackpressure() throws }); } + /** + * A recovered ring can hold trimmable segments the moment it registers, + * with no append, ACK or pin release to follow: register itself must wake + * a worker that is already parked. + */ + @Test + public void testRegisterWakesParkedWorkerForRecoveredTrimmableRing() throws Exception { + TestUtils.assertMemoryLeak(() -> { + long segSize = MmapSegment.HEADER_SIZE + 2 * (MmapSegment.FRAME_HEADER_SIZE + 32); + long buf = Unsafe.malloc(32, MemoryTag.NATIVE_DEFAULT); + try (SegmentRing ring = new SegmentRing(MmapSegment.createInMemory(0, segSize), segSize); + SegmentManager mgr = new SegmentManager(segSize)) { + QueueSegmentSource.install(ring, MmapSegment.createInMemory(2, segSize)); + for (int i = 0; i < 3; i++) ring.appendOrFsn(buf, 32); // seals the first segment + ring.acknowledge(1L); + assertNotNull(ring.firstSealed()); + mgr.start(); + Thread.sleep(100); // the worker parks on an empty ring list + mgr.register(ring, null); + assertTrue("register must wake the parked worker to trim the recovered ring", + waitFor(() -> ring.firstSealed() == null, 2000)); + } finally { + Unsafe.free(buf, 32, MemoryTag.NATIVE_DEFAULT); + } + }); + } + @Test public void testManagerTrimsAckedSegmentFiles() throws Exception { TestUtils.assertMemoryLeak(() -> { diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentProvisionerTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentProvisionerTest.java index aa286f7c1..2567fcde0 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentProvisionerTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentProvisionerTest.java @@ -169,6 +169,54 @@ public boolean allocate(int fd, long size) { }); } + /** + * The floor path with a failing create: the charge past the cap is + * released and the retry waits for the timer like any other failure. + * (The manager-era version of this spun at one attempt per loop pass.) + */ + @Test + public void testFailedCreateUnderTheFloorIsRetriedOnTimer() throws Exception { + TestUtils.assertMemoryLeak(() -> { + File dir = java.nio.file.Files.createTempDirectory("qdb-provisioner-floor-fail").toFile(); + AtomicLong attempts = new AtomicLong(); + FilesFacade facade = new DelegatingFilesFacade() { + @Override + public boolean allocate(int fd, long size) { + attempts.incrementAndGet(); + return false; + } + }; + String path = new File(dir, "sf-0000000000000000.sfa").getPath(); + // Below two segments: every create goes through the floor charge. + SegmentBudget budget = new SegmentBudget(SEGMENT_SIZE + SEGMENT_SIZE / 2); + long buf = Unsafe.malloc(FRAME_PAYLOAD, MemoryTag.NATIVE_DEFAULT); + try (SegmentRing ring = new SegmentRing(MmapSegment.create(path, 0, SEGMENT_SIZE), SEGMENT_SIZE); + SegmentManager manager = new SegmentManager(SEGMENT_SIZE, budget, facade, System::nanoTime)) { + manager.register(ring, dir.getPath()); + Assert.assertEquals(0L, ring.appendOrFsn(buf, FRAME_PAYLOAD)); + Assert.assertEquals(1L, ring.appendOrFsn(buf, FRAME_PAYLOAD)); + long end = System.nanoTime() + 300_000_000L; + while (System.nanoTime() < end) { + Assert.assertEquals(SegmentRing.BACKPRESSURE_NO_SPARE, ring.appendOrFsn(buf, FRAME_PAYLOAD)); + Assert.assertEquals("a failed floor create must release its charge", SEGMENT_SIZE, budget.getSegmentBytes()); + java.util.concurrent.locks.LockSupport.parkNanos(50_000L); + } + long n = attempts.get(); + Assert.assertTrue("expected about 30 create attempts in 300 ms, got " + n, n >= 5 && n < 100); + manager.deregister(ring); + } finally { + Unsafe.free(buf, FRAME_PAYLOAD, MemoryTag.NATIVE_DEFAULT); + File[] files = dir.listFiles(); + if (files != null) { + for (File f : files) { + f.delete(); + } + } + dir.delete(); + } + }); + } + /** Disk mode: the file is generation-named past what the slot already holds, with the final base. */ @Test public void testDiskRotationNamesFilePastExistingGenerations() throws Exception { diff --git a/design/wake-protocol.md b/design/wake-protocol.md index e3868647a..ad8dd2eb9 100644 --- a/design/wake-protocol.md +++ b/design/wake-protocol.md @@ -59,8 +59,9 @@ No open gaps. No open gaps. Spare provisioning is no longer a manager input either: the producer creates its next segment itself at rotation (`SegmentProvisioner`, called from `appendOrFsn`), which removed `needsHotSpare`, the high-water -backup wake, the provision-retry timer, the liveness-floor charge and the -budget's refused-wake list (f37967c4's bug class). A refused producer retries +backup wake, the provision-retry timer and the budget's refused-wake list +(f37967c4's bug class). The liveness floor itself survives as one branch in +the provisioner, with no wake to withdraw. A refused producer retries on its own 50 µs backpressure poll; a failed create is retried by the same poll after `CREATE_RETRY_NANOS`. From 5a892a40a599ca09803a3aa982aa0109e20c51de Mon Sep 17 00:00:00 2001 From: Jaromir Hamala Date: Fri, 23 Oct 2026 18:07:44 +0200 Subject: [PATCH 36/36] chore(qwp): drop a stale install-pass comment --- .../client/sf/cursor/CursorSendEngineSlotReacquisitionTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorSendEngineSlotReacquisitionTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorSendEngineSlotReacquisitionTest.java index e5a55dc1a..f86686712 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorSendEngineSlotReacquisitionTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorSendEngineSlotReacquisitionTest.java @@ -699,7 +699,7 @@ public void testMemoryModeOwnedCloseHandsCleanupToWorkerExit() throws Exception }); // Phase 2: rotate; the rotation wakes the worker, whose next pass - // re-enters the (in-memory) install pass and parks. + // parks in the hook. Unsafe.getUnsafe().putLong(buf, 0L); Assert.assertEquals(0L, engine.appendBlocking(buf, payloadLen)); Assert.assertEquals(1L, engine.appendBlocking(buf, payloadLen));