Skip to content

Commit 49f1683

Browse files
bluestreak01claude
andcommitted
feat(ilp): cursor SF engine primitives (mmap segments, ring, manager)
Lays the foundation for a lock-free, mmap-backed alternative to the current SegmentLog + WebSocketSendQueue + processingLock design. Today ~85% of the user thread's flush() time is spent parked in __psynch_cvwait waiting for the I/O thread to signal completion (see QwpIngressLatencyBenchmark async-profiler flamegraph). The cursor design moves SF.append onto the user thread, making the cross-thread wait unnecessary -- the user-thread append microbench now reports p50=42ns vs ~38us in the legacy SF path on the same hardware. What lands: * mmap/munmap/msync ported from QuestDB OSS into client/std/Files (both POSIX and Win32). Native rebuild required per-platform; the darwin-aarch64 dev lib is the only one rebuilt locally. * MmapSegment: one mmap'd file, format-compatible with the legacy SegmentLog (same SF01 magic, 24-byte header, [crc | u32 len | payload] frame layout). Single-producer cursor (appendCursor plain field, publishedCursor volatile). tryAppend is pure memory + CRC. openExisting + scanFrames recover from torn tails. * SegmentRing: chain of MmapSegments with hot-spare swap and ACK-driven trim. Four cursors, all single-writer (no CAS). Rotation rebases the spare's baseSeq at promotion time to avoid the precompute race. * SegmentManager: JVM-wide background thread that pre-creates spares and trims fully-acked segments. Moves the open + truncate + fsync + rename + unlink quartet (45k samples / 100k I/O thread samples in the legacy flamegraph) off the hot path. * CursorSendEngine: facade bundling ring + manager with the API a future WebSocketSendQueue rewrite will consume. * sf_engine=legacy|cursor config option in LineSenderBuilder. Default legacy. Selecting cursor at build time fails fast with a clear "not yet wired" message -- the WebSocketSendQueue integration that actually consumes CursorSendEngine is the next PR. * CursorEngineAppendLatencyBenchmark: standalone microbench for the user-thread append path (the floor a wired cursor engine would inherit). 20 new tests across the cursor/ package, all green. FilesTest gains a mmap roundtrip test. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f58f766 commit 49f1683

17 files changed

Lines changed: 2437 additions & 0 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ core/questdb/client/bin-local
2121
core/cmake-build-debug
2222
core/cmake-build-debug-coverage
2323
core/cmake-build-release
24+
core/build_native
2425
core/CMakeCache.txt
2526
**/.project
2627
**/.settings

core/src/main/c/share/files.c

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
#include <unistd.h>
2828
#include <sys/stat.h>
2929
#include <sys/file.h>
30+
#include <sys/mman.h>
3031
#include <fcntl.h>
3132
#include <errno.h>
3233
#include <stdlib.h>
@@ -36,6 +37,12 @@
3637

3738
#include "files.h"
3839

40+
/* Mirror of io.questdb.client.std.Files.MAP_RO / MAP_RW. Hard-coded rather
41+
* than #include'd from a javah-generated header because this file does not
42+
* pull in any generated symbols (the rest of the file works the same way). */
43+
#define QDB_MAP_RO 1
44+
#define QDB_MAP_RW 2
45+
3946
#define RESTARTABLE(_expr_, _rc_) \
4047
do { _rc_ = (_expr_); } while ((_rc_) == -1 && errno == EINTR)
4148

@@ -268,3 +275,26 @@ JNIEXPORT jlong JNICALL Java_io_questdb_client_std_Files_getPageSize0
268275
long sz = sysconf(_SC_PAGESIZE);
269276
return (jlong) (sz > 0 ? sz : 4096);
270277
}
278+
279+
JNIEXPORT jlong JNICALL Java_io_questdb_client_std_Files_mmap0
280+
(JNIEnv *e, jclass cl, jint fd, jlong len, jlong offset, jint flags, jlong baseAddress) {
281+
int prot = 0;
282+
if (flags == QDB_MAP_RO) {
283+
prot = PROT_READ;
284+
} else if (flags == QDB_MAP_RW) {
285+
prot = PROT_READ | PROT_WRITE;
286+
}
287+
void *addr = mmap((void *) (uintptr_t) baseAddress, (size_t) len, prot, MAP_SHARED, (int) fd, (off_t) offset);
288+
/* MAP_FAILED is (void *) -1; cast to jlong gives -1 sentinel matching FAILED_MMAP_ADDRESS. */
289+
return (jlong) (intptr_t) addr;
290+
}
291+
292+
JNIEXPORT jint JNICALL Java_io_questdb_client_std_Files_munmap0
293+
(JNIEnv *e, jclass cl, jlong address, jlong len) {
294+
return munmap((void *) (uintptr_t) address, (size_t) len);
295+
}
296+
297+
JNIEXPORT jint JNICALL Java_io_questdb_client_std_Files_msync
298+
(JNIEnv *e, jclass cl, jlong addr, jlong len, jboolean async) {
299+
return msync((void *) (uintptr_t) addr, (size_t) len, async ? MS_ASYNC : MS_SYNC);
300+
}

core/src/main/c/windows/files.c

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -468,3 +468,95 @@ JNIEXPORT jlong JNICALL Java_io_questdb_client_std_Files_getPageSize0
468468
GetSystemInfo(&si);
469469
return (jlong) si.dwAllocationGranularity;
470470
}
471+
472+
/* Mirror of io.questdb.client.std.Files.MAP_RO / MAP_RW. */
473+
#define QDB_MAP_RO 1
474+
#define QDB_MAP_RW 2
475+
476+
JNIEXPORT jlong JNICALL Java_io_questdb_client_std_Files_mmap0
477+
(JNIEnv *e, jclass cl, jint fd, jlong len, jlong offset, jint flags, jlong baseAddress) {
478+
if (len == 0) {
479+
/* Win32 MapViewOfFileEx interprets dwNumberOfBytesToMap == 0 as
480+
* "map to end of mapping". Reject explicitly so the wrapper has
481+
* POSIX-compatible semantics (POSIX mmap with len==0 returns
482+
* EINVAL). */
483+
SetLastError(ERROR_INVALID_PARAMETER);
484+
SaveLastError();
485+
return -1;
486+
}
487+
488+
jlong maxsize = offset + len;
489+
DWORD flProtect;
490+
DWORD dwDesiredAccess;
491+
if (flags == QDB_MAP_RW) {
492+
flProtect = PAGE_READWRITE;
493+
dwDesiredAccess = FILE_MAP_WRITE;
494+
} else {
495+
flProtect = PAGE_READONLY;
496+
dwDesiredAccess = FILE_MAP_READ;
497+
}
498+
499+
HANDLE hMapping = CreateFileMapping(
500+
FD_TO_HANDLE(fd),
501+
NULL,
502+
flProtect | SEC_RESERVE,
503+
(DWORD) (maxsize >> 32),
504+
(DWORD) maxsize,
505+
NULL);
506+
if (hMapping == NULL) {
507+
SaveLastError();
508+
return -1;
509+
}
510+
511+
LPCVOID address = MapViewOfFileEx(
512+
hMapping,
513+
dwDesiredAccess,
514+
(DWORD) (offset >> 32),
515+
(DWORD) offset,
516+
(SIZE_T) len,
517+
(LPVOID) (uintptr_t) baseAddress);
518+
519+
SaveLastError();
520+
521+
/* The mapping handle can be closed immediately — the view holds its own
522+
* reference and the file mapping persists until the last view is unmapped. */
523+
if (CloseHandle(hMapping) == 0) {
524+
SaveLastError();
525+
if (address != NULL) {
526+
UnmapViewOfFile(address);
527+
}
528+
return -1;
529+
}
530+
531+
if (address == NULL) {
532+
return -1;
533+
}
534+
return (jlong) (uintptr_t) address;
535+
}
536+
537+
JNIEXPORT jint JNICALL Java_io_questdb_client_std_Files_munmap0
538+
(JNIEnv *e, jclass cl, jlong address, jlong len) {
539+
if (UnmapViewOfFile((LPCVOID) (uintptr_t) address) == 0) {
540+
SaveLastError();
541+
return -1;
542+
}
543+
return 0;
544+
}
545+
546+
JNIEXPORT jint JNICALL Java_io_questdb_client_std_Files_msync
547+
(JNIEnv *e, jclass cl, jlong addr, jlong len, jboolean async) {
548+
/* FlushViewOfFile schedules a write, blocking until the file system
549+
* driver has accepted the write into its cache. For "fully durable"
550+
* (POSIX MS_SYNC equivalent) we need a follow-up FlushFileBuffers,
551+
* but that needs the file handle which we no longer hold here.
552+
* MS_ASYNC maps cleanly: don't wait for further confirmation. */
553+
if (FlushViewOfFile((LPCVOID) (uintptr_t) addr, (SIZE_T) len) == 0) {
554+
SaveLastError();
555+
return -1;
556+
}
557+
/* We deliberately do NOT call FlushFileBuffers in the async case;
558+
* sync callers wanting the strongest durability should fsync the fd
559+
* separately via Files.fsync. */
560+
(void) async;
561+
return 0;
562+
}

core/src/main/java/io/questdb/client/Sender.java

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -632,6 +632,12 @@ public int getTimeout() {
632632
private long sfMaxTotalBytes = PARAMETER_NOT_SET_EXPLICITLY;
633633
private boolean sfFsync;
634634
private boolean sfFsyncOnFlush;
635+
// SF storage engine: "legacy" = SegmentLog + WebSocketSendQueue (today's
636+
// default). "cursor" = mmap-backed SegmentRing + lock-free cursor design
637+
// (in-progress; not yet wired into the Sender — selecting it at build
638+
// time fails fast). null = parameter not explicitly set (defaults to
639+
// "legacy").
640+
private String sfEngine;
635641
private boolean tlsEnabled;
636642
private TlsValidationMode tlsValidationMode;
637643
private char[] trustStorePassword;
@@ -934,6 +940,19 @@ public Sender build() {
934940
);
935941
}
936942

943+
// Engine selection (legacy default). The cursor engine is
944+
// implemented (see io.questdb.client.cutlass.qwp.client.sf.cursor)
945+
// but not yet plumbed through QwpWebSocketSender — fail fast
946+
// here instead of silently falling back to legacy.
947+
if ("cursor".equals(sfEngine)) {
948+
throw new LineSenderException(
949+
"sf_engine=cursor is not yet wired into the Sender — the engine "
950+
+ "primitives (MmapSegment / SegmentRing / SegmentManager / "
951+
+ "CursorSendEngine) are in place but the WebSocketSendQueue "
952+
+ "rewrite that consumes them is the next PR. Track the "
953+
+ "follow-up issue and use sf_engine=legacy in the meantime.");
954+
}
955+
937956
SegmentLog segmentLog = null;
938957
if (storeAndForward) {
939958
if (sfDir == null) {
@@ -1689,6 +1708,31 @@ public LineSenderBuilder storeAndForwardFsyncOnFlush(boolean enabled) {
16891708
return this;
16901709
}
16911710

1711+
/**
1712+
* Selects the SF storage engine. Allowed values:
1713+
* <ul>
1714+
* <li>{@code "legacy"} — pwrite-based {@code SegmentLog} routed
1715+
* through {@code WebSocketSendQueue}. Today's default.</li>
1716+
* <li>{@code "cursor"} — mmap-backed {@code SegmentRing} with a
1717+
* background segment manager and a lock-free user-thread
1718+
* append path. Substantially lower per-flush latency. NOT YET
1719+
* WIRED into {@code QwpWebSocketSender}; selecting it at build
1720+
* time throws {@link LineSenderException} so users can't
1721+
* silently fall back to legacy. Tracking issue / future PR.</li>
1722+
* </ul>
1723+
*/
1724+
public LineSenderBuilder storeAndForwardEngine(String engine) {
1725+
if (protocol != PARAMETER_NOT_SET_EXPLICITLY && protocol != PROTOCOL_WEBSOCKET) {
1726+
throw new LineSenderException("sf_engine is only supported for WebSocket transport");
1727+
}
1728+
if (!"legacy".equals(engine) && !"cursor".equals(engine)) {
1729+
throw new LineSenderException("invalid sf_engine [value=").put(engine)
1730+
.put(", allowed-values=[legacy, cursor]]");
1731+
}
1732+
this.sfEngine = engine;
1733+
return this;
1734+
}
1735+
16921736
/**
16931737
* Configures the maximum time the Sender will spend retrying upon receiving a recoverable error from the server.
16941738
* <br>
@@ -2167,6 +2211,12 @@ private LineSenderBuilder fromConfig(CharSequence configurationString) {
21672211
} else {
21682212
throw new LineSenderException("invalid sf_fsync_on_flush [value=").put(sink).put(", allowed-values=[on, off]]");
21692213
}
2214+
} else if (Chars.equals("sf_engine", sink)) {
2215+
if (protocol != PROTOCOL_WEBSOCKET) {
2216+
throw new LineSenderException("sf_engine is only supported for WebSocket transport");
2217+
}
2218+
pos = getValue(configurationString, pos, sink, "sf_engine");
2219+
storeAndForwardEngine(sink.toString());
21702220
} else if (Chars.equals("max_datagram_size", sink)) {
21712221
pos = getValue(configurationString, pos, sink, "max_datagram_size");
21722222
int mds = parseIntValue(sink, "max_datagram_size");

0 commit comments

Comments
 (0)