diff --git a/README.md b/README.md
index cc1b25ae..01aa3370 100644
--- a/README.md
+++ b/README.md
@@ -142,8 +142,9 @@ You can also let the client flush batches for you with the `auto_flush_rows` / `
`ws::addr=localhost:9000;auto_flush_rows=10000;auto_flush_interval=1000;`.
**Confirm a batch is durably received.** Over QWP each flush returns a frame sequence number (FSN); `awaitAckedFsn`
-blocks until the server has acknowledged it. Rows are safe in the store-and-forward log even if the ack has not landed
-yet — they replay on reconnect.
+blocks until the server has acknowledged it. With `sf_dir`, rows in the store-and-forward log replay after reconnect
+or a producer-process restart. For periodic host-power-loss checkpoints, also configure
+`sf_durability=periodic;sf_sync_interval_millis=5000;`.
```java
try (Sender sender = db.borrowSender()) {
@@ -259,7 +260,9 @@ try (QuestDB db = QuestDB.builder()
List every cluster node in one `addr` server list; the single string configures both the ingest and query pools across
all of them. On the query side, `target` selects the node role to route to (`any`, `primary`, or `replica`) and
`failover=on` enables failover across the list. The ingest side reconnects across the same node list on its own — a
-store-and-forward sender keeps buffering rows through a failover window and never drops them.
+store-and-forward sender keeps buffering rows through a failover window and replays unacknowledged data. The default
+`memory` durability mode protects process restarts, not host power loss; use periodic durability for background disk
+checkpoints.
```java
try (QuestDB db = QuestDB.connect(
@@ -439,7 +442,10 @@ Applied by the query pool to select and fail over between the nodes in the `addr
| `client_id` | | Opaque client identifier surfaced server-side for observability |
The ingest side also accepts store-and-forward and reconnection tuning keys (`auto_flush_*`, `initial_connect_retry`,
-`reconnect_*`, `request_durable_ack`, `sf_*`, `max_frame_rejections`, `poison_min_escalation_window_millis`, …). See the
+`reconnect_*`, `request_durable_ack`, `sf_*`, `max_frame_rejections`, `poison_min_escalation_window_millis`, …).
+`sf_durability=periodic` checkpoints mmap-published data in the background; `sf_sync_interval_millis` defaults to `5000`
+in that mode. The interval is a target cadence: JVM scheduling and storage-sync latency add to the actual loss window.
+Use `request_durable_ack=on` when end-to-end server durability is also required. See the
[QuestDB documentation](https://questdb.com/docs/) for the full reference.
## Requirements
diff --git a/core/src/main/c/share/files.c b/core/src/main/c/share/files.c
index 75f228fa..b469e17b 100644
--- a/core/src/main/c/share/files.c
+++ b/core/src/main/c/share/files.c
@@ -65,6 +65,13 @@ JNIEXPORT jint JNICALL Java_io_questdb_client_std_Files_openRW0
return (jint) fd;
}
+JNIEXPORT jint JNICALL Java_io_questdb_client_std_Files_openRWExclusive0
+ (JNIEnv *e, jclass cl, jlong lpszName) {
+ int fd;
+ RESTARTABLE(open((const char *) (uintptr_t) lpszName, O_CREAT | O_EXCL | O_RDWR, 0644), fd);
+ return (jint) fd;
+}
+
JNIEXPORT jint JNICALL Java_io_questdb_client_std_Files_openAppend0
(JNIEnv *e, jclass cl, jlong lpszName) {
int fd;
@@ -124,6 +131,21 @@ JNIEXPORT jint JNICALL Java_io_questdb_client_std_Files_fsync
return res;
}
+JNIEXPORT jint JNICALL Java_io_questdb_client_std_Files_fsyncDir0
+ (JNIEnv *e, jclass cl, jlong lpszName) {
+ int fd;
+ RESTARTABLE(open((const char *) (uintptr_t) lpszName, O_RDONLY), fd);
+ if (fd < 0) {
+ return -1;
+ }
+ int res;
+ RESTARTABLE(fsync(fd), res);
+ int saved_errno = errno;
+ close(fd);
+ errno = saved_errno;
+ return res;
+}
+
JNIEXPORT jboolean JNICALL Java_io_questdb_client_std_Files_truncate
(JNIEnv *e, jclass cl, jint fd, jlong size) {
int res;
@@ -236,6 +258,18 @@ JNIEXPORT jint JNICALL Java_io_questdb_client_std_Files_lock
return flock((int) fd, LOCK_EX | LOCK_NB);
}
+JNIEXPORT jint JNICALL Java_io_questdb_client_cutlass_qwp_client_sf_cursor_SlotLock_release0
+ (JNIEnv *e, jclass cl, jint fd) {
+ if (flock((int) fd, LOCK_UN) != 0) {
+ return -1;
+ }
+ /* Unlock success confirms that the slot is reusable. close() is one-shot:
+ * POSIX leaves descriptor state unspecified on EINTR, so retrying its
+ * numeric value could close an unrelated descriptor after reuse. */
+ (void) close((int) fd);
+ return 0;
+}
+
JNIEXPORT jint JNICALL Java_io_questdb_client_std_Files_mkdir0
(JNIEnv *e, jclass cl, jlong lpszPath, jint mode) {
return mkdir((const char *) (uintptr_t) lpszPath, (mode_t) mode);
@@ -354,3 +388,15 @@ JNIEXPORT jint JNICALL Java_io_questdb_client_std_Files_msync
(JNIEnv *e, jclass cl, jlong addr, jlong len, jboolean async) {
return msync((void *) (uintptr_t) addr, (size_t) len, async ? MS_ASYNC : MS_SYNC);
}
+
+/* Best-effort page pin. Callers treat a refusal (RLIMIT_MEMLOCK, missing
+ * privilege) as a soft downgrade, so no errno capture is required here. */
+JNIEXPORT jint JNICALL Java_io_questdb_client_std_Files_mlock0
+ (JNIEnv *e, jclass cl, jlong addr, jlong len) {
+ return mlock((void *) (uintptr_t) addr, (size_t) len);
+}
+
+JNIEXPORT jint JNICALL Java_io_questdb_client_std_Files_munlock0
+ (JNIEnv *e, jclass cl, jlong addr, jlong len) {
+ return munlock((void *) (uintptr_t) addr, (size_t) len);
+}
diff --git a/core/src/main/c/share/net.c b/core/src/main/c/share/net.c
index 3b0162fc..f3bc1ddd 100644
--- a/core/src/main/c/share/net.c
+++ b/core/src/main/c/share/net.c
@@ -128,6 +128,12 @@ JNIEXPORT jint JNICALL Java_io_questdb_client_network_Net_send
return com_questdb_network_Net_EOTHERDISCONNECT;
}
+JNIEXPORT jint JNICALL Java_io_questdb_client_network_Net_shutdown
+ (JNIEnv *e, jclass cl, jint fd) {
+ const int result = shutdown((int) fd, SHUT_RDWR);
+ return result == -1 && errno == ENOTCONN ? 0 : result;
+}
+
JNIEXPORT jint JNICALL Java_io_questdb_client_network_Net_recv
(JNIEnv *e, jclass cl, jint fd, jlong ptr, jint len) {
ssize_t n;
diff --git a/core/src/main/c/share/net.h b/core/src/main/c/share/net.h
index 27143639..d80617e2 100644
--- a/core/src/main/c/share/net.h
+++ b/core/src/main/c/share/net.h
@@ -80,6 +80,14 @@ JNIEXPORT jint JNICALL Java_io_questdb_client_network_Net_recv
JNIEXPORT jint JNICALL Java_io_questdb_client_network_Net_send
(JNIEnv *, jclass, jint, jlong, jint);
+/*
+ * Class: io_questdb_client_network_Net
+ * Method: shutdown
+ * Signature: (I)I
+ */
+JNIEXPORT jint JNICALL Java_io_questdb_client_network_Net_shutdown
+ (JNIEnv *, jclass, jint);
+
/*
* Class: io_questdb_client_network_Net
* Method: getSndBuf
diff --git a/core/src/main/c/windows/errno.h b/core/src/main/c/windows/errno.h
index 093d52c2..a2971056 100644
--- a/core/src/main/c/windows/errno.h
+++ b/core/src/main/c/windows/errno.h
@@ -6,4 +6,10 @@ static DWORD dwTlsIndexLastError = 0;
void SaveLastError();
+/* Returns the per-thread error code most recently stored by SaveLastError().
+ * Both functions are defined in os.c, the only translation unit whose static
+ * dwTlsIndexLastError copy is initialised by TlsAlloc() in DllMain; every
+ * other includer's copy of the variable is an unused zero. */
+DWORD GetSavedLastError();
+
#endif //ZLIB_ERRNO_H
diff --git a/core/src/main/c/windows/files.c b/core/src/main/c/windows/files.c
index 119f6315..e2b97af7 100644
--- a/core/src/main/c/windows/files.c
+++ b/core/src/main/c/windows/files.c
@@ -77,11 +77,16 @@ static jint open_file(const char *utf8Path,
}
HANDLE h = CreateFileW(wide, desiredAccess, shareMode, NULL,
creationDisposition, flagsAndAttributes, NULL);
- free(wide);
if (h == INVALID_HANDLE_VALUE) {
+ // Save the CreateFileW failure code BEFORE free(): the thread's
+ // last-error value may be overwritten by any subsequent API/CRT call
+ // (HeapFree can SetLastError), and callers such as fsyncDir0 rely on
+ // the saved value being the CreateFileW error.
SaveLastError();
+ free(wide);
return -1;
}
+ free(wide);
return HANDLE_TO_FD(h);
}
@@ -103,6 +108,15 @@ JNIEXPORT jint JNICALL Java_io_questdb_client_std_Files_openRW0
FILE_ATTRIBUTE_NORMAL);
}
+JNIEXPORT jint JNICALL Java_io_questdb_client_std_Files_openRWExclusive0
+ (JNIEnv *e, jclass cl, jlong lpszName) {
+ return open_file((const char *) (uintptr_t) lpszName,
+ GENERIC_READ | GENERIC_WRITE,
+ FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
+ CREATE_NEW,
+ FILE_ATTRIBUTE_NORMAL);
+}
+
JNIEXPORT jint JNICALL Java_io_questdb_client_std_Files_openAppend0
(JNIEnv *e, jclass cl, jlong lpszName) {
jint fd = open_file((const char *) (uintptr_t) lpszName,
@@ -215,6 +229,52 @@ JNIEXPORT jint JNICALL Java_io_questdb_client_std_Files_fsync
return 0;
}
+JNIEXPORT jint JNICALL Java_io_questdb_client_std_Files_fsyncDir0
+ (JNIEnv *e, jclass cl, jlong lpszName) {
+ jint fd = open_file((const char *) (uintptr_t) lpszName,
+ GENERIC_READ | GENERIC_WRITE,
+ FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
+ OPEN_EXISTING,
+ FILE_FLAG_BACKUP_SEMANTICS);
+ if (fd < 0) {
+ // A directory can be crash-consistent yet not openable for write:
+ // some ACL/filesystem configurations refuse a GENERIC_WRITE open of a
+ // directory handle with ERROR_ACCESS_DENIED. Directory-entry durability
+ // on NTFS is provided by metadata journaling ($LogFile), so degrade to
+ // best-effort success here rather than hard-failing the SF durability
+ // path. Consult the error open_file() saved at the CreateFileW failure
+ // point rather than the live GetLastError(): the intervening free()
+ // and return path inside open_file() are not guaranteed to preserve
+ // the thread's last-error value. A genuine failure such as a missing
+ // directory (ERROR_PATH_NOT_FOUND) still propagates as fatal.
+ if (GetSavedLastError() == ERROR_ACCESS_DENIED) {
+ return 0;
+ }
+ return -1;
+ }
+ if (!FlushFileBuffers(FD_TO_HANDLE(fd))) {
+ DWORD err = GetLastError();
+ CloseHandle(FD_TO_HANDLE(fd));
+ // FlushFileBuffers is documented for file/volume handles; NTFS refuses
+ // it on a directory handle (typically ERROR_ACCESS_DENIED, and
+ // ERROR_INVALID_FUNCTION on filesystems that do not implement it).
+ // create/rename/unlink of directory entries are made crash consistent
+ // by NTFS metadata journaling, so treat those "not supported"
+ // signatures as best-effort success. This mirrors libgit2/PostgreSQL/
+ // SQLite, which do not rely on directory fsync on Windows, and keeps
+ // the SF manifest-create, slot-lock, and trim/unlink barriers from
+ // hard-failing. Any other error is a real I/O failure and stays fatal.
+ if (err == ERROR_ACCESS_DENIED || err == ERROR_INVALID_FUNCTION) {
+ return 0;
+ }
+ SetLastError(err);
+ SaveLastError();
+ return -1;
+ }
+ CloseHandle(FD_TO_HANDLE(fd));
+ return 0;
+}
+
JNIEXPORT jboolean JNICALL Java_io_questdb_client_std_Files_truncate
(JNIEnv *e, jclass cl, jint fd, jlong size) {
FILE_END_OF_FILE_INFO eof;
@@ -300,18 +360,24 @@ JNIEXPORT jlong JNICALL Java_io_questdb_client_std_Files_length0
OPEN_EXISTING,
FILE_ATTRIBUTE_NORMAL,
NULL);
- free(wide);
if (h == INVALID_HANDLE_VALUE) {
+ /* Save the CreateFileW failure code BEFORE free(): any subsequent
+ * API/CRT call (HeapFree can SetLastError) may overwrite the
+ * thread's last-error value -- same pattern as open_file(). */
SaveLastError();
+ free(wide);
return -1;
}
+ free(wide);
LARGE_INTEGER sz;
BOOL ok = GetFileSizeEx(h, &sz);
- CloseHandle(h);
if (!ok) {
+ /* Save before CloseHandle() can clobber the last-error value. */
SaveLastError();
+ CloseHandle(h);
return -1;
}
+ CloseHandle(h);
return (jlong) sz.QuadPart;
}
@@ -331,6 +397,20 @@ JNIEXPORT jint JNICALL Java_io_questdb_client_std_Files_lock
return 0;
}
+JNIEXPORT jint JNICALL Java_io_questdb_client_cutlass_qwp_client_sf_cursor_SlotLock_release0
+ (JNIEnv *e, jclass cl, jint fd) {
+ OVERLAPPED ov;
+ memset(&ov, 0, sizeof(ov));
+ if (!UnlockFileEx(FD_TO_HANDLE(fd), 0, MAXDWORD, MAXDWORD, &ov)) {
+ SaveLastError();
+ return -1;
+ }
+ /* Unlock success confirms that the slot is reusable. Match POSIX by
+ * closing once without making handle cleanup part of that signal. */
+ (void) CloseHandle(FD_TO_HANDLE(fd));
+ return 0;
+}
+
JNIEXPORT jint JNICALL Java_io_questdb_client_std_Files_mkdir0
(JNIEnv *e, jclass cl, jlong lpszPath, jint mode) {
(void) mode;
@@ -340,11 +420,13 @@ JNIEXPORT jint JNICALL Java_io_questdb_client_std_Files_mkdir0
return -1;
}
BOOL ok = CreateDirectoryW(wide, NULL);
- free(wide);
if (!ok) {
+ /* Save before free() can clobber the last-error value. */
SaveLastError();
+ free(wide);
return -1;
}
+ free(wide);
return 0;
}
@@ -373,11 +455,13 @@ JNIEXPORT jboolean JNICALL Java_io_questdb_client_std_Files_remove0
} else {
ok = DeleteFileW(wide);
}
- free(wide);
if (!ok) {
+ /* Save before free() can clobber the last-error value. */
SaveLastError();
+ free(wide);
return JNI_FALSE;
}
+ free(wide);
return JNI_TRUE;
}
@@ -395,12 +479,15 @@ JNIEXPORT jint JNICALL Java_io_questdb_client_std_Files_rename0
return -1;
}
BOOL ok = MoveFileExW(oldW, newW, MOVEFILE_REPLACE_EXISTING);
- free(oldW);
- free(newW);
if (!ok) {
+ /* Save before free() can clobber the last-error value. */
SaveLastError();
+ free(oldW);
+ free(newW);
return -1;
}
+ free(oldW);
+ free(newW);
return 0;
}
@@ -438,11 +525,13 @@ JNIEXPORT jlong JNICALL Java_io_questdb_client_std_Files_findFirst0
pattern[pathLen] = '\0';
wchar_t *wide = utf8_to_wide(pattern);
- free(pattern);
if (!wide) {
+ /* Save before free() can clobber the last-error value. */
SaveLastError();
+ free(pattern);
return 0;
}
+ free(pattern);
qdb_find_t *find = (qdb_find_t *) malloc(sizeof(qdb_find_t));
if (!find) {
@@ -450,12 +539,14 @@ JNIEXPORT jlong JNICALL Java_io_questdb_client_std_Files_findFirst0
return 0;
}
find->handle = FindFirstFileW(wide, &find->data);
- free(wide);
if (find->handle == INVALID_HANDLE_VALUE) {
+ /* Save before free() can clobber the last-error value. */
SaveLastError();
+ free(wide);
free(find);
return 0;
}
+ free(wide);
find->hasEntry = 1;
win_findname_to_utf8(find);
return (jlong) (uintptr_t) find;
@@ -615,3 +706,16 @@ JNIEXPORT jint JNICALL Java_io_questdb_client_std_Files_msync
(void) async;
return 0;
}
+
+/* Best-effort page pin. VirtualLock is quota-bound to the process working-set
+ * minimum; callers treat any refusal as a soft downgrade, so no
+ * SaveLastError() -- the refusal is not surfaced as an error. */
+JNIEXPORT jint JNICALL Java_io_questdb_client_std_Files_mlock0
+ (JNIEnv *e, jclass cl, jlong addr, jlong len) {
+ return VirtualLock((LPVOID) (uintptr_t) addr, (SIZE_T) len) ? 0 : -1;
+}
+
+JNIEXPORT jint JNICALL Java_io_questdb_client_std_Files_munlock0
+ (JNIEnv *e, jclass cl, jlong addr, jlong len) {
+ return VirtualUnlock((LPVOID) (uintptr_t) addr, (SIZE_T) len) ? 0 : -1;
+}
diff --git a/core/src/main/c/windows/net.c b/core/src/main/c/windows/net.c
index fd290629..191295ab 100644
--- a/core/src/main/c/windows/net.c
+++ b/core/src/main/c/windows/net.c
@@ -230,6 +230,28 @@ JNIEXPORT jint JNICALL Java_io_questdb_client_network_Net_configureNonBlocking
return res;
}
+JNIEXPORT jint JNICALL Java_io_questdb_client_network_Net_shutdown
+ (JNIEnv *e, jclass cl, jint fd) {
+ // Unlike POSIX, a Winsock shutdown() does not unblock a recv() that is
+ // already in progress on another thread. Cancel any outstanding I/O on the
+ // handle first so a worker parked in a blocking recv() wakes up. CancelIoEx
+ // does not close the socket, so the descriptor stays allocated and the fd
+ // cannot be reused underneath the worker before close() releases it. A
+ // FALSE return with ERROR_NOT_FOUND (nothing was pending) is expected here
+ // and deliberately ignored.
+ CancelIoEx((HANDLE) (SOCKET) fd, NULL);
+ const int result = shutdown((SOCKET) fd, SD_BOTH);
+ if (result == SOCKET_ERROR) {
+ const int error = WSAGetLastError();
+ if (error == WSAENOTCONN) {
+ return 0;
+ }
+ WSASetLastError(error);
+ SaveLastError();
+ }
+ return result;
+}
+
JNIEXPORT jint JNICALL Java_io_questdb_client_network_Net_recv
(JNIEnv *e, jclass cl, jint fd, jlong addr, jint len) {
const int n = recv((SOCKET) fd, (char *) addr, len, 0);
diff --git a/core/src/main/c/windows/os.c b/core/src/main/c/windows/os.c
index 73bb4307..e50ce84c 100644
--- a/core/src/main/c/windows/os.c
+++ b/core/src/main/c/windows/os.c
@@ -237,3 +237,7 @@ JNIEXPORT jlong JNICALL Java_io_questdb_client_std_Os_currentTimeMicros
void SaveLastError() {
TlsSetValue(dwTlsIndexLastError, (LPVOID) (DWORD_PTR) GetLastError());
};
+
+DWORD GetSavedLastError() {
+ return (DWORD) (DWORD_PTR) TlsGetValue(dwTlsIndexLastError);
+}
diff --git a/core/src/main/java/io/questdb/client/QuestDB.java b/core/src/main/java/io/questdb/client/QuestDB.java
index 1a9aacae..a4c7cb03 100644
--- a/core/src/main/java/io/questdb/client/QuestDB.java
+++ b/core/src/main/java/io/questdb/client/QuestDB.java
@@ -130,10 +130,17 @@ static QuestDB connect(CharSequence configurationString) {
Sender borrowSender();
/**
- * Shuts down the pools, closing every underlying {@link Sender} and
- * query client. Idempotent. Threads currently blocked in
- * {@link #borrowSender()} or {@link Query#submit()} are released with an
- * error.
+ * Shuts down the pools and their published clients. Idempotent. Threads
+ * currently blocked in {@link #borrowSender()} or {@link Query#submit()}
+ * are released with an error.
+ *
+ * In-progress client creation: close() waits up to the builder's
+ * {@link QuestDBBuilder#acquireTimeoutMillis(long) acquire timeout},
+ * hard-capped at 5 seconds, for a creation blocked in DNS, TCP, TLS, or a
+ * WebSocket handshake. If that budget expires, the creator retains cleanup
+ * ownership and closes its unpublished client (and releases any SF slot)
+ * when construction returns. This keeps close() bounded even when
+ * {@code connect_timeout} is unset without abandoning late resources.
*
* Outstanding leases: a borrowed {@link Sender} is never torn down
* underneath the thread using it. Instead, close() waits up to the
diff --git a/core/src/main/java/io/questdb/client/Sender.java b/core/src/main/java/io/questdb/client/Sender.java
index 4ec70dbd..e2891201 100644
--- a/core/src/main/java/io/questdb/client/Sender.java
+++ b/core/src/main/java/io/questdb/client/Sender.java
@@ -819,17 +819,22 @@ enum InitialConnectMode {
*
{@link #MEMORY} — never fsync explicitly. Bytes live in the OS
* page cache; survive a JVM crash but not an OS crash. Default
* and the lowest-latency setting.
- *
{@link #FLUSH} — fsync the active segment at every
- * {@code Sender.flush()} (and at the implicit close-flush). One
- * fsync per user flush, regardless of frame count.
- *
{@link #APPEND} — fsync after every individual frame append.
- * Strongest guarantee, slowest path; pay a disk fsync per row.
+ *
{@link #PERIODIC} — checkpoint published frames in the background
+ * at {@code sf_sync_interval_millis}. The configured interval is a
+ * target cadence; scheduler and storage latency add to the actual
+ * power-loss recovery window.
+ *
{@link #FLUSH} — reserved for a future synchronous
+ * {@code Sender.flush()} barrier; currently rejected by
+ * {@code build()}.
+ *
{@link #APPEND} — reserved for a future barrier after every frame
+ * append; currently rejected by {@code build()}.
*
*/
enum SfDurability {
MEMORY,
FLUSH,
- APPEND
+ APPEND,
+ PERIODIC
}
/**
@@ -972,6 +977,7 @@ final class LineSenderBuilder {
// syscall cost so smaller segments give finer trim granularity and
// make the cap arithmetic friendlier (cap / segment >> 2).
private static final long DEFAULT_SEGMENT_BYTES = 4L * 1024 * 1024;
+ private static final long DEFAULT_SF_SYNC_INTERVAL_MILLIS = 5_000L;
// Slot identity within sfDir. Each sender owns // and
// takes an advisory exclusive lock there. Default "default" is fine for
// single-sender deployments; multi-sender setups must set this explicitly
@@ -1119,12 +1125,12 @@ public int getConnectTimeout() {
// there is no separate on/off flag (presence of the directory is the switch).
// null sfDir → memory-only async ingest (same lock-free architecture, no disk).
private String sfDir;
- // Durability contract for SF append/flush. Today only MEMORY is
- // implemented; FLUSH and APPEND are deferred follow-ups (cursor needs
- // to learn fsync first).
+ // Durability contract for SF append/flush. FLUSH and APPEND remain
+ // deferred follow-ups; PERIODIC uses the segment manager.
private SfDurability sfDurability = SfDurability.MEMORY;
private long sfMaxBytes = PARAMETER_NOT_SET_EXPLICITLY;
private long sfMaxTotalBytes = PARAMETER_NOT_SET_EXPLICITLY;
+ private long sfSyncIntervalMillis = PARAMETER_NOT_SET_EXPLICITLY;
private boolean shouldDestroyPrivKey;
private boolean tlsEnabled;
private TlsValidationMode tlsValidationMode;
@@ -1443,9 +1449,9 @@ public Sender build() {
// Setting sfDir enables store-and-forward (mmap'd, recoverable
// across sender restarts); omitting it gives memory-only mode
- // (same lock-free architecture, no disk involvement). The
- // sf_durability != memory rejection lives in validateParameters
- // so it is reached by build() and by no-connect validation alike.
+ // (same lock-free architecture, no disk involvement).
+ // Durability-combination validation lives in validateParameters
+ // so build() and no-connect validation apply the same rules.
long actualSfMaxBytes = sfMaxBytes == PARAMETER_NOT_SET_EXPLICITLY
? DEFAULT_SEGMENT_BYTES
: sfMaxBytes;
@@ -1534,15 +1540,25 @@ public Sender build() {
"could not create sf_dir: " + sfDir + " rc=" + rc);
}
}
+ if (sfDurability == SfDurability.PERIODIC
+ && Files.fsyncParentDir(sfDir) != 0) {
+ throw new LineSenderException(
+ "could not sync parent directory for sf_dir: " + sfDir);
+ }
slotPath = sfDir + "/" + senderId;
}
long actualSfAppendDeadlineNanos =
sfAppendDeadlineMillis == PARAMETER_NOT_SET_EXPLICITLY
? CursorSendEngine.DEFAULT_APPEND_DEADLINE_NANOS
: sfAppendDeadlineMillis * 1_000_000L;
+ long actualSfSyncIntervalNanos = sfDurability == SfDurability.PERIODIC
+ ? (sfSyncIntervalMillis == PARAMETER_NOT_SET_EXPLICITLY
+ ? DEFAULT_SF_SYNC_INTERVAL_MILLIS : sfSyncIntervalMillis) * 1_000_000L
+ : 0L;
CursorSendEngine cursorEngine = new CursorSendEngine(
slotPath, actualSfMaxBytes,
- actualSfMaxTotalBytes, actualSfAppendDeadlineNanos);
+ actualSfMaxTotalBytes, actualSfAppendDeadlineNanos,
+ actualSfSyncIntervalNanos);
int actualErrorInboxCapacity = errorInboxCapacity != PARAMETER_NOT_SET_EXPLICITLY
? errorInboxCapacity
: io.questdb.client.cutlass.qwp.client.sf.cursor.SenderErrorDispatcher.DEFAULT_CAPACITY;
@@ -1623,7 +1639,8 @@ public Sender build() {
orphans,
maxBackgroundDrainers,
actualSfMaxBytes,
- actualSfMaxTotalBytes);
+ actualSfMaxTotalBytes,
+ actualSfSyncIntervalNanos);
}
}
return connected;
@@ -2701,20 +2718,18 @@ public LineSenderBuilder sfAppendDeadlineMillis(long millis) {
* directory is the on-switch — there is no separate
* enable/disable flag. SF is off iff {@code dir} was never set.
*
- * Every batch is persisted to disk before it leaves the wire and is
- * reclaimed as soon as the server acknowledges it. On restart the
- * sender replays only batches whose acknowledgement had not been
- * received before the previous sender shut down — typically the last
- * in-flight batches at close time. Acknowledged batches are not
- * replayed: their disk space is freed during normal operation by an
- * automatic per-frame trim that force-rotates the active segment
- * once every frame in it has been acknowledged.
+ * The sender publishes each batch into a memory-mapped segment before
+ * transmission and reclaims acknowledged segments in the background.
+ * The default {@link SfDurability#MEMORY} mode relies on OS page-cache
+ * writeback: it survives a producer-process restart but not guaranteed
+ * host power loss. {@link SfDurability#PERIODIC} adds checked background
+ * storage barriers at the configured target cadence.
*
- * Note that {@link io.questdb.client.cutlass.qwp.client.QwpWebSocketSender#close()}
- * under SF returns once data is on disk, not on server-ack, so a
- * sender closed immediately after a flush may still have unacked
- * batches in flight; those will be replayed by the next sender
- * against the same directory. WebSocket transport only.
+ * On restart, the sender replays frames after its durable acknowledgement
+ * watermark. An acknowledgement that reached the server but not that
+ * watermark can replay, so applications that require row-level
+ * idempotence should configure server-side deduplication.
+ * WebSocket transport only.
*
* The sender takes ownership of the underlying SF storage and closes
* it when the sender itself is closed.
@@ -2734,7 +2749,10 @@ public LineSenderBuilder storeAndForwardDir(String dir) {
/**
* Selects the durability contract for SF appends and flushes. See
- * {@link SfDurability} for the value semantics.
+ * {@link SfDurability} for the value semantics. The client currently
+ * supports {@link SfDurability#MEMORY} and
+ * {@link SfDurability#PERIODIC}; {@code build()} rejects the reserved
+ * {@code FLUSH} and {@code APPEND} values.
*
* Replaces the prior pair of independent {@code sf_fsync} and
* {@code sf_fsync_on_flush} booleans — they were three states
@@ -2788,6 +2806,22 @@ public LineSenderBuilder storeAndForwardMaxTotalBytes(long maxTotalBytes) {
return this;
}
+ /**
+ * Sets the target background checkpoint cadence for
+ * {@link SfDurability#PERIODIC}. Scheduler and storage latency add to
+ * the configured interval. Defaults to 5000 ms in periodic mode.
+ */
+ public LineSenderBuilder storeAndForwardSyncIntervalMillis(long millis) {
+ if (protocol != PARAMETER_NOT_SET_EXPLICITLY && protocol != PROTOCOL_WEBSOCKET) {
+ throw new LineSenderException("store_and_forward is only supported for WebSocket transport");
+ }
+ if (millis <= 0L || millis > Long.MAX_VALUE / 1_000_000L) {
+ throw new LineSenderException("sf_sync_interval_millis is out of range: ").put(millis);
+ }
+ this.sfSyncIntervalMillis = millis;
+ return this;
+ }
+
private static boolean charsEqualsRange(CharSequence a, CharSequence b, int bStart, int bEnd) {
int len = bEnd - bStart;
if (a.length() != len) {
@@ -2810,10 +2844,11 @@ private static int getValue(CharSequence configurationString, int pos, StringSin
private static SfDurability parseDurabilityValue(@NotNull StringSink value) {
if (Chars.equalsIgnoreCase("memory", value)) return SfDurability.MEMORY;
+ if (Chars.equalsIgnoreCase("periodic", value)) return SfDurability.PERIODIC;
if (Chars.equalsIgnoreCase("flush", value)) return SfDurability.FLUSH;
if (Chars.equalsIgnoreCase("append", value)) return SfDurability.APPEND;
throw new LineSenderException("invalid sf_durability [value=").put(value)
- .put(", allowed-values=[memory, flush, append]]");
+ .put(", allowed-values=[memory, periodic, flush, append]]");
}
private static int parseIntValue(@NotNull StringSink value, @NotNull String name) {
@@ -3370,6 +3405,12 @@ private LineSenderBuilder fromConfig(CharSequence configurationString) {
}
pos = getValue(configurationString, pos, sink, "sf_durability");
storeAndForwardDurability(parseDurabilityValue(sink));
+ } else if (Chars.equals("sf_sync_interval_millis", sink)) {
+ if (protocol != PROTOCOL_WEBSOCKET) {
+ throw new LineSenderException("sf_sync_interval_millis is only supported for WebSocket transport");
+ }
+ pos = getValue(configurationString, pos, sink, "sf_sync_interval_millis");
+ storeAndForwardSyncIntervalMillis(parseLongValue(sink, "sf_sync_interval_millis"));
} else if (Chars.equals("close_flush_timeout_millis", sink)) {
if (protocol != PROTOCOL_WEBSOCKET) {
throw new LineSenderException("close_flush_timeout_millis is only supported for WebSocket transport");
@@ -3695,6 +3736,9 @@ private LineSenderBuilder fromConfigWebSocket(CharSequence configurationString)
if (view.has("sf_max_total_bytes")) {
storeAndForwardMaxTotalBytes(wsSize(view, v, "sf_max_total_bytes"));
}
+ if (view.has("sf_sync_interval_millis")) {
+ storeAndForwardSyncIntervalMillis(wsLong(view, v, "sf_sync_interval_millis"));
+ }
s = view.getStr("sf_dir");
if (s != null) {
@@ -3852,6 +3896,7 @@ public java.util.Map wsConfigSnapshotForTest() {
m.put("sf_max_total_bytes", sfMaxTotalBytes);
m.put("sf_durability", sfDurability == null ? null : sfDurability.name());
m.put("sf_append_deadline_millis", sfAppendDeadlineMillis);
+ m.put("sf_sync_interval_millis", sfSyncIntervalMillis);
m.put("close_flush_timeout_millis", closeFlushTimeoutMillis);
m.put("durable_ack_keepalive_interval_millis", durableAckKeepaliveIntervalMillis);
m.put("initial_connect_retry", initialConnectMode == null ? null : initialConnectMode.name());
@@ -4057,14 +4102,18 @@ private void validateParameters() {
if (autoFlushIntervalMillis == Integer.MAX_VALUE) {
throw new LineSenderException("disabling auto-flush is not supported for WebSocket protocol");
}
- // The cursor send path does not fsync yet, so any sf_durability
- // other than memory is rejected rather than silently downgraded.
- // Validating it here (rather than at connect time) lets a
- // no-connect config check reject it as a full build() does.
- if (sfDurability != SfDurability.MEMORY) {
+ if ((sfDurability == SfDurability.FLUSH || sfDurability == SfDurability.APPEND)) {
throw new LineSenderException(
"sf_durability=" + sfDurability.name().toLowerCase()
- + " is not yet supported (deferred follow-up; use sf_durability=memory)");
+ + " is not yet supported (use sf_durability=memory or periodic)");
+ }
+ if (sfDurability == SfDurability.PERIODIC && sfDir == null) {
+ throw new LineSenderException("sf_durability=periodic requires sf_dir");
+ }
+ if (sfSyncIntervalMillis != PARAMETER_NOT_SET_EXPLICITLY
+ && sfDurability != SfDurability.PERIODIC) {
+ throw new LineSenderException(
+ "sf_sync_interval_millis requires sf_durability=periodic");
}
} else {
throw new LineSenderException("unsupported protocol ")
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 fd16dca7..94018fff 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
@@ -248,6 +248,15 @@ public void close() {
}
}
+ /**
+ * Shuts down socket traffic without releasing the descriptor or freeing
+ * buffers that a concurrent I/O worker may still access. The owner must
+ * call {@link #close()} after joining the worker to complete cleanup.
+ */
+ public void closeTraffic() {
+ socket.closeTraffic();
+ }
+
/**
* Connects to a WebSocket server.
*
diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpQueryClient.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpQueryClient.java
index b63b4fa9..f6587631 100644
--- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpQueryClient.java
+++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpQueryClient.java
@@ -165,6 +165,10 @@ public class QwpQueryClient implements QuietCloseable {
private final Random failoverRandom = new Random();
private long authTimeoutMs = DEFAULT_AUTH_TIMEOUT_MS;
private String authorizationHeader;
+ // Deterministic lifecycle barrier used by facade shutdown tests. Null in
+ // production; close() clears and invokes it after winning close ownership
+ // without allowing hook failures to prevent resource teardown.
+ private volatile Runnable beforeCloseHook;
// Upper bound (ms) on each TCP connect attempt. 0 (default) falls back to
// the OS connect timeout.
private int connectTimeoutMs = 0;
@@ -620,6 +624,16 @@ public void close() {
// scratch, double-freeing it.
return;
}
+ Runnable hook = beforeCloseHook;
+ beforeCloseHook = null;
+ if (hook != null) {
+ try {
+ hook.run();
+ } catch (Throwable ignored) {
+ // Omit diagnostics for this test-only hook: even rendering its
+ // failure must not prevent production resource cleanup.
+ }
+ }
connected = false;
lastCloseTimedOut = false;
try {
@@ -1004,6 +1018,11 @@ public void seedFailoverRandomForTest(long seed) {
}
}
+ @TestOnly
+ public void setBeforeCloseHookForTest(Runnable hook) {
+ beforeCloseHook = hook;
+ }
+
/**
* Returns true if the most recent {@link #close()} call abandoned the I/O thread
* because it failed to exit within the join timeout. The native buffer pool and
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 d1744065..5a813c83 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
@@ -39,6 +39,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.BackgroundDrainer;
import io.questdb.client.cutlass.qwp.client.sf.cursor.BackgroundDrainerListener;
import io.questdb.client.cutlass.qwp.client.sf.cursor.BackgroundDrainerPool;
import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorSendEngine;
@@ -189,11 +190,19 @@ public class QwpWebSocketSender implements Sender {
// Null in production; set reflectively by tests.
@TestOnly
private volatile java.util.function.Supplier clientFactoryOverride;
+ // Test-only lifecycle witness. drainOnClose() invokes and clears it after
+ // confirming a real unacknowledged close target, immediately before waiting.
+ private volatile Runnable closeDrainWaitingHook;
// close() drain timeout in millis. Default applied at construction.
// 0 or -1 means "fast close" (skip the drain); otherwise close blocks
// up to this many millis for ackedFsn to catch up to publishedFsn.
+ private volatile boolean closeCleanupComplete;
+ private boolean closeCleanupStarted;
private long closeFlushTimeoutMillis = 5_000L;
private volatile boolean closed;
+ // Test-only lifecycle witness. close() invokes and clears it strictly after
+ // publishing closed=true and before starting any drain or teardown work.
+ private volatile Runnable closeStartedHook;
private boolean connected;
private SenderConnectionDispatcher connectionDispatcher;
// Async-delivery sink for SenderConnectionEvent notifications. Default
@@ -278,11 +287,23 @@ public class QwpWebSocketSender implements Sender {
private boolean ownsCursorEngine;
private long pendingBytes;
// Set true by close() once the SF slot flock has been released (the normal
- // teardown path). Stays false if close() bailed early with the I/O thread
- // still running -- then cursorEngine.close() never ran and the flock is
- // still held, so the owning pool MUST keep the slot reserved rather than
- // hand the still-locked dir to the next borrow ("sf slot already in use").
- private boolean slotLockReleased;
+ // teardown path). Stays false if an I/O or manager worker did not stop and
+ // cursorEngine retained the flock, so the owning pool MUST keep the slot
+ // reserved rather than hand the still-locked dir to the next borrow
+ // ("sf slot already in use"). May flip to true LATER via the getter's
+ // re-probe of retainedEngine, once the deferred cleanup (manager-worker
+ // exit path or delegated I/O-thread close) releases the flock — pools
+ // re-probe retired slots to recover their capacity. volatile: written on
+ // the closing thread, read by pool threads.
+ private volatile boolean slotLockReleased;
+ // Optional owning-pool notification, relayed after the engine confirms the
+ // flock release and slotLockReleased is visible to pool re-probes.
+ private volatile Runnable slotLockReleaseListener;
+ // Engine whose close() could not complete during sender close() — its
+ // cleanup is pending on a worker/I/O-thread exit path. isSlotLockReleased()
+ // re-probes it so a late flock release becomes visible to the owning pool.
+ // Only ever set inside close(); null for a sender that closed cleanly.
+ private volatile CursorSendEngine retainedEngine;
private int pendingRowCount;
private SenderProgressDispatcher progressDispatcher;
// Async-delivery sink for ack-watermark advances. Default no-op; a
@@ -847,9 +868,12 @@ public boolean awaitAckedFsn(long targetFsn, long timeoutMillis) {
if (cursorEngine == null) {
return targetFsn < 0L;
}
- // Surface latched I/O errors before any early-return path, so a
- // caller polling with timeoutMillis <= 0 to drive their own loop
- // sees the terminal throw instead of an indefinite "not yet".
+ cursorEngine.checkDurability();
+ // Surface latched errors before any early-return path, so a caller
+ // polling with timeoutMillis <= 0 to drive their own loop sees the
+ // throw instead of an indefinite "not yet". The durability latch
+ // above is transient: it throws while latched, and clears once a
+ // later periodic sync pass fully succeeds so producers can resume.
if (cursorSendLoop != null) {
cursorSendLoop.checkError();
}
@@ -862,6 +886,7 @@ public boolean awaitAckedFsn(long targetFsn, long timeoutMillis) {
}
long deadlineNanos = System.nanoTime() + timeoutMillis * 1_000_000L;
while (cursorEngine.ackedFsn() < targetFsn) {
+ cursorEngine.checkDurability();
if (cursorSendLoop != null) {
cursorSendLoop.checkError();
}
@@ -1038,13 +1063,26 @@ public QwpWebSocketSender charColumn(CharSequence columnName, char value) {
* replaying frames get a 2.5s grace window plus a 0.5s stop window
* — worst case ~3s when a drainer sits in a blocking native
* connect (15s background deadline) and must be abandoned to exit
- * on its own.
+ * on its own;
+ *
SF manager stop: normally immediate, bounded by 5s when its worker
+ * is stuck in a filesystem operation. On timeout the slot remains
+ * locked rather than being exposed to a stale worker.
*
*/
@Override
public void close() {
if (!closed) {
closed = true;
+ Runnable hook = closeStartedHook;
+ closeStartedHook = null;
+ if (hook != null) {
+ try {
+ hook.run();
+ } catch (Throwable t) {
+ // A test witness must never prevent production resource cleanup.
+ LOG.error("Error in close-started test hook: {}", String.valueOf(t));
+ }
+ }
boolean ioThreadStopped = true;
// Captures the first error from the flush/drain path AND any
// secondary errors from cleanup steps (added via addSuppressed).
@@ -1175,106 +1213,26 @@ public void close() {
}
if (!ioThreadStopped) {
- // The I/O thread may still be using the socket and microbatch
- // buffers. Freeing them would risk SIGSEGV.
- LOG.error("I/O thread is still running, leaking WebSocket client and microbatch buffers");
- // The engine, however, need not leak: delegate its close to
- // the I/O thread's exit path, which runs it strictly after
- // the thread's last engine access — the mapping and slot
- // lock release as soon as the stuck wire call resolves
- // (bounded by OS timeouts). slotLockReleased intentionally
- // stays false: the lock is released only when the delegated
- // close actually runs, so the pool must not reuse the slot
- // meanwhile. A false return means the thread exited between
- // the failed close() and now — then closing here is safe.
- if (ownsCursorEngine && cursorEngine != null && cursorSendLoop != null
- && !cursorSendLoop.delegateEngineClose()) {
- try {
- cursorEngine.close();
- } catch (Throwable t) {
- LOG.error("Error closing owned CursorSendEngine: {}", String.valueOf(t));
- terminalError = captureCloseError(terminalError, t);
- }
- cursorEngine = null;
- ownsCursorEngine = false;
- slotLockReleased = true;
- }
- rethrowTerminal(terminalError);
- return;
- }
-
- if (buffer0 != null) {
- try {
- buffer0.close();
- } catch (Throwable t) {
- LOG.error("Error closing buffer0: {}", String.valueOf(t));
- terminalError = captureCloseError(terminalError, t);
- }
- }
- if (buffer1 != null) {
- try {
- buffer1.close();
- } catch (Throwable t) {
- LOG.error("Error closing buffer1: {}", String.valueOf(t));
- terminalError = captureCloseError(terminalError, t);
- }
- }
-
- if (client != null) {
- try {
- client.close();
- } catch (Throwable t) {
- LOG.error("Error closing WebSocket client: {}", String.valueOf(t));
- terminalError = captureCloseError(terminalError, t);
- }
- client = null;
- }
-
- if (ownsCursorEngine && cursorEngine != null) {
- try {
- cursorEngine.close();
- } catch (Throwable t) {
- LOG.error("Error closing owned CursorSendEngine: {}", String.valueOf(t));
- terminalError = captureCloseError(terminalError, t);
- }
- cursorEngine = null;
- ownsCursorEngine = false;
- }
- // Past the ioThreadStopped guard => cursorEngine.close() ran and
- // released the SF flock in its finally (or this sender owned no
- // engine holding one). Signal the pool it may reuse the slot.
- slotLockReleased = true;
-
- // Shutdown order: dispatcher last, after the I/O loop has stopped
- // producing into it. close() drains pending entries with a short
- // deadline so any final errors land in the user's handler.
- if (errorDispatcher != null) {
- try {
- errorDispatcher.close();
- } catch (Throwable t) {
- LOG.error("Error closing error dispatcher: {}", String.valueOf(t));
- terminalError = captureCloseError(terminalError, t);
- }
- }
- if (progressDispatcher != null) {
- try {
- progressDispatcher.close();
- } catch (Throwable t) {
- LOG.error("Error closing progress dispatcher: {}", String.valueOf(t));
- terminalError = captureCloseError(terminalError, t);
+ // The worker may still touch every resource below. Hand the
+ // complete sender-owned tail to its exit path rather than
+ // permanently leaking everything except the engine. The
+ // callback is idempotence-gated by closeRemainingResources().
+ if (ownsCursorEngine && cursorEngine != null) {
+ retainedEngine = cursorEngine;
}
- }
- if (connectionDispatcher != null) {
- try {
- connectionDispatcher.close();
- } catch (Throwable t) {
- LOG.error("Error closing connection dispatcher: {}", String.valueOf(t));
- terminalError = captureCloseError(terminalError, t);
+ Runnable closeCallback = () -> closeRemainingResources(null);
+ if (cursorSendLoop != null && cursorSendLoop.delegateClose(closeCallback)) {
+ rethrowTerminal(terminalError);
+ return;
}
+ // The worker exited between close() failing and delegation.
+ // Cleanup is safe here and its failures remain suppressed on
+ // the original close error.
+ terminalError = closeRemainingResources(terminalError);
+ } else {
+ terminalError = closeRemainingResources(terminalError);
}
- LOG.info("QwpWebSocketSender closed");
-
// If close() ended up holding the same instance the user already
// caught earlier, suppress the rethrow. The user's catch block
// wraps close() (try-with-resources), and Throwable refuses
@@ -1286,14 +1244,62 @@ public void close() {
}
}
+ @TestOnly
+ public boolean isCloseCleanupComplete() {
+ return closeCleanupComplete;
+ }
+
/**
- * True once {@link #close()} has released the store-and-forward slot
- * flock. False means close() leaked the still-running I/O thread (and its
- * resources), so the flock is still held; the owning pool must keep the
- * slot index reserved instead of reusing the still-locked slot dir.
+ * True once the store-and-forward slot flock has been released. False
+ * means an I/O or manager worker did not stop and close() retained the
+ * lock and worker-reachable resources; the owning pool must keep the slot
+ * index reserved instead of reusing the still-locked dir.
+ *
+ * Not a one-shot snapshot: when close() left engine cleanup pending on a
+ * manager-worker quiescence or I/O-thread exit path, this re-probes the
+ * retained engine and latches true the moment that cleanup completes — pools re-probe retired
+ * slots through this getter to recover their capacity. Monotonic:
+ * false→true only, never back. Cheap (volatile reads on every common
+ * path) so pools may call it under their capacity lock; only the rare
+ * orphaned-retry state below does more.
+ *
+ * The probe is also the recovery surface for a retained engine whose
+ * flock-release retry fell off the shared driver because the driver
+ * thread could not start (e.g. OOM at thread creation): close() is
+ * one-shot, so without the re-arm below that slot's capacity would stay
+ * lost until process exit.
*/
public boolean isSlotLockReleased() {
- return slotLockReleased;
+ if (slotLockReleased) {
+ return true;
+ }
+ CursorSendEngine engine = retainedEngine;
+ if (engine != null) {
+ if (engine.isCloseCompleted()) {
+ // Benign latch race: concurrent callers may both observe the
+ // completed cleanup and both write true.
+ slotLockReleased = true;
+ return true;
+ }
+ engine.ensureFlockReleaseRetryScheduled();
+ }
+ return false;
+ }
+
+ /**
+ * Registers a callback for confirmed SF slot-lock release. Pools use this
+ * to wake borrowers without waiting for a timeout or housekeeper tick.
+ */
+ public void setSlotLockReleaseListener(Runnable listener) {
+ slotLockReleaseListener = listener;
+ if (listener != null && slotLockReleased) {
+ listener.run();
+ }
+ }
+
+ @TestOnly
+ public void setSlotLockReleasedForTesting(boolean isReleased) {
+ slotLockReleased = isReleased;
}
@Override
@@ -1518,15 +1524,19 @@ public void flush() {
@Override
public long flushAndGetSequence() {
checkNotClosed();
+ if (cursorEngine != null) {
+ cursorEngine.checkDurability();
+ }
ensureNoInProgressRow();
ensureConnected();
long beforeFsn = cursorEngine != null ? cursorEngine.publishedFsn() : -1L;
- // Cursor SF: SF.append happens on the user thread inside
- // sealAndSwapBuffer, so by the time we reach here every encoded
- // batch is durable on its mmap'd segment. No processingCount to
- // drain, no awaitPendingAcks. Just surface any I/O thread error.
+ // Cursor SF: append happens on the user thread inside
+ // sealAndSwapBuffer, so by the time we reach here every encoded batch
+ // is published in its mmap'd segment. PERIODIC stable-storage barriers
+ // run independently in the manager. No processingCount to drain and no
+ // awaitPendingAcks here; just surface any I/O thread error.
flushPendingRows(deferCommit);
if (!deferCommit && hasDeferredMessages) {
sendCommitMessage();
@@ -1709,6 +1719,36 @@ public long getDroppedConnectionNotifications() {
return d == null ? 0L : d.getDroppedNotifications();
}
+ @TestOnly
+ public SenderConnectionDispatcher getConnectionDispatcherForTesting() {
+ return connectionDispatcher;
+ }
+
+ @TestOnly
+ public CursorSendEngine getCursorEngineForTesting() {
+ return cursorEngine;
+ }
+
+ @TestOnly
+ public SenderErrorDispatcher getErrorDispatcherForTesting() {
+ return errorDispatcher;
+ }
+
+ @TestOnly
+ public Sender.InitialConnectMode getInitialConnectModeForTesting() {
+ return initialConnectMode;
+ }
+
+ @TestOnly
+ public SenderProgressDispatcher getProgressDispatcherForTesting() {
+ return progressDispatcher;
+ }
+
+ @TestOnly
+ public Runnable getSlotLockReleaseListenerForTesting() {
+ return slotLockReleaseListener;
+ }
+
/**
* Number of {@link SenderError} notifications dropped because the
* bounded inbox was full. Non-zero means the user-supplied
@@ -2135,6 +2175,36 @@ public void setClientFactoryOverride(java.util.function.Supplier live =
+ ObjList live =
pool.snapshot();
for (int i = 0, n = live.size(); i < n; i++) {
live.getQuick(i).setListener(listener);
@@ -2334,6 +2431,25 @@ public synchronized void startOrphanDrainers(
int maxBackgroundDrainers,
long segmentSizeBytes,
long sfMaxTotalBytes
+ ) {
+ startOrphanDrainers(
+ orphanSlotPaths,
+ maxBackgroundDrainers,
+ segmentSizeBytes,
+ sfMaxTotalBytes,
+ 0L);
+ }
+
+ /**
+ * Starts orphan drainers while preserving the foreground sender's periodic
+ * store-and-forward checkpoint interval.
+ */
+ public synchronized void startOrphanDrainers(
+ io.questdb.client.std.ObjList orphanSlotPaths,
+ int maxBackgroundDrainers,
+ long segmentSizeBytes,
+ long sfMaxTotalBytes,
+ long syncIntervalNanos
) {
if (orphanSlotPaths == null || orphanSlotPaths.size() == 0
|| maxBackgroundDrainers <= 0) {
@@ -2360,17 +2476,18 @@ public synchronized void startOrphanDrainers(
// (the factory needs the drainer, the drainer's constructor
// needs the factory); the ref write happens-before the drainer
// runs because submit() publishes the task afterwards.
- final io.questdb.client.cutlass.qwp.client.sf.cursor.BackgroundDrainer[] ref =
- new io.questdb.client.cutlass.qwp.client.sf.cursor.BackgroundDrainer[1];
+ final BackgroundDrainer[] ref =
+ new BackgroundDrainer[1];
ReconnectSupplier factory = new ReconnectSupplier(
() -> {
- io.questdb.client.cutlass.qwp.client.sf.cursor.BackgroundDrainer d = ref[0];
+ BackgroundDrainer d = ref[0];
return d != null && d.isStopRequested();
},
"drainer stop requested during connect");
- io.questdb.client.cutlass.qwp.client.sf.cursor.BackgroundDrainer drainer =
- new io.questdb.client.cutlass.qwp.client.sf.cursor.BackgroundDrainer(
+ BackgroundDrainer drainer =
+ new BackgroundDrainer(
slot, segmentSizeBytes, sfMaxTotalBytes,
+ syncIntervalNanos,
factory,
reconnectMaxDurationMillis,
reconnectInitialBackoffMillis,
@@ -2705,22 +2822,37 @@ private WebSocketClient newWebSocketClient() {
* reconnect and {@code close()} paths are therefore never queued
* behind a drainer's endpoint walk.
*/
- private WebSocketClient buildAndConnect(ReconnectSupplier ctx) {
+ private WebSocketClient buildAndConnect(ReconnectSupplier ctx, CursorWebSocketSendLoop.ConnectCancellation cancellation) {
if (ctx.isBackground()) {
// Lock-free: the walk below touches only internally-synchronized
// hostTracker health state and walk-local/cursor-local state on
// the background path.
- return connectWalk(ctx);
+ return connectWalk(ctx, cancellation);
}
connectWalkLock.lock();
try {
- return connectWalk(ctx);
+ return connectWalk(ctx, cancellation);
} finally {
connectWalkLock.unlock();
}
}
- private WebSocketClient connectWalk(ReconnectSupplier ctx) {
+ // Drop the in-flight connect handle once the walk has disposed a client on
+ // a connect/upgrade FAILURE, so inFlight never dangles at a disposed client
+ // across the inter-attempt backoff. Without this a concurrent
+ // close()->cancel() could closeTraffic() a client the walk no longer owns;
+ // proven a no-op on both production transports today (closed-fd closeTraffic
+ // no-ops), but a future custom transport that threw on a closed socket would
+ // spuriously loud-fail close(). Null-guarded: the no-arg reconnect() path
+ // and the Unsafe.allocateInstance bare-loop tests pass a null handle. Pairs
+ // with the success-path clear() after upgrade().
+ private static void clearInFlight(CursorWebSocketSendLoop.ConnectCancellation cancellation) {
+ if (cancellation != null) {
+ cancellation.clear();
+ }
+ }
+
+ private WebSocketClient connectWalk(ReconnectSupplier ctx, CursorWebSocketSendLoop.ConnectCancellation cancellation) {
// Background (drainer) factories share this connect walk -- endpoint
// list and hostTracker HEALTH state (never the shared round: a
// background sweep walks its own RoundCursor and records with
@@ -2808,9 +2940,34 @@ private WebSocketClient connectWalk(ReconnectSupplier ctx) {
newClient.setQwpClientId(QwpConstants.CLIENT_ID);
newClient.setQwpRequestDurableAck(requestDurableAck);
newClient.setConnectTimeout(effectiveConnectTimeoutMs(background, connectTimeoutMs));
+ if (cancellation != null) {
+ // Publish the client we are about to block on so a
+ // concurrent CursorWebSocketSendLoop.close() can break its
+ // traffic and unwind a black-holed native connect
+ // (connect_timeout=0 => OS SYN-retry) instead of hanging on
+ // the untimed shutdown-latch await. The publish-then-check
+ // handshake pairs with ConnectCancellation.cancel(): if we
+ // observe cancellation here we skip the blocking connect
+ // entirely; otherwise cancel() observed this client and
+ // breaks it. The walk's per-attempt catch disposes the
+ // client and, since running has flipped false, the
+ // top-of-loop ctx.isAborted() gate ends the walk.
+ cancellation.publish(newClient);
+ if (cancellation.isCancelled()) {
+ throw new LineSenderException(ctx.abortMessage());
+ }
+ }
newClient.connect(ep.host, ep.port);
int upgradeTimeoutMs = (int) Math.min(authTimeoutMs, Integer.MAX_VALUE);
newClient.upgrade(WRITE_PATH, upgradeTimeoutMs, authorizationHeader);
+ if (cancellation != null) {
+ // connect()+upgrade() completed: this client is no longer
+ // blocking, so drop it from the in-flight handle before it
+ // becomes the loop's `client` field. close() must then break
+ // its traffic via the field path exactly once -- leaving it
+ // in the handle too would double-shut-down the socket.
+ cancellation.clear();
+ }
} catch (HttpClientException e) {
// Close BEFORE classify: the sibling catch (Error) below does not
// guard catch-arm bodies, so an Error thrown inside classify()
@@ -2820,6 +2977,7 @@ private WebSocketClient connectWalk(ReconnectSupplier ctx) {
// upgradeStatusCode) that are set during upgrade() and survive
// close().
newClient.close();
+ clearInFlight(cancellation);
HttpClientException classified = QwpUpgradeFailures.classify(newClient, ep.host, ep.port, e);
if (classified instanceof QwpIngressRoleRejectedException) {
QwpIngressRoleRejectedException re = (QwpIngressRoleRejectedException) classified;
@@ -2866,6 +3024,7 @@ private WebSocketClient connectWalk(ReconnectSupplier ctx) {
continue;
} catch (Exception e) {
newClient.close();
+ clearInFlight(cancellation);
hostTracker.recordTransportError(idx, !background);
lastError = e;
if (!background) {
@@ -2888,6 +3047,7 @@ private WebSocketClient connectWalk(ReconnectSupplier ctx) {
// the cursor reconnect loop, BackgroundDrainer) rethrows Error
// rather than retrying, so this stays a loud one-shot failure.
closeQuietlyOnError(newClient);
+ clearInFlight(cancellation);
throw e;
}
// Guard the post-upgrade tail: from here until newClient is
@@ -3084,6 +3244,83 @@ private void checkTableSelected() {
}
}
+ private synchronized Throwable closeRemainingResources(Throwable terminalError) {
+ if (closeCleanupStarted) {
+ return terminalError;
+ }
+ closeCleanupStarted = true;
+ try {
+ try {
+ buffer0.close();
+ } catch (Throwable t) {
+ LOG.error("Error closing buffer0: {}", String.valueOf(t));
+ terminalError = captureCloseError(terminalError, t);
+ }
+ try {
+ buffer1.close();
+ } catch (Throwable t) {
+ LOG.error("Error closing buffer1: {}", String.valueOf(t));
+ terminalError = captureCloseError(terminalError, t);
+ }
+ if (client != null) {
+ try {
+ client.close();
+ } catch (Throwable t) {
+ LOG.error("Error closing WebSocket client: {}", String.valueOf(t));
+ terminalError = captureCloseError(terminalError, t);
+ }
+ client = null;
+ }
+ if (ownsCursorEngine && cursorEngine != null) {
+ CursorSendEngine engine = cursorEngine;
+ try {
+ engine.close();
+ } catch (Throwable t) {
+ LOG.error("Error closing owned CursorSendEngine: {}", String.valueOf(t));
+ terminalError = captureCloseError(terminalError, t);
+ }
+ if (engine.isCloseCompleted()) {
+ cursorEngine = null;
+ ownsCursorEngine = false;
+ slotLockReleased = true;
+ } else {
+ slotLockReleased = false;
+ retainedEngine = engine;
+ }
+ } else {
+ slotLockReleased = true;
+ }
+ if (errorDispatcher != null) {
+ try {
+ errorDispatcher.close();
+ } catch (Throwable t) {
+ LOG.error("Error closing error dispatcher: {}", String.valueOf(t));
+ terminalError = captureCloseError(terminalError, t);
+ }
+ }
+ if (progressDispatcher != null) {
+ try {
+ progressDispatcher.close();
+ } catch (Throwable t) {
+ LOG.error("Error closing progress dispatcher: {}", String.valueOf(t));
+ terminalError = captureCloseError(terminalError, t);
+ }
+ }
+ if (connectionDispatcher != null) {
+ try {
+ connectionDispatcher.close();
+ } catch (Throwable t) {
+ LOG.error("Error closing connection dispatcher: {}", String.valueOf(t));
+ terminalError = captureCloseError(terminalError, t);
+ }
+ }
+ LOG.info("QwpWebSocketSender closed");
+ return terminalError;
+ } finally {
+ closeCleanupComplete = true;
+ }
+ }
+
private int countNonEmptyTables(ObjList keys) {
int tableCount = 0;
for (int i = 0, n = keys.size(); i < n; i++) {
@@ -3185,6 +3422,16 @@ private void drainOnClose(boolean errorOwnedByCustomHandler) {
if (cursorEngine.ackedFsn() >= target) {
return;
}
+ Runnable hook = closeDrainWaitingHook;
+ closeDrainWaitingHook = null;
+ if (hook != null) {
+ try {
+ hook.run();
+ } catch (Throwable t) {
+ // A test witness must never prevent production resource cleanup.
+ LOG.error("Error in close-drain-waiting test hook: {}", String.valueOf(t));
+ }
+ }
long deadlineNanos = System.nanoTime() + closeFlushTimeoutMillis * 1_000_000L;
while (cursorEngine.ackedFsn() < target) {
// Stop on a latched terminal (acks will never reach target);
@@ -3545,6 +3792,14 @@ private void flushPendingRowsSplit(ObjList keys, boolean deferComm
resetTableBuffersAfterFlush(keys);
}
+ private void onSlotLockReleased() {
+ slotLockReleased = true;
+ Runnable listener = slotLockReleaseListener;
+ if (listener != null) {
+ listener.run();
+ }
+ }
+
private void resetTableBuffersAfterFlush(ObjList keys) {
for (int i = 0, n = keys.size(); i < n; i++) {
CharSequence tableName = keys.getQuick(i);
@@ -3850,7 +4105,12 @@ boolean isAborted() {
@Override
public WebSocketClient reconnect() {
- return buildAndConnect(this);
+ return buildAndConnect(this, null);
+ }
+
+ @Override
+ public WebSocketClient reconnect(CursorWebSocketSendLoop.ConnectCancellation cancellation) {
+ return buildAndConnect(this, cancellation);
}
}
}
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 469c91ea..b11ae561 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
@@ -24,10 +24,13 @@
package io.questdb.client.cutlass.qwp.client.sf.cursor;
+import io.questdb.client.std.Crc32c;
import io.questdb.client.std.Files;
+import io.questdb.client.std.FilesFacade;
import io.questdb.client.std.MemoryTag;
import io.questdb.client.std.QuietCloseable;
import io.questdb.client.std.Unsafe;
+import org.jetbrains.annotations.TestOnly;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -42,191 +45,267 @@
* "everything <= N is durable"), so a single monotonic watermark suffices;
* no per-frame bitmap is needed.
*
- * Layout (16 bytes, little-endian, mmap'd for the engine's lifetime):
+ * Layout (8192 bytes, little-endian, mmap'd for the engine's lifetime):
+ * two independently CRC-protected 64-byte records at offsets 0 and 4096.
+ * Placing each record at the start of a separate 4 KiB slot prevents one
+ * aligned 512-byte or 4 KiB sector tear from damaging both. Each record contains:
*
+ * {@link #write(long)} rewrites the record not selected by the current
+ * generation and stores its CRC last. Recovery selects the valid record with
+ * the greatest generation. A torn update therefore falls back to the older
+ * valid record; if neither record validates, recovery conservatively uses the
+ * segment-derived seed.
*
- * Zero-alloc, zero-syscall writes: {@link #open(String)} opens the
- * file and maps the 16 bytes once. {@link #write(long)} is a single
- * 8-byte aligned {@code Unsafe.putLong} into the mapped region. No
- * malloc/free, no read/write syscalls, no rename, on the manager's hot
- * tick path. An 8-byte aligned store is hardware-atomic on x86_64 and
- * arm64, and disk-atomic within one sector (the file is 16 bytes —
- * trivially within one sector), so a torn FSN across a crash boundary
- * is not a concern.
+ * 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.
*
- * Why no CRC: the watermark is an optimization. If the on-disk
- * value is somehow corrupted (filesystem bug, hardware fault), the
- * recovery path's {@code max(lowestBase - 1, watermark)} clamp absorbs
- * the inconsistency: a stale-low watermark just means more re-replay, a
- * stale-high watermark is impossible because no write produces an FSN
- * higher than the segments on disk could account for. CRC adds
- * complexity (multi-store with fence + read-side validate) for
- * marginal additional safety.
+ * 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: intentionally NOT performed. Host crash falls
- * back to recovery's {@code lowestBase - 1} seed (same as before this
- * feature, no regression), at the cost of a bounded re-replay window
- * for whatever durable-acks landed since the last successful page
- * cache flush.
- *
- * 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 {@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.
*/
public final class AckWatermark implements QuietCloseable {
/**
- * Filename of the watermark within the slot directory.
- * Dot-prefixed so directory enumerators that filter by extension
- * (segment recovery, OrphanScanner) skip it automatically.
+ * Filename of the watermark within the slot directory. Dot-prefixed so
+ * directory enumerators that filter by extension skip it automatically.
*/
public static final String FILE_NAME = ".ack-watermark";
- public static final int FILE_SIZE = 16;
+ public static final int FILE_SIZE = 8 * 1024;
/**
- * Sentinel returned by {@link #read()} when the watermark file is
- * present but has not yet been written to (the magic field is zero
- * because the OS zero-filled the freshly created file).
+ * Sentinel returned by {@link #read()} when neither watermark record is
+ * valid.
*/
public static final long INVALID = Long.MIN_VALUE;
static final int FILE_MAGIC = 0x31574B41; // 'AKW1' little-endian
- private static final int FSN_OFFSET = 8;
+ private static final int CRC_OFFSET = 60;
+ private static final int FSN_OFFSET = 16;
private static final Logger LOG = LoggerFactory.getLogger(AckWatermark.class);
private static final int MAGIC_OFFSET = 0;
+ private static final int RECORD_SIZE = 64;
+ private static final int RECORD_SLOT_SIZE = 4 * 1024;
+ private static final int VERSION = 1;
private final int fd;
+ private final FilesFacade filesFacade;
private final long mmapAddress;
private boolean closed;
- // Stamped once per process either at open() (if the file already
- // has the magic from a prior session) or on the first write() that
- // observes it unset. After the flag flips, write() degenerates to
- // a single 8-byte putLong against the mapped FSN slot -- no memory
- // load of magic on the hot path. Manager-thread-only after
- // construction; no synchronisation needed.
- private boolean magicWritten;
-
- private AckWatermark(int fd, long mmapAddress, boolean magicAlreadyWritten) {
+ private long fsn;
+ private long generation;
+ private boolean isStorageReleased;
+
+ private AckWatermark(FilesFacade filesFacade, int fd, long mmapAddress,
+ long generation, long fsn) {
this.fd = fd;
+ this.filesFacade = filesFacade;
+ this.fsn = fsn;
+ this.generation = generation;
this.mmapAddress = mmapAddress;
- this.magicWritten = magicAlreadyWritten;
}
@Override
public void close() {
if (closed) return;
closed = true;
- if (mmapAddress != 0L && mmapAddress != Files.FAILED_MMAP_ADDRESS) {
- Files.munmap(mmapAddress, FILE_SIZE, MemoryTag.MMAP_DEFAULT);
- }
- if (fd >= 0) {
- Files.close(fd);
- }
+ releaseStorage();
}
/**
- * Opens (creating if absent) the watermark file in {@code slotDir}
- * and maps it for the engine's lifetime. Returns {@code null} on
- * any setup failure (open fail, mmap fail, unexpected size) — the
- * caller falls back to the no-watermark behaviour, no exception
- * escapes. Idempotent at the engine layer: a stale file from a
- * prior session is reused as-is; the first {@link #write(long)}
- * stamps the magic and the new FSN atomically.
+ * Opens (creating if absent) the watermark file in {@code slotDir} and
+ * maps it for the engine's lifetime. Returns {@code null} on any setup
+ * failure, leaving the caller to choose whether its durability contract
+ * permits operation without one.
+ *
+ * Wrong-sized files, including the legacy 16-byte non-CRC format, are
+ * reset. Trusting a legacy FSN would retain the torn-write ambiguity this
+ * format removes; resetting it causes conservative replay from the
+ * segment-derived seed instead.
*/
public static AckWatermark open(String slotDir) {
+ return open(FilesFacade.INSTANCE, slotDir);
+ }
+
+ /**
+ * Facade-aware variant of {@link #open(String)}. Every filesystem call,
+ * including the lifetime mapping, goes through {@code filesFacade} so
+ * tests can observe or fault-inject the watermark mmap.
+ */
+ public static AckWatermark open(FilesFacade filesFacade, String slotDir) {
String filePath = slotDir + "/" + FILE_NAME;
- // Decide by size: existing-and-correct -> openRW preserves the
- // previous session's watermark (defeating which is the whole
- // point of NOT calling openCleanRW unconditionally); missing or
- // wrong-sized -> openCleanRW + allocate creates a fresh
- // FILE_SIZE-byte file (zero magic, read() reports INVALID until
- // the first write).
- long existing = Files.exists(filePath) ? Files.length(filePath) : -1L;
+ long existing = filesFacade.exists(filePath) ? filesFacade.length(filePath) : -1L;
int fd;
if (existing == FILE_SIZE) {
- fd = Files.openRW(filePath);
+ fd = filesFacade.openRW(filePath);
} else {
- fd = Files.openCleanRW(filePath);
- if (fd >= 0 && !Files.allocate(fd, FILE_SIZE)) {
- // FilesFacade.allocate contract on a false return:
- // close the fd AND unlink the partial file.
- Files.close(fd);
- Files.remove(filePath);
+ fd = filesFacade.openCleanRW(filePath);
+ if (fd >= 0 && !filesFacade.allocate(fd, FILE_SIZE)) {
+ // FilesFacade.allocate contract on a false return: close the
+ // fd and unlink the partial file.
+ filesFacade.close(fd);
+ filesFacade.remove(filePath);
fd = -1;
}
}
if (fd < 0) {
- LOG.warn("ack watermark {} could not be opened (rc={}); proceeding without it",
- filePath, fd);
+ LOG.warn("ack watermark {} could not be opened (rc={})", filePath, fd);
return null;
}
- long addr = Files.mmap(fd, FILE_SIZE, 0, Files.MAP_RW, MemoryTag.MMAP_DEFAULT);
+ long addr = filesFacade.mmap(fd, FILE_SIZE, 0, Files.MAP_RW, MemoryTag.MMAP_DEFAULT);
if (addr == Files.FAILED_MMAP_ADDRESS) {
- LOG.warn("ack watermark {} could not be mmapped; proceeding without it", filePath);
- Files.close(fd);
+ LOG.warn("ack watermark {} could not be mmapped", filePath);
+ filesFacade.close(fd);
return null;
}
- // Inspect the existing magic once at open time. If it's already
- // set (cross-session reopen, e.g. recovery after a clean
- // shutdown), the first write() can skip the magic store
- // entirely and degenerate to a single 8-byte FSN put.
- int magic = Unsafe.getUnsafe().getInt(addr + MAGIC_OFFSET);
- return new AckWatermark(fd, addr, magic == FILE_MAGIC);
+ Record selected = selectRecord(addr);
+ return selected == null
+ ? new AckWatermark(filesFacade, fd, addr, 0L, INVALID)
+ : new AckWatermark(filesFacade, fd, addr, selected.generation, selected.fsn);
+ }
+
+ /**
+ * Releases the native storage while deliberately leaving the logical
+ * closed flag clear. Test-only: recreates a stale racing writer without
+ * reflective access to descriptor and mapping internals.
+ */
+ @TestOnly
+ public boolean releaseStorageButKeepWritableForTesting() {
+ return releaseStorage();
}
/**
- * Best-effort removal of a stale watermark file. Used by the
- * engine startup path when no segments are recovered — a stale
- * watermark file with no segments behind it is meaningless and
- * would only confuse the next session's seed.
+ * Best-effort removal of a stale watermark file. Used by the engine
+ * startup path when no segments are recovered.
*/
public static void removeOrphan(String slotDir) {
- Files.remove(slotDir + "/" + FILE_NAME);
+ removeOrphan(FilesFacade.INSTANCE, slotDir);
+ }
+
+ static boolean removeOrphan(FilesFacade filesFacade, String slotDir) {
+ return filesFacade.remove(slotDir + "/" + FILE_NAME);
}
/**
- * Single-load read of the current FSN. Returns {@link #INVALID} if
- * the file has never been written (magic field is zero, i.e. the
- * file was freshly created by {@link #open(String)} and no
- * {@link #write(long)} has run yet against this slot).
+ * Returns the FSN from the greatest-generation valid record, or
+ * {@link #INVALID} when neither record validates.
*/
public long read() {
if (closed) return INVALID;
- int magic = Unsafe.getUnsafe().getInt(mmapAddress + MAGIC_OFFSET);
- if (magic != FILE_MAGIC) {
- // Either freshly created (all zeros) or some kind of corruption.
- // Either way, fall back to the segment-derived seed.
- return INVALID;
+ Record selected = selectRecord(mmapAddress);
+ if (selected == null) {
+ fsn = INVALID;
+ generation = 0L;
+ } else {
+ fsn = selected.fsn;
+ generation = selected.generation;
}
- return Unsafe.getUnsafe().getLong(mmapAddress + FSN_OFFSET);
+ return fsn;
}
/**
- * Atomically updates the persisted FSN. Single 8-byte aligned
- * store to the mapped region. The first write also stamps the
- * magic so the next session's {@link #read()} can distinguish
- * "valid watermark" from "freshly created file".
+ * Flushes the mapped bytes and then the backing fd. The caller must sync
+ * the slot directory after this succeeds so a newly-created watermark's
+ * directory entry is durable before segment deletion begins.
+ */
+ public void sync() {
+ if (closed) {
+ throw new IllegalStateException("ack watermark is closed");
+ }
+ if (filesFacade.msync(mmapAddress, FILE_SIZE, false) != 0) {
+ throw new IllegalStateException("could not msync ack watermark");
+ }
+ if (filesFacade.fsync(fd) != 0) {
+ throw new IllegalStateException("could not fsync ack watermark");
+ }
+ }
+
+ /**
+ * Updates the inactive record and selects it in memory only after its CRC
+ * 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 should only call this when {@code fsn} has advanced past
- * the last write.
+ * Caller responsibility: monotonic ordering. The manager's tick loop only
+ * calls this when {@code fsn} has advanced past the last write.
*/
public void write(long fsn) {
if (closed) return;
- // Steady-state hot path: a single 8-byte aligned putLong, no
- // memory load of the magic. Order matters only on the very
- // first write of a fresh file: FSN first so the next reader
- // that observes the magic (set immediately below) also
- // observes a valid FSN -- no fence needed because the same
- // thread reads both in program order, and crash recovery
- // resumes a fresh process that sees whatever disk-level state
- // the kernel flushed.
- Unsafe.getUnsafe().putLong(mmapAddress + FSN_OFFSET, fsn);
- if (!magicWritten) {
- Unsafe.getUnsafe().putInt(mmapAddress + MAGIC_OFFSET, FILE_MAGIC);
- magicWritten = true;
+ long nextGeneration = generation + 1L;
+ long recordAddress = mmapAddress + (nextGeneration & 1L) * RECORD_SLOT_SIZE;
+ Unsafe.getUnsafe().setMemory(recordAddress, RECORD_SIZE, (byte) 0);
+ Unsafe.getUnsafe().putInt(recordAddress + MAGIC_OFFSET, FILE_MAGIC);
+ Unsafe.getUnsafe().putInt(recordAddress + 4, VERSION);
+ Unsafe.getUnsafe().putLong(recordAddress + 8, nextGeneration);
+ Unsafe.getUnsafe().putLong(recordAddress + FSN_OFFSET, fsn);
+ int crc = Crc32c.update(Crc32c.INIT, recordAddress, CRC_OFFSET);
+ Unsafe.getUnsafe().putInt(recordAddress + CRC_OFFSET, crc);
+ generation = nextGeneration;
+ this.fsn = fsn;
+ }
+
+ private boolean releaseStorage() {
+ if (isStorageReleased) {
+ return false;
+ }
+ isStorageReleased = true;
+ if (mmapAddress != 0L && mmapAddress != Files.FAILED_MMAP_ADDRESS) {
+ filesFacade.munmap(mmapAddress, FILE_SIZE, MemoryTag.MMAP_DEFAULT);
+ }
+ if (fd >= 0) {
+ filesFacade.close(fd);
+ }
+ return true;
+ }
+
+ private static Record readRecord(long address) {
+ if (Unsafe.getUnsafe().getInt(address + MAGIC_OFFSET) != FILE_MAGIC
+ || Unsafe.getUnsafe().getInt(address + 4) != VERSION) {
+ return null;
+ }
+ int expectedCrc = Unsafe.getUnsafe().getInt(address + CRC_OFFSET);
+ int actualCrc = Crc32c.update(Crc32c.INIT, address, CRC_OFFSET);
+ if (expectedCrc != actualCrc) {
+ return null;
+ }
+ long generation = Unsafe.getUnsafe().getLong(address + 8);
+ long fsn = Unsafe.getUnsafe().getLong(address + FSN_OFFSET);
+ if (generation <= 0 || fsn < -1L) {
+ return null;
+ }
+ return new Record(generation, fsn);
+ }
+
+ private static Record selectRecord(long address) {
+ Record first = readRecord(address);
+ Record second = readRecord(address + RECORD_SLOT_SIZE);
+ if (first == null) {
+ return second;
+ }
+ if (second == null || first.generation > second.generation) {
+ return first;
+ }
+ return second;
+ }
+
+ private static final class Record {
+ private final long fsn;
+ private final long generation;
+
+ private Record(long generation, long fsn) {
+ this.fsn = fsn;
+ this.generation = generation;
}
}
}
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 d79080d4..c83f9f93 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
@@ -56,12 +56,15 @@
*
*
* On terminal failure (auth-rejection on reconnect, a cluster-wide durable-ack
- * capability gap that exhausts its settle budget, recovery error), the drainer
- * drops a {@link OrphanScanner#FAILED_SENTINEL_NAME} sentinel into the slot
- * before exiting. Future scans skip the slot until an operator clears the
- * sentinel — bounded automatic retry, then human-in-the-loop. A transient
- * all-replica failover window is NOT terminal: it is retried indefinitely
- * (Invariant B), never quarantined on a wall-clock budget or attempt cap.
+ * capability gap that exhausts its settle budget, corrupt or incomplete durable
+ * recovery state), the drainer drops a
+ * {@link OrphanScanner#FAILED_SENTINEL_NAME} sentinel into the slot before
+ * exiting. Future scans skip the slot until an operator clears the sentinel —
+ * bounded automatic retry, then human-in-the-loop. Operational setup failures
+ * leave no sentinel so a later orphan scan can retry. JVM/programming Errors
+ * also leave no sentinel and propagate after teardown. A transient all-replica
+ * failover window is NOT terminal: it is retried indefinitely (Invariant B),
+ * never quarantined on a wall-clock budget or attempt cap.
*/
public final class BackgroundDrainer implements Runnable {
@@ -106,6 +109,7 @@ public final class BackgroundDrainer implements Runnable {
private final long segmentSizeBytes;
private final long sfMaxTotalBytes;
private final String slotPath;
+ private final long syncIntervalNanos;
/** Latest known {@code engine.ackedFsn()}; published for visibility. */
private volatile long ackedFsn = -1L;
private volatile String lastErrorMessage;
@@ -170,10 +174,36 @@ public BackgroundDrainer(
long durableAckKeepaliveIntervalMillis,
int maxHeadFrameRejections,
long poisonMinEscalationWindowMillis
+ ) {
+ this(slotPath, segmentSizeBytes, sfMaxTotalBytes, 0L, clientFactory,
+ reconnectMaxDurationMillis, reconnectInitialBackoffMillis,
+ reconnectMaxBackoffMillis, requestDurableAck,
+ durableAckKeepaliveIntervalMillis, maxHeadFrameRejections,
+ poisonMinEscalationWindowMillis);
+ }
+
+ /**
+ * Master constructor with the periodic SF checkpoint interval inherited
+ * from the sender that adopted the orphan slot.
+ */
+ public BackgroundDrainer(
+ String slotPath,
+ long segmentSizeBytes,
+ long sfMaxTotalBytes,
+ long syncIntervalNanos,
+ CursorWebSocketSendLoop.ReconnectFactory clientFactory,
+ long reconnectMaxDurationMillis,
+ long reconnectInitialBackoffMillis,
+ long reconnectMaxBackoffMillis,
+ boolean requestDurableAck,
+ long durableAckKeepaliveIntervalMillis,
+ int maxHeadFrameRejections,
+ long poisonMinEscalationWindowMillis
) {
this.slotPath = slotPath;
this.segmentSizeBytes = segmentSizeBytes;
this.sfMaxTotalBytes = sfMaxTotalBytes;
+ this.syncIntervalNanos = syncIntervalNanos;
this.clientFactory = clientFactory;
this.reconnectMaxDurationMillis = reconnectMaxDurationMillis;
this.reconnectInitialBackoffMillis = reconnectInitialBackoffMillis;
@@ -379,9 +409,9 @@ public WebSocketClient connectWithDurableAckRetry() {
// retrying cannot clear it, and spinning here would pin
// the slot .lock forever with no .failed sentinel and only
// a throttled, possibly-null-message WARN as a trace.
- // Rethrow: run()'s outer catch quarantines the slot
- // (markFailed + FAILED) and its finally releases the lock
- // -- quarantine-and-exit, exactly as genuine terminals do.
+ // Rethrow: run() records the failure without attempting
+ // allocation-heavy logging or a .failed write, its finally
+ // releases the lock, and then the Error remains visible.
throw (Error) t;
}
// INVARIANT B: a transport failure -- the whole cluster is
@@ -530,17 +560,60 @@ public void run() {
// holds it, the engine constructor throws and we exit silently
// (no .failed sentinel — contention is expected, not an error).
try {
- engine = new CursorSendEngine(slotPath, segmentSizeBytes,
- sfMaxTotalBytes, CursorSendEngine.DEFAULT_APPEND_DEADLINE_NANOS);
- } catch (IllegalStateException t) {
- String msg = t.getMessage();
- if (msg != null && msg.contains("already in use")) {
- LOG.info("orphan slot already locked, skipping: {} ({})",
- slotPath, msg);
- outcome = DrainOutcome.LOCKED_BY_OTHER;
- return;
+ try {
+ engine = new CursorSendEngine(slotPath, segmentSizeBytes,
+ sfMaxTotalBytes, CursorSendEngine.DEFAULT_APPEND_DEADLINE_NANOS,
+ syncIntervalNanos);
+ } catch (SfSanitizedResidueException first) {
+ // First sight of proven-dead sealed residue: recovery
+ // durably zeroed it BEFORE failing closed, so the chain
+ // on disk is already healed and a .failed sentinel here
+ // would strand a replayable backlog no scan revisits
+ // (the sentinel gates isCandidateOrphan and nothing in
+ // production clears it). Retry once over the healed
+ // chain; the WARN keeps the incident surfaced. Any
+ // failure of the retry is genuine and takes the normal
+ // classification below -- including a repeat of this
+ // type, which the SfRecoveryException arm then treats
+ // as the terminal quarantine a non-sticking heal is.
+ LOG.warn("drainer slot {}: sealed SF residue sanitized during recovery ({}); "
+ + "retrying engine construction over the healed chain",
+ slotPath, first.getMessage());
+ engine = new CursorSendEngine(slotPath, segmentSizeBytes,
+ sfMaxTotalBytes, CursorSendEngine.DEFAULT_APPEND_DEADLINE_NANOS,
+ syncIntervalNanos);
}
+ } catch (SlotLockContentionException t) {
+ LOG.info("orphan slot already locked, skipping: {} ({})",
+ slotPath, t.getMessage());
+ outcome = DrainOutcome.LOCKED_BY_OTHER;
+ return;
+ } catch (SfRecoveryException | MmapSegmentCorruptionException t) {
+ // The durable chain itself is proven corrupt or incomplete.
+ // Repeated scans cannot repair it, so preserve the terminal
+ // quarantine path in the outer catch below.
throw t;
+ } catch (Exception t) {
+ // Every other pre-publication construction exception is
+ // retryable: setup I/O, resource pressure, unexpected setup
+ // faults, and future operational failures do not prove durable
+ // data corruption. The constructor has closed partial
+ // resources; SlotLock retains and retries any unconfirmed
+ // unlock. Leave no .failed sentinel for the next orphan scan.
+ // Error deliberately escapes to the outer Error path: it is
+ // observable after teardown but cannot quarantine intact data.
+ // This path retries on every orphan scan, so the log line is
+ // the ONLY diagnostic a deterministic bug (e.g. an unexpected
+ // NPE, whose getMessage() is null) ever produces: attach the
+ // throwable for the stack trace and carry class+message into
+ // the telemetry surface, mirroring the outer setup-failure
+ // catch below.
+ String msg = t.toString();
+ LOG.warn("drainer setup temporarily unavailable for slot {}: {}",
+ slotPath, msg, t);
+ lastErrorMessage = msg;
+ outcome = DrainOutcome.FAILED;
+ return;
}
long target = engine.publishedFsn();
if (engine.ackedFsn() >= target) {
@@ -590,6 +663,16 @@ public void run() {
try {
loop.checkError();
} catch (Throwable t) {
+ // The I/O loop latches a JVM/programming Error inside a
+ // LineSenderException so checkError() can cross the
+ // thread boundary. Preserve the original Error contract:
+ // no wire quarantine, tear down, then propagate it.
+ if (t instanceof Error) {
+ throw (Error) t;
+ }
+ if (t.getCause() instanceof Error) {
+ throw (Error) t.getCause();
+ }
if (loop.capabilityGapTerminal() != null) {
// Capability gap mid-drain: recycle the wire, NOT
// the slot. connectWithDurableAckRetry() owns the
@@ -645,6 +728,18 @@ public void run() {
// outer condition, which is false for the same reason.
}
outcome = DrainOutcome.STOPPED;
+ } catch (Error t) {
+ // Resource pressure and JVM/programming failures prove neither
+ // durable corruption nor a terminal server response. Log the Error
+ // best-effort, but never let a secondary logging failure (especially
+ // under OOME) mask the original Error or prevent teardown.
+ try {
+ LOG.error("drainer failed with Error for slot {}", slotPath, t);
+ } catch (Throwable ignored) {
+ }
+ lastErrorMessage = t.getMessage();
+ outcome = DrainOutcome.FAILED;
+ throw t;
} catch (Throwable t) {
String msg = t.getMessage();
if (slotPath != null) {
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 64ca75d0..80f54486 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
@@ -26,10 +26,17 @@
import io.questdb.client.std.Compat;
import io.questdb.client.std.Files;
+import io.questdb.client.std.FilesFacade;
import io.questdb.client.std.ObjList;
import io.questdb.client.std.QuietCloseable;
+import org.jetbrains.annotations.TestOnly;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.concurrent.ThreadFactory;
+import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.locks.LockSupport;
+import java.util.function.LongConsumer;
/**
* Facade that bundles a {@link SegmentRing} with a {@link SegmentManager} and
@@ -63,6 +70,18 @@ public final class CursorSendEngine implements QuietCloseable {
public static final long DEFAULT_APPEND_DEADLINE_NANOS = 30_000_000_000L;
private static final org.slf4j.Logger LOG =
org.slf4j.LoggerFactory.getLogger(CursorSendEngine.class);
+ private static final ThreadFactory DEFAULT_FLOCK_RELEASE_RETRY_THREAD_FACTORY =
+ runnable -> new Thread(runnable, "qdb-sf-flock-release-retry");
+ private static final long FLOCK_RELEASE_RETRY_BASE_PARK_NANOS = 100_000_000L; // 100 ms
+ private static final Object FLOCK_RELEASE_RETRY_LOCK = new Object();
+ private static final long FLOCK_RELEASE_RETRY_MAX_PARK_NANOS = 5_000_000_000L; // 5 s
+ private static final ArrayDeque FLOCK_RELEASE_RETRY_QUEUE = new ArrayDeque<>();
+ private static volatile Runnable afterFlockReleaseRetryFailureHook;
+ private static volatile Runnable beforeDeferredCloseCreationHook;
+ private static volatile LongConsumer flockReleaseRetryParkOverride;
+ private static Thread flockReleaseRetryThread;
+ private static volatile ThreadFactory flockReleaseRetryThreadFactory =
+ DEFAULT_FLOCK_RELEASE_RETRY_THREAD_FACTORY;
private final long appendDeadlineNanos;
// Number of times appendBlocking observed BACKPRESSURE_NO_SPARE on its first
// ring.appendOrFsn attempt. One increment per blocking-call that had to wait
@@ -70,6 +89,11 @@ public final class CursorSendEngine implements QuietCloseable {
// writer; volatile because the user may sample it from any thread.
private final java.util.concurrent.atomic.AtomicLong backpressureStallCount =
new java.util.concurrent.atomic.AtomicLong();
+ // Constructed before an owned manager acquires its native path scratch, so
+ // callback allocation failure cannot orphan manager resources. A timed-out
+ // close can then hand it to either manager path without allocating.
+ private final Runnable deferredClose;
+ private final FilesFacade filesFacade;
private final SegmentManager manager;
// We own the manager iff the user constructed us with no manager — in that
// case close() also stops the manager. When the manager is shared across
@@ -78,6 +102,7 @@ public final class CursorSendEngine implements QuietCloseable {
private final SegmentRing ring;
private final long segmentSizeBytes;
private final String sfDir;
+ private final long syncIntervalNanos;
// Held for the engine's lifetime in disk mode. {@code null} in memory
// mode (no slot, no lock). Released by {@link #close()}; the kernel
// also drops it on hard process exit.
@@ -104,16 +129,67 @@ public final class CursorSendEngine implements QuietCloseable {
// range with a cumulative self-acknowledge once everything below is
// server-acked (CursorWebSocketSendLoop.tryRetireOrphanTail).
private long recoveredOrphanTipFsn = -1L;
- // Engine-owned mmap'd watermark file. {@code null} in memory mode and
- // in disk mode if open() failed (we proceed without it; recovery just
- // falls back to lowestBase - 1). 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.
+ // Engine-owned mmap'd watermark file. {@code null} only in memory mode;
+ // 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.
private final AckWatermark watermark;
// close() is publicly callable from any thread (Sender.close from a user
// thread, JVM shutdown hooks, test cleanup). volatile + synchronized
// close() makes the check-and-set atomic and gives readers a fence.
private volatile boolean closed;
+ // True once close() has run its full cleanup sequence INCLUDING a
+ // CONFIRMED slot-flock release — finishClose() publishes this strictly
+ // after SlotLock.release() reports success, never before. Pool threads
+ // treat the flip as "the slot dir is reusable" and free the slot index
+ // the moment they observe it (QwpWebSocketSender.isSlotLockReleased ->
+ // SenderPool.reprobeRetiredSlots), so publishing before the release
+ // would let a replacement engine's SlotLock.acquire collide with the
+ // still-open fd. Stays false when a close attempt could not confirm
+ // manager-worker quiescence (or the flock release itself failed) and had
+ // to leak the ring/watermark/slot lock — in that case a later close()
+ // call retries the cleanup (the worker may have exited by then).
+ // volatile: latched by finishClose(), but read lock-free by
+ // isCloseCompleted() from pool threads re-probing a retired slot (see the
+ // getter for why it must not synchronize).
+ private volatile boolean closeCompleted;
+ // Invoked after closeCompleted publishes a confirmed flock release. Used
+ // by an owning sender pool to wake capacity-starved borrowers immediately.
+ private volatile Runnable slotLockReleaseListener;
+ // Test-only hook run by finishClose() between the terminal cleanup and
+ // the flock release. Lets a test park the releasing thread inside the
+ // cleanup/release window and assert that closeCompleted stays false —
+ // i.e. that completion is never observable while the flock is still
+ // held. volatile: finishClose may run on the manager worker's exit
+ // thread while the hook is installed from a test thread.
+ private volatile Runnable beforeFlockReleaseHook;
+ // Ensures this engine has at most one entry in the shared flock-release
+ // retry driver. The error path only: ordinary closes never enqueue work.
+ private final AtomicBoolean flockReleaseRetryStarted = new AtomicBoolean();
+ // Published before deferredClose is registered. The manager lock provides
+ // the callback handoff fence; volatile also covers a direct test/retry read.
+ private volatile boolean fullyDrainedForDeferredClose;
+ // Exactly-once claim on the terminal cleanup (finishClose). Contended by
+ // close() and a worker-exit handoff (completeDeferredClose); whoever wins
+ // the CAS runs the cleanup, the loser never touches ring/watermark/flock.
+ // Deliberately NOT the engine monitor: a retried close() holds the
+ // monitor while joining the manager worker, and the worker cannot die
+ // until its exit-path cleanup finishes — monitor-based exclusion would
+ // stall that close() for its full join budget (test-visible livelock).
+ // With the CAS the worker's cleanup never blocks, so the join returns as
+ // soon as the pass ends.
+ private final AtomicBoolean terminalCleanupClaimed = new AtomicBoolean();
+ // True only after a quiescent close reached the final watermark barrier
+ // and that barrier failed. It permits the shared retry driver to claim
+ // terminal cleanup; false while cleanup is merely waiting for manager
+ // quiescence, where an independent retry would race the live worker.
+ private volatile boolean terminalCleanupRetryReady;
+ // Published only after ring/watermark/unlink cleanup is finished. A close
+ // that loses terminalCleanupClaimed may retry the flock only after this
+ // becomes true, otherwise it could expose the slot while cleanup is live.
+ private volatile boolean terminalResourcesCleaned;
// Producer-thread-only: timestamp of the last "we're backpressured" log
// line, used to throttle. Plain long is fine.
private long lastBackpressureLogNs;
@@ -125,7 +201,7 @@ public final class CursorSendEngine implements QuietCloseable {
*/
public CursorSendEngine(String sfDir, long segmentSizeBytes) {
this(sfDir, segmentSizeBytes, SegmentManager.UNLIMITED_TOTAL_BYTES,
- DEFAULT_APPEND_DEADLINE_NANOS);
+ DEFAULT_APPEND_DEADLINE_NANOS, 0L);
}
/**
@@ -137,9 +213,18 @@ public CursorSendEngine(String sfDir, long segmentSizeBytes) {
*/
public CursorSendEngine(String sfDir, long segmentSizeBytes,
long maxTotalBytes, long appendDeadlineNanos) {
- this(sfDir, segmentSizeBytes,
- new SegmentManager(segmentSizeBytes, SegmentManager.DEFAULT_POLL_NANOS, maxTotalBytes),
- true, appendDeadlineNanos);
+ this(sfDir, segmentSizeBytes, maxTotalBytes, appendDeadlineNanos, 0L);
+ }
+
+ /**
+ * Creates an engine with an optional periodic data-checkpoint interval.
+ * A positive interval requires disk-backed store-and-forward mode.
+ */
+ public CursorSendEngine(String sfDir, long segmentSizeBytes,
+ long maxTotalBytes, long appendDeadlineNanos,
+ long syncIntervalNanos) {
+ this(sfDir, segmentSizeBytes, null, true, maxTotalBytes,
+ appendDeadlineNanos, syncIntervalNanos);
}
/**
@@ -148,17 +233,53 @@ public CursorSendEngine(String sfDir, long segmentSizeBytes,
* ownership of the manager. Uses the default append deadline.
*/
public CursorSendEngine(String sfDir, long segmentSizeBytes, SegmentManager manager) {
- this(sfDir, segmentSizeBytes, manager, false, DEFAULT_APPEND_DEADLINE_NANOS);
+ this(sfDir, segmentSizeBytes, manager, false, DEFAULT_APPEND_DEADLINE_NANOS, 0L);
+ }
+
+ public CursorSendEngine(String sfDir, long segmentSizeBytes, SegmentManager manager,
+ long syncIntervalNanos) {
+ this(sfDir, segmentSizeBytes, manager, false,
+ DEFAULT_APPEND_DEADLINE_NANOS, syncIntervalNanos);
}
private CursorSendEngine(String sfDir, long segmentSizeBytes, SegmentManager manager,
boolean ownsManager, long appendDeadlineNanos) {
+ this(sfDir, segmentSizeBytes, manager, ownsManager, appendDeadlineNanos, 0L);
+ }
+
+ private CursorSendEngine(String sfDir, long segmentSizeBytes, SegmentManager manager,
+ boolean ownsManager, long appendDeadlineNanos,
+ long syncIntervalNanos) {
+ this(sfDir, segmentSizeBytes, manager, ownsManager,
+ SegmentManager.UNLIMITED_TOTAL_BYTES, appendDeadlineNanos, syncIntervalNanos);
+ }
+
+ private CursorSendEngine(String sfDir, long segmentSizeBytes, SegmentManager manager,
+ boolean ownsManager, long maxTotalBytes, long appendDeadlineNanos,
+ long syncIntervalNanos) {
+ // Allocate the bound callback before constructing an owned manager.
+ // Field initializers have completed, but no engine-owned native/disk
+ // resource exists yet. If callback allocation throws, construction
+ // stops without a manager whose native path scratch could be orphaned.
+ this.deferredClose = createDeferredClose();
+
// sfDir == null → memory-only mode (non-SF async ingest). Same
// cursor architecture, no disk involvement; segments
// live in malloc'd native memory.
// sfDir != null → store-and-forward mode. Segments are mmap'd files
// under sfDir, recoverable across sender restarts.
boolean memoryMode = sfDir == null;
+ // Validate before an owned manager allocates its native path scratch.
+ if (syncIntervalNanos < 0L) {
+ throw new IllegalArgumentException("syncIntervalNanos must not be negative");
+ }
+ if (memoryMode && syncIntervalNanos > 0L) {
+ throw new IllegalArgumentException("periodic sync requires disk store-and-forward mode");
+ }
+ if (ownsManager && manager == null) {
+ manager = new SegmentManager(
+ segmentSizeBytes, SegmentManager.DEFAULT_POLL_NANOS, maxTotalBytes);
+ }
SlotLock acquiredLock = null;
if (!memoryMode) {
try {
@@ -170,13 +291,11 @@ private CursorSendEngine(String sfDir, long segmentSizeBytes, SegmentManager man
// recovery and create overlapping FSN ranges. SlotLock.acquire
// also creates the slot dir if it doesn't exist yet — no
// separate mkdir step needed here.
- acquiredLock = SlotLock.acquire(sfDir);
+ acquiredLock = SlotLock.acquire(sfDir, syncIntervalNanos > 0L);
} catch (Throwable t) {
- // The delegating constructors evaluate `new SegmentManager(...)`
- // BEFORE this body runs, so on a pre-try throw (e.g. slot lock
- // collision) an owned manager is already alive and would leak
- // its native path-scratch sink -- 256 bytes per failed
- // construction attempt. Close it before propagating.
+ // Callback creation and owned-manager construction have already
+ // completed. A slot-lock failure must close the owned manager's
+ // native path scratch before propagating.
if (ownsManager) {
try {
manager.close();
@@ -190,8 +309,10 @@ private CursorSendEngine(String sfDir, long segmentSizeBytes, SegmentManager man
this.sfDir = sfDir;
this.segmentSizeBytes = segmentSizeBytes;
this.manager = manager;
+ this.filesFacade = manager.filesFacade();
this.ownsManager = ownsManager;
this.appendDeadlineNanos = appendDeadlineNanos;
+ this.syncIntervalNanos = syncIntervalNanos;
// Track the ring locally until every step succeeds — only commit it
// to this.ring at the very end. If anything between ring allocation
@@ -204,9 +325,11 @@ private CursorSendEngine(String sfDir, long segmentSizeBytes, SegmentManager man
// session before deciding to start fresh. Without this the engine
// would create a new sf-initial.sfa at baseSeq=0, overlapping FSNs
// already on disk and corrupting ACK translation, trim, and replay.
- SegmentRing recovered = memoryMode ? null
- : SegmentRing.openExisting(sfDir, segmentSizeBytes);
- this.wasRecoveredFromDisk = recovered != null;
+ SegmentRing.Recovery recovery = memoryMode ? null
+ : SegmentRing.recover(filesFacade, sfDir, segmentSizeBytes);
+ SegmentRing recovered = recovery == null ? null : recovery.ring();
+ this.wasRecoveredFromDisk = recovery != null
+ && recovery.status() == SegmentRing.RecoveryStatus.RECOVERED;
if (recovered != null) {
ringInProgress = recovered;
// Seed ackedFsn to one below the lowest segment's baseSeq.
@@ -253,14 +376,18 @@ private CursorSendEngine(String sfDir, long segmentSizeBytes, SegmentManager man
// - trim ran before persist: segments are gone (so
// lowestBase is higher than watermark), watermark
// is stale; max picks lowestBase - 1.
- // open() returns null on any setup failure so a missing
- // mmap doesn't take down the engine -- we just fall
- // back to the bare lowestBase - 1 seed.
- watermarkInProgress = AckWatermark.open(sfDir);
+ // Segment-derived state cannot tell whether frames in the
+ // lowest surviving segment were acknowledged. Starting
+ // without the watermark would therefore expose acknowledged
+ // residue for replay, so disk recovery must fail closed when
+ // the file cannot be opened or mapped.
+ watermarkInProgress = AckWatermark.open(filesFacade, sfDir);
+ if (watermarkInProgress == null) {
+ throw new SfOperationalException(
+ "could not open required ack watermark for SF slot " + sfDir);
+ }
long baseSeed = lowestBase - 1;
- long watermarkFsn = watermarkInProgress != null
- ? watermarkInProgress.read()
- : AckWatermark.INVALID;
+ 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
@@ -307,32 +434,75 @@ private CursorSendEngine(String sfDir, long segmentSizeBytes, SegmentManager man
// so the new session's first read() correctly reports
// INVALID (magic=0 on a freshly zero-filled file).
if (!memoryMode) {
- AckWatermark.removeOrphan(sfDir);
- watermarkInProgress = AckWatermark.open(sfDir);
+ AckWatermark.removeOrphan(filesFacade, sfDir);
+ watermarkInProgress = AckWatermark.open(filesFacade, sfDir);
+ if (watermarkInProgress == null) {
+ throw new SfOperationalException(
+ "could not open required ack watermark for SF slot " + sfDir);
+ }
}
MmapSegment initial;
String initialPath = null;
+ SfManifest initialManifest = null;
if (memoryMode) {
initial = MmapSegment.createInMemory(0L, segmentSizeBytes);
} else {
+ // Created WITHOUT the manifest-required flag: the flag is
+ // a durable promise that sf-manifest.bin exists, so it
+ // must only be stamped after the manifest itself is on
+ // disk. Stamping it at create time would leave a crash
+ // window (initial durable, manifest not yet created)
+ // whose recovery hard-fails with "segment requires
+ // missing manifest" even though nothing was lost.
initialPath = sfDir + "/sf-initial.sfa";
- initial = MmapSegment.create(initialPath, 0L, segmentSizeBytes);
+ initial = MmapSegment.create(filesFacade, initialPath, 0L, segmentSizeBytes);
}
try {
- ringInProgress = new SegmentRing(initial, segmentSizeBytes);
+ if (!memoryMode) {
+ // Ordering is load-bearing for crash recovery:
+ // 1. initial segment durable (header + dirent),
+ // 2. manifest created (its own create fsyncs),
+ // 3. flag stamped on the segment.
+ // A crash after (1) recovers via the legacy
+ // (manifest-less) path; after (2) via the manifest
+ // path with an unflagged-but-valid empty active;
+ // after (3) it is the steady state. Every window
+ // self-heals without operator action.
+ initial.syncHeader();
+ if (filesFacade.fsyncDir(sfDir) != 0) {
+ throw new MmapSegmentException(
+ "could not sync fresh SF segment directory " + sfDir);
+ }
+ initialManifest = SfManifest.create(filesFacade, sfDir, 0L, 0L);
+ initial.markManifestRequired();
+ }
+ ringInProgress = new SegmentRing(initial, segmentSizeBytes, initialManifest);
+ initialManifest = null;
} catch (Throwable t) {
initial.close();
+ if (initialManifest != null) {
+ initialManifest.close();
+ SfManifest.removeFile(filesFacade, sfDir);
+ }
if (initialPath != null) {
- Files.remove(initialPath);
+ filesFacade.remove(initialPath);
}
throw t;
}
}
+ if (syncIntervalNanos > 0L) {
+ ringInProgress.enablePeriodicSync();
+ if (recovered != null) {
+ // Establish a durable baseline before exposing data that a
+ // previous MEMORY-mode process may have left in page cache.
+ ringInProgress.syncAllLiveSegments();
+ }
+ }
if (ownsManager) {
manager.start();
}
- manager.register(ringInProgress, sfDir, watermarkInProgress);
+ manager.register(ringInProgress, sfDir, watermarkInProgress, syncIntervalNanos);
// All construction succeeded — commit the ring and
// watermark references.
this.ring = ringInProgress;
@@ -399,6 +569,10 @@ public MmapSegment activeSegment() {
return ring.getActive();
}
+ MmapSegment pinActiveSegment() {
+ return ring.pinActiveSegment();
+ }
+
/**
* Append the payload, blocking up to {@link #appendDeadlineNanos} when
* the cursor ring is at its memory/disk cap and waiting for ACK-driven
@@ -431,11 +605,13 @@ public long appendBlocking(long payloadAddr, int payloadLen) {
if (now >= deadlineNs) {
throw new io.questdb.client.cutlass.line.LineSenderException(
"cursor ring backpressured for ").put(appendDeadlineNanos / 1_000_000L)
- .put(" ms — wire path is not draining (server slow / disconnected, or sf_max_total_bytes too small)");
+ .put(" ms - wire path is not draining (server slow / disconnected, "
+ + "periodic disk sync is slow, or sf_max_total_bytes is too small)");
}
if (now - lastBackpressureLogNs >= BACKPRESSURE_LOG_THROTTLE_NANOS) {
lastBackpressureLogNs = now;
- LOG.warn("cursor producer backpressured ({} stalls so far); waiting for I/O drain — will throw after {} ms",
+ LOG.warn("cursor producer backpressured ({} stalls so far); waiting for I/O or periodic disk sync; "
+ + "will throw after {} ms",
backpressureStallCount.get(), appendDeadlineNanos / 1_000_000L);
}
LockSupport.parkNanos(50_000L); // 50 µs
@@ -478,51 +654,239 @@ public long appendOrFsn(long payloadAddr, int payloadLen, long spinDeadlineNanos
return SegmentRing.BACKPRESSURE_NO_SPARE;
}
+ public void checkDurability() {
+ ring.checkDurability();
+ }
+
@Override
public synchronized void close() {
- if (closed) return;
+ if (closed && closeCompleted) return;
closed = true;
// Capture drain state BEFORE closing the ring — once the ring is
// closed, its accessors aren't safe to read. The active segment is
- // never trimmed by drainTrimmable (only sealed segments are), so
+ // never trim-eligible (only sealed segments are), so
// when everything published has been acked we have to unlink the
// residual .sfa files here. Without this, the next sender (or a
// drainer adopting this slot) would replay already-acked data
// against potentially-fresh server state — duplicate writes when
// the server has no dedup state for those messageSequences.
// Memory mode has no files to unlink.
- // The whole close sequence runs under try/finally so the slot lock
- // is ALWAYS released, even if manager/ring close or unlink throws —
- // otherwise a kernel-held flock outlives the engine and the next
- // sender for the same slot collides on a lock the dead engine
- // never released.
+ //
+ // Cleanup is gated on worker quiescence: releasing the ring,
+ // watermark, segment files or the slot lock while the manager worker
+ // is still mid service pass would let a replacement engine acquire
+ // the slot and have its files unlinked by the stale worker —
+ // store-and-forward data loss after restart. When the bounded worker
+ // join times out on an owned manager, cleanup OWNERSHIP TRANSFERS to
+ // the worker's exit path (deferUntilWorkerExit): the worker is
+ // provably the last thread able to touch the slot directory, so it
+ // releases everything the moment its in-flight pass finishes instead
+ // of leaking the slot until process exit.
+ //
+ // "Fully drained" includes BOTH the obvious case (every published
+ // FSN has been acked) AND the never-published case (publishedFsn
+ // < 0). The latter matters because a drainer adopting an empty
+ // orphan slot — segments filtered as empty by recovery, engine
+ // recreates a fresh sf-initial.sfa — would otherwise leave that
+ // fresh empty file behind, the next scanner finds it, adopts the
+ // slot again, and the cycle repeats forever (M6).
+ // Own try/catch so sabotaged/broken ring state cannot skip the
+ // quiescence barrier below or the slot-lock release in finishClose.
+ boolean drained = false;
try {
- // "Fully drained" includes BOTH the obvious case (every published
- // FSN has been acked) AND the never-published case (publishedFsn
- // < 0). The latter matters because a drainer adopting an empty
- // orphan slot — segments filtered as empty by recovery, engine
- // recreates a fresh sf-initial.sfa — would otherwise leave that
- // fresh empty file behind, the next scanner finds it, adopts the
- // slot again, and the cycle repeats forever (M6).
- boolean fullyDrained = sfDir != null
+ drained = sfDir != null
&& (ring.publishedFsn() < 0
|| ring.ackedFsn() >= ring.publishedFsn());
- // Each cleanup step in its own try/catch so a single failure
- // doesn't strand later cleanups — mirrors the constructor's
- // catch block. Without this, a throw from manager.deregister
- // or manager.close() would leave the ring mmap'd and any
- // residual .sfa files on disk, where the next sender can
- // adopt them and replay already-acked data.
+ } catch (Throwable ignored) {
+ }
+ final boolean fullyDrained = drained;
+ fullyDrainedForDeferredClose = fullyDrained;
+ // Each cleanup step in its own try/catch so a single failure
+ // doesn't strand later cleanups — mirrors the constructor's
+ // catch block. Without this, a throw from manager.deregister
+ // or manager.close() would leave the ring mmap'd and any
+ // residual .sfa files on disk, where the next sender can
+ // adopt them and replay already-acked data.
+ try {
+ manager.deregister(ring);
+ } catch (Throwable ignored) {
+ }
+ // Quiescence barrier. deregister alone only removes the entry
+ // from the registry — the worker may still be mid service pass
+ // for this ring (creating a spare file, trimming, unlinking).
+ boolean workerQuiescent = false;
+ if (ownsManager) {
+ // Stopping and reaping a private manager is a stronger barrier
+ // than waiting for this ring alone. Do it directly so a stuck
+ // worker consumes at most one workerJoinTimeoutMillis budget,
+ // rather than one here and a second one in manager.close().
try {
- manager.deregister(ring);
+ manager.close();
} catch (Throwable ignored) {
}
- if (ownsManager) {
- try {
- manager.close();
- } catch (Throwable ignored) {
+ try {
+ workerQuiescent = manager.isWorkerReaped();
+ } catch (Throwable ignored) {
+ }
+ } else {
+ // A shared manager must keep serving its other rings, so wait
+ // only for the deregistered ring's current pass to finish.
+ try {
+ workerQuiescent = manager.awaitRingQuiescence(ring);
+ } catch (Throwable ignored) {
+ }
+ }
+ if (!workerQuiescent && ownsManager) {
+ // Ownership handoff: manager.close() already stopped the worker
+ // loop (running=false), so the worker exits as soon as its
+ // in-flight pass finishes — it is merely slow, or wedged in a
+ // syscall. Either way it is the last thread able to touch the
+ // slot, so hand it the terminal cleanup instead of leaking the
+ // slot until process exit. completeDeferredClose and a retried
+ // close() race through the terminalCleanupClaimed CAS, so the
+ // cleanup runs exactly once and the worker never blocks on the
+ // engine monitor a retried close() holds while joining it.
+ boolean handedOff = false;
+ boolean registrationFailed = false;
+ try {
+ handedOff = manager.deferOwnedEngineCloseUntilWorkerExit(deferredClose);
+ } catch (Throwable ignored) {
+ // Unexpected monitor/VM failure carries no worker-liveness
+ // information. Ordinary handoff cannot allocate: both the
+ // callback and the manager's single slot were preallocated.
+ registrationFailed = true;
+ }
+ if (handedOff) {
+ LOG.error("SF manager worker did not quiesce during engine close; ring, watermark "
+ + "and slot-lock release are handed to the worker's exit path and run the "
+ + "moment its in-flight service pass finishes. The slot stays locked (and "
+ + "isCloseCompleted() false) until then, so no replacement engine can race "
+ + "the stale worker on slot {}", sfDir == null ? "" : sfDir);
+ return;
+ }
+ if (registrationFailed) {
+ // The handoff never registered and the worker was never
+ // observed — it must be presumed live and mid service pass.
+ // Retain every worker-reachable resource (ring, watermark,
+ // segment files, slot flock) and leave terminalCleanupClaimed
+ // unclaimed and closeCompleted false, exactly like the
+ // shared-manager leak branch: manager.close() above already
+ // stopped the worker loop, so a retried close() converges via
+ // isWorkerReaped() once the in-flight pass ends. The kernel
+ // releases the slot flock on process exit regardless.
+ LOG.error("SF worker-exit handoff registration failed during engine close; "
+ + "leaking the ring, watermark and slot lock so a possibly-live "
+ + "worker cannot corrupt a future engine on slot {}. close() may be "
+ + "invoked again to retry cleanup once the worker has exited.",
+ sfDir == null ? "" : sfDir);
+ return;
+ }
+ // Handoff rejected: the worker loop exited between the failed
+ // bounded join and the registration attempt (both sides share the
+ // manager's lock, so this observation is exact). A worker past
+ // its loop can never touch slot paths again — inline cleanup is
+ // as safe as a reaped worker.
+ workerQuiescent = true;
+ }
+ if (!workerQuiescent) {
+ // A shared manager keeps serving sibling rings, so it cannot use
+ // the whole-worker exit handoff above. Transfer cleanup to this
+ // ring's current pass instead. Registration and pass completion
+ // share the manager lock: true means the worker owns cleanup;
+ // false means the pass already finished and inline cleanup is safe.
+ boolean handedOff = false;
+ boolean registrationFailed = false;
+ try {
+ handedOff = manager.deferUntilRingQuiescent(ring, deferredClose);
+ } catch (Throwable ignored) {
+ registrationFailed = true;
+ }
+ if (handedOff) {
+ LOG.error("SF manager worker did not quiesce during engine close; ring, watermark "
+ + "and slot-lock release are handed to the current ring pass and run the "
+ + "moment that pass finishes. The slot stays locked (and "
+ + "isCloseCompleted() false) until then, so no replacement engine can "
+ + "race the stale worker on slot {}",
+ sfDir == null ? "" : sfDir);
+ return;
+ }
+ if (registrationFailed) {
+ LOG.error("SF ring-pass cleanup handoff registration failed during engine close; "
+ + "leaking the ring, watermark and slot lock so a possibly-live worker "
+ + "cannot corrupt a future engine on slot {}. The kernel releases the "
+ + "flock on process exit.", sfDir == null ? "" : sfDir);
+ return;
+ }
+ workerQuiescent = true;
+ }
+ if (!terminalCleanupClaimed.compareAndSet(false, true)) {
+ // A worker-exit handoff or earlier close owns the one-time
+ // ring/watermark cleanup. Once that work is published complete,
+ // this close may still retry an unconfirmed flock release.
+ retryFlockReleaseIfReady();
+ return;
+ }
+ finishClose(fullyDrained);
+ }
+
+ /**
+ * Terminal cleanup: closes the ring and watermark, unlinks drained
+ * segment files, releases the slot flock, and — only once the release
+ * is confirmed — latches {@link #closeCompleted}. Publish order
+ * is load-bearing: pools free the slot index the instant they observe
+ * {@code closeCompleted} (via {@code isSlotLockReleased()}), so it must
+ * never be visible while the flock is still held, or a replacement
+ * engine races the release and fails acquisition on a live slot.
+ * The caller must hold the engine monitor and
+ * must have established that the manager worker can no longer touch the
+ * slot directory (reaped, provably exited, or running this on its own
+ * exit path) AND have won the {@link #terminalCleanupClaimed} CAS — the
+ * claim token, not the engine monitor, is the exclusion between a
+ * worker-exit handoff and a retried close(), so ring/watermark/flock are
+ * never double-released and the worker's cleanup can never deadlock
+ * against a close() that holds the monitor while joining the worker.
+ */
+ private void finishClose(boolean fullyDrained) {
+ if (!fullyDrained && syncIntervalNanos > 0L) {
+ try {
+ ring.syncAllLiveSegments();
+ } catch (RuntimeException | Error e) {
+ terminalCleanupRetryReady = true;
+ terminalCleanupClaimed.set(false);
+ startFlockReleaseRetry();
+ 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:
+ // 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);
+ watermark.sync();
+ if (filesFacade.fsyncDir(sfDir) != 0) {
+ throw new IllegalStateException(
+ "could not fsync SF slot directory before segment cleanup");
+ }
}
+ } catch (RuntimeException | Error e) {
+ terminalCleanupRetryReady = true;
+ terminalCleanupClaimed.set(false);
+ startFlockReleaseRetry();
+ throw e;
}
+ }
+ terminalCleanupRetryReady = false;
+
+ try {
+ RuntimeException durabilityFailure = null;
try {
ring.close();
} catch (Throwable ignored) {
@@ -541,24 +905,363 @@ public synchronized void close() {
} catch (Throwable ignored) {
}
}
- if (fullyDrained) {
+ if (fullyDrained && watermark != null) {
+ boolean segmentsRemoved = false;
try {
- unlinkAllSegmentFiles(sfDir);
+ segmentsRemoved = unlinkAllSegmentFiles(filesFacade, sfDir);
} catch (Throwable ignored) {
}
+ // Remove the watermark ONLY once every segment unlink is
+ // durable. Until the directory fsync succeeds, stable storage
+ // may still contain acknowledged segments and therefore still
+ // needs the durable watermark.
+ if (segmentsRemoved) {
+ if (filesFacade.fsyncDir(sfDir) != 0) {
+ durabilityFailure = new IllegalStateException(
+ "could not fsync SF slot directory after segment cleanup");
+ } else {
+ AckWatermark.removeOrphan(filesFacade, sfDir);
+ }
+ } else {
+ LOG.warn("close-time segment cleanup incomplete on slot {}; retaining the ack "
+ + "watermark so residual acknowledged segments stay covered -- the next "
+ + "engine on this slot recovers them as fully acked and retries the "
+ + "unlink on its own close", sfDir);
+ }
+ }
+ if (durabilityFailure != null) {
+ throw durabilityFailure;
+ }
+ } finally {
+ // The final watermark covering barrier has succeeded, so terminal
+ // resources can be released even if later best-effort cleanup or
+ // its post-unlink directory sync failed. The durable watermark
+ // still covers any segment a host crash restores.
+ //
+ // ORDER MATTERS: explicitly release the flock, verify it, and
+ // only then publish closeCompleted. Pools read isCloseCompleted()
+ // as "the slot dir is reusable" and free the slot index the moment
+ // it flips. SlotLock closes the fd once after confirmed unlock,
+ // but that close result cannot safely control publication because
+ // POSIX may consume the fd even when close reports failure.
+ Runnable hook = beforeFlockReleaseHook;
+ if (hook != null) {
try {
- AckWatermark.removeOrphan(sfDir);
+ hook.run();
} catch (Throwable ignored) {
+ // test-only; must never block the release
}
}
- } finally {
- if (slotLock != null) {
+ terminalResourcesCleaned = true;
+ retryFlockReleaseIfReady();
+ }
+ }
+
+ @TestOnly
+ public SegmentManager getManagerForTesting() {
+ return manager;
+ }
+
+ @TestOnly
+ public SegmentRing getRingForTesting() {
+ return ring;
+ }
+
+ @TestOnly
+ public SlotLock getSlotLockForTesting() {
+ return slotLock;
+ }
+
+ @TestOnly
+ public long getSyncIntervalNanosForTesting() {
+ return syncIntervalNanos;
+ }
+
+ /**
+ * Installs a hook invoked after each failed shared-driver flock release.
+ * Test-only: makes persistent retry progress observable without sleeps.
+ */
+ @TestOnly
+ public static void setAfterFlockReleaseRetryFailureHook(Runnable hook) {
+ afterFlockReleaseRetryFailureHook = hook;
+ }
+
+ /**
+ * Installs a constructor fault hook immediately before the bound deferred
+ * close callback is created. Test-only: proves callback allocation failure
+ * occurs before an owned manager acquires native resources.
+ */
+ @TestOnly
+ public static void setBeforeDeferredCloseCreationHook(Runnable hook) {
+ beforeDeferredCloseCreationHook = hook;
+ }
+
+ /**
+ * Replaces the shared retry driver's inter-round park with a callback
+ * receiving the park duration the driver would have used. Test-only:
+ * makes the retry cadence inspectable and rounds coordinatable without
+ * wall-clock waits.
+ */
+ @TestOnly
+ public static void setFlockReleaseRetryParkOverride(LongConsumer override) {
+ flockReleaseRetryParkOverride = override;
+ }
+
+ /**
+ * Overrides creation of the single shared flock-release retry thread.
+ * Test-only: makes thread creation/start failure and persistent retry
+ * scalability deterministic without relying on scheduler timing.
+ */
+ @TestOnly
+ public static void setFlockReleaseRetryThreadFactory(ThreadFactory factory) {
+ synchronized (FLOCK_RELEASE_RETRY_LOCK) {
+ if (flockReleaseRetryThread != null || !FLOCK_RELEASE_RETRY_QUEUE.isEmpty()) {
+ throw new IllegalStateException("flock-release retry driver is active");
+ }
+ flockReleaseRetryThreadFactory = factory == null
+ ? DEFAULT_FLOCK_RELEASE_RETRY_THREAD_FACTORY
+ : factory;
+ }
+ }
+
+ /**
+ * Installs a hook that {@link #finishClose} runs between the terminal
+ * cleanup and the slot-flock release. Test-only: makes the otherwise
+ * microsecond-wide cleanup/release window deterministic so tests can
+ * assert {@link #isCloseCompleted()} stays false until the release is
+ * confirmed.
+ */
+ @TestOnly
+ public void setBeforeFlockReleaseHook(Runnable hook) {
+ this.beforeFlockReleaseHook = hook;
+ }
+
+ /**
+ * Runs on a safe manager-worker handoff path when {@link #close()} moved
+ * cleanup ownership to either a shared manager's ring-pass completion or
+ * an owned manager's worker exit.
+ * Deliberately does NOT take the engine monitor: a retried close() can
+ * hold it while joining this very worker, and the thread cannot die until
+ * this method returns — the {@link #terminalCleanupClaimed} CAS provides
+ * the exactly-once exclusion instead, so the worker always exits promptly
+ * and the racing close() converges via {@code isWorkerReaped()}.
+ */
+ private void completeDeferredClose() {
+ if (!terminalCleanupClaimed.compareAndSet(false, true)) {
+ // A retried close() (or an earlier duplicate handoff) already ran
+ // the terminal cleanup.
+ return;
+ }
+ finishClose(fullyDrainedForDeferredClose);
+ LOG.info("deferred SF engine resource cleanup completed after manager-worker quiescence; "
+ + "slot release confirmed: {} [slot={}]",
+ closeCompleted, sfDir == null ? "" : sfDir);
+ }
+
+ private Runnable createDeferredClose() {
+ Runnable hook = beforeDeferredCloseCreationHook;
+ if (hook != null) {
+ hook.run();
+ }
+ return this::completeDeferredClose;
+ }
+
+ private boolean retryFlockReleaseIfReady() {
+ if (closeCompleted) {
+ return true;
+ }
+ if (!terminalResourcesCleaned) {
+ if (!terminalCleanupRetryReady
+ || !terminalCleanupClaimed.compareAndSet(false, true)) {
+ return false;
+ }
+ try {
+ finishClose(fullyDrainedForDeferredClose);
+ } catch (Throwable ignored) {
+ return false;
+ }
+ return closeCompleted;
+ }
+ boolean released;
+ if (slotLock != null) {
+ try {
+ released = slotLock.release();
+ } catch (Throwable ignored) {
+ released = false;
+ }
+ } else {
+ released = true;
+ }
+ if (released) {
+ closeCompleted = true;
+ Runnable listener = slotLockReleaseListener;
+ if (listener != null) {
try {
- slotLock.close();
+ listener.run();
} catch (Throwable ignored) {
- // best-effort; flock is also released by kernel on process exit
+ // A notification failure must not invalidate a confirmed release.
}
}
+ return true;
+ }
+ startFlockReleaseRetry();
+ return false;
+ }
+
+ private static void runFlockReleaseRetryDriver() {
+ // Capped exponential backoff: a persistent unlock failure must not
+ // burn a fixed 10 rounds of native release syscalls per second
+ // forever, but a transient failure must still recover promptly. The
+ // ramp doubles per fully-failed round from 100 ms up to 5 s.
+ long parkNanos = FLOCK_RELEASE_RETRY_BASE_PARK_NANOS;
+ while (true) {
+ final int roundSize;
+ synchronized (FLOCK_RELEASE_RETRY_LOCK) {
+ roundSize = FLOCK_RELEASE_RETRY_QUEUE.size();
+ if (roundSize == 0) {
+ flockReleaseRetryThread = null;
+ return;
+ }
+ }
+ boolean hasFailures = false;
+ boolean hasSuccesses = false;
+ for (int i = 0; i < roundSize; i++) {
+ final CursorSendEngine engine;
+ synchronized (FLOCK_RELEASE_RETRY_LOCK) {
+ engine = FLOCK_RELEASE_RETRY_QUEUE.pollFirst();
+ }
+ if (engine.retryFlockReleaseIfReady()) {
+ engine.flockReleaseRetryStarted.set(false);
+ hasSuccesses = true;
+ } else {
+ synchronized (FLOCK_RELEASE_RETRY_LOCK) {
+ FLOCK_RELEASE_RETRY_QUEUE.addLast(engine);
+ }
+ hasFailures = true;
+ Runnable hook = afterFlockReleaseRetryFailureHook;
+ if (hook != null) {
+ hook.run();
+ }
+ }
+ }
+ if (hasSuccesses) {
+ // Progress: the failure condition is clearing, so retry the
+ // remaining engines on the base cadence again.
+ parkNanos = FLOCK_RELEASE_RETRY_BASE_PARK_NANOS;
+ }
+ if (hasFailures) {
+ // Interruption must not abandon a retained flock, but clear
+ // the flag so subsequent parks still throttle retries.
+ Thread.interrupted();
+ LongConsumer parkOverride = flockReleaseRetryParkOverride;
+ if (parkOverride != null) {
+ parkOverride.accept(parkNanos);
+ } else {
+ LockSupport.parkNanos(parkNanos);
+ }
+ parkNanos = Math.min(parkNanos * 2, FLOCK_RELEASE_RETRY_MAX_PARK_NANOS);
+ }
+ }
+ }
+
+ private void startFlockReleaseRetry() {
+ if (!flockReleaseRetryStarted.compareAndSet(false, true)) {
+ return;
+ }
+ Throwable startFailure = null;
+ synchronized (FLOCK_RELEASE_RETRY_LOCK) {
+ FLOCK_RELEASE_RETRY_QUEUE.addLast(this);
+ if (flockReleaseRetryThread != null) {
+ // The driver may be parked on a ramped backoff; wake it so a
+ // freshly failed release gets its first driver retry promptly
+ // instead of inheriting older engines' full backoff.
+ LockSupport.unpark(flockReleaseRetryThread);
+ } else {
+ try {
+ Thread retryThread = flockReleaseRetryThreadFactory.newThread(
+ CursorSendEngine::runFlockReleaseRetryDriver);
+ if (retryThread == null) {
+ throw new IllegalStateException("retry thread factory returned null");
+ }
+ retryThread.setDaemon(true);
+ flockReleaseRetryThread = retryThread;
+ retryThread.start();
+ } catch (Throwable t) {
+ startFailure = t;
+ flockReleaseRetryThread = null;
+ CursorSendEngine queued;
+ while ((queued = FLOCK_RELEASE_RETRY_QUEUE.pollFirst()) != null) {
+ queued.flockReleaseRetryStarted.set(false);
+ }
+ }
+ }
+ }
+ if (startFailure == null) {
+ LOG.error("SF terminal cleanup or slot flock release failed during engine close; "
+ + "keeping closeCompleted=false and retrying on the shared driver so "
+ + "retired capacity recovers after the transient failure [slot={}]",
+ sfDir == null ? "" : sfDir);
+ } else {
+ // A later explicit close() or a pool's retired-slot probe
+ // (ensureFlockReleaseRetryScheduled) can still retry without
+ // repeating the one-time ring/watermark cleanup. The failed queue
+ // is cleared so the driver does not retain engines it cannot
+ // service.
+ LOG.error("Could not start SF flock-release retry driver; a retried close() "
+ + "or pool re-probe re-arms the retry [slot={}, error={}]",
+ sfDir == null ? "" : sfDir, String.valueOf(startFailure));
+ }
+ }
+
+ /**
+ * Whether {@link #close()} completed all cleanup, including a
+ * confirmed release of the SF slot lock — the flip is published
+ * strictly after explicit unlock succeeds, so observing {@code true}
+ * guarantees the slot dir is acquirable by a replacement engine. A false
+ * value after close means manager-worker quiescence could not be
+ * confirmed (or the flock release itself failed) and the
+ * worker-reachable resources were retained deliberately — either handed to the worker's exit path (owned manager),
+ * which flips this to true the moment the worker's in-flight pass
+ * finishes, or leaked until a retried close() (shared manager). Owners
+ * must not reuse the slot while this is false; pools may re-probe it to
+ * recover a retired slot's capacity once it flips.
+ *
+ * Deliberately unsynchronized ({@code closeCompleted} is volatile): pools
+ * probe this under their own capacity lock, and the deferred cleanup can
+ * hold the engine monitor through munmap/unlink syscalls — a synchronized
+ * getter would stall the pool's hot borrow path behind them.
+ */
+ public boolean isCloseCompleted() {
+ return closeCompleted;
+ }
+
+ /**
+ * Registers a callback for confirmed SF slot-lock release. If release
+ * already completed, invokes the callback before returning.
+ */
+ public void setSlotLockReleaseListener(Runnable listener) {
+ slotLockReleaseListener = listener;
+ if (listener != null && closeCompleted) {
+ listener.run();
+ }
+ }
+
+ /**
+ * Re-arms the shared terminal retry for an engine whose final watermark
+ * barrier or confirmed flock release is still pending and no longer
+ * scheduled because the retry driver thread failed to start (e.g. OOM at
+ * thread creation). A close still waiting for worker quiescence cannot be
+ * retried here because terminal cleanup may not race that worker.
+ * {@code Sender.close()} is one-shot by contract, so pool probes
+ * ({@code QwpWebSocketSender.isSlotLockReleased()}) call this to keep a
+ * retired slot's capacity recoverable instead of lost until process
+ * exit. Cheap unless the engine is in that orphan state (volatile reads,
+ * then one failed CAS when the retry is already scheduled), so probes
+ * may call it under their capacity lock.
+ */
+ public void ensureFlockReleaseRetryScheduled() {
+ if (!closeCompleted && (terminalResourcesCleaned || terminalCleanupRetryReady)) {
+ startFlockReleaseRetry();
}
}
@@ -569,6 +1272,10 @@ public MmapSegment findSegmentContaining(long fsn) {
return ring.findSegmentContaining(fsn);
}
+ MmapSegment pinSegmentContaining(long fsn) {
+ return ring.pinSegmentContaining(fsn);
+ }
+
/**
* Pass-through to {@link SegmentRing#firstSealed()}.
*/
@@ -593,6 +1300,14 @@ public MmapSegment nextSealedAfter(MmapSegment current) {
return ring.nextSealedAfter(current);
}
+ MmapSegment advancePinnedSegment(MmapSegment current) {
+ return ring.advancePinnedSegment(current);
+ }
+
+ void releasePinnedSegment(MmapSegment current) {
+ ring.releasePinnedSegment(current);
+ }
+
/**
* I/O thread accessor: highest FSN whose frame is fully written.
*/
@@ -660,36 +1375,138 @@ public long recoveredOrphanTipFsn() {
}
/**
- * Unlinks every {@code .sfa} file under {@code dir}. Called only on
- * clean shutdown when the ring confirms every published FSN has been
- * acked — at that moment the slot has no recoverable work and the
- * files are pure noise that would mislead the next sender's recovery.
- * Best-effort: logs and continues on failures, since we're already on
- * the close path.
+ * Ascending removal rank of a segment file name for
+ * {@link #unlinkAllSegmentFiles(String)}. {@code sf-initial.sfa} is
+ * always the fresh-start segment at baseSeq 0, so it ranks first.
+ * Spare files carry a monotonic generation ({@code sf-.sfa})
+ * assigned in creation == rotation == baseSeq order, so the parsed
+ * generation ranks them. Anything else with the extension is not a
+ * live segment and ranks last.
+ */
+ private static long segmentCleanupRank(String name) {
+ if ("sf-initial.sfa".equals(name)) {
+ return Long.MIN_VALUE;
+ }
+ if (name.length() == 23 && name.startsWith("sf-")) {
+ try {
+ return Long.parseUnsignedLong(name.substring(3, 19), 16);
+ } catch (NumberFormatException ignored) {
+ // fall through to the unrecognized-name rank
+ }
+ }
+ return Long.MAX_VALUE;
+ }
+
+ /**
+ * Unlinks every {@code .sfa} file under {@code dir}, then the SF
+ * manifest. Called only on clean shutdown when the ring confirms every
+ * published FSN has been acked — at that moment the slot has no
+ * recoverable work and the files are pure noise that would mislead the
+ * next sender's recovery.
+ *
+ * Crash safety comes from a single durable manifest update committed
+ * BEFORE the first unlink: collapsing the boundaries to
+ * {@code head == active} declares every frame below the active base
+ * trimmed, so any subset of surviving files recovers cleanly (stale
+ * files are discarded, a surviving active recovers as the whole chain,
+ * zero files with a leftover manifest is the recognized drain window
+ * and recovers as EMPTY). Removal still runs in ascending segment order
+ * and STOPS at the first failure; the retained ack watermark (== the
+ * final acked FSN) covers every surviving frame, so a successor replays
+ * nothing. Legacy slots without a manifest keep the pre-manifest
+ * argument: ascending-order removal preserves a contiguous top slice
+ * that passes FSN-contiguity.
+ *
+ * @return {@code true} only when enumeration succeeded and every
+ * {@code .sfa} file was confirmed removed — the caller keeps the ack
+ * watermark on {@code false} so residual acknowledged segments stay
+ * covered.
*/
- private static void unlinkAllSegmentFiles(String dir) {
- if (!io.questdb.client.std.Files.exists(dir)) return;
- long find = io.questdb.client.std.Files.findFirst(dir);
+ private static boolean unlinkAllSegmentFiles(FilesFacade filesFacade, String dir) {
+ if (!filesFacade.exists(dir)) return true;
+ long find = filesFacade.findFirst(dir);
if (find < 0) {
LOG.warn("close-time unlink could not enumerate {}; "
+ "any residual sf-*.sfa files will be picked up by the next recovery", dir);
- return;
+ return false;
}
- if (find == 0) return;
+ if (find == 0) return true;
+ ArrayList names = new ArrayList<>();
+ int rc = 1;
try {
- int rc = 1;
while (rc > 0) {
String name = io.questdb.client.std.Files.utf8ToString(
- io.questdb.client.std.Files.findName(find));
- rc = io.questdb.client.std.Files.findNext(find);
- if (name == null || !name.endsWith(".sfa")) continue;
- String path = dir + "/" + name;
- if (!io.questdb.client.std.Files.remove(path)) {
- LOG.warn("Failed to unlink fully-acked segment {} on close", path);
+ filesFacade.findName(find));
+ rc = filesFacade.findNext(find);
+ if (name != null && name.endsWith(".sfa")) {
+ names.add(name);
}
}
} finally {
- io.questdb.client.std.Files.findClose(find);
+ filesFacade.findClose(find);
+ }
+ if (rc < 0) {
+ // A partial listing must not drive any unlink: removing only the
+ // files we happened to see could delete the segment holding the
+ // highest frame while a lower one survives, leaving residual
+ // state the retained watermark can no longer vouch for.
+ LOG.warn("close-time unlink could not fully enumerate {}; "
+ + "leaving all segment files for the next recovery", dir);
+ return false;
+ }
+ names.sort((a, b) -> {
+ int byRank = Long.compare(segmentCleanupRank(a), segmentCleanupRank(b));
+ return byRank != 0 ? byRank : a.compareTo(b);
+ });
+ // Manifest-aware drain protocol. Before removing anything, durably
+ // collapse the committed boundaries to head == active. From that
+ // moment EVERY crash state is a valid recovery input:
+ // - surviving data below the active base is "stale below head"
+ // (acked by definition here) and is discarded by recovery;
+ // - a surviving active-base segment recovers as the whole chain;
+ // - empty spares are validated extras;
+ // - zero segment files with the manifest still present is the
+ // recognized drain window and recovers as EMPTY.
+ // Without this barrier, deleting the file the manifest calls "head"
+ // would make the next startup fail on a slot that lost nothing.
+ // The manifest itself is removed last, after every segment unlink.
+ SfManifest manifest = null;
+ try {
+ try {
+ manifest = SfManifest.open(filesFacade, dir);
+ } catch (Throwable t) {
+ LOG.warn("close-time unlink could not read the SF manifest in {}; leaving "
+ + "all files for the next recovery", dir, t);
+ return false;
+ }
+ if (manifest != null && !names.isEmpty()) {
+ try {
+ long activeBase = manifest.activeBase();
+ manifest.update(activeBase, activeBase);
+ } catch (Throwable t) {
+ LOG.warn("close-time unlink could not commit drain boundaries in {}; "
+ + "leaving all files for the next recovery", dir, t);
+ return false;
+ }
+ }
+ for (int i = 0, n = names.size(); i < n; i++) {
+ String path = dir + "/" + names.get(i);
+ if (!filesFacade.remove(path)) {
+ LOG.warn("Failed to unlink fully-acked segment {} on close; stopping so the "
+ + "residual files stay a watermark-covered, manifest-consistent range", path);
+ return false;
+ }
+ }
+ } finally {
+ if (manifest != null) {
+ manifest.close();
+ }
+ }
+ if (!SfManifest.removeFile(filesFacade, dir)) {
+ // Safe to proceed: a manifest with zero segment files is the
+ // recognized drain window and recovers as EMPTY.
+ LOG.warn("could not remove SF manifest in {} after full segment cleanup", dir);
}
+ return true;
}
}
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 13a69f77..58c3d31e 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
@@ -49,6 +49,7 @@
import java.util.Arrays;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ThreadLocalRandom;
+import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.locks.LockSupport;
@@ -88,6 +89,29 @@ public final class CursorWebSocketSendLoop implements QuietCloseable {
* sub-second confirmation latency once the upload completes
* server-side. {@code 0} or negative disables the keepalive entirely.
*/
+ /**
+ * Bounded-await backstop for {@link #close()}: the maximum time close()
+ * waits for the I/O thread to stop (count down {@code shutdownLatch})
+ * before it loud-fails and delegates final teardown to the I/O thread's
+ * exit path. In the common case the round-2 in-flight connect cancellation
+ * (see {@link ConnectCancellation}) makes the I/O thread unwind and count
+ * the latch down within milliseconds, so this budget is never reached. It
+ * only fires in the pathological, astronomically-rare TOCTOU window where
+ * {@code cancel()}'s {@code closeTraffic()} lands between the pre-connect
+ * guard and native fd creation, so it is a no-op and the connect blocks the
+ * full OS SYN-retry (~60-130s per endpoint, possibly across several).
+ *
+ * {@code 30_000} ms is comfortably UNDER the sidecar's 120 s shutdown
+ * deadline (~4x headroom) yet ~1000x a healthy close's millisecond latch
+ * countdown, so it never prematurely abandons a legitimately draining
+ * close (the I/O thread only has to finish its current — now traffic-broken
+ * — native send/receive and run {@code ioLoop}'s finally). This bounds the
+ * WAIT inside close(), NOT the connect itself (rejected Option A): a
+ * legitimately slow SUCCESSFUL connect is unaffected — it is not concurrent
+ * with a close, and when close() does fire it cancels the in-flight connect
+ * rather than waiting it out.
+ */
+ public static final long DEFAULT_CLOSE_SHUTDOWN_AWAIT_MILLIS = 30_000L;
public static final long DEFAULT_DURABLE_ACK_KEEPALIVE_INTERVAL_MILLIS = 200L;
public static final long DEFAULT_PARK_NANOS = 50_000L; // 50us idle backoff
/**
@@ -163,6 +187,11 @@ public final class CursorWebSocketSendLoop implements QuietCloseable {
// the durable watermark can lag behind the OK watermark.
private final ArrayDeque pendingDurable = new ArrayDeque<>();
private final ArrayDeque pendingDurablePool = new ArrayDeque<>();
+ // Race-safe cancellation handle for an in-flight connect attempt. Passed
+ // into reconnectFactory.reconnect(...) on the I/O thread so close() can
+ // break a connect blocked mid-attempt (a black-holed native connect that
+ // neither unpark nor interrupt cancels). See ConnectCancellation.
+ private final ConnectCancellation connectCancellation = new ConnectCancellation();
// Optional reconnect plumbing. When non-null, a wire failure triggers a
// reconnect attempt instead of a terminal fail(). The factory produces a
// fresh, connected+upgraded WebSocketClient.
@@ -200,7 +229,7 @@ public final class CursorWebSocketSendLoop implements QuietCloseable {
// by category. Includes both retriable and terminal outcomes — i.e. every
// server-side rejection observed regardless of how the loop reacted.
private final AtomicLong totalServerErrors = new AtomicLong();
- private WebSocketClient client;
+ private volatile WebSocketClient client;
// Optional: when non-null, every server-rejection error (retriable and
// terminal alike) is offered to the dispatcher for async delivery to the user's
// handler. Null disables async delivery entirely; the producer-side
@@ -225,6 +254,12 @@ public final class CursorWebSocketSendLoop implements QuietCloseable {
// it is engine.ackedFsn() + 1, so the first replayed frame on the new
// connection is wireSeq=0 and server-side cumulative ACKs still line up.
private long fsnAtZero;
+ // Bounded-await backstop budget for close() (see
+ // DEFAULT_CLOSE_SHUTDOWN_AWAIT_MILLIS). Overridable via
+ // setShutdownAwaitTimeoutMillis so tests can exercise the timeout branch
+ // deterministically without a multi-second real wait; production always
+ // uses the default. Read only on the owner thread inside close().
+ private long shutdownAwaitTimeoutMillis = DEFAULT_CLOSE_SHUTDOWN_AWAIT_MILLIS;
// Sticky flag: false until the very first time a live client is installed
// (either via the constructor in SYNC/OFF mode or via swapClient on a
// successful connect attempt in any mode). Once true, stays true. Used to
@@ -244,11 +279,12 @@ public final class CursorWebSocketSendLoop implements QuietCloseable {
// terminalError: the only writer runs on the I/O thread under the same
// first-writer-wins latch.
private volatile QwpDurableAckMismatchException capabilityGapTerminal;
- // Failed-stop hand-off flag: set by delegateEngineClose() when an owner's
- // close() could not stop the I/O thread and the engine close is therefore
- // performed by the I/O thread's exit path. Write-once, owner thread only;
- // read by the I/O thread strictly after its shutdown-latch countdown (see
- // the handshake contract on delegateEngineClose).
+ // Failed-stop hand-off callback: set when an owner could not stop the I/O
+ // thread and must defer worker-reachable cleanup until the thread exits.
+ // Read strictly after shutdownLatch.countDown(); see delegateClose().
+ private volatile Runnable delegatedClose;
+ // Engine-only hand-off retained for BackgroundDrainer, whose remaining
+ // resources are owned by its run method rather than by a sender.
private volatile boolean engineCloseDelegated;
// The latched terminal failure — THE exception every checkError() call
// rethrows. Write-once for the loop's lifetime: the only writer is
@@ -268,11 +304,11 @@ public final class CursorWebSocketSendLoop implements QuietCloseable {
private long nextWireSeq;
private volatile SenderProgressDispatcher progressDispatcher;
// Frames sent during the post-reconnect catch-up window — i.e. frames
- // whose FSN was already published before the wire dropped. A non-zero
+ // whose FSN completed a send on an earlier connection. A non-zero
// value confirms replay is working; a sustained nonzero rate means
// the connection is flapping and replay is doing real work each cycle.
- // Set at swapClient time to publishedFsn at that moment; cleared back
- // to -1 once trySendOne has caught up past it. Used to count replay
+ // Snapshotted when an established connection enters reconnect; cleared
+ // back to -1 once trySendOne has caught up past it. Used to count replay
// frames without a per-frame branch on the steady-state path.
private long replayTargetFsn = -1L;
// Recovered orphaned deferred tail: frames [orphanSkipStartFsn ..
@@ -763,8 +799,74 @@ public synchronized void close() {
// would block forever. isAlive()==false also covers the normal
// post-exit case where the latch is already counted down.
if (t.isAlive()) {
+ // Break a native send/receive before joining. Full client close
+ // must remain after the worker exit because it frees buffers the
+ // worker may still access.
+ WebSocketClient activeClient = client;
+ if (activeClient != null) {
+ try {
+ activeClient.closeTraffic();
+ } catch (Throwable e) {
+ // A custom transport that cannot safely cancel traffic
+ // must not be destructively closed while the worker is
+ // live. Fail without joining indefinitely; the worker's
+ // exit path retains ownership of final cleanup.
+ throw new LineSenderException(
+ "cursor I/O thread did not stop: active transport does not support safe traffic shutdown; "
+ + "client/engine teardown is delegated to the I/O thread's exit path",
+ e
+ );
+ }
+ }
+ // Cancel an in-flight connect attempt not yet installed as the
+ // `client` field: async initial connect leaves `client` null
+ // and a mid-flight reconnect leaves it pointing at the stale
+ // pre-drop client, so neither is reachable by the closeTraffic()
+ // above. The reconnect factory publishes the connecting client
+ // here before it blocks, so breaking its traffic unwinds a
+ // black-holed native connect (connect_timeout=0 => OS SYN-retry,
+ // ~60-130s) that would otherwise pin the untimed await below.
+ // Same loud-fail contract as the field client: a transport that
+ // cannot safely shut down traffic is not destructively closed;
+ // the worker's exit path retains ownership of final cleanup.
+ try {
+ connectCancellation.cancel();
+ } catch (Throwable e) {
+ throw new LineSenderException(
+ "cursor I/O thread did not stop: active transport does not support safe traffic shutdown; "
+ + "client/engine teardown is delegated to the I/O thread's exit path",
+ e
+ );
+ }
try {
- shutdownLatch.await();
+ // Bounded backstop: never wait forever. The round-2 in-flight
+ // connect cancellation makes the I/O thread unwind and count
+ // the latch down in milliseconds, so this await returns true
+ // almost always. The timeout branch fires only in the
+ // pathological uninterruptible-connect TOCTOU window where
+ // cancel()'s closeTraffic() was a no-op and the connect blocks
+ // the full OS SYN-retry -- guaranteeing close() still returns
+ // (bounded, comfortably under the sidecar's 120s deadline)
+ // rather than hanging on an untimed await.
+ if (!shutdownLatch.await(shutdownAwaitTimeoutMillis, TimeUnit.MILLISECONDS)) {
+ // Latch still up after the budget: the I/O thread did not
+ // stop. Same failed-stop protocol as the interrupt branch
+ // below -- loud-fail without touching the client/engine
+ // (freeing native buffers under a still-live I/O thread
+ // risks a C5 SEGV; a quiet return would let the owner
+ // unmap the engine under it). The I/O thread's own exit
+ // path (ioLoop's finally) retains ownership of final
+ // cleanup; QwpWebSocketSender.close() keys its
+ // ioThreadStopped guard on this throw and BackgroundDrainer
+ // switches to delegateEngineClose(). ioThread stays set
+ // (not nulled below, since we throw) so a duplicate close()
+ // re-signals rather than silently succeeding.
+ throw new LineSenderException(
+ "cursor I/O thread did not stop: close() timed out after "
+ + shutdownAwaitTimeoutMillis + "ms awaiting shutdown; "
+ + "client/engine teardown is delegated to the I/O "
+ + "thread's exit path");
+ }
} catch (InterruptedException e) {
// Re-assert the flag for the caller's stack, then decide.
// If the I/O thread has genuinely not exited (latch still
@@ -794,6 +896,9 @@ public synchronized void close() {
}
ioThread = null;
}
+ // Covers close-before-start and start failures; normal I/O-thread
+ // exit already released the same pin in its finally block.
+ releaseSendingSegment();
// Close the current client. After a reconnect, swapClient has
// replaced the original (and closed it); the owner only retains
// the stale pre-reconnect reference. Without closing the live
@@ -815,27 +920,20 @@ public synchronized void close() {
}
/**
- * Failed-stop hand-off for the engine. Called by an owner whose
- * {@link #close()} threw because the I/O thread would not stop: the owner
- * must not free the engine (munmap/Unsafe.free of segment memory) while
- * the thread may still touch it with raw {@code Unsafe} reads. Setting
- * the delegation flag makes the I/O thread run {@code engine.close()} on
- * its exit path, strictly after its last engine access and after the
- * shutdown-latch countdown — releasing the slot lock as soon as the
- * stuck wire call resolves (bounded by OS timeouts) instead of leaking
- * the mapping and lock forever.
- *
- * Returns {@code true} when the I/O thread is still live and has adopted
- * the engine close; {@code false} when the thread has already exited —
- * the caller must close the engine itself.
- *
- * Memory model — the classic store/load handshake: this method writes the
- * volatile flag, then reads the latch count; the exit path counts the
- * latch down, then reads the flag. Under the sequential consistency of
- * volatile (and AQS latch state) accesses, if this method observes the
- * latch still up, the exit path is guaranteed to observe the flag — no
- * missed close. If both sides act, {@link CursorSendEngine#close()} is
- * synchronized and idempotent, so the double close is benign.
+ * Hands complete owner cleanup to the I/O thread when it could not be
+ * stopped. The callback runs after the thread's last client, buffer, and
+ * engine access. Returns false if the thread already exited, in which case
+ * the caller must run the callback. The callback itself must be idempotent:
+ * the latch/callback handshake guarantees execution but permits both sides
+ * to race after the countdown.
+ */
+ public boolean delegateClose(Runnable closeCallback) {
+ delegatedClose = closeCallback;
+ return shutdownLatch.getCount() != 0L;
+ }
+
+ /**
+ * Engine-only failed-stop hand-off used by BackgroundDrainer.
*/
public boolean delegateEngineClose() {
engineCloseDelegated = true;
@@ -983,6 +1081,17 @@ public void setProgressDispatcher(SenderProgressDispatcher dispatcher) {
this.progressDispatcher = dispatcher;
}
+ /**
+ * Test seam: shrink the {@link #close()} bounded-await backstop
+ * (default {@link #DEFAULT_CLOSE_SHUTDOWN_AWAIT_MILLIS}) so a test can
+ * exercise the timeout branch deterministically without a multi-second
+ * real wait. Production never calls this. Set before {@link #close()}.
+ */
+ @TestOnly
+ public void setShutdownAwaitTimeoutMillis(long millis) {
+ this.shutdownAwaitTimeoutMillis = millis;
+ }
+
public synchronized void start() {
if (ioThread != null) {
throw new IllegalStateException("already started");
@@ -996,17 +1105,24 @@ public synchronized void start() {
// walks back to the lowest unacked frame so sealed-segment data
// actually reaches the wire — without it, start() would skip
// straight to the active and orphan everything in sealed.
- positionCursorForStart();
+ try {
+ positionCursorForStart();
+ } catch (Throwable t) {
+ running = false;
+ releaseSendingSegment();
+ shutdownLatch.countDown();
+ throw t;
+ }
Thread t = new Thread(this::ioLoop, "qdb-cursor-ws-io");
t.setDaemon(true);
try {
t.start();
} catch (Throwable th) {
// Thread.start() failed (e.g. native stack alloc OOM). ioLoop
- // never ran, so its finally{shutdownLatch.countDown()} never
- // fires. Release the latch and reset state so a subsequent
- // close() doesn't block on a thread that doesn't exist.
+ // never ran, so its finally block cannot release the segment pin
+ // or count down the latch.
running = false;
+ releaseSendingSegment();
shutdownLatch.countDown();
throw th;
}
@@ -1026,35 +1142,17 @@ private PendingDurableEntry acquirePendingEntry() {
* else the active). Returns the same segment if it's still being written
* (we're on the active and just need to wait for more publishedFsn).
*
- * Uses {@link CursorSendEngine#nextSealedAfter} so we never have to
- * snapshot the full sealed list — important when the producer outpaces
- * the I/O thread and the sealed list can grow to thousands of entries
- * (cursor SF lets the producer fan out at memory speed; the wire path
- * catches up at WebSocket speed).
+ * The ring switches the single I/O pin atomically with choosing the next
+ * segment, so trim can never unmap either side of the handoff. No sealed
+ * list snapshot is needed when the producer outpaces the wire path.
*/
private MmapSegment advanceSegment() {
MmapSegment current = sendingSegment;
- MmapSegment liveActive = engine.activeSegment();
- if (current == liveActive) {
- // We're on the active — there's no "next", just wait for more
- // bytes to be published into it. Caller's sendOne will see
- // publishedOffset > sendOffset eventually and resume.
- return current;
+ MmapSegment next = engine.advancePinnedSegment(current);
+ if (next != current) {
+ sendOffset = MmapSegment.HEADER_SIZE;
}
- sendOffset = MmapSegment.HEADER_SIZE;
- MmapSegment next = engine.nextSealedAfter(current);
- if (next != null) {
- return next;
- }
- // current was the newest sealed (no later sealed exists). If it's
- // still in the sealed list, the next segment must be the active;
- // if it's been trimmed out from under us, fall back to the oldest
- // remaining sealed before resorting to the active.
- next = engine.firstSealed();
- if (next != null && next.baseSeq() > current.baseSeq()) {
- return next;
- }
- return liveActive;
+ return next;
}
private void applyDurableAck() {
@@ -1114,10 +1212,14 @@ private void clearDurableAckTracking() {
* before the first connect attempt — see {@link #failPaced}.
*/
private void connectLoop(Throwable initial, String phase, long paceFirstAttemptMillis) {
- if (reconnectFactory == null || !running) {
+ if (!running) {
+ return;
+ }
+ if (reconnectFactory == null) {
recordFatal(initial);
return;
}
+ snapshotReplayTarget();
LOG.warn("cursor I/O loop entering {} loop: {}",
phase, initial.getMessage());
long outageStartNanos = System.nanoTime();
@@ -1156,7 +1258,7 @@ private void connectLoop(Throwable initial, String phase, long paceFirstAttemptM
attempts++;
totalReconnectAttempts.incrementAndGet();
try {
- WebSocketClient newClient = reconnectFactory.reconnect();
+ WebSocketClient newClient = reconnectFactory.reconnect(connectCancellation);
if (newClient != null) {
if (!running) {
// close() ran while this connect attempt was in
@@ -1650,6 +1752,9 @@ private void ioLoop() {
}
}
} finally {
+ // Release native-segment lifetime before publishing I/O-thread
+ // completion or running delegated engine cleanup.
+ releaseSendingSegment();
// Last act of the I/O thread: dispose of whatever client it
// holds. This is the airtight half of the close()-vs-reconnect
// race — when close()'s latch await is interrupted (drainer pool
@@ -1670,15 +1775,14 @@ private void ioLoop() {
}
}
shutdownLatch.countDown();
- // Failed-stop hand-off (see delegateEngineClose): the owner could
- // not free the engine safely while this thread was alive, so the
- // engine close — and with it the slot-lock release — happens
- // here, strictly after this thread's last engine access. The flag
- // is read only after the countDown: the store/load pairing with
- // delegateEngineClose's flag-write-then-latch-read guarantees
- // either this branch or the owner's fallback runs (or both —
- // engine.close() is idempotent).
- if (engineCloseDelegated) {
+ Runnable closeCallback = delegatedClose;
+ if (closeCallback != null) {
+ try {
+ closeCallback.run();
+ } catch (Throwable ignored) {
+ // The owner callback logs individual cleanup failures.
+ }
+ } else if (engineCloseDelegated) {
try {
engine.close();
} catch (Throwable ignored) {
@@ -1700,7 +1804,7 @@ private void ioLoop() {
* loop will then wait until the producer publishes more bytes.
*/
private void positionCursorAt(long targetFsn) {
- MmapSegment seg = engine.findSegmentContaining(targetFsn);
+ MmapSegment seg = engine.pinSegmentContaining(targetFsn);
if (seg == null) {
// No segment currently advertises targetFsn. That normally means
// targetFsn is just past publishedFsn and there is nothing to
@@ -1709,14 +1813,14 @@ private void positionCursorAt(long targetFsn) {
// The producer is concurrent with this I/O thread, though. It can
// publish targetFsn after the first findSegmentContaining() returns
// null but before or during the active-tip snapshot below.
- sendingSegment = engine.activeSegment();
+ sendingSegment = engine.pinActiveSegment();
sendOffset = sendingSegment.publishedOffset();
// The publishedOffset read is the producer's volatile publish
// barrier. If it saw the new frame bytes, the frameCount write that
// makes targetFsn discoverable is also visible, so a second lookup
// must now find it. If the producer publishes later, sendOffset is
// still at the old tip and trySendOne() will send the frame normally.
- seg = engine.findSegmentContaining(targetFsn);
+ seg = engine.pinSegmentContaining(targetFsn);
if (seg != null) {
positionCursorInSegment(seg, targetFsn);
}
@@ -1796,6 +1900,14 @@ private void releasePendingEntry(PendingDurableEntry e) {
pendingDurablePool.addFirst(e);
}
+ private void releaseSendingSegment() {
+ MmapSegment segment = sendingSegment;
+ if (segment != null) {
+ engine.releasePinnedSegment(segment);
+ sendingSegment = null;
+ }
+ }
+
/**
* Send a WebSocket PING to prod the server into flushing pending
* STATUS_DURABLE_ACK frames, but only when the throttle interval has
@@ -1823,6 +1935,23 @@ private void sendDurableAckKeepaliveIfDue() {
}
}
+ /**
+ * Freezes the highest FSN that completed a send on the connection which
+ * just failed. Frames published before the first connection, or while a
+ * reconnect is in progress, have not been sent and therefore do not belong
+ * to the replay metric. Keep an older, higher target when another failure
+ * interrupts catch-up so the remaining frames still count as re-sends on
+ * the following connection.
+ */
+ private void snapshotReplayTarget() {
+ if (hasEverConnected && nextWireSeq > 0L) {
+ long lastSentFsn = fsnAtZero + (nextWireSeq - 1L);
+ if (lastSentFsn > replayTargetFsn) {
+ replayTargetFsn = lastSentFsn;
+ }
+ }
+ }
+
/**
* Reset wire state for a fresh connection: install the new client,
* realign {@code fsnAtZero} to the next unacked FSN, restart wire
@@ -1851,11 +1980,12 @@ private void swapClient(WebSocketClient newClient) {
long replayStart = engine.ackedFsn() + 1L;
this.fsnAtZero = replayStart;
this.nextWireSeq = 0L;
- // Snapshot publishedFsn at swap time — frames at FSN ≤ this value
- // were already on the wire before the drop and will be replayed.
- // trySendOne resets replayTargetFsn to -1 once we cross the boundary.
- long pubAtSwap = engine.publishedFsn();
- this.replayTargetFsn = pubAtSwap >= replayStart ? pubAtSwap : -1L;
+ // snapshotReplayTarget froze the completed-send boundary when the
+ // outage began. ACK progress can move replayStart past that boundary;
+ // in that case no frame needs re-sending on this connection.
+ if (replayTargetFsn < replayStart) {
+ replayTargetFsn = -1L;
+ }
// Drop any durable-ack tracking from the previous connection. The
// new connection will re-OK every replayed batch and the server
// re-emits cumulative durable-ack watermarks from scratch, so
@@ -2034,6 +2164,104 @@ private boolean tryRetireOrphanTail() {
@FunctionalInterface
public interface ReconnectFactory {
WebSocketClient reconnect() throws Exception;
+
+ /**
+ * Cancellable variant of {@link #reconnect()}. The loop passes a
+ * per-attempt {@link ConnectCancellation} so a transport that blocks
+ * inside a native connect can publish the in-flight client to the
+ * handle BEFORE it blocks; {@link #close()} then breaks that client's
+ * traffic to unwind a black-holed connect promptly, rather than falling
+ * back to the bounded {@code shutdownLatch.await(...)} backstop (which
+ * only bounds the WAIT, not the connect).
+ *
+ * Default: ignore the handle and delegate to {@link #reconnect()} --
+ * a transport that cannot publish its in-flight client is simply not
+ * cancellable mid-connect, and {@code close()} falls back to its
+ * existing field-client {@code closeTraffic()} / loud-fail path. The
+ * default keeps this a {@link FunctionalInterface} and preserves
+ * source compatibility for existing lambda / method-reference
+ * implementors (e.g. {@link #connectWithRetry}).
+ */
+ default WebSocketClient reconnect(ConnectCancellation cancellation) throws Exception {
+ return reconnect();
+ }
+ }
+
+ /**
+ * Race-safe cancellation handle for a single in-flight connect attempt.
+ * Owned per-loop and passed into {@link ReconnectFactory#reconnect(ConnectCancellation)}
+ * on the I/O thread. The transport's connect walk {@link #publish}es the
+ * {@link WebSocketClient} it is about to block on before the blocking
+ * {@code connect()}, and {@link #clear}s it once the attempt is installed
+ * or disposed. {@link #close()} (owner thread) calls {@link #cancel()} to
+ * break the in-flight client's traffic path so a black-holed native
+ * connect unwinds and the I/O thread counts down the shutdown latch.
+ *
+ * Java 8: two volatiles, no locks. Visibility/ordering:
+ *
+ *
{@code inFlight} is written by the I/O thread (publish/clear) and
+ * read by the owner thread (cancel).
+ *
{@code cancelled} is written by the owner thread (cancel) and read
+ * by the I/O thread (the pre-connect guard).
+ *
+ * The publish (write inFlight, then read cancelled) / cancel (write
+ * cancelled, then read inFlight) handshake covers the close-vs-connect
+ * race in both directions: if the guard observes {@code cancelled==false}
+ * then, by the volatile total order, {@code cancel()} observed the
+ * published client and broke its traffic; if the guard observes
+ * {@code cancelled==true} it skips the blocking connect entirely.
+ */
+ public static final class ConnectCancellation {
+ // The client the connect walk is currently about to block / blocked
+ // on. Written by the I/O thread only (publish/clear); read by the
+ // owner thread (cancel). volatile for cross-thread visibility.
+ private volatile WebSocketClient inFlight;
+ // Latched once close() requested cancellation. Written by the owner
+ // thread (cancel); read by the I/O thread's pre-connect guard.
+ private volatile boolean cancelled;
+
+ public boolean isCancelled() {
+ return cancelled;
+ }
+
+ /**
+ * I/O-thread hook: record the client the walk is about to block on,
+ * BEFORE the blocking {@code connect()}. Pairs with {@link #cancel()}
+ * so an attempt that publishes after cancellation is caught by the
+ * caller's {@link #isCancelled()} guard, and an attempt already
+ * blocked in {@code connect()} is broken by {@code cancel()}.
+ */
+ public void publish(WebSocketClient client) {
+ inFlight = client;
+ }
+
+ /**
+ * I/O-thread hook: the walk is done with the in-flight attempt
+ * (installed on success, disposed on failure). Drop the reference so a
+ * later {@link #cancel()} cannot touch a client the walk no longer
+ * owns. Single writer (I/O thread) for publish/clear, so no CAS is
+ * needed.
+ */
+ public void clear() {
+ inFlight = null;
+ }
+
+ /**
+ * Owner-thread hook from {@link #close()}: request cancellation, then
+ * break the traffic path of any in-flight connect so the I/O thread's
+ * connect walk unwinds. {@code cancelled} is written before reading
+ * {@code inFlight} to pair with {@link #publish(WebSocketClient)}. May
+ * throw when the transport cannot safely shut down traffic --
+ * {@code close()} maps that to its existing loud-fail, exactly as it
+ * does for the field client's {@code closeTraffic()}.
+ */
+ void cancel() {
+ cancelled = true;
+ WebSocketClient c = inFlight;
+ if (c != null) {
+ c.closeTraffic();
+ }
+ }
}
/**
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 78e5db9f..edf70252 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
@@ -31,9 +31,12 @@
import io.questdb.client.std.Os;
import io.questdb.client.std.QuietCloseable;
import io.questdb.client.std.Unsafe;
+import org.jetbrains.annotations.TestOnly;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import java.util.concurrent.atomic.AtomicBoolean;
+
/**
* One mmap-backed SF segment file. The user thread (the single producer)
* appends frames into the mapping; the I/O thread (the single consumer) reads
@@ -54,19 +57,27 @@
*
* The mapping is sized at construction and never grows. When
* {@link #tryAppend} returns -1 the caller must rotate to a fresh segment.
- * Closing the segment unmaps and closes the fd; data already written is
- * durable under the page cache (and recoverable across JVM restarts) — call
- * {@link #msync} for OS-crash durability.
+ * Closing the segment unmaps and closes the fd. Dirty mapped pages normally
+ * survive a producer-process restart in the OS page cache, but that is not a
+ * host-power-loss guarantee. {@link #msync} preserves the legacy mmap-only
+ * flush API; use the checked {@link #syncPublished()} mapping-plus-fd barrier
+ * for portable power-loss durability.
*/
public final class MmapSegment implements QuietCloseable {
public static final int FILE_MAGIC = 0x31304653; // 'SF01' little-endian
public static final int FRAME_HEADER_SIZE = 8; // u32 crc + u32 payloadLen
public static final int HEADER_SIZE = 24;
+ public static final byte MANIFEST_REQUIRED_FLAG = 1;
public static final byte VERSION = 1;
- private static final int[] CRC32C_TABLE = buildCrc32cTable();
private static final Logger LOG = LoggerFactory.getLogger(MmapSegment.class);
+ // Deduplicates the process-wide "mlock refused" warning: the refusal is a
+ // soft downgrade (see syncPublished) and must not spam the log once per
+ // barrier when RLIMIT_MEMLOCK or the platform says no.
+ private static final AtomicBoolean MLOCK_REFUSAL_WARNED = new AtomicBoolean();
+ private static final int RECOVERY_BUFFER_SIZE = 64 * 1024;
+ private final FilesFacade filesFacade;
private final String path;
private final long sizeBytes;
// memoryBacked: true when the segment buffer lives in malloc'd native
@@ -82,6 +93,10 @@ public final class MmapSegment implements QuietCloseable {
// segment manager pre-creates spares before the producer knows the exact
// baseSeq the new active will need.
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
+ // allowing rotation to seal the segment.
+ private volatile long durableCursor;
private int fd;
// frameCount: number of frames successfully appended. Single writer (the
// producer thread in tryAppend); read cross-thread by the I/O thread via
@@ -95,16 +110,34 @@ public final class MmapSegment implements QuietCloseable {
// because the consumer must see writes in publication order — once the
// producer bumps publishedCursor, every byte before it is fully written.
private volatile long publishedCursor;
+ // Number of failure-arm re-dirty passes performed by syncPublished.
+ // Cold-path only; volatile so tests observing from another thread see it.
+ private volatile long redirtyPasses;
+ // Monotonic in-memory link to the segment that immediately follows this
+ // one. SegmentRing publishes it before promoting the successor to active;
+ // close deliberately retains it so a cursor can advance after head trim.
+ private volatile MmapSegment successor;
// Bytes between the last valid frame and the file end that look like an
- // attempted-but-invalid frame write (non-zero bytes at the bail-out
- // position). Zero for fresh segments and for cleanly partially-filled
+ // attempted-but-invalid frame write (the suffix contains non-zero bytes).
+ // Zero for fresh segments and for cleanly partially-filled
// segments (uninitialised tail). Set only by openExisting; visible to
- // recovery callers for diagnostics. Final after construction.
+ // recovery callers for diagnostics. openExisting only OBSERVES the
+ // residue -- it never mutates the file: after a mid-file tear the suffix
+ // can hold unreachable valid-CRC frames that are the only surviving copy
+ // of real payloads. Ring recovery decides after the chain fully validates
+ // whether to zero it (sanitizeTornTail on the resumed active or on a
+ // proven-dead sealed suffix) or to fail closed leaving the bytes on disk
+ // for operator extraction. Final after construction.
private final long tornTailBytes;
+ // Set once sanitizeTornTail has durably zeroed the observed residue.
+ // Written and read only on the recovery path (single-threaded, before
+ // the segment is published to producer/consumer threads).
+ private boolean tornTailSanitized;
- private MmapSegment(String path, int fd, long mmapAddress, long sizeBytes,
+ private MmapSegment(FilesFacade filesFacade, String path, int fd, long mmapAddress, long sizeBytes,
long baseSeq, long initialCursor, long frameCount,
boolean memoryBacked, long tornTailBytes) {
+ this.filesFacade = filesFacade;
this.path = path;
this.fd = fd;
this.mmapAddress = mmapAddress;
@@ -112,6 +145,7 @@ private MmapSegment(String path, int fd, long mmapAddress, long sizeBytes,
this.baseSeq = baseSeq;
this.appendCursor = initialCursor;
this.publishedCursor = initialCursor;
+ this.durableCursor = memoryBacked ? initialCursor : HEADER_SIZE;
this.frameCount = frameCount;
this.memoryBacked = memoryBacked;
this.tornTailBytes = tornTailBytes;
@@ -145,9 +179,13 @@ public static MmapSegment create(String path, long baseSeq, long sizeBytes) {
* that filesystem only.
*/
public static MmapSegment create(FilesFacade ff, String path, long baseSeq, long sizeBytes) {
+ return create(ff, path, baseSeq, sizeBytes, false);
+ }
+
+ static MmapSegment create(FilesFacade ff, String path, long baseSeq, long sizeBytes, boolean manifestRequired) {
long pathPtr = ff.allocNativePath(path);
try {
- return create(ff, pathPtr, path, baseSeq, sizeBytes);
+ return create(ff, pathPtr, path, baseSeq, sizeBytes, manifestRequired);
} finally {
ff.freeNativePath(pathPtr);
}
@@ -162,13 +200,17 @@ public static MmapSegment create(FilesFacade ff, String path, long baseSeq, long
* per-call {@code byte[]} + native-malloc the way the String overload does.
*/
public static MmapSegment create(FilesFacade ff, long pathPtr, String displayPath, long baseSeq, long sizeBytes) {
+ return create(ff, pathPtr, displayPath, baseSeq, sizeBytes, false);
+ }
+
+ static MmapSegment create(FilesFacade ff, long pathPtr, String displayPath, long baseSeq, long sizeBytes, boolean manifestRequired) {
if (sizeBytes < HEADER_SIZE + FRAME_HEADER_SIZE + 1) {
throw new IllegalArgumentException(
"sizeBytes too small for header + one minimal frame: " + sizeBytes);
}
- int fd = ff.openCleanRW(pathPtr);
+ int fd = ff.openRWExclusive(pathPtr);
if (fd < 0) {
- throw new MmapSegmentException("openCleanRW failed for " + displayPath);
+ throw new MmapSegmentException("exclusive create failed for " + displayPath);
}
// Reserve real disk blocks and advance EOF to sizeBytes in one
// call. ENOSPC surfaces here, before the producer thread starts
@@ -186,21 +228,21 @@ public static MmapSegment create(FilesFacade ff, long pathPtr, String displayPat
}
long addr = Files.FAILED_MMAP_ADDRESS;
try {
- addr = Files.mmap(fd, sizeBytes, 0, Files.MAP_RW, MemoryTag.MMAP_DEFAULT);
+ addr = ff.mmap(fd, sizeBytes, 0, Files.MAP_RW, MemoryTag.MMAP_DEFAULT);
if (addr == Files.FAILED_MMAP_ADDRESS) {
throw new MmapSegmentException("mmap failed for " + displayPath);
}
// Header goes straight into the mapping — no separate write syscall.
Unsafe.getUnsafe().putInt(addr, FILE_MAGIC);
Unsafe.getUnsafe().putByte(addr + 4, VERSION);
- Unsafe.getUnsafe().putByte(addr + 5, (byte) 0); // flags
+ Unsafe.getUnsafe().putByte(addr + 5, manifestRequired ? MANIFEST_REQUIRED_FLAG : (byte) 0); // flags
Unsafe.getUnsafe().putShort(addr + 6, (short) 0); // reserved
Unsafe.getUnsafe().putLong(addr + 8, baseSeq);
Unsafe.getUnsafe().putLong(addr + 16, Os.currentTimeMicros());
- return new MmapSegment(displayPath, fd, addr, sizeBytes, baseSeq, HEADER_SIZE, 0, false, 0L);
+ return new MmapSegment(ff, displayPath, fd, addr, sizeBytes, baseSeq, HEADER_SIZE, 0, false, 0L);
} catch (Throwable t) {
if (addr != Files.FAILED_MMAP_ADDRESS) {
- Files.munmap(addr, sizeBytes, MemoryTag.MMAP_DEFAULT);
+ ff.munmap(addr, sizeBytes, MemoryTag.MMAP_DEFAULT);
}
ff.close(fd);
// mmap (or header writes) failed after a successful allocate —
@@ -234,7 +276,7 @@ public static MmapSegment createInMemory(long baseSeq, long sizeBytes) {
Unsafe.getUnsafe().putShort(addr + 6, (short) 0);
Unsafe.getUnsafe().putLong(addr + 8, baseSeq);
Unsafe.getUnsafe().putLong(addr + 16, Os.currentTimeMicros());
- return new MmapSegment(null, -1, addr, sizeBytes, baseSeq, HEADER_SIZE, 0, true, 0L);
+ return new MmapSegment(null, null, -1, addr, sizeBytes, baseSeq, HEADER_SIZE, 0, true, 0L);
} catch (Throwable t) {
Unsafe.free(addr, sizeBytes, MemoryTag.NATIVE_DEFAULT);
throw t;
@@ -242,111 +284,136 @@ public static MmapSegment createInMemory(long baseSeq, long sizeBytes) {
}
/**
- * Opens an existing segment file for recovery. mmaps it RW, validates the
- * header magic / version, then scans frames forward verifying each CRC.
+ * Opens an existing segment file for recovery. Validates the header and
+ * scans frames through positioned reads, then mmaps the validated file RW.
* The first bad CRC (or a frame whose declared length runs past the file
* end) is treated as a torn tail; both cursors are positioned at the
* start of that frame. Returns the segment ready for further appends.
* Throws {@link MmapSegmentException} on header validation failure.
*
- * If recovery observes a torn tail (the bytes at the bail-out position
- * are non-zero, indicating an attempted-but-failed frame write rather
- * than clean unwritten space), a {@code WARN} is emitted with the byte
- * count and the bytes are exposed via {@link #tornTailBytes()} so
- * operators can detect silent truncation from corruption or partial
- * writes. Clean partial fills (writer never attempted to write past the
- * last valid frame) do not log and report {@code 0}.
+ * If recovery observes a torn tail (the suffix after the last valid frame
+ * contains non-zero bytes, indicating an attempted-but-failed frame write
+ * rather than clean unwritten space), a {@code WARN} is emitted with the
+ * byte count and the observed size is exposed via {@link #tornTailBytes()}.
+ * The residue itself is NOT touched: after a mid-file tear the suffix can
+ * still hold unreachable frames with valid CRCs -- the only surviving copy
+ * of real payloads -- so whether it may be destroyed is a chain-level
+ * decision this method cannot make. {@link SegmentRing} recovery invokes
+ * {@link #sanitizeTornTail()} only once the chain has fully validated,
+ * and the justification differs by role: sealed suffixes are zeroed on
+ * PROOF (frame accounting validated complete, so the residue can hold no
+ * replayable frame -- a tear that cost frames fails closed instead, with
+ * every byte left on disk for operator extraction), while the resumed
+ * active's tail is zeroed by POLICY -- past a mid-file tear it can still
+ * hold valid-CRC frames of real unacked payloads, but they are
+ * unreachable by replay (the FSN sequence breaks at the tear) and
+ * leaving them risks the reseal-brick and stale-frame-resurrection
+ * hazards. Clean
+ * partial fills (writer never attempted to write past the last valid
+ * frame) do not log and report {@code 0}.
*/
public static MmapSegment openExisting(String path) {
return openExisting(FilesFacade.INSTANCE, path);
}
/**
- * Facade-aware variant of {@link #openExisting(String)} that takes the file
- * length (and open/close) through a {@link FilesFacade} instead of straight
- * off {@link Files}. Production uses {@link FilesFacade#INSTANCE}; the seam
- * exists so recovery's mmap-fault guard can be regression-tested on any
- * filesystem.
+ * Facade-aware variant of {@link #openExisting(String)}. Recovery reads the
+ * file through {@link FilesFacade#read(int, long, long, long)} before it
+ * creates the mapping, so sparse or unbacked pages cannot raise SIGBUS in
+ * the JVM. The descriptor length is checked before and after the scan and
+ * again after mmap; a short read or size change is an operational failure
+ * and aborts recovery.
*
- * The mapping is sized to {@code ff.length(path)} and every recovery read
- * runs straight out of it, so a facade that reports a length larger
- * than the real file makes the mapping extend past end-of-file. A read of a
- * page beyond real EOF raises SIGBUS on every filesystem — the same
- * fault an unbacked/sparse page raises on ZFS, but reproduced
- * deterministically on ext4/xfs, where a within-EOF hole would instead
- * zero-fill and never exercise the guard. See
- * {@link FilesFacade#length(String)}.
+ * The caller must prevent concurrent writers from modifying the file during
+ * recovery and for the lifetime of the returned segment. The length checks
+ * detect observed truncate/extend races, but no mmap-based implementation
+ * can remain safe against uncoordinated mutation after the final check.
*/
public static MmapSegment openExisting(FilesFacade ff, String path) {
- long fileSize = ff.length(path);
- if (fileSize < HEADER_SIZE) {
- throw new MmapSegmentException("file shorter than header: " + path + " size=" + fileSize);
- }
int fd = ff.openRW(path);
if (fd < 0) {
throw new MmapSegmentException("openRW failed for " + path);
}
long addr = Files.FAILED_MMAP_ADDRESS;
+ long fileSize = -1L;
try {
- addr = Files.mmap(fd, fileSize, 0, Files.MAP_RW, MemoryTag.MMAP_DEFAULT);
+ fileSize = ff.length(fd);
+ if (fileSize < 0) {
+ throw new MmapSegmentException(
+ "could not stat open segment " + path + " [errno=" + Os.errno() + ']');
+ }
+ if (fileSize < HEADER_SIZE) {
+ // Corruption, not an operational error: the bytes themselves prove
+ // this cannot be a whole segment (a create() is never durable at a
+ // sub-header size — allocate() reserves the full extent up front).
+ throw new MmapSegmentCorruptionException(
+ "file shorter than header: " + path + " size=" + fileSize);
+ }
+
+ RecoveryScan scan = scanForRecovery(ff, fd, path, fileSize);
+ long finalSize = ff.length(fd);
+ if (finalSize < 0) {
+ throw new MmapSegmentException(
+ "could not re-stat open segment " + path + " [errno=" + Os.errno() + ']');
+ }
+ if (finalSize != fileSize) {
+ throw new MmapSegmentException(
+ "segment size changed during recovery: " + path
+ + " [before=" + fileSize + ", after=" + finalSize + ']');
+ }
+
+ addr = ff.mmap(fd, fileSize, 0, Files.MAP_RW, MemoryTag.MMAP_DEFAULT);
if (addr == Files.FAILED_MMAP_ADDRESS) {
throw new MmapSegmentException("mmap failed for " + path);
}
- int magic = Unsafe.getUnsafe().getInt(addr);
- if (magic != FILE_MAGIC) {
+ long mappedSize = ff.length(fd);
+ if (mappedSize < 0) {
throw new MmapSegmentException(
- "bad magic in " + path + ": 0x" + Integer.toHexString(magic));
- }
- byte version = Unsafe.getUnsafe().getByte(addr + 4);
- if (version != VERSION) {
- throw new MmapSegmentException("unsupported version in " + path + ": " + version);
+ "could not stat mapped segment " + path + " [errno=" + Os.errno() + ']');
}
- long baseSeq = Unsafe.getUnsafe().getLong(addr + 8);
- // FSNs are non-negative by construction (see SegmentRing).
- // A negative baseSeq on disk means bit-rot or a malicious file —
- // refuse the segment so SegmentRing.openExisting's narrow catch
- // skips it like any other unreadable .sfa rather than feeding
- // the bad value into Long.compareUnsigned-based contiguity
- // checks (which would place the segment last in baseSeq order
- // and trip the FSN-gap throw, taking the whole recovery down).
- if (baseSeq < 0L) {
+ if (mappedSize != fileSize) {
throw new MmapSegmentException(
- "bad baseSeq in " + path + ": " + baseSeq);
+ "segment size changed while mapping: " + path
+ + " [before=" + fileSize + ", after=" + mappedSize + ']');
}
- long lastGood = scanFrames(addr, fileSize);
- long count = countFrames(addr, lastGood);
- long tornTail = detectTornTail(addr, lastGood, fileSize);
- if (tornTail > 0) {
+ if (scan.tornTailBytes > 0) {
+ // Observe-only: report the residue, never touch it here. A
+ // torn tail may be an interrupted append (dead bytes) OR a
+ // mid-file tear with unreachable valid-CRC frames past it --
+ // the only surviving copy of real payloads. The
+ // destroy/preserve decision belongs to SegmentRing.recover.
+ // For sealed members chain validation PROVES the residue
+ // dead (complete frame accounting) before sanitizeTornTail
+ // runs, and a tear that cost frames fails closed with every
+ // byte left on disk. For the segment resumed as active no
+ // such proof exists (there is no successor to bound it): its
+ // residue is zeroed by policy once the rest of the chain
+ // validates, because replay cannot cross the tear and
+ // unzeroed residue risks reseal-brick and stale-frame
+ // resurrection.
LOG.warn("SF segment {}: torn tail of {} bytes at offset {} "
+ "(file size {}, frames recovered {}). "
- + "Recovery will overwrite this region on next append; "
- + "frames past the tear (if any) are discarded. "
+ + "The residue is preserved pending chain validation. "
+ "Investigate disk health or unexpected writer crash.",
- path, tornTail, lastGood, fileSize, count);
+ path, scan.tornTailBytes, scan.lastGood, fileSize, scan.frameCount);
}
- return new MmapSegment(path, fd, addr, fileSize, baseSeq, lastGood, count, false, tornTail);
+ return new MmapSegment(
+ ff,
+ path,
+ fd,
+ addr,
+ fileSize,
+ scan.baseSeq,
+ scan.lastGood,
+ scan.frameCount,
+ false,
+ scan.tornTailBytes
+ );
} catch (Throwable t) {
if (addr != Files.FAILED_MMAP_ADDRESS) {
- Files.munmap(addr, fileSize, MemoryTag.MMAP_DEFAULT);
+ ff.munmap(addr, fileSize, MemoryTag.MMAP_DEFAULT);
}
ff.close(fd);
- // The header reads above (magic/version/baseSeq) run before
- // scanFrames and are otherwise unguarded. An unbacked page 0 --
- // an unflushed header on a truncate-based-allocate filesystem
- // after a crash (create() does not msync), or a file truncated
- // under the mapping -- faults at the Unsafe intrinsic site as a
- // catchable InternalError. Convert it to a MmapSegmentException so
- // SegmentRing's per-file catch skips just this .sfa, instead of
- // letting the raw InternalError escape to SegmentRing's outer catch
- // and abort recovery of every sibling segment. scanFrames and
- // detectTornTail already handle their own in-mapping faults; this
- // covers the header block and any future reader placed ahead of the
- // scan.
- if (isMmapAccessFault(t)) {
- throw new MmapSegmentException(
- "unreadable mapped header page in " + path
- + " (unbacked/sparse page 0): " + t.getMessage(), t);
- }
throw t;
}
}
@@ -375,33 +442,147 @@ public void close() {
if (memoryBacked) {
Unsafe.free(mmapAddress, sizeBytes, MemoryTag.NATIVE_DEFAULT);
} else {
- Files.munmap(mmapAddress, sizeBytes, MemoryTag.MMAP_DEFAULT);
+ filesFacade.munmap(mmapAddress, sizeBytes, MemoryTag.MMAP_DEFAULT);
}
mmapAddress = 0;
}
if (fd >= 0) {
- Files.close(fd);
+ filesFacade.close(fd);
fd = -1;
}
}
+ boolean manifestRequired() {
+ return !memoryBacked && (Unsafe.getUnsafe().getByte(mmapAddress + 5) & MANIFEST_REQUIRED_FLAG) != 0;
+ }
+
+ void markManifestRequired() {
+ if (memoryBacked || manifestRequired()) {
+ return;
+ }
+ Unsafe.getUnsafe().putByte(mmapAddress + 5, MANIFEST_REQUIRED_FLAG);
+ syncHeader();
+ }
+
+ void syncHeader() {
+ if (memoryBacked) {
+ return;
+ }
+ if (filesFacade.msync(mmapAddress, HEADER_SIZE, false) != 0 || filesFacade.fsync(fd) != 0) {
+ throw new MmapSegmentException("could not sync segment header " + path);
+ }
+ if (durableCursor < HEADER_SIZE) {
+ durableCursor = HEADER_SIZE;
+ }
+ }
+
public boolean isFull() {
return capacityRemaining() <= 0;
}
+ public boolean isPublishedDurable() {
+ return durableCursor >= publishedCursor;
+ }
+
/**
- * Synchronously flushes dirty pages of {@code [HEADER_SIZE, publishedOffset())}
- * to disk via {@code msync(MS_SYNC)}. Off the hot path — call only when
- * the user has opted into OS-crash durability (e.g. {@code sf_msync_on_flush=on}).
+ * Preserves the original explicit mmap-flush behavior for callers that use
+ * this low-level API directly. Periodic durability uses
+ * {@link #syncPublished()}, which adds checked error handling and an fd
+ * barrier.
*/
public void msync() {
- if (memoryBacked) return; // no on-disk pages to flush
- long pub = publishedCursor;
- if (pub > HEADER_SIZE) {
- Files.msync(mmapAddress, pub, false);
+ if (memoryBacked) {
+ return;
+ }
+ long published = publishedCursor;
+ if (published > HEADER_SIZE) {
+ filesFacade.msync(mmapAddress, published, false);
+ }
+ }
+
+ /**
+ * Synchronously flushes every complete frame published when this method
+ * captures {@link #publishedCursor}. A concurrent producer may publish
+ * more bytes while the barrier runs; those bytes remain outside the
+ * returned durable boundary until a later call.
+ *
+ * The not-yet-durable range is pinned with a best-effort {@code mlock}
+ * for the duration of the barrier and, on failure, re-dirtied before the
+ * pin is released. Rationale (fsyncgate): after a failed writeback the
+ * kernel marks the affected pages clean and reports the error once per
+ * open file description, so a naive retry would msync+fsync clean pages
+ * and return a vacuous 0 without persisting anything -- and an unpinned
+ * clean page can be reclaimed and later re-faulted from stale disk
+ * content, silently replacing the only good copy of the frames. The pin
+ * guarantees the failure arm re-dirties the genuine in-memory bytes; the
+ * re-dirty guarantees the next barrier performs real writeback and
+ * reports real errors, so the manager's unlatch-on-success stays honest.
+ * A refused mlock (RLIMIT_MEMLOCK, missing capability, stale native
+ * library) never affects the barrier outcome; it only widens the
+ * microseconds-scale window between the failed syscall and the re-dirty.
+ *
+ * @return the captured byte offset covered by the successful barrier
+ */
+ public long syncPublished() {
+ long published = publishedCursor;
+ if (memoryBacked) {
+ durableCursor = published;
+ return published;
+ }
+ if (published <= durableCursor) {
+ return durableCursor;
+ }
+ long lockOffset = durableCursor & -Files.PAGE_SIZE;
+ long lockAddr = mmapAddress + lockOffset;
+ long lockLen = published - lockOffset;
+ boolean locked = filesFacade.mlock(lockAddr, lockLen) == 0;
+ if (!locked && MLOCK_REFUSAL_WARNED.compareAndSet(false, true)) {
+ LOG.warn("mlock refused for SF barrier range in {} (degrading to re-dirty-only retry protection); "
+ + "raise RLIMIT_MEMLOCK or grant CAP_IPC_LOCK to close the post-failure reclaim window", path);
+ }
+ boolean durable = false;
+ try {
+ if (filesFacade.msync(mmapAddress, published, false) != 0) {
+ throw new MmapSegmentException("could not sync segment data " + path);
+ }
+ // FlushViewOfFile alone is not power-loss durable on Windows. Keep the
+ // fd barrier on every platform so this method has one portable contract.
+ if (filesFacade.fsync(fd) != 0) {
+ throw new MmapSegmentException("could not sync segment file " + path);
+ }
+ durable = true;
+ durableCursor = published;
+ return published;
+ } finally {
+ if (!durable) {
+ redirtyRange(lockAddr, mmapAddress + published);
+ }
+ if (locked) {
+ filesFacade.munlock(lockAddr, lockLen);
+ }
}
}
+ /**
+ * Marks every page overlapping {@code [fromAddr, endAddr)} dirty again by
+ * re-storing one byte per page. Every touched offset lies inside the
+ * published prefix (or the immutable header magic after aligning down),
+ * so the same-value store races with nothing; kernel dirty tracking is
+ * value-blind, so the store is a real dirtying event that forces the next
+ * writeback to re-submit the whole page.
+ */
+ private void redirtyRange(long fromAddr, long endAddr) {
+ for (long addr = fromAddr; addr < endAddr; addr += Files.PAGE_SIZE) {
+ Unsafe.getUnsafe().putByte(addr, Unsafe.getUnsafe().getByte(addr));
+ }
+ redirtyPasses++;
+ }
+
+ @TestOnly
+ public long redirtyPassesForTest() {
+ return redirtyPasses;
+ }
+
/**
* Bytes safely written and visible to the consumer. Reading any byte at
* offset {@code >= publishedOffset()} from the mapping is undefined —
@@ -438,6 +619,21 @@ public long sizeBytes() {
return sizeBytes;
}
+ void linkSuccessor(MmapSegment next) {
+ if (next == null) {
+ throw new IllegalArgumentException("successor must not be null");
+ }
+ MmapSegment existing = successor;
+ if (existing != null && existing != next) {
+ throw new IllegalStateException("segment successor already linked");
+ }
+ successor = next;
+ }
+
+ MmapSegment successor() {
+ return successor;
+ }
+
/**
* Appends one frame: writes {@code [crc32c | u32 payloadLen | payload]}
* starting at the current append cursor, then advances both cursors
@@ -458,16 +654,17 @@ public long tryAppend(long payloadAddr, int payloadLen) {
return -1L;
}
// CRC32C over the (payloadLen, payload) pair. Recovery scans validate
- // each frame by recomputing this CRC over the on-disk bytes.
+ // each frame by recomputing this CRC over the on-disk bytes. The
+ // payloadLen field (4 bytes at lenAddr) and the payload (at lenAddr+4)
+ // are physically contiguous, so a single CRC pass covers both -- one
+ // native call per frame, byte-identical to the chained form the
+ // recovery scanner recomputes (see scanForRecovery).
long lenAddr = mmapAddress + offset + 4;
Unsafe.getUnsafe().putInt(lenAddr, payloadLen);
if (payloadLen > 0) {
Unsafe.getUnsafe().copyMemory(payloadAddr, mmapAddress + offset + FRAME_HEADER_SIZE, payloadLen);
}
- int crc = Crc32c.update(Crc32c.INIT, lenAddr, 4);
- if (payloadLen > 0) {
- crc = Crc32c.update(crc, mmapAddress + offset + FRAME_HEADER_SIZE, payloadLen);
- }
+ int crc = 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
@@ -538,204 +735,286 @@ public long frameCount() {
* recovery observes non-zero bytes past the bail-out point. {@code 0} for
* fresh segments, memory-backed segments, and cleanly partially-filled
* recovered segments. Operators / tests can read this to tell silent
- * truncation (corruption) from a normal partial fill (no incident).
- *
- * One case this does NOT count: when the scan stops because the bail-out
- * region is itself an unbacked mapped page (an in-mapping fault, not a
- * backed torn header), that region cannot be probed, so this returns
- * {@code 0} even though frames may have been discarded. That outcome is
- * surfaced by the {@code WARN} in {@link #scanFrames} instead -- see it for
- * the benign-tail-vs-media-error caveat.
+ * truncation (corruption) from a normal partial fill (no incident). This
+ * is the open-time observation and is never updated: a later
+ * {@link #sanitizeTornTail()} zeroes the on-disk residue (after which a
+ * re-open reports {@code 0}) but leaves this diagnostic intact. Sparse
+ * holes read back as zeroes; an actual positioned-read failure aborts
+ * recovery instead of being classified as a clean tail.
*/
public long tornTailBytes() {
return tornTailBytes;
}
/**
- * True when {@code t} is the JVM's recoverable signal for a fault while
- * accessing a memory-mapped region -- a SIGBUS/SIGSEGV that HotSpot
- * translates into an {@code InternalError} at an {@code Unsafe} intrinsic
- * site instead of aborting the process. It surfaces when a mapped page is
- * not backed by real file blocks: a sparse {@code .sfa} tail on a
- * filesystem whose pre-allocation leaves holes (e.g. ZFS, where a
- * truncate-based {@code allocate} does not materialize blocks), or a file
- * truncated under the mapping. Recovery treats this as an I/O boundary --
- * the same way MappedByteBuffer readers do -- not a fatal VM error.
+ * Durably zeroes the torn-tail residue this segment was opened with:
+ * {@code [sizeBytes - tornTailBytes, sizeBytes)} is set to zero and made
+ * durable with an msync+fsync barrier. Restores the invariant every fresh
+ * segment already has -- all bytes past the append cursor are zero -- so
+ * residue that resumed appends do not fully overwrite cannot survive into
+ * a reseal (where sealed-suffix recovery would treat it as fatal
+ * corruption on every subsequent startup) and a byte-aligned stale frame
+ * with a valid CRC cannot be resurrected at a recycled FSN by a later
+ * scan (the frame envelope binds neither position nor FSN).
*
- * The message is matched on the fragment {@code "unsafe memory access
- * operation"}, which is common to both HotSpot wordings and NOT
- * version-stable as a whole: pre-21 JDKs (including the shipping/CI JDK 8)
- * emit {@code "a fault occurred in a recent unsafe memory access operation
- * in compiled Java code"}, while JDK 21+ shortened it to {@code "a fault
- * occurred in an unsafe memory access operation"}. Matching the shared
- * fragment keeps the guard effective on JDK 8/11/17 as well as 21+, while
- * still being specific enough that a genuine VirtualMachineError (real OOM,
- * StackOverflow) is never swallowed.
+ * DESTRUCTIVE by design and therefore never called by
+ * {@link #openExisting}: after a mid-file tear the residue can hold
+ * unreachable valid-CRC frames that are the only surviving copy of
+ * unacked payloads. The caller ({@link SegmentRing} recovery) must first
+ * establish the residue is not load-bearing, and the strength of that
+ * claim differs by role: a sealed suffix is PROVEN dead (frame
+ * accounting validated complete against the chain), while the resumed
+ * active's tail past a mid-file tear is discarded by POLICY, not proof
+ * -- it can hold valid-CRC frames of real unacked payloads that are
+ * unreachable by replay (the FSN sequence breaks at the tear), and
+ * leaving them risks the two hazards above. Must run before any append
+ * resumes; appending first would put live frames inside the zeroed range,
+ * so that ordering is rejected. Idempotent; no-op when no residue was
+ * observed. The fsync is load-bearing: in MEMORY durability mode rotation
+ * does not sync the sealed predecessor's data pages, so an unflushed
+ * zero-write plus a crash would regenerate the exact poisoned state this
+ * sanitization removes. A failed barrier throws and the caller fails
+ * closed; the zeroes may still reach disk via the page cache, which is
+ * safe: a retry either observes a clean tail or re-zeroes.
*/
- private static boolean isMmapAccessFault(Throwable t) {
- if (!(t instanceof InternalError)) {
- return false;
+ public void sanitizeTornTail() {
+ if (tornTailBytes == 0 || tornTailSanitized) {
+ return;
+ }
+ long residueStart = sizeBytes - tornTailBytes;
+ if (appendCursor != residueStart) {
+ throw new MmapSegmentException(
+ "torn tail must be sanitized before appends resume in " + path
+ + " [appendCursor=" + appendCursor
+ + ", residueStart=" + residueStart + ']');
}
- String msg = t.getMessage();
- return msg != null && msg.contains("unsafe memory access operation");
+ Unsafe.getUnsafe().setMemory(mmapAddress + residueStart, tornTailBytes, (byte) 0);
+ if (filesFacade.msync(mmapAddress, sizeBytes, false) != 0 || filesFacade.fsync(fd) != 0) {
+ throw new MmapSegmentException(
+ "could not sync zeroed torn tail in " + path
+ + " [errno=" + Os.errno() + ']');
+ }
+ tornTailSanitized = true;
+ LOG.warn("SF segment {}: zeroed {} bytes of torn-tail residue at offset {}; "
+ + "frames past the tear (if any) are discarded",
+ path, tornTailBytes, residueStart);
}
/**
- * Forward scan that returns the offset just past the last frame whose
- * CRC verifies. A torn-tail frame (declared length runs past EOF, or
- * CRC mismatch) leaves both cursors at the start of that frame; the
- * next {@link #tryAppend} will overwrite it. The scan only reads from
- * the mapping — no syscalls.
+ * True when recovery observed torn-tail residue that
+ * {@link #sanitizeTornTail()} has not yet durably zeroed -- i.e. the
+ * on-disk suffix still carries the observed bytes.
*/
- private static long scanFrames(long addr, long fileSize) {
- long pos = HEADER_SIZE;
- try {
- while (pos + FRAME_HEADER_SIZE <= fileSize) {
- int crcRead = Unsafe.getUnsafe().getInt(addr + pos);
- int payloadLen = Unsafe.getUnsafe().getInt(addr + pos + 4);
- // Defensive: a corrupt length field could be enormous or negative,
- // both of which would otherwise overrun the mapping.
- if (payloadLen < 0 || pos + FRAME_HEADER_SIZE + payloadLen > fileSize) {
- return pos;
+ public boolean hasUnsanitizedTornTail() {
+ return tornTailBytes > 0 && !tornTailSanitized;
+ }
+
+ /**
+ * Scans the header and frames through a bounded positioned-read buffer.
+ * Sparse file holes are returned by {@code pread}/{@code ReadFile} as zero
+ * bytes, while media errors and concurrent truncation surface as ordinary
+ * read failures. No recovery byte is dereferenced through mmap.
+ */
+ private static RecoveryScan scanForRecovery(
+ FilesFacade ff,
+ int fd,
+ String path,
+ long fileSize
+ ) {
+ try (RecoveryReader reader = new RecoveryReader(ff, fd, path, fileSize)) {
+ int magic = reader.getInt(0L);
+ if (magic != FILE_MAGIC) {
+ throw new MmapSegmentCorruptionException(
+ "bad magic in " + path + ": 0x" + Integer.toHexString(magic));
+ }
+ byte version = reader.getByte(4L);
+ if (version != VERSION) {
+ // Deliberately NOT the corruption subtype: an unsupported
+ // version is a well-formed segment written by a different
+ // client build (e.g. after a downgrade). Quarantine-renaming
+ // it would strand its frames for the writer that CAN read it;
+ // failing recovery keeps the slot intact for that writer.
+ throw new MmapSegmentException("unsupported version in " + path + ": " + version);
+ }
+ long baseSeq = reader.getLong(8L);
+ // FSNs are non-negative by construction (see SegmentRing). A
+ // negative value on disk is positively identified corruption.
+ if (baseSeq < 0L) {
+ throw new MmapSegmentCorruptionException(
+ "bad baseSeq in " + path + ": " + baseSeq);
+ }
+
+ long frameCount = 0L;
+ long pos = HEADER_SIZE;
+ while (fileSize - pos >= FRAME_HEADER_SIZE) {
+ int crcRead = reader.getInt(pos);
+ int payloadLen = reader.getInt(pos + 4L);
+ // Avoid addition overflow while rejecting a negative or
+ // beyond-EOF frame length as a torn tail.
+ if (payloadLen < 0 || payloadLen > fileSize - pos - FRAME_HEADER_SIZE) {
+ break;
}
- // CRC over the contiguous (payloadLen, payload) pair, folded
- // via Unsafe reads rather than the native Crc32c.update.
- // Recovery maps to the file's stat length, but a page inside
- // that range can be unbacked: a sparse pre-allocation tail (a
- // truncate-based allocate that never materialized blocks, as on
- // ZFS), or -- via a torn write, since tryAppend writes the
- // length field before copying the payload -- a real positive
- // payloadLen whose payload spans into an unwritten hole. A raw
- // read of an unbacked page raises SIGBUS; HotSpot translates
- // that into a catchable InternalError ONLY at an Unsafe
- // intrinsic site, NEVER inside JNI native code, so a native
- // Crc32c.update over such a page aborts the whole JVM (and,
- // empirically on pre-21 JDKs, a preceding Unsafe pre-touch does
- // not reliably fault first once an earlier native CRC ran in
- // the same scan). Folding over Unsafe keeps every fault
- // catchable -- handled below as the boundary of recoverable
- // data; a page that instead reads back as zeroes just fails the
- // CRC check and ends the scan. Recovery is cold, so the slower
- // table CRC here is immaterial.
- int crcCalc = crc32cRecovery(addr + pos + 4, 4L + payloadLen);
+ int crcCalc = reader.crc32c(pos + 4L, 4L + payloadLen);
if (crcCalc != crcRead) {
- return pos;
+ break;
}
pos += FRAME_HEADER_SIZE + payloadLen;
+ frameCount++;
}
- } catch (InternalError e) {
- // The read at `pos` hit a mapped page that is not backed by real
- // file blocks: the JVM translates the underlying SIGBUS into a
- // recoverable InternalError instead of aborting the process. This
- // happens when a prior session left a sparse segment tail (a
- // truncate-based pre-allocation that does not materialize blocks,
- // as on ZFS) or the file was truncated under the mapping. Every
- // frame below `pos` already verified; treat the unreadable region
- // exactly like unwritten space or a torn tail -- the boundary of
- // recoverable data -- rather than letting the error abort recovery
- // of the whole slot. Anything that is not the documented mmap
- // access fault is a genuine VM error, so rethrow it.
- if (!isMmapAccessFault(e)) {
- throw e;
- }
- LOG.warn("SF segment recovery: unreadable mapped page at offset {} (file size {}); "
- + "treating it as the end of recoverable data -- any frames beyond this "
- + "offset are discarded. The usual cause is a benign sparse pre-allocation "
- + "tail (e.g. a truncate-based allocate on ZFS) left by a prior session, "
- + "but a mid-file media error (bad sector) is indistinguishable here; "
- + "check disk health if this segment was expected to be fully written or "
- + "if this recurs.",
- pos, fileSize);
+ long tornTailBytes = detectTornTail(reader, pos, fileSize);
+ return new RecoveryScan(baseSeq, frameCount, pos, tornTailBytes);
}
- return pos;
}
/**
- * CRC-32C (Castagnoli) of {@code [addr, addr + len)} read through
- * {@link Unsafe}, seeded like {@code Crc32c.update(Crc32c.INIT, addr, len)}
- * and bit-identical to it (verified) -- but every byte load is an Unsafe
- * intrinsic, so a fault on an unbacked mapped page is a catchable
- * {@link InternalError} instead of the uncatchable JNI SIGBUS the native
- * {@link Crc32c} would raise. Byte-at-a-time via a precomputed table
- * ({@code ~0.5 GiB/s}); used only on the cold recovery scan, never on the
- * append hot path (which stays on the native, hardware-friendly path).
+ * Distinguishes attempted frame data from clean zero-filled space after the
+ * recovery boundary. The entire suffix is inspected: an all-zero corrupt
+ * frame header must not hide a non-zero payload or later frames and make a
+ * data-bearing segment look like a reusable empty spare. Positioned-read
+ * failures are intentionally not swallowed; they are operational failures,
+ * not proof of an unwritten tail.
*/
- private static int crc32cRecovery(long addr, long len) {
- int crc = ~Crc32c.INIT;
- for (long i = 0; i < len; i++) {
- crc = (crc >>> 8) ^ CRC32C_TABLE[(crc ^ Unsafe.getUnsafe().getByte(addr + i)) & 0xFF];
+ private static long detectTornTail(RecoveryReader reader, long lastGood, long fileSize) {
+ if (lastGood >= fileSize) {
+ return 0L;
}
- return ~crc;
+ return reader.hasNonZero(lastGood, fileSize - lastGood)
+ ? fileSize - lastGood
+ : 0L;
}
- /**
- * Standard reflected CRC-32C byte table (polynomial {@code 0x82F63B78}),
- * matching {@code crc32c_table[0]} in the native {@code crc32c.c}. Computed
- * at class init to avoid 256 hand-transcribed literals; drives
- * {@link #crc32cRecovery}.
- */
- private static int[] buildCrc32cTable() {
- int[] table = new int[256];
- for (int n = 0; n < 256; n++) {
- int c = n;
- for (int k = 0; k < 8; k++) {
- c = (c & 1) != 0 ? (0x82F63B78 ^ (c >>> 1)) : (c >>> 1);
+ private static final class RecoveryReader implements QuietCloseable {
+ private final long bufferAddress;
+ private final int fd;
+ private final FilesFacade filesFacade;
+ private final long fileSize;
+ private final String path;
+ private int bufferLength;
+ private long bufferOffset = -1L;
+
+ private RecoveryReader(FilesFacade filesFacade, int fd, String path, long fileSize) {
+ this.filesFacade = filesFacade;
+ this.fd = fd;
+ this.path = path;
+ this.fileSize = fileSize;
+ this.bufferAddress = Unsafe.malloc(RECOVERY_BUFFER_SIZE, MemoryTag.NATIVE_DEFAULT);
+ }
+
+ @Override
+ public void close() {
+ Unsafe.free(bufferAddress, RECOVERY_BUFFER_SIZE, MemoryTag.NATIVE_DEFAULT);
+ }
+
+ private int crc32c(long offset, long len) {
+ int crc = Crc32c.INIT;
+ while (len > 0L) {
+ ensure(offset, 1);
+ int bufferIndex = (int) (offset - bufferOffset);
+ long chunk = Math.min(len, bufferLength - bufferIndex);
+ crc = Crc32c.update(crc, bufferAddress + bufferIndex, chunk);
+ offset += chunk;
+ len -= chunk;
}
- table[n] = c;
+ return crc;
}
- return table;
- }
- /**
- * Distinguishes "torn tail" (writer attempted a write past the last valid
- * frame and failed — partial write, mid-stream corruption, bit rot) from
- * clean unwritten space (manager-allocated segment with zero-filled tail).
- * Returns the byte count from {@code lastGood} to {@code fileSize} when
- * the bytes at the bail-out frame header are non-zero, else {@code 0}.
- *
- * Heuristic but robust for the common cases: {@link #create} truncates the
- * file to size, leaving the tail zero-filled; the writer only writes
- * non-zero bytes via {@link #tryAppend}, which writes the CRC and length
- * fields together. So a non-zero byte at the failed-frame position
- * implies an attempted write — exactly the case operators want flagged.
- */
- private static long detectTornTail(long addr, long lastGood, long fileSize) {
- if (lastGood >= fileSize) {
- return 0L;
+ private void ensure(long offset, int requiredBytes) {
+ if (offset >= bufferOffset
+ && offset - bufferOffset <= bufferLength - requiredBytes) {
+ return;
+ }
+ if (offset < 0L || requiredBytes < 0 || offset > fileSize - requiredBytes) {
+ throw new MmapSegmentException(
+ "recovery read outside segment " + path
+ + " [offset=" + offset + ", required=" + requiredBytes
+ + ", fileSize=" + fileSize + ']');
+ }
+ int len = (int) Math.min((long) RECOVERY_BUFFER_SIZE, fileSize - offset);
+ readFully(bufferAddress, len, offset);
+ bufferOffset = offset;
+ bufferLength = len;
}
- long probe = Math.min(FRAME_HEADER_SIZE, fileSize - lastGood);
- try {
- for (long i = 0; i < probe; i++) {
- if (Unsafe.getUnsafe().getByte(addr + lastGood + i) != 0) {
- return fileSize - lastGood;
+
+ private byte getByte(long offset) {
+ ensure(offset, 1);
+ return Unsafe.getUnsafe().getByte(bufferAddress + offset - bufferOffset);
+ }
+
+ private int getInt(long offset) {
+ ensure(offset, Integer.BYTES);
+ return Unsafe.getUnsafe().getInt(bufferAddress + offset - bufferOffset);
+ }
+
+ private long getLong(long offset) {
+ ensure(offset, Long.BYTES);
+ return Unsafe.getUnsafe().getLong(bufferAddress + offset - bufferOffset);
+ }
+
+ private boolean hasNonZero(long offset, long len) {
+ boolean nonZero = false;
+ while (len > 0L) {
+ ensure(offset, 1);
+ int bufferIndex = (int) (offset - bufferOffset);
+ int chunk = (int) Math.min(len, bufferLength - bufferIndex);
+ long address = bufferAddress + bufferIndex;
+ int i = 0;
+ while (i < chunk && ((address + i) & (Long.BYTES - 1L)) != 0L) {
+ nonZero |= Unsafe.getUnsafe().getByte(address + i++) != 0;
}
+ while (i <= chunk - Long.BYTES) {
+ nonZero |= Unsafe.getUnsafe().getLong(address + i) != 0L;
+ i += Long.BYTES;
+ }
+ while (i < chunk) {
+ nonZero |= Unsafe.getUnsafe().getByte(address + i++) != 0;
+ }
+ offset += chunk;
+ len -= chunk;
}
- } catch (InternalError e) {
- // The bail-out region is an unbacked (sparse) mapped page -- see
- // scanFrames for the mechanism. An unbacked hole was never written,
- // so it is clean unwritten space, not a torn write. Rethrow any
- // error that is not the recoverable mmap access fault.
- if (!isMmapAccessFault(e)) {
- throw e;
+ return nonZero;
+ }
+
+ private void readFully(long address, long len, long offset) {
+ long read = 0L;
+ while (read < len) {
+ long n = filesFacade.read(fd, address + read, len - read, offset + read);
+ if (n < 0L) {
+ throw new MmapSegmentException(
+ "could not read SF segment " + path
+ + " [offset=" + (offset + read)
+ + ", remaining=" + (len - read)
+ + ", errno=" + Os.errno() + ']');
+ }
+ if (n == 0L) {
+ throw new MmapSegmentException(
+ "short read while recovering SF segment " + path
+ + " [offset=" + (offset + read)
+ + ", remaining=" + (len - read)
+ + ", fileSize=" + fileSize + ']');
+ }
+ if (n > len - read) {
+ throw new MmapSegmentException(
+ "invalid read length while recovering SF segment " + path
+ + " [offset=" + (offset + read)
+ + ", requested=" + (len - read)
+ + ", actual=" + n + ']');
+ }
+ read += n;
}
- return 0L;
}
- return 0L;
}
- /**
- * Counts frames in {@code [HEADER_SIZE, lastGood)}. Walks the framing in
- * lockstep with {@link #scanFrames} (which already validated CRCs); so
- * this is just length-driven traversal, no CRC re-check.
- */
- private static long countFrames(long addr, long lastGood) {
- long pos = HEADER_SIZE;
- long count = 0;
- while (pos < lastGood) {
- int payloadLen = Unsafe.getUnsafe().getInt(addr + pos + 4);
- pos += FRAME_HEADER_SIZE + payloadLen;
- count++;
- }
- return count;
+ private static final class RecoveryScan {
+ private final long baseSeq;
+ private final long frameCount;
+ private final long lastGood;
+ private final long tornTailBytes;
+
+ private RecoveryScan(long baseSeq, long frameCount, long lastGood, long tornTailBytes) {
+ this.baseSeq = baseSeq;
+ this.frameCount = frameCount;
+ this.lastGood = lastGood;
+ this.tornTailBytes = tornTailBytes;
+ }
}
}
diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/MmapSegmentCorruptionException.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/MmapSegmentCorruptionException.java
new file mode 100644
index 00000000..8a72a026
--- /dev/null
+++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/MmapSegmentCorruptionException.java
@@ -0,0 +1,50 @@
+/*******************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * 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;
+
+/**
+ * Positively-identified segment corruption: the file's own bytes prove it is
+ * not (or no longer) a readable SF segment — truncated below the fixed header,
+ * wrong magic, or a negative {@code baseSeq}. Distinct from its parent
+ * {@link MmapSegmentException}, which recovery treats as an operational
+ * failure (open/read/mmap error on a file
+ * whose contents may be perfectly intact) and must therefore be fatal.
+ *
+ * Recovery quarantines corruption (rename to {@code .corrupt}) and
+ * relies on manifest boundaries / FSN contiguity to decide whether the
+ * quarantined file was load-bearing; operational failures always abort
+ * startup so a transient {@code EMFILE}/{@code ENOMEM} can never silently
+ * drop durable frames.
+ */
+public final class MmapSegmentCorruptionException extends MmapSegmentException {
+
+ public MmapSegmentCorruptionException(String message) {
+ super(message);
+ }
+
+ public MmapSegmentCorruptionException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/MmapSegmentException.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/MmapSegmentException.java
index eec0c0d9..371d980c 100644
--- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/MmapSegmentException.java
+++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/MmapSegmentException.java
@@ -28,9 +28,19 @@
* Hard failure of the MmapSegment layer — bad header, mmap rejection, file
* too short for header, etc. Indicates the segment is unusable, not that
* the disk is full (the latter surfaces as backpressure on the producer
- * via {@link io.questdb.client.cutlass.qwp.client.LineSenderException}).
+ * via {@link io.questdb.client.cutlass.line.LineSenderException}).
+ *
+ * Recovery distinguishes three flavors: this base type marks operational
+ * failures (open/read/mmap/enumeration errors — the file's contents may be fine,
+ * so recovery must fail closed); {@link MmapSegmentCorruptionException} marks
+ * positively-identified corruption in one file's own bytes, which
+ * recovery may quarantine when the surviving chain proves safe; and
+ * {@link SfRecoveryException} marks a terminal chain failure that needs
+ * operator intervention (with one self-healed refinement,
+ * {@link SfSanitizedResidueException}, that unattended callers retry once
+ * instead of quarantining).
*/
-public final class MmapSegmentException extends RuntimeException {
+public class MmapSegmentException extends RuntimeException {
public MmapSegmentException(String message) {
super(message);
}
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 3b7cff60..e4a23090 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
@@ -34,8 +34,11 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+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
@@ -49,27 +52,32 @@
* in a JVM). Polls each ring on a configurable tick (default 1 ms) — short
* enough that a producer rarely sees {@link SegmentRing#BACKPRESSURE_NO_SPARE}
* in the steady state, long enough that an idle JVM doesn't burn CPU.
- *
- * baseSeq race window: the spare is created with
- * {@code baseSeq = ring.nextSeqHint()} as observed by the manager. If the
- * producer thread appends more frames before the rotation actually fires,
- * the spare's baseSeq will be stale and {@link SegmentRing#appendOrFsn} will
- * throw on the mismatch check. In practice this is benign — by the time
- * {@link SegmentRing#needsHotSpare()} returns true the producer has very
- * little room left in the active segment, and the manager polls fast enough
- * to install before the producer fills the rest. Hardening to make the race
- * impossible (lazy header write at rotation time) is a separate refinement
- * deferred to PR2.
*/
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;
+ private static final int ENTRY_IN_SERVICE = 1;
+ private static final int ENTRY_REGISTERED = 0;
+ private static final AtomicReferenceFieldUpdater ENTRY_CLEANUP_UPDATER =
+ AtomicReferenceFieldUpdater.newUpdater(RingEntry.class, Runnable.class, "quiescenceCleanup");
+ private static final AtomicIntegerFieldUpdater ENTRY_STATE_UPDATER =
+ AtomicIntegerFieldUpdater.newUpdater(RingEntry.class, "state");
private static final Logger LOG = LoggerFactory.getLogger(SegmentManager.class);
+ private static final int MAX_TRIMS_PER_RING_PASS = 64;
+ 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;
+ private static final int TRIM_RETRY_POST_BARRIER = 3;
+ private static final int TRIM_RETRY_PRE_BARRIER = 1;
+ private static final int TRIM_RETRY_UNLINK = 2;
private static final long WORKER_JOIN_TIMEOUT_MILLIS = 5_000L;
private final AtomicLong fileGeneration = new AtomicLong();
+ private final FilesFacade filesFacade;
private final Object lock = new Object();
private final long maxTotalBytes;
// Reused by the manager worker thread to build spare-segment paths
@@ -86,6 +94,25 @@ public final class SegmentManager implements QuietCloseable {
private final ObjList ringSnapshot = new ObjList<>();
private final ObjList rings = new ObjList<>();
private final long segmentSizeBytes;
+ // Reused by the manager worker while it checkpoints one ring. The entry
+ // in-service state keeps these segment mappings alive until the pass ends.
+ private final ObjList syncScratch = new ObjList<>();
+ private final LongSupplier ticks;
+ // Reused by the worker for one bounded trim quantum. Keeping the unlinked
+ // prefix here until the post-unlink directory barrier succeeds avoids both
+ // per-pass allocation and publishing logical removal before it is durable.
+ private final MmapSegment[] trimBatch = new MmapSegment[MAX_TRIMS_PER_RING_PASS];
+ // Test seam: runs 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.
+ private volatile Runnable afterRingCleanupHook;
+ // Test seam: runs at the top of deferUntilWorkerExit, before the
+ // worker-liveness check. Null in production; registration-failure tests
+ // throw from it to simulate an allocation failure (OOM building the
+ // cleanup lambda or growing exitCleanups) while the worker is still
+ // 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 + the
// totalBytes += segmentSize commit). Null in production; tests use it to
@@ -93,33 +120,75 @@ public final class SegmentManager implements QuietCloseable {
// 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.
+ private volatile Runnable beforeRingQuiescenceAwaitHook;
// Test seam: runs on the worker thread just before the trim block's
// synchronized(lock) entry. Null in production; only
// SegmentManagerTrimDeregisterRaceTest installs it, to deterministically
// inject a deregister(ring) call into the exact race window that the
- // registered flag check inside the trim block closes for watermark writes
- // and totalBytes accounting.
+ // entry-state check inside the trim block closes for watermark writes and
+ // totalBytes 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
+ // without binding to a particular SLF4J backend.
+ private volatile Runnable retryLogHook;
+ // Entry the worker is claiming or servicing, or null between passes.
+ // Volatile publication lets teardown find the entry after deregister()
+ // removes it from rings. RingEntry.state is the authoritative barrier:
+ // the worker publishes this reference before its REGISTERED->IN_SERVICE
+ // CAS and only touches ring resources after that CAS succeeds.
+ private volatile RingEntry inService;
+ // Cleanup actions handed to the worker's exit block by an owning engine
+ // whose close() found the worker still mid service pass after the bounded
+ // join timed out (see deferUntilWorkerExit). Guarded by {@link #lock};
+ // consumed exactly once by the worker-loop finally, which runs them
+ // OUTSIDE `lock`: they perform syscalls (munmap/unlink/flock release),
+ // and no caller-facing lock may ever be held while running third-party
+ // cleanup code.
+ private ObjList exitCleanups;
+ // A private manager belongs to exactly one CursorSendEngine. Its callback
+ // is preallocated by that engine and stored directly here, so the critical
+ // 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;
+ private boolean workerLoopExited;
// Total bytes currently allocated across every segment owned by every
// registered ring (active + sealed + hot-spare). Mutated by the manager
// thread on provision/trim and by register/deregister callers under
// {@link #lock}; the lock covers both paths so the counter stays
// consistent across registration boundaries.
private long totalBytes;
- private long workerJoinTimeoutMillis = WORKER_JOIN_TIMEOUT_MILLIS;
+ // volatile: read by awaitRingQuiescence() from arbitrary caller threads
+ // while the @TestOnly setter may run on another.
+ private volatile long workerJoinTimeoutMillis = WORKER_JOIN_TIMEOUT_MILLIS;
// volatile because wakeWorker() reads workerThread without holding the
// monitor; the synchronized start()/close() pair handles the
// start-vs-close ordering.
private volatile Thread workerThread;
public SegmentManager(long segmentSizeBytes) {
- this(segmentSizeBytes, DEFAULT_POLL_NANOS, UNLIMITED_TOTAL_BYTES);
+ this(segmentSizeBytes, DEFAULT_POLL_NANOS, UNLIMITED_TOTAL_BYTES, FilesFacade.INSTANCE, System::nanoTime);
}
public SegmentManager(long segmentSizeBytes, long pollNanos) {
- this(segmentSizeBytes, pollNanos, UNLIMITED_TOTAL_BYTES);
+ this(segmentSizeBytes, pollNanos, UNLIMITED_TOTAL_BYTES, FilesFacade.INSTANCE, System::nanoTime);
}
/**
@@ -145,6 +214,22 @@ public SegmentManager(long segmentSizeBytes, long pollNanos) {
* hold an initial active plus one hot spare.
*/
public SegmentManager(long segmentSizeBytes, long pollNanos, long maxTotalBytes) {
+ this(segmentSizeBytes, pollNanos, maxTotalBytes, FilesFacade.INSTANCE, System::nanoTime);
+ }
+
+ @TestOnly
+ public SegmentManager(long segmentSizeBytes, long pollNanos, long maxTotalBytes, FilesFacade filesFacade) {
+ this(segmentSizeBytes, pollNanos, maxTotalBytes, filesFacade, System::nanoTime);
+ }
+
+ @TestOnly
+ public SegmentManager(
+ long segmentSizeBytes,
+ long pollNanos,
+ long maxTotalBytes,
+ 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
@@ -159,9 +244,15 @@ public SegmentManager(long segmentSizeBytes, long pollNanos, long maxTotalBytes)
"maxTotalBytes (" + maxTotalBytes + ") must allow at least one segment of "
+ segmentSizeBytes + " bytes");
}
+ this.filesFacade = filesFacade;
this.segmentSizeBytes = segmentSizeBytes;
this.pollNanos = pollNanos;
this.maxTotalBytes = maxTotalBytes;
+ this.ticks = ticks;
+ }
+
+ FilesFacade filesFacade() {
+ return filesFacade;
}
@Override
@@ -194,18 +285,251 @@ public synchronized void close() {
}
}
if (t.isAlive()) {
- LOG.warn("SegmentManager worker did not stop before close wait completed; "
- + "leaving worker-owned resources allocated");
- return;
+ 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");
+ return;
+ }
+ }
+ // The worker has left its service loop (workerLoopExited) and
+ // is now running only its finite exit cleanups -- the owning
+ // engine's finishClose(), which releases the slot flock and
+ // only THEN publishes closeCompleted. It can no longer wedge in
+ // a service pass, so give it a second bounded join to finish
+ // before reaping. Without this, a bounded-join timeout that
+ // lands mid-finishClose reaps the worker (workerThread=null =>
+ // isWorkerReaped()) while the flock release is still in flight,
+ // so a caller reading isCloseCompleted() observes a stale false
+ // and a spurious flock-release retry gets scheduled. This reuses
+ // the same join-under-monitor pattern as the bounded join above
+ // -- the worker uses a lock-free CAS for exactly-once cleanup
+ // and never blocks on this monitor -- and still reaps on
+ // timeout, so a pathologically slow cleanup cannot hang close().
+ //
+ // Budget with the fixed WORKER_JOIN_TIMEOUT_MILLIS, NOT the
+ // tunable workerJoinTimeoutMillis: the first join bounds a
+ // possibly-wedged SERVICE pass (tests shrink it to force the
+ // timed-out path), but the exit cleanups are finite and must be
+ // allowed to finish regardless of that tuning. In production
+ // both values are equal, so this only matters under a shrunk
+ // test override.
+ boolean cleanupInterrupted = Thread.interrupted();
+ long cleanupDeadlineNanos = System.nanoTime() + WORKER_JOIN_TIMEOUT_MILLIS * 1_000_000L;
+ try {
+ while (t.isAlive()) {
+ long remainingMillis = (cleanupDeadlineNanos - System.nanoTime()) / 1_000_000L;
+ if (remainingMillis <= 0) {
+ break;
+ }
+ try {
+ t.join(remainingMillis);
+ } catch (InterruptedException ignored) {
+ cleanupInterrupted = true;
+ }
+ }
+ } finally {
+ if (cleanupInterrupted) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ // If the cleanups still have not finished (pathologically slow
+ // syscall), fall through and reap anyway -- identical to the
+ // best-effort behaviour before this second join existed.
}
workerThread = null;
}
// Free the rotation-path native scratch buffer only after worker
- // termination has been observed. The worker is the only thread that
- // touches the buffer, but close() uses a bounded join; if the worker is
- // still alive, leaking this one scratch allocation is safer than freeing
- // native memory it may still read or write.
- pathScratch.close();
+ // 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();
+ }
+ }
+ }
+
+ /**
+ * Hands the single owning engine's preallocated close callback to the
+ * worker exit block. Registration only assigns a reference under
+ * {@link #lock}; it cannot allocate in the timed-out teardown path.
+ * Returns {@code false} only after the worker loop has exited (or when it
+ * never started), so the caller may then clean up inline safely.
+ */
+ public boolean deferOwnedEngineCloseUntilWorkerExit(Runnable cleanup) {
+ synchronized (lock) {
+ if (workerLoopExited || workerThread == null) {
+ return false;
+ }
+ if (ownedEngineExitCleanup != null && ownedEngineExitCleanup != cleanup) {
+ throw new IllegalStateException("owned manager already has an engine-exit cleanup");
+ }
+ ownedEngineExitCleanup = cleanup;
+ return true;
+ }
+ }
+
+ /**
+ * Hands a cleanup action to the current service pass for {@code ring}.
+ * The worker runs it outside the manager lock immediately after that pass
+ * finishes. Returns {@code false} when no pass for the ring remains in
+ * flight, in which case the caller already owns a quiescent ring and must
+ * run the cleanup itself.
+ *
+ * Registration and pass completion coordinate through the entry's atomic
+ * state and callback fields, so there is no gap without an owner: either
+ * this method attaches the cleanup while the pass remains active, the
+ * worker claims an attached cleanup while completing, or this method
+ * observes completed state and rejects the handoff. A repeated registration
+ * for the same pass returns {@code true} without replacing its existing
+ * owner.
+ */
+ public boolean deferUntilRingQuiescent(SegmentRing ring, Runnable cleanup) {
+ RingEntry e = inService;
+ if (e == null || e.ring != ring || !e.isInService()) {
+ return false;
+ }
+ Runnable existing = ENTRY_CLEANUP_UPDATER.get(e);
+ if (existing == null && ENTRY_CLEANUP_UPDATER.compareAndSet(e, null, cleanup)) {
+ if (e.isInService()) {
+ return true;
+ }
+ // Completion changed the state before observing the callback.
+ // Remove our callback and clean inline, unless the worker already
+ // claimed it (in which case that worker remains the owner).
+ return !ENTRY_CLEANUP_UPDATER.compareAndSet(e, cleanup, null);
+ }
+ // A callback already attached to this pass is an owner. The sole
+ // production caller always supplies the engine's preallocated bound
+ // callback; duplicates intentionally do not replace it.
+ return true;
+ }
+
+ /**
+ * Hands a cleanup action to the worker thread's exit block, to run
+ * strictly after the worker loop has finished its final service pass --
+ * i.e. after the last point where the worker can create, write or unlink
+ * anything under a registered ring's slot directory. Returns {@code false}
+ * when the worker loop has already exited (or the worker never started):
+ * the caller must run the cleanup itself, which is equally safe for the
+ * same reason -- no further worker access to the slot is possible.
+ *
+ * This is the slot-ownership transfer used by an owning engine's close()
+ * when the bounded worker join timed out: instead of retiring the slot
+ * until process exit, ring/watermark/flock release moves to the worker,
+ * which is provably the last thread able to touch the slot directory.
+ * The registration here and the exit block's {@code workerLoopExited}
+ * flip share {@link #lock}, so the cleanup runs exactly once: either it
+ * is registered before the flip and the worker runs it, or the flip won
+ * and this method rejects the handoff.
+ *
+ * May throw on allocation failure (the lambda at the call site, the
+ * {@code ObjList}, or its growth). A throw carries NO liveness
+ * information: the worker was never observed, so the caller must treat
+ * it as "worker possibly still live" and retain resources — never as
+ * the exact {@code false} return above.
+ */
+ public boolean deferUntilWorkerExit(Runnable cleanup) {
+ Runnable hook = beforeExitCleanupRegistrationHook;
+ if (hook != null) {
+ hook.run();
+ }
+ synchronized (lock) {
+ if (workerLoopExited || workerThread == null) {
+ return false;
+ }
+ if (exitCleanups == null) {
+ exitCleanups = new ObjList<>();
+ }
+ exitCleanups.add(cleanup);
+ return true;
+ }
+ }
+
+ /**
+ * Quiescence barrier for {@link #deregister(SegmentRing)}. Blocks until
+ * the worker thread is provably no longer executing a service pass for
+ * {@code ring}, or the worker-join timeout elapses. After this returns
+ * {@code true}, the worker will never again touch the ring, its
+ * watermark, or path names under its slot directory: deregister has
+ * removed the entry from the registry, a stale snapshot entry that has
+ * not started its pass is skipped by the registration check at the top
+ * of {@link #serviceRing(RingEntry)}, and this method has observed the
+ * end of any in-flight pass. Only then may the caller release dependent
+ * resources (ring, watermark, segment files, slot lock).
+ *
+ * Returns {@code true} immediately when no worker is running or when
+ * called from the worker thread itself (test hooks inject deregister
+ * calls there; waiting would self-deadlock). Returns {@code false} when
+ * the in-flight pass did not finish within the timeout — the caller must
+ * treat the worker as still live and leak rather than release.
+ *
+ * A pending caller interrupt is preserved but does not abort the wait,
+ * mirroring {@link #close()}.
+ */
+ public boolean awaitRingQuiescence(SegmentRing ring) {
+ Runnable hook = beforeRingQuiescenceAwaitHook;
+ if (hook != null) {
+ hook.run();
+ }
+ Thread t = workerThread;
+ if (t == null || t == Thread.currentThread()) {
+ return true;
+ }
+ long deadlineNanos = System.nanoTime() + workerJoinTimeoutMillis * 1_000_000L;
+ boolean interrupted = Thread.interrupted();
+ try {
+ RingEntry e = inService;
+ if (e == null || e.ring != ring || !e.isInService()) {
+ return true;
+ }
+ synchronized (lock) {
+ e.quiescenceWaiters++;
+ try {
+ while (e.isInService()) {
+ long remainingNanos = deadlineNanos - System.nanoTime();
+ if (remainingNanos <= 0) {
+ return false;
+ }
+ try {
+ // Round up so a sub-millisecond remainder still waits
+ // instead of spinning through wait(0) == wait-forever.
+ lock.wait(Math.max(1L, remainingNanos / 1_000_000L));
+ } catch (InterruptedException ignored) {
+ interrupted = true;
+ }
+ }
+ } finally {
+ e.quiescenceWaiters--;
+ }
+ }
+ return true;
+ } finally {
+ if (interrupted) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ }
+
+ /**
+ * True when no manager worker thread can be running: either
+ * {@link #start()} was never called, or a {@link #close()} confirmed
+ * worker termination and reaped the thread. Owners use this as a
+ * stronger fallback barrier when {@link #awaitRingQuiescence(SegmentRing)}
+ * times out but a subsequent {@code close()} join succeeded.
+ */
+ public synchronized boolean isWorkerReaped() {
+ return workerThread == null;
}
/**
@@ -213,6 +537,13 @@ public synchronized void close() {
* 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.
+ *
+ * Non-blocking: a worker service pass already in flight for this ring
+ * may still be running when this returns. Callers about to release
+ * resources the worker can reach (the ring itself, its watermark, its
+ * segment files, or the slot lock guarding its directory) MUST follow
+ * up with {@link #awaitRingQuiescence(SegmentRing)} and only release on
+ * a {@code true} result.
*/
public void deregister(SegmentRing ring) {
synchronized (lock) {
@@ -226,7 +557,7 @@ public void deregister(SegmentRing ring) {
// single subtraction covers both the initial seed
// and the net manager activity (provisions minus
// trims) for this ring.
- e.registered = false;
+ e.deregister();
totalBytes -= ring.totalSegmentBytes();
rings.remove(i);
return;
@@ -247,7 +578,7 @@ public void deregister(SegmentRing ring) {
* the high-water mark — no waiting on the next tick.
*/
public void register(SegmentRing ring, String dir) {
- register(ring, dir, null);
+ register(ring, dir, null, 0L);
}
/**
@@ -260,6 +591,20 @@ public void register(SegmentRing ring, String dir) {
* {@code lowestSurvivingBaseSeq - 1}.
*/
public void register(SegmentRing ring, String dir, AckWatermark watermark) {
+ register(ring, dir, watermark, 0L);
+ }
+
+ /**
+ * Registers a ring with an optional periodic data-checkpoint interval.
+ * A positive interval requires disk-backed store-and-forward mode.
+ */
+ public void register(SegmentRing ring, String dir, AckWatermark watermark, long syncIntervalNanos) {
+ if (syncIntervalNanos < 0L) {
+ throw new IllegalArgumentException("syncIntervalNanos must not be negative");
+ }
+ if (syncIntervalNanos > 0L && dir == null) {
+ throw new IllegalArgumentException("periodic sync requires a segment directory");
+ }
// 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, totalBytes stays at 0 and the per-tick cap check
@@ -273,7 +618,7 @@ public void register(SegmentRing ring, String dir, AckWatermark watermark) {
// the in-flight mmap. Memory-mode rings have no dir; nothing to scan.
long minNextGeneration = dir == null ? -1L : scanMaxGeneration(dir) + 1L;
Runnable managerWakeup = this::wakeWorker;
- RingEntry e = new RingEntry(ring, dir, watermark);
+ RingEntry e = new RingEntry(ring, dir, watermark, syncIntervalNanos, ticks.getAsLong());
// ObjList.add either throws before storing e or makes the entry visible.
// Once visible, only non-throwing state commits may remain.
synchronized (lock) {
@@ -282,6 +627,12 @@ public void register(SegmentRing ring, String dir, AckWatermark watermark) {
}
rings.add(e);
totalBytes += ringBytes;
+ if (syncIntervalNanos > 0L) {
+ ring.enablePeriodicSync();
+ if (syncIntervalNanos < shortestSyncIntervalNanos) {
+ shortestSyncIntervalNanos = syncIntervalNanos;
+ }
+ }
}
ring.setManagerWakeup(managerWakeup);
// Nudge the worker so it picks up the new ring on its very next
@@ -297,16 +648,82 @@ public void register(SegmentRing ring, String dir, AckWatermark watermark) {
wakeWorker();
}
+ @TestOnly
+ public SegmentRing getInServiceRingForTesting() {
+ RingEntry entry = inService;
+ return entry == null ? null : entry.ring;
+ }
+
+ @TestOnly
+ public long getTotalBytesForTesting() {
+ synchronized (lock) {
+ return totalBytes;
+ }
+ }
+
+ @TestOnly
+ public Thread getWorkerThreadForTesting() {
+ return workerThread;
+ }
+
+ @TestOnly
+ public boolean isPathScratchAllocatedForTesting() {
+ synchronized (lock) {
+ return !scratchFreed;
+ }
+ }
+
+ @TestOnly
+ public boolean serviceRingForTesting(SegmentRing ring) {
+ if (workerThread != null) {
+ throw new IllegalStateException("test service requires a stopped manager");
+ }
+ RingEntry selected = null;
+ synchronized (lock) {
+ for (int i = 0, n = rings.size(); i < n; i++) {
+ RingEntry candidate = rings.getQuick(i);
+ if (candidate.ring == ring) {
+ selected = candidate;
+ break;
+ }
+ }
+ }
+ if (selected == null) {
+ throw new IllegalArgumentException("ring is not registered");
+ }
+ return serviceRing(selected);
+ }
+
+ @TestOnly
+ public void setAfterRingCleanupHook(Runnable hook) {
+ this.afterRingCleanupHook = hook;
+ }
+
+ @TestOnly
+ 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;
+ }
+
@TestOnly
public void setBeforeTrimSyncHook(Runnable hook) {
this.beforeTrimSyncHook = hook;
}
+ @TestOnly
+ public void setRetryLogHook(Runnable hook) {
+ this.retryLogHook = hook;
+ }
+
@TestOnly
public void setWorkerJoinTimeoutMillis(long millis) {
this.workerJoinTimeoutMillis = millis;
@@ -341,21 +758,19 @@ public void wakeWorker() {
* files in {@code dir}, or {@code -1} if none exist. Skips files that
* don't match the pattern (e.g. the legacy {@code sf-initial.sfa}).
*/
- private static long scanMaxGeneration(String dir) {
+ private long scanMaxGeneration(String dir) {
long max = -1L;
- if (!Files.exists(dir)) return max;
- long find = Files.findFirst(dir);
+ if (!filesFacade.exists(dir)) return max;
+ long find = filesFacade.findFirst(dir);
if (find < 0) {
- LOG.warn("scanMaxGeneration could not enumerate {}; "
- + "next spare may collide with an existing on-disk segment", dir);
- return max;
+ throw new SfOperationalException("could not enumerate SF segment directory " + dir);
}
if (find == 0) return max;
try {
int rc = 1;
while (rc > 0) {
- String name = Files.utf8ToString(Files.findName(find));
- rc = Files.findNext(find);
+ String name = Files.utf8ToString(filesFacade.findName(find));
+ rc = filesFacade.findNext(find);
if (name == null || !name.startsWith("sf-") || !name.endsWith(".sfa")) {
continue;
}
@@ -368,8 +783,11 @@ private static long scanMaxGeneration(String dir) {
// sf-initial.sfa or non-hex — skip
}
}
+ if (rc < 0) {
+ throw new SfOperationalException("could not fully enumerate SF segment directory " + dir);
+ }
} finally {
- Files.findClose(find);
+ filesFacade.findClose(find);
}
return max;
}
@@ -411,14 +829,62 @@ private String nextSparePath(String dir) {
return displayPath;
}
- private void serviceRing(RingEntry e) {
+ 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,
+ // this stale snapshot pass skips the ring without touching it.
+ inService = e;
+ if (!ENTRY_STATE_UPDATER.compareAndSet(e, ENTRY_REGISTERED, ENTRY_IN_SERVICE)) {
+ inService = null;
+ return false;
+ }
+ boolean hasMoreTrimmable;
+ try {
+ hasMoreTrimmable = serviceRing0(e);
+ } finally {
+ e.finishService();
+ Runnable cleanup = e.quiescenceCleanup == null
+ ? null
+ : ENTRY_CLEANUP_UPDATER.getAndSet(e, null);
+ inService = null;
+ // A normal pass performs only the two state CAS operations above.
+ // The manager monitor is entered here only for an actual close
+ // waiter; the recheck under lock prevents lost wakeups.
+ if (e.quiescenceWaiters > 0) {
+ synchronized (lock) {
+ if (e.quiescenceWaiters > 0) {
+ lock.notifyAll();
+ }
+ }
+ }
+ if (cleanup != null) {
+ try {
+ cleanup.run();
+ } catch (Throwable t) {
+ LOG.error("deferred engine cleanup failed after manager-worker ring pass", t);
+ } finally {
+ Runnable hook = afterRingCleanupHook;
+ if (hook != null) {
+ hook.run();
+ }
+ }
+ }
+ }
+ return hasMoreTrimmable;
+ }
+
+ private boolean serviceRing0(RingEntry e) {
+ boolean memoryMode = e.dir == null;
+ if (!memoryMode) {
+ servicePeriodicSync(e, ticks.getAsLong());
+ }
+
// 1. Provision a hot spare if the ring needs one AND we have headroom
// under the disk-total cap. Cap check is per-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.
- boolean memoryMode = e.dir == null;
if (e.ring.needsHotSpare()) {
// Snapshot totalBytes under lock — register/deregister can mutate
// it from caller threads. Heavy provisioning I/O happens outside
@@ -455,14 +921,21 @@ private void serviceRing(RingEntry e) {
// via its long-ptr overload, bypassing the byte[] + native
// malloc that the String overload would incur on every
// rotation.
- spare = MmapSegment.create(FilesFacade.INSTANCE,
+ spare = MmapSegment.create(filesFacade,
pathScratch.ptr(), path,
- e.ring.nextSeqHint(), segmentSizeBytes);
+ 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 + commit atomically under the manager lock.
// If `e.ring` was deregistered between the snapshot
// above and now, abandoning the spare here is the only
@@ -476,7 +949,7 @@ private void serviceRing(RingEntry e) {
// observe the spare in the ring (and subtract it) or
// run before installation (so no install happens).
synchronized (lock) {
- if (e.registered) {
+ if (e.isRegistered()) {
e.ring.installHotSpare(spare);
totalBytes += segmentSizeBytes;
installed = true;
@@ -493,110 +966,367 @@ private void serviceRing(RingEntry e) {
} catch (Throwable ignored) {
}
}
- // Remove the file even when spare is null (i.e. when
- // MmapSegment.create itself threw): MmapSegment.create's
- // catch already best-effort removes, but if anything
- // before mmap (e.g. an exception thrown by the JVM
- // between openCleanRW and the try block) leaves a file
- // on disk, this is the second-line defense. Repeated
- // unlink on an already-removed path is a harmless no-op.
- if (path != null) {
- Files.remove(path);
+ // 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 any segments that the ring says are fully acked. For
- // memory-mode rings, "trim" is just close() (Unsafe.free) — no
- // file to unlink.
- //
- // The watermark write and totalBytes commit are registration-gated
- // under `lock` so stale worker snapshots cannot touch the
- // engine-owned watermark or mutate accounting after deregister()
- // returns. drainTrimmable still runs for stale snapshots: it
- // transfers ownership of fully-acked sealed segments to this
- // worker, preserving the old close + unlink behavior.
- //
- // munmap + unlink stay outside the lock — they can be slow
- // and shouldn't block register/deregister or sibling rings.
- ObjList trim;
+ // 2. Trim fully ACKed segments. The ring first transfers one bounded,
+ // unpinned prefix out of live traversal and into hidden pending
+ // ownership. Only then may this worker unmap it. Disk mode keeps the
+ // pending prefix until unlink + directory fsync are durable; memory
+ // mode commits each successfully freed prefix immediately. No
+ // syscall runs under the manager lock.
Runnable hook = beforeTrimSyncHook;
if (hook != null) {
hook.run();
}
- synchronized (lock) {
- boolean registered = e.registered;
- // Persist the current ackedFsn watermark BEFORE the trim runs.
- // On host crash between the persist and the unlinks below, the
- // segments survive and the watermark is correct. On crash AFTER
- // the unlinks but before next tick, the segments are gone and
- // the watermark is stale, but recovery clamps with
- // max(lowestSurvivingBaseSeq - 1, watermark) so either ordering
- // is safe. Memory-mode rings (and callers that didn't supply a
- // watermark) skip the write.
- // Persist only on advance to avoid pointless mmap stores when
- // ackedFsn is steady. The store is a single 8-byte put against
- // an already-mapped region -- no syscall, no allocation -- but
- // the gate keeps the dirty-page footprint minimal under
- // steady-state load with no new acks arriving.
- if (registered && e.watermark != null) {
- long currentAck = e.ring.ackedFsn();
- if (currentAck > e.lastPersistedAck) {
- e.watermark.write(currentAck);
- e.lastPersistedAck = currentAck;
- }
- }
- trim = e.ring.drainTrimmable();
- if (registered && trim != null) {
- for (int i = 0, n = trim.size(); i < n; i++) {
- totalBytes -= trim.get(i).sizeBytes();
- }
- }
- }
- if (trim != null) {
- for (int i = 0, n = trim.size(); i < n; i++) {
- MmapSegment s = trim.get(i);
- String path = s.path();
- try {
- s.close();
- if (path != null && !Files.remove(path)) {
- LOG.warn("Failed to unlink trimmed segment {}", path);
+ 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;
+ }
+
+ if (memoryMode) {
+ int batchSize = pendingCount > 0
+ ? e.ring.copyPendingTrims(trimBatch)
+ : e.ring.stagePendingTrims(
+ trimBatch, MAX_TRIMS_PER_RING_PASS, e.ring.ackedFsn());
+ int closed = 0;
+ Throwable closeFailure = null;
+ while (closed < batchSize) {
+ try {
+ trimBatch[closed].close();
+ closed++;
} catch (Throwable t) {
- LOG.warn("Failed to trim segment {}", path == null ? "" : path, t);
+ closeFailure = t;
+ break;
}
}
+ if (closed > 0) {
+ synchronized (lock) {
+ long removedBytes = e.ring.commitPendingTrims(trimBatch, closed);
+ if (e.isRegistered()) {
+ totalBytes -= removedBytes;
+ }
+ }
+ }
+ for (int i = 0; i < batchSize; i++) {
+ trimBatch[i] = null;
+ }
+ if (closeFailure != null) {
+ LOG.warn("Failed to trim memory segment", closeFailure);
+ return false;
+ }
+ return e.ring.firstTrimmable() != null;
+ }
+
+ // A deferred disk retry does no sync, unlink, or logging work. Signed
+ // subtraction is the standard wrap-safe deadline comparison because
+ // the bounded delay is many orders of magnitude below half the long
+ // range, even when the monotonic clock wraps.
+ long now = ticks.getAsLong();
+ if (e.trimRetryDelayNanos != 0 && now - e.trimRetryAtNanos < 0) {
+ 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.
+ if (e.watermark == null) {
+ if (!e.missingWatermarkLogged) {
+ e.missingWatermarkLogged = true;
+ LOG.warn("Cannot durably trim acknowledged segments in {} without an ack watermark", e.dir);
+ }
+ return false;
+ }
+ long durableAck;
+ synchronized (lock) {
+ if (!e.isRegistered()) {
+ return false;
+ }
+ durableAck = e.ring.ackedFsn();
+ if (durableAck > e.lastPersistedAck) {
+ e.watermark.write(durableAck);
+ e.lastPersistedAck = durableAck;
+ }
+ }
+ try {
+ e.watermark.sync();
+ if (filesFacade.fsyncDir(e.dir) != 0) {
+ recordTrimFailure(e, TRIM_RETRY_PRE_BARRIER, now, null);
+ return false;
+ }
+ } catch (Throwable t) {
+ recordTrimFailure(e, TRIM_RETRY_PRE_BARRIER, now, t);
+ return false;
+ }
+
+ int batchSize;
+ if (pendingCount > 0) {
+ batchSize = e.ring.copyPendingTrims(trimBatch);
+ } else {
+ try {
+ // Under the ring monitor: advance the manifest past the last
+ // eligible member, then atomically hide the batch. No I/O pin
+ // can appear between the eligibility check and live removal.
+ batchSize = e.ring.stagePendingTrims(
+ trimBatch, MAX_TRIMS_PER_RING_PASS, durableAck);
+ } catch (Throwable t) {
+ for (int i = 0; i < trimBatch.length; i++) {
+ trimBatch[i] = null;
+ }
+ recordTrimFailure(e, TRIM_RETRY_UNLINK, now, t);
+ return false;
+ }
+ if (batchSize == 0) {
+ return false;
+ }
+ }
+
+ boolean trimFailed = false;
+ Throwable trimFailure = null;
+ int unlinked = 0;
+ while (unlinked < batchSize) {
+ MmapSegment trimming = trimBatch[unlinked];
+ String path = trimming.path();
+ try {
+ trimming.close();
+ if (!filesFacade.remove(path) && filesFacade.exists(path)) {
+ trimFailed = true;
+ break;
+ }
+ unlinked++;
+ } catch (Throwable t) {
+ trimFailed = true;
+ trimFailure = t;
+ break;
+ }
+ }
+
+ if (unlinked > 0) {
+ try {
+ if (filesFacade.fsyncDir(e.dir) != 0) {
+ for (int i = 0; i < batchSize; i++) {
+ trimBatch[i] = null;
+ }
+ recordTrimFailure(e, TRIM_RETRY_POST_BARRIER, now, null);
+ return false;
+ }
+ } catch (Throwable t) {
+ for (int i = 0; i < batchSize; i++) {
+ trimBatch[i] = null;
+ }
+ recordTrimFailure(e, TRIM_RETRY_POST_BARRIER, now, t);
+ return false;
+ }
+ try {
+ synchronized (lock) {
+ long removedBytes = e.ring.commitPendingTrims(trimBatch, unlinked);
+ if (e.isRegistered()) {
+ totalBytes -= removedBytes;
+ }
+ }
+ } catch (Throwable t) {
+ for (int i = 0; i < batchSize; i++) {
+ trimBatch[i] = null;
+ }
+ recordTrimFailure(e, TRIM_RETRY_POST_BARRIER, now, t);
+ return false;
+ }
+ }
+ for (int i = 0; i < batchSize; i++) {
+ trimBatch[i] = null;
+ }
+ if (trimFailed) {
+ recordTrimFailure(e, TRIM_RETRY_UNLINK, now, trimFailure);
+ return false;
+ }
+ recordTrimSuccess(e);
+ return e.ring.firstTrimmable() != null;
+ }
+
+ private void recordTrimFailure(RingEntry e, int failureKind, long now, Throwable failure) {
+ long delay = e.trimRetryDelayNanos == 0
+ ? TRIM_RETRY_INITIAL_NANOS
+ : Math.min(e.trimRetryDelayNanos << 1, TRIM_RETRY_MAX_NANOS);
+ e.trimRetryAtNanos = now + delay;
+ e.trimRetryDelayNanos = delay;
+ if (e.trimRetryFailureKind != failureKind) {
+ e.trimRetryFailureKind = failureKind;
+ if (failure == null) {
+ LOG.warn("Durable segment trim failed in {} during {} (retry delayed)",
+ e.dir, trimFailureName(failureKind));
+ } else {
+ LOG.warn("Durable segment trim failed in {} during {} (retry delayed)",
+ e.dir, trimFailureName(failureKind), failure);
+ }
+ Runnable hook = retryLogHook;
+ if (hook != null) {
+ hook.run();
+ }
+ }
+ }
+
+ private void recordTrimSuccess(RingEntry e) {
+ if (e.trimRetryDelayNanos != 0) {
+ e.trimRetryAtNanos = 0;
+ e.trimRetryDelayNanos = 0;
+ e.trimRetryFailureKind = TRIM_RETRY_NONE;
+ LOG.info("Durable segment trim recovered in {}", e.dir);
+ Runnable hook = retryLogHook;
+ if (hook != null) {
+ hook.run();
+ }
+ }
+ }
+
+ private static String trimFailureName(int failureKind) {
+ switch (failureKind) {
+ case TRIM_RETRY_PRE_BARRIER:
+ return "covering barrier";
+ case TRIM_RETRY_UNLINK:
+ return "segment unlink";
+ default:
+ return "directory commit barrier";
+ }
+ }
+
+ private void servicePeriodicSync(RingEntry e, long now) {
+ if (e.syncIntervalNanos <= 0L
+ || (!e.ring.isSyncRequested() && now - e.nextDataSyncNanos < 0L)) {
+ return;
+ }
+ try {
+ e.ring.copyLiveSegmentsForSync(syncScratch);
+ for (int i = 0, n = syncScratch.size(); i < n; i++) {
+ syncScratch.getQuick(i).syncPublished();
+ }
+ e.ring.clearSyncRequestIfActiveDurable();
+ // The pass above covered every live segment's published range, and
+ // a failed barrier re-dirties its range under an mlock pin (see
+ // MmapSegment.syncPublished), so a success here is a genuine
+ // re-persist -- not a vacuous retry over pages a failed writeback
+ // marked clean (fsyncgate). Unlatch so a transient disk fault
+ // doesn't permanently brick the producer.
+ e.ring.clearDurabilityFailure();
+ e.nextDataSyncNanos = now + e.syncIntervalNanos;
+ if (e.syncFailureLogged) {
+ e.syncFailureLogged = false;
+ LOG.info("Periodic SF data sync recovered for {}", e.dir);
+ }
+ } catch (Throwable failure) {
+ e.ring.recordDurabilityFailure(failure);
+ if (!e.syncFailureLogged) {
+ e.syncFailureLogged = true;
+ LOG.error("Periodic SF data sync failed for {}", e.dir, failure);
+ }
+ long retry = Math.min(e.syncIntervalNanos, 1_000_000_000L);
+ e.nextDataSyncNanos = now + retry;
+ } finally {
+ syncScratch.clear();
}
}
private void workerLoop() {
- 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.
- ringSnapshot.clear();
+ 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.
+ ringSnapshot.clear();
+ synchronized (lock) {
+ for (int i = 0, n = rings.size(); i < n; i++) {
+ ringSnapshot.add(rings.getQuick(i));
+ }
+ }
+ boolean hasMoreTrimmable = false;
+ for (int i = 0, n = ringSnapshot.size(); i < n; i++) {
+ if (!running) break;
+ if (serviceRing(ringSnapshot.getQuick(i))) {
+ hasMoreTrimmable = true;
+ }
+ }
+ // Drop strong refs so a deregistered ring becomes collectable
+ // before the next tick (otherwise the snapshot pins it for up
+ // to pollNanos after deregister).
+ ringSnapshot.clear();
+ if (!running) break;
+ if (!hasMoreTrimmable) {
+ LockSupport.parkNanos(Math.min(pollNanos, shortestSyncIntervalNanos));
+ }
+ }
+ } 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) {
- for (int i = 0, n = rings.size(); i < n; i++) {
- ringSnapshot.add(rings.getQuick(i));
+ workerLoopExited = true;
+ if (scratchHandedToWorker && !scratchFreed) {
+ scratchFreed = true;
+ pathScratch.close();
}
+ cleanups = exitCleanups;
+ exitCleanups = null;
+ ownedEngineCleanup = ownedEngineExitCleanup;
+ ownedEngineExitCleanup = null;
}
- for (int i = 0, n = ringSnapshot.size(); i < n; i++) {
- if (!running) break;
- serviceRing(ringSnapshot.getQuick(i));
+ // Deferred engine cleanups run OUTSIDE
+ // `lock`: they perform syscalls (munmap, unlink, flock release)
+ // and must never execute under a lock that close()/register/
+ // deregister callers contend on. Running them after the loop body
+ // is what makes the handoff safe: this thread can no longer touch
+ // any slot path. They must also never block on a caller-held
+ // monitor — a retried engine.close() joins this thread while
+ // holding the engine monitor, which is why the engine side uses a
+ // lock-free claim (CAS), not synchronization, for exactly-once.
+ if (ownedEngineCleanup != null) {
+ try {
+ ownedEngineCleanup.run();
+ } catch (Throwable t) {
+ LOG.error("deferred owned-engine cleanup failed on manager-worker exit", t);
+ }
+ }
+ if (cleanups != null) {
+ for (int i = 0, n = cleanups.size(); i < n; i++) {
+ try {
+ cleanups.getQuick(i).run();
+ } catch (Throwable t) {
+ LOG.error("deferred engine cleanup failed on manager-worker exit", t);
+ }
+ }
}
- // Drop strong refs so a deregistered ring becomes collectable
- // before the next tick (otherwise the snapshot pins it for up
- // to pollNanos after deregister).
- ringSnapshot.clear();
- if (!running) break;
- LockSupport.parkNanos(pollNanos);
}
}
private static final class RingEntry {
final String dir;
final SegmentRing ring;
+ 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).
@@ -607,16 +1337,77 @@ private static final class RingEntry {
// Survives across multiple serviceRing ticks and avoids a
// write-storm when ackedFsn is steady.
long lastPersistedAck = -1L;
- // Guarded by SegmentManager.lock. A worker snapshot may retain this
- // entry after deregister() removes it from rings; registered=false is
- // the O(1) ownership check that prevents post-deregister writes through
- // the engine-owned watermark, hot-spare installs, and accounting.
- boolean registered = true;
+ // Prevents a legacy disk registration without a watermark from
+ // flooding the log on every manager tick.
+ boolean missingWatermarkLogged;
+ long nextDataSyncNanos;
+ boolean syncFailureLogged;
+ // Zero-allocation manager-thread-only retry state. The deadline uses
+ // the manager's monotonic clock and the delay doubles to a fixed cap.
+ long trimRetryAtNanos;
+ long trimRetryDelayNanos;
+ int trimRetryFailureKind;
+ // Updated lock-free by deferUntilRingQuiescent and pass completion.
+ // The field updater avoids one AtomicReference allocation per ring.
+ volatile Runnable quiescenceCleanup;
+ // Waiters mutate this under SegmentManager.lock; pass completion reads
+ // it before taking the otherwise-cold notification path.
+ volatile int quiescenceWaiters;
+ // REGISTERED -> IN_SERVICE -> REGISTERED on a normal pass.
+ // Deregistration changes either registered state to its corresponding
+ // deregistered state; completion then changes DEREGISTERED_IN_SERVICE
+ // to DEREGISTERED. The field updater avoids per-entry allocation.
+ volatile int state = ENTRY_REGISTERED;
- RingEntry(SegmentRing ring, String dir, AckWatermark watermark) {
+ RingEntry(
+ SegmentRing ring,
+ String dir,
+ AckWatermark watermark,
+ long syncIntervalNanos,
+ long now
+ ) {
this.ring = ring;
this.dir = dir;
this.watermark = watermark;
+ this.syncIntervalNanos = syncIntervalNanos;
+ // Run the first periodic pass immediately. This establishes a
+ // durable baseline for segments recovered from MEMORY mode.
+ this.nextDataSyncNanos = now;
+ }
+
+ void deregister() {
+ while (true) {
+ int current = state;
+ int next = current == ENTRY_IN_SERVICE
+ ? ENTRY_DEREGISTERED_IN_SERVICE
+ : ENTRY_DEREGISTERED;
+ if (current == ENTRY_DEREGISTERED || current == ENTRY_DEREGISTERED_IN_SERVICE
+ || ENTRY_STATE_UPDATER.compareAndSet(this, current, next)) {
+ return;
+ }
+ }
+ }
+
+ void finishService() {
+ while (true) {
+ int current = state;
+ int next = current == ENTRY_DEREGISTERED_IN_SERVICE
+ ? ENTRY_DEREGISTERED
+ : ENTRY_REGISTERED;
+ if (ENTRY_STATE_UPDATER.compareAndSet(this, current, next)) {
+ return;
+ }
+ }
+ }
+
+ boolean isInService() {
+ int current = state;
+ return current == ENTRY_IN_SERVICE || current == ENTRY_DEREGISTERED_IN_SERVICE;
+ }
+
+ boolean isRegistered() {
+ int current = state;
+ return current == ENTRY_REGISTERED || current == ENTRY_IN_SERVICE;
}
}
}
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 c8516c4c..d366fbc5 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
@@ -25,12 +25,15 @@
package io.questdb.client.cutlass.qwp.client.sf.cursor;
import io.questdb.client.std.Files;
+import io.questdb.client.std.FilesFacade;
import io.questdb.client.std.ObjList;
import io.questdb.client.std.QuietCloseable;
import org.jetbrains.annotations.TestOnly;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import java.util.IdentityHashMap;
+
/**
* Chain of {@link MmapSegment}s presented to the user thread as one logical
* append-only log keyed by frame sequence number (FSN). Owns segment
@@ -40,10 +43,10 @@
*
+ * (single writer), and one pinned segment cursor for native reads.
*
Segment manager: polls {@link #needsHotSpare}, hands new
- * segments via {@link #installHotSpare}, drains trim-eligible segments
- * via {@link #drainTrimmable} on its own cadence.
+ * segments via {@link #installHotSpare}, and stages trim-eligible
+ * segments into hidden cleanup ownership on its own cadence.
*
* No locks; the only cross-thread state is {@link #publishedFsn} (volatile,
* single-writer) and {@link #ackedFsn} (volatile, single-writer). Hot-spare
@@ -62,15 +65,31 @@ public final class SegmentRing implements QuietCloseable {
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;
+ private static final RetainedSegmentMembershipMode DEFAULT_MEMBERSHIP_MODE =
+ RetainedSegmentMembershipMode.IDENTITY;
private static final Logger LOG = LoggerFactory.getLogger(SegmentRing.class);
+ private static final int MAX_PENDING_TRIMS = 64;
+ // Tally of sealed-list entries inspected by nextSealedAfter(). Test-only
+ // operation count for deterministic traversal-complexity assertions.
+ private static long nextSealedComparisons;
// Tally of baseSeq comparisons performed by sortByBaseSeq across every
// openExisting() recovery on this JVM. Used by SegmentRingTest to
// assert the sort stays O(N log N) without relying on wall-clock time
// (CI runner variance makes elapsed-millisecond bounds flaky). Cheap
- // in production: one volatile-free add per partition pass, dwarfed by
- // the mmap I/O the recovery does on every segment.
+ // in production: one volatile-free add per partition pass (plus two per
+ // sift level in the rare heapsort fallback), dwarfed by the mmap I/O
+ // the recovery does on every segment.
private static long sortComparisons;
+ // References copied while compacting the logical sealed-segment head.
+ // Test-only operation count for deterministic trim-complexity assertions.
+ private static long trimMovedReferences;
private final long maxBytesPerSegment;
+ private final SfManifest manifest;
+ // ACKed segments leave live traversal under the ring monitor before the
+ // manager unmaps them. They remain owned here until close + unlink + the
+ // directory barrier succeed, so failures stay retryable and accounted.
+ // At most one bounded manager batch is pending at a time.
+ private final ObjList pendingTrims = new ObjList<>(MAX_PENDING_TRIMS);
// Sealed segments in baseSeq order, oldest first. Active is held separately.
// Single-writer (producer thread, on rotation); single-reader at trim time
// (the segment manager). For now, both sides synchronize via the single-
@@ -78,6 +97,9 @@ public final class SegmentRing implements QuietCloseable {
// looks at sealedSegments after observing a higher ackedFsn, by which
// point the producer thread's add to sealedSegments has retired.
private final ObjList sealedSegments = new ObjList<>();
+ // Logical head of sealedSegments. Head removal nulls one entry and advances
+ // this index; occasional compaction bounds unused prefix slots.
+ private int sealedHead;
// 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
@@ -96,18 +118,28 @@ public final class SegmentRing implements QuietCloseable {
// 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;
+ // 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 (rotation just consumed one, or active crossed the
- // high-water mark while no spare is installed). Without this, the
- // manager only notices on its next polling tick -- fine on average,
- // but the worst-case wait is the full poll interval. Producer-thread-only.
+ // 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.
private Runnable managerWakeup;
private long nextSeq;
+ private boolean periodicSyncEnabled;
private volatile long publishedFsn;
+ 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
@@ -120,10 +152,15 @@ public final class SegmentRing implements QuietCloseable {
* frameCount == 0); typically supplied by the segment manager at startup.
*/
public SegmentRing(MmapSegment initialActive, long maxBytesPerSegment) {
+ this(initialActive, maxBytesPerSegment, null);
+ }
+
+ SegmentRing(MmapSegment initialActive, long maxBytesPerSegment, SfManifest manifest) {
if (initialActive == null) {
throw new IllegalArgumentException("initialActive must not be null");
}
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.
@@ -133,190 +170,720 @@ public SegmentRing(MmapSegment initialActive, long maxBytesPerSegment) {
}
/**
- * Recovers a ring from segments already on disk in {@code sfDir}. Used at
- * sender startup when the user's previous session left durable but
- * not-yet-acked frames behind. Walks every {@code *.sfa} file in the
- * directory, opens each via {@link MmapSegment#openExisting}, and
- * arranges them by baseSeq:
- *
- *
Highest-baseSeq segment becomes the active (further appends land
- * there until it fills, at which point normal rotation kicks in).
- *
All others become sealed segments awaiting ACK and trim.
- *
- * Returns {@code null} if the directory is empty or contains no
- * recognizable {@code .sfa} files -- the caller should then construct a
- * fresh ring with {@link #SegmentRing(MmapSegment, long)} and a freshly
- * created initial segment.
- *
- * Recovery is best-effort: a single bad-magic file is silently skipped
- * (logged-then-ignored is the right call here; a stray unrelated file in
- * the SF dir shouldn't take the whole sender down). A failure to open
- * an otherwise-valid segment IS fatal -- the caller's data integrity
- * depends on every segment being readable.
+ * Compatibility wrapper using the production facade. New startup code uses
+ * {@link #recover(FilesFacade, String, long)} so EMPTY is explicit.
*/
public static SegmentRing openExisting(String sfDir, long maxBytesPerSegment) {
- if (!Files.exists(sfDir)) {
- return null;
+ return openExisting(FilesFacade.INSTANCE, sfDir, maxBytesPerSegment);
+ }
+
+ /**
+ * Facade-aware variant of {@link #openExisting(String, long)}: every
+ * filesystem touch (enumeration, open, mmap, quarantine rename, manifest
+ * I/O) goes through {@code filesFacade} so recovery's fail-closed behavior
+ * can be fault-injected in tests. Returns {@code null} when the slot holds
+ * nothing recoverable; throws {@link MmapSegmentException} when the slot's
+ * state cannot be proven safe.
+ */
+ public static SegmentRing openExisting(FilesFacade filesFacade, String sfDir, long maxBytesPerSegment) {
+ Recovery recovery = recover(filesFacade, sfDir, maxBytesPerSegment);
+ return recovery.status == RecoveryStatus.EMPTY ? null : recovery.ring;
+ }
+
+ /**
+ * Exhaustively discovers and validates an SF slot without mutating frame
+ * data. Only after enumeration, opens, CRC scans, contiguity and manifest
+ * boundaries all succeed does it migrate a legacy chain or discard
+ * validated spares. Torn-tail residue is handled destroy-last:
+ * {@link MmapSegment#openExisting} only observes it, and recovery zeroes
+ * it exclusively where validation has proven it non-load-bearing -- the
+ * resumed active's tail (about to be reclaimed by appends) and sealed
+ * suffixes whose frame accounting validated complete against the chain.
+ * A tear that cost frames (e.g. a mid-file tear in a sealed member)
+ * fails closed BEFORE any of that, leaving every byte on disk for
+ * operator extraction.
+ */
+ static Recovery recover(FilesFacade filesFacade, String sfDir, long maxBytesPerSegment) {
+ return recover(filesFacade, sfDir, maxBytesPerSegment, null);
+ }
+
+ static Recovery recover(
+ FilesFacade filesFacade,
+ String sfDir,
+ long maxBytesPerSegment,
+ MembershipObserver membershipObserver
+ ) {
+ if (!filesFacade.exists(sfDir)) {
+ return Recovery.empty();
}
- ObjList opened = new ObjList<>();
- long find = Files.findFirst(sfDir);
+ ObjList names = new ObjList<>();
+ long find = filesFacade.findFirst(sfDir);
if (find < 0) {
- LOG.warn("openExisting could not enumerate {} - treating as empty, "
- + "but this may indicate a permission or transient error", sfDir);
- return null;
+ throw new MmapSegmentException("could not enumerate SF directory " + sfDir);
}
- if (find == 0) {
- return null;
- }
- // Outer try-catch: anything escaping the recovery body -- IOOBE from
- // ObjList growth, OOM from native mmap during MmapSegment.openExisting,
- // unforeseen RuntimeException from the contiguity check, etc. -- must
- // not leave fds + mmaps owned by `opened` orphaned. Close every
- // recovered segment and rethrow so the engine surfaces the failure.
- try {
+ if (find > 0) {
+ int rc = 1;
try {
- int rc = 1;
while (rc > 0) {
- String name = Files.utf8ToString(Files.findName(find));
+ String name = Files.utf8ToString(filesFacade.findName(find));
if (name != null && name.endsWith(".sfa")) {
- String path = sfDir + "/" + name;
- MmapSegment seg = null;
- try {
- seg = MmapSegment.openExisting(path);
- // Filter out empty leftovers -- typically hot-spare
- // segments the manager pre-allocated for a prior
- // session that never got rotated into active. They
- // carry the provisional baseSeq=0 and frameCount=0,
- // which would otherwise collide with the real
- // baseSeq=0 segment and trip the contiguity check
- // below. No data to recover; close and unlink.
- // Without the unlink the file persists across crash
- // cycles and the disk leak compounds with every
- // unclean shutdown.
- //
- // CAUTION: only unlink when the file is genuinely
- // empty past the header. If frame[0] failed CRC
- // (bit-rot, partial-page-write at crash, etc.) but
- // valid frames followed, scanFrames returns
- // lastGood=HEADER_SIZE and frameCount=0 -- yet
- // tornTailBytes is non-zero. Treating that as
- // "empty hot-spare" would silently destroy every
- // surviving frame. Quarantine to .corrupt
- // instead so a postmortem can recover what's left.
- if (seg.frameCount() == 0) {
- long torn = seg.tornTailBytes();
- seg.close();
- seg = null;
- if (torn > 0) {
- Files.rename(path, path + ".corrupt");
- } else {
- Files.remove(path);
- }
- } else {
- opened.add(seg);
- seg = null;
+ names.add(name);
+ }
+ rc = filesFacade.findNext(find);
+ }
+ if (rc < 0) {
+ throw new MmapSegmentException("could not fully enumerate SF directory " + sfDir);
+ }
+ } finally {
+ filesFacade.findClose(find);
+ }
+ }
+
+ ObjList all = new ObjList<>();
+ // Files whose own bytes prove corruption (bad magic, sub-header size,
+ // negative baseSeq). They are excluded from the chain and quarantined
+ // to .corrupt — but only AFTER the surviving chain validates (or
+ // resolves to EMPTY). The precise invariant: a failed recovery never
+ // mutates COMMITTED CHAIN BYTES. It is not "never mutates the slot"
+ // -- several windows durably mutate before a later step can still
+ // fail: proven-dead sealed residue is zeroed just before the
+ // fail-closed first-sight throw in sanitizeSealedResidue; the
+ // legacy-migration sanitize zeroes proven-dead residue before
+ // SfManifest.create (or any later step) can fail; and validated-
+ // extra cleanup plus corrupt-file quarantine run before the
+ // active-sanitize barrier or ring construction can fail. Every such
+ // window is confined to bytes the already-validated chain proves no
+ // replay can ever need, or to preserve-by-rename quarantines that
+ // keep the bytes on disk.
+ // Whether a quarantined file was load-bearing is decided by the
+ // manifest-boundary / contiguity checks below, not by the skip itself.
+ // Operational open/stat/read/mmap errors, observed size instability,
+ // and unsupported versions are NOT in this bucket: they throw the
+ // plain MmapSegmentException type and abort recovery, because the
+ // underlying file may be perfectly intact and silently dropping it
+ // could lose durable frames.
+ ObjList corruptPaths = null;
+ SfManifest manifest = null;
+ try {
+ for (int i = 0, n = names.size(); i < n; i++) {
+ String path = sfDir + "/" + names.get(i);
+ try {
+ all.add(MmapSegment.openExisting(filesFacade, path));
+ } catch (MmapSegmentCorruptionException e) {
+ LOG.warn("recovery: {} is not a readable SF segment; excluding it and "
+ + "deferring quarantine until the surviving chain validates -- {}",
+ path, e.toString());
+ if (corruptPaths == null) {
+ corruptPaths = new ObjList<>();
+ }
+ corruptPaths.add(path);
+ } catch (MmapSegmentException e) {
+ throw new MmapSegmentException("recovery failed for recognized segment " + path, e);
+ }
+ }
+ manifest = SfManifest.open(filesFacade, sfDir);
+ if (all.size() == 0) {
+ if (corruptPaths != null) {
+ // Nothing valid survived. With a manifest this is still a
+ // hole we can prove (boundaries reference segments that are
+ // now unreadable) -- fail without mutating. Without one,
+ // legacy semantics apply: quarantine and start fresh.
+ if (manifest != null) {
+ throw new SfRecoveryException("every SF segment in " + sfDir
+ + " is corrupt but " + SfManifest.FILE_NAME
+ + " references durable data");
+ }
+ quarantineCorrupt(filesFacade, corruptPaths);
+ return Recovery.empty();
+ }
+ if (manifest != null) {
+ // No .sfa files at all. Only two protocols legitimately
+ // produce this, and both leave head == active: the
+ // close-time drain durably collapses the boundaries
+ // BEFORE its first unlink, and a fresh-start crash can
+ // leave a boundary-less (0,0) manifest behind. Uncollapsed
+ // boundaries therefore prove durable, never-declared-acked
+ // frames existed in [headBase, activeBase] whose files
+ // vanished outside the protocol (manual wipe, partial
+ // restore) -- fail closed and keep the manifest as
+ // evidence instead of silently starting fresh.
+ long manifestHeadBase = manifest.headBase();
+ long manifestActiveBase = manifest.activeBase();
+ if (manifestHeadBase != manifestActiveBase) {
+ throw new SfRecoveryException(SfManifest.FILE_NAME + " in " + sfDir
+ + " references durable data (headBase=" + manifestHeadBase
+ + ", activeBase=" + manifestActiveBase
+ + ") but no segment files exist");
+ }
+ // Collapsed boundaries: nothing recoverable exists, so
+ // accept EMPTY -- but shout, because a manual wipe of a
+ // fully-acked slot looks identical and the operator
+ // should know.
+ LOG.warn("SF manifest exists in {} with collapsed boundaries ({}) and "
+ + "no segment files (clean-drain or fresh-start crash window, or "
+ + "manual segment removal); discarding it and starting fresh",
+ sfDir, manifestActiveBase);
+ manifest.close();
+ manifest = null;
+ if (!SfManifest.removeFile(filesFacade, sfDir)) {
+ throw new MmapSegmentException(
+ "could not remove stale SF manifest in " + sfDir);
+ }
+ }
+ return Recovery.empty();
+ }
+
+ ObjList data = new ObjList<>();
+ boolean requiresManifest = false;
+ for (int i = 0, n = all.size(); i < n; i++) {
+ MmapSegment segment = all.get(i);
+ requiresManifest |= segment.manifestRequired();
+ if (segment.frameCount() > 0) {
+ data.add(segment);
+ }
+ }
+ sortByBaseSeq(data, 0, data.size());
+ if (manifest == null && requiresManifest) {
+ throw new SfRecoveryException("new-format SF segment exists but "
+ + SfManifest.FILE_NAME + " is missing");
+ }
+
+ MmapSegment active;
+ ObjList chain = new ObjList<>();
+ long headBase;
+ long activeBase;
+ if (manifest == null) {
+ if (data.size() > 0) {
+ validateContiguous(data);
+ for (int i = 0, n = data.size(); i < n; i++) {
+ chain.add(data.get(i));
+ }
+ active = chain.get(chain.size() - 1);
+ headBase = chain.get(0).baseSeq();
+ activeBase = active.baseSeq();
+ // Legacy sealed members carrying pre-manifest torn
+ // residue: contiguity just proved their frame accounting
+ // complete, so the residue is dead bytes. Zero it now
+ // (silently -- legacy slots predate the fail-closed
+ // sealed-suffix contract) so the migrated chain presents
+ // the all-zero sealed suffixes the manifest-era
+ // invariant requires.
+ sanitizeSealedResidue(chain, false);
+ } else {
+ active = chooseEmptyInitial(all, sfDir);
+ if (active == null) {
+ // Legacy slot holding only empty leftovers, every one
+ // of them torn (a clean empty would have been chosen).
+ // Nothing recoverable: quarantine the torn evidence,
+ // drop the clean debris, start fresh -- exactly the
+ // pre-manifest behavior.
+ for (int i = 0, n = all.size(); i < n; i++) {
+ MmapSegment segment = all.get(i);
+ String path = segment.path();
+ long torn = segment.tornTailBytes();
+ segment.close();
+ if (torn > 0) {
+ quarantineFile(filesFacade, path);
+ } else if (!filesFacade.remove(path)) {
+ LOG.warn("could not remove empty SF leftover {}", path);
}
- } catch (MmapSegmentException t) {
- // Per-file data error (bad magic, bad header,
- // unsupported version, mmap rejection on this one
- // file). Don't take down recovery for one corrupt
- // .sfa -- log and skip so siblings still recover.
- // Resource exhaustion (OOM) and programmer errors
- // (IOOBE) deliberately propagate to the outer
- // catch, which closes every already-recovered
- // segment and rethrows: continuing the loop after
- // an OOM would just fail again on the next file
- // while silently leaking the segments we managed
- // to recover before it.
- LOG.warn("openExisting: skipping {} -- {}", path, t.toString());
- } finally {
- // Close any seg whose ownership wasn't transferred
- // (either to opened, or via the empty-branch close
- // above). Fires on a propagating throw between
- // open and transfer -- most importantly an OOM
- // from ObjList.add growing its backing array
- // after the mmap+fd were already acquired.
- if (seg != null) {
- try {
- seg.close();
- } catch (Throwable closeErr) {
- LOG.warn("openExisting: error closing in-flight segment {}",
- path, closeErr);
- }
+ }
+ all.clear();
+ quarantineCorrupt(filesFacade, corruptPaths);
+ return Recovery.empty();
+ }
+ chain.add(active);
+ headBase = active.baseSeq();
+ activeBase = active.baseSeq();
+ }
+ manifest = SfManifest.create(filesFacade, sfDir, headBase, activeBase);
+ for (int i = 0, n = chain.size(); i < n; i++) {
+ chain.get(i).markManifestRequired();
+ }
+ } else {
+ headBase = manifest.headBase();
+ activeBase = manifest.activeBase();
+ for (int i = 0, n = data.size(); i < n; i++) {
+ MmapSegment segment = data.get(i);
+ long end = segment.baseSeq() + segment.frameCount();
+ if (segment.baseSeq() < headBase) {
+ if (end > headBase) {
+ throw new SfRecoveryException("segment overlaps committed SF head boundary");
+ }
+ continue; // acknowledged stale file after manifest-before-unlink crash
+ }
+ if (segment.baseSeq() > activeBase) {
+ throw new SfRecoveryException("segment exists beyond committed SF active boundary");
+ }
+ chain.add(segment);
+ }
+ if (chain.size() > 0) {
+ validateContiguous(chain);
+ if (chain.get(0).baseSeq() != headBase) {
+ throw new SfRecoveryException("missing expected SF head segment at base " + headBase);
+ }
+ }
+ active = findActive(all, activeBase);
+ if (active == null) {
+ if (chain.size() == 0 && headBase == activeBase && corruptPaths == null) {
+ // Clean-drain crash window: the close-time drain first
+ // durably collapses the boundaries to head == active
+ // (declaring every frame acked), then unlinks segments
+ // in ascending order -- so dying between the active's
+ // unlink and the spare's/manifest's leaves exactly
+ // this state: no data frame anywhere, no file at the
+ // committed active base, only empty spares and/or
+ // acked stale files. Nothing recoverable exists;
+ // accept EMPTY and clear the debris. Guarded on
+ // corruptPaths because an unreadable .sfa of unknown
+ // identity could be the real active -- in that case
+ // fail closed instead of guessing.
+ LOG.warn("SF manifest in {} has collapsed boundaries ({}) with no "
+ + "segment at the active base and no recovered frames; "
+ + "accepting the clean-drain crash window as empty",
+ sfDir, activeBase);
+ for (int i = 0, n = all.size(); i < n; i++) {
+ MmapSegment segment = all.get(i);
+ String path = segment.path();
+ long torn = segment.tornTailBytes();
+ segment.close();
+ if (torn > 0) {
+ quarantineFile(filesFacade, path);
+ } else if (!filesFacade.remove(path)) {
+ LOG.warn("could not remove drained SF leftover {}", path);
}
}
+ all.clear();
+ manifest.close();
+ manifest = null;
+ if (!SfManifest.removeFile(filesFacade, sfDir)) {
+ throw new MmapSegmentException(
+ "could not remove stale SF manifest in " + sfDir);
+ }
+ return Recovery.empty();
}
- rc = Files.findNext(find);
+ throw new SfRecoveryException("missing expected SF active segment at base " + activeBase);
}
- } finally {
- Files.findClose(find);
- }
- if (opened.size() == 0) {
- return null;
- }
- // Sort by baseSeq ascending. Worst-case segment count is
- // sf_max_total_bytes / sf_max_bytes -- at the documented ceiling
- // (1 TiB / 64 MiB) that is ~16K entries, where an O(N²) sort spends
- // multiple seconds in compares + shifts before the I/O thread can
- // start. In-place quicksort with median-of-three pivot keeps the
- // no-allocation discipline of the surrounding code; median-of-three
- // is required because readdir on many filesystems returns entries
- // in lexicographic (== baseSeq-hex) order and a naive first-element
- // pivot would degrade back to O(N²) on exactly that common case.
- sortByBaseSeq(opened, 0, opened.size());
- // Sanity: the recovered segments must form a contiguous FSN range.
- // Detect gaps so they don't silently produce duplicate or missing
- // FSNs after recovery. A gap means a segment went missing (a
- // manual deletion) or a sealed segment under-recovered -- its tail
- // was cut short by a sparse/unbacked page or a mid-file media error
- // (bad sector), the same class of fault scanFrames tolerates on the
- // active segment but which corrupts the range on a sealed one.
- for (int i = 1, n = opened.size(); i < n; i++) {
- MmapSegment prev = opened.get(i - 1);
- MmapSegment curr = opened.get(i);
- long expected = prev.baseSeq() + prev.frameCount();
- if (curr.baseSeq() != expected) {
- throw new MmapSegmentException(
- "FSN gap in recovered segments: prev baseSeq=" + prev.baseSeq()
- + " frameCount=" + prev.frameCount()
- + " expected next baseSeq=" + expected
- + " but got " + curr.baseSeq()
- + " -- a segment was deleted, or a sealed segment's tail was"
- + " truncated (sparse/unbacked page or disk media error);"
- + " check disk health");
+ if (chain.size() == 0) {
+ if (headBase != activeBase || active.frameCount() != 0 || corruptPaths != null) {
+ // corruptPaths guard: with an unreadable .sfa in the
+ // slot, the innocent-looking empty at the active base
+ // could be a leftover spare coincidentally carrying
+ // the same provisional baseSeq as a corrupted real
+ // active -- accepting it would quarantine unacked
+ // frames and re-issue their FSNs. Fail closed.
+ throw new SfRecoveryException(
+ "missing SF chain between committed boundaries"
+ + (corruptPaths != null
+ ? " (a corrupt segment prevents proving the empty state)" : ""));
+ }
+ chain.add(active);
+ } else if (chain.get(chain.size() - 1) != active) {
+ MmapSegment tail = chain.get(chain.size() - 1);
+ long chainEnd = tail.baseSeq() + tail.frameCount();
+ if (corruptPaths == null && active.frameCount() == 0 && active.baseSeq() == chainEnd) {
+ // Rotation committed (manifest fsync'd, promoted spare's
+ // header synced) but the process/OS died before a single
+ // frame of the new active reached disk: the sealed chain
+ // ends exactly where the empty active begins. Legal
+ // crash state -- resume appending into it. Refused when
+ // corrupt segments exist (same stand-in hazard as the
+ // empty-chain acceptance above).
+ chain.add(active);
+ } else {
+ throw new SfRecoveryException(
+ "missing expected SF active/tail segment at base " + activeBase);
+ }
+ }
+ // Sealed members must present an all-zero suffix: fresh
+ // segments are zero-allocated and this recovery sanitizes
+ // the resumed active before it takes appends, so a reseal
+ // can never carry residue forward. Reaching this point
+ // proves every sealed member's frame accounting is complete
+ // (validateContiguous plus the head/active boundary matching
+ // above), so any suffix residue here is dead bytes -- legacy
+ // pre-sanitization poison or an out-of-protocol scribble
+ // past the last frame, never lost frames. Zero it durably,
+ // then still fail closed on first sight so the incident is
+ // surfaced; the restart then proves the chain clean. A tear
+ // that DID cost frames never gets here: the contiguity or
+ // boundary checks above throw first, with every byte left
+ // on disk for operator extraction.
+ sanitizeSealedResidue(chain, true);
+ for (int i = 0, n = chain.size(); i < n; i++) {
+ chain.get(i).markManifestRequired();
}
}
- // The newest segment becomes the active. Even if it's full, that's OK:
- // the next appendOrFsn returns BACKPRESSURE_NO_SPARE, the manager
- // installs a hot spare, the producer rotates. Same fast path as a
- // mid-life ring.
- int last = opened.size() - 1;
- MmapSegment active = opened.get(last);
- opened.remove(last);
- SegmentRing ring = new SegmentRing(active, maxBytesPerSegment);
- // Older segments become sealed in baseSeq order.
- for (int i = 0, n = opened.size(); i < n; i++) {
- ring.sealedSegments.add(opened.get(i));
+
+ // Build membership and clean validated extras while every mmap and
+ // the manifest still have one local owner. In particular, an OOM
+ // while allocating the identity map falls through to the outer
+ // failure cleanup instead of stranding extras after transfer.
+ RetainedSegmentMembership retained = newDefaultMembership(chain, membershipObserver);
+ for (int i = 0, n = all.size(); i < n; i++) {
+ MmapSegment segment = all.get(i);
+ if (!retained.contains(segment)) {
+ String path = null;
+ long torn = 0;
+ Throwable inspectionError = null;
+ try {
+ path = segment.path();
+ torn = segment.tornTailBytes();
+ } catch (Throwable error) {
+ inspectionError = error;
+ }
+ // A close failure is not best-effort: keep local ownership
+ // and fail through the outer cleanup rather than transfer a
+ // ring while an extra may still own an mmap or fd.
+ segment.close();
+ all.setQuick(i, null);
+ if (inspectionError != null) {
+ warnPostRecoveryCleanupFailure(inspectionError);
+ continue;
+ }
+ try {
+ cleanupClosedExtra(filesFacade, path, torn);
+ } catch (Throwable cleanupError) {
+ warnPostRecoveryCleanupFailure(cleanupError);
+ }
+ }
}
- return ring;
+ quarantineCorrupt(filesFacade, corruptPaths);
+
+ // The resumed active is the only segment that takes appends and
+ // the only one a later rotation reseals, so recovery zeroes ITS
+ // residue exactly here -- every chain/manifest check has passed
+ // and the ring is about to be exposed. Resumed appends restart
+ // at lastGood but stop wherever the last payload fits, so
+ // unzeroed residue would survive a seal-via-rotation (bricking
+ // the next startup's sealed-suffix check) and a byte-aligned
+ // stale frame with a valid CRC could be resurrected at a
+ // recycled FSN by a later scan. The durable barrier inside
+ // sanitizeTornTail is load-bearing in MEMORY durability mode,
+ // where rotation does not sync the sealed predecessor's data
+ // pages. A failed barrier aborts recovery (fail closed); the
+ // retry re-observes the same residue because openExisting never
+ // mutates. Unlike sealed suffixes, the active's residue is NOT
+ // proven dead -- past a mid-file tear it can hold valid-CRC
+ // frames of real unacked payloads. Zeroing is policy: replay
+ // cannot cross the tear, and preserving the bytes would trade
+ // the two hazards above for data no recovery path can use (see
+ // sanitizeTornTail).
+ active.sanitizeTornTail();
+ for (int i = 1, n = chain.size(); i < n; i++) {
+ chain.get(i - 1).linkSuccessor(chain.get(i));
+ }
+ SegmentRing ring = new SegmentRing(active, maxBytesPerSegment, manifest);
+ for (int i = 0, n = chain.size() - 1; i < n; i++) {
+ ring.sealedSegments.add(chain.get(i));
+ }
+ // 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.
+ Recovery recovery = Recovery.recovered(ring);
+ manifest = null;
+ all.clear();
+ return recovery;
} catch (Throwable t) {
- // Close every recovered MmapSegment that's still in `opened`.
- // After the success path, `opened` no longer contains the active
- // segment (removed above), but the sealed segments transferred to
- // ring.sealedSegments are still owned by the ring once it's
- // returned -- so this catch only fires before the return statement.
- for (int i = 0, n = opened.size(); i < n; i++) {
+ for (int i = 0, n = all.size(); i < n; i++) {
+ MmapSegment segment = all.get(i);
+ if (segment != null) {
+ try {
+ segment.close();
+ } catch (Throwable closeError) {
+ warnRecoveryCloseFailure(closeError);
+ }
+ }
+ }
+ if (manifest != null) {
try {
- opened.get(i).close();
- } catch (Throwable closeErr) {
- LOG.warn("openExisting: error closing recovered segment during cleanup",
- closeErr);
+ manifest.close();
+ } catch (Throwable closeError) {
+ warnRecoveryCloseFailure(closeError);
}
}
throw t;
}
}
+ /**
+ * Durably advances the manifest head past {@code trimming} (the LAST
+ * sealed segment of the bounded batch the manager is about to unlink).
+ * One durable commit covers every earlier batch member: head values are
+ * segment boundaries and the batch is a contiguous prefix of the sealed
+ * chain, so recovery discards each member as "stale below head"
+ * regardless of how far the unlink loop got. The successor and the
+ * current active are both read under the ring monitor, so a concurrent
+ * rotation (which also mutates the manifest under this monitor) can never
+ * make the head leapfrog a still-live sealed segment: if rotation sealed
+ * the old active after the caller's snapshot, {@code trimming.successor()}
+ * now points at that sealed segment, not at the new active.
+ */
+ private synchronized void advanceManifestHeadPast(MmapSegment trimming) {
+ if (manifest == null) {
+ return;
+ }
+ MmapSegment successor = trimming.successor();
+ long newHeadBase = (successor == null || successor == active)
+ ? active.baseSeq()
+ : successor.baseSeq();
+ manifest.update(newHeadBase, active.baseSeq());
+ }
+
+ /**
+ * Picks the clean (untorn) empty segment to reuse as a legacy slot's
+ * initial active, preferring {@code sf-initial.sfa}. Returns {@code null}
+ * when no clean empty exists; torn empties are never reused here because
+ * their bytes are quarantine evidence, not blank space.
+ */
+ private static MmapSegment chooseEmptyInitial(ObjList all, String sfDir) {
+ String initialPath = sfDir + "/sf-initial.sfa";
+ MmapSegment selected = null;
+ for (int i = 0, n = all.size(); i < n; i++) {
+ MmapSegment segment = all.get(i);
+ if (segment.frameCount() != 0 || segment.tornTailBytes() > 0) {
+ continue;
+ }
+ if (selected == null || initialPath.equals(segment.path())) {
+ selected = segment;
+ }
+ }
+ return selected;
+ }
+
+ private static void cleanupClosedExtra(FilesFacade filesFacade, String path, long torn) {
+ if (torn > 0) {
+ quarantineFile(filesFacade, path);
+ } else if (!filesFacade.remove(path)) {
+ LOG.warn("could not remove validated stale/empty SF segment {}", path);
+ }
+ }
+
+ private static RetainedSegmentMembership newDefaultMembership(
+ final ObjList chain,
+ final MembershipObserver observer
+ ) {
+ if (observer != null) {
+ observer.beforeMembershipAllocation();
+ }
+ if (DEFAULT_MEMBERSHIP_MODE == RetainedSegmentMembershipMode.LINEAR) {
+ if (observer == null) {
+ return new RetainedSegmentMembership() {
+ @Override
+ public boolean contains(MmapSegment segment) {
+ for (int i = 0, n = chain.size(); i < n; i++) {
+ if (chain.get(i) == segment) {
+ return true;
+ }
+ }
+ return false;
+ }
+ };
+ }
+ return new RetainedSegmentMembership() {
+ @Override
+ public boolean contains(MmapSegment segment) {
+ for (int i = 0, n = chain.size(); i < n; i++) {
+ observer.onMembershipOperation();
+ if (chain.get(i) == segment) {
+ return true;
+ }
+ }
+ return false;
+ }
+ };
+ }
+
+ final IdentityHashMap retained = new IdentityHashMap<>(chain.size());
+ for (int i = 0, n = chain.size(); i < n; i++) {
+ retained.put(chain.get(i), Boolean.TRUE);
+ }
+ if (observer == null) {
+ return new RetainedSegmentMembership() {
+ @Override
+ public boolean contains(MmapSegment segment) {
+ return retained.containsKey(segment);
+ }
+ };
+ }
+ return new RetainedSegmentMembership() {
+ @Override
+ public boolean contains(MmapSegment segment) {
+ observer.onMembershipOperation();
+ return retained.containsKey(segment);
+ }
+ };
+ }
+
+ /** Renames every collected corrupt path to {@code .corrupt}, best-effort. */
+ private static void quarantineCorrupt(FilesFacade filesFacade, ObjList corruptPaths) {
+ if (corruptPaths == null) {
+ return;
+ }
+ for (int i = 0, n = corruptPaths.size(); i < n; i++) {
+ try {
+ quarantineFile(filesFacade, corruptPaths.get(i));
+ } catch (Throwable cleanupError) {
+ warnPostRecoveryCleanupFailure(cleanupError);
+ }
+ }
+ }
+
+ private static void quarantineFile(FilesFacade filesFacade, String path) {
+ if (filesFacade.rename(path, path + ".corrupt") != 0) {
+ LOG.warn("could not quarantine {} to {}.corrupt; it will be re-examined "
+ + "on the next recovery", path, path);
+ }
+ }
+
+ /**
+ * Locates the segment the manifest's {@code activeBase} refers to.
+ * Preference order among same-base candidates:
+ *
+ *
a segment with recovered frames (the durable chain tail);
+ *
an empty segment with a torn tail (the promoted active whose
+ * first frame write was cut short — an attempted write marks it as
+ * 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
+ * state would turn every "kill -9 shortly after start" into a manual
+ * repair.
+ */
+ private static MmapSegment findActive(ObjList all, long activeBase) {
+ MmapSegment tornEmpty = null;
+ MmapSegment cleanEmpty = null;
+ for (int i = 0, n = all.size(); i < n; i++) {
+ MmapSegment segment = all.get(i);
+ if (segment.baseSeq() != activeBase) {
+ continue;
+ }
+ if (segment.frameCount() > 0) {
+ return segment;
+ }
+ if (segment.tornTailBytes() > 0) {
+ if (tornEmpty == null) {
+ tornEmpty = segment;
+ }
+ } else if (cleanEmpty == null) {
+ cleanEmpty = segment;
+ }
+ }
+ return tornEmpty != null ? tornEmpty : cleanEmpty;
+ }
+
+ private static void validateContiguous(ObjList segments) {
+ for (int i = 1, n = segments.size(); i < n; i++) {
+ MmapSegment previous = segments.get(i - 1);
+ MmapSegment current = segments.get(i);
+ long expected = previous.baseSeq() + previous.frameCount();
+ if (current.baseSeq() != expected) {
+ throw new SfRecoveryException("FSN gap in recovered segments: expected "
+ + expected + " but got " + current.baseSeq());
+ }
+ }
+ }
+
+ /**
+ * Durably zeroes torn-tail residue on the chain's sealed members (every
+ * element but the last, which is the active). Callers must have already
+ * proven each sealed member's frame accounting complete -- contiguity
+ * plus head/active boundary matching -- which is exactly what makes the
+ * residue provably dead: a tear that cost frames breaks those checks and
+ * fails recovery before any mutation, preserving the bytes (potentially
+ * the only copy of unreachable valid-CRC frames) for operator
+ * extraction. With {@code failClosedOnSight} the incident is still
+ * surfaced as a first-sight {@link SfSanitizedResidueException} after
+ * sanitizing: the residue is already durably zeroed when it propagates,
+ * so a retry proves the chain clean (attended callers get that via
+ * restart; unattended callers key off the distinct type to retry
+ * instead of quarantining a just-healed slot). Without the flag the
+ * chain proceeds immediately (legacy migration, which predates the
+ * sealed-suffix contract).
+ */
+ private static void sanitizeSealedResidue(ObjList chain, boolean failClosedOnSight) {
+ String firstTornPath = null;
+ for (int i = 0, n = chain.size() - 1; i < n; i++) {
+ MmapSegment sealed = chain.get(i);
+ if (sealed.tornTailBytes() > 0) {
+ sealed.sanitizeTornTail();
+ if (firstTornPath == null) {
+ firstTornPath = sealed.path();
+ }
+ }
+ }
+ if (failClosedOnSight && firstTornPath != null) {
+ throw new SfSanitizedResidueException("corrupt torn tail in sealed SF segment " + firstTornPath);
+ }
+ }
+
+ private static void warnPostRecoveryCleanupFailure(Throwable cleanupError) {
+ try {
+ LOG.warn("post-recovery cleanup failed; leftover files will be "
+ + "re-examined on the next startup", cleanupError);
+ } catch (Throwable ignored) {
+ // Cleanup diagnostics must not invalidate a recovered ring.
+ }
+ }
+
+ private static void warnRecoveryCloseFailure(Throwable closeError) {
+ try {
+ LOG.warn("error closing SF resource after recovery failure", closeError);
+ } catch (Throwable ignored) {
+ // Preserve the original recovery failure and continue closing.
+ }
+ }
+
+ interface MembershipObserver {
+ void beforeMembershipAllocation();
+
+ void onMembershipOperation();
+ }
+
+ static final class Recovery {
+ private final SegmentRing ring;
+ private final RecoveryStatus status;
+
+ private Recovery(RecoveryStatus status, SegmentRing ring) {
+ this.status = status;
+ this.ring = ring;
+ }
+
+ static Recovery empty() {
+ return new Recovery(RecoveryStatus.EMPTY, null);
+ }
+
+ SegmentRing ring() {
+ return ring;
+ }
+
+ static Recovery recovered(SegmentRing ring) {
+ return new Recovery(RecoveryStatus.RECOVERED, ring);
+ }
+
+ RecoveryStatus status() {
+ return status;
+ }
+ }
+
+ enum RecoveryStatus {
+ EMPTY,
+ RECOVERED
+ }
+
+ interface RetainedSegmentMembership {
+ boolean contains(MmapSegment segment);
+ }
+
+ private enum RetainedSegmentMembershipMode {
+ IDENTITY,
+ LINEAR
+ }
+
/**
* Highest FSN that the server has ACK'd. Read by the segment manager to
* decide which sealed segments are safe to munmap + unlink.
@@ -367,6 +934,7 @@ public boolean acknowledge(long seq) {
* {@link #needsHotSpare}) to prepare the next spare.
*/
public long appendOrFsn(long payloadAddr, int payloadLen) {
+ checkDurability();
long offset = active.tryAppend(payloadAddr, payloadLen);
if (offset == -1L) {
// Active is full. Try to rotate.
@@ -374,20 +942,61 @@ public long appendOrFsn(long payloadAddr, int payloadLen) {
if (spare == null) {
return BACKPRESSURE_NO_SPARE;
}
+ // Periodic mode must make the predecessor's complete published
+ // range durable before the manifest can name its successor. The
+ // manager performs the barrier; the producer uses the existing
+ // backpressure path while it waits.
+ MmapSegment previous = active;
+ if (requestSyncBeforeRotation(previous)) {
+ 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.
- long actualBase = active.baseSeq() + active.frameCount();
+ long actualBase = previous.baseSeq() + previous.frameCount();
spare.rebaseSeq(actualBase);
- // Mutate sealedSegments under the same monitor used by
- // snapshotSealedSegments -- the I/O thread reads through that
- // path and must not see a half-resized ObjList.
+ 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();
+ }
+ // 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) {
- sealedSegments.add(active);
+ 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);
+ }
+ previous.linkSuccessor(spare);
+ sealedSegments.add(previous);
+ active = spare;
}
- active = spare;
hotSpare = null;
// Fresh active just consumed the spare → ask the manager to start
// making the next one immediately, before this segment fills.
@@ -422,6 +1031,67 @@ public long appendOrFsn(long payloadAddr, int payloadLen) {
return fsn;
}
+ public void checkDurability() {
+ MmapSegmentException failure = durabilityFailure;
+ if (failure != null) {
+ throw failure;
+ }
+ }
+
+ /**
+ * Clears a latched periodic data-barrier failure. Called by the segment
+ * manager after a subsequent periodic sync pass over every live segment
+ * succeeds: the published range the failed barrier was meant to cover is
+ * durable now, so the producer may resume. The monitor serializes the
+ * clear with {@link #recordDurabilityFailure(Throwable)}; the volatile
+ * write publishes it to producer threads calling {@link #checkDurability()}.
+ */
+ void clearDurabilityFailure() {
+ if (durabilityFailure != null) {
+ synchronized (this) {
+ durabilityFailure = null;
+ }
+ }
+ }
+
+ synchronized void clearSyncRequestIfActiveDurable() {
+ if (active != null && active.isPublishedDurable()) {
+ syncRequested = false;
+ }
+ }
+
+ synchronized void copyLiveSegmentsForSync(ObjList target) {
+ target.clear();
+ for (int i = sealedHead, n = sealedSegments.size(); i < n; i++) {
+ target.add(sealedSegments.get(i));
+ }
+ if (active != null) {
+ target.add(active);
+ }
+ }
+
+ void enablePeriodicSync() {
+ periodicSyncEnabled = true;
+ syncRequested = true;
+ }
+
+ boolean isSyncRequested() {
+ return syncRequested;
+ }
+
+ void recordDurabilityFailure(Throwable failure) {
+ if (durabilityFailure == null) {
+ MmapSegmentException wrapped = failure instanceof MmapSegmentException
+ ? (MmapSegmentException) failure
+ : new MmapSegmentException("periodic SF data sync failed", failure);
+ synchronized (this) {
+ if (durabilityFailure == null) {
+ durabilityFailure = wrapped;
+ }
+ }
+ }
+ }
+
@Override
public synchronized void close() {
// Marking closed BEFORE freeing fields ensures any concurrent
@@ -431,6 +1101,7 @@ public synchronized void close() {
// / firstSealed / findSegmentContaining, so they don't iterate
// half-freed state.
closed = true;
+ ioPinnedSegment = null;
if (active != null) {
active.close();
active = null;
@@ -439,13 +1110,18 @@ public synchronized void close() {
hotSpare.close();
hotSpare = null;
}
- for (int i = 0, n = sealedSegments.size(); i < n; i++) {
- MmapSegment s = sealedSegments.get(i);
- if (s != null) {
- s.close();
- }
+ for (int i = sealedHead, n = sealedSegments.size(); i < n; i++) {
+ sealedSegments.get(i).close();
}
sealedSegments.clear();
+ sealedHead = 0;
+ for (int i = 0, n = pendingTrims.size(); i < n; i++) {
+ pendingTrims.getQuick(i).close();
+ }
+ pendingTrims.clear();
+ if (manifest != null) {
+ manifest.close();
+ }
}
/**
@@ -461,10 +1137,11 @@ public synchronized ObjList drainTrimmable() {
ObjList out = null;
// Sealed segments are in baseSeq order, oldest first; once we hit one
// that isn't fully acked, none of the later ones can be either.
- // Synchronized so the I/O thread's snapshotSealedSegments() can't
- // race against the remove(0) shuffling slots underneath it.
- while (sealedSegments.size() > 0) {
- MmapSegment s = sealedSegments.get(0);
+ while (sealedHead < sealedSegments.size()) {
+ MmapSegment s = sealedSegments.get(sealedHead);
+ if (s == ioPinnedSegment) {
+ break;
+ }
long lastSeq = s.baseSeq() + s.frameCount() - 1;
if (lastSeq > acked) {
break;
@@ -473,7 +1150,7 @@ public synchronized ObjList drainTrimmable() {
out = new ObjList<>();
}
out.add(s);
- sealedSegments.remove(0);
+ removeSealedHead();
}
return out;
}
@@ -485,26 +1162,26 @@ public synchronized ObjList drainTrimmable() {
* to position the I/O thread's cursor at the first unacked frame for
* replay.
*
- * Walks sealed first (oldest → newest) then the active. The sealed list
- * is small enough -- and reconnects are rare enough -- that the linear
- * scan cost doesn't matter.
+ * Binary-searches the sealed list, then falls back to the active
+ * segment: O(log live-sealed) per call, so repositioning stays cheap
+ * even when a producer-outpaces-drain backlog has grown the sealed
+ * list without bound.
*/
public synchronized MmapSegment findSegmentContaining(long fsn) {
- for (int i = 0, n = sealedSegments.size(); i < n; i++) {
- MmapSegment s = sealedSegments.get(i);
- long base = s.baseSeq();
- if (fsn >= base && fsn < base + s.frameCount()) {
- return s;
- }
- }
- MmapSegment a = active;
- if (a != null) {
- long base = a.baseSeq();
- if (fsn >= base && fsn < base + a.frameCount()) {
- return a;
- }
+ return findSegmentContaining0(fsn);
+ }
+
+ /**
+ * Atomically finds and pins the segment containing {@code fsn}. The pin
+ * remains until the I/O cursor switches or releases it, preventing trim
+ * staging from hiding or unmapping the returned segment meanwhile.
+ */
+ synchronized MmapSegment pinSegmentContaining(long fsn) {
+ MmapSegment segment = findSegmentContaining0(fsn);
+ if (segment != null) {
+ switchIoPin(segment);
}
- return null;
+ return segment;
}
/**
@@ -513,7 +1190,25 @@ public synchronized MmapSegment findSegmentContaining(long fsn) {
* fallback -- see {@link #nextSealedAfter(MmapSegment)}.
*/
public synchronized MmapSegment firstSealed() {
- return sealedSegments.size() > 0 ? sealedSegments.get(0) : null;
+ return sealedHead < sealedSegments.size() ? sealedSegments.get(sealedHead) : null;
+ }
+
+ /**
+ * Returns the oldest fully acknowledged, unpinned live segment without
+ * removing it. Staging later transfers it to hidden ring-owned cleanup
+ * state, where unlink/barrier failures remain retryable without exposing
+ * an unmapped segment to traversal.
+ */
+ public synchronized MmapSegment firstTrimmable() {
+ if (sealedHead == sealedSegments.size()) {
+ return null;
+ }
+ MmapSegment segment = sealedSegments.get(sealedHead);
+ if (segment == ioPinnedSegment) {
+ return null;
+ }
+ long lastSeq = segment.baseSeq() + segment.frameCount() - 1;
+ return lastSeq <= ackedFsn ? segment : null;
}
/** Active segment -- exposed for the I/O thread's "send next batch" path. */
@@ -536,7 +1231,7 @@ public synchronized MmapSegment firstSealed() {
*/
public synchronized long findLastFsnWithoutPayloadFlag(int flagsOffset, int flagMask, int headerMagic, int minPayloadLen) {
long best = -1L;
- for (int i = 0, n = sealedSegments.size(); i < n; i++) {
+ for (int i = sealedHead, n = sealedSegments.size(); i < n; i++) {
long fsn = sealedSegments.get(i).findLastFrameFsnWithoutPayloadFlag(flagsOffset, flagMask, headerMagic, minPayloadLen);
if (fsn > best) {
best = fsn;
@@ -550,13 +1245,21 @@ public MmapSegment getActive() {
return active;
}
+ /** Atomically pins and returns the current active segment for I/O. */
+ synchronized MmapSegment pinActiveSegment() {
+ MmapSegment segment = active;
+ switchIoPin(segment);
+ return segment;
+ }
+
/**
* Direct view of sealed segments (oldest first). NOT thread-safe -- use
* only from the producer thread, or alongside a lock that excludes
* concurrent rotation. Cross-thread readers (typically the I/O loop)
* should use {@link #snapshotSealedSegments(MmapSegment[])} instead.
*/
- public ObjList getSealedSegments() {
+ public synchronized ObjList getSealedSegments() {
+ compactSealedSegments();
return sealedSegments;
}
@@ -591,6 +1294,30 @@ 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
+ * remains. An active cursor stays pinned in place until rotation seals it.
+ */
+ synchronized MmapSegment advancePinnedSegment(MmapSegment current) {
+ assert ioPinnedSegment == current;
+ MmapSegment liveActive = active;
+ if (current == liveActive) {
+ return current;
+ }
+ MmapSegment next = nextSealedAfter(current);
+ if (next == null) {
+ MmapSegment first = sealedHead < sealedSegments.size()
+ ? sealedSegments.get(sealedHead)
+ : null;
+ next = first != null && first.baseSeq() > current.baseSeq()
+ ? first
+ : liveActive;
+ }
+ switchIoPin(next);
+ return next;
+ }
+
/**
* Returns the sealed segment whose {@code baseSeq} immediately follows
* {@code current.baseSeq()}, or {@code null} if no such segment exists
@@ -600,21 +1327,28 @@ public boolean needsHotSpare() {
* outpaces the I/O thread and sealed segments accumulate well beyond
* any reasonable snapshot-array size.
*
- * Identity match is intentionally avoided: we compare {@code baseSeq}
- * so the loop is robust against the case where {@code current} was
- * trimmed out from under us (already ACK'd before the I/O thread
- * advanced) -- we still return the next segment in baseSeq order rather
- * than failing. Synchronized against rotation.
+ * Each segment publishes its successor once, before rotation exposes that
+ * successor as active. A constant-time head check detects when trimming
+ * removed the immediate successor and falls forward to the oldest live
+ * sealed segment. Synchronized against rotation and head removal.
*/
public synchronized MmapSegment nextSealedAfter(MmapSegment current) {
- long currentBase = current.baseSeq();
- for (int i = 0, n = sealedSegments.size(); i < n; i++) {
- MmapSegment s = sealedSegments.get(i);
- if (s.baseSeq() > currentBase) {
- return s;
- }
+ nextSealedComparisons++;
+ MmapSegment successor = current.successor();
+ if (successor == null) {
+ return null;
}
- return null;
+ if (successor == active) {
+ return null;
+ }
+ MmapSegment first = sealedHead < sealedSegments.size() ? sealedSegments.get(sealedHead) : null;
+ if (first != null && successor.baseSeq() >= first.baseSeq()) {
+ return successor;
+ }
+ // Head trimming may have removed the immediate successor while the
+ // I/O cursor still held an older segment. Trims only remove a prefix,
+ // so the current head is the first live segment after that prefix.
+ return first;
}
/**
@@ -634,6 +1368,147 @@ public long publishedFsn() {
return publishedFsn;
}
+ /**
+ * Drops a directory-durable prefix from hidden trim ownership and returns
+ * its exact byte total for manager accounting.
+ */
+ synchronized long commitPendingTrims(MmapSegment[] expected, int count) {
+ if (count < 0 || count > pendingTrims.size()) {
+ throw new IllegalArgumentException("invalid pending trim count: " + count);
+ }
+ long bytes = 0;
+ for (int i = 0; i < count; i++) {
+ MmapSegment segment = pendingTrims.getQuick(i);
+ if (segment != expected[i]) {
+ throw new IllegalStateException("pending trim prefix changed");
+ }
+ bytes += segment.sizeBytes();
+ }
+ if (count == pendingTrims.size()) {
+ pendingTrims.clear();
+ } else if (count > 0) {
+ pendingTrims.remove(0, count - 1);
+ }
+ return bytes;
+ }
+
+ /** Copies the hidden retry batch into manager-thread scratch storage. */
+ synchronized int copyPendingTrims(MmapSegment[] target) {
+ int count = pendingTrims.size();
+ if (target.length < count) {
+ throw new IllegalArgumentException("pending trim target is too small");
+ }
+ for (int i = 0; i < count; i++) {
+ target[i] = pendingTrims.getQuick(i);
+ }
+ return count;
+ }
+
+ /** Number of hidden trim entries retained for retry. */
+ synchronized int pendingTrimCount() {
+ return pendingTrims.size();
+ }
+
+ @TestOnly
+ public synchronized MmapSegment getHotSpareForTesting() {
+ return hotSpare;
+ }
+
+ @TestOnly
+ public synchronized int getPendingTrimCount() {
+ return pendingTrims.size();
+ }
+
+ @TestOnly
+ public synchronized MmapSegment pinSegmentContainingForTest(long fsn) {
+ return pinSegmentContaining(fsn);
+ }
+
+ @TestOnly
+ public void recordDurabilityFailureForTesting(Throwable failure) {
+ recordDurabilityFailure(failure);
+ }
+
+ @TestOnly
+ public synchronized void releasePinnedSegmentForTest(MmapSegment expected) {
+ releasePinnedSegment(expected);
+ }
+
+ private synchronized boolean requestSyncBeforeRotation(MmapSegment previous) {
+ if (periodicSyncEnabled && !previous.isPublishedDurable()) {
+ syncRequested = true;
+ return true;
+ }
+ return false;
+ }
+
+ /** Releases the I/O cursor pin and wakes trim if it still names {@code expected}. */
+ synchronized void releasePinnedSegment(MmapSegment expected) {
+ if (ioPinnedSegment == expected) {
+ ioPinnedSegment = null;
+ wakeManager();
+ }
+ }
+
+ /**
+ * Commits removal of the segment returned by {@link #firstTrimmable()}.
+ * Returns false if concurrent lifecycle activity changed the head.
+ */
+ public synchronized boolean removeTrimmable(MmapSegment segment) {
+ if (sealedHead == sealedSegments.size()
+ || sealedSegments.get(sealedHead) != segment
+ || segment == ioPinnedSegment) {
+ return false;
+ }
+ long lastSeq = segment.baseSeq() + segment.frameCount() - 1;
+ if (lastSeq > ackedFsn) {
+ return false;
+ }
+ removeSealedHead();
+ return true;
+ }
+
+ /**
+ * Durably advances the manifest past, then hides, one bounded ACKed and
+ * unpinned sealed prefix. Once this returns, I/O lookup cannot discover
+ * any staged segment; the manager owns only the physical cleanup attempt.
+ */
+ synchronized int stagePendingTrims(MmapSegment[] target, int maxCount, long coveredAck) {
+ if (pendingTrims.size() != 0) {
+ return 0;
+ }
+ if (maxCount < 0 || maxCount > MAX_PENDING_TRIMS || target.length < maxCount) {
+ throw new IllegalArgumentException("invalid trim batch size: " + maxCount);
+ }
+ int count = 0;
+ for (int i = sealedHead, n = sealedSegments.size(); i < n && count < maxCount; i++) {
+ MmapSegment segment = sealedSegments.get(i);
+ long lastSeq = segment.baseSeq() + segment.frameCount() - 1L;
+ if (segment == ioPinnedSegment || lastSeq > coveredAck) {
+ break;
+ }
+ target[count++] = segment;
+ }
+ if (count == 0) {
+ return 0;
+ }
+ try {
+ advanceManifestHeadPast(target[count - 1]);
+ } catch (Throwable t) {
+ for (int i = 0; i < count; i++) {
+ target[i] = null;
+ }
+ throw t;
+ }
+ for (int i = 0; i < count; i++) {
+ MmapSegment segment = target[i];
+ assert sealedSegments.get(sealedHead) == segment;
+ pendingTrims.add(segment);
+ removeSealedHead();
+ }
+ return count;
+ }
+
/**
* Registers a wakeup callback that the producer thread will invoke when
* a hot spare is needed -- either right after a rotation has consumed the
@@ -663,17 +1538,21 @@ public void setManagerWakeup(Runnable wakeup) {
* the I/O loop is about to do.
*/
public synchronized int snapshotSealedSegments(MmapSegment[] target) {
- int n = sealedSegments.size();
- if (n > target.length) {
- for (int i = 0; i < target.length; i++) {
- target[i] = sealedSegments.get(i);
- }
- return -1;
+ int n = sealedSegments.size() - sealedHead;
+ int copyCount = Math.min(n, target.length);
+ for (int i = 0; i < copyCount; i++) {
+ target[i] = sealedSegments.get(sealedHead + i);
}
- for (int i = 0; i < n; i++) {
- target[i] = sealedSegments.get(i);
+ return n > target.length ? -1 : n;
+ }
+
+ public void syncAllLiveSegments() {
+ ObjList segments = new ObjList<>();
+ copyLiveSegmentsForSync(segments);
+ for (int i = 0, n = segments.size(); i < n; i++) {
+ segments.getQuick(i).syncPublished();
}
- return n;
+ clearSyncRequestIfActiveDurable();
}
/**
@@ -689,21 +1568,101 @@ public synchronized long totalSegmentBytes() {
if (a != null) total += a.sizeBytes();
MmapSegment hs = hotSpare;
if (hs != null) total += hs.sizeBytes();
- for (int i = 0, n = sealedSegments.size(); i < n; i++) {
- MmapSegment s = sealedSegments.get(i);
- if (s != null) total += s.sizeBytes();
+ for (int i = sealedHead, n = sealedSegments.size(); i < n; i++) {
+ total += sealedSegments.get(i).sizeBytes();
+ }
+ for (int i = 0, n = pendingTrims.size(); i < n; i++) {
+ total += pendingTrims.getQuick(i).sizeBytes();
}
return total;
}
+ private MmapSegment findSegmentContaining0(long fsn) {
+ // The sealed list is strictly ascending and contiguous in baseSeq:
+ // rotation seals the predecessor exactly where the promoted spare's
+ // rebased baseSeq starts, and recovery sorts the chain then rejects
+ // any gap via validateContiguous. The only sealed segment that can
+ // cover fsn is therefore the rightmost one whose baseSeq is at or
+ // below fsn -- binary-search for it (unsigned, matching
+ // sortByBaseSeq's key order) instead of walking a list that grows
+ // without bound while the producer outpaces the drain.
+ int lo = sealedHead;
+ int hi = sealedSegments.size() - 1;
+ while (lo <= hi) {
+ int mid = (lo + hi) >>> 1;
+ if (Long.compareUnsigned(sealedSegments.get(mid).baseSeq(), fsn) <= 0) {
+ lo = mid + 1;
+ } else {
+ hi = mid - 1;
+ }
+ }
+ if (hi >= sealedHead) {
+ MmapSegment segment = sealedSegments.get(hi);
+ long base = segment.baseSeq();
+ if (fsn >= base && fsn < base + segment.frameCount()) {
+ return segment;
+ }
+ }
+ MmapSegment liveActive = active;
+ if (liveActive != null) {
+ long base = liveActive.baseSeq();
+ if (fsn >= base && fsn < base + liveActive.frameCount()) {
+ return liveActive;
+ }
+ }
+ return null;
+ }
+
+ private void compactSealedSegments() {
+ if (sealedHead > 0) {
+ int liveCount = sealedSegments.size() - sealedHead;
+ trimMovedReferences += liveCount;
+ sealedSegments.remove(0, sealedHead - 1);
+ sealedHead = 0;
+ }
+ }
+
+ private void removeSealedHead() {
+ sealedSegments.setQuick(sealedHead++, null);
+ int size = sealedSegments.size();
+ if (sealedHead == size) {
+ sealedSegments.clear();
+ sealedHead = 0;
+ } else if (sealedHead >= 64 && sealedHead >= size - sealedHead) {
+ compactSealedSegments();
+ }
+ }
+
+ private void switchIoPin(MmapSegment segment) {
+ MmapSegment previous = ioPinnedSegment;
+ ioPinnedSegment = segment;
+ if (previous != null && previous != segment) {
+ wakeManager();
+ }
+ }
+
+ private void wakeManager() {
+ Runnable wakeup = managerWakeup;
+ if (wakeup != null) {
+ wakeup.run();
+ }
+ }
+
+ /** Returns the sealed-list operation count used by traversal tests. */
+ @TestOnly
+ public static long getNextSealedComparisons() {
+ return nextSealedComparisons;
+ }
+
/**
* Returns the cumulative count of baseSeq comparisons performed by
* {@link #sortByBaseSeq} since the last {@link #resetSortComparisons()}
* (or process start). The count is incremented once per partition pass
* for the median-of-three pivot pick plus once per element compared
- * against the pivot, so a clean run on N segments adds roughly
- * {@code 3 + (hi - lo - 1)} per recursive frame, summing to O(N log N).
- * Exposed for {@code SegmentRingTest} to detect O(N²) regressions
+ * against the pivot ({@code 3 + (hi - lo - 1)} per pass), and by two per
+ * sift-down level when a range falls back to heapsort, so it strictly
+ * upper-bounds the true compare count and sums to O(N log N) on every
+ * input. Exposed for {@code SegmentRingTest} to detect O(N²) regressions
* deterministically.
*/
@TestOnly
@@ -711,22 +1670,71 @@ public static long getSortComparisons() {
return sortComparisons;
}
+ /** Returns the references moved by sealed-list compaction. */
+ @TestOnly
+ public static long getTrimMovedReferences() {
+ return trimMovedReferences;
+ }
+
+ /** Zeroes the counter exposed via {@link #getNextSealedComparisons()}. */
+ @TestOnly
+ public static void resetNextSealedComparisons() {
+ nextSealedComparisons = 0;
+ }
+
/** Zeroes the counter exposed via {@link #getSortComparisons()}. */
@TestOnly
public static void resetSortComparisons() {
sortComparisons = 0;
}
+ /** Zeroes the counter exposed via {@link #getTrimMovedReferences()}. */
+ @TestOnly
+ public static void resetTrimMovedReferences() {
+ trimMovedReferences = 0;
+ }
+
/**
- * In-place quicksort over {@code list[lo, hi)} keyed by ascending
- * {@code baseSeq}. Median-of-three pivot avoids the pathological O(N²)
- * on already-sorted input that lexicographic readdir produces (our
- * filenames are zero-padded hex of {@code baseSeq}). Recursion depth is
- * bounded by ~2 log₂(N) -- for the documented 16K-segment ceiling, well
- * under the JVM default stack.
+ * Drives the recovery-time baseSeq sort over the whole list. Exposed so
+ * {@code SegmentRingTest} can feed adversarial orders (organ-pipe, mass
+ * duplicates, median-of-three killer, unsigned-boundary keys) straight
+ * into the sort and assert comparison bounds without staging thousands
+ * of segment files on disk.
+ */
+ @TestOnly
+ public static void sortByBaseSeqForTest(ObjList list) {
+ sortByBaseSeq(list, 0, list.size());
+ }
+
+ /**
+ * In-place introsort over {@code list[lo, hi)} keyed by ascending
+ * unsigned {@code baseSeq}. Median-of-three quicksort handles the readdir
+ * orders a healthy slot produces (lexicographic enumeration of the
+ * generation-numbered filenames yields already-sorted baseSeqs; hashed
+ * directory order is effectively random), and a partition-pass budget of
+ * 2·⌊log₂(N)⌋ demotes any range that keeps splitting badly to in-place
+ * heapsort. Without that budget, Lomuto with a median-of-three pivot is
+ * O(N²) on organ-pipe, duplicate-heavy and median-of-three-killer orders
+ * -- reachable only through corrupted-yet-parseable or operator-copied
+ * headers, but at the documented 16K-segment ceiling that is 10⁷..10⁸
+ * comparisons of startup stall before recovery validation gets to
+ * reject the slot, so the fallback makes O(N log N) unconditional.
+ * Recursion depth stays under log₂(N) (recurse on the smaller side,
+ * loop on the larger), well within the JVM default stack.
*/
private static void sortByBaseSeq(ObjList list, int lo, int hi) {
+ int n = hi - lo;
+ if (n > 1) {
+ sortByBaseSeq(list, lo, hi, 2 * (31 - Integer.numberOfLeadingZeros(n)));
+ }
+ }
+
+ private static void sortByBaseSeq(ObjList list, int lo, int hi, int budget) {
while (hi - lo > 1) {
+ if (budget-- == 0) {
+ heapSortByBaseSeq(list, lo, hi);
+ return;
+ }
int mid = (lo + hi) >>> 1;
long a = list.get(lo).baseSeq();
long b = list.get(mid).baseSeq();
@@ -756,17 +1764,61 @@ private static void sortByBaseSeq(ObjList list, int lo, int hi) {
}
swap(list, store, hi - 1);
// Recurse on the smaller partition; loop on the larger to keep
- // recursion depth bounded by log₂(N).
+ // recursion depth bounded by log₂(N). Children inherit the
+ // remaining pass budget: it counts passes along a root-to-leaf
+ // path, so a chain of bad splits exhausts it after ~2 log₂(N)
+ // levels no matter how the work is divided.
if (store - lo < hi - store - 1) {
- sortByBaseSeq(list, lo, store);
+ sortByBaseSeq(list, lo, store, budget);
lo = store + 1;
} else {
- sortByBaseSeq(list, store + 1, hi);
+ sortByBaseSeq(list, store + 1, hi, budget);
hi = store;
}
}
}
+ /**
+ * In-place heapsort over {@code list[lo, hi)} keyed by ascending unsigned
+ * {@code baseSeq}: the introsort fallback for ranges whose partition-pass
+ * budget ran out. Guaranteed O(N log N) for any key distribution and any
+ * initial order; no allocation.
+ */
+ private static void heapSortByBaseSeq(ObjList list, int lo, int hi) {
+ int n = hi - lo;
+ for (int root = (n >>> 1) - 1; root >= 0; root--) {
+ siftDownByBaseSeq(list, lo, root, n);
+ }
+ for (int end = n - 1; end > 0; end--) {
+ swap(list, lo, lo + end);
+ siftDownByBaseSeq(list, lo, 0, end);
+ }
+ }
+
+ private static void siftDownByBaseSeq(ObjList list, int lo, int root, int heapSize) {
+ while (true) {
+ int child = (root << 1) + 1;
+ if (child >= heapSize) {
+ return;
+ }
+ // At most two unsigned compares per level (sibling pick + parent
+ // test); bump the counter by the constant 2 up front -- same
+ // cheap-upper-bound convention as the partition pass.
+ sortComparisons += 2;
+ if (child + 1 < heapSize
+ && Long.compareUnsigned(list.get(lo + child).baseSeq(),
+ list.get(lo + child + 1).baseSeq()) < 0) {
+ child++;
+ }
+ if (Long.compareUnsigned(list.get(lo + root).baseSeq(),
+ list.get(lo + child).baseSeq()) >= 0) {
+ return;
+ }
+ swap(list, lo + root, lo + child);
+ root = child;
+ }
+ }
+
private static void swap(ObjList list, int i, int j) {
if (i == j) return;
MmapSegment tmp = list.get(i);
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 2dec7668..541f7dcf 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
@@ -27,6 +27,7 @@
import io.questdb.client.SenderConnectionEvent;
import io.questdb.client.SenderConnectionListener;
import io.questdb.client.std.QuietCloseable;
+import org.jetbrains.annotations.TestOnly;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -146,6 +147,11 @@ public long getTotalDelivered() {
return totalDelivered.get();
}
+ @TestOnly
+ public Thread getWorkerThreadForTesting() {
+ return dispatcherThread;
+ }
+
/**
* Non-blocking enqueue. Always admits the new event unless the dispatcher
* is closed or {@code event} is null. Returns {@code true} when the new
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 aaa0e4f9..fb796712 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
@@ -27,6 +27,7 @@
import io.questdb.client.SenderError;
import io.questdb.client.SenderErrorHandler;
import io.questdb.client.std.QuietCloseable;
+import org.jetbrains.annotations.TestOnly;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -209,6 +210,11 @@ public long getTotalDelivered() {
return totalDelivered.get();
}
+ @TestOnly
+ public Thread getWorkerThreadForTesting() {
+ return dispatcherThread;
+ }
+
/**
* True if at least one error has been delivered to a user-installed
* (non-default) handler since this dispatcher started. Used by
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 2c0e90a1..9e01a630 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
@@ -26,6 +26,7 @@
import io.questdb.client.SenderProgressHandler;
import io.questdb.client.std.QuietCloseable;
+import org.jetbrains.annotations.TestOnly;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -172,6 +173,11 @@ public long getTotalDelivered() {
return totalDelivered.get();
}
+ @TestOnly
+ public Thread getWorkerThreadForTesting() {
+ return dispatcherThread;
+ }
+
/**
* Replace the user-supplied handler. Effective immediately for any
* subsequent delivery. Pass {@code null} to install the no-op default.
diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SfManifest.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SfManifest.java
new file mode 100644
index 00000000..b6afda99
--- /dev/null
+++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SfManifest.java
@@ -0,0 +1,308 @@
+/*******************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * 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.Crc32c;
+import io.questdb.client.std.FilesFacade;
+import io.questdb.client.std.MemoryTag;
+import io.questdb.client.std.QuietCloseable;
+import io.questdb.client.std.Unsafe;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Crash-safe boundary record for an SF segment chain. Two fixed-size,
+ * independently CRC-protected records at offsets 0 and 4096 alternate on
+ * update. Recovery selects the valid record with the greatest generation.
+ * The separate 4 KiB slots prevent one aligned 512-byte or 4 KiB sector tear
+ * from erasing both the update and the previous committed boundary.
+ *
+ * Public within this exported-internal package (like {@link MmapSegment} and
+ * {@link SegmentRing}) so the boundary contract -- notably the monotonic
+ * clamp in {@link #update} -- can be pinned by direct unit tests; it is not
+ * part of the supported client API.
+ */
+public final class SfManifest implements QuietCloseable {
+ static final String FILE_NAME = "sf-manifest.bin";
+ private static final Logger LOG = LoggerFactory.getLogger(SfManifest.class);
+ private static final int CRC_OFFSET = 60;
+ private static final long FILE_SIZE = 8 * 1024;
+ private static final int MAGIC = 0x314d4653; // SFM1 little-endian
+ private static final int RECORD_SIZE = 64;
+ private static final int RECORD_SLOT_SIZE = 4 * 1024;
+ private static final int VERSION = 1;
+ private final int fd;
+ private final FilesFacade filesFacade;
+ private final String path;
+ // Preallocated record scratch for update(): the trim path calls update()
+ // once per batch and must not malloc/free per call. Guarded by this
+ // object's monitor (update() and close() are both synchronized).
+ private final long writeScratch;
+ private long activeBase;
+ private boolean closed;
+ private long generation;
+ private long headBase;
+
+ private SfManifest(FilesFacade filesFacade, String path, int fd,
+ long generation, long headBase, long activeBase) {
+ this.filesFacade = filesFacade;
+ this.path = path;
+ this.fd = fd;
+ this.generation = generation;
+ this.headBase = headBase;
+ this.activeBase = activeBase;
+ this.writeScratch = Unsafe.malloc(RECORD_SIZE, MemoryTag.NATIVE_DEFAULT);
+ }
+
+ public static SfManifest create(FilesFacade filesFacade, String dir, long headBase, long activeBase) {
+ String path = dir + "/" + FILE_NAME;
+ int fd = filesFacade.openRWExclusive(path);
+ if (fd < 0) {
+ throw new MmapSegmentException("exclusive create failed for SF manifest " + path);
+ }
+ boolean success = false;
+ SfManifest manifest = null;
+ try {
+ if (!filesFacade.allocate(fd, FILE_SIZE)) {
+ throw new MmapSegmentException("could not allocate SF manifest " + path);
+ }
+ manifest = new SfManifest(filesFacade, path, fd, 0, -1, -1);
+ manifest.update(headBase, activeBase);
+ if (filesFacade.fsyncDir(dir) != 0) {
+ throw new MmapSegmentException("could not sync SF manifest directory " + dir);
+ }
+ success = true;
+ return manifest;
+ } finally {
+ if (!success) {
+ if (manifest != null) {
+ // close() frees the constructor-owned scratch buffer as
+ // well as the fd; closing only the raw fd would leak it.
+ manifest.close();
+ } else {
+ filesFacade.close(fd);
+ }
+ filesFacade.remove(path);
+ }
+ }
+ }
+
+ public static SfManifest open(FilesFacade filesFacade, String dir) {
+ String path = dir + "/" + FILE_NAME;
+ if (!filesFacade.exists(path)) {
+ return null;
+ }
+ if (filesFacade.length(path) != FILE_SIZE) {
+ // A wrong-sized manifest is creation debris: create() reaches the
+ // full FILE_SIZE via allocate() before writing the first record,
+ // so a mis-sized file proves the crash happened before any
+ // boundary was ever committed — nothing can depend on it yet
+ // (segment flags are stamped only after create() returns). Treat
+ // as absent so startup self-heals; genuine post-creation loss is
+ // still caught by the manifest-required flag check.
+ quarantineDebris(filesFacade, path, "wrong size " + filesFacade.length(path));
+ return null;
+ }
+ int fd = filesFacade.openRW(path);
+ if (fd < 0) {
+ throw new MmapSegmentException("could not open SF manifest " + path);
+ }
+ long buffer = Unsafe.malloc(RECORD_SIZE, MemoryTag.NATIVE_DEFAULT);
+ try {
+ Record first = readRecord(filesFacade, fd, buffer, 0);
+ Record second = readRecord(filesFacade, fd, buffer, RECORD_SLOT_SIZE);
+ Record selected;
+ if (first == null) {
+ selected = second;
+ } else if (second == null || first.generation > second.generation) {
+ selected = first;
+ } else {
+ selected = second;
+ }
+ if (selected == null) {
+ // No valid record in either slot. create() makes the first
+ // record durable (write + fsync) before returning, and every
+ // later update() rewrites only ONE slot — so a torn update
+ // leaves the sibling record intact. Zero valid records
+ // therefore proves a creation crash, not boundary loss.
+ // Self-heal by treating the file as absent. If durable state
+ // DID depend on a manifest (flags stamped, i.e. double-slot
+ // bit rot), recovery still fails closed on the
+ // manifest-required flag check.
+ filesFacade.close(fd);
+ // Ownership released: quarantineDebris may throw (when both
+ // rename and remove fail) and the catch below must not close
+ // this fd again -- the OS may already have handed the number
+ // to another thread, and a double-close would silently kill
+ // an unrelated descriptor.
+ fd = -1;
+ quarantineDebris(filesFacade, path, "no valid CRC-protected record");
+ return null;
+ }
+ return new SfManifest(filesFacade, path, fd, selected.generation,
+ selected.headBase, selected.activeBase);
+ } catch (Throwable t) {
+ if (fd != -1) {
+ filesFacade.close(fd);
+ }
+ throw t;
+ } finally {
+ Unsafe.free(buffer, RECORD_SIZE, MemoryTag.NATIVE_DEFAULT);
+ }
+ }
+
+ public long activeBase() {
+ return activeBase;
+ }
+
+ @Override
+ public synchronized void close() {
+ // Synchronized against update() so the scratch buffer can never be
+ // freed under a concurrent writer; update() checks `closed` inside
+ // the same monitor.
+ if (!closed) {
+ closed = true;
+ Unsafe.free(writeScratch, RECORD_SIZE, MemoryTag.NATIVE_DEFAULT);
+ filesFacade.close(fd);
+ }
+ }
+
+ public long headBase() {
+ return headBase;
+ }
+
+ /**
+ * Unlinks {@code dir}'s manifest file. Used when a slot is being reset to
+ * the "nothing durable" state (fresh-start cleanup, close-time drain, or
+ * recovery accepting a segment-less slot as empty). Returns {@code true}
+ * when the file is confirmed gone (removed, or never existed).
+ */
+ static boolean removeFile(FilesFacade filesFacade, String dir) {
+ String path = dir + "/" + FILE_NAME;
+ return filesFacade.remove(path) || !filesFacade.exists(path);
+ }
+
+ public synchronized void update(long newHeadBase, long newActiveBase) {
+ if (closed) {
+ throw new IllegalStateException("SF manifest is closed");
+ }
+ // Committed boundaries only ever move forward: head advances on trim,
+ // active advances on rotation. Clamp instead of throwing because the
+ // two writers (producer rotation, manager trim) are serialized on the
+ // ring monitor but may each compute their argument from a snapshot
+ // the other has already moved past — e.g. rotation reads the sealed
+ // list while a trimmed-but-not-yet-removed head segment still sits in
+ // it. Regressing a durable boundary would let a later crash-recovery
+ // demand a segment file the trim path already unlinked (startup would
+ // fail on "missing head segment") or, worse, re-expose stale files
+ // below an already-committed head.
+ if (generation > 0) {
+ if (newHeadBase < headBase) {
+ newHeadBase = headBase;
+ }
+ if (newActiveBase < activeBase) {
+ newActiveBase = activeBase;
+ }
+ }
+ if (newHeadBase < 0 || newActiveBase < newHeadBase) {
+ throw new IllegalArgumentException("invalid SF manifest boundaries");
+ }
+ if (generation > 0 && headBase == newHeadBase && activeBase == newActiveBase) {
+ return;
+ }
+ long nextGeneration = generation + 1;
+ Unsafe.getUnsafe().setMemory(writeScratch, RECORD_SIZE, (byte) 0);
+ Unsafe.getUnsafe().putInt(writeScratch, MAGIC);
+ Unsafe.getUnsafe().putInt(writeScratch + 4, VERSION);
+ Unsafe.getUnsafe().putLong(writeScratch + 8, nextGeneration);
+ Unsafe.getUnsafe().putLong(writeScratch + 16, newHeadBase);
+ Unsafe.getUnsafe().putLong(writeScratch + 24, newActiveBase);
+ int crc = Crc32c.update(Crc32c.INIT, writeScratch, CRC_OFFSET);
+ Unsafe.getUnsafe().putInt(writeScratch + CRC_OFFSET, crc);
+ long offset = (nextGeneration & 1L) * RECORD_SLOT_SIZE;
+ if (filesFacade.write(fd, writeScratch, RECORD_SIZE, offset) != RECORD_SIZE) {
+ throw new MmapSegmentException("short write updating SF manifest " + path);
+ }
+ if (filesFacade.fsync(fd) != 0) {
+ throw new MmapSegmentException("could not sync SF manifest " + path);
+ }
+ generation = nextGeneration;
+ headBase = newHeadBase;
+ activeBase = newActiveBase;
+ }
+
+ /**
+ * Moves creation-crash debris aside so a subsequent exclusive create can
+ * succeed. Prefers rename (keeps the bytes for postmortem); falls back to
+ * remove; throws when neither works — leaving the debris in place would
+ * wedge every subsequent {@link #create}.
+ */
+ private static void quarantineDebris(FilesFacade filesFacade, String path, String reason) {
+ LOG.warn("SF manifest {} is creation-crash debris ({}); quarantining and starting "
+ + "from the segment files", path, reason);
+ if (filesFacade.rename(path, path + ".corrupt") == 0) {
+ return;
+ }
+ if (filesFacade.remove(path)) {
+ return;
+ }
+ throw new MmapSegmentException("could not quarantine invalid SF manifest " + path);
+ }
+
+ private static Record readRecord(FilesFacade filesFacade, int fd, long buffer, long offset) {
+ Unsafe.getUnsafe().setMemory(buffer, RECORD_SIZE, (byte) 0);
+ if (filesFacade.read(fd, buffer, RECORD_SIZE, offset) != RECORD_SIZE) {
+ return null;
+ }
+ if (Unsafe.getUnsafe().getInt(buffer) != MAGIC
+ || Unsafe.getUnsafe().getInt(buffer + 4) != VERSION) {
+ return null;
+ }
+ int expected = Unsafe.getUnsafe().getInt(buffer + CRC_OFFSET);
+ int actual = Crc32c.update(Crc32c.INIT, buffer, CRC_OFFSET);
+ if (expected != actual) {
+ return null;
+ }
+ long generation = Unsafe.getUnsafe().getLong(buffer + 8);
+ long headBase = Unsafe.getUnsafe().getLong(buffer + 16);
+ long activeBase = Unsafe.getUnsafe().getLong(buffer + 24);
+ if (generation <= 0 || headBase < 0 || activeBase < headBase) {
+ return null;
+ }
+ return new Record(generation, headBase, activeBase);
+ }
+
+ private static final class Record {
+ private final long activeBase;
+ private final long generation;
+ private final long headBase;
+
+ private Record(long generation, long headBase, long activeBase) {
+ this.generation = generation;
+ this.headBase = headBase;
+ this.activeBase = activeBase;
+ }
+ }
+}
diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SfOperationalException.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SfOperationalException.java
new file mode 100644
index 00000000..308b99ee
--- /dev/null
+++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SfOperationalException.java
@@ -0,0 +1,38 @@
+/*+*****************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * 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;
+
+/**
+ * Operational SF setup failure that may clear on a later retry. Extending
+ * {@link IllegalStateException} preserves the existing caller-visible
+ * construction-failure contract while giving internal recovery code a typed
+ * signal that must not permanently quarantine an orphan slot.
+ */
+public final class SfOperationalException extends IllegalStateException {
+
+ public SfOperationalException(String message) {
+ super(message);
+ }
+}
diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SfRecoveryException.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SfRecoveryException.java
new file mode 100644
index 00000000..d3f266c3
--- /dev/null
+++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SfRecoveryException.java
@@ -0,0 +1,44 @@
+/*+*****************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * 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;
+
+/**
+ * Terminal SF recovery failure. The on-disk state proves that the durable
+ * segment chain is corrupt or incomplete and requires operator intervention.
+ * Operational filesystem failures continue to use {@link MmapSegmentException}
+ * so callers can retry them without quarantining otherwise recoverable data.
+ *
+ * One refinement is deliberately non-terminal for unattended callers:
+ * {@link SfSanitizedResidueException} marks a first-sight failure thrown
+ * AFTER recovery durably healed the chain, so a single retry validates
+ * clean. Catch sites that quarantine on this type must intercept the
+ * refinement first.
+ */
+public class SfRecoveryException extends MmapSegmentException {
+
+ public SfRecoveryException(String message) {
+ super(message);
+ }
+}
diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SfSanitizedResidueException.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SfSanitizedResidueException.java
new file mode 100644
index 00000000..8a58c5b2
--- /dev/null
+++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SfSanitizedResidueException.java
@@ -0,0 +1,50 @@
+/*+*****************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * 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;
+
+/**
+ * First-sight failure over a chain that recovery has already healed.
+ * Thrown from the fail-closed branch of the sealed-residue sanitize: frame
+ * accounting was proven complete (contiguity plus boundary checks), the
+ * proven-dead suffix residue was durably zeroed ({@code msync} +
+ * {@code fsync} completed — a sync failure throws
+ * {@link MmapSegmentException} instead and never reaches this type), and
+ * the throw exists solely to surface the incident on the startup that
+ * observed it. An immediate re-open re-runs the same proofs over the
+ * zeroed suffix and succeeds.
+ *
+ * Attended callers (producer startup, where an operator or supervisor
+ * restarts the process) should keep the parent's fail-closed semantics:
+ * the restart proves the chain clean. Unattended callers (the orphan
+ * drainer) may retry construction once instead of quarantining — dropping
+ * a {@code .failed} sentinel over a just-healed slot would strand its
+ * replayable backlog until an operator clears the sentinel by hand.
+ */
+public final class SfSanitizedResidueException extends SfRecoveryException {
+
+ public SfSanitizedResidueException(String message) {
+ super(message);
+ }
+}
diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SlotLock.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SlotLock.java
index 0b8379de..17264c5a 100644
--- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SlotLock.java
+++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SlotLock.java
@@ -29,6 +29,7 @@
import io.questdb.client.std.MemoryTag;
import io.questdb.client.std.QuietCloseable;
import io.questdb.client.std.Unsafe;
+import org.jetbrains.annotations.TestOnly;
import java.nio.charset.StandardCharsets;
@@ -36,9 +37,9 @@
* Advisory exclusive lock for a single SF slot directory.
*
* One {@code .lock} file per slot, held via {@code flock}/{@code LockFileEx}
- * for the entire lifetime of the engine that owns the slot. The lock is
- * automatically released when the fd is closed — including on hard process
- * exit, since the kernel cleans up file locks for terminated processes.
+ * for the entire lifetime of the engine that owns the slot. Normal teardown
+ * explicitly unlocks it before closing the fd; hard process exit remains a
+ * backstop because the kernel cleans up file locks for terminated processes.
*
* The holder's PID is written to a sibling {@code .lock.pid} file at
* acquisition time. A failed acquisition reads it back so the error message
@@ -56,10 +57,15 @@
*/
public final class SlotLock implements QuietCloseable {
+ private static final int DEAD_FD_FOR_TESTING = 1_000_000_000;
private static final String LOCK_FILE_NAME = ".lock";
private static final String LOCK_PID_FILE_NAME = ".lock.pid";
+ private static final Object RELEASE_RETRY_LOCK = new Object();
+ private static SlotLock releaseRetryHead;
private final String slotDir;
private int fd;
+ private boolean isReleaseRetryPending;
+ private SlotLock releaseRetryNext;
private SlotLock(String slotDir, int fd) {
this.slotDir = slotDir;
@@ -71,25 +77,43 @@ private SlotLock(String slotDir, int fd) {
* acquires an exclusive {@code flock} on it. On contention, reads the
* existing PID payload and throws with a descriptive message.
*
- * @throws IllegalStateException on dir-create failure, file-open failure,
- * or lock contention.
+ * @throws SfOperationalException on directory or lock-file setup failure
+ * @throws SlotLockContentionException on lock contention
*/
public static SlotLock acquire(String slotDir) {
+ return acquire(slotDir, false);
+ }
+
+ /**
+ * Acquires a slot and optionally makes the slot entry durable in its
+ * parent directory before any segment file is created.
+ */
+ public static SlotLock acquire(String slotDir, boolean syncParentDirectory) {
if (slotDir == null || slotDir.isEmpty()) {
throw new IllegalArgumentException("slotDir must not be empty");
}
+ // Construction cleanup may have retained locks after explicit unlock
+ // failures. Drive every pending owner before opening a new descriptor.
+ // Path text cannot identify a physical file portably (symlinks and
+ // Windows case aliases are counterexamples), while the pending list is
+ // cold, error-only state and normally empty.
+ retryPendingReleases();
if (!Files.exists(slotDir)) {
int rc = Files.mkdir(slotDir, Files.DIR_MODE_DEFAULT);
if (rc != 0) {
- throw new IllegalStateException(
+ throw new SfOperationalException(
"could not create slot dir: " + slotDir + " rc=" + rc);
}
}
+ if (syncParentDirectory && Files.fsyncParentDir(slotDir) != 0) {
+ throw new SfOperationalException(
+ "could not sync parent directory for SF slot: " + slotDir);
+ }
String lockPath = slotDir + "/" + LOCK_FILE_NAME;
String pidPath = slotDir + "/" + LOCK_PID_FILE_NAME;
int fd = Files.openRW(lockPath);
if (fd < 0) {
- throw new IllegalStateException(
+ throw new SfOperationalException(
"could not open slot lock file: " + lockPath);
}
boolean ok = false;
@@ -97,7 +121,7 @@ public static SlotLock acquire(String slotDir) {
int rc = Files.lock(fd);
if (rc != 0) {
String holder = readHolder(pidPath);
- throw new IllegalStateException(
+ throw new SlotLockContentionException(
"sf slot already in use by another process [slot="
+ slotDir + ", holder=" + holder + "]");
}
@@ -111,19 +135,126 @@ public static SlotLock acquire(String slotDir) {
}
}
+ /**
+ * Side-effect-light contention probe: reports the current holder of the
+ * slot flock without creating the slot dir or lock file and without
+ * paying a full engine build. Opens the existing {@code .lock} file
+ * (absent means nothing can hold a flock on it), try-locks it
+ * non-blocking, and releases immediately on success.
+ *
+ * Returns a non-null holder description (the {@code .lock.pid} payload,
+ * or {@code "unknown"}) when the flock is currently held by a live
+ * owner; {@code null} when the lock is free or the probe could not
+ * determine state (missing lock file, open failure). Callers must treat
+ * {@code null} as "proceed to a full acquire", which owns real error
+ * classification -- the probe never throws.
+ *
+ * Races are benign in both directions: a free probe can still lose the
+ * subsequent acquire to a concurrent owner (the caller handles that
+ * contention exactly as before), and a held probe that goes stale the
+ * moment the owner exits is simply re-observed on the caller's next
+ * cycle. The probe's momentary hold can make a concurrent acquirer see
+ * spurious contention -- the same class of race two real contenders
+ * already have.
+ */
+ public static String probeHolder(String slotDir) {
+ if (slotDir == null || slotDir.isEmpty()) {
+ return null;
+ }
+ // Same pre-step as acquire(): a lock retained by THIS process after
+ // an unconfirmed unlock would otherwise read as a live holder for as
+ // long as the retry list carries it.
+ retryPendingReleases();
+ String lockPath = slotDir + "/" + LOCK_FILE_NAME;
+ if (!Files.exists(lockPath)) {
+ return null;
+ }
+ int fd = Files.openRW(lockPath);
+ if (fd < 0) {
+ return null;
+ }
+ if (Files.lock(fd) != 0) {
+ String holder = readHolder(slotDir + "/" + LOCK_PID_FILE_NAME);
+ Files.close(fd);
+ return holder;
+ }
+ // The flock was free and is momentarily ours. Route the release
+ // through the standard close() so an unconfirmed unlock is retained
+ // on the retry list exactly like a normal owner's -- a probe must
+ // never leak a held flock.
+ new SlotLock(slotDir, fd).close();
+ return null;
+ }
+
+ /**
+ * Replaces the live descriptor with a known-dead value until the returned
+ * guard closes. Test-only: exercises release retry paths without exposing
+ * mutable descriptor state.
+ */
+ @TestOnly
+ public synchronized ReleaseFailureForTesting injectReleaseFailureForTesting() {
+ if (fd < 0 || fd == DEAD_FD_FOR_TESTING) {
+ throw new IllegalStateException("slot lock is not held by a live descriptor");
+ }
+ ReleaseFailureForTesting releaseFailure = new ReleaseFailureForTesting(fd);
+ fd = DEAD_FD_FOR_TESTING;
+ return releaseFailure;
+ }
+
+ @TestOnly
+ public synchronized boolean isReleaseFailureInjectedForTesting() {
+ return fd == DEAD_FD_FOR_TESTING;
+ }
+
/** Slot dir this lock guards. */
public String slotDir() {
return slotDir;
}
+ /**
+ * Explicitly releases the flock and reports whether the release was
+ * confirmed. After a successful unlock the native primitive closes
+ * the descriptor once, best-effort, and this object forgets its numeric
+ * value. It never retries that close: POSIX leaves descriptor state
+ * unspecified after some close failures (notably {@code EINTR}), so a
+ * retry could close an unrelated descriptor that reused the same number.
+ * We do NOT remove the {@code .lock} file or {@code .lock.pid} sidecar; a
+ * stale PID is harmless because the next acquirer overwrites it.
+ *
+ * When the explicit unlock itself fails, the fd is retained so a later
+ * attempt can safely retry the non-consuming unlock operation. Idempotent
+ * once the unlock has succeeded.
+ *
+ * Owners that gate a "slot dir is reusable" signal on the release
+ * (e.g. {@code CursorSendEngine.finishClose} publishing
+ * {@code closeCompleted}) must call this and check the result rather
+ * than {@link #close()}, which is best-effort by contract.
+ *
+ * @return {@code true} if the lock was explicitly released (or was already
+ * released), {@code false} if the OS reported an unlock failure
+ */
+ public synchronized boolean release() {
+ if (fd < 0) {
+ return true;
+ }
+ if (release0(fd) == 0) {
+ fd = -1;
+ return true;
+ }
+ return false;
+ }
+
@Override
public void close() {
- // Closing the fd releases the lock. We do NOT remove the .lock
- // file or the .lock.pid sidecar — a stale PID is harmless (next
- // acquirer overwrites .lock.pid on success).
- if (fd >= 0) {
- Files.close(fd);
- fd = -1;
+ // QuietCloseable cannot report a failure, so retain this object on an
+ // allocation-free retry list when unlock is unconfirmed. Serialize the
+ // release attempt and publication: an acquire that starts after close
+ // returns must not miss the retained owner. An acquire already racing
+ // an in-progress close may still observe ordinary lock contention.
+ synchronized (RELEASE_RETRY_LOCK) {
+ if (!release()) {
+ retainForReleaseRetryLocked();
+ }
}
}
@@ -152,6 +283,50 @@ private static String readHolder(String pidPath) {
}
}
+ private static native int release0(int fd);
+
+ private void retainForReleaseRetryLocked() {
+ if (!isReleaseRetryPending) {
+ isReleaseRetryPending = true;
+ releaseRetryNext = releaseRetryHead;
+ releaseRetryHead = this;
+ }
+ }
+
+ private synchronized void restoreFdForTesting(int savedFd) {
+ if (fd != DEAD_FD_FOR_TESTING) {
+ throw new IllegalStateException("slot lock release failure is not injected");
+ }
+ fd = savedFd;
+ }
+
+ private static void retryPendingReleases() {
+ synchronized (RELEASE_RETRY_LOCK) {
+ SlotLock previous = null;
+ SlotLock lock = releaseRetryHead;
+ while (lock != null) {
+ SlotLock next = lock.releaseRetryNext;
+ // release() reports operational unlock failure as false. Do
+ // not catch Error or unexpected programming failures here:
+ // hiding them as apparent lock contention would misdiagnose
+ // the process and create a new retry contract for VM errors.
+ if (lock.release()) {
+ if (previous == null) {
+ releaseRetryHead = next;
+ } else {
+ previous.releaseRetryNext = next;
+ }
+ lock.isReleaseRetryPending = false;
+ lock.releaseRetryNext = null;
+ lock = next;
+ continue;
+ }
+ previous = lock;
+ lock = next;
+ }
+ }
+ }
+
private static void writePid(String pidPath) {
long pid;
try {
@@ -181,4 +356,22 @@ private static void writePid(String pidPath) {
Files.close(wfd);
}
}
+
+ @TestOnly
+ public final class ReleaseFailureForTesting implements QuietCloseable {
+ private final int savedFd;
+ private boolean isRestored;
+
+ private ReleaseFailureForTesting(int savedFd) {
+ this.savedFd = savedFd;
+ }
+
+ @Override
+ public synchronized void close() {
+ if (!isRestored) {
+ restoreFdForTesting(savedFd);
+ isRestored = true;
+ }
+ }
+ }
}
diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SlotLockContentionException.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SlotLockContentionException.java
new file mode 100644
index 00000000..6dc98f67
--- /dev/null
+++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SlotLockContentionException.java
@@ -0,0 +1,37 @@
+/*+*****************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * 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;
+
+/**
+ * Signals that another sender or drainer currently owns an SF slot lock.
+ * This expected contention condition is distinct from operational failures
+ * that prevent the lock file from being opened or created.
+ */
+public final class SlotLockContentionException extends IllegalStateException {
+
+ public SlotLockContentionException(String message) {
+ super(message);
+ }
+}
diff --git a/core/src/main/java/io/questdb/client/impl/ConfigSchema.java b/core/src/main/java/io/questdb/client/impl/ConfigSchema.java
index a255e638..9cd18134 100644
--- a/core/src/main/java/io/questdb/client/impl/ConfigSchema.java
+++ b/core/src/main/java/io/questdb/client/impl/ConfigSchema.java
@@ -88,6 +88,7 @@ public final class ConfigSchema {
str("sf_durability", Side.INGRESS);
str("sf_max_bytes", Side.INGRESS);
str("sf_max_total_bytes", Side.INGRESS);
+ str("sf_sync_interval_millis", Side.INGRESS);
str("transaction", Side.INGRESS);
// EGRESS -- the QwpQueryClient applies. Typed where there is a range or
diff --git a/core/src/main/java/io/questdb/client/impl/PooledSender.java b/core/src/main/java/io/questdb/client/impl/PooledSender.java
index 1379c76c..7b4e5f80 100644
--- a/core/src/main/java/io/questdb/client/impl/PooledSender.java
+++ b/core/src/main/java/io/questdb/client/impl/PooledSender.java
@@ -32,6 +32,7 @@
import io.questdb.client.std.Decimal64;
import io.questdb.client.std.bytes.DirectByteSlice;
import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.TestOnly;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
@@ -380,6 +381,21 @@ long generation() {
return generation;
}
+ @TestOnly
+ public Sender getDelegateForTesting() {
+ return slot.delegate();
+ }
+
+ @TestOnly
+ public int getSlotIndexForTesting() {
+ return slot.slotIndex();
+ }
+
+ @TestOnly
+ public boolean hasSameSlotForTesting(PooledSender that) {
+ return slot == that.slot;
+ }
+
SenderSlot slot() {
return slot;
}
diff --git a/core/src/main/java/io/questdb/client/impl/QueryClientPool.java b/core/src/main/java/io/questdb/client/impl/QueryClientPool.java
index 259ff10c..75323a12 100644
--- a/core/src/main/java/io/questdb/client/impl/QueryClientPool.java
+++ b/core/src/main/java/io/questdb/client/impl/QueryClientPool.java
@@ -27,6 +27,8 @@
import io.questdb.client.QueryException;
import io.questdb.client.cutlass.qwp.client.QwpQueryClient;
import org.jetbrains.annotations.TestOnly;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
import java.util.ArrayDeque;
import java.util.ArrayList;
@@ -56,10 +58,19 @@ public final class QueryClientPool implements AutoCloseable {
// close() can never block the caller unbounded. Tunable per pool via
// closeQueryTimeoutMillis(long).
static final long DEFAULT_CLOSE_QUERY_TIMEOUT_MILLIS = 5_000;
+ // Hard cap on close()'s wait for a creation blocked in DNS, TCP, TLS, or
+ // WebSocket setup. A late creator retains the client and native-scratch
+ // cleanup obligation until it returns and observes the closed pool.
+ static final long MAX_CLOSE_CREATION_WAIT_MILLIS = 5_000;
+ private static final Logger LOG = LoggerFactory.getLogger(QueryClientPool.class);
private final long acquireTimeoutMillis;
private final ArrayList all;
private final ArrayDeque available;
private final String configurationString;
+ // Signals completion of internally owned creation lifecycles. Kept
+ // separate from workerReleased so an acquire waiter cannot consume the only
+ // wakeup intended for close().
+ private final Condition creationFinished;
// Test seam. Production connects via QwpQueryClient.connect(); white-box
// tests in io.questdb.client.test.impl reach the package-private constructor
// by reflection to inject a hook that throws a non-RuntimeException
@@ -146,6 +157,7 @@ public QueryClientPool(
this.maxLifetimeMillis = maxLifetimeMillis;
this.all = new ArrayList<>(maxSize);
this.available = new ArrayDeque<>(maxSize);
+ this.creationFinished = lock.newCondition();
this.workerReleased = lock.newCondition();
int built = 0;
// Tracks a worker built by createUnlocked() but not yet added to `all`:
@@ -231,16 +243,11 @@ public QueryWorker acquire() {
// incremented, permanently shrinking pool capacity until
// every acquire() times out. Restoring the reservation
// for any throwable is safe.
- lock.lock();
- inFlightCreations--;
- workerReleased.signal();
- lock.unlock();
// createUnlocked() returns a fully connected client
// (socket + native scratch + I/O thread), so if start()
// threw afterwards we must close it here -- nothing else
- // references it. createUnlocked() already self-cleans
- // when connect() throws, leaving created == null, so
- // this only fires on the start()-throws path.
+ // references it. Keep the creation reservation until
+ // that cleanup finishes so close() cannot return first.
if (created != null) {
try {
created.shutdown();
@@ -249,33 +256,45 @@ public QueryWorker acquire() {
// original creation failure rethrown below.
}
}
+ lock.lock();
+ try {
+ inFlightCreations--;
+ creationFinished.signalAll();
+ workerReleased.signal();
+ } finally {
+ lock.unlock();
+ }
throw new QueryException((byte) 0,
"failed to create query client: " + e.getMessage(), e);
}
lock.lock();
- inFlightCreations--;
if (closed) {
- // Pool was closed mid-creation -- tear the fresh worker
- // down rather than leaking it, but OUTSIDE the lock:
- // shutdown() joins the dispatch thread for up to
- // SHUTDOWN_JOIN_MILLIS, and close()/release()/discard()/
- // cancelIfCurrent() all contend on this lock (whose
- // contract is "held only briefly"). The accounting above
- // already ran under the lock, and the worker never
- // entered `all`, so close()'s snapshot loop cannot race
- // this teardown.
+ // Keep inFlightCreations reserved while shutdown runs
+ // outside the lock. The worker never entered `all`, so
+ // this reservation is close()'s sole ownership record.
lock.unlock();
try {
created.shutdown();
} catch (Throwable ignored) {
// Best-effort: an Error from teardown must not mask
// the closed-pool signal.
+ } finally {
+ lock.lock();
+ try {
+ inFlightCreations--;
+ creationFinished.signalAll();
+ workerReleased.signalAll();
+ } finally {
+ lock.unlock();
+ }
}
throw new QueryException((byte) 0, "QuestDB handle is closed");
}
all.add(created);
// Stamp the first lease id for this freshly built worker.
created.bumpGeneration();
+ inFlightCreations--;
+ creationFinished.signalAll();
return created;
}
if (remainingNanos <= 0) {
@@ -307,6 +326,35 @@ public void close() {
}
closed = true;
workerReleased.signalAll();
+ // A creator can block in DNS, TCP, TLS, WebSocket upgrade, or the
+ // SERVER_INFO handshake. Wait boundedly rather than turning an
+ // unset connect_timeout into an unbounded QuestDB.close(). Timing
+ // out does not abandon the client or its native scratch: the creator
+ // keeps the reservation and, once construction returns, observes
+ // closed and shuts the worker down before releasing ownership.
+ // Preserve interruption while applying the same finite budget.
+ final long creationWaitMillis = Math.max(0,
+ Math.min(acquireTimeoutMillis, MAX_CLOSE_CREATION_WAIT_MILLIS));
+ final long creationWaitNanos = TimeUnit.MILLISECONDS.toNanos(creationWaitMillis);
+ final long creationWaitDeadlineNanos = System.nanoTime() + creationWaitNanos;
+ long creationRemainingNanos = creationWaitNanos;
+ boolean creationWaitInterrupted = false;
+ while (inFlightCreations > 0 && creationRemainingNanos > 0) {
+ try {
+ creationFinished.awaitNanos(creationRemainingNanos);
+ } catch (InterruptedException e) {
+ creationWaitInterrupted = true;
+ }
+ creationRemainingNanos = creationWaitDeadlineNanos - System.nanoTime();
+ }
+ if (creationWaitInterrupted) {
+ Thread.currentThread().interrupt();
+ }
+ if (inFlightCreations > 0) {
+ LOG.warn("QueryClientPool.close(): {} query client creation(s) still in flight after {}ms; "
+ + "each creator retains cleanup ownership until construction returns",
+ inFlightCreations, creationWaitMillis);
+ }
snapshot = new ArrayList<>(all);
} finally {
lock.unlock();
@@ -480,6 +528,16 @@ void release(QueryWorker w, long gen) {
}
}
+ @TestOnly
+ public boolean hasCreationWaiterForTesting() {
+ lock.lock();
+ try {
+ return lock.hasWaiters(creationFinished);
+ } finally {
+ lock.unlock();
+ }
+ }
+
// White-box accessor for tests: reports the current in-flight creation count
// under the pool lock. A non-zero value after a failed acquire() means the
// slot reservation was never released -- the capacity-shrink bug this guards
@@ -494,6 +552,11 @@ public int inFlightCreations() {
}
}
+ @TestOnly
+ public boolean isClosedForTesting() {
+ return closed;
+ }
+
private QueryWorker createUnlocked() {
QwpQueryClient client = QwpQueryClient.fromConfig(configurationString);
try {
diff --git a/core/src/main/java/io/questdb/client/impl/QuestDBImpl.java b/core/src/main/java/io/questdb/client/impl/QuestDBImpl.java
index e3da539b..75ee0a26 100644
--- a/core/src/main/java/io/questdb/client/impl/QuestDBImpl.java
+++ b/core/src/main/java/io/questdb/client/impl/QuestDBImpl.java
@@ -181,8 +181,20 @@ public Sender borrowSender() {
return senderPool.borrow();
}
+ // synchronized so concurrent close() callers serialize THROUGH the bounded
+ // shutdown sequence, not merely through the `closed` flip. `closed` is set
+ // before the teardown chain runs, so a plain volatile guard (or a bare CAS)
+ // would let a second caller observe closed==true and return while the first
+ // is still closing published resources. The monitor makes the losing caller
+ // block until the winner finishes, then it enters, sees `closed` and returns
+ // a no-op. A pool creator that outlives its finite shutdown wait retains and
+ // eventually cleans its unpublished resources on the creator thread; this
+ // monitor does not wait past that budget. No deadlock: the teardown steps
+ // (markClosing/housekeeper.stop()/queryPool.close()/senderPool.close())
+ // never call back into QuestDBImpl.close() on another thread, so nothing
+ // contends for this monitor from within the critical section.
@Override
- public void close() {
+ public synchronized void close() {
if (closed) {
return;
}
@@ -203,6 +215,16 @@ public void close() {
closeQuietly(senderPool);
}
+ @TestOnly
+ public QueryClientPool getQueryPoolForTesting() {
+ return queryPool;
+ }
+
+ @TestOnly
+ public SenderPool getSenderPoolForTesting() {
+ return senderPool;
+ }
+
private static void closeQuietly(PoolHousekeeper housekeeper) {
if (housekeeper == null) {
return;
diff --git a/core/src/main/java/io/questdb/client/impl/SenderPool.java b/core/src/main/java/io/questdb/client/impl/SenderPool.java
index 1cc214b4..d49a24e1 100644
--- a/core/src/main/java/io/questdb/client/impl/SenderPool.java
+++ b/core/src/main/java/io/questdb/client/impl/SenderPool.java
@@ -31,6 +31,8 @@
import io.questdb.client.cutlass.qwp.client.QwpWebSocketSender;
import io.questdb.client.cutlass.qwp.client.sf.cursor.BackgroundDrainerListener;
import io.questdb.client.cutlass.qwp.client.sf.cursor.OrphanScanner;
+import io.questdb.client.cutlass.qwp.client.sf.cursor.SlotLock;
+import io.questdb.client.cutlass.qwp.client.sf.cursor.SlotLockContentionException;
import io.questdb.client.std.Files;
import io.questdb.client.std.IntList;
import org.jetbrains.annotations.TestOnly;
@@ -40,6 +42,7 @@
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Iterator;
+import java.util.concurrent.ThreadFactory;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
@@ -85,8 +88,8 @@ public final class SenderPool implements AutoCloseable {
// Per-slot wall-clock cap on a single startup-recovery drain. Kept BELOW the
// PoolHousekeeper stop/join budget (PoolHousekeeper.STOP_TIMEOUT_MILLIS) so a
// recovery drain still in flight when close() arrives cannot outlive the
- // housekeeper join -- the residual-budget bound that, together with the
- // early markClosing() signal, keeps close() prompt (C1 fix).
+ // driver join -- the residual-budget bound that, together with the early
+ // markClosing() signal, keeps close() prompt (C1 fix).
//
// This caps only the DRAIN. The recovery build that precedes it is bounded
// separately: recovery delegates force initial_connect_mode=OFF (see
@@ -96,6 +99,27 @@ public final class SenderPool implements AutoCloseable {
// -- the residual window documented on recoverOneSlotStep -- because the
// transport has no application-level connect timeout to clamp it.
private static final long RECOVERY_DRAIN_BUDGET_MILLIS = 1_000;
+ // The scan parks ONE candidate slot and moves on (C2) on the slot's Nth
+ // (3rd) consecutive recoverOneSlotStep failure: two retries in place,
+ // park on the third. The retry-in-place policy
+ // assumes a failure is server-wide and will repeat for every remaining
+ // slot, but a slot-specific persistent condition (e.g. a corrupt slot dir
+ // that fails the recovery build every time) would otherwise pin the cursor
+ // forever: the driver would livelock retrying that one slot each second
+ // while every higher-index slot's durable orphan data starves. Small
+ // enough to bound how long one slot can block the scan; large enough that
+ // the dominant server-wide transient failure keeps its retry-the-same-
+ // candidate behavior for the first attempts.
+ private static final int RECOVERY_MAX_SLOT_FAILURE_STREAK = 3;
+ // A direct SenderPool has no PoolHousekeeper to retry a transient startup
+ // recovery failure. Its private driver waits this long between failed
+ // attempts so an unavailable server does not cause a hot retry loop.
+ private static final long RECOVERY_RETRY_INTERVAL_MILLIS = 1_000;
+ // Hard cap on close()'s wait for a creation that is blocked in DNS, TCP,
+ // TLS, or WebSocket setup. The creator retains ownership after this budget:
+ // once construction returns, borrow() observes closed and tears down the
+ // delegate before releasing its reservation (including an SF slot).
+ static final long MAX_CLOSE_CREATION_WAIT_MILLIS = 5_000;
// Hard cap on close()'s outstanding-lease wait. The acquire timeout is a
// BORROW policy -- Long.MAX_VALUE legitimately means "block until a slot
// frees" -- and must never unbound SHUTDOWN: without this cap a forgotten
@@ -106,7 +130,16 @@ public final class SenderPool implements AutoCloseable {
private final long acquireTimeoutMillis;
private final ArrayList all;
private final ArrayDeque available;
+ // Test-only constructor seam. Runs immediately before a possibly-started
+ // recovery driver is joined after constructor failure. Null in production;
+ // lifecycle tests use it to release a deliberately held driver and prove
+ // delegate cleanup happens only after the join.
+ private final Runnable beforeFailedStartupRecoveryJoinHook;
private final String configurationString;
+ // Signals completion of internally owned creation lifecycles. Kept
+ // separate from slotReleased so a capacity waiter cannot consume the only
+ // wakeup intended for close().
+ private final Condition creationFinished;
// User-supplied ingest callbacks, shared across every pooled Sender this
// pool builds. Null -> each sender keeps its loud-not-silent default.
private final SenderConnectionListener connectionListener;
@@ -119,15 +152,19 @@ public final class SenderPool implements AutoCloseable {
// RuntimeException Throwable (e.g. an -ea AssertionError) mid-prewarm,
// exercising the Error-safe delegate cleanup loop.
private final IntFunction senderFactory;
+ // Test seam: runs immediately after a delegate factory returns, before
+ // listener registration and SenderSlot construction. Null in production;
+ // error-safety tests inject a preallocated throwable at this ownership gap.
+ private final Runnable postFactoryHook;
// Factory for startup-recovery delegates. Distinct from senderFactory so a
// recoverer can force a non-blocking initial connect (initial_connect_mode=
- // OFF) regardless of user config: a recovery build runs on the
- // PoolHousekeeper thread and must NOT inherit SYNC (auto-enabled by any
- // reconnect_* knob), which would retry the connect for the whole reconnect
- // budget inside build() -- far past PoolHousekeeper.STOP_TIMEOUT_MILLIS, so
- // a close() landing during that build would make housekeeper.stop()'s join
- // time out and leave the recoverer holding the slot flock after close()
- // returned (M1). Mirrors senderFactory's test seam: an injected factory
+ // OFF) regardless of user config: a recovery build runs on a private direct
+ // driver or the PoolHousekeeper thread and must NOT inherit SYNC
+ // (auto-enabled by any reconnect_* knob), which would retry the connect for
+ // the whole reconnect budget inside build() -- far past
+ // PoolHousekeeper.STOP_TIMEOUT_MILLIS, so a close() landing during that
+ // build could make its driver join time out and leave the recoverer holding
+ // the slot flock after close() returned (M1). Mirrors senderFactory's test seam: an injected factory
// (non-null) drives BOTH paths so white-box recovery tests keep control.
private final IntFunction recoverySenderFactory;
private final ReentrantLock lock = new ReentrantLock();
@@ -148,6 +185,55 @@ public final class SenderPool implements AutoCloseable {
private final Condition slotReleased;
// True iff the configuration enables store-and-forward (sf_dir set).
private final boolean storeAndForward;
+ // Direct pools run one inline recovery attempt for backwards-compatible
+ // startup behavior, then this daemon continues any backlog left by budget
+ // exhaustion or a transient failure. Deferred pools use PoolHousekeeper and
+ // leave this null.
+ private final Object startupRecoverySignal = new Object();
+ private final Thread startupRecoveryThread;
+ // Revived direct-pool recovery driver: spawned by recoverRetiredSlotAt()
+ // when a retired slot's late flock release re-arms an already-latched
+ // scan on a pool whose original private driver has exited. Deferred
+ // pools re-arm through the PoolHousekeeper tick instead and never spawn
+ // one. Joined by stopStartupRecoveryDriver() on close. Volatile: written
+ // under `lock`, read by close() without it.
+ private volatile Thread revivedStartupRecoveryThread;
+ // Ownership token for the private (direct-pool) recovery driver duty.
+ // Guarded by `lock`. TRUE while some thread owns runStartupRecoveryLoop;
+ // the loop drops it transactionally on exit (re-checking the latch under
+ // the lock first -- see releaseRecoveryDriverOwnership), and
+ // reviveDirectRecoveryDriverIfNeeded() spawns a successor only when it
+ // is FALSE. Replaces a Thread.isAlive() heuristic whose read raced the
+ // driver's final latch check: a driver observed "alive" could already
+ // have committed to exit, leaving an un-latched scan ownerless until the
+ // next release event or a restart.
+ private boolean recoveryDriverRunning;
+ // Driver policy captured for the re-arm path: deferred pools are driven
+ // by PoolHousekeeper (never spawn private drivers); direct pools own
+ // their (revivable) private driver, built via recoveryThreadFactory.
+ private final boolean deferStartupRecovery;
+ private final ThreadFactory recoveryThreadFactory;
+ // Test-only constructor seam for the failed-retry operation. Production
+ // uses waitForStartupRecoveryRetry(), which performs the one-second signal
+ // wait; lifecycle tests inject an event barrier without wall-clock checks.
+ private final Runnable startupRecoveryWaiter;
+ // Test seam: runs after the direct recovery driver's bounded join returns.
+ // Null in production; lifecycle tests prove the joined thread is quiescent.
+ private volatile Runnable afterStartupRecoveryJoinHook;
+ // Test seam: runs immediately before a capacity-starved borrow enters its
+ // condition wait, while it still holds the pool lock. Null in production;
+ // concurrency tests use a latch here to prove that several borrowers have
+ // all reached the wait path before recovering retired capacity.
+ private volatile Runnable beforeBorrowWaitHook;
+ // Test seam: runs immediately before the direct recovery driver's bounded
+ // join. Null in production; lifecycle tests coordinate close() with a
+ // deliberately held driver.
+ private volatile Runnable beforeStartupRecoveryJoinHook;
+ // Test seam: runs after a capacity-starved borrow's condition wait has
+ // exhausted its positive timeout, before the loop's terminal pass. Null in
+ // production; regression tests release a retired slot here to prove that
+ // the terminal pass re-probes returned capacity before throwing.
+ private volatile Runnable borrowWaitExpiredHook;
// Slots removed from `all` whose delegate is still releasing its flock.
// They keep reserving capacity (and their slotInUse mark) until the
// flock drops, so the cap check and the slot allocator stay consistent
@@ -171,34 +257,91 @@ public final class SenderPool implements AutoCloseable {
// down on another thread. Guarded by lock.
private int pendingLeaseTeardowns;
// Slots whose delegate close() returned with the SF flock still held
- // (the I/O thread refused to stop). Permanently consumed: the index is
+ // because an I/O or manager worker did not stop. Consumed while retired:
// never freed and never reused, so no borrow ever hands out a still-
// locked slot dir. Counted in the cap check so the lost capacity is
- // accounted for. Guarded by lock; only ever ticks for SF slots.
+ // accounted for. NOT necessarily permanent: engine cleanup may be pending
+ // on a worker/I/O-thread exit path, so reprobeRetiredSlots() re-checks
+ // retiredSlots and returns any index whose flock has since dropped.
+ // Guarded by lock; only ever ticks for SF slots.
private int leakedSlots;
+ // Deterministic white-box complexity counter. Counts delegate release
+ // probes performed by direct callbacks and fallback scans. Guarded by lock.
+ private long retiredSlotProbeCount;
+ // The retired slots behind the leakedSlots count: runtime reclaim paths
+ // (discardBroken/reapIdle via reclaimSlot) and the in-range startup-
+ // recovery pass (recoverOneSlotStep, which retains the recoverer slot for
+ // exactly this purpose). Re-probed by reprobeRetiredSlots() so a late
+ // flock release (deferred engine cleanup on a worker exit path) restores
+ // the pool's capacity instead of ratcheting it down until process exit.
+ // Out-of-range startup recoverers are NEVER added: they carry no
+ // leakedSlots tick and their index has no slotInUse entry to free.
+ // Pre-sized to maxSize (every entry keeps a distinct in-range slot index
+ // reserved, so size can never exceed maxSize): add() never grows the
+ // backing array, so a retire (leakedSlots++ then add, under lock) cannot
+ // fail on allocation and strand a counted-but-untracked slot that
+ // reprobeRetiredSlots() could never recover. Guarded by lock.
+ private final ArrayList retiredSlots;
// SF slots currently held by the in-range startup-recovery pass
// (recoverOneSlotStep): each is reserved under `lock` for the
// duration of its drain and counted in the borrow() cap check so a
// concurrent borrow can neither over-allocate past maxSize nor target a
- // dir being recovered. Only ever non-zero on the deferred (housekeeper-
- // driven) recovery path, where recovery overlaps borrow()/return; on the
- // inline construction path the pool is still single-threaded. Guarded by
- // lock; only ever ticks for SF slots.
+ // dir being recovered. The inline constructor drive is single-threaded,
+ // but the continuing direct-pool driver and deferred housekeeper driver can
+ // overlap borrow()/return after publication. Guarded by lock; only ever
+ // ticks for SF slots.
private int recoveringSlots;
// Resumable startup-recovery scan cursor. Advanced only by the single
- // recovery driver -- the inline constructor loop (single-threaded,
- // unpublished) or the PoolHousekeeper thread (the sole deferred driver) --
+ // recovery driver -- the inline constructor loop followed by its private
+ // direct-pool thread, or the PoolHousekeeper thread (the sole deferred
+ // driver) --
// so the cursor itself needs no lock; the per-slot reservation it performs
// (slotInUse/recoveringSlots) is still taken under `lock` because borrow()
// races it. recoveryInRangeNext is the next in-range index in [0, maxSize)
// for pass 1; recoveryOutOfRange / recoveryOutOfRangeNext are the lazily
// built pass-2 work list (same-base slots at index >= maxSize) and its
- // cursor; recoveryComplete latches true when the whole scan finishes or is
- // aborted, making runStartupRecoveryStep()/...ToCompletion() idempotent.
+ // cursor; recoveryComplete latches true only when the whole scan finishes.
+ // A transient build failure or drain timeout leaves the current candidate
+ // pending so a later tick or explicit drive can retry it on the same pool.
+ // A slot-SPECIFIC failure -- flock contention with another live owner, or
+ // RECOVERY_MAX_SLOT_FAILURE_STREAK consecutive failures on one slot -- is
+ // instead "parked": the cursor advances past it so it cannot starve the
+ // remaining slots, recoveryDeferredThisCycle records the park, and once
+ // both passes finish with parked slots outstanding the cursors rewind for
+ // another cycle on a later tick instead of latching recoveryComplete.
+ // A RETIRED index whose dir still holds data is deferred the same way on
+ // every walk (see the reserved-skip branch in recoverOneSlotStep) so the
+ // latch can never strand a retired slot's data while the pool lives.
+ // If the retire lands only AFTER the scan latched (a runtime
+ // discardBroken/reapIdle reclaim), the late flock release re-arms the
+ // scan instead: recoverRetiredSlotAt() rewinds the cursors and clears
+ // recoveryComplete -- volatile, so the un-latch (written under `lock` by
+ // release callbacks and reprobes) is visible to the unlocked driver
+ // gates -- and, for direct pools whose private driver already exited,
+ // revives the driver. Deferred pools re-arm through the housekeeper tick.
+ // recoveryFailStreak/-Slot track consecutive failures on one candidate;
+ // recoveryWarnedSlots dedups the per-slot WARNs so an indefinitely
+ // retried slot logs once per failure episode, not once per retry. All of
+ // this state is owned by the single recovery driver like the cursors.
private int recoveryInRangeNext;
private IntList recoveryOutOfRange;
private int recoveryOutOfRangeNext;
- private boolean recoveryComplete;
+ private volatile boolean recoveryComplete;
+ // Set by recoverRetiredSlotAt() whenever a released retired slot's dir is
+ // still a candidate orphan, and consumed (under `lock`) by the scan's
+ // end-of-cycle latch decision. Closes the mid-cycle window: a retire AND
+ // its release can both land while the scan is alive but after the cursor
+ // already passed that index (it was LIVE when walked -- no retired-
+ // candidate deferral -- and recoveryComplete was still false at release,
+ // so the post-latch re-arm does not apply). Without this flag such a
+ // cycle could end with zero deferrals and latch past the freed dir's
+ // stranded data. Producer and consumer both run under `lock`, so no
+ // set can slip between the driver's check and its latch write.
+ private volatile boolean recoveryRearmRequested;
+ private int recoveryDeferredThisCycle;
+ private int recoveryFailStreak;
+ private int recoveryFailStreakSlot = -1;
+ private final IntList recoveryWarnedSlots = new IntList();
public SenderPool(
String configurationString,
@@ -209,7 +352,7 @@ public SenderPool(
long maxLifetimeMillis
) {
this(configurationString, minSize, maxSize, acquireTimeoutMillis,
- idleTimeoutMillis, maxLifetimeMillis, null, false, null, null, null);
+ idleTimeoutMillis, maxLifetimeMillis, null, false, null, null, null, null, null, null, null);
}
// Test-only constructor exposing the senderFactory seam: production builds
@@ -238,8 +381,8 @@ public SenderPool(
// reachable-but-not-acking server; the owner (QuestDBImpl) then drives
// recovery one slot per tick on the PoolHousekeeper thread via
// runStartupRecoveryStep(). White-box SF tests call this directly; the
- // in-range recovery pass is concurrency-safe against borrow()/return on the
- // deferred path -- see recoverOneSlotStep().
+ // in-range recovery pass is concurrency-safe against borrow()/return after
+ // pool publication -- see recoverOneSlotStep().
@TestOnly
public SenderPool(
String configurationString,
@@ -253,7 +396,43 @@ public SenderPool(
) {
this(configurationString, minSize, maxSize, acquireTimeoutMillis,
idleTimeoutMillis, maxLifetimeMillis, senderFactory,
- deferStartupRecovery, null, null, null);
+ deferStartupRecovery, null, null, null, null, null, null, null);
+ }
+
+ // Test-only constructor adding a deterministic fault hook for the ownership
+ // gap after a delegate factory returns.
+ @TestOnly
+ public SenderPool(
+ String configurationString,
+ int minSize,
+ int maxSize,
+ long acquireTimeoutMillis,
+ long idleTimeoutMillis,
+ long maxLifetimeMillis,
+ IntFunction senderFactory,
+ boolean deferStartupRecovery,
+ Runnable postFactoryHook
+ ) {
+ this(configurationString, minSize, maxSize, acquireTimeoutMillis,
+ idleTimeoutMillis, maxLifetimeMillis, senderFactory,
+ deferStartupRecovery, null, null, null, postFactoryHook, null, null, null);
+ }
+
+ @TestOnly
+ public static SenderPool createWithRecoveryControlsForTesting(
+ String configurationString,
+ int minSize,
+ int maxSize,
+ long acquireTimeoutMillis,
+ IntFunction senderFactory,
+ ThreadFactory recoveryThreadFactory,
+ Runnable recoveryWaiter,
+ Runnable beforeFailedRecoveryJoinHook
+ ) {
+ return new SenderPool(configurationString, minSize, maxSize, acquireTimeoutMillis,
+ Long.MAX_VALUE, Long.MAX_VALUE, senderFactory, false,
+ null, null, null, null, recoveryThreadFactory, recoveryWaiter,
+ beforeFailedRecoveryJoinHook);
}
// Full constructor adding the user-supplied ingest callbacks (error
@@ -273,6 +452,29 @@ public SenderPool(
SenderErrorHandler errorHandler,
SenderConnectionListener connectionListener,
BackgroundDrainerListener drainerListener
+ ) {
+ this(configurationString, minSize, maxSize, acquireTimeoutMillis,
+ idleTimeoutMillis, maxLifetimeMillis, senderFactory,
+ deferStartupRecovery, errorHandler, connectionListener,
+ drainerListener, null, null, null, null);
+ }
+
+ private SenderPool(
+ String configurationString,
+ int minSize,
+ int maxSize,
+ long acquireTimeoutMillis,
+ long idleTimeoutMillis,
+ long maxLifetimeMillis,
+ IntFunction senderFactory,
+ boolean deferStartupRecovery,
+ SenderErrorHandler errorHandler,
+ SenderConnectionListener connectionListener,
+ BackgroundDrainerListener drainerListener,
+ Runnable postFactoryHook,
+ ThreadFactory recoveryThreadFactory,
+ Runnable recoveryWaiter,
+ Runnable beforeFailedRecoveryJoinHook
) {
if (minSize < 0 || maxSize < 1 || minSize > maxSize) {
throw new IllegalArgumentException("invalid pool sizing: min=" + minSize + ", max=" + maxSize);
@@ -291,8 +493,17 @@ public SenderPool(
this.acquireTimeoutMillis = acquireTimeoutMillis;
this.idleTimeoutMillis = idleTimeoutMillis;
this.maxLifetimeMillis = maxLifetimeMillis;
+ this.postFactoryHook = postFactoryHook;
+ this.beforeFailedStartupRecoveryJoinHook = beforeFailedRecoveryJoinHook;
+ this.deferStartupRecovery = deferStartupRecovery;
+ this.recoveryThreadFactory = recoveryThreadFactory;
+ this.startupRecoveryWaiter = recoveryWaiter != null
+ ? recoveryWaiter
+ : this::waitForStartupRecoveryRetry;
this.all = new ArrayList<>(maxSize);
this.available = new ArrayDeque<>(maxSize);
+ this.retiredSlots = new ArrayList<>(maxSize);
+ this.creationFinished = lock.newCondition();
this.slotReleased = lock.newCondition();
// Probe the config once, up front: this validates it eagerly (so a
// bad config fails at construction even when minSize == 0) and tells
@@ -324,17 +535,7 @@ public SenderPool(
// propagate without running the cleanup below, leaking every
// already-built delegate's flock + mmap'd ring + I/O thread and
// resurrecting "sf slot already in use" on the next attempt.
- for (int i = 0; i < built; i++) {
- try {
- all.get(i).delegate().close();
- } catch (Throwable ignored) {
- // Best-effort cleanup: a delegate close() can throw an
- // Error (e.g. an -ea AssertionError) as well as a
- // RuntimeException. Swallow either so we still close the
- // remaining pre-warmed slots and rethrow the original
- // construction failure below.
- }
- }
+ closePrewarmedDelegates(built);
throw e;
}
// Prewarm succeeded. Recover any unacked data a previous run left in
@@ -343,18 +544,46 @@ public SenderPool(
// (deferStartupRecovery=true) so QuestDB.build() never blocks on a slow
// or reachable-but-not-acking server; direct constructions run it inline
// here, while still single-threaded and unpublished.
- if (!deferStartupRecovery) {
- runStartupRecoveryToCompletion();
+ Thread recoveryThread = null;
+ try {
+ if (!deferStartupRecovery) {
+ runStartupRecoveryToCompletion();
+ if (storeAndForward && !recoveryComplete) {
+ ThreadFactory threadFactory = recoveryThreadFactory != null
+ ? recoveryThreadFactory
+ : SenderPool::createStartupRecoveryThread;
+ recoveryThread = threadFactory.newThread(this::runStartupRecoveryLoop);
+ recoveryThread.setDaemon(true);
+ // Ownership precedes start(): the loop's transactional
+ // exit is the only place the token is dropped.
+ recoveryDriverRunning = true;
+ }
+ }
+ this.startupRecoveryThread = recoveryThread;
+ if (recoveryThread != null) {
+ recoveryThread.start();
+ }
+ } catch (Throwable e) {
+ // Thread allocation, configuration, or start can throw after
+ // prewarm transferred delegate ownership to this constructor. The
+ // pool cannot be returned, so stop a possibly-started driver before
+ // tearing down delegates it could otherwise still recover against.
+ // Preserve the construction throwable; cleanup is best-effort.
+ markClosing();
+ stopFailedStartupRecoveryDriver(recoveryThread);
+ closePrewarmedDelegates(built);
+ throw e;
}
}
/**
- * Drives startup SF recovery to completion in a single call, bounded by one
- * shared {@code acquireTimeoutMillis} wall-clock budget (and each individual
- * drain by {@link #RECOVERY_DRAIN_BUDGET_MILLIS}). Used by the inline
- * construction path -- single-threaded and unpublished -- and by manual /
- * test drives. No-op when SF is off, the pool is shutting down, or recovery
- * has already finished. Idempotent.
+ * Drives startup SF recovery toward completion in a single call, bounded by
+ * one shared {@code acquireTimeoutMillis} wall-clock budget (and each
+ * individual drain by {@link #RECOVERY_DRAIN_BUDGET_MILLIS}). Used by the
+ * inline construction path -- single-threaded and unpublished -- and by
+ * manual / test drives. Budget exhaustion or a transient slot failure leaves
+ * the cursor pending for the pool's continuing driver. No-op when SF is off,
+ * the pool is shutting down, or recovery has already finished. Idempotent.
*/
void runStartupRecoveryToCompletion() {
if (!storeAndForward) {
@@ -366,41 +595,110 @@ void runStartupRecoveryToCompletion() {
// accepted for a single borrow, so we reuse it as the total budget; once
// spent, the remaining slots wait for a later attempt (data stays
// durable on disk).
- final long deadlineNanos =
- System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(acquireTimeoutMillis);
+ final long budgetNanos = TimeUnit.MILLISECONDS.toNanos(acquireTimeoutMillis);
+ final long startNanos = System.nanoTime();
while (!closed && !recoveryComplete) {
- long remainingMillis = TimeUnit.NANOSECONDS.toMillis(deadlineNanos - System.nanoTime());
- if (remainingMillis <= 0) {
+ // Subtract nanoTime samples before comparing them with the duration.
+ // This avoids an overflowed absolute deadline when
+ // acquireTimeoutMillis is Long.MAX_VALUE and keeps the intended
+ // duration comparison explicit.
+ long elapsedNanos = System.nanoTime() - startNanos;
+ if (elapsedNanos >= budgetNanos) {
LOG.warn("startup SF recovery: {}ms budget exhausted; "
- + "skipping remaining slots", acquireTimeoutMillis);
- recoveryComplete = true;
+ + "deferring remaining slots", acquireTimeoutMillis);
return;
}
+ long remainingNanos = budgetNanos - elapsedNanos;
+ long remainingMillis = TimeUnit.NANOSECONDS.toMillis(remainingNanos);
+ if (remainingMillis == 0) {
+ // drain() accepts milliseconds; preserve a positive sub-ms
+ // remainder instead of treating truncation as exhaustion.
+ remainingMillis = 1;
+ }
if (!recoverOneSlotStep(Math.min(remainingMillis, RECOVERY_DRAIN_BUDGET_MILLIS))) {
return;
}
}
}
+ private void runStartupRecoveryLoop() {
+ while (true) {
+ if (closed || Thread.currentThread().isInterrupted()) {
+ // Unconditional exits: still drop ownership so a later re-arm
+ // on a live pool can spawn a successor (the revive gate
+ // ignores a closing pool anyway).
+ releaseRecoveryDriverOwnership(false);
+ return;
+ }
+ if (recoveryComplete) {
+ // Transactional exit: re-check the latch under `lock` so an
+ // un-latch (recoverRetiredSlotAt's re-arm) cannot slip
+ // between this thread's decision to exit and its ownership
+ // drop -- the lost-wakeup race a Thread.isAlive() probe had.
+ if (releaseRecoveryDriverOwnership(true)) {
+ return;
+ }
+ // An un-latch landed first: this thread stays the driver.
+ continue;
+ }
+ boolean hasImmediateWork;
+ try {
+ hasImmediateWork = runStartupRecoveryStep();
+ } catch (Throwable ignored) {
+ // Match PoolHousekeeper: startup recovery is best-effort and a
+ // delegate Error must not permanently kill its only driver.
+ hasImmediateWork = false;
+ }
+ if (!hasImmediateWork && !closed && !recoveryComplete) {
+ startupRecoveryWaiter.run();
+ }
+ }
+ }
+
+ /**
+ * Drops this thread's private-driver ownership token, transactionally
+ * against the re-arm path. With {@code recheckLatch}, an un-latch that
+ * raced this driver's exit decision is detected under {@code lock} and
+ * ownership is KEPT ({@code false} is returned; the caller keeps
+ * driving). The producer ({@link #recoverRetiredSlotAt}) clears the
+ * latch and reads {@code recoveryDriverRunning} under the same lock, so
+ * exactly one of the two parties always owns the follow-up: either this
+ * driver observes the cleared latch, or the producer observes the
+ * dropped token and spawns a successor. No interleaving leaves an
+ * un-latched scan ownerless.
+ */
+ private boolean releaseRecoveryDriverOwnership(boolean recheckLatch) {
+ lock.lock();
+ try {
+ if (recheckLatch && !closed && !recoveryComplete) {
+ return false;
+ }
+ recoveryDriverRunning = false;
+ return true;
+ } finally {
+ lock.unlock();
+ }
+ }
+
/**
* Recovers at most ONE stranded managed slot and reports whether more remain.
- * Driven by the {@link PoolHousekeeper} one slot per step, back-to-back, on
- * the reap-loop thread: each step performs at most one drain bounded by
- * {@link #RECOVERY_DRAIN_BUDGET_MILLIS} -- kept below the housekeeper
- * stop/join budget -- on a delegate whose initial connect is forced OFF
+ * The private direct driver or {@link PoolHousekeeper} drives steps
+ * back-to-back: each step performs at most one drain bounded by
+ * {@link #RECOVERY_DRAIN_BUDGET_MILLIS} -- kept below the driver stop/join
+ * budget -- on a delegate whose initial connect is forced OFF
* ({@link #defaultRecoverySender}) so the build makes at most one connect
- * attempt. The housekeeper re-checks {@code stop} between steps while
- * {@link QuestDBImpl#close()} raises the {@code closed} shutdown signal (via
- * {@link #markClosing()}) BEFORE stopping it, so a {@code close()} landing
- * mid-recovery normally only has to wait out a single bounded drain. The one
+ * attempt. Each owner raises the {@code closed} shutdown signal before
+ * joining its driver, so a {@code close()} landing mid-recovery normally
+ * only has to wait out a single bounded drain. The one
* exception is an in-flight connect to a black-holed host, which blocks on
* the OS connect timeout -- see the residual-window note on
* {@link #recoverOneSlotStep}.
* No-op (returns {@code false}) when SF is off, the pool is shutting down, or
* recovery has already finished.
*
- * @return {@code true} if recovery has more work (call again), {@code false}
- * when recovery is complete or the pool is shutting down
+ * @return {@code true} if recovery has more work immediately; {@code false}
+ * when recovery is complete, the pool is shutting down, or a transient
+ * failure deferred the current candidate until a later tick
*/
boolean runStartupRecoveryStep() {
if (!storeAndForward || closed || recoveryComplete) {
@@ -432,23 +730,38 @@ boolean runStartupRecoveryStep() {
*
* The in-range pass reserves each slot index under {@code lock} for the
* duration of its recovery AND counts it in the borrow() capacity check (via
- * {@code recoveringSlots}), so a concurrent borrow on the deferred path can
- * neither target the dir being recovered nor over-allocate past
+ * {@code recoveringSlots}), so a concurrent borrow after pool publication
+ * can neither target the dir being recovered nor over-allocate past
* {@code maxSize}. Prewarmed/borrowed slots (already live, holding their
* flock) are skipped, as are empty slots (a cheap directory probe); only a
- * slot that actually holds stranded data spends the step's single drain. The
+ * slot that actually holds stranded data spends the step's single drain. A
+ * RETIRED index (a wedged close() kept its flock; see {@link #reclaimSlot}
+ * and the retire branch below) is likewise never drained in place, but
+ * while its dir still holds data it counts as a deferral, so the scan keeps
+ * cycling instead of latching {@code recoveryComplete} past stranded data
+ * that only a restart or a lucky borrow of that index would ever deliver. The
* out-of-range pass needs no reservation: those indices have no
* {@code slotInUse} entry and are never allocated by borrow().
*
* Best-effort throughout: a build/close Error or a slow drain is logged and
- * never propagates, since the data stays durable on disk for a later attempt;
- * the first build failure or drain timeout latches {@code recoveryComplete}
- * (the failure will very likely repeat for every remaining slot).
+ * never propagates, since the data stays durable on disk for a later attempt.
+ * A build failure or drain timeout stops the current drive but leaves its
+ * candidate pending so a later driver attempt can retry after a transient
+ * condition clears; it does not poison recovery for the life of the pool.
+ * A slot-SPECIFIC condition -- flock contention with another live owner, or
+ * {@link #RECOVERY_MAX_SLOT_FAILURE_STREAK} consecutive failures on the
+ * same candidate -- instead parks that slot: the cursor advances past it so
+ * one wedged slot cannot starve the remaining slots' recovery, and once
+ * both passes finish with parked slots outstanding the scan rewinds for
+ * another cycle on a later tick (nothing is abandoned while the pool
+ * lives). Per-slot failure WARNs are deduplicated across retries via
+ * {@link #warnSlotOnce}.
*
- * Boundedness / residual window. Recovery is driven on the
- * PoolHousekeeper thread, and {@code close()} relies on a step finishing
- * within {@code PoolHousekeeper.STOP_TIMEOUT_MILLIS}. A step is build +
- * drain + close. The drain is capped by {@link #RECOVERY_DRAIN_BUDGET_MILLIS}
+ * Boundedness / residual window. A continuing recovery step runs on
+ * either the direct pool's private driver or the PoolHousekeeper thread, and
+ * {@code close()} relies on a step finishing within
+ * {@code PoolHousekeeper.STOP_TIMEOUT_MILLIS}. A step is build + drain +
+ * close. The drain is capped by {@link #RECOVERY_DRAIN_BUDGET_MILLIS}
* and the build forces {@code initial_connect_mode=OFF} (see
* {@link #defaultRecoverySender}), so it makes at most ONE connect attempt
* instead of a SYNC reconnect-budget retry loop. That removes the
@@ -456,7 +769,7 @@ boolean runStartupRecoveryStep() {
* One residual window remains and is NOT closed here: a single in-flight
* connect to a black-holed/firewalled host blocks on the OS connect timeout
* (the transport exposes no application-level connect timeout to clamp it).
- * If {@code close()} lands during that one connect the housekeeper join can
+ * If {@code close()} lands during that one connect, its driver join can
* still time out and the detached build releases the slot flock shortly
* after {@code close()} returns. No data is lost (the slot stays durable on
* disk); the exposure is a brief "sf slot already in use" window on an
@@ -470,7 +783,7 @@ private boolean recoverOneSlotStep(long stepBudgetMillis) {
recoveryComplete = true;
return false;
}
- final boolean[] flockHeld = new boolean[1];
+ final SenderSlot[] retained = new SenderSlot[1];
// Pass 1: in-range managed slots [0, maxSize). Skip live and empty slots
// cheaply; spend the step on the first slot that actually holds data.
@@ -479,26 +792,49 @@ private boolean recoverOneSlotStep(long stepBudgetMillis) {
return false;
}
int i = recoveryInRangeNext;
- // Reserve this index unless prewarm (or a concurrent borrow, on the
- // deferred path) already holds it live. Count the reservation in
+ String slotPath = sfDir + "/" + slotBaseId + "-" + i;
+ // Reserve this index unless prewarm (or a concurrent borrow after
+ // publication) already holds it live. Count the reservation in
// recoveringSlots so the borrow() cap check cannot over-allocate
// while this slot is held for recovery.
boolean reserved;
+ boolean retired = false;
lock.lock();
try {
reserved = slotInUse[i];
if (!reserved) {
slotInUse[i] = true;
recoveringSlots++;
+ } else {
+ retired = isRetiredSlotIndex(i);
}
} finally {
lock.unlock();
}
if (reserved) {
+ // A reserved index is normally LIVE (prewarm or a concurrent
+ // borrow owns it) and is none of recovery's business. A RETIRED
+ // index is different: its flock is held by this pool's own
+ // wedged former delegate, and any unacked data in its dir is
+ // exactly as stranded as a CONTENDED slot's. Without counting
+ // it as a deferral the scan would latch recoveryComplete and
+ // abandon that data until a restart or a lucky borrow of this
+ // exact index -- which steady low load may never produce. Count
+ // it -- the same rule as a CONTENDED park -- so the end-of-scan
+ // rewind keeps the cycle alive; once the deferred engine
+ // cleanup releases the flock, reprobeRetiredSlots()/the release
+ // callback frees the index and a later cycle reserves and
+ // drains it right here. The isCandidateOrphan probe (a few
+ // syscalls, outside the lock) keeps an already-clean retired
+ // dir from cycling the scan forever, and racing a concurrent
+ // recover/borrow can only over-count -- costing one extra
+ // cheap rewound walk, never a missed candidate.
+ if (retired && OrphanScanner.isCandidateOrphan(slotPath)) {
+ recoveryDeferredThisCycle++;
+ }
recoveryInRangeNext++;
continue;
}
- String slotPath = sfDir + "/" + slotBaseId + "-" + i;
if (!OrphanScanner.isCandidateOrphan(slotPath)) {
// No stranded data: release the reservation and keep scanning;
// an empty slot must not cost a whole step.
@@ -513,44 +849,70 @@ private boolean recoverOneSlotStep(long stepBudgetMillis) {
recoveryInRangeNext++;
continue;
}
- // A real candidate -> spend the step on it. Advance the cursor first
- // so a resume never reprocesses this index.
- recoveryInRangeNext++;
- boolean stopScan = drainCandidateSlotForRecovery(i, slotPath, stepBudgetMillis, flockHeld);
+ // A real candidate -> spend the step on it. Advance the cursor only
+ // after success so a transient build/drain failure remains retryable.
+ RecoveryDrainOutcome outcome = drainCandidateSlotForRecovery(i, slotPath, stepBudgetMillis, retained);
lock.lock();
try {
// Release the recovery reservation accounting; from here either
// leakedSlots (retire) or the freed index carries the cap math.
recoveringSlots--;
- if (flockHeld[0]) {
- // close() bailed early with the I/O thread still running and
- // the flock still held. Retire the slot permanently (mirror
+ if (retained[0] != null) {
+ // close() retained the flock because an I/O or manager
+ // worker did not stop. Retire the slot (mirror
// discardBroken/reapIdle): keep slotInUse[i] set and count it
// in leakedSlots so the borrow() cap math accounts for the
// lost capacity and no later borrow ever reuses the
- // still-locked dir.
+ // still-locked dir. Keep the recoverer in retiredSlots so
+ // reprobeRetiredSlots() restores the capacity once the
+ // deferred engine cleanup releases the flock — without it
+ // the retirement would be permanent even after the release
+ // (fatal at maxSize=1: every later borrow would time out).
leakedSlots++;
- LOG.warn("startup SF recovery: slot {} retired permanently: delegate close() returned with "
- + "the flock still held (I/O thread refused to stop); pool capacity reduced by 1, "
- + "now {} of {} usable [leakedSlots={}]",
+ addRetiredSlot(retained[0]);
+ LOG.warn("startup SF recovery: slot {} retired: delegate close() returned with "
+ + "the flock still held (I/O or manager worker did not stop); pool capacity reduced by 1, "
+ + "now {} of {} usable [leakedSlots={}]; the slot is re-probed and recovered "
+ + "if the worker releases the flock later",
i, maxSize - leakedSlots, maxSize, leakedSlots);
} else {
slotInUse[i] = false;
- // On the deferred path a borrow may be waiting on capacity
- // this recovery held; the freed index can now admit a
- // creation.
+ // On a post-publication drive, a borrow may be waiting on
+ // capacity this recovery held; the freed index can now admit
+ // a creation.
slotReleased.signal();
}
} finally {
lock.unlock();
}
- if (stopScan) {
- // A build failure or drain timeout that will very likely repeat
- // for every remaining slot -- abort the scan; the data stays
- // durable on disk for a later attempt. Do not start pass 2.
- recoveryComplete = true;
+ if (outcome == RecoveryDrainOutcome.CONTENDED) {
+ // Slot-specific by construction: another LIVE owner holds this
+ // slot's flock and may do so indefinitely. Park the slot --
+ // advance past it; the end-of-scan cycle rewind re-probes it
+ // later -- so it cannot starve the remaining slots, and keep
+ // scanning within this step (no drain was spent on it).
+ recoveryDeferredThisCycle++;
+ recoveryInRangeNext++;
+ continue;
+ }
+ if (outcome == RecoveryDrainOutcome.FAILED) {
+ if (parkAfterFailure(i)) {
+ // The same candidate failed RECOVERY_MAX_SLOT_FAILURE_STREAK
+ // consecutive times: stop presuming the failure is
+ // server-wide. Park the slot and move on; the end-of-scan
+ // cycle rewind retries it later, so nothing is abandoned.
+ recoveryDeferredThisCycle++;
+ recoveryInRangeNext++;
+ return true;
+ }
+ // Presumed transient/server-wide: stop this drive without
+ // advancing the cursor. The same live pool retries this
+ // candidate after the transient condition is removed instead
+ // of paying a likely-identical failure per remaining slot.
return false;
}
+ clearFailStreak(i);
+ recoveryInRangeNext++;
return true;
}
@@ -571,75 +933,192 @@ private boolean recoverOneSlotStep(long stepBudgetMillis) {
if (closed) {
return false;
}
- int idx = recoveryOutOfRange.getQuick(recoveryOutOfRangeNext++);
+ int idx = recoveryOutOfRange.getQuick(recoveryOutOfRangeNext);
String slotPath = sfDir + "/" + slotBaseId + "-" + idx;
if (!OrphanScanner.isCandidateOrphan(slotPath)) {
+ recoveryOutOfRangeNext++;
continue;
}
- boolean stopScan = drainCandidateSlotForRecovery(idx, slotPath, stepBudgetMillis, flockHeld);
- if (flockHeld[0]) {
+ RecoveryDrainOutcome outcome = drainCandidateSlotForRecovery(idx, slotPath, stepBudgetMillis, retained);
+ if (retained[0] != null) {
// Out of the pool's [0, maxSize) capacity range: there is no
// slotInUse entry to retire and no future borrow targets this
- // dir, so a still-held flock only leaks this recoverer's I/O
- // thread (a best-effort teardown loss, logged). Crucially we do
+ // dir, so a still-held flock only leaks this recoverer's
+ // worker-reachable resources (a best-effort teardown loss,
+ // logged). Crucially we do
// NOT touch leakedSlots -- that would wrongly shrink the
- // in-range pool capacity.
+ // in-range pool capacity -- and we do NOT add to retiredSlots:
+ // there is no capacity to recover, and freeSlotIndex(idx)
+ // would index past the slotInUse array (sized maxSize).
LOG.warn("startup SF recovery: out-of-range slot {} closed with the flock still held "
- + "(I/O thread refused to stop); its data is durable on disk for a later attempt",
+ + "(I/O or manager worker did not stop); its data is durable on disk for a later attempt",
slotPath);
}
- if (stopScan) {
- recoveryComplete = true;
+ if (outcome == RecoveryDrainOutcome.CONTENDED) {
+ // Same parking rule as the in-range pass: a live flock holder
+ // (e.g. a sibling drainer that adopted this out-of-range slot)
+ // is slot-specific and possibly long-lived; the end-of-scan
+ // cycle rewind re-probes it after the holder lets go. No
+ // capacity bookkeeping is involved out of range.
+ recoveryDeferredThisCycle++;
+ recoveryOutOfRangeNext++;
+ continue;
+ }
+ if (outcome == RecoveryDrainOutcome.FAILED) {
+ if (parkAfterFailure(idx)) {
+ recoveryDeferredThisCycle++;
+ recoveryOutOfRangeNext++;
+ return true;
+ }
+ // Keep the out-of-range cursor on this candidate so a later
+ // tick retries it after the presumed-transient condition
+ // clears; no capacity bookkeeping is involved.
return false;
}
+ clearFailStreak(idx);
+ recoveryOutOfRangeNext++;
return true;
}
- recoveryComplete = true;
+ if (recoveryDeferredThisCycle > 0) {
+ // At least one slot was parked this cycle (contended, persistently
+ // failing, or retired with data still on disk). Its data stays durable on disk, so
+ // instead of latching recoveryComplete -- which would abandon it
+ // until a restart or a lucky borrow of that index -- rewind the
+ // scan and let the driver's retry cadence run another cycle.
+ // Recovered, live and empty slots are re-skipped cheaply; parked
+ // slots get a fresh (bounded) attempt. Per-slot WARNs stay
+ // deduplicated across cycles via recoveryWarnedSlots.
+ recoveryDeferredThisCycle = 0;
+ recoveryInRangeNext = 0;
+ recoveryOutOfRangeNext = 0;
+ return false;
+ }
+ // Latch decision under `lock`: a mid-cycle flock release can free a
+ // still-candidate dir AFTER this cycle's cursor passed its index (it
+ // was live/retired at walk time -- no deferral) and BEFORE the latch
+ // (so the post-latch re-arm in recoverRetiredSlotAt does not apply
+ // either). recoverRetiredSlotAt raises recoveryRearmRequested under
+ // the same lock, so consuming it here mutually excludes the race: no
+ // release can slip between this check and the latch write.
+ lock.lock();
+ try {
+ if (recoveryRearmRequested) {
+ recoveryRearmRequested = false;
+ recoveryDeferredThisCycle = 0;
+ recoveryInRangeNext = 0;
+ recoveryOutOfRangeNext = 0;
+ return false;
+ }
+ recoveryComplete = true;
+ } finally {
+ lock.unlock();
+ }
return false;
}
+ private void closePrewarmedDelegates(int built) {
+ for (int i = 0; i < built; i++) {
+ try {
+ all.get(i).delegate().close();
+ } catch (Throwable ignored) {
+ // Best-effort cleanup: one delegate close failure must not
+ // strand later prewarmed delegates or replace the original
+ // construction throwable.
+ }
+ }
+ }
+
+ private static Thread createStartupRecoveryThread(Runnable runnable) {
+ return new Thread(runnable, "questdb-sender-pool-recovery");
+ }
+
/**
* Drains one candidate orphan slot dir within {@code remainingMillis},
* best-effort and never throwing. Builds a recoverer on {@code slotIndex}
* (whose {@link #defaultSender} derives the dir {@code -slotIndex}),
* drains its unacked data, and closes the delegate. Shared by both recovery
* passes -- the in-range pass and the out-of-range pass -- which differ only
- * in their slot bookkeeping, handled by the caller via {@code flockHeld}.
+ * in their slot bookkeeping, handled by the caller via {@code retainedOut}.
*
- * @param flockHeld single-element out-param set to {@code true} iff a
- * recoverer was built and its {@code close()} returned with
- * the flock still held (the I/O thread refused to stop)
- * @return {@code true} if a build/drain failure occurred that will very
- * likely repeat for every remaining slot, so the caller should stop scanning
+ * @param retainedOut single-element out-param set to the recoverer iff one
+ * was built and its {@code close()} returned with the
+ * flock still held because a worker did not stop; the
+ * in-range caller keeps it in {@link #retiredSlots} so a
+ * late flock release can be re-probed. {@code null} when
+ * the flock was released (or no recoverer was built).
+ * @return {@link RecoveryDrainOutcome#CONTENDED} when the slot flock is
+ * held by another live owner (slot-specific: park it and keep scanning);
+ * {@link RecoveryDrainOutcome#FAILED} on any other build/drain failure
+ * (presumed server-wide, likely to repeat for every remaining slot);
+ * {@link RecoveryDrainOutcome#DRAINED} when the candidate was drained or
+ * was no longer a candidate
*/
- private boolean drainCandidateSlotForRecovery(int slotIndex, String slotPath,
- long remainingMillis, boolean[] flockHeld) {
- flockHeld[0] = false;
+ private RecoveryDrainOutcome drainCandidateSlotForRecovery(int slotIndex, String slotPath,
+ long remainingMillis, SenderSlot[] retainedOut) {
+ retainedOut[0] = null;
// Hoisted so the flock check after the try can consult it:
// createRecoverer() takes the slot flock on -slotIndex, and
- // delegate().close() can early-return with the I/O thread still running
- // (flock still held).
+ // delegate().close() can retain it when an I/O or manager worker does
+ // not stop.
SenderSlot recoverer = null;
- boolean stopScan = false;
+ RecoveryDrainOutcome outcome = RecoveryDrainOutcome.DRAINED;
try {
if (!OrphanScanner.isCandidateOrphan(slotPath)) {
- return false;
+ return RecoveryDrainOutcome.DRAINED;
+ }
+ // O(1) contention pre-probe (flock only). A slot parked as
+ // CONTENDED is re-probed on every retry cycle for as long as its
+ // live owner runs -- potentially that owner's whole lifetime --
+ // and the full recovery build below (config re-parse, builder
+ // graph, parent-dir fsync barriers in periodic durability, owned
+ // SegmentManager allocation) would exist only to reach
+ // SlotLock.acquire and throw. Ask the flock directly first so
+ // the steady-state cost of a held slot is a few syscalls per
+ // cycle, not a build. Races are benign in both directions: a
+ // free probe can still lose the acquire inside the build (the
+ // contention catch below parks exactly as before), and a held
+ // probe that goes stale is re-observed on the next cycle. An
+ // indeterminate probe (null) falls through to the build, which
+ // owns real error classification.
+ String probedHolder = SlotLock.probeHolder(slotPath);
+ if (probedHolder != null) {
+ if (warnSlotOnce(slotIndex)) {
+ LOG.warn("startup SF recovery: slot {} is held by another live owner (holder={}); "
+ + "parking it and continuing with the remaining slots",
+ slotPath, probedHolder);
+ }
+ return RecoveryDrainOutcome.CONTENDED;
}
try {
// Recovery delegate: forced OFF-mode initial connect (see
// createRecoverer / defaultRecoverySender), so this build does
// at most ONE connect attempt -- it never inherits the SYNC
- // reconnect-budget retry loop that would block this
- // (PoolHousekeeper) thread for minutes (M1).
+ // reconnect-budget retry loop that would block this recovery
+ // driver for minutes (M1).
recoverer = createRecoverer(slotIndex);
+ } catch (SlotLockContentionException contention) {
+ // The slot flock is held by another LIVE owner. That condition
+ // is scoped to THIS slot dir by construction -- it says nothing
+ // about the server or the remaining slots -- and it can persist
+ // for the owner's whole lifetime, so retrying it in place would
+ // starve every remaining slot (C2). Report it distinctly so the
+ // scan parks this slot and continues.
+ if (warnSlotOnce(slotIndex)) {
+ LOG.warn("startup SF recovery: slot {} is held by another live owner ({}); "
+ + "parking it and continuing with the remaining slots",
+ slotPath, contention.toString());
+ }
+ return RecoveryDrainOutcome.CONTENDED;
} catch (Throwable buildErr) {
// A build/connect failure (e.g. server unreachable) will very
// likely repeat for every remaining slot, so stop here rather
// than pay a connect timeout per slot.
- LOG.warn("startup SF recovery: could not open slot {} ({}); "
- + "skipping remaining slots", slotPath, buildErr.toString());
- return true;
+ if (warnSlotOnce(slotIndex)) {
+ LOG.warn("startup SF recovery: could not open slot {} ({}); "
+ + "deferring this and remaining slots", slotPath, buildErr.toString());
+ }
+ return RecoveryDrainOutcome.FAILED;
}
try {
// Cap the drain at the remaining shared budget and short-circuit
@@ -647,14 +1126,19 @@ private boolean drainCandidateSlotForRecovery(int slotIndex, String slotPath,
// will very likely do the same for every remaining slot -- the
// same reasoning as the build-failure case above.
if (!recoverer.delegate().drain(remainingMillis)) {
- LOG.warn("startup SF recovery: drain did not ack slot {} "
- + "within {}ms; skipping remaining slots",
- slotPath, remainingMillis);
- stopScan = true;
+ if (warnSlotOnce(slotIndex)) {
+ LOG.warn("startup SF recovery: drain did not ack slot {} "
+ + "within {}ms; deferring this and remaining slots",
+ slotPath, remainingMillis);
+ }
+ outcome = RecoveryDrainOutcome.FAILED;
}
} catch (Throwable drainErr) {
- LOG.warn("startup SF recovery: drain failed for slot {} ({})",
- slotPath, drainErr.toString());
+ if (warnSlotOnce(slotIndex)) {
+ LOG.warn("startup SF recovery: drain failed for slot {} ({}); deferring it",
+ slotPath, drainErr.toString());
+ }
+ outcome = RecoveryDrainOutcome.FAILED;
} finally {
try {
recoverer.delegate().close();
@@ -664,13 +1148,69 @@ private boolean drainCandidateSlotForRecovery(int slotIndex, String slotPath,
}
}
} catch (Throwable scanErr) {
- LOG.warn("startup SF recovery: scan failed for slot {} ({})",
- slotPath, scanErr.toString());
+ if (warnSlotOnce(slotIndex)) {
+ LOG.warn("startup SF recovery: scan failed for slot {} ({}); deferring it",
+ slotPath, scanErr.toString());
+ }
+ outcome = RecoveryDrainOutcome.FAILED;
+ }
+ if (recoverer != null && !flockReleased(recoverer)) {
+ retainedOut[0] = recoverer;
}
- if (recoverer != null) {
- flockHeld[0] = !flockReleased(recoverer);
+ if (outcome == RecoveryDrainOutcome.DRAINED && recoveryWarnedSlots.contains(slotIndex)) {
+ recoveryWarnedSlots.remove(slotIndex);
+ LOG.info("startup SF recovery: slot {} recovered after earlier deferrals", slotPath);
+ }
+ return outcome;
+ }
+
+ /**
+ * Clears the consecutive-failure streak after a successful drain so the
+ * next candidate starts from a clean count. Driver-thread state (see the
+ * scan cursor comment); no lock needed.
+ */
+ private void clearFailStreak(int slotIndex) {
+ if (recoveryFailStreakSlot == slotIndex) {
+ recoveryFailStreakSlot = -1;
+ recoveryFailStreak = 0;
+ }
+ }
+
+ /**
+ * Records one FAILED drain attempt on a candidate slot and reports whether
+ * the scan should park it: {@code true} once the SAME slot has failed
+ * {@link #RECOVERY_MAX_SLOT_FAILURE_STREAK} consecutive times, meaning the
+ * failure is evidently slot-specific rather than server-wide and must not
+ * pin the cursor any longer (C2). Driver-thread state; no lock needed.
+ */
+ private boolean parkAfterFailure(int slotIndex) {
+ if (recoveryFailStreakSlot != slotIndex) {
+ recoveryFailStreakSlot = slotIndex;
+ recoveryFailStreak = 0;
+ }
+ if (++recoveryFailStreak < RECOVERY_MAX_SLOT_FAILURE_STREAK) {
+ return false;
}
- return stopScan;
+ recoveryFailStreakSlot = -1;
+ recoveryFailStreak = 0;
+ return true;
+ }
+
+ /**
+ * Dedups per-slot recovery WARNs: a contended or persistently failing slot
+ * is retried indefinitely (its data stays durable on disk), and warning on
+ * every retry produced an unbounded one-WARN-per-second stream for the life
+ * of the process (C2). Returns {@code true} only the first time a slot
+ * fails since its last successful drain, so each failure episode logs
+ * exactly once (a follow-up failure with a DIFFERENT cause is deliberately
+ * folded into the same episode). Driver-thread state; no lock needed.
+ */
+ private boolean warnSlotOnce(int slotIndex) {
+ if (recoveryWarnedSlots.contains(slotIndex)) {
+ return false;
+ }
+ recoveryWarnedSlots.add(slotIndex);
+ return true;
}
public PooledSender borrow() {
@@ -715,33 +1255,26 @@ public PooledSender borrow() {
// idempotent, so undoing the reservation for any
// throwable is safe.
lock.lock();
- inFlightCreations--;
- freeSlotIndex(slotIndex);
- slotReleased.signal();
- lock.unlock();
+ try {
+ inFlightCreations--;
+ freeSlotIndex(slotIndex);
+ creationFinished.signalAll();
+ slotReleased.signal();
+ } finally {
+ lock.unlock();
+ }
throw e;
}
lock.lock();
- inFlightCreations--;
if (closed) {
- // Pool was closed mid-creation -- destroy the new connection
- // rather than leaking it. Other waiters have been signaled
- // by close() already. The delegate is closed OUTSIDE the
- // lock (mirroring retireLease): its close() can block for
- // seconds (bounded ack drain, drainer-pool wind-down) or
- // longer (unbounded I/O-thread latch await behind an
- // OS-level connect), which held here would stall close(),
- // giveBack/retireLease and reapIdle behind the pool lock.
- // Accounting first, under the lock: for an SF slot the
- // index reservation moves from inFlightCreations to
- // closingSlots until the close below releases the flock,
- // and pendingLeaseTeardowns keeps the out-of-lock close
- // visible to close()'s outstanding-teardown wait.
+ // Pool was closed mid-creation. Keep inFlightCreations
+ // reserved while the delegate is closed outside the
+ // lock: close() waits on that counter, so the creation
+ // remains internally owned until its teardown completes.
boolean reserved = created.slotIndex() >= 0;
if (reserved) {
closingSlots++;
}
- pendingLeaseTeardowns++;
lock.unlock();
try {
created.delegate().close();
@@ -749,31 +1282,56 @@ public PooledSender borrow() {
// Best-effort: an Error (e.g. -ea AssertionError)
// from teardown must not mask the closed-pool signal.
} finally {
- // Re-lock to reclaim the SF slot index and signal a
- // close() waiting on this teardown. MUST run even if
- // the delegate close threw, otherwise the slot stays
- // reserved forever and close() waits out its full
- // budget on a teardown that already happened.
lock.lock();
- pendingLeaseTeardowns--;
- if (reserved) {
- reclaimSlot(created, " after closed-mid-creation teardown");
+ try {
+ if (reserved) {
+ reclaimSlot(created, " after closed-mid-creation teardown");
+ }
+ inFlightCreations--;
+ creationFinished.signalAll();
+ slotReleased.signalAll();
+ } finally {
+ lock.unlock();
}
- slotReleased.signalAll();
- lock.unlock();
}
throw new LineSenderException("QuestDB handle is closed");
}
all.add(created);
created.bumpGeneration();
+ inFlightCreations--;
+ creationFinished.signalAll();
return new PooledSender(created, created.generation());
}
+ // Capacity-starved: re-probe retired slots BEFORE the terminal
+ // timeout check — a deferred engine cleanup may have released a
+ // flock since the retire, and the freed index can admit a
+ // creation right now. The delegate normally signals this pool
+ // after deferred release, while this probe also covers delegates
+ // that do not expose that notification and release/listener races.
+ // Ordering matters twice over: a
+ // zero-timeout (try-once) borrow must get its one probe before
+ // throwing, and a borrower whose awaitNanos budget just expired
+ // must get a final probe on its wake-up pass instead of timing
+ // out on capacity that has already come back.
+ if (reprobeRetiredSlots()) {
+ continue;
+ }
if (remainingNanos <= 0) {
throw new LineSenderException(
"timed out waiting for a Sender from the pool after " + acquireTimeoutMillis + "ms");
}
try {
+ Runnable beforeWaitHook = beforeBorrowWaitHook;
+ if (beforeWaitHook != null) {
+ beforeWaitHook.run();
+ }
remainingNanos = slotReleased.awaitNanos(remainingNanos);
+ if (remainingNanos <= 0) {
+ Runnable hook = borrowWaitExpiredHook;
+ if (hook != null) {
+ hook.run();
+ }
+ }
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new LineSenderException("interrupted while waiting for a Sender from the pool");
@@ -786,12 +1344,127 @@ public PooledSender borrow() {
}
}
+ @TestOnly
+ public Sender buildRecoverySenderForTesting(int slotIndex) {
+ return defaultRecoverySender(slotIndex);
+ }
+
+ @TestOnly
+ public Sender buildSenderForTesting(int slotIndex) {
+ return defaultSender(slotIndex);
+ }
+
+ @TestOnly
+ public void discardBrokenForTesting(PooledSender sender) {
+ discardBroken(sender);
+ }
+
+ @TestOnly
+ public int getInFlightCreationsForTesting() {
+ lock.lock();
+ try {
+ return inFlightCreations;
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ @TestOnly
+ public int getRetiredSlotCountForTesting() {
+ lock.lock();
+ try {
+ return retiredSlots.size();
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ @TestOnly
+ public long getRetiredSlotProbeCountForTesting() {
+ lock.lock();
+ try {
+ return retiredSlotProbeCount;
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ @TestOnly
+ public Thread getStartupRecoveryThreadForTesting() {
+ return startupRecoveryThread;
+ }
+
+ @TestOnly
+ public boolean hasCreationWaiterForTesting() {
+ lock.lock();
+ try {
+ return lock.hasWaiters(creationFinished);
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ @TestOnly
+ public boolean isCloseStartedForTesting() {
+ lock.lock();
+ try {
+ return closeStarted;
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ @TestOnly
+ public boolean isClosedForTesting() {
+ return closed;
+ }
+
+ @TestOnly
+ public boolean isRecoveryCompleteForTesting() {
+ return recoveryComplete;
+ }
+
+ @TestOnly
+ public boolean isSlotInUseForTesting(int slotIndex) {
+ lock.lock();
+ try {
+ return slotInUse[slotIndex];
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ @TestOnly
+ public void markClosingForTesting() {
+ markClosing();
+ }
+
+ @TestOnly
+ public void runStartupRecoveryToCompletionForTesting() {
+ runStartupRecoveryToCompletion();
+ }
+
+ @TestOnly
+ public boolean runStartupRecoveryStepForTesting() {
+ return runStartupRecoveryStep();
+ }
+
+ @TestOnly
+ public void setBeforeBorrowWaitHook(Runnable hook) {
+ this.beforeBorrowWaitHook = hook;
+ }
+
+ @TestOnly
+ public void setBorrowWaitExpiredHook(Runnable hook) {
+ this.borrowWaitExpiredHook = hook;
+ }
+
/**
* Raises the shutdown signal early -- without tearing down live delegates --
- * so an in-flight startup-recovery step driven on the {@link PoolHousekeeper}
- * thread stops promptly between slots. {@link QuestDBImpl#close()} calls this
- * BEFORE stopping the housekeeper, so the housekeeper join cannot time out
- * waiting on a fresh slot's recovery drain. Idle-delegate teardown and the
+ * so an in-flight startup-recovery step stops promptly between slots. Direct
+ * {@link #close()} calls this before joining its private driver;
+ * {@link QuestDBImpl#close()} calls it before stopping the housekeeper.
+ * Idle-delegate teardown and the
* outstanding-lease wait still happen in {@link #close()} (guarded by
* {@code closeStarted}, so this early signal never short-circuits them);
* borrowed delegates returned after this signal are torn down on the
@@ -800,6 +1473,9 @@ public PooledSender borrow() {
*/
void markClosing() {
closed = true;
+ synchronized (startupRecoverySignal) {
+ startupRecoverySignal.notifyAll();
+ }
}
/**
@@ -809,6 +1485,9 @@ void markClosing() {
* free their native memory under its feet -- a use-after-free / SEGV, not
* an exception (C1). Instead:
*
+ *
waits boundedly for internally owned creations; a late creator keeps
+ * ownership and performs its closed-mid-creation teardown asynchronously;
+ * then
*
waits boundedly (up to {@code acquireTimeoutMillis}, hard-capped at
* {@link #MAX_CLOSE_LEASE_WAIT_MILLIS}) for outstanding leases to come home -- {@link #giveBack} and {@link #discardBroken}
* observe {@code closed} and tear each delegate down on the returning
@@ -823,6 +1502,11 @@ void markClosing() {
*/
@Override
public void close() {
+ // Direct pools own their continuing recovery driver. Stop it before
+ // snapshotting/closing delegates; deferred pools have a null thread and
+ // QuestDBImpl stops their external PoolHousekeeper before calling here.
+ markClosing();
+ stopStartupRecoveryDriver();
SenderSlot[] idleSnapshot;
lock.lock();
try {
@@ -835,6 +1519,35 @@ public void close() {
closed = true;
// Wake parked borrowers so they observe the shutdown and throw.
slotReleased.signalAll();
+ // A creator can block in DNS, TCP, TLS, or WebSocket setup. Wait
+ // boundedly rather than turning an unset connect_timeout into an
+ // unbounded QuestDB.close(). Timing out does not abandon ownership:
+ // the reservation and any SF slot stay assigned to the creator,
+ // which observes closed and tears down a late result before releasing
+ // them. Preserve the caller's interrupt status while still applying
+ // the same finite wait budget.
+ final long creationWaitMillis = Math.max(0,
+ Math.min(acquireTimeoutMillis, MAX_CLOSE_CREATION_WAIT_MILLIS));
+ final long creationWaitNanos = TimeUnit.MILLISECONDS.toNanos(creationWaitMillis);
+ final long creationWaitDeadlineNanos = System.nanoTime() + creationWaitNanos;
+ long creationRemainingNanos = creationWaitNanos;
+ boolean creationWaitInterrupted = false;
+ while (inFlightCreations > 0 && creationRemainingNanos > 0) {
+ try {
+ creationFinished.awaitNanos(creationRemainingNanos);
+ } catch (InterruptedException e) {
+ creationWaitInterrupted = true;
+ }
+ creationRemainingNanos = creationWaitDeadlineNanos - System.nanoTime();
+ }
+ if (creationWaitInterrupted) {
+ Thread.currentThread().interrupt();
+ }
+ if (inFlightCreations > 0) {
+ LOG.warn("SenderPool.close(): {} sender creation(s) still in flight after {}ms; "
+ + "each creator retains cleanup ownership and releases its SF slot when construction returns",
+ inFlightCreations, creationWaitMillis);
+ }
// Bounded graceful wait for outstanding leases. A slot is borrowed
// iff it is in `all` but not in `available`; retireLease's
// delegate-close section (running outside the lock on a returning
@@ -934,6 +1647,83 @@ public void giveBack(PooledSender ps) {
retireLease(ps, " during pool shutdown");
}
+ @TestOnly
+ public void setRetiredSlotProbeCountForTesting(long count) {
+ lock.lock();
+ try {
+ retiredSlotProbeCount = count;
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ @TestOnly
+ public void setStartupRecoveryJoinHooksForTesting(Runnable beforeJoinHook, Runnable afterJoinHook) {
+ this.beforeStartupRecoveryJoinHook = beforeJoinHook;
+ this.afterStartupRecoveryJoinHook = afterJoinHook;
+ }
+
+ private void stopFailedStartupRecoveryDriver(Thread recoveryThread) {
+ if (recoveryThread == null || recoveryThread == Thread.currentThread()) {
+ return;
+ }
+ if (beforeFailedStartupRecoveryJoinHook != null) {
+ beforeFailedStartupRecoveryJoinHook.run();
+ }
+ boolean interrupted = false;
+ while (recoveryThread.isAlive()) {
+ try {
+ recoveryThread.join();
+ } catch (InterruptedException e) {
+ interrupted = true;
+ }
+ }
+ if (interrupted) {
+ Thread.currentThread().interrupt();
+ }
+ }
+
+ private void stopStartupRecoveryDriver() {
+ if (startupRecoveryThread != null && startupRecoveryThread != Thread.currentThread()) {
+ if (beforeStartupRecoveryJoinHook != null) {
+ beforeStartupRecoveryJoinHook.run();
+ }
+ try {
+ startupRecoveryThread.join(PoolHousekeeper.STOP_TIMEOUT_MILLIS);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ if (afterStartupRecoveryJoinHook != null) {
+ afterStartupRecoveryJoinHook.run();
+ }
+ }
+ // A revived driver (late-release re-arm on a direct pool; see
+ // reviveDirectRecoveryDriverIfNeeded) is joined with the same bound:
+ // `closed` is already raised, so its loop exits on the next waiter
+ // wake-up, within RECOVERY_RETRY_INTERVAL_MILLIS < STOP_TIMEOUT.
+ Thread revived = revivedStartupRecoveryThread;
+ if (revived != null && revived != Thread.currentThread()) {
+ try {
+ revived.join(PoolHousekeeper.STOP_TIMEOUT_MILLIS);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ }
+
+ private void waitForStartupRecoveryRetry() {
+ synchronized (startupRecoverySignal) {
+ if (closed || recoveryComplete) {
+ return;
+ }
+ try {
+ startupRecoverySignal.wait(RECOVERY_RETRY_INTERVAL_MILLIS);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ }
+
/**
* Retires one lease on the calling (borrower's) thread: validates the
* lease generation under the lock, removes the slot from {@code all},
@@ -1002,7 +1792,9 @@ private void retireLease(PooledSender ps, String context) {
pendingLeaseTeardowns--;
if (reserved) {
// Free the index only when the flock was released; a slot
- // left locked is retired permanently.
+ // left locked is retired into retiredSlots, recoverable
+ // by reprobeRetiredSlots() if the deferred cleanup drops
+ // the flock later.
reclaimSlot(s, context);
}
slotReleased.signalAll();
@@ -1028,6 +1820,11 @@ public void reapIdle() {
if (closed) {
return;
}
+ // Housekeeper tick doubles as the retired-slot recovery driver:
+ // a slot retired because its worker did not stop is re-probed
+ // here and returns to the free set once the deferred cleanup
+ // finally released its flock.
+ reprobeRetiredSlots();
Iterator it = available.iterator();
while (it.hasNext() && all.size() > minSize) {
SenderSlot s = it.next();
@@ -1067,7 +1864,7 @@ public void reapIdle() {
}
// Return reserved SF slot indices to the free set -- but only for
// slots whose delegate confirmed the flock was released. A slot
- // left locked (I/O thread refused to stop) is retired permanently.
+ // left locked because a worker did not stop is retired permanently.
if (storeAndForward) {
lock.lock();
try {
@@ -1106,12 +1903,15 @@ public int totalSize() {
}
/**
- * Snapshot of the number of SF slots permanently retired because a
- * delegate {@code close()} returned with the slot flock still held (the
- * I/O thread refused to stop). Each leaked slot permanently lowers the
- * pool's effective capacity ({@code maxSize - leakedSlotCount()}). A
- * non-zero, growing value explains a pool that has started timing out
- * every {@code borrow()}. For metrics and tests.
+ * Snapshot of the number of SF slots currently retired because a
+ * delegate {@code close()} returned with the slot flock still held after
+ * an I/O or manager worker did not stop. Each leaked slot lowers the
+ * pool's effective capacity ({@code maxSize - leakedSlotCount()}) while
+ * retired. Retired slots are re-probed (housekeeper tick and
+ * capacity-starved borrows) and recovered once the delegate's deferred
+ * cleanup releases the flock, so the count can go back down; a non-zero,
+ * persistent value means a worker is still wedged and explains a pool
+ * that has started timing out {@code borrow()}. For metrics and tests.
*/
public int leakedSlotCount() {
lock.lock();
@@ -1122,8 +1922,39 @@ public int leakedSlotCount() {
}
}
+ private SenderSlot createSlot(IntFunction factory, int slotIndex) {
+ Sender delegate = factory.apply(slotIndex);
+ try {
+ if (postFactoryHook != null) {
+ postFactoryHook.run();
+ }
+ SenderSlot slot = new SenderSlot(delegate, this, slotIndex);
+ if (delegate instanceof QwpWebSocketSender) {
+ ((QwpWebSocketSender) delegate).setSlotLockReleaseListener(
+ () -> recoverReleasedSlot(slot));
+ }
+ return slot;
+ } catch (Throwable failure) {
+ if (delegate instanceof QwpWebSocketSender) {
+ try {
+ ((QwpWebSocketSender) delegate).setSlotLockReleaseListener(null);
+ } catch (Throwable deregistrationFailure) {
+ addSuppressed(failure, deregistrationFailure);
+ }
+ }
+ if (delegate != null) {
+ try {
+ delegate.close();
+ } catch (Throwable closeFailure) {
+ addSuppressed(failure, closeFailure);
+ }
+ }
+ throw failure;
+ }
+ }
+
private SenderSlot createUnlocked(int slotIndex) {
- return new SenderSlot(senderFactory.apply(slotIndex), this, slotIndex);
+ return createSlot(senderFactory, slotIndex);
}
/**
@@ -1134,7 +1965,18 @@ private SenderSlot createUnlocked(int slotIndex) {
* {@link #drainCandidateSlotForRecovery}.
*/
private SenderSlot createRecoverer(int slotIndex) {
- return new SenderSlot(recoverySenderFactory.apply(slotIndex), this, slotIndex);
+ return createSlot(recoverySenderFactory, slotIndex);
+ }
+
+ private static void addSuppressed(Throwable failure, Throwable cleanupFailure) {
+ if (failure != cleanupFailure) {
+ try {
+ failure.addSuppressed(cleanupFailure);
+ } catch (Throwable ignored) {
+ // Preserve the original construction failure even if recording
+ // the secondary cleanup failure cannot allocate.
+ }
+ }
}
private Sender defaultSender(int slotIndex) {
@@ -1144,9 +1986,10 @@ private Sender defaultSender(int slotIndex) {
/**
* Same managed-slot delegate as {@link #defaultSender}, but with the
* initial connect forced to {@link Sender.InitialConnectMode#OFF}. Used
- * only for startup recovery, which runs on the PoolHousekeeper thread: OFF
- * makes the build do at most ONE connect attempt instead of retrying for
- * the whole reconnect budget (SYNC, auto-enabled by any reconnect_* knob),
+ * only for startup recovery, which runs on a private direct driver or the
+ * PoolHousekeeper thread: OFF makes the build do at most ONE connect attempt
+ * instead of retrying for the whole reconnect budget (SYNC, auto-enabled by
+ * any reconnect_* knob),
* keeping a recovery step bounded below
* {@code PoolHousekeeper.STOP_TIMEOUT_MILLIS}. See M1 / the residual-window
* note on {@link #recoverOneSlotStep}.
@@ -1215,8 +2058,8 @@ private Sender buildManagedSlotSender(int slotIndex, boolean forRecovery) {
// its OWN slot (the one recoverOneSlotStep is processing); it must
// never start a BackgroundDrainerPool for sibling/foreign orphans.
// If it did, the delegate's close() -- called from
- // drainCandidateSlotForRecovery() on the PoolHousekeeper thread,
- // BEFORE its cursorEngine.close() releases the slot flock -- would
+ // drainCandidateSlotForRecovery() on the recovery driver, BEFORE
+ // its cursorEngine.close() releases the slot flock -- would
// block in BackgroundDrainerPool.close() for up to
// GRACEFUL_DRAIN_MILLIS + STOP_GRACE_MILLIS (3s) against a
// reachable-but-not-acking server. That overruns a recovery step's
@@ -1271,7 +2114,7 @@ private void freeSlotIndex(int idx) {
* non-QWP delegate never holds an SF flock, so it is always treated as
* released. A {@link QwpWebSocketSender} reports it via
* {@link QwpWebSocketSender#isSlotLockReleased()} -- false means close()
- * bailed early with the I/O thread still running and the flock still held.
+ * retained the flock because an I/O or manager worker did not stop.
*/
private static boolean flockReleased(SenderSlot s) {
Sender d = s.delegate();
@@ -1281,11 +2124,17 @@ private static boolean flockReleased(SenderSlot s) {
/**
* Reclaims one SF slot after its delegate's {@code close()} has been
* attempted. When the flock was released the index returns to the free
- * set; when {@code close()} returned with the flock still held (the I/O
- * thread refused to stop) the slot is retired permanently --
- * {@code leakedSlots++} and {@code slotInUse[idx]} stays set -- so the cap
- * math accounts for the lost capacity and no later borrow ever reuses the
- * still-locked dir. Either way {@code closingSlots} is decremented.
+ * set; when {@code close()} returned with the flock still held because an
+ * I/O or manager worker did not stop, the slot is retired --
+ * {@code leakedSlots++}, {@code slotInUse[idx]} stays set, and the sender
+ * joins {@code retiredSlots} -- so the cap math accounts for the lost
+ * capacity and no borrow reuses the still-locked dir unless
+ * {@link #reprobeRetiredSlots} later observes the deferred cleanup's
+ * release and recovers the index. Either way {@code closingSlots} is
+ * decremented. A later release that frees a dir still holding unacked
+ * data also re-arms the startup recovery scan (see
+ * {@link #recoverRetiredSlotAt}) so the data is drained in-process
+ * instead of waiting for a restart or a lucky borrow of that index.
*
* Caller must hold {@code lock} and is responsible for signalling waiters
* (only the free path admits a new creation). Shared by
@@ -1303,9 +2152,202 @@ private boolean reclaimSlot(SenderSlot s, String context) {
return true;
}
leakedSlots++;
- LOG.warn("SF slot {} retired permanently{}: delegate close() returned with the flock still held " +
- "(I/O thread refused to stop); pool capacity reduced by 1, now {} of {} usable [leakedSlots={}]",
+ addRetiredSlot(s);
+ LOG.warn("SF slot {} retired{}: delegate close() returned with the flock still held " +
+ "(I/O or manager worker did not stop); pool capacity reduced by 1, now {} of {} usable " +
+ "[leakedSlots={}]; the slot is re-probed and recovered if the worker releases the flock later",
s.slotIndex(), context, maxSize - leakedSlots, maxSize, leakedSlots);
return false;
}
+
+ private boolean recoverReleasedSlot(SenderSlot s) {
+ lock.lock();
+ try {
+ int retiredIndex = s.retiredIndex();
+ if (retiredIndex < 0
+ || retiredIndex >= retiredSlots.size()
+ || retiredSlots.get(retiredIndex) != s) {
+ // The callback raced retirement, or is stale/duplicate. The
+ // retirement path probes before insertion, and periodic scans
+ // remain as a fallback, so no capacity can be lost here.
+ return false;
+ }
+ retiredSlotProbeCount++;
+ if (!flockReleased(s)) {
+ return false;
+ }
+ recoverRetiredSlotAt(retiredIndex);
+ slotReleased.signalAll();
+ return true;
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ /**
+ * Re-probes every retired slot (see {@link #reclaimSlot}) and returns to
+ * the free set any whose delegate now reports the flock released — the
+ * deferred engine cleanup (manager-worker or I/O-thread exit path) has
+ * run since the retire. Restores {@code leakedSlots} capacity and signals
+ * waiters so a parked borrow can admit a creation immediately.
+ *
+ * Caller must hold {@code lock}. The probe is cheap on the delegate side
+ * ({@code isSlotLockReleased()} reads volatiles, and only re-arms the
+ * shared flock-release retry in the rare orphaned-retry state), so
+ * holding the pool lock across it cannot stall behind delegate teardown.
+ *
+ * @return {@code true} if at least one slot's capacity was recovered
+ */
+ private boolean reprobeRetiredSlots() {
+ boolean recovered = false;
+ for (int i = retiredSlots.size() - 1; i >= 0; i--) {
+ SenderSlot s = retiredSlots.get(i);
+ retiredSlotProbeCount++;
+ if (flockReleased(s)) {
+ recoverRetiredSlotAt(i);
+ recovered = true;
+ }
+ }
+ if (recovered) {
+ slotReleased.signalAll();
+ }
+ return recovered;
+ }
+
+ private void addRetiredSlot(SenderSlot s) {
+ s.retiredIndex(retiredSlots.size());
+ retiredSlots.add(s);
+ }
+
+ /**
+ * Whether {@code idx} is currently held by a RETIRED slot (see
+ * {@link #reclaimSlot} and the in-range recovery retire branch) rather
+ * than a live one. Lets the recovery scan tell "reserved because
+ * borrowed/prewarmed" apart from "reserved because a wedged close() left
+ * the flock held" for its deferral accounting: only the latter's dir can
+ * hold stranded data no borrow is coming for. Caller must hold
+ * {@code lock}; retiredSlots is bounded by maxSize, so the walk is cheap.
+ */
+ private boolean isRetiredSlotIndex(int idx) {
+ for (int i = 0, n = retiredSlots.size(); i < n; i++) {
+ if (retiredSlots.get(i).slotIndex() == idx) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private void recoverRetiredSlotAt(int retiredIndex) {
+ SenderSlot s = retiredSlots.get(retiredIndex);
+ int last = retiredSlots.size() - 1;
+ if (retiredIndex < last) {
+ SenderSlot moved = retiredSlots.get(last);
+ retiredSlots.set(retiredIndex, moved);
+ moved.retiredIndex(retiredIndex);
+ }
+ retiredSlots.remove(last);
+ s.retiredIndex(-1);
+ leakedSlots--;
+ freeSlotIndex(s.slotIndex());
+ LOG.info("SF slot {} recovered: deferred cleanup released the flock after retirement; " +
+ "pool capacity restored, now {} of {} usable [leakedSlots={}]",
+ s.slotIndex(), maxSize - leakedSlots, maxSize, leakedSlots);
+ // The freed dir may still hold unacked durable data: a runtime retire
+ // (discardBroken/reapIdle) can land AFTER the startup scan latched
+ // recoveryComplete, and close() never drains an unowned dir, so
+ // without re-arming the scan that data would wait for a restart or a
+ // lucky borrow of exactly this index. When the latch is already set,
+ // rewind the cursors FIRST, then clear the volatile latch (the write
+ // order publishes the rewound cursors to the next driver's unlocked
+ // gate read), then revive the private driver when this is a direct
+ // pool whose original driver already exited; recoveryComplete==true
+ // here also guarantees no driver is mid-step, so touching the
+ // driver-owned cursors is safe. When the scan is still ALIVE, only
+ // raise recoveryRearmRequested: the cursor may already have passed
+ // this index while it was live/retired, and the flag makes the
+ // end-of-cycle latch decision rewind instead -- the cursors stay
+ // strictly driver-owned. The isCandidateOrphan probe is a bounded
+ // directory scan; holding the pool lock across it mirrors
+ // reprobeRetiredSlots()' delegate probes. The pass-2 work list is
+ // kept and only its cursor rewound, exactly like the end-of-scan
+ // cycle rewind.
+ if (sfDir != null
+ && OrphanScanner.isCandidateOrphan(sfDir + "/" + slotBaseId + "-" + s.slotIndex())) {
+ if (recoveryComplete) {
+ recoveryInRangeNext = 0;
+ recoveryOutOfRangeNext = 0;
+ recoveryDeferredThisCycle = 0;
+ recoveryRearmRequested = false;
+ recoveryComplete = false;
+ LOG.info("SF slot {} released with stranded data still on disk; re-arming the "
+ + "startup recovery scan to drain it", s.slotIndex());
+ reviveDirectRecoveryDriverIfNeeded();
+ } else {
+ recoveryRearmRequested = true;
+ LOG.info("SF slot {} released with stranded data while the recovery scan is "
+ + "mid-cycle; flagging a rewind so this cycle cannot latch past it",
+ s.slotIndex());
+ }
+ }
+ }
+
+ /**
+ * Re-arms a driver for an un-latched scan (see {@link #recoverRetiredSlotAt}).
+ * Deferred pools need nothing: {@code PoolHousekeeper} calls
+ * {@link #runStartupRecoveryStep()} on every tick and its gate re-opens
+ * with the cleared latch. A direct pool's private driver exits once the
+ * scan completes, so when no thread currently owns the driver duty
+ * ({@code recoveryDriverRunning} false), spawn a successor running the
+ * same loop -- it drains the backlog, re-latches, and exits; close()
+ * joins it via {@link #stopStartupRecoveryDriver}. Ownership is a
+ * lock-guarded token, not a {@code Thread.isAlive()} probe: the loop
+ * drops it transactionally ({@link #releaseRecoveryDriverOwnership}
+ * re-checks the latch under this same lock first), so a driver that is
+ * mid-exit either observes this caller's un-latch and keeps driving, or
+ * has already dropped the token and a successor is spawned here -- no
+ * interleaving leaves the un-latched scan ownerless. Caller must hold
+ * {@code lock}; {@code Thread.start()} is cheap enough to run under it.
+ * Best-effort: a spawn failure is logged and never propagates into the
+ * release callback that triggered the re-arm (the data stays durable
+ * for a restart).
+ */
+ private void reviveDirectRecoveryDriverIfNeeded() {
+ if (deferStartupRecovery || closed) {
+ return;
+ }
+ if (recoveryDriverRunning) {
+ // A live driver's exit re-checks the latch under this lock, so
+ // it is guaranteed to observe the un-latch that brought us here.
+ return;
+ }
+ try {
+ ThreadFactory factory = recoveryThreadFactory != null
+ ? recoveryThreadFactory
+ : SenderPool::createStartupRecoveryThread;
+ Thread t = factory.newThread(this::runStartupRecoveryLoop);
+ t.setDaemon(true);
+ recoveryDriverRunning = true;
+ revivedStartupRecoveryThread = t;
+ t.start();
+ } catch (Throwable e) {
+ recoveryDriverRunning = false;
+ LOG.warn("could not revive the startup recovery driver ({}); the released slot's "
+ + "data stays durable on disk for a later attempt or restart", e.toString());
+ }
+ }
+
+ // Outcome of one drainCandidateSlotForRecovery attempt, letting the scan
+ // tell a slot-specific contention (park the slot and keep scanning) apart
+ // from a failure presumed server-wide (retry the same candidate, bounded
+ // by RECOVERY_MAX_SLOT_FAILURE_STREAK).
+ private enum RecoveryDrainOutcome {
+ // createRecoverer() lost the slot flock to another live owner -- a
+ // condition scoped to that slot dir by construction that can outlive
+ // any retry interval.
+ CONTENDED,
+ // The candidate was drained, or was no longer a candidate.
+ DRAINED,
+ // Any other build/drain failure, presumed transient and server-wide.
+ FAILED
+ }
}
diff --git a/core/src/main/java/io/questdb/client/impl/SenderSlot.java b/core/src/main/java/io/questdb/client/impl/SenderSlot.java
index 19c93671..5d9ed6ff 100644
--- a/core/src/main/java/io/questdb/client/impl/SenderSlot.java
+++ b/core/src/main/java/io/questdb/client/impl/SenderSlot.java
@@ -48,6 +48,11 @@ final class SenderSlot {
private final Sender delegate;
private final SenderPool pool;
private final int slotIndex;
+ // Index in SenderPool.retiredSlots, or -1 while this slot is not retired.
+ // Guarded by the pool lock. The pool maintains this index across swap
+ // removals so the delegate's release callback can recover this exact slot
+ // without scanning every other retired slot.
+ private int retiredIndex = -1;
// Monotonic lease id. Mutated only under the SenderPool lock (bumped in
// borrow() when the slot is handed out and in giveBack()/discardBroken()
// when it is returned). A PooledSender wrapper captures it live for its
@@ -112,6 +117,14 @@ SenderPool pool() {
return pool;
}
+ int retiredIndex() {
+ return retiredIndex;
+ }
+
+ void retiredIndex(int retiredIndex) {
+ this.retiredIndex = retiredIndex;
+ }
+
int slotIndex() {
return slotIndex;
}
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 c1b1eec7..f4dc94df 100644
--- a/core/src/main/java/io/questdb/client/network/JavaTlsClientSocket.java
+++ b/core/src/main/java/io/questdb/client/network/JavaTlsClientSocket.java
@@ -31,6 +31,7 @@
import io.questdb.client.std.MemoryTag;
import io.questdb.client.std.Unsafe;
import io.questdb.client.std.Vect;
+import org.jetbrains.annotations.TestOnly;
import org.slf4j.Logger;
import javax.net.ssl.SSLContext;
@@ -149,6 +150,14 @@ public void close() {
}
}
+ @Override
+ public void closeTraffic() {
+ // A concurrent TLS send may still use the SSLEngine and direct buffers.
+ // Shut down only the delegate traffic path here; close() releases the
+ // retained fd and frees TLS state after the owning worker has stopped.
+ delegate.closeTraffic();
+ }
+
@Override
public int getFd() {
return delegate.getFd();
@@ -166,6 +175,26 @@ public void of(int fd) {
state = STATE_PLAINTEXT;
}
+ @TestOnly
+ public void setPlaintextStateForTesting() {
+ sslEngine = null;
+ state = STATE_PLAINTEXT;
+ }
+
+ @TestOnly
+ public void setTlsStateForTesting(SSLEngine engine) {
+ if (state != STATE_PLAINTEXT) {
+ throw new IllegalStateException("socket must be in plaintext state");
+ }
+ sslEngine = engine;
+ state = STATE_TLS;
+ }
+
+ @TestOnly
+ public TlsStateForTesting snapshotTlsStateForTesting() {
+ return new TlsStateForTesting(this);
+ }
+
@Override
public int recv(long bufferPtr, int bufferLen) {
assert sslEngine != null;
@@ -643,4 +672,35 @@ private int writeToSocket(int bytesToSend) {
LIMIT_FIELD_OFFSET = Unsafe.getUnsafe().objectFieldOffset(limitField);
CAPACITY_FIELD_OFFSET = Unsafe.getUnsafe().objectFieldOffset(capacityField);
}
+
+ @TestOnly
+ public static final class TlsStateForTesting {
+ private final ByteBuffer callerOutputBuffer;
+ private final SSLEngine sslEngine;
+ private final int state;
+ private final ByteBuffer unwrapInputBuffer;
+ private final ByteBuffer unwrapOutputBuffer;
+ private final ByteBuffer wrapInputBuffer;
+ private final ByteBuffer wrapOutputBuffer;
+
+ private TlsStateForTesting(JavaTlsClientSocket socket) {
+ callerOutputBuffer = socket.callerOutputBuffer;
+ sslEngine = socket.sslEngine;
+ state = socket.state;
+ unwrapInputBuffer = socket.unwrapInputBuffer;
+ unwrapOutputBuffer = socket.unwrapOutputBuffer;
+ wrapInputBuffer = socket.wrapInputBuffer;
+ wrapOutputBuffer = socket.wrapOutputBuffer;
+ }
+
+ public boolean hasSameStateForTesting(TlsStateForTesting that) {
+ return callerOutputBuffer == that.callerOutputBuffer
+ && sslEngine == that.sslEngine
+ && state == that.state
+ && unwrapInputBuffer == that.unwrapInputBuffer
+ && unwrapOutputBuffer == that.unwrapOutputBuffer
+ && wrapInputBuffer == that.wrapInputBuffer
+ && wrapOutputBuffer == that.wrapOutputBuffer;
+ }
+ }
}
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 f649d330..b9e669d0 100644
--- a/core/src/main/java/io/questdb/client/network/Net.java
+++ b/core/src/main/java/io/questdb/client/network/Net.java
@@ -151,6 +151,8 @@ public static void init() {
public static native int setTcpNoDelay(int fd, boolean noDelay);
+ public static native int shutdown(int fd);
+
public static long sockaddr(int ipv4address, int port) {
SOCK_ADDR_COUNTER.incrementAndGet();
return sockaddr0(ipv4address, port);
diff --git a/core/src/main/java/io/questdb/client/network/NetworkFacade.java b/core/src/main/java/io/questdb/client/network/NetworkFacade.java
index d23824a5..4c66f0df 100644
--- a/core/src/main/java/io/questdb/client/network/NetworkFacade.java
+++ b/core/src/main/java/io/questdb/client/network/NetworkFacade.java
@@ -78,6 +78,15 @@ public interface NetworkFacade {
int setTcpNoDelay(int fd, boolean noDelay);
+ /**
+ * Shuts down traffic on a descriptor owned by this facade without releasing
+ * the descriptor. Custom facades must override this method rather than let
+ * native code operate on a synthetic or remapped descriptor.
+ */
+ default int shutdown(int fd) {
+ throw new UnsupportedOperationException("traffic shutdown is not supported by this network facade");
+ }
+
long sockaddr(int address, int port);
int socketTcp(boolean blocking);
diff --git a/core/src/main/java/io/questdb/client/network/NetworkFacadeImpl.java b/core/src/main/java/io/questdb/client/network/NetworkFacadeImpl.java
index 64ea0dc7..1d1d7e20 100644
--- a/core/src/main/java/io/questdb/client/network/NetworkFacadeImpl.java
+++ b/core/src/main/java/io/questdb/client/network/NetworkFacadeImpl.java
@@ -132,6 +132,11 @@ public int setTcpNoDelay(int fd, boolean noDelay) {
return Net.setTcpNoDelay(fd, noDelay);
}
+ @Override
+ public int shutdown(int fd) {
+ return Net.shutdown(fd);
+ }
+
@Override
public long sockaddr(int address, int port) {
return Net.sockaddr(address, port);
diff --git a/core/src/main/java/io/questdb/client/network/PlainSocket.java b/core/src/main/java/io/questdb/client/network/PlainSocket.java
index 555affd2..cc175f51 100644
--- a/core/src/main/java/io/questdb/client/network/PlainSocket.java
+++ b/core/src/main/java/io/questdb/client/network/PlainSocket.java
@@ -29,7 +29,7 @@
public class PlainSocket implements Socket {
private final Logger log;
private final NetworkFacade nf;
- private int fd = -1;
+ private volatile int fd = -1;
public PlainSocket(NetworkFacade nf, Logger log) {
this.nf = nf;
@@ -37,13 +37,22 @@ public PlainSocket(NetworkFacade nf, Logger log) {
}
@Override
- public void close() {
+ public synchronized void close() {
if (fd != -1) {
nf.close(fd, log);
fd = -1;
}
}
+ @Override
+ public synchronized void closeTraffic() {
+ if (fd != -1 && nf.shutdown(fd) != 0) {
+ throw new IllegalStateException(
+ "could not shut down socket traffic [fd=" + fd + ", errno=" + nf.errno() + ']'
+ );
+ }
+ }
+
@Override
public int getFd() {
return fd;
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 0cdce517..6fceb30b 100644
--- a/core/src/main/java/io/questdb/client/network/Socket.java
+++ b/core/src/main/java/io/questdb/client/network/Socket.java
@@ -37,6 +37,17 @@
public interface Socket extends QuietCloseable {
int WRITE_FLAG = 1;
+ /**
+ * Closes only the network traffic path so a concurrent blocking send or
+ * receive returns. Implementations must retain any I/O buffers until the
+ * owning worker has stopped and {@link #close()} performs full cleanup.
+ * The compatibility default fails without touching implementation-owned
+ * resources; custom sockets must override this method to opt in.
+ */
+ default void closeTraffic() {
+ throw new UnsupportedOperationException("traffic shutdown is not supported by this socket");
+ }
+
/**
* @return file descriptor associated with the socket.
*/
diff --git a/core/src/main/java/io/questdb/client/std/DefaultFilesFacade.java b/core/src/main/java/io/questdb/client/std/DefaultFilesFacade.java
index dca93e84..194d0972 100644
--- a/core/src/main/java/io/questdb/client/std/DefaultFilesFacade.java
+++ b/core/src/main/java/io/questdb/client/std/DefaultFilesFacade.java
@@ -86,6 +86,11 @@ public int fsync(int fd) {
return Files.fsync(fd);
}
+ @Override
+ public int fsyncDir(String dir) {
+ return Files.fsyncDir(dir);
+ }
+
@Override
public long length(int fd) {
return Files.length(fd);
@@ -106,6 +111,21 @@ public int mkdir(String path, int mode) {
return Files.mkdir(path, mode);
}
+ @Override
+ public long mmap(int fd, long len, long offset, int flags, int memoryTag) {
+ return Files.mmap(fd, len, offset, flags, memoryTag);
+ }
+
+ @Override
+ public int msync(long addr, long len, boolean async) {
+ return Files.msync(addr, len, async);
+ }
+
+ @Override
+ public void munmap(long address, long len, int memoryTag) {
+ Files.munmap(address, len, memoryTag);
+ }
+
@Override
public int openCleanRW(String path) {
return Files.openCleanRW(path);
@@ -126,6 +146,16 @@ public int openRW(long pathPtr) {
return Files.openRW(pathPtr);
}
+ @Override
+ public int openRWExclusive(String path) {
+ return Files.openRWExclusive(path);
+ }
+
+ @Override
+ public int openRWExclusive(long pathPtr) {
+ return Files.openRWExclusive(pathPtr);
+ }
+
@Override
public long length(long pathPtr) {
return Files.length(pathPtr);
diff --git a/core/src/main/java/io/questdb/client/std/Files.java b/core/src/main/java/io/questdb/client/std/Files.java
index 02756c37..522dddc3 100644
--- a/core/src/main/java/io/questdb/client/std/Files.java
+++ b/core/src/main/java/io/questdb/client/std/Files.java
@@ -24,6 +24,7 @@
package io.questdb.client.std;
+import java.io.File;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
@@ -154,6 +155,24 @@ public static int openRW(long pathPtr) {
return openRW0(pathPtr);
}
+ /**
+ * Atomically creates {@code path} for read-write access. Fails with -1
+ * when the path already exists; existing bytes are never truncated.
+ */
+ public static int openRWExclusive(String path) {
+ long ptr = pathPtr(path);
+ try {
+ return openRWExclusive0(ptr);
+ } finally {
+ freePathPtr(ptr);
+ }
+ }
+
+ /** Native-path variant of {@link #openRWExclusive(String)}. */
+ public static int openRWExclusive(long pathPtr) {
+ return openRWExclusive0(pathPtr);
+ }
+
/**
* Opens {@code path} for append-only writes, creating it (mode 0644) if
* absent. Every {@link #append(int, long, long)} writes at end-of-file
@@ -408,6 +427,29 @@ public static String utf8ToString(long nameZ) {
*/
public static native int fsync(int fd);
+ /**
+ * Forces directory-entry updates under {@code dir} to durable storage.
+ * Returns 0 on success and non-zero on failure. Callers use this after
+ * unlinking files whose absence must survive a host crash.
+ */
+ public static int fsyncDir(String dir) {
+ long ptr = pathPtr(dir);
+ try {
+ return fsyncDir0(ptr);
+ } finally {
+ freePathPtr(ptr);
+ }
+ }
+
+ /**
+ * Forces the directory entry naming {@code path} to durable storage by
+ * syncing its absolute parent directory.
+ */
+ public static int fsyncParentDir(String path) {
+ File parent = new File(path).getAbsoluteFile().getParentFile();
+ return parent == null ? 0 : fsyncDir(parent.getPath());
+ }
+
/**
* Truncates the file to exactly {@code size} bytes via {@code ftruncate}.
* Returns {@code true} on success. Does NOT reserve disk space — the
@@ -531,6 +573,53 @@ public static void munmap(long address, long len, int memoryTag) {
*/
public static native int msync(long addr, long len, boolean async);
+ // Guards the optional mlock/munlock natives: a packaged native library
+ // built before these symbols existed throws UnsatisfiedLinkError on first
+ // use. Flip to unavailable and report refusal (-1) from then on, so
+ // callers degrade to their unpinned tier instead of crashing.
+ private static volatile boolean mlockLinked = true;
+
+ /**
+ * Best-effort page pin over {@code [addr, addr+len)} of an mmap'd region.
+ * Whole pages containing any part of the range are locked; {@code addr}
+ * should be page-aligned for portability. Returns 0 on success, -1 when
+ * the platform refuses (RLIMIT_MEMLOCK, missing privilege) or the loaded
+ * native library predates the symbol. Callers must treat refusal as a
+ * soft downgrade, never an error.
+ */
+ public static int mlock(long addr, long len) {
+ if (!mlockLinked) {
+ return -1;
+ }
+ try {
+ return mlock0(addr, len);
+ } catch (UnsatisfiedLinkError err) {
+ mlockLinked = false;
+ return -1;
+ }
+ }
+
+ /**
+ * Releases a pin taken by {@link #mlock(long, long)}. Best-effort: a
+ * refusal is ignorable ({@code munmap} implicitly drops any remaining
+ * locks on the range).
+ */
+ public static int munlock(long addr, long len) {
+ if (!mlockLinked) {
+ return -1;
+ }
+ try {
+ return munlock0(addr, len);
+ } catch (UnsatisfiedLinkError err) {
+ mlockLinked = false;
+ return -1;
+ }
+ }
+
+ private static native int mlock0(long addr, long len);
+
+ private static native int munlock0(long addr, long len);
+
/**
* Returns a native pointer to the current entry's null-terminated name
* (UTF-8). Pointer is valid only until the next {@link #findNext(long)}
@@ -555,10 +644,14 @@ public static void munmap(long address, long len, int memoryTag) {
static native int close0(int fd);
+ static native int fsyncDir0(long lpszName);
+
static native int openRO0(long lpszName);
static native int openRW0(long lpszName);
+ static native int openRWExclusive0(long lpszName);
+
static native int openAppend0(long lpszName);
static native int openCleanRW0(long lpszName);
diff --git a/core/src/main/java/io/questdb/client/std/FilesFacade.java b/core/src/main/java/io/questdb/client/std/FilesFacade.java
index 1b408cf4..921684a0 100644
--- a/core/src/main/java/io/questdb/client/std/FilesFacade.java
+++ b/core/src/main/java/io/questdb/client/std/FilesFacade.java
@@ -85,21 +85,22 @@ public interface FilesFacade {
int fsync(int fd);
+ default int fsyncDir(String dir) {
+ return Files.fsyncDir(dir);
+ }
+
+ /**
+ * Returns the current byte length of the file referenced by open descriptor
+ * {@code fd}, or a negative value when the descriptor cannot be statted.
+ */
long length(int fd);
/**
- * Stat length of the file at {@code path}, in bytes. Default delegates to
- * {@link Files#length(String)}.
- *
- *
Test injection point: {@code MmapSegment.openExisting} maps the file to
- * this length and scans straight out of the mapping, so a wrapping facade
- * that returns a value larger than the real file makes the mapping
- * extend past end-of-file. A read of a page beyond real EOF raises SIGBUS on
- * every filesystem (which HotSpot translates to a catchable
- * {@code InternalError} at an {@code Unsafe} intrinsic site) — the same fault
- * a genuinely unbacked/sparse page raises on ZFS, but reproduced
- * deterministically on ext4/xfs too. That is what lets recovery's mmap-fault
- * guard be regression-tested on any CI runner rather than only on ZFS.
+ * Stat length of the file at {@code path}, in bytes.
+ * {@link DefaultFilesFacade} delegates to {@link Files#length(String)}.
+ * Code that already owns an open descriptor should prefer
+ * {@link #length(int)} so path replacement cannot make the stat refer to a
+ * different file.
*/
long length(String path);
@@ -107,6 +108,36 @@ public interface FilesFacade {
int mkdir(String path, int mode);
+ /**
+ * Best-effort page pin over {@code [addr, addr+len)} of an mmap'd region.
+ * Returns 0 when the range is locked, non-zero when the platform refuses
+ * (RLIMIT_MEMLOCK, missing privilege, or a native library without the
+ * symbol). Callers must treat refusal as a soft downgrade, never an error.
+ */
+ default int mlock(long addr, long len) {
+ return Files.mlock(addr, len);
+ }
+
+ default long mmap(int fd, long len, long offset, int flags, int memoryTag) {
+ return Files.mmap(fd, len, offset, flags, memoryTag);
+ }
+
+ default int msync(long addr, long len, boolean async) {
+ return Files.msync(addr, len, async);
+ }
+
+ /**
+ * Releases a pin taken by {@link #mlock(long, long)}. Best-effort;
+ * refusals are ignorable ({@code munmap} implicitly unlocks).
+ */
+ default int munlock(long addr, long len) {
+ return Files.munlock(addr, len);
+ }
+
+ default void munmap(long address, long len, int memoryTag) {
+ Files.munmap(address, len, memoryTag);
+ }
+
int openCleanRW(String path);
/**
@@ -122,12 +153,27 @@ public interface FilesFacade {
/** Variant of {@link #openRW(String)} taking a pre-encoded native UTF-8 path pointer. */
int openRW(long pathPtr);
+ default int openRWExclusive(String path) {
+ return Files.openRWExclusive(path);
+ }
+
+ default int openRWExclusive(long pathPtr) {
+ return Files.openRWExclusive(pathPtr);
+ }
+
/**
* Variant of {@code length(String)} taking a pre-encoded native UTF-8 path
* pointer; same allocation-elision rationale as {@link #openRW(long)}.
*/
long length(long pathPtr);
+ /**
+ * Reads up to {@code len} bytes from the absolute file {@code offset} into
+ * native memory at {@code addr}, without changing the descriptor position.
+ * A positive result may be shorter than requested and callers that require
+ * the whole range must retry. For a non-zero request, {@code 0} means EOF
+ * or no progress and a negative result indicates an operating-system error.
+ */
long read(int fd, long addr, long len, long offset);
boolean remove(String path);
diff --git a/core/src/main/java/io/questdb/client/std/ObjList.java b/core/src/main/java/io/questdb/client/std/ObjList.java
index 9e0e7b29..34d989ae 100644
--- a/core/src/main/java/io/questdb/client/std/ObjList.java
+++ b/core/src/main/java/io/questdb/client/std/ObjList.java
@@ -149,7 +149,7 @@ public void remove(int from, int to) {
System.arraycopy(buffer, to + 1, buffer, from, move);
}
pos = Math.max(0, pos - (to - from + 1));
- Arrays.fill(buffer, pos, buffer.length - 1, null);
+ Arrays.fill(buffer, pos, buffer.length, null);
}
public void remove(int index) {
diff --git a/core/src/test/java/io/questdb/client/test/SourceHygieneTest.java b/core/src/test/java/io/questdb/client/test/SourceHygieneTest.java
new file mode 100644
index 00000000..9351e95d
--- /dev/null
+++ b/core/src/test/java/io/questdb/client/test/SourceHygieneTest.java
@@ -0,0 +1,88 @@
+/*******************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * 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;
+
+import org.junit.Test;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.SimpleFileVisitor;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Tripwire against mutation-tooling edits leaking into shipped sources.
+ *
+ * Commit {@code 88d6b792} accidentally captured a concurrent mutation-testing
+ * edit that neutralized {@code SegmentRing.requestSyncBeforeRotation} to
+ * {@code return false; // MUTANT: gate neutralized} -- injected into a shared
+ * worktree between test validation and {@code git add}. The full suite stayed
+ * green and only human re-reading caught it ({@code 71cfbe2e}). Mutation
+ * tools mark their edits precisely so they can be found; this test makes that
+ * marker a build failure instead of a code-review lottery ticket.
+ */
+public class SourceHygieneTest {
+
+ private static final String MARKER = "MUTANT";
+
+ @Test
+ public void testNoMutationToolMarkersInMainSources() throws IOException {
+ // Surefire runs with the module directory (core/) as cwd.
+ Path root = Paths.get("src", "main", "java");
+ if (!Files.isDirectory(root)) {
+ root = Paths.get("core", "src", "main", "java");
+ }
+ assertTrue("main source root not found from " + Paths.get("").toAbsolutePath()
+ + " -- fix the path resolution rather than skipping the tripwire",
+ Files.isDirectory(root));
+
+ final List offenders = new ArrayList<>();
+ Files.walkFileTree(root, new SimpleFileVisitor() {
+ @Override
+ public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
+ if (file.toString().endsWith(".java")) {
+ List lines = Files.readAllLines(file, StandardCharsets.UTF_8);
+ for (int i = 0; i < lines.size(); i++) {
+ if (lines.get(i).contains(MARKER)) {
+ offenders.add(file + ":" + (i + 1) + " " + lines.get(i).trim());
+ }
+ }
+ }
+ return FileVisitResult.CONTINUE;
+ }
+ });
+ assertTrue("mutation-tool markers must never reach main sources "
+ + "(a neutralized durability gate shipped exactly this way in 88d6b792); "
+ + "offending lines:\n" + String.join("\n", offenders),
+ offenders.isEmpty());
+ }
+}
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/CloseDrainTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/CloseDrainTest.java
index 9f1500f2..e4fb86c5 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/CloseDrainTest.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/CloseDrainTest.java
@@ -35,8 +35,10 @@
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.file.Paths;
+import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
+import java.util.concurrent.atomic.AtomicReference;
/**
* Regression tests for the close() drain semantics.
@@ -49,6 +51,83 @@
*/
public class CloseDrainTest {
+ @Test(timeout = 30_000L)
+ public void testCloseBlocksAcrossAllReplicaWindowUntilPromotion() throws Exception {
+ DelayingAckHandler handler = new DelayingAckHandler(0);
+ try (TestWebSocketServer server = new TestWebSocketServer(handler, false, "PRIMARY")) {
+ server.setRejectWithRole("REPLICA");
+ server.start();
+ Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS));
+
+ String cfg = "ws::addr=localhost:" + server.getPort()
+ + ";initial_connect_retry=async"
+ + ";reconnect_initial_backoff_millis=20"
+ + ";reconnect_max_backoff_millis=100"
+ + ";close_flush_timeout_millis=10000;";
+ QwpWebSocketSender sender = (QwpWebSocketSender) Sender.fromConfig(cfg);
+ CountDownLatch closeDrainWaiting = new CountDownLatch(1);
+ CountDownLatch releaseCloseDrain = new CountDownLatch(1);
+ AtomicReference closeFailure = new AtomicReference<>();
+ AtomicReference hookFailure = new AtomicReference<>();
+ sender.setCloseDrainWaitingHook(() -> {
+ closeDrainWaiting.countDown();
+ try {
+ if (!releaseCloseDrain.await(10, TimeUnit.SECONDS)) {
+ throw new AssertionError("promotion did not release the close-drain witness");
+ }
+ } catch (Throwable t) {
+ hookFailure.set(t);
+ }
+ });
+ sender.table("foo").longColumn("v", 1L).atNow();
+ sender.flush();
+
+ Thread closer = new Thread(() -> {
+ try {
+ sender.close();
+ } catch (Throwable t) {
+ closeFailure.set(t);
+ }
+ }, "all-replica-close-drain");
+ try {
+ closer.start();
+ Assert.assertTrue("server never produced the all-replica role rejection",
+ server.awaitRoleReject(5, TimeUnit.SECONDS));
+ Assert.assertTrue("close never observed its real unacknowledged drain target",
+ closeDrainWaiting.await(5, TimeUnit.SECONDS));
+
+ Assert.assertTrue("pre-promotion witness must include a role rejection",
+ server.roleRejectCount() >= 1);
+ Assert.assertEquals("pre-promotion data must remain unacknowledged",
+ -1L, sender.getAckedFsn());
+ Assert.assertTrue("close must remain pending for the whole all-replica window",
+ closer.isAlive());
+ Assert.assertEquals("promotion must not have delivered or replayed the frame yet",
+ 0L, handler.nextSeq.get());
+
+ // The close-drain barrier has held continuously since it observed
+ // targetFsn > ackedFsn. Promote first, then release the barrier:
+ // completion therefore cannot precede the deterministic recovery event.
+ server.setAdvertisedRole("PRIMARY");
+ server.setRejectWithRole(null);
+ releaseCloseDrain.countDown();
+ closer.join(10_000L);
+
+ Assert.assertFalse("close did not complete after promotion", closer.isAlive());
+ Assert.assertNull("close-drain witness failed", hookFailure.get());
+ Assert.assertNull("close failed after promotion", closeFailure.get());
+ Assert.assertEquals("the unacknowledged frame must be delivered exactly once",
+ 1L, handler.nextSeq.get());
+ } finally {
+ server.setAdvertisedRole("PRIMARY");
+ server.setRejectWithRole(null);
+ releaseCloseDrain.countDown();
+ closer.join(10_000L);
+ sender.close();
+ }
+ }
+ }
+
@Test
public void testCloseBlocksUntilAckArrives() throws Exception {
// Server delays every ACK by 800ms. With the default
@@ -77,6 +156,56 @@ public void testCloseBlocksUntilAckArrives() throws Exception {
}
}
+ @Test
+ public void testCloseStartedHookRunsAfterClosedStateTransition() throws Exception {
+ QwpWebSocketSender sender = QwpWebSocketSender.createForTesting("localhost", 1);
+ CountDownLatch entered = new CountDownLatch(1);
+ CountDownLatch release = new CountDownLatch(1);
+ AtomicReference closerFailure = new AtomicReference<>();
+ AtomicReference hookFailure = new AtomicReference<>();
+ sender.setCloseStartedHook(() -> {
+ try {
+ try {
+ sender.table("must_reject_after_close_started");
+ Assert.fail("close-started hook ran before closed=true was published");
+ } catch (LineSenderException expected) {
+ // Required lifecycle boundary: public operations already reject.
+ }
+ entered.countDown();
+ if (!release.await(10, TimeUnit.SECONDS)) {
+ throw new AssertionError("close-started hook release timed out");
+ }
+ } catch (Throwable t) {
+ hookFailure.set(t);
+ entered.countDown();
+ }
+ });
+
+ Assert.assertEquals("installing the hook must not emit a pre-invocation witness",
+ 1L, entered.getCount());
+ Thread closer = new Thread(() -> {
+ try {
+ sender.close();
+ } catch (Throwable t) {
+ closerFailure.set(t);
+ }
+ }, "close-started-hook-test");
+ try {
+ closer.start();
+ Assert.assertTrue("close() never reached its internal lifecycle witness",
+ entered.await(10, TimeUnit.SECONDS));
+ Assert.assertTrue("close() completed while its internal witness was held",
+ closer.isAlive());
+ } finally {
+ release.countDown();
+ closer.join(10_000L);
+ sender.close();
+ }
+ Assert.assertFalse("close thread did not finish", closer.isAlive());
+ Assert.assertNull("close-started hook failed", hookFailure.get());
+ Assert.assertNull("close() failed", closerFailure.get());
+ }
+
@Test
public void testCloseFastWhenTimeoutIsZero() throws Exception {
// Same delayed-ACK server, but with close_flush_timeout_millis=0
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/InitialConnectAsyncTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/InitialConnectAsyncTest.java
index 173374ec..5f04ca68 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/InitialConnectAsyncTest.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/InitialConnectAsyncTest.java
@@ -27,6 +27,7 @@
import io.questdb.client.Sender;
import io.questdb.client.SenderError;
import io.questdb.client.cutlass.qwp.client.QwpWebSocketSender;
+import io.questdb.client.cutlass.qwp.websocket.WebSocketCloseCode;
import io.questdb.client.test.cutlass.qwp.websocket.TestWebSocketServer;
import org.jetbrains.annotations.NotNull;
import org.junit.Assert;
@@ -43,8 +44,10 @@
import java.nio.charset.StandardCharsets;
import java.util.concurrent.CountDownLatch;
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.LongSupplier;
/**
* Behavior of {@code initial_connect_retry=async}: the producer-thread
@@ -205,6 +208,78 @@ public void testAsyncDeliversBufferedRowsWhenServerArrivesLate() throws Exceptio
}
}
+ @Test
+ public void testAsyncMetricsDistinguishInitialSendFromReconnectReplay() throws Exception {
+ ReplayMetricsHandler handler = new ReplayMetricsHandler();
+ try (TestWebSocketServer server = new TestWebSocketServer(handler)) {
+ String cfg = "ws::addr=localhost:" + server.getPort()
+ + sfDirOpt() + ";initial_connect_retry=async"
+ + ";reconnect_initial_backoff_millis=20"
+ + ";reconnect_max_backoff_millis=200"
+ + ";close_flush_timeout_millis=2000;";
+ Sender sender = Sender.fromConfig(cfg);
+ try {
+ QwpWebSocketSender wss = (QwpWebSocketSender) sender;
+ handler.bind(wss, server);
+
+ // Publish before the server starts accepting. The frame has
+ // never been on any wire, so its eventual first delivery must
+ // increase sent, but not replayed.
+ sender.table("foo").longColumn("v", 1L).atNow();
+ sender.flush();
+ awaitAtLeastOneConnectAttempt(wss);
+ Assert.assertEquals("the server must not handshake before start", 0, server.handshakeCount());
+
+ server.start();
+ Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS));
+ Assert.assertTrue("the initial frame must be ACKed",
+ handler.awaitInitialAck(5, TimeUnit.SECONDS));
+ awaitMetricAtLeast("initial ACK", wss::getTotalAcks, 1L);
+ Assert.assertEquals("exactly one frame must be sent initially",
+ 1L, wss.getTotalFramesSent());
+ long replayedAfterInitialDelivery = wss.getTotalFramesReplayed();
+
+ // The second frame reaches the established connection, but the
+ // fixture closes that connection without ACKing it. It must then
+ // arrive again on a genuinely new connection and count once as
+ // replayed.
+ sender.table("foo").longColumn("v", 2L).atNow();
+ sender.flush();
+ Assert.assertTrue("the fixture must close after the unacked frame",
+ handler.awaitDisconnect(5, TimeUnit.SECONDS));
+ Assert.assertTrue("the reconnect must reach the gated server",
+ server.awaitRoleReject(5, TimeUnit.SECONDS));
+
+ // Publish another frame while reconnect is blocked at a role
+ // reject, then reopen the gate. This frame has never been sent
+ // and must not inflate the replay counter after reconnect.
+ sender.table("foo").longColumn("v", 3L).atNow();
+ sender.flush();
+ server.setRejectWithRole(null);
+
+ Assert.assertTrue("the unacked frame must be replayed",
+ handler.awaitReplay(5, TimeUnit.SECONDS));
+ Assert.assertTrue("the outage-queued frame must be delivered",
+ handler.awaitOutageQueuedDelivery(5, TimeUnit.SECONDS));
+ awaitMetricAtLeast("replayed frame", wss::getTotalFramesReplayed, 1L);
+ awaitMetricAtLeast("post-reconnect ACKs", wss::getTotalAcks, 3L);
+
+ Assert.assertTrue("replay must arrive on a new WebSocket connection",
+ handler.wasReplayOnNewConnection());
+ Assert.assertTrue("the server must observe a reconnect",
+ server.handshakeCount() >= 2);
+ Assert.assertEquals("initial delivery is not a replay",
+ 0L, replayedAfterInitialDelivery);
+ Assert.assertEquals("three first sends plus one genuine resend",
+ 4L, wss.getTotalFramesSent());
+ Assert.assertEquals("only the genuine resend is replayed",
+ 1L, wss.getTotalFramesReplayed());
+ } finally {
+ closeQuietly(sender);
+ }
+ }
+ }
+
@Test
public void testAsyncReturnsImmediatelyWithNoServer() {
// No server. With async mode, fromConfig must return fast — the
@@ -345,6 +420,17 @@ private static void awaitAtLeastOneConnectAttempt(QwpWebSocketSender wss) {
* before the budget expired would satisfy it even if the loop then
* exited.
*/
+ private static void awaitMetricAtLeast(String label, LongSupplier metric, long expected) {
+ long deadlineNanos = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
+ while (metric.getAsLong() < expected) {
+ if (System.nanoTime() > deadlineNanos) {
+ throw new AssertionError(label + " did not reach " + expected
+ + " within 5s; current=" + metric.getAsLong());
+ }
+ io.questdb.client.std.Compat.onSpinWait();
+ }
+ }
+
private static void awaitReconnectAttemptsAdvance(QwpWebSocketSender wss) {
long snapshot = wss.getTotalReconnectAttempts();
long deadlineNanos = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
@@ -476,6 +562,83 @@ public void onError(@NotNull SenderError err) {
}
}
+ private static class ReplayMetricsHandler implements TestWebSocketServer.WebSocketServerHandler {
+ private final CountDownLatch disconnect = new CountDownLatch(1);
+ private final CountDownLatch initialAck = new CountDownLatch(1);
+ private final CountDownLatch outageQueuedDelivery = new CountDownLatch(1);
+ private final AtomicLong received = new AtomicLong();
+ private final CountDownLatch replay = new CountDownLatch(1);
+ private final AtomicBoolean replayOnNewConnection = new AtomicBoolean();
+ private TestWebSocketServer.ClientHandler initialClient;
+ private volatile TestWebSocketServer server;
+ private volatile QwpWebSocketSender sender;
+
+ boolean awaitDisconnect(long timeout, TimeUnit unit) throws InterruptedException {
+ return disconnect.await(timeout, unit);
+ }
+
+ boolean awaitInitialAck(long timeout, TimeUnit unit) throws InterruptedException {
+ return initialAck.await(timeout, unit);
+ }
+
+ boolean awaitOutageQueuedDelivery(long timeout, TimeUnit unit) throws InterruptedException {
+ return outageQueuedDelivery.await(timeout, unit);
+ }
+
+ boolean awaitReplay(long timeout, TimeUnit unit) throws InterruptedException {
+ return replay.await(timeout, unit);
+ }
+
+ void bind(QwpWebSocketSender sender, TestWebSocketServer server) {
+ this.sender = sender;
+ this.server = server;
+ }
+
+ @Override
+ public void onBinaryMessage(TestWebSocketServer.ClientHandler client, byte[] data) {
+ long ordinal = received.incrementAndGet();
+ try {
+ if (ordinal == 1L) {
+ initialClient = client;
+ client.sendBinary(AckHandler.buildAck(0L));
+ initialAck.countDown();
+ } else if (ordinal == 2L) {
+ // Receipt alone can race the sender's post-send metric
+ // increments. Wait until sendBinary returned before closing,
+ // proving this frame completed a send and must be replayed.
+ awaitSentFrames(2L);
+ server.setRejectWithRole("REPLICA");
+ client.sendClose(WebSocketCloseCode.GOING_AWAY, "test reconnect");
+ disconnect.countDown();
+ } else if (ordinal == 3L) {
+ replayOnNewConnection.set(client != initialClient);
+ client.sendBinary(AckHandler.buildAck(0L));
+ replay.countDown();
+ } else if (ordinal == 4L) {
+ client.sendBinary(AckHandler.buildAck(1L));
+ outageQueuedDelivery.countDown();
+ }
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ boolean wasReplayOnNewConnection() {
+ return replayOnNewConnection.get();
+ }
+
+ private void awaitSentFrames(long expected) {
+ long deadlineNanos = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
+ while (sender.getTotalFramesSent() < expected) {
+ if (System.nanoTime() > deadlineNanos) {
+ throw new AssertionError("sent-frame metric did not reach " + expected
+ + " before disconnect; current=" + sender.getTotalFramesSent());
+ }
+ io.questdb.client.std.Compat.onSpinWait();
+ }
+ }
+ }
+
/**
* Raw-socket fixture: every accepted connection responds with HTTP
* 401 Unauthorized and closes. Used to drive the async-init
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpWebSocketSenderCursorEngineAttachmentTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpWebSocketSenderCursorEngineAttachmentTest.java
new file mode 100644
index 00000000..9184fd3c
--- /dev/null
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/QwpWebSocketSenderCursorEngineAttachmentTest.java
@@ -0,0 +1,191 @@
+/*+*****************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * 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.cutlass.line.LineSenderException;
+import io.questdb.client.cutlass.qwp.client.QwpWebSocketSender;
+import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorSendEngine;
+import io.questdb.client.cutlass.qwp.client.sf.cursor.MmapSegmentException;
+import org.junit.Assert;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+import java.io.File;
+
+import static io.questdb.client.test.tools.TestUtils.assertMemoryLeak;
+
+public class QwpWebSocketSenderCursorEngineAttachmentTest {
+
+ private static final long SEGMENT_SIZE = 64 * 1024L;
+
+ @Rule
+ public final TemporaryFolder sfDir = TemporaryFolder.builder().assureDeletion().build();
+
+ @Test
+ public void testNullDoesNotDetachAnAttachedEngine() throws Exception {
+ assertMemoryLeak(() -> {
+ CursorSendEngine engine = new CursorSendEngine(null, SEGMENT_SIZE);
+ QwpWebSocketSender sender = QwpWebSocketSender.createForTesting("localhost", 1);
+ try {
+ sender.setCursorEngine(engine, true);
+ assertSecondAttachmentRejected(sender, null, false);
+ sender.close();
+ Assert.assertTrue("sender must retain ownership after rejected detach",
+ engine.isCloseCompleted());
+ } finally {
+ sender.close();
+ engine.close();
+ }
+ });
+ }
+
+ @Test
+ public void testOwnedEngineCannotBeReplacedAndItsSlotIsReleased() throws Exception {
+ assertMemoryLeak(() -> {
+ File slotDir = new File(sfDir.getRoot(), "owned-slot");
+ CursorSendEngine first = null;
+ CursorSendEngine replacement = null;
+ CursorSendEngine reacquired = null;
+ QwpWebSocketSender sender = null;
+ boolean replacementRejected = false;
+ try {
+ first = new CursorSendEngine(slotDir.getAbsolutePath(), SEGMENT_SIZE);
+ replacement = new CursorSendEngine(null, SEGMENT_SIZE);
+ sender = QwpWebSocketSender.createForTesting("localhost", 1);
+ sender.setCursorEngine(first, true);
+ try {
+ sender.setCursorEngine(replacement, true);
+ } catch (LineSenderException e) {
+ replacementRejected = true;
+ Assert.assertTrue(e.getMessage().contains("already attached"));
+ }
+
+ sender.close();
+ sender = null;
+ try {
+ reacquired = new CursorSendEngine(slotDir.getAbsolutePath(), SEGMENT_SIZE);
+ } catch (IllegalStateException e) {
+ throw new AssertionError("sender close did not release the first owned "
+ + "engine's slot after a second attachment attempt", e);
+ }
+ Assert.assertTrue("second attachment must be rejected", replacementRejected);
+ } finally {
+ if (sender != null) {
+ sender.close();
+ }
+ if (reacquired != null) {
+ reacquired.close();
+ }
+ if (replacement != null) {
+ replacement.close();
+ }
+ if (first != null) {
+ first.close();
+ }
+ }
+ });
+ }
+
+ @Test
+ public void testSameSharedEngineCannotTransferOwnership() throws Exception {
+ assertMemoryLeak(() -> {
+ CursorSendEngine engine = new CursorSendEngine(null, SEGMENT_SIZE);
+ QwpWebSocketSender sender = QwpWebSocketSender.createForTesting("localhost", 1);
+ try {
+ sender.setCursorEngine(engine, false);
+ assertSecondAttachmentRejected(sender, engine, true);
+ sender.close();
+ Assert.assertFalse("rejected attachment must not transfer ownership",
+ engine.isCloseCompleted());
+ } finally {
+ sender.close();
+ engine.close();
+ }
+ });
+ }
+
+ @Test
+ public void testLatchedDurabilityFailureSurfacesThroughSenderFlushAndAwait() throws Exception {
+ assertMemoryLeak(() -> {
+ // The ring gate (SegmentRing.appendOrFsn) guards every publish
+ // path, but an EMPTY flush()/flushAndGetSequence() and a
+ // timeoutMillis <= 0 awaitAckedFsn() poll publish nothing: the
+ // sender-level cursorEngine.checkDurability() call sites are
+ // their ONLY durability act. Without them an empty flush
+ // silently succeeds against an unsyncable slot and a poll loop
+ // spins on "not yet" forever instead of surfacing the latched
+ // barrier failure. Deleting any of the three sender call sites
+ // previously survived the whole suite -- this pins the
+ // user-visible layer (the engine seam has its own pin in
+ // CursorSendEngineTest).
+ CursorSendEngine engine = new CursorSendEngine(null, SEGMENT_SIZE);
+ QwpWebSocketSender sender = QwpWebSocketSender.createForTesting("localhost", 1);
+ try {
+ sender.setCursorEngine(engine, false);
+ MmapSegmentException failure = new MmapSegmentException("injected data-sync failure");
+ engine.getRingForTesting().recordDurabilityFailureForTesting(failure);
+ for (int i = 0; i < 2; i++) { // repeatable until cleared, not one-shot
+ try {
+ sender.flush();
+ Assert.fail("empty flush() must surface the latched durability failure, call #" + i);
+ } catch (MmapSegmentException expected) {
+ Assert.assertSame("the latched instance itself must surface", failure, expected);
+ }
+ try {
+ sender.flushAndGetSequence();
+ Assert.fail("empty flushAndGetSequence() must surface the latched durability "
+ + "failure, call #" + i);
+ } catch (MmapSegmentException expected) {
+ Assert.assertSame(failure, expected);
+ }
+ try {
+ sender.awaitAckedFsn(0L, 0L);
+ Assert.fail("awaitAckedFsn(fsn, 0) must throw instead of polling 'not yet', "
+ + "call #" + i);
+ } catch (MmapSegmentException expected) {
+ Assert.assertSame(failure, expected);
+ }
+ }
+ } finally {
+ sender.close();
+ engine.close();
+ }
+ });
+ }
+
+ private static void assertSecondAttachmentRejected(
+ QwpWebSocketSender sender,
+ CursorSendEngine engine,
+ boolean takeOwnership
+ ) {
+ try {
+ sender.setCursorEngine(engine, takeOwnership);
+ Assert.fail("expected second cursor engine attachment to be rejected");
+ } catch (LineSenderException e) {
+ Assert.assertTrue(e.getMessage().contains("already attached"));
+ }
+ }
+}
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 85522e9d..cc501a8c 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,9 +24,22 @@
package io.questdb.client.test.cutlass.qwp.client;
+import io.questdb.client.DefaultHttpClientConfiguration;
import io.questdb.client.Sender;
+import io.questdb.client.SenderConnectionEvent;
+import io.questdb.client.SenderError;
+import io.questdb.client.cutlass.http.client.WebSocketClient;
import io.questdb.client.cutlass.qwp.client.QwpWebSocketSender;
+import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorSendEngine;
import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorWebSocketSendLoop;
+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.SenderConnectionDispatcher;
+import io.questdb.client.cutlass.qwp.client.sf.cursor.SenderErrorDispatcher;
+import io.questdb.client.cutlass.qwp.client.sf.cursor.SenderProgressDispatcher;
+import io.questdb.client.cutlass.qwp.client.sf.cursor.SlotLock;
+import io.questdb.client.network.PlainSocketFactory;
+import io.questdb.client.std.Files;
import io.questdb.client.test.cutlass.qwp.websocket.TestWebSocketServer;
import io.questdb.client.test.tools.TestUtils;
import org.junit.Assert;
@@ -36,8 +49,12 @@
import java.lang.reflect.Field;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
+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.AtomicLong;
+import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.locks.LockSupport;
/**
@@ -46,9 +63,13 @@
*
*
Happy path — a clean {@code close()} that winds the I/O thread
* down reports {@code isSlotLockReleased() == true}.
- *
Leak path — a {@code close()} that early-returns because the I/O
- * thread refused to stop ({@code ioThreadStopped == false}) reports
+ *
Leak path — a {@code close()} that retains the lock because an
+ * I/O or manager worker did not stop reports
* {@code isSlotLockReleased() == false}.
+ *
Recovery path — the getter is monotonic but not frozen: it
+ * re-probes the retained engine, so once the deferred cleanup (worker
+ * exit path or a retried close) releases the flock, the getter flips to
+ * {@code true} and a pool that retired the slot can recover it.
*
* The pool's {@code flockReleased(s)} treats {@code false} as "flock still held,
* retire the slot permanently". Before this test that contract was driven only
@@ -178,6 +199,380 @@ public void testSlotLockNotReleasedWhenIoThreadRefusesToStop() throws Exception
});
}
+ /**
+ * Manager-worker handoff path: engine close retains the slot while a shared
+ * manager is still inside a service pass. The sender must expose that
+ * retained flock to SenderPool. Repeated sender close calls remain no-ops;
+ * the manager pass owns deferred cleanup and releases the flock when it
+ * finishes, so the sender can expose recovery without a direct close retry.
+ */
+ @Test(timeout = 30_000L)
+ public void testSlotLockNotReleasedUntilManagerWorkerQuiesces() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ String tmpDir = Paths.get(System.getProperty("java.io.tmpdir"),
+ "qdb-slot-lock-manager-" + System.nanoTime()).toString();
+ 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));
+ CountDownLatch cleanupFinished = new CountDownLatch(1);
+ CountDownLatch workerBlocked = new CountDownLatch(1);
+ CountDownLatch releaseWorker = new CountDownLatch(1);
+ AtomicBoolean fired = new AtomicBoolean();
+ AtomicReference hookErr = new AtomicReference<>();
+ CursorSendEngine engine = null;
+ QwpWebSocketSender wss = null;
+ boolean managerClosed = false;
+ try {
+ manager.setAfterRingCleanupHook(cleanupFinished::countDown);
+ manager.setBeforeInstallSyncHook(() -> {
+ if (!fired.compareAndSet(false, true)) return;
+ workerBlocked.countDown();
+ try {
+ if (!releaseWorker.await(20, TimeUnit.SECONDS)) {
+ hookErr.compareAndSet(null,
+ new AssertionError("timed out waiting for test to release worker"));
+ }
+ } catch (Throwable t) {
+ hookErr.compareAndSet(null, t);
+ }
+ });
+ manager.start();
+ engine = new CursorSendEngine(slot, segSize, manager);
+ Assert.assertTrue("worker never reached install hook",
+ workerBlocked.await(5, TimeUnit.SECONDS));
+
+ wss = QwpWebSocketSender.createForTesting("localhost", 1);
+ wss.setCursorEngine(engine, true);
+ manager.setWorkerJoinTimeoutMillis(50L);
+ wss.close();
+
+ Assert.assertFalse("sender reported a retained flock as released",
+ wss.isSlotLockReleased());
+ Assert.assertFalse("engine close must remain incomplete while worker is in service",
+ engine.isCloseCompleted());
+ try {
+ SlotLock probe = SlotLock.acquire(slot);
+ probe.close();
+ Assert.fail("slot became acquirable while manager worker was still in service");
+ } catch (Exception expected) {
+ // good — the incomplete close retained the flock.
+ }
+
+ // Sender.close() is idempotent: a second call must not retry
+ // or change ownership while the manager worker is still live.
+ wss.close();
+ Assert.assertFalse("repeated close changed retained-flock reporting",
+ wss.isSlotLockReleased());
+ Assert.assertFalse("repeated close unexpectedly retried engine cleanup",
+ engine.isCloseCompleted());
+
+ releaseWorker.countDown();
+ Assert.assertTrue("shared-manager pass did not finish deferred cleanup",
+ cleanupFinished.await(5, TimeUnit.SECONDS));
+ Assert.assertTrue("deferred cleanup did not complete after the ring pass",
+ engine.isCloseCompleted());
+ // Recovery contract: the sender re-probes its retained engine,
+ // so the completed cleanup (and released flock) MUST become
+ // visible — this is what lets SenderPool recover a slot it
+ // had already retired as leaked.
+ Assert.assertTrue("sender must expose the late flock release to the pool",
+ wss.isSlotLockReleased());
+ try (SlotLock probe = SlotLock.acquire(slot)) {
+ Assert.assertNotNull("slot must be acquirable after deferred cleanup", probe);
+ }
+
+ manager.close();
+ managerClosed = true;
+ if (hookErr.get() != null) {
+ throw new AssertionError("install hook failed", hookErr.get());
+ }
+ } finally {
+ manager.setAfterRingCleanupHook(null);
+ manager.setBeforeInstallSyncHook(null);
+ releaseWorker.countDown();
+ if (wss != null) {
+ try {
+ wss.close();
+ } catch (Throwable ignored) {
+ }
+ }
+ if (!managerClosed) {
+ manager.close();
+ }
+ rmDirRecursive(tmpDir);
+ Files.remove(tmpDir);
+ }
+ });
+ }
+
+ /**
+ * Delegated-I/O-close recovery path: when {@code close()} bails early
+ * because the I/O thread refuses to stop, the engine close is delegated
+ * to that thread's exit path ({@code delegateEngineClose()} returned
+ * true) and the sender must retain the engine for re-probing —
+ * {@code isSlotLockReleased()} stays false while the thread lives, then
+ * flips true the moment the thread's exit path completes the engine
+ * close and releases the flock. Pre-fix, this branch discarded the
+ * engine reference, so the late release was permanently invisible to a
+ * pool that had retired the slot. The existing forged I/O-refusal test
+ * can never reach this branch: its sabotaged loop throws from
+ * {@code delegateEngineClose()} itself (nulled latch), before the
+ * retained-engine assignment.
+ *
+ * The wedge is the same deterministic one CursorWebSocketSendLoop's C5
+ * test uses: the I/O thread sits in a blocking "connect" (a
+ * ReconnectFactory immune to unpark, never interrupted by loop.close()),
+ * and the closer thread's pre-set interrupt flag makes
+ * {@code shutdownLatch.await()} throw immediately — the real production
+ * path to {@code ioThreadStopped = false}.
+ */
+ @Test(timeout = 30_000L)
+ public void testDelegatedIoThreadEngineCloseFlipsSlotLockReleased() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ String tmpDir = Paths.get(System.getProperty("java.io.tmpdir"),
+ "qdb-slot-lock-delegated-" + System.nanoTime()).toString();
+ Assert.assertEquals(0, Files.mkdir(tmpDir, Files.DIR_MODE_DEFAULT));
+ String slot = tmpDir + "/slot";
+ long segSize = MmapSegment.HEADER_SIZE + MmapSegment.FRAME_HEADER_SIZE + 32L;
+ final CountDownLatch enteredConnect = new CountDownLatch(1);
+ final CountDownLatch releaseConnect = new CountDownLatch(1);
+ final AtomicReference ioThreadRef = new AtomicReference<>();
+ final StubWebSocketClient stubClient = new StubWebSocketClient();
+ // Healthy owned manager: once the wedged I/O thread is released,
+ // its exit-path engine.close() completes normally and releases
+ // the flock — the flip this test pins.
+ CursorSendEngine engine = new CursorSendEngine(slot, segSize);
+ CursorWebSocketSendLoop loop = null;
+ QwpWebSocketSender wss = null;
+ try {
+ // Stand-in for a blocking native connect(2): entered by the
+ // loop's I/O thread, immune to unpark, never interrupted by
+ // loop.close().
+ CursorWebSocketSendLoop.ReconnectFactory stuckConnect = () -> {
+ ioThreadRef.set(Thread.currentThread());
+ enteredConnect.countDown();
+ releaseConnect.await();
+ return stubClient;
+ };
+ loop = new CursorWebSocketSendLoop(
+ null /* async-initial-connect: the I/O thread drives the connect */,
+ engine, 0L, 1_000L,
+ stuckConnect,
+ 5_000L, 100L, 5_000L, false);
+ loop.start();
+ Assert.assertTrue("I/O thread never reached the connect factory",
+ enteredConnect.await(5, TimeUnit.SECONDS));
+
+ wss = QwpWebSocketSender.createForTesting("localhost", 1);
+ wss.setCursorEngine(engine, true);
+ wss.setCursorSendLoopForTesting(loop);
+
+ // Drive the real early-bail close() on a thread whose pending
+ // interrupt lands in loop.close()'s shutdownLatch.await().
+ AtomicReference closeFailure = new AtomicReference<>();
+ QwpWebSocketSender wssRef = wss;
+ Thread closer = new Thread(() -> {
+ Thread.currentThread().interrupt();
+ try {
+ wssRef.close();
+ } catch (Throwable t) {
+ closeFailure.set(t);
+ }
+ }, "delegated-close-closer");
+ closer.setDaemon(true);
+ closer.start();
+ closer.join(TimeUnit.SECONDS.toMillis(10));
+ Assert.assertFalse("closer thread did not finish", closer.isAlive());
+ Assert.assertNotNull("close() must surface the failed I/O-thread stop",
+ closeFailure.get());
+
+ // The I/O thread is still wedged: the flock is retained and
+ // must be reported retained.
+ Assert.assertFalse(
+ "isSlotLockReleased() must be false while the delegated engine "
+ + "close is pending on the wedged I/O thread",
+ wss.isSlotLockReleased());
+ Assert.assertFalse("engine close must not have run yet",
+ engine.isCloseCompleted());
+ try {
+ SlotLock probe = SlotLock.acquire(slot);
+ probe.close();
+ Assert.fail("slot became acquirable while the delegated engine close "
+ + "was still pending");
+ } catch (Exception expected) {
+ // good — the flock is genuinely held.
+ }
+
+ // Un-wedge the connect. The I/O thread exits; its exit path
+ // runs the delegated engine.close(), which releases the flock.
+ releaseConnect.countDown();
+ Thread ioThread = ioThreadRef.get();
+ Assert.assertNotNull(ioThread);
+ ioThread.join(TimeUnit.SECONDS.toMillis(10));
+ Assert.assertFalse("I/O thread did not exit after the connect returned",
+ ioThread.isAlive());
+
+ // The recovery contract under test: the getter re-probes the
+ // retained engine, so the late release MUST become visible —
+ // this is what lets SenderPool recover the retired slot.
+ Assert.assertTrue("delegated engine close must have completed on the "
+ + "I/O thread's exit path",
+ engine.isCloseCompleted());
+ Assert.assertTrue(
+ "isSlotLockReleased() must flip true once the delegated engine "
+ + "close released the flock — otherwise the pool retires the "
+ + "slot's capacity until process exit",
+ wss.isSlotLockReleased());
+ try (SlotLock probe = SlotLock.acquire(slot)) {
+ Assert.assertNotNull("slot must be acquirable after the delegated close", probe);
+ }
+ } finally {
+ releaseConnect.countDown();
+ // Reap the loop's bookkeeping now that the I/O thread is gone
+ // (close() threw mid-teardown, so ioThread was left set).
+ Thread.interrupted();
+ if (loop != null) {
+ try {
+ loop.close();
+ } catch (Throwable ignored) {
+ }
+ }
+ if (engine != null && !engine.isCloseCompleted()) {
+ try {
+ engine.close();
+ } catch (Throwable ignored) {
+ }
+ }
+ // The early-return close() deliberately leaked the resources
+ // the (then-running) I/O thread might touch; free the same set
+ // the post-guard tail would have freed.
+ if (wss != null) {
+ freeFieldQuietly(wss, "buffer0");
+ freeFieldQuietly(wss, "buffer1");
+ freeFieldQuietly(wss, "client");
+ freeFieldQuietly(wss, "errorDispatcher");
+ freeFieldQuietly(wss, "progressDispatcher");
+ freeFieldQuietly(wss, "connectionDispatcher");
+ }
+ stubClient.close();
+ rmDirRecursive(tmpDir);
+ Files.remove(tmpDir);
+ }
+ });
+ }
+
+ @Test(timeout = 30_000L)
+ public void testFailedIoStopReclaimsSenderResourcesAfterWorkerExit() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ String tmpDir = Paths.get(System.getProperty("java.io.tmpdir"),
+ "qdb-slot-lock-full-cleanup-" + System.nanoTime()).toString();
+ Assert.assertEquals(0, Files.mkdir(tmpDir, Files.DIR_MODE_DEFAULT));
+ String slot = tmpDir + "/slot";
+ long segSize = MmapSegment.HEADER_SIZE + MmapSegment.FRAME_HEADER_SIZE + 32L;
+ CountDownLatch enteredConnect = new CountDownLatch(1);
+ CountDownLatch releaseConnect = new CountDownLatch(1);
+ AtomicReference ioThreadRef = new AtomicReference<>();
+ StubWebSocketClient loopClient = new StubWebSocketClient();
+ StubWebSocketClient senderClient = new StubWebSocketClient();
+ CursorSendEngine engine = new CursorSendEngine(slot, segSize);
+ CursorWebSocketSendLoop loop = null;
+ try {
+ CursorWebSocketSendLoop.ReconnectFactory stuckConnect = () -> {
+ ioThreadRef.set(Thread.currentThread());
+ enteredConnect.countDown();
+ releaseConnect.await();
+ return loopClient;
+ };
+ loop = new CursorWebSocketSendLoop(
+ null, engine, 0L, 1_000L, stuckConnect,
+ 5_000L, 100L, 5_000L, false);
+ loop.start();
+ Assert.assertTrue("I/O thread never reached the connect factory",
+ enteredConnect.await(5, TimeUnit.SECONDS));
+
+ QwpWebSocketSender sender = QwpWebSocketSender.createForTesting("localhost", 1);
+ sender.setConnectionListener(event -> {
+ });
+ sender.setErrorHandler(error -> {
+ });
+ sender.setProgressHandler(ackedFsn -> {
+ });
+ sender.setClientForTesting(senderClient);
+ sender.setCursorEngine(engine, true);
+ sender.setCursorSendLoopForTesting(loop);
+
+ SenderConnectionDispatcher connectionDispatcher = sender.getConnectionDispatcherForTesting();
+ SenderErrorDispatcher errorDispatcher = sender.getErrorDispatcherForTesting();
+ SenderProgressDispatcher progressDispatcher = sender.getProgressDispatcherForTesting();
+ Assert.assertTrue(connectionDispatcher.offer(new SenderConnectionEvent(
+ SenderConnectionEvent.Kind.DISCONNECTED,
+ null, SenderConnectionEvent.NO_PORT,
+ null, SenderConnectionEvent.NO_PORT,
+ SenderConnectionEvent.NO_ATTEMPT_NUMBER,
+ SenderConnectionEvent.NO_ROUND_NUMBER,
+ null, 0L)));
+ Assert.assertTrue(errorDispatcher.offer(new SenderError(
+ SenderError.Category.UNKNOWN, SenderError.Policy.RETRIABLE,
+ SenderError.NO_STATUS_BYTE, null, SenderError.NO_MESSAGE_SEQUENCE,
+ -1L, -1L, null, 0L)));
+ Assert.assertTrue(progressDispatcher.offer(0L));
+ Thread connectionDispatcherThread = connectionDispatcher.getWorkerThreadForTesting();
+ Thread errorDispatcherThread = errorDispatcher.getWorkerThreadForTesting();
+ Thread progressDispatcherThread = progressDispatcher.getWorkerThreadForTesting();
+ Assert.assertNotNull(connectionDispatcherThread);
+ Assert.assertNotNull(errorDispatcherThread);
+ Assert.assertNotNull(progressDispatcherThread);
+
+ AtomicReference closeFailure = new AtomicReference<>();
+ Thread closer = new Thread(() -> {
+ Thread.currentThread().interrupt();
+ try {
+ sender.close();
+ } catch (Throwable t) {
+ closeFailure.set(t);
+ }
+ }, "full-cleanup-closer");
+ closer.start();
+ closer.join(TimeUnit.SECONDS.toMillis(10));
+ Assert.assertFalse("closer thread did not finish", closer.isAlive());
+ Assert.assertNotNull("close() must surface the failed I/O-thread stop",
+ closeFailure.get());
+
+ releaseConnect.countDown();
+ Thread ioThread = ioThreadRef.get();
+ Assert.assertNotNull(ioThread);
+ ioThread.join(TimeUnit.SECONDS.toMillis(10));
+ Assert.assertFalse("I/O thread did not exit after release", ioThread.isAlive());
+ Assert.assertTrue("complete sender cleanup callback did not finish",
+ sender.isCloseCleanupComplete());
+ Assert.assertTrue("loop-owned WebSocket client was not closed", loopClient.isClosed());
+ Assert.assertTrue("sender-owned WebSocket client was not closed", senderClient.isClosed());
+ Assert.assertFalse("connection dispatcher worker was not reclaimed",
+ connectionDispatcherThread.isAlive());
+ Assert.assertFalse("error dispatcher worker was not reclaimed",
+ errorDispatcherThread.isAlive());
+ Assert.assertFalse("progress dispatcher worker was not reclaimed",
+ progressDispatcherThread.isAlive());
+ Assert.assertTrue("engine cleanup did not complete", engine.isCloseCompleted());
+ try (SlotLock probe = SlotLock.acquire(slot)) {
+ Assert.assertNotNull(probe);
+ }
+ } finally {
+ releaseConnect.countDown();
+ Thread.interrupted();
+ if (loop != null) {
+ try {
+ loop.close();
+ } catch (Throwable ignored) {
+ }
+ }
+ rmDirRecursive(tmpDir);
+ Files.remove(tmpDir);
+ }
+ });
+ }
+
// ------------------------------------------------------------------ utils
private static void freeFieldQuietly(Object target, String name) {
@@ -192,6 +587,28 @@ private static void freeFieldQuietly(Object target, String name) {
}
}
+ private static void rmDirRecursive(String dir) {
+ if (!Files.exists(dir)) return;
+ long find = Files.findFirst(dir);
+ if (find <= 0) return;
+ 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);
+ Files.remove(child);
+ }
+ }
+ rc = Files.findNext(find);
+ }
+ } finally {
+ Files.findClose(find);
+ }
+ }
+
private static T readField(Object target, String name, Class type) throws Exception {
Class> cls = target.getClass();
while (cls != null) {
@@ -221,6 +638,39 @@ private static void setField(Object target, String name, Object value) throws Ex
throw new NoSuchFieldException(name);
}
+ /**
+ * Minimal concrete {@link WebSocketClient} — never performs I/O. Handed
+ * 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 final AtomicBoolean isClosed = new AtomicBoolean();
+
+ StubWebSocketClient() {
+ super(DefaultHttpClientConfiguration.INSTANCE, PlainSocketFactory.INSTANCE);
+ }
+
+ @Override
+ public void close() {
+ isClosed.set(true);
+ super.close();
+ }
+
+ @Override
+ protected void ioWait(int timeout, int op) {
+ throw new UnsupportedOperationException("stub: no socket");
+ }
+
+ boolean isClosed() {
+ return isClosed.get();
+ }
+
+ @Override
+ protected void setupIoWait() {
+ // no-op
+ }
+ }
+
/** ACKs every binary frame with a running sequence so flush/close drain cleanly. */
private static final class AckAllHandler implements TestWebSocketServer.WebSocketServerHandler {
private final AtomicLong nextSeq = new AtomicLong();
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/SfFromConfigTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/SfFromConfigTest.java
index 0c27d698..1644d561 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/SfFromConfigTest.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/SfFromConfigTest.java
@@ -26,6 +26,7 @@
import io.questdb.client.Sender;
import io.questdb.client.cutlass.line.LineSenderException;
+import io.questdb.client.cutlass.qwp.client.QwpWebSocketSender;
import io.questdb.client.std.Files;
import io.questdb.client.test.cutlass.qwp.websocket.TestWebSocketServer;
import io.questdb.client.test.tools.TestUtils;
@@ -165,6 +166,46 @@ public void testSfMaxTotalBytesAcceptsLargeValue() throws Exception {
});
}
+ @Test
+ public void testPeriodicDurabilityDefaultAndExplicitInterval() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ AckHandler handler = new AckHandler();
+ try (TestWebSocketServer server = new TestWebSocketServer(handler)) {
+ server.start();
+ Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS));
+
+ String base = "ws::addr=localhost:" + server.getPort()
+ + ";sf_dir=" + sfDir + ";sf_durability=periodic;";
+ try (Sender sender = Sender.fromConfig(base)) {
+ QwpWebSocketSender ws = (QwpWebSocketSender) sender;
+ Assert.assertEquals(5_000_000_000L,
+ ws.getCursorEngineForTesting().getSyncIntervalNanosForTesting());
+ sender.table("foo").longColumn("v", 1L).atNow();
+ sender.flush();
+ }
+
+ try (Sender sender = Sender.fromConfig(base
+ + "sender_id=explicit;sf_sync_interval_millis=123;")) {
+ QwpWebSocketSender ws = (QwpWebSocketSender) sender;
+ Assert.assertEquals(123_000_000L,
+ ws.getCursorEngineForTesting().getSyncIntervalNanosForTesting());
+ }
+ }
+ });
+ }
+
+ @Test
+ public void testPeriodicDurabilityRequiresDirectory() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ try (Sender ignored = Sender.fromConfig(
+ "ws::addr=localhost:1;sf_durability=periodic;")) {
+ Assert.fail("expected periodic mode without sf_dir to fail");
+ } catch (LineSenderException expected) {
+ Assert.assertTrue(expected.getMessage(), expected.getMessage().contains("requires sf_dir"));
+ }
+ });
+ }
+
@Test
public void testSfDurabilityAppendNotYetSupported() throws Exception {
TestUtils.assertMemoryLeak(() -> {
@@ -221,6 +262,20 @@ public void testSfDurabilityOnTcpRejected() throws Exception {
});
}
+ @Test
+ public void testSfSyncIntervalRequiresPeriodicMode() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ String config = "ws::addr=localhost:1;sf_dir=" + sfDir
+ + ";sf_sync_interval_millis=5000;";
+ try (Sender ignored = Sender.fromConfig(config)) {
+ Assert.fail("expected interval without periodic mode to fail");
+ } catch (LineSenderException expected) {
+ Assert.assertTrue(expected.getMessage(),
+ expected.getMessage().contains("requires sf_durability=periodic"));
+ }
+ });
+ }
+
@Test
public void testSfMaxBytesAcceptsSizeSuffixes() throws Exception {
TestUtils.assertMemoryLeak(() -> {
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/AckWatermarkTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/AckWatermarkTest.java
index 001f62ce..848305b2 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/AckWatermarkTest.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/AckWatermarkTest.java
@@ -26,6 +26,9 @@
import io.questdb.client.cutlass.qwp.client.sf.cursor.AckWatermark;
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.TestUtils;
import org.junit.After;
import org.junit.Before;
@@ -36,6 +39,7 @@
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
public class AckWatermarkTest {
@@ -76,6 +80,61 @@ public void testCrossSessionPersistence() throws Exception {
});
}
+ @Test
+ public void testFacadeMmapFaultFailsOpenAndClosesFd() throws Exception {
+ // The watermark mapping must be reachable from an injected facade so
+ // tests can fault-inject mmap. On a rejected mapping, open() must
+ // fail cleanly and release the fd through the same facade.
+ TestUtils.assertMemoryLeak(() -> {
+ MappingFilesFacade ff = new MappingFilesFacade(true);
+ assertNull("open must fail when the injected facade rejects mmap",
+ AckWatermark.open(ff, slotDir));
+ assertEquals("facade must receive the watermark mmap call", 1, ff.mmapCalls);
+ assertEquals("failed open must close the watermark fd via the facade",
+ 1, ff.watermarkFdCloseCalls);
+ assertEquals("a rejected mapping must not be munmapped", 0, ff.munmapCalls);
+ });
+ }
+
+ @Test
+ public void testFallsBackFromTornPlausibleHighValue() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ try (AckWatermark watermark = AckWatermark.open(slotDir)) {
+ assertNotNull(watermark);
+ watermark.write(255L);
+ watermark.write(256L);
+ watermark.sync();
+ }
+
+ // Model a torn little-endian 255 -> 256 transition: the high byte
+ // reached storage while the low byte retained 0xff, yielding the
+ // false FSN 511. A recovered ring with publishedFsn=599 cannot
+ // reject that value using its segment ceiling.
+ long publishedFsn = 599L;
+ long corruptFsn = 511L;
+ assertTrue(corruptFsn <= publishedFsn);
+ String path = slotDir + "/" + AckWatermark.FILE_NAME;
+ int fd = Files.openRW(path);
+ assertTrue(fd >= 0);
+ long corruptByte = Unsafe.malloc(1, MemoryTag.NATIVE_DEFAULT);
+ try {
+ Unsafe.getUnsafe().putByte(corruptByte, (byte) 0xff);
+ long fsnOffset = AckWatermark.FILE_SIZE == 16 ? 8L : 16L;
+ assertEquals(1L, Files.write(fd, corruptByte, 1, fsnOffset));
+ assertEquals(0, Files.fsync(fd));
+ } finally {
+ Files.close(fd);
+ Unsafe.free(corruptByte, 1, MemoryTag.NATIVE_DEFAULT);
+ }
+
+ try (AckWatermark watermark = AckWatermark.open(slotDir)) {
+ assertNotNull(watermark);
+ assertEquals("torn latest record must fall back to the older watermark",
+ 255L, watermark.read());
+ }
+ });
+ }
+
@Test
public void testFreshFileReadsAsInvalid() throws Exception {
// open() creates the file zero-filled, so magic is 0 and read()
@@ -102,6 +161,53 @@ public void testNegativeFsnRoundTrips() throws Exception {
});
}
+ @Test
+ public void testOpenAndCloseRouteMappingThroughFacade() throws Exception {
+ // The lifetime mapping and its release must go through the injected
+ // FilesFacade, matching MmapSegment, so test facades can observe them.
+ TestUtils.assertMemoryLeak(() -> {
+ MappingFilesFacade ff = new MappingFilesFacade(false);
+ try (AckWatermark w = AckWatermark.open(ff, slotDir)) {
+ assertNotNull(w);
+ assertEquals("open must mmap through the injected facade", 1, ff.mmapCalls);
+ w.write(7L);
+ assertEquals(7L, w.read());
+ }
+ assertEquals("close must munmap through the injected facade", 1, ff.munmapCalls);
+ assertEquals("close must release the watermark fd via the facade",
+ 1, ff.watermarkFdCloseCalls);
+ });
+ }
+
+ @Test
+ public void testPhysicalReleaseIsIdempotentAcrossTestingSeamAndClose() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ AckWatermark watermark = AckWatermark.open(slotDir);
+ assertNotNull(watermark);
+ try {
+ assertTrue("first test release must relinquish physical storage",
+ watermark.releaseStorageButKeepWritableForTesting());
+ assertFalse("repeated test release must not touch physical storage again",
+ watermark.releaseStorageButKeepWritableForTesting());
+ watermark.close();
+ assertFalse("close after test release must keep physical cleanup idempotent",
+ watermark.releaseStorageButKeepWritableForTesting());
+ } finally {
+ watermark.close();
+ }
+
+ AckWatermark normallyClosed = AckWatermark.open(slotDir);
+ assertNotNull(normallyClosed);
+ try {
+ normallyClosed.close();
+ assertFalse("ordinary close must record physical relinquishment",
+ normallyClosed.releaseStorageButKeepWritableForTesting());
+ } finally {
+ normallyClosed.close();
+ }
+ });
+ }
+
@Test
public void testRemoveOrphanDeletesFile() throws Exception {
TestUtils.assertMemoryLeak(() -> {
@@ -133,6 +239,38 @@ public void testRepeatedWriteUpdatesValue() throws Exception {
});
}
+ @Test
+ public void testSingleSectorTearLeavesPriorRecordRecoverable() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ try (AckWatermark watermark = AckWatermark.open(slotDir)) {
+ assertNotNull(watermark);
+ watermark.write(100L);
+ watermark.write(200L);
+ watermark.sync();
+ }
+
+ String path = slotDir + "/" + AckWatermark.FILE_NAME;
+ int fd = Files.openRW(path);
+ assertTrue(fd >= 0);
+ int tornBytes = (int) Math.min(512L, Files.length(path));
+ long tornSector = Unsafe.malloc(tornBytes, MemoryTag.NATIVE_DEFAULT);
+ try {
+ Unsafe.getUnsafe().setMemory(tornSector, tornBytes, (byte) 0xA5);
+ assertEquals(tornBytes, Files.write(fd, tornSector, tornBytes, 0));
+ assertEquals(0, Files.fsync(fd));
+ } finally {
+ Files.close(fd);
+ Unsafe.free(tornSector, tornBytes, MemoryTag.NATIVE_DEFAULT);
+ }
+
+ try (AckWatermark watermark = AckWatermark.open(slotDir)) {
+ assertNotNull(watermark);
+ assertEquals("one aligned 512-byte tear must leave the prior record valid",
+ 100L, watermark.read());
+ }
+ });
+ }
+
@Test
public void testStaleFileWithWrongSizeIsResetOnOpen() throws Exception {
// A leftover file with an unexpected size (corruption, partial
@@ -171,4 +309,68 @@ public void testWriteReadInSameSession() throws Exception {
}
});
}
+
+ /**
+ * Delegating facade that counts mmap/munmap traffic and watermark-fd
+ * closes, optionally rejecting the mapping to exercise the open()
+ * failure path.
+ */
+ private static final class MappingFilesFacade implements FilesFacade {
+ private final boolean failMmap;
+ private int mmapCalls;
+ private int munmapCalls;
+ private int watermarkFd = -1;
+ private int watermarkFdCloseCalls;
+
+ private MappingFilesFacade(boolean failMmap) {
+ this.failMmap = failMmap;
+ }
+
+ @Override public boolean allocate(int fd, long size) { return INSTANCE.allocate(fd, size); }
+ @Override public long allocNativePath(String path) { return INSTANCE.allocNativePath(path); }
+ @Override public int close(int fd) {
+ if (fd >= 0 && fd == watermarkFd) watermarkFdCloseCalls++;
+ return INSTANCE.close(fd);
+ }
+ @Override public boolean exists(String path) { return INSTANCE.exists(path); }
+ @Override public void findClose(long findPtr) { INSTANCE.findClose(findPtr); }
+ @Override public long findFirst(String dir) { return INSTANCE.findFirst(dir); }
+ @Override public long findName(long findPtr) { return INSTANCE.findName(findPtr); }
+ @Override public int findNext(long findPtr) { return INSTANCE.findNext(findPtr); }
+ @Override public int findType(long findPtr) { return INSTANCE.findType(findPtr); }
+ @Override public void freeNativePath(long pathPtr) { INSTANCE.freeNativePath(pathPtr); }
+ @Override public int fsync(int fd) { return INSTANCE.fsync(fd); }
+ @Override public long length(int fd) { return INSTANCE.length(fd); }
+ @Override public long length(String path) { return INSTANCE.length(path); }
+ @Override public long length(long pathPtr) { return INSTANCE.length(pathPtr); }
+ @Override public int lock(int fd) { return INSTANCE.lock(fd); }
+ @Override public int mkdir(String path, int mode) { return INSTANCE.mkdir(path, mode); }
+ @Override public long mmap(int fd, long len, long offset, int flags, int memoryTag) {
+ mmapCalls++;
+ if (failMmap) return Files.FAILED_MMAP_ADDRESS;
+ return INSTANCE.mmap(fd, len, offset, flags, memoryTag);
+ }
+ @Override public void munmap(long address, long len, int memoryTag) {
+ munmapCalls++;
+ INSTANCE.munmap(address, len, memoryTag);
+ }
+ @Override public int openCleanRW(String path) {
+ int fd = INSTANCE.openCleanRW(path);
+ if (path.endsWith(AckWatermark.FILE_NAME)) watermarkFd = fd;
+ return fd;
+ }
+ @Override public int openCleanRW(long pathPtr) { return INSTANCE.openCleanRW(pathPtr); }
+ @Override public int openRW(String path) {
+ int fd = INSTANCE.openRW(path);
+ if (path.endsWith(AckWatermark.FILE_NAME)) watermarkFd = fd;
+ return fd;
+ }
+ @Override public int openRW(long pathPtr) { return INSTANCE.openRW(pathPtr); }
+ @Override public long read(int fd, long addr, long len, long offset) { return INSTANCE.read(fd, addr, len, offset); }
+ @Override public boolean remove(String path) { return INSTANCE.remove(path); }
+ @Override public boolean remove(long pathPtr) { return INSTANCE.remove(pathPtr); }
+ @Override public int rename(String oldPath, String newPath) { return INSTANCE.rename(oldPath, newPath); }
+ @Override public boolean truncate(int fd, long size) { return INSTANCE.truncate(fd, size); }
+ @Override public long write(int fd, long addr, long len, long offset) { return INSTANCE.write(fd, addr, len, offset); }
+ }
}
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 0a6b69f8..ad9f5930 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
@@ -511,8 +511,8 @@ public void testJvmErrorEscapesConnectRetryLoop() throws Exception {
// .lock forever with no .failed sentinel and only a throttled WARN as
// a trace. A JVM/programming failure is not a transport outage:
// retrying cannot clear it, so it must escape the loop on the FIRST
- // sweep. run()'s outer catch then quarantines the slot (markFailed +
- // FAILED) and its finally releases the lock -- quarantine-and-exit.
+ // sweep. run() records FAILED without quarantining recoverable data,
+ // releases the lock in finally, and propagates the Error.
CountingListener listener = new CountingListener();
ScriptedFactory factory = ScriptedFactory.alwaysFailing(
() -> new LinkageError("simulated JVM failure"));
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerSetupFailureTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerSetupFailureTest.java
new file mode 100644
index 00000000..a8bd96c9
--- /dev/null
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/BackgroundDrainerSetupFailureTest.java
@@ -0,0 +1,424 @@
+/*+*****************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * 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.BackgroundDrainer;
+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.OrphanScanner;
+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.atomic.AtomicInteger;
+
+public class BackgroundDrainerSetupFailureTest {
+
+ private static final long SEGMENT_BYTES = 1L << 20;
+ private String slotPath;
+
+ @Before
+ public void setUp() {
+ slotPath = Paths.get(System.getProperty("java.io.tmpdir"),
+ "qdb-drainer-setup-failure-" + System.nanoTime()).toString();
+ Assert.assertEquals(0, Files.mkdir(slotPath, Files.DIR_MODE_DEFAULT));
+ }
+
+ @After
+ public void tearDown() {
+ removeRecursive(slotPath);
+ }
+
+ @Test
+ public void testConnectErrorPropagatesWithoutQuarantine() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ seedUnackedFrame();
+ LinkageError injected = new LinkageError("injected drainer connect error");
+ AtomicInteger connectAttempts = new AtomicInteger();
+ BackgroundDrainer drainer = new BackgroundDrainer(
+ slotPath,
+ SEGMENT_BYTES,
+ Long.MAX_VALUE,
+ () -> {
+ connectAttempts.incrementAndGet();
+ throw injected;
+ },
+ 5_000L,
+ 1L,
+ 10L,
+ true,
+ 200L);
+
+ LinkageError thrown = null;
+ try {
+ drainer.run();
+ } catch (LinkageError e) {
+ thrown = e;
+ }
+
+ Assert.assertSame("post-publication Error must remain visible to the caller",
+ injected, thrown);
+ Assert.assertEquals("connect error must occur on the first attempt",
+ 1, connectAttempts.get());
+ Assert.assertEquals(BackgroundDrainer.DrainOutcome.FAILED, drainer.outcome());
+ TestUtils.assertContains(drainer.getLastErrorMessage(),
+ "injected drainer connect error");
+ Assert.assertFalse("a post-publication Error must not quarantine recoverable data",
+ Files.exists(slotPath + "/" + OrphanScanner.FAILED_SENTINEL_NAME));
+ Assert.assertTrue("unacknowledged segment must remain scanner-eligible",
+ OrphanScanner.isCandidateOrphan(slotPath));
+ try (CursorSendEngine ignored = new CursorSendEngine(slotPath, SEGMENT_BYTES)) {
+ Assert.assertTrue("drainer teardown must release the slot lock",
+ OrphanScanner.isCandidateOrphan(slotPath));
+ }
+ });
+ }
+
+ @Test
+ public void testConstructionErrorPropagatesWithoutQuarantine() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ seedUnackedFrame();
+ OutOfMemoryError injected = new OutOfMemoryError("injected drainer construction error");
+ CursorSendEngine.setBeforeDeferredCloseCreationHook(() -> {
+ throw injected;
+ });
+ AtomicInteger connectAttempts = new AtomicInteger();
+ BackgroundDrainer drainer = new BackgroundDrainer(
+ slotPath,
+ SEGMENT_BYTES,
+ Long.MAX_VALUE,
+ () -> {
+ connectAttempts.incrementAndGet();
+ throw new AssertionError("construction error must happen before connect");
+ },
+ 5_000L,
+ 1L,
+ 10L,
+ true,
+ 200L);
+
+ OutOfMemoryError thrown = null;
+ try {
+ drainer.run();
+ } catch (OutOfMemoryError e) {
+ thrown = e;
+ } finally {
+ CursorSendEngine.setBeforeDeferredCloseCreationHook(null);
+ }
+
+ Assert.assertSame("construction Error must remain visible to the caller",
+ injected, thrown);
+ Assert.assertEquals("construction error must precede connect",
+ 0, connectAttempts.get());
+ Assert.assertEquals(BackgroundDrainer.DrainOutcome.FAILED, drainer.outcome());
+ TestUtils.assertContains(drainer.getLastErrorMessage(),
+ "injected drainer construction error");
+ Assert.assertFalse("a VM Error must not quarantine recoverable data",
+ Files.exists(slotPath + "/" + OrphanScanner.FAILED_SENTINEL_NAME));
+ Assert.assertTrue("unacknowledged segment must remain scanner-eligible",
+ OrphanScanner.isCandidateOrphan(slotPath));
+ try (CursorSendEngine ignored = new CursorSendEngine(slotPath, SEGMENT_BYTES)) {
+ Assert.assertTrue("constructor teardown must release the slot lock",
+ OrphanScanner.isCandidateOrphan(slotPath));
+ }
+ });
+ }
+
+ @Test
+ public void testCorruptRecoveredChainIsQuarantined() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ seedUnackedFrame();
+
+ String segmentPath = slotPath + "/sf-initial.sfa";
+ long corruptByte = Unsafe.malloc(1, MemoryTag.NATIVE_DEFAULT);
+ int fd = Files.openRW(segmentPath);
+ Assert.assertTrue("could not open segment for corruption", fd >= 0);
+ try {
+ Unsafe.getUnsafe().putByte(corruptByte, (byte) 0);
+ Assert.assertEquals(1L, Files.write(fd, corruptByte, 1, 0L));
+ } finally {
+ Files.close(fd);
+ Unsafe.free(corruptByte, 1, MemoryTag.NATIVE_DEFAULT);
+ }
+
+ AtomicInteger connectAttempts = new AtomicInteger();
+ BackgroundDrainer drainer = new BackgroundDrainer(
+ slotPath,
+ SEGMENT_BYTES,
+ Long.MAX_VALUE,
+ () -> {
+ connectAttempts.incrementAndGet();
+ throw new AssertionError("recovery failure must happen before connect");
+ },
+ 5_000L,
+ 1L,
+ 10L,
+ true,
+ 200L);
+
+ drainer.run();
+
+ Assert.assertEquals("corrupt recovery must precede connect",
+ 0, connectAttempts.get());
+ Assert.assertEquals(BackgroundDrainer.DrainOutcome.FAILED, drainer.outcome());
+ Assert.assertTrue("a corrupt durable chain requires operator quarantine",
+ Files.exists(slotPath + "/" + OrphanScanner.FAILED_SENTINEL_NAME));
+ Assert.assertTrue("corrupt segment evidence must remain on disk",
+ Files.exists(segmentPath));
+ });
+ }
+
+ @Test
+ public void testSealedResidueFirstSightHealsAndDoesNotQuarantine() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ // A sealed member whose frame accounting is complete but whose
+ // suffix gap carries legacy pre-sanitization poison. Recovery
+ // durably zeroes the residue BEFORE failing closed on first
+ // sight (SegmentRing's sanitize-then-throw), so at the instant
+ // the drainer sees the throw the chain on disk is already
+ // healed. The drainer must retry construction over the healed
+ // chain and reach connect -- not strand the replayable backlog
+ // behind a .failed sentinel that no orphan scan revisits.
+ long segSize = MmapSegment.HEADER_SIZE
+ + 4 * (MmapSegment.FRAME_HEADER_SIZE + 16)
+ + 12; // sealed suffix gap that can never fit a frame
+ String p0Path = slotPath + "/p0.sfa";
+ String p1Path = slotPath + "/p1.sfa";
+ long gapStart;
+ long buf = Unsafe.malloc(16, MemoryTag.NATIVE_DEFAULT);
+ try {
+ Unsafe.getUnsafe().setMemory(buf, 16, (byte) 5);
+ MmapSegment s0 = MmapSegment.create(p0Path, 0, segSize);
+ for (int i = 0; i < 4; i++) {
+ s0.tryAppend(buf, 16);
+ }
+ gapStart = s0.publishedOffset();
+ s0.close();
+ MmapSegment s1 = MmapSegment.create(p1Path, 4, segSize);
+ s1.tryAppend(buf, 16);
+ s1.close();
+ } finally {
+ Unsafe.free(buf, 16, MemoryTag.NATIVE_DEFAULT);
+ }
+
+ // Adopt the chain once so the slot carries a manifest, lock and
+ // watermark like any real producer slot (without a manifest the
+ // legacy-migration path sanitizes silently and never fails
+ // closed); close releases the lock.
+ try (CursorSendEngine seed = new CursorSendEngine(slotPath, segSize)) {
+ Assert.assertEquals("highest published FSN must cover frames 0..4",
+ 4L, seed.publishedFsn());
+ }
+
+ // Poison the sealed suffix gap the way a pre-sanitization
+ // client's reseal-after-recovery did.
+ int fd = Files.openRW(p0Path);
+ Assert.assertTrue("openRW must succeed", fd >= 0);
+ long junk = Unsafe.malloc(12, MemoryTag.NATIVE_DEFAULT);
+ try {
+ for (int i = 0; i < 3; i++) {
+ Unsafe.getUnsafe().putInt(junk + i * 4L, 0xCAFEBABE);
+ }
+ Assert.assertEquals(12L, Files.write(fd, junk, 12, gapStart));
+ Files.fsync(fd);
+ } finally {
+ Unsafe.free(junk, 12, MemoryTag.NATIVE_DEFAULT);
+ Files.close(fd);
+ }
+
+ LinkageError injected = new LinkageError("injected drainer connect error");
+ AtomicInteger connectAttempts = new AtomicInteger();
+ BackgroundDrainer drainer = new BackgroundDrainer(
+ slotPath,
+ segSize,
+ Long.MAX_VALUE,
+ () -> {
+ connectAttempts.incrementAndGet();
+ throw injected;
+ },
+ 5_000L,
+ 1L,
+ 10L,
+ true,
+ 200L);
+
+ LinkageError thrown = null;
+ try {
+ drainer.run();
+ } catch (LinkageError e) {
+ thrown = e;
+ }
+
+ // The heal precedes the first-sight throw: frames intact,
+ // residue durably zeroed. This holds with or without the
+ // drainer fix -- it is exactly what makes quarantine wrong.
+ try (MmapSegment sealed = MmapSegment.openExisting(p0Path)) {
+ Assert.assertEquals("frames must be untouched", 4L, sealed.frameCount());
+ Assert.assertEquals("proven-dead residue must be zeroed on disk",
+ 0L, sealed.tornTailBytes());
+ }
+
+ Assert.assertSame("drainer must reach connect over the healed chain",
+ injected, thrown);
+ Assert.assertEquals("exactly one connect attempt after the healed retry",
+ 1, connectAttempts.get());
+ TestUtils.assertContains(drainer.getLastErrorMessage(),
+ "injected drainer connect error");
+ Assert.assertEquals(BackgroundDrainer.DrainOutcome.FAILED, drainer.outcome());
+ Assert.assertFalse("a just-healed slot must not be quarantined",
+ Files.exists(slotPath + "/" + OrphanScanner.FAILED_SENTINEL_NAME));
+ Assert.assertTrue("replayable backlog must remain scanner-eligible",
+ OrphanScanner.isCandidateOrphan(slotPath));
+ try (CursorSendEngine engine = new CursorSendEngine(slotPath, segSize)) {
+ Assert.assertEquals("backlog must remain fully replayable",
+ 4L, engine.publishedFsn());
+ Assert.assertTrue("drainer teardown must release the slot lock",
+ OrphanScanner.isCandidateOrphan(slotPath));
+ }
+ });
+ }
+
+ @Test
+ public void testLockOpenFailureDoesNotQuarantineRecoverableData() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ seedUnackedFrame();
+
+ String lockPath = slotPath + "/.lock";
+ Assert.assertTrue("seeded lock file must exist", Files.exists(lockPath));
+ Assert.assertTrue("could not remove seeded lock file", Files.remove(lockPath));
+ Assert.assertEquals("could not plant deterministic lock-open failure",
+ 0, Files.mkdir(lockPath, Files.DIR_MODE_DEFAULT));
+
+ AtomicInteger connectAttempts = new AtomicInteger();
+ BackgroundDrainer drainer = new BackgroundDrainer(
+ slotPath,
+ SEGMENT_BYTES,
+ Long.MAX_VALUE,
+ () -> {
+ connectAttempts.incrementAndGet();
+ throw new AssertionError("lock setup failure must happen before connect");
+ },
+ 5_000L,
+ 1L,
+ 10L,
+ true,
+ 200L);
+
+ drainer.run();
+
+ Assert.assertEquals("lock failure must precede connect",
+ 0, connectAttempts.get());
+ Assert.assertEquals(BackgroundDrainer.DrainOutcome.FAILED, drainer.outcome());
+ TestUtils.assertContains(drainer.getLastErrorMessage(),
+ "could not open slot lock file");
+ Assert.assertFalse("an operational lock-open failure must remain retryable",
+ Files.exists(slotPath + "/" + OrphanScanner.FAILED_SENTINEL_NAME));
+ Assert.assertTrue("unacknowledged segment must remain scanner-eligible",
+ OrphanScanner.isCandidateOrphan(slotPath));
+ });
+ }
+
+ @Test
+ public void testWatermarkOpenFailureDoesNotQuarantineRecoverableData() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ seedUnackedFrame();
+
+ String watermarkPath = slotPath + "/" + AckWatermark.FILE_NAME;
+ Assert.assertTrue("seeded watermark must exist", Files.exists(watermarkPath));
+ Assert.assertTrue("could not remove seeded watermark", Files.remove(watermarkPath));
+ Assert.assertEquals("could not plant deterministic watermark-open failure",
+ 0, Files.mkdir(watermarkPath, Files.DIR_MODE_DEFAULT));
+
+ AtomicInteger connectAttempts = new AtomicInteger();
+ BackgroundDrainer drainer = new BackgroundDrainer(
+ slotPath,
+ SEGMENT_BYTES,
+ Long.MAX_VALUE,
+ () -> {
+ connectAttempts.incrementAndGet();
+ throw new AssertionError("setup failure must happen before connect");
+ },
+ 5_000L,
+ 1L,
+ 10L,
+ true,
+ 200L);
+
+ drainer.run();
+
+ Assert.assertEquals("watermark failure must precede connect",
+ 0, connectAttempts.get());
+ Assert.assertEquals(BackgroundDrainer.DrainOutcome.FAILED, drainer.outcome());
+ TestUtils.assertContains(drainer.getLastErrorMessage(),
+ "could not open required ack watermark");
+ Assert.assertFalse("an operational watermark-open failure must remain retryable",
+ Files.exists(slotPath + "/" + OrphanScanner.FAILED_SENTINEL_NAME));
+ Assert.assertTrue("unacknowledged segment must remain on disk",
+ OrphanScanner.isCandidateOrphan(slotPath));
+ });
+ }
+
+ private static void removeRecursive(String path) {
+ if (path == null || !Files.exists(path)) {
+ return;
+ }
+ long find = Files.findFirst(path);
+ 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 = path + "/" + name;
+ if (!Files.remove(child)) {
+ removeRecursive(child);
+ }
+ }
+ rc = Files.findNext(find);
+ }
+ } finally {
+ Files.findClose(find);
+ }
+ }
+ Files.remove(path);
+ }
+
+ private void seedUnackedFrame() {
+ long buffer = Unsafe.malloc(16, MemoryTag.NATIVE_DEFAULT);
+ try (CursorSendEngine engine = new CursorSendEngine(slotPath, SEGMENT_BYTES)) {
+ Unsafe.getUnsafe().setMemory(buffer, 16, (byte) 1);
+ Assert.assertEquals(0L, engine.appendBlocking(buffer, 16));
+ } finally {
+ Unsafe.free(buffer, 16, MemoryTag.NATIVE_DEFAULT);
+ }
+ }
+}
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
new file mode 100644
index 00000000..9aefc2c1
--- /dev/null
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorSendEngineClosePartialEnumerationTest.java
@@ -0,0 +1,163 @@
+/*******************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * 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.SegmentManager;
+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.TestUtils;
+import org.junit.After;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Proxy;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * Close-time unlink under a TORN directory enumeration ({@code findNext}
+ * fails after the listing already produced entries). A partial listing must
+ * not drive any unlink: removing only the files the walk happened to see
+ * could delete the segment holding the highest frame while a lower one
+ * survives, leaving residual state the retained ack watermark can no longer
+ * vouch for. The contract is all-or-nothing: abort the cleanup, keep every
+ * {@code .sfa} file and the watermark, and let the next recovery (or a
+ * successor's fully-drained close) retry.
+ *
+ * The sibling {@link CursorSendEngineCloseUnlinkFailureTest} injects a
+ * failing UNLINK (permission trick, root-skipped); nothing exercised the
+ * enumeration-abort branch itself. Same determinism trick as the sibling:
+ * the shared manager is never started, so no worker touches the slot and no
+ * concurrent enumeration can trip the armed fault.
+ */
+public class CursorSendEngineClosePartialEnumerationTest {
+
+ private String tmpDir;
+
+ @Before
+ public void setUp() {
+ tmpDir = TestUtils.createTmpDir("qdb-engine-close-enum-fault-");
+ }
+
+ @After
+ public void tearDown() {
+ TestUtils.removeTmpDir(tmpDir);
+ }
+
+ @Test(timeout = 20_000L)
+ public void testTornCloseTimeEnumerationUnlinksNothingAndSuccessorRetries() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ final AtomicBoolean failFindNext = new AtomicBoolean();
+ final AtomicInteger sfaRemoveAttempts = new AtomicInteger();
+ FilesFacade faultFacade = (FilesFacade) Proxy.newProxyInstance(
+ FilesFacade.class.getClassLoader(),
+ new Class>[]{FilesFacade.class},
+ (proxy, method, args) -> {
+ if (failFindNext.get() && "findNext".equals(method.getName())) {
+ return -1;
+ }
+ if ("remove".equals(method.getName())
+ && args != null && args.length == 1
+ && args[0] instanceof String
+ && ((String) args[0]).endsWith(".sfa")) {
+ sfaRemoveAttempts.incrementAndGet();
+ }
+ try {
+ return method.invoke(FilesFacade.INSTANCE, args);
+ } catch (InvocationTargetException e) {
+ throw e.getCause();
+ }
+ });
+
+ long segSize = 4096L;
+ String slot = tmpDir + "/slot";
+ Assert.assertEquals(0, Files.mkdir(slot, Files.DIR_MODE_DEFAULT));
+ SegmentManager manager = new SegmentManager(
+ segSize,
+ SegmentManager.DEFAULT_POLL_NANOS,
+ segSize * 4L,
+ faultFacade,
+ System::nanoTime);
+ long buf = Unsafe.malloc(16, MemoryTag.NATIVE_DEFAULT);
+ try {
+ CursorSendEngine engine = new CursorSendEngine(slot, segSize, manager);
+ boolean engineClosed = false;
+ try {
+ Assert.assertEquals(0L, engine.appendBlocking(buf, 16));
+ Assert.assertTrue(engine.acknowledge(0L));
+
+ // Fully drained close, but the directory listing tears
+ // mid-walk: the cleanup must abort BEFORE the first
+ // unlink.
+ failFindNext.set(true);
+ engine.close();
+ engineClosed = true;
+ Assert.assertTrue("close must complete despite the aborted cleanup",
+ engine.isCloseCompleted());
+ Assert.assertEquals(
+ "a torn enumeration must drive ZERO segment unlinks -- "
+ + "removing only the files the walk happened to see can "
+ + "strand residual state the watermark cannot vouch for",
+ 0, sfaRemoveAttempts.get());
+ Assert.assertTrue("the segment file must survive the aborted cleanup",
+ Files.exists(slot + "/sf-initial.sfa"));
+ } finally {
+ if (!engineClosed) {
+ engine.close();
+ }
+ }
+
+ // Heal the directory walk; a successor adopts the slot,
+ // recovers the residual (fully acknowledged) state, and its
+ // own fully-drained close retries the cleanup successfully.
+ failFindNext.set(false);
+ CursorSendEngine successor = new CursorSendEngine(slot, segSize, manager);
+ boolean successorClosed = false;
+ try {
+ Assert.assertTrue("successor must recover the residual slot state",
+ successor.wasRecoveredFromDisk());
+ successor.close();
+ successorClosed = true;
+ Assert.assertTrue(successor.isCloseCompleted());
+ Assert.assertFalse(
+ "successor's fully-drained close must retry and complete the unlink",
+ Files.exists(slot + "/sf-initial.sfa"));
+ } finally {
+ if (!successorClosed) {
+ successor.close();
+ }
+ }
+ } finally {
+ Unsafe.free(buf, 16, MemoryTag.NATIVE_DEFAULT);
+ manager.close();
+ }
+ });
+ }
+}
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
new file mode 100644
index 00000000..a9df2b67
--- /dev/null
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorSendEngineCloseUnlinkFailureTest.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.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.SegmentManager;
+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.Assume;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.nio.file.Path;
+import java.nio.file.Paths;
+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.
+ *
+ * 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.
+ */
+public class CursorSendEngineCloseUnlinkFailureTest {
+
+ private String tmpDir;
+
+ @Before
+ public void setUp() {
+ tmpDir = Paths.get(System.getProperty("java.io.tmpdir"),
+ "qdb-engine-close-unlink-fault-" + System.nanoTime()).toString();
+ Assert.assertEquals(0, Files.mkdir(tmpDir, Files.DIR_MODE_DEFAULT));
+ }
+
+ @After
+ public void tearDown() {
+ if (tmpDir != null) {
+ removeRecursive(tmpDir);
+ }
+ }
+
+ @Test(timeout = 20_000L)
+ public void testFailedCloseTimeUnlinkMustNotExposeAckedFramesToSuccessor() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ long segmentSize = MmapSegment.HEADER_SIZE + MmapSegment.FRAME_HEADER_SIZE + 32L;
+ 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));
+ CursorSendEngine pred = null;
+ CursorSendEngine succ = null;
+ boolean slotDirReadOnly = false;
+ try {
+ fill(payload, 32, (byte) 0x33);
+ pred = new CursorSendEngine(slot, segmentSize, manager);
+ Assert.assertEquals(0L, pred.appendBlocking(payload, 32));
+ Assert.assertEquals(0L, pred.publishedFsn());
+ // The server durably acknowledged FSN 0 in this session.
+ Assert.assertTrue(pred.acknowledge(0L));
+ Assert.assertEquals(0L, pred.ackedFsn());
+
+ // Inject a close-time unlink failure: drop write permission on
+ // the slot dir. Prove the injection works with a probe file --
+ // root (and some filesystems) ignore directory permissions.
+ String probePath = slot + "/probe";
+ Assert.assertTrue(java.nio.file.Files.exists(
+ java.nio.file.Files.createFile(Paths.get(probePath))));
+ try {
+ setPermissions(slotPath, "r-xr-xr-x");
+ } catch (UnsupportedOperationException e) {
+ Assume.assumeNoException("POSIX permissions unavailable on this platform", e);
+ }
+ slotDirReadOnly = true;
+ boolean probeRemoved = Files.remove(probePath);
+ if (probeRemoved) {
+ setPermissions(slotPath, "rwxr-xr-x");
+ slotDirReadOnly = false;
+ }
+ Assume.assumeFalse("directory permissions not enforced (running as root?)",
+ probeRemoved);
+
+ // Fully-drained close: tries to unlink the acknowledged
+ // segment files and fails.
+ pred.close();
+ Assert.assertTrue("flock release needs no dir write; close must complete",
+ pred.isCloseCompleted());
+ pred = null;
+ Assert.assertTrue("injected unlink failure must leave the acknowledged segment",
+ Files.exists(slot + "/sf-initial.sfa"));
+
+ // The transient failure clears before the successor arrives.
+ setPermissions(slotPath, "rwxr-xr-x");
+ slotDirReadOnly = false;
+ Files.remove(probePath);
+
+ succ = new CursorSendEngine(slot, segmentSize, manager);
+ Assert.assertTrue(succ.wasRecoveredFromDisk());
+ Assert.assertEquals(0L, succ.publishedFsn());
+ // THE regression: FSN 0 was acknowledged by the server during
+ // the predecessor's session. The successor must not see it as
+ // replayable, or a non-DEDUP table receives duplicate rows.
+ Assert.assertTrue("successor exposes already-acknowledged frames for replay "
+ + "[ackedFsn=" + succ.ackedFsn()
+ + ", publishedFsn=" + succ.publishedFsn() + "]",
+ succ.ackedFsn() >= succ.publishedFsn());
+
+ // The successor's own fully-drained close retries the cleanup
+ // now that the failure has cleared: segments and watermark gone.
+ succ.close();
+ Assert.assertTrue(succ.isCloseCompleted());
+ succ = null;
+ Assert.assertFalse("successor close did not retry the segment unlink",
+ Files.exists(slot + "/sf-initial.sfa"));
+ Assert.assertFalse("watermark must be removed once no segment file remains",
+ Files.exists(slot + "/" + AckWatermark.FILE_NAME));
+ } finally {
+ if (slotDirReadOnly) {
+ try {
+ setPermissions(slotPath, "rwxr-xr-x");
+ } catch (Throwable ignored) {
+ }
+ }
+ if (pred != null) {
+ pred.close();
+ }
+ if (succ != null) {
+ succ.close();
+ }
+ manager.close();
+ Unsafe.free(payload, 32, MemoryTag.NATIVE_DEFAULT);
+ }
+ });
+ }
+
+ private static void fill(long address, int len, byte value) {
+ for (int i = 0; i < len; i++) {
+ Unsafe.getUnsafe().putByte(address + i, value);
+ }
+ }
+
+ private static void removeRecursive(String dir) {
+ long find = Files.findFirst(dir);
+ if (find > 0) {
+ try {
+ int rc = 1;
+ while (rc > 0) {
+ String name = Files.utf8ToString(Files.findName(find));
+ if (name != null && !".".equals(name) && !"..".equals(name)) {
+ String child = dir + "/" + name;
+ if (!Files.remove(child)) {
+ removeRecursive(child);
+ }
+ }
+ rc = Files.findNext(find);
+ }
+ } finally {
+ Files.findClose(find);
+ }
+ }
+ Files.remove(dir);
+ }
+
+ private static void setPermissions(Path path, String posix) throws Exception {
+ Set perms = PosixFilePermissions.fromString(posix);
+ java.nio.file.Files.setPosixFilePermissions(path, perms);
+ }
+}
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
new file mode 100644
index 00000000..8fe040d7
--- /dev/null
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorSendEngineCloseUnlinkStopOnFirstFailureTest.java
@@ -0,0 +1,193 @@
+/*******************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * 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.SegmentManager;
+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.TestUtils;
+import org.junit.After;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Proxy;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * Close-time unlink STOP-ON-FIRST-FAILURE on a legacy (manifest-less) slot.
+ * Removal runs in ascending generation order and must stop at the first
+ * failed unlink so the residue is always a contiguous top slice that passes
+ * FSN-contiguity at the next recovery. Continuing past a failed
+ * low-generation remove deletes higher generations and can leave
+ * non-contiguous residue -- a startup brick on a slot that lost nothing.
+ *
+ * The siblings cover the OTHER close-cleanup contracts:
+ * {@link CursorSendEngineClosePartialEnumerationTest} proves a torn
+ * enumeration drives zero unlinks, and the unlink-failure sibling fails ALL
+ * removals (permission trick), which cannot discriminate stop-vs-continue
+ * (every file survives either way). A continue-past-first-failure mutant in
+ * {@code unlinkAllSegmentFiles} previously survived the whole suite; this
+ * test kills it via the higher-generation-survival assertions. Same
+ * determinism trick as the siblings: the shared manager is never started, so
+ * no worker touches the slot and no concurrent cleanup can trip the armed
+ * fault.
+ */
+public class CursorSendEngineCloseUnlinkStopOnFirstFailureTest {
+
+ private static final long SEGMENT_SIZE = 4096L;
+
+ private String tmpDir;
+
+ @Before
+ public void setUp() {
+ tmpDir = TestUtils.createTmpDir("qdb-engine-close-unlink-stop-");
+ }
+
+ @After
+ public void tearDown() {
+ TestUtils.removeTmpDir(tmpDir);
+ }
+
+ @Test(timeout = 20_000L)
+ public void testCloseUnlinkStopsAtFirstFailedRemoveOnLegacySlot() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ final String slot = tmpDir + "/legacy-slot";
+ final String lowPath = slot + "/sf-initial.sfa";
+ final String highPath = slot + "/sf-0000000000000002.sfa";
+ final AtomicBoolean armLowestRemoveFailure = new AtomicBoolean();
+ final AtomicInteger lowRemoveAttempts = new AtomicInteger();
+ final AtomicInteger highRemoveAttempts = new AtomicInteger();
+ FilesFacade faultFacade = (FilesFacade) Proxy.newProxyInstance(
+ FilesFacade.class.getClassLoader(),
+ new Class>[]{FilesFacade.class},
+ (proxy, method, args) -> {
+ if ("remove".equals(method.getName()) && args[0] instanceof String) {
+ String path = (String) args[0];
+ if (path.equals(lowPath)) {
+ lowRemoveAttempts.incrementAndGet();
+ if (armLowestRemoveFailure.get()) {
+ // EBUSY-style transient refusal: no unlink happens.
+ return false;
+ }
+ } else if (path.equals(highPath)) {
+ highRemoveAttempts.incrementAndGet();
+ }
+ }
+ try {
+ return method.invoke(FilesFacade.INSTANCE, args);
+ } catch (InvocationTargetException e) {
+ throw e.getCause();
+ }
+ });
+
+ Assert.assertEquals(0, Files.mkdir(slot, Files.DIR_MODE_DEFAULT));
+ // Legacy slot: two FSN-contiguous segments on disk, NO manifest.
+ long buf = Unsafe.malloc(32, MemoryTag.NATIVE_DEFAULT);
+ SegmentManager manager = null;
+ try {
+ MmapSegment low = MmapSegment.create(lowPath, 0L, SEGMENT_SIZE);
+ Assert.assertTrue("setup: append must land", low.tryAppend(buf, 32) >= 0);
+ Assert.assertTrue("setup: append must land", low.tryAppend(buf, 32) >= 0);
+ low.close();
+ MmapSegment high = MmapSegment.create(highPath, 2L, SEGMENT_SIZE);
+ Assert.assertTrue("setup: append must land", high.tryAppend(buf, 32) >= 0);
+ high.close();
+
+ manager = new SegmentManager(
+ SEGMENT_SIZE,
+ SegmentManager.DEFAULT_POLL_NANOS,
+ SEGMENT_SIZE * 4L,
+ faultFacade,
+ System::nanoTime);
+
+ CursorSendEngine engine = new CursorSendEngine(slot, SEGMENT_SIZE, manager);
+ boolean engineClosed = false;
+ try {
+ Assert.assertTrue("engine must recover the legacy two-segment chain",
+ engine.wasRecoveredFromDisk());
+ Assert.assertEquals("recovered chain must publish FSNs 0..2",
+ 2L, engine.publishedFsn());
+ Assert.assertTrue(engine.acknowledge(2L));
+
+ // Fully drained close with the LOWEST-generation removal
+ // refused once: ascending-order removal must STOP there.
+ armLowestRemoveFailure.set(true);
+ engine.close();
+ engineClosed = true;
+ Assert.assertTrue("close must complete despite the aborted cleanup",
+ engine.isCloseCompleted());
+ Assert.assertEquals("cleanup must attempt the lowest generation first",
+ 1, lowRemoveAttempts.get());
+ Assert.assertEquals(
+ "removal must STOP at the first failed unlink -- continuing would "
+ + "delete higher generations and could leave non-contiguous "
+ + "residue that fails FSN-contiguity at the next recovery",
+ 0, highRemoveAttempts.get());
+ Assert.assertTrue("the refused lowest segment must survive",
+ Files.exists(lowPath));
+ Assert.assertTrue("higher-generation segment must survive the stopped cleanup",
+ Files.exists(highPath));
+ } finally {
+ if (!engineClosed) {
+ engine.close();
+ }
+ }
+
+ // Heal the fault; a successor adopts the contiguous residue
+ // and its fully-drained close completes the cleanup.
+ armLowestRemoveFailure.set(false);
+ CursorSendEngine successor = new CursorSendEngine(slot, SEGMENT_SIZE, manager);
+ boolean successorClosed = false;
+ try {
+ Assert.assertTrue("successor must recover the contiguous residue",
+ successor.wasRecoveredFromDisk());
+ successor.close();
+ successorClosed = true;
+ Assert.assertTrue(successor.isCloseCompleted());
+ Assert.assertFalse("successor's fully-drained close must complete the unlink",
+ Files.exists(lowPath));
+ Assert.assertFalse("successor's fully-drained close must complete the unlink",
+ Files.exists(highPath));
+ } finally {
+ if (!successorClosed) {
+ successor.close();
+ }
+ }
+ } finally {
+ Unsafe.free(buf, 32, MemoryTag.NATIVE_DEFAULT);
+ if (manager != null) {
+ manager.close();
+ }
+ }
+ });
+ }
+}
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
new file mode 100644
index 00000000..a7017961
--- /dev/null
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorSendEngineCrashConsistencyTest.java
@@ -0,0 +1,458 @@
+/*+*****************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * 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.SegmentManager;
+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.TestUtils;
+import org.junit.Assert;
+import org.junit.Test;
+
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+public class CursorSendEngineCrashConsistencyTest {
+
+ @Test
+ public void testCloseDurabilityOrderAndPostCleanupSyncFailurePropagation() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ for (int failAt : new int[]{-1, 4}) {
+ String root = Paths.get(System.getProperty("java.io.tmpdir"),
+ "qdb-close-crash-" + failAt + "-" + System.nanoTime()).toString();
+ String slot = root + "/slot";
+ SegmentManager manager = null;
+ CursorSendEngine engine = null;
+ long payload = 0;
+ Throwable failure = null;
+ try {
+ 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),
+ SegmentManager.UNLIMITED_TOTAL_BYTES, ff);
+ payload = Unsafe.malloc(32, MemoryTag.NATIVE_DEFAULT);
+ engine = new CursorSendEngine(slot, segmentSize, manager);
+ Unsafe.getUnsafe().setMemory(payload, 32, (byte) 7);
+ Assert.assertEquals(0L, engine.appendBlocking(payload, 32));
+ Assert.assertTrue(engine.acknowledge(0L));
+ ff.beginClose();
+ try {
+ engine.close();
+ if (failAt >= 0) {
+ Assert.fail("sync failure was swallowed at boundary " + failAt);
+ }
+ } catch (IllegalStateException expected) {
+ Assert.assertTrue("unexpected close failure: " + expected, failAt >= 0);
+ }
+ engine = null;
+
+ Assert.assertFalse("simulated crash replays acknowledged rows at boundary " + failAt,
+ ff.durableSegments && !ff.durableWatermark);
+ if (failAt == -1) {
+ Assert.assertEquals(Arrays.asList("watermark-msync", "watermark-fsync",
+ "dir-fsync", "segment-remove", "dir-fsync", "watermark-remove"),
+ ff.events);
+ }
+ } catch (Throwable t) {
+ failure = t;
+ } finally {
+ failure = closeEngine(failure, engine);
+ failure = closeManager(failure, manager);
+ failure = freePayload(failure, payload);
+ failure = removeRoot(failure, root);
+ }
+ rethrow(failure);
+ }
+ });
+ }
+
+ @Test
+ public void testFinalWatermarkBarrierFailureRetainsSlotUntilRetry() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ for (String barrier : Arrays.asList("watermark-msync", "watermark-fsync", "dir-fsync")) {
+ String root = Paths.get(System.getProperty("java.io.tmpdir"),
+ "qdb-close-barrier-" + barrier + "-" + System.nanoTime()).toString();
+ String slot = root + "/slot";
+ SegmentManager manager = null;
+ CursorSendEngine predecessor = null;
+ CursorSendEngine successor = null;
+ CrashImageFilesFacade ff = null;
+ long payload = 0;
+ Throwable failure = null;
+ try {
+ 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),
+ SegmentManager.UNLIMITED_TOTAL_BYTES, ff);
+ payload = Unsafe.malloc(32, MemoryTag.NATIVE_DEFAULT);
+ predecessor = new CursorSendEngine(slot, segmentSize, manager);
+ Unsafe.getUnsafe().setMemory(payload, 32, (byte) 7);
+ Assert.assertEquals(0L, predecessor.appendBlocking(payload, 32));
+ Assert.assertTrue(predecessor.acknowledge(0L));
+
+ ff.failPersistently(barrier);
+ try {
+ predecessor.close();
+ Assert.fail("expected close to report failed final barrier " + barrier);
+ } catch (IllegalStateException expected) {
+ Assert.assertTrue("unexpected close failure: " + expected,
+ expected.getMessage().contains("ack watermark")
+ || expected.getMessage().contains("slot directory"));
+ }
+ try {
+ successor = new CursorSendEngine(slot, segmentSize, manager);
+ Assert.fail("successor acquired a slot whose final ACK barrier failed: " + barrier);
+ } catch (IllegalStateException expected) {
+ Assert.assertTrue("successor failed for the wrong reason: " + expected,
+ expected.getMessage().contains("slot already in use"));
+ }
+
+ ff.clearPersistentFailure();
+ awaitCloseCompleted(predecessor);
+ predecessor = null;
+
+ successor = new CursorSendEngine(slot, segmentSize, manager);
+ Assert.assertEquals("successful retry must leave no ACKed frame to replay",
+ -1L, successor.publishedFsn());
+ successor.close();
+ Assert.assertTrue(successor.isCloseCompleted());
+ successor = null;
+ } catch (Throwable t) {
+ failure = t;
+ } finally {
+ if (failure != null && ff != null) {
+ // Let a retained predecessor converge before cleanup.
+ // The exact red assertion remains attached to failure.
+ ff.clearPersistentFailure();
+ }
+ failure = closeEngine(failure, successor);
+ failure = closeEngine(failure, predecessor);
+ failure = closeManager(failure, manager);
+ failure = freePayload(failure, payload);
+ failure = removeRoot(failure, root);
+ }
+ rethrow(failure);
+ }
+ });
+ }
+
+ @Test
+ public void testRecoveredSlotRejectsMissingWatermark() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ String root = Paths.get(System.getProperty("java.io.tmpdir"),
+ "qdb-missing-watermark-" + System.nanoTime()).toString();
+ String slot = root + "/slot";
+ SegmentManager manager = null;
+ CursorSendEngine predecessor = null;
+ CursorSendEngine successor = null;
+ long payload = 0;
+ Throwable failure = null;
+ try {
+ 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),
+ SegmentManager.UNLIMITED_TOTAL_BYTES, ff);
+ payload = Unsafe.malloc(32, MemoryTag.NATIVE_DEFAULT);
+ predecessor = new CursorSendEngine(slot, segmentSize, manager);
+ Unsafe.getUnsafe().setMemory(payload, 32, (byte) 7);
+ Assert.assertEquals(0L, predecessor.appendBlocking(payload, 32));
+ Assert.assertTrue(predecessor.acknowledge(0L));
+ ff.beginClose();
+ ff.blockSegmentRemove = true;
+ predecessor.close();
+ Assert.assertTrue(predecessor.isCloseCompleted());
+ predecessor = null;
+ Assert.assertTrue("precondition: ACKed segment residue must survive",
+ Files.exists(slot + "/sf-initial.sfa"));
+
+ ff.failWatermarkOpen = true;
+ try {
+ successor = new CursorSendEngine(slot, segmentSize, manager);
+ Assert.fail("recovered disk slot started without a usable ACK watermark");
+ } catch (IllegalStateException expected) {
+ Assert.assertTrue("successor failed for the wrong reason: " + expected,
+ expected.getMessage().contains("ack watermark"));
+ }
+
+ ff.failWatermarkOpen = false;
+ ff.blockSegmentRemove = false;
+ successor = new CursorSendEngine(slot, segmentSize, manager);
+ Assert.assertTrue(successor.wasRecoveredFromDisk());
+ Assert.assertTrue("successor exposes predecessor's ACKed frame",
+ successor.ackedFsn() >= successor.publishedFsn());
+ successor.close();
+ Assert.assertTrue(successor.isCloseCompleted());
+ successor = null;
+ } catch (Throwable t) {
+ failure = t;
+ } finally {
+ failure = closeEngine(failure, successor);
+ failure = closeEngine(failure, predecessor);
+ failure = closeManager(failure, manager);
+ failure = freePayload(failure, payload);
+ failure = removeRoot(failure, root);
+ }
+ rethrow(failure);
+ });
+ }
+
+ private static Throwable addCleanupFailure(Throwable failure, Throwable cleanupFailure) {
+ if (failure == null) {
+ return cleanupFailure;
+ }
+ if (failure != cleanupFailure) {
+ failure.addSuppressed(cleanupFailure);
+ }
+ return failure;
+ }
+
+ private static void awaitCloseCompleted(CursorSendEngine engine) {
+ long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
+ while (!engine.isCloseCompleted() && System.nanoTime() < deadline) {
+ Thread.yield();
+ }
+ Assert.assertTrue("close retry did not complete", engine.isCloseCompleted());
+ }
+
+ private static Throwable closeEngine(Throwable failure, CursorSendEngine engine) {
+ if (engine != null) {
+ try {
+ engine.close();
+ } catch (Throwable cleanupFailure) {
+ failure = addCleanupFailure(failure, cleanupFailure);
+ }
+ }
+ return failure;
+ }
+
+ private static Throwable closeManager(Throwable failure, SegmentManager manager) {
+ if (manager != null) {
+ try {
+ manager.close();
+ } catch (Throwable cleanupFailure) {
+ failure = addCleanupFailure(failure, cleanupFailure);
+ }
+ }
+ return failure;
+ }
+
+ private static Throwable freePayload(Throwable failure, long payload) {
+ if (payload != 0) {
+ try {
+ Unsafe.free(payload, 32, MemoryTag.NATIVE_DEFAULT);
+ } catch (Throwable cleanupFailure) {
+ failure = addCleanupFailure(failure, cleanupFailure);
+ }
+ }
+ return failure;
+ }
+
+ private static void removeRecursive(String dir) {
+ long find = Files.findFirst(dir);
+ if (find > 0) {
+ try {
+ int rc = 1;
+ while (rc > 0) {
+ String name = Files.utf8ToString(Files.findName(find));
+ if (name != null && !".".equals(name) && !"..".equals(name)) {
+ String child = dir + "/" + name;
+ if (!Files.remove(child)) {
+ removeRecursive(child);
+ }
+ }
+ rc = Files.findNext(find);
+ }
+ } finally {
+ Files.findClose(find);
+ }
+ }
+ Files.remove(dir);
+ }
+
+ private static Throwable removeRoot(Throwable failure, String root) {
+ try {
+ removeRecursive(root);
+ Assert.assertFalse("test directory was not removed: " + root, Files.exists(root));
+ } catch (Throwable cleanupFailure) {
+ failure = addCleanupFailure(failure, cleanupFailure);
+ }
+ return failure;
+ }
+
+ private static void rethrow(Throwable failure) {
+ if (failure != null) {
+ CursorSendEngineCrashConsistencyTest.throwUnchecked(failure);
+ }
+ }
+
+ @SuppressWarnings("unchecked")
+ private static void throwUnchecked(Throwable failure) throws T {
+ throw (T) failure;
+ }
+
+ private static final class CrashImageFilesFacade implements FilesFacade {
+ private final List events = new ArrayList<>();
+ private final int failAt;
+ private final String slot;
+ private boolean active;
+ private boolean blockSegmentRemove;
+ private boolean durableSegments = true;
+ private boolean durableWatermark;
+ private int eventIndex;
+ private boolean failWatermarkOpen;
+ private String persistentFailure;
+ private int watermarkFd = -1;
+
+ private CrashImageFilesFacade(String slot, int failAt) {
+ this.slot = slot;
+ this.failAt = failAt;
+ }
+
+ private void beginClose() {
+ active = true;
+ events.clear();
+ eventIndex = 0;
+ }
+
+ private void clearPersistentFailure() {
+ persistentFailure = null;
+ }
+
+ private boolean fail(String event) {
+ events.add(event);
+ return event.equals(persistentFailure) || eventIndex++ == failAt;
+ }
+
+ private void failPersistently(String event) {
+ active = true;
+ events.clear();
+ eventIndex = 0;
+ persistentFailure = event;
+ }
+
+ @Override
+ public boolean allocate(int fd, long size) { return INSTANCE.allocate(fd, size); }
+ @Override
+ public long allocNativePath(String path) { return INSTANCE.allocNativePath(path); }
+ @Override
+ public int close(int fd) { return INSTANCE.close(fd); }
+ @Override
+ public boolean exists(String path) { return INSTANCE.exists(path); }
+ @Override
+ public void findClose(long findPtr) { INSTANCE.findClose(findPtr); }
+ @Override
+ public long findFirst(String dir) { return INSTANCE.findFirst(dir); }
+ @Override
+ public long findName(long findPtr) { return INSTANCE.findName(findPtr); }
+ @Override
+ public int findNext(long findPtr) { return INSTANCE.findNext(findPtr); }
+ @Override
+ public int findType(long findPtr) { return INSTANCE.findType(findPtr); }
+ @Override
+ public void freeNativePath(long pathPtr) { INSTANCE.freeNativePath(pathPtr); }
+ @Override
+ public int fsync(int fd) {
+ return active && fd == watermarkFd && fail("watermark-fsync") ? -1 : INSTANCE.fsync(fd);
+ }
+ @Override
+ public int fsyncDir(String dir) {
+ if (active && slot.equals(dir)) {
+ if (fail("dir-fsync")) return -1;
+ if (events.contains("segment-remove")) {
+ durableSegments = false;
+ } else {
+ durableWatermark = true;
+ }
+ }
+ return INSTANCE.fsyncDir(dir);
+ }
+ @Override
+ public long length(int fd) { return INSTANCE.length(fd); }
+ @Override
+ public long length(String path) { return INSTANCE.length(path); }
+ @Override
+ public long length(long pathPtr) { return INSTANCE.length(pathPtr); }
+ @Override
+ public int lock(int fd) { return INSTANCE.lock(fd); }
+ @Override
+ public int mkdir(String path, int mode) { return INSTANCE.mkdir(path, mode); }
+ @Override
+ public int msync(long addr, long len, boolean async) {
+ return active && fail("watermark-msync") ? -1 : INSTANCE.msync(addr, len, async);
+ }
+ @Override
+ public int openCleanRW(String path) {
+ if (failWatermarkOpen && path.equals(slot + "/" + AckWatermark.FILE_NAME)) return -1;
+ int fd = INSTANCE.openCleanRW(path);
+ if (path.equals(slot + "/" + AckWatermark.FILE_NAME)) watermarkFd = fd;
+ return fd;
+ }
+ @Override
+ public int openCleanRW(long pathPtr) { return INSTANCE.openCleanRW(pathPtr); }
+ @Override
+ public int openRW(String path) {
+ if (failWatermarkOpen && path.equals(slot + "/" + AckWatermark.FILE_NAME)) return -1;
+ int fd = INSTANCE.openRW(path);
+ if (path.equals(slot + "/" + AckWatermark.FILE_NAME)) watermarkFd = fd;
+ return fd;
+ }
+ @Override
+ public int openRW(long pathPtr) { return INSTANCE.openRW(pathPtr); }
+ @Override
+ public long read(int fd, long addr, long len, long offset) {
+ return INSTANCE.read(fd, addr, len, offset);
+ }
+ @Override
+ public boolean remove(String path) {
+ if (active && path.endsWith(".sfa")) {
+ if (blockSegmentRemove || fail("segment-remove")) return false;
+ } else if (active && path.equals(slot + "/" + AckWatermark.FILE_NAME)) {
+ if (fail("watermark-remove")) return false;
+ }
+ return INSTANCE.remove(path);
+ }
+ @Override
+ public boolean remove(long pathPtr) { return INSTANCE.remove(pathPtr); }
+ @Override
+ public int rename(String oldPath, String newPath) { return INSTANCE.rename(oldPath, newPath); }
+ @Override
+ public boolean truncate(int fd, long size) { return INSTANCE.truncate(fd, size); }
+ @Override
+ public long write(int fd, long addr, long len, long offset) {
+ return INSTANCE.write(fd, addr, len, offset);
+ }
+ }
+}
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
new file mode 100644
index 00000000..4c59bcfe
--- /dev/null
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorSendEngineSlotReacquisitionTest.java
@@ -0,0 +1,852 @@
+/*******************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * 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.SegmentManager;
+import io.questdb.client.cutlass.qwp.client.sf.cursor.SegmentRing;
+import io.questdb.client.cutlass.qwp.client.sf.cursor.SlotLock;
+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.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+
+/**
+ * Engine-level regression for the shutdown hazard where
+ * {@link CursorSendEngine#close()} released the slot lock, closed the ring
+ * and watermark, and unlinked segment files while the shared
+ * {@link SegmentManager} worker was still mid service pass for the engine's
+ * ring. A replacement engine could acquire the same slot the moment the
+ * lock was released, after which the stale worker's abandon/trim path could
+ * unlink a segment path the replacement was actively writing through —
+ * store-and-forward data loss after restart.
+ *
+ * The fix makes {@code close()} run a quiescence barrier
+ * ({@link SegmentManager#awaitRingQuiescence}) after {@code deregister} and
+ * refuse to release any worker-reachable resource (ring, watermark, segment
+ * files, slot lock) until the barrier confirms the worker cannot touch the
+ * slot again. On barrier timeout an owned-manager engine hands cleanup
+ * ownership to the worker's exit path (see
+ * {@code SegmentManager.deferUntilWorkerExit}); a shared-manager engine
+ * deliberately leaks and a later {@code close()} retries the cleanup.
+ */
+public class CursorSendEngineSlotReacquisitionTest {
+
+ private String tmpDir;
+
+ @Before
+ public void setUp() {
+ tmpDir = Paths.get(System.getProperty("java.io.tmpdir"),
+ "qdb-engine-slot-reacq-" + System.nanoTime()).toString();
+ Assert.assertEquals(0, Files.mkdir(tmpDir, Files.DIR_MODE_DEFAULT));
+ }
+
+ @After
+ public void tearDown() {
+ if (tmpDir == null) return;
+ rmDirRecursive(tmpDir);
+ Files.remove(tmpDir);
+ }
+
+ /**
+ * The structural guarantee: while the manager worker is provably still
+ * inside a service pass for the engine's ring, repeated direct
+ * {@code close()} calls must NOT hand the slot to anyone else. With the
+ * duplicate-cleanup-owner branch reverted, the second close runs cleanup
+ * inline and the mid-test {@code SlotLock.acquire} probe succeeds. Once
+ * the pass finishes, its deferred cleanup must run exactly once and
+ * release the slot without another close retry.
+ */
+ @Test(timeout = 30_000L)
+ public void testRepeatedCloseRetainsSlotWhileWorkerIsMidServicePass() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ long segSize = MmapSegment.HEADER_SIZE + (MmapSegment.FRAME_HEADER_SIZE + 32);
+ 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));
+ CountDownLatch cleanupFinished = new CountDownLatch(1);
+ CountDownLatch workerBlocked = new CountDownLatch(1);
+ CountDownLatch releaseWorker = new CountDownLatch(1);
+ AtomicBoolean fired = new AtomicBoolean();
+ AtomicInteger cleanupCount = new AtomicInteger();
+ AtomicReference hookErr = new AtomicReference<>();
+ boolean managerClosed = false;
+ CursorSendEngine engine = null;
+ try {
+ manager.setAfterRingCleanupHook(() -> {
+ cleanupCount.incrementAndGet();
+ cleanupFinished.countDown();
+ });
+ manager.setBeforeInstallSyncHook(() -> {
+ if (!fired.compareAndSet(false, true)) return;
+ workerBlocked.countDown();
+ try {
+ if (!releaseWorker.await(20, TimeUnit.SECONDS)) {
+ hookErr.compareAndSet(null,
+ new AssertionError("timed out waiting for test to release worker"));
+ }
+ } catch (Throwable t) {
+ hookErr.compareAndSet(null, t);
+ }
+ });
+ manager.start();
+
+ // Shared manager: ownsManager=false, so engine close() cannot
+ // fall back on manager.close()'s join — the per-ring barrier
+ // is the only protection, which is exactly what we pin here.
+ engine = new CursorSendEngine(slot, segSize, manager);
+ Assert.assertTrue("worker never reached the install hook",
+ workerBlocked.await(5, TimeUnit.SECONDS));
+
+ // Barrier must time out fast: the worker is parked inside the
+ // service pass for this engine's ring.
+ manager.setWorkerJoinTimeoutMillis(50L);
+ engine.close();
+ Assert.assertFalse("incomplete close must remain observable to the owner",
+ engine.isCloseCompleted());
+
+ // Exercise CursorSendEngine.close() directly again while the
+ // same pass already owns deferredClose. Sender.close() cannot
+ // reach this branch because its second call is a no-op.
+ engine.close();
+ Assert.assertFalse("repeated close must not steal deferred cleanup ownership",
+ engine.isCloseCompleted());
+ Assert.assertEquals("cleanup ran while the worker pass was blocked",
+ 0, cleanupCount.get());
+
+ // The slot must still be locked: a replacement engine (or raw
+ // SlotLock) acquiring it now would race the stale worker.
+ try {
+ SlotLock probe = SlotLock.acquire(slot);
+ probe.close();
+ Assert.fail("engine.close() released the slot lock while the manager "
+ + "worker was still mid service pass for its ring — a "
+ + "replacement engine could acquire the slot and have its "
+ + "segment files unlinked by the stale worker");
+ } catch (Exception expected) {
+ // good — slot retained.
+ }
+
+ // Let the worker finish its pass (it abandons the spare: the
+ // ring was deregistered by the close attempt above).
+ releaseWorker.countDown();
+ Assert.assertTrue("ring pass did not finish deferred cleanup",
+ cleanupFinished.await(5, TimeUnit.SECONDS));
+ Assert.assertEquals("ring-pass cleanup must run exactly once",
+ 1, cleanupCount.get());
+ Assert.assertTrue("ring-pass cleanup must report complete cleanup",
+ engine.isCloseCompleted());
+ engine = null;
+
+ try (SlotLock probe = SlotLock.acquire(slot)) {
+ Assert.assertNotNull("slot must be acquirable after a completed close", probe);
+ } catch (Exception e) {
+ throw new AssertionError("ring-pass cleanup did not release the slot lock", e);
+ }
+
+ manager.close();
+ managerClosed = true;
+ if (hookErr.get() != null) {
+ throw new AssertionError("install hook failed", hookErr.get());
+ }
+ } finally {
+ manager.setAfterRingCleanupHook(null);
+ manager.setBeforeInstallSyncHook(null);
+ releaseWorker.countDown();
+ if (!managerClosed) {
+ manager.close();
+ }
+ }
+ });
+ }
+
+ /**
+ * Owned-manager twin of {@link #testRepeatedCloseRetainsSlotWhileWorkerIsMidServicePass}:
+ * the ONLY construction shape production uses (Sender.build, BackgroundDrainer,
+ * QwpWebSocketSender.connect all own their manager). The owned close path does
+ * not run the per-ring barrier at all — it relies on {@code manager.close()}'s
+ * bounded join and the {@code isWorkerReaped()} check. If that check regressed
+ * to report quiescence unconditionally (or {@code isWorkerReaped()} itself
+ * returned true while the worker is alive), close() would release the slot
+ * 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.
+ */
+ @Test(timeout = 30_000L)
+ public void testOwnedEngineCloseRetainsSlotWhileWorkerIsMidServicePass() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ final int payloadLen = 32;
+ long segSize = MmapSegment.HEADER_SIZE + (MmapSegment.FRAME_HEADER_SIZE + payloadLen);
+ String slot = tmpDir + "/owned-parked-slot";
+ CountDownLatch workerBlocked = new CountDownLatch(1);
+ CountDownLatch releaseWorker = new CountDownLatch(1);
+ AtomicBoolean fired = new AtomicBoolean();
+ AtomicReference hookErr = new AtomicReference<>();
+ // Production shape: private, owned manager (ownsManager=true).
+ CursorSendEngine engine = new CursorSendEngine(slot, segSize);
+ 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.
+ 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(() -> {
+ if (!fired.compareAndSet(false, true)) return;
+ workerBlocked.countDown();
+ try {
+ if (!releaseWorker.await(20, TimeUnit.SECONDS)) {
+ hookErr.compareAndSet(null,
+ new AssertionError("timed out waiting for test to release worker"));
+ }
+ } catch (Throwable t) {
+ hookErr.compareAndSet(null, t);
+ }
+ });
+
+ // 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.
+ 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",
+ workerBlocked.await(5, TimeUnit.SECONDS));
+
+ // Phase 3: owned close with the worker provably mid service
+ // pass. manager.close()'s 50 ms join times out, the worker is
+ // not reaped, and close() must retain every worker-reachable
+ // resource — above all the slot flock.
+ manager.setWorkerJoinTimeoutMillis(50L);
+ engine.close();
+ Assert.assertFalse("incomplete owned close must remain observable to the owner",
+ engine.isCloseCompleted());
+ try {
+ SlotLock probe = SlotLock.acquire(slot);
+ probe.close();
+ Assert.fail("owned engine.close() released the slot lock while its manager "
+ + "worker was still mid service pass — a replacement engine could "
+ + "acquire the slot and have its segment files unlinked by the "
+ + "stale worker (the production SF-data-loss hazard)");
+ } catch (Exception expected) {
+ // good — slot retained.
+ }
+
+ // Phase 4: release the worker (it abandons the spare: the ring
+ // was deregistered by the close attempt) and retry. The join
+ // now reaps the worker and the full cleanup must complete.
+ releaseWorker.countDown();
+ manager.setWorkerJoinTimeoutMillis(TimeUnit.SECONDS.toMillis(60));
+ engine.close();
+ Assert.assertTrue("retried owned close must report complete cleanup",
+ engine.isCloseCompleted());
+ try (SlotLock probe = SlotLock.acquire(slot)) {
+ Assert.assertNotNull("slot must be acquirable after a completed close", probe);
+ } catch (Exception e) {
+ throw new AssertionError("retried owned close() did not release the slot lock", e);
+ }
+ if (hookErr.get() != null) {
+ throw new AssertionError("install hook failed", hookErr.get());
+ }
+ } finally {
+ Unsafe.free(buf, payloadLen, MemoryTag.NATIVE_DEFAULT);
+ manager.setBeforeInstallSyncHook(null);
+ releaseWorker.countDown();
+ manager.setWorkerJoinTimeoutMillis(TimeUnit.SECONDS.toMillis(60));
+ try {
+ engine.close();
+ } catch (Throwable ignored) {
+ }
+ }
+ });
+ }
+
+ /**
+ * The ownership handoff (owned manager): when close() cannot confirm
+ * worker quiescence within the bounded join, the terminal cleanup (ring,
+ * watermark, flock release) transfers to the worker's exit path — the
+ * worker is provably the last thread able to touch the slot directory.
+ * Once the parked pass is released the worker must run the cleanup
+ * itself, WITHOUT any retried {@code close()}: {@code isCloseCompleted()}
+ * flips true and the slot becomes acquirable again. This is what lets a
+ * pool recover a retired slot instead of losing its capacity until
+ * process exit.
+ */
+ @Test(timeout = 30_000L)
+ public void testOwnedEngineCloseHandsCleanupToWorkerExit() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ final int payloadLen = 32;
+ long segSize = MmapSegment.HEADER_SIZE + (MmapSegment.FRAME_HEADER_SIZE + payloadLen);
+ String slot = tmpDir + "/owned-handoff-slot";
+ CountDownLatch workerBlocked = new CountDownLatch(1);
+ CountDownLatch releaseWorker = new CountDownLatch(1);
+ AtomicBoolean fired = new AtomicBoolean();
+ AtomicReference hookErr = new AtomicReference<>();
+ // Production shape: private, owned manager (ownsManager=true).
+ CursorSendEngine engine = new CursorSendEngine(slot, segSize);
+ 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
+ // 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(() -> {
+ if (!fired.compareAndSet(false, true)) return;
+ workerBlocked.countDown();
+ try {
+ if (!releaseWorker.await(20, TimeUnit.SECONDS)) {
+ hookErr.compareAndSet(null,
+ new AssertionError("timed out waiting for test to release worker"));
+ }
+ } catch (Throwable t) {
+ hookErr.compareAndSet(null, t);
+ }
+ });
+
+ // Phase 2: fill the active segment and rotate onto the spare;
+ // the worker's next tick re-enters the install pass and parks.
+ Unsafe.getUnsafe().putLong(buf, 0L);
+ Assert.assertEquals(0L, engine.appendBlocking(buf, payloadLen));
+ Assert.assertEquals(1L, engine.appendBlocking(buf, payloadLen));
+ Assert.assertTrue("worker never re-entered a spare-install pass",
+ workerBlocked.await(5, TimeUnit.SECONDS));
+
+ // Phase 3: owned close with the worker provably mid-pass. The
+ // bounded join times out; cleanup ownership is handed to the
+ // worker's exit path. Every worker-reachable resource — above
+ // all the slot flock — must still be retained at this point.
+ manager.setWorkerJoinTimeoutMillis(50L);
+ engine.close();
+ Assert.assertFalse("close must stay incomplete while the worker holds the handoff",
+ engine.isCloseCompleted());
+ try {
+ SlotLock probe = SlotLock.acquire(slot);
+ probe.close();
+ Assert.fail("engine.close() released the slot lock while its manager worker "
+ + "was still mid service pass — the handoff must not weaken the "
+ + "quiescence gate");
+ } catch (Exception expected) {
+ // good — slot retained while the worker can still touch it.
+ }
+
+ // Phase 4 — the contract under test: release the worker and do
+ // NOT retry close(). The worker finishes its pass, exits, and
+ // runs the deferred cleanup itself, flipping isCloseCompleted
+ // and releasing the flock with no further caller action.
+ releaseWorker.countDown();
+ long cleanupDeadlineNs = System.nanoTime() + TimeUnit.SECONDS.toNanos(10);
+ while (!engine.isCloseCompleted()) {
+ if (System.nanoTime() > cleanupDeadlineNs) {
+ throw new AssertionError(
+ "deferred cleanup never ran on manager-worker exit — the slot "
+ + "would stay retired until process exit");
+ }
+ Thread.sleep(1);
+ }
+ try (SlotLock probe = SlotLock.acquire(slot)) {
+ Assert.assertNotNull("slot must be acquirable after the worker-exit cleanup", probe);
+ } catch (Exception e) {
+ throw new AssertionError("worker-exit cleanup did not release the slot lock", e);
+ }
+ if (hookErr.get() != null) {
+ throw new AssertionError("install hook failed", hookErr.get());
+ }
+ } finally {
+ Unsafe.free(buf, payloadLen, MemoryTag.NATIVE_DEFAULT);
+ manager.setBeforeInstallSyncHook(null);
+ releaseWorker.countDown();
+ manager.setWorkerJoinTimeoutMillis(TimeUnit.SECONDS.toMillis(60));
+ try {
+ engine.close();
+ } catch (Throwable ignored) {
+ }
+ }
+ });
+ }
+
+ /**
+ * Registration-failure twin of
+ * {@link #testOwnedEngineCloseHandsCleanupToWorkerExit}: when
+ * {@code deferUntilWorkerExit} itself throws (allocation failure while
+ * building the handoff), close() must NOT mistake the swallowed throw
+ * for "worker already exited" and run the terminal cleanup inline — the
+ * worker is provably still mid service pass, so releasing the ring,
+ * watermark or slot flock here is the original stale-worker UAF/data-loss
+ * hazard. Every worker-reachable resource must be retained and the close
+ * must stay incomplete; a retried close() after the worker exits
+ * converges and releases the slot.
+ */
+ @Test(timeout = 30_000L)
+ public void testOwnedEngineCloseRetainsSlotWhenHandoffRegistrationFails() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ final int payloadLen = 32;
+ long segSize = MmapSegment.HEADER_SIZE + (MmapSegment.FRAME_HEADER_SIZE + payloadLen);
+ String slot = tmpDir + "/owned-regfail-slot";
+ CountDownLatch workerBlocked = new CountDownLatch(1);
+ CountDownLatch releaseWorker = new CountDownLatch(1);
+ AtomicBoolean fired = new AtomicBoolean();
+ AtomicReference hookErr = new AtomicReference<>();
+ // Production shape: private, owned manager (ownsManager=true).
+ CursorSendEngine engine = new CursorSendEngine(slot, segSize);
+ 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
+ // 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(() -> {
+ if (!fired.compareAndSet(false, true)) return;
+ workerBlocked.countDown();
+ try {
+ if (!releaseWorker.await(20, TimeUnit.SECONDS)) {
+ hookErr.compareAndSet(null,
+ new AssertionError("timed out waiting for test to release worker"));
+ }
+ } catch (Throwable t) {
+ hookErr.compareAndSet(null, t);
+ }
+ });
+
+ // Phase 2: fill the active segment and rotate onto the spare;
+ // the worker's next tick re-enters the install pass and parks.
+ Unsafe.getUnsafe().putLong(buf, 0L);
+ Assert.assertEquals(0L, engine.appendBlocking(buf, payloadLen));
+ Assert.assertEquals(1L, engine.appendBlocking(buf, payloadLen));
+ Assert.assertTrue("worker never re-entered a spare-install pass",
+ workerBlocked.await(5, TimeUnit.SECONDS));
+
+ // Phase 3: make the handoff registration throw — simulating
+ // an OutOfMemoryError allocating the cleanup lambda/list —
+ // with the worker provably still mid service pass. close()
+ // must retain everything: no inline finishClose, no flock
+ // release, closeCompleted stays false.
+ manager.setBeforeExitCleanupRegistrationHook(() -> {
+ throw new OutOfMemoryError("simulated allocation failure registering exit cleanup");
+ });
+ manager.setWorkerJoinTimeoutMillis(50L);
+ engine.close();
+ Assert.assertFalse("close must stay incomplete when handoff registration fails",
+ engine.isCloseCompleted());
+ try {
+ SlotLock probe = SlotLock.acquire(slot);
+ probe.close();
+ Assert.fail("engine.close() released the slot lock after a failed handoff "
+ + "registration while the manager worker was still mid service "
+ + "pass — the swallowed throw was mistaken for proof the worker "
+ + "exited (stale-worker UAF/data-loss hazard)");
+ } catch (Exception expected) {
+ // good — slot retained while the worker can still touch it.
+ }
+
+ // Phase 4: clear the fault, release the worker (its loop was
+ // already stopped by the close attempt, so it exits), and
+ // retry close(). The retry must converge via isWorkerReaped()
+ // and release the slot.
+ manager.setBeforeExitCleanupRegistrationHook(null);
+ releaseWorker.countDown();
+ manager.setWorkerJoinTimeoutMillis(TimeUnit.SECONDS.toMillis(60));
+ engine.close();
+ Assert.assertTrue("retried close must report complete cleanup",
+ engine.isCloseCompleted());
+ try (SlotLock probe = SlotLock.acquire(slot)) {
+ Assert.assertNotNull("slot must be acquirable after the retried close", probe);
+ } catch (Exception e) {
+ throw new AssertionError("retried close() did not release the slot lock", e);
+ }
+ if (hookErr.get() != null) {
+ throw new AssertionError("install hook failed", hookErr.get());
+ }
+ } finally {
+ Unsafe.free(buf, payloadLen, MemoryTag.NATIVE_DEFAULT);
+ manager.setBeforeInstallSyncHook(null);
+ manager.setBeforeExitCleanupRegistrationHook(null);
+ releaseWorker.countDown();
+ manager.setWorkerJoinTimeoutMillis(TimeUnit.SECONDS.toMillis(60));
+ try {
+ engine.close();
+ } catch (Throwable ignored) {
+ }
+ }
+ });
+ }
+
+ /**
+ * Exactly-once contention on the terminal-cleanup claim
+ * ({@code terminalCleanupClaimed} CAS): after a timed-out owned close
+ * handed cleanup to the worker's exit path, a retried {@code close()}
+ * that races the worker MID-{@code finishClose} must neither re-run the
+ * terminal cleanup (double munmap / double flock release) nor block on
+ * the worker, nor publish completion on the worker's behalf. The race
+ * window is made deterministic by parking the worker inside
+ * {@code finishClose} (via {@code beforeFlockReleaseHook}) while the
+ * retried close() converges through {@code isWorkerReaped()} and loses
+ * the CAS.
+ */
+ @Test(timeout = 30_000L)
+ public void testTerminalCleanupRunsExactlyOnceWhenRetriedCloseRacesWorkerHandoff() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ final int payloadLen = 32;
+ long segSize = MmapSegment.HEADER_SIZE + (MmapSegment.FRAME_HEADER_SIZE + payloadLen);
+ String slot = tmpDir + "/cas-contention-slot";
+ CountDownLatch workerBlocked = new CountDownLatch(1);
+ CountDownLatch releaseWorker = new CountDownLatch(1);
+ CountDownLatch inFinishClose = new CountDownLatch(1);
+ CountDownLatch releaseFinishClose = new CountDownLatch(1);
+ AtomicBoolean fired = new AtomicBoolean();
+ AtomicInteger finishCloseRuns = new AtomicInteger();
+ AtomicReference hookErr = new AtomicReference<>();
+ // Production shape: private, owned manager (ownsManager=true).
+ CursorSendEngine engine = new CursorSendEngine(slot, segSize);
+ 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.
+ 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(() -> {
+ if (!fired.compareAndSet(false, true)) return;
+ workerBlocked.countDown();
+ try {
+ if (!releaseWorker.await(20, TimeUnit.SECONDS)) {
+ hookErr.compareAndSet(null,
+ new AssertionError("timed out waiting for test to release worker"));
+ }
+ } catch (Throwable t) {
+ hookErr.compareAndSet(null, t);
+ }
+ });
+ // Counts terminal-cleanup executions and parks the FIRST one
+ // (the worker's deferred cleanup) mid-finishClose, before the
+ // flock release — the exact window a retried close() races.
+ engine.setBeforeFlockReleaseHook(() -> {
+ if (finishCloseRuns.incrementAndGet() == 1) {
+ inFinishClose.countDown();
+ try {
+ if (!releaseFinishClose.await(20, TimeUnit.SECONDS)) {
+ hookErr.compareAndSet(null, new AssertionError(
+ "timed out waiting for test to release finishClose"));
+ }
+ } catch (Throwable t) {
+ hookErr.compareAndSet(null, t);
+ }
+ }
+ });
+
+ // Phase 2: rotate onto the spare so the worker parks 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",
+ workerBlocked.await(5, TimeUnit.SECONDS));
+
+ // Phase 3: timed-out close — cleanup ownership transfers to
+ // the worker's exit path.
+ manager.setWorkerJoinTimeoutMillis(50L);
+ engine.close();
+ Assert.assertFalse("close must stay incomplete while the worker holds the handoff",
+ engine.isCloseCompleted());
+
+ // Phase 4: release the pass. The worker exits its loop, wins
+ // the cleanup CAS, enters finishClose and parks in the hook —
+ // mid-cleanup, flock still held, completion unpublished.
+ releaseWorker.countDown();
+ Assert.assertTrue("worker never entered the deferred finishClose",
+ inFinishClose.await(10, TimeUnit.SECONDS));
+ Assert.assertEquals(1, finishCloseRuns.get());
+ Assert.assertFalse("completion must not be observable mid-finishClose",
+ engine.isCloseCompleted());
+
+ // Phase 5 — the contention under test: a retried close() while
+ // the worker is parked INSIDE finishClose. The worker loop has
+ // already exited (workerLoopExited=true precedes the deferred
+ // cleanups), so the short bounded join reaps the manager state
+ // and close() converges to the CAS — which it must LOSE,
+ // returning promptly without touching ring/watermark/flock.
+ engine.close();
+ Assert.assertEquals(
+ "retried close() re-ran the terminal cleanup while the worker's "
+ + "deferred cleanup was mid-flight — ring/watermark/flock would "
+ + "be double-released",
+ 1, finishCloseRuns.get());
+ Assert.assertFalse("retried close() must not publish completion on the worker's behalf",
+ engine.isCloseCompleted());
+ try {
+ SlotLock probe = SlotLock.acquire(slot);
+ probe.close();
+ Assert.fail("slot lock observable as released while the worker was still "
+ + "parked before its flock release");
+ } catch (Exception expected) {
+ // good — flock still held by the parked cleanup.
+ }
+
+ // Phase 6: let the worker finish. Completion publishes, the
+ // slot becomes acquirable, and the cleanup count stays at 1.
+ releaseFinishClose.countDown();
+ long cleanupDeadlineNs = System.nanoTime() + TimeUnit.SECONDS.toNanos(10);
+ while (!engine.isCloseCompleted()) {
+ if (System.nanoTime() > cleanupDeadlineNs) {
+ throw new AssertionError("deferred cleanup never completed after release");
+ }
+ Thread.sleep(1);
+ }
+ Assert.assertEquals(1, finishCloseRuns.get());
+ try (SlotLock probe = SlotLock.acquire(slot)) {
+ Assert.assertNotNull("slot must be acquirable after the worker-exit cleanup", probe);
+ }
+ // A final close() takes the fast no-op path.
+ engine.close();
+ Assert.assertEquals("post-completion close() must be a no-op",
+ 1, finishCloseRuns.get());
+ if (hookErr.get() != null) {
+ throw new AssertionError("hook failed", hookErr.get());
+ }
+ } finally {
+ Unsafe.free(buf, payloadLen, MemoryTag.NATIVE_DEFAULT);
+ manager.setBeforeInstallSyncHook(null);
+ engine.setBeforeFlockReleaseHook(null);
+ releaseWorker.countDown();
+ releaseFinishClose.countDown();
+ manager.setWorkerJoinTimeoutMillis(TimeUnit.SECONDS.toMillis(60));
+ try {
+ engine.close();
+ } catch (Throwable ignored) {
+ }
+ }
+ });
+ }
+
+ /**
+ * Memory-mode twin of {@link #testOwnedEngineCloseHandsCleanupToWorkerExit}:
+ * {@code sfDir == null}, so there is no slot lock, no watermark and no
+ * segment files — but the ring's malloc'd native segments are still
+ * worker-reachable, so the timed-out close must take the same handoff
+ * path with every SF-only resource null. Pins that (a) the handoff
+ * branch tolerates null slotLock/watermark/sfDir without NPE, (b) the
+ * close stays incomplete while the worker can still touch the ring, and
+ * (c) the worker-exit cleanup completes the close and frees the ring's
+ * native memory (assertMemoryLeak is the leak oracle here).
+ */
+ @Test(timeout = 30_000L)
+ public void testMemoryModeOwnedCloseHandsCleanupToWorkerExit() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ final int payloadLen = 32;
+ long segSize = MmapSegment.HEADER_SIZE + (MmapSegment.FRAME_HEADER_SIZE + payloadLen);
+ CountDownLatch workerBlocked = new CountDownLatch(1);
+ CountDownLatch releaseWorker = new CountDownLatch(1);
+ AtomicBoolean fired = new AtomicBoolean();
+ AtomicReference hookErr = new AtomicReference<>();
+ // Memory mode: null sfDir, private owned manager — the exact
+ // shape non-SF async ingest uses.
+ CursorSendEngine engine = new CursorSendEngine(null, segSize);
+ 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.
+ 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(() -> {
+ if (!fired.compareAndSet(false, true)) return;
+ workerBlocked.countDown();
+ try {
+ if (!releaseWorker.await(20, TimeUnit.SECONDS)) {
+ hookErr.compareAndSet(null,
+ new AssertionError("timed out waiting for test to release worker"));
+ }
+ } catch (Throwable t) {
+ hookErr.compareAndSet(null, t);
+ }
+ });
+
+ // Phase 2: rotate onto the spare; the worker's next tick
+ // re-enters the (in-memory) install pass and parks.
+ Unsafe.getUnsafe().putLong(buf, 0L);
+ Assert.assertEquals(0L, engine.appendBlocking(buf, payloadLen));
+ Assert.assertEquals(1L, engine.appendBlocking(buf, payloadLen));
+ Assert.assertTrue("worker never re-entered a spare-install pass",
+ workerBlocked.await(5, TimeUnit.SECONDS));
+
+ // Phase 3: timed-out memory-mode close. The worker can still
+ // touch the ring's native memory, so the close must hand off
+ // and stay incomplete — releasing the ring here would be a
+ // use-after-free on the worker's install path.
+ manager.setWorkerJoinTimeoutMillis(50L);
+ engine.close();
+ Assert.assertFalse(
+ "memory-mode close must stay incomplete while the worker is mid service pass",
+ engine.isCloseCompleted());
+
+ // Phase 4: release the worker; its exit path must run the
+ // deferred cleanup (null slotLock/watermark/sfDir) and flip
+ // completion with no further caller action.
+ releaseWorker.countDown();
+ long cleanupDeadlineNs = System.nanoTime() + TimeUnit.SECONDS.toNanos(10);
+ while (!engine.isCloseCompleted()) {
+ if (System.nanoTime() > cleanupDeadlineNs) {
+ throw new AssertionError(
+ "deferred memory-mode cleanup never ran on manager-worker exit — "
+ + "the ring's native segments would leak for the process lifetime");
+ }
+ Thread.sleep(1);
+ }
+ if (hookErr.get() != null) {
+ throw new AssertionError("install hook failed", hookErr.get());
+ }
+ } finally {
+ Unsafe.free(buf, payloadLen, MemoryTag.NATIVE_DEFAULT);
+ manager.setBeforeInstallSyncHook(null);
+ releaseWorker.countDown();
+ manager.setWorkerJoinTimeoutMillis(TimeUnit.SECONDS.toMillis(60));
+ try {
+ engine.close();
+ } catch (Throwable ignored) {
+ }
+ }
+ });
+ }
+
+ /**
+ * An engine that owns its manager must use the whole-manager stop/join as
+ * its only quiescence barrier. Calling the per-ring barrier first would
+ * give a stuck worker two independent timeout budgets.
+ */
+ @Test(timeout = 30_000L)
+ public void testOwnedManagerCloseSkipsPerRingQuiescenceWait() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ String slot = tmpDir + "/owned-slot";
+ CursorSendEngine engine = new CursorSendEngine(slot, 4L * 1024 * 1024);
+ SegmentManager manager = engine.getManagerForTesting();
+ AtomicBoolean perRingAwaited = new AtomicBoolean();
+ try {
+ manager.setBeforeRingQuiescenceAwaitHook(() -> perRingAwaited.set(true));
+ engine.close();
+ Assert.assertTrue("owned engine close did not complete", engine.isCloseCompleted());
+ Assert.assertFalse("owned engine close spent a separate per-ring wait budget",
+ perRingAwaited.get());
+ } finally {
+ manager.setBeforeRingQuiescenceAwaitHook(null);
+ engine.close();
+ }
+ });
+ }
+
+ /**
+ * Plain-positive path: after a normal close (worker quiesces promptly),
+ * a second engine must be able to acquire and use the same slot.
+ */
+ @Test(timeout = 30_000L)
+ public void testSameSlotReacquirableAfterNormalClose() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ String slot = tmpDir + "/slot";
+ CursorSendEngine first = new CursorSendEngine(slot, 4L * 1024 * 1024);
+ first.close();
+ CursorSendEngine second = new CursorSendEngine(slot, 4L * 1024 * 1024);
+ try {
+ Assert.assertFalse("fully-drained close must leave no segments to recover",
+ second.wasRecoveredFromDisk());
+ } finally {
+ second.close();
+ }
+ });
+ }
+
+ private static void rmDirRecursive(String dir) {
+ if (!Files.exists(dir)) return;
+ long find = Files.findFirst(dir);
+ if (find <= 0) return;
+ 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);
+ Files.remove(child);
+ }
+ }
+ rc = Files.findNext(find);
+ }
+ } finally {
+ Files.findClose(find);
+ }
+ }
+}
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 f4de1ff2..0cf5ab69 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
@@ -28,6 +28,7 @@
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.MmapSegmentException;
import io.questdb.client.cutlass.qwp.client.sf.cursor.SegmentManager;
import io.questdb.client.cutlass.qwp.client.sf.cursor.SlotLock;
import io.questdb.client.std.Files;
@@ -104,6 +105,55 @@ public void testAcknowledgePropagatesToRing() throws Exception {
});
}
+ @Test
+ public void testCheckDurabilitySurfacesLatchedFailureToSenderEntryPoints() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ // QwpWebSocketSender.flushAndGetSequence()/awaitAckedFsn() call
+ // engine.checkDurability() as their FIRST durability act, so this
+ // delegation seam is what stands between a latched periodic
+ // data-barrier failure and a producer that keeps publishing into
+ // an unsyncable slot. It had zero test references before this
+ // pin. Contract: quiet when clean, throws the LATCHED instance
+ // (not a copy) on every call until the manager's healed pass
+ // clears it -- callers poll it, so it must be repeatable, not
+ // one-shot.
+ try (CursorSendEngine engine = new CursorSendEngine(tmpDir, 4096)) {
+ engine.checkDurability(); // clean: must not throw
+ MmapSegmentException failure = new MmapSegmentException("injected data-sync failure");
+ engine.getRingForTesting().recordDurabilityFailureForTesting(failure);
+ for (int i = 0; i < 2; i++) {
+ try {
+ engine.checkDurability();
+ fail("latched durability failure must surface on call #" + i);
+ } catch (MmapSegmentException expected) {
+ assertTrue("the latched instance itself must surface",
+ expected == failure);
+ }
+ }
+ }
+ });
+ }
+
+ @Test
+ public void testAppendChecksLatchedDurabilityFailureBeforePublishing() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ long buf = Unsafe.malloc(16, MemoryTag.NATIVE_DEFAULT);
+ try (CursorSendEngine engine = new CursorSendEngine(tmpDir, 4096)) {
+ MmapSegmentException failure = new MmapSegmentException("injected data-sync failure");
+ engine.getRingForTesting().recordDurabilityFailureForTesting(failure);
+ try {
+ engine.appendBlocking(buf, 16);
+ fail("expected latched durability failure");
+ } catch (MmapSegmentException expected) {
+ assertTrue(expected == failure);
+ }
+ assertEquals(-1L, engine.publishedFsn());
+ } finally {
+ Unsafe.free(buf, 16, MemoryTag.NATIVE_DEFAULT);
+ }
+ });
+ }
+
@Test
public void testAppendBlockingNeverFailsUnderManagerSupply() throws Exception {
TestUtils.assertMemoryLeak(() -> {
@@ -197,6 +247,25 @@ public void testCloseIsIdempotent() throws Exception {
});
}
+ @Test
+ public void testCallbackCreationFailurePrecedesOwnedManagerResources() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ CursorSendEngine.setBeforeDeferredCloseCreationHook(() -> {
+ throw new OutOfMemoryError("simulated bound callback allocation failure");
+ });
+ try {
+ try {
+ new CursorSendEngine(tmpDir, 4096);
+ fail("expected callback allocation failure");
+ } catch (OutOfMemoryError expected) {
+ assertEquals("simulated bound callback allocation failure", expected.getMessage());
+ }
+ } finally {
+ CursorSendEngine.setBeforeDeferredCloseCreationHook(null);
+ }
+ });
+ }
+
@Test
public void testConstructorFailureAfterOwnedManagerStartCleansResources() throws Exception {
TestUtils.assertMemoryLeak(() -> {
@@ -381,6 +450,53 @@ public void testManagerPersistedWatermarkSurvivesRestart() throws Exception {
});
}
+ @Test
+ public void testPeriodicRotationWaitsForDurablePredecessor() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ long segmentSize = MmapSegment.HEADER_SIZE
+ + 2L * (MmapSegment.FRAME_HEADER_SIZE + 64L);
+ long buf = Unsafe.malloc(64, MemoryTag.NATIVE_DEFAULT);
+ CursorSendEngine engine = new CursorSendEngine(
+ tmpDir, segmentSize, segmentSize * 4L,
+ TimeUnit.SECONDS.toNanos(5), TimeUnit.HOURS.toNanos(1));
+ try {
+ assertEquals(0L, engine.appendBlocking(buf, 64));
+ assertEquals(1L, engine.appendBlocking(buf, 64));
+ // The third append needs rotation. Its predecessor is dirty and
+ // the periodic deadline is an hour away, so only the explicit
+ // rotation request can make progress.
+ assertEquals(2L, engine.appendBlocking(buf, 64));
+ ObjList sealed = engine.getRingForTesting().getSealedSegments();
+ assertEquals(1, sealed.size());
+ assertTrue(sealed.getQuick(0).isPublishedDurable());
+ MmapSegment active = engine.getRingForTesting().getActive();
+ assertFalse(active.isPublishedDurable());
+ engine.close();
+ assertTrue("close must sync the unacknowledged active", active.isPublishedDurable());
+ } finally {
+ engine.close();
+ Unsafe.free(buf, 64, MemoryTag.NATIVE_DEFAULT);
+ }
+ });
+ }
+
+ @Test
+ public void testPeriodicValidationPrecedesOwnedManagerAllocation() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ try {
+ new CursorSendEngine(
+ null,
+ 4096L,
+ 8192L,
+ CursorSendEngine.DEFAULT_APPEND_DEADLINE_NANOS,
+ 1L);
+ fail("expected periodic memory-mode validation failure");
+ } catch (IllegalArgumentException expected) {
+ assertTrue(expected.getMessage().contains("requires disk store-and-forward mode"));
+ }
+ });
+ }
+
@Test
public void testRestartIntoNonEmptySfDirContinuesFsnSequence() throws Exception {
TestUtils.assertMemoryLeak(() -> {
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
new file mode 100644
index 00000000..20039fa5
--- /dev/null
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopBlockedSendCloseTest.java
@@ -0,0 +1,263 @@
+/*+*****************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * Copyright (c) 2014-2019 Appsicle
+ * Copyright (c) 2019-2026 QuestDB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ ******************************************************************************/
+
+package io.questdb.client.test.cutlass.qwp.client.sf.cursor;
+
+import io.questdb.client.DefaultHttpClientConfiguration;
+import io.questdb.client.cutlass.http.client.WebSocketClient;
+import io.questdb.client.cutlass.line.LineSenderException;
+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.MemoryTag;
+import io.questdb.client.std.Unsafe;
+import io.questdb.client.test.tools.TestUtils;
+import org.junit.Assert;
+import org.junit.Test;
+
+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.atomic.AtomicReference;
+
+public class CursorWebSocketSendLoopBlockedSendCloseTest {
+
+ @Test(timeout = 30_000L)
+ public void testCloseBreaksBlockedSendBeforeJoiningWorker() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ BlockingSendClient client = new BlockingSendClient(true);
+ CursorSendEngine engine = new CursorSendEngine(null, 64 * 1024);
+ CursorWebSocketSendLoop loop = new CursorWebSocketSendLoop(
+ client,
+ engine,
+ 0L,
+ CursorWebSocketSendLoop.DEFAULT_PARK_NANOS,
+ null,
+ 100L,
+ 1_000L,
+ 5_000L,
+ false
+ );
+ long payload = Unsafe.malloc(16, MemoryTag.NATIVE_DEFAULT);
+ Thread closer = null;
+ AtomicReference closeFailure = new AtomicReference<>();
+ try {
+ Unsafe.getUnsafe().putLong(payload, 0x0102030405060708L);
+ Unsafe.getUnsafe().putLong(payload + 8, 0x1112131415161718L);
+ Assert.assertEquals(0L, engine.appendBlocking(payload, 16));
+ loop.start();
+ Assert.assertTrue("I/O worker never entered the blocking send",
+ client.sendEntered.await(5, TimeUnit.SECONDS));
+
+ closer = new Thread(() -> {
+ try {
+ loop.close();
+ } catch (Throwable t) {
+ closeFailure.set(t);
+ }
+ }, "blocked-send-closer");
+ closer.start();
+
+ Assert.assertTrue("close did not break the traffic path before joining",
+ client.trafficClosed.await(5, TimeUnit.SECONDS));
+ Assert.assertEquals("traffic path must close exactly once", 1, client.trafficCloseCount.get());
+ Assert.assertEquals("the closer must break traffic, not the I/O worker",
+ closer, client.trafficCloseThread.get());
+ Assert.assertTrue("blocked send did not observe traffic-path closure",
+ client.sendExited.await(5, TimeUnit.SECONDS));
+
+ closer.join(TimeUnit.SECONDS.toMillis(5));
+ Assert.assertFalse("close did not join the I/O worker", closer.isAlive());
+ Assert.assertNull("close failed", closeFailure.get());
+ Assert.assertNull("ordinary close must not manufacture a terminal error", loop.getTerminalError());
+
+ Thread ioThread = client.sendThread.get();
+ Assert.assertNotNull(ioThread);
+ // close() returns when the worker counts shutdownLatch down --
+ // the worker's last action before its exit tail -- so the
+ // thread can be observably alive for a scheduling beat after
+ // close() returns. Quiescence is the latch plus the cleanup
+ // asserts below (cleanup runs before the countdown), not
+ // thread death: join briefly instead of asserting the race.
+ ioThread.join(TimeUnit.SECONDS.toMillis(5));
+ Assert.assertFalse("I/O worker did not exit after close returned", ioThread.isAlive());
+ Assert.assertEquals("full client cleanup must run exactly once", 1, client.cleanupCount.get());
+ Assert.assertEquals("the I/O worker must own cleanup before publishing exit",
+ ioThread, client.cleanupThread.get());
+ Assert.assertFalse("close must not manufacture caller interruption",
+ closer.isInterrupted());
+ } finally {
+ client.releaseSend.countDown();
+ if (closer != null) {
+ closer.join(TimeUnit.SECONDS.toMillis(5));
+ }
+ loop.close();
+ engine.close();
+ client.close();
+ Unsafe.free(payload, 16, MemoryTag.NATIVE_DEFAULT);
+ }
+ });
+ }
+
+ @Test(timeout = 30_000L)
+ public void testUnsupportedCustomTransportFailsWithoutDestroyingWorkerResources() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ BlockingSendClient client = new BlockingSendClient(false);
+ CursorSendEngine engine = new CursorSendEngine(null, 64 * 1024);
+ CursorWebSocketSendLoop loop = new CursorWebSocketSendLoop(
+ client,
+ engine,
+ 0L,
+ CursorWebSocketSendLoop.DEFAULT_PARK_NANOS,
+ null,
+ 100L,
+ 1_000L,
+ 5_000L,
+ false
+ );
+ long payload = Unsafe.malloc(16, MemoryTag.NATIVE_DEFAULT);
+ Thread closer = null;
+ AtomicReference closeFailure = new AtomicReference<>();
+ CountDownLatch closeEntered = new CountDownLatch(1);
+ try {
+ Unsafe.getUnsafe().putLong(payload, 0x0102030405060708L);
+ Unsafe.getUnsafe().putLong(payload + 8, 0x1112131415161718L);
+ Assert.assertEquals(0L, engine.appendBlocking(payload, 16));
+ loop.start();
+ Assert.assertTrue("I/O worker never entered the blocking send",
+ client.sendEntered.await(5, TimeUnit.SECONDS));
+
+ closer = new Thread(() -> {
+ closeEntered.countDown();
+ try {
+ loop.close();
+ } catch (Throwable t) {
+ closeFailure.set(t);
+ }
+ }, "unsupported-transport-closer");
+ closer.start();
+
+ Assert.assertTrue("close did not start", closeEntered.await(5, TimeUnit.SECONDS));
+ closer.join(TimeUnit.SECONDS.toMillis(5));
+ Assert.assertFalse("unsupported transport made close join indefinitely", closer.isAlive());
+ Assert.assertTrue(closeFailure.get() instanceof LineSenderException);
+ Assert.assertTrue(closeFailure.get().getCause() instanceof UnsupportedOperationException);
+ Assert.assertEquals("unsupported cancellation must not release the blocked send",
+ 1L, client.sendExited.getCount());
+ Assert.assertEquals("unsupported cancellation must not perform full cleanup", 0, client.cleanupCount.get());
+ Assert.assertTrue("worker must retain resource ownership", client.sendThread.get().isAlive());
+
+ client.releaseSend.countDown();
+ Assert.assertTrue("released send did not exit", client.sendExited.await(5, TimeUnit.SECONDS));
+ Assert.assertTrue("worker did not complete delegated cleanup", client.cleanupDone.await(5, TimeUnit.SECONDS));
+ client.sendThread.get().join(TimeUnit.SECONDS.toMillis(5));
+ Assert.assertFalse("worker lingered after the custom transport was released", client.sendThread.get().isAlive());
+ Assert.assertEquals(1, client.cleanupCount.get());
+ Assert.assertNull("ordinary worker exit must remain non-terminal", loop.getTerminalError());
+ } finally {
+ client.releaseSend.countDown();
+ if (closer != null) {
+ closer.join(TimeUnit.SECONDS.toMillis(5));
+ }
+ Thread ioThread = client.sendThread.get();
+ if (ioThread != null) {
+ ioThread.join(TimeUnit.SECONDS.toMillis(5));
+ }
+ loop.close();
+ engine.close();
+ client.close();
+ Unsafe.free(payload, 16, MemoryTag.NATIVE_DEFAULT);
+ }
+ });
+ }
+
+ private static final class BlockingSendClient extends WebSocketClient {
+ private final AtomicBoolean cleanupClaimed = new AtomicBoolean();
+ private final AtomicInteger cleanupCount = new AtomicInteger();
+ private final CountDownLatch cleanupDone = new CountDownLatch(1);
+ private final AtomicReference cleanupThread = new AtomicReference<>();
+ private final boolean trafficShutdownSupported;
+ private final CountDownLatch releaseSend = new CountDownLatch(1);
+ private final CountDownLatch sendEntered = new CountDownLatch(1);
+ private final CountDownLatch sendExited = new CountDownLatch(1);
+ private final AtomicReference sendThread = new AtomicReference<>();
+ private final AtomicInteger trafficCloseCount = new AtomicInteger();
+ private final AtomicReference trafficCloseThread = new AtomicReference<>();
+ private final CountDownLatch trafficClosed = new CountDownLatch(1);
+
+ private BlockingSendClient(boolean trafficShutdownSupported) {
+ super(DefaultHttpClientConfiguration.INSTANCE, PlainSocketFactory.INSTANCE);
+ this.trafficShutdownSupported = trafficShutdownSupported;
+ }
+
+ @Override
+ public void close() {
+ if (cleanupClaimed.compareAndSet(false, true)) {
+ cleanupCount.incrementAndGet();
+ cleanupThread.set(Thread.currentThread());
+ cleanupDone.countDown();
+ }
+ super.close();
+ }
+
+ @Override
+ public void closeTraffic() {
+ if (!trafficShutdownSupported) {
+ throw new UnsupportedOperationException("custom transport has no safe cancellation capability");
+ }
+ trafficCloseThread.compareAndSet(null, Thread.currentThread());
+ trafficCloseCount.incrementAndGet();
+ trafficClosed.countDown();
+ releaseSend.countDown();
+ }
+
+ @Override
+ public void sendBinary(long dataPtr, int length, int timeout) {
+ sendThread.set(Thread.currentThread());
+ sendEntered.countDown();
+ try {
+ if (!releaseSend.await(10, TimeUnit.SECONDS)) {
+ throw new AssertionError("traffic path was not closed");
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new AssertionError("I/O worker was interrupted instead of traffic being closed", e);
+ } finally {
+ sendExited.countDown();
+ }
+ throw new LineSenderException("traffic path closed");
+ }
+
+ @Override
+ protected void ioWait(int timeout, int op) {
+ throw new UnsupportedOperationException("stub: no socket");
+ }
+
+ @Override
+ protected void setupIoWait() {
+ // no-op
+ }
+ }
+}
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
new file mode 100644
index 00000000..0225d071
--- /dev/null
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/CursorWebSocketSendLoopConnectPhaseCloseTest.java
@@ -0,0 +1,469 @@
+/*+*****************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * Copyright (c) 2014-2019 Appsicle
+ * Copyright (c) 2019-2026 QuestDB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ ******************************************************************************/
+
+package io.questdb.client.test.cutlass.qwp.client.sf.cursor;
+
+import io.questdb.client.DefaultHttpClientConfiguration;
+import io.questdb.client.cutlass.http.client.WebSocketClient;
+import io.questdb.client.cutlass.http.client.WebSocketFrameHandler;
+import io.questdb.client.cutlass.line.LineSenderException;
+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.test.tools.TestUtils;
+import org.junit.Assert;
+import org.junit.Test;
+
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+
+/**
+ * TRUE-CANCELLATION proof (P1): {@code close()} must cancel a connect attempt
+ * that is blocked inside {@code reconnectFactory.reconnect(...)} (a black-holed
+ * native connect that neither unpark nor interrupt can cancel). The in-flight
+ * {@link WebSocketClient} is walk-local -- it is NOT the loop's {@code client}
+ * field (that field is {@code null} on the async-initial connect and points at
+ * the stale pre-drop client on a mid-flight reconnect) -- so before the fix
+ * {@code close()}'s field-client {@code closeTraffic()} could not reach it and
+ * {@code close()} blocked on the untimed {@code shutdownLatch.await()} for the
+ * whole OS SYN-retry window (~60-130s per endpoint).
+ *
+ * The fix publishes the in-flight client to a race-safe
+ * {@link CursorWebSocketSendLoop.ConnectCancellation} handle before the
+ * blocking connect, and {@code close()} breaks that client's traffic. These
+ * tests assert TRUE cancellation, not just return: the fake in-flight client's
+ * {@code closeTraffic()} is the ONLY thing that unblocks the parked
+ * {@code reconnect()}, and the tests witness that {@code closeTraffic()} was
+ * invoked (exactly once, by the closer thread) AND that {@code close()}
+ * returned. Deterministic latches only; {@code @Test(timeout=...)} fails a
+ * regression fast.
+ */
+public class CursorWebSocketSendLoopConnectPhaseCloseTest {
+
+ /**
+ * Generous budget: with the fix, close() breaks the in-flight connect and
+ * returns well within this. Without the fix, close() blocks on the untimed
+ * shutdown latch for the entire (simulated) connect and this budget lapses.
+ */
+ private static final long CLOSE_BUDGET_MILLIS = 5_000L;
+
+ /**
+ * Small, injectable bounded-await backstop for the TOCTOU test. Shrunk from
+ * the production {@code DEFAULT_CLOSE_SHUTDOWN_AWAIT_MILLIS} (30 s) via
+ * {@link CursorWebSocketSendLoop#setShutdownAwaitTimeoutMillis(long)} so the
+ * timeout branch fires fast and deterministically -- no multi-second real
+ * wait -- while still leaving CLOSE_BUDGET_MILLIS of slack for the closer
+ * thread to return.
+ */
+ private static final long BACKSTOP_MILLIS = 500L;
+
+ /**
+ * Async-initial-connect path: the loop is built with a {@code null} client
+ * and a reconnect factory, so the I/O thread drives the very first connect
+ * through connectLoop while the {@code client} field stays {@code null}.
+ */
+ @Test(timeout = 30_000L)
+ public void testCloseCancelsAsyncInitialConnectViaInFlightClient() throws Exception {
+ runConnectCancelledByCloseTraffic(false);
+ }
+
+ /**
+ * Mid-flight-reconnect path: an initial client is installed, its first
+ * receive fails, so connectLoop reconnects and blocks. The {@code client}
+ * field then points at the stale pre-drop client, never the in-flight one.
+ */
+ @Test(timeout = 30_000L)
+ public void testCloseCancelsMidFlightReconnectViaInFlightClient() throws Exception {
+ runConnectCancelledByCloseTraffic(true);
+ }
+
+ /**
+ * BACKSTOP (bounded-await) proof: models the pathological, uninterruptible
+ * TOCTOU case where {@code cancel()}'s {@code closeTraffic()} CANNOT break
+ * the in-flight connect (as if cancellation landed after the pre-connect
+ * guard but before native fd creation, making closeTraffic() a no-op). The
+ * connect stays parked, so round-2 cancellation does NOT release the latch.
+ * Asserts {@code close()} STILL returns within the bounded backstop (does
+ * not hang) and surfaces the failed-stop contract: a loud
+ * {@link LineSenderException} whose message names the stalled I/O thread and
+ * the timeout, with client/engine teardown delegated to the I/O thread's
+ * own exit path (no destructive close under the still-live worker).
+ */
+ @Test(timeout = 30_000L)
+ public void testCloseReturnsBoundedWhenCancellationCannotBreakConnect() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ final AtomicReference published = new AtomicReference<>();
+ final CountDownLatch connectEntered = new CountDownLatch(1);
+ // The ONLY thing that releases the parked connect. closeTraffic()
+ // deliberately does NOT touch it, modelling a cancel that cannot
+ // break the connect; the test itself counts it down, AFTER the
+ // backstop assertions, so the worker can unwind for the leak check.
+ final CountDownLatch release = new CountDownLatch(1);
+
+ final CursorWebSocketSendLoop.ReconnectFactory factory = new CursorWebSocketSendLoop.ReconnectFactory() {
+ @Override
+ public WebSocketClient reconnect() throws Exception {
+ return reconnect(null);
+ }
+
+ @Override
+ public WebSocketClient reconnect(CursorWebSocketSendLoop.ConnectCancellation cancellation) {
+ UninterruptibleInFlightClient c = new UninterruptibleInFlightClient(release);
+ published.set(c);
+ if (cancellation != null) {
+ cancellation.publish(c);
+ }
+ connectEntered.countDown();
+ // Uninterruptible AND uncancellable: closeTraffic() does not
+ // release this park; only the test's release latch does.
+ c.awaitRelease();
+ c.close();
+ throw new LineSenderException("connect ended after release");
+ }
+ };
+
+ final CursorSendEngine engine = new CursorSendEngine(null, 64 * 1024);
+ final CursorWebSocketSendLoop loop = new CursorWebSocketSendLoop(
+ null,
+ engine,
+ 0L,
+ CursorWebSocketSendLoop.DEFAULT_PARK_NANOS,
+ factory,
+ /* reconnectMaxDurationMillis */ 60_000L,
+ /* reconnectInitialBackoffMillis */ 1_000L,
+ /* reconnectMaxBackoffMillis */ 5_000L,
+ false
+ );
+ // Shrink the bounded-await backstop so the timeout branch fires fast
+ // (no multi-second real wait); production uses the 30s default.
+ loop.setShutdownAwaitTimeoutMillis(BACKSTOP_MILLIS);
+
+ final AtomicReference closeFailure = new AtomicReference<>();
+ Thread closer = null;
+ try {
+ loop.start();
+ Assert.assertTrue("I/O worker never entered the blocking connect",
+ connectEntered.await(5, TimeUnit.SECONDS));
+
+ closer = new Thread(() -> {
+ try {
+ loop.close();
+ } catch (Throwable t) {
+ closeFailure.set(t);
+ }
+ }, "backstop-closer");
+ closer.start();
+
+ closer.join(CLOSE_BUDGET_MILLIS);
+ final boolean closedInTime = !closer.isAlive();
+
+ final UninterruptibleInFlightClient inFlight = published.get();
+ Assert.assertNotNull("no in-flight client was ever published", inFlight);
+ // cancel() DID attempt to break the connect -- but closeTraffic()
+ // is a no-op here, so the connect is still parked.
+ Assert.assertTrue("close() must have attempted to cancel the in-flight connect",
+ inFlight.trafficCloseCount.get() >= 1);
+ Assert.assertEquals("the parked connect must NOT have been released by closeTraffic()",
+ 1L, release.getCount());
+
+ // Decisive: close() returns within the bounded backstop even
+ // though cancellation could not break the connect.
+ Assert.assertTrue(
+ "close() must return within the bounded backstop (" + CLOSE_BUDGET_MILLIS
+ + "ms) even when cancellation cannot break the connect; instead it hung",
+ closedInTime);
+
+ // Failed-stop contract: loud LineSenderException naming the
+ // stalled thread and the timeout.
+ final Throwable failure = closeFailure.get();
+ Assert.assertNotNull("close() must loud-fail when the backstop times out", failure);
+ Assert.assertTrue("failed stop must be a LineSenderException, was " + failure,
+ failure instanceof LineSenderException);
+ Assert.assertTrue("message must name the stalled I/O thread: " + failure.getMessage(),
+ failure.getMessage().contains("cursor I/O thread did not stop"));
+ Assert.assertTrue("message must attribute the failed stop to the backstop timeout: "
+ + failure.getMessage(),
+ failure.getMessage().contains("timed out"));
+ // Teardown is delegated to the I/O thread's exit path, not done
+ // destructively under the live worker: no terminal manufactured.
+ Assert.assertNull("backstop timeout must not manufacture a terminal error",
+ loop.getTerminalError());
+ } finally {
+ // Now let the parked connect unwind so the worker exits and the
+ // leak check sees a clean teardown. Restore a generous backstop
+ // so the reconciling close() below awaits the worker's exit.
+ loop.setShutdownAwaitTimeoutMillis(CLOSE_BUDGET_MILLIS);
+ release.countDown();
+ if (closer != null) {
+ closer.join(TimeUnit.SECONDS.toMillis(5));
+ }
+ loop.close();
+ engine.close();
+ }
+ });
+ }
+
+ private void runConnectCancelledByCloseTraffic(boolean withInitialClient) throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ // Allocate the initial client INSIDE the leak-checked block so its
+ // native buffers are inside the baseline, not counted as over-free.
+ final WebSocketClient initialClient = withInitialClient ? new FailingInitialClient() : null;
+ final AtomicReference published = new AtomicReference<>();
+ final CountDownLatch connectEntered = new CountDownLatch(1);
+
+ // A reconnect factory that models the real connect walk: it creates
+ // the client it is about to block on, PUBLISHES it to the loop's
+ // cancellation handle before the blocking connect, then parks in a
+ // way that ONLY closeTraffic() (i.e. cancellation) can release --
+ // exactly a black-holed native connect that unpark/interrupt cannot
+ // cancel.
+ final CursorWebSocketSendLoop.ReconnectFactory factory = new CursorWebSocketSendLoop.ReconnectFactory() {
+ @Override
+ public WebSocketClient reconnect() throws Exception {
+ return reconnect(null);
+ }
+
+ @Override
+ public WebSocketClient reconnect(CursorWebSocketSendLoop.ConnectCancellation cancellation) {
+ InFlightConnectClient c = new InFlightConnectClient();
+ published.set(c);
+ if (cancellation != null) {
+ cancellation.publish(c);
+ }
+ connectEntered.countDown();
+ // Black-holed connect: returns only once closeTraffic() breaks it.
+ c.awaitTrafficBreak();
+ // Cancelled: dispose the half-built client (frees native
+ // buffers) and surface a transport error, exactly as the
+ // real connect walk's catch does on a broken connect.
+ c.close();
+ throw new LineSenderException("connect cancelled by closeTraffic()");
+ }
+ };
+
+ final CursorSendEngine engine = new CursorSendEngine(null, 64 * 1024);
+ final CursorWebSocketSendLoop loop = new CursorWebSocketSendLoop(
+ initialClient,
+ engine,
+ 0L,
+ CursorWebSocketSendLoop.DEFAULT_PARK_NANOS,
+ factory,
+ /* reconnectMaxDurationMillis */ 60_000L,
+ /* reconnectInitialBackoffMillis */ 1_000L,
+ /* reconnectMaxBackoffMillis */ 5_000L,
+ false
+ );
+
+ final AtomicReference closeFailure = new AtomicReference<>();
+ Thread closer = null;
+ try {
+ loop.start();
+ Assert.assertTrue("I/O worker never entered the blocking connect",
+ connectEntered.await(5, TimeUnit.SECONDS));
+
+ closer = new Thread(() -> {
+ try {
+ loop.close();
+ } catch (Throwable t) {
+ closeFailure.set(t);
+ }
+ }, "connect-phase-closer");
+ closer.start();
+
+ closer.join(CLOSE_BUDGET_MILLIS);
+ final boolean closedInTime = !closer.isAlive();
+
+ final InFlightConnectClient inFlight = published.get();
+ Assert.assertNotNull("no in-flight client was ever published to the cancellation handle",
+ inFlight);
+
+ // Let the worker finish unwinding regardless of outcome so the
+ // memory-leak check sees a clean teardown.
+ inFlight.trafficBroken.countDown();
+ closer.join(TimeUnit.SECONDS.toMillis(10));
+
+ Assert.assertNull("close() must not fail", closeFailure.get());
+ Assert.assertTrue(
+ "close() must cancel the connect blocked inside reconnect() and return "
+ + "within " + CLOSE_BUDGET_MILLIS + "ms; instead it blocked on the "
+ + "untimed shutdown latch for the whole connect",
+ closedInTime);
+ // Decisive: closeTraffic() on the IN-FLIGHT client is what
+ // unblocked the parked connect -- and it was the closer thread,
+ // not the I/O worker, that broke it.
+ Assert.assertEquals(
+ "close() must break the in-flight connect's traffic exactly once",
+ 1, inFlight.trafficCloseCount.get());
+ Assert.assertEquals(
+ "the closer thread (not the I/O worker) must cancel the in-flight connect",
+ closer, inFlight.trafficCloseThread.get());
+ Assert.assertNull("ordinary connect-phase close must not manufacture a terminal error",
+ loop.getTerminalError());
+ } finally {
+ InFlightConnectClient inFlight = published.get();
+ if (inFlight != null) {
+ inFlight.trafficBroken.countDown();
+ }
+ if (closer != null) {
+ closer.join(TimeUnit.SECONDS.toMillis(5));
+ }
+ loop.close();
+ engine.close();
+ if (initialClient != null) {
+ initialClient.close();
+ }
+ }
+ });
+ }
+
+ /**
+ * The fake in-flight client. It never really connects: the factory parks in
+ * {@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 {
+ final CountDownLatch trafficBroken = new CountDownLatch(1);
+ final AtomicInteger trafficCloseCount = new AtomicInteger();
+ final AtomicReference trafficCloseThread = new AtomicReference<>();
+
+ private InFlightConnectClient() {
+ super(DefaultHttpClientConfiguration.INSTANCE, PlainSocketFactory.INSTANCE);
+ }
+
+ @Override
+ public void closeTraffic() {
+ trafficCloseThread.compareAndSet(null, Thread.currentThread());
+ trafficCloseCount.incrementAndGet();
+ trafficBroken.countDown();
+ }
+
+ void awaitTrafficBreak() {
+ // Uninterruptible: only closeTraffic()'s countDown may release us,
+ // faithfully modelling a native connect that unpark/interrupt cannot
+ // cancel.
+ boolean interrupted = false;
+ while (trafficBroken.getCount() != 0L) {
+ try {
+ trafficBroken.await();
+ } catch (InterruptedException e) {
+ interrupted = true;
+ }
+ }
+ if (interrupted) {
+ Thread.currentThread().interrupt();
+ }
+ }
+
+ @Override
+ protected void ioWait(int timeout, int op) {
+ throw new UnsupportedOperationException("stub: no socket");
+ }
+
+ @Override
+ protected void setupIoWait() {
+ // no-op
+ }
+ }
+
+ /**
+ * The fake in-flight client for the BACKSTOP test. Its {@link #closeTraffic()}
+ * is a no-op w.r.t. releasing the park -- it only records that cancellation
+ * was attempted -- so a {@code close()}->{@code cancel()} CANNOT unblock the
+ * parked connect. This models the pathological uninterruptible/TOCTOU case;
+ * the ONLY release is the test-owned {@code release} latch, counted down
+ * AFTER the backstop assertions.
+ */
+ private static final class UninterruptibleInFlightClient extends WebSocketClient {
+ final AtomicInteger trafficCloseCount = new AtomicInteger();
+ private final CountDownLatch release;
+
+ private UninterruptibleInFlightClient(CountDownLatch release) {
+ super(DefaultHttpClientConfiguration.INSTANCE, PlainSocketFactory.INSTANCE);
+ this.release = release;
+ }
+
+ @Override
+ public void closeTraffic() {
+ // Records the cancel attempt but does NOT release the parked
+ // connect: exactly a closeTraffic() that lands before the native fd
+ // exists (a no-op), so the connect blocks on regardless.
+ trafficCloseCount.incrementAndGet();
+ }
+
+ void awaitRelease() {
+ // Uninterruptible: neither unpark, interrupt, nor closeTraffic()
+ // releases us -- only the test's release latch.
+ boolean interrupted = false;
+ while (release.getCount() != 0L) {
+ try {
+ release.await();
+ } catch (InterruptedException e) {
+ interrupted = true;
+ }
+ }
+ if (interrupted) {
+ Thread.currentThread().interrupt();
+ }
+ }
+
+ @Override
+ protected void ioWait(int timeout, int op) {
+ throw new UnsupportedOperationException("stub: no socket");
+ }
+
+ @Override
+ protected void setupIoWait() {
+ // no-op
+ }
+ }
+
+ /**
+ * A pre-installed client whose first receive fails, driving the I/O loop
+ * 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 FailingInitialClient() {
+ super(DefaultHttpClientConfiguration.INSTANCE, PlainSocketFactory.INSTANCE);
+ }
+
+ @Override
+ public boolean tryReceiveFrame(WebSocketFrameHandler handler) {
+ throw new LineSenderException("initial wire dropped");
+ }
+
+ @Override
+ protected void ioWait(int timeout, int op) {
+ throw new UnsupportedOperationException("stub: no socket");
+ }
+
+ @Override
+ protected void setupIoWait() {
+ // no-op
+ }
+ }
+}
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/EngineClosePublishAfterFlockReleaseTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/EngineClosePublishAfterFlockReleaseTest.java
new file mode 100644
index 00000000..81fd36ef
--- /dev/null
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/EngineClosePublishAfterFlockReleaseTest.java
@@ -0,0 +1,247 @@
+/*+*****************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * 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.SlotLock;
+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.AtomicReference;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+/**
+ * Regression test for the completion/release ordering in
+ * {@code CursorSendEngine.finishClose()}: {@code closeCompleted} must be
+ * published strictly AFTER the slot flock release is confirmed, never
+ * before.
+ *
+ *
The bug being pinned: {@code closeCompleted = true} used to be written
+ * before {@code slotLock} was closed. A pool thread could observe completion
+ * through {@code QwpWebSocketSender.isSlotLockReleased()}, free the slot
+ * index in {@code SenderPool.reprobeRetiredSlots()}, and admit a replacement
+ * sender whose {@code SlotLock.acquire} then collided with the still-open
+ * flock fd — a spurious "sf slot already in use" construction failure naming
+ * the process's own pid as the holder.
+ *
+ *
The window between the publish and the {@code Files.close(fd)} is
+ * microseconds wide in the wild; {@code setBeforeFlockReleaseHook} makes it
+ * deterministic. The closing thread is parked between terminal cleanup and
+ * the flock release, and the test asserts from outside the window's two
+ * halves of the contract:
+ *
+ *
inside the window: {@code isCloseCompleted()} is still false AND a
+ * fresh {@code SlotLock.acquire} on the slot fails (proving the flock
+ * is genuinely held — i.e. reporting completion here would have been
+ * a lie);
+ *
after the window: {@code isCloseCompleted()} is true AND a fresh
+ * {@code SlotLock.acquire} succeeds (completion still implies
+ * reusability — the reorder did not break the happy path).
+ *
+ */
+public class EngineClosePublishAfterFlockReleaseTest {
+
+ private String sfDir;
+
+ @Before
+ public void setUp() {
+ sfDir = Paths.get(System.getProperty("java.io.tmpdir"),
+ "qdb-engine-close-publish-order-" + System.nanoTime()).toString();
+ }
+
+ @After
+ public void tearDown() {
+ if (sfDir == null) return;
+ long find = Files.findFirst(sfDir);
+ 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(sfDir + "/" + name);
+ }
+ rc = Files.findNext(find);
+ }
+ } finally {
+ Files.findClose(find);
+ }
+ }
+ Files.remove(sfDir);
+ }
+
+ @Test(timeout = 30_000L)
+ public void testCloseCompletedPublishedOnlyAfterConfirmedFlockRelease() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ CursorSendEngine engine = new CursorSendEngine(sfDir, 4L * 1024 * 1024);
+ CountDownLatch inWindow = new CountDownLatch(1);
+ CountDownLatch proceed = new CountDownLatch(1);
+ engine.setBeforeFlockReleaseHook(() -> {
+ inWindow.countDown();
+ try {
+ // Bounded so a failed assertion on the main thread can
+ // never wedge the closer past the test timeout.
+ proceed.await(20, TimeUnit.SECONDS);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ });
+
+ AtomicReference closerError = new AtomicReference<>();
+ Thread closer = new Thread(() -> {
+ try {
+ engine.close();
+ } catch (Throwable t) {
+ closerError.set(t);
+ }
+ }, "engine-closer");
+ closer.start();
+
+ try {
+ assertTrue("closer thread never reached the cleanup/release window",
+ inWindow.await(10, TimeUnit.SECONDS));
+
+ // Inside the window: terminal cleanup has run, the flock has
+ // NOT been released. Completion must not be observable yet —
+ // this is the exact read a pool thread performs before
+ // freeing the slot index.
+ assertFalse("closeCompleted was published before the flock release; "
+ + "a pool observing this would free the slot index and a "
+ + "replacement sender would collide with the still-held flock",
+ engine.isCloseCompleted());
+
+ // Prove the window is real: the flock is genuinely still
+ // held, so acquisition by a "replacement engine" fails.
+ try {
+ SlotLock probe = SlotLock.acquire(sfDir);
+ probe.close();
+ fail("scaffolding error: expected the slot flock to still be "
+ + "held inside the pre-release window, but a fresh "
+ + "SlotLock.acquire succeeded");
+ } catch (IllegalStateException expected) {
+ // good — slot is locked, which is why completion must
+ // not have been published yet.
+ }
+ } finally {
+ proceed.countDown();
+ closer.join(10_000L);
+ }
+ assertFalse("closer thread did not finish", closer.isAlive());
+ assertNull("engine.close() threw", closerError.get());
+
+ // After the window: the release is confirmed, so completion must
+ // now be latched, and completion must still imply reusability.
+ assertTrue("closeCompleted must latch once the flock release is confirmed",
+ engine.isCloseCompleted());
+ try (SlotLock ignored = SlotLock.acquire(sfDir)) {
+ // good — completion implies the slot dir is acquirable.
+ } catch (IllegalStateException stillHeld) {
+ fail("closeCompleted reported true but the slot flock is still held: "
+ + stillHeld.getMessage());
+ }
+ });
+ }
+
+ /**
+ * The error half of the publish-after-release contract: when
+ * {@code SlotLock.release()} reports failure (the OS refused the explicit
+ * unlock, so the flock may still be held), {@code closeCompleted} must
+ * stay false until a later close confirms release. A pool observing
+ * {@code isCloseCompleted() == false} keeps the slot retired while the
+ * flock is genuinely held, then recovers it once the retry completes.
+ *
+ * The lock's fd is swapped to a known-bad descriptor for a deterministic
+ * native unlock failure, then restored before retrying through the engine's
+ * public close API.
+ */
+ @Test(timeout = 30_000L)
+ public void testUnconfirmedFlockReleaseKeepsCloseIncompleteUntilRetry() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ CursorSendEngine engine = new CursorSendEngine(sfDir, 4L * 1024 * 1024);
+ SlotLock slotLock = engine.getSlotLockForTesting();
+ assertNotNull("disk-mode engine must hold a slot lock", slotLock);
+
+ // This test owns the explicit close() retry. Prevent the shared
+ // asynchronous retry driver from racing it and from surviving into
+ // another test class; FlockReleaseRetryDriverTest covers the real
+ // driver lifecycle separately. A start failure synchronously clears
+ // the shared retry queue and resets the engine's scheduling flag.
+ CursorSendEngine.setFlockReleaseRetryThreadFactory(task -> {
+ throw new IllegalStateException("retry driver disabled for explicit close retry test");
+ });
+ try {
+ try (SlotLock.ReleaseFailureForTesting releaseFailure =
+ slotLock.injectReleaseFailureForTesting()) {
+ engine.close();
+ assertFalse(
+ "closeCompleted was published despite an unconfirmed flock release; "
+ + "a pool observing this would free the slot index while the "
+ + "flock fd is still open",
+ engine.isCloseCompleted());
+ // The REAL flock is still held — the incomplete report is true.
+ try {
+ SlotLock probe = SlotLock.acquire(sfDir);
+ probe.close();
+ fail("slot must not be acquirable while the original flock fd is still open");
+ } catch (IllegalStateException expected) {
+ // good — incomplete close really means "still locked".
+ }
+
+ // Remove the injected fault. The retry must only re-attempt
+ // the retained fd release: ring/watermark cleanup stays
+ // exactly-once, while completion and slot reusability recover.
+ releaseFailure.close();
+ engine.close();
+ assertTrue("retried close() must complete after the flock release succeeds",
+ engine.isCloseCompleted());
+ try (SlotLock ignored = SlotLock.acquire(sfDir)) {
+ // good — eventual completion implies reusable capacity.
+ }
+ }
+ } finally {
+ try {
+ // The guard has restored the real fd on every exit. Release
+ // it if an assertion failed before the successful retry.
+ if (!engine.isCloseCompleted()) {
+ assertTrue("restored fd must release cleanly", slotLock.release());
+ }
+ } finally {
+ CursorSendEngine.setFlockReleaseRetryThreadFactory(null);
+ }
+ }
+ });
+ }
+}
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/FlockReleaseRetryDriverTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/FlockReleaseRetryDriverTest.java
new file mode 100644
index 00000000..967a4fab
--- /dev/null
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/FlockReleaseRetryDriverTest.java
@@ -0,0 +1,482 @@
+/*+*****************************************************************************
+ * ___ _ ____ ____
+ * / _ \ _ _ ___ ___| |_| _ \| __ )
+ * | | | | | | |/ _ \/ __| __| | | | _ \
+ * | |_| | |_| | __/\__ \ |_| |_| | |_) |
+ * \__\_\\__,_|\___||___/\__|____/|____/
+ *
+ * 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.QwpWebSocketSender;
+import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorSendEngine;
+import io.questdb.client.cutlass.qwp.client.sf.cursor.SlotLock;
+import io.questdb.client.std.Files;
+import io.questdb.client.test.tools.TestUtils;
+import org.junit.After;
+import org.junit.Test;
+
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Semaphore;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+public class FlockReleaseRetryDriverTest {
+
+ private final List sfDirs = new ArrayList<>();
+
+ @After
+ public void tearDown() {
+ CursorSendEngine.setAfterFlockReleaseRetryFailureHook(null);
+ CursorSendEngine.setFlockReleaseRetryParkOverride(null);
+ CursorSendEngine.setFlockReleaseRetryThreadFactory(null);
+ for (String sfDir : sfDirs) {
+ removeDir(sfDir);
+ }
+ }
+
+ /**
+ * Driver-start failure must not strand retired capacity until process
+ * exit. {@code Sender.close()} is a one-shot no-op by contract, so the
+ * only recovery surface a pool has is its retired-slot probe
+ * ({@code isSlotLockReleased()}, called from the housekeeper tick and
+ * capacity-starved borrows) — that probe must re-arm the shared retry
+ * driver once thread creation works again.
+ */
+ @Test(timeout = 30_000L)
+ public void testDriverStartFailureRecoversViaPoolProbe() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ AtomicInteger starts = new AtomicInteger();
+ CursorSendEngine.setFlockReleaseRetryThreadFactory(task -> new Thread(task) {
+ @Override
+ public synchronized void start() {
+ starts.incrementAndGet();
+ throw new IllegalStateException("injected start failure");
+ }
+ });
+
+ CursorSendEngine engine = new CursorSendEngine(
+ newSfDir("probe-rearm"), 4L * 1024 * 1024);
+ SlotLock slotLock = engine.getSlotLockForTesting();
+ QwpWebSocketSender sender = QwpWebSocketSender.createForTesting("localhost", 1);
+ sender.setCursorEngine(engine, true);
+ AtomicReference rearmedDriver = new AtomicReference<>();
+ boolean recovered = false;
+ SlotLock.ReleaseFailureForTesting releaseFailure = slotLock.injectReleaseFailureForTesting();
+ try {
+ sender.close();
+ assertEquals("retry driver start must be attempted once", 1, starts.get());
+ assertFalse("failed unlock must remain unpublished", engine.isCloseCompleted());
+
+ // Sender.close() is idempotent: repeat calls never reach the
+ // engine again, so they cannot restart the failed driver.
+ sender.close();
+ assertEquals("no-op close must not retry the driver start", 1, starts.get());
+
+ // While the fault persists, each pool probe re-attempts the
+ // re-arm (and fails again) without publishing a release.
+ assertFalse("failed unlock must keep the slot reported as held",
+ sender.isSlotLockReleased());
+ assertTrue("pool probe must re-attempt the driver start",
+ starts.get() >= 2);
+
+ // The transient condition clears: thread creation works again
+ // (the failed start drained the queue, so the factory swap is
+ // legal) and the flock fd is back.
+ CursorSendEngine.setFlockReleaseRetryThreadFactory(task -> {
+ Thread thread = new Thread(task, "test-rearmed-flock-release-retry");
+ rearmedDriver.set(thread);
+ return thread;
+ });
+ releaseFailure.close();
+ CountDownLatch released = new CountDownLatch(1);
+ engine.setSlotLockReleaseListener(released::countDown);
+
+ // Production recovery surface: the pool re-probes the retired
+ // slot through isSlotLockReleased().
+ sender.isSlotLockReleased();
+ assertTrue("pool probe must re-arm the flock-release retry after "
+ + "a driver start failure",
+ released.await(10, TimeUnit.SECONDS));
+ assertTrue(engine.isCloseCompleted());
+ assertTrue("probe must expose the recovered release",
+ sender.isSlotLockReleased());
+ recovered = true;
+ } finally {
+ Thread driver = rearmedDriver.get();
+ if (driver != null) {
+ driver.join(10_000L);
+ }
+ if (!recovered) {
+ CursorSendEngine.setFlockReleaseRetryThreadFactory(null);
+ releaseFailure.close();
+ if (!slotLock.release()) {
+ fail("restored flock fd did not release");
+ }
+ }
+ }
+ CursorSendEngine.setFlockReleaseRetryThreadFactory(null);
+ });
+ }
+
+ @Test(timeout = 30_000L)
+ public void testPersistentFailuresShareOneRetryThread() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ final int engineCount = 32;
+ AtomicInteger retryFailures = new AtomicInteger();
+ AtomicInteger threadsCreated = new AtomicInteger();
+ AtomicReference retryThreadRef = new AtomicReference<>();
+ CountDownLatch retryFailuresObserved = new CountDownLatch(engineCount * 2);
+ CountDownLatch retryThreadStarted = new CountDownLatch(1);
+ CountDownLatch runRetryDriver = new CountDownLatch(1);
+ CursorSendEngine.setAfterFlockReleaseRetryFailureHook(() -> {
+ retryFailures.incrementAndGet();
+ retryFailuresObserved.countDown();
+ });
+ CursorSendEngine.setFlockReleaseRetryThreadFactory(task -> {
+ threadsCreated.incrementAndGet();
+ Thread thread = new Thread(() -> {
+ retryThreadStarted.countDown();
+ try {
+ runRetryDriver.await();
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ task.run();
+ }, "test-shared-flock-release-retry");
+ retryThreadRef.set(thread);
+ return thread;
+ });
+
+ List engines = new ArrayList<>(engineCount);
+ List releaseFailures = new ArrayList<>(engineCount);
+ boolean failuresRestored = false;
+ try {
+ for (int i = 0; i < engineCount; i++) {
+ String sfDir = newSfDir("persistent-" + i);
+ CursorSendEngine engine = new CursorSendEngine(sfDir, 4L * 1024 * 1024);
+ SlotLock slotLock = engine.getSlotLockForTesting();
+ engines.add(engine);
+ boolean isTracked = false;
+ SlotLock.ReleaseFailureForTesting releaseFailure =
+ slotLock.injectReleaseFailureForTesting();
+ try {
+ releaseFailures.add(releaseFailure);
+ isTracked = true;
+ engine.close();
+ } finally {
+ if (!isTracked) {
+ releaseFailure.close();
+ }
+ }
+ assertFalse("injected unlock failure must keep close incomplete",
+ engine.isCloseCompleted());
+ if (i == 0) {
+ assertTrue("retry thread was not started",
+ retryThreadStarted.await(10, TimeUnit.SECONDS));
+ }
+ }
+
+ assertEquals("persistent failures must share one retry thread",
+ 1, threadsCreated.get());
+ runRetryDriver.countDown();
+ assertTrue("driver did not perform two failed rounds",
+ retryFailuresObserved.await(10, TimeUnit.SECONDS));
+ assertTrue("driver did not retain persistent failures",
+ retryFailures.get() >= engineCount * 2);
+ for (CursorSendEngine engine : engines) {
+ assertFalse("failed releases must remain unpublished",
+ engine.isCloseCompleted());
+ }
+
+ restoreReleaseFailures(releaseFailures);
+ failuresRestored = true;
+ Thread retryThread = retryThreadRef.get();
+ retryThread.join(10_000L);
+ assertFalse("shared retry thread retained lifecycle resources after drain",
+ retryThread.isAlive());
+ for (CursorSendEngine engine : engines) {
+ assertTrue("driver must release every restored flock",
+ engine.isCloseCompleted());
+ }
+ assertEquals("retries must not create another thread",
+ 1, threadsCreated.get());
+ } finally {
+ if (!failuresRestored) {
+ restoreReleaseFailures(releaseFailures);
+ }
+ runRetryDriver.countDown();
+ Thread retryThread = retryThreadRef.get();
+ if (retryThread != null) {
+ retryThread.join(10_000L);
+ }
+ CursorSendEngine.setAfterFlockReleaseRetryFailureHook(null);
+ }
+ CursorSendEngine.setFlockReleaseRetryThreadFactory(null);
+ });
+ }
+
+ /**
+ * Schedule inspection for the shared driver's retry cadence: the
+ * inter-round park must grow exponentially from 100 ms and cap at 5 s,
+ * so a persistent unlock failure does not burn 10 syscalls per second
+ * per engine forever. The park override replaces the real park, so the
+ * test coordinates rounds without wall-clock waits.
+ */
+ @Test(timeout = 30_000L)
+ public void testRetryBackoffDoublesToCap() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ List parks = new ArrayList<>();
+ Semaphore parked = new Semaphore(0);
+ Semaphore proceed = new Semaphore(0);
+ AtomicReference driverRef = new AtomicReference<>();
+ CursorSendEngine.setFlockReleaseRetryParkOverride(nanos -> {
+ parks.add(nanos);
+ parked.release();
+ proceed.acquireUninterruptibly();
+ });
+ CursorSendEngine.setFlockReleaseRetryThreadFactory(task -> {
+ Thread thread = new Thread(task, "test-backoff-flock-release-retry");
+ driverRef.set(thread);
+ return thread;
+ });
+
+ CursorSendEngine engine = new CursorSendEngine(
+ newSfDir("backoff-cap"), 4L * 1024 * 1024);
+ SlotLock slotLock = engine.getSlotLockForTesting();
+ boolean failureRestored = false;
+ SlotLock.ReleaseFailureForTesting releaseFailure = slotLock.injectReleaseFailureForTesting();
+ try {
+ engine.close();
+ assertFalse("injected unlock failure must keep close incomplete",
+ engine.isCloseCompleted());
+
+ // Eight failed rounds: enough to observe the full ramp and
+ // two capped parks.
+ for (int round = 1; round <= 8; round++) {
+ assertTrue("driver did not park after failed round " + round,
+ parked.tryAcquire(10, TimeUnit.SECONDS));
+ if (round < 8) {
+ proceed.release();
+ }
+ }
+
+ releaseFailure.close();
+ failureRestored = true;
+ proceed.release();
+ Thread driver = driverRef.get();
+ driver.join(10_000L);
+ assertFalse("driver did not drain after the release succeeded",
+ driver.isAlive());
+ assertTrue("restored flock must be released", engine.isCloseCompleted());
+
+ List expected = new ArrayList<>();
+ expected.add(100_000_000L);
+ expected.add(200_000_000L);
+ expected.add(400_000_000L);
+ expected.add(800_000_000L);
+ expected.add(1_600_000_000L);
+ expected.add(3_200_000_000L);
+ expected.add(5_000_000_000L);
+ expected.add(5_000_000_000L);
+ assertEquals("retry parks must double from 100ms and cap at 5s",
+ expected, parks);
+ } finally {
+ if (!failureRestored) {
+ releaseFailure.close();
+ }
+ proceed.release(1_000);
+ Thread driver = driverRef.get();
+ if (driver != null) {
+ driver.join(10_000L);
+ }
+ }
+ CursorSendEngine.setFlockReleaseRetryParkOverride(null);
+ CursorSendEngine.setFlockReleaseRetryThreadFactory(null);
+ });
+ }
+
+ /**
+ * A successful release in a round is progress: the driver must reset its
+ * backoff to the base so the remaining engines are retried promptly
+ * while the failure condition is clearing.
+ */
+ @Test(timeout = 30_000L)
+ public void testRetryBackoffResetsOnProgress() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ List parks = new ArrayList<>();
+ Semaphore parked = new Semaphore(0);
+ Semaphore proceed = new Semaphore(0);
+ AtomicReference driverRef = new AtomicReference<>();
+ CursorSendEngine.setFlockReleaseRetryParkOverride(nanos -> {
+ parks.add(nanos);
+ parked.release();
+ proceed.acquireUninterruptibly();
+ });
+ CursorSendEngine.setFlockReleaseRetryThreadFactory(task -> {
+ Thread thread = new Thread(task, "test-reset-flock-release-retry");
+ driverRef.set(thread);
+ return thread;
+ });
+
+ CursorSendEngine engineA = new CursorSendEngine(
+ newSfDir("backoff-reset-a"), 4L * 1024 * 1024);
+ CursorSendEngine engineB = new CursorSendEngine(
+ newSfDir("backoff-reset-b"), 4L * 1024 * 1024);
+ SlotLock slotLockA = engineA.getSlotLockForTesting();
+ SlotLock slotLockB = engineB.getSlotLockForTesting();
+ try {
+ try (SlotLock.ReleaseFailureForTesting releaseFailureA =
+ slotLockA.injectReleaseFailureForTesting();
+ SlotLock.ReleaseFailureForTesting releaseFailureB =
+ slotLockB.injectReleaseFailureForTesting()) {
+ engineA.close();
+ engineB.close();
+
+ // Three failed rounds ramp the backoff to 400ms.
+ for (int round = 1; round <= 3; round++) {
+ assertTrue("driver did not park after failed round " + round,
+ parked.tryAcquire(10, TimeUnit.SECONDS));
+ if (round < 3) {
+ proceed.release();
+ }
+ }
+
+ // Engine A recovers; round 4 has one success and one failure,
+ // so its park must be back at the 100ms base.
+ releaseFailureA.close();
+ proceed.release();
+ assertTrue("driver did not park after the mixed round",
+ parked.tryAcquire(10, TimeUnit.SECONDS));
+ assertTrue("recovered engine must publish completion",
+ engineA.isCloseCompleted());
+
+ releaseFailureB.close();
+ proceed.release();
+ Thread driver = driverRef.get();
+ driver.join(10_000L);
+ assertFalse("driver did not drain after both releases succeeded",
+ driver.isAlive());
+ assertTrue(engineB.isCloseCompleted());
+
+ List expected = new ArrayList<>();
+ expected.add(100_000_000L);
+ expected.add(200_000_000L);
+ expected.add(400_000_000L);
+ expected.add(100_000_000L);
+ assertEquals("a successful release must reset the backoff to base",
+ expected, parks);
+ }
+ } finally {
+ proceed.release(1_000);
+ Thread driver = driverRef.get();
+ if (driver != null) {
+ driver.join(10_000L);
+ }
+ }
+ CursorSendEngine.setFlockReleaseRetryParkOverride(null);
+ CursorSendEngine.setFlockReleaseRetryThreadFactory(null);
+ });
+ }
+
+ @Test(timeout = 30_000L)
+ public void testRetryThreadStartFailureLeavesExplicitCloseRetryable() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ AtomicInteger starts = new AtomicInteger();
+ CursorSendEngine.setFlockReleaseRetryThreadFactory(task -> new Thread(task) {
+ @Override
+ public synchronized void start() {
+ starts.incrementAndGet();
+ throw new IllegalStateException("injected start failure");
+ }
+ });
+
+ CursorSendEngine engine = new CursorSendEngine(
+ newSfDir("start-failure"), 4L * 1024 * 1024);
+ SlotLock slotLock = engine.getSlotLockForTesting();
+ SlotLock.ReleaseFailureForTesting releaseFailure = slotLock.injectReleaseFailureForTesting();
+ try {
+ engine.close();
+ assertEquals("retry driver start must be attempted once", 1, starts.get());
+ assertFalse("failed unlock must remain unpublished", engine.isCloseCompleted());
+
+ // This also proves the failed driver cleared its queue: the
+ // setter rejects replacement while any engine remains queued.
+ CursorSendEngine.setFlockReleaseRetryThreadFactory(null);
+ releaseFailure.close();
+ engine.close();
+ assertTrue("explicit close must recover after retry-thread start failure",
+ engine.isCloseCompleted());
+ } finally {
+ if (!engine.isCloseCompleted()) {
+ CursorSendEngine.setFlockReleaseRetryThreadFactory(null);
+ releaseFailure.close();
+ if (!slotLock.release()) {
+ fail("restored flock fd did not release");
+ }
+ }
+ }
+ });
+ }
+
+ private static void removeDir(String sfDir) {
+ long find = Files.findFirst(sfDir);
+ 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(sfDir + "/" + name);
+ }
+ rc = Files.findNext(find);
+ }
+ } finally {
+ Files.findClose(find);
+ }
+ }
+ Files.remove(sfDir);
+ }
+
+ private static void restoreReleaseFailures(
+ List releaseFailures
+ ) {
+ for (int i = 0; i < releaseFailures.size(); i++) {
+ releaseFailures.get(i).close();
+ }
+ }
+
+ private String newSfDir(String suffix) {
+ String sfDir = Paths.get(
+ System.getProperty("java.io.tmpdir"),
+ "qdb-flock-release-retry-" + suffix + "-" + System.nanoTime()
+ ).toString();
+ sfDirs.add(sfDir);
+ return sfDir;
+ }
+}
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/MmapSegmentRecoveryFaultTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/MmapSegmentRecoveryFaultTest.java
index 861f761c..fbe179e8 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/MmapSegmentRecoveryFaultTest.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/MmapSegmentRecoveryFaultTest.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.MmapSegmentCorruptionException;
import io.questdb.client.cutlass.qwp.client.sf.cursor.MmapSegmentException;
import io.questdb.client.std.Files;
import io.questdb.client.std.FilesFacade;
@@ -36,61 +37,17 @@
import org.junit.Test;
import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
/**
- * Regression guard for the recovery-time SIGBUS hazard in {@link MmapSegment}.
- *
- * On recovery, {@link MmapSegment#openExisting} maps a persisted {@code .sfa}
- * to its stat length and scans frames straight out of the mapping. When a prior
- * session left a sparse segment tail -- a truncate-based pre-allocation that
- * never materialized the tail blocks, as happens on ZFS -- a read of an
- * unbacked page raises the JVM's recoverable
- * {@code InternalError("...unsafe memory access operation...")} (a translated
- * SIGBUS). Recovery must treat that page as the boundary of recoverable data,
- * keep every frame below it, and hand back a usable segment -- not let the
- * error abort recovery of the whole slot (the reported ZFS-CI flake).
- *
- * These tests drive the production entry point ({@code openExisting}),
- * not the private scan methods via reflection. That matters for two reasons:
- *
- *
It exercises the real recovery path end to end.
- *
On pre-21 JDKs the mmap-fault {@code InternalError} is delivered
- * imprecisely ("a fault occurred in a recent unsafe memory access
- * operation in compiled Java code") and escapes a reflective
- * {@code Method.invoke} frame instead of being caught inside the scan --
- * so a reflection-based test spuriously fails on the shipping JDK 8/11/17
- * even though the direct-call production path catches it fine.
- *
- * The fault-delivery mechanism the fix rests on was verified directly on the
- * shipping/CI Java floor -- JDK 8 (Temurin 1.8.0_492) -- not merely inferred
- * from the adjacent pre-21 LTS releases: the whole class passes there in both
- * interpreter ({@code -Xint}) and JIT modes, HotSpot emits the exact pre-21
- * message above, and a direct {@code try/catch} catches the fault in
- * interpreter, C1, and C2 modes. {@code isMmapAccessFault}'s shared
- * {@code "unsafe memory access operation"} fragment matches that message while
- * the JDK 21+-only needle it replaced does not -- the guard is live on JDK 8.
- * The unbacked tail is produced portably by truncating the file down (dropping
- * the tail blocks) and back up to the mapping size (leaving a sparse hole). A
- * hole-faulting filesystem (ZFS) then faults on the read exactly as in
- * production -- the case the fix must survive rather than fold the CRC through
- * the native, JNI-side {@code Crc32c} where a SIGBUS is uncatchable and aborts
- * the JVM. A hole-zero-filling filesystem (ext4) instead reads the hole back as
- * zeroes, which fails the frame CRC; either way recovery must stop at the same
- * boundary and recover the same frames.
- *
- * Fail-on-revert on any filesystem. The sparse-hole tests above only
- * fault on ZFS: on ext4/xfs the within-EOF hole zero-fills, so the scan stops
- * via the CRC-mismatch / bad-magic branch and they stay green even with the
- * mmap-fault guard reverted -- no regression protection on the ext4/xfs CI
- * runners. The two {@code MapPastEof} tests below close that gap portably.
- * They truncate the file down (freeing the tail blocks) and hand
- * {@code openExisting} a {@link FilesFacade} that reports the original, larger
- * length, so the mapping extends past real end-of-file. A read of a page beyond
- * real EOF raises SIGBUS on every filesystem -- the same catchable
- * {@code InternalError} an unbacked ZFS page raises -- so they exercise the
- * real fault path (and fail on revert) on ext4/xfs too, not only on ZFS.
+ * Regression guards for recovery through positioned file reads. Recovery must
+ * consume every byte it validates before mmap creation so sparse or unbacked
+ * pages never raise SIGBUS during validation. Sparse holes are ordinary zero-filled
+ * reads; negative reads, premature EOF, and file-size changes are operational
+ * errors that fail recovery closed without returning a live mapping or mutating
+ * the segment.
*/
public class MmapSegmentRecoveryFaultTest {
@@ -125,8 +82,8 @@ public void testRecoveryKeepsFramesBeforeUnbackedTail() throws Exception {
long boundary = writeSegment(path, 7L, new int[]{payloadLen});
assertEquals("frame must fill exactly one page", page, boundary);
- // Drop the tail blocks, then re-extend logically so [page, SEGMENT_BYTES)
- // is an unbacked hole under the recovery mapping.
+ // Drop the tail blocks, then re-extend logically so
+ // [page, SEGMENT_BYTES) is an unbacked hole in the persisted file.
punchSparseTail(path, page);
try (MmapSegment seg = MmapSegment.openExisting(path)) {
@@ -138,15 +95,9 @@ public void testRecoveryKeepsFramesBeforeUnbackedTail() throws Exception {
}
/**
- * The harder case: a frame whose 8-byte header sits on a backed page but
- * whose payload reaches into the unbacked hole (a torn write leaves a real
- * positive {@code payloadLen} with the payload spanning the boundary). The
- * CRC fold therefore reads across the backed-to-unbacked edge. Recovery
- * must reject that frame and keep the one below it -- and, crucially, must
- * do so via {@code Unsafe} reads: the native, JNI-side {@code Crc32c} over
- * an unbacked page raises a SIGBUS that HotSpot cannot translate, aborting
- * the whole JVM (verified: an {@code hs_err} in
- * {@code Java_io_questdb_client_std_Crc32c_update}).
+ * The harder sparse case: a frame header is backed but its payload reaches
+ * into a hole. Positioned reads return zeroes for the hole, so CRC
+ * validation rejects that frame without dereferencing the mapping.
*/
@Test
public void testRecoverySurvivesPayloadReachingUnbackedPage() throws Exception {
@@ -159,10 +110,16 @@ public void testRecoverySurvivesPayloadReachingUnbackedPage() throws Exception {
// the backed->unbacked edge.
final long frame2Offset = boundary - 16;
final int payloadLen2 = (int) page;
- final int payloadLen1 = (int) (frame2Offset - MmapSegment.HEADER_SIZE - MmapSegment.FRAME_HEADER_SIZE);
+ final int payloadLen1 = (int) (
+ frame2Offset - MmapSegment.HEADER_SIZE - MmapSegment.FRAME_HEADER_SIZE
+ );
long used = writeSegment(path, 11L, new int[]{payloadLen1, payloadLen2});
- assertEquals("frame 2's header must end 8 bytes below the page boundary", boundary - 8, frame2Offset + MmapSegment.FRAME_HEADER_SIZE);
+ assertEquals(
+ "frame 2's header must end 8 bytes below the page boundary",
+ boundary - 8,
+ frame2Offset + MmapSegment.FRAME_HEADER_SIZE
+ );
assertTrue("frame 2 payload must reach past the boundary", used > boundary);
punchSparseTail(path, boundary);
@@ -178,18 +135,8 @@ public void testRecoverySurvivesPayloadReachingUnbackedPage() throws Exception {
}
/**
- * M1 regression: the header block (magic/version/baseSeq) is read before
- * {@code scanFrames}, so an unbacked page 0 faults ahead of the guarded
- * scan. {@link MmapSegment#openExisting} must surface that as a
- * {@link MmapSegmentException} -- the per-file signal {@code SegmentRing}
- * catches to skip just this {@code .sfa} -- and never let the raw
- * {@code InternalError} escape and abort recovery of every sibling segment.
- *
- * Portable across filesystems: on a hole-faulting FS (ZFS) the fault is
- * converted to a {@code MmapSegmentException} in {@code openExisting}'s
- * catch; on a hole-zero-filling FS (ext4) page 0 reads back as zeroes, so
- * the magic check fails and throws {@code MmapSegmentException} directly.
- * Either way the file is skippable, not fatal.
+ * A sparse page zero header is positively identified as corrupt from the
+ * bytes returned by positioned read and is therefore skippable per-file.
*/
@Test
public void testUnbackedHeaderPageIsSkippableNotFatal() throws Exception {
@@ -200,101 +147,172 @@ public void testUnbackedHeaderPageIsSkippableNotFatal() throws Exception {
punchSparseTail(path, 0L);
try {
MmapSegment.openExisting(path).close();
- fail("expected MmapSegmentException for an unbacked header page");
+ fail("expected corruption for a sparse zero header");
+ } catch (MmapSegmentCorruptionException expected) {
+ // ok -- SegmentRing's narrow corruption catch skips just this
+ // file instead of aborting recovery of the whole slot.
+ } catch (MmapSegmentException unexpected) {
+ fail("expected quarantinable corruption subtype, got " + unexpected);
+ }
+ });
+ }
+
+ @Test
+ public void testLargeFrameRecoveryCrossesReadBuffer() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ final String path = tmpDir + "/seg-large-frame.sfa";
+ // Put frame 2's length field across the first 64 KiB boundary,
+ // then make its payload span several recovery-buffer refills.
+ final int firstPayloadLen = 64 * 1024
+ - MmapSegment.HEADER_SIZE - MmapSegment.FRAME_HEADER_SIZE - 5;
+ final int largePayloadLen = 3 * 64 * 1024 + 17;
+ assertEquals(64 * 1024 - 5,
+ MmapSegment.HEADER_SIZE + MmapSegment.FRAME_HEADER_SIZE + firstPayloadLen);
+ long expectedEnd = writeSegment(path, 13L, new int[]{firstPayloadLen, largePayloadLen, 31});
+ try (MmapSegment seg = MmapSegment.openExisting(path)) {
+ assertEquals(3L, seg.frameCount());
+ assertEquals(expectedEnd, seg.publishedOffset());
+ assertEquals(0L, seg.tornTailBytes());
+ }
+ });
+ }
+
+ @Test
+ public void testReadErrorFailsClosedBeforeMmap() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ final String path = tmpDir + "/seg-read-error.sfa";
+ writeSegment(path, 17L, new int[]{256});
+ RecoveryReadFacade ff = new RecoveryReadFacade();
+ ff.failReadWithError = true;
+ ff.stopReadsAt = MmapSegment.HEADER_SIZE + MmapSegment.FRAME_HEADER_SIZE + 32L;
+ try {
+ MmapSegment.openExisting(ff, path).close();
+ fail("expected positioned-read failure");
} catch (MmapSegmentException expected) {
- // ok -- SegmentRing's per-file catch skips just this file
- // instead of aborting recovery of the whole slot.
+ assertFalse("operational read errors must not be quarantinable corruption",
+ expected instanceof MmapSegmentCorruptionException);
+ assertTrue(expected.getMessage(), expected.getMessage().contains("could not read"));
}
+ assertEquals("mapping must not start after a failed scan", 0, ff.mmapCalls);
+ assertEquals("open descriptor must be closed", 1, ff.closeCalls);
+ assertTrue("read failure must not mutate the segment", Files.exists(path));
});
}
- /**
- * Portable fail-on-revert guard for the recovery mmap-fault handling on the
- * scan path. Unlike {@link #testRecoveryKeepsFramesBeforeUnbackedTail}
- * (which only faults on ZFS), this maps the file past real EOF via the
- * length-injecting facade, so the scan's read of the beyond-EOF page faults
- * on ext4/xfs too. The fix must recognize that fault and keep
- * recovery safe -- never a JVM abort, never a raw {@code InternalError}
- * escaping into {@code SegmentRing}'s recovery loop. Revert the
- * {@code scanFrames}/{@code openExisting} mmap-fault guard (or fold the CRC
- * back through native {@code Crc32c}) and this errors or aborts the fork.
- *
- * Two handled outcomes are accepted, because which one occurs depends on
- * whether the recovery methods are JIT-compiled at fault time:
- *
- *
Interpreter / C1: {@code scanFrames}'s own
- * {@code catch (InternalError)} fires, so the frame below the tear is
- * recovered and a usable segment is returned.
- *
C2: once {@code scanFrames} is inlined into
- * {@code openExisting}, HotSpot delivers the async unsafe-access
- * {@code InternalError} to {@code openExisting}'s outer
- * {@code catch (Throwable)} instead of the inlined inner one, which
- * converts the file to a skippable {@link MmapSegmentException}.
- * Still fully handled -- {@code SegmentRing} skips just this
- * {@code .sfa} rather than aborting the slot.
- *
- * (The C2 delivery imprecision is a property of HotSpot's async
- * unsafe-access fault handling, not of this seam; the seam only makes it
- * reproducible off ZFS.)
- */
@Test
- public void testScanFaultOnMapPastEofIsHandledAnyFilesystem() throws Exception {
+ public void testReadErrorAfterDetectedTornBytesStillFailsClosed() throws Exception {
TestUtils.assertMemoryLeak(() -> {
- final String path = tmpDir + "/seg-mappasteof-scan.sfa";
- final long page = Files.PAGE_SIZE;
- // One frame that ends exactly on the first page boundary.
- final int payloadLen = (int) (page - MmapSegment.HEADER_SIZE - MmapSegment.FRAME_HEADER_SIZE);
- long boundary = writeSegment(path, 5L, new int[]{payloadLen});
- assertEquals("frame must fill exactly one page", page, boundary);
- // Free every block past the first page: the file is now exactly one
- // (fully backed) page, with nothing beyond it on disk.
- truncateTo(path, page);
- // Report twice the real length so openExisting maps a second,
- // beyond-EOF page; the scan faults reading it on any filesystem.
- FilesFacade ff = new MapPastEofFacade(path, 2 * page);
+ final String path = tmpDir + "/seg-torn-then-read-error.sfa";
+ long lastGood = writeSegment(path, 18L, new int[]{256});
+
+ // Make the failed-frame header non-zero in the first recovery
+ // buffer. Recovery must still read the rest of the suffix so an
+ // operational error in a later buffer cannot hide behind the
+ // already-established torn-tail signal.
+ int fd = Files.openRW(path);
+ assertTrue("openRW failed", fd >= 0);
+ long marker = Unsafe.malloc(1, MemoryTag.NATIVE_DEFAULT);
+ try {
+ Unsafe.getUnsafe().putByte(marker, (byte) 1);
+ assertEquals(1L, Files.write(fd, marker, 1L, lastGood));
+ } finally {
+ Unsafe.free(marker, 1, MemoryTag.NATIVE_DEFAULT);
+ Files.close(fd);
+ }
+
+ RecoveryReadFacade ff = new RecoveryReadFacade();
+ ff.failReadWithError = true;
+ ff.stopReadsAt = 64L * 1024L;
+ try {
+ MmapSegment.openExisting(ff, path).close();
+ fail("expected later positioned-read failure");
+ } catch (MmapSegmentException expected) {
+ assertFalse(expected instanceof MmapSegmentCorruptionException);
+ assertTrue(expected.getMessage(), expected.getMessage().contains("could not read"));
+ }
+ assertTrue("fault must occur after recovery consumed the first buffer", ff.readCalls > 1);
+ assertEquals("mapping must not start after any suffix read fails", 0, ff.mmapCalls);
+ assertEquals("open descriptor must be closed", 1, ff.closeCalls);
+ assertTrue("read failure must not mutate the segment", Files.exists(path));
+ });
+ }
+
+ @Test
+ public void testShortReadFailsClosedBeforeMmap() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ final String path = tmpDir + "/seg-short-read.sfa";
+ writeSegment(path, 19L, new int[]{256});
+ RecoveryReadFacade ff = new RecoveryReadFacade();
+ ff.stopReadsAt = MmapSegment.HEADER_SIZE + MmapSegment.FRAME_HEADER_SIZE + 32L;
+ try {
+ MmapSegment.openExisting(ff, path).close();
+ fail("expected premature EOF failure");
+ } catch (MmapSegmentException expected) {
+ assertFalse("premature EOF must remain an operational failure",
+ expected instanceof MmapSegmentCorruptionException);
+ assertTrue(expected.getMessage(), expected.getMessage().contains("short read"));
+ }
+ assertEquals("mapping must not start after a failed scan", 0, ff.mmapCalls);
+ assertEquals("open descriptor must be closed", 1, ff.closeCalls);
+ assertTrue("short read must not mutate the segment", Files.exists(path));
+ });
+ }
+
+ @Test
+ public void testShortReadsAreRetried() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ final String path = tmpDir + "/seg-partial-reads.sfa";
+ long expectedEnd = writeSegment(path, 23L, new int[]{31, 127, 4097});
+ RecoveryReadFacade ff = new RecoveryReadFacade();
+ ff.maxReadSize = 1024;
try (MmapSegment seg = MmapSegment.openExisting(ff, path)) {
- // Interpreter / C1: graceful partial recovery.
- assertEquals("the frame below the beyond-EOF page must be recovered", 1L, seg.frameCount());
- assertEquals("scan must stop at the beyond-EOF boundary", page, seg.publishedOffset());
- assertEquals("a beyond-EOF page is not a torn write", 0L, seg.tornTailBytes());
- } catch (MmapSegmentException skippedUnderC2) {
- // C2: the inlined fault escaped to openExisting's outer catch and
- // was converted to a per-file skip. Assert it is the recognized
- // mmap fault (not some other data error) so a revert -- which
- // lets a raw InternalError through instead -- still fails here.
- assertTrue(skippedUnderC2.getMessage(),
- skippedUnderC2.getMessage().contains("unsafe memory access operation"));
+ assertEquals(3L, seg.frameCount());
+ assertEquals(expectedEnd, seg.publishedOffset());
}
+ assertTrue("recovery must loop over partial positioned reads", ff.readCalls > 1);
+ assertEquals(1, ff.mmapCalls);
+ assertEquals(1, ff.closeCalls);
});
}
- /**
- * Portable fail-on-revert guard for the {@code openExisting} header-block
- * guard. The file is truncated to empty and the facade reports a full page,
- * so the very first header read (magic) lands on a beyond-EOF page and
- * faults on any filesystem. {@code openExisting} must convert that to a
- * {@link MmapSegmentException} -- the per-file signal {@code SegmentRing}
- * skips on -- not let the raw {@code InternalError} escape and abort recovery
- * of every sibling. Revert the header-block conversion and this throws
- * {@code InternalError} instead of {@code MmapSegmentException}.
- */
@Test
- public void testHeaderFaultOnMapPastEofIsSkippableAnyFilesystem() throws Exception {
+ public void testSizeChangeFailsClosedBeforeMmap() throws Exception {
TestUtils.assertMemoryLeak(() -> {
- final String path = tmpDir + "/seg-mappasteof-header.sfa";
- final long page = Files.PAGE_SIZE;
- writeSegment(path, 9L, new int[]{64});
- // Free every block: the file is now empty, so even page 0 (the
- // header) is beyond EOF under the reported one-page mapping.
- truncateTo(path, 0L);
- FilesFacade ff = new MapPastEofFacade(path, page);
+ final String path = tmpDir + "/seg-size-change.sfa";
+ writeSegment(path, 29L, new int[]{64});
+ RecoveryReadFacade ff = new RecoveryReadFacade();
+ ff.changeLengthAfterScan = true;
try {
MmapSegment.openExisting(ff, path).close();
- fail("expected MmapSegmentException for a beyond-EOF header page");
+ fail("expected unstable-size failure");
} catch (MmapSegmentException expected) {
- // ok -- SegmentRing's per-file catch skips just this file
- // instead of aborting recovery of the whole slot.
+ assertFalse(expected instanceof MmapSegmentCorruptionException);
+ assertTrue(expected.getMessage(), expected.getMessage().contains("size changed"));
}
+ assertEquals("mapping must not start for an unstable file", 0, ff.mmapCalls);
+ assertEquals("open descriptor must be closed", 1, ff.closeCalls);
+ assertTrue("size-race detection must not mutate the segment", Files.exists(path));
+ });
+ }
+
+ @Test
+ public void testSizeChangeWhileMappingFailsClosedAndUnmaps() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ final String path = tmpDir + "/seg-size-change-while-mapping.sfa";
+ writeSegment(path, 31L, new int[]{64});
+ RecoveryReadFacade ff = new RecoveryReadFacade();
+ ff.changeLengthOnMmap = true;
+ try {
+ MmapSegment.openExisting(ff, path).close();
+ fail("expected size change during mmap to fail recovery");
+ } catch (MmapSegmentException expected) {
+ assertFalse(expected instanceof MmapSegmentCorruptionException);
+ assertTrue(expected.getMessage(), expected.getMessage().contains("size changed while mapping"));
+ }
+ assertEquals("scan should map only after validation", 1, ff.mmapCalls);
+ assertEquals("rejected mapping must be released", 1, ff.munmapCalls);
+ assertEquals("open descriptor must be closed", 1, ff.closeCalls);
+ assertTrue("injected size observation must not mutate the segment", Files.exists(path));
});
}
@@ -311,9 +329,7 @@ private static long writeSegment(String path, long baseSeq, int[] payloadLens) {
}
long buf = Unsafe.malloc(maxLen, MemoryTag.NATIVE_DEFAULT);
try {
- for (int i = 0; i < maxLen; i++) {
- Unsafe.getUnsafe().putByte(buf + i, (byte) (i | 1)); // all non-zero
- }
+ Unsafe.getUnsafe().setMemory(buf, maxLen, (byte) 1);
try (MmapSegment seg = MmapSegment.create(path, baseSeq, SEGMENT_BYTES)) {
for (int len : payloadLens) {
assertTrue("append must fit", seg.tryAppend(buf, len) >= 0);
@@ -329,8 +345,8 @@ private static long writeSegment(String path, long baseSeq, int[] payloadLens) {
* Turns {@code [keepBytes, SEGMENT_BYTES)} of the file into an unbacked
* sparse hole: truncate down to {@code keepBytes} (frees the tail blocks),
* then back up to {@code SEGMENT_BYTES} (re-extends the logical size without
- * allocating blocks). Recovery maps the full stat length, so the hole is
- * inside the mapping -- reads of it fault on ZFS and zero-fill on ext4.
+ * allocating blocks). Positioned recovery reads observe the hole as zeroes
+ * without dereferencing it through mmap.
*/
private static void punchSparseTail(String path, long keepBytes) {
int fd = Files.openRW(path);
@@ -344,36 +360,20 @@ private static void punchSparseTail(String path, long keepBytes) {
}
/**
- * Shrinks the file to {@code keepBytes}, freeing every block past it, and
- * leaves it there (no re-extend). Combined with a facade that reports a
- * larger length, the freed region becomes a beyond-EOF part of the mapping
- * that faults on read on any filesystem.
+ * Positioned-read fault seam. Calls not involved in recovery scanning
+ * delegate to the production {@link FilesFacade#INSTANCE}.
*/
- private static void truncateTo(String path, long keepBytes) {
- int fd = Files.openRW(path);
- assertTrue("openRW failed", fd >= 0);
- try {
- assertTrue("truncate failed", Files.truncate(fd, keepBytes));
- } finally {
- Files.close(fd);
- }
- }
-
- /**
- * A {@link FilesFacade} that reports an inflated stat length for one target
- * path so {@code openExisting} maps that file past end-of-file (see
- * {@link FilesFacade#length(String)}); every other call, including
- * {@code length} for any other path, delegates to the production
- * {@link FilesFacade#INSTANCE}.
- */
- private static final class MapPastEofFacade implements FilesFacade {
- private final long reportedLength;
- private final String targetPath;
-
- MapPastEofFacade(String targetPath, long reportedLength) {
- this.targetPath = targetPath;
- this.reportedLength = reportedLength;
- }
+ private static final class RecoveryReadFacade implements FilesFacade {
+ private boolean changeLengthAfterScan;
+ private boolean changeLengthOnMmap;
+ private int closeCalls;
+ private boolean failReadWithError;
+ private int lengthCalls;
+ private int maxReadSize = Integer.MAX_VALUE;
+ private int mmapCalls;
+ private int munmapCalls;
+ private int readCalls;
+ private long stopReadsAt = Long.MAX_VALUE;
@Override
public boolean allocate(int fd, long size) {
@@ -387,6 +387,7 @@ public long allocNativePath(String path) {
@Override
public int close(int fd) {
+ closeCalls++;
return INSTANCE.close(fd);
}
@@ -432,7 +433,13 @@ public int fsync(int fd) {
@Override
public long length(int fd) {
- return INSTANCE.length(fd);
+ long length = INSTANCE.length(fd);
+ lengthCalls++;
+ if ((changeLengthAfterScan && lengthCalls > 1)
+ || (changeLengthOnMmap && mmapCalls > 0)) {
+ return length - 1L;
+ }
+ return length;
}
@Override
@@ -442,7 +449,7 @@ public long length(long pathPtr) {
@Override
public long length(String path) {
- return targetPath.equals(path) ? reportedLength : INSTANCE.length(path);
+ return INSTANCE.length(path);
}
@Override
@@ -455,6 +462,18 @@ public int mkdir(String path, int mode) {
return INSTANCE.mkdir(path, mode);
}
+ @Override
+ public long mmap(int fd, long len, long offset, int flags, int memoryTag) {
+ mmapCalls++;
+ return INSTANCE.mmap(fd, len, offset, flags, memoryTag);
+ }
+
+ @Override
+ public void munmap(long address, long len, int memoryTag) {
+ munmapCalls++;
+ INSTANCE.munmap(address, len, memoryTag);
+ }
+
@Override
public int openCleanRW(String path) {
return INSTANCE.openCleanRW(path);
@@ -477,7 +496,15 @@ public int openRW(long pathPtr) {
@Override
public long read(int fd, long addr, long len, long offset) {
- return INSTANCE.read(fd, addr, len, offset);
+ readCalls++;
+ if (offset >= stopReadsAt) {
+ return failReadWithError ? -1L : 0L;
+ }
+ long delegatedLen = Math.min(len, (long) maxReadSize);
+ if (delegatedLen > stopReadsAt - offset) {
+ delegatedLen = stopReadsAt - offset;
+ }
+ return INSTANCE.read(fd, addr, delegatedLen, offset);
}
@Override
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/MmapSegmentTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/MmapSegmentTest.java
index 177ee5f6..b63a0e97 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/MmapSegmentTest.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/MmapSegmentTest.java
@@ -35,6 +35,7 @@
import org.junit.Before;
import org.junit.Test;
+import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotEquals;
@@ -55,6 +56,60 @@ public void tearDown() {
TestUtils.removeTmpDir(tmpDir);
}
+ @Test
+ public void testBarrierChecksMsyncAndFsyncFailures() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ long payload = Unsafe.malloc(32, MemoryTag.NATIVE_DEFAULT);
+ try {
+ FaultyFilesFacade success = new FaultyFilesFacade();
+ try (MmapSegment segment = MmapSegment.create(
+ success, tmpDir + "/barrier-ok.sfa", 0L, 4096L)) {
+ fillPattern(payload, 32, 1);
+ assertTrue(segment.tryAppend(payload, 32) >= 0L);
+ assertFalse(segment.isPublishedDurable());
+ assertEquals(segment.publishedOffset(), segment.syncPublished());
+ assertTrue(segment.isPublishedDurable());
+ assertEquals(1, success.msyncCalls);
+ assertEquals(1, success.fsyncCalls);
+ }
+
+ FaultyFilesFacade msyncFailure = new FaultyFilesFacade();
+ msyncFailure.failOnMsync = true;
+ try (MmapSegment segment = MmapSegment.create(
+ msyncFailure, tmpDir + "/barrier-msync-fail.sfa", 0L, 4096L)) {
+ assertTrue(segment.tryAppend(payload, 32) >= 0L);
+ try {
+ segment.syncPublished();
+ fail("expected msync failure");
+ } catch (MmapSegmentException expected) {
+ assertTrue(expected.getMessage().contains("sync segment data"));
+ }
+ assertFalse(segment.isPublishedDurable());
+ assertEquals(1, msyncFailure.msyncCalls);
+ assertEquals(0, msyncFailure.fsyncCalls);
+ }
+
+ FaultyFilesFacade fsyncFailure = new FaultyFilesFacade();
+ fsyncFailure.failOnFsync = true;
+ try (MmapSegment segment = MmapSegment.create(
+ fsyncFailure, tmpDir + "/barrier-fsync-fail.sfa", 0L, 4096L)) {
+ assertTrue(segment.tryAppend(payload, 32) >= 0L);
+ try {
+ segment.syncPublished();
+ fail("expected fsync failure");
+ } catch (MmapSegmentException expected) {
+ assertTrue(expected.getMessage().contains("sync segment file"));
+ }
+ assertFalse(segment.isPublishedDurable());
+ assertEquals(1, fsyncFailure.msyncCalls);
+ assertEquals(1, fsyncFailure.fsyncCalls);
+ }
+ } finally {
+ Unsafe.free(payload, 32, MemoryTag.NATIVE_DEFAULT);
+ }
+ });
+ }
+
@Test
public void testCapacityRemainingAccountsForFrameEnvelope() throws Exception {
TestUtils.assertMemoryLeak(() -> {
@@ -80,6 +135,27 @@ public void testCapacityRemainingAccountsForFrameEnvelope() throws Exception {
});
}
+ @Test
+ public void testCompatibilityMsyncKeepsLegacyCallPattern() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ long payload = Unsafe.malloc(32, MemoryTag.NATIVE_DEFAULT);
+ try {
+ FaultyFilesFacade filesFacade = new FaultyFilesFacade();
+ try (MmapSegment segment = MmapSegment.create(
+ filesFacade, tmpDir + "/compat-msync.sfa", 0L, 4096L)) {
+ assertTrue(segment.tryAppend(payload, 32) >= 0L);
+ segment.msync();
+ segment.msync();
+ assertEquals(2, filesFacade.msyncCalls);
+ assertEquals(0, filesFacade.fsyncCalls);
+ assertFalse(segment.isPublishedDurable());
+ }
+ } finally {
+ Unsafe.free(payload, 32, MemoryTag.NATIVE_DEFAULT);
+ }
+ });
+ }
+
@Test
public void testCreateAppendCloseReopenScansAllFrames() throws Exception {
TestUtils.assertMemoryLeak(() -> {
@@ -128,7 +204,7 @@ public void testCreateFailsCleanlyWhenAllocateReturnsFalse() throws Exception {
assertTrue(expected.getMessage(),
expected.getMessage().contains("pre-allocation failed"));
}
- assertEquals("openCleanRW must run exactly once", 1, ff.openCleanRWCalls);
+ assertEquals("exclusive create must run exactly once", 1, ff.openRWExclusiveCalls);
assertEquals("allocate must run exactly once", 1, ff.allocateCalls);
assertEquals("fd must be closed on allocate failure", 1, ff.closeCalls);
assertEquals("file must be removed on allocate failure", 1, ff.removeCalls);
@@ -138,28 +214,31 @@ public void testCreateFailsCleanlyWhenAllocateReturnsFalse() throws Exception {
}
@Test
- public void testCreateFailsCleanlyWhenOpenCleanRWReturnsMinusOne() throws Exception {
+ public void testCreateFailsCleanlyWhenExclusiveOpenReturnsMinusOne() throws Exception {
TestUtils.assertMemoryLeak(() -> {
String path = tmpDir + "/seg-noopen.sfa";
long sizeBytes = MmapSegment.HEADER_SIZE
+ MmapSegment.FRAME_HEADER_SIZE + 64;
FaultyFilesFacade ff = new FaultyFilesFacade();
- ff.failOnOpenCleanRW = true;
+ ff.failOnOpenRWExclusive = true;
try {
MmapSegment.create(ff, path, 0L, sizeBytes).close();
- fail("expected MmapSegmentException from openCleanRW returning -1");
+ fail("expected MmapSegmentException from openRWExclusive returning -1");
} catch (MmapSegmentException expected) {
assertTrue(expected.getMessage(),
- expected.getMessage().contains("openCleanRW failed"));
+ expected.getMessage().contains("exclusive create failed"));
}
- assertEquals("openCleanRW must run exactly once", 1, ff.openCleanRWCalls);
- assertEquals("allocate must not run after openCleanRW failure",
+ assertEquals("exclusive create must run exactly once", 1, ff.openRWExclusiveCalls);
+ assertEquals("allocate must not run after exclusive-create failure",
0, ff.allocateCalls);
assertEquals("close must not be called when no fd was opened",
0, ff.closeCalls);
- assertEquals("remove must not be called when openCleanRW failed",
+ // With O_EXCL semantics a create failure can mean "path already
+ // exists and belongs to another lifecycle" -- create() must NOT
+ // unlink a file it never owned.
+ assertEquals("remove must not be called when exclusive create failed",
0, ff.removeCalls);
- assertFalse("no file should exist when openCleanRW failed",
+ assertFalse("no file should exist when exclusive create failed",
Files.exists(path));
});
}
@@ -198,7 +277,7 @@ public void testCreateRepeatedAllocateFailuresDoNotAccumulateOrphans() throws Ex
}
assertEquals("no orphan files may survive repeated allocate failures",
0, survivors);
- assertEquals(attempts, ff.openCleanRWCalls);
+ assertEquals(attempts, ff.openRWExclusiveCalls);
assertEquals(attempts, ff.allocateCalls);
assertEquals(attempts, ff.closeCalls);
assertEquals(attempts, ff.removeCalls);
@@ -212,9 +291,9 @@ public void testFirstFrameCrcCorruptionFlagsTornTailAndPreservesFile() throws Ex
// frames are followed by garbage. None cover frame[0] itself
// being corrupt — yet a single bit-flip on the CRC of frame[0]
// at rest (bit-rot, partial-page-write at crash) is the
- // worst-case data-loss trigger: scanFrames bails at HEADER_SIZE
- // and frameCount drops to 0, even though valid frames still
- // sit on disk past the corrupt header.
+ // worst-case data-loss trigger: the recovery scan bails at
+ // HEADER_SIZE and frameCount drops to 0, even though valid frames
+ // still sit on disk past the corrupt header.
//
// Contract: tornTailBytes() must be non-zero (because non-zero
// bytes exist past the last good frame), and openExisting
@@ -255,7 +334,7 @@ public void testFirstFrameCrcCorruptionFlagsTornTailAndPreservesFile() throws Ex
Files.exists(path));
try (MmapSegment seg = MmapSegment.openExisting(path)) {
- assertEquals("scanFrames must bail at the corrupt frame[0]",
+ assertEquals("recovery scan must bail at the corrupt frame[0]",
0L, seg.frameCount());
assertEquals("publishedOffset must rewind to the header end",
MmapSegment.HEADER_SIZE, seg.publishedOffset());
@@ -265,6 +344,16 @@ public void testFirstFrameCrcCorruptionFlagsTornTailAndPreservesFile() throws Ex
+ "hot-spare leftover; got " + seg.tornTailBytes(),
seg.tornTailBytes() > 0L);
}
+ // A second open must see the SAME evidence: openExisting is
+ // observe-only, so the valid-CRC frames past the corrupt
+ // frame[0] -- the only copy of that data -- stay on disk for
+ // operator extraction (chain-level recovery of such a member
+ // fails closed without mutating it).
+ try (MmapSegment seg = MmapSegment.openExisting(path)) {
+ assertEquals(0L, seg.frameCount());
+ assertEquals("observe-only recovery must preserve the residue",
+ 4096L - MmapSegment.HEADER_SIZE, seg.tornTailBytes());
+ }
assertTrue("openExisting must not unlink the corrupt file",
Files.exists(path));
} finally {
@@ -273,6 +362,47 @@ public void testFirstFrameCrcCorruptionFlagsTornTailAndPreservesFile() throws Ex
});
}
+ @Test
+ public void testAllZeroCorruptFrameHeaderDoesNotHideNonZeroSuffix() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ String path = tmpDir + "/seg-zero-frame-header.sfa";
+ long payload = Unsafe.malloc(32, MemoryTag.NATIVE_DEFAULT);
+ try {
+ fillPattern(payload, 32, 0);
+ try (MmapSegment seg = MmapSegment.create(path, 0L, 4096)) {
+ assertTrue(seg.tryAppend(payload, 32) >= 0L);
+ assertTrue(seg.tryAppend(payload, 32) >= 0L);
+ seg.msync();
+ }
+
+ // Zero both the CRC and length of frame[0]. Looking only at
+ // this 8-byte header would misclassify the segment as a clean
+ // empty spare even though its payload and frame[1] remain.
+ int fd = Files.openRW(path);
+ assertTrue("openRW must succeed", fd >= 0);
+ long zeroHeader = Unsafe.malloc(MmapSegment.FRAME_HEADER_SIZE, MemoryTag.NATIVE_DEFAULT);
+ try {
+ Unsafe.getUnsafe().setMemory(zeroHeader, MmapSegment.FRAME_HEADER_SIZE, (byte) 0);
+ assertEquals(MmapSegment.FRAME_HEADER_SIZE,
+ Files.write(fd, zeroHeader, MmapSegment.FRAME_HEADER_SIZE, MmapSegment.HEADER_SIZE));
+ Files.fsync(fd);
+ } finally {
+ Unsafe.free(zeroHeader, MmapSegment.FRAME_HEADER_SIZE, MemoryTag.NATIVE_DEFAULT);
+ Files.close(fd);
+ }
+
+ try (MmapSegment seg = MmapSegment.openExisting(path)) {
+ assertEquals(0L, seg.frameCount());
+ assertEquals(MmapSegment.HEADER_SIZE, seg.publishedOffset());
+ assertEquals("non-zero bytes later in the suffix must preserve corruption evidence",
+ 4096L - MmapSegment.HEADER_SIZE, seg.tornTailBytes());
+ }
+ } finally {
+ Unsafe.free(payload, 32, MemoryTag.NATIVE_DEFAULT);
+ }
+ });
+ }
+
@Test
public void testFullSegmentRejectsFurtherAppends() throws Exception {
TestUtils.assertMemoryLeak(() -> {
@@ -372,6 +502,315 @@ public void testRecoveryDoesNotFlagFreshUnusedSegment() throws Exception {
});
}
+ @Test
+ public void testOpenExistingObservesTornTailWithoutMutation() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ // openExisting must be a pure observer: a torn tail can hide
+ // unreachable valid-CRC frames past a mid-file tear -- the only
+ // copy of real payloads -- and whether they may be destroyed is
+ // a chain-level decision (SegmentRing sanitizes the segment it
+ // resumes as active, plus proven-dead sealed residue, and fails
+ // closed preserving the bytes otherwise). Repeated opens must
+ // keep reporting the same observation over identical bytes.
+ String path = tmpDir + "/seg-observe.sfa";
+ long buf = Unsafe.malloc(16, MemoryTag.NATIVE_DEFAULT);
+ long lastGood;
+ try {
+ try (MmapSegment seg = MmapSegment.create(path, 0L, 4096)) {
+ for (int i = 0; i < 3; i++) {
+ fillPattern(buf, 16, i);
+ seg.tryAppend(buf, 16);
+ }
+ lastGood = seg.publishedOffset();
+ long addr = seg.address();
+ for (long off = lastGood; off + 4 <= 4096; off += 4) {
+ Unsafe.getUnsafe().putInt(addr + off, 0xCAFEBABE);
+ }
+ seg.msync();
+ }
+ byte[] before = java.nio.file.Files.readAllBytes(java.nio.file.Paths.get(path));
+ for (int open = 0; open < 2; open++) {
+ try (MmapSegment seg = MmapSegment.openExisting(path)) {
+ assertEquals("open #" + open + " must report the residue",
+ 4096L - lastGood, seg.tornTailBytes());
+ assertEquals(3L, seg.frameCount());
+ assertTrue(seg.hasUnsanitizedTornTail());
+ }
+ assertArrayEquals(
+ "observe-only recovery must not change a single byte (open #" + open + ')',
+ before, java.nio.file.Files.readAllBytes(java.nio.file.Paths.get(path)));
+ }
+ } finally {
+ Unsafe.free(buf, 16, MemoryTag.NATIVE_DEFAULT);
+ }
+ });
+ }
+
+ @Test
+ public void testSanitizeTornTailZeroesResidueOnDisk() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ // Once ring recovery proves the residue is not load-bearing it
+ // calls sanitizeTornTail: the open keeps reporting the observed
+ // residue (operator signal), but after sanitization a later open
+ // of the untouched file must find a clean zero suffix and intact
+ // frames.
+ String path = tmpDir + "/seg-zeroed.sfa";
+ long buf = Unsafe.malloc(16, MemoryTag.NATIVE_DEFAULT);
+ long lastGood;
+ try {
+ try (MmapSegment seg = MmapSegment.create(path, 0L, 4096)) {
+ for (int i = 0; i < 3; i++) {
+ fillPattern(buf, 16, i);
+ seg.tryAppend(buf, 16);
+ }
+ lastGood = seg.publishedOffset();
+ long addr = seg.address();
+ for (long off = lastGood; off + 4 <= 4096; off += 4) {
+ Unsafe.getUnsafe().putInt(addr + off, 0xCAFEBABE);
+ }
+ seg.msync();
+ }
+ try (MmapSegment seg = MmapSegment.openExisting(path)) {
+ assertEquals("recovery must report the observed residue",
+ 4096L - lastGood, seg.tornTailBytes());
+ assertTrue(seg.hasUnsanitizedTornTail());
+ seg.sanitizeTornTail();
+ assertEquals("the observation must survive sanitization for diagnostics",
+ 4096L - lastGood, seg.tornTailBytes());
+ assertFalse(seg.hasUnsanitizedTornTail());
+ // Idempotent: a second call is a no-op, not a re-zero.
+ seg.sanitizeTornTail();
+ }
+ try (MmapSegment seg = MmapSegment.openExisting(path)) {
+ assertEquals("sanitize must have zeroed the residue on disk",
+ 0L, seg.tornTailBytes());
+ assertEquals(lastGood, seg.publishedOffset());
+ assertEquals(3L, seg.frameCount());
+ }
+ } finally {
+ Unsafe.free(buf, 16, MemoryTag.NATIVE_DEFAULT);
+ }
+ });
+ }
+
+ @Test
+ public void testResealGapResidueCannotSurviveRecoveryAppends() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ // Two-crash reseal regression at segment level. Crash #1 leaves
+ // residue up to the file end; recovery resumes at lastGood; the
+ // resumed writer fills the segment but its last frame stops short
+ // of the file end (the remaining gap cannot fit another frame).
+ // Pre-fix the stale residue survived in that gap, so the segment
+ // -- sealed as-is by rotation -- failed the sealed-suffix-must-
+ // be-zero check on the NEXT recovery, permanently failing startup.
+ long segSize = MmapSegment.HEADER_SIZE
+ + 4 * (MmapSegment.FRAME_HEADER_SIZE + 16)
+ + 12; // reseal gap: a 5th 24-byte frame can never fit
+ String path = tmpDir + "/seg-reseal.sfa";
+ long buf = Unsafe.malloc(16, MemoryTag.NATIVE_DEFAULT);
+ try {
+ // Session 1: two frames, then a crash mid-write near the end
+ // -- garbage from the torn frame all the way to the file end.
+ try (MmapSegment seg = MmapSegment.create(path, 0L, segSize)) {
+ fillPattern(buf, 16, 0);
+ seg.tryAppend(buf, 16);
+ fillPattern(buf, 16, 1);
+ seg.tryAppend(buf, 16);
+ long addr = seg.address();
+ for (long off = seg.publishedOffset(); off + 4 <= segSize; off += 4) {
+ Unsafe.getUnsafe().putInt(addr + off, 0xCAFEBABE);
+ }
+ seg.msync();
+ }
+ // Recovery #1 + session 2: fill the segment to its rotation
+ // point. The 4th frame ends 12 bytes short of the file end --
+ // a region session 2 never overwrites. Sanitize before the
+ // appends, exactly as SegmentRing.recover does for the
+ // segment it resumes as active.
+ try (MmapSegment seg = MmapSegment.openExisting(path)) {
+ assertEquals(2L, seg.frameCount());
+ seg.sanitizeTornTail();
+ fillPattern(buf, 16, 2);
+ assertTrue(seg.tryAppend(buf, 16) >= 0);
+ fillPattern(buf, 16, 3);
+ assertTrue(seg.tryAppend(buf, 16) >= 0);
+ assertEquals("next append must not fit (rotation would seal here)",
+ -1L, seg.tryAppend(buf, 16));
+ seg.msync();
+ }
+ // Recovery #2: the state a sealed segment presents at the next
+ // startup. Its suffix must be clean or ring recovery bricks.
+ try (MmapSegment seg = MmapSegment.openExisting(path)) {
+ assertEquals("all four frames must recover", 4L, seg.frameCount());
+ assertEquals("no residue may survive in the reseal gap: a sealed "
+ + "segment with a non-zero suffix permanently fails "
+ + "ring recovery",
+ 0L, seg.tornTailBytes());
+ }
+ } finally {
+ Unsafe.free(buf, 16, MemoryTag.NATIVE_DEFAULT);
+ }
+ });
+ }
+
+ @Test
+ public void testDiscardedStaleFrameNotResurrectedByLaterRecovery() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ // The frame envelope [crc(len|payload)][len][payload] binds
+ // neither position nor FSN, so a stale frame with a valid CRC
+ // sitting past the tear -- byte-aligned with the resumed writer's
+ // frames, natural with fixed-size records -- would be silently
+ // re-adopted by the next recovery scan at a recycled FSN.
+ // Recovery #1 discarded it; recovery #2 must not bring it back.
+ String path = tmpDir + "/seg-stale.sfa";
+ long buf = Unsafe.malloc(16, MemoryTag.NATIVE_DEFAULT);
+ long frameB;
+ try {
+ try (MmapSegment seg = MmapSegment.create(path, 0L, 4096)) {
+ fillPattern(buf, 16, 0);
+ seg.tryAppend(buf, 16); // frame A, FSN 0
+ fillPattern(buf, 16, 1);
+ frameB = seg.tryAppend(buf, 16); // frame B, FSN 1
+ fillPattern(buf, 16, 2);
+ seg.tryAppend(buf, 16); // frame C, FSN 2
+ // The crash tears frame B only: flip its CRC. C keeps a
+ // valid CRC at a frame-aligned offset past the tear.
+ long addr = seg.address();
+ int crc = Unsafe.getUnsafe().getInt(addr + frameB);
+ Unsafe.getUnsafe().putInt(addr + frameB, crc ^ 0x5A5A5A5A);
+ seg.msync();
+ }
+ // Recovery #1: B fails CRC, so the scan stops at B; B and C
+ // are discarded, and the resume-as-active sanitization (the
+ // same call SegmentRing.recover makes) zeroes them on disk.
+ try (MmapSegment seg = MmapSegment.openExisting(path)) {
+ assertEquals("scan must stop at the torn frame", 1L, seg.frameCount());
+ seg.sanitizeTornTail();
+ // The resumed writer re-issues FSN 1 with a fresh payload
+ // of the old B's exact size -- the byte-aligned case.
+ fillPattern(buf, 16, 7);
+ assertEquals(frameB, seg.tryAppend(buf, 16));
+ seg.msync();
+ }
+ // Recovery #2: pre-fix the scan walked A, B' and then adopted
+ // the STALE C (valid CRC) as a live frame -- data recovery #1
+ // had already discarded, resurrected behind the engine's back.
+ try (MmapSegment seg = MmapSegment.openExisting(path)) {
+ assertEquals("stale frame C must stay discarded, not be "
+ + "resurrected at a recycled FSN", 2L, seg.frameCount());
+ assertEquals(0L, seg.tornTailBytes());
+ }
+ } finally {
+ Unsafe.free(buf, 16, MemoryTag.NATIVE_DEFAULT);
+ }
+ });
+ }
+
+ @Test
+ public void testSanitizeTornTailSyncFailureFailsClosed() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ // The sanitize barrier is load-bearing: if the zeroes cannot be
+ // made durable, ring recovery must fail closed rather than expose
+ // a segment whose reseal could permanently fail the next startup.
+ // openExisting itself runs no barrier -- it never writes. A
+ // failed attempt may still leave zeroes in the page cache; that
+ // is safe (a retry either sees a clean tail or re-zeroes), so
+ // the follow-up open with a healthy facade must succeed.
+ long buf = Unsafe.malloc(16, MemoryTag.NATIVE_DEFAULT);
+ try {
+ String msyncPath = tmpDir + "/seg-zero-msync-fail.sfa";
+ String fsyncPath = tmpDir + "/seg-zero-fsync-fail.sfa";
+ for (String path : new String[]{msyncPath, fsyncPath}) {
+ try (MmapSegment seg = MmapSegment.create(path, 0L, 4096)) {
+ fillPattern(buf, 16, 0);
+ seg.tryAppend(buf, 16);
+ long addr = seg.address();
+ Unsafe.getUnsafe().putInt(addr + seg.publishedOffset(), 0xCAFEBABE);
+ Unsafe.getUnsafe().putInt(addr + seg.publishedOffset() + 4, 16);
+ seg.msync();
+ }
+ }
+
+ FaultyFilesFacade msyncFailure = new FaultyFilesFacade();
+ msyncFailure.failOnMsync = true;
+ try (MmapSegment seg = MmapSegment.openExisting(msyncFailure, msyncPath)) {
+ assertEquals("observe-only open must not run the zeroing barrier",
+ 0, msyncFailure.msyncCalls);
+ try {
+ seg.sanitizeTornTail();
+ fail("expected sanitize to abort when the zeroed tail cannot be msync'd");
+ } catch (MmapSegmentException expected) {
+ assertTrue(expected.getMessage(),
+ expected.getMessage().contains("zeroed torn tail"));
+ }
+ assertTrue("a failed barrier must leave the residue unsanitized",
+ seg.hasUnsanitizedTornTail());
+ }
+ assertEquals(1, msyncFailure.msyncCalls);
+ assertEquals(0, msyncFailure.fsyncCalls);
+ try (MmapSegment seg = MmapSegment.openExisting(msyncPath)) {
+ assertEquals(1L, seg.frameCount());
+ }
+
+ FaultyFilesFacade fsyncFailure = new FaultyFilesFacade();
+ fsyncFailure.failOnFsync = true;
+ try (MmapSegment seg = MmapSegment.openExisting(fsyncFailure, fsyncPath)) {
+ try {
+ seg.sanitizeTornTail();
+ fail("expected sanitize to abort when the zeroed tail cannot be fsync'd");
+ } catch (MmapSegmentException expected) {
+ assertTrue(expected.getMessage(),
+ expected.getMessage().contains("zeroed torn tail"));
+ }
+ assertTrue("a failed barrier must leave the residue unsanitized",
+ seg.hasUnsanitizedTornTail());
+ }
+ assertEquals(1, fsyncFailure.msyncCalls);
+ assertEquals(1, fsyncFailure.fsyncCalls);
+ try (MmapSegment seg = MmapSegment.openExisting(fsyncPath)) {
+ assertEquals(1L, seg.frameCount());
+ }
+ } finally {
+ Unsafe.free(buf, 16, MemoryTag.NATIVE_DEFAULT);
+ }
+ });
+ }
+
+ @Test
+ public void testSanitizeTornTailRefusedAfterAppendsResume() throws Exception {
+ TestUtils.assertMemoryLeak(() -> {
+ // Zeroing [residueStart, EOF) after appends resumed would destroy
+ // freshly appended frames, so that ordering is rejected outright:
+ // sanitization is a recovery-time-only operation that must run
+ // before the segment takes traffic.
+ String path = tmpDir + "/seg-sanitize-late.sfa";
+ long buf = Unsafe.malloc(16, MemoryTag.NATIVE_DEFAULT);
+ try {
+ try (MmapSegment seg = MmapSegment.create(path, 0L, 4096)) {
+ fillPattern(buf, 16, 0);
+ seg.tryAppend(buf, 16);
+ long addr = seg.address();
+ Unsafe.getUnsafe().putInt(addr + seg.publishedOffset(), 0xCAFEBABE);
+ seg.msync();
+ }
+ try (MmapSegment seg = MmapSegment.openExisting(path)) {
+ assertTrue(seg.hasUnsanitizedTornTail());
+ fillPattern(buf, 16, 1);
+ assertTrue(seg.tryAppend(buf, 16) >= 0);
+ try {
+ seg.sanitizeTornTail();
+ fail("sanitize after appends must be refused");
+ } catch (MmapSegmentException expected) {
+ assertTrue(expected.getMessage(),
+ expected.getMessage().contains("before appends resume"));
+ }
+ }
+ } finally {
+ Unsafe.free(buf, 16, MemoryTag.NATIVE_DEFAULT);
+ }
+ });
+ }
+
@Test
public void testRecoverySignalsTornTailWithByteCount() throws Exception {
TestUtils.assertMemoryLeak(() -> {
@@ -494,10 +933,34 @@ private static final class FaultyFilesFacade implements FilesFacade {
int allocateCalls;
int closeCalls;
boolean failOnAllocate;
+ boolean failOnFsync;
+ boolean failOnMsync;
boolean failOnOpenCleanRW;
+ boolean failOnOpenRWExclusive;
+ int fsyncCalls;
+ int msyncCalls;
int openCleanRWCalls;
+ int openRWExclusiveCalls;
int removeCalls;
+ @Override
+ public int openRWExclusive(String path) {
+ openRWExclusiveCalls++;
+ if (failOnOpenRWExclusive) {
+ return -1;
+ }
+ return INSTANCE.openRWExclusive(path);
+ }
+
+ @Override
+ public int openRWExclusive(long pathPtr) {
+ openRWExclusiveCalls++;
+ if (failOnOpenRWExclusive) {
+ return -1;
+ }
+ return INSTANCE.openRWExclusive(pathPtr);
+ }
+
@Override
public long allocNativePath(String path) {
return INSTANCE.allocNativePath(path);
@@ -555,7 +1018,8 @@ public void freeNativePath(long pathPtr) {
@Override
public int fsync(int fd) {
- return INSTANCE.fsync(fd);
+ fsyncCalls++;
+ return failOnFsync ? -1 : INSTANCE.fsync(fd);
}
@Override
@@ -578,6 +1042,12 @@ public int mkdir(String path, int mode) {
return INSTANCE.mkdir(path, mode);
}
+ @Override
+ public int msync(long addr, long len, boolean async) {
+ msyncCalls++;
+ return failOnMsync ? -1 : INSTANCE.msync(addr, len, async);
+ }
+
@Override
public int openCleanRW(String path) {
openCleanRWCalls++;
diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/PrReviewRedTests.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/PrReviewRedTests.java
index e551c6f8..ae987fee 100644
--- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/PrReviewRedTests.java
+++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/PrReviewRedTests.java
@@ -79,8 +79,8 @@ public void tearDown() {
/**
* Finding C1 / C10 — first-frame CRC corruption silently deletes the segment.
*
- * If frame[0] of a recovered .sfa fails CRC validation, scanFrames returns
- * lastGood=HEADER_SIZE, countFrames returns 0, and SegmentRing.openExisting
+ * If frame[0] of a recovered .sfa fails CRC validation, the recovery scan
+ * returns lastGood=HEADER_SIZE and frameCount=0, and SegmentRing.openExisting
* unlinks the file as an "empty hot-spare leftover" — destroying every frame
* that physically followed the corrupt header. The torn-tail WARN inside
* MmapSegment.openExisting is dropped on the floor.
@@ -128,8 +128,8 @@ public void testC1_recoveryMustNotUnlinkSegmentWithCorruptFirstFrame() throws Ex
// Run recovery.
SegmentRing recovered = SegmentRing.openExisting(tmpDir, 64 * 1024);
try {
- // The bug: openExisting sees frameCount=0 (because scanFrames
- // bailed at the corrupt frame[0]) and treats the segment as
+ // The bug: openExisting sees frameCount=0 (because the recovery
+ // scan bailed at the corrupt frame[0]) and treats the segment as
// an "empty hot-spare leftover" — closing AND UNLINKING the
// file. The user's frames 1, 2, 3 are gone forever; the only
// record was a WARN log line that's already been emitted.
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 3d7c6a7c..46ea1b0d 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
@@ -27,16 +27,13 @@
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.bytes.DirectByteSink;
import io.questdb.client.std.Files;
-import io.questdb.client.std.str.DirectUtf8Sink;
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.lang.reflect.Field;
import java.nio.file.Paths;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
@@ -116,12 +113,9 @@ public void testManagerDoesNotInstallSpareIntoClosedRing() throws Exception {
manager.close();
}
- Field hotSpareField = SegmentRing.class.getDeclaredField("hotSpare");
- hotSpareField.setAccessible(true);
-
int leaked = 0;
for (int i = 0; i < ITERATIONS; i++) {
- Object hs = hotSpareField.get(rings[i]);
+ Object hs = rings[i].getHotSpareForTesting();
if (hs != null) {
leaked++;
// Don't leak in the test: close the survivor.
@@ -204,6 +198,391 @@ public void testCloseDoesNotFreePathScratchWhenWorkerStillAlive() throws Excepti
});
}
+ /**
+ * Pins the claim-time registration gate at the top of
+ * {@code SegmentManager.serviceRing}: a snapshot entry whose ring was
+ * deregistered BEFORE the worker claims it must be skipped entirely —
+ * never claimed as {@code inService}, never handed to
+ * {@code serviceRing0}. This is the exact guarantee
+ * {@code CursorSendEngine.close()} relies on when it releases the ring,
+ * watermark and slot flock right after
+ * {@code awaitRingQuiescence(ring) == true}: the deregistering thread may
+ * already be freeing those resources, so a stale snapshot entry must not
+ * be touched at all (spare install, watermark write, drainTrimmable, or
+ * path building under the slot dir).
+ *
+ * Deterministic shape: three rings A, B, C registered before start, so
+ * the worker's first snapshot is exactly [A, B, C]. The worker parks in
+ * A's spare-install pass; while it is parked, B is deregistered (and a
+ * quiescence barrier for B must pass immediately — no pass for B is in
+ * flight). After release the worker walks the rest of its stale
+ * snapshot: it must skip B and service C. Every serviced ring is
+ * recorded from inside the worker's own pass (via the trim-sync hook
+ * reading {@code inService}), so the assertion is exact — no timing
+ * grace, no sleeps.
+ */
+ @Test(timeout = 15_000L)
+ 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));
+ SegmentRing[] rings = new SegmentRing[3];
+ String[] slots = new String[3];
+ CountDownLatch workerBlocked = new CountDownLatch(1);
+ CountDownLatch releaseWorker = new CountDownLatch(1);
+ AtomicBoolean fired = new AtomicBoolean();
+ AtomicReference hookErr = new AtomicReference<>();
+ // Rings the worker actually claimed and serviced, recorded from
+ // the worker thread itself at the trim-sync point every service
+ // pass reaches (spare needed or not).
+ java.util.Set