Skip to content

Commit e17c12d

Browse files
bluestreak01claude
andcommitted
feat(ilp): wire cursor SF as the only async path; refactor connect string
Wire CursorSendEngine into the public Sender API and collapse the connect- string surface around it. * Sender.build(): cursor is the only async ingest path. sfDir present → store-and-forward (mmap'd, recoverable); sfDir absent → memory-only ring (same lock-free architecture, no disk). * Connect-string keys reshaped: - drop store_and_forward (sf_dir is the on-switch) - drop sf_fsync / sf_fsync_on_flush (replaced by sf_durability) - drop sf_engine (cursor is unconditional now) - sf_durability=memory|flush|append (today only memory works; flush/append throw "not yet supported" until cursor learns fsync) - size suffixes accepted on sf_max_bytes / sf_max_total_bytes (64m, 4g) - default sf_max_bytes 4 MiB; default sf_max_total_bytes 128 MiB (memory mode) / 10 GiB (SF mode) — bounded by default rather than the previous unlimited foot-gun * MmapSegment.createInMemory() — memory-backed (Unsafe.malloc) variant for the non-SF async path; same on-the-wire layout. * SegmentManager — when the registered ring's dir is null, provisions memory-backed spares and skips file unlink on trim. Producer-thread unpark of the worker (eager wakeup) cuts the post-rotation tail by preempting the polling tick. * CursorSendEngine.appendBlocking — bounded backpressure: deadline (default 30 s) throws LineSenderException; cumulative getTotalBackpressureStalls() counter; throttled WARN log every 5 s of sustained backpressure. No more silent unbounded waits. * CursorWebSocketSendLoop.advanceSegment — replaced fixed-size sealed-list snapshot with SegmentRing.nextSealedAfter() / firstSealed() lookups. Fixes "sealed snapshot grew unexpectedly large" crash when the producer outpaces the wire. Legacy SF and async-queue paths are dead code at the test layer; their tests are removed and the remaining src files (WebSocketSendQueue, SegmentLog, InFlightWindow, Reconnector, SfDiskFullException, SfException) will be deleted in a follow-up that strips QwpWebSocketSender's legacy fields and connect overloads. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 889c46c commit e17c12d

24 files changed

Lines changed: 972 additions & 10337 deletions

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

Lines changed: 166 additions & 190 deletions
Large diffs are not rendered by default.

core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java

Lines changed: 184 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@
3434
import io.questdb.client.cutlass.line.array.DoubleArray;
3535
import io.questdb.client.cutlass.line.array.LongArray;
3636
import io.questdb.client.cutlass.qwp.client.sf.SegmentLog;
37+
import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorSendEngine;
38+
import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorWebSocketSendLoop;
3739
import io.questdb.client.cutlass.qwp.protocol.QwpConstants;
3840
import io.questdb.client.cutlass.qwp.protocol.QwpTableBuffer;
3941
import io.questdb.client.std.CharSequenceLongHashMap;
@@ -172,6 +174,13 @@ public class QwpWebSocketSender implements Sender {
172174
// True when this sender took ownership of segmentLog (e.g. via the
173175
// connect-string builder); close() will then close the log too.
174176
private boolean ownsSegmentLog;
177+
// Cursor engine SF: when set, replaces the legacy sendQueue + SegmentLog
178+
// pair. The producer (user thread) writes encoded QWP frames into the
179+
// engine's mmap'd ring; the cursorSendLoop is the I/O thread that walks
180+
// the ring and sends frames. Mutually exclusive with segmentLog.
181+
private CursorSendEngine cursorEngine;
182+
private boolean ownsCursorEngine;
183+
private CursorWebSocketSendLoop cursorSendLoop;
175184
// When true, every successful flush() (including the implicit flush
176185
// during close()) routes a fsync request to the I/O thread before
177186
// returning. Off by default — opt-in via setSegmentLogFsyncOnFlush
@@ -348,7 +357,7 @@ public static QwpWebSocketSender connect(
348357
) {
349358
return connect(host, port, tlsConfig, autoFlushRows, autoFlushBytes, autoFlushIntervalNanos,
350359
inFlightWindowSize, authorizationHeader, maxSchemasPerConnection, requestDurableAck,
351-
null);
360+
(SegmentLog) null);
352361
}
353362

354363
/**
@@ -381,6 +390,42 @@ public static QwpWebSocketSender connect(
381390
* because small-batch / frequent-flush senders pay one disk fsync per
382391
* call.
383392
*/
393+
/**
394+
* Connect overload that wires the cursor SF engine into the sender.
395+
* Mutually exclusive with the {@code SegmentLog} overload — the
396+
* connect-string builder picks one based on {@code sf_engine}.
397+
*/
398+
public static QwpWebSocketSender connect(
399+
String host,
400+
int port,
401+
ClientTlsConfiguration tlsConfig,
402+
int autoFlushRows,
403+
int autoFlushBytes,
404+
long autoFlushIntervalNanos,
405+
int inFlightWindowSize,
406+
String authorizationHeader,
407+
int maxSchemasPerConnection,
408+
boolean requestDurableAck,
409+
CursorSendEngine cursorEngine
410+
) {
411+
QwpWebSocketSender sender = new QwpWebSocketSender(
412+
host, port, tlsConfig,
413+
autoFlushRows, autoFlushBytes, autoFlushIntervalNanos,
414+
inFlightWindowSize, authorizationHeader, maxSchemasPerConnection
415+
);
416+
try {
417+
sender.setRequestDurableAck(requestDurableAck);
418+
if (cursorEngine != null) {
419+
sender.setCursorEngine(cursorEngine, true);
420+
}
421+
sender.ensureConnected();
422+
} catch (Throwable t) {
423+
sender.close();
424+
throw t;
425+
}
426+
return sender;
427+
}
428+
384429
public static QwpWebSocketSender connect(
385430
String host,
386431
int port,
@@ -622,7 +667,13 @@ public void close() {
622667
// server-acked", so close() — like flush() — skips awaitPendingAcks.
623668
// Unsealed acks remain on disk; the next sender against the same
624669
// SF dir will replay them.
625-
if (sendQueue != null) {
670+
if (cursorEngine != null) {
671+
// Cursor SF: appendBlocking ran on the user thread inside
672+
// sealAndSwapBuffer, so every batch is already durable on
673+
// its mmap'd segment. The cursor I/O thread keeps draining
674+
// to the wire in the background; we don't wait for it.
675+
cursorSendLoop.checkError();
676+
} else if (sendQueue != null) {
626677
sendQueue.flush();
627678
if (fsyncOnFlush && segmentLog != null) {
628679
// Same opt-in fsync as the public flush(): the
@@ -656,6 +707,14 @@ public void close() {
656707
LOG.error("Error closing send queue: {}", String.valueOf(e));
657708
}
658709
}
710+
if (cursorSendLoop != null) {
711+
try {
712+
cursorSendLoop.close();
713+
} catch (Exception e) {
714+
ioThreadStopped = false;
715+
LOG.error("Error closing cursor send loop: {}", String.valueOf(e));
716+
}
717+
}
659718

660719
// Always free resources the I/O thread never touches:
661720
// encoder and table buffers are user-thread-only.
@@ -700,6 +759,16 @@ public void close() {
700759
segmentLog = null;
701760
ownsSegmentLog = false;
702761
}
762+
// Same lifecycle for the cursor engine.
763+
if (ownsCursorEngine && cursorEngine != null) {
764+
try {
765+
cursorEngine.close();
766+
} catch (Throwable t) {
767+
LOG.error("Error closing owned CursorSendEngine: {}", String.valueOf(t));
768+
}
769+
cursorEngine = null;
770+
ownsCursorEngine = false;
771+
}
703772

704773
LOG.info("QwpWebSocketSender closed");
705774
}
@@ -900,6 +969,20 @@ public void flush() {
900969
ensureNoInProgressRow();
901970
ensureConnected();
902971

972+
if (cursorEngine != null) {
973+
// Cursor SF: SF.append happens on the user thread inside
974+
// sealAndSwapBuffer, so by the time we reach here every encoded
975+
// batch is durable on its mmap'd segment. No processingCount to
976+
// drain, no awaitPendingAcks. Just surface any I/O thread error.
977+
flushPendingRows();
978+
if (activeBuffer != null && activeBuffer.hasData()) {
979+
sealAndSwapBuffer();
980+
}
981+
cursorSendLoop.checkError();
982+
checkConnectionError();
983+
return;
984+
}
985+
903986
if (inFlightWindowSize > 1) {
904987
// Async mode (window > 1): flush pending rows and wait for ACKs
905988
flushPendingRows();
@@ -1201,6 +1284,11 @@ public QwpWebSocketSender longColumn(CharSequence columnName, long value) {
12011284
public void ping() {
12021285
checkNotClosed();
12031286
ensureConnected();
1287+
if (cursorEngine != null) {
1288+
// PR1 cursor scope: ping/pong is on the legacy I/O loop only.
1289+
throw new LineSenderException(
1290+
"ping() is not yet supported with sf_engine=cursor (deferred to a follow-up PR)");
1291+
}
12041292
if (inFlightWindowSize > 1) {
12051293
sendQueue.ping();
12061294
} else {
@@ -1295,10 +1383,45 @@ public void setSegmentLog(SegmentLog log, boolean takeOwnership) {
12951383
throw new LineSenderException(
12961384
"store-and-forward requires async mode (inFlightWindowSize > 1)");
12971385
}
1386+
if (log != null && cursorEngine != null) {
1387+
throw new LineSenderException(
1388+
"SegmentLog and CursorSendEngine are mutually exclusive (sf_engine selects one)");
1389+
}
12981390
this.segmentLog = log;
12991391
this.ownsSegmentLog = takeOwnership && log != null;
13001392
}
13011393

1394+
/**
1395+
* Attach a {@link CursorSendEngine} for store-and-forward, replacing the
1396+
* legacy {@link SegmentLog} pipeline. The cursor engine puts the SF append
1397+
* on the user thread (writing into an mmap'd ring) and runs a dedicated
1398+
* I/O thread to drain frames to the wire — substantially lower per-flush
1399+
* latency than the legacy queue's hand-off + pwrite model.
1400+
* <p>
1401+
* Must be called before the first send. Requires async mode
1402+
* ({@code inFlightWindowSize > 1}). Mutually exclusive with
1403+
* {@link #setSegmentLog(SegmentLog, boolean)}.
1404+
*/
1405+
public void setCursorEngine(CursorSendEngine engine, boolean takeOwnership) {
1406+
if (closed) {
1407+
throw new LineSenderException("Sender is closed");
1408+
}
1409+
if (connected) {
1410+
throw new LineSenderException(
1411+
"setCursorEngine must be called before the first send");
1412+
}
1413+
if (engine != null && inFlightWindowSize <= 1) {
1414+
throw new LineSenderException(
1415+
"cursor engine requires async mode (inFlightWindowSize > 1)");
1416+
}
1417+
if (engine != null && segmentLog != null) {
1418+
throw new LineSenderException(
1419+
"CursorSendEngine and SegmentLog are mutually exclusive (sf_engine selects one)");
1420+
}
1421+
this.cursorEngine = engine;
1422+
this.ownsCursorEngine = takeOwnership && engine != null;
1423+
}
1424+
13021425
/**
13031426
* Opt in to fsyncing the SF active segment at every {@link #flush()}
13041427
* (and at the implicit flush during {@link #close()}). Off by default.
@@ -1627,19 +1750,36 @@ private void ensureConnected() {
16271750
// Initialize send queue for async mode (window > 1)
16281751
// The send queue handles both sending AND receiving (single I/O thread)
16291752
if (inFlightWindowSize > 1) {
1630-
try {
1631-
Reconnector reconnector = segmentLog != null ? this::performReconnect : null;
1632-
sendQueue = new WebSocketSendQueue(client, inFlightWindow,
1633-
WebSocketSendQueue.DEFAULT_ENQUEUE_TIMEOUT_MS,
1634-
WebSocketSendQueue.DEFAULT_SHUTDOWN_TIMEOUT_MS,
1635-
this::recordConnectionFailure,
1636-
segmentLog,
1637-
reconnector);
1638-
} catch (Throwable t) {
1639-
inFlightWindow = null;
1640-
client.close();
1641-
client = null;
1642-
throw new LineSenderException("Failed to start I/O thread for " + host + ":" + port, t);
1753+
if (cursorEngine != null) {
1754+
// Cursor SF: skip the legacy sendQueue entirely. The cursor
1755+
// I/O loop polls publishedFsn and drains frames straight
1756+
// from the mmap'd ring, so no enqueue / processingCount
1757+
// handshake is needed on the user thread.
1758+
try {
1759+
cursorSendLoop = new CursorWebSocketSendLoop(client, cursorEngine);
1760+
cursorSendLoop.start();
1761+
} catch (Throwable t) {
1762+
inFlightWindow = null;
1763+
client.close();
1764+
client = null;
1765+
throw new LineSenderException(
1766+
"Failed to start cursor I/O thread for " + host + ":" + port, t);
1767+
}
1768+
} else {
1769+
try {
1770+
Reconnector reconnector = segmentLog != null ? this::performReconnect : null;
1771+
sendQueue = new WebSocketSendQueue(client, inFlightWindow,
1772+
WebSocketSendQueue.DEFAULT_ENQUEUE_TIMEOUT_MS,
1773+
WebSocketSendQueue.DEFAULT_SHUTDOWN_TIMEOUT_MS,
1774+
this::recordConnectionFailure,
1775+
segmentLog,
1776+
reconnector);
1777+
} catch (Throwable t) {
1778+
inFlightWindow = null;
1779+
client.close();
1780+
client = null;
1781+
throw new LineSenderException("Failed to start I/O thread for " + host + ":" + port, t);
1782+
}
16431783
}
16441784
}
16451785
// Sync mode (window=1): no send queue - we send and read ACKs synchronously
@@ -2041,19 +2181,37 @@ private void sealAndSwapBuffer() {
20412181
}
20422182
activeBuffer.reset();
20432183

2044-
// Enqueue the sealed buffer for sending.
2045-
// If enqueue fails, roll back local state so the same batch can be retried.
2046-
try {
2047-
if (!sendQueue.enqueue(toSend)) {
2048-
throw new LineSenderException("Failed to enqueue buffer for sending");
2184+
// Hand off the sealed buffer. Cursor mode does it on the user
2185+
// thread (durable mmap append, returns once published); legacy
2186+
// mode enqueues to the I/O thread.
2187+
if (cursorEngine != null) {
2188+
try {
2189+
toSend.markSending();
2190+
cursorEngine.appendBlocking(toSend.getBufferPtr(), toSend.getBufferPos());
2191+
toSend.markRecycled();
2192+
} catch (Throwable t) {
2193+
// Surface any I/O thread error first — appendBlocking itself
2194+
// only throws on PAYLOAD_TOO_LARGE, but the buffer pointer
2195+
// might have been corrupted by a concurrent failure. The
2196+
// cursorSendLoop can also have failed independently.
2197+
cursorSendLoop.checkError();
2198+
throw new LineSenderException("cursor SF append failed", t);
20492199
}
2050-
} catch (LineSenderException e) {
2051-
activeBuffer = toSend;
2052-
if (toSend.isSealed()) {
2053-
toSend.rollbackSealForRetry();
2200+
} else {
2201+
// Enqueue the sealed buffer for sending.
2202+
// If enqueue fails, roll back local state so the same batch can be retried.
2203+
try {
2204+
if (!sendQueue.enqueue(toSend)) {
2205+
throw new LineSenderException("Failed to enqueue buffer for sending");
2206+
}
2207+
} catch (LineSenderException e) {
2208+
activeBuffer = toSend;
2209+
if (toSend.isSealed()) {
2210+
toSend.rollbackSealForRetry();
2211+
}
2212+
checkConnectionError();
2213+
throw e;
20542214
}
2055-
checkConnectionError();
2056-
throw e;
20572215
}
20582216
}
20592217

0 commit comments

Comments
 (0)