Skip to content

Commit 153cdd5

Browse files
authored
fix(qwp): fix missed terminal send errors during Sender close (#42)
1 parent 809d453 commit 153cdd5

11 files changed

Lines changed: 1376 additions & 134 deletions

File tree

.pi/skills/review-pr/SKILL.md

Lines changed: 339 additions & 0 deletions
Large diffs are not rendered by default.

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

Lines changed: 74 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -928,14 +928,20 @@ public void close() {
928928
// SCHEMA_MISMATCH HALT) from users who only call close() and
929929
// never call flush() afterwards.
930930
Throwable terminalError = null;
931-
// Snapshot the latched terminal error that the user thread has
932-
// ALREADY caught (via flush()/at()) before close() ran. If
933-
// flushPendingRows/drainOnClose below also rethrow the same
931+
// Snapshot the exact terminal error instance that a user-thread
932+
// API call ALREADY caught (via flush()/at()) before close() ran.
933+
// If flushPendingRows/drainOnClose below also rethrow the same
934934
// instance, dropping it at the final rethrow avoids
935935
// try-with-resources self-suppression: Throwable.addSuppressed
936936
// raises IllegalArgumentException when primary == suppressed.
937-
Throwable alreadyOwnedByUser = (cursorSendLoop != null && !cursorSendLoop.hasUnsurfacedError())
938-
? cursorSendLoop.getLastError() : null;
937+
// Must stay this single read: the snapshot needs the identity of
938+
// the error the user already owns, and only
939+
// getSynchronouslySurfacedError() holds it. Deriving it from two
940+
// separate latch reads races the I/O thread -- a terminal latched
941+
// between the reads would be adopted as user-owned and silently
942+
// dropped (see CloseOwnershipRaceTest).
943+
Throwable alreadyOwnedByUser = cursorSendLoop != null
944+
? cursorSendLoop.getSynchronouslySurfacedError() : null;
939945

940946
try {
941947
// Only drain when both the engine and the I/O loop are wired
@@ -953,35 +959,42 @@ public void close() {
953959
if (activeBuffer != null && activeBuffer.hasData()) {
954960
sealAndSwapBuffer();
955961
}
956-
// 2) Safety-net rethrow: surface a latched terminal error
957-
// only when no other channel has already delivered it
958-
// to the user. "Already delivered" means either the
959-
// producer thread saw it synchronously via
960-
// flush()/append() (errorSurfacedSynchronously) or the
961-
// async dispatcher delivered it to a user-installed
962-
// custom handler at any point in this sender's life
963-
// (deliveredToCustomHandler). The latter survives a
964-
// setErrorHandler(null) cleanup in test helpers --
965-
// once the user has owned an error, close() should
966-
// not double-signal it. The default no-op logging
967-
// handler does not count as "delivered to user", so a
968-
// config-string-only caller still gets the loud
969-
// rethrow on shutdown.
970-
boolean alreadyDeliveredToCustomHandler = errorDispatcher != null
971-
&& errorDispatcher.hasDeliveredToCustomHandler();
972-
if (!alreadyDeliveredToCustomHandler
973-
&& cursorSendLoop.hasUnsurfacedError()) {
974-
cursorSendLoop.checkError();
962+
// 2) Safety-net rethrow: surface the latched terminal
963+
// error only when no other channel has already
964+
// delivered THIS terminal to the user. "Already
965+
// delivered" means either the producer thread saw it
966+
// synchronously via flush()/append() (checkUnsurfacedError
967+
// is silent in that case) or the async dispatcher
968+
// actually delivered the latched terminal to a
969+
// user-installed custom handler
970+
// (hasDeliveredTerminalToCustomHandler, checked here).
971+
// The test is terminal-specific on purpose: an earlier
972+
// routine DROP_AND_CONTINUE rejection delivered to the
973+
// handler must NOT suppress a later genuine HALT
974+
// terminal (the "any error ever" flag did, silently
975+
// losing it). It also stays false when the terminal
976+
// reached only the default handler after a
977+
// setErrorHandler(null) revert, or is still
978+
// queued/abandoned behind a slow handler -- so a
979+
// config-string-only caller, and a reverting caller,
980+
// both still get the loud rethrow on shutdown.
981+
boolean terminalOwnedByCustomHandler = errorDispatcher != null
982+
&& errorDispatcher.hasDeliveredTerminalToCustomHandler();
983+
if (!terminalOwnedByCustomHandler) {
984+
cursorSendLoop.checkUnsurfacedError();
975985
}
976986
// 3) Bounded drain: block until the server has ACK'd
977987
// everything we just published, or until the
978988
// configured timeout elapses. closeFlushTimeoutMillis
979989
// <= 0 opts out (fast close, may lose memory-mode
980-
// data on JVM exit). Errors still surface via the
981-
// safety-net checkError() above and via the async
982-
// error handler.
990+
// data on JVM exit). Pass the same ownership flag the
991+
// step-2 safety net used: when the custom handler
992+
// already owns THIS terminal, the drain must stop on it
993+
// without re-throwing (re-throwing would double-signal
994+
// an error the user already handled). Otherwise the
995+
// drain keeps the loud safety net and surfaces it.
983996
if (closeFlushTimeoutMillis > 0L) {
984-
drainOnClose();
997+
drainOnClose(terminalOwnedByCustomHandler);
985998
}
986999
}
9871000
} catch (Throwable t) {
@@ -2593,7 +2606,7 @@ private void checkConnectionError() {
25932606
error.fillInStackTrace();
25942607
throw error;
25952608
}
2596-
// Poll the cursor I/O loop's lastError too. Without this, a fatal
2609+
// Poll the cursor I/O loop's terminalError too. Without this, a fatal
25972610
// wire / server-rejection error recorded by the I/O thread would
25982611
// only surface on the next flush() / close() — every row-level
25992612
// method (table, longColumn, atNow, etc.) routes through
@@ -2673,8 +2686,30 @@ private void dispatchConnectionEvent(
26732686
* SF-mode users can recover the unacked tail by reopening a sender on
26742687
* the same SF directory; memory-mode users have no recovery path and
26752688
* must treat this as fatal.
2689+
* <p>
2690+
* A latched terminal error means the server will never ACK up to
2691+
* {@code target}, so the drain must stop on it regardless. Whether it is
2692+
* also re-thrown from close() is a separate surfacing policy that mirrors
2693+
* the step-2 safety net in {@link #close()}:
2694+
* <ul>
2695+
* <li>{@code errorOwnedByCustomHandler == true}: a custom error handler
2696+
* has already delivered this terminal to the user, so stop silently —
2697+
* re-throwing here would double-signal it (the M3 drainOnClose
2698+
* double-signal).</li>
2699+
* <li>{@code errorOwnedByCustomHandler == false}: re-throw via
2700+
* {@code checkError()} to preserve the loud safety net (a
2701+
* config-string-only caller's only channel). The throw also breaks the
2702+
* loop; an error a synchronous {@code flush()}/{@code at()} caller
2703+
* already owns is then suppressed by close()'s
2704+
* {@code terminalError == alreadyOwnedByUser} check, so it is not
2705+
* double-signalled either.</li>
2706+
* </ul>
2707+
*
2708+
* @param errorOwnedByCustomHandler whether the async dispatcher has
2709+
* already delivered a terminal to a
2710+
* user-installed handler
26762711
*/
2677-
private void drainOnClose() {
2712+
private void drainOnClose(boolean errorOwnedByCustomHandler) {
26782713
if (closeFlushTimeoutMillis <= 0L) {
26792714
return;
26802715
}
@@ -2684,7 +2719,15 @@ private void drainOnClose() {
26842719
}
26852720
long deadlineNanos = System.nanoTime() + closeFlushTimeoutMillis * 1_000_000L;
26862721
while (cursorEngine.ackedFsn() < target) {
2687-
cursorSendLoop.checkError();
2722+
// Stop on a latched terminal (acks will never reach target);
2723+
// surface it only when no other channel already delivered it.
2724+
if (errorOwnedByCustomHandler) {
2725+
if (cursorSendLoop.getTerminalError() != null) {
2726+
return;
2727+
}
2728+
} else {
2729+
cursorSendLoop.checkError();
2730+
}
26882731
if (System.nanoTime() >= deadlineNanos) {
26892732
long acked = cursorEngine.ackedFsn();
26902733
LOG.warn("close() drain timed out after {}ms [target={} acked={}], pending data may be lost",

0 commit comments

Comments
 (0)