Skip to content

Commit 889c46c

Browse files
bluestreak01claude
andcommitted
feat(ilp): cursor SF -- happy-path WebSocket send loop
CursorWebSocketSendLoop is the cursor-engine equivalent of WebSocketSendQueue's I/O loop. Owns one I/O thread that: * Polls CursorSendEngine.publishedFsn() and walks newly-published frames from the engine's segments (active + sealed). Sends each frame's payload as one WS binary frame via WebSocketClient.sendBinary -- exactly the bytes the legacy WebSocketSendQueue would send, minus the 8-byte SF envelope which is engine-internal. * Polls the WebSocket for server ACKs via tryReceiveFrame. On each successful ACK with cumulative wire seq N, calls engine.acknowledge(fsnAtZero + N), which advances ackedFsn so the SegmentManager can trim fully-acked sealed segments. No locks. The producer thread (user) writes into the engine; this thread reads. publishedFsn is the volatile publish barrier. Sealed- segment iteration uses the synchronized snapshot accessor added in the previous commit, so the producer's rotation can't tear the ObjList underneath us. PR1 scope is deliberately the happy path. Deferred (TODO PR2): * Ping/pong heartbeat * fsync-on-flush request channel * Per-table seqTxn tracking * Reconnect / replay-on-reconnect (walk segments from ackedFsn+1) * Disk-full retry (the cap from the previous commit handles the upstream signal; PR2 wires the producer-side recovery) * Multi-connection failover Errors are reported via getLastError(); the I/O thread sets it and exits, producers polling checkError() surface the failure. Same wireSeq-clamp safety check the legacy path uses (clamp ACK sequence to nextWireSeq-1 so a malformed/replayed server ACK can't force trim of segments the new server has never seen). Companion change: CursorSendEngine.sealedSegmentsSnapshot pass-through to SegmentRing's thread-safe snapshot accessor. No new tests in this commit -- the integration test for the wired end-to-end path lands with the QwpWebSocketSender wiring (next slice). The class compiles and the 2020-test suite continues to pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent cc4a68f commit 889c46c

2 files changed

Lines changed: 360 additions & 1 deletion

File tree

core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/CursorSendEngine.java

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,11 +189,24 @@ public long publishedFsn() {
189189
return ring.publishedFsn();
190190
}
191191

192-
/** I/O thread accessor: sealed segments waiting to drain. */
192+
/**
193+
* I/O thread accessor: sealed segments waiting to drain. Direct view —
194+
* NOT thread-safe under producer-thread rotation. The I/O loop should
195+
* use {@link #sealedSegmentsSnapshot(MmapSegment[])} instead.
196+
*/
193197
public io.questdb.client.std.ObjList<MmapSegment> sealedSegments() {
194198
return ring.getSealedSegments();
195199
}
196200

201+
/**
202+
* Thread-safe snapshot pass-through to
203+
* {@link SegmentRing#snapshotSealedSegments(MmapSegment[])}. Returns
204+
* the count copied, or -1 if the buffer is too small.
205+
*/
206+
public int sealedSegmentsSnapshot(MmapSegment[] target) {
207+
return ring.snapshotSealedSegments(target);
208+
}
209+
197210
/** Configured per-segment size in bytes. */
198211
public long segmentSizeBytes() {
199212
return segmentSizeBytes;
Lines changed: 346 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,346 @@
1+
/*******************************************************************************
2+
* ___ _ ____ ____
3+
* / _ \ _ _ ___ ___| |_| _ \| __ )
4+
* | | | | | | |/ _ \/ __| __| | | | _ \
5+
* | |_| | |_| | __/\__ \ |_| |_| | |_) |
6+
* \__\_\\__,_|\___||___/\__|____/|____/
7+
*
8+
* Copyright (c) 2014-2019 Appsicle
9+
* Copyright (c) 2019-2026 QuestDB
10+
*
11+
* Licensed under the Apache License, Version 2.0 (the "License");
12+
* you may not use this file except in compliance with the License.
13+
* You may obtain a copy of the License at
14+
*
15+
* http://www.apache.org/licenses/LICENSE-2.0
16+
*
17+
* Unless required by applicable law or agreed to in writing, software
18+
* distributed under the License is distributed on an "AS IS" BASIS,
19+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
20+
* See the License for the specific language governing permissions and
21+
* limitations under the License.
22+
*
23+
******************************************************************************/
24+
25+
package io.questdb.client.cutlass.qwp.client.sf.cursor;
26+
27+
import io.questdb.client.cutlass.http.client.WebSocketClient;
28+
import io.questdb.client.cutlass.http.client.WebSocketFrameHandler;
29+
import io.questdb.client.cutlass.line.LineSenderException;
30+
import io.questdb.client.cutlass.qwp.client.WebSocketResponse;
31+
import io.questdb.client.std.QuietCloseable;
32+
import io.questdb.client.std.Unsafe;
33+
import org.slf4j.Logger;
34+
import org.slf4j.LoggerFactory;
35+
36+
import java.util.concurrent.CountDownLatch;
37+
import java.util.concurrent.atomic.AtomicLong;
38+
import java.util.concurrent.locks.LockSupport;
39+
40+
/**
41+
* The cursor-engine equivalent of {@code WebSocketSendQueue}'s I/O loop.
42+
* Owns one I/O thread that:
43+
* <ol>
44+
* <li>Polls {@link CursorSendEngine#publishedFsn()} and walks newly-published
45+
* frames from the engine's segments, sending each as one WebSocket
46+
* binary frame to the server.</li>
47+
* <li>Polls the WebSocket for server ACK frames; on each ACK with
48+
* cumulative wire sequence {@code N}, calls
49+
* {@code engine.acknowledge(fsnAtZero + N)} so the segment manager
50+
* can trim fully-acked segments.</li>
51+
* </ol>
52+
* No locks. The producer thread (user) writes into the engine; this thread
53+
* reads. {@code engine.publishedFsn()} is the volatile publish barrier.
54+
* <p>
55+
* <b>PR1 scope (deliberately minimal):</b>
56+
* <ul>
57+
* <li>Happy-path send + ACK round-trip only.</li>
58+
* <li>No ping/pong, no fsync requests, no per-table seqTxn tracking
59+
* (the legacy {@code WebSocketSendQueue} has all of these — port
60+
* them as PR2 once latency wins are confirmed).</li>
61+
* <li>No reconnect / replay — a connection failure is fatal; the user
62+
* must construct a new sender. Replay-on-reconnect needs to walk
63+
* segments from {@code ackedFsn+1} forward and is the next PR.</li>
64+
* <li>Single-connection only (no failover); WebSocketClient is provided
65+
* and assumed to be already connected.</li>
66+
* <li>Engine starts fresh (no on-disk recovery into the wire path).</li>
67+
* </ul>
68+
* Errors are reported via {@link #getLastError()}; the I/O thread sets it
69+
* and exits. Producers polling {@link #checkError()} surface the failure.
70+
*/
71+
public final class CursorWebSocketSendLoop implements QuietCloseable {
72+
73+
public static final long DEFAULT_PARK_NANOS = 50_000L; // 50us idle backoff
74+
private static final Logger LOG = LoggerFactory.getLogger(CursorWebSocketSendLoop.class);
75+
76+
private final WebSocketClient client;
77+
private final AtomicLong consecutiveSendErrors = new AtomicLong();
78+
private final CursorSendEngine engine;
79+
// fsnAtZero: FSN that wireSeq=0 maps to on this connection. For a fresh
80+
// connection starting from a fresh engine (no recovery), this is 0.
81+
// Once recovery / reconnect lands (PR2), this is set to the first
82+
// unacked FSN at connect time so wire-seq math stays aligned.
83+
private final long fsnAtZero;
84+
private final long parkNanos;
85+
private final WebSocketResponse response = new WebSocketResponse();
86+
private final ResponseHandler responseHandler = new ResponseHandler();
87+
private final CountDownLatch shutdownLatch = new CountDownLatch(1);
88+
private final AtomicLong totalAcks = new AtomicLong();
89+
private final AtomicLong totalFramesSent = new AtomicLong();
90+
// Snapshot buffer for sealedSegments — reused across loop ticks to avoid
91+
// per-iteration allocation. Grown if the snapshot ever overflows.
92+
private MmapSegment[] sealedSnapshot = new MmapSegment[16];
93+
// sendingSegment: the segment we're currently consuming bytes from. Starts
94+
// at engine.activeSegment(); advances to newer sealed segments / the new
95+
// active as the producer rotates.
96+
private MmapSegment sendingSegment;
97+
// sendOffset: byte offset inside sendingSegment of the first not-yet-sent
98+
// byte. Initialized to MmapSegment.HEADER_SIZE on a fresh segment.
99+
private long sendOffset = MmapSegment.HEADER_SIZE;
100+
private long nextWireSeq;
101+
private volatile boolean running;
102+
private volatile Throwable lastError;
103+
private Thread ioThread;
104+
105+
public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine) {
106+
this(client, engine, 0L, DEFAULT_PARK_NANOS);
107+
}
108+
109+
public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine,
110+
long fsnAtZero, long parkNanos) {
111+
if (client == null || engine == null) {
112+
throw new IllegalArgumentException("client and engine must be non-null");
113+
}
114+
this.client = client;
115+
this.engine = engine;
116+
this.fsnAtZero = fsnAtZero;
117+
this.parkNanos = parkNanos;
118+
}
119+
120+
/**
121+
* Surfaces any error the I/O thread recorded. Called by the producer
122+
* thread (typically from inside its append wrapper) so failures don't
123+
* stay silent. Idempotent; once an error is set the loop has already
124+
* exited.
125+
*/
126+
public void checkError() {
127+
Throwable e = lastError;
128+
if (e != null) {
129+
if (e instanceof LineSenderException) throw (LineSenderException) e;
130+
throw new LineSenderException("I/O thread failed: " + e.getMessage(), e);
131+
}
132+
}
133+
134+
@Override
135+
public void close() {
136+
running = false;
137+
if (ioThread != null) {
138+
try {
139+
shutdownLatch.await();
140+
} catch (InterruptedException ignored) {
141+
Thread.currentThread().interrupt();
142+
}
143+
ioThread = null;
144+
}
145+
}
146+
147+
public Throwable getLastError() {
148+
return lastError;
149+
}
150+
151+
public long getTotalAcks() {
152+
return totalAcks.get();
153+
}
154+
155+
public long getTotalFramesSent() {
156+
return totalFramesSent.get();
157+
}
158+
159+
public synchronized void start() {
160+
if (ioThread != null) {
161+
throw new IllegalStateException("already started");
162+
}
163+
running = true;
164+
sendingSegment = engine.activeSegment();
165+
ioThread = new Thread(this::ioLoop, "qdb-cursor-ws-io");
166+
ioThread.setDaemon(true);
167+
ioThread.start();
168+
}
169+
170+
/**
171+
* Walks to the next segment when the current one is sealed and fully
172+
* drained. Returns the next segment to consume (newer sealed if available,
173+
* else the active). Returns the same segment if it's still being written
174+
* (we're on the active and just need to wait for more publishedFsn).
175+
*/
176+
private MmapSegment advanceSegment() {
177+
MmapSegment current = sendingSegment;
178+
MmapSegment liveActive = engine.activeSegment();
179+
if (current == liveActive) {
180+
// We're on the active — there's no "next", just wait for more
181+
// bytes to be published into it. Caller's sendOne will see
182+
// publishedOffset > sendOffset eventually and resume.
183+
return current;
184+
}
185+
// current is a sealed segment. Find it in the snapshot and return
186+
// the segment immediately after.
187+
int n = engine.sealedSegmentsSnapshot(sealedSnapshot);
188+
if (n == -1) {
189+
// Snapshot buffer too small — grow and retry.
190+
sealedSnapshot = new MmapSegment[sealedSnapshot.length * 2];
191+
n = engine.sealedSegmentsSnapshot(sealedSnapshot);
192+
if (n == -1) {
193+
throw new IllegalStateException("sealed snapshot grew unexpectedly large");
194+
}
195+
}
196+
for (int i = 0; i < n; i++) {
197+
if (sealedSnapshot[i] == current) {
198+
if (i + 1 < n) {
199+
sendOffset = MmapSegment.HEADER_SIZE;
200+
return sealedSnapshot[i + 1];
201+
}
202+
// No more sealed after us — move to the active.
203+
sendOffset = MmapSegment.HEADER_SIZE;
204+
return liveActive;
205+
}
206+
}
207+
// current is not in the sealed list and not == active. It must have
208+
// been trimmed out from under us — which can only happen if we
209+
// already sent every frame in it. Move to the next remaining one.
210+
// For robustness: fall back to the oldest sealed (if any), else active.
211+
if (n > 0) {
212+
sendOffset = MmapSegment.HEADER_SIZE;
213+
return sealedSnapshot[0];
214+
}
215+
sendOffset = MmapSegment.HEADER_SIZE;
216+
return liveActive;
217+
}
218+
219+
private void fail(Throwable t) {
220+
if (lastError == null) {
221+
lastError = t;
222+
}
223+
running = false;
224+
LOG.error("Cursor I/O loop failure: {}", t.getMessage(), t);
225+
}
226+
227+
private void ioLoop() {
228+
try {
229+
while (running) {
230+
boolean didWork = false;
231+
// 1. Try to send next frame(s).
232+
if (trySendOne()) {
233+
didWork = true;
234+
}
235+
// 2. Try to receive ACKs.
236+
if (tryReceiveAcks()) {
237+
didWork = true;
238+
}
239+
if (!didWork && running) {
240+
LockSupport.parkNanos(parkNanos);
241+
}
242+
}
243+
} catch (Throwable t) {
244+
fail(t);
245+
} finally {
246+
shutdownLatch.countDown();
247+
}
248+
}
249+
250+
/**
251+
* Returns true if at least one frame was sent (caller skips the park).
252+
* Bounded: sends at most one frame per call so the ACK side gets
253+
* scheduling fairness.
254+
*/
255+
private boolean trySendOne() {
256+
long pub = sendingSegment.publishedOffset();
257+
if (sendOffset >= pub) {
258+
// Nothing more in the current segment. If it's a sealed segment
259+
// (no longer the live active), advance to the next one.
260+
if (sendingSegment != engine.activeSegment()) {
261+
MmapSegment next = advanceSegment();
262+
if (next != sendingSegment) {
263+
sendingSegment = next;
264+
return true; // let the next iteration try sending
265+
}
266+
}
267+
return false;
268+
}
269+
// At least the frame header is published; check we have the full frame.
270+
if (sendOffset + MmapSegment.FRAME_HEADER_SIZE > pub) {
271+
return false;
272+
}
273+
long base = sendingSegment.address();
274+
// Frame layout: [u32 crc][u32 payloadLen][payload].
275+
int payloadLen = Unsafe.getUnsafe().getInt(base + sendOffset + 4);
276+
if (payloadLen < 0) {
277+
fail(new LineSenderException(
278+
"negative payloadLen at offset " + sendOffset
279+
+ " in segment baseSeq=" + sendingSegment.baseSeq()));
280+
return false;
281+
}
282+
long frameEnd = sendOffset + MmapSegment.FRAME_HEADER_SIZE + payloadLen;
283+
if (frameEnd > pub) {
284+
return false; // payload not fully published yet
285+
}
286+
try {
287+
client.sendBinary(base + sendOffset + MmapSegment.FRAME_HEADER_SIZE, payloadLen);
288+
} catch (Throwable t) {
289+
fail(t);
290+
return false;
291+
}
292+
sendOffset = frameEnd;
293+
nextWireSeq++;
294+
totalFramesSent.incrementAndGet();
295+
consecutiveSendErrors.set(0);
296+
return true;
297+
}
298+
299+
private boolean tryReceiveAcks() {
300+
boolean any = false;
301+
try {
302+
while (running && client.tryReceiveFrame(responseHandler)) {
303+
any = true;
304+
}
305+
} catch (Throwable t) {
306+
fail(t);
307+
}
308+
return any;
309+
}
310+
311+
/** Inner ACK handler — parses the binary frame, calls engine.acknowledge. */
312+
private final class ResponseHandler implements WebSocketFrameHandler {
313+
@Override
314+
public void onClose(int code, String reason) {
315+
fail(new LineSenderException("WebSocket closed by server: code=" + code + " reason=" + reason));
316+
}
317+
318+
@Override
319+
public void onBinaryMessage(long payloadPtr, int payloadLen) {
320+
if (!response.readFrom(payloadPtr, payloadLen)) {
321+
fail(new LineSenderException(
322+
"Invalid ACK response payload [length=" + payloadLen + ']'));
323+
return;
324+
}
325+
long wireSeq = response.getSequence();
326+
if (response.isSuccess()) {
327+
// Same sanity clamp as legacy: don't trust an ACK beyond
328+
// what we've actually sent, otherwise a malformed/replayed
329+
// server response would force trim of segments the new
330+
// server hasn't seen.
331+
long highestSent = nextWireSeq - 1;
332+
if (highestSent < 0) return; // ACK before any send — ignore
333+
long capped = Math.min(wireSeq, highestSent);
334+
if (capped < wireSeq) {
335+
LOG.warn("server ACK wire seq {} exceeds highest sent {} — clamping",
336+
wireSeq, highestSent);
337+
}
338+
engine.acknowledge(fsnAtZero + capped);
339+
totalAcks.incrementAndGet();
340+
} else {
341+
fail(new LineSenderException(
342+
"server reported error for wire seq " + wireSeq));
343+
}
344+
}
345+
}
346+
}

0 commit comments

Comments
 (0)