From 22891662a3e6735eb7b6530e612d0f9d8698af49 Mon Sep 17 00:00:00 2001 From: Jaromir Hamala Date: Tue, 27 Oct 2026 13:20:45 +0100 Subject: [PATCH 01/11] perf(qwp): event-driven I/O loop, zero CPU when idle A QWP sender burned ~70% of a core per connection even when idle: the I/O thread polled the socket and the segment ring every 50 us. Kafka Connect users saw 30% CPU on QWP vs 3% on HTTP for the same quiet sink. The I/O thread now blocks until there is something to do: socket readable, a producer published a frame, close(), or a PING is due. The producer wakes it through a native doorbell (eventfd on Linux, kqueue EVFILT_USER on macOS/FreeBSD, a kernel Event on Windows), and only when the thread is parked, so a busy connection never pays a syscall per flush. Ack waiters, the segment manager and the dispatchers are event-driven as well: a waiter in awaitAckedFsn/drain is unparked on every ack advance, terminal and durability latch; the segment manager parks until a rotation, ACK or timer needs it and persists the ack watermark on a 1 ms cadence while ACKs stream; the progress dispatcher thread starts only when a handler is installed. No change to the Sender API or configuration. Idle CPU drops to 0, flush-to-ack latency falls 3-15x and saturation throughput rises ~20%. Above ~25k small flushes per second per connection the client uses 1.1-1.45x the CPU of the polling loop: the I/O thread takes two prompt wakes per flush (doorbell, then ACK). design/tla holds TLA+ models of the two wake protocols. --- core/src/main/c/share/net.c | 106 ++++ core/src/main/c/windows/net.c | 60 ++ .../main/java/io/questdb/client/Sender.java | 9 +- .../cutlass/http/client/WebSocketClient.java | 70 +++ .../qwp/client/QwpWebSocketSender.java | 151 +++-- .../qwp/client/sf/cursor/AckWaiters.java | 93 +++ .../qwp/client/sf/cursor/AckWatermark.java | 33 +- .../client/sf/cursor/BackgroundDrainer.java | 2 +- .../client/sf/cursor/CursorSendEngine.java | 149 +++-- .../sf/cursor/CursorWebSocketSendLoop.java | 294 ++++++++-- .../qwp/client/sf/cursor/MmapSegment.java | 7 +- .../qwp/client/sf/cursor/SegmentBudget.java | 29 +- .../qwp/client/sf/cursor/SegmentManager.java | 541 ++++-------------- .../client/sf/cursor/SegmentProvisioner.java | 192 +++++++ .../qwp/client/sf/cursor/SegmentRing.java | 340 ++++++----- .../qwp/client/sf/cursor/SegmentSource.java | 56 ++ .../sf/cursor/SenderConnectionDispatcher.java | 3 +- .../sf/cursor/SenderErrorDispatcher.java | 17 +- .../sf/cursor/SenderProgressDispatcher.java | 50 +- .../client/network/JavaTlsClientSocket.java | 18 +- .../java/io/questdb/client/network/Net.java | 25 + .../io/questdb/client/network/Socket.java | 11 + .../client/WebSocketClientDoorbellTest.java | 128 +++++ .../http/client/WebSocketClientTest.java | 9 +- .../qwp/client/AckWaiterSignalTest.java | 355 ++++++++++++ ...QwpConnectWalkBackgroundIsolationTest.java | 4 +- ...QwpWebSocketSenderJvmErrorCleanupTest.java | 3 +- .../qwp/client/SegmentSkipQuarantineTest.java | 5 +- .../client/SlotLockReleasedContractTest.java | 17 +- .../SymbolDictRecycleFsnContinuityTest.java | 60 ++ .../SymbolDictRecycleLoopTerminalTest.java | 4 +- .../client/SymbolDictRecycleSlotHealTest.java | 4 +- .../qwp/client/SymbolDictRecycleTest.java | 3 +- .../qwp/client/TlsLargeAckIdleWaitTest.java | 120 ++++ .../TlsTruncatedRecordReconnectTest.java | 209 +++++++ .../qwp/client/sf/cursor/AckWaitersTest.java | 226 ++++++++ .../BackgroundDrainerDurableAckRetryTest.java | 3 +- ...kgroundDrainerInterruptedTeardownTest.java | 3 +- .../sf/cursor/CloseOwnershipRaceTest.java | 2 +- ...SendEngineClosePartialEnumerationTest.java | 1 - ...ursorSendEngineCloseUnlinkFailureTest.java | 23 +- ...gineCloseUnlinkStopOnFirstFailureTest.java | 1 - .../CursorSendEngineCrashConsistencyTest.java | 6 +- ...CursorSendEngineSlotReacquisitionTest.java | 124 ++-- .../sf/cursor/CursorSendEngineTest.java | 90 ++- ...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 | 443 ++++++++++++++ ...ocketSendLoopInterruptedCloseLeakTest.java | 5 +- ...CursorWebSocketSendLoopMirrorLeakTest.java | 10 +- ...CursorWebSocketSendLoopOrphanTailTest.java | 6 +- ...ursorWebSocketSendLoopPoisonFrameTest.java | 19 +- ...rsorWebSocketSendLoopRotationRaceTest.java | 14 +- ...sorWebSocketSendLoopTornDictGuardTest.java | 5 +- ...ursorWebSocketSendLoopZeroBackoffTest.java | 4 +- .../client/sf/cursor/QueueSegmentSource.java | 83 +++ .../SegmentManagerAckWatermarkLagTest.java | 150 +++++ .../cursor/SegmentManagerCloseRaceTest.java | 290 +--------- .../SegmentManagerCrashConsistencyTest.java | 17 +- .../SegmentManagerManifestFsyncTest.java | 2 +- .../SegmentManagerPeriodicSyncTest.java | 16 +- .../cursor/SegmentManagerPinnedTrimTest.java | 7 +- .../cursor/SegmentManagerRecoveryCapTest.java | 3 +- .../SegmentManagerSharedBudgetTest.java | 65 ++- .../cursor/SegmentManagerSideFileCapTest.java | 114 ++-- .../client/sf/cursor/SegmentManagerTest.java | 262 +++------ .../SegmentManagerTotalBytesRaceTest.java | 224 -------- .../SegmentManagerTrimDeregisterRaceTest.java | 25 +- .../SegmentManagerUnlinkFailureTest.java | 55 +- ...entManagerWatermarkDeregisterRaceTest.java | 191 ------- .../sf/cursor/SegmentProvisionerTest.java | 320 +++++++++++ .../cursor/SegmentRecoveryIntegrityTest.java | 25 +- .../cursor/SegmentRingAckOffMonitorTest.java | 144 +++++ .../qwp/client/sf/cursor/SegmentRingTest.java | 186 ++---- .../cursor/SenderProgressDispatcherTest.java | 46 ++ .../SymbolDictRecycleCrashWindowsTest.java | 27 +- .../client/test/impl/SenderPoolSfTest.java | 12 +- .../test/impl/SenderPoolSharedBudgetTest.java | 13 +- .../test/tools/TestWebSocketClient.java | 76 +++ design/tla/AckWait.cfg | 4 + design/tla/AckWait.tla | 69 +++ design/tla/IdleWait.tla | 182 ++++++ design/tla/IdleWait_bug.cfg | 7 + design/tla/IdleWait_fix.cfg | 7 + design/tla/IdleWait_stampbug.cfg | 7 + design/tla/README.md | 33 ++ design/wake-protocol.md | 141 +++++ 93 files changed, 4867 insertions(+), 2149 deletions(-) create mode 100644 core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/AckWaiters.java 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/http/client/WebSocketClientDoorbellTest.java create mode 100644 core/src/test/java/io/questdb/client/test/cutlass/qwp/client/AckWaiterSignalTest.java create mode 100644 core/src/test/java/io/questdb/client/test/cutlass/qwp/client/TlsLargeAckIdleWaitTest.java create mode 100644 core/src/test/java/io/questdb/client/test/cutlass/qwp/client/TlsTruncatedRecordReconnectTest.java create mode 100644 core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/AckWaitersTest.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/cutlass/qwp/client/sf/cursor/QueueSegmentSource.java create mode 100644 core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerAckWatermarkLagTest.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 create mode 100644 core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentRingAckOffMonitorTest.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/IdleWait_stampbug.cfg create mode 100644 design/tla/README.md create mode 100644 design/wake-protocol.md diff --git a/core/src/main/c/share/net.c b/core/src/main/c/share/net.c index f3bc1ddd5..4bded793a 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,107 @@ 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 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_awaitReadableOrRing + (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); + 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; +} + +#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 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_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. + 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..277316561 100644 --- a/core/src/main/c/windows/net.c +++ b/core/src/main/c/windows/net.c @@ -325,6 +325,66 @@ 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 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_awaitReadableOrRing + (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_OBJECT_0 + 1) { + // Consume the ring (manual-reset event). + ResetEvent((HANDLE) (uintptr_t) (uint32_t) doorbell); + } + 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/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/http/client/WebSocketClient.java b/core/src/main/java/io/questdb/client/cutlass/http/client/WebSocketClient.java index c54889569..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 @@ -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,14 @@ 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; + // 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(). @@ -207,7 +216,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 +265,7 @@ public void close() { Unsafe.free(recvBufPtr, recvBufSize, MemoryTag.NATIVE_DEFAULT); recvBufPtr = 0; } + closeDoorbell(); } } @@ -624,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(); @@ -634,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; @@ -1099,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); @@ -1472,4 +1497,49 @@ 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) { + 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.awaitReadableOrRing(socket.getFd(), doorbell, timeoutMillis); + if (n < 0) { + throw new HttpClientException("internal error: poll failure [errno=").put(nf.errno()).put(']'); + } + 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..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 @@ -41,6 +41,7 @@ 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; @@ -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 @@ -573,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. @@ -1484,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 @@ -1522,14 +1527,33 @@ public boolean awaitAckedFsn(long targetFsn, long timeoutMillis) { return false; } long deadlineNanos = System.nanoTime() + timeoutMillis * 1_000_000L; - while (getAckedFsn() < targetFsn) { - checkAwaitAckedFsnErrors(); - if (System.nanoTime() >= deadlineNanos) { - return false; + Thread me = Thread.currentThread(); + // 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) { + return true; + } + checkAwaitAckedFsnErrors(); + long remaining = deadlineNanos - System.nanoTime(); + if (remaining <= 0L) { + return false; + } + interrupted |= Thread.interrupted(); + java.util.concurrent.locks.LockSupport.parkNanos(this, remaining); + } + } finally { + ackWaiters.remove(me); + if (interrupted) { + me.interrupt(); } - java.util.concurrent.locks.LockSupport.parkNanos(50_000L); } - return true; } /** @@ -1547,6 +1571,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"); + } + } } /** @@ -3372,6 +3407,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 @@ -3406,6 +3444,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); @@ -4870,46 +4909,55 @@ private void drainOnClose(boolean errorOwnedByCustomHandler, boolean[] restoreIn long deadlineNanos = System.nanoTime() + closeFlushTimeoutMillis * 1_000_000L; restoreInterrupt[0] |= Thread.interrupted(); try { - while (cursorEngine.ackedFsn() < target) { - // 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; + Thread me = Thread.currentThread(); + ackWaiters.add(me); // once per drain, see AckWaiters + try { + while (true) { + 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()); } - java.util.concurrent.locks.LockSupport.parkNanos(50_000L); + } finally { + ackWaiters.remove(me); } } finally { // Close the last-iteration race: an interrupt can land after the loop observes its final ACK but @@ -5024,7 +5072,7 @@ private void ensureConnected() { try { cursorSendLoop = new CursorWebSocketSendLoop( client, cursorEngine, - 0L, CursorWebSocketSendLoop.DEFAULT_PARK_NANOS, + 0L, reconnectFactory, reconnectInitialBackoffMillis, reconnectMaxBackoffMillis, @@ -5035,6 +5083,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. @@ -5801,6 +5850,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); @@ -5862,6 +5912,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/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..7f3ee52c0 --- /dev/null +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/AckWaiters.java @@ -0,0 +1,93 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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.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 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. + *

+ * 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 volatile Thread[] slots = new Thread[4]; + + public synchronized void add(Thread t) { + 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[] s = slots; + for (int i = 0; i < s.length; i++) { + if (s[i] == t) { + s[i] = null; + return; + } + } + } + + /** + * Unparks every registered waiter. Safe from any thread; spurious wakes are + * fine because waiters re-check their condition. + */ + public void signal() { + 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/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..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 { @@ -237,8 +240,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/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..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 @@ -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: @@ -195,6 +196,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; @@ -372,7 +377,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) { @@ -445,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() @@ -471,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 @@ -526,21 +514,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 @@ -901,7 +901,25 @@ 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; + } + + /** + * 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); } /** @@ -934,7 +952,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 +976,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"); } @@ -966,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. @@ -978,7 +996,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 +1007,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(); } @@ -1221,19 +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 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: + // 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( @@ -1255,9 +1291,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 @@ -1841,6 +1877,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 7f04fa73c..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 @@ -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 the engine's + * ack-state listener wakes any ack waiters.
  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; + // 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 + // 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 -- + // 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; @@ -981,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 @@ -988,7 +1098,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 +1108,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 +1128,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 +1139,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 +1476,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 +1956,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 +2094,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; } @@ -2495,6 +2613,22 @@ private void dispatchProgress(long ackedFsn) { } } + private void signalAckProgress() { + Runnable l = ackProgressListener; + if (l != null) { + l.run(); + } + } + + /** + * 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; + } + /** * Pop every head entry whose tables are all covered by the durable * watermarks and call {@link CursorSendEngine#acknowledge} once with @@ -2663,12 +2797,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.publishStamp(); 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 +2829,21 @@ private void ioLoop() { if (!didWork && running && keepaliveIntervalNanos > 0L) { sendPingIfDue(keepaliveIntervalNanos); } - if (!didWork && running) { - LockSupport.parkNanos(parkNanos); + // 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. 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) { + awaitIdle(); + } + } finally { + ioParked = false; + } } } } catch (Throwable t) { @@ -2848,6 +3005,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 +3049,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 +3172,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/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 579f19001..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,16 +39,18 @@ * 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. + * 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 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 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 { @@ -97,7 +98,7 @@ 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; @@ -120,8 +121,8 @@ 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. + * are one atomic step, so rings sharing the budget can never both take its + * last free segment. */ synchronized boolean tryCharge(long bytes) { if (segmentBytes + sideFileBytes() + bytes > capacityBytes) { 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..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 @@ -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,8 +53,6 @@ */ public final class SegmentManager implements QuietCloseable { - public static final long DEFAULT_POLL_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; @@ -69,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; @@ -87,34 +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); - 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<>(); @@ -128,6 +92,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: 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 // callback completion without sleeps or polling. @@ -139,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. @@ -154,8 +113,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 @@ -180,20 +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; 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; - private volatile long shortestSyncIntervalNanos = Long.MAX_VALUE; + // 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. @@ -204,87 +150,79 @@ 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, new SegmentBudget(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 - * 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 + * @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 (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, long pollNanos, long maxTotalBytes) { - this(segmentSizeBytes, pollNanos, 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, 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, long pollNanos, SegmentBudget budget) { - this(segmentSizeBytes, pollNanos, 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 - 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 ) { - // 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"); @@ -292,14 +230,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 - // 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; } @@ -339,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; } } @@ -398,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(); - } - } } /** @@ -585,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 @@ -602,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) { @@ -622,25 +530,25 @@ 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 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. + * 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); } /** - * 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}. @@ -677,28 +585,33 @@ 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 // 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; - Runnable managerWakeup = this::wakeWorker; + // Start the file-generation counter past whatever's already on disk in + // 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)); + } 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); } @@ -713,22 +626,14 @@ 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); + 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 for the full poll interval. - // 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(); } @@ -753,13 +658,6 @@ public Thread getWorkerThreadForTesting() { return workerThread; } - @TestOnly - public boolean isPathScratchAllocatedForTesting() { - synchronized (lock) { - return !scratchFreed; - } - } - @TestOnly public boolean serviceRingForTesting(SegmentRing ring) { if (workerThread != null) { @@ -791,11 +689,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; @@ -827,7 +720,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 @@ -879,43 +772,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,158 +821,11 @@ private boolean serviceRing0(RingEntry e) { if (!memoryMode) { servicePeriodicSync(e, ticks.getAsLong()); } + return trimAcked(e, memoryMode); + } - // 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); - } - 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; - } - } 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) { - 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); - } - } - } - } - - // 2. Trim fully ACKed segments. The ring first transfers one bounded, + private boolean trimAcked(RingEntry e, boolean memoryMode) { + // 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 @@ -1129,17 +838,6 @@ private boolean serviceRing0(RingEntry e) { 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; - } - } - } return false; } @@ -1186,9 +884,14 @@ private boolean serviceRing0(RingEntry e) { 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 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; @@ -1202,10 +905,6 @@ private boolean serviceRing0(RingEntry e) { return false; } durableAck = e.ring.ackedFsn(); - if (durableAck > e.lastPersistedAck) { - e.watermark.write(durableAck); - e.lastPersistedAck = durableAck; - } } try { e.watermark.sync(); @@ -1389,8 +1088,8 @@ 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(); synchronized (lock) { for (int i = 0, n = rings.size(); i < n; i++) { @@ -1404,28 +1103,41 @@ private void workerLoop() { hasMoreTrimmable = true; } } + // Event-driven idle: every state change the worker services + // 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(); + 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)); + } + if (e.trimRetryDelayNanos != 0L) { + parkNanos = Math.min(parkNanos, Math.max(0L, e.trimRetryAtNanos - 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 { - // 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; @@ -1469,15 +1181,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; // 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/SegmentProvisioner.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentProvisioner.java new file mode 100644 index 000000000..a950c08f8 --- /dev/null +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentProvisioner.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.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 SegmentRing ring; + 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, + SegmentBudget budget, FilesFacade ff) { + this.ring = ring; + this.dir = dir; + this.nextGeneration = firstGeneration; + this.segmentSizeBytes = segmentSizeBytes; + this.budget = budget; + this.ff = ff; + } + + @Override + public void close() { + // 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) { + 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; + } + if (!budget.tryCharge(segmentSizeBytes)) { + // 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(); + 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. + if (path != null) { + 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 d18589e91..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 @@ -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,51 +112,48 @@ 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 + // 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. 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. - 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 // disk fault does not permanently brick the producer. private volatile MmapSegmentException durabilityFailure; - // hotSpare: written by segment manager (installHotSpare), read+cleared by - // producer thread on rotation. Volatile so the producer sees fresh installs. - private volatile MmapSegment hotSpare; + // 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; // 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; 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; + // 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 @@ -175,9 +171,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; } @@ -665,6 +658,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. @@ -820,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 provisioned hot spare carrying 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. */ @@ -991,33 +985,62 @@ public boolean acknowledge(long seq) { if (seq > pub) { seq = pub; } - if (seq > ackedFsn) { + long before = ackedFsn; + if (seq > before) { ackedFsn = seq; + // 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(); 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 * 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; + // 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 // range durable before the manifest can name its successor. The @@ -1028,60 +1051,67 @@ 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(); + } + } + 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 (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); + } + } catch (Throwable t) { + try { + source.discard(spare); + } catch (Throwable suppressed) { + t.addSuppressed(suppressed); + } + throw t; } + // Committed: from here the rotation must not be undone. previous.linkSuccessor(spare); sealedSegments.add(previous); + refreshOldestTrimmable(); active = spare; } - 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(); @@ -1105,19 +1135,7 @@ public long appendOrFsn(long payloadAddr, int payloadLen) { // publishedOffset() for an instant, but never behind it. publishedFsn = fsn; target.publishWritten(); - 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(); - } + publishStamp = publishStamp + 1; // single producer return fsn; } @@ -1219,26 +1237,24 @@ void recordDurabilityFailure(Throwable failure) { durabilityFailure = wrapped; } } + signalAckState(); } } @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; ioPinnedSegment = null; if (active != null) { 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(); @@ -1246,6 +1262,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(); } @@ -1389,37 +1406,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 @@ -1497,6 +1487,13 @@ public long publishedFsn() { return publishedFsn; } + /** + * Changes after every completed publish; see the field comment. + */ + public long publishStamp() { + return publishStamp; + } + /** * Drops a directory-durable prefix from hidden trim ownership and returns * its exact byte total for manager accounting. @@ -1538,11 +1535,6 @@ synchronized int pendingTrimCount() { return pendingTrims.size(); } - @TestOnly - public synchronized MmapSegment getHotSpareForTesting() { - return hotSpare; - } - @TestOnly public synchronized int getPendingTrimCount() { return pendingTrims.size(); @@ -1589,6 +1581,7 @@ private synchronized boolean requestSyncBeforeRotation(MmapSegment previous) { synchronized void releasePinnedSegment(MmapSegment expected) { if (ioPinnedSegment == expected) { ioPinnedSegment = null; + refreshOldestTrimmable(); wakeManager(); } } @@ -1653,11 +1646,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. @@ -1666,6 +1659,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 @@ -1699,18 +1711,21 @@ 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. */ + /** 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; 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(); } @@ -1788,11 +1803,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/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..6adbbe07d --- /dev/null +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentSource.java @@ -0,0 +1,56 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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 + * 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); + + /** Releases whatever the source still holds; the owning ring calls this from its own close. */ + @Override + void close(); +} 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..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 @@ -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; @@ -151,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; @@ -310,7 +307,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..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 @@ -70,9 +73,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 @@ -152,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() { @@ -184,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(); + } } /** @@ -201,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(); @@ -211,7 +232,6 @@ public boolean offer(long ackedFsn) { if (t != null) { LockSupport.unpark(t); } - return true; } private void dispatchLoop() { @@ -237,9 +257,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/JavaTlsClientSocket.java b/core/src/main/java/io/questdb/client/network/JavaTlsClientSocket.java index 4649b596c..426727e81 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 @@ -222,11 +231,16 @@ 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 - // 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/Net.java b/core/src/main/java/io/questdb/client/network/Net.java index b9e669d0c..63cd61b64 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,31 @@ public static long sockaddr(int ipv4address, int port) { public static native int socketUdp(); + /** + * 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(); + + /** + * 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). + * 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 awaitReadableOrRing(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/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/http/client/WebSocketClientDoorbellTest.java b/core/src/test/java/io/questdb/client/test/cutlass/http/client/WebSocketClientDoorbellTest.java new file mode 100644 index 000000000..b01aa99ae --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/http/client/WebSocketClientDoorbellTest.java @@ -0,0 +1,128 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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.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; + +/** + * 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: 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 awaitReadableOrRing cannot register. + client.close(); + 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.awaitReadableOrRing(-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/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/AckWaiterSignalTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/AckWaiterSignalTest.java new file mode 100644 index 000000000..f4d5f88c0 --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/AckWaiterSignalTest.java @@ -0,0 +1,355 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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.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; +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); + } + }); + } + + /** + * 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); + } + }); + } + + /** + * 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); + } + }); + } + + /** + * 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); + server.start(); + Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); + 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(); + + 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); + } + } + } +} 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/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 2ae605b7f..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 @@ -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); @@ -278,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 { @@ -342,7 +345,7 @@ public void testSlotLockNotReleasedUntilManagerWorkerQuiesces() throws Exception } } finally { manager.setAfterRingCleanupHook(null); - manager.setBeforeInstallSyncHook(null); + manager.setBeforeTrimSyncHook(null); releaseWorker.countDown(); if (wss != null) { try { @@ -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/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/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/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/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(); + } + } +} 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(); + } + } +} 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); + } + } +} 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..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 @@ -43,25 +43,26 @@ 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. *

    - * {@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 { @@ -88,7 +89,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..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 @@ -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); @@ -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 { @@ -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)); @@ -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(); @@ -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 { @@ -229,17 +226,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,12 +243,12 @@ 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)); - 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 @@ -280,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)); @@ -298,7 +288,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 { @@ -335,18 +325,11 @@ 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(); - 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 { @@ -359,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 @@ -409,7 +392,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 { @@ -447,18 +430,11 @@ 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(); - 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 { @@ -471,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 @@ -522,7 +498,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)); @@ -564,17 +540,10 @@ 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(); - 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 { @@ -603,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 @@ -674,7 +643,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(); @@ -713,17 +682,10 @@ 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(); - 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 { @@ -736,12 +698,12 @@ public void testMemoryModeOwnedCloseHandsCleanupToWorkerExit() throws Exception } }); - // Phase 2: rotate onto the spare; the worker's next tick - // re-enters the (in-memory) install pass and parks. + // Phase 2: 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: timed-out memory-mode close. The worker can still @@ -772,7 +734,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 7f802535a..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 @@ -667,17 +667,64 @@ 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 - // 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); @@ -699,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). @@ -1052,12 +1099,17 @@ 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 - // watermark exist, without adding a production test hook. - Field f = SegmentManager.class.getDeclaredField("fileGeneration"); + // 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 { @@ -1067,10 +1119,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)) { @@ -1083,7 +1135,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/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..37fbc3ffa --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopIdleWaitTest.java @@ -0,0 +1,443 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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.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; +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.CountDownLatch; +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'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.failFirstSend = 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 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(); + } + } + }); + } + + /** + * 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 { + 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(); + 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() { + 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() { + wakes.incrementAndGet(); + doorbell.release(); + } + + @Override + public void sendBinary(long dataPtr, int length) { + if (failFirstSend) { + failFirstSend = false; + throw new HttpClientException("peer disconnect [errno=104]"); + } + sentFrames.incrementAndGet(); + receivesSinceSend = 0; + } + + @Override + public void sendPing() { + pingAttempts.incrementAndGet(); + if (failPing) { + throw new HttpClientException("timed out [errno=11]"); + } + } + + @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 + int len = 1 + 8 + 2 + 2 + 1 + 8; + long p = Unsafe.malloc(len, MemoryTag.NATIVE_DEFAULT); + try { + Unsafe.getUnsafe().putByte(p, (byte) 0); + Unsafe.getUnsafe().putLong(p + 1, 0L); + Unsafe.getUnsafe().putShort(p + 9, (short) 1); + Unsafe.getUnsafe().putShort(p + 11, (short) 1); + Unsafe.getUnsafe().putByte(p + 13, (byte) 't'); + Unsafe.getUnsafe().putLong(p + 14, 1L); + handler.onBinaryMessage(p, len); + } finally { + Unsafe.free(p, len, MemoryTag.NATIVE_DEFAULT); + } + return true; + } + return false; + } + + @Override + protected void ioWait(int timeout, int op) { + } + + @Override + protected void setupIoWait() { + } + } +} 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..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 @@ -24,12 +24,13 @@ 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; 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; @@ -55,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 { @@ -69,7 +70,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 +82,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, 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); 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 +141,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/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 new file mode 100644 index 000000000..bfcede06c --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerAckWatermarkLagTest.java @@ -0,0 +1,150 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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.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.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; +import java.util.concurrent.atomic.AtomicLong; + +/** + * 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; + + @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); + } + + /** + * The manager is never started, so nothing but the acknowledging thread + * can have written the watermark. + */ + @Test(timeout = 20_000L) + public void testAckWritesWatermarkBeforeReturning() throws Exception { + TestUtils.assertMemoryLeak(() -> { + long segmentSize = MmapSegment.HEADER_SIZE + 64 * (MmapSegment.FRAME_HEADER_SIZE + 32L); + long payload = Unsafe.malloc(32, MemoryTag.NATIVE_DEFAULT); + 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++) { + engine.appendBlocking(payload, 32); + } + 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); + } + }); + } + + @Test(timeout = 30_000L) + public void testStreamingAcksInsideActiveSegmentNeverWakeWorker() throws Exception { + TestUtils.assertMemoryLeak(() -> { + final int acks = 20_000; + // 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); + 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))) { + 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); + } + // 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 (worker.getState() != Thread.State.WAITING && System.nanoTime() < deadline) { + Thread.sleep(1); + } + Assert.assertEquals(Thread.State.WAITING, worker.getState()); + passes.set(0); + for (int seq = 0; seq < acks; seq++) { + ring.acknowledge(seq); + } + Thread.sleep(100); + Assert.assertEquals("worker ran for ACKs inside the active segment", 0L, passes.get()); + } finally { + Unsafe.free(payload, 32, MemoryTag.NATIVE_DEFAULT); + watermark.close(); + ring.close(); + } + }); + } +} 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..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 @@ -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; @@ -41,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 { @@ -82,122 +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, 1_000L, - 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, TimeUnit.SECONDS.toNanos(60)); - 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 @@ -225,7 +95,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); @@ -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, TimeUnit.SECONDS.toNanos(60)); - 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 @@ -438,7 +218,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); @@ -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(); @@ -526,7 +306,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(); @@ -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(); @@ -591,7 +371,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); @@ -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(); } @@ -713,7 +489,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<>(); @@ -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 126c79c84..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 @@ -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; @@ -63,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; @@ -116,7 +117,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 +152,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 +204,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, new SegmentBudget(segmentSize * 8L))) { manager.setBeforeTrimSyncHook(servicePass::countDown); manager.register(ring, root, null); manager.start(); @@ -237,7 +238,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 +280,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 +327,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..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,13 +58,12 @@ 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)); manager = new SegmentManager( segmentSize, - SegmentManager.DEFAULT_POLL_NANOS, segmentSize * 4L, filesFacade, ticks::get); @@ -145,11 +144,10 @@ 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, - SegmentManager.DEFAULT_POLL_NANOS, segmentSize * 4L, filesFacade, ticks::get); @@ -245,12 +243,11 @@ 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( 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); @@ -401,13 +397,12 @@ 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)); manager = new SegmentManager( segmentSize, - SegmentManager.DEFAULT_POLL_NANOS, segmentSize * 4L, filesFacade, ticks::get); @@ -549,13 +544,12 @@ 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)); 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..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 @@ -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; @@ -41,13 +42,13 @@ 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()); CountDownLatch trimPass = new CountDownLatch(1); try (SegmentManager manager = new SegmentManager( - segmentSize, TimeUnit.SECONDS.toNanos(60), 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, TimeUnit.SECONDS.toNanos(60), 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 eed6bc878..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, 1_000_000L /* 1ms */, 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/SegmentManagerSharedBudgetTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerSharedBudgetTest.java index 7efcc48ba..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 @@ -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; @@ -47,7 +49,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); @@ -57,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; @@ -70,7 +71,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 +149,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(); @@ -162,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); @@ -176,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()); @@ -193,6 +192,43 @@ public void testRingsOfDifferentManagersShareOneBudget() throws Exception { }); } + /** + * 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 testSiblingDeregisterFreesRoomForRefusedProducer() 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); + 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)); + } 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 { @@ -207,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 3006ec1b2..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 @@ -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; @@ -37,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 { @@ -72,28 +72,26 @@ 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); 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, 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(); }); } @@ -101,30 +99,25 @@ 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); 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, 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(); }); @@ -135,46 +128,57 @@ 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); 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, 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 2381d8b14..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 @@ -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; @@ -44,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 { @@ -90,29 +92,31 @@ 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)); + 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()); @@ -124,7 +128,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)); @@ -137,24 +141,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, 200_000L /* 0.2ms */)) { - 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(() -> { @@ -163,7 +149,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); @@ -180,7 +166,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(); } @@ -192,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(() -> { @@ -202,15 +215,13 @@ 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); // 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)); @@ -244,14 +255,12 @@ 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); // 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(); @@ -275,175 +284,49 @@ 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); 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, new SegmentBudget(cap))) { assertTrue("watermark must open", watermark != null); mgr.start(); // 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); + 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); - 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(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(() -> { - // 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, - // 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 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)) { - 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 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)) { - 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 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)) { - 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(() -> { - 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,19 +349,14 @@ 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); 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 15b8e8a65..000000000 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerTotalBytesRaceTest.java +++ /dev/null @@ -1,224 +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.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, TimeUnit.SECONDS.toNanos(60), 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 for the full 60 s pollNanos. That - // TIMED_WAITING transition 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 e99898af6..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 @@ -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; @@ -88,12 +89,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, new SegmentBudget(maxTotal))) { mgr.start(); String dir = tmpDir + "/single-ring"; @@ -109,14 +110,12 @@ public void testTrimPathDoesNotDoubleSubtractAfterDeregister() throws Exception // 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. @@ -167,9 +166,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); @@ -212,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 b4b60d6fb..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 @@ -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"); @@ -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)); + 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. + 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)) { + 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(() -> { @@ -117,15 +160,19 @@ 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)); 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 +211,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 deleted file mode 100644 index 605b8defe..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, TimeUnit.SECONDS.toNanos(60)); - 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..2567fcde0 --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentProvisionerTest.java @@ -0,0 +1,320 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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.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.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.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +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(); + } + }); + } + + /** + * 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 { + 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); + } + }); + } + + /** + * 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); + } + }); + } +} 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..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); } }); } @@ -611,7 +608,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 +640,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/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..a59145ac6 --- /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 { + 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); + } + 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); + } + } +} 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..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); @@ -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); + 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 + // 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, QueueSegmentSource.peek(ring)); + 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(() -> { @@ -163,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()); @@ -175,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 @@ -261,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); } @@ -308,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. @@ -433,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()); @@ -1168,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 @@ -1275,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); } @@ -1325,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). @@ -1389,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(); @@ -1424,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)); } @@ -1656,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); @@ -1803,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)); @@ -1866,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/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); 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 7744cd1a1..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 @@ -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)); @@ -1626,7 +1627,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); @@ -1634,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; } @@ -1693,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 @@ -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"); } 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/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..39f5c8955 --- /dev/null +++ b/design/tla/AckWait.tla @@ -0,0 +1,69 @@ +-------------------------------- MODULE AckWait -------------------------------- +(* 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 waiter waits for + +VARIABLES acked, \* engine.ackedFsn(), written by the I/O thread + 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 = "register" /\ iopc = "idle" + +---------------------------------------------------------------------------- +(* Waiter: ackWaiters.add(me); loop { check; parkNanos }; ackWaiters.remove. *) +Register == /\ ppc = "register" /\ waiter' = TRUE /\ ppc' = "read" + /\ UNCHANGED <> + +Read == /\ ppc = "read" /\ seen' = acked \* getAckedFsn() >= targetFsn ? + /\ IF acked >= Target THEN waiter' = FALSE /\ ppc' = "done" + ELSE waiter' = waiter /\ ppc' = "park" + /\ UNCHANGED <> + +(* parkNanos: blocks until the permit is available, consumes it. *) +Park == /\ ppc = "park" /\ permit + /\ permit' = FALSE /\ ppc' = "read" + /\ UNCHANGED <> + +---------------------------------------------------------------------------- +(* 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 <> + +Signal == /\ iopc = "signal" + /\ permit' = (permit \/ waiter) /\ iopc' = "idle" + /\ UNCHANGED <> + +Next == Register \/ Read \/ Park \/ Ack \/ Signal + \/ (ppc = "done" /\ acked = Target /\ UNCHANGED vars) + +Spec == Init /\ [][Next]_vars + /\ WF_vars(Register \/ Read \/ Park) + /\ WF_vars(Ack \/ Signal) + +---------------------------------------------------------------------------- +(* 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 registration visible). *) +NoLostAck == (ppc = "park" /\ acked /= seen) => (permit \/ (iopc = "signal" /\ waiter)) + +(* LIVENESS: reaching the target eventually returns the waiter. *) +Returns == (acked >= Target) ~> (ppc = "done") +============================================================================= diff --git a/design/tla/IdleWait.tla b/design/tla/IdleWait.tla new file mode 100644 index 000000000..6a401ab19 --- /dev/null +++ b/design/tla/IdleWait.tla @@ -0,0 +1,182 @@ +------------------------------- 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 = 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 + StampFix \* TRUE: the idle re-check keys on publishStamp + +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 + 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 /\ 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: 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' = 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 < bytes + /\ sent' = sent + 1 /\ didWork' = TRUE /\ pc' = "recv" + /\ UNCHANGED <> + +SendNone == /\ pc = "send" /\ ~serverDrop /\ sent = bytes + /\ 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 Key /= 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 == 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(PublishBytes) /\ 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 < 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 /\ 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. *) +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..357198ae0 --- /dev/null +++ b/design/tla/IdleWait_bug.cfg @@ -0,0 +1,7 @@ +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 new file mode 100644 index 000000000..31d832a8d --- /dev/null +++ b/design/tla/IdleWait_fix.cfg @@ -0,0 +1,7 @@ +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 new file mode 100644 index 000000000..30fd3cfbb --- /dev/null +++ b/design/tla/README.md @@ -0,0 +1,33 @@ +# 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 (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 +``` + +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. diff --git a/design/wake-protocol.md b/design/wake-protocol.md new file mode 100644 index 000000000..ad8dd2eb9 --- /dev/null +++ b/design/wake-protocol.md @@ -0,0 +1,141 @@ +# 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; the ACK-watermark row was removed after it (see Consumer B). + +## 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) | — | +| 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 | +| 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. 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 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`. + +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 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 + +`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 | — | + +## 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 + (`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 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 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. 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 + +Keep moving state to the thread that produces it instead of adding wake +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 e557b628676feaafa2dc24b9125ebbde7ea37f06 Mon Sep 17 00:00:00 2001 From: Jaromir Hamala Date: Sat, 24 Oct 2026 02:01:12 +0200 Subject: [PATCH 02/11] perf(qwp): inline the append-memory capacity check; identity shortcut in column lookup ensureCapacity() carried the realloc in the same body, which was large enough that the JIT would not inline it into every putLong/putDouble. The check is now one compare against a precomputed limit address with the realloc in grow(). lookupColumn() first compares the column name by identity, which callers passing String constants hit every row. asipc, mock server, 60M rows, 1000 rows/flush, 3 interleaved reps: instructions/row -26..-68, rows/s +1..2% across numeric/symbol/string. --- .../qwp/protocol/OffHeapAppendMemory.java | 24 ++++++++++++++----- .../cutlass/qwp/protocol/QwpTableBuffer.java | 4 +++- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/protocol/OffHeapAppendMemory.java b/core/src/main/java/io/questdb/client/cutlass/qwp/protocol/OffHeapAppendMemory.java index 67f9f20e5..2c352518d 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/protocol/OffHeapAppendMemory.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/protocol/OffHeapAppendMemory.java @@ -44,6 +44,8 @@ public class OffHeapAppendMemory implements ArrayBufferAppender, QuietCloseable private static final int DEFAULT_INITIAL_CAPACITY = 128; private long appendAddress; private long capacity; + // pageAddress + capacity, so the hot capacity check is one compare. + private long limitAddress; private long pageAddress; public OffHeapAppendMemory() { @@ -54,6 +56,7 @@ public OffHeapAppendMemory(long initialCapacity) { this.capacity = Math.max(initialCapacity, 8); this.pageAddress = Unsafe.malloc(this.capacity, MemoryTag.NATIVE_ILP_RSS); this.appendAddress = pageAddress; + this.limitAddress = pageAddress + this.capacity; } /** @@ -68,6 +71,7 @@ public void close() { if (pageAddress != 0) { Unsafe.free(pageAddress, capacity, MemoryTag.NATIVE_ILP_RSS); pageAddress = 0; + limitAddress = 0; appendAddress = 0; capacity = 0; } @@ -214,12 +218,20 @@ public void truncate() { } private void ensureCapacity(long needed) { - long used = appendAddress - pageAddress; - if (used + needed > capacity) { - long newCapacity = Math.max(capacity * 2, used + needed); - pageAddress = Unsafe.realloc(pageAddress, capacity, newCapacity, MemoryTag.NATIVE_ILP_RSS); - capacity = newCapacity; - appendAddress = pageAddress + used; + // Fast path kept tiny so the JIT inlines it into every put: the + // realloc lives in grow(). This check ran once per column per row and + // was 15% of the producer's cycles as one body with the slow path. + if (appendAddress + needed > limitAddress) { + grow(needed); } } + + private void grow(long needed) { + long used = appendAddress - pageAddress; + long newCapacity = Math.max(capacity * 2, used + needed); + pageAddress = Unsafe.realloc(pageAddress, capacity, newCapacity, MemoryTag.NATIVE_ILP_RSS); + capacity = newCapacity; + appendAddress = pageAddress + used; + limitAddress = pageAddress + capacity; + } } diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/protocol/QwpTableBuffer.java b/core/src/main/java/io/questdb/client/cutlass/qwp/protocol/QwpTableBuffer.java index 9f01f0ab8..1d8cc3966 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/protocol/QwpTableBuffer.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/protocol/QwpTableBuffer.java @@ -436,7 +436,9 @@ private ColumnBuffer lookupColumn(CharSequence name, byte type) { int n = columns.size(); if (columnAccessCursor < n) { ColumnBuffer candidate = fastColumns[columnAccessCursor]; - if (Chars.equalsIgnoreCase(candidate.name, name)) { + // Callers almost always pass the same String constant row after + // row: identity settles it without the call. + if (candidate.name == name || Chars.equalsIgnoreCase(candidate.name, name)) { columnAccessCursor++; assertColumnType(name, type, candidate); return candidate; From c50bffa7e8d92805448f5c79f76aed14316a3dd8 Mon Sep 17 00:00:00 2001 From: Jaromir Hamala Date: Sun, 25 Oct 2026 20:05:45 +0100 Subject: [PATCH 03/11] perf(sf): skip the frame CRC in memory-backed segments Only recovery reads a frame's CRC, and a memory-backed segment is never recovered, yet every frame paid a CRC pass over all its bytes: 13% of the producer's cycles at one row per flush. The CRC field stays, zeroed, so both backings keep one frame layout; disk segments are unchanged (inverting the condition fails the recovery tests). Measured on asipc against the batching mock, producer and I/O thread pinned to CPUs 2 and 4, 5 interleaved reps, medians: 1 row/flush: rows/s +7.2%, producer instructions/row -4.4% 1000-row numeric: rows/s +2.5% against the baseline's fast mode (+22.9% against its median: the baseline runs are bimodal, 28.0-28.8M or 34.6M; this one is steady at 34.8-36.4M), instructions/row -3.2% 1000-row symbol: +4.3%; 1000-row string: +3.9% --- .../client/cutlass/qwp/client/sf/cursor/MmapSegment.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 0da666e0e..3e176f9da 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 @@ -740,7 +740,10 @@ long tryWrite(long payloadAddr, int payloadLen) { if (payloadLen > 0) { Unsafe.getUnsafe().copyMemory(payloadAddr, mmapAddress + offset + FRAME_HEADER_SIZE, payloadLen); } - int crc = Crc32c.update(Crc32c.INIT, lenAddr, 4L + payloadLen); + // Only recovery reads the CRC, and a memory-backed segment is never + // recovered: it would be a pass over every byte for nothing. The field + // stays, zeroed, so both backings keep one frame layout. + int crc = memoryBacked ? 0 : Crc32c.update(Crc32c.INIT, lenAddr, 4L + payloadLen); Unsafe.getUnsafe().putInt(mmapAddress + offset, crc); appendCursor = offset + total; // Plain read + write of the volatile field. `frameCount++` would From b602fbd252117ce48e97b1da86e9dc98db5eb0b1 Mon Sep 17 00:00:00 2001 From: Jaromir Hamala Date: Sun, 25 Oct 2026 20:34:32 +0100 Subject: [PATCH 04/11] perf(qwp): ASCII fast path in OffHeapAppendMemory.putUtf8 Encode on a local cursor and leave for the general loop at the first non-ASCII char. Writing the appendAddress field per char, interleaved with Unsafe stores, kept the JIT from holding the address in a register. String column (80M rows, 1000 rows/flush, pinned): +8.6% rows/s, producer instructions -9.0%. Other shapes unchanged. --- .../qwp/protocol/OffHeapAppendMemory.java | 69 ++++++++++++------- .../qwp/protocol/OffHeapAppendMemoryTest.java | 34 +++++++++ 2 files changed, 79 insertions(+), 24 deletions(-) diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/protocol/OffHeapAppendMemory.java b/core/src/main/java/io/questdb/client/cutlass/qwp/protocol/OffHeapAppendMemory.java index 2c352518d..1326bc1da 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/protocol/OffHeapAppendMemory.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/protocol/OffHeapAppendMemory.java @@ -173,33 +173,22 @@ public void putUtf8(CharSequence value) { } int len = value.length(); ensureCapacity((long) len * 4); // worst case: all supplementary chars - for (int i = 0; i < len; i++) { + // ASCII fast path on a local cursor: writing the appendAddress field + // per char, interleaved with Unsafe stores, kept the JIT from holding + // the address in a register. Most values never leave this loop. + long addr = appendAddress; + int i = 0; + for (; i < len; i++) { char c = value.charAt(i); - if (c < 0x80) { - Unsafe.getUnsafe().putByte(appendAddress++, (byte) c); - } else if (c < 0x800) { - Unsafe.getUnsafe().putByte(appendAddress++, (byte) (0xC0 | (c >> 6))); - Unsafe.getUnsafe().putByte(appendAddress++, (byte) (0x80 | (c & 0x3F))); - } else if (c >= 0xD800 && c <= 0xDBFF && i + 1 < len) { - char c2 = value.charAt(++i); - if (Character.isLowSurrogate(c2)) { - int codePoint = 0x10000 + ((c - 0xD800) << 10) + (c2 - 0xDC00); - Unsafe.getUnsafe().putByte(appendAddress++, (byte) (0xF0 | (codePoint >> 18))); - Unsafe.getUnsafe().putByte(appendAddress++, (byte) (0x80 | ((codePoint >> 12) & 0x3F))); - Unsafe.getUnsafe().putByte(appendAddress++, (byte) (0x80 | ((codePoint >> 6) & 0x3F))); - Unsafe.getUnsafe().putByte(appendAddress++, (byte) (0x80 | (codePoint & 0x3F))); - } else { - Unsafe.getUnsafe().putByte(appendAddress++, (byte) '?'); - i--; - } - } else if (Character.isSurrogate(c)) { - Unsafe.getUnsafe().putByte(appendAddress++, (byte) '?'); - } else { - Unsafe.getUnsafe().putByte(appendAddress++, (byte) (0xE0 | (c >> 12))); - Unsafe.getUnsafe().putByte(appendAddress++, (byte) (0x80 | ((c >> 6) & 0x3F))); - Unsafe.getUnsafe().putByte(appendAddress++, (byte) (0x80 | (c & 0x3F))); + if (c >= 0x80) { + break; } + Unsafe.getUnsafe().putByte(addr++, (byte) c); } + if (i < len) { + addr = putUtf8NonAscii(value, i, len, addr); + } + appendAddress = addr; } /** @@ -217,6 +206,38 @@ public void truncate() { appendAddress = pageAddress; } + // From the first non-ASCII char on; returns the address past the last byte. + private static long putUtf8NonAscii(CharSequence value, int i, int len, long addr) { + for (; i < len; i++) { + char c = value.charAt(i); + if (c < 0x80) { + Unsafe.getUnsafe().putByte(addr++, (byte) c); + } else if (c < 0x800) { + Unsafe.getUnsafe().putByte(addr++, (byte) (0xC0 | (c >> 6))); + Unsafe.getUnsafe().putByte(addr++, (byte) (0x80 | (c & 0x3F))); + } else if (c >= 0xD800 && c <= 0xDBFF && i + 1 < len) { + char c2 = value.charAt(++i); + if (Character.isLowSurrogate(c2)) { + int codePoint = 0x10000 + ((c - 0xD800) << 10) + (c2 - 0xDC00); + Unsafe.getUnsafe().putByte(addr++, (byte) (0xF0 | (codePoint >> 18))); + Unsafe.getUnsafe().putByte(addr++, (byte) (0x80 | ((codePoint >> 12) & 0x3F))); + Unsafe.getUnsafe().putByte(addr++, (byte) (0x80 | ((codePoint >> 6) & 0x3F))); + Unsafe.getUnsafe().putByte(addr++, (byte) (0x80 | (codePoint & 0x3F))); + } else { + Unsafe.getUnsafe().putByte(addr++, (byte) '?'); + i--; + } + } else if (Character.isSurrogate(c)) { + Unsafe.getUnsafe().putByte(addr++, (byte) '?'); + } else { + Unsafe.getUnsafe().putByte(addr++, (byte) (0xE0 | (c >> 12))); + Unsafe.getUnsafe().putByte(addr++, (byte) (0x80 | ((c >> 6) & 0x3F))); + Unsafe.getUnsafe().putByte(addr++, (byte) (0x80 | (c & 0x3F))); + } + } + return addr; + } + private void ensureCapacity(long needed) { // Fast path kept tiny so the JIT inlines it into every put: the // realloc lives in grow(). This check ran once per column per row and diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/protocol/OffHeapAppendMemoryTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/protocol/OffHeapAppendMemoryTest.java index 0f441bd51..164dae412 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/protocol/OffHeapAppendMemoryTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/protocol/OffHeapAppendMemoryTest.java @@ -316,6 +316,40 @@ public void testPutUtf8Mixed() throws Exception { }); } + @Test + public void testPutUtf8MatchesTheJavaEncoderOnRandomStrings() throws Exception { + assertMemoryLeak(() -> { + // ASCII, 2- and 3-byte chars, a surrogate pair, and lone high and low + // surrogates (Java's encoder writes '?' for those), so every run mixes + // the ASCII fast path with the general one in every order. + final String[] pieces = {"a", "Z", "7", " ", "\u00e9", "\u20ac", "\uD83D\uDE00", "\uD800", "\uDC00"}; + final java.util.Random rnd = new java.util.Random(42); + try (OffHeapAppendMemory mem = new OffHeapAppendMemory()) { + java.io.ByteArrayOutputStream expected = new java.io.ByteArrayOutputStream(); + for (int n = 0; n < 2000; n++) { + StringBuilder sb = new StringBuilder(); + for (int k = rnd.nextInt(40); k > 0; k--) { + sb.append(pieces[rnd.nextInt(pieces.length)]); + } + String value = sb.toString(); + // String and a non-String CharSequence take the same path + if ((n & 1) == 0) { + mem.putUtf8(value); + } else { + mem.putUtf8(sb); + } + byte[] utf8 = value.getBytes(java.nio.charset.StandardCharsets.UTF_8); + expected.write(utf8, 0, utf8.length); + } + byte[] want = expected.toByteArray(); + assertEquals(want.length, mem.getAppendOffset()); + for (int i = 0; i < want.length; i++) { + assertEquals("byte " + i, want[i], Unsafe.getUnsafe().getByte(mem.addressOf(i))); + } + } + }); + } + @Test public void testPutUtf8MultiByte() throws Exception { assertMemoryLeak(() -> { From 5981feab83c81ca5bd0440e7d51f6db634d3dbda Mon Sep 17 00:00:00 2001 From: Jaromir Hamala Date: Sun, 25 Oct 2026 21:08:14 +0100 Subject: [PATCH 05/11] perf(qwp): word moves for small native copies Vect.memcpy copies up to 64 bytes with 8-byte moves instead of the Unsafe.copyMemory stub, and NativeBufferWriter.putBlockOfBytes goes through it. At one row per flush each column is a few bytes, and the stub's call and dispatch cost more than the copy: 7.3% of the producer. One row per flush, pinned: +8.3% rows/s, producer instructions/row -7.5%. 1000-row shapes unchanged. --- .../qwp/client/NativeBufferWriter.java | 3 +- .../main/java/io/questdb/client/std/Vect.java | 14 ++++++++ .../questdb/client/test/std/VectFuzzTest.java | 34 +++++++++++++++++++ 3 files changed, 50 insertions(+), 1 deletion(-) diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/NativeBufferWriter.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/NativeBufferWriter.java index 863437725..7e0edb96e 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/NativeBufferWriter.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/NativeBufferWriter.java @@ -27,6 +27,7 @@ import io.questdb.client.std.MemoryTag; import io.questdb.client.std.QuietCloseable; import io.questdb.client.std.Unsafe; +import io.questdb.client.std.Vect; import io.questdb.client.std.str.Utf8s; /** @@ -183,7 +184,7 @@ public void putBlockOfBytes(long from, long len) { } int intLen = (int) len; ensureCapacity(intLen); - Unsafe.getUnsafe().copyMemory(from, bufferPtr + position, intLen); + Vect.memcpy(bufferPtr + position, from, intLen); position += intLen; } diff --git a/core/src/main/java/io/questdb/client/std/Vect.java b/core/src/main/java/io/questdb/client/std/Vect.java index ed75d2636..f177d2826 100644 --- a/core/src/main/java/io/questdb/client/std/Vect.java +++ b/core/src/main/java/io/questdb/client/std/Vect.java @@ -25,7 +25,21 @@ package io.questdb.client.std; public final class Vect { + private static final int SMALL_COPY_MAX = 64; + public static void memcpy(long dst, long src, long len) { + if (len <= SMALL_COPY_MAX) { + // A column of one row is a few bytes: word moves beat the copy + // stub's call and dispatch. + long i = 0; + for (; i + 8 <= len; i += 8) { + Unsafe.getUnsafe().putLong(dst + i, Unsafe.getUnsafe().getLong(src + i)); + } + for (; i < len; i++) { + Unsafe.getUnsafe().putByte(dst + i, Unsafe.getUnsafe().getByte(src + i)); + } + return; + } // the split length was determined experimentally // using 'MemCopyBenchmark' bench if (len < 4096) { diff --git a/core/src/test/java/io/questdb/client/test/std/VectFuzzTest.java b/core/src/test/java/io/questdb/client/test/std/VectFuzzTest.java index 4d606843d..89ee60066 100644 --- a/core/src/test/java/io/questdb/client/test/std/VectFuzzTest.java +++ b/core/src/test/java/io/questdb/client/test/std/VectFuzzTest.java @@ -34,6 +34,40 @@ import org.junit.Test; public class VectFuzzTest { + @Test + public void testMemcpyEverySmallLengthAndAlignment() throws Exception { + assertMemoryLeak(() -> { + // crosses the small-copy cutoff, at every source/destination misalignment; + // the guard byte after each copy must stay untouched + int maxLen = 160; + int size = maxLen + 16; + long from = Unsafe.malloc(size, MemoryTag.NATIVE_DEFAULT); + long to = Unsafe.malloc(size, MemoryTag.NATIVE_DEFAULT); + try { + for (int i = 0; i < size; i++) { + Unsafe.getUnsafe().putByte(from + i, (byte) (i * 31 + 7)); + } + for (int len = 0; len <= maxLen; len++) { + for (int srcOff = 0; srcOff < 8; srcOff++) { + for (int dstOff = 0; dstOff < 8; dstOff++) { + Vect.memset(to, size, 0); + Vect.memcpy(to + dstOff, from + srcOff, len); + for (int i = 0; i < len; i++) { + if (Unsafe.getUnsafe().getByte(to + dstOff + i) != Unsafe.getUnsafe().getByte(from + srcOff + i)) { + Assert.fail("len=" + len + " srcOff=" + srcOff + " dstOff=" + dstOff + " byte " + i); + } + } + Assert.assertEquals("guard, len=" + len, 0, Unsafe.getUnsafe().getByte(to + dstOff + len)); + } + } + } + } finally { + Unsafe.free(from, size, MemoryTag.NATIVE_DEFAULT); + Unsafe.free(to, size, MemoryTag.NATIVE_DEFAULT); + } + }); + } + @Test public void testMemmove() throws Exception { assertMemoryLeak(() -> { From b48a01274fcb0fa10c3f53f5ce986cf8e4f52382 Mon Sep 17 00:00:00 2001 From: Jaromir Hamala Date: Tue, 27 Oct 2026 14:23:30 +0100 Subject: [PATCH 06/11] Release a held segment's budget on deregister A rotation whose manifest update throws hands its new segment back to the SegmentProvisioner, still charged to the SegmentBudget, so a retry can reuse it. SegmentManager.deregister released only SegmentRing.totalSegmentBytes(), which did not count that segment, and SegmentProvisioner.close() closed it without a release. A pooled sender closed after such a flush failure therefore left one segment charged to the pool's shared budget for the life of the pool. SegmentSource gains heldBytes(), which the provisioner reports for its retained segment, and totalSegmentBytes() includes it. Deregister now releases the whole ring, as it did for the hot spare before. Close still does not release, so the charge is returned exactly once. SegmentManagerSharedBudgetTest injects a manifest fsync failure and checks that deregister returns the budget to zero, and that repeated failed rotations and the retry that finally commits charge one segment only. --- .../qwp/client/sf/cursor/SegmentManager.java | 3 +- .../client/sf/cursor/SegmentProvisioner.java | 18 ++- .../qwp/client/sf/cursor/SegmentRing.java | 18 ++- .../qwp/client/sf/cursor/SegmentSource.java | 10 ++ .../SegmentManagerSharedBudgetTest.java | 137 ++++++++++++++++++ 5 files changed, 174 insertions(+), 12 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 0c27e794d..b716b3143 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 @@ -516,7 +516,8 @@ public void deregister(SegmentRing 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. + // totalSegmentBytes() now, including a segment the + // provisioner holds after a failed rotation. e.deregister(); budget.release(ring.totalSegmentBytes()); if (e.sideFileBytes != null) { 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 a950c08f8..b547fba7b 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 @@ -73,9 +73,11 @@ public final class SegmentProvisioner implements SegmentSource { // 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; + // a base with no segment and quarantine the slot). Still charged, and + // counted by heldBytes() so a deregister releases it; the next rotation + // at the same base reuses it. Volatile: deregister reads it from the + // closing thread. + private volatile MmapSegment retained; private long retryAtNanos; SegmentProvisioner(SegmentRing ring, String dir, long firstGeneration, long segmentSizeBytes, @@ -92,7 +94,9 @@ public final class SegmentProvisioner implements SegmentSource { public void close() { // 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. + // name, which recovery accepts either way. Its budget charge is not + // released here: SegmentManager.deregister already released it as + // part of the ring's totalSegmentBytes() (see heldBytes()). if (retained != null) { MmapSegment r = retained; retained = null; @@ -105,6 +109,12 @@ public void discard(MmapSegment segment) { retained = segment; } + @Override + public long heldBytes() { + MmapSegment r = retained; + return r == null ? 0L : r.sizeBytes(); + } + @Override public MmapSegment next(long baseSeq) { if (retained != 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 fc273dbca..da7c347f4 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 @@ -1710,20 +1710,24 @@ public void syncAllLiveSegments() { clearSyncRequestIfActiveDurable(); } - /** - * 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. - */ /** 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(); } + /** + * Total mmap'd bytes the ring currently owns: active + every sealed + * segment (pending trims included) + whatever its {@link SegmentSource} + * holds for the next rotation (a segment a failed rotation handed back). + * Used by {@code SegmentManager} to seed its budget charge at register + * time and to reverse the ring's whole charge at deregister time. + * Synchronized against rotation so we never read a half-resized sealed + * list. + */ public synchronized long totalSegmentBytes() { long total = 0L; + SegmentSource source = segmentSource; + if (source != null) total += source.heldBytes(); MmapSegment a = active; if (a != null) total += a.sizeBytes(); for (int i = sealedHead, n = sealedSegments.size(); i < n; i++) { 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 6adbbe07d..f3c05c365 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 @@ -50,6 +50,16 @@ public interface SegmentSource extends QuietCloseable { */ void discard(MmapSegment segment); + /** + * Bytes of segments the source holds for the ring's next rotation (a + * segment handed back through {@link #discard}). They stay charged to the + * budget, so {@link SegmentRing#totalSegmentBytes()} counts them and a + * deregister releases them with the rest of the ring. + */ + default long heldBytes() { + return 0L; + } + /** 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/sf/cursor/SegmentManagerSharedBudgetTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentManagerSharedBudgetTest.java index aa621f34d..892e09f8e 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 @@ -25,12 +25,14 @@ 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.MmapSegmentException; 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; @@ -142,6 +144,89 @@ public void testConcurrentManagersStayWithinBudgetAndReleaseEveryCharge() throws }); } + @Test + public void testFailedRotationThenDeregisterReleasesTheHeldSegment() throws Exception { + // A rotation whose manifest fsync fails hands its new segment back to + // the provisioner, still charged. A sender that is closed at that point + // (what a pool does with a sender whose flush threw) must give that + // charge back to the shared budget along with the rest of the ring. + String dir = TestUtils.createTmpDir("qdb-sf-shared-budget-"); + try { + TestUtils.assertMemoryLeak(() -> { + ManifestFsyncFaultFacade ff = new ManifestFsyncFaultFacade(dir); + SegmentBudget budget = new SegmentBudget(8 * SEGMENT_SIZE); + long buf = Unsafe.malloc(FRAME_PAYLOAD, MemoryTag.NATIVE_DEFAULT); + try (SegmentRing ring = newDiskRing(ff, dir); + SegmentManager manager = new SegmentManager(SEGMENT_SIZE, budget, ff, System::nanoTime)) { + manager.start(); + manager.register(ring, dir); + Assert.assertEquals(SEGMENT_SIZE, budget.getSegmentBytes()); + appendFrames(ring, buf, 2); + + ff.failManifestFsync = true; + assertRotationFails(ring, buf); + Assert.assertEquals("the handed-back segment stays charged", + 2 * SEGMENT_SIZE, budget.getSegmentBytes()); + Assert.assertEquals("the ring accounts for the held segment", + 2 * SEGMENT_SIZE, ring.totalSegmentBytes()); + + manager.deregister(ring); + Assert.assertTrue(manager.awaitRingQuiescence(ring)); + Assert.assertEquals("deregister must release the held segment too", + 0, budget.getSegmentBytes()); + ring.close(); + Assert.assertEquals("closing the ring must not release it a second time", + 0, budget.getSegmentBytes()); + } finally { + Unsafe.free(buf, FRAME_PAYLOAD, MemoryTag.NATIVE_DEFAULT); + } + }); + } finally { + TestUtils.removeTmpDir(dir); + } + } + + @Test + public void testFailedRotationRetriesReuseTheHeldSegment() throws Exception { + // Repeated manifest failures keep handing back and reusing ONE charged + // segment, and the rotation that finally commits takes it over without + // a second charge: deregister then releases exactly what was charged. + String dir = TestUtils.createTmpDir("qdb-sf-shared-budget-"); + try { + TestUtils.assertMemoryLeak(() -> { + ManifestFsyncFaultFacade ff = new ManifestFsyncFaultFacade(dir); + SegmentBudget budget = new SegmentBudget(8 * SEGMENT_SIZE); + long buf = Unsafe.malloc(FRAME_PAYLOAD, MemoryTag.NATIVE_DEFAULT); + try (SegmentRing ring = newDiskRing(ff, dir); + SegmentManager manager = new SegmentManager(SEGMENT_SIZE, budget, ff, System::nanoTime)) { + manager.start(); + manager.register(ring, dir); + appendFrames(ring, buf, 2); + + ff.failManifestFsync = true; + for (int i = 0; i < 3; i++) { + assertRotationFails(ring, buf); + Assert.assertEquals("retry " + i + " must not charge another segment", + 2 * SEGMENT_SIZE, budget.getSegmentBytes()); + } + + ff.failManifestFsync = false; + appendFrames(ring, buf, 1); + Assert.assertEquals(2 * SEGMENT_SIZE, ring.totalSegmentBytes()); + Assert.assertEquals(2 * SEGMENT_SIZE, budget.getSegmentBytes()); + + manager.deregister(ring); + Assert.assertTrue(manager.awaitRingQuiescence(ring)); + Assert.assertEquals(0, budget.getSegmentBytes()); + } finally { + Unsafe.free(buf, FRAME_PAYLOAD, MemoryTag.NATIVE_DEFAULT); + } + }); + } finally { + TestUtils.removeTmpDir(dir); + } + } + @Test public void testRingsOfDifferentManagersShareOneBudget() throws Exception { TestUtils.assertMemoryLeak(() -> { @@ -247,10 +332,62 @@ private static void assertCapped(SegmentRing ring, long buf) { Assert.assertEquals(SegmentRing.BACKPRESSURE_NO_SPARE, ring.appendOrFsn(buf, FRAME_PAYLOAD)); } + private static void assertRotationFails(SegmentRing ring, long buf) { + try { + ring.appendOrFsn(buf, FRAME_PAYLOAD); + Assert.fail("rotation must surface the manifest fsync failure"); + } catch (MmapSegmentException e) { + TestUtils.assertContains(e.getMessage(), "could not sync SF manifest"); + } + } + + // A disk ring with a manifest: one empty segment recovered from dir. + private static SegmentRing newDiskRing(FilesFacade ff, String dir) { + MmapSegment.create(ff, dir + "/sf-initial.sfa", 0, SEGMENT_SIZE).close(); + SegmentRing ring = SegmentRing.openExisting(ff, dir, SEGMENT_SIZE); + Assert.assertNotNull(ring); + return ring; + } + private static SegmentRing newRing() { return new SegmentRing(MmapSegment.createInMemory(0, SEGMENT_SIZE), SEGMENT_SIZE); } + private static final class ManifestFsyncFaultFacade extends DelegatingFilesFacade { + private final String manifestPath; + private volatile boolean failManifestFsync; + private volatile int manifestFd = -1; + + private ManifestFsyncFaultFacade(String dir) { + this.manifestPath = dir + "/sf-manifest.bin"; + } + + @Override + public int fsync(int fd) { + if (failManifestFsync && fd == manifestFd) { + return -1; + } + return super.fsync(fd); + } + + @Override + public int openRW(String path) { + return track(path, super.openRW(path)); + } + + @Override + public int openRWExclusive(String path) { + return track(path, super.openRWExclusive(path)); + } + + private int track(String path, int fd) { + if (manifestPath.equals(path)) { + manifestFd = fd; + } + return fd; + } + } + private static boolean waitFor(BooleanSupplier condition) throws InterruptedException { long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10); while (!condition.getAsBoolean()) { From 4ea23c167ab31647959f9a69f70cf6351241b441 Mon Sep 17 00:00:00 2001 From: Jaromir Hamala Date: Thu, 29 Oct 2026 14:45:23 +0100 Subject: [PATCH 07/11] Remove compatibility bridges from internal QWP send loop --- .../sf/cursor/CursorWebSocketSendLoop.java | 342 +----------------- .../SymbolDictRecycleLoopTerminalTest.java | 3 +- ...ursorWebSocketSendLoopConnectGateTest.java | 5 +- ...sorWebSocketSendLoopGracefulCloseTest.java | 5 +- .../CursorWebSocketSendLoopIdleWaitTest.java | 11 +- 5 files changed, 31 insertions(+), 335 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 48a20b5d9..6d429bcb7 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 @@ -661,96 +661,17 @@ public final class CursorWebSocketSendLoop implements QuietCloseable { // only suppresses errors the user already owned before close() began. private volatile LineSenderException synchronouslySurfacedError; - public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine, - long fsnAtZero, - ReconnectFactory reconnectFactory, - long reconnectInitialBackoffMillis, - long reconnectMaxBackoffMillis, - boolean durableAckMode) { - this(client, engine, fsnAtZero, reconnectFactory, - reconnectInitialBackoffMillis, reconnectMaxBackoffMillis, - durableAckMode ? DurableAckTiers.REPLICATED : DurableAckTiers.NONE); - } - - public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine, - long fsnAtZero, - ReconnectFactory reconnectFactory, - long reconnectInitialBackoffMillis, - long reconnectMaxBackoffMillis, - boolean durableAckMode, - long durableAckKeepaliveIntervalMillis) { - this(client, engine, fsnAtZero, reconnectFactory, - reconnectInitialBackoffMillis, reconnectMaxBackoffMillis, - durableAckMode ? DurableAckTiers.REPLICATED : DurableAckTiers.NONE, - durableAckKeepaliveIntervalMillis); - } - - public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine, - long fsnAtZero, - ReconnectFactory reconnectFactory, - long reconnectInitialBackoffMillis, - long reconnectMaxBackoffMillis, - boolean durableAckMode, - long durableAckKeepaliveIntervalMillis, - int maxHeadFrameRejections) { - this(client, engine, fsnAtZero, reconnectFactory, - reconnectInitialBackoffMillis, reconnectMaxBackoffMillis, - durableAckMode ? DurableAckTiers.REPLICATED : DurableAckTiers.NONE, - durableAckKeepaliveIntervalMillis, maxHeadFrameRejections); - } - - public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine, - long fsnAtZero, - ReconnectFactory reconnectFactory, - long reconnectInitialBackoffMillis, - long reconnectMaxBackoffMillis, - boolean durableAckMode, - long durableAckKeepaliveIntervalMillis, - int maxHeadFrameRejections, - long poisonMinEscalationWindowMillis) { - this(client, engine, fsnAtZero, reconnectFactory, - reconnectInitialBackoffMillis, reconnectMaxBackoffMillis, - durableAckMode ? DurableAckTiers.REPLICATED : DurableAckTiers.NONE, - durableAckKeepaliveIntervalMillis, maxHeadFrameRejections, - poisonMinEscalationWindowMillis); - } - - public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine, - long fsnAtZero, - ReconnectFactory reconnectFactory, - long reconnectInitialBackoffMillis, - long reconnectMaxBackoffMillis, - boolean durableAckMode, - long durableAckKeepaliveIntervalMillis, - int maxHeadFrameRejections, - long poisonMinEscalationWindowMillis, - long catchUpCapGapMinEscalationWindowMillis) { - this(client, engine, fsnAtZero, reconnectFactory, - reconnectInitialBackoffMillis, reconnectMaxBackoffMillis, - durableAckMode ? DurableAckTiers.REPLICATED : DurableAckTiers.NONE, - durableAckKeepaliveIntervalMillis, maxHeadFrameRejections, - poisonMinEscalationWindowMillis, catchUpCapGapMinEscalationWindowMillis); - } - - public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine, - long fsnAtZero, - 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 ? DurableAckTiers.REPLICATED : DurableAckTiers.NONE, - durableAckKeepaliveIntervalMillis, maxHeadFrameRejections, - poisonMinEscalationWindowMillis, catchUpCapGapMinEscalationWindowMillis, - reconnectPolicy, 0L); - } - + /** + * Policy-aware master constructor. A foreground sender fails fast while + * establishing its first connection, then retries endpoint-policy failures + * indefinitely after it has been live. An orphan drainer returns such failures + * to its owner so the slot can follow its settle/quarantine policy. + *

    + * {@code externalFsnBase} is the additive offset this loop folds into every + * user-visible FSN it produces (progress-dispatcher advances and + * {@link SenderError} spans) -- see {@link #externalFsnBase}. Pass {@code 0L} + * unless the caller is replacing an engine a symbol-dict recycle rebuilt. + */ public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine, long fsnAtZero, ReconnectFactory reconnectFactory, @@ -798,12 +719,12 @@ public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine, } /** - * Same as the seven-arg constructor but with explicit control over + * Same as the six-arg constructor but with explicit control over * durable-ack-driven trim. A non-empty {@code durableAckTiers} set * ({@link DurableAckTiers}) switches the loop to trim only on the * strongest requested tier's ack frames; OK * frames are queued until their per-table seqTxns are covered by a durable - * watermark. The default (false) preserves the historical OK-driven trim + * watermark. The default (NONE) preserves the historical OK-driven trim * and ignores any durable-ack frames that arrive (logging a warning, since * a server should not emit them when the client did not opt in). */ @@ -840,7 +761,7 @@ public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine, } /** - * Ten-arg overload — omits the poison-escalation dwell window, which + * Nine-arg overload — omits the poison-escalation dwell window, which * defaults to {@code 0} (legacy: escalate as soon as maxHeadFrameRejections * strikes accrue, with no minimum wall-clock). The user-facing 5s default * ({@link #DEFAULT_POISON_MIN_ESCALATION_WINDOW_MILLIS}) is applied at the @@ -863,8 +784,8 @@ public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine, } /** - * Eleven-arg overload — omits the symbol-dict cap-gap escalation dwell, which - * defaults to {@code 0}. This and the twelve-arg overload use the foreground-safe + * Ten-arg overload — omits the symbol-dict cap-gap escalation dwell, which + * defaults to {@code 0}. This and the eleven-arg overload use the foreground-safe * retry-forever policy; orphan drainers opt into count-and-dwell escalation through * the policy-aware master constructor. */ @@ -885,7 +806,7 @@ public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine, } /** - * Twelve-arg overload. Catch-up cap gaps are retried indefinitely, which is the + * Eleven-arg overload. Catch-up cap gaps are retried indefinitely, which is the * required policy for a foreground store-and-forward sender. Orphan drainers use the * policy-aware overload below to opt into bounded terminal escalation. *

    @@ -1146,235 +1067,6 @@ public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine, reconnectPolicy, 0L); } - /** - * Policy-aware master constructor. A foreground sender fails fast while - * establishing its first connection, then retries endpoint-policy failures - * indefinitely after it has been live. An orphan drainer returns such failures - * to its owner so the slot can follow its settle/quarantine policy. - *

    - * {@code externalFsnBase} is the additive offset this loop folds into every - * user-visible FSN it produces (progress-dispatcher advances and - * {@link SenderError} spans) -- see {@link #externalFsnBase}. Pass {@code 0L} - * unless the caller is replacing an engine a symbol-dict recycle rebuilt. - */ - public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine, - long fsnAtZero, - ReconnectFactory reconnectFactory, - long reconnectInitialBackoffMillis, - long reconnectMaxBackoffMillis, - boolean durableAckMode, - long durableAckKeepaliveIntervalMillis, - int maxHeadFrameRejections, - long poisonMinEscalationWindowMillis, - long catchUpCapGapMinEscalationWindowMillis, - ReconnectPolicy reconnectPolicy, - long externalFsnBase) { - this(client, engine, fsnAtZero, reconnectFactory, - reconnectInitialBackoffMillis, - reconnectMaxBackoffMillis, - durableAckMode ? DurableAckTiers.REPLICATED : DurableAckTiers.NONE, - durableAckKeepaliveIntervalMillis, maxHeadFrameRejections, - poisonMinEscalationWindowMillis, catchUpCapGapMinEscalationWindowMillis, - reconnectPolicy, externalFsnBase); - } - - /** - * 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); - } - - @Deprecated - public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine, - long fsnAtZero, long parkNanos, - ReconnectFactory reconnectFactory, - long reconnectInitialBackoffMillis, - long reconnectMaxBackoffMillis, - int durableAckTiers, - long durableAckKeepaliveIntervalMillis, - int maxHeadFrameRejections, - long poisonMinEscalationWindowMillis, - long catchUpCapGapMinEscalationWindowMillis, - ReconnectPolicy reconnectPolicy, - long externalFsnBase) { - this(client, engine, fsnAtZero, reconnectFactory, reconnectInitialBackoffMillis, reconnectMaxBackoffMillis, durableAckTiers, durableAckKeepaliveIntervalMillis, maxHeadFrameRejections, poisonMinEscalationWindowMillis, catchUpCapGapMinEscalationWindowMillis, reconnectPolicy, externalFsnBase); - } - - @Deprecated - public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine, - long fsnAtZero, long parkNanos, - ReconnectFactory reconnectFactory, - long reconnectInitialBackoffMillis, - long reconnectMaxBackoffMillis, - int durableAckTiers) { - this(client, engine, fsnAtZero, reconnectFactory, reconnectInitialBackoffMillis, reconnectMaxBackoffMillis, durableAckTiers); - } - - @Deprecated - public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine, - long fsnAtZero, long parkNanos, - ReconnectFactory reconnectFactory, - long reconnectInitialBackoffMillis, - long reconnectMaxBackoffMillis, - int durableAckTiers, - long durableAckKeepaliveIntervalMillis) { - this(client, engine, fsnAtZero, reconnectFactory, reconnectInitialBackoffMillis, reconnectMaxBackoffMillis, durableAckTiers, durableAckKeepaliveIntervalMillis); - } - - @Deprecated - public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine, - long fsnAtZero, long parkNanos, - ReconnectFactory reconnectFactory, - long reconnectInitialBackoffMillis, - long reconnectMaxBackoffMillis, - int durableAckTiers, - long durableAckKeepaliveIntervalMillis, - int maxHeadFrameRejections) { - this(client, engine, fsnAtZero, reconnectFactory, reconnectInitialBackoffMillis, reconnectMaxBackoffMillis, durableAckTiers, durableAckKeepaliveIntervalMillis, maxHeadFrameRejections); - } - - @Deprecated - public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine, - long fsnAtZero, long parkNanos, - ReconnectFactory reconnectFactory, - long reconnectInitialBackoffMillis, - long reconnectMaxBackoffMillis, - int durableAckTiers, - long durableAckKeepaliveIntervalMillis, - int maxHeadFrameRejections, - long poisonMinEscalationWindowMillis) { - this(client, engine, fsnAtZero, reconnectFactory, reconnectInitialBackoffMillis, reconnectMaxBackoffMillis, durableAckTiers, durableAckKeepaliveIntervalMillis, maxHeadFrameRejections, poisonMinEscalationWindowMillis); - } - - @Deprecated - public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine, - long fsnAtZero, long parkNanos, - ReconnectFactory reconnectFactory, - long reconnectInitialBackoffMillis, - long reconnectMaxBackoffMillis, - int durableAckTiers, - long durableAckKeepaliveIntervalMillis, - int maxHeadFrameRejections, - long poisonMinEscalationWindowMillis, - long catchUpCapGapMinEscalationWindowMillis) { - this(client, engine, fsnAtZero, reconnectFactory, reconnectInitialBackoffMillis, reconnectMaxBackoffMillis, durableAckTiers, durableAckKeepaliveIntervalMillis, maxHeadFrameRejections, poisonMinEscalationWindowMillis, catchUpCapGapMinEscalationWindowMillis); - } - - @Deprecated - public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine, - long fsnAtZero, long parkNanos, - ReconnectFactory reconnectFactory, - long reconnectInitialBackoffMillis, - long reconnectMaxBackoffMillis, - int durableAckTiers, - long durableAckKeepaliveIntervalMillis, - int maxHeadFrameRejections, - long poisonMinEscalationWindowMillis, - long catchUpCapGapMinEscalationWindowMillis, - ReconnectPolicy reconnectPolicy) { - this(client, engine, fsnAtZero, reconnectFactory, reconnectInitialBackoffMillis, reconnectMaxBackoffMillis, durableAckTiers, durableAckKeepaliveIntervalMillis, maxHeadFrameRejections, poisonMinEscalationWindowMillis, catchUpCapGapMinEscalationWindowMillis, reconnectPolicy); - } - - @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, - long externalFsnBase) { - this(client, engine, fsnAtZero, reconnectFactory, reconnectInitialBackoffMillis, reconnectMaxBackoffMillis, durableAckMode, durableAckKeepaliveIntervalMillis, maxHeadFrameRejections, poisonMinEscalationWindowMillis, catchUpCapGapMinEscalationWindowMillis, reconnectPolicy, externalFsnBase); - } - private static CatchUpCapGapPolicy catchUpPolicyFor(ReconnectPolicy reconnectPolicy) { if (reconnectPolicy == null) { throw new IllegalArgumentException("reconnectPolicy must be non-null"); 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 6fccb2d5d..ef68248a9 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.cutlass.qwp.client.DurableAckTiers; import io.questdb.client.test.tools.TestWebSocketClient; import io.questdb.client.DefaultHttpClientConfiguration; import io.questdb.client.LineSenderServerException; @@ -216,7 +217,7 @@ private static CursorWebSocketSendLoop newLoop(WebSocketClient client, CursorSen }, /* reconnectInitialBackoffMillis */ 1_000L, /* reconnectMaxBackoffMillis */ 5_000L, - false + DurableAckTiers.NONE ); } 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 881745126..4ffd8a590 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,6 +24,7 @@ package io.questdb.client.test.cutlass.qwp.client.sf.cursor; +import io.questdb.client.cutlass.qwp.client.DurableAckTiers; import io.questdb.client.test.tools.TestWebSocketClient; import io.questdb.client.DefaultHttpClientConfiguration; import io.questdb.client.cutlass.http.client.WebSocketFrameHandler; @@ -143,7 +144,7 @@ public void testCloseIfLinkUpRefusesDuringConnectWalkThenStopsLiveLoop() throws factory, /* reconnectInitialBackoffMillis */ 1_000L, /* reconnectMaxBackoffMillis */ 5_000L, - false + DurableAckTiers.NONE ); try { loop.start(); @@ -228,7 +229,7 @@ public void testCloseIfLinkUpRefusesWhenTheWalkStartsBehindItsLinkCheck() throws factory, /* reconnectInitialBackoffMillis */ 1_000L, /* reconnectMaxBackoffMillis */ 5_000L, - false + DurableAckTiers.NONE ); // A stop that wrongly goes ahead waits on the blocked walk; keep that wait short. loop.setShutdownAwaitTimeoutMillis(1_000L); 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 0a6236102..a6af02986 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.cutlass.qwp.client.DurableAckTiers; import io.questdb.client.test.tools.TestWebSocketClient; import io.questdb.client.DefaultHttpClientConfiguration; import io.questdb.client.cutlass.http.client.WebSocketClient; @@ -83,7 +84,7 @@ public WebSocketClient reconnect(CursorWebSocketSendLoop.ConnectCancellation can factory, 1_000L, 5_000L, - false + DurableAckTiers.NONE ); // Far beyond the test timeout: waiting it out here would fail the test. loop.setGracefulStopMillis(TimeUnit.MINUTES.toMillis(5)); @@ -125,7 +126,7 @@ public void testCloseLetsIdleWorkerCloseClientBeforeBreakingTraffic() throws Exc null, 1_000L, 5_000L, - false + DurableAckTiers.NONE ); // Far above any scheduling delay, so a loaded machine cannot flip the outcome. loop.setGracefulStopMillis(TimeUnit.SECONDS.toMillis(20)); 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 37fbc3ffa..22d30a0e8 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 @@ -24,6 +24,7 @@ package io.questdb.client.test.cutlass.qwp.client.sf.cursor; +import io.questdb.client.cutlass.qwp.client.DurableAckTiers; import io.questdb.client.DefaultHttpClientConfiguration; import io.questdb.client.cutlass.http.client.HttpClientException; import io.questdb.client.cutlass.http.client.WebSocketClient; @@ -96,7 +97,7 @@ public void testIdleLoopBlocksAndPublishWakesIt() throws Exception { client, engine, 0L, BlockingDoorbellClient::new, 100L, 1_000L, - false, + DurableAckTiers.NONE, CursorWebSocketSendLoop.DEFAULT_DURABLE_ACK_KEEPALIVE_INTERVAL_MILLIS, CursorWebSocketSendLoop.DEFAULT_MAX_HEAD_FRAME_REJECTIONS); try { @@ -156,7 +157,7 @@ public void testReplayAfterReconnectIsNotStrandedByIdleWait() throws Exception { first, engine, 0L, () -> replacement, 1L, 10L, - false, + DurableAckTiers.NONE, CursorWebSocketSendLoop.DEFAULT_DURABLE_ACK_KEEPALIVE_INTERVAL_MILLIS, CursorWebSocketSendLoop.DEFAULT_MAX_HEAD_FRAME_REJECTIONS); try { @@ -193,7 +194,7 @@ public void testReplayAfterPingFailureIsNotStrandedByIdleWait() throws Exception first, engine, 0L, () -> replacement, 1L, 10L, - true, + DurableAckTiers.REPLICATED, CursorWebSocketSendLoop.DEFAULT_DURABLE_ACK_KEEPALIVE_INTERVAL_MILLIS, CursorWebSocketSendLoop.DEFAULT_MAX_HEAD_FRAME_REJECTIONS); try { @@ -235,7 +236,7 @@ public void testBusyLoopIsNotWokenPerPublish() throws Exception { client, engine, 0L, BlockingDoorbellClient::new, 100L, 1_000L, - false, + DurableAckTiers.NONE, CursorWebSocketSendLoop.DEFAULT_DURABLE_ACK_KEEPALIVE_INTERVAL_MILLIS, CursorWebSocketSendLoop.DEFAULT_MAX_HEAD_FRAME_REJECTIONS); try { @@ -282,7 +283,7 @@ public void testPublishInsideIdleDecisionWindowIsNotStranded() throws Exception client, engine, 0L, BlockingDoorbellClient::new, 100L, 1_000L, - false, + DurableAckTiers.NONE, CursorWebSocketSendLoop.DEFAULT_DURABLE_ACK_KEEPALIVE_INTERVAL_MILLIS, CursorWebSocketSendLoop.DEFAULT_MAX_HEAD_FRAME_REJECTIONS); try { From 084cb4713ea9e0b0a26bd908bb402867e5efdd02 Mon Sep 17 00:00:00 2001 From: Jaromir Hamala Date: Thu, 29 Oct 2026 15:22:04 +0100 Subject: [PATCH 08/11] Remove compatibility wrappers from internal sender factories Drop boolean QWP forwarding overloads, obsolete HTTP factories and polling constructors. Remove the unused progress-dispatcher capacity and migrate internal callers and tests to the current interfaces. --- .../line/http/AbstractLineHttpSender.java | 53 -- .../qwp/client/QwpWebSocketSender.java | 541 +----------------- .../qwp/client/sf/cursor/SegmentManager.java | 18 - .../sf/cursor/SenderProgressDispatcher.java | 13 +- .../line/LineHttpSenderConstructorTest.java | 3 +- .../client/SymbolDictRecycleArmingTest.java | 19 +- .../client/SymbolDictRecycleHealingTest.java | 10 +- .../SymbolDictRecycleSlotOwnershipTest.java | 3 +- .../qwp/client/SymbolDictRecycleTest.java | 10 +- .../client/sf/DurableAckIntegrationTest.java | 36 +- .../cursor/SenderProgressDispatcherTest.java | 40 +- 11 files changed, 65 insertions(+), 681 deletions(-) diff --git a/core/src/main/java/io/questdb/client/cutlass/line/http/AbstractLineHttpSender.java b/core/src/main/java/io/questdb/client/cutlass/line/http/AbstractLineHttpSender.java index eadc0495f..5895a3b3a 100644 --- a/core/src/main/java/io/questdb/client/cutlass/line/http/AbstractLineHttpSender.java +++ b/core/src/main/java/io/questdb/client/cutlass/line/http/AbstractLineHttpSender.java @@ -226,59 +226,6 @@ protected AbstractLineHttpSender( this.rnd = rnd; } - @SuppressWarnings("unused") - public static AbstractLineHttpSender createLineSender( - String host, - int port, - String path, - HttpClientConfiguration clientConfiguration, - ClientTlsConfiguration tlsConfig, - int autoFlushRows, - String authToken, - String username, - String password, - int maxNameLength, - long maxRetriesNanos, - int maxBackoffMillis, - long minRequestThroughput, - long flushIntervalNanos, - int protocolVersion - ) { - return createLineSender(new ObjList<>(host), IntList.createWithValues(port), path, clientConfiguration, tlsConfig, autoFlushRows, authToken, username, password, maxNameLength, maxRetriesNanos, maxBackoffMillis, minRequestThroughput, - flushIntervalNanos, - protocolVersion, - null - ); - } - - /** - * Provider-less form of the overload below, kept so callers compiled against the pre-{@code - * httpTokenProvider} signature keep linking. Mirrors the single-host overload above, which delegates - * with the same {@code null} provider. - */ - @SuppressWarnings("unused") - public static AbstractLineHttpSender createLineSender( - ObjList hosts, - IntList ports, - String path, - HttpClientConfiguration clientConfiguration, - ClientTlsConfiguration tlsConfig, - int autoFlushRows, - String authToken, - String username, - String password, - int maxNameLength, - long maxRetriesNanos, - int maxBackoffMillis, - long minRequestThroughput, - long flushIntervalNanos, - int protocolVersion - ) { - return createLineSender(hosts, ports, path, clientConfiguration, tlsConfig, autoFlushRows, - authToken, username, password, maxNameLength, maxRetriesNanos, maxBackoffMillis, - minRequestThroughput, flushIntervalNanos, protocolVersion, null); - } - public static AbstractLineHttpSender createLineSender( ObjList hosts, IntList ports, 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 fba8041ad..6b8eec79e 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 @@ -1126,431 +1126,10 @@ autoFlushIntervalNanos, fixedAuthHeader(authorizationHeader), CursorWebSocketSendLoop.DEFAULT_KEEPALIVE_INTERVAL_MILLIS); } - // ------------------------------------------------------------------ - // Boolean durable-ack overloads. Each maps its boolean flag onto the - // DurableAckTiers bitmask the master entry point takes: true becomes - // the legacy request (REPLICATED | LEGACY_TRUE -- header value "true", - // confirmed by the "enabled" token), false becomes NONE. They keep - // binary compatibility for callers linked against the boolean - // signatures. - // ------------------------------------------------------------------ - - public static QwpWebSocketSender connect( - String host, - int port, - ClientTlsConfiguration tlsConfig, - int autoFlushRows, - int autoFlushBytes, - long autoFlushIntervalNanos, - String authorizationHeader, - boolean requestDurableAck, - CursorSendEngine cursorEngine - ) { - return connect( - host, - port, - tlsConfig, - autoFlushRows, - autoFlushBytes, - autoFlushIntervalNanos, - authorizationHeader, - requestDurableAck - ? DurableAckTiers.REPLICATED | DurableAckTiers.LEGACY_TRUE - : DurableAckTiers.NONE, - cursorEngine - ); - } - - public static QwpWebSocketSender connect( - String host, - int port, - ClientTlsConfiguration tlsConfig, - int autoFlushRows, - int autoFlushBytes, - long autoFlushIntervalNanos, - String authorizationHeader, - boolean requestDurableAck, - CursorSendEngine cursorEngine, - long closeFlushTimeoutMillis - ) { - return connect( - host, - port, - tlsConfig, - autoFlushRows, - autoFlushBytes, - autoFlushIntervalNanos, - authorizationHeader, - requestDurableAck - ? DurableAckTiers.REPLICATED | DurableAckTiers.LEGACY_TRUE - : DurableAckTiers.NONE, - cursorEngine, - closeFlushTimeoutMillis - ); - } - - public static QwpWebSocketSender connect( - String host, - int port, - ClientTlsConfiguration tlsConfig, - int autoFlushRows, - int autoFlushBytes, - long autoFlushIntervalNanos, - String authorizationHeader, - boolean requestDurableAck, - CursorSendEngine cursorEngine, - long closeFlushTimeoutMillis, - long reconnectMaxDurationMillis, - long reconnectInitialBackoffMillis, - long reconnectMaxBackoffMillis - ) { - return connect( - host, - port, - tlsConfig, - autoFlushRows, - autoFlushBytes, - autoFlushIntervalNanos, - authorizationHeader, - requestDurableAck - ? DurableAckTiers.REPLICATED | DurableAckTiers.LEGACY_TRUE - : DurableAckTiers.NONE, - cursorEngine, - closeFlushTimeoutMillis, - reconnectMaxDurationMillis, - reconnectInitialBackoffMillis, - reconnectMaxBackoffMillis - ); - } - - public static QwpWebSocketSender connect( - String host, - int port, - ClientTlsConfiguration tlsConfig, - int autoFlushRows, - int autoFlushBytes, - long autoFlushIntervalNanos, - String authorizationHeader, - boolean requestDurableAck, - CursorSendEngine cursorEngine, - long closeFlushTimeoutMillis, - long reconnectMaxDurationMillis, - long reconnectInitialBackoffMillis, - long reconnectMaxBackoffMillis, - Sender.InitialConnectMode initialConnectMode - ) { - return connect( - host, - port, - tlsConfig, - autoFlushRows, - autoFlushBytes, - autoFlushIntervalNanos, - authorizationHeader, - requestDurableAck - ? DurableAckTiers.REPLICATED | DurableAckTiers.LEGACY_TRUE - : DurableAckTiers.NONE, - cursorEngine, - closeFlushTimeoutMillis, - reconnectMaxDurationMillis, - reconnectInitialBackoffMillis, - reconnectMaxBackoffMillis, - initialConnectMode - ); - } - - public static QwpWebSocketSender connect( - String host, - int port, - ClientTlsConfiguration tlsConfig, - int autoFlushRows, - int autoFlushBytes, - long autoFlushIntervalNanos, - String authorizationHeader, - boolean requestDurableAck, - CursorSendEngine cursorEngine, - long closeFlushTimeoutMillis, - long reconnectMaxDurationMillis, - long reconnectInitialBackoffMillis, - long reconnectMaxBackoffMillis, - Sender.InitialConnectMode initialConnectMode, - SenderErrorHandler errorHandler, - int errorInboxCapacity - ) { - return connect( - host, - port, - tlsConfig, - autoFlushRows, - autoFlushBytes, - autoFlushIntervalNanos, - authorizationHeader, - requestDurableAck - ? DurableAckTiers.REPLICATED | DurableAckTiers.LEGACY_TRUE - : DurableAckTiers.NONE, - cursorEngine, - closeFlushTimeoutMillis, - reconnectMaxDurationMillis, - reconnectInitialBackoffMillis, - reconnectMaxBackoffMillis, - initialConnectMode, - errorHandler, - errorInboxCapacity - ); - } - - public static QwpWebSocketSender connect( - String host, - int port, - ClientTlsConfiguration tlsConfig, - int autoFlushRows, - int autoFlushBytes, - long autoFlushIntervalNanos, - String authorizationHeader, - boolean requestDurableAck, - CursorSendEngine cursorEngine, - long closeFlushTimeoutMillis, - long reconnectMaxDurationMillis, - long reconnectInitialBackoffMillis, - long reconnectMaxBackoffMillis, - Sender.InitialConnectMode initialConnectMode, - SenderErrorHandler errorHandler, - int errorInboxCapacity, - long durableAckKeepaliveIntervalMillis - ) { - return connect( - host, - port, - tlsConfig, - autoFlushRows, - autoFlushBytes, - autoFlushIntervalNanos, - authorizationHeader, - requestDurableAck - ? DurableAckTiers.REPLICATED | DurableAckTiers.LEGACY_TRUE - : DurableAckTiers.NONE, - cursorEngine, - closeFlushTimeoutMillis, - reconnectMaxDurationMillis, - reconnectInitialBackoffMillis, - reconnectMaxBackoffMillis, - initialConnectMode, - errorHandler, - errorInboxCapacity, - durableAckKeepaliveIntervalMillis - ); - } - - public static QwpWebSocketSender connect( - List endpoints, - ClientTlsConfiguration tlsConfig, - int autoFlushRows, - int autoFlushBytes, - long autoFlushIntervalNanos, - String authorizationHeader, - boolean requestDurableAck, - CursorSendEngine cursorEngine, - long closeFlushTimeoutMillis, - long reconnectMaxDurationMillis, - long reconnectInitialBackoffMillis, - long reconnectMaxBackoffMillis, - Sender.InitialConnectMode initialConnectMode, - SenderErrorHandler errorHandler, - int errorInboxCapacity, - long durableAckKeepaliveIntervalMillis, - long authTimeoutMs - ) { - return connect( - endpoints, - tlsConfig, - autoFlushRows, - autoFlushBytes, - autoFlushIntervalNanos, - authorizationHeader, - requestDurableAck - ? DurableAckTiers.REPLICATED | DurableAckTiers.LEGACY_TRUE - : DurableAckTiers.NONE, - cursorEngine, - closeFlushTimeoutMillis, - reconnectMaxDurationMillis, - reconnectInitialBackoffMillis, - reconnectMaxBackoffMillis, - initialConnectMode, - errorHandler, - errorInboxCapacity, - durableAckKeepaliveIntervalMillis, - authTimeoutMs - ); - } - - public static QwpWebSocketSender connect( - List endpoints, - ClientTlsConfiguration tlsConfig, - int autoFlushRows, - int autoFlushBytes, - long autoFlushIntervalNanos, - String authorizationHeader, - boolean requestDurableAck, - CursorSendEngine cursorEngine, - long closeFlushTimeoutMillis, - long reconnectMaxDurationMillis, - long reconnectInitialBackoffMillis, - long reconnectMaxBackoffMillis, - Sender.InitialConnectMode initialConnectMode, - SenderErrorHandler errorHandler, - int errorInboxCapacity, - long durableAckKeepaliveIntervalMillis, - long authTimeoutMs, - int connectTimeoutMs, - SenderConnectionListener connectionListener, - int connectionListenerInboxCapacity - ) { - return connect( - endpoints, - tlsConfig, - autoFlushRows, - autoFlushBytes, - autoFlushIntervalNanos, - authorizationHeader, - requestDurableAck - ? DurableAckTiers.REPLICATED | DurableAckTiers.LEGACY_TRUE - : DurableAckTiers.NONE, - cursorEngine, - closeFlushTimeoutMillis, - reconnectMaxDurationMillis, - reconnectInitialBackoffMillis, - reconnectMaxBackoffMillis, - initialConnectMode, - errorHandler, - errorInboxCapacity, - durableAckKeepaliveIntervalMillis, - authTimeoutMs, - connectTimeoutMs, - connectionListener, - connectionListenerInboxCapacity - ); - } - - public static QwpWebSocketSender connect( - List endpoints, - ClientTlsConfiguration tlsConfig, - int autoFlushRows, - int autoFlushBytes, - long autoFlushIntervalNanos, - String authorizationHeader, - boolean requestDurableAck, - CursorSendEngine cursorEngine, - long closeFlushTimeoutMillis, - long reconnectMaxDurationMillis, - long reconnectInitialBackoffMillis, - long reconnectMaxBackoffMillis, - Sender.InitialConnectMode initialConnectMode, - SenderErrorHandler errorHandler, - int errorInboxCapacity, - long durableAckKeepaliveIntervalMillis, - long authTimeoutMs, - int connectTimeoutMs, - SenderConnectionListener connectionListener, - int connectionListenerInboxCapacity, - int maxFrameRejections, - long poisonMinEscalationWindowMillis, - long catchUpCapGapMinEscalationWindowMillis - ) { - return connect( - endpoints, - tlsConfig, - autoFlushRows, - autoFlushBytes, - autoFlushIntervalNanos, - authorizationHeader, - requestDurableAck - ? DurableAckTiers.REPLICATED | DurableAckTiers.LEGACY_TRUE - : DurableAckTiers.NONE, - cursorEngine, - closeFlushTimeoutMillis, - reconnectMaxDurationMillis, - reconnectInitialBackoffMillis, - reconnectMaxBackoffMillis, - initialConnectMode, - errorHandler, - errorInboxCapacity, - durableAckKeepaliveIntervalMillis, - authTimeoutMs, - connectTimeoutMs, - connectionListener, - connectionListenerInboxCapacity, - maxFrameRejections, - poisonMinEscalationWindowMillis, - catchUpCapGapMinEscalationWindowMillis - ); - } - - - /** - * As the twenty-three-arg {@code connect} overload above, but also accepts the symbol-dictionary - * recycle knobs ({@code symbol_dict_reset}, {@code symbol_dict_reset_threshold}, - * {@code symbol_dict_reset_max_wait_millis}) explicitly instead of defaulting them -- the - * constant-credential counterpart to the master {@code connectWithCredentialSupplier} overload below. - * A distinct (wider) arity, not a retyped signature, so callers compiled against the twenty-three-arg - * form keep linking. - *

    - * Recycling requires a non-null engine rebuild factory installed via - * {@link #setEngineRebuildFactory(EngineRebuildFactory)}; - * {@link io.questdb.client.Sender.LineSenderBuilder#build() Sender.LineSenderBuilder.build()} - * installs one automatically for WebSocket senders. - *

    - * {@code symbolDictResetThresholdSymbols} must be between {@code 1} and half of - * {@link QwpConstants#MAX_SYMBOL_DICTIONARY_SIZE}, and {@code symbolDictResetMaxWaitMillis} between - * {@code 0} (never wait) and {@code Long.MAX_VALUE / 1_000_000}: the ranges the builder enforces. - * - * @throws LineSenderException if either recycle setting is out of range - */ - public static QwpWebSocketSender connect( - List endpoints, - ClientTlsConfiguration tlsConfig, - int autoFlushRows, - int autoFlushBytes, - long autoFlushIntervalNanos, - String authorizationHeader, - boolean requestDurableAck, - CursorSendEngine cursorEngine, - long closeFlushTimeoutMillis, - long reconnectMaxDurationMillis, - long reconnectInitialBackoffMillis, - long reconnectMaxBackoffMillis, - Sender.InitialConnectMode initialConnectMode, - SenderErrorHandler errorHandler, - int errorInboxCapacity, - long durableAckKeepaliveIntervalMillis, - long authTimeoutMs, - int connectTimeoutMs, - SenderConnectionListener connectionListener, - int connectionListenerInboxCapacity, - int maxFrameRejections, - long poisonMinEscalationWindowMillis, - long catchUpCapGapMinEscalationWindowMillis, - boolean symbolDictResetEnabled, - int symbolDictResetThresholdSymbols, - long symbolDictResetMaxWaitMillis - ) { - return connectWithCredentialSupplier(endpoints, tlsConfig, autoFlushRows, autoFlushBytes, - autoFlushIntervalNanos, fixedAuthHeader(authorizationHeader), - requestDurableAck ? DurableAckTiers.REPLICATED | DurableAckTiers.LEGACY_TRUE : DurableAckTiers.NONE, - cursorEngine, closeFlushTimeoutMillis, reconnectMaxDurationMillis, - reconnectInitialBackoffMillis, reconnectMaxBackoffMillis, - initialConnectMode, errorHandler, errorInboxCapacity, - durableAckKeepaliveIntervalMillis, authTimeoutMs, connectTimeoutMs, - connectionListener, connectionListenerInboxCapacity, - maxFrameRejections, poisonMinEscalationWindowMillis, - catchUpCapGapMinEscalationWindowMillis, - symbolDictResetEnabled, symbolDictResetThresholdSymbols, - symbolDictResetMaxWaitMillis, CursorWebSocketSendLoop.DEFAULT_KEEPALIVE_INTERVAL_MILLIS); - } - /** * As the twenty-three-arg {@code connect} overload above, but takes the credential as a * {@link Supplier} instead of wrapping a constant header -- the rotating-credential counterpart to - * {@link #connect(List, ClientTlsConfiguration, int, int, long, String, boolean, CursorSendEngine, + * {@link #connect(List, ClientTlsConfiguration, int, int, long, String, int, CursorSendEngine, * long, long, long, long, Sender.InitialConnectMode, SenderErrorHandler, int, long, long, int, * SenderConnectionListener, int, int, long, long)}. Kept as its own overload, delegating to the master * below with the symbol-dictionary recycle knobs defaulted, so a caller compiled before those knobs' @@ -1601,118 +1180,6 @@ public static QwpWebSocketSender connectWithCredentialSupplier( CursorWebSocketSendLoop.DEFAULT_KEEPALIVE_INTERVAL_MILLIS); } - public static QwpWebSocketSender connectWithCredentialSupplier( - List endpoints, - ClientTlsConfiguration tlsConfig, - int autoFlushRows, - int autoFlushBytes, - long autoFlushIntervalNanos, - Supplier authorizationHeaderSupplier, - boolean requestDurableAck, - CursorSendEngine cursorEngine, - long closeFlushTimeoutMillis, - long reconnectMaxDurationMillis, - long reconnectInitialBackoffMillis, - long reconnectMaxBackoffMillis, - Sender.InitialConnectMode initialConnectMode, - SenderErrorHandler errorHandler, - int errorInboxCapacity, - long durableAckKeepaliveIntervalMillis, - long authTimeoutMs, - int connectTimeoutMs, - SenderConnectionListener connectionListener, - int connectionListenerInboxCapacity - ) { - return connectWithCredentialSupplier(endpoints, tlsConfig, autoFlushRows, autoFlushBytes, - autoFlushIntervalNanos, authorizationHeaderSupplier, - requestDurableAck ? DurableAckTiers.REPLICATED | DurableAckTiers.LEGACY_TRUE : DurableAckTiers.NONE, - cursorEngine, closeFlushTimeoutMillis, reconnectMaxDurationMillis, - reconnectInitialBackoffMillis, reconnectMaxBackoffMillis, - initialConnectMode, errorHandler, errorInboxCapacity, - durableAckKeepaliveIntervalMillis, authTimeoutMs, connectTimeoutMs, - connectionListener, connectionListenerInboxCapacity); - } - - public static QwpWebSocketSender connectWithCredentialSupplier( - List endpoints, - ClientTlsConfiguration tlsConfig, - int autoFlushRows, - int autoFlushBytes, - long autoFlushIntervalNanos, - Supplier authorizationHeaderSupplier, - boolean requestDurableAck, - CursorSendEngine cursorEngine, - long closeFlushTimeoutMillis, - long reconnectMaxDurationMillis, - long reconnectInitialBackoffMillis, - long reconnectMaxBackoffMillis, - Sender.InitialConnectMode initialConnectMode, - SenderErrorHandler errorHandler, - int errorInboxCapacity, - long durableAckKeepaliveIntervalMillis, - long authTimeoutMs, - int connectTimeoutMs, - SenderConnectionListener connectionListener, - int connectionListenerInboxCapacity, - int maxFrameRejections, - long poisonMinEscalationWindowMillis, - long catchUpCapGapMinEscalationWindowMillis - ) { - return connectWithCredentialSupplier(endpoints, tlsConfig, autoFlushRows, autoFlushBytes, - autoFlushIntervalNanos, authorizationHeaderSupplier, - requestDurableAck ? DurableAckTiers.REPLICATED | DurableAckTiers.LEGACY_TRUE : DurableAckTiers.NONE, - cursorEngine, closeFlushTimeoutMillis, reconnectMaxDurationMillis, - reconnectInitialBackoffMillis, reconnectMaxBackoffMillis, - initialConnectMode, errorHandler, errorInboxCapacity, - durableAckKeepaliveIntervalMillis, authTimeoutMs, connectTimeoutMs, - connectionListener, connectionListenerInboxCapacity, - maxFrameRejections, poisonMinEscalationWindowMillis, - catchUpCapGapMinEscalationWindowMillis); - } - - public static QwpWebSocketSender connectWithCredentialSupplier( - List endpoints, - ClientTlsConfiguration tlsConfig, - int autoFlushRows, - int autoFlushBytes, - long autoFlushIntervalNanos, - Supplier authorizationHeaderSupplier, - boolean requestDurableAck, - CursorSendEngine cursorEngine, - long closeFlushTimeoutMillis, - long reconnectMaxDurationMillis, - long reconnectInitialBackoffMillis, - long reconnectMaxBackoffMillis, - Sender.InitialConnectMode initialConnectMode, - SenderErrorHandler errorHandler, - int errorInboxCapacity, - long durableAckKeepaliveIntervalMillis, - long authTimeoutMs, - int connectTimeoutMs, - SenderConnectionListener connectionListener, - int connectionListenerInboxCapacity, - int maxFrameRejections, - long poisonMinEscalationWindowMillis, - long catchUpCapGapMinEscalationWindowMillis, - boolean symbolDictResetEnabled, - int symbolDictResetThresholdSymbols, - long symbolDictResetMaxWaitMillis - ) { - return connectWithCredentialSupplier(endpoints, tlsConfig, autoFlushRows, autoFlushBytes, - autoFlushIntervalNanos, authorizationHeaderSupplier, - requestDurableAck ? DurableAckTiers.REPLICATED | DurableAckTiers.LEGACY_TRUE : DurableAckTiers.NONE, - cursorEngine, closeFlushTimeoutMillis, reconnectMaxDurationMillis, - reconnectInitialBackoffMillis, reconnectMaxBackoffMillis, - initialConnectMode, errorHandler, errorInboxCapacity, - durableAckKeepaliveIntervalMillis, authTimeoutMs, connectTimeoutMs, - connectionListener, connectionListenerInboxCapacity, - maxFrameRejections, poisonMinEscalationWindowMillis, - catchUpCapGapMinEscalationWindowMillis, - symbolDictResetEnabled, symbolDictResetThresholdSymbols, - symbolDictResetMaxWaitMillis, - CursorWebSocketSendLoop.DEFAULT_KEEPALIVE_INTERVAL_MILLIS); - } - /** * Master connect entry point — also accepts the poison-frame detector * threshold ({@code max_frame_rejections}) and the symbol-dictionary @@ -1724,7 +1191,7 @@ public static QwpWebSocketSender connectWithCredentialSupplier( *

    * Named apart from {@code connect} for the reason given on * {@link #connectWithCredentialSupplier(List, ClientTlsConfiguration, int, int, long, Supplier, - * boolean, CursorSendEngine, long, long, long, long, Sender.InitialConnectMode, SenderErrorHandler, + * int, CursorSendEngine, long, long, long, long, Sender.InitialConnectMode, SenderErrorHandler, * int, long, long, int, SenderConnectionListener, int)}. *

    * Recycling requires a non-null engine rebuild factory installed via @@ -3935,7 +3402,7 @@ public void setCursorSendLoopForTesting(CursorWebSocketSendLoop loop) { } if (progressDispatcher == null) { progressDispatcher = new SenderProgressDispatcher( - progressHandler, SenderProgressDispatcher.DEFAULT_CAPACITY); + progressHandler); } loop.setConnectionDispatcher(connectionDispatcher); loop.setErrorDispatcher(errorDispatcher); @@ -5583,7 +5050,7 @@ private void ensureConnected() { // I/O thread should never observe a null dispatcher between // an ack arrival and the notify call. if (progressDispatcher == null) { - progressDispatcher = new SenderProgressDispatcher(progressHandler, SenderProgressDispatcher.DEFAULT_CAPACITY); + progressDispatcher = new SenderProgressDispatcher(progressHandler); } cursorSendLoop.setProgressDispatcher(progressDispatcher); // Connection-event dispatcher: lets the cursor I/O loop fire 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 b716b3143..13dc1a8b4 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 @@ -176,24 +176,6 @@ public SegmentManager(long segmentSizeBytes, SegmentBudget budget) { this(segmentSizeBytes, budget, FilesFacade.INSTANCE, System::nanoTime); } - /** - * @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}. - */ - @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 public SegmentManager(long segmentSizeBytes, long maxTotalBytes, FilesFacade filesFacade) { this(segmentSizeBytes, new SegmentBudget(maxTotalBytes), filesFacade, System::nanoTime); 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 c348a24c0..59d059042 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 @@ -71,7 +71,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 private static final Logger LOG = LoggerFactory.getLogger(SenderProgressDispatcher.class); // Sentinel for "no value ever offered". Real FSNs are non-negative, so @@ -100,20 +99,14 @@ public final class SenderProgressDispatcher implements QuietCloseable { // subsumes any prior undelivered value. private volatile long latestFsn = EMPTY; - public SenderProgressDispatcher(SenderProgressHandler handler, int capacity) { - this(handler, capacity, "qdb-sf-progress-dispatcher"); + public SenderProgressDispatcher(SenderProgressHandler handler) { + this(handler, "qdb-sf-progress-dispatcher"); } - public SenderProgressDispatcher(SenderProgressHandler handler, int capacity, String threadName) { + public SenderProgressDispatcher(SenderProgressHandler handler, String threadName) { if (handler == null) { throw new IllegalArgumentException("handler must be non-null"); } - // Capacity is retained on the constructor for API compatibility but - // the mailbox is single-slot by design (see class doc). The bound is - // still validated so misconfiguration surfaces at construction. - if (capacity < 1) { - throw new IllegalArgumentException("capacity must be >= 1, was " + capacity); - } this.handler = handler; this.threadName = threadName; } diff --git a/core/src/test/java/io/questdb/client/test/cutlass/line/LineHttpSenderConstructorTest.java b/core/src/test/java/io/questdb/client/test/cutlass/line/LineHttpSenderConstructorTest.java index 74ea54c8c..46991c352 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/line/LineHttpSenderConstructorTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/line/LineHttpSenderConstructorTest.java @@ -146,7 +146,8 @@ public void testSelfBuiltClientIsReleasedWhenRequestPreambleDoesNotFit() throws 0, 0, Long.MAX_VALUE, - Sender.PROTOCOL_VERSION_V2 + Sender.PROTOCOL_VERSION_V2, + null )); Assert.fail("expected HttpClientException"); } catch (HttpClientException ignore) { diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleArmingTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleArmingTest.java index c99f95481..1b2559057 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleArmingTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleArmingTest.java @@ -24,6 +24,7 @@ package io.questdb.client.test.cutlass.qwp.client; +import io.questdb.client.cutlass.qwp.client.DurableAckTiers; import io.questdb.client.Sender; import io.questdb.client.cutlass.line.LineSenderException; import io.questdb.client.cutlass.qwp.client.QwpWebSocketSender; @@ -147,12 +148,12 @@ public void testDoesNotArmWithoutRebuildFactory() throws Exception { CursorSendEngine engine = new CursorSendEngine( slot, 4L * 1024 * 1024, 64L * 1024 * 1024, CursorSendEngine.DEFAULT_APPEND_DEADLINE_NANOS, ff); - QwpWebSocketSender sender = QwpWebSocketSender.connect( + QwpWebSocketSender sender = QwpWebSocketSender.connectWithCredentialSupplier( Collections.singletonList(new QwpWebSocketSender.Endpoint("localhost", port)), null, // tlsConfig 0, 0, 0L, // autoFlushRows, autoFlushBytes, autoFlushIntervalNanos - null, // authorizationHeader - false, // requestDurableAck + QwpWebSocketSender.fixedAuthHeader(null), // authorizationHeader + DurableAckTiers.NONE, // durableAckTiers engine, 5_000L, // closeFlushTimeoutMillis CursorWebSocketSendLoop.DEFAULT_RECONNECT_MAX_DURATION_MILLIS, @@ -171,7 +172,8 @@ public void testDoesNotArmWithoutRebuildFactory() throws Exception { CursorWebSocketSendLoop.DEFAULT_CATCHUP_CAP_GAP_MIN_ESCALATION_WINDOW_MILLIS, true, // symbolDictResetEnabled 3, // symbolDictResetThresholdSymbols -- low, deliberately crossed below - QwpWebSocketSender.DEFAULT_SYMBOL_DICT_RESET_MAX_WAIT_MILLIS); + QwpWebSocketSender.DEFAULT_SYMBOL_DICT_RESET_MAX_WAIT_MILLIS, + CursorWebSocketSendLoop.DEFAULT_KEEPALIVE_INTERVAL_MILLIS); try { ff.armed = true; // next dictionary mmap growth raises a recognised fault sender.table("m").symbol("s", "a").longColumn("v", 1L).atNow(); @@ -379,12 +381,12 @@ private static void assertConnectRejects( long maxWaitMillis ) { try { - QwpWebSocketSender.connect( + QwpWebSocketSender.connectWithCredentialSupplier( Collections.singletonList(new QwpWebSocketSender.Endpoint("localhost", 1)), null, // tlsConfig 0, 0, 0L, // autoFlushRows, autoFlushBytes, autoFlushIntervalNanos - null, // authorizationHeader - false, // requestDurableAck + QwpWebSocketSender.fixedAuthHeader(null), // authorizationHeader + DurableAckTiers.NONE, // durableAckTiers null, // cursorEngine 5_000L, // closeFlushTimeoutMillis CursorWebSocketSendLoop.DEFAULT_RECONNECT_MAX_DURATION_MILLIS, @@ -403,7 +405,8 @@ private static void assertConnectRejects( CursorWebSocketSendLoop.DEFAULT_CATCHUP_CAP_GAP_MIN_ESCALATION_WINDOW_MILLIS, resetEnabled, // symbolDictResetEnabled thresholdSymbols, - maxWaitMillis).close(); + maxWaitMillis, + CursorWebSocketSendLoop.DEFAULT_KEEPALIVE_INTERVAL_MILLIS).close(); Assert.fail("expected the connect to throw: " + expectedMessage); } catch (LineSenderException e) { TestUtils.assertContains(e.getMessage(), expectedMessage); diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleHealingTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleHealingTest.java index a14426623..ddcbdb105 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleHealingTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleHealingTest.java @@ -24,6 +24,7 @@ package io.questdb.client.test.cutlass.qwp.client; +import io.questdb.client.cutlass.qwp.client.DurableAckTiers; import io.questdb.client.Sender; import io.questdb.client.cutlass.line.LineSenderException; import io.questdb.client.cutlass.qwp.client.QwpWebSocketSender; @@ -371,12 +372,12 @@ private static TestWebSocketServer ackingServer() throws Exception { */ private static QwpWebSocketSender buildSender(int port, CursorSendEngine engine, int thresholdSymbols) throws Exception { - return QwpWebSocketSender.connect( + return QwpWebSocketSender.connectWithCredentialSupplier( Collections.singletonList(new QwpWebSocketSender.Endpoint("localhost", port)), null, // tlsConfig 0, 0, 0L, // autoFlushRows, autoFlushBytes, autoFlushIntervalNanos - null, // authorizationHeader - false, // requestDurableAck + QwpWebSocketSender.fixedAuthHeader(null), // authorizationHeader + DurableAckTiers.NONE, // durableAckTiers engine, 5_000L, // closeFlushTimeoutMillis CursorWebSocketSendLoop.DEFAULT_RECONNECT_MAX_DURATION_MILLIS, @@ -395,7 +396,8 @@ private static QwpWebSocketSender buildSender(int port, CursorSendEngine engine, CursorWebSocketSendLoop.DEFAULT_CATCHUP_CAP_GAP_MIN_ESCALATION_WINDOW_MILLIS, true, // symbolDictResetEnabled thresholdSymbols, - QwpWebSocketSender.DEFAULT_SYMBOL_DICT_RESET_MAX_WAIT_MILLIS); + QwpWebSocketSender.DEFAULT_SYMBOL_DICT_RESET_MAX_WAIT_MILLIS, + CursorWebSocketSendLoop.DEFAULT_KEEPALIVE_INTERVAL_MILLIS); } /** {@code [deltaStart, deltaCount]} read from a frame that must carry a symbol-dict delta. */ diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleSlotOwnershipTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleSlotOwnershipTest.java index 4297673a3..84f0efe81 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleSlotOwnershipTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/SymbolDictRecycleSlotOwnershipTest.java @@ -24,6 +24,7 @@ package io.questdb.client.test.cutlass.qwp.client; +import io.questdb.client.cutlass.qwp.client.DurableAckTiers; import io.questdb.client.Sender; import io.questdb.client.SenderErrorHandler; import io.questdb.client.cutlass.line.LineSenderException; @@ -378,7 +379,7 @@ private static void assertIsSlotContention(String when, Throwable t) { private static void assertUnlockableSlotKeepsIngesting(TestWebSocketServer server, String slot) throws Exception { CursorSendEngine engine = new CursorSendEngine(slot, SEGMENT_BYTES); try (QwpWebSocketSender sender = QwpWebSocketSender.connect( - "localhost", server.getPort(), null, 0, 0, 0L, null, false, engine)) { + "localhost", server.getPort(), null, 0, 0, 0L, null, DurableAckTiers.NONE, engine)) { sender.setEngineRebuildFactory(() -> new CursorSendEngine(slot, SEGMENT_BYTES)); sender.table("t").symbol("s", "a").longColumn("v", 1L).atNow(); Assert.assertTrue(sender.awaitAckedFsn(sender.flushAndGetSequence(), 5_000)); 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 302022aad..870e0ceed 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 @@ -24,6 +24,7 @@ package io.questdb.client.test.cutlass.qwp.client; +import io.questdb.client.cutlass.qwp.client.DurableAckTiers; import io.questdb.client.ConnectionHealth; import io.questdb.client.Sender; import io.questdb.client.SenderConnectionEvent; @@ -179,12 +180,12 @@ public void testConnectBuiltSenderNeverRecyclesWithoutFactory() throws Exception CursorSendEngine engine = new CursorSendEngine( null, 4L * 1024 * 1024, 128L * 1024 * 1024, CursorSendEngine.DEFAULT_APPEND_DEADLINE_NANOS); - QwpWebSocketSender sender = QwpWebSocketSender.connect( + QwpWebSocketSender sender = QwpWebSocketSender.connectWithCredentialSupplier( Collections.singletonList(new QwpWebSocketSender.Endpoint("localhost", port)), null, // tlsConfig 0, 0, 0L, // autoFlushRows, autoFlushBytes, autoFlushIntervalNanos - null, // authorizationHeader - false, // requestDurableAck + QwpWebSocketSender.fixedAuthHeader(null), // authorizationHeader + DurableAckTiers.NONE, // durableAckTiers engine, 5_000L, // closeFlushTimeoutMillis CursorWebSocketSendLoop.DEFAULT_RECONNECT_MAX_DURATION_MILLIS, @@ -203,7 +204,8 @@ public void testConnectBuiltSenderNeverRecyclesWithoutFactory() throws Exception CursorWebSocketSendLoop.DEFAULT_CATCHUP_CAP_GAP_MIN_ESCALATION_WINDOW_MILLIS, true, // symbolDictResetEnabled 2, // symbolDictResetThresholdSymbols -- low, deliberately crossed below - QwpWebSocketSender.DEFAULT_SYMBOL_DICT_RESET_MAX_WAIT_MILLIS); + QwpWebSocketSender.DEFAULT_SYMBOL_DICT_RESET_MAX_WAIT_MILLIS, + CursorWebSocketSendLoop.DEFAULT_KEEPALIVE_INTERVAL_MILLIS); try { sender.table("t").symbol("s", "a").longColumn("v", 1L).atNow(); sender.table("t").symbol("s", "b").longColumn("v", 1L).atNow(); diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/DurableAckIntegrationTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/DurableAckIntegrationTest.java index fb57360f6..85b7b9b6b 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/DurableAckIntegrationTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/DurableAckIntegrationTest.java @@ -29,7 +29,6 @@ import io.questdb.client.cutlass.qwp.client.QwpDurableAckMismatchException; 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.CursorWebSocketSendLoop; import io.questdb.client.std.Files; import io.questdb.client.test.cutlass.qwp.websocket.TestWebSocketServer; @@ -135,25 +134,30 @@ public void testConnectStringOnRequiresServerSupport() throws Exception { } @Test - public void testBooleanConnectOverloadRequestsLegacyTier() throws Exception { - // The boolean connect overloads map true onto the legacy request: - // the upgrade header carries "true" and the "enabled" grant is - // accepted. Callers linked against the boolean signatures keep the - // pre-tier wire behavior. (ExportedApiCompatibilityTest pins the - // signatures; this pins what they do.) + public void testBooleanBuilderRequestsLegacyTier() throws Exception { + assertBooleanBuilderRequest(true, "true"); + } + + @Test + public void testBooleanBuilderDisablesDurableAck() throws Exception { + assertBooleanBuilderRequest(false, ""); + } + + private void assertBooleanBuilderRequest(boolean enabled, String expectedHeader) throws Exception { TestUtils.assertMemoryLeak(() -> { - Assert.assertEquals(0, Files.mkdir(sfDir, Files.DIR_MODE_DEFAULT)); DurableAckCapableHandler handler = new DurableAckCapableHandler(); - try (TestWebSocketServer server = new TestWebSocketServer(handler, true)) { + try (TestWebSocketServer server = new TestWebSocketServer(handler, enabled)) { server.start(); Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS)); - int port = server.getPort(); - - CursorSendEngine engine = new CursorSendEngine(sfDir, 16384); - try (Sender ignored = QwpWebSocketSender.connect( - "localhost", port, null, 0, 0, 0L, null, true, engine)) { - Assert.assertEquals("true", - server.pollDurableAckRequest(5, TimeUnit.SECONDS)); + // The public boolean API must retain the legacy true/enabled + // handshake; false must work against a server with no durable-ack support. + try (Sender ignored = Sender.builder(Sender.Transport.WEBSOCKET) + .address("localhost:" + server.getPort()) + .requestDurableAck(enabled) + .storeAndForwardDir(sfDir) + .build()) { + Assert.assertEquals(expectedHeader, + server.pollDurableAckRequest(100, TimeUnit.MILLISECONDS)); } } }); 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 2184967bf..8a4da1a7d 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 @@ -59,8 +59,7 @@ public void testCloseDeliversLatestPendingValue() throws Exception { } else { lastDelivered.set(fsn); } - }, - 4); + }); Assert.assertTrue(d.offer(10)); Assert.assertTrue("first delivery should start", firstDelivered.await(2, TimeUnit.SECONDS)); @@ -80,33 +79,17 @@ public void testCloseDeliversLatestPendingValue() throws Exception { @Test public void testCloseIsIdempotent() { - SenderProgressDispatcher d = new SenderProgressDispatcher(fsn -> { /* no-op */ }, 4); + SenderProgressDispatcher d = new SenderProgressDispatcher(fsn -> { /* no-op */ }); d.offer(1); d.close(); d.close(); d.close(); } - @Test - public void testConstructorRejectsBadCapacity() { - try { - new SenderProgressDispatcher(fsn -> { /* no-op */ }, 0).close(); - Assert.fail("expected IllegalArgumentException"); - } catch (IllegalArgumentException expected) { - Assert.assertTrue(expected.getMessage().contains("capacity")); - } - try { - new SenderProgressDispatcher(fsn -> { /* no-op */ }, -1).close(); - Assert.fail("expected IllegalArgumentException"); - } catch (IllegalArgumentException expected) { - // ok - } - } - @Test public void testConstructorRejectsNullHandler() { try { - new SenderProgressDispatcher(null, 4).close(); + new SenderProgressDispatcher(null).close(); Assert.fail("expected IllegalArgumentException"); } catch (IllegalArgumentException expected) { Assert.assertTrue(expected.getMessage().contains("handler")); @@ -123,7 +106,7 @@ public void testDeliveryIsStrictlyMonotonic() throws Exception { synchronized (received) { received.add(fsn); } - }, 16)) { + })) { d.offer(5); waitForDelivered(d, 1); // Re-offers of equal-or-lower values must not produce additional @@ -168,7 +151,7 @@ public void testDroppedNotificationsCounter() throws Exception { Thread.currentThread().interrupt(); } delivered.incrementAndGet(); - }, 4)) { + })) { Assert.assertTrue(d.offer(1)); Assert.assertTrue("handler must enter before pile-up", firstDelivered.await(2, TimeUnit.SECONDS)); @@ -197,7 +180,7 @@ public void testHandlerInstalledAfterSkippedOffersDelivers() throws Exception { AtomicLong last = new AtomicLong(-1); CountDownLatch gotNewest = new CountDownLatch(1); try (SenderProgressDispatcher d = new SenderProgressDispatcher( - DefaultSenderProgressHandler.INSTANCE, 4, "late-handler-progress-test")) { + DefaultSenderProgressHandler.INSTANCE, "late-handler-progress-test")) { for (int i = 1; i <= 1000; i++) { Assert.assertTrue(d.offer(i)); } @@ -230,7 +213,7 @@ public void testHandlerThrowDoesNotKillDispatcher() throws Exception { if (fsn == 3) { reachedThird.countDown(); } - }, 4)) { + })) { Assert.assertTrue(d.offer(1)); // Ensure the throwing call has happened before queuing the next // values, otherwise overwrite-collapse may swallow them. @@ -249,8 +232,7 @@ public void testHandlerThrowDoesNotKillDispatcher() throws Exception { public void testLazyStartOnFirstOffer() throws Exception { // No thread should exist before the first offer; workloads with zero // acks pay zero thread cost. - try (SenderProgressDispatcher d = new SenderProgressDispatcher(fsn -> { /* no-op */ }, - 16, "lazy-start-progress-test")) { + try (SenderProgressDispatcher d = new SenderProgressDispatcher(fsn -> { /* no-op */ }, "lazy-start-progress-test")) { Assert.assertNull("dispatcher thread must not exist before first offer", findThreadByName("lazy-start-progress-test")); Assert.assertTrue(d.offer(0)); @@ -271,7 +253,7 @@ public void testNoOpHandlerDoesNotStartDispatcherThread() { // 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")) { + DefaultSenderProgressHandler.INSTANCE, "noop-progress-test")) { for (int i = 0; i < 1000; i++) { Assert.assertTrue(d.offer(i)); } @@ -285,7 +267,7 @@ public void testNoOpHandlerDoesNotStartDispatcherThread() { @Test public void testOfferAfterCloseReturnsFalse() { - SenderProgressDispatcher d = new SenderProgressDispatcher(fsn -> { /* no-op */ }, 4); + SenderProgressDispatcher d = new SenderProgressDispatcher(fsn -> { /* no-op */ }); d.close(); Assert.assertFalse(d.offer(1)); Assert.assertEquals(0L, d.getDroppedNotifications()); @@ -299,7 +281,7 @@ public void testTotalDeliveredCounter() throws Exception { if (fsn == 3) { reachedLast.countDown(); } - }, 4)) { + })) { d.offer(1); // Wait for delivery before issuing the next, to avoid 1 and 2 // collapsing into a single delivery. From 584a7c2bb54c17e96be6e969cfab92e2bd067396 Mon Sep 17 00:00:00 2001 From: Jaromir Hamala Date: Thu, 29 Oct 2026 16:58:36 +0100 Subject: [PATCH 09/11] Remove branch-added TLA models and design notes --- design/tla/AckWait.cfg | 4 - design/tla/AckWait.tla | 69 ------------ design/tla/IdleWait.tla | 182 ------------------------------- design/tla/IdleWait_bug.cfg | 7 -- design/tla/IdleWait_fix.cfg | 7 -- design/tla/IdleWait_stampbug.cfg | 7 -- design/tla/README.md | 33 ------ design/wake-protocol.md | 141 ------------------------ 8 files changed, 450 deletions(-) delete mode 100644 design/tla/AckWait.cfg delete mode 100644 design/tla/AckWait.tla delete mode 100644 design/tla/IdleWait.tla delete mode 100644 design/tla/IdleWait_bug.cfg delete mode 100644 design/tla/IdleWait_fix.cfg delete mode 100644 design/tla/IdleWait_stampbug.cfg delete mode 100644 design/tla/README.md delete mode 100644 design/wake-protocol.md diff --git a/design/tla/AckWait.cfg b/design/tla/AckWait.cfg deleted file mode 100644 index 17d8be998..000000000 --- a/design/tla/AckWait.cfg +++ /dev/null @@ -1,4 +0,0 @@ -SPECIFICATION Spec -CONSTANT Target = 3 -INVARIANT NoLostAck -PROPERTY Returns diff --git a/design/tla/AckWait.tla b/design/tla/AckWait.tla deleted file mode 100644 index 39f5c8955..000000000 --- a/design/tla/AckWait.tla +++ /dev/null @@ -1,69 +0,0 @@ --------------------------------- MODULE AckWait -------------------------------- -(* 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 waiter waits for - -VARIABLES acked, \* engine.ackedFsn(), written by the I/O thread - 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 = "register" /\ iopc = "idle" - ----------------------------------------------------------------------------- -(* Waiter: ackWaiters.add(me); loop { check; parkNanos }; ackWaiters.remove. *) -Register == /\ ppc = "register" /\ waiter' = TRUE /\ ppc' = "read" - /\ UNCHANGED <> - -Read == /\ ppc = "read" /\ seen' = acked \* getAckedFsn() >= targetFsn ? - /\ IF acked >= Target THEN waiter' = FALSE /\ ppc' = "done" - ELSE waiter' = waiter /\ ppc' = "park" - /\ UNCHANGED <> - -(* parkNanos: blocks until the permit is available, consumes it. *) -Park == /\ ppc = "park" /\ permit - /\ permit' = FALSE /\ ppc' = "read" - /\ UNCHANGED <> - ----------------------------------------------------------------------------- -(* 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 <> - -Signal == /\ iopc = "signal" - /\ permit' = (permit \/ waiter) /\ iopc' = "idle" - /\ UNCHANGED <> - -Next == Register \/ Read \/ Park \/ Ack \/ Signal - \/ (ppc = "done" /\ acked = Target /\ UNCHANGED vars) - -Spec == Init /\ [][Next]_vars - /\ WF_vars(Register \/ Read \/ Park) - /\ WF_vars(Ack \/ Signal) - ----------------------------------------------------------------------------- -(* 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 registration visible). *) -NoLostAck == (ppc = "park" /\ acked /= seen) => (permit \/ (iopc = "signal" /\ waiter)) - -(* LIVENESS: reaching the target eventually returns the waiter. *) -Returns == (acked >= Target) ~> (ppc = "done") -============================================================================= diff --git a/design/tla/IdleWait.tla b/design/tla/IdleWait.tla deleted file mode 100644 index 6a401ab19..000000000 --- a/design/tla/IdleWait.tla +++ /dev/null @@ -1,182 +0,0 @@ -------------------------------- 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 = 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 - StampFix \* TRUE: the idle re-check keys on publishStamp - -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 - 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 /\ 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: 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' = 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 < bytes - /\ sent' = sent + 1 /\ didWork' = TRUE /\ pc' = "recv" - /\ UNCHANGED <> - -SendNone == /\ pc = "send" /\ ~serverDrop /\ sent = bytes - /\ 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 Key /= 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 == 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(PublishBytes) /\ 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 < 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 /\ 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. *) -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 deleted file mode 100644 index 357198ae0..000000000 --- a/design/tla/IdleWait_bug.cfg +++ /dev/null @@ -1,7 +0,0 @@ -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 deleted file mode 100644 index 31d832a8d..000000000 --- a/design/tla/IdleWait_fix.cfg +++ /dev/null @@ -1,7 +0,0 @@ -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 deleted file mode 100644 index b43ff5b0c..000000000 --- a/design/tla/IdleWait_stampbug.cfg +++ /dev/null @@ -1,7 +0,0 @@ -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 deleted file mode 100644 index 30fd3cfbb..000000000 --- a/design/tla/README.md +++ /dev/null @@ -1,33 +0,0 @@ -# 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 (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 -``` - -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. diff --git a/design/wake-protocol.md b/design/wake-protocol.md deleted file mode 100644 index ad8dd2eb9..000000000 --- a/design/wake-protocol.md +++ /dev/null @@ -1,141 +0,0 @@ -# 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; the ACK-watermark row was removed after it (see Consumer B). - -## 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) | — | -| 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 | -| 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. 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 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`. - -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 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 - -`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 | — | - -## 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 - (`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 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 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. 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 - -Keep moving state to the thread that produces it instead of adding wake -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 fe98739fa330c56784a3d05ea5b5f2e6b5a95079 Mon Sep 17 00:00:00 2001 From: Jaromir Hamala Date: Thu, 29 Oct 2026 17:11:36 +0100 Subject: [PATCH 10/11] Fix late-handler wakeup test and stale store-and-forward comments --- .../client/sf/cursor/CursorSendEngine.java | 40 +++++++-------- .../qwp/client/sf/cursor/SegmentManager.java | 49 ++++++------------- .../SegmentManagerUnlinkFailureTest.java | 2 +- .../cursor/SenderProgressDispatcherTest.java | 6 +-- 4 files changed, 38 insertions(+), 59 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 7f7c66b8f..e1418f2df 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 @@ -92,9 +92,8 @@ public final class CursorSendEngine implements QuietCloseable { // instance via adoptCounters(), so a symbol-dictionary recycle's rebuilt // engine keeps counting where the outgoing one stopped. private CursorSendCounters counters = new CursorSendCounters(); - // Constructed before an owned manager acquires its native path scratch, so - // callback allocation failure cannot orphan manager resources. A timed-out - // close can then hand it to either manager path without allocating. + // Allocated before the engine acquires native or disk resources, so a + // timed-out close can delegate cleanup to the manager without allocating. private final Runnable deferredClose; // Facade for the persisted symbol dictionary only. Separate from // filesFacade (which the manager supplies for segments, watermark and @@ -263,8 +262,8 @@ public CursorSendEngine(String sfDir, long segmentSizeBytes) { /** * Creates an engine with a private, non-shared {@link SegmentManager} * capped at {@code maxTotalBytes} of cursor-allocated memory/disk - * (active + spare + sealed). Producer's {@link #appendBlocking} blocks - * up to {@code appendDeadlineNanos} when the cap is full and ACKs + * (active, sealed, and retained segments). Producer's {@link #appendBlocking} + * blocks up to {@code appendDeadlineNanos} when the cap is full and ACKs * haven't drained sealed segments; on deadline expiry it throws. */ public CursorSendEngine(String sfDir, long segmentSizeBytes, @@ -311,9 +310,10 @@ public CursorSendEngine(String sfDir, long segmentSizeBytes, * engine's private {@link SegmentManager} charges its segments to * {@code budget} instead of to a budget of its own. Engines built on one * budget share its capacity: together they never hold more than it, apart - * from the minimum working set (the active segment plus one spare) each - * ring is always granted. A sender pool builds every sender this way, so - * {@code sf_max_total_bytes} caps the pool rather than each pooled sender. + * from the minimum working set (the active segment plus the one being + * rotated into) each ring is always granted. A sender pool builds every + * sender this way, so {@code sf_max_total_bytes} caps the pool rather than + * each pooled sender. */ public CursorSendEngine(String sfDir, long segmentSizeBytes, SegmentBudget budget, long appendDeadlineNanos, long syncIntervalNanos) { @@ -356,10 +356,8 @@ private CursorSendEngine(String sfDir, long segmentSizeBytes, SegmentManager man boolean ownsManager, SegmentBudget budget, long appendDeadlineNanos, long syncIntervalNanos, FilesFacade dictFf) { this.dictFf = dictFf; - // Allocate the bound callback before constructing an owned manager. - // Field initializers have completed, but no engine-owned native/disk - // resource exists yet. If callback allocation throws, construction - // stops without a manager whose native path scratch could be orphaned. + // Allocate the bound callback before acquiring native or disk resources. + // A timed-out close can then delegate cleanup without allocating. this.deferredClose = createDeferredClose(); // sfDir == null → memory-only mode (non-SF async ingest). Same @@ -368,7 +366,7 @@ private CursorSendEngine(String sfDir, long segmentSizeBytes, SegmentManager man // sfDir != null → store-and-forward mode. Segments are mmap'd files // under sfDir, recoverable across sender restarts. boolean memoryMode = sfDir == null; - // Validate before an owned manager allocates its native path scratch. + // Validate before acquiring the slot lock or allocating segments. if (syncIntervalNanos < 0L) { throw new IllegalArgumentException("syncIntervalNanos must not be negative"); } @@ -393,8 +391,8 @@ private CursorSendEngine(String sfDir, long segmentSizeBytes, SegmentManager man acquiredLock = SlotLock.acquire(sfDir, syncIntervalNanos > 0L); } catch (Throwable t) { // Callback creation and owned-manager construction have already - // completed. A slot-lock failure must close the owned manager's - // native path scratch before propagating. + // completed. Close the owned manager before propagating a + // slot-lock failure. if (ownsManager) { try { manager.close(); @@ -827,7 +825,7 @@ private CursorSendEngine(String sfDir, long segmentSizeBytes, SegmentManager man // up to one window. // The gauge outlives close() safely: it is a pair of wait-free // volatile reads that take no lock and never touch the fd, so a - // manager tick racing engine shutdown observes the final values. + // manager pass racing engine shutdown observes the final values. // It must not become synchronized -- see sideFileBytesLocked(). manager.register(ringInProgress, sfDir, watermarkInProgress, syncIntervalNanos, persistedDictInProgress == null ? null : persistedDictInProgress::occupiedDiskBytes); @@ -841,9 +839,8 @@ private CursorSendEngine(String sfDir, long segmentSizeBytes, SegmentManager man // Stop an owned manager before freeing the ring and watermark it may // touch, then release the slot lock. Each cleanup is in its own // try/catch so a single failure doesn't strand later cleanups. - // Closing an owned-but-never-started manager is safe (no worker to - // join) and required: skipping it leaked the manager's native - // path-scratch sink whenever construction failed before start(). + // Closing an owned-but-never-started manager is safe: there is no + // worker to join. if (ownsManager) { try { manager.close(); @@ -1002,8 +999,7 @@ public long appendOrFsn(long payloadAddr, int payloadLen, long spinDeadlineNanos return fsn; } // Backpressure: spin briefly, then return so the caller decides. - // The spin tightens the gap between manager-installs-spare and - // producer-consumes-spare — usually a few µs on an idle manager thread. + // Retry briefly for budget space or a pending sync barrier to clear. while (System.nanoTime() < spinDeadlineNanos) { Compat.onSpinWait(); fsn = ring.appendOrFsn(payloadAddr, payloadLen); @@ -1114,7 +1110,7 @@ public synchronized void close(boolean reclaimLogicalSlotLock) { } // Quiescence barrier. deregister alone only removes the entry // from the registry — the worker may still be mid service pass - // for this ring (creating a spare file, trimming, unlinking). + // for this ring (syncing or trimming segments). boolean workerQuiescent = false; if (ownsManager) { // Stopping and reaping a private manager is a stronger barrier 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 13dc1a8b4..f1039c284 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 @@ -47,9 +47,8 @@ * 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 - * enough that a producer rarely sees {@link SegmentRing#BACKPRESSURE_NO_SPARE} - * in the steady state, long enough that an idle JVM doesn't burn CPU. + * in a JVM). The worker parks until work is signalled or a periodic-sync + * or trim-retry deadline is due. */ public final class SegmentManager implements QuietCloseable { @@ -79,8 +78,8 @@ public final class SegmentManager implements QuietCloseable { private final SegmentBudget budget; private final FilesFacade filesFacade; private final Object lock = new Object(); - // Reused by the worker thread each tick to snapshot `rings` under the - // lock without per-tick allocation. Owned exclusively by workerLoop(). + // Reused by the worker to snapshot rings under the lock without allocating + // a new list on every pass. Owned exclusively by workerLoop(). private final ObjList ringSnapshot = new ObjList<>(); private final ObjList rings = new ObjList<>(); private final long segmentSizeBytes; @@ -155,22 +154,10 @@ public SegmentManager(long segmentSizeBytes) { /** * @param segmentSizeBytes per-segment file size in bytes - * @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 (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}, the active segment - * plus the one it rotates into. + * @param budget shared accounting for every ring-owned segment, + * including recovered and retained segments. See + * {@link SegmentProvisioner} for backpressure and + * the per-ring liveness floor. */ public SegmentManager(long segmentSizeBytes, SegmentBudget budget) { this(segmentSizeBytes, budget, FilesFacade.INSTANCE, System::nanoTime); @@ -551,15 +538,14 @@ public void register(SegmentRing ring, String dir, AckWatermark watermark, long /** * Same as {@link #register(SegmentRing, String, AckWatermark, long)} but * also wires a live gauge of the slot's {@code .symbol-dict} side-file - * bytes. The manager reads the gauge at every provisioning cap check so + * bytes. The budget reads the gauge at every provisioning cap check so * the dictionary counts against {@code sf_max_total_bytes} alongside the * {@code .sfa} segments; a {@code null} gauge contributes zero (memory * mode, degraded full-dict sessions). *

    * The gauge is invoked under the {@link SegmentBudget}'s monitor, so it - * must be wait-free and must not throw: a throwing gauge terminates the - * worker of every manager charging that budget, and a blocking gauge - * stalls register/deregister and provisioning for all of their slots. + * must be wait-free and must not throw. A blocking gauge stalls budget + * operations for every slot sharing that budget. */ public void register(SegmentRing ring, String dir, AckWatermark watermark, long syncIntervalNanos, LongSupplier sideFileBytes) { if (syncIntervalNanos < 0L) { @@ -574,11 +560,8 @@ public void register(SegmentRing ring, String dir, AckWatermark watermark, long 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 - // at serviceRing would let the manager keep provisioning new spares on - // top of the recovered set, effectively doubling the documented cap. + // Charge bytes the ring already owns, including recovered segments, + // before the provisioner can charge for new segments. long ringBytes = ring.totalSegmentBytes(); // Start the file-generation counter past whatever's already on disk in // this slot, or on recovery the first rotation would try to create @@ -1072,7 +1055,7 @@ private void workerLoop() { while (running) { // Snapshot the rings under the lock so we don't hold it through the // (potentially slow) syscalls during unlink. ringSnapshot is a - // thread-confined field — no per-tick allocation. + // thread-confined field reused between service passes. ringSnapshot.clear(); synchronized (lock) { for (int i = 0, n = rings.size(); i < n; i++) { @@ -1104,7 +1087,7 @@ private void workerLoop() { } } // Drop strong refs so a deregistered ring becomes collectable - // before the next tick (otherwise the snapshot pins it until + // before the next wait (otherwise the snapshot pins it until // the next wakeup). ringSnapshot.clear(); if (!running) break; @@ -1169,7 +1152,7 @@ private static final class RingEntry { // never closes it. final AckWatermark watermark; // Prevents a legacy disk registration without a watermark from - // flooding the log on every manager tick. + // flooding the log on every manager pass. boolean missingWatermarkLogged; long nextDataSyncNanos; boolean syncFailureLogged; 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 40a741d34..cd32bda57 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 @@ -123,7 +123,7 @@ public void testFailedUnlinkIsRetriedWithoutExternalWakeup() throws Exception { Assert.assertEquals(0L, ring.appendOrFsn(payload, 32)); 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. + // Pre-create the next segment so the test exercises only the unlink retry. QueueSegmentSource.install(ring, MmapSegment.create(dir + "/sf-0000000000000002.sfa", 2L, segmentSize)); ring.acknowledge(0L); FailingFilesFacade facade = new FailingFilesFacade(failedPath, null, null); 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 8a4da1a7d..abf9c0167 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 @@ -188,14 +188,14 @@ public void testHandlerInstalledAfterSkippedOffersDelivers() throws Exception { d.getWorkerThreadForTesting()); d.setHandler(fsn -> { last.set(fsn); - if (fsn == 1001) { + if (fsn == 1000) { gotNewest.countDown(); } }); - Assert.assertTrue(d.offer(1001)); + // No later offer: setHandler() must start delivery of the saved watermark. Assert.assertTrue("real handler must receive the newest watermark", gotNewest.await(1, TimeUnit.SECONDS)); - Assert.assertEquals(1001L, last.get()); + Assert.assertEquals(1000L, last.get()); Assert.assertNotNull(d.getWorkerThreadForTesting()); } } From bc67d5d41b3110f114143552ee20d99002a0cfa0 Mon Sep 17 00:00:00 2001 From: Jaromir Hamala Date: Fri, 30 Oct 2026 00:00:13 +0100 Subject: [PATCH 11/11] Reduce allocations during store-and-forward segment rotation --- .../qwp/client/sf/cursor/MmapSegment.java | 85 +++++---- .../client/sf/cursor/SegmentProvisioner.java | 38 ++-- .../java/io/questdb/client/std/Files.java | 5 + .../io/questdb/client/std/FilesFacade.java | 5 + .../CursorSendEngineCrashConsistencyTest.java | 4 + .../SegmentManagerCrashConsistencyTest.java | 4 + .../sf/cursor/SegmentPathAllocationTest.java | 170 ++++++++++++++++++ .../test/tools/DelegatingFilesFacade.java | 7 + 8 files changed, 276 insertions(+), 42 deletions(-) create mode 100644 core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentPathAllocationTest.java 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 3e176f9da..762e5e8c7 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 @@ -28,9 +28,12 @@ import io.questdb.client.std.Files; import io.questdb.client.std.FilesFacade; import io.questdb.client.std.MemoryTag; +import io.questdb.client.std.Misc; +import io.questdb.client.std.Numbers; import io.questdb.client.std.Os; import io.questdb.client.std.QuietCloseable; import io.questdb.client.std.Unsafe; +import io.questdb.client.std.str.StringSink; import org.jetbrains.annotations.TestOnly; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -78,7 +81,11 @@ public final class MmapSegment implements QuietCloseable { private static final int RECOVERY_BUFFER_SIZE = 64 * 1024; private final FilesFacade filesFacade; - private final String path; + // Generation-named files retain their identity without constructing a + // filename String on the producer. Trim/diagnostics materialize it lazily. + private final String directory; + private final long fileGeneration; + private volatile String path; private final long sizeBytes; // memoryBacked: true when the segment buffer lives in malloc'd native // memory rather than an mmap'd file. The "non-SF async" path uses @@ -135,9 +142,11 @@ public final class MmapSegment implements QuietCloseable { private MmapSegment(FilesFacade filesFacade, String path, int fd, long mmapAddress, long sizeBytes, long baseSeq, long initialCursor, long frameCount, - boolean memoryBacked, long tornTailBytes) { + boolean memoryBacked, long tornTailBytes, String directory, long fileGeneration) { this.filesFacade = filesFacade; this.path = path; + this.directory = directory; + this.fileGeneration = fileGeneration; this.fd = fd; this.mmapAddress = mmapAddress; this.sizeBytes = sizeBytes; @@ -153,7 +162,7 @@ private MmapSegment(FilesFacade filesFacade, String path, int fd, long mmapAddre /** * Convenience overload of {@link #create(FilesFacade, String, long, long)} * that uses {@link FilesFacade#INSTANCE} (production default). Tests that - * need to fault-inject filesystem failures (ENOSPC at openCleanRW or + * need to fault-inject filesystem failures (ENOSPC at openRWExclusive or * allocate) should call the facade-aware overload directly. */ public static MmapSegment create(String path, long baseSeq, long sizeBytes) { @@ -165,7 +174,7 @@ public static MmapSegment create(String path, long baseSeq, long sizeBytes) { * {@code sizeBytes} bytes of real disk blocks and mmapping the whole * region RW. Writes the 24-byte header and positions the cursor * immediately after it. Throws {@link MmapSegmentException} on any I/O - * failure (file already exists, openCleanRW failed, ENOSPC during + * failure (file already exists, openRWExclusive failed, ENOSPC during * pre-allocation, mmap rejected). *

    * Pre-allocation uses {@link FilesFacade#allocate(int, long)} so that @@ -178,14 +187,9 @@ public static MmapSegment create(String path, long baseSeq, long sizeBytes) { * that filesystem only. */ public static MmapSegment create(FilesFacade ff, String path, long baseSeq, long sizeBytes) { - return create(ff, path, baseSeq, sizeBytes, false); - } - - static MmapSegment create(FilesFacade ff, String path, long baseSeq, long sizeBytes, - boolean manifestRequired) { long pathPtr = ff.allocNativePath(path); try { - return create(ff, pathPtr, path, baseSeq, sizeBytes, manifestRequired); + return create(ff, pathPtr, path, baseSeq, sizeBytes, false, null, 0); } finally { ff.freeNativePath(pathPtr); } @@ -201,18 +205,23 @@ static MmapSegment create(FilesFacade ff, String path, long baseSeq, long sizeBy */ public static MmapSegment create(FilesFacade ff, long pathPtr, String displayPath, long baseSeq, long sizeBytes) { - return create(ff, pathPtr, displayPath, baseSeq, sizeBytes, false); + return create(ff, pathPtr, displayPath, baseSeq, sizeBytes, false, null, 0); + } + + static MmapSegment createGenerated(FilesFacade ff, long pathPtr, String directory, long generation, + long baseSeq, long sizeBytes) { + return create(ff, pathPtr, null, baseSeq, sizeBytes, true, directory, generation); } - static MmapSegment create(FilesFacade ff, long pathPtr, String displayPath, long baseSeq, long sizeBytes, - boolean manifestRequired) { + private static MmapSegment create(FilesFacade ff, long pathPtr, String displayPath, long baseSeq, + long sizeBytes, boolean manifestRequired, String directory, long generation) { if (sizeBytes < HEADER_SIZE + FRAME_HEADER_SIZE + 1) { throw new IllegalArgumentException( "sizeBytes too small for header + one minimal frame: " + sizeBytes); } int fd = ff.openRWExclusive(pathPtr); if (fd < 0) { - throw new MmapSegmentException("exclusive create failed for " + displayPath); + throw new MmapSegmentException("exclusive create failed for " + pathForError(displayPath, pathPtr)); } // Reserve real disk blocks and advance EOF to sizeBytes in one // call. ENOSPC surfaces here, before the producer thread starts @@ -221,18 +230,15 @@ static MmapSegment create(FilesFacade ff, long pathPtr, String displayPath, long // the JVM). if (!ff.allocate(fd, sizeBytes)) { ff.close(fd); - // Unlink the partially-created file so a sf_max_segment_bytes-sized - // empty file does not survive the failure. Under sustained - // disk-full pressure with the manager polling, hundreds would - // otherwise accumulate. + // Remove the partial file so repeated failures do not leave empty segments. ff.remove(pathPtr); - throw new MmapSegmentException("pre-allocation failed for " + displayPath); + throw new MmapSegmentException("pre-allocation failed for " + pathForError(displayPath, pathPtr)); } long addr = Files.FAILED_MMAP_ADDRESS; try { addr = ff.mmap(fd, sizeBytes, 0, Files.MAP_RW, MemoryTag.MMAP_DEFAULT); if (addr == Files.FAILED_MMAP_ADDRESS) { - throw new MmapSegmentException("mmap failed for " + displayPath); + throw new MmapSegmentException("mmap failed for " + pathForError(displayPath, pathPtr)); } // Header goes straight into the mapping — no separate write syscall. Unsafe.getUnsafe().putInt(addr, FILE_MAGIC); @@ -242,7 +248,7 @@ static MmapSegment create(FilesFacade ff, long pathPtr, String displayPath, long Unsafe.getUnsafe().putLong(addr + 8, baseSeq); Unsafe.getUnsafe().putLong(addr + 16, Os.currentTimeMicros()); return new MmapSegment(ff, displayPath, fd, addr, sizeBytes, baseSeq, - HEADER_SIZE, 0, false, 0L); + HEADER_SIZE, 0, false, 0L, directory, generation); } catch (Throwable t) { if (addr != Files.FAILED_MMAP_ADDRESS) { ff.munmap(addr, sizeBytes, MemoryTag.MMAP_DEFAULT); @@ -280,13 +286,17 @@ public static MmapSegment createInMemory(long baseSeq, long sizeBytes) { Unsafe.getUnsafe().putLong(addr + 8, baseSeq); Unsafe.getUnsafe().putLong(addr + 16, Os.currentTimeMicros()); return new MmapSegment(null, null, -1, addr, sizeBytes, baseSeq, - HEADER_SIZE, 0, true, 0L); + HEADER_SIZE, 0, true, 0L, null, 0); } catch (Throwable t) { Unsafe.free(addr, sizeBytes, MemoryTag.NATIVE_DEFAULT); throw t; } } + private static String pathForError(String path, long pathPtr) { + return path != null ? path : Files.utf8ToString(pathPtr); + } + /** * True when {@code t} is the JVM's recoverable signal for a fault while * accessing a memory-mapped region -- a SIGBUS/SIGSEGV that HotSpot @@ -456,7 +466,9 @@ public static MmapSegment openExisting(FilesFacade ff, String path) { scan.lastGood, scan.frameCount, false, - scan.tornTailBytes + scan.tornTailBytes, + null, + 0 ); } catch (Throwable t) { if (addr != Files.FAILED_MMAP_ADDRESS) { @@ -518,7 +530,7 @@ void syncHeader() { return; } if (filesFacade.msync(mmapAddress, HEADER_SIZE, false) != 0 || filesFacade.fsync(fd) != 0) { - throw new MmapSegmentException("could not sync segment header " + path); + throw new MmapSegmentException("could not sync segment header " + path()); } if (durableCursor < HEADER_SIZE) { durableCursor = HEADER_SIZE; @@ -587,17 +599,17 @@ public long syncPublished() { boolean locked = filesFacade.mlock(lockAddr, lockLen) == 0; if (!locked && MLOCK_REFUSAL_WARNED.compareAndSet(false, true)) { LOG.warn("mlock refused for SF barrier range in {} (degrading to re-dirty-only retry protection); " - + "raise RLIMIT_MEMLOCK or grant CAP_IPC_LOCK to close the post-failure reclaim window", path); + + "raise RLIMIT_MEMLOCK or grant CAP_IPC_LOCK to close the post-failure reclaim window", path()); } boolean durable = false; try { if (filesFacade.msync(mmapAddress, published, false) != 0) { - throw new MmapSegmentException("could not sync segment data " + path); + throw new MmapSegmentException("could not sync segment data " + path()); } // FlushViewOfFile alone is not power-loss durable on Windows. Keep the // fd barrier on every platform so this method has one portable contract. if (filesFacade.fsync(fd) != 0) { - throw new MmapSegmentException("could not sync segment file " + path); + throw new MmapSegmentException("could not sync segment file " + path()); } durable = true; durableCursor = published; @@ -641,9 +653,18 @@ public long publishedOffset() { return publishedCursor; } - /** The on-disk file path this segment was created from / opened against. */ + /** The on-disk file path; generation-named paths are materialized on first access. */ public String path() { - return path; + String result = path; + if (result == null && directory != null) { + StringSink sink = Misc.getThreadLocalSink(); + sink.put(directory).put("/sf-"); + Numbers.appendHex(sink, fileGeneration, true); + sink.put(".sfa"); + result = sink.toString(); + path = result; + } + return result; } /** @@ -835,20 +856,20 @@ public void sanitizeTornTail() { long residueStart = sizeBytes - tornTailBytes; if (appendCursor != residueStart) { throw new MmapSegmentException( - "torn tail must be sanitized before appends resume in " + path + "torn tail must be sanitized before appends resume in " + path() + " [appendCursor=" + appendCursor + ", residueStart=" + residueStart + ']'); } Unsafe.getUnsafe().setMemory(mmapAddress + residueStart, tornTailBytes, (byte) 0); if (filesFacade.msync(mmapAddress, sizeBytes, false) != 0 || filesFacade.fsync(fd) != 0) { throw new MmapSegmentException( - "could not sync zeroed torn tail in " + path + "could not sync zeroed torn tail in " + path() + " [errno=" + Os.errno() + ']'); } tornTailSanitized = true; LOG.warn("SF segment {}: zeroed {} bytes of torn-tail residue at offset {}; " + "frames past the tear (if any) are discarded", - path, tornTailBytes, residueStart); + path(), tornTailBytes, residueStart); } /** 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 b547fba7b..c3e74886a 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 @@ -25,6 +25,9 @@ package io.questdb.client.cutlass.qwp.client.sf.cursor; import io.questdb.client.std.FilesFacade; +import io.questdb.client.std.Numbers; +import io.questdb.client.std.str.DirectUtf8Sink; +import io.questdb.client.std.str.Utf8s; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -66,6 +69,9 @@ public final class SegmentProvisioner implements SegmentSource { // null = memory mode private final String dir; private final FilesFacade ff; + // Producer-owned, sized at registration for the full UTF-8 filename and + // NUL terminator. Ring close frees it after manager/I/O quiescence. + private final DirectUtf8Sink pathScratch; private final SegmentRing ring; private final long segmentSizeBytes; private long lastLogNs; @@ -88,6 +94,7 @@ public final class SegmentProvisioner implements SegmentSource { this.segmentSizeBytes = segmentSizeBytes; this.budget = budget; this.ff = ff; + this.pathScratch = dir == null ? null : new DirectUtf8Sink(Utf8s.utf8Bytes(dir) + 25L); } @Override @@ -97,10 +104,16 @@ public void close() { // name, which recovery accepts either way. Its budget charge is not // released here: SegmentManager.deregister already released it as // part of the ring's totalSegmentBytes() (see heldBytes()). - if (retained != null) { - MmapSegment r = retained; - retained = null; - r.close(); + try { + if (retained != null) { + MmapSegment r = retained; + retained = null; + r.close(); + } + } finally { + if (pathScratch != null) { + pathScratch.close(); + } } } @@ -158,7 +171,6 @@ public MmapSegment next(long baseSeq) { } } MmapSegment segment = null; - String path = null; try { if (dir == null) { segment = MmapSegment.createInMemory(baseSeq, segmentSizeBytes); @@ -166,12 +178,18 @@ public MmapSegment next(long baseSeq) { // 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); + long generation = nextGeneration++; + pathScratch.clear(); + pathScratch.put(dir).putAscii("/sf-"); + Numbers.appendHex(pathScratch, generation, true); + pathScratch.putAscii(".sfa").putAscii('\0'); + segment = MmapSegment.createGenerated(ff, pathScratch.ptr(), dir, generation, baseSeq, segmentSizeBytes); // Header and directory entry durable before the ring's // manifest can name this segment. segment.syncHeader(); - if (ff.fsyncDir(dir) != 0) { + pathScratch.clear(); + pathScratch.put(dir).putAscii('\0'); + if (ff.fsyncDir(pathScratch.ptr()) != 0) { throw new MmapSegmentException("could not sync segment directory " + dir); } } @@ -186,8 +204,8 @@ public MmapSegment next(long baseSeq) { } // create() removes its own file when it throws; only a // segment that exists needs unlinking here. - if (path != null) { - ff.remove(path); + if (dir != null) { + ff.remove(segment.path()); } } retryAtNanos = now + CREATE_RETRY_NANOS; diff --git a/core/src/main/java/io/questdb/client/std/Files.java b/core/src/main/java/io/questdb/client/std/Files.java index 47bf60d9a..f3168825a 100644 --- a/core/src/main/java/io/questdb/client/std/Files.java +++ b/core/src/main/java/io/questdb/client/std/Files.java @@ -467,6 +467,11 @@ public static int fsyncDir(String dir) { } } + /** Syncs a directory named by a NUL-terminated native UTF-8 path. */ + public static int fsyncDir(long dirPtr) { + return fsyncDir0(dirPtr); + } + /** * Forces the directory entry naming {@code path} to durable storage by * syncing its absolute parent directory. diff --git a/core/src/main/java/io/questdb/client/std/FilesFacade.java b/core/src/main/java/io/questdb/client/std/FilesFacade.java index be090593c..5d700960a 100644 --- a/core/src/main/java/io/questdb/client/std/FilesFacade.java +++ b/core/src/main/java/io/questdb/client/std/FilesFacade.java @@ -104,6 +104,11 @@ default int fsyncDir(String dir) { return Files.fsyncDir(dir); } + /** Native-path variant of {@link #fsyncDir(String)}. */ + default int fsyncDir(long dirPtr) { + return Files.fsyncDir(dirPtr); + } + /** * Whether callers should use this facade's mmap path. The production facade * returns {@code true}; wrapping fault facades retain positioned I/O unless 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 24e2406c9..06367cfa4 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 @@ -400,6 +400,10 @@ public int fsyncDir(String dir) { return INSTANCE.fsyncDir(dir); } @Override + public int fsyncDir(long dirPtr) { + return fsyncDir(Files.utf8ToString(dirPtr)); + } + @Override public long length(int fd) { return INSTANCE.length(fd); } @Override public long length(String path) { return INSTANCE.length(path); } 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 4fae01c50..e9abca491 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 @@ -495,6 +495,10 @@ private long ticks() { } return INSTANCE.fsyncDir(dir); } + @Override + public int fsyncDir(long dirPtr) { + return fsyncDir(Files.utf8ToString(dirPtr)); + } @Override public long length(int fd) { return INSTANCE.length(fd); } @Override public long length(String path) { return INSTANCE.length(path); } @Override public long length(long pathPtr) { return INSTANCE.length(pathPtr); } diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentPathAllocationTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentPathAllocationTest.java new file mode 100644 index 000000000..be9ccce28 --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentPathAllocationTest.java @@ -0,0 +1,170 @@ +/*+***************************************************************************** + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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.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.Assume; +import org.junit.Test; + +import java.io.File; +import java.lang.management.ManagementFactory; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +public class SegmentPathAllocationTest { + private static final int OBJECT_SAMPLES = 1024; + private static final int OBJECT_WARMUP = 10_000; + private static final int PAYLOAD = 64; + private static final int ROTATIONS = 256; + private static final int ROTATION_WARMUP = 64; + private static final long SIZE = MmapSegment.HEADER_SIZE + MmapSegment.FRAME_HEADER_SIZE + PAYLOAD; + private static volatile MmapSegment observed; + + @Test(timeout = 30_000L) + public void testRotationsDoNotAllocatePaths() throws Exception { + Assume.assumeTrue(ManagementFactory.getThreadMXBean() instanceof com.sun.management.ThreadMXBean); + com.sun.management.ThreadMXBean mx = (com.sun.management.ThreadMXBean) ManagementFactory.getThreadMXBean(); + Assume.assumeTrue(mx.isThreadAllocatedMemorySupported()); + mx.setThreadAllocatedMemoryEnabled(true); + TestUtils.assertMemoryLeak(() -> { + long tid = Thread.currentThread().getId(); + long objectBytes = 0; + for (int i = 0; i < OBJECT_WARMUP + OBJECT_SAMPLES; i++) { + long before = mx.getThreadAllocatedBytes(tid); + MmapSegment segment = MmapSegment.createInMemory(0, SIZE); + observed = segment; // Keep the object from being scalar-replaced. + long bytes = mx.getThreadAllocatedBytes(tid) - before; + if (i >= OBJECT_WARMUP) { + objectBytes += bytes; + } + segment.close(); + observed = null; + } + objectBytes /= OBJECT_SAMPLES; + Assert.assertTrue("segment object must be counted", objectBytes > 0); + String dir = TestUtils.createTmpDir("qdb-path-allocation-"); + long buf = Unsafe.malloc(PAYLOAD, MemoryTag.NATIVE_DEFAULT); + try (SegmentRing ring = new SegmentRing(MmapSegment.create(dir + "/sf-initial.sfa", 0, SIZE), SIZE); + SegmentManager manager = new SegmentManager(SIZE)) { + manager.register(ring, dir); + // Exclude sealed-list growth from the path allocation measurement. + ring.getSealedSegments().checkCapacity(512); + Assert.assertEquals(0L, ring.appendOrFsn(buf, PAYLOAD)); + MmapSegment first = null; + long allocated = 0; + for (int i = 1; i <= ROTATION_WARMUP + ROTATIONS; i++) { + long before = mx.getThreadAllocatedBytes(tid); + long fsn = ring.appendOrFsn(buf, PAYLOAD); + long bytes = mx.getThreadAllocatedBytes(tid) - before; + Assert.assertEquals(i, fsn); + if (i > ROTATION_WARMUP) { + allocated += bytes; + } + if (i == 1) { + first = ring.getActive(); + } + } + // JDK 8 may charge a few hundred one-off runtime bytes. Any + // extra object per rotation exceeds this allowance over 256 rotations. + Assert.assertTrue(allocated >= objectBytes * ROTATIONS); + Assert.assertTrue("unexpected rotation allocation: " + allocated, + allocated - objectBytes * ROTATIONS < 1024); + // Check old filenames after subsequent rotations overwrite the native buffer. + Assert.assertEquals(dir + "/sf-0000000000000000.sfa", first.path()); + Assert.assertEquals(dir + "/sf-000000000000013f.sfa", ring.getActive().path()); + Assert.assertEquals(320L, ring.getActive().baseSeq()); + manager.deregister(ring); + } finally { + Unsafe.free(buf, PAYLOAD, MemoryTag.NATIVE_DEFAULT); + TestUtils.removeTmpDir(dir); + } + }); + } + + @Test + public void testLongUnicodePathAndFailedNativeDirectorySync() throws Exception { + TestUtils.assertMemoryLeak(() -> { + String root = TestUtils.createTmpDir("qdb-path-unicode-"); + StringBuilder part = new StringBuilder(); + for (int i = 0; i < 64; i++) { + part.append('a'); + } + String parent = root + "/" + part; + Assert.assertEquals(0, Files.mkdir(parent, Files.DIR_MODE_DEFAULT)); + part.setLength(0); + for (int i = 0; i < 14; i++) { + part.append("žluť😀"); + } + String dir = parent + "/" + part; + Assert.assertEquals(0, Files.mkdir(dir, Files.DIR_MODE_DEFAULT)); + AtomicBoolean failSync = new AtomicBoolean(true); + AtomicInteger syncCalls = new AtomicInteger(); + Thread producer = Thread.currentThread(); + DelegatingFilesFacade ff = new DelegatingFilesFacade() { + @Override + public boolean allocate(int fd, long bytes) { + Assert.assertSame(producer, Thread.currentThread()); + return super.allocate(fd, bytes); + } + @Override + public int fsyncDir(long ptr) { + Assert.assertEquals(dir, Files.utf8ToString(ptr)); + syncCalls.incrementAndGet(); + return failSync.get() ? -1 : Files.fsyncDir(ptr); + } + }; + SegmentBudget budget = new SegmentBudget(4 * SIZE); + long buf = Unsafe.malloc(PAYLOAD, MemoryTag.NATIVE_DEFAULT); + try (SegmentRing ring = new SegmentRing(MmapSegment.create(dir + "/sf-0000000000000007.sfa", 0, SIZE), SIZE); + SegmentManager manager = new SegmentManager(SIZE, budget, ff, System::nanoTime)) { + manager.register(ring, dir); + Assert.assertEquals(0L, ring.appendOrFsn(buf, PAYLOAD)); + Assert.assertEquals(SegmentRing.BACKPRESSURE_NO_SPARE, ring.appendOrFsn(buf, PAYLOAD)); + Assert.assertEquals(SIZE, budget.getSegmentBytes()); + Assert.assertEquals("failed directory barrier removes created file", 1, new File(dir).list().length); + failSync.set(false); + Thread.sleep(20); + Assert.assertEquals(1L, ring.appendOrFsn(buf, PAYLOAD)); + Assert.assertEquals(dir + "/sf-0000000000000009.sfa", ring.getActive().path()); + Assert.assertEquals(2, syncCalls.get()); + Assert.assertEquals(2 * SIZE, budget.getSegmentBytes()); + manager.deregister(ring); + Assert.assertEquals(0L, budget.getSegmentBytes()); + } finally { + Unsafe.free(buf, PAYLOAD, MemoryTag.NATIVE_DEFAULT); + TestUtils.removeTmpDir(root); + } + }); + } +} diff --git a/core/src/test/java/io/questdb/client/test/tools/DelegatingFilesFacade.java b/core/src/test/java/io/questdb/client/test/tools/DelegatingFilesFacade.java index 748de117f..b6184f8db 100644 --- a/core/src/test/java/io/questdb/client/test/tools/DelegatingFilesFacade.java +++ b/core/src/test/java/io/questdb/client/test/tools/DelegatingFilesFacade.java @@ -24,6 +24,7 @@ package io.questdb.client.test.tools; +import io.questdb.client.std.Files; import io.questdb.client.std.FilesFacade; /** @@ -98,6 +99,12 @@ public int fsync(int fd) { return INSTANCE.fsync(fd); } + // Preserve String-based directory fault injection for native-path callers. + @Override + public int fsyncDir(long dirPtr) { + return fsyncDir(Files.utf8ToString(dirPtr)); + } + @Override public long length(int fd) { return INSTANCE.length(fd);