Skip to content

Commit 12049d8

Browse files
bluestreak01claude
andcommitted
Add async initial connect for cursor SF sender
initial_connect_retry already had a SYNC mode that retried on the user thread up to reconnect_max_duration_millis, but that contradicts SF's core promise of decoupling the producer from network state: at startup the application thread is blocked on a wire that may never come up. This change extends initial_connect_retry with a third value, async, which: - Returns from Sender.fromConfig immediately. The producer thread can call at()/atNow()/flush() right away; rows accumulate in the cursor SF engine while the I/O thread runs the connect retry loop in the background. - Reuses the existing per-outage retry/backoff/cap/auth-terminal machinery from CursorWebSocketSendLoop. fail() is refactored into a shared connectLoop(initial, phase) helper used by both the in-flight reconnect path (phase="reconnect") and the new attemptInitialConnect path (phase="initial connect"). The I/O loop's first iteration now drives initial connect when the constructor was handed a null client. - Surfaces terminal failures (auth/upgrade reject, budget exhaustion) through the existing SenderError dispatcher rather than throwing from the constructor. close() still rethrows the latched terminal so users without a custom error_handler still see the failure. The new InitialConnectMode enum (OFF, SYNC, ASYNC) replaces the internal boolean. The legacy initialConnectRetry(boolean) builder method is preserved as a back-compat shim that maps false to OFF and true to SYNC, and the config string parser accepts off/false, on/true/ sync, and async as values for initial_connect_retry. Connectivity classification: When the connect-retry budget exhausts, the SenderError now tags the failure by what was actually observed on the wire so users can tell a config typo apart from a transient blip: - never-connected-budget-exhausted: ... -- never reached the server (check addr/port/firewall) -- when the I/O loop has not once installed a live, upgraded WebSocket. Most likely a typo, wrong port, firewall block, or server not yet deployed. - connection-lost-budget-exhausted: ... -- server unreachable since last connect (transient) -- when the loop did connect at least once and the wire dropped after. A sticky volatile boolean hasEverConnected on the I/O loop tracks the distinction; CursorWebSocketSendLoop.hasEverConnected() and QwpWebSocketSender.wasEverConnected() expose it for handlers that want to branch programmatically without parsing the message string. The flag is set in the constructor for SYNC/OFF modes (which are handed a live client) and inside swapClient on every successful connect for ASYNC and reconnect paths. Auth/upgrade rejects keep the existing ws-upgrade-failed message and SECURITY_ERROR category -- the failure cause is already self-describing and disambiguation is unnecessary. Tests: InitialConnectAsyncTest covers six cases: - fromConfig returns immediately in async mode with no server reachable; - buffered rows are delivered once a late-arriving server starts; - never-connected budget exhaustion uses the never-connected tag and wasEverConnected() returns false; - 401 upgrade reject delivers SECURITY_ERROR via the inbox short of the cap; - connect-then-disconnect budget exhaustion uses the connection-lost tag and wasEverConnected() remains true; - OFF/SYNC modes report wasEverConnected()==true the moment the sender is visible to the caller. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent fc8d8b3 commit 12049d8

4 files changed

Lines changed: 733 additions & 51 deletions

File tree

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

Lines changed: 66 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -569,6 +569,36 @@ enum SfDurability {
569569
APPEND
570570
}
571571

572+
/**
573+
* Initial-connect behavior for the WebSocket cursor SF transport.
574+
* <ul>
575+
* <li>{@link #OFF} — single attempt on the user thread; a startup
576+
* failure throws immediately. Default; correct for fail-fast
577+
* deployments where a misconfigured host should not stall app
578+
* startup.</li>
579+
* <li>{@link #SYNC} — same retry loop the in-flight reconnect path
580+
* uses, but it runs on the user thread inside {@code fromConfig}.
581+
* Blocks up to {@code reconnect_max_duration_millis}. Auth/upgrade
582+
* failures stay terminal. Useful when the server is expected to
583+
* come up shortly after the producer and the producer is willing
584+
* to wait.</li>
585+
* <li>{@link #ASYNC} — {@code fromConfig} returns immediately with an
586+
* unconnected sender; the I/O thread runs the same retry loop in
587+
* the background. The user thread can call {@code at()} /
588+
* {@code flush()} immediately; rows accumulate in the cursor SF
589+
* engine until the wire is up. A connect-budget exhaustion or a
590+
* terminal upgrade failure is delivered to the async error inbox
591+
* as a {@link io.questdb.client.SenderError} (no synchronous
592+
* throw on the user call site). Wire {@code error_handler=...}
593+
* to observe these.</li>
594+
* </ul>
595+
*/
596+
enum InitialConnectMode {
597+
OFF,
598+
SYNC,
599+
ASYNC
600+
}
601+
572602
final class LineSenderBuilder {
573603
private static final int AUTO_FLUSH_DISABLED = 0;
574604
private static final int DEFAULT_AUTO_FLUSH_INTERVAL_MILLIS = 1_000;
@@ -694,11 +724,11 @@ public int getTimeout() {
694724
private long reconnectMaxDurationMillis = PARAMETER_NOT_SET_EXPLICITLY;
695725
private long reconnectInitialBackoffMillis = PARAMETER_NOT_SET_EXPLICITLY;
696726
private long reconnectMaxBackoffMillis = PARAMETER_NOT_SET_EXPLICITLY;
697-
// When true, the initial connect goes through the same
698-
// backoff/cap/auth-terminal retry path as reconnect. Default false:
699-
// a misconfigured host or down server should fail fast at startup,
700-
// not after the cap. Auth failures stay terminal even with retry on.
701-
private boolean initialConnectRetry = false;
727+
// Drives the initial-connect strategy. OFF is fail-fast (default).
728+
// SYNC retries on the user thread up to the reconnect cap. ASYNC
729+
// returns immediately and lets the I/O thread retry in the
730+
// background, surfacing terminal failures via the error inbox.
731+
private InitialConnectMode initialConnectMode = InitialConnectMode.OFF;
702732
// Per-append deadline for SF appendBlocking spin-then-throw. Used to
703733
// be a hardcoded 30s constant; expose so tight-SLA users can lower
704734
// and offline-tolerant users can raise.
@@ -1112,7 +1142,7 @@ public Sender build() {
11121142
actualReconnectMaxDurationMillis,
11131143
actualReconnectInitialBackoffMillis,
11141144
actualReconnectMaxBackoffMillis,
1115-
initialConnectRetry,
1145+
initialConnectMode,
11161146
errorHandler,
11171147
actualErrorInboxCapacity
11181148
);
@@ -1966,12 +1996,34 @@ public LineSenderBuilder reconnectMaxBackoffMillis(long millis) {
19661996
* sit retrying for 5 minutes. Set true if your deployment expects
19671997
* the server to come up shortly after the sender. Auth failures
19681998
* (HTTP 401/403/non-101) stay terminal in either mode.
1999+
* <p>
2000+
* For non-blocking startup (the producer thread returns immediately
2001+
* and the I/O thread retries in the background), use
2002+
* {@link #initialConnectMode(InitialConnectMode)} with
2003+
* {@link InitialConnectMode#ASYNC}.
19692004
*/
19702005
public LineSenderBuilder initialConnectRetry(boolean enabled) {
19712006
if (protocol != PARAMETER_NOT_SET_EXPLICITLY && protocol != PROTOCOL_WEBSOCKET) {
19722007
throw new LineSenderException("initial_connect_retry is only supported for WebSocket transport");
19732008
}
1974-
this.initialConnectRetry = enabled;
2009+
this.initialConnectMode = enabled ? InitialConnectMode.SYNC : InitialConnectMode.OFF;
2010+
return this;
2011+
}
2012+
2013+
/**
2014+
* Three-way control over initial-connect behavior — see
2015+
* {@link InitialConnectMode} for the value semantics. WebSocket
2016+
* transport only. Replaces {@link #initialConnectRetry(boolean)}
2017+
* for users who want the {@link InitialConnectMode#ASYNC} mode.
2018+
*/
2019+
public LineSenderBuilder initialConnectMode(InitialConnectMode mode) {
2020+
if (protocol != PARAMETER_NOT_SET_EXPLICITLY && protocol != PROTOCOL_WEBSOCKET) {
2021+
throw new LineSenderException("initial_connect_mode is only supported for WebSocket transport");
2022+
}
2023+
if (mode == null) {
2024+
throw new LineSenderException("initial_connect_mode cannot be null");
2025+
}
2026+
this.initialConnectMode = mode;
19752027
return this;
19762028
}
19772029

@@ -2594,12 +2646,15 @@ private LineSenderBuilder fromConfig(CharSequence configurationString) {
25942646
throw new LineSenderException("initial_connect_retry is only supported for WebSocket transport");
25952647
}
25962648
pos = getValue(configurationString, pos, sink, "initial_connect_retry");
2597-
if (Chars.equalsIgnoreCase("on", sink) || Chars.equalsIgnoreCase("true", sink)) {
2598-
initialConnectRetry(true);
2649+
if (Chars.equalsIgnoreCase("on", sink) || Chars.equalsIgnoreCase("true", sink)
2650+
|| Chars.equalsIgnoreCase("sync", sink)) {
2651+
initialConnectMode(InitialConnectMode.SYNC);
25992652
} else if (Chars.equalsIgnoreCase("off", sink) || Chars.equalsIgnoreCase("false", sink)) {
2600-
initialConnectRetry(false);
2653+
initialConnectMode(InitialConnectMode.OFF);
2654+
} else if (Chars.equalsIgnoreCase("async", sink)) {
2655+
initialConnectMode(InitialConnectMode.ASYNC);
26012656
} else {
2602-
throw new LineSenderException("invalid initial_connect_retry [value=").put(sink).put(", allowed-values=[on, off, true, false]]");
2657+
throw new LineSenderException("invalid initial_connect_retry [value=").put(sink).put(", allowed-values=[on, off, true, false, sync, async]]");
26032658
}
26042659
} else if (Chars.equals("sf_append_deadline_millis", sink)) {
26052660
if (protocol != PROTOCOL_WEBSOCKET) {

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

Lines changed: 75 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -182,10 +182,15 @@ public class QwpWebSocketSender implements Sender {
182182
CursorWebSocketSendLoop.DEFAULT_RECONNECT_INITIAL_BACKOFF_MILLIS;
183183
private long reconnectMaxBackoffMillis =
184184
CursorWebSocketSendLoop.DEFAULT_RECONNECT_MAX_BACKOFF_MILLIS;
185-
// false → startup connect failure is immediately terminal (default).
186-
// true → startup connect goes through the same retry-with-backoff
185+
// OFF → startup connect failure is immediately terminal (default).
186+
// SYNC → startup connect goes through the same retry-with-backoff
187187
// loop as in-flight reconnect; auth failures still terminal.
188-
private boolean initialConnectRetry = false;
188+
// ASYNC → user thread does not connect at all. The I/O thread runs
189+
// the same retry loop in the background; terminal failures
190+
// (auth/upgrade reject, budget exhaustion) are delivered
191+
// to the SenderError dispatcher rather than thrown from the
192+
// constructor.
193+
private Sender.InitialConnectMode initialConnectMode = Sender.InitialConnectMode.OFF;
189194
// Orphan-slot drainer pool. Non-null only when the builder requested
190195
// drain_orphans=true AND we have a slot path to scan against. Closed
191196
// alongside the cursor send loop in close().
@@ -361,14 +366,15 @@ public static QwpWebSocketSender connect(
361366
maxSchemasPerConnection, requestDurableAck, cursorEngine,
362367
closeFlushTimeoutMillis, reconnectMaxDurationMillis,
363368
reconnectInitialBackoffMillis, reconnectMaxBackoffMillis,
364-
false);
369+
Sender.InitialConnectMode.OFF);
365370
}
366371

367372
/**
368-
* Master connect overload — also accepts {@code initialConnectRetry}.
369-
* When true, the initial connect goes through the same retry loop as
370-
* in-flight reconnect (backoff + cap + auth-terminal). When false
371-
* (default), a startup connect failure is immediately terminal.
373+
* Master connect overload — also accepts {@code initialConnectMode}.
374+
* See {@link Sender.InitialConnectMode} for the value semantics:
375+
* {@code OFF} fails fast (default), {@code SYNC} retries on the user
376+
* thread up to the reconnect cap, {@code ASYNC} returns immediately
377+
* and lets the I/O thread retry in the background.
372378
*/
373379
public static QwpWebSocketSender connect(
374380
String host,
@@ -386,14 +392,14 @@ public static QwpWebSocketSender connect(
386392
long reconnectMaxDurationMillis,
387393
long reconnectInitialBackoffMillis,
388394
long reconnectMaxBackoffMillis,
389-
boolean initialConnectRetry
395+
Sender.InitialConnectMode initialConnectMode
390396
) {
391397
return connect(host, port, tlsConfig, autoFlushRows, autoFlushBytes,
392398
autoFlushIntervalNanos, inFlightWindowSize, authorizationHeader,
393399
maxSchemasPerConnection, requestDurableAck, cursorEngine,
394400
closeFlushTimeoutMillis, reconnectMaxDurationMillis,
395401
reconnectInitialBackoffMillis, reconnectMaxBackoffMillis,
396-
initialConnectRetry, null, SenderErrorDispatcher.DEFAULT_CAPACITY);
402+
initialConnectMode, null, SenderErrorDispatcher.DEFAULT_CAPACITY);
397403
}
398404

399405
/**
@@ -417,7 +423,7 @@ public static QwpWebSocketSender connect(
417423
long reconnectMaxDurationMillis,
418424
long reconnectInitialBackoffMillis,
419425
long reconnectMaxBackoffMillis,
420-
boolean initialConnectRetry,
426+
Sender.InitialConnectMode initialConnectMode,
421427
SenderErrorHandler errorHandler,
422428
int errorInboxCapacity
423429
) {
@@ -432,7 +438,9 @@ public static QwpWebSocketSender connect(
432438
sender.reconnectMaxDurationMillis = reconnectMaxDurationMillis;
433439
sender.reconnectInitialBackoffMillis = reconnectInitialBackoffMillis;
434440
sender.reconnectMaxBackoffMillis = reconnectMaxBackoffMillis;
435-
sender.initialConnectRetry = initialConnectRetry;
441+
sender.initialConnectMode = initialConnectMode == null
442+
? Sender.InitialConnectMode.OFF
443+
: initialConnectMode;
436444
if (errorHandler != null) {
437445
sender.setErrorHandler(errorHandler);
438446
}
@@ -1584,6 +1592,20 @@ public QwpWebSocketSender uuidColumn(CharSequence columnName, long lo, long hi)
15841592
return this;
15851593
}
15861594

1595+
/**
1596+
* True iff this sender has at least once installed a live (connected
1597+
* + upgraded) WebSocket. Sticky — once true, stays true even after a
1598+
* subsequent disconnect. Lets a {@link SenderErrorHandler}
1599+
* disambiguate a "never reached the server" budget exhaustion (likely
1600+
* a config typo or firewall block) from a "lost connection after we
1601+
* were up" failure (likely transient). Returns {@code false} if no
1602+
* I/O loop is running.
1603+
*/
1604+
public boolean wasEverConnected() {
1605+
CursorWebSocketSendLoop l = cursorSendLoop;
1606+
return l != null && l.hasEverConnected();
1607+
}
1608+
15871609
private void atMicros(long timestampMicros) {
15881610
// Add designated timestamp column (empty name for designated timestamp)
15891611
// Use cached reference to avoid hashmap lookup per row
@@ -1679,15 +1701,30 @@ private void ensureConnected() {
16791701
if (cursorEngine == null) {
16801702
throw new LineSenderException("cursor engine must be attached before connect");
16811703
}
1682-
if (initialConnectRetry) {
1683-
client = CursorWebSocketSendLoop.connectWithRetry(
1684-
this::buildAndConnect,
1685-
reconnectMaxDurationMillis,
1686-
reconnectInitialBackoffMillis,
1687-
reconnectMaxBackoffMillis,
1688-
"initial connect");
1689-
} else {
1690-
client = buildAndConnect();
1704+
switch (initialConnectMode) {
1705+
case SYNC:
1706+
client = CursorWebSocketSendLoop.connectWithRetry(
1707+
this::buildAndConnect,
1708+
reconnectMaxDurationMillis,
1709+
reconnectInitialBackoffMillis,
1710+
reconnectMaxBackoffMillis,
1711+
"initial connect");
1712+
break;
1713+
case ASYNC:
1714+
// Defer the actual connect to the I/O thread. The user thread
1715+
// returns immediately; rows accumulate in the cursor SF engine.
1716+
// Encoder stays at its default (V1 — the only supported wire
1717+
// version today). When v2+ ships, frames written before the
1718+
// first successful connect will commit to V1 because cursor
1719+
// segments are immutable. Auth/upgrade rejects and budget
1720+
// exhaustion are surfaced via the error inbox by the I/O
1721+
// thread, not thrown here.
1722+
client = null;
1723+
break;
1724+
case OFF:
1725+
default:
1726+
client = buildAndConnect();
1727+
break;
16911728
}
16921729

16931730
try {
@@ -1708,13 +1745,27 @@ private void ensureConnected() {
17081745
cursorSendLoop.setErrorDispatcher(errorDispatcher);
17091746
cursorSendLoop.start();
17101747
} catch (Throwable t) {
1711-
client.close();
1712-
client = null;
1748+
if (client != null) {
1749+
client.close();
1750+
client = null;
1751+
}
17131752
throw new LineSenderException(
17141753
"Failed to start cursor I/O thread for " + host + ":" + port, t);
17151754
}
17161755

1717-
encoder.setVersion((byte) client.getServerQwpVersion());
1756+
if (client != null) {
1757+
encoder.setVersion((byte) client.getServerQwpVersion());
1758+
LOG.info("Connected to WebSocket [host={}, port={}, windowSize={}, qwpVersion={}]",
1759+
host, port, inFlightWindowSize, client.getServerQwpVersion());
1760+
} else {
1761+
// Async mode: I/O thread will drive the connect. Encoder uses
1762+
// its default version (V1). Schema state still gets reset for
1763+
// consistency with the sync path; the post-connect replay path
1764+
// does not need a producer-side reset signal because every
1765+
// cursor frame is self-sufficient.
1766+
LOG.info("Async initial connect deferred to I/O thread [host={}, port={}, windowSize={}]",
1767+
host, port, inFlightWindowSize);
1768+
}
17181769
// Server starts fresh on each connection — discard any schema IDs
17191770
// retained from prior state. Cursor frames are self-sufficient (every
17201771
// frame carries full schema + full symbol-dict delta from id 0), so
@@ -1723,8 +1774,6 @@ private void ensureConnected() {
17231774
connectionError.set(null);
17241775

17251776
connected = true;
1726-
LOG.info("Connected to WebSocket [host={}, port={}, windowSize={}, qwpVersion={}]",
1727-
host, port, inFlightWindowSize, client.getServerQwpVersion());
17281777
}
17291778

17301779
/**

0 commit comments

Comments
 (0)