|
34 | 34 | import io.questdb.client.cutlass.line.array.DoubleArray; |
35 | 35 | import io.questdb.client.cutlass.line.array.LongArray; |
36 | 36 | 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; |
37 | 39 | import io.questdb.client.cutlass.qwp.protocol.QwpConstants; |
38 | 40 | import io.questdb.client.cutlass.qwp.protocol.QwpTableBuffer; |
39 | 41 | import io.questdb.client.std.CharSequenceLongHashMap; |
@@ -172,6 +174,13 @@ public class QwpWebSocketSender implements Sender { |
172 | 174 | // True when this sender took ownership of segmentLog (e.g. via the |
173 | 175 | // connect-string builder); close() will then close the log too. |
174 | 176 | 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; |
175 | 184 | // When true, every successful flush() (including the implicit flush |
176 | 185 | // during close()) routes a fsync request to the I/O thread before |
177 | 186 | // returning. Off by default — opt-in via setSegmentLogFsyncOnFlush |
@@ -348,7 +357,7 @@ public static QwpWebSocketSender connect( |
348 | 357 | ) { |
349 | 358 | return connect(host, port, tlsConfig, autoFlushRows, autoFlushBytes, autoFlushIntervalNanos, |
350 | 359 | inFlightWindowSize, authorizationHeader, maxSchemasPerConnection, requestDurableAck, |
351 | | - null); |
| 360 | + (SegmentLog) null); |
352 | 361 | } |
353 | 362 |
|
354 | 363 | /** |
@@ -381,6 +390,42 @@ public static QwpWebSocketSender connect( |
381 | 390 | * because small-batch / frequent-flush senders pay one disk fsync per |
382 | 391 | * call. |
383 | 392 | */ |
| 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 | + |
384 | 429 | public static QwpWebSocketSender connect( |
385 | 430 | String host, |
386 | 431 | int port, |
@@ -622,7 +667,13 @@ public void close() { |
622 | 667 | // server-acked", so close() — like flush() — skips awaitPendingAcks. |
623 | 668 | // Unsealed acks remain on disk; the next sender against the same |
624 | 669 | // 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) { |
626 | 677 | sendQueue.flush(); |
627 | 678 | if (fsyncOnFlush && segmentLog != null) { |
628 | 679 | // Same opt-in fsync as the public flush(): the |
@@ -656,6 +707,14 @@ public void close() { |
656 | 707 | LOG.error("Error closing send queue: {}", String.valueOf(e)); |
657 | 708 | } |
658 | 709 | } |
| 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 | + } |
659 | 718 |
|
660 | 719 | // Always free resources the I/O thread never touches: |
661 | 720 | // encoder and table buffers are user-thread-only. |
@@ -700,6 +759,16 @@ public void close() { |
700 | 759 | segmentLog = null; |
701 | 760 | ownsSegmentLog = false; |
702 | 761 | } |
| 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 | + } |
703 | 772 |
|
704 | 773 | LOG.info("QwpWebSocketSender closed"); |
705 | 774 | } |
@@ -900,6 +969,20 @@ public void flush() { |
900 | 969 | ensureNoInProgressRow(); |
901 | 970 | ensureConnected(); |
902 | 971 |
|
| 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 | + |
903 | 986 | if (inFlightWindowSize > 1) { |
904 | 987 | // Async mode (window > 1): flush pending rows and wait for ACKs |
905 | 988 | flushPendingRows(); |
@@ -1201,6 +1284,11 @@ public QwpWebSocketSender longColumn(CharSequence columnName, long value) { |
1201 | 1284 | public void ping() { |
1202 | 1285 | checkNotClosed(); |
1203 | 1286 | 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 | + } |
1204 | 1292 | if (inFlightWindowSize > 1) { |
1205 | 1293 | sendQueue.ping(); |
1206 | 1294 | } else { |
@@ -1295,10 +1383,45 @@ public void setSegmentLog(SegmentLog log, boolean takeOwnership) { |
1295 | 1383 | throw new LineSenderException( |
1296 | 1384 | "store-and-forward requires async mode (inFlightWindowSize > 1)"); |
1297 | 1385 | } |
| 1386 | + if (log != null && cursorEngine != null) { |
| 1387 | + throw new LineSenderException( |
| 1388 | + "SegmentLog and CursorSendEngine are mutually exclusive (sf_engine selects one)"); |
| 1389 | + } |
1298 | 1390 | this.segmentLog = log; |
1299 | 1391 | this.ownsSegmentLog = takeOwnership && log != null; |
1300 | 1392 | } |
1301 | 1393 |
|
| 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 | + |
1302 | 1425 | /** |
1303 | 1426 | * Opt in to fsyncing the SF active segment at every {@link #flush()} |
1304 | 1427 | * (and at the implicit flush during {@link #close()}). Off by default. |
@@ -1627,19 +1750,36 @@ private void ensureConnected() { |
1627 | 1750 | // Initialize send queue for async mode (window > 1) |
1628 | 1751 | // The send queue handles both sending AND receiving (single I/O thread) |
1629 | 1752 | 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 | + } |
1643 | 1783 | } |
1644 | 1784 | } |
1645 | 1785 | // Sync mode (window=1): no send queue - we send and read ACKs synchronously |
@@ -2041,19 +2181,37 @@ private void sealAndSwapBuffer() { |
2041 | 2181 | } |
2042 | 2182 | activeBuffer.reset(); |
2043 | 2183 |
|
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); |
2049 | 2199 | } |
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; |
2054 | 2214 | } |
2055 | | - checkConnectionError(); |
2056 | | - throw e; |
2057 | 2215 | } |
2058 | 2216 | } |
2059 | 2217 |
|
|
0 commit comments