Skip to content

Commit 052f6ee

Browse files
bluestreak01claude
andcommitted
Make close() rethrow latched terminal errors
Three small changes to QwpWebSocketSender that close a silent-loss window in the async client: 1. close() now captures the first Throwable raised during the flush/drain path, finishes shutdown of the I/O thread, socket and microbatch buffers, and then rethrows. Previously a HALT policy SenderError surfaced from drainOnClose was swallowed into a LOG.error(), so a user who only ever called close() (no flush() afterwards) had no way to observe the failure and no signal that data was lost. A static rethrowTerminal helper wraps any non-RuntimeException Throwable in a LineSenderException so close() keeps its existing throws contract. 2. drainOnClose now throws a LineSenderException on timeout carrying publishedFsn, ackedFsn, and the count of unacked batches, instead of logging a WARN and returning silently. The exception propagates through close() via mechanism (1) above. SF-mode users can recover the unacked tail by reopening on the same SF directory; memory-mode users have no recovery path and must treat this as fatal. 3. Adds two accessors so tests and user code can observe storage-drained progress without polling internal state: - getAckedFsn() returns the highest server-acknowledged FSN (or -1 before the I/O loop has started). - awaitAckedFsn(targetFsn, timeoutMillis) blocks until acked >= target or the timeout elapses, returning a boolean and surfacing latched I/O errors via cursorSendLoop.checkError(). Pair with flushAndGetSequence() to wait for a specific publish to land on disk server-side. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 41ae975 commit 052f6ee

1 file changed

Lines changed: 93 additions & 7 deletions

File tree

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

Lines changed: 93 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -560,6 +560,45 @@ public void atNow() {
560560
}
561561
}
562562

563+
/**
564+
* Blocks until {@code ackedFsn() >= targetFsn}, or until {@code timeoutMillis}
565+
* elapses. Polls the cursor engine on a 50us park; surfaces I/O loop errors
566+
* synchronously via {@code cursorSendLoop.checkError()}.
567+
* <p>
568+
* Useful for tests and user code that need to confirm a specific publish
569+
* has been server-acknowledged. Pair with {@link #flushAndGetSequence()} to
570+
* obtain {@code targetFsn}.
571+
*
572+
* @param targetFsn FSN to wait for; typically {@link #flushAndGetSequence()}'s return value
573+
* @param timeoutMillis upper bound on the wait; {@code <= 0} returns immediately
574+
* @return {@code true} if {@code ackedFsn() >= targetFsn} on return, {@code false} on timeout
575+
* @throws LineSenderException if the I/O loop has latched a terminal error
576+
*/
577+
public boolean awaitAckedFsn(long targetFsn, long timeoutMillis) {
578+
checkNotClosed();
579+
if (cursorEngine == null) {
580+
return targetFsn < 0L;
581+
}
582+
if (cursorEngine.ackedFsn() >= targetFsn) {
583+
return true;
584+
}
585+
if (timeoutMillis <= 0L) {
586+
return false;
587+
}
588+
long deadlineNanos = System.nanoTime() + timeoutMillis * 1_000_000L;
589+
while (cursorEngine.ackedFsn() < targetFsn) {
590+
if (cursorSendLoop != null) {
591+
cursorSendLoop.checkError();
592+
}
593+
checkConnectionError();
594+
if (System.nanoTime() >= deadlineNanos) {
595+
return false;
596+
}
597+
java.util.concurrent.locks.LockSupport.parkNanos(50_000L);
598+
}
599+
return true;
600+
}
601+
563602
@Override
564603
public QwpWebSocketSender boolColumn(CharSequence columnName, boolean value) {
565604
checkNotClosed();
@@ -641,6 +680,12 @@ public void close() {
641680
if (!closed) {
642681
closed = true;
643682
boolean ioThreadStopped = true;
683+
// Captures the first error from the flush/drain path so it can be
684+
// rethrown after all cleanup is done. Silently swallowing it here
685+
// would hide latched terminal SenderError HALTs (server-side
686+
// rejections like MESSAGE_TOO_BIG, SCHEMA_MISMATCH HALT) from
687+
// users who only call close() and never call flush() afterwards.
688+
Throwable terminalError = null;
644689

645690
try {
646691
// Only drain when both the engine and the I/O loop are wired
@@ -663,8 +708,8 @@ public void close() {
663708
// data on JVM exit).
664709
drainOnClose();
665710
}
666-
} catch (Exception e) {
667-
LOG.error("Error during close: {}", String.valueOf(e));
711+
} catch (Throwable t) {
712+
terminalError = t;
668713
}
669714

670715
// Shut down the I/O thread before closing the socket or buffers
@@ -705,6 +750,7 @@ public void close() {
705750
// The I/O thread may still be using the socket and microbatch
706751
// buffers. Freeing them would risk SIGSEGV.
707752
LOG.error("I/O thread is still running, leaking WebSocket client and microbatch buffers");
753+
rethrowTerminal(terminalError);
708754
return;
709755
}
710756

@@ -742,6 +788,8 @@ public void close() {
742788
}
743789

744790
LOG.info("QwpWebSocketSender closed");
791+
792+
rethrowTerminal(terminalError);
745793
}
746794
}
747795

@@ -982,6 +1030,18 @@ public long flushAndGetSequence() {
9821030
return cursorEngine != null ? cursorEngine.publishedFsn() : -1L;
9831031
}
9841032

1033+
/**
1034+
* Highest FSN that has been server-acknowledged (or skipped past on a
1035+
* {@link SenderError.Policy#DROP_AND_CONTINUE} rejection). {@code -1} if
1036+
* the I/O loop has not yet started or no batch has been published.
1037+
* <p>
1038+
* Snapshot accessor — for a bounded wait, use
1039+
* {@link #awaitAckedFsn(long, long)}.
1040+
*/
1041+
public long getAckedFsn() {
1042+
return cursorEngine != null ? cursorEngine.ackedFsn() : -1L;
1043+
}
1044+
9851045
/**
9861046
* Returns the auto-flush byte threshold.
9871047
*/
@@ -1856,9 +1916,12 @@ private void resetSchemaStateForNewConnection() {
18561916
/**
18571917
* Bounded drain on close: block until {@code ackedFsn >= publishedFsn}
18581918
* or until {@code closeFlushTimeoutMillis} elapses. {@code <= 0} skips
1859-
* the drain (fast close). On timeout, log a WARN and proceed with
1860-
* shutdown — pending data is lost in memory mode and recovered by
1861-
* the next sender in SF mode.
1919+
* the drain (fast close). On timeout, throw a {@link LineSenderException}
1920+
* so the caller cannot silently lose data — close() collects the
1921+
* exception, finishes shutdown, and rethrows it from close() itself.
1922+
* SF-mode users can recover the unacked tail by reopening a sender on
1923+
* the same SF directory; memory-mode users have no recovery path and
1924+
* must treat this as fatal.
18621925
*/
18631926
private void drainOnClose() {
18641927
if (closeFlushTimeoutMillis <= 0L) {
@@ -1872,14 +1935,37 @@ private void drainOnClose() {
18721935
while (cursorEngine.ackedFsn() < target) {
18731936
cursorSendLoop.checkError();
18741937
if (System.nanoTime() >= deadlineNanos) {
1938+
long acked = cursorEngine.ackedFsn();
18751939
LOG.warn("close() drain timed out after {}ms [target={} acked={}] — pending data may be lost",
1876-
closeFlushTimeoutMillis, target, cursorEngine.ackedFsn());
1877-
return;
1940+
closeFlushTimeoutMillis, target, acked);
1941+
throw new LineSenderException("close() drain timed out after ")
1942+
.put(closeFlushTimeoutMillis).put(" ms [publishedFsn=")
1943+
.put(target).put(", ackedFsn=").put(acked)
1944+
.put("] - server did not acknowledge ")
1945+
.put(target - acked)
1946+
.put(" pending batches; data may be lost (use larger closeFlushTimeoutMillis or smaller batches)");
18781947
}
18791948
java.util.concurrent.locks.LockSupport.parkNanos(50_000L);
18801949
}
18811950
}
18821951

1952+
private static void rethrowTerminal(Throwable t) {
1953+
if (t == null) {
1954+
return;
1955+
}
1956+
if (t instanceof RuntimeException) {
1957+
throw (RuntimeException) t;
1958+
}
1959+
if (t instanceof Error) {
1960+
throw (Error) t;
1961+
}
1962+
// Wrap any checked Throwable so close() stays declared without a
1963+
// throws clause. flush/drain only ever raises RuntimeException
1964+
// subclasses today, but defending against future changes here is
1965+
// cheaper than chasing a leaked checked throw later.
1966+
throw new LineSenderException("close failed: " + t);
1967+
}
1968+
18831969
private void rollbackRow() {
18841970
if (currentTableBuffer != null) {
18851971
currentTableBuffer.cancelCurrentRow();

0 commit comments

Comments
 (0)