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/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/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..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 @@ -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 @@ -741,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 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..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 @@ -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,11 @@ 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, including a segment the + // provisioner holds after a failed rotation. e.deregister(); budget.release(ring.totalSegmentBytes()); if (e.sideFileBytes != null) { @@ -622,25 +531,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 +586,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 +627,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 +659,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 +690,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 +721,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 +773,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 +822,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 +839,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 +885,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 +906,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 +1089,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 +1104,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 +1182,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..b547fba7b --- /dev/null +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentProvisioner.java @@ -0,0 +1,202 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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, 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, + 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. 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(); + } + } + + @Override + 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) { + 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..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 @@ -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 @@ -1698,19 +1710,26 @@ public void syncAllLiveSegments() { clearSyncRequestIfActiveDurable(); } + /** 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 + hot spare (if - * installed) + 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. + * 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(); - 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 +1807,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..f3c05c365 --- /dev/null +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentSource.java @@ -0,0 +1,66 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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); + + /** + * 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/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/cutlass/qwp/protocol/OffHeapAppendMemory.java b/core/src/main/java/io/questdb/client/cutlass/qwp/protocol/OffHeapAppendMemory.java index 67f9f20e5..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 @@ -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; } @@ -169,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; } /** @@ -213,13 +206,53 @@ 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) { - 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; 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/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/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..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,17 +25,21 @@ 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; 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 +51,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 +60,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 +73,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); } @@ -141,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(() -> { @@ -148,8 +234,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 +248,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 +262,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 +277,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,17 +328,66 @@ 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)); } + 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()) { 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/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(() -> { 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/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(() -> { 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.